利益表按最多两页、9000字符分块调用 LLM,成功分块按保单年度合并、去重、排序。[extraction.py (line 125)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:125) 身份字段只发送关键页面;没有提领关键词时不再发送整份 PDF 做空提领请求。 单个利益分块失败不会丢失其他成功分块,并记录具体分块编号和错误。[extraction.py (line 1161)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:1161) RemoteProtocolError、连接超时、408/429/部分5xx现在会按配置执行真实退避重试;DeepSeek默认最多调用3次。[llm_client.py (line 600)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/llm_client.py:600) 只有一个供应商时,错误信息会明确显示“已配置1个供应商”,不再误导为存在多个备用供应商。 利益表分块失败或储蓄险年度数据不足时,结果标记为 partial,不再伪装完整成功。[extraction.py (line 750)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:750) 会话显示“解析完成,部分文件需校对”,同时保留可人工修正的数据。 日志增加文件名、文件序号、最终状态、利益行数,以及身份/利益各分块/提领的独立耗时、token和错误信息。[celery_tasks.py (line 181)](D:/work/code/python/coding/baodanagent/api/insurance/generation/celery_tasks.py:181) 缓存版本升级至 v8,旧解析缓存自动失效。 验证结果: PPT专项测试:54 passed, 1 skipped 大范围测试:231 passed, 1 skipped 仅剩既有聊天日志测试缺少 Flask application context 完整测试收集另受本机缺少 python-pptx 影响 Python语法检查:通过 前端生产构建:通过 git diff --check:通过
726 lines
26 KiB
Python
726 lines
26 KiB
Python
"""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
|