batch.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. """按矿复用main_fan Agent的本地批次:先发现、再逐井多轮执行,结果不进入推送队列。"""
  2. import asyncio
  3. from contextlib import contextmanager
  4. from datetime import datetime
  5. import fcntl
  6. import hashlib
  7. from html import escape
  8. import json
  9. from pathlib import Path
  10. import shutil
  11. from uuid import uuid4
  12. from zoneinfo import ZoneInfo
  13. import config
  14. from agents.workspace import session_root
  15. from db import chat_store
  16. from extensions.main_fan import exploration, mine_catalog
  17. from extensions.main_fan.gateway_queries import parse_time
  18. from extensions.main_fan.batch_context import BatchScope, current_scope
  19. from extensions.main_fan.batch_models import BatchRequest, MinePlan
  20. from extensions.main_fan.report_api import ReportClient, mock_report_client
  21. from runtime import execution
  22. from runtime.python_sandbox import digest, workspace_file
  23. @contextmanager
  24. def batch_lock(folder):
  25. folder.mkdir(parents=True, exist_ok=True)
  26. with (folder / '.run.lock').open('a') as stream:
  27. try:
  28. fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
  29. except BlockingIOError:
  30. raise ValueError('该批次正在执行,不能重复启动') from None
  31. try:
  32. yield
  33. finally:
  34. fcntl.flock(stream, fcntl.LOCK_UN)
  35. def exportable_files(directory, names):
  36. # 沙箱中的代码可创建链接;宿主导出时不跟随链接读取工作区外的文件。
  37. return [name for name in names if (Path(directory) / name).is_symlink()
  38. or not ((Path(directory) / name).is_file() or (Path(directory) / name).is_dir())]
  39. def export_index(folder, state):
  40. rows = []
  41. for mine in state['mines']:
  42. for shaft in mine.get('shafts', []):
  43. base = mine['dept_id'] + '/' + shaft['shaft_id'] + '/'
  44. report = '<a href="' + base + '报告.md">查看报告</a>' if shaft['status'] == 'completed' else escape(shaft.get('error', '待处理'))
  45. rows.append('<tr><td>' + escape(mine['mine_name']) + '</td><td>' + escape(shaft['shaft_name']) +
  46. '</td><td>' + escape(shaft.get('result_code', shaft['status'])) + '</td><td>' + report + '</td></tr>')
  47. if not mine.get('shafts'):
  48. rows.append('<tr><td>' + escape(mine['mine_name']) + '</td><td>矿级发现</td><td>' +
  49. escape(mine['status']) + '</td><td>' + escape(mine.get('error', '待处理')) + '</td></tr>')
  50. body = '''<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>主通风机本地批次</title>
  51. <style>body{max-width:1100px;margin:48px auto;padding:0 24px;font:16px/1.7 system-ui;color:#17303b;background:#f5f7f8}
  52. table{border-collapse:collapse;width:100%;background:white}td,th{padding:12px;border-bottom:1px solid #dbe3e5;text-align:left}
  53. a{color:#076b78}.note{padding:16px;background:#fff2d7;border-radius:8px}code{font-size:14px}</style>
  54. <h1>主通风机判识 · 本地预览</h1>'''
  55. body += '<p class="note">测定PDF使用既有测试原件的模拟接口,保留原件真实身份。结果仅写入本地文件,未推送。</p>'
  56. body += '<p>批次:<code>' + escape(state['batch_id']) + '</code> 状态:' + escape(state['status']) + '</p>'
  57. body += '<p>截止时刻:' + escape(state['cutoff']) + ',回查 ' + str(state['lookback_days']) + ' 天。</p>'
  58. verification = state.get('local_verification')
  59. if verification:
  60. body += '<p class="note"><strong>已交付本地数据核验。</strong> <a href="' + escape(verification['html_path'], quote=True) + '\">打开核验报告</a>。'
  61. body += 'Agent补充复核待模型权限恢复,旧AI初稿已标记未通过复核。此核验不作为正式业务结论。</p>'
  62. body += '<p><a href="batch.json">批次记录</a> · <a href="mine-catalog.json">冻结矿井清单</a></p>'
  63. body += '<table><thead><tr><th>煤矿</th><th>通风井/待确认对象</th><th>结果</th><th>文件</th></tr></thead><tbody>' + ''.join(rows) + '</tbody></table></html>'
  64. (folder / 'index.html').write_text(body, encoding='utf-8')
  65. class BatchRunner:
  66. def __init__(self, output, pdf_path, *, cache_root=None, executor=None, progress=None, max_mines=None):
  67. self.output = Path(output).resolve()
  68. self.pdf_path = Path(pdf_path).resolve()
  69. self.cache_root = Path(cache_root or config.ROOT / 'data/main_fan/report_archives').resolve()
  70. self.executor = executor or execution.execute
  71. self.progress = progress
  72. self.max_mines = max_mines
  73. def persist(self, state):
  74. exploration._write(self.output / 'batch.json', state)
  75. export_index(self.output, state)
  76. async def run(self, request, *, snapshot=None):
  77. request = BatchRequest.model_validate(request)
  78. if len(set(request.dept_ids)) != len(request.dept_ids):
  79. raise ValueError('选择的煤矿部门不能重复')
  80. with batch_lock(self.output):
  81. return await self._run(request, snapshot)
  82. async def _run(self, request, snapshot):
  83. state_path = self.output / 'batch.json'
  84. if state_path.is_file():
  85. state = json.loads(state_path.read_text())
  86. if (state.get('requested_dept_ids', state['dept_ids']) != request.dept_ids
  87. or state.get('all_producing_mines', False) != request.all_producing_mines
  88. or state['lookback_days'] != request.lookback_days
  89. or state['pdf_sha256'] != digest(self.pdf_path)
  90. or state['user_name'] != request.user_name
  91. or (request.cutoff and parse_time(request.cutoff).replace(microsecond=0).isoformat() != state['cutoff'])
  92. or (request.snapshot_id and request.snapshot_id != state['snapshot_id'])):
  93. raise ValueError('恢复批次的矿井、时段、报告或用户与原记录不一致')
  94. snapshot = json.loads((self.output / 'mine-catalog.json').read_text())
  95. if request.reassess:
  96. for item in state['mines']:
  97. if item.get('plan'):
  98. item['status'] = 'pending'
  99. for entry in item['shafts']:
  100. folder = self.output / item['dept_id'] / entry['shaft_id']
  101. if entry.get('analysis_id') and folder.is_dir():
  102. revision = folder / 'revisions' / entry['analysis_id']
  103. revision.mkdir(parents=True, exist_ok=True)
  104. for name in ('报告.md', '结果.json', '来源与覆盖.json', '待审阅报文.json'):
  105. if (folder / name).is_file():
  106. shutil.copyfile(folder / name, revision / name)
  107. entry['status'] = 'pending'
  108. state['status'] = 'running'
  109. else:
  110. snapshot = snapshot or (mine_catalog.load(request.snapshot_id) if request.snapshot_id
  111. else await mine_catalog.refresh())
  112. selected = {mine['dept_id']: mine for mine in snapshot['mines']}
  113. dept_ids = list(selected) if request.all_producing_mines else request.dept_ids
  114. if self.max_mines is not None and len(dept_ids) > self.max_mines:
  115. raise ValueError('本批次矿井数量超过配置上限,尚未启动任何矿井Agent')
  116. if any(dept not in selected for dept in dept_ids):
  117. raise ValueError('选择的部门不在本批次正常生产煤矿清单中')
  118. cutoff = (parse_time(request.cutoff)
  119. if request.cutoff else datetime.now(ZoneInfo('Asia/Shanghai'))).replace(microsecond=0).isoformat()
  120. state = {'schema_version': 1, 'batch_id': uuid4().hex, 'snapshot_id': snapshot['snapshot_id'],
  121. 'status': 'running', 'publish': False, 'delivery': 'local_files', 'pdf_mode': 'mock',
  122. 'pdf_sha256': digest(self.pdf_path), 'cutoff': cutoff, 'lookback_days': request.lookback_days,
  123. 'dept_ids': dept_ids, 'requested_dept_ids': request.dept_ids,
  124. 'all_producing_mines': request.all_producing_mines, 'user_name': request.user_name,
  125. 'mines': [{**{key: selected[dept][key] for key in ('dept_id', 'mine_id', 'mine_name')},
  126. 'status': 'pending', 'shafts': []} for dept in dept_ids]}
  127. exploration._write(self.output / 'mine-catalog.json', snapshot)
  128. if state['status'] != 'completed':
  129. state['status'] = 'running'
  130. self.persist(state)
  131. async with mock_report_client(self.pdf_path) as http:
  132. report_client = ReportClient(http)
  133. for item in state['mines']:
  134. if item['status'] == 'completed':
  135. continue
  136. mine_dir = self.output / item['dept_id']
  137. mine_dir.mkdir(exist_ok=True)
  138. if not item.get('session_id'):
  139. session = chat_store.create_session(request.user_name, 'full', item['mine_name'] + ' 主通风机月度判识')
  140. item['session_id'] = session['id']
  141. elif not chat_store.get_session_for_user(item['session_id'], request.user_name):
  142. raise ValueError('恢复批次的原矿级会话不存在,请使用原业务数据库')
  143. scope = BatchScope(state['batch_id'], item, request.user_name, item['session_id'],
  144. mine_dir, self.cache_root, state['cutoff'], state['lookback_days'], report_client,
  145. progress=self.progress)
  146. token = current_scope.set(scope)
  147. try:
  148. item.update(status='running', error='')
  149. self.persist(state)
  150. if not item.get('plan'):
  151. data = self.input_for(scope, 'discovery')
  152. result = await self.call(scope, data, 'discovery')
  153. plan = MinePlan.model_validate(result['assessment']['plan'])
  154. item['plan'] = plan.model_dump(mode='json')
  155. item['shafts'] = [{**shaft.model_dump(mode='json'), 'status': 'pending'} for shaft in plan.shafts]
  156. exploration._write(mine_dir / 'discovery.json', result)
  157. self.persist(state)
  158. plan = MinePlan.model_validate(item['plan'])
  159. for shaft, entry in zip(plan.shafts, item['shafts']):
  160. if entry['status'] == 'completed':
  161. continue
  162. scope.shaft = shaft
  163. entry.update(status='running', error='')
  164. self.persist(state)
  165. try:
  166. result = await self.call(scope, self.input_for(scope, 'assessment'), shaft.shaft_id)
  167. self.export_shaft(scope, result)
  168. entry.update(status='completed', result_code=result['platform_payload']['judgment']['resultCode'],
  169. analysis_id=result['analysis_id'], workflow_id=result['workflow_id'])
  170. except asyncio.CancelledError:
  171. entry['status'] = 'interrupted'
  172. raise
  173. except Exception as exc:
  174. entry.update(status='failed', error=type(exc).__name__ + ': ' + str(exc)[:500])
  175. exploration._write(mine_dir / shaft.shaft_id / 'failure.json', {'error': entry['error']})
  176. self.persist(state)
  177. item['status'] = 'completed' if all(row['status'] == 'completed' for row in item['shafts']) else 'partial'
  178. except asyncio.CancelledError:
  179. item['status'] = 'interrupted'
  180. state['status'] = 'interrupted'
  181. self.persist(state)
  182. raise
  183. except Exception as exc:
  184. item.update(status='failed', error=type(exc).__name__ + ': ' + str(exc)[:500])
  185. exploration._write(mine_dir / 'failure.json', {'error': item['error']})
  186. finally:
  187. current_scope.reset(token)
  188. # 包含真实请求、响应、PDF、脚本和证据,便于用户本地检查;没有服务端凭证。
  189. source = session_root(scope.user, scope.session)
  190. for directory in ('workspace', 'python_runs'):
  191. if (source / directory).is_dir():
  192. shutil.copytree(source / directory, mine_dir / directory,
  193. dirs_exist_ok=True, ignore=exportable_files)
  194. self.persist(state)
  195. state['status'] = 'completed' if all(row['status'] == 'completed' for row in state['mines']) else 'partial'
  196. state['finished_at'] = datetime.now(ZoneInfo('Asia/Shanghai')).isoformat()
  197. self.persist(state)
  198. return state
  199. @staticmethod
  200. def input_for(scope, phase):
  201. return {'mine_code': scope.mine['mine_id'], 'mine_name': scope.mine['mine_name'],
  202. 'dept_id': scope.mine['dept_id'], 'use_gateway': True,
  203. 'fan_pair': scope.shaft.fan_pair if scope.shaft else [],
  204. 'task_description': '本地批次主通风机判识',
  205. 'measurements': {'batch': {'phase': phase, 'batch_id': scope.batch_id,
  206. 'cutoff': scope.cutoff, 'lookback_days': scope.lookback_days,
  207. 'shaft_plan': scope.shaft.model_dump(mode='json') if scope.shaft else None}}}
  208. async def call(self, scope, data, phase):
  209. if self.progress:
  210. self.progress({'stage': phase, 'dept_id': scope.mine['dept_id'], 'session_id': scope.session})
  211. mode = data['measurements']['batch']['phase']
  212. task_id = hashlib.sha256((scope.batch_id + scope.mine['dept_id'] + mode + ':' + phase).encode()).hexdigest()[:32]
  213. prepared = exploration.prepare(data, scope.user, scope.session, trusted=data, job_id=task_id)
  214. scope.active_workflow_id = prepared['workflow_id']
  215. message = ('这是主通风机本地批次,先读取业务Skill及references/batch.md。完整输入已由宿主注入,'
  216. '调用prepare_main_fan_task后读取input.json和其中measurements.batch。'
  217. '本轮阶段为' + ('矿级发现:查询本矿两个实时列表并取齐分页,自主确认通风井和测点,最后submit_main_fan_mine_plan。'
  218. if mode == 'discovery' else
  219. '井级分析:fetch_main_fan_reports核对PDF及历史档案;query_main_fan_history按天查满开停和风量。'
  220. '首次PDF完整读取后archive_main_fan_report保存提取,复用档案时跳过PDF识别。'
  221. '新的档案契约要求page_sources和每页实际read_file记录,渲染不能代替阅读。合订PDF中的每份报告都要单独提取,不能只整理第一份。'
  222. '自编Python完成时序、稳定窗口分析和报告,最后submit_main_fan_report。') +
  223. '历史取数后先读取agent_data_path的观测状态区间、变化边界、异常和风量统计。'
  224. '各日原件在单个ZIP中,按manifest的archive成员用Python按需复核;不要逐份读取或打印数十万条采样。'
  225. '切换分析必须基于具体时间而不只是开停占比。在details.switch_analysis中交付status、method、events、limitations;'
  226. '每个event记录候选或确认状态、原机停机时刻、另一台启动时刻、间隔和测点证据。'
  227. '无法确认整机时仍逐条保留相关测点的状态变化时刻作为候选,说明限制,不能仅凭占比宣称已发现切换。'
  228. '重大设备历史已有真实样例data[].sbNodeCurve[],其中dataTime为时刻、realData为原始读数。'
  229. '优先采用整理结果中的实际样本数和质量信息;这些嵌套数组有数据时不能称为“无历史数据”,全为0也须报告实际样本和数据有效性限制。'
  230. '结果仅存本地,不调用推送。模拟PDF保留原件真实身份,禁止移植为本矿正式报告。'
  231. '本地任务已获授权,身份或数据不足时提交受限结果,不请求人工确认。'
  232. '\n本轮已准备好的唯一workflow_id:' + prepared['workflow_id'] +
  233. ';请使用此ID,前一阶段的ID已经结束。输入文件:' + prepared['input_path'] +
  234. '\n矿井身份:' + json.dumps({key: data[key] for key in ('mine_code', 'mine_name', 'dept_id')}, ensure_ascii=False))
  235. result = await self.executor(agent_name='main_fan', user_name=scope.user, session_id=scope.session,
  236. message=message, kind='business', graph_config={'recursion_limit': config.get_int('AGENT_EXPLORATION_RECURSION_LIMIT', 480),
  237. 'configurable': {'business_task_input': data, 'business_task_id': task_id}})
  238. exploration._write(scope.output / ('execution-' + phase + '.json'), {
  239. 'status': result.status, 'run_id': result.run_id, 'error': result.error, 'output': result.output,
  240. 'usage': result.usage, 'interrupts': getattr(result, 'interrupts', []),
  241. 'structured_output': result.structured_output})
  242. if result.status != 'success' or not result.structured_output:
  243. raise ValueError('Agent未完成实际提交:' + (result.error or result.status))
  244. return result.structured_output
  245. @staticmethod
  246. def export_shaft(scope, result):
  247. folder = scope.output / scope.shaft.shaft_id
  248. folder.mkdir(parents=True, exist_ok=True)
  249. exploration._write(folder / '结果.json', result)
  250. (folder / '报告.md').write_text(result['result'], encoding='utf-8')
  251. payload = result.get('platform_payload')
  252. if payload:
  253. exploration._write(folder / '待审阅报文.json', payload)
  254. metadata = {'mine': {key: scope.mine[key] for key in ('dept_id', 'mine_id', 'mine_name')},
  255. 'shaft': scope.shaft.model_dump(mode='json'), 'cutoff': scope.cutoff,
  256. 'lookback_days': scope.lookback_days, 'publish': False, 'pdf_mode': 'mock',
  257. 'history_coverage': result.get('assessment', {}).get('history_coverage'),
  258. 'report_archives': result.get('assessment', {}).get('report_archives', [])}
  259. exploration._write(folder / '来源与覆盖.json', metadata)