阶段 当前状态 说明 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)。
225 lines
7.4 KiB
Python
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"}
|