baodan/api/insurance/models/plan_snapshot.py

148 lines
6.4 KiB
Python
Raw Normal View History

前工程开发已经推进到 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
"""不可变 PlanData 快照、字段证据和人工覆盖模型。"""
import json
from sqlalchemy import BigInteger, Boolean, Column, Float, ForeignKey, Index, Integer, String, Text, TIMESTAMP, UniqueConstraint, func
from insurance.db.compat import db
SNAPSHOT_ID_TYPE = BigInteger().with_variant(Integer, "sqlite")
def _json(value, fallback):
if not value:
return fallback
try:
return json.loads(value)
except (TypeError, ValueError):
return fallback
class InsurancePlanSnapshot(db.Model):
__tablename__ = "insurance_plan_snapshots"
__table_args__ = (
UniqueConstraint("document_id", "snapshot_version", name="uq_plan_snapshots_document_version"),
Index("idx_plan_snapshots_user_status", "user_id", "status"),
Index("idx_plan_snapshots_hash", "snapshot_hash"),
Index("idx_plan_snapshots_expires", "expires_at"),
)
id = Column(SNAPSHOT_ID_TYPE, primary_key=True, autoincrement=True)
user_id = Column(String(64), nullable=False)
document_id = Column(SNAPSHOT_ID_TYPE, ForeignKey("insurance_documents.id", ondelete="CASCADE"), nullable=False)
schema_version = Column(String(30), nullable=False, default="plan-data-v1")
snapshot_version = Column(Integer, nullable=False)
previous_snapshot_id = Column(SNAPSHOT_ID_TYPE, ForeignKey("insurance_plan_snapshots.id"), nullable=True)
status = Column(String(30), nullable=False, default="draft")
plan_data_json = Column(Text, nullable=False)
validation_results_json = Column(Text, nullable=False)
snapshot_hash = Column(String(64), nullable=False)
document_sha256 = Column(String(64), nullable=False)
confirmed_by = Column(String(64), nullable=True)
confirmed_at = Column(TIMESTAMP, nullable=True)
is_golden = Column(Boolean, nullable=False, default=False)
golden_approved_by = Column(String(64), nullable=True)
golden_approved_at = Column(TIMESTAMP, nullable=True)
golden_review_note = Column(String(500), nullable=True)
expires_at = Column(TIMESTAMP, nullable=True)
created_at = Column(TIMESTAMP, server_default=func.now())
def plan_data(self) -> dict:
return _json(self.plan_data_json, {})
def validation(self) -> dict:
return _json(self.validation_results_json, {})
def to_public_dict(self) -> dict:
return {
"id": self.id,
"schemaVersion": self.schema_version,
"snapshotVersion": self.snapshot_version,
"previousSnapshotId": self.previous_snapshot_id,
"status": self.status,
"document": {"documentId": self.document_id, "sha256": self.document_sha256},
"planData": self.plan_data(),
"validation": self.validation(),
"snapshotHash": self.snapshot_hash,
"confirmedBy": self.confirmed_by,
"confirmedAt": self.confirmed_at.isoformat() if self.confirmed_at else None,
"isGolden": bool(self.is_golden),
"goldenApprovedBy": self.golden_approved_by,
"goldenApprovedAt": self.golden_approved_at.isoformat() if self.golden_approved_at else None,
"goldenReviewNote": self.golden_review_note or "",
"expiresAt": self.expires_at.isoformat() if self.expires_at else None,
"createdAt": self.created_at.isoformat() if self.created_at else None,
}
class InsuranceFieldEvidence(db.Model):
__tablename__ = "insurance_field_evidence"
__table_args__ = (
Index("idx_field_evidence_snapshot_path", "snapshot_id", "field_path"),
Index("idx_field_evidence_document_page", "document_id", "page_number"),
)
id = Column(SNAPSHOT_ID_TYPE, primary_key=True, autoincrement=True)
snapshot_id = Column(SNAPSHOT_ID_TYPE, ForeignKey("insurance_plan_snapshots.id", ondelete="CASCADE"), nullable=False)
field_path = Column(String(500), nullable=False)
document_id = Column(SNAPSHOT_ID_TYPE, ForeignKey("insurance_documents.id", ondelete="CASCADE"), nullable=False)
page_number = Column(Integer, nullable=False)
bbox_json = Column(Text, nullable=True)
table_id = Column(String(100), nullable=True)
row_id = Column(String(100), nullable=True)
column_id = Column(String(100), nullable=True)
raw_text = Column(String(200), nullable=True)
text_hash = Column(String(64), nullable=True)
extractor = Column(String(50), nullable=True)
extractor_version = Column(String(100), nullable=True)
confidence = Column(Float, nullable=True)
evidence_status = Column(String(20), nullable=False, default="auto")
created_at = Column(TIMESTAMP, server_default=func.now())
def to_public_dict(self) -> dict:
return {
"id": self.id,
"snapshotId": self.snapshot_id,
"fieldPath": self.field_path,
"evidence": {
"documentId": self.document_id,
"pageNumber": self.page_number,
"bbox": _json(self.bbox_json, None),
"tableId": self.table_id,
"rowId": self.row_id,
"columnId": self.column_id,
"textHash": self.text_hash,
},
"snippet": self.raw_text or "",
"extractor": self.extractor,
"extractorVersion": self.extractor_version,
"confidence": self.confidence,
"status": self.evidence_status,
}
class InsurancePlanOverride(db.Model):
__tablename__ = "insurance_plan_overrides"
__table_args__ = (Index("idx_plan_overrides_snapshot", "snapshot_id"),)
id = Column(SNAPSHOT_ID_TYPE, primary_key=True, autoincrement=True)
snapshot_id = Column(SNAPSHOT_ID_TYPE, ForeignKey("insurance_plan_snapshots.id", ondelete="CASCADE"), nullable=False)
field_path = Column(String(500), nullable=False)
old_value_json = Column(Text, nullable=True)
new_value_json = Column(Text, nullable=True)
reason = Column(String(500), nullable=False)
operator_id = Column(String(64), nullable=False)
created_at = Column(TIMESTAMP, server_default=func.now())
def to_public_dict(self) -> dict:
return {
"id": self.id,
"snapshotId": self.snapshot_id,
"fieldPath": self.field_path,
"oldValue": _json(self.old_value_json, None),
"newValue": _json(self.new_value_json, None),
"reason": self.reason,
"operatorId": self.operator_id,
"createdAt": self.created_at.isoformat() if self.created_at else None,
}