baodan/api/insurance/ppt/versioning.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

683 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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