baodan/api/insurance/ppt/comparison.py
wsb1224 2422303b36 前工程开发已经推进到 Phase 6 基础能力,但正式验收还没有完成。更准确地说:Phase 0~5 的主要代码链路已经落地,Phase 6 完成了治理框架,尚缺生产化和真实数据验收。
阶段	当前状态	说明
Phase 0~3	基本完成	Document IR、证据链、人工确认、不可变 PlanData Snapshot、海报/PPT 投影已实现
Phase 4	代码完成	场景、策略、模板版本,校验/发布门禁,确定性 resolver 和严格槽位合并已实现
Phase 5	代码完成	输入冻结、PPTX/海报对账、失败关闭、幂等、心跳、重试和冻结输入重放已实现
Phase 6	基础完成	质量看板、结构化告警、Golden 审批、留存清理、孤儿检查和灰度开关已实现
正式上线	未完成	缺真实样本、生产模板、业务规则签字和灰度观察

目前验证基线:
PPT/海报专项测试:107 passed, 1 skipped
Vue TypeScript 检查:通过
前端生产构建:通过
代码变更仍在工作区,尚未提交
仓库全量测试仍有既有失败/挂起项,暂时不能宣称全仓测试完全绿色
仍未完成的代码任务主要有:
影子解析差异流水线
目前有灰度开关,但还没有完整的“新旧解析同时运行、字段差异入库、按保司/profile 聚合”的影子比较任务。

自动视觉回归
目前实现的是 DOM 模块、溢出、尺寸、文本和数值检查;还缺基于真实模板和标准图片的像素差异、字体缺失、遮挡和裁切回归。

告警通道接入
后台已经能产生结构化质量告警,但尚未自动推送到邮件、企微或其他通知通道。

留存任务生产化
dry-run、实删服务和失败审计已经具备,但尚未接入周期性 Celery/定时任务,也没有自动重试失败清理批次。

旧链路最终下线
旧 PPT 解析器和海报紧凑解析仍保留为回滚路径。需要全量灰度稳定后才能删除或彻底关闭写入口。

全仓测试收口
需要处理现有无关失败和挂起测试,建立真正全绿的 CI 基线。

仍需外部输入和生产环境完成的事项:
至少 30 份脱敏 Golden PDF,并完成双人标注和精确率验收。
业务专家确认派生公式、缺失值、可比较性和结论策略。
上传并标注真实生产 PPTX 的语义 shape、页面类型和容量。
安装生产字体并建立视觉基准图片。
实际执行数据库迁移 038~040。
完成留存 dry-run、实删演练以及 10% → 30% → 100% 灰度。
观察期通过后开启 SCENARIO_ENGINE_V2,目前它仍默认关闭;真实清理开关也默认关闭。
完整状态记录在 [PPT与海报Phase4至6补充实施记录](D:/work/code/python/coding/baodanagent/docs/PPT与海报Phase4至6补充实施记录_20260802.md)。
2026-08-02 16:34:06 +08:00

402 lines
17 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.

"""多份保险计划书的确定性对比计算。"""
from __future__ import annotations
from typing import Iterable
from insurance.ppt.irr import compute_irr_ma, ia_irr_cap
DEFAULT_COMPARISON_YEARS = [5, 10, 20, 30]
MAX_COMPARISON_PRODUCTS = 10
SCENARIO_SINGLE_SAVINGS = "single_savings"
SCENARIO_MULTI_SAVINGS = "multi_savings_comparison"
SCENARIO_SAVINGS_IUL = "savings_iul_comprehensive"
SCENARIO_GENERIC_SINGLE = "generic_single"
SCENARIO_GENERIC_COMPARE = "generic_compare"
SCENARIO_GENERIC_PORTFOLIO = "generic_portfolio"
def detect_generation_scenario(products: list[dict]) -> str:
"""根据正式计划书险种组合确定生成场景,不依赖文件上传顺序。"""
kinds = [str(product.get("kind") or "").lower() for product in products]
savings_count = kinds.count("savings")
iul_count = kinds.count("iul")
if len(kinds) == 1 and savings_count == 1:
return SCENARIO_SINGLE_SAVINGS
if len(kinds) >= 2 and savings_count == len(kinds):
return SCENARIO_MULTI_SAVINGS
if savings_count >= 1 and iul_count >= 1 and savings_count + iul_count == len(kinds):
return SCENARIO_SAVINGS_IUL
if len(kinds) == 1:
return SCENARIO_GENERIC_SINGLE
if len(set(kinds)) == 1:
return SCENARIO_GENERIC_COMPARE
return SCENARIO_GENERIC_PORTFOLIO
def generation_mode_for_scenario(scenario: str) -> str:
if scenario in {SCENARIO_MULTI_SAVINGS, SCENARIO_GENERIC_COMPARE}:
return "compare"
if scenario in {SCENARIO_SAVINGS_IUL, SCENARIO_GENERIC_PORTFOLIO}:
return "portfolio"
return "single"
def build_scenario_slides(products: list[dict], scenario: str) -> list[dict]:
"""生成场景对应的硬性页面清单;模板只负责视觉,不得删减这些页面。"""
if scenario == SCENARIO_SINGLE_SAVINGS:
product = products[0]
slides = [
{"pageType": "cover", "title": "单份储蓄计划分析"},
{
"pageType": "guidance",
"title": "先确认客户任务,再讨论产品数字",
"bullets": [
"确认可持续投入、资金使用年份与年度现金流目标",
"确认退休、教育或传承任务,以及短期流动性要求",
"所有结论均以正式计划书数据和客户已确认资料为基础",
],
},
{"pageType": "company", "title": "长期承诺需要长期经营能力支撑"},
{
"pageType": "guidance",
"title": "这份保单承担长期储蓄与计划提取任务",
"bullets": [
"前期按计划完成缴费,中期保留积累窗口",
"到正式计划书约定年度后再按计划提取",
"提取后继续核对剩余退保价值与身故保障",
],
},
{"pageType": "timeline", "title": "缴费、积累与提取分成三个阶段"},
{"pageType": "chart", "chartType": "growth", "title": "不提取路径建立长期价值基准"},
{"pageType": "table", "tableType": "no_withdraw", "title": "不提取情景关键年度"},
{"pageType": "compare", "title": "保证与非保证利益必须分开理解"},
{
"pageType": "policy_summary",
"productIndex": 0,
"summaryMode": "no_withdraw",
"title": "保单摘要|不提取",
},
]
if product.get("withdrawalRows"):
slides.extend([
{"pageType": "table", "tableType": "withdraw", "title": "计划提取情景关键年度"},
{
"pageType": "policy_summary",
"productIndex": 0,
"summaryMode": "withdraw",
"title": "保单摘要|计划提取",
},
])
slides.extend([
{
"pageType": "guidance",
"title": "流动性来自选择,也伴随成本",
"bullets": [
"早期退保价值可能低于累计已缴保费",
"提取、贷款或货币转换可能降低后续价值与身故利益",
"非保证利益可能高于或低于演示,不能表述为确定收益",
],
},
{
"pageType": "guidance",
"title": "先判断单份计划是否独立适合客户",
"bullets": [
"确认缴费能力与独立应急资金",
"确认计划使用年份与非保证利益接受程度",
"单份计划成立后,再讨论是否需要其他保障或资产工具",
],
},
{"pageType": "closing", "title": "下一步:确认目标年份与正式数据"},
])
return slides
if scenario == SCENARIO_MULTI_SAVINGS:
slides = [
{"pageType": "cover", "title": "储蓄计划专业对比"},
{
"pageType": "guidance",
"title": "先建立统一口径,再比较产品差异",
"bullets": [
"核对年龄、性别、吸烟状态、币种、年缴保费和缴费年限",
"按相同保单年度比较保证价值、非保证利益和总退保价值",
"条件不一致时只展示差异,不做产品高低排名",
],
},
{"pageType": "comparison_chart", "title": "同一保单年度下的总退保价值走势"},
{"pageType": "comparison_table", "title": "关键年度同时拆分保证与非保证价值"},
]
for index, _product in enumerate(products):
slides.append({
"pageType": "policy_summary",
"productIndex": index,
"summaryMode": "no_withdraw",
"title": f"产品 {index + 1} 保单摘要",
})
slides.extend([
{
"pageType": "guidance",
"title": "目标年份决定比较重点",
"bullets": [
"缴费完成年度关注早期退出损失",
"第10、20年关注中期使用与保证底层",
"第25、30年及以后关注退休、长期增长和传承",
],
},
{
"pageType": "guidance",
"title": "提取情景必须取得相同条件的正式计划书",
"bullets": [
"开始年度、结束年度、年度金额、频率与币种必须一致",
"不得用不提取数据推算提取后的价值",
"缺失数据统一标记“待正式计划书确认”",
],
},
{"pageType": "conclusion", "title": "结论取决于目标年份、确定性与功能需求"},
{"pageType": "closing", "title": "下一步:统一条件后完成最终选择"},
])
return slides
if scenario == SCENARIO_SAVINGS_IUL:
slides = [
{"pageType": "cover", "title": "储蓄现金流与 IUL 保障综合方案"},
{
"pageType": "guidance",
"title": "先讲家庭任务,再安排两份保单",
"bullets": [
"储蓄计划负责建立可持续提取的现金流仓",
"IUL 负责放大身故保障并承接长期家庭责任",
"两份保单按现金流发生年度对应,不按年龄强行对齐",
],
},
{"pageType": "timeline", "title": "先看缴费、提取与保障启动时间"},
{"pageType": "synergy", "title": "组合不是并列持有,而是现金流接力"},
]
for index, product in enumerate(products):
slides.extend([
{
"pageType": "chart",
"productIndex": index,
"chartType": "growth",
"title": f"{product.get('productName') or f'方案 {index + 1}'}价值演示",
},
{
"pageType": "policy_summary",
"productIndex": index,
"summaryMode": "no_withdraw",
"title": f"{product.get('productName') or f'方案 {index + 1}'}保单摘要",
},
])
slides.extend([
{"pageType": "cashflow_bridge", "title": "储蓄提取如何承接 IUL 计划保费"},
{"pageType": "launch_paths", "title": "组合方案必须同时呈现两种启动路径"},
{"pageType": "combined_summary", "title": "组合保单摘要"},
{"pageType": "alignment_table", "title": "两份计划书按保单年度对应"},
{
"pageType": "guidance",
"title": "风险与执行条件必须说清楚",
"bullets": [
"储蓄保单未到正式提取年度前IUL 保费属于客户自有资金",
"保证价值与非保证演示价值必须分开呈现",
"所有金额来自正式计划书,缺失数字不自行推算",
],
},
{"pageType": "conclusion", "title": "选择保障先行或现金流同步"},
{"pageType": "closing", "title": "下一步:确认启动路径与前期现金流"},
])
return slides
page_types = ["cover", "company", "narrative", "chart"]
if len(products) > 1:
page_types.append("compare")
if scenario != SCENARIO_GENERIC_COMPARE:
page_types.append("synergy")
page_types.extend(["table", "conclusion", "closing"])
return [{"pageType": page_type} for page_type in page_types]
def build_comparison_contract(
products: list[dict],
mode: str = "single",
comparison_years: Iterable[int] | None = None,
calculation_policy: dict | None = None,
) -> dict:
"""将归一化产品转换为渲染器可直接使用的对比契约。"""
normalized_mode = (mode or "single").lower()
if normalized_mode not in {"single", "compare", "portfolio"}:
raise ValueError("generationMode 必须是 single、compare 或 portfolio")
if normalized_mode == "single":
return {"mode": "single", "products": [], "years": [], "warnings": []}
if len(products) < 2:
raise ValueError("产品对比或组合方案至少需要 2 份有效计划书")
if len(products) > MAX_COMPARISON_PRODUCTS:
raise ValueError(f"一次最多对比 {MAX_COMPARISON_PRODUCTS} 份计划书")
years = _normalize_years(comparison_years)
kinds = {str(p.get("kind") or "").lower() for p in products}
currencies = {
str((p.get("policy") or {}).get("currency") or "").upper()
for p in products
if (p.get("policy") or {}).get("currency")
}
warnings: list[str] = []
if normalized_mode == "compare":
if len(kinds) != 1:
raise ValueError("不同险种不能直接横向排名,请选择“组合方案”模式")
if len(currencies) > 1:
raise ValueError("不同币种不能直接比较,请上传相同币种的计划书")
elif len(currencies) > 1:
warnings.append("组合中包含不同币种,金额仅分别展示,不计算合计")
insured_signatures = {
(
(p.get("insured") or {}).get("age"),
str((p.get("insured") or {}).get("gender") or "").lower(),
)
for p in products
}
if len(insured_signatures) > 1:
warnings.append("计划书的受保人年龄或性别不一致,比较结论需谨慎使用")
return {
"mode": normalized_mode,
"years": years,
"currency": next(iter(currencies), ""),
"productKinds": sorted(kinds),
"products": [_build_product_metrics(product, years, calculation_policy) for product in products],
"nonComparableReason": (
"不同险种或币种仅并列展示,不进行排名"
if normalized_mode == "portfolio" and (len(kinds) > 1 or len(currencies) > 1)
else None
),
"warnings": warnings,
}
def _normalize_years(years: Iterable[int] | None) -> list[int]:
values = []
for value in years or DEFAULT_COMPARISON_YEARS:
try:
year = int(value)
except (TypeError, ValueError):
continue
if 1 <= year <= 100 and year not in values:
values.append(year)
return sorted(values) or list(DEFAULT_COMPARISON_YEARS)
def _derived_metric_spec(calculation_policy: dict | None, code: str):
"""None 表示兼容旧链路False 表示版本化策略明确禁用该派生指标。"""
if calculation_policy is None:
return None
metrics = calculation_policy.get("derivedMetrics") or []
for item in metrics:
if isinstance(item, dict) and item.get("code") == code:
return item
return False
def _build_product_metrics(product: dict, years: list[int], calculation_policy: dict | None = None) -> dict:
policy = product.get("policy") or {}
rows = product.get("benefitRows") or []
annual_premium = _optional_number(policy.get("annualPremium"))
raw_pay_years = _optional_number(policy.get("payYears"))
pay_years = int(raw_pay_years) if raw_pay_years is not None else None
total_premium = _optional_number(_first_present(
policy.get("contractualTotalPremium"),
policy.get("totalPremium"),
))
currency = str(policy.get("currency") or "").upper()
year_values = {}
for year in years:
row = _find_year(rows, year)
if not row:
year_values[str(year)] = None
continue
total_value = _optional_number(row.get("totalSurrenderValue"))
guaranteed_value = _optional_number(row.get("guaranteedCashValue"))
death_benefit = _optional_number(_first_present(
row.get("nonGuaranteedDeathBenefit"),
row.get("deathBenefit"),
row.get("guaranteedDeathBenefit"),
))
irr = None
irr_spec = _derived_metric_spec(calculation_policy, "irr")
if irr_spec is not False and annual_premium is not None and pay_years and total_value is not None:
irr = compute_irr_ma(
annual_premium,
pay_years,
total_value,
year,
ia_irr_cap(currency or "HKD"),
)
year_values[str(year)] = {
"guaranteedValue": guaranteed_value,
"totalValue": total_value,
"deathBenefit": death_benefit,
"irr": round(irr * 100, int((irr_spec or {}).get("precision", 2))) if irr is not None else None,
"sourcePage": row.get("sourcePage"),
}
return {
"fileId": product.get("fileId", ""),
"pdfName": product.get("pdfName", ""),
"companyId": product.get("companyId", ""),
"productName": product.get("productName", ""),
"kind": product.get("kind", "savings"),
"currency": currency,
"annualPremium": annual_premium,
"payYears": pay_years,
"totalPremium": total_premium,
"sumInsured": _optional_number(policy.get("sumInsured")),
"breakevenYear": _find_breakeven_year(rows),
"yearValues": year_values,
}
def _find_year(rows: list[dict], year: int) -> dict | None:
for row in rows:
row_year = _optional_number(row.get("policyYear"))
if row_year is not None and int(row_year) == year:
return row
return None
def _find_breakeven_year(rows: list[dict]) -> int | None:
for row in rows:
premium = _optional_number(row.get("totalPremiumPaid"))
value = _optional_number(row.get("totalSurrenderValue"))
year = _optional_number(row.get("policyYear"))
if premium is not None and value is not None and year is not None and premium > 0 and value >= premium:
return int(year)
return None
def _number(value) -> float:
"""兼容旧调用;新对比逻辑使用 ``_optional_number``。"""
parsed = _optional_number(value)
return parsed if parsed is not None else 0.0
def _optional_number(value) -> float | None:
if value is None or isinstance(value, bool) or (isinstance(value, str) and not value.strip()):
return None
try:
parsed = float(value)
except (TypeError, ValueError):
return None
return parsed if parsed == parsed and parsed not in (float("inf"), float("-inf")) else None
def _first_present(*values):
for value in values:
if value is not None and value != "":
return value
return None