From fe2153ad762ff5456b0ba02b5a06f616b4ffcc0d Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Wed, 18 Mar 2026 00:02:24 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E6=A8=A1=E5=9D=97=EF=BC=9B=E7=BB=93=E6=9E=84=E6=9B=B4?= =?UTF-8?q?=E5=8A=A0=E6=B8=85=E6=99=B0=EF=BC=8C=E4=BB=A3=E7=A0=81=E8=A7=A3?= =?UTF-8?q?=E8=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package/yuxi/knowledge/indexing.py | 234 ++++-------------- backend/package/yuxi/plugins/__init__.py | 20 +- .../package/yuxi/plugins/parser/__init__.py | 15 ++ .../base.py} | 0 .../deepseek_ocr.py} | 2 +- .../factory.py} | 52 ++-- .../{mineru_parser.py => parser/mineru.py} | 8 +- .../mineru_official.py} | 9 +- .../pp_structure_v3.py} | 42 ++-- .../rapid_ocr.py} | 8 +- .../package/yuxi/plugins/parser/zip_utils.py | 195 +++++++++++++++ backend/server/routers/system_router.py | 4 +- docs/latest/advanced/document-processing.md | 8 +- docs/latest/advanced/misc.md | 2 +- docs/latest/changelog/faq.md | 2 +- web/src/components/FileUploadModal.vue | 40 +-- 16 files changed, 368 insertions(+), 273 deletions(-) create mode 100644 backend/package/yuxi/plugins/parser/__init__.py rename backend/package/yuxi/plugins/{document_processor_base.py => parser/base.py} (100%) rename backend/package/yuxi/plugins/{deepseek_ocr_parser.py => parser/deepseek_ocr.py} (98%) rename backend/package/yuxi/plugins/{document_processor_factory.py => parser/factory.py} (63%) rename backend/package/yuxi/plugins/{mineru_parser.py => parser/mineru.py} (96%) rename backend/package/yuxi/plugins/{mineru_official_parser.py => parser/mineru_official.py} (98%) rename backend/package/yuxi/plugins/{paddlex_parser.py => parser/pp_structure_v3.py} (85%) rename backend/package/yuxi/plugins/{rapid_ocr_processor.py => parser/rapid_ocr.py} (97%) create mode 100644 backend/package/yuxi/plugins/parser/zip_utils.py diff --git a/backend/package/yuxi/knowledge/indexing.py b/backend/package/yuxi/knowledge/indexing.py index 9da480e2..e0b05430 100644 --- a/backend/package/yuxi/knowledge/indexing.py +++ b/backend/package/yuxi/knowledge/indexing.py @@ -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"![{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. diff --git a/backend/package/yuxi/plugins/__init__.py b/backend/package/yuxi/plugins/__init__.py index 5a794fc3..e8cf5a43 100644 --- a/backend/package/yuxi/plugins/__init__.py +++ b/backend/package/yuxi/plugins/__init__.py @@ -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", ] diff --git a/backend/package/yuxi/plugins/parser/__init__.py b/backend/package/yuxi/plugins/parser/__init__.py new file mode 100644 index 00000000..8e99b18d --- /dev/null +++ b/backend/package/yuxi/plugins/parser/__init__.py @@ -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", +] diff --git a/backend/package/yuxi/plugins/document_processor_base.py b/backend/package/yuxi/plugins/parser/base.py similarity index 100% rename from backend/package/yuxi/plugins/document_processor_base.py rename to backend/package/yuxi/plugins/parser/base.py diff --git a/backend/package/yuxi/plugins/deepseek_ocr_parser.py b/backend/package/yuxi/plugins/parser/deepseek_ocr.py similarity index 98% rename from backend/package/yuxi/plugins/deepseek_ocr_parser.py rename to backend/package/yuxi/plugins/parser/deepseek_ocr.py index 7a57a608..dc7bacbd 100644 --- a/backend/package/yuxi/plugins/deepseek_ocr_parser.py +++ b/backend/package/yuxi/plugins/parser/deepseek_ocr.py @@ -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 diff --git a/backend/package/yuxi/plugins/document_processor_factory.py b/backend/package/yuxi/plugins/parser/factory.py similarity index 63% rename from backend/package/yuxi/plugins/document_processor_factory.py rename to backend/package/yuxi/plugins/parser/factory.py index 11de84be..d6ab1e97 100644 --- a/backend/package/yuxi/plugins/document_processor_factory.py +++ b/backend/package/yuxi/plugins/parser/factory.py @@ -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]: """返回所有可用的处理器类型""" diff --git a/backend/package/yuxi/plugins/mineru_parser.py b/backend/package/yuxi/plugins/parser/mineru.py similarity index 96% rename from backend/package/yuxi/plugins/mineru_parser.py rename to backend/package/yuxi/plugins/parser/mineru.py index e6b7ef22..aabe61e1 100644 --- a/backend/package/yuxi/plugins/mineru_parser.py +++ b/backend/package/yuxi/plugins/parser/mineru.py @@ -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) diff --git a/backend/package/yuxi/plugins/mineru_official_parser.py b/backend/package/yuxi/plugins/parser/mineru_official.py similarity index 98% rename from backend/package/yuxi/plugins/mineru_official_parser.py rename to backend/package/yuxi/plugins/parser/mineru_official.py index 7c276d8c..11450afa 100644 --- a/backend/package/yuxi/plugins/mineru_official_parser.py +++ b/backend/package/yuxi/plugins/parser/mineru_official.py @@ -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 diff --git a/backend/package/yuxi/plugins/paddlex_parser.py b/backend/package/yuxi/plugins/parser/pp_structure_v3.py similarity index 85% rename from backend/package/yuxi/plugins/paddlex_parser.py rename to backend/package/yuxi/plugins/parser/pp_structure_v3.py index 528e095a..44951ac5 100644 --- a/backend/package/yuxi/plugins/paddlex_parser.py +++ b/backend/package/yuxi/plugins/parser/pp_structure_v3.py @@ -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") diff --git a/backend/package/yuxi/plugins/rapid_ocr_processor.py b/backend/package/yuxi/plugins/parser/rapid_ocr.py similarity index 97% rename from backend/package/yuxi/plugins/rapid_ocr_processor.py rename to backend/package/yuxi/plugins/parser/rapid_ocr.py index 4b25170e..9b9e7a09 100644 --- a/backend/package/yuxi/plugins/rapid_ocr_processor.py +++ b/backend/package/yuxi/plugins/parser/rapid_ocr.py @@ -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 diff --git a/backend/package/yuxi/plugins/parser/zip_utils.py b/backend/package/yuxi/plugins/parser/zip_utils.py new file mode 100644 index 00000000..717952e1 --- /dev/null +++ b/backend/package/yuxi/plugins/parser/zip_utils.py @@ -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) + diff --git a/backend/server/routers/system_router.py b/backend/server/routers/system_router.py index 431e9c1c..08386e12 100644 --- a/backend/server/routers/system_router.py +++ b/backend/server/routers/system_router.py @@ -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 = {} diff --git a/docs/latest/advanced/document-processing.md b/docs/latest/advanced/document-processing.md index 3c0bc228..12a5a8fa 100644 --- a/docs/latest/advanced/document-processing.md +++ b/docs/latest/advanced/document-processing.md @@ -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` 环境变量调整超时时间 diff --git a/docs/latest/advanced/misc.md b/docs/latest/advanced/misc.md index fe57f326..f8527694 100644 --- a/docs/latest/advanced/misc.md +++ b/docs/latest/advanced/misc.md @@ -73,7 +73,7 @@ LLM 检测会增加用户交互的延迟,请根据实际需求选择是否启 | 端口 | 服务 | 说明 | |------|------|------| | 30000 | MinerU | PDF 解析服务 | -| 8080 | PP-StructureV3 | OCR 服务 | +| 8080 | PP-Structure-V3 | OCR 服务 | | 8081 | vLLM | 本地推理服务 | ### 快速访问 diff --git a/docs/latest/changelog/faq.md b/docs/latest/changelog/faq.md index 6419a094..0931b63e 100644 --- a/docs/latest/changelog/faq.md +++ b/docs/latest/changelog/faq.md @@ -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 版本是否兼容 ### 登录失败被锁定 diff --git a/web/src/components/FileUploadModal.vue b/web/src/components/FileUploadModal.vue index 5b4d6057..8d69718a 100644 --- a/web/src/components/FileUploadModal.vue +++ b/web/src/components/FileUploadModal.vue @@ -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服务可用性