vent_plan_tools.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. # -*- coding: utf-8 -*-
  2. """
  3. 配风计划审查 - MCP 工具模块
  4. 通过 MCP 客户端查询外部数据源,供配风计划审查智能体(数据一致性审查)调用。
  5. MCP 服务端提供的工具:
  6. - query_monthly_mining_plan: 月度采掘计划查询
  7. - query_working_face_procedure: 工作面作业规程查询
  8. - query_gas_identification_report: 瓦斯等级鉴定报告查询
  9. - query_ventilation_report: 测风报表查询
  10. 工具列表:
  11. - query_mining_plan: 查询煤矿月度采掘计划
  12. - query_face_procedure: 查询工作面作业规程
  13. - query_gas_report: 查询瓦斯等级鉴定报告
  14. - query_wind_report: 查询测风报表
  15. """
  16. import json
  17. from fastmcp import Client
  18. from tools.mcp_logger import log_mcp_call
  19. # ============================================================
  20. # MCP 客户端辅助函数
  21. # ============================================================
  22. def _get_mcp_url() -> str:
  23. """获取 MCP 服务 URL。
  24. 从项目根目录 .env 读取 MCP_BASE_URL 配置,拼接 /mcp 路径。
  25. """
  26. from dotenv import dotenv_values
  27. from pathlib import Path as _Path
  28. _env_file = _Path(__file__).parent.parent / ".env"
  29. _cfg = dotenv_values(str(_env_file))
  30. mcp_url = _cfg.get("MCP_BASE_URL", "http://localhost:8100").rstrip("/")
  31. return f"{mcp_url}/mcp"
  32. async def _call_mcp_tool(tool_name: str, arguments: dict) -> str:
  33. """通过 FastMCP 异步客户端调用远程 MCP 工具。
  34. Args:
  35. tool_name: MCP 工具名称
  36. arguments: 工具参数字典
  37. Returns:
  38. JSON 字符串格式的查询结果
  39. """
  40. mcp_url = _get_mcp_url()
  41. try:
  42. client = Client(mcp_url)
  43. async with client:
  44. result = await client.call_tool(tool_name, arguments)
  45. raw = result.content[0].text
  46. log_mcp_call(tool_name, arguments, raw)
  47. return raw
  48. except Exception as e:
  49. log_mcp_call(tool_name, arguments, "", error=str(e))
  50. import traceback
  51. traceback.print_exc()
  52. return json.dumps(
  53. {"error": f"MCP调用失败: {e}", "tool": tool_name, "args": arguments},
  54. ensure_ascii=False,
  55. )
  56. # ============================================================
  57. # 配风计划审查 MCP 工具
  58. # ============================================================
  59. def _normalize_plan_month(raw: str) -> str:
  60. """规范化计划月份为 yyyy-MM 格式(匹配 MCP 服务端要求)。
  61. 支持的输入格式:
  62. - "2026-6" / "2026-06" → "2026-06"
  63. - "2026年6月" → "2026-06"
  64. - "2026/6" → "2026-06"
  65. - "2026.6" → "2026-06"
  66. Args:
  67. raw: LLM 从 PDF 中提取的原始月份字符串
  68. Returns:
  69. str: yyyy-MM 格式的计划月份
  70. """
  71. import re
  72. raw = raw.strip().replace(" ", "")
  73. # 匹配 "2026年6月" 格式
  74. m = re.match(r"(\d{4})\s*年\s*(\d{1,2})\s*月?", raw)
  75. if m:
  76. return f"{m.group(1)}-{int(m.group(2)):02d}"
  77. # 匹配 "2026-6" / "2026-06" / "2026/6" / "2026.6" 格式
  78. m = re.match(r"(\d{4})\s*[-/\.]\s*(\d{1,2})", raw)
  79. if m:
  80. return f"{m.group(1)}-{int(m.group(2)):02d}"
  81. # 无法识别则原样返回(MCP 服务端做模糊匹配)
  82. return raw
  83. # ============================================================
  84. # 煤矿名称模糊重试机制
  85. # ============================================================
  86. def _generate_name_variations(mine_name: str, max_variations: int = 3) -> list:
  87. """生成煤矿名称的渐进放宽变体列表,用于 MCP 查询重试。
  88. 典型场景:智能体从 PDF 中提取的矿名与数据库中的矿名存在细微差异,
  89. 如 "合阳县金桥煤炭有限责任公司"(PDF 提取) vs "合阳县金桥煤炭有限公司"(DB 记录)。
  90. 变体优先级(从最精确到最宽松):
  91. Level 0: 原始名称
  92. Level 1: 替换「有限责任公司」→「有限公司」(最常见的不匹配模式)
  93. Level 2: 去除所有企业后缀(有限责任/股份/有限/公司)
  94. Level 3: 进一步去除行业词汇(煤炭/煤业/矿业/煤矿),仅保留地名+核心名
  95. Args:
  96. mine_name: 原始煤矿名称
  97. max_variations: 最大变体数(不含原始名称),默认 3
  98. Returns:
  99. list[str]: 名称变体列表,第一个元素始终是原始名称
  100. """
  101. import re
  102. variations = [mine_name.strip()]
  103. if not mine_name or len(mine_name.strip()) < 3:
  104. return variations
  105. name = mine_name.strip()
  106. # ── Level 1: 替换「有限责任公司」为「有限公司」──
  107. if "有限责任公司" in name:
  108. v = name.replace("有限责任公司", "有限公司")
  109. if v != name and v not in variations:
  110. variations.append(v)
  111. if len(variations) > max_variations:
  112. return variations[:max_variations + 1]
  113. # ── Level 2: 去除常见企业后缀 ──
  114. # 按长度降序匹配,避免短后缀先匹配导致长后缀残留
  115. entity_suffixes = [
  116. "股份有限公司", "有限责任公司",
  117. "股份公司", "有限公司",
  118. "集团公司", "总公司",
  119. "公司",
  120. ]
  121. v2 = name
  122. for suffix in entity_suffixes:
  123. if v2.endswith(suffix):
  124. v2 = v2[:-len(suffix)]
  125. break
  126. if v2 != name and v2 not in variations:
  127. variations.append(v2)
  128. if len(variations) > max_variations:
  129. return variations[:max_variations + 1]
  130. # ── Level 3: 进一步去除行业/类型词汇 ──
  131. industry_words = ["煤炭", "煤业", "矿业", "煤矿", "煤电", "煤化"]
  132. v3 = v2
  133. for w in industry_words:
  134. v3 = v3.replace(w, "")
  135. v3 = v3.strip()
  136. if v3 and v3 != name and v3 not in variations:
  137. variations.append(v3)
  138. return variations[:max_variations + 1]
  139. def _has_data(result: str) -> bool:
  140. """检测 MCP 返回结果是否包含有效数据(非错误、非空)。
  141. 判断依据:
  142. - 空字符串/纯空白 → 无数据
  143. - 包含 {"error": ...} → 无数据(MCP 调用异常)
  144. - JSON 对象所有值均为空/None/[] → 无数据
  145. - JSON 数组为空 → 无数据
  146. - 有效 JSON 且有实质内容 → 有数据
  147. - 非 JSON 长文本 → 可能有数据(保守判定为有数据)
  148. Args:
  149. result: MCP 工具返回的原始字符串
  150. Returns:
  151. bool: True 表示结果中包含有效数据
  152. """
  153. if not result or not result.strip():
  154. return False
  155. stripped = result.strip()
  156. try:
  157. data = json.loads(stripped)
  158. except (json.JSONDecodeError, TypeError):
  159. # 非 JSON 响应:若长度 > 50 字符则保守判定为有数据
  160. return len(stripped) > 50
  161. # 显式错误响应
  162. if isinstance(data, dict):
  163. if data.get("error"):
  164. return False
  165. # 检查是否有任何实质内容
  166. has_content = any(
  167. v is not None and v != [] and v != {} and v != ""
  168. for v in data.values()
  169. )
  170. return has_content
  171. if isinstance(data, list):
  172. return len(data) > 0
  173. # 其他类型(字符串、数字等)视为有数据
  174. return True
  175. async def _query_mine_with_retry(
  176. tool_name: str,
  177. mine_name: str,
  178. extra_args: dict = None,
  179. max_retries: int = 3,
  180. ) -> str:
  181. """带名称放宽重试的 MCP 查询包装器。
  182. 首次使用原始名称查询;若返回空结果,则逐步放宽煤矿名称并重试,
  183. 最多重试 max_retries 次(即总共最多查询 1 + max_retries 次)。
  184. Args:
  185. tool_name: MCP 工具名称
  186. mine_name: 煤矿名称
  187. extra_args: 除 mine_name 外的额外参数(如 plan_month, year 等)
  188. max_retries: 最大重试次数,默认 3
  189. Returns:
  190. str: MCP 工具返回的 JSON 字符串(首次查到数据即返回)
  191. """
  192. names_to_try = _generate_name_variations(mine_name, max_retries)
  193. last_result = None
  194. for i, name in enumerate(names_to_try):
  195. args = {"mine_name": name}
  196. if extra_args:
  197. args.update(extra_args)
  198. if i == 0:
  199. print(f"[MCP Retry] 首次查询,mine_name='{name}'")
  200. else:
  201. print(f"[MCP Retry] 第 {i} 次重试,放宽名称: '{name}'")
  202. result = await _call_mcp_tool(tool_name, args)
  203. last_result = result
  204. if _has_data(result):
  205. if i > 0:
  206. print(f"[MCP Retry] ✓ 重试成功!使用放宽名称 '{name}' 查到数据")
  207. return result
  208. else:
  209. print(f"[MCP Retry] ✗ 名称 '{name}' 未查到数据,"
  210. f"结果预览: {str(result)[:150]}")
  211. # 所有重试均未查到数据
  212. print(f"[MCP Retry] {len(names_to_try)} 次尝试均未查到有效数据,返回最后一次结果")
  213. return last_result if last_result else json.dumps(
  214. {"error": f"查询无数据: 已尝试 {len(names_to_try)} 个名称变体,均未匹配到数据",
  215. "names_tried": names_to_try},
  216. ensure_ascii=False,
  217. )
  218. async def query_mining_plan(mine_name: str, plan_month: str) -> str:
  219. """查询煤矿月度采掘计划。
  220. 调用远程 MCP 工具 query_monthly_mining_plan,按照煤矿名称模糊匹配 +
  221. 计划月份精确查询,返回:
  222. - 主表信息(矿井基本信息、计划编制日期等)
  223. - 生产准备任务列表
  224. - 巷道掘进明细列表(含巷道名称、设计长度、支护方式等)
  225. 用于配风计划审查「用风地点完整性」检查:比对采掘计划中的工作面清单
  226. 与配风计划中列出的用风地点是否一致。
  227. 内置名称放宽重试:若首次查询无数据,依次尝试去除企业后缀、行业词汇后重试(最多 3 次)。
  228. Args:
  229. mine_name: 煤矿企业名称(模糊匹配),如 "合阳县金桥煤炭有限公司"
  230. plan_month: 计划月份,格式 yyyy-M 或 yyyy-MM,如 "2026-6" 或 "2026-06"(自动规范化为 yyyy-MM)
  231. Returns:
  232. JSON格式的采掘计划数据
  233. """
  234. plan_month = _normalize_plan_month(plan_month)
  235. return await _query_mine_with_retry(
  236. "query_monthly_mining_plan",
  237. mine_name,
  238. extra_args={"plan_month": plan_month},
  239. )
  240. async def query_face_procedure(mine_name: str) -> str:
  241. """查询工作面作业规程。
  242. 调用远程 MCP 工具 query_working_face_procedure,按照矿井名称模糊匹配,
  243. 返回最近创建的一条作业规程记录(按 create_time 降序取第一条)。
  244. 包含工作面参数:最大控顶距、最小控顶距、平均采高、工作面长度等。
  245. 用于配风计划审查「工作面参数一致性」检查:比对作业规程中的参数
  246. 与配风计划中该地点列出的参数是否一致。
  247. 内置名称放宽重试:若首次查询无数据,依次尝试去除企业后缀、行业词汇后重试(最多 3 次)。
  248. Args:
  249. mine_name: 矿井名称(模糊匹配),如 "合阳县金桥煤炭有限公司"
  250. Returns:
  251. JSON格式的作业规程数据
  252. """
  253. return await _query_mine_with_retry(
  254. "query_working_face_procedure",
  255. mine_name,
  256. )
  257. async def query_gas_report(mine_name: str, year: int) -> str:
  258. """查询瓦斯等级鉴定报告。
  259. 调用远程 MCP 工具 query_gas_identification_report,按照矿井名称模糊匹配 +
  260. 鉴定年度精确查询,关联查询:
  261. - 主表:矿井瓦斯等级、鉴定日期、审批意见等
  262. - 明细表:各工作面瓦斯参数(绝对瓦斯涌出量、相对瓦斯涌出量、
  263. 绝对二氧化碳涌出量、相对二氧化碳涌出量等)
  264. 用于配风计划审查「瓦斯与二氧化碳数据一致性」检查:
  265. 校验配风计划中各用风地点的瓦斯/CO2 涌出量是否与鉴定报告一致。
  266. 内置名称放宽重试:若首次查询无数据,依次尝试去除企业后缀、行业词汇后重试(最多 3 次)。
  267. Args:
  268. mine_name: 矿井名称(模糊匹配),如 "合阳县金桥煤炭有限公司"
  269. year: 鉴定年度(精确匹配),如 2026
  270. Returns:
  271. JSON格式的瓦斯等级鉴定数据
  272. """
  273. # 确保 year 是 int 类型
  274. year_int = int(year) if not isinstance(year, int) else year
  275. return await _query_mine_with_retry(
  276. "query_gas_identification_report",
  277. mine_name,
  278. extra_args={"year": year_int},
  279. )
  280. async def query_wind_report(mine_name: str, report_month: str) -> str:
  281. """查询测风报表。
  282. 调用远程 MCP 工具 query_ventilation_report,按照煤矿名称模糊匹配 +
  283. 报表月份精确查询,关联查询:
  284. - 主表:报表基本信息(编制日期、审核人等)
  285. - 风井风机表:各风井风机运行参数
  286. - 测点表:各测风点数据(风速、风量、温度、瓦斯浓度等)
  287. 用于配风计划审查「风速与温度数据匹配」检查:
  288. 根据当月温度监测数据,核验配风计划中温度系数选择是否合理。
  289. 内置名称放宽重试:若首次查询无数据,依次尝试去除企业后缀、行业词汇后重试(最多 3 次)。
  290. Args:
  291. mine_name: 煤矿名称(模糊匹配),如 "合阳县金桥煤炭有限公司"
  292. report_month: 报表月份,格式 yyyy-MM,如 "2026-06"(自动规范化为 yyyy-MM)
  293. Returns:
  294. JSON格式的测风报表数据
  295. """
  296. report_month = _normalize_plan_month(report_month)
  297. return await _query_mine_with_retry(
  298. "query_ventilation_report",
  299. mine_name,
  300. extra_args={"report_month": report_month},
  301. )