test_device_observability.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. from __future__ import annotations
  2. from dataclasses import replace
  3. from datetime import UTC, datetime
  4. import pytest
  5. from app.core.data_research.device_observability import (
  6. DeviceObservabilityConflict,
  7. DeviceObservabilityInvalid,
  8. DeviceObservabilityNotFound,
  9. DeviceObservabilityService,
  10. EvidenceRelationRecord,
  11. GraphNode,
  12. GraphProjection,
  13. )
  14. SOURCE_UID = "11111111-1111-4111-8111-111111111111"
  15. DEVICE_UID = "22222222-2222-4222-8222-222222222222"
  16. ISSUE_UID = "33333333-3333-4333-8333-333333333333"
  17. ACTOR_UID = "44444444-4444-4444-8444-444444444444"
  18. class MemoryRepository:
  19. def __init__(self):
  20. self.events = {}
  21. self.identities = {}
  22. self.relations = {}
  23. self.nodes = {
  24. ("asset", DEVICE_UID): GraphNode(
  25. kind="asset",
  26. uid=DEVICE_UID,
  27. node_type="device",
  28. label="一号泵",
  29. occurred_at=None,
  30. evidence_refs=(),
  31. ),
  32. ("quality_issue", ISSUE_UID): GraphNode(
  33. kind="quality_issue",
  34. uid=ISSUE_UID,
  35. node_type="quality_issue",
  36. label="QI-001",
  37. occurred_at=None,
  38. evidence_refs=(),
  39. ),
  40. }
  41. def source_exists(self, source_uid):
  42. return source_uid == SOURCE_UID
  43. def get_event_by_identity(self, source_uid, source_entity, source_code):
  44. uid = self.identities.get((source_uid, source_entity, source_code))
  45. return self.events.get(uid)
  46. def add_event(self, record):
  47. self.events[record.uid] = record
  48. self.identities[
  49. (record.source_uid, record.source_entity, record.source_code)
  50. ] = record.uid
  51. self.nodes[("event", record.uid)] = GraphNode(
  52. kind="event",
  53. uid=record.uid,
  54. node_type=record.event_type,
  55. label=record.title,
  56. occurred_at=record.occurred_at,
  57. evidence_refs=record.evidence_refs,
  58. )
  59. def resolve_event_identity(self, source_uid, source_entity, source_code):
  60. return self.identities.get((source_uid, source_entity, source_code))
  61. def node_exists(self, kind, uid):
  62. return (kind, uid) in self.nodes
  63. def get_relation(self, record):
  64. return self.relations.get(record.identity)
  65. def add_relation(self, record):
  66. self.relations[record.identity] = record
  67. def graph(self, anchor_kind, anchor_uid, max_hops, max_nodes, max_edges):
  68. assert max_hops <= 3
  69. nodes = {f"{anchor_kind}:{anchor_uid}": self.nodes[(anchor_kind, anchor_uid)]}
  70. edges = []
  71. frontier = {(anchor_kind, anchor_uid)}
  72. visited = set(frontier)
  73. for _ in range(max_hops):
  74. next_frontier = set()
  75. for relation in self.relations.values():
  76. source = (relation.from_kind, relation.from_uid)
  77. target = (relation.to_kind, relation.to_uid)
  78. if source not in frontier and target not in frontier:
  79. continue
  80. if len(edges) >= max_edges:
  81. break
  82. edges.append(relation)
  83. for endpoint in (source, target):
  84. if endpoint not in visited and len(nodes) < max_nodes:
  85. visited.add(endpoint)
  86. next_frontier.add(endpoint)
  87. nodes[f"{endpoint[0]}:{endpoint[1]}"] = self.nodes[endpoint]
  88. frontier = next_frontier
  89. if not frontier:
  90. break
  91. return GraphProjection(
  92. anchor_kind=anchor_kind,
  93. anchor_uid=anchor_uid,
  94. nodes=tuple(nodes.values()),
  95. relations=tuple(edges),
  96. truncated=False,
  97. )
  98. def service(repository=None, uid_values=None):
  99. values = iter(
  100. uid_values
  101. or (
  102. "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1",
  103. "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2",
  104. "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3",
  105. "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4",
  106. "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa5",
  107. "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa6",
  108. "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa7",
  109. "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa8",
  110. )
  111. )
  112. return DeviceObservabilityService(
  113. repository or MemoryRepository(),
  114. uid_factory=lambda: next(values),
  115. )
  116. def event(source_code, event_type, title, **extra):
  117. return {
  118. "source_entity": f"{event_type}_events",
  119. "source_code": source_code,
  120. "event_type": event_type,
  121. "asset_uid": DEVICE_UID,
  122. "title": title,
  123. "severity": extra.pop("severity", "warning"),
  124. "status": extra.pop("status", "observed"),
  125. "occurred_at": extra.pop(
  126. "occurred_at",
  127. datetime(2026, 7, 29, 8, 0, tzinfo=UTC).isoformat(),
  128. ),
  129. "evidence": extra.pop("evidence", {"source_row": source_code}),
  130. **extra,
  131. }
  132. def event_endpoint(event_type, source_code):
  133. return {
  134. "kind": "event",
  135. "source_entity": f"{event_type}_events",
  136. "source_code": source_code,
  137. }
  138. def test_import_is_idempotent_by_source_identity_and_rejects_conflicts():
  139. repository = MemoryRepository()
  140. observability = service(repository)
  141. payload = {
  142. "source_uid": SOURCE_UID,
  143. "events": [event("AL-1", "alarm", "轴承温度高")],
  144. "relations": [],
  145. }
  146. created = observability.import_evidence(payload, ACTOR_UID)
  147. duplicate = observability.import_evidence(payload, ACTOR_UID)
  148. assert created.created_events == 1
  149. assert isinstance(created.events[0].occurred_at, datetime)
  150. assert created.events[0].occurred_at.tzinfo is not None
  151. assert duplicate.existing_events == 1
  152. with pytest.raises(DeviceObservabilityConflict):
  153. observability.import_evidence(
  154. {
  155. **payload,
  156. "events": [event("AL-1", "alarm", "轴承振动高")],
  157. },
  158. ACTOR_UID,
  159. )
  160. def test_import_rejects_secrets_and_invalid_event_windows():
  161. observability = service()
  162. with pytest.raises(DeviceObservabilityInvalid, match="secret"):
  163. observability.import_evidence(
  164. {
  165. "source_uid": SOURCE_UID,
  166. "events": [
  167. event(
  168. "AL-2",
  169. "alarm",
  170. "高温告警",
  171. evidence={"password": "must-not-persist"},
  172. )
  173. ],
  174. "relations": [],
  175. },
  176. ACTOR_UID,
  177. )
  178. with pytest.raises(DeviceObservabilityInvalid, match="ended_at"):
  179. observability.import_evidence(
  180. {
  181. "source_uid": SOURCE_UID,
  182. "events": [
  183. event(
  184. "DT-1",
  185. "downtime",
  186. "设备停机",
  187. ended_at="2026-07-29T07:00:00+00:00",
  188. )
  189. ],
  190. "relations": [],
  191. },
  192. ACTOR_UID,
  193. )
  194. def test_relations_resolve_new_event_identities_and_require_existing_nodes():
  195. repository = MemoryRepository()
  196. observability = service(repository)
  197. result = observability.import_evidence(
  198. {
  199. "source_uid": SOURCE_UID,
  200. "events": [
  201. event("AL-3", "alarm", "压力告警"),
  202. event("FT-3", "fault", "泵体故障"),
  203. ],
  204. "relations": [
  205. {
  206. "from": event_endpoint("alarm", "AL-3"),
  207. "relation_type": "indicates",
  208. "to": event_endpoint("fault", "FT-3"),
  209. "evidence": {"rule": "same-device-window"},
  210. }
  211. ],
  212. },
  213. ACTOR_UID,
  214. )
  215. assert result.created_relations == 1
  216. relation = next(iter(repository.relations.values()))
  217. assert relation.from_kind == "event"
  218. assert relation.to_kind == "event"
  219. with pytest.raises(DeviceObservabilityConflict):
  220. observability.import_evidence(
  221. {
  222. "source_uid": SOURCE_UID,
  223. "events": [],
  224. "relations": [
  225. {
  226. "from": event_endpoint("alarm", "AL-3"),
  227. "relation_type": "indicates",
  228. "to": event_endpoint("fault", "FT-3"),
  229. "evidence": {"rule": "different-evidence"},
  230. }
  231. ],
  232. },
  233. ACTOR_UID,
  234. )
  235. with pytest.raises(DeviceObservabilityInvalid, match="does not exist"):
  236. observability.import_evidence(
  237. {
  238. "source_uid": SOURCE_UID,
  239. "events": [],
  240. "relations": [
  241. {
  242. "from": {"kind": "asset", "uid": DEVICE_UID},
  243. "relation_type": "part_of",
  244. "to": {
  245. "kind": "asset",
  246. "uid": "99999999-9999-4999-8999-999999999999",
  247. },
  248. "evidence": {},
  249. }
  250. ],
  251. },
  252. ACTOR_UID,
  253. )
  254. def test_graph_limits_hops_and_requires_a_real_anchor():
  255. observability = service()
  256. with pytest.raises(DeviceObservabilityInvalid, match="max_hops"):
  257. observability.graph("asset", DEVICE_UID, max_hops=4)
  258. with pytest.raises(DeviceObservabilityNotFound, match="does not exist"):
  259. observability.graph(
  260. "asset",
  261. "99999999-9999-4999-8999-999999999999",
  262. max_hops=2,
  263. )
  264. def test_root_cause_returns_evidence_paths_without_claiming_a_verdict():
  265. repository = MemoryRepository()
  266. observability = service(repository)
  267. observability.import_evidence(
  268. {
  269. "source_uid": SOURCE_UID,
  270. "events": [
  271. event("AL-4", "alarm", "轴承温度高"),
  272. event("FT-4", "fault", "轴承故障"),
  273. event("DT-4", "downtime", "一号泵停机"),
  274. ],
  275. "relations": [
  276. {
  277. "from": event_endpoint("alarm", "AL-4"),
  278. "relation_type": "indicates",
  279. "to": event_endpoint("fault", "FT-4"),
  280. "evidence": {"window_minutes": 5},
  281. },
  282. {
  283. "from": event_endpoint("fault", "FT-4"),
  284. "relation_type": "triggered",
  285. "to": event_endpoint("downtime", "DT-4"),
  286. "evidence": {"work_order": "WO-4"},
  287. },
  288. ],
  289. },
  290. ACTOR_UID,
  291. )
  292. anchor_uid = repository.resolve_event_identity(
  293. SOURCE_UID,
  294. "downtime_events",
  295. "DT-4",
  296. )
  297. result = observability.root_cause("event", anchor_uid, max_hops=3)
  298. assert result.analysis_status == "supported_candidates"
  299. assert result.conclusion == "发现有证据支持的根因候选,仍需设备专家确认"
  300. assert [item.event_type for item in result.candidates] == ["fault", "alarm"]
  301. assert result.candidates[0].path_relation_types == ("triggered",)
  302. assert result.candidates[1].path_relation_types == (
  303. "indicates",
  304. "triggered",
  305. )
  306. assert "不等同于已确认根因" in result.limitations[0]
  307. def test_root_cause_explicitly_reports_insufficient_evidence():
  308. repository = MemoryRepository()
  309. observability = service(repository)
  310. imported = observability.import_evidence(
  311. {
  312. "source_uid": SOURCE_UID,
  313. "events": [event("DT-5", "downtime", "二号泵停机")],
  314. "relations": [
  315. {
  316. "from": event_endpoint("downtime", "DT-5"),
  317. "relation_type": "occurred_on",
  318. "to": {"kind": "asset", "uid": DEVICE_UID},
  319. "evidence": {"source": "downtime-log"},
  320. }
  321. ],
  322. },
  323. ACTOR_UID,
  324. )
  325. anchor_uid = imported.events[0].uid
  326. result = observability.root_cause("event", anchor_uid)
  327. assert result.analysis_status == "insufficient_evidence"
  328. assert result.conclusion == "证据不足,无法确认根因"
  329. assert result.candidates == ()
  330. def test_root_cause_does_not_treat_a_future_event_as_a_candidate_cause():
  331. repository = MemoryRepository()
  332. observability = service(repository)
  333. imported = observability.import_evidence(
  334. {
  335. "source_uid": SOURCE_UID,
  336. "events": [
  337. event(
  338. "DT-6",
  339. "downtime",
  340. "三号泵停机",
  341. occurred_at="2026-07-29T08:00:00+00:00",
  342. ),
  343. event(
  344. "FT-6",
  345. "fault",
  346. "停机后补录故障",
  347. occurred_at="2026-07-29T09:00:00+00:00",
  348. ),
  349. ],
  350. "relations": [
  351. {
  352. "from": event_endpoint("fault", "FT-6"),
  353. "relation_type": "triggered",
  354. "to": event_endpoint("downtime", "DT-6"),
  355. "evidence": {"source": "late-manual-entry"},
  356. }
  357. ],
  358. },
  359. ACTOR_UID,
  360. )
  361. anchor_uid = next(
  362. item.uid
  363. for item in imported.events
  364. if item.event_type == "downtime"
  365. )
  366. result = observability.root_cause("event", anchor_uid)
  367. assert result.analysis_status == "insufficient_evidence"
  368. assert result.candidates == ()
  369. def test_relation_identity_includes_direction_and_type():
  370. base = EvidenceRelationRecord(
  371. uid="aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
  372. from_kind="event",
  373. from_uid="bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
  374. relation_type="indicates",
  375. to_kind="event",
  376. to_uid="cccccccc-cccc-4ccc-8ccc-cccccccccccc",
  377. evidence_refs=(),
  378. source="imported",
  379. created_by=ACTOR_UID,
  380. created_at=None,
  381. )
  382. assert base.identity != replace(base, relation_type="triggered").identity
  383. assert base.identity != replace(
  384. base,
  385. from_uid=base.to_uid,
  386. to_uid=base.from_uid,
  387. ).identity