海报确认、文案生成、海报生成增加服务端失败关闭门禁,绑定文件哈希、解析快照哈希和确认数据哈希,并返回 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:通过,仅有换行符提示
169 lines
6.1 KiB
Python
169 lines
6.1 KiB
Python
#!/usr/bin/env python3
|
|
"""批量评估计划书解析金标准,输出 JSON 与 Markdown 报告。"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from dataclasses import asdict, dataclass
|
|
from decimal import Decimal, InvalidOperation
|
|
from pathlib import Path
|
|
|
|
|
|
@dataclass
|
|
class EvaluationReport:
|
|
status: str
|
|
sample_count: int
|
|
minimum_samples: int
|
|
scalar_accuracy: float
|
|
cell_accuracy: float
|
|
target_row_recall: float
|
|
evidence_coverage: float
|
|
differences: list[dict]
|
|
|
|
|
|
def evaluate(manifest_path: Path, predictions_dir: Path) -> EvaluationReport:
|
|
manifest = _read_json(manifest_path)
|
|
samples = manifest.get("samples") or []
|
|
minimum = int(manifest.get("minimumDevelopmentSamples") or 10)
|
|
scalar_total = scalar_correct = 0
|
|
cell_total = cell_correct = 0
|
|
row_total = row_recalled = 0
|
|
evidence_total = evidence_correct = 0
|
|
differences: list[dict] = []
|
|
|
|
for sample in samples:
|
|
sample_id = str(sample.get("id") or "")
|
|
label_path = manifest_path.parent / str(sample.get("label") or "")
|
|
prediction_path = predictions_dir / f"{sample_id}.json"
|
|
if not sample_id or not label_path.is_file() or not prediction_path.is_file():
|
|
differences.append({"sampleId": sample_id, "type": "missing_file"})
|
|
continue
|
|
expected = _read_json(label_path)
|
|
actual = _read_json(prediction_path)
|
|
|
|
expected_scalars = expected.get("scalars") or {}
|
|
actual_scalars = actual.get("scalars") or {}
|
|
for field_path, expected_field in expected_scalars.items():
|
|
scalar_total += 1
|
|
actual_field = actual_scalars.get(field_path)
|
|
if _field_equal(expected_field, actual_field):
|
|
scalar_correct += 1
|
|
else:
|
|
differences.append({"sampleId": sample_id, "type": "scalar", "path": field_path})
|
|
if not bool((expected_field or {}).get("allowMissing")):
|
|
evidence_total += 1
|
|
if _has_evidence(actual_field):
|
|
evidence_correct += 1
|
|
|
|
expected_rows = _rows_by_key(expected.get("benefitRows") or [])
|
|
actual_rows = _rows_by_key(actual.get("benefitRows") or [])
|
|
row_total += len(expected_rows)
|
|
row_recalled += len(set(expected_rows).intersection(actual_rows))
|
|
for row_key, expected_row in expected_rows.items():
|
|
actual_row = actual_rows.get(row_key) or {}
|
|
for field_name, expected_field in (expected_row.get("values") or {}).items():
|
|
cell_total += 1
|
|
actual_field = (actual_row.get("values") or {}).get(field_name)
|
|
if _field_equal(expected_field, actual_field):
|
|
cell_correct += 1
|
|
else:
|
|
differences.append({
|
|
"sampleId": sample_id,
|
|
"type": "cell",
|
|
"path": f"{row_key}.{field_name}",
|
|
})
|
|
|
|
status = "pass" if len(samples) >= minimum and not differences else "blocked"
|
|
return EvaluationReport(
|
|
status=status,
|
|
sample_count=len(samples),
|
|
minimum_samples=minimum,
|
|
scalar_accuracy=_ratio(scalar_correct, scalar_total),
|
|
cell_accuracy=_ratio(cell_correct, cell_total),
|
|
target_row_recall=_ratio(row_recalled, row_total),
|
|
evidence_coverage=_ratio(evidence_correct, evidence_total),
|
|
differences=differences,
|
|
)
|
|
|
|
|
|
def write_report(report: EvaluationReport, output_dir: Path) -> None:
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
payload = asdict(report)
|
|
(output_dir / "plan-golden-report.json").write_text(
|
|
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
)
|
|
lines = [
|
|
"# 计划书金标准评测报告",
|
|
"",
|
|
f"- 状态:`{report.status}`",
|
|
f"- 样本:{report.sample_count}/{report.minimum_samples}",
|
|
f"- 标量精确率:{report.scalar_accuracy:.2%}",
|
|
f"- 金额单元格精确率:{report.cell_accuracy:.2%}",
|
|
f"- 目标行召回率:{report.target_row_recall:.2%}",
|
|
f"- 证据覆盖率:{report.evidence_coverage:.2%}",
|
|
f"- 差异数:{len(report.differences)}",
|
|
]
|
|
(output_dir / "plan-golden-report.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
|
|
|
|
def _rows_by_key(rows: list[dict]) -> dict[str, dict]:
|
|
result = {}
|
|
for row in rows:
|
|
key = "|".join(str(row.get(name) or "") for name in (
|
|
"tableId", "scenarioType", "policyYear", "rowVariant"
|
|
))
|
|
if key.strip("|"):
|
|
result[key] = row
|
|
return result
|
|
|
|
|
|
def _field_equal(expected, actual) -> bool:
|
|
if not isinstance(expected, dict) or not isinstance(actual, dict):
|
|
return False
|
|
return (
|
|
_normalized_value(expected.get("value")) == _normalized_value(actual.get("value"))
|
|
and expected.get("currency") == actual.get("currency")
|
|
and expected.get("unit") == actual.get("unit")
|
|
)
|
|
|
|
|
|
def _normalized_value(value):
|
|
if isinstance(value, bool) or value is None:
|
|
return value
|
|
try:
|
|
return Decimal(str(value))
|
|
except (InvalidOperation, ValueError):
|
|
return str(value).strip()
|
|
|
|
|
|
def _has_evidence(field) -> bool:
|
|
if not isinstance(field, dict):
|
|
return False
|
|
return any(
|
|
isinstance(item, dict) and item.get("pageNumber") and item.get("bbox")
|
|
for item in field.get("evidence") or []
|
|
)
|
|
|
|
|
|
def _ratio(numerator: int, denominator: int) -> float:
|
|
return numerator / denominator if denominator else 0.0
|
|
|
|
|
|
def _read_json(path: Path) -> dict:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--manifest", type=Path, default=Path("tests/fixtures/plan_goldens/manifest.json"))
|
|
parser.add_argument("--predictions", type=Path, required=True)
|
|
parser.add_argument("--output", type=Path, default=Path("tmp/plan-golden-report"))
|
|
args = parser.parse_args()
|
|
report = evaluate(args.manifest, args.predictions)
|
|
write_report(report, args.output)
|
|
return 0 if report.status == "pass" else 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|