主要完成: PPT/海报统一“数据可信度”摘要、缺失/冲突/人工修改统计:[ReviewStatusSummary.vue (line 5)](D:/work/code/python/coding/baodanagent/frontend/src/components/generation/ReviewStatusSummary.vue:5) PPT 证据原文查看、页码定位和 PDF bbox 区域高亮:[PptDataReview.vue (line 464)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:464)、[PdfPagePreview.vue (line 59)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PdfPagePreview.vue:59) 场景匹配原因、场景/模板版本及配置异常阻断:[PptGenerate.vue (line 95)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptGenerate.vue:95) 任务版本、生成哈希、输出一致性、当前/历史结果追溯:[GenerationTracePanel.vue (line 1)](D:/work/code/python/coding/baodanagent/frontend/src/components/generation/GenerationTracePanel.vue:1)、[TasksPage.vue (line 70)](D:/work/code/python/coding/baodanagent/frontend/src/pages/TasksPage.vue:70) PPT/海报历史页增加“任务与版本追溯”视图:[PptHistoryPage.vue (line 13)](D:/work/code/python/coding/baodanagent/frontend/src/pages/PptHistoryPage.vue:13)、[PosterHistoryPage.vue (line 9)](D:/work/code/python/coding/baodanagent/frontend/src/pages/PosterHistoryPage.vue:9) 治理后台增加 JSON 实时校验、格式化、校验报告、失败重试和移动端适配:[PptGovernanceAdmin.vue (line 206)](D:/work/code/python/coding/baodanagent/frontend/src/pages/admin/PptGovernanceAdmin.vue:206) 恢复此前被注释、点击无反应的产品推荐弹窗:[ChatPage.vue (line 65)](D:/work/code/python/coding/baodanagent/frontend/src/pages/ChatPage.vue:65) 后端 PDF 信息接口补充页面尺寸,用于准确绘制证据框:[routes.py (line 1737)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/routes.py:1737) 验证结果: 全量 ESLint:通过 Vue TypeScript 类型检查:通过 生产构建:通过 PPT 相关定向测试:21 项通过 项目测试:296 项通过、3 项跳过;另有 2 项既有测试环境问题,与本次修改无关 API 健康检查:HTTP 200,数据库、Redis、存储、数据表均正常 前端容器已确认挂载最新 frontend/dist,当前最新资源已通过 9080 端口提供 现在直接访问 http://localhost:9080 即可。容器使用目录挂载,不需要重新构建镜像;如果仍然看到旧界面,请按 Ctrl+F5 强制刷新浏览器缓存。
1744 lines
70 KiB
Python
1744 lines
70 KiB
Python
"""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 account_required as 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, PptScenario, PptTemplate
|
||
companies = PptCompany.query.filter_by(status=1, deleted_at=None).order_by(
|
||
PptCompany.sort_order.asc(), PptCompany.id.asc()
|
||
).all()
|
||
products = PptProduct.query.filter_by(status=1, deleted_at=None).order_by(
|
||
PptProduct.sort_order.asc(), PptProduct.id.asc()
|
||
).all()
|
||
templates = PptTemplate.query.filter_by(status=1, deleted_at=None).order_by(
|
||
PptTemplate.scenario_tag.asc(),
|
||
PptTemplate.plan_type.asc(),
|
||
PptTemplate.asset_version.desc(),
|
||
PptTemplate.id.asc(),
|
||
).all()
|
||
scenarios = PptScenario.query.filter_by(status=1, deleted_at=None).order_by(
|
||
PptScenario.sort_order.asc(), PptScenario.code.asc()
|
||
).all()
|
||
scenario_map = {item.code: item.to_dict() for item in scenarios}
|
||
template_items = []
|
||
for template in templates:
|
||
item = template.to_dict()
|
||
scenario_config = scenario_map.get(template.scenario_tag) or {}
|
||
item["scenarioBase"] = scenario_config.get("baseScenario")
|
||
item["generationMode"] = scenario_config.get("generationMode")
|
||
item["scenarioName"] = scenario_config.get("name")
|
||
template_items.append(item)
|
||
from insurance.ppt.masking import public_company_option, public_product_option
|
||
return success({
|
||
"companies": [public_company_option(c.to_dict()) for c in companies],
|
||
"products": [public_product_option(p.to_dict()) for p in products],
|
||
"templates": template_items,
|
||
"scenarios": [item.to_dict() for item in scenarios],
|
||
})
|
||
|
||
|
||
# ─── 上传 PDF ─────────────────────────────────────────────
|
||
|
||
@ppt_bp.route("/upload", methods=["POST"])
|
||
@jwt_required
|
||
def upload_pdfs():
|
||
"""上传 PDF 文件并创建会话。
|
||
|
||
支持 1~10 份计划书,五组并行数组(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, deleted_at=None
|
||
).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, deleted_at=None
|
||
).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=getattr(session, "draft_revision", None) 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"),
|
||
"errorCode": e.get("errorCode"),
|
||
"errorDetails": e.get("errorDetails") or {},
|
||
"documentId": e.get("documentId"),
|
||
"documentStatus": e.get("documentStatus"),
|
||
} 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"
|
||
requested_company_id = data.get("companyId", "")
|
||
template_id = data.get("templateId", "")
|
||
files = json.loads(session.files_json) if session.files_json else []
|
||
product_ids = [item.get("productId") for item in files if item.get("productId")]
|
||
company_ids = list(dict.fromkeys(
|
||
item.get("companyId") for item in files if item.get("companyId")
|
||
))
|
||
if not company_ids and requested_company_id:
|
||
company_ids = [requested_company_id]
|
||
|
||
from insurance.models.ppt_config import PptCompany
|
||
configured_companies = (
|
||
PptCompany.query.filter(
|
||
PptCompany.id.in_(company_ids),
|
||
PptCompany.status == 1,
|
||
PptCompany.deleted_at.is_(None),
|
||
).all()
|
||
if company_ids else []
|
||
)
|
||
companies_by_id = {item.id: item for item in configured_companies}
|
||
missing_company_ids = [item for item in company_ids if item not in companies_by_id]
|
||
if missing_company_ids:
|
||
return error(ErrorCode.PARAM_ERROR, "所选保司不存在或已停用")
|
||
configured_companies = [companies_by_id[item] for item in company_ids]
|
||
company_id = company_ids[0] if len(company_ids) == 1 else ""
|
||
|
||
from insurance.models.ppt_config import PptProduct
|
||
configured_products = (
|
||
PptProduct.query.filter(
|
||
PptProduct.id.in_(product_ids),
|
||
PptProduct.status == 1,
|
||
PptProduct.deleted_at.is_(None),
|
||
).all()
|
||
if product_ids else []
|
||
)
|
||
from insurance.ppt.masking import build_brand_policy
|
||
brand_policy = build_brand_policy(
|
||
[company.to_dict() for company in configured_companies],
|
||
[product.to_dict() for product in configured_products],
|
||
)
|
||
from insurance.ppt.masking import apply_brand_policy
|
||
company_definitions = {}
|
||
for company in configured_companies:
|
||
masked_company, _ = apply_brand_policy(company.to_dict(), None, brand_policy)
|
||
company_definitions[str(company.id)] = masked_company
|
||
product_definitions = {}
|
||
for product in configured_products:
|
||
_, masked_product = apply_brand_policy(None, product.to_dict(), brand_policy)
|
||
product_definitions[str(product.id)] = masked_product
|
||
|
||
template_data = None
|
||
template_asset_snapshot = None
|
||
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, deleted_at=None
|
||
).first()
|
||
if not template:
|
||
return error(ErrorCode.PARAM_ERROR, "所选 PPT 模板不存在或已停用")
|
||
template_data = template.to_dict()
|
||
if template.source_template_asset_id:
|
||
from insurance.ppt.template_asset_service import (
|
||
resolve_template_asset,
|
||
template_asset_sha256,
|
||
)
|
||
try:
|
||
asset_path = resolve_template_asset(template.source_template_asset_id)
|
||
asset_sha256 = template.asset_sha256 or (
|
||
template_asset_sha256(asset_path) if asset_path and os.path.isfile(asset_path) else None
|
||
)
|
||
except (OSError, ValueError):
|
||
asset_path = None
|
||
asset_sha256 = None
|
||
if not asset_path or not asset_sha256:
|
||
return error(ErrorCode.PARAM_ERROR, "所选 PPT 模板文件不存在或校验失败")
|
||
template_asset_snapshot = {
|
||
"assetId": template.source_template_asset_id,
|
||
"version": template.asset_version or 1,
|
||
"sha256": asset_sha256,
|
||
"rendererMode": template.clone_renderer or "clone-edit-v2",
|
||
}
|
||
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 ""
|
||
from insurance.ppt.scenarios import template_scenario_compatible
|
||
if (
|
||
template.scenario_tag
|
||
and file_scenario
|
||
and not template_scenario_compatible(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 []
|
||
from insurance.ppt.template_selection import template_scope_compatible
|
||
if not template_scope_compatible(template_data, company_ids, product_ids):
|
||
if applicable_companies and any(item not in applicable_companies for item in company_ids):
|
||
return error(ErrorCode.PARAM_ERROR, "所选 PPT 模板不适用于当前保司")
|
||
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, "无有效提取数据")
|
||
|
||
requested_snapshot_ids = data.get("snapshotIds")
|
||
if requested_snapshot_ids is None:
|
||
requested_snapshot_ids = json.loads(session.snapshot_ids_json) if session.snapshot_ids_json else []
|
||
if not isinstance(requested_snapshot_ids, list) or not requested_snapshot_ids:
|
||
return jsonify({
|
||
"code": 4201,
|
||
"message": "请先确认计划书数据",
|
||
"data": {"errorCode": "SNAPSHOT_REQUIRED"},
|
||
}), 422
|
||
from insurance.plan_data.projections import to_deck_contract
|
||
from insurance.plan_data.service import get_owned_snapshot
|
||
confirmed_snapshots = []
|
||
for snapshot_id in requested_snapshot_ids:
|
||
snapshot_item = get_owned_snapshot(int(snapshot_id), user_id)
|
||
if not snapshot_item or snapshot_item.status != "confirmed":
|
||
return jsonify({
|
||
"code": 4201,
|
||
"message": "计划书快照未确认或已不可用",
|
||
"data": {"errorCode": "SNAPSHOT_NOT_CONFIRMED", "snapshotId": snapshot_id},
|
||
}), 422
|
||
confirmed_snapshots.append(snapshot_item)
|
||
extraction_document_ids = {
|
||
int(item["documentId"])
|
||
for item in extractions
|
||
if item.get("status") in ("success", "partial") and item.get("data") and item.get("documentId")
|
||
}
|
||
if {item.document_id for item in confirmed_snapshots} != extraction_document_ids:
|
||
return jsonify({
|
||
"code": 4201,
|
||
"message": "快照与当前计划书列表不一致,请重新确认",
|
||
"data": {"errorCode": "SNAPSHOT_DOCUMENT_MISMATCH"},
|
||
}), 409
|
||
deck_contracts = [
|
||
to_deck_contract(item.plan_data(), document_sha256=item.document_sha256)
|
||
for item in confirmed_snapshots
|
||
]
|
||
extraction_by_document = {
|
||
int(item["documentId"]): item for item in extractions if item.get("documentId")
|
||
}
|
||
for contract, snapshot_item in zip(deck_contracts, confirmed_snapshots):
|
||
source = extraction_by_document.get(snapshot_item.document_id, {})
|
||
contract.update({
|
||
"fileId": str(snapshot_item.document_id),
|
||
"pdfName": source.get("pdfName") or "",
|
||
"companyId": source.get("companyId") or "",
|
||
"productId": source.get("productId") or "",
|
||
"snapshotId": snapshot_item.id,
|
||
"snapshotHash": snapshot_item.snapshot_hash,
|
||
})
|
||
from insurance.plan_data.validators import canonical_json_hash
|
||
snapshot_set_hash = canonical_json_hash([item.snapshot_hash for item in confirmed_snapshots])
|
||
|
||
from insurance.ppt.comparison import detect_generation_scenario
|
||
detected_scenario = detect_generation_scenario([{"kind": item.get("kind")} for item in deck_contracts])
|
||
requested_scenario = str(data.get("scenario") or "").strip()
|
||
scenario_version = None
|
||
policy_version = None
|
||
template_version = None
|
||
reconciliation_manifest = []
|
||
from insurance.generation.feature_flags import enabled_for
|
||
scenario_v2_enabled = enabled_for(
|
||
"SCENARIO_ENGINE_V2", identity=user_id,
|
||
profile=(company_ids[0] if len(company_ids) == 1 else "multi"),
|
||
)
|
||
if scenario_v2_enabled:
|
||
from insurance.ppt.versioning import (
|
||
VersioningError, published_policy, published_template, resolve_published_scenario,
|
||
)
|
||
try:
|
||
resolution = resolve_published_scenario({
|
||
"fileCount": len(deck_contracts),
|
||
"planTypes": [item.get("kind") for item in deck_contracts],
|
||
"companyIds": company_ids,
|
||
"productIds": product_ids,
|
||
}, data.get("scenarioVersionId"))
|
||
except VersioningError as exc:
|
||
return jsonify({"code": 4201, "message": str(exc), "data": {
|
||
"errorCode": exc.code, **exc.details,
|
||
}}), 422
|
||
scenario_version = resolution["scenarioVersion"]
|
||
scenario = scenario_version["scenarioCode"]
|
||
policy_row = published_policy(str(data.get("policyCode") or "default"))
|
||
if not policy_row:
|
||
return jsonify({"code": 4201, "message": "没有已发布的生成策略", "data": {
|
||
"errorCode": "POLICY_VERSION_REQUIRED",
|
||
}}), 422
|
||
policy_version = policy_row.to_public_dict()
|
||
if not template_id:
|
||
return jsonify({"code": 4201, "message": "版本化生成必须明确选择模板", "data": {
|
||
"errorCode": "TEMPLATE_VERSION_REQUIRED",
|
||
}}), 422
|
||
template_row = published_template(template_id)
|
||
if not template_row:
|
||
return jsonify({"code": 4201, "message": "所选模板没有已发布版本", "data": {
|
||
"errorCode": "TEMPLATE_VERSION_REQUIRED",
|
||
}}), 422
|
||
template_version = template_row.to_public_dict()
|
||
if scenario_version["id"] not in template_version["supportedScenarioVersionIds"]:
|
||
return jsonify({"code": 4201, "message": "模板版本不支持当前场景版本", "data": {
|
||
"errorCode": "TEMPLATE_SCENARIO_VERSION_MISMATCH",
|
||
}}), 422
|
||
from insurance.ppt.versioning import build_runtime_merge
|
||
try:
|
||
merge_plan = build_runtime_merge(scenario_version, template_version, policy_version)
|
||
except VersioningError as exc:
|
||
return jsonify({"code": 4201, "message": str(exc), "data": {
|
||
"errorCode": exc.code, **exc.details,
|
||
}}), 422
|
||
from insurance.ppt.template_asset_service import resolve_template_asset, template_asset_sha256
|
||
try:
|
||
version_asset_path = resolve_template_asset(template_version["assetId"])
|
||
version_asset_hash = template_asset_sha256(version_asset_path)
|
||
except (OSError, ValueError):
|
||
version_asset_path = None
|
||
version_asset_hash = None
|
||
if not version_asset_path or version_asset_hash != template_version["assetSha256"]:
|
||
return jsonify({"code": 4201, "message": "已发布模板版本的源资产缺失或校验失败", "data": {
|
||
"errorCode": "TEMPLATE_VERSION_ASSET_INVALID",
|
||
}}), 422
|
||
template_asset_snapshot = {
|
||
"assetId": template_version["assetId"],
|
||
"version": template_version["version"],
|
||
"sha256": template_version["assetSha256"],
|
||
"rendererMode": "clone-edit-v2",
|
||
}
|
||
template_data = dict(template_data or {})
|
||
template_data.update(merge_plan["templateConfig"])
|
||
template_data.update({
|
||
"sourceTemplateAssetId": template_version["assetId"],
|
||
"assetSha256": template_version["assetSha256"],
|
||
"assetVersion": template_version["version"],
|
||
"scenarioPageSpecs": merge_plan["pages"],
|
||
"generationPolicy": policy_version,
|
||
})
|
||
from insurance.ppt.versioning import build_reconciliation_manifest
|
||
try:
|
||
reconciliation_manifest = build_reconciliation_manifest(
|
||
deck_contracts, scenario_version, policy_version
|
||
)
|
||
except VersioningError as exc:
|
||
return jsonify({"code": 4201, "message": str(exc), "data": {
|
||
"errorCode": exc.code, **exc.details,
|
||
}}), 422
|
||
scenario_override_trace = {
|
||
"resolver": "published-scenario-resolver-v2",
|
||
"detectedScenario": detected_scenario,
|
||
"requestedScenario": requested_scenario or None,
|
||
"finalScenario": scenario,
|
||
"scenarioVersionId": scenario_version["id"],
|
||
"checks": resolution["matchTrace"],
|
||
}
|
||
elif requested_scenario:
|
||
from insurance.ppt.scenarios import template_scenario_compatible
|
||
if not template_scenario_compatible(requested_scenario, detected_scenario):
|
||
return jsonify({
|
||
"code": 4201,
|
||
"message": "所选场景与当前计划书组合不兼容",
|
||
"data": {"errorCode": "SCENARIO_INCOMPATIBLE"},
|
||
}), 422
|
||
if not scenario_v2_enabled:
|
||
scenario = requested_scenario or detected_scenario
|
||
scenario_override_trace = {
|
||
"resolver": "legacy_server_detector",
|
||
"detectedScenario": detected_scenario,
|
||
"requestedScenario": requested_scenario or None,
|
||
"templateScenarioTag": (template_data or {}).get("scenarioTag") or None,
|
||
"finalScenario": scenario,
|
||
}
|
||
|
||
# 更新会话草稿选项
|
||
import uuid
|
||
session.draft_options_json = json.dumps({
|
||
"theme": theme, "templateId": template_id,
|
||
"templateName": (template_data or {}).get("name") or template_id,
|
||
"stylePreset": (template_data or {}).get("stylePreset") or theme,
|
||
"companyId": company_id, "companyIds": company_ids, "productIds": product_ids,
|
||
"brandPolicy": brand_policy,
|
||
"scenario": scenario,
|
||
"scenarioOverrideTrace": scenario_override_trace,
|
||
}, ensure_ascii=False)
|
||
session.draft_revision = (session.draft_revision or 1) + 1
|
||
_save_session(session)
|
||
|
||
# 创建异步任务
|
||
from insurance.generation import task_service
|
||
generation_fingerprint = canonical_json_hash({
|
||
"artifactType": "ppt",
|
||
"snapshotHash": snapshot_set_hash,
|
||
"scenarioHash": (scenario_version or {}).get("definitionHash"),
|
||
"policyHash": (policy_version or {}).get("policyHash"),
|
||
"templateHash": (template_version or {}).get("definitionHash"),
|
||
"assetSha256": (template_asset_snapshot or {}).get("sha256"),
|
||
"rendererVersion": "insurance-renderer-v1",
|
||
"submitRevision": session.draft_revision or 1,
|
||
})
|
||
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,
|
||
"companyIds": company_ids,
|
||
"productIds": product_ids,
|
||
"brandPolicy": brand_policy,
|
||
"companyDefinitions": company_definitions,
|
||
"productDefinitions": product_definitions,
|
||
"scenario": scenario,
|
||
"scenarioOverrideTrace": scenario_override_trace,
|
||
"templateAsset": template_asset_snapshot,
|
||
"templateDefinition": template_data,
|
||
"scenarioVersion": scenario_version,
|
||
"scenarioVersionId": (scenario_version or {}).get("id"),
|
||
"scenarioHash": (scenario_version or {}).get("definitionHash"),
|
||
"policyVersion": policy_version,
|
||
"policyVersionId": (policy_version or {}).get("id"),
|
||
"policyHash": (policy_version or {}).get("policyHash"),
|
||
"templateVersion": template_version,
|
||
"templateVersionId": (template_version or {}).get("id"),
|
||
"templateHash": (template_version or {}).get("definitionHash"),
|
||
"reconciliationManifest": reconciliation_manifest,
|
||
"planSnapshots": [{
|
||
"id": item.id,
|
||
"version": item.snapshot_version,
|
||
"hash": item.snapshot_hash,
|
||
"documentId": item.document_id,
|
||
"documentSha256": item.document_sha256,
|
||
} for item in confirmed_snapshots],
|
||
"deckContracts": deck_contracts,
|
||
"snapshotHash": snapshot_set_hash,
|
||
},
|
||
input_revision=session.draft_revision or 1,
|
||
idempotency_key=f"ppt_gen_{generation_fingerprint[:32]}",
|
||
)
|
||
|
||
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),
|
||
})
|
||
|
||
# ── 跨文件兼容性校验(同险种比较场景) ──
|
||
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)})
|
||
except Exception as e:
|
||
logger.warning("跨文件兼容性校验异常: %s", 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")
|
||
info_count = sum(1 for i in all_issues if i["severity"] == "info")
|
||
return success({
|
||
"sessionId": session_id,
|
||
"validated": error_count == 0,
|
||
"canProceed": error_count == 0,
|
||
"blockerCount": error_count,
|
||
"warningCount": warn_count,
|
||
"infoCount": info_count,
|
||
"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 必须是数组")
|
||
|
||
# 合并更新:Document ID 是新链路主键;旧记录仅兼容 pdfName。
|
||
existing = json.loads(session.extractions_json) if session.extractions_json else []
|
||
existing_by_document = {
|
||
str(item.get("documentId")): item for item in existing if item.get("documentId")
|
||
}
|
||
existing_by_name = {item.get("pdfName"): item for item in existing if item.get("pdfName")}
|
||
|
||
for ext in extractions:
|
||
current = None
|
||
if ext.get("documentId"):
|
||
current = existing_by_document.get(str(ext["documentId"]))
|
||
if current is None and ext.get("pdfName"):
|
||
current = existing_by_name.get(ext["pdfName"])
|
||
if current is None:
|
||
continue
|
||
# 更新数据字段
|
||
if "data" in ext:
|
||
current["data"] = ext["data"]
|
||
if "productName" in ext:
|
||
current["productName"] = ext["productName"]
|
||
if "planType" in ext:
|
||
current["planType"] = ext["planType"]
|
||
if isinstance(ext.get("modificationLog"), list):
|
||
current["modificationLog"] = ext["modificationLog"][-500:]
|
||
current["overrideReason"] = str(ext.get("overrideReason") or "").strip() or None
|
||
_reassess_extraction(current)
|
||
|
||
updated = existing
|
||
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, "会话不存在")
|
||
|
||
versions = json.loads(session.versions_json) if session.versions_json else []
|
||
requested_revision = request.args.get("revision", type=int)
|
||
selected_version = next(
|
||
(item for item in versions if item.get("revision") == requested_revision),
|
||
None,
|
||
) if requested_revision is not None else None
|
||
if requested_revision is not None and not selected_version:
|
||
return error(ErrorCode.NOT_FOUND, "PPT 版本不存在")
|
||
|
||
preview_status = session.preview_status or "none"
|
||
slides_data = None
|
||
slides_json_path = (
|
||
selected_version.get("slidesJsonPath")
|
||
if selected_version else session.slides_json_path
|
||
)
|
||
|
||
if slides_json_path:
|
||
if os.path.exists(slides_json_path):
|
||
try:
|
||
with open(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", 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": versions,
|
||
"generatedRevision": session.generated_revision or 0,
|
||
"viewingRevision": (
|
||
requested_revision
|
||
if requested_revision is not None else session.generated_revision or 0
|
||
),
|
||
"generationConfig": (
|
||
json.loads(session.draft_options_json)
|
||
if session.draft_options_json else {}
|
||
),
|
||
})
|
||
|
||
|
||
@ppt_bp.route("/scenarios/resolve", methods=["POST"])
|
||
@jwt_required
|
||
def resolve_scenario():
|
||
"""基于 confirmed snapshot 列表返回唯一服务端场景和命中轨迹。"""
|
||
payload = request.get_json(silent=True) or {}
|
||
snapshot_ids = payload.get("snapshotIds") or []
|
||
if not isinstance(snapshot_ids, list) or not snapshot_ids:
|
||
return jsonify({
|
||
"code": 4201,
|
||
"message": "snapshotIds 不能为空",
|
||
"data": {"errorCode": "SNAPSHOT_REQUIRED"},
|
||
}), 422
|
||
user_id = str(getattr(request, "user_id", "guest"))
|
||
from insurance.plan_data.projections import to_deck_contract
|
||
from insurance.plan_data.service import get_owned_snapshot
|
||
|
||
snapshots = [get_owned_snapshot(int(item), user_id) for item in snapshot_ids]
|
||
if any(item is None or item.status != "confirmed" for item in snapshots):
|
||
return jsonify({
|
||
"code": 4201,
|
||
"message": "存在未确认或无权访问的快照",
|
||
"data": {"errorCode": "SNAPSHOT_NOT_CONFIRMED"},
|
||
}), 422
|
||
contracts = [to_deck_contract(item.plan_data()) for item in snapshots]
|
||
from insurance.generation.feature_flags import enabled_for
|
||
if enabled_for("SCENARIO_ENGINE_V2", identity=user_id):
|
||
from insurance.models.ppt_config import PptScenario
|
||
from insurance.ppt.versioning import VersioningError, resolve_published_scenario
|
||
context = {
|
||
"fileCount": len(contracts),
|
||
"planTypes": [item.get("kind") for item in contracts],
|
||
"companyIds": list(dict.fromkeys(item.get("companyId") for item in contracts if item.get("companyId"))),
|
||
"productIds": list(dict.fromkeys(item.get("productId") for item in contracts if item.get("productId"))),
|
||
}
|
||
try:
|
||
resolution = resolve_published_scenario(context, payload.get("scenarioVersionId"))
|
||
except VersioningError as exc:
|
||
return jsonify({"code": 4201, "message": str(exc), "data": {
|
||
"errorCode": exc.code, **exc.details,
|
||
}}), 422
|
||
selected = resolution["scenarioVersion"]
|
||
scenario = PptScenario.query.get(selected["scenarioCode"])
|
||
return success({
|
||
"scenario": selected["scenarioCode"],
|
||
"scenarioVersion": selected,
|
||
"generationMode": scenario.generation_mode if scenario else "single",
|
||
"matchTrace": {
|
||
"resolver": "published-scenario-resolver-v2",
|
||
"snapshotIds": [item.id for item in snapshots],
|
||
"checks": resolution["matchTrace"],
|
||
"finalScenario": selected["scenarioCode"],
|
||
"scenarioVersionId": selected["id"],
|
||
},
|
||
})
|
||
from insurance.ppt.comparison import detect_generation_scenario, generation_mode_for_scenario
|
||
from insurance.ppt.scenarios import template_scenario_compatible
|
||
|
||
detected = detect_generation_scenario([{"kind": item.get("kind")} for item in contracts])
|
||
requested = str(payload.get("scenarioCode") or "").strip()
|
||
if requested and not template_scenario_compatible(requested, detected):
|
||
return jsonify({
|
||
"code": 4201,
|
||
"message": "所选场景与当前计划书组合不兼容",
|
||
"data": {
|
||
"errorCode": "SCENARIO_INCOMPATIBLE",
|
||
"matchTrace": {"detectedScenario": detected, "requestedScenario": requested},
|
||
},
|
||
}), 422
|
||
resolved = requested or detected
|
||
return success({
|
||
"scenario": resolved,
|
||
"generationMode": generation_mode_for_scenario(detected),
|
||
"matchTrace": {
|
||
"resolver": "server-snapshot-resolver-v1",
|
||
"snapshotIds": [item.id for item in snapshots],
|
||
"planTypes": [item.get("kind") for item in contracts],
|
||
"detectedScenario": detected,
|
||
"requestedScenario": requested or None,
|
||
"finalScenario": resolved,
|
||
},
|
||
})
|
||
|
||
|
||
@ppt_bp.route("/session/<session_id>/confirm", methods=["POST"])
|
||
@jwt_required
|
||
def confirm_extractions(session_id):
|
||
"""将当前复核结果固化为每份文档的 confirmed PlanData Snapshot。"""
|
||
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 []
|
||
previous_ids = json.loads(session.snapshot_ids_json) if session.snapshot_ids_json else []
|
||
previous_by_document = {}
|
||
from insurance.plan_data.service import get_owned_snapshot
|
||
for snapshot_id in previous_ids:
|
||
previous = get_owned_snapshot(snapshot_id, user_id)
|
||
if previous:
|
||
previous_by_document[previous.document_id] = previous.id
|
||
|
||
from insurance.plan_data.conversion import legacy_path_to_plan_path
|
||
from insurance.plan_data.service import (
|
||
SnapshotError,
|
||
confirm_snapshot,
|
||
create_draft_from_legacy,
|
||
patch_draft,
|
||
record_overrides,
|
||
)
|
||
confirmed = []
|
||
try:
|
||
for extraction in extractions:
|
||
if extraction.get("status") not in ("success", "partial") or not extraction.get("data"):
|
||
continue
|
||
document_id = int(extraction.get("documentId") or 0)
|
||
if not document_id:
|
||
raise SnapshotError("DOCUMENT_IR_REQUIRED", "存在未接入 Document IR 的计划书,请重新解析")
|
||
data = extraction["data"]
|
||
draft = create_draft_from_legacy(
|
||
document_id,
|
||
user_id,
|
||
data,
|
||
evidence_entries=extraction.get("evidence") or [],
|
||
previous_snapshot_id=previous_by_document.get(document_id),
|
||
)
|
||
changes_by_path = {}
|
||
structural_changes = []
|
||
for item in extraction.get("modificationLog") or []:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
target = legacy_path_to_plan_path(data, item.get("path") or "")
|
||
if target:
|
||
changes_by_path[target] = item.get("newValue")
|
||
else:
|
||
structural_changes.append({
|
||
"fieldPath": item.get("path") or "structure",
|
||
"oldValue": item.get("oldValue"),
|
||
"newValue": item.get("newValue"),
|
||
})
|
||
if changes_by_path or structural_changes:
|
||
reason = str(extraction.get("overrideReason") or "").strip()
|
||
if not reason:
|
||
raise SnapshotError("OVERRIDE_REASON_REQUIRED", "人工修改计划书数据后必须填写修改原因")
|
||
draft = patch_draft(
|
||
draft.id,
|
||
user_id,
|
||
expected_hash=draft.snapshot_hash,
|
||
changes=[{
|
||
"fieldPath": path,
|
||
"value": value,
|
||
"overrideReason": reason,
|
||
} for path, value in changes_by_path.items()],
|
||
)
|
||
snapshot = confirm_snapshot(draft.id, user_id)
|
||
if structural_changes:
|
||
record_overrides(snapshot.id, user_id, structural_changes, reason)
|
||
confirmed.append(snapshot)
|
||
except SnapshotError as exc:
|
||
return jsonify({
|
||
"code": 4201,
|
||
"message": exc.message,
|
||
"data": {"errorCode": exc.code, **exc.data},
|
||
}), exc.status
|
||
|
||
if not confirmed:
|
||
return error(ErrorCode.PARAM_ERROR, "没有可确认的解析结果", 422)
|
||
session.snapshot_ids_json = json.dumps([item.id for item in confirmed])
|
||
session.status = "confirmed"
|
||
_save_session(session)
|
||
return success({
|
||
"sessionId": session_id,
|
||
"status": "confirmed",
|
||
"snapshots": [item.to_public_dict() for item in confirmed],
|
||
})
|
||
|
||
|
||
@ppt_bp.route("/preview/<session_id>/versions/<int:revision>/restore", methods=["POST"])
|
||
@jwt_required
|
||
def restore_preview_version(session_id, revision):
|
||
"""把历史版本复制为新的当前版本,保留原历史文件。"""
|
||
import shutil
|
||
import uuid
|
||
from insurance.config import get_storage_root
|
||
from insurance.db.compat import db
|
||
|
||
user_id = str(getattr(request, "user_id", "guest"))
|
||
session = _get_session(session_id, user_id)
|
||
if not session:
|
||
return error(ErrorCode.NOT_FOUND, "会话不存在")
|
||
versions = json.loads(session.versions_json) if session.versions_json else []
|
||
source = next((item for item in versions if item.get("revision") == revision), None)
|
||
if not source:
|
||
return error(ErrorCode.NOT_FOUND, "PPT 版本不存在")
|
||
|
||
ppt_path = source.get("path")
|
||
slides_path = source.get("slidesJsonPath")
|
||
if not ppt_path or not os.path.exists(ppt_path):
|
||
return error(ErrorCode.NOT_FOUND, "历史版本文件已被清理")
|
||
|
||
output_root = os.path.abspath(os.path.join(get_storage_root(), "outputs", "ppt"))
|
||
source_abs = os.path.abspath(ppt_path)
|
||
if not source_abs.startswith(output_root):
|
||
return error(ErrorCode.PARAM_ERROR, "历史版本路径无效")
|
||
|
||
new_revision = max(
|
||
[int(item.get("revision") or 0) for item in versions] + [session.generated_revision or 0]
|
||
) + 1
|
||
output_dir = os.path.join(
|
||
output_root, str(user_id), f"restore_{uuid.uuid4().hex[:12]}"
|
||
)
|
||
os.makedirs(output_dir, exist_ok=True)
|
||
target_ppt = os.path.join(output_dir, "presentation.pptx")
|
||
shutil.copy2(source_abs, target_ppt)
|
||
target_slides = None
|
||
if slides_path and os.path.exists(slides_path):
|
||
slides_abs = os.path.abspath(slides_path)
|
||
if not slides_abs.startswith(output_root):
|
||
return error(ErrorCode.PARAM_ERROR, "历史预览路径无效")
|
||
slides_dir = os.path.join(output_dir, "slides")
|
||
os.makedirs(slides_dir, exist_ok=True)
|
||
target_slides = os.path.join(slides_dir, "slides.json")
|
||
shutil.copy2(slides_abs, target_slides)
|
||
|
||
restored = {
|
||
"revision": new_revision,
|
||
"path": target_ppt,
|
||
"slidesJsonPath": target_slides,
|
||
"deckPath": source.get("deckPath"),
|
||
"slideCount": source.get("slideCount") or 0,
|
||
"sourceRevision": revision,
|
||
"source": "restore",
|
||
"createdAt": __import__("datetime").datetime.now().isoformat(),
|
||
}
|
||
versions.append(restored)
|
||
session.versions_json = json.dumps(versions, ensure_ascii=False)
|
||
session.ppt_path = target_ppt
|
||
session.latest_output_path = target_ppt
|
||
session.slides_json_path = target_slides
|
||
session.slide_count = restored["slideCount"]
|
||
session.preview_status = "ready" if target_slides else "failed"
|
||
session.generated_revision = new_revision
|
||
session.draft_revision = max(session.draft_revision or 1, new_revision)
|
||
db.session.commit()
|
||
_record_history(
|
||
user_id=user_id,
|
||
action_type="restore_version",
|
||
session_id=session_id,
|
||
content_snapshot={"sourceRevision": revision, "newRevision": new_revision},
|
||
file_url=target_ppt,
|
||
)
|
||
return success({
|
||
"sessionId": session_id,
|
||
"revision": new_revision,
|
||
"sourceRevision": revision,
|
||
})
|
||
|
||
|
||
@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
|
||
# 渲染为 PNG,2x 缩放(约 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)
|
||
page_sizes = [
|
||
{
|
||
"pageNumber": index + 1,
|
||
"width": round(float(page.rect.width), 3),
|
||
"height": round(float(page.rect.height), 3),
|
||
}
|
||
for index, page in enumerate(doc)
|
||
]
|
||
doc.close()
|
||
return success({
|
||
"pageCount": page_count,
|
||
"pageSizes": page_sizes,
|
||
"pdfName": ext.get("pdfName", ""),
|
||
})
|
||
except ImportError:
|
||
return error(ErrorCode.INTERNAL_ERROR, "PDF 渲染库未安装")
|
||
except Exception as e:
|
||
return error(ErrorCode.INTERNAL_ERROR, str(e)[:100])
|