batch_tools.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. """矿级 Agent 的发现提交、批量取数与受控报告复用。"""
  2. import asyncio
  3. import json
  4. import httpx
  5. from langchain.tools import ToolRuntime
  6. from langchain_core.messages import ToolMessage
  7. from langgraph.types import Command
  8. from db import main_fan_store
  9. from extensions import define_tool
  10. from extensions.main_fan import exploration, history, report_archive
  11. from extensions.main_fan.batch_context import scope_for
  12. from extensions.main_fan.batch_models import MinePlan
  13. from extensions.main_fan.exploration_tools import _action, _identity, StringList, WorkspacePath
  14. from extensions.main_fan.schemas import MainFanResult
  15. from extensions.main_fan.source_policy import validate_history_sources
  16. from runtime.python_sandbox import workspace_file
  17. def checked_plan(workflow, user, session, plan):
  18. known = {}
  19. context = workflow['context']
  20. for system, collection in (('safety', 'sensor_queries'), ('equipment', 'equipment_queries')):
  21. queries = context.get(collection, [])
  22. if not queries:
  23. raise ValueError('矿级发现须查询安全监控与重大设备两个实时列表,保留各系统可用情况')
  24. if any(row.get('next_page') and not any(other.get('page_num') == row['next_page'] for other in queries)
  25. for row in queries):
  26. raise ValueError('实时列表还有未获取分页,请取齐后再提交矿级计划')
  27. for query in queries:
  28. body = json.loads(workspace_file(user, session, query['data_path']).read_text())['payload']
  29. rows = body.get('rows') if isinstance(body, dict) else body
  30. if rows is None and isinstance(body, dict):
  31. data = body.get('data')
  32. rows = data.get('rows') if isinstance(data, dict) else data
  33. for row in rows or []:
  34. if isinstance(row, dict) and row.get('nodeId'):
  35. known.setdefault((system, str(row['nodeId'])), []).append(row)
  36. for shaft in plan.shafts:
  37. if shaft.identity_status == 'confirmed' and not any(
  38. shaft.shaft_name in str(value)
  39. for rows in known.values() for row in rows for value in row.values() if isinstance(value, str)):
  40. raise ValueError('已确认通风井的名称须能在本矿实际资料中找到原文。仅有1#、2#主通风机不能确认井名;请保留一个unresolved-main主备系统(通风井归属待确认)。')
  41. if shaft.identity_status == 'candidate' and (not shaft.shaft_id.startswith('unresolved-')
  42. or not any(word in shaft.shaft_name for word in ('待确认', '候选'))):
  43. raise ValueError('未确认通风井时请使用unresolved-内部标识和带“待确认”的名称;风机号不能当作通风井号')
  44. for point in shaft.points:
  45. if (point.system, point.node_id) not in known:
  46. raise ValueError('计划包含未从本矿实际列表取得的测点:' + point.node_id)
  47. rows = known[(point.system, point.node_id)]
  48. if point.role == 'flow':
  49. valid = any('风量' in str(row.get('nodeTypeName', row.get('sensorName', ''))) for row in rows)
  50. elif point.system == 'safety':
  51. valid = any(str(row.get('sensorTypeId', row.get('SensorTypeID', ''))) == '1010' for row in rows)
  52. else:
  53. valid = any(any(word in str(row.get('nodeTypeName', '')) for word in ('运行状态', '开停', '启停')) for row in rows)
  54. if not valid:
  55. raise ValueError('测点实际类型与声明用途不符:' + point.node_id + '。只选主通风机开停/运行状态与风量,电流、振动、温度等不能声明为fan_state。')
  56. validate_history_sources(shaft.points)
  57. return plan
  58. @define_tool(group='主通风机', label='提交矿级通风井与测点计划')
  59. @_action
  60. async def submit_main_fan_mine_plan(workflow_id: str, plan: MinePlan, runtime: ToolRuntime):
  61. """仅本地批次发现阶段:取得本矿两个实时列表及分页后,提交全部通风井和历史测点计划并结束本轮。
  62. plan含coverage(confirmed/partial)、shafts、limitations。每井含shaft_id(内部安全标识)、shaft_name、
  63. identity_status(confirmed/candidate)、identity_reason、fan_pair(最多两个设备标识)、points及limitations。
  64. 每个point含system(safety/equipment)、node_id(实际列表编号)、role(fan_state/flow)、fan_id、label、
  65. unit、mapping_confirmed、mapping_reason。fan_state只用safety,flow只用equipment;重大设备运行状态不入计划。
  66. 一级/二级须分别保留,不默认等于整机;不明通风井可用
  67. candidate及内部标识unresolved-main,不编造正式井号。所有测点须来自本轮本矿实际返回。
  68. """
  69. _, user, session = _identity(runtime)
  70. scope = scope_for(user, session)
  71. if scope.shaft is not None:
  72. raise ValueError('当前已是通风井分析阶段,请提交井级报告')
  73. workflow = exploration._load(workflow_id, user, session)
  74. exploration.validate_inputs(workflow, user, session)
  75. plan = checked_plan(workflow, user, session, plan)
  76. value = {'status': 'needs_materials', 'mine_code': scope.mine['mine_id'],
  77. 'mine_name': scope.mine['mine_name'], 'result_title': '矿级通风井与测点发现', 'check_object': '主通风机测点目录',
  78. 'result': '已保存本矿实际测点与通风井计划,等待逐井分析。',
  79. 'workflow_id': workflow_id, 'assessment': {'mode': 'mine_discovery', 'plan': plan.model_dump(mode='json')}}
  80. value = main_fan_store.save_analysis(user, session, value)
  81. value = MainFanResult.model_validate(value).model_dump(mode='json')
  82. main_fan_store.update_workflow(workflow_id, result=value, status='completed')
  83. return Command(update={'structured_response': value, 'messages': [ToolMessage(
  84. content=json.dumps({'ok': True, 'shaft_count': len(plan.shafts), 'message': '计划已保存,宿主将继续派发本矿逐井分析。'}, ensure_ascii=False),
  85. tool_call_id=runtime.tool_call_id, name='submit_main_fan_mine_plan')]})
  86. @define_tool(group='主通风机', label='按天回查全部开停和风量历史')
  87. @_action
  88. async def query_main_fan_history(workflow_id: str, runtime: ToolRuntime) -> dict:
  89. """对宿主确认的本井计划测点,从批次截止时刻向前按天回查40天。一次工具调用完成传输与归档。
  90. 每个日窗口的请求、响应及完整数据压缩为一个ZIP;先读agent_data_path里的观测状态区间、
  91. 变化边界、缺口和风量计数。不把一级/二级直接当整机。需要复核变化或计算风量窗口时,
  92. 自编Python按manifest的archive.path及members读取ZIP中的对应段,避免打印全部采样。
  93. 开停仅查安全监控,重大设备仅查风量;均查满,失败段再次调用可补取,成功段不重复请求。
  94. """
  95. _, user, session = _identity(runtime)
  96. return await history.query(workflow_id, user, session)
  97. @define_tool(group='主通风机', label='获取测定PDF及可复用报告档案')
  98. @_action
  99. async def fetch_main_fan_reports(workflow_id: str, runtime: ToolRuntime) -> dict:
  100. """按本井通过报告接口查询并下载PDF,计算实际SHA256,取得同矿同井的成功历史提取档案。
  101. mode=analyze须按Skill完成全页提取;mode=reuse读取archive_python_path中的facts,不再渲染识别。
  102. is_mock=true表示既有测试原件,须读取原件真实矿名并核对目标归属,不得把测试报告改写为其他矿的正式报告。
  103. """
  104. _, user, session = _identity(runtime)
  105. try:
  106. return await report_archive.fetch(workflow_id, user, session)
  107. except httpx.HTTPError as exc:
  108. raise ValueError('报告接口查询或下载失败:' + type(exc).__name__) from None
  109. @define_tool(group='主通风机', label='保存已完整提取的报告档案')
  110. @_action
  111. async def archive_main_fan_report(workflow_id: str, facts_path: WorkspacePath,
  112. python_run_ids: StringList, runtime: ToolRuntime) -> dict:
  113. """保存成功沙箱输出,供下次相同报告复用;调用后仍继续当期时序分析,不结束Agent。
  114. facts_path为JSON,字段严格为source_sha256、page_count、read_pages(完整1..N)、
  115. page_sources(物理页码到各页独立图片/文本路径,必须有实际完整read_file记录)、
  116. reports(每份报告的真实身份、原页、参数及性能事实,合订文件不得漏后面的报告)、analysis、limitations。
  117. 必须真实读完全部页,不能用空对象冒充提取。首次解析完成后即保存;当期历史分析失败不影响已成功的报告档案。
  118. """
  119. _, user, session = _identity(runtime)
  120. return await asyncio.to_thread(report_archive.save, workflow_id, user, session, facts_path, python_run_ids)