阶段 当前状态 说明 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)。
683 lines
34 KiB
Python
683 lines
34 KiB
Python
"""PPT 配置版本化、发布门禁与确定性场景解析。"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import zipfile
|
||
from datetime import datetime
|
||
from typing import Any
|
||
from xml.etree import ElementTree
|
||
|
||
from sqlalchemy import func
|
||
|
||
from insurance.db.compat import db
|
||
from insurance.models.ppt_config import PptScenario, PptTemplate
|
||
from insurance.models.ppt_version import GenerationPolicyVersion, PptScenarioVersion, PptTemplateVersion
|
||
from insurance.plan_data.validators import canonical_json_hash
|
||
|
||
|
||
class VersioningError(ValueError):
|
||
def __init__(self, code: str, message: str, details: dict | None = None):
|
||
super().__init__(message)
|
||
self.code = code
|
||
self.details = details or {}
|
||
|
||
|
||
def _dump(value: Any) -> str:
|
||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||
|
||
|
||
def _next_version(model, column, value) -> int:
|
||
current = db.session.query(func.max(model.version)).filter(column == value).scalar()
|
||
return int(current or 0) + 1
|
||
|
||
|
||
def _require_draft_or_validated(row):
|
||
if row.lifecycle in {"published", "retired"}:
|
||
raise VersioningError("VERSION_IMMUTABLE", "已发布或已退役版本不可修改,请创建新草稿")
|
||
|
||
|
||
def create_scenario_version(scenario_code: str, data: dict, user_id: str) -> PptScenarioVersion:
|
||
scenario = PptScenario.query.filter_by(code=scenario_code, deleted_at=None).first()
|
||
if not scenario:
|
||
raise VersioningError("SCENARIO_NOT_FOUND", "场景不存在")
|
||
definition = {
|
||
"selector": data.get("selector") or {},
|
||
"pageSpecs": data.get("pageSpecs") or [],
|
||
"metricSpecs": data.get("metricSpecs") or [],
|
||
"comparisonYears": data.get("comparisonYears") or [],
|
||
"compatibilityRules": data.get("compatibilityRules") or {},
|
||
"fallbackScenario": data.get("fallbackScenario") or None,
|
||
}
|
||
row = PptScenarioVersion(
|
||
scenario_code=scenario_code,
|
||
version=_next_version(PptScenarioVersion, PptScenarioVersion.scenario_code, scenario_code),
|
||
selector_json=_dump(definition["selector"]),
|
||
page_specs_json=_dump(definition["pageSpecs"]),
|
||
metric_specs_json=_dump(definition["metricSpecs"]),
|
||
comparison_years_json=_dump(definition["comparisonYears"]),
|
||
compatibility_rules_json=_dump(definition["compatibilityRules"]),
|
||
fallback_scenario=definition["fallbackScenario"],
|
||
definition_hash=canonical_json_hash(definition),
|
||
created_by=user_id,
|
||
)
|
||
db.session.add(row)
|
||
db.session.commit()
|
||
return row
|
||
|
||
|
||
def clone_scenario_version(version_id: int, user_id: str) -> PptScenarioVersion:
|
||
source = db.session.get(PptScenarioVersion, version_id)
|
||
if not source:
|
||
raise VersioningError("VERSION_NOT_FOUND", "场景版本不存在")
|
||
return create_scenario_version(source.scenario_code, source.definition(), user_id)
|
||
|
||
|
||
def try_scenario_version(version_id: int, context: dict) -> dict:
|
||
row = db.session.get(PptScenarioVersion, version_id)
|
||
if not row:
|
||
raise VersioningError("VERSION_NOT_FOUND", "场景版本不存在")
|
||
matched, trace, specificity = _selector_matches(row.definition()["selector"], context or {})
|
||
return {"matched": matched, "specificity": specificity, "matchTrace": trace, "version": row.to_public_dict()}
|
||
|
||
|
||
def validate_scenario_version(version_id: int, data: dict | None = None) -> PptScenarioVersion:
|
||
row = db.session.get(PptScenarioVersion, version_id)
|
||
if not row:
|
||
raise VersioningError("VERSION_NOT_FOUND", "场景版本不存在")
|
||
_require_draft_or_validated(row)
|
||
definition = row.definition()
|
||
issues = _validate_scenario_definition(definition)
|
||
sample_ids = _sample_ids(data)
|
||
issues.extend(_validate_samples(sample_ids))
|
||
report = {
|
||
"valid": not issues,
|
||
"issues": issues,
|
||
"sampleSnapshotIds": sample_ids,
|
||
"definitionHash": row.definition_hash,
|
||
"validatedAt": datetime.now().isoformat(),
|
||
}
|
||
row.validation_report_json = _dump(report)
|
||
row.lifecycle = "validated" if not issues else "draft"
|
||
db.session.commit()
|
||
if issues:
|
||
raise VersioningError("VALIDATION_FAILED", "场景版本校验未通过", report)
|
||
return row
|
||
|
||
|
||
def publish_scenario_version(version_id: int, user_id: str) -> PptScenarioVersion:
|
||
row = db.session.get(PptScenarioVersion, version_id)
|
||
if not row:
|
||
raise VersioningError("VERSION_NOT_FOUND", "场景版本不存在")
|
||
if row.lifecycle != "validated":
|
||
raise VersioningError("VERSION_NOT_VALIDATED", "只有通过校验的版本可以发布")
|
||
report = json.loads(row.validation_report_json or "{}")
|
||
if not report.get("valid") or not report.get("sampleSnapshotIds"):
|
||
raise VersioningError("GOLDEN_REQUIRED", "发布前必须通过至少一个已确认快照样本")
|
||
if report.get("definitionHash") != row.definition_hash or canonical_json_hash(row.definition()) != row.definition_hash:
|
||
raise VersioningError("VALIDATION_STALE", "版本定义在校验后发生变化,请重新校验")
|
||
now = datetime.now()
|
||
PptScenarioVersion.query.filter_by(
|
||
scenario_code=row.scenario_code, lifecycle="published"
|
||
).update({"lifecycle": "retired"}, synchronize_session=False)
|
||
row.lifecycle = "published"
|
||
row.published_by = user_id
|
||
row.published_at = now
|
||
db.session.commit()
|
||
return row
|
||
|
||
|
||
def create_policy_version(code: str, data: dict, user_id: str) -> GenerationPolicyVersion:
|
||
if not code.strip():
|
||
raise VersioningError("POLICY_CODE_REQUIRED", "策略编码不能为空")
|
||
definition = {
|
||
"calculationPolicy": data.get("calculationPolicy") or {},
|
||
"missingValuePolicy": data.get("missingValuePolicy") or {},
|
||
"conclusionPolicy": data.get("conclusionPolicy") or {},
|
||
}
|
||
row = GenerationPolicyVersion(
|
||
code=code.strip(),
|
||
version=_next_version(GenerationPolicyVersion, GenerationPolicyVersion.code, code.strip()),
|
||
calculation_policy_json=_dump(definition["calculationPolicy"]),
|
||
missing_value_policy_json=_dump(definition["missingValuePolicy"]),
|
||
conclusion_policy_json=_dump(definition["conclusionPolicy"]),
|
||
policy_hash=canonical_json_hash(definition),
|
||
created_by=user_id,
|
||
)
|
||
db.session.add(row)
|
||
db.session.commit()
|
||
return row
|
||
|
||
|
||
def clone_policy_version(version_id: int, user_id: str) -> GenerationPolicyVersion:
|
||
source = db.session.get(GenerationPolicyVersion, version_id)
|
||
if not source:
|
||
raise VersioningError("VERSION_NOT_FOUND", "生成策略版本不存在")
|
||
return create_policy_version(source.code, source.definition(), user_id)
|
||
|
||
|
||
def validate_policy_version(version_id: int) -> GenerationPolicyVersion:
|
||
row = db.session.get(GenerationPolicyVersion, version_id)
|
||
if not row:
|
||
raise VersioningError("VERSION_NOT_FOUND", "生成策略版本不存在")
|
||
_require_draft_or_validated(row)
|
||
definition = row.definition()
|
||
issues = []
|
||
calculation = definition["calculationPolicy"]
|
||
if not calculation:
|
||
issues.append(_issue("CALCULATION_POLICY_REQUIRED", "计算策略不能为空", "calculationPolicy"))
|
||
metrics = calculation.get("derivedMetrics") if isinstance(calculation, dict) else None
|
||
if not isinstance(metrics, list):
|
||
issues.append(_issue("DERIVED_METRICS_REQUIRED", "必须明确声明 derivedMetrics;不计算时填写空数组", "calculationPolicy.derivedMetrics"))
|
||
else:
|
||
supported = {"irr": "money_weighted_annualized"}
|
||
seen = set()
|
||
for index, metric in enumerate(metrics):
|
||
metric = metric if isinstance(metric, dict) else {}
|
||
code = metric.get("code")
|
||
if code in seen:
|
||
issues.append(_issue("DERIVED_METRIC_DUPLICATED", "派生指标编码不能重复", f"calculationPolicy.derivedMetrics.{index}.code"))
|
||
seen.add(code)
|
||
if code not in supported or metric.get("formula") != supported.get(code):
|
||
issues.append(_issue("DERIVED_METRIC_FORMULA_INVALID", "派生指标或公式不受支持", f"calculationPolicy.derivedMetrics.{index}.formula"))
|
||
if not isinstance(metric.get("inputs"), list) or not metric.get("inputs"):
|
||
issues.append(_issue("DERIVED_METRIC_INPUTS_REQUIRED", "派生指标必须声明输入字段", f"calculationPolicy.derivedMetrics.{index}.inputs"))
|
||
precision = metric.get("precision")
|
||
if not isinstance(precision, int) or not 0 <= precision <= 6:
|
||
issues.append(_issue("DERIVED_METRIC_PRECISION_INVALID", "精度必须是 0 至 6 的整数", f"calculationPolicy.derivedMetrics.{index}.precision"))
|
||
missing_mode = definition["missingValuePolicy"].get("mode")
|
||
if missing_mode not in {"block", "omit", "mark_unavailable"}:
|
||
issues.append(_issue("MISSING_VALUE_POLICY_INVALID", "缺失值策略 mode 无效", "missingValuePolicy.mode"))
|
||
conclusion_mode = definition["conclusionPolicy"].get("mode")
|
||
if conclusion_mode not in {"disabled", "rules_only", "rules_with_llm_copy"}:
|
||
issues.append(_issue("CONCLUSION_POLICY_INVALID", "结论策略 mode 无效", "conclusionPolicy.mode"))
|
||
report = {"valid": not issues, "issues": issues, "policyHash": row.policy_hash, "validatedAt": datetime.now().isoformat()}
|
||
row.validation_report_json = _dump(report)
|
||
row.lifecycle = "validated" if not issues else "draft"
|
||
db.session.commit()
|
||
if issues:
|
||
raise VersioningError("VALIDATION_FAILED", "生成策略版本校验未通过", report)
|
||
return row
|
||
|
||
|
||
def publish_policy_version(version_id: int, user_id: str) -> GenerationPolicyVersion:
|
||
row = db.session.get(GenerationPolicyVersion, version_id)
|
||
if not row:
|
||
raise VersioningError("VERSION_NOT_FOUND", "生成策略版本不存在")
|
||
if row.lifecycle != "validated":
|
||
raise VersioningError("VERSION_NOT_VALIDATED", "只有通过校验的版本可以发布")
|
||
report = json.loads(row.validation_report_json or "{}")
|
||
if report.get("policyHash") != row.policy_hash or canonical_json_hash(row.definition()) != row.policy_hash:
|
||
raise VersioningError("VALIDATION_STALE", "版本定义在校验后发生变化,请重新校验")
|
||
GenerationPolicyVersion.query.filter_by(code=row.code, lifecycle="published").update(
|
||
{"lifecycle": "retired"}, synchronize_session=False
|
||
)
|
||
row.lifecycle = "published"
|
||
row.published_by = user_id
|
||
row.published_at = datetime.now()
|
||
db.session.commit()
|
||
return row
|
||
|
||
|
||
def create_template_version(template_id: str, data: dict, user_id: str) -> PptTemplateVersion:
|
||
template = PptTemplate.query.filter_by(id=template_id, deleted_at=None).first()
|
||
if not template:
|
||
raise VersioningError("TEMPLATE_NOT_FOUND", "模板不存在")
|
||
definition = {
|
||
"assetId": data.get("assetId") or template.source_template_asset_id,
|
||
"assetSha256": data.get("assetSha256") or template.asset_sha256,
|
||
"pageSlots": data.get("pageSlots") or [],
|
||
"theme": data.get("theme") or {"stylePreset": template.style_preset},
|
||
"capacityRules": data.get("capacityRules") or {},
|
||
"supportedScenarioVersionIds": data.get("supportedScenarioVersionIds") or [],
|
||
}
|
||
if not definition["assetId"] or not definition["assetSha256"]:
|
||
raise VersioningError("TEMPLATE_ASSET_REQUIRED", "模板版本必须绑定可校验的源文件")
|
||
row = PptTemplateVersion(
|
||
template_id=template_id,
|
||
version=_next_version(PptTemplateVersion, PptTemplateVersion.template_id, template_id),
|
||
asset_id=definition["assetId"], asset_sha256=definition["assetSha256"],
|
||
page_slots_json=_dump(definition["pageSlots"]), theme_json=_dump(definition["theme"]),
|
||
capacity_rules_json=_dump(definition["capacityRules"]),
|
||
supported_scenario_versions_json=_dump(definition["supportedScenarioVersionIds"]),
|
||
definition_hash=canonical_json_hash(definition), created_by=user_id,
|
||
)
|
||
db.session.add(row)
|
||
db.session.commit()
|
||
return row
|
||
|
||
|
||
def clone_template_version(version_id: int, user_id: str) -> PptTemplateVersion:
|
||
source = db.session.get(PptTemplateVersion, version_id)
|
||
if not source:
|
||
raise VersioningError("VERSION_NOT_FOUND", "模板版本不存在")
|
||
return create_template_version(source.template_id, source.definition(), user_id)
|
||
|
||
|
||
def retire_version(model, version_id: int, user_id: str):
|
||
row = db.session.get(model, version_id)
|
||
if not row:
|
||
raise VersioningError("VERSION_NOT_FOUND", "版本不存在")
|
||
if row.lifecycle != "published":
|
||
raise VersioningError("VERSION_NOT_PUBLISHED", "只有已发布版本可以停用")
|
||
row.lifecycle = "retired"
|
||
if model is PptTemplateVersion:
|
||
template = db.session.get(PptTemplate, row.template_id)
|
||
other = PptTemplateVersion.query.filter(
|
||
PptTemplateVersion.template_id == row.template_id,
|
||
PptTemplateVersion.lifecycle == "published",
|
||
PptTemplateVersion.id != row.id,
|
||
).first()
|
||
if template and not other:
|
||
template.clone_ready = False
|
||
db.session.commit()
|
||
from insurance.utils.audit import log_operation
|
||
log_operation(user_id, "retire", model.__tablename__, str(row.id), {
|
||
"version": row.version,
|
||
})
|
||
return row
|
||
|
||
|
||
def validate_template_version(version_id: int, data: dict | None = None) -> PptTemplateVersion:
|
||
row = db.session.get(PptTemplateVersion, version_id)
|
||
if not row:
|
||
raise VersioningError("VERSION_NOT_FOUND", "模板版本不存在")
|
||
_require_draft_or_validated(row)
|
||
definition = row.definition()
|
||
issues = _validate_template_definition(row.template_id, definition)
|
||
issues.extend(_validate_template_asset(definition))
|
||
sample_ids = _sample_ids(data)
|
||
issues.extend(_validate_samples(sample_ids))
|
||
report = {"valid": not issues, "issues": issues, "sampleSnapshotIds": sample_ids,
|
||
"definitionHash": row.definition_hash,
|
||
"validatedAt": datetime.now().isoformat()}
|
||
row.validation_report_json = _dump(report)
|
||
row.lifecycle = "validated" if not issues else "draft"
|
||
db.session.commit()
|
||
if issues:
|
||
raise VersioningError("VALIDATION_FAILED", "模板版本校验未通过", report)
|
||
return row
|
||
|
||
|
||
def publish_template_version(version_id: int, user_id: str) -> PptTemplateVersion:
|
||
row = db.session.get(PptTemplateVersion, version_id)
|
||
if not row:
|
||
raise VersioningError("VERSION_NOT_FOUND", "模板版本不存在")
|
||
if row.lifecycle != "validated":
|
||
raise VersioningError("VERSION_NOT_VALIDATED", "只有通过校验的版本可以发布")
|
||
report = json.loads(row.validation_report_json or "{}")
|
||
if not report.get("valid") or not report.get("sampleSnapshotIds"):
|
||
raise VersioningError("GOLDEN_REQUIRED", "发布前必须通过至少一个已确认快照样本")
|
||
asset_issues = _validate_template_asset(row.definition())
|
||
if report.get("definitionHash") != row.definition_hash or canonical_json_hash(row.definition()) != row.definition_hash or asset_issues:
|
||
raise VersioningError("VALIDATION_STALE", "模板定义或资产在校验后发生变化,请重新校验", {"issues": asset_issues})
|
||
PptTemplateVersion.query.filter_by(template_id=row.template_id, lifecycle="published").update(
|
||
{"lifecycle": "retired"}, synchronize_session=False
|
||
)
|
||
row.lifecycle = "published"
|
||
row.published_by = user_id
|
||
row.published_at = datetime.now()
|
||
template = db.session.get(PptTemplate, row.template_id)
|
||
if template:
|
||
template.clone_ready = True
|
||
db.session.commit()
|
||
return row
|
||
|
||
|
||
def list_versions(model, filter_column, filter_value) -> list[dict]:
|
||
rows = model.query.filter(filter_column == filter_value).order_by(model.version.desc()).all()
|
||
return [row.to_public_dict() for row in rows]
|
||
|
||
|
||
def resolve_published_scenario(context: dict, explicit_version_id: int | None = None) -> dict:
|
||
query = PptScenarioVersion.query.filter_by(lifecycle="published")
|
||
rows = ([query.filter_by(id=explicit_version_id).first()] if explicit_version_id else query.all())
|
||
rows = [row for row in rows if row]
|
||
if explicit_version_id and not rows:
|
||
raise VersioningError("SCENARIO_VERSION_UNAVAILABLE", "指定场景版本不存在或尚未发布")
|
||
matches = []
|
||
for row in rows:
|
||
matched, trace, specificity = _selector_matches(row.definition()["selector"], context)
|
||
if matched:
|
||
priority = int(row.definition()["selector"].get("priority", 0))
|
||
matches.append((priority, specificity, row.version, row, trace))
|
||
if not matches:
|
||
raise VersioningError("SCENARIO_NO_MATCH", "没有已发布场景版本匹配当前材料")
|
||
matches.sort(key=lambda item: (-item[0], -item[1], -item[2], item[3].id))
|
||
if len(matches) > 1 and matches[0][:2] == matches[1][:2]:
|
||
raise VersioningError("SCENARIO_MULTI_MATCH", "存在多个同优先级场景,请调整 selector",
|
||
{"versionIds": [matches[0][3].id, matches[1][3].id]})
|
||
selected = matches[0]
|
||
return {"scenarioVersion": selected[3].to_public_dict(), "matchTrace": selected[4]}
|
||
|
||
|
||
def published_policy(code: str = "default") -> GenerationPolicyVersion | None:
|
||
return GenerationPolicyVersion.query.filter_by(code=code, lifecycle="published").first()
|
||
|
||
|
||
def published_template(template_id: str) -> PptTemplateVersion | None:
|
||
return PptTemplateVersion.query.filter_by(template_id=template_id, lifecycle="published").first()
|
||
|
||
|
||
def build_runtime_merge(scenario_version: dict, template_version: dict, policy_version: dict | None = None) -> dict:
|
||
"""按唯一规则把 pageSpecs 映射到已声明模板页槽位。"""
|
||
page_specs = scenario_version.get("pageSpecs") or []
|
||
page_slots = template_version.get("pageSlots") or []
|
||
available = list(page_slots)
|
||
merged = []
|
||
frame_map = []
|
||
business_shapes = []
|
||
capacity = template_version.get("capacityRules") or {}
|
||
conclusion_mode = ((policy_version or {}).get("conclusionPolicy") or {}).get("mode")
|
||
for index, page_spec in enumerate(page_specs):
|
||
page_type = page_spec.get("pageType")
|
||
if page_type == "conclusion" and conclusion_mode == "disabled":
|
||
raise VersioningError(
|
||
"POLICY_PAGE_CONFLICT", "生成策略已禁用结论页,但场景仍声明 conclusion pageSpec",
|
||
{"pageSpecIndex": index},
|
||
)
|
||
candidates = sorted(
|
||
[slot for slot in available if slot.get("pageType") == page_type],
|
||
key=lambda slot: (int(slot.get("order", 0)), str(slot.get("slotId"))),
|
||
)
|
||
if not candidates:
|
||
raise VersioningError(
|
||
"TEMPLATE_SLOT_MISSING", f"模板缺少页面类型 {page_type} 的语义槽位",
|
||
{"pageSpecIndex": index, "pageType": page_type},
|
||
)
|
||
slot = candidates[0]
|
||
available.remove(slot)
|
||
title = str(page_spec.get("title") or "")
|
||
max_title = capacity.get("maxTitleChars")
|
||
if max_title and len(title) > int(max_title):
|
||
raise VersioningError(
|
||
"TEMPLATE_CAPACITY_EXCEEDED", "页面标题超过模板容量",
|
||
{"pageSpecIndex": index, "actual": len(title), "maximum": int(max_title)},
|
||
)
|
||
shape_names = [
|
||
item.get("shapeName") for item in slot.get("shapeSlots", [])
|
||
if isinstance(item, dict) and item.get("shapeName")
|
||
]
|
||
merged.append({**page_spec, "templateSlotId": slot["slotId"]})
|
||
frame_map.append(int(slot["sourceSlideIndex"]))
|
||
business_shapes.append(shape_names)
|
||
return {
|
||
"pages": merged,
|
||
"templateConfig": {
|
||
"strictSemanticSlots": True,
|
||
"slidesConfig": merged,
|
||
"frameMap": frame_map,
|
||
"businessShapeNamesBySlide": business_shapes,
|
||
},
|
||
}
|
||
|
||
|
||
def build_reconciliation_manifest(
|
||
deck_contracts: list[dict], scenario_version: dict, policy_version: dict | None = None,
|
||
) -> list[dict]:
|
||
"""根据版本化 metricSpecs 生成最终输出必须出现的字段清单。"""
|
||
entries = []
|
||
years = set(scenario_version.get("comparisonYears") or [])
|
||
missing_mode = ((policy_version or {}).get("missingValuePolicy") or {}).get("mode", "block")
|
||
for product_index, contract in enumerate(deck_contracts):
|
||
currency = str((contract.get("policy") or {}).get("currency") or "").upper()
|
||
for spec in scenario_version.get("metricSpecs") or []:
|
||
if not spec.get("required", True):
|
||
continue
|
||
field_path = str(spec.get("fieldPath") or "")
|
||
values = _manifest_values(contract, field_path, years)
|
||
present_values = [(suffix, value) for suffix, value in values if value is not None]
|
||
if not present_values:
|
||
if missing_mode == "block":
|
||
raise VersioningError(
|
||
"REQUIRED_METRIC_MISSING", "发布场景要求的输出指标在冻结快照中缺失",
|
||
{"productIndex": product_index, "fieldPath": field_path},
|
||
)
|
||
if missing_mode == "mark_unavailable":
|
||
entries.append({
|
||
"key": f"products.{product_index}.{field_path}", "fieldPath": field_path,
|
||
"pageTypes": spec.get("pageTypes") or [], "currency": currency,
|
||
"value": None, "status": "unavailable",
|
||
})
|
||
continue
|
||
for suffix, value in present_values:
|
||
entries.append({
|
||
"key": f"products.{product_index}.{field_path}{suffix}",
|
||
"fieldPath": field_path,
|
||
"pageTypes": spec.get("pageTypes") or [],
|
||
"currency": currency,
|
||
"value": value,
|
||
})
|
||
return entries
|
||
|
||
|
||
def _manifest_values(contract: dict, field_path: str, years: set[int]):
|
||
if field_path.startswith("benefitRows[]."):
|
||
leaf = field_path.split("[].", 1)[1]
|
||
result = []
|
||
for row in contract.get("benefitRows") or []:
|
||
year = row.get("policyYear")
|
||
if years and year not in years:
|
||
continue
|
||
result.append((f"[year={year}]", row.get(leaf)))
|
||
return result
|
||
value = contract
|
||
for part in field_path.split("."):
|
||
if not isinstance(value, dict):
|
||
return []
|
||
value = value.get(part)
|
||
return [("", value)]
|
||
|
||
|
||
def _validate_scenario_definition(definition: dict) -> list[dict]:
|
||
issues = []
|
||
selector = definition.get("selector")
|
||
if not isinstance(selector, dict) or not selector:
|
||
issues.append(_issue("SELECTOR_REQUIRED", "selector 不能为空", "selector"))
|
||
elif "fileCount" in selector and not isinstance(selector["fileCount"], (int, dict)):
|
||
issues.append(_issue("FILE_COUNT_INVALID", "fileCount 必须是整数或 min/max 对象", "selector.fileCount"))
|
||
page_specs = definition.get("pageSpecs")
|
||
if not isinstance(page_specs, list) or not page_specs:
|
||
issues.append(_issue("PAGE_SPECS_REQUIRED", "pageSpecs 至少包含一页", "pageSpecs"))
|
||
else:
|
||
for index, spec in enumerate(page_specs):
|
||
if not isinstance(spec, dict) or not str(spec.get("pageType") or "").strip():
|
||
issues.append(_issue("PAGE_TYPE_REQUIRED", "每个页面规范必须包含 pageType", f"pageSpecs.{index}.pageType"))
|
||
years = definition.get("comparisonYears")
|
||
if not isinstance(years, list) or any(not isinstance(year, int) or year <= 0 for year in years):
|
||
issues.append(_issue("COMPARISON_YEARS_INVALID", "comparisonYears 必须是正整数数组", "comparisonYears"))
|
||
elif len(set(years)) != len(years):
|
||
issues.append(_issue("COMPARISON_YEARS_DUPLICATED", "comparisonYears 不能重复", "comparisonYears"))
|
||
metric_specs = definition.get("metricSpecs")
|
||
if not isinstance(metric_specs, list):
|
||
issues.append(_issue("METRIC_SPECS_INVALID", "metricSpecs 必须是数组", "metricSpecs"))
|
||
else:
|
||
for index, spec in enumerate(metric_specs):
|
||
spec = spec if isinstance(spec, dict) else {}
|
||
if not spec.get("code") or not spec.get("fieldPath"):
|
||
issues.append(_issue("METRIC_SPEC_INVALID", "每个指标必须包含 code 和 fieldPath", f"metricSpecs.{index}"))
|
||
if not isinstance(spec.get("pageTypes"), list) or not spec.get("pageTypes"):
|
||
issues.append(_issue("METRIC_PAGE_TYPES_REQUIRED", "指标必须声明出现在哪些 pageType", f"metricSpecs.{index}.pageTypes"))
|
||
return issues
|
||
|
||
|
||
def _validate_template_definition(template_id: str, definition: dict) -> list[dict]:
|
||
issues = []
|
||
template = db.session.get(PptTemplate, template_id)
|
||
slots = definition.get("pageSlots")
|
||
if not isinstance(slots, list) or not slots:
|
||
issues.append(_issue("PAGE_SLOTS_REQUIRED", "pageSlots 至少包含一个语义槽位", "pageSlots"))
|
||
else:
|
||
seen = set()
|
||
for index, slot in enumerate(slots):
|
||
page_type = slot.get("pageType") if isinstance(slot, dict) else None
|
||
slot_id = slot.get("slotId") if isinstance(slot, dict) else None
|
||
if not page_type or not slot_id:
|
||
issues.append(_issue("SLOT_INVALID", "每个槽位必须包含 slotId 和 pageType", f"pageSlots.{index}"))
|
||
elif slot_id in seen:
|
||
issues.append(_issue("SLOT_DUPLICATED", "slotId 不能重复", f"pageSlots.{index}.slotId"))
|
||
seen.add(slot_id)
|
||
source_index = slot.get("sourceSlideIndex") if isinstance(slot, dict) else None
|
||
if not isinstance(source_index, int) or source_index <= 0:
|
||
issues.append(_issue("SOURCE_SLIDE_INDEX_INVALID", "语义页槽位必须绑定正整数源页码", f"pageSlots.{index}.sourceSlideIndex"))
|
||
shape_slots = slot.get("shapeSlots") if isinstance(slot, dict) else None
|
||
if not isinstance(shape_slots, list) or not shape_slots:
|
||
issues.append(_issue("SHAPE_SLOTS_REQUIRED", "每个语义页至少声明一个业务 shape 槽位", f"pageSlots.{index}.shapeSlots"))
|
||
else:
|
||
for shape_index, shape in enumerate(shape_slots):
|
||
if not isinstance(shape, dict) or not shape.get("shapeName") or not shape.get("role"):
|
||
issues.append(_issue("SHAPE_SLOT_INVALID", "shape 槽位必须包含 shapeName 和 role", f"pageSlots.{index}.shapeSlots.{shape_index}"))
|
||
required = set((template.to_dict().get("requiredPageTypes") if template else []) or [])
|
||
covered = {slot.get("pageType") for slot in slots if isinstance(slot, dict)}
|
||
missing = sorted(required - covered)
|
||
if missing:
|
||
issues.append(_issue("REQUIRED_PAGE_TYPE_MISSING", "模板必需页面未映射语义槽位", "pageSlots", {"missing": missing}))
|
||
capacity = definition.get("capacityRules")
|
||
if not isinstance(capacity, dict) or not capacity:
|
||
issues.append(_issue("CAPACITY_RULES_REQUIRED", "capacityRules 不能为空", "capacityRules"))
|
||
elif any(not isinstance(value, int) or value <= 0 for value in capacity.values()):
|
||
issues.append(_issue("CAPACITY_RULES_INVALID", "容量规则必须是正整数", "capacityRules"))
|
||
scenario_ids = definition.get("supportedScenarioVersionIds")
|
||
if not isinstance(scenario_ids, list) or not scenario_ids:
|
||
issues.append(_issue("SUPPORTED_SCENARIOS_REQUIRED", "必须声明支持的已发布场景版本", "supportedScenarioVersionIds"))
|
||
else:
|
||
scenario_rows = PptScenarioVersion.query.filter(
|
||
PptScenarioVersion.id.in_(scenario_ids), PptScenarioVersion.lifecycle == "published"
|
||
).all()
|
||
if len(scenario_rows) != len(set(scenario_ids)):
|
||
issues.append(_issue("SCENARIO_VERSION_UNPUBLISHED", "存在未发布或不存在的场景版本", "supportedScenarioVersionIds"))
|
||
covered = {slot.get("pageType") for slot in slots if isinstance(slot, dict)} if isinstance(slots, list) else set()
|
||
for scenario in scenario_rows:
|
||
required_types = {item.get("pageType") for item in scenario.definition()["pageSpecs"] if isinstance(item, dict)}
|
||
missing = sorted(required_types - covered)
|
||
if missing:
|
||
issues.append(_issue(
|
||
"SCENARIO_PAGE_SLOT_MISSING", "模板无法承载所声明支持场景的全部页面",
|
||
"supportedScenarioVersionIds", {"scenarioVersionId": scenario.id, "missing": missing},
|
||
))
|
||
return issues
|
||
|
||
|
||
def _validate_template_asset(definition: dict) -> list[dict]:
|
||
from insurance.ppt.template_asset_service import resolve_template_asset, template_asset_sha256
|
||
|
||
issues = []
|
||
try:
|
||
path = resolve_template_asset(definition.get("assetId"))
|
||
actual_hash = template_asset_sha256(path) if path else None
|
||
except (OSError, ValueError):
|
||
path = None
|
||
actual_hash = None
|
||
if not path or actual_hash != definition.get("assetSha256"):
|
||
return [_issue("TEMPLATE_ASSET_INVALID", "模板源资产缺失或 SHA-256 不一致", "assetSha256")]
|
||
try:
|
||
shape_names, fonts = _inspect_pptx_asset(path)
|
||
except (OSError, zipfile.BadZipFile, ElementTree.ParseError):
|
||
return [_issue("TEMPLATE_ASSET_UNREADABLE", "模板源资产无法读取", "assetId")]
|
||
used_pages = set()
|
||
for index, slot in enumerate(definition.get("pageSlots") or []):
|
||
source_index = slot.get("sourceSlideIndex")
|
||
if not isinstance(source_index, int) or not 1 <= source_index <= len(shape_names):
|
||
issues.append(_issue("SOURCE_SLIDE_OUT_OF_RANGE", "语义页槽位引用的源页不存在", f"pageSlots.{index}.sourceSlideIndex"))
|
||
continue
|
||
if source_index in used_pages:
|
||
issues.append(_issue("SOURCE_SLIDE_DUPLICATED", "同一源页面不能映射到多个输出页槽位", f"pageSlots.{index}.sourceSlideIndex"))
|
||
used_pages.add(source_index)
|
||
available = shape_names[source_index - 1]
|
||
for shape_index, shape in enumerate(slot.get("shapeSlots") or []):
|
||
if shape.get("shapeName") not in available:
|
||
issues.append(_issue(
|
||
"BUSINESS_SHAPE_NOT_FOUND", "声明的业务 shape 在源页面中不存在",
|
||
f"pageSlots.{index}.shapeSlots.{shape_index}.shapeName",
|
||
{"shapeName": shape.get("shapeName"), "sourceSlideIndex": source_index},
|
||
))
|
||
required_fonts = definition.get("theme", {}).get("requiredFonts") or []
|
||
missing_fonts = sorted(set(required_fonts) - fonts)
|
||
if missing_fonts:
|
||
issues.append(_issue("TEMPLATE_FONT_MISSING", "模板缺少声明的必需字体", "theme.requiredFonts", {"missing": missing_fonts}))
|
||
return issues
|
||
|
||
|
||
def _inspect_pptx_asset(path: str) -> tuple[list[set[str]], set[str]]:
|
||
slide_pattern = re.compile(r"ppt/slides/slide(\d+)\.xml")
|
||
shape_names = []
|
||
fonts = set()
|
||
with zipfile.ZipFile(path) as archive:
|
||
slide_files = sorted(
|
||
(name for name in archive.namelist() if slide_pattern.fullmatch(name)),
|
||
key=lambda name: int(slide_pattern.fullmatch(name).group(1)),
|
||
)
|
||
for name in slide_files:
|
||
root = ElementTree.fromstring(archive.read(name))
|
||
shape_names.append({
|
||
node.attrib.get("name") for node in root.iter()
|
||
if node.tag.endswith("}cNvPr") and node.attrib.get("name")
|
||
})
|
||
fonts.update(
|
||
node.attrib.get("typeface") for node in root.iter()
|
||
if node.attrib.get("typeface")
|
||
)
|
||
for name in archive.namelist():
|
||
if name.startswith("ppt/theme/") and name.endswith(".xml"):
|
||
root = ElementTree.fromstring(archive.read(name))
|
||
fonts.update(
|
||
node.attrib.get("typeface") for node in root.iter()
|
||
if node.attrib.get("typeface")
|
||
)
|
||
return shape_names, {value for value in fonts if value}
|
||
|
||
|
||
def _sample_ids(data: dict | None) -> list[int]:
|
||
values = (data or {}).get("sampleSnapshotIds") or []
|
||
return list(dict.fromkeys(int(value) for value in values if str(value).isdigit()))
|
||
|
||
|
||
def _validate_samples(sample_ids: list[int]) -> list[dict]:
|
||
if not sample_ids:
|
||
return [_issue("GOLDEN_REQUIRED", "至少选择一个已确认快照作为发布样本", "sampleSnapshotIds")]
|
||
from insurance.models.plan_snapshot import InsurancePlanSnapshot
|
||
count = InsurancePlanSnapshot.query.filter(
|
||
InsurancePlanSnapshot.id.in_(sample_ids),
|
||
InsurancePlanSnapshot.status == "confirmed",
|
||
InsurancePlanSnapshot.is_golden.is_(True),
|
||
).count()
|
||
if count != len(sample_ids):
|
||
return [_issue("GOLDEN_INVALID", "样本快照不存在、尚未确认或未获批为脱敏金标准", "sampleSnapshotIds")]
|
||
return []
|
||
|
||
|
||
def _selector_matches(selector: dict, context: dict) -> tuple[bool, list[dict], int]:
|
||
trace = []
|
||
specificity = 0
|
||
checks = {
|
||
"fileCount": context.get("fileCount", len(context.get("planTypes") or [])),
|
||
"planTypes": context.get("planTypes") or [],
|
||
"companyIds": context.get("companyIds") or [],
|
||
"productIds": context.get("productIds") or [],
|
||
}
|
||
for key, actual in checks.items():
|
||
expected = selector.get(key)
|
||
if expected is None:
|
||
continue
|
||
specificity += 1
|
||
if key == "fileCount":
|
||
if isinstance(expected, int):
|
||
ok = actual == expected
|
||
else:
|
||
ok = int(expected.get("min", 0)) <= actual <= int(expected.get("max", 10 ** 9))
|
||
else:
|
||
mode = str(selector.get(f"{key}Mode") or "all")
|
||
expected_set, actual_set = set(expected), set(actual)
|
||
ok = bool(expected_set & actual_set) if mode == "any" else expected_set.issubset(actual_set)
|
||
trace.append({"field": key, "expected": expected, "actual": actual, "matched": ok})
|
||
if not ok:
|
||
return False, trace, specificity
|
||
return True, trace, specificity
|
||
|
||
|
||
def _issue(code: str, message: str, path: str, details: dict | None = None) -> dict:
|
||
result = {"code": code, "message": message, "path": path}
|
||
if details:
|
||
result["details"] = details
|
||
return result
|