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

208 lines
8.3 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:
if not isinstance(row, dict):
continue
entry = {
"year": _first_present(row, "year", "policy_year", "policyYear"),
"guaranteed": _first_present(row, "guaranteed_cash_value", "guaranteed_csv", "guaranteedCashValue", "guaranteed"),
"nonGuaranteed": _first_present(row, "non_guaranteed_cash_value", "non_guaranteed", "nonGuaranteed"),
"totalSurrender": _first_present(row, "total_surrender_value", "total_surrender", "totalSurrenderValue", "total"),
"deathBenefit": _first_present(row, "death_benefit", "deathBenefit"),
}
if isinstance(entry["year"], (int, float)) and 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": None,
"nonGuaranteed": None,
"totalSurrender": explicit_value,
"deathBenefit": None,
}
result.append(by_year[year])
elif by_year[year]["totalSurrender"] != explicit_value:
by_year[year]["conflict"] = {
"field": "totalSurrender",
"tableValue": by_year[year]["totalSurrender"],
"explicitValue": explicit_value,
"status": "unresolved",
}
return result
def _first_present(mapping: dict, *keys):
for key in keys:
value = mapping.get(key)
if value is not None and value != "":
return value
return None
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")),
}