baodan/api/insurance/poster/content_builder.py
wsb1224 d57bd59acd 主要改动:
PPT 表格现在按槽位解析,正确保留 —、空列和单空格表格,不再发生金额左移。[regex_extractor.py (line 470)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:470)
补齐年龄、累计保费、非保证现金价值、非保证身故赔偿等别名。
IUL 使用专属 LLM 提取结构,并加强年度、年龄、账户价值完整性校验。[extraction.py (line 741)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:741)
PPT 缓存升级到 v6,旧错误缓存会自动失效。
提领方案不再与普通退保价值混淆,相关提示语已统一。
海报摘要年度金额与收益图表改为同一数据源;缺少部分里程碑时会从原始真实年度补足,不会隐藏图表。[content_builder.py (line 51)](D:/work/code/python/coding/baodanagent/api/insurance/poster/content_builder.py:51)
系统计算、利益表派生、人工修改增加来源标记。
增加“重新解析”功能,旧海报计划书无需重新上传。[routes.py (line 266)](D:/work/code/python/coding/baodanagent/api/insurance/poster/routes.py:266)
ECharts 导出增加双帧就绪检测和事件竞态保护,不再依赖不存在的 .once()。
PPT 手机端改为可编辑数据卡片,320px 操作栏自动纵向排列;补齐按钮语义、键盘焦点及 44px 触控区域。[PptDataReview.vue (line 195)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:195)
验证结果:
核心专项测试:145 passed
除既有聊天日志测试外的测试集:236 passed
前端生产构建:通过
Python 语法检查:通过
git diff --check:通过
Impeccable 前端检测:无发现
全量测试仅剩一个与本次无关的既有失败:test_chat_logs_query 缺少 Flask application context
2026-08-01 01:22:07 +08:00

193 lines
7.8 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.

"""海报内容构建器 — 将计划书和小册子数据组装为单图/长图模块。"""
import logging
logger = logging.getLogger(__name__)
def build_poster_content(
parsed_data: dict,
product_rules: dict,
output_mode: str = "single",
plan_type: str = "other",
) -> dict:
"""组装海报内容模块。
参数:
parsed_data: 计划书解析数据(含 benefit_table
product_rules: 产品小册子解析数据(含 features 来源页)
output_mode: 'single''long'
返回:
结构化的海报内容 dict供前端 PosterHtmlCanvas 使用
"""
from insurance.poster.field_profiles import get_field_profile
profile = get_field_profile(plan_type, output_mode)
features = _build_features(product_rules)[:profile["maxFeatureCount"]]
benefit_table = _select_benefit_rows(
_build_benefit_table(parsed_data),
profile["milestoneYears"],
profile["outputMode"],
)
if profile["planType"] in ("ci", "other"):
benefit_table = []
content = {
"summary": _build_summary(parsed_data),
"features": features,
"benefit_table": benefit_table,
"bonus_mechanism": product_rules.get("bonus_mechanism", ""),
"flexible_options": product_rules.get("flexible_options", []),
"risk_warnings": product_rules.get("risk_warnings", []),
"investment_rules": product_rules.get("investment_rules", {}),
"currency_options": product_rules.get("currency_options", []),
"data_completeness": _assess_completeness(parsed_data, product_rules),
"field_profile": profile,
"warnings": _profile_warnings(profile, parsed_data, product_rules),
}
return content
def _select_benefit_rows(rows: list, milestone_years: list[int], output_mode: str) -> list:
"""按画像裁剪年度数据,绝不把完整利益表塞进单图。"""
if not rows or not milestone_years:
return []
ordered = sorted(rows, key=lambda row: row.get("year") or 0)
selected = [row for row in ordered if row.get("year") in milestone_years]
if output_mode == "single":
return (selected or ordered)[:1]
# 命中部分里程碑时,用原表中的真实年度补足图表最低三行;不插值、不编造。
target_count = min(6, len(ordered))
if len(selected) < target_count:
if target_count == 1:
representative = ordered
else:
representative = [
ordered[round(index * (len(ordered) - 1) / (target_count - 1))]
for index in range(target_count)
]
selected_years = {row.get("year") for row in selected}
for row in representative + ordered:
if row.get("year") not in selected_years:
selected.append(row)
selected_years.add(row.get("year"))
if len(selected) >= target_count:
break
return sorted(selected, key=lambda row: row.get("year") or 0)[:6]
def _profile_warnings(profile: dict, parsed_data: dict, product_rules: dict) -> list[str]:
warnings = []
if profile["planType"] == "ci" and profile["outputMode"] == "long":
if not product_rules.get("coverage_highlights"):
warnings.append("保障列表为空,已使用简版长图")
if profile["outputMode"] == "long" and profile["planType"] in ("savings", "iul"):
rows = _build_benefit_table(parsed_data)
if len(rows) < 3:
warnings.append("利益演示数据不足,已隐藏年度图表")
return warnings
def _build_summary(parsed_data: dict) -> dict:
"""构建客户摘要卡片数据。"""
return {
"age": parsed_data.get("age"),
"gender": parsed_data.get("gender"),
"smoking_status": parsed_data.get("smoking_status"),
"currency": parsed_data.get("currency"),
"sum_assured": parsed_data.get("sum_assured"),
"basic_sum_assured": parsed_data.get("basic_sum_assured"),
"annual_premium": parsed_data.get("annual_premium"),
"basic_plan_annual_premium": parsed_data.get("basic_plan_annual_premium"),
"total_premium": parsed_data.get("total_premium"),
"first_year_amount_due": parsed_data.get("first_year_amount_due"),
"premium_term": parsed_data.get("premium_term"),
"coverage_period": parsed_data.get("coverage_period"),
"initial_death_benefit": parsed_data.get("initial_death_benefit"),
"surrender_value_10": parsed_data.get("surrender_value_10"),
"surrender_value_20": parsed_data.get("surrender_value_20"),
"surrender_value_30": parsed_data.get("surrender_value_30"),
}
def _build_features(product_rules: dict) -> list:
"""构建产品亮点列表,保留来源页码。
优先使用小册子提取的 features带 source_page
如果小册子无数据,回退到计划书的 key_benefits。
"""
features = product_rules.get("features", [])
if features:
result = []
for f in features[:8]:
item = {
"title": f.get("title", ""),
"summary": f.get("summary", ""),
"source_page": f.get("source_page"),
}
if item["title"]:
result.append(item)
return result
# 回退:使用计划书的 key_benefits
return [] # 由调用方从 parsed_data.key_benefits 补充
def _build_benefit_table(parsed_data: dict) -> list:
"""构建利益演示表数据(供图表组件使用)。"""
table = (
parsed_data.get("benefit_table")
or parsed_data.get("benefit_illustration")
or []
)
result = []
for row in table:
entry = {
"year": row.get("year") or row.get("policy_year") or row.get("policyYear") or 0,
"guaranteed": row.get("guaranteed_cash_value") or row.get("guaranteed_csv") or row.get("guaranteedCashValue") or row.get("guaranteed") or 0,
"nonGuaranteed": row.get("non_guaranteed_cash_value") or row.get("non_guaranteed") or row.get("nonGuaranteed") or 0,
"totalSurrender": row.get("total_surrender_value") or row.get("total_surrender") or row.get("totalSurrenderValue") or row.get("total") or 0,
"deathBenefit": row.get("death_benefit") or row.get("deathBenefit") or 0,
}
if entry["year"] > 0:
result.append(entry)
by_year = {int(row["year"]): row for row in result}
for year in (10, 20, 30):
explicit_value = parsed_data.get(f"surrender_value_{year}")
if explicit_value is None:
continue
if year not in by_year:
by_year[year] = {
"year": year,
"guaranteed": 0,
"nonGuaranteed": 0,
"totalSurrender": explicit_value,
"deathBenefit": 0,
}
result.append(by_year[year])
else:
by_year[year]["totalSurrender"] = explicit_value
return result
def _assess_completeness(parsed_data: dict, product_rules: dict) -> dict:
"""评估数据完整度,决定哪些模块应显示。
返回:
{field: bool} 表示各模块是否有足够数据
"""
has_benefit_table = bool(parsed_data.get("benefit_table") or parsed_data.get("benefit_illustration"))
benefit_rows = parsed_data.get("benefit_table") or parsed_data.get("benefit_illustration") or []
return {
"has_summary": bool(parsed_data.get("age") or parsed_data.get("sum_assured")),
"has_benefit_chart": has_benefit_table and len(benefit_rows) >= 3,
"has_features": bool(product_rules.get("features")),
"has_currency_options": bool(product_rules.get("currency_options")),
"has_bonus": bool(product_rules.get("bonus_mechanism")),
"has_flexible_options": bool(product_rules.get("flexible_options")),
"has_risk_warnings": bool(product_rules.get("risk_warnings")),
}