batch.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. """固定监测预处理、PDF 提取和单次 Qwen 研判,默认只在本地保存。"""
  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. import time
  12. from uuid import uuid4
  13. from zoneinfo import ZoneInfo
  14. import config
  15. from agents.workspace import session_root
  16. from db import chat_store, main_fan_store, business_store, db_conn
  17. from extensions.main_fan import data_export, discovery, exploration, mine_catalog, preparation, workflow
  18. from extensions.main_fan.gateway_queries import parse_time
  19. from extensions.main_fan.batch_context import BatchScope, current_scope
  20. from extensions.main_fan.batch_models import BatchRequest, MinePlan
  21. from extensions.main_fan.crawler_reports import open_report_source, report_source
  22. from obs import bind_log_context, unbind_log_context, get_logger
  23. logger = get_logger(__name__)
  24. @contextmanager
  25. def batch_lock(folder):
  26. folder.mkdir(parents=True, exist_ok=True)
  27. with (folder / '.run.lock').open('a') as stream:
  28. try:
  29. fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
  30. except BlockingIOError:
  31. raise ValueError('该批次正在执行,不能重复启动') from None
  32. try:
  33. yield
  34. finally:
  35. fcntl.flock(stream, fcntl.LOCK_UN)
  36. def exportable_files(directory, names):
  37. # 沙箱中的代码可创建链接;宿主导出时不跟随链接读取工作区外的文件。
  38. return [name for name in names if (Path(directory) / name).is_symlink()
  39. or not ((Path(directory) / name).is_file() or (Path(directory) / name).is_dir())]
  40. def export_index(folder, state):
  41. rows = []
  42. for mine in state['mines']:
  43. for shaft in mine.get('shafts', []):
  44. base = mine['dept_id'] + '/' + shaft['shaft_id'] + '/'
  45. report = '<a href="' + base + '报告.md">查看报告</a>' if shaft['status'] == 'completed' else escape(shaft.get('error', '待处理'))
  46. rows.append('<tr><td>' + escape(mine['mine_name']) + '</td><td>' + escape(shaft['shaft_name']) +
  47. '</td><td>' + escape(shaft.get('result_code', shaft['status'])) + '</td><td>' + report + '</td></tr>')
  48. if not mine.get('shafts'):
  49. rows.append('<tr><td>' + escape(mine['mine_name']) + '</td><td>矿级发现</td><td>' +
  50. escape(mine['status']) + '</td><td>' + escape(mine.get('error', '待处理')) + '</td></tr>')
  51. body = '''<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>主通风机本地批次</title>
  52. <style>body{max-width:1100px;margin:48px auto;padding:0 24px;font:16px/1.7 system-ui;color:#17303b;background:#f5f7f8}
  53. table{border-collapse:collapse;width:100%;background:white}td,th{padding:12px;border-bottom:1px solid #dbe3e5;text-align:left}
  54. a{color:#076b78}.note{padding:16px;background:#fff2d7;border-radius:8px}code{font-size:14px}</style>
  55. <h1>主通风机判识 · 本地预览</h1>'''
  56. source_note = '测定PDF使用测试原件模拟接口。' if state['pdf_mode'] == 'mock' else '测定PDF来自资料采集服务及只读挂载目录。'
  57. delivery_note = '结果先保存在本地,符合提交条件的结果进入推送队列;推送状态见业务任务记录。' if state['publish'] else '结果仅保存在本地,推送关闭。'
  58. body += '<p class="note">' + source_note + delivery_note + '</p>'
  59. body += '<p>批次:<code>' + escape(state['batch_id']) + '</code> 状态:' + escape(state['status']) + '</p>'
  60. body += '<p>截止时刻:' + escape(state['cutoff']) + ',回查 ' + str(state['lookback_days']) + ' 天。</p>'
  61. verification = state.get('local_verification')
  62. if verification:
  63. body += '<p class="note"><strong>已交付本地数据核验。</strong> <a href="' + escape(verification['html_path'], quote=True) + '\">打开核验报告</a>。'
  64. body += 'Agent补充复核待模型权限恢复,旧AI初稿已标记未通过复核。此核验不作为正式业务结论。</p>'
  65. body += '<p><a href="batch.json">批次记录</a> · <a href="mine-catalog.json">冻结矿井清单</a></p>'
  66. body += '<table><thead><tr><th>煤矿</th><th>通风井/待确认对象</th><th>结果</th><th>文件</th></tr></thead><tbody>' + ''.join(rows) + '</tbody></table></html>'
  67. (folder / 'index.html').write_text(body, encoding='utf-8')
  68. class BatchRunner:
  69. def __init__(self, output, pdf_path=None, *, cache_root=None, executor=None, progress=None, max_mines=None):
  70. self.output = Path(output).resolve()
  71. self.pdf_path = Path(pdf_path).resolve() if pdf_path is not None else None
  72. self.source = report_source(self.pdf_path)
  73. self.publish = workflow.publish_enabled()
  74. self.cache_root = Path(cache_root or config.ROOT / 'data/main_fan/report_archives').resolve()
  75. self.executor = executor or workflow.execute
  76. self.progress = progress
  77. self.max_mines = max_mines
  78. def persist(self, state):
  79. exploration._write(self.output / 'batch.json', state)
  80. export_index(self.output, state)
  81. async def run(self, request, *, snapshot=None):
  82. request = BatchRequest.model_validate(request)
  83. if len(set(request.dept_ids)) != len(request.dept_ids):
  84. raise ValueError('选择的煤矿部门不能重复')
  85. with batch_lock(self.output):
  86. started = time.monotonic()
  87. token = bind_log_context(batch_output=str(self.output))
  88. try:
  89. logger.info('主通风机批次启动', extra={'kv': {'event': 'batch.started',
  90. 'report_mode': self.source['mode'], 'publish': self.publish, 'lookback_days': request.lookback_days}})
  91. state = await self._run(request, snapshot)
  92. logger.info('主通风机批次结束', extra={'kv': {'event': 'batch.finished',
  93. 'batch_id': state['batch_id'], 'status': state['status'],
  94. 'duration_ms': round((time.monotonic() - started) * 1000)}})
  95. return state
  96. except Exception:
  97. logger.exception('主通风机批次失败', extra={'kv': {'event': 'batch.failed'}})
  98. raise
  99. finally:
  100. unbind_log_context(token)
  101. async def _run(self, request, snapshot):
  102. state_path = self.output / 'batch.json'
  103. if state_path.is_file():
  104. state = json.loads(state_path.read_text())
  105. old_execution = state.get('execution_mode') != workflow.VERSION
  106. if old_execution and not request.reassess:
  107. raise ValueError('这是旧执行版本批次,请新建工作流批次,或显式 reassess 重新研判并保留旧结果')
  108. if (state.get('requested_dept_ids', state['dept_ids']) != request.dept_ids
  109. or state.get('all_producing_mines', False) != request.all_producing_mines
  110. or state['lookback_days'] != request.lookback_days
  111. or state.get('report_source', {'mode': 'mock', 'sha256': state.get('pdf_sha256')}) != self.source
  112. or state.get('publish', False) != self.publish
  113. or state['user_name'] != request.user_name
  114. or (request.cutoff and parse_time(request.cutoff).replace(microsecond=0).isoformat() != state['cutoff'])
  115. or (request.snapshot_id and request.snapshot_id != state['snapshot_id'])):
  116. raise ValueError('恢复批次的矿井、时段、报告源、推送开关或用户与原记录不一致')
  117. snapshot = json.loads((self.output / 'mine-catalog.json').read_text())
  118. if request.reassess:
  119. for item in state['mines']:
  120. if item.get('plan'):
  121. item['status'] = 'pending'
  122. for entry in item['shafts']:
  123. folder = self.output / item['dept_id'] / entry['shaft_id']
  124. if entry.get('analysis_id') and folder.is_dir():
  125. revision = folder / 'revisions' / entry['analysis_id']
  126. revision.mkdir(parents=True, exist_ok=True)
  127. for name in ('报告.md', '结果.json', '来源与覆盖.json', '待审阅报文.json',
  128. '推送记录.json', '给Agent的数据.json', '给Qwen的数据.json',
  129. 'PDF提取结果.json', 'PDF筛选记录.json', 'Qwen原始响应.json',
  130. 'Qwen最终响应.json', 'JSON格式修复记录.json',
  131. '监测规则判定.json', '最终结论核对.json', '研判数据.zip', '数据清单.json'):
  132. if (folder / name).is_file():
  133. shutil.copyfile(folder / name, revision / name)
  134. entry['status'] = 'pending'
  135. entry.pop('analysis_saved', None)
  136. entry['assessment_revision'] = uuid4().hex
  137. if old_execution:
  138. # 新版本修正了编号识别;旧 plan 的风机归属不能直接沿用。
  139. item.pop('plan', None)
  140. item['shafts'] = []
  141. item['status'] = 'pending'
  142. state['status'] = 'running'
  143. else:
  144. snapshot = snapshot or (mine_catalog.load(request.snapshot_id) if request.snapshot_id
  145. else await mine_catalog.refresh())
  146. selected = {mine['dept_id']: mine for mine in snapshot['mines']}
  147. dept_ids = list(selected) if request.all_producing_mines else request.dept_ids
  148. if self.max_mines is not None and len(dept_ids) > self.max_mines:
  149. raise ValueError('本批次矿井数量超过配置上限,尚未启动任何矿井研判')
  150. if any(dept not in selected for dept in dept_ids):
  151. raise ValueError('选择的部门不在本批次正常生产煤矿清单中')
  152. cutoff = (parse_time(request.cutoff)
  153. if request.cutoff else datetime.now(ZoneInfo('Asia/Shanghai'))).replace(microsecond=0).isoformat()
  154. state = {'schema_version': 1, 'batch_id': uuid4().hex, 'snapshot_id': snapshot['snapshot_id'],
  155. 'status': 'running', 'publish': self.publish, 'execution_mode': workflow.VERSION,
  156. 'delivery': 'local_files_and_push_queue' if self.publish else 'local_files', 'pdf_mode': self.source['mode'],
  157. 'report_source': self.source, 'pdf_sha256': self.source.get('sha256'),
  158. 'cutoff': cutoff, 'lookback_days': request.lookback_days,
  159. 'dept_ids': dept_ids, 'requested_dept_ids': request.dept_ids,
  160. 'all_producing_mines': request.all_producing_mines, 'user_name': request.user_name,
  161. 'mines': [{**{key: selected[dept][key] for key in ('dept_id', 'mine_id', 'mine_name')},
  162. 'status': 'pending', 'shafts': []} for dept in dept_ids]}
  163. exploration._write(self.output / 'mine-catalog.json', snapshot)
  164. if state['status'] != 'completed':
  165. state['status'] = 'running'
  166. state['data_preparation'] = 'fixed_code'
  167. state['execution_mode'] = workflow.VERSION
  168. self.persist(state)
  169. async with open_report_source(self.source, self.pdf_path) as report_client:
  170. for item in state['mines']:
  171. if item['status'] == 'completed':
  172. continue
  173. mine_dir = self.output / item['dept_id']
  174. mine_dir.mkdir(exist_ok=True)
  175. if not item.get('session_id'):
  176. session = chat_store.create_session(request.user_name, 'full', item['mine_name'] + ' 主通风机月度判识')
  177. item['session_id'] = session['id']
  178. elif not chat_store.get_session_for_user(item['session_id'], request.user_name):
  179. raise ValueError('恢复批次的原矿级会话不存在,请使用原业务数据库')
  180. scope = BatchScope(state['batch_id'], item, request.user_name, item['session_id'],
  181. mine_dir, self.cache_root, state['cutoff'], state['lookback_days'], report_client,
  182. progress=self.progress, report_mode=self.source['mode'], publish=self.publish)
  183. token = current_scope.set(scope)
  184. log_token = bind_log_context(batch_id=scope.batch_id, dept_id=item['dept_id'],
  185. mine_name=item['mine_name'], session_id=scope.session)
  186. mine_started = time.monotonic()
  187. try:
  188. logger.info('矿井处理开始', extra={'kv': {'event': 'batch.mine.started'}})
  189. item.update(status='running', error='')
  190. self.persist(state)
  191. if not item.get('plan'):
  192. data = self.input_for(scope, 'discovery')
  193. _, prepared = self.prepare_workflow(scope, data, 'discovery')
  194. plan = await discovery.discover(prepared['workflow_id'], scope.user, scope.session)
  195. main_fan_store.update_workflow(prepared['workflow_id'], status='completed')
  196. item['plan'] = plan.model_dump(mode='json')
  197. item['shafts'] = [{**shaft.model_dump(mode='json'), 'status': 'pending'} for shaft in plan.shafts]
  198. exploration._write(mine_dir / 'discovery.json', {'mode': 'fixed_code', 'plan': item['plan']})
  199. self.persist(state)
  200. plan = MinePlan.model_validate(item['plan'])
  201. for shaft, entry in zip(plan.shafts, item['shafts']):
  202. if entry['status'] == 'completed':
  203. continue
  204. scope.shaft = shaft
  205. scope.assessment_revision = entry.get('assessment_revision', '')
  206. shaft_log = bind_log_context(shaft_id=shaft.shaft_id)
  207. try:
  208. entry.update(status='running', error='')
  209. self.persist(state)
  210. folder = mine_dir / shaft.shaft_id
  211. # 推送入队失败后恢复时直接使用已落地的结果,避免重复调用模型。
  212. if entry.get('analysis_saved') and (folder / '结果.json').is_file():
  213. result = json.loads((folder / '结果.json').read_text(encoding='utf-8'))
  214. else:
  215. result = await self.call(scope, self.input_for(scope, 'assessment'), shaft.shaft_id)
  216. self.export_shaft(scope, result)
  217. entry['analysis_saved'] = True
  218. self.persist(state)
  219. entry['data_archive'] = await asyncio.to_thread(data_export.export, scope, result)
  220. logger.info('研判所用数据已与结果一同归档', extra={'kv': {
  221. 'event': 'batch.data.exported', **entry['data_archive']}})
  222. self.queue_delivery(scope, result)
  223. (folder / 'failure.json').unlink(missing_ok=True)
  224. entry.update(status='completed', result_code=result['platform_payload']['judgment']['resultCode'],
  225. analysis_id=result['analysis_id'], workflow_id=result['workflow_id'])
  226. logger.info('通风井研判已保存', extra={'kv': {'event': 'batch.shaft.completed',
  227. 'analysis_id': result['analysis_id'], 'publish': self.publish}})
  228. except asyncio.CancelledError:
  229. entry['status'] = 'interrupted'
  230. raise
  231. except Exception as exc:
  232. logger.exception('通风井处理失败', extra={'kv': {'event': 'batch.shaft.failed'}})
  233. entry.update(status='failed', error=type(exc).__name__ + ': ' + str(exc)[:500])
  234. exploration._write(mine_dir / shaft.shaft_id / 'failure.json', {'error': entry['error']})
  235. finally:
  236. unbind_log_context(shaft_log)
  237. self.persist(state)
  238. item['status'] = 'completed' if all(row['status'] == 'completed' for row in item['shafts']) else 'partial'
  239. except asyncio.CancelledError:
  240. item['status'] = 'interrupted'
  241. state['status'] = 'interrupted'
  242. self.persist(state)
  243. raise
  244. except Exception as exc:
  245. logger.exception('矿井处理失败', extra={'kv': {'event': 'batch.mine.failed'}})
  246. item.update(status='failed', error=type(exc).__name__ + ': ' + str(exc)[:500])
  247. exploration._write(mine_dir / 'failure.json', {'error': item['error']})
  248. finally:
  249. logger.info('矿井处理结束', extra={'kv': {'event': 'batch.mine.finished',
  250. 'status': item['status'], 'duration_ms': round((time.monotonic() - mine_started) * 1000)}})
  251. current_scope.reset(token)
  252. # 包含真实请求、响应、PDF、脚本和证据,便于用户本地检查;没有服务端凭证。
  253. try:
  254. source = session_root(scope.user, scope.session)
  255. for directory in ('workspace', 'python_runs'):
  256. if (source / directory).is_dir():
  257. shutil.copytree(source / directory, mine_dir / directory,
  258. dirs_exist_ok=True, ignore=exportable_files)
  259. self.persist(state)
  260. finally:
  261. unbind_log_context(log_token)
  262. state['status'] = 'completed' if all(row['status'] == 'completed' for row in state['mines']) else 'partial'
  263. state['finished_at'] = datetime.now(ZoneInfo('Asia/Shanghai')).isoformat()
  264. self.persist(state)
  265. return state
  266. @staticmethod
  267. def input_for(scope, phase):
  268. return {'mine_code': scope.mine['mine_id'], 'mine_name': scope.mine['mine_name'],
  269. 'dept_id': scope.mine['dept_id'], 'use_gateway': True,
  270. 'fan_pair': scope.shaft.fan_pair if scope.shaft else [],
  271. 'document_cache_policy': config.get_str('MAIN_FAN_PDF_CACHE_POLICY', 'auto'),
  272. 'task_description': '本地批次主通风机判识',
  273. 'measurements': {'batch': {'phase': phase, 'batch_id': scope.batch_id, 'data_preparation': 'fixed_code',
  274. 'execution_mode': workflow.VERSION,
  275. 'cutoff': scope.cutoff, 'lookback_days': scope.lookback_days,
  276. 'assessment_revision': scope.assessment_revision,
  277. 'shaft_plan': scope.shaft.model_dump(mode='json') if scope.shaft else None}}}
  278. @staticmethod
  279. def prepare_workflow(scope, data, phase):
  280. mode = data['measurements']['batch']['phase']
  281. seed = scope.batch_id + scope.mine['dept_id'] + mode + ':' + phase + ':' + workflow.VERSION
  282. if scope.assessment_revision:
  283. seed += ':' + scope.assessment_revision
  284. task_id = hashlib.sha256(seed.encode()).hexdigest()[:32]
  285. if mode == 'assessment':
  286. # 模型结果已入库但本地导出失败时,继续交付同一原件,不重跑 Qwen。
  287. for row in db_conn.query(
  288. "SELECT id FROM main_fan_workflows WHERE user_name=? AND session_id=? AND status='completed' ORDER BY created_at DESC LIMIT 40",
  289. (scope.user, scope.session)):
  290. previous = main_fan_store.workflow(row['id'], scope.user, scope.session)
  291. if (previous['context'].get('job_id') == task_id
  292. and (previous.get('result') or {}).get('assessment', {}).get('mode') == workflow.VERSION):
  293. exploration.validate_inputs(previous, scope.user, scope.session)
  294. scope.active_workflow_id = previous['id']
  295. return task_id, {**exploration.manifest(previous, scope.user, scope.session), 'completed_result': True}
  296. prepared = exploration.prepare(data, scope.user, scope.session, trusted=data, job_id=task_id)
  297. scope.active_workflow_id = prepared['workflow_id']
  298. return task_id, prepared
  299. async def call(self, scope, data, phase):
  300. started = time.monotonic()
  301. logger.info('固定代码准备分析输入', extra={'kv': {'event': 'batch.preparation.started'}})
  302. if self.progress:
  303. self.progress({'stage': 'fixed_data_preparation', 'dept_id': scope.mine['dept_id'], 'session_id': scope.session})
  304. _, prepared = self.prepare_workflow(scope, data, phase)
  305. if not prepared.get('completed_result'):
  306. await preparation.assessment(prepared['workflow_id'], scope.user, scope.session)
  307. logger.info('监测输入已准备,开始执行固定研判工作流', extra={'kv': {'event': 'batch.workflow.started',
  308. 'workflow_id': prepared['workflow_id'], 'duration_ms': round((time.monotonic() - started) * 1000)}})
  309. result = await self.executor(workflow_id=prepared['workflow_id'], user_name=scope.user,
  310. session_id=scope.session, progress=self.progress)
  311. exploration._write(scope.output / ('execution-' + phase + '.json'), {
  312. 'status': result.status, 'run_id': result.run_id, 'error': result.error, 'output': result.output,
  313. 'usage': result.usage, 'interrupts': getattr(result, 'interrupts', []),
  314. 'structured_output': result.structured_output, 'diagnostics': getattr(result, 'diagnostics', {})})
  315. if result.status != 'success' or not result.structured_output:
  316. reason = result.error or ('缺少工作流校验后的正式结果'
  317. if result.status == 'success' else result.status)
  318. detail = getattr(result, 'diagnostics', {}).get('error', '')
  319. raise ValueError('工作流研判未完成:' + reason + (';' + detail if detail else ''))
  320. logger.info('工作流研判完成', extra={'kv': {'event': 'batch.workflow.completed',
  321. 'workflow_id': prepared['workflow_id'], 'run_id': result.run_id, 'usage': result.usage,
  322. 'duration_ms': round((time.monotonic() - started) * 1000)}})
  323. return result.structured_output
  324. def queue_delivery(self, scope, result):
  325. folder = scope.output / scope.shaft.shaft_id
  326. receipt = {'enabled': self.publish, 'status': 'local_only', 'task_id': result['platform_payload']['taskId']}
  327. if self.publish:
  328. if result.get('status') != 'completed':
  329. receipt['status'] = 'skipped_incomplete'
  330. else:
  331. from extensions.result_platform.settings import ResultPlatformSettings
  332. settings = ResultPlatformSettings.from_config()
  333. job = business_store.record_completed_result('main_fan', scope.user, scope.session,
  334. self.input_for(scope, 'assessment'), result, settings.url)
  335. receipt.update(status=job['push_status'], job_id=job['id'])
  336. logger.info('本地结果已进入持久化推送队列', extra={'kv': {'event': 'batch.delivery.queued',
  337. 'task_id': job['id'], 'push_status': job['push_status']}})
  338. exploration._write(folder / '推送记录.json', receipt)
  339. @staticmethod
  340. def export_shaft(scope, result):
  341. folder = scope.output / scope.shaft.shaft_id
  342. folder.mkdir(parents=True, exist_ok=True)
  343. exploration._write(folder / '结果.json', result)
  344. (folder / '报告.md').write_text(result['result'], encoding='utf-8')
  345. payload = result.get('platform_payload')
  346. if payload:
  347. exploration._write(folder / '待审阅报文.json', payload)
  348. metadata = {'mine': {key: scope.mine[key] for key in ('dept_id', 'mine_id', 'mine_name')},
  349. 'shaft': scope.shaft.model_dump(mode='json'), 'cutoff': scope.cutoff,
  350. 'lookback_days': scope.lookback_days, 'publish': scope.publish, 'pdf_mode': scope.report_mode,
  351. 'history_coverage': result.get('assessment', {}).get('history_coverage'),
  352. 'report_archives': result.get('assessment', {}).get('report_archives', [])}
  353. exploration._write(folder / '来源与覆盖.json', metadata)