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