tool_support.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. """风量研判工具的支撑代码:参数解析、大对象落盘、地点匹配、证据链展开与来源抽取。
  2. 与 tools.py 分开的原因:tools.py 是**对外契约**(工具名、参数、摘要字段),改动要
  3. 克制;这里是内部实现细节,跟着解析器与引擎的接口演进。混在一起会让"哪些是契约"
  4. 看不出来。
  5. """
  6. from __future__ import annotations
  7. import functools
  8. import json
  9. from typing import Any, Iterable, Mapping
  10. import config
  11. from algorithms.airflow_review.contracts import MINE_TZ, mappings, now_iso
  12. from algorithms.airflow_review.engine.matching import match_locations, match_return_systems
  13. from algorithms.airflow_review.engine.trace import trace_evidence
  14. from extensions.vent_airflow import workspace_paths
  15. from obs import get_logger
  16. from tools.registry import source
  17. logger = get_logger(__name__)
  18. _INLINE = config.AIRFLOW_MAX_INLINE_ITEMS
  19. def _readable_errors(func):
  20. """把工具内部的意外异常转成可读错误返回,而不是抛出去打断整轮对话。
  21. 工具抛异常会一路穿过 ToolNode 打掉整个 SSE 流(实测:比对工具因为入参里时间窗被
  22. 写成字符串,崩掉的是整轮研判)。AGENTS §四的约定是"工具失败返回可读原因,不抛裸
  23. 异常"——返回说明后模型能自己改参数重试,日志里也留了完整堆栈。
  24. """
  25. @functools.wraps(func)
  26. def wrapper(*args: Any, **kwargs: Any) -> dict[str, Any]:
  27. try:
  28. return func(*args, **kwargs)
  29. except Exception as exc: # noqa: BLE001 - 这里就是要兜住一切,见上
  30. logger.exception("工具执行失败 tool=%s", getattr(func, "__name__", "?"))
  31. return {
  32. "ok": False,
  33. "tool": func.__name__,
  34. "error": f"工具内部错误(已记录日志):{type(exc).__name__}: {exc}",
  35. "hint": ("请核对入参形态:时间窗要传 {start, end} 对象(字符串也能被识别,"
  36. "但请优先传对象);数值字段不要带单位;数组里不要混入字符串。"
  37. "若反复失败,如实告知用户并转人工复核。"),
  38. }
  39. return wrapper
  40. def _build_targets(
  41. dataset: Mapping[str, Any],
  42. verification: Mapping[str, Any],
  43. *,
  44. mine_id: str,
  45. ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
  46. """把数据集与核验通过的需风量组装成比对对象;地点匹配全部在工具内确定性完成。"""
  47. verified = list(verification.get("verified_items") or [])
  48. locations = list(dataset.get("locations") or [])
  49. workplace_match = match_locations(locations, verified)
  50. system_match = match_return_systems(list(dataset.get("return_systems") or []), verified)
  51. observed = _observed_window(dataset)
  52. mine_total = next((item for item in verified if str(item.get("target_type")) == "mine_total"), None)
  53. reuse_key = str((mine_total or {}).get("required_reuse_key") or "")
  54. targets: list[dict[str, Any]] = []
  55. for pair in workplace_match["pairs"]:
  56. required = next((item for item in verified if item.get("target_id") == pair["target_id"]), {})
  57. targets.append(_target(pair["target_id"], pair["target_type"], pair.get("target_name"),
  58. actual=pair["actual_airflow_m3_min"], required=required,
  59. location_type=pair.get("location_type"), observed=observed,
  60. measured_evidence=locations))
  61. system_evidence = {str(item.get("location_name")): item.get("evidence_id")
  62. for item in (dataset.get("return_systems") or [])}
  63. for pair in system_match["pairs"]:
  64. required = next((item for item in verified if item.get("target_id") == pair["target_id"]), {})
  65. target = _target(pair["target_id"], "return_air_system", pair.get("target_name"),
  66. actual=pair["actual_airflow_m3_min"], required=required,
  67. location_type="", observed=observed, measured_evidence=[])
  68. measured_id = system_evidence.get(str(pair.get("location_name")))
  69. if measured_id:
  70. target["evidence_ids"] = [measured_id, *target["evidence_ids"]]
  71. targets.append(target)
  72. if mine_total:
  73. intake = dataset.get("mine_total_intake") or {}
  74. returned = dataset.get("mine_total_return") or {}
  75. for target_id, label, measured, evidence_id in (
  76. ("MINE_TOTAL_INTAKE", "矿井总进风量", intake, intake.get("evidence_id")),
  77. ("MINE_TOTAL_RETURN", "矿井总回风量", returned, returned.get("evidence_id")),
  78. ):
  79. if measured.get("airflow_m3_min") is None:
  80. continue
  81. target = _target(target_id, "mine_total_intake" if "INTAKE" in target_id else "mine_total_return",
  82. label, actual=measured.get("airflow_m3_min"), required=mine_total,
  83. location_type="", observed=observed, measured_evidence=[],
  84. extra_reuse=reuse_key)
  85. if evidence_id:
  86. target["evidence_ids"] = [evidence_id, *target["evidence_ids"]]
  87. targets.append(target)
  88. report = {
  89. "workplace": workplace_match,
  90. "return_system": system_match,
  91. "mine_total_available": mine_total is not None,
  92. }
  93. return targets, report
  94. def _target(
  95. target_id: str,
  96. target_type: str,
  97. target_name: Any,
  98. *,
  99. actual: Any,
  100. required: Mapping[str, Any],
  101. location_type: Any,
  102. observed: Mapping[str, Any],
  103. measured_evidence: list[Mapping[str, Any]],
  104. extra_reuse: str = "",
  105. ) -> dict[str, Any]:
  106. evidence_ids = [item.get("evidence_id") for item in measured_evidence
  107. if item.get("location_id") == target_id and item.get("evidence_id")]
  108. return {
  109. "target_id": target_id,
  110. "target_type": target_type,
  111. "target_name": target_name,
  112. "location_type": location_type or "",
  113. "actual_airflow_m3_min": actual,
  114. "actual_source": "measurement_report",
  115. "actual_window": observed,
  116. "required_airflow_m3_min": required.get("required_airflow_m3_min"),
  117. "required_source": required.get("required_source", "verified_monthly_air_distribution_plan"),
  118. "required_reuse_key": required.get("required_reuse_key") or extra_reuse,
  119. "plan_id": required.get("plan_id", ""),
  120. "plan_version": required.get("plan_version", ""),
  121. "plan_verification_status": "valid",
  122. "required_effective_window": required.get("required_effective_window") or {},
  123. "evidence_ids": [eid for eid in evidence_ids if eid] + list(required.get("evidence_ids") or []),
  124. }
  125. def _observed_window(dataset: Mapping[str, Any]) -> dict[str, Any]:
  126. """实测窗口:旬报按观测时点取点窗口;无时点时用报表月份首日。"""
  127. records = dataset.get("records") or []
  128. observed = next((record.get("observed_at") for record in records if record.get("observed_at")), "")
  129. if not observed:
  130. period = str(dataset.get("report_period") or "")
  131. observed = f"{period}-01T09:00:00+08:00" if period else ""
  132. if not observed:
  133. return {}
  134. return {"start": observed, "end": observed, "aggregation": "point"}
  135. def _match_summary(report: Mapping[str, Any]) -> dict[str, Any]:
  136. workplace = report.get("workplace") or {}
  137. system = report.get("return_system") or {}
  138. return {
  139. "workplace_matched": workplace.get("matched_count", 0),
  140. "workplace_match_rate": workplace.get("match_rate", 0.0),
  141. "unmatched_dataset_count": len(workplace.get("unmatched_dataset") or []),
  142. "unmatched_plan_workplace_count": len(workplace.get("unmatched_plan_workplaces") or []),
  143. "return_system_matched": len(system.get("pairs") or []),
  144. "unmatched_return_system_count": len(system.get("unmatched_dataset") or []),
  145. }
  146. def _manual_summary(details: list[Mapping[str, Any]]) -> dict[str, Any]:
  147. breakdown: dict[str, int] = {}
  148. for detail in details:
  149. key = str(detail.get("location_type") or "未分类")
  150. breakdown[key] = breakdown.get(key, 0) + 1
  151. return {"count": len(details), "by_location_type": breakdown,
  152. "sample": [detail.get("target_name") for detail in details[:8]]}
  153. def _collect_evidence(payload: Any) -> dict[str, Mapping[str, Any]]:
  154. """从任意层级的产物里收集 evidence 映射(工具产物结构不同,统一扫一遍)。"""
  155. found: dict[str, Mapping[str, Any]] = {}
  156. def walk(node: Any, depth: int = 0) -> None:
  157. if depth > 6:
  158. return
  159. if isinstance(node, Mapping):
  160. evidence = node.get("evidence")
  161. if isinstance(evidence, Mapping):
  162. for key, value in evidence.items():
  163. if isinstance(value, Mapping) and value.get("evidence_id"):
  164. found.setdefault(str(key), value)
  165. for value in node.values():
  166. walk(value, depth + 1)
  167. elif isinstance(node, list):
  168. for item in node:
  169. walk(item, depth + 1)
  170. walk(payload)
  171. return found
  172. def _evidence_ids_for_finding(payload: Any, finding_id: str) -> list[str]:
  173. for finding in (payload.get("findings") if isinstance(payload, Mapping) else None) or []:
  174. if isinstance(finding, Mapping) and finding.get("finding_id") == finding_id:
  175. return list(finding.get("evidence_ids") or [])
  176. return []
  177. def _attach_evidence_chains(
  178. findings: list[Mapping[str, Any]],
  179. evidence_path: str,
  180. verified_items: list[Mapping[str, Any]],
  181. ) -> tuple[list[dict[str, Any]], list[str]]:
  182. """把问题的证据链展开到 findings 上,并补上权威需风量引用的证据。
  183. 报告中"证据索引"与"证据完整率"都依赖这一步:问题只有 evidence_ids 是不够的,
  184. 报告要能看到"哪个文件、哪一页/哪个单元格、什么值"。
  185. """
  186. if not evidence_path:
  187. return mappings(findings), []
  188. payload, error = _load_argument(evidence_path)
  189. if error:
  190. return mappings(findings), [f"证据文件读取失败,报告未展开证据链:{error}"]
  191. store = _collect_evidence(payload)
  192. if not store:
  193. return mappings(findings), ["证据文件中没有 evidence 映射,报告未展开证据链"]
  194. for item in verified_items:
  195. for evidence_id in item.get("evidence_ids") or []:
  196. store.setdefault(str(evidence_id), {"evidence_id": evidence_id,
  197. "source_file": "配风计划", "source_type": "plan",
  198. "note": "权威需风量引用"})
  199. enriched: list[dict[str, Any]] = []
  200. incomplete: list[str] = []
  201. for finding in findings:
  202. item = dict(finding)
  203. chain = trace_evidence(evidence=store, evidence_ids=item.get("evidence_ids") or [],
  204. finding_id=str(item.get("finding_id") or ""))
  205. item["evidence_chain"] = chain["evidence_chain"]
  206. if not chain["is_complete"]:
  207. item["manual_review_required"] = True
  208. item["evidence_status"] = chain["conclusion"]
  209. incomplete.append(f"{item.get('finding_id')}:{chain['conclusion']}")
  210. enriched.append(item)
  211. return enriched, incomplete[:5]
  212. def _sources(evidence: Any, evidence_ids: Iterable[str] | None, limit: int = 10) -> list[dict[str, Any]]:
  213. """把证据转成来源标注;条数截断,避免刷屏占用上下文。"""
  214. if not isinstance(evidence, Mapping):
  215. return []
  216. result: list[dict[str, Any]] = []
  217. for evidence_id in list(evidence_ids or [])[:limit]:
  218. record = evidence.get(evidence_id)
  219. if not isinstance(record, Mapping):
  220. continue
  221. locator = record.get("locator") or {}
  222. where = " ".join(str(locator.get(key)) for key in ("sheet", "cell", "page") if locator.get(key))
  223. result.append(source(title=str(record.get("source_file") or "原始资料"),
  224. url=f"evidence://{record.get('evidence_id')}",
  225. snippet=f"{where} {record.get('normalized_value') or record.get('raw_value')}".strip(),
  226. score=record.get("confidence")))
  227. return result
  228. def _save(payload: Any, name: str, *, suffix: str = ".json") -> str:
  229. return workspace_paths.write_artifact(json.dumps(payload, ensure_ascii=False, default=str, indent=2),
  230. name, suffix=suffix)
  231. def _cleaned_text(document: Mapping[str, Any]) -> str:
  232. """把清洗后的文本落成文件,并在每页前加页标记,便于模型引用页码。"""
  233. lines: list[str] = []
  234. for block in document.get("blocks") or []:
  235. if block.get("line") == 1:
  236. lines.append(f"\n===== 第 {block.get('page')} 页 =====")
  237. lines.append(str(block.get("text") or ""))
  238. header = (f"# 配风计划清洗文本:{document.get('source_file')}\n"
  239. f"# 共 {document.get('page_count')} 页;已去页眉页脚与重复行;"
  240. f"表格行内的制表符表示不同列\n")
  241. return header + "\n".join(lines)
  242. def _load_argument(value: Any, *, default: Any = None) -> tuple[Any, str]:
  243. """参数既可以是 JSON 字符串,也可以是文件路径(模型经 write_file 落盘的产物)。"""
  244. if value is None or value == "":
  245. return (default, "") if default is not None else (None, "参数为空")
  246. if isinstance(value, (dict, list)):
  247. return value, ""
  248. text = str(value).strip()
  249. if text.startswith("{") or text.startswith("["):
  250. try:
  251. return json.loads(text), ""
  252. except json.JSONDecodeError as exc:
  253. return None, f"JSON 解析失败:{exc.msg}(第 {exc.lineno} 行)"
  254. path = text
  255. try:
  256. real = workspace_paths.resolve_input(path)
  257. except workspace_paths.WorkspacePathError as exc:
  258. return None, (f"既不是合法 JSON,也无法作为文件路径解析:{exc}"
  259. if default is None else "")
  260. try:
  261. return json.loads(real.read_text(encoding="utf-8")), ""
  262. except (OSError, json.JSONDecodeError) as exc:
  263. return None, f"读取 {path} 失败:{exc}"
  264. def _error(tool: str, message: str, **extra: Any) -> dict[str, Any]:
  265. """工具失败返回可读原因,不抛裸异常(AGENTS.md 约定)。"""
  266. return {"ok": False, "tool": tool, "error": message, "at": now_iso(MINE_TZ), **extra}