baodan/api/insurance/plan_data/field_values.py
wsb1224 2422303b36 前工程开发已经推进到 Phase 6 基础能力,但正式验收还没有完成。更准确地说:Phase 0~5 的主要代码链路已经落地,Phase 6 完成了治理框架,尚缺生产化和真实数据验收。
阶段	当前状态	说明
Phase 0~3	基本完成	Document IR、证据链、人工确认、不可变 PlanData Snapshot、海报/PPT 投影已实现
Phase 4	代码完成	场景、策略、模板版本,校验/发布门禁,确定性 resolver 和严格槽位合并已实现
Phase 5	代码完成	输入冻结、PPTX/海报对账、失败关闭、幂等、心跳、重试和冻结输入重放已实现
Phase 6	基础完成	质量看板、结构化告警、Golden 审批、留存清理、孤儿检查和灰度开关已实现
正式上线	未完成	缺真实样本、生产模板、业务规则签字和灰度观察

目前验证基线:
PPT/海报专项测试:107 passed, 1 skipped
Vue TypeScript 检查:通过
前端生产构建:通过
代码变更仍在工作区,尚未提交
仓库全量测试仍有既有失败/挂起项,暂时不能宣称全仓测试完全绿色
仍未完成的代码任务主要有:
影子解析差异流水线
目前有灰度开关,但还没有完整的“新旧解析同时运行、字段差异入库、按保司/profile 聚合”的影子比较任务。

自动视觉回归
目前实现的是 DOM 模块、溢出、尺寸、文本和数值检查;还缺基于真实模板和标准图片的像素差异、字体缺失、遮挡和裁切回归。

告警通道接入
后台已经能产生结构化质量告警,但尚未自动推送到邮件、企微或其他通知通道。

留存任务生产化
dry-run、实删服务和失败审计已经具备,但尚未接入周期性 Celery/定时任务,也没有自动重试失败清理批次。

旧链路最终下线
旧 PPT 解析器和海报紧凑解析仍保留为回滚路径。需要全量灰度稳定后才能删除或彻底关闭写入口。

全仓测试收口
需要处理现有无关失败和挂起测试,建立真正全绿的 CI 基线。

仍需外部输入和生产环境完成的事项:
至少 30 份脱敏 Golden PDF,并完成双人标注和精确率验收。
业务专家确认派生公式、缺失值、可比较性和结论策略。
上传并标注真实生产 PPTX 的语义 shape、页面类型和容量。
安装生产字体并建立视觉基准图片。
实际执行数据库迁移 038~040。
完成留存 dry-run、实删演练以及 10% → 30% → 100% 灰度。
观察期通过后开启 SCENARIO_ENGINE_V2,目前它仍默认关闭;真实清理开关也默认关闭。
完整状态记录在 [PPT与海报Phase4至6补充实施记录](D:/work/code/python/coding/baodanagent/docs/PPT与海报Phase4至6补充实施记录_20260802.md)。
2026-08-02 16:34:06 +08:00

225 lines
7.4 KiB
Python

"""FieldValue 与提取候选合并。
这里只处理来源、值和冲突,不推断保险业务公式。
"""
from __future__ import annotations
import math
from collections import defaultdict
from decimal import Decimal, InvalidOperation
from typing import Any
SOURCE_WEIGHTS = {
"pdf_table": 1.0,
"profile_table": 0.98,
"regex": 0.86,
"ocr": 0.78,
"llm": 0.68,
"context_hint": 0.2,
}
def missing_field_value(*, currency: str | None = None, unit: str | None = None) -> dict:
return {
"status": "missing",
"value": None,
"currency": currency,
"unit": unit,
"confidence": None,
"evidence": [],
}
def extracted_field_value(
value: Any,
*,
currency: str | None = None,
unit: str | None = None,
confidence: float | None = None,
evidence: list[dict] | None = None,
) -> dict:
if value is None:
return missing_field_value(currency=currency, unit=unit)
return {
"status": "extracted",
"value": value,
"currency": currency,
"unit": unit,
"confidence": _confidence(confidence),
"evidence": list(evidence or []),
}
def confirmed_field_value(value: Any, *, previous: dict | None = None, reason: str) -> dict:
reason = str(reason or "").strip()
if not reason:
raise ValueError("人工覆盖必须填写原因")
previous = previous or {}
return {
"status": "confirmed",
"value": value,
"currency": previous.get("currency"),
"unit": previous.get("unit"),
"confidence": previous.get("confidence"),
"evidence": list(previous.get("evidence") or []),
"overrideReason": reason,
}
def make_candidate(
field_path: str,
raw_value: Any,
*,
normalized_value: Any = None,
source: str,
currency: str | None = None,
unit: str | None = None,
confidence: float | None = None,
evidence: list[dict] | None = None,
extractor_version: str = "",
context_hint: dict | None = None,
) -> dict:
value = raw_value if normalized_value is None else normalized_value
return {
"fieldPath": str(field_path),
"rawValue": raw_value,
"normalizedValue": value,
"source": str(source),
"currency": currency,
"unit": unit,
"confidence": _confidence(confidence),
"evidence": list(evidence or []),
"extractorVersion": str(extractor_version or ""),
"contextHint": dict(context_hint or {}),
}
def reconcile_candidates(candidates: list[dict]) -> dict[str, dict]:
"""按字段合并候选;不同真实值全部保留为 conflict。"""
grouped: dict[str, list[dict]] = defaultdict(list)
for candidate in candidates:
path = str(candidate.get("fieldPath") or "").strip()
if path:
grouped[path].append(candidate)
result: dict[str, dict] = {}
for field_path, items in grouped.items():
ranked = sorted(items, key=_candidate_score, reverse=True)
values: list[Any] = []
by_value: dict[str, list[dict]] = defaultdict(list)
for item in ranked:
key = _value_key(item.get("normalizedValue"))
by_value[key].append(item)
if not any(_same_value(item.get("normalizedValue"), current) for current in values):
values.append(item.get("normalizedValue"))
best = ranked[0]
merged_evidence = _dedupe_evidence([
evidence
for item in by_value[_value_key(best.get("normalizedValue"))]
for evidence in item.get("evidence") or []
])
if len(values) > 1:
result[field_path] = {
"status": "conflict",
"value": None,
"currency": best.get("currency"),
"unit": best.get("unit"),
"confidence": best.get("confidence"),
"evidence": _dedupe_evidence([
evidence for item in ranked for evidence in item.get("evidence") or []
]),
"conflictCandidates": values,
}
else:
result[field_path] = extracted_field_value(
best.get("normalizedValue"),
currency=best.get("currency"),
unit=best.get("unit"),
confidence=best.get("confidence"),
evidence=merged_evidence,
)
return result
def validate_field_value(value: dict, field_path: str = "") -> list[dict]:
issues: list[dict] = []
if not isinstance(value, dict):
return [_issue("FIELD_VALUE_INVALID", "字段值格式无效", field_path)]
status = value.get("status")
if status not in {"missing", "extracted", "conflict", "confirmed", "derived"}:
issues.append(_issue("FIELD_STATUS_INVALID", "字段状态无效", field_path))
if status == "missing" and value.get("value") is not None:
issues.append(_issue("MISSING_VALUE_NOT_NULL", "缺失字段的值必须为 null", field_path))
if status == "conflict" and len(value.get("conflictCandidates") or []) < 2:
issues.append(_issue("CONFLICT_CANDIDATES_MISSING", "冲突字段必须保留候选值", field_path))
if status == "derived" and not str(value.get("derivedBy") or "").strip():
issues.append(_issue("DERIVATION_MISSING", "推导字段必须记录公式版本", field_path))
if value.get("overrideReason") and status != "confirmed":
issues.append(_issue("OVERRIDE_STATUS_INVALID", "人工覆盖字段必须为 confirmed", field_path))
scalar = value.get("value")
if isinstance(scalar, float) and not math.isfinite(scalar):
issues.append(_issue("NUMBER_INVALID", "字段数值必须为有限数", field_path))
return issues
def _candidate_score(candidate: dict) -> tuple:
source = str(candidate.get("source") or "")
evidence = candidate.get("evidence") or []
has_bbox = any(item.get("bbox") for item in evidence if isinstance(item, dict))
confidence = candidate.get("confidence")
return (
1 if has_bbox else 0,
SOURCE_WEIGHTS.get(source, 0.5),
float(confidence) if confidence is not None else 0.0,
len(evidence),
)
def _confidence(value) -> float | None:
if value is None:
return None
try:
parsed = float(value)
except (TypeError, ValueError):
return None
if not math.isfinite(parsed):
return None
return min(1.0, max(0.0, parsed))
def _same_value(left: Any, right: Any) -> bool:
try:
return Decimal(str(left).replace(",", "")) == Decimal(str(right).replace(",", ""))
except (InvalidOperation, ValueError):
return left == right
def _value_key(value: Any) -> str:
try:
return f"number:{Decimal(str(value).replace(',', '')).normalize()}"
except (InvalidOperation, ValueError):
return f"value:{value!r}"
def _dedupe_evidence(items: list[dict]) -> list[dict]:
unique: list[dict] = []
seen = set()
for item in items:
if not isinstance(item, dict):
continue
key = (
item.get("documentId"), item.get("pageNumber"),
tuple(item.get("bbox") or ()), item.get("tableId"),
item.get("rowId"), item.get("columnId"), item.get("textHash"),
)
if key not in seen:
seen.add(key)
unique.append(dict(item))
return unique
def _issue(code: str, message: str, path: str) -> dict:
return {"code": code, "message": message, "path": path, "severity": "error"}