refactor: 重构解析模块;结构更加清晰,代码解耦
This commit is contained in:
parent
c7b2bd8d71
commit
fe2153ad76
@ -3,7 +3,6 @@ import base64
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
@ -21,7 +20,7 @@ from langchain_community.document_loaders import (
|
||||
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||
from markdownify import markdownify as md_convert
|
||||
|
||||
from yuxi.knowledge.utils import calculate_content_hash
|
||||
from yuxi.plugins.parser.zip_utils import process_zip_file as _process_zip_file
|
||||
from yuxi.storage.minio import get_minio_client
|
||||
from yuxi.utils import hashstr, logger
|
||||
|
||||
@ -166,6 +165,41 @@ def _convert_with_docling(file_path: Path, params: dict | None = None) -> str:
|
||||
return doc.export_to_markdown()
|
||||
|
||||
|
||||
def _convert_docx_with_python_docx(file_path: Path) -> str:
|
||||
"""使用 python-docx 解析 DOCX(Docling 失败时兜底)"""
|
||||
from docx import Document
|
||||
|
||||
document = Document(str(file_path))
|
||||
blocks: list[str] = []
|
||||
|
||||
for para in document.paragraphs:
|
||||
text = para.text.strip()
|
||||
if text:
|
||||
blocks.append(text)
|
||||
|
||||
for table in document.tables:
|
||||
rows: list[list[str]] = []
|
||||
for row in table.rows:
|
||||
cells = [cell.text.strip().replace("\n", " ") for cell in row.cells]
|
||||
if any(cells):
|
||||
rows.append(cells)
|
||||
|
||||
if not rows:
|
||||
continue
|
||||
|
||||
header = rows[0]
|
||||
blocks.append(f"| {' | '.join(header)} |")
|
||||
blocks.append(f"| {' | '.join(['---'] * len(header))} |")
|
||||
|
||||
for row in rows[1:]:
|
||||
normalized_row = row + [""] * (len(header) - len(row))
|
||||
blocks.append(f"| {' | '.join(normalized_row[: len(header)])} |")
|
||||
|
||||
blocks.append("")
|
||||
|
||||
return "\n\n".join(blocks).strip()
|
||||
|
||||
|
||||
def chunk_with_parser(file_path, params=None):
|
||||
"""
|
||||
使用文件解析器将文件切分成固定大小的块
|
||||
@ -291,8 +325,8 @@ def parse_pdf(file, params=None):
|
||||
Raises:
|
||||
DocumentProcessorException: 处理失败时抛出
|
||||
"""
|
||||
from yuxi.plugins.document_processor_base import DocumentProcessorException
|
||||
from yuxi.plugins.document_processor_factory import DocumentProcessorFactory
|
||||
from yuxi.plugins.parser.base import DocumentProcessorException
|
||||
from yuxi.plugins.parser.factory import DocumentProcessorFactory
|
||||
|
||||
params = params or {}
|
||||
opt_ocr = params.get("enable_ocr", "disable")
|
||||
@ -326,8 +360,8 @@ def parse_image(file, params=None):
|
||||
DocumentProcessorException: 处理失败时抛出
|
||||
ValueError: 图像文件禁用OCR时抛出
|
||||
"""
|
||||
from yuxi.plugins.document_processor_base import DocumentProcessorException
|
||||
from yuxi.plugins.document_processor_factory import DocumentProcessorFactory
|
||||
from yuxi.plugins.parser.base import DocumentProcessorException
|
||||
from yuxi.plugins.parser.factory import DocumentProcessorFactory
|
||||
|
||||
params = params or {}
|
||||
opt_ocr = params.get("enable_ocr", "disable")
|
||||
@ -336,7 +370,7 @@ def parse_image(file, params=None):
|
||||
if opt_ocr == "disable":
|
||||
raise ValueError(
|
||||
"图像文件必须启用OCR才能提取文本内容。"
|
||||
"请选择OCR方式 (onnx_rapid_ocr/mineru_ocr/mineru_official/paddlex_ocr) 或移除该文件。"
|
||||
"请选择OCR方式 (rapid_ocr/mineru_ocr/mineru_official/pp_structure_v3_ocr) 或移除该文件。"
|
||||
)
|
||||
|
||||
try:
|
||||
@ -443,8 +477,16 @@ async def process_file_to_markdown(file_path: str, params: dict | None = None) -
|
||||
content = f.read()
|
||||
result = f"{content}"
|
||||
|
||||
elif file_ext in [".docx", ".pptx"]:
|
||||
# 使用 Docling 处理 docx 和 pptx
|
||||
elif file_ext == ".docx":
|
||||
# 优先使用 Docling,失败时回退到 python-docx
|
||||
try:
|
||||
result = _convert_with_docling(file_path_obj, params=params)
|
||||
except Exception as e:
|
||||
logger.warning(f"Docling 解析 DOCX 失败,回退到 python-docx: {file_path_obj.name}, {e}")
|
||||
result = _convert_docx_with_python_docx(file_path_obj)
|
||||
|
||||
elif file_ext == ".pptx":
|
||||
# 使用 Docling 处理 pptx
|
||||
result = _convert_with_docling(file_path_obj, params=params)
|
||||
|
||||
elif file_ext == ".doc":
|
||||
@ -538,180 +580,6 @@ async def process_file_to_markdown(file_path: str, params: dict | None = None) -
|
||||
return result
|
||||
|
||||
|
||||
async def _process_zip_file(zip_path: str, db_id: str) -> dict:
|
||||
"""
|
||||
处理ZIP文件,提取markdown内容和图片(内部函数)
|
||||
|
||||
Args:
|
||||
zip_path: ZIP文件路径
|
||||
db_id: 数据库ID
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"markdown_content": str, # markdown内容
|
||||
"content_hash": str, # 内容哈希值
|
||||
"images_info": list[dict] # 图片信息列表
|
||||
}
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: ZIP文件不存在
|
||||
ValueError: ZIP文件格式错误或内容不符合要求
|
||||
"""
|
||||
# 1. 安全检查
|
||||
if not os.path.exists(zip_path):
|
||||
raise FileNotFoundError(f"ZIP 文件不存在: {zip_path}")
|
||||
|
||||
# 2. 解压ZIP并提取内容
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
# 安全检查:防止路径遍历攻击
|
||||
for name in zf.namelist():
|
||||
if name.startswith("/") or name.startswith("\\"):
|
||||
raise ValueError(f"ZIP 包含不安全路径: {name}")
|
||||
if ".." in Path(name).parts:
|
||||
raise ValueError(f"ZIP 路径包含上级引用: {name}")
|
||||
|
||||
# 查找markdown文件
|
||||
md_files = [n for n in zf.namelist() if n.lower().endswith(".md")]
|
||||
if not md_files:
|
||||
raise ValueError("压缩包中未找到 .md 文件")
|
||||
|
||||
# 优先使用 full.md,否则使用第一个md文件
|
||||
md_file = next((n for n in md_files if Path(n).name == "full.md"), md_files[0])
|
||||
|
||||
# 读取markdown内容
|
||||
with zf.open(md_file) as f:
|
||||
markdown_content = f.read().decode("utf-8")
|
||||
|
||||
# 3. 处理图片
|
||||
images_info = []
|
||||
images_dir = _find_images_directory(zf, md_file)
|
||||
|
||||
if images_dir:
|
||||
images_info = await _process_images(zf, images_dir, db_id, md_file)
|
||||
markdown_content = _replace_image_links(markdown_content, images_info)
|
||||
|
||||
# 4. 生成结果
|
||||
content_hash = await calculate_content_hash(markdown_content.encode("utf-8"))
|
||||
|
||||
return {
|
||||
"markdown_content": markdown_content,
|
||||
"content_hash": content_hash,
|
||||
"images_info": images_info,
|
||||
}
|
||||
|
||||
|
||||
def _find_images_directory(zip_file: zipfile.ZipFile, md_file_path: str) -> str | None:
|
||||
"""查找images目录"""
|
||||
md_parent = Path(md_file_path).parent
|
||||
|
||||
# 候选目录
|
||||
candidates = []
|
||||
if str(md_parent) != ".":
|
||||
candidates.extend([str(md_parent / "images"), str(md_parent.parent / "images")])
|
||||
candidates.append("images")
|
||||
|
||||
# 查找存在的目录
|
||||
for cand in candidates:
|
||||
cand_clean = cand.rstrip("/")
|
||||
if any(n.startswith(cand_clean + "/") for n in zip_file.namelist()):
|
||||
return cand_clean
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _process_images(zip_file: zipfile.ZipFile, images_dir: str, db_id: str, md_file_path: str) -> list[dict]:
|
||||
"""处理图片:上传到MinIO并返回信息"""
|
||||
# 支持的图片格式
|
||||
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
|
||||
CONTENT_TYPE_MAP = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
}
|
||||
|
||||
images = []
|
||||
image_names = [n for n in zip_file.namelist() if n.startswith(images_dir + "/")]
|
||||
|
||||
# 上传图片到MinIO
|
||||
minio_client = get_minio_client()
|
||||
bucket_name = "kb-images"
|
||||
await asyncio.to_thread(minio_client.ensure_bucket_exists, bucket_name)
|
||||
|
||||
file_id = hashstr(Path(md_file_path).name, length=16)
|
||||
|
||||
for img_name in image_names:
|
||||
suffix = Path(img_name).suffix.lower()
|
||||
if suffix not in SUPPORTED_EXTENSIONS:
|
||||
continue
|
||||
|
||||
try:
|
||||
# 读取图片数据
|
||||
with zip_file.open(img_name) as f:
|
||||
data = f.read()
|
||||
|
||||
# 上传到MinIO
|
||||
timestamp = int(time.time() * 1000000)
|
||||
object_name = f"{db_id}/{file_id}/images/{timestamp}_{Path(img_name).name}"
|
||||
content_type = CONTENT_TYPE_MAP.get(suffix, "image/jpeg")
|
||||
|
||||
result = await minio_client.aupload_file(
|
||||
bucket_name=bucket_name,
|
||||
object_name=object_name,
|
||||
data=data,
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
# 记录图片信息
|
||||
img_info = {"name": Path(img_name).name, "url": result.url, "path": f"images/{Path(img_name).name}"}
|
||||
images.append(img_info)
|
||||
|
||||
logger.debug(f"图片上传成功: {Path(img_name).name} -> {result.url}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"上传图片失败 {Path(img_name).name}: {e}")
|
||||
continue
|
||||
|
||||
return images
|
||||
|
||||
|
||||
def _replace_image_links(markdown_content: str, images: list[dict]) -> str:
|
||||
"""替换markdown中的图片链接为MinIO URL"""
|
||||
if not images:
|
||||
return markdown_content
|
||||
|
||||
# 构建路径映射
|
||||
image_map = {}
|
||||
for img in images:
|
||||
path = img["path"]
|
||||
url = img["url"]
|
||||
image_map[path] = url
|
||||
image_map[f"/{path}"] = url
|
||||
image_map[img["name"]] = url
|
||||
|
||||
def replace_link(match):
|
||||
alt_text = match.group(1) or ""
|
||||
img_path = match.group(2)
|
||||
|
||||
# 尝试匹配各种路径格式
|
||||
for pattern, url in image_map.items():
|
||||
if img_path.endswith(pattern) or img_path == pattern:
|
||||
return f""
|
||||
|
||||
# 尝试文件名匹配
|
||||
filename = os.path.basename(img_path)
|
||||
if filename in image_map:
|
||||
return f""
|
||||
|
||||
return match.group(0)
|
||||
|
||||
# 使用正则表达式替换图片链接
|
||||
pattern = r"!\[([^\]]*)\]\(([^)]+)\)"
|
||||
return re.sub(pattern, replace_link, markdown_content)
|
||||
|
||||
|
||||
async def process_url_to_markdown(url: str, params: dict | None = None) -> str:
|
||||
"""
|
||||
Fetch a URL and convert its content to Markdown.
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
# 新的统一文档处理器接口
|
||||
from yuxi.plugins.document_processor_factory import DocumentProcessorFactory
|
||||
from yuxi.plugins.mineru_official_parser import MinerUOfficialParser
|
||||
from yuxi.plugins.mineru_parser import MinerUParser
|
||||
from yuxi.plugins.paddlex_parser import PaddleXDocumentParser
|
||||
from yuxi.plugins.rapid_ocr_processor import RapidOCRProcessor
|
||||
from yuxi.plugins.parser.base import (
|
||||
BaseDocumentProcessor,
|
||||
DocumentParserException,
|
||||
DocumentProcessorException,
|
||||
OCRException,
|
||||
)
|
||||
from yuxi.plugins.parser.factory import DocumentProcessorFactory
|
||||
|
||||
__all__ = [
|
||||
"BaseDocumentProcessor",
|
||||
"DocumentProcessorException",
|
||||
"DocumentParserException",
|
||||
"OCRException",
|
||||
"DocumentProcessorFactory", # 推荐使用
|
||||
"RapidOCRProcessor",
|
||||
"MinerUParser",
|
||||
"MinerUOfficialParser",
|
||||
"PaddleXDocumentParser",
|
||||
]
|
||||
|
||||
15
backend/package/yuxi/plugins/parser/__init__.py
Normal file
15
backend/package/yuxi/plugins/parser/__init__.py
Normal file
@ -0,0 +1,15 @@
|
||||
from yuxi.plugins.parser.base import (
|
||||
BaseDocumentProcessor,
|
||||
DocumentParserException,
|
||||
DocumentProcessorException,
|
||||
OCRException,
|
||||
)
|
||||
from yuxi.plugins.parser.factory import DocumentProcessorFactory
|
||||
|
||||
__all__ = [
|
||||
"BaseDocumentProcessor",
|
||||
"DocumentProcessorException",
|
||||
"DocumentParserException",
|
||||
"OCRException",
|
||||
"DocumentProcessorFactory",
|
||||
]
|
||||
@ -14,7 +14,7 @@ from typing import Any
|
||||
import fitz # PyMuPDF
|
||||
import requests
|
||||
|
||||
from yuxi.plugins.document_processor_base import BaseDocumentProcessor, DocumentParserException
|
||||
from yuxi.plugins.parser.base import BaseDocumentProcessor, DocumentParserException
|
||||
from yuxi.utils import logger
|
||||
|
||||
|
||||
@ -4,14 +4,11 @@
|
||||
提供统一的文档处理器创建和管理接口
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from yuxi.plugins.deepseek_ocr_parser import DeepSeekOCRParser
|
||||
from yuxi.plugins.document_processor_base import BaseDocumentProcessor
|
||||
from yuxi.plugins.mineru_official_parser import MinerUOfficialParser
|
||||
from yuxi.plugins.mineru_parser import MinerUParser
|
||||
from yuxi.plugins.paddlex_parser import PaddleXDocumentParser
|
||||
from yuxi.plugins.rapid_ocr_processor import RapidOCRProcessor
|
||||
from yuxi.plugins.parser.base import BaseDocumentProcessor
|
||||
from yuxi.utils import logger
|
||||
|
||||
# 处理器实例缓存
|
||||
@ -21,15 +18,30 @@ _PROCESSOR_CACHE: dict[str, BaseDocumentProcessor] = {}
|
||||
class DocumentProcessorFactory:
|
||||
"""文档处理器工厂"""
|
||||
|
||||
# 处理器类型映射
|
||||
# 处理器类型映射: processor_type -> (module_path, class_name)
|
||||
PROCESSOR_TYPES = {
|
||||
"onnx_rapid_ocr": RapidOCRProcessor,
|
||||
"mineru_ocr": MinerUParser,
|
||||
"mineru_official": MinerUOfficialParser,
|
||||
"paddlex_ocr": PaddleXDocumentParser,
|
||||
"deepseek_ocr": DeepSeekOCRParser,
|
||||
"rapid_ocr": ("yuxi.plugins.parser.rapid_ocr", "RapidOCRParser"),
|
||||
"mineru_ocr": ("yuxi.plugins.parser.mineru", "MinerUParser"),
|
||||
"mineru_official": ("yuxi.plugins.parser.mineru_official", "MinerUOfficialParser"),
|
||||
"pp_structure_v3_ocr": ("yuxi.plugins.parser.pp_structure_v3", "PPStructureV3Parser"),
|
||||
"deepseek_ocr": ("yuxi.plugins.parser.deepseek_ocr", "DeepSeekOCRParser"),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _build_cache_key(cls, processor_type: str, kwargs: dict[str, Any]) -> str:
|
||||
if not kwargs:
|
||||
return processor_type
|
||||
|
||||
kwargs_repr = "|".join(f"{key}={kwargs[key]!r}" for key in sorted(kwargs))
|
||||
return f"{processor_type}|{kwargs_repr}"
|
||||
|
||||
@classmethod
|
||||
def _load_processor_class(cls, processor_type: str) -> type[BaseDocumentProcessor]:
|
||||
module_path, class_name = cls.PROCESSOR_TYPES[processor_type]
|
||||
module = import_module(module_path)
|
||||
processor_class = getattr(module, class_name)
|
||||
return processor_class
|
||||
|
||||
@classmethod
|
||||
def get_processor(cls, processor_type: str, **kwargs) -> BaseDocumentProcessor:
|
||||
"""
|
||||
@ -37,10 +49,10 @@ class DocumentProcessorFactory:
|
||||
|
||||
Args:
|
||||
processor_type: 处理器类型
|
||||
- "onnx_rapid_ocr": RapidOCR 本地 OCR
|
||||
- "rapid_ocr": RapidOCR 本地 OCR
|
||||
- "mineru_ocr": MinerU HTTP API 文档解析
|
||||
- "mineru_official": MinerU 官方云服务 API 文档解析
|
||||
- "paddlex_ocr": PP-StructureV3 版面解析
|
||||
- "pp_structure_v3_ocr": PP-Structure-V3 版面解析
|
||||
- "deepseek_ocr": DeepSeek-OCR SiliconFlow API
|
||||
**kwargs: 处理器初始化参数
|
||||
|
||||
@ -54,9 +66,9 @@ class DocumentProcessorFactory:
|
||||
raise ValueError(f"不支持的处理器类型: {processor_type}. 支持的类型: {list(cls.PROCESSOR_TYPES.keys())}")
|
||||
|
||||
# 使用缓存避免重复创建
|
||||
cache_key = f"{processor_type}_{hash(frozenset(kwargs.items()))}"
|
||||
cache_key = cls._build_cache_key(processor_type, kwargs)
|
||||
if cache_key not in _PROCESSOR_CACHE:
|
||||
processor_class = cls.PROCESSOR_TYPES[processor_type]
|
||||
processor_class = cls._load_processor_class(processor_type)
|
||||
_PROCESSOR_CACHE[cache_key] = processor_class(**kwargs)
|
||||
logger.debug(f"创建文档处理器: {processor_type}")
|
||||
|
||||
@ -115,6 +127,14 @@ class DocumentProcessorFactory:
|
||||
health_status[processor_type] = cls.check_health(processor_type)
|
||||
return health_status
|
||||
|
||||
@classmethod
|
||||
async def check_all_health_async(cls) -> dict[str, dict[str, Any]]:
|
||||
async def run_check(processor_type: str) -> tuple[str, dict[str, Any]]:
|
||||
return processor_type, await asyncio.to_thread(cls.check_health, processor_type)
|
||||
|
||||
results = await asyncio.gather(*(run_check(processor_type) for processor_type in cls.PROCESSOR_TYPES))
|
||||
return {processor_type: health for processor_type, health in results}
|
||||
|
||||
@classmethod
|
||||
def get_available_processors(cls) -> list[str]:
|
||||
"""返回所有可用的处理器类型"""
|
||||
@ -11,8 +11,8 @@ from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from yuxi.knowledge.indexing import _process_zip_file
|
||||
from yuxi.plugins.document_processor_base import BaseDocumentProcessor, DocumentParserException
|
||||
from yuxi.plugins.parser.base import BaseDocumentProcessor, DocumentParserException
|
||||
from yuxi.plugins.parser.zip_utils import process_zip_file_sync
|
||||
from yuxi.utils import logger
|
||||
|
||||
|
||||
@ -189,9 +189,7 @@ class MinerUParser(BaseDocumentProcessor):
|
||||
tmp_zip.flush()
|
||||
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
processed = asyncio.run(_process_zip_file(tmp_zip.name, params.get("db_id")))
|
||||
processed = process_zip_file_sync(tmp_zip.name, params.get("db_id") or "ocr-temp")
|
||||
text = processed["markdown_content"]
|
||||
finally:
|
||||
os.unlink(tmp_zip.name)
|
||||
@ -13,7 +13,8 @@ from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from yuxi.plugins.document_processor_base import BaseDocumentProcessor, DocumentParserException
|
||||
from yuxi.plugins.parser.base import BaseDocumentProcessor, DocumentParserException
|
||||
from yuxi.plugins.parser.zip_utils import process_zip_file_sync
|
||||
from yuxi.utils import hashstr, logger
|
||||
|
||||
|
||||
@ -154,12 +155,8 @@ class MinerUOfficialParser(BaseDocumentProcessor):
|
||||
)
|
||||
return text
|
||||
|
||||
import asyncio
|
||||
|
||||
from yuxi.knowledge.indexing import _process_zip_file
|
||||
|
||||
try:
|
||||
processed = asyncio.run(_process_zip_file(zip_path, params.get("db_id") or "ocr-test"))
|
||||
processed = process_zip_file_sync(zip_path, params.get("db_id") or "ocr-test")
|
||||
text = processed["markdown_content"]
|
||||
except Exception:
|
||||
import zipfile
|
||||
@ -1,7 +1,7 @@
|
||||
"""
|
||||
PP-StructureV3 文档解析器
|
||||
PP-Structure-V3 文档解析器
|
||||
|
||||
使用 PP-StructureV3 进行文档版面解析和内容提取
|
||||
使用 PP-Structure-V3 进行文档版面解析和内容提取
|
||||
"""
|
||||
|
||||
import base64
|
||||
@ -12,12 +12,12 @@ from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from yuxi.plugins.document_processor_base import BaseDocumentProcessor, DocumentParserException
|
||||
from yuxi.plugins.parser.base import BaseDocumentProcessor, DocumentParserException
|
||||
from yuxi.utils import logger
|
||||
|
||||
|
||||
class PaddleXDocumentParser(BaseDocumentProcessor):
|
||||
"""PP-StructureV3 文档解析器 - 使用 PP-StructureV3 进行版面解析"""
|
||||
class PPStructureV3Parser(BaseDocumentProcessor):
|
||||
"""PP-Structure-V3 文档解析器 - 使用 PP-Structure-V3 进行版面解析"""
|
||||
|
||||
def __init__(self, server_url: str | None = None):
|
||||
self.server_url = server_url or os.getenv("PADDLEX_URI") or "http://localhost:8080"
|
||||
@ -25,10 +25,10 @@ class PaddleXDocumentParser(BaseDocumentProcessor):
|
||||
self.endpoint = f"{self.base_url}/layout-parsing"
|
||||
|
||||
def get_service_name(self) -> str:
|
||||
return "paddlex_ocr"
|
||||
return "pp_structure_v3_ocr"
|
||||
|
||||
def get_supported_extensions(self) -> list[str]:
|
||||
"""PP-StructureV3 支持 PDF 和多种图像格式"""
|
||||
"""PP-Structure-V3 支持 PDF 和多种图像格式"""
|
||||
return [".pdf", ".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif"]
|
||||
|
||||
def _encode_file_to_base64(self, file_path: str) -> str:
|
||||
@ -64,7 +64,7 @@ class PaddleXDocumentParser(BaseDocumentProcessor):
|
||||
use_seal_recognition: bool = False,
|
||||
**kwargs,
|
||||
) -> dict[str, Any]:
|
||||
"""调用PP-StructureV3版面解析API"""
|
||||
"""调用PP-Structure-V3版面解析API"""
|
||||
# 处理文件输入
|
||||
processed_file_input = self._process_file_input(file_input)
|
||||
payload = {"file": processed_file_input}
|
||||
@ -92,7 +92,7 @@ class PaddleXDocumentParser(BaseDocumentProcessor):
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
error_msg = f"PP-StructureV3 API请求失败: {response.status_code}"
|
||||
error_msg = f"PP-Structure-V3 API请求失败: {response.status_code}"
|
||||
try:
|
||||
error_result = response.json()
|
||||
raise DocumentParserException(f"{error_msg}: {error_result}", self.get_service_name(), "api_error")
|
||||
@ -157,45 +157,45 @@ class PaddleXDocumentParser(BaseDocumentProcessor):
|
||||
return parsed_result
|
||||
|
||||
def check_health(self) -> dict:
|
||||
"""检查 PP-StructureV3 服务健康状态"""
|
||||
"""检查 PP-Structure-V3 服务健康状态"""
|
||||
try:
|
||||
response = requests.get(f"{self.base_url}/health", timeout=5)
|
||||
|
||||
if response.status_code == 200:
|
||||
return {
|
||||
"status": "healthy",
|
||||
"message": "PP-StructureV3 服务运行正常",
|
||||
"message": "PP-Structure-V3 服务运行正常",
|
||||
"details": {"server_url": self.server_url},
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "unhealthy",
|
||||
"message": f"PP-StructureV3 服务响应异常: {response.status_code}",
|
||||
"message": f"PP-Structure-V3 服务响应异常: {response.status_code}",
|
||||
"details": {"server_url": self.server_url},
|
||||
}
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
return {
|
||||
"status": "unavailable",
|
||||
"message": "PP-StructureV3 服务无法连接,请检查服务是否启动",
|
||||
"message": "PP-Structure-V3 服务无法连接,请检查服务是否启动",
|
||||
"details": {"server_url": self.server_url},
|
||||
}
|
||||
except requests.exceptions.Timeout:
|
||||
return {
|
||||
"status": "timeout",
|
||||
"message": "PP-StructureV3 服务连接超时",
|
||||
"message": "PP-Structure-V3 服务连接超时",
|
||||
"details": {"server_url": self.server_url},
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"PP-StructureV3 健康检查失败: {str(e)}",
|
||||
"message": f"PP-Structure-V3 健康检查失败: {str(e)}",
|
||||
"details": {"server_url": self.server_url, "error": str(e)},
|
||||
}
|
||||
|
||||
def process_file(self, file_path: str, params: dict | None = None) -> str:
|
||||
"""
|
||||
使用 PP-StructureV3 处理文档
|
||||
使用 PP-Structure-V3 处理文档
|
||||
|
||||
Args:
|
||||
file_path: 文件路径
|
||||
@ -220,7 +220,7 @@ class PaddleXDocumentParser(BaseDocumentProcessor):
|
||||
health = self.check_health()
|
||||
if health["status"] != "healthy":
|
||||
raise DocumentParserException(
|
||||
f"PP-StructureV3 服务不可用: {health['message']}", self.get_service_name(), health["status"]
|
||||
f"PP-Structure-V3 服务不可用: {health['message']}", self.get_service_name(), health["status"]
|
||||
)
|
||||
|
||||
try:
|
||||
@ -230,7 +230,7 @@ class PaddleXDocumentParser(BaseDocumentProcessor):
|
||||
# 判断文件类型
|
||||
file_type = 0 if file_ext == ".pdf" else 1
|
||||
|
||||
logger.info(f"PP-StructureV3 开始处理: {os.path.basename(file_path)}")
|
||||
logger.info(f"PP-Structure-V3 开始处理: {os.path.basename(file_path)}")
|
||||
|
||||
# 调用API
|
||||
api_result = self._call_layout_api(
|
||||
@ -244,7 +244,7 @@ class PaddleXDocumentParser(BaseDocumentProcessor):
|
||||
# 检查API调用是否成功
|
||||
if api_result.get("errorCode") != 0:
|
||||
raise DocumentParserException(
|
||||
f"PP-StructureV3 API错误: {api_result.get('errorMsg', '未知错误')}",
|
||||
f"PP-Structure-V3 API错误: {api_result.get('errorMsg', '未知错误')}",
|
||||
self.get_service_name(),
|
||||
"api_error",
|
||||
)
|
||||
@ -255,7 +255,7 @@ class PaddleXDocumentParser(BaseDocumentProcessor):
|
||||
|
||||
processing_time = time.time() - start_time
|
||||
logger.info(
|
||||
f"PP-StructureV3 处理成功: {os.path.basename(file_path)} - {len(text)} 字符 ({processing_time:.2f}s)"
|
||||
f"PP-Structure-V3 处理成功: {os.path.basename(file_path)} - {len(text)} 字符 ({processing_time:.2f}s)"
|
||||
)
|
||||
|
||||
# 记录统计信息
|
||||
@ -269,6 +269,6 @@ class PaddleXDocumentParser(BaseDocumentProcessor):
|
||||
raise
|
||||
except Exception as e:
|
||||
processing_time = time.time() - start_time
|
||||
error_msg = f"PP-StructureV3 处理失败: {str(e)}"
|
||||
error_msg = f"PP-Structure-V3 处理失败: {str(e)}"
|
||||
logger.error(f"{error_msg} ({processing_time:.2f}s)")
|
||||
raise DocumentParserException(error_msg, self.get_service_name(), "processing_failed")
|
||||
@ -1,5 +1,5 @@
|
||||
"""
|
||||
RapidOCR 处理器 - 纯OCR文字识别
|
||||
RapidOCR 解析器 - 纯OCR文字识别
|
||||
|
||||
使用 RapidOCR (PP-OCRv4) 进行文字识别
|
||||
"""
|
||||
@ -14,12 +14,12 @@ import numpy as np
|
||||
from PIL import Image
|
||||
from rapidocr_onnxruntime import RapidOCR
|
||||
|
||||
from yuxi.plugins.document_processor_base import BaseDocumentProcessor, OCRException
|
||||
from yuxi.plugins.parser.base import BaseDocumentProcessor, OCRException
|
||||
from yuxi.utils import logger
|
||||
|
||||
|
||||
class RapidOCRProcessor(BaseDocumentProcessor):
|
||||
"""RapidOCR 处理器 - 使用 ONNX 模型进行文字识别"""
|
||||
class RapidOCRParser(BaseDocumentProcessor):
|
||||
"""RapidOCR 解析器 - 使用 ONNX 模型进行文字识别"""
|
||||
|
||||
def __init__(self, det_box_thresh: float = 0.3):
|
||||
self.ocr = None
|
||||
195
backend/package/yuxi/plugins/parser/zip_utils.py
Normal file
195
backend/package/yuxi/plugins/parser/zip_utils.py
Normal file
@ -0,0 +1,195 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from yuxi.storage.minio import get_minio_client
|
||||
from yuxi.utils import hashstr, logger
|
||||
|
||||
|
||||
async def process_zip_file(zip_path: str, db_id: str) -> dict:
|
||||
"""
|
||||
处理ZIP文件,提取markdown内容和图片
|
||||
|
||||
Args:
|
||||
zip_path: ZIP文件路径
|
||||
db_id: 数据库ID
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"markdown_content": str,
|
||||
"content_hash": str,
|
||||
"images_info": list[dict]
|
||||
}
|
||||
"""
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
for name in zf.namelist():
|
||||
if name.startswith("/") or name.startswith("\\"):
|
||||
raise ValueError(f"ZIP 包含不安全路径: {name}")
|
||||
if ".." in Path(name).parts:
|
||||
raise ValueError(f"ZIP 路径包含上级引用: {name}")
|
||||
|
||||
md_files = [n for n in zf.namelist() if n.lower().endswith(".md")]
|
||||
if not md_files:
|
||||
raise ValueError("压缩包中未找到 .md 文件")
|
||||
|
||||
md_file = next((n for n in md_files if Path(n).name == "full.md"), md_files[0])
|
||||
|
||||
with zf.open(md_file) as f:
|
||||
markdown_content = f.read().decode("utf-8")
|
||||
|
||||
images_info = []
|
||||
images_dir = find_images_directory(zf, md_file)
|
||||
|
||||
if images_dir:
|
||||
images_info = await process_images(zf, images_dir, db_id, md_file)
|
||||
markdown_content = replace_image_links(markdown_content, images_info)
|
||||
|
||||
content_hash = hashlib.sha256(markdown_content.encode("utf-8")).hexdigest()
|
||||
|
||||
return {
|
||||
"markdown_content": markdown_content,
|
||||
"content_hash": content_hash,
|
||||
"images_info": images_info,
|
||||
}
|
||||
|
||||
|
||||
def process_zip_file_sync(zip_path: str, db_id: str) -> dict:
|
||||
"""同步调用 ZIP 处理,供同步解析器使用。"""
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(process_zip_file(zip_path, db_id))
|
||||
|
||||
result: dict | None = None
|
||||
error: Exception | None = None
|
||||
|
||||
def runner() -> None:
|
||||
nonlocal result, error
|
||||
try:
|
||||
result = asyncio.run(process_zip_file(zip_path, db_id))
|
||||
except Exception as exc: # pragma: no cover - pass through outer raise
|
||||
error = exc
|
||||
|
||||
import threading
|
||||
|
||||
thread = threading.Thread(target=runner, daemon=True)
|
||||
thread.start()
|
||||
thread.join()
|
||||
|
||||
if error is not None:
|
||||
raise error
|
||||
|
||||
if result is None:
|
||||
raise RuntimeError("ZIP 处理失败: 未返回结果")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def find_images_directory(zip_file: zipfile.ZipFile, md_file_path: str) -> str | None:
|
||||
"""查找images目录"""
|
||||
md_parent = Path(md_file_path).parent
|
||||
|
||||
candidates = []
|
||||
if str(md_parent) != ".":
|
||||
candidates.extend([str(md_parent / "images"), str(md_parent.parent / "images")])
|
||||
candidates.append("images")
|
||||
|
||||
for cand in candidates:
|
||||
cand_clean = cand.rstrip("/")
|
||||
if any(n.startswith(cand_clean + "/") for n in zip_file.namelist()):
|
||||
return cand_clean
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def process_images(zip_file: zipfile.ZipFile, images_dir: str, db_id: str, md_file_path: str) -> list[dict]:
|
||||
"""处理图片:上传到MinIO并返回信息"""
|
||||
supported_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
|
||||
content_type_map = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
}
|
||||
|
||||
images = []
|
||||
image_names = [n for n in zip_file.namelist() if n.startswith(images_dir + "/")]
|
||||
|
||||
minio_client = get_minio_client()
|
||||
bucket_name = "kb-images"
|
||||
await asyncio.to_thread(minio_client.ensure_bucket_exists, bucket_name)
|
||||
|
||||
file_id = hashstr(Path(md_file_path).name, length=16)
|
||||
|
||||
for img_name in image_names:
|
||||
suffix = Path(img_name).suffix.lower()
|
||||
if suffix not in supported_extensions:
|
||||
continue
|
||||
|
||||
try:
|
||||
with zip_file.open(img_name) as f:
|
||||
data = f.read()
|
||||
|
||||
timestamp = int(time.time() * 1000000)
|
||||
object_name = f"{db_id}/{file_id}/images/{timestamp}_{Path(img_name).name}"
|
||||
content_type = content_type_map.get(suffix, "image/jpeg")
|
||||
|
||||
result = await minio_client.aupload_file(
|
||||
bucket_name=bucket_name,
|
||||
object_name=object_name,
|
||||
data=data,
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
img_info = {
|
||||
"name": Path(img_name).name,
|
||||
"url": result.url,
|
||||
"path": f"images/{Path(img_name).name}",
|
||||
}
|
||||
images.append(img_info)
|
||||
|
||||
logger.debug(f"图片上传成功: {Path(img_name).name} -> {result.url}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"上传图片失败 {Path(img_name).name}: {e}")
|
||||
continue
|
||||
|
||||
return images
|
||||
|
||||
|
||||
def replace_image_links(markdown_content: str, images: list[dict]) -> str:
|
||||
"""替换markdown中的图片链接为MinIO URL"""
|
||||
if not images:
|
||||
return markdown_content
|
||||
|
||||
image_map = {}
|
||||
for img in images:
|
||||
path = img["path"]
|
||||
url = img["url"]
|
||||
image_map[path] = url
|
||||
image_map[f"/{path}"] = url
|
||||
image_map[img["name"]] = url
|
||||
|
||||
def replace_link(match):
|
||||
alt_text = match.group(1) or ""
|
||||
img_path = match.group(2)
|
||||
|
||||
for pattern, url in image_map.items():
|
||||
if img_path.endswith(pattern) or img_path == pattern:
|
||||
return f""
|
||||
|
||||
filename = os.path.basename(img_path)
|
||||
if filename in image_map:
|
||||
return f""
|
||||
|
||||
return match.group(0)
|
||||
|
||||
pattern = r"!\[([^\]]*)\]\(([^)]+)\)"
|
||||
return re.sub(pattern, replace_link, markdown_content)
|
||||
|
||||
@ -155,11 +155,11 @@ async def check_ocr_services_health(current_user: User = Depends(get_admin_user)
|
||||
检查所有OCR服务的健康状态
|
||||
返回各个OCR服务的可用性信息
|
||||
"""
|
||||
from yuxi.plugins.document_processor_factory import DocumentProcessorFactory
|
||||
from yuxi.plugins.parser.factory import DocumentProcessorFactory
|
||||
|
||||
try:
|
||||
# 使用统一的健康检查接口
|
||||
health_status = DocumentProcessorFactory.check_all_health()
|
||||
health_status = await DocumentProcessorFactory.check_all_health_async()
|
||||
|
||||
# 转换为旧格式以保持API兼容性
|
||||
formatted_status = {}
|
||||
|
||||
@ -49,14 +49,14 @@ Yuxi 支持多种文档格式的智能解析,从简单的文本文件到复杂
|
||||
| RapidOCR | 基础文字识别 | CPU | 免费开源,速度快 |
|
||||
| MinerU | 复杂 PDF、表格 | GPU | 精度高,版面分析好 |
|
||||
| MinerU Official | 复杂文档 | 无 | 官方云服务,开箱即用 |
|
||||
| PP-StructureV3 | 表格、票据 | GPU | 专业版面解析 |
|
||||
| PP-Structure-V3 | 表格、票据 | GPU | 专业版面解析 |
|
||||
| DeepSeek OCR | 智能理解 | 无 | 云端服务,Markdown 输出 |
|
||||
|
||||
### 选择建议
|
||||
|
||||
- **个人使用或 CPU 环境**:选择 RapidOCR,免费且资源占用低
|
||||
- **高精度需求**:选择 MinerU(需要 GPU)或 MinerU Official
|
||||
- **表格密集型文档**:选择 PP-StructureV3
|
||||
- **表格密集型文档**:选择 PP-Structure-V3
|
||||
- **简单云服务**:选择 DeepSeek OCR
|
||||
|
||||
## 快速配置
|
||||
@ -94,7 +94,7 @@ MINERU_API_KEY=your-api-key-here
|
||||
|
||||
从 [MinerU 官网](https://mineru.net) 获取 API 密钥。
|
||||
|
||||
### PP-StructureV3(结构化)
|
||||
### PP-Structure-V3(结构化)
|
||||
|
||||
```bash
|
||||
# 启动服务(需要 GPU)
|
||||
@ -121,6 +121,6 @@ HOST_IP=your_server_ip
|
||||
## 注意事项
|
||||
|
||||
1. **图片文件必须启用 OCR**:否则无法提取内容
|
||||
2. **GPU 要求**:MinerU 和 PP-StructureV3 需要 GPU 支持
|
||||
2. **GPU 要求**:MinerU 和 PP-Structure-V3 需要 GPU 支持
|
||||
3. **API 密钥**:部分服务需要额外的 API 密钥配置
|
||||
4. **超时处理**:复杂文档解析可能耗时较长,可通过 `MINERU_TIMEOUT` 环境变量调整超时时间
|
||||
|
||||
@ -73,7 +73,7 @@ LLM 检测会增加用户交互的延迟,请根据实际需求选择是否启
|
||||
| 端口 | 服务 | 说明 |
|
||||
|------|------|------|
|
||||
| 30000 | MinerU | PDF 解析服务 |
|
||||
| 8080 | PP-StructureV3 | OCR 服务 |
|
||||
| 8080 | PP-Structure-V3 | OCR 服务 |
|
||||
| 8081 | vLLM | 本地推理服务 |
|
||||
|
||||
### 快速访问
|
||||
|
||||
@ -87,7 +87,7 @@ docker compose logs --tail=100
|
||||
### OCR 服务不可用
|
||||
|
||||
- **RapidOCR**:确保 `MODEL_DIR/SWHL/RapidOCR` 下存在 `PP-OCRv4` 模型
|
||||
- **MinerU / PP-StructureV3**:检查 GPU 和 CUDA 版本是否兼容
|
||||
- **MinerU / PP-Structure-V3**:检查 GPU 和 CUDA 版本是否兼容
|
||||
|
||||
### 登录失败被锁定
|
||||
|
||||
|
||||
@ -551,10 +551,10 @@ const handleUrlKeydown = (e) => {
|
||||
|
||||
// OCR服务健康状态
|
||||
const ocrHealthStatus = ref({
|
||||
onnx_rapid_ocr: { status: 'unknown', message: '' },
|
||||
rapid_ocr: { status: 'unknown', message: '' },
|
||||
mineru_ocr: { status: 'unknown', message: '' },
|
||||
mineru_official: { status: 'unknown', message: '' },
|
||||
paddlex_ocr: { status: 'unknown', message: '' },
|
||||
pp_structure_v3_ocr: { status: 'unknown', message: '' },
|
||||
deepseek_ocr: { status: 'unknown', message: '' }
|
||||
})
|
||||
|
||||
@ -665,12 +665,12 @@ const enableOcrOptions = computed(() => [
|
||||
title: '不启用'
|
||||
},
|
||||
{
|
||||
value: 'onnx_rapid_ocr',
|
||||
value: 'rapid_ocr',
|
||||
label: getRapidOcrLabel(),
|
||||
title: 'ONNX with RapidOCR',
|
||||
disabled:
|
||||
ocrHealthStatus.value?.onnx_rapid_ocr?.status === 'unavailable' ||
|
||||
ocrHealthStatus.value?.onnx_rapid_ocr?.status === 'error'
|
||||
ocrHealthStatus.value?.rapid_ocr?.status === 'unavailable' ||
|
||||
ocrHealthStatus.value?.rapid_ocr?.status === 'error'
|
||||
},
|
||||
{
|
||||
value: 'mineru_ocr',
|
||||
@ -689,12 +689,12 @@ const enableOcrOptions = computed(() => [
|
||||
ocrHealthStatus.value?.mineru_official?.status === 'error'
|
||||
},
|
||||
{
|
||||
value: 'paddlex_ocr',
|
||||
label: getPaddleXLabel(),
|
||||
title: 'PP-StructureV3',
|
||||
value: 'pp_structure_v3_ocr',
|
||||
label: getPPStructureV3Label(),
|
||||
title: 'PP-Structure-V3',
|
||||
disabled:
|
||||
ocrHealthStatus.value?.paddlex_ocr?.status === 'unavailable' ||
|
||||
ocrHealthStatus.value?.paddlex_ocr?.status === 'error'
|
||||
ocrHealthStatus.value?.pp_structure_v3_ocr?.status === 'unavailable' ||
|
||||
ocrHealthStatus.value?.pp_structure_v3_ocr?.status === 'error'
|
||||
},
|
||||
{
|
||||
value: 'deepseek_ocr',
|
||||
@ -709,14 +709,14 @@ const enableOcrOptions = computed(() => [
|
||||
// 获取当前选中OCR服务的状态
|
||||
const selectedOcrStatus = computed(() => {
|
||||
switch (chunkParams.value.enable_ocr) {
|
||||
case 'onnx_rapid_ocr':
|
||||
return ocrHealthStatus.value?.onnx_rapid_ocr?.status || 'unknown'
|
||||
case 'rapid_ocr':
|
||||
return ocrHealthStatus.value?.rapid_ocr?.status || 'unknown'
|
||||
case 'mineru_ocr':
|
||||
return ocrHealthStatus.value?.mineru_ocr?.status || 'unknown'
|
||||
case 'mineru_official':
|
||||
return ocrHealthStatus.value?.mineru_official?.status || 'unknown'
|
||||
case 'paddlex_ocr':
|
||||
return ocrHealthStatus.value?.paddlex_ocr?.status || 'unknown'
|
||||
case 'pp_structure_v3_ocr':
|
||||
return ocrHealthStatus.value?.pp_structure_v3_ocr?.status || 'unknown'
|
||||
case 'deepseek_ocr':
|
||||
return ocrHealthStatus.value?.deepseek_ocr?.status || 'unknown'
|
||||
default:
|
||||
@ -727,14 +727,14 @@ const selectedOcrStatus = computed(() => {
|
||||
// 获取当前选中OCR服务的状态消息
|
||||
const selectedOcrMessage = computed(() => {
|
||||
switch (chunkParams.value.enable_ocr) {
|
||||
case 'onnx_rapid_ocr':
|
||||
return ocrHealthStatus.value?.onnx_rapid_ocr?.message || ''
|
||||
case 'rapid_ocr':
|
||||
return ocrHealthStatus.value?.rapid_ocr?.message || ''
|
||||
case 'mineru_ocr':
|
||||
return ocrHealthStatus.value?.mineru_ocr?.message || ''
|
||||
case 'mineru_official':
|
||||
return ocrHealthStatus.value?.mineru_official?.message || ''
|
||||
case 'paddlex_ocr':
|
||||
return ocrHealthStatus.value?.paddlex_ocr?.message || ''
|
||||
case 'pp_structure_v3_ocr':
|
||||
return ocrHealthStatus.value?.pp_structure_v3_ocr?.message || ''
|
||||
case 'deepseek_ocr':
|
||||
return ocrHealthStatus.value?.deepseek_ocr?.message || ''
|
||||
default:
|
||||
@ -759,10 +759,10 @@ const getOcrLabel = (serviceKey, displayName) => {
|
||||
}
|
||||
|
||||
// 兼容性包装器
|
||||
const getRapidOcrLabel = () => getOcrLabel('onnx_rapid_ocr', 'RapidOCR (ONNX)')
|
||||
const getRapidOcrLabel = () => getOcrLabel('rapid_ocr', 'RapidOCR (ONNX)')
|
||||
const getMinerULabel = () => getOcrLabel('mineru_ocr', 'MinerU OCR')
|
||||
const getMinerUOfficialLabel = () => getOcrLabel('mineru_official', 'MinerU Official API')
|
||||
const getPaddleXLabel = () => getOcrLabel('paddlex_ocr', 'PP-StructureV3')
|
||||
const getPPStructureV3Label = () => getOcrLabel('pp_structure_v3_ocr', 'PP-Structure-V3')
|
||||
const getDeepSeekOcrLabel = () => getOcrLabel('deepseek_ocr', 'DeepSeek OCR')
|
||||
|
||||
// 验证OCR服务可用性
|
||||
|
||||
Loading…
Reference in New Issue
Block a user