exploration.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. """Skill 与沙箱工作台:宿主取数、保护输入、核验交付并归档,不执行业务分析。"""
  2. import hashlib
  3. import json
  4. import math
  5. from pathlib import Path
  6. import re
  7. from agents.workspace import session_root
  8. from algorithms.main_fan.analysis import same_mine
  9. from algorithms.main_fan.models import ScreeningInput, Device, MonitorPoint, OperatingSeries, SwitchRecord, FanStateRecord
  10. from db import main_fan_store, business_store, db_conn
  11. from extensions import get_service
  12. from extensions.registry import load_extensions
  13. from extensions.main_fan import data_source
  14. from extensions.main_fan.pipeline import load_rules
  15. from extensions.main_fan.schemas import AgentAssessment, MainFanInput
  16. from extensions.main_fan.targets import load_catalog
  17. from extensions.main_fan.service import _save_result
  18. from runtime.business_models import RemoteFile
  19. from runtime.python_sandbox import workspace_file, digest, capability, execution_receipt
  20. MODE = 'sandbox_exploration'
  21. QUERY_COLLECTIONS = ('timeseries_queries', 'sensor_queries', 'equipment_queries', 'history_queries', 'report_queries')
  22. def _write(path, value):
  23. path.parent.mkdir(parents=True, exist_ok=True)
  24. temporary = path.with_name(path.name + '.writing')
  25. temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False), encoding='utf-8')
  26. temporary.replace(path)
  27. def _load(identifier, user, session, allow_completed=False):
  28. from extensions.main_fan.batch_context import current_scope
  29. scope = current_scope.get()
  30. if scope is not None and scope.active_workflow_id and identifier != scope.active_workflow_id:
  31. raise ValueError('当前矿级会话已进入新的阶段或通风井,请使用本轮workflow_id:' + scope.active_workflow_id)
  32. workflow = main_fan_store.workflow(identifier, user, session)
  33. if workflow['context'].get('mode') != MODE:
  34. raise ValueError('该任务不是当前沙箱探索模式,请重新 prepare_main_fan_task')
  35. if workflow['status'] == 'completed' and not allow_completed:
  36. raise ValueError('该探索任务已归档;新的研判请新建任务')
  37. return workflow
  38. def _data(context):
  39. # 始终导出可信原始输入;Agent 提取的事实和中间数据保存在自己的派生文件中。
  40. return ScreeningInput.model_validate({key: value for key, value in context['task_input'].items()
  41. if key in ScreeningInput.model_fields})
  42. def _fan_names(context, data):
  43. names = dict(context.get('fan_names', {}))
  44. names.update({row.device_code: row.name for row in data.devices if same_mine(row, data)})
  45. if data.fan_id and data.fan_name:
  46. names[data.fan_id] = data.fan_name
  47. return names
  48. def _persist(workflow, user, session, operation, result, *, status='completed', export=False):
  49. context = workflow['context']
  50. stages = [*workflow['stages'], {'name': operation, 'status': status, 'result': result}]
  51. if len(stages) > 128:
  52. raise ValueError('探索操作已达上限,请提交已有结果或如实说明未完成原因')
  53. base = session_root(user, session) / 'workspace/exploration' / workflow['id']
  54. if export:
  55. data = _data(context)
  56. files = {'input.json': context['task_input'], 'data.json': data.model_dump(mode='json'),
  57. 'rules.json': context['policy'], 'submission.schema.json': AgentAssessment.model_json_schema(),
  58. 'identities.json': {'mine_code': data.mine_code, 'mine_name': data.mine_name,
  59. 'fan_pair': data.fan_pair, 'fan_names': _fan_names(context, data)}}
  60. context['exports'] = {}
  61. for name, value in files.items():
  62. _write(base / name, value)
  63. context['exports']['/workspace/exploration/' + workflow['id'] + '/' + name] = digest(base / name)
  64. _write(base / 'operations.json', stages)
  65. main_fan_store.update_workflow(workflow['id'], context=context, stages=stages)
  66. workflow['stages'] = stages
  67. return base
  68. def prepare(target, user, session, trusted=None, job_id=None):
  69. request = MainFanInput.model_validate(trusted if trusted is not None else target)
  70. inputs = request.model_dump(mode='json')
  71. for material in inputs['materials']:
  72. path = workspace_file(user, session, material.get('path', ''))
  73. if not path.is_file():
  74. raise ValueError('本次附件不存在:' + material.get('path', ''))
  75. material['sha256'] = digest(path)
  76. fingerprint = hashlib.sha256(json.dumps(inputs, ensure_ascii=False, sort_keys=True).encode()).hexdigest()
  77. existing = db_conn.query(
  78. "SELECT id FROM main_fan_workflows WHERE user_name=? AND session_id=? AND status='running' ORDER BY created_at DESC LIMIT 10",
  79. (user, session))
  80. for row in existing:
  81. workflow = main_fan_store.workflow(row['id'], user, session)
  82. if (workflow['context'].get('mode') == MODE
  83. and workflow['context'].get('input_fingerprint') == fingerprint
  84. and workflow['context'].get('job_id') == job_id):
  85. validate_inputs(workflow, user, session)
  86. return manifest(workflow, user, session)
  87. context = {'mode': MODE, 'task_input': inputs, 'job_id': job_id,
  88. 'input_fingerprint': fingerprint, 'policy': load_rules().model_dump(mode='json'),
  89. 'fan_names': {item.fan_id: item.fan_name for item in load_catalog().targets
  90. if item.mine_code == request.mine_code and item.mine_name == request.mine_name},
  91. 'gateway_loaded': False, 'python_runs': []}
  92. identifier = main_fan_store.create_workflow(user, session, context, [])
  93. workflow = main_fan_store.workflow(identifier, user, session)
  94. _persist(workflow, user, session, '准备本轮资料', {'material_count': len(inputs['materials'])}, export=True)
  95. return manifest(workflow, user, session)
  96. def manifest(workflow, user, session):
  97. context = workflow['context']
  98. prefix = '/workspace/exploration/' + workflow['id']
  99. return {'ok': True, 'mode': MODE, 'workflow_id': workflow['id'],
  100. 'mine_code': context['task_input']['mine_code'], 'mine_name': context['task_input']['mine_name'],
  101. 'input_path': prefix + '/input.json', 'data_path': prefix + '/data.json',
  102. 'identities_path': prefix + '/identities.json', 'rules_path': prefix + '/rules.json',
  103. 'submission_schema_path': prefix + '/submission.schema.json',
  104. 'materials': context['task_input']['materials'], 'use_gateway': context['task_input']['use_gateway'],
  105. 'python': capability(), 'python_paths': python_paths(workflow),
  106. **{key: context.get(key, []) for key in QUERY_COLLECTIONS},
  107. 'prepared_data': context.get('prepared_data'),
  108. 'operations': [step['name'] for step in workflow['stages']]}
  109. def python_paths(workflow):
  110. """直接给模型代码中可使用的路径,避免误用工具虚拟路径。"""
  111. prefix = 'exploration/' + workflow['id']
  112. return {'cwd': '本会话workspace根目录', 'input': prefix + '/input.json', 'data': prefix + '/data.json',
  113. 'rules': prefix + '/rules.json', 'identities': prefix + '/identities.json',
  114. 'materials': [item['path'].removeprefix('/workspace/') for item in workflow['context']['task_input']['materials']],
  115. 'report_example': 'report.md', 'assessment_example': 'assessment.json',
  116. 'note': '以上是Python代码实际使用的相对路径。工具参数仍用/workspace/虚拟路径;不要缩成单独input.json。'}
  117. def validate_inputs(workflow, user, session):
  118. context = workflow['context']
  119. fingerprints = {item['path']: item['sha256'] for item in context['task_input']['materials']}
  120. fingerprints.update(context['exports'])
  121. fingerprints.update(context.get('query_exports', {}))
  122. for virtual, expected in fingerprints.items():
  123. path = workspace_file(user, session, virtual)
  124. if not path.is_file() or digest(path) != expected:
  125. raise ValueError('原件或可信任务文件已被修改:' + virtual + ';请保留输入,将派生结果另存')
  126. async def fetch(identifier, user, session):
  127. workflow = _load(identifier, user, session)
  128. validate_inputs(workflow, user, session)
  129. context = workflow['context']
  130. if context['gateway_loaded']:
  131. return manifest(workflow, user, session)
  132. request = context['task_input']
  133. if not request['use_gateway']:
  134. raise ValueError('本次输入未启用网关,请使用已提供的原件与时序文件')
  135. gateway = get_service('shaanxi_gateway')
  136. spec = load_extensions().business('main_fan')
  137. incoming = await data_source.fetch(gateway, spec.gateway.agent_code, request['mine_code'])
  138. for key, schema in (('devices', Device), ('points', MonitorPoint), ('operating_series', OperatingSeries),
  139. ('switch_records', SwitchRecord), ('fan_state_records', FanStateRecord)):
  140. request[key].extend(schema.model_validate(row).model_dump(mode='json') for row in incoming.get(key, []))
  141. known = {item.get('file_id') for item in request['materials']}
  142. for raw in incoming['files']:
  143. source = RemoteFile.model_validate(raw)
  144. if source.file_id in known:
  145. continue
  146. filename = re.sub(r'[^\w.\-\u4e00-\u9fff]', '_', Path(source.filename).name)
  147. key = hashlib.sha256((source.file_id + '\0' + source.version).encode()).hexdigest()[:12]
  148. virtual = '/workspace/uploads/' + key + '_' + filename
  149. local = workspace_file(user, session, virtual)
  150. local.parent.mkdir(parents=True, exist_ok=True)
  151. metadata = await gateway.download(spec.gateway.agent_code, source, local)
  152. if context['job_id']:
  153. business_store.record_file(context['job_id'], metadata)
  154. request['materials'].append({'file_id': source.file_id, 'version': source.version,
  155. 'filename': source.filename, 'path': virtual, 'sha256': digest(local)})
  156. known.add(source.file_id)
  157. context['task_input'] = MainFanInput.model_validate(request).model_dump(mode='json')
  158. context['gateway_loaded'] = True
  159. _persist(workflow, user, session, '读取网关资料', {'material_count': len(request['materials'])}, export=True)
  160. return manifest(workflow, user, session)
  161. def record_python(identifier, user, session, receipt):
  162. workflow = _load(identifier, user, session)
  163. summary = {key: receipt[key] for key in ('run_id', 'script_path', 'script_sha256', 'exit_code', 'timed_out', 'outputs')}
  164. summary['receipt_path'] = receipt['receipt_path']
  165. workflow['context']['python_runs'].append(summary)
  166. _persist(workflow, user, session, '执行自编 Python', summary,
  167. status='completed' if receipt['exit_code'] == 0 and not receipt['timed_out'] else 'failed')
  168. def _read_json(path):
  169. def invalid_constant(value):
  170. raise ValueError('结果 JSON 不接受非有限数值:' + value)
  171. def finite_float(value):
  172. number = float(value)
  173. if not math.isfinite(number):
  174. invalid_constant(value)
  175. return number
  176. return json.loads(path.read_text(encoding='utf-8-sig'), parse_constant=invalid_constant, parse_float=finite_float)
  177. def submit(identifier, user, session, report_path, calculations_path=None, python_run_ids=(), artifacts=()):
  178. workflow = _load(identifier, user, session, allow_completed=True)
  179. if workflow['status'] == 'completed':
  180. return workflow['result']
  181. validate_inputs(workflow, user, session)
  182. context = workflow['context']
  183. if (context['task_input']['use_gateway'] and not context['gateway_loaded']
  184. and not any(context.get(key) for key in QUERY_COLLECTIONS)):
  185. raise ValueError('本轮要求网关资料,请先调用 fetch_main_fan_data 或实时/时序查询工具取得实际输入')
  186. report = workspace_file(user, session, report_path)
  187. if not report.is_file() or report.suffix.lower() != '.md' or report.stat().st_size > 1024 * 1024:
  188. raise ValueError('报告须为工作区内不超过 1MB 的 Markdown 文件')
  189. body = report.read_text(encoding='utf-8-sig').strip()
  190. if not body or 'data:image/' in body or ';base64,' in body:
  191. raise ValueError('报告正文为空或包含内嵌图像编码')
  192. if not calculations_path:
  193. raise ValueError('请提供沙箱脚本生成的研判 JSON calculations_path,格式见 submission.schema.json')
  194. calculations = workspace_file(user, session, calculations_path)
  195. if not calculations.is_file() or calculations.stat().st_size > 4 * 1024 * 1024:
  196. raise ValueError('研判 JSON 不存在或超过 4MB')
  197. submitted = AgentAssessment.model_validate(_read_json(calculations))
  198. if not python_run_ids or len(python_run_ids) > 40 or len(artifacts) > 20:
  199. raise ValueError('提交须关联本轮成功执行记录(1~40次),附件最多20个')
  200. known_runs = {item['run_id'] for item in context['python_runs']}
  201. if not set(python_run_ids).issubset(known_runs):
  202. raise ValueError('执行记录不属于本轮任务,请用 workflow_id 执行脚本')
  203. receipts = [execution_receipt(user, session, value) for value in dict.fromkeys(python_run_ids)]
  204. outputs = {item['path'] for receipt in receipts for item in receipt['outputs']}
  205. delivery = list(dict.fromkeys([report_path, calculations_path, *artifacts]))
  206. if not set(delivery).issubset(outputs):
  207. raise ValueError('报告、研判 JSON 和附件必须是成功沙箱执行的声明输出,请重新执行生成脚本')
  208. sources = {item['path']: item['sha256'] for item in context['task_input']['materials']}
  209. prefix = '/workspace/exploration/' + identifier
  210. sources.update({path: sha for path, sha in context['exports'].items()
  211. if path in {prefix + '/input.json', prefix + '/data.json'}})
  212. sources.update(context.get('query_exports', {}))
  213. bound_evidence = []
  214. for evidence in submitted.evidence:
  215. if evidence.source not in sources:
  216. raise ValueError('证据来源不属于本轮原件或原始输入:' + evidence.source)
  217. bound_evidence.append({**evidence.model_dump(), 'sha256': sources[evidence.source]})
  218. data = _data(context)
  219. names = _fan_names(context, data)
  220. pair = [{'fan_id': fan_id, 'fan_name': names.get(fan_id, fan_id)} for fan_id in data.fan_pair]
  221. assessment = {'mode': MODE, 'agent_result': submitted.model_dump(mode='json'),
  222. 'decision': {'result_code': submitted.result_code, 'summary': submitted.summary},
  223. 'fan_pair': pair, 'evidence': bound_evidence,
  224. 'exploration': {'mode': MODE, 'operations': [step['name'] for step in workflow['stages']],
  225. 'calculations_path': calculations_path, 'calculations': submitted.model_dump(mode='json'),
  226. 'execution_history': context['python_runs'],
  227. **{key: context.get(key, []) for key in QUERY_COLLECTIONS},
  228. 'python_runs': [{'run_id': receipt['run_id'], 'script_path': receipt['script_path'],
  229. 'script_sha256': receipt['script_sha256'], 'outputs': receipt['outputs']} for receipt in receipts]}}
  230. attachments = []
  231. for virtual in delivery:
  232. path = workspace_file(user, session, virtual)
  233. if not path.is_file():
  234. raise ValueError('提交的附件不存在:' + virtual)
  235. attachments.append({'filename': path.name, 'path': virtual})
  236. source_list = [{'title': item.get('filename') or Path(item['path']).name, 'url': item['path'], 'type': 'file'}
  237. for item in context['task_input']['materials']]
  238. if any(item['source'].endswith(('/input.json', '/data.json')) for item in bound_evidence):
  239. source_list.append({'title': '本轮原始输入与监测数据', 'url': prefix + '/input.json', 'type': 'file'})
  240. for source in dict.fromkeys(item['source'] for item in bound_evidence):
  241. if source in context.get('query_exports', {}):
  242. source_list.append({'title': '本轮网关取数 · ' + Path(source).name, 'url': source, 'type': 'file'})
  243. value = {'status': submitted.status, 'mine_code': data.mine_code, 'mine_name': data.mine_name,
  244. 'check_object': '、'.join(fan['fan_name'] for fan in pair) or data.fan_name or '主备风机',
  245. 'result_title': data.mine_name + ' 主备风机研判报告', 'result': body, 'result_format': 'report',
  246. 'workflow_id': identifier, 'sources': source_list, 'artifacts': attachments,
  247. 'check_process': ' → '.join(step['name'] for step in workflow['stages']),
  248. 'missing_materials': submitted.missing_materials, 'clues': [], 'analysis': None,
  249. 'assessment': assessment, 'judgment_scope': 'main_fan_consistency'}
  250. # 批次工具的原件和覆盖信息由宿主附加,避免报告输出遗漏查询失败或模拟来源。
  251. if context.get('history_manifest') is not None:
  252. value['assessment']['history_coverage'] = {key: context['history_manifest'][key]
  253. for key in ('cutoff', 'lookback_days', 'point_count', 'expected_segments',
  254. 'completed_segments', 'failed_segments', 'coverage_complete')}
  255. value['assessment']['report_archives'] = context.get('report_archives', [])
  256. with db_conn.transaction():
  257. saved = _save_result(value, data, None, user, session, context['job_id'])
  258. stages = [*workflow['stages'], {'name': '核验交付并归档', 'status': 'completed',
  259. 'result': {'analysis_id': saved['analysis_id'], 'result_code': submitted.result_code}}]
  260. main_fan_store.update_workflow(identifier, context=context, stages=stages, result=saved, status='completed')
  261. return saved