baodan/tests/ppt_poster_optimization_test.py
wsb1224 7a3e2870dc 主要成果:
海报确认、文案生成、海报生成增加服务端失败关闭门禁,绑定文件哈希、解析快照哈希和确认数据哈希,并返回 422 业务错误。[validators.py (line 65)](D:/work/code/python/coding/baodanagent/api/insurance/plan_data/validators.py:65)
缺失金额不再转换为 0;删除错误字段兜底和“年缴×年期=合同总保费”事实推导;里程碑冲突会阻断确认。[normalizer.py (line 14)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/normalizer.py:14)
PPT 渲染器支持可空金额和实际币种,缺失值显示“待确认”,避免 float(None)、空值除法等异常。
模板必须覆盖全部输入保司和产品;自动选择排序确定化,同优先级歧义时阻断。[template_selection.py (line 4)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/template_selection.py:4)
场景判定写入 scenarioOverrideTrace,记录请求、模板、服务端及 Worker 最终判定。[routes.py (line 555)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/routes.py:555)
前端增加哈希提交、人工调整原因、模板歧义提示及真实能力说明。
冻结三份核心 Schema,并建立 Goldens manifest、说明和评估脚本。
验证结果:
后端目标回归:111 passed, 1 skipped
PPT 运行时回归:81 passed
前端生产构建和 vue-tsc:通过
Python compileall:通过
三份 Schema JSON:解析通过
git diff --check:通过,仅有换行符提示
2026-08-02 12:58:41 +08:00

1192 lines
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""PPT/海报优化规则回归测试。"""
import asyncio
import sys
from pathlib import Path
from types import SimpleNamespace
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_smoker_aliases_are_canonical_before_review():
from insurance.ppt.normalizer import normalize_smoker
assert normalize_smoker("Non-Smoker Standard") == "no"
assert normalize_smoker(False) == "no"
assert normalize_smoker("非吸烟者") == "no"
assert normalize_smoker("Smoker Standard") == "yes"
assert normalize_smoker(None) == "unknown"
def test_labeled_savings_amounts_are_not_confused_with_first_year_premium():
from insurance.ppt.regex_extractor import extract_insurance_regex
data = extract_insurance_regex("""
投保时 基本金额 50,506
投保时 保额 5,250
投保时 年缴保费 5,000.09
投保时 年缴总保费 5,005.09
保费供款年期 5
""")
assert data["policy"]["sum_insured"] == 5250
assert data["policy"]["basic_sum_insured"] == 50506
assert data["policy"]["annual_premium"] == 5000.09
assert data["policy"]["annual_premium_source_label"]
assert data["policy"]["first_year_amount_due"] == 5005.09
def test_summary_word_coordinates_keep_amounts_in_their_columns():
from insurance.ppt.regex_extractor import _extract_summary_fields_from_words
words = [
(192, 362, 241, 376, "基本金额(1)"),
(262, 356, 321, 370, "投保时保额(2)"),
(360, 362, 401, 376, "年缴保费"),
(201, 383, 232, 397, "50,506"),
(279, 383, 304, 397, "5,250"),
(386, 383, 425, 397, "5,000.09"),
]
policy = _extract_summary_fields_from_words(words)
assert policy["basic_sum_insured"] == 50506
assert policy["sum_insured"] == 5250
assert policy["annual_premium"] == 5000.09
def test_iul_slash_rows_use_current_scenario_and_keep_year_age_alignment():
from insurance.ppt.regex_extractor import _extract_iul_slash_rows
rows = _extract_iul_slash_rows("""
[PAGE 2]
指数账户
以保证派息率计算
1/49 80,060 56,101 0 0 0 3,000,000 3,000,000
2/50 80,060 111,582 37,601 67,284 67,284 3,000,000 3,000,000
[PAGE 3]
以当前假设派息率计算
1/49 80,060 61,120 0 0 0 3,000,000 3,000,000
2/50 80,060 126,558 52,576 67,432 67,432 3,000,000 3,000,000
""", issue_age=48)
assert [row["policy_year"] for row in rows] == [1, 2]
assert [row["age"] for row in rows] == [49, 50]
assert rows[1]["total_premium_paid"] == 160120
assert rows[1]["total_surrender_value"] == 67432
assert rows[1]["non_guaranteed_death_benefit"] == 3000000
def test_iul_spaced_rows_recover_ocr_dropped_year_digits():
from insurance.ppt.regex_extractor import _extract_iul_spaced_rows
rows = _extract_iul_spaced_rows("""
[PAGE 4]
Indexed Account
Illustrated at the current assumed crediting interest rate and current charges.
10 61 0 367,545 337,925 198,191 337,925 2,000,000 2,000,000
1 62 0 382,057 357,097 194,602 357,097 2,000,000 2,000,000
12 63 0 397,325 377,005 190,533 377,005 2,000,000 2,000,000
""", issue_age=51)
assert [row["policy_year"] for row in rows] == [10, 11, 12]
assert rows[1]["age"] == 62
assert rows[1]["total_surrender_value"] == 357097
def test_savings_scenario_rows_join_death_and_surrender_pages():
from insurance.ppt.regex_extractor import _extract_savings_scenario_rows
rows = _extract_savings_scenario_rows("""
[PAGE 2]
DEATH BENEFIT
1/26 60,480 65,319 0 65,319 0 65,319
2/27 120,961 130,638 453 131,091 907 131,545
[PAGE 4]
SURRENDER VALUE
1/26 60,480 0 0 0 0 0
2/27 120,961 6,048 453 6,501 907 6,955
""")
assert [row["policy_year"] for row in rows] == [1, 2]
assert rows[1]["age"] == 27
assert rows[1]["total_premium_paid"] == 120961
assert rows[1]["death_benefit"] == 131091
assert rows[1]["guaranteed_cash_value"] == 6048
assert rows[1]["total_surrender_value"] == 6501
def test_savings_scenario_rows_accept_separate_year_and_age_columns():
from insurance.ppt.regex_extractor import _extract_savings_scenario_rows
rows = _extract_savings_scenario_rows("""
[PAGE 2]
DEATH BENEFIT
1 61 400,000 400,000 0 400,000 0 400,000
2 62 400,000 400,000 11,200 411,200 20,000 420,000
[PAGE 4]
SURRENDER VALUE
1 61 400,000 0 0 0 0 0
2 62 400,000 40,000 11,200 51,200 20,000 60,000
[PAGE 6]
SURVIVAL BENEFIT AND CASH BONUS
2 62 6,403 6,403 6,253 12,623 19,026 340,114 423,090
""")
assert [row["policy_year"] for row in rows] == [1, 2]
assert rows[1]["age"] == 62
assert rows[1]["death_benefit"] == 411200
assert rows[1]["total_surrender_value"] == 51200
def test_savings_rows_are_not_misclassified_as_iul_rows():
from insurance.ppt.regex_extractor import _extract_iul_spaced_rows
rows = _extract_iul_spaced_rows("""
DEATH BENEFIT
1 61 400,000 400,000 0 400,000 0 400,000
2 62 400,000 400,000 11,200 411,200 20,000 420,000
""", issue_age=60)
assert rows == []
def test_iul_table_matrix_expands_vertical_columns():
from insurance.ppt.regex_extractor import _parse_iul_table_matrix
matrix = [
[
"保单年度结束", "年龄", "保费计划", "账户价值",
"账户价值减去退保费用", "累计保证账户价值减去退保费用",
"退保价值", "保障金额", "身故赔偿",
],
[
"1\n2\n3", "50\n51\n52", "113,340\n88,900\n88,900",
"92,237\n166,221\n244,170", "36,523\n110,507\n188,456",
"31,779\n97,461\n163,241", "36,523\n110,507\n188,456",
"3,000,000\n3,000,000\n3,000,000", "3,000,000\n3,000,000\n3,000,000",
],
]
rows = _parse_iul_table_matrix(matrix, source_page=4)
assert len(rows) == 3
assert rows[0]["policy_year"] == 1
assert rows[0]["age"] == 50
assert rows[0]["_annual_premium_paid"] == 113340
assert rows[0]["non_guaranteed_account_value"] == 92237
assert rows[2]["total_surrender_value"] == 188456
assert rows[2]["non_guaranteed_death_benefit"] == 3000000
def test_iul_table_matrix_prefers_rightmost_non_guaranteed_columns():
from insurance.ppt.regex_extractor import _parse_iul_table_matrix
matrix = [
["年度/年龄", "总年度保费(累计)", "现金价值", "户口价值", "身故利益",
"现金价值", "户口价值", "身故利益"],
["1 49", "96,637", "498", "66,690", "3,000,000",
"4,586", "72,326", "3,000,000"],
["2 50", "193,274", "80,603", "138,981", "3,000,000",
"92,783", "155,663", "3,000,000"],
["3 51", "289,911", "161,279", "210,412", "3,000,000",
"185,993", "244,043", "3,000,000"],
]
rows = _parse_iul_table_matrix(matrix, source_page=2)
assert rows[0]["total_premium_paid"] == 96637
assert rows[0]["total_surrender_value"] == 4586
assert rows[0]["non_guaranteed_account_value"] == 72326
assert rows[0]["non_guaranteed_death_benefit"] == 3000000
def test_iul_layout_rows_do_not_replace_better_ocr_rows():
from insurance.ppt.extraction import _should_use_iul_layout_rows
existing = [
{
"policy_year": year,
"total_surrender_value": year * 1000,
"non_guaranteed_account_value": year * 1200,
"non_guaranteed_death_benefit": 3000000,
}
for year in range(1, 21)
]
false_positive_layout = [
{"policy_year": year, "total_surrender_value": year * 10}
for year in range(1, 7)
]
assert _should_use_iul_layout_rows(existing, false_positive_layout) is False
def test_filename_hints_cover_uploaded_iul_and_premium_named_samples():
from insurance.ppt.extraction import _apply_filename_hints
iul = _apply_filename_hints(
{"insured": {"age": 1}, "policy": {}},
"/tmp/SLS_SBIUL2_F-49-N-CN-USD-S3m-10x__coi__SC.pdf",
"iul",
)
savings = _apply_filename_hints(
{"insured": {}, "policy": {}},
"/tmp/CL_HISP+M-25-N-CN-USD-P0.3m-5x+(EN).pdf",
"savings",
)
assert iul["product_name"] == "Sun Life SBIUL 2"
assert iul["product_type"] == "iul"
assert iul["insured"] == {"age": 49, "gender": "female", "smoker": "no"}
assert iul["policy"]["sum_insured"] == 3000000
assert iul["policy"]["premium_payment_period"] == 10
assert savings["product_name"] == "CL HISP+"
assert savings["product_type"] == "savings"
assert savings["insured"] == {"gender": "male", "age": 25, "smoker": "no"}
assert savings["policy"]["annual_premium"] == 60000
assert savings["policy"]["contractual_total_premium"] == 300000
assert savings["policy"]["premium_payment_period"] == 5
def test_trusted_labeled_amounts_and_regex_smoker_fill_llm_result():
from insurance.ppt.extraction import _merge_extraction_data
llm_data = {
"product_name": "储蓄计划",
"insured": {"age": 41, "gender": "female", "smoker": None},
"policy": {"sum_insured": 5000.09, "annual_premium": 5250},
}
regex_data = {
"insured": {"smoker": "no"},
"policy": {
"basic_sum_insured": 50506,
"basic_sum_insured_source_label": "投保时基本金额",
"sum_insured": 5250,
"sum_insured_source_label": "投保时保额",
"annual_premium": 5000.09,
"annual_premium_source_label": "投保时年缴保费",
"first_year_amount_due": 5005.09,
"first_year_amount_due_source_label": "投保时年缴总保费",
},
}
merged = _merge_extraction_data(llm_data, regex_data, "savings")
assert merged["insured"]["smoker"] == "no"
assert merged["policy"]["sum_insured"] == 5250
assert merged["policy"]["basic_sum_insured"] == 50506
assert merged["policy"]["annual_premium"] == 5000.09
assert merged["policy"]["first_year_amount_due"] == 5005.09
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 fallback_company_id or ""' in source
assert 'company_ids = list(snapshot.get("companyIds")' in source
assert 'item["companyInfo"] = company_infos.get(item.get("companyId"), {})' 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_public_render_options_only_expose_effective_masked_names():
from insurance.ppt.masking import public_company_option, public_product_option
company = public_company_option({
"id": "aia",
"displayName": "友邦保险",
"maskedDisplayName": "友X保险",
"maskingEnabled": True,
"aliases": ["AIA", "友邦"],
})
product = public_product_option({
"id": "p1",
"companyId": "aia",
"planType": "savings",
"displayName": "真实产品",
"maskedDisplayName": "产X",
"maskingEnabled": True,
})
assert company == {"id": "aia", "displayName": "友X保险"}
assert product == {
"id": "p1", "companyId": "aia", "planType": "savings",
"displayName": "产X",
}
def test_brand_policy_keeps_company_rules_per_company():
from insurance.ppt.masking import build_brand_policy
policy = build_brand_policy([
{
"id": "aia", "displayName": "友邦保险",
"maskedDisplayName": "友X保险", "maskingEnabled": True,
"logoEnabled": False,
},
{
"id": "manulife", "displayName": "宏利",
"maskedDisplayName": "宏X", "maskingEnabled": True,
"logoEnabled": False,
},
], [])
assert policy["policyVersion"] == 2
assert policy["companyPolicyById"]["aia"]["maskingEnabled"] is True
assert policy["companyPolicyById"]["manulife"]["displayName"] == "宏X"
def test_iul_surrender_value_cannot_repeat_death_benefit_for_most_rows():
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,
"totalSurrenderValue": 3000000,
"nonGuaranteedDeathBenefit": 3000000,
}
for year in range(1, 21)
],
"source": {"pdfHash": "hash"},
})
assert any(
issue.code == "IUL_SURRENDER_EQUALS_DEATH_BENEFIT" and issue.level == "error"
for issue in issues
)
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"] is None
assert mapped["derived_total_premium_estimate"] == 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["derived_total_premium_estimate"]["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_extracts_english_sum_assured_label():
from insurance.ppt.regex_extractor import extract_insurance_regex
data = extract_insurance_regex("""
Your plan
1Sum assured: 5,336
Currency: USD
""")
assert data["policy"]["sum_insured"] == 5336
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_conflicts_with_chart_row_without_overwrite():
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"] == 10000
assert row_10["conflict"] == {
"field": "totalSurrender",
"tableValue": 10000,
"explicitValue": 99999,
"status": "unresolved",
}
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
def test_poster_page_selection_prefers_summary_and_excludes_withdrawal_scenarios():
from insurance.ppt.extraction import _select_poster_pages
pdf_text = "\n".join([
"[PAGE 1]\n受保人 年龄 性别 保单货币 年缴保费",
"[PAGE 2]\n款项提取说明 保单年度 退保价值 10 20 30",
"[PAGE 3]\n基本计划说明摘要 保单年度 退保价值 保证现金 1 5 10 15 20 25 30",
"[PAGE 4]\n悲观情景 保单年度 退保价值 10 20 30",
])
selected = _select_poster_pages(pdf_text, "savings")
assert "[PAGE 1]" in selected
assert "[PAGE 3]" in selected
assert "[PAGE 2]" not in selected
assert "[PAGE 4]" not in selected
def test_compact_poster_extraction_uses_one_call_and_keeps_target_years(monkeypatch):
from insurance.ppt import extraction
from insurance.ppt.llm_client import llm_client
pdf_text = (
"[PAGE 1]\n受保人 年龄 35 性别 男 保单货币 USD 年缴保费 100000\n"
"[PAGE 2]\n说明摘要 保单年度 退保价值 保证现金 10 20 30"
)
monkeypatch.setattr(extraction, "_extract_pdf_text", lambda _path: (pdf_text, []))
calls = []
async def fake_structured_output(**kwargs):
calls.append(kwargs["prompt"])
return {
"product_name": "测试储蓄计划",
"insured": {"age": 35, "gender": "male", "smoking_status": "non-smoker"},
"policy": {
"currency": "USD", "sum_insured": 500000,
"annual_premium": 100000, "first_year_amount_due": 92000,
"premium_payment_period": 5,
},
"benefit_illustration": [
{"policy_year": 10, "surrender_value": {"total": 300000}},
{"policy_year": 20, "total_surrender_value": 700000},
{"policy_year": 30, "total_surrender_value": 1200000},
{"policy_year": 40, "total_surrender_value": 1800000},
],
}, SimpleNamespace(latency_ms=120)
monkeypatch.setattr(llm_client, "structured_output", fake_structured_output)
progress = []
result = asyncio.run(extraction.ExtractionOrchestrator(use_cache=False).extract_for_poster(
"poster.pdf",
plan_type="savings",
progress_callback=lambda value, message: progress.append((value, message)),
))
assert len(calls) == 1
assert "提领、提款或压力情景" in calls[0]
assert [row["policy_year"] for row in result["benefit_illustration"]] == [10, 20, 30]
assert result["benefit_illustration"][0]["total_surrender_value"] == 300000
assert result["policy"]["annual_premium"] == 100000
assert result["policy"]["first_year_amount_due"] == 92000
assert result["_meta"]["method"] == "poster_compact"
assert progress[-1][0] == 90
def test_poster_source_polling_uses_backend_progress_without_local_timeout():
root = Path(__file__).resolve().parents[1]
source = (root / "frontend/src/components/poster/workspace/PosterSourcePanel.vue").read_text(encoding="utf-8")
assert "MAX_POLL_RETRIES" not in source
assert "setInterval" not in source
assert "data?.parseProgress" in source
assert "data.parseError" in source
assert "网络连接不稳定,后台仍在解析" in source
def test_poster_case_serializes_parsed_data_and_hides_unsafe_errors():
from insurance.models.poster_case_upload import PosterCaseUpload
record = PosterCaseUpload(
user_id="user-1",
product_id="product-1",
source_file_url="poster.pdf",
parse_status="failed",
parse_progress=100,
parse_error='Traceback: File "/app/insurance/tasks.py"',
parsed_data='{"annual_premium": 100000}',
product_snapshot_json='{"name": "测试产品"}',
)
data = record.to_dict()
assert data["parsedData"] == {"annual_premium": 100000}
assert data["productSnapshot"] == {"name": "测试产品"}
assert data["parseError"] == "解析失败,请重试;如多次失败请联系管理员"