exploration.py 16 KB

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