baodan/api/insurance/ppt/validator.py
wsb1224 d57bd59acd 主要改动:
PPT 表格现在按槽位解析,正确保留 —、空列和单空格表格,不再发生金额左移。[regex_extractor.py (line 470)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:470)
补齐年龄、累计保费、非保证现金价值、非保证身故赔偿等别名。
IUL 使用专属 LLM 提取结构,并加强年度、年龄、账户价值完整性校验。[extraction.py (line 741)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:741)
PPT 缓存升级到 v6,旧错误缓存会自动失效。
提领方案不再与普通退保价值混淆,相关提示语已统一。
海报摘要年度金额与收益图表改为同一数据源;缺少部分里程碑时会从原始真实年度补足,不会隐藏图表。[content_builder.py (line 51)](D:/work/code/python/coding/baodanagent/api/insurance/poster/content_builder.py:51)
系统计算、利益表派生、人工修改增加来源标记。
增加“重新解析”功能,旧海报计划书无需重新上传。[routes.py (line 266)](D:/work/code/python/coding/baodanagent/api/insurance/poster/routes.py:266)
ECharts 导出增加双帧就绪检测和事件竞态保护,不再依赖不存在的 .once()。
PPT 手机端改为可编辑数据卡片,320px 操作栏自动纵向排列;补齐按钮语义、键盘焦点及 44px 触控区域。[PptDataReview.vue (line 195)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:195)
验证结果:
核心专项测试:145 passed
除既有聊天日志测试外的测试集:236 passed
前端生产构建:通过
Python 语法检查:通过
git diff --check:通过
Impeccable 前端检测:无发现
全量测试仅剩一个与本次无关的既有失败:test_chat_logs_query 缺少 Flask application context
2026-08-01 01:22:07 +08:00

290 lines
13 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 dataclasses import dataclass, field
from typing import Optional
@dataclass
class FormalDeckIssue:
code: str
level: str # "error" | "warn"
message: str
path: str = "" # 字段路径,如 "insured.age"、"benefitRows[5].totalSurrenderValue"
section: str = "" # 分区fields、benefitRows、withdrawalRows、coverageItems、indexAccounts
suggested_action: str = "" # fill_or_confirm | modify | review | none
@dataclass
class ValidationIssue:
field: str
message: str
level: str # "error" | "warn"
def _is_continuous(rows: list[dict]) -> bool:
"""检查保单年度是否连续。"""
years = sorted(r.get("policyYear", 0) for r in rows if isinstance(r, dict))
for i in range(1, len(years)):
if years[i] != years[i - 1] + 1:
return False
return True
def _has_sufficient_milestone_coverage(rows: list[dict]) -> bool:
"""判断非连续利益表是否覆盖正式方案所需的关键年度。"""
years = {
int(r.get("policyYear", 0))
for r in rows
if isinstance(r, dict) and _safe_number(r.get("policyYear")) > 0
}
return len(years) >= 5 and {10, 20, 30}.issubset(years) and max(years) >= 30
def validate_formal_savings_plan(plan: dict) -> list[FormalDeckIssue]:
"""验证归一化储蓄险数据的导出就绪性。"""
issues = []
def err(code, msg, path="", section="fields", suggested_action="review"):
issues.append(FormalDeckIssue(code, "error", msg, path=path, section=section, suggested_action=suggested_action))
def warn(code, msg, path="", section="fields", suggested_action="review"):
issues.append(FormalDeckIssue(code, "warn", msg, path=path, section=section, suggested_action=suggested_action))
if not plan.get("productName"):
err("PRODUCT_NAME_MISSING", "产品名称缺失", path="productName", suggested_action="fill_or_confirm")
insured = plan.get("insured", {})
if not insured.get("age"):
err("INSURED_AGE_MISSING", "被保险人年龄缺失", path="insured.age", suggested_action="fill_or_confirm")
if insured.get("smoker") not in ("yes", "no"):
warn("SMOKER_STATUS_UNKNOWN", "吸烟状态未知,请核对计划书", path="insured.smoker")
policy = plan.get("policy", {})
if not policy.get("currency"):
err("CURRENCY_MISSING", "币种缺失", path="policy.currency", suggested_action="fill_or_confirm")
if _safe_number(policy.get("annualPremium")) <= 0:
err("ANNUAL_PREMIUM_INVALID", "年缴保费必须大于 0", path="policy.annualPremium", suggested_action="fill_or_confirm")
if _safe_number(policy.get("payYears")) <= 0:
err("PAY_YEARS_INVALID", "缴费年期必须大于 0", path="policy.payYears", suggested_action="fill_or_confirm")
benefit_rows = plan.get("benefitRows", [])
if len(benefit_rows) < 20:
if _has_sufficient_milestone_coverage(benefit_rows):
warn(
"BENEFIT_ROWS_MILESTONE_ONLY",
f"利益演示仅提供里程碑年度(当前 {len(benefit_rows)} 行),已覆盖 10、20、30 年,可继续生成",
path="benefitRows", section="benefitRows",
)
else:
warn(
"BENEFIT_ROWS_INCOMPLETE",
f"利益演示数据不足(当前 {len(benefit_rows)} 行,且未覆盖 10、20、30 年)",
path="benefitRows", section="benefitRows",
)
if benefit_rows and not _is_continuous(benefit_rows):
warn(
"BENEFIT_ROWS_DISCONTINUOUS",
"利益演示采用里程碑年度,保单年度不连续,请在生成前核对关键年份",
path="benefitRows", section="benefitRows",
)
source = plan.get("source", {})
if not source.get("pdfHash"):
err("SOURCE_HASH_MISSING", "缺少 PDF 哈希(来源追溯)", path="source.pdfHash")
if not any(r.get("sourcePage") for r in benefit_rows):
warn("BENEFIT_SOURCE_PAGE_MISSING", "利益演示缺少来源页码",
path="benefitRows", section="benefitRows")
withdrawal_rows = plan.get("withdrawalRows", [])
if withdrawal_rows and not _is_continuous(withdrawal_rows):
warn("WITHDRAWAL_ROWS_DISCONTINUOUS", "提领方案保单年度不连续",
path="withdrawalRows", section="withdrawalRows")
if withdrawal_rows and not any(r.get("sourcePage") for r in withdrawal_rows):
warn("WITHDRAWAL_SOURCE_PAGE_MISSING", "提领方案缺少来源页码",
path="withdrawalRows", section="withdrawalRows")
if not withdrawal_rows:
warn("WITHDRAWAL_ROWS_MISSING", "计划书未包含提领/提款方案(正式 PPT 将隐藏提领页面)",
path="withdrawalRows", section="withdrawalRows")
# 检查 total_surrender_value >= guaranteed_cash_value 一致性
for i, row in enumerate(benefit_rows):
gcv = _safe_number(row.get("guaranteedCashValue"))
rev = _safe_number(row.get("reversionaryBonus"))
term = _safe_number(row.get("terminalDividend"))
total = _safe_number(row.get("totalSurrenderValue"))
if total > 0 and gcv > 0 and total < gcv:
year = row.get("policyYear", "?")
expected = gcv + rev + term
warn(
"TOTAL_SURRENDER_VALUE_INCONSISTENT",
f"{year} 年总退保价值({total})低于保证现金价值({gcv})"
f"应为 {expected},请核对数据是否列错位",
path=f"benefitRows[{i}].totalSurrenderValue", section="benefitRows",
)
return issues
def validate_formal_ci_plan(plan: dict) -> list[FormalDeckIssue]:
"""验证归一化重疾险数据。"""
issues = []
def err(code, msg):
issues.append(FormalDeckIssue(code, "error", msg))
def warn(code, msg):
issues.append(FormalDeckIssue(code, "warn", msg))
if not plan.get("productName"):
err("CI_PRODUCT_NAME_MISSING", "产品名称缺失")
if not plan.get("insured", {}).get("age"):
err("CI_INSURED_AGE_MISSING", "被保险人年龄缺失")
if plan.get("insured", {}).get("smoker") not in ("yes", "no"):
warn("CI_SMOKER_STATUS_UNKNOWN", "吸烟状态未知,请核对计划书")
if not plan.get("policy", {}).get("currency"):
err("CI_CURRENCY_MISSING", "币种缺失")
if _safe_number(plan.get("policy", {}).get("sumInsured")) <= 0:
err("CI_SUM_INSURED_INVALID", "保额必须大于 0")
if _safe_number(plan.get("policy", {}).get("annualPremium")) <= 0:
err("CI_ANNUAL_PREMIUM_INVALID", "年缴保费必须大于 0")
if _safe_number(plan.get("policy", {}).get("payYears")) <= 0:
err("CI_PAY_YEARS_INVALID", "缴费年期必须大于 0")
if not plan.get("coverageItems"):
warn("CI_COVERAGE_ITEMS_MISSING", "保障项目列表为空,相关页面将隐藏")
return issues
def validate_formal_iul_plan(plan: dict) -> list[FormalDeckIssue]:
"""验证归一化 IUL 数据。"""
issues = []
def err(code, msg):
issues.append(FormalDeckIssue(code, "error", msg))
def warn(code, msg):
issues.append(FormalDeckIssue(code, "warn", msg))
if not plan.get("productName"):
err("IUL_PRODUCT_NAME_MISSING", "产品名称缺失")
if not plan.get("insured", {}).get("age"):
err("IUL_INSURED_AGE_MISSING", "被保险人年龄缺失")
if plan.get("insured", {}).get("smoker") not in ("yes", "no"):
warn("IUL_SMOKER_STATUS_UNKNOWN", "吸烟状态未知,请核对计划书")
policy = plan.get("policy", {})
if not policy.get("currency"):
err("IUL_CURRENCY_MISSING", "币种缺失")
if _safe_number(plan.get("policy", {}).get("sumInsured")) <= 0:
err("IUL_SUM_INSURED_INVALID", "保额必须大于 0")
if max(
_safe_number(policy.get("targetPremium")),
_safe_number(policy.get("annualPremium")),
_safe_number(policy.get("initialPremium")),
) <= 0:
err("IUL_PREMIUM_INVALID", "目标保费、计划保费或首期保费至少填写一项")
if not plan.get("indexAccounts"):
warn("IUL_INDEX_ACCOUNT_MISSING", "指数账户配置为空,相关页面将隐藏")
benefit_rows = plan.get("benefitRows", [])
if len(benefit_rows) < 20:
warn("IUL_BENEFIT_ROWS_INCOMPLETE", f"利益演示行数不足(当前 {len(benefit_rows)} 行),可继续生成")
is_continuous = _is_continuous(benefit_rows) if benefit_rows else True
if benefit_rows and not is_continuous:
warn("IUL_BENEFIT_ROWS_DISCONTINUOUS", "利益演示保单年度不连续,请确认 PDF 是否只提供里程碑年度")
source = plan.get("source", {})
if not source.get("pdfHash"):
err("IUL_SOURCE_HASH_MISSING", "缺少 PDF 哈希")
if benefit_rows and not any(r.get("sourcePage") for r in benefit_rows):
warn("IUL_SOURCE_PAGE_MISSING", "利益演示缺少来源页码")
annual_premium = _safe_number(policy.get("annualPremium"))
positive_surrender = [
_safe_number(row.get("totalSurrenderValue"))
for row in benefit_rows
if _safe_number(row.get("totalSurrenderValue")) > 0
]
if annual_premium > 0 and len(positive_surrender) >= 3:
tiny_values = [value for value in positive_surrender if value < annual_premium * 0.01]
if len(tiny_values) / len(positive_surrender) >= 0.6:
err(
"IUL_BENEFIT_VALUE_IMPLAUSIBLE",
"多数退保价值不足年缴保费的 1%,疑似把年龄、页码或百分比识别为金额,请核对利益表",
)
# 缴费年期一致性检查
payment_period = plan.get("policy", {}).get("paymentPeriod", "")
if benefit_rows and payment_period and is_continuous:
# 通过数据检测实际缴费年数
detected_years = 0
for i in range(1, len(benefit_rows)):
prev = _safe_number(benefit_rows[i - 1].get("totalPremiumPaid"))
curr = _safe_number(benefit_rows[i].get("totalPremiumPaid"))
if curr > prev:
detected_years += 1
# 解析声明的缴费年期
stated_years = _extract_years(payment_period)
if stated_years > 0 and detected_years > 0 and abs(detected_years - stated_years) > 1:
err("IUL_PAY_TERM_MISMATCH", f"缴费年期不一致:声明 {stated_years} 年,数据检测 {detected_years}")
# 年龄合理性检查
insured_age = _safe_number(plan.get("insured", {}).get("age"))
for row in benefit_rows:
if isinstance(row, dict):
age = _safe_number(row.get("age"))
if age > 0 and (age < 0 or age > 150):
err("IUL_AGE_OUT_OF_RANGE", f"年龄超出合理范围: {age}")
break
policy_year = _safe_number(row.get("policyYear"))
expected_age = insured_age + policy_year - 1
if age > 0 and insured_age > 0 and policy_year > 0 and abs(age - expected_age) > 1:
err(
"IUL_AGE_YEAR_MISMATCH",
f"{int(policy_year)} 保单年度年龄应约为 {int(expected_age)},实际为 {int(age)}",
)
break
return issues
def validate_savings_metrics(metrics: dict) -> list[ValidationIssue]:
"""验证储蓄险关键指标。"""
issues = []
if not metrics.get("productName"):
issues.append(ValidationIssue("productName", "产品名称缺失", "error"))
if _safe_number(metrics.get("insuredAge")) <= 0:
issues.append(ValidationIssue("insuredAge", "被保险人年龄无效", "error"))
if not metrics.get("currency"):
issues.append(ValidationIssue("currency", "货币缺失", "error"))
if _safe_number(metrics.get("annualPremium")) <= 0:
issues.append(ValidationIssue("annualPremium", "年缴保费无效", "error"))
if _safe_number(metrics.get("payYears")) <= 0:
issues.append(ValidationIssue("payYears", "缴费年期无效", "error"))
if metrics.get("multiple20") is None:
issues.append(ValidationIssue("multiple20", "20年倍数缺失", "warn"))
if metrics.get("multiple30") is None:
issues.append(ValidationIssue("multiple30", "30年倍数缺失", "warn"))
if metrics.get("breakevenYear") is None:
issues.append(ValidationIssue("breakevenYear", "回本年度缺失", "warn"))
return issues
def _safe_number(value) -> float:
if value is None:
return 0
try:
return float(value)
except (ValueError, TypeError):
return 0
def _extract_years(value) -> int:
import re
if value is None:
return 0
match = re.search(r"\d+(?:\.\d+)?", str(value))
return int(float(match.group())) if match else 0