data_export.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. """将单次研判的实际输入和执行证据归档到同一个结果目录。"""
  2. import hashlib
  3. import json
  4. from pathlib import PurePosixPath
  5. import re
  6. import shutil
  7. from uuid import uuid4
  8. from zipfile import ZipFile, ZipInfo, ZIP_DEFLATED, ZIP_STORED
  9. from agents.workspace import session_root
  10. from db import main_fan_store
  11. from extensions.main_fan.exploration import _write
  12. from runtime.python_sandbox import digest, workspace_file
  13. def export(scope, result):
  14. workflow = main_fan_store.workflow(result['workflow_id'], scope.user, scope.session)
  15. context = workflow['context']
  16. folder = scope.output / scope.shaft.shaft_id
  17. folder.mkdir(parents=True, exist_ok=True)
  18. files, skipped = {}, []
  19. def add(virtual, sha):
  20. if not isinstance(virtual, str) or not virtual.startswith('/workspace/') or '..' in PurePosixPath(virtual).parts:
  21. raise ValueError('研判数据归档包含非法工作区路径')
  22. if not isinstance(sha, str) or not re.fullmatch(r'[0-9a-f]{64}', sha):
  23. raise ValueError('研判数据归档缺少有效文件指纹')
  24. if virtual in files and files[virtual][1] != sha:
  25. raise ValueError('同一研判数据路径出现不同指纹')
  26. files[virtual] = (workspace_file(scope.user, scope.session, virtual), sha,
  27. str(PurePosixPath(virtual).relative_to('/')))
  28. def derived(virtual, sha):
  29. source = workspace_file(scope.user, scope.session, virtual)
  30. # 多轮脚本可以覆盖或删除中间结果;原始输入始终严格核验,
  31. # 已不存在的中间版本不能用当前同名文件冒充,记录在清单中。
  32. if not source.is_file() or digest(source) != sha:
  33. skipped.append({'source': virtual, 'sha256': sha, 'reason': '中间输出已被覆盖或删除,执行时指纹见运行收据'})
  34. else:
  35. add(virtual, sha)
  36. for key in ('exports', 'query_exports'):
  37. for virtual, sha in context.get(key, {}).items():
  38. add(virtual, sha)
  39. for row in context['task_input'].get('materials', []):
  40. add(row['path'], row['sha256'])
  41. for virtual, row in context.get('page_reads', {}).items():
  42. derived(virtual, row['sha256'])
  43. root = session_root(scope.user, scope.session).resolve()
  44. outputs = {}
  45. for receipt in context.get('python_runs', []):
  46. run_id = receipt['run_id']
  47. if not re.fullmatch(r'[0-9a-f]{32}', run_id):
  48. raise ValueError('研判执行记录 ID 无效')
  49. for name in ('script.py', 'receipt.json'):
  50. path = root / 'python_runs' / run_id / name
  51. if path.is_symlink() or not path.resolve().is_relative_to(root / 'python_runs') or not path.is_file():
  52. raise ValueError('研判执行记录缺失或越界')
  53. key = 'python_runs/' + run_id + '/' + name
  54. files[key] = (path, digest(path), key)
  55. for output in receipt.get('outputs', []):
  56. outputs[output['path']] = output['sha256']
  57. for virtual, sha in outputs.items():
  58. derived(virtual, sha)
  59. manifest = {'workflow_id': result['workflow_id'], 'analysis_id': result['analysis_id'],
  60. 'mine': {key: scope.mine[key] for key in ('dept_id', 'mine_id', 'mine_name')},
  61. 'shaft_id': scope.shaft.shaft_id, 'cutoff': scope.cutoff, 'lookback_days': scope.lookback_days,
  62. 'note': 'workspace/ 对应本会话 /workspace/;历史原始请求、响应和采样在 history-evidence.zip 内,按 manifest 查阅。',
  63. 'files': [], 'unavailable_intermediates': skipped}
  64. temporary = folder / ('.研判数据-' + uuid4().hex + '.writing')
  65. try:
  66. with ZipFile(temporary, 'w', allowZip64=True) as archive:
  67. for virtual, (source, expected, member) in sorted(files.items()):
  68. if not source.is_file():
  69. raise ValueError('研判数据缺失:' + virtual)
  70. info = ZipInfo(member)
  71. info.compress_type = ZIP_STORED if source.suffix.lower() == '.zip' else ZIP_DEFLATED
  72. sha, size = hashlib.sha256(), 0
  73. with source.open('rb') as incoming, archive.open(info, 'w', force_zip64=True) as outgoing:
  74. while chunk := incoming.read(1024 * 1024):
  75. sha.update(chunk)
  76. size += len(chunk)
  77. outgoing.write(chunk)
  78. if sha.hexdigest() != expected:
  79. raise ValueError('研判数据内容与分析时的指纹不一致:' + virtual)
  80. manifest['files'].append({'source': virtual, 'path': member, 'sha256': expected, 'size': size})
  81. archive.writestr('数据清单.json', json.dumps(manifest, ensure_ascii=False, indent=2))
  82. sha = digest(temporary)
  83. target = folder / '研判数据.zip'
  84. temporary.replace(target)
  85. finally:
  86. temporary.unlink(missing_ok=True)
  87. _write(folder / '数据清单.json', {**manifest, 'archive': target.name, 'sha256': sha})
  88. agent_path = context.get('prepared_data', {}).get('agent_data_path')
  89. if agent_path:
  90. with ZipFile(target) as archive, archive.open(files[agent_path][2]) as source:
  91. with (folder / '给Agent的数据.json').open('wb') as output:
  92. shutil.copyfileobj(source, output)
  93. # 首次研判和最终采纳响应分别保存,格式修复不覆盖原始响应。
  94. workflow_files = context.get('assessment_workflow', {}).get('files', {})
  95. for original, display in (('qwen-input.json', '给Qwen的数据.json'),
  96. ('pdf-extraction.json', 'PDF提取结果.json'),
  97. ('pdf-page-selection.json', 'PDF筛选记录.json'),
  98. ('qwen-response.json', 'Qwen原始响应.json'),
  99. ('qwen-final-response.json', 'Qwen最终响应.json'),
  100. ('json-repair-history.json', 'JSON格式修复记录.json'),
  101. ('monitoring-assessment.json', '监测规则判定.json'),
  102. ('decision-resolution.json', '最终结论核对.json')):
  103. virtual = workflow_files.get(original)
  104. if virtual:
  105. source = workspace_file(scope.user, scope.session, virtual)
  106. if virtual not in files or digest(source) != files[virtual][1]:
  107. raise ValueError('工作流导出文件与本轮指纹不一致:' + original)
  108. shutil.copyfile(source, folder / display)
  109. metadata = folder / '来源与覆盖.json'
  110. if metadata.is_file():
  111. value = json.loads(metadata.read_text(encoding='utf-8'))
  112. value['data_archive'] = {'path': target.name, 'sha256': sha, 'manifest': '数据清单.json'}
  113. _write(metadata, value)
  114. return {'path': target.name, 'sha256': sha, 'file_count': len(files), 'size': target.stat().st_size}