海报确认、文案生成、海报生成增加服务端失败关闭门禁,绑定文件哈希、解析快照哈希和确认数据哈希,并返回 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:通过,仅有换行符提示
208 lines
8.3 KiB
Python
208 lines
8.3 KiB
Python
"""海报内容构建器 — 将计划书和小册子数据组装为单图/长图模块。"""
|
||
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")),
|
||
}
|