report_archive.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. """按矿、通风井和实际PDF内容复用成功报告提取;档案由宿主导出为可信输入。"""
  2. import hashlib
  3. import json
  4. import pypdfium2
  5. from extensions.main_fan import exploration
  6. from extensions.main_fan.batch_context import scope_for
  7. from extensions.main_fan.batch_models import ReportArchiveFacts
  8. from runtime.python_sandbox import digest, execution_receipt, workspace_file
  9. from obs import get_logger
  10. logger = get_logger(__name__)
  11. VERSION = 'main-fan-agent-report-v2-page-reads'
  12. def cache_key(scope, sha):
  13. value = [VERSION, scope.mine['mine_id'], scope.shaft.shaft_id, sha]
  14. return hashlib.sha256(json.dumps(value).encode()).hexdigest()
  15. def load_cached(scope, sha):
  16. path = scope.cache_root / (cache_key(scope, sha) + '.json')
  17. if not path.is_file():
  18. return None
  19. try:
  20. value = json.loads(path.read_text())
  21. facts = ReportArchiveFacts.model_validate(value['facts'])
  22. encoded = json.dumps(value['facts'], ensure_ascii=False, sort_keys=True).encode()
  23. if (value['version'] != VERSION or facts.source_sha256 != sha
  24. or value['mine_id'] != scope.mine['mine_id'] or value['shaft_id'] != scope.shaft.shaft_id
  25. or hashlib.sha256(encoded).hexdigest() != value['facts_sha256']):
  26. return None
  27. expected = list(range(1, facts.page_count + 1))
  28. reads = value.get('page_read_evidence', {})
  29. if (sorted(facts.read_pages) != expected or sorted(facts.page_sources) != expected
  30. or set(reads) != {str(page) for page in expected}
  31. or any(not row.get('complete') or len(row.get('sha256', '')) != 64 for row in reads.values())):
  32. return None
  33. return value
  34. except (KeyError, ValueError, TypeError):
  35. return None
  36. async def fetch(identifier, user, session):
  37. scope = scope_for(user, session)
  38. if scope.shaft is None:
  39. raise ValueError('测定报告按通风井任务获取,请先完成矿级发现')
  40. workflow = exploration._load(identifier, user, session)
  41. exploration.validate_inputs(workflow, user, session)
  42. context = workflow['context']
  43. if hasattr(scope.report_client, 'list_for_mine'):
  44. records, request, body = await scope.report_client.list_for_mine(
  45. scope.mine, scope.cutoff, scope.output / 'report-source')
  46. else:
  47. records, request, body = await scope.report_client.list(scope.mine['mine_id'], scope.shaft.shaft_id)
  48. prefix = '/workspace/exploration/' + identifier + '/reports'
  49. for name, content in (('request.json', request), ('response.json', body)):
  50. virtual = prefix + '/' + name
  51. local = workspace_file(user, session, virtual)
  52. exploration._write(local, content)
  53. context.setdefault('query_exports', {})[virtual] = digest(local)
  54. result = []
  55. # 重新核对本次报告集合,不能把上一轮已移除的原件视为本轮有效报告。
  56. materials = [row for row in context['task_input']['materials'] if row.get('role') != 'measurement_report']
  57. for number, record in enumerate(records):
  58. if not isinstance(record, dict):
  59. raise ValueError('报告列表包含非对象记录')
  60. virtual = prefix + '/report-' + str(number + 1) + '.pdf'
  61. local = workspace_file(user, session, virtual)
  62. sha = await scope.report_client.download(record, local)
  63. with pypdfium2.PdfDocument(local) as pdf:
  64. page_count = len(pdf)
  65. item = {'path': virtual, 'sha256': sha, 'filename': record.get('filename', local.name),
  66. 'role': 'measurement_report', 'is_mock': bool(record.get('is_mock')),
  67. 'binding': record.get('binding', ''), 'note': record.get('note', ''),
  68. **({'source_relative_path': record['source_relative_path']} if record.get('source_relative_path') else {})}
  69. materials.append(item)
  70. cached = load_cached(scope, sha)
  71. receipt = {**item, 'mode': 'reuse' if cached else 'analyze', 'archive_version': VERSION, 'page_count': page_count,
  72. 'python_path': virtual.removeprefix('/workspace/')}
  73. if cached:
  74. facts_virtual = prefix + '/' + sha[:16] + '-archive.json'
  75. archive_local = workspace_file(user, session, facts_virtual)
  76. exploration._write(archive_local, cached)
  77. context.setdefault('query_exports', {})[facts_virtual] = digest(archive_local)
  78. receipt.update(archive_path=facts_virtual, archive_python_path=facts_virtual.removeprefix('/workspace/'))
  79. result.append(receipt)
  80. context['task_input']['materials'] = materials
  81. context['report_archives'] = result
  82. receipt = {'ok': True, 'reports': result, 'report_count': len(result),
  83. 'reused_count': sum(row['mode'] == 'reuse' for row in result),
  84. 'note': '仅mode=analyze的PDF需要全页提取;mode=reuse读取宿主校验档案。所有原件均须核对矿井、报告日期及通风井/风机归属。'}
  85. context.setdefault('report_queries', []).append(receipt)
  86. exploration._persist(workflow, user, session, '取得测定报告及历史档案', receipt, export=True)
  87. logger.info('测定报告已准备', extra={'kv': {'event': 'report.prepared', 'workflow_id': identifier,
  88. 'report_count': len(result), 'reused_count': receipt['reused_count'], 'report_mode': scope.report_mode}})
  89. return receipt
  90. def save(identifier, user, session, facts_path, python_run_ids):
  91. scope = scope_for(user, session)
  92. workflow = exploration._load(identifier, user, session)
  93. exploration.validate_inputs(workflow, user, session)
  94. context = workflow['context']
  95. known = {row['run_id'] for row in context['python_runs']}
  96. if not python_run_ids or not set(python_run_ids).issubset(known):
  97. raise ValueError('报告提取档案须关联本轮成功沙箱执行')
  98. receipts = [execution_receipt(user, session, run_id) for run_id in python_run_ids]
  99. if facts_path not in {row['path'] for receipt in receipts for row in receipt['outputs']}:
  100. raise ValueError('报告档案必须为成功沙箱执行的声明输出')
  101. local = workspace_file(user, session, facts_path)
  102. if local.stat().st_size > 4 * 1024 * 1024:
  103. raise ValueError('报告提取档案超过4MB')
  104. facts = ReportArchiveFacts.model_validate(exploration._read_json(local))
  105. material = next((row for row in context['task_input']['materials']
  106. if row.get('role') == 'measurement_report' and row['sha256'] == facts.source_sha256), None)
  107. if material is None:
  108. raise ValueError('报告提取档案的hash不属于本轮测定报告')
  109. with pypdfium2.PdfDocument(workspace_file(user, session, material['path'])) as pdf:
  110. pages = len(pdf)
  111. text_pages = {index + 1: bool(pdf[index].get_textpage().get_text_range().strip()) for index in range(pages)}
  112. if facts.page_count != pages or sorted(facts.read_pages) != list(range(1, pages + 1)):
  113. raise ValueError('报告提取须完整覆盖实际PDF的全部物理页,不能缓存部分提取')
  114. page_sources = {page: source if source.startswith('/workspace/') else '/workspace/' + source.removeprefix('./')
  115. for page, source in facts.page_sources.items()}
  116. if sorted(page_sources) != list(range(1, pages + 1)) or len(set(page_sources.values())) != pages:
  117. raise ValueError('档案须提供page_sources,将每个物理页1..N对应到独立的已读取图片/文本路径;仅声明read_pages不能证明已读。')
  118. reads = context.get('page_reads', {})
  119. for page, source in page_sources.items():
  120. if not text_pages[page] and not source.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')):
  121. raise ValueError(f'物理第{page}页是扫描页,须实际读取对应渲染图片,不能以自写文本代替读图。')
  122. record = reads.get(source)
  123. path = workspace_file(user, session, source)
  124. if not record or not record['complete'] or not path.is_file() or digest(path) != record['sha256']:
  125. raise ValueError(f'物理第{page}页没有可核验的完整read_file读取记录,或读取后文件已修改;请实际读完全部页并分别整理每份报告。')
  126. raw_facts = facts.model_dump(mode='json')
  127. raw_facts['page_sources'] = {str(page): source for page, source in page_sources.items()}
  128. value = {'version': VERSION, 'mine_id': scope.mine['mine_id'], 'shaft_id': scope.shaft.shaft_id,
  129. 'source_sha256': facts.source_sha256, 'facts': raw_facts,
  130. 'facts_sha256': hashlib.sha256(json.dumps(raw_facts, ensure_ascii=False, sort_keys=True).encode()).hexdigest(),
  131. 'rules_sha256': hashlib.sha256(json.dumps(context['policy'], sort_keys=True).encode()).hexdigest(),
  132. 'is_mock': material['is_mock'], 'python_run_ids': list(python_run_ids),
  133. 'page_read_evidence': {page: reads[source] for page, source in page_sources.items()}}
  134. exploration._write(scope.cache_root / (cache_key(scope, facts.source_sha256) + '.json'), value)
  135. trusted = '/workspace/exploration/' + identifier + '/reports/' + facts.source_sha256[:16] + '-archive.json'
  136. trusted_local = workspace_file(user, session, trusted)
  137. exploration._write(trusted_local, value)
  138. context.setdefault('query_exports', {})[trusted] = digest(trusted_local)
  139. for item in context['report_archives']:
  140. if item['sha256'] == facts.source_sha256:
  141. item.update(archive_path=trusted, archive_python_path=trusted.removeprefix('/workspace/'),
  142. archive_version=VERSION, archived=True)
  143. receipt = {'ok': True, 'source_sha256': facts.source_sha256, 'page_count': pages,
  144. 'archive_path': trusted, 'archive_python_path': trusted.removeprefix('/workspace/')}
  145. exploration._persist(workflow, user, session, '保存完整报告提取档案', receipt)
  146. return receipt