baodan/api/insurance/ppt/routes.py
wsb1224 29657d31e4 所有阶段 3-5 的任务全部完成。最终盘点:
全部改动文件总览
后端(8 个文件)
文件	操作
api/insurance/models/ppt_session.py	修改 — 新增 5 个字段
api/insurance/db/migrate_025.py	新建 — 数据库迁移
api/insurance/ppt/renderer.py	修改 — 新增 parse_slides(),deck 返回
api/insurance/ppt/quality_checker.py	新建 — 质量检查
api/insurance/ppt/routes.py	修改 — 新增 5 个接口
api/insurance/generation/celery_tasks.py	修改 — 集成预览+质检+版本化,新增 regenerate 任务
api/insurance/generation/task_service.py	修改 — 注册 regenerate 任务
前端(8 个文件)
文件	操作
frontend/src/utils/ppt-api.ts	修改 — 新增类型和 API 方法
frontend/src/pages/components/ppt/PptSlideCanvas.vue	新建 — Canvas 渲染器
frontend/src/pages/components/ppt/PptResult.vue	重写 — 三栏工作台 + 版本历史
frontend/src/pages/components/ppt/PptUpload.vue	重写 — 双栏 Hero 布局
frontend/src/pages/components/ppt/PptParsing.vue	重写 — 五阶段进度
frontend/src/pages/components/ppt/PptDataReview.vue	修改 — 主题色更新
frontend/src/pages/PptPage.vue	修改 — 步骤描述 + 主题色
frontend/src/pages/PptHistoryPage.vue	重写 — 卡片式布局
新增后端接口
方法	路径	功能
GET	/ppt/preview/:id	获取幻灯片 JSON + 质量报告 + 版本历史
PUT	/ppt/preview/:id/slide/:index	更新编辑内容 + 显隐
PUT	/ppt/preview/:id/quality-confirm	人工质量确认
POST	/ppt/preview/:id/regenerate	基于编辑生成新版本
2026-07-29 22:14:02 +08:00

944 lines
35 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 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, 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")
passwords = request.form.getlist("passwords")
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 prepare_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
password = passwords[i] if i < len(passwords) else ""
is_valid, err_msg, pdf_bytes = prepare_pdf_upload(f, password)
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)
with open(filepath, "wb") as output:
output.write(pdf_bytes)
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/<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":
_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},
)
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/<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, "会话不存在")
_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/<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", "")
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
from insurance.ppt.comparison import detect_generation_scenario
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()
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 ""
if template.scenario_tag and file_scenario and 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 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, "无有效提取数据")
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,
"companyId": company_id, "productIds": product_ids,
"useMaskedData": use_masked_data,
"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,
"productIds": product_ids,
"useMaskedData": use_masked_data,
"scenario": scenario,
},
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/<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)
# ─── 预览接口 ─────────────────────────────────────────────
@ppt_bp.route("/preview/<session_id>", 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, "会话不存在")
preview_status = session.preview_status or "none"
slides_data = None
if session.slides_json_path:
if os.path.exists(session.slides_json_path):
try:
with open(session.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", session.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": json.loads(session.versions_json) if session.versions_json else [],
"generatedRevision": session.generated_revision or 0,
})
@ppt_bp.route("/preview/<session_id>/slide/<int:index>", 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/<session_id>/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/<session_id>/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,
},
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,
})