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

390 lines
16 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.

"""PlanData 兼容门禁。
Phase 0 暂时继续使用 ``PosterCaseUpload``,但确认和生成必须经过同一组
失败关闭规则。后续不可变快照落地后,这里的纯函数可直接复用。
"""
from __future__ import annotations
import hashlib
import json
import math
import os
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
ALLOWED_CURRENCIES = {"USD", "HKD", "CNY", "SGD", "EUR", "GBP"}
ALLOWED_GENDERS = {"male", "female", "", ""}
CONFIRMABLE_PARSE_STATUSES = {"parsed", "partial"}
@dataclass(frozen=True)
class GateIssue:
code: str
message: str
path: str = ""
def to_dict(self) -> dict:
return {"code": self.code, "message": self.message, "path": self.path}
@dataclass(frozen=True)
class GateResult:
valid: bool
plan_data: dict
parse_snapshot_hash: str
issues: tuple[GateIssue, ...]
override_reason: str = ""
def error_data(self) -> dict:
return {
"errorCode": self.issues[0].code if self.issues else "PLAN_DATA_INVALID",
"issues": [issue.to_dict() for issue in self.issues],
}
def canonical_json_hash(value: Any) -> str:
"""以固定 JSON 表达计算 SHA-256保留 ``None`` 与数值 ``0`` 的差异。"""
payload = json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def parse_snapshot_hash(record) -> str:
parsed = _json_object(getattr(record, "parsed_data", None))
return canonical_json_hash(parsed) if parsed else ""
def validate_case_confirmation(record, request_data: dict, product_snapshot: dict | None = None) -> GateResult:
"""校验人工确认请求,并返回可持久化的确认数据。"""
issues: list[GateIssue] = []
plan_data = request_data.get("confirmedData")
plan_data = plan_data if isinstance(plan_data, dict) else {}
override_reason = str(request_data.get("overrideReason") or "").strip()
current_parse_hash = parse_snapshot_hash(record)
if getattr(record, "parse_status", None) not in CONFIRMABLE_PARSE_STATUSES:
issues.append(GateIssue("PARSE_STATUS_INVALID", "当前解析状态不可确认", "parseStatus"))
if not plan_data:
issues.append(GateIssue("PLAN_DATA_EMPTY", "确认数据不能为空", "confirmedData"))
expected_file_hash = str(getattr(record, "file_hash", None) or "")
submitted_file_hash = str(request_data.get("fileHash") or "")
if not expected_file_hash or submitted_file_hash != expected_file_hash:
issues.append(GateIssue("SOURCE_HASH_MISMATCH", "源文件版本已变化,请重新加载解析结果", "fileHash"))
elif not _source_file_matches(record, expected_file_hash):
issues.append(GateIssue("SOURCE_FILE_CHANGED", "源文件校验失败,请重新上传计划书", "fileHash"))
submitted_parse_hash = str(request_data.get("parseSnapshotHash") or "")
if not current_parse_hash or submitted_parse_hash != current_parse_hash:
issues.append(GateIssue("PARSE_SNAPSHOT_CHANGED", "解析结果已变化,请重新核对", "parseSnapshotHash"))
if plan_data:
issues.extend(_validate_business_fields(plan_data, product_snapshot or {}, override_reason))
if issues:
return GateResult(False, plan_data, current_parse_hash, tuple(issues), override_reason)
clean_data = dict(plan_data)
clean_data.pop("_confirmation", None)
clean_data["_confirmation"] = {
"schemaVersion": "poster-confirmation-v1",
"fileHash": expected_file_hash,
"parseSnapshotHash": current_parse_hash,
"payloadHash": canonical_json_hash(clean_data),
"confirmedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
"overrideReason": override_reason or None,
}
return GateResult(True, clean_data, current_parse_hash, (), override_reason)
def validate_confirmed_case(record, product_snapshot: dict | None = None) -> GateResult:
"""生成前重新校验已确认数据,防止旧确认或篡改数据进入成品。"""
confirmed = _json_object(getattr(record, "confirmed_data", None))
confirmation = confirmed.get("_confirmation") if confirmed else None
plan_data = dict(confirmed) if confirmed else {}
plan_data.pop("_confirmation", None)
issues: list[GateIssue] = []
current_parse_hash = parse_snapshot_hash(record)
if not plan_data or not isinstance(confirmation, dict):
issues.append(GateIssue("DATA_NOT_CONFIRMED", "请先完成计划书数据确认", "confirmedData"))
else:
if confirmation.get("fileHash") != getattr(record, "file_hash", None):
issues.append(GateIssue("SOURCE_HASH_MISMATCH", "源文件与确认版本不一致,请重新确认", "fileHash"))
if confirmation.get("parseSnapshotHash") != current_parse_hash:
issues.append(GateIssue("PARSE_SNAPSHOT_CHANGED", "解析结果已更新,请重新确认", "parseSnapshotHash"))
if confirmation.get("payloadHash") != canonical_json_hash(plan_data):
issues.append(GateIssue("CONFIRMED_DATA_CHANGED", "已确认数据发生变化,请重新确认", "confirmedData"))
if not _source_file_matches(record, str(getattr(record, "file_hash", None) or "")):
issues.append(GateIssue("SOURCE_FILE_CHANGED", "源文件校验失败,请重新上传计划书", "fileHash"))
issues.extend(_validate_business_fields(
plan_data,
product_snapshot or {},
str(confirmation.get("overrideReason") or ""),
))
if issues:
return GateResult(False, plan_data, current_parse_hash, tuple(issues))
return GateResult(True, plan_data, current_parse_hash, ())
def _validate_business_fields(plan_data: dict, product_snapshot: dict, override_reason: str) -> list[GateIssue]:
issues: list[GateIssue] = []
plan_type = str(
plan_data.get("plan_type")
or (plan_data.get("meta") or {}).get("planType")
or product_snapshot.get("planType")
or "other"
).lower()
required = {
"savings": ("age", "gender", "currency", "annual_premium", "premium_term"),
"ci": ("age", "gender", "currency", "sum_assured"),
"iul": ("age", "gender", "currency", "sum_assured", "annual_premium"),
}.get(plan_type, ("age", "gender", "currency"))
for field in required:
if _is_missing(plan_data.get(field)):
issues.append(GateIssue("REQUIRED_FIELD_MISSING", "必填字段缺失", field))
age = _nullable_number(plan_data.get("age"))
if not _is_missing(plan_data.get("age")) and (age is None or not 0 < age <= 120):
issues.append(GateIssue("AGE_INVALID", "年龄必须是 1 至 120 的有效数字", "age"))
if not _is_missing(plan_data.get("gender")) and str(plan_data.get("gender")).lower() not in ALLOWED_GENDERS:
issues.append(GateIssue("GENDER_INVALID", "性别必须为男或女", "gender"))
currency = str(plan_data.get("currency") or "").upper()
if currency and currency not in ALLOWED_CURRENCIES:
issues.append(GateIssue("CURRENCY_INVALID", "币种不受支持或无法识别", "currency"))
positive_fields = ("annual_premium", "premium_term", "sum_assured")
for field in positive_fields:
value = plan_data.get(field)
if _is_missing(value):
continue
number = _nullable_number(value)
if number is None or number <= 0:
issues.append(GateIssue("NUMBER_INVALID", "必须填写大于 0 的有效数字", field))
for field in ("total_premium", "initial_death_benefit", "surrender_value_10", "surrender_value_20", "surrender_value_30"):
value = plan_data.get(field)
if _is_missing(value):
continue
number = _nullable_number(value)
if number is None or number < 0:
issues.append(GateIssue("AMOUNT_INVALID", "金额必须是大于或等于 0 的有效数字", field))
issues.extend(_unresolved_conflicts(plan_data))
issues.extend(_validate_benefit_table(plan_data))
issues.extend(_validate_product_match(plan_data, product_snapshot, override_reason))
issues.extend(_validate_manual_overrides(plan_data, override_reason))
issues.extend(_validate_critical_evidence(plan_data, plan_type, override_reason))
return issues
def _unresolved_conflicts(plan_data: dict) -> list[GateIssue]:
conflicts = (plan_data.get("meta") or {}).get("conflicts") or []
issues: list[GateIssue] = []
if isinstance(conflicts, dict):
conflicts = [{"field": key, **(value if isinstance(value, dict) else {})} for key, value in conflicts.items()]
if isinstance(conflicts, list):
for conflict in conflicts:
if not isinstance(conflict, dict) or str(conflict.get("status") or "unresolved") != "resolved":
path = conflict.get("field") if isinstance(conflict, dict) else "meta.conflicts"
issues.append(GateIssue("UNRESOLVED_CONFLICT", "存在未解决的数据冲突", str(path or "meta.conflicts")))
for index, row in enumerate(plan_data.get("benefit_table") or []):
if not isinstance(row, dict):
continue
conflict = row.get("conflict")
if isinstance(conflict, dict) and str(conflict.get("status") or "unresolved") != "resolved":
issues.append(GateIssue(
"UNRESOLVED_CONFLICT",
"利益表存在未解决的数据冲突",
f"benefit_table.{index}.conflict",
))
year = _nullable_number(_first_present(row, "policy_year", "policyYear", "year"))
if year is None:
continue
explicit = _nullable_number(plan_data.get(f"surrender_value_{int(year)}"))
table_value = _nullable_number(_first_present(
row,
"total_surrender_value",
"totalSurrenderValue",
"total_surrender",
"totalSurrender",
))
if explicit is not None and table_value is not None and explicit != table_value:
issues.append(GateIssue(
"UNRESOLVED_CONFLICT",
"里程碑金额与完整利益表不一致",
f"surrender_value_{int(year)}",
))
return issues
def _validate_benefit_table(plan_data: dict) -> list[GateIssue]:
rows = plan_data.get("benefit_table")
if rows is None:
return []
if not isinstance(rows, list):
return [GateIssue("BENEFIT_TABLE_INVALID", "利益表格式无效", "benefit_table")]
issues: list[GateIssue] = []
amount_fields = (
("total_premium_paid", "totalPremiumPaid"),
("guaranteed_cash_value", "guaranteedCashValue"),
("non_guaranteed_cash_value", "nonGuaranteedCashValue"),
("total_surrender_value", "totalSurrenderValue", "total_surrender", "totalSurrender"),
("death_benefit", "deathBenefit"),
)
for index, row in enumerate(rows):
if not isinstance(row, dict):
issues.append(GateIssue("BENEFIT_ROW_INVALID", "利益表行格式无效", f"benefit_table.{index}"))
continue
year = _nullable_number(_first_present(row, "policy_year", "policyYear", "year"))
if year is None or year <= 0 or not year.is_integer():
issues.append(GateIssue("BENEFIT_YEAR_INVALID", "保单年度必须为正整数", f"benefit_table.{index}.policy_year"))
for aliases in amount_fields:
field = aliases[0]
value = _first_present(row, *aliases)
if _is_missing(value):
continue
number = _nullable_number(value)
if number is None or number < 0:
issues.append(GateIssue(
"BENEFIT_AMOUNT_INVALID",
"利益表金额必须是大于或等于 0 的有效数字",
f"benefit_table.{index}.{field}",
))
return issues
def _validate_product_match(plan_data: dict, product_snapshot: dict, override_reason: str) -> list[GateIssue]:
extracted = _normalize_name(plan_data.get("product_name"))
selected = _normalize_name(
product_snapshot.get("productName")
or (product_snapshot.get("productData") or {}).get("displayName")
)
if extracted and selected and extracted != selected and not override_reason:
return [GateIssue(
"PRODUCT_MISMATCH",
"PDF 提取产品与所选产品不一致,必须填写人工覆盖原因",
"product_name",
)]
return []
def _validate_manual_overrides(plan_data: dict, override_reason: str) -> list[GateIssue]:
provenance = (plan_data.get("meta") or {}).get("provenance") or {}
if not isinstance(provenance, dict):
return []
has_override = any(
isinstance(value, dict) and value.get("source") == "manual_override"
for value in provenance.values()
)
if has_override and not override_reason:
return [GateIssue("OVERRIDE_REASON_REQUIRED", "人工修改数据时必须填写修改原因", "overrideReason")]
return []
def _validate_critical_evidence(plan_data: dict, plan_type: str, override_reason: str) -> list[GateIssue]:
fields = ["annual_premium"] if plan_type == "savings" else ["sum_assured"]
if plan_type == "iul":
fields.append("annual_premium")
fields.extend(
field for field in ("surrender_value_10", "surrender_value_20", "surrender_value_30")
if not _is_missing(plan_data.get(field))
)
issues = []
for field in fields:
if _is_missing(plan_data.get(field)):
continue
if _has_evidence(plan_data, field) or override_reason:
continue
issues.append(GateIssue(
"CRITICAL_EVIDENCE_MISSING",
"关键金额缺少来源证据或人工覆盖原因",
field,
))
return issues
def _has_evidence(plan_data: dict, field: str) -> bool:
provenance = (plan_data.get("meta") or {}).get("provenance") or {}
aliases = {field, f"policy.{field}", field.replace("_", ".")}
for key in aliases:
value = provenance.get(key) if isinstance(provenance, dict) else None
if isinstance(value, dict) and value.get("source") not in (None, "", "system_derived"):
return True
match = re.fullmatch(r"surrender_value_(\d+)", field)
if not match:
return False
target_year = int(match.group(1))
for row in plan_data.get("benefit_table") or []:
if not isinstance(row, dict):
continue
year = _nullable_number(row.get("policy_year", row.get("policyYear", row.get("year"))))
if year == target_year and row.get("source_page", row.get("sourcePage")):
return True
return False
def _source_file_matches(record, expected_hash: str) -> bool:
path = getattr(record, "source_file_url", None)
if not path or not expected_hash or not os.path.isfile(path):
return False
digest = hashlib.sha256()
try:
with open(path, "rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
except OSError:
return False
return digest.hexdigest() == expected_hash
def _json_object(value: Any) -> dict:
if isinstance(value, dict):
return value
if not value:
return {}
try:
parsed = json.loads(value)
except (TypeError, ValueError):
return {}
return parsed if isinstance(parsed, dict) else {}
def _nullable_number(value: Any) -> float | None:
if value is None or isinstance(value, bool) or (isinstance(value, str) and not value.strip()):
return None
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if math.isfinite(number) else None
def _is_missing(value: Any) -> bool:
return value is None or (isinstance(value, str) and not value.strip())
def _first_present(mapping: dict, *keys):
for key in keys:
value = mapping.get(key)
if not _is_missing(value):
return value
return None
def _normalize_name(value: Any) -> str:
return re.sub(r"[^0-9a-z\u4e00-\u9fff]+", "", str(value or "").lower())