"""PPT/海报优化规则回归测试。""" import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "api")) def test_currency_aliases_and_unknown_are_not_fabricated(): from insurance.ppt.normalizer import _normalize_currency assert _normalize_currency("USB") == "USD" assert _normalize_currency("RMB") == "CNY" assert _normalize_currency("¥") == "CNY" assert _normalize_currency("XYZ") is None assert _normalize_currency(None) is None def test_optional_review_fields_are_normalized_without_becoming_required(): from insurance.ppt.normalizer import normalize_savings_plan plan = normalize_savings_plan({ "product_name": "储蓄计划", "insured": {"age": 35, "gender": "女", "smoker": "否"}, "policy": { "currency": "RMB", "annual_premium": 10000, "premium_payment_period": 5, "basic_plan_annual_premium": 9000, "basic_sum_insured": 500000, "first_year_amount_due": 9800, }, }) assert plan["insured"]["smoker"] == "no" assert plan["policy"]["currency"] == "CNY" assert plan["policy"]["basicPlanAnnualPremium"] == 9000 assert plan["policy"]["basicSumInsured"] == 500000 assert plan["policy"]["firstYearAmountDue"] == 9800 def test_iul_key_fields_accept_raw_and_normalized_names_without_data_loss(): from insurance.ppt.normalizer import normalize_iul_plan plan = normalize_iul_plan({ "productName": "Manulife SIUL 3", "insured": {"age": 48, "gender": "female", "smoker": "non-smoker"}, "policy": { "currency": "USD", "sumInsured": 3000000, "annualPremium": 80060, "payYears": 5, "coverage_period": "终身", }, "benefit_illustration": [ {"policy_year": 1, "total_surrender_value": 31600}, {"policy_year": 10, "total_surrender_value": 76800}, ], }) assert plan["productName"] == "Manulife SIUL 3" assert plan["insured"]["age"] == 48 assert plan["insured"]["smoker"] == "no" assert plan["policy"]["sumInsured"] == 3000000 assert plan["policy"]["annualPremium"] == 80060 assert plan["policy"]["payYears"] == 5 assert plan["benefitRows"][0]["age"] == 48 assert plan["benefitRows"][1]["age"] == 57 assert plan["benefitRows"][1]["totalSurrenderValue"] == 76800 def test_iul_implausibly_tiny_benefit_values_block_generation(): from insurance.ppt.validator import validate_formal_iul_plan issues = validate_formal_iul_plan({ "productName": "Manulife SIUL 3", "insured": {"age": 48, "smoker": "no"}, "policy": { "currency": "USD", "sumInsured": 3000000, "annualPremium": 80060, "paymentPeriod": "5", }, "indexAccounts": [{"name": "S&P 500"}], "benefitRows": [ {"policyYear": year, "age": 48 + year - 1, "totalSurrenderValue": value} for year, value in [(1, 56), (10, 85), (20, 114), (30, 122)] ], "source": {"pdfHash": "hash"}, }) assert any( issue.code == "IUL_BENEFIT_VALUE_IMPLAUSIBLE" and issue.level == "error" for issue in issues ) def test_ppt_generation_preserves_company_selected_for_uploaded_file(): source = ( Path(__file__).resolve().parents[1] / "api/insurance/generation/celery_tasks.py" ).read_text(encoding="utf-8") assert 'normalized["companyId"] = ext.get("companyId") or company_id or ""' in source assert "if not company_id:" in source def test_benefit_and_withdrawal_issues_never_block_savings_generation(): from insurance.ppt.validator import validate_formal_savings_plan issues = validate_formal_savings_plan({ "productName": "储蓄计划", "insured": {"age": 35, "smoker": "no"}, "policy": {"currency": "USD", "annualPremium": 10000, "payYears": 5}, "benefitRows": [ { "policyYear": 1, "guaranteedCashValue": 100, "totalSurrenderValue": 50, } ], "withdrawalRows": [{"policyYear": 1}, {"policyYear": 3}], "source": {"pdfHash": "hash"}, }) affected = [ issue for issue in issues if issue.section in ("benefitRows", "withdrawalRows") ] assert affected assert all(issue.level == "warn" for issue in affected) def test_missing_optional_withdrawal_plan_does_not_require_review(): from insurance.ppt.validator import validate_formal_savings_plan issues = validate_formal_savings_plan({ "productName": "储蓄计划", "insured": {"age": 35, "smoker": "no"}, "policy": {"currency": "USD", "annualPremium": 10000, "payYears": 5}, "benefitRows": [ {"policyYear": year, "sourcePage": 10, "totalSurrenderValue": year * 1000} for year in range(1, 31) ], "withdrawalRows": [], "source": {"pdfHash": "hash"}, }) assert not any(issue.code == "WITHDRAWAL_ROWS_MISSING" for issue in issues) def test_brand_policy_controls_company_product_and_logo_independently(): from insurance.ppt.masking import apply_brand_policy, build_brand_policy company = { "id": "c1", "displayName": "真实保司", "maskedDisplayName": "保X", "maskingEnabled": True, "logoEnabled": False, "logoUrl": "/logo.png", } product = { "id": "p1", "displayName": "真实产品", "maskedDisplayName": "产X", "maskingEnabled": False, } policy = build_brand_policy(company, [product]) masked_company, masked_product = apply_brand_policy(company, product, policy) assert masked_company["displayName"] == "保X" assert masked_company["logoUrl"] == "" assert masked_product["displayName"] == "真实产品" def test_all_six_core_poster_profiles_have_distinct_content_budgets(): from insurance.poster.field_profiles import get_field_profile profiles = { (plan_type, output_mode): get_field_profile(plan_type, output_mode) for plan_type in ("savings", "ci", "iul") for output_mode in ("single", "long") } assert len(profiles) == 6 for plan_type in ("savings", "ci", "iul"): assert profiles[(plan_type, "single")]["maxFeatureCount"] == 3 assert profiles[(plan_type, "long")]["maxFeatureCount"] == 6 def test_single_poster_never_contains_full_benefit_table(): from insurance.poster.content_builder import build_poster_content rows = [ {"policy_year": year, "total_surrender": year * 1000} for year in range(1, 31) ] content = build_poster_content( {"benefit_illustration": rows}, {"features": [{"title": str(index)} for index in range(8)]}, output_mode="single", plan_type="savings", ) assert len(content["benefit_table"]) <= 1 assert len(content["features"]) <= 3 def test_custom_scenario_is_compatible_only_with_its_calculation_mode(monkeypatch): from insurance.ppt import scenarios monkeypatch.setattr(scenarios, "get_scenario_config", lambda _code: { "generationMode": "single", "baseScenario": None, }) assert scenarios.template_scenario_compatible("retirement", "generic_single") assert not scenarios.template_scenario_compatible("retirement", "generic_compare") def test_poster_case_mapping_reads_nested_insured_and_policy_fields(): from insurance.poster.tasks import _map_extract_plan_fields mapped = _map_extract_plan_fields({ "insured": {"age": 35, "gender": "female"}, "policy": { "currency": "RMB", "sum_insured": 500000, "annual_premium": 100000, "premium_payment_period": 5, "coverage_period": "终身", }, "benefit_illustration": [{"policy_year": 10, "total_surrender_value": 800000}], }, "savings", "success") assert mapped["age"] == 35 assert mapped["gender"] == "女" assert mapped["currency"] == "CNY" assert mapped["sum_assured"] == 500000 assert mapped["annual_premium"] == 100000 assert mapped["premium_term"] == 5 assert mapped["coverage_period"] == "终身" assert mapped["benefit_table"][0]["policy_year"] == 10 assert mapped["meta"]["status"] == "parsed" def test_poster_case_mapping_keeps_product_and_surrender_milestones(): from insurance.poster.tasks import _map_extract_plan_fields mapped = _map_extract_plan_fields({ "insured": {"age": 35, "gender": "female", "smoker_status": "no"}, "policy": { "currency": "USD", "sum_insured": 500000, "annual_premium": 10000, "premium_payment_period": 5, }, "benefit_illustration": [ {"policy_year": 1, "death_benefit": 500000, "total_surrender_value": 1000}, {"policy_year": 10, "total_surrender_value": 31600}, {"policy_year": 20, "total_surrender_value": 76800}, {"policy_year": 30, "total_surrender_value": 129300}, ], }, "savings", "success", product_context={ "productName": "宏挚传承", "companyName": "宏利", }) assert mapped["product_name"] == "宏挚传承" assert mapped["company_name"] == "宏利" assert mapped["smoking_status"] == "非吸烟" assert mapped["total_premium"] == 50000 assert mapped["initial_death_benefit"] == 500000 assert mapped["surrender_value_10"] == 31600 assert mapped["surrender_value_20"] == 76800 assert mapped["surrender_value_30"] == 129300 provenance = mapped["meta"]["provenance"] assert provenance["total_premium"]["source"] == "system_derived" assert provenance["initial_death_benefit"]["source"] == "system_derived" assert provenance["surrender_value_20"]["from"] == "benefit_table.year_20" def test_poster_content_accepts_canonical_snake_case_benefit_fields(): from insurance.poster.content_builder import build_poster_content content = build_poster_content({ "benefit_illustration": [ { "policy_year": year, "guaranteed_cash_value": year * 100, "total_surrender_value": year * 1000, } for year in (10, 20, 30) ], }, {}, output_mode="long", plan_type="savings") assert content["benefit_table"][0]["guaranteed"] == 1000 assert content["benefit_table"][0]["totalSurrender"] == 10000 def test_regex_benefit_table_keeps_age_separate_from_money(): from insurance.ppt.regex_extractor import extract_insurance_regex data = extract_insurance_regex(""" 产品名称:Manulife SIUL 3 受保人年龄:47 年缴保费:80,060 保单年度 受保人年龄 缴付保费总额 退保总值 1 48 80,060 31,600 8 55 640,480 76,800 """) first = data["benefit_illustration"][0] assert first["policy_year"] == 1 assert first["age"] == 48 assert first["total_premium_paid"] == 80060 assert first["total_surrender_value"] == 31600 def test_regex_age_only_table_derives_policy_year_from_explicit_issue_age(): from insurance.ppt.regex_extractor import extract_insurance_regex data = extract_insurance_regex(""" 产品名称:储蓄计划 受保人年龄:47 年缴保费:10,000 受保人年龄 退保价值 48 1,000 49 2,000 """) rows = data["benefit_illustration"] assert [row["policy_year"] for row in rows] == [1, 2] assert [row["age"] for row in rows] == [48, 49] assert rows[0]["policy_year_source"] == "derived_from_age" def test_regex_iul_table_preserves_dash_placeholders_without_column_shift(): from insurance.ppt.regex_extractor import extract_insurance_regex data = extract_insurance_regex(""" 产品名称:Manulife SIUL 3 受保人年龄:47 年缴保费:80,060 保单年度 年龄 累计保费 保证现金价值 保证账户价值 非保证账户价值 非保证现金价值 退保总值 非保证身故赔偿 37 — 2,962,220 2,000 — — 1,000 85 10,000 44 — 3,522,640 3,000 — — 2,000 92 12,000 """) row = data["benefit_illustration"][0] assert row["policy_year"] == 37 assert row["age"] is None assert row["total_premium_paid"] == 2962220 assert row["guaranteed_cash_value"] == 2000 assert row["guaranteed_account_value"] is None assert row["non_guaranteed_account_value"] is None assert row["non_guaranteed_cash_value"] == 1000 assert row["total_surrender_value"] == 85 assert row["non_guaranteed_death_benefit"] == 10000 assert data["benefit_illustration"][1]["total_premium_paid"] == 3522640 assert data["benefit_illustration"][1]["total_surrender_value"] == 92 def test_regex_quality_gate_rejects_age_like_surrender_values(): from insurance.ppt.extraction import _regex_quality_gate passed, problems = _regex_quality_gate({ "product_name": "Manulife SIUL 3", "insured": {"age": 47}, "policy": {"sum_insured": 3000000, "annual_premium": 80060}, "index_accounts": [{"name": "S&P 500"}], "benefit_illustration": [ {"policy_year": year, "total_surrender_value": age} for year, age in ((1, 49), (8, 56), (37, 85), (44, 92)) ], }, "iul") assert passed is False assert "benefit_illustration.total_surrender_value_implausible" in problems def test_regex_quality_gate_rejects_iul_rows_without_iul_value_columns(): from insurance.ppt.extraction import _regex_quality_gate passed, problems = _regex_quality_gate({ "product_name": "Manulife SIUL 3", "insured": {"age": 47}, "policy": {"sum_insured": 3000000, "annual_premium": 80060}, "index_accounts": [{"name": "S&P 500"}], "benefit_illustration": [ {"policy_year": year, "total_premium_paid": 80060 * year, "total_surrender_value": 10000 * year} for year in (1, 8, 20) ], }, "iul") assert passed is False assert "benefit_illustration.iul_value_columns" in problems def test_iul_split_fallback_requests_iul_specific_fields(): import asyncio from types import SimpleNamespace from insurance.ppt.extraction import _llm_extract_split prompts = [] class FakeClient: async def structured_output(self, **kwargs): prompt = kwargs["prompt"] prompts.append(prompt) if "身份和保单字段" in prompt: return { "product_name": "SIUL", "insured": {"age": 47, "gender": "male"}, "policy": {"sum_insured": 3000000}, "index_accounts": [{"name": "S&P 500"}], }, SimpleNamespace(tokens={}) if "利益演示表" in prompt: return {"benefit_illustration": []}, SimpleNamespace(tokens={}) return {"withdrawal_illustration": []}, SimpleNamespace(tokens={}) asyncio.run(_llm_extract_split( "[PAGE 1]\nIUL 计划书\n\n" "[PAGE 2]\n提领方案 保单年度 提取金额 退保价值", "iul", FakeClient(), )) assert "index_accounts" in prompts[0] assert "guaranteed_account_value" in prompts[1] assert "non_guaranteed_cash_value" in prompts[1] assert "non_guaranteed_death_benefit" in prompts[1] assert "根对象只能包含 benefit_illustration 字段" in prompts[1] assert "根对象只能包含 withdrawal_illustration 字段" in prompts[2] def test_split_extraction_chunks_benefit_pages_and_merges_rows(): import asyncio import re from types import SimpleNamespace from insurance.ppt.extraction import _llm_extract_split prompts = [] class FakeClient: async def structured_output(self, **kwargs): prompt = kwargs["prompt"] prompts.append(prompt) response = SimpleNamespace(tokens={"input": 10, "output": 5}, latency_ms=10) if "身份和保单字段" in prompt: return { "product_name": "储蓄计划", "insured": {"age": 35, "gender": "female"}, "policy": {"currency": "USD", "sum_insured": None, "annual_premium": 10000, "premium_payment_period": 5}, }, response page_numbers = [int(value) for value in re.findall(r"\[PAGE (\d+)\]", prompt)] return { "benefit_illustration": [ {"policy_year": page, "total_surrender_value": page * 1000, "source_page": page} for page in page_numbers ], }, response pdf_text = "[PAGE 1]\n产品名称:储蓄计划\n受保人年龄:35\n" pdf_text += "\n\n".join( f"[PAGE {page}]\n保单年度 保证现金价值 退保价值\n{page} 100 {page * 1000}" for page in range(2, 7) ) data, _response = asyncio.run(_llm_extract_split(pdf_text, "savings", FakeClient())) benefit_prompts = [ prompt for prompt in prompts if "利益演示表" in prompt and "当前分块" in prompt ] assert len(benefit_prompts) == 3 assert [row["policy_year"] for row in data["benefit_illustration"]] == [2, 3, 4, 5, 6] assert data["_meta"]["split_extraction"]["benefit"]["status"] == "success" assert data["_meta"]["split_extraction"]["benefit"]["chunkCount"] == 3 def test_incomplete_savings_benefit_rows_are_partial(): from insurance.ppt.extraction import assess_extraction_payload status, message = assess_extraction_payload({ "product_name": "储蓄计划", "insured": {"age": 35}, "policy": {"annual_premium": 10000}, "benefit_illustration": [ {"policy_year": year, "total_surrender_value": year * 1000} for year in (1, 10, 20) ], "_meta": { "split_extraction": { "benefit": {"status": "failed", "errors": ["RemoteProtocolError"]}, }, }, }, "savings") assert status == "partial" assert "利益演示数据不足" in message def test_llm_client_retries_retryable_transport_error(monkeypatch): import asyncio import httpx from insurance.ppt import llm_client as llm_module client = llm_module.LLMClient.__new__(llm_module.LLMClient) config = llm_module.LLMProviderConfig( name="deepseek", base_url="https://example.com/v1", model="model", max_retries=2, ) client._configs = [(config, "secret")] client._limiters = {} client._active_idx = 0 client._timeout_ms = 180000 client._try_load_db_config = lambda: None attempts = [] async def fake_provider(*_args, **_kwargs): attempts.append(1) if len(attempts) == 1: raise httpx.RemoteProtocolError("incomplete chunked read") return llm_module.LLMResponse(content='{"ok": true}', provider="deepseek") async def no_wait(_seconds): return None monkeypatch.setattr(llm_module, "_call_provider", fake_provider) monkeypatch.setattr(llm_module.asyncio, "sleep", no_wait) response = asyncio.run(client._call([{"role": "user", "content": "test"}], json_mode=True)) assert response.provider == "deepseek" assert len(attempts) == 2 def test_single_required_array_schema_normalizes_safe_equivalent_roots(): from insurance.ppt.llm_client import _normalize_schema_root schema = { "type": "object", "required": ["benefit_illustration"], "properties": {"benefit_illustration": {"type": "array"}}, } assert _normalize_schema_root([{"policy_year": 1}], schema) == { "benefit_illustration": [{"policy_year": 1}], } assert _normalize_schema_root({"rows": [{"policy_year": 2}]}, schema) == { "benefit_illustration": [{"policy_year": 2}], } assert _normalize_schema_root({"message": "not rows"}, schema) == {"message": "not rows"} assert _normalize_schema_root({"message": []}, schema) == {"message": []} def test_structured_output_repair_receives_the_missing_field_error(): import asyncio from types import SimpleNamespace from insurance.ppt.llm_client import LLMClient client = LLMClient.__new__(LLMClient) responses = iter([ SimpleNamespace(content='{"message": "missing root"}'), SimpleNamespace(content='{"benefit_illustration": []}'), ]) calls = [] async def fake_call(messages, **_kwargs): calls.append(messages) return next(responses) client._call = fake_call schema = { "type": "object", "required": ["benefit_illustration"], "properties": {"benefit_illustration": {"type": "array"}}, } parsed, _response = asyncio.run(client.structured_output("extract", schema=schema)) assert parsed == {"benefit_illustration": []} assert "缺少必填字段: benefit_illustration" in calls[1][-1]["content"] def test_withdrawal_regex_accepts_title_on_previous_line(): from insurance.ppt.regex_extractor import extract_insurance_regex data = extract_insurance_regex(""" 产品名称:储蓄计划 受保人年龄:35 年缴保费:10,000 计划提领方案 保单年度 total withdrawn surrender value after 10 50,000 180,000 20 150,000 360,000 """) rows = data["withdrawal_illustration"] assert [row["policy_year"] for row in rows] == [10, 20] assert rows[0]["total_withdrawn"] == 50000 def test_gender_token_is_not_accepted_as_product_name(): from insurance.ppt.extraction import _is_obviously_invalid_product_name assert _is_obviously_invalid_product_name("Female") assert _is_obviously_invalid_product_name("女") assert not _is_obviously_invalid_product_name("环盈活储蓄保险计划") def test_long_poster_fills_partial_milestones_with_real_rows(): from insurance.poster.content_builder import build_poster_content content = build_poster_content({ "benefit_table": [ {"policy_year": year, "total_surrender_value": year * 1000} for year in (1, 10, 11, 12) ], }, {}, output_mode="long", plan_type="savings") assert len(content["benefit_table"]) >= 3 assert 10 in [row["year"] for row in content["benefit_table"]] def test_poster_explicit_milestone_overrides_chart_row(): from insurance.poster.content_builder import build_poster_content content = build_poster_content({ "surrender_value_10": 99999, "benefit_table": [ {"policy_year": year, "total_surrender_value": year * 1000} for year in (10, 20, 30) ], }, {}, output_mode="long", plan_type="savings") row_10 = next(row for row in content["benefit_table"] if row["year"] == 10) assert row_10["totalSurrender"] == 99999 def test_frontend_chart_and_mobile_rules_cover_reported_failures(): root = Path(__file__).resolve().parents[1] chart = (root / "frontend/src/components/poster/long/PosterBenefitChart.vue").read_text(encoding="utf-8") ppt_page = (root / "frontend/src/pages/PptPage.vue").read_text(encoding="utf-8") review = (root / "frontend/src/pages/components/ppt/PptDataReview.vue").read_text(encoding="utf-8") assert ".once('finished'" not in chart assert "instance.on('finished'" in chart assert "instance.off('finished'" in chart assert "@container" not in ppt_page assert "@media (max-width: 767px)" in ppt_page assert 'aria-label="返回 PPT 工作区列表"' in ppt_page assert "transition: width" not in ppt_page assert "mobile-data-card" in review assert "退保价值 · ${getSurrenderRows(currentExt).length} 行" in review assert ':data="getSurrenderRows(currentExt)"' in review assert "提领方案" in review assert "提领/提款方案属于可选情景" in review def test_poster_case_mapping_does_not_report_empty_result_as_parsed(): from insurance.poster.tasks import _map_extract_plan_fields mapped = _map_extract_plan_fields({}, "savings", "partial") assert mapped["meta"]["status"] == "failed" assert mapped["meta"]["validFieldCount"] == 0 assert "age" in mapped["meta"]["missingFields"] def test_poster_compliance_returns_all_character_ranges_and_revision(): from insurance.poster.compliance import check_copy_compliance result = check_copy_compliance({ "headline": "保证收益", "body": "并非无风险,也不是零风险。", "call_to_action": "立即咨询", }) assert result["status"] == "block" assert result["revision"] assert [(issue["field"], issue["text"]) for issue in result["issues"]] == [ ("headline", "保证"), ("body", "无风险"), ("body", "零风险"), ] assert result["issues"][0]["start"] == 0 assert result["issues"][0]["end"] == 2 def test_poster_compliance_warns_and_returns_direct_replacement(): from insurance.poster.compliance import check_copy_compliance result = check_copy_compliance({ "headline": "行业领先的保障方案", "body": "具体内容以正式合同为准。", "call_to_action": "了解详情", }) assert result["status"] == "warn" assert result["issues"][0]["severity"] == "warn" assert result["issues"][0]["replacement"] == "具有特色" def test_poster_generation_requires_template_before_other_processing(): from insurance.poster.service import PosterService result = PosterService().generate_poster("user-1", { "copyContent": { "headline": "保障方案", "body": "具体内容以正式合同为准。", "call_to_action": "了解详情", }, }) assert result == {"code": 1001, "message": "请选择海报模板", "data": None} def test_poster_case_mapping_preserves_parse_diagnostics(): from insurance.poster.tasks import _map_extract_plan_fields mapped = _map_extract_plan_fields({ "insured": {"age": 35}, "policy": {"currency": "USD", "annual_premium": 10000, "premium_payment_period": 5}, "_meta": {"method": "regex+ocr", "low_quality_pages": [3, 8]}, "_provenance": {"insured.age": {"source": "ocr", "confidence": 0.8}}, }, "savings", "partial") assert mapped["meta"]["method"] == "regex+ocr" assert mapped["meta"]["lowQualityPages"] == [3, 8] assert mapped["meta"]["provenance"]["insured.age"]["confidence"] == 0.8