阶段 当前状态 说明 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)。
224 lines
7.2 KiB
Python
224 lines
7.2 KiB
Python
"""DEPRECATED:旧线程式 PPT 解析器,仅保留历史只读兼容。
|
||
|
||
新任务统一使用 ``insurance.generation.celery_tasks.parse_ppt_task``。
|
||
本模块已确认无生产路由引用;禁止新增调用,待独立清理变更删除。
|
||
"""
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import threading
|
||
from datetime import datetime
|
||
|
||
from insurance.db.compat import db
|
||
from insurance.models.ppt_session import PptSession
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_local_locks: set[str] = set()
|
||
_local_locks_guard = threading.Lock()
|
||
_redis_locks: set[str] = set()
|
||
|
||
STALE_TASK_TIMEOUT = 600 # 10 分钟
|
||
DEPRECATED = True
|
||
|
||
|
||
def recover_stale_tasks():
|
||
"""启动时将长时间 parsing 的会话标记为 failed。"""
|
||
from datetime import timedelta
|
||
cutoff = datetime.now() - timedelta(seconds=STALE_TASK_TIMEOUT)
|
||
|
||
stale = PptSession.query.filter(
|
||
PptSession.status.in_(["parsing"]),
|
||
PptSession.created_at < cutoff,
|
||
).all()
|
||
for session in stale:
|
||
session.status = "error"
|
||
session.parse_error = "任务因服务重启而中断,请重新处理"
|
||
session.parse_finished_at = datetime.now()
|
||
logger.warning(f"恢复过期 PPT 解析任务: session_id={session.id}")
|
||
|
||
if stale:
|
||
db.session.commit()
|
||
logger.info(f"已恢复 {len(stale)} 个过期 PPT 解析任务")
|
||
|
||
|
||
def start_parse_task(app, session_id: str, user_id: str) -> bool:
|
||
"""启动后台解析任务,返回是否新启动。"""
|
||
if not _acquire_task_lock(session_id):
|
||
return False
|
||
|
||
thread = threading.Thread(
|
||
target=_run_parse_task,
|
||
args=(app, session_id, user_id),
|
||
daemon=True,
|
||
)
|
||
thread.start()
|
||
return True
|
||
|
||
|
||
def _run_parse_task(app, session_id: str, user_id: str):
|
||
with app.app_context():
|
||
try:
|
||
_execute_parse(session_id, user_id)
|
||
except Exception as exc:
|
||
logger.error(f"PPT 解析后台任务失败 [{session_id}]: {exc}", exc_info=True)
|
||
_mark_session_failed(session_id, str(exc))
|
||
finally:
|
||
_release_task_lock(session_id)
|
||
db.session.remove()
|
||
|
||
|
||
def _execute_parse(session_id: str, user_id: str):
|
||
from insurance.ppt.extraction import ExtractionOrchestrator
|
||
|
||
session = PptSession.query.filter_by(id=session_id, user_id=user_id).first()
|
||
if not session:
|
||
return
|
||
|
||
files = json.loads(session.files_json) if session.files_json else []
|
||
if not files:
|
||
_mark_session_failed(session_id, "没有可处理的 PDF 文件")
|
||
return
|
||
|
||
orchestrator = ExtractionOrchestrator()
|
||
extractions = []
|
||
total = len(files)
|
||
|
||
session.status = "parsing"
|
||
session.parse_progress = 0
|
||
session.parse_message = "数据结构化任务已启动"
|
||
session.parse_error = None
|
||
session.parse_started_at = datetime.now()
|
||
session.parse_finished_at = None
|
||
session.extractions_json = json.dumps([], ensure_ascii=False)
|
||
db.session.commit()
|
||
|
||
for index, file_info in enumerate(files, start=1):
|
||
filename = file_info.get("name", "")
|
||
filepath = file_info.get("path", "")
|
||
plan_type = file_info.get("type", "savings")
|
||
|
||
_update_progress(
|
||
session_id,
|
||
progress=_progress(index - 1, total),
|
||
message=f"正在处理 {filename or f'第 {index} 个文件'}",
|
||
extractions=extractions,
|
||
)
|
||
|
||
try:
|
||
result = asyncio.run(orchestrator.extract_plan(filepath, plan_type, force_reparse=True))
|
||
extractions.append(_build_extraction(file_info, filepath, result))
|
||
except Exception as exc:
|
||
logger.error(f"PDF 解析失败 [{filename}]: {exc}", exc_info=True)
|
||
extractions.append({
|
||
"pdfName": filename,
|
||
"pdfPath": filepath,
|
||
"planType": plan_type,
|
||
"status": "error",
|
||
"productName": "unknown",
|
||
"data": None,
|
||
"error": str(exc),
|
||
"yearCount": 0,
|
||
})
|
||
|
||
_update_progress(
|
||
session_id,
|
||
progress=_progress(index, total),
|
||
message=f"已完成 {index}/{total} 个文件",
|
||
extractions=extractions,
|
||
)
|
||
|
||
session = PptSession.query.filter_by(id=session_id, user_id=user_id).first()
|
||
if not session:
|
||
return
|
||
|
||
all_failed = all(e.get("status") == "error" for e in extractions)
|
||
partial_count = sum(1 for e in extractions if e.get("status") == "partial")
|
||
session.extractions_json = json.dumps(extractions, ensure_ascii=False)
|
||
session.status = "error" if all_failed else "parsed"
|
||
session.parse_progress = 100
|
||
if all_failed:
|
||
session.parse_message = "处理失败"
|
||
elif partial_count:
|
||
session.parse_message = f"处理完成,{partial_count} 个文件需补充数据"
|
||
else:
|
||
session.parse_message = "处理完成"
|
||
session.parse_error = "所有文件均处理失败" if all_failed else None
|
||
session.parse_finished_at = datetime.now()
|
||
db.session.commit()
|
||
|
||
|
||
def _build_extraction(file_info: dict, filepath: str, result) -> dict:
|
||
return {
|
||
"pdfName": file_info.get("name", ""),
|
||
"pdfPath": filepath,
|
||
"planType": result.plan_type,
|
||
"status": result.status,
|
||
"productName": result.product_name,
|
||
"data": result.data,
|
||
"error": result.error,
|
||
"yearCount": len(result.data.get("benefit_illustration", [])) if result.data else 0,
|
||
}
|
||
|
||
|
||
def _update_progress(session_id: str, progress: int, message: str, extractions: list[dict]):
|
||
session = PptSession.query.filter_by(id=session_id).first()
|
||
if not session:
|
||
return
|
||
session.status = "parsing"
|
||
session.parse_progress = progress
|
||
session.parse_message = message
|
||
session.extractions_json = json.dumps(extractions, ensure_ascii=False)
|
||
db.session.commit()
|
||
|
||
|
||
def _mark_session_failed(session_id: str, error: str):
|
||
session = PptSession.query.filter_by(id=session_id).first()
|
||
if not session:
|
||
return
|
||
session.status = "error"
|
||
session.parse_progress = 100
|
||
session.parse_message = "处理失败"
|
||
session.parse_error = error[:1000]
|
||
session.parse_finished_at = datetime.now()
|
||
db.session.commit()
|
||
|
||
|
||
def _progress(done: int, total: int) -> int:
|
||
if total <= 0:
|
||
return 0
|
||
return min(99, int(done / total * 100))
|
||
|
||
|
||
def _acquire_task_lock(session_id: str) -> bool:
|
||
redis_key = f"ppt_parse_lock:{session_id}"
|
||
try:
|
||
from insurance.db.compat import redis_client
|
||
if redis_client and redis_client.set(redis_key, "1", nx=True, ex=1800):
|
||
_redis_locks.add(session_id)
|
||
return True
|
||
if redis_client:
|
||
return False
|
||
except Exception:
|
||
pass
|
||
|
||
with _local_locks_guard:
|
||
if session_id in _local_locks:
|
||
return False
|
||
_local_locks.add(session_id)
|
||
return True
|
||
|
||
|
||
def _release_task_lock(session_id: str):
|
||
if session_id in _redis_locks:
|
||
try:
|
||
from insurance.db.compat import redis_client
|
||
if redis_client:
|
||
redis_client.delete(f"ppt_parse_lock:{session_id}")
|
||
except Exception:
|
||
pass
|
||
_redis_locks.discard(session_id)
|
||
|
||
with _local_locks_guard:
|
||
_local_locks.discard(session_id)
|