test_decision.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. import json
  2. from pathlib import Path
  3. import pytest
  4. from ontorefactor_governance.agents import execute_governance_agent
  5. from ontorefactor_governance.builds import publish_ontology_build, run_ontology_build
  6. from ontorefactor_governance.db import Database
  7. from ontorefactor_governance.decision import collect_project_context, evaluate_retirement_gate, resolve_retirement_target
  8. from ontorefactor_governance.governance_qa import ask_governance_agent
  9. from ontorefactor_governance.llm import LongCatProvider, PROMPT_VERSION
  10. from ontorefactor_governance.service import GovernanceError, create_assertion, create_evidence, seed_challenge_cup_demo, upsert_element
  11. SAMPLES = Path(__file__).parents[1] / "src" / "ontorefactor_governance" / "static" / "samples"
  12. @pytest.fixture
  13. def db():
  14. value = Database("sqlite:///:memory:")
  15. yield value
  16. value.close()
  17. def _demo(db):
  18. seeded = seed_challenge_cup_demo(db, "decision-demo")
  19. project_id = seeded["project"]["id"]
  20. definitions = (
  21. ("01-dcp-user.sql", "ddl"),
  22. ("02-user-profile-openapi.yaml", "openapi"),
  23. ("03-dcp-asset-inventory.json", "inventory"),
  24. ("04-governance-evidence.json", "inventory"),
  25. )
  26. sources = [{
  27. "source_name": name,
  28. "source_type": source_type,
  29. "content": (SAMPLES / name).read_text(encoding="utf-8"),
  30. } for name, source_type in definitions]
  31. build = run_ontology_build(
  32. db, "decision-demo", project_id, sources,
  33. semantic_mode="deterministic", environment="production",
  34. decision_target="dcp_user.mobile",
  35. )
  36. publish_ontology_build(
  37. db, "decision-demo", project_id, build["build_id"],
  38. reviewer="test-builder", decision_target="dcp_user.mobile",
  39. )
  40. target = resolve_retirement_target(db, "decision-demo", project_id, "dcp_user.mobile")
  41. return project_id, target["id"]
  42. def _set_metadata(db, project_id, kind, metadata):
  43. db.execute(
  44. "UPDATE governance_evidence SET metadata=? WHERE project_id=? AND kind=?",
  45. (json.dumps(metadata, ensure_ascii=False), project_id, kind),
  46. )
  47. db.commit()
  48. def test_demo_decision_is_computed_from_evidence_not_seeded_issues(db):
  49. project_id, target_id = _demo(db)
  50. decision = evaluate_retirement_gate(db, "decision-demo", project_id, target_id)
  51. assert decision["status"] == "BLOCKED"
  52. assert decision["summary"] == {"passed": 1, "failed": 4, "unknown": 0, "total": 5}
  53. checks = {item["key"]: item for item in decision["checks"]}
  54. assert checks["active-consumers"]["actual"] == 3
  55. assert checks["migration-quality"]["actual"] == 0.9987
  56. assert checks["compatibility-window"]["actual"] == 0
  57. assert not db.fetchall("SELECT id FROM governance_issues WHERE project_id=? AND rule_id LIKE 'demo:%'", (project_id,))
  58. def test_fourth_agent_answers_a_natural_language_question_independently(db, monkeypatch):
  59. monkeypatch.delenv("AGENTPAAS_BASE_URL", raising=False)
  60. project_id, _ = _demo(db)
  61. result = ask_governance_agent(
  62. db, "decision-demo", project_id,
  63. "请问 dcp_user.mobile 现在是否可以迁移并删除?",
  64. )
  65. assert result["agent"] == "governance-decision-agent"
  66. assert result["decision"]["status"] == "BLOCKED"
  67. assert result["resolved_target"]["id"]
  68. assert "目前不可以" in result["answer"]
  69. run = db.fetchone("SELECT * FROM governance_agent_runs WHERE id=?", (result["run_id"],))
  70. assert run["source_type"] == "governance-question"
  71. assert run["decision_id"] == result["decision"]["id"]
  72. def test_fourth_agent_requires_a_published_ontology(db):
  73. project_id = seed_challenge_cup_demo(db, "unpublished-qa")["project"]["id"]
  74. with pytest.raises(GovernanceError) as error:
  75. ask_governance_agent(db, "unpublished-qa", project_id, "dcp_user.mobile 可以删除吗?")
  76. assert error.value.code == "ONTOLOGY_NOT_PUBLISHED"
  77. assert error.value.status == 409
  78. def test_retirement_gate_returns_insufficient_when_required_evidence_is_missing(db):
  79. project_id, target_id = _demo(db)
  80. db.execute("DELETE FROM governance_evidence WHERE project_id=?", (project_id,))
  81. db.execute("UPDATE governance_assertions SET review_status='rejected' WHERE project_id=? AND predicate IN ('READS','USES')", (project_id,))
  82. db.commit()
  83. decision = evaluate_retirement_gate(db, "decision-demo", project_id, target_id, persist=True)
  84. assert decision["status"] == "INSUFFICIENT_EVIDENCE"
  85. assert decision["summary"]["failed"] == 0
  86. assert decision["summary"]["unknown"] == 4
  87. def test_retirement_gate_returns_allow_only_after_current_facts_pass(db):
  88. project_id, target_id = _demo(db)
  89. _set_metadata(db, project_id, "RuntimeObservation", {
  90. "target": "dcp_user.mobile", "window": "24h", "consumer_count": 0, "active_consumers": [],
  91. })
  92. source_rows = db.fetchall("SELECT id,source,metadata FROM governance_evidence WHERE project_id=? AND kind='SourceArtifact'", (project_id,))
  93. for row in source_rows:
  94. metadata = json.loads(row["metadata"])
  95. if "consumer_count" in metadata:
  96. metadata.update({"consumer_count": 0, "active_consumers": []})
  97. db.execute("UPDATE governance_evidence SET metadata=? WHERE id=?", (json.dumps(metadata), row["id"]))
  98. _set_metadata(db, project_id, "QualityReport", {
  99. "target": "dcp_user.mobile", "checked_rows": 10189432, "unmatched_rows": 0,
  100. "completeness": 1.0, "threshold": 1.0,
  101. })
  102. _set_metadata(db, project_id, "ExpertStatement", {
  103. "target": "dcp_user.mobile", "approved": ["architecture", "data-owner", "security"],
  104. "pending": [], "rejected": [],
  105. })
  106. _set_metadata(db, project_id, "Contract", {
  107. "target": "dcp_user.mobile", "required_release_cycles": 2, "completed_release_cycles": 2,
  108. })
  109. db.execute("UPDATE governance_assertions SET review_status='rejected' WHERE project_id=? AND predicate IN ('READS','USES')", (project_id,))
  110. db.commit()
  111. decision = evaluate_retirement_gate(db, "decision-demo", project_id, target_id, persist=True)
  112. assert decision["status"] == "ALLOW"
  113. assert decision["summary"] == {"passed": 5, "failed": 0, "unknown": 0, "total": 5}
  114. assert not db.fetchall(
  115. "SELECT id FROM governance_issues WHERE project_id=? AND rule_id LIKE 'decision:retirement:%'",
  116. (project_id,),
  117. )
  118. def test_target_scoped_evidence_does_not_leak_between_fields(db):
  119. project_id, target_id = _demo(db)
  120. create_evidence(db, "decision-demo", project_id, {
  121. "kind": "QualityReport", "uri": "evidence://other-field/newer-quality",
  122. "title": "另一个字段的迁移报告", "source": "quality://other-field/latest",
  123. "observed_at": "2099-01-01T00:00:00+00:00",
  124. "metadata": {"target": "dcp_user.email", "completeness": 1.0, "unmatched_rows": 0},
  125. })
  126. decision = evaluate_retirement_gate(db, "decision-demo", project_id, target_id)
  127. quality_check = next(item for item in decision["checks"] if item["key"] == "migration-quality")
  128. assert quality_check["actual"] == 0.9987
  129. assert quality_check["state"] == "failed"
  130. def test_repeated_bootstrap_does_not_add_project_data(db):
  131. project_id, _ = _demo(db)
  132. before = {
  133. "builds": db.fetchone("SELECT COUNT(*) n FROM ontology_builds WHERE project_id=?", (project_id,))["n"],
  134. "elements": db.fetchone("SELECT COUNT(*) n FROM ontology_elements WHERE project_id=?", (project_id,))["n"],
  135. "evidence": db.fetchone("SELECT COUNT(*) n FROM governance_evidence WHERE project_id=?", (project_id,))["n"],
  136. }
  137. seed_challenge_cup_demo(db, "decision-demo")
  138. after = {
  139. "builds": db.fetchone("SELECT COUNT(*) n FROM ontology_builds WHERE project_id=?", (project_id,))["n"],
  140. "elements": db.fetchone("SELECT COUNT(*) n FROM ontology_elements WHERE project_id=?", (project_id,))["n"],
  141. "evidence": db.fetchone("SELECT COUNT(*) n FROM governance_evidence WHERE project_id=?", (project_id,))["n"],
  142. }
  143. assert after == before
  144. def test_pending_generated_assertions_are_used_when_requested(db):
  145. project_id, target_id = _demo(db)
  146. db.execute("UPDATE governance_assertions SET review_status='rejected' WHERE project_id=? AND predicate IN ('READS','USES')", (project_id,))
  147. source = db.fetchone(
  148. "SELECT id FROM ontology_elements WHERE project_id=? AND kind='BusinessObject' LIMIT 1", (project_id,)
  149. )
  150. create_assertion(db, "decision-demo", project_id, {
  151. "subject_id": source["id"], "predicate": "READS", "object_id": target_id,
  152. "assertion_type": "inferred", "confidence": .8, "review_status": "pending",
  153. "generated_by": "longcat:test", "evidence_ids": [],
  154. })
  155. for row in db.fetchall("SELECT id,metadata FROM governance_evidence WHERE project_id=?", (project_id,)):
  156. metadata = json.loads(row["metadata"])
  157. if "consumer_count" in metadata:
  158. metadata["consumer_count"] = 0
  159. db.execute("UPDATE governance_evidence SET metadata=? WHERE id=?", (json.dumps(metadata), row["id"]))
  160. db.commit()
  161. using_pending = evaluate_retirement_gate(db, "decision-demo", project_id, target_id, include_pending=True)
  162. accepted_only = evaluate_retirement_gate(db, "decision-demo", project_id, target_id, include_pending=False)
  163. pending_check = next(item for item in using_pending["checks"] if item["key"] == "active-consumers")
  164. accepted_check = next(item for item in accepted_only["checks"] if item["key"] == "active-consumers")
  165. assert pending_check["actual"] == 1
  166. assert pending_check["state"] == "failed"
  167. assert accepted_check["actual"] == 0
  168. assert accepted_check["state"] == "passed"
  169. def test_generated_assertion_on_equivalent_imported_field_is_used(db):
  170. project_id, target_id = _demo(db)
  171. imported_target = upsert_element(db, "decision-demo", project_id, {
  172. "layer": "M1", "profile": "software", "kind": "Column", "name": "DcpUserMobileColumn",
  173. "uri": "urn:ontorefactor:column:iam:dcp_user:mobile", "attributes": {"sensitive": True},
  174. })
  175. imported_cipher = upsert_element(db, "decision-demo", project_id, {
  176. "layer": "M1", "profile": "software", "kind": "Column", "name": "DcpUserMobileCipherColumn",
  177. "uri": "urn:ontorefactor:column:iam:dcp_user:mobile_cipher", "attributes": {"sensitive": True},
  178. })
  179. generated = create_assertion(db, "decision-demo", project_id, {
  180. "subject_id": imported_target["id"], "predicate": "MIRRORS_TO", "object_id": imported_cipher["id"],
  181. "assertion_type": "inferred", "confidence": .8, "review_status": "pending",
  182. "generated_by": "longcat:test", "evidence_ids": [],
  183. })
  184. decision = evaluate_retirement_gate(db, "decision-demo", project_id, target_id, include_pending=True)
  185. security_check = next(item for item in decision["checks"] if item["key"] == "encrypted-replacement")
  186. assert imported_target["id"] in decision["resolved_target_ids"]
  187. assert generated["id"] in security_check["assertion_ids"]
  188. def test_agent_reads_bounded_project_context_before_generating_assertions(db):
  189. project_id, _ = _demo(db)
  190. context = collect_project_context(db, "decision-demo", project_id, target_hint="dcp_user.mobile")
  191. assert context["scope"]["included_evidence"] == context["scope"]["total_evidence"] == 9
  192. assert context["scope"]["included_assets"] <= 80
  193. result = execute_governance_agent(db, "decision-demo", project_id, {
  194. "source_type": "ddl", "source_name": "live.sql", "semantic_mode": "deterministic",
  195. "decision_target": "dcp_user.mobile",
  196. "content": "CREATE TABLE iam.dcp_user (id BIGINT PRIMARY KEY, mobile VARCHAR(32));",
  197. })
  198. assert result["decision"]["status"] == "BLOCKED"
  199. assert result["project_context"]["scope"]["total_evidence"] == 9
  200. run = db.fetchone("SELECT context_hash,context_scope,decision_id FROM governance_agent_runs WHERE id=?", (result["run_id"],))
  201. assert run["context_hash"]
  202. assert run["decision_id"] == result["decision"]["id"]
  203. def test_project_context_redacts_credentials_before_model_use(db):
  204. project_id, _ = _demo(db)
  205. create_evidence(db, "decision-demo", project_id, {
  206. "kind": "SourceArtifact", "uri": "evidence://secret/redaction-test",
  207. "title": "连接器配置", "source": "connector://test",
  208. "content": "Authorization: Bearer abcdefghijklmnopqrstuvwxyz and ak_1234567890abcdefghijkl",
  209. "metadata": {"apiKey": "ak_should_not_escape_123456", "credential": "private"},
  210. })
  211. context = collect_project_context(db, "decision-demo", project_id, target_hint="dcp_user.mobile")
  212. serialized = json.dumps(context, ensure_ascii=False)
  213. assert "ak_should_not_escape" not in serialized
  214. assert "abcdefghijklmnopqrstuvwxyz" not in serialized
  215. assert "[REDACTED]" in serialized
  216. def test_longcat_can_generate_cross_source_assertion_from_project_evidence(db, monkeypatch):
  217. project_id, target_id = _demo(db)
  218. module = db.fetchone(
  219. "SELECT id FROM ontology_elements WHERE project_id=? AND kind='Module' LIMIT 1", (project_id,)
  220. )
  221. captured = {}
  222. def fake_chat(provider, messages):
  223. captured["prompt"] = messages[-1]["content"]
  224. provider.last_metrics = {
  225. "provider": "longcat", "model": "LongCat-2.0", "prompt_version": PROMPT_VERSION,
  226. "input_tokens": 50, "output_tokens": 20, "latency_ms": 8,
  227. "request_hash": "request", "response_hash": "response",
  228. }
  229. return json.dumps({
  230. "business_objects": [], "owners": [], "classifications": [], "quality_rules": [], "issues": [],
  231. "relationships": [{
  232. "subject_asset_id": f"existing:{module['id']}", "predicate": "USES",
  233. "object_asset_id": f"existing:{target_id}", "confidence": .8,
  234. "reason": "项目证据显示模块仍使用手机号字段",
  235. }],
  236. })
  237. monkeypatch.setenv("LONGCAT_API_KEY", "test-key")
  238. monkeypatch.setattr(LongCatProvider, "chat", fake_chat)
  239. result = execute_governance_agent(db, "decision-demo", project_id, {
  240. "source_type": "ddl", "source_name": "change.sql", "semantic_mode": "llm",
  241. "decision_target": "dcp_user.mobile", "instruction": "判断字段能否退役",
  242. "content": "CREATE TABLE change_request (id BIGINT PRIMARY KEY);",
  243. })
  244. assert "手机号明文与密文双跑比对" in captured["prompt"]
  245. generated = db.fetchone(
  246. "SELECT * FROM governance_assertions WHERE project_id=? AND subject_id=? AND predicate='USES' AND object_id=?",
  247. (project_id, module["id"], target_id),
  248. )
  249. assert generated["review_status"] == "pending"
  250. assert generated["generated_by"].startswith("longcat:")
  251. active_check = next(item for item in result["decision"]["checks"] if item["key"] == "active-consumers")
  252. assert generated["id"] in active_check["assertion_ids"]