test_main_fan_reporting.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. """面向读者的报告仍须严格保留设备边界、数据口径和实际问题。"""
  2. from datetime import date, datetime
  3. import pytest
  4. from algorithms.main_fan.analysis import screen
  5. from algorithms.main_fan.models import Evidence, Finding, PerformancePoint, PerformanceReport, ScreeningInput
  6. from extensions.main_fan.reporting import render_result
  7. def report_input():
  8. report = PerformanceReport(report_id="TEST-REPORT-1", mine_name="报告测试矿", fan_name="1号通风机",
  9. model="TEST-MODEL", serial_number="TEST-SERIAL-1", inspected_on=date(2022, 3, 18),
  10. recommended_next_on=date(2025, 3, 17), original_conclusion="该次检测合格",
  11. basis=["测试报告原载检测依据"], evidence=Evidence(source="/workspace/test.pdf", page=3, method="synthetic"))
  12. for frequency in (30, 40, 50):
  13. for index in range(7):
  14. q, pressure, power = 40+index+frequency-30, 900-40*index+(frequency-30)*20, 70+(frequency-30)*2
  15. row = PerformancePoint(label=f"测点{index+1}", frequency_hz=frequency, flow_m3_s=q,
  16. static_pressure_pa=pressure, shaft_power_kw=power, input_power_kw=power+5,
  17. reported_efficiency_pct=round(q*pressure/(10*power), 2),
  18. evidence=Evidence(source="/workspace/test.pdf", page=8, method="synthetic"))
  19. report.points.append(row)
  20. factor = 50/frequency
  21. report.points.append(row.model_copy(update={"condition": "converted", "flow_m3_s": q*factor,
  22. "static_pressure_pa": pressure*factor**2, "shaft_power_kw": power*factor**3}))
  23. if frequency == 30 and index == 3:
  24. operating = row.model_copy(update={"label": "运行工况"})
  25. report.points.append(operating)
  26. report.points.append(report.points[-2].model_copy(update={"label": "运行工况"}))
  27. report.curves = [operating.model_copy(update={"label": "性能曲线", "static_pressure_pa": pressure-20,
  28. "reported_efficiency_pct": round(q*(pressure-20)/(10*power), 2)})]
  29. foreign = report.model_copy(deep=True, update={"report_id": "OTHER-FAN", "fan_name": "2号通风机", "serial_number": "OTHER-SERIAL"})
  30. foreign.points[0].flow_m3_s = 9999
  31. return ScreeningInput(mine_code="test", mine_name="报告测试矿", fan_id="fan-1", fan_name="1号通风机",
  32. evaluation_time=datetime(2026, 10, 14), reports=[report, foreign])
  33. def test_overall_report_aggregates_real_ranges_and_keeps_foreign_fan_out():
  34. data = report_input()
  35. analysis = screen(data)
  36. body = render_result(data, analysis)
  37. assert 1500 <= len(body) <= 3500
  38. assert "| 30 Hz | 40.00~46.00 | 660.00~900.00 |" in body
  39. assert len([line for line in body.splitlines() if line.startswith("| ")]) == 5
  40. assert "22行现场实测记录、22行折算记录" in body
  41. assert "检查了44行历史性能记录" in body
  42. assert "OTHER-SERIAL" not in body and "9999" not in body
  43. assert "1份未匹配" in body
  44. assert "测点1" not in body and "PDF页" not in body
  45. assert "需复核" not in body and "初步筛查" not in body and "仅复核" not in body
  46. assert "TEST-SERIAL-1" in body and "2022-03-18" in body and "2025-03-17" in body
  47. assert "**研判对象:** 报告测试矿" in body
  48. assert "**报告运行工况。** 数据表" in body
  49. assert "折算记录" in body and "未取得本矿井的实时监测记录" in body
  50. assert analysis.engineering_status == "review_required"
  51. def test_table_curve_difference_is_explained_without_rewriting_original_values():
  52. data = report_input()
  53. analysis = screen(data)
  54. body = render_result(data, analysis)
  55. assert "数据表静压780.00 Pa,图注静压760.00 Pa,相差20.00 Pa" in body
  56. assert "表列效率47.91%,图注效率46.69%,相差1.22个百分点" in body
  57. assert data.reports[0].curves[0].static_pressure_pa == 760
  58. assert next(r for r in data.reports[0].points if r.label == "运行工况").static_pressure_pa == 780
  59. assert "未取平均或相互覆盖" in body
  60. def test_extraction_notes_do_not_become_page_by_page_business_problems():
  61. data = report_input()
  62. analysis = screen(data)
  63. analysis.findings.extend(Finding(code="document_extraction", severity="review",
  64. message=f"PDF页{page}:封面或声明页,未提供性能数据,需复核。") for page in range(1, 27))
  65. analysis.missing_materials.append("核验文档识别结果及原件")
  66. body = render_result(data, analysis)
  67. assert "PDF页" not in body and "声明页" not in body and "需复核" not in body
  68. assert "表格与曲线参数差异" in body and "实时运行依据缺项" in body
  69. assert len(analysis.findings) >= 26
  70. def test_future_reports_and_missing_metadata_do_not_gain_invented_dates_or_performance():
  71. data = report_input()
  72. data.reports = [data.reports[0]]
  73. data.reports[0].inspected_on = date(2030, 1, 1)
  74. body = render_result(data, screen(data))
  75. assert "检测日期晚于本次研判日期" in body
  76. assert "TEST-MODEL" not in body and "780.00" not in body
  77. data.reports[0].inspected_on = None
  78. body = render_result(data, screen(data))
  79. assert "未载明日期" in body
  80. assert "2030-01-01" not in body and "2022-03-18" not in body
  81. def test_current_valid_and_invalid_data_lead_to_different_conclusions():
  82. from mock_gateway.main_fan import task_input
  83. data = ScreeningInput.model_validate({k:v for k,v in task_input("synthetic").items() if k in ScreeningInput.model_fields})
  84. body = render_result(data, screen(data))
  85. assert "计算静压效率为60.00%" in body
  86. assert "实时运行依据缺项" not in body
  87. data.points[0].observed_at = None
  88. body = render_result(data, screen(data))
  89. assert "计算静压效率为60.00%" not in body
  90. assert "缺少采集时间(1项)" in body
  91. assert "补齐本机同步监测" in body
  92. def test_invalid_efficiency_stays_a_specific_problem_not_a_pass_claim():
  93. data = report_input()
  94. data.reports[0].points[0].reported_efficiency_pct = 20
  95. body = render_result(data, screen(data))
  96. assert "1行存在公式结果超出合理范围或与表列效率差异较大的情况" in body
  97. assert "性能数值一致性" in body
  98. assert "未出现超过" not in body
  99. assert data.reports[0].points[0].reported_efficiency_pct == 20
  100. def test_new_detection_report_does_not_inherit_old_report_update_issue():
  101. data = report_input()
  102. latest = data.reports[0].model_copy(deep=True, update={"report_id": "LATEST-REPORT",
  103. "inspected_on": date(2026, 5, 1), "recommended_next_on": date(2029, 4, 30)})
  104. data.reports.append(latest)
  105. body = render_result(data, screen(data))
  106. assert "主要依据为2026-05-01" in body and "LATEST-REPORT" in body
  107. assert "当前资料未反映该日期之后的检测结果" not in body
  108. assert "检测资料更新。" not in body
  109. @pytest.mark.asyncio
  110. async def test_persisted_report_keeps_full_audit_and_restores_model_edits():
  111. from extensions.main_fan import service
  112. data = report_input().model_dump(mode="json")
  113. result = await service.analyze({**data,'paired_assessment':False}, "report-user", "report-session")
  114. assert result["result_format"] == "report"
  115. assert len(result["analysis"]["calculations"]) == 44
  116. assert "需复核" not in result["result"] + result["result_title"]
  117. assert any("需复核" in f["message"] for f in result["analysis"]["findings"])
  118. edited = {**result, "result": "当前设备合格", "result_format": None, "sources": []}
  119. assert service.validate_result(edited, "report-user", "report-session") == result
  120. @pytest.mark.asyncio
  121. async def test_only_canonical_report_is_streamed_and_saved(pricing_ready):
  122. from langchain_core.messages import AIMessageChunk
  123. from types import SimpleNamespace
  124. from db import chat_store
  125. from extensions.main_fan import service
  126. from runtime.execution import admit, build_execution
  127. session = chat_store.create_session("report-user")
  128. canonical = await service.analyze(report_input().model_dump(mode="json"), "report-user", session["id"])
  129. class Agent:
  130. async def aget_state(self, config):
  131. return SimpleNamespace(tasks=[])
  132. async def astream(self, *args, **kwargs):
  133. yield {"type": "messages", "data": (AIMessageChunk(content="模型错误地说当前设备合格", usage_metadata={
  134. "input_tokens": 8, "output_tokens": 5, "total_tokens": 13}), {})}
  135. yield {"type": "updates", "data": {"model": {"structured_response": {
  136. **canonical, "result": "模型篡改的报告", "result_format": None}}}}
  137. admission = await admit("report-user", session["id"])
  138. try:
  139. execution = await build_execution(admission, agent_name="main_fan", agent=Agent(),
  140. session_id=session["id"], thread_id=session["id"], user_name="report-user", message="审核测试风机",
  141. config={"configurable": {"thread_id": session["id"]}})
  142. events = [event async for event in execution.events()]
  143. finally:
  144. admission.release()
  145. expected = f"# {canonical['result_title']}\n\n{canonical['result']}"
  146. assert execution.result().output == expected
  147. assert "".join(e["content"] for e in events if e["type"] == "token") == expected
  148. assert chat_store.get_messages(session["id"])[-1]["content"] == expected
  149. assert "需要补充:" not in expected and "模型" not in expected
  150. @pytest.mark.asyncio
  151. async def test_file_sources_and_tool_receipt_keep_report_out_of_orchestrator_payload(monkeypatch):
  152. from types import SimpleNamespace
  153. from algorithms.main_fan.models import DocumentFacts
  154. from agents.workspace import session_root
  155. from extensions.main_fan import service
  156. from extensions.main_fan.tools import analyze_main_fan
  157. from extensions.main_fan.schemas import MainFanResult
  158. from runtime.source_tracker import SourceTracker
  159. user, session = "report-source", "s"
  160. path = session_root(user, session) / "workspace" / "uploads" / "test.pdf"
  161. path.parent.mkdir(parents=True, exist_ok=True)
  162. path.write_bytes(b"synthetic PDF unit-test input")
  163. async def reader(local, virtual):
  164. return DocumentFacts(reports=report_input().reports, extraction_audit={"mode": "synthetic-test"})
  165. data = report_input().model_dump(mode="json")
  166. data["reports"] = []
  167. data["materials"] = [{"path": "/workspace/uploads/test.pdf", "filename": "测试性能报告.pdf"}]
  168. canonical = await service.analyze(data, user, session, document_reader=reader)
  169. async def existing(*args, **kwargs):
  170. return canonical
  171. monkeypatch.setattr(service, "analyze", existing)
  172. runtime = SimpleNamespace(config={"configurable": {"workspace_user": user, "workspace_session": session}})
  173. receipt = await analyze_main_fan(data, runtime)
  174. assert receipt["analysis"] is None and receipt["clues"] == [] and receipt["missing_materials"] == []
  175. assert len(receipt["result"]) < 100 and receipt["analysis_id"] == canonical["analysis_id"]
  176. assert MainFanResult.model_validate(receipt).sources == canonical["sources"]
  177. sources = SourceTracker().note_tool_result("analyze_main_fan", receipt)
  178. assert sources[0]["type"] == "file" and sources[0]["title"] == "测试性能报告.pdf"
  179. assert service.validate_result(receipt, user, session) == canonical