test_airflow_tools.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562
  1. """风量研判工具层与真实资料回归测试。
  2. 真实资料(`矿井风量研判资料/`)不入库,缺失时相关用例跳过;合成数据与基准 fixture
  3. 始终可用,保证 CI 与离线环境仍能覆盖解析、核验、比对与报告全链路。
  4. """
  5. from __future__ import annotations
  6. import json
  7. import re
  8. from pathlib import Path
  9. import pytest
  10. import config
  11. from algorithms.airflow_review.engine.findings import FindingFactory
  12. from algorithms.airflow_review.engine.verify import verify_plan
  13. from algorithms.airflow_review.parsers.measurement_report import parse_measurement_report
  14. from algorithms.airflow_review.parsers.plan_preprocess import preprocess_plan_document
  15. from algorithms.airflow_review.rules.loader import load_rules
  16. from extensions.registry import load_extensions
  17. from extensions.vent_airflow import monitoring, tools, workspace_paths
  18. ROOT = Path(__file__).resolve().parents[1]
  19. def raw_materials_dir() -> Path | None:
  20. """真实资料目录:位置由使用者决定,因此可探测或显式指定。
  21. 依次看:AIRFLOW_RAW_DIR(本地便利开关)→ 仓库根 矿井风量研判资料。
  22. 真实资料不入库,缺失时调用方应 skip。
  23. """
  24. import os
  25. candidates = []
  26. override = os.getenv("AIRFLOW_RAW_DIR", "").strip()
  27. if override:
  28. candidates.append(Path(override) if Path(override).is_absolute() else ROOT / override)
  29. candidates.append(ROOT / "矿井风量研判资料")
  30. for candidate in candidates:
  31. if candidate.is_dir() and (any(candidate.glob("*.pdf")) or any(candidate.glob("*.xlsx"))):
  32. return candidate
  33. return None
  34. def _real_file(name: str) -> Path:
  35. directory = raw_materials_dir()
  36. return (directory / name) if directory else (ROOT / name)
  37. REAL_REPORT = _real_file("测风报表20260528 .xlsx")
  38. REAL_PLAN = _real_file("布尔台矿矿井风量分配计划20241201(1).pdf")
  39. TABLE9_FIXTURE = ROOT / "tests" / "fixtures" / "airflow" / "bolitai_plan_table9.json"
  40. @pytest.fixture(autouse=True)
  41. def isolate_outputs(tmp_path, monkeypatch):
  42. """工具产物落到临时目录,避免测试污染仓库。"""
  43. monkeypatch.setattr(config, "AIRFLOW_OUTPUT_DIR", str(tmp_path / "airflow"))
  44. monkeypatch.setattr(config, "AIRFLOW_MONITORING_DIR", str(tmp_path / "monitoring"))
  45. # ---- 扩展与路径 -----------------------------------------------------------
  46. def test_extension_registers_all_tools_and_agent():
  47. catalog = load_extensions()
  48. expected = {
  49. "parse_airflow_measurement_report", "query_monitoring_airflow",
  50. "preprocess_air_plan_document", "validate_plan_extraction",
  51. "verify_and_extract_plan_airflow", "reconcile_airflow_sources",
  52. "compare_airflow_actual_vs_required", "trace_airflow_evidence",
  53. "render_airflow_review_report",
  54. }
  55. assert expected <= set(catalog.tools)
  56. assert "airflow-review-orchestrator" in catalog.agents
  57. agent = catalog.agents["airflow-review-orchestrator"]
  58. assert set(agent.tools) == expected
  59. assert "风量研判" in agent.description
  60. def test_workspace_path_parity_with_agents_layer():
  61. """工具侧的沙箱路径映射必须与 agents.workspace 完全一致,否则线上找不到上传文件。"""
  62. from agents import workspace
  63. for user, session in (("alice", "s-1"), ("矿长", "会话-2")):
  64. assert workspace_paths.session_root(user, session) == workspace.session_root(user, session)
  65. def test_resolve_input_rejects_escape_and_missing(tmp_path):
  66. with pytest.raises(workspace_paths.WorkspacePathError):
  67. workspace_paths.resolve_input("")
  68. with pytest.raises(workspace_paths.WorkspacePathError, match="无法解析|不存在"):
  69. workspace_paths.resolve_input("does/not/exist.xlsx")
  70. # ---- 工具错误路径 ---------------------------------------------------------
  71. def test_tools_return_readable_errors_instead_of_raising():
  72. assert tools.parse_airflow_measurement_report("nope.xlsx")["ok"] is False
  73. result = tools.validate_plan_extraction_tool("{不是 json")
  74. assert result["ok"] is False and "JSON" in result["error"]
  75. result = tools.compare_airflow_actual_vs_required(targets="[]")
  76. assert result["ok"] is False
  77. result = tools.render_airflow_review_report(findings='"string"')
  78. assert result["ok"] is False
  79. result = tools.trace_airflow_evidence(evidence_path="missing.json")
  80. assert result["ok"] is False
  81. # ---- 监控适配器 -----------------------------------------------------------
  82. def test_monitoring_source_missing_returns_insufficient(tmp_path):
  83. result = tools.query_monitoring_airflow(mine_id="M1", target_ids="L1",
  84. start_time="2026-05-01T00:00:00+08:00",
  85. end_time="2026-05-02T00:00:00+08:00")
  86. data = result["data"]
  87. assert data["status"] == "data_insufficient"
  88. assert data["manual_review_required"] is True
  89. assert "不得用测风报表代替" in data["note"]
  90. def test_monitoring_source_aggregates_and_flags_anomalies(tmp_path):
  91. directory = tmp_path / "monitoring"
  92. directory.mkdir()
  93. samples = [{"at": f"2026-05-01T{h:02d}:{m:02d}:00+08:00", "airflow_m3_min": 800}
  94. for h in range(3) for m in range(0, 60, 10)]
  95. samples.append({"at": "2026-05-01T03:00:00+08:00", "airflow_m3_min": 400}) # 突跳
  96. samples.append({"at": "2026-05-01T07:00:00+08:00", "airflow_m3_min": 900}) # 断线后恢复
  97. (directory / "M1.json").write_text(json.dumps({
  98. "series": [{"sensor_id": "S1", "target_id": "L1", "target_type": "workplace",
  99. "interval_minutes": 10, "samples": samples}]
  100. }, ensure_ascii=False), encoding="utf-8")
  101. result = monitoring.LocalFileMonitoringSource(directory).query(
  102. mine_id="M1", target_ids=["L1"], sensor_ids=[],
  103. start_time="2026-05-01T00:00:00+08:00", end_time="2026-05-01T23:59:59+08:00",
  104. aggregation="hour")
  105. series = result["series"][0]
  106. statistics = series["statistics"]
  107. assert statistics["min_m3_min"] == 400
  108. assert statistics["max_m3_min"] == 900
  109. assert statistics["valid_sample_rate"] < 1.0
  110. kinds = {anomaly["kind"] for anomaly in series["anomalies"]}
  111. assert "突跳" in kinds and "断线" in kinds
  112. assert series["sensor_status"] in ("abnormal", "offline")
  113. # ---- 真实资料回归 ---------------------------------------------------------
  114. @pytest.mark.skipif(not REAL_REPORT.is_file(), reason="真实测风报表未提供")
  115. def test_real_measurement_report_totals_match_source():
  116. """真实报表的关键读数必须与原始文件一致(硬编码自 2026-05 旬报表头)。"""
  117. dataset = parse_measurement_report(REAL_REPORT, mine_id="BLT", report_period="2026-05")
  118. assert dataset["mine_total_intake"]["airflow_m3_min"] == 35181
  119. assert dataset["mine_total_return"]["airflow_m3_min"] == 35713
  120. assert dataset["effective_airflow"]["value"] == 32208
  121. assert round(dataset["effective_airflow_rate"]["value"], 2) == 91.55
  122. assert {fan["location_name"]: fan["airflow_m3_min"] for fan in dataset["main_fans"]} == {
  123. "明安木独回风立井总回风": pytest.approx(22512.6801, rel=1e-4),
  124. "松定霍洛回风立井总回风": pytest.approx(13716.1408, rel=1e-4),
  125. }
  126. assert [system["airflow_m3_min"] for system in dataset["return_systems"]] == [22193.0, 13520.0]
  127. # 主扇字段不能混用:运转机号是机组编号,功率另有输入/输出两列
  128. first_fan = dataset["main_fans"][0]
  129. assert first_fan["fan_unit"] == "一号主扇"
  130. assert first_fan["input_power_kw"] == pytest.approx(949.7437, rel=1e-4)
  131. assert first_fan["output_power_kw"] == pytest.approx(469.0142, rel=1e-4)
  132. assert first_fan["blade_angle_and_speed"] == "568/0"
  133. assert "motor_power" not in first_fan
  134. assert dataset["data_quality"]["intake_return_difference_m3_min"] == 532.0
  135. assert dataset["data_quality"]["record_count"] > 80
  136. # 未判定地点类型的测点必须进待复核,不能静默丢弃
  137. assert dataset["data_quality"]["unmapped_location_count"] == len(dataset["pending_review"]) - 0 or \
  138. dataset["data_quality"]["unmapped_location_count"] >= 1
  139. @pytest.mark.skipif(not REAL_PLAN.is_file(), reason="真实配风计划未提供")
  140. def test_real_plan_preprocess_is_faithful():
  141. """预处理产出的表格线索必须能对上计划声称的两套回风系统合计,否则说明文本被吞。"""
  142. document = preprocess_plan_document(REAL_PLAN, mine_id="BLT", month="2024-12")
  143. assert document["status"] == "ok"
  144. assert document["page_count"] == 63
  145. assert document["noise_removed"]["removed_header_lines"] > 50
  146. assert document["noise_removed"]["page_offset"] == 4
  147. sums = sorted(round(table["row_count"] and sum(row["value"] for row in table["rows"]), 2)
  148. for table in document["tables"])
  149. assert 9228.0 in sums # 松定霍洛回风系统
  150. assert 7282.0 in sums # 采掘工作面计划风量
  151. assert any(abs(total - 17853.0) < 0.01 or 12230.0 == total or 5623.0 == total for total in sums)
  152. template = document["extraction_template"]
  153. assert template["rule_version"] == load_rules().rule_version
  154. assert len(template["formula_ids"]["采煤工作面"]) == 5
  155. assert template["target_schema"]["items"]
  156. # ---- 全链路 ---------------------------------------------------------------
  157. def test_verify_real_table9_fixture_is_exact():
  158. plan = json.loads(TABLE9_FIXTURE.read_text(encoding="utf-8"))
  159. result = verify_plan(plan, load_rules(), finding_factory=FindingFactory())
  160. assert result["plan_authoritative"] is True
  161. assert result["plan_calculation_status"] == "manual_review" # 89 个地点缺计算过程
  162. required = {item["target_id"]: item["required_airflow_m3_min"] for item in result["verified_items"]}
  163. assert required == {"R-SDHL": 11073.6, "R-MAMD": 21423.6, "MINE_TOTAL": 32497.2}
  164. assert result["hierarchy"]["single_return_system"] is False
  165. assert result["hierarchy"]["issues"] == []
  166. assert len(result["hierarchy"]["levels"]["workplace"]) == 89
  167. def test_tool_chain_verify_compare_trace_report(tmp_path):
  168. """工具串起来跑:核验真实 fixture → 比对(时间不可对齐)→ 证据链 → 报告。"""
  169. verify_result = tools.verify_and_extract_plan_airflow(str(TABLE9_FIXTURE), mine_id="BLT")
  170. assert verify_result["ok"] is not False
  171. verified = verify_result["data"]
  172. assert verified["plan_authoritative"] is True
  173. assert len(verified["verified_items"]) == 3
  174. dataset = {
  175. "dataset_id": "AIR-DATASET-BLT-2024-12", "report_period": "2024-12",
  176. "mine_total_intake": {"airflow_m3_min": 33000, "evidence_id": "e001"},
  177. "mine_total_return": {"airflow_m3_min": 32500, "evidence_id": "e002"},
  178. "return_systems": [{"location_name": "明安木独回风立井总回风", "airflow_m3_min": 10000,
  179. "evidence_id": "e003"}],
  180. "locations": [], "records": [{"observed_at": "2024-12-15T09:00:00+08:00"}],
  181. "data_quality": {"record_count": 0, "unmapped_location_count": 0},
  182. "evidence": {
  183. "e001": {"evidence_id": "e001", "source_file": "测风报表.xlsx", "source_type": "measurement_report",
  184. "normalized_value": 33000, "unit": "m3/min", "locator": {"sheet": "测风报表", "cell": "C11"},
  185. "confidence": 0.98},
  186. "e002": {"evidence_id": "e002", "source_file": "测风报表.xlsx", "source_type": "measurement_report",
  187. "normalized_value": 32500, "unit": "m3/min", "locator": {"sheet": "测风报表", "cell": "J7"},
  188. "confidence": 0.98},
  189. "e003": {"evidence_id": "e003", "source_file": "测风报表.xlsx", "source_type": "measurement_report",
  190. "normalized_value": 10000, "unit": "m3/min", "locator": {"sheet": "测风报表", "cell": "J5"},
  191. "confidence": 0.98},
  192. },
  193. }
  194. dataset_path = tmp_path / "dataset.json"
  195. dataset_path.write_text(json.dumps(dataset, ensure_ascii=False), encoding="utf-8")
  196. comparison = tools.compare_airflow_actual_vs_required(
  197. dataset_path=str(dataset_path), verification_path=verified["plan_path"], mine_id="BLT")
  198. body = comparison["data"]
  199. # 计划生效窗口是 2024-12,合成数据集也在 2024-12 → 应能对齐并给出结论
  200. statuses = {record["target_name"]: record["comparison_status"] for record in body["comparisons"]}
  201. # 明安木独回风系统实测 10000 < 需风 11073.6 → 回风系统风量不足;矿井总风量正常
  202. assert statuses["明安木独回风立井回风系统"] == "major_risk"
  203. assert statuses["矿井总进风量"] == "normal"
  204. assert all(record["time_alignment_status"] == "aligned" for record in body["comparisons"])
  205. assert body["finding_summary"]["by_finding_type"]["return_system_airflow_shortage"] == 1
  206. trace = tools.trace_airflow_evidence(str(dataset_path), finding_id="F001")
  207. assert trace["data"]["evidence_chain"]
  208. report = tools.render_airflow_review_report(
  209. findings=body["result_path"], mine_id="BLT", period="2024-12",
  210. comparisons=body["result_path"], verified_items=verified["plan_path"],
  211. evidence_path=str(dataset_path), report_format="both")
  212. report_data = report["data"]
  213. assert report_data["report_id"] == "AIR-REVIEW-BLT-2024-12"
  214. assert report_data["finding_count"] >= 1
  215. assert Path(report_data["file_paths"]["md"]).is_file()
  216. text = Path(report_data["file_paths"]["md"]).read_text(encoding="utf-8")
  217. assert "本报告为智能辅助审查结果" in text
  218. assert "## 七、证据索引" in text
  219. if not report_data["unsupported_formats"]:
  220. assert Path(report_data["file_paths"]["docx"]).is_file()
  221. def test_validate_then_verify_round_trip():
  222. """校验工具写出的规范化计划可以直接喂给核验工具。"""
  223. extraction = {
  224. "mine_id": "M1", "month": "2026-05", "plan_id": "P1", "plan_version": "V1",
  225. "approved_at": "2026-04-28",
  226. "effective_window": {"start": "2026-05-01T00:00:00+08:00", "end": "2026-05-31T23:59:59+08:00"},
  227. "items": [
  228. {"target_id": "L1", "target_type": "workplace", "target_name": "22210 综采工作面",
  229. "location_type": "采煤工作面", "parent_return_system_id": "R1",
  230. "planned_required_airflow_m3_min": 1221, "planned_formula": "WF-CFI-01",
  231. "planned_parameters": {"v_cfi": 1.0, "S_cfi": 11.3, "k_chi": 1.2, "k_cli": 1.5,
  232. "section_area_basis": "有效断面积", "q_cgi": 3.4, "k_cgi": 1.6,
  233. "q_cci": 0.5, "k_cci": 1.5, "l_cbi": 5.0, "l_csi": 4.4, "h_cfi": 3.0},
  234. "page": 10},
  235. {"target_id": "R1", "target_type": "return_air_system", "target_name": "唯一回风系统",
  236. "component_workplace_ids": ["L1"], "parent_mine_total_id": "MINE_TOTAL",
  237. "planned_required_airflow_m3_min": 1465.2,
  238. "planned_parameters": {"reserve_coefficient": 1.2}},
  239. {"target_id": "MINE_TOTAL", "target_type": "mine_total", "target_name": "矿井总需风量",
  240. "planned_required_airflow_m3_min": 1465.2, "component_return_system_ids": ["R1"]},
  241. ],
  242. }
  243. validated = tools.validate_plan_extraction_tool(json.dumps(extraction, ensure_ascii=False), mine_id="M1")
  244. assert validated["data"]["status"] in ("ok", "warning")
  245. assert validated["data"]["plan_path"]
  246. verified = tools.verify_and_extract_plan_airflow(validated["data"]["plan_path"], mine_id="M1")
  247. assert verified["data"]["plan_calculation_status"] == "valid"
  248. reuse = {item["target_id"]: item["required_reuse_key"] for item in verified["data"]["verified_items"]}
  249. assert reuse["R1"] == reuse["MINE_TOTAL"] != ""
  250. def test_preprocess_tool_returns_template_and_cleaned_text(tmp_path):
  251. """预处理工具必须走通完整返回路径(含来源标注构造),输入用纯文本以脱离 PDF 依赖。"""
  252. plan_text = "\n".join([
  253. "布尔台煤矿矿井风量分配计划", "1", "一、主要配风地点:",
  254. "42 上205 综放工作面", "1523", "综放",
  255. "42 上208 主运顺槽工作面局扇安设点", "929", "掘进",
  256. "中央水仓变电所", "200", "硐室",
  257. "Q 采 1=60×70%v 采 S 采 K 采高 K 采面长", "=1221m3/min",
  258. ])
  259. source = tmp_path / "plan.txt"
  260. source.write_text(plan_text, encoding="utf-8")
  261. result = tools.preprocess_air_plan_document(str(source), mine_id="M1", month="2026-05")
  262. assert result.get("ok") is True, result
  263. body = result["data"]
  264. assert body["cleaned_text_path"]
  265. assert Path(body["cleaned_text_path"]).is_file()
  266. assert body["extraction_template"]["formula_ids"]["采煤工作面"]
  267. assert result["sources"], "预处理结果必须带来源标注"
  268. assert body["extraction_template"]["target_schema"]["items"]
  269. # ---- 技能包结构 -----------------------------------------------------------
  270. SKILL_DIR = ROOT / "skills" / "airflow-review"
  271. OLD_SKILL_NAMES = (
  272. "airflow-data-preparation", "air-distribution-plan-verification",
  273. "mine-system-airflow-assessment", "workplace-airflow-assessment",
  274. "multi-source-airflow-reconciliation", "airflow-review-reporting",
  275. "airflow-review-orchestrator",
  276. )
  277. def test_skill_is_discovered_by_framework_and_old_ones_are_gone():
  278. """框架只扫 skills/<name>/SKILL.md 一层;合并后应只发现一个风量研判技能。"""
  279. from deepagents.backends import FilesystemBackend
  280. from deepagents.middleware.skills import _list_skills
  281. backend = FilesystemBackend(root_dir=str(ROOT), virtual_mode=True)
  282. listed = _list_skills(backend, "/skills/")
  283. # 不同 deepagents 版本返回 dict 或 SkillMetadata,两种都兼容
  284. skills = {entry["name"] if isinstance(entry, dict) else entry.name: entry for entry in listed}
  285. assert "airflow-review" in skills
  286. assert not (set(OLD_SKILL_NAMES) & set(skills)), f"旧技能目录应已删除:{sorted(set(OLD_SKILL_NAMES) & set(skills))}"
  287. for name in OLD_SKILL_NAMES:
  288. assert not (ROOT / "skills" / name).exists(), f"旧技能目录残留:{name}"
  289. entry = skills["airflow-review"]
  290. path = entry["path"] if isinstance(entry, dict) else entry.path
  291. assert path == "/skills/airflow-review/SKILL.md"
  292. def test_skill_bundle_frontmatter_and_references_resolve():
  293. """SKILL.md 的 name 必须等于目录名;它引用的每一份子文件都必须真实存在。"""
  294. import yaml
  295. content = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
  296. front = yaml.safe_load(content.split("---")[1])
  297. assert front["name"] == SKILL_DIR.name == "airflow-review"
  298. assert len(front["description"]) <= 1024, "description 超过框架上限 1024 字符"
  299. assert "75%" in front["description"] and "配风计划" in front["description"]
  300. referenced = set(re.findall(r"references/[\w./-]+\.md", content))
  301. assert len(referenced) >= 8, f"SKILL.md 应索引全部子文件,实际 {sorted(referenced)}"
  302. for relative in referenced:
  303. assert (SKILL_DIR / relative).is_file(), f"SKILL.md 引用了不存在的文件:{relative}"
  304. # 每份子文件都要能被 SKILL.md 索引到(防止加了文件却没人读)
  305. on_disk = {path.relative_to(SKILL_DIR).as_posix() for path in (SKILL_DIR / "references").rglob("*.md")}
  306. assert on_disk == referenced, f"未被索引的文件:{sorted(on_disk - referenced)}"
  307. def test_contract_doc_matches_registered_tools():
  308. """契约文档与实际注册的工具必须一一对应,避免文档漂移。"""
  309. from extensions.vent_airflow.extension import TOOL_NAMES
  310. doc = (SKILL_DIR / "references" / "contracts" / "tools.md").read_text(encoding="utf-8")
  311. documented = set(re.findall(r"^## \d+\. (\w+)$", doc, flags=re.M))
  312. assert documented == set(TOOL_NAMES), (
  313. f"文档多出:{sorted(documented - set(TOOL_NAMES))};文档缺失:{sorted(set(TOOL_NAMES) - documented)}")
  314. catalog = load_extensions()
  315. for name in TOOL_NAMES:
  316. assert name in catalog.tools, f"文档中声明但未注册:{name}"
  317. def test_disclaimer_matches_renderer_across_skill_and_prompt():
  318. """免责声明在渲染器、技能入口与子 Agent 提示词里必须是同一句话(需求 §2.3 工具 8)。"""
  319. from algorithms.airflow_review.report.render import DISCLAIMER
  320. skill = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
  321. prompt = (ROOT / "extensions" / "vent_airflow" / "prompt.md").read_text(encoding="utf-8")
  322. assert DISCLAIMER in skill, "技能入口未声明免责声明原文"
  323. assert DISCLAIMER in prompt, "子 Agent 提示词未声明免责声明原文"
  324. def test_release_packaging_includes_skill_references():
  325. """打包白名单必须包含技能包里的 references——漏了上线才会发现技能读不到子流程。"""
  326. import sys as _sys
  327. _sys.path.insert(0, str(ROOT / "release"))
  328. import build as release_build
  329. packaged = {path.relative_to(ROOT).as_posix() for path in release_build.source_files(ROOT)}
  330. on_disk = {path.relative_to(ROOT).as_posix()
  331. for path in SKILL_DIR.rglob("*") if path.is_file()}
  332. assert on_disk <= packaged, f"未被打包:{sorted(on_disk - packaged)}"
  333. LLM_EXTRACTION_FIXTURE = ROOT / "tests" / "fixtures" / "airflow" / "bolitai_plan_extraction.llm.json"
  334. @pytest.mark.skipif(not LLM_EXTRACTION_FIXTURE.is_file(), reason="未保存模型真实提取结果")
  335. def test_real_llm_extraction_verifies_without_false_errors():
  336. """真实模型对真实 PDF 的提取结果必须能核验通过,不能被误判成计算错误。
  337. 这份 fixture 是一次真实运行留下的产物:92 个对象,风量值全部来自计划原文。
  338. 它固定住了两个曾经踩过的坑——模型把参数口径写成同义说法("平均有效净断面积"),
  339. 以及自洽核验只认声明口径。两者都会把正确计划整盘阻断。
  340. """
  341. validated = tools.validate_plan_extraction_tool(str(LLM_EXTRACTION_FIXTURE), mine_id="布尔台矿")
  342. assert validated.get("ok") is True, validated
  343. body = validated["data"]
  344. assert body["item_count"] == 92
  345. assert body["plan_path"]
  346. verified = tools.verify_and_extract_plan_airflow(body["plan_path"], mine_id="布尔台矿")
  347. data = verified["data"]
  348. assert data["plan_authoritative"] is True
  349. assert data["blocked_items"] == [], "正确计划不应被阻断"
  350. required = {item["target_id"]: item["required_airflow_m3_min"] for item in data["verified_items"]}
  351. assert required == {"RS-SDHL": 11073.6, "RS-MAMD": 21423.6, "MT-BLT": 32497.2}
  352. assert not [f for f in data["findings"] if f.get("severity") == "major"], "不应产生重大风险问题"
  353. def test_validate_tool_returns_validation_details_instead_of_empty_error():
  354. """校验发现阻断性问题时,必须把 validation 明细交给模型,不能塌缩成一句空错误。"""
  355. extraction = {
  356. "mine_id": "M1", "month": "2026-05", "plan_id": "P1", "plan_version": "V1",
  357. "effective_window": {"start": "2026-05-01T00:00:00+08:00", "end": "2026-05-31T23:59:59+08:00"},
  358. "items": [
  359. {"target_id": "L1", "target_type": "workplace", "target_name": "某个面",
  360. "parent_return_system_id": "R-missing", "planned_required_airflow_m3_min": 900},
  361. ],
  362. }
  363. result = tools.validate_plan_extraction_tool(json.dumps(extraction, ensure_ascii=False), mine_id="M1")
  364. assert result.get("ok") is True
  365. body = result["data"]
  366. assert body["status"] == "error" # 校验判定为有阻断性问题
  367. assert body["item_count"] == 1
  368. codes = {issue["code"] for issue in body["validation"]["issues"]}
  369. assert "invalid_parent" in codes or "missing_aggregation" in codes
  370. assert body["next_step"] and "修正" in body["next_step"]
  371. # ---- 入参容错与工具安全网 -------------------------------------------------
  372. def test_tool_schemas_expose_documented_parameters():
  373. """工具包装(含异常安全网)不能吃掉入参 schema——吃到参数模型就没法调用工具了。"""
  374. from extensions.vent_airflow.extension import TOOL_NAMES
  375. from tools import registry
  376. catalog = load_extensions()
  377. expected_min = {
  378. "parse_airflow_measurement_report": {"file_path", "mine_id", "report_period", "scope"},
  379. "query_monitoring_airflow": {"mine_id", "aggregation"},
  380. "preprocess_air_plan_document": {"file_path", "mine_id", "month"},
  381. "validate_plan_extraction": {"extraction", "mine_id"},
  382. "verify_and_extract_plan_airflow": {"plan_path", "mine_id", "rule_version"},
  383. "reconcile_airflow_sources": {"targets"},
  384. "compare_airflow_actual_vs_required": {"targets", "dataset_path", "verification_path"},
  385. "trace_airflow_evidence": {"evidence_path", "finding_id", "evidence_ids"},
  386. "render_airflow_review_report": {"findings", "mine_id", "period", "report_format"},
  387. }
  388. assert set(expected_min) == set(TOOL_NAMES)
  389. for name, expected in expected_min.items():
  390. tool = registry.spec_as_tool(catalog.tools[name])
  391. assert expected <= set(tool.args), f"{name} 少了参数:{sorted(expected - set(tool.args))}"
  392. def test_compare_tool_tolerates_windows_written_as_strings():
  393. """真实故障回归:模型把 required_effective_window 写成字符串,曾经直接崩掉整轮。"""
  394. targets = [{
  395. "target_id": "L1", "target_type": "workplace", "location_type": "采煤工作面",
  396. "target_name": "某采面", "actual_airflow_m3_min": 800, "required_airflow_m3_min": 900,
  397. "plan_verification_status": "valid",
  398. "actual_window": "2024年12月15日",
  399. "required_effective_window": "2024年12月1日 ~ 2024年12月31日",
  400. }]
  401. result = tools.compare_airflow_actual_vs_required(targets=json.dumps(targets, ensure_ascii=False))
  402. assert result.get("ok") is True, result
  403. record = result["data"]["comparisons"][0]
  404. assert record["time_alignment_status"] == "aligned"
  405. assert record["required_effective_window"].get("normalized_from") == "string"
  406. assert result["data"]["finding_summary"]["by_finding_type"] == {"workplace_airflow_below_75_percent": 1}
  407. def test_compare_tool_marks_manual_review_for_unparsable_window():
  408. """时间窗实在认不出来时转人工复核,而不是抛错、也不是硬判正常。"""
  409. targets = [{
  410. "target_id": "L1", "target_type": "workplace", "target_name": "某面",
  411. "actual_airflow_m3_min": 800, "required_airflow_m3_min": 900,
  412. "plan_verification_status": "valid",
  413. "actual_window": "本月中旬", "required_effective_window": {"start": "2024-12-01T00:00:00+08:00",
  414. "end": "2024-12-31T23:59:59+08:00"},
  415. }]
  416. result = tools.compare_airflow_actual_vs_required(targets=json.dumps(targets, ensure_ascii=False))
  417. record = result["data"]["comparisons"][0]
  418. assert record["comparison_status"] == "insufficient"
  419. assert result["data"]["findings"][0]["manual_review_required"] is True
  420. def test_reconcile_tool_tolerates_string_numbers_and_dates():
  421. request = [{
  422. "target_id": "L1", "target_type": "workplace", "target_name": "某面",
  423. "comparison_window": "2024年12月15日 09:00 至 2024年12月15日 23:59",
  424. "sources": [
  425. {"source_type": "measurement_report", "airflow_m3_min": "860", "observed_at": "2024年12月15日 09:00"},
  426. {"source_type": "monitoring", "airflow_m3_min": 820, "observed_at": "2024-12-15T09:30:00+08:00"},
  427. ],
  428. }]
  429. result = tools.reconcile_airflow_sources_tool(targets=json.dumps(request, ensure_ascii=False))
  430. first = result["data"]["results"][0]
  431. assert first["consistency"] == "acceptable"
  432. assert first["deviation_percent"] == pytest.approx(4.878, rel=1e-3)
  433. def test_tools_return_readable_error_when_engine_raises(monkeypatch):
  434. """安全网:引擎抛意外异常时返回可读错误,而不是把整轮对话打断。"""
  435. def boom(*_args, **_kwargs):
  436. raise RuntimeError("模拟引擎内部错误")
  437. monkeypatch.setattr(tools, "compare_targets", boom)
  438. result = tools.compare_airflow_actual_vs_required(targets=json.dumps([{"target_id": "L1"}]))
  439. assert result["ok"] is False
  440. assert "工具内部错误" in result["error"] and "RuntimeError" in result["error"]
  441. assert result["hint"]
  442. def test_validate_tool_never_raises_on_garbage_items():
  443. """items 里混入字符串、参数写成数组等畸形输入,只能得到可读错误或校验问题。"""
  444. extraction = {"plan_id": "P", "plan_version": "V", "effective_window": "2024年12月",
  445. "items": ["不是对象", {"target_id": "L1", "target_type": "workplace",
  446. "target_name": "面", "planned_parameters": ["a", "b"]}]}
  447. result = tools.validate_plan_extraction_tool(json.dumps(extraction, ensure_ascii=False), mine_id="M1")
  448. assert "error" not in result or "工具内部错误" not in str(result.get("error", ""))
  449. if result.get("ok"):
  450. codes = {issue["code"] for issue in result["data"]["validation"]["issues"]}
  451. assert "invalid_item" in codes or "unparsable_effective_window" in codes
  452. def test_report_filename_is_sanitized_but_report_id_is_not(tmp_path):
  453. """真实故障回归:模型把期间写成含斜杠的一长串,斜杠被当路径分隔符 → 写文件失败。
  454. 文件名必须安全化(斜杠、Windows 非法字符、结尾点空格),而 report_id 保持原样用于
  455. 展示与追溯——两者不能混为一谈。
  456. """
  457. from algorithms.airflow_review.report.render import render_report
  458. messy = "2024-12(配风计划)/ 2026-05-28(实测): <草稿>"
  459. result = render_report(mine_id="布尔台矿", period=messy, findings=[],
  460. report_format="both", output_dir=tmp_path)
  461. assert result["report_id"].endswith(messy) # 逻辑 ID 原样
  462. assert result["files"], result
  463. for path in result["files"].values():
  464. written = Path(path)
  465. assert written.is_file()
  466. assert written.parent == tmp_path # 没有被当成子目录
  467. assert not set(written.name) & set('/:*?"<>|\\')
  468. assert result["report_id"] not in written.name # 用的是安全化后的名字