2026-07-23 13:10:50 +08:00
|
|
|
|
"""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():
|
|
|
|
|
|
"""获取可用的公司和模板风格列表。"""
|
2026-07-29 12:19:26 +08:00
|
|
|
|
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()
|
2026-07-23 13:10:50 +08:00
|
|
|
|
return success({
|
|
|
|
|
|
"companies": [c.to_dict() for c in companies],
|
2026-07-29 12:19:26 +08:00
|
|
|
|
"products": [p.to_dict() for p in products],
|
2026-07-23 13:10:50 +08:00
|
|
|
|
"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")
|
2026-07-29 12:19:26 +08:00
|
|
|
|
products = request.form.getlist("products")
|
2026-07-23 13:10:50 +08:00
|
|
|
|
|
|
|
|
|
|
if not files:
|
|
|
|
|
|
return error(ErrorCode.PARAM_ERROR, "未上传文件")
|
|
|
|
|
|
|
2026-07-27 13:52:09 +08:00
|
|
|
|
# 创建上传目录(使用持久化存储)
|
|
|
|
|
|
from insurance.config import get_storage_root
|
|
|
|
|
|
upload_dir = os.path.join(get_storage_root(), "uploads", "ppt", user_id)
|
2026-07-23 13:10:50 +08:00
|
|
|
|
os.makedirs(upload_dir, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
file_records = []
|
2026-07-27 13:52:09 +08:00
|
|
|
|
validation_errors = []
|
|
|
|
|
|
from insurance.utils.security import validate_pdf_upload
|
2026-07-23 13:10:50 +08:00
|
|
|
|
for i, f in enumerate(files):
|
|
|
|
|
|
if not f.filename or not f.filename.lower().endswith(".pdf"):
|
|
|
|
|
|
continue
|
2026-07-29 12:19:26 +08:00
|
|
|
|
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
|
2026-07-27 13:52:09 +08:00
|
|
|
|
# 文件安全校验(SEC-P1-01)
|
|
|
|
|
|
is_valid, err_msg = validate_pdf_upload(f)
|
|
|
|
|
|
if not is_valid:
|
|
|
|
|
|
validation_errors.append(f"{f.filename}: {err_msg}")
|
|
|
|
|
|
continue
|
2026-07-23 13:10:50 +08:00
|
|
|
|
# 保存文件
|
|
|
|
|
|
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,
|
2026-07-29 12:19:26 +08:00
|
|
|
|
"productId": product_id,
|
2026-07-23 13:10:50 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if not file_records:
|
2026-07-27 13:52:09 +08:00
|
|
|
|
if validation_errors:
|
|
|
|
|
|
return error(ErrorCode.FILE_FORMAT_ERROR, "; ".join(validation_errors))
|
2026-07-23 13:10:50 +08:00
|
|
|
|
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):
|
2026-07-28 17:53:14 +08:00
|
|
|
|
"""触发 AI 解析 PDF(异步任务)。"""
|
2026-07-23 13:10:50 +08:00
|
|
|
|
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 []
|
2026-07-27 10:37:00 +08:00
|
|
|
|
if not files:
|
|
|
|
|
|
return error(ErrorCode.PARAM_ERROR, "没有可解析的 PDF 文件")
|
2026-07-23 13:10:50 +08:00
|
|
|
|
|
2026-07-27 10:37:00 +08:00
|
|
|
|
if session.status == "parsing":
|
|
|
|
|
|
return success(_build_parse_status(session), "解析任务正在进行")
|
2026-07-23 13:10:50 +08:00
|
|
|
|
|
2026-07-28 17:53:14 +08:00
|
|
|
|
# 创建异步任务
|
|
|
|
|
|
from insurance.generation import task_service
|
2026-07-27 10:37:00 +08:00
|
|
|
|
session.status = "parsing"
|
|
|
|
|
|
session.parse_progress = 0
|
|
|
|
|
|
session.parse_message = "解析任务已提交"
|
|
|
|
|
|
session.parse_error = None
|
|
|
|
|
|
session.extractions_json = json.dumps([], ensure_ascii=False)
|
2026-07-23 13:10:50 +08:00
|
|
|
|
_save_session(session)
|
|
|
|
|
|
|
2026-07-28 17:53:14 +08:00
|
|
|
|
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", "解析任务提交失败")
|
2026-07-23 13:10:50 +08:00
|
|
|
|
|
|
|
|
|
|
return success({
|
|
|
|
|
|
"sessionId": session_id,
|
2026-07-27 10:37:00 +08:00
|
|
|
|
"status": session.status,
|
|
|
|
|
|
"progress": session.parse_progress or 0,
|
|
|
|
|
|
"message": message,
|
2026-07-28 17:53:14 +08:00
|
|
|
|
"taskId": result.get("data", {}).get("id") if result.get("code") == 0 else None,
|
2026-07-27 10:37:00 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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,
|
2026-07-23 13:10:50 +08:00
|
|
|
|
"extractions": [{
|
2026-07-27 10:37:00 +08:00
|
|
|
|
"pdfName": e.get("pdfName", ""),
|
|
|
|
|
|
"planType": e.get("planType", ""),
|
|
|
|
|
|
"status": e.get("status", ""),
|
|
|
|
|
|
"productName": e.get("productName", ""),
|
|
|
|
|
|
"yearCount": e.get("yearCount", 0),
|
2026-07-23 13:10:50 +08:00
|
|
|
|
"error": e.get("error"),
|
|
|
|
|
|
} for e in extractions],
|
2026-07-27 10:37:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-07-23 13:10:50 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── 获取会话状态 ─────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@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):
|
2026-07-28 17:53:14 +08:00
|
|
|
|
"""生成 PPT(异步任务)。
|
|
|
|
|
|
|
|
|
|
|
|
创建任务后立即返回 202,前端通过任务接口轮询状态。
|
|
|
|
|
|
"""
|
2026-07-23 13:10:50 +08:00
|
|
|
|
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", "")
|
2026-07-29 12:19:26 +08:00
|
|
|
|
template_id = data.get("templateId", "")
|
2026-07-28 16:45:14 +08:00
|
|
|
|
use_masked_data = bool(data.get("useMaskedData"))
|
2026-07-29 12:19:26 +08:00
|
|
|
|
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
|
2026-07-23 13:10:50 +08:00
|
|
|
|
|
|
|
|
|
|
extractions = json.loads(session.extractions_json) if session.extractions_json else []
|
|
|
|
|
|
if not extractions:
|
|
|
|
|
|
return error(ErrorCode.PARAM_ERROR, "无解析数据")
|
|
|
|
|
|
|
2026-07-28 17:53:14 +08:00
|
|
|
|
# 快速校验:确保有可生成的数据
|
|
|
|
|
|
has_valid = any(
|
|
|
|
|
|
e.get("status") in ("success", "partial") and e.get("data")
|
|
|
|
|
|
for e in extractions
|
|
|
|
|
|
)
|
|
|
|
|
|
if not has_valid:
|
2026-07-23 13:10:50 +08:00
|
|
|
|
return error(ErrorCode.PARAM_ERROR, "无有效提取数据")
|
|
|
|
|
|
|
2026-07-28 17:53:14 +08:00
|
|
|
|
# 更新会话草稿选项
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
session.draft_options_json = json.dumps({
|
2026-07-29 12:19:26 +08:00
|
|
|
|
"theme": theme, "templateId": template_id,
|
|
|
|
|
|
"companyId": company_id, "productIds": product_ids,
|
|
|
|
|
|
"useMaskedData": use_masked_data,
|
2026-07-28 17:53:14 +08:00
|
|
|
|
}, ensure_ascii=False)
|
|
|
|
|
|
session.draft_revision = (session.draft_revision or 1) + 1
|
2026-07-23 13:10:50 +08:00
|
|
|
|
_save_session(session)
|
|
|
|
|
|
|
2026-07-28 17:53:14 +08:00
|
|
|
|
# 创建异步任务
|
|
|
|
|
|
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,
|
2026-07-29 12:19:26 +08:00
|
|
|
|
"templateId": template_id,
|
2026-07-28 17:53:14 +08:00
|
|
|
|
"companyId": company_id,
|
2026-07-29 12:19:26 +08:00
|
|
|
|
"productIds": product_ids,
|
2026-07-28 17:53:14 +08:00
|
|
|
|
"useMaskedData": use_masked_data,
|
|
|
|
|
|
},
|
|
|
|
|
|
idempotency_key=f"ppt_gen_{session_id}_{session.draft_revision}",
|
feat(ppt): upgrade renderer from 5 fixed slides to 10+ configurable pages
Rewrite fast_pptx_renderer.py to read requiredPageTypes from template
config instead of hardcoding 5 slides. Add 10 slide builder functions
(cover, company, narrative, chart, timeline, table, compare, synergy,
conclusion, closing) with python-pptx native charts.
Key changes:
- Renderer reads templateConfig.requiredPageTypes and slidesConfig
from DeckContract to determine slide sequence and per-slide metadata
- routes.py loads PptTemplate and PptCompany from DB, normalizes all
PDF extractions (not just the first), passes full context to renderer
- renderer.py injects templateConfig, company info, and multi-product
data into DeckContract
- Add slides_config_json column to PptTemplate (migrate_017) for
per-slide title/narrative/chartType configuration via admin UI
- Admin template editor now supports drag-reorder slides, per-slide
title/narrative hint, chart/table type selection
- Add requiredPageTypes to savings/ink, savings/minimal, savings/business
templates (were missing, causing fallback to defaults)
- Fix IUL normalizer: add payYears and totalPremium to policy dict
- Fix CI/IUL normalizer: add totalSurrenderValue alias to benefitRows
so charts render correctly for all product types
- Port calculation functions from baodanppt: decade_rows, paid_premium,
simple_return, compound_return, find_payback_year
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 17:49:09 +08:00
|
|
|
|
)
|
2026-07-23 13:10:50 +08:00
|
|
|
|
|
2026-07-28 17:53:14 +08:00
|
|
|
|
if result.get("code") != 0:
|
|
|
|
|
|
return error(ErrorCode.PARAM_ERROR, result.get("message", "创建任务失败"))
|
2026-07-23 13:10:50 +08:00
|
|
|
|
|
2026-07-28 17:53:14 +08:00
|
|
|
|
task_data = result["data"]
|
|
|
|
|
|
session.latest_task_id = task_data["id"]
|
2026-07-23 13:10:50 +08:00
|
|
|
|
_save_session(session)
|
|
|
|
|
|
|
2026-07-28 17:53:14 +08:00
|
|
|
|
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
|
2026-07-23 13:10:50 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── 下载 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 文件不存在")
|
|
|
|
|
|
|
2026-07-23 15:04:16 +08:00
|
|
|
|
# 记录下载历史
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2026-07-23 13:10:50 +08:00
|
|
|
|
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:
|
2026-07-27 10:37:00 +08:00
|
|
|
|
if ext.get("status") not in ("success", "partial") or not ext.get("data"):
|
2026-07-23 13:10:50 +08:00
|
|
|
|
continue
|
|
|
|
|
|
data = ext["data"]
|
2026-07-27 10:37:00 +08:00
|
|
|
|
plan_type = (ext.get("planType") or data.get("product_type") or "savings").lower()
|
|
|
|
|
|
pdf_path = ext.get("pdfPath")
|
2026-07-23 13:10:50 +08:00
|
|
|
|
try:
|
|
|
|
|
|
if plan_type == "ci":
|
2026-07-27 10:37:00 +08:00
|
|
|
|
normalized = normalize_ci_plan(data, pdf_path)
|
2026-07-23 13:10:50 +08:00
|
|
|
|
issues = validate_formal_ci_plan(normalized)
|
|
|
|
|
|
elif plan_type == "iul":
|
2026-07-27 10:37:00 +08:00
|
|
|
|
normalized = normalize_iul_plan(data, pdf_path)
|
2026-07-23 13:10:50 +08:00
|
|
|
|
issues = validate_formal_iul_plan(normalized)
|
|
|
|
|
|
else:
|
2026-07-27 10:37:00 +08:00
|
|
|
|
normalized = normalize_savings_plan(data, pdf_path)
|
2026-07-23 13:10:50 +08:00
|
|
|
|
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,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 18:22:14 +08:00
|
|
|
|
# ─── 更新提取数据 ─────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@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"]
|
2026-07-27 10:37:00 +08:00
|
|
|
|
_reassess_extraction(existing_map[pdf_name])
|
2026-07-24 18:22:14 +08:00
|
|
|
|
|
|
|
|
|
|
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],
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 13:10:50 +08:00
|
|
|
|
# ─── 公司知识库匹配 ───────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@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)
|
2026-07-23 15:04:16 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── 历史记录 ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
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)
|