baodan/api/insurance/ppt/routes.py
wsb1224 ed9f0327d2 已完成:
OCR 表格模式改为更适合计划书的 PSM 4。
分别支持斜杠年度、独立年度/年龄列、纵向压缩表格、保证/非保证双栏。
防止错误的坐标解析结果覆盖正确 OCR 数据。
身故利益表与退保价值表按保单年度合并。
修复吸烟状态、保额、年缴/单缴金额和缴费年期。
补齐 SIUL3、SBIUL2、GIUL3、FWD IF、AIA PIL2 产品配置。
多份计划书现在保留全部保司,不再只取第一家公司。
用户端保司选项、生成快照和渲染过程统一使用后台脱敏名
2026-08-01 19:12:17 +08:00

1337 lines
51 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""PPT 生成模块 Blueprint 路由。"""
import os
import uuid
import json
import logging
from datetime import datetime
from flask import Blueprint, request, jsonify, send_file
from insurance.middleware.auth_middleware import jwt_required
from insurance.utils.response import success, error, ErrorCode
logger = logging.getLogger(__name__)
ppt_bp = Blueprint("ppt", __name__)
def _get_session(session_id: str, user_id: str):
"""获取会话记录。"""
from insurance.db.compat import db
from insurance.models.ppt_session import PptSession
session = PptSession.query.filter_by(id=session_id).first()
if not session:
return None
if session.user_id != user_id:
return None
return session
def _save_session(session):
"""保存会话记录。"""
from insurance.db.compat import db
db.session.add(session)
db.session.commit()
# ─── 健康检查 ─────────────────────────────────────────────
@ppt_bp.route("/health", methods=["GET"])
def health():
return success({"status": "ok"})
# ─── 渲染选项 ─────────────────────────────────────────────
@ppt_bp.route("/render-options", methods=["GET"])
def render_options():
"""获取可用的公司和模板风格列表。"""
from insurance.models.ppt_config import PptCompany, PptProduct, 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.plan_type.asc(), 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"),
} 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],
)
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 []
applicable_products = template_data.get("applicableProductIds") or []
if applicable_companies and any(
item not in applicable_companies for item in company_ids
):
return error(ErrorCode.PARAM_ERROR, "所选 PPT 模板不适用于当前保司")
if applicable_products and not set(product_ids).intersection(applicable_products):
return error(ErrorCode.PARAM_ERROR, "所选 PPT 模板不适用于当前产品")
theme = template.style_preset
extractions = json.loads(session.extractions_json) if session.extractions_json else []
if not extractions:
return error(ErrorCode.PARAM_ERROR, "无解析数据")
# 快速校验:确保有可生成的数据
has_valid = any(
e.get("status") in ("success", "partial") and e.get("data")
for e in extractions
)
if not has_valid:
return error(ErrorCode.PARAM_ERROR, "无有效提取数据")
from insurance.ppt.comparison import detect_generation_scenario
scenario = detect_generation_scenario([
{"kind": e.get("planType") or (e.get("data") or {}).get("product_type")}
for e in extractions
if e.get("status") in ("success", "partial") and e.get("data")
])
# 更新会话草稿选项
import uuid
session.draft_options_json = json.dumps({
"theme": theme, "templateId": template_id,
"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,
}, ensure_ascii=False)
session.draft_revision = (session.draft_revision or 1) + 1
_save_session(session)
# 创建异步任务
from insurance.generation import task_service
result = task_service.create_task(
user_id=user_id,
artifact_type="ppt",
operation="generate",
workspace_id=session_id,
title=session.title or f"PPT {session_id[:8]}",
input_snapshot={
"theme": theme,
"templateId": template_id,
"companyId": company_id,
"companyIds": company_ids,
"productIds": product_ids,
"brandPolicy": brand_policy,
"scenario": scenario,
"templateAsset": template_asset_snapshot,
},
input_revision=session.draft_revision or 1,
idempotency_key=f"ppt_gen_{session_id}_{session.draft_revision}",
)
if result.get("code") != 0:
return error(ErrorCode.PARAM_ERROR, result.get("message", "创建任务失败"))
task_data = result["data"]
session.latest_task_id = task_data["id"]
_save_session(session)
from flask import jsonify, make_response
resp = make_response(jsonify({
"code": 0,
"data": {
"taskId": task_data["id"],
"status": "queued",
"sessionId": session_id,
"scenario": scenario,
"pollUrl": f"/insurance/workspace/tasks/{task_data['id']}",
},
}))
resp.status_code = 202
return resp
# ─── 下载 PPT ─────────────────────────────────────────────
@ppt_bp.route("/download/<session_id>", methods=["GET"])
@jwt_required
def download_ppt(session_id):
"""下载生成的 PPT。"""
user_id = str(getattr(request, "user_id", "guest"))
session = _get_session(session_id, user_id)
if not session:
return error(ErrorCode.NOT_FOUND, "会话不存在")
if not session.ppt_path or not os.path.exists(session.ppt_path):
return error(ErrorCode.NOT_FOUND, "PPT 文件不存在")
# 记录下载历史
try:
_record_history(
user_id=user_id,
action_type="download",
session_id=session_id,
file_url=session.ppt_path,
)
except Exception:
logger.warning("记录下载历史失败", exc_info=True)
return send_file(
session.ppt_path,
as_attachment=True,
download_name=f"{session_id}.pptx",
mimetype="application/vnd.openxmlformats-officedocument.presentationml.presentation",
)
# ─── 验证提取数据 ─────────────────────────────────────────
@ppt_bp.route("/validate/<session_id>", methods=["GET"])
@jwt_required
def validate_extraction(session_id):
"""验证提取数据完整性。"""
user_id = str(getattr(request, "user_id", "guest"))
session = _get_session(session_id, user_id)
if not session:
return error(ErrorCode.NOT_FOUND, "会话不存在")
extractions = json.loads(session.extractions_json) if session.extractions_json else []
all_issues = []
from insurance.ppt.normalizer import normalize_savings_plan, normalize_ci_plan, normalize_iul_plan
from insurance.ppt.validator import validate_formal_savings_plan, validate_formal_ci_plan, validate_formal_iul_plan
for idx, ext in enumerate(extractions):
pdf_name = ext.get("pdfName", f"文件{idx + 1}")
product_name = ext.get("productName", "unknown")
# 解析失败的文件必须产生阻断问题,不能静默跳过
if ext.get("status") not in ("success", "partial") or not ext.get("data"):
error_detail = ext.get("error", "未知错误")
all_issues.append({
"extractionIndex": idx,
"pdfName": pdf_name,
"field": "EXTRACTION_FAILED",
"path": "",
"section": "fields",
"severity": "error",
"message": f"{pdf_name}{product_name})解析失败:{error_detail}",
"suggestedAction": "review",
"state": "unresolved",
})
continue
data = ext["data"]
plan_type = (ext.get("planType") or data.get("product_type") or "savings").lower()
pdf_path = ext.get("pdfPath")
try:
if plan_type == "ci":
normalized = normalize_ci_plan(data, pdf_path)
issues = validate_formal_ci_plan(normalized)
elif plan_type == "iul":
normalized = normalize_iul_plan(data, pdf_path)
issues = validate_formal_iul_plan(normalized)
else:
normalized = normalize_savings_plan(data, pdf_path)
issues = validate_formal_savings_plan(normalized)
all_issues.extend([{
"extractionIndex": idx,
"pdfName": pdf_name,
"field": i.code,
"path": i.path or "",
"section": i.section or "",
"severity": i.level,
"message": i.message,
"suggestedAction": i.suggested_action or "review",
"state": "unresolved",
} for i in issues])
except Exception as e:
all_issues.append({
"extractionIndex": idx,
"pdfName": pdf_name,
"field": "general",
"severity": "error",
"message": str(e),
})
# ── 跨文件兼容性校验(同险种比较场景) ──
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 必须是数组")
# 合并更新:只更新 data 字段,保留 pdfPath/status 等元信息
existing = json.loads(session.extractions_json) if session.extractions_json else []
existing_map = {e["pdfName"]: e for e in existing}
for ext in extractions:
pdf_name = ext.get("pdfName")
if not pdf_name or pdf_name not in existing_map:
continue
# 更新数据字段
if "data" in ext:
existing_map[pdf_name]["data"] = ext["data"]
if "productName" in ext:
existing_map[pdf_name]["productName"] = ext["productName"]
if "planType" in ext:
existing_map[pdf_name]["planType"] = ext["planType"]
_reassess_extraction(existing_map[pdf_name])
updated = list(existing_map.values())
session.extractions_json = json.dumps(updated, ensure_ascii=False)
session.status = "parsed" # 回到 parsed 状态,需要重新生成
_save_session(session)
return success({
"sessionId": session_id,
"status": "updated",
"extractions": [{
"pdfName": e["pdfName"],
"planType": e["planType"],
"status": e["status"],
"productName": e["productName"],
"yearCount": e["yearCount"],
} for e in updated],
})
# ─── 公司知识库匹配 ───────────────────────────────────────
@ppt_bp.route("/company-kb/match", methods=["POST"])
@jwt_required
def match_company():
"""匹配公司知识库。"""
data = request.get_json(silent=True) or {}
product_name = data.get("productName")
company_hint = data.get("companyHint")
forced_company_id = data.get("companyId")
from insurance.models.ppt_config import PptCompany, PptProduct
from insurance.ppt.knowledge import match_company_knowledge
companies = [c.to_dict() for c in PptCompany.query.all()]
products = [p.to_dict() for p in PptProduct.query.all()]
result = match_company_knowledge(
product_name=product_name,
company_hint=company_hint,
forced_company_id=forced_company_id,
companies=companies,
products=products,
)
return success(result)
# ─── 历史记录 ─────────────────────────────────────────────
def _record_history(user_id, action_type, session_id=None, company_id=None,
product_id=None, template_id=None, content_snapshot=None,
file_url=None):
"""写入历史记录(内部函数)。"""
from insurance.db.compat import db
from insurance.models.ppt_history import PptHistory
from flask import request as req
record = PptHistory(
user_id=user_id,
session_id=session_id,
action_type=action_type,
company_id=company_id,
product_id=product_id,
template_id=template_id,
content_snapshot=json.dumps(content_snapshot, ensure_ascii=False) if content_snapshot else None,
file_url=file_url,
ip=req.remote_addr,
user_agent=req.headers.get("User-Agent", "")[:500],
)
db.session.add(record)
db.session.commit()
@ppt_bp.route("/history", methods=["GET"])
@jwt_required
def list_history():
"""当前用户的历史记录列表。"""
from insurance.db.compat import db
from insurance.models.ppt_history import PptHistory
user_id = str(getattr(request, "user_id", "guest"))
page = max(1, request.args.get("page", 1, type=int))
page_size = min(100, max(1, request.args.get("page_size", 20, type=int)))
company_id = request.args.get("company_id", "")
action_type = request.args.get("action_type", "")
query = db.session.query(PptHistory).filter(PptHistory.user_id == user_id)
if company_id:
query = query.filter(PptHistory.company_id == company_id)
if action_type:
query = query.filter(PptHistory.action_type == action_type)
query = query.order_by(PptHistory.created_at.desc())
total = query.count()
items = query.offset((page - 1) * page_size).limit(page_size).all()
return success({
"total": total,
"items": [h.to_dict() for h in items],
})
@ppt_bp.route("/history/<int:history_id>", methods=["GET"])
@jwt_required
def get_history(history_id):
"""单条历史详情。"""
from insurance.models.ppt_history import PptHistory
user_id = str(getattr(request, "user_id", "guest"))
record = PptHistory.query.get(history_id)
if not record or record.user_id != user_id:
return error(ErrorCode.NOT_FOUND, "记录不存在")
return success(record.to_dict())
@ppt_bp.route("/history/<int:history_id>/re-download", methods=["GET", "POST"])
@jwt_required
def re_download(history_id):
"""重新下载历史文件。"""
from insurance.models.ppt_history import PptHistory
user_id = str(getattr(request, "user_id", "guest"))
record = PptHistory.query.get(history_id)
if not record or record.user_id != user_id:
return error(ErrorCode.NOT_FOUND, "记录不存在")
if not record.file_url or not os.path.exists(record.file_url):
return error(ErrorCode.NOT_FOUND, "文件不存在")
return send_file(record.file_url, as_attachment=True)
# ─── 预览接口 ─────────────────────────────────────────────
@ppt_bp.route("/preview/<session_id>", methods=["GET"])
@jwt_required
def get_preview(session_id):
"""获取幻灯片预览数据(结构化 JSON + 质量报告)。"""
user_id = str(getattr(request, "user_id", "guest"))
session = _get_session(session_id, user_id)
if not session:
return error(ErrorCode.NOT_FOUND, "会话不存在")
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("/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)
doc.close()
return success({"pageCount": page_count, "pdfName": ext.get("pdfName", "")})
except ImportError:
return error(ErrorCode.INTERNAL_ERROR, "PDF 渲染库未安装")
except Exception as e:
return error(ErrorCode.INTERNAL_ERROR, str(e)[:100])