test_result_platform.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. """结果平台独立性、业务编码、固定报文重试和 SQLite 幂等接收。"""
  2. from datetime import date, datetime
  3. import json
  4. import httpx
  5. import pytest
  6. from algorithms.main_fan.models import Evidence, PerformancePoint, PerformanceReport
  7. from db import business_store as store, db_conn
  8. from extensions.main_fan import service
  9. from extensions.main_fan.result_output import report_text
  10. from extensions.result_platform.client import ResultPlatformClient, ResultPlatformError
  11. from extensions.result_platform.settings import ResultPlatformSettings
  12. from mock_gateway.operating_data import load_operating_data
  13. from mock_result_platform.app import LOCAL_TOKEN, ResultStore, create_app
  14. from runtime.business_tasks import BusinessTaskController, submit
  15. from runtime.engine import ExecutionResult
  16. from runtime.result_models import PlatformResultPayload
  17. from pydantic import ValidationError
  18. def complete_data(*, after_flow=None, angle=35):
  19. # 完整的测试输入;不依赖外部模型、原件缓存或既有研判结论。
  20. incoming = load_operating_data(db_conn.config.ROOT / "examples/main_fan")
  21. data = dict(mine_code="mock-zy", mine_name="陕西众源煤业集团有限公司",
  22. fan_id="mock-report-fan-1", fan_name="1号通风机", fan_pair=["mock-report-fan-1", "mock-report-fan-2"],
  23. evaluation_time="2026-10-14T11:00:00+08:00", use_gateway=False,
  24. **{key: incoming[key] for key in ("devices", "fan_state_records", "operating_series")})
  25. for device in data["devices"]:
  26. device.update(fan_role="standby" if device["device_code"].endswith("-1") else "in_service",
  27. availability_status="available", observed_at=data["evaluation_time"])
  28. reports = []
  29. for number in (1, 2):
  30. evidence = Evidence(source=f"/workspace/fan-{number}.pdf", method="synthetic", page=1)
  31. points = [PerformancePoint(label="运行工况" if index == 1 else f"测点{index}",
  32. frequency_hz=50, flow_m3_s=q, static_pressure_pa=500, shaft_power_kw=80,
  33. reported_efficiency_pct=q*500/800, evidence=evidence) for index, q in enumerate((40, 50, 60, 70))]
  34. reports.append(PerformanceReport(report_id=f"R-{number}", mine_code=data["mine_code"], mine_name=data["mine_name"],
  35. fan_name=f"{number}号通风机", model="FBCDZ-No24", rated_power_raw="2×185kW", blade_angle_deg=angle,
  36. inspected_on=date(2026, 10, 14), evidence=evidence, points=points).model_dump(mode="json"))
  37. data["reports"] = reports
  38. if after_flow is not None:
  39. for series in data["operating_series"]:
  40. if series["fan_id"].endswith("-2"):
  41. for point in series["samples"]:
  42. if point["value"] > 3000:
  43. point["value"] = after_flow
  44. return data
  45. def settings(url="http://results.test/api/judgments", **kwargs):
  46. return ResultPlatformSettings(url=url, token=LOCAL_TOKEN, **kwargs)
  47. async def result_payload():
  48. result = await service.analyze(complete_data(), "test", "session")
  49. return result["platform_payload"]
  50. @pytest.mark.asyncio
  51. async def test_complete_case_emits_the_agreed_normal_schema_and_whole_report():
  52. result = await service.analyze(complete_data(), "normal", "session")
  53. payload = result["platform_payload"]
  54. assert set(payload) == {"agentCode", "agentName", "taskId", "checkedAt", "mineCode", "mineName", "objectDetail", "dataDetail", "judgment"}
  55. assert set(payload["judgment"]) == {"resultCode", "result", "reason", "evidenceLink"}
  56. assert payload["judgment"]["resultCode"] == "NORMAL"
  57. assert "NORMAL" in result["result"]
  58. assert all(name in payload["objectDetail"] for name in ("1号通风机", "2号通风机"))
  59. assert "6分钟" in payload["judgment"]["result"] and "5.00%" in payload["judgment"]["result"]
  60. assert "3610" in payload["dataDetail"] and "3800" in payload["dataDetail"]
  61. assert payload["judgment"]["reason"] == report_text(result["result"])
  62. assert "data:image" not in payload["judgment"]["reason"]
  63. assert payload["judgment"]["evidenceLink"] == ""
  64. assert all(isinstance(value, str) for key, value in payload.items() if key != "judgment")
  65. assert all(isinstance(value, str) for value in payload["judgment"].values())
  66. restored = service.validate_result({**result, "platform_payload": {**payload, "judgment": {**payload["judgment"], "resultCode": "ABNORMAL"}}}, "normal", "session")
  67. assert restored == result
  68. @pytest.mark.asyncio
  69. async def test_four_codes_are_the_entire_public_contract_and_leaf_values_are_strings():
  70. payload = await result_payload()
  71. for code in ("NORMAL", "WARNING", "ABNORMAL", "UNDETERMINED"):
  72. assert PlatformResultPayload.model_validate({**payload, "judgment": {**payload["judgment"], "resultCode": code}}).judgment.resultCode == code
  73. for code in ("PARTIAL", "EXEMPTED", "Warning"):
  74. with pytest.raises(ValidationError):
  75. PlatformResultPayload.model_validate({**payload, "judgment": {**payload["judgment"], "resultCode": code}})
  76. with pytest.raises(ValidationError):
  77. PlatformResultPayload.model_validate({**payload, "mineCode": 123})
  78. @pytest.mark.asyncio
  79. async def test_start_does_not_register_or_subscribe_to_data_gateway():
  80. class DataOnlyGateway:
  81. def __getattr__(self, name):
  82. raise AssertionError(f"启动时不应调用取数网关:{name}")
  83. controller = BusinessTaskController(DataOnlyGateway())
  84. await controller.start()
  85. try:
  86. assert not any(task.get_name().startswith("business-gateway") for task in controller.tasks)
  87. finally:
  88. await controller.close()
  89. @pytest.mark.asyncio
  90. @pytest.mark.parametrize("change,code", [("no_angle", "NORMAL"), ("power", "WARNING"),
  91. ("flow", "ABNORMAL"), ("flow_warning", "WARNING"), ("severe_boundary", "ABNORMAL"), ("boundary", "NORMAL")])
  92. async def test_code_comes_from_actual_assessment_and_preserves_inclusive_boundaries(change, code):
  93. data = complete_data()
  94. if change == "no_angle":
  95. for report in data["reports"]:
  96. report["blade_angle_deg"] = None
  97. elif change == "power":
  98. data["reports"][1]["rated_power_raw"] = "2×200kW"
  99. elif change in {"flow", "flow_warning", "severe_boundary", "boundary"}:
  100. data = complete_data(after_flow={"flow":3040, "flow_warning":3382, "severe_boundary":3230, "boundary":3420}[change])
  101. result = await service.analyze(data, "code", "session")
  102. assert result["platform_payload"]["judgment"]["resultCode"] == code
  103. assert code in result["result"]
  104. assert "PARTIAL" not in json.dumps(result["platform_payload"])
  105. assert "EXEMPTED" not in json.dumps(result["platform_payload"])
  106. @pytest.mark.asyncio
  107. async def test_three_passes_are_normal_despite_unknown_metadata_ocr_and_table_graph_difference(monkeypatch):
  108. data = complete_data(angle=None)
  109. for device in data["devices"]:
  110. device.update(fan_role="unknown", availability_status="unknown", observed_at=None)
  111. point = data["reports"][0]["points"][1]
  112. data["reports"][0]["curves"] = [{**point, "label":"性能曲线", "static_pressure_pa":480,
  113. "reported_efficiency_pct":30}]
  114. original = service.parse_fan_evidence_documents
  115. async def with_ocr_notes(*args, **kwargs):
  116. parsed = await original(*args, **kwargs)
  117. parsed[1].extend(f"PDF页{page}:封面或声明页,未载明叶片角度。" for page in range(1,27))
  118. return parsed
  119. monkeypatch.setattr(service, "parse_fan_evidence_documents", with_ocr_notes)
  120. result = await service.analyze(data, "three-pass", "session")
  121. assert result["assessment"]["decision"]["items"] == dict(parameters="pass", switch_interval="pass", flow_change="pass")
  122. assert result["assessment"]["decision"]["result_code"] == "NORMAL"
  123. assert result["platform_payload"]["judgment"]["resultCode"] == "NORMAL"
  124. assert any(f["code"] == "document_extraction" for f in result["analysis"]["findings"])
  125. assert any(f["code"] == "curve_table_disagreement" for f in result["analysis"]["findings"])
  126. assert "检测资料一致性事项" in result["result"] and "相差20.00 Pa" in result["result"]
  127. assert "PDF页" not in result["result"] and "需复核" not in result["result"]
  128. @pytest.mark.asyncio
  129. async def test_supplementary_curve_and_pressure_failures_cannot_veto_three_passes():
  130. data = complete_data()
  131. for point in data["reports"][1]["points"]:
  132. point.update(static_pressure_pa=900, reported_efficiency_pct=point["flow_m3_s"]*900/800)
  133. for series in list(data["operating_series"]):
  134. data["operating_series"].append({**series, "metric":"static_pressure", "unit":"Pa",
  135. "sensor_id":series["sensor_id"]+"-pressure", "samples":[{**p, "value":500 if series["fan_id"].endswith("-1") else 1000} for p in series["samples"]]})
  136. result = await service.analyze(data, "supplementary", "session")
  137. assessment = result["assessment"]
  138. assert assessment["performance_comparison"]["status"] == "fail"
  139. assert assessment["switch_comparison"]["status"] == "fail"
  140. assert assessment["switch_comparison"]["events"][0]["checks"] == dict(switch_interval="pass", flow_change="pass")
  141. assert result["platform_payload"]["judgment"]["resultCode"] == "NORMAL"
  142. @pytest.mark.asyncio
  143. @pytest.mark.parametrize("change", ["other_parameter", "long_interval"])
  144. async def test_clear_minor_issue_is_warning_instead_of_abnormal(change):
  145. data = complete_data()
  146. if change == "other_parameter":
  147. for number, report in enumerate(data["reports"]):
  148. report["motor_model"] = f"MOTOR-{number}"
  149. else:
  150. start = datetime.fromisoformat("2026-10-14T10:11:00+08:00")
  151. for record in data["fan_state_records"]:
  152. if record["fan_id"].endswith("-2"):
  153. record["state"] = "running" if datetime.fromisoformat(record["observed_at"]) >= start else "stopped"
  154. result = await service.analyze(data, "minor-issue", "session")
  155. assert result["platform_payload"]["judgment"]["resultCode"] == "WARNING"
  156. assert result["assessment"]["decision"]["severe_findings"] == []
  157. @pytest.mark.asyncio
  158. @pytest.mark.parametrize("change", ["unconfirmed", "missing", "conflicting", "missing_parameters"])
  159. async def test_invalid_core_data_is_undetermined_and_not_assumed_to_pass(change):
  160. data = complete_data()
  161. if change == "unconfirmed":
  162. data["operating_series"][0]["mapping_confirmed"] = False
  163. elif change == "missing":
  164. data["operating_series"][0]["samples"] = []
  165. elif change == "conflicting":
  166. series = data["operating_series"][0]
  167. series["samples"].extend({**p,"value":p["value"]+30} for p in list(series["samples"]))
  168. else:
  169. for report in data["reports"]:
  170. report["model"] = ""
  171. result = await service.analyze(data, "bad-core", "session")
  172. assert result["platform_payload"]["judgment"]["resultCode"] == "UNDETERMINED"
  173. assert "insufficient" in result["assessment"]["decision"]["items"].values()
  174. @pytest.mark.asyncio
  175. async def test_partial_scope_is_not_normal_and_does_not_list_absent_projects():
  176. data = complete_data()
  177. data.update(fan_state_records=[], operating_series=[], devices=[])
  178. result = await service.analyze(data, "scope", "session")
  179. payload = result["platform_payload"]
  180. assert payload["judgment"]["resultCode"] == "UNDETERMINED"
  181. assert "切换" not in payload["judgment"]["reason"]
  182. assert "备用状态" not in payload["dataDetail"]
  183. assert "开停" not in payload["dataDetail"]
  184. def test_report_tables_are_independent_even_after_operating_point_paragraphs():
  185. text = report_text("## 比较\n| 设备 | 风量 |\n| --- | ---: |\n| 1号 | 3800 |\n\n说明\n\n| 指标 | 偏差 |\n| --- | --- |\n| 静压 | 2% |\n![图](data:image/svg+xml;base64,abc)")
  186. assert "设备:1号;风量:3800" in text and "指标:静压;偏差:2%" in text
  187. assert "base64" not in text
  188. @pytest.mark.asyncio
  189. async def test_multiple_frequency_operating_points_keep_headers_and_both_fans_in_export():
  190. data = complete_data()
  191. for report in data["reports"]:
  192. points = list(report["points"])
  193. report["points"] = [{**point, "frequency_hz": frequency} for frequency in (30, 40, 50) for point in points]
  194. result = await service.analyze(data, "multi-frequency", "session")
  195. reason = result["platform_payload"]["judgment"]["reason"]
  196. for frequency in (30, 40, 50):
  197. for number in (1, 2):
  198. assert f"频率与设备:{frequency} Hz {number}号通风机;风量" in reason
  199. @pytest.mark.asyncio
  200. async def test_local_result_platform_persists_deduplicates_and_rejects_changed_task(tmp_path):
  201. payload = await result_payload()
  202. storage = ResultStore(tmp_path / "received.sqlite")
  203. async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(storage)), base_url="http://results.test") as http:
  204. assert (await http.post("/api/judgments", json=payload)).status_code == 401
  205. headers = {"Authorization": f"Bearer {LOCAL_TOKEN}", "Idempotency-Key": payload["taskId"]}
  206. first = await http.post("/api/judgments", headers=headers, json=payload)
  207. again = await http.post("/api/judgments", headers=headers, json=payload)
  208. assert first.status_code == 200 and first.json() == again.json()
  209. altered = {**payload, "dataDetail": "changed"}
  210. assert (await http.post("/api/judgments", headers=headers, json=altered)).status_code == 409
  211. assert storage.count() == 1
  212. invalid = {**payload, "judgment": {**payload["judgment"], "resultCode": "PARTIAL"}}
  213. assert (await http.post("/api/judgments", headers=headers, json=invalid)).status_code == 422
  214. reopened = ResultStore(storage.path)
  215. assert reopened.count() == 1 and reopened.rows()[0]["payload"] == payload
  216. @pytest.mark.asyncio
  217. async def test_retries_read_frozen_payload_after_controller_restart_and_do_not_touch_gateway(tmp_path):
  218. storage = ResultStore(tmp_path / "received.sqlite")
  219. app = create_app(storage, fail_first=1)
  220. publisher = ResultPlatformClient(settings(), transport=httpx.ASGITransport(app=app))
  221. calls = []
  222. class DataGateway:
  223. def __getattr__(self, name):
  224. raise AssertionError(f"本地资料研判和结果推送不应调用网关:{name}")
  225. async def executor(**kwargs):
  226. calls.append(kwargs)
  227. options = kwargs["graph_config"]["configurable"]
  228. result = await service.analyze(options["business_task_input"], kwargs["user_name"], kwargs["session_id"], job_id=options["business_task_id"])
  229. result["platform_payload"]["judgment"]["resultCode"] = "ABNORMAL"
  230. return ExecutionResult("run", kwargs["session_id"], "success", "", "", {}, [], [], result)
  231. controller = BusinessTaskController(DataGateway(), publisher=publisher, executor=executor)
  232. try:
  233. job, _ = store.enqueue("main_fan", "retry", complete_data(), [], publish=True)
  234. await controller.run_job(store.claim("main_fan"))
  235. completed = store.get(job["id"])
  236. before = completed["push_payload"]
  237. assert completed["push_status"] == "pending"
  238. assert json.loads(before)["judgment"]["resultCode"] == "NORMAL"
  239. await controller.publish_job(completed)
  240. failed = store.get(job["id"])
  241. assert failed["push_status"] == "pending" and failed["push_attempts"] == 1
  242. restored = BusinessTaskController(publisher=publisher, executor=executor)
  243. await restored.publish_job(failed)
  244. saved = store.get(job["id"])
  245. assert saved["push_status"] == "pushed"
  246. assert saved["push_payload"] == before
  247. assert json.loads(saved["receipt_json"])["data"]["taskId"] == job["id"]
  248. assert len(calls) == 1 and storage.count() == 1
  249. assert storage.rows()[0]["payload"] == json.loads(before)
  250. finally:
  251. await publisher.close()
  252. @pytest.mark.asyncio
  253. async def test_automatic_chat_delivery_only_enqueues_once_and_no_model_is_reexecuted(monkeypatch, tmp_path):
  254. monkeypatch.setenv("RESULT_PLATFORM_ENABLED", "1")
  255. monkeypatch.setenv("RESULT_PLATFORM_URL", "http://results.test/api/judgments")
  256. monkeypatch.setenv("RESULT_PLATFORM_TOKEN", LOCAL_TOKEN)
  257. monkeypatch.setenv("MAIN_FAN_AUTO_PUBLISH", "1")
  258. result = await service.analyze(complete_data(), "chat", "session")
  259. payload = result["platform_payload"]
  260. job = store.get(payload["taskId"])
  261. assert job["source"] == "chat" and job["push_status"] == "pending"
  262. assert job["status"] == "completed"
  263. assert store.claim("main_fan") is None
  264. store.record_completed_result("main_fan", "chat", "session", {}, result, "http://results.test/api/judgments")
  265. assert len(store.list_jobs("main_fan", "chat")) == 1
  266. publisher = ResultPlatformClient(settings(), transport=httpx.ASGITransport(app=create_app(ResultStore(tmp_path / "received.sqlite"))))
  267. try:
  268. await BusinessTaskController(publisher=publisher).publish_job(job)
  269. assert store.get(job["id"])["push_status"] == "pushed"
  270. finally:
  271. await publisher.close()
  272. @pytest.mark.asyncio
  273. async def test_changed_destination_blocks_old_pending_payload(tmp_path):
  274. result = await service.analyze(complete_data(), "test", "session")
  275. payload = result["platform_payload"]
  276. job = store.record_completed_result("main_fan", "test", "session", {}, result, "http://old.test/api/judgments")
  277. def forbidden(request):
  278. raise AssertionError("配置变化后不能自动向新地址发送历史结果")
  279. publisher = ResultPlatformClient(settings(), transport=httpx.MockTransport(forbidden))
  280. try:
  281. await BusinessTaskController(publisher=publisher).publish_job(job)
  282. assert store.get(job["id"])["push_status"] == "failed"
  283. assert json.loads(store.get(job["id"])["push_payload"])["taskId"] == payload["taskId"]
  284. finally:
  285. await publisher.close()
  286. def test_publish_configuration_is_independent_from_data_gateway(monkeypatch):
  287. monkeypatch.setenv("RESULT_PLATFORM_ENABLED", "1")
  288. monkeypatch.setenv("SHANXI_GATEWAY_ENABLED", "0")
  289. job = submit("main_fan", complete_data(), publish=True)
  290. assert job["status"] == "queued"
  291. monkeypatch.setenv("RESULT_PLATFORM_ENABLED", "0")
  292. with pytest.raises(ValueError, match="结果接收平台"):
  293. submit("main_fan", complete_data(), publish=True)
  294. @pytest.mark.asyncio
  295. async def test_client_checks_business_receipts_and_rate_limit_without_gateway_credentials():
  296. payload = await result_payload()
  297. replies = [httpx.Response(429, headers={"Retry-After": "120"}),
  298. httpx.Response(200, json={"code": 500, "success": False}),
  299. httpx.Response(200, text="login page"), httpx.Response(302, headers={"Location": "http://elsewhere.test"}),
  300. httpx.Response(200, json={"code": 200, "data": {"taskId": "other"}}), httpx.Response(204)]
  301. def handler(request):
  302. assert request.headers["Authorization"] == f"Bearer {LOCAL_TOKEN}"
  303. assert request.url.host == "results.test"
  304. assert "X-Agent-Code" not in request.headers
  305. return replies.pop(0)
  306. client = ResultPlatformClient(settings(), transport=httpx.MockTransport(handler))
  307. try:
  308. with pytest.raises(ResultPlatformError) as limited:
  309. await client.push(payload)
  310. assert limited.value.retryable and limited.value.retry_after == 120
  311. for _ in range(4):
  312. with pytest.raises(ResultPlatformError):
  313. await client.push(payload)
  314. assert (await client.push(payload))["httpStatus"] == 204
  315. finally:
  316. await client.close()
  317. @pytest.mark.asyncio
  318. async def test_custom_auth_header_and_success_contract():
  319. payload = await result_payload()
  320. def handler(request):
  321. assert request.headers["X-API-Key"] == "result-secret"
  322. assert "Authorization" not in request.headers
  323. return httpx.Response(200, json={"status": {"value": "OK"}})
  324. client = ResultPlatformClient(ResultPlatformSettings(url="http://results.test/save", token="result-secret",
  325. auth_header="X-API-Key", auth_scheme="", success_field="status.value", success_values=("OK",)),
  326. transport=httpx.MockTransport(handler))
  327. try:
  328. assert (await client.push(payload))["status"]["value"] == "OK"
  329. finally:
  330. await client.close()
  331. @pytest.mark.asyncio
  332. async def test_export_and_publish_api_respect_saved_result_and_owner(monkeypatch, tmp_path):
  333. from main import app
  334. from runtime import business_tasks
  335. monkeypatch.setenv("BUSINESS_API_TOKEN", "business-secret")
  336. result = await service.analyze(complete_data(), "owner", "session")
  337. job = store.record_completed_result("main_fan", "owner", "session", {}, result, "http://results.test/api/judgments")
  338. publisher = ResultPlatformClient(settings(), transport=httpx.ASGITransport(app=create_app(ResultStore(tmp_path / "received.sqlite"))))
  339. monkeypatch.setattr(business_tasks, "_controller", BusinessTaskController(publisher=publisher))
  340. base = f"/api/business-agents/main_fan/jobs/{job['id']}"
  341. try:
  342. async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://agent.test") as http:
  343. headers = {"Authorization": "Bearer business-secret"}
  344. assert (await http.get(base + "/result")).status_code == 401
  345. assert (await http.get(base + "/result", headers=headers, params={"user_name": "other"})).status_code == 404
  346. exported = await http.get(base + "/result", headers=headers, params={"user_name": "owner"})
  347. assert exported.json() == result["platform_payload"]
  348. again = await http.post(base + "/publish", headers=headers, params={"user_name": "owner"})
  349. assert again.status_code == 202 and again.json()["push_status"] == "pending"
  350. analysis = await http.get(f"/api/business-agents/main_fan/analyses/{result['analysis_id']}/result", headers=headers,
  351. params={"session_id": "session", "user_name": "owner"})
  352. assert analysis.json() == result["platform_payload"]
  353. finally:
  354. await publisher.close()