baodan/api/insurance/generation/celery_tasks.py
wsb1224 9dc494d968 主要修改:
利益表按最多两页、9000字符分块调用 LLM,成功分块按保单年度合并、去重、排序。[extraction.py (line 125)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:125)
身份字段只发送关键页面;没有提领关键词时不再发送整份 PDF 做空提领请求。
单个利益分块失败不会丢失其他成功分块,并记录具体分块编号和错误。[extraction.py (line 1161)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:1161)
RemoteProtocolError、连接超时、408/429/部分5xx现在会按配置执行真实退避重试;DeepSeek默认最多调用3次。[llm_client.py (line 600)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/llm_client.py:600)
只有一个供应商时,错误信息会明确显示“已配置1个供应商”,不再误导为存在多个备用供应商。
利益表分块失败或储蓄险年度数据不足时,结果标记为 partial,不再伪装完整成功。[extraction.py (line 750)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:750)
会话显示“解析完成,部分文件需校对”,同时保留可人工修正的数据。
日志增加文件名、文件序号、最终状态、利益行数,以及身份/利益各分块/提领的独立耗时、token和错误信息。[celery_tasks.py (line 181)](D:/work/code/python/coding/baodanagent/api/insurance/generation/celery_tasks.py:181)
缓存版本升级至 v8,旧解析缓存自动失效。
验证结果:
PPT专项测试:54 passed, 1 skipped
大范围测试:231 passed, 1 skipped
仅剩既有聊天日志测试缺少 Flask application context
完整测试收集另受本机缺少 python-pptx 影响
Python语法检查:通过
前端生产构建:通过
git diff --check:通过
2026-08-01 02:25:35 +08:00

1531 lines
62 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 _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:
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,
))
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,
})
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)
extractions.append({
"pdfName": filename, "pdfPath": filepath, "planType": plan_type,
"companyId": company_id, "productId": product_id,
"status": "error", "productName": "unknown", "data": None,
"error": str(exc), "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")
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)
_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
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 {}
company_id = snapshot.get("companyId", "")
brand_policy = snapshot.get("brandPolicy") or {}
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", "")
normalized["companyId"] = ext.get("companyId") or company_id or ""
product_id = ext.get("productId")
if product_id:
configured_product = PptProduct.query.filter_by(
id=product_id, status=1, deleted_at=None
).first()
if configured_product:
product_config = configured_product.to_dict()
product_masking = (brand_policy.get("productMaskingById") or {}).get(
str(product_id),
bool(configured_product.masking_enabled),
)
if product_masking:
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)
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")
# 加载模板和公司信息
template = None
if template_id:
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
else:
from insurance.ppt.scenarios import scenario_codes_for_auto_match
template = PptTemplate.query.filter(
PptTemplate.scenario_tag.in_(scenario_codes_for_auto_match(scenario)),
PptTemplate.status == 1,
PptTemplate.deleted_at.is_(None),
).first()
if template is None and not template_id:
template = PptTemplate.query.filter_by(
plan_type=plan_type, style_preset=theme, status=1, deleted_at=None
).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_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"
)
if not company_id:
company_id = next(
(item.get("companyId") for item in all_normalized if item.get("companyId")),
"",
)
company_info = None
if company_id:
company = PptCompany.query.filter_by(id=company_id, deleted_at=None).first()
if company:
company_info = company.to_dict()
company_masking = brand_policy.get(
"companyMaskingEnabled",
bool(company.masking_enabled),
)
logo_enabled = brand_policy.get("logoEnabled", bool(company.logo_enabled))
if company_masking:
from insurance.ppt.masking import apply_company_mask
apply_company_mask(company_info, True)
if not logo_enabled:
company_info["logoUrl"] = ""
_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:
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)
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),
"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,
"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": session.generated_revision if session else 0,
}, 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)
_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)
_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",
)
# 组装 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 = datetime.now()
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),
)
# ─── 产品小册子解析任务 ──────────────────────────────────────
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),
)