baodan/tests/plan_data_validation_test.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

218 lines
7.5 KiB
Python

import hashlib
import json
from pathlib import Path
from types import SimpleNamespace
def _record(tmp_path: Path, *, status="parsed", parsed=None):
source = tmp_path / "plan.pdf"
source.write_bytes(b"approved-test-pdf")
digest = hashlib.sha256(source.read_bytes()).hexdigest()
parsed = parsed or {"meta": {"status": status}}
return SimpleNamespace(
parse_status=status,
parsed_data=json.dumps(parsed, ensure_ascii=False),
confirmed_data=None,
file_hash=digest,
source_file_url=str(source),
)
def _valid_data():
return {
"plan_type": "savings",
"product_name": "测试储蓄计划",
"age": 35,
"gender": "",
"currency": "HKD",
"annual_premium": 0,
"premium_term": 5,
"surrender_value_10": 0,
"benefit_table": [{"policy_year": 10, "total_surrender_value": 0, "source_page": 8}],
"meta": {"provenance": {"annual_premium": {"source": "pdf_table", "page": 2}}},
}
def test_confirmation_rejects_empty_data_and_wrong_status(tmp_path):
from insurance.plan_data.validators import validate_case_confirmation
record = _record(tmp_path, status="failed")
result = validate_case_confirmation(record, {})
assert not result.valid
assert {issue.code for issue in result.issues} >= {"PARSE_STATUS_INVALID", "PLAN_DATA_EMPTY"}
def test_service_confirmation_cannot_be_bypassed_by_direct_request(tmp_path, monkeypatch):
from insurance.poster import service as service_module
record = _record(tmp_path)
record.id = 7
record.user_id = "user-1"
record.product_snapshot_json = "{}"
fake_model = SimpleNamespace(query=SimpleNamespace(get=lambda _record_id: record))
monkeypatch.setattr(service_module, "PosterCaseUpload", fake_model)
result = service_module.PosterService().confirm_case_upload(
7,
"user-1",
{"confirmedData": {}},
)
assert result["code"] == 4201
assert result["data"]["errorCode"] == "PLAN_DATA_EMPTY"
def test_confirmation_preserves_zero_but_requires_positive_premium(tmp_path):
from insurance.plan_data.validators import parse_snapshot_hash, validate_case_confirmation
record = _record(tmp_path)
data = _valid_data()
result = validate_case_confirmation(record, {
"confirmedData": data,
"fileHash": record.file_hash,
"parseSnapshotHash": parse_snapshot_hash(record),
})
assert not result.valid
assert any(issue.path == "annual_premium" and issue.code == "NUMBER_INVALID" for issue in result.issues)
assert not any(issue.path == "surrender_value_10" and issue.code == "AMOUNT_INVALID" for issue in result.issues)
def test_confirmation_and_generation_bind_file_parse_and_payload_hashes(tmp_path):
from insurance.plan_data.validators import (
parse_snapshot_hash,
validate_case_confirmation,
validate_confirmed_case,
)
record = _record(tmp_path)
data = _valid_data()
data["annual_premium"] = 10000
request = {
"confirmedData": data,
"fileHash": record.file_hash,
"parseSnapshotHash": parse_snapshot_hash(record),
}
confirmed = validate_case_confirmation(record, request, {"productName": "测试储蓄计划"})
assert confirmed.valid
record.confirmed_data = json.dumps(confirmed.plan_data, ensure_ascii=False)
assert validate_confirmed_case(record, {"productName": "测试储蓄计划"}).valid
tampered = json.loads(record.confirmed_data)
tampered["annual_premium"] = 1
record.confirmed_data = json.dumps(tampered, ensure_ascii=False)
result = validate_confirmed_case(record, {"productName": "测试储蓄计划"})
assert not result.valid
assert any(issue.code == "CONFIRMED_DATA_CHANGED" for issue in result.issues)
def test_product_mismatch_and_unresolved_conflict_fail_closed(tmp_path):
from insurance.plan_data.validators import parse_snapshot_hash, validate_case_confirmation
record = _record(tmp_path)
data = _valid_data()
data["annual_premium"] = 10000
data["meta"]["conflicts"] = [{"field": "surrender_value_10", "status": "unresolved"}]
result = validate_case_confirmation(record, {
"confirmedData": data,
"fileHash": record.file_hash,
"parseSnapshotHash": parse_snapshot_hash(record),
}, {"productName": "另一产品"})
codes = {issue.code for issue in result.issues}
assert "UNRESOLVED_CONFLICT" in codes
assert "PRODUCT_MISMATCH" in codes
def test_milestone_and_benefit_table_disagreement_cannot_be_confirmed(tmp_path):
from insurance.plan_data.validators import parse_snapshot_hash, validate_case_confirmation
record = _record(tmp_path)
data = _valid_data()
data["annual_premium"] = 10000
data["surrender_value_10"] = 120000
data["benefit_table"][0]["total_surrender_value"] = 100000
result = validate_case_confirmation(record, {
"confirmedData": data,
"fileHash": record.file_hash,
"parseSnapshotHash": parse_snapshot_hash(record),
})
assert not result.valid
assert any(
issue.code == "UNRESOLVED_CONFLICT" and issue.path == "surrender_value_10"
for issue in result.issues
)
def test_normalizer_keeps_unknown_distinct_from_zero_and_removes_wrong_fallbacks():
from insurance.ppt.normalizer import normalize_ci_plan, normalize_iul_plan, normalize_savings_plan
savings = normalize_savings_plan({
"insured": {"age": 30},
"policy": {"currency": "HKD", "annual_premium": 0, "premium_payment_period": 5},
"benefit_illustration": [{"policy_year": 10, "total_surrender_value": 0}],
})
assert savings["policy"]["annualPremium"] == 0
assert savings["policy"]["contractualTotalPremium"] is None
assert savings["benefitRows"][0]["deathBenefit"] is None
ci = normalize_ci_plan({
"insured": {"age": 30},
"policy": {"sum_insured": 500000},
"benefit_illustration": [{"policy_year": 1, "death_benefit": 500000}],
})
assert ci["benefitRows"][0]["deathBenefit"] == 500000
assert ci["benefitRows"][0]["totalSurrenderValue"] is None
iul = normalize_iul_plan({
"insured": {"age": 30},
"policy": {},
"benefit_illustration": [{"policy_year": 1, "account_value": 1000, "cash_value": 900}],
})
assert iul["benefitRows"][0]["nonGuaranteedAccountValue"] is None
assert iul["benefitRows"][0]["totalSurrenderValue"] is None
def test_golden_manifest_is_explicitly_blocked_without_approved_samples():
import runpy
root = Path(__file__).resolve().parents[1]
script = root / "scripts/tools/evaluate_plan_goldens.py"
evaluate = runpy.run_path(str(script))["evaluate"]
report = evaluate(
root / "tests/fixtures/plan_goldens/manifest.json",
root / "tests/fixtures/plan_goldens/predictions",
)
assert report.status == "blocked"
assert report.sample_count == 0
assert report.minimum_samples == 10
def test_template_scope_requires_every_input_product_and_company():
from insurance.ppt.template_selection import template_scope_compatible
template = {
"applicableCompanyIds": ["company-a", "company-b"],
"applicableProductIds": ["product-a", "product-b"],
}
assert template_scope_compatible(
template,
["company-a", "company-b"],
["product-a", "product-b"],
)
assert not template_scope_compatible(
template,
["company-a", "company-c"],
["product-a", "product-b"],
)
assert not template_scope_compatible(
template,
["company-a"],
["product-a", "product-c"],
)