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

232 lines
9.0 KiB
Python

"""旧提取结构到 PlanData v1 的无推断转换。"""
from __future__ import annotations
import re
from typing import Any
from insurance.plan_data.field_values import extracted_field_value, missing_field_value
IDENTITY_FIELDS = {
"productName": ("product_name",),
"productType": ("product_type", "plan_type"),
}
INSURED_FIELDS = {
"insuredName": ("name",),
"insuredAge": ("age",),
"insuredGender": ("gender",),
"insuredSmoker": ("smoker",),
}
POLICY_FIELDS = {
"currency": ("currency",),
"sumAssured": ("sum_insured", "sumAssured"),
"annualPremium": ("annual_premium", "annualPremium"),
"premiumPaymentPeriod": ("premium_payment_period", "payYears"),
"coveragePeriod": ("coverage_period", "coveragePeriod"),
"contractualTotalPremium": ("total_premium", "contractualTotalPremium"),
}
def legacy_to_plan_data(data: dict, evidence_entries: list[dict] | None = None) -> dict:
evidence_by_path = _evidence_map(evidence_entries or (data.get("meta") or {}).get("evidence") or [])
identity = {
target: _field_value(_first(data, *aliases), evidence_by_path, aliases)
for target, aliases in IDENTITY_FIELDS.items()
}
insured = data.get("insured") or {}
identity.update({
target: _field_value(
_first(insured, *aliases),
evidence_by_path,
tuple(f"insured.{item}" for item in aliases),
)
for target, aliases in INSURED_FIELDS.items()
})
policy = data.get("policy") or {}
currency = _first(policy, "currency") or data.get("currency")
policy_values = {}
for target, aliases in POLICY_FIELDS.items():
value = _first(policy, *aliases)
if value is None:
value = _first(data, *aliases)
paths = tuple(f"policy.{item}" for item in aliases) + aliases
policy_values[target] = _field_value(
value,
evidence_by_path,
paths,
currency=(
str(currency).upper()
if target not in {"currency", "premiumPaymentPeriod", "coveragePeriod"} and currency
else None
),
)
consumed_policy_keys = {alias for aliases in POLICY_FIELDS.values() for alias in aliases}
for key, value in policy.items():
if key in consumed_policy_keys:
continue
policy_values[_camel_case(key)] = _field_value(
value,
evidence_by_path,
(f"policy.{key}",),
currency=str(currency).upper() if currency and _looks_amount_field(key) else None,
)
scenarios: dict[str, list[dict]] = {}
for row in data.get("benefit_illustration") or data.get("benefit_table") or []:
if not isinstance(row, dict):
continue
scenario = str(row.get("scenario_type") or row.get("scenarioType") or "base")
scenarios.setdefault(scenario, []).append(_benefit_row(row, scenario, evidence_by_path, currency))
withdrawals = [
_plain_row(row, currency)
for row in data.get("withdrawal_illustration") or data.get("withdrawals") or []
if isinstance(row, dict)
]
return {
"identity": identity,
"policy": policy_values,
"benefitScenarios": [
{"scenarioType": scenario, "rows": rows}
for scenario, rows in sorted(scenarios.items())
],
"withdrawals": withdrawals,
"riders": list(data.get("riders") or data.get("coverage_items") or []),
"assumptions": dict(data.get("assumptions") or {}),
}
def legacy_path_to_plan_path(data: dict, path: str) -> str | None:
"""把复核界面的旧字段路径映射到 PlanData 字段路径。"""
normalized = str(path or "").replace("[", ".").replace("]", "").strip(".")
direct = {
"product_name": "identity.productName",
"product_type": "identity.productType",
"insured.name": "identity.insuredName",
"insured.age": "identity.insuredAge",
"insured.gender": "identity.insuredGender",
"insured.smoker": "identity.insuredSmoker",
"policy.currency": "policy.currency",
"policy.sum_insured": "policy.sumAssured",
"policy.annual_premium": "policy.annualPremium",
"policy.premium_payment_period": "policy.premiumPaymentPeriod",
"policy.coverage_period": "policy.coveragePeriod",
"policy.total_premium": "policy.contractualTotalPremium",
}
if normalized in direct:
return direct[normalized]
parts = normalized.split(".")
if parts and parts[0] == "policy" and len(parts) == 2:
return f"policy.{_camel_case(parts[1])}"
if len(parts) < 3 or parts[0] != "benefit_illustration" or not parts[1].isdigit():
return None
source_rows = data.get("benefit_illustration") or []
source_index = int(parts[1])
if source_index >= len(source_rows) or not isinstance(source_rows[source_index], dict):
return None
source_row = source_rows[source_index]
scenario = str(source_row.get("scenario_type") or source_row.get("scenarioType") or "base")
scenario_names = sorted({
str(row.get("scenario_type") or row.get("scenarioType") or "base")
for row in source_rows if isinstance(row, dict)
})
scenario_index = scenario_names.index(scenario)
scenario_row_index = sum(
1 for row in source_rows[:source_index]
if isinstance(row, dict)
and str(row.get("scenario_type") or row.get("scenarioType") or "base") == scenario
)
return (
f"benefitScenarios.{scenario_index}.rows.{scenario_row_index}."
f"{_camel_case(parts[2])}"
)
def _benefit_row(row: dict, scenario: str, evidence_by_path: dict, currency) -> dict:
year = _first(row, "policy_year", "policyYear", "year")
variant = str(row.get("row_variant") or row.get("rowVariant") or "default")
result = {
"policyYear": _field_value(year, evidence_by_path, (f"benefit.{scenario}.{year}.{variant}.policy_year",)),
"rowVariant": variant,
}
aliases = {
"age": ("age",),
"totalPremiumPaid": ("total_premium_paid", "totalPremiumPaid"),
"guaranteedCashValue": ("guaranteed_cash_value", "guaranteedCashValue"),
"nonGuaranteedCashValue": ("non_guaranteed_cash_value", "nonGuaranteedCashValue"),
"totalSurrenderValue": ("total_surrender_value", "totalSurrenderValue", "total_surrender"),
"deathBenefit": ("death_benefit", "deathBenefit"),
"accountValue": ("account_value", "accountValue"),
}
for target, source_aliases in aliases.items():
paths = tuple(f"benefit.{scenario}.{year}.{variant}.{item}" for item in source_aliases)
result[target] = _field_value(
_first(row, *source_aliases),
evidence_by_path,
paths,
currency=str(currency).upper() if target != "age" and currency else None,
)
consumed = {alias for source_aliases in aliases.values() for alias in source_aliases}
consumed.update({"scenario_type", "scenarioType", "row_variant", "rowVariant", "source_page", "sourcePage"})
for key, value in row.items():
if key in consumed:
continue
result[_camel_case(key)] = _field_value(
value,
evidence_by_path,
(f"benefit.{scenario}.{year}.{variant}.{key}",),
currency=str(currency).upper() if currency and _looks_amount_field(key) else None,
)
return result
def _plain_row(row: dict, currency) -> dict:
result = {}
for key, value in row.items():
result[_camel_case(key)] = (
extracted_field_value(
value,
currency=str(currency).upper() if currency and _looks_amount_field(key) else None,
)
if value is not None else missing_field_value()
)
return result
def _field_value(value: Any, evidence_by_path: dict, paths, currency: str | None = None) -> dict:
evidence = []
for path in paths:
evidence.extend(evidence_by_path.get(path, ()))
if value is None or (isinstance(value, str) and not value.strip()):
return missing_field_value(currency=currency)
return extracted_field_value(value, currency=currency, evidence=evidence)
def _evidence_map(entries: list[dict]) -> dict[str, list[dict]]:
result: dict[str, list[dict]] = {}
for entry in entries:
if not isinstance(entry, dict):
continue
path = str(entry.get("fieldPath") or "")
ref = entry.get("evidence") if "evidence" in entry else entry
if path and isinstance(ref, dict):
result.setdefault(path, []).append(dict(ref))
return result
def _first(mapping: dict, *keys):
for key in keys:
if key in mapping and mapping[key] is not None and mapping[key] != "":
return mapping[key]
return None
def _looks_amount_field(key: str) -> bool:
token = str(key).lower()
return any(word in token for word in ("premium", "value", "benefit", "amount", "withdraw"))
def _camel_case(value: str) -> str:
return re.sub(r"_([a-z0-9])", lambda match: match.group(1).upper(), str(value))