baodan/api/insurance/admin/ppt_quality_service.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

151 lines
7.1 KiB
Python

"""PPT/海报链路的脱敏质量指标。"""
import json
from collections import Counter
from insurance.models.generation_task import GenerationTask
from insurance.models.insurance_document import (
InsuranceDocument, InsuranceDocumentPage, InsuranceDocumentTable,
)
from insurance.models.plan_snapshot import InsuranceFieldEvidence, InsurancePlanOverride, InsurancePlanSnapshot
from insurance.models.ppt_version import GenerationPolicyVersion, PptScenarioVersion, PptTemplateVersion
def quality_summary() -> dict:
documents = InsuranceDocument.query.all()
snapshots = InsurancePlanSnapshot.query.all()
tasks = GenerationTask.query.filter(GenerationTask.artifact_type.in_(["ppt", "poster"])).all()
pages = InsuranceDocumentPage.query.all()
tables = InsuranceDocumentTable.query.all()
reconciliation = Counter()
visual_checks = Counter()
companies = Counter()
products = Counter()
renderer_versions = Counter()
version_sets = Counter()
queue_seconds = []
execution_seconds = []
for task in tasks:
report = _json(task.output_reconciliation_json)
reconciliation[(report or {}).get("status") or "not_run"] += 1
visual = _json(task.visual_check_json)
visual_checks[(visual or {}).get("status") or "not_run"] += 1
snapshot = _json(task.input_snapshot_json) or {}
companies.update(str(value) for value in snapshot.get("companyIds") or [] if value)
products.update(str(value) for value in snapshot.get("productIds") or [] if value)
renderer_versions[task.renderer_version or "unknown"] += 1
version_sets[
f"s:{task.scenario_version_id or '-'}|p:{task.policy_version_id or '-'}|t:{task.template_version_id or '-'}"
] += 1
if task.created_at and task.started_at:
queue_seconds.append(max(0, (task.started_at - task.created_at).total_seconds()))
if task.started_at and task.finished_at:
execution_seconds.append(max(0, (task.finished_at - task.started_at).total_seconds()))
overrides = InsurancePlanOverride.query.all()
evidence_count = InsuranceFieldEvidence.query.count()
native_pages = sum(1 for item in pages if item.page_class == "native")
ocr_pages = sum(1 for item in pages if item.ocr_blocks_json not in (None, "", "[]"))
low_quality_pages = sum(1 for item in pages if item.page_class in {"scanned", "low_quality", "mixed"})
cross_page_tables = 0
table_conflicts = 0
for table in tables:
source_pages = _json(table.source_pages_json) or []
conflicts = _json(table.conflicts_json) or []
if table.continuation_of or len(source_pages) > 1:
cross_page_tables += 1
table_conflicts += len(conflicts) if isinstance(conflicts, list) else 0
return {
"documents": {
"total": len(documents),
"byStatus": dict(Counter(item.status for item in documents)),
"byPdfKind": dict(Counter(item.pdf_kind for item in documents)),
"byProfile": dict(Counter(item.profile_code or "unprofiled" for item in documents)),
"byParserVersion": dict(Counter(item.parser_version for item in documents)),
"pages": {
"total": len(pages), "native": native_pages, "ocr": ocr_pages,
"lowQuality": low_quality_pages,
},
"tables": {
"total": len(tables), "crossPage": cross_page_tables,
"conflicts": table_conflicts,
},
},
"snapshots": {
"total": len(snapshots),
"byStatus": dict(Counter(item.status for item in snapshots)),
"goldenCount": sum(1 for item in snapshots if item.is_golden),
"evidenceCount": evidence_count,
"overrideCount": len(overrides),
"overridesByField": dict(Counter(item.field_path for item in overrides)),
},
"versions": {
"scenarios": dict(Counter(item.lifecycle for item in PptScenarioVersion.query.all())),
"policies": dict(Counter(item.lifecycle for item in GenerationPolicyVersion.query.all())),
"templates": dict(Counter(item.lifecycle for item in PptTemplateVersion.query.all())),
},
"generation": {
"total": len(tasks),
"byArtifact": dict(Counter(item.artifact_type for item in tasks)),
"byStatus": dict(Counter(item.status for item in tasks)),
"byResultState": dict(Counter(item.result_state or "current" for item in tasks)),
"reconciliation": dict(reconciliation),
"visualChecks": dict(visual_checks),
"retriedTaskCount": sum(1 for item in tasks if (item.attempt_count or 0) > 0),
"activeWithoutHeartbeatCount": sum(
1 for item in tasks if item.status == "running" and not item.heartbeat_at
),
"byCompany": dict(companies),
"byProduct": dict(products),
"byRendererVersion": dict(renderer_versions),
"byVersionSet": dict(version_sets),
"latencySeconds": {
"queueAverage": _average(queue_seconds),
"queueP95": _percentile(queue_seconds, 0.95),
"executionAverage": _average(execution_seconds),
"executionP95": _percentile(execution_seconds, 0.95),
},
},
}
def quality_alerts(metrics: dict) -> list[dict]:
"""把关键发布阻断指标转换为不含敏感正文的结构化告警。"""
alerts = []
generation = metrics.get("generation") or {}
documents = metrics.get("documents") or {}
snapshots = metrics.get("snapshots") or {}
checks = (
("OUTPUT_RECONCILIATION_FAILED", "critical", (generation.get("reconciliation") or {}).get("failed", 0), "存在输出对账失败任务"),
("VISUAL_CHECK_FAILED", "critical", (generation.get("visualChecks") or {}).get("failed", 0), "存在视觉门禁失败任务"),
("RUNNING_WITHOUT_HEARTBEAT", "critical", generation.get("activeWithoutHeartbeatCount", 0), "存在运行中但没有心跳的任务"),
("DOCUMENT_BLOCKED", "warning", (documents.get("byStatus") or {}).get("blocked", 0), "存在被解析门禁阻断的文档"),
)
for code, severity, count, message in checks:
if int(count or 0) > 0:
alerts.append({"code": code, "severity": severity, "count": int(count), "message": message})
if int(snapshots.get("goldenCount") or 0) < 30:
alerts.append({
"code": "GOLDEN_SAMPLE_TARGET_NOT_MET", "severity": "warning",
"count": int(snapshots.get("goldenCount") or 0),
"message": "金标准样本少于正式切换要求的 30 份",
})
return alerts
def _json(value):
try:
return json.loads(value) if value else None
except (TypeError, ValueError):
return None
def _average(values):
return round(sum(values) / len(values), 3) if values else None
def _percentile(values, percentile):
if not values:
return None
ordered = sorted(values)
index = min(len(ordered) - 1, max(0, int((len(ordered) - 1) * percentile)))
return round(ordered[index], 3)