"""PPT 生成模块 Blueprint 路由。""" import os import uuid import json import logging from datetime import datetime from flask import Blueprint, request, jsonify, send_file from insurance.middleware.auth_middleware import account_required as jwt_required from insurance.utils.response import success, error, ErrorCode logger = logging.getLogger(__name__) ppt_bp = Blueprint("ppt", __name__) def _get_session(session_id: str, user_id: str): """获取会话记录。""" from insurance.db.compat import db from insurance.models.ppt_session import PptSession session = PptSession.query.filter_by(id=session_id).first() if not session: return None if session.user_id != user_id: return None return session def _save_session(session): """保存会话记录。""" from insurance.db.compat import db db.session.add(session) db.session.commit() # ─── 健康检查 ───────────────────────────────────────────── @ppt_bp.route("/health", methods=["GET"]) def health(): return success({"status": "ok"}) # ─── 渲染选项 ───────────────────────────────────────────── @ppt_bp.route("/render-options", methods=["GET"]) def render_options(): """获取可用的公司和模板风格列表。""" from insurance.models.ppt_config import PptCompany, PptProduct, PptScenario, PptTemplate companies = PptCompany.query.filter_by(status=1, deleted_at=None).order_by( PptCompany.sort_order.asc(), PptCompany.id.asc() ).all() products = PptProduct.query.filter_by(status=1, deleted_at=None).order_by( PptProduct.sort_order.asc(), PptProduct.id.asc() ).all() templates = PptTemplate.query.filter_by(status=1, deleted_at=None).order_by( PptTemplate.scenario_tag.asc(), PptTemplate.plan_type.asc(), PptTemplate.asset_version.desc(), PptTemplate.id.asc(), ).all() scenarios = PptScenario.query.filter_by(status=1, deleted_at=None).order_by( PptScenario.sort_order.asc(), PptScenario.code.asc() ).all() scenario_map = {item.code: item.to_dict() for item in scenarios} template_items = [] for template in templates: item = template.to_dict() scenario_config = scenario_map.get(template.scenario_tag) or {} item["scenarioBase"] = scenario_config.get("baseScenario") item["generationMode"] = scenario_config.get("generationMode") item["scenarioName"] = scenario_config.get("name") template_items.append(item) from insurance.ppt.masking import public_company_option, public_product_option return success({ "companies": [public_company_option(c.to_dict()) for c in companies], "products": [public_product_option(p.to_dict()) for p in products], "templates": template_items, "scenarios": [item.to_dict() for item in scenarios], }) # ─── 上传 PDF ───────────────────────────────────────────── @ppt_bp.route("/upload", methods=["POST"]) @jwt_required def upload_pdfs(): """上传 PDF 文件并创建会话。 支持 1~10 份计划书,五组并行数组(files/types/companies/products/passwords)长度必须一致。 采用"先校验后写入"的原子上传策略:任一文件校验失败时不创建会话。 """ from flask import jsonify as _jsonify MAX_UPLOAD_FILES = 10 VALID_TYPES = {"savings", "ci", "iul"} user_id = str(getattr(request, "user_id", "guest")) files = request.files.getlist("files") types = request.form.getlist("types") companies = request.form.getlist("companies") products = request.form.getlist("products") passwords = request.form.getlist("passwords") # ── 基础参数校验 ── if not files: return error(ErrorCode.PARAM_ERROR, "请至少上传 1 份计划书") if len(files) > MAX_UPLOAD_FILES: return error(ErrorCode.PARAM_ERROR, f"最多支持上传 {MAX_UPLOAD_FILES} 份计划书") for name, arr in [("types", types), ("companies", companies), ("products", products), ("passwords", passwords)]: if len(arr) != len(files): return error(ErrorCode.PARAM_ERROR, f"{name} 数组长度与 files 不一致") for i, t in enumerate(types): if t not in VALID_TYPES: return error(ErrorCode.PARAM_ERROR, f"第 {i + 1} 份文件的险种 '{t}' 不合法") # ── 第一阶段:校验全部文件,不写入磁盘 ── from insurance.utils.security import prepare_pdf_upload from insurance.models.ppt_config import PptCompany, PptProduct validated = [] # (f, pdf_bytes, file_record) 通过校验的文件 file_errors = [] # 结构化错误列表 for i, f in enumerate(files): fname = f.filename or f"文件{i + 1}" if not f.filename or not f.filename.lower().endswith(".pdf"): file_errors.append({"index": i, "fileName": fname, "field": "file", "message": "仅支持 PDF 格式"}) continue plan_type = types[i] company_id = companies[i] if i < len(companies) else "" product_id = products[i] if i < len(products) else "" password = passwords[i] if i < len(passwords) else "" # 保司校验 company = PptCompany.query.filter_by( id=company_id, status=1, deleted_at=None ).first() if company_id else None if company_id and not company: file_errors.append({"index": i, "fileName": fname, "field": "company", "message": "所选保司不存在或已停用"}) continue # 产品校验 product = PptProduct.query.filter_by( id=product_id, status=1, deleted_at=None ).first() if product_id else None if product_id and ( not product or product.plan_type != plan_type or (company_id and product.company_id != company_id) ): file_errors.append({"index": i, "fileName": fname, "field": "product", "message": "所选产品与险种或保司不匹配"}) continue if product and not company_id: company_id = product.company_id # PDF 安全校验 is_valid, err_msg, pdf_bytes = prepare_pdf_upload(f, password) if not is_valid: file_errors.append({"index": i, "fileName": fname, "field": "password", "message": err_msg or "PDF 校验失败"}) continue validated.append((f, pdf_bytes, { "name": fname, "type": plan_type, "companyId": company_id, "productId": product_id, })) # 任一文件失败则整体拒绝 if file_errors: return _jsonify({ "code": ErrorCode.FILE_FORMAT_ERROR, "message": "部分计划书校验失败", "data": {"fileErrors": file_errors}, }), 400 if not validated: return error(ErrorCode.FILE_FORMAT_ERROR, "无有效 PDF 文件") # ── 第二阶段:全部通过,统一写入磁盘并创建会话 ── from insurance.config import get_storage_root upload_dir = os.path.join(get_storage_root(), "uploads", "ppt", user_id) os.makedirs(upload_dir, exist_ok=True) file_records = [] for f, pdf_bytes, meta in validated: filename = f"{uuid.uuid4().hex[:8]}_{meta['name']}" filepath = os.path.join(upload_dir, filename) with open(filepath, "wb") as output: output.write(pdf_bytes) file_records.append({ "path": filepath, "name": meta["name"], "type": meta["type"], "companyId": meta["companyId"], "productId": meta["productId"], }) session_id = uuid.uuid4().hex from insurance.models.ppt_session import PptSession session = PptSession( id=session_id, user_id=user_id, status="created", files_json=json.dumps(file_records, ensure_ascii=False), ) _save_session(session) return success({ "sessionId": session_id, "files": [f["name"] for f in file_records], }) # ─── 解析 PDF ───────────────────────────────────────────── @ppt_bp.route("/parse/", 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//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/", 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/", 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/", 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/", 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/", 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//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/", 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//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/", 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//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//versions//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//slide/", 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//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//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///", methods=["GET"]) @jwt_required def pdf_page_preview(session_id, extraction_index, page_number): """返回 PDF 指定页的 PNG 预览图。 用于核对页面展示 PDF 原文证据。 """ import tempfile user_id = str(getattr(request, "user_id", "guest")) session = _get_session(session_id, user_id) if not session: return error(ErrorCode.NOT_FOUND, "会话不存在") extractions = json.loads(session.extractions_json) if session.extractions_json else [] if extraction_index < 0 or extraction_index >= len(extractions): return error(ErrorCode.PARAM_ERROR, "提取索引越界") ext = extractions[extraction_index] pdf_path = ext.get("pdfPath") if not pdf_path or not os.path.exists(pdf_path): return error(ErrorCode.NOT_FOUND, "PDF 文件不存在") try: try: import fitz except ImportError: import pymupdf as fitz doc = fitz.open(pdf_path) if page_number < 1 or page_number > len(doc): doc.close() return error(ErrorCode.PARAM_ERROR, f"页码越界(共 {len(doc)} 页)") page = doc[page_number - 1] # 1-based to 0-based # 渲染为 PNG,2x 缩放(约 150 DPI,平衡质量和大小) pixmap = page.get_pixmap(matrix=fitz.Matrix(2.0, 2.0)) doc.close() # 写入临时文件返回 img_bytes = pixmap.tobytes("png") import io buf = io.BytesIO(img_bytes) buf.seek(0) return send_file( buf, mimetype="image/png", as_attachment=False, download_name=f"page-{page_number}.png", ) except ImportError: return error(ErrorCode.INTERNAL_ERROR, "PDF 渲染库未安装(需要 PyMuPDF)") except Exception as e: logger.error(f"PDF 页面预览失败: {e}") return error(ErrorCode.INTERNAL_ERROR, f"PDF 渲染失败: {str(e)[:100]}") @ppt_bp.route("/pdf-info//", 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])