海报确认、文案生成、海报生成增加服务端失败关闭门禁,绑定文件哈希、解析快照哈希和确认数据哈希,并返回 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:通过,仅有换行符提示
218 lines
7.5 KiB
Python
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"],
|
|
)
|