""" ============================================================ PDF OCR 智能问答系统 - Web UI (FastAPI) ============================================================ 模型栈: PaddleOCR-VL-1.5 + Embedding API + LLM API (OpenAI 兼容) 支持格式: PDF / PNG / JPG / JPEG / BMP / TIF / TIFF 启动: python app.py 访问: http://localhost:7860 前置依赖: 需先启动 Embedding API 和 LLM API 服务 (vLLM 或其他 OpenAI 兼容服务) """ import gc import time import shutil from pathlib import Path from typing import List, Optional, Dict, Any, Tuple # ---- 环境补丁 ---- # 修复两个环境兼容性问题: # 1. transformers + torch<2.4 → is_torch_available()=False → NameError # 2. paddleocr → paddlex → langchain_text_splitters → sentence_transformers # → transformers 的损坏导入链 # # 策略: 在触发导入链之前: # a) 提前导入 torch 并用 mock 模块替代 langchain_text_splitters # b) 提前导入我们的 RecursiveCharacterTextSplitter 并注入到 sys.modules def _apply_env_patches(): """尽早修复已知的环境兼容性问题""" import sys import types # Step 1: Mock `langchain_text_splitters` 以避免其 __init__.py # 触发 sentence_transformers → transformers 损坏链 if "langchain_text_splitters" not in sys.modules: mock_lts = types.ModuleType("langchain_text_splitters") mock_lts.__path__ = [] sys.modules["langchain_text_splitters"] = mock_lts # Step 2: 将我们的 RecursiveCharacterTextSplitter 注入到 mock 模块 mock_lts = sys.modules["langchain_text_splitters"] from text_processor import RecursiveCharacterTextSplitter as OurSplitter mock_lts.RecursiveCharacterTextSplitter = OurSplitter # Step 3: 确保 torch 对 transformers 可用 if "torch" not in sys.modules: try: import torch # noqa: F401 except ImportError: pass _apply_env_patches() from fastapi import FastAPI, File, Form, UploadFile, HTTPException from fastapi.responses import HTMLResponse, FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from loguru import logger import config from rag_chain import PDFRAGPipeline, RAGChain from vector_store import VectorStoreManager from ocr_loader import PaddleOCRLoader from text_processor import TextProcessingPipeline # ============================================================ # 全局状态 # ============================================================ _pipeline: Optional[PDFRAGPipeline] = None _processed_files: List[Dict[str, Any]] = [] _chat_history: List[Dict[str, str]] = [] # OCR 文本持久化目录 _OCR_OUTPUT_DIR = config.OCR_OUTPUT_DIR _FILES_JSON = _OCR_OUTPUT_DIR / "_files.json" def _load_files_from_disk(): """启动时从磁盘恢复已处理文件列表""" global _processed_files if _FILES_JSON.exists(): try: import json data = json.loads(_FILES_JSON.read_text(encoding="utf-8")) _processed_files = data.get("files", []) logger.info(f"从磁盘恢复 {len(_processed_files)} 个已处理文件") except Exception as e: logger.warning(f"恢复文件列表失败: {e}") def _save_files_to_disk(): """将已处理文件列表持久化到磁盘""" import json _FILES_JSON.parent.mkdir(parents=True, exist_ok=True) _FILES_JSON.write_text( json.dumps({"files": _processed_files}, ensure_ascii=False, indent=2), encoding="utf-8", ) def _get_ocr_text_path(filename: str) -> Path: """获取 OCR 文本的磁盘路径""" return _OCR_OUTPUT_DIR / f"{Path(filename).stem}.txt" def _save_ocr_text(filename: str, text: str): """保存 OCR 文本到磁盘""" path = _get_ocr_text_path(filename) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8") def _load_ocr_text(filename: str) -> str: """从磁盘读取 OCR 文本""" path = _get_ocr_text_path(filename) if path.exists(): return path.read_text(encoding="utf-8") return "" def _delete_ocr_text(filename: str): """从磁盘删除 OCR 文本""" path = _get_ocr_text_path(filename) if path.exists(): path.unlink() def get_pipeline() -> PDFRAGPipeline: global _pipeline if _pipeline is None: _pipeline = PDFRAGPipeline(verbose=False) return _pipeline # ============================================================ # 核心处理逻辑 (从原 Gradio 回调中提取) # ============================================================ def process_file_impl( file_path: Path, chunk_size: int = 800, chunk_overlap: int = 150, ) -> Tuple[Dict[str, Any], str]: """处理上传的文件: OCR → 分割 → 向量化入库""" global _pipeline, _processed_files, _chat_history suffix = file_path.suffix.lower() if suffix not in config.SUPPORTED_FORMATS: raise ValueError( f"不支持的文件格式: {suffix}\n支持: {', '.join(sorted(config.SUPPORTED_FORMATS))}" ) file_size_mb = file_path.stat().st_size / (1024 * 1024) if file_size_mb > config.MAX_FILE_SIZE_MB: raise ValueError(f"文件过大: {file_size_mb:.1f}MB (限制: {config.MAX_FILE_SIZE_MB}MB)") # 复用 pipeline 对象避免重复创建 LLM 实例 if _pipeline is None: _pipeline = PDFRAGPipeline( chunk_size=int(chunk_size), chunk_overlap=int(chunk_overlap), verbose=False, ) loader = PaddleOCRLoader(str(file_path), verbose=False) raw_docs = loader.load() # 逐页写入 OCR 文本到磁盘,避免内存中构建完整副本 ocr_path = _get_ocr_text_path(file_path.name) ocr_path.parent.mkdir(parents=True, exist_ok=True) with open(ocr_path, "w", encoding="utf-8") as ocr_f: preview_parts = [] for i, doc in enumerate(raw_docs): page_num = doc.metadata.get("page", i + 1) ocr_f.write(f"--- 第 {page_num} 页 ---\n{doc.page_content}\n\n") if i < 3: preview_parts.append( f"--- 第 {page_num} 页 ---\n{doc.page_content[:200]}..." ) if len(raw_docs) > 3: preview_parts.append(f"\n... (共 {len(raw_docs)} 页/文档)") preview = "\n\n".join(preview_parts) # 文本分割 pipeline = TextProcessingPipeline( chunk_size=int(chunk_size), chunk_overlap=int(chunk_overlap), ) chunks = pipeline.process(raw_docs) # 释放 raw_docs 引用,让 GC 可以回收 raw_docs.clear() # 向量化入库 _pipeline._vector_store_manager = VectorStoreManager( store_type=config.VECTOR_STORE_TYPE, ) _pipeline._vector_store_manager.clear() _pipeline._vector_store_manager.add_documents(chunks) _pipeline._rag_chain = RAGChain( vector_store_manager=_pipeline._vector_store_manager, llm=_pipeline.llm, ) _chat_history = [] file_info = { "name": file_path.name, "format": suffix, "pages": len(raw_docs) if raw_docs else _count_ocr_pages(ocr_path), "chunks": len(chunks), "size_mb": round(file_size_mb, 2), "time": time.strftime("%Y-%m-%d %H:%M:%S"), "path": str(file_path), } _processed_files.append(file_info) # 强制 GC 回收 OCR 过程中产生的临时对象 del chunks gc.collect() logger.info(f"文件处理成功: {file_path.name}, {file_info['pages']} 页, {file_info['chunks']} 块") return file_info, preview def _count_ocr_pages(ocr_path: Path) -> int: """从保存的 OCR 文件统计页数""" try: text = ocr_path.read_text(encoding="utf-8") return text.count("--- 第") or 1 except Exception: return 1 def ask_question_impl(question: str) -> Dict[str, Any]: """执行 RAG 问答""" global _pipeline, _chat_history if _pipeline is None or not _pipeline.is_ready: raise RuntimeError("请先上传并处理文件") result = _pipeline.ask_with_history(question, _chat_history) _chat_history.append({"role": "user", "content": question}) _chat_history.append({"role": "assistant", "content": result["answer"]}) # 限制历史长度以防止内存无限增长 (保留最近 20 轮) if len(_chat_history) > 40: # 20 pairs _chat_history = _chat_history[-40:] sources = [] for src in result.get("sources", []): sources.append({ "rank": src["rank"], "document": src["document"], "page": src["page"], "content_type": src.get("content_type", ""), "content": src["content"][:200], }) return {"answer": result["answer"], "sources": sources} def clear_chat_impl(): global _chat_history _chat_history = [] def get_system_status_impl() -> Dict[str, Any]: global _pipeline, _processed_files def _mask_key(key: str) -> str: if not key or key == "not-needed": return "" if len(key) <= 8: return "*" * len(key) return key[:4] + "****" + key[-4:] status = { "embedding": { "model": config.EMBEDDING_MODEL_NAME, "api_base": config.EMBEDDING_API_BASE, "api_key": _mask_key(config.EMBEDDING_API_KEY), }, "llm": { "model": config.LLM_MODEL_NAME, "api_base": config.LLM_API_BASE, "api_key": _mask_key(config.LLM_API_KEY), }, "ocr": { "engine": config.OCR_ENGINE, "model": config.OCR_API_MODEL, "api_base": config.OCR_API_BASE, "api_key": _mask_key(config.OCR_API_KEY), }, "vector_store": config.VECTOR_STORE_TYPE, "params": { "chunk_size": config.CHUNK_SIZE, "chunk_overlap": config.CHUNK_OVERLAP, "retrieval_top_k": config.RETRIEVAL_TOP_K, }, "document_count": 0, "files": _processed_files, } if _pipeline is not None: try: stats = _pipeline.stats status["document_count"] = stats.get("document_count", 0) except Exception: pass return status def preload_ocr_engine(): """启动时预热 OCR 引擎, 避免首次上传等待模型加载""" if config.OCR_ENGINE == "paddle": try: logger.info("预热 PaddleOCR-VL 引擎...") from ocr_loader import _get_ocr_vl_pipeline _get_ocr_vl_pipeline() logger.info("OCR 引擎预热完成 ✓") except Exception as e: logger.warning(f"OCR 引擎预热跳过: {e}") elif config.OCR_ENGINE == "api": logger.info(f"OCR API 模式, 跳过预热 (endpoint: {config.OCR_API_BASE})") # ============================================================ # FastAPI App # ============================================================ app = FastAPI(title="PDF OCR 智能问答系统", version="2.0") # Static files STATIC_DIR = Path(__file__).resolve().parent / "static" STATIC_DIR.mkdir(exist_ok=True) app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") class ChatRequest(BaseModel): question: str class ChatResponse(BaseModel): answer: str sources: List[Dict[str, Any]] # ── Routes ── @app.get("/", response_class=HTMLResponse) async def index(): """Serve the main frontend""" index_path = STATIC_DIR / "index.html" if index_path.exists(): return FileResponse(index_path) return HTMLResponse("

Frontend not found

", status_code=404) @app.post("/api/upload") async def upload_files( files: List[UploadFile] = File(...), chunk_size: int = Form(800), chunk_overlap: int = Form(150), ): """Upload and process multiple documents""" if not files or all(not f.filename for f in files): raise HTTPException(400, "No files provided") upload_dir = config.UPLOAD_DIR upload_dir.mkdir(parents=True, exist_ok=True) results = [] all_errors = [] for file in files: if not file.filename: continue tmp_path = upload_dir / file.filename try: with open(tmp_path, "wb") as f: shutil.copyfileobj(file.file, f) file_info, preview = process_file_impl(tmp_path, chunk_size, chunk_overlap) results.append({ "success": True, "name": file_info["name"], "format": file_info["format"], "pages": file_info["pages"], "chunks": file_info["chunks"], "size_mb": file_info["size_mb"], "time": file_info["time"], "preview": preview, "message": "处理完成", }) except ValueError as e: all_errors.append(f"{file.filename}: {e}") except Exception as e: logger.error(f"处理失败 {file.filename}: {e}") import traceback traceback.print_exc() all_errors.append(f"{file.filename}: {e}") if not results and all_errors: raise HTTPException(500, "; ".join(all_errors)) _save_files_to_disk() return { "success": True, "results": results, "errors": all_errors, "total": len(results), } @app.delete("/api/files/{index}") async def delete_file(index: int): """Remove a processed file from the list by index""" global _processed_files if 0 <= index < len(_processed_files): removed = _processed_files.pop(index) _delete_ocr_text(removed["name"]) _save_files_to_disk() logger.info(f"已移除文件: {removed['name']}") return {"success": True, "removed": removed["name"]} raise HTTPException(404, "File index not found") @app.get("/api/preview/{index}") async def get_preview(index: int): """Get full OCR text for a processed file (reads from disk)""" if 0 <= index < len(_processed_files): filename = _processed_files[index]["name"] text = _load_ocr_text(filename) if text: return {"success": True, "text": text, "index": index, "filename": filename} return {"success": False, "text": "", "message": "OCR text file not found on disk"} raise HTTPException(404, "File index out of range") @app.get("/api/file/{index}") async def get_original_file(index: int): """Serve the original uploaded file for preview""" if 0 <= index < len(_processed_files): filename = _processed_files[index]["name"] # 1) 尝试存储的路径 file_path = _processed_files[index].get("path", "") if file_path and Path(file_path).exists(): return FileResponse(file_path) # 2) 回退: 在 upload 目录中按文件名查找 fallback = config.UPLOAD_DIR / filename if fallback.exists(): return FileResponse(str(fallback)) raise HTTPException(404, f"Original file not found: {filename}") raise HTTPException(404, f"File index {index} out of range (total: {len(_processed_files)})") @app.post("/api/chat", response_model=ChatResponse) async def chat(req: ChatRequest): """Ask a question about the processed document""" try: result = ask_question_impl(req.question) return ChatResponse(**result) except RuntimeError as e: return {"answer": str(e), "sources": []} except Exception as e: logger.error(f"问答失败: {e}") import traceback traceback.print_exc() return {"answer": f"问答失败: {str(e)}", "sources": []} @app.delete("/api/chat") async def clear_chat(): """Clear chat history""" clear_chat_impl() return {"success": True} @app.get("/api/status") async def get_status(): """Get system status""" return get_system_status_impl() # ── Config API ── CONFIG_KEYS = { "EMBEDDING_API_BASE", "EMBEDDING_MODEL_NAME", "EMBEDDING_API_KEY", "LLM_API_BASE", "LLM_MODEL_NAME", "LLM_API_KEY", "OCR_API_BASE", "OCR_API_MODEL", "OCR_API_KEY", "OCR_ENGINE", "CHUNK_SIZE", "CHUNK_OVERLAP", "RETRIEVAL_TOP_K", } def _update_env_file(updates: Dict[str, str]): """将配置变更写入 .env 文件""" env_path = config.BASE_DIR / ".env" if env_path.exists(): lines = env_path.read_text(encoding="utf-8").splitlines() else: lines = [] updated_keys = set() new_lines = [] for line in lines: stripped = line.strip() if stripped and not stripped.startswith("#") and "=" in stripped: key = stripped.split("=", 1)[0].strip() if key in updates: new_lines.append(f"{key}={updates[key]}") updated_keys.add(key) continue new_lines.append(line) for k, v in updates.items(): if k not in updated_keys: new_lines.append(f"{k}={v}") env_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8") @app.get("/api/config") async def get_config(): """获取当前 API 配置""" return { "embedding": { "api_base": config.EMBEDDING_API_BASE, "model_name": config.EMBEDDING_MODEL_NAME, "api_key": config.EMBEDDING_API_KEY, }, "llm": { "api_base": config.LLM_API_BASE, "model_name": config.LLM_MODEL_NAME, "api_key": config.LLM_API_KEY, }, "ocr": { "engine": config.OCR_ENGINE, "api_base": config.OCR_API_BASE, "model_name": config.OCR_API_MODEL, "api_key": config.OCR_API_KEY, }, "retrieval": { "chunk_size": config.CHUNK_SIZE, "chunk_overlap": config.CHUNK_OVERLAP, "top_k": config.RETRIEVAL_TOP_K, }, } @app.post("/api/config") async def update_config(updates: Dict[str, str]): """更新 API 配置 (写入 .env 并即时生效)""" import os as _os applied = {} for key in updates: if key in CONFIG_KEYS: applied[key] = str(updates[key]) _os.environ[key] = str(updates[key]) if applied: _update_env_file(applied) # 重新加载 config 模块以生效 import importlib importlib.reload(config) # 重置全局单例使新配置生效 from embeddings import reset_embedding_model reset_embedding_model() logger.info(f"配置已更新: {list(applied.keys())}") return {"success": True, "updated": list(applied.keys())} # ============================================================ # Main # ============================================================ def main(): import uvicorn logger.remove() logger.add( config.LOG_DIR / "app_{time:YYYY-MM-DD}.log", level=config.LOG_LEVEL, format=config.LOG_FORMAT, rotation="100 MB", retention="30 days", encoding="utf-8", ) logger.add( lambda msg: print(msg, end=""), level="INFO", format="{time:HH:mm:ss} | {level: <8} | {message}", colorize=True, ) logger.info("=" * 50) logger.info(" PDF OCR 智能问答系统 启动中...") logger.info("=" * 50) logger.info(f" OCR: PaddleOCR-VL-1.5 ({config.OCR_VL_BACKEND})") logger.info(f" 嵌入: {config.EMBEDDING_MODEL_NAME} (API: {config.EMBEDDING_API_BASE})") logger.info(f" LLM: {config.LLM_MODEL_NAME} (API: {config.LLM_API_BASE})") logger.info(f" OCR: {config.OCR_ENGINE} ({config.OCR_API_BASE if config.OCR_ENGINE == 'api' else 'local'})") logger.info(f" 向量数据库: {config.VECTOR_STORE_TYPE}") logger.info(f" 支持格式: {sorted(config.SUPPORTED_FORMATS)}") # 从磁盘恢复已处理文件列表 _load_files_from_disk() # 预热 OCR 引擎 preload_ocr_engine() uvicorn.run( app, host="0.0.0.0", port=7860, reload=False, log_level="info", ) if __name__ == "__main__": main()