refactor: 重构解析模块;结构更加清晰,代码解耦

This commit is contained in:
Wenjie Zhang 2026-03-18 00:02:24 +08:00
parent c7b2bd8d71
commit fe2153ad76
16 changed files with 368 additions and 273 deletions

View File

@ -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 解析 DOCXDocling 失败时兜底)"""
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"![{alt_text}]({url})"
# 尝试文件名匹配
filename = os.path.basename(img_path)
if filename in image_map:
return f"![{alt_text}]({image_map[filename]})"
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.

View File

@ -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",
]

View 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",
]

View File

@ -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

View File

@ -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]:
"""返回所有可用的处理器类型"""

View File

@ -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)

View File

@ -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

View File

@ -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")

View File

@ -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

View 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"![{alt_text}]({url})"
filename = os.path.basename(img_path)
if filename in image_map:
return f"![{alt_text}]({image_map[filename]})"
return match.group(0)
pattern = r"!\[([^\]]*)\]\(([^)]+)\)"
return re.sub(pattern, replace_link, markdown_content)

View File

@ -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 = {}

View File

@ -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` 环境变量调整超时时间

View File

@ -73,7 +73,7 @@ LLM 检测会增加用户交互的延迟,请根据实际需求选择是否启
| 端口 | 服务 | 说明 |
|------|------|------|
| 30000 | MinerU | PDF 解析服务 |
| 8080 | PP-StructureV3 | OCR 服务 |
| 8080 | PP-Structure-V3 | OCR 服务 |
| 8081 | vLLM | 本地推理服务 |
### 快速访问

View File

@ -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 版本是否兼容
### 登录失败被锁定

View File

@ -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