test_api.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. from __future__ import annotations
  2. def _evidence():
  3. from app.core.knowledge.retrieval.contracts import KnowledgeEvidence
  4. return KnowledgeEvidence(
  5. chunk_id="chunk-1",
  6. content="客户同步用途",
  7. score=0.9,
  8. retriever="lexical+vector",
  9. object_uid="object-1",
  10. object_type="DataFlow",
  11. object_version=2,
  12. business_domain_uid="domain-a",
  13. point_keys=("DataFlow/object-1/purpose",),
  14. point_revisions=(3,),
  15. generation=1,
  16. source_updated_at="2026-07-23T00:00:00+00:00",
  17. )
  18. class _AuditRepository:
  19. def __init__(self, records=()):
  20. self.records = list(records)
  21. self.recorded = []
  22. def record(self, record):
  23. self.recorded.append(record)
  24. def list(self, *, limit):
  25. return tuple(self.records[:limit])
  26. def test_viewer_can_post_search_and_receives_stable_canonical_contract(monkeypatch):
  27. from app import create_app
  28. from app.core.knowledge.access import KnowledgeAccessContext
  29. from app.core.knowledge.retrieval.contracts import SearchResult
  30. class Pipeline:
  31. def search(self, *_args, **_kwargs):
  32. return SearchResult((_evidence(),), "semantic")
  33. monkeypatch.setattr(
  34. "app.core.system.permissions.authenticate_request",
  35. lambda: {"id": "user-1", "roles": ["viewer"]},
  36. )
  37. monkeypatch.setattr(
  38. "app.api.knowledge_base.routes.build_access_context",
  39. lambda *_args, **_kwargs: KnowledgeAccessContext(
  40. subject_id="user-1",
  41. roles=frozenset({"viewer"}),
  42. permissions=frozenset({"governance:read"}),
  43. business_domain_uids=frozenset({"domain-a"}),
  44. correlation_id="correlation-1",
  45. ),
  46. )
  47. app = create_app()
  48. app.extensions["knowledge_retrieval_pipeline"] = Pipeline()
  49. audit = _AuditRepository()
  50. app.extensions["knowledge_query_audit_repository"] = audit
  51. response = app.test_client().post(
  52. "/api/knowledge/search", json={"query": "用途", "mode": "semantic"}
  53. )
  54. assert response.status_code == 200
  55. payload = response.get_json()["data"]
  56. assert payload["evidence"][0]["object_uid"] == "object-1"
  57. assert payload["evidence"][0]["point_revisions"] == [3]
  58. assert payload["evidence"][0]["index_generation"] == 1
  59. assert len(audit.recorded) == 1
  60. def test_search_rejects_client_domain_filter_shape_before_retrieval(monkeypatch):
  61. from app import create_app
  62. monkeypatch.setattr(
  63. "app.core.system.permissions.authenticate_request",
  64. lambda: {"id": "user-1", "roles": ["viewer"]},
  65. )
  66. app = create_app()
  67. response = app.test_client().post(
  68. "/api/knowledge/search",
  69. json={"query": "用途", "business_domain_uids": "domain-a"},
  70. )
  71. assert response.status_code == 400
  72. def test_search_rejects_non_numeric_limit(monkeypatch):
  73. from app import create_app
  74. monkeypatch.setattr(
  75. "app.core.system.permissions.authenticate_request",
  76. lambda: {"id": "user-1", "roles": ["viewer"]},
  77. )
  78. app = create_app()
  79. response = app.test_client().post(
  80. "/api/knowledge/search",
  81. json={"query": "用途", "limit": "many"},
  82. )
  83. assert response.status_code == 400
  84. assert response.get_json()["message"] == "limit 必须为整数"
  85. def test_device_search_and_grounded_answer_return_safe_evidence_and_citation_content(
  86. monkeypatch,
  87. ):
  88. from app import create_app
  89. from app.core.knowledge.access import KnowledgeAccessContext
  90. from app.core.knowledge.qa import AnswerResult, Citation
  91. from app.core.knowledge.retrieval.contracts import (
  92. KnowledgeEvidence,
  93. SearchResult,
  94. )
  95. evidence = KnowledgeEvidence(
  96. chunk_id="device:asset-1:v2",
  97. content="设备名称:循环泵\n源 ID:EQ-001\n责任人:张工",
  98. score=0.95,
  99. retriever="device",
  100. object_uid="asset-1",
  101. object_type="DeviceAsset",
  102. object_version=2,
  103. business_domain_uid="domain-a",
  104. point_keys=("DeviceAsset/asset-1/summary",),
  105. point_revisions=(2,),
  106. generation=2,
  107. source_updated_at="2026-07-29T08:00:00+00:00",
  108. )
  109. class Pipeline:
  110. def search(self, *_args, **_kwargs):
  111. return SearchResult((evidence,), "exact", ("vector",))
  112. class Synthesizer:
  113. def answer(self, _query, _evidence):
  114. return AnswerResult(
  115. "grounded",
  116. "循环泵的责任人是张工。",
  117. (
  118. Citation(
  119. object_uid="asset-1",
  120. object_type="DeviceAsset",
  121. object_version=2,
  122. point_key="DeviceAsset/asset-1/summary",
  123. point_revision=2,
  124. chunk_id="device:asset-1:v2",
  125. section_path=None,
  126. source_updated_at="2026-07-29T08:00:00+00:00",
  127. index_generation=2,
  128. retrievers=("device",),
  129. score=0.95,
  130. freshness_status="fresh",
  131. ),
  132. ),
  133. "fresh",
  134. )
  135. monkeypatch.setattr(
  136. "app.core.system.permissions.authenticate_request",
  137. lambda: {
  138. "id": "11111111-1111-4111-8111-111111111111",
  139. "roles": ["viewer"],
  140. },
  141. )
  142. monkeypatch.setattr(
  143. "app.api.knowledge_base.routes.build_access_context",
  144. lambda *_args, **_kwargs: KnowledgeAccessContext(
  145. subject_id="11111111-1111-4111-8111-111111111111",
  146. roles=frozenset({"viewer"}),
  147. permissions=frozenset({"governance:read"}),
  148. business_domain_uids=frozenset({"domain-a"}),
  149. correlation_id="22222222-2222-4222-8222-222222222222",
  150. ),
  151. )
  152. app = create_app()
  153. app.extensions["knowledge_retrieval_pipeline"] = Pipeline()
  154. app.extensions["knowledge_answer_synthesizer"] = Synthesizer()
  155. audit = _AuditRepository()
  156. app.extensions["knowledge_query_audit_repository"] = audit
  157. client = app.test_client()
  158. search_response = client.post(
  159. "/api/knowledge/search",
  160. json={"query": "EQ-001", "mode": "exact"},
  161. )
  162. ask_response = client.post(
  163. "/api/knowledge/ask",
  164. json={"query": "循环泵的责任人是谁?"},
  165. )
  166. assert search_response.status_code == 200
  167. assert (
  168. search_response.get_json()["data"]["evidence"][0]["object_type"]
  169. == "DeviceAsset"
  170. )
  171. assert ask_response.status_code == 200
  172. answer = ask_response.get_json()["data"]
  173. assert answer["evidence"][0]["content"].endswith("责任人:张工")
  174. assert answer["citations"][0]["content"].endswith("责任人:张工")
  175. assert answer["citations"][0]["object_uid"] == "asset-1"
  176. assert len(audit.recorded) == 2
  177. assert audit.recorded[1].cited_points == (
  178. {
  179. "point_key": "DeviceAsset/asset-1/summary",
  180. "point_revision": 2,
  181. },
  182. )
  183. def test_model_unavailable_returns_no_answer_but_keeps_authorized_evidence(
  184. monkeypatch,
  185. ):
  186. from app import create_app
  187. from app.core.knowledge.access import KnowledgeAccessContext
  188. from app.core.knowledge.qa import AnswerResult
  189. from app.core.knowledge.retrieval.contracts import SearchResult
  190. class Pipeline:
  191. def search(self, *_args, **_kwargs):
  192. return SearchResult((_evidence(),), "semantic")
  193. class Synthesizer:
  194. def answer(self, _query, _evidence):
  195. return AnswerResult(
  196. "model_unavailable",
  197. None,
  198. (),
  199. "degraded",
  200. )
  201. monkeypatch.setattr(
  202. "app.core.system.permissions.authenticate_request",
  203. lambda: {
  204. "id": "11111111-1111-4111-8111-111111111111",
  205. "roles": ["viewer"],
  206. },
  207. )
  208. monkeypatch.setattr(
  209. "app.api.knowledge_base.routes.build_access_context",
  210. lambda *_args, **_kwargs: KnowledgeAccessContext(
  211. subject_id="11111111-1111-4111-8111-111111111111",
  212. roles=frozenset({"viewer"}),
  213. permissions=frozenset({"governance:read"}),
  214. business_domain_uids=frozenset({"domain-a"}),
  215. correlation_id="22222222-2222-4222-8222-222222222222",
  216. ),
  217. )
  218. app = create_app()
  219. app.extensions["knowledge_retrieval_pipeline"] = Pipeline()
  220. app.extensions["knowledge_answer_synthesizer"] = Synthesizer()
  221. app.extensions["knowledge_query_audit_repository"] = _AuditRepository()
  222. response = app.test_client().post(
  223. "/api/knowledge/ask",
  224. json={"query": "用途是什么?"},
  225. )
  226. assert response.status_code == 200
  227. data = response.get_json()["data"]
  228. assert data["answer"] is None
  229. assert data["answer_status"] == "model_unavailable"
  230. assert data["citations"] == []
  231. assert data["evidence"][0]["content"] == "客户同步用途"
  232. def test_device_source_detail_is_authorized_and_contains_no_private_payload(
  233. monkeypatch,
  234. ):
  235. from datetime import UTC, datetime
  236. from app import create_app
  237. from app.core.knowledge.access import KnowledgeAccessContext
  238. from app.core.knowledge.retrieval.device import DeviceSearchRow
  239. class Repository:
  240. def get_detail(self, asset_uid, **_kwargs):
  241. if asset_uid != "11111111-1111-4111-8111-111111111111":
  242. return None
  243. return DeviceSearchRow(
  244. asset_uid=asset_uid,
  245. asset_type="device",
  246. name="循环泵",
  247. current_version=2,
  248. location="动力车间",
  249. organization="设备动力部",
  250. responsible_person="张工",
  251. source_codes=("EQ-001",),
  252. related_events=(("fault", "轴承故障", "FT-001"),),
  253. business_domain_uid="domain-a",
  254. updated_at=datetime(2026, 7, 29, tzinfo=UTC),
  255. rank=1.0,
  256. )
  257. monkeypatch.setattr(
  258. "app.core.system.permissions.authenticate_request",
  259. lambda: {
  260. "id": "11111111-1111-4111-8111-111111111111",
  261. "roles": ["viewer"],
  262. },
  263. )
  264. monkeypatch.setattr(
  265. "app.api.knowledge_base.routes._source_document",
  266. lambda *_args, **_kwargs: None,
  267. )
  268. monkeypatch.setattr(
  269. "app.api.knowledge_base.routes._request_context",
  270. lambda _payload: (
  271. KnowledgeAccessContext(
  272. subject_id="11111111-1111-4111-8111-111111111111",
  273. roles=frozenset({"viewer"}),
  274. permissions=frozenset({"governance:read"}),
  275. business_domain_uids=frozenset({"domain-a"}),
  276. correlation_id="22222222-2222-4222-8222-222222222222",
  277. ),
  278. "22222222-2222-4222-8222-222222222222",
  279. ),
  280. )
  281. app = create_app()
  282. app.extensions["device_knowledge_repository"] = Repository()
  283. response = app.test_client().get(
  284. "/api/knowledge/sources/11111111-1111-4111-8111-111111111111"
  285. )
  286. assert response.status_code == 200
  287. data = response.get_json()["data"]
  288. assert data["object_type"] == "DeviceAsset"
  289. assert data["source_codes"] == ["EQ-001"]
  290. assert data["related_events"][0]["title"] == "轴承故障"
  291. assert "config" not in repr(data)
  292. assert "permission_scope" not in repr(data)
  293. def test_admin_can_manage_device_source_scope_and_list_minimized_query_audits(
  294. monkeypatch,
  295. ):
  296. from app import create_app
  297. from app.core.data_research.sources import IngestionSourceRecord
  298. domain_uid = "33333333-3333-4333-8333-333333333333"
  299. source_uid = "44444444-4444-4444-8444-444444444444"
  300. class ScopeService:
  301. def list(self):
  302. return (
  303. {
  304. "uid": source_uid,
  305. "name": "设备源",
  306. "business_domains": (),
  307. "admin_only": True,
  308. },
  309. )
  310. def update(self, uid, payload, *, actor_is_admin):
  311. assert uid == source_uid
  312. assert payload == {"business_domains": [domain_uid]}
  313. assert actor_is_admin is True
  314. return IngestionSourceRecord(
  315. uid=uid,
  316. source_type="database",
  317. name="设备源",
  318. config={"password": "private"},
  319. permission_scope={"business_domains": [domain_uid]},
  320. status="active",
  321. created_by="admin-1",
  322. )
  323. monkeypatch.setattr(
  324. "app.core.system.permissions.authenticate_request",
  325. lambda: {
  326. "id": "11111111-1111-4111-8111-111111111111",
  327. "roles": ["admin"],
  328. },
  329. )
  330. audit_row = {
  331. "id": "audit-1",
  332. "query_hash": "a" * 64,
  333. "mode": "exact",
  334. "retriever_counts": {"device": 1},
  335. "cited_points": [],
  336. "degraded_components": ["vector"],
  337. "correlation_id": "correlation-1",
  338. "created_at": "2026-07-29T08:00:00+00:00",
  339. }
  340. app = create_app()
  341. app.extensions["device_source_scope_service"] = ScopeService()
  342. app.extensions["knowledge_query_audit_repository"] = _AuditRepository(
  343. (audit_row,)
  344. )
  345. client = app.test_client()
  346. listed = client.get("/api/knowledge/admin/device-sources")
  347. updated = client.put(
  348. f"/api/knowledge/admin/device-sources/{source_uid}/scope",
  349. json={"business_domains": [domain_uid]},
  350. )
  351. audits = client.get("/api/knowledge/admin/query-audits")
  352. assert listed.status_code == 200
  353. assert listed.get_json()["data"][0]["admin_only"] is True
  354. assert updated.status_code == 200
  355. updated_data = updated.get_json()["data"]
  356. assert updated_data["business_domains"] == [domain_uid]
  357. assert "config" not in repr(updated_data)
  358. assert audits.status_code == 200
  359. assert audits.get_json()["data"][0]["query_hash"] == "a" * 64
  360. assert "query" not in audits.get_json()["data"][0]
  361. def test_executed_search_fails_when_mandatory_audit_persistence_fails(
  362. monkeypatch,
  363. ):
  364. from app import create_app
  365. from app.core.knowledge.access import KnowledgeAccessContext
  366. from app.core.knowledge.retrieval.contracts import SearchResult
  367. class Pipeline:
  368. def search(self, *_args, **_kwargs):
  369. return SearchResult((_evidence(),), "semantic")
  370. class Audit:
  371. def record(self, _record):
  372. raise RuntimeError("audit unavailable")
  373. monkeypatch.setattr(
  374. "app.core.system.permissions.authenticate_request",
  375. lambda: {
  376. "id": "11111111-1111-4111-8111-111111111111",
  377. "roles": ["viewer"],
  378. },
  379. )
  380. monkeypatch.setattr(
  381. "app.api.knowledge_base.routes.build_access_context",
  382. lambda *_args, **_kwargs: KnowledgeAccessContext(
  383. subject_id="11111111-1111-4111-8111-111111111111",
  384. roles=frozenset({"viewer"}),
  385. permissions=frozenset({"governance:read"}),
  386. business_domain_uids=frozenset({"domain-a"}),
  387. correlation_id="22222222-2222-4222-8222-222222222222",
  388. ),
  389. )
  390. app = create_app()
  391. app.extensions["knowledge_retrieval_pipeline"] = Pipeline()
  392. app.extensions["knowledge_query_audit_repository"] = Audit()
  393. response = app.test_client().post(
  394. "/api/knowledge/search",
  395. json={"query": "用途"},
  396. )
  397. assert response.status_code == 503
  398. assert response.get_json()["message"] == "知识查询审计写入失败"