阶段 当前状态 说明 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)。
1898 lines
79 KiB
Python
1898 lines
79 KiB
Python
"""统一生成任务 Celery 任务定义。
|
||
|
||
使用 BaoDan 的 Celery 实例,通过 autodiscover 或显式导入注册。
|
||
任务通过数据库状态机保证幂等,不依赖 Celery 内置重试。
|
||
"""
|
||
import json
|
||
import logging
|
||
from datetime import datetime
|
||
|
||
from celery import shared_task
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _update_task_status(task_id: str, **kwargs):
|
||
"""更新任务状态(数据库层面)。"""
|
||
from insurance.db.compat import db
|
||
from insurance.models.generation_task import GenerationTask
|
||
|
||
task = GenerationTask.query.get(task_id)
|
||
if not task:
|
||
return
|
||
|
||
for key, value in kwargs.items():
|
||
if hasattr(task, key):
|
||
setattr(task, key, value)
|
||
|
||
task.heartbeat_at = datetime.now()
|
||
db.session.commit()
|
||
return task
|
||
|
||
|
||
def _claim_task(task_id: str) -> bool:
|
||
"""尝试领取任务(queued → running),返回是否成功。"""
|
||
from insurance.db.compat import db
|
||
from insurance.models.generation_task import GenerationTask
|
||
|
||
result = db.session.execute(
|
||
db.text("UPDATE insurance_generation_tasks SET status = 'running', "
|
||
"started_at = NOW(), heartbeat_at = NOW(), "
|
||
"attempt_count = attempt_count + 1 "
|
||
"WHERE id = :id AND status = 'queued'"),
|
||
{"id": task_id},
|
||
)
|
||
db.session.commit()
|
||
return result.rowcount > 0
|
||
|
||
|
||
def _retry_with_frozen_input(task_context, task_id: str, exc: Exception) -> bool:
|
||
"""把瞬时异常任务重新排队;输入快照保持不变。达到上限时返回 False。"""
|
||
retries = int(getattr(getattr(task_context, "request", None), "retries", 0) or 0)
|
||
max_retries = int(getattr(task_context, "max_retries", 0) or 0)
|
||
if retries >= max_retries:
|
||
return False
|
||
from insurance.db.compat import db
|
||
from insurance.models.generation_task import GenerationTask
|
||
|
||
task = db.session.get(GenerationTask, task_id)
|
||
if not task:
|
||
return False
|
||
task.status = "queued"
|
||
task.stage = "retry_wait"
|
||
task.message = f"任务异常,准备第 {retries + 2} 次尝试"
|
||
task.error_code = "retryable_error"
|
||
task.error_message = str(exc)[:1000]
|
||
task.finished_at = None
|
||
db.session.commit()
|
||
task_context.retry(exc=exc)
|
||
return True
|
||
|
||
|
||
@shared_task(bind=True, name="insurance.extract_document", max_retries=2, default_retry_delay=30)
|
||
def extract_document_task(self, task_id: str):
|
||
"""从已持久化 Document IR 创建 review_required PlanData 快照。"""
|
||
if not _claim_task(task_id):
|
||
return
|
||
import asyncio
|
||
|
||
from insurance.models.generation_task import GenerationTask
|
||
|
||
task = GenerationTask.query.get(task_id)
|
||
frozen_input = json.loads(task.input_snapshot_json or "{}") if task else {}
|
||
try:
|
||
document_id = int(frozen_input.get("documentId") or 0)
|
||
from insurance.document.service import get_owned_document, load_document_ir
|
||
|
||
document = get_owned_document(document_id, task.user_id)
|
||
if not document or document.sha256 != frozen_input.get("documentSha256"):
|
||
raise ValueError("文档不存在或版本已变化")
|
||
if document.status == "blocked":
|
||
raise ValueError("文档质量不足,无法进入字段提取")
|
||
_update_task_status(task_id, stage="extracting", progress=20, message="正在提取计划书字段")
|
||
document_ir = load_document_ir(document)
|
||
|
||
from insurance.ppt.extraction import ExtractionOrchestrator
|
||
|
||
orchestrator = ExtractionOrchestrator()
|
||
result = asyncio.run(orchestrator.extract_plan(
|
||
document.storage_key,
|
||
plan_type=frozen_input.get("planType"),
|
||
company_id=frozen_input.get("companyId") or "",
|
||
product_id=frozen_input.get("productId") or "",
|
||
force_reparse=True,
|
||
document_ir=document_ir,
|
||
))
|
||
if result.status == "error" or not result.data:
|
||
raise ValueError(result.error or "计划书字段提取失败")
|
||
from insurance.document.evidence import map_extraction_evidence
|
||
|
||
evidence = map_extraction_evidence(document.id, result.data, document_ir)
|
||
_update_task_status(task_id, stage="reconciling", progress=75, message="正在建立候选与证据")
|
||
from insurance.plan_data.service import create_draft_from_legacy
|
||
|
||
plan_snapshot = create_draft_from_legacy(
|
||
document.id,
|
||
task.user_id,
|
||
result.data,
|
||
evidence_entries=evidence,
|
||
)
|
||
_update_task_status(
|
||
task_id,
|
||
status="done",
|
||
stage="completed",
|
||
progress=100,
|
||
message="字段提取完成,等待人工复核",
|
||
output_json=json.dumps({
|
||
"documentId": document.id,
|
||
"snapshotId": plan_snapshot.id,
|
||
"snapshotHash": plan_snapshot.snapshot_hash,
|
||
"snapshotStatus": plan_snapshot.status,
|
||
"planType": result.plan_type,
|
||
"productName": result.product_name,
|
||
"validation": plan_snapshot.validation(),
|
||
}, ensure_ascii=False),
|
||
finished_at=datetime.now(),
|
||
)
|
||
except Exception as exc:
|
||
logger.error("Document IR 字段提取失败: task=%s error=%s", task_id, exc, exc_info=True)
|
||
_update_task_status(
|
||
task_id,
|
||
status="failed",
|
||
stage="failed",
|
||
progress=100,
|
||
error_code="document_extraction_failed",
|
||
error_message=str(exc)[:500],
|
||
message="字段提取失败",
|
||
finished_at=datetime.now(),
|
||
)
|
||
|
||
|
||
def _sync_session_progress(session_id: str, progress: int, message: str = None,
|
||
extractions: list = None, status: str = None,
|
||
error_message: str = None, workflow_step: str = None):
|
||
"""同步进度到 PptSession(前端轮询读取这张表)。"""
|
||
from insurance.db.compat import db
|
||
from insurance.models.ppt_session import PptSession
|
||
|
||
session = PptSession.query.get(session_id)
|
||
if not session:
|
||
return
|
||
|
||
session.parse_progress = progress
|
||
if message is not None:
|
||
session.parse_message = message
|
||
if extractions is not None:
|
||
session.extractions_json = json.dumps(extractions, ensure_ascii=False)
|
||
if status is not None:
|
||
session.status = status
|
||
if error_message is not None:
|
||
session.parse_error = error_message[:1000]
|
||
if workflow_step is not None:
|
||
session.workflow_step = workflow_step
|
||
if status in ("parsed", "error"):
|
||
session.parse_finished_at = datetime.now()
|
||
db.session.commit()
|
||
|
||
|
||
# ─── PPT 解析任务 ──────────────────────────────────────────
|
||
|
||
@shared_task(bind=True, name="insurance.parse_ppt", max_retries=3, default_retry_delay=30)
|
||
def parse_ppt_task(self, task_id: str):
|
||
"""PPT PDF 解析任务。
|
||
|
||
幂等:通过数据库状态机保证(queued → running → done/failed)。
|
||
"""
|
||
if not _claim_task(task_id):
|
||
logger.info(f"任务 {task_id} 已被领取或不在 queued 状态,跳过")
|
||
return
|
||
|
||
_update_task_status(task_id, stage="extracting", progress=5, message="开始解析 PDF")
|
||
|
||
try:
|
||
_execute_ppt_parse(task_id)
|
||
except Exception as exc:
|
||
logger.error(f"PPT 解析任务失败 [{task_id}]: {exc}", exc_info=True)
|
||
_update_task_status(task_id,
|
||
status="failed",
|
||
error_code="parse_error",
|
||
error_message=str(exc)[:1000],
|
||
finished_at=datetime.now())
|
||
# 同步错误状态到 PptSession
|
||
from insurance.models.generation_task import GenerationTask
|
||
task = GenerationTask.query.get(task_id)
|
||
if task:
|
||
_sync_session_progress(task.workspace_id, 100, "处理失败",
|
||
status="error", error_message=str(exc))
|
||
raise
|
||
|
||
|
||
def _execute_ppt_parse(task_id: str):
|
||
"""执行 PPT 解析逻辑。"""
|
||
import asyncio
|
||
import os
|
||
|
||
from insurance.db.compat import db
|
||
from insurance.models.generation_task import GenerationTask
|
||
from insurance.models.ppt_session import PptSession
|
||
from insurance.ppt.extraction import ExtractionOrchestrator
|
||
|
||
task = GenerationTask.query.get(task_id)
|
||
if not task:
|
||
return
|
||
|
||
session_id = task.workspace_id
|
||
session = PptSession.query.get(session_id)
|
||
if not session:
|
||
_update_task_status(task_id, status="failed", error_code="workspace_not_found",
|
||
error_message="工作区不存在", finished_at=datetime.now())
|
||
return
|
||
|
||
files = json.loads(session.files_json) if session.files_json else []
|
||
if not files:
|
||
_update_task_status(task_id, status="failed", error_code="no_files",
|
||
error_message="没有可解析的 PDF 文件", finished_at=datetime.now())
|
||
return
|
||
|
||
orchestrator = ExtractionOrchestrator()
|
||
extractions = []
|
||
total = len(files)
|
||
|
||
# 同步初始进度到 PptSession
|
||
_sync_session_progress(session_id, 5, "正在读取 PDF 文件", status="parsing", workflow_step="parsing")
|
||
|
||
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")
|
||
company_id = file_info.get("companyId", "")
|
||
product_id = file_info.get("productId", "")
|
||
|
||
# 查询产品先验信息(名称、别名)传入解析器
|
||
product_name_hint = ""
|
||
product_aliases = []
|
||
if product_id:
|
||
try:
|
||
from insurance.db.compat import db
|
||
from insurance.models.ppt_config import PptProduct
|
||
product = db.session.query(PptProduct).get(product_id)
|
||
if product:
|
||
product_name_hint = product.display_name or ""
|
||
product_aliases = json.loads(product.aliases_json) if product.aliases_json else []
|
||
except Exception as e:
|
||
logger.warning(f"[PPT解析] 查询产品信息失败: {e}")
|
||
|
||
progress = max(5, int((index - 1) / total * 90))
|
||
msg = f"正在解析 {filename or f'第 {index} 个文件'}"
|
||
_update_task_status(task_id, progress=progress, message=msg)
|
||
_sync_session_progress(session_id, progress, msg, extractions=extractions)
|
||
|
||
def report_file_progress(file_progress: int, stage_message: str):
|
||
overall = min(
|
||
95,
|
||
int(((index - 1) + max(0, min(file_progress, 100)) / 100) / total * 95),
|
||
)
|
||
_update_task_status(
|
||
task_id, stage="extracting", progress=overall, message=stage_message
|
||
)
|
||
_sync_session_progress(
|
||
session_id, overall, stage_message, extractions=extractions
|
||
)
|
||
|
||
logger.info(
|
||
f"[PPT解析] 开始文件 {index}/{total}: {filename or filepath} "
|
||
f"plan_type={plan_type}"
|
||
)
|
||
try:
|
||
from insurance.document.service import ingest_for_processing
|
||
|
||
document, ingest_result = ingest_for_processing(
|
||
filepath,
|
||
task.user_id,
|
||
original_name=filename,
|
||
profile_code=product_id or company_id or None,
|
||
profile_version="insurance-product-profile-v1" if product_id or company_id else None,
|
||
rules_version="insurance-extraction-rules-v1",
|
||
model_version=os.getenv("INSURANCE_LLM_MODEL_VERSION") or "runtime-configured",
|
||
prompt_version="insurance-ppt-prompts-v1",
|
||
)
|
||
result = asyncio.run(orchestrator.extract_plan(
|
||
filepath,
|
||
plan_type,
|
||
force_reparse=True,
|
||
progress_callback=report_file_progress,
|
||
company_id=company_id,
|
||
product_id=product_id,
|
||
product_name_hint=product_name_hint,
|
||
product_aliases=product_aliases,
|
||
document_ir=ingest_result.document_ir,
|
||
))
|
||
from insurance.document.evidence import map_extraction_evidence
|
||
evidence = map_extraction_evidence(
|
||
document.id,
|
||
result.data or {},
|
||
ingest_result.document_ir,
|
||
)
|
||
extractions.append({
|
||
"pdfName": filename,
|
||
"pdfPath": filepath,
|
||
"planType": result.plan_type,
|
||
"status": result.status,
|
||
"productName": result.product_name,
|
||
"companyId": company_id,
|
||
"productId": product_id,
|
||
"data": result.data,
|
||
"error": result.error,
|
||
"yearCount": len(result.data.get("benefit_illustration", [])) if result.data else 0,
|
||
"provenance": result.provenance,
|
||
"documentId": document.id,
|
||
"documentStatus": document.status,
|
||
"evidence": evidence,
|
||
})
|
||
logger.info(
|
||
f"[PPT解析] 完成文件 {index}/{total}: {filename or filepath} "
|
||
f"status={result.status} rows="
|
||
f"{len(result.data.get('benefit_illustration', [])) if result.data else 0} "
|
||
f"error={result.error or '-'}"
|
||
)
|
||
except Exception as exc:
|
||
logger.error(f"PDF 解析失败 [{filename}]: {exc}", exc_info=True)
|
||
from insurance.document.errors import DocumentIngestError
|
||
document_error = exc.to_dict() if isinstance(exc, DocumentIngestError) else None
|
||
extractions.append({
|
||
"pdfName": filename, "pdfPath": filepath, "planType": plan_type,
|
||
"companyId": company_id, "productId": product_id,
|
||
"status": "error", "productName": "unknown", "data": None,
|
||
"error": document_error["message"] if document_error else "PDF 解析失败,请重试",
|
||
"errorCode": document_error["code"] if document_error else "PDF_PARSE_FAILED",
|
||
"errorDetails": document_error["details"] if document_error else {},
|
||
"yearCount": 0,
|
||
})
|
||
|
||
done_progress = min(99, int(index / total * 100))
|
||
_sync_session_progress(session_id, done_progress,
|
||
f"已完成 {index}/{total} 个文件", extractions=extractions)
|
||
|
||
# 更新会话
|
||
session = PptSession.query.get(session_id)
|
||
if not session:
|
||
return
|
||
|
||
all_failed = all(e.get("status") == "error" for e in extractions)
|
||
needs_review = any(e.get("status") != "success" for e in extractions)
|
||
first_error = next(
|
||
(str(e.get("error")) for e in extractions if e.get("error")),
|
||
"所有文件均处理失败",
|
||
)
|
||
session.extractions_json = json.dumps(extractions, ensure_ascii=False)
|
||
session.status = "error" if all_failed else "parsed"
|
||
session.parse_progress = 100
|
||
session.parse_message = (
|
||
"处理失败" if all_failed
|
||
else "处理完成,部分文件需校对" if needs_review
|
||
else "处理完成"
|
||
)
|
||
session.parse_error = first_error[:1000] if all_failed else None
|
||
session.parse_finished_at = datetime.now()
|
||
# 成功时 workflow_step 推进到 review(数据核对),失败保持 parsing
|
||
session.workflow_step = "parsing" if all_failed else "review"
|
||
db.session.commit()
|
||
|
||
# 更新任务状态
|
||
_update_task_status(
|
||
task_id,
|
||
status="failed" if all_failed else "done",
|
||
stage="completed",
|
||
progress=100,
|
||
message=(
|
||
"解析失败" if all_failed
|
||
else "解析完成,部分文件需校对" if needs_review
|
||
else "解析完成"
|
||
),
|
||
error_code="all_failed" if all_failed else "",
|
||
error_message=first_error[:1000] if all_failed else "",
|
||
finished_at=datetime.now(),
|
||
)
|
||
if all_failed:
|
||
raise RuntimeError(first_error)
|
||
|
||
|
||
# ─── PPT 生成任务 ──────────────────────────────────────────
|
||
|
||
@shared_task(bind=True, name="insurance.generate_ppt", max_retries=3, default_retry_delay=30)
|
||
def generate_ppt_task(self, task_id: str):
|
||
"""PPT 生成任务。"""
|
||
if not _claim_task(task_id):
|
||
logger.info(f"任务 {task_id} 已被领取或不在 queued 状态,跳过")
|
||
return
|
||
|
||
try:
|
||
from insurance.db.compat import db
|
||
from insurance.models.generation_task import GenerationTask
|
||
from insurance.models.ppt_session import PptSession
|
||
|
||
task = GenerationTask.query.get(task_id)
|
||
if not task:
|
||
logger.warning("PPT 生成任务记录不存在: task_id=%s", task_id)
|
||
return
|
||
|
||
_update_task_status(task_id, stage="validating", progress=5, message="开始生成 PPT")
|
||
|
||
session = PptSession.query.get(task.workspace_id)
|
||
if session:
|
||
session.workflow_step = "generating"
|
||
db.session.commit()
|
||
|
||
_execute_ppt_generate(task_id)
|
||
except Exception as exc:
|
||
logger.error(f"PPT 生成任务失败 [{task_id}]: {exc}", exc_info=True)
|
||
if _retry_with_frozen_input(self, task_id, exc):
|
||
return
|
||
_update_task_status(task_id,
|
||
status="failed",
|
||
error_code="generate_error",
|
||
error_message=str(exc)[:1000],
|
||
finished_at=datetime.now())
|
||
# 同步工作区状态
|
||
from insurance.models.generation_task import GenerationTask
|
||
task = GenerationTask.query.get(task_id)
|
||
if task:
|
||
from insurance.generation.task_service import sync_workspace_status
|
||
sync_workspace_status(task)
|
||
raise
|
||
|
||
|
||
def _execute_ppt_generate(task_id: str):
|
||
"""执行 PPT 生成逻辑。"""
|
||
import os
|
||
import uuid as uuid_mod
|
||
|
||
from insurance.db.compat import db
|
||
from insurance.models.generation_task import GenerationTask
|
||
from insurance.models.ppt_history import PptHistory
|
||
from insurance.models.ppt_session import PptSession
|
||
from insurance.models.ppt_config import PptTemplate, PptCompany, PptProduct, PptScenario
|
||
from insurance.ppt.normalizer import normalize_savings_plan, normalize_ci_plan, normalize_iul_plan
|
||
from insurance.ppt.validator import validate_formal_savings_plan, validate_formal_ci_plan, validate_formal_iul_plan
|
||
from insurance.ppt.renderer import PptRenderer
|
||
|
||
task = GenerationTask.query.get(task_id)
|
||
if not task:
|
||
return
|
||
|
||
session = PptSession.query.get(task.workspace_id)
|
||
if not session:
|
||
_update_task_status(task_id, status="failed", error_code="workspace_not_found",
|
||
error_message="工作区不存在", finished_at=datetime.now())
|
||
return
|
||
|
||
# 从快照中读取生成参数
|
||
snapshot = json.loads(task.input_snapshot_json) if task.input_snapshot_json else {}
|
||
theme = snapshot.get("theme", "broker")
|
||
template_id = snapshot.get("templateId", "")
|
||
template_asset_snapshot = snapshot.get("templateAsset") or {}
|
||
frozen_template_definition = snapshot.get("templateDefinition") or None
|
||
frozen_scenario_version = snapshot.get("scenarioVersion") or None
|
||
frozen_policy_version = snapshot.get("policyVersion") or None
|
||
company_id = snapshot.get("companyId", "")
|
||
company_ids = list(snapshot.get("companyIds") or ([company_id] if company_id else []))
|
||
product_ids = list(snapshot.get("productIds") or [])
|
||
brand_policy = snapshot.get("brandPolicy") or {}
|
||
frozen_company_definitions = snapshot.get("companyDefinitions") or {}
|
||
frozen_product_definitions = snapshot.get("productDefinitions") or {}
|
||
requested_scenario = snapshot.get("scenario", "")
|
||
|
||
frozen_contracts = snapshot.get("deckContracts")
|
||
if not isinstance(frozen_contracts, list) or not frozen_contracts:
|
||
_update_task_status(
|
||
task_id,
|
||
status="failed",
|
||
error_code="snapshot_required",
|
||
error_message="生成任务缺少已确认 PlanData 冻结输入",
|
||
finished_at=datetime.now(),
|
||
)
|
||
return
|
||
# Worker 只能消费提交时冻结的 DeckContract,不再读取会话中的可变提取结果。
|
||
extractions = [{
|
||
"status": "success",
|
||
"data": json.loads(json.dumps(contract, ensure_ascii=False)),
|
||
"planType": contract.get("kind"),
|
||
"pdfName": contract.get("pdfName") or "",
|
||
"companyId": contract.get("companyId") or "",
|
||
"productId": contract.get("productId") or "",
|
||
"_deckContract": True,
|
||
} for contract in frozen_contracts]
|
||
|
||
_update_task_status(task_id, stage="normalizing", progress=10, message="数据归一化中")
|
||
|
||
# 归一化
|
||
all_normalized = []
|
||
for ext in extractions:
|
||
if ext.get("status") not in ("success", "partial") or not ext.get("data"):
|
||
continue
|
||
ext_data = ext["data"]
|
||
pdf_path = ext.get("pdfPath")
|
||
plan_type = (ext.get("planType") or ext_data.get("product_type") or "savings").lower()
|
||
try:
|
||
if ext.get("_deckContract"):
|
||
normalized = ext_data
|
||
if plan_type == "ci":
|
||
issues = validate_formal_ci_plan(normalized)
|
||
elif plan_type == "iul":
|
||
issues = validate_formal_iul_plan(normalized)
|
||
else:
|
||
issues = validate_formal_savings_plan(normalized)
|
||
elif plan_type == "ci":
|
||
normalized = normalize_ci_plan(ext_data, pdf_path)
|
||
issues = validate_formal_ci_plan(normalized)
|
||
elif plan_type == "iul":
|
||
normalized = normalize_iul_plan(ext_data, pdf_path)
|
||
issues = validate_formal_iul_plan(normalized)
|
||
else:
|
||
normalized = normalize_savings_plan(ext_data, pdf_path)
|
||
issues = validate_formal_savings_plan(normalized)
|
||
normalized["fileId"] = ext.get("fileId") or ext.get("pdfName", "")
|
||
normalized["pdfName"] = ext.get("pdfName", "")
|
||
fallback_company_id = company_ids[0] if len(company_ids) == 1 else company_id
|
||
normalized["companyId"] = ext.get("companyId") or fallback_company_id or ""
|
||
product_id = ext.get("productId")
|
||
if product_id:
|
||
product_config = frozen_product_definitions.get(str(product_id))
|
||
configured_product = None
|
||
if not product_config:
|
||
configured_product = PptProduct.query.filter_by(
|
||
id=product_id, status=1, deleted_at=None
|
||
).first()
|
||
product_config = configured_product.to_dict() if configured_product else None
|
||
if product_config:
|
||
normalized["rawProductName"] = normalized.get("productName", "")
|
||
normalized["productName"] = product_config["displayName"]
|
||
normalized["productId"] = product_config.get("id") or product_id
|
||
normalized["companyId"] = product_config.get("companyId") or normalized.get("companyId", "")
|
||
normalized["productConfig"] = product_config
|
||
errors = [i for i in issues if i.level == "error"]
|
||
if errors:
|
||
_update_task_status(task_id, status="failed", error_code="validation_error",
|
||
error_message=f"数据校验不通过: {'; '.join(e.message for e in errors)}",
|
||
finished_at=datetime.now())
|
||
return
|
||
all_normalized.append(normalized)
|
||
except Exception as exc:
|
||
logger.error(f"归一化失败: {exc}", exc_info=True)
|
||
|
||
if not all_normalized:
|
||
_update_task_status(task_id, status="failed", error_code="no_valid_data",
|
||
error_message="无有效提取数据", finished_at=datetime.now())
|
||
return
|
||
|
||
from insurance.ppt.comparison import (
|
||
build_comparison_contract,
|
||
detect_generation_scenario,
|
||
generation_mode_for_scenario,
|
||
)
|
||
worker_detected_scenario = detect_generation_scenario(all_normalized)
|
||
scenario = (
|
||
frozen_scenario_version.get("scenarioCode")
|
||
if isinstance(frozen_scenario_version, dict) else worker_detected_scenario
|
||
)
|
||
scenario_override_trace = dict(snapshot.get("scenarioOverrideTrace") or {})
|
||
scenario_override_trace.update({
|
||
"workerDetectedScenario": worker_detected_scenario,
|
||
"finalScenario": scenario,
|
||
})
|
||
logger.info(
|
||
"PPT 场景判定追踪: task_id=%s trace=%s",
|
||
task_id,
|
||
json.dumps(scenario_override_trace, ensure_ascii=False, sort_keys=True),
|
||
)
|
||
if requested_scenario and requested_scenario != scenario:
|
||
logger.warning(
|
||
"PPT 场景已按实际计划书修正: requested=%s, detected=%s",
|
||
requested_scenario,
|
||
scenario,
|
||
)
|
||
if frozen_scenario_version:
|
||
configured_scenario = PptScenario.query.get(scenario)
|
||
generation_mode = configured_scenario.generation_mode if configured_scenario else generation_mode_for_scenario(worker_detected_scenario)
|
||
else:
|
||
generation_mode = generation_mode_for_scenario(scenario)
|
||
try:
|
||
comparison = build_comparison_contract(
|
||
all_normalized,
|
||
mode=generation_mode,
|
||
comparison_years=(frozen_scenario_version or {}).get("comparisonYears"),
|
||
calculation_policy=(frozen_policy_version or {}).get("calculationPolicy") if frozen_policy_version else None,
|
||
)
|
||
except ValueError as ve:
|
||
_update_task_status(task_id, status="failed",
|
||
error_code="comparison_validation_error",
|
||
error_message=str(ve),
|
||
finished_at=datetime.now())
|
||
return
|
||
|
||
_update_task_status(task_id, stage="loading_template", progress=30, message="加载模板")
|
||
|
||
normalized = all_normalized[0]
|
||
plan_type = normalized.get("kind", "savings")
|
||
|
||
# 加载模板和公司信息
|
||
template = None
|
||
|
||
def template_applies(candidate) -> bool:
|
||
candidate_data = candidate.to_dict()
|
||
from insurance.ppt.template_selection import template_scope_compatible
|
||
return template_scope_compatible(candidate_data, company_ids, product_ids)
|
||
|
||
if template_id:
|
||
if frozen_template_definition:
|
||
from types import SimpleNamespace
|
||
frozen_data = json.loads(json.dumps(frozen_template_definition, ensure_ascii=False))
|
||
template = SimpleNamespace(
|
||
id=frozen_data.get("id") or template_id,
|
||
scenario_tag=frozen_data.get("scenarioTag"),
|
||
plan_type=frozen_data.get("planType"),
|
||
style_preset=frozen_data.get("stylePreset") or theme,
|
||
source_template_asset_id=frozen_data.get("sourceTemplateAssetId"),
|
||
asset_sha256=frozen_data.get("assetSha256"),
|
||
asset_version=frozen_data.get("assetVersion") or 1,
|
||
clone_renderer=frozen_data.get("cloneRenderer"),
|
||
to_dict=lambda: json.loads(json.dumps(frozen_data, ensure_ascii=False)),
|
||
)
|
||
else:
|
||
template = PptTemplate.query.filter_by(
|
||
id=template_id, status=1, deleted_at=None
|
||
).first()
|
||
from insurance.ppt.scenarios import template_scenario_compatible
|
||
if not template:
|
||
_update_task_status(
|
||
task_id, status="failed", error_code="template_unavailable",
|
||
error_message="所选 PPT 模板不存在或已停用,请返回上一步重新选择",
|
||
finished_at=datetime.now(),
|
||
)
|
||
return
|
||
if template.scenario_tag and not template_scenario_compatible(
|
||
template.scenario_tag, scenario
|
||
):
|
||
_update_task_status(
|
||
task_id, status="failed", error_code="template_scenario_mismatch",
|
||
error_message="所选 PPT 模板与实际计划书场景不匹配,请重新选择",
|
||
finished_at=datetime.now(),
|
||
)
|
||
return
|
||
if not template.scenario_tag and template.plan_type != plan_type:
|
||
_update_task_status(
|
||
task_id, status="failed", error_code="template_plan_type_mismatch",
|
||
error_message="所选 PPT 模板与实际险种不匹配,请重新选择",
|
||
finished_at=datetime.now(),
|
||
)
|
||
return
|
||
if not template_applies(template):
|
||
_update_task_status(
|
||
task_id, status="failed", error_code="template_scope_mismatch",
|
||
error_message="所选 PPT 模板不适用于全部保司或产品,请重新选择",
|
||
finished_at=datetime.now(),
|
||
)
|
||
return
|
||
else:
|
||
from insurance.ppt.scenarios import scenario_codes_for_auto_match
|
||
scenario_codes = list(scenario_codes_for_auto_match(scenario))
|
||
candidates = PptTemplate.query.filter(
|
||
PptTemplate.scenario_tag.in_(scenario_codes),
|
||
PptTemplate.status == 1,
|
||
PptTemplate.deleted_at.is_(None),
|
||
).order_by(PptTemplate.asset_version.desc(), PptTemplate.id.asc()).all()
|
||
candidates = [candidate for candidate in candidates if template_applies(candidate)]
|
||
if candidates:
|
||
def candidate_priority(candidate):
|
||
data = candidate.to_dict()
|
||
return (
|
||
scenario_codes.index(candidate.scenario_tag),
|
||
0 if candidate.style_preset == theme else 1,
|
||
0 if data.get("applicableProductIds") else 1,
|
||
0 if data.get("applicableCompanyIds") else 1,
|
||
-int(candidate.asset_version or 0),
|
||
)
|
||
|
||
candidates.sort(key=lambda candidate: (*candidate_priority(candidate), candidate.id))
|
||
if len(candidates) > 1 and candidate_priority(candidates[0]) == candidate_priority(candidates[1]):
|
||
_update_task_status(
|
||
task_id, status="failed", error_code="template_ambiguous",
|
||
error_message="存在多个同等优先级 PPT 模板,请管理员明确适用范围",
|
||
finished_at=datetime.now(),
|
||
)
|
||
return
|
||
template = candidates[0]
|
||
if template is None and not template_id:
|
||
fallback_candidates = PptTemplate.query.filter_by(
|
||
plan_type=plan_type, style_preset=theme, status=1, deleted_at=None
|
||
).order_by(PptTemplate.asset_version.desc(), PptTemplate.id.asc()).all()
|
||
fallback_candidates = [candidate for candidate in fallback_candidates if template_applies(candidate)]
|
||
if len(fallback_candidates) > 1:
|
||
top_version = int(fallback_candidates[0].asset_version or 0)
|
||
if int(fallback_candidates[1].asset_version or 0) == top_version:
|
||
_update_task_status(
|
||
task_id, status="failed", error_code="template_ambiguous",
|
||
error_message="存在多个同等优先级 PPT 模板,请管理员明确适用范围",
|
||
finished_at=datetime.now(),
|
||
)
|
||
return
|
||
template = fallback_candidates[0] if fallback_candidates else None
|
||
template_config = template.to_dict() if template else None
|
||
if template_config and template.source_template_asset_id:
|
||
from insurance.ppt.template_asset_service import (
|
||
resolve_template_asset,
|
||
template_asset_sha256,
|
||
)
|
||
asset_id = template_asset_snapshot.get("assetId") or template.source_template_asset_id
|
||
source_template_path = resolve_template_asset(asset_id)
|
||
if not source_template_path or not os.path.isfile(source_template_path):
|
||
_update_task_status(
|
||
task_id, status="failed", error_code="template_asset_missing",
|
||
error_message="生成任务引用的模板文件不存在,请重新提交",
|
||
finished_at=datetime.now(),
|
||
)
|
||
return
|
||
actual_sha256 = template_asset_sha256(source_template_path)
|
||
expected_sha256 = template_asset_snapshot.get("sha256")
|
||
if expected_sha256 and actual_sha256 != expected_sha256:
|
||
_update_task_status(
|
||
task_id, status="failed", error_code="template_asset_changed",
|
||
error_message="模板文件版本校验失败,请重新提交生成任务",
|
||
finished_at=datetime.now(),
|
||
)
|
||
return
|
||
template_config["sourceTemplateAssetId"] = asset_id
|
||
template_config["sourceTemplatePath"] = source_template_path
|
||
template_config["assetSha256"] = actual_sha256
|
||
template_config["assetVersion"] = (
|
||
template_asset_snapshot.get("version") or template.asset_version or 1
|
||
)
|
||
template_config["cloneRenderer"] = (
|
||
template_asset_snapshot.get("rendererMode")
|
||
or template.clone_renderer
|
||
or "clone-edit-v2"
|
||
)
|
||
|
||
company_ids = list(dict.fromkeys(
|
||
item.get("companyId") for item in all_normalized if item.get("companyId")
|
||
)) or company_ids
|
||
company_infos = {}
|
||
if company_ids:
|
||
from insurance.ppt.masking import apply_brand_policy
|
||
for current_company_id in company_ids:
|
||
masked_company = frozen_company_definitions.get(str(current_company_id))
|
||
if not masked_company:
|
||
company = PptCompany.query.filter_by(
|
||
id=current_company_id, deleted_at=None
|
||
).first()
|
||
if not company:
|
||
continue
|
||
masked_company, _ = apply_brand_policy(company.to_dict(), None, brand_policy)
|
||
company_infos[current_company_id] = masked_company
|
||
|
||
for item in all_normalized:
|
||
item["companyInfo"] = company_infos.get(item.get("companyId"), {})
|
||
|
||
company_info = None
|
||
if len(company_infos) == 1:
|
||
company_info = next(iter(company_infos.values()))
|
||
company_id = company_info.get("id", "")
|
||
elif len(company_infos) > 1:
|
||
company_info = {
|
||
"id": "multi",
|
||
"displayName": " / ".join(
|
||
item.get("displayName", "")
|
||
for item in company_infos.values()
|
||
if item.get("displayName")
|
||
),
|
||
"companyIntro": "",
|
||
"companyHighlights": [],
|
||
"rating": "",
|
||
"logoUrl": "",
|
||
}
|
||
company_id = ""
|
||
|
||
_update_task_status(task_id, stage="rendering", progress=50, message="渲染 PPT")
|
||
|
||
# 渲染
|
||
renderer = PptRenderer()
|
||
user_id = task.user_id
|
||
from insurance.config import get_storage_root
|
||
output_dir = os.path.join(get_storage_root(), "outputs", "ppt", user_id, task_id)
|
||
os.makedirs(output_dir, exist_ok=True)
|
||
output_path = os.path.join(output_dir, f"presentation.pptx")
|
||
|
||
result = renderer.render_enhanced(
|
||
normalized, output_path, theme=theme,
|
||
company_id=company_id, company_info=company_info,
|
||
template_config=template_config,
|
||
all_products=all_normalized if len(all_normalized) > 1 else None,
|
||
comparison=comparison,
|
||
generation_mode=generation_mode,
|
||
scenario=scenario,
|
||
)
|
||
|
||
if not result.get("ok"):
|
||
_update_task_status(task_id, status="failed", error_code="render_error",
|
||
error_message=f"渲染失败: {result.get('error', '未知错误')}",
|
||
finished_at=datetime.now())
|
||
return
|
||
|
||
_update_task_status(task_id, stage="saving", progress=90, message="保存文件")
|
||
|
||
# 保存 DeckContract 快照(供后续版本化编辑回溯)
|
||
deck_path = None
|
||
deck_data = result.get("deck")
|
||
if deck_data:
|
||
if snapshot.get("reconciliationManifest"):
|
||
deck_data["reconciliationManifest"] = snapshot["reconciliationManifest"]
|
||
try:
|
||
deck_path = os.path.join(output_dir, "deck_contract.json")
|
||
with open(deck_path, "w", encoding="utf-8") as _f:
|
||
json.dump(deck_data, _f, ensure_ascii=False, indent=2)
|
||
logger.info("DeckContract 已保存: %s", deck_path)
|
||
except Exception as exc:
|
||
logger.warning("保存 DeckContract 失败: %s", exc)
|
||
|
||
from insurance.generation.reconciliation import reconcile_pptx
|
||
reconciliation_report = reconcile_pptx(deck_data, output_path)
|
||
from insurance.generation.feature_flags import enabled_for
|
||
if (
|
||
reconciliation_report["status"] != "passed"
|
||
and enabled_for("OUTPUT_RECONCILIATION_V1", identity=task.user_id)
|
||
):
|
||
_update_task_status(
|
||
task_id,
|
||
status="failed",
|
||
stage="reconciliation_failed",
|
||
progress=100,
|
||
message="输出数值对账失败",
|
||
error_code="output_reconciliation_failed",
|
||
error_message="PPT 输出与冻结契约不一致,已阻止下载",
|
||
output_reconciliation_json=json.dumps(reconciliation_report, ensure_ascii=False),
|
||
result_state="failed",
|
||
finished_at=datetime.now(),
|
||
)
|
||
return
|
||
|
||
# 解析幻灯片结构(失败不阻断主流程)
|
||
slides_data = None
|
||
slides_dir = os.path.join(output_dir, "slides")
|
||
try:
|
||
slides_result = renderer.parse_slides(output_path, slides_dir)
|
||
if slides_result.get("ok"):
|
||
import json as _json
|
||
with open(slides_result["jsonPath"], "r", encoding="utf-8") as _f:
|
||
slides_data = _json.load(_f)
|
||
logger.info("幻灯片结构解析成功: %d 页", slides_result.get("slideCount", 0))
|
||
else:
|
||
logger.warning("幻灯片结构解析失败: %s", slides_result.get("error", ""))
|
||
except Exception as exc:
|
||
logger.warning("幻灯片结构解析异常: %s", exc)
|
||
|
||
# 质量检查(失败不阻断)
|
||
quality_report = None
|
||
try:
|
||
from insurance.ppt.quality_checker import QualityChecker
|
||
checker = QualityChecker()
|
||
quality_report = checker.check(
|
||
pptx_path=output_path,
|
||
slides_data=slides_data,
|
||
extractions=extractions,
|
||
expected_slide_count=result.get("slideCount", 0),
|
||
)
|
||
logger.info("质量检查完成: pass=%s, fail=%s, warn=%s",
|
||
quality_report["summary"]["pass"],
|
||
quality_report["summary"]["fail"],
|
||
quality_report["summary"]["warn"])
|
||
except Exception as exc:
|
||
logger.warning("质量检查异常: %s", exc)
|
||
|
||
# 更新会话
|
||
session = PptSession.query.get(task.workspace_id)
|
||
submitted_revision = task.submit_revision or task.input_revision or 1
|
||
result_state = "stale"
|
||
if session:
|
||
is_current = (session.draft_revision or 1) == submitted_revision
|
||
result_state = "current" if is_current else "stale"
|
||
if is_current:
|
||
session.ppt_path = output_path
|
||
session.slide_count = result.get("slideCount", 0)
|
||
session.status = "done"
|
||
session.generated_revision = submitted_revision
|
||
session.latest_output_path = output_path
|
||
if slides_data:
|
||
slides_json_path = os.path.join(slides_dir, "slides.json")
|
||
session.slides_json_path = slides_json_path
|
||
session.preview_status = "ready"
|
||
else:
|
||
session.preview_status = "failed"
|
||
if quality_report:
|
||
session.quality_report_json = json.dumps(quality_report, ensure_ascii=False)
|
||
if deck_path:
|
||
session.deck_contract_path = deck_path
|
||
# 追加版本历史
|
||
versions = json.loads(session.versions_json) if session.versions_json else []
|
||
versions.append({
|
||
"revision": submitted_revision,
|
||
"resultState": result_state,
|
||
"path": output_path,
|
||
"slidesJsonPath": os.path.join(slides_dir, "slides.json") if slides_data else None,
|
||
"deckPath": deck_path,
|
||
"slideCount": result.get("slideCount", 0),
|
||
"templateId": template.id if template else None,
|
||
"templateName": template.name if template else None,
|
||
"stylePreset": template.style_preset if template else theme,
|
||
"templateAsset": ({
|
||
"assetId": template_config.get("sourceTemplateAssetId"),
|
||
"version": template_config.get("assetVersion"),
|
||
"sha256": template_config.get("assetSha256"),
|
||
"rendererMode": result.get("rendererMode", "generic-builder-v1"),
|
||
} if template_config and template_config.get("sourceTemplateAssetId") else None),
|
||
"createdAt": datetime.now().isoformat(),
|
||
})
|
||
session.versions_json = json.dumps(versions, ensure_ascii=False)
|
||
db.session.add(PptHistory(
|
||
user_id=task.user_id,
|
||
session_id=task.workspace_id,
|
||
action_type="export",
|
||
company_id=company_id or None,
|
||
product_id=(snapshot.get("productIds") or [None])[0],
|
||
template_id=template_id or None,
|
||
content_snapshot=json.dumps({
|
||
"theme": theme,
|
||
"slideCount": result.get("slideCount", 0),
|
||
"brandPolicy": brand_policy,
|
||
"scenario": scenario,
|
||
}, ensure_ascii=False),
|
||
file_url=output_path,
|
||
))
|
||
db.session.commit()
|
||
|
||
# 完成任务
|
||
_update_task_status(
|
||
task_id,
|
||
status="done",
|
||
stage="completed",
|
||
progress=100,
|
||
message="生成完成",
|
||
finished_at=datetime.now(),
|
||
output_json=json.dumps({
|
||
"downloadUrl": f"/insurance/workspace/tasks/{task_id}/download",
|
||
"slideCount": result.get("slideCount", 0),
|
||
"filePath": output_path,
|
||
"scenario": scenario,
|
||
"scenarioOverrideTrace": scenario_override_trace,
|
||
"generationMode": generation_mode,
|
||
"requestedTemplateId": template_id or None,
|
||
"appliedTemplateId": template.id if template else None,
|
||
"appliedTemplateName": template.name if template else None,
|
||
"appliedStylePreset": template.style_preset if template else theme,
|
||
"templateAssetId": template_config.get("sourceTemplateAssetId") if template_config else None,
|
||
"templateAssetVersion": template_config.get("assetVersion") if template_config else None,
|
||
"templateAssetSha256": template_config.get("assetSha256") if template_config else None,
|
||
"rendererMode": result.get("rendererMode", "generic-builder-v1"),
|
||
"templateFrameMap": result.get("templateFrameMap", []),
|
||
"templateFallback": False,
|
||
"revision": submitted_revision,
|
||
"resultState": result_state,
|
||
}, ensure_ascii=False),
|
||
result_state=result_state,
|
||
output_reconciliation_json=json.dumps(reconciliation_report, ensure_ascii=False),
|
||
)
|
||
|
||
# 同步 workflow_step 到 result
|
||
session = PptSession.query.get(task.workspace_id)
|
||
if session:
|
||
session.workflow_step = "result"
|
||
db.session.commit()
|
||
|
||
|
||
# ─── PPT 重新生成任务(版本化)─────────────────────────────
|
||
|
||
def _apply_edits_to_pptx(pptx_path: str, edit_slides: list):
|
||
"""将 slides.json 中的编辑应用到已渲染的 PPTX 文件。
|
||
|
||
1. 删除被标记为 hidden 的幻灯片(从后往前删避免索引偏移)
|
||
2. 对非隐藏页,按位置匹配文本框并更新文字
|
||
"""
|
||
from pptx import Presentation
|
||
from pptx.util import Emu
|
||
|
||
prs = Presentation(pptx_path)
|
||
slides = list(prs.slides)
|
||
|
||
# 收集需要删除的幻灯片索引(倒序处理)
|
||
hidden_indices = sorted(
|
||
[i for i, s in enumerate(edit_slides) if s.get("hidden") and i < len(slides)],
|
||
reverse=True,
|
||
)
|
||
for idx in hidden_indices:
|
||
slide_id = slides[idx].slide_id
|
||
rId = prs.slides._sldIdLst[idx].get("{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id")
|
||
if not rId:
|
||
# 尝试通过 rels 查找
|
||
for rel in prs.part.rels.values():
|
||
if rel.target_part is slides[idx].part:
|
||
rId = rel.rId
|
||
break
|
||
if rId:
|
||
prs.part.drop_rel(rId)
|
||
del prs.slides._sldIdLst[idx]
|
||
logger.info("已删除隐藏幻灯片: index=%d", idx)
|
||
|
||
# 应用文字编辑(匹配位置)
|
||
TOLERANCE = 50000 # EMU 容差(约 0.5px)
|
||
edited_count = 0
|
||
for slide_idx, edit_slide in enumerate(edit_slides):
|
||
if edit_slide.get("hidden"):
|
||
continue
|
||
if slide_idx >= len(list(prs.slides)):
|
||
continue
|
||
slide = list(prs.slides)[slide_idx]
|
||
edit_shapes = edit_slide.get("shapes", [])
|
||
for edit_shape in edit_shapes:
|
||
shape_type = edit_shape.get("type")
|
||
if shape_type not in ("textbox", "table"):
|
||
continue
|
||
edit_paras = edit_shape.get("paragraphs", [])
|
||
edit_rows = edit_shape.get("rows", [])
|
||
if not edit_paras and not edit_rows:
|
||
continue
|
||
edit_x = edit_shape.get("x", 0)
|
||
edit_y = edit_shape.get("y", 0)
|
||
# 匹配 PPTX 中的形状(按位置)
|
||
for shape in slide.shapes:
|
||
if shape_type == "textbox" and not shape.has_text_frame:
|
||
continue
|
||
if shape_type == "table" and not shape.has_table:
|
||
continue
|
||
shape_x = int(shape.left) if shape.left else 0
|
||
shape_y = int(shape.top) if shape.top else 0
|
||
if abs(shape_x - _px_to_emu(edit_x)) > TOLERANCE:
|
||
continue
|
||
if abs(shape_y - _px_to_emu(edit_y)) > TOLERANCE:
|
||
continue
|
||
if shape_type == "table":
|
||
_update_table_text(shape, edit_rows)
|
||
else:
|
||
_update_shape_text(shape, edit_paras)
|
||
edited_count += 1
|
||
break
|
||
|
||
if edited_count > 0 or hidden_indices:
|
||
prs.save(pptx_path)
|
||
logger.info("PPTX 后处理完成: 编辑 %d 个文本框, 删除 %d 个隐藏页",
|
||
edited_count, len(hidden_indices))
|
||
|
||
|
||
def _px_to_emu(px: int) -> int:
|
||
"""像素转 EMU(96 DPI)。"""
|
||
return round(px * 914400 / 96)
|
||
|
||
|
||
def _update_shape_text(shape, edit_paras: list):
|
||
"""将编辑后的段落文本写入 python-pptx 形状。"""
|
||
from pptx.util import Pt
|
||
from pptx.dml.color import RGBColor
|
||
from pptx.enum.text import PP_ALIGN
|
||
|
||
tf = shape.text_frame
|
||
existing_paras = list(tf.paragraphs)
|
||
|
||
for i, edit_para in enumerate(edit_paras):
|
||
new_text = edit_para.get("text", "")
|
||
if i < len(existing_paras):
|
||
para = existing_paras[i]
|
||
# 保留第一个 run 的格式,替换文本
|
||
if para.runs:
|
||
para.runs[0].text = new_text
|
||
first_run = para.runs[0]
|
||
# 删除多余 runs
|
||
for run in para.runs[1:]:
|
||
run.text = ""
|
||
else:
|
||
para.text = new_text
|
||
first_run = para.runs[0] if para.runs else None
|
||
else:
|
||
# 新增段落
|
||
para = tf.add_paragraph()
|
||
para.text = new_text
|
||
first_run = para.runs[0] if para.runs else None
|
||
|
||
align_map = {
|
||
"left": PP_ALIGN.LEFT,
|
||
"center": PP_ALIGN.CENTER,
|
||
"right": PP_ALIGN.RIGHT,
|
||
"justify": PP_ALIGN.JUSTIFY,
|
||
}
|
||
if edit_para.get("align") in align_map:
|
||
para.alignment = align_map[edit_para["align"]]
|
||
if first_run:
|
||
if edit_para.get("fontSize"):
|
||
first_run.font.size = Pt(float(edit_para["fontSize"]))
|
||
if edit_para.get("fontFamily"):
|
||
first_run.font.name = str(edit_para["fontFamily"])
|
||
first_run.font.bold = bool(edit_para.get("bold"))
|
||
color = str(edit_para.get("color") or "").lstrip("#")
|
||
if len(color) == 6:
|
||
try:
|
||
first_run.font.color.rgb = RGBColor.from_string(color.upper())
|
||
except ValueError:
|
||
pass
|
||
|
||
# 删除多余段落(如果编辑后段落变少了)
|
||
# python-pptx 不支持直接删除段落,只能清空
|
||
for i in range(len(edit_paras), len(existing_paras)):
|
||
existing_paras[i].text = ""
|
||
|
||
|
||
def _update_table_text(shape, edit_rows: list):
|
||
"""将编辑后的表格单元格写回 PPTX。"""
|
||
from pptx.util import Pt
|
||
|
||
for row_index, edit_row in enumerate(edit_rows):
|
||
if row_index >= len(shape.table.rows):
|
||
break
|
||
row = shape.table.rows[row_index]
|
||
for col_index, edit_cell in enumerate(edit_row):
|
||
if col_index >= len(row.cells):
|
||
break
|
||
cell = row.cells[col_index]
|
||
cell.text = str(edit_cell.get("text") or "")
|
||
for para in cell.text_frame.paragraphs:
|
||
for run in para.runs:
|
||
run.font.bold = bool(edit_cell.get("bold"))
|
||
if edit_cell.get("fontSize"):
|
||
run.font.size = Pt(float(edit_cell["fontSize"]))
|
||
|
||
|
||
@shared_task(bind=True, name="insurance.regenerate_ppt", max_retries=3, default_retry_delay=30)
|
||
def regenerate_ppt_task(self, task_id: str):
|
||
"""PPT 重新生成任务(基于编辑内容生成新版本)。"""
|
||
if not _claim_task(task_id):
|
||
logger.info(f"任务 {task_id} 已被领取或不在 queued 状态,跳过")
|
||
return
|
||
|
||
_update_task_status(task_id, stage="preparing", progress=5, message="准备重新生成")
|
||
|
||
try:
|
||
_execute_ppt_regenerate(task_id)
|
||
except Exception as exc:
|
||
logger.error(f"PPT 重新生成任务失败 [{task_id}]: {exc}", exc_info=True)
|
||
if _retry_with_frozen_input(self, task_id, exc):
|
||
return
|
||
_update_task_status(task_id,
|
||
status="failed",
|
||
error_code="regenerate_error",
|
||
error_message=str(exc)[:1000],
|
||
finished_at=datetime.now())
|
||
# 同步工作区状态
|
||
from insurance.models.generation_task import GenerationTask as _GT
|
||
_task = _GT.query.get(task_id)
|
||
if _task:
|
||
from insurance.generation.task_service import sync_workspace_status
|
||
sync_workspace_status(_task)
|
||
raise
|
||
|
||
|
||
def _execute_ppt_regenerate(task_id: str):
|
||
"""执行 PPT 重新生成:读取 DeckContract → 应用编辑 → 重新渲染。"""
|
||
import os
|
||
|
||
from insurance.db.compat import db
|
||
from insurance.models.generation_task import GenerationTask
|
||
from insurance.models.ppt_history import PptHistory
|
||
from insurance.models.ppt_session import PptSession
|
||
from insurance.ppt.renderer import PptRenderer
|
||
|
||
task = GenerationTask.query.get(task_id)
|
||
if not task:
|
||
return
|
||
|
||
session = PptSession.query.get(task.workspace_id)
|
||
if not session:
|
||
_update_task_status(task_id, status="failed", error_code="workspace_not_found",
|
||
error_message="工作区不存在", finished_at=datetime.now())
|
||
return
|
||
|
||
# 读取 DeckContract
|
||
deck_path = session.deck_contract_path
|
||
if not deck_path or not os.path.exists(deck_path):
|
||
_update_task_status(task_id, status="failed", error_code="no_deck",
|
||
error_message="DeckContract 快照不存在,请重新生成",
|
||
finished_at=datetime.now())
|
||
return
|
||
|
||
with open(deck_path, "r", encoding="utf-8") as f:
|
||
deck = json.load(f)
|
||
|
||
# 读取编辑数据
|
||
snapshot = json.loads(task.input_snapshot_json) if task.input_snapshot_json else {}
|
||
edits = snapshot.get("edits")
|
||
|
||
_update_task_status(task_id, stage="rendering", progress=30, message="重新渲染 PPT")
|
||
|
||
# 使用 DeckContract 重新渲染
|
||
renderer = PptRenderer()
|
||
user_id = task.user_id
|
||
from insurance.config import get_storage_root
|
||
output_dir = os.path.join(get_storage_root(), "outputs", "ppt", user_id, task_id)
|
||
os.makedirs(output_dir, exist_ok=True)
|
||
output_path = os.path.join(output_dir, "presentation.pptx")
|
||
|
||
# 从 deck 中提取渲染参数
|
||
theme = deck.get("stylePreset", "broker")
|
||
all_products = deck.get("products", [])
|
||
normalized_data = all_products[0] if all_products else {}
|
||
company_info = deck.get("company")
|
||
template_config = deck.get("templateConfig")
|
||
comparison = deck.get("comparison")
|
||
generation_mode = deck.get("generationMode")
|
||
scenario = deck.get("scenario")
|
||
|
||
result = renderer.render_enhanced(
|
||
normalized_data, output_path, theme=theme,
|
||
company_info=company_info,
|
||
template_config=template_config,
|
||
all_products=all_products if len(all_products) > 1 else None,
|
||
comparison=comparison,
|
||
generation_mode=generation_mode,
|
||
scenario=scenario,
|
||
)
|
||
|
||
if not result.get("ok"):
|
||
_update_task_status(task_id, status="failed", error_code="render_error",
|
||
error_message=f"渲染失败: {result.get('error', '未知错误')}",
|
||
finished_at=datetime.now())
|
||
return
|
||
|
||
_update_task_status(task_id, stage="saving", progress=70, message="应用编辑")
|
||
|
||
# 后处理:应用文字编辑和删除隐藏页
|
||
if edits and edits.get("slides"):
|
||
try:
|
||
_apply_edits_to_pptx(output_path, edits["slides"])
|
||
except Exception as exc:
|
||
logger.warning("应用编辑到 PPTX 失败: %s", exc)
|
||
|
||
_update_task_status(task_id, stage="saving", progress=80, message="保存新版本")
|
||
|
||
# 解析幻灯片结构
|
||
slides_data = None
|
||
slides_dir = os.path.join(output_dir, "slides")
|
||
try:
|
||
slides_result = renderer.parse_slides(output_path, slides_dir)
|
||
if slides_result.get("ok"):
|
||
with open(slides_result["jsonPath"], "r", encoding="utf-8") as _f:
|
||
slides_data = json.load(_f)
|
||
except Exception as exc:
|
||
logger.warning("重新生成: 幻灯片解析失败: %s", exc)
|
||
|
||
# 质量检查(传入 extractions 避免误判)
|
||
quality_report = None
|
||
try:
|
||
from insurance.ppt.quality_checker import QualityChecker
|
||
checker = QualityChecker()
|
||
# 从 session 读取原始 extractions 用于关键数据检查
|
||
session_extractions = json.loads(session.extractions_json) if session.extractions_json else []
|
||
quality_report = checker.check(
|
||
pptx_path=output_path,
|
||
slides_data=slides_data,
|
||
extractions=session_extractions,
|
||
expected_slide_count=result.get("slideCount", 0),
|
||
)
|
||
except Exception as exc:
|
||
logger.warning("重新生成: 质量检查失败: %s", exc)
|
||
|
||
# 保存 DeckContract 快照
|
||
new_deck_path = None
|
||
if result.get("deck"):
|
||
try:
|
||
new_deck_path = os.path.join(output_dir, "deck_contract.json")
|
||
with open(new_deck_path, "w", encoding="utf-8") as _f:
|
||
json.dump(result["deck"], _f, ensure_ascii=False, indent=2)
|
||
except Exception:
|
||
pass
|
||
|
||
# 更新会话
|
||
session = PptSession.query.get(task.workspace_id)
|
||
if session:
|
||
session.ppt_path = output_path
|
||
session.slide_count = result.get("slideCount", 0)
|
||
session.status = "done"
|
||
session.generated_revision = session.draft_revision
|
||
session.latest_output_path = output_path
|
||
if slides_data:
|
||
session.slides_json_path = os.path.join(slides_dir, "slides.json")
|
||
session.preview_status = "ready"
|
||
if quality_report:
|
||
session.quality_report_json = json.dumps(quality_report, ensure_ascii=False)
|
||
if new_deck_path:
|
||
session.deck_contract_path = new_deck_path
|
||
# 追加版本历史
|
||
versions = json.loads(session.versions_json) if session.versions_json else []
|
||
versions.append({
|
||
"revision": session.generated_revision,
|
||
"path": output_path,
|
||
"slidesJsonPath": os.path.join(slides_dir, "slides.json") if slides_data else None,
|
||
"deckPath": new_deck_path,
|
||
"slideCount": result.get("slideCount", 0),
|
||
"createdAt": datetime.now().isoformat(),
|
||
})
|
||
session.versions_json = json.dumps(versions, ensure_ascii=False)
|
||
db.session.add(PptHistory(
|
||
user_id=task.user_id,
|
||
session_id=task.workspace_id,
|
||
action_type="regenerate",
|
||
content_snapshot=json.dumps({
|
||
"revision": session.generated_revision,
|
||
"slideCount": result.get("slideCount", 0),
|
||
}, ensure_ascii=False),
|
||
file_url=output_path,
|
||
))
|
||
db.session.commit()
|
||
|
||
_update_task_status(
|
||
task_id,
|
||
status="done",
|
||
stage="completed",
|
||
progress=100,
|
||
message="重新生成完成",
|
||
finished_at=datetime.now(),
|
||
output_json=json.dumps({
|
||
"downloadUrl": f"/insurance/workspace/tasks/{task_id}/download",
|
||
"slideCount": result.get("slideCount", 0),
|
||
"filePath": output_path,
|
||
"revision": session.generated_revision if session else 0,
|
||
}, ensure_ascii=False),
|
||
)
|
||
|
||
|
||
# ─── 海报生成任务 ──────────────────────────────────────────
|
||
|
||
@shared_task(bind=True, name="insurance.generate_poster", max_retries=3, default_retry_delay=30)
|
||
def generate_poster_task(self, task_id: str):
|
||
"""海报图片生成任务。"""
|
||
if not _claim_task(task_id):
|
||
logger.info(f"任务 {task_id} 已被领取或不在 queued 状态,跳过")
|
||
return
|
||
|
||
_update_task_status(task_id, stage="preparing_data", progress=5, message="开始生成海报")
|
||
|
||
try:
|
||
_execute_poster_generate(task_id)
|
||
except Exception as exc:
|
||
logger.error(f"海报生成任务失败 [{task_id}]: {exc}", exc_info=True)
|
||
if _retry_with_frozen_input(self, task_id, exc):
|
||
return
|
||
_update_task_status(task_id,
|
||
status="failed",
|
||
error_code="generate_error",
|
||
error_message=str(exc)[:1000],
|
||
finished_at=datetime.now())
|
||
# 同步工作区状态
|
||
from insurance.models.generation_task import GenerationTask as _GT
|
||
_task = _GT.query.get(task_id)
|
||
if _task:
|
||
from insurance.generation.task_service import sync_workspace_status
|
||
sync_workspace_status(_task)
|
||
raise
|
||
|
||
|
||
def _execute_poster_generate(task_id: str):
|
||
"""执行海报生成逻辑。"""
|
||
import os
|
||
import re
|
||
import uuid as uuid_mod
|
||
|
||
from insurance.db.compat import db
|
||
from insurance.models.generation_task import GenerationTask
|
||
from insurance.models.poster_record import PosterRecord
|
||
from insurance.models.poster_template_model import PosterTemplate
|
||
from insurance.models.ppt_config import PptProduct, PptCompany
|
||
|
||
def sync_poster_progress(progress: int, status: str = "pending", message: str = None):
|
||
"""同步进度到 PosterRecord,保证页面轮询海报记录时能看到真实进度。"""
|
||
_record = PosterRecord.query.get(task.workspace_id)
|
||
if _record:
|
||
_record.task_status = status
|
||
_record.task_progress = progress
|
||
if message:
|
||
_record.task_error = message if status == "failed" else None
|
||
db.session.commit()
|
||
|
||
task = GenerationTask.query.get(task_id)
|
||
if not task:
|
||
return
|
||
|
||
record = PosterRecord.query.get(task.workspace_id)
|
||
if not record:
|
||
_update_task_status(task_id, status="failed", error_code="workspace_not_found",
|
||
error_message="工作区不存在", finished_at=datetime.now())
|
||
return
|
||
|
||
# 标记海报记录为运行中
|
||
record.task_status = "running"
|
||
record.task_progress = 5
|
||
db.session.commit()
|
||
|
||
snapshot = json.loads(task.input_snapshot_json) if task.input_snapshot_json else {}
|
||
template_id = snapshot.get("templateId")
|
||
product_id = snapshot.get("productId")
|
||
copy_content = snapshot.get("copyContent", {})
|
||
render_document = (
|
||
snapshot.get("renderDocument")
|
||
if isinstance(snapshot.get("renderDocument"), dict)
|
||
else None
|
||
)
|
||
if record.document_json:
|
||
try:
|
||
stored_document = json.loads(record.document_json)
|
||
if isinstance(stored_document, dict) and stored_document.get("schemaVersion") == 2:
|
||
render_document = stored_document
|
||
except (json.JSONDecodeError, TypeError):
|
||
logger.warning("海报 renderDocument JSON 无效: record_id=%s", record.id)
|
||
if render_document:
|
||
copy_content = dict(render_document.get("copy") or {})
|
||
size = snapshot.get("size", "1024x1536")
|
||
output_mode = snapshot.get("outputMode", "single")
|
||
reference_image = snapshot.get("referenceImage")
|
||
brand_policy = snapshot.get("brandPolicy") or {}
|
||
|
||
from insurance.poster.format_registry import PosterFormatError, resolve_poster_format
|
||
try:
|
||
format_spec = resolve_poster_format(
|
||
format_id=snapshot.get("formatId"),
|
||
legacy_size=size,
|
||
output_mode=output_mode,
|
||
)
|
||
background_size = format_spec["backgroundAssetSize"]
|
||
except PosterFormatError:
|
||
if snapshot.get("formatId"):
|
||
raise
|
||
# 兼容部署前已经排队的旧任务;新请求会在 PosterService 中被严格校验。
|
||
logger.warning("旧海报任务使用安全背景尺寸: task_id=%s, size=%s", task_id, size)
|
||
background_size = "1024x1536"
|
||
|
||
_update_task_status(task_id, stage="preparing_data", progress=20, message="准备数据")
|
||
sync_poster_progress(20, "running")
|
||
|
||
# 获取模板和产品信息。新记录优先使用提交时快照,旧记录回退公共产品查询。
|
||
poster_template = PosterTemplate.query.get(template_id) if template_id else None
|
||
product_snapshot_data = {}
|
||
if record.product_snapshot_json:
|
||
try:
|
||
product_snapshot_data = json.loads(record.product_snapshot_json)
|
||
except (json.JSONDecodeError, TypeError):
|
||
logger.warning("海报产品快照 JSON 无效: record_id=%s", record.id)
|
||
|
||
product = None
|
||
company = None
|
||
if product_snapshot_data:
|
||
product_dict = dict(product_snapshot_data.get("productData") or {})
|
||
company_dict = dict(product_snapshot_data.get("companyData") or {})
|
||
product_rules = dict(product_snapshot_data.get("rules") or {})
|
||
real_product_name = product_snapshot_data.get("productName") or ""
|
||
real_company_name = product_snapshot_data.get("companyName") or ""
|
||
else:
|
||
product = PptProduct.query.get(product_id) if product_id else None
|
||
company = PptCompany.query.get(product.company_id) if product else None
|
||
product_dict = product.to_dict() if product else {}
|
||
company_dict = company.to_dict() if company else {}
|
||
product_rules = {}
|
||
if product and product.manual_parsed_rules:
|
||
try:
|
||
product_rules = json.loads(product.manual_parsed_rules)
|
||
except (json.JSONDecodeError, TypeError):
|
||
logger.warning("产品规则 JSON 解析失败: product_id=%s", product_id)
|
||
real_product_name = product.display_name if product else ""
|
||
real_company_name = company.display_name if company else ""
|
||
|
||
if not reference_image and poster_template:
|
||
reference_image = poster_template.reference_image
|
||
|
||
from insurance.ppt.masking import apply_brand_policy, build_brand_policy, mask_text
|
||
if not brand_policy:
|
||
brand_policy = build_brand_policy(company_dict, [product_dict])
|
||
company_dict, product_dict = apply_brand_policy(
|
||
company_dict, product_dict, brand_policy
|
||
)
|
||
masked_name = product_dict.get("displayName", "")
|
||
masked_company = company_dict.get("displayName", "")
|
||
replacements = {}
|
||
if real_product_name and masked_name and real_product_name != masked_name:
|
||
replacements[real_product_name] = masked_name
|
||
if real_company_name and masked_company and real_company_name != masked_company:
|
||
replacements[real_company_name] = masked_company
|
||
if replacements:
|
||
for key in copy_content:
|
||
if isinstance(copy_content[key], str):
|
||
copy_content[key] = mask_text(copy_content[key], replacements)
|
||
if render_document:
|
||
render_document["copy"] = copy_content
|
||
product_dict = product_dict or None
|
||
company_dict = company_dict or None
|
||
|
||
_update_task_status(task_id, stage="building_prompt", progress=40, message="构建 Prompt")
|
||
sync_poster_progress(40, "running")
|
||
|
||
if render_document:
|
||
poster_content = {
|
||
"summary": render_document.get("summary") or {},
|
||
"features": render_document.get("features") or [],
|
||
"benefit_table": render_document.get("benefits") or [],
|
||
"field_profile": render_document.get("fieldProfile") or {},
|
||
"warnings": render_document.get("warnings") or [],
|
||
}
|
||
else:
|
||
from insurance.poster.content_builder import build_poster_content
|
||
poster_content = build_poster_content(
|
||
parsed_data=product_rules.get("facts") or product_rules,
|
||
product_rules=product_rules,
|
||
output_mode=output_mode,
|
||
plan_type=(product_snapshot_data.get("planType") if product_snapshot_data else None)
|
||
or (product_dict or {}).get("planType")
|
||
or "other",
|
||
)
|
||
|
||
from insurance.generation.feature_flags import enabled_for
|
||
from insurance.generation.reconciliation import reconcile_render_document
|
||
reconciliation_report = reconcile_render_document(
|
||
snapshot.get("planDataProjection"), render_document
|
||
)
|
||
if enabled_for("OUTPUT_RECONCILIATION_V1", identity=task.user_id) and reconciliation_report["status"] == "failed":
|
||
_update_task_status(
|
||
task_id, status="failed", stage="reconciliation_failed", progress=100,
|
||
message="海报文案数值对账失败", error_code="output_reconciliation_failed",
|
||
error_message="海报文案包含冻结计划数据之外的数值,已阻止生成",
|
||
output_reconciliation_json=json.dumps(reconciliation_report, ensure_ascii=False),
|
||
result_state="failed", finished_at=datetime.now(),
|
||
)
|
||
sync_poster_progress(100, "failed", "海报文案数值对账失败")
|
||
return
|
||
|
||
# 组装 prompt(传入小册子 features 用于风格参考)
|
||
from insurance.poster.image_generator import PosterImageGenerator
|
||
generator = PosterImageGenerator()
|
||
prompt = generator.build_prompt(
|
||
template=poster_template.to_dict() if poster_template else None,
|
||
product=product_dict,
|
||
company=company_dict,
|
||
copy=copy_content,
|
||
size=background_size,
|
||
poster_content=poster_content,
|
||
)
|
||
|
||
_update_task_status(task_id, stage="requesting_image", progress=60, message="请求图片生成")
|
||
sync_poster_progress(60, "running")
|
||
|
||
# 生成图片
|
||
generation_mode = "ai"
|
||
provider_info = {}
|
||
try:
|
||
image_bytes, provider_info = generator.generate(
|
||
prompt,
|
||
size=background_size,
|
||
reference_image=reference_image,
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"图片 API 失败,使用降级方案: {e}")
|
||
from insurance.poster.image_generator import generate_fallback
|
||
image_bytes = generate_fallback(copy_content, size=background_size)
|
||
generation_mode = "fallback"
|
||
|
||
_update_task_status(task_id, stage="saving", progress=85, message="保存文件")
|
||
sync_poster_progress(85, "running")
|
||
|
||
# 写盘前先确认供应商/降级结果确实是可解码图片,避免留下损坏资产。
|
||
from PIL import Image
|
||
import io
|
||
with Image.open(io.BytesIO(image_bytes)) as generated_image:
|
||
generated_image.verify()
|
||
with Image.open(io.BytesIO(image_bytes)) as generated_image:
|
||
actual_background = {
|
||
"width": generated_image.width,
|
||
"height": generated_image.height,
|
||
}
|
||
|
||
# 保存文件
|
||
from insurance.config import get_storage_root
|
||
output_dir = os.path.join(get_storage_root(), "outputs", "posters", task_id)
|
||
os.makedirs(output_dir, exist_ok=True)
|
||
filename = f"poster.png"
|
||
filepath = os.path.join(output_dir, filename)
|
||
with open(filepath, "wb") as f:
|
||
f.write(image_bytes)
|
||
|
||
# 更新记录
|
||
record = PosterRecord.query.get(task.workspace_id)
|
||
if record:
|
||
if render_document:
|
||
render_document["background"] = {
|
||
"requestedSize": background_size,
|
||
"actualOutput": actual_background,
|
||
"provider": provider_info.get("provider", ""),
|
||
"model": provider_info.get("model", ""),
|
||
"generationMode": generation_mode,
|
||
}
|
||
record.document_json = json.dumps(render_document, ensure_ascii=False)
|
||
record.background_file_url = filepath
|
||
record.export_format = "png"
|
||
record.generation_mode = generation_mode
|
||
record.image_provider = provider_info.get("provider", "")
|
||
record.image_model = provider_info.get("model", "")
|
||
record.prompt_used = prompt[:2000] if prompt else None
|
||
record.task_status = "background_ready"
|
||
record.task_progress = 90
|
||
record.finished_at = None
|
||
db.session.commit()
|
||
|
||
# 完成任务
|
||
_update_task_status(
|
||
task_id,
|
||
status="done",
|
||
stage="completed",
|
||
progress=100,
|
||
message="背景生成完成,等待最终合成",
|
||
finished_at=datetime.now(),
|
||
output_json=json.dumps({
|
||
"backgroundUrl": f"/insurance/poster/records/{record.id}/background",
|
||
"backgroundFilePath": filepath,
|
||
"filePath": filepath,
|
||
"generationMode": generation_mode,
|
||
"provider": provider_info,
|
||
"documentRevision": record.document_revision or 1,
|
||
}, ensure_ascii=False),
|
||
output_reconciliation_json=json.dumps(reconciliation_report, ensure_ascii=False),
|
||
visual_check_json=json.dumps({
|
||
"status": "unverified",
|
||
"reason": "AI 背景图需要最终合成后进行人工视觉验收",
|
||
"actualSize": actual_background,
|
||
}, ensure_ascii=False),
|
||
)
|
||
|
||
|
||
# ─── 海报计划书解析任务 ──────────────────────────────────────
|
||
|
||
POSTER_CASE_PARSE_SOFT_TIMEOUT = 300
|
||
POSTER_CASE_PARSE_HARD_TIMEOUT = 360
|
||
|
||
|
||
@shared_task(
|
||
bind=True,
|
||
name="insurance.parse_poster_case",
|
||
queue="insurance",
|
||
soft_time_limit=POSTER_CASE_PARSE_SOFT_TIMEOUT,
|
||
time_limit=POSTER_CASE_PARSE_HARD_TIMEOUT,
|
||
)
|
||
def parse_poster_case_task(self, case_upload_id: int):
|
||
"""在 Worker 中解析海报计划书,数据库状态转换保证幂等。"""
|
||
from insurance.db.compat import db
|
||
|
||
claimed = db.session.execute(
|
||
db.text(
|
||
"UPDATE poster_case_uploads SET parse_status = 'parsing', "
|
||
"parse_progress = 10, parse_message = '正在读取 PDF 文本', "
|
||
"parse_error = NULL, parse_started_at = NOW(), "
|
||
"parse_heartbeat_at = NOW(), parse_finished_at = NULL "
|
||
"WHERE id = :id AND parse_status = 'queued'"
|
||
),
|
||
{"id": case_upload_id},
|
||
)
|
||
db.session.commit()
|
||
if claimed.rowcount == 0:
|
||
logger.info("海报计划书 %s 已被领取或不在 queued 状态,跳过", case_upload_id)
|
||
return
|
||
|
||
try:
|
||
from insurance.poster.tasks import _execute_case_parse
|
||
|
||
_execute_case_parse(case_upload_id)
|
||
except Exception as exc:
|
||
logger.error("海报计划书解析失败 [%s]: %s", case_upload_id, exc, exc_info=True)
|
||
from insurance.poster.tasks import _mark_case_failed
|
||
|
||
_mark_case_failed(case_upload_id, str(exc))
|
||
raise
|
||
finally:
|
||
db.session.remove()
|
||
|
||
|
||
# ─── 产品小册子解析任务 ──────────────────────────────────────
|
||
|
||
MANUAL_PARSE_SOFT_TIMEOUT = 300 # 5 分钟软超时
|
||
MANUAL_PARSE_HARD_TIMEOUT = 360 # 6 分钟硬超时
|
||
|
||
|
||
@shared_task(
|
||
bind=True,
|
||
name="insurance.parse_product_manual",
|
||
queue="insurance",
|
||
soft_time_limit=MANUAL_PARSE_SOFT_TIMEOUT,
|
||
time_limit=MANUAL_PARSE_HARD_TIMEOUT,
|
||
)
|
||
def parse_product_manual_task(self, product_id: str):
|
||
"""产品小册子 PDF 解析任务(异步)。
|
||
|
||
流程:queued → parsing → parsed / failed
|
||
"""
|
||
from insurance.db.compat import db
|
||
from insurance.models.ppt_config import PptProduct
|
||
|
||
product = PptProduct.query.get(product_id)
|
||
if not product:
|
||
logger.error(f"小册子解析任务:产品 {product_id} 不存在")
|
||
return
|
||
|
||
# 状态检查:只处理 queued 状态
|
||
if product.manual_parse_status != "queued":
|
||
logger.info(f"产品 {product_id} 当前状态 {product.manual_parse_status},跳过")
|
||
return
|
||
|
||
# 更新为 parsing
|
||
product.manual_parse_status = "parsing"
|
||
product.manual_parse_message = "正在解析 PDF..."
|
||
product.manual_parse_error = None
|
||
product.manual_parse_started_at = datetime.now()
|
||
product.manual_parse_finished_at = None
|
||
db.session.commit()
|
||
|
||
try:
|
||
import asyncio
|
||
import os
|
||
from insurance.poster.manual_parser import parse_manual_pdf
|
||
|
||
filepath = product.manual_file_url
|
||
if not filepath or not os.path.exists(filepath):
|
||
raise FileNotFoundError(f"小册子文件不存在: {filepath}")
|
||
|
||
# 更新进度
|
||
product.manual_parse_message = "正在提取文本并调用 LLM..."
|
||
db.session.commit()
|
||
|
||
# 执行解析(同步调用异步函数)
|
||
loop = asyncio.new_event_loop()
|
||
try:
|
||
result = loop.run_until_complete(parse_manual_pdf(filepath))
|
||
finally:
|
||
loop.close()
|
||
|
||
# 成功
|
||
product.manual_parsed_rules = json.dumps(result, ensure_ascii=False)
|
||
product.manual_parse_status = "parsed"
|
||
product.manual_parse_message = "解析完成"
|
||
product.manual_parse_error = None
|
||
product.manual_parse_finished_at = datetime.now()
|
||
db.session.commit()
|
||
logger.info(f"产品 {product_id} 小册子解析成功")
|
||
|
||
except Exception as exc:
|
||
error_msg = str(exc)[:1000]
|
||
logger.error(f"产品 {product_id} 小册子解析失败: {exc}", exc_info=True)
|
||
product.manual_parse_status = "failed"
|
||
product.manual_parse_message = "解析失败"
|
||
product.manual_parse_error = error_msg
|
||
product.manual_parse_finished_at = datetime.now()
|
||
db.session.commit()
|
||
|
||
|
||
@shared_task(
|
||
bind=True,
|
||
name="insurance.parse_user_product_material",
|
||
queue="insurance",
|
||
soft_time_limit=MANUAL_PARSE_SOFT_TIMEOUT,
|
||
time_limit=MANUAL_PARSE_HARD_TIMEOUT,
|
||
)
|
||
def parse_user_product_material_task(self, material_id: int):
|
||
"""解析用户私有产品小册子。"""
|
||
from insurance.db.compat import db
|
||
from insurance.models.user_product_material import UserProductMaterial
|
||
|
||
material = UserProductMaterial.query.get(material_id)
|
||
if not material:
|
||
logger.error("用户小册子解析任务:资料 %s 不存在", material_id)
|
||
return
|
||
if material.parse_status != "queued":
|
||
logger.info("用户资料 %s 当前状态 %s,跳过", material_id, material.parse_status)
|
||
return
|
||
|
||
material.parse_status = "parsing"
|
||
material.parse_message = "正在提取文本并解析产品信息..."
|
||
material.parse_error = None
|
||
material.parse_started_at = datetime.now()
|
||
material.parse_finished_at = None
|
||
db.session.commit()
|
||
|
||
try:
|
||
import asyncio
|
||
import os
|
||
|
||
from insurance.poster.manual_parser import parse_manual_pdf
|
||
from insurance.poster.product_material_service import resolve_file_key
|
||
|
||
filepath = resolve_file_key(material.file_key)
|
||
if not os.path.exists(filepath):
|
||
raise FileNotFoundError("小册子原文件不存在")
|
||
|
||
loop = asyncio.new_event_loop()
|
||
try:
|
||
result = loop.run_until_complete(parse_manual_pdf(filepath))
|
||
finally:
|
||
loop.close()
|
||
|
||
if not isinstance(result, dict):
|
||
raise ValueError("解析结果格式错误")
|
||
material.parsed_rules = json.dumps(result, ensure_ascii=False)
|
||
material.product_name = str(result.get("product_name") or "")[:150] or None
|
||
material.parse_status = "parsed"
|
||
material.parse_message = "解析完成,请核对产品信息"
|
||
material.parse_error = None
|
||
material.parse_finished_at = datetime.now()
|
||
material.confirmed_rules = None
|
||
material.confirmed_by = None
|
||
material.confirmed_at = None
|
||
db.session.commit()
|
||
logger.info("用户产品资料 %s 解析成功", material_id)
|
||
except Exception as exc:
|
||
logger.error("用户产品资料 %s 解析失败: %s", material_id, exc, exc_info=True)
|
||
material.parse_status = "failed"
|
||
material.parse_message = "解析失败"
|
||
material.parse_error = str(exc)[:1000]
|
||
material.parse_finished_at = datetime.now()
|
||
db.session.commit()
|
||
|
||
|
||
def recover_stale_manual_tasks():
|
||
"""启动时恢复卡住的小册子解析任务。
|
||
|
||
将长时间停留在 queued/parsing 的记录标记为 failed。
|
||
应在应用启动时调用。
|
||
"""
|
||
from insurance.db.compat import db
|
||
from insurance.models.ppt_config import PptProduct
|
||
from insurance.models.user_product_material import UserProductMaterial
|
||
|
||
timeout_minutes = 10
|
||
from datetime import timedelta
|
||
cutoff = datetime.now() - timedelta(minutes=timeout_minutes)
|
||
|
||
# 恢复卡在 parsing 的记录
|
||
stale_parsing = PptProduct.query.filter(
|
||
PptProduct.manual_parse_status == "parsing",
|
||
PptProduct.manual_parse_started_at < cutoff,
|
||
).all()
|
||
for p in stale_parsing:
|
||
p.manual_parse_status = "failed"
|
||
p.manual_parse_error = "任务因服务重启而中断,请重新解析"
|
||
p.manual_parse_finished_at = datetime.now()
|
||
|
||
# 恢复长时间 queued 的记录
|
||
stale_queued = PptProduct.query.filter(
|
||
PptProduct.manual_parse_status == "queued",
|
||
PptProduct.updated_at < cutoff,
|
||
).all()
|
||
for p in stale_queued:
|
||
p.manual_parse_status = "failed"
|
||
p.manual_parse_error = "任务排队超时,请重新解析"
|
||
p.manual_parse_finished_at = datetime.now()
|
||
|
||
stale_user_materials = UserProductMaterial.query.filter(
|
||
UserProductMaterial.parse_status.in_(["queued", "parsing"]),
|
||
UserProductMaterial.updated_at < cutoff,
|
||
).all()
|
||
for material in stale_user_materials:
|
||
material.parse_status = "failed"
|
||
material.parse_message = "解析任务已中断"
|
||
material.parse_error = "任务因服务重启或排队超时而中断,请重新解析"
|
||
material.parse_finished_at = datetime.now()
|
||
|
||
if stale_parsing or stale_queued or stale_user_materials:
|
||
db.session.commit()
|
||
logger.info(
|
||
"已恢复 %s 个公共产品解析任务、%s 个排队任务、%s 个用户资料任务",
|
||
len(stale_parsing),
|
||
len(stale_queued),
|
||
len(stale_user_materials),
|
||
)
|