baodan/api/insurance/ppt/routes.py

1054 lines
40 KiB
Python
Raw Normal View History

2026-07-23 13:10:50 +08:00
"""PPT 生成模块 Blueprint 路由。"""
import os
import uuid
import json
import logging
from datetime import datetime
2026-07-23 13:10:50 +08:00
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():
2026-07-30 10:30:33 +08:00
"""上传 PDF 文件并创建会话。
支持 110 份计划书五组并行数组(files/types/companies/products/passwords)长度必须一致
采用"先校验后写入"的原子上传策略任一文件校验失败时不创建会话
"""
from flask import jsonify as _jsonify
MAX_UPLOAD_FILES = 10
VALID_TYPES = {"savings", "ci", "iul"}
2026-07-23 13:10:50 +08:00
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")
passwords = request.form.getlist("passwords")
2026-07-23 13:10:50 +08:00
2026-07-30 10:30:33 +08:00
# ── 基础参数校验 ──
2026-07-23 13:10:50 +08:00
if not files:
2026-07-30 10:30:33 +08:00
return error(ErrorCode.PARAM_ERROR, "请至少上传 1 份计划书")
if len(files) > MAX_UPLOAD_FILES:
return error(ErrorCode.PARAM_ERROR, f"最多支持上传 {MAX_UPLOAD_FILES} 份计划书")
for name, arr in [("types", types), ("companies", companies),
("products", products), ("passwords", passwords)]:
if len(arr) != len(files):
return error(ErrorCode.PARAM_ERROR, f"{name} 数组长度与 files 不一致")
for i, t in enumerate(types):
if t not in VALID_TYPES:
return error(ErrorCode.PARAM_ERROR, f"{i + 1} 份文件的险种 '{t}' 不合法")
# ── 第一阶段:校验全部文件,不写入磁盘 ──
from insurance.utils.security import prepare_pdf_upload
from insurance.models.ppt_config import PptCompany, PptProduct
2026-07-23 13:10:50 +08:00
2026-07-30 10:30:33 +08:00
validated = [] # (f, pdf_bytes, file_record) 通过校验的文件
file_errors = [] # 结构化错误列表
2026-07-23 13:10:50 +08:00
for i, f in enumerate(files):
2026-07-30 10:30:33 +08:00
fname = f.filename or f"文件{i + 1}"
2026-07-23 13:10:50 +08:00
if not f.filename or not f.filename.lower().endswith(".pdf"):
2026-07-30 10:30:33 +08:00
file_errors.append({"index": i, "fileName": fname, "field": "file", "message": "仅支持 PDF 格式"})
2026-07-23 13:10:50 +08:00
continue
2026-07-30 10:30:33 +08:00
plan_type = types[i]
2026-07-29 12:19:26 +08:00
company_id = companies[i] if i < len(companies) else ""
product_id = products[i] if i < len(products) else ""
2026-07-30 10:30:33 +08:00
password = passwords[i] if i < len(passwords) else ""
# 保司校验
company = PptCompany.query.filter_by(id=company_id, status=1).first() if company_id else None
2026-07-29 12:19:26 +08:00
if company_id and not company:
2026-07-30 10:30:33 +08:00
file_errors.append({"index": i, "fileName": fname, "field": "company", "message": "所选保司不存在或已停用"})
2026-07-29 12:19:26 +08:00
continue
2026-07-30 10:30:33 +08:00
# 产品校验
product = PptProduct.query.filter_by(id=product_id, status=1).first() if product_id else None
2026-07-29 12:19:26 +08:00
if product_id and (
not product
or product.plan_type != plan_type
or (company_id and product.company_id != company_id)
):
2026-07-30 10:30:33 +08:00
file_errors.append({"index": i, "fileName": fname, "field": "product", "message": "所选产品与险种或保司不匹配"})
2026-07-29 12:19:26 +08:00
continue
if product and not company_id:
company_id = product.company_id
2026-07-30 10:30:33 +08:00
# PDF 安全校验
is_valid, err_msg, pdf_bytes = prepare_pdf_upload(f, password)
if not is_valid:
2026-07-30 10:30:33 +08:00
file_errors.append({"index": i, "fileName": fname, "field": "password", "message": err_msg or "PDF 校验失败"})
continue
2026-07-23 13:10:50 +08:00
2026-07-30 10:30:33 +08:00
validated.append((f, pdf_bytes, {
"name": fname,
2026-07-23 13:10:50 +08:00
"type": plan_type,
"companyId": company_id,
2026-07-29 12:19:26 +08:00
"productId": product_id,
2026-07-30 10:30:33 +08:00
}))
2026-07-23 13:10:50 +08:00
2026-07-30 10:30:33 +08:00
# 任一文件失败则整体拒绝
if file_errors:
return _jsonify({
"code": ErrorCode.FILE_FORMAT_ERROR,
"message": "部分计划书校验失败",
"data": {"fileErrors": file_errors},
}), 400
if not validated:
2026-07-23 13:10:50 +08:00
return error(ErrorCode.FILE_FORMAT_ERROR, "无有效 PDF 文件")
2026-07-30 10:30:33 +08:00
# ── 第二阶段:全部通过,统一写入磁盘并创建会话 ──
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 = []
for f, pdf_bytes, meta in validated:
filename = f"{uuid.uuid4().hex[:8]}_{meta['name']}"
filepath = os.path.join(upload_dir, filename)
with open(filepath, "wb") as output:
output.write(pdf_bytes)
file_records.append({
"path": filepath,
"name": meta["name"],
"type": meta["type"],
"companyId": meta["companyId"],
"productId": meta["productId"],
})
2026-07-23 13:10:50 +08:00
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异步任务"""
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 []
if not files:
return error(ErrorCode.PARAM_ERROR, "没有可解析的 PDF 文件")
2026-07-23 13:10:50 +08:00
if session.status == "parsing":
_reconcile_parse_task(session)
if session.status == "parsing":
return success(_build_parse_status(session), "解析任务正在进行")
2026-07-23 13:10:50 +08:00
# 创建异步任务
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)
2026-07-23 13:10:50 +08:00
_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},
input_revision 正确记录 ✅ task_service.py — create_task() 新增 input_revision 参数,存入 GenerationTask.input_revision generation_task.py — 模型的静态 create_task 方法同步新增参数 ppt/routes.py — 三处调用(parse/generate/regenerate)均传入 session.draft_revision poster/service.py — 海报生成传入 record.draft_revision 现在每个任务快照都记录了创建时的草稿版本号,不再一直是默认值 1 2. 海报任务进度实时同步 ✅ celery_tasks.py — 在海报生成的每个阶段(preparing_data→20%、building_prompt→40%、requesting_image→60%、saving→85%)调用 sync_poster_progress() 同步到 PosterRecord.task_status/task_progress 任务领取时立即将 PosterRecord.task_status 设为 running 解决了"页面轮询海报记录时一直显示排队中"的问题 3. 后端测试覆盖 ✅ test_task_state_sync.py — 新增 13 个测试用例: sync_workspace_status 同步 cancelled/failed 到 PPT session(4 个) sync_workspace_status 同步 cancelled/failed 到 PosterRecord(2 个) 旧任务跳过同步的安全检查(1 个) cancel_task 状态更新和非 queued 拒绝(2 个) mark_task_viewed 正确标记和权限校验(2 个) input_revision 正确记录和默认值(2 个) list_active_tasks 排除终态任务(1 个) 所有 13 个新测试 + 原有 10 个测试全部通过 4. useAutoSave TypeScript 类型修复 ✅ useAutoSave.ts — getEndpoint 变量显式标注 () => string 类型,消除 TS2345 编译错误
2026-07-30 13:54:44 +08:00
input_revision=session.draft_revision or 1,
)
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 = "解析任务已启动"
2026-07-23 13:10:50 +08:00
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,
2026-07-23 13:10:50 +08:00
"extractions": [{
"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],
}
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):
"""生成 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
from insurance.ppt.comparison import detect_generation_scenario
2026-07-29 12:19:26 +08:00
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
):
2026-07-29 12:19:26 +08:00
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, "无解析数据")
# 快速校验:确保有可生成的数据
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, "无有效提取数据")
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({
2026-07-29 12:19:26 +08:00
"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
2026-07-23 13:10:50 +08:00
_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,
2026-07-29 12:19:26 +08:00
"templateId": template_id,
"companyId": company_id,
2026-07-29 12:19:26 +08:00
"productIds": product_ids,
"useMaskedData": use_masked_data,
"scenario": scenario,
},
input_revision 正确记录 ✅ task_service.py — create_task() 新增 input_revision 参数,存入 GenerationTask.input_revision generation_task.py — 模型的静态 create_task 方法同步新增参数 ppt/routes.py — 三处调用(parse/generate/regenerate)均传入 session.draft_revision poster/service.py — 海报生成传入 record.draft_revision 现在每个任务快照都记录了创建时的草稿版本号,不再一直是默认值 1 2. 海报任务进度实时同步 ✅ celery_tasks.py — 在海报生成的每个阶段(preparing_data→20%、building_prompt→40%、requesting_image→60%、saving→85%)调用 sync_poster_progress() 同步到 PosterRecord.task_status/task_progress 任务领取时立即将 PosterRecord.task_status 设为 running 解决了"页面轮询海报记录时一直显示排队中"的问题 3. 后端测试覆盖 ✅ test_task_state_sync.py — 新增 13 个测试用例: sync_workspace_status 同步 cancelled/failed 到 PPT session(4 个) sync_workspace_status 同步 cancelled/failed 到 PosterRecord(2 个) 旧任务跳过同步的安全检查(1 个) cancel_task 状态更新和非 queued 拒绝(2 个) mark_task_viewed 正确标记和权限校验(2 个) input_revision 正确记录和默认值(2 个) list_active_tasks 排除终态任务(1 个) 所有 13 个新测试 + 原有 10 个测试全部通过 4. useAutoSave TypeScript 类型修复 ✅ useAutoSave.ts — getEndpoint 变量显式标注 () => string 类型,消除 TS2345 编译错误
2026-07-30 13:54:44 +08:00
input_revision=session.draft_revision or 1,
idempotency_key=f"ppt_gen_{session_id}_{session.draft_revision}",
)
2026-07-23 13:10:50 +08:00
if result.get("code") != 0:
return error(ErrorCode.PARAM_ERROR, result.get("message", "创建任务失败"))
2026-07-23 13:10:50 +08:00
task_data = result["data"]
session.latest_task_id = task_data["id"]
2026-07-23 13:10:50 +08:00
_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
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
2026-07-31 09:00:39 +08:00
for idx, ext in enumerate(extractions):
pdf_name = ext.get("pdfName", f"文件{idx + 1}")
product_name = ext.get("productName", "unknown")
# 解析失败的文件必须产生阻断问题,不能静默跳过
if ext.get("status") not in ("success", "partial") or not ext.get("data"):
2026-07-31 09:00:39 +08:00
error_detail = ext.get("error", "未知错误")
all_issues.append({
"extractionIndex": idx,
"pdfName": pdf_name,
"field": "EXTRACTION_FAILED",
"severity": "error",
"message": f"{pdf_name}{product_name})解析失败:{error_detail}",
})
2026-07-23 13:10:50 +08:00
continue
2026-07-31 09:00:39 +08:00
2026-07-23 13:10:50 +08:00
data = ext["data"]
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":
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":
normalized = normalize_iul_plan(data, pdf_path)
2026-07-23 13:10:50 +08:00
issues = validate_formal_iul_plan(normalized)
else:
normalized = normalize_savings_plan(data, pdf_path)
2026-07-23 13:10:50 +08:00
issues = validate_formal_savings_plan(normalized)
2026-07-31 09:00:39 +08:00
all_issues.extend([{
"extractionIndex": idx,
"pdfName": pdf_name,
"field": i.code,
"path": i.path or "",
"section": i.section or "",
"severity": i.level,
"message": i.message,
} for i in issues])
2026-07-23 13:10:50 +08:00
except Exception as e:
2026-07-31 09:00:39 +08:00
all_issues.append({
"extractionIndex": idx,
"pdfName": pdf_name,
"field": "general",
"severity": "error",
"message": str(e),
})
2026-07-23 13:10:50 +08:00
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")
2026-07-30 10:30:33 +08:00
# ── 跨文件兼容性校验(同险种比较场景) ──
valid_extractions = [
e for e in extractions
if e.get("status") in ("success", "partial") and e.get("data")
]
if len(valid_extractions) >= 2:
try:
from insurance.ppt.comparison import (
detect_generation_scenario,
generation_mode_for_scenario,
build_comparison_contract,
SCENARIO_GENERIC_COMPARE,
SCENARIO_MULTI_SAVINGS,
)
normalized_for_check = []
from insurance.ppt.normalizer import normalize_savings_plan as _ns, normalize_ci_plan as _nc, normalize_iul_plan as _ni
for ext in valid_extractions:
data = ext["data"]
pt = (ext.get("planType") or data.get("product_type") or "savings").lower()
try:
if pt == "ci":
n = _nc(data, ext.get("pdfPath"))
elif pt == "iul":
n = _ni(data, ext.get("pdfPath"))
else:
n = _ns(data, ext.get("pdfPath"))
n["kind"] = pt
normalized_for_check.append(n)
except Exception:
pass
if len(normalized_for_check) >= 2:
scenario = detect_generation_scenario(normalized_for_check)
mode = generation_mode_for_scenario(scenario)
try:
contract = build_comparison_contract(normalized_for_check, mode=mode)
for w in contract.get("warnings", []):
all_issues.append({"field": "comparison", "severity": "warn", "message": w})
except ValueError as ve:
all_issues.append({"field": "comparison", "severity": "error", "message": str(ve)})
error_count += 1
except Exception as e:
logger.warning("跨文件兼容性校验异常: %s", e)
2026-07-23 13:10:50 +08:00
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],
})
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)
# ─── 预览接口 ─────────────────────────────────────────────
@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
所有阶段 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
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,
2026-07-29 21:41:28 +08:00
"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")
2026-07-29 21:41:28 +08:00
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})")
2026-07-29 21:41:28 +08:00
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})
2026-07-29 21:41:28 +08:00
@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,
},
input_revision 正确记录 ✅ task_service.py — create_task() 新增 input_revision 参数,存入 GenerationTask.input_revision generation_task.py — 模型的静态 create_task 方法同步新增参数 ppt/routes.py — 三处调用(parse/generate/regenerate)均传入 session.draft_revision poster/service.py — 海报生成传入 record.draft_revision 现在每个任务快照都记录了创建时的草稿版本号,不再一直是默认值 1 2. 海报任务进度实时同步 ✅ celery_tasks.py — 在海报生成的每个阶段(preparing_data→20%、building_prompt→40%、requesting_image→60%、saving→85%)调用 sync_poster_progress() 同步到 PosterRecord.task_status/task_progress 任务领取时立即将 PosterRecord.task_status 设为 running 解决了"页面轮询海报记录时一直显示排队中"的问题 3. 后端测试覆盖 ✅ test_task_state_sync.py — 新增 13 个测试用例: sync_workspace_status 同步 cancelled/failed 到 PPT session(4 个) sync_workspace_status 同步 cancelled/failed 到 PosterRecord(2 个) 旧任务跳过同步的安全检查(1 个) cancel_task 状态更新和非 queued 拒绝(2 个) mark_task_viewed 正确标记和权限校验(2 个) input_revision 正确记录和默认值(2 个) list_active_tasks 排除终态任务(1 个) 所有 13 个新测试 + 原有 10 个测试全部通过 4. useAutoSave TypeScript 类型修复 ✅ useAutoSave.ts — getEndpoint 变量显式标注 () => string 类型,消除 TS2345 编译错误
2026-07-30 13:54:44 +08:00
input_revision=session.draft_revision or 1,
2026-07-29 21:41:28 +08:00
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,
})