tools.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. """风量研判对外工具(需求 §2.3 的 8 个外部工具,其中工具 3 拆为预处理与校验两段)。
  2. 每个工具都是薄封装:参数解析、调用 algorithms/airflow_review 的确定性引擎、
  3. 把大对象落盘、返回紧凑摘要与证据来源。工具自身不做任何风量计算。
  4. """
  5. from __future__ import annotations
  6. from pathlib import Path
  7. from typing import Any, Mapping
  8. import config
  9. from algorithms.airflow_review.engine.compare import compare_targets
  10. from algorithms.airflow_review.engine.dedupe import dedupe_findings
  11. from algorithms.airflow_review.engine.findings import FindingFactory, summarize
  12. from algorithms.airflow_review.engine.reconcile import reconcile_sources
  13. from algorithms.airflow_review.engine.trace import trace_evidence
  14. from algorithms.airflow_review.engine.verify import verify_plan
  15. from algorithms.airflow_review.parsers.measurement_report import parse_measurement_report
  16. from algorithms.airflow_review.parsers.plan_extraction import validate_plan_extraction
  17. from algorithms.airflow_review.parsers.plan_preprocess import preprocess_plan_document
  18. from algorithms.airflow_review.report.render import render_report
  19. from algorithms.airflow_review.rules.loader import load_rules
  20. from extensions import define_tool
  21. from extensions.vent_airflow import monitoring, workspace_paths
  22. from extensions.vent_airflow.tool_support import (
  23. _readable_errors,
  24. _build_targets,
  25. _match_summary,
  26. _manual_summary,
  27. _collect_evidence,
  28. _evidence_ids_for_finding,
  29. _attach_evidence_chains,
  30. _sources,
  31. _save,
  32. _cleaned_text,
  33. _load_argument,
  34. _error,
  35. )
  36. from obs import get_logger
  37. from tools.registry import source, sourced
  38. logger = get_logger(__name__)
  39. _GROUP = "风量研判"
  40. _INLINE = config.AIRFLOW_MAX_INLINE_ITEMS
  41. @define_tool(group=_GROUP, label="解析测风报表",
  42. summary="解析测风报表或人工填报表格,提取总进风、总回风、主要通风机、主要巷道与各用风地点的实测风量,构建研判数据集")
  43. @_readable_errors
  44. def parse_airflow_measurement_report(
  45. file_path: str,
  46. mine_id: str = "",
  47. report_period: str = "",
  48. report_type: str = "测风报表",
  49. scope: str = "",
  50. ) -> dict[str, Any]:
  51. """解析测风报表;scope 用逗号分隔,可取 mine_total_intake、mine_total_return、main_fans、main_roadways、workplaces。"""
  52. try:
  53. real = workspace_paths.resolve_input(file_path)
  54. except workspace_paths.WorkspacePathError as exc:
  55. return _error("parse_airflow_measurement_report", str(exc))
  56. scopes = [item.strip() for item in str(scope or "").replace(",", ",").split(",") if item.strip()]
  57. dataset = parse_measurement_report(real, mine_id=mine_id, report_type=report_type,
  58. report_period=report_period, scope=scopes or None)
  59. if dataset.get("status") == "error":
  60. return _error("parse_airflow_measurement_report", str(dataset.get("error")), dataset=dataset)
  61. path = _save(dataset, f"measurement-dataset-{mine_id or 'mine'}", suffix=".json")
  62. quality = dataset.get("data_quality") or {}
  63. payload = {
  64. "dataset_id": dataset["dataset_id"],
  65. "dataset_path": path,
  66. "source_file": dataset["source_file"],
  67. "sheet": dataset.get("sheet"),
  68. "mine_id": mine_id or dataset.get("mine_id"),
  69. "report_period": report_period,
  70. "mine_total_intake_m3_min": (dataset.get("mine_total_intake") or {}).get("airflow_m3_min"),
  71. "mine_total_return_m3_min": (dataset.get("mine_total_return") or {}).get("airflow_m3_min"),
  72. "return_systems": dataset.get("return_systems") or [],
  73. "main_fans": dataset.get("main_fans") or [],
  74. "effective_airflow_m3_min": (dataset.get("effective_airflow") or {}).get("value"),
  75. "effective_airflow_rate_percent": (dataset.get("effective_airflow_rate") or {}).get("value"),
  76. "internal_leakage_rate_percent": (dataset.get("internal_leakage_rate") or {}).get("value"),
  77. "location_count": len(dataset.get("locations") or []),
  78. "location_sample": (dataset.get("locations") or [])[:_INLINE],
  79. "data_quality": quality,
  80. "pending_review": dataset.get("pending_review") or [],
  81. "parse_warnings": dataset.get("parse_warnings") or [],
  82. "next_step": "若存在 pending_review,先与用户确认地点映射与单位,再进入实测比对",
  83. }
  84. warnings = [item.get("message") for item in payload["parse_warnings"]]
  85. if quality.get("unmapped_location_count"):
  86. warnings.append(f"{quality['unmapped_location_count']} 个测点未判定地点类型,已转人工复核")
  87. return sourced(ok=True, data=payload, sources=_sources(dataset.get("evidence"), dataset.get("evidence_ids")),
  88. warnings=warnings)
  89. # ---- 工具 2:安全监控查询 -------------------------------------------------
  90. @define_tool(group=_GROUP, label="查询监控风量",
  91. summary="查询安全监控系统的风量、风速与传感器状态序列,按点/小时/班次/日/月聚合;无数据源时明确返回数据不足并转人工复核")
  92. @_readable_errors
  93. def query_monitoring_airflow(
  94. mine_id: str,
  95. target_ids: str = "",
  96. sensor_ids: str = "",
  97. start_time: str = "",
  98. end_time: str = "",
  99. aggregation: str = "day",
  100. ) -> dict[str, Any]:
  101. """查询监控风量;target_ids 与 sensor_ids 用逗号分隔,aggregation 取 point/hour/shift/day/month。"""
  102. result = monitoring.get_source().query(
  103. mine_id=mine_id,
  104. target_ids=[item.strip() for item in str(target_ids or "").replace(",", ",").split(",") if item.strip()],
  105. sensor_ids=[item.strip() for item in str(sensor_ids or "").replace(",", ",").split(",") if item.strip()],
  106. start_time=start_time, end_time=end_time, aggregation=aggregation or "day",
  107. )
  108. series = result.get("series") or []
  109. payload = {
  110. "status": result.get("status"),
  111. "mine_id": mine_id,
  112. "window": result.get("window"),
  113. "series_count": len(series),
  114. "series_sample": series[:_INLINE],
  115. "data_quality": result.get("data_quality") or {},
  116. "reason": result.get("reason", ""),
  117. "note": result.get("note", ""),
  118. "manual_review_required": bool(result.get("manual_review_required")),
  119. "next_step": ("监控数据可用,可用于交叉校核与时间窗补充"
  120. if result.get("status") == "ok" else
  121. "监控数据不可用:相关对象的连续监测结论必须转人工复核,不得用测风报表替代"),
  122. }
  123. if result.get("status") == "error":
  124. return _error("query_monitoring_airflow", str(result.get("error")))
  125. return sourced(ok=True, data=payload, sources=[],
  126. warnings=[payload["reason"]] if payload["reason"] else [])
  127. # ---- 工具 3a:配风计划预处理 ----------------------------------------------
  128. @define_tool(group=_GROUP, label="预处理配风计划",
  129. summary="确定性预处理配风计划文档:抽干净文本、去页眉页脚、按坐标重建行序、识别章节/表格/公式,并给出提取模板与清洗后文本路径(不提取任何数值)")
  130. @_readable_errors
  131. def preprocess_air_plan_document(file_path: str, mine_id: str = "", month: str = "") -> dict[str, Any]:
  132. """预处理配风计划;返回提取模板、章节与表格线索,以及可被 read_file 分段读取的清洗文本路径。"""
  133. try:
  134. real = workspace_paths.resolve_input(file_path)
  135. except workspace_paths.WorkspacePathError as exc:
  136. return _error("preprocess_air_plan_document", str(exc))
  137. document = preprocess_plan_document(real, mine_id=mine_id, month=month)
  138. if document.get("status") == "error":
  139. return _error("preprocess_air_plan_document", str(document.get("error")))
  140. text_path = workspace_paths.write_artifact(_cleaned_text(document), f"plan-cleaned-{mine_id or 'mine'}",
  141. suffix=".txt")
  142. structure_path = _save(document, f"plan-preprocess-{mine_id or 'mine'}")
  143. payload = {
  144. "source_file": document["source_file"],
  145. "doc_type": document["doc_type"],
  146. "page_count": document["page_count"],
  147. "cleaned_text_path": text_path,
  148. "structure_path": structure_path,
  149. "extraction_template": document["extraction_template"],
  150. "section_count": len(document.get("sections") or []),
  151. "sections": (document.get("sections") or [])[:40],
  152. "formula_count": len(document.get("formulas") or []),
  153. "tables": [{"table_id": table["table_id"], "page_start": table["page_start"],
  154. "page_end": table["page_end"], "row_count": table["row_count"],
  155. "value_sum": round(sum(row["value"] for row in table["rows"]), 2),
  156. "sample_rows": table["rows"][:5]} for table in (document.get("tables") or [])],
  157. "noise_removed": document.get("noise_removed"),
  158. "warnings": document.get("warnings") or [],
  159. "next_step": (f"用 read_file 分段读取 {text_path}(每次 200~400 行),按 extraction_template "
  160. f"全量提取,每个数值带页码与原文片段;提取完调用 validate_plan_extraction 校验"),
  161. }
  162. return sourced(ok=True, data=payload, sources=[source(title=document["source_file"], url=f"file://{real}",
  163. snippet=f"共 {document['page_count']} 页")],
  164. warnings=[item.get("message", "") for item in payload["warnings"]])
  165. # ---- 工具 3b:提取结果校验 ------------------------------------------------
  166. @define_tool(group=_GROUP, label="校验计划提取", name="validate_plan_extraction",
  167. summary="校验按模板提取出来的配风计划 JSON:字段完整性、单位、公式映射、层级归属;通过后写出规范化计划文件供复算核验使用")
  168. @_readable_errors
  169. def validate_plan_extraction_tool(extraction: str, mine_id: str = "", rule_version: str = "") -> dict[str, Any]:
  170. """校验提取结果;extraction 可直接传 JSON 字符串,也可传已写好的 JSON 文件路径。"""
  171. payload, error = _load_argument(extraction)
  172. if error:
  173. return _error("validate_plan_extraction", error)
  174. result = validate_plan_extraction(payload, mine_id=mine_id, rule_version=rule_version)
  175. # 只有"连输入都读不出来"才算工具失败。校验发现的阻断性问题属于**正常产出**:
  176. # 必须把 validation 明细完整交给模型,它才知道按什么去修;早先把这种情况也当失败,
  177. # 模型只拿到一句空错误,只能反复瞎试。
  178. if result.get("status") == "error" and result.get("error"):
  179. return _error("validate_plan_extraction", str(result["error"]))
  180. if result.get("plan") is None:
  181. return _error("validate_plan_extraction", "提取结果为空,未解析出任何计划对象")
  182. plan = result["plan"]
  183. path = _save(plan, f"plan-normalized-{mine_id or 'mine'}")
  184. validation = result["validation"]
  185. body = {
  186. "status": result["status"],
  187. "plan_path": path,
  188. "plan_id": plan["plan_id"],
  189. "plan_version": plan["plan_version"],
  190. "item_count": len(plan["items"]),
  191. "effective_window": plan["effective_window"],
  192. "validation": {**validation, "issues": validation["issues"][:_INLINE]},
  193. "pending_review": (result.get("pending_review") or [])[:_INLINE],
  194. "next_step": result["next_step"],
  195. }
  196. return sourced(ok=True, data=body, sources=[],
  197. warnings=[issue["message"] for issue in validation["issues"] if issue["severity"] == "major"][:10])
  198. # ---- 工具 4:配风计划核验 -------------------------------------------------
  199. @define_tool(group=_GROUP, label="核验配风计划",
  200. summary="按规则库自下而上复算各用风地点、各回风系统与矿井总需风量,校验层级汇总关系,提取核验通过的权威需风量并阻断计算错误的对象")
  201. @_readable_errors
  202. def verify_and_extract_plan_airflow(
  203. plan_path: str,
  204. mine_id: str = "",
  205. rule_version: str = "",
  206. tolerance_relative_percent: float = 0.0,
  207. tolerance_absolute_m3_min: float = 0.0,
  208. ) -> dict[str, Any]:
  209. """核验配风计划;plan_path 为 validate_plan_extraction 返回的规范化计划文件路径。"""
  210. plan, error = _load_argument(plan_path)
  211. if error:
  212. return _error("verify_and_extract_plan_airflow", error)
  213. if not isinstance(plan, Mapping) or not plan.get("items"):
  214. return _error("verify_and_extract_plan_airflow",
  215. "计划文件缺少 items;请先调用 validate_plan_extraction 生成规范化计划")
  216. rules = load_rules(rule_version or None)
  217. tolerance = {}
  218. if tolerance_relative_percent:
  219. tolerance["relative_percent"] = float(tolerance_relative_percent)
  220. if tolerance_absolute_m3_min:
  221. tolerance["absolute_m3_min"] = float(tolerance_absolute_m3_min)
  222. result = verify_plan(plan, rules, tolerance=tolerance, finding_factory=FindingFactory())
  223. path = _save(result, f"plan-verification-{mine_id or plan.get('mine_id') or 'mine'}")
  224. verified = result["verified_items"]
  225. findings = result["findings"]
  226. hierarchy = result["hierarchy"]
  227. body = {
  228. "plan_path": path,
  229. "plan_id": result["plan_id"],
  230. "plan_version": result["plan_version"],
  231. "plan_calculation_status": result["plan_calculation_status"],
  232. "plan_authoritative": result["plan_authoritative"],
  233. "rule_version": result["rule_version"],
  234. "tolerance": result["tolerance"],
  235. "verified_item_count": len(verified),
  236. "verified_items": verified,
  237. "blocked_items": result["blocked_items"],
  238. "manual_review_item_count": len(result["manual_review_items"]),
  239. "manual_review_summary": _manual_summary(result.get("manual_review_details") or []),
  240. "hierarchy": {
  241. "level_counts": {key: len(value) for key, value in (hierarchy.get("levels") or {}).items()},
  242. "issues": (hierarchy.get("issues") or [])[:_INLINE],
  243. "single_return_system": hierarchy.get("single_return_system"),
  244. "single_return_equivalence": hierarchy.get("single_return_equivalence"),
  245. },
  246. "finding_count": len(findings),
  247. "finding_summary": summarize(findings),
  248. "findings": findings[:_INLINE],
  249. "next_step": ("计划核验通过:把 verified_items 作为权威需风量进入实测比对"
  250. if result["plan_authoritative"] else
  251. "计划存在计算错误:相关对象不得进入正式实测比对,先按 findings 说明问题"),
  252. }
  253. return sourced(ok=True, data=body, sources=[],
  254. warnings=[finding["conclusion"] for finding in findings
  255. if finding.get("severity") == "major"][:5])
  256. # ---- 工具 5:多源交叉校核 -------------------------------------------------
  257. @define_tool(group=_GROUP, label="多源交叉校核", name="reconcile_airflow_sources",
  258. summary="对同一对象、同一时间窗的测风报表与安全监控数据做时间对齐、偏差计算与可信度判定;偏差超限或时间不对齐时转人工复核")
  259. @_readable_errors
  260. def reconcile_airflow_sources_tool(targets: str) -> dict[str, Any]:
  261. """交叉校核;targets 为 JSON 或 JSON 文件路径,元素含 target_id、target_type、comparison_window、sources[]。"""
  262. items, error = _load_argument(targets)
  263. if error:
  264. return _error("reconcile_airflow_sources", error)
  265. if isinstance(items, Mapping):
  266. items = [items]
  267. if not isinstance(items, list) or not items:
  268. return _error("reconcile_airflow_sources", "targets 必须是非空数组")
  269. rules = load_rules()
  270. factory = FindingFactory()
  271. results = [reconcile_sources(item, rules, factory) for item in items]
  272. findings = [result["finding"] for result in results if result.get("finding")]
  273. body = {
  274. "rule_version": rules.rule_version,
  275. "target_count": len(results),
  276. "results": [{key: value for key, value in result.items() if key != "finding"} for result in results][:_INLINE],
  277. "findings": findings,
  278. "finding_summary": summarize(findings),
  279. "conflict_count": sum(1 for result in results if result.get("consistency") == "conflict"),
  280. "manual_review_count": sum(1 for result in results if result.get("requires_manual_review")),
  281. "next_step": "存在冲突或时间不对齐的对象必须人工复核确认采信值,不得用平均值掩盖",
  282. }
  283. return sourced(ok=True, data=body, sources=[], warnings=[finding["conclusion"] for finding in findings][:5])
  284. # ---- 工具 6:实际 vs 需风量比对 -------------------------------------------
  285. @define_tool(group=_GROUP, label="实际与需风量比对",
  286. summary="把实测风量与核验通过的需风量按对象类型比对:矿井总进风/总回风与回风系统按 1.0,采掘工作面按 75% 红线;时间对不上或缺数据只输出待复核")
  287. @_readable_errors
  288. def compare_airflow_actual_vs_required(
  289. targets: str = "",
  290. dataset_path: str = "",
  291. verification_path: str = "",
  292. mine_id: str = "",
  293. ) -> dict[str, Any]:
  294. """比对:targets 直接给比对对象数组;也可给 dataset_path + verification_path 由工具自动匹配地点后组装。"""
  295. rules = load_rules()
  296. factory = FindingFactory()
  297. built_from: list[str] = []
  298. if targets:
  299. items, error = _load_argument(targets)
  300. if error:
  301. return _error("compare_airflow_actual_vs_required", error)
  302. if isinstance(items, Mapping):
  303. items = [items]
  304. if not isinstance(items, list) or not items:
  305. return _error("compare_airflow_actual_vs_required", "targets 必须是非空数组")
  306. else:
  307. if not dataset_path or not verification_path:
  308. return _error("compare_airflow_actual_vs_required",
  309. "需要 targets,或同时给 dataset_path 与 verification_path")
  310. dataset, error = _load_argument(dataset_path)
  311. if error:
  312. return _error("compare_airflow_actual_vs_required", f"数据集读取失败:{error}")
  313. verification, error = _load_argument(verification_path)
  314. if error:
  315. return _error("compare_airflow_actual_vs_required", f"核验结果读取失败:{error}")
  316. items, match_report = _build_targets(dataset, verification, mine_id=mine_id)
  317. built_from = ["dataset_path", "verification_path"]
  318. if not items:
  319. return _error("compare_airflow_actual_vs_required",
  320. "没有可比对的对象:实测地点与核验通过的需风量没有匹配上,请先解决地点映射",
  321. match_report=match_report)
  322. result = compare_targets(items, rules, mine_id=mine_id, factory=factory)
  323. deduped = dedupe_findings(result["findings"])
  324. path = _save({**result, "findings": deduped["findings"], "dedupe": deduped},
  325. f"comparison-{mine_id or 'mine'}")
  326. comparisons = result["comparisons"]
  327. body = {
  328. "result_path": path,
  329. "built_from": built_from,
  330. "rule_version": rules.rule_version,
  331. "summary": result["summary"],
  332. "comparisons": [{key: value for key, value in record.items() if key != "finding"}
  333. for record in comparisons][:_INLINE],
  334. "finding_count": len(deduped["findings"]),
  335. "finding_summary": summarize(deduped["findings"]),
  336. "findings": [{key: value for key, value in finding.items() if key != "calculation_trace"}
  337. for finding in deduped["findings"]][:_INLINE],
  338. "dedupe": {"merged_count": deduped["merged_count"], "reuse_groups": deduped["reuse_groups"]},
  339. }
  340. if built_from:
  341. body["match_report"] = _match_summary(match_report)
  342. if match_report.get("unmatched_dataset"):
  343. body["unmatched_locations"] = match_report["unmatched_dataset"][:_INLINE]
  344. return sourced(ok=True, data=body, sources=[],
  345. warnings=[finding["conclusion"] for finding in deduped["findings"]
  346. if finding.get("severity") == "major"][:5])
  347. # ---- 工具 7:证据链追溯 ---------------------------------------------------
  348. @define_tool(group=_GROUP, label="追溯证据链",
  349. summary="把 evidence_id 解析成完整证据链:原始文件、页码或单元格、数值、计算过程与规则版本;证据缺失时问题状态必须为人工复核")
  350. @_readable_errors
  351. def trace_airflow_evidence(
  352. evidence_path: str,
  353. finding_id: str = "",
  354. evidence_ids: str = "",
  355. trace_level: str = "full",
  356. ) -> dict[str, Any]:
  357. """追溯证据链;evidence_path 为任一工具产物文件(内含 evidence 映射),evidence_ids 用逗号分隔。"""
  358. payload, error = _load_argument(evidence_path)
  359. if error:
  360. return _error("trace_airflow_evidence", error)
  361. store = _collect_evidence(payload)
  362. if not store:
  363. return _error("trace_airflow_evidence",
  364. "该文件里没有 evidence 映射;请传入测风报表解析结果或计划预处理/核验结果文件")
  365. wanted = [item.strip() for item in str(evidence_ids or "").replace(",", ",").split(",") if item.strip()]
  366. if not wanted and finding_id:
  367. wanted = _evidence_ids_for_finding(payload, finding_id)
  368. if not wanted:
  369. wanted = list(store)
  370. result = trace_evidence(evidence=store, evidence_ids=wanted, finding_id=finding_id,
  371. trace_level=trace_level)
  372. return sourced(ok=True, data=result, sources=[],
  373. warnings=[] if result["is_complete"] else [result["conclusion"]])
  374. # ---- 工具 8:报告渲染 -----------------------------------------------------
  375. @define_tool(group=_GROUP, label="渲染审查报告",
  376. summary="生成风量研判审查报告(md 与 docx),固定包含研判范围、数据来源、核查方法、问题清单、计算过程、证据索引、数据质量说明与待复核事项")
  377. @_readable_errors
  378. def render_airflow_review_report(
  379. findings: str,
  380. mine_id: str = "",
  381. period: str = "",
  382. data_quality_summary: str = "",
  383. review_scope: str = "",
  384. report_format: str = "both",
  385. comparisons: str = "",
  386. verified_items: str = "",
  387. evidence_path: str = "",
  388. ) -> dict[str, Any]:
  389. """渲染报告;findings/comparisons/verified_items/data_quality_summary 均为 JSON 或 JSON 文件路径,report_format 取 md/docx/both。
  390. evidence_path 指向任一含 evidence 映射的工具产物(如测风报表解析结果)时,报告会把
  391. 每个问题的证据链展开并据此计算证据完整率;不给则在报告中注明证据链缺失。
  392. """
  393. parsed_findings, error = _load_argument(findings)
  394. if error:
  395. return _error("render_airflow_review_report", f"findings 读取失败:{error}")
  396. if isinstance(parsed_findings, Mapping):
  397. parsed_findings = parsed_findings.get("findings") or []
  398. if not isinstance(parsed_findings, list):
  399. return _error("render_airflow_review_report", "findings 必须是数组或含 findings 的对象")
  400. quality, _ = _load_argument(data_quality_summary, default={})
  401. comparison_list, _ = _load_argument(comparisons, default=[])
  402. verified, _ = _load_argument(verified_items, default=[])
  403. if isinstance(comparison_list, Mapping):
  404. comparison_list = comparison_list.get("comparisons") or []
  405. if isinstance(verified, Mapping):
  406. verified = verified.get("verified_items") or []
  407. scope = [item.strip() for item in str(review_scope or "").replace(",", ",").split(",") if item.strip()]
  408. deduped = dedupe_findings(parsed_findings) if parsed_findings else {"findings": []}
  409. enriched, trace_warnings = _attach_evidence_chains(
  410. deduped["findings"], evidence_path, verified if isinstance(verified, list) else [])
  411. try:
  412. real_output, virtual_output = workspace_paths.output_path(f"AIR-REVIEW-{mine_id or 'mine'}-{period or ''}")
  413. except workspace_paths.WorkspacePathError:
  414. real_output, virtual_output = None, None
  415. result = render_report(
  416. mine_id=mine_id, period=period, findings=enriched,
  417. data_quality_summary=quality or {}, review_scope=scope or None,
  418. report_format=report_format or "both", comparisons=comparison_list,
  419. verified_items=verified if isinstance(verified, list) else [],
  420. output_dir=real_output.parent if real_output else None,
  421. )
  422. body = dict(result)
  423. if virtual_output:
  424. # 报告文件名由 render_report 按 report_id 决定,因此以它**实际写出的文件**为准,
  425. # 不能拿预先分配的产物路径改名(两者只共享目录,文件名不同)。
  426. files = result.get("files") or {}
  427. parent = virtual_output.rsplit("/", 1)[0] if "/" in virtual_output else str(Path(virtual_output).parent).replace("\\", "/")
  428. body["files"] = {kind: Path(path).name for kind, path in files.items()}
  429. body["file_paths"] = {kind: f"{parent}/{Path(path).name}" for kind, path in files.items()}
  430. body["file_url"] = body["file_paths"].get("md") or body["file_paths"].get("docx") or ""
  431. body["dedupe_merged_count"] = deduped.get("merged_count", 0)
  432. body["evidence_chain_attached"] = bool(evidence_path)
  433. return sourced(ok=True, data=body, sources=[],
  434. warnings=[*(result.get("warnings") or []), *trace_warnings])
  435. __all__ = [
  436. "parse_airflow_measurement_report",
  437. "query_monitoring_airflow",
  438. "preprocess_air_plan_document",
  439. "validate_plan_extraction_tool",
  440. "verify_and_extract_plan_airflow",
  441. "reconcile_airflow_sources_tool",
  442. "compare_airflow_actual_vs_required",
  443. "trace_airflow_evidence",
  444. "render_airflow_review_report",
  445. ]