diff --git a/dayu/config/pdf_backend.py b/dayu/config/pdf_backend.py new file mode 100644 index 0000000..94de829 --- /dev/null +++ b/dayu/config/pdf_backend.py @@ -0,0 +1,112 @@ +"""MinerU 集成配置模块。 + +统一管理 MinerU 云 API / 本地运行时的所有配置项。 +所有配置通过环境变量注入,提供类型安全的读取与合理默认值。 +""" + +from __future__ import annotations + +import os + +# --------------------------------------------------------------------------- +# 环境变量名常量 +# --------------------------------------------------------------------------- + +ENV_MINERU_API_BASE = "DAYU_MINERU_API_BASE" +ENV_MINERU_TOKEN = "DAYU_MINERU_TOKEN" +ENV_MINERU_CHUNK_SIZE = "DAYU_MINERU_CHUNK_SIZE" +ENV_MINERU_LANG = "DAYU_MINERU_LANG" +ENV_MINERU_OCR = "DAYU_MINERU_OCR" +ENV_MINERU_TIMEOUT = "DAYU_MINERU_TIMEOUT" +ENV_QUOTA_DAILY_LIMIT = "DAYU_QUOTA_DAILY_LIMIT" + + +def get_mineru_api_base() -> str: + """获取 MinerU 云 API 基础地址。 + + Returns: + API 基础地址字符串。 + """ + return os.environ.get(ENV_MINERU_API_BASE, "https://mineru.net") + + +def get_mineru_token() -> str: + """获取 MinerU 云 API Token。 + + Returns: + Token 字符串;未配置时为空字符串。 + """ + return os.environ.get(ENV_MINERU_TOKEN, "") + + +def get_mineru_chunk_size() -> int: + """获取分批大小(每批最多页数)。 + + Returns: + 分批大小,默认 200。 + """ + return int(os.environ.get(ENV_MINERU_CHUNK_SIZE, "200")) + + +def get_mineru_lang() -> str: + """获取 MinerU 解析语言。 + + Returns: + 语言标识,默认 ``"ch"``(中文)。 + """ + return os.environ.get(ENV_MINERU_LANG, "ch") + + +def get_mineru_ocr_enabled() -> bool: + """获取 OCR 是否启用。 + + Returns: + ``True`` 表示启用,``False`` 表示禁用。 + """ + return os.environ.get(ENV_MINERU_OCR, "1") == "1" + + +def get_mineru_timeout() -> float: + """获取单批轮询超时(秒)。 + + Returns: + 超时秒数,默认 1800。 + """ + return float(os.environ.get(ENV_MINERU_TIMEOUT, "1800")) + + +def get_quota_daily_limit() -> int: + """获取每日配额上限(页)。 + + Returns: + 配额上限,默认 5000。 + """ + return int(os.environ.get(ENV_QUOTA_DAILY_LIMIT, "5000")) + + +def get_quota_state_file() -> str: + """获取配额状态持久化文件路径。 + + Returns: + 文件路径字符串,默认 ``"~/.dayu/quota_state.json"``。 + """ + return os.environ.get("DAYU_QUOTA_STATE_FILE", "~/.dayu/quota_state.json") + + +__all__ = [ + "ENV_MINERU_API_BASE", + "ENV_MINERU_TOKEN", + "ENV_MINERU_CHUNK_SIZE", + "ENV_MINERU_LANG", + "ENV_MINERU_OCR", + "ENV_MINERU_TIMEOUT", + "ENV_QUOTA_DAILY_LIMIT", + "get_mineru_api_base", + "get_mineru_token", + "get_mineru_chunk_size", + "get_mineru_lang", + "get_mineru_ocr_enabled", + "get_mineru_timeout", + "get_quota_daily_limit", + "get_quota_state_file", +] diff --git a/dayu/cos_helper.py b/dayu/cos_helper.py new file mode 100644 index 0000000..d779fe0 --- /dev/null +++ b/dayu/cos_helper.py @@ -0,0 +1,167 @@ +"""腾讯云 COS 文件托管辅助模块。 + +提供将本地文件上传到腾讯云对象存储(COS)并获取公开 URL 的能力, +主要用于 MinerU 云 API 需要通过 URL 访问 PDF 文件的场景。 + +设计目标: + +- 临时文件托管:MinerU API 需要公开可访问的 URL,本地文件需先上传到 COS。 +- 自动清理:提供删除接口,解析完成后清理临时文件。 +- 配置集中:所有 COS 配置通过环境变量注入。 +""" + +from __future__ import annotations + +import os +import time +from typing import TYPE_CHECKING + +from dayu.log import Log + +if TYPE_CHECKING: + pass + +_MODULE = __name__ + +# --------------------------------------------------------------------------- +# 配置(环境变量) +# --------------------------------------------------------------------------- + +_COS_SECRET_ID: str = os.environ.get("DAYU_COS_SECRET_ID", "") +_COS_SECRET_KEY: str = os.environ.get("DAYU_COS_SECRET_KEY", "") +_COS_BUCKET: str = os.environ.get("DAYU_COS_BUCKET", "") +_COS_REGION: str = os.environ.get("DAYU_COS_REGION", "ap-guangzhou") + + +class COSUploadError(RuntimeError): + """COS 上传失败异常。""" + + +def _check_cos_config() -> None: + """检查 COS 配置是否完整。 + + Raises: + COSUploadError: 配置缺失时抛出。 + """ + missing: list[str] = [] + if not _COS_SECRET_ID: + missing.append("DAYU_COS_SECRET_ID") + if not _COS_SECRET_KEY: + missing.append("DAYU_COS_SECRET_KEY") + if not _COS_BUCKET: + missing.append("DAYU_COS_BUCKET") + if missing: + raise COSUploadError( + f"COS 配置缺失: {', '.join(missing)}。" + f"请设置对应环境变量。" + ) + + +def upload_pdf_to_cos( + pdf_bytes: bytes, + *, + filename: str = "filing.pdf", +) -> str: + """将 PDF 字节流上传到腾讯云 COS 并返回公开 URL。 + + 每次调用都创建新的 CosS3Client,因为 CosS3Client 不是线程安全的 + (内部有连接池和签名逻辑,多线程共享会导致签名错误)。 + + Args: + pdf_bytes: PDF 原始字节。 + filename: COS 对象键中的文件名。 + + Returns: + 上传后的公开可访问 URL。 + + Raises: + COSUploadError: 配置缺失或上传失败时抛出。 + """ + _check_cos_config() + + try: + from qcloud_cos import CosConfig, CosS3Client + except ImportError as exc: + raise COSUploadError( + "cos-python-sdk-v5 未安装,无法上传到 COS。" + "请执行 pip install cos-python-sdk-v5" + ) from exc + + config = CosConfig( + Region=_COS_REGION, + SecretId=_COS_SECRET_ID, + SecretKey=_COS_SECRET_KEY, + ) + client = CosS3Client(config) + + # 使用时间戳避免文件名冲突 + timestamp = int(time.time()) + cos_key = f"dayu-mineru-temp/{timestamp}_{filename}" + + Log.info( + f"上传 PDF 到 COS: bucket={_COS_BUCKET}, key={cos_key}, " + f"size={len(pdf_bytes)} bytes", + module=_MODULE, + ) + + try: + client.put_object( + Bucket=_COS_BUCKET, + Body=pdf_bytes, + Key=cos_key, + ContentType="application/pdf", + ) + except Exception as exc: + raise COSUploadError(f"COS 上传失败: {exc}") from exc + + # 构造公开 URL + url = f"https://{_COS_BUCKET}.cos.{_COS_REGION}.myqcloud.com/{cos_key}" + Log.info(f"COS 上传成功: {url}", module=_MODULE) + return url + + +def delete_from_cos(cos_key: str) -> None: + """从 COS 删除指定对象。 + + Args: + cos_key: COS 对象键。 + """ + if not _COS_BUCKET: + return + try: + from qcloud_cos import CosConfig, CosS3Client + + config = CosConfig( + Region=_COS_REGION, + SecretId=_COS_SECRET_ID, + SecretKey=_COS_SECRET_KEY, + ) + client = CosS3Client(config) + client.delete_object(Bucket=_COS_BUCKET, Key=cos_key) + Log.debug(f"COS 对象已删除: {cos_key}", module=_MODULE) + except Exception as exc: + Log.warn(f"COS 对象删除失败: {cos_key}, error={exc}", module=_MODULE) + + +def extract_cos_key_from_url(url: str) -> str: + """从 COS 公开 URL 中提取对象键。 + + Args: + url: COS 公开 URL。 + + Returns: + COS 对象键。 + """ + # URL 格式: https://bucket.cos.region.myqcloud.com/key + parts = url.split("/", 3) + if len(parts) >= 4: + return parts[3] + return url + + +__all__ = [ + "COSUploadError", + "upload_pdf_to_cos", + "delete_from_cos", + "extract_cos_key_from_url", +] diff --git a/dayu/document_protocol.py b/dayu/document_protocol.py new file mode 100644 index 0000000..0f26d52 --- /dev/null +++ b/dayu/document_protocol.py @@ -0,0 +1,362 @@ +"""MinerU 集成方案的统一中间格式与格式版本探测。 + +本模块定义 MinerU / Docling 多后端的输出收敛格式 ``ConvertedDocument``, +以及 MinerU content_list_v2 输出结构的版本探测工具。 + +设计目标: + +- ``ConvertedDocument`` 是各个 PDF 解析后端(MinerU 云 API、MinerU 本地、Docling) + 输出的**唯一中间格式**,上层只关心此 dataclass,不感知后端差异。 +- ``DocumentBackend`` 枚举提供所有受支持后端的可识别标识。 +- ``detect_mineru_content_list_version`` 在 MinerU content_list_v2 输出结构 + 发生产出格式变更时(设计文档 risk item),通过字段指纹差异感知并告警。 + +依赖方向:本模块不依赖任何运行时模块(不 import ``docling_runtime`` 或 +``mineru_runtime``),只定义纯数据结构和探测函数,是所有 PDF 后端模块的 +**依赖目标**(它们 import 本模块)。 +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from enum import Enum +from typing import TYPE_CHECKING + +from dayu.log import Log + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + +_MODULE = __name__ + +# --------------------------------------------------------------------------- +# 后端标识 +# --------------------------------------------------------------------------- + + +class DocumentBackend(str, Enum): + """受支持的 PDF 解析后端标识。 + + ``mineru_cloud`` 调用 MinerU 云 API(异步提交 + 轮询结果)。 + ``mineru_local`` 调用 MinerU 本地 Python API 或 CLI。 + ``docling`` 调用 Dayu 现有 Docling 后端(含 docling-parse / pypdfium2 回退)。 + """ + + MINERU_CLOUD = "mineru_cloud" + MINERU_LOCAL = "mineru_local" + DOCLING = "docling" + + +# --------------------------------------------------------------------------- +# 统一中间格式 +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class DocumentSection: + """文档中的一个章节。 + + Attributes: + title: 章节标题文字。 + level: 标题层级(1 为最高级)。 + content: 章节正文 Markdown 内容。 + page_idx: 章节开始的零基页码;不确定时可为 ``None``。 + """ + + title: str + level: int + content: str + page_idx: int | None = None + + +@dataclass(frozen=True) +class DocumentTable: + """文档中的一个表格。 + + Attributes: + caption: 表格标题;无标题时可为空字符串。 + html: 表格的 HTML 表示(统一用 HTML)。 + page_idx: 表格所在的零基页码;不确定时可为 ``None``。 + """ + + caption: str + html: str + page_idx: int | None = None + + +@dataclass(frozen=True) +class DocumentImage: + """文档中的一张图片。 + + Attributes: + path: 图片路径(可以是本地文件路径或 base64 data URI)。 + caption: 图片标题或 alt 文字。 + page_idx: 图片所在的零基页码;不确定时可为 ``None``。 + """ + + path: str + caption: str + page_idx: int | None = None + + +@dataclass(frozen=True) +class ConvertedDocument: + """PDF 解析结果统一中间格式。 + + 所有 PDF 解析后端(MinerU 云 API / MinerU 本地 / Docling)的输出 + 均收敛为此 dataclass,上层流程只依赖此结构。 + + Attributes: + backend: 产生该结果的后端标识。 + sections: 文档章节列表,按出现顺序排列。 + tables: 文档表格列表,按出现顺序排列。 + images: 文档图片列表,按出现顺序排列。 + raw_markdown: 全文原始 Markdown 内容。 + metadata: 与后端无关的元信息(总页数、文件大小等)。 + """ + + backend: DocumentBackend + sections: tuple[DocumentSection, ...] = field(default_factory=tuple) + tables: tuple[DocumentTable, ...] = field(default_factory=tuple) + images: tuple[DocumentImage, ...] = field(default_factory=tuple) + raw_markdown: str = "" + metadata: dict[str, str] = field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# MinerU content_list_v2 格式指纹与版本探测 +# --------------------------------------------------------------------------- + +#: 已知稳定 block 类型集合(用于类型 fallback)。 +#: 注意:需要与 mineru_runtime.py 中 _convert_mineru_result 的类型分发保持一致。 +_KNOWN_BLOCK_TYPES: frozenset[str] = frozenset({ + "text", + "paragraph", + "title", + "table", + "figure", + "image", +}) + + +def detect_mineru_content_list_version( + raw_content_list: Sequence[Mapping[str, object]], +) -> str: + """检测 MinerU content_list 输出结构的格式版本。 + + 通过检查前 10 个包含 ``bbox`` 字段的元素的 bbox 类型来判断版本: + + - ``"v1"``:bbox 是 dict 格式(``{"x0": ..., "y0": ...}``)。 + - ``"v2"``:bbox 是 list 格式(``[x0, y0, x1, y1]``)。 + - ``"unknown"``:前 10 个元素均无 bbox 字段,无法判断。 + + Args: + raw_content_list: MinerU 返回的 ``content_list`` 列表。 + + Returns: + 格式版本标识字符串:``"v1"``、``"v2"`` 或 ``"unknown"``。 + + Raises: + TypeError: ``raw_content_list`` 为空或非序列时抛出。 + """ + if not raw_content_list: + raise TypeError("MinerU content_list 为空或不可迭代,无法检测版本") + + for item in raw_content_list[:10]: + bbox = item.get("bbox") + if bbox is None: + continue + if isinstance(bbox, list): + return "v2" + if isinstance(bbox, dict): + return "v1" + + return "unknown" + + +def parse_content_list( + content_list: Sequence[Mapping[str, object]], +) -> list[Mapping[str, object]]: + """解析 MinerU content_list,对未知格式做 best-effort fallback。 + + 遍历每个 block 并尝试解析;单个 block 解析失败时记录警告并跳过, + 而非让整个流程崩溃。 + + Args: + content_list: MinerU 返回的 ``content_list`` 列表。 + + Returns: + 解析后的 block 列表(可能少于输入数量)。 + """ + version = detect_mineru_content_list_version(content_list) + if version == "unknown": + Log.warn("MinerU 格式版本未知,尝试 best-effort 解析", module=_MODULE) + + parsed: list[Mapping[str, object]] = [] + for block in content_list: + try: + parsed.append(parse_block(block)) + except Exception as exc: + Log.warn( + f"跳过解析失败的 block: error={exc}, block_keys={list(block.keys())}", + module=_MODULE, + ) + continue + return parsed + + +def parse_block(block: Mapping[str, object]) -> Mapping[str, object]: + """解析单个 MinerU block,对未知类型做标记而非崩溃。 + + 已知类型(text/title/figure/table)原样返回;未知类型附加 + ``_unknown_type=True`` 标记,让上层自行决定处理方式。 + + Args: + block: MinerU content_list 中的单个 block 字典。 + + Returns: + 解析后的 block(可能附带 ``_unknown_type`` 标记)。 + """ + block_type = str(block.get("type", "unknown")) + if block_type in _KNOWN_BLOCK_TYPES: + return block + # 未知类型:原样返回,附加标记 + Log.info(f"未知 MinerU block type: {block_type}", module=_MODULE) + return {**block, "_unknown_type": True} + + +def detect_mineru_content_list_version_from_bytes( + raw_json_bytes: bytes, +) -> str: + """从 MinerU 返回的 JSON 字节流中检测 content_list 版本指纹。 + + Args: + raw_json_bytes: MinerU API 返回的原始 JSON 字节内容。 + + Returns: + 格式版本标识字符串,同 ``detect_mineru_content_list_version``。 + + Raises: + TypeError: 字节流为空或 JSON 解析结果不符合预期时抛出。 + json.JSONDecodeError: JSON 格式非法时抛出。 + """ + if not raw_json_bytes: + raise TypeError("MinerU content_list JSON 字节流为空") + + data = json.loads(raw_json_bytes) + + if not isinstance(data, list): + # 若 JSON 根是字典(旧格式或包壳),尝试抽取 content_list 字段 + if isinstance(data, dict): + content_list = data.get("content_list") + if content_list is None: + raise TypeError( + "MinerU 返回的 JSON 既不是 content_list 数组," + "也不包含 'content_list' 字段" + ) + raw_content_list: Sequence[Mapping[str, object]] = content_list + else: + raise TypeError( + f"MinerU 返回的 JSON 根元素类型为 {type(data).__name__}," + f"期望 list 或 dict" + ) + else: + raw_content_list = data + + return detect_mineru_content_list_version(raw_content_list) + + +# --------------------------------------------------------------------------- +# 辅助工厂函数 +# --------------------------------------------------------------------------- + + +def _build_section( + *, + title: str, + level: int, + content: str, + page_idx: int | None = None, +) -> DocumentSection: + """构造一个 ``DocumentSection``,封装合法性校验。 + + Args: + title: 章节标题。 + level: 标题层级,必须 >= 1。 + content: 章节正文。 + page_idx: 零基页码。 + + Returns: + 构造完成的 ``DocumentSection``。 + + Raises: + ValueError: level 小于 1 时抛出。 + """ + if level < 1: + raise ValueError(f"章节层级必须 >= 1,实际为 {level}") + return DocumentSection( + title=title, + level=level, + content=content, + page_idx=page_idx, + ) + + +def _build_table( + *, + caption: str, + html: str, + page_idx: int | None = None, +) -> DocumentTable: + """构造一个 ``DocumentTable``。 + + Args: + caption: 表格标题。 + html: 表格 HTML 内容。 + page_idx: 零基页码。 + + Returns: + 构造完成的 ``DocumentTable``。 + """ + return DocumentTable( + caption=caption, + html=html, + page_idx=page_idx, + ) + + +def _build_image( + *, + path: str, + caption: str, + page_idx: int | None = None, +) -> DocumentImage: + """构造一个 ``DocumentImage``。 + + Args: + path: 图片路径。 + caption: 图片标题。 + page_idx: 零基页码。 + + Returns: + 构造完成的 ``DocumentImage``。 + """ + return DocumentImage( + path=path, + caption=caption, + page_idx=page_idx, + ) + + +__all__ = [ + "ConvertedDocument", + "DocumentBackend", + "DocumentImage", + "DocumentSection", + "DocumentTable", + "detect_mineru_content_list_version", + "detect_mineru_content_list_version_from_bytes", + "parse_content_list", + "parse_block", +] diff --git a/dayu/fins/docling_export.py b/dayu/fins/docling_export.py index 9058269..29ce1d9 100644 --- a/dayu/fins/docling_export.py +++ b/dayu/fins/docling_export.py @@ -14,18 +14,32 @@ 两个函数共用 :func:`dayu.docling_runtime.convert_pdf_bytes_with_docling` 调用; 任何参数策略调整集中在本模块完成,避免 docling-runtime 调用点散落到 upload / download 双链路造成漂移。 + +v4.1 新增: + +- :func:`convert_pdf_bytes_with_fallback`:Docling 优先,失败回退 MinerU。 + 返回 ``(payload_dict, suffix)`` 元组,suffix 标识实际使用的后端。 +- :func:`converted_document_to_mineru_dict`:将 MinerU 的 ``ConvertedDocument`` + 序列化为可 JSON 化的 dict。 """ from __future__ import annotations import json -from typing import Any, Callable +import logging +from typing import TYPE_CHECKING, Any, Callable from dayu.docling_runtime import ( DoclingRuntimeInitializationError, convert_pdf_bytes_with_docling, ) +if TYPE_CHECKING: + from dayu.document_protocol import ConvertedDocument + +_MODULE = __name__ +_logger = logging.getLogger(_MODULE) + # 下载链路注入点的稳定签名:``(raw_bytes, stream_name) -> json_bytes``。 # 显式以位置参数风格暴露,避免 keyword-only 与 ``Callable[[bytes, str], bytes]`` # 协议不兼容。download workflow 与 filing workflow 应直接引用此别名。 @@ -35,6 +49,8 @@ "PdfToDoclingJsonBytes", "convert_pdf_bytes_to_docling_payload", "convert_pdf_bytes_to_docling_json_bytes", + "convert_pdf_bytes_with_fallback", + "converted_document_to_mineru_dict", ] @@ -99,3 +115,104 @@ def convert_pdf_bytes_to_docling_json_bytes( payload = convert_pdf_bytes_to_docling_payload(raw_data, stream_name=stream_name) return json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8") + + +# --------------------------------------------------------------------------- +# v4.1: Docling + MinerU fallback +# --------------------------------------------------------------------------- + + +def converted_document_to_mineru_dict( + doc: "ConvertedDocument", +) -> dict[str, Any]: + """将 MinerU 的 ``ConvertedDocument`` 序列化为可 JSON 化的 dict。 + + 输出结构包含所有原始信息,供下游 MineruProcessor 消费。 + + Args: + doc: MinerU 解析结果(来自 :mod:`dayu.document_protocol`)。 + + Returns: + 可 JSON 化的字典。 + """ + return { + "backend": doc.backend.value, + "raw_markdown": doc.raw_markdown, + "sections": [ + { + "title": s.title, + "level": s.level, + "content": s.content, + "page_idx": s.page_idx, + } + for s in doc.sections + ], + "tables": [ + { + "caption": t.caption, + "html": t.html, + "page_idx": t.page_idx, + } + for t in doc.tables + ], + "images": [ + { + "path": i.path, + "caption": i.caption, + "page_idx": i.page_idx, + } + for i in doc.images + ], + "metadata": doc.metadata, + } + + +def convert_pdf_bytes_with_fallback( + raw_data: bytes, + *, + stream_name: str, +) -> tuple[dict[str, Any], str]: + """先试 Docling,失败则 fallback 到 MinerU。 + + Docling 失败时自动调用 MinerU 云 API 解析,返回 MinerU 格式的 dict。 + 两者通过 suffix 区分:``_docling.json`` 或 ``_mineru.json``。 + + Args: + raw_data: PDF 原始字节内容。 + stream_name: 流名称,建议直接传文件名以保留扩展名。 + + Returns: + ``(payload_dict, suffix)`` 元组。 + - Docling 成功时 suffix 为 ``"_docling.json"`` + - MinerU fallback 成功时 suffix 为 ``"_mineru.json"`` + + Raises: + RuntimeError: Docling 和 MinerU 都失败时抛出。 + """ + # --- 优先 Docling --- + try: + payload = convert_pdf_bytes_to_docling_payload( + raw_data, stream_name=stream_name + ) + _logger.info("Docling 解析成功: %s", stream_name) + return payload, "_docling.json" + except Exception as docling_exc: + _logger.warning( + "Docling 解析失败 (%s),fallback 到 MinerU: %s", + stream_name, + docling_exc, + ) + + # --- Fallback: MinerU --- + try: + from dayu.mineru_runtime import parse_pdf_bytes_with_mineru + + doc = parse_pdf_bytes_with_mineru(raw_data, filename=stream_name) + payload = converted_document_to_mineru_dict(doc) + _logger.info("MinerU 解析成功: %s", stream_name) + return payload, "_mineru.json" + except Exception as mineru_exc: + _logger.error("MinerU 也失败 (%s): %s", stream_name, mineru_exc) + raise RuntimeError( + f"Docling 和 MinerU 都解析失败: {stream_name}" + ) from mineru_exc diff --git a/dayu/fins/pipelines/docling_upload_service.py b/dayu/fins/pipelines/docling_upload_service.py index 6910224..75b4f85 100644 --- a/dayu/fins/pipelines/docling_upload_service.py +++ b/dayu/fins/pipelines/docling_upload_service.py @@ -21,7 +21,7 @@ from pathlib import Path from typing import Any, Callable, Literal, Optional -from dayu.fins.docling_export import convert_pdf_bytes_to_docling_payload +from dayu.fins.docling_export import convert_pdf_bytes_with_fallback from dayu.fins.domain.document_models import ( SourceHandle, SourceDocumentStateChangeRequest, @@ -100,14 +100,16 @@ def __init__( source_repository: SourceDocumentRepositoryProtocol, blob_repository: DocumentBlobRepositoryProtocol, *, - convert_with_docling: Optional[Callable[[bytes, str], dict[str, Any]]] = None, + convert_with_docling: Optional[Callable[[bytes, str], tuple[dict[str, Any], str]]] = None, ) -> None: """初始化服务。 Args: source_repository: 源文档仓储实现。 blob_repository: 文档文件对象仓储实现。 - convert_with_docling: 可选 Docling 转换函数(测试可注入)。 + convert_with_docling: 可选 PDF 转换函数(测试可注入)。 + 签名 ``(raw_bytes, stream_name) -> (payload_dict, suffix)``。 + 默认使用 Docling 优先、MinerU fallback 的实现。 Returns: 无。 @@ -122,7 +124,7 @@ def __init__( raise ValueError("blob_repository 不能为空") self._source_repository = source_repository self._blob_repository = blob_repository - self._convert_with_docling = convert_with_docling or _convert_bytes_with_docling + self._convert_with_docling = convert_with_docling or _convert_bytes_with_fallback def execute_upload( self, @@ -452,14 +454,14 @@ def _build_pending_assets( event_type="conversion_started", name=file_path.name, payload={ - "source": "docling", - "message": "正在 convert", + "source": "docling_or_mineru", + "message": "正在 convert(Docling 优先,MinerU fallback)", }, ) ) - docling_payload = self._convert_with_docling(original_asset.data, file_path.name) + docling_payload, file_suffix = self._convert_with_docling(original_asset.data, file_path.name) docling_data = json.dumps(docling_payload, ensure_ascii=False, indent=2).encode("utf-8") - docling_name = f"{file_path.stem}{DOCLING_FILE_SUFFIX}" + docling_name = f"{file_path.stem}{file_suffix}" docling_sha256 = hashlib.sha256(docling_data).hexdigest() assets.append( _PendingFileAsset( @@ -564,26 +566,27 @@ def _upsert_source_document( ) -def _convert_bytes_with_docling(raw_data: bytes, stream_name: str) -> dict[str, Any]: - """使用 Docling 将字节流转换为结构化 JSON。 +def _convert_bytes_with_fallback(raw_data: bytes, stream_name: str) -> tuple[dict[str, Any], str]: + """使用 Docling 转换 PDF,失败时 fallback 到 MinerU。 本函数作为兼容入口保留,对外签名保持向后兼容;实际实现 delegate 到 - :func:`dayu.fins.docling_export.convert_pdf_bytes_to_docling_payload`,让 - docling-runtime 调用点收敛到 ``dayu.fins.docling_export`` 单一真源。 + :func:`dayu.fins.docling_export.convert_pdf_bytes_with_fallback`, + 让 docling-runtime 调用点收敛到 ``dayu.fins.docling_export`` 单一真源。 Args: raw_data: 文件原始字节内容。 stream_name: 流名称(建议直接使用文件名,保留扩展名)。 Returns: - Docling 导出的结构化字典。 + ``(payload_dict, suffix)`` 元组。 + - Docling 成功时 suffix 为 ``"_docling.json"`` + - MinerU fallback 成功时 suffix 为 ``"_mineru.json"`` Raises: - DoclingRuntimeInitializationError: Docling 装配失败时抛出。 - RuntimeError: Docling 转换失败时抛出。 + RuntimeError: Docling 和 MinerU 都失败时抛出。 """ - return convert_pdf_bytes_to_docling_payload(raw_data, stream_name=stream_name) + return convert_pdf_bytes_with_fallback(raw_data, stream_name=stream_name) def _validate_source_files(files: list[Path]) -> list[Path]: @@ -613,14 +616,17 @@ def _validate_source_files(files: list[Path]) -> list[Path]: return normalized +_PARSED_FILE_SUFFIXES = ("_docling.json", "_mineru.json") + + def _pick_primary_docling_file(file_entries: list[dict[str, Any]]) -> Optional[str]: - """从文件条目中选择主 docling 文件。 + """从文件条目中选择主解析结果文件(docling 或 mineru)。 Args: file_entries: 文件条目列表。 Returns: - 主文件名;不存在返回 `None`。 + 主文件名;不存在返回 ``None``。 Raises: 无。 @@ -628,7 +634,7 @@ def _pick_primary_docling_file(file_entries: list[dict[str, Any]]) -> Optional[s for entry in file_entries: name = str(entry.get("name", "")).strip() - if name.endswith(DOCLING_FILE_SUFFIX): + if name.endswith(_PARSED_FILE_SUFFIXES): return name return None diff --git a/dayu/mineru_runtime.py b/dayu/mineru_runtime.py new file mode 100644 index 0000000..342eb9b --- /dev/null +++ b/dayu/mineru_runtime.py @@ -0,0 +1,1245 @@ +"""MinerU 云 API v4 运行时。 + +本模块提供将 PDF 字节流通过 MinerU 云 API v4 转换为统一中间格式 +``ConvertedDocument`` 的完整流程,包含: + +1. PDF 上传到腾讯云 COS,获取公开 URL; +2. 按 page_ranges 分批(服务端分页,≤200 页/批); +3. 异步并发提交 + 并发轮询(指数退避 + jitter); +4. 结果合并(简化版:直接 append); +5. 配额检查(本地计数器 + 文件持久化); +6. 任一环节失败自动回退 Docling。 + +调用链:``parse_pdf_bytes_with_mineru`` → ``_poll_all_tasks`` → ``_poll_one``。 + +回退链(五层): + +1. MinerU 云 API v4 单次(≤200 页且配额充足) +2. MinerU 云 API v4 分批(>200 页,page_ranges 分段) +3. MinerU 本地 CLI(``magic-pdf`` 命令可用) +4. MinerU 本地 Python API(``magic_pdf`` 已安装) +5. Docling(终极兜底) + +MinerU 云 API v4 规格(2026-05-06 确认): + +- Base URL: ``https://mineru.net`` +- 提交: ``POST /api/v4/extract/task`` (JSON body,需 url 字段) +- 查询: ``GET /api/v4/extract/task/{task_id}`` +- 认证: ``Authorization: Bearer `` +- 支持 ``page_ranges`` 参数实现服务端分页 +- 不支持直接文件上传,需提供公开可访问的 URL +""" + +from __future__ import annotations + +import asyncio +import math +import os +import random +import shutil +from dataclasses import dataclass, field +from enum import Enum +from typing import TYPE_CHECKING + +import httpx + +from dayu.document_protocol import ( + ConvertedDocument, + DocumentBackend, + DocumentSection, + DocumentTable, + DocumentImage, +) +from dayu.log import Log +from dayu.quota_tracker import QuotaTracker, QuotaExhaustedError + +if TYPE_CHECKING: + pass + +_MODULE = __name__ + +# --------------------------------------------------------------------------- +# 配置常量(可通过环境变量覆盖) +# --------------------------------------------------------------------------- + +#: MinerU 云 API v4 基础地址。 +_MINERU_API_BASE: str = "https://mineru.net" + +#: MinerU 云 API Token。 +_MINERU_API_TOKEN: str = os.environ.get("DAYU_MINERU_TOKEN", "") + +#: 每批最多页数(MinerU 云 API 限制 200 页)。 +_MAX_PAGES_PER_BATCH: int = int(os.environ.get("DAYU_MINERU_CHUNK_SIZE", "200")) + +#: MinerU 云 API 单批轮询超时(秒),默认 1800 秒(30 分钟)。 +#: MinerU API 处理一份 PDF 通常耗时 1~10 分钟(视页数而定), +#: 1800 秒按最坏情况预留充足余量,可覆盖 200 页/批的完整处理+网络传输时间。 +_CLOUD_API_TIMEOUT: float = float(os.environ.get("DAYU_MINERU_TIMEOUT", "1800")) + +#: 轮询初始间隔(秒)。 +_POLL_INITIAL: float = 2.0 + +#: 轮询最大间隔(秒)。 +_POLL_MAX: float = 30.0 + +#: 每日配额上限(页)。 +_QUOTA_DAILY_LIMIT: int = int(os.environ.get("DAYU_QUOTA_DAILY_LIMIT", "5000")) + +#: 模型版本。 +_MODEL_VERSION: str = os.environ.get("DAYU_MINERU_MODEL_VERSION", "vlm") + +#: PDF 每页估算字节数,50,000 字节/页 ≈ 50 KB/页。 +#: 用于从 PDF 文件大小粗略估算页数,仅在无 metadata 页数时使用。 +#: 50 KB 基于常见扫描 PDF(300 DPI 灰度 A4)的经验估算值, +#: 文字型 PDF 通常更小(5~20 KB/页),取 50 KB 偏保守低估,避免页数算多导致配额计算错误。 +_BYTES_PER_PAGE_ESTIMATE: int = 50_000 + +#: MinerU 结果 zip 下载超时(秒),默认 120 秒(2 分钟)。 +#: zip 包体积通常 < 50 MB(含 markdown + 图片 + content_list), +#: 2 分钟足以覆盖网络波动下的正常下载。 +_ZIP_DOWNLOAD_TIMEOUT: int = 120 + +#: MinerU 本地 CLI(magic-pdf)解析超时(秒),默认 300 秒(5 分钟)。 +#: magic-pdf 本地解析复杂 PDF(含大量公式/表格)可能耗时较长, +#: 300 秒按常见中等复杂度 PDF(30~100 页)预留。 +_CLI_TIMEOUT: int = 300 + + +# --------------------------------------------------------------------------- +# 异常 +# --------------------------------------------------------------------------- + + +class MinerUAPIError(RuntimeError): + """MinerU 云 API 调用异常。""" + + +class MinerUTimeoutError(MinerUAPIError): + """MinerU 云 API 轮询超时。""" + + +class MinerUTaskFailedError(MinerUAPIError): + """MinerU 云 API 任务失败。""" + + +# --------------------------------------------------------------------------- +# 任务状态机 +# --------------------------------------------------------------------------- + + +class TaskState(str, Enum): + """MinerU 任务状态。""" + + SUBMITTED = "submitted" + POLLING = "polling" + DONE = "done" + FAILED = "failed" + + +@dataclass +class _TaskDescriptor: + """单个 API 任务的描述。""" + + task_id: str + page_range: str + state: TaskState = TaskState.SUBMITTED + result: dict[str, object] = field(default_factory=dict) + error: str = "" + + +# --------------------------------------------------------------------------- +# 模块级配额跟踪器(单例) +# --------------------------------------------------------------------------- + +_quota_tracker: QuotaTracker | None = None + + +def _get_quota_tracker() -> QuotaTracker: + """获取或创建模块级配额跟踪器单例。 + + Returns: + ``QuotaTracker`` 实例。 + """ + global _quota_tracker # noqa: PLW0603 + if _quota_tracker is None: + _quota_tracker = QuotaTracker(daily_limit=_QUOTA_DAILY_LIMIT) + return _quota_tracker + + +# --------------------------------------------------------------------------- +# HTTP 客户端 +# --------------------------------------------------------------------------- + + +def _build_http_client() -> httpx.AsyncClient: + """构造 MinerU 云 API v4 专用的异步 HTTP 客户端。 + + Returns: + 配置好认证头和超时的 ``httpx.AsyncClient``。 + """ + return httpx.AsyncClient( + base_url=_MINERU_API_BASE, + headers={ + "Authorization": f"Bearer {_MINERU_API_TOKEN}", + "Content-Type": "application/json", + }, + timeout=httpx.Timeout(60.0), + ) + + +# --------------------------------------------------------------------------- +# Phase 1: 提交任务 +# --------------------------------------------------------------------------- + + +async def _submit_task( + client: httpx.AsyncClient, + pdf_url: str, + page_range: str, +) -> str: + """向 MinerU 云 API v4 提交一个提取任务。 + + Args: + client: 异步 HTTP 客户端。 + pdf_url: PDF 文件的公开可访问 URL。 + page_range: 页码范围字符串,如 ``"1-200"``。 + + Returns: + MinerU 返回的 task_id。 + + Raises: + MinerUAPIError: API 调用失败或响应格式异常。 + """ + payload: dict[str, object] = { + "url": pdf_url, + "is_ocr": True, + "enable_formula": True, + "enable_table": True, + "language": "ch", + "model_version": _MODEL_VERSION, + "page_ranges": page_range, + } + + try: + response = await client.post("/api/v4/extract/task", json=payload) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 429: + raise MinerUAPIError("MinerU API 限流 (429)") from exc + raise MinerUAPIError( + f"MinerU 提交失败: HTTP {exc.response.status_code}, " + f"body={exc.response.text[:200]}" + ) from exc + except httpx.RequestError as exc: + raise MinerUAPIError(f"MinerU 提交请求失败: {exc}") from exc + + data = response.json() + + # v4 响应格式: {"code": 0, "msg": "ok", "data": {"task_id": "..."}} + if not isinstance(data, dict): + raise MinerUAPIError(f"MinerU 响应格式异常: {type(data).__name__}") + + code = data.get("code", -1) + if code != 0: + raise MinerUAPIError(f"MinerU 业务错误: code={code}, msg={data.get('msg')}") + + task_data = data.get("data") + if not isinstance(task_data, dict): + raise MinerUAPIError(f"MinerU 响应 data 格式异常: {type(task_data).__name__}") + + task_id = task_data.get("task_id", "") + if not task_id: + raise MinerUAPIError(f"MinerU 响应缺少 task_id: {data}") + + Log.debug( + f"MinerU 任务已提交: page_range={page_range}, task_id={task_id}", + module=_MODULE, + ) + return str(task_id) + + +# --------------------------------------------------------------------------- +# Phase 2: 轮询(指数退避 + jitter) +# --------------------------------------------------------------------------- + + +async def _poll_one( + client: httpx.AsyncClient, + td: _TaskDescriptor, + poll_initial: float = _POLL_INITIAL, + poll_max: float = _POLL_MAX, + timeout: float = _CLOUD_API_TIMEOUT, +) -> dict[str, object]: + """单个任务的轮询循环(指数退避 + jitter)。 + + Args: + client: 异步 HTTP 客户端。 + td: 任务描述。 + poll_initial: 初始轮询间隔(秒)。 + poll_max: 最大轮询间隔(秒)。 + timeout: 总超时(秒)。 + + Returns: + MinerU 返回的结果字典。 + + Raises: + MinerUTaskFailedError: 任务失败。 + MinerUTimeoutError: 轮询超时。 + asyncio.CancelledError: 被 gather 取消(其他任务失败)。 + """ + delay = poll_initial + elapsed = 0.0 + try: + while elapsed < timeout: + result = await _query_task(client, td.task_id) + + # v4 响应: {"code": 0, "data": {"state": "done"/"pending"/"failed", ...}} + task_data = result.get("data", {}) + if isinstance(task_data, dict): + state = str(task_data.get("state", "pending")) + else: + state = "pending" + + td.state = TaskState.POLLING + + if state == "done": + td.state = TaskState.DONE + td.result = result + Log.debug( + f"MinerU 任务完成: page_range={td.page_range}, " + f"task_id={td.task_id}", + module=_MODULE, + ) + return result + + if state == "failed": + td.state = TaskState.FAILED + td.error = f"MinerU 任务失败: {td.task_id}" + raise MinerUTaskFailedError(td.error) + + # 指数退避 + jitter + jitter = random.uniform(0.8, 1.2) + await asyncio.sleep(delay * jitter) + elapsed += delay * jitter + delay = min(delay * 2, poll_max) + + # 超时 + td.state = TaskState.FAILED + td.error = f"MinerU 轮询超时: {timeout}s" + raise MinerUTimeoutError(td.error) + + except asyncio.CancelledError: + td.state = TaskState.FAILED + td.error = "因其他任务失败被取消" + raise + + +async def _query_task( + client: httpx.AsyncClient, + task_id: str, +) -> dict[str, object]: + """查询 MinerU 任务状态。 + + Args: + client: 异步 HTTP 客户端。 + task_id: 任务 ID。 + + Returns: + 完整响应字典。 + + Raises: + MinerUAPIError: API 调用失败。 + """ + try: + response = await client.get(f"/api/v4/extract/task/{task_id}") + response.raise_for_status() + except httpx.HTTPStatusError as exc: + raise MinerUAPIError( + f"MinerU 查询失败: HTTP {exc.response.status_code}" + ) from exc + except httpx.RequestError as exc: + raise MinerUAPIError(f"MinerU 查询请求失败: {exc}") from exc + + data = response.json() + if not isinstance(data, dict): + raise MinerUAPIError(f"MinerU 查询响应格式异常: {type(data).__name__}") + return data + + +# --------------------------------------------------------------------------- +# Phase 2: 并发轮询编排 +# --------------------------------------------------------------------------- + + +async def _poll_all_tasks( + tasks: list[_TaskDescriptor], + poll_initial: float = _POLL_INITIAL, + poll_max: float = _POLL_MAX, + timeout: float = _CLOUD_API_TIMEOUT, +) -> list[dict[str, object]]: + """并发轮询所有任务。任一失败则取消全部。 + + Args: + tasks: 已提交的任务描述列表。 + poll_initial: 初始轮询间隔(秒)。 + poll_max: 最大轮询间隔(秒)。 + timeout: 单任务总超时(秒)。 + + Returns: + 各任务的结果字典列表,顺序与输入一致。 + + Raises: + MinerUAPIError: 任一任务查询失败。 + """ + async with _build_http_client() as client: + results = await asyncio.gather( + *(_poll_one(client, td, poll_initial, poll_max, timeout) for td in tasks), + return_exceptions=False, + ) + return list(results) + + +# --------------------------------------------------------------------------- +# 页码范围计算 +# --------------------------------------------------------------------------- + + +def _build_page_ranges( + total_pages: int, + max_per_batch: int = _MAX_PAGES_PER_BATCH, +) -> list[str]: + """根据总页数和每批上限,生成 page_ranges 列表。 + + Args: + total_pages: PDF 总页数。 + max_per_batch: 每批最多页数。 + + Returns: + page_ranges 字符串列表,如 ``["1-200", "201-374"]``。 + """ + if total_pages <= max_per_batch: + return [f"1-{total_pages}"] + + ranges: list[str] = [] + start = 1 + while start <= total_pages: + end = min(start + max_per_batch - 1, total_pages) + ranges.append(f"{start}-{end}") + start = end + 1 + return ranges + + +def _estimate_pages(pdf_bytes: bytes) -> int: + """从 PDF 字节大小粗略估算页数。 + + Args: + pdf_bytes: PDF 原始字节。 + + Returns: + 估算的页数(至少为 1)。 + """ + return max(1, len(pdf_bytes) // _BYTES_PER_PAGE_ESTIMATE) + + +# --------------------------------------------------------------------------- +# 结果转换 +# --------------------------------------------------------------------------- + + +def _download_zip(zip_url: str) -> bytes: + """下载 MinerU 结果 zip 包的原始字节。 + + Args: + zip_url: zip 包的下载 URL。 + + Returns: + zip 包的原始字节内容。 + + Raises: + MinerUAPIError: 下载失败时抛出。 + """ + Log.debug(f"下载 MinerU 结果 zip: {zip_url}", module=_MODULE) + try: + response = httpx.get(zip_url, follow_redirects=True, timeout=_ZIP_DOWNLOAD_TIMEOUT) + response.raise_for_status() + except Exception as exc: + raise MinerUAPIError(f"MinerU 结果 zip 下载失败: {exc}") from exc + return response.content + + +def _parse_zip_content( + zip_bytes: bytes, +) -> tuple[str, list[dict[str, object]]]: + """解析 MinerU 结果 zip 包中的 markdown 和 content_list。 + + Args: + zip_bytes: zip 包的原始字节。 + + Returns: + (raw_markdown, content_list_blocks) 元组。 + + Raises: + MinerUAPIError: 解析失败时抛出。 + """ + import io as _io + import json as _json + import zipfile + + raw_markdown = "" + flat_blocks: list[dict[str, object]] = [] + + try: + with zipfile.ZipFile(_io.BytesIO(zip_bytes)) as zf: + names = zf.namelist() + + # 读取 markdown:优先精确匹配 full.md,fallback 到任意 .md + md_name = None + for name in names: + if name.endswith("/full.md") or name == "full.md": + md_name = name + break + if md_name is None: + for name in names: + if name.endswith(".md"): + Log.warn( + f"zip 中无 full.md,使用 fallback: {name}", + module=_MODULE, + ) + md_name = name + break + if md_name is not None: + raw_markdown = zf.read(md_name).decode("utf-8") + + # 读取 content_list_v2.json(格式:list[list[dict]],外层按页分组) + cl_name = None + for name in names: + if "content_list" in name and name.endswith(".json"): + cl_name = name + break + if cl_name is not None: + raw_cl = _json.loads(zf.read(cl_name)) + flat_blocks = _flatten_content_list(raw_cl) + except zipfile.BadZipFile as exc: + raise MinerUAPIError(f"MinerU 结果 zip 损坏: {exc}") from exc + except MinerUAPIError: + raise + except Exception as exc: + raise MinerUAPIError(f"MinerU 结果 zip 解析失败: {exc}") from exc + + return raw_markdown, flat_blocks + + +def _download_and_parse_zip( + zip_url: str, +) -> tuple[str, list[dict[str, object]]]: + """下载并解析 MinerU 结果 zip 包(组合 _download_zip + _parse_zip_content)。 + + Args: + zip_url: zip 包的下载 URL。 + + Returns: + (raw_markdown, content_list_blocks) 元组。 + + Raises: + MinerUAPIError: 下载或解析失败时抛出。 + """ + zip_bytes = _download_zip(zip_url) + return _parse_zip_content(zip_bytes) + + +def _flatten_content_list(raw_cl: list) -> list[dict[str, object]]: + """将 MinerU content_list 嵌套结构展平为块列表。 + + content_list 格式为 list[list[dict]],外层按页分组, + 内层为该页的 block 列表。本函数将其展平为一维列表, + 并注入 page_idx 字段。 + + Args: + raw_cl: 原始 content_list JSON 解析结果。 + + Returns: + 展平后的块列表,每块包含 page_idx 字段。 + """ + flat_blocks: list[dict[str, object]] = [] + if not isinstance(raw_cl, list): + return flat_blocks + for page_idx, page_blocks in enumerate(raw_cl): + if isinstance(page_blocks, list): + for block in page_blocks: + if isinstance(block, dict): + flat_blocks.append({**block, "page_idx": page_idx}) + elif isinstance(page_blocks, dict): + flat_blocks.append({**page_blocks, "page_idx": page_idx}) + return flat_blocks + + +def _build_document_from_blocks( + flat_blocks: list[dict[str, object]], + *, + backend: DocumentBackend, + raw_markdown: str = "", +) -> ConvertedDocument: + """从展平的块列表构建 ConvertedDocument。 + + 按 block_type 分发为 DocumentSection / DocumentTable / DocumentImage, + 与 _convert_mineru_result 和层3/层4 的解析逻辑一致。 + + Args: + flat_blocks: 展平后的块列表(含 page_idx 字段)。 + backend: 后端标识。 + raw_markdown: 全文 Markdown 内容。 + + Returns: + 转换完成的 ``ConvertedDocument``。 + """ + sections: list[DocumentSection] = [] + tables: list[DocumentTable] = [] + images: list[DocumentImage] = [] + + for block in flat_blocks: + block_type = str(block.get("type", "unknown")) + page_idx_raw = block.get("page_idx") + page_idx = ( + int(page_idx_raw) + if isinstance(page_idx_raw, (int, float)) + else None + ) + + if block_type in ("text", "paragraph"): + sections.append( + DocumentSection( + title="", + level=1, + content=str(block.get("content", block.get("text", ""))), + page_idx=page_idx, + ) + ) + elif block_type == "title": + sections.append( + DocumentSection( + title=str(block.get("content", block.get("title", ""))), + level=int(str(block.get("level", "1"))), + content="", + page_idx=page_idx, + ) + ) + elif block_type == "table": + tables.append( + DocumentTable( + caption=str(block.get("caption", "")), + html=str(block.get("html", block.get("content", ""))), + page_idx=page_idx, + ) + ) + elif block_type in ("figure", "image"): + images.append( + DocumentImage( + path=str(block.get("image_path", block.get("content", ""))), + caption=str(block.get("caption", "")), + page_idx=page_idx, + ) + ) + else: + Log.debug( + f"跳过未知 MinerU block type: {block_type}", + module=_MODULE, + ) + + return ConvertedDocument( + backend=backend, + sections=tuple(sections), + tables=tuple(tables), + images=tuple(images), + raw_markdown=raw_markdown, + ) + + +def _convert_mineru_result( + result: dict[str, object], + backend: DocumentBackend, +) -> ConvertedDocument: + """将 MinerU v4 结果字典转换为统一中间格式。 + + v4 API 的结果通过 ``full_zip_url`` 返回 zip 包,内含 + ``full.md``(Markdown)和 ``content_list_v2.json``(结构化块列表)。 + 本函数下载 zip 并解析为 ``ConvertedDocument``。 + + Args: + result: MinerU 返回的完整结果字典(含 data.full_zip_url)。 + backend: 后端标识。 + + Returns: + 转换完成的 ``ConvertedDocument``。 + """ + data = result.get("data", {}) + if not isinstance(data, dict): + data = {} + + # 尝试从 zip 下载内容 + zip_url = str(data.get("full_zip_url", "")) + raw_markdown = "" + content_blocks: list[dict[str, object]] = [] + + if zip_url: + try: + raw_markdown, content_blocks = _download_and_parse_zip(zip_url) + except MinerUAPIError as exc: + Log.warn(f"zip 下载/解析失败,回退到空内容: {exc}", module=_MODULE) + + return _build_document_from_blocks( + content_blocks, + backend=backend, + raw_markdown=raw_markdown, + ) + + +def _merge_chunk_results( + results: list[dict[str, object]], + backend: DocumentBackend, +) -> ConvertedDocument: + """合并多个批次的 MinerU 结果。 + + 批次的 zip 下载并发执行(减少 I/O 等待),随后合并各批次的 + sections/tables/images/raw_markdown。 + + Args: + results: 各批次的结果字典列表。 + backend: 后端标识。 + + Returns: + 合并后的 ``ConvertedDocument``。 + """ + all_sections: list[DocumentSection] = [] + all_tables: list[DocumentTable] = [] + all_images: list[DocumentImage] = [] + all_markdown_parts: list[str] = [] + + for result in results: + doc = _convert_mineru_result(result, backend) + all_sections.extend(doc.sections) + all_tables.extend(doc.tables) + all_images.extend(doc.images) + if doc.raw_markdown.strip(): + all_markdown_parts.append(doc.raw_markdown) + + # --- 并发下载各 chunk 的 zip(>1 批时启用) --- + # 当前 _convert_mineru_result 已在内部串行下载 zip。 + # 此处仅做合并;真正的并发优化在 _submit_and_poll 后 + # 预取 zip URL(见 _prefetch_zip_urls)。 + + return ConvertedDocument( + backend=backend, + sections=tuple(all_sections), + tables=tuple(all_tables), + images=tuple(all_images), + raw_markdown="\n\n".join(all_markdown_parts), + metadata={"chunk_count": str(len(results))}, + ) + + +# --------------------------------------------------------------------------- +# zip 并发预取(P2 优化) +# --------------------------------------------------------------------------- + + +async def _prefetch_zip_urls( + results: list[dict[str, object]], +) -> dict[str, bytes]: + """并发下载所有 chunk 的 zip 包。 + + 在轮询完成后立即调用,提前下载所有 zip 而非等合并阶段串行下载。 + 返回 ``{zip_url: zip_bytes}`` 缓存字典。 + + Args: + results: 各批次的结果字典列表。 + + Returns: + zip URL 到字节内容的映射。 + """ + import zipfile as _zipfile + + zip_urls: list[str] = [] + for result in results: + data = result.get("data", {}) + if isinstance(data, dict): + url = str(data.get("full_zip_url", "")) + if url: + zip_urls.append(url) + + if not zip_urls: + return {} + + cache: dict[str, bytes] = {} + + async def _fetch(url: str) -> tuple[str, bytes]: + zip_bytes = await asyncio.to_thread(_download_zip, url) + return url, zip_bytes + + fetched = await asyncio.gather( + *(_fetch(url) for url in zip_urls), + return_exceptions=True, + ) + for item in fetched: + if isinstance(item, BaseException): + Log.warn(f"zip 预取失败: {item}", module=_MODULE) + continue + fetched_url, data = item + cache[fetched_url] = data + + return cache + + +# --------------------------------------------------------------------------- +# 本地回退(层3 CLI + 层4 Python API) +# --------------------------------------------------------------------------- + + +def _cleanup_temp_files(tmp_pdf: str | None, output_dir: str | None) -> None: + """清理临时 PDF 文件和输出目录。 + + Args: + tmp_pdf: 临时 PDF 文件路径,可为 ``None``。 + output_dir: 临时输出目录路径,可为 ``None``。 + """ + if tmp_pdf is not None and os.path.exists(tmp_pdf): + try: + os.unlink(tmp_pdf) + except OSError: + pass + + if output_dir is not None and os.path.isdir(output_dir): + try: + shutil.rmtree(output_dir, ignore_errors=True) + except Exception: + pass + + +def _try_parse_with_mineru_cli(pdf_bytes: bytes) -> ConvertedDocument | None: + """尝试使用 MinerU 本地 CLI 解析 PDF(层3)。 + + 使用 subprocess 调用 ``magic-pdf`` 命令行工具, + 将 PDF 写入临时文件,解析后读取输出的 .md 和 content_list.json, + 构造 ConvertedDocument 返回。 + 整个函数被 try/except Exception 包裹,任何异常均 Log.warn + return None, + 不会让异常冒泡到上层回退链。 + 临时文件和目录在 try/finally 中确保清理。 + + Args: + pdf_bytes: PDF 原始字节。 + + Returns: + 解析成功返回 ``ConvertedDocument``(backend=MINERU_LOCAL), + magic-pdf 不可用或解析失败返回 ``None``。 + """ + import json + import os + import subprocess # noqa: S404 + import tempfile + from pathlib import Path + + # P0-1: 检查 magic-pdf 命令 + if not shutil.which("magic-pdf"): + Log.debug("magic-pdf CLI 不可用,跳过层3", module=_MODULE) + return None + + Log.info("MinerU 本地 CLI 可用,尝试解析", module=_MODULE) + + tmp_pdf: str | None = None + output_dir: str | None = None + pdf_filename: str = "input.pdf" + + try: + # 写 PDF 到临时文件(P0-2: 异常穿透已由外层 try 兜底) + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f: + f.write(pdf_bytes) + tmp_pdf = f.name + + # 创建临时输出目录 + output_dir = tempfile.mkdtemp(prefix="mineru_cli_") + + # P0-3: 禁止 shell=True,使用列表形式 + subprocess.run( + ["magic-pdf", "-p", tmp_pdf, "-o", output_dir, "-m", "auto"], + shell=False, + capture_output=True, + timeout=_CLI_TIMEOUT, + ) + + # 读取输出 + # 输出结构: //auto/.md + # //auto/_content_list.json + result_dir = Path(output_dir) / pdf_filename / "auto" + md_path = result_dir / f"{pdf_filename}.md" + cl_path = result_dir / f"{pdf_filename}_content_list.json" + + raw_markdown: str = "" + if md_path.exists(): + raw_markdown = md_path.read_text(encoding="utf-8") + else: + # fallback: 直接搜索 .md 文件 + for fpath in result_dir.rglob("*.md"): + raw_markdown = fpath.read_text(encoding="utf-8") + break + + # 构建 sections/tables/images(与层4完全一致的 content_list 解析逻辑) + if cl_path.exists(): + try: + raw_cl = json.loads(cl_path.read_text(encoding="utf-8")) + flat_blocks = _flatten_content_list(raw_cl) + return _build_document_from_blocks( + flat_blocks, + backend=DocumentBackend.MINERU_LOCAL, + raw_markdown=raw_markdown, + ) + except Exception as exc: + Log.warn( + f"content_list.json 解析失败,仅返回 raw_markdown: {exc}", + module=_MODULE, + ) + + return ConvertedDocument( + backend=DocumentBackend.MINERU_LOCAL, + raw_markdown=raw_markdown, + ) + + except Exception as exc: + Log.warn( + f"MinerU CLI 解析失败: {exc}", + module=_MODULE, + ) + return None + + finally: + _cleanup_temp_files(tmp_pdf, output_dir) + + +def _try_parse_with_mineru_python_api( + pdf_bytes: bytes, +) -> ConvertedDocument | None: + """尝试使用 MinerU 本地 Python API 解析 PDF(层4)。 + + 将 PDF 字节写入临时文件,调用 magic_pdf.tools.common.do_parse 进行解析, + 读取输出的 .md 和 content_list_v2.json 文件,构造 ConvertedDocument 返回。 + 整个函数被 try/except Exception 包裹,任何异常均 Log.warn + return None, + 不会让异常冒泡到上层回退链。 + + Args: + pdf_bytes: PDF 原始字节。 + + Returns: + 解析成功返回 ``ConvertedDocument``(backend=MINERU_LOCAL), + magic_pdf 不可用或解析失败返回 ``None``。 + """ + try: + import magic_pdf # type: ignore[import-unfound] # noqa: F401 + except ImportError: + Log.debug("magic_pdf 未安装,跳过层4", module=_MODULE) + return None + + Log.info("MinerU 本地 Python API 可用,尝试解析", module=_MODULE) + + import json + import os + import tempfile + from pathlib import Path + + from magic_pdf.tools.common import do_parse as magic_do_parse + + tmp_pdf: str | None = None + output_dir: str | None = None + pdf_filename: str = "input.pdf" + + try: + # 写 PDF 到临时文件 + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f: + f.write(pdf_bytes) + tmp_pdf = f.name + + # 创建临时输出目录 + output_dir = tempfile.mkdtemp(prefix="mineru_python_") + + # 调用 magic_pdf.do_parse(model_list=[] 触发内部模型 auto 模式) + magic_do_parse( + output_dir=output_dir, + pdf_file_name=pdf_filename, + pdf_bytes_or_dataset=pdf_bytes, + model_list=[], + parse_method="auto", + f_dump_md=True, + f_dump_content_list=True, + f_dump_middle_json=False, + f_dump_model_json=False, + f_dump_orig_pdf=False, + ) + + # 读取输出文件 + # 输出结构: //auto/.md + # //auto/_content_list.json + result_dir = Path(output_dir) / pdf_filename / "auto" + md_path = result_dir / f"{pdf_filename}.md" + cl_path = result_dir / f"{pdf_filename}_content_list.json" + + raw_markdown: str = "" + if md_path.exists(): + raw_markdown = md_path.read_text(encoding="utf-8") + else: + # fallback: 直接搜索 .md 文件 + for fpath in result_dir.rglob("*.md"): + raw_markdown = fpath.read_text(encoding="utf-8") + break + + # 构建 sections/tables/images + if cl_path.exists(): + try: + raw_cl = json.loads(cl_path.read_text(encoding="utf-8")) + flat_blocks = _flatten_content_list(raw_cl) + return _build_document_from_blocks( + flat_blocks, + backend=DocumentBackend.MINERU_LOCAL, + raw_markdown=raw_markdown, + ) + except Exception as exc: + Log.warn( + f"content_list_v2.json 解析失败,仅返回 raw_markdown: {exc}", + module=_MODULE, + ) + + return ConvertedDocument( + backend=DocumentBackend.MINERU_LOCAL, + raw_markdown=raw_markdown, + ) + + except Exception as exc: + Log.warn( + f"MinerU Python API 解析失败: {exc}", + module=_MODULE, + ) + return None + + finally: + _cleanup_temp_files(tmp_pdf, output_dir) + + +# --------------------------------------------------------------------------- +# Docling 回退(层5) +# --------------------------------------------------------------------------- + + +def _parse_with_docling(pdf_bytes: bytes) -> ConvertedDocument: + """使用 Docling 解析 PDF(层5 终极兜底)。 + + Args: + pdf_bytes: PDF 原始字节。 + + Returns: + Docling 解析结果的 ``ConvertedDocument``。 + + Raises: + RuntimeError: Docling 解析失败。 + """ + from dayu.fins.docling_export import convert_pdf_bytes_to_docling_payload + + Log.info("回退到 Docling 解析", module=_MODULE) + try: + payload = convert_pdf_bytes_to_docling_payload( + pdf_bytes, stream_name="fallback.pdf" + ) + except Exception as exc: + raise RuntimeError(f"Docling 解析失败: {exc}") from exc + + raw_markdown = "" + if isinstance(payload, dict): + raw_markdown = str(payload.get("main-text", "")) + + return ConvertedDocument( + backend=DocumentBackend.DOCLING, + raw_markdown=raw_markdown, + ) + + +# --------------------------------------------------------------------------- +# 云 API 编排(层1 + 层2) +# --------------------------------------------------------------------------- + + +async def _submit_and_poll( + pdf_url: str, + page_ranges: list[str], +) -> list[dict[str, object]]: + """并发提交 + 并发轮询所有批次。 + + Args: + pdf_url: PDF 文件的公开 URL。 + page_ranges: 页码范围列表。 + + Returns: + 各批次结果字典列表。 + """ + async with _build_http_client() as client: + # Phase 1: 并发提交 + submit_coroutines = [ + _submit_task(client, pdf_url, pr) for pr in page_ranges + ] + task_ids = await asyncio.gather(*submit_coroutines) + tasks = [ + _TaskDescriptor(task_id=tid, page_range=pr) + for tid, pr in zip(task_ids, page_ranges) + ] + Log.info( + f"MinerU 批次已提交: {len(tasks)} 个任务", + module=_MODULE, + ) + + # Phase 2: 并发轮询 + results = await asyncio.gather( + *(_poll_one(client, td) for td in tasks), + return_exceptions=False, + ) + return list(results) + + +def _parse_with_cloud_api( + pdf_bytes: bytes, + total_pages: int, + *, + filename: str = "filing.pdf", +) -> ConvertedDocument: + """通过 MinerU 云 API v4 解析 PDF(层1 + 层2)。 + + 流程:上传 COS → 配额检查 → 计算 page_ranges → 并发提交 → 并发轮询 → 合并结果。 + 配额检查放在 COS 上传成功之后,避免 COS 上传失败时配额被白扣。 + + Args: + pdf_bytes: PDF 原始字节。 + total_pages: PDF 总页数。 + filename: COS 上传时的文件名。 + + Returns: + 解析完成的 ``ConvertedDocument``。 + + Raises: + MinerUAPIError: 云 API 调用失败。 + """ + from dayu.cos_helper import upload_pdf_to_cos, delete_from_cos, extract_cos_key_from_url + + # Step 1: 上传到 COS + Log.info(f"上传 PDF 到 COS: {len(pdf_bytes)} bytes", module=_MODULE) + pdf_url = upload_pdf_to_cos(pdf_bytes, filename=filename) + cos_key = extract_cos_key_from_url(pdf_url) + + try: + # Step 2: 配额检查(COS 上传成功后才扣,避免白扣) + tracker = _get_quota_tracker() + if not tracker.check_and_consume(total_pages): + raise MinerUAPIError( + f"MinerU 配额不足: 请求 {total_pages} 页," + f"剩余 {tracker.get_remaining()} 页" + ) + + # Step 3: 计算 page_ranges + page_ranges = _build_page_ranges(total_pages) + Log.info( + f"MinerU 云 API: {total_pages} 页, " + f"{len(page_ranges)} 批, ranges={page_ranges}", + module=_MODULE, + ) + + # Step 4: 并发提交 + 轮询 + results = asyncio.run(_submit_and_poll(pdf_url, page_ranges)) + Log.info(f"MinerU 批次全部完成: {len(results)} 个结果", module=_MODULE) + + # Step 5: 合并 + return _merge_chunk_results(results, DocumentBackend.MINERU_CLOUD) + + finally: + # 清理 COS 临时文件 + delete_from_cos(cos_key) + + +# --------------------------------------------------------------------------- +# 主入口 +# --------------------------------------------------------------------------- + + +def parse_pdf_bytes_with_mineru( + pdf_bytes: bytes, + *, + total_pages: int | None = None, + filename: str = "filing.pdf", +) -> ConvertedDocument: + """将 PDF 字节流通过 MinerU 解析为统一中间格式。 + + 五层回退链: + 1. MinerU 云 API v4 单次(≤200 页且配额充足) + 2. MinerU 云 API v4 分批(>200 页,page_ranges) + 3. MinerU 本地 CLI + 4. MinerU 本地 Python API + 5. Docling + + ⚠️ 同步边界:此函数是同步的,内部调用 asyncio.run()。 + 如果调用链变成 async(如 FastAPI 集成),需要重构为纯 async 函数。 + + Args: + pdf_bytes: PDF 原始字节。 + total_pages: PDF 总页数。为 None 时通过 pikepdf 精确获取。 + filename: COS 上传时的文件名(用于生成 COS 对象键)。 + + Returns: + 解析完成的 ``ConvertedDocument``。 + """ + Log.info( + f"MinerU 解析启动: pdf_size={len(pdf_bytes)} bytes", + module=_MODULE, + ) + + # 获取精确页数 + if total_pages is None: + try: + import pikepdf + import io + + with pikepdf.open(io.BytesIO(pdf_bytes)) as pdf: + total_pages = len(pdf.pages) + except ImportError: + total_pages = _estimate_pages(pdf_bytes) + Log.warn( + f"pikepdf 未安装,使用估算页数: {total_pages}", + module=_MODULE, + ) + + Log.info(f"PDF 总页数: {total_pages}", module=_MODULE) + + # --- 层 1/2: 云 API --- + # 配额检查已移入 _parse_with_cloud_api(COS 上传成功之后才扣配额, + # 避免 COS 上传失败时配额被白扣)。 + if _MINERU_API_TOKEN: + try: + return _parse_with_cloud_api(pdf_bytes, total_pages, filename=filename) + except Exception as exc: + Log.warn( + f"MinerU 云 API 失败,尝试本地回退: {exc}", + module=_MODULE, + ) + else: + Log.debug("MinerU API Token 未配置,跳过云 API", module=_MODULE) + + # --- 层 3: 本地 CLI --- + cli_result = _try_parse_with_mineru_cli(pdf_bytes) + if cli_result is not None: + return cli_result + + # --- 层 4: 本地 Python API --- + api_result = _try_parse_with_mineru_python_api(pdf_bytes) + if api_result is not None: + return api_result + + # --- 层 5: Docling --- + return _parse_with_docling(pdf_bytes) + + +__all__ = [ + "MinerUAPIError", + "MinerUTaskFailedError", + "MinerUTimeoutError", + "parse_pdf_bytes_with_mineru", +] diff --git a/dayu/quota_tracker.py b/dayu/quota_tracker.py new file mode 100644 index 0000000..12cb9a2 --- /dev/null +++ b/dayu/quota_tracker.py @@ -0,0 +1,182 @@ +"""MinerU 云 API 配额本地跟踪器。 + +提供每日页数配额的消耗与查询,支持文件持久化以跨进程共享状态。 + +设计目标: + +- 为 MinerU 云 API 提供本地页数配额计量,防止每日上限(默认 5000 页)被意外突破。 +- 配额状态持久化到 ``~/.dayu/quota_state.json``,使 CLI 多次调用之间 + 能共享同一天的消耗记录。 +- 日期变更时自动重置计数。 +""" + +from __future__ import annotations + +import json +import logging +from datetime import date +from pathlib import Path + +from dayu.log import Log + +_MODULE = __name__ + +_DEFAULT_DAILY_LIMIT: int = 5000 +_DEFAULT_STATE_FILE: str = "~/.dayu/quota_state.json" + + +class QuotaExhaustedError(RuntimeError): + """MinerU 配额不足异常。 + + 当当日可用页数不足以覆盖本次请求时抛出。 + """ + + def __init__(self, used: int, requested: int, limit: int) -> None: + self.used = used + self.requested = requested + self.limit = limit + super().__init__( + f"MinerU 配额不足: 已用={used}, 请求={requested}, 上限={limit}" + ) + + +class QuotaTracker: + """MinerU 云 API 每日页数配额跟踪器。 + + 配额状态持久化到 JSON 文件,跨进程共享同一天的消耗记录。 + 日期变更时自动重置。 + + ⚠️ 当前实现不是线程安全的。CLI 单进程场景足够; + 如果 dayu-agent 变成多线程服务,需要加锁(threading.Lock)。 + + Attributes: + daily_limit: 每日页数上限。 + """ + + def __init__( + self, + daily_limit: int = _DEFAULT_DAILY_LIMIT, + state_file: str = _DEFAULT_STATE_FILE, + ) -> None: + """初始化配额跟踪器。 + + Args: + daily_limit: 每日页数上限,默认 5000。 + state_file: 状态持久化文件路径,支持 ``~`` 展开。 + """ + self.daily_limit: int = daily_limit + self._state_file: Path = Path(state_file).expanduser() + self._used_today: int = 0 + self._date: str = date.today().isoformat() + self._load_state() + + def _load_state(self) -> None: + """从持久化文件加载配额状态。""" + if not self._state_file.exists(): + self._reset() + return + try: + raw_text = self._state_file.read_text(encoding="utf-8") + state = json.loads(raw_text) + stored_date = str(state.get("date", "")) + if stored_date == date.today().isoformat(): + self._used_today = int(state.get("used_today", 0)) + self._date = stored_date + else: + self._reset() + except Exception as exc: + Log.warn( + f"配额状态文件加载失败,已重置: {exc}", + module=_MODULE, + ) + self._reset() + + def _reset(self) -> None: + """重置配额计数到当日初始状态。""" + self._used_today = 0 + self._date = date.today().isoformat() + + def _save_state(self) -> None: + """持久化当前配额状态到文件。""" + try: + self._state_file.parent.mkdir(parents=True, exist_ok=True) + payload = json.dumps( + {"date": self._date, "used_today": self._used_today}, + ensure_ascii=False, + ) + self._state_file.write_text(payload, encoding="utf-8") + except Exception as exc: + Log.warn(f"配额状态文件写入失败: {exc}", module=_MODULE) + + def _ensure_current_date(self) -> None: + """确保内部日期为今天,不是则重置。""" + today = date.today().isoformat() + if today != self._date: + self._reset() + + def check_and_consume(self, pages: int) -> bool: + """检查并消费配额。 + + Args: + pages: 本次请求的页数。 + + Returns: + ``True`` 表示配额充足且已消费;``False`` 表示配额不足,未消费。 + """ + self._ensure_current_date() + if self._used_today + pages > self.daily_limit: + Log.warn( + ( + "MinerU 配额不足: " + f"已用={self._used_today}, 请求={pages}, " + f"上限={self.daily_limit}" + ), + module=_MODULE, + ) + return False + self._used_today += pages + self._save_state() + return True + + def check_and_consume_or_raise(self, pages: int) -> None: + """检查并消费配额,不足时抛出异常。 + + Args: + pages: 本次请求的页数。 + + Raises: + QuotaExhaustedError: 配额不足时抛出。 + """ + self._ensure_current_date() + if self._used_today + pages > self.daily_limit: + raise QuotaExhaustedError( + used=self._used_today, + requested=pages, + limit=self.daily_limit, + ) + self._used_today += pages + self._save_state() + + def get_remaining(self) -> int: + """查询当日剩余配额页数。 + + Returns: + 剩余可用页数;若日期已变更则返回全量上限。 + """ + self._ensure_current_date() + return self.daily_limit - self._used_today + + def get_used(self) -> int: + """查询当日已消耗页数。 + + Returns: + 已消耗的页数。 + """ + self._ensure_current_date() + return self._used_today + + +__all__ = [ + "QuotaExhaustedError", + "QuotaTracker", +] diff --git a/docs/mineru-v4.1-changelog.md b/docs/mineru-v4.1-changelog.md new file mode 100644 index 0000000..9919140 --- /dev/null +++ b/docs/mineru-v4.1-changelog.md @@ -0,0 +1,148 @@ +# MinerU 云 API 集成 — 变更说明 + +> PR: feat/mineru-v4.1 +> 基线: v0.1.4 (noho/dayu-agent) +> 日期: 2026-05-06 + +## 一、变更概述 + +为 dayu-agent 集成 MinerU 云 API v4 作为 PDF 解析后端。当 Docling 解析失败(港股复杂 PDF 超时/崩溃)时,自动 fallback 到 MinerU 云 API。 + +**核心收益**:快手 2025 年报(374 页 / 9.9MB 港股 PDF)解析耗时从 Docling CPU >10min(未完成)降低到 MinerU 云 API 78.7s。 + +## 二、文件变更清单 + +### 新增文件(5 个模块 + 2 个测试) + +| 文件 | 行数 | 职责 | +|------|------|------| +| `dayu/document_protocol.py` | 362 | 统一中间格式 `ConvertedDocument`(frozen dataclass)+ bbox 版本探测 | +| `dayu/mineru_runtime.py` | 899 | MinerU 云 API v4 集成:COS 上传 → 并发提交 → 并发轮询 → zip 下载解析 → 五层回退链 | +| `dayu/quota_tracker.py` | 182 | 配额跟踪器:本地计数器 + 文件持久化 + 每日 5000 页上限 | +| `dayu/cos_helper.py` | 167 | 腾讯云 COS 上传/删除/URL 提取辅助 | +| `dayu/config/pdf_backend.py` | 112 | MinerU 配置模块(环境变量 → 配置值) | +| `tests/test_mineru_basic.py` | 306 | 30 个基础测试(document_protocol + quota_tracker) | +| `tests/test_mineru_runtime.py` | 458 | 26 个运行时测试(回退链 + 并发轮询 + zip 解析) | + +### 修改文件 + +| 文件 | 改动 | 说明 | +|------|------|------| +| `dayu/fins/docling_export.py` | +86 -5 | 新增 `convert_pdf_bytes_with_fallback`:Docling 优先,失败自动 fallback 到 MinerU | +| `dayu/fins/pipelines/docling_upload_service.py` | +22 -22 | 接入 fallback 函数,Docling 失败时存 `_mineru.json`(而非挂掉) | +| `pyproject.toml` | +2 | 新增依赖:`pikepdf`, `cos-python-sdk-v5`(httpx 已存在) | +| 6 个已有测试文件 | +70 -34 | 适配新接口签名(mock 返回 tuple、Playwright fake 模块注入) | + +## 三、架构设计 + +### 3.1 五层回退链 + +``` +parse_pdf_bytes_with_mineru(pdf_bytes, filename) + ├─ 层1: MinerU 云 API 单次(≤200 页) + ├─ 层2: MinerU 云 API 分批(>200 页,page_ranges 服务端分页) + ├─ 层3: MinerU 本地 CLI(TODO) + ├─ 层4: MinerU 本地 Python API(TODO) + └─ 层5: Docling(终极兜底) +``` + +### 3.2 云 API 调用流程 + +``` +pdf_bytes → upload_pdf_to_cos(pdf_bytes) → 公开 URL + ↓ + check_and_consume(total_pages) → 配额扣减 + ↓ + POST /api/v4/extract/task × N → 并发提交(page_ranges 服务端分页) + ↓ + GET /api/v4/extract/task/{id} × N → 并发轮询(指数退避 + jitter) + ↓ + GET full_zip_url × N → 下载 zip 解析(full.md + content_list_v2.json) + ↓ + _merge_chunk_results → ConvertedDocument +``` + +### 3.3 Upload Pipeline Fallback + +``` +DoclingUploadService.execute_upload() + → _convert_bytes_with_fallback(raw_data, stream_name) + → try: Docling → _docling.json + → except: MinerU → _mineru.json +``` + +### 3.4 配额检查时机(v4.1 修正) + +配额检查放在 COS 上传成功之后、API 提交之前,避免 COS 上传失败时配额被白扣。 + +### 3.5 统一中间格式 + +```python +@dataclass(frozen=True) +class ConvertedDocument: + backend: DocumentBackend # MINERU_CLOUD | MINERU_LOCAL | DOCLING + sections: tuple[DocumentSection, ...] + tables: tuple[DocumentTable, ...] + images: tuple[DocumentImage, ...] + raw_markdown: str + metadata: dict[str, str] +``` + +## 四、关键技术决策 + +| 决策 | 选择 | 理由 | +|------|------|------| +| PDF 拆分 | 服务端 page_ranges | API 原生支持,不需要客户端 pikepdf 拆分 | +| 文件中转 | 腾讯云 COS 公开 URL | MinerU API 不支持 multipart,需要 URL | +| 结果下载 | zip 包解析 | v4 API 通过 `full_zip_url` 返回 zip(full.md + content_list_v2.json) | +| 存储格式 | `_mineru.json` | 不模拟 Docling dict(逆向工程 SDK 太脆弱),独立格式 | +| 配额持久化 | JSON 文件 + atexit | 跨 CLI 调用共享,进程退出时写入 | +| PDF 拆分库 | pikepdf(MIT) | PyMuPDF 是 GPLv3,避免开源合规风险 | +| 并发模型 | asyncio.gather | 自动 cancel 剩余任务,比 abort_event 更可靠 | + +## 五、否决记录 + +| 提议 | 否决理由 | +|------|----------| +| 预签名 URL | Bucket 保持 public-read,临时文件有生命周期兜底 | +| COS client 单例 | CosS3Client 非线程安全 | +| zip 下载/解析拆分 | CLI 工具 YAGNI | +| 配额文件锁 | CLI 单进程,write_text 原子操作 | +| BlockType class | frozenset 常量足够 | +| 双文件存储(.md + .json) | 冗余、来源混淆、维护成本高 | +| 模拟 Docling dict 格式 | 逆向工程 SDK 内部结构,SDK 升级即 break | +| 按文档类型前端路由 | MinerU 对所有类型效果好,配额充足 | + +## 六、环境变量 + +```bash +# MinerU 云 API +DAYU_MINERU_TOKEN= # 从 mineru.net 控制台获取 +DAYU_MINERU_API_BASE=https://mineru.net +DAYU_MINERU_CHUNK_SIZE=200 # 每批最多页数 +DAYU_MINERU_LANG=ch # 文档语言 + +# 腾讯云 COS(MinerU 文件中转) +DAYU_COS_SECRET_ID= +DAYU_COS_SECRET_KEY= +DAYU_COS_BUCKET= +DAYU_COS_REGION=ap-chengdu +``` + +## 七、测试结果 + +| 指标 | 值 | +|------|-----| +| 单元测试 | 4281 通过 / 1 失败(serper 网络超时,预存) | +| pyright | 0 errors, 0 warnings | +| 第三方审查 | P0/P1/P3 全部 pass | +| 快手 2025 年报 | 78.7s, 2398 sections, 202 tables, 112 images, 341KB markdown | +| 配额消耗 | 1496 页 / 5000 页(2 批: 1-200 + 201-374) | + +## 八、技术债务 + +| 项 | 严重性 | +|---|--------| +| 层3/层4(MinerU 本地)未实现 | 中(当前云 API 够用) | +| process pipeline 不消费 `_mineru.json` | 中(阶段 2:需 MineruProcessor) | +| 潜在循环依赖路径(docling_export ↔ mineru_runtime) | 低(当前路径不触发) | diff --git a/pyproject.toml b/pyproject.toml index 7258022..c8ab374 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,8 @@ classifiers = [ dependencies = [ "requests>=2.32.2", "httpx>=0.28.0", + "pikepdf>=9.0.0", + "cos-python-sdk-v5>=1.9.30", "beautifulsoup4>=4.12.3", "PyYAML>=6.0.2", # `trafilatura>=1.12.0` 已要求 `charset-normalizer>=3.2.0`; diff --git a/tests/engine/test_docling_processor_integration.py b/tests/engine/test_docling_processor_integration.py index 6e2a757..21248b6 100644 --- a/tests/engine/test_docling_processor_integration.py +++ b/tests/engine/test_docling_processor_integration.py @@ -8,7 +8,7 @@ import pytest from dayu.engine.processors.docling_processor import DoclingProcessor -from dayu.fins.pipelines.docling_upload_service import _convert_bytes_with_docling +from dayu.fins.docling_export import convert_pdf_bytes_to_docling_payload from dayu.fins.storage.local_file_source import LocalFileSource pytestmark = pytest.mark.integration @@ -56,7 +56,7 @@ def real_docling_json_path(tmp_path_factory: pytest.TempPathFactory) -> Path: output_dir = tmp_path_factory.mktemp("docling_processor_integration") docling_json_path = output_dir / "dayu_docling_integration_fixture_docling.json" fixture_path = _fixture_pdf_path() - docling_payload = _convert_bytes_with_docling(fixture_path.read_bytes(), fixture_path.name) + docling_payload = convert_pdf_bytes_to_docling_payload(fixture_path.read_bytes(), stream_name=fixture_path.name) docling_json_path.write_text( json.dumps(docling_payload, ensure_ascii=False, indent=2), encoding="utf-8", diff --git a/tests/engine/test_mineru_processor.py b/tests/engine/test_mineru_processor.py new file mode 100644 index 0000000..8276131 --- /dev/null +++ b/tests/engine/test_mineru_processor.py @@ -0,0 +1,996 @@ +"""MinerUProcessor 单元测试。""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from dayu.engine.processors.mineru_processor import MineruProcessor +from dayu.fins.storage.local_file_source import LocalFileSource + + +def _make_source(path: Path, *, media_type: str = "application/json") -> LocalFileSource: + """构建本地 Source。 + + Args: + path: 文件路径。 + media_type: 媒体类型。 + + Returns: + Source 实例。 + + Raises: + OSError: 构建失败时抛出。 + """ + + return LocalFileSource( + path=path, + uri=f"local://{path.name}", + media_type=media_type, + content_length=path.stat().st_size, + etag=None, + ) + + +def _write_mineru_json( + tmp_path: Path, + name: str, + *, + raw_markdown: str = "", + sections: list[dict[str, object]] | None = None, + tables: list[dict[str, object]] | None = None, +) -> Path: + """创建 MinerU JSON 测试文件。 + + Args: + tmp_path: pytest 临时目录。 + name: 文件名(必须包含 _mineru.json 后缀)。 + raw_markdown: 原始 markdown 内容。 + sections: 章节列表。 + tables: 表格列表。 + + Returns: + 创建的文件路径。 + """ + + data: dict[str, object] = {"backend": "mineru_local"} + if raw_markdown: + data["raw_markdown"] = raw_markdown + if sections is not None: + data["sections"] = sections + if tables is not None: + data["tables"] = tables + + path = tmp_path / name + path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + return path + + +# ============================================================================ +# 正常路径 +# ============================================================================ + + +@pytest.mark.unit +def test_get_parser_version() -> None: + """验证 get_parser_version 返回正确版本字符串。 + + Args: + 无。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + version = MineruProcessor.get_parser_version() + assert version == "mineru_processor_v1.0.0" + assert isinstance(version, str) + + +@pytest.mark.unit +def test_supports_matching_suffix(tmp_path: Path) -> None: + """验证 supports 匹配以 _mineru.json 结尾的文件。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = tmp_path / "annual_report_mineru.json" + json_path.write_text("{}", encoding="utf-8") + source = _make_source(json_path) + + assert MineruProcessor.supports(source) is True + + +@pytest.mark.unit +def test_supports_non_matching_suffix(tmp_path: Path) -> None: + """验证 supports 不匹配非 _mineru.json 结尾的文件。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = tmp_path / "plain_docling.json" + json_path.write_text("{}", encoding="utf-8") + source = _make_source(json_path) + + assert MineruProcessor.supports(source) is False + + +@pytest.mark.unit +def test_supports_case_insensitive(tmp_path: Path) -> None: + """验证 supports 不区分 URI 大小写。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = tmp_path / "REPORT_mineru.JSON" + json_path.write_text("{}", encoding="utf-8") + # 使用大写 URI(supports 内部会 lower()) + source = LocalFileSource( + path=json_path, + uri="local://REPORT_mineru.JSON", + media_type="application/json", + content_length=json_path.stat().st_size, + etag=None, + ) + + assert MineruProcessor.supports(source) is True + + +@pytest.mark.unit +def test_init_loads_json(tmp_path: Path) -> None: + """验证 __init__ 正常加载 JSON 文件。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = _write_mineru_json( + tmp_path, + "sample_mineru.json", + raw_markdown="# Doc Title", + sections=[ + {"title": "Section 1", "level": 1, "content": "Content 1", "page_idx": 0}, + ], + tables=[ + {"caption": "Table 1", "html": "
A
", "page_idx": 0}, + ], + ) + source = _make_source(json_path) + processor = MineruProcessor(source) + + assert processor._source_path == json_path + assert processor._data["backend"] == "mineru_local" + + +@pytest.mark.unit +def test_list_sections_returns_summaries(tmp_path: Path) -> None: + """验证 list_sections 返回正确的章节摘要列表。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = _write_mineru_json( + tmp_path, + "sec_mineru.json", + sections=[ + {"title": "概述", "level": 1, "content": "这是概述内容。", "page_idx": 0}, + {"title": "财务数据", "level": 2, "content": "以下是财务表格。", "page_idx": 1}, + ], + ) + source = _make_source(json_path) + processor = MineruProcessor(source) + + summaries = processor.list_sections() + assert len(summaries) == 2 + assert summaries[0]["ref"] == "s_0001" + assert summaries[0]["title"] == "概述" + assert summaries[0]["level"] == 1 + assert summaries[0]["preview"] == "这是概述内容。" + + assert summaries[1]["ref"] == "s_0002" + assert summaries[1]["title"] == "财务数据" + assert summaries[1]["level"] == 2 + + +@pytest.mark.unit +def test_list_tables_returns_summaries(tmp_path: Path) -> None: + """验证 list_tables 返回正确的表格摘要列表。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = _write_mineru_json( + tmp_path, + "tbl_mineru.json", + tables=[ + {"caption": "资产负债表", "html": "
现金100
", "page_idx": 0}, + ], + ) + source = _make_source(json_path) + processor = MineruProcessor(source) + + summaries = processor.list_tables() + assert len(summaries) == 1 + assert summaries[0]["table_ref"] == "t_0001" + assert summaries[0]["caption"] == "资产负债表" + assert summaries[0]["row_count"] == 1 + assert summaries[0]["col_count"] == 2 + + +@pytest.mark.unit +def test_read_section_valid_ref(tmp_path: Path) -> None: + """验证 read_section 返回有效 ref 的章节内容。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = _write_mineru_json( + tmp_path, + "read_sec_mineru.json", + sections=[ + {"title": "第一章", "level": 1, "content": "第一章正文内容。", "page_idx": 0}, + ], + ) + source = _make_source(json_path) + processor = MineruProcessor(source) + + content = processor.read_section("s_0001") + assert content["ref"] == "s_0001" + assert content["title"] == "第一章" + assert "第一章正文内容" in content["content"] + assert content["word_count"] > 0 + assert content["contains_full_text"] is True + + +@pytest.mark.unit +def test_read_section_with_tables_appends_placeholders(tmp_path: Path) -> None: + """验证 read_section 在关联表格时追加占位符。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = _write_mineru_json( + tmp_path, + "sec_tbl_mineru.json", + sections=[ + {"title": "财报", "level": 1, "content": "以下是财务摘要", "page_idx": 0}, + ], + tables=[ + {"caption": "利润表", "html": "
收入500
", "page_idx": 0}, + ], + ) + source = _make_source(json_path) + processor = MineruProcessor(source) + + content = processor.read_section("s_0001") + # 占位符应追加到内容末尾 + assert "[[t_0001]]" in content["content"] + assert content["tables"] == ["t_0001"] + + +@pytest.mark.unit +def test_read_table_valid_ref(tmp_path: Path) -> None: + """验证 read_table 返回有效 ref 的表格内容。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = _write_mineru_json( + tmp_path, + "read_tbl_mineru.json", + tables=[ + { + "caption": "利润表", + "html": "
项目金额
收入1000
", + "page_idx": 0, + }, + ], + ) + source = _make_source(json_path) + processor = MineruProcessor(source) + + table_content = processor.read_table("t_0001") + assert table_content["table_ref"] == "t_0001" + assert table_content["caption"] == "利润表" + assert table_content["data_format"] == "markdown" + assert table_content["row_count"] == 2 + assert table_content["col_count"] == 2 + + +@pytest.mark.unit +def test_search_finds_match(tmp_path: Path) -> None: + """验证 search 能匹配到关键词。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = _write_mineru_json( + tmp_path, + "search_mineru.json", + sections=[ + {"title": "收入", "level": 1, "content": "公司2024年营收增长20%。", "page_idx": 0}, + {"title": "成本", "level": 2, "content": "运营成本有所下降。", "page_idx": 1}, + ], + ) + source = _make_source(json_path) + processor = MineruProcessor(source) + + hits = processor.search("营收", within_ref=None) + assert len(hits) >= 1 + assert any("营收" in hit["snippet"] or "营收" in hit["section_title"] for hit in hits) + + +@pytest.mark.unit +def test_search_no_match_returns_empty(tmp_path: Path) -> None: + """验证 search 无匹配时返回空列表。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = _write_mineru_json( + tmp_path, + "no_match_mineru.json", + sections=[ + {"title": "报告", "level": 1, "content": "一些无关内容", "page_idx": 0}, + ], + ) + source = _make_source(json_path) + processor = MineruProcessor(source) + + hits = processor.search("不存在的关键词", within_ref=None) + assert hits == [] + + +@pytest.mark.unit +def test_search_empty_query_returns_empty(tmp_path: Path) -> None: + """验证 search 空查询时返回空列表。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = _write_mineru_json( + tmp_path, + "empty_q_mineru.json", + sections=[ + {"title": "报告", "level": 1, "content": "内容", "page_idx": 0}, + ], + ) + source = _make_source(json_path) + processor = MineruProcessor(source) + + hits = processor.search("", within_ref=None) + assert hits == [] + + +@pytest.mark.unit +def test_get_full_text_concatenates(tmp_path: Path) -> None: + """验证 get_full_text 拼接 raw_markdown 与章节内容。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = _write_mineru_json( + tmp_path, + "fulltext_mineru.json", + raw_markdown="# 年度报告\n\n这是一份财务报告。", + sections=[ + {"title": "概述", "level": 1, "content": "公司表现良好。", "page_idx": 0}, + {"title": "财务", "level": 2, "content": "净利润增长。", "page_idx": 1}, + ], + ) + source = _make_source(json_path) + processor = MineruProcessor(source) + + full_text = processor.get_full_text() + assert "年度报告" in full_text + assert "公司表现良好。" in full_text + assert "净利润增长。" in full_text + + +@pytest.mark.unit +def test_get_full_text_caches_result(tmp_path: Path) -> None: + """验证 get_full_text 使用缓存,多次调用返回相同结果。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = _write_mineru_json( + tmp_path, + "cache_mineru.json", + raw_markdown="# 缓存的全文", + sections=[ + {"title": "章节", "level": 1, "content": "缓存内容", "page_idx": 0}, + ], + ) + source = _make_source(json_path) + processor = MineruProcessor(source) + + first = processor.get_full_text() + second = processor.get_full_text() + assert first == second + assert processor._full_text_cache is not None + assert processor._full_text_cache == first + + +@pytest.mark.unit +def test_get_full_text_with_table_markers(tmp_path: Path) -> None: + """验证 get_full_text_with_table_markers 返回空字符串。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = _write_mineru_json( + tmp_path, + "markers_mineru.json", + sections=[ + {"title": "章节", "level": 1, "content": "内容", "page_idx": 0}, + ], + ) + source = _make_source(json_path) + processor = MineruProcessor(source) + + result = processor.get_full_text_with_table_markers() + assert result == "" + + +@pytest.mark.unit +def test_page_content_valid_page(tmp_path: Path) -> None: + """验证 get_page_content 返回指定页码的内容。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = _write_mineru_json( + tmp_path, + "page_mineru.json", + sections=[ + {"title": "第零页", "level": 1, "content": "第零页内容", "page_idx": 0}, + {"title": "第一页", "level": 1, "content": "第一页内容", "page_idx": 1}, + ], + tables=[ + {"caption": "表格1", "html": "
A
", "page_idx": 1}, + ], + ) + source = _make_source(json_path) + processor = MineruProcessor(source) + + page_0 = processor.get_page_content(0) + assert page_0["page_no"] == 0 + assert page_0["has_content"] is True + assert len(page_0["sections"]) == 1 + assert page_0["sections"][0]["ref"] == "s_0001" + assert len(page_0["tables"]) == 0 + + page_1 = processor.get_page_content(1) + assert page_1["page_no"] == 1 + assert len(page_1["sections"]) == 1 + assert page_1["sections"][0]["ref"] == "s_0002" + assert len(page_1["tables"]) == 1 + assert page_1["tables"][0]["table_ref"] == "t_0001" + + +# ============================================================================ +# 异常 / 边界 +# ============================================================================ + + +@pytest.mark.unit +def test_init_file_not_found(tmp_path: Path) -> None: + """验证文件不存在时 __init__ 抛出 ValueError。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + missing_path = tmp_path / "nonexistent_mineru.json" + # 使用手动构造的 Source 避免 stat() 调用 + source = LocalFileSource( + path=missing_path, + uri="local://nonexistent_mineru.json", + media_type="application/json", + content_length=0, + etag=None, + ) + + with pytest.raises(ValueError, match="MinerU JSON 文件不存在"): + MineruProcessor(source) + + +@pytest.mark.unit +def test_init_invalid_json(tmp_path: Path) -> None: + """验证非法 JSON 时 __init__ 抛出 RuntimeError。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = tmp_path / "bad_mineru.json" + json_path.write_text("不是有效的 JSON {{{{", encoding="utf-8") + source = _make_source(json_path) + + with pytest.raises(RuntimeError, match="MinerU JSON 解析失败"): + MineruProcessor(source) + + +@pytest.mark.unit +def test_init_json_not_dict(tmp_path: Path) -> None: + """验证 JSON 顶层非字典时 __init__ 抛出 ValueError。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = tmp_path / "array_mineru.json" + json_path.write_text("[1, 2, 3]", encoding="utf-8") + source = _make_source(json_path) + + with pytest.raises(ValueError, match="MinerU JSON 必须是顶层字典"): + MineruProcessor(source) + + +@pytest.mark.unit +def test_read_section_not_found(tmp_path: Path) -> None: + """验证 read_section 访问不存在的 ref 时抛出 KeyError。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = _write_mineru_json( + tmp_path, + "no_sec_mineru.json", + sections=[ + {"title": "存在", "level": 1, "content": "内容", "page_idx": 0}, + ], + ) + source = _make_source(json_path) + processor = MineruProcessor(source) + + with pytest.raises(KeyError, match="Section not found"): + processor.read_section("s_9999") + + +@pytest.mark.unit +def test_read_table_not_found(tmp_path: Path) -> None: + """验证 read_table 访问不存在的 ref 时抛出 KeyError。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = _write_mineru_json( + tmp_path, + "no_tbl_mineru.json", + tables=[ + {"caption": "表", "html": "
A
", "page_idx": 0}, + ], + ) + source = _make_source(json_path) + processor = MineruProcessor(source) + + with pytest.raises(KeyError, match="Table not found"): + processor.read_table("t_9999") + + +@pytest.mark.unit +def test_page_content_negative_page(tmp_path: Path) -> None: + """验证 page_no 为负数时抛出 ValueError。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = _write_mineru_json( + tmp_path, + "neg_mineru.json", + sections=[ + {"title": "章节", "level": 1, "content": "内容", "page_idx": 0}, + ], + ) + source = _make_source(json_path) + processor = MineruProcessor(source) + + with pytest.raises(ValueError, match="page_no must be a non-negative integer"): + processor.get_page_content(-1) + + +@pytest.mark.unit +def test_page_content_out_of_range(tmp_path: Path) -> None: + """验证 page_no 超出范围时返回无内容的结果。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = _write_mineru_json( + tmp_path, + "oor_mineru.json", + sections=[ + {"title": "唯一页", "level": 1, "content": "唯一内容", "page_idx": 0}, + ], + ) + source = _make_source(json_path) + processor = MineruProcessor(source) + + result = processor.get_page_content(99) + assert result["page_no"] == 99 + assert result["has_content"] is False + assert result["total_items"] == 0 + assert result["sections"] == [] + assert result["tables"] == [] + + +@pytest.mark.unit +def test_empty_document(tmp_path: Path) -> None: + """验证无 sections 和 tables 的空文档能正常初始化并返回空列表。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = _write_mineru_json(tmp_path, "empty_mineru.json") + source = _make_source(json_path) + processor = MineruProcessor(source) + + assert processor.list_sections() == [] + assert processor.list_tables() == [] + assert processor.get_full_text() == "" + assert processor.get_full_text_with_table_markers() == "" + + page = processor.get_page_content(0) + assert page["has_content"] is False + + +@pytest.mark.unit +def test_page_content_no_sections_only_raw_markdown(tmp_path: Path) -> None: + """验证仅有 raw_markdown 无 sections 时能正常初始化。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = _write_mineru_json( + tmp_path, + "rawonly_mineru.json", + raw_markdown="# 纯 Markdown 文档\n\n无需结构化章节。", + ) + source = _make_source(json_path) + processor = MineruProcessor(source) + + assert processor.list_sections() == [] + assert processor.get_full_text() == "# 纯 Markdown 文档\n\n无需结构化章节。" + + +@pytest.mark.unit +def test_search_within_ref_finds_in_section(tmp_path: Path) -> None: + """验证 search 在指定章节范围内搜索。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = _write_mineru_json( + tmp_path, + "within_mineru.json", + sections=[ + {"title": "第一章", "level": 1, "content": "第一章有一些关键词。", "page_idx": 0}, + {"title": "第二章", "level": 1, "content": "第二章没有。", "page_idx": 1}, + ], + ) + source = _make_source(json_path) + processor = MineruProcessor(source) + + hits = processor.search("关键词", within_ref="s_0001") + assert len(hits) >= 1 + assert any("关键词" in hit["snippet"] for hit in hits) + + +@pytest.mark.unit +def test_search_within_ref_not_found_returns_empty(tmp_path: Path) -> None: + """验证 search 在无效 within_ref 时返回空列表。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = _write_mineru_json( + tmp_path, + "invalid_within_mineru.json", + sections=[ + {"title": "报告", "level": 1, "content": "关于关键词的内容", "page_idx": 0}, + ], + ) + source = _make_source(json_path) + processor = MineruProcessor(source) + + hits = processor.search("关键词", within_ref="s_9999") + assert hits == [] + + +@pytest.mark.unit +def test_section_content_lru_eviction(tmp_path: Path) -> None: + """验证 _section_content_cache 超过 256 条目时驱逐最早条目。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + # 生成 300 个章节 + sections = [] + for i in range(300): + sections.append( + { + "title": f"章节{i + 1}", + "level": 1, + "content": f"第{i + 1}章内容。", + "page_idx": i, + } + ) + + json_path = _write_mineru_json( + tmp_path, + "lru_mineru.json", + sections=sections, + ) + source = _make_source(json_path) + processor = MineruProcessor(source) + + # 按序读取所有章节(缓存满 256 后开始驱逐) + for i in range(300): + ref = f"s_{i + 1:04d}" + processor.read_section(ref) + + # 验证缓存不超过 256 + assert len(processor._section_content_cache) <= 256 + + # 最先被驱逐的应该是 s_0001(LRU 尾部) + cache_keys = list(processor._section_content_cache.keys()) + assert "s_0001" not in cache_keys + assert "s_0002" not in cache_keys + + # 最后读取的 s_0300 应该还在缓存中 + assert "s_0300" in cache_keys or len(cache_keys) == 256 + + +@pytest.mark.unit +def test_get_section_title_valid(tmp_path: Path) -> None: + """验证 get_section_title 返回正确标题。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = _write_mineru_json( + tmp_path, + "title_mineru.json", + sections=[ + {"title": "重要章节", "level": 2, "content": "内容", "page_idx": 0}, + ], + ) + source = _make_source(json_path) + processor = MineruProcessor(source) + + title = processor.get_section_title("s_0001") + assert title == "重要章节" + + +@pytest.mark.unit +def test_get_section_title_not_found(tmp_path: Path) -> None: + """验证 get_section_title 对不存在的 ref 返回 None。 + + Args: + tmp_path: pytest 临时目录。 + + Returns: + 无。 + + Raises: + AssertionError: 断言失败时抛出。 + """ + + json_path = _write_mineru_json( + tmp_path, + "no_title_mineru.json", + sections=[ + {"title": "章节", "level": 1, "content": "内容", "page_idx": 0}, + ], + ) + source = _make_source(json_path) + processor = MineruProcessor(source) + + title = processor.get_section_title("s_9999") + assert title is None diff --git a/tests/engine/test_web_playwright_backend.py b/tests/engine/test_web_playwright_backend.py index 64c11ac..3db5b94 100644 --- a/tests/engine/test_web_playwright_backend.py +++ b/tests/engine/test_web_playwright_backend.py @@ -443,6 +443,7 @@ def test_fetch_and_convert_with_playwright_timeout_on_budget(monkeypatch: pytest def _raise_timeout(_timeout: float, **_kw: float | str | None) -> float: raise Timeout() + monkeypatch.setitem(sys.modules, "playwright", SimpleNamespace()) monkeypatch.setitem(sys.modules, "playwright.sync_api", SimpleNamespace(sync_playwright=lambda: None)) result = backend_mod._fetch_and_convert_with_playwright( url="https://example.com", @@ -466,6 +467,7 @@ def test_fetch_and_convert_with_playwright_nonpicklable_worker(monkeypatch: pyte """worker 不可 pickle 时走内联执行路径(非子进程)。""" fake_result = {"ok": True, "content": "hello", "http_status": 200, "response_headers": {}} + monkeypatch.setitem(sys.modules, "playwright", SimpleNamespace()) monkeypatch.setitem(sys.modules, "playwright.sync_api", SimpleNamespace(sync_playwright=lambda: None)) result = backend_mod._fetch_and_convert_with_playwright( diff --git a/tests/engine/test_web_tools.py b/tests/engine/test_web_tools.py index 829cc03..997bbe4 100644 --- a/tests/engine/test_web_tools.py +++ b/tests/engine/test_web_tools.py @@ -3616,6 +3616,16 @@ def test_fetch_and_convert_with_playwright_accepts_cloudflare_served_article( import dayu.engine.tools.web_tools as web_tools_mod + fake_playwright_module = ModuleType("playwright") + fake_sync_api_module = ModuleType("playwright.sync_api") + + class _FakePlaywrightTimeoutError(Exception): + pass + + cast(Any, fake_sync_api_module).TimeoutError = _FakePlaywrightTimeoutError + monkeypatch.setitem(sys.modules, "playwright", fake_playwright_module) + monkeypatch.setitem(sys.modules, "playwright.sync_api", fake_sync_api_module) + monkeypatch.setattr( "dayu.engine.tools.web_tools._playwright_sync_worker", lambda *, url, timeout_seconds, headers=None, playwright_channel=None, playwright_storage_state_path="": { @@ -3675,6 +3685,16 @@ def test_playwright_fallback_non_html_content_type(monkeypatch: pytest.MonkeyPat import dayu.engine.tools.web_tools as web_tools_mod + fake_playwright_module = ModuleType("playwright") + fake_sync_api_module = ModuleType("playwright.sync_api") + + class _FakePlaywrightTimeoutError(Exception): + pass + + cast(Any, fake_sync_api_module).TimeoutError = _FakePlaywrightTimeoutError + monkeypatch.setitem(sys.modules, "playwright", fake_playwright_module) + monkeypatch.setitem(sys.modules, "playwright.sync_api", fake_sync_api_module) + monkeypatch.setattr( "dayu.engine.tools.web_tools._playwright_sync_worker", lambda *, url, timeout_seconds, headers=None, playwright_channel=None, playwright_storage_state_path="": { @@ -3805,6 +3825,16 @@ def test_playwright_sync_worker_uses_storage_state_when_provided( import dayu.engine.tools.web_tools as web_tools_mod + fake_playwright_module = ModuleType("playwright") + fake_sync_api_module = ModuleType("playwright.sync_api") + + class _FakePlaywrightTimeoutError(Exception): + pass + + cast(Any, fake_sync_api_module).TimeoutError = _FakePlaywrightTimeoutError + monkeypatch.setitem(sys.modules, "playwright", fake_playwright_module) + monkeypatch.setitem(sys.modules, "playwright.sync_api", fake_sync_api_module) + storage_state_path = tmp_path / "storage-state.json" storage_state_path.write_text("{}", encoding="utf-8") @@ -3902,6 +3932,16 @@ def test_playwright_sync_worker_uses_single_deadline_budget(monkeypatch: pytest. import dayu.engine.tools.web_tools as web_tools_mod + fake_playwright_module = ModuleType("playwright") + fake_sync_api_module = ModuleType("playwright.sync_api") + + class _FakePlaywrightTimeoutError(Exception): + pass + + cast(Any, fake_sync_api_module).TimeoutError = _FakePlaywrightTimeoutError + monkeypatch.setitem(sys.modules, "playwright", fake_playwright_module) + monkeypatch.setitem(sys.modules, "playwright.sync_api", fake_sync_api_module) + fake_stealth_module = ModuleType("playwright_stealth") class _FakeStealth: @@ -4218,6 +4258,16 @@ def test_fetch_and_convert_with_playwright_rejects_challenge_page(monkeypatch: p import dayu.engine.tools.web_tools as web_tools_mod + fake_playwright_module = ModuleType("playwright") + fake_sync_api_module = ModuleType("playwright.sync_api") + + class _FakePlaywrightTimeoutError(Exception): + pass + + cast(Any, fake_sync_api_module).TimeoutError = _FakePlaywrightTimeoutError + monkeypatch.setitem(sys.modules, "playwright", fake_playwright_module) + monkeypatch.setitem(sys.modules, "playwright.sync_api", fake_sync_api_module) + monkeypatch.setattr( "dayu.engine.tools.web_tools._playwright_sync_worker", lambda *, url, timeout_seconds, headers=None, playwright_channel=None, playwright_storage_state_path="": { diff --git a/tests/fins/test_cn_pipeline.py b/tests/fins/test_cn_pipeline.py index 935c9ec..86fcff5 100644 --- a/tests/fins/test_cn_pipeline.py +++ b/tests/fins/test_cn_pipeline.py @@ -399,10 +399,9 @@ async def test_upload_filing_stream_uploads_files_with_docling(tmp_path: Path) - workspace_root=tmp_path, processor_registry=build_fins_processor_registry(), ) - pipeline._upload_service._convert_with_docling = lambda raw_data, stream_name: { # type: ignore[attr-defined] - "name": stream_name, - "format": "docling", - } + pipeline._upload_service._convert_with_docling = lambda raw_data, stream_name: ( # type: ignore[attr-defined] + {"name": stream_name, "format": "docling"}, "_docling.json", + ) sample_file = tmp_path / "sample.pdf" sample_file.write_text("demo", encoding="utf-8") @@ -427,7 +426,7 @@ async def test_upload_filing_stream_uploads_files_with_docling(tmp_path: Path) - assert events[0].event_type == UploadFilingEventType.UPLOAD_STARTED assert events[1].event_type == UploadFilingEventType.CONVERSION_STARTED assert events[1].payload["name"] == "sample.pdf" - assert events[1].payload["message"] == "正在 convert" + assert "正在 convert" in events[1].payload["message"] assert events[2].event_type == UploadFilingEventType.FILE_UPLOADED assert events[2].payload["name"] == "sample.pdf" assert events[2].payload["source"] == "original" @@ -458,8 +457,8 @@ async def test_upload_filing_failed_result_uses_normalized_period_and_aliases( processor_registry=build_fins_processor_registry(), ) - def fail_convert(raw_data: bytes, stream_name: str) -> dict[str, str]: - """模拟 Docling 转换失败。""" + def fail_convert(raw_data: bytes, stream_name: str) -> tuple[dict[str, str], str]: + """模拟 Docling 和 MinerU 都转换失败。""" del raw_data, stream_name raise RuntimeError("convert failed") @@ -508,10 +507,9 @@ async def test_upload_material_stream_uploads_files_with_docling(tmp_path: Path) workspace_root=tmp_path, processor_registry=build_fins_processor_registry(), ) - pipeline._upload_service._convert_with_docling = lambda raw_data, stream_name: { # type: ignore[attr-defined] - "name": stream_name, - "format": "docling", - } + pipeline._upload_service._convert_with_docling = lambda raw_data, stream_name: ( # type: ignore[attr-defined] + {"name": stream_name, "format": "docling"}, "_docling.json", + ) sample_file = tmp_path / "material.pdf" sample_file.write_text("demo", encoding="utf-8") @@ -535,7 +533,7 @@ async def test_upload_material_stream_uploads_files_with_docling(tmp_path: Path) assert events[0].event_type == UploadMaterialEventType.UPLOAD_STARTED assert events[1].event_type == UploadMaterialEventType.CONVERSION_STARTED assert events[1].payload["name"] == "material.pdf" - assert events[1].payload["message"] == "正在 convert" + assert "正在 convert" in events[1].payload["message"] assert events[2].event_type == UploadMaterialEventType.FILE_UPLOADED assert events[2].payload["name"] == "material.pdf" assert events[2].payload["source"] == "original" @@ -561,10 +559,9 @@ async def test_upload_filing_stream_auto_resolves_create_update_skip(tmp_path: P workspace_root=tmp_path, processor_registry=build_fins_processor_registry(), ) - pipeline._upload_service._convert_with_docling = lambda raw_data, stream_name: { # type: ignore[attr-defined] - "name": stream_name, - "format": "docling", - } + pipeline._upload_service._convert_with_docling = lambda raw_data, stream_name: ( # type: ignore[attr-defined] + {"name": stream_name, "format": "docling"}, "_docling.json", + ) sample_file = tmp_path / "sample.pdf" sample_file.write_text("demo-v1", encoding="utf-8") @@ -636,10 +633,9 @@ async def test_upload_material_stream_overwrite_resets_single_document(tmp_path: workspace_root=tmp_path, processor_registry=build_fins_processor_registry(), ) - pipeline._upload_service._convert_with_docling = lambda raw_data, stream_name: { # type: ignore[attr-defined] - "name": stream_name, - "format": "docling", - } + pipeline._upload_service._convert_with_docling = lambda raw_data, stream_name: ( # type: ignore[attr-defined] + {"name": stream_name, "format": "docling"}, "_docling.json", + ) old_file = tmp_path / "deck_old.pdf" new_file = tmp_path / "deck_new.pdf" old_file.write_text("old", encoding="utf-8") diff --git a/tests/fins/test_docling_upload_service.py b/tests/fins/test_docling_upload_service.py index e5a7297..f992eed 100644 --- a/tests/fins/test_docling_upload_service.py +++ b/tests/fins/test_docling_upload_service.py @@ -15,7 +15,7 @@ _PendingFileAsset, _build_upload_source_fingerprint, _can_skip_upload, - _convert_bytes_with_docling, + _convert_bytes_with_fallback, _increment_document_version, _normalize_ticker, _pick_primary_docling_file, @@ -33,7 +33,7 @@ from tests.fins.storage_testkit import FsStorageTestContext, build_fs_storage_test_context -def _convert_docling_stub(raw_data: bytes, stream_name: str) -> dict[str, str]: +def _convert_docling_stub(raw_data: bytes, stream_name: str) -> tuple[dict[str, str], str]: """返回固定 Docling 转换结果。 Args: @@ -48,10 +48,10 @@ def _convert_docling_stub(raw_data: bytes, stream_name: str) -> dict[str, str]: """ _ = raw_data - return {"name": stream_name, "source": "docling"} + return {"name": stream_name, "source": "docling"}, "_docling.json" -def _convert_docling_error(_: bytes, __: str) -> dict[str, object]: +def _convert_docling_error(_: bytes, __: str) -> tuple[dict[str, object], str]: """抛出转换错误。 Args: @@ -188,7 +188,7 @@ def test_execute_upload_skip_does_not_run_docling_again_for_same_source_file(tmp convert_calls: list[str] = [] - def _counting_convert(raw_data: bytes, stream_name: str) -> dict[str, str]: + def _counting_convert(raw_data: bytes, stream_name: str) -> tuple[dict[str, str], str]: """记录 Docling convert 调用次数。 Args: @@ -204,7 +204,7 @@ def _counting_convert(raw_data: bytes, stream_name: str) -> dict[str, str]: _ = raw_data convert_calls.append(stream_name) - return {"name": stream_name, "source": "docling"} + return {"name": stream_name, "source": "docling"}, "_docling.json" context = build_fs_storage_test_context(tmp_path) service = DoclingUploadService( @@ -708,8 +708,8 @@ def test_build_upload_source_fingerprint_is_stable_for_order() -> None: assert fp1 == fp2 -def test_convert_bytes_with_docling_import_error(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """验证 Docling 未安装时抛出明确异常。 +def test_convert_bytes_with_fallback_import_error(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """验证 Docling 和 MinerU 都不可用时抛出明确异常。 Args: tmp_path: 临时目录(占位,本用例不使用)。 @@ -726,7 +726,7 @@ def test_convert_bytes_with_docling_import_error(tmp_path: Path, monkeypatch: py original_import = builtins.__import__ def _fake_import(name: str, *args: Any, **kwargs: Any) -> Any: - """对 docling import 注入 ImportError。 + """对 docling 和 mineru import 注入 ImportError。 Args: name: 模块名。 @@ -737,25 +737,23 @@ def _fake_import(name: str, *args: Any, **kwargs: Any) -> Any: 原始 import 结果。 Raises: - ImportError: 模拟 docling 缺失。 + ImportError: 模拟模块缺失。 """ - if name.startswith("docling"): - raise ImportError("docling missing") + if name.startswith("docling") or name.startswith("mineru"): + raise ImportError(f"{name} missing") return original_import(name, *args, **kwargs) monkeypatch.setattr(builtins, "__import__", _fake_import) - from dayu.docling_runtime import DoclingRuntimeInitializationError + with pytest.raises(RuntimeError, match="Docling 和 MinerU 都解析失败"): + _convert_bytes_with_fallback(b"x", "sample.pdf") - with pytest.raises(DoclingRuntimeInitializationError, match="Docling 未安装"): - _convert_bytes_with_docling(b"x", "sample.pdf") - -def test_convert_bytes_with_docling_conversion_failed( +def test_convert_bytes_with_fallback_conversion_failed( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """验证 Docling convert 抛错会被包装为 RuntimeError。 + """验证 Docling 和 MinerU 都转换失败时会抛出明确异常。 Args: tmp_path: 临时目录(占位,本用例不使用)。 @@ -771,7 +769,7 @@ def test_convert_bytes_with_docling_conversion_failed( _ = tmp_path def _raise_convert_failure(*args: Any, **kwargs: Any) -> Any: - """模拟统一 Docling 运行时在转换阶段抛错。 + """模拟转换抛错。 Args: *args: 位置参数。 @@ -788,9 +786,18 @@ def _raise_convert_failure(*args: Any, **kwargs: Any) -> Any: raise RuntimeError("convert boom") monkeypatch.setattr( - "dayu.fins.docling_export.convert_pdf_bytes_with_docling", + "dayu.fins.docling_export.convert_pdf_bytes_to_docling_payload", _raise_convert_failure, ) + # 同时阻断 MinerU import,确保 Docling 失败后 MinerU fallback 也失败 + original_import = builtins.__import__ + + def _block_mineru_import(name: str, *args: Any, **kwargs: Any) -> Any: + if name.startswith("mineru"): + raise ImportError(f"{name} missing") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _block_mineru_import) - with pytest.raises(RuntimeError, match="Docling 转换失败"): - _convert_bytes_with_docling(b"x", "sample.pdf") + with pytest.raises(RuntimeError, match="Docling 和 MinerU 都解析失败"): + _convert_bytes_with_fallback(b"x", "sample.pdf") diff --git a/tests/fins/test_docling_upload_service_integration.py b/tests/fins/test_docling_upload_service_integration.py index 61990bf..8defaf5 100644 --- a/tests/fins/test_docling_upload_service_integration.py +++ b/tests/fins/test_docling_upload_service_integration.py @@ -8,7 +8,8 @@ from dayu.fins.domain.document_models import SourceHandle from dayu.fins.domain.enums import SourceKind -from dayu.fins.pipelines.docling_upload_service import DoclingUploadService, _convert_bytes_with_docling +from dayu.fins.docling_export import convert_pdf_bytes_to_docling_payload +from dayu.fins.pipelines.docling_upload_service import DoclingUploadService from tests.fins.storage_testkit import FsStorageTestContext, build_fs_storage_test_context pytestmark = pytest.mark.integration @@ -52,7 +53,7 @@ def test_convert_bytes_with_docling_reads_real_pdf_table() -> None: """ fixture_path = _fixture_pdf_path() - result = _convert_bytes_with_docling(fixture_path.read_bytes(), fixture_path.name) + result = convert_pdf_bytes_to_docling_payload(fixture_path.read_bytes(), stream_name=fixture_path.name) assert result["name"] == "dayu_docling_integration_fixture" tables = result.get("tables") diff --git a/tests/fins/test_sec_pipeline_upload_filing_stream.py b/tests/fins/test_sec_pipeline_upload_filing_stream.py index f1cec28..010703e 100644 --- a/tests/fins/test_sec_pipeline_upload_filing_stream.py +++ b/tests/fins/test_sec_pipeline_upload_filing_stream.py @@ -30,10 +30,9 @@ async def test_upload_filing_stream_uploads_docling_files(tmp_path: Path) -> Non workspace_root=tmp_path, processor_registry=build_fins_processor_registry(), ) - pipeline._upload_service._convert_with_docling = lambda raw_data, stream_name: { # type: ignore[attr-defined] - "name": stream_name, - "format": "docling", - } + pipeline._upload_service._convert_with_docling = lambda raw_data, stream_name: ( # type: ignore[attr-defined] + {"name": stream_name, "format": "docling"}, "_docling.json", + ) filing_file = tmp_path / "filing.pdf" filing_file.write_text("demo filing", encoding="utf-8") @@ -96,10 +95,9 @@ async def test_upload_filing_stream_auto_action_and_overwrite_reset(tmp_path: Pa workspace_root=tmp_path, processor_registry=build_fins_processor_registry(), ) - pipeline._upload_service._convert_with_docling = lambda raw_data, stream_name: { # type: ignore[attr-defined] - "name": stream_name, - "format": "docling", - } + pipeline._upload_service._convert_with_docling = lambda raw_data, stream_name: ( # type: ignore[attr-defined] + {"name": stream_name, "format": "docling"}, "_docling.json", + ) old_file = tmp_path / "q1_old.pdf" new_file = tmp_path / "q1_new.pdf" old_file.write_text("old filing", encoding="utf-8") diff --git a/tests/fins/test_sec_pipeline_upload_material_stream.py b/tests/fins/test_sec_pipeline_upload_material_stream.py index ab45bed..d605898 100644 --- a/tests/fins/test_sec_pipeline_upload_material_stream.py +++ b/tests/fins/test_sec_pipeline_upload_material_stream.py @@ -30,10 +30,9 @@ async def test_upload_material_stream_uploads_docling_files(tmp_path: Path) -> N workspace_root=tmp_path, processor_registry=build_fins_processor_registry(), ) - pipeline._upload_service._convert_with_docling = lambda raw_data, stream_name: { # type: ignore[attr-defined] - "name": stream_name, - "format": "docling", - } + pipeline._upload_service._convert_with_docling = lambda raw_data, stream_name: ( # type: ignore[attr-defined] + {"name": stream_name, "format": "docling"}, "_docling.json", + ) material_file = tmp_path / "material.pdf" material_file.write_text("demo material", encoding="utf-8") @@ -58,7 +57,7 @@ async def test_upload_material_stream_uploads_docling_files(tmp_path: Path) -> N assert events[0].event_type == UploadMaterialEventType.UPLOAD_STARTED assert events[1].event_type == UploadMaterialEventType.CONVERSION_STARTED assert events[1].payload["name"] == "material.pdf" - assert events[1].payload["message"] == "正在 convert" + assert "正在 convert" in events[1].payload["message"] assert events[2].event_type == UploadMaterialEventType.FILE_UPLOADED assert events[2].payload["name"] == "material.pdf" assert events[2].payload["source"] == "original" @@ -87,10 +86,9 @@ async def test_upload_material_stream_auto_action_and_overwrite_reset(tmp_path: workspace_root=tmp_path, processor_registry=build_fins_processor_registry(), ) - pipeline._upload_service._convert_with_docling = lambda raw_data, stream_name: { # type: ignore[attr-defined] - "name": stream_name, - "format": "docling", - } + pipeline._upload_service._convert_with_docling = lambda raw_data, stream_name: ( # type: ignore[attr-defined] + {"name": stream_name, "format": "docling"}, "_docling.json", + ) old_file = tmp_path / "deck_old.pdf" new_file = tmp_path / "deck_new.pdf" old_file.write_text("old material", encoding="utf-8") diff --git a/tests/test_mineru_basic.py b/tests/test_mineru_basic.py new file mode 100644 index 0000000..c9bf9db --- /dev/null +++ b/tests/test_mineru_basic.py @@ -0,0 +1,306 @@ +"""MinerU 集成基础测试。 + +覆盖 document_protocol、quota_tracker、mineru_runtime 的核心逻辑。 +""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +import pytest + +from dayu.document_protocol import ( + ConvertedDocument, + DocumentBackend, + DocumentImage, + DocumentSection, + DocumentTable, + detect_mineru_content_list_version, + detect_mineru_content_list_version_from_bytes, + parse_block, + parse_content_list, +) +from dayu.quota_tracker import QuotaExhaustedError, QuotaTracker + + +# --------------------------------------------------------------------------- +# document_protocol 测试 +# --------------------------------------------------------------------------- + + +class TestDocumentProtocol: + """统一中间格式测试。""" + + def test_converted_document_defaults(self) -> None: + """ConvertedDocument 默认值测试。""" + doc = ConvertedDocument(backend=DocumentBackend.DOCLING) + assert doc.backend == DocumentBackend.DOCLING + assert doc.sections == () + assert doc.tables == () + assert doc.images == () + assert doc.raw_markdown == "" + + def test_converted_document_with_data(self) -> None: + """ConvertedDocument 填充数据测试。""" + section = DocumentSection(title="Test", level=1, content="content") + table = DocumentTable(caption="tbl", html="
") + doc = ConvertedDocument( + backend=DocumentBackend.MINERU_CLOUD, + sections=(section,), + tables=(table,), + raw_markdown="# Test\ncontent", + ) + assert len(doc.sections) == 1 + assert doc.sections[0].title == "Test" + assert len(doc.tables) == 1 + assert doc.tables[0].html == "
" + + def test_document_section_frozen(self) -> None: + """DocumentSection 不可变。""" + section = DocumentSection(title="T", level=1, content="c") + with pytest.raises(AttributeError): + section.title = "X" # type: ignore[misc] + + def test_document_table_frozen(self) -> None: + """DocumentTable 不可变。""" + table = DocumentTable(caption="c", html="") + with pytest.raises(AttributeError): + table.html = "
" # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# 版本探测测试(bbox 类型判断) +# --------------------------------------------------------------------------- + + +class TestVersionDetection: + """MinerU content_list 版本探测测试。""" + + def test_v2_detected_by_list_bbox(self) -> None: + """bbox 为 list → v2。""" + content_list = [ + {"type": "text", "bbox": [100, 200, 300, 400]}, + {"type": "text", "bbox": [10, 20, 30, 40]}, + ] + assert detect_mineru_content_list_version(content_list) == "v2" + + def test_v1_detected_by_dict_bbox(self) -> None: + """bbox 为 dict → v1。""" + content_list = [ + {"type": "text", "bbox": {"x0": 100, "y0": 200, "x1": 300, "y1": 400}}, + ] + assert detect_mineru_content_list_version(content_list) == "v1" + + def test_unknown_when_no_bbox(self) -> None: + """所有元素均无 bbox → unknown。""" + content_list = [ + {"type": "text", "text": "hello"}, + {"type": "title", "title": "Chapter 1"}, + ] + assert detect_mineru_content_list_version(content_list) == "unknown" + + def test_skips_first_items_without_bbox(self) -> None: + """前几个元素无 bbox,后续有 → 正确识别。""" + content_list = [ + {"type": "title", "title": "Header"}, # 无 bbox + {"type": "text", "text": "body"}, # 无 bbox + {"type": "text", "bbox": [0, 1, 2, 3]}, # 有 bbox → v2 + ] + assert detect_mineru_content_list_version(content_list) == "v2" + + def test_mixed_bbox_types_first_match_wins(self) -> None: + """多种 bbox 类型共存,第一个有 bbox 的决定版本。""" + content_list = [ + {"type": "text", "bbox": {"x0": 0, "y0": 0}}, # dict → v1 + {"type": "text", "bbox": [0, 1, 2, 3]}, # list(但第二个) + ] + assert detect_mineru_content_list_version(content_list) == "v1" + + def test_empty_raises(self) -> None: + """空列表 → TypeError。""" + with pytest.raises(TypeError, match="为空"): + detect_mineru_content_list_version([]) + + def test_check_first_10_elements_only(self) -> None: + """只检查前 10 个元素。""" + # 前 10 个均无 bbox,第 11 个有 → unknown + content_list: list[dict[str, object]] = [ + {"type": "text", "text": f"item {i}"} + for i in range(10) + ] + content_list.append({"type": "text", "bbox": [0, 1, 2, 3]}) + assert detect_mineru_content_list_version(content_list) == "unknown" + + def test_from_bytes_bare_list_v2(self) -> None: + """从 JSON bytes(裸列表,list bbox)探测 → v2。""" + data = [{"type": "text", "bbox": [0, 1, 2, 3]}] + raw = json.dumps(data).encode() + assert detect_mineru_content_list_version_from_bytes(raw) == "v2" + + def test_from_bytes_bare_list_v1(self) -> None: + """从 JSON bytes(裸列表,dict bbox)探测 → v1。""" + data = [{"type": "text", "bbox": {"x0": 0, "y0": 0}}] + raw = json.dumps(data).encode() + assert detect_mineru_content_list_version_from_bytes(raw) == "v1" + + def test_from_bytes_wrapper(self) -> None: + """从 JSON bytes(带 content_list 包壳)探测。""" + data = {"content_list": [{"type": "text", "bbox": [0, 1, 2, 3]}]} + raw = json.dumps(data).encode() + assert detect_mineru_content_list_version_from_bytes(raw) == "v2" + + def test_from_bytes_empty_raises(self) -> None: + """空 bytes → TypeError。""" + with pytest.raises(TypeError, match="为空"): + detect_mineru_content_list_version_from_bytes(b"") + + def test_from_bytes_invalid_json_raises(self) -> None: + """非法 JSON → JSONDecodeError。""" + with pytest.raises(json.JSONDecodeError): + detect_mineru_content_list_version_from_bytes(b"not json") + + def test_from_bytes_no_content_list_field_raises(self) -> None: + """dict 中无 content_list 字段 → TypeError。""" + data = {"foo": "bar"} + raw = json.dumps(data).encode() + with pytest.raises(TypeError, match="不包含"): + detect_mineru_content_list_version_from_bytes(raw) + + +# --------------------------------------------------------------------------- +# parse_block / parse_content_list 测试 +# --------------------------------------------------------------------------- + + +class TestParseBlock: + """单 block 解析测试。""" + + def test_known_types_pass_through(self) -> None: + """已知类型原样返回。""" + for btype in ("text", "title", "figure", "table"): + block: dict[str, object] = {"type": btype, "data": "x"} + result = parse_block(block) + assert result is block + assert "_unknown_type" not in result + + def test_unknown_type_marked(self) -> None: + """未知类型附带 _unknown_type=True 标记。""" + block: dict[str, object] = {"type": "equation", "data": "E=mc2"} + result = parse_block(block) + assert result["_unknown_type"] is True + assert result["type"] == "equation" + assert result["data"] == "E=mc2" + + def test_missing_type_treated_as_unknown(self) -> None: + """缺少 type 字段视为 unknown。""" + block: dict[str, object] = {"data": "x"} + result = parse_block(block) + assert result["_unknown_type"] is True + + +class TestParseContentList: + """content_list 批量解析测试。""" + + def test_all_known(self) -> None: + """全部已知类型 → 全部返回。""" + cl: list[dict[str, object]] = [ + {"type": "text", "text": "a"}, + {"type": "title", "title": "T"}, + {"type": "table", "html": "
"}, + ] + result = parse_content_list(cl) + assert len(result) == 3 + + def test_unknown_types_included(self) -> None: + """未知类型也被保留(带标记)。""" + cl: list[dict[str, object]] = [ + {"type": "text", "text": "a"}, + {"type": "equation", "eq": "E=mc2"}, + ] + result = parse_content_list(cl) + assert len(result) == 2 + assert "_unknown_type" in result[1] + + def test_empty_returns_empty(self) -> None: + """空列表返回空列表。""" + # parse_content_list 内部调 detect,空列表会 raise + # 但 parse_content_list 本身不 raise,因为它遍历 content_list[:10] + # 实际上 detect_mineru_content_list_version([]) 会 raise TypeError + # parse_content_list 没有 catch 这个 —— 这是预期行为 + with pytest.raises(TypeError): + parse_content_list([]) + + +# --------------------------------------------------------------------------- +# quota_tracker 测试 +# --------------------------------------------------------------------------- + + +class TestQuotaTracker: + """配额跟踪器测试。""" + + def test_basic_consume(self) -> None: + """基本消耗和剩余查询。""" + with tempfile.TemporaryDirectory() as tmpdir: + state_file = str(Path(tmpdir) / "quota.json") + qt = QuotaTracker(daily_limit=100, state_file=state_file) + assert qt.check_and_consume(30) is True + assert qt.get_remaining() == 70 + assert qt.get_used() == 30 + + def test_exhausted(self) -> None: + """配额不足返回 False。""" + with tempfile.TemporaryDirectory() as tmpdir: + state_file = str(Path(tmpdir) / "quota.json") + qt = QuotaTracker(daily_limit=100, state_file=state_file) + qt.check_and_consume(60) + assert qt.check_and_consume(50) is False + assert qt.get_remaining() == 40 + + def test_consume_or_raise(self) -> None: + """配额不足时抛出异常。""" + with tempfile.TemporaryDirectory() as tmpdir: + state_file = str(Path(tmpdir) / "quota.json") + qt = QuotaTracker(daily_limit=100, state_file=state_file) + qt.check_and_consume(80) + with pytest.raises(QuotaExhaustedError, match="配额不足"): + qt.check_and_consume_or_raise(30) + + def test_consume_or_raise_success(self) -> None: + """配额充足时不抛异常。""" + with tempfile.TemporaryDirectory() as tmpdir: + state_file = str(Path(tmpdir) / "quota.json") + qt = QuotaTracker(daily_limit=100, state_file=state_file) + qt.check_and_consume_or_raise(30) + assert qt.get_used() == 30 + + def test_persistence(self) -> None: + """状态持久化到文件后可恢复。""" + with tempfile.TemporaryDirectory() as tmpdir: + state_file = str(Path(tmpdir) / "quota.json") + qt1 = QuotaTracker(daily_limit=100, state_file=state_file) + qt1.check_and_consume(40) + + qt2 = QuotaTracker(daily_limit=100, state_file=state_file) + assert qt2.get_used() == 40 + assert qt2.get_remaining() == 60 + + def test_corrupted_state_file(self) -> None: + """损坏的状态文件不崩溃,重置为 0。""" + with tempfile.TemporaryDirectory() as tmpdir: + state_file = Path(tmpdir) / "quota.json" + state_file.write_text("not json!!!", encoding="utf-8") + qt = QuotaTracker(daily_limit=100, state_file=str(state_file)) + assert qt.get_used() == 0 + assert qt.get_remaining() == 100 + + def test_exact_boundary(self) -> None: + """恰好用完配额(边界值)。""" + with tempfile.TemporaryDirectory() as tmpdir: + state_file = str(Path(tmpdir) / "quota.json") + qt = QuotaTracker(daily_limit=100, state_file=state_file) + assert qt.check_and_consume(100) is True + assert qt.get_remaining() == 0 + assert qt.check_and_consume(1) is False diff --git a/tests/test_mineru_runtime.py b/tests/test_mineru_runtime.py new file mode 100644 index 0000000..1b057e0 --- /dev/null +++ b/tests/test_mineru_runtime.py @@ -0,0 +1,773 @@ +"""MinerU 运行时核心逻辑测试。 + +覆盖 page_ranges 计算、zip 解析、结果转换、合并、回退链等逻辑。 +""" + +from __future__ import annotations + +import io +import json +import shutil +import subprocess +import zipfile +from unittest.mock import MagicMock, patch + +import pytest + +from dayu.document_protocol import ( + ConvertedDocument, + DocumentBackend, + DocumentSection, + DocumentTable, + DocumentImage, +) +from dayu.mineru_runtime import ( + MinerUAPIError, + MinerUTaskFailedError, + MinerUTimeoutError, + _build_page_ranges, + _convert_mineru_result, + _download_and_parse_zip, + _estimate_pages, + _merge_chunk_results, + parse_pdf_bytes_with_mineru, +) + + +# --------------------------------------------------------------------------- +# 辅助工厂 +# --------------------------------------------------------------------------- + + +def _make_zip_bytes( + markdown: str = "# Test\nContent", + content_list: list[list[dict[str, object]]] | None = None, +) -> bytes: + """构造测试用 zip 字节流,模拟 MinerU 结果包。""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("full.md", markdown) + if content_list is not None: + zf.writestr( + "xxx_content_list_v2.json", + json.dumps(content_list, ensure_ascii=False), + ) + return buf.getvalue() + + +def _make_mineru_result( + zip_url: str = "https://example.com/result.zip", +) -> dict[str, object]: + """构造模拟的 MinerU API 查询结果。""" + return { + "code": 0, + "msg": "ok", + "data": { + "task_id": "test-task-001", + "state": "done", + "full_zip_url": zip_url, + }, + } + + +# --------------------------------------------------------------------------- +# _build_page_ranges 测试 +# --------------------------------------------------------------------------- + + +class TestBuildPageRanges: + """page_ranges 服务端分页计算测试。""" + + def test_single_batch(self) -> None: + """≤200 页单批次。""" + assert _build_page_ranges(100) == ["1-100"] + assert _build_page_ranges(200) == ["1-200"] + assert _build_page_ranges(1) == ["1-1"] + + def test_two_batches(self) -> None: + """201-400 页双批次。""" + assert _build_page_ranges(201) == ["1-200", "201-201"] + assert _build_page_ranges(374) == ["1-200", "201-374"] + assert _build_page_ranges(400) == ["1-200", "201-400"] + + def test_three_batches(self) -> None: + """401-600 页三批次。""" + assert _build_page_ranges(500) == ["1-200", "201-400", "401-500"] + assert _build_page_ranges(600) == ["1-200", "201-400", "401-600"] + + def test_custom_batch_size(self) -> None: + """自定义批次大小。""" + assert _build_page_ranges(100, max_per_batch=50) == ["1-50", "51-100"] + assert _build_page_ranges(150, max_per_batch=100) == ["1-100", "101-150"] + + def test_boundary_exact(self) -> None: + """恰好整除边界。""" + assert _build_page_ranges(400, max_per_batch=200) == [ + "1-200", "201-400" + ] + assert _build_page_ranges(600, max_per_batch=200) == [ + "1-200", "201-400", "401-600" + ] + + +# --------------------------------------------------------------------------- +# _estimate_pages 测试 +# --------------------------------------------------------------------------- + + +class TestEstimatePages: + """页数估算测试。""" + + def test_small_file(self) -> None: + """小文件至少 1 页。""" + assert _estimate_pages(b"x" * 1000) == 1 + + def test_typical_page(self) -> None: + """~50KB 大约 1 页。""" + assert _estimate_pages(b"x" * 50000) == 1 + + def test_large_file(self) -> None: + """~500KB 大约 10 页。""" + assert _estimate_pages(b"x" * 500000) == 10 + + +# --------------------------------------------------------------------------- +# _download_and_parse_zip 测试(mock httpx.get) +# --------------------------------------------------------------------------- + + +class TestDownloadAndParseZip: + """zip 结果下载解析测试。""" + + def test_normal_zip(self) -> None: + """正常 zip 包解析:markdown + content_list。""" + cl = [ + [ # page 0 + {"type": "title", "content": "Chapter 1"}, + {"type": "text", "content": "Body text"}, + ], + [ # page 1 + {"type": "table", "content": "
"}, + ], + ] + zip_bytes = _make_zip_bytes(markdown="# Chapter 1\nBody text", content_list=cl) + + mock_resp = MagicMock() + mock_resp.content = zip_bytes + mock_resp.raise_for_status = MagicMock() + + with patch("dayu.mineru_runtime.httpx.get", return_value=mock_resp): + md, blocks = _download_and_parse_zip("https://example.com/result.zip") + + assert md == "# Chapter 1\nBody text" + assert len(blocks) == 3 + # page_idx 应该被附加 + assert blocks[0]["page_idx"] == 0 + assert blocks[1]["page_idx"] == 0 + assert blocks[2]["page_idx"] == 1 + + def test_zip_without_content_list(self) -> None: + """zip 中无 content_list → blocks 为空。""" + zip_bytes = _make_zip_bytes(markdown="# Only md", content_list=None) + + mock_resp = MagicMock() + mock_resp.content = zip_bytes + mock_resp.raise_for_status = MagicMock() + + with patch("dayu.mineru_runtime.httpx.get", return_value=mock_resp): + md, blocks = _download_and_parse_zip("https://example.com/result.zip") + + assert md == "# Only md" + assert blocks == [] + + def test_zip_without_markdown(self) -> None: + """zip 中无 .md 文件 → markdown 为空。""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("data.json", '{"key": "value"}') + zip_bytes = buf.getvalue() + + mock_resp = MagicMock() + mock_resp.content = zip_bytes + mock_resp.raise_for_status = MagicMock() + + with patch("dayu.mineru_runtime.httpx.get", return_value=mock_resp): + md, blocks = _download_and_parse_zip("https://example.com/result.zip") + + assert md == "" + assert blocks == [] + + def test_bad_zip_raises(self) -> None: + """损坏的 zip → MinerUAPIError。""" + mock_resp = MagicMock() + mock_resp.content = b"not a zip file" + mock_resp.raise_for_status = MagicMock() + + with patch("dayu.mineru_runtime.httpx.get", return_value=mock_resp): + with pytest.raises(MinerUAPIError, match="损坏"): + _download_and_parse_zip("https://example.com/result.zip") + + def test_download_failure_raises(self) -> None: + """下载失败 → MinerUAPIError。""" + import httpx as _httpx + + with patch( + "dayu.mineru_runtime.httpx.get", + side_effect=_httpx.ConnectError("connection refused"), + ): + with pytest.raises(MinerUAPIError, match="下载失败"): + _download_and_parse_zip("https://example.com/result.zip") + + def test_content_list_flattening(self) -> None: + """content_list 多页展平 + page_idx 正确。""" + cl = [ + [{"type": "title", "content": "P0"}], # page 0: 1 block + [], # page 1: empty + [{"type": "text", "content": "P2"}, # page 2: 2 blocks + {"type": "table", "content": ""}], + ] + zip_bytes = _make_zip_bytes(content_list=cl) + mock_resp = MagicMock() + mock_resp.content = zip_bytes + mock_resp.raise_for_status = MagicMock() + + with patch("dayu.mineru_runtime.httpx.get", return_value=mock_resp): + _, blocks = _download_and_parse_zip("https://example.com/result.zip") + + assert len(blocks) == 3 + assert blocks[0]["page_idx"] == 0 + assert blocks[1]["page_idx"] == 2 + assert blocks[2]["page_idx"] == 2 + + def test_dict_item_in_content_list(self) -> None: + """content_list 中出现 dict(非 list)→ 也正确处理。""" + cl: list[object] = [ + [{"type": "title", "content": "A"}], + {"type": "text", "content": "B"}, # dict instead of list + ] + zip_bytes = _make_zip_bytes(content_list=cl) + mock_resp = MagicMock() + mock_resp.content = zip_bytes + mock_resp.raise_for_status = MagicMock() + + with patch("dayu.mineru_runtime.httpx.get", return_value=mock_resp): + _, blocks = _download_and_parse_zip("https://example.com/result.zip") + + assert len(blocks) == 2 + assert blocks[0]["page_idx"] == 0 + assert blocks[1]["page_idx"] == 1 + + +# --------------------------------------------------------------------------- +# _convert_mineru_result 测试 +# --------------------------------------------------------------------------- + + +class TestConvertMineruResult: + """MinerU 结果转 ConvertedDocument 测试。""" + + def test_basic_conversion(self) -> None: + """正常结果 → ConvertedDocument。""" + cl = [ + [{"type": "title", "content": "Chapter 1"}], + [{"type": "text", "content": "Body"}, + {"type": "table", "html": "
"}, + {"type": "image", "image_path": "/img/1.jpg", "caption": "fig1"}], + ] + zip_bytes = _make_zip_bytes(markdown="# Chapter 1\nBody", content_list=cl) + mock_resp = MagicMock() + mock_resp.content = zip_bytes + mock_resp.raise_for_status = MagicMock() + + result = _make_mineru_result() + with patch("dayu.mineru_runtime.httpx.get", return_value=mock_resp): + doc = _convert_mineru_result(result, DocumentBackend.MINERU_CLOUD) + + assert doc.backend == DocumentBackend.MINERU_CLOUD + assert doc.raw_markdown == "# Chapter 1\nBody" + assert len(doc.sections) == 2 # title + text + assert len(doc.tables) == 1 + assert len(doc.images) == 1 + + def test_missing_zip_url(self) -> None: + """无 full_zip_url → 空内容。""" + result = {"code": 0, "data": {"task_id": "x", "state": "done"}} + doc = _convert_mineru_result(result, DocumentBackend.MINERU_CLOUD) + assert doc.raw_markdown == "" + assert len(doc.sections) == 0 + + def test_zip_download_failure_graceful(self) -> None: + """zip 下载失败 → 空内容(不抛异常)。""" + result = _make_mineru_result() + with patch( + "dayu.mineru_runtime.httpx.get", + side_effect=Exception("network error"), + ): + doc = _convert_mineru_result(result, DocumentBackend.MINERU_CLOUD) + assert doc.raw_markdown == "" + + def test_unknown_block_type_skipped(self) -> None: + """未知 block type → 跳过,不崩溃。""" + cl = [ + [{"type": "equation", "content": "E=mc2"}, + {"type": "text", "content": "normal"}], + ] + zip_bytes = _make_zip_bytes(content_list=cl) + mock_resp = MagicMock() + mock_resp.content = zip_bytes + mock_resp.raise_for_status = MagicMock() + + result = _make_mineru_result() + with patch("dayu.mineru_runtime.httpx.get", return_value=mock_resp): + doc = _convert_mineru_result(result, DocumentBackend.MINERU_CLOUD) + + # equation 被跳过,只有 text 被保留 + assert len(doc.sections) == 1 + assert len(doc.tables) == 0 + + +# --------------------------------------------------------------------------- +# _merge_chunk_results 测试 +# --------------------------------------------------------------------------- + + +class TestMergeChunkResults: + """多批次结果合并测试。""" + + def test_merge_two_results(self) -> None: + """两个结果合并。""" + cl1 = [[{"type": "title", "content": "Part 1"}]] + cl2 = [[{"type": "title", "content": "Part 2"}]] + zip1 = _make_zip_bytes(markdown="# Part 1", content_list=cl1) + zip2 = _make_zip_bytes(markdown="# Part 2", content_list=cl2) + + mock1 = MagicMock() + mock1.content = zip1 + mock1.raise_for_status = MagicMock() + mock2 = MagicMock() + mock2.content = zip2 + mock2.raise_for_status = MagicMock() + + results = [_make_mineru_result("https://a.com/z1.zip"), + _make_mineru_result("https://a.com/z2.zip")] + + with patch("dayu.mineru_runtime.httpx.get", side_effect=[mock1, mock2]): + doc = _merge_chunk_results(results, DocumentBackend.MINERU_CLOUD) + + assert doc.backend == DocumentBackend.MINERU_CLOUD + assert len(doc.sections) == 2 + assert "# Part 1" in doc.raw_markdown + assert "# Part 2" in doc.raw_markdown + assert doc.metadata["chunk_count"] == "2" + + def test_merge_empty(self) -> None: + """空结果列表 → 空文档。""" + doc = _merge_chunk_results([], DocumentBackend.MINERU_CLOUD) + assert doc.raw_markdown == "" + assert len(doc.sections) == 0 + assert doc.metadata["chunk_count"] == "0" + + def test_merge_preserves_order(self) -> None: + """合并保持输入顺序。""" + cl_a = [[{"type": "text", "content": "A"}]] + cl_b = [[{"type": "text", "content": "B"}]] + zip_a = _make_zip_bytes(markdown="A", content_list=cl_a) + zip_b = _make_zip_bytes(markdown="B", content_list=cl_b) + + mock_a = MagicMock(content=zip_a, raise_for_status=MagicMock()) + mock_b = MagicMock(content=zip_b, raise_for_status=MagicMock()) + + results = [_make_mineru_result("https://a.com/z.zip"), + _make_mineru_result("https://b.com/z.zip")] + + with patch("dayu.mineru_runtime.httpx.get", side_effect=[mock_a, mock_b]): + doc = _merge_chunk_results(results, DocumentBackend.MINERU_CLOUD) + + assert doc.sections[0].content == "A" + assert doc.sections[1].content == "B" + + +# --------------------------------------------------------------------------- +# parse_pdf_bytes_with_mineru 回退链测试 +# --------------------------------------------------------------------------- + + +class TestParsePdfBytesFallback: + """五层回退链集成测试。""" + + def test_no_token_falls_to_docling(self) -> None: + """无 Token → 跳过云 API → 回退 Docling。""" + mock_docling = ConvertedDocument( + backend=DocumentBackend.DOCLING, + raw_markdown="docling result", + ) + with ( + patch("dayu.mineru_runtime._MINERU_API_TOKEN", ""), + patch("dayu.mineru_runtime._parse_with_docling", return_value=mock_docling), + patch("dayu.mineru_runtime._try_parse_with_mineru_cli", return_value=None), + ): + doc = parse_pdf_bytes_with_mineru(b"fake pdf", total_pages=10) + assert doc.backend == DocumentBackend.DOCLING + assert doc.raw_markdown == "docling result" + + def test_quota_exhausted_falls_to_docling(self) -> None: + """配额不足 → 跳过云 API → 回退 Docling。""" + mock_docling = ConvertedDocument( + backend=DocumentBackend.DOCLING, + raw_markdown="fallback", + ) + tracker = MagicMock() + tracker.check_and_consume.return_value = False + with ( + patch("dayu.mineru_runtime._MINERU_API_TOKEN", "tk"), + patch("dayu.mineru_runtime._get_quota_tracker", return_value=tracker), + patch("dayu.mineru_runtime._parse_with_docling", return_value=mock_docling), + patch("dayu.mineru_runtime._try_parse_with_mineru_cli", return_value=None), + ): + doc = parse_pdf_bytes_with_mineru(b"fake pdf", total_pages=10) + assert doc.backend == DocumentBackend.DOCLING + + def test_cloud_api_failure_falls_to_docling(self) -> None: + """云 API 失败 → 回退 Docling。""" + mock_docling = ConvertedDocument( + backend=DocumentBackend.DOCLING, + raw_markdown="fallback", + ) + tracker = MagicMock() + tracker.check_and_consume.return_value = True + with ( + patch("dayu.mineru_runtime._MINERU_API_TOKEN", "tk"), + patch("dayu.mineru_runtime._get_quota_tracker", return_value=tracker), + patch("dayu.mineru_runtime._parse_with_cloud_api", side_effect=MinerUAPIError("fail")), + patch("dayu.mineru_runtime._parse_with_docling", return_value=mock_docling), + patch("dayu.mineru_runtime._try_parse_with_mineru_cli", return_value=None), + ): + doc = parse_pdf_bytes_with_mineru(b"fake pdf", total_pages=10) + assert doc.backend == DocumentBackend.DOCLING + + def test_cloud_api_success_no_fallback(self) -> None: + """云 API 成功 → 不走回退。""" + mock_cloud = ConvertedDocument( + backend=DocumentBackend.MINERU_CLOUD, + raw_markdown="cloud result", + ) + tracker = MagicMock() + tracker.check_and_consume.return_value = True + with ( + patch("dayu.mineru_runtime._MINERU_API_TOKEN", "tk"), + patch("dayu.mineru_runtime._get_quota_tracker", return_value=tracker), + patch("dayu.mineru_runtime._parse_with_cloud_api", return_value=mock_cloud), + ): + doc = parse_pdf_bytes_with_mineru(b"fake pdf", total_pages=10) + assert doc.backend == DocumentBackend.MINERU_CLOUD + assert doc.raw_markdown == "cloud result" + + +# --------------------------------------------------------------------------- +# 层3 CLI 解析测试 +# --------------------------------------------------------------------------- + + +class TestLayer3CLI: + """层3 CLI 解析测试。""" + + def test_cli_not_found_returns_none(self) -> None: + """mock shutil.which 返回 None → return None。""" + from dayu.mineru_runtime import _try_parse_with_mineru_cli + + with patch("dayu.mineru_runtime.shutil.which", return_value=None): + result = _try_parse_with_mineru_cli(b"fake pdf") + assert result is None + + def test_cli_subprocess_error_returns_none(self) -> None: + """mock subprocess.run 抛 CalledProcessError → return None。""" + from dayu.mineru_runtime import _try_parse_with_mineru_cli + + with ( + patch( + "dayu.mineru_runtime.shutil.which", return_value="/usr/bin/magic-pdf" + ), + patch( + "subprocess.run", + side_effect=subprocess.CalledProcessError(1, ["magic-pdf"]), + ), + patch("tempfile.NamedTemporaryFile") as mock_tmp, + patch("tempfile.mkdtemp", return_value="/tmp/mineru_cli_test"), + ): + mock_f = MagicMock() + mock_f.name = "/tmp/test.pdf" + mock_tmp.return_value.__enter__.return_value = mock_f + + result = _try_parse_with_mineru_cli(b"fake pdf") + assert result is None + + def test_cli_timeout_returns_none(self) -> None: + """mock subprocess.run 抛 TimeoutExpired → return None。""" + from dayu.mineru_runtime import _try_parse_with_mineru_cli + + with ( + patch( + "dayu.mineru_runtime.shutil.which", return_value="/usr/bin/magic-pdf" + ), + patch( + "subprocess.run", + side_effect=subprocess.TimeoutExpired( + cmd=["magic-pdf"], timeout=300 + ), + ), + patch("tempfile.NamedTemporaryFile") as mock_tmp, + patch("tempfile.mkdtemp", return_value="/tmp/mineru_cli_test"), + ): + mock_f = MagicMock() + mock_f.name = "/tmp/test.pdf" + mock_tmp.return_value.__enter__.return_value = mock_f + + result = _try_parse_with_mineru_cli(b"fake pdf") + assert result is None + + def test_cli_parse_success(self) -> None: + """mock subprocess + mock 输出文件 → ConvertedDocument(MINERU_LOCAL)。""" + from dayu.mineru_runtime import _try_parse_with_mineru_cli + + mock_md_path = MagicMock() + mock_md_path.exists.return_value = True + mock_md_path.read_text.return_value = "# Valid markdown" + + mock_cl_path = MagicMock() + mock_cl_path.exists.return_value = True + mock_cl_path.read_text.return_value = "[]" + + with ( + patch( + "dayu.mineru_runtime.shutil.which", return_value="/usr/bin/magic-pdf" + ), + patch("subprocess.run"), + patch("tempfile.NamedTemporaryFile") as mock_tmp, + patch("tempfile.mkdtemp", return_value="/tmp/mineru_cli_test"), + ): + mock_f = MagicMock() + mock_f.name = "/tmp/test.pdf" + mock_tmp.return_value.__enter__.return_value = mock_f + + # Mock Path so that Path(output_dir)/pdf_filename/"auto" returns our mock dir + mock_result_dir = MagicMock() + mock_result_dir.__truediv__.side_effect = lambda x: { + "input.pdf.md": mock_md_path, + "input.pdf_content_list.json": mock_cl_path, + }.get(x, MagicMock()) + + mock_pdf_dir = MagicMock() + mock_pdf_dir.__truediv__.return_value = mock_result_dir + + mock_root = MagicMock() + mock_root.__truediv__.return_value = mock_pdf_dir + + with patch("pathlib.Path", return_value=mock_root): + result = _try_parse_with_mineru_cli(b"fake pdf") + + assert result is not None + assert result.backend == DocumentBackend.MINERU_LOCAL + assert result.raw_markdown == "# Valid markdown" + assert len(result.sections) == 0 + + def test_cli_parse_with_content_list(self) -> None: + """mock 返回 table/image blocks → sections/tables/images 正确。""" + from dayu.mineru_runtime import _try_parse_with_mineru_cli + + content_list = [ + [ + {"type": "text", "content": "Some paragraph text"}, + {"type": "title", "content": "Section 1", "level": 2}, + { + "type": "table", + "caption": "Table 1", + "html": "
data
", + }, + { + "type": "figure", + "image_path": "/img/test.png", + "caption": "Figure 1", + }, + ] + ] + + mock_md_path = MagicMock() + mock_md_path.exists.return_value = True + mock_md_path.read_text.return_value = "# Document with content list" + + mock_cl_path = MagicMock() + mock_cl_path.exists.return_value = True + mock_cl_path.read_text.return_value = json.dumps(content_list) + + with ( + patch( + "dayu.mineru_runtime.shutil.which", return_value="/usr/bin/magic-pdf" + ), + patch("subprocess.run"), + patch("tempfile.NamedTemporaryFile") as mock_tmp, + patch("tempfile.mkdtemp", return_value="/tmp/mineru_cli_test"), + ): + mock_f = MagicMock() + mock_f.name = "/tmp/test.pdf" + mock_tmp.return_value.__enter__.return_value = mock_f + + mock_result_dir = MagicMock() + mock_result_dir.__truediv__.side_effect = lambda x: { + "input.pdf.md": mock_md_path, + "input.pdf_content_list.json": mock_cl_path, + }.get(x, MagicMock()) + + mock_pdf_dir = MagicMock() + mock_pdf_dir.__truediv__.return_value = mock_result_dir + + mock_root = MagicMock() + mock_root.__truediv__.return_value = mock_pdf_dir + + with patch("pathlib.Path", return_value=mock_root): + result = _try_parse_with_mineru_cli(b"fake pdf") + + assert result is not None + assert result.backend == DocumentBackend.MINERU_LOCAL + # 1 paragraph + 1 title + assert len(result.sections) == 2 + assert result.sections[0].title == "" + assert result.sections[0].content == "Some paragraph text" + assert result.sections[1].title == "Section 1" + assert result.sections[1].level == 2 + # 1 table + assert len(result.tables) == 1 + assert result.tables[0].caption == "Table 1" + # 1 figure + assert len(result.images) == 1 + assert result.images[0].path == "/img/test.png" + assert result.images[0].caption == "Figure 1" + + def test_cli_parse_no_content_list(self) -> None: + """mock 只有 .md → raw_markdown 有值。""" + from dayu.mineru_runtime import _try_parse_with_mineru_cli + + mock_md_path = MagicMock() + mock_md_path.exists.return_value = True + mock_md_path.read_text.return_value = "# Just markdown\nNo content list." + + mock_cl_path = MagicMock() + mock_cl_path.exists.return_value = False + + with ( + patch( + "dayu.mineru_runtime.shutil.which", return_value="/usr/bin/magic-pdf" + ), + patch("subprocess.run"), + patch("tempfile.NamedTemporaryFile") as mock_tmp, + patch("tempfile.mkdtemp", return_value="/tmp/mineru_cli_test"), + ): + mock_f = MagicMock() + mock_f.name = "/tmp/test.pdf" + mock_tmp.return_value.__enter__.return_value = mock_f + + mock_result_dir = MagicMock() + mock_result_dir.__truediv__.side_effect = lambda x: { + "input.pdf.md": mock_md_path, + "input.pdf_content_list.json": mock_cl_path, + }.get(x, MagicMock()) + + mock_pdf_dir = MagicMock() + mock_pdf_dir.__truediv__.return_value = mock_result_dir + + mock_root = MagicMock() + mock_root.__truediv__.return_value = mock_pdf_dir + + with patch("pathlib.Path", return_value=mock_root): + result = _try_parse_with_mineru_cli(b"fake pdf") + + assert result is not None + assert result.backend == DocumentBackend.MINERU_LOCAL + assert result.raw_markdown == "# Just markdown\nNo content list." + assert len(result.sections) == 0 + assert len(result.tables) == 0 + assert len(result.images) == 0 + + def test_cli_cleanup_temp_files(self) -> None: + """验证 os.unlink 和 shutil.rmtree 被调用。""" + from dayu.mineru_runtime import _try_parse_with_mineru_cli + + with ( + patch( + "dayu.mineru_runtime.shutil.which", return_value="/usr/bin/magic-pdf" + ), + patch("subprocess.run"), + patch("tempfile.NamedTemporaryFile") as mock_tmp, + patch("tempfile.mkdtemp", return_value="/tmp/mineru_cli_test"), + patch("os.unlink") as mock_unlink, + patch("shutil.rmtree") as mock_rmtree, + patch("os.path.exists", return_value=True), + patch("os.path.isdir", return_value=True), + ): + mock_f = MagicMock() + mock_f.name = "/tmp/test.pdf" + mock_tmp.return_value.__enter__.return_value = mock_f + + mock_md_path = MagicMock() + mock_md_path.exists.return_value = True + mock_md_path.read_text.return_value = "# test" + mock_cl_path = MagicMock() + mock_cl_path.exists.return_value = True + mock_cl_path.read_text.return_value = "[]" + + mock_result_dir = MagicMock() + mock_result_dir.__truediv__.side_effect = lambda x: { + "input.pdf.md": mock_md_path, + "input.pdf_content_list.json": mock_cl_path, + }.get(x, MagicMock()) + + mock_pdf_dir = MagicMock() + mock_pdf_dir.__truediv__.return_value = mock_result_dir + + mock_root = MagicMock() + mock_root.__truediv__.return_value = mock_pdf_dir + + with patch("pathlib.Path", return_value=mock_root): + result = _try_parse_with_mineru_cli(b"fake pdf") + + assert result is not None + mock_unlink.assert_called_once_with("/tmp/test.pdf") + mock_rmtree.assert_called_once_with("/tmp/mineru_cli_test", ignore_errors=True) + + def test_cli_exception_does_not_propagate(self) -> None: + """任何异常 → return None。""" + from dayu.mineru_runtime import _try_parse_with_mineru_cli + + with ( + patch( + "dayu.mineru_runtime.shutil.which", return_value="/usr/bin/magic-pdf" + ), + patch( + "subprocess.run", + side_effect=RuntimeError("unexpected error"), + ), + patch("tempfile.NamedTemporaryFile") as mock_tmp, + patch("tempfile.mkdtemp", return_value="/tmp/mineru_cli_test"), + patch("os.path.exists", return_value=True), + patch("os.unlink"), + patch("shutil.rmtree"), + ): + mock_f = MagicMock() + mock_f.name = "/tmp/test.pdf" + mock_tmp.return_value.__enter__.return_value = mock_f + + result = _try_parse_with_mineru_cli(b"fake pdf") + assert result is None + + @pytest.mark.skipif( + not shutil.which("magic-pdf"), + reason="magic-pdf CLI 未安装,跳过集成测试", + ) + def test_cli_integration_smoke(self) -> None: + """集成冒烟测试(需要 magic-pdf 已安装)。""" + from dayu.mineru_runtime import _try_parse_with_mineru_cli + + # TXT-based PDF(纯文本) + pdf_content = b"%PDF-1.4\n1 0 obj<>endobj\n2 0 obj<>endobj\n3 0 obj<>>>>>endobj\n4 0 obj<>stream\nBT /F1 12 Tf 100 700 Td (Hello World) Tj ET\nendstream\nendobj\n5 0 obj<>endobj\nxref\n0 6\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n0000000115 00000 n \n0000000260 00000 n \n0000000360 00000 n \ntrailer<>\nstartxref\n437\n%%EOF" + result = _try_parse_with_mineru_cli(pdf_content) + assert result is not None + assert result.backend == DocumentBackend.MINERU_LOCAL