From e9681c8a325be2635e6f95f7b1e7324e44a017ad Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Mon, 29 Dec 2025 10:32:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(ocr):=20=E6=B7=BB=E5=8A=A0=20DeepSeek=20OC?= =?UTF-8?q?R=20=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 DeepSeek OCR 解析器,基于 SiliconFlow API 实现文档处理和 OCR 功能 更新文档处理工厂和前端组件以支持新 OCR 选项 优化前端 OCR 选项标签生成逻辑为通用函数 --- docs/latest/advanced/document-processing.md | 29 +++- server/routers/mindmap_router.py | 3 - server/routers/system_router.py | 15 +- src/plugins/deepseek_ocr_parser.py | 181 ++++++++++++++++++++ src/plugins/document_processor_factory.py | 3 + src/storage/conversation/manager.py | 2 +- web/src/components/FileUploadModal.vue | 77 ++++----- 7 files changed, 248 insertions(+), 62 deletions(-) create mode 100644 src/plugins/deepseek_ocr_parser.py diff --git a/docs/latest/advanced/document-processing.md b/docs/latest/advanced/document-processing.md index 3fb3e7df..671b8877 100644 --- a/docs/latest/advanced/document-processing.md +++ b/docs/latest/advanced/document-processing.md @@ -1,11 +1,12 @@ # 文档处理与 OCR -系统提供 4 种文档处理选项: +系统提供 5 种文档处理选项: - **RapidOCR**: CPU 友好,无需 GPU,适合基础文字识别 - **MinerU**: 本地化高精度 VLM 解析,适合复杂 PDF 和表格文档 - **MinerU Official**: 官方云服务 API,无需本地部署,开箱即用 - **PaddleX**: 结构化解析,适合表格、票据等特殊格式 +- **DeepSeek OCR**: 基于 SiliconFlow API 的 DeepSeek OCR OCR 服务 ## 支持的文件类型 @@ -92,6 +93,27 @@ docker compose up -d paddlex docker compose up -d api ``` +### 5. 智能云端 OCR (DeepSeek OCR) + +DeepSeek OCR 基于 SiliconFlow API,提供智能文档理解和 Markdown 格式输出。 + +API 密钥可以从 [SiliconFlow](https://cloud.siliconflow.cn/i/Eo5yTHGJ) 申请。 + +然后在 `.env` 文件中添加: + +```bash +# 设置 SiliconFlow API 密钥 +SILICONFLOW_API_KEY="your-api-key-here" +``` + +重启后端服务即可使用: + +```bash +docker compose restart api +``` + +注:当前还不支持保存其中的图片信息,mineru 当前版本已支持。 + ## 处理器选择 | 处理器 | 适用场景 | 硬件要求 | 特点 | @@ -100,6 +122,7 @@ docker compose up -d api | **MinerU** | 复杂 PDF、表格、公式 | GPU | 精度高,版面分析好 | | **MinerU Official** | 复杂文档解析(云服务) | 无特殊要求 | 官方云服务,开箱即用,有 API 配额 | | **PaddleX** | 表格、票据、结构化文档 | GPU | 专业版面解析 | +| **DeepSeek OCR** | 智能文档理解和 Markdown 输出 | 无特殊要求 | 云端服务 | ## 参数说明 @@ -112,9 +135,11 @@ docker compose up -d api - `mineru_ocr`: MinerU HTTP API 处理 - `mineru_official`: MinerU 官方云服务 API 处理 - `paddlex_ocr`: PaddleX 处理 +- `deepseek_ocr`: DeepSeek OCR(SiliconFlow API)处理 ### 注意事项 - **图片文件必须启用 OCR**,否则无法提取内容 - MinerU 和 PaddleX 需要 GPU 支持 - MinerU Official 需要设置 `MINERU_API_KEY` 环境变量 -- RapidOCR 适合 CPU 环境和基础识别需求 +- DeepSeek OCR 需要设置 `SILICONFLOW_API_KEY` 环境变量 +- RapidOCR 适合 CPU 环境和基础识别需求 \ No newline at end of file diff --git a/server/routers/mindmap_router.py b/server/routers/mindmap_router.py index 91e0d3e6..81fa27a1 100644 --- a/server/routers/mindmap_router.py +++ b/server/routers/mindmap_router.py @@ -360,6 +360,3 @@ async def get_database_mindmap(db_id: str, current_user: User = Depends(get_admi except Exception as e: logger.error(f"获取知识库思维导图失败: {e}, {traceback.format_exc()}") raise HTTPException(status_code=500, detail=f"获取思维导图失败: {str(e)}") - - - diff --git a/server/routers/system_router.py b/server/routers/system_router.py index deb6984e..7be72c3d 100644 --- a/server/routers/system_router.py +++ b/server/routers/system_router.py @@ -52,10 +52,7 @@ async def update_config_batch(items: dict = Body(...), current_user: User = Depe @system.get("/logs") -async def get_system_logs( - levels: str | None = None, - current_user: User = Depends(get_admin_user) -): +async def get_system_logs(levels: str | None = None, current_user: User = Depends(get_admin_user)): """获取系统日志 Args: @@ -67,25 +64,25 @@ async def get_system_logs( # 解析日志级别过滤条件 level_filter = None if levels: - level_filter = set(level.strip().upper() for level in levels.split(',') if level.strip()) + level_filter = set(level.strip().upper() for level in levels.split(",") if level.strip()) async with aiofiles.open(LOG_FILE) as f: # 读取最后1000行 lines = [] async for line in f: - filtered_line = line.rstrip('\n\r') + filtered_line = line.rstrip("\n\r") # 如果指定了日志级别过滤,则按级别过滤 if level_filter: # 日志格式: 2025-03-10 08:26:37,269 - INFO - module - message # 提取日志级别 - parts = filtered_line.split(' - ') + parts = filtered_line.split(" - ") if len(parts) >= 2 and parts[1].strip() in level_filter: - lines.append(filtered_line + '\n') + lines.append(filtered_line + "\n") # 继续读取以保持行数统计准确 if len(lines) > 1000: lines.pop(0) else: - lines.append(filtered_line + '\n') + lines.append(filtered_line + "\n") if len(lines) > 1000: lines.pop(0) diff --git a/src/plugins/deepseek_ocr_parser.py b/src/plugins/deepseek_ocr_parser.py new file mode 100644 index 00000000..b772a1db --- /dev/null +++ b/src/plugins/deepseek_ocr_parser.py @@ -0,0 +1,181 @@ +""" +DeepSeek OCR Parser + +Uses DeepSeek-OCR via SiliconFlow API for document parsing and OCR. +""" + +import base64 +import os +import re +import time +from pathlib import Path +from typing import Any + +import fitz # PyMuPDF +import requests + +from src.plugins.document_processor_base import BaseDocumentProcessor, DocumentParserException +from src.utils import logger + + +class DeepSeekOCRParser(BaseDocumentProcessor): + """DeepSeek OCR Parser using SiliconFlow API""" + + # MIME type mapping for supported formats + MIME_TYPE_MAP = { + ".pdf": "application/pdf", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".bmp": "image/bmp", + ".webp": "image/webp", + } + + def __init__(self, api_key: str | None = None): + self.api_key = api_key or os.getenv("SILICONFLOW_API_KEY") + if not self.api_key: + raise DocumentParserException( + "SILICONFLOW_API_KEY environment variable not set", "deepseek_ocr", "missing_api_key" + ) + + self.api_url = "https://api.siliconflow.cn/v1/chat/completions" + self.model = "deepseek-ai/DeepSeek-OCR" + + self.headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + } + + def get_service_name(self) -> str: + return "deepseek_ocr" + + def get_supported_extensions(self) -> list[str]: + """DeepSeek OCR supports PDF and images""" + return list(self.MIME_TYPE_MAP.keys()) + + def check_health(self) -> dict[str, Any]: + """Check API availability and key validity""" + try: + # We can't easily "ping" without cost, but we can check if the model list is accessible + models_url = "https://api.siliconflow.cn/v1/models" + response = requests.get(models_url, headers=self.headers, timeout=10) + + if response.status_code == 200: + return { + "status": "healthy", + "message": "DeepSeek OCR (SiliconFlow) is available", + "details": {"api_url": self.api_url}, + } + elif response.status_code == 401: + return {"status": "unhealthy", "message": "Invalid API Key", "details": {"error_code": "401"}} + else: + return { + "status": "unhealthy", + "message": f"API Error: {response.status_code}", + "details": {"status_code": response.status_code}, + } + except Exception as e: + return {"status": "unavailable", "message": f"Connection failed: {str(e)}", "details": {"error": str(e)}} + + def process_file(self, file_path: str, params: dict[str, Any] | None = None) -> str: + """ + Process file using DeepSeek OCR via SiliconFlow + """ + if not os.path.exists(file_path): + raise DocumentParserException(f"File not found: {file_path}", self.get_service_name(), "file_not_found") + + file_ext = Path(file_path).suffix.lower() + if not self.supports_file_type(file_ext): + raise DocumentParserException( + f"Unsupported file type: {file_ext}", self.get_service_name(), "unsupported_file_type" + ) + + try: + start_time = time.time() + logger.info(f"DeepSeek OCR starting: {os.path.basename(file_path)}") + + if file_ext == ".pdf": + content = self._process_pdf(file_path) + else: + content = self._process_image(file_path) + + processing_time = time.time() - start_time + logger.info( + f"DeepSeek OCR finished: {os.path.basename(file_path)} - {len(content)} chars ({processing_time:.2f}s)" + ) + + return content + + except Exception as e: + if isinstance(e, DocumentParserException): + raise + error_msg = f"DeepSeek OCR failed: {str(e)}" + logger.error(error_msg) + raise DocumentParserException(error_msg, self.get_service_name(), "processing_failed") + + def _process_pdf(self, file_path: str) -> str: + """Process PDF by converting pages to images""" + doc = fitz.open(file_path) + try: + full_text = [] + + total_pages = len(doc) + logger.info(f"Processing PDF with {total_pages} pages") + + for i, page in enumerate(doc): + logger.debug(f"Processing page {i + 1}/{total_pages}") + # Convert page to image (200 DPI for better quality) + pix = page.get_pixmap(dpi=200) + img_bytes = pix.tobytes("png") + + page_text = self._call_api(img_bytes, "image/png") + full_text.append(page_text) + + return "\n\n".join(full_text) + finally: + doc.close() + + def _process_image(self, file_path: str) -> str: + """Process single image file""" + mime_type = self._get_mime_type(file_path) + with open(file_path, "rb") as f: + file_content = f.read() + return self._call_api(file_content, mime_type) + + def _call_api(self, data_bytes: bytes, mime_type: str) -> str: + """Call SiliconFlow API""" + encoded_string = base64.b64encode(data_bytes).decode("utf-8") + data_url = f"data:{mime_type};base64,{encoded_string}" + + messages = [ + { + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": data_url}}, + {"type": "text", "text": "\n<|grounding|>Convert the document to markdown. "}, + ], + } + ] + + payload = {"model": self.model, "messages": messages, "max_tokens": 4096, "temperature": 0.1} + + response = requests.post(self.api_url, headers=self.headers, json=payload, timeout=120) + + if response.status_code != 200: + error_msg = f"API Error {response.status_code}: {response.text}" + logger.error(error_msg) + raise DocumentParserException(error_msg, self.get_service_name(), f"http_{response.status_code}") + + result = response.json() + content = result["choices"][0]["message"]["content"] + + # Clean up special tags like <|ref|>...<|/ref|> and <|det|>...<|/det|> + content = re.sub(r"<\|ref\|>.*?<\|/ref\|>", "", content) + content = re.sub(r"<\|det\|>.*?<\|/det\|>", "", content) + # content = re.sub(r"<\|.*?\|>", "", content) + + return content.strip() + + def _get_mime_type(self, file_path: str) -> str: + file_ext = Path(file_path).suffix.lower() + return self.MIME_TYPE_MAP.get(file_ext, "image/jpeg") # Default fallback diff --git a/src/plugins/document_processor_factory.py b/src/plugins/document_processor_factory.py index fa6148cc..394678b9 100644 --- a/src/plugins/document_processor_factory.py +++ b/src/plugins/document_processor_factory.py @@ -6,6 +6,7 @@ from typing import Any +from src.plugins.deepseek_ocr_parser import DeepSeekOCRParser from src.plugins.document_processor_base import BaseDocumentProcessor from src.plugins.mineru_official_parser import MinerUOfficialParser from src.plugins.mineru_parser import MinerUParser @@ -26,6 +27,7 @@ class DocumentProcessorFactory: "mineru_ocr": MinerUParser, "mineru_official": MinerUOfficialParser, "paddlex_ocr": PaddleXDocumentParser, + "deepseek_ocr": DeepSeekOCRParser, } @classmethod @@ -39,6 +41,7 @@ class DocumentProcessorFactory: - "mineru_ocr": MinerU HTTP API 文档解析 - "mineru_official": MinerU 官方云服务 API 文档解析 - "paddlex_ocr": PaddleX 版面解析 + - "deepseek_ocr": DeepSeek-OCR SiliconFlow API **kwargs: 处理器初始化参数 Returns: diff --git a/src/storage/conversation/manager.py b/src/storage/conversation/manager.py index d50c96ca..a3e0e30f 100644 --- a/src/storage/conversation/manager.py +++ b/src/storage/conversation/manager.py @@ -247,7 +247,7 @@ class ConversationManager: select(Message) .options( selectinload(Message.tool_calls), # Preload tool calls - selectinload(Message.feedbacks), # Preload feedbacks for UI state + selectinload(Message.feedbacks), # Preload feedbacks for UI state ) .filter(Message.conversation_id == conversation_id) .order_by(Message.created_at.asc()) diff --git a/web/src/components/FileUploadModal.vue b/web/src/components/FileUploadModal.vue index 71ca1511..b915b711 100644 --- a/web/src/components/FileUploadModal.vue +++ b/web/src/components/FileUploadModal.vue @@ -302,7 +302,8 @@ const ocrHealthStatus = ref({ onnx_rapid_ocr: { status: 'unknown', message: '' }, mineru_ocr: { status: 'unknown', message: '' }, mineru_official: { status: 'unknown', message: '' }, - paddlex_ocr: { status: 'unknown', message: '' } + paddlex_ocr: { status: 'unknown', message: '' }, + deepseek_ocr: { status: 'unknown', message: '' } }); // OCR健康检查状态 @@ -423,6 +424,12 @@ const enableOcrOptions = computed(() => [ title: 'PaddleX OCR', disabled: ocrHealthStatus.value?.paddlex_ocr?.status === 'unavailable' || ocrHealthStatus.value?.paddlex_ocr?.status === 'error' }, + { + value: 'deepseek_ocr', + label: getDeepSeekOcrLabel(), + title: 'DeepSeek OCR (SiliconFlow)', + disabled: ocrHealthStatus.value?.deepseek_ocr?.status === 'unavailable' || ocrHealthStatus.value?.deepseek_ocr?.status === 'error' + }, ]); // 获取当前选中OCR服务的状态 @@ -436,6 +443,8 @@ const selectedOcrStatus = computed(() => { return ocrHealthStatus.value?.mineru_official?.status || 'unknown'; case 'paddlex_ocr': return ocrHealthStatus.value?.paddlex_ocr?.status || 'unknown'; + case 'deepseek_ocr': + return ocrHealthStatus.value?.deepseek_ocr?.status || 'unknown'; default: return null; } @@ -452,61 +461,35 @@ const selectedOcrMessage = computed(() => { return ocrHealthStatus.value?.mineru_official?.message || ''; case 'paddlex_ocr': return ocrHealthStatus.value?.paddlex_ocr?.message || ''; + case 'deepseek_ocr': + return ocrHealthStatus.value?.deepseek_ocr?.message || ''; default: return ''; } }); -// OCR选项标签生成函数 -const getRapidOcrLabel = () => { - const status = ocrHealthStatus.value?.onnx_rapid_ocr?.status || 'unknown'; - const statusIcons = { - 'healthy': '✅', - 'unavailable': '❌', - 'error': '⚠️', - 'unknown': '❓' - }; - return `${statusIcons[status] || '❓'} RapidOCR (ONNX)`; +// OCR服务状态图标映射 +const STATUS_ICONS = { + 'healthy': '✅', + 'unavailable': '❌', + 'unhealthy': '⚠️', + 'timeout': '⏰', + 'error': '⚠️', + 'unknown': '❓' }; -const getMinerULabel = () => { - const status = ocrHealthStatus.value?.mineru_ocr?.status || 'unknown'; - const statusIcons = { - 'healthy': '✅', - 'unavailable': '❌', - 'unhealthy': '⚠️', - 'timeout': '⏰', - 'error': '⚠️', - 'unknown': '❓' - }; - return `${statusIcons[status] || '❓'} MinerU OCR`; +// OCR选项标签生成通用函数 +const getOcrLabel = (serviceKey, displayName) => { + const status = ocrHealthStatus.value?.[serviceKey]?.status || 'unknown'; + return `${STATUS_ICONS[status] || '❓'} ${displayName}`; }; -const getMinerUOfficialLabel = () => { - const status = ocrHealthStatus.value?.mineru_official?.status || 'unknown'; - const statusIcons = { - 'healthy': '✅', - 'unavailable': '❌', - 'unhealthy': '⚠️', - 'timeout': '⏰', - 'error': '⚠️', - 'unknown': '❓' - }; - return `${statusIcons[status] || '❓'} MinerU Official API`; -}; - -const getPaddleXLabel = () => { - const status = ocrHealthStatus.value?.paddlex_ocr?.status || 'unknown'; - const statusIcons = { - 'healthy': '✅', - 'unavailable': '❌', - 'unhealthy': '⚠️', - 'timeout': '⏰', - 'error': '⚠️', - 'unknown': '❓' - }; - return `${statusIcons[status] || '❓'} PaddleX OCR`; -}; +// 兼容性包装器 +const getRapidOcrLabel = () => getOcrLabel('onnx_rapid_ocr', 'RapidOCR (ONNX)'); +const getMinerULabel = () => getOcrLabel('mineru_ocr', 'MinerU OCR'); +const getMinerUOfficialLabel = () => getOcrLabel('mineru_official', 'MinerU Official API'); +const getPaddleXLabel = () => getOcrLabel('paddlex_ocr', 'PaddleX OCR'); +const getDeepSeekOcrLabel = () => getOcrLabel('deepseek_ocr', 'DeepSeek OCR'); // 验证OCR服务可用性 const validateOcrService = () => {