baodan/tests/ppt_poster_optimization_test.py
wsb1224 9939188a4b 主要修复:
海报 PDF 解析从 Gunicorn 后台线程迁移到 Celery Worker,消除 gevent/asyncio.run 冲突和任务卡死。
海报改为紧凑解析,只选择客户资料、保费和核心利益页;排除提领方案、悲观/乐观情景页。
LLM 调用由原来的约 27 次降为 1 次。
补充年缴保费、首年实缴、缴费期、总保费及第 1/5/10/15/20/25/30 年退保价值。
增加真实解析进度、错误信息、任务 ID、心跳和完成时间。
相同用户重复上传同一份计划书时复用现有任务或结果。
前端取消 180 秒本地假超时,改为串行轮询后端真实状态;网络波动不再误判解析失败。
增加服务重启后的过期任务恢复机制。
修复解析结果 JSON 序列化遗漏问题。
关键文件:
[extraction.py](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py)
[tasks.py](D:/work/code/python/coding/baodanagent/api/insurance/poster/tasks.py)
[celery_tasks.py](D:/work/code/python/coding/baodanagent/api/insurance/generation/celery_tasks.py)
[service.py](D:/work/code/python/coding/baodanagent/api/insurance/poster/service.py)
[migrate_032.py](D:/work/code/python/coding/baodanagent/api/insurance/db/migrate_032.py)
[PosterSourcePanel.vue](D:/work/code/python/coding/baodanagent/frontend/src/components/poster/workspace/PosterSourcePanel.vue)
[回归测试](D:/work/code/python/coding/baodanagent/tests/ppt_poster_optimization_test.py)
2026-08-01 03:15:43 +08:00

825 lines
30 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_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
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"] == "解析失败,请重试;如多次失败请联系管理员"