baodan/api/insurance/plan_data/projections.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

132 lines
5.3 KiB
Python

"""Confirmed PlanData 的只读海报与 PPT 投影。"""
from __future__ import annotations
def to_poster_projection(plan_data: dict) -> dict:
identity = plan_data.get("identity") or {}
policy = plan_data.get("policy") or {}
scenarios = plan_data.get("benefitScenarios") or []
rows = _single_comparable_rows(scenarios)
projection = {
"plan_type": _value(identity.get("productType")),
"product_name": _value(identity.get("productName")),
"age": _value(identity.get("insuredAge")),
"gender": _value(identity.get("insuredGender")),
"currency": _value(policy.get("currency")),
"annual_premium": _value(policy.get("annualPremium")),
"premium_term": _value(policy.get("premiumPaymentPeriod")),
"sum_assured": _value(policy.get("sumAssured")),
"total_premium": _contract_value(policy.get("contractualTotalPremium")),
"benefit_table": [_legacy_row(row) for row in rows],
"meta": {
"projectionVersion": "poster-projection-v1",
"reconciliationStatus": "confirmed",
},
}
by_year = {
_value(row.get("policyYear")): _value(row.get("totalSurrenderValue"))
for row in rows
if _value(row.get("policyYear")) is not None
}
for year in (10, 20, 30):
projection[f"surrender_value_{year}"] = by_year.get(year)
return projection
def to_deck_contract(plan_data: dict, *, document_sha256: str | None = None) -> dict:
identity = plan_data.get("identity") or {}
policy = plan_data.get("policy") or {}
scenarios = plan_data.get("benefitScenarios") or []
comparable = len(scenarios) <= 1
return {
"schemaVersion": "deck-contract-v1",
"kind": _value(identity.get("productType")),
"productName": _value(identity.get("productName")),
"insured": {
"name": _value(identity.get("insuredName")),
"age": _value(identity.get("insuredAge")),
"gender": _value(identity.get("insuredGender")),
"smoker": _value(identity.get("insuredSmoker")),
},
"policy": {
**{key: _contract_value(value) for key, value in policy.items()},
"currency": _value(policy.get("currency")),
"sumInsured": _value(policy.get("sumAssured")),
"annualPremium": _value(policy.get("annualPremium")),
"payYears": _value(policy.get("premiumPaymentPeriod")),
"contractualTotalPremium": _contract_value(policy.get("contractualTotalPremium")),
},
"benefitScenarios": scenarios,
"benefitRows": [_deck_row(row) for row in _single_comparable_rows(scenarios)] if comparable else [],
"withdrawalRows": [_unwrap_row(row) for row in plan_data.get("withdrawals") or []],
"coverageItems": list(plan_data.get("riders") or []),
"indexAccounts": list((plan_data.get("assumptions") or {}).get("indexAccounts") or []),
"source": {"pdfHash": document_sha256 or "", "parser": "plan-data-v1"},
"comparable": comparable,
"nonComparableReason": None if comparable else "存在多个利益场景,需由已发布生成策略明确选择后才能比较",
"reconciliationStatus": "confirmed",
}
def _single_comparable_rows(scenarios: list[dict]) -> list[dict]:
if not scenarios:
return []
if len(scenarios) == 1:
return list(scenarios[0].get("rows") or [])
base = [item for item in scenarios if item.get("scenarioType") == "base"]
return list(base[0].get("rows") or []) if len(base) == 1 else []
def _legacy_row(row: dict) -> dict:
return {
"policy_year": _value(row.get("policyYear")),
"age": _value(row.get("age")),
"total_premium_paid": _value(row.get("totalPremiumPaid")),
"guaranteed_cash_value": _value(row.get("guaranteedCashValue")),
"non_guaranteed_cash_value": _value(row.get("nonGuaranteedCashValue")),
"total_surrender_value": _value(row.get("totalSurrenderValue")),
"death_benefit": _value(row.get("deathBenefit")),
}
def _deck_row(row: dict) -> dict:
return {
**{key: _value(value) for key, value in row.items() if key != "rowVariant"},
"policyYear": _value(row.get("policyYear")),
"age": _value(row.get("age")),
"totalPremiumPaid": _value(row.get("totalPremiumPaid")),
"guaranteedCashValue": _value(row.get("guaranteedCashValue")),
"nonGuaranteedCashValue": _value(row.get("nonGuaranteedCashValue")),
"totalSurrenderValue": _value(row.get("totalSurrenderValue")),
"deathBenefit": _value(row.get("deathBenefit")),
"evidence": _row_evidence(row),
"sourcePage": _first_evidence_page(row),
}
def _row_evidence(row: dict) -> list[dict]:
result = []
for value in row.values():
if isinstance(value, dict):
result.extend(value.get("evidence") or [])
return result
def _first_evidence_page(row: dict):
evidence = _row_evidence(row)
return evidence[0].get("pageNumber") if evidence else None
def _unwrap_row(row: dict) -> dict:
return {key: _value(value) for key, value in row.items()}
def _value(field):
return field.get("value") if isinstance(field, dict) else field
def _contract_value(field):
if not isinstance(field, dict) or field.get("status") == "derived":
return None
return field.get("value")