test_main_fan_workflow.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. """固定工作流回归:PDF 分流、完整输入、单次 Qwen、标准报文及禁止推送。"""
  2. import json
  3. import shutil
  4. import httpx
  5. from PIL import Image
  6. import pypdfium2 as pdfium
  7. import pytest
  8. import config
  9. from db import business_store, main_fan_store
  10. from extensions.main_fan import exploration, workflow, workflow_documents, workflow_json_repair, workflow_page_filter
  11. from extensions.main_fan.batch import BatchRunner
  12. from runtime.python_sandbox import digest, workspace_file
  13. from runtime.result_models import PlatformResultPayload
  14. from test_main_fan_batch import batch_workspace, bound
  15. from test_main_fan_documents import native_pdf
  16. from test_main_fan_preparation import FixedGateway
  17. def conclusion(code='NORMAL'):
  18. return {'objectDetail': '测试甲矿主备风机(通风井归属待确认)',
  19. 'dataDetail': '测试资料,开停及风量来自固定代码。',
  20. 'judgment': {'resultCode': code, 'result': '现有数据未发现确凿异常。',
  21. 'reason': '原件为模拟资料,不代入本矿实际结论;未提供准确切换间隔。', 'evidenceLink': ''}}
  22. @pytest.mark.asyncio
  23. async def test_mixed_pdf_uses_python_and_ocr_then_rebinds_raw_cache(batch_workspace, monkeypatch):
  24. native, scan, mixed = [batch_workspace.root / name for name in ('native.pdf', 'scan.pdf', 'mixed.pdf')]
  25. native_pdf(native)
  26. image = Image.new('RGB', (300, 400), 'white')
  27. image.save(scan, 'PDF')
  28. image.close()
  29. with pdfium.PdfDocument.new() as combined:
  30. with pdfium.PdfDocument(native) as doc:
  31. combined.import_pages(doc)
  32. with pdfium.PdfDocument(scan) as doc:
  33. combined.import_pages(doc)
  34. combined.save(mixed)
  35. calls = []
  36. async def respond(request):
  37. calls.append(json.loads(request.content))
  38. assert 'authorization' not in request.headers
  39. return httpx.Response(200, json={'choices': [{'finish_reason': 'stop',
  40. 'message': {'content': '合成扫描页:风量 80.50 m³/s,功率 132.6 kW。'}}]})
  41. with bound(batch_workspace) as (scope, identifier):
  42. virtual = '/workspace/exploration/' + identifier + '/mixed.pdf'
  43. local = workspace_file(scope.user, scope.session, virtual)
  44. shutil.copyfile(mixed, local)
  45. material = {'path': virtual, 'filename': 'mixed.pdf', 'sha256': digest(local),
  46. 'page_count': 2, 'is_mock': True, 'binding': 'synthetic'}
  47. async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as client:
  48. first = workflow_documents.PDFExtractor(scope.user, scope.session,
  49. '/workspace/exploration/' + identifier + '/first', scope.cache_root, client=client)
  50. reports = await first.extract([material])
  51. assert reports[0]['complete'] and not reports[0]['reused']
  52. assert [row['method'] for row in reports[0]['pages']] == ['python', 'glm_ocr']
  53. assert 'NATIVE-TEST-001' in reports[0]['pages'][0]['text']
  54. assert len(calls) == 1 and calls[0]['messages'][0]['content'][1]['text'] == 'Text Recognition:'
  55. second = workflow_documents.PDFExtractor(scope.user, scope.session,
  56. '/workspace/exploration/' + identifier + '/second', scope.cache_root, client=client)
  57. cached = await second.extract([material])
  58. assert cached[0]['reused'] and len(calls) == 1
  59. assert all('/second/' in row['text_path'] for row in cached[0]['pages'])
  60. def test_watermark_or_large_embedded_scan_is_not_full_native_text():
  61. assert not workflow_documents.usable_text('契约锁\n扫描全能王')
  62. assert workflow_documents.usable_text('Main fan performance table report with readable values 80.5 132.6')
  63. class ImageObject:
  64. def get_bounds(self):
  65. return (0, 0, 595, 600)
  66. class Page:
  67. def get_size(self):
  68. return (595, 842)
  69. def get_objects(self, **kwargs):
  70. return iter([ImageObject()])
  71. assert workflow_documents.large_raster(Page()), '带可读页眉的扫描表格也必须送 OCR'
  72. def test_full_monitoring_expands_existing_results_without_recalculation(batch_workspace):
  73. with bound(batch_workspace) as (scope, identifier):
  74. current = main_fan_store.workflow(identifier, scope.user, scope.session)
  75. prefix = '/workspace/exploration/' + identifier
  76. periods = [{'state': '开' if i % 2 else '停', 'observed_from': '2026-10-01T00:00:00+08:00',
  77. 'observed_until': '2026-10-01T00:01:00+08:00', 'sample_count': 1} for i in range(25)]
  78. events = [{'event_id': 'S' + str(i), 'from_fan': '1号风机', 'to_fan': '2号风机',
  79. 'after': '2026-10-01T00:00:00+08:00', 'by': '2026-10-01T00:01:00+08:00',
  80. 'flow_context': [], 'flow_comparison': {'method': '已整理的单点对照',
  81. 'relative_change_percent': None, 'invalid_reasons': ['flow_pair_not_unique']}} for i in range(25)]
  82. details = {'cutoff': scope.cutoff, 'lookback_days': 40, 'mine': scope.mine,
  83. 'points': [{'node_id': 'state1', 'label': '1号一级', 'system': 'safety', 'role': 'fan_state',
  84. 'state_intervals': periods, 'state_changes': [], 'state_value_semantics': '接口原标签'}],
  85. 'preprocessing': {'switch_candidates': events}}
  86. detail_path, compact_path = prefix + '/details.json', prefix + '/compact.json'
  87. exploration._write(workspace_file(scope.user, scope.session, detail_path), details)
  88. exploration._write(workspace_file(scope.user, scope.session, compact_path), {'details_path': detail_path})
  89. context = current['context']
  90. context['prepared_data'] = {'agent_data_path': compact_path}
  91. context.setdefault('query_exports', {})[detail_path] = digest(workspace_file(scope.user, scope.session, detail_path))
  92. actual = workflow.full_monitoring(context, scope.user, scope.session)
  93. assert len(actual['states'][0]['periods']) == 25
  94. assert len(actual['switch_candidates']) == 25
  95. assert not actual['states'][0].get('truncated')
  96. assert actual['switch_candidates'][-1]['comparison']['change_percent'] is None
  97. @pytest.mark.parametrize('raw', ['', '准备调用工具', '{"objectDetail":"a"}', json.dumps(conclusion(), ensure_ascii=False) + ' trailing'])
  98. def test_invalid_model_reply_never_becomes_completed_result(raw):
  99. with pytest.raises(workflow.WorkflowError):
  100. workflow.parse_decision(raw)
  101. def test_unescaped_quotes_report_json_position_without_disclosing_body():
  102. raw = json.dumps(conclusion(), ensure_ascii=False).replace('现有数据未发现确凿异常。', '核心项"性能一致性"未核实。')
  103. with pytest.raises(workflow.WorkflowError) as error:
  104. workflow.parse_decision(raw)
  105. assert error.value.code == 'qwen_invalid_json'
  106. assert error.value.details['error_type'] == 'JSONDecodeError'
  107. assert error.value.details['line'] == 1 and error.value.details['column'] > 1
  108. assert 'objectDetail' not in str(error.value)
  109. def test_schema_failure_is_distinct_from_invalid_json():
  110. with pytest.raises(workflow.WorkflowError) as error:
  111. workflow.parse_decision('{}')
  112. assert error.value.code == 'qwen_schema_validation'
  113. assert error.value.details['errors'][0]['loc'] == ('objectDetail',)
  114. assert 'input' not in error.value.details['errors'][0]
  115. def test_json_repair_can_fill_fixed_link_but_preserves_business_values():
  116. value = conclusion()
  117. value['dataDetail'] = '风量96.7 m³/s,静压495.58 Pa。'
  118. original = json.dumps(value, ensure_ascii=False)[:-1]
  119. assert workflow_json_repair.can_repair(original, 'qwen_invalid_json')
  120. candidate = workflow.WorkflowDecision.model_validate(value)
  121. assert not workflow_json_repair.preservation_errors(original, candidate)
  122. candidate.dataDetail = '风量97.7 m³/s,静压495.58 Pa。'
  123. assert 'numeric_content' in workflow_json_repair.preservation_errors(original, candidate)
  124. candidate.dataDetail = '风量96.7 m³/s,静压495.58 kPa。'
  125. assert 'identifiers_or_units' in workflow_json_repair.preservation_errors(original, candidate)
  126. missing_link = json.loads(json.dumps(value))
  127. missing_link['judgment'].pop('evidenceLink')
  128. assert workflow_json_repair.can_repair(json.dumps(missing_link), 'qwen_schema_validation')
  129. assert not workflow_json_repair.preservation_errors(json.dumps(missing_link),
  130. workflow.WorkflowDecision.model_validate(value))
  131. def test_all_failed_pdf_pages_do_not_block_available_monitoring():
  132. reports = [{'path': '/workspace/report.pdf', 'pages': [
  133. {'page': number, 'status': 'failed', 'text': '', 'error_stage': 'python_text',
  134. 'error': 'TypeError: unsupported context manager'} for number in (1, 2)]}]
  135. monitoring = {'states': [{'periods': [{'state': '开'}]}], 'flows': [], 'switch_candidates': []}
  136. availability = workflow.data_availability(reports, monitoring)
  137. assert availability['any_available'] and availability['pdf_readable_pages'] == 0
  138. assert availability['known_state_periods'] == 1
  139. workflow.validate_result_policy(workflow.WorkflowDecision.model_validate(conclusion()), availability)
  140. @pytest.mark.parametrize('monitoring', [
  141. {'flows': [{'samples': 1, 'range': [0, 0]}]},
  142. {'switch_candidates': [{'comparison': {'before_value': 100, 'after_value': 0, 'change_percent': 100}}]},
  143. {'switch_candidates': [{'flow': [{'before': None, 'after': ['2026-10-22 00:00:00', 0]}]}]},
  144. ])
  145. def test_zero_flow_and_partial_reference_values_count_as_available_data(monitoring):
  146. availability = workflow.data_availability([], monitoring)
  147. assert availability['any_available']
  148. codes = workflow.decision_schema(availability)['$defs']['WorkflowJudgment']['properties']['resultCode']['enum']
  149. assert codes == ['NORMAL', 'WARNING', 'ABNORMAL']
  150. with pytest.raises(workflow.WorkflowError) as error:
  151. workflow.validate_result_policy(workflow.WorkflowDecision.model_validate(conclusion('UNDETERMINED')), availability)
  152. assert error.value.code == 'workflow_result_policy'
  153. def test_undetermined_only_when_all_three_sources_have_no_usable_data():
  154. reports = [{'is_mock': True, 'pages': [{'status': 'completed', 'text': '合成报告'}]}]
  155. monitoring = {'states': [{'periods': [{'state': '未知'}]}], 'flows': [{'samples': 0}],
  156. 'switch_candidates': [{'duration_minutes': None, 'comparison': {'change_percent': None}}]}
  157. availability = workflow.data_availability(reports, monitoring)
  158. assert not availability['any_available']
  159. codes = workflow.response_format(availability)['json_schema']['schema']['$defs']['WorkflowJudgment']['properties']['resultCode']['enum']
  160. assert codes == ['UNDETERMINED']
  161. decision = workflow.WorkflowDecision.model_validate(conclusion('UNDETERMINED'))
  162. workflow.validate_result_policy(decision, availability)
  163. with pytest.raises(workflow.WorkflowError):
  164. workflow.validate_result_policy(workflow.WorkflowDecision.model_validate(conclusion()), availability)
  165. def test_readable_real_report_counts_when_monitoring_is_missing():
  166. reports = [{'is_mock': False, 'pages': [{'status': 'completed', 'text': '测试甲矿通风机性能报告'}]}]
  167. availability = workflow.data_availability(reports, {})
  168. assert availability['any_available'] and availability['pdf_readable_pages'] == 1
  169. workflow.validate_result_policy(workflow.WorkflowDecision.model_validate(conclusion()), availability)
  170. def test_page_filter_only_removes_known_irrelevant_pages_and_preserves_source():
  171. pages = [{'page': number, 'status': 'completed', 'text': text} for number, text in enumerate([
  172. '报告封面:测试甲矿,编号R-01', '声 明\n本报告未经批准不得复制。',
  173. '检测检验用仪器设备一览表\n记录包含风量,保守保留', '性能测定数据\n频率45Hz,叶片角度0°',
  174. '', '', '资质证书\n附有本矿通风机编号与额定功率', '',
  175. ], 1)]
  176. pages[5]['status'] = 'failed'
  177. pages[7]['blank'] = True
  178. source = {'path': '/workspace/report.pdf', 'page_count': 8, 'complete': False, 'pages': pages}
  179. selected, manifest = workflow_page_filter.select_pages([source])
  180. assert [p['page'] for p in selected[0]['pages']] == [1, 3, 4, 5, 6, 7]
  181. assert [p['page'] for p in source['pages']] == list(range(1, 9))
  182. assert 'page_selection' not in source and selected[0]['page_count'] == 8
  183. assert manifest[0]['excluded_pages'] == [{'page': 2, 'reason': '声明页'}, {'page': 8, 'reason': '空白页'}]
  184. @pytest.mark.asyncio
  185. @pytest.mark.parametrize('primary_failure,fallback_failure', [('length', False), ('repetition', False), ('length', True)])
  186. async def test_failed_glm_page_uses_qwen_once_and_retains_both_responses(batch_workspace, monkeypatch, primary_failure, fallback_failure):
  187. monkeypatch.setattr(config, 'MODEL', 'Qwen/Qwen3.5-35B-A3B-FP8')
  188. monkeypatch.setattr(config, 'OPENAI_BASE_URL', 'https://qwen.invalid/v1')
  189. monkeypatch.setattr(config, 'OPENAI_API_KEY', 'qwen-only-key')
  190. monkeypatch.setattr(config, 'THINKING_LEVEL', 'highest')
  191. calls, qwen_calls, usages = [], [], []
  192. async def respond(request):
  193. body = json.loads(request.content)
  194. calls.append(body)
  195. if body['model'] == 'glm-ocr':
  196. assert 'authorization' not in request.headers
  197. finish, text = ('stop', '1.0500 1.1429\n' * 20) if primary_failure == 'repetition' else ('length', '不完整页')
  198. else:
  199. assert request.headers['authorization'] == 'Bearer qwen-only-key'
  200. assert body['chat_template_kwargs']['enable_thinking'] is True
  201. finish, text = ('length', '仍未完整') if fallback_failure else ('stop', '测试甲矿2号风机45Hz性能表:风量96.7,静压495.58。')
  202. return httpx.Response(200, json={'choices': [{'finish_reason': finish, 'message': {'content': text}}],
  203. 'usage': {'prompt_tokens': 10, 'completion_tokens': 20}})
  204. with bound(batch_workspace) as (scope, identifier):
  205. directory = workspace_file(scope.user, scope.session, '/workspace/exploration/' + identifier + '/page.png').parent
  206. image = Image.new('RGB', (30, 40), 'white')
  207. image.save(directory / 'page.png')
  208. image.close()
  209. row = {'page': 16, 'status': 'pending', 'method': 'glm_ocr', 'text': '', 'image_name': 'page.png'}
  210. async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as client:
  211. extractor = workflow_documents.PDFExtractor(scope.user, scope.session, '/workspace/exploration/' + identifier,
  212. scope.cache_root, client=client, on_qwen_call=lambda: qwen_calls.append(True), on_qwen_usage=usages.append)
  213. await extractor._ocr(row, directory, client)
  214. assert len(calls) == 2 and len(qwen_calls) == 1 and len(usages) == 1
  215. assert row['method'] == 'qwen_ocr' and row['fallback_used'] and len(row['attempts']) == 2
  216. assert row['status'] == ('failed' if fallback_failure else 'completed')
  217. assert bool(row['text']) is not fallback_failure
  218. assert (directory / 'page-0016.response.json').is_file()
  219. assert (directory / 'page-0016.qwen.response.json').is_file()
  220. @pytest.mark.asyncio
  221. @pytest.mark.parametrize('repair_outcome', ['success', 'invalid_json', 'changed_result'])
  222. async def test_json_repair_is_bounded_does_not_resend_source_or_reassess(batch_workspace, monkeypatch, repair_outcome):
  223. from extensions.main_fan import gateway_queries, history
  224. gateway = FixedGateway(batch_workspace.mine)
  225. monkeypatch.setenv('SHANXI_GATEWAY_ENABLED', '1')
  226. monkeypatch.setenv('MAIN_FAN_WORKFLOW_PUBLISH_ENABLED', 'False')
  227. monkeypatch.setenv('MAIN_FAN_JSON_REPAIR_MAX_ATTEMPTS', '3')
  228. monkeypatch.setenv('BILLING_ENABLED', 'False')
  229. monkeypatch.setattr(config, 'MODEL', 'Qwen/Qwen3.5-35B-A3B-FP8')
  230. monkeypatch.setattr(config, 'THINKING_LEVEL', 'highest')
  231. monkeypatch.setattr(config, 'OPENAI_BASE_URL', 'https://qwen.invalid/v1')
  232. monkeypatch.setattr(gateway_queries, 'get_service', lambda _: gateway)
  233. monkeypatch.setattr(history, 'get_service', lambda _: gateway)
  234. original = json.dumps(conclusion(), ensure_ascii=False)[:-1]
  235. calls = []
  236. async def respond(request):
  237. body = json.loads(request.content)
  238. calls.append(body)
  239. if len(calls) == 1:
  240. assert 'pdf' in json.loads(body['messages'][1]['content'])
  241. reply = original
  242. else:
  243. inputs = json.loads(body['messages'][1]['content'])
  244. assert set(inputs) == {'invalid_json', 'required_schema', 'validation_error'}
  245. assert '原件为模拟资料' in inputs['invalid_json']
  246. reply = original if repair_outcome == 'invalid_json' else json.dumps(
  247. conclusion('WARNING' if repair_outcome == 'changed_result' else 'NORMAL'), ensure_ascii=False)
  248. return httpx.Response(200, json={'id': 'synthetic-' + str(len(calls)),
  249. 'usage': {'prompt_tokens': 10, 'completion_tokens': 20},
  250. 'choices': [{'finish_reason': 'stop', 'message': {'content': reply}}]})
  251. monkeypatch.setattr(workflow, 'qwen_http_client', lambda: httpx.AsyncClient(transport=httpx.MockTransport(respond)))
  252. output = batch_workspace.root / ('repair-' + repair_outcome)
  253. result = await BatchRunner(output, batch_workspace.pdf).run(
  254. {'dept_ids': ['1219'], 'cutoff': '2026-10-20 15:00:00', 'lookback_days': 1},
  255. snapshot={'snapshot_id': 'f' * 32, 'mines': [batch_workspace.mine]})
  256. assert len(calls) == (2 if repair_outcome == 'success' else 4)
  257. execution = json.loads((output / '1219/execution-unresolved-main.json').read_text())
  258. assert execution['diagnostics']['judgment_calls'] == 1
  259. assert execution['diagnostics']['json_repair_calls'] == len(calls) - 1
  260. assert not business_store.list_jobs('main_fan', 'main-fan-batch', 100)
  261. folder = output / '1219/unresolved-main'
  262. if repair_outcome == 'success':
  263. assert result['status'] == 'completed'
  264. first = json.loads((folder / 'Qwen原始响应.json').read_text())['choices'][0]['message']['content']
  265. assert first == original
  266. final = json.loads((folder / 'Qwen最终响应.json').read_text())['choices'][0]['message']['content']
  267. assert json.loads(final) == conclusion()
  268. else:
  269. assert result['status'] == 'partial'
  270. assert execution['error'] == 'qwen_json_repair_exhausted'
  271. assert not (folder / '待审阅报文.json').exists()
  272. @pytest.mark.asyncio
  273. @pytest.mark.parametrize('invalid', [False, True])
  274. async def test_production_batch_calls_qwen_once_archives_inputs_and_never_pushes(batch_workspace, monkeypatch, invalid):
  275. from extensions.main_fan import gateway_queries, history
  276. gateway = FixedGateway(batch_workspace.mine)
  277. monkeypatch.setenv('SHANXI_GATEWAY_ENABLED', '1')
  278. monkeypatch.setenv('MAIN_FAN_WORKFLOW_PUBLISH_ENABLED', 'False')
  279. monkeypatch.setenv('BILLING_ENABLED', 'False')
  280. monkeypatch.setattr(config, 'MODEL', 'Qwen/Qwen3.5-35B-A3B-FP8')
  281. monkeypatch.setattr(config, 'THINKING_LEVEL', 'highest')
  282. monkeypatch.setattr(config, 'OPENAI_BASE_URL', 'https://qwen.invalid/v1')
  283. monkeypatch.setattr(gateway_queries, 'get_service', lambda _: gateway)
  284. monkeypatch.setattr(history, 'get_service', lambda _: gateway)
  285. calls = []
  286. async def respond(request):
  287. body = json.loads(request.content)
  288. calls.append(body)
  289. assert 'tools' not in body and len(body['messages']) == 2
  290. assert body['chat_template_kwargs']['enable_thinking'] is True
  291. assert body['response_format']['type'] == 'json_schema'
  292. assert body['response_format']['json_schema']['strict'] is True
  293. inputs = json.loads(body['messages'][1]['content'])
  294. assert inputs['pdf'][0]['pages'][0]['method'] == 'python'
  295. assert len(inputs['airflow']['flows']) == 2 and len(inputs['switching']['states']) == 4
  296. return httpx.Response(200, json={'id': 'synthetic-qwen-response',
  297. 'usage': {'prompt_tokens': 123, 'completion_tokens': 45, 'total_tokens': 168},
  298. 'choices': [{'finish_reason': 'stop', 'message': {'content': '{}' if invalid else json.dumps(conclusion()),
  299. 'reasoning': 'Synthetic provider reasoning retained only in raw response.'}}]})
  300. # 在默认工作流的 HTTP 边界替换网络,不注入另一个业务执行器。
  301. monkeypatch.setattr(workflow, 'qwen_http_client',
  302. lambda: httpx.AsyncClient(transport=httpx.MockTransport(respond)))
  303. output = batch_workspace.root / ('invalid-workflow' if invalid else 'workflow-output')
  304. result = await BatchRunner(output, batch_workspace.pdf).run(
  305. {'dept_ids': ['1219'], 'cutoff': '2026-10-20 15:00:00', 'lookback_days': 1},
  306. snapshot={'snapshot_id': 'e' * 32, 'mines': [batch_workspace.mine]})
  307. assert len(calls) == 1 and not business_store.list_jobs('main_fan', 'main-fan-batch', 100)
  308. assert result['publish'] is False
  309. folder = output / '1219/unresolved-main'
  310. if invalid:
  311. assert result['status'] == 'partial'
  312. assert not (folder / '待审阅报文.json').exists()
  313. assert 'qwen_schema_validation' in result['mines'][0]['shafts'][0]['error']
  314. else:
  315. assert result['status'] == 'completed', result
  316. payload = PlatformResultPayload.model_validate_json((folder / '待审阅报文.json').read_text())
  317. assert payload.mineCode == batch_workspace.mine['mine_id']
  318. assert payload.judgment.resultCode == 'NORMAL'
  319. assert (folder / '给Qwen的数据.json').is_file() and (folder / 'PDF提取结果.json').is_file()
  320. assert 'reasoning' in (folder / 'Qwen原始响应.json').read_text()
  321. assert json.loads((folder / '推送记录.json').read_text())['status'] == 'local_only'