"""PPT 生成模块 Blueprint 路由。""" import os import uuid import json import logging from datetime import datetime from flask import Blueprint, request, jsonify, send_file from insurance.middleware.auth_middleware import jwt_required from insurance.utils.response import success, error, ErrorCode logger = logging.getLogger(__name__) ppt_bp = Blueprint("ppt", __name__) def _get_session(session_id: str, user_id: str): """获取会话记录。""" from insurance.db.compat import db from insurance.models.ppt_session import PptSession session = PptSession.query.filter_by(id=session_id).first() if not session: return None if session.user_id != user_id: return None return session def _save_session(session): """保存会话记录。""" from insurance.db.compat import db db.session.add(session) db.session.commit() # ─── 健康检查 ───────────────────────────────────────────── @ppt_bp.route("/health", methods=["GET"]) def health(): return success({"status": "ok"}) # ─── 渲染选项 ───────────────────────────────────────────── @ppt_bp.route("/render-options", methods=["GET"]) def render_options(): """获取可用的公司和模板风格列表。""" from insurance.models.ppt_config import PptCompany, PptProduct, PptScenario, PptTemplate companies = PptCompany.query.filter_by(status=1, deleted_at=None).order_by( PptCompany.sort_order.asc(), PptCompany.id.asc() ).all() products = PptProduct.query.filter_by(status=1, deleted_at=None).order_by( PptProduct.sort_order.asc(), PptProduct.id.asc() ).all() templates = PptTemplate.query.filter_by(status=1, deleted_at=None).order_by( PptTemplate.plan_type.asc(), PptTemplate.id.asc() ).all() scenarios = PptScenario.query.filter_by(status=1, deleted_at=None).order_by( PptScenario.sort_order.asc(), PptScenario.code.asc() ).all() scenario_map = {item.code: item.to_dict() for item in scenarios} template_items = [] for template in templates: item = template.to_dict() scenario_config = scenario_map.get(template.scenario_tag) or {} item["scenarioBase"] = scenario_config.get("baseScenario") item["generationMode"] = scenario_config.get("generationMode") item["scenarioName"] = scenario_config.get("name") template_items.append(item) from insurance.ppt.masking import public_company_option, public_product_option return success({ "companies": [public_company_option(c.to_dict()) for c in companies], "products": [public_product_option(p.to_dict()) for p in products], "templates": template_items, "scenarios": [item.to_dict() for item in scenarios], }) # ─── 上传 PDF ───────────────────────────────────────────── @ppt_bp.route("/upload", methods=["POST"]) @jwt_required def upload_pdfs(): """上传 PDF 文件并创建会话。 支持 1~10 份计划书,五组并行数组(files/types/companies/products/passwords)长度必须一致。 采用"先校验后写入"的原子上传策略:任一文件校验失败时不创建会话。 """ from flask import jsonify as _jsonify MAX_UPLOAD_FILES = 10 VALID_TYPES = {"savings", "ci", "iul"} user_id = str(getattr(request, "user_id", "guest")) files = request.files.getlist("files") types = request.form.getlist("types") companies = request.form.getlist("companies") products = request.form.getlist("products") passwords = request.form.getlist("passwords") # ── 基础参数校验 ── if not files: return error(ErrorCode.PARAM_ERROR, "请至少上传 1 份计划书") if len(files) > MAX_UPLOAD_FILES: return error(ErrorCode.PARAM_ERROR, f"最多支持上传 {MAX_UPLOAD_FILES} 份计划书") for name, arr in [("types", types), ("companies", companies), ("products", products), ("passwords", passwords)]: if len(arr) != len(files): return error(ErrorCode.PARAM_ERROR, f"{name} 数组长度与 files 不一致") for i, t in enumerate(types): if t not in VALID_TYPES: return error(ErrorCode.PARAM_ERROR, f"第 {i + 1} 份文件的险种 '{t}' 不合法") # ── 第一阶段:校验全部文件,不写入磁盘 ── from insurance.utils.security import prepare_pdf_upload from insurance.models.ppt_config import PptCompany, PptProduct validated = [] # (f, pdf_bytes, file_record) 通过校验的文件 file_errors = [] # 结构化错误列表 for i, f in enumerate(files): fname = f.filename or f"文件{i + 1}" if not f.filename or not f.filename.lower().endswith(".pdf"): file_errors.append({"index": i, "fileName": fname, "field": "file", "message": "仅支持 PDF 格式"}) continue plan_type = types[i] company_id = companies[i] if i < len(companies) else "" product_id = products[i] if i < len(products) else "" password = passwords[i] if i < len(passwords) else "" # 保司校验 company = PptCompany.query.filter_by( id=company_id, status=1, deleted_at=None ).first() if company_id else None if company_id and not company: file_errors.append({"index": i, "fileName": fname, "field": "company", "message": "所选保司不存在或已停用"}) continue # 产品校验 product = PptProduct.query.filter_by( id=product_id, status=1, deleted_at=None ).first() if product_id else None if product_id and ( not product or product.plan_type != plan_type or (company_id and product.company_id != company_id) ): file_errors.append({"index": i, "fileName": fname, "field": "product", "message": "所选产品与险种或保司不匹配"}) continue if product and not company_id: company_id = product.company_id # PDF 安全校验 is_valid, err_msg, pdf_bytes = prepare_pdf_upload(f, password) if not is_valid: file_errors.append({"index": i, "fileName": fname, "field": "password", "message": err_msg or "PDF 校验失败"}) continue validated.append((f, pdf_bytes, { "name": fname, "type": plan_type, "companyId": company_id, "productId": product_id, })) # 任一文件失败则整体拒绝 if file_errors: return _jsonify({ "code": ErrorCode.FILE_FORMAT_ERROR, "message": "部分计划书校验失败", "data": {"fileErrors": file_errors}, }), 400 if not validated: return error(ErrorCode.FILE_FORMAT_ERROR, "无有效 PDF 文件") # ── 第二阶段:全部通过,统一写入磁盘并创建会话 ── from insurance.config import get_storage_root upload_dir = os.path.join(get_storage_root(), "uploads", "ppt", user_id) os.makedirs(upload_dir, exist_ok=True) file_records = [] for f, pdf_bytes, meta in validated: filename = f"{uuid.uuid4().hex[:8]}_{meta['name']}" filepath = os.path.join(upload_dir, filename) with open(filepath, "wb") as output: output.write(pdf_bytes) file_records.append({ "path": filepath, "name": meta["name"], "type": meta["type"], "companyId": meta["companyId"], "productId": meta["productId"], }) session_id = uuid.uuid4().hex from insurance.models.ppt_session import PptSession session = PptSession( id=session_id, user_id=user_id, status="created", files_json=json.dumps(file_records, ensure_ascii=False), ) _save_session(session) return success({ "sessionId": session_id, "files": [f["name"] for f in file_records], }) # ─── 解析 PDF ───────────────────────────────────────────── @ppt_bp.route("/parse/", methods=["POST"]) @jwt_required def parse_session(session_id): """触发 AI 解析 PDF(异步任务)。""" user_id = str(getattr(request, "user_id", "guest")) session = _get_session(session_id, user_id) if not session: return error(ErrorCode.NOT_FOUND, "会话不存在") files = json.loads(session.files_json) if session.files_json else [] if not files: return error(ErrorCode.PARAM_ERROR, "没有可解析的 PDF 文件") if session.status == "parsing": _reconcile_parse_task(session) if session.status == "parsing": return success(_build_parse_status(session), "解析任务正在进行") # 创建异步任务 from insurance.generation import task_service session.status = "parsing" session.parse_progress = 0 session.parse_message = "解析任务已提交" session.parse_error = None session.parse_started_at = datetime.now() session.parse_finished_at = None session.extractions_json = json.dumps([], ensure_ascii=False) _save_session(session) result = task_service.create_task( user_id=user_id, artifact_type="ppt", operation="parse", workspace_id=session_id, title=session.title or f"PPT {session_id[:8]}", input_snapshot={"files": files}, input_revision=getattr(session, "draft_revision", None) or 1, ) if result.get("code") != 0: message = result.get("message", "解析任务提交失败") session.status = "error" session.parse_progress = 100 session.parse_message = "处理失败" session.parse_error = message session.parse_finished_at = datetime.now() _save_session(session) return error(ErrorCode.SERVER_ERROR, message, status=503) session.latest_task_id = result["data"]["id"] _save_session(session) message = "解析任务已启动" return success({ "sessionId": session_id, "status": session.status, "progress": session.parse_progress or 0, "message": message, "taskId": result.get("data", {}).get("id") if result.get("code") == 0 else None, }) @ppt_bp.route("/parse//status", methods=["GET"]) @jwt_required def parse_status(session_id): """获取 AI 解析进度。""" user_id = str(getattr(request, "user_id", "guest")) session = _get_session(session_id, user_id) if not session: return error(ErrorCode.NOT_FOUND, "会话不存在") _reconcile_parse_task(session) return success(_build_parse_status(session)) def _reconcile_parse_task(session): """修正任务表与 PPT 会话不一致的终态。""" if session.status != "parsing" or not session.latest_task_id: return from insurance.models.generation_task import GenerationTask task = GenerationTask.query.get(session.latest_task_id) if not task or task.status not in ("failed", "cancelled"): return session.status = "error" session.parse_progress = 100 session.parse_message = "处理失败" session.parse_error = task.error_message or ( "任务已取消" if task.status == "cancelled" else "解析任务失败,请重新提交" ) session.parse_finished_at = task.finished_at or datetime.now() _save_session(session) def _build_parse_status(session): extractions = json.loads(session.extractions_json) if session.extractions_json else [] return { "sessionId": session.id, "status": session.status, "progress": session.parse_progress or 0, "message": session.parse_message or "", "error": session.parse_error, "extractions": [{ "pdfName": e.get("pdfName", ""), "planType": e.get("planType", ""), "status": e.get("status", ""), "productName": e.get("productName", ""), "yearCount": e.get("yearCount", 0), "error": e.get("error"), } for e in extractions], } def _reassess_extraction(ext: dict): from insurance.ppt.extraction import assess_extraction_payload, infer_plan_type data = ext.get("data") if not data: return plan_type = infer_plan_type(data) status, extraction_error = assess_extraction_payload(data, plan_type) data["product_type"] = plan_type ext["planType"] = plan_type ext["status"] = status ext["productName"] = (data.get("product_name") or "").strip() or "unknown" ext["error"] = extraction_error or None rows = data.get("benefit_illustration") or data.get("benefitRows") or [] ext["yearCount"] = len(rows) if isinstance(rows, list) else 0 # ─── 获取会话状态 ───────────────────────────────────────── @ppt_bp.route("/session/", methods=["GET"]) @jwt_required def get_session(session_id): """获取完整会话状态。""" user_id = str(getattr(request, "user_id", "guest")) session = _get_session(session_id, user_id) if not session: return error(ErrorCode.NOT_FOUND, "会话不存在") return success(session.to_dict()) # ─── 对话 ───────────────────────────────────────────────── @ppt_bp.route("/chat/", methods=["POST"]) @jwt_required def chat(session_id): """AI 保险顾问对话。""" user_id = str(getattr(request, "user_id", "guest")) session = _get_session(session_id, user_id) if not session: return error(ErrorCode.NOT_FOUND, "会话不存在") data = request.get_json(silent=True) or {} message = data.get("message", "").strip() if not message: return error(ErrorCode.PARAM_ERROR, "消息不能为空") import asyncio from insurance.ppt.llm_client import llm_client # 构建上下文 extractions = json.loads(session.extractions_json) if session.extractions_json else [] context_parts = [] for ext in extractions: if ext.get("data"): context_parts.append(f"产品: {ext['productName']}, 类型: {ext['planType']}") data_inner = ext["data"] policy = data_inner.get("policy", {}) context_parts.append(f"年缴保费: {policy.get('annual_premium', 'N/A')}") context_parts.append(f"缴费年期: {policy.get('premium_payment_period', 'N/A')}") system_prompt = ( "你是一位资深的香港保险顾问,擅长为保险经纪人分析保险计划书。" "请基于以下保单数据,用温暖、专业、数据驱动的方式回答问题。\n\n" f"保单数据:\n{''.join(context_parts)}" ) try: response = asyncio.run(llm_client.chat(message, system_prompt)) reply = response.content except Exception as e: reply = f"抱歉,暂时无法回答。错误信息:{e}" # 更新对话历史 history = json.loads(session.chat_history_json) if session.chat_history_json else [] history.append({"role": "user", "content": message}) history.append({"role": "assistant", "content": reply}) # 保留最近 20 条 history = history[-20:] session.chat_history_json = json.dumps(history, ensure_ascii=False) _save_session(session) return success({ "sessionId": session_id, "message": reply, "history": history, }) # ─── 生成 PPT ───────────────────────────────────────────── @ppt_bp.route("/generate/", methods=["POST"]) @jwt_required def generate_ppt(session_id): """生成 PPT(异步任务)。 创建任务后立即返回 202,前端通过任务接口轮询状态。 """ user_id = str(getattr(request, "user_id", "guest")) session = _get_session(session_id, user_id) if not session: return error(ErrorCode.NOT_FOUND, "会话不存在") data = request.get_json(silent=True) or {} theme = data.get("theme") or data.get("style") or "broker" requested_company_id = data.get("companyId", "") template_id = data.get("templateId", "") files = json.loads(session.files_json) if session.files_json else [] product_ids = [item.get("productId") for item in files if item.get("productId")] company_ids = list(dict.fromkeys( item.get("companyId") for item in files if item.get("companyId") )) if not company_ids and requested_company_id: company_ids = [requested_company_id] from insurance.models.ppt_config import PptCompany configured_companies = ( PptCompany.query.filter( PptCompany.id.in_(company_ids), PptCompany.status == 1, PptCompany.deleted_at.is_(None), ).all() if company_ids else [] ) companies_by_id = {item.id: item for item in configured_companies} missing_company_ids = [item for item in company_ids if item not in companies_by_id] if missing_company_ids: return error(ErrorCode.PARAM_ERROR, "所选保司不存在或已停用") configured_companies = [companies_by_id[item] for item in company_ids] company_id = company_ids[0] if len(company_ids) == 1 else "" from insurance.models.ppt_config import PptProduct configured_products = ( PptProduct.query.filter( PptProduct.id.in_(product_ids), PptProduct.status == 1, PptProduct.deleted_at.is_(None), ).all() if product_ids else [] ) from insurance.ppt.masking import build_brand_policy brand_policy = build_brand_policy( [company.to_dict() for company in configured_companies], [product.to_dict() for product in configured_products], ) template_data = None template_asset_snapshot = None if template_id: from insurance.models.ppt_config import PptTemplate from insurance.ppt.comparison import detect_generation_scenario template = PptTemplate.query.filter_by( id=template_id, status=1, deleted_at=None ).first() if not template: return error(ErrorCode.PARAM_ERROR, "所选 PPT 模板不存在或已停用") template_data = template.to_dict() if template.source_template_asset_id: from insurance.ppt.template_asset_service import ( resolve_template_asset, template_asset_sha256, ) try: asset_path = resolve_template_asset(template.source_template_asset_id) asset_sha256 = template.asset_sha256 or ( template_asset_sha256(asset_path) if asset_path and os.path.isfile(asset_path) else None ) except (OSError, ValueError): asset_path = None asset_sha256 = None if not asset_path or not asset_sha256: return error(ErrorCode.PARAM_ERROR, "所选 PPT 模板文件不存在或校验失败") template_asset_snapshot = { "assetId": template.source_template_asset_id, "version": template.asset_version or 1, "sha256": asset_sha256, "rendererMode": template.clone_renderer or "clone-edit-v2", } file_kinds = [ {"kind": item.get("type")} for item in files if item.get("type") ] file_scenario = detect_generation_scenario(file_kinds) if file_kinds else "" primary_plan_type = file_kinds[0]["kind"] if file_kinds else "" from insurance.ppt.scenarios import template_scenario_compatible if ( template.scenario_tag and file_scenario and not template_scenario_compatible(template.scenario_tag, file_scenario) ): return error(ErrorCode.PARAM_ERROR, "所选 PPT 模板不适用于当前计划书组合") if ( not template.scenario_tag and primary_plan_type and template.plan_type != primary_plan_type ): return error(ErrorCode.PARAM_ERROR, "所选 PPT 模板不适用于当前险种") applicable_companies = template_data.get("applicableCompanyIds") or [] applicable_products = template_data.get("applicableProductIds") or [] if applicable_companies and any( item not in applicable_companies for item in company_ids ): return error(ErrorCode.PARAM_ERROR, "所选 PPT 模板不适用于当前保司") if applicable_products and not set(product_ids).intersection(applicable_products): return error(ErrorCode.PARAM_ERROR, "所选 PPT 模板不适用于当前产品") theme = template.style_preset extractions = json.loads(session.extractions_json) if session.extractions_json else [] if not extractions: return error(ErrorCode.PARAM_ERROR, "无解析数据") # 快速校验:确保有可生成的数据 has_valid = any( e.get("status") in ("success", "partial") and e.get("data") for e in extractions ) if not has_valid: return error(ErrorCode.PARAM_ERROR, "无有效提取数据") from insurance.ppt.comparison import detect_generation_scenario scenario = detect_generation_scenario([ {"kind": e.get("planType") or (e.get("data") or {}).get("product_type")} for e in extractions if e.get("status") in ("success", "partial") and e.get("data") ]) # 更新会话草稿选项 import uuid session.draft_options_json = json.dumps({ "theme": theme, "templateId": template_id, "templateName": (template_data or {}).get("name") or template_id, "stylePreset": (template_data or {}).get("stylePreset") or theme, "companyId": company_id, "companyIds": company_ids, "productIds": product_ids, "brandPolicy": brand_policy, "scenario": scenario, }, ensure_ascii=False) session.draft_revision = (session.draft_revision or 1) + 1 _save_session(session) # 创建异步任务 from insurance.generation import task_service result = task_service.create_task( user_id=user_id, artifact_type="ppt", operation="generate", workspace_id=session_id, title=session.title or f"PPT {session_id[:8]}", input_snapshot={ "theme": theme, "templateId": template_id, "companyId": company_id, "companyIds": company_ids, "productIds": product_ids, "brandPolicy": brand_policy, "scenario": scenario, "templateAsset": template_asset_snapshot, }, input_revision=session.draft_revision or 1, idempotency_key=f"ppt_gen_{session_id}_{session.draft_revision}", ) if result.get("code") != 0: return error(ErrorCode.PARAM_ERROR, result.get("message", "创建任务失败")) task_data = result["data"] session.latest_task_id = task_data["id"] _save_session(session) from flask import jsonify, make_response resp = make_response(jsonify({ "code": 0, "data": { "taskId": task_data["id"], "status": "queued", "sessionId": session_id, "scenario": scenario, "pollUrl": f"/insurance/workspace/tasks/{task_data['id']}", }, })) resp.status_code = 202 return resp # ─── 下载 PPT ───────────────────────────────────────────── @ppt_bp.route("/download/", methods=["GET"]) @jwt_required def download_ppt(session_id): """下载生成的 PPT。""" user_id = str(getattr(request, "user_id", "guest")) session = _get_session(session_id, user_id) if not session: return error(ErrorCode.NOT_FOUND, "会话不存在") if not session.ppt_path or not os.path.exists(session.ppt_path): return error(ErrorCode.NOT_FOUND, "PPT 文件不存在") # 记录下载历史 try: _record_history( user_id=user_id, action_type="download", session_id=session_id, file_url=session.ppt_path, ) except Exception: logger.warning("记录下载历史失败", exc_info=True) return send_file( session.ppt_path, as_attachment=True, download_name=f"{session_id}.pptx", mimetype="application/vnd.openxmlformats-officedocument.presentationml.presentation", ) # ─── 验证提取数据 ───────────────────────────────────────── @ppt_bp.route("/validate/", methods=["GET"]) @jwt_required def validate_extraction(session_id): """验证提取数据完整性。""" user_id = str(getattr(request, "user_id", "guest")) session = _get_session(session_id, user_id) if not session: return error(ErrorCode.NOT_FOUND, "会话不存在") extractions = json.loads(session.extractions_json) if session.extractions_json else [] all_issues = [] 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 for idx, ext in enumerate(extractions): pdf_name = ext.get("pdfName", f"文件{idx + 1}") product_name = ext.get("productName", "unknown") # 解析失败的文件必须产生阻断问题,不能静默跳过 if ext.get("status") not in ("success", "partial") or not ext.get("data"): error_detail = ext.get("error", "未知错误") all_issues.append({ "extractionIndex": idx, "pdfName": pdf_name, "field": "EXTRACTION_FAILED", "path": "", "section": "fields", "severity": "error", "message": f"{pdf_name}({product_name})解析失败:{error_detail}", "suggestedAction": "review", "state": "unresolved", }) continue data = ext["data"] plan_type = (ext.get("planType") or data.get("product_type") or "savings").lower() pdf_path = ext.get("pdfPath") try: if plan_type == "ci": normalized = normalize_ci_plan(data, pdf_path) issues = validate_formal_ci_plan(normalized) elif plan_type == "iul": normalized = normalize_iul_plan(data, pdf_path) issues = validate_formal_iul_plan(normalized) else: normalized = normalize_savings_plan(data, pdf_path) issues = validate_formal_savings_plan(normalized) all_issues.extend([{ "extractionIndex": idx, "pdfName": pdf_name, "field": i.code, "path": i.path or "", "section": i.section or "", "severity": i.level, "message": i.message, "suggestedAction": i.suggested_action or "review", "state": "unresolved", } for i in issues]) except Exception as e: all_issues.append({ "extractionIndex": idx, "pdfName": pdf_name, "field": "general", "severity": "error", "message": str(e), }) # ── 跨文件兼容性校验(同险种比较场景) ── valid_extractions = [ e for e in extractions if e.get("status") in ("success", "partial") and e.get("data") ] if len(valid_extractions) >= 2: try: from insurance.ppt.comparison import ( detect_generation_scenario, generation_mode_for_scenario, build_comparison_contract, SCENARIO_GENERIC_COMPARE, SCENARIO_MULTI_SAVINGS, ) normalized_for_check = [] from insurance.ppt.normalizer import normalize_savings_plan as _ns, normalize_ci_plan as _nc, normalize_iul_plan as _ni for ext in valid_extractions: data = ext["data"] pt = (ext.get("planType") or data.get("product_type") or "savings").lower() try: if pt == "ci": n = _nc(data, ext.get("pdfPath")) elif pt == "iul": n = _ni(data, ext.get("pdfPath")) else: n = _ns(data, ext.get("pdfPath")) n["kind"] = pt normalized_for_check.append(n) except Exception: pass if len(normalized_for_check) >= 2: scenario = detect_generation_scenario(normalized_for_check) mode = generation_mode_for_scenario(scenario) try: contract = build_comparison_contract(normalized_for_check, mode=mode) for w in contract.get("warnings", []): all_issues.append({"field": "comparison", "severity": "warn", "message": w}) except ValueError as ve: all_issues.append({"field": "comparison", "severity": "error", "message": str(ve)}) except Exception as e: logger.warning("跨文件兼容性校验异常: %s", e) error_count = sum(1 for i in all_issues if i["severity"] == "error") warn_count = sum(1 for i in all_issues if i["severity"] == "warn") info_count = sum(1 for i in all_issues if i["severity"] == "info") return success({ "sessionId": session_id, "validated": error_count == 0, "canProceed": error_count == 0, "blockerCount": error_count, "warningCount": warn_count, "infoCount": info_count, "errorCount": error_count, "warnCount": warn_count, "issues": all_issues, }) # ─── 更新提取数据 ───────────────────────────────────────── @ppt_bp.route("/session//extractions", methods=["PUT"]) @jwt_required def update_extractions(session_id): """保存用户修改后的提取数据。""" user_id = str(getattr(request, "user_id", "guest")) session = _get_session(session_id, user_id) if not session: return error(ErrorCode.NOT_FOUND, "会话不存在") data = request.get_json(silent=True) or {} extractions = data.get("extractions") if not isinstance(extractions, list): return error(ErrorCode.PARAM_ERROR, "extractions 必须是数组") # 合并更新:只更新 data 字段,保留 pdfPath/status 等元信息 existing = json.loads(session.extractions_json) if session.extractions_json else [] existing_map = {e["pdfName"]: e for e in existing} for ext in extractions: pdf_name = ext.get("pdfName") if not pdf_name or pdf_name not in existing_map: continue # 更新数据字段 if "data" in ext: existing_map[pdf_name]["data"] = ext["data"] if "productName" in ext: existing_map[pdf_name]["productName"] = ext["productName"] if "planType" in ext: existing_map[pdf_name]["planType"] = ext["planType"] _reassess_extraction(existing_map[pdf_name]) updated = list(existing_map.values()) session.extractions_json = json.dumps(updated, ensure_ascii=False) session.status = "parsed" # 回到 parsed 状态,需要重新生成 _save_session(session) return success({ "sessionId": session_id, "status": "updated", "extractions": [{ "pdfName": e["pdfName"], "planType": e["planType"], "status": e["status"], "productName": e["productName"], "yearCount": e["yearCount"], } for e in updated], }) # ─── 公司知识库匹配 ─────────────────────────────────────── @ppt_bp.route("/company-kb/match", methods=["POST"]) @jwt_required def match_company(): """匹配公司知识库。""" data = request.get_json(silent=True) or {} product_name = data.get("productName") company_hint = data.get("companyHint") forced_company_id = data.get("companyId") from insurance.models.ppt_config import PptCompany, PptProduct from insurance.ppt.knowledge import match_company_knowledge companies = [c.to_dict() for c in PptCompany.query.all()] products = [p.to_dict() for p in PptProduct.query.all()] result = match_company_knowledge( product_name=product_name, company_hint=company_hint, forced_company_id=forced_company_id, companies=companies, products=products, ) return success(result) # ─── 历史记录 ───────────────────────────────────────────── def _record_history(user_id, action_type, session_id=None, company_id=None, product_id=None, template_id=None, content_snapshot=None, file_url=None): """写入历史记录(内部函数)。""" from insurance.db.compat import db from insurance.models.ppt_history import PptHistory from flask import request as req record = PptHistory( user_id=user_id, session_id=session_id, action_type=action_type, company_id=company_id, product_id=product_id, template_id=template_id, content_snapshot=json.dumps(content_snapshot, ensure_ascii=False) if content_snapshot else None, file_url=file_url, ip=req.remote_addr, user_agent=req.headers.get("User-Agent", "")[:500], ) db.session.add(record) db.session.commit() @ppt_bp.route("/history", methods=["GET"]) @jwt_required def list_history(): """当前用户的历史记录列表。""" from insurance.db.compat import db from insurance.models.ppt_history import PptHistory user_id = str(getattr(request, "user_id", "guest")) page = max(1, request.args.get("page", 1, type=int)) page_size = min(100, max(1, request.args.get("page_size", 20, type=int))) company_id = request.args.get("company_id", "") action_type = request.args.get("action_type", "") query = db.session.query(PptHistory).filter(PptHistory.user_id == user_id) if company_id: query = query.filter(PptHistory.company_id == company_id) if action_type: query = query.filter(PptHistory.action_type == action_type) query = query.order_by(PptHistory.created_at.desc()) total = query.count() items = query.offset((page - 1) * page_size).limit(page_size).all() return success({ "total": total, "items": [h.to_dict() for h in items], }) @ppt_bp.route("/history/", methods=["GET"]) @jwt_required def get_history(history_id): """单条历史详情。""" from insurance.models.ppt_history import PptHistory user_id = str(getattr(request, "user_id", "guest")) record = PptHistory.query.get(history_id) if not record or record.user_id != user_id: return error(ErrorCode.NOT_FOUND, "记录不存在") return success(record.to_dict()) @ppt_bp.route("/history//re-download", methods=["GET", "POST"]) @jwt_required def re_download(history_id): """重新下载历史文件。""" from insurance.models.ppt_history import PptHistory user_id = str(getattr(request, "user_id", "guest")) record = PptHistory.query.get(history_id) if not record or record.user_id != user_id: return error(ErrorCode.NOT_FOUND, "记录不存在") if not record.file_url or not os.path.exists(record.file_url): return error(ErrorCode.NOT_FOUND, "文件不存在") return send_file(record.file_url, as_attachment=True) # ─── 预览接口 ───────────────────────────────────────────── @ppt_bp.route("/preview/", methods=["GET"]) @jwt_required def get_preview(session_id): """获取幻灯片预览数据(结构化 JSON + 质量报告)。""" user_id = str(getattr(request, "user_id", "guest")) session = _get_session(session_id, user_id) if not session: return error(ErrorCode.NOT_FOUND, "会话不存在") versions = json.loads(session.versions_json) if session.versions_json else [] requested_revision = request.args.get("revision", type=int) selected_version = next( (item for item in versions if item.get("revision") == requested_revision), None, ) if requested_revision is not None else None if requested_revision is not None and not selected_version: return error(ErrorCode.NOT_FOUND, "PPT 版本不存在") preview_status = session.preview_status or "none" slides_data = None slides_json_path = ( selected_version.get("slidesJsonPath") if selected_version else session.slides_json_path ) if slides_json_path: if os.path.exists(slides_json_path): try: with open(slides_json_path, "r", encoding="utf-8") as f: slides_data = json.load(f) except Exception as e: logger.warning("读取 slides.json 失败: %s", e) preview_status = "failed" else: logger.warning("slides.json 已被清理: %s", slides_json_path) preview_status = "failed" quality_report = None if session.quality_report_json: try: quality_report = json.loads(session.quality_report_json) except Exception: pass return success({ "sessionId": session_id, "previewStatus": preview_status, "slides": slides_data, "qualityReport": quality_report, "slideCount": session.slide_count or 0, "versions": versions, "generatedRevision": session.generated_revision or 0, "viewingRevision": ( requested_revision if requested_revision is not None else session.generated_revision or 0 ), "generationConfig": ( json.loads(session.draft_options_json) if session.draft_options_json else {} ), }) @ppt_bp.route("/preview//versions//restore", methods=["POST"]) @jwt_required def restore_preview_version(session_id, revision): """把历史版本复制为新的当前版本,保留原历史文件。""" import shutil import uuid from insurance.config import get_storage_root from insurance.db.compat import db user_id = str(getattr(request, "user_id", "guest")) session = _get_session(session_id, user_id) if not session: return error(ErrorCode.NOT_FOUND, "会话不存在") versions = json.loads(session.versions_json) if session.versions_json else [] source = next((item for item in versions if item.get("revision") == revision), None) if not source: return error(ErrorCode.NOT_FOUND, "PPT 版本不存在") ppt_path = source.get("path") slides_path = source.get("slidesJsonPath") if not ppt_path or not os.path.exists(ppt_path): return error(ErrorCode.NOT_FOUND, "历史版本文件已被清理") output_root = os.path.abspath(os.path.join(get_storage_root(), "outputs", "ppt")) source_abs = os.path.abspath(ppt_path) if not source_abs.startswith(output_root): return error(ErrorCode.PARAM_ERROR, "历史版本路径无效") new_revision = max( [int(item.get("revision") or 0) for item in versions] + [session.generated_revision or 0] ) + 1 output_dir = os.path.join( output_root, str(user_id), f"restore_{uuid.uuid4().hex[:12]}" ) os.makedirs(output_dir, exist_ok=True) target_ppt = os.path.join(output_dir, "presentation.pptx") shutil.copy2(source_abs, target_ppt) target_slides = None if slides_path and os.path.exists(slides_path): slides_abs = os.path.abspath(slides_path) if not slides_abs.startswith(output_root): return error(ErrorCode.PARAM_ERROR, "历史预览路径无效") slides_dir = os.path.join(output_dir, "slides") os.makedirs(slides_dir, exist_ok=True) target_slides = os.path.join(slides_dir, "slides.json") shutil.copy2(slides_abs, target_slides) restored = { "revision": new_revision, "path": target_ppt, "slidesJsonPath": target_slides, "deckPath": source.get("deckPath"), "slideCount": source.get("slideCount") or 0, "sourceRevision": revision, "source": "restore", "createdAt": __import__("datetime").datetime.now().isoformat(), } versions.append(restored) session.versions_json = json.dumps(versions, ensure_ascii=False) session.ppt_path = target_ppt session.latest_output_path = target_ppt session.slides_json_path = target_slides session.slide_count = restored["slideCount"] session.preview_status = "ready" if target_slides else "failed" session.generated_revision = new_revision session.draft_revision = max(session.draft_revision or 1, new_revision) db.session.commit() _record_history( user_id=user_id, action_type="restore_version", session_id=session_id, content_snapshot={"sourceRevision": revision, "newRevision": new_revision}, file_url=target_ppt, ) return success({ "sessionId": session_id, "revision": new_revision, "sourceRevision": revision, }) @ppt_bp.route("/preview//slide/", methods=["PUT"]) @jwt_required def update_slide(session_id, index): """更新指定页的编辑内容。""" user_id = str(getattr(request, "user_id", "guest")) session = _get_session(session_id, user_id) if not session: return error(ErrorCode.NOT_FOUND, "会话不存在") if not session.slides_json_path or not os.path.exists(session.slides_json_path): return error(ErrorCode.NOT_FOUND, "预览数据不存在") data = request.get_json(silent=True) or {} shapes = data.get("shapes") hidden = data.get("hidden") try: with open(session.slides_json_path, "r", encoding="utf-8") as f: slides_data = json.load(f) except Exception: return error(ErrorCode.SERVER_ERROR, "读取预览数据失败") slides = slides_data.get("slides", []) if index < 0 or index >= len(slides): return error(ErrorCode.PARAM_ERROR, f"页码超出范围 (0-{len(slides) - 1})") if shapes is not None: if not isinstance(shapes, list): return error(ErrorCode.PARAM_ERROR, "shapes 必须是数组") slides[index]["shapes"] = shapes if hidden is not None: slides[index]["hidden"] = bool(hidden) try: with open(session.slides_json_path, "w", encoding="utf-8") as f: json.dump(slides_data, f, ensure_ascii=False) except Exception: return error(ErrorCode.SERVER_ERROR, "保存预览数据失败") # 记录编辑历史 try: _record_history( user_id=user_id, action_type="edit_slide", session_id=session_id, content_snapshot={"slideIndex": index, "shapeCount": len(shapes)}, ) except Exception: logger.warning("记录编辑历史失败", exc_info=True) return success({"sessionId": session_id, "slideIndex": index, "updated": True}) @ppt_bp.route("/preview//quality-confirm", methods=["PUT"]) @jwt_required def update_quality_confirm(session_id): """更新人工质量确认项。""" user_id = str(getattr(request, "user_id", "guest")) session = _get_session(session_id, user_id) if not session: return error(ErrorCode.NOT_FOUND, "会话不存在") if not session.quality_report_json: return error(ErrorCode.NOT_FOUND, "质量报告不存在") data = request.get_json(silent=True) or {} key = data.get("key", "").strip() confirmed = bool(data.get("confirmed")) if not key: return error(ErrorCode.PARAM_ERROR, "key 必填") try: report = json.loads(session.quality_report_json) except Exception: return error(ErrorCode.SERVER_ERROR, "质量报告解析失败") # 更新人工确认项 updated = False for item in report.get("manual", []): if item["key"] == key: item["confirmed"] = confirmed updated = True break if not updated: return error(ErrorCode.PARAM_ERROR, f"未知的确认项: {key}") # 更新待确认计数 report["summary"]["manualPending"] = sum( 1 for m in report.get("manual", []) if not m.get("confirmed") ) session.quality_report_json = json.dumps(report, ensure_ascii=False) from insurance.db.compat import db db.session.commit() return success({"sessionId": session_id, "key": key, "confirmed": confirmed}) @ppt_bp.route("/preview//regenerate", methods=["POST"]) @jwt_required def regenerate_version(session_id): """基于当前编辑内容重新生成新版本 PPT。 不覆盖当前版本,生成后追加到版本历史。 """ user_id = str(getattr(request, "user_id", "guest")) session = _get_session(session_id, user_id) if not session: return error(ErrorCode.NOT_FOUND, "会话不存在") if not session.ppt_path or not os.path.exists(session.ppt_path): return error(ErrorCode.PARAM_ERROR, "当前无可用的 PPT 文件") # 检查是否有运行中的任务 from insurance.generation import task_service if session.latest_task_id: from insurance.models.generation_task import GenerationTask active = GenerationTask.query.filter( GenerationTask.id == session.latest_task_id, GenerationTask.status.in_(["queued", "running"]), ).first() if active: return error(ErrorCode.PARAM_ERROR, "有正在执行的任务,请等待完成") # 读取当前编辑后的 slides.json edits_data = None if session.slides_json_path and os.path.exists(session.slides_json_path): try: with open(session.slides_json_path, "r", encoding="utf-8") as f: edits_data = json.load(f) except Exception: pass # 递增版本号 session.draft_revision = (session.draft_revision or 1) + 1 session.status = "generating" _save_session(session) # 创建异步任务,传递编辑数据 result = task_service.create_task( user_id=user_id, artifact_type="ppt", operation="regenerate", workspace_id=session_id, title=session.title or f"PPT {session_id[:8]}", input_snapshot={ "revision": session.draft_revision, "edits": edits_data, }, input_revision=session.draft_revision or 1, idempotency_key=f"ppt_regen_{session_id}_{session.draft_revision}", ) if result.get("code") != 0: return error(ErrorCode.PARAM_ERROR, result.get("message", "创建任务失败")) task_data = result["data"] session.latest_task_id = task_data["id"] _save_session(session) return success({ "taskId": task_data["id"], "status": "queued", "sessionId": session_id, "revision": session.draft_revision, }) # ─── PDF 页面预览 ───────────────────────────────────────── @ppt_bp.route("/pdf-preview///", methods=["GET"]) @jwt_required def pdf_page_preview(session_id, extraction_index, page_number): """返回 PDF 指定页的 PNG 预览图。 用于核对页面展示 PDF 原文证据。 """ import tempfile user_id = str(getattr(request, "user_id", "guest")) session = _get_session(session_id, user_id) if not session: return error(ErrorCode.NOT_FOUND, "会话不存在") extractions = json.loads(session.extractions_json) if session.extractions_json else [] if extraction_index < 0 or extraction_index >= len(extractions): return error(ErrorCode.PARAM_ERROR, "提取索引越界") ext = extractions[extraction_index] pdf_path = ext.get("pdfPath") if not pdf_path or not os.path.exists(pdf_path): return error(ErrorCode.NOT_FOUND, "PDF 文件不存在") try: try: import fitz except ImportError: import pymupdf as fitz doc = fitz.open(pdf_path) if page_number < 1 or page_number > len(doc): doc.close() return error(ErrorCode.PARAM_ERROR, f"页码越界(共 {len(doc)} 页)") page = doc[page_number - 1] # 1-based to 0-based # 渲染为 PNG,2x 缩放(约 150 DPI,平衡质量和大小) pixmap = page.get_pixmap(matrix=fitz.Matrix(2.0, 2.0)) doc.close() # 写入临时文件返回 img_bytes = pixmap.tobytes("png") import io buf = io.BytesIO(img_bytes) buf.seek(0) return send_file( buf, mimetype="image/png", as_attachment=False, download_name=f"page-{page_number}.png", ) except ImportError: return error(ErrorCode.INTERNAL_ERROR, "PDF 渲染库未安装(需要 PyMuPDF)") except Exception as e: logger.error(f"PDF 页面预览失败: {e}") return error(ErrorCode.INTERNAL_ERROR, f"PDF 渲染失败: {str(e)[:100]}") @ppt_bp.route("/pdf-info//", methods=["GET"]) @jwt_required def pdf_info(session_id, extraction_index): """返回 PDF 基本信息(总页数等)。""" user_id = str(getattr(request, "user_id", "guest")) session = _get_session(session_id, user_id) if not session: return error(ErrorCode.NOT_FOUND, "会话不存在") extractions = json.loads(session.extractions_json) if session.extractions_json else [] if extraction_index < 0 or extraction_index >= len(extractions): return error(ErrorCode.PARAM_ERROR, "提取索引越界") ext = extractions[extraction_index] pdf_path = ext.get("pdfPath") if not pdf_path or not os.path.exists(pdf_path): return error(ErrorCode.NOT_FOUND, "PDF 文件不存在") try: try: import fitz except ImportError: import pymupdf as fitz doc = fitz.open(pdf_path) page_count = len(doc) doc.close() return success({"pageCount": page_count, "pdfName": ext.get("pdfName", "")}) except ImportError: return error(ErrorCode.INTERNAL_ERROR, "PDF 渲染库未安装") except Exception as e: return error(ErrorCode.INTERNAL_ERROR, str(e)[:100])