baodan/api/insurance/ppt/routes.py
wsb1224 11f9b108db 已完成本轮剩余前端功能开发,计划书中的前端核心任务目前没有功能性遗留。
主要完成:
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 强制刷新浏览器缓存。
2026-08-02 17:11:07 +08:00

1744 lines
70 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 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 文件并创建会话。
支持 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, 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
# 渲染为 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)
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])