baodan/api/insurance/generation/celery_tasks.py

1112 lines
43 KiB
Python
Raw Normal View History

"""统一生成任务 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
2026-07-29 12:19:26 +08:00
def _sync_session_progress(session_id: str, progress: int, message: str = None,
extractions: list = None, status: str = None,
error_message: str = None):
2026-07-29 12:19:26 +08:00
"""同步进度到 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 status in ("parsed", "error"):
session.parse_finished_at = datetime.now()
2026-07-29 12:19:26 +08:00
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())
2026-07-29 12:19:26 +08:00
# 同步错误状态到 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
2026-07-29 12:19:26 +08:00
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)
2026-07-29 12:19:26 +08:00
# 同步初始进度到 PptSession
_sync_session_progress(session_id, 5, "正在读取 PDF 文件", status="parsing")
2026-07-29 12:19:26 +08:00
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")
2026-07-29 12:19:26 +08:00
company_id = file_info.get("companyId", "")
product_id = file_info.get("productId", "")
progress = max(5, int((index - 1) / total * 90))
2026-07-29 12:19:26 +08:00
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
)
try:
result = asyncio.run(orchestrator.extract_plan(
filepath,
plan_type,
force_reparse=True,
progress_callback=report_file_progress,
))
extractions.append({
"pdfName": filename,
"pdfPath": filepath,
"planType": result.plan_type,
"status": result.status,
"productName": result.product_name,
2026-07-29 12:19:26 +08:00
"companyId": company_id,
"productId": product_id,
"data": result.data,
"error": result.error,
"yearCount": len(result.data.get("benefit_illustration", [])) if result.data else 0,
})
except Exception as exc:
logger.error(f"PDF 解析失败 [{filename}]: {exc}", exc_info=True)
extractions.append({
"pdfName": filename, "pdfPath": filepath, "planType": plan_type,
2026-07-29 12:19:26 +08:00
"companyId": company_id, "productId": product_id,
"status": "error", "productName": "unknown", "data": None,
"error": str(exc), "yearCount": 0,
})
2026-07-29 12:19:26 +08:00
done_progress = min(99, int(index / total * 100))
_sync_session_progress(session_id, done_progress,
f"已完成 {index}/{total} 个文件", extractions=extractions)
# 更新会话
2026-07-29 12:19:26 +08:00
session = PptSession.query.get(session_id)
if not session:
return
all_failed = all(e.get("status") == "error" 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
2026-07-29 12:19:26 +08:00
session.parse_message = "处理失败" if all_failed else "处理完成"
session.parse_error = first_error[:1000] if all_failed else None
session.parse_finished_at = datetime.now()
db.session.commit()
# 更新任务状态
_update_task_status(
task_id,
status="failed" if all_failed else "done",
stage="completed",
progress=100,
message="解析失败" if all_failed 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
_update_task_status(task_id, stage="validating", progress=5, message="开始生成 PPT")
try:
_execute_ppt_generate(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="generate_error",
error_message=str(exc)[:1000],
finished_at=datetime.now())
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
2026-07-29 12:19:26 +08:00
from insurance.models.ppt_config import PptTemplate, PptCompany, PptProduct
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")
2026-07-29 12:19:26 +08:00
template_id = snapshot.get("templateId", "")
company_id = snapshot.get("companyId", "")
use_masked_data = bool(snapshot.get("useMaskedData"))
requested_scenario = snapshot.get("scenario", "")
extractions = json.loads(session.extractions_json) if session.extractions_json else []
_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 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", "")
2026-07-29 12:19:26 +08:00
product_id = ext.get("productId")
if product_id:
configured_product = PptProduct.query.filter_by(
id=product_id, status=1
).first()
if configured_product:
product_config = configured_product.to_dict()
if use_masked_data:
from insurance.ppt.masking import apply_product_mask
apply_product_mask(product_config, True)
normalized["rawProductName"] = normalized.get("productName", "")
normalized["productName"] = product_config["displayName"]
normalized["productId"] = configured_product.id
normalized["companyId"] = configured_product.company_id
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,
)
scenario = detect_generation_scenario(all_normalized)
if requested_scenario and requested_scenario != scenario:
logger.warning(
"PPT 场景已按实际计划书修正: requested=%s, detected=%s",
requested_scenario,
scenario,
)
generation_mode = generation_mode_for_scenario(scenario)
2026-07-30 10:30:33 +08:00
try:
comparison = build_comparison_contract(all_normalized, mode=generation_mode)
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")
# 加载模板和公司信息
2026-07-29 12:19:26 +08:00
template = None
if template_id:
template = PptTemplate.query.filter_by(
id=template_id, plan_type=plan_type, status=1
).first()
if template and template.scenario_tag and template.scenario_tag != scenario:
logger.warning(
"所选模板场景不匹配,改用自动场景模板: template=%s, scenario=%s",
template.id,
scenario,
)
template = None
if template is None:
template = PptTemplate.query.filter_by(
scenario_tag=scenario, status=1
).first()
2026-07-29 12:19:26 +08:00
if template is None:
template = PptTemplate.query.filter_by(
plan_type=plan_type, style_preset=theme, status=1
).first()
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_config["sourceTemplatePath"] = resolve_template_asset(
template.source_template_asset_id
)
company_info = None
if company_id:
company = PptCompany.query.get(company_id)
if company:
company_info = company.to_dict()
2026-07-29 12:19:26 +08:00
if use_masked_data:
from insurance.ppt.masking import apply_company_mask
apply_company_mask(company_info, True)
_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="保存文件")
2026-07-29 21:41:28 +08:00
# 保存 DeckContract 快照(供后续版本化编辑回溯)
deck_path = None
deck_data = result.get("deck")
if deck_data:
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)
# 解析幻灯片结构(失败不阻断主流程)
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)
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:
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)
2026-07-29 21:41:28 +08:00
if deck_path:
session.deck_contract_path = 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": 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="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),
"useMaskedData": use_masked_data,
"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/ppt/download/{task.workspace_id}",
"slideCount": result.get("slideCount", 0),
"filePath": output_path,
"scenario": scenario,
"generationMode": generation_mode,
}, ensure_ascii=False),
)
2026-07-29 21:41:28 +08:00
# ─── PPT 重新生成任务(版本化)─────────────────────────────
2026-07-30 09:15:07 +08:00
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:
if edit_shape.get("type") != "textbox":
continue
edit_paras = edit_shape.get("paragraphs", [])
if not edit_paras:
continue
edit_x = edit_shape.get("x", 0)
edit_y = edit_shape.get("y", 0)
# 匹配 PPTX 中的形状(按位置)
for shape in slide.shapes:
if not shape.has_text_frame:
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
# 位置匹配,更新文字
_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
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
# 删除多余 runs
for run in para.runs[1:]:
run.text = ""
else:
para.text = new_text
else:
# 新增段落
para = tf.add_paragraph()
para.text = new_text
# 删除多余段落(如果编辑后段落变少了)
# python-pptx 不支持直接删除段落,只能清空
for i in range(len(edit_paras), len(existing_paras)):
existing_paras[i].text = ""
2026-07-29 21:41:28 +08:00
@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)
_update_task_status(task_id,
status="failed",
error_code="regenerate_error",
error_message=str(exc)[:1000],
finished_at=datetime.now())
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")
2026-07-30 10:30:33 +08:00
all_products = deck.get("products", [])
normalized_data = all_products[0] if all_products else {}
2026-07-29 21:41:28 +08:00
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,
2026-07-30 10:30:33 +08:00
all_products=all_products if len(all_products) > 1 else None,
2026-07-29 21:41:28 +08:00
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
2026-07-30 09:15:07 +08:00
_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)
2026-07-29 21:41:28 +08:00
_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)
2026-07-30 09:15:07 +08:00
# 质量检查(传入 extractions 避免误判)
2026-07-29 21:41:28 +08:00
quality_report = None
try:
from insurance.ppt.quality_checker import QualityChecker
checker = QualityChecker()
2026-07-30 09:15:07 +08:00
# 从 session 读取原始 extractions 用于关键数据检查
session_extractions = json.loads(session.extractions_json) if session.extractions_json else []
2026-07-29 21:41:28 +08:00
quality_report = checker.check(
pptx_path=output_path,
slides_data=slides_data,
2026-07-30 09:15:07 +08:00
extractions=session_extractions,
2026-07-29 21:41:28 +08:00
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/ppt/download/{task.workspace_id}",
"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)
_update_task_status(task_id,
status="failed",
error_code="generate_error",
error_message=str(exc)[:1000],
finished_at=datetime.now())
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
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
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", {})
size = snapshot.get("size", "1024x1792")
reference_image = snapshot.get("referenceImage")
use_masked_data = bool(snapshot.get("useMaskedData"))
_update_task_status(task_id, stage="preparing_data", progress=20, message="准备数据")
# 获取模板和产品信息
poster_template = PosterTemplate.query.get(template_id) if template_id else None
product = PptProduct.query.get(product_id) if product_id else None
company = PptCompany.query.get(product.company_id) if product else None
2026-07-29 12:19:26 +08:00
if not reference_image and poster_template:
reference_image = poster_template.reference_image
# 脱敏处理
if use_masked_data:
from insurance.ppt.masking import apply_product_mask, apply_company_mask, mask_text
product_dict = product.to_dict() if product else None
company_dict = company.to_dict() if company else None
if product_dict:
apply_product_mask(product_dict, True)
if company_dict:
apply_company_mask(company_dict, True)
if product and company:
real_name = product.display_name
masked_name = (product_dict or {}).get("displayName", "")
real_company = company.display_name
masked_company = (company_dict or {}).get("displayName", "")
replacements = {}
if real_name and masked_name and real_name != masked_name:
replacements[real_name] = masked_name
if real_company and masked_company and real_company != masked_company:
replacements[real_company] = masked_company
if replacements:
for key in copy_content:
if isinstance(copy_content[key], str):
copy_content[key] = mask_text(copy_content[key], replacements)
else:
product_dict = product.to_dict() if product else None
company_dict = {"displayName": company.display_name} if company else None
_update_task_status(task_id, stage="building_prompt", progress=40, message="构建 Prompt")
# 组装 prompt
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=size,
)
_update_task_status(task_id, stage="requesting_image", progress=60, message="请求图片生成")
# 生成图片
generation_mode = "ai"
provider_info = {}
try:
image_bytes, provider_info = generator.generate(prompt, size=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=size)
generation_mode = "fallback"
_update_task_status(task_id, stage="saving", progress=85, message="保存文件")
# 保存文件
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:
record.export_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 = "done"
record.task_progress = 100
record.finished_at = datetime.now()
record.generated_revision = record.draft_revision
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/poster/download/{task.workspace_id}",
"filePath": filepath,
}, ensure_ascii=False),
)
# ─── 产品小册子解析任务 ──────────────────────────────────────
MANUAL_PARSE_SOFT_TIMEOUT = 300 # 5 分钟软超时
MANUAL_PARSE_HARD_TIMEOUT = 360 # 6 分钟硬超时
@shared_task(
bind=True,
name="insurance.parse_product_manual",
2026-07-29 12:19:26 +08:00
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..."
2026-07-29 12:19:26 +08:00
product.manual_parse_error = None
product.manual_parse_started_at = datetime.now()
2026-07-29 12:19:26 +08:00
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()
def recover_stale_manual_tasks():
"""启动时恢复卡住的小册子解析任务。
将长时间停留在 queued/parsing 的记录标记为 failed
应在应用启动时调用
"""
from insurance.db.compat import db
from insurance.models.ppt_config import PptProduct
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()
if stale_parsing or stale_queued:
db.session.commit()
logger.info(f"已恢复 {len(stale_parsing)} 个解析过期任务, {len(stale_queued)} 个排队超时任务")