阶段 当前状态 说明 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)。
151 lines
7.1 KiB
Python
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)
|