test_main_fan_documents.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. """文字、扫描、混合 PDF 路由及缓存隔离;测试数据不属于现场报告。"""
  2. import json
  3. from datetime import date
  4. from pathlib import Path
  5. from PIL import Image
  6. import pypdfium2 as pdfium
  7. import pytest
  8. from extensions.main_fan import documents
  9. from db import main_fan_store
  10. def native_pdf(target: Path):
  11. lines = ["SYNTHETIC NATIVE TEXT TEST - NOT A REAL MINE REPORT",
  12. "Main ventilation fan system performance report",
  13. "Report ID: NATIVE-TEST-001", "Inspected mine: Synthetic Native Test Mine",
  14. "Equipment: 1# main ventilation fan", "Inspection date: 2026-01-02",
  15. "Recommended next inspection: 2029-01-01", "Model: SYNTHETIC-FAN Serial: TEST-001",
  16. "30 Hz measured operating condition: Flow 60 m3/s; static pressure 500 Pa;",
  17. "Shaft power 50 kW; electric input power 55 kW; static efficiency 60 percent.",
  18. "Original conclusion: Synthetic data for text extraction test only."]
  19. content = b"BT /F1 10 Tf 50 780 Td "
  20. for index, line in enumerate(lines):
  21. if index: content += b"0 -18 Td "
  22. content += b"(" + line.encode("ascii") + b") Tj\n"
  23. content += b"ET\n"
  24. objects = [b"<< /Type /Catalog /Pages 2 0 R >>", b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
  25. b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
  26. b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
  27. b"<< /Length " + str(len(content)).encode() + b" >>\nstream\n"+content+b"endstream"]
  28. value, offsets = b"%PDF-1.4\n", [0]
  29. for i, obj in enumerate(objects, 1):
  30. offsets.append(len(value)); value += str(i).encode()+b" 0 obj\n"+obj+b"\nendobj\n"
  31. xref = len(value)
  32. value += f"xref\n0 {len(objects)+1}\n0000000000 65535 f \n".encode()
  33. value += b"".join(f"{offset:010d} 00000 n \n".encode() for offset in offsets[1:])
  34. value += f"trailer\n<< /Size {len(objects)+1} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode()
  35. target.write_bytes(value)
  36. def report(page=1, mine="Auto Extracted Mine"):
  37. return {"reports": [{"report_id": "AUTO-001", "mine_name": mine, "fan_name": "1号通风机",
  38. "evidence": {"page": page, "excerpt": "Automatically supplied test response"}}]}
  39. def test_native_scan_and_mixed_pdf_routes(tmp_path):
  40. native, scanned, mixed = [tmp_path/name for name in ("native.pdf", "scan.pdf", "mixed.pdf")]
  41. native_pdf(native)
  42. Image.new("RGB", (120, 160), "white").save(scanned, "PDF")
  43. with pdfium.PdfDocument.new() as combined:
  44. with pdfium.PdfDocument(native) as first: combined.import_pages(first)
  45. with pdfium.PdfDocument(scanned) as second: combined.import_pages(second)
  46. combined.save(mixed)
  47. for source, expected in [(native, ["text"]), (scanned, ["vision"]), (mixed, ["text", "vision"])]:
  48. folder = tmp_path/source.stem; folder.mkdir()
  49. pages = documents._prepare(source, folder)
  50. assert [p["route"] for p in pages] == expected
  51. if source == native:
  52. assert "NATIVE-TEST-001" in pages[0]["text"]
  53. assert pages[0]["image"] is None and not list(folder.glob("*.png"))
  54. @pytest.mark.asyncio
  55. async def test_native_pdf_needs_no_vision_setting_and_sends_no_images(tmp_path, monkeypatch):
  56. source = tmp_path/"native.pdf"; native_pdf(source)
  57. monkeypatch.setenv("MAIN_FAN_VISION_MODEL", "")
  58. calls = []
  59. class TextModel:
  60. async def ainvoke(self, messages, config):
  61. from langchain_core.messages import AIMessage
  62. calls.append(messages)
  63. return AIMessage(content=json.dumps(report()), response_metadata={"finish_reason": "stop"},
  64. usage_metadata={"input_tokens": 5,"output_tokens": 5,"total_tokens":10})
  65. monkeypatch.setattr("agents.model_factory.build_model", lambda **kw: TextModel())
  66. facts = await documents.read_document(source,"/workspace/native.pdf",cache_policy="refresh")
  67. assert facts.reports[0].evidence.method == "text_extraction"
  68. assert facts.extraction_audit["pages"][0]["route"] == "text"
  69. assert facts.extraction_audit["cache_used"] is False
  70. assert all(block["type"] == "text" for block in calls[0][-1].content)
  71. @pytest.mark.asyncio
  72. async def test_default_reader_rejects_manually_seeded_cache(tmp_path):
  73. source=tmp_path/"scan.pdf"; Image.new("RGB", (120, 160), "white").save(source,"PDF")
  74. digest=documents.fingerprint(source)
  75. main_fan_store.save_document(digest,report(mine="Pretranscribed Mine"),"manual_transcription")
  76. seen=[]
  77. async def extractor(pages,folder):
  78. seen.extend(pages)
  79. return report(mine="Fresh Automatic Mine")
  80. facts=await documents.read_document(source,"/workspace/scan.pdf",extractor=extractor)
  81. assert seen and facts.reports[0].mine_name == "Fresh Automatic Mine"
  82. assert facts.extraction_audit["transcript_seed_loaded"] is False
  83. @pytest.mark.asyncio
  84. @pytest.mark.parametrize("policy",["refresh","off"])
  85. async def test_refresh_and_off_bypass_existing_automatic_cache(tmp_path,policy):
  86. source=tmp_path/"scan.pdf"; Image.new("RGB", (120, 160), "white").save(source,"PDF")
  87. digest=documents.fingerprint(source)
  88. main_fan_store.save_document(digest,report(mine="Old Auto Mine"),"automatic_extraction")
  89. async def extractor(pages,folder): return report(mine="Fresh Auto Mine")
  90. facts=await documents.read_document(source,"/workspace/scan.pdf",extractor=extractor,cache_policy=policy)
  91. assert facts.reports[0].mine_name == "Fresh Auto Mine"
  92. assert facts.extraction_audit["cache_used"] is False
  93. cached=main_fan_store.document(digest)
  94. assert cached["reports"][0]["mine_name"] == ("Old Auto Mine" if policy=="off" else "Fresh Auto Mine")
  95. @pytest.mark.asyncio
  96. async def test_truncated_model_output_is_audited_but_never_cached(tmp_path,monkeypatch):
  97. source=tmp_path/"scan.pdf"; Image.new("RGB", (120, 160), "white").save(source,"PDF")
  98. monkeypatch.setenv("MAIN_FAN_VISION_MODEL","test-vision")
  99. class Truncated:
  100. async def ainvoke(self,*args,**kw):
  101. from langchain_core.messages import AIMessage
  102. return AIMessage(content='{"reports":[',response_metadata={"finish_reason":"length"})
  103. monkeypatch.setattr("agents.model_factory.build_model",lambda **kw:Truncated())
  104. with pytest.raises(ValueError,match="截断"):
  105. await documents.read_document(source,"/workspace/scan.pdf",audit_dir=tmp_path/"audit")
  106. assert main_fan_store.document(documents.fingerprint(source)) is None
  107. call=json.loads(next((tmp_path/"audit").glob('call-*.json')).read_text())
  108. assert call["finish_reason"] == "length" and call["route"] == "vision"
  109. def test_multi_page_identity_normalizes_spacing_and_fan_notation():
  110. from algorithms.main_fan.models import DocumentFacts
  111. all_facts,reports=DocumentFacts(),{}
  112. first=DocumentFacts.model_validate(report())
  113. first.reports[0].report_id="ABC - 001"
  114. first.reports[0].fan_name="1#通风机"
  115. documents._merge_batch(all_facts,reports,first,[{"page":1,"route":"vision"}])
  116. second=DocumentFacts.model_validate(report(page=2))
  117. second.reports[0].report_id="ABC-001"
  118. documents._merge_batch(all_facts,reports,second,[{"page":2,"route":"text"}])
  119. assert len(reports)==1
  120. def test_source_units_and_input_power_cannot_create_performance_rows():
  121. from algorithms.main_fan.models import DocumentFacts
  122. facts=DocumentFacts.model_validate({'reports':[{'report_id':'SYN','mine_name':'测试矿','fan_name':'1号通风机','points':[
  123. {'label':'判定汇总','frequency_hz':30,'flow_m3_s':63.11/60,'static_pressure_pa':520.74,'shaft_power_kw':56.12,
  124. 'evidence':{'page':1,'excerpt':'30Hz63.11m³/s;静压520.74Pa;电动机运行功率56.12kW'}}]}]})
  125. issues=documents._numeric_issues(facts)
  126. assert len(issues)==1
  127. assert any('风量原文单位' in reason for reason in issues[0]['reasons'])
  128. assert any('原文未明确轴功率' in reason for reason in issues[0]['reasons'])
  129. assert facts.reports[0].points[0].shaft_power_kw==56.12 # 校验只标记,不能按公式改数。
  130. def test_curve_caption_must_match_table_flow_and_shaft_power_before_comparison():
  131. from algorithms.main_fan.models import DocumentFacts
  132. from algorithms.main_fan.analysis import screen
  133. from algorithms.main_fan.models import ScreeningInput
  134. facts=DocumentFacts.model_validate({'reports':[{'report_id':'SYN','mine_name':'测试矿','fan_name':'1号通风机',
  135. 'points':[{'label':'运行工况','frequency_hz':30,'condition':'converted','flow_m3_s':105,
  136. 'static_pressure_pa':1600,'shaft_power_kw':260}],
  137. 'curves':[{'label':'图注','frequency_hz':30,'condition':'converted','flow_m3_s':63,
  138. 'static_pressure_pa':510,'shaft_power_kw':51}]}]})
  139. analysis=screen(ScreeningInput(mine_code='synthetic',mine_name='测试矿',fan_name='1号通风机',reports=facts.reports))
  140. assert not any(f.code=='curve_table_disagreement' for f in analysis.findings)
  141. @pytest.mark.parametrize("key,value,excerpt,valid",[
  142. ("inspected_on", date(2022,3,18), "检测检验日期:2022年03月18日", True),
  143. ("inspected_on", date(2022,3,18), "检测检验日期 2022 年 03 月 18 日", True),
  144. ("recommended_next_on", date(2025,3,17), "建议下次检测检验日期 2025 年 03 月 17 日", True),
  145. ("inspected_on", date(2017,8,1), "生产/出厂日期:2017年08月", False),
  146. ("inspected_on", date(2025,3,17), "建议下次检测检验日期:2025年03月17日", False),
  147. ("recommended_next_on", date(2025,3,17), "建议下次检测检验日期:2025年03月17日", True),
  148. ("inspected_on", date(2026,1,2), "Inspection date: 2026-01-02", True),
  149. ("inspected_on", date(2023,3,18), "检测检验日期:2022年03月18日", False)])
  150. def test_date_requires_matching_field_label_and_year_month_day(key,value,excerpt,valid):
  151. assert documents._date_supported(value,key,excerpt) == valid
  152. def test_missing_identity_page_can_only_inherit_a_matching_automatic_report():
  153. from algorithms.main_fan.models import DocumentFacts
  154. all_facts,reports=DocumentFacts(),{}
  155. first=DocumentFacts.model_validate(report())
  156. documents._merge_batch(all_facts,reports,first,[{"page":1,"route":"vision"}])
  157. next_report=DocumentFacts.model_validate(report())
  158. next_report.reports[0].evidence.page=None
  159. documents._merge_batch(all_facts,reports,next_report,[{"page":2,"route":"vision"}])
  160. assert reports['AUTO-001'].evidence.page==1
  161. wrong=DocumentFacts.model_validate(report(mine="Different Mine"))
  162. wrong.reports[0].evidence.page=None
  163. with pytest.raises(ValueError,match="原页"):
  164. documents._merge_batch(all_facts,reports,wrong,[{"page":2,"route":"vision"}])
  165. def test_conflicting_inspection_dates_do_not_become_valid_again_on_later_pages():
  166. from algorithms.main_fan.models import DocumentFacts
  167. all_facts,reports=DocumentFacts(),{}
  168. for page,year in [(1,2022),(2,2023),(3,2022)]:
  169. data=report(page=page)
  170. data['reports'][0]['inspected_on']=f'{year}-03-18'
  171. data['reports'][0]['evidence']['excerpt']=f'检测检验日期:{year}年03月18日'
  172. documents._merge_batch(all_facts,reports,DocumentFacts.model_validate(data),[{"page":page,"route":"vision"}])
  173. assert reports['AUTO-001'].inspected_on is None
  174. assert all_facts.extraction_audit['metadata_conflicts']
  175. def test_server_rebinds_metadata_methods_from_real_page_route():
  176. from algorithms.main_fan.models import DocumentFacts
  177. raw=report()
  178. raw['reports'][0]['metadata_evidence']={'inspected_on':{'page':1,'excerpt':'检测日期 2022-03-18','method':'text_extraction'}}
  179. facts=DocumentFacts.model_validate(raw)
  180. facts.extraction_audit={'pages':[{'page':1,'route':'vision'}]}
  181. documents.rebind_sources(facts,'/workspace/original.pdf','synthetic-hash')
  182. evidence=facts.reports[0].metadata_evidence['inspected_on']
  183. assert evidence.source == '/workspace/original.pdf' and evidence.sha256 == 'synthetic-hash'
  184. assert evidence.method == 'model_extraction'
  185. @pytest.mark.asyncio
  186. async def test_incomplete_curve_caption_is_excluded_without_discarding_complete_rows(monkeypatch,tmp_path):
  187. monkeypatch.setenv('MAIN_FAN_VISION_MODEL','test-vision')
  188. raw=report()
  189. raw['reports'][0]['curves']=[{'label':'40Hz图','frequency_hz':40,'flow_m3_s':None,
  190. 'static_pressure_pa':None,'shaft_power_kw':None,'evidence':{'page':1,'excerpt':'40Hz性能曲线'}}]
  191. class Model:
  192. async def ainvoke(self,*args,**kwargs):
  193. from langchain_core.messages import AIMessage
  194. return AIMessage(content=json.dumps(raw))
  195. monkeypatch.setattr('agents.model_factory.build_model',lambda **kw:Model())
  196. (tmp_path/'image.png').write_bytes(b'synthetic')
  197. facts=await documents.model_extract([{'page':1,'route':'vision','text':'','image':'image.png'}],tmp_path)
  198. assert facts.reports and not facts.reports[0].curves
  199. assert any('未纳入公式计算' in item for item in facts.warnings)
  200. @pytest.mark.asyncio
  201. async def test_report_warnings_are_preserved_at_document_level(monkeypatch,tmp_path):
  202. monkeypatch.setenv('MAIN_FAN_VISION_MODEL','test-vision')
  203. raw=report()
  204. raw['reports'][0]['warnings']=['电动机输入功率不可代替轴功率']
  205. class Model:
  206. async def ainvoke(self,*args,**kwargs):
  207. from langchain_core.messages import AIMessage
  208. return AIMessage(content=json.dumps(raw))
  209. monkeypatch.setattr('agents.model_factory.build_model',lambda **kw:Model())
  210. (tmp_path/'image.png').write_bytes(b'synthetic')
  211. facts=await documents.model_extract([{'page':1,'route':'vision','text':'','image':'image.png'}],tmp_path)
  212. assert facts.reports[0].report_id == 'AUTO-001'
  213. assert facts.warnings == ['报告 AUTO-001:电动机输入功率不可代替轴功率']
  214. @pytest.mark.asyncio
  215. @pytest.mark.parametrize('always_invalid',[False,True])
  216. async def test_schema_retry_rereads_original_pages_and_is_bounded(monkeypatch,tmp_path,always_invalid):
  217. source=tmp_path/'native.pdf'; native_pdf(source)
  218. seen=[]
  219. class Model:
  220. async def ainvoke(self,messages,config):
  221. from langchain_core.messages import AIMessage
  222. seen.append(messages)
  223. raw=report()
  224. if len(seen)==1 or always_invalid:
  225. raw['reports'][0]['unrecognized_field']='invalid response shape'
  226. return AIMessage(content=json.dumps(raw),response_metadata={'finish_reason':'stop'})
  227. monkeypatch.setattr('agents.model_factory.build_model',lambda **kw:Model())
  228. if always_invalid:
  229. with pytest.raises(ValueError):
  230. await documents.read_document(source,'/workspace/native.pdf',audit_dir=tmp_path/'audit')
  231. assert main_fan_store.document(documents.fingerprint(source)) is None
  232. else:
  233. facts=await documents.read_document(source,'/workspace/native.pdf',audit_dir=tmp_path/'audit')
  234. assert facts.reports[0].report_id=='AUTO-001'
  235. assert facts.extraction_audit['calls'][0]['validation_error']=='ValidationError'
  236. assert len(seen)==2
  237. assert seen[0][-1].content == seen[1][-1].content
  238. assert 'NATIVE-TEST-001' in seen[1][-1].content[0]['text']
  239. assert '结构校验未通过' in seen[1][0].content
  240. def test_date_label_can_be_stored_separately_from_its_original_value():
  241. from algorithms.main_fan.models import DocumentFacts
  242. raw=report()
  243. raw['reports'][0]['inspected_on']='2022-03-18'
  244. raw['reports'][0]['metadata_evidence']={'inspected_on':{'page':1,'label':'检测检验日期','excerpt':'2022 年 03 月 18 日'}}
  245. facts=DocumentFacts.model_validate(raw)
  246. reports={}
  247. documents._merge_batch(DocumentFacts(),reports,facts,[{'page':1,'route':'vision'}])
  248. assert reports['AUTO-001'].inspected_on == date(2022,3,18)
  249. @pytest.mark.asyncio
  250. @pytest.mark.parametrize('still_inconsistent',[False,True])
  251. async def test_numeric_recheck_uses_original_image_crop_and_never_backcalculates(monkeypatch,tmp_path,still_inconsistent):
  252. source=tmp_path/'scan.pdf'; Image.new('RGB',(240,320),'white').save(source,'PDF')
  253. monkeypatch.setenv('MAIN_FAN_VISION_MODEL','test-vision')
  254. seen=[]
  255. class Model:
  256. async def ainvoke(self,messages,config):
  257. from langchain_core.messages import AIMessage
  258. seen.append(messages)
  259. raw=report()
  260. raw['reports'][0]['curves']=[{'label':'synthetic curve','flow_m3_s':60,'static_pressure_pa':500,
  261. 'shaft_power_kw':60 if len(seen)==1 or still_inconsistent else 50,
  262. 'reported_efficiency_pct':60,'evidence':{'page':1,'excerpt':'synthetic caption'}}]
  263. return AIMessage(content=json.dumps(raw),response_metadata={'finish_reason':'stop'})
  264. monkeypatch.setattr('agents.model_factory.build_model',lambda **kw:Model())
  265. facts=await documents.read_document(source,'/workspace/scan.pdf',cache_policy='refresh')
  266. assert len(seen)==2
  267. second_images=[block for block in seen[1][-1].content if block['type']=='image_url']
  268. assert len(second_images)==2
  269. assert second_images[0] == [block for block in seen[0][-1].content if block['type']=='image_url'][0]
  270. assert '不按公式反算修正' in seen[1][0].content
  271. assert facts.extraction_audit['calls'][1]['purpose']=='numeric_recheck'
  272. assert facts.extraction_audit['calls'][1]['detail_images']==1
  273. assert facts.reports[0].curves[0].shaft_power_kw == (60 if still_inconsistent else 50)
  274. assert any('放大复读后公式仍不一致' in w for w in facts.warnings) == still_inconsistent
  275. def test_deepseek_off_is_explicit_but_other_provider_settings_remain_generic(monkeypatch):
  276. from agents import model_factory
  277. seen=[]
  278. monkeypatch.setattr(model_factory,'_instantiate',lambda name,common:seen.append(common) or object())
  279. model_factory.build_model(model_name='deepseek-flash',thinking='off')
  280. assert seen[-1]['extra_body']['thinking']['type']=='disabled'
  281. model_factory.build_model(model_name='generic-test',thinking='off',api_key='synthetic-vision-token',base_url='https://vision.invalid')
  282. assert 'extra_body' not in seen[-1]
  283. assert seen[-1]['api_key']=='synthetic-vision-token' and seen[-1]['base_url']=='https://vision.invalid'