阶段 当前状态 说明 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)。
232 lines
9.0 KiB
Python
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))
|