exploration_tools.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. """业务只保留网关取数;准备、沙箱执行和提交是宿主基础设施。"""
  2. import asyncio
  3. from functools import wraps
  4. import inspect
  5. import json
  6. import math
  7. import subprocess
  8. from typing import Annotated, Any
  9. from weakref import WeakValueDictionary
  10. from langchain.tools import ToolRuntime
  11. from langchain_core.messages import ToolMessage
  12. from langgraph.types import Command
  13. from pydantic import BeforeValidator, ValidationError
  14. from extensions import define_tool
  15. from extensions.main_fan import equipment, exploration, realtime, timeseries
  16. from extensions.shaanxi.client import GatewayError
  17. from runtime import python_sandbox
  18. _locks = WeakValueDictionary()
  19. def _list_argument(value):
  20. # 部分兼容模型把数组参数再次编码成 JSON 字符串;保持工具 schema 为数组,容忍这层编码。
  21. if isinstance(value, str):
  22. value = json.loads(value)
  23. return value
  24. def _workspace_argument(value):
  25. if isinstance(value, str) and value and not value.startswith('/'):
  26. return '/workspace/' + value.removeprefix('./')
  27. return value
  28. WorkspacePath = Annotated[str, BeforeValidator(_workspace_argument)]
  29. WorkspacePaths = Annotated[list[WorkspacePath], BeforeValidator(_list_argument)]
  30. StringList = Annotated[list[str], BeforeValidator(_list_argument)]
  31. def _identity(runtime):
  32. identity = runtime.config.get('configurable', {})
  33. if not identity.get('workspace_user') or not identity.get('workspace_session'):
  34. raise ValueError('探索工具缺少服务端会话身份')
  35. return identity, identity['workspace_user'], identity['workspace_session']
  36. def _action(function):
  37. """同会话串行写入;可纠正的交付错误交还 Agent 继续探索。"""
  38. signature = inspect.signature(function)
  39. @wraps(function)
  40. async def wrapped(*args, **kwargs):
  41. try:
  42. runtime = signature.bind(*args, **kwargs).arguments['runtime']
  43. _, user, session = _identity(runtime)
  44. key = (user, session)
  45. lock = _locks.get(key)
  46. if lock is None:
  47. lock = _locks[key] = asyncio.Lock()
  48. async with lock:
  49. return await function(*args, **kwargs)
  50. except (ValueError, OSError, subprocess.SubprocessError, GatewayError) as exc:
  51. detail = (json.dumps(exc.errors(include_input=False, include_url=False), ensure_ascii=False, default=str)
  52. if isinstance(exc, ValidationError) else str(exc))
  53. result = {'ok': False, 'error_type': type(exc).__name__, 'message': detail[:3000],
  54. 'next_action': '读取反馈、修正代码或补取原始数据后重试;不能把本次失败写成已完成。'}
  55. if isinstance(exc, GatewayError):
  56. result.update(retryable=exc.retryable, retry_after=exc.retry_after)
  57. return result
  58. return wrapped
  59. @define_tool(group='自主探索', label='准备任务与可信输入')
  60. @_action
  61. async def prepare_main_fan_task(target: dict[str, Any] | str, runtime: ToolRuntime) -> dict:
  62. """绑定任务身份,导出本轮原始输入、原件路径、规则、身份和交付 schema;不解析文档或计算。
  63. target 传用户原目标或明确给出的输入。队列任务使用服务端已校验输入,不自行重造确认或时序。
  64. 后续文档解析、数值分析和报告生成均由你按 Skill 写 Python,在沙箱中执行。
  65. """
  66. identity, user, session = _identity(runtime)
  67. trusted = identity.get('business_task_input') if identity.get('business_agent') == 'main_fan' else None
  68. return await asyncio.to_thread(exploration.prepare, target, user, session, trusted,
  69. identity.get('business_task_id'))
  70. @define_tool(group='主通风机', label='取得本次网关资料')
  71. @_action
  72. async def fetch_main_fan_data(workflow_id: str, runtime: ToolRuntime) -> dict:
  73. """use_gateway=true 时取得已配置网关的设备、原件和时序,更新 input.json/data.json;不解析或分析。"""
  74. _, user, session = _identity(runtime)
  75. return await exploration.fetch(workflow_id, user, session)
  76. @define_tool(group='主通风机', label='按测点和时间查询历史时序')
  77. @_action
  78. async def query_fan_timeseries(workflow_id: str, node_id: str, dept_id: str,
  79. start_time: str, end_time: str, runtime: ToolRuntime) -> dict:
  80. """查询主通风机相关测点的历史曲线,保存完整原始响应和规范化 JSON,不计算切换或研判结论。
  81. 先 prepare_main_fan_task,使用它返回的 workflow_id。node_id、dept_id 必须来自用户或真实
  82. 测点目录,不猜编号或设备归属。单次查一个点,可多次调用查询两台风机的不同点或时间窗口。
  83. 时间支持 2026-10-19 11:00:00 或带时区的 ISO 格式;无时区按 Asia/Shanghai,精确到秒,
  84. end_time 必须晚于 start_time,默认最多24小时。网关凭证和 X-Agent-Code 由宿主配置提供。
  85. 返回 sample_count、实际时间范围、质量计数、少量 preview、data_path、raw_path 和
  86. python_paths;空数据会明确返回0条。Python用python_paths中的相对路径读取完整samples,
  87. 不以preview代替全量分析。文件已加入本轮可信来源,可在研判JSON的evidence中引用。
  88. 此工具返回测点原始读数,一级/二级开停不自动解释为整机状态,单位未知时不补猜。
  89. """
  90. _, user, session = _identity(runtime)
  91. return await timeseries.query(workflow_id, node_id, dept_id, start_time, end_time, user, session)
  92. @define_tool(group='主通风机', label='查询安全监控实时传感器列表')
  93. @_action
  94. async def query_fan_sensor_realtime(workflow_id: str, dept_id: str, runtime: ToolRuntime,
  95. sensor_type_list: StringList | None = None,
  96. page_num: int = 1, page_size: int = 100) -> dict:
  97. """通过 /aqjk/node/getNodeRealtimeDetails 查询本部门传感器目录及当前值,一次查询一页。
  98. 先准备本轮workflow_id;dept_id、类型编码来自用户或真实目录。sensor_type_list可传
  99. ["0005","0013","1001","1010","0001,0011,0043","0002"],宿主按索引构造键值对数组。
  100. 不传类型数组时不添加类型筛选,不把无返回类型解释为未安装。page_num从1开始,默认每页100条。
  101. 返回row_count、total、has_more、next_page、少量preview及完整data_path/raw_path。
  102. 按分页信息继续查询,不能把单页当全部。Python用python_paths.data读取JSON的payload原始字段。
  103. 未识别列表结构时row_count为null,需读取完整响应,不当成空数据。文件可作为报告证据引用。
  104. 安全监控测点与重大设备测点编码体系分别核对,设备归属及单位不凭名称补猜。
  105. """
  106. _, user, session = _identity(runtime)
  107. return await realtime.query_sensors(workflow_id, dept_id, page_num, page_size, sensor_type_list, user, session)
  108. @define_tool(group='主通风机', label='查询主通风机重大设备实时列表')
  109. @_action
  110. async def query_fan_equipment_realtime(workflow_id: str, dept_id: str, runtime: ToolRuntime,
  111. page_num: int = 1, page_size: int = 100) -> dict:
  112. """通过 /zdsb/sbnode/selectSbNodeList 查询主通风机系统重大设备的实时数据,一次查询一页。
  113. 使用准备工具返回的workflow_id与真实dept_id。宿主固定devOfSystem=30,自动补齐鉴权和
  114. 当前业务X-Agent-Code;page_num从1开始,默认page_size=100。返回完整数据文件及分页信息。
  115. 可以从真实返回中查找电机、轴承温度、风量等测点,但不保证一定存在,字段与单位以原文为准。
  116. Python用python_paths.data读取payload,保留设备及其测点关联;preview不能替代全量或全部分页。
  117. 列表结构未知时会明确提示,不能按0条处理。取得重大设备测点编号后,可调用
  118. query_fan_equipment_timeseries查询对应历史,不直接套用安全监控曲线接口的测点编号。
  119. 返回文件是本轮可信输入,可在报告evidence中引用。
  120. """
  121. _, user, session = _identity(runtime)
  122. return await realtime.query_equipment(workflow_id, dept_id, page_num, page_size, user, session)
  123. @define_tool(group='主通风机', label='查询重大设备测点历史时序')
  124. @_action
  125. async def query_fan_equipment_timeseries(workflow_id: str, node_id: str, dept_id: str,
  126. start_time: str, end_time: str, runtime: ToolRuntime) -> dict:
  127. """通过 /zdsb/sbnode/selectSbNodeCurve 查询电机、轴承温度等重大设备测点的历史曲线。
  128. 先准备本轮workflow_id。node_id、dept_id来自用户或重大设备实时列表,不能混用其他部门或
  129. 安全监控测点编号。宿主使用单数nodeId参数,自动补齐凭证和业务身份。
  130. 起止时间支持YYYY-MM-DD HH:MM:SS或带时区ISO时间,无时区按Asia/Shanghai,默认最多24小时。
  131. 原样保存完整上游响应;data_path文件的payload就是原始业务响应。Python使用python_paths.data,
  132. 自行核对实际曲线字段、时间、测点与单位再分析。工具不猜字段、不假设数组项数等于采样点数。
  133. 支持多次查询与报告证据引用;失败反馈ok=false,不伪造空曲线。
  134. """
  135. _, user, session = _identity(runtime)
  136. return await equipment.query_timeseries(workflow_id, node_id, dept_id, start_time, end_time, user, session)
  137. @define_tool(group='自主探索', label='在沙箱执行 Agent 编写的 Python')
  138. @_action
  139. async def run_python(script_path: WorkspacePath, runtime: ToolRuntime, arguments: StringList | None = None,
  140. output_paths: WorkspacePaths | None = None, timeout_seconds: int = 60,
  141. workflow_id: str | None = None) -> dict:
  142. """执行你用 write_file/edit_file 编写的 /workspace/*.py,返回真实 stdout/stderr、退出码和 run_id。
  143. 代码当前目录为本会话 workspace,使用相对路径。可用标准库、numpy、pypdfium2、Pillow、cv2;
  144. 无网络、不传服务端凭证、不支持临时安装依赖。自己编写 PDF 文本提取/渲染、计算及报告生成代码。
  145. 对扫描件可输出 PNG 并用 read_file 看图,之后自行编写提取与分析脚本,不调用旧业务算法。
  146. 必须传 workflow_id 关联本轮交付,声明 output_paths 保存输出哈希;原件与可信输入不要修改。
  147. 报告 Markdown、研判 JSON 和附件都须由成功脚本生成,并在提交时引用相应 run_id。
  148. """
  149. _, user, session = _identity(runtime)
  150. if workflow_id:
  151. workflow = await asyncio.to_thread(exploration._load, workflow_id, user, session)
  152. await asyncio.to_thread(exploration.validate_inputs, workflow, user, session)
  153. receipt = await python_sandbox.run(user, session, script_path, arguments or (), output_paths or (), timeout_seconds)
  154. if workflow_id:
  155. await asyncio.to_thread(exploration.record_python, workflow_id, user, session, receipt)
  156. receipt['python_paths'] = exploration.python_paths(workflow)
  157. if not receipt['ok']:
  158. receipt['next_action'] = '代码执行失败;先按 stderr 最后的异常类型和行号修正代码,再重新运行。'
  159. if 'FileNotFoundError' in receipt['stderr'] or 'PermissionError' in receipt['stderr']:
  160. receipt['next_action'] += ('代码内使用 python_paths 中的相对路径;不要用 /workspace/ 绝对路径,'
  161. '也不要把 exploration/任务ID/input.json 缩成 input.json。')
  162. elif 'AttributeError' in receipt['stderr']:
  163. receipt['next_action'] += '文档库 API 用法见 Skill 的 references/document-reading.md,先核对实际可用方法。'
  164. elif receipt['missing_outputs'] or receipt['unchanged_outputs']:
  165. receipt['next_action'] = ('脚本退出码为0,但部分声明文件未生成或本次未改写。核对 stdout 中实际保存位置,'
  166. '将正确的 /workspace/ 输出路径传入 output_paths 后重新运行生成代码,再提交。')
  167. return receipt
  168. @define_tool(group='自主探索', label='核验沙箱交付并归档')
  169. @_action
  170. async def submit_main_fan_report(workflow_id: str, report_path: WorkspacePath, calculations_path: WorkspacePath,
  171. runtime: ToolRuntime, python_run_ids: StringList,
  172. artifacts: WorkspacePaths | None = None) -> Any:
  173. """提交沙箱脚本实际生成的 Markdown 和 AgentAssessment JSON,不重跑文档解析、业务计算或报告模板。
  174. 格式见准备工具返回的 submission_schema_path。宿主只核验身份、原件指纹、来源引用、文件格式和
  175. 本轮成功执行输出。python_run_ids 必填,报告、JSON 和附件必须都在相应成功执行的 output_paths 中。
  176. 不生成 MainFanResult/analysis_id。失败可修正重提;成功提交作为最后操作,须单独调用。
  177. """
  178. _, user, session = _identity(runtime)
  179. calls = getattr(runtime.state.get('messages', [])[-1], 'tool_calls', []) if runtime.state.get('messages') else []
  180. if len(calls) > 1:
  181. raise ValueError('提交报告应单独调用,不能与其他工具并行执行')
  182. from extensions.main_fan.batch_context import current_scope
  183. scope = current_scope.get()
  184. if scope is not None:
  185. workflow = exploration._load(workflow_id, user, session, allow_completed=True)
  186. context = workflow['context']
  187. if not context.get('report_queries') or 'history_manifest' not in context:
  188. raise ValueError('本地井级批次必须先查询测定报告并回查本井40天开停和风量历史')
  189. from extensions.main_fan.report_archive import VERSION
  190. if any(item.get('archive_version') != VERSION for item in context.get('report_archives', [])):
  191. raise ValueError('旧报告档案缺少实际全页读取核验,请重新fetch_main_fan_reports,并按当前契约完整读取后归档;不得把旧的已读声明当作事实。')
  192. candidate = exploration.AgentAssessment.model_validate(exploration._read_json(
  193. python_sandbox.workspace_file(user, session, calculations_path)))
  194. switching = candidate.details.get('switch_analysis', {})
  195. flow = candidate.details.get('flow_analysis', {})
  196. actual_flow = [row for row in context['history_manifest'].get('equipment_sample_counts', {}).values() if row['role'] == 'flow']
  197. flow_count = flow.get('raw_sample_count', flow.get('sample_count'))
  198. if actual_flow and (flow.get('status') not in {'evaluated', 'insufficient_data', 'no_data', 'unrecognized_structure'}
  199. or (not isinstance(flow_count, int) and not (flow.get('status') == 'unrecognized_structure' and flow_count is None))):
  200. raise ValueError('请在details.flow_analysis中记录status、raw_sample_count及有效样本数,按实际data[].sbNodeCurve[].dataTime/realData核对风量样本;未知结构的数量用null,不当成空数据。')
  201. if any(row['recognized_samples'] > 0 for row in actual_flow) and (flow.get('status') == 'no_data' or flow_count in (None, 0)):
  202. raise ValueError('实际风量sbNodeCurve内已有历史记录。请读取完整嵌套数组并区分全0读数、无效数据和真正空数据,不能声明没有历史数据。')
  203. if flow.get('status') == 'no_data' and any(row['unrecognized_windows'] for row in actual_flow):
  204. raise ValueError('仍有未识别的风量响应结构,不能将其宣称为空数据;请自编代码核对或明确记录解析受限。')
  205. if (switching.get('status') not in {'evaluated', 'insufficient_data'}
  206. or not isinstance(switching.get('events'), list) or not switching.get('method')
  207. or 'limitations' not in switching):
  208. raise ValueError('请在details.switch_analysis中交付status(evaluated/insufficient_data)、method、events数组和limitations。不能只用开停占比代替切换识别;候选事件也须列出实际状态变化时刻和证据。')
  209. if switching.get('switch_detected') and not switching['events']:
  210. raise ValueError('宣称发现切换时必须给出具体事件时刻及测点证据;仅有开停占比不能认定已切换。')
  211. from extensions.main_fan.gateway_queries import parse_time
  212. for event in switching['events']:
  213. if not isinstance(event, dict) or event.get('status') not in {'confirmed', 'candidate'}:
  214. raise ValueError('每个切换事件须为对象,status为confirmed或candidate')
  215. if not all(key in event for key in ('from_stopped_at', 'to_started_at', 'interval_minutes', 'evidence')):
  216. raise ValueError('切换事件须含from_stopped_at、to_started_at、interval_minutes和evidence;无法确定的时刻或间隔填null并说明限制')
  217. for key in ('from_stopped_at', 'to_started_at'):
  218. if event[key] is not None:
  219. parse_time(event[key])
  220. if not event.get('from_stopped_at') and not event.get('to_started_at') and not event.get('state_changes'):
  221. raise ValueError('候选事件也须保留至少一条实际状态变化时刻')
  222. interval = event['interval_minutes']
  223. if interval is not None and (type(interval) not in (int, float) or not math.isfinite(interval) or interval < 0):
  224. raise ValueError('切换间隔须为非负有限分钟数,无法计算填null;先启动后停机单列重叠并按Skill处理')
  225. if candidate.result_code == 'NORMAL' and (not context['history_manifest']['coverage_complete']
  226. or scope.shaft.identity_status != 'confirmed'):
  227. raise ValueError('历史覆盖不完整或通风井归属未确认时不能提交NORMAL')
  228. result = await asyncio.to_thread(exploration.submit, workflow_id, user, session, report_path,
  229. calculations_path, python_run_ids, artifacts or ())
  230. receipt = {'ok': True, 'status': result['status'], 'analysis_id': result['analysis_id'],
  231. 'workflow_id': workflow_id, 'sources': result['sources'], 'message': '报告已归档,本轮研判完成。'}
  232. return Command(update={'structured_response': result, 'messages': [ToolMessage(
  233. content=json.dumps(receipt, ensure_ascii=False), tool_call_id=runtime.tool_call_id,
  234. name='submit_main_fan_report')]})