test_device_semantics.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  1. from __future__ import annotations
  2. from dataclasses import replace
  3. from datetime import datetime, timedelta, timezone
  4. import pytest
  5. DOMAIN_UID = "01900000-0000-7000-8000-000000000501"
  6. MANAGER_UID = "01900000-0000-7000-8000-000000000502"
  7. OTHER_UID = "01900000-0000-7000-8000-000000000503"
  8. SOURCE_UID = "01900000-0000-7000-8000-000000000504"
  9. def memory_repository():
  10. from app.core.data_research.ontology.repository import (
  11. MemoryOntologyRepository,
  12. )
  13. ids = iter(
  14. (
  15. "01900000-0000-7000-8000-000000000511",
  16. "01900000-0000-7000-8000-000000000512",
  17. "01900000-0000-7000-8000-000000000513",
  18. "01900000-0000-7000-8000-000000000514",
  19. )
  20. )
  21. return MemoryOntologyRepository(uid_factory=ids.__next__)
  22. def responsibility_lookup(manager_uid=MANAGER_UID):
  23. def lookup(resource_type, resource_uid):
  24. assert resource_type == "device_ontology"
  25. assert resource_uid
  26. return {
  27. "resource_type": resource_type,
  28. "resource_uid": resource_uid,
  29. "revision": 1,
  30. "assignments": [
  31. {
  32. "user_id": manager_uid,
  33. "responsibility_role": "asset_manager",
  34. "raci_role": "accountable",
  35. }
  36. ],
  37. }
  38. return lookup
  39. def test_device_graph_is_deterministic_complete_and_generically_valid():
  40. from app.core.data_research.device_semantics import (
  41. assess_device_graph,
  42. build_device_graph,
  43. )
  44. from app.core.data_research.ontology.validation import validate_graph
  45. first = build_device_graph(DOMAIN_UID)
  46. second = build_device_graph(DOMAIN_UID)
  47. profile = assess_device_graph(first)
  48. assert first == second
  49. assert {item["uid"] for item in first["classes"]} == {
  50. "device",
  51. "component",
  52. "measurement_point",
  53. "alarm",
  54. "maintenance_record",
  55. "location",
  56. "organization",
  57. "person",
  58. "fault",
  59. "cause",
  60. "action",
  61. }
  62. assert {
  63. item["uid"] for item in first["relations"]
  64. } >= {
  65. "component.part_of",
  66. "measurement_point.monitors",
  67. "alarm.raised_by",
  68. "maintenance_record.maintains",
  69. "device.located_at",
  70. "device.owned_by",
  71. "device.responsible_to",
  72. "alarm.classified_as",
  73. "fault.caused_by",
  74. "maintenance_record.uses_action",
  75. }
  76. assert {
  77. item["property_uid"]
  78. for item in first["element_mappings"]
  79. } >= {"device.platform_uid", "device.source_code"}
  80. assert profile.ready_to_publish is True
  81. assert profile.missing_classes == ()
  82. assert profile.missing_relations == ()
  83. assert validate_graph(first) == []
  84. def test_device_profile_reports_missing_semantic_requirements():
  85. from app.core.data_research.device_semantics import (
  86. assess_device_graph,
  87. build_device_graph,
  88. )
  89. graph = build_device_graph(DOMAIN_UID)
  90. graph["classes"] = [
  91. item for item in graph["classes"] if item["uid"] != "fault"
  92. ]
  93. graph["relations"] = [
  94. item
  95. for item in graph["relations"]
  96. if item["uid"] != "alarm.classified_as"
  97. ]
  98. graph["element_mappings"] = [
  99. item
  100. for item in graph["element_mappings"]
  101. if item["property_uid"] != "device.platform_uid"
  102. ]
  103. profile = assess_device_graph(graph)
  104. assert profile.ready_to_publish is False
  105. assert profile.missing_classes == ("fault",)
  106. assert profile.missing_relations == ("alarm.classified_as",)
  107. assert profile.missing_mappings == ("device.platform_uid",)
  108. def test_device_semantic_bootstrap_is_idempotent_and_keeps_one_draft():
  109. from app.core.data_research.device_semantics import DeviceSemanticService
  110. repository = memory_repository()
  111. commits = []
  112. service = DeviceSemanticService(
  113. repository,
  114. commit=lambda: commits.append(True),
  115. )
  116. first = service.bootstrap(
  117. {"owner_domain_uid": DOMAIN_UID},
  118. actor_uid=MANAGER_UID,
  119. )
  120. second = service.bootstrap(
  121. {"owner_domain_uid": DOMAIN_UID},
  122. actor_uid=MANAGER_UID,
  123. )
  124. assert first.created is True
  125. assert second.created is False
  126. assert second.ontology.uid == first.ontology.uid
  127. assert second.version.uid == first.version.uid
  128. assert second.profile.ready_to_publish is True
  129. assert len(repository.list()) == 1
  130. assert len(repository.list_versions(first.ontology.uid)) == 1
  131. assert len(commits) == 1
  132. def test_device_publication_requires_complete_profile_and_accountable_manager():
  133. from app.core.data_research.device_semantics import (
  134. DeviceOntologyPublicationAuthorizer,
  135. DeviceSemanticForbidden,
  136. DeviceSemanticInvalid,
  137. DeviceSemanticService,
  138. )
  139. repository = memory_repository()
  140. result = DeviceSemanticService(repository).bootstrap(
  141. {"owner_domain_uid": DOMAIN_UID},
  142. actor_uid=MANAGER_UID,
  143. )
  144. authorizer = DeviceOntologyPublicationAuthorizer(
  145. repository,
  146. responsibility_lookup=responsibility_lookup(),
  147. )
  148. authorizer(result.version, MANAGER_UID)
  149. with pytest.raises(DeviceSemanticForbidden, match="accountable"):
  150. authorizer(result.version, OTHER_UID)
  151. incomplete = replace(
  152. result.version,
  153. graph_document=result.version.graph_document.from_dict(
  154. {
  155. **result.version.graph_document.to_dict(),
  156. "classes": [],
  157. }
  158. ),
  159. )
  160. with pytest.raises(DeviceSemanticInvalid, match="profile"):
  161. authorizer(incomplete, MANAGER_UID)
  162. def test_non_device_ontology_does_not_require_device_responsibility():
  163. from app.core.data_research.device_semantics import (
  164. DeviceOntologyPublicationAuthorizer,
  165. )
  166. from tests.data_research.test_ontology_validation import base_graph
  167. repository = memory_repository()
  168. ontology = repository.create(
  169. code="CUSTOMER",
  170. name="客户本体",
  171. owner_uid=MANAGER_UID,
  172. domain_links=base_graph()["domain_links"],
  173. )
  174. version = repository.save_draft(
  175. ontology.uid,
  176. base_graph(),
  177. expected_revision=0,
  178. actor_uid=MANAGER_UID,
  179. )
  180. DeviceOntologyPublicationAuthorizer(
  181. repository,
  182. responsibility_lookup=lambda *_args: pytest.fail(
  183. "generic ontology must not query device responsibility"
  184. ),
  185. )(version, OTHER_UID)
  186. class MemorySemanticCodeRepository:
  187. def __init__(self, ontology_uid):
  188. self.ontologies = {ontology_uid: "DEVICE_SEMANTIC"}
  189. self.codes = {}
  190. self.versions_by_code = {}
  191. self.reviews_by_code = {}
  192. def ontology_exists(self, ontology_uid):
  193. return ontology_uid in self.ontologies
  194. def is_device_ontology(self, ontology_uid):
  195. return self.ontologies.get(ontology_uid) == "DEVICE_SEMANTIC"
  196. def find_code(self, ontology_uid, code_type, canonical_code):
  197. return next(
  198. (
  199. item
  200. for item in self.codes.values()
  201. if (
  202. item.ontology_uid,
  203. item.code_type,
  204. item.canonical_code,
  205. )
  206. == (ontology_uid, code_type, canonical_code)
  207. ),
  208. None,
  209. )
  210. def get_code(self, code_uid, *, for_update=False):
  211. del for_update
  212. return self.codes.get(code_uid)
  213. def create_code(self, record, version):
  214. if self.find_code(
  215. record.ontology_uid,
  216. record.code_type,
  217. record.canonical_code,
  218. ):
  219. from app.core.data_research.errors import (
  220. DeviceSemanticConflict,
  221. )
  222. raise DeviceSemanticConflict("semantic code already exists")
  223. self.codes[record.uid] = record
  224. self.versions_by_code[record.uid] = [version]
  225. self.reviews_by_code[record.uid] = []
  226. return record
  227. def revise_code(self, record, version):
  228. self.codes[record.uid] = record
  229. self.versions_by_code[record.uid].append(version)
  230. return record
  231. def set_status(self, record, status, *, updated_at, updated_by):
  232. updated = replace(
  233. record,
  234. status=status,
  235. updated_at=updated_at,
  236. updated_by=updated_by,
  237. )
  238. self.codes[record.uid] = updated
  239. return updated
  240. def review_code(self, record, review, status):
  241. updated = replace(
  242. record,
  243. status=status,
  244. updated_at=review.created_at,
  245. updated_by=review.actor_uid,
  246. )
  247. self.codes[record.uid] = updated
  248. self.reviews_by_code[record.uid].append(review)
  249. return updated, review
  250. def search(self, filters, *, page, page_size):
  251. records = list(self.codes.values())
  252. for name in ("ontology_uid", "code_type", "status"):
  253. if filters.get(name):
  254. records = [
  255. item
  256. for item in records
  257. if getattr(item, name) == filters[name]
  258. ]
  259. keyword = filters.get("keyword")
  260. if keyword:
  261. folded = keyword.casefold()
  262. records = [
  263. item
  264. for item in records
  265. if folded in item.canonical_code.casefold()
  266. or folded in item.canonical_name.casefold()
  267. ]
  268. records.sort(key=lambda item: (item.code_type, item.canonical_code))
  269. start = (page - 1) * page_size
  270. return records[start : start + page_size], len(records)
  271. def list_versions(self, code_uid):
  272. return list(reversed(self.versions_by_code.get(code_uid, ())))
  273. def list_reviews(self, code_uid):
  274. return list(reversed(self.reviews_by_code.get(code_uid, ())))
  275. def code_service(repository=None, *, authorizer=None):
  276. from app.core.data_research.device_semantics import (
  277. DeviceSemanticCodeService,
  278. )
  279. ontology_uid = "01900000-0000-7000-8000-000000000521"
  280. ids = iter(
  281. (
  282. "01900000-0000-7000-8000-000000000531",
  283. "01900000-0000-7000-8000-000000000532",
  284. "01900000-0000-7000-8000-000000000533",
  285. "01900000-0000-7000-8000-000000000534",
  286. "01900000-0000-7000-8000-000000000535",
  287. )
  288. )
  289. clock = iter(
  290. (
  291. datetime(2026, 7, 29, 15, 0, tzinfo=timezone(timedelta(hours=8))),
  292. datetime(2026, 7, 29, 15, 1, tzinfo=timezone(timedelta(hours=8))),
  293. datetime(2026, 7, 29, 15, 2, tzinfo=timezone(timedelta(hours=8))),
  294. datetime(2026, 7, 29, 15, 3, tzinfo=timezone(timedelta(hours=8))),
  295. )
  296. )
  297. code_repository = repository or MemorySemanticCodeRepository(
  298. ontology_uid
  299. )
  300. return (
  301. DeviceSemanticCodeService(
  302. code_repository,
  303. uid_factory=ids.__next__,
  304. now_factory=clock.__next__,
  305. review_authorizer=authorizer or (
  306. lambda _ontology_uid, _actor_uid: None
  307. ),
  308. ),
  309. code_repository,
  310. ontology_uid,
  311. )
  312. @pytest.mark.parametrize("code_type", ("fault", "cause", "action"))
  313. def test_semantic_code_supports_three_governed_types_and_immutable_revision(
  314. code_type,
  315. ):
  316. service, repository, ontology_uid = code_service()
  317. created = service.create(
  318. {
  319. "ontology_uid": ontology_uid,
  320. "code_type": code_type,
  321. "canonical_code": f"{code_type}-001",
  322. "canonical_name": f"{code_type}示例",
  323. "definition": "首版定义",
  324. "source_mappings": [
  325. {
  326. "source_uid": SOURCE_UID,
  327. "source_code": "SRC-001",
  328. }
  329. ],
  330. },
  331. actor_uid=MANAGER_UID,
  332. )
  333. revised = service.revise(
  334. created.uid,
  335. {
  336. "canonical_name": f"{code_type}统一名称",
  337. "definition": "修订定义",
  338. "source_mappings": list(created.source_mappings),
  339. },
  340. expected_version=1,
  341. actor_uid=MANAGER_UID,
  342. )
  343. assert created.canonical_code == f"{code_type.upper()}-001"
  344. assert revised.uid == created.uid
  345. assert revised.current_version == 2
  346. versions = repository.list_versions(created.uid)
  347. assert [item.version for item in versions] == [2, 1]
  348. assert versions[0].snapshot["canonical_name"] == f"{code_type}统一名称"
  349. assert versions[1].snapshot["canonical_name"] == f"{code_type}示例"
  350. def test_semantic_code_rejects_secrets_and_unproven_suggestions():
  351. from app.core.data_research.errors import DeviceSemanticInvalid
  352. service, _repository, ontology_uid = code_service()
  353. base = {
  354. "ontology_uid": ontology_uid,
  355. "code_type": "fault",
  356. "canonical_code": "F-001",
  357. "canonical_name": "轴承过热",
  358. }
  359. with pytest.raises(DeviceSemanticInvalid, match="secret"):
  360. service.create(
  361. {
  362. **base,
  363. "source_mappings": [
  364. {
  365. "source_uid": SOURCE_UID,
  366. "source_code": "SRC-001",
  367. "token": "secret",
  368. }
  369. ],
  370. },
  371. actor_uid=MANAGER_UID,
  372. )
  373. with pytest.raises(DeviceSemanticInvalid, match="secret"):
  374. service.create(
  375. {
  376. **base,
  377. "contact_phone": "13800000000",
  378. },
  379. actor_uid=MANAGER_UID,
  380. )
  381. with pytest.raises(DeviceSemanticInvalid, match="evidence"):
  382. service.create(
  383. {**base, "suggestion_source": "rule"},
  384. actor_uid=MANAGER_UID,
  385. )
  386. with pytest.raises(DeviceSemanticInvalid, match="confidence"):
  387. service.create(
  388. {
  389. **base,
  390. "suggestion_source": "ai",
  391. "evidence_uids": ["evidence-1"],
  392. "confidence": 1.2,
  393. },
  394. actor_uid=MANAGER_UID,
  395. )
  396. def test_semantic_codes_cannot_be_attached_to_a_generic_ontology():
  397. from app.core.data_research.errors import DeviceSemanticInvalid
  398. service, repository, _ontology_uid = code_service()
  399. generic_uid = "01900000-0000-7000-8000-000000000522"
  400. repository.ontologies[generic_uid] = "CUSTOMER"
  401. with pytest.raises(DeviceSemanticInvalid, match="device ontology"):
  402. service.create(
  403. {
  404. "ontology_uid": generic_uid,
  405. "code_type": "fault",
  406. "canonical_code": "F-OTHER-001",
  407. "canonical_name": "非设备故障",
  408. },
  409. actor_uid=MANAGER_UID,
  410. )
  411. def test_semantic_code_submit_and_review_are_stateful_and_audited():
  412. approvals = []
  413. service, repository, ontology_uid = code_service(
  414. authorizer=lambda ontology, actor: approvals.append(
  415. (ontology, actor)
  416. )
  417. )
  418. record = service.create(
  419. {
  420. "ontology_uid": ontology_uid,
  421. "code_type": "fault",
  422. "canonical_code": "F-001",
  423. "canonical_name": "轴承过热",
  424. },
  425. actor_uid=OTHER_UID,
  426. )
  427. submitted = service.submit(
  428. record.uid,
  429. expected_version=1,
  430. actor_uid=OTHER_UID,
  431. )
  432. reviewed, review = service.review(
  433. record.uid,
  434. {
  435. "decision": "approve",
  436. "reason": "与企业故障字典一致",
  437. "expected_version": 1,
  438. },
  439. actor_uid=MANAGER_UID,
  440. )
  441. assert submitted.status == "in_review"
  442. assert reviewed.status == "published"
  443. assert review.version == 1
  444. assert review.decision == "approve"
  445. assert review.actor_uid == MANAGER_UID
  446. assert approvals == [(ontology_uid, MANAGER_UID)]
  447. assert repository.list_reviews(record.uid) == [review]
  448. def test_semantic_code_rejects_stale_revision_duplicate_and_bad_transition():
  449. from app.core.data_research.errors import (
  450. DeviceSemanticConflict,
  451. DeviceSemanticInvalid,
  452. )
  453. service, _repository, ontology_uid = code_service()
  454. payload = {
  455. "ontology_uid": ontology_uid,
  456. "code_type": "cause",
  457. "canonical_code": "C-001",
  458. "canonical_name": "润滑不足",
  459. }
  460. record = service.create(payload, actor_uid=MANAGER_UID)
  461. with pytest.raises(DeviceSemanticConflict, match="exists"):
  462. service.create(payload, actor_uid=MANAGER_UID)
  463. with pytest.raises(DeviceSemanticConflict, match="version"):
  464. service.revise(
  465. record.uid,
  466. {"canonical_name": "润滑油不足"},
  467. expected_version=0,
  468. actor_uid=MANAGER_UID,
  469. )
  470. with pytest.raises(DeviceSemanticInvalid, match="in_review"):
  471. service.review(
  472. record.uid,
  473. {
  474. "decision": "approve",
  475. "reason": "尚未提交",
  476. "expected_version": 1,
  477. },
  478. actor_uid=MANAGER_UID,
  479. )
  480. def test_semantic_code_search_and_traceability_are_bounded():
  481. service, _repository, ontology_uid = code_service()
  482. fault = service.create(
  483. {
  484. "ontology_uid": ontology_uid,
  485. "code_type": "fault",
  486. "canonical_code": "F-001",
  487. "canonical_name": "轴承过热",
  488. },
  489. actor_uid=MANAGER_UID,
  490. )
  491. service.create(
  492. {
  493. "ontology_uid": ontology_uid,
  494. "code_type": "cause",
  495. "canonical_code": "C-001",
  496. "canonical_name": "润滑不足",
  497. },
  498. actor_uid=MANAGER_UID,
  499. )
  500. records, total = service.search(
  501. {
  502. "ontology_uid": ontology_uid,
  503. "code_type": "fault",
  504. "keyword": "过热",
  505. },
  506. page=1,
  507. page_size=20,
  508. )
  509. assert total == 1
  510. assert records == [fault]
  511. assert service.get(fault.uid) == fault
  512. assert service.versions(fault.uid)[0].snapshot["canonical_code"] == "F-001"