海报确认、文案生成、海报生成增加服务端失败关闭门禁,绑定文件哈希、解析快照哈希和确认数据哈希,并返回 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:通过,仅有换行符提示
1192 lines
43 KiB
Python
1192 lines
43 KiB
Python
"""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"] == "解析失败,请重试;如多次失败请联系管理员"
|