修复 PPT 异步任务无法生成的问题,包括任务变量引用错误、失败状态回写、心跳缺失任务恢复。 脱敏改为保司/产品后台统一配置,生成端不再让用户选择;任务创建时保存策略快照。 保司支持独立控制 PPT、海报 Logo 显示。 PPT 核验新增吸烟状态、币种及三个条件字段。 利益演示、退保提取调整为警告,不再阻止生成。 PPT 生成完成后可以直接返回数据核验页修改。 建立不同险种、单图/长图共六套海报字段画像。 PPT“生成场景”支持后台新增、启停和删除。 保司、产品、PPT 模板、文案模板均支持安全删除。 内置模板禁止删除,只允许停用;存在关联数据时拒绝危险删除。 补充策略变更及删除审计日志。 更新 API 文档、部署文档及修复计划实施记录。 关键交付文件: [数据库迁移 migrate_027.py](D:/work/code/python/coding/baodanagent/api/insurance/db/migrate_027.py) [海报字段画像 field_profiles.py](D:/work/code/python/coding/baodanagent/api/insurance/poster/field_profiles.py) [动态场景服务 scenarios.py](D:/work/code/python/coding/baodanagent/api/insurance/ppt/scenarios.py) [新增回归测试](D:/work/code/python/coding/baodanagent/tests/ppt_poster_optimization_test.py) [优化修复计划书](D:/work/code/python/coding/baodanagent/docs/保险智能客服系统_PPT与海报优化修复计划书_20260731.md) 验证结果: 核心链路测试:37 passed,1 skipped 扩展回归测试:140 passed PPT 渲染器测试:6 passed 前端生产构建:通过 Python 编译检查:通过 完整测试集:190 passed,1 failed 唯一失败为 tests/test_chat_save.py::test_chat_logs_query 未建立 Flask application context,与本次 PPT/海报链路无关。
267 lines
12 KiB
Python
267 lines
12 KiB
Python
"""验证模块 — 数据完整性和导出就绪检查。"""
|
||
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", "利益演示缺少来源页码")
|
||
|
||
# 缴费年期一致性检查
|
||
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} 年")
|
||
|
||
# 年龄合理性检查
|
||
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
|
||
|
||
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
|