baodan/api/insurance/ppt/routes.py
wsb1224 63c7e87ea7 feat(ppt): add data review step between parsing and generation
Add a mandatory data verification step so users can inspect and edit
LLM-extracted data before generating the PPT.

- New PptDataReview.vue: auto-validation on mount, key metrics cards,
  editable benefit/withdrawal tables, inline error highlighting
- New PUT /session/<id>/extractions API endpoint for saving user edits
- Extend PptPage flow from 4 steps to 5 (upload→parse→review→generate→result)
- Update ppt-api.ts with updateExtractions() method
- Update PptParsing button text to match new flow

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 18:22:14 +08:00

613 lines
22 KiB
Python

"""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, "未上传文件")
# 创建上传目录
upload_dir = os.path.join(os.getcwd(), "uploads", user_id)
os.makedirs(upload_dir, exist_ok=True)
file_records = []
for i, f in enumerate(files):
if not f.filename or not f.filename.lower().endswith(".pdf"):
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:
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, "会话不存在")
import asyncio
from insurance.ppt.extraction import ExtractionOrchestrator
orchestrator = ExtractionOrchestrator()
files = json.loads(session.files_json) if session.files_json else []
extractions = []
session.status = "parsing"
_save_session(session)
for file_info in files:
filepath = file_info.get("path", "")
plan_type = file_info.get("type", "savings")
try:
result = asyncio.run(orchestrator.extract_plan(filepath, plan_type))
extractions.append({
"pdfName": file_info.get("name", ""),
"pdfPath": filepath,
"planType": result.plan_type,
"status": result.status,
"productName": result.product_name,
"data": result.data,
"error": result.error,
"yearCount": len(result.data.get("benefit_illustration", [])) if result.data else 0,
})
except Exception as e:
logger.error(f"PDF 解析失败 [{file_info.get('name', '')}]: {e}", exc_info=True)
extractions.append({
"pdfName": file_info.get("name", ""),
"pdfPath": filepath,
"planType": plan_type,
"status": "error",
"productName": "unknown",
"data": None,
"error": str(e),
"yearCount": 0,
})
session.extractions_json = json.dumps(extractions, ensure_ascii=False)
session.status = "parsed"
_save_session(session)
# 生成摘要
lines = ["| 产品 | 类型 | 状态 |", "|------|------|------|"]
for ext in extractions:
status_icon = "" if ext["status"] == "success" else ""
lines.append(f"| {ext['productName']} | {ext['planType']} | {status_icon} |")
return success({
"sessionId": session_id,
"status": "parsed",
"extractions": [{
"pdfName": e["pdfName"],
"planType": e["planType"],
"status": e["status"],
"productName": e["productName"],
"yearCount": e["yearCount"],
"error": e.get("error"),
} for e in extractions],
"message": "\n".join(lines),
})
# ─── 获取会话状态 ─────────────────────────────────────────
@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。"""
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", "")
extractions = json.loads(session.extractions_json) if session.extractions_json else []
if not extractions:
return error(ErrorCode.PARAM_ERROR, "无解析数据")
# 归一化所有成功的提取结果
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
all_normalized = []
for ext in extractions:
if ext.get("status") not in ("success", "cached") or not ext.get("data"):
continue
ext_data = ext["data"]
pdf_path = ext.get("pdfPath")
plan_type = ext_data.get("product_type", "savings")
try:
if plan_type == "ci":
normalized = normalize_ci_plan(ext_data, pdf_path)
issues = validate_formal_ci_plan(normalized)
elif plan_type == "iul":
normalized = normalize_iul_plan(ext_data, pdf_path)
issues = validate_formal_iul_plan(normalized)
else:
normalized = normalize_savings_plan(ext_data, pdf_path)
issues = validate_formal_savings_plan(normalized)
errors = [i for i in issues if i.level == "error"]
if errors:
logger.warning(f"产品 {ext_data.get('product_type', '')} 有验证错误: {errors}")
all_normalized.append(normalized)
except Exception as e:
logger.error(f"归一化失败: {e}", exc_info=True)
if not all_normalized:
return error(ErrorCode.PARAM_ERROR, "无有效提取数据")
normalized = all_normalized[0]
plan_type = normalized.get("kind", "savings")
# 渲染 PPT
session.status = "generating"
_save_session(session)
from insurance.ppt.renderer import PptRenderer
renderer = PptRenderer()
output_dir = os.path.join(os.getcwd(), "downloads", user_id)
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, f"{session_id}.pptx")
# 加载模板配置
from insurance.models.ppt_config import PptTemplate, PptCompany
template = PptTemplate.query.filter_by(plan_type=plan_type, style_preset=theme, status=1).first()
template_config = template.to_dict() if template else None
# 加载公司信息
company_info = None
if company_id:
company = PptCompany.query.get(company_id)
if company:
company_info = company.to_dict()
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,
)
if not result.get("ok"):
session.status = "error"
_save_session(session)
return error(ErrorCode.SERVER_ERROR, f"渲染失败: {result.get('error', '未知错误')}")
session.ppt_path = output_path
session.slide_count = result.get("slideCount", 0)
session.status = "done"
_save_session(session)
# 记录历史
try:
_record_history(
user_id=user_id,
action_type="export",
session_id=session_id,
company_id=company_id,
content_snapshot={"theme": theme, "slideCount": result.get("slideCount", 0)},
file_url=output_path,
)
except Exception:
logger.warning("记录 PPT 历史失败", exc_info=True)
return success({
"sessionId": session_id,
"status": "done",
"downloadUrl": f"/insurance/ppt/download/{session_id}",
"slideCount": result.get("slideCount", 0),
})
# ─── 下载 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", "cached") or not ext.get("data"):
continue
data = ext["data"]
plan_type = data.get("product_type", "savings")
try:
if plan_type == "ci":
normalized = normalize_ci_plan(data)
issues = validate_formal_ci_plan(normalized)
elif plan_type == "iul":
normalized = normalize_iul_plan(data)
issues = validate_formal_iul_plan(normalized)
else:
normalized = normalize_savings_plan(data)
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"]
# 重新计算行数
d = existing_map[pdf_name].get("data")
if d:
rows = d.get("benefit_illustration") or d.get("benefitRows") or []
existing_map[pdf_name]["yearCount"] = len(rows)
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)