baodan/api/insurance/ppt/routes.py
wsb1224 c7df34d0cd 07-27 阶段 状态 进度
阶段 1:数据库与工作区	 完成	100%
阶段 2:Celery 后台任务	 完成	100%
阶段 3:前端刷新恢复与多工作区	 完成	100%
阶段 4:任务坞与任务中心	 完成	100%
阶段 5:版本化编辑	 完成	100%
阶段 6:测试与灰度	 未开始	0%
2026-07-28 17:53:14 +08:00

610 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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, PptTemplate
companies = PptCompany.query.all()
templates = PptTemplate.query.all()
return success({
"companies": [c.to_dict() for c in companies],
"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")
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
# 文件安全校验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)
# 确定产品类型
plan_type = types[i] if i < len(types) else "savings"
company_id = companies[i] if i < len(companies) else ""
file_records.append({
"path": filepath,
"name": f.filename,
"type": plan_type,
"companyId": company_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/<session_id>", 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/<session_id>/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/<session_id>", 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/<session_id>", 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/<session_id>", 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", "")
use_masked_data = bool(data.get("useMaskedData"))
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, "companyId": company_id, "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,
"companyId": company_id,
"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/<session_id>", 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/<session_id>", 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/<session_id>/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/<int:history_id>", 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/<int:history_id>/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)