baodan/api/insurance/ppt/routes.py

1151 lines
43 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 文件并创建会话。
支持 110 份计划书,五组并行数组(files/types/companies/products/passwords)长度必须一致。
采用"先校验后写入"的原子上传策略:任一文件校验失败时不创建会话。
"""
from flask import jsonify as _jsonify
MAX_UPLOAD_FILES = 10
VALID_TYPES = {"savings", "ci", "iul"}
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, "请至少上传 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
validated = [] # (f, pdf_bytes, file_record) 通过校验的文件
file_errors = [] # 结构化错误列表
for i, f in enumerate(files):
fname = f.filename or f"文件{i + 1}"
if not f.filename or not f.filename.lower().endswith(".pdf"):
file_errors.append({"index": i, "fileName": fname, "field": "file", "message": "仅支持 PDF 格式"})
continue
plan_type = types[i]
company_id = companies[i] if i < len(companies) else ""
product_id = products[i] if i < len(products) else ""
password = passwords[i] if i < len(passwords) else ""
# 保司校验
company = PptCompany.query.filter_by(id=company_id, status=1).first() if company_id else None
if company_id and not company:
file_errors.append({"index": i, "fileName": fname, "field": "company", "message": "所选保司不存在或已停用"})
continue
# 产品校验
product = PptProduct.query.filter_by(id=product_id, status=1).first() if product_id else None
if product_id and (
not product
or product.plan_type != plan_type
or (company_id and product.company_id != company_id)
):
file_errors.append({"index": i, "fileName": fname, "field": "product", "message": "所选产品与险种或保司不匹配"})
continue
if product and not company_id:
company_id = product.company_id
# PDF 安全校验
is_valid, err_msg, pdf_bytes = prepare_pdf_upload(f, password)
if not is_valid:
file_errors.append({"index": i, "fileName": fname, "field": "password", "message": err_msg or "PDF 校验失败"})
continue
validated.append((f, pdf_bytes, {
"name": fname,
"type": plan_type,
"companyId": company_id,
"productId": product_id,
}))
# 任一文件失败则整体拒绝
if file_errors:
return _jsonify({
"code": ErrorCode.FILE_FORMAT_ERROR,
"message": "部分计划书校验失败",
"data": {"fileErrors": file_errors},
}), 400
if not validated:
return error(ErrorCode.FILE_FORMAT_ERROR, "无有效 PDF 文件")
# ── 第二阶段:全部通过,统一写入磁盘并创建会话 ──
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"],
})
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},
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 = "解析任务已启动"
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,
},
input_revision=session.draft_revision or 1,
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 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"):
error_detail = ext.get("error", "未知错误")
all_issues.append({
"extractionIndex": idx,
"pdfName": pdf_name,
"field": "EXTRACTION_FAILED",
"path": "",
"section": "fields",
"severity": "error",
"message": f"{pdf_name}{product_name})解析失败:{error_detail}",
"suggestedAction": "review",
"state": "unresolved",
})
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([{
"extractionIndex": idx,
"pdfName": pdf_name,
"field": i.code,
"path": i.path or "",
"section": i.section or "",
"severity": i.level,
"message": i.message,
"suggestedAction": i.suggested_action or "review",
"state": "unresolved",
} for i in issues])
except Exception as e:
all_issues.append({
"extractionIndex": idx,
"pdfName": pdf_name,
"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")
# ── 跨文件兼容性校验(同险种比较场景) ──
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)
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,
},
input_revision=session.draft_revision or 1,
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,
})
# ─── PDF 页面预览 ─────────────────────────────────────────
@ppt_bp.route("/pdf-preview/<session_id>/<int:extraction_index>/<int:page_number>", methods=["GET"])
@jwt_required
def pdf_page_preview(session_id, extraction_index, page_number):
"""返回 PDF 指定页的 PNG 预览图。
用于核对页面展示 PDF 原文证据。
"""
import tempfile
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 []
if extraction_index < 0 or extraction_index >= len(extractions):
return error(ErrorCode.PARAM_ERROR, "提取索引越界")
ext = extractions[extraction_index]
pdf_path = ext.get("pdfPath")
if not pdf_path or not os.path.exists(pdf_path):
return error(ErrorCode.NOT_FOUND, "PDF 文件不存在")
try:
try:
import fitz
except ImportError:
import pymupdf as fitz
doc = fitz.open(pdf_path)
if page_number < 1 or page_number > len(doc):
doc.close()
return error(ErrorCode.PARAM_ERROR, f"页码越界(共 {len(doc)} 页)")
page = doc[page_number - 1] # 1-based to 0-based
# 渲染为 PNG2x 缩放(约 150 DPI平衡质量和大小
pixmap = page.get_pixmap(matrix=fitz.Matrix(2.0, 2.0))
doc.close()
# 写入临时文件返回
img_bytes = pixmap.tobytes("png")
import io
buf = io.BytesIO(img_bytes)
buf.seek(0)
return send_file(
buf,
mimetype="image/png",
as_attachment=False,
download_name=f"page-{page_number}.png",
)
except ImportError:
return error(ErrorCode.INTERNAL_ERROR, "PDF 渲染库未安装(需要 PyMuPDF")
except Exception as e:
logger.error(f"PDF 页面预览失败: {e}")
return error(ErrorCode.INTERNAL_ERROR, f"PDF 渲染失败: {str(e)[:100]}")
@ppt_bp.route("/pdf-info/<session_id>/<int:extraction_index>", methods=["GET"])
@jwt_required
def pdf_info(session_id, extraction_index):
"""返回 PDF 基本信息(总页数等)。"""
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 []
if extraction_index < 0 or extraction_index >= len(extractions):
return error(ErrorCode.PARAM_ERROR, "提取索引越界")
ext = extractions[extraction_index]
pdf_path = ext.get("pdfPath")
if not pdf_path or not os.path.exists(pdf_path):
return error(ErrorCode.NOT_FOUND, "PDF 文件不存在")
try:
try:
import fitz
except ImportError:
import pymupdf as fitz
doc = fitz.open(pdf_path)
page_count = len(doc)
doc.close()
return success({"pageCount": page_count, "pdfName": ext.get("pdfName", "")})
except ImportError:
return error(ErrorCode.INTERNAL_ERROR, "PDF 渲染库未安装")
except Exception as e:
return error(ErrorCode.INTERNAL_ERROR, str(e)[:100])