baodan/api/insurance/models/ppt_version.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

169 lines
7.9 KiB
Python

"""PPT 场景、计算策略与模板的不可变发布版本。"""
import json
from sqlalchemy import BigInteger, Column, ForeignKey, Index, Integer, String, Text, TIMESTAMP, UniqueConstraint, event, func, inspect
from insurance.db.compat import db
VERSION_ID_TYPE = BigInteger().with_variant(Integer, "sqlite")
LIFECYCLES = {"draft", "validated", "published", "retired"}
def _load_json(value, fallback):
if not value:
return fallback
try:
return json.loads(value)
except (TypeError, ValueError):
return fallback
class PptScenarioVersion(db.Model):
__tablename__ = "insurance_ppt_scenario_versions"
__table_args__ = (
UniqueConstraint("scenario_code", "version", name="uq_ppt_scenario_version"),
Index("idx_ppt_scenario_versions_lifecycle", "scenario_code", "lifecycle"),
Index("idx_ppt_scenario_versions_hash", "definition_hash"),
)
id = Column(VERSION_ID_TYPE, primary_key=True, autoincrement=True)
scenario_code = Column(String(50), ForeignKey("insurance_ppt_scenarios.code"), nullable=False)
version = Column(Integer, nullable=False)
lifecycle = Column(String(20), nullable=False, default="draft")
selector_json = Column(Text, nullable=False, default="{}")
page_specs_json = Column(Text, nullable=False, default="[]")
metric_specs_json = Column(Text, nullable=False, default="[]")
comparison_years_json = Column(Text, nullable=False, default="[]")
compatibility_rules_json = Column(Text, nullable=False, default="{}")
fallback_scenario = Column(String(50), nullable=True)
definition_hash = Column(String(64), nullable=False)
validation_report_json = Column(Text, nullable=True)
created_by = Column(String(64), nullable=False)
published_by = Column(String(64), nullable=True)
published_at = Column(TIMESTAMP, nullable=True)
created_at = Column(TIMESTAMP, server_default=func.now())
def definition(self):
return {
"selector": _load_json(self.selector_json, {}),
"pageSpecs": _load_json(self.page_specs_json, []),
"metricSpecs": _load_json(self.metric_specs_json, []),
"comparisonYears": _load_json(self.comparison_years_json, []),
"compatibilityRules": _load_json(self.compatibility_rules_json, {}),
"fallbackScenario": self.fallback_scenario,
}
def to_public_dict(self):
return {
"id": self.id, "scenarioCode": self.scenario_code, "version": self.version,
"lifecycle": self.lifecycle, **self.definition(), "definitionHash": self.definition_hash,
"validationReport": _load_json(self.validation_report_json, None),
"createdBy": self.created_by, "publishedBy": self.published_by,
"publishedAt": self.published_at.isoformat() if self.published_at else None,
"createdAt": self.created_at.isoformat() if self.created_at else None,
}
class GenerationPolicyVersion(db.Model):
__tablename__ = "insurance_generation_policy_versions"
__table_args__ = (
UniqueConstraint("code", "version", name="uq_generation_policy_version"),
Index("idx_generation_policy_versions_lifecycle", "code", "lifecycle"),
)
id = Column(VERSION_ID_TYPE, primary_key=True, autoincrement=True)
code = Column(String(50), nullable=False)
version = Column(Integer, nullable=False)
lifecycle = Column(String(20), nullable=False, default="draft")
calculation_policy_json = Column(Text, nullable=False, default="{}")
missing_value_policy_json = Column(Text, nullable=False, default="{}")
conclusion_policy_json = Column(Text, nullable=False, default="{}")
policy_hash = Column(String(64), nullable=False)
validation_report_json = Column(Text, nullable=True)
created_by = Column(String(64), nullable=False)
published_by = Column(String(64), nullable=True)
published_at = Column(TIMESTAMP, nullable=True)
created_at = Column(TIMESTAMP, server_default=func.now())
def definition(self):
return {
"calculationPolicy": _load_json(self.calculation_policy_json, {}),
"missingValuePolicy": _load_json(self.missing_value_policy_json, {}),
"conclusionPolicy": _load_json(self.conclusion_policy_json, {}),
}
def to_public_dict(self):
return {
"id": self.id, "code": self.code, "version": self.version,
"lifecycle": self.lifecycle, **self.definition(), "policyHash": self.policy_hash,
"validationReport": _load_json(self.validation_report_json, None),
"createdBy": self.created_by, "publishedBy": self.published_by,
"publishedAt": self.published_at.isoformat() if self.published_at else None,
"createdAt": self.created_at.isoformat() if self.created_at else None,
}
class PptTemplateVersion(db.Model):
__tablename__ = "insurance_ppt_template_versions"
__table_args__ = (
UniqueConstraint("template_id", "version", name="uq_ppt_template_version"),
Index("idx_ppt_template_versions_lifecycle", "template_id", "lifecycle"),
)
id = Column(VERSION_ID_TYPE, primary_key=True, autoincrement=True)
template_id = Column(String(50), ForeignKey("insurance_ppt_templates.id"), nullable=False)
version = Column(Integer, nullable=False)
lifecycle = Column(String(20), nullable=False, default="draft")
asset_id = Column(String(255), nullable=False)
asset_sha256 = Column(String(64), nullable=False)
page_slots_json = Column(Text, nullable=False, default="[]")
theme_json = Column(Text, nullable=False, default="{}")
capacity_rules_json = Column(Text, nullable=False, default="{}")
supported_scenario_versions_json = Column(Text, nullable=False, default="[]")
validation_report_json = Column(Text, nullable=True)
definition_hash = Column(String(64), nullable=False)
created_by = Column(String(64), nullable=False)
published_by = Column(String(64), nullable=True)
published_at = Column(TIMESTAMP, nullable=True)
created_at = Column(TIMESTAMP, server_default=func.now())
def definition(self):
return {
"assetId": self.asset_id, "assetSha256": self.asset_sha256,
"pageSlots": _load_json(self.page_slots_json, []),
"theme": _load_json(self.theme_json, {}),
"capacityRules": _load_json(self.capacity_rules_json, {}),
"supportedScenarioVersionIds": _load_json(self.supported_scenario_versions_json, []),
}
def to_public_dict(self):
return {
"id": self.id, "templateId": self.template_id, "version": self.version,
"lifecycle": self.lifecycle, **self.definition(), "definitionHash": self.definition_hash,
"validationReport": _load_json(self.validation_report_json, None),
"createdBy": self.created_by, "publishedBy": self.published_by,
"publishedAt": self.published_at.isoformat() if self.published_at else None,
"createdAt": self.created_at.isoformat() if self.created_at else None,
}
def _protect_immutable_definition(mapper, connection, target):
"""阻止绕过服务直接篡改已发布/已退役定义。"""
state = inspect(target)
lifecycle_history = state.attrs.lifecycle.history
previous = lifecycle_history.deleted[0] if lifecycle_history.deleted else target.lifecycle
if previous not in {"published", "retired"}:
return
mutable_metadata = {"lifecycle", "published_by", "published_at", "validation_report_json"}
changed = {
attribute.key for attribute in state.mapper.column_attrs
if attribute.key not in mutable_metadata and state.attrs[attribute.key].history.has_changes()
}
if changed:
raise ValueError("published version definition is immutable")
for _model in (PptScenarioVersion, GenerationPolicyVersion, PptTemplateVersion):
event.listen(_model, "before_update", _protect_immutable_definition)