test_llm.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. import json
  2. from dataclasses import replace
  3. from io import BytesIO
  4. from urllib.error import HTTPError
  5. import pytest
  6. from lambdagent import Context
  7. from ontorefactor_governance.agents import execute_governance_agent, normalize_source
  8. from ontorefactor_governance.db import Database
  9. from ontorefactor_governance.llm import (
  10. LLMSettings,
  11. LongCatProvider,
  12. LongCatSemanticAgent,
  13. PROMPT_VERSION,
  14. public_llm_status,
  15. test_llm_connection as check_llm_connection,
  16. validate_semantic_output,
  17. )
  18. from ontorefactor_governance.service import GovernanceError
  19. from ontorefactor_governance.service import create_project, list_assertions, list_evidence
  20. def settings(api_key="test-key"):
  21. return LLMSettings(
  22. deployment_mode="auto",
  23. provider="longcat",
  24. model="LongCat-2.0",
  25. base_url="https://api.longcat.chat/openai",
  26. api_key=api_key,
  27. temperature=0.1,
  28. max_tokens=4096,
  29. timeout=30,
  30. max_input_chars=120000,
  31. max_retries=0,
  32. )
  33. class FakeResponse:
  34. def __init__(self, value):
  35. self.value = value
  36. def __enter__(self):
  37. return self
  38. def __exit__(self, *_):
  39. return False
  40. def read(self):
  41. return json.dumps(self.value).encode()
  42. def test_longcat_provider_uses_official_openai_endpoint(monkeypatch):
  43. captured = {}
  44. def fake_urlopen(request, timeout):
  45. captured["url"] = request.full_url
  46. captured["authorization"] = request.get_header("Authorization")
  47. captured["body"] = json.loads(request.data)
  48. captured["timeout"] = timeout
  49. return FakeResponse({
  50. "model": "LongCat-2.0",
  51. "choices": [{"message": {"content": '{"ok":true}'}}],
  52. "usage": {"prompt_tokens": 12, "completion_tokens": 4},
  53. })
  54. monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
  55. provider = LongCatProvider(settings())
  56. result = provider.chat([{"role": "user", "content": "health"}])
  57. assert result == '{"ok":true}'
  58. assert captured["url"] == "https://api.longcat.chat/openai/v1/chat/completions"
  59. assert captured["authorization"] == "Bearer test-key"
  60. assert captured["body"]["model"] == "LongCat-2.0"
  61. assert captured["body"]["response_format"] == {"type": "json_object"}
  62. assert captured["body"]["thinking"] == {"type": "disabled"}
  63. assert provider.last_metrics["input_tokens"] == 12
  64. assert provider.last_metrics["output_tokens"] == 4
  65. assert provider.last_metrics["request_hash"]
  66. assert provider.last_metrics["response_hash"]
  67. def test_public_status_never_exposes_api_key():
  68. status = public_llm_status(settings("super-secret-value"))
  69. assert status["configured"] is True
  70. assert status["prompt_version"] == PROMPT_VERSION
  71. assert "super-secret-value" not in json.dumps(status)
  72. def test_longcat_retries_rate_limits_with_backoff(monkeypatch):
  73. attempts, delays = [], []
  74. def fake_urlopen(_request, timeout):
  75. attempts.append(1)
  76. if len(attempts) == 1:
  77. raise HTTPError("https://api.longcat.chat", 429, "rate limited", {"Retry-After": "0"}, BytesIO(b'{"error":"limited"}'))
  78. return FakeResponse({"choices": [{"message": {"content": '{"ok":true}'}}], "usage": {}})
  79. monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
  80. monkeypatch.setattr("time.sleep", lambda seconds: delays.append(seconds))
  81. provider = LongCatProvider(replace(settings(), max_retries=1))
  82. assert provider.chat([{"role": "user", "content": "health"}]) == '{"ok":true}'
  83. assert len(attempts) == 2
  84. assert delays == [0.0]
  85. def test_connection_maps_auth_failure_without_leaking_provider_body(monkeypatch):
  86. secret = "must-not-leak"
  87. def fake_urlopen(_request, timeout):
  88. raise HTTPError(
  89. "https://api.longcat.chat/openai/v1/chat/completions",
  90. 401,
  91. "unauthorized",
  92. {},
  93. BytesIO(f'{{"error":"invalid {secret}"}}'.encode()),
  94. )
  95. monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
  96. with pytest.raises(GovernanceError) as error:
  97. check_llm_connection(settings(secret))
  98. assert error.value.code == "LLM_AUTH_FAILED"
  99. assert error.value.status == 502
  100. assert secret not in str(error.value)
  101. def test_model_output_is_validated_capped_and_pending():
  102. catalog = [{
  103. "local_id": "m1:table:public:customer", "layer": "M1", "profile": "software",
  104. "kind": "Table", "name": "CustomerTable", "attributes": {},
  105. }]
  106. raw = json.dumps({
  107. "business_objects": [{
  108. "source_asset_id": "m1:table:public:customer", "name": "Customer",
  109. "description": "客户主数据", "domain": "CRM", "confidence": 0.99, "reason": "表名与字段结构",
  110. }],
  111. "owners": [{
  112. "target_asset_id": "m1:table:public:customer", "owner_role": "CRMDataOwner",
  113. "confidence": 0.8, "reason": "属于客户域",
  114. }],
  115. "classifications": [], "quality_rules": [], "issues": [],
  116. }, ensure_ascii=False)
  117. result = validate_semantic_output(raw, {}, catalog, {"model": "LongCat-2.0"})
  118. assert len(result["elements"]) == 2
  119. assert len(result["assertions"]) == 2
  120. assert all(item["review_status"] == "pending" for item in result["assertions"])
  121. assert max(item["confidence"] for item in result["assertions"]) <= 0.85
  122. assert all(item["generated_by"].startswith("longcat:LongCat-2.0") for item in result["assertions"])
  123. def test_chinese_semantic_names_produce_unique_stable_uris():
  124. catalog = [{
  125. "local_id": f"m1:table:public:source_{index}", "layer": "M1", "profile": "software",
  126. "kind": "Table", "name": f"Source{index}", "attributes": {},
  127. } for index in (1, 2)]
  128. raw = json.dumps({
  129. "business_objects": [{
  130. "source_asset_id": item["local_id"], "name": "用户档案", "description": "用户数据",
  131. "domain": "IAM", "confidence": 0.8, "reason": "名称和结构证据",
  132. } for item in catalog],
  133. "owners": [], "classifications": [],
  134. "quality_rules": [{
  135. "target_asset_id": item["local_id"], "name": "完整性规则", "dimension": "completeness",
  136. "description": "字段不能为空", "expression": "not_null", "severity": "high",
  137. "confidence": 0.8, "reason": "必填字段证据",
  138. } for item in catalog],
  139. "issues": [], "relationships": [],
  140. }, ensure_ascii=False)
  141. result = validate_semantic_output(raw, {}, catalog, {"model": "LongCat-2.0"})
  142. uris = [item["uri"] for item in result["elements"]]
  143. assert len(uris) == len(set(uris)) == 4
  144. assert all("unnamed" not in uri for uri in uris)
  145. def test_model_cannot_reference_unknown_assets():
  146. raw = json.dumps({
  147. "business_objects": [{
  148. "source_asset_id": "invented:database", "name": "Invented", "description": "",
  149. "domain": "", "confidence": 0.5, "reason": "guess",
  150. }],
  151. "owners": [], "classifications": [], "quality_rules": [], "issues": [],
  152. })
  153. with pytest.raises(GovernanceError) as error:
  154. validate_semantic_output(raw, {}, [], {"model": "LongCat-2.0"})
  155. assert error.value.code == "UNSAFE_LLM_OUTPUT"
  156. def test_model_aliases_are_normalized_then_strictly_validated():
  157. catalog = [
  158. {"local_id": "m1:table:default:customer", "layer": "M1", "profile": "software", "kind": "Table", "name": "customer", "attributes": {}},
  159. {"local_id": "m1:column:default:customer:email", "layer": "M1", "profile": "software", "kind": "Column", "name": "email", "attributes": {}},
  160. ]
  161. raw = json.dumps({
  162. "business_objects": [{"name": "Customer", "description": "客户", "asset_ids": ["m1:table:default:customer"]}],
  163. "owners": [{"asset_id": "m1:table:default:customer", "role": "CustomerDataOwner", "confidence": 0.8}],
  164. "classifications": [{"asset_id": "m1:column:default:customer:email", "classification": "confidential", "confidence": 0.8}],
  165. "quality_rules": [{"asset_id": "m1:column:default:customer:email", "rule": "邮箱格式校验", "expression": "email LIKE '%@%'", "confidence": 0.7}],
  166. "issues": [{"asset_ids": ["m1:table:default:customer"], "description": "缺少数据保留策略", "confidence": 0.7}],
  167. }, ensure_ascii=False)
  168. result = validate_semantic_output(raw, {}, catalog, {"model": "LongCat-2.0"})
  169. assert len(result["elements"]) == 4
  170. assert len(result["assertions"]) == 4
  171. assert result["issues"][0]["code"].startswith("LLM_MODEL_RISK_")
  172. assert all(item["review_status"] == "pending" for item in result["assertions"])
  173. def test_longcat_is_a_real_lambdagent_term(monkeypatch):
  174. provider = LongCatProvider(settings())
  175. def fake_chat(_messages):
  176. provider.last_metrics = {
  177. "provider": "longcat", "model": "LongCat-2.0", "prompt_version": PROMPT_VERSION,
  178. "input_tokens": 30, "output_tokens": 20, "latency_ms": 10,
  179. "request_hash": "request", "response_hash": "response",
  180. }
  181. return json.dumps({
  182. "business_objects": [{
  183. "source_asset_id": "m1:table:default:customer", "name": "Customer",
  184. "description": "客户", "domain": "CRM", "confidence": 0.8, "reason": "名称证据",
  185. }],
  186. "owners": [], "classifications": [], "quality_rules": [], "issues": [],
  187. })
  188. monkeypatch.setattr(provider, "chat", fake_chat)
  189. source = normalize_source({
  190. "source_type": "ddl", "source_name": "customer.sql", "environment": "development",
  191. "content": "CREATE TABLE customer (id INTEGER PRIMARY KEY);",
  192. })
  193. context = Context(run_id="test-longcat")
  194. result = LongCatSemanticAgent(settings(), provider).apply(source, context)
  195. assert result["agent"] == "business-semantics-llm"
  196. assert result["llm"]["input_tokens"] == 30
  197. assert result["assertions"][0]["review_status"] == "pending"
  198. assert any(entry.term_name == "longcat-governance-semantics" for entry in context.trace)
  199. def test_semantic_agent_repairs_one_invalid_model_response(monkeypatch):
  200. provider = LongCatProvider(settings())
  201. calls = []
  202. def fake_chat(_messages):
  203. calls.append(1)
  204. provider.last_metrics = {
  205. "provider": "longcat", "model": "LongCat-2.0", "prompt_version": PROMPT_VERSION,
  206. "input_tokens": 10, "output_tokens": 5, "latency_ms": 2,
  207. "request_hash": f"request-{len(calls)}", "response_hash": f"response-{len(calls)}",
  208. }
  209. if len(calls) == 1:
  210. return '{"business_objects": ['
  211. return json.dumps({
  212. "business_objects": [], "owners": [], "classifications": [], "quality_rules": [], "issues": [],
  213. })
  214. monkeypatch.setattr(provider, "chat", fake_chat)
  215. source = normalize_source({
  216. "source_type": "ddl", "source_name": "empty.sql", "content": "CREATE TABLE sample (id INTEGER);",
  217. })
  218. result = LongCatSemanticAgent(settings(), provider).apply(source, Context(run_id="repair-test"))
  219. assert len(calls) == 2
  220. assert result["llm"]["attempts"] == 2
  221. assert result["llm"]["input_tokens"] == 20
  222. assert result["llm"]["output_tokens"] == 10
  223. def test_truncated_model_output_keeps_only_complete_semantic_arrays():
  224. catalog = [{
  225. "local_id": "m1:table:default:customer", "layer": "M1", "profile": "software",
  226. "kind": "Table", "name": "customer", "attributes": {},
  227. }]
  228. raw = (
  229. '{"business_objects":[],"owners":[{"target_asset_id":"m1:table:default:customer",'
  230. '"owner_role":"CustomerDataOwner","confidence":0.8,"reason":"客户域"}],'
  231. '"classifications":[],"quality_rules":[{"target_asset_id":"m1:table:default:customer"'
  232. )
  233. result = validate_semantic_output(raw, {}, catalog, {"model": "LongCat-2.0"})
  234. assert result["llm"]["partial_output_recovered"] is True
  235. assert len(result["assertions"]) == 1
  236. assert result["assertions"][0]["predicate"] == "OWNED_BY"
  237. def test_llm_run_persists_model_evidence_and_audit_metrics(monkeypatch):
  238. monkeypatch.setenv("LONGCAT_API_KEY", "test-key")
  239. monkeypatch.setenv("ONTOREFACTOR_LLM_MODE", "auto")
  240. def fake_chat(provider, _messages):
  241. provider.last_metrics = {
  242. "provider": "longcat", "model": "LongCat-2.0", "prompt_version": PROMPT_VERSION,
  243. "input_tokens": 40, "output_tokens": 25, "latency_ms": 12.5,
  244. "request_hash": "request-hash", "response_hash": "response-hash",
  245. }
  246. return json.dumps({
  247. "business_objects": [{
  248. "source_asset_id": "m1:table:default:customer", "name": "Customer",
  249. "description": "客户主数据", "domain": "CRM", "confidence": 0.8, "reason": "表结构证据",
  250. }],
  251. "owners": [], "classifications": [], "quality_rules": [], "issues": [],
  252. })
  253. monkeypatch.setattr(LongCatProvider, "chat", fake_chat)
  254. db = Database("sqlite:///:memory:")
  255. project = create_project(db, "tenant-a", {"name": "LLM", "namespace": "urn:test:llm"})
  256. result = execute_governance_agent(db, "tenant-a", project["id"], {
  257. "source_type": "ddl", "source_name": "customer.sql", "semantic_mode": "llm",
  258. "content": "CREATE TABLE customer (id INTEGER PRIMARY KEY);",
  259. })
  260. assert result["semantic_mode"] == "llm"
  261. assert result["llm"]["input_tokens"] == 40
  262. assert any(item["kind"] == "ModelInference" for item in list_evidence(db, "tenant-a", project["id"]))
  263. inferred = [item for item in list_assertions(db, "tenant-a", project["id"]) if item["generated_by"].startswith("longcat:")]
  264. assert len(inferred) == 1
  265. assert inferred[0]["review_status"] == "pending"
  266. run = db.fetchone("SELECT * FROM governance_agent_runs WHERE id=?", (result["run_id"],))
  267. assert run["llm_model"] == "LongCat-2.0"
  268. assert run["prompt_version"] == PROMPT_VERSION
  269. assert run["input_tokens"] == 40
  270. assert run["output_tokens"] == 25
  271. assert run["llm_request_hash"] == "request-hash"
  272. assert run["llm_response_hash"] == "response-hash"
  273. db.close()