"""PPT 异步任务生命周期回归测试。""" import asyncio import sys import types from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "api")) from insurance.generation import task_service from insurance.ppt import extraction as extraction_module from insurance.ppt import regex_extractor from insurance.ppt.extraction import ExtractionOrchestrator, ExtractionResult from insurance.ppt.llm_client import ( LLMResponse, _parse_json_content, _parse_timeout_ms, _validate_required_fields, ) from insurance.ppt.prompts import select_key_pages class _Field: def in_(self, _values): return self class _Query: def filter_by(self, **_kwargs): return self def filter(self, *_args): return self def first(self): return None class _Session: def __init__(self): self.commits = 0 def add(self, _value): return None def commit(self): self.commits += 1 class _GenerationTask: status = _Field() query = _Query() def __init__(self, **kwargs): self.id = "task-1" self.status = "queued" self.error_code = "" self.error_message = None self.finished_at = None for key, value in kwargs.items(): setattr(self, key, value) def to_dict(self): return { "id": self.id, "status": self.status, "errorCode": self.error_code, "errorMessage": self.error_message, } def test_dispatch_failure_finishes_task(monkeypatch): """消息队列不可用时,不应留下永久 queued 任务。""" fake_db = types.SimpleNamespace(session=_Session()) compat_module = types.ModuleType("insurance.db.compat") compat_module.db = fake_db model_module = types.ModuleType("insurance.models.generation_task") model_module.GenerationTask = _GenerationTask monkeypatch.setitem(sys.modules, "insurance.db.compat", compat_module) monkeypatch.setitem(sys.modules, "insurance.models.generation_task", model_module) synced = [] monkeypatch.setattr( task_service, "_dispatch_to_celery", lambda _task: (_ for _ in ()).throw(ConnectionError("broker unavailable")), ) monkeypatch.setattr( task_service, "_sync_failed_ppt_session", lambda task, message: synced.append((task.id, message)), ) result = task_service.create_task( user_id="user-1", artifact_type="ppt", operation="parse", workspace_id="session-1", ) assert result["code"] == 9999 assert result["data"]["status"] == "failed" assert result["data"]["errorCode"] == "dispatch_failed" assert synced and synced[0][0] == result["data"]["id"] def test_cached_extraction_reports_completion(tmp_path, monkeypatch): """重复解析应命中缓存,并向调用方报告完成阶段。""" pdf_path = tmp_path / "plan.pdf" pdf_path.write_bytes(b"%PDF-1.4") cached = ExtractionResult( pdf_path=str(pdf_path), product_name="测试产品", plan_type="savings", status="success", data={"product_name": "测试产品"}, ) orchestrator = ExtractionOrchestrator() monkeypatch.setattr(orchestrator, "_load_from_cache", lambda _path: cached) updates = [] result = asyncio.run(orchestrator.extract_plan( str(pdf_path), progress_callback=lambda progress, message: updates.append((progress, message)), )) assert result is cached assert updates == [(100, "已使用历史解析结果")] def test_default_llm_timeout_is_bounded(): assert _parse_timeout_ms(None) == 180_000 assert _parse_timeout_ms("30000") == 30_000 def test_llm_json_parser_accepts_explanation_and_trailing_comma(): content = '结果如下:\n```json\n{"product_name": "测试产品",}\n```\n请核对。' assert _parse_json_content(content) == {"product_name": "测试产品"} def test_structured_schema_rejects_empty_or_incomplete_success_payload(): schema = { "type": "object", "required": ["insured", "policy"], "properties": { "insured": {"type": "object", "required": ["age"]}, "policy": {"type": "object", "required": ["annual_premium"]}, }, } with pytest.raises(ValueError, match="缺少必填字段"): _validate_required_fields({}, schema) with pytest.raises(ValueError, match="annual_premium"): _validate_required_fields({"insured": {"age": 48}, "policy": {}}, schema) _validate_required_fields( {"insured": {"age": 48}, "policy": {"annual_premium": 80060}}, schema, ) def test_pdf_pages_keep_page_numbers_and_select_late_benefit_page(): text = extraction_module._format_pdf_pages([ "Product Name: Example IUL", "general terms", "Policy Year Account Value Cash Surrender Value Death Benefit 1 1000 900 500000", ]) assert "[PAGE 1]" in text assert "[PAGE 3]" in text selected = select_key_pages(text, max_pages=2, max_chars=2000) assert "Product Name: Example IUL" in selected assert "Cash Surrender Value" in selected def test_corrupted_pdf_text_detection_accepts_normal_text_and_rejects_font_garbage(): normal = "保险计划书 被保人年龄 48 岁\nPolicy Year 1 Cash Value 100000\n" * 3 corrupted = "\uffff\uffff\x81\x82ĤøùÿxĀ’@BQā" * 20 assert extraction_module._looks_corrupted(normal) is False assert extraction_module._looks_corrupted(corrupted) is True cid_garbage = "[PAGE 1]\n" + "(cid:4)(cid:17)(cid:238)(cid:99)" * 30 assert extraction_module._looks_corrupted(cid_garbage) is True def test_iul_filename_hint_corrects_ocr_product_name(): data = { "product_name": "SAR Feel", "policy": {"product_name": "SAR Feel"}, } corrected = extraction_module._apply_filename_hints( data, "/tmp/MLS_SIUL3_F-48-N-CN-USD-S3m-5x_coi__SC_.pdf", "iul", ) assert corrected["product_name"] == "Manulife SIUL 3" assert corrected["policy"]["product_name"] == "Manulife SIUL 3" assert corrected["insured"] == {"gender": "female", "age": 48, "smoker": "no"} assert corrected["policy"]["currency"] == "USD" assert corrected["policy"]["sum_insured"] == 3_000_000 assert corrected["policy"]["premium_payment_period"] == 5 def test_iul_filename_hint_does_not_override_extracted_values(): data = { "product_name": "unknown", "insured": {"age": 49, "gender": "female", "smoker": "no"}, "policy": {"currency": "HKD", "sum_insured": 2_000_000, "premium_payment_period": 8}, } corrected = extraction_module._apply_filename_hints( data, "/tmp/MLS_SIUL3_F-48-N-CN-USD-S3m-5x.pdf", "iul", ) assert corrected["insured"]["age"] == 49 assert corrected["policy"]["currency"] == "HKD" assert corrected["policy"]["sum_insured"] == 2_000_000 assert corrected["policy"]["premium_payment_period"] == 8 def test_iul_ocr_label_extracts_planned_annual_premium(): from insurance.ppt.regex_extractor import _extract_annual_premium text = "性别 Female 首期规划保费 US$80,060.00\n偿还至形成基金所需保费 US$80,060.00 从第1年至第5年" assert _extract_annual_premium(text) == 80_060 def test_savings_milestone_rows_are_warnings_not_blocking_errors(): from insurance.ppt.validator import validate_formal_savings_plan plan = { "productName": "测试储蓄计划", "insured": {"age": 41, "smoker": "no"}, "policy": {"currency": "USD", "annualPremium": 5250, "payYears": 5}, "benefitRows": [ {"policyYear": year, "sourcePage": 3} for year in [1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 65, 70, 75, 80, 85, 90, 95, 100] ], "withdrawalRows": [], "source": {"pdfHash": "abc"}, } issues = validate_formal_savings_plan(plan) assert not [issue for issue in issues if issue.level == "error"] assert any(issue.code == "BENEFIT_ROWS_MILESTONE_ONLY" for issue in issues) assert any(issue.code == "BENEFIT_ROWS_DISCONTINUOUS" for issue in issues) def test_savings_rows_without_key_years_are_non_blocking_warnings(): from insurance.ppt.validator import validate_formal_savings_plan plan = { "productName": "测试储蓄计划", "insured": {"age": 41, "smoker": "no"}, "policy": {"currency": "USD", "annualPremium": 5250, "payYears": 5}, "benefitRows": [ {"policyYear": year, "sourcePage": 3} for year in [1, 2, 3, 4, 5] ], "withdrawalRows": [], "source": {"pdfHash": "abc"}, } issues = validate_formal_savings_plan(plan) assert any( issue.code == "BENEFIT_ROWS_INCOMPLETE" and issue.level == "warn" for issue in issues ) assert not [issue for issue in issues if issue.level == "error"] def test_split_extraction_merges_identity_and_benefit_result(tmp_path, monkeypatch): pdf_path = tmp_path / "iul.pdf" pdf_path.write_bytes(b"%PDF-1.4") monkeypatch.setattr( extraction_module, "_extract_pdf_text", lambda _path: ("有效计划书文本" * 100, []), ) monkeypatch.setattr(regex_extractor, "extract_insurance_regex", lambda _text: {}) monkeypatch.setattr(regex_extractor, "count_benefit_rows", lambda _data: 0) complete = { "product_name": "测试 IUL", "product_type": "iul", "insured": {"age": 35}, "policy": {"sum_insured": 500000, "index_account_rate": 0.05}, "index_accounts": [{"name": "S&P 500"}], "benefit_illustration": [{"policy_year": 1, "account_value": 1000}], } async def fake_structured_output(**kwargs): prompt = kwargs.get("prompt", "") if "身份和保单字段" in prompt: data = {key: value for key, value in complete.items() if key != "benefit_illustration"} elif "利益演示表" in prompt: data = {"benefit_illustration": complete["benefit_illustration"]} elif "提领/提款演示表" in prompt: data = {"withdrawal_illustration": []} else: data = {} return data, LLMResponse(content="{}", provider="test") from insurance.ppt.llm_client import llm_client monkeypatch.setattr(llm_client, "structured_output", fake_structured_output) result = asyncio.run( ExtractionOrchestrator(use_cache=False).extract_plan(str(pdf_path), "iul") ) assert result.status == "success" assert result.product_name == "测试 IUL" assert result.data["benefit_illustration"] def test_generate_ppt_task_marks_failure_and_syncs_workspace(monkeypatch): """生成入口自身异常也必须结束任务,不能永久停在 running。""" celery_module = types.ModuleType("celery") def shared_task(*_args, **_kwargs): return lambda func: func celery_module.shared_task = shared_task monkeypatch.setitem(sys.modules, "celery", celery_module) sys.modules.pop("insurance.generation.celery_tasks", None) from insurance.generation import celery_tasks task = types.SimpleNamespace(id="task-1", workspace_id="session-1") session = types.SimpleNamespace(workflow_step="ready") class TaskQuery: @staticmethod def get(_task_id): return task class SessionQuery: @staticmethod def get(_session_id): return session generation_model = types.ModuleType("insurance.models.generation_task") generation_model.GenerationTask = types.SimpleNamespace(query=TaskQuery()) session_model = types.ModuleType("insurance.models.ppt_session") session_model.PptSession = types.SimpleNamespace(query=SessionQuery()) fake_db = types.SimpleNamespace(session=_Session()) compat_module = types.ModuleType("insurance.db.compat") compat_module.db = fake_db monkeypatch.setitem(sys.modules, "insurance.models.generation_task", generation_model) monkeypatch.setitem(sys.modules, "insurance.models.ppt_session", session_model) monkeypatch.setitem(sys.modules, "insurance.db.compat", compat_module) monkeypatch.setattr(celery_tasks, "_claim_task", lambda _task_id: True) updates = [] monkeypatch.setattr( celery_tasks, "_update_task_status", lambda _task_id, **kwargs: updates.append(kwargs), ) monkeypatch.setattr( celery_tasks, "_execute_ppt_generate", lambda _task_id: (_ for _ in ()).throw(RuntimeError("render failed")), ) synced = [] monkeypatch.setattr(task_service, "sync_workspace_status", lambda value: synced.append(value)) with pytest.raises(RuntimeError, match="render failed"): celery_tasks.generate_ppt_task(None, "task-1") assert session.workflow_step == "generating" assert any(update.get("status") == "failed" for update in updates) assert synced == [task]