baodan/tests/ppt_versioning_retention_test.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

282 lines
12 KiB
Python

import sys
from datetime import datetime, timedelta
from pathlib import Path
import pytest
from flask import Flask
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "api"))
def _app():
from insurance.db.database import db
import insurance.models # noqa: F401
app = Flask(__name__)
app.config.update(
SQLALCHEMY_DATABASE_URI="sqlite:///:memory:",
SQLALCHEMY_TRACK_MODIFICATIONS=False,
)
db.init_app(app)
return app
def _seed_snapshot():
from insurance.db.compat import db
from insurance.models.insurance_document import InsuranceDocument
from insurance.models.plan_snapshot import InsurancePlanSnapshot
document = InsuranceDocument(
user_id="user-1", sha256="a" * 64, storage_key="plans/a.pdf",
original_name="a.pdf", mime_type="application/pdf", file_size=10,
page_count=1, pdf_kind="native", status="parsed", parser_version="test",
)
db.session.add(document)
db.session.flush()
snapshot = InsurancePlanSnapshot(
user_id="user-1", document_id=document.id, snapshot_version=1,
status="confirmed", plan_data_json="{}", validation_results_json='{"valid":true}',
snapshot_hash="b" * 64, document_sha256=document.sha256,
confirmed_by="user-1", confirmed_at=datetime.now(),
is_golden=True, golden_approved_by="admin", golden_approved_at=datetime.now(),
)
db.session.add(snapshot)
db.session.commit()
return snapshot
def _scenario_definition(priority=10):
return {
"selector": {"priority": priority, "fileCount": 1, "planTypes": ["savings"]},
"pageSpecs": [{"pageType": "cover"}, {"pageType": "chart"}],
"metricSpecs": [{
"code": "surrender_value", "fieldPath": "benefitRows[].totalSurrenderValue",
"pageTypes": ["chart"], "required": True,
}],
"comparisonYears": [10, 20, 30],
"compatibilityRules": {"planTypes": ["savings"]},
}
def test_version_lifecycle_publish_gate_and_deterministic_multi_match():
from insurance.db.compat import db
from insurance.models.ppt_config import PptScenario
from insurance.ppt.versioning import (
VersioningError, create_scenario_version, publish_scenario_version,
resolve_published_scenario, validate_scenario_version,
)
app = _app()
with app.app_context():
db.create_all()
snapshot = _seed_snapshot()
for code in ("retirement", "wealth"):
db.session.add(PptScenario(code=code, name=code, generation_mode="single"))
db.session.commit()
first = create_scenario_version("retirement", _scenario_definition(), "admin")
with pytest.raises(VersioningError) as no_golden:
validate_scenario_version(first.id, {})
assert no_golden.value.code == "VALIDATION_FAILED"
first = validate_scenario_version(first.id, {"sampleSnapshotIds": [snapshot.id]})
assert first.lifecycle == "validated"
first = publish_scenario_version(first.id, "admin")
assert first.lifecycle == "published"
second = create_scenario_version("wealth", _scenario_definition(), "admin")
validate_scenario_version(second.id, {"sampleSnapshotIds": [snapshot.id]})
publish_scenario_version(second.id, "admin")
with pytest.raises(VersioningError) as ambiguous:
resolve_published_scenario({"fileCount": 1, "planTypes": ["savings"]})
assert ambiguous.value.code == "SCENARIO_MULTI_MATCH"
def test_template_and_policy_versions_publish_only_after_validation():
from insurance.db.compat import db
from insurance.models.ppt_config import PptScenario, PptTemplate
from insurance.ppt.versioning import (
_inspect_pptx_asset,
create_policy_version, create_scenario_version, create_template_version,
publish_policy_version, publish_scenario_version, publish_template_version,
validate_policy_version, validate_scenario_version, validate_template_version,
)
app = _app()
with app.app_context():
db.create_all()
snapshot = _seed_snapshot()
from insurance.ppt.template_asset_service import resolve_template_asset, template_asset_sha256
asset_id = "builtin://single_savings.pptx"
asset_path = resolve_template_asset(asset_id)
asset_hash = template_asset_sha256(asset_path)
shape_names, _ = _inspect_pptx_asset(asset_path)
cover_shape = sorted(shape_names[0])[0]
chart_shape = sorted(shape_names[1])[0]
db.session.add(PptScenario(code="single", name="single", generation_mode="single"))
db.session.add(PptTemplate(
id="broker", plan_type="savings", style_preset="broker",
source_template_asset_id=asset_id, asset_sha256=asset_hash,
required_page_types_json='["cover"]',
))
db.session.commit()
scenario = create_scenario_version("single", _scenario_definition(), "admin")
validate_scenario_version(scenario.id, {"sampleSnapshotIds": [snapshot.id]})
publish_scenario_version(scenario.id, "admin")
policy = create_policy_version("default", {
"calculationPolicy": {"totals": "contract_only", "derivedMetrics": []},
"missingValuePolicy": {"mode": "block"},
"conclusionPolicy": {"mode": "rules_only"},
}, "admin")
validate_policy_version(policy.id)
assert publish_policy_version(policy.id, "admin").lifecycle == "published"
template = create_template_version("broker", {
"pageSlots": [{
"slotId": "cover-1", "pageType": "cover", "sourceSlideIndex": 1,
"shapeSlots": [{"shapeName": cover_shape, "role": "title"}],
}, {
"slotId": "chart-1", "pageType": "chart", "sourceSlideIndex": 2,
"shapeSlots": [{"shapeName": chart_shape, "role": "chart"}],
}],
"capacityRules": {"maxTitleChars": 40},
"supportedScenarioVersionIds": [scenario.id],
}, "admin")
validate_template_version(template.id, {"sampleSnapshotIds": [snapshot.id]})
assert publish_template_version(template.id, "admin").lifecycle == "published"
def test_retention_dry_run_records_audit_without_deleting(monkeypatch):
from insurance.db.compat import db
from insurance.models.insurance_document import InsuranceDocument
from insurance.models.retention_audit import RetentionCleanupAudit
from insurance.retention.service import run_cleanup
app = _app()
with app.app_context():
db.create_all()
monkeypatch.setenv("INSURANCE_DOCUMENT_RETENTION_DAYS", "30")
document = InsuranceDocument(
user_id="user-1", sha256="d" * 64, storage_key="plans/expired.pdf",
original_name="expired.pdf", mime_type="application/pdf", file_size=10,
page_count=1, pdf_kind="native", status="parsed", parser_version="test",
expires_at=datetime.now() - timedelta(days=1),
)
db.session.add(document)
db.session.commit()
document_id = document.id
result = run_cleanup(dry_run=True)
assert result["count"] == 1
assert db.session.get(InsuranceDocument, document_id) is not None
assert RetentionCleanupAudit.query.one().action == "dry_run"
def test_retention_orphan_scan_is_read_only_and_uses_relative_paths(monkeypatch, tmp_path):
from insurance.db.compat import db
from insurance.models.insurance_document import InsuranceDocument
from insurance.retention.service import find_orphan_files
app = _app()
with app.app_context():
db.create_all()
monkeypatch.setenv("INSURANCE_STORAGE_ROOT", str(tmp_path))
referenced = tmp_path / "plans" / "kept.pdf"
orphan = tmp_path / "outputs" / "unused.png"
referenced.parent.mkdir(parents=True)
orphan.parent.mkdir(parents=True)
referenced.write_bytes(b"pdf")
orphan.write_bytes(b"png")
db.session.add(InsuranceDocument(
user_id="user-1", sha256="e" * 64, storage_key="plans/kept.pdf",
original_name="kept.pdf", mime_type="application/pdf", file_size=3,
page_count=1, pdf_kind="native", status="parsed", parser_version="test",
))
db.session.commit()
result = find_orphan_files()
assert result["orphanCount"] == 1
assert result["items"][0]["relativePath"] == "outputs/unused.png"
assert orphan.exists()
def test_runtime_merge_is_exact_and_capacity_fails_closed():
from insurance.ppt.versioning import VersioningError, build_reconciliation_manifest, build_runtime_merge
scenario = {"pageSpecs": [
{"pageType": "cover", "title": "方案"},
{"pageType": "chart", "title": "利益趋势"},
]}
template = {
"capacityRules": {"maxTitleChars": 8},
"pageSlots": [
{"slotId": "chart", "pageType": "chart", "sourceSlideIndex": 4,
"shapeSlots": [{"shapeName": "Chart", "role": "chart"}]},
{"slotId": "cover", "pageType": "cover", "sourceSlideIndex": 1,
"shapeSlots": [{"shapeName": "Title", "role": "title"}]},
],
}
result = build_runtime_merge(scenario, template)
assert result["templateConfig"]["frameMap"] == [1, 4]
assert result["templateConfig"]["businessShapeNamesBySlide"] == [["Title"], ["Chart"]]
with pytest.raises(VersioningError) as overflow:
build_runtime_merge({"pageSpecs": [{"pageType": "cover", "title": "超过容量的标题文字"}]}, template)
assert overflow.value.code == "TEMPLATE_CAPACITY_EXCEEDED"
with pytest.raises(VersioningError) as missing:
build_reconciliation_manifest(
[{"policy": {"currency": "HKD"}}],
{"comparisonYears": [10], "metricSpecs": [{
"fieldPath": "policy.annualPremium", "pageTypes": ["chart"], "required": True,
}]},
{"missingValuePolicy": {"mode": "block"}},
)
assert missing.value.code == "REQUIRED_METRIC_MISSING"
def test_replay_reuses_frozen_input(monkeypatch):
from insurance.db.compat import db
from insurance.generation import task_service
from insurance.models.generation_task import GenerationTask
app = _app()
with app.app_context():
db.create_all()
original = GenerationTask(
id="old-task", user_id="user-1", artifact_type="ppt", operation="generate",
workspace_id="workspace-1", status="failed", input_revision=3, submit_revision=3,
input_snapshot_json='{"snapshotHash":"abc","deckContracts":[{"kind":"savings"}]}'
)
db.session.add(original)
db.session.commit()
captured = {}
def fake_create_task(**kwargs):
captured.update(kwargs)
return {"code": 0, "data": {"id": "new-task"}}
monkeypatch.setattr(task_service, "create_task", fake_create_task)
result = task_service.replay_task(original.id, "user-1")
assert result["code"] == 0
assert captured["input_snapshot"]["deckContracts"] == [{"kind": "savings"}]
assert captured["input_snapshot"]["replay"]["sourceTaskId"] == "old-task"
assert captured["input_revision"] == 3
def test_feature_flag_rollout_is_stable_and_profile_scoped(monkeypatch):
from insurance.generation.feature_flags import enabled_for
monkeypatch.setenv("INSURANCE_SCENARIO_ENGINE_V2", "true")
monkeypatch.setenv("INSURANCE_SCENARIO_ENGINE_V2_PERCENT", "50")
monkeypatch.setenv("INSURANCE_SCENARIO_ENGINE_V2_PROFILES", "company-a")
first = enabled_for("SCENARIO_ENGINE_V2", identity="user-1", profile="company-a")
assert enabled_for("SCENARIO_ENGINE_V2", identity="user-1", profile="company-a") is first
assert enabled_for("SCENARIO_ENGINE_V2", identity="user-1", profile="company-b") is False