vent_comparison_tools.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. """
  2. 需风量对照与检查工具集
  3. 工具1: get_plan_required_wind — 获取配风计划需风量
  4. 工具2: get_model_calculated_wind — 获取模型解算风量
  5. 工具3: get_sensor_wind — 获取监测风量
  6. 工具4: merge_wind_by_location — 使用LLM按名称关联合并风量数据
  7. 工具5: check_wind_compliance — 风量合规规则检查
  8. """
  9. import json
  10. import os
  11. import logging
  12. from typing import Annotated, Any
  13. from langchain.tools import tool, InjectedState
  14. from langchain.chat_models import init_chat_model
  15. from openai import OpenAI
  16. from service.data_service import VentDataService
  17. from config.settings import JSON_CACHE_DIR, BASE_URL, LLM_API_KEY, LLM_BASE_URL, LLM_MODEL
  18. from service.auth import AuthService
  19. import requests
  20. logging.basicConfig(level=logging.INFO, format="【%(name)s】%(message)s")
  21. logger = logging.getLogger("VentComparisonTool")
  22. # 模块级存储,用于同一个 Agent 调用链中工具间传递中间结果
  23. _store: dict = {}
  24. # 独立初始化 LLM model(避免循环导入)
  25. # _merge_model = init_chat_model(
  26. # model=LLM_MODEL,
  27. # api_key=LLM_API_KEY,
  28. # base_url=LLM_BASE_URL,
  29. # temperature=0,
  30. # streaming=True,
  31. # model_provider="openai",
  32. # extra_body={"enable_thinking": False},
  33. # timeout=300
  34. # )
  35. client = OpenAI(
  36. api_key=os.getenv("LLM_API_KEY"),
  37. base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
  38. )
  39. MERGED_MODEL = "qwen3.7-max"
  40. # ============================================================
  41. # 工具1: 获取配风计划需风量
  42. # ============================================================
  43. @tool
  44. def get_plan_required_wind(
  45. state: Annotated[dict, InjectedState]
  46. ) -> str:
  47. """
  48. 从用户上传的配风计划文件中提取各用风地点的需风量数据。
  49. 自动读取解析缓存(基于文件hashID),若未解析则先解析再缓存。
  50. 提取字段:
  51. - coal_faces: face_name → q_max
  52. - tunneling_faces: face_name → q_max
  53. - chambers: name → qd
  54. - other_points: name → Qo 或其他风量字段
  55. 返回统一列表 [{"plan_name":"地点名","plan_q":需风量值}]
  56. """
  57. vent_plan = state.get("vent_plan_data", {})
  58. if not vent_plan:
  59. return json.dumps({"error": "未读取到配风计划数据,请先上传PDF文件"}, ensure_ascii=False)
  60. logger.info("===== 开始提取配风计划需风量 =====")
  61. plan_list = []
  62. # 采煤工作面
  63. coal_faces = vent_plan.get("coal_faces", [])
  64. for face in coal_faces:
  65. name = face.get("face_name", "")
  66. q_max = face.get("q_max", 0)
  67. if name:
  68. plan_list.append({"plan_name": name, "plan_q": q_max if q_max else 0})
  69. logger.info(f" 采煤工作面: {name} -> 需风量 {q_max}")
  70. # 掘进工作面
  71. tunneling_faces = vent_plan.get("tunneling_faces", [])
  72. for face in tunneling_faces:
  73. name = face.get("face_name", "")
  74. q_max = face.get("q_max", 0)
  75. if name:
  76. plan_list.append({"plan_name": name, "plan_q": q_max if q_max else 0})
  77. logger.info(f" 掘进工作面: {name} -> 需风量 {q_max}")
  78. # 硐室
  79. chambers = vent_plan.get("chambers", [])
  80. for chamber in chambers:
  81. name = chamber.get("name", "")
  82. qd = chamber.get("qd", 0)
  83. if name:
  84. plan_list.append({"plan_name": name, "plan_q": qd if qd else 0})
  85. logger.info(f" 硐室: {name} -> 需风量 {qd}")
  86. # 其他用风地点
  87. other_points = vent_plan.get("other_points", [])
  88. for point in other_points:
  89. name = point.get("name", "")
  90. q_val = point.get("Qo") or point.get("q_max") or 0
  91. if name:
  92. plan_list.append({"plan_name": name, "plan_q": q_val if q_val else 0})
  93. logger.info(f" 其他地点: {name} -> 需风量 {q_val}")
  94. logger.info(f"===== 提取完成,共 {len(plan_list)} 个地点 =====")
  95. result = {"plan_list": plan_list, "count": len(plan_list)}
  96. # 写入模块级存储供后续工具使用
  97. _store["plan_wind_result"] = result
  98. logger.info(f"===== 提取完成,共 {len(plan_list)} 个地点 =====")
  99. return json.dumps(result, ensure_ascii=False)
  100. # ============================================================
  101. # 工具2: 获取模型解算风量
  102. # ============================================================
  103. @tool
  104. def get_model_calculated_wind() -> str:
  105. """
  106. 获取通风模型解算风量数据。
  107. 通过 GET /Vmodel/agent/get/model/wind/{model_id} 接口拉取。
  108. 返回所有地点的模型解算风量列表。
  109. 获取成功后自动将结果存入模块级存储供后续工具使用。
  110. """
  111. logger.info("===== 开始获取模型解算风量 =====")
  112. data_service = VentDataService()
  113. default_model_id = data_service._get_default_model_id()
  114. if not default_model_id:
  115. logger.warning("无法获取默认模型ID")
  116. return json.dumps({"error": "无法获取默认模型ID,请确认通风系统已配置默认模型"}, ensure_ascii=False)
  117. logger.info(f"默认模型ID: {default_model_id}")
  118. try:
  119. url = f"{BASE_URL}/Vmodel/agent/get/model/wind/{default_model_id}"
  120. headers = {"X-Access-Token": AuthService.get_token()} if AuthService.get_token() else {}
  121. logger.info(f"请求地址: {url}")
  122. resp = requests.get(url, headers=headers, timeout=30)
  123. resp.raise_for_status()
  124. data = resp.json()
  125. if data.get("success"):
  126. model_list = data.get("result", [])
  127. formatted = []
  128. for item in model_list:
  129. formatted.append({
  130. "name": item.get("name", ""),
  131. "fq": item.get("fq", 0)
  132. })
  133. logger.info(f"获取到 {len(formatted)} 条模型解算风量数据")
  134. result = {"model_list": formatted, "count": len(formatted)}
  135. _store["model_wind_result"] = result
  136. return json.dumps(result, ensure_ascii=False)
  137. else:
  138. logger.warning(f"接口返回失败: {data.get('message', '')}")
  139. return json.dumps({"error": f"获取模型解算风量失败: {data.get('message', '')}"}, ensure_ascii=False)
  140. except Exception as e:
  141. logger.error(f"获取模型解算风量异常: {str(e)}")
  142. return json.dumps({"error": f"获取模型解算风量异常: {str(e)}"}, ensure_ascii=False)
  143. # ============================================================
  144. # 工具3: 获取监测风量
  145. # ============================================================
  146. @tool
  147. def get_sensor_wind() -> str:
  148. """
  149. 获取传感器监测风量数据。
  150. 通过 GET /Vmodel/agent/get/sensor/wind/{model_id} 接口拉取。
  151. 返回所有传感器的监测风量列表。
  152. 获取成功后自动将结果存入模块级存储供后续工具使用。
  153. """
  154. logger.info("===== 开始获取传感器监测风量 =====")
  155. data_service = VentDataService()
  156. default_model_id = data_service._get_default_model_id()
  157. if not default_model_id:
  158. logger.warning("无法获取默认模型ID")
  159. return json.dumps({"error": "无法获取默认模型ID,请确认通风系统已配置默认模型"}, ensure_ascii=False)
  160. logger.info(f"默认模型ID: {default_model_id}")
  161. try:
  162. url = f"{BASE_URL}/Vmodel/agent/get/sensor/wind/{default_model_id}"
  163. headers = {"X-Access-Token": AuthService.get_token()} if AuthService.get_token() else {}
  164. logger.info(f"请求地址: {url}")
  165. resp = requests.get(url, headers=headers, timeout=30)
  166. resp.raise_for_status()
  167. data = resp.json()
  168. if data.get("success"):
  169. sensor_list = data.get("result", [])
  170. formatted = []
  171. for item in sensor_list:
  172. formatted.append({
  173. "tunName": item.get("tunName", ""),
  174. "location": item.get("location", ""),
  175. "fq": item.get("fq", 0)
  176. })
  177. logger.info(f"获取到 {len(formatted)} 条监测风量数据")
  178. result = {"sensor_list": formatted, "count": len(formatted)}
  179. _store["sensor_wind_result"] = result
  180. return json.dumps(result, ensure_ascii=False)
  181. else:
  182. logger.warning(f"接口返回失败: {data.get('message', '')}")
  183. return json.dumps({"error": f"获取监测风量失败: {data.get('message', '')}"}, ensure_ascii=False)
  184. except Exception as e:
  185. logger.error(f"获取监测风量异常: {str(e)}")
  186. return json.dumps({"error": f"获取监测风量异常: {str(e)}"}, ensure_ascii=False)
  187. # ============================================================
  188. # 工具4: LLM 名称关联合并风量数据
  189. # ============================================================
  190. @tool
  191. def merge_wind_by_location() -> str:
  192. """
  193. 使用大模型的语义理解能力,将配风计划、模型解算、传感器监测三份数据
  194. 按地点名称进行语义关联与合并。
  195. 从前序工具自动存入的模块级存储中读取数据
  196. 合并规则:
  197. - 以配风计划中的地点为主(plan_name / plan_q)
  198. - 从模型解算数据中匹配对应地点的 model_fq
  199. - 从传感器监测数据中匹配对应地点的 wind_fq
  200. - 忽略两个外部接口中无法匹配到配风计划的冗余地点
  201. 返回:合并后的JSON数组。
  202. """
  203. logger.info("===== 开始LLM名称关联合并风量数据 =====")
  204. store = _store
  205. plan_data = store.get("plan_wind_result")
  206. model_data = store.get("model_wind_result")
  207. sensor_data = store.get("sensor_wind_result")
  208. # 校验数据完整性
  209. missing = []
  210. if not plan_data:
  211. missing.append("配风计划需风量(plan_wind_result)")
  212. if not model_data:
  213. missing.append("模型解算风量(model_wind_result)")
  214. if not sensor_data:
  215. missing.append("监测风量(sensor_wind_result)")
  216. if missing:
  217. return json.dumps({"error": f"缺少数据源: {', '.join(missing)},请确保前序工具已执行并保存结果"}, ensure_ascii=False)
  218. plan_data_json = json.dumps(plan_data, ensure_ascii=False)
  219. model_data_json = json.dumps(model_data, ensure_ascii=False)
  220. sensor_data_json = json.dumps(sensor_data, ensure_ascii=False)
  221. prompt = f"""你是一个煤矿通风专业的数据关联专家。请将以下三份数据按地点名称进行语义关联与合并。
  222. 关联规则:
  223. 1. 以配风计划需风量中的地点为基准(plan_name),这是核心列表
  224. 2. 从模型解算风量中找出名称语义最相似的地点,提取其 fq 作为 model_fq
  225. 3. 从监测风量中找出名称语义最相似的地点,提取其 fq 作为 wind_fq。监测风量中的 tunName 比 location 更具参考价值
  226. 4. 如果某个配风计划地点在模型解算/监测中找不到对应项,则对应字段填 null
  227. 5. 忽略模型解算和监测中无法与任何配风计划地点关联的冗余数据
  228. 6. 名称匹配需要语义理解,例如:22210综采工作面 约等于 22210 综采工作面 约等于 22210工作面
  229. ============================
  230. 配风计划需风量:
  231. {plan_data_json}
  232. 模型解算风量:
  233. {model_data_json}
  234. 监测风量:
  235. {sensor_data_json}
  236. ============================
  237. 请输出严格JSON数组(不要包含任何解释文字),格式如下:
  238. [{{
  239. "location": "地点名称",
  240. "plan_fq": 数值,
  241. "model_fq": 数值或null,
  242. "wind_fq": 数值或null
  243. }}]
  244. 注意:
  245. - plan_fq 使用配风计划中的 plan_q 值
  246. - model_fq 使用模型解算中的 fq 值,找不到则为 null
  247. - wind_fq 使用监测风量中的 fq 值,找不到则为 null
  248. - 只输出JSON数组,开头不要包含```json标记,结尾不要包含```标记
  249. """
  250. try:
  251. messages = [{"role": "user", "content": prompt}]
  252. completion = client.chat.completions.create(
  253. model=MERGED_MODEL,
  254. messages=messages,
  255. extra_body={"enable_thinking": False},
  256. stream=False,
  257. )
  258. result_text = completion.choices[0].message.content
  259. # 清洗可能的markdown标记
  260. if result_text.startswith("```"):
  261. lines = result_text.split("\n")
  262. if lines[0].startswith("```"):
  263. lines = lines[1:]
  264. if lines and lines[-1].startswith("```"):
  265. lines = lines[:-1]
  266. result_text = "\n".join(lines)
  267. logger.info(f"LLM合并结果前500字符: {result_text[:500]}")
  268. # 验证是否为有效JSON
  269. merged = json.loads(result_text)
  270. result = {"merged_list": merged, "count": len(merged)}
  271. with open("result_output.json", "w", encoding="utf-8") as f:
  272. json.dump(result, f, ensure_ascii=False, indent=2)
  273. _store["merged_wind_result"] = result
  274. return json.dumps(result, ensure_ascii=False)
  275. except json.JSONDecodeError as e:
  276. logger.error(f"LLM返回非JSON格式: {result_text[:300]}")
  277. return json.dumps({"error": f"LLM合并结果格式异常: {str(e)}", "raw": result_text[:500]}, ensure_ascii=False)
  278. except Exception as e:
  279. logger.error(f"LLM合并异常: {str(e)}")
  280. return json.dumps({"error": f"LLM合并异常: {str(e)}"}, ensure_ascii=False)
  281. # ============================================================
  282. # 工具5: 风量合规规则检查
  283. # ============================================================
  284. @tool
  285. def check_wind_compliance() -> str:
  286. """
  287. 从模块级存储中读取合并后的风量数据进行合规性规则检查。
  288. 检查规则:
  289. 1. 每个地点:解算风量(model_fq) 且 监测风量(wind_fq) >= 需风量(plan_fq)
  290. 2. 每个地点:进风量(取解算风量和监测风量中较大者) > 需风量 * 150% 视为过量
  291. 返回:合规与不合规地点的分类结果及详细分析
  292. """
  293. logger.info("===== 开始风量合规规则检查 =====")
  294. merged_data = _store.get("merged_wind_result")
  295. if not merged_data:
  296. return json.dumps({"error": "未获取到合并后的风量数据,请确保 merge_wind_by_location 已执行"}, ensure_ascii=False)
  297. merged_list = merged_data.get("merged_list", [])
  298. if not merged_list:
  299. return json.dumps({"error": "合并后的风量数据为空"}, ensure_ascii=False)
  300. compliant = []
  301. non_compliant = []
  302. for item in merged_list:
  303. location = item.get("location", "未知地点")
  304. plan_fq = item.get("plan_fq") or 0
  305. model_fq = item.get("model_fq")
  306. wind_fq = item.get("wind_fq")
  307. issues = []
  308. # 规则1:解算风量 且 监测风量 >= 需风量
  309. model_check = True
  310. wind_check = True
  311. if model_fq is not None and model_fq > 0:
  312. if model_fq < plan_fq:
  313. model_check = False
  314. diff = plan_fq - model_fq
  315. issues.append(f"解算风量({model_fq:.1f}) < 需风量({plan_fq:.1f}),差额{diff:.1f}")
  316. elif plan_fq > 0:
  317. model_check = False
  318. issues.append(f"缺少解算风量数据,无法满足需风量({plan_fq:.1f})")
  319. if wind_fq is not None and wind_fq > 0:
  320. if wind_fq < plan_fq:
  321. wind_check = False
  322. diff = plan_fq - wind_fq
  323. issues.append(f"监测风量({wind_fq:.1f}) < 需风量({plan_fq:.1f}),差额{diff:.1f}")
  324. elif plan_fq > 0:
  325. wind_check = False
  326. issues.append(f"缺少监测风量数据,无法满足需风量({plan_fq:.1f})")
  327. # 规则2:进风量 > 需风量 * 150%
  328. over_supply = False
  329. actual_wind = max(
  330. model_fq if model_fq else 0,
  331. wind_fq if wind_fq else 0
  332. )
  333. if plan_fq > 0 and actual_wind > 0:
  334. ratio = actual_wind / plan_fq
  335. if ratio > 1.5:
  336. over_supply = True
  337. issues.append(f"进风量({actual_wind:.1f}) > 需风量*150%({plan_fq * 1.5:.1f}),供风过量{ratio*100:.0f}%")
  338. entry = {
  339. "location": location,
  340. "plan_fq": plan_fq,
  341. "model_fq": model_fq,
  342. "wind_fq": wind_fq,
  343. "issues": issues,
  344. "model_check": model_check,
  345. "wind_check": wind_check,
  346. "over_supply": over_supply,
  347. "is_compliant": len(issues) == 0
  348. }
  349. if entry["is_compliant"]:
  350. compliant.append(entry)
  351. else:
  352. non_compliant.append(entry)
  353. logger.info(f"合规: {len(compliant)}, 不合规: {len(non_compliant)}")
  354. total = len(merged_list)
  355. compliant_count = len(compliant)
  356. result = {
  357. "compliant": compliant,
  358. "non_compliant": non_compliant,
  359. "summary": {
  360. "total": total,
  361. "compliant_count": compliant_count,
  362. "non_compliant_count": len(non_compliant),
  363. "compliance_rate": f"{compliant_count / total * 100:.1f}%" if total else "0%"
  364. }
  365. }
  366. return json.dumps(result, ensure_ascii=False, indent=2)
  367. if __name__ == '__main__':
  368. # 本地调试代码
  369. print("vent_comparison_tools 加载成功")