feat(ocr): 添加 DeepSeek OCR 支持
新增 DeepSeek OCR 解析器,基于 SiliconFlow API 实现文档处理和 OCR 功能 更新文档处理工厂和前端组件以支持新 OCR 选项 优化前端 OCR 选项标签生成逻辑为通用函数
This commit is contained in:
parent
b0fa9d249b
commit
e9681c8a32
@ -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 环境和基础识别需求
|
||||
@ -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)}")
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
181
src/plugins/deepseek_ocr_parser.py
Normal file
181
src/plugins/deepseek_ocr_parser.py
Normal file
@ -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": "<image>\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
|
||||
@ -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:
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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 = () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user