baodan/api/insurance/ppt/normalizer.py
wsb1224 7a3e2870dc 主要成果:
海报确认、文案生成、海报生成增加服务端失败关闭门禁,绑定文件哈希、解析快照哈希和确认数据哈希,并返回 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:通过,仅有换行符提示
2026-08-02 12:58:41 +08:00

489 lines
20 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.

"""归一化模块 — 将 LLM 提取的原始数据转为标准结构。"""
import re
import hashlib
import math
from typing import Optional
def _safe_number(value) -> float:
"""兼容旧计算路径:无效值返回 0事实字段应使用 ``_nullable_number``。"""
parsed = _nullable_number(value)
return parsed if parsed is not None else 0
def _nullable_number(value) -> Optional[float]:
"""事实数值转换:缺失或非法返回 None真实 0 原样保留。"""
if value is None or isinstance(value, bool) or (isinstance(value, str) and not value.strip()):
return None
try:
parsed = float(value)
except (ValueError, TypeError):
return None
return parsed if math.isfinite(parsed) else None
def _normalize_gender(raw) -> str:
"""统一性别值为 male/female/unknown。兼容旧数据中的 男/女。"""
if not raw:
return "unknown"
s = str(raw).strip().lower()
if s in ("male", "m", ""):
return "male"
if s in ("female", "f", ""):
return "female"
return "unknown"
def normalize_smoker(raw) -> str:
"""统一吸烟状态为 yes/no/unknown。"""
if raw is None:
return "unknown"
value = str(raw).strip().lower()
if (
value in ("no", "n", "false", "0", "", "不吸烟", "不吸煙", "非吸烟者", "非吸煙者")
or "non-smoker" in value
or "non smoker" in value
or "nonsmoker" in value
or "不吸烟" in value
or "不吸煙" in value
or "非吸烟" in value
or "非吸煙" in value
):
return "no"
if (
value in ("yes", "y", "true", "1", "", "吸烟", "吸煙")
or "smoker" in value
or "吸烟" in value
or "吸煙" in value
):
return "yes"
return "unknown"
def _normalize_currency(raw) -> Optional[str]:
"""统一常见币种别名;未知值保持为空,避免伪造 USD。"""
if raw is None:
return None
value = str(raw).strip().upper().replace(" ", "")
aliases = {
"US$": "USD",
"$": "USD",
"USB": "USD",
"RMB": "CNY",
"人民币": "CNY",
"": "CNY",
"¥": "CNY",
"HK$": "HKD",
"S$": "SGD",
"": "EUR",
"£": "GBP",
}
normalized = aliases.get(value, value)
return normalized if normalized in {"USD", "HKD", "CNY", "SGD", "EUR", "GBP"} else None
def _optional_number(value):
"""可选金额:缺失保留 None不把未知值伪装成 0。"""
return _nullable_number(value)
def _first_value(mapping: dict, *keys):
"""从 snake_case/camelCase 字段中读取第一个非空值。"""
for key in keys:
value = mapping.get(key)
if value not in (None, ""):
return value
return None
def _normalize_pay_years(raw) -> int:
"""统一缴费年期为 int。兼容旧数据中的 '5年' 字符串。"""
if raw is None:
return 0
if isinstance(raw, (int, float)):
return int(raw)
s = str(raw).strip()
m = re.search(r'(\d+)', s)
if m:
return int(m.group(1))
if "整付" in s or "趸缴" in s or "single" in s.lower():
return 1 # 整付视为 1 年
return 0
def _extract_years(value) -> int:
"""从字符串提取年数(如 '5年' → 5"""
if value is None:
return 0
match = re.search(r"\d+(?:\.\d+)?", str(value))
return int(float(match.group())) if match else 0
def _sha256(file_path: Optional[str]) -> str:
"""计算文件 SHA-256。"""
if not file_path:
return ""
try:
h = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
return h.hexdigest()
except Exception:
return ""
def normalize_savings_plan(raw: dict, pdf_path: str = None, parser: str = "llm-json") -> dict:
"""归一化储蓄险提取数据。"""
insured_age = _nullable_number(raw.get("insured", {}).get("age"))
insured = raw.get("insured", {})
# 归一化利益演示行
benefit_rows = []
for row in raw.get("benefit_illustration", []):
if not isinstance(row, dict):
continue
policy_year = _safe_number(row.get("policy_year"))
if policy_year <= 0:
continue
explicit_age = _nullable_number(row.get("age"))
age = explicit_age if explicit_age is not None else (
insured_age + policy_year - 1 if insured_age is not None else None
)
benefit_rows.append({
"policyYear": int(policy_year),
"age": int(age) if age is not None else None,
"totalPremiumPaid": _nullable_number(row.get("total_premium_paid")),
"guaranteedCashValue": _nullable_number(row.get("guaranteed_cash_value")),
"reversionaryBonus": _nullable_number(row.get("reversionary_bonus")),
"terminalDividend": _nullable_number(row.get("terminal_dividend")),
"totalSurrenderValue": _nullable_number(row.get("total_surrender_value")),
"deathBenefit": _nullable_number(row.get("death_benefit")),
"sourcePage": int(_safe_number(row.get("source_page"))) if row.get("source_page") else None,
})
benefit_rows.sort(key=lambda r: r["policyYear"])
# 归一化提领行
cumulative = 0.0
withdrawal_rows = []
for row in raw.get("withdrawal_illustration", []):
if not isinstance(row, dict):
continue
policy_year = _safe_number(row.get("policy_year"))
if policy_year <= 0:
continue
annual = _nullable_number(_first_value(row, "annual_withdrawal", "withdrawal_amount"))
total_withdrawn = _nullable_number(_first_value(row, "total_withdrawn", "cumulative_withdrawal"))
if total_withdrawn is not None:
cumulative = total_withdrawn
elif annual is not None:
cumulative += annual
explicit_age = _nullable_number(row.get("age"))
age = explicit_age if explicit_age is not None else (
insured_age + policy_year if insured_age is not None else None
)
withdrawal_rows.append({
"policyYear": int(policy_year),
"age": int(age) if age is not None else None,
"totalPremiumPaid": _nullable_number(row.get("total_premium_paid")),
"annualWithdrawal": annual,
"cumulativeWithdrawal": cumulative if total_withdrawn is not None or annual is not None else None,
"surrenderValueAfter": _nullable_number(_first_value(row, "surrender_value_after", "remaining_surrender_value")),
"guaranteedValueAfter": _nullable_number(row.get("guaranteed_value_after")),
"basicSumInsuredAfter": _nullable_number(row.get("basic_sum_insured_after")),
"sourcePage": int(_safe_number(row.get("source_page"))) if row.get("source_page") else None,
})
withdrawal_rows.sort(key=lambda r: r["policyYear"])
# 保单信息
policy = raw.get("policy", {})
annual_premium = _nullable_number(_first_value(policy, "annual_premium", "annualPremium"))
pay_years = _extract_years(_first_value(policy, "premium_payment_period", "payYears", "paymentPeriod"))
raw_product_name = _first_value(raw, "product_name", "productName") or _first_value(policy, "product_name", "productName") or ""
return {
"kind": "savings",
"productName": raw_product_name,
"rawProductName": raw_product_name,
"insured": {
"name": insured.get("name") or "客户",
"age": int(insured_age) if insured_age is not None else None,
"gender": _normalize_gender(insured.get("gender")),
"smoker": normalize_smoker(insured.get("smoker")),
},
"policy": {
"currency": _normalize_currency(policy.get("currency")),
"sumInsured": _nullable_number(_first_value(policy, "sum_insured", "sumInsured")),
"annualPremium": annual_premium,
"basicPlanAnnualPremium": _optional_number(_first_value(policy, "basic_plan_annual_premium", "basicPlanAnnualPremium")),
"basicSumInsured": _optional_number(_first_value(policy, "basic_sum_insured", "basicSumInsured")),
"firstYearAmountDue": _optional_number(_first_value(policy, "first_year_amount_due", "firstYearAmountDue")),
"annualPremiumWithLevy": policy.get("total_premium_with_levy"),
"payYears": pay_years,
"contractualTotalPremium": _optional_number(_first_value(policy, "total_premium", "contractualTotalPremium")),
"derivedTotalPremiumEstimate": annual_premium * pay_years if annual_premium is not None and pay_years > 0 else None,
"coveragePeriod": policy.get("coverage_period", ""),
},
"benefitRows": benefit_rows,
"withdrawalRows": withdrawal_rows,
"withdrawalProvenance": "official_extracted" if withdrawal_rows else "missing",
"source": {
"pdfHash": _sha256(pdf_path),
"pdfPath": pdf_path,
"parser": parser,
},
}
def normalize_ci_plan(raw: dict, pdf_path: str = None, parser: str = "llm-json") -> dict:
"""归一化重疾险提取数据。"""
insured = raw.get("insured", {})
policy = raw.get("policy", {})
insured_age = _nullable_number(insured.get("age"))
# 保障项目
coverage_items = []
for item in raw.get("coverage_items", []):
if not isinstance(item, dict):
continue
name = item.get("name") or item.get("label") or ""
coverage_items.append({
"name": name,
"amount": _nullable_number(item.get("amount")),
"description": item.get("description") or "",
"sourcePage": int(_safe_number(item.get("source_page"))) if item.get("source_page") else None,
})
# 利益演示行
benefit_rows = []
for row in raw.get("benefit_illustration", []):
if not isinstance(row, dict):
continue
policy_year = _safe_number(row.get("policy_year"))
if policy_year <= 0:
continue
death_benefit = _nullable_number(row.get("death_benefit"))
benefit_rows.append({
"policyYear": int(policy_year),
"totalPremiumPaid": _nullable_number(row.get("total_premium_paid")),
"deathBenefit": death_benefit,
"totalSurrenderValue": _nullable_number(row.get("total_surrender_value")),
"ciBenefit": _nullable_number(row.get("ci_benefit")),
"sourcePage": int(_safe_number(row.get("source_page"))) if row.get("source_page") else None,
})
benefit_rows.sort(key=lambda r: r["policyYear"])
annual_premium = _nullable_number(_first_value(policy, "annual_premium", "annualPremium"))
pay_years = _extract_years(_first_value(policy, "premium_payment_period", "payYears", "paymentPeriod"))
base_sum_insured = _safe_number(
raw.get("base_sum_insured")
or policy.get("basic_sum_insured")
or policy.get("sum_insured")
)
return {
"kind": "ci",
"productName": raw.get("product_name", ""),
"insured": {
"name": insured.get("name") or "客户",
"age": int(insured_age) if insured_age is not None else None,
"gender": _normalize_gender(insured.get("gender")),
"smoker": normalize_smoker(insured.get("smoker")),
},
"policy": {
"currency": _normalize_currency(policy.get("currency")),
"sumInsured": _nullable_number(_first_value(policy, "sum_insured", "sumInsured")),
"baseSumInsured": base_sum_insured,
"basicPlanAnnualPremium": _optional_number(policy.get("basic_plan_annual_premium")),
"basicSumInsured": _optional_number(policy.get("basic_sum_insured")),
"firstYearAmountDue": _optional_number(policy.get("first_year_amount_due")),
"upgradeBenefitAmount": _safe_number(raw.get("upgrade_benefit_amount")),
"upgradeBenefitYears": _safe_number(raw.get("upgrade_benefit_years")),
"annualPremium": annual_premium,
"annualPremiumWithLevy": policy.get("total_premium_with_levy"),
"payYears": pay_years,
"totalPremium": _optional_number(_first_value(policy, "total_premium", "contractualTotalPremium")),
"derivedTotalPremiumEstimate": annual_premium * pay_years if annual_premium is not None and pay_years > 0 else None,
"coveragePeriod": policy.get("coverage_period", ""),
},
"coverageSummary": {
"majorCiCount": int(_safe_number(raw.get("major_ci_count"))),
"earlyCiCount": int(_safe_number(raw.get("early_ci_count"))),
},
"coverageItems": coverage_items,
"icuBenefitRules": raw.get("icu_benefit_rules", []),
"multiClaimRules": raw.get("multi_claim", []),
"premiumWaiverRiders": raw.get("premium_waiver_riders", []),
"benefitRows": benefit_rows,
"source": {
"pdfHash": _sha256(pdf_path),
"pdfPath": pdf_path,
"parser": parser,
},
}
def normalize_iul_plan(raw: dict, pdf_path: str = None, parser: str = "llm-json") -> dict:
"""归一化 IUL 提取数据。"""
insured = raw.get("insured", {})
policy = raw.get("policy", {})
insured_age = _nullable_number(insured.get("age"))
# 指数账户
index_accounts = []
for acc in raw.get("index_accounts", []):
if not isinstance(acc, dict):
continue
allocation = acc.get("allocation", 0)
if isinstance(allocation, str):
try:
allocation = float(allocation)
except ValueError:
allocation = 0
index_accounts.append({
"name": acc.get("name", ""),
"allocation": allocation,
"assumedRate": acc.get("current_assumed_rate") or acc.get("current_rate") or "",
"floorRate": acc.get("guaranteed_floor_rate") or acc.get("guaranteed_floor") or "",
"capRate": acc.get("cap_rate") or "",
"participationRate": acc.get("participation_rate") or "",
})
# 利益演示行
benefit_rows = []
for row in raw.get("benefit_illustration", []):
if not isinstance(row, dict):
continue
policy_year = _safe_number(row.get("policy_year"))
if policy_year <= 0:
continue
explicit_age = _nullable_number(row.get("age"))
age = explicit_age if explicit_age is not None else (
insured_age + policy_year - 1 if insured_age is not None else None
)
non_guaranteed_account = _nullable_number(row.get("non_guaranteed_account_value"))
non_guaranteed_cash = _nullable_number(row.get("non_guaranteed_cash_value"))
total_surrender = _nullable_number(_first_value(row, "total_surrender_value", "totalSurrenderValue"))
non_guaranteed_death = _nullable_number(_first_value(row, "non_guaranteed_death_benefit", "death_benefit"))
benefit_rows.append({
"policyYear": int(policy_year),
"age": int(age) if age is not None else None,
"totalPremiumPaid": _nullable_number(row.get("total_premium_paid")),
"guaranteedAccountValue": _nullable_number(row.get("guaranteed_account_value")),
"guaranteedCashValue": _nullable_number(row.get("guaranteed_cash_value")),
"nonGuaranteedAccountValue": non_guaranteed_account,
"nonGuaranteedCashValue": non_guaranteed_cash,
"totalSurrenderValue": total_surrender,
"guaranteedDeathBenefit": _nullable_number(row.get("guaranteed_death_benefit")),
"nonGuaranteedDeathBenefit": non_guaranteed_death,
"costOfInsurance": _nullable_number(row.get("cost_of_insurance")),
"sourcePage": int(_safe_number(row.get("source_page"))) if row.get("source_page") else None,
})
benefit_rows.sort(key=lambda r: r["policyYear"])
annual_premium = _nullable_number(_first_value(policy, "annual_premium", "annualPremium"))
payment_period = _first_value(policy, "premium_payment_period", "payYears", "paymentPeriod") or ""
pay_years = _extract_years(payment_period)
return {
"kind": "iul",
"productName": _first_value(raw, "product_name", "productName") or "",
"insured": {
"name": insured.get("name") or "客户",
"age": int(insured_age) if insured_age is not None else None,
"gender": _normalize_gender(insured.get("gender")),
"smoker": normalize_smoker(insured.get("smoker")),
},
"policy": {
"currency": _normalize_currency(policy.get("currency")),
"sumInsured": _nullable_number(_first_value(policy, "sum_insured", "sumInsured")),
"initialPremium": _nullable_number(policy.get("initial_premium")),
"annualPremium": annual_premium,
"targetPremium": _optional_number(policy.get("target_premium")),
"minimumPremium": _optional_number(policy.get("minimum_premium")),
"basicPlanAnnualPremium": _optional_number(_first_value(policy, "basic_plan_annual_premium", "basicPlanAnnualPremium")),
"basicSumInsured": _optional_number(_first_value(policy, "basic_sum_insured", "basicSumInsured")),
"firstYearAmountDue": _optional_number(_first_value(policy, "first_year_amount_due", "firstYearAmountDue")),
"payYears": pay_years,
"totalPremium": _optional_number(_first_value(policy, "total_premium", "contractualTotalPremium")),
"derivedTotalPremiumEstimate": annual_premium * pay_years if annual_premium is not None and pay_years > 0 else None,
"paymentPeriod": str(payment_period),
"coveragePeriod": policy.get("coverage_period", ""),
},
"indexAccounts": index_accounts,
"benefitRows": benefit_rows,
"source": {
"pdfHash": _sha256(pdf_path),
"pdfPath": pdf_path,
"parser": parser,
},
}
def map_savings_metrics(data: dict) -> dict:
"""提取储蓄险关键指标。"""
insured = data.get("insured", {})
policy = data.get("policy", {})
benefit_rows = data.get("benefit_illustration", [])
if not isinstance(benefit_rows, list):
benefit_rows = []
annual_premium = _nullable_number(policy.get("annual_premium"))
pay_years = _extract_years(policy.get("premium_payment_period"))
total_premium = _nullable_number(_first_value(policy, "total_premium", "contractualTotalPremium"))
# 回本年度
breakeven_year = None
for row in benefit_rows:
if not isinstance(row, dict):
continue
tp = _safe_number(row.get("total_premium_paid"))
sv = _safe_number(row.get("total_surrender_value"))
if tp > 0 and sv >= tp:
breakeven_year = int(_safe_number(row.get("policy_year")))
break
# 20年/30年倍数
multiple_20 = None
multiple_30 = None
for row in benefit_rows:
if not isinstance(row, dict):
continue
py = int(_safe_number(row.get("policy_year")))
tp = _safe_number(row.get("total_premium_paid"))
sv = _safe_number(row.get("total_surrender_value"))
if py == 20 and tp > 0:
multiple_20 = round(sv / tp, 2)
if py == 30 and tp > 0:
multiple_30 = round(sv / tp, 2)
# 退保开始年度
withdraw_start_year = None
withdraw_start_age = None
for row in data.get("withdrawal_illustration", []):
if not isinstance(row, dict):
continue
if _safe_number(row.get("annual_withdrawal")) > 0:
withdraw_start_year = int(_safe_number(row.get("policy_year")))
withdraw_start_age = int(_safe_number(row.get("age")))
break
return {
"insuredName": insured.get("name") or "",
"insuredAge": int(_nullable_number(insured.get("age"))) if _nullable_number(insured.get("age")) is not None else None,
"insuredGender": insured.get("gender") or "",
"productName": data.get("product_name") or policy.get("product_name") or "",
"currency": policy.get("currency") or "",
"annualPremium": annual_premium,
"payYears": pay_years,
"totalPremium": total_premium,
"derivedTotalPremiumEstimate": annual_premium * pay_years if annual_premium is not None and pay_years > 0 else None,
"breakevenYear": breakeven_year,
"multiple20": multiple_20,
"multiple30": multiple_30,
"withdrawStartYear": withdraw_start_year,
"withdrawStartAge": withdraw_start_age,
}