baodan/api/insurance/generation/celery_tasks.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

1898 lines
79 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""统一生成任务 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:
"""像素转 EMU96 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),
)