| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355 |
- """固定工作流回归:PDF 分流、完整输入、单次 Qwen、标准报文及禁止推送。"""
- import json
- import shutil
- import httpx
- from PIL import Image
- import pypdfium2 as pdfium
- import pytest
- import config
- from db import business_store, main_fan_store
- from extensions.main_fan import exploration, workflow, workflow_documents, workflow_json_repair, workflow_page_filter
- from extensions.main_fan.batch import BatchRunner
- from runtime.python_sandbox import digest, workspace_file
- from runtime.result_models import PlatformResultPayload
- from test_main_fan_batch import batch_workspace, bound
- from test_main_fan_documents import native_pdf
- from test_main_fan_preparation import FixedGateway
- def conclusion(code='NORMAL'):
- return {'objectDetail': '测试甲矿主备风机(通风井归属待确认)',
- 'dataDetail': '测试资料,开停及风量来自固定代码。',
- 'judgment': {'resultCode': code, 'result': '现有数据未发现确凿异常。',
- 'reason': '原件为模拟资料,不代入本矿实际结论;未提供准确切换间隔。', 'evidenceLink': ''}}
- @pytest.mark.asyncio
- async def test_mixed_pdf_uses_python_and_ocr_then_rebinds_raw_cache(batch_workspace, monkeypatch):
- native, scan, mixed = [batch_workspace.root / name for name in ('native.pdf', 'scan.pdf', 'mixed.pdf')]
- native_pdf(native)
- image = Image.new('RGB', (300, 400), 'white')
- image.save(scan, 'PDF')
- image.close()
- with pdfium.PdfDocument.new() as combined:
- with pdfium.PdfDocument(native) as doc:
- combined.import_pages(doc)
- with pdfium.PdfDocument(scan) as doc:
- combined.import_pages(doc)
- combined.save(mixed)
- calls = []
- async def respond(request):
- calls.append(json.loads(request.content))
- assert 'authorization' not in request.headers
- return httpx.Response(200, json={'choices': [{'finish_reason': 'stop',
- 'message': {'content': '合成扫描页:风量 80.50 m³/s,功率 132.6 kW。'}}]})
- with bound(batch_workspace) as (scope, identifier):
- virtual = '/workspace/exploration/' + identifier + '/mixed.pdf'
- local = workspace_file(scope.user, scope.session, virtual)
- shutil.copyfile(mixed, local)
- material = {'path': virtual, 'filename': 'mixed.pdf', 'sha256': digest(local),
- 'page_count': 2, 'is_mock': True, 'binding': 'synthetic'}
- async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as client:
- first = workflow_documents.PDFExtractor(scope.user, scope.session,
- '/workspace/exploration/' + identifier + '/first', scope.cache_root, client=client)
- reports = await first.extract([material])
- assert reports[0]['complete'] and not reports[0]['reused']
- assert [row['method'] for row in reports[0]['pages']] == ['python', 'glm_ocr']
- assert 'NATIVE-TEST-001' in reports[0]['pages'][0]['text']
- assert len(calls) == 1 and calls[0]['messages'][0]['content'][1]['text'] == 'Text Recognition:'
- second = workflow_documents.PDFExtractor(scope.user, scope.session,
- '/workspace/exploration/' + identifier + '/second', scope.cache_root, client=client)
- cached = await second.extract([material])
- assert cached[0]['reused'] and len(calls) == 1
- assert all('/second/' in row['text_path'] for row in cached[0]['pages'])
- def test_watermark_or_large_embedded_scan_is_not_full_native_text():
- assert not workflow_documents.usable_text('契约锁\n扫描全能王')
- assert workflow_documents.usable_text('Main fan performance table report with readable values 80.5 132.6')
- class ImageObject:
- def get_bounds(self):
- return (0, 0, 595, 600)
- class Page:
- def get_size(self):
- return (595, 842)
- def get_objects(self, **kwargs):
- return iter([ImageObject()])
- assert workflow_documents.large_raster(Page()), '带可读页眉的扫描表格也必须送 OCR'
- def test_full_monitoring_expands_existing_results_without_recalculation(batch_workspace):
- with bound(batch_workspace) as (scope, identifier):
- current = main_fan_store.workflow(identifier, scope.user, scope.session)
- prefix = '/workspace/exploration/' + identifier
- periods = [{'state': '开' if i % 2 else '停', 'observed_from': '2026-10-01T00:00:00+08:00',
- 'observed_until': '2026-10-01T00:01:00+08:00', 'sample_count': 1} for i in range(25)]
- events = [{'event_id': 'S' + str(i), 'from_fan': '1号风机', 'to_fan': '2号风机',
- 'after': '2026-10-01T00:00:00+08:00', 'by': '2026-10-01T00:01:00+08:00',
- 'flow_context': [], 'flow_comparison': {'method': '已整理的单点对照',
- 'relative_change_percent': None, 'invalid_reasons': ['flow_pair_not_unique']}} for i in range(25)]
- details = {'cutoff': scope.cutoff, 'lookback_days': 40, 'mine': scope.mine,
- 'points': [{'node_id': 'state1', 'label': '1号一级', 'system': 'safety', 'role': 'fan_state',
- 'state_intervals': periods, 'state_changes': [], 'state_value_semantics': '接口原标签'}],
- 'preprocessing': {'switch_candidates': events}}
- detail_path, compact_path = prefix + '/details.json', prefix + '/compact.json'
- exploration._write(workspace_file(scope.user, scope.session, detail_path), details)
- exploration._write(workspace_file(scope.user, scope.session, compact_path), {'details_path': detail_path})
- context = current['context']
- context['prepared_data'] = {'agent_data_path': compact_path}
- context.setdefault('query_exports', {})[detail_path] = digest(workspace_file(scope.user, scope.session, detail_path))
- actual = workflow.full_monitoring(context, scope.user, scope.session)
- assert len(actual['states'][0]['periods']) == 25
- assert len(actual['switch_candidates']) == 25
- assert not actual['states'][0].get('truncated')
- assert actual['switch_candidates'][-1]['comparison']['change_percent'] is None
- @pytest.mark.parametrize('raw', ['', '准备调用工具', '{"objectDetail":"a"}', json.dumps(conclusion(), ensure_ascii=False) + ' trailing'])
- def test_invalid_model_reply_never_becomes_completed_result(raw):
- with pytest.raises(workflow.WorkflowError):
- workflow.parse_decision(raw)
- def test_unescaped_quotes_report_json_position_without_disclosing_body():
- raw = json.dumps(conclusion(), ensure_ascii=False).replace('现有数据未发现确凿异常。', '核心项"性能一致性"未核实。')
- with pytest.raises(workflow.WorkflowError) as error:
- workflow.parse_decision(raw)
- assert error.value.code == 'qwen_invalid_json'
- assert error.value.details['error_type'] == 'JSONDecodeError'
- assert error.value.details['line'] == 1 and error.value.details['column'] > 1
- assert 'objectDetail' not in str(error.value)
- def test_schema_failure_is_distinct_from_invalid_json():
- with pytest.raises(workflow.WorkflowError) as error:
- workflow.parse_decision('{}')
- assert error.value.code == 'qwen_schema_validation'
- assert error.value.details['errors'][0]['loc'] == ('objectDetail',)
- assert 'input' not in error.value.details['errors'][0]
- def test_json_repair_can_fill_fixed_link_but_preserves_business_values():
- value = conclusion()
- value['dataDetail'] = '风量96.7 m³/s,静压495.58 Pa。'
- original = json.dumps(value, ensure_ascii=False)[:-1]
- assert workflow_json_repair.can_repair(original, 'qwen_invalid_json')
- candidate = workflow.WorkflowDecision.model_validate(value)
- assert not workflow_json_repair.preservation_errors(original, candidate)
- candidate.dataDetail = '风量97.7 m³/s,静压495.58 Pa。'
- assert 'numeric_content' in workflow_json_repair.preservation_errors(original, candidate)
- candidate.dataDetail = '风量96.7 m³/s,静压495.58 kPa。'
- assert 'identifiers_or_units' in workflow_json_repair.preservation_errors(original, candidate)
- missing_link = json.loads(json.dumps(value))
- missing_link['judgment'].pop('evidenceLink')
- assert workflow_json_repair.can_repair(json.dumps(missing_link), 'qwen_schema_validation')
- assert not workflow_json_repair.preservation_errors(json.dumps(missing_link),
- workflow.WorkflowDecision.model_validate(value))
- def test_all_failed_pdf_pages_do_not_block_available_monitoring():
- reports = [{'path': '/workspace/report.pdf', 'pages': [
- {'page': number, 'status': 'failed', 'text': '', 'error_stage': 'python_text',
- 'error': 'TypeError: unsupported context manager'} for number in (1, 2)]}]
- monitoring = {'states': [{'periods': [{'state': '开'}]}], 'flows': [], 'switch_candidates': []}
- availability = workflow.data_availability(reports, monitoring)
- assert availability['any_available'] and availability['pdf_readable_pages'] == 0
- assert availability['known_state_periods'] == 1
- workflow.validate_result_policy(workflow.WorkflowDecision.model_validate(conclusion()), availability)
- @pytest.mark.parametrize('monitoring', [
- {'flows': [{'samples': 1, 'range': [0, 0]}]},
- {'switch_candidates': [{'comparison': {'before_value': 100, 'after_value': 0, 'change_percent': 100}}]},
- {'switch_candidates': [{'flow': [{'before': None, 'after': ['2026-10-22 00:00:00', 0]}]}]},
- ])
- def test_zero_flow_and_partial_reference_values_count_as_available_data(monitoring):
- availability = workflow.data_availability([], monitoring)
- assert availability['any_available']
- codes = workflow.decision_schema(availability)['$defs']['WorkflowJudgment']['properties']['resultCode']['enum']
- assert codes == ['NORMAL', 'WARNING', 'ABNORMAL']
- with pytest.raises(workflow.WorkflowError) as error:
- workflow.validate_result_policy(workflow.WorkflowDecision.model_validate(conclusion('UNDETERMINED')), availability)
- assert error.value.code == 'workflow_result_policy'
- def test_undetermined_only_when_all_three_sources_have_no_usable_data():
- reports = [{'is_mock': True, 'pages': [{'status': 'completed', 'text': '合成报告'}]}]
- monitoring = {'states': [{'periods': [{'state': '未知'}]}], 'flows': [{'samples': 0}],
- 'switch_candidates': [{'duration_minutes': None, 'comparison': {'change_percent': None}}]}
- availability = workflow.data_availability(reports, monitoring)
- assert not availability['any_available']
- codes = workflow.response_format(availability)['json_schema']['schema']['$defs']['WorkflowJudgment']['properties']['resultCode']['enum']
- assert codes == ['UNDETERMINED']
- decision = workflow.WorkflowDecision.model_validate(conclusion('UNDETERMINED'))
- workflow.validate_result_policy(decision, availability)
- with pytest.raises(workflow.WorkflowError):
- workflow.validate_result_policy(workflow.WorkflowDecision.model_validate(conclusion()), availability)
- def test_readable_real_report_counts_when_monitoring_is_missing():
- reports = [{'is_mock': False, 'pages': [{'status': 'completed', 'text': '测试甲矿通风机性能报告'}]}]
- availability = workflow.data_availability(reports, {})
- assert availability['any_available'] and availability['pdf_readable_pages'] == 1
- workflow.validate_result_policy(workflow.WorkflowDecision.model_validate(conclusion()), availability)
- def test_page_filter_only_removes_known_irrelevant_pages_and_preserves_source():
- pages = [{'page': number, 'status': 'completed', 'text': text} for number, text in enumerate([
- '报告封面:测试甲矿,编号R-01', '声 明\n本报告未经批准不得复制。',
- '检测检验用仪器设备一览表\n记录包含风量,保守保留', '性能测定数据\n频率45Hz,叶片角度0°',
- '', '', '资质证书\n附有本矿通风机编号与额定功率', '',
- ], 1)]
- pages[5]['status'] = 'failed'
- pages[7]['blank'] = True
- source = {'path': '/workspace/report.pdf', 'page_count': 8, 'complete': False, 'pages': pages}
- selected, manifest = workflow_page_filter.select_pages([source])
- assert [p['page'] for p in selected[0]['pages']] == [1, 3, 4, 5, 6, 7]
- assert [p['page'] for p in source['pages']] == list(range(1, 9))
- assert 'page_selection' not in source and selected[0]['page_count'] == 8
- assert manifest[0]['excluded_pages'] == [{'page': 2, 'reason': '声明页'}, {'page': 8, 'reason': '空白页'}]
- @pytest.mark.asyncio
- @pytest.mark.parametrize('primary_failure,fallback_failure', [('length', False), ('repetition', False), ('length', True)])
- async def test_failed_glm_page_uses_qwen_once_and_retains_both_responses(batch_workspace, monkeypatch, primary_failure, fallback_failure):
- monkeypatch.setattr(config, 'MODEL', 'Qwen/Qwen3.5-35B-A3B-FP8')
- monkeypatch.setattr(config, 'OPENAI_BASE_URL', 'https://qwen.invalid/v1')
- monkeypatch.setattr(config, 'OPENAI_API_KEY', 'qwen-only-key')
- monkeypatch.setattr(config, 'THINKING_LEVEL', 'highest')
- calls, qwen_calls, usages = [], [], []
- async def respond(request):
- body = json.loads(request.content)
- calls.append(body)
- if body['model'] == 'glm-ocr':
- assert 'authorization' not in request.headers
- finish, text = ('stop', '1.0500 1.1429\n' * 20) if primary_failure == 'repetition' else ('length', '不完整页')
- else:
- assert request.headers['authorization'] == 'Bearer qwen-only-key'
- assert body['chat_template_kwargs']['enable_thinking'] is True
- finish, text = ('length', '仍未完整') if fallback_failure else ('stop', '测试甲矿2号风机45Hz性能表:风量96.7,静压495.58。')
- return httpx.Response(200, json={'choices': [{'finish_reason': finish, 'message': {'content': text}}],
- 'usage': {'prompt_tokens': 10, 'completion_tokens': 20}})
- with bound(batch_workspace) as (scope, identifier):
- directory = workspace_file(scope.user, scope.session, '/workspace/exploration/' + identifier + '/page.png').parent
- image = Image.new('RGB', (30, 40), 'white')
- image.save(directory / 'page.png')
- image.close()
- row = {'page': 16, 'status': 'pending', 'method': 'glm_ocr', 'text': '', 'image_name': 'page.png'}
- async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as client:
- extractor = workflow_documents.PDFExtractor(scope.user, scope.session, '/workspace/exploration/' + identifier,
- scope.cache_root, client=client, on_qwen_call=lambda: qwen_calls.append(True), on_qwen_usage=usages.append)
- await extractor._ocr(row, directory, client)
- assert len(calls) == 2 and len(qwen_calls) == 1 and len(usages) == 1
- assert row['method'] == 'qwen_ocr' and row['fallback_used'] and len(row['attempts']) == 2
- assert row['status'] == ('failed' if fallback_failure else 'completed')
- assert bool(row['text']) is not fallback_failure
- assert (directory / 'page-0016.response.json').is_file()
- assert (directory / 'page-0016.qwen.response.json').is_file()
- @pytest.mark.asyncio
- @pytest.mark.parametrize('repair_outcome', ['success', 'invalid_json', 'changed_result'])
- async def test_json_repair_is_bounded_does_not_resend_source_or_reassess(batch_workspace, monkeypatch, repair_outcome):
- from extensions.main_fan import gateway_queries, history
- gateway = FixedGateway(batch_workspace.mine)
- monkeypatch.setenv('SHANXI_GATEWAY_ENABLED', '1')
- monkeypatch.setenv('MAIN_FAN_WORKFLOW_PUBLISH_ENABLED', 'False')
- monkeypatch.setenv('MAIN_FAN_JSON_REPAIR_MAX_ATTEMPTS', '3')
- monkeypatch.setenv('BILLING_ENABLED', 'False')
- monkeypatch.setattr(config, 'MODEL', 'Qwen/Qwen3.5-35B-A3B-FP8')
- monkeypatch.setattr(config, 'THINKING_LEVEL', 'highest')
- monkeypatch.setattr(config, 'OPENAI_BASE_URL', 'https://qwen.invalid/v1')
- monkeypatch.setattr(gateway_queries, 'get_service', lambda _: gateway)
- monkeypatch.setattr(history, 'get_service', lambda _: gateway)
- original = json.dumps(conclusion(), ensure_ascii=False)[:-1]
- calls = []
- async def respond(request):
- body = json.loads(request.content)
- calls.append(body)
- if len(calls) == 1:
- assert 'pdf' in json.loads(body['messages'][1]['content'])
- reply = original
- else:
- inputs = json.loads(body['messages'][1]['content'])
- assert set(inputs) == {'invalid_json', 'required_schema', 'validation_error'}
- assert '原件为模拟资料' in inputs['invalid_json']
- reply = original if repair_outcome == 'invalid_json' else json.dumps(
- conclusion('WARNING' if repair_outcome == 'changed_result' else 'NORMAL'), ensure_ascii=False)
- return httpx.Response(200, json={'id': 'synthetic-' + str(len(calls)),
- 'usage': {'prompt_tokens': 10, 'completion_tokens': 20},
- 'choices': [{'finish_reason': 'stop', 'message': {'content': reply}}]})
- monkeypatch.setattr(workflow, 'qwen_http_client', lambda: httpx.AsyncClient(transport=httpx.MockTransport(respond)))
- output = batch_workspace.root / ('repair-' + repair_outcome)
- result = await BatchRunner(output, batch_workspace.pdf).run(
- {'dept_ids': ['1219'], 'cutoff': '2026-10-20 15:00:00', 'lookback_days': 1},
- snapshot={'snapshot_id': 'f' * 32, 'mines': [batch_workspace.mine]})
- assert len(calls) == (2 if repair_outcome == 'success' else 4)
- execution = json.loads((output / '1219/execution-unresolved-main.json').read_text())
- assert execution['diagnostics']['judgment_calls'] == 1
- assert execution['diagnostics']['json_repair_calls'] == len(calls) - 1
- assert not business_store.list_jobs('main_fan', 'main-fan-batch', 100)
- folder = output / '1219/unresolved-main'
- if repair_outcome == 'success':
- assert result['status'] == 'completed'
- first = json.loads((folder / 'Qwen原始响应.json').read_text())['choices'][0]['message']['content']
- assert first == original
- final = json.loads((folder / 'Qwen最终响应.json').read_text())['choices'][0]['message']['content']
- assert json.loads(final) == conclusion()
- else:
- assert result['status'] == 'partial'
- assert execution['error'] == 'qwen_json_repair_exhausted'
- assert not (folder / '待审阅报文.json').exists()
- @pytest.mark.asyncio
- @pytest.mark.parametrize('invalid', [False, True])
- async def test_production_batch_calls_qwen_once_archives_inputs_and_never_pushes(batch_workspace, monkeypatch, invalid):
- from extensions.main_fan import gateway_queries, history
- gateway = FixedGateway(batch_workspace.mine)
- monkeypatch.setenv('SHANXI_GATEWAY_ENABLED', '1')
- monkeypatch.setenv('MAIN_FAN_WORKFLOW_PUBLISH_ENABLED', 'False')
- monkeypatch.setenv('BILLING_ENABLED', 'False')
- monkeypatch.setattr(config, 'MODEL', 'Qwen/Qwen3.5-35B-A3B-FP8')
- monkeypatch.setattr(config, 'THINKING_LEVEL', 'highest')
- monkeypatch.setattr(config, 'OPENAI_BASE_URL', 'https://qwen.invalid/v1')
- monkeypatch.setattr(gateway_queries, 'get_service', lambda _: gateway)
- monkeypatch.setattr(history, 'get_service', lambda _: gateway)
- calls = []
- async def respond(request):
- body = json.loads(request.content)
- calls.append(body)
- assert 'tools' not in body and len(body['messages']) == 2
- assert body['chat_template_kwargs']['enable_thinking'] is True
- assert body['response_format']['type'] == 'json_schema'
- assert body['response_format']['json_schema']['strict'] is True
- inputs = json.loads(body['messages'][1]['content'])
- assert inputs['pdf'][0]['pages'][0]['method'] == 'python'
- assert len(inputs['airflow']['flows']) == 2 and len(inputs['switching']['states']) == 4
- return httpx.Response(200, json={'id': 'synthetic-qwen-response',
- 'usage': {'prompt_tokens': 123, 'completion_tokens': 45, 'total_tokens': 168},
- 'choices': [{'finish_reason': 'stop', 'message': {'content': '{}' if invalid else json.dumps(conclusion()),
- 'reasoning': 'Synthetic provider reasoning retained only in raw response.'}}]})
- # 在默认工作流的 HTTP 边界替换网络,不注入另一个业务执行器。
- monkeypatch.setattr(workflow, 'qwen_http_client',
- lambda: httpx.AsyncClient(transport=httpx.MockTransport(respond)))
- output = batch_workspace.root / ('invalid-workflow' if invalid else 'workflow-output')
- result = await BatchRunner(output, batch_workspace.pdf).run(
- {'dept_ids': ['1219'], 'cutoff': '2026-10-20 15:00:00', 'lookback_days': 1},
- snapshot={'snapshot_id': 'e' * 32, 'mines': [batch_workspace.mine]})
- assert len(calls) == 1 and not business_store.list_jobs('main_fan', 'main-fan-batch', 100)
- assert result['publish'] is False
- folder = output / '1219/unresolved-main'
- if invalid:
- assert result['status'] == 'partial'
- assert not (folder / '待审阅报文.json').exists()
- assert 'qwen_schema_validation' in result['mines'][0]['shafts'][0]['error']
- else:
- assert result['status'] == 'completed', result
- payload = PlatformResultPayload.model_validate_json((folder / '待审阅报文.json').read_text())
- assert payload.mineCode == batch_workspace.mine['mine_id']
- assert payload.judgment.resultCode == 'NORMAL'
- assert (folder / '给Qwen的数据.json').is_file() and (folder / 'PDF提取结果.json').is_file()
- assert 'reasoning' in (folder / 'Qwen原始响应.json').read_text()
- assert json.loads((folder / '推送记录.json').read_text())['status'] == 'local_only'
|