"""PPT 生成模块 Blueprint 路由。""" import os import uuid import json import logging 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, PptTemplate companies = PptCompany.query.filter_by(status=1).order_by( PptCompany.sort_order.asc(), PptCompany.id.asc() ).all() products = PptProduct.query.filter_by(status=1).order_by( PptProduct.sort_order.asc(), PptProduct.id.asc() ).all() templates = PptTemplate.query.filter_by(status=1).order_by( PptTemplate.plan_type.asc(), PptTemplate.id.asc() ).all() return success({ "companies": [c.to_dict() for c in companies], "products": [p.to_dict() for p in products], "templates": [t.to_dict() for t in templates], }) # ─── 上传 PDF ───────────────────────────────────────────── @ppt_bp.route("/upload", methods=["POST"]) @jwt_required def upload_pdfs(): """上传 PDF 文件并创建会话。""" 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") if not files: return error(ErrorCode.PARAM_ERROR, "未上传文件") # 创建上传目录(使用持久化存储) 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 = [] validation_errors = [] from insurance.utils.security import validate_pdf_upload for i, f in enumerate(files): if not f.filename or not f.filename.lower().endswith(".pdf"): continue plan_type = types[i] if i < len(types) else "savings" company_id = companies[i] if i < len(companies) else "" product_id = products[i] if i < len(products) else "" from insurance.models.ppt_config import PptCompany, PptProduct company = ( PptCompany.query.filter_by(id=company_id, status=1).first() if company_id else None ) product = ( PptProduct.query.filter_by(id=product_id, status=1).first() if product_id else None ) if company_id and not company: validation_errors.append(f"{f.filename}: 所选保司不存在或已停用") continue if product_id and ( not product or product.plan_type != plan_type or (company_id and product.company_id != company_id) ): validation_errors.append(f"{f.filename}: 所选产品与险种或保司不匹配") continue if product and not company_id: company_id = product.company_id # 文件安全校验(SEC-P1-01) is_valid, err_msg = validate_pdf_upload(f) if not is_valid: validation_errors.append(f"{f.filename}: {err_msg}") continue # 保存文件 filename = f"{uuid.uuid4().hex[:8]}_{f.filename}" filepath = os.path.join(upload_dir, filename) f.save(filepath) file_records.append({ "path": filepath, "name": f.filename, "type": plan_type, "companyId": company_id, "productId": product_id, }) if not file_records: if validation_errors: return error(ErrorCode.FILE_FORMAT_ERROR, "; ".join(validation_errors)) return error(ErrorCode.FILE_FORMAT_ERROR, "无有效 PDF 文件") # 创建会话 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": 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.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}, idempotency_key=f"ppt_parse_{session_id}", ) if result.get("code") == 0: session.latest_task_id = result["data"]["id"] _save_session(session) message = "解析任务已启动" else: message = result.get("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, "会话不存在") return success(_build_parse_status(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" company_id = data.get("companyId", "") template_id = data.get("templateId", "") use_masked_data = bool(data.get("useMaskedData")) files = json.loads(session.files_json) if session.files_json else [] product_ids = [item.get("productId") for item in files if item.get("productId")] if not company_id: company_id = next( (item.get("companyId") for item in files if item.get("companyId")), "" ) if company_id: from insurance.models.ppt_config import PptCompany if not PptCompany.query.filter_by(id=company_id, status=1).first(): return error(ErrorCode.PARAM_ERROR, "所选保司不存在或已停用") if template_id: from insurance.models.ppt_config import PptTemplate template = PptTemplate.query.filter_by(id=template_id, status=1).first() if not template: return error(ErrorCode.PARAM_ERROR, "所选 PPT 模板不存在或已停用") template_data = template.to_dict() primary_plan_type = next( (item.get("type") for item in files if item.get("type")), "" ) if 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 company_id not in applicable_companies: 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, "无有效提取数据") # 更新会话草稿选项 import uuid session.draft_options_json = json.dumps({ "theme": theme, "templateId": template_id, "companyId": company_id, "productIds": product_ids, "useMaskedData": use_masked_data, }, 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, "productIds": product_ids, "useMaskedData": use_masked_data, }, 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, "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 ext in extractions: if ext.get("status") not in ("success", "partial") or not ext.get("data"): 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([{"field": i.code, "severity": i.level, "message": i.message} for i in issues]) except Exception as e: all_issues.append({"field": "general", "severity": "error", "message": str(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") return success({ "sessionId": session_id, "validated": error_count == 0, "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)