chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
"""Document parsing layer.
|
||||
|
||||
Sub-packages and modules:
|
||||
|
||||
- ``routing``: parser engine selection and per-file parser directives.
|
||||
- ``debug``: minimal ``LightRAG`` stand-in for offline parser debugging.
|
||||
- ``cli``: ``python -m lightrag.parser.cli`` entry point for single-file
|
||||
parser debugging across all engines.
|
||||
- ``docx``: native ``.docx`` parser. Additional native format parsers
|
||||
should live as sibling sub-packages here (e.g. ``parser/pdf/``).
|
||||
- ``external``: adapters for external parsing services (``mineru``,
|
||||
``docling``) that post to a remote API and cache the raw bundle.
|
||||
"""
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Shared HTML-table parsing helpers for parser engines.
|
||||
|
||||
Pure functions that recover structural facts from an HTML ``<table>`` string
|
||||
without a heavy dependency: row/column counts (colspan-aware), the verbatim
|
||||
``<thead>`` substring, table-payload detection, and ``<html>/<body>`` wrapper
|
||||
stripping. Originally private to the mineru IR builder; lifted into a leaf
|
||||
module so the native markdown IR builder can reuse the exact same logic
|
||||
(merged-cell semantics must survive identically across engines).
|
||||
|
||||
Leaf module with no parser-layer imports — safe for any engine to import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from html.parser import HTMLParser
|
||||
|
||||
from lightrag.utils import logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class HTMLTableInfo:
|
||||
num_rows: int = 0
|
||||
num_cols: int = 0
|
||||
|
||||
|
||||
class _HTMLTableInfoParser(HTMLParser):
|
||||
"""Count ``<tr>`` rows and their (colspan-aware) column widths.
|
||||
|
||||
Used only to recover ``num_rows`` / ``num_cols`` when the engine did not
|
||||
supply them; the ``<thead>`` header itself is preserved verbatim by
|
||||
:func:`extract_thead_html`, not reconstructed here.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
# ``col_count`` (sum of colspans) for each completed top-level ``<tr>``.
|
||||
self.row_col_counts: list[int] = []
|
||||
self._tr_depth = 0
|
||||
self._cell_depth = 0
|
||||
self._row_col_count = 0
|
||||
self._cell_colspan = 1
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
tag = tag.lower()
|
||||
if tag == "tr":
|
||||
if self._tr_depth == 0:
|
||||
self._row_col_count = 0
|
||||
self._tr_depth += 1
|
||||
return
|
||||
if tag in {"td", "th"} and self._tr_depth > 0:
|
||||
if self._cell_depth == 0:
|
||||
self._cell_colspan = _cell_span(attrs, "colspan")
|
||||
self._cell_depth += 1
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
tag = tag.lower()
|
||||
if tag in {"td", "th"} and self._cell_depth > 0:
|
||||
self._cell_depth -= 1
|
||||
if self._cell_depth == 0:
|
||||
self._row_col_count += self._cell_colspan
|
||||
self._cell_colspan = 1
|
||||
return
|
||||
if tag == "tr" and self._tr_depth > 0:
|
||||
self._tr_depth -= 1
|
||||
# ``col_count > 0`` ⇔ the row held at least one cell (colspan ≥ 1),
|
||||
# so an empty ``<tr></tr>`` is skipped exactly as before.
|
||||
if self._tr_depth == 0 and self._row_col_count > 0:
|
||||
self.row_col_counts.append(self._row_col_count)
|
||||
self._row_col_count = 0
|
||||
|
||||
|
||||
def _cell_span(attrs: list[tuple[str, str | None]], name: str) -> int:
|
||||
"""Read a ``colspan``/``rowspan`` attribute as an int ``>= 1`` (default 1)."""
|
||||
for key, value in attrs:
|
||||
if key.lower() != name:
|
||||
continue
|
||||
try:
|
||||
return max(int(value or "1"), 1)
|
||||
except ValueError:
|
||||
return 1
|
||||
return 1
|
||||
|
||||
|
||||
def extract_html_table_info(html: str) -> HTMLTableInfo:
|
||||
parser = _HTMLTableInfoParser()
|
||||
try:
|
||||
parser.feed(html or "")
|
||||
parser.close()
|
||||
except Exception as exc: # pragma: no cover - HTMLParser is forgiving.
|
||||
logger.debug("[html_table] failed to parse table HTML: %s", exc)
|
||||
return HTMLTableInfo()
|
||||
return HTMLTableInfo(
|
||||
num_rows=len(parser.row_col_counts),
|
||||
num_cols=max(parser.row_col_counts, default=0),
|
||||
)
|
||||
|
||||
|
||||
def extract_thead_html(html: str) -> str | None:
|
||||
"""Return the first top-level ``<thead …>…</thead>`` substring verbatim.
|
||||
|
||||
The raw markup is kept so merged-cell semantics (``rowspan`` / ``colspan``)
|
||||
survive into ``tables.json`` and, later, into every repeated header chunk
|
||||
of a split table. Returns ``None`` when the table has no ``<thead>`` or the
|
||||
``<thead>`` carries no visible text (a blank spacer row, which would
|
||||
otherwise emit empty ``<th>`` headers).
|
||||
"""
|
||||
stripped = (html or "").strip()
|
||||
lower = stripped.lower()
|
||||
start = find_html_tag(lower, "thead")
|
||||
if start < 0:
|
||||
return None
|
||||
close = lower.find("</thead>", start)
|
||||
if close < 0:
|
||||
return None
|
||||
thead = stripped[start : close + len("</thead>")]
|
||||
# Blank check: drop a header whose cells hold no non-whitespace text.
|
||||
if not re.sub(r"<[^>]+>", "", thead).strip():
|
||||
return None
|
||||
return thead
|
||||
|
||||
|
||||
def looks_like_html_table_payload(body: str) -> bool:
|
||||
lower = (body or "").lstrip().lower()
|
||||
return any(
|
||||
starts_with_html_tag(lower, tag)
|
||||
for tag in ("table", "thead", "tbody", "tfoot", "tr", "html", "body")
|
||||
)
|
||||
|
||||
|
||||
def unwrap_html_table(payload: str) -> str:
|
||||
"""Strip a ``<html>/<body>`` document wrapper that a table model
|
||||
sometimes emits, returning the outermost ``<table…>…</table>`` span. Keeps
|
||||
a single clean ``<table>`` so the writer does not nest tables and the
|
||||
non-greedy ``TABLE_TAG_RE`` is not truncated at an inner ``</table>``.
|
||||
Falls back to the stripped payload when no ``<table>`` element exists."""
|
||||
stripped = (payload or "").strip()
|
||||
lower = stripped.lower()
|
||||
start = _find_table_open(lower)
|
||||
if start < 0:
|
||||
return stripped
|
||||
close = lower.rfind("</table>")
|
||||
if close < start:
|
||||
return stripped
|
||||
return stripped[start : close + len("</table>")]
|
||||
|
||||
|
||||
def _find_table_open(lower: str) -> int:
|
||||
"""First index of a real ``<table`` start tag (not e.g. ``<tablefoo``).
|
||||
Returns -1 when none is present."""
|
||||
return find_html_tag(lower, "table")
|
||||
|
||||
|
||||
def find_html_tag(lower: str, tag: str) -> int:
|
||||
"""First index of a real ``<tag`` start tag (not e.g. ``<tablefoo`` for
|
||||
``tag="table"``). ``lower`` must already be lower-cased. Returns -1 when
|
||||
none is present."""
|
||||
needle = f"<{tag}"
|
||||
idx = 0
|
||||
while True:
|
||||
idx = lower.find(needle, idx)
|
||||
if idx < 0:
|
||||
return -1
|
||||
nxt = idx + len(needle)
|
||||
if nxt >= len(lower) or lower[nxt] in {" ", "\t", "\r", "\n", ">", "/"}:
|
||||
return idx
|
||||
idx = nxt
|
||||
|
||||
|
||||
def starts_with_html_tag(lower: str, tag: str) -> bool:
|
||||
prefix = f"<{tag}"
|
||||
if not lower.startswith(prefix):
|
||||
return False
|
||||
if len(lower) == len(prefix):
|
||||
return True
|
||||
return lower[len(prefix)] in {" ", "\t", "\r", "\n", ">", "/"}
|
||||
|
||||
|
||||
def html_table_inner_body(html: str) -> str:
|
||||
stripped = (html or "").strip()
|
||||
lower = stripped.lower()
|
||||
if not starts_with_html_tag(lower, "table"):
|
||||
return stripped
|
||||
open_end = _open_tag_end(stripped)
|
||||
close_start = lower.rfind("</table>")
|
||||
if open_end < 0 or close_start <= open_end:
|
||||
return stripped
|
||||
return stripped[open_end + 1 : close_start].strip()
|
||||
|
||||
|
||||
def _open_tag_end(html: str) -> int:
|
||||
"""Index of the ``>`` closing the leading tag, skipping quoted attribute
|
||||
values so a ``>`` inside an attribute (e.g. ``<table data-x="a>b">``) does
|
||||
not terminate the tag early. Returns -1 when no closing ``>`` is found."""
|
||||
quote: str | None = None
|
||||
for idx, ch in enumerate(html):
|
||||
if quote is not None:
|
||||
if ch == quote:
|
||||
quote = None
|
||||
elif ch in {'"', "'"}:
|
||||
quote = ch
|
||||
elif ch == ">":
|
||||
return idx
|
||||
return -1
|
||||
|
||||
|
||||
__all__ = [
|
||||
"HTMLTableInfo",
|
||||
"extract_html_table_info",
|
||||
"extract_thead_html",
|
||||
"looks_like_html_table_payload",
|
||||
"unwrap_html_table",
|
||||
"find_html_tag",
|
||||
"starts_with_html_tag",
|
||||
"html_table_inner_body",
|
||||
]
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Shared markdown rendering helpers for parser engines.
|
||||
|
||||
Used by the native docx parser and the external (mineru / docling) IR
|
||||
builders so heading-line rendering stays identical across engines. This is
|
||||
a leaf module with no heavy imports — ``lightrag/parser/__init__.py`` only
|
||||
carries a docstring — so all three engines can import it without risking a
|
||||
circular dependency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# Markdown caps heading levels at 6 (``######``); deeper outline levels are
|
||||
# clamped to 6 rather than emitting an illegal 7+ run of ``#``.
|
||||
MAX_HEADING_LEVEL = 6
|
||||
|
||||
# A heading line that is ALREADY markdown: 1-6 ``#`` followed by one or more
|
||||
# spaces. Used to avoid double-prefixing text that an upstream engine emitted
|
||||
# with its own markdown heading marker (e.g. mineru/docling extracting
|
||||
# ``# Foo``).
|
||||
#
|
||||
# Ambiguity note: this is a heuristic, not a parse. A heading whose text
|
||||
# genuinely starts with ``#`` plus a space (an author literally writing
|
||||
# ``"# Note"`` as heading content) is indistinguishable from an
|
||||
# already-rendered markdown heading and will be treated as the latter. This
|
||||
# is accepted as a rare edge case in exchange for engine-agnostic dedup.
|
||||
_MD_HEADING_RE = re.compile(r"^#{1,6} +")
|
||||
|
||||
|
||||
def strip_heading_markdown_prefix(text: str) -> str:
|
||||
"""Return heading metadata without an existing markdown heading prefix.
|
||||
|
||||
The content renderer may keep a source line such as ``"# Foo"`` verbatim
|
||||
to avoid double-prefixing, but structured metadata (``heading``,
|
||||
``parent_headings``, doc title) must stay clean. The whole ``#`` run and
|
||||
all following spaces are removed, so ``"# Extra"`` yields ``"Extra"``
|
||||
(no leading space leaks into the metadata).
|
||||
|
||||
See the module-level ambiguity note: text that genuinely begins with a
|
||||
``#`` + space run is stripped here too.
|
||||
"""
|
||||
return _MD_HEADING_RE.sub("", text, count=1)
|
||||
|
||||
|
||||
def render_heading_line(level: int, text: str) -> str:
|
||||
"""Render a heading as a markdown-prefixed content line.
|
||||
|
||||
Args:
|
||||
level: 1-based heading level (1 = H1). Values < 1 are treated as 1;
|
||||
values > :data:`MAX_HEADING_LEVEL` are clamped so a level >= 7
|
||||
heading still gets ``######``.
|
||||
text: The heading text.
|
||||
|
||||
Returns:
|
||||
``text`` unchanged when it already starts with a markdown heading
|
||||
prefix (``^#{1,6} +`` — 1-6 ``#`` then one or more spaces); otherwise
|
||||
``"#" * clamped_level + " " + text``. See the module-level ambiguity
|
||||
note: a heading whose content genuinely begins with such a run is
|
||||
kept verbatim rather than re-prefixed.
|
||||
"""
|
||||
if _MD_HEADING_RE.match(text):
|
||||
return text
|
||||
hashes = "#" * min(max(level, 1), MAX_HEADING_LEVEL)
|
||||
return f"{hashes} {text}"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MAX_HEADING_LEVEL",
|
||||
"render_heading_line",
|
||||
"strip_heading_markdown_prefix",
|
||||
]
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Unified parser contract for native + external parser engines.
|
||||
|
||||
Every engine (native docx, mineru, docling, legacy, plus the internal
|
||||
``reuse``/``passthrough`` format handlers) implements :class:`BaseParser`.
|
||||
The pipeline dispatches through the registry
|
||||
(:mod:`lightrag.parser.registry`) instead of a growing ``if engine == …``
|
||||
chain.
|
||||
|
||||
Design notes:
|
||||
|
||||
- ``BaseParser`` carries *behaviour only* (the ``parse`` coroutine and any
|
||||
engine-private hooks). Capability metadata (supported suffixes, queue
|
||||
group, endpoint requirements) lives in the registry's lightweight
|
||||
``ParserSpec`` table so capability queries never import a parser
|
||||
implementation.
|
||||
- ``ParseResult.to_dict()`` emits only semantically-present fields so the
|
||||
returned dict stays byte-for-byte compatible with the pre-refactor
|
||||
``parse_native``/``parse_mineru``/``parse_docling`` return shapes (the
|
||||
worker reads these by ``.get(...)``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lightrag.sidecar.ir import IRDoc # noqa: F401
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedSource:
|
||||
"""The common parse preamble shared by every engine."""
|
||||
|
||||
source_path: Path
|
||||
document_name: str
|
||||
parsed_dir: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParseContext:
|
||||
"""Inputs handed to :meth:`BaseParser.parse`.
|
||||
|
||||
Wraps the LightRAG instance plus the per-document handles so a parser
|
||||
does not receive a bare ``self``. Convenience helpers lazily import
|
||||
pipeline-layer functions at call time, keeping this module import-cheap
|
||||
and free of import cycles.
|
||||
"""
|
||||
|
||||
rag: Any
|
||||
doc_id: str
|
||||
file_path: str
|
||||
content_data: dict[str, Any]
|
||||
|
||||
def source_path(self, parser_engine: str) -> Path:
|
||||
"""Resolve the on-disk source file for this document."""
|
||||
from lightrag.pipeline import (
|
||||
_call_source_file_resolver,
|
||||
_read_source_file,
|
||||
)
|
||||
|
||||
return Path(
|
||||
_call_source_file_resolver(
|
||||
self.rag,
|
||||
self.file_path,
|
||||
source_file=_read_source_file(self.content_data),
|
||||
parser_engine=parser_engine,
|
||||
)
|
||||
)
|
||||
|
||||
def resolve(self, parser_engine: str) -> ResolvedSource:
|
||||
"""Resolve ``(source_path, document_name, parsed_dir)``.
|
||||
|
||||
Mirrors the preamble shared by ``parse_mineru``/``parse_docling``/
|
||||
``parse_native``: canonicalize the document name defensively (so
|
||||
direct callers may pass absolute or hint-bearing paths) and derive
|
||||
the ``__parsed__/<base>.parsed/`` output directory.
|
||||
"""
|
||||
from lightrag.utils_pipeline import (
|
||||
normalize_document_file_path,
|
||||
parsed_artifact_dir_for,
|
||||
)
|
||||
|
||||
source_path = self.source_path(parser_engine)
|
||||
document_name = normalize_document_file_path(self.file_path)
|
||||
if document_name == "unknown_source":
|
||||
document_name = source_path.name or f"{self.doc_id}.bin"
|
||||
parsed_dir = parsed_artifact_dir_for(
|
||||
document_name, parent_hint=source_path.parent
|
||||
)
|
||||
return ResolvedSource(source_path, document_name, parsed_dir)
|
||||
|
||||
async def archive_source(self, source_path: str) -> str | None:
|
||||
"""Archive the source after a successful parse + full_docs sync.
|
||||
|
||||
Resolved through the pipeline module's namespace (where the function
|
||||
is imported) so existing tests that patch
|
||||
``lightrag.pipeline.archive_docx_source_after_full_docs_sync`` keep
|
||||
intercepting it now that the call site lives in the parser layer.
|
||||
"""
|
||||
import lightrag.pipeline as _pipeline
|
||||
|
||||
return await _pipeline.archive_docx_source_after_full_docs_sync(source_path)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParseResult:
|
||||
"""Structured parser output.
|
||||
|
||||
``to_dict`` only emits fields that carry meaning so the dict matches the
|
||||
pre-refactor return shapes exactly (no spurious ``None``/``False`` keys).
|
||||
"""
|
||||
|
||||
doc_id: str
|
||||
file_path: str
|
||||
parse_format: str
|
||||
content: str
|
||||
blocks_path: str = ""
|
||||
parse_engine: str | None = None
|
||||
parse_stage_skipped: bool = False
|
||||
parse_warnings: dict[str, Any] | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {
|
||||
"doc_id": self.doc_id,
|
||||
"file_path": self.file_path,
|
||||
"parse_format": self.parse_format,
|
||||
"content": self.content,
|
||||
"blocks_path": self.blocks_path,
|
||||
}
|
||||
if self.parse_engine is not None:
|
||||
out["parse_engine"] = self.parse_engine
|
||||
if self.parse_stage_skipped:
|
||||
out["parse_stage_skipped"] = True
|
||||
if self.parse_warnings:
|
||||
out["parse_warnings"] = self.parse_warnings
|
||||
return out
|
||||
|
||||
|
||||
class BaseParser(ABC):
|
||||
"""Abstract base for every parser engine.
|
||||
|
||||
Subclasses set ``engine_name`` and implement :meth:`parse`. Capability
|
||||
metadata (suffixes/queue group/endpoint) is declared in the registry
|
||||
``ParserSpec``, not here.
|
||||
"""
|
||||
|
||||
engine_name: str
|
||||
|
||||
@abstractmethod
|
||||
async def parse(self, ctx: ParseContext) -> ParseResult:
|
||||
"""Parse one document and return its :class:`ParseResult`."""
|
||||
...
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Unified sidecar debug CLI for any registered parser engine.
|
||||
|
||||
Dispatches one source file through the parser registry
|
||||
(``get_parser(engine).parse(...)``) and writes the resulting sidecar (and
|
||||
raw bundle, for external engines) into a flat layout — no ``__parsed__/``
|
||||
middle layer, source file never archived — so the artifacts can be
|
||||
inspected next to the input file. Because dispatch goes through the
|
||||
registry, a third-party engine registered via ``register_parser`` is a
|
||||
valid ``--engine`` choice with no CLI changes.
|
||||
|
||||
Invocation::
|
||||
|
||||
python -m lightrag.parser.cli path/to/sample.docx --engine native
|
||||
python -m lightrag.parser.cli path/to/sample.pdf --engine mineru
|
||||
python -m lightrag.parser.cli path/to/sample.pdf --engine docling --force-reparse
|
||||
|
||||
See ``docs/ParserDebugCLI-zh.md`` for the full reference.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from contextlib import ExitStack
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
|
||||
def _normalize_direct_script_sys_path() -> None:
|
||||
if __package__:
|
||||
return
|
||||
|
||||
parser_dir = Path(__file__).resolve().parent
|
||||
repo_root = parser_dir.parent.parent
|
||||
|
||||
# Direct execution adds lightrag/parser to sys.path, which makes the
|
||||
# native parser's third-party ``docx`` import resolve to parser/docx.
|
||||
sys.path[:] = [
|
||||
entry for entry in sys.path if Path(entry or ".").resolve() != parser_dir
|
||||
]
|
||||
repo_root_str = str(repo_root)
|
||||
if repo_root_str in sys.path:
|
||||
sys.path.remove(repo_root_str)
|
||||
sys.path.insert(0, repo_root_str)
|
||||
|
||||
|
||||
_normalize_direct_script_sys_path()
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
# Registry import is cheap by design (no parser impl is pulled in), so
|
||||
# deriving --engine choices here keeps third-party engines selectable
|
||||
# without making --help pay for a heavy import.
|
||||
from lightrag.parser.registry import supported_parser_engines
|
||||
|
||||
engines = sorted(supported_parser_engines())
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="parse_sidecar",
|
||||
description=(
|
||||
"Run a registered parser engine on a single file and emit sidecar "
|
||||
"artifacts (plus a raw bundle for external engines) into a flat "
|
||||
"layout alongside the source. No __parsed__/ middle layer; the "
|
||||
"source file is never moved."
|
||||
),
|
||||
)
|
||||
parser.add_argument("input_file", type=Path, help="Source file to parse.")
|
||||
parser.add_argument(
|
||||
"--engine",
|
||||
required=True,
|
||||
choices=engines,
|
||||
help="Parser engine to drive.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--sidecar-parent-dir",
|
||||
type=Path,
|
||||
default=None,
|
||||
help=(
|
||||
"Parent directory for <name>.parsed/ and <name>.<engine>_raw/. "
|
||||
"Default: the source file's parent directory."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--doc-id",
|
||||
default=None,
|
||||
help="Override the doc id. Default: doc-<md5(absolute input path)>.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force-reparse",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Only affects mineru/docling. By default a non-empty raw_dir is "
|
||||
"treated as a valid cache and reused without manifest checks; "
|
||||
"this flag clears raw_dir and forces a fresh download/parse."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview",
|
||||
type=int,
|
||||
default=5,
|
||||
metavar="N",
|
||||
help="Number of block rows to preview after parsing (0 disables).",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def _print_summary(blocks_path: Path, raw_dir: Path | None, preview: int) -> None:
|
||||
with blocks_path.open("r", encoding="utf-8") as fh:
|
||||
meta_line = fh.readline().strip()
|
||||
if not meta_line:
|
||||
raise SystemExit(f"empty blocks file at {blocks_path}")
|
||||
meta = json.loads(meta_line)
|
||||
rows = [json.loads(line) for line in fh if line.strip()]
|
||||
parsed_dir = blocks_path.parent
|
||||
print(f"parsed dir : {parsed_dir} (exists={parsed_dir.exists()})")
|
||||
if raw_dir is not None:
|
||||
print(f"raw dir : {raw_dir} (exists={raw_dir.exists()})")
|
||||
print(f"document : {meta.get('document_name')}")
|
||||
print(f"doc_id : {meta.get('doc_id')}")
|
||||
print(f"engine : {meta.get('parse_engine')}")
|
||||
print(f"blocks : {meta.get('blocks')}")
|
||||
print(
|
||||
f"sidecars : tables={meta.get('table_file')} "
|
||||
f"drawings={meta.get('drawing_file')} "
|
||||
f"equations={meta.get('equation_file')} "
|
||||
f"asset_dir={meta.get('asset_dir')}"
|
||||
)
|
||||
if preview > 0 and rows:
|
||||
shown = min(preview, len(rows))
|
||||
print(f"--- preview (first {shown} of {len(rows)} blocks) ---")
|
||||
for row in rows[:preview]:
|
||||
heading = row.get("heading") or ""
|
||||
content = (row.get("content") or "").replace("\n", " ")
|
||||
snippet = content if len(content) <= 80 else content[:77] + "..."
|
||||
print(f" [{row.get('blockid', '')[:8]}] heading={heading!r} :: {snippet}")
|
||||
|
||||
|
||||
def _print_raw_summary(result: dict, preview: int) -> None:
|
||||
"""Summary for engines that emit plain content with no sidecar (legacy /
|
||||
any non-sidecar third-party engine: ``blocks_path`` is empty)."""
|
||||
content = result.get("content") or ""
|
||||
print(f"engine : {result.get('parse_engine')}")
|
||||
print(f"format : {result.get('parse_format')}")
|
||||
print(f"content : {len(content)} chars")
|
||||
if preview > 0 and content:
|
||||
snippet = content[:400]
|
||||
print(f"--- preview (first {len(snippet)} of {len(content)} chars) ---")
|
||||
print(snippet + ("..." if len(content) > len(snippet) else ""))
|
||||
|
||||
|
||||
async def _run(args: argparse.Namespace) -> int:
|
||||
# Pipeline + heavy parser imports are deferred so ``--help`` and the
|
||||
# input-file existence check don't pay for them.
|
||||
from lightrag.constants import FULL_DOCS_FORMAT_PENDING_PARSE
|
||||
from lightrag.parser.base import ParseContext
|
||||
from lightrag.parser.external._base import ExternalParserBase
|
||||
from lightrag.parser.registry import get_parser, suffix_capabilities
|
||||
from lightrag.parser.debug import build_debug_rag
|
||||
from lightrag.parser.docx.parse_document import DocxContentError
|
||||
from lightrag.utils import compute_mdhash_id
|
||||
import lightrag.pipeline as pipeline_mod
|
||||
import lightrag.utils_pipeline as utils_pipeline_mod
|
||||
|
||||
source = args.input_file.resolve()
|
||||
if not source.is_file():
|
||||
print(f"error: input file does not exist: {source}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Reject suffix/engine mismatches up-front: the pipeline would otherwise
|
||||
# fail deep inside the IR builder with a less helpful message.
|
||||
suffix = source.suffix.lstrip(".").lower()
|
||||
supported = suffix_capabilities(args.engine)
|
||||
if suffix not in supported:
|
||||
print(
|
||||
f"error: engine '{args.engine}' does not support .{suffix or '<no suffix>'} "
|
||||
f"files (supported: {', '.join(sorted(supported))})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
parser = get_parser(args.engine)
|
||||
if parser is None:
|
||||
print(f"error: engine '{args.engine}' is not registered", file=sys.stderr)
|
||||
return 1
|
||||
is_external = isinstance(parser, ExternalParserBase)
|
||||
|
||||
sidecar_parent = (args.sidecar_parent_dir or source.parent).resolve()
|
||||
sidecar_parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
parsed_dir = sidecar_parent / f"{source.name}.parsed"
|
||||
# External engines preserve a raw bundle next to the sidecar; its name is
|
||||
# derived from the engine's own raw_dir_suffix (mirrors the flattened
|
||||
# raw_dir_for_parsed_dir layout), so any external engine works here.
|
||||
raw_dir = (
|
||||
sidecar_parent / f"{source.name}{parser.raw_dir_suffix}"
|
||||
if is_external
|
||||
else None
|
||||
)
|
||||
|
||||
doc_id = args.doc_id or compute_mdhash_id(str(source), prefix="doc-")
|
||||
|
||||
def _patched_artifact_dir(
|
||||
file_path: str | None = None,
|
||||
*,
|
||||
parent_hint: Any | None = None,
|
||||
) -> Path:
|
||||
# Flatten the production "<INPUT_DIR>/__parsed__/<base>.parsed/"
|
||||
# layout to "<sidecar_parent>/<source.name>.parsed/" so the sidecar
|
||||
# and the source file sit side by side.
|
||||
return parsed_dir
|
||||
|
||||
def _lenient_bundle(
|
||||
raw_dir_arg: Path, _source_file: Path | None = None, *_args: Any, **_kwargs: Any
|
||||
) -> bool:
|
||||
# Tolerate the ExternalParserBase hook's keyword-only ``engine_params``
|
||||
# (and any future args) — this stub replaces ``is_bundle_valid``.
|
||||
return raw_dir_arg.exists() and any(raw_dir_arg.iterdir())
|
||||
|
||||
def _force_miss(*_args: Any, **_kwargs: Any) -> bool:
|
||||
return False
|
||||
|
||||
bundle_check = _force_miss if args.force_reparse else _lenient_bundle
|
||||
|
||||
async def _noop_archive(*_args: Any, **_kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
rag = build_debug_rag()
|
||||
|
||||
with ExitStack() as stack:
|
||||
# Patch 1: redirect sidecar output to the flat layout.
|
||||
# parsed_artifact_dir_for is from-imported into pipeline at
|
||||
# module load, so patch both namespaces.
|
||||
stack.enter_context(
|
||||
mock.patch.object(
|
||||
utils_pipeline_mod,
|
||||
"parsed_artifact_dir_for",
|
||||
_patched_artifact_dir,
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
mock.patch.object(
|
||||
pipeline_mod,
|
||||
"parsed_artifact_dir_for",
|
||||
_patched_artifact_dir,
|
||||
)
|
||||
)
|
||||
|
||||
# Patch 2: raw cache strategy. ExternalParserBase.parse calls
|
||||
# ``self.is_bundle_valid(...)``, so patch the resolved instance method
|
||||
# directly — works for any external engine without knowing its module.
|
||||
if is_external:
|
||||
stack.enter_context(
|
||||
mock.patch.object(parser, "is_bundle_valid", bundle_check)
|
||||
)
|
||||
|
||||
# Patch 3: keep the source file in place. Every engine archives via
|
||||
# ctx.archive_source -> lightrag.pipeline.archive_docx_source_after_...
|
||||
stack.enter_context(
|
||||
mock.patch.object(
|
||||
pipeline_mod,
|
||||
"archive_docx_source_after_full_docs_sync",
|
||||
_noop_archive,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
result = (
|
||||
await parser.parse(
|
||||
ParseContext(
|
||||
rag,
|
||||
doc_id,
|
||||
str(source),
|
||||
{
|
||||
"parse_format": FULL_DOCS_FORMAT_PENDING_PARSE,
|
||||
"content": "",
|
||||
},
|
||||
)
|
||||
)
|
||||
).to_dict()
|
||||
except DocxContentError as exc:
|
||||
# The native DOCX parser now raises (instead of sys.exit) on a
|
||||
# content-limit violation so the server pipeline can fail just the
|
||||
# offending document. Preserve the friendly CLI UX: print the
|
||||
# formatted message and return a non-zero exit code, no traceback.
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
blocks_path = result.get("blocks_path") or ""
|
||||
if blocks_path:
|
||||
_print_summary(Path(blocks_path), raw_dir, args.preview)
|
||||
else:
|
||||
# No sidecar (legacy / non-sidecar third-party engine): the result
|
||||
# carries plain content, so summarize that instead of a blocks file.
|
||||
_print_raw_summary(result, args.preview)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
# Discover third-party parser engines before --engine choices are built,
|
||||
# so a `lightrag.parsers` entry-point engine is selectable here exactly
|
||||
# like in the server (which loads plugins in create_app).
|
||||
from lightrag.parser.plugins import load_third_party_parsers
|
||||
|
||||
load_third_party_parsers()
|
||||
args = _build_parser().parse_args(argv)
|
||||
return asyncio.run(_run(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Shared debug LightRAG stand-in for registry-dispatched parsing.
|
||||
|
||||
A minimal ``LightRAG`` stand-in plus a deterministic ``datetime`` shim,
|
||||
shared by the unified parser debug CLI (``lightrag/parser/cli.py``),
|
||||
the golden-fixture regen script (``scripts/regen_native_docx_golden.py``),
|
||||
and the byte-equivalence golden tests
|
||||
(``tests/parser/docx/test_native_docx_golden.py``).
|
||||
|
||||
Every engine is driven the same way — ``get_parser(engine).parse(
|
||||
ParseContext(rag, ...))`` — and ``ParseContext`` reads the same ``rag``
|
||||
surface (``_persist_parsed_full_docs``, ``_resolve_source_file_for_parser``,
|
||||
``self.full_docs``, ``self.doc_status``), so a single stand-in covers every
|
||||
engine — when a parser grows a new dependency, extend this module rather
|
||||
than copy-pasting parallel stubs into each call site.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
class DebugFullDocs:
|
||||
"""In-memory ``full_docs`` shim — captures the persisted record."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.data: dict[str, Any] = {}
|
||||
|
||||
async def upsert(self, payload: dict[str, Any]) -> None:
|
||||
self.data.update(payload)
|
||||
|
||||
async def get_by_id(self, doc_id: str) -> Any:
|
||||
return self.data.get(doc_id)
|
||||
|
||||
async def index_done_callback(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class DebugDocStatus:
|
||||
"""No-op ``doc_status`` shim — the parse_* methods never read/write it."""
|
||||
|
||||
async def get_by_id(self, doc_id: str) -> Any:
|
||||
return None
|
||||
|
||||
async def upsert(self, data: dict[str, Any]) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def build_debug_rag():
|
||||
"""Build a minimal LightRAG stand-in that exposes what a parser reads.
|
||||
|
||||
The import of ``LightRAG`` is intentionally function-local: deferring
|
||||
it avoids a circular import when this helper is loaded during package
|
||||
init (the parser CLI resolves ``lightrag.parser.debug`` before
|
||||
``lightrag`` itself is fully bound).
|
||||
|
||||
A parser is driven via ``get_parser(engine).parse(ParseContext(rag, ...))``
|
||||
(CLI / golden tests / regen script). ``ParseContext`` reads these off the
|
||||
``rag`` it is handed — every entry MUST be provided by this stand-in:
|
||||
|
||||
- **methods** (rebound from :class:`LightRAG`):
|
||||
- ``_persist_parsed_full_docs(doc_id, payload)`` — async; touches
|
||||
``self.full_docs``.
|
||||
- ``_resolve_source_file_for_parser(file_path)`` — returns the
|
||||
on-disk source path. Stubbed to identity here since the CLI / tests
|
||||
feed an already-resolved path.
|
||||
- **storages**:
|
||||
- ``self.full_docs.upsert(...)`` / ``.get_by_id(...)`` /
|
||||
``.index_done_callback()`` — :class:`DebugFullDocs` covers all three.
|
||||
- ``self.doc_status.get_by_id(...)`` / ``.upsert(...)`` —
|
||||
:class:`DebugDocStatus` covers both.
|
||||
|
||||
When a parser grows a new dependency on the ``rag`` handed to
|
||||
``ParseContext``, extend this stand-in (and update the list above) rather
|
||||
than copy-pasting a parallel stub into the call sites.
|
||||
"""
|
||||
from lightrag import LightRAG
|
||||
|
||||
class _DebugRag:
|
||||
_persist_parsed_full_docs = LightRAG._persist_parsed_full_docs
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.full_docs = DebugFullDocs()
|
||||
self.doc_status = DebugDocStatus()
|
||||
|
||||
def _resolve_source_file_for_parser(self, file_path: str) -> str:
|
||||
return file_path
|
||||
|
||||
return _DebugRag()
|
||||
|
||||
|
||||
_FROZEN_NOW = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class FrozenDateTime(datetime):
|
||||
"""Pin ``datetime.now`` so ``write_sidecar`` stamps a deterministic time."""
|
||||
|
||||
@classmethod
|
||||
def now(cls, tz=None): # noqa: D401
|
||||
return _FROZEN_NOW if tz is None else _FROZEN_NOW.astimezone(tz)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""LightRAG native DOCX parser package.
|
||||
|
||||
The :mod:`parse_document` / :mod:`numbering_resolver` / :mod:`table_extractor` /
|
||||
:mod:`drawing_image_extractor` / :mod:`utils` / :mod:`omml` modules ship the
|
||||
upstream DOCX extraction logic verbatim (with imports localized for the new
|
||||
package path).
|
||||
|
||||
The pipeline-side orchestration (extract → IR → sidecar) now lives in
|
||||
:meth:`lightrag.pipeline._PipelineMixin.parse_native` so the native and
|
||||
MinerU engines share one shape; see :mod:`lightrag.parser.docx.ir_builder`
|
||||
for the engine IR builder.
|
||||
"""
|
||||
|
||||
__all__: list[str] = []
|
||||
@@ -0,0 +1,445 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ABOUTME: Shared drawing/image extraction utilities for DOCX parsing and editing
|
||||
ABOUTME: Resolves w:drawing -> a:blip relationships, exports embedded images, builds placeholders
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import posixpath
|
||||
import re
|
||||
import shutil
|
||||
import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
from html import escape, unescape
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Dict, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
from defusedxml import ElementTree as ET
|
||||
except ImportError: # pragma: no cover
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
|
||||
NS = {
|
||||
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
|
||||
"wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
|
||||
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
||||
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
||||
"v": "urn:schemas-microsoft-com:vml",
|
||||
}
|
||||
|
||||
REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
|
||||
CONTENT_TYPE_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
|
||||
IMAGE_REL_TYPE = (
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
|
||||
)
|
||||
SOURCE_DOCUMENT_PART = "/word/document.xml"
|
||||
|
||||
# Match old and new drawing placeholders (requires id/name, allows extra attributes)
|
||||
DRAWING_PATTERN = re.compile(
|
||||
r'<drawing\b(?=[^>]*\bid="[^"]*")(?=[^>]*\bname="[^"]*")[^>]*/>'
|
||||
)
|
||||
DRAWING_TAG_PATTERN = re.compile(r"<drawing\b[^>]*/>")
|
||||
DRAWING_ATTR_PATTERN = re.compile(r'([a-zA-Z_][\w:.-]*)="([^"]*)"')
|
||||
|
||||
|
||||
@dataclass
|
||||
class DrawingRelationship:
|
||||
"""Relationship metadata for a single relationship ID."""
|
||||
|
||||
rel_id: str
|
||||
target: str
|
||||
target_mode: str
|
||||
rel_type: str
|
||||
part_name: Optional[str] = None
|
||||
content_type: Optional[str] = None
|
||||
image_format: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DrawingExtractionContext:
|
||||
"""Context used to resolve and export drawing images for one DOCX file."""
|
||||
|
||||
docx_path: Path
|
||||
blocks_output_path: Optional[Path] = None
|
||||
export_dir_name: Optional[str] = None
|
||||
export_dir_path: Optional[Path] = None
|
||||
relationships: Dict[str, DrawingRelationship] = field(default_factory=dict)
|
||||
_exported_part_to_relpath: Dict[str, str] = field(default_factory=dict)
|
||||
_used_filenames: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def resolve_relationship(self, rel_id: str) -> Optional[DrawingRelationship]:
|
||||
return self.relationships.get(rel_id)
|
||||
|
||||
def export_embedded_image(self, rel: DrawingRelationship) -> Optional[str]:
|
||||
"""
|
||||
Export an embedded image relationship target to export_dir.
|
||||
|
||||
Returns:
|
||||
Relative path like "<blocks_stem>.image/image1.png" if exported,
|
||||
or None when export is not applicable.
|
||||
"""
|
||||
if not self.export_dir_path or not self.export_dir_name:
|
||||
return None
|
||||
if rel.target_mode.lower() == "external":
|
||||
return None
|
||||
if not rel.part_name:
|
||||
return None
|
||||
if rel.part_name in self._exported_part_to_relpath:
|
||||
return self._exported_part_to_relpath[rel.part_name]
|
||||
|
||||
zip_member = rel.part_name.lstrip("/")
|
||||
try:
|
||||
with zipfile.ZipFile(self.docx_path, "r") as zf:
|
||||
blob = zf.read(zip_member)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
filename = self._dedupe_filename(PurePosixPath(rel.part_name).name or "image")
|
||||
output_file = self.export_dir_path / filename
|
||||
output_file.write_bytes(blob)
|
||||
|
||||
rel_path = str(PurePosixPath(self.export_dir_name) / filename)
|
||||
self._exported_part_to_relpath[rel.part_name] = rel_path
|
||||
return rel_path
|
||||
|
||||
def _dedupe_filename(self, base_name: str) -> str:
|
||||
if base_name not in self._used_filenames:
|
||||
self._used_filenames[base_name] = base_name
|
||||
return base_name
|
||||
|
||||
stem = Path(base_name).stem
|
||||
suffix = Path(base_name).suffix
|
||||
index = 2
|
||||
while True:
|
||||
candidate = f"{stem}_{index}{suffix}"
|
||||
if candidate not in self._used_filenames:
|
||||
self._used_filenames[candidate] = candidate
|
||||
return candidate
|
||||
index += 1
|
||||
|
||||
|
||||
def _normalize_image_format(ext_or_type: str) -> Optional[str]:
|
||||
if not ext_or_type:
|
||||
return None
|
||||
value = ext_or_type.strip().lower()
|
||||
|
||||
# Content-Type
|
||||
if value.startswith("image/"):
|
||||
value = value.split("/", 1)[1]
|
||||
if "+" in value:
|
||||
value = value.split("+", 1)[0]
|
||||
if value.startswith("x-"):
|
||||
value = value[2:]
|
||||
|
||||
# Extension (with or without leading dot)
|
||||
value = value.lstrip(".")
|
||||
if value == "jpg":
|
||||
return "jpeg"
|
||||
if value in {"jpeg", "png", "gif", "bmp", "tiff", "webp", "svg", "emf", "wmf"}:
|
||||
return value
|
||||
return value or None
|
||||
|
||||
|
||||
def _infer_format_from_target(target: str) -> Optional[str]:
|
||||
if not target:
|
||||
return None
|
||||
parsed = urlparse(target)
|
||||
path = parsed.path if parsed.scheme else target
|
||||
suffix = PurePosixPath(path).suffix
|
||||
return _normalize_image_format(suffix)
|
||||
|
||||
|
||||
def _resolve_part_name(source_part_name: str, target: str) -> str:
|
||||
if target.startswith("/"):
|
||||
return posixpath.normpath(target)
|
||||
source_dir = posixpath.dirname(source_part_name)
|
||||
joined = posixpath.join(source_dir, target)
|
||||
normalized = posixpath.normpath(joined)
|
||||
if not normalized.startswith("/"):
|
||||
normalized = "/" + normalized
|
||||
return normalized
|
||||
|
||||
|
||||
def create_drawing_context(
|
||||
docx_path: str,
|
||||
blocks_output_path: Optional[str] = None,
|
||||
) -> DrawingExtractionContext:
|
||||
"""
|
||||
Create extraction context for a DOCX file.
|
||||
|
||||
If blocks_output_path is provided, this also prepares `<blocks_stem>.image/`
|
||||
beside the blocks file and clears any previous content.
|
||||
"""
|
||||
docx_file = Path(docx_path)
|
||||
ctx = DrawingExtractionContext(docx_path=docx_file)
|
||||
|
||||
if blocks_output_path:
|
||||
output_path = Path(blocks_output_path)
|
||||
export_dir_name = f"{output_path.stem}.image"
|
||||
export_dir_path = output_path.parent / export_dir_name
|
||||
if export_dir_path.exists():
|
||||
shutil.rmtree(export_dir_path)
|
||||
export_dir_path.mkdir(parents=True, exist_ok=True)
|
||||
ctx.blocks_output_path = output_path
|
||||
ctx.export_dir_name = export_dir_name
|
||||
ctx.export_dir_path = export_dir_path
|
||||
|
||||
load_relationships(ctx)
|
||||
return ctx
|
||||
|
||||
|
||||
def load_relationships(ctx: DrawingExtractionContext) -> None:
|
||||
rels_xml = "word/_rels/document.xml.rels"
|
||||
content_types_xml = "[Content_Types].xml"
|
||||
|
||||
overrides: Dict[str, str] = {}
|
||||
defaults: Dict[str, str] = {}
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(ctx.docx_path, "r") as zf:
|
||||
if content_types_xml in zf.namelist():
|
||||
ct_root = ET.parse(zf.open(content_types_xml)).getroot()
|
||||
for node in ct_root.findall(f".//{{{CONTENT_TYPE_NS}}}Override"):
|
||||
part_name = node.get("PartName")
|
||||
content_type = node.get("ContentType")
|
||||
if part_name and content_type:
|
||||
overrides[part_name] = content_type
|
||||
for node in ct_root.findall(f".//{{{CONTENT_TYPE_NS}}}Default"):
|
||||
ext = node.get("Extension")
|
||||
content_type = node.get("ContentType")
|
||||
if ext and content_type:
|
||||
defaults[ext.lower()] = content_type
|
||||
|
||||
if rels_xml not in zf.namelist():
|
||||
return
|
||||
rels_root = ET.parse(zf.open(rels_xml)).getroot()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
for rel in rels_root.findall(f".//{{{REL_NS}}}Relationship"):
|
||||
rel_id = rel.get("Id")
|
||||
target = rel.get("Target", "")
|
||||
target_mode = rel.get("TargetMode", "")
|
||||
rel_type = rel.get("Type", "")
|
||||
if not rel_id:
|
||||
continue
|
||||
|
||||
part_name = None
|
||||
content_type = None
|
||||
image_format = None
|
||||
|
||||
if target_mode.lower() != "external":
|
||||
part_name = _resolve_part_name(SOURCE_DOCUMENT_PART, target)
|
||||
if part_name:
|
||||
content_type = overrides.get(part_name)
|
||||
if not content_type:
|
||||
ext = PurePosixPath(part_name).suffix.lower().lstrip(".")
|
||||
content_type = defaults.get(ext)
|
||||
image_format = _normalize_image_format(
|
||||
content_type or _infer_format_from_target(part_name)
|
||||
)
|
||||
else:
|
||||
image_format = _normalize_image_format(_infer_format_from_target(target))
|
||||
|
||||
ctx.relationships[rel_id] = DrawingRelationship(
|
||||
rel_id=rel_id,
|
||||
target=target,
|
||||
target_mode=target_mode,
|
||||
rel_type=rel_type,
|
||||
part_name=part_name,
|
||||
content_type=content_type,
|
||||
image_format=image_format,
|
||||
)
|
||||
|
||||
|
||||
def _extract_blip_relationship(drawing_elem) -> Optional[Tuple[str, str]]:
|
||||
for blip in drawing_elem.findall(".//a:blip", NS):
|
||||
# Prefer explicit external links when both link/embed are present on one blip.
|
||||
# Word may keep an embedded cache for linked pictures.
|
||||
rel_link = blip.get(f"{{{NS['r']}}}link")
|
||||
if rel_link:
|
||||
return "link", rel_link
|
||||
rel_embed = blip.get(f"{{{NS['r']}}}embed")
|
||||
if rel_embed:
|
||||
return "embed", rel_embed
|
||||
return None
|
||||
|
||||
|
||||
def _extract_imagedata_relationship(container_elem) -> Optional[str]:
|
||||
"""Find an image relationship id from a w:pict / w:object via v:imagedata.
|
||||
|
||||
These legacy VML containers are how Word references EMF/WMF metafiles
|
||||
(and the rendered preview of any embedded OLE object). v:imagedata uses
|
||||
``r:id`` to point at the image part for both embedded and externally
|
||||
linked images — the relationship's ``TargetMode`` is what disambiguates
|
||||
the two cases, so the caller must inspect the resolved relationship.
|
||||
"""
|
||||
r_id_attr = f"{{{NS['r']}}}id"
|
||||
for imgdata in container_elem.findall(".//v:imagedata", NS):
|
||||
rel_id = imgdata.get(r_id_attr)
|
||||
if rel_id:
|
||||
return rel_id
|
||||
return None
|
||||
|
||||
|
||||
def _build_placeholder(attrs: Dict[str, str]) -> str:
|
||||
ordered_keys = ["id", "name", "path", "format"]
|
||||
pieces = []
|
||||
for key in ordered_keys:
|
||||
if key in attrs and attrs[key] is not None:
|
||||
pieces.append(f'{key}="{escape(str(attrs[key]), quote=True)}"')
|
||||
|
||||
# Preserve extra attributes deterministically (sorted by name)
|
||||
for key in sorted(k for k in attrs.keys() if k not in ordered_keys):
|
||||
value = attrs[key]
|
||||
if value is not None:
|
||||
pieces.append(f'{key}="{escape(str(value), quote=True)}"')
|
||||
|
||||
return f"<drawing {' '.join(pieces)} />"
|
||||
|
||||
|
||||
def extract_drawing_placeholder_from_element(
|
||||
drawing_elem,
|
||||
context: Optional[DrawingExtractionContext] = None,
|
||||
include_extended_attrs: bool = True,
|
||||
) -> str:
|
||||
"""
|
||||
Build a <drawing ... /> placeholder from a w:drawing element.
|
||||
|
||||
Behavior:
|
||||
- Always emits id/name from wp:docPr when present.
|
||||
- For embedded images (a:blip@r:embed): exports image and sets path/format.
|
||||
- For linked images (a:blip@r:link): does not download; path is original link target.
|
||||
- When no image reference exists (e.g. chart drawing): keeps id/name only.
|
||||
"""
|
||||
doc_pr = drawing_elem.find(".//wp:docPr", NS)
|
||||
attrs = {
|
||||
"id": doc_pr.get("id", "") if doc_pr is not None else "",
|
||||
"name": doc_pr.get("name", "") if doc_pr is not None else "",
|
||||
}
|
||||
|
||||
if include_extended_attrs:
|
||||
rel_ref = _extract_blip_relationship(drawing_elem)
|
||||
if rel_ref is not None and context is not None:
|
||||
rel_kind, rel_id = rel_ref
|
||||
rel = context.resolve_relationship(rel_id)
|
||||
if rel is not None:
|
||||
if rel_kind == "embed" and rel.rel_type == IMAGE_REL_TYPE:
|
||||
rel_path = context.export_embedded_image(rel)
|
||||
if rel_path:
|
||||
attrs["path"] = rel_path
|
||||
if rel.image_format:
|
||||
attrs["format"] = rel.image_format
|
||||
elif rel_kind == "link":
|
||||
if rel.target:
|
||||
attrs["path"] = rel.target
|
||||
if rel.image_format:
|
||||
attrs["format"] = rel.image_format
|
||||
|
||||
return _build_placeholder(attrs)
|
||||
|
||||
|
||||
def extract_vml_image_placeholder_from_element(
|
||||
container_elem,
|
||||
context: Optional[DrawingExtractionContext] = None,
|
||||
include_extended_attrs: bool = True,
|
||||
) -> str:
|
||||
"""
|
||||
Build a <drawing ... /> placeholder from a w:pict or w:object element.
|
||||
|
||||
Legacy Word documents and OLE-embedded objects (Visio diagrams, equation
|
||||
editor previews, etc.) expose their rendered image via VML rather than
|
||||
DrawingML. The image is referenced through ``<v:imagedata r:id="..."/>``
|
||||
inside ``<v:shape>``, and the underlying bytes are commonly EMF/WMF
|
||||
metafiles. This function exports those bytes through the same context as
|
||||
DrawingML images so EMF/WMF assets land in the blocks.assets directory
|
||||
alongside PNG/JPEG ones.
|
||||
|
||||
The output placeholder format matches
|
||||
``extract_drawing_placeholder_from_element`` so downstream consumers
|
||||
treat both paths uniformly.
|
||||
"""
|
||||
shape = container_elem.find(".//v:shape", NS)
|
||||
attrs = {
|
||||
"id": shape.get("id", "") if shape is not None else "",
|
||||
"name": shape.get("alt", "") if shape is not None else "",
|
||||
}
|
||||
|
||||
if include_extended_attrs:
|
||||
rel_id = _extract_imagedata_relationship(container_elem)
|
||||
if rel_id and context is not None:
|
||||
rel = context.resolve_relationship(rel_id)
|
||||
if rel is not None and rel.rel_type == IMAGE_REL_TYPE:
|
||||
# VML reuses r:id for both embedded image parts and externally
|
||||
# linked images; only the resolved TargetMode tells us which.
|
||||
# Treating an external relationship as embedded would call
|
||||
# export_embedded_image() (which short-circuits on external)
|
||||
# and silently drop the linked path.
|
||||
if rel.target_mode.lower() == "external":
|
||||
if rel.target:
|
||||
attrs["path"] = rel.target
|
||||
if rel.image_format:
|
||||
attrs["format"] = rel.image_format
|
||||
else:
|
||||
rel_path = context.export_embedded_image(rel)
|
||||
if rel_path:
|
||||
attrs["path"] = rel_path
|
||||
if rel.image_format:
|
||||
attrs["format"] = rel.image_format
|
||||
|
||||
return _build_placeholder(attrs)
|
||||
|
||||
|
||||
def parse_drawing_attributes(placeholder: str) -> Dict[str, str]:
|
||||
"""Parse attributes from a <drawing ... /> placeholder."""
|
||||
return {
|
||||
name: unescape(value)
|
||||
for name, value in DRAWING_ATTR_PATTERN.findall(placeholder)
|
||||
}
|
||||
|
||||
|
||||
def normalize_drawing_placeholder(
|
||||
placeholder: str,
|
||||
include_extended_attrs: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Normalize one drawing placeholder into canonical attribute order.
|
||||
|
||||
Args:
|
||||
placeholder: Input placeholder string
|
||||
include_extended_attrs: If False, keeps only id/name.
|
||||
"""
|
||||
attrs = parse_drawing_attributes(placeholder)
|
||||
normalized = {
|
||||
"id": attrs.get("id", ""),
|
||||
"name": attrs.get("name", ""),
|
||||
}
|
||||
if include_extended_attrs:
|
||||
if "path" in attrs:
|
||||
normalized["path"] = attrs["path"]
|
||||
if "format" in attrs:
|
||||
normalized["format"] = attrs["format"]
|
||||
for key, value in attrs.items():
|
||||
if key not in {"id", "name", "path", "format"}:
|
||||
normalized[key] = value
|
||||
return _build_placeholder(normalized)
|
||||
|
||||
|
||||
def normalize_drawing_placeholders_in_text(
|
||||
text: str,
|
||||
include_extended_attrs: bool = False,
|
||||
) -> str:
|
||||
"""Normalize all drawing placeholders inside a text blob."""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
def _replace(match: re.Match) -> str:
|
||||
return normalize_drawing_placeholder(
|
||||
match.group(0),
|
||||
include_extended_attrs=include_extended_attrs,
|
||||
)
|
||||
|
||||
return DRAWING_TAG_PATTERN.sub(_replace, text)
|
||||
@@ -0,0 +1,339 @@
|
||||
"""Native DOCX IR builder: ``extract_docx_blocks`` output → :class:`IRDoc`.
|
||||
|
||||
Input contract: a list of block dicts as produced by
|
||||
``lightrag.parser.docx.parse_document.extract_docx_blocks``. Each
|
||||
block carries ``content`` text in which ``<table>``, ``<equation>`` and
|
||||
``<drawing …/>`` placeholders are already embedded by the upstream parser.
|
||||
The builder rewrites those placeholders into IR placeholder tokens
|
||||
(``{{TBL:k}} / {{EQ:k}} / {{EQI:k}} / {{IMG:k}}``) and builds the matching
|
||||
``IRTable`` / ``IREquation`` / ``IRDrawing`` items.
|
||||
|
||||
Asset bytes are extracted to disk by the upstream parser *before* this
|
||||
builder runs (via ``DrawingExtractionContext`` passed to
|
||||
``extract_docx_blocks``). The builder therefore declares assets with
|
||||
``AssetSpec.source=None`` — the writer records each entry's size without
|
||||
copying.
|
||||
|
||||
Block-vs-inline equation distinction follows the legacy native rule: an
|
||||
``<equation>…</equation>`` tag is *block* iff each side is either the
|
||||
content boundary or a ``\\n`` character. Anything else stays inline,
|
||||
keeps its tag in block text without an id, and never enters
|
||||
``equations.json``.
|
||||
|
||||
Positions are always emitted as ``IRPosition(type="paraid", range=[start,
|
||||
end])`` where each side may be ``None`` (legacy / non-Word docx authors
|
||||
sometimes omit ``w14:paraId``). The writer's ``to_jsonable`` faithfully
|
||||
preserves the per-side null so consumers can distinguish "start missing"
|
||||
vs "both missing".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
from lightrag.parser.docx.drawing_image_extractor import (
|
||||
DRAWING_TAG_PATTERN,
|
||||
parse_drawing_attributes,
|
||||
)
|
||||
from lightrag.sidecar.ir import (
|
||||
AssetSpec,
|
||||
IRBlock,
|
||||
IRDoc,
|
||||
IRDrawing,
|
||||
IREquation,
|
||||
IRPosition,
|
||||
IRTable,
|
||||
)
|
||||
|
||||
|
||||
_TABLE_TAG_RE = re.compile(r"<table>(.*?)</table>", re.DOTALL)
|
||||
_EQUATION_TAG_RE = re.compile(r"<equation>(.*?)</equation>", re.DOTALL)
|
||||
|
||||
|
||||
def _normalize_dimension(rows_value: Any) -> tuple[int, int]:
|
||||
if not isinstance(rows_value, list):
|
||||
return 0, 0
|
||||
num_rows = len(rows_value)
|
||||
num_cols = max((len(r) for r in rows_value if isinstance(r, list)), default=0)
|
||||
return num_rows, num_cols
|
||||
|
||||
|
||||
def _placeholder_keyspace() -> Callable[[str], str]:
|
||||
"""Return a fresh counter producing ``{prefix}{N}`` keys (1-indexed)."""
|
||||
counter = itertools.count(1)
|
||||
return lambda prefix: f"{prefix}{next(counter)}"
|
||||
|
||||
|
||||
def _safe_asset_ref_from_path(path_val: str, asset_prefix: str) -> str | None:
|
||||
"""Return the path inside ``asset_prefix`` only when it is safe.
|
||||
|
||||
Native DOCX images are pre-extracted into ``<base>.blocks.assets/``.
|
||||
Treat a drawing path as local only when the suffix is a clean POSIX
|
||||
relative path. Unsafe local-looking paths are dropped instead of being
|
||||
registered as assets or preserved as linked references.
|
||||
"""
|
||||
if not asset_prefix or not path_val.startswith(asset_prefix):
|
||||
return None
|
||||
|
||||
rel_raw = path_val[len(asset_prefix) :]
|
||||
if not rel_raw or "\\" in rel_raw:
|
||||
return None
|
||||
|
||||
rel_path = PurePosixPath(rel_raw)
|
||||
if rel_path.is_absolute():
|
||||
return None
|
||||
if any(part == ".." for part in rel_path.parts):
|
||||
return None
|
||||
|
||||
rel = rel_path.as_posix()
|
||||
if rel in {"", "."}:
|
||||
return None
|
||||
return rel
|
||||
|
||||
|
||||
@dataclass
|
||||
class _BlockBuilder:
|
||||
"""Per-block scratch state for the three ``re.sub`` rewrite passes.
|
||||
|
||||
Keeping the replacer routines as bound methods (rather than closures
|
||||
redefined inside the per-block loop) means they're compiled once at
|
||||
class-load and the state they mutate — ``tables`` / ``drawings`` /
|
||||
``equations`` / ``table_position`` — is held explicitly rather than
|
||||
captured implicitly from the enclosing frame.
|
||||
"""
|
||||
|
||||
next_key: Callable[[str], str]
|
||||
assets: list[AssetSpec]
|
||||
seen_asset_refs: set[str]
|
||||
asset_prefix: str
|
||||
block_table_headers: list[Any]
|
||||
tables: list[IRTable] = field(default_factory=list)
|
||||
drawings: list[IRDrawing] = field(default_factory=list)
|
||||
equations: list[IREquation] = field(default_factory=list)
|
||||
# Position of the *next* ``<table>`` placeholder within this block,
|
||||
# used to look up the matching entry in ``block_table_headers``.
|
||||
table_position: int = 0
|
||||
|
||||
def replace_table(self, match: "re.Match[str]") -> str:
|
||||
table_body_raw = match.group(1)
|
||||
try:
|
||||
rows = json.loads(table_body_raw)
|
||||
if not isinstance(rows, list):
|
||||
rows = None
|
||||
except json.JSONDecodeError:
|
||||
rows = None
|
||||
|
||||
if rows is not None:
|
||||
parsed_rows: list[list[str]] | None = [
|
||||
[str(c) for c in r] if isinstance(r, list) else [str(r)] for r in rows
|
||||
]
|
||||
html: str | None = None
|
||||
else:
|
||||
parsed_rows = None
|
||||
html = table_body_raw
|
||||
|
||||
num_rows, num_cols = _normalize_dimension(parsed_rows)
|
||||
|
||||
header_pos = self.table_position
|
||||
self.table_position += 1
|
||||
header_rows = (
|
||||
self.block_table_headers[header_pos]
|
||||
if header_pos < len(self.block_table_headers)
|
||||
else None
|
||||
)
|
||||
# Treat empty list / explicit None identically: no header
|
||||
# entry on the sidecar item.
|
||||
table_header = header_rows if header_rows else None
|
||||
|
||||
placeholder = self.next_key("tb")
|
||||
self.tables.append(
|
||||
IRTable(
|
||||
placeholder_key=placeholder,
|
||||
rows=parsed_rows,
|
||||
html=html,
|
||||
num_rows=num_rows,
|
||||
num_cols=num_cols,
|
||||
caption="",
|
||||
footnotes=[],
|
||||
table_header=table_header,
|
||||
body_override=table_body_raw,
|
||||
)
|
||||
)
|
||||
return f"{{{{TBL:{placeholder}}}}}"
|
||||
|
||||
def replace_equation(self, match: "re.Match[str]") -> str:
|
||||
latex = match.group(1)
|
||||
source = match.string
|
||||
start, end = match.start(), match.end()
|
||||
is_block = (start == 0 or source[start - 1] == "\n") and (
|
||||
end == len(source) or source[end] == "\n"
|
||||
)
|
||||
placeholder = self.next_key("eq")
|
||||
self.equations.append(
|
||||
IREquation(
|
||||
placeholder_key=placeholder,
|
||||
latex=latex,
|
||||
is_block=is_block,
|
||||
caption="",
|
||||
footnotes=[],
|
||||
)
|
||||
)
|
||||
token = "EQ" if is_block else "EQI"
|
||||
return f"{{{{{token}:{placeholder}}}}}"
|
||||
|
||||
def replace_drawing(self, match: "re.Match[str]") -> str:
|
||||
attrs = parse_drawing_attributes(match.group(0))
|
||||
path_val = attrs.get("path", "") or ""
|
||||
src_val = attrs.get("src", "") or ""
|
||||
fmt = attrs.get("format", "") or ""
|
||||
if not fmt and path_val:
|
||||
fmt = Path(path_val).suffix.lower().lstrip(".")
|
||||
|
||||
# Two flavours of <drawing path="…">:
|
||||
# 1. Local asset under <base>.blocks.assets/ — already
|
||||
# extracted to disk by DrawingExtractionContext;
|
||||
# register as AssetSpec(source=None) and let the
|
||||
# writer resolve the path via asset_paths.
|
||||
# 2. External/linked path (URL, or any path that does
|
||||
# not live under asset_prefix) — pass through
|
||||
# verbatim via IRDrawing.path_override; do NOT emit
|
||||
# an AssetSpec (no on-disk bytes to materialize).
|
||||
rel_inside_assets = _safe_asset_ref_from_path(path_val, self.asset_prefix)
|
||||
if rel_inside_assets is not None:
|
||||
asset_ref = rel_inside_assets
|
||||
suggested_name = Path(rel_inside_assets).name or rel_inside_assets
|
||||
if asset_ref and asset_ref not in self.seen_asset_refs:
|
||||
self.assets.append(
|
||||
AssetSpec(
|
||||
ref=asset_ref,
|
||||
suggested_name=suggested_name,
|
||||
source=None, # already extracted to disk
|
||||
)
|
||||
)
|
||||
self.seen_asset_refs.add(asset_ref)
|
||||
path_override: str | None = None
|
||||
else:
|
||||
asset_ref = ""
|
||||
# Only mark as an external/linked reference when the
|
||||
# upstream parser actually emitted a path. An empty
|
||||
# ``path=""`` should fall back to the regular asset-
|
||||
# resolution path (which will also produce ``path=""``
|
||||
# downstream) rather than masquerading as an explicit
|
||||
# builder override.
|
||||
path_override = (
|
||||
None
|
||||
if self.asset_prefix and path_val.startswith(self.asset_prefix)
|
||||
else path_val or None
|
||||
)
|
||||
|
||||
placeholder = self.next_key("im")
|
||||
self.drawings.append(
|
||||
IRDrawing(
|
||||
placeholder_key=placeholder,
|
||||
asset_ref=asset_ref,
|
||||
fmt=fmt,
|
||||
caption="",
|
||||
footnotes=[],
|
||||
src=src_val,
|
||||
path_override=path_override,
|
||||
)
|
||||
)
|
||||
return f"{{{{IMG:{placeholder}}}}}"
|
||||
|
||||
|
||||
class NativeDocxIRBuilder:
|
||||
"""Translate ``extract_docx_blocks`` output into an :class:`IRDoc`.
|
||||
|
||||
The builder is stateless — instantiate per call. ``asset_dir_name`` is
|
||||
the relative name (without trailing slash) of ``<base>.blocks.assets/``
|
||||
that the upstream parser used when emitting ``<drawing path>``
|
||||
attributes; the builder strips that prefix when building
|
||||
:attr:`AssetSpec.ref` so the writer's ref↔filename mapping has
|
||||
predictable keys.
|
||||
"""
|
||||
|
||||
def normalize(
|
||||
self,
|
||||
blocks: list[dict[str, Any]],
|
||||
*,
|
||||
document_name: str,
|
||||
asset_dir_name: str,
|
||||
parse_metadata: dict[str, Any] | None = None,
|
||||
) -> IRDoc:
|
||||
next_key = _placeholder_keyspace()
|
||||
ir_blocks: list[IRBlock] = []
|
||||
assets: list[AssetSpec] = []
|
||||
seen_asset_refs: set[str] = set()
|
||||
|
||||
asset_prefix = f"{asset_dir_name}/" if asset_dir_name else ""
|
||||
|
||||
for block in blocks:
|
||||
raw_content = block.get("content") or ""
|
||||
heading = block.get("heading") or ""
|
||||
level = int(block.get("level", 0) or 0)
|
||||
parent_headings = list(block.get("parent_headings") or [])
|
||||
# Preserve per-side nulls in [start, end].
|
||||
uuid_start = block.get("uuid") or None
|
||||
uuid_end = block.get("uuid_end") or None
|
||||
|
||||
builder = _BlockBuilder(
|
||||
next_key=next_key,
|
||||
assets=assets,
|
||||
seen_asset_refs=seen_asset_refs,
|
||||
asset_prefix=asset_prefix,
|
||||
block_table_headers=list(block.get("table_headers") or []),
|
||||
)
|
||||
|
||||
# Rewrite order matches the legacy native flow: tables, then
|
||||
# equations, then drawings — each ``re.sub`` operates on the
|
||||
# output of the previous pass.
|
||||
content_template = _TABLE_TAG_RE.sub(builder.replace_table, raw_content)
|
||||
content_template = _EQUATION_TAG_RE.sub(
|
||||
builder.replace_equation, content_template
|
||||
)
|
||||
content_template = DRAWING_TAG_PATTERN.sub(
|
||||
builder.replace_drawing, content_template
|
||||
)
|
||||
|
||||
positions = [
|
||||
IRPosition(type="paraid", range=[uuid_start, uuid_end]),
|
||||
]
|
||||
|
||||
ir_blocks.append(
|
||||
IRBlock(
|
||||
content_template=content_template,
|
||||
heading=heading,
|
||||
level=level,
|
||||
parent_headings=parent_headings,
|
||||
positions=positions,
|
||||
tables=builder.tables,
|
||||
drawings=builder.drawings,
|
||||
equations=builder.equations,
|
||||
)
|
||||
)
|
||||
|
||||
# doc_title: parse_metadata["first_heading"] when present, else file
|
||||
# stem fallback (resolved here so the writer doesn't have to know).
|
||||
first_heading = ""
|
||||
if isinstance(parse_metadata, dict):
|
||||
first_heading = str(parse_metadata.get("first_heading") or "")
|
||||
doc_title = first_heading or (Path(document_name).stem or document_name)
|
||||
|
||||
return IRDoc(
|
||||
document_name=document_name,
|
||||
document_format=Path(document_name).suffix.lower().lstrip("."),
|
||||
doc_title=doc_title,
|
||||
split_option={},
|
||||
blocks=ir_blocks,
|
||||
assets=assets,
|
||||
bbox_attributes=None,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["NativeDocxIRBuilder"]
|
||||
@@ -0,0 +1,423 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ABOUTME: Resolves automatic numbering labels from DOCX documents
|
||||
ABOUTME: Parses numbering.xml and computes rendered number strings
|
||||
"""
|
||||
|
||||
import zipfile
|
||||
from defusedxml import ElementTree as ET
|
||||
from typing import Dict
|
||||
|
||||
NSMAP = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
|
||||
|
||||
|
||||
class NumberingResolver:
|
||||
"""
|
||||
Resolves paragraph numbering to rendered label strings.
|
||||
|
||||
DOCX stores numbering definitions in numbering.xml:
|
||||
- abstractNum: Defines format templates (lvlText like "%1.%2.")
|
||||
- num: Links numId to abstractNumId
|
||||
|
||||
Each paragraph references: numId (which definition) + ilvl (which level)
|
||||
"""
|
||||
|
||||
# Number format converters
|
||||
FORMAT_CONVERTERS = {
|
||||
"decimal": lambda n: str(n),
|
||||
"lowerLetter": lambda n: chr(ord("a") + (n - 1) % 26),
|
||||
"upperLetter": lambda n: chr(ord("A") + (n - 1) % 26),
|
||||
"lowerRoman": lambda n: NumberingResolver._to_roman(n).lower(),
|
||||
"upperRoman": lambda n: NumberingResolver._to_roman(n),
|
||||
"chineseCountingThousand": lambda n: NumberingResolver._to_chinese(n),
|
||||
"ideographTraditional": lambda n: "甲乙丙丁戊己庚辛壬癸"[(n - 1) % 10],
|
||||
"bullet": lambda n: "•",
|
||||
"none": lambda n: "",
|
||||
}
|
||||
|
||||
def __init__(self, docx_path: str):
|
||||
self.abstract_nums: Dict[str, dict] = {} # abstractNumId -> level definitions
|
||||
self.num_to_abstract: Dict[str, str] = {} # numId -> abstractNumId
|
||||
self.counters: Dict[
|
||||
str, Dict[int, int]
|
||||
] = {} # numId -> {ilvl -> current_count}
|
||||
self.start_overrides: Dict[
|
||||
str, Dict[int, int]
|
||||
] = {} # numId -> {ilvl -> start_value}
|
||||
self.style_numpr: Dict[
|
||||
str, dict
|
||||
] = {} # styleId -> {numId, ilvl} from styles.xml
|
||||
self.style_based_on: Dict[str, str] = {} # styleId -> basedOn styleId
|
||||
# Smart numbering merge state (Word's rendering behavior)
|
||||
self.last_numId: str = None # Previous paragraph's numId
|
||||
self.last_abstract_id: str = None # Previous paragraph's abstractNumId
|
||||
self.last_style_id: str = None # Previous paragraph's style ID
|
||||
self._parse_numbering_xml(docx_path)
|
||||
self._parse_styles_xml(docx_path)
|
||||
|
||||
def _parse_numbering_xml(self, docx_path: str):
|
||||
"""Parse numbering.xml from DOCX archive"""
|
||||
try:
|
||||
with zipfile.ZipFile(docx_path, "r") as zf:
|
||||
if "word/numbering.xml" not in zf.namelist():
|
||||
return
|
||||
|
||||
tree = ET.parse(zf.open("word/numbering.xml"))
|
||||
root = tree.getroot()
|
||||
|
||||
# Parse abstractNum definitions
|
||||
for abstract in root.findall(".//w:abstractNum", NSMAP):
|
||||
abstract_id = abstract.get(f"{{{NSMAP['w']}}}abstractNumId")
|
||||
levels = {}
|
||||
|
||||
for lvl in abstract.findall("w:lvl", NSMAP):
|
||||
ilvl = int(lvl.get(f"{{{NSMAP['w']}}}ilvl"))
|
||||
|
||||
start_elem = lvl.find("w:start", NSMAP)
|
||||
start = (
|
||||
int(start_elem.get(f"{{{NSMAP['w']}}}val"))
|
||||
if start_elem is not None
|
||||
else 1
|
||||
)
|
||||
|
||||
num_fmt_elem = lvl.find("w:numFmt", NSMAP)
|
||||
num_fmt = (
|
||||
num_fmt_elem.get(f"{{{NSMAP['w']}}}val")
|
||||
if num_fmt_elem is not None
|
||||
else "decimal"
|
||||
)
|
||||
|
||||
lvl_text_elem = lvl.find("w:lvlText", NSMAP)
|
||||
lvl_text = (
|
||||
lvl_text_elem.get(f"{{{NSMAP['w']}}}val")
|
||||
if lvl_text_elem is not None
|
||||
else "%1."
|
||||
)
|
||||
|
||||
is_lgl_elem = lvl.find("w:isLgl", NSMAP)
|
||||
is_lgl = False
|
||||
if is_lgl_elem is not None:
|
||||
val = is_lgl_elem.get(f"{{{NSMAP['w']}}}val")
|
||||
is_lgl = val is None or val not in ("0", "false")
|
||||
|
||||
levels[ilvl] = {
|
||||
"start": start,
|
||||
"numFmt": num_fmt,
|
||||
"lvlText": lvl_text,
|
||||
"isLgl": is_lgl,
|
||||
}
|
||||
|
||||
self.abstract_nums[abstract_id] = levels
|
||||
|
||||
# Parse num -> abstractNum mapping and startOverride
|
||||
for num in root.findall(".//w:num", NSMAP):
|
||||
num_id = num.get(f"{{{NSMAP['w']}}}numId")
|
||||
abstract_ref = num.find("w:abstractNumId", NSMAP)
|
||||
if abstract_ref is not None:
|
||||
self.num_to_abstract[num_id] = abstract_ref.get(
|
||||
f"{{{NSMAP['w']}}}val"
|
||||
)
|
||||
|
||||
# Parse lvlOverride/startOverride for this num
|
||||
for lvl_override in num.findall("w:lvlOverride", NSMAP):
|
||||
ilvl = int(lvl_override.get(f"{{{NSMAP['w']}}}ilvl"))
|
||||
start_override = lvl_override.find("w:startOverride", NSMAP)
|
||||
if start_override is not None:
|
||||
start_val = int(start_override.get(f"{{{NSMAP['w']}}}val"))
|
||||
if num_id not in self.start_overrides:
|
||||
self.start_overrides[num_id] = {}
|
||||
self.start_overrides[num_id][ilvl] = start_val
|
||||
except Exception:
|
||||
# Silently ignore parsing errors - document may not have numbering
|
||||
pass
|
||||
|
||||
def _parse_styles_xml(self, docx_path: str):
|
||||
"""Parse styles.xml to get style-inherited numbering definitions"""
|
||||
try:
|
||||
with zipfile.ZipFile(docx_path, "r") as zf:
|
||||
if "word/styles.xml" not in zf.namelist():
|
||||
return
|
||||
|
||||
tree = ET.parse(zf.open("word/styles.xml"))
|
||||
root = tree.getroot()
|
||||
|
||||
# Parse style definitions
|
||||
for style in root.findall(".//w:style", NSMAP):
|
||||
style_id = style.get(f"{{{NSMAP['w']}}}styleId")
|
||||
if not style_id:
|
||||
continue
|
||||
|
||||
# Check for basedOn (style inheritance)
|
||||
based_on = style.find("w:basedOn", NSMAP)
|
||||
if based_on is not None:
|
||||
parent_id = based_on.get(f"{{{NSMAP['w']}}}val")
|
||||
if parent_id:
|
||||
self.style_based_on[style_id] = parent_id
|
||||
|
||||
# Check for numPr in style's pPr
|
||||
pPr = style.find("w:pPr", NSMAP)
|
||||
if pPr is not None:
|
||||
numPr = pPr.find("w:numPr", NSMAP)
|
||||
if numPr is not None:
|
||||
num_id_elem = numPr.find("w:numId", NSMAP)
|
||||
ilvl_elem = numPr.find("w:ilvl", NSMAP)
|
||||
|
||||
if num_id_elem is not None:
|
||||
num_id = num_id_elem.get(f"{{{NSMAP['w']}}}val")
|
||||
ilvl = (
|
||||
int(ilvl_elem.get(f"{{{NSMAP['w']}}}val"))
|
||||
if ilvl_elem is not None
|
||||
else 0
|
||||
)
|
||||
self.style_numpr[style_id] = {
|
||||
"numId": num_id,
|
||||
"ilvl": ilvl,
|
||||
}
|
||||
except Exception:
|
||||
# Silently ignore parsing errors
|
||||
pass
|
||||
|
||||
def _get_numbering_from_style(self, style_id: str, visited=None) -> dict:
|
||||
"""
|
||||
Get numbering definition from style, following inheritance chain.
|
||||
|
||||
Args:
|
||||
style_id: Style ID to look up
|
||||
visited: Set of visited style IDs (to prevent circular references)
|
||||
|
||||
Returns:
|
||||
dict with 'numId' and 'ilvl', or None
|
||||
"""
|
||||
if visited is None:
|
||||
visited = set()
|
||||
|
||||
# Prevent circular references
|
||||
if style_id in visited:
|
||||
return None
|
||||
visited.add(style_id)
|
||||
|
||||
# Check if this style has numPr
|
||||
if style_id in self.style_numpr:
|
||||
return self.style_numpr[style_id]
|
||||
|
||||
# Check parent style
|
||||
if style_id in self.style_based_on:
|
||||
parent_id = self.style_based_on[style_id]
|
||||
return self._get_numbering_from_style(parent_id, visited)
|
||||
|
||||
return None
|
||||
|
||||
def reset_tracking_state(self):
|
||||
"""
|
||||
Reset numbering tracking state.
|
||||
|
||||
Call this when encountering structural breaks that should
|
||||
interrupt numbering continuity:
|
||||
- Section breaks (sectPr)
|
||||
- Table boundaries (before and after tables)
|
||||
|
||||
This prevents incorrect numbering continuation across
|
||||
document structure boundaries.
|
||||
"""
|
||||
self.last_numId = None
|
||||
self.last_abstract_id = None
|
||||
self.last_style_id = None
|
||||
|
||||
def get_label(self, para_element) -> str:
|
||||
"""
|
||||
Get rendered numbering label for a paragraph.
|
||||
|
||||
Checks both direct numPr and style-inherited numbering. Direct numPr
|
||||
is a paragraph-local override and applies only to the current
|
||||
paragraph; subsequent paragraphs that carry only pStyle fall back to
|
||||
the style's numPr declared in styles.xml.
|
||||
|
||||
Args:
|
||||
para_element: lxml Element for <w:p>
|
||||
|
||||
Returns:
|
||||
Rendered label string (e.g., "1.1", "a)", "第一章") or empty string
|
||||
"""
|
||||
try:
|
||||
pPr = para_element.find(f"{{{NSMAP['w']}}}pPr")
|
||||
if pPr is None:
|
||||
return ""
|
||||
|
||||
num_id = None
|
||||
ilvl = 0
|
||||
style_id = None
|
||||
|
||||
# Get pStyle (if present)
|
||||
pStyle = pPr.find(f"{{{NSMAP['w']}}}pStyle")
|
||||
if pStyle is not None:
|
||||
style_id = pStyle.get(f"{{{NSMAP['w']}}}val")
|
||||
|
||||
# Check for direct numPr in paragraph
|
||||
numPr = pPr.find(f"{{{NSMAP['w']}}}numPr")
|
||||
if numPr is not None:
|
||||
num_id_elem = numPr.find(f"{{{NSMAP['w']}}}numId")
|
||||
ilvl_elem = numPr.find(f"{{{NSMAP['w']}}}ilvl")
|
||||
|
||||
if num_id_elem is not None:
|
||||
num_id = num_id_elem.get(f"{{{NSMAP['w']}}}val")
|
||||
ilvl = (
|
||||
int(ilvl_elem.get(f"{{{NSMAP['w']}}}val"))
|
||||
if ilvl_elem is not None
|
||||
else 0
|
||||
)
|
||||
|
||||
# If no direct numPr, fall back to style-inherited numbering.
|
||||
# Direct numPr is a paragraph-local override in Word; it must not
|
||||
# persist as a runtime default for the style, otherwise subsequent
|
||||
# paragraphs that only carry pStyle will keep following the local
|
||||
# override instead of the style's declared numPr.
|
||||
if num_id is None and style_id:
|
||||
style_num = self._get_numbering_from_style(style_id)
|
||||
if style_num:
|
||||
num_id = style_num["numId"]
|
||||
ilvl = style_num["ilvl"]
|
||||
|
||||
# If still no numbering found, clear state and return empty
|
||||
if num_id is None:
|
||||
# We should use list structure breaking logic to reset last_numId, last_abstract_id and last_style_id
|
||||
return ""
|
||||
|
||||
# Get abstract definition
|
||||
abstract_id = self.num_to_abstract.get(num_id)
|
||||
if abstract_id is None or abstract_id not in self.abstract_nums:
|
||||
# Clear state for invalid numbering
|
||||
self.last_numId = None
|
||||
self.last_abstract_id = None
|
||||
return ""
|
||||
|
||||
levels = self.abstract_nums[abstract_id]
|
||||
if ilvl not in levels:
|
||||
# Clear state for invalid level
|
||||
self.last_numId = None
|
||||
self.last_abstract_id = None
|
||||
return ""
|
||||
|
||||
# Smart numbering merge: (Word's rendering behavior)
|
||||
# When consecutive paragraphs have different numId but same abstractNumId,
|
||||
# Word continues the numbering sequence rather than restarting.
|
||||
# This happens regardless of whether the numId is new or style matches.
|
||||
|
||||
if (
|
||||
self.last_numId is not None
|
||||
and self.last_numId != num_id
|
||||
and self.last_abstract_id == abstract_id
|
||||
and self.last_numId in self.counters
|
||||
):
|
||||
# Merge: copy previous numId's counter to current numId
|
||||
self.counters[num_id] = self.counters[self.last_numId].copy()
|
||||
|
||||
# Initialize/update counter
|
||||
if num_id not in self.counters:
|
||||
self.counters[num_id] = {}
|
||||
|
||||
# Initialize all parent levels if not present (for deep nested numbering)
|
||||
for i in range(ilvl):
|
||||
if i not in self.counters[num_id] and i in levels:
|
||||
# Use startOverride if exists, otherwise use abstractNum's start value
|
||||
if (
|
||||
num_id in self.start_overrides
|
||||
and i in self.start_overrides[num_id]
|
||||
):
|
||||
self.counters[num_id][i] = self.start_overrides[num_id][i]
|
||||
else:
|
||||
self.counters[num_id][i] = levels[i]["start"]
|
||||
|
||||
# Reset lower levels when higher level increments
|
||||
for i in range(ilvl + 1, 10):
|
||||
if i in self.counters[num_id]:
|
||||
del self.counters[num_id][i]
|
||||
|
||||
# Initialize current level if needed
|
||||
if ilvl not in self.counters[num_id]:
|
||||
# Use startOverride if exists, otherwise use abstractNum's start value
|
||||
if (
|
||||
num_id in self.start_overrides
|
||||
and ilvl in self.start_overrides[num_id]
|
||||
):
|
||||
self.counters[num_id][ilvl] = self.start_overrides[num_id][ilvl]
|
||||
else:
|
||||
self.counters[num_id][ilvl] = levels[ilvl]["start"]
|
||||
else:
|
||||
self.counters[num_id][ilvl] += 1
|
||||
|
||||
# Format the label using lvlText template
|
||||
label = self._format_label(num_id, ilvl, levels)
|
||||
|
||||
# Update tracking state for next paragraph
|
||||
self.last_numId = num_id
|
||||
self.last_abstract_id = abstract_id
|
||||
self.last_style_id = style_id
|
||||
|
||||
return label
|
||||
except Exception:
|
||||
# Return empty on any error to avoid breaking document parsing
|
||||
return ""
|
||||
|
||||
def _format_label(self, num_id: str, ilvl: int, levels: dict) -> str:
|
||||
"""Format label string by replacing %1, %2, etc."""
|
||||
try:
|
||||
lvl_text = levels[ilvl]["lvlText"]
|
||||
result = lvl_text
|
||||
current_is_lgl = levels[ilvl].get("isLgl", False)
|
||||
|
||||
for i in range(ilvl + 1):
|
||||
if i in levels and i in self.counters.get(num_id, {}):
|
||||
num_fmt = levels[i]["numFmt"]
|
||||
if current_is_lgl and i < ilvl:
|
||||
num_fmt = "decimal"
|
||||
count = self.counters[num_id][i]
|
||||
converter = self.FORMAT_CONVERTERS.get(num_fmt, lambda n: str(n))
|
||||
formatted = converter(count)
|
||||
result = result.replace(f"%{i + 1}", formatted)
|
||||
|
||||
return result
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _to_roman(n: int) -> str:
|
||||
"""Convert integer to Roman numeral"""
|
||||
if n <= 0 or n >= 4000:
|
||||
return str(n)
|
||||
values = [
|
||||
(1000, "M"),
|
||||
(900, "CM"),
|
||||
(500, "D"),
|
||||
(400, "CD"),
|
||||
(100, "C"),
|
||||
(90, "XC"),
|
||||
(50, "L"),
|
||||
(40, "XL"),
|
||||
(10, "X"),
|
||||
(9, "IX"),
|
||||
(5, "V"),
|
||||
(4, "IV"),
|
||||
(1, "I"),
|
||||
]
|
||||
result = ""
|
||||
for value, numeral in values:
|
||||
while n >= value:
|
||||
result += numeral
|
||||
n -= value
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _to_chinese(n: int) -> str:
|
||||
"""Convert integer to Chinese numeral"""
|
||||
digits = "零一二三四五六七八九"
|
||||
if n <= 0 or n > 99:
|
||||
return str(n)
|
||||
if n < 10:
|
||||
return digits[n]
|
||||
if n < 20:
|
||||
return "十" + (digits[n % 10] if n % 10 else "")
|
||||
if n < 100:
|
||||
tens = n // 10
|
||||
ones = n % 10
|
||||
return digits[tens] + "十" + (digits[ones] if ones else "")
|
||||
return str(n)
|
||||
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
ABOUTME: OMML (Office Math Markup Language) to LaTeX conversion
|
||||
"""
|
||||
|
||||
from .ommlparser import OMMLParser
|
||||
|
||||
|
||||
def convert_omml_to_latex(omml_element) -> str:
|
||||
"""Convert an m:oMath XML element to a LaTeX string."""
|
||||
return OMMLParser().parse(omml_element)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
Postprocessing functions for cleaning up latex equations in linear format which don't give valid LaTeX.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
clean_exps = {
|
||||
r"\\degf": "°F",
|
||||
r"\\degc": "°C",
|
||||
r"(\\cbrt)(\w+)": r"\\sqrt[3]{\2}",
|
||||
r"(\\qdrt)(\w+)": r"\\sqrt[4]{\2}",
|
||||
r"\\sfrac": r"\\frac",
|
||||
r"(\\o[i]+nt)(\w+)": r"\1{\2}",
|
||||
r"\\bullet(\w+)": r"\\bullet \1",
|
||||
r"\\sum([a-zA-Z0-9]+)": r"\\sum{\1}",
|
||||
r"\\prod([a-zA-Z0-9]+)": r"\\prod{\1}",
|
||||
r"\\amalg([a-zA-Z0-9]+)": r"\\amalg{\1}",
|
||||
r"\\bigcup([a-zA-Z0-9]+)": r"\\bigcup{\1}",
|
||||
r"\\bigcap([a-zA-Z0-9]+)": r"\\bigcap{\1}",
|
||||
r"\\bigvee([a-zA-Z0-9]+)": r"\\bigvee{\1}",
|
||||
r"\\bigwedge([a-zA-Z0-9]+)": r"\\bigwedge{\1}",
|
||||
r"\\lfloor([a-zA-Z0-9]+)": r"\\lfloor{\1}",
|
||||
r"\\lceil([a-zA-Z0-9]+)": r"\\lceil{\1}",
|
||||
r"\\lim\\below\{(.+)\}\{(.+)\}": r"\\lim_{\1}{\2}",
|
||||
r"\\min\\below\{(.+)\}\{(.+)\}": r"\\min_{\1}{\2}",
|
||||
r"\\max\\below\{(.+)\}\{(.+)\}": r"\\max_{\1}{\2}",
|
||||
}
|
||||
|
||||
|
||||
def clean_exp(exp):
|
||||
"""
|
||||
Takes in a linear expression and converts known invalid LaTeX equations to valid LaTeX
|
||||
:param exp:str - An equation in invalid syntax
|
||||
:return :str - A valid equation
|
||||
"""
|
||||
for e in clean_exps:
|
||||
exp = re.sub(e, clean_exps[e], exp)
|
||||
return exp
|
||||
@@ -0,0 +1,511 @@
|
||||
from xml.etree.cElementTree import Element
|
||||
|
||||
from .utils import qn
|
||||
|
||||
|
||||
class OMMLParser:
|
||||
"""
|
||||
Parser class for reading OMML and converting it into LaTeX.
|
||||
"""
|
||||
|
||||
FUNCTION_MAP = {
|
||||
"sin": "\\sin",
|
||||
"cos": "\\cos",
|
||||
"tan": "\\tan",
|
||||
"cot": "\\cot",
|
||||
"sec": "\\sec",
|
||||
"csc": "\\csc",
|
||||
"sinh": "\\sinh",
|
||||
"cosh": "\\cosh",
|
||||
"tanh": "\\tanh",
|
||||
"coth": "\\coth",
|
||||
"sech": "\\operatorname{sech}",
|
||||
"csch": "\\operatorname{csch}",
|
||||
"log": "\\log",
|
||||
"ln": "\\ln",
|
||||
"min": "\\min",
|
||||
"max": "\\max",
|
||||
"lim": "\\lim",
|
||||
}
|
||||
|
||||
def _normalize_func_name(self, content: str) -> str:
|
||||
if not content:
|
||||
return content
|
||||
if content.startswith("\\"):
|
||||
return content
|
||||
key = content.strip()
|
||||
mapped = self.FUNCTION_MAP.get(key)
|
||||
return mapped if mapped else content
|
||||
|
||||
def parse(self, root: Element) -> str:
|
||||
"""
|
||||
Parses an m:oMath OMML tag into LaTeX.
|
||||
:param root: An m:oMath OMML tag
|
||||
:return: The LaTeX representation of the OMML input
|
||||
"""
|
||||
text = ""
|
||||
try:
|
||||
if root.tag == qn("m:t"):
|
||||
return self.parse_t(root)
|
||||
for child in root:
|
||||
if child.tag in self.parsers:
|
||||
text += self.parsers[child.tag](self, child)
|
||||
except AttributeError:
|
||||
# In case of missing attributes on OMML tags,
|
||||
# we return an empty string (ref:issue_14)
|
||||
return ""
|
||||
return text
|
||||
|
||||
def parse_e(self, root: Element) -> str:
|
||||
text = ""
|
||||
for child in root:
|
||||
text += self.parse(child)
|
||||
return text
|
||||
|
||||
def parse_r(self, root: Element) -> str:
|
||||
# TODO: Add support for m:rPr and m:scr to support different character styles
|
||||
# For now, we just parse the text content of m:r
|
||||
text = ""
|
||||
for child in root:
|
||||
text += self.parse(child)
|
||||
return text
|
||||
|
||||
def parse_t(self, root: Element):
|
||||
symbol_map = {
|
||||
"≜": "\\triangleq",
|
||||
"≝": "\\stackrel{\\tiny def}{=}",
|
||||
"≞": "\\stackrel{\\tiny m}{=}",
|
||||
}
|
||||
replacements = {
|
||||
"<": "\\lt ",
|
||||
">": "\\gt ",
|
||||
"≤": "\\leq ",
|
||||
"≥": "\\geq ",
|
||||
"∞": "\\infty ",
|
||||
"<": "\\lt ",
|
||||
">": "\\gt ",
|
||||
"≤": "\\leq ",
|
||||
"≥": "\\geq ",
|
||||
}
|
||||
text = root.text.split()
|
||||
if not text:
|
||||
return " "
|
||||
for i, t in enumerate(text):
|
||||
if t in symbol_map:
|
||||
text[i] = symbol_map[t]
|
||||
for key, value in replacements.items():
|
||||
for i, t in enumerate(text):
|
||||
text[i] = t.replace(key, value)
|
||||
return " ".join(text)
|
||||
|
||||
def parse_acc(self, root: Element) -> str:
|
||||
character_map = {
|
||||
768: "\\grave",
|
||||
769: "\\acute",
|
||||
770: "\\hat",
|
||||
771: "\\tilde",
|
||||
773: "\\bar",
|
||||
774: "\\breve",
|
||||
775: "\\dot",
|
||||
776: "\\ddot",
|
||||
780: "\\check",
|
||||
831: "\\overline{\\overline",
|
||||
8400: "\\overset\\leftharpoonup",
|
||||
8401: "\\overset\\rightharpoonup",
|
||||
8406: "\\overleftarrow",
|
||||
8407: "\\overrightarrow",
|
||||
8411: "\\dddot",
|
||||
8417: "\\overset\\leftrightarrow",
|
||||
}
|
||||
text = ""
|
||||
accent = 770
|
||||
for child in root:
|
||||
if child.tag == qn("m:accPr"):
|
||||
for child2 in child:
|
||||
if child2.tag == qn("m:chr"):
|
||||
val = child2.attrib.get(qn("m:val"))
|
||||
if val:
|
||||
try:
|
||||
accent = ord(val)
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
accent_cmd = character_map.get(accent)
|
||||
if accent_cmd is None:
|
||||
accent_cmd = character_map.get(770, "\\hat")
|
||||
text += accent_cmd + "{"
|
||||
for child in root:
|
||||
if child.tag == qn("m:e"):
|
||||
text += self.parse(child)
|
||||
text += "}"
|
||||
if accent == 831:
|
||||
text += "}"
|
||||
return text
|
||||
|
||||
def parse_bar(self, root: Element) -> str:
|
||||
text = "\\overline{"
|
||||
for child in root:
|
||||
if child.tag == qn("m:barPr"):
|
||||
for child2 in child:
|
||||
if child2.tag == qn("m:pos"):
|
||||
if child2.attrib.get(qn("m:val")) == "bot":
|
||||
text = "\\underline{"
|
||||
|
||||
for child in root:
|
||||
if child.tag == qn("m:e"):
|
||||
text += self.parse(child)
|
||||
text += "}"
|
||||
return text
|
||||
|
||||
def parse_border_box(self, root: Element) -> str:
|
||||
text = "\\boxed{"
|
||||
for child in root:
|
||||
if child.tag == qn("m:e"):
|
||||
text += self.parse(child)
|
||||
text += "}"
|
||||
return text
|
||||
|
||||
def parse_box(self, root: Element) -> str:
|
||||
text = ""
|
||||
for child in root:
|
||||
text += self.parse(child)
|
||||
return text
|
||||
|
||||
def parse_group_chr(self, root: Element) -> str:
|
||||
character_map = {
|
||||
"←": "\\leftarrow",
|
||||
"→": "\\rightarrow",
|
||||
"↔": "\\leftrightarrow",
|
||||
"⇐": "\\Leftarrow",
|
||||
"⇒": "\\Rightarrow",
|
||||
"⇔": "\\Leftrightarrow",
|
||||
}
|
||||
text = "\\underbrace{"
|
||||
bottom = False
|
||||
for child in root:
|
||||
if child.tag == qn("m:groupChrPr"):
|
||||
for child2 in child:
|
||||
if child2.tag == qn("m:chr"):
|
||||
char = child2.attrib.get(qn("m:val"))
|
||||
if char in character_map:
|
||||
text = character_map[char]
|
||||
for child2 in child:
|
||||
if (
|
||||
child2.tag == qn("m:pos")
|
||||
and child2.attrib.get(qn("m:val")) == "top"
|
||||
):
|
||||
# If m:pos is set to "top", the symbol is supposed to
|
||||
# be on top and the text is actually supposed to be under
|
||||
bottom = True
|
||||
|
||||
content = ""
|
||||
for child in root:
|
||||
if child.tag == qn("m:e"):
|
||||
content = self.parse(child)
|
||||
if text == "\\underbrace{":
|
||||
if bottom:
|
||||
text = "\\overbrace{" + content + "}"
|
||||
else:
|
||||
text += content + "}"
|
||||
else:
|
||||
if not bottom:
|
||||
text = "\\overset{" + content + "}" + "{" + text + "}"
|
||||
else:
|
||||
text = "\\underset{" + content + "}" + "{" + text + "}"
|
||||
return text
|
||||
|
||||
def parse_d(self, root: Element) -> str:
|
||||
bracket_map = {
|
||||
"(": "\\left(",
|
||||
")": "\\right)",
|
||||
"[": "\\left[",
|
||||
"]": "\\right]",
|
||||
"{": "\\left{",
|
||||
"}": "\\right}",
|
||||
"〈": "\\left\\langle",
|
||||
"〉": "\\right\\rangle",
|
||||
"⟨": "\\left\\langle",
|
||||
"⟩": "\\right\\rangle",
|
||||
"⌊": "\\left\\lfloor",
|
||||
"⌋": "\\right\\rfloor",
|
||||
"⌈": "\\left\\lceil",
|
||||
"⌉": "\\right\\rceil",
|
||||
"|": "\\left|",
|
||||
"‖": "\\left\\|",
|
||||
"⟦": "[\\![",
|
||||
"⟧": "]\\!]",
|
||||
}
|
||||
text = ""
|
||||
start_bracket = "("
|
||||
end_bracket = ")"
|
||||
seperator = "|"
|
||||
is_matrix = False
|
||||
for child in root:
|
||||
for child2 in child:
|
||||
if child.tag == qn("m:dPr"):
|
||||
if child2.tag == qn("m:begChr"):
|
||||
start_bracket = child2.attrib.get(qn("m:val"))
|
||||
if child2.tag == qn("m:endChr"):
|
||||
end_bracket = child2.attrib.get(qn("m:val"))
|
||||
if child2.tag == qn("m:sepChr"):
|
||||
seperator = child2.attrib.get(qn("m:val"))
|
||||
if child2.tag == qn("m:m"):
|
||||
is_matrix = True
|
||||
|
||||
for child in root:
|
||||
if child.tag == qn("m:e"):
|
||||
if text:
|
||||
text += seperator
|
||||
text += self.parse(child)
|
||||
end_bracket_replacements = {
|
||||
"|": "\\right|",
|
||||
"‖": "\\right\\|",
|
||||
"[": "\\right[",
|
||||
}
|
||||
start_bracket_replacements = {
|
||||
"]": "\\left]",
|
||||
}
|
||||
start = ""
|
||||
end = ""
|
||||
if start_bracket:
|
||||
if start_bracket in start_bracket_replacements:
|
||||
start = start_bracket_replacements[start_bracket] + " "
|
||||
elif start_bracket in bracket_map:
|
||||
start = bracket_map[start_bracket] + " "
|
||||
else:
|
||||
start = "\\left(" + " "
|
||||
if end_bracket:
|
||||
if end_bracket in end_bracket_replacements:
|
||||
end = " " + end_bracket_replacements[end_bracket]
|
||||
elif end_bracket in bracket_map:
|
||||
end = " " + bracket_map[end_bracket]
|
||||
else:
|
||||
end = " " + "\\right)"
|
||||
# If there is no end bracket and this tag contains an m:eqArr tag as a
|
||||
# child, we assume that the eqArr should be translated to a cases environment
|
||||
# instead of an eqnarray* environment.
|
||||
else:
|
||||
for child in root:
|
||||
if child.tag == qn("m:e"):
|
||||
for child2 in child:
|
||||
if child2.tag == qn("m:eqArr"):
|
||||
text = text.replace("\\begin{eqnarray*}", "")
|
||||
text = text.replace("\\end{eqnarray*}", "")
|
||||
return "\\begin{cases} " + text + " \\end{cases}"
|
||||
if is_matrix:
|
||||
if start_bracket == "(" and end_bracket == ")":
|
||||
return text.replace("{matrix}", "{pmatrix}")
|
||||
elif start_bracket == "|" and end_bracket == "|":
|
||||
return text.replace("{matrix}", "{vmatrix}")
|
||||
elif start_bracket == "‖" and end_bracket == "‖":
|
||||
return text.replace("{matrix}", "{Vmatrix}")
|
||||
else:
|
||||
return text.replace("{matrix}", "{bmatrix}")
|
||||
return start + text + end
|
||||
|
||||
def parse_eq_arr(self, root: Element) -> str:
|
||||
text = "\\begin{eqnarray*}"
|
||||
for child in root:
|
||||
if child.tag == qn("m:e"):
|
||||
text += self.parse(child) + " \\\\"
|
||||
text += "\\end{eqnarray*}"
|
||||
return text
|
||||
|
||||
def parse_f(self, root: Element) -> str:
|
||||
text = "\\frac{"
|
||||
num = ""
|
||||
den = ""
|
||||
is_binom = False
|
||||
for child in root:
|
||||
if child.tag == qn("m:fPr"):
|
||||
for child2 in child:
|
||||
if (
|
||||
child2.tag == qn("m:type")
|
||||
and child2.attrib.get(qn("m:val")) == "noBar"
|
||||
):
|
||||
is_binom = True
|
||||
if child.tag == qn("m:num"):
|
||||
num = self.parse(child)
|
||||
if child.tag == qn("m:den"):
|
||||
den = self.parse(child)
|
||||
if is_binom:
|
||||
text = "\\genfrac{}{}{0pt}{}{"
|
||||
text += num + "}{" + den + "}"
|
||||
return text
|
||||
|
||||
def parse_m(self, root: Element) -> str:
|
||||
text = "\\begin{matrix} "
|
||||
text += self.parse(root)[:-3] # Remove the last ' \\'
|
||||
text += "\\end{matrix}"
|
||||
return text
|
||||
|
||||
def parse_mr(self, root: Element) -> str:
|
||||
text = ""
|
||||
for child in root:
|
||||
if child.tag == qn("m:e"):
|
||||
text += self.parse(child) + " & "
|
||||
return text[:-2] + "\\\\ " # Remove the last ' & '
|
||||
|
||||
def parse_func(self, root: Element) -> str:
|
||||
subscript = ""
|
||||
superscript = ""
|
||||
text = ""
|
||||
func_name = "sin"
|
||||
for child in root:
|
||||
if child.tag == qn("m:fName"):
|
||||
for child2 in child:
|
||||
if child2.tag in [qn("m:sSup"), qn("m:sSub"), qn("m:r")]:
|
||||
for child3 in child2:
|
||||
if child3.tag == qn("m:sub"):
|
||||
subscript = self.parse(child3)
|
||||
if child3.tag == qn("m:sup"):
|
||||
superscript = self.parse(child3)
|
||||
if child3.tag == qn("m:t") or child3.tag == qn("m:e"):
|
||||
func_name = self.parse(child3)
|
||||
elif child2.tag == qn("m:limLow"):
|
||||
for child3 in child2:
|
||||
if child3.tag == qn("m:lim"):
|
||||
for child4 in child3:
|
||||
subscript += self.parse(child4)
|
||||
if child3.tag == qn("m:e"):
|
||||
func_name = self.parse(child3)
|
||||
|
||||
if child.tag == qn("m:e"):
|
||||
text += self.parse(child)
|
||||
if func_name in ["lim", "max", "min"]:
|
||||
return f"\\{func_name}\\limits_{{{subscript}}}^{{{superscript}}}{{{text}}}"
|
||||
if func_name not in self.FUNCTION_MAP:
|
||||
return f"{{{func_name}}}^{{{superscript}}}_{{{subscript}}}{{{text}}}"
|
||||
return (
|
||||
self.FUNCTION_MAP[func_name]
|
||||
+ f"_{{{subscript}}}^{{{superscript}}}{{{text}}}"
|
||||
)
|
||||
|
||||
def parse_s_sup(self, root: Element) -> str:
|
||||
content = ""
|
||||
exp_content = ""
|
||||
for child in root:
|
||||
if child.tag == qn("m:e"):
|
||||
content = self.parse(child)
|
||||
if child.tag == qn("m:sup"):
|
||||
exp_content = self.parse(child)
|
||||
content = self._normalize_func_name(content)
|
||||
return f"{{{content}}}^{{{exp_content}}}"
|
||||
|
||||
def parse_s_sub(self, root: Element) -> str:
|
||||
content = ""
|
||||
sub_content = ""
|
||||
for child in root:
|
||||
if child.tag == qn("m:e"):
|
||||
content = self.parse(child)
|
||||
if child.tag == qn("m:sub"):
|
||||
sub_content = self.parse(child)
|
||||
content = self._normalize_func_name(content)
|
||||
return f"{{{content}}}_{{{sub_content}}}"
|
||||
|
||||
def parse_s_sub_sup(self, root: Element) -> str:
|
||||
content = ""
|
||||
sub_content = ""
|
||||
exp_content = ""
|
||||
for child in root:
|
||||
if child.tag == qn("m:e"):
|
||||
content = self.parse(child)
|
||||
if child.tag == qn("m:sub"):
|
||||
sub_content = self.parse(child)
|
||||
if child.tag == qn("m:sup"):
|
||||
exp_content = self.parse(child)
|
||||
content = self._normalize_func_name(content)
|
||||
return f"{{{content}}}_{{{sub_content}}}^{{{exp_content}}}"
|
||||
|
||||
def parse_s_pre(self, root: Element) -> str:
|
||||
content = ""
|
||||
sub_content = ""
|
||||
exp_content = ""
|
||||
for child in root:
|
||||
if child.tag == qn("m:e"):
|
||||
content = self.parse(child)
|
||||
if child.tag == qn("m:sub"):
|
||||
sub_content = self.parse(child)
|
||||
if child.tag == qn("m:sup"):
|
||||
exp_content = self.parse(child)
|
||||
return "{}^{" + exp_content + "}_{" + sub_content + "}{" + content + "}"
|
||||
|
||||
def parse_rad(self, root: Element) -> str:
|
||||
content = ""
|
||||
order = ""
|
||||
for child in root:
|
||||
if child.tag == qn("m:deg"):
|
||||
order = self.parse(child)
|
||||
if child.tag == qn("m:e"):
|
||||
content += self.parse(child)
|
||||
if order:
|
||||
return f"\\sqrt[{order}]{{{content}}}"
|
||||
return f"\\sqrt{{{content}}}"
|
||||
|
||||
def parse_nary(self, root: Element) -> str:
|
||||
character_map = {
|
||||
8719: "\\prod",
|
||||
8720: "\\coprod",
|
||||
8721: "\\sum",
|
||||
8747: "\\int",
|
||||
8748: "\\iint",
|
||||
8749: "\\iiint",
|
||||
8750: "\\oint",
|
||||
8751: "\\oiint",
|
||||
8752: "\\oiiint",
|
||||
8896: "\\bigwedge",
|
||||
8897: "\\bigvee",
|
||||
8898: "\\bigcap",
|
||||
8899: "\\bigcup",
|
||||
}
|
||||
char = 8747
|
||||
for child in root:
|
||||
if child.tag == qn("m:naryPr"):
|
||||
for child2 in child:
|
||||
if child2.tag == qn("m:chr"):
|
||||
val = child2.attrib.get(qn("m:val"))
|
||||
if val:
|
||||
try:
|
||||
char = ord(val)
|
||||
except TypeError:
|
||||
pass
|
||||
text = character_map.get(char, character_map[8721])
|
||||
sub = ""
|
||||
sup = ""
|
||||
content = ""
|
||||
for child in root:
|
||||
if child.tag == qn("m:sub"):
|
||||
sub = self.parse(child)
|
||||
if child.tag == qn("m:sup"):
|
||||
sup = self.parse(child)
|
||||
if child.tag == qn("m:e"):
|
||||
content = self.parse(child)
|
||||
if sub:
|
||||
text += f"_{{{sub}}}"
|
||||
if sup:
|
||||
text += f"^{{{sup}}}"
|
||||
text += "{" + content + "}"
|
||||
return text
|
||||
|
||||
parsers = {
|
||||
qn("m:r"): parse_r,
|
||||
qn("m:acc"): parse_acc,
|
||||
qn("m:borderBox"): parse_border_box,
|
||||
qn("m:bar"): parse_bar,
|
||||
qn("m:box"): parse_box,
|
||||
qn("m:d"): parse_d,
|
||||
qn("m:e"): parse_e,
|
||||
qn("m:groupChr"): parse_group_chr,
|
||||
qn("m:f"): parse_f,
|
||||
qn("m:sSup"): parse_s_sup,
|
||||
qn("m:sSub"): parse_s_sub,
|
||||
qn("m:sSubSup"): parse_s_sub_sup,
|
||||
qn("m:sPre"): parse_s_pre,
|
||||
qn("m:t"): parse_t,
|
||||
qn("m:rad"): parse_rad,
|
||||
qn("m:nary"): parse_nary,
|
||||
qn("m:eqArr"): parse_eq_arr,
|
||||
qn("m:func"): parse_func,
|
||||
qn("m:m"): parse_m,
|
||||
qn("m:mr"): parse_mr,
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Utility functions to extract text from the supported mathematical equations from xml tags and
|
||||
convert them into LaTeX
|
||||
"""
|
||||
|
||||
from .cleaners import clean_exp
|
||||
|
||||
ns_map = {
|
||||
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
|
||||
"m": "http://schemas.openxmlformats.org/officeDocument/2006/math",
|
||||
}
|
||||
|
||||
|
||||
def linear_expression(tag):
|
||||
"""
|
||||
Just returns the text contained in the given tag while setting docxlatex_skip_iteration flags
|
||||
for all its children.
|
||||
:param tag:defusedxml.Element - An xml element which contains a math equation in linear form
|
||||
:return text:str - The equation in valid LaTeX syntax
|
||||
"""
|
||||
text = ""
|
||||
for child in tag.iter():
|
||||
child.set("docxlatex_skip_iteration", True)
|
||||
text += child.text if child.text is not None else ""
|
||||
text = clean_exp(text)
|
||||
return text
|
||||
|
||||
|
||||
def qn(tag):
|
||||
"""
|
||||
A utility function to turn a namespace
|
||||
prefixed tag name into a Clark-notation qualified tag name for lxml. For
|
||||
example, qn('m:oMath') returns '{http://schemas.openxmlformats.org/officeDocument/2006/math}oMath'
|
||||
|
||||
:param tag:str - A namespace-prefixed tag name
|
||||
:return qn:str - A Clark-notation qualified name tag for lxml.
|
||||
"""
|
||||
prefix, tag_root = tag.split(":")
|
||||
uri = ns_map[prefix]
|
||||
return "{{{}}}{}".format(uri, tag_root)
|
||||
Executable
+932
@@ -0,0 +1,932 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ABOUTME: Parses DOCX documents into text blocks using python-docx
|
||||
ABOUTME: Extracts automatic numbering, splits by headings, converts tables to JSON
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
from docx import Document
|
||||
from docx.opc.exceptions import PackageNotFoundError
|
||||
except ImportError as exc:
|
||||
# Raise instead of sys.exit: this module is imported in-process by the
|
||||
# gunicorn/uvicorn worker, where a SystemExit would tear down the whole
|
||||
# worker rather than surfacing a normal, catchable error.
|
||||
raise ImportError(
|
||||
"python-docx not installed. Run: pip install python-docx"
|
||||
) from exc
|
||||
|
||||
from lightrag.parser._markdown import (
|
||||
render_heading_line,
|
||||
strip_heading_markdown_prefix,
|
||||
)
|
||||
from .numbering_resolver import NumberingResolver
|
||||
from .table_extractor import TableExtractor
|
||||
from .drawing_image_extractor import (
|
||||
DrawingExtractionContext,
|
||||
extract_drawing_placeholder_from_element,
|
||||
extract_vml_image_placeholder_from_element,
|
||||
)
|
||||
|
||||
|
||||
# Constants for content validation (character-based for UI/display)
|
||||
MAX_HEADING_LENGTH = 200 # Maximum heading length in characters (UI constraint)
|
||||
|
||||
# OOXML tracked-change/comment tags whose subtree must be dropped so we only
|
||||
# surface the *final* revised text. w:ins / w:moveTo are kept via default
|
||||
# recursion so inserted/moved-in content survives.
|
||||
_SKIP_REVISION_TAGS = frozenset({"del", "moveFrom"})
|
||||
_SKIP_COMMENT_TAGS = frozenset(
|
||||
{"commentRangeStart", "commentRangeEnd", "commentReference", "annotationRef"}
|
||||
)
|
||||
_SKIP_PARAGRAPH_TAGS = _SKIP_REVISION_TAGS | _SKIP_COMMENT_TAGS
|
||||
|
||||
|
||||
class DocxContentError(ValueError):
|
||||
"""DOCX content violates a parsing constraint (heading/table/anchor limits).
|
||||
|
||||
Raised instead of calling ``sys.exit`` so the pipeline's per-document
|
||||
``except Exception`` handler marks just that document FAILED while the
|
||||
gunicorn/uvicorn worker process keeps running. Subclasses ``ValueError``
|
||||
(i.e. an ``Exception``, not ``BaseException``) so the existing pipeline
|
||||
handlers catch it.
|
||||
"""
|
||||
|
||||
|
||||
def format_error(title: str, details: str, solution: str) -> str:
|
||||
"""
|
||||
Build a friendly, formatted error message (title / details / SOLUTION).
|
||||
|
||||
Args:
|
||||
title: Error title
|
||||
details: Detailed error information
|
||||
solution: Suggested solution steps
|
||||
|
||||
Returns:
|
||||
str: The formatted multi-line message.
|
||||
"""
|
||||
return (
|
||||
"\n"
|
||||
+ "=" * 68
|
||||
+ f"\nERROR: {title}\n"
|
||||
+ "=" * 68
|
||||
+ f"\n\n{details}"
|
||||
+ "\n\nSOLUTION:\n"
|
||||
+ solution
|
||||
+ "\n\n"
|
||||
+ "=" * 68
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
|
||||
def print_error(title: str, details: str, solution: str):
|
||||
"""Print a friendly, formatted error message to stderr."""
|
||||
print(format_error(title, details, solution), file=sys.stderr)
|
||||
|
||||
|
||||
def _diagnose_invalid_docx(file_path: str) -> tuple[str, str]:
|
||||
"""Diagnose why a ``.docx`` file is not a valid OOXML/ZIP package.
|
||||
|
||||
python-docx raises ``PackageNotFoundError("Package not found at '...'")``
|
||||
both when the path is missing AND when the file exists but is not a valid
|
||||
zip. By the time this runs the file has already been confirmed to exist
|
||||
(the native worker validates ``p.exists()`` first), so the real cause is a
|
||||
corrupt file or a non-DOCX payload wearing a ``.docx`` extension. Sniff the
|
||||
magic bytes to name the actual format so the error message reflects the
|
||||
real problem instead of an empty "not found".
|
||||
|
||||
Returns a ``(details, solution)`` tuple for :func:`format_error`. Reads only
|
||||
the file header and never raises — any IO failure degrades to a generic
|
||||
"cannot read" diagnosis.
|
||||
"""
|
||||
import zipfile
|
||||
|
||||
convert_solution = (
|
||||
" 1. Open the file in Microsoft Word or WPS\n"
|
||||
' 2. Use "Save As" and choose "Word Document (*.docx)"\n'
|
||||
" 3. Re-upload the converted .docx to LightRAG"
|
||||
)
|
||||
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
head = f.read(8)
|
||||
except OSError as exc:
|
||||
return (
|
||||
f"The file at '{file_path}' could not be read: {exc}",
|
||||
" 1. Verify the file exists and is readable\n"
|
||||
" 2. Re-upload it to LightRAG",
|
||||
)
|
||||
|
||||
if not head:
|
||||
return (
|
||||
f"The file at '{file_path}' is empty (0 bytes). The upload was "
|
||||
"likely truncated or the source file is corrupt.",
|
||||
" 1. Check the original document opens correctly\n"
|
||||
" 2. Re-upload a complete copy to LightRAG",
|
||||
)
|
||||
|
||||
if head.startswith(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"):
|
||||
# OLE2 Compound File — the legacy binary Word 97-2003 .doc format.
|
||||
return (
|
||||
f"The file at '{file_path}' is a legacy Word 97-2003 (.doc) "
|
||||
"document saved with a .docx extension. The .doc binary format is "
|
||||
"not a ZIP/OOXML package and cannot be parsed by the native engine.",
|
||||
convert_solution,
|
||||
)
|
||||
|
||||
if head.startswith(b"{\\rtf"):
|
||||
return (
|
||||
f"The file at '{file_path}' is an RTF document saved with a .docx "
|
||||
"extension. RTF is not a ZIP/OOXML package.",
|
||||
convert_solution,
|
||||
)
|
||||
|
||||
if head.startswith(b"%PDF"):
|
||||
return (
|
||||
f"The file at '{file_path}' is a PDF saved with a .docx extension. "
|
||||
"It is not a ZIP/OOXML package.",
|
||||
" 1. Convert the PDF to .docx, or upload it through a PDF-capable "
|
||||
"parser engine (e.g. mineru/docling)\n"
|
||||
" 2. Re-upload to LightRAG",
|
||||
)
|
||||
|
||||
stripped = head.lstrip()
|
||||
if stripped.startswith(b"<"):
|
||||
# <?xml ...>, <html ...>, or Word 2003 "<w:wordDocument>" flat XML.
|
||||
return (
|
||||
f"The file at '{file_path}' is an HTML or XML document saved with a "
|
||||
".docx extension, not a ZIP/OOXML package.",
|
||||
convert_solution,
|
||||
)
|
||||
|
||||
if head.startswith(b"PK\x03\x04") and not zipfile.is_zipfile(file_path):
|
||||
# Has the ZIP local-file-header magic but the archive is unreadable.
|
||||
return (
|
||||
f"The file at '{file_path}' starts like a ZIP archive but is "
|
||||
"truncated or corrupt, so it cannot be opened as a DOCX package.",
|
||||
" 1. Check the original document opens correctly\n"
|
||||
" 2. Re-upload a complete, uncorrupted copy to LightRAG",
|
||||
)
|
||||
|
||||
return (
|
||||
f"The file at '{file_path}' is not a valid DOCX (ZIP/OOXML) package. "
|
||||
"It is either corrupt or a different file format saved with a .docx "
|
||||
"extension.",
|
||||
convert_solution,
|
||||
)
|
||||
|
||||
|
||||
def truncate_heading(heading_text: str, para_id: str = None) -> str:
|
||||
"""
|
||||
Truncate heading if it exceeds MAX_HEADING_LENGTH.
|
||||
|
||||
Args:
|
||||
heading_text: The heading text to check
|
||||
para_id: Optional paragraph ID for warning message
|
||||
|
||||
Returns:
|
||||
str: Original heading if within limit, truncated heading with "..." if too long
|
||||
"""
|
||||
if len(heading_text) > MAX_HEADING_LENGTH:
|
||||
truncated = heading_text[: MAX_HEADING_LENGTH - 3] + "..."
|
||||
location = f" (para_id: {para_id})" if para_id else ""
|
||||
print(
|
||||
f"Warning: Heading truncated (length {len(heading_text)} > max {MAX_HEADING_LENGTH}){location}: "
|
||||
f'"{truncated}"',
|
||||
file=sys.stderr,
|
||||
)
|
||||
return truncated
|
||||
return heading_text
|
||||
|
||||
|
||||
def validate_heading_length(heading_text: str, para_id: str):
|
||||
"""
|
||||
Validate that heading length does not exceed MAX_HEADING_LENGTH.
|
||||
|
||||
Args:
|
||||
heading_text: The heading text to validate
|
||||
para_id: The paragraph ID for error reporting
|
||||
|
||||
Raises:
|
||||
DocxContentError: if heading exceeds maximum length
|
||||
"""
|
||||
if len(heading_text) > MAX_HEADING_LENGTH:
|
||||
preview = (
|
||||
heading_text[:100] + "..." if len(heading_text) > 100 else heading_text
|
||||
)
|
||||
raise DocxContentError(
|
||||
format_error(
|
||||
f"Heading too long ({len(heading_text)} characters, max {MAX_HEADING_LENGTH})",
|
||||
f"The following heading exceeds the maximum allowed length:\n\n{preview}\n\n"
|
||||
f"Location(para_id): {para_id}\n"
|
||||
f"Actual length: {len(heading_text)} characters",
|
||||
" 1. Open the document in Microsoft Word\n"
|
||||
f" 2. Shorten this heading to {MAX_HEADING_LENGTH} characters or less\n"
|
||||
" 3. Re-upload it to LightRAG",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def find_first_valid_para_id(para_ids: list) -> str | None:
|
||||
"""
|
||||
Find the first valid paraId in a 2D array of paraIds.
|
||||
|
||||
Args:
|
||||
para_ids: 2D list of paraIds from table cells
|
||||
|
||||
Returns:
|
||||
First non-None paraId found, or None when every cell lacks a paraId.
|
||||
Callers must tolerate ``None`` and treat it as a tracking gap rather
|
||||
than a fatal error (legacy / non-Word docx authors omit ``w14:paraId``
|
||||
attributes and we want to keep parsing).
|
||||
"""
|
||||
for row in para_ids:
|
||||
for para_id in row:
|
||||
if para_id:
|
||||
return para_id
|
||||
return None
|
||||
|
||||
|
||||
def find_last_valid_para_id(para_ids: list) -> str | None:
|
||||
"""
|
||||
Find the last valid paraId in a 2D array of paraIds.
|
||||
|
||||
Returns the last non-None paraId, falling back to the first valid one
|
||||
when reverse-iteration does not yield anything (single-paraId tables),
|
||||
and finally ``None`` when every cell lacks a paraId.
|
||||
"""
|
||||
for row in reversed(para_ids):
|
||||
for para_id in reversed(row):
|
||||
if para_id:
|
||||
return para_id
|
||||
|
||||
return find_first_valid_para_id(para_ids)
|
||||
|
||||
|
||||
def _table_has_any_paraid(para_ids: list) -> bool:
|
||||
"""True when at least one cell in the 2D paraId grid carries an id."""
|
||||
return find_first_valid_para_id(para_ids) is not None
|
||||
|
||||
|
||||
def extract_para_id(para_element) -> str:
|
||||
"""
|
||||
Extract w14:paraId attribute from paragraph element.
|
||||
|
||||
Args:
|
||||
para_element: lxml paragraph element
|
||||
|
||||
Returns:
|
||||
8-character hex paraId, or ``None`` when the paragraph carries no
|
||||
``w14:paraId`` attribute (legacy / non-Word docx authors). Callers
|
||||
propagate the ``None`` upward — the LightRAG adapter counts these
|
||||
and surfaces a single warning per document.
|
||||
"""
|
||||
return para_element.get(
|
||||
"{http://schemas.microsoft.com/office/word/2010/wordml}paraId"
|
||||
)
|
||||
|
||||
|
||||
def parse_styles_outline_levels(docx_path: str) -> dict:
|
||||
"""
|
||||
Parse styles.xml to extract outlineLvl definitions for each style,
|
||||
following style inheritance chain (basedOn).
|
||||
|
||||
Args:
|
||||
docx_path: Path to DOCX file
|
||||
|
||||
Returns:
|
||||
dict: styleId -> outlineLvl (0-8 for headings, 9 for body text)
|
||||
"""
|
||||
import zipfile
|
||||
|
||||
try:
|
||||
from defusedxml import ElementTree as ET
|
||||
except ImportError:
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
styles_outline = {} # styleId -> outlineLvl (directly defined)
|
||||
style_based_on = {} # styleId -> parent styleId
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(docx_path, "r") as zf:
|
||||
if "word/styles.xml" not in zf.namelist():
|
||||
return styles_outline
|
||||
|
||||
tree = ET.parse(zf.open("word/styles.xml"))
|
||||
root = tree.getroot()
|
||||
|
||||
ns = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
||||
|
||||
# First pass: collect outlineLvl and basedOn for all styles
|
||||
for style in root.findall(f".//{{{ns}}}style"):
|
||||
style_id = style.get(f"{{{ns}}}styleId")
|
||||
if not style_id:
|
||||
continue
|
||||
|
||||
# Check for basedOn (style inheritance)
|
||||
based_on = style.find(f"{{{ns}}}basedOn")
|
||||
if based_on is not None:
|
||||
parent_id = based_on.get(f"{{{ns}}}val")
|
||||
if parent_id:
|
||||
style_based_on[style_id] = parent_id
|
||||
|
||||
# Check for outlineLvl in style's pPr
|
||||
pPr = style.find(f"{{{ns}}}pPr")
|
||||
if pPr is not None:
|
||||
outline_lvl_elem = pPr.find(f"{{{ns}}}outlineLvl")
|
||||
if outline_lvl_elem is not None:
|
||||
level = int(outline_lvl_elem.get(f"{{{ns}}}val"))
|
||||
styles_outline[style_id] = level
|
||||
|
||||
# Second pass: resolve inheritance chain for styles without direct outlineLvl
|
||||
def get_outline_level(style_id: str, visited: set = None) -> int:
|
||||
if visited is None:
|
||||
visited = set()
|
||||
if style_id in visited:
|
||||
return None # Prevent circular references
|
||||
visited.add(style_id)
|
||||
|
||||
# If this style directly defines outlineLvl, return it
|
||||
if style_id in styles_outline:
|
||||
return styles_outline[style_id]
|
||||
|
||||
# Otherwise check parent style
|
||||
if style_id in style_based_on:
|
||||
parent_id = style_based_on[style_id]
|
||||
return get_outline_level(parent_id, visited)
|
||||
|
||||
return None
|
||||
|
||||
# Fill in missing outlineLvl from inheritance chain
|
||||
all_style_ids = set(styles_outline.keys()) | set(style_based_on.keys())
|
||||
for style_id in all_style_ids:
|
||||
if style_id not in styles_outline:
|
||||
level = get_outline_level(style_id)
|
||||
if level is not None:
|
||||
styles_outline[style_id] = level
|
||||
except Exception:
|
||||
# Silently ignore parsing errors
|
||||
pass
|
||||
|
||||
return styles_outline
|
||||
|
||||
|
||||
def get_heading_level(para_element, styles_outline_map: dict) -> int:
|
||||
"""
|
||||
Get heading level from paragraph, checking both direct format and style.
|
||||
|
||||
Priority: paragraph outlineLvl > style outlineLvl
|
||||
|
||||
Args:
|
||||
para_element: lxml paragraph element
|
||||
styles_outline_map: dict of styleId -> outlineLvl from styles.xml
|
||||
|
||||
Returns:
|
||||
int: 0-8 for heading levels (0=level 1, 1=level 2, etc.), None for non-heading
|
||||
"""
|
||||
# 1. Check paragraph direct format
|
||||
pPr = para_element.find(
|
||||
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}pPr"
|
||||
)
|
||||
if pPr is not None:
|
||||
outline_elem = pPr.find(
|
||||
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}outlineLvl"
|
||||
)
|
||||
if outline_elem is not None:
|
||||
level = int(
|
||||
outline_elem.get(
|
||||
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val"
|
||||
)
|
||||
)
|
||||
# Only 0-8 are true heading levels (9 is body text)
|
||||
if level < 9:
|
||||
return level
|
||||
else:
|
||||
return None # Level 9 is body text
|
||||
|
||||
# 2. Check style definition's outlineLvl
|
||||
if pPr is not None:
|
||||
pStyle_elem = pPr.find(
|
||||
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}pStyle"
|
||||
)
|
||||
if pStyle_elem is not None:
|
||||
style_id = pStyle_elem.get(
|
||||
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val"
|
||||
)
|
||||
if style_id and style_id in styles_outline_map:
|
||||
level = styles_outline_map[style_id]
|
||||
if level < 9:
|
||||
return level
|
||||
else:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extract_text_from_run(
|
||||
run,
|
||||
ns: dict,
|
||||
drawing_context: DrawingExtractionContext = None,
|
||||
) -> str:
|
||||
"""
|
||||
Extract text from a run element, preserving superscript/subscript with markup.
|
||||
|
||||
Converts Word formatting to HTML-like tags:
|
||||
- Superscript: <sup>text</sup>
|
||||
- Subscript: <sub>text</sub>
|
||||
- Normal text: unchanged
|
||||
|
||||
Args:
|
||||
run: lxml run element (w:r)
|
||||
ns: XML namespace dictionary
|
||||
|
||||
Returns:
|
||||
Text string with <sup>/<sub> markup for formatted portions
|
||||
"""
|
||||
text = ""
|
||||
|
||||
# Check for vertAlign in rPr (superscript/subscript)
|
||||
vert_align = None
|
||||
rPr = run.find("w:rPr", ns)
|
||||
if rPr is not None:
|
||||
vert_elem = rPr.find("w:vertAlign", ns)
|
||||
if vert_elem is not None:
|
||||
vert_align = vert_elem.get(
|
||||
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val"
|
||||
)
|
||||
|
||||
# Extract text content from run children
|
||||
for child in run:
|
||||
tag = child.tag.split("}")[-1] # Remove namespace
|
||||
if tag == "t" and child.text:
|
||||
text += child.text
|
||||
elif tag == "tab":
|
||||
text += "\t"
|
||||
elif tag == "br":
|
||||
# Handle line breaks - textWrapping or no type = soft line break
|
||||
br_type = child.get(
|
||||
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}type"
|
||||
)
|
||||
if br_type in (None, "textWrapping"):
|
||||
text += "\n"
|
||||
# Skip page and column breaks (layout elements)
|
||||
elif tag == "drawing":
|
||||
text += extract_drawing_placeholder_from_element(
|
||||
child,
|
||||
context=drawing_context,
|
||||
include_extended_attrs=True,
|
||||
)
|
||||
elif tag in ("pict", "object"):
|
||||
text += extract_vml_image_placeholder_from_element(
|
||||
child,
|
||||
context=drawing_context,
|
||||
include_extended_attrs=True,
|
||||
)
|
||||
|
||||
# Apply superscript/subscript markup if needed
|
||||
if text and vert_align == "superscript":
|
||||
return f"<sup>{text}</sup>"
|
||||
elif text and vert_align == "subscript":
|
||||
return f"<sub>{text}</sub>"
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def extract_paragraph_content(
|
||||
element,
|
||||
ns,
|
||||
drawing_context: DrawingExtractionContext = None,
|
||||
) -> str:
|
||||
"""
|
||||
Extract text and equations from a paragraph element in document order.
|
||||
|
||||
Handles w:r (text runs), m:oMath (inline equations), and m:oMathPara
|
||||
(block equations). Recurses into container elements (e.g., w:hyperlink,
|
||||
w:ins, w:sdt, w:fldSimple, w:smartTag) to avoid dropping content.
|
||||
|
||||
Args:
|
||||
element: lxml paragraph element (w:p)
|
||||
ns: XML namespace dictionary
|
||||
|
||||
Returns:
|
||||
Text string with equations wrapped in <equation> tags
|
||||
"""
|
||||
parts = []
|
||||
|
||||
def append_from(node) -> None:
|
||||
tag = node.tag.split("}")[-1]
|
||||
# Drop tracked-change deletions (w:del/w:moveFrom) and comment markers
|
||||
# (w:commentRangeStart/End, w:commentReference, w:annotationRef) so the
|
||||
# output only contains the final revised text without annotation glyphs.
|
||||
if tag in _SKIP_PARAGRAPH_TAGS:
|
||||
return
|
||||
if tag == "r":
|
||||
parts.append(
|
||||
extract_text_from_run(node, ns, drawing_context=drawing_context)
|
||||
)
|
||||
return
|
||||
if tag == "oMath":
|
||||
from .omml import convert_omml_to_latex
|
||||
|
||||
latex = convert_omml_to_latex(node)
|
||||
if latex:
|
||||
parts.append(f"<equation>{latex}</equation>")
|
||||
return
|
||||
if tag == "oMathPara":
|
||||
from .omml import convert_omml_to_latex
|
||||
|
||||
for omath in node:
|
||||
if omath.tag.split("}")[-1] == "oMath":
|
||||
latex = convert_omml_to_latex(omath)
|
||||
if latex:
|
||||
parts.append(f"<equation>{latex}</equation>")
|
||||
return
|
||||
for child in node:
|
||||
append_from(child)
|
||||
|
||||
for child in element:
|
||||
append_from(child)
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _is_table_empty(rows: list) -> bool:
|
||||
"""Return True iff every cell in ``rows`` is whitespace-only."""
|
||||
return all(not (cell or "").strip() for row in rows for cell in row)
|
||||
|
||||
|
||||
def _collect_table_headers(paragraphs: list) -> list:
|
||||
"""Collect per-table cross-page header rows from ``is_table`` paragraphs.
|
||||
|
||||
The returned list is aligned 1:1 with the order of ``<table>`` placeholder
|
||||
tags emitted into the block's content; entries are either the list of
|
||||
header rows captured from ``w:tblHeader`` or ``None`` when the table has
|
||||
no cross-page repeating header.
|
||||
"""
|
||||
return [p.get("_table_header") for p in paragraphs if p.get("is_table")]
|
||||
|
||||
|
||||
def _build_unsplit_block(
|
||||
heading: str, paragraphs: list, parent_headings: list, level: int
|
||||
) -> dict:
|
||||
"""Build a single block from paragraphs without size-based splitting."""
|
||||
last_para = paragraphs[-1]
|
||||
block = {
|
||||
"uuid": paragraphs[0]["para_id"],
|
||||
"uuid_end": last_para.get("para_id_end") or last_para.get("para_id"),
|
||||
"heading": heading,
|
||||
"content": "\n".join(p["text"] for p in paragraphs),
|
||||
"type": "text",
|
||||
"parent_headings": parent_headings,
|
||||
"level": level,
|
||||
}
|
||||
table_headers = _collect_table_headers(paragraphs)
|
||||
if table_headers:
|
||||
block["table_headers"] = table_headers
|
||||
return block
|
||||
|
||||
|
||||
def _flush_current_block(
|
||||
blocks: list,
|
||||
heading: str,
|
||||
paragraphs: list,
|
||||
parent_headings: list,
|
||||
level: int,
|
||||
) -> None:
|
||||
"""Flush accumulated paragraphs into a single heading-scoped block.
|
||||
|
||||
The native parser performs only heading-driven structural splitting; block
|
||||
sizing (long-block anchor splitting, table row splitting, small-block
|
||||
merging) is the downstream paragraph-semantic chunker's responsibility.
|
||||
"""
|
||||
if not paragraphs:
|
||||
return
|
||||
|
||||
blocks.append(_build_unsplit_block(heading, paragraphs, parent_headings, level))
|
||||
|
||||
|
||||
def extract_docx_blocks(
|
||||
file_path: str,
|
||||
drawing_context: DrawingExtractionContext = None,
|
||||
parse_warnings: dict | None = None,
|
||||
parse_metadata: dict | None = None,
|
||||
) -> list:
|
||||
"""
|
||||
Extract heading-scoped text blocks from a DOCX file.
|
||||
|
||||
Uses python-docx with a custom numbering resolver to:
|
||||
1. Capture automatic numbering (list labels)
|
||||
2. Split the document into one block per heading (structural splitting)
|
||||
3. Convert tables to JSON (2D array) and emit them as <table> placeholders
|
||||
4. Preserve superscript/subscript formatting with <sup>/<sub> markup
|
||||
|
||||
Block sizing — long-block anchor splitting, table row splitting, and
|
||||
small-block merging — is intentionally NOT done here; it is the downstream
|
||||
paragraph-semantic chunker's responsibility. Blocks emitted here may
|
||||
therefore be arbitrarily large.
|
||||
|
||||
Args:
|
||||
file_path: Path to the DOCX file
|
||||
parse_warnings: Optional out-dict that this function mutates with
|
||||
non-fatal warnings observed during parsing. Currently used for
|
||||
``missing_paraid_count`` — incremented once per body-level
|
||||
paragraph (heading or text) that lacks a ``w14:paraId`` and once
|
||||
per table whose every cell lacks one. Callers (the LightRAG
|
||||
adapter / debug CLI) read this to surface a one-line warning per
|
||||
document instead of crashing.
|
||||
parse_metadata: Optional out-dict that this function mutates with
|
||||
document-level metadata derived during parsing. Currently used
|
||||
for ``first_heading`` — the text of the first heading encountered
|
||||
in document order (regardless of level). Used by the LightRAG
|
||||
adapter to populate ``meta.doc_title`` in ``.blocks.jsonl``.
|
||||
|
||||
Returns:
|
||||
List of block dictionaries with heading, content, type, and metadata
|
||||
"""
|
||||
try:
|
||||
doc = Document(file_path)
|
||||
except PackageNotFoundError as exc:
|
||||
# python-docx surfaces a misleading "Package not found at '...'" for any
|
||||
# file it cannot open as a ZIP/OOXML package — including files that
|
||||
# exist but are corrupt or a different format wearing a .docx extension.
|
||||
# Diagnose the real cause from the magic bytes and raise a DocxContentError
|
||||
# (a ValueError) so the pipeline's per-document handler marks just this
|
||||
# document FAILED with an accurate, actionable message.
|
||||
details, solution = _diagnose_invalid_docx(file_path)
|
||||
raise DocxContentError(
|
||||
format_error("File is not a valid DOCX document", details, solution)
|
||||
) from exc
|
||||
resolver = NumberingResolver(file_path)
|
||||
styles_outline = parse_styles_outline_levels(file_path)
|
||||
|
||||
blocks = []
|
||||
current_heading = "Preface/Uncategorized"
|
||||
current_heading_level = 1 # Default level for "Preface/Uncategorized"
|
||||
current_heading_stack = {} # {level: heading_text} - Use dict to correctly track heading hierarchy
|
||||
current_parent_headings = [] # Parent headings for current block
|
||||
current_paragraphs = [] # Track paragraphs with metadata for splitting
|
||||
first_heading_recorded = (
|
||||
False # Track whether the document's first heading has been captured
|
||||
)
|
||||
|
||||
# Iterate through document body elements (paragraphs and tables)
|
||||
body = doc._element.body
|
||||
|
||||
for element in body:
|
||||
tag = element.tag.split("}")[-1] # Remove namespace
|
||||
|
||||
if tag == "sectPr": # Document-level section break
|
||||
resolver.reset_tracking_state()
|
||||
continue
|
||||
|
||||
if tag == "p": # Paragraph
|
||||
# Get paragraph text with superscript/subscript markup and equations
|
||||
para_text = ""
|
||||
ns = {
|
||||
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
|
||||
"wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
|
||||
"m": "http://schemas.openxmlformats.org/officeDocument/2006/math",
|
||||
}
|
||||
para_text = extract_paragraph_content(
|
||||
element,
|
||||
ns,
|
||||
drawing_context=drawing_context,
|
||||
)
|
||||
|
||||
para_text = para_text.strip()
|
||||
if not para_text:
|
||||
continue
|
||||
|
||||
# Get numbering label using our resolver
|
||||
label = resolver.get_label(element)
|
||||
full_text = f"{label} {para_text}".strip() if label else para_text
|
||||
|
||||
# Check if this is a heading using the new function
|
||||
outline_level = get_heading_level(element, styles_outline)
|
||||
|
||||
# A "heading" longer than MAX_HEADING_LENGTH is not a real heading.
|
||||
# The common cause (WPS/Word): the author set an outline level on a
|
||||
# paragraph but typed the body with soft line breaks (Shift+Enter →
|
||||
# <w:br/> → '\n') instead of starting a new paragraph, so heading
|
||||
# text + body live in one <w:p>. Split at the first soft break: the
|
||||
# first line stays the heading, the remainder becomes body text. If
|
||||
# there is no usable soft break (a genuine single-line over-long
|
||||
# heading), demote the whole paragraph to body text. Either way we
|
||||
# avoid crashing via validate_heading_length() and never drop content.
|
||||
demoted_body_text = None
|
||||
if outline_level is not None and len(full_text) > MAX_HEADING_LENGTH:
|
||||
head, sep, rest = full_text.partition("\n")
|
||||
if sep and len(head) <= MAX_HEADING_LENGTH:
|
||||
full_text = head
|
||||
demoted_body_text = rest.strip() or None
|
||||
if parse_warnings is not None:
|
||||
parse_warnings["heading_softbreak_split_count"] = (
|
||||
parse_warnings.get("heading_softbreak_split_count", 0) + 1
|
||||
)
|
||||
print(
|
||||
f"Warning: heading paragraph exceeded {MAX_HEADING_LENGTH} "
|
||||
"chars; split at soft line break — kept first line as "
|
||||
"heading, rest as body.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
outline_level = None
|
||||
if parse_warnings is not None:
|
||||
parse_warnings["demoted_oversize_heading_count"] = (
|
||||
parse_warnings.get("demoted_oversize_heading_count", 0) + 1
|
||||
)
|
||||
print(
|
||||
f"Warning: paragraph has outline level but is "
|
||||
f"{len(full_text)} chars (> {MAX_HEADING_LENGTH}); treating "
|
||||
"as body text, not a heading.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
if outline_level is not None:
|
||||
# This is a heading (outline level 0-8)
|
||||
# Convert 0-based to 1-based level
|
||||
level = outline_level + 1
|
||||
|
||||
# Extract paraId for this heading
|
||||
heading_para_id = extract_para_id(element)
|
||||
if parse_warnings is not None and not heading_para_id:
|
||||
parse_warnings["missing_paraid_count"] = (
|
||||
parse_warnings.get("missing_paraid_count", 0) + 1
|
||||
)
|
||||
|
||||
# Validate heading length
|
||||
validate_heading_length(full_text, heading_para_id)
|
||||
|
||||
# Truncate heading if needed before storing
|
||||
truncated_text = truncate_heading(full_text, heading_para_id)
|
||||
clean_heading_text = strip_heading_markdown_prefix(truncated_text)
|
||||
|
||||
# Record the document's first heading (any level) for meta.doc_title.
|
||||
if not first_heading_recorded:
|
||||
if parse_metadata is not None:
|
||||
parse_metadata["first_heading"] = clean_heading_text
|
||||
first_heading_recorded = True
|
||||
|
||||
# Every recognized heading starts its own block. Always flush the
|
||||
# accumulated paragraphs so a heading with no body becomes a
|
||||
# standalone block whose content is just the heading text,
|
||||
# instead of being folded into the next heading's block.
|
||||
if current_paragraphs:
|
||||
_flush_current_block(
|
||||
blocks,
|
||||
current_heading,
|
||||
current_paragraphs,
|
||||
current_parent_headings,
|
||||
current_heading_level,
|
||||
)
|
||||
|
||||
# Reset for new block
|
||||
current_paragraphs = []
|
||||
|
||||
# Add heading to current_paragraphs. The content line gets
|
||||
# a markdown ``#`` prefix (capped at 6) via
|
||||
# render_heading_line; ``clean_heading_text`` is kept
|
||||
# for the heading field / stack / parent_headings below.
|
||||
current_paragraphs.append(
|
||||
{
|
||||
"text": render_heading_line(level, truncated_text),
|
||||
"para_id": heading_para_id,
|
||||
"is_table": False,
|
||||
}
|
||||
)
|
||||
|
||||
# Update current_heading and parent_headings for the FIRST heading in a block
|
||||
# (when current_paragraphs just had this heading added as its first element)
|
||||
if len(current_paragraphs) == 1:
|
||||
current_heading = clean_heading_text
|
||||
current_heading_level = level # Only set level when setting heading
|
||||
# Parent headings = all headings from levels strictly less than current level
|
||||
# Sort by level to maintain hierarchy order
|
||||
current_parent_headings = [
|
||||
current_heading_stack[lvl]
|
||||
for lvl in sorted(current_heading_stack.keys())
|
||||
if lvl < level
|
||||
]
|
||||
|
||||
# Update heading stack: remove current level and all lower levels, then add current
|
||||
current_heading_stack = {
|
||||
k: v for k, v in current_heading_stack.items() if k < level
|
||||
}
|
||||
current_heading_stack[level] = clean_heading_text
|
||||
|
||||
# Carry the body text that followed a soft break in an over-long
|
||||
# heading paragraph as a regular body paragraph in the same block.
|
||||
if demoted_body_text:
|
||||
current_paragraphs.append(
|
||||
{
|
||||
"text": demoted_body_text,
|
||||
"para_id": heading_para_id,
|
||||
"is_table": False,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Regular paragraph content
|
||||
para_id = extract_para_id(element)
|
||||
if parse_warnings is not None and not para_id:
|
||||
parse_warnings["missing_paraid_count"] = (
|
||||
parse_warnings.get("missing_paraid_count", 0) + 1
|
||||
)
|
||||
|
||||
# Store paragraph with metadata for potential splitting
|
||||
current_paragraphs.append(
|
||||
{"text": full_text, "para_id": para_id, "is_table": False}
|
||||
)
|
||||
|
||||
# Check for paragraph-level section break (after processing paragraph)
|
||||
# sectPr in pPr means this paragraph ends a section
|
||||
pPr = element.find(
|
||||
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}pPr"
|
||||
)
|
||||
if pPr is not None:
|
||||
sectPr = pPr.find(
|
||||
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}sectPr"
|
||||
)
|
||||
if sectPr is not None:
|
||||
# Section break after this paragraph - reset tracking
|
||||
resolver.reset_tracking_state()
|
||||
|
||||
elif tag == "tbl": # Table
|
||||
# Reset numbering tracking before table (table start boundary)
|
||||
resolver.reset_tracking_state()
|
||||
|
||||
# Directly create Table object from XML element to avoid index mismatch
|
||||
# (doc.tables may have different order due to nested tables)
|
||||
from docx.table import Table
|
||||
|
||||
table = Table(element, doc)
|
||||
table_metadata = TableExtractor.extract_with_metadata(
|
||||
table,
|
||||
numbering_resolver=resolver,
|
||||
drawing_context=drawing_context,
|
||||
)
|
||||
|
||||
table_rows = table_metadata["rows"]
|
||||
para_ids = table_metadata["para_ids"]
|
||||
para_ids_end = table_metadata["para_ids_end"] # Last paraId in each cell
|
||||
header_indices = table_metadata["header_indices"]
|
||||
|
||||
# Skip tables whose every cell is whitespace-only — otherwise an
|
||||
# empty `<table>[[""]]</table>` placeholder would leak into block
|
||||
# content and a useless IRTable would appear in tables.json.
|
||||
if _is_table_empty(table_rows):
|
||||
resolver.reset_tracking_state()
|
||||
continue
|
||||
|
||||
# Count tables whose cells carry no w14:paraId. Legacy / non-Word
|
||||
# docx authors omit these attributes; we no longer fail-fast, but
|
||||
# the adapter surfaces a single warning so the user knows the edit
|
||||
# range hints will be missing for these tables.
|
||||
if parse_warnings is not None and not _table_has_any_paraid(para_ids):
|
||||
parse_warnings["missing_paraid_count"] = (
|
||||
parse_warnings.get("missing_paraid_count", 0) + 1
|
||||
)
|
||||
|
||||
# Convert table to JSON
|
||||
table_json = json.dumps(table_rows, ensure_ascii=False)
|
||||
|
||||
# Extract cross-page repeating header rows (w:tblHeader) once per
|
||||
# table so we can surface them to the sidecar via the block-level
|
||||
# ``table_headers`` list.
|
||||
header_rows = []
|
||||
if header_indices:
|
||||
header_rows = [
|
||||
table_rows[idx] for idx in header_indices if idx < len(table_rows)
|
||||
]
|
||||
header_rows_or_none = header_rows if header_rows else None
|
||||
|
||||
# Emit the whole table as a single <table> placeholder. Token-based
|
||||
# table row splitting is the downstream chunker's responsibility.
|
||||
# Use first valid paraId from table, and last valid paraId (from
|
||||
# para_ids_end) for uuid_end.
|
||||
table_para_id = find_first_valid_para_id(para_ids)
|
||||
table_para_id_end = find_last_valid_para_id(para_ids_end)
|
||||
current_paragraphs.append(
|
||||
{
|
||||
"text": f"<table>{table_json}</table>",
|
||||
"para_id": table_para_id,
|
||||
"para_id_end": table_para_id_end, # Store end paraId for uuid_end calculation
|
||||
"is_table": True,
|
||||
"_table_header": header_rows_or_none,
|
||||
}
|
||||
)
|
||||
|
||||
# Reset numbering tracking after table (table end boundary)
|
||||
resolver.reset_tracking_state()
|
||||
|
||||
# Save final block
|
||||
_flush_current_block(
|
||||
blocks,
|
||||
current_heading,
|
||||
current_paragraphs,
|
||||
current_parent_headings,
|
||||
current_heading_level,
|
||||
)
|
||||
|
||||
return blocks
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Native DOCX engine adapter (implements NativeParserBase hooks)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from lightrag.constants import PARSER_ENGINE_NATIVE
|
||||
from lightrag.parser.native_base import NativeParserBase
|
||||
from lightrag.utils import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lightrag.sidecar.ir import IRDoc
|
||||
|
||||
|
||||
class NativeDocxParser(NativeParserBase):
|
||||
"""Native DOCX parser for LightRAG's production parsing path.
|
||||
|
||||
``extract_docx_blocks`` performs only heading-driven structural splitting
|
||||
(one block per DOCX heading). Block sizing is intentionally left to the
|
||||
downstream paragraph-semantic chunker, so this parser emits the
|
||||
one-heading-one-block sidecar contract that chunking consumes.
|
||||
"""
|
||||
|
||||
engine_name = PARSER_ENGINE_NATIVE
|
||||
sidecar_path_style = "basename_only" # legacy native docx convention
|
||||
empty_content_label = "DOCX"
|
||||
|
||||
def validate_source(self, source: Path, file_path: str) -> None:
|
||||
if not (
|
||||
source.exists() and source.is_file() and source.suffix.lower() == ".docx"
|
||||
):
|
||||
raise ValueError(
|
||||
f"Native parser does not support pending file: {file_path}"
|
||||
)
|
||||
|
||||
def extract(
|
||||
self, source: Path, *, parsed_dir: Path, asset_dir: Path, base_name: str
|
||||
) -> tuple[list[dict[str, Any]], dict[str, Any], dict[str, Any]]:
|
||||
"""Extract heading-scoped DOCX blocks (sizing left to the chunker)."""
|
||||
|
||||
from lightrag.parser.docx.drawing_image_extractor import (
|
||||
DrawingExtractionContext,
|
||||
load_relationships,
|
||||
)
|
||||
from lightrag.parser.docx.parse_document import extract_docx_blocks
|
||||
|
||||
ctx = DrawingExtractionContext(
|
||||
docx_path=source,
|
||||
blocks_output_path=parsed_dir / f"{base_name}.blocks.jsonl",
|
||||
export_dir_name=asset_dir.name,
|
||||
export_dir_path=asset_dir,
|
||||
)
|
||||
load_relationships(ctx)
|
||||
warnings: dict[str, Any] = {}
|
||||
metadata: dict[str, Any] = {}
|
||||
blocks = extract_docx_blocks(
|
||||
str(source),
|
||||
drawing_context=ctx,
|
||||
parse_warnings=warnings,
|
||||
parse_metadata=metadata,
|
||||
)
|
||||
return blocks, warnings, metadata
|
||||
|
||||
def build_ir(
|
||||
self,
|
||||
blocks: list[dict[str, Any]],
|
||||
*,
|
||||
document_name: str,
|
||||
asset_dir_name: str,
|
||||
metadata: dict[str, Any],
|
||||
) -> "IRDoc":
|
||||
from lightrag.parser.docx.ir_builder import NativeDocxIRBuilder
|
||||
|
||||
return NativeDocxIRBuilder().normalize(
|
||||
blocks,
|
||||
document_name=document_name,
|
||||
asset_dir_name=asset_dir_name,
|
||||
parse_metadata=metadata,
|
||||
)
|
||||
|
||||
def surface_warnings(
|
||||
self, warnings: dict[str, Any], source: Path
|
||||
) -> dict[str, Any] | None:
|
||||
missing = int(warnings.get("missing_paraid_count", 0) or 0)
|
||||
if missing > 0:
|
||||
# Surface once per document; affected blocks emit
|
||||
# ``positions: [{"type": "paraid", "range": null}]``.
|
||||
logger.warning(
|
||||
"[parse_native] %s: %d paragraphs lack paraId; "
|
||||
"Re-saving file in Word 2013+ to regenerate ids.",
|
||||
source.name,
|
||||
missing,
|
||||
)
|
||||
return {"missing_paraid_count": missing}
|
||||
return None
|
||||
@@ -0,0 +1,419 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ABOUTME: Extracts tables from DOCX with proper merged cell handling
|
||||
ABOUTME: Vertically merged cells: content repeated in all rows with shared paraId
|
||||
ABOUTME: Horizontally merged cells: content in first cell only
|
||||
ABOUTME: Preserves superscript/subscript formatting with <sup>/<sub> markup
|
||||
"""
|
||||
|
||||
from docx.table import Table
|
||||
from docx.oxml.ns import qn
|
||||
from typing import List
|
||||
|
||||
from .drawing_image_extractor import (
|
||||
DrawingExtractionContext,
|
||||
extract_drawing_placeholder_from_element,
|
||||
extract_vml_image_placeholder_from_element,
|
||||
)
|
||||
|
||||
# Keep in sync with parse_document._SKIP_PARAGRAPH_TAGS — duplicated here to
|
||||
# avoid a circular import between parse_document and table_extractor.
|
||||
_SKIP_PARAGRAPH_TAGS = frozenset(
|
||||
{
|
||||
"del",
|
||||
"moveFrom",
|
||||
"commentRangeStart",
|
||||
"commentRangeEnd",
|
||||
"commentReference",
|
||||
"annotationRef",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def extract_text_from_run_table(
|
||||
run_elem,
|
||||
qn_func,
|
||||
drawing_context: DrawingExtractionContext = None,
|
||||
) -> str:
|
||||
"""
|
||||
Extract text from a run element in table cell, preserving superscript/subscript with markup.
|
||||
|
||||
Converts Word formatting to HTML-like tags:
|
||||
- Superscript: <sup>text</sup>
|
||||
- Subscript: <sub>text</sub>
|
||||
- Normal text: unchanged
|
||||
|
||||
Args:
|
||||
run_elem: lxml run element (w:r)
|
||||
qn_func: qn function for namespace handling
|
||||
|
||||
Returns:
|
||||
Text string with <sup>/<sub> markup for formatted portions
|
||||
"""
|
||||
text = ""
|
||||
|
||||
# Check for vertAlign in rPr (superscript/subscript)
|
||||
vert_align = None
|
||||
rPr = run_elem.find(qn_func("w:rPr"))
|
||||
if rPr is not None:
|
||||
vert_elem = rPr.find(qn_func("w:vertAlign"))
|
||||
if vert_elem is not None:
|
||||
vert_align = vert_elem.get(qn_func("w:val"))
|
||||
|
||||
# Extract text content from run children
|
||||
for child in run_elem:
|
||||
tag = child.tag.split("}")[-1] # Remove namespace
|
||||
if tag == "t" and child.text:
|
||||
text += child.text
|
||||
elif tag == "tab":
|
||||
text += "\t"
|
||||
elif tag == "br":
|
||||
# Handle line breaks - textWrapping or no type = soft line break
|
||||
br_type = child.get(qn_func("w:type"))
|
||||
if br_type in (None, "textWrapping"):
|
||||
text += "\n"
|
||||
# Skip page and column breaks (layout elements)
|
||||
elif tag == "drawing":
|
||||
text += extract_drawing_placeholder_from_element(
|
||||
child,
|
||||
context=drawing_context,
|
||||
include_extended_attrs=True,
|
||||
)
|
||||
elif tag in ("pict", "object"):
|
||||
text += extract_vml_image_placeholder_from_element(
|
||||
child,
|
||||
context=drawing_context,
|
||||
include_extended_attrs=True,
|
||||
)
|
||||
|
||||
# Apply superscript/subscript markup if needed
|
||||
if text and vert_align == "superscript":
|
||||
return f"<sup>{text}</sup>"
|
||||
elif text and vert_align == "subscript":
|
||||
return f"<sub>{text}</sub>"
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def extract_paragraph_content_table(
|
||||
para_elem,
|
||||
qn_func,
|
||||
drawing_context: DrawingExtractionContext = None,
|
||||
) -> str:
|
||||
"""
|
||||
Extract text and equations from a table cell paragraph in document order.
|
||||
|
||||
Handles w:r (text runs), m:oMath (inline equations), and m:oMathPara
|
||||
(block equations). Recurses into container elements (e.g., w:hyperlink,
|
||||
w:ins, w:sdt, w:fldSimple, w:smartTag) to avoid dropping content.
|
||||
|
||||
Args:
|
||||
para_elem: lxml paragraph element (w:p)
|
||||
qn_func: qn function for namespace handling
|
||||
|
||||
Returns:
|
||||
Text string with equations wrapped in <equation> tags
|
||||
"""
|
||||
parts = []
|
||||
|
||||
def append_from(node) -> None:
|
||||
tag = node.tag.split("}")[-1]
|
||||
# Drop tracked-change deletions (w:del/w:moveFrom) and comment markers
|
||||
# (w:commentRangeStart/End, w:commentReference, w:annotationRef) so the
|
||||
# output only contains the final revised text without annotation glyphs.
|
||||
if tag in _SKIP_PARAGRAPH_TAGS:
|
||||
return
|
||||
if tag == "r":
|
||||
parts.append(
|
||||
extract_text_from_run_table(
|
||||
node,
|
||||
qn_func,
|
||||
drawing_context=drawing_context,
|
||||
)
|
||||
)
|
||||
return
|
||||
if tag == "oMath":
|
||||
from omml import convert_omml_to_latex
|
||||
|
||||
latex = convert_omml_to_latex(node)
|
||||
if latex:
|
||||
parts.append(f"<equation>{latex}</equation>")
|
||||
return
|
||||
if tag == "oMathPara":
|
||||
from omml import convert_omml_to_latex
|
||||
|
||||
for omath in node:
|
||||
if omath.tag.split("}")[-1] == "oMath":
|
||||
latex = convert_omml_to_latex(omath)
|
||||
if latex:
|
||||
parts.append(f"<equation>{latex}</equation>")
|
||||
return
|
||||
for child in node:
|
||||
append_from(child)
|
||||
|
||||
for child in para_elem:
|
||||
append_from(child)
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
class TableExtractor:
|
||||
"""
|
||||
Extract table content handling merged cells correctly.
|
||||
|
||||
Merged cells in DOCX:
|
||||
- Horizontal: w:gridSpan specifies how many columns cell spans
|
||||
- Vertical: w:vMerge with val="restart" starts merge, subsequent cells continue
|
||||
|
||||
Output format:
|
||||
- 2D list of strings
|
||||
- Vertically merged cells: content repeated in all rows, all rows use the same paraId (from start cell)
|
||||
- Horizontally merged cells: content in left-most position only, other positions empty
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def extract(
|
||||
table: Table,
|
||||
numbering_resolver=None,
|
||||
drawing_context: DrawingExtractionContext = None,
|
||||
) -> List[List[str]]:
|
||||
"""
|
||||
Extract table to 2D string array.
|
||||
|
||||
Args:
|
||||
table: python-docx Table object
|
||||
numbering_resolver: Optional NumberingResolver for extracting numbering
|
||||
|
||||
Returns:
|
||||
List of rows, each row is list of cell text strings
|
||||
"""
|
||||
result = TableExtractor.extract_with_metadata(
|
||||
table,
|
||||
numbering_resolver=numbering_resolver,
|
||||
drawing_context=drawing_context,
|
||||
)
|
||||
return result["rows"]
|
||||
|
||||
@staticmethod
|
||||
def extract_with_metadata(
|
||||
table: Table,
|
||||
numbering_resolver=None,
|
||||
drawing_context: DrawingExtractionContext = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Extract table to 2D string array with metadata (paraIds, header info).
|
||||
|
||||
Vertical merge behavior:
|
||||
- All rows in a vertically merged region share the same content
|
||||
- All rows use the paraId from the merge start cell (for precise edit targeting)
|
||||
|
||||
Args:
|
||||
table: python-docx Table object
|
||||
numbering_resolver: Optional NumberingResolver for extracting numbering
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- rows: 2D list of cell text strings
|
||||
- para_ids: 2D list of paraIds (first paraId in each cell, or None)
|
||||
For vertically merged cells, all rows share the start cell's paraId
|
||||
- para_ids_end: 2D list of paraIds (last paraId in each cell, or None)
|
||||
For vertically merged cells, all rows share the start cell's paraId
|
||||
- header_indices: List of row indices marked as table headers
|
||||
"""
|
||||
tbl = table._tbl
|
||||
|
||||
# Get number of columns from tblGrid
|
||||
tbl_grid = tbl.find(qn("w:tblGrid"))
|
||||
num_cols = 0
|
||||
if tbl_grid is not None:
|
||||
num_cols = len(tbl_grid.findall(qn("w:gridCol")))
|
||||
|
||||
if num_cols == 0:
|
||||
return {
|
||||
"rows": [],
|
||||
"para_ids": [],
|
||||
"para_ids_end": [],
|
||||
"header_indices": [],
|
||||
}
|
||||
|
||||
# Detect header rows using w:tblHeader attribute
|
||||
header_indices = []
|
||||
for idx, tr in enumerate(tbl.findall(qn("w:tr"))):
|
||||
trPr = tr.find(qn("w:trPr"))
|
||||
if trPr is not None:
|
||||
tbl_header = trPr.find(qn("w:tblHeader"))
|
||||
if tbl_header is not None:
|
||||
header_indices.append(idx)
|
||||
|
||||
# Process each row by directly iterating <w:tr> elements
|
||||
grid = []
|
||||
para_ids_grid = []
|
||||
para_ids_end_grid = [] # Track last paraId in each cell
|
||||
vmerge_content = {} # Track vertical merge by column: {col: {'text': str, 'para_id': str, 'para_id_end': str}}
|
||||
|
||||
for tr in tbl.findall(qn("w:tr")):
|
||||
row_data = [""] * num_cols # Pre-fill with empty strings
|
||||
row_para_ids = [None] * num_cols # Pre-fill with None
|
||||
row_para_ids_end = [None] * num_cols # Pre-fill with None for last paraId
|
||||
grid_col = 0
|
||||
|
||||
# Iterate actual <w:tc> elements (each physical cell appears once)
|
||||
for tc in tr.findall(qn("w:tc")):
|
||||
# Reset numbering state when cell changes to prevent incorrect continuation
|
||||
if numbering_resolver is not None:
|
||||
numbering_resolver.reset_tracking_state()
|
||||
|
||||
tcPr = tc.find(qn("w:tcPr"))
|
||||
|
||||
# Check gridSpan (horizontal merge)
|
||||
grid_span = 1
|
||||
if tcPr is not None:
|
||||
gs = tcPr.find(qn("w:gridSpan"))
|
||||
if gs is not None:
|
||||
grid_span = int(gs.get(qn("w:val")))
|
||||
|
||||
# Check vMerge (vertical merge)
|
||||
vmerge_elem = None
|
||||
vmerge_val = None
|
||||
if tcPr is not None:
|
||||
vmerge_elem = tcPr.find(qn("w:vMerge"))
|
||||
if vmerge_elem is not None:
|
||||
vmerge_val = vmerge_elem.get(
|
||||
qn("w:val")
|
||||
) # 'restart' or None (means 'continue')
|
||||
|
||||
# Determine vMerge status
|
||||
is_vmerge_restart = vmerge_elem is not None and vmerge_val == "restart"
|
||||
is_vmerge_continue = vmerge_elem is not None and vmerge_val in (
|
||||
None,
|
||||
"continue",
|
||||
)
|
||||
is_normal_cell = vmerge_elem is None
|
||||
|
||||
cell_text = ""
|
||||
cell_para_id = None
|
||||
cell_para_id_end = None # Track last paraId in cell
|
||||
|
||||
# Handle different vMerge cases
|
||||
if is_vmerge_restart or is_normal_cell:
|
||||
# Extract content for restart or normal cells
|
||||
# Get cell text with numbering support and format preservation
|
||||
if numbering_resolver is not None:
|
||||
# Extract text with numbering labels and superscript/subscript markup
|
||||
cell_paragraphs = []
|
||||
for para_elem in tc.findall(qn("w:p")):
|
||||
# Capture paraId from each paragraph
|
||||
para_id_attr = para_elem.get(
|
||||
"{http://schemas.microsoft.com/office/word/2010/wordml}paraId"
|
||||
)
|
||||
if para_id_attr:
|
||||
if cell_para_id is None:
|
||||
cell_para_id = para_id_attr # First paraId
|
||||
cell_para_id_end = (
|
||||
para_id_attr # Always update to get last
|
||||
)
|
||||
|
||||
# Get text content with format preservation (superscript/subscript/equations)
|
||||
para_text = extract_paragraph_content_table(
|
||||
para_elem,
|
||||
qn,
|
||||
drawing_context=drawing_context,
|
||||
)
|
||||
|
||||
# Get numbering label
|
||||
label = numbering_resolver.get_label(para_elem)
|
||||
|
||||
# Combine label and text
|
||||
if label:
|
||||
full_text = f"{label} {para_text}".strip()
|
||||
else:
|
||||
full_text = para_text.strip()
|
||||
|
||||
if full_text:
|
||||
cell_paragraphs.append(full_text)
|
||||
|
||||
cell_text = "\n".join(cell_paragraphs).replace("\x07", "")
|
||||
else:
|
||||
# Fallback to simple text extraction with format preservation
|
||||
# Cannot use cell.text here, must extract from XML
|
||||
para_texts = []
|
||||
for para_elem in tc.findall(qn("w:p")):
|
||||
# Capture paraId from each paragraph
|
||||
para_id_attr = para_elem.get(
|
||||
"{http://schemas.microsoft.com/office/word/2010/wordml}paraId"
|
||||
)
|
||||
if para_id_attr:
|
||||
if cell_para_id is None:
|
||||
cell_para_id = para_id_attr # First paraId
|
||||
cell_para_id_end = (
|
||||
para_id_attr # Always update to get last
|
||||
)
|
||||
|
||||
# Extract text with format preservation (superscript/subscript/equations)
|
||||
para_text = extract_paragraph_content_table(
|
||||
para_elem,
|
||||
qn,
|
||||
drawing_context=drawing_context,
|
||||
)
|
||||
|
||||
if para_text:
|
||||
para_texts.append(para_text.strip())
|
||||
cell_text = "\n".join(para_texts).replace("\x07", "")
|
||||
|
||||
# Store content and paraIds for vMerge restart
|
||||
if is_vmerge_restart:
|
||||
vmerge_content[grid_col] = {
|
||||
"text": cell_text,
|
||||
"para_id": cell_para_id,
|
||||
"para_id_end": cell_para_id_end,
|
||||
}
|
||||
elif is_normal_cell:
|
||||
# For normal cells: if empty and we have active vMerge, copy all from start
|
||||
# If non-empty, this ends the vMerge region
|
||||
if not cell_text and grid_col in vmerge_content:
|
||||
# Empty cell in vMerge region - copy content and paraIds from start
|
||||
cell_text = vmerge_content[grid_col]["text"]
|
||||
cell_para_id = vmerge_content[grid_col]["para_id"]
|
||||
cell_para_id_end = vmerge_content[grid_col]["para_id_end"]
|
||||
elif cell_text:
|
||||
# Non-empty cell - this ends the vMerge for this column
|
||||
vmerge_content.pop(grid_col, None)
|
||||
|
||||
elif is_vmerge_continue:
|
||||
# Copy content and para_id from previous merge start
|
||||
# But extract actual para_id_end from this continue cell for range boundary
|
||||
if grid_col in vmerge_content:
|
||||
cell_text = vmerge_content[grid_col]["text"]
|
||||
cell_para_id = vmerge_content[grid_col][
|
||||
"para_id"
|
||||
] # Use restart's paraId for edit targeting
|
||||
|
||||
# Extract actual paraId from this continue cell for uuid_end (range boundary)
|
||||
for para_elem in tc.findall(qn("w:p")):
|
||||
para_id_attr = para_elem.get(
|
||||
"{http://schemas.microsoft.com/office/word/2010/wordml}paraId"
|
||||
)
|
||||
if para_id_attr:
|
||||
cell_para_id_end = (
|
||||
para_id_attr # Use actual paraId for range boundary
|
||||
)
|
||||
|
||||
# Place content at starting grid position only
|
||||
if grid_col < num_cols:
|
||||
row_data[grid_col] = cell_text
|
||||
row_para_ids[grid_col] = cell_para_id
|
||||
row_para_ids_end[grid_col] = cell_para_id_end
|
||||
|
||||
# Move grid position by gridSpan
|
||||
grid_col += grid_span
|
||||
|
||||
grid.append(row_data)
|
||||
para_ids_grid.append(row_para_ids)
|
||||
para_ids_end_grid.append(row_para_ids_end)
|
||||
|
||||
return {
|
||||
"rows": grid,
|
||||
"para_ids": para_ids_grid,
|
||||
"para_ids_end": para_ids_end_grid,
|
||||
"header_indices": header_indices,
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ABOUTME: Shared token estimation utilities for audit scripts
|
||||
ABOUTME: XML sanitization helpers for document processing
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
try:
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
HAS_GEMINI = True
|
||||
except ImportError: # pragma: no cover - optional dependency
|
||||
genai = None
|
||||
types = None
|
||||
HAS_GEMINI = False
|
||||
|
||||
try:
|
||||
import openai
|
||||
|
||||
HAS_OPENAI = True
|
||||
except ImportError: # pragma: no cover - optional dependency
|
||||
openai = None
|
||||
HAS_OPENAI = False
|
||||
|
||||
|
||||
def estimate_tokens(text: str) -> int:
|
||||
"""
|
||||
Estimate token count for LLM context management.
|
||||
|
||||
Uses a weighted formula based on character types:
|
||||
- Chinese characters: ~0.75 tokens per character (subword tokenization)
|
||||
- JSON structural characters (brackets, quotes, commas): ~1 tokens per character
|
||||
- Other characters (English, numbers, symbols): ~0.4 tokens per character (~3 chars/token)
|
||||
|
||||
Includes 5% buffer and safety offset for special formatting and system prompt overhead.
|
||||
|
||||
Args:
|
||||
text: Input text to estimate tokens for
|
||||
|
||||
Returns:
|
||||
int: Estimated token count
|
||||
"""
|
||||
if not text:
|
||||
return 0
|
||||
|
||||
chinese_count = len(re.findall(r"[\u4e00-\u9fa5]", text))
|
||||
json_chars_count = len(re.findall(r'[\[\]",{}]', text))
|
||||
other_count = len(text) - chinese_count - json_chars_count
|
||||
|
||||
base_estimate = (
|
||||
(chinese_count * 0.75) + (json_chars_count * 1) + (other_count * 0.4)
|
||||
)
|
||||
final_tokens = int(base_estimate * 1.05) + 2
|
||||
return final_tokens
|
||||
|
||||
|
||||
def sanitize_xml_string(text: str) -> str:
|
||||
"""
|
||||
Remove control characters that are illegal in XML 1.0.
|
||||
|
||||
XML 1.0 allows: #x9 (tab), #xA (LF), #xD (CR), and #x20-#xD7FF, #xE000-#xFFFD, #x10000-#x10FFFF
|
||||
This function removes all other control characters (0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F).
|
||||
|
||||
Args:
|
||||
text: Text that may contain control characters
|
||||
|
||||
Returns:
|
||||
Sanitized text safe for XML. Returns input unchanged if not a non-empty string.
|
||||
"""
|
||||
if not text or not isinstance(text, str):
|
||||
return text
|
||||
# Build a translation table to remove illegal control characters
|
||||
# Keep: \t (0x09), \n (0x0A), \r (0x0D)
|
||||
# Remove: 0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F
|
||||
illegal_chars = "".join(chr(c) for c in range(0x20) if c not in (0x09, 0x0A, 0x0D))
|
||||
return text.translate(str.maketrans("", "", illegal_chars))
|
||||
|
||||
|
||||
def is_vertex_ai_mode() -> bool:
|
||||
"""
|
||||
Check if Vertex AI mode is enabled via environment variable.
|
||||
|
||||
Returns:
|
||||
True if GOOGLE_GENAI_USE_VERTEXAI is set to 'true', False otherwise
|
||||
"""
|
||||
return os.getenv("GOOGLE_GENAI_USE_VERTEXAI", "").lower() == "true"
|
||||
|
||||
|
||||
def create_gemini_client(use_async: bool = False):
|
||||
"""
|
||||
Create Gemini client for AI Studio or Vertex AI.
|
||||
|
||||
Supports two modes:
|
||||
- AI Studio (default): Uses GOOGLE_API_KEY for authentication
|
||||
- Vertex AI: Uses ADC (GOOGLE_APPLICATION_CREDENTIALS or gcloud auth)
|
||||
|
||||
Environment variables for Vertex AI mode:
|
||||
- GOOGLE_GENAI_USE_VERTEXAI: Set to 'true' to enable Vertex AI mode
|
||||
- GOOGLE_CLOUD_PROJECT: Required GCP project ID
|
||||
- GOOGLE_CLOUD_LOCATION: Optional region (default: us-central1)
|
||||
- GOOGLE_VERTEX_BASE_URL: Optional custom API endpoint (for API gateway proxies)
|
||||
- GOOGLE_APPLICATION_CREDENTIALS: Path to service account JSON (or use gcloud auth)
|
||||
|
||||
Args:
|
||||
use_async: If True, return the async client (.aio), otherwise return sync client
|
||||
|
||||
Returns:
|
||||
Gemini client instance (sync or async based on use_async parameter)
|
||||
|
||||
Raises:
|
||||
ValueError: If required environment variables are not set
|
||||
"""
|
||||
use_vertex = is_vertex_ai_mode()
|
||||
|
||||
if use_vertex:
|
||||
# Vertex AI mode - uses ADC (GOOGLE_APPLICATION_CREDENTIALS or gcloud auth)
|
||||
project = os.getenv("GOOGLE_CLOUD_PROJECT")
|
||||
location = os.getenv("GOOGLE_CLOUD_LOCATION", "us-central1")
|
||||
base_url = os.getenv("GOOGLE_VERTEX_BASE_URL")
|
||||
|
||||
if not project:
|
||||
raise ValueError(
|
||||
"GOOGLE_CLOUD_PROJECT is required for Vertex AI mode. "
|
||||
"Set GOOGLE_GENAI_USE_VERTEXAI=false to use AI Studio mode instead."
|
||||
)
|
||||
|
||||
# Build http_options only if custom base_url is specified
|
||||
http_options = None
|
||||
if base_url:
|
||||
http_options = {"base_url": base_url}
|
||||
|
||||
# Note: ADC handles authentication automatically
|
||||
# via GOOGLE_APPLICATION_CREDENTIALS env var or gcloud auth
|
||||
client = genai.Client(
|
||||
vertexai=True, project=project, location=location, http_options=http_options
|
||||
)
|
||||
else:
|
||||
# AI Studio mode - requires API key
|
||||
api_key = os.getenv("GOOGLE_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"GOOGLE_API_KEY is required for AI Studio mode. "
|
||||
"Set GOOGLE_GENAI_USE_VERTEXAI=true and configure GCP credentials for Vertex AI mode."
|
||||
)
|
||||
|
||||
client = genai.Client(api_key=api_key)
|
||||
|
||||
# Return async or sync client based on parameter
|
||||
return client.aio if use_async else client
|
||||
|
||||
|
||||
def get_gemini_provider_name() -> str:
|
||||
"""
|
||||
Get the Gemini provider name based on current mode.
|
||||
|
||||
Returns:
|
||||
Provider name string for display purposes
|
||||
"""
|
||||
if is_vertex_ai_mode():
|
||||
project = os.getenv("GOOGLE_CLOUD_PROJECT", "unknown")
|
||||
location = os.getenv("GOOGLE_CLOUD_LOCATION", "us-central1")
|
||||
return f"Google Gemini (Vertex AI: {project}/{location})"
|
||||
return "Google Gemini (AI Studio)"
|
||||
|
||||
|
||||
def create_openai_client(use_async: bool = True):
|
||||
"""
|
||||
Create OpenAI client with optional custom base URL.
|
||||
|
||||
Environment variables:
|
||||
- OPENAI_API_KEY: Required API key
|
||||
- OPENAI_BASE_URL: Optional custom API endpoint (for proxies, Azure, etc.)
|
||||
|
||||
Args:
|
||||
use_async: If True, return AsyncOpenAI, otherwise return OpenAI
|
||||
|
||||
Returns:
|
||||
OpenAI client instance (async or sync based on use_async parameter)
|
||||
|
||||
Raises:
|
||||
ValueError: If OPENAI_API_KEY is not set
|
||||
"""
|
||||
if not HAS_OPENAI:
|
||||
raise ValueError("openai library is not installed.")
|
||||
api_key = os.getenv("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError("OPENAI_API_KEY is required for OpenAI mode.")
|
||||
|
||||
base_url = os.getenv("OPENAI_BASE_URL")
|
||||
|
||||
if use_async:
|
||||
return openai.AsyncOpenAI(base_url=base_url)
|
||||
return openai.OpenAI(base_url=base_url)
|
||||
|
||||
|
||||
def get_openai_provider_name() -> str:
|
||||
"""
|
||||
Get the OpenAI provider name, including custom endpoint if configured.
|
||||
|
||||
Returns:
|
||||
Provider name string for display purposes
|
||||
"""
|
||||
base_url = os.getenv("OPENAI_BASE_URL")
|
||||
if base_url:
|
||||
return f"OpenAI (Custom: {base_url})"
|
||||
return "OpenAI"
|
||||
|
||||
|
||||
def is_openai_reasoning_model(model_name: str) -> bool:
|
||||
"""
|
||||
Check if the OpenAI model supports reasoning_effort parameter.
|
||||
|
||||
Models that support reasoning_effort:
|
||||
- o-series: o1, o3, o4 and their variants (o1-mini, o1-2024-12-17, etc.)
|
||||
- gpt-5 series: gpt-5, gpt-5.2, gpt-5-turbo, etc.
|
||||
|
||||
Non-reasoning models like gpt-4.1, gpt-4o, etc. will reject this parameter.
|
||||
|
||||
Handles proxy/router prefixes like "openai/o1-mini" or "openrouter/gpt-5.2".
|
||||
|
||||
Args:
|
||||
model_name: The OpenAI model name (may include path prefix)
|
||||
|
||||
Returns:
|
||||
True if the model supports reasoning_effort, False otherwise
|
||||
"""
|
||||
model_lower = model_name.lower()
|
||||
|
||||
# Handle proxy/router prefixes like "openai/o1-mini", "openrouter/gpt-5.2"
|
||||
# Extract the base model name after the last "/"
|
||||
if "/" in model_lower:
|
||||
model_lower = model_lower.rsplit("/", 1)[-1]
|
||||
|
||||
# Match o-series and gpt-5 series
|
||||
return model_lower.startswith(("o1", "o3", "o4", "gpt-5"))
|
||||
|
||||
|
||||
def is_openai_retryable(error: Exception) -> bool:
|
||||
"""
|
||||
Determine if an OpenAI error should be retried.
|
||||
|
||||
Non-retryable errors:
|
||||
- AuthenticationError (401): Invalid API key
|
||||
- PermissionDeniedError (403): No access to resource
|
||||
- BadRequestError (400): Invalid request format
|
||||
- NotFoundError (404): Model or resource not found
|
||||
|
||||
Retryable errors:
|
||||
- RateLimitError (429): Rate limit exceeded
|
||||
- APIConnectionError: Network issues
|
||||
- InternalServerError (500): Server errors
|
||||
- APIStatusError with 502, 503, 504: Gateway/service errors
|
||||
|
||||
Args:
|
||||
error: The exception from OpenAI API call
|
||||
|
||||
Returns:
|
||||
True if the error should be retried, False otherwise
|
||||
"""
|
||||
if not HAS_OPENAI:
|
||||
return True
|
||||
|
||||
# Authentication error - invalid API key (401)
|
||||
if isinstance(error, openai.AuthenticationError):
|
||||
return False
|
||||
|
||||
# Permission denied - no access to resource (403)
|
||||
if isinstance(error, openai.PermissionDeniedError):
|
||||
return False
|
||||
|
||||
# Bad request - invalid request format (400)
|
||||
if isinstance(error, openai.BadRequestError):
|
||||
return False
|
||||
|
||||
# Not found - model or resource doesn't exist (404)
|
||||
if isinstance(error, openai.NotFoundError):
|
||||
return False
|
||||
|
||||
# Rate limit exceeded - should retry with backoff (429)
|
||||
if isinstance(error, openai.RateLimitError):
|
||||
return True
|
||||
|
||||
# API connection error - network issues, should retry
|
||||
if isinstance(error, openai.APIConnectionError):
|
||||
return True
|
||||
|
||||
# Internal server error - should retry (500)
|
||||
if isinstance(error, openai.InternalServerError):
|
||||
return True
|
||||
|
||||
# For other APIStatusError, check HTTP status code
|
||||
if isinstance(error, openai.APIStatusError):
|
||||
# Retryable server-side errors
|
||||
return error.status_code in (429, 500, 502, 503, 504)
|
||||
|
||||
# For unknown errors, default to retry (network issues, timeouts, etc.)
|
||||
return True
|
||||
|
||||
|
||||
def is_gemini_retryable(error: Exception) -> bool:
|
||||
"""
|
||||
Determine if a Gemini error should be retried.
|
||||
|
||||
Uses string matching on error messages since google-genai may not have
|
||||
well-defined exception types for all error cases.
|
||||
|
||||
Non-retryable errors:
|
||||
- API key errors
|
||||
- Authentication/permission errors
|
||||
- Invalid request errors
|
||||
- Model not found errors
|
||||
- Billing/quota permanently exceeded
|
||||
|
||||
Retryable errors:
|
||||
- Rate limit (429)
|
||||
- Server errors (500, 502, 503, 504)
|
||||
- Timeout/connection errors
|
||||
|
||||
Args:
|
||||
error: The exception from Gemini API call
|
||||
|
||||
Returns:
|
||||
True if the error should be retried, False otherwise
|
||||
"""
|
||||
error_str = str(error).lower()
|
||||
|
||||
# API key / authentication errors - do not retry
|
||||
if "api_key" in error_str or "api key" in error_str:
|
||||
return False
|
||||
if "authentication" in error_str or "authenticate" in error_str:
|
||||
return False
|
||||
if "invalid_api_key" in error_str or "invalid api key" in error_str:
|
||||
return False
|
||||
|
||||
# Permission / forbidden errors - do not retry
|
||||
if "permission" in error_str and "denied" in error_str:
|
||||
return False
|
||||
if "forbidden" in error_str or "403" in error_str:
|
||||
return False
|
||||
|
||||
# Invalid request errors - do not retry
|
||||
if "invalid" in error_str and ("request" in error_str or "argument" in error_str):
|
||||
return False
|
||||
if "400" in error_str and "bad request" in error_str:
|
||||
return False
|
||||
|
||||
# Model not found - do not retry
|
||||
if "model" in error_str and ("not found" in error_str or "not exist" in error_str):
|
||||
return False
|
||||
if "404" in error_str:
|
||||
return False
|
||||
|
||||
# Billing / permanent quota errors - do not retry
|
||||
if "billing" in error_str:
|
||||
return False
|
||||
if "quota" in error_str and ("exceeded" in error_str or "exhausted" in error_str):
|
||||
# Check if it mentions billing which indicates permanent quota issue
|
||||
if "billing" in error_str or "payment" in error_str:
|
||||
return False
|
||||
# Temporary quota (rate limit) - should retry
|
||||
return True
|
||||
|
||||
# Rate limit errors - should retry (429)
|
||||
if "rate" in error_str and "limit" in error_str:
|
||||
return True
|
||||
if "429" in error_str or "resource_exhausted" in error_str:
|
||||
return True
|
||||
|
||||
# Server errors - should retry (500, 502, 503, 504)
|
||||
if any(code in error_str for code in ["500", "502", "503", "504"]):
|
||||
return True
|
||||
if "internal" in error_str and ("error" in error_str or "server" in error_str):
|
||||
return True
|
||||
if "service" in error_str and "unavailable" in error_str:
|
||||
return True
|
||||
if "gateway" in error_str:
|
||||
return True
|
||||
|
||||
# Timeout / connection errors - should retry
|
||||
if "timeout" in error_str or "timed out" in error_str:
|
||||
return True
|
||||
if "connection" in error_str:
|
||||
return True
|
||||
if "network" in error_str:
|
||||
return True
|
||||
|
||||
# Unknown errors - default to retry with limited attempts
|
||||
return True
|
||||
|
||||
|
||||
# JSON Schema for LLM structured output
|
||||
AUDIT_RESULT_SCHEMA = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"is_violation": {
|
||||
"type": "boolean",
|
||||
"description": "Whether any violations were found",
|
||||
},
|
||||
"violations": {
|
||||
"type": "array",
|
||||
"description": "List of violations found",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"rule_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the violated rule (e.g., R001)",
|
||||
},
|
||||
"violation_text": {
|
||||
"type": "string",
|
||||
"description": "The problematic text directly verbatim quote from the source content, and not span multiple cells",
|
||||
},
|
||||
"violation_reason": {
|
||||
"type": "string",
|
||||
"description": "Explanation of why this violates the rule",
|
||||
},
|
||||
"fix_action": {
|
||||
"type": "string",
|
||||
"enum": ["replace", "manual"],
|
||||
"description": "Action type: replace substitutes text (including deletion-via-replace), manual requires human review",
|
||||
},
|
||||
"revised_text": {
|
||||
"type": "string",
|
||||
"description": "For replace: complete replacement text (including deletion-via-replace). For manual: additional guidance for human reviewer",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"rule_id",
|
||||
"violation_text",
|
||||
"violation_reason",
|
||||
"fix_action",
|
||||
"revised_text",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["is_violation", "violations"],
|
||||
}
|
||||
|
||||
# JSON Schema for global extraction output
|
||||
GLOBAL_EXTRACT_SCHEMA = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"results": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"rule_id": {"type": "string"},
|
||||
"extracted_results": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"entity": {"type": "string"},
|
||||
"fields": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"value": {"type": "string"},
|
||||
"evidence": {"type": "string"},
|
||||
},
|
||||
"required": ["name", "value", "evidence"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["entity", "fields"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["rule_id", "extracted_results"],
|
||||
},
|
||||
}
|
||||
},
|
||||
"required": ["results"],
|
||||
}
|
||||
|
||||
# JSON Schema for global verification output
|
||||
GLOBAL_VERIFY_SCHEMA = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"violations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"rule_id": {"type": "string"},
|
||||
"uuid": {"type": "string"},
|
||||
"uuid_end": {"type": "string"},
|
||||
"violation_text": {"type": "string"},
|
||||
"violation_reason": {"type": "string"},
|
||||
"fix_action": {"type": "string", "enum": ["replace", "manual"]},
|
||||
"revised_text": {"type": "string"},
|
||||
},
|
||||
"required": [
|
||||
"rule_id",
|
||||
"uuid",
|
||||
"uuid_end",
|
||||
"violation_text",
|
||||
"violation_reason",
|
||||
"fix_action",
|
||||
"revised_text",
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
"required": ["violations"],
|
||||
}
|
||||
|
||||
|
||||
async def global_extract_gemini_async(
|
||||
user_prompt: str,
|
||||
system_prompt: str,
|
||||
model_name: str,
|
||||
client,
|
||||
thinking_level: str = None,
|
||||
thinking_budget: int = None,
|
||||
) -> dict:
|
||||
thinking_config = None
|
||||
if thinking_level and thinking_level.upper() in (
|
||||
"MINIMAL",
|
||||
"LOW",
|
||||
"MEDIUM",
|
||||
"HIGH",
|
||||
):
|
||||
level_map = {
|
||||
"MINIMAL": types.ThinkingLevel.MINIMAL,
|
||||
"LOW": types.ThinkingLevel.LOW,
|
||||
"MEDIUM": types.ThinkingLevel.MEDIUM,
|
||||
"HIGH": types.ThinkingLevel.HIGH,
|
||||
}
|
||||
thinking_config = types.ThinkingConfig(
|
||||
thinking_level=level_map[thinking_level.upper()]
|
||||
)
|
||||
elif thinking_budget is not None:
|
||||
thinking_config = types.ThinkingConfig(thinking_budget=int(thinking_budget))
|
||||
|
||||
config_params = {
|
||||
"system_instruction": system_prompt,
|
||||
"response_mime_type": "application/json",
|
||||
"response_schema": GLOBAL_EXTRACT_SCHEMA,
|
||||
}
|
||||
if thinking_config:
|
||||
config_params["thinking_config"] = thinking_config
|
||||
|
||||
response = await client.models.generate_content(
|
||||
model=model_name,
|
||||
contents=user_prompt,
|
||||
config=types.GenerateContentConfig(**config_params),
|
||||
)
|
||||
return json.loads(response.text)
|
||||
|
||||
|
||||
async def global_extract_openai_async(
|
||||
user_prompt: str,
|
||||
system_prompt: str,
|
||||
model_name: str,
|
||||
client,
|
||||
reasoning_effort: str = None,
|
||||
) -> dict:
|
||||
request_params = {
|
||||
"model": model_name,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "global_extract",
|
||||
"strict": True,
|
||||
"schema": GLOBAL_EXTRACT_SCHEMA,
|
||||
},
|
||||
},
|
||||
}
|
||||
if (
|
||||
reasoning_effort
|
||||
and reasoning_effort.lower() in ("low", "medium", "high")
|
||||
and is_openai_reasoning_model(model_name)
|
||||
):
|
||||
request_params["reasoning_effort"] = reasoning_effort.lower()
|
||||
|
||||
response = await client.chat.completions.create(**request_params)
|
||||
return json.loads(response.choices[0].message.content)
|
||||
|
||||
|
||||
async def global_verify_gemini_async(
|
||||
user_prompt: str,
|
||||
system_prompt: str,
|
||||
model_name: str,
|
||||
client,
|
||||
thinking_level: str = None,
|
||||
thinking_budget: int = None,
|
||||
) -> dict:
|
||||
thinking_config = None
|
||||
if thinking_level and thinking_level.upper() in (
|
||||
"MINIMAL",
|
||||
"LOW",
|
||||
"MEDIUM",
|
||||
"HIGH",
|
||||
):
|
||||
level_map = {
|
||||
"MINIMAL": types.ThinkingLevel.MINIMAL,
|
||||
"LOW": types.ThinkingLevel.LOW,
|
||||
"MEDIUM": types.ThinkingLevel.MEDIUM,
|
||||
"HIGH": types.ThinkingLevel.HIGH,
|
||||
}
|
||||
thinking_config = types.ThinkingConfig(
|
||||
thinking_level=level_map[thinking_level.upper()]
|
||||
)
|
||||
elif thinking_budget is not None:
|
||||
thinking_config = types.ThinkingConfig(thinking_budget=int(thinking_budget))
|
||||
|
||||
config_params = {
|
||||
"system_instruction": system_prompt,
|
||||
"response_mime_type": "application/json",
|
||||
"response_schema": GLOBAL_VERIFY_SCHEMA,
|
||||
}
|
||||
if thinking_config:
|
||||
config_params["thinking_config"] = thinking_config
|
||||
|
||||
response = await client.models.generate_content(
|
||||
model=model_name,
|
||||
contents=user_prompt,
|
||||
config=types.GenerateContentConfig(**config_params),
|
||||
)
|
||||
return json.loads(response.text)
|
||||
|
||||
|
||||
async def global_verify_openai_async(
|
||||
user_prompt: str,
|
||||
system_prompt: str,
|
||||
model_name: str,
|
||||
client,
|
||||
reasoning_effort: str = None,
|
||||
) -> dict:
|
||||
request_params = {
|
||||
"model": model_name,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "global_verify",
|
||||
"strict": True,
|
||||
"schema": GLOBAL_VERIFY_SCHEMA,
|
||||
},
|
||||
},
|
||||
}
|
||||
if (
|
||||
reasoning_effort
|
||||
and reasoning_effort.lower() in ("low", "medium", "high")
|
||||
and is_openai_reasoning_model(model_name)
|
||||
):
|
||||
request_params["reasoning_effort"] = reasoning_effort.lower()
|
||||
|
||||
response = await client.chat.completions.create(**request_params)
|
||||
return json.loads(response.choices[0].message.content)
|
||||
|
||||
|
||||
async def audit_block_gemini_async(
|
||||
user_prompt: str,
|
||||
system_prompt: str,
|
||||
model_name: str,
|
||||
client,
|
||||
thinking_level: str = None,
|
||||
thinking_budget: int = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Audit a text block using Google Gemini with strict JSON mode (async version).
|
||||
|
||||
Args:
|
||||
user_prompt: User prompt to audit
|
||||
system_prompt: Cached system prompt with rules and instructions
|
||||
model_name: Gemini model to use
|
||||
client: Gemini async client instance (client.aio)
|
||||
thinking_level: Thinking level for Gemini 3 models (MINIMAL, LOW, MEDIUM, HIGH)
|
||||
thinking_budget: Thinking token budget for Gemini 2.5 models (integer)
|
||||
|
||||
Returns:
|
||||
Audit result dictionary
|
||||
"""
|
||||
# Build thinking config based on model and parameters
|
||||
thinking_config = None
|
||||
|
||||
if thinking_level and thinking_level.upper() in (
|
||||
"MINIMAL",
|
||||
"LOW",
|
||||
"MEDIUM",
|
||||
"HIGH",
|
||||
):
|
||||
# For Gemini 3 models
|
||||
level_map = {
|
||||
"MINIMAL": types.ThinkingLevel.MINIMAL,
|
||||
"LOW": types.ThinkingLevel.LOW,
|
||||
"MEDIUM": types.ThinkingLevel.MEDIUM,
|
||||
"HIGH": types.ThinkingLevel.HIGH,
|
||||
}
|
||||
thinking_config = types.ThinkingConfig(
|
||||
thinking_level=level_map[thinking_level.upper()]
|
||||
)
|
||||
elif thinking_budget is not None:
|
||||
# For Gemini 2.5 models
|
||||
thinking_config = types.ThinkingConfig(thinking_budget=int(thinking_budget))
|
||||
|
||||
config_params = {
|
||||
"system_instruction": system_prompt,
|
||||
"response_mime_type": "application/json",
|
||||
"response_schema": AUDIT_RESULT_SCHEMA,
|
||||
}
|
||||
|
||||
# Only add thinking_config if it's configured
|
||||
if thinking_config:
|
||||
config_params["thinking_config"] = thinking_config
|
||||
|
||||
response = await client.models.generate_content(
|
||||
model=model_name,
|
||||
contents=user_prompt,
|
||||
config=types.GenerateContentConfig(**config_params),
|
||||
)
|
||||
|
||||
# With structured output, response is guaranteed to be valid JSON
|
||||
result = json.loads(response.text)
|
||||
return result
|
||||
|
||||
|
||||
async def audit_block_openai_async(
|
||||
user_prompt: str,
|
||||
system_prompt: str,
|
||||
model_name: str,
|
||||
client,
|
||||
reasoning_effort: str = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Audit a text block using OpenAI with strict JSON mode (async version).
|
||||
|
||||
Args:
|
||||
user_prompt: User prompt to audit
|
||||
system_prompt: Cached system prompt with rules and instructions
|
||||
model_name: OpenAI model to use
|
||||
client: AsyncOpenAI client instance
|
||||
reasoning_effort: Reasoning effort for o-series models (low, medium, high)
|
||||
|
||||
Returns:
|
||||
Audit result dictionary
|
||||
"""
|
||||
request_params = {
|
||||
"model": model_name,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "audit_result",
|
||||
"strict": True,
|
||||
"schema": AUDIT_RESULT_SCHEMA,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Add reasoning_effort only for o-series models that support it
|
||||
if (
|
||||
reasoning_effort
|
||||
and reasoning_effort.lower() in ("low", "medium", "high")
|
||||
and is_openai_reasoning_model(model_name)
|
||||
):
|
||||
request_params["reasoning_effort"] = reasoning_effort.lower()
|
||||
|
||||
response = await client.chat.completions.create(**request_params)
|
||||
|
||||
# With structured output, response is guaranteed to be valid JSON
|
||||
result = json.loads(response.choices[0].message.content)
|
||||
return result
|
||||
Vendored
+50
@@ -0,0 +1,50 @@
|
||||
"""Adapters for external document parsing services.
|
||||
|
||||
Each subpackage under ``parser/external/`` integrates one external parser
|
||||
(docling, mineru, ...) by handling:
|
||||
|
||||
- request/upload/poll choreography against the parser's HTTP API,
|
||||
- on-disk caching of the raw bundle under ``<base>.<engine>_raw/``,
|
||||
- normalization into LightRAG IR (``IRDoc``) for the sidecar writer.
|
||||
|
||||
Shared cross-engine helpers (size/hash, atomic manifest IO, safe zip
|
||||
extraction, env coercion) live at this package root in private modules
|
||||
prefixed ``_``. Engine-specific cache validation, manifest construction,
|
||||
and IR adaptation live in each subpackage.
|
||||
"""
|
||||
|
||||
from lightrag.parser.external._common import (
|
||||
clear_dir_contents,
|
||||
compute_size_and_hash,
|
||||
env_bool,
|
||||
env_int,
|
||||
env_json,
|
||||
raw_dir_for_parsed_dir,
|
||||
)
|
||||
from lightrag.parser.external._manifest import (
|
||||
MANIFEST_FILENAME,
|
||||
MANIFEST_VERSION,
|
||||
Manifest,
|
||||
ManifestFile,
|
||||
load_manifest,
|
||||
manifest_path,
|
||||
write_manifest,
|
||||
)
|
||||
from lightrag.parser.external._zip import safe_extract_zip
|
||||
|
||||
__all__ = [
|
||||
"MANIFEST_FILENAME",
|
||||
"MANIFEST_VERSION",
|
||||
"Manifest",
|
||||
"ManifestFile",
|
||||
"clear_dir_contents",
|
||||
"compute_size_and_hash",
|
||||
"env_bool",
|
||||
"env_int",
|
||||
"env_json",
|
||||
"load_manifest",
|
||||
"manifest_path",
|
||||
"raw_dir_for_parsed_dir",
|
||||
"safe_extract_zip",
|
||||
"write_manifest",
|
||||
]
|
||||
Vendored
+189
@@ -0,0 +1,189 @@
|
||||
"""Shared template for external (download + raw-bundle cache) parser engines.
|
||||
|
||||
``ExternalParserBase.parse`` fixes the common MinerU/Docling flow once:
|
||||
|
||||
resolve → raw_dir → force-reparse check → cache-hit skip
|
||||
else (mkdir + clear_dir_contents + download_into) → build_ir
|
||||
→ write_sidecar → persist full_docs (lightrag) → archive source
|
||||
|
||||
Subclasses implement three engine-private hooks (``is_bundle_valid`` /
|
||||
``download_into`` / ``build_ir``) and set ``raw_dir_suffix`` /
|
||||
``force_reparse_env``. This is the reshaped #3207 contract — now an
|
||||
*internal* template rather than the top-level parser interface — with its two
|
||||
gaps fixed: ``clear_dir_contents`` runs inside the template (cache-miss only),
|
||||
and the per-engine upload-name divergence is normalised to a single
|
||||
``upload_name`` hook parameter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from lightrag.constants import FULL_DOCS_FORMAT_LIGHTRAG
|
||||
from lightrag.parser.base import BaseParser, ParseContext, ParseResult
|
||||
from lightrag.utils import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lightrag.sidecar.ir import IRDoc
|
||||
|
||||
|
||||
class ExternalParserBase(BaseParser):
|
||||
"""Base for engines that fetch a raw bundle from an external service."""
|
||||
|
||||
raw_dir_suffix: str
|
||||
force_reparse_env: str
|
||||
|
||||
# --- engine-private hooks ------------------------------------------------
|
||||
@abstractmethod
|
||||
def is_bundle_valid(
|
||||
self,
|
||||
raw_dir: Path,
|
||||
source_path: Path,
|
||||
*,
|
||||
engine_params: Mapping[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Cheap cache-hit check against the raw bundle on disk.
|
||||
|
||||
``engine_params`` is the per-file engine-parameter override (decoded
|
||||
from ``parse_engine``); it MUST participate in the cache signature so an
|
||||
overridden document does not spuriously hit a bundle parsed with
|
||||
different params.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def download_into(
|
||||
self,
|
||||
raw_dir: Path,
|
||||
source_path: Path,
|
||||
*,
|
||||
upload_name: str,
|
||||
engine_params: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Fetch the raw bundle into ``raw_dir`` (called on cache miss only).
|
||||
|
||||
``engine_params`` is the per-file engine-parameter override applied to
|
||||
both the request payload and the recorded cache signature.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def build_ir(self, raw_dir: Path, document_name: str) -> "IRDoc":
|
||||
"""Convert the raw bundle to an :class:`IRDoc`."""
|
||||
...
|
||||
|
||||
def validate_ir(self, ir: "IRDoc", *, file_path: str, raw_dir: Path) -> None:
|
||||
"""Optional post-build validation hook (default no-op)."""
|
||||
|
||||
# --- template ------------------------------------------------------------
|
||||
async def parse(self, ctx: ParseContext) -> ParseResult:
|
||||
from lightrag.parser.external._common import (
|
||||
clear_dir_contents,
|
||||
env_bool,
|
||||
raw_dir_for_parsed_dir,
|
||||
)
|
||||
from lightrag.parser.routing import decode_parse_engine, encode_parse_engine
|
||||
from lightrag.sidecar import write_sidecar
|
||||
from lightrag.utils_pipeline import (
|
||||
make_lightrag_doc_content,
|
||||
sidecar_uri_for,
|
||||
)
|
||||
|
||||
# Per-file engine params are encoded in the stored ``parse_engine``
|
||||
# directive (e.g. ``mineru(page_range=1-3)``); decode them once and
|
||||
# thread the SAME dict into both the cache-hit check and the download so
|
||||
# an overridden doc can never hit a bundle parsed with different params.
|
||||
# A malformed/corrupt directive fails this doc loudly rather than
|
||||
# silently parsing with no params.
|
||||
_engine, engine_params, decode_errs = decode_parse_engine(
|
||||
ctx.content_data.get("parse_engine")
|
||||
if isinstance(ctx.content_data, dict)
|
||||
else None
|
||||
)
|
||||
if decode_errs:
|
||||
raise ValueError(
|
||||
f"{self.engine_name}: invalid parse_engine for doc_id={ctx.doc_id}: "
|
||||
+ "; ".join(decode_errs)
|
||||
)
|
||||
engine_params = engine_params or None
|
||||
|
||||
rs = ctx.resolve(self.engine_name)
|
||||
source = rs.source_path
|
||||
if not source.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"{self.engine_name} source file not found: {source}"
|
||||
)
|
||||
raw_dir = raw_dir_for_parsed_dir(rs.parsed_dir, suffix=self.raw_dir_suffix)
|
||||
force_reparse = env_bool(self.force_reparse_env, False)
|
||||
|
||||
parse_stage_skipped = False
|
||||
if not force_reparse and self.is_bundle_valid(
|
||||
raw_dir, source, engine_params=engine_params
|
||||
):
|
||||
# Cache hit: stay purely local so a re-parse still works when the
|
||||
# external endpoint is temporarily unavailable.
|
||||
parse_stage_skipped = True
|
||||
logger.info("[%s] raw cache hit doc_id=%s", self.engine_name, ctx.doc_id)
|
||||
else:
|
||||
if force_reparse and raw_dir.exists():
|
||||
logger.info(
|
||||
"[%s] %s set; discarding bundle at %s",
|
||||
self.engine_name,
|
||||
self.force_reparse_env,
|
||||
raw_dir,
|
||||
)
|
||||
# download_into mkdir's raw_dir; we wipe stale contents first so a
|
||||
# previous bundle cannot leak into the new one (cache-miss only).
|
||||
raw_dir.mkdir(parents=True, exist_ok=True)
|
||||
clear_dir_contents(raw_dir)
|
||||
logger.info(
|
||||
"[%s] Parsing %s %s (may take a few minutes)",
|
||||
self.engine_name,
|
||||
ctx.doc_id,
|
||||
source.name,
|
||||
)
|
||||
await self.download_into(
|
||||
raw_dir,
|
||||
source,
|
||||
upload_name=rs.document_name,
|
||||
engine_params=engine_params,
|
||||
)
|
||||
|
||||
ir = self.build_ir(raw_dir, rs.document_name)
|
||||
self.validate_ir(ir, file_path=ctx.file_path, raw_dir=raw_dir)
|
||||
parsed_data = write_sidecar(
|
||||
ir,
|
||||
parsed_dir=rs.parsed_dir,
|
||||
doc_id=ctx.doc_id,
|
||||
engine=self.engine_name,
|
||||
)
|
||||
|
||||
await ctx.rag._persist_parsed_full_docs(
|
||||
ctx.doc_id,
|
||||
{
|
||||
"content": make_lightrag_doc_content(parsed_data["content"]),
|
||||
"file_path": ctx.file_path,
|
||||
"parse_format": FULL_DOCS_FORMAT_LIGHTRAG,
|
||||
"sidecar_location": sidecar_uri_for(rs.parsed_dir),
|
||||
# Re-encode the engine + params so the persisted directive keeps
|
||||
# the per-file params (the `{**existing, **record}` merge in
|
||||
# _persist_parsed_full_docs would otherwise revert it to the
|
||||
# bare engine name).
|
||||
"parse_engine": encode_parse_engine(self.engine_name, engine_params),
|
||||
"update_time": int(time.time()),
|
||||
},
|
||||
)
|
||||
await ctx.archive_source(str(source))
|
||||
return ParseResult(
|
||||
doc_id=ctx.doc_id,
|
||||
file_path=ctx.file_path,
|
||||
parse_format=FULL_DOCS_FORMAT_LIGHTRAG,
|
||||
content=parsed_data["content"],
|
||||
blocks_path=parsed_data["blocks_path"],
|
||||
parse_engine=self.engine_name,
|
||||
parse_stage_skipped=parse_stage_skipped,
|
||||
)
|
||||
Vendored
+152
@@ -0,0 +1,152 @@
|
||||
"""Shared helpers for ``lightrag/parser/external/<engine>/`` packages.
|
||||
|
||||
Currently consumed by the docling subpackage; expected to be reused when
|
||||
mineru is migrated under ``parser/external/mineru/``.
|
||||
|
||||
These are pure functions with no engine-specific knowledge. Engine-specific
|
||||
logic (endpoint signature, options signature, cache validation policy) lives
|
||||
in each engine's own ``cache.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from lightrag.constants import PARSED_DIR_SUFFIX
|
||||
from lightrag.utils import logger
|
||||
|
||||
|
||||
def compute_size_and_hash(path: Path) -> tuple[int, str]:
|
||||
"""Single-read computation of ``(size_bytes, "sha256:<hex>")``.
|
||||
|
||||
Manifest writes use this so the recorded size and hash are guaranteed to
|
||||
describe the same byte stream; using two ``open()`` calls would risk a
|
||||
TOCTOU mismatch if the file changed in between.
|
||||
"""
|
||||
h = hashlib.sha256()
|
||||
size = 0
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(chunk)
|
||||
size += len(chunk)
|
||||
return size, f"sha256:{h.hexdigest()}"
|
||||
|
||||
|
||||
def clear_dir_contents(directory: Path) -> None:
|
||||
"""Delete everything inside ``directory`` but keep ``directory`` itself."""
|
||||
if not directory.exists():
|
||||
return
|
||||
for entry in directory.iterdir():
|
||||
try:
|
||||
if entry.is_dir() and not entry.is_symlink():
|
||||
shutil.rmtree(entry, ignore_errors=True)
|
||||
else:
|
||||
entry.unlink()
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
def raw_dir_for_parsed_dir(parsed_dir: Path, *, suffix: str) -> Path:
|
||||
"""Sibling raw dir for a ``*.parsed`` dir.
|
||||
|
||||
``foo.parsed/`` with ``suffix=".docling_raw"`` → ``foo.docling_raw/``.
|
||||
``suffix`` must start with ``.`` and be engine-specific (the caller
|
||||
binds it via ``functools.partial`` or a thin wrapper).
|
||||
"""
|
||||
if not suffix.startswith("."):
|
||||
raise ValueError(f"raw dir suffix must start with '.', got {suffix!r}")
|
||||
stem = parsed_dir.name
|
||||
if stem.endswith(PARSED_DIR_SUFFIX):
|
||||
stem = stem[: -len(PARSED_DIR_SUFFIX)]
|
||||
return parsed_dir.parent / f"{stem}{suffix}"
|
||||
|
||||
|
||||
def env_bool(name: str, default: bool) -> bool:
|
||||
raw = os.getenv(name, "").strip().lower()
|
||||
if raw in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if raw in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def env_int(name: str, default: int) -> int:
|
||||
raw = os.getenv(name, "").strip()
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"[external_parser] %s=%r is not an integer; using %s", name, raw, default
|
||||
)
|
||||
return default
|
||||
|
||||
|
||||
def env_json(name: str, default: Any) -> Any:
|
||||
"""Parse a JSON env var; on parse error log a warning and return default."""
|
||||
raw = os.getenv(name, "").strip()
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
"[external_parser] %s=%r is not valid JSON; using default", name, raw
|
||||
)
|
||||
return default
|
||||
|
||||
|
||||
def response_error_detail(resp: Any, *, limit: int = 1000) -> str:
|
||||
"""Return a compact response body snippet for HTTP error reporting."""
|
||||
try:
|
||||
payload = resp.json() if getattr(resp, "text", "") else None
|
||||
except Exception:
|
||||
payload = None
|
||||
|
||||
if payload is not None:
|
||||
try:
|
||||
detail = json.dumps(payload, ensure_ascii=False, sort_keys=True)
|
||||
except TypeError:
|
||||
detail = repr(payload)
|
||||
else:
|
||||
detail = str(getattr(resp, "text", "") or "").strip()
|
||||
|
||||
detail = " ".join(detail.split())
|
||||
if not detail:
|
||||
return "empty response body"
|
||||
if len(detail) > limit:
|
||||
return f"{detail[:limit]}...<truncated>"
|
||||
return detail
|
||||
|
||||
|
||||
def raise_for_status_with_detail(resp: Any, operation: str) -> None:
|
||||
"""Raise an HTTP error that preserves service-provided response details.
|
||||
|
||||
Treats any non-2xx response as an error, matching httpx's
|
||||
``raise_for_status`` status handling (which also raises on 1xx/3xx,
|
||||
not just 4xx/5xx) while attaching a compact response-body snippet to
|
||||
the message for faster diagnosis.
|
||||
"""
|
||||
status_code = int(getattr(resp, "status_code", 0) or 0)
|
||||
if 200 <= status_code < 300:
|
||||
return
|
||||
detail = response_error_detail(resp)
|
||||
raise RuntimeError(f"{operation} failed: HTTP {status_code} {detail}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"clear_dir_contents",
|
||||
"compute_size_and_hash",
|
||||
"env_bool",
|
||||
"env_int",
|
||||
"env_json",
|
||||
"raise_for_status_with_detail",
|
||||
"raw_dir_for_parsed_dir",
|
||||
"response_error_detail",
|
||||
]
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
"""Shared ``_manifest.json`` schema for ``parser/external/<engine>/`` bundles.
|
||||
|
||||
The manifest is the *atomic success marker* for a raw bundle. Its presence
|
||||
implies "all files in this directory finished downloading"; its content is
|
||||
the cache key for "is this bundle for the same source file, the same engine
|
||||
version, the same endpoint, and the same option signature we are using right
|
||||
now?".
|
||||
|
||||
Write path: :func:`write_manifest` writes a temp file then atomically renames
|
||||
to ``_manifest.json``. A crash mid-download leaves no manifest, so the next
|
||||
parse call cleanly invalidates and re-downloads.
|
||||
|
||||
Read path: :func:`load_manifest` returns ``None`` if absent, malformed, or
|
||||
recorded under a different engine — either way the bundle is treated as
|
||||
stale.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
MANIFEST_FILENAME = "_manifest.json"
|
||||
MANIFEST_VERSION = "1.0"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManifestFile:
|
||||
"""One file entry inside the bundle. Size always; sha256 only for files
|
||||
where silent corruption would break the adapter (the "critical" file).
|
||||
"""
|
||||
|
||||
path: str # relative to the raw dir
|
||||
size: int
|
||||
sha256: str | None = None # ``"sha256:<hex>"`` or ``None``
|
||||
|
||||
|
||||
@dataclass
|
||||
class Manifest:
|
||||
"""Generic manifest schema. ``engine`` is filled by the caller (docling /
|
||||
mineru / etc.); ``options_signature`` lets per-engine cache layers detect
|
||||
when env-driven request parameters changed without bumping the version.
|
||||
"""
|
||||
|
||||
engine: str
|
||||
source_content_hash: str
|
||||
source_size_bytes: int
|
||||
source_filename_at_parse: str
|
||||
critical_file: ManifestFile
|
||||
files: list[ManifestFile]
|
||||
total_size_bytes: int
|
||||
task_id: str = ""
|
||||
api_mode: str = ""
|
||||
engine_version: str = ""
|
||||
endpoint_signature: str = ""
|
||||
options_signature: str = ""
|
||||
downloaded_at: str = ""
|
||||
extras: dict = field(default_factory=dict)
|
||||
version: str = MANIFEST_VERSION
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"version": self.version,
|
||||
"engine": self.engine,
|
||||
"api_mode": self.api_mode,
|
||||
"engine_version": self.engine_version,
|
||||
"endpoint_signature": self.endpoint_signature,
|
||||
"options_signature": self.options_signature,
|
||||
"source_content_hash": self.source_content_hash,
|
||||
"source_size_bytes": int(self.source_size_bytes),
|
||||
"source_filename_at_parse": self.source_filename_at_parse,
|
||||
"task_id": self.task_id,
|
||||
"downloaded_at": self.downloaded_at,
|
||||
"critical_file": asdict(self.critical_file),
|
||||
"files": [asdict(f) for f in self.files],
|
||||
"total_size_bytes": int(self.total_size_bytes),
|
||||
"extras": dict(self.extras or {}),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict) -> "Manifest":
|
||||
critical_raw = payload.get("critical_file") or {}
|
||||
files_raw = payload.get("files") or []
|
||||
return cls(
|
||||
version=str(payload.get("version") or MANIFEST_VERSION),
|
||||
engine=str(payload.get("engine") or ""),
|
||||
api_mode=str(payload.get("api_mode") or ""),
|
||||
engine_version=str(payload.get("engine_version") or ""),
|
||||
endpoint_signature=str(payload.get("endpoint_signature") or ""),
|
||||
options_signature=str(payload.get("options_signature") or ""),
|
||||
source_content_hash=str(payload.get("source_content_hash") or ""),
|
||||
source_size_bytes=int(payload.get("source_size_bytes") or 0),
|
||||
source_filename_at_parse=str(payload.get("source_filename_at_parse") or ""),
|
||||
task_id=str(payload.get("task_id") or ""),
|
||||
downloaded_at=str(payload.get("downloaded_at") or ""),
|
||||
critical_file=ManifestFile(
|
||||
path=str(critical_raw.get("path") or ""),
|
||||
size=int(critical_raw.get("size") or 0),
|
||||
sha256=(
|
||||
str(critical_raw["sha256"]) if critical_raw.get("sha256") else None
|
||||
),
|
||||
),
|
||||
files=[
|
||||
ManifestFile(
|
||||
path=str(f.get("path") or ""),
|
||||
size=int(f.get("size") or 0),
|
||||
sha256=(str(f["sha256"]) if f.get("sha256") else None),
|
||||
)
|
||||
for f in files_raw
|
||||
if isinstance(f, dict)
|
||||
],
|
||||
total_size_bytes=int(payload.get("total_size_bytes") or 0),
|
||||
extras=dict(payload.get("extras") or {}),
|
||||
)
|
||||
|
||||
|
||||
def manifest_path(raw_dir: Path) -> Path:
|
||||
return raw_dir / MANIFEST_FILENAME
|
||||
|
||||
|
||||
def load_manifest(raw_dir: Path, *, expected_engine: str) -> Manifest | None:
|
||||
"""Return the parsed manifest or ``None`` if absent / malformed / for a
|
||||
different engine. ``expected_engine`` is required so a future shared raw
|
||||
dir cannot serve a bundle that belongs to another engine.
|
||||
"""
|
||||
p = manifest_path(raw_dir)
|
||||
if not p.is_file():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(p.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
if payload.get("version") != MANIFEST_VERSION:
|
||||
return None
|
||||
if payload.get("engine") != expected_engine:
|
||||
return None
|
||||
try:
|
||||
return Manifest.from_dict(payload)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def write_manifest(raw_dir: Path, manifest: Manifest) -> None:
|
||||
"""Atomically write the manifest using temp-file + rename."""
|
||||
raw_dir.mkdir(parents=True, exist_ok=True)
|
||||
final = manifest_path(raw_dir)
|
||||
tmp = final.with_suffix(".json.tmp")
|
||||
tmp.write_text(
|
||||
json.dumps(manifest.to_dict(), ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(tmp, final)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MANIFEST_FILENAME",
|
||||
"MANIFEST_VERSION",
|
||||
"Manifest",
|
||||
"ManifestFile",
|
||||
"load_manifest",
|
||||
"manifest_path",
|
||||
"write_manifest",
|
||||
]
|
||||
Vendored
+67
@@ -0,0 +1,67 @@
|
||||
"""Shared zip-bundle extraction for external parser engines.
|
||||
|
||||
Engines like docling return their full output as a zip archive. This helper
|
||||
extracts it safely (refusing path traversal / absolute paths) into a target
|
||||
directory. Engine-specific post-extraction normalization (e.g. mineru's
|
||||
nested-subdir hoist) is *not* done here — each engine's client handles its
|
||||
own quirks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def safe_extract_zip(
|
||||
payload: bytes,
|
||||
dest_dir: Path,
|
||||
*,
|
||||
max_entries: int | None = None,
|
||||
max_total_bytes: int | None = None,
|
||||
) -> list[str]:
|
||||
"""Extract a zip archive into ``dest_dir``, refusing unsafe paths.
|
||||
|
||||
Raises ``RuntimeError`` if any entry name is absolute or contains ``..``
|
||||
components after normalization. Returns the list of extracted member
|
||||
names (as stored in the zip, prior to OS-specific normalization), so
|
||||
callers can validate the bundle layout without re-walking the directory.
|
||||
|
||||
Optional zip-bomb guards (both default ``None`` = unlimited, preserving the
|
||||
original behaviour for existing callers): ``max_entries`` caps the member
|
||||
count and ``max_total_bytes`` caps the summed *uncompressed* size declared
|
||||
in the archive's central directory. Both are checked from ``infolist()``
|
||||
metadata *before* extraction, so a malicious archive is rejected without
|
||||
being written to disk.
|
||||
"""
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
buf = io.BytesIO(payload)
|
||||
with zipfile.ZipFile(buf) as zf:
|
||||
infos = zf.infolist()
|
||||
if max_entries is not None and len(infos) > max_entries:
|
||||
raise RuntimeError(
|
||||
f"Refusing zip with {len(infos)} entries (max {max_entries})"
|
||||
)
|
||||
if max_total_bytes is not None:
|
||||
total = sum(info.file_size for info in infos)
|
||||
if total > max_total_bytes:
|
||||
raise RuntimeError(
|
||||
f"Refusing zip: uncompressed size {total} bytes "
|
||||
f"exceeds limit {max_total_bytes}"
|
||||
)
|
||||
names = zf.namelist()
|
||||
for name in names:
|
||||
norm = os.path.normpath(name)
|
||||
if (
|
||||
norm.startswith("..")
|
||||
or os.path.isabs(norm)
|
||||
or norm.startswith(("/", os.sep))
|
||||
):
|
||||
raise RuntimeError(f"Refusing zip entry with unsafe path: {name!r}")
|
||||
zf.extractall(dest_dir)
|
||||
return names
|
||||
|
||||
|
||||
__all__ = ["safe_extract_zip"]
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
"""Docling parser integration (raw client, cache, manifest, IR adapter).
|
||||
|
||||
Public surface for the rest of the codebase. ``parse_docling`` imports
|
||||
only from this facade so the inner module layout stays free to evolve.
|
||||
"""
|
||||
|
||||
from lightrag.constants import DOCLING_RAW_DIR_SUFFIX
|
||||
from lightrag.parser.external._common import (
|
||||
clear_dir_contents,
|
||||
raw_dir_for_parsed_dir as _raw_dir_for_parsed_dir,
|
||||
)
|
||||
|
||||
MANIFEST_ENGINE = "docling"
|
||||
|
||||
|
||||
def raw_dir_for_parsed_dir(parsed_dir):
|
||||
"""``foo.parsed/`` → ``foo.docling_raw/`` (docling-specific binding)."""
|
||||
return _raw_dir_for_parsed_dir(parsed_dir, suffix=DOCLING_RAW_DIR_SUFFIX)
|
||||
|
||||
|
||||
# Imported after ``MANIFEST_ENGINE`` / ``DOCLING_RAW_DIR_SUFFIX`` because
|
||||
# the submodules read those constants at import time.
|
||||
from lightrag.parser.external.docling.ir_builder import ( # noqa: E402
|
||||
DoclingIRBuilder,
|
||||
)
|
||||
from lightrag.parser.external.docling.cache import ( # noqa: E402
|
||||
is_bundle_valid,
|
||||
)
|
||||
from lightrag.parser.external.docling.client import ( # noqa: E402
|
||||
DoclingRawClient,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DOCLING_RAW_DIR_SUFFIX",
|
||||
"MANIFEST_ENGINE",
|
||||
"DoclingIRBuilder",
|
||||
"DoclingRawClient",
|
||||
"clear_dir_contents",
|
||||
"is_bundle_valid",
|
||||
"raw_dir_for_parsed_dir",
|
||||
]
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
"""Cache validation for ``*.docling_raw/`` bundles.
|
||||
|
||||
Validation policy (settled in
|
||||
``docs/DoclingSidecarRefactorPlan-zh.md`` §4.1):
|
||||
|
||||
1. ``_manifest.json`` exists, parses, ``engine="docling"`` ∧ schema version
|
||||
matches.
|
||||
2. **Source size fast-path**: ``source_file.stat().st_size`` matches the
|
||||
manifest; mismatch → miss without hashing.
|
||||
3. **Source content_hash**: full sha256 of the current source file matches
|
||||
the manifest.
|
||||
4. **Engine version**: if ``DOCLING_ENGINE_VERSION`` is set in env and the
|
||||
manifest recorded a non-empty value, they must match.
|
||||
5. **Endpoint signature**: if the active ``DOCLING_ENDPOINT`` differs from
|
||||
what was recorded at parse time, miss (avoids re-using a bundle produced
|
||||
by a different docling-serve instance).
|
||||
6. **Options signature**: covers every env or fixed constant that changes
|
||||
the produced bundle (OCR flags, language list, formula enrichment,
|
||||
target format and pipeline). Any change → miss.
|
||||
7. **Critical file**: the main JSON must exist with matching size **and**
|
||||
sha256 — final tie-breaker against silent corruption affecting the file
|
||||
the adapter depends on.
|
||||
8. **Other files**: size-only verification (cheap; covers most corruption
|
||||
modes for markdown / artifacts).
|
||||
|
||||
Any failed step ⇒ cache miss; the caller wipes the directory contents and
|
||||
re-runs the download.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from lightrag.parser.external._common import compute_size_and_hash, env_bool
|
||||
from lightrag.parser.external._manifest import load_manifest
|
||||
from lightrag.parser.external.docling import MANIFEST_ENGINE
|
||||
from lightrag.utils import logger
|
||||
|
||||
# Legacy upload-path suffix. ``env.example`` historically documented
|
||||
# ``DOCLING_ENDPOINT=http://host:5001/v1/convert/file/async`` (the full
|
||||
# upload URL); the current client expects a base URL and appends the path
|
||||
# itself. Strip the suffix so an unmodified pre-refactor ``.env`` keeps
|
||||
# working instead of producing
|
||||
# ``/v1/convert/file/async/v1/convert/file/async`` requests.
|
||||
_LEGACY_UPLOAD_PATH_SUFFIX = "/v1/convert/file/async"
|
||||
_legacy_endpoint_warned = False
|
||||
|
||||
# Envs that change the bytes docling-serve produces. Any change here must
|
||||
# invalidate the bundle cache. ``DOCLING_BBOX_ATTRIBUTES`` is intentionally
|
||||
# NOT in this list: it only affects how the adapter writes IR meta, not the
|
||||
# docling bundle, so flipping it should re-emit the sidecar (which we always
|
||||
# do) without forcing a re-download.
|
||||
DOCLING_TUNABLE_ENVS: tuple[str, ...] = (
|
||||
"DOCLING_DO_OCR",
|
||||
"DOCLING_FORCE_OCR",
|
||||
"DOCLING_OCR_ENGINE",
|
||||
"DOCLING_OCR_PRESET",
|
||||
"DOCLING_OCR_LANG",
|
||||
"DOCLING_DO_FORMULA_ENRICHMENT",
|
||||
)
|
||||
|
||||
|
||||
def current_endpoint_signature() -> str:
|
||||
"""The active docling endpoint, normalized to a base URL.
|
||||
|
||||
Normalization:
|
||||
|
||||
- Trims surrounding whitespace and strips trailing slashes.
|
||||
- Strips the legacy ``/v1/convert/file/async`` upload suffix if present,
|
||||
preserving backwards compatibility with the pre-refactor ``env.example``
|
||||
that documented the full upload URL.
|
||||
|
||||
Returns ``""`` if ``DOCLING_ENDPOINT`` is unset — callers that need a
|
||||
real endpoint (``DoclingRawClient``) raise on empty; callers that only
|
||||
compare against a recorded manifest field (``is_bundle_valid``) silently
|
||||
skip the check when either side is empty.
|
||||
"""
|
||||
global _legacy_endpoint_warned
|
||||
endpoint = os.getenv("DOCLING_ENDPOINT", "").strip().rstrip("/")
|
||||
if endpoint.endswith(_LEGACY_UPLOAD_PATH_SUFFIX):
|
||||
endpoint = endpoint[: -len(_LEGACY_UPLOAD_PATH_SUFFIX)]
|
||||
if not _legacy_endpoint_warned:
|
||||
_legacy_endpoint_warned = True
|
||||
logger.warning(
|
||||
"DOCLING_ENDPOINT still includes the legacy %r upload suffix; "
|
||||
"stripping it. Update your .env to a base URL "
|
||||
"(e.g. http://host:5001).",
|
||||
_LEGACY_UPLOAD_PATH_SUFFIX,
|
||||
)
|
||||
return endpoint
|
||||
|
||||
|
||||
def compute_options_signature(
|
||||
*,
|
||||
tunable_env: dict[str, str],
|
||||
fixed_constants: dict[str, object],
|
||||
) -> str:
|
||||
"""Stable signature over user-tunable env values and fixed pipeline
|
||||
constants.
|
||||
|
||||
Storing the constants in the signature means a future code change that
|
||||
flips e.g. ``image_export_mode`` from ``referenced`` to ``embedded``
|
||||
invalidates every existing cache without anyone having to remember to
|
||||
bump a version.
|
||||
"""
|
||||
payload = json.dumps(
|
||||
{"env": tunable_env, "fixed": fixed_constants},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return "sha256:" + hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def snapshot_tunable_env(
|
||||
overrides: "Mapping[str, Any] | None" = None,
|
||||
) -> dict[str, str]:
|
||||
"""Read effective docling tunables so equivalent requests share a signature.
|
||||
|
||||
``overrides`` carries decoded per-file engine params (Phase 2: ``force_ocr``)
|
||||
and replaces the corresponding env value so the override feeds BOTH the live
|
||||
request and the cache signature.
|
||||
"""
|
||||
overrides = overrides or {}
|
||||
force_ocr = (
|
||||
bool(overrides["force_ocr"])
|
||||
if "force_ocr" in overrides
|
||||
else env_bool("DOCLING_FORCE_OCR", True)
|
||||
)
|
||||
return {
|
||||
"DOCLING_DO_OCR": str(env_bool("DOCLING_DO_OCR", True)).lower(),
|
||||
"DOCLING_FORCE_OCR": str(force_ocr).lower(),
|
||||
"DOCLING_OCR_ENGINE": os.getenv("DOCLING_OCR_ENGINE", "auto").strip() or "auto",
|
||||
"DOCLING_OCR_PRESET": os.getenv("DOCLING_OCR_PRESET", "auto").strip() or "auto",
|
||||
"DOCLING_OCR_LANG": os.getenv("DOCLING_OCR_LANG", "").strip(),
|
||||
"DOCLING_DO_FORMULA_ENRICHMENT": str(
|
||||
env_bool("DOCLING_DO_FORMULA_ENRICHMENT", False)
|
||||
).lower(),
|
||||
}
|
||||
|
||||
|
||||
def is_bundle_valid(
|
||||
raw_dir: Path,
|
||||
source_file: Path,
|
||||
*,
|
||||
overrides: "Mapping[str, Any] | None" = None,
|
||||
) -> bool:
|
||||
"""Return True iff the bundle matches the current source + env state."""
|
||||
if not raw_dir.is_dir():
|
||||
return False
|
||||
|
||||
manifest = load_manifest(raw_dir, expected_engine=MANIFEST_ENGINE)
|
||||
if manifest is None:
|
||||
return False
|
||||
|
||||
# 1. Source size fast-path
|
||||
try:
|
||||
cur_size = source_file.stat().st_size
|
||||
except OSError:
|
||||
return False
|
||||
if cur_size != int(manifest.source_size_bytes):
|
||||
return False
|
||||
|
||||
# 2. Source content_hash
|
||||
_, cur_hash = compute_size_and_hash(source_file)
|
||||
if cur_hash != manifest.source_content_hash:
|
||||
return False
|
||||
|
||||
# 3. Engine version. Skip the comparison when either side is empty so
|
||||
# operators can opt out by unsetting the env, and so bundles from
|
||||
# earlier code that never recorded the field aren't force-invalidated.
|
||||
cur_engine_version = os.getenv("DOCLING_ENGINE_VERSION", "").strip()
|
||||
if (
|
||||
cur_engine_version
|
||||
and manifest.engine_version
|
||||
and cur_engine_version != manifest.engine_version
|
||||
):
|
||||
return False
|
||||
|
||||
# 4. Endpoint signature. Same "both non-empty to compare" rule: a bundle
|
||||
# parsed against a different docling-serve URL must not be reused, but
|
||||
# we don't reject the cache just because the env happens to be unset
|
||||
# at validation time (e.g. CLI tooling that only reads the cache).
|
||||
cur_endpoint = current_endpoint_signature()
|
||||
if (
|
||||
cur_endpoint
|
||||
and manifest.endpoint_signature
|
||||
and cur_endpoint != manifest.endpoint_signature
|
||||
):
|
||||
return False
|
||||
|
||||
# 5. Options signature: only enforced if the manifest recorded one
|
||||
# (manifests written before this commit have it empty — they are
|
||||
# treated as stale and re-downloaded the next time the env changes).
|
||||
#
|
||||
# Compare against the *current* fixed constants from client.py, not
|
||||
# the copy stashed in the manifest — using the manifest's copy would
|
||||
# always reproduce the recorded signature and silently swallow
|
||||
# code-only changes (e.g. flipping image_export_mode or to_formats),
|
||||
# defeating the invalidation this step is supposed to provide.
|
||||
# Lazy import: client.py imports from cache.py.
|
||||
#
|
||||
# When per-file overrides are requested (e.g. docling(force_ocr=true)) but
|
||||
# the manifest predates signature recording, we cannot prove the bundle was
|
||||
# produced with those overrides — accepting it would silently drop the
|
||||
# user's explicit param. Treat that as a miss so the override is honored
|
||||
# (mirrors MinerU, which misses on any absent signature). The no-override
|
||||
# case keeps the deliberate leniency above for legacy bundles.
|
||||
if overrides and not manifest.options_signature:
|
||||
return False
|
||||
if manifest.options_signature:
|
||||
from lightrag.parser.external.docling.client import FIXED_CONSTANTS
|
||||
|
||||
cur_options = compute_options_signature(
|
||||
tunable_env=snapshot_tunable_env(overrides),
|
||||
fixed_constants=FIXED_CONSTANTS,
|
||||
)
|
||||
if cur_options != manifest.options_signature:
|
||||
return False
|
||||
|
||||
# 6. Critical file: size + sha256
|
||||
crit = manifest.critical_file
|
||||
crit_path = raw_dir / crit.path
|
||||
try:
|
||||
if crit_path.stat().st_size != int(crit.size):
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
if crit.sha256:
|
||||
_, crit_actual = compute_size_and_hash(crit_path)
|
||||
if crit_actual != crit.sha256:
|
||||
return False
|
||||
|
||||
# 7. Other files: size only
|
||||
for entry in manifest.files:
|
||||
ep = raw_dir / entry.path
|
||||
try:
|
||||
if ep.stat().st_size != int(entry.size):
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DOCLING_TUNABLE_ENVS",
|
||||
"compute_options_signature",
|
||||
"current_endpoint_signature",
|
||||
"is_bundle_valid",
|
||||
"snapshot_tunable_env",
|
||||
]
|
||||
+353
@@ -0,0 +1,353 @@
|
||||
"""Docling raw bundle downloader.
|
||||
|
||||
Talks to Docling Serve v1 over HTTP:
|
||||
|
||||
- ``POST /v1/convert/file/async`` — multipart upload, returns ``task_id``,
|
||||
- ``GET /v1/status/poll/{task_id}?wait=5`` — long-poll for terminal state,
|
||||
- ``GET /v1/result/{task_id}`` — zip download (only on ``success``).
|
||||
|
||||
The zip is extracted safely under ``raw_dir/`` (refusing path traversal /
|
||||
absolute entries). A success manifest is written atomically at the very
|
||||
end; mid-run crashes therefore leave the directory in a state the cache
|
||||
layer marks as invalid (no manifest → miss → re-download).
|
||||
|
||||
Pipeline constants (``pipeline``, ``target_type``, ``to_formats``,
|
||||
``image_export_mode``) are intentionally **not** env-driven — the sidecar
|
||||
flow depends on them — and are recorded inside the manifest so a future
|
||||
code change automatically invalidates pre-existing caches.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from lightrag.parser.external._common import (
|
||||
env_bool,
|
||||
env_int,
|
||||
raise_for_status_with_detail,
|
||||
)
|
||||
from lightrag.parser.external._zip import safe_extract_zip
|
||||
from lightrag.parser.external.docling.cache import (
|
||||
compute_options_signature,
|
||||
current_endpoint_signature,
|
||||
snapshot_tunable_env,
|
||||
)
|
||||
from lightrag.parser.external.docling.manifest import (
|
||||
build_and_write_docling_manifest,
|
||||
select_main_json,
|
||||
)
|
||||
from lightrag.utils import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import httpx
|
||||
else:
|
||||
try:
|
||||
import httpx
|
||||
except ImportError: # pragma: no cover
|
||||
httpx = None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixed pipeline constants (NOT env-driven)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PIPELINE = "standard"
|
||||
TARGET_TYPE = "zip"
|
||||
TO_FORMATS: tuple[str, ...] = ("json", "md")
|
||||
IMAGE_EXPORT_MODE = "referenced"
|
||||
|
||||
FIXED_CONSTANTS: dict[str, object] = {
|
||||
"pipeline": PIPELINE,
|
||||
"target_type": TARGET_TYPE,
|
||||
"to_formats": list(TO_FORMATS),
|
||||
"image_export_mode": IMAGE_EXPORT_MODE,
|
||||
}
|
||||
|
||||
CONVERT_PATH = "/v1/convert/file/async"
|
||||
POLL_PATH = "/v1/status/poll/{task_id}"
|
||||
RESULT_PATH = "/v1/result/{task_id}"
|
||||
|
||||
DEFAULT_POLL_WAIT_SECONDS = 5
|
||||
DEFAULT_MAX_POLLS = 240 # 240 * 5s long-poll ≈ 20 min worst case
|
||||
|
||||
# ConversionStatus enum from the docling-serve OpenAPI
|
||||
SUCCESS_STATES = {"success"}
|
||||
FAILURE_STATES = {"failure", "partial_success", "skipped"}
|
||||
IN_PROGRESS_STATES = {"pending", "started"}
|
||||
|
||||
|
||||
class DoclingRawClient:
|
||||
"""Downloads docling-serve bundles into ``raw_dir``.
|
||||
|
||||
Construct once per parse call (cheap). Reads ``DOCLING_*`` envs at
|
||||
``__init__`` time, so callers can flip env between calls and pick up
|
||||
the new values without holding a stale instance.
|
||||
"""
|
||||
|
||||
def __init__(self, *, overrides: "Mapping[str, Any] | None" = None) -> None:
|
||||
self._overrides = overrides or {}
|
||||
self.endpoint = current_endpoint_signature()
|
||||
if not self.endpoint:
|
||||
raise ValueError("DOCLING_ENDPOINT is required")
|
||||
self.engine_version = os.getenv("DOCLING_ENGINE_VERSION", "").strip()
|
||||
|
||||
self.do_ocr = env_bool("DOCLING_DO_OCR", True)
|
||||
self.force_ocr = (
|
||||
bool(self._overrides["force_ocr"])
|
||||
if "force_ocr" in self._overrides
|
||||
else env_bool("DOCLING_FORCE_OCR", True)
|
||||
)
|
||||
self.ocr_engine = os.getenv("DOCLING_OCR_ENGINE", "auto").strip() or "auto"
|
||||
self.ocr_preset = os.getenv("DOCLING_OCR_PRESET", "auto").strip() or "auto"
|
||||
self.ocr_lang_raw = os.getenv("DOCLING_OCR_LANG", "").strip()
|
||||
self.do_formula_enrichment = env_bool("DOCLING_DO_FORMULA_ENRICHMENT", False)
|
||||
|
||||
# Poll cadence: docling-serve's ``?wait=N`` is a server-side long-poll
|
||||
# window. ``DOCLING_POLL_INTERVAL_SECONDS`` sets that window; the
|
||||
# client does NOT add its own sleep between polls. ``DOCLING_MAX_POLLS``
|
||||
# bounds the total polling budget — exceeding it raises ``TimeoutError``.
|
||||
wait = env_int("DOCLING_POLL_INTERVAL_SECONDS", DEFAULT_POLL_WAIT_SECONDS)
|
||||
self.poll_wait_seconds = wait if wait > 0 else DEFAULT_POLL_WAIT_SECONDS
|
||||
max_polls = env_int("DOCLING_MAX_POLLS", DEFAULT_MAX_POLLS)
|
||||
self.max_poll_attempts = max_polls if max_polls > 0 else DEFAULT_MAX_POLLS
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def download_into(
|
||||
self,
|
||||
raw_dir: Path,
|
||||
source_file_path: Path,
|
||||
*,
|
||||
upload_filename: str | None = None,
|
||||
):
|
||||
"""Upload, poll, download, extract, and write the manifest.
|
||||
|
||||
``upload_filename`` overrides the multipart filename sent to
|
||||
docling-serve (defaults to ``source_file_path.name``). The pipeline
|
||||
passes the canonical, hint-stripped document name here so the
|
||||
bundle's ``<stem>.json`` ends up canonical too — otherwise a file
|
||||
named ``report.[docling].pdf`` would produce ``report.[docling].json``
|
||||
inside the bundle, and the adapter (which only knows the canonical
|
||||
``report.pdf``) would not be able to locate it via the preferred
|
||||
``<stem>.json`` lookup.
|
||||
|
||||
Pre-condition: caller cleared ``raw_dir`` (e.g. via
|
||||
:func:`lightrag.parser.external.clear_dir_contents`). This method
|
||||
does not clean the directory itself — keeping that explicit at the
|
||||
``parse_docling`` entry point.
|
||||
"""
|
||||
if httpx is None:
|
||||
raise RuntimeError(
|
||||
"httpx is required for Docling parsing but is not installed"
|
||||
)
|
||||
raw_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
effective_filename = upload_filename or source_file_path.name
|
||||
|
||||
timeout = httpx.Timeout(120.0, connect=30.0)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
task_id = await self._submit(
|
||||
client, source_file_path, filename=effective_filename
|
||||
)
|
||||
await self._poll_until_done(client, task_id)
|
||||
payload = await self._download_zip_bytes(client, task_id)
|
||||
|
||||
safe_extract_zip(payload, raw_dir)
|
||||
# Defensive: confirm the main JSON exists before anyone reads the
|
||||
# bundle. Look it up by the *uploaded* filename's stem — that's
|
||||
# what docling-serve uses to name the JSON inside the zip.
|
||||
select_main_json(raw_dir, Path(effective_filename))
|
||||
|
||||
options_signature = compute_options_signature(
|
||||
tunable_env=snapshot_tunable_env(self._overrides),
|
||||
fixed_constants=FIXED_CONSTANTS,
|
||||
)
|
||||
return build_and_write_docling_manifest(
|
||||
raw_dir,
|
||||
source_file_path=source_file_path,
|
||||
task_id=task_id,
|
||||
endpoint_signature=self.endpoint,
|
||||
engine_version=self.engine_version,
|
||||
options_signature=options_signature,
|
||||
fixed_constants=FIXED_CONSTANTS,
|
||||
recorded_filename=effective_filename,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Upload + poll + download
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_multipart_data(self) -> dict[str, str | list[str]]:
|
||||
"""Form fields (everything except the file payload).
|
||||
|
||||
Returns a ``dict`` (not a list of tuples): httpx ≥ 0.28 short-circuits
|
||||
non-``Mapping`` ``data`` into raw-content encoding and ignores
|
||||
``files=`` entirely, producing a sync-only stream that an
|
||||
``AsyncClient`` then rejects. List-valued entries are emitted as
|
||||
repeated form keys by ``MultipartStream``, matching docling-serve's
|
||||
pydantic ``List[Enum]`` form parsing. ``ocr_lang`` is omitted entirely
|
||||
when empty so the engine uses its own default.
|
||||
"""
|
||||
data: dict[str, str | list[str]] = {
|
||||
"pipeline": PIPELINE,
|
||||
"target_type": TARGET_TYPE,
|
||||
"image_export_mode": IMAGE_EXPORT_MODE,
|
||||
"do_ocr": _bool_form(self.do_ocr),
|
||||
"force_ocr": _bool_form(self.force_ocr),
|
||||
"ocr_engine": self.ocr_engine,
|
||||
"ocr_preset": self.ocr_preset,
|
||||
"do_formula_enrichment": _bool_form(self.do_formula_enrichment),
|
||||
"to_formats": list(TO_FORMATS),
|
||||
}
|
||||
if self.ocr_lang_raw:
|
||||
langs = _parse_ocr_lang(self.ocr_lang_raw)
|
||||
if langs:
|
||||
data["ocr_lang"] = langs
|
||||
return data
|
||||
|
||||
async def _submit(
|
||||
self,
|
||||
client: "httpx.AsyncClient",
|
||||
source_file_path: Path,
|
||||
*,
|
||||
filename: str,
|
||||
) -> str:
|
||||
url = f"{self.endpoint}{CONVERT_PATH}"
|
||||
# Hand httpx a file object so its MultipartStream reads the body in
|
||||
# chunks instead of materializing the whole PDF/PPTX in worker memory.
|
||||
# With ``max_parallel_parse_docling > 1`` a per-doc bytes copy can
|
||||
# OOM the worker before docling-serve ever sees the request.
|
||||
with source_file_path.open("rb") as fh:
|
||||
files = {"files": (filename, fh, "application/octet-stream")}
|
||||
resp = await client.post(
|
||||
url, data=self._build_multipart_data(), files=files
|
||||
)
|
||||
raise_for_status_with_detail(resp, f"Docling upload for {filename!r}")
|
||||
payload = resp.json() if resp.text else {}
|
||||
task_id = str(payload.get("task_id") or payload.get("id") or "").strip()
|
||||
if not task_id:
|
||||
raise RuntimeError(f"Docling upload response missing task_id: {payload!r}")
|
||||
return task_id
|
||||
|
||||
async def _poll_until_done(
|
||||
self,
|
||||
client: "httpx.AsyncClient",
|
||||
task_id: str,
|
||||
) -> None:
|
||||
encoded_task_id = quote(task_id, safe="")
|
||||
url = f"{self.endpoint}{POLL_PATH.format(task_id=encoded_task_id)}"
|
||||
params = {"wait": self.poll_wait_seconds}
|
||||
for _ in range(self.max_poll_attempts):
|
||||
iteration_started = time.monotonic()
|
||||
resp = await client.get(url, params=params)
|
||||
raise_for_status_with_detail(resp, f"Docling task {task_id} poll")
|
||||
payload = resp.json() if resp.text else {}
|
||||
status = str(
|
||||
payload.get("task_status") or payload.get("status") or ""
|
||||
).lower()
|
||||
|
||||
if status in SUCCESS_STATES:
|
||||
return
|
||||
if status in FAILURE_STATES:
|
||||
raise RuntimeError(_format_failure(task_id, status, payload))
|
||||
if status not in IN_PROGRESS_STATES:
|
||||
# Unknown status: keep polling, but surface it so operators notice.
|
||||
logger.warning(
|
||||
"[docling] unknown task status %r for task %s; continuing to poll",
|
||||
status,
|
||||
task_id,
|
||||
)
|
||||
|
||||
# The intended cadence is one poll per ``poll_wait_seconds`` — the
|
||||
# design relies on docling-serve's ``?wait=N`` long-polling for
|
||||
# that. Some deployments return immediately instead, which would
|
||||
# burn through ``max_poll_attempts`` in milliseconds and fail
|
||||
# with a spurious timeout. Cap each iteration at the configured
|
||||
# interval ourselves so the total budget holds either way.
|
||||
elapsed = time.monotonic() - iteration_started
|
||||
remaining = self.poll_wait_seconds - elapsed
|
||||
if remaining > 0:
|
||||
await asyncio.sleep(remaining)
|
||||
|
||||
raise TimeoutError(f"Docling task {task_id} polling timeout")
|
||||
|
||||
async def _download_zip_bytes(
|
||||
self,
|
||||
client: "httpx.AsyncClient",
|
||||
task_id: str,
|
||||
) -> bytes:
|
||||
encoded_task_id = quote(task_id, safe="")
|
||||
url = f"{self.endpoint}{RESULT_PATH.format(task_id=encoded_task_id)}"
|
||||
resp = await client.get(url)
|
||||
raise_for_status_with_detail(resp, f"Docling result {task_id} download")
|
||||
ctype = resp.headers.get("content-type", "")
|
||||
if "zip" not in ctype.lower():
|
||||
raise RuntimeError(
|
||||
f"Docling result {task_id} returned non-zip content-type "
|
||||
f"{ctype!r}; body prefix={resp.text[:400]!r}"
|
||||
)
|
||||
return resp.content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _bool_form(v: bool) -> str:
|
||||
return "true" if v else "false"
|
||||
|
||||
|
||||
def _parse_ocr_lang(raw: str) -> list[str]:
|
||||
"""Best-effort parser for ``DOCLING_OCR_LANG``.
|
||||
|
||||
Accepts a JSON array (``["en","zh"]``) or a comma-separated list
|
||||
(``en,zh``). Returns a list of stripped non-empty strings; empty in →
|
||||
empty out.
|
||||
"""
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
if isinstance(parsed, list):
|
||||
return [str(x).strip() for x in parsed if str(x).strip()]
|
||||
return [item.strip() for item in raw.split(",") if item.strip()]
|
||||
|
||||
|
||||
def _format_failure(task_id: str, status: str, payload: Any) -> str:
|
||||
if isinstance(payload, dict):
|
||||
err = (
|
||||
payload.get("error_message")
|
||||
or payload.get("error")
|
||||
or payload.get("message")
|
||||
or "<no error_message>"
|
||||
)
|
||||
else:
|
||||
err = "<no error_message>"
|
||||
truncated = json.dumps(payload, ensure_ascii=False)[:400]
|
||||
return f"Docling task {task_id} ended in {status}: {err}; payload={truncated}"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DoclingRawClient",
|
||||
"CONVERT_PATH",
|
||||
"DEFAULT_MAX_POLLS",
|
||||
"DEFAULT_POLL_WAIT_SECONDS",
|
||||
"FIXED_CONSTANTS",
|
||||
"IMAGE_EXPORT_MODE",
|
||||
"PIPELINE",
|
||||
"POLL_PATH",
|
||||
"RESULT_PATH",
|
||||
"SUCCESS_STATES",
|
||||
"FAILURE_STATES",
|
||||
"TARGET_TYPE",
|
||||
"TO_FORMATS",
|
||||
]
|
||||
+1072
File diff suppressed because it is too large
Load Diff
+130
@@ -0,0 +1,130 @@
|
||||
"""Helpers for building ``_manifest.json`` for docling raw bundles.
|
||||
|
||||
Wraps the generic :class:`Manifest` schema with docling-specific knowledge:
|
||||
|
||||
- the critical file is the main ``<stem>.json`` produced by docling-serve,
|
||||
- non-critical files are the markdown + every entry under ``artifacts/``,
|
||||
- ``extras`` carries the fixed pipeline constants so the options signature
|
||||
remains reproducible across runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from lightrag.parser.external._common import compute_size_and_hash
|
||||
from lightrag.parser.external._manifest import (
|
||||
MANIFEST_FILENAME,
|
||||
Manifest,
|
||||
ManifestFile,
|
||||
write_manifest,
|
||||
)
|
||||
from lightrag.parser.external.docling import MANIFEST_ENGINE
|
||||
|
||||
|
||||
def select_main_json(raw_dir: Path, source_file_path: Path) -> Path:
|
||||
"""Locate the primary docling JSON inside ``raw_dir``.
|
||||
|
||||
Priority: ``<source_stem>.json`` if present, else the single ``*.json``
|
||||
sitting at ``raw_dir`` root (excluding ``_manifest.json``, which always
|
||||
sits in the bundle once a download has completed and would otherwise
|
||||
collide with the bundle JSON in the fallback). Raises ``RuntimeError``
|
||||
if zero or multiple candidates exist.
|
||||
"""
|
||||
preferred = raw_dir / f"{source_file_path.stem}.json"
|
||||
if preferred.is_file():
|
||||
return preferred
|
||||
|
||||
candidates = sorted(
|
||||
p for p in raw_dir.glob("*.json") if p.is_file() and p.name != MANIFEST_FILENAME
|
||||
)
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
if not candidates:
|
||||
raise RuntimeError(f"Docling raw bundle at {raw_dir} contains no .json file")
|
||||
names = ", ".join(p.name for p in candidates)
|
||||
raise RuntimeError(
|
||||
f"Docling raw bundle at {raw_dir} has multiple .json candidates ({names}); "
|
||||
f"expected exactly one to derive the critical file from"
|
||||
)
|
||||
|
||||
|
||||
def select_main_md(raw_dir: Path, source_file_path: Path) -> Path | None:
|
||||
"""Locate the markdown twin of the main JSON. Returns ``None`` if no
|
||||
markdown was produced (defensive — docling-serve always emits one for
|
||||
``to_formats=["json","md"]`` but we don't want to crash if it is
|
||||
missing)."""
|
||||
preferred = raw_dir / f"{source_file_path.stem}.md"
|
||||
if preferred.is_file():
|
||||
return preferred
|
||||
candidates = sorted(p for p in raw_dir.glob("*.md") if p.is_file())
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
|
||||
def build_and_write_docling_manifest(
|
||||
raw_dir: Path,
|
||||
*,
|
||||
source_file_path: Path,
|
||||
task_id: str,
|
||||
endpoint_signature: str,
|
||||
engine_version: str,
|
||||
options_signature: str,
|
||||
fixed_constants: dict[str, object],
|
||||
recorded_filename: str | None = None,
|
||||
) -> Manifest:
|
||||
"""Construct the manifest for a freshly downloaded docling bundle and
|
||||
persist it atomically. Returns the in-memory manifest for callers that
|
||||
need the task_id / signatures for logging.
|
||||
|
||||
``recorded_filename`` is the name passed to docling-serve at upload
|
||||
time (canonical, hint-stripped form when called from the pipeline).
|
||||
It governs both the preferred-path lookup for the bundle JSON and the
|
||||
value persisted as ``source_filename_at_parse``. When ``None``, falls
|
||||
back to ``source_file_path.name`` for backward compatibility.
|
||||
"""
|
||||
lookup_path = Path(recorded_filename) if recorded_filename else source_file_path
|
||||
main_json = select_main_json(raw_dir, lookup_path)
|
||||
crit_size, crit_hash = compute_size_and_hash(main_json)
|
||||
critical = ManifestFile(
|
||||
path=main_json.relative_to(raw_dir).as_posix(),
|
||||
size=crit_size,
|
||||
sha256=crit_hash,
|
||||
)
|
||||
|
||||
others: list[ManifestFile] = []
|
||||
for path in sorted(raw_dir.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
rel = path.relative_to(raw_dir).as_posix()
|
||||
if rel == critical.path or rel.startswith("_manifest"):
|
||||
continue
|
||||
others.append(ManifestFile(path=rel, size=path.stat().st_size))
|
||||
|
||||
source_size, source_hash = compute_size_and_hash(source_file_path)
|
||||
total = crit_size + sum(f.size for f in others)
|
||||
|
||||
manifest = Manifest(
|
||||
engine=MANIFEST_ENGINE,
|
||||
source_content_hash=source_hash,
|
||||
source_size_bytes=source_size,
|
||||
source_filename_at_parse=recorded_filename or source_file_path.name,
|
||||
critical_file=critical,
|
||||
files=others,
|
||||
total_size_bytes=total,
|
||||
task_id=task_id,
|
||||
endpoint_signature=endpoint_signature,
|
||||
engine_version=engine_version,
|
||||
options_signature=options_signature,
|
||||
downloaded_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
extras={"fixed_constants": dict(fixed_constants)},
|
||||
)
|
||||
write_manifest(raw_dir, manifest)
|
||||
return manifest
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_and_write_docling_manifest",
|
||||
"select_main_json",
|
||||
"select_main_md",
|
||||
]
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
"""Docling engine adapter (implements ExternalParserBase hooks)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from lightrag.constants import DOCLING_RAW_DIR_SUFFIX, PARSER_ENGINE_DOCLING
|
||||
from lightrag.parser.external._base import ExternalParserBase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lightrag.sidecar.ir import IRDoc
|
||||
|
||||
|
||||
class DoclingParser(ExternalParserBase):
|
||||
engine_name = PARSER_ENGINE_DOCLING
|
||||
raw_dir_suffix = DOCLING_RAW_DIR_SUFFIX
|
||||
force_reparse_env = "LIGHTRAG_FORCE_REPARSE_DOCLING"
|
||||
|
||||
def is_bundle_valid(
|
||||
self,
|
||||
raw_dir: Path,
|
||||
source_path: Path,
|
||||
*,
|
||||
engine_params: "Mapping[str, Any] | None" = None,
|
||||
) -> bool:
|
||||
from lightrag.parser.external.docling import is_bundle_valid
|
||||
|
||||
return is_bundle_valid(raw_dir, source_path, overrides=engine_params)
|
||||
|
||||
async def download_into(
|
||||
self,
|
||||
raw_dir: Path,
|
||||
source_path: Path,
|
||||
*,
|
||||
upload_name: str,
|
||||
engine_params: "Mapping[str, Any] | None" = None,
|
||||
) -> None:
|
||||
from lightrag.parser.external.docling import DoclingRawClient
|
||||
|
||||
# Map the canonical ``upload_name`` onto docling-serve's multipart
|
||||
# filename so the bundle's main JSON is named ``<canonical_stem>.json``
|
||||
# (the IR builder locates it via that canonical stem).
|
||||
await DoclingRawClient(overrides=engine_params).download_into(
|
||||
raw_dir, source_path, upload_filename=upload_name
|
||||
)
|
||||
|
||||
def build_ir(self, raw_dir: Path, document_name: str) -> "IRDoc":
|
||||
from lightrag.parser.external.docling import DoclingIRBuilder
|
||||
|
||||
return DoclingIRBuilder().normalize_from_workdir(
|
||||
raw_dir, document_name=document_name
|
||||
)
|
||||
|
||||
def validate_ir(self, ir: "IRDoc", *, file_path: str, raw_dir: Path) -> None:
|
||||
if not ir.blocks:
|
||||
raise ValueError(
|
||||
f"Docling IR builder produced zero blocks for {file_path} "
|
||||
f"(raw_dir={raw_dir})"
|
||||
)
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
"""MinerU parser integration (raw client, cache, manifest, IR builder).
|
||||
|
||||
Public surface for the rest of the codebase. ``parse_mineru`` imports
|
||||
only from this facade so the inner module layout stays free to evolve.
|
||||
|
||||
See ``docs/LightRAGSidecarFormat-zh.md`` for sidecar format and
|
||||
``docs/FileProcessingConfiguration-zh.md`` for cache lifecycle.
|
||||
"""
|
||||
|
||||
from lightrag.parser.external.mineru.cache import (
|
||||
MINERU_RAW_DIR_SUFFIX,
|
||||
clear_dir_contents,
|
||||
compute_size_and_hash,
|
||||
is_bundle_valid,
|
||||
raw_dir_for_parsed_dir,
|
||||
)
|
||||
from lightrag.parser.external.mineru.client import MinerURawClient
|
||||
from lightrag.parser.external.mineru.ir_builder import MinerUIRBuilder
|
||||
from lightrag.parser.external.mineru.manifest import Manifest, ManifestFile
|
||||
|
||||
__all__ = [
|
||||
"MINERU_RAW_DIR_SUFFIX",
|
||||
"Manifest",
|
||||
"ManifestFile",
|
||||
"MinerUIRBuilder",
|
||||
"MinerURawClient",
|
||||
"clear_dir_contents",
|
||||
"compute_size_and_hash",
|
||||
"is_bundle_valid",
|
||||
"raw_dir_for_parsed_dir",
|
||||
]
|
||||
+431
@@ -0,0 +1,431 @@
|
||||
"""Cache validation for ``*.mineru_raw/`` bundles.
|
||||
|
||||
Validation policy (settled in design discussion; see
|
||||
``LightRAGSidecarFormat-zh.md`` related notes):
|
||||
|
||||
1. ``_manifest.json`` exists, parses, ``version=1.0`` ∧ ``engine=mineru``.
|
||||
2. **Source size fast-path**: ``source_file.stat().st_size`` matches manifest;
|
||||
mismatch → miss without hashing.
|
||||
3. **Source content_hash**: full sha256 of the current source file matches
|
||||
manifest. The size+hash pair is computed by a single-read helper so the
|
||||
stored manifest is internally self-consistent.
|
||||
4. **API mode**: if the manifest recorded ``api_mode`` and it differs from
|
||||
current ``MINERU_API_MODE``, miss.
|
||||
5. **Parser options**: the manifest must record an ``options_signature`` that
|
||||
matches the current effective MinerU request options. Missing signatures
|
||||
from older manifests are treated as stale.
|
||||
6. **Engine version**: if ``MINERU_ENGINE_VERSION`` is set and the manifest
|
||||
recorded a non-empty one, they must match.
|
||||
7. **Endpoint signature**: if the active MinerU endpoint is set and the
|
||||
manifest recorded a non-empty one, they must match.
|
||||
8. **Critical file**: ``content_list.json`` must exist with matching size
|
||||
**and** sha256 — sha256 here is the final tie-breaker against silent
|
||||
corruption affecting the file the adapter depends on.
|
||||
9. **Other files**: size-only verification (cheap; covers most corruption
|
||||
modes for image / middle.json / layout.pdf).
|
||||
|
||||
Any failed step ⇒ cache miss; the caller wipes the directory contents
|
||||
(preserving the directory itself) and re-runs the download.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from lightrag.constants import MINERU_RAW_DIR_SUFFIX, PARSED_DIR_SUFFIX
|
||||
from lightrag.parser.external.mineru.manifest import load_manifest
|
||||
from lightrag.utils import logger
|
||||
|
||||
DEFAULT_MINERU_API_MODE = "local"
|
||||
DEFAULT_MINERU_OFFICIAL_ENDPOINT = "https://mineru.net"
|
||||
DEFAULT_MINERU_MODEL_VERSION = "vlm"
|
||||
DEFAULT_MINERU_LANGUAGE = "ch"
|
||||
DEFAULT_MINERU_LOCAL_BACKEND = "hybrid-auto-engine"
|
||||
DEFAULT_MINERU_LOCAL_PARSE_METHOD = "auto"
|
||||
DEFAULT_MINERU_LOCAL_IMAGE_ANALYSIS = False
|
||||
DEFAULT_MINERU_LOCAL_START_PAGE_ID = 0
|
||||
DEFAULT_MINERU_LOCAL_END_PAGE_ID = 99999
|
||||
DEFAULT_MINERU_ENABLE_TABLE = True
|
||||
DEFAULT_MINERU_ENABLE_FORMULA = True
|
||||
DEFAULT_MINERU_IS_OCR = False
|
||||
|
||||
|
||||
def raw_dir_for_parsed_dir(parsed_dir: Path) -> Path:
|
||||
"""Sibling raw dir for a given ``*.parsed`` dir.
|
||||
|
||||
``foo.parsed/`` → ``foo.mineru_raw/``. Used both at download time and at
|
||||
cache check time so the layout is canonical.
|
||||
"""
|
||||
stem = parsed_dir.name
|
||||
if stem.endswith(PARSED_DIR_SUFFIX):
|
||||
stem = stem[: -len(PARSED_DIR_SUFFIX)]
|
||||
return parsed_dir.parent / f"{stem}{MINERU_RAW_DIR_SUFFIX}"
|
||||
|
||||
|
||||
def clear_dir_contents(directory: Path) -> None:
|
||||
"""Delete everything inside ``directory`` but keep ``directory`` itself."""
|
||||
if not directory.exists():
|
||||
return
|
||||
for entry in directory.iterdir():
|
||||
try:
|
||||
if entry.is_dir() and not entry.is_symlink():
|
||||
_rmtree_safe(entry)
|
||||
else:
|
||||
entry.unlink()
|
||||
except OSError:
|
||||
# Best-effort cleanup; subsequent download will overwrite.
|
||||
continue
|
||||
|
||||
|
||||
def _rmtree_safe(directory: Path) -> None:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(directory, ignore_errors=True)
|
||||
|
||||
|
||||
def compute_size_and_hash(path: Path) -> tuple[int, str]:
|
||||
"""Single-read computation of ``(size_bytes, "sha256:<hex>")``.
|
||||
|
||||
Manifest writes use this so the recorded size and hash are guaranteed to
|
||||
describe the same byte stream; using two ``open()`` calls would risk a
|
||||
TOCTOU mismatch if the file changed in between.
|
||||
"""
|
||||
h = hashlib.sha256()
|
||||
size = 0
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(chunk)
|
||||
size += len(chunk)
|
||||
return size, f"sha256:{h.hexdigest()}"
|
||||
|
||||
|
||||
def _current_api_mode() -> str:
|
||||
mode = _normalize_api_mode(os.getenv("MINERU_API_MODE", DEFAULT_MINERU_API_MODE))
|
||||
return mode
|
||||
|
||||
|
||||
def _normalize_api_mode(mode: str) -> str:
|
||||
mode = str(mode or "").strip().lower()
|
||||
return mode if mode in {"official", "local"} else DEFAULT_MINERU_API_MODE
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
raw = os.getenv(name, "").strip().lower()
|
||||
if raw in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if raw in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
raw = os.getenv(name, "").strip()
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"[mineru_raw] %s=%r is not an integer; using %s", name, raw, default
|
||||
)
|
||||
return default
|
||||
|
||||
|
||||
def _current_endpoint_signature() -> str:
|
||||
mode = _current_api_mode()
|
||||
if mode == "official":
|
||||
return (
|
||||
os.getenv("MINERU_OFFICIAL_ENDPOINT", DEFAULT_MINERU_OFFICIAL_ENDPOINT)
|
||||
.strip()
|
||||
.rstrip("/")
|
||||
)
|
||||
if mode == "local":
|
||||
return os.getenv("MINERU_LOCAL_ENDPOINT", "").strip().rstrip("/")
|
||||
return ""
|
||||
|
||||
|
||||
def local_page_bounds(page_ranges: str) -> tuple[int, int]:
|
||||
raw = page_ranges.strip()
|
||||
if not raw:
|
||||
return DEFAULT_MINERU_LOCAL_START_PAGE_ID, DEFAULT_MINERU_LOCAL_END_PAGE_ID
|
||||
if "," in raw:
|
||||
raise ValueError(
|
||||
"MINERU_PAGE_RANGES with MINERU_API_MODE=local supports only a "
|
||||
"single page or simple range such as '1-10'"
|
||||
)
|
||||
if raw.isdigit():
|
||||
page = max(int(raw), 1)
|
||||
return page - 1, page - 1
|
||||
if "-" in raw:
|
||||
left, _, right = raw.partition("-")
|
||||
if left.isdigit() and right.isdigit():
|
||||
start = max(int(left), 1)
|
||||
end = max(int(right), start)
|
||||
return start - 1, end - 1
|
||||
raise ValueError(
|
||||
"MINERU_PAGE_RANGES with MINERU_API_MODE=local must be a single "
|
||||
"positive page number or simple range such as '1-10'"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MinerUParserOptions:
|
||||
"""Effective MinerU parser options used both for live requests and the
|
||||
cache signature.
|
||||
|
||||
Constructed once via :meth:`from_env` so the client and the cache
|
||||
validator agree on every defaulting / normalization rule.
|
||||
"""
|
||||
|
||||
api_mode: str
|
||||
model_version: str
|
||||
language: str
|
||||
enable_table: bool
|
||||
enable_formula: bool
|
||||
is_ocr: bool
|
||||
page_ranges: str
|
||||
local_backend: str
|
||||
local_parse_method: str
|
||||
local_image_analysis: bool
|
||||
local_start_page_id: int
|
||||
local_end_page_id: int
|
||||
|
||||
@classmethod
|
||||
def from_env(
|
||||
cls,
|
||||
*,
|
||||
api_mode: str | None = None,
|
||||
overrides: "Mapping[str, Any] | None" = None,
|
||||
) -> "MinerUParserOptions":
|
||||
"""Build the effective options from env, with optional per-file overrides.
|
||||
|
||||
``overrides`` carries decoded per-file engine params (``page_range`` →
|
||||
``page_ranges``, ``language``, ``local_parse_method``). They feed both
|
||||
the live request and the cache signature, so an overridden document gets
|
||||
its own bundle.
|
||||
"""
|
||||
overrides = overrides or {}
|
||||
mode = (
|
||||
_normalize_api_mode(api_mode)
|
||||
if api_mode is not None
|
||||
else _current_api_mode()
|
||||
)
|
||||
page_ranges = str(
|
||||
overrides.get("page_range", os.getenv("MINERU_PAGE_RANGES", ""))
|
||||
).strip()
|
||||
language = (
|
||||
str(
|
||||
overrides.get(
|
||||
"language", os.getenv("MINERU_LANGUAGE", DEFAULT_MINERU_LANGUAGE)
|
||||
)
|
||||
).strip()
|
||||
or DEFAULT_MINERU_LANGUAGE
|
||||
)
|
||||
local_parse_method = (
|
||||
str(
|
||||
overrides.get(
|
||||
"local_parse_method",
|
||||
os.getenv(
|
||||
"MINERU_LOCAL_PARSE_METHOD", DEFAULT_MINERU_LOCAL_PARSE_METHOD
|
||||
),
|
||||
)
|
||||
).strip()
|
||||
or DEFAULT_MINERU_LOCAL_PARSE_METHOD
|
||||
)
|
||||
local_start = _env_int(
|
||||
"MINERU_LOCAL_START_PAGE_ID", DEFAULT_MINERU_LOCAL_START_PAGE_ID
|
||||
)
|
||||
local_end = _env_int(
|
||||
"MINERU_LOCAL_END_PAGE_ID", DEFAULT_MINERU_LOCAL_END_PAGE_ID
|
||||
)
|
||||
if mode == "local" and page_ranges:
|
||||
local_start, local_end = local_page_bounds(page_ranges)
|
||||
return cls(
|
||||
api_mode=mode,
|
||||
model_version=(
|
||||
os.getenv("MINERU_MODEL_VERSION", DEFAULT_MINERU_MODEL_VERSION).strip()
|
||||
or DEFAULT_MINERU_MODEL_VERSION
|
||||
),
|
||||
language=language,
|
||||
enable_table=_env_bool("MINERU_ENABLE_TABLE", DEFAULT_MINERU_ENABLE_TABLE),
|
||||
enable_formula=_env_bool(
|
||||
"MINERU_ENABLE_FORMULA", DEFAULT_MINERU_ENABLE_FORMULA
|
||||
),
|
||||
is_ocr=_env_bool("MINERU_IS_OCR", DEFAULT_MINERU_IS_OCR),
|
||||
page_ranges=page_ranges,
|
||||
local_backend=(
|
||||
os.getenv("MINERU_LOCAL_BACKEND", DEFAULT_MINERU_LOCAL_BACKEND).strip()
|
||||
or DEFAULT_MINERU_LOCAL_BACKEND
|
||||
),
|
||||
local_parse_method=local_parse_method,
|
||||
local_image_analysis=_env_bool(
|
||||
"MINERU_LOCAL_IMAGE_ANALYSIS", DEFAULT_MINERU_LOCAL_IMAGE_ANALYSIS
|
||||
),
|
||||
local_start_page_id=local_start,
|
||||
local_end_page_id=local_end,
|
||||
)
|
||||
|
||||
def signature(self) -> str:
|
||||
return mineru_options_signature(**asdict(self))
|
||||
|
||||
|
||||
def mineru_options_signature(
|
||||
*,
|
||||
api_mode: str,
|
||||
model_version: str = DEFAULT_MINERU_MODEL_VERSION,
|
||||
language: str = DEFAULT_MINERU_LANGUAGE,
|
||||
enable_table: bool = DEFAULT_MINERU_ENABLE_TABLE,
|
||||
enable_formula: bool = DEFAULT_MINERU_ENABLE_FORMULA,
|
||||
is_ocr: bool = DEFAULT_MINERU_IS_OCR,
|
||||
page_ranges: str = "",
|
||||
local_backend: str = DEFAULT_MINERU_LOCAL_BACKEND,
|
||||
local_parse_method: str = DEFAULT_MINERU_LOCAL_PARSE_METHOD,
|
||||
local_image_analysis: bool = DEFAULT_MINERU_LOCAL_IMAGE_ANALYSIS,
|
||||
local_start_page_id: int = DEFAULT_MINERU_LOCAL_START_PAGE_ID,
|
||||
local_end_page_id: int = DEFAULT_MINERU_LOCAL_END_PAGE_ID,
|
||||
) -> str:
|
||||
mode = _normalize_api_mode(api_mode)
|
||||
payload: dict[str, Any] = {
|
||||
"signature_version": 1,
|
||||
"api_mode": mode,
|
||||
"language": str(language or "").strip() or DEFAULT_MINERU_LANGUAGE,
|
||||
"enable_table": bool(enable_table),
|
||||
"enable_formula": bool(enable_formula),
|
||||
}
|
||||
if mode == "official":
|
||||
payload.update(
|
||||
{
|
||||
"model_version": str(model_version or "").strip()
|
||||
or DEFAULT_MINERU_MODEL_VERSION,
|
||||
"is_ocr": bool(is_ocr),
|
||||
"page_ranges": str(page_ranges or "").strip(),
|
||||
}
|
||||
)
|
||||
else:
|
||||
payload.update(
|
||||
{
|
||||
"local_backend": str(local_backend or "").strip()
|
||||
or DEFAULT_MINERU_LOCAL_BACKEND,
|
||||
"local_parse_method": str(local_parse_method or "").strip()
|
||||
or DEFAULT_MINERU_LOCAL_PARSE_METHOD,
|
||||
"local_image_analysis": bool(local_image_analysis),
|
||||
"local_start_page_id": int(local_start_page_id),
|
||||
"local_end_page_id": int(local_end_page_id),
|
||||
}
|
||||
)
|
||||
|
||||
raw = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
return "sha256:" + hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def current_mineru_options_signature(
|
||||
overrides: "Mapping[str, Any] | None" = None,
|
||||
) -> str:
|
||||
return MinerUParserOptions.from_env(overrides=overrides).signature()
|
||||
|
||||
|
||||
def is_bundle_valid(
|
||||
raw_dir: Path,
|
||||
source_file: Path,
|
||||
*,
|
||||
overrides: "Mapping[str, Any] | None" = None,
|
||||
) -> bool:
|
||||
"""Return True iff the bundle is intact and matches the current source.
|
||||
|
||||
See module docstring for the full policy. Returns False on any of:
|
||||
missing manifest, malformed manifest, schema version mismatch, source
|
||||
size/hash mismatch, parser options mismatch, engine/endpoint env mismatch,
|
||||
critical file missing or corrupted, or any non-critical file size mismatch.
|
||||
"""
|
||||
if not raw_dir.is_dir():
|
||||
return False
|
||||
|
||||
manifest = load_manifest(raw_dir)
|
||||
if manifest is None:
|
||||
return False
|
||||
|
||||
# 1. Source size fast-path
|
||||
try:
|
||||
cur_size = source_file.stat().st_size
|
||||
except OSError:
|
||||
return False
|
||||
if cur_size != int(manifest.source_size_bytes):
|
||||
return False
|
||||
|
||||
# 2. Source content_hash
|
||||
_, cur_hash = compute_size_and_hash(source_file)
|
||||
if cur_hash != manifest.source_content_hash:
|
||||
return False
|
||||
|
||||
# 3. API mode (only when manifest had one; old manifests remain compatible)
|
||||
cur_api_mode = _current_api_mode()
|
||||
if manifest.api_mode and cur_api_mode != manifest.api_mode:
|
||||
return False
|
||||
|
||||
# 4. Parser options. Old manifests did not record this and must miss so
|
||||
# changes such as MINERU_LOCAL_BACKEND cannot silently reuse stale output.
|
||||
if not manifest.options_signature:
|
||||
return False
|
||||
if current_mineru_options_signature(overrides) != manifest.options_signature:
|
||||
return False
|
||||
|
||||
# 5. Engine version (only when current env exposes one AND manifest had one)
|
||||
cur_engine_version = os.getenv("MINERU_ENGINE_VERSION", "").strip()
|
||||
if (
|
||||
cur_engine_version
|
||||
and manifest.engine_version
|
||||
and cur_engine_version != manifest.engine_version
|
||||
):
|
||||
return False
|
||||
|
||||
# 6. Endpoint signature
|
||||
cur_endpoint = _current_endpoint_signature()
|
||||
if (
|
||||
cur_endpoint
|
||||
and manifest.endpoint_signature
|
||||
and cur_endpoint != manifest.endpoint_signature
|
||||
):
|
||||
return False
|
||||
|
||||
# 7. Critical file: size + sha256
|
||||
crit = manifest.critical_file
|
||||
crit_path = raw_dir / crit.path
|
||||
try:
|
||||
if crit_path.stat().st_size != int(crit.size):
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
if crit.sha256:
|
||||
_, crit_actual = compute_size_and_hash(crit_path)
|
||||
if crit_actual != crit.sha256:
|
||||
return False
|
||||
|
||||
# 8. Other files: size only
|
||||
for entry in manifest.files:
|
||||
ep = raw_dir / entry.path
|
||||
try:
|
||||
if ep.stat().st_size != int(entry.size):
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MINERU_RAW_DIR_SUFFIX",
|
||||
"MinerUParserOptions",
|
||||
"clear_dir_contents",
|
||||
"compute_size_and_hash",
|
||||
"current_mineru_options_signature",
|
||||
"is_bundle_valid",
|
||||
"local_page_bounds",
|
||||
"mineru_options_signature",
|
||||
"raw_dir_for_parsed_dir",
|
||||
]
|
||||
+702
@@ -0,0 +1,702 @@
|
||||
"""MinerU raw bundle downloader.
|
||||
|
||||
Supports MinerU's official cloud and self-hosted API protocols and lands the
|
||||
final parser bundle on disk under ``raw_dir/``:
|
||||
|
||||
- ``official`` — MinerU precision API v4: apply for signed upload URL, PUT the
|
||||
local file, poll batch results, download ``full_zip_url``.
|
||||
- ``local`` — self-hosted ``mineru-api`` / ``mineru-router``: submit
|
||||
``POST /tasks``, poll ``GET /tasks/{task_id}``, download
|
||||
``GET /tasks/{task_id}/result``.
|
||||
|
||||
Both protocols request a zip result bundle. Archives are extracted under
|
||||
``raw_dir/`` and normalized so the adapter can read a root-level
|
||||
``content_list.json``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
from lightrag.parser.external._common import raise_for_status_with_detail
|
||||
from lightrag.parser.external.mineru.cache import (
|
||||
MinerUParserOptions,
|
||||
compute_size_and_hash,
|
||||
)
|
||||
from lightrag.parser.external.mineru.manifest import (
|
||||
Manifest,
|
||||
ManifestFile,
|
||||
write_manifest,
|
||||
)
|
||||
from lightrag.utils import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import httpx
|
||||
else:
|
||||
try:
|
||||
import httpx
|
||||
except ImportError: # pragma: no cover
|
||||
httpx = None
|
||||
|
||||
CONTENT_LIST_FILENAME = "content_list.json"
|
||||
DEFAULT_MINERU_API_MODE = "local"
|
||||
DEFAULT_MINERU_OFFICIAL_ENDPOINT = "https://mineru.net"
|
||||
VALID_MINERU_API_MODES = {"official", "local"}
|
||||
OFFICIAL_DONE_STATES = {"done"}
|
||||
OFFICIAL_FAILED_STATES = {"failed"}
|
||||
LOCAL_DONE_STATES = {"completed"}
|
||||
LOCAL_FAILED_STATES = {"failed"}
|
||||
UPLOAD_CHUNK_SIZE = 1024 * 1024
|
||||
|
||||
|
||||
def _get_by_path(payload: Any, path: str) -> Any:
|
||||
"""Walk a dotted path through a nested dict; returns None if any segment
|
||||
is missing or non-dict."""
|
||||
if not path:
|
||||
return None
|
||||
cur = payload
|
||||
for part in path.split("."):
|
||||
if isinstance(cur, dict) and part in cur:
|
||||
cur = cur[part]
|
||||
else:
|
||||
return None
|
||||
return cur
|
||||
|
||||
|
||||
def _strip_trailing_slash(url: str) -> str:
|
||||
return url.rstrip("/")
|
||||
|
||||
|
||||
def _resolve_upload_name(upload_name: str | None, source_file_path: Path) -> str:
|
||||
candidate = Path(str(upload_name or "")).name
|
||||
return candidate or source_file_path.name
|
||||
|
||||
|
||||
async def _iter_file_bytes(path: Path) -> AsyncIterator[bytes]:
|
||||
with path.open("rb") as fh:
|
||||
while True:
|
||||
chunk = await asyncio.to_thread(fh.read, UPLOAD_CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
|
||||
def _validate_base_url(
|
||||
name: str, endpoint: str, forbidden_segments: tuple[str, ...]
|
||||
) -> None:
|
||||
parsed = urlparse(endpoint)
|
||||
path = (parsed.path or "").rstrip("/")
|
||||
for segment in forbidden_segments:
|
||||
if path.endswith(segment) or f"{segment}/" in path:
|
||||
raise ValueError(
|
||||
f"{name} must be a base URL, not an API path: {endpoint!r}"
|
||||
)
|
||||
|
||||
|
||||
class MinerURawClient:
|
||||
"""Downloads MinerU bundles into ``raw_dir``.
|
||||
|
||||
Construct once per call (cheap). Reads ``MINERU_*`` env vars at
|
||||
construction time. Methods are async and use a single shared httpx
|
||||
client across all calls in :meth:`download_into`.
|
||||
|
||||
Implements the MinerU-specific upload + poll + zip download flow
|
||||
inline; bundle handling needs the ``result_url`` *and* the
|
||||
``Content-Type`` of the response, which a generic protocol helper
|
||||
cannot expose without leaking abstractions.
|
||||
"""
|
||||
|
||||
def __init__(self, *, overrides: "Mapping[str, Any] | None" = None) -> None:
|
||||
self._overrides = overrides or {}
|
||||
self.api_mode = (
|
||||
os.getenv("MINERU_API_MODE", DEFAULT_MINERU_API_MODE).strip().lower()
|
||||
)
|
||||
if self.api_mode not in VALID_MINERU_API_MODES:
|
||||
allowed = ", ".join(sorted(VALID_MINERU_API_MODES))
|
||||
raise ValueError(
|
||||
f"MINERU_API_MODE must be one of {allowed}, got {self.api_mode!r}"
|
||||
)
|
||||
|
||||
self.official_endpoint = _strip_trailing_slash(
|
||||
os.getenv(
|
||||
"MINERU_OFFICIAL_ENDPOINT", DEFAULT_MINERU_OFFICIAL_ENDPOINT
|
||||
).strip()
|
||||
or DEFAULT_MINERU_OFFICIAL_ENDPOINT
|
||||
)
|
||||
self.local_endpoint = _strip_trailing_slash(
|
||||
os.getenv("MINERU_LOCAL_ENDPOINT", "").strip()
|
||||
)
|
||||
self.api_token = os.getenv("MINERU_API_TOKEN", "").strip()
|
||||
if self.api_mode == "official":
|
||||
if not self.api_token:
|
||||
raise ValueError(
|
||||
"MINERU_API_TOKEN is required when MINERU_API_MODE=official"
|
||||
)
|
||||
_validate_base_url(
|
||||
"MINERU_OFFICIAL_ENDPOINT",
|
||||
self.official_endpoint,
|
||||
("/api/v4", "/api/v4/file-urls/batch", "/api/v4/extract/task"),
|
||||
)
|
||||
self.endpoint = self.official_endpoint
|
||||
elif self.api_mode == "local":
|
||||
if not self.local_endpoint:
|
||||
raise ValueError(
|
||||
"MINERU_LOCAL_ENDPOINT is required when MINERU_API_MODE=local"
|
||||
)
|
||||
_validate_base_url(
|
||||
"MINERU_LOCAL_ENDPOINT",
|
||||
self.local_endpoint,
|
||||
("/tasks", "/file_parse", "/health"),
|
||||
)
|
||||
self.endpoint = self.local_endpoint
|
||||
self.poll_interval = float(os.getenv("MINERU_POLL_INTERVAL_SECONDS", "2"))
|
||||
# 600 * 2s client-side sleep ≈ 20 min worst case; raise for very large PDFs.
|
||||
self.max_polls = int(os.getenv("MINERU_MAX_POLLS", "600"))
|
||||
self.engine_version = os.getenv("MINERU_ENGINE_VERSION", "").strip()
|
||||
|
||||
options = MinerUParserOptions.from_env(
|
||||
api_mode=self.api_mode, overrides=self._overrides
|
||||
)
|
||||
self._parser_options = options
|
||||
self.model_version = options.model_version
|
||||
self.language = options.language
|
||||
self.enable_table = options.enable_table
|
||||
self.enable_formula = options.enable_formula
|
||||
self.is_ocr = options.is_ocr
|
||||
self.page_ranges = options.page_ranges
|
||||
self.local_backend = options.local_backend
|
||||
self.local_parse_method = options.local_parse_method
|
||||
self.local_image_analysis = options.local_image_analysis
|
||||
self.local_start_page_id = options.local_start_page_id
|
||||
self.local_end_page_id = options.local_end_page_id
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def download_into(
|
||||
self,
|
||||
raw_dir: Path,
|
||||
source_file_path: Path,
|
||||
*,
|
||||
upload_name: str | None = None,
|
||||
) -> Manifest:
|
||||
"""Download a fresh bundle and write the manifest.
|
||||
|
||||
Pre-condition: caller cleared ``raw_dir`` contents (recommended via
|
||||
:func:`clear_dir_contents`). This method does NOT clean the
|
||||
directory itself — leaving that to the caller keeps cache miss
|
||||
semantics explicit at the parse_mineru entry point.
|
||||
|
||||
Returns the :class:`Manifest` describing the bundle.
|
||||
"""
|
||||
if httpx is None:
|
||||
raise RuntimeError("httpx is required for MinerU parsing but not installed")
|
||||
raw_dir.mkdir(parents=True, exist_ok=True)
|
||||
resolved_upload_name = _resolve_upload_name(upload_name, source_file_path)
|
||||
|
||||
timeout = httpx.Timeout(120.0, connect=30.0)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
if self.api_mode == "official":
|
||||
task_id = await self._download_official(
|
||||
client, source_file_path, raw_dir, resolved_upload_name
|
||||
)
|
||||
else:
|
||||
task_id = await self._download_local(
|
||||
client, source_file_path, raw_dir, resolved_upload_name
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
# Transport-level failures (connection refused/reset, server
|
||||
# disconnect, read/connect timeout) bubble up from httpx with an
|
||||
# opaque, sometimes empty message like "All connection attempts
|
||||
# failed" that gives no hint the parse engine was MinerU. HTTP
|
||||
# status errors and protocol errors already raise context-rich
|
||||
# RuntimeErrors via raise_for_status_with_detail, so they stay
|
||||
# untouched. Re-raise with the engine + endpoint and the exception
|
||||
# class name so the doc_status error_msg is always non-empty and
|
||||
# clearly attributable to the MinerU backend.
|
||||
raise RuntimeError(
|
||||
f"MinerU {self.api_mode} backend request failed "
|
||||
f"(endpoint={self.endpoint}): {type(exc).__name__}: {exc}"
|
||||
) from exc
|
||||
|
||||
self._normalize_raw_bundle(raw_dir, source_file_path, resolved_upload_name)
|
||||
return self._build_and_write_manifest(
|
||||
raw_dir, source_file_path, task_id, resolved_upload_name
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Upload + poll
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _official_headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_token}",
|
||||
}
|
||||
|
||||
def _official_payload(self, upload_name: str) -> dict[str, Any]:
|
||||
file_entry: dict[str, Any] = {"name": upload_name}
|
||||
if self.is_ocr:
|
||||
file_entry["is_ocr"] = True
|
||||
if self.page_ranges:
|
||||
file_entry["page_ranges"] = self.page_ranges
|
||||
return {
|
||||
"files": [file_entry],
|
||||
"model_version": self.model_version,
|
||||
"language": self.language,
|
||||
"enable_table": self.enable_table,
|
||||
"enable_formula": self.enable_formula,
|
||||
}
|
||||
|
||||
async def _download_official(
|
||||
self,
|
||||
client: "httpx.AsyncClient",
|
||||
source_file_path: Path,
|
||||
raw_dir: Path,
|
||||
upload_name: str,
|
||||
) -> str:
|
||||
apply_url = f"{self.official_endpoint}/api/v4/file-urls/batch"
|
||||
resp = await client.post(
|
||||
apply_url,
|
||||
headers=self._official_headers(),
|
||||
json=self._official_payload(upload_name),
|
||||
)
|
||||
raise_for_status_with_detail(resp, "MinerU official upload URL request")
|
||||
payload = resp.json() if resp.text else {}
|
||||
self._raise_if_official_error(payload, "MinerU official upload URL request")
|
||||
data = payload.get("data") if isinstance(payload, dict) else {}
|
||||
batch_id = str((data or {}).get("batch_id") or "")
|
||||
file_urls = (data or {}).get("file_urls") or []
|
||||
if not batch_id or not isinstance(file_urls, list) or not file_urls:
|
||||
raise RuntimeError(
|
||||
f"MinerU official upload URL response missing batch_id/file_urls: "
|
||||
f"{payload}"
|
||||
)
|
||||
|
||||
first_file_url = file_urls[0]
|
||||
if isinstance(first_file_url, dict):
|
||||
upload_url = str(
|
||||
first_file_url.get("url") or first_file_url.get("file_url") or ""
|
||||
)
|
||||
else:
|
||||
upload_url = str(first_file_url)
|
||||
if not upload_url:
|
||||
raise RuntimeError(
|
||||
f"MinerU official upload URL response had an empty upload URL: "
|
||||
f"{payload}"
|
||||
)
|
||||
upload_resp = await client.put(
|
||||
upload_url,
|
||||
content=_iter_file_bytes(source_file_path),
|
||||
headers={"Content-Length": str(source_file_path.stat().st_size)},
|
||||
)
|
||||
raise_for_status_with_detail(upload_resp, "MinerU official file upload")
|
||||
|
||||
result_url = await self._poll_official_batch(client, batch_id, upload_name)
|
||||
await self._download_zip(client, result_url, raw_dir)
|
||||
return batch_id
|
||||
|
||||
async def _poll_official_batch(
|
||||
self,
|
||||
client: "httpx.AsyncClient",
|
||||
batch_id: str,
|
||||
upload_name: str,
|
||||
) -> str:
|
||||
encoded_batch_id = quote(batch_id, safe="")
|
||||
poll_url = (
|
||||
f"{self.official_endpoint}/api/v4/extract-results/batch/{encoded_batch_id}"
|
||||
)
|
||||
for _ in range(self.max_polls):
|
||||
await asyncio.sleep(self.poll_interval)
|
||||
resp = await client.get(poll_url, headers=self._official_headers())
|
||||
raise_for_status_with_detail(resp, "MinerU official batch poll")
|
||||
payload = resp.json() if resp.text else {}
|
||||
self._raise_if_official_error(payload, "MinerU official batch poll")
|
||||
results = _get_by_path(payload, "data.extract_result")
|
||||
if isinstance(results, dict):
|
||||
results = [results]
|
||||
if not isinstance(results, list):
|
||||
continue
|
||||
|
||||
selected = _select_official_extract_result(results, upload_name)
|
||||
if selected is None:
|
||||
continue
|
||||
state = str(selected.get("state") or "").lower()
|
||||
if state in OFFICIAL_DONE_STATES:
|
||||
full_zip_url = str(selected.get("full_zip_url") or "")
|
||||
if not full_zip_url:
|
||||
raise RuntimeError(
|
||||
f"MinerU official batch {batch_id} is done but has no "
|
||||
f"full_zip_url: {selected}"
|
||||
)
|
||||
return full_zip_url
|
||||
if state in OFFICIAL_FAILED_STATES:
|
||||
err = selected.get("err_msg") or selected.get("error") or selected
|
||||
raise RuntimeError(
|
||||
f"MinerU official parse failed for batch {batch_id}: {err}"
|
||||
)
|
||||
|
||||
raise TimeoutError(f"MinerU official batch polling timeout: {batch_id}")
|
||||
|
||||
def _raise_if_official_error(self, payload: Any, operation: str) -> None:
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError(f"{operation} returned non-object payload: {payload!r}")
|
||||
code = payload.get("code", 0)
|
||||
if code not in (0, "0", None):
|
||||
raise RuntimeError(
|
||||
f"{operation} failed: code={code} msg={payload.get('msg')!r}"
|
||||
)
|
||||
|
||||
def _local_form_data(self) -> dict[str, str]:
|
||||
return {
|
||||
"lang_list": self.language,
|
||||
"backend": self.local_backend,
|
||||
"parse_method": self.local_parse_method,
|
||||
"formula_enable": _bool_form(self.enable_formula),
|
||||
"table_enable": _bool_form(self.enable_table),
|
||||
"image_analysis": _bool_form(self.local_image_analysis),
|
||||
"return_md": "true",
|
||||
"return_middle_json": "true",
|
||||
"return_model_output": "true",
|
||||
"return_content_list": "true",
|
||||
"return_images": "true",
|
||||
"response_format_zip": "true",
|
||||
"return_original_file": "true",
|
||||
"start_page_id": str(self.local_start_page_id),
|
||||
"end_page_id": str(self.local_end_page_id),
|
||||
}
|
||||
|
||||
async def _download_local(
|
||||
self,
|
||||
client: "httpx.AsyncClient",
|
||||
source_file_path: Path,
|
||||
raw_dir: Path,
|
||||
upload_name: str,
|
||||
) -> str:
|
||||
submit_url = f"{self.local_endpoint}/tasks"
|
||||
# Keep data as a Mapping so httpx 0.28 builds an async MultipartStream
|
||||
# and reads the file handle in chunks instead of buffering the payload.
|
||||
with source_file_path.open("rb") as fh:
|
||||
files = {"files": (upload_name, fh, "application/octet-stream")}
|
||||
resp = await client.post(
|
||||
submit_url,
|
||||
data=self._local_form_data(),
|
||||
files=files,
|
||||
)
|
||||
raise_for_status_with_detail(
|
||||
resp,
|
||||
f"MinerU local task submission for {upload_name!r}",
|
||||
)
|
||||
payload = resp.json() if resp.text else {}
|
||||
task_id = str(payload.get("task_id") or "")
|
||||
if not task_id:
|
||||
raise RuntimeError(
|
||||
f"MinerU local /tasks response missing task_id: {payload}"
|
||||
)
|
||||
|
||||
await self._poll_local_task(client, task_id)
|
||||
await self._download_zip(
|
||||
client,
|
||||
f"{self.local_endpoint}/tasks/{quote(task_id, safe='')}/result",
|
||||
raw_dir,
|
||||
)
|
||||
return task_id
|
||||
|
||||
async def _poll_local_task(
|
||||
self,
|
||||
client: "httpx.AsyncClient",
|
||||
task_id: str,
|
||||
) -> None:
|
||||
# ``task_id`` is service-returned; encode it as a single path segment so
|
||||
# a crafted value can't break out of ``/tasks/{id}``. The raw value is
|
||||
# kept for the error/timeout messages below where the real ID matters.
|
||||
poll_url = f"{self.local_endpoint}/tasks/{quote(task_id, safe='')}"
|
||||
for _ in range(self.max_polls):
|
||||
await asyncio.sleep(self.poll_interval)
|
||||
resp = await client.get(poll_url)
|
||||
raise_for_status_with_detail(resp, "MinerU local task poll")
|
||||
payload = resp.json() if resp.text else {}
|
||||
status = str(payload.get("status") or "").lower()
|
||||
if status in LOCAL_DONE_STATES:
|
||||
return
|
||||
if status in LOCAL_FAILED_STATES:
|
||||
err = payload.get("error") or payload.get("message") or payload
|
||||
raise RuntimeError(
|
||||
f"MinerU local parse failed for task {task_id}: {err}"
|
||||
)
|
||||
|
||||
raise TimeoutError(f"MinerU local task polling timeout: {task_id}")
|
||||
|
||||
async def _download_zip(
|
||||
self,
|
||||
client: "httpx.AsyncClient",
|
||||
result_url: str,
|
||||
raw_dir: Path,
|
||||
resp: Any = None,
|
||||
) -> None:
|
||||
"""Download (or re-use already-fetched response) and extract."""
|
||||
if resp is None or not hasattr(resp, "content"):
|
||||
resp = await client.get(result_url)
|
||||
raise_for_status_with_detail(resp, "MinerU result bundle download")
|
||||
buf = io.BytesIO(resp.content)
|
||||
with zipfile.ZipFile(buf) as zf:
|
||||
# Safe-extract: refuse absolute paths and ``..`` traversal.
|
||||
for name in zf.namelist():
|
||||
norm = os.path.normpath(name)
|
||||
if norm.startswith("..") or os.path.isabs(norm):
|
||||
raise RuntimeError(f"Refusing zip entry with unsafe path: {name!r}")
|
||||
zf.extractall(raw_dir)
|
||||
|
||||
# Normalize: if the zip nested everything under a single top-level
|
||||
# dir, hoist its contents up so content_list.json sits at raw_dir
|
||||
# root. This matches the common MinerU bundle layout.
|
||||
self._maybe_hoist_single_subdir(raw_dir)
|
||||
|
||||
def _maybe_hoist_single_subdir(self, raw_dir: Path) -> None:
|
||||
entries = [p for p in raw_dir.iterdir() if p.name != "_manifest.json"]
|
||||
if len(entries) != 1 or not entries[0].is_dir():
|
||||
return
|
||||
sub = entries[0]
|
||||
for child in list(sub.iterdir()):
|
||||
child.rename(raw_dir / child.name)
|
||||
try:
|
||||
sub.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _normalize_raw_bundle(
|
||||
self,
|
||||
raw_dir: Path,
|
||||
source_file_path: Path,
|
||||
upload_name: str | None = None,
|
||||
) -> None:
|
||||
"""Ensure a downloaded bundle has root-level ``content_list.json``.
|
||||
|
||||
Official and local MinerU zip archives commonly place parser outputs at
|
||||
``<doc>/<parse_method>/<doc>_content_list.json``. The adapter consumes a
|
||||
canonical root ``content_list.json`` plus optional root ``images/``.
|
||||
|
||||
After hoisting we delete the nested originals so the manifest does not
|
||||
bookkeep two copies (and disk usage doesn't double for big bundles).
|
||||
Sibling artifacts of the parse subdir (``*.md``, ``middle.json`` etc.)
|
||||
are also hoisted to ``raw_dir`` root for easier diagnostics.
|
||||
"""
|
||||
if (raw_dir / CONTENT_LIST_FILENAME).is_file():
|
||||
return
|
||||
|
||||
candidate = _select_content_list_candidate(
|
||||
raw_dir, source_file_path, upload_name
|
||||
)
|
||||
if candidate is None:
|
||||
return
|
||||
|
||||
source_dir = candidate.parent
|
||||
target_root = raw_dir.resolve()
|
||||
# Guard: never hoist from above raw_dir (defensive — candidate already
|
||||
# comes from rglob inside raw_dir, but cheap to verify).
|
||||
try:
|
||||
source_dir.resolve().relative_to(target_root)
|
||||
except ValueError:
|
||||
shutil.copy2(candidate, raw_dir / CONTENT_LIST_FILENAME)
|
||||
return
|
||||
|
||||
# Move the critical file first; then hoist sibling files/dirs that
|
||||
# don't already exist at raw_dir root.
|
||||
shutil.move(str(candidate), str(raw_dir / CONTENT_LIST_FILENAME))
|
||||
for entry in list(source_dir.iterdir()):
|
||||
target = raw_dir / entry.name
|
||||
if target.exists():
|
||||
continue
|
||||
shutil.move(str(entry), str(target))
|
||||
|
||||
# Best-effort cleanup of the now-empty parse subtree.
|
||||
cursor = source_dir
|
||||
while cursor != raw_dir and cursor.is_dir():
|
||||
try:
|
||||
cursor.rmdir()
|
||||
except OSError:
|
||||
break
|
||||
cursor = cursor.parent
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Manifest construction
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_and_write_manifest(
|
||||
self,
|
||||
raw_dir: Path,
|
||||
source_file_path: Path,
|
||||
task_id: str,
|
||||
upload_name: str,
|
||||
) -> Manifest:
|
||||
source_size, source_hash = compute_size_and_hash(source_file_path)
|
||||
|
||||
# Critical file — required.
|
||||
crit_path = raw_dir / CONTENT_LIST_FILENAME
|
||||
if not crit_path.is_file():
|
||||
raise RuntimeError(
|
||||
f"MinerU bundle missing required {CONTENT_LIST_FILENAME} "
|
||||
f"after download (raw_dir={raw_dir})"
|
||||
)
|
||||
crit_size, crit_hash = compute_size_and_hash(crit_path)
|
||||
|
||||
# Other files.
|
||||
others: list[ManifestFile] = []
|
||||
total = crit_size
|
||||
for p in sorted(raw_dir.rglob("*")):
|
||||
if not p.is_file():
|
||||
continue
|
||||
if p.name == "_manifest.json":
|
||||
continue
|
||||
rel = p.relative_to(raw_dir).as_posix()
|
||||
if rel == CONTENT_LIST_FILENAME:
|
||||
continue
|
||||
size = p.stat().st_size
|
||||
others.append(ManifestFile(path=rel, size=size))
|
||||
total += size
|
||||
|
||||
manifest = Manifest(
|
||||
source_content_hash=source_hash,
|
||||
source_size_bytes=source_size,
|
||||
source_filename_at_parse=upload_name,
|
||||
critical_file=ManifestFile(
|
||||
path=CONTENT_LIST_FILENAME,
|
||||
size=crit_size,
|
||||
sha256=crit_hash,
|
||||
),
|
||||
files=others,
|
||||
total_size_bytes=total,
|
||||
task_id=task_id,
|
||||
api_mode=self.api_mode,
|
||||
engine_version=self.engine_version,
|
||||
endpoint_signature=self.endpoint,
|
||||
options_signature=self._options_signature(),
|
||||
downloaded_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
write_manifest(raw_dir, manifest)
|
||||
return manifest
|
||||
|
||||
def _options_signature(self) -> str:
|
||||
return self._parser_options.signature()
|
||||
|
||||
|
||||
def _find_content_list(payload: Any, content_field: str) -> list[dict] | None:
|
||||
"""Heuristic content_list extractor.
|
||||
|
||||
Tries (in order):
|
||||
|
||||
1. The provided dotted path if it lands on a list of dicts.
|
||||
2. Direct ``content_list`` / ``content`` / ``items`` / ``result`` keys.
|
||||
3. Recursive descent.
|
||||
"""
|
||||
if isinstance(payload, list):
|
||||
if payload and all(isinstance(x, dict) for x in payload):
|
||||
return payload
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
|
||||
via_field = _get_by_path(payload, content_field)
|
||||
candidate = _find_content_list(via_field, content_field)
|
||||
if candidate is not None:
|
||||
return candidate
|
||||
|
||||
for key in ("content_list", "content", "items", "result"):
|
||||
value = payload.get(key)
|
||||
candidate = _find_content_list(value, content_field)
|
||||
if candidate is not None:
|
||||
return candidate
|
||||
|
||||
for value in payload.values():
|
||||
candidate = _find_content_list(value, content_field)
|
||||
if candidate is not None:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _bool_form(value: bool) -> str:
|
||||
return "true" if value else "false"
|
||||
|
||||
|
||||
def _select_official_extract_result(
|
||||
results: list[Any],
|
||||
source_filename: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Pick the extract_result entry that matches the file we uploaded.
|
||||
|
||||
Invariant: :meth:`MinerURawClient._download_official` always submits a
|
||||
single-file batch, so a non-matching ``file_name`` from the API would
|
||||
indicate either a server response we don't understand or a future
|
||||
multi-file extension. We fall back to ``dict_results[0]`` to remain
|
||||
forward-compatible but log a warning so the mismatch is visible.
|
||||
"""
|
||||
dict_results = [item for item in results if isinstance(item, dict)]
|
||||
if not dict_results:
|
||||
return None
|
||||
source_name = Path(source_filename).name
|
||||
source_stem = Path(source_filename).stem
|
||||
for item in dict_results:
|
||||
file_name = str(item.get("file_name") or item.get("name") or "")
|
||||
if Path(file_name).name == source_name or Path(file_name).stem == source_stem:
|
||||
return item
|
||||
logger.warning(
|
||||
"[mineru_raw] official extract_result did not contain a match for "
|
||||
"%r; falling back to the first entry (%r). This is unexpected for "
|
||||
"a single-file batch.",
|
||||
source_name,
|
||||
str(dict_results[0].get("file_name") or dict_results[0].get("name") or ""),
|
||||
)
|
||||
return dict_results[0]
|
||||
|
||||
|
||||
def _select_content_list_candidate(
|
||||
raw_dir: Path,
|
||||
source_file_path: Path,
|
||||
upload_name: str | None = None,
|
||||
) -> Path | None:
|
||||
source_stem = Path(upload_name or source_file_path.name).stem
|
||||
candidates: list[tuple[int, int, str, Path]] = []
|
||||
for path in raw_dir.rglob("*.json"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if path.name != CONTENT_LIST_FILENAME and not path.name.endswith(
|
||||
"_content_list.json"
|
||||
):
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
content_list = _find_content_list(payload, "content")
|
||||
if content_list is None:
|
||||
continue
|
||||
|
||||
score = 10
|
||||
if path.name == CONTENT_LIST_FILENAME:
|
||||
score = 0
|
||||
elif path.name == f"{source_stem}_content_list.json":
|
||||
score = 1
|
||||
elif path.stem.endswith("_content_list"):
|
||||
score = 2
|
||||
depth = len(path.relative_to(raw_dir).parts)
|
||||
candidates.append((score, depth, path.as_posix(), path))
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
candidates.sort()
|
||||
return candidates[0][3]
|
||||
|
||||
|
||||
__all__ = ["MinerURawClient", "CONTENT_LIST_FILENAME"]
|
||||
+785
@@ -0,0 +1,785 @@
|
||||
"""MinerU IR builder: ``content_list.json`` (+ images/) → :class:`IRDoc`.
|
||||
|
||||
Input contract: a ``*.mineru_raw/`` directory containing at least
|
||||
``content_list.json``. Optional sibling resources (``images/``,
|
||||
``middle.json``, ``full.md``, ``layout.pdf``) are kept as-is; this builder
|
||||
only reads the content list and image asset bytes.
|
||||
|
||||
Conversion rules (informed by spec §3-§六):
|
||||
|
||||
- ``text`` items with ``text_level>0`` and ``title`` / ``section_header``
|
||||
start a NEW block. The heading text is rendered with a markdown ``#``
|
||||
prefix matching the level (``# foo``, ``## bar`` …) as the first line of
|
||||
the new block's content.
|
||||
- All other items (``text``, ``list``, ``code``, ``table``, ``image``,
|
||||
``equation``) are MERGED into the current block — their text / placeholder
|
||||
is appended (newline-separated) to the heading's block. This mirrors the
|
||||
native docx parser's "split-by-heading, merge-everything-under-heading"
|
||||
behavior (see ``parser/docx/parse_document.py``).
|
||||
- Content emitted before the first heading lands in a synthetic
|
||||
``Preface/Uncategorized`` block at level 0.
|
||||
- ``list`` items joined with ``\n``; ``code`` body taken from ``code_body``
|
||||
if present.
|
||||
- ``table`` → IRTable + ``{{TBL:k}}`` placeholder. MinerU HTML tables are
|
||||
preserved verbatim on ``IRTable.html`` so merged cells (``rowspan`` /
|
||||
``colspan``) survive in ``tables.json``; the block placeholder receives
|
||||
only the table's inner HTML to avoid nested ``<table>`` wrappers. ``rows``
|
||||
is reserved for explicit 2D-array / non-HTML compatibility inputs. A real
|
||||
HTML ``<thead>`` populates ``table_header`` (per spec §5); otherwise the
|
||||
adapter does not guess a header row.
|
||||
- ``image`` / ``picture`` / ``drawing`` → IRDrawing + ``{{IMG:k}}`` placeholder.
|
||||
Asset bytes are referenced via ``img_path`` relative to the raw dir.
|
||||
- ``equation`` → IREquation. ``is_block`` is decided by whether
|
||||
``text_format=="block"`` (MinerU explicit flag) OR ``text_level==0`` with
|
||||
no inline neighbours; otherwise inline. The latex string is preserved
|
||||
verbatim (including any ``$$``/``$`` wrappers) so ``blocks.jsonl``'s
|
||||
``<equation>`` body matches MinerU's raw output; the writer strips the
|
||||
wrappers when persisting ``equations.json`` content.
|
||||
- ``page_idx`` + ``bbox`` → ``IRPosition(type="bbox", anchor=page, range=[x0,y0,x1,y1])``.
|
||||
Empty/missing bbox is acceptable; positions accumulate on the merged block.
|
||||
- ``IRDoc.split_option`` records the MinerU engine version when available.
|
||||
- ``IRDoc.bbox_attributes`` defaults to ``{"origin":"LEFTTOP","max":1000}``
|
||||
reflecting MinerU's PDF coordinate convention. Operators may override
|
||||
via ``MINERU_BBOX_ATTRIBUTES`` (JSON string).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from lightrag.parser._html_table import (
|
||||
HTMLTableInfo,
|
||||
extract_html_table_info,
|
||||
extract_thead_html,
|
||||
html_table_inner_body,
|
||||
looks_like_html_table_payload,
|
||||
unwrap_html_table,
|
||||
)
|
||||
from lightrag.parser._markdown import (
|
||||
render_heading_line,
|
||||
strip_heading_markdown_prefix,
|
||||
)
|
||||
from lightrag.sidecar.ir import (
|
||||
AssetSpec,
|
||||
IRBlock,
|
||||
IRDoc,
|
||||
IRDrawing,
|
||||
IREquation,
|
||||
IRPosition,
|
||||
IRTable,
|
||||
)
|
||||
from lightrag.utils import logger
|
||||
|
||||
|
||||
PREFACE_HEADING = "Preface/Uncategorized"
|
||||
CONTENT_LIST_FILENAME = "content_list.json"
|
||||
|
||||
|
||||
class MinerUIRBuilder:
|
||||
"""Stateless except for env-driven config. Reusable across calls."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.engine_version = os.getenv("MINERU_ENGINE_VERSION", "").strip()
|
||||
# Mirror MinerURawClient.__init__: when this is set, the downloader
|
||||
# stores ALL referenced images (including relative ones) under
|
||||
# ``images/<basename>``. The builder has to look in the same place.
|
||||
self.image_url_template = os.getenv("MINERU_IMAGE_URL_TEMPLATE", "").strip()
|
||||
self.bbox_attributes = self._load_bbox_attributes_env()
|
||||
|
||||
def _load_bbox_attributes_env(self) -> dict[str, Any]:
|
||||
default = {"origin": "LEFTTOP", "max": 1000}
|
||||
raw = os.getenv("MINERU_BBOX_ATTRIBUTES", "").strip()
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning(
|
||||
"[mineru_ir_builder] MINERU_BBOX_ATTRIBUTES is not valid JSON "
|
||||
"(%s); falling back to default %s",
|
||||
exc,
|
||||
default,
|
||||
)
|
||||
return default
|
||||
if not isinstance(parsed, dict):
|
||||
logger.warning(
|
||||
"[mineru_ir_builder] MINERU_BBOX_ATTRIBUTES must decode to a JSON "
|
||||
"object, got %s; falling back to default %s",
|
||||
type(parsed).__name__,
|
||||
default,
|
||||
)
|
||||
return default
|
||||
return parsed
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def normalize_from_workdir(
|
||||
self,
|
||||
raw_dir: Path,
|
||||
*,
|
||||
document_name: str,
|
||||
) -> IRDoc:
|
||||
"""Read ``raw_dir/content_list.json`` and emit an IRDoc.
|
||||
|
||||
``document_name`` is the canonical filename (e.g. ``foo.pdf``) used
|
||||
for ``meta.document_name``; resolved by the caller from the parser
|
||||
hint chain.
|
||||
"""
|
||||
content_list_path = raw_dir / "content_list.json"
|
||||
if not content_list_path.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"MinerU raw bundle missing content_list.json at {raw_dir}"
|
||||
)
|
||||
content_list = json.loads(content_list_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(content_list, list):
|
||||
raise ValueError(
|
||||
f"MinerU content_list.json malformed (not a JSON array) at {raw_dir}"
|
||||
)
|
||||
return self._normalize_content_list(
|
||||
content_list, raw_dir, document_name=document_name
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _normalize_content_list(
|
||||
self,
|
||||
content_list: list[Any],
|
||||
raw_dir: Path,
|
||||
*,
|
||||
document_name: str,
|
||||
) -> IRDoc:
|
||||
document_format = Path(document_name).suffix.lower().lstrip(".")
|
||||
|
||||
blocks: list[IRBlock] = []
|
||||
assets: list[AssetSpec] = []
|
||||
seen_assets: dict[str, str] = {} # ref → suggested_name
|
||||
doc_title = ""
|
||||
placeholder_counter = 0
|
||||
|
||||
def _next_key(prefix: str) -> str:
|
||||
nonlocal placeholder_counter
|
||||
placeholder_counter += 1
|
||||
return f"{prefix}{placeholder_counter}"
|
||||
|
||||
# Heading hierarchy stack — index = level-1 (level 1 lives at [0]).
|
||||
heading_stack: list[str] = []
|
||||
|
||||
# Current-block accumulator. The block is materialized when the next
|
||||
# heading arrives (or at end-of-document). The initial block is the
|
||||
# synthetic "Preface/Uncategorized" container at level 0.
|
||||
cb_lines: list[str] = []
|
||||
cb_tables: list[IRTable] = []
|
||||
cb_drawings: list[IRDrawing] = []
|
||||
cb_equations: list[IREquation] = []
|
||||
# Positions are split into two channels:
|
||||
# - ``cb_page_set`` collects ``page_idx`` of bbox-less items; at flush
|
||||
# each unique page becomes one anchor-only summary ``IRPosition``.
|
||||
# - ``cb_bbox_positions`` keeps one fine-grained position per item that
|
||||
# carried a parseable bbox (anchor + range), in source order, with
|
||||
# no deduplication.
|
||||
cb_page_set: set[str] = set()
|
||||
cb_bbox_positions: list[IRPosition] = []
|
||||
cb_heading = PREFACE_HEADING
|
||||
cb_level = 0
|
||||
cb_parents: list[str] = []
|
||||
|
||||
def _record_position(item: dict) -> None:
|
||||
"""Route an item's positional info into the right channel.
|
||||
|
||||
Items with a parseable ``bbox`` produce one fine-grained
|
||||
IRPosition appended to ``cb_bbox_positions`` (no dedupe).
|
||||
Otherwise, ``page_idx`` (if any) is added to ``cb_page_set``
|
||||
and emitted as a single anchor-only summary entry at flush.
|
||||
"""
|
||||
bbox_pos = _extract_bbox_position(item)
|
||||
if bbox_pos is not None:
|
||||
cb_bbox_positions.append(bbox_pos)
|
||||
return
|
||||
page = _extract_page_anchor(item)
|
||||
if page is not None:
|
||||
cb_page_set.add(page)
|
||||
|
||||
def _flush_block() -> None:
|
||||
"""Emit the in-flight block if it carries any content."""
|
||||
nonlocal cb_lines, cb_tables, cb_drawings, cb_equations
|
||||
nonlocal cb_page_set, cb_bbox_positions
|
||||
has_payload = bool(cb_lines or cb_tables or cb_drawings or cb_equations)
|
||||
if not has_payload:
|
||||
return
|
||||
content = "\n".join(line for line in cb_lines if line)
|
||||
if not content.strip() and not (cb_tables or cb_drawings or cb_equations):
|
||||
# Reset and skip — nothing meaningful to emit.
|
||||
cb_lines = []
|
||||
cb_page_set = set()
|
||||
cb_bbox_positions = []
|
||||
return
|
||||
positions = [
|
||||
IRPosition(type="bbox", anchor=p)
|
||||
for p in _sort_page_anchors(cb_page_set)
|
||||
] + list(cb_bbox_positions)
|
||||
blocks.append(
|
||||
IRBlock(
|
||||
content_template=content,
|
||||
heading=cb_heading,
|
||||
level=cb_level,
|
||||
parent_headings=list(cb_parents),
|
||||
positions=positions,
|
||||
tables=list(cb_tables),
|
||||
drawings=list(cb_drawings),
|
||||
equations=list(cb_equations),
|
||||
)
|
||||
)
|
||||
cb_lines = []
|
||||
cb_tables = []
|
||||
cb_drawings = []
|
||||
cb_equations = []
|
||||
cb_page_set = set()
|
||||
cb_bbox_positions = []
|
||||
|
||||
def _open_block(
|
||||
heading: str, level: int, parents: list[str], raw_heading: str | None = None
|
||||
) -> None:
|
||||
nonlocal cb_heading, cb_level, cb_parents
|
||||
cb_heading = heading
|
||||
cb_level = level
|
||||
cb_parents = parents
|
||||
# Render the heading line into the block body so the merged
|
||||
# text reads like markdown (``# Foo`` / ``## Bar`` / …). Levels
|
||||
# are capped at 6 ``#`` and headings already carrying a markdown
|
||||
# prefix are left untouched (see ``render_heading_line``).
|
||||
cb_lines.append(render_heading_line(level, raw_heading or heading))
|
||||
|
||||
def _append_text(text: str) -> bool:
|
||||
"""Append ``text`` to the current block body and return whether
|
||||
anything was actually written. Callers use the return value to
|
||||
decide whether to also record the item's source position — an
|
||||
empty text item must NOT leak its ``page_idx`` to the block.
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
cb_lines.append(text)
|
||||
return True
|
||||
|
||||
for item_index, item in enumerate(content_list):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
item_type = str(item.get("type") or item.get("label") or "").lower()
|
||||
|
||||
# Page numbers are layout noise, not document body. MinerU emits a
|
||||
# ``page_number`` item per page; skip it entirely so it never enters
|
||||
# the block content nor leaks its page_idx into block positions.
|
||||
# (Empty-text page numbers were already dropped by the fallback's
|
||||
# _append_text guard; this also drops page numbers that carry real
|
||||
# text like "12" / "iii".)
|
||||
if item_type == "page_number":
|
||||
continue
|
||||
|
||||
heading_text, heading_level = _detect_heading(item, item_type)
|
||||
if heading_text:
|
||||
clean_heading = strip_heading_markdown_prefix(heading_text)
|
||||
# Heading hierarchy is updated unconditionally so deeper
|
||||
# parents resolve correctly once the next real body item
|
||||
# opens a fresh block.
|
||||
heading_stack = heading_stack[: max(heading_level - 1, 0)]
|
||||
parents = [h for h in heading_stack if h]
|
||||
heading_stack.append(clean_heading)
|
||||
|
||||
# Every recognized heading starts its own block: flush the
|
||||
# in-flight block (whether it had body or was a bare heading)
|
||||
# and open a fresh one. A heading with no following body thus
|
||||
# becomes a standalone block whose content is just the heading
|
||||
# line, matching the native docx parser's behaviour.
|
||||
_flush_block()
|
||||
_open_block(clean_heading, heading_level, parents, heading_text)
|
||||
_record_position(item)
|
||||
|
||||
if not doc_title and heading_level == 1:
|
||||
doc_title = clean_heading
|
||||
continue
|
||||
|
||||
if item_type == "text":
|
||||
if _append_text(_coerce_text(item)):
|
||||
_record_position(item)
|
||||
continue
|
||||
|
||||
if item_type == "list":
|
||||
items = item.get("list_items")
|
||||
if isinstance(items, list):
|
||||
text = "\n".join(str(x) for x in items if str(x).strip())
|
||||
else:
|
||||
text = _coerce_text(item)
|
||||
if _append_text(text):
|
||||
_record_position(item)
|
||||
continue
|
||||
|
||||
if item_type == "code":
|
||||
if _append_text(item.get("code_body") or _coerce_text(item)):
|
||||
_record_position(item)
|
||||
continue
|
||||
|
||||
if item_type == "equation":
|
||||
latex_raw = _coerce_text(item)
|
||||
if not latex_raw:
|
||||
# Spec compliance fix: empty equation must not enter sidecar.
|
||||
continue
|
||||
# Preserve MinerU's raw latex (including any ``$$``/``$``
|
||||
# wrappers); the writer strips them when emitting
|
||||
# equations.json so blocks.jsonl shows the raw form while
|
||||
# the per-equation sidecar holds clean latex.
|
||||
latex = latex_raw.strip()
|
||||
is_block = _is_block_equation(item)
|
||||
caption = str(item.get("caption") or "")
|
||||
placeholder = _next_key("eq")
|
||||
token = "EQ" if is_block else "EQI"
|
||||
cb_equations.append(
|
||||
IREquation(
|
||||
placeholder_key=placeholder,
|
||||
latex=latex,
|
||||
is_block=is_block,
|
||||
caption=caption,
|
||||
footnotes=_as_str_list(item.get("footnotes")),
|
||||
self_ref=_content_list_self_ref(item_index) if is_block else "",
|
||||
)
|
||||
)
|
||||
cb_lines.append(f"{{{{{token}:{placeholder}}}}}")
|
||||
_record_position(item)
|
||||
continue
|
||||
|
||||
if item_type == "table":
|
||||
table = self._build_ir_table(item)
|
||||
if table is None:
|
||||
# Empty body — _build_ir_table already logged the drop.
|
||||
# Skip placeholder allocation and position recording so
|
||||
# the misidentified item leaves no trace in the IR.
|
||||
continue
|
||||
placeholder = _next_key("tb")
|
||||
table.placeholder_key = placeholder
|
||||
table.self_ref = _content_list_self_ref(item_index)
|
||||
cb_tables.append(table)
|
||||
cb_lines.append(f"{{{{TBL:{placeholder}}}}}")
|
||||
_record_position(item)
|
||||
continue
|
||||
|
||||
if item_type in {"image", "picture", "drawing"}:
|
||||
drawing, asset = self._build_ir_drawing(item, raw_dir, seen_assets)
|
||||
placeholder = _next_key("im")
|
||||
drawing.placeholder_key = placeholder
|
||||
drawing.self_ref = _content_list_self_ref(item_index)
|
||||
if asset is not None and asset.ref not in {a.ref for a in assets}:
|
||||
assets.append(asset)
|
||||
cb_drawings.append(drawing)
|
||||
cb_lines.append(f"{{{{IMG:{placeholder}}}}}")
|
||||
_record_position(item)
|
||||
continue
|
||||
|
||||
# Fallback: serialize unknown items as plain text so we don't
|
||||
# silently drop information. Position only recorded when the
|
||||
# fallback actually contributed text — empty unknown items must
|
||||
# not leak their page_idx into the current block.
|
||||
if _append_text(_coerce_text(item)):
|
||||
_record_position(item)
|
||||
|
||||
_flush_block()
|
||||
|
||||
if not doc_title:
|
||||
doc_title = Path(document_name).stem or document_name
|
||||
|
||||
split_option: dict[str, Any] = {}
|
||||
if self.engine_version:
|
||||
split_option["engine_version"] = self.engine_version
|
||||
# Reserved hook for later: detect OCR flag from middle.json / config.
|
||||
|
||||
return IRDoc(
|
||||
document_name=document_name,
|
||||
document_format=document_format,
|
||||
doc_title=doc_title,
|
||||
split_option=split_option,
|
||||
blocks=blocks,
|
||||
assets=assets,
|
||||
bbox_attributes=dict(self.bbox_attributes),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tables / drawings
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_ir_table(self, item: dict) -> IRTable | None:
|
||||
rows: list[list[str]] | None = None
|
||||
html: str | None = None
|
||||
body_override: str | None = None
|
||||
body_field = item.get("rows")
|
||||
body = body_field if body_field is not None else item.get("table_body")
|
||||
|
||||
if isinstance(body, list):
|
||||
rows = _normalize_grid(body)
|
||||
elif isinstance(body, str):
|
||||
stripped = body.strip()
|
||||
if looks_like_html_table_payload(stripped):
|
||||
# MinerU's table model sometimes wraps output in a
|
||||
# ``<html><body>…</body></html>`` document; unwrap to the bare
|
||||
# ``<table>…</table>`` so the sidecar ``content`` stays a single
|
||||
# clean table and the writer does not nest ``<table>`` wrappers.
|
||||
html = unwrap_html_table(stripped) or None
|
||||
if html:
|
||||
# ``or None`` so a degenerate ``<table></table>`` (empty
|
||||
# inner body) falls back to rendering ``table.html`` in the
|
||||
# writer instead of emitting an empty ``body_override``.
|
||||
body_override = html_table_inner_body(html) or None
|
||||
elif stripped.startswith("[") and stripped.endswith("]"):
|
||||
try:
|
||||
decoded = json.loads(stripped)
|
||||
if isinstance(decoded, list):
|
||||
rows = _normalize_grid(decoded)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if rows is None and html is None:
|
||||
# Non-HTML, non-JSON string (or JSON that failed to parse):
|
||||
# fall back to the raw payload as the html body.
|
||||
html = stripped or None
|
||||
elif isinstance(body, dict):
|
||||
grid = body.get("grid") or body.get("rows")
|
||||
if isinstance(grid, list):
|
||||
rows = _normalize_grid(grid)
|
||||
else:
|
||||
html = json.dumps(body, ensure_ascii=False)
|
||||
|
||||
# MinerU occasionally emits table items with no usable body (e.g. when
|
||||
# a page number or blank region is misidentified as a table). Dropping
|
||||
# them here keeps the sidecar free of items that would later trip the
|
||||
# analyze worker's "missing table content" hard-failure path.
|
||||
if not _ir_table_body_has_content(rows, html):
|
||||
logger.debug(
|
||||
"[mineru_ir_builder] dropping empty table item "
|
||||
"(body type=%s, num_rows=%s, num_cols=%s)",
|
||||
type(body).__name__,
|
||||
item.get("num_rows"),
|
||||
item.get("num_cols"),
|
||||
)
|
||||
return None
|
||||
|
||||
num_rows = int(item.get("num_rows") or (len(rows) if rows else 0) or 0)
|
||||
num_cols_default = max((len(r) for r in rows), default=0) if rows else 0
|
||||
num_cols = int(item.get("num_cols") or num_cols_default or 0)
|
||||
html_table_info: HTMLTableInfo | None = None
|
||||
if html and (num_rows <= 0 or num_cols <= 0):
|
||||
html_table_info = extract_html_table_info(html)
|
||||
if num_rows <= 0:
|
||||
num_rows = html_table_info.num_rows
|
||||
if num_cols <= 0:
|
||||
num_cols = html_table_info.num_cols
|
||||
|
||||
captions = item.get("table_caption")
|
||||
caption = str(item.get("caption") or "")
|
||||
if not caption and isinstance(captions, list) and captions:
|
||||
caption = str(captions[0])
|
||||
|
||||
# The header representation follows the table's format so merged-cell
|
||||
# semantics survive: HTML tables keep the raw ``<thead>…</thead>``
|
||||
# (preserving rowspan/colspan); grid/JSON tables keep a 2-D grid.
|
||||
table_header_raw = item.get("header")
|
||||
table_header: list[list[str]] | str | None = None
|
||||
if html:
|
||||
table_header = extract_thead_html(html)
|
||||
# Fallback: an HTML table whose markup carries no ``<thead>`` but for
|
||||
# which MinerU supplied a separate ``header`` grid keeps that grid —
|
||||
# the writer renders it to a (span-less) ``<thead>`` rather than
|
||||
# silently dropping the recovered header.
|
||||
if (
|
||||
table_header is None
|
||||
and isinstance(table_header_raw, list)
|
||||
and table_header_raw
|
||||
):
|
||||
table_header = _normalize_grid(table_header_raw)
|
||||
elif isinstance(table_header_raw, list) and table_header_raw:
|
||||
table_header = _normalize_grid(table_header_raw)
|
||||
|
||||
return IRTable(
|
||||
placeholder_key="", # filled by caller
|
||||
rows=rows,
|
||||
html=html,
|
||||
num_rows=num_rows,
|
||||
num_cols=num_cols,
|
||||
caption=caption,
|
||||
footnotes=_as_str_list(item.get("table_footnote") or item.get("footnotes")),
|
||||
table_header=table_header,
|
||||
body_override=body_override,
|
||||
)
|
||||
|
||||
def _build_ir_drawing(
|
||||
self,
|
||||
item: dict,
|
||||
raw_dir: Path,
|
||||
seen: dict[str, str],
|
||||
) -> tuple[IRDrawing, AssetSpec | None]:
|
||||
img_path = str(item.get("img_path") or item.get("path") or "")
|
||||
src_val = str(item.get("src") or "")
|
||||
captions = item.get("image_caption") or item.get("captions")
|
||||
caption = str(item.get("caption") or "")
|
||||
if not caption and isinstance(captions, list) and captions:
|
||||
caption = str(captions[0])
|
||||
|
||||
fmt = Path(img_path).suffix.lower().lstrip(".") if img_path else ""
|
||||
if not fmt:
|
||||
fmt = str(item.get("format") or "")
|
||||
|
||||
asset: AssetSpec | None = None
|
||||
ref = ""
|
||||
if img_path:
|
||||
ref = img_path
|
||||
if ref in seen:
|
||||
# Already declared by a previous block; reuse name.
|
||||
pass
|
||||
else:
|
||||
# Asset source: file on disk inside raw_dir. ``img_path`` is
|
||||
# untrusted (it comes from MinerU's content_list.json or a
|
||||
# downloaded zip), so we go through a safe resolver that
|
||||
# refuses to escape ``raw_dir`` and mirrors the downloader's
|
||||
# storage layout for absolute-URL / templated references.
|
||||
local_path = _safe_local_asset_path(
|
||||
raw_dir,
|
||||
img_path,
|
||||
image_url_template=self.image_url_template,
|
||||
)
|
||||
suggested_name = _suggested_asset_name(img_path, fmt, len(seen))
|
||||
asset = AssetSpec(
|
||||
ref=ref,
|
||||
suggested_name=suggested_name,
|
||||
source=local_path
|
||||
if local_path is not None and local_path.is_file()
|
||||
else None,
|
||||
)
|
||||
seen[ref] = suggested_name
|
||||
|
||||
drawing = IRDrawing(
|
||||
placeholder_key="", # filled by caller
|
||||
asset_ref=ref,
|
||||
fmt=fmt,
|
||||
caption=caption,
|
||||
footnotes=_as_str_list(item.get("image_footnote") or item.get("footnotes")),
|
||||
src=src_val,
|
||||
)
|
||||
return drawing, asset
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# helpers
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _detect_heading(item: dict, item_type: str) -> tuple[str, int]:
|
||||
"""Return ``(heading_text, level)`` if ``item`` is a heading, else ``("", 0)``.
|
||||
|
||||
A heading is either an explicit ``title``/``section_header`` block, or a
|
||||
``text`` block whose ``text_level`` is positive (MinerU's convention).
|
||||
"""
|
||||
if item_type in {"title", "section_header"}:
|
||||
text = _coerce_text(item).strip()
|
||||
level = max(int(item.get("text_level") or item.get("level") or 1), 1)
|
||||
return text, level
|
||||
if item_type == "text":
|
||||
try:
|
||||
tl = int(item.get("text_level") or 0)
|
||||
except (TypeError, ValueError):
|
||||
tl = 0
|
||||
if tl > 0:
|
||||
return _coerce_text(item).strip(), tl
|
||||
return "", 0
|
||||
|
||||
|
||||
def _coerce_text(item: dict) -> str:
|
||||
for key in ("text", "content", "body", "code_body"):
|
||||
val = item.get(key)
|
||||
if isinstance(val, str) and val.strip():
|
||||
return val
|
||||
return ""
|
||||
|
||||
|
||||
def _as_str_list(value: Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return [str(x) for x in value if str(x).strip()]
|
||||
s = str(value).strip()
|
||||
return [s] if s else []
|
||||
|
||||
|
||||
def _content_list_self_ref(index: int) -> str:
|
||||
return f"{CONTENT_LIST_FILENAME}#/{index}"
|
||||
|
||||
|
||||
def _normalize_grid(grid: Any) -> list[list[str]]:
|
||||
out: list[list[str]] = []
|
||||
if not isinstance(grid, list):
|
||||
return out
|
||||
for row in grid:
|
||||
if not isinstance(row, list):
|
||||
continue
|
||||
out_row: list[str] = []
|
||||
for cell in row:
|
||||
if isinstance(cell, dict):
|
||||
out_row.append(str(cell.get("text", "")).strip())
|
||||
else:
|
||||
out_row.append(str(cell).strip())
|
||||
out.append(out_row)
|
||||
return out
|
||||
|
||||
|
||||
def _ir_table_body_has_content(rows: list[list[str]] | None, html: str | None) -> bool:
|
||||
"""True iff the parsed table body carries any visible cell text or HTML."""
|
||||
if html and html.strip():
|
||||
return True
|
||||
if rows:
|
||||
for row in rows:
|
||||
for cell in row:
|
||||
if isinstance(cell, str) and cell.strip():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_block_equation(item: dict) -> bool:
|
||||
"""Heuristic: MinerU's ``text_format`` distinguishes block vs inline.
|
||||
|
||||
Fallback when absent: treat as block (most MinerU equation items in
|
||||
PDF context represent display equations); inline equations are usually
|
||||
embedded inside ``text`` items rather than first-class ``equation``
|
||||
items.
|
||||
"""
|
||||
fmt = str(item.get("text_format") or "").lower()
|
||||
if fmt in {"inline", "inline_equation"}:
|
||||
return False
|
||||
if fmt in {"block", "block_equation", "display"}:
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def _extract_page_anchor(item: dict) -> str | None:
|
||||
"""Return a 1-based page anchor from MinerU's ``page_idx`` / ``page``.
|
||||
|
||||
Always returns a string so ``blocks.jsonl`` carries a uniform anchor
|
||||
type across Roman / letter / numeric page labels. Integers are bumped
|
||||
to 1-based (``page_idx=0`` → ``"1"``); strings are stripped and passed
|
||||
through verbatim. Returns ``None`` when no usable page info is present.
|
||||
"""
|
||||
page_raw = item.get("page_idx")
|
||||
if page_raw is None:
|
||||
page_raw = item.get("page")
|
||||
if isinstance(page_raw, bool):
|
||||
# bool is a subclass of int — guard so True/False don't sneak in.
|
||||
return None
|
||||
if isinstance(page_raw, int):
|
||||
return str(page_raw + 1 if page_raw >= 0 else page_raw)
|
||||
if isinstance(page_raw, str) and page_raw.strip():
|
||||
return page_raw.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _sort_page_anchors(pages: set[str]) -> list[str]:
|
||||
"""Order page anchors using book pagination convention.
|
||||
|
||||
Non-numeric labels (Roman preface pages ``i``/``ii``/``iv``…, letter
|
||||
pages like ``A``, ``B-1``) come first in lexical order; numeric labels
|
||||
follow, sorted by their integer value so ``"2"`` precedes ``"10"``.
|
||||
Mixing both kinds is safe — the bucketed key avoids the ``TypeError``
|
||||
that ``sorted({"ii", "1"})`` raises when ints and strings mix.
|
||||
"""
|
||||
non_numeric = sorted(p for p in pages if not p.isdigit())
|
||||
numeric = sorted((p for p in pages if p.isdigit()), key=int)
|
||||
return non_numeric + numeric
|
||||
|
||||
|
||||
def _extract_bbox_position(item: dict) -> IRPosition | None:
|
||||
"""Build a fine-grained ``IRPosition`` when ``bbox`` is parseable.
|
||||
|
||||
Returns ``None`` when ``bbox`` is missing or malformed; the caller then
|
||||
falls back to page-only tracking via :func:`_extract_page_anchor`.
|
||||
"""
|
||||
bbox = item.get("bbox")
|
||||
if not isinstance(bbox, (list, tuple)) or len(bbox) < 4:
|
||||
return None
|
||||
try:
|
||||
coords = [float(x) for x in bbox[:4]]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return IRPosition(type="bbox", anchor=_extract_page_anchor(item), range=coords)
|
||||
|
||||
|
||||
def _safe_local_asset_path(
|
||||
raw_dir: Path,
|
||||
img_path: str,
|
||||
*,
|
||||
image_url_template: str = "",
|
||||
) -> Path | None:
|
||||
"""Resolve ``img_path`` to a concrete file location inside ``raw_dir``.
|
||||
|
||||
``img_path`` comes from MinerU's ``content_list.json`` and is therefore
|
||||
untrusted. This resolver mirrors :meth:`MinerURawClient._fetch_one_image`
|
||||
storage rules so the builder always looks where the downloader wrote
|
||||
the file:
|
||||
|
||||
- absolute http(s) URLs and absolute filesystem paths
|
||||
→ ``raw_dir/images/<basename>``;
|
||||
- any ref when ``MINERU_IMAGE_URL_TEMPLATE`` is configured (the
|
||||
downloader routes ALL refs — including relative ones — through
|
||||
:meth:`_image_dest_rel`) → ``raw_dir/images/<basename>``;
|
||||
- otherwise relative paths resolve under ``raw_dir`` with ``..``
|
||||
traversal refused and a final ``Path.relative_to`` check.
|
||||
|
||||
Returns ``None`` when the candidate is unsafe or cannot be expressed
|
||||
inside ``raw_dir``. The caller treats ``None`` the same as "file missing"
|
||||
— the drawing tag still gets written, but no bytes are copied.
|
||||
"""
|
||||
if not img_path:
|
||||
return None
|
||||
|
||||
if img_path.startswith(("http://", "https://")):
|
||||
name = Path(urlparse(img_path).path).name
|
||||
return raw_dir / "images" / name if name else None
|
||||
|
||||
if os.path.isabs(img_path):
|
||||
# Absolute filesystem path in img_path is never trusted to point
|
||||
# outside raw_dir; mirror the downloader's basename rule.
|
||||
name = Path(img_path).name
|
||||
return raw_dir / "images" / name if name else None
|
||||
|
||||
if image_url_template:
|
||||
# Templated mode: downloader stored every ref (incl. relative) at
|
||||
# images/<basename>, so we must look there too.
|
||||
name = Path(img_path).name
|
||||
return raw_dir / "images" / name if name else None
|
||||
|
||||
normalized = os.path.normpath(img_path)
|
||||
if normalized.startswith("..") or os.path.isabs(normalized):
|
||||
return None
|
||||
candidate = (raw_dir / normalized).resolve()
|
||||
try:
|
||||
candidate.relative_to(raw_dir.resolve())
|
||||
except ValueError:
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
def _suggested_asset_name(img_path: str, fmt: str, seen_count: int) -> str:
|
||||
"""Pick an in-assets-dir filename for an asset.
|
||||
|
||||
For URL refs, use the URL path's basename so we get a useful filename
|
||||
(``foo.png`` rather than the whole URL). For local refs, the regular
|
||||
basename. Falls back to ``image-<n>[.fmt]`` when nothing usable.
|
||||
"""
|
||||
if img_path.startswith(("http://", "https://")):
|
||||
name = Path(urlparse(img_path).path).name
|
||||
else:
|
||||
name = Path(img_path).name
|
||||
if name:
|
||||
return name
|
||||
return f"image-{seen_count + 1}{('.' + fmt) if fmt else ''}"
|
||||
|
||||
|
||||
__all__ = ["MinerUIRBuilder"]
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
"""``_manifest.json`` schema for ``*.mineru_raw/`` bundles.
|
||||
|
||||
The manifest is the *atomic success marker* for a raw bundle. Its presence
|
||||
implies "all files in this directory finished downloading"; its content is
|
||||
the cache key for "is this bundle for the same source file, the same MinerU
|
||||
parser options, engine version, and endpoint we are using right now?".
|
||||
|
||||
Write path: ``write_manifest(path, manifest)`` writes a temp file then
|
||||
atomically renames to ``_manifest.json``. A crash mid-download leaves no
|
||||
manifest, so the next ``parse_mineru`` call cleanly invalidates and
|
||||
re-downloads.
|
||||
|
||||
Read path: ``load_manifest(path)`` returns ``None`` if absent or malformed
|
||||
— either way the bundle is treated as stale.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
MANIFEST_FILENAME = "_manifest.json"
|
||||
MANIFEST_VERSION = "1.0"
|
||||
MANIFEST_ENGINE = "mineru"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManifestFile:
|
||||
"""One file entry inside the bundle. Size always; sha256 only for the
|
||||
critical file (content_list.json) — see :class:`Manifest.critical_file`.
|
||||
"""
|
||||
|
||||
path: str # relative to the raw dir
|
||||
size: int
|
||||
sha256: str | None = None # ``"sha256:<hex>"`` form or ``None``
|
||||
|
||||
|
||||
@dataclass
|
||||
class Manifest:
|
||||
"""Schema for ``_manifest.json``. Backward-compat policy: new optional
|
||||
fields can be added without bumping version; **any** mismatch on existing
|
||||
field semantics requires a version bump.
|
||||
"""
|
||||
|
||||
source_content_hash: str # ``"sha256:<hex>"`` of source file
|
||||
source_size_bytes: int
|
||||
source_filename_at_parse: str
|
||||
critical_file: ManifestFile # content_list.json; size + sha256
|
||||
files: list[ManifestFile] # other files; size only
|
||||
total_size_bytes: int
|
||||
task_id: str = ""
|
||||
api_mode: str = ""
|
||||
engine_version: str = ""
|
||||
endpoint_signature: str = ""
|
||||
options_signature: str = ""
|
||||
downloaded_at: str = ""
|
||||
version: str = MANIFEST_VERSION
|
||||
engine: str = MANIFEST_ENGINE
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"version": self.version,
|
||||
"engine": self.engine,
|
||||
"api_mode": self.api_mode,
|
||||
"engine_version": self.engine_version,
|
||||
"endpoint_signature": self.endpoint_signature,
|
||||
"options_signature": self.options_signature,
|
||||
"source_content_hash": self.source_content_hash,
|
||||
"source_size_bytes": int(self.source_size_bytes),
|
||||
"source_filename_at_parse": self.source_filename_at_parse,
|
||||
"task_id": self.task_id,
|
||||
"downloaded_at": self.downloaded_at,
|
||||
"critical_file": asdict(self.critical_file),
|
||||
"files": [asdict(f) for f in self.files],
|
||||
"total_size_bytes": int(self.total_size_bytes),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict) -> "Manifest":
|
||||
critical_raw = payload.get("critical_file") or {}
|
||||
files_raw = payload.get("files") or []
|
||||
return cls(
|
||||
version=str(payload.get("version") or MANIFEST_VERSION),
|
||||
engine=str(payload.get("engine") or MANIFEST_ENGINE),
|
||||
api_mode=str(payload.get("api_mode") or ""),
|
||||
engine_version=str(payload.get("engine_version") or ""),
|
||||
endpoint_signature=str(payload.get("endpoint_signature") or ""),
|
||||
options_signature=str(payload.get("options_signature") or ""),
|
||||
source_content_hash=str(payload.get("source_content_hash") or ""),
|
||||
source_size_bytes=int(payload.get("source_size_bytes") or 0),
|
||||
source_filename_at_parse=str(payload.get("source_filename_at_parse") or ""),
|
||||
task_id=str(payload.get("task_id") or ""),
|
||||
downloaded_at=str(payload.get("downloaded_at") or ""),
|
||||
critical_file=ManifestFile(
|
||||
path=str(critical_raw.get("path") or ""),
|
||||
size=int(critical_raw.get("size") or 0),
|
||||
sha256=(
|
||||
str(critical_raw["sha256"]) if critical_raw.get("sha256") else None
|
||||
),
|
||||
),
|
||||
files=[
|
||||
ManifestFile(
|
||||
path=str(f.get("path") or ""),
|
||||
size=int(f.get("size") or 0),
|
||||
sha256=(str(f["sha256"]) if f.get("sha256") else None),
|
||||
)
|
||||
for f in files_raw
|
||||
if isinstance(f, dict)
|
||||
],
|
||||
total_size_bytes=int(payload.get("total_size_bytes") or 0),
|
||||
)
|
||||
|
||||
|
||||
def manifest_path(raw_dir: Path) -> Path:
|
||||
return raw_dir / MANIFEST_FILENAME
|
||||
|
||||
|
||||
def load_manifest(raw_dir: Path) -> Manifest | None:
|
||||
"""Return the parsed manifest or ``None`` if absent / malformed."""
|
||||
p = manifest_path(raw_dir)
|
||||
if not p.is_file():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(p.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
if payload.get("version") != MANIFEST_VERSION:
|
||||
return None
|
||||
if payload.get("engine") != MANIFEST_ENGINE:
|
||||
return None
|
||||
try:
|
||||
return Manifest.from_dict(payload)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def write_manifest(raw_dir: Path, manifest: Manifest) -> None:
|
||||
"""Atomically write the manifest. The temp-file + rename pattern
|
||||
guarantees the manifest never appears in a partially-written state."""
|
||||
raw_dir.mkdir(parents=True, exist_ok=True)
|
||||
final = manifest_path(raw_dir)
|
||||
tmp = final.with_suffix(".json.tmp")
|
||||
tmp.write_text(
|
||||
json.dumps(manifest.to_dict(), ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(tmp, final)
|
||||
|
||||
|
||||
# Re-exported for convenience.
|
||||
__all__ = [
|
||||
"MANIFEST_FILENAME",
|
||||
"MANIFEST_VERSION",
|
||||
"MANIFEST_ENGINE",
|
||||
"Manifest",
|
||||
"ManifestFile",
|
||||
"load_manifest",
|
||||
"manifest_path",
|
||||
"write_manifest",
|
||||
]
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
"""MinerU engine adapter (implements ExternalParserBase hooks)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from lightrag.constants import MINERU_RAW_DIR_SUFFIX, PARSER_ENGINE_MINERU
|
||||
from lightrag.parser.external._base import ExternalParserBase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lightrag.sidecar.ir import IRDoc
|
||||
|
||||
|
||||
class MinerUParser(ExternalParserBase):
|
||||
engine_name = PARSER_ENGINE_MINERU
|
||||
raw_dir_suffix = MINERU_RAW_DIR_SUFFIX
|
||||
force_reparse_env = "LIGHTRAG_FORCE_REPARSE_MINERU"
|
||||
|
||||
def is_bundle_valid(
|
||||
self,
|
||||
raw_dir: Path,
|
||||
source_path: Path,
|
||||
*,
|
||||
engine_params: "Mapping[str, Any] | None" = None,
|
||||
) -> bool:
|
||||
from lightrag.parser.external.mineru import is_bundle_valid
|
||||
|
||||
return is_bundle_valid(raw_dir, source_path, overrides=engine_params)
|
||||
|
||||
async def download_into(
|
||||
self,
|
||||
raw_dir: Path,
|
||||
source_path: Path,
|
||||
*,
|
||||
upload_name: str,
|
||||
engine_params: "Mapping[str, Any] | None" = None,
|
||||
) -> None:
|
||||
from lightrag.parser.external.mineru import MinerURawClient
|
||||
|
||||
await MinerURawClient(overrides=engine_params).download_into(
|
||||
raw_dir, source_path, upload_name=upload_name
|
||||
)
|
||||
|
||||
def build_ir(self, raw_dir: Path, document_name: str) -> "IRDoc":
|
||||
from lightrag.parser.external.mineru import MinerUIRBuilder
|
||||
|
||||
return MinerUIRBuilder().normalize_from_workdir(
|
||||
raw_dir, document_name=document_name
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Legacy parser engine: simple in-process text extraction (no sidecar).
|
||||
|
||||
Produces ``raw``-format plain text. The extraction helpers
|
||||
(:func:`extract_text` and the per-format ``_extract_*`` functions) were moved
|
||||
here from the API layer so the core parser owns them (the API layer imports
|
||||
from here instead of the other way round).
|
||||
"""
|
||||
|
||||
from lightrag.parser.legacy.extractors import (
|
||||
LegacyExtractionError,
|
||||
extract_text,
|
||||
)
|
||||
|
||||
__all__ = ["LegacyExtractionError", "extract_text"]
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Legacy text extractors (moved from the API layer).
|
||||
|
||||
``extract_text`` dispatches on file suffix: binary office/pdf formats use the
|
||||
dedicated ``_extract_*`` helpers; everything else is decoded as UTF-8 text
|
||||
with the same validation (empty / binary-looking / non-UTF-8) the API upload
|
||||
path used to enforce — now raised as :class:`LegacyExtractionError` so a bad
|
||||
file fails the parse stage instead of silently yielding an empty document.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
|
||||
class LegacyExtractionError(ValueError):
|
||||
"""Raised when legacy extraction cannot produce usable text."""
|
||||
|
||||
|
||||
def _extract_pdf_pypdf(file_bytes: bytes, password: str | None = None) -> str:
|
||||
"""Extract PDF content using pypdf (synchronous)."""
|
||||
from pypdf import PdfReader # type: ignore
|
||||
|
||||
pdf_file = BytesIO(file_bytes)
|
||||
reader = PdfReader(pdf_file)
|
||||
|
||||
if reader.is_encrypted:
|
||||
# Try empty password first (covers permission-only encrypted PDFs)
|
||||
decrypt_result = reader.decrypt(password or "")
|
||||
if decrypt_result == 0:
|
||||
if password:
|
||||
raise Exception("Incorrect PDF password")
|
||||
else:
|
||||
raise Exception("PDF is encrypted but no password provided")
|
||||
|
||||
content = ""
|
||||
for page in reader.pages:
|
||||
content += page.extract_text() + "\n"
|
||||
return content
|
||||
|
||||
|
||||
def _extract_docx(file_bytes: bytes) -> str:
|
||||
"""Extract DOCX content including tables in document order (synchronous)."""
|
||||
from docx import Document # type: ignore
|
||||
from docx.table import Table # type: ignore
|
||||
from docx.text.paragraph import Paragraph # type: ignore
|
||||
|
||||
docx_file = BytesIO(file_bytes)
|
||||
doc = Document(docx_file)
|
||||
|
||||
def escape_cell(cell_value: str | None) -> str:
|
||||
if cell_value is None:
|
||||
return ""
|
||||
text = str(cell_value)
|
||||
return (
|
||||
text.replace("\\", "\\\\")
|
||||
.replace("\t", "  ")
|
||||
.replace("\r\n", "<br>")
|
||||
.replace("\r", "<br>")
|
||||
.replace("\n", "<br>")
|
||||
)
|
||||
|
||||
content_parts = []
|
||||
in_table = False
|
||||
for element in doc.element.body:
|
||||
if element.tag.endswith("p"):
|
||||
if in_table:
|
||||
content_parts.append("")
|
||||
in_table = False
|
||||
paragraph = Paragraph(element, doc)
|
||||
content_parts.append(paragraph.text)
|
||||
elif element.tag.endswith("tbl"):
|
||||
if content_parts and not in_table:
|
||||
content_parts.append("")
|
||||
in_table = True
|
||||
table = Table(element, doc)
|
||||
for row in table.rows:
|
||||
row_text = [escape_cell(cell.text) for cell in row.cells]
|
||||
if any(cell for cell in row_text):
|
||||
content_parts.append("\t".join(row_text))
|
||||
return "\n".join(content_parts)
|
||||
|
||||
|
||||
def _extract_pptx(file_bytes: bytes) -> str:
|
||||
"""Extract PPTX content (synchronous)."""
|
||||
from pptx import Presentation # type: ignore
|
||||
|
||||
pptx_file = BytesIO(file_bytes)
|
||||
prs = Presentation(pptx_file)
|
||||
content = ""
|
||||
for slide in prs.slides:
|
||||
for shape in slide.shapes:
|
||||
if hasattr(shape, "text"):
|
||||
content += shape.text + "\n"
|
||||
return content
|
||||
|
||||
|
||||
def _extract_xlsx(file_bytes: bytes) -> str:
|
||||
"""Extract XLSX content in tab-delimited format with sheet separators."""
|
||||
from openpyxl import load_workbook # type: ignore
|
||||
|
||||
xlsx_file = BytesIO(file_bytes)
|
||||
wb = load_workbook(xlsx_file)
|
||||
|
||||
def escape_cell(cell_value: str | int | float | None) -> str:
|
||||
if cell_value is None:
|
||||
return ""
|
||||
text = str(cell_value)
|
||||
return (
|
||||
text.replace("\\", "\\\\")
|
||||
.replace("\t", "\\t")
|
||||
.replace("\r\n", "\\n")
|
||||
.replace("\r", "\\n")
|
||||
.replace("\n", "\\n")
|
||||
)
|
||||
|
||||
def escape_sheet_title(title: str) -> str:
|
||||
return str(title).replace("\n", " ").replace("\t", " ").replace("\r", " ")
|
||||
|
||||
content_parts: list[str] = []
|
||||
sheet_separator = "=" * 20
|
||||
|
||||
for idx, sheet in enumerate(wb):
|
||||
if idx > 0:
|
||||
content_parts.append("")
|
||||
safe_title = escape_sheet_title(sheet.title)
|
||||
content_parts.append(f"{sheet_separator} Sheet: {safe_title} {sheet_separator}")
|
||||
max_columns = sheet.max_column if sheet.max_column else 0
|
||||
for row in sheet.iter_rows(values_only=True):
|
||||
row_parts = []
|
||||
for col_idx in range(max_columns):
|
||||
if col_idx < len(row):
|
||||
row_parts.append(escape_cell(row[col_idx]))
|
||||
else:
|
||||
row_parts.append("")
|
||||
if all(part == "" for part in row_parts):
|
||||
content_parts.append("")
|
||||
else:
|
||||
content_parts.append("\t".join(row_parts))
|
||||
content_parts.append(sheet_separator)
|
||||
return "\n".join(content_parts)
|
||||
|
||||
|
||||
# Suffixes (without dot) routed to dedicated binary extractors.
|
||||
_BINARY_EXTRACTORS = {
|
||||
"pdf": _extract_pdf_pypdf,
|
||||
"docx": _extract_docx,
|
||||
"pptx": _extract_pptx,
|
||||
"xlsx": _extract_xlsx,
|
||||
}
|
||||
|
||||
|
||||
def _decode_text(file_bytes: bytes) -> str:
|
||||
"""UTF-8 decode with the upload-path validation, raised on failure."""
|
||||
try:
|
||||
content = file_bytes.decode("utf-8")
|
||||
except UnicodeDecodeError as e:
|
||||
raise LegacyExtractionError(
|
||||
"File is not valid UTF-8 encoded text. Please convert it to "
|
||||
f"UTF-8 before processing: {e}"
|
||||
) from e
|
||||
if not content or len(content.strip()) == 0:
|
||||
raise LegacyExtractionError("File contains no content or only whitespace")
|
||||
if content.startswith("b'") or content.startswith('b"'):
|
||||
raise LegacyExtractionError(
|
||||
"File appears to contain binary data representation instead of text"
|
||||
)
|
||||
return content
|
||||
|
||||
|
||||
def extract_text(
|
||||
file_bytes: bytes, suffix: str, *, pdf_password: str | None = None
|
||||
) -> str:
|
||||
"""Extract plain text from ``file_bytes`` based on ``suffix`` (no dot).
|
||||
|
||||
Synchronous; callers run it in a thread. Raises
|
||||
:class:`LegacyExtractionError` (or the extractor's own exception) on
|
||||
failure.
|
||||
"""
|
||||
suffix = suffix.lower().lstrip(".")
|
||||
extractor = _BINARY_EXTRACTORS.get(suffix)
|
||||
if extractor is _extract_pdf_pypdf:
|
||||
return _extract_pdf_pypdf(file_bytes, pdf_password)
|
||||
if extractor is not None:
|
||||
return extractor(file_bytes)
|
||||
return _decode_text(file_bytes)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Legacy engine adapter: worker-stage plain-text extraction (RAW output)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
|
||||
from lightrag.constants import FULL_DOCS_FORMAT_RAW, PARSER_ENGINE_LEGACY
|
||||
from lightrag.parser.base import BaseParser, ParseContext, ParseResult
|
||||
|
||||
|
||||
class LegacyParser(BaseParser):
|
||||
"""Extract plain text in-process and store it as a ``raw`` document.
|
||||
|
||||
Also serves as the dispatch fallback engine: an unknown/unsupported
|
||||
suffix raises (caught at the parse stage → doc_status FAILED) rather than
|
||||
silently producing empty content.
|
||||
"""
|
||||
|
||||
engine_name = PARSER_ENGINE_LEGACY
|
||||
|
||||
async def parse(self, ctx: ParseContext) -> ParseResult:
|
||||
from lightrag.parser.legacy.extractors import (
|
||||
LegacyExtractionError,
|
||||
extract_text,
|
||||
)
|
||||
from lightrag.parser.registry import suffix_capabilities
|
||||
|
||||
rs = ctx.resolve(self.engine_name)
|
||||
source = rs.source_path
|
||||
if not source.is_file():
|
||||
raise FileNotFoundError(f"legacy source file not found: {source}")
|
||||
|
||||
suffix = source.suffix.lower().lstrip(".")
|
||||
if suffix not in suffix_capabilities(self.engine_name):
|
||||
raise ValueError(
|
||||
f"legacy parser does not support .{suffix or '<no suffix>'}: "
|
||||
f"doc_id={ctx.doc_id} file={ctx.file_path}"
|
||||
)
|
||||
|
||||
file_bytes = await asyncio.to_thread(source.read_bytes)
|
||||
# The PDF password is sourced from env only: the parser layer reads
|
||||
# ``PDF_DECRYPT_PASSWORD`` directly rather than from the API layer's
|
||||
# ``global_args`` (which would invert the parser -> API dependency
|
||||
# direction).
|
||||
pdf_password = os.getenv("PDF_DECRYPT_PASSWORD") or None
|
||||
text = await asyncio.to_thread(
|
||||
extract_text, file_bytes, suffix, pdf_password=pdf_password
|
||||
)
|
||||
# The binary extractors (pdf/docx/pptx/xlsx) return whatever the
|
||||
# library yields — a scanned PDF with no text layer extracts to pure
|
||||
# whitespace. Fail the parse (like the text-decode path already does)
|
||||
# instead of persisting an empty document into chunking.
|
||||
if not text.strip():
|
||||
raise LegacyExtractionError(
|
||||
f"extracted no usable text from {ctx.file_path} (doc_id={ctx.doc_id})"
|
||||
)
|
||||
|
||||
await ctx.rag._persist_parsed_full_docs(
|
||||
ctx.doc_id,
|
||||
{
|
||||
"content": text,
|
||||
"file_path": ctx.file_path,
|
||||
"parse_format": FULL_DOCS_FORMAT_RAW,
|
||||
"parse_engine": self.engine_name,
|
||||
"update_time": int(time.time()),
|
||||
},
|
||||
)
|
||||
await ctx.archive_source(str(source))
|
||||
return ParseResult(
|
||||
doc_id=ctx.doc_id,
|
||||
file_path=ctx.file_path,
|
||||
parse_format=FULL_DOCS_FORMAT_RAW,
|
||||
content=text,
|
||||
blocks_path="",
|
||||
parse_engine=self.engine_name,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Native Markdown parser engine (.md / .textpack) for the native engine."""
|
||||
@@ -0,0 +1,391 @@
|
||||
"""Pure markdown → block-list extraction for the native markdown engine.
|
||||
|
||||
This is the engine-private counterpart of ``parser/docx/parse_document.py``:
|
||||
it turns raw markdown text into the same shape the native IR builder consumes
|
||||
(a list of heading-split block dicts whose ``content`` carries placeholder
|
||||
markers), plus side tables describing the tables / equations / images those
|
||||
markers stand for.
|
||||
|
||||
Placeholder protocol (two-stage, mirroring the docx parser):
|
||||
|
||||
- ``extract_markdown`` embeds **self-closing temporary markers** in block
|
||||
``content`` — ``<mdtable ref="t0"/>`` / ``<mdequation ref="e0"/>`` /
|
||||
``<mddrawing ref="d0"/>`` — and never builds IR objects itself.
|
||||
- :class:`lightrag.parser.markdown.ir_builder.NativeMarkdownIRBuilder` later
|
||||
rewrites each marker into the IR placeholder token (``{{TBL:k}}`` /
|
||||
``{{EQ:k}}`` / ``{{IMG:k}}``) and builds the matching IR item from the side
|
||||
tables.
|
||||
|
||||
The actual table/equation/image payloads live in side tables keyed by the
|
||||
marker ref (NOT inside the marker string). This keeps a captured HTML
|
||||
``<table>`` out of the content stream — so its inner ``</table>`` can never
|
||||
truncate a naive ``<mdtable>…</mdtable>`` wrapper — and keeps image bytes off
|
||||
the content string entirely.
|
||||
|
||||
Supported subset (NOT full CommonMark/GFM, by design — see the parser plan):
|
||||
ATX headings, simple pipe tables (with a header row), block-level ``$$`` math,
|
||||
inline ```` images, and HTML ``<table>`` blocks. Reference-style
|
||||
images, escaped pipes, nested tables, setext headings and list/quote-nested
|
||||
structures are left as verbatim text rather than misrecognised.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol
|
||||
|
||||
from lightrag.parser._html_table import starts_with_html_tag
|
||||
from lightrag.parser._markdown import render_heading_line
|
||||
|
||||
PREFACE_HEADING = "Preface/Uncategorized"
|
||||
|
||||
# --- placeholder marker protocol (shared with the IR builder) --------------
|
||||
_TABLE_MARKER = '<mdtable ref="{ref}"/>'
|
||||
_EQUATION_MARKER = '<mdequation ref="{ref}"/>'
|
||||
_DRAWING_MARKER = '<mddrawing ref="{ref}"/>'
|
||||
|
||||
TABLE_MARKER_RE = re.compile(r'<mdtable ref="([^"]+)"/>')
|
||||
EQUATION_MARKER_RE = re.compile(r'<mdequation ref="([^"]+)"/>')
|
||||
DRAWING_MARKER_RE = re.compile(r'<mddrawing ref="([^"]+)"/>')
|
||||
|
||||
# --- markdown token patterns ----------------------------------------------
|
||||
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*$")
|
||||
# Trailing closing-hash run of an ATX heading (``## Foo ##`` → ``Foo``).
|
||||
_HEADING_TRAILING_HASHES_RE = re.compile(r"\s+#+\s*$")
|
||||
_FENCE_RE = re.compile(r"^(`{3,}|~{3,})(.*)$")
|
||||
# A GFM delimiter row: one or more ``---`` cells with optional ``:`` alignment.
|
||||
_DELIMITER_ROW_RE = re.compile(r"^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$")
|
||||
# A single delimiter cell (after splitting on ``|``): ``---`` with optional
|
||||
# ``:`` alignment markers, nothing else.
|
||||
_DELIMITER_CELL_RE = re.compile(r"^:?-+:?$")
|
||||
# Inline image: ````. ``src`` may be wrapped in
|
||||
# angle brackets. base64 data URLs and bare URLs/paths (no spaces, no ``)``).
|
||||
_IMAGE_RE = re.compile(
|
||||
r'!\[(?P<alt>[^\]]*)\]\(\s*(?P<src><[^>]*>|[^)\s]+)(?:\s+"[^"]*")?\s*\)'
|
||||
)
|
||||
|
||||
|
||||
def table_marker(ref: str) -> str:
|
||||
return _TABLE_MARKER.format(ref=ref)
|
||||
|
||||
|
||||
def equation_marker(ref: str) -> str:
|
||||
return _EQUATION_MARKER.format(ref=ref)
|
||||
|
||||
|
||||
def drawing_marker(ref: str) -> str:
|
||||
return _DRAWING_MARKER.format(ref=ref)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedImage:
|
||||
"""Outcome of resolving one ```` reference.
|
||||
|
||||
``kind``:
|
||||
* ``"local"`` — bytes available; ``asset_ref`` is a stable identity used
|
||||
to deduplicate (same identity ⇒ one on-disk asset shared by every
|
||||
occurrence). ``data`` / ``suggested_name`` / ``fmt`` describe it.
|
||||
* ``"external"`` — keep as an external link; ``url`` is rendered verbatim
|
||||
into the drawing's ``path_override`` (no bytes materialized).
|
||||
* ``"skip"`` — drop the image (resolver already logged / counted it).
|
||||
"""
|
||||
|
||||
kind: str
|
||||
asset_ref: str = ""
|
||||
data: bytes | None = None
|
||||
suggested_name: str = ""
|
||||
fmt: str = ""
|
||||
url: str = ""
|
||||
|
||||
|
||||
class ImageResolver(Protocol):
|
||||
"""Resolves a markdown image ``src`` to bytes / link / skip.
|
||||
|
||||
Implementations own all I/O (base64 decode, HTTP download, textpack asset
|
||||
read) and any deduplication caching, plus bumping warning counters for
|
||||
skipped / failed images. ``extract_markdown`` stays pure and only records
|
||||
what the resolver returns.
|
||||
"""
|
||||
|
||||
def resolve(self, src: str) -> ResolvedImage: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarkdownExtraction:
|
||||
"""Result of :func:`extract_markdown`.
|
||||
|
||||
``blocks`` mirrors the docx block-dict shape (``heading`` / ``level`` /
|
||||
``parent_headings`` / ``content``). The side tables are keyed by marker
|
||||
ref; ``assets`` is keyed by :attr:`ResolvedImage.asset_ref` (deduped).
|
||||
"""
|
||||
|
||||
blocks: list[dict] = field(default_factory=list)
|
||||
tables: dict[str, dict] = field(default_factory=dict)
|
||||
equations: dict[str, str] = field(default_factory=dict)
|
||||
drawings: dict[str, dict] = field(default_factory=dict)
|
||||
assets: dict[str, dict] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _clean_heading(text: str) -> str:
|
||||
return _HEADING_TRAILING_HASHES_RE.sub("", text).strip()
|
||||
|
||||
|
||||
def _split_pipe_row(line: str) -> list[str]:
|
||||
"""Split a pipe-table row into trimmed cells (no escaped-pipe handling)."""
|
||||
s = line.strip()
|
||||
if s.startswith("|"):
|
||||
s = s[1:]
|
||||
if s.endswith("|"):
|
||||
s = s[:-1]
|
||||
return [cell.strip() for cell in s.split("|")]
|
||||
|
||||
|
||||
def _is_pipe_table_delimiter(header_line: str, delim_line: str) -> bool:
|
||||
"""True iff ``delim_line`` is a GFM delimiter row matching ``header_line``.
|
||||
|
||||
Beyond the row-shape regex, every delimiter cell must be a bare ``---`` (no
|
||||
stray text) and the column count must equal the header's. This rejects a
|
||||
bare ``---`` (a thematic break or setext underline) sitting under a
|
||||
pipe-containing paragraph — that has one column versus the header's many, so
|
||||
it is not a table, matching GFM's column-count rule."""
|
||||
if not _DELIMITER_ROW_RE.match(delim_line):
|
||||
return False
|
||||
delim_cells = _split_pipe_row(delim_line)
|
||||
if not all(_DELIMITER_CELL_RE.match(cell) for cell in delim_cells):
|
||||
return False
|
||||
return len(delim_cells) == len(_split_pipe_row(header_line))
|
||||
|
||||
|
||||
def extract_markdown(
|
||||
text: str,
|
||||
*,
|
||||
image_resolver: ImageResolver,
|
||||
) -> MarkdownExtraction:
|
||||
"""Extract markdown ``text`` into heading-split blocks + side tables.
|
||||
|
||||
Image I/O and any warning counting are delegated to ``image_resolver``;
|
||||
this function stays pure (no filesystem / network).
|
||||
"""
|
||||
out = MarkdownExtraction()
|
||||
lines = text.splitlines()
|
||||
|
||||
heading_stack: list[tuple[int, str]] = [] # (level, clean_text)
|
||||
counters = {"t": 0, "e": 0, "d": 0}
|
||||
|
||||
cur_heading = PREFACE_HEADING
|
||||
cur_level = 0
|
||||
cur_parents: list[str] = []
|
||||
cur_lines: list[str] = []
|
||||
has_block_payload = False # any marker emitted in the current block
|
||||
|
||||
def _flush() -> None:
|
||||
nonlocal cur_lines, has_block_payload
|
||||
content = "\n".join(cur_lines).strip()
|
||||
if not content and not has_block_payload:
|
||||
cur_lines = []
|
||||
has_block_payload = False
|
||||
return
|
||||
out.blocks.append(
|
||||
{
|
||||
"heading": cur_heading,
|
||||
"level": cur_level,
|
||||
"parent_headings": list(cur_parents),
|
||||
"content": "\n".join(cur_lines).rstrip(),
|
||||
}
|
||||
)
|
||||
cur_lines = []
|
||||
has_block_payload = False
|
||||
|
||||
def _open(level: int, clean: str, raw: str, parents: list[str]) -> None:
|
||||
nonlocal cur_heading, cur_level, cur_parents
|
||||
cur_heading = clean
|
||||
cur_level = level
|
||||
cur_parents = parents
|
||||
cur_lines.append(render_heading_line(level, raw))
|
||||
|
||||
def _next_ref(kind: str) -> str:
|
||||
counters[kind] += 1
|
||||
return f"{kind}{counters[kind]}"
|
||||
|
||||
def _resolve_image(match: re.Match[str]) -> str:
|
||||
src = match.group("src").strip()
|
||||
if src.startswith("<") and src.endswith(">"):
|
||||
src = src[1:-1].strip()
|
||||
resolved = image_resolver.resolve(src)
|
||||
if resolved.kind == "skip":
|
||||
# Resolver already warned/counted; drop the image, keep nothing.
|
||||
return ""
|
||||
# A base64 data URL carries no meaningful reference name and would
|
||||
# bloat the sidecar (the bytes are already materialized as an asset),
|
||||
# so it is not echoed into ``src``.
|
||||
display_src = "" if src.lower().startswith("data:") else src
|
||||
ref = _next_ref("d")
|
||||
if resolved.kind == "local":
|
||||
asset_ref = resolved.asset_ref
|
||||
if asset_ref not in out.assets:
|
||||
out.assets[asset_ref] = {
|
||||
"suggested_name": resolved.suggested_name,
|
||||
"data": resolved.data,
|
||||
"fmt": resolved.fmt,
|
||||
}
|
||||
out.drawings[ref] = {
|
||||
"kind": "local",
|
||||
"asset_ref": asset_ref,
|
||||
"fmt": resolved.fmt,
|
||||
"src": display_src,
|
||||
}
|
||||
else: # external
|
||||
out.drawings[ref] = {
|
||||
"kind": "external",
|
||||
"url": resolved.url or src,
|
||||
"fmt": resolved.fmt,
|
||||
"src": display_src,
|
||||
}
|
||||
return drawing_marker(ref)
|
||||
|
||||
def _emit_inline(line: str) -> None:
|
||||
nonlocal has_block_payload
|
||||
before = len(out.drawings)
|
||||
new_line = _IMAGE_RE.sub(_resolve_image, line)
|
||||
if len(out.drawings) != before:
|
||||
has_block_payload = True
|
||||
cur_lines.append(new_line)
|
||||
|
||||
n = len(lines)
|
||||
i = 0
|
||||
fence: tuple[str, int, bool] | None = None # (char, length, is_open)
|
||||
while i < n:
|
||||
line = lines[i]
|
||||
stripped = line.strip()
|
||||
|
||||
# --- fenced code blocks: verbatim, suppress all detection ----------
|
||||
fence_match = _FENCE_RE.match(stripped)
|
||||
if fence is not None:
|
||||
cur_lines.append(line)
|
||||
if fence_match:
|
||||
ch, run = fence_match.group(1)[0], len(fence_match.group(1))
|
||||
# A closing fence is the same char, length >= opener, no info.
|
||||
if ch == fence[0] and run >= fence[1] and not fence_match.group(2):
|
||||
fence = None
|
||||
i += 1
|
||||
continue
|
||||
if fence_match:
|
||||
fence = (fence_match.group(1)[0], len(fence_match.group(1)), True)
|
||||
cur_lines.append(line)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# --- ATX heading ---------------------------------------------------
|
||||
heading_match = _HEADING_RE.match(line)
|
||||
if heading_match:
|
||||
level = len(heading_match.group(1))
|
||||
raw = heading_match.group(2)
|
||||
clean = _clean_heading(raw)
|
||||
heading_stack[:] = heading_stack[: max(level - 1, 0)]
|
||||
parents = [h for _, h in heading_stack if h]
|
||||
heading_stack.append((level, clean))
|
||||
_flush()
|
||||
_open(level, clean, raw, parents)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# --- block equation ($$ … $$) --------------------------------------
|
||||
if stripped.startswith("$$"):
|
||||
consumed, latex = _consume_block_equation(lines, i)
|
||||
if consumed > 0:
|
||||
ref = _next_ref("e")
|
||||
out.equations[ref] = latex
|
||||
cur_lines.append(equation_marker(ref))
|
||||
has_block_payload = True
|
||||
i += consumed
|
||||
continue
|
||||
|
||||
# --- HTML <table> block --------------------------------------------
|
||||
if starts_with_html_tag(stripped.lower(), "table"):
|
||||
consumed, html = _consume_html_table(lines, i)
|
||||
if consumed > 0:
|
||||
ref = _next_ref("t")
|
||||
out.tables[ref] = {"kind": "html", "html": html}
|
||||
cur_lines.append(table_marker(ref))
|
||||
has_block_payload = True
|
||||
i += consumed
|
||||
continue
|
||||
|
||||
# --- pipe table ----------------------------------------------------
|
||||
if "|" in line and i + 1 < n and _is_pipe_table_delimiter(line, lines[i + 1]):
|
||||
consumed, rows, header = _consume_pipe_table(lines, i)
|
||||
if consumed > 0:
|
||||
ref = _next_ref("t")
|
||||
out.tables[ref] = {"kind": "pipe", "rows": rows, "header": header}
|
||||
cur_lines.append(table_marker(ref))
|
||||
has_block_payload = True
|
||||
i += consumed
|
||||
continue
|
||||
|
||||
# --- plain text line (inline images resolved here) -----------------
|
||||
_emit_inline(line)
|
||||
i += 1
|
||||
|
||||
_flush()
|
||||
return out
|
||||
|
||||
|
||||
def _consume_block_equation(lines: list[str], start: int) -> tuple[int, str]:
|
||||
"""Parse a ``$$``-delimited block equation starting at ``lines[start]``.
|
||||
|
||||
Returns ``(lines_consumed, latex)`` or ``(0, "")`` when the block is not
|
||||
closed (treated as plain text by the caller). Only paragraph-level math is
|
||||
recognised: the opening line's stripped text must start with ``$$``.
|
||||
"""
|
||||
first = lines[start].strip()
|
||||
inner_first = first[2:]
|
||||
# Single-line ``$$ … $$``.
|
||||
if inner_first.rstrip().endswith("$$") and len(inner_first.rstrip()) >= 2:
|
||||
latex = inner_first.rstrip()[:-2].strip()
|
||||
return 1, latex
|
||||
# Multi-line: collect until a line whose stripped text ends with ``$$``.
|
||||
body: list[str] = []
|
||||
if inner_first.strip():
|
||||
body.append(inner_first.strip())
|
||||
j = start + 1
|
||||
while j < len(lines):
|
||||
s = lines[j].strip()
|
||||
if s.endswith("$$"):
|
||||
tail = s[:-2].strip()
|
||||
if tail:
|
||||
body.append(tail)
|
||||
return (j - start + 1), "\n".join(body).strip()
|
||||
body.append(lines[j])
|
||||
j += 1
|
||||
return 0, ""
|
||||
|
||||
|
||||
def _consume_html_table(lines: list[str], start: int) -> tuple[int, str]:
|
||||
"""Collect a ``<table>…</table>`` block (line-spanning). ``(consumed, html)``
|
||||
or ``(0, "")`` when no closing ``</table>`` is found."""
|
||||
buf: list[str] = []
|
||||
j = start
|
||||
while j < len(lines):
|
||||
buf.append(lines[j])
|
||||
if "</table>" in lines[j].lower():
|
||||
return (j - start + 1), "\n".join(buf).strip()
|
||||
j += 1
|
||||
return 0, ""
|
||||
|
||||
|
||||
def _consume_pipe_table(
|
||||
lines: list[str], start: int
|
||||
) -> tuple[int, list[list[str]], list[list[str]] | None]:
|
||||
"""Parse a GFM pipe table whose header is ``lines[start]`` and delimiter is
|
||||
``lines[start+1]``. Returns ``(consumed, body_rows, header_grid)``."""
|
||||
header = _split_pipe_row(lines[start])
|
||||
body: list[list[str]] = []
|
||||
j = start + 2 # skip header + delimiter
|
||||
while j < len(lines):
|
||||
s = lines[j].strip()
|
||||
if not s or "|" not in s:
|
||||
break
|
||||
body.append(_split_pipe_row(lines[j]))
|
||||
j += 1
|
||||
return (j - start), body, [header] if header else None
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Native Markdown IR builder: ``extract_markdown`` output → :class:`IRDoc`.
|
||||
|
||||
Input contract: the block dicts and side tables produced by
|
||||
:func:`lightrag.parser.markdown.extract.extract_markdown`, threaded through
|
||||
``NativeMarkdownParser.extract`` as ``(blocks, _, metadata)``. ``metadata``
|
||||
carries the marker→payload side tables under the ``md_*`` keys.
|
||||
|
||||
Each block's ``content`` holds self-closing markers (``<mdtable ref=…/>`` /
|
||||
``<mdequation ref=…/>`` / ``<mddrawing ref=…/>``). This builder rewrites them
|
||||
into IR placeholder tokens (``{{TBL:k}}`` / ``{{EQ:k}}`` / ``{{IMG:k}}``) and
|
||||
builds the matching :class:`IRTable` / :class:`IREquation` / :class:`IRDrawing`
|
||||
from the side tables.
|
||||
|
||||
Image bytes are carried in ``metadata["md_assets"]`` (keyed by a stable
|
||||
identity), so assets are declared as ``AssetSpec(source=bytes)`` and the writer
|
||||
materializes + deduplicates them. External-link images carry no bytes and are
|
||||
rendered verbatim through ``IRDrawing.path_override``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from lightrag.parser._html_table import (
|
||||
extract_html_table_info,
|
||||
extract_thead_html,
|
||||
html_table_inner_body,
|
||||
unwrap_html_table,
|
||||
)
|
||||
from lightrag.parser.markdown.extract import (
|
||||
DRAWING_MARKER_RE,
|
||||
EQUATION_MARKER_RE,
|
||||
PREFACE_HEADING,
|
||||
TABLE_MARKER_RE,
|
||||
)
|
||||
from lightrag.sidecar.ir import (
|
||||
AssetSpec,
|
||||
IRBlock,
|
||||
IRDoc,
|
||||
IRDrawing,
|
||||
IREquation,
|
||||
IRPosition,
|
||||
IRTable,
|
||||
)
|
||||
|
||||
|
||||
def _placeholder_keyspace() -> Callable[[str], str]:
|
||||
counter = itertools.count(1)
|
||||
return lambda prefix: f"{prefix}{next(counter)}"
|
||||
|
||||
|
||||
class NativeMarkdownIRBuilder:
|
||||
"""Translate ``extract_markdown`` output into an :class:`IRDoc`.
|
||||
|
||||
Stateless — instantiate per call. ``asset_dir_name`` is the relative name
|
||||
of ``<base>.blocks.assets/``; the writer applies it as the on-disk prefix
|
||||
via ``block_drawing_path_style="with_prefix"``.
|
||||
"""
|
||||
|
||||
def normalize(
|
||||
self,
|
||||
blocks: list[dict[str, Any]],
|
||||
*,
|
||||
document_name: str,
|
||||
asset_dir_name: str,
|
||||
parse_metadata: dict[str, Any] | None = None,
|
||||
) -> IRDoc:
|
||||
meta = parse_metadata or {}
|
||||
tables_meta: dict[str, dict] = meta.get("md_tables") or {}
|
||||
equations_meta: dict[str, str] = meta.get("md_equations") or {}
|
||||
drawings_meta: dict[str, dict] = meta.get("md_drawings") or {}
|
||||
assets_meta: dict[str, dict] = meta.get("md_assets") or {}
|
||||
|
||||
next_key = _placeholder_keyspace()
|
||||
ir_blocks: list[IRBlock] = []
|
||||
assets: list[AssetSpec] = []
|
||||
seen_asset_refs: set[str] = set()
|
||||
doc_title = ""
|
||||
|
||||
for block in blocks:
|
||||
content = block.get("content") or ""
|
||||
heading = block.get("heading") or ""
|
||||
level = int(block.get("level", 0) or 0)
|
||||
parent_headings = list(block.get("parent_headings") or [])
|
||||
|
||||
tables: list[IRTable] = []
|
||||
equations: list[IREquation] = []
|
||||
drawings: list[IRDrawing] = []
|
||||
|
||||
def _replace_table(match: "re.Match[str]") -> str:
|
||||
ref = match.group(1)
|
||||
spec = tables_meta.get(ref) or {}
|
||||
placeholder = next_key("tb")
|
||||
tables.append(_build_table(placeholder, spec))
|
||||
return f"{{{{TBL:{placeholder}}}}}"
|
||||
|
||||
def _replace_equation(match: "re.Match[str]") -> str:
|
||||
ref = match.group(1)
|
||||
latex = equations_meta.get(ref, "")
|
||||
placeholder = next_key("eq")
|
||||
equations.append(
|
||||
IREquation(placeholder_key=placeholder, latex=latex, is_block=True)
|
||||
)
|
||||
return f"{{{{EQ:{placeholder}}}}}"
|
||||
|
||||
def _replace_drawing(match: "re.Match[str]") -> str:
|
||||
ref = match.group(1)
|
||||
spec = drawings_meta.get(ref) or {}
|
||||
placeholder = next_key("im")
|
||||
drawings.append(
|
||||
_build_drawing(
|
||||
placeholder, spec, assets_meta, assets, seen_asset_refs
|
||||
)
|
||||
)
|
||||
return f"{{{{IMG:{placeholder}}}}}"
|
||||
|
||||
content = TABLE_MARKER_RE.sub(_replace_table, content)
|
||||
content = EQUATION_MARKER_RE.sub(_replace_equation, content)
|
||||
content = DRAWING_MARKER_RE.sub(_replace_drawing, content)
|
||||
|
||||
anchor = heading if heading and heading != PREFACE_HEADING else None
|
||||
positions = [IRPosition(type="heading", anchor=anchor)]
|
||||
|
||||
ir_blocks.append(
|
||||
IRBlock(
|
||||
content_template=content,
|
||||
heading=heading,
|
||||
level=level,
|
||||
parent_headings=parent_headings,
|
||||
positions=positions,
|
||||
tables=tables,
|
||||
drawings=drawings,
|
||||
equations=equations,
|
||||
)
|
||||
)
|
||||
|
||||
if not doc_title and level == 1 and heading and heading != PREFACE_HEADING:
|
||||
doc_title = heading
|
||||
|
||||
if not doc_title:
|
||||
doc_title = Path(document_name).stem or document_name
|
||||
|
||||
return IRDoc(
|
||||
document_name=document_name,
|
||||
document_format=Path(document_name).suffix.lower().lstrip("."),
|
||||
doc_title=doc_title,
|
||||
split_option={},
|
||||
blocks=ir_blocks,
|
||||
assets=assets,
|
||||
bbox_attributes=None,
|
||||
)
|
||||
|
||||
|
||||
def _build_table(placeholder: str, spec: dict) -> IRTable:
|
||||
if spec.get("kind") == "html":
|
||||
raw = str(spec.get("html") or "")
|
||||
html = unwrap_html_table(raw) or None
|
||||
body_override = html_table_inner_body(html) or None if html else None
|
||||
info = extract_html_table_info(html or "")
|
||||
return IRTable(
|
||||
placeholder_key=placeholder,
|
||||
rows=None,
|
||||
html=html,
|
||||
num_rows=info.num_rows,
|
||||
num_cols=info.num_cols,
|
||||
table_header=extract_thead_html(html or ""),
|
||||
body_override=body_override,
|
||||
)
|
||||
# pipe table
|
||||
rows = [[str(c) for c in row] for row in (spec.get("rows") or [])]
|
||||
header = spec.get("header")
|
||||
num_rows = len(rows)
|
||||
num_cols = max((len(r) for r in rows), default=0)
|
||||
if header:
|
||||
num_cols = max(num_cols, max((len(h) for h in header), default=0))
|
||||
return IRTable(
|
||||
placeholder_key=placeholder,
|
||||
rows=rows,
|
||||
html=None,
|
||||
num_rows=num_rows,
|
||||
num_cols=num_cols,
|
||||
table_header=header if header else None,
|
||||
)
|
||||
|
||||
|
||||
def _build_drawing(
|
||||
placeholder: str,
|
||||
spec: dict,
|
||||
assets_meta: dict[str, dict],
|
||||
assets: list[AssetSpec],
|
||||
seen_asset_refs: set[str],
|
||||
) -> IRDrawing:
|
||||
fmt = str(spec.get("fmt") or "")
|
||||
src = str(spec.get("src") or "")
|
||||
if spec.get("kind") == "external":
|
||||
return IRDrawing(
|
||||
placeholder_key=placeholder,
|
||||
asset_ref="",
|
||||
fmt=fmt,
|
||||
src=src,
|
||||
path_override=str(spec.get("url") or src),
|
||||
)
|
||||
asset_ref = str(spec.get("asset_ref") or "")
|
||||
if asset_ref and asset_ref not in seen_asset_refs:
|
||||
asset = assets_meta.get(asset_ref) or {}
|
||||
assets.append(
|
||||
AssetSpec(
|
||||
ref=asset_ref,
|
||||
suggested_name=str(asset.get("suggested_name") or asset_ref),
|
||||
source=asset.get("data"),
|
||||
)
|
||||
)
|
||||
seen_asset_refs.add(asset_ref)
|
||||
return IRDrawing(
|
||||
placeholder_key=placeholder,
|
||||
asset_ref=asset_ref,
|
||||
fmt=fmt,
|
||||
src=src,
|
||||
path_override=None,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["NativeMarkdownIRBuilder"]
|
||||
@@ -0,0 +1,673 @@
|
||||
"""Native Markdown engine adapter (.md / .textpack).
|
||||
|
||||
Implements the :class:`NativeParserBase` hooks for markdown input. ``.textpack``
|
||||
is a zipped TextBundle (a ``text.markdown`` plus an ``assets/`` resource dir;
|
||||
Bear / Ulysses export format); a plain ``.md`` file is parsed directly.
|
||||
|
||||
Image handling (see the parser plan):
|
||||
|
||||
- base64 data-URL images are decoded and materialized into the sidecar assets.
|
||||
- file-reference images are resolved ONLY inside a ``.textpack`` bundle, from
|
||||
the safely-extracted bundle directory (with a read-side path-traversal
|
||||
guard); a relative reference in a standalone ``.md`` is skipped + warned.
|
||||
- external ``http(s)`` images are downloaded + embedded by default
|
||||
(``NATIVE_MD_IMAGE_DOWNLOAD_ENABLED`` defaults to ``true``), SSRF/size guarded;
|
||||
a drawing is always emitted — the fetched asset on success, or an external-link
|
||||
fallback on failure. Set the flag to ``false`` to instead DROP external images
|
||||
entirely (no drawing emitted, so a doc whose only images are external links
|
||||
produces no drawings.json).
|
||||
- SVG images (base64 / textpack file / downloaded) are rasterized to PNG via
|
||||
cairosvg before entering the sidecar; if cairosvg is unavailable or rendering
|
||||
fails the image is skipped + warned.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import http.client
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from ipaddress import ip_address, ip_network
|
||||
from math import ceil
|
||||
from pathlib import Path
|
||||
from shutil import rmtree
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from lightrag.constants import NATIVE_RAW_DIR_SUFFIX, PARSER_ENGINE_NATIVE
|
||||
from lightrag.parser.external._common import raw_dir_for_parsed_dir
|
||||
from lightrag.parser.markdown.extract import ResolvedImage, extract_markdown
|
||||
from lightrag.parser.markdown.raw_cache import (
|
||||
NativeImageRawCache,
|
||||
native_md_options_signature,
|
||||
)
|
||||
from lightrag.parser.native_base import NativeParserBase
|
||||
from lightrag.utils import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lightrag.sidecar.ir import IRDoc
|
||||
|
||||
# Zip-bomb guards for .textpack extraction.
|
||||
_TEXTPACK_MAX_ENTRIES = 10_000
|
||||
_TEXTPACK_MAX_TOTAL_BYTES = 512 * 1024 * 1024 # 512 MiB uncompressed
|
||||
|
||||
# Magic-byte signatures → file extension. Authoritative for download
|
||||
# validation; ``None`` means "not a supported image".
|
||||
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
_JPEG_SIGNATURE = b"\xff\xd8\xff"
|
||||
_GIF_SIGNATURES = (b"GIF87a", b"GIF89a")
|
||||
_WEBP_RIFF = b"RIFF"
|
||||
_WEBP_TAG = b"WEBP"
|
||||
|
||||
# Content-Type values that carry no usable type signal — fall back to magic.
|
||||
_GENERIC_CONTENT_TYPES = {"", "application/octet-stream", "binary/octet-stream"}
|
||||
|
||||
|
||||
def _image_ext_from_magic(raw: bytes) -> str | None:
|
||||
"""Return the file extension for ``raw`` raster image bytes, or ``None`` if
|
||||
the bytes are not a recognised raster image (unlike
|
||||
``_vision_utils._detect_mime``, which always falls back to PNG). SVG is text,
|
||||
not a raster format, and is handled separately via :func:`_looks_like_svg`
|
||||
/ :func:`_rasterize_svg`."""
|
||||
if raw.startswith(_PNG_SIGNATURE):
|
||||
return "png"
|
||||
if raw.startswith(_JPEG_SIGNATURE):
|
||||
return "jpg"
|
||||
if any(raw.startswith(sig) for sig in _GIF_SIGNATURES):
|
||||
return "gif"
|
||||
if len(raw) >= 12 and raw[0:4] == _WEBP_RIFF and raw[8:12] == _WEBP_TAG:
|
||||
return "webp"
|
||||
return None
|
||||
|
||||
|
||||
def _looks_like_svg(raw: bytes) -> bool:
|
||||
"""True iff ``raw`` sniffs as SVG markup (an ``<svg`` tag near the start,
|
||||
possibly after an XML declaration / DOCTYPE / comments)."""
|
||||
head = raw[:4096].lstrip(b"\xef\xbb\xbf").lstrip() # drop UTF-8 BOM + space
|
||||
return b"<svg" in head[:2048].lower()
|
||||
|
||||
|
||||
# CSS absolute-length units → pixels (96 px/in). Relative units (``%``, ``em``,
|
||||
# ``ex``) depend on a viewport we do not have, so a dimension in those units is
|
||||
# treated as unresolvable and falls back to the ``viewBox``.
|
||||
_SVG_UNIT_TO_PX = {
|
||||
"": 1.0,
|
||||
"px": 1.0,
|
||||
"pt": 96.0 / 72.0,
|
||||
"pc": 16.0,
|
||||
"in": 96.0,
|
||||
"cm": 96.0 / 2.54,
|
||||
"mm": 96.0 / 25.4,
|
||||
"q": 96.0 / 25.4 / 4.0,
|
||||
}
|
||||
_SVG_LENGTH_RE = re.compile(r"^([0-9]*\.?[0-9]+)\s*([a-z%]*)$")
|
||||
|
||||
|
||||
def _svg_length_px(value: str) -> float | None:
|
||||
"""Parse a CSS/SVG length to pixels, or ``None`` for an unresolvable unit
|
||||
(``%`` / ``em`` / ``ex``) or unparseable text."""
|
||||
match = _SVG_LENGTH_RE.match(value.strip().lower())
|
||||
if not match:
|
||||
return None
|
||||
factor = _SVG_UNIT_TO_PX.get(match.group(2))
|
||||
if factor is None:
|
||||
return None
|
||||
return float(match.group(1)) * factor
|
||||
|
||||
|
||||
def _svg_pixel_dimensions(raw: bytes) -> tuple[int, int] | None:
|
||||
"""Best-effort render dimensions (px) for an SVG, from ``width``/``height``
|
||||
or, failing that, the ``viewBox``. Returns ``None`` when the root is not an
|
||||
``<svg>``, dimensions are missing/unresolvable, or the XML does not parse
|
||||
(parsed with defusedxml, which also blocks XML entity-expansion bombs)."""
|
||||
try:
|
||||
from defusedxml.ElementTree import fromstring
|
||||
|
||||
root = fromstring(raw)
|
||||
except Exception as exc: # noqa: BLE001 - malformed / hostile XML
|
||||
logger.debug("[native_md] SVG XML parse failed: %s", exc)
|
||||
return None
|
||||
if root.tag.rsplit("}", 1)[-1].lower() != "svg":
|
||||
return None
|
||||
width = _svg_length_px(root.get("width") or "")
|
||||
height = _svg_length_px(root.get("height") or "")
|
||||
if width is None or height is None:
|
||||
view_box = (root.get("viewBox") or "").replace(",", " ").split()
|
||||
if len(view_box) != 4:
|
||||
return None
|
||||
try:
|
||||
width, height = float(view_box[2]), float(view_box[3])
|
||||
except ValueError:
|
||||
return None
|
||||
if width <= 0 or height <= 0:
|
||||
return None
|
||||
return ceil(width), ceil(height)
|
||||
|
||||
|
||||
def _rasterize_svg(raw: bytes, *, max_pixels: int) -> bytes | None:
|
||||
"""Render SVG bytes to PNG bytes via cairosvg. Returns ``None`` (caller
|
||||
skips the image) when cairosvg is unavailable, rendering fails, or the SVG's
|
||||
declared canvas exceeds ``max_pixels`` — the dimension check runs *before*
|
||||
rendering so a tiny SVG declaring a huge canvas cannot blow up memory/CPU
|
||||
inside cairosvg before the output-size cap would catch it. A single bad SVG
|
||||
must not abort the whole document."""
|
||||
dims = _svg_pixel_dimensions(raw)
|
||||
if dims is None:
|
||||
logger.warning("[native_md] SVG dimensions missing/unparseable; skipping")
|
||||
return None
|
||||
width, height = dims
|
||||
if width * height > max_pixels:
|
||||
logger.warning(
|
||||
"[native_md] SVG canvas %dx%d exceeds %d px budget; skipping",
|
||||
width,
|
||||
height,
|
||||
max_pixels,
|
||||
)
|
||||
return None
|
||||
try:
|
||||
import cairosvg
|
||||
except Exception as exc: # noqa: BLE001 - optional native dep
|
||||
logger.warning(
|
||||
"[native_md] cairosvg unavailable, cannot rasterize SVG: %s", exc
|
||||
)
|
||||
return None
|
||||
try:
|
||||
return cairosvg.svg2png(bytestring=raw)
|
||||
except Exception as exc: # noqa: BLE001 - malformed / hostile SVG
|
||||
logger.warning("[native_md] SVG rasterization failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _image_bytes_and_ext(
|
||||
raw: bytes, *, max_bytes: int, max_svg_pixels: int
|
||||
) -> tuple[bytes, str] | None:
|
||||
"""Coerce ``raw`` image bytes into a sidecar-ready raster image.
|
||||
|
||||
A recognised raster image passes through unchanged; an SVG is rasterized to
|
||||
PNG — bounded *before* rendering by ``max_svg_pixels`` (declared canvas) and
|
||||
*after* by ``max_bytes`` (output size), so a hostile SVG that explodes into a
|
||||
giant bitmap is rejected rather than embedded. Returns ``(bytes, ext)`` or
|
||||
``None`` when the bytes are neither a raster image nor a convertible SVG.
|
||||
"""
|
||||
ext = _image_ext_from_magic(raw)
|
||||
if ext is not None:
|
||||
return raw, ext
|
||||
if _looks_like_svg(raw):
|
||||
png = _rasterize_svg(raw, max_pixels=max_svg_pixels)
|
||||
if png is not None and len(png) <= max_bytes and _image_ext_from_magic(png):
|
||||
return png, "png"
|
||||
return None
|
||||
|
||||
|
||||
def _env_bool(key: str, default: bool) -> bool:
|
||||
raw = os.getenv(key)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in ("1", "true", "yes", "on", "t", "y")
|
||||
|
||||
|
||||
def _env_int(key: str, default: int) -> int:
|
||||
raw = os.getenv(key)
|
||||
if raw is None or not raw.strip():
|
||||
return default
|
||||
try:
|
||||
return int(raw.strip())
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def _allowed_non_public_networks() -> list:
|
||||
"""Parse ``NATIVE_MD_IMAGE_ALLOWED_NON_PUBLIC_CIDRS`` (comma-separated
|
||||
CIDRs / IPs) into networks. Invalid tokens are warned and dropped."""
|
||||
raw = os.getenv("NATIVE_MD_IMAGE_ALLOWED_NON_PUBLIC_CIDRS", "")
|
||||
nets = []
|
||||
for token in raw.split(","):
|
||||
token = token.strip()
|
||||
if not token:
|
||||
continue
|
||||
try:
|
||||
nets.append(ip_network(token, strict=False))
|
||||
except ValueError:
|
||||
logger.warning("[native_md] ignoring invalid allowed CIDR: %s", token)
|
||||
return nets
|
||||
|
||||
|
||||
def _validated_addresses(host: str) -> list[str]:
|
||||
"""Resolve ``host`` and return its addresses iff ALL are safe to fetch.
|
||||
|
||||
Default-deny: an address is accepted only when ``ip.is_global`` (so SSRF to
|
||||
loopback / private / link-local / reserved / CGNAT ``100.64.0.0/10`` /
|
||||
TEST-NET and any other non-globally-routable range is blocked) or it matches
|
||||
the operator-configured ``NATIVE_MD_IMAGE_ALLOWED_NON_PUBLIC_CIDRS`` escape
|
||||
hatch. Returns ``[]`` when resolution fails or *any* resolved address is
|
||||
non-public — so a single poisoned A/AAAA record rejects the whole host.
|
||||
|
||||
The returned addresses are what the connection actually dials (see
|
||||
:class:`_GuardedHTTPConnection`): validating and connecting share one
|
||||
resolution, closing the DNS-rebinding TOCTOU window between check and
|
||||
connect.
|
||||
"""
|
||||
allow = _allowed_non_public_networks()
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, None)
|
||||
except socket.gaierror:
|
||||
return []
|
||||
addrs: list[str] = []
|
||||
for info in infos:
|
||||
addr = str(info[4][0]).split("%", 1)[0]
|
||||
try:
|
||||
ip = ip_address(addr)
|
||||
except ValueError:
|
||||
return []
|
||||
if not (ip.is_global or any(ip in net for net in allow)):
|
||||
return []
|
||||
addrs.append(addr)
|
||||
return addrs
|
||||
|
||||
|
||||
def _host_is_public(host: str) -> bool:
|
||||
"""True iff every resolved address for ``host`` is safe to fetch."""
|
||||
return bool(_validated_addresses(host))
|
||||
|
||||
|
||||
def _pin_socket(host: str, port: int, timeout, source_address):
|
||||
"""Open a TCP socket to a *validated* resolved address of ``host``.
|
||||
|
||||
The address comes from the same :func:`_validated_addresses` resolution
|
||||
that authorised the host, so urllib never independently re-resolves the
|
||||
name (which a DNS-rebinding attacker could flip to an internal IP between
|
||||
our check and the actual connect)."""
|
||||
addrs = _validated_addresses(host)
|
||||
if not addrs:
|
||||
raise OSError(f"refusing connection to non-public host: {host!r}")
|
||||
sock = socket.create_connection((addrs[0], port), timeout, source_address)
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
return sock
|
||||
|
||||
|
||||
# No proxy/CONNECT-tunnel handling below: the opener disables proxies, so the
|
||||
# connection always dials the origin directly (``_tunnel_host`` is never set).
|
||||
class _GuardedHTTPConnection(http.client.HTTPConnection):
|
||||
def connect(self) -> None:
|
||||
self.sock = _pin_socket(self.host, self.port, self.timeout, self.source_address)
|
||||
|
||||
|
||||
class _GuardedHTTPSConnection(http.client.HTTPSConnection):
|
||||
def connect(self) -> None:
|
||||
sock = _pin_socket(self.host, self.port, self.timeout, self.source_address)
|
||||
# Wrap with the original hostname so SNI / certificate validation still
|
||||
# runs against the domain, not the pinned IP.
|
||||
self.sock = self._context.wrap_socket(sock, server_hostname=self.host)
|
||||
|
||||
|
||||
class _GuardedHTTPHandler(urllib.request.HTTPHandler):
|
||||
def http_open(self, req):
|
||||
return self.do_open(_GuardedHTTPConnection, req)
|
||||
|
||||
|
||||
class _GuardedHTTPSHandler(urllib.request.HTTPSHandler):
|
||||
def https_open(self, req):
|
||||
# Mirror the stdlib handler's own arguments, which differ across
|
||||
# versions: Python <3.12 stores ``_check_hostname`` on the handler and
|
||||
# forwards it; 3.12+ folds it into ``_context`` and drops the attribute.
|
||||
# Forwarding a non-existent ``_check_hostname`` raised AttributeError on
|
||||
# 3.12, silently failing every download into the external-link fallback.
|
||||
kwargs = {"context": self._context}
|
||||
if hasattr(self, "_check_hostname"):
|
||||
kwargs["check_hostname"] = self._check_hostname
|
||||
return self.do_open(_GuardedHTTPSConnection, req, **kwargs)
|
||||
|
||||
|
||||
class _GuardedRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
"""Re-validate the host on every redirect so a redirect cannot bounce the
|
||||
request to an internal address. (Defense in depth: the pinned connection
|
||||
re-validates at connect time too.)"""
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[override]
|
||||
host = urlparse(newurl).hostname or ""
|
||||
if not _host_is_public(host):
|
||||
raise urllib.error.HTTPError(
|
||||
newurl, code, "redirect to non-public host blocked", headers, fp
|
||||
)
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
def _build_guarded_opener() -> urllib.request.OpenerDirector:
|
||||
"""Opener whose connections pin to a validated IP and that ignores any
|
||||
ambient ``HTTP(S)_PROXY`` (an env proxy would otherwise fetch the blocked
|
||||
URL on our behalf, bypassing the IP guard)."""
|
||||
return urllib.request.build_opener(
|
||||
urllib.request.ProxyHandler({}),
|
||||
_GuardedHTTPHandler(),
|
||||
_GuardedHTTPSHandler(),
|
||||
_GuardedRedirectHandler(),
|
||||
)
|
||||
|
||||
|
||||
class _MarkdownImageResolver:
|
||||
"""Concrete :class:`~lightrag.parser.markdown.extract.ImageResolver`.
|
||||
|
||||
Caches by ``src`` so a repeated reference downloads / decodes once and
|
||||
every occurrence shares one on-disk asset.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bundle_root: Path | None,
|
||||
warnings: dict[str, int],
|
||||
download_enabled: bool,
|
||||
download_required: bool,
|
||||
timeout: int,
|
||||
max_bytes: int,
|
||||
max_svg_pixels: int,
|
||||
raw_cache: NativeImageRawCache | None = None,
|
||||
) -> None:
|
||||
self._bundle_root = bundle_root.resolve() if bundle_root else None
|
||||
self._warnings = warnings
|
||||
self._download_enabled = download_enabled
|
||||
self._download_required = download_required
|
||||
self._timeout = timeout
|
||||
self._max_bytes = max_bytes
|
||||
self._max_svg_pixels = max_svg_pixels
|
||||
self._raw_cache = raw_cache
|
||||
self._cache: dict[str, ResolvedImage] = {}
|
||||
|
||||
def resolve(self, src: str) -> ResolvedImage:
|
||||
cached = self._cache.get(src)
|
||||
if cached is not None:
|
||||
return cached
|
||||
result = self._resolve_uncached(src)
|
||||
self._cache[src] = result
|
||||
return result
|
||||
|
||||
def _bump(self, key: str) -> None:
|
||||
self._warnings[key] = self._warnings.get(key, 0) + 1
|
||||
|
||||
def _skip(self, reason: str, src: str) -> ResolvedImage:
|
||||
logger.warning("[native_md] skipping image (%s): %s", reason, src[:120])
|
||||
self._bump("images_skipped")
|
||||
return ResolvedImage(kind="skip")
|
||||
|
||||
def _local(self, data: bytes, ext: str, suggested_name: str) -> ResolvedImage:
|
||||
asset_ref = "sha256:" + hashlib.sha256(data).hexdigest()
|
||||
return ResolvedImage(
|
||||
kind="local",
|
||||
asset_ref=asset_ref,
|
||||
data=data,
|
||||
suggested_name=suggested_name,
|
||||
fmt=ext,
|
||||
)
|
||||
|
||||
def _resolve_uncached(self, src: str) -> ResolvedImage:
|
||||
lower = src.lower()
|
||||
if lower.startswith("data:"):
|
||||
return self._resolve_data_url(src)
|
||||
if lower.startswith(("http://", "https://")):
|
||||
return self._resolve_remote(src)
|
||||
return self._resolve_relative(src)
|
||||
|
||||
def _resolve_data_url(self, src: str) -> ResolvedImage:
|
||||
# ``data:[<mime>][;base64],<payload>`` — only base64 image payloads.
|
||||
if ";base64," not in src:
|
||||
return self._skip("data url not base64", src)
|
||||
_, _, payload = src.partition(";base64,")
|
||||
try:
|
||||
data = base64.b64decode("".join(payload.split()), validate=True)
|
||||
except (ValueError, binascii.Error):
|
||||
return self._skip("invalid base64", src)
|
||||
# Magic bytes are authoritative — the declared MIME type is not trusted
|
||||
# for validation (matching the remote-download path). SVG is rasterized
|
||||
# to PNG here.
|
||||
coerced = _image_bytes_and_ext(
|
||||
data, max_bytes=self._max_bytes, max_svg_pixels=self._max_svg_pixels
|
||||
)
|
||||
if coerced is None:
|
||||
return self._skip("unrecognised image", src)
|
||||
data, ext = coerced
|
||||
digest = hashlib.sha256(data).hexdigest()[:12]
|
||||
return self._local(data, ext, f"image-{digest}.{ext}")
|
||||
|
||||
def _resolve_relative(self, src: str) -> ResolvedImage:
|
||||
if self._bundle_root is None:
|
||||
# Standalone .md: file references are unresolved by design.
|
||||
return self._skip("file reference outside .textpack", src)
|
||||
rel = unquote(src.split("#", 1)[0].split("?", 1)[0])
|
||||
if not rel or "\\" in rel or rel.startswith("/") or os.path.isabs(rel):
|
||||
return self._skip("unsafe image path", src)
|
||||
if any(part == ".." for part in Path(rel).parts):
|
||||
return self._skip("unsafe image path", src)
|
||||
candidate = (self._bundle_root / rel).resolve()
|
||||
if not candidate.is_relative_to(self._bundle_root):
|
||||
return self._skip("image path escapes bundle", src)
|
||||
if not candidate.is_file():
|
||||
return self._skip("image file missing", src)
|
||||
# Cap a single bundled asset at the same ceiling as a remote download,
|
||||
# so one oversized file inside the (zip-bomb-bounded) bundle cannot pull
|
||||
# hundreds of MB into memory.
|
||||
if candidate.stat().st_size > self._max_bytes:
|
||||
return self._skip("image exceeds size limit", src)
|
||||
data = candidate.read_bytes()
|
||||
# Magic bytes are authoritative; the filename suffix is not trusted for
|
||||
# validation. SVG is rasterized to PNG (so the on-disk name takes the
|
||||
# resolved extension, e.g. ``logo.svg`` -> ``logo.png``).
|
||||
coerced = _image_bytes_and_ext(
|
||||
data, max_bytes=self._max_bytes, max_svg_pixels=self._max_svg_pixels
|
||||
)
|
||||
if coerced is None:
|
||||
return self._skip("unrecognised image", src)
|
||||
data, ext = coerced
|
||||
return self._local(data, ext, f"{candidate.stem}.{ext}")
|
||||
|
||||
def _resolve_remote(self, src: str) -> ResolvedImage:
|
||||
ext_hint = Path(urlparse(src).path).suffix.lower().lstrip(".")
|
||||
if not self._download_enabled:
|
||||
# Downloading is opt-in: with it disabled, external images are
|
||||
# dropped entirely (no drawing emitted), so a doc whose only images
|
||||
# are external links produces no drawings.json. This is expected
|
||||
# configuration, not a problem, so it is logged at debug level and
|
||||
# counted under a dedicated key rather than warned per image.
|
||||
logger.debug(
|
||||
"[native_md] dropping external image (download disabled): %s", src[:120]
|
||||
)
|
||||
self._bump("images_external_dropped")
|
||||
return ResolvedImage(kind="skip")
|
||||
if self._raw_cache is not None:
|
||||
hit = self._raw_cache.get(src)
|
||||
if hit is not None:
|
||||
# Reuse the cached bytes (already post-SVG-rasterization), so a
|
||||
# re-parse skips both the network fetch and the rasterization.
|
||||
data, ext = hit
|
||||
self._bump("images_cache_hit")
|
||||
digest = hashlib.sha256(data).hexdigest()[:12]
|
||||
return self._local(data, ext, f"image-{digest}.{ext}")
|
||||
try:
|
||||
data, ext = self._download(src)
|
||||
except Exception as exc: # noqa: BLE001 - best-effort network fetch
|
||||
if self._download_required:
|
||||
raise
|
||||
logger.warning("[native_md] image download failed (%s): %s", exc, src[:120])
|
||||
self._bump("images_download_failed")
|
||||
return ResolvedImage(kind="external", url=src, fmt=ext_hint)
|
||||
if self._raw_cache is not None:
|
||||
self._raw_cache.put(src, data, ext)
|
||||
digest = hashlib.sha256(data).hexdigest()[:12]
|
||||
return self._local(data, ext, f"image-{digest}.{ext}")
|
||||
|
||||
def _download(self, src: str) -> tuple[bytes, str]:
|
||||
parsed = urlparse(src)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError(f"unsupported scheme: {parsed.scheme!r}")
|
||||
if not _host_is_public(parsed.hostname or ""):
|
||||
raise ValueError("non-public host blocked")
|
||||
opener = _build_guarded_opener()
|
||||
req = urllib.request.Request(src, headers={"User-Agent": "lightrag-native-md"})
|
||||
with opener.open(req, timeout=self._timeout) as resp:
|
||||
content_type = (resp.headers.get_content_type() or "").lower()
|
||||
# Read at most max_bytes + 1 so we can detect an over-limit body.
|
||||
data = resp.read(self._max_bytes + 1)
|
||||
if len(data) > self._max_bytes:
|
||||
raise ValueError(f"image exceeds {self._max_bytes} bytes")
|
||||
# Magic bytes are authoritative; SVG is rasterized to PNG here. Reject
|
||||
# only an *explicit* non-image Content-Type — but ``image/svg+xml`` is a
|
||||
# valid image type, so the check stays correct for converted SVGs.
|
||||
if (
|
||||
content_type
|
||||
and content_type not in _GENERIC_CONTENT_TYPES
|
||||
and not content_type.startswith("image/")
|
||||
):
|
||||
raise ValueError(f"non-image Content-Type: {content_type!r}")
|
||||
coerced = _image_bytes_and_ext(
|
||||
data, max_bytes=self._max_bytes, max_svg_pixels=self._max_svg_pixels
|
||||
)
|
||||
if coerced is None:
|
||||
raise ValueError("downloaded bytes are not a supported image")
|
||||
return coerced
|
||||
|
||||
|
||||
class NativeMarkdownParser(NativeParserBase):
|
||||
engine_name = PARSER_ENGINE_NATIVE
|
||||
empty_content_label = "Markdown"
|
||||
|
||||
def validate_source(self, source: Path, file_path: str) -> None:
|
||||
if not (
|
||||
source.exists()
|
||||
and source.is_file()
|
||||
and source.suffix.lower() in (".md", ".textpack")
|
||||
):
|
||||
raise ValueError(
|
||||
f"Native markdown parser does not support pending file: {file_path}"
|
||||
)
|
||||
|
||||
def extract(
|
||||
self, source: Path, *, parsed_dir: Path, asset_dir: Path, base_name: str
|
||||
) -> tuple[list[dict[str, Any]], dict[str, Any], dict[str, Any]]:
|
||||
# Per-document downloaded-image cache. Lives in a ``<file>.native_raw/``
|
||||
# sibling of ``parsed_dir`` so it survives the ``rmtree(parsed_dir)`` the
|
||||
# base parser runs before each re-extraction; reused across re-parses
|
||||
# unless the source content or download config changed.
|
||||
raw_cache = NativeImageRawCache(
|
||||
raw_dir_for_parsed_dir(parsed_dir, suffix=NATIVE_RAW_DIR_SUFFIX),
|
||||
source_path=source,
|
||||
options_signature=native_md_options_signature(),
|
||||
force_reparse=_env_bool("LIGHTRAG_FORCE_REPARSE_NATIVE", False),
|
||||
)
|
||||
raw_cache.load()
|
||||
if source.suffix.lower() == ".textpack":
|
||||
tmp_dir = Path(tempfile.mkdtemp(prefix="textpack-"))
|
||||
try:
|
||||
md_text, bundle_root = self._open_textpack(source, tmp_dir)
|
||||
result = self._extract_text(
|
||||
md_text, bundle_root=bundle_root, raw_cache=raw_cache
|
||||
)
|
||||
finally:
|
||||
rmtree(tmp_dir, ignore_errors=True)
|
||||
else:
|
||||
md_text = source.read_bytes().decode("utf-8-sig")
|
||||
result = self._extract_text(md_text, bundle_root=None, raw_cache=raw_cache)
|
||||
# Flush only on successful extraction so a transient failure cannot prune
|
||||
# a previously-valid bundle (an exception propagates before this line).
|
||||
raw_cache.flush()
|
||||
return result
|
||||
|
||||
def _open_textpack(self, source: Path, tmp_dir: Path) -> tuple[str, Path]:
|
||||
from lightrag.parser.external._zip import safe_extract_zip
|
||||
|
||||
safe_extract_zip(
|
||||
source.read_bytes(),
|
||||
tmp_dir,
|
||||
max_entries=_TEXTPACK_MAX_ENTRIES,
|
||||
max_total_bytes=_TEXTPACK_MAX_TOTAL_BYTES,
|
||||
)
|
||||
text_file = self._find_text_file(tmp_dir, source.name)
|
||||
return text_file.read_bytes().decode("utf-8-sig"), text_file.parent
|
||||
|
||||
@staticmethod
|
||||
def _find_text_file(root: Path, source_name: str) -> Path:
|
||||
# The body is located by extension, not a fixed ``text.markdown`` name,
|
||||
# so any zip tool can produce a valid textpack. Layout rules:
|
||||
# * If the archive holds a ``*.textbundle`` directory, exactly one is
|
||||
# allowed and the body must live directly inside it.
|
||||
# * Otherwise the body must live directly in the archive root.
|
||||
# * The chosen directory must hold exactly one ``*.md``/``*.markdown``.
|
||||
# ``bundle_root`` (the returned file's parent) anchors asset resolution.
|
||||
bundles = sorted(
|
||||
p
|
||||
for p in root.iterdir()
|
||||
if p.is_dir() and p.suffix.lower() == ".textbundle"
|
||||
)
|
||||
if len(bundles) > 1:
|
||||
names = ", ".join(p.name for p in bundles)
|
||||
raise ValueError(
|
||||
f"multiple .textbundle directories in textpack: {source_name} ({names})"
|
||||
)
|
||||
search_dir = bundles[0] if bundles else root
|
||||
markdown = sorted(
|
||||
p
|
||||
for p in search_dir.iterdir()
|
||||
if p.is_file() and p.suffix.lower() in (".md", ".markdown")
|
||||
)
|
||||
if len(markdown) > 1:
|
||||
names = ", ".join(p.name for p in markdown)
|
||||
raise ValueError(
|
||||
f"multiple markdown files in textpack: {source_name} ({names})"
|
||||
)
|
||||
if not markdown:
|
||||
raise ValueError(f"no markdown text file found in textpack: {source_name}")
|
||||
return markdown[0]
|
||||
|
||||
def _extract_text(
|
||||
self,
|
||||
md_text: str,
|
||||
*,
|
||||
bundle_root: Path | None,
|
||||
raw_cache: NativeImageRawCache | None = None,
|
||||
) -> tuple[list[dict[str, Any]], dict[str, Any], dict[str, Any]]:
|
||||
warnings: dict[str, int] = {}
|
||||
resolver = _MarkdownImageResolver(
|
||||
bundle_root=bundle_root,
|
||||
warnings=warnings,
|
||||
download_enabled=_env_bool("NATIVE_MD_IMAGE_DOWNLOAD_ENABLED", True),
|
||||
download_required=_env_bool("NATIVE_MD_IMAGE_DOWNLOAD_REQUIRED", False),
|
||||
timeout=_env_int("NATIVE_MD_IMAGE_DOWNLOAD_TIMEOUT", 30),
|
||||
max_bytes=_env_int("NATIVE_MD_IMAGE_MAX_BYTES", 25 * 1024 * 1024),
|
||||
max_svg_pixels=_env_int("NATIVE_MD_IMAGE_MAX_SVG_PIXELS", 16_000_000),
|
||||
raw_cache=raw_cache,
|
||||
)
|
||||
extraction = extract_markdown(md_text, image_resolver=resolver)
|
||||
metadata: dict[str, Any] = {
|
||||
"md_tables": extraction.tables,
|
||||
"md_equations": extraction.equations,
|
||||
"md_drawings": extraction.drawings,
|
||||
"md_assets": extraction.assets,
|
||||
}
|
||||
return extraction.blocks, dict(warnings), metadata
|
||||
|
||||
def build_ir(
|
||||
self,
|
||||
blocks: list[dict[str, Any]],
|
||||
*,
|
||||
document_name: str,
|
||||
asset_dir_name: str,
|
||||
metadata: dict[str, Any],
|
||||
) -> "IRDoc":
|
||||
from lightrag.parser.markdown.ir_builder import NativeMarkdownIRBuilder
|
||||
|
||||
return NativeMarkdownIRBuilder().normalize(
|
||||
blocks,
|
||||
document_name=document_name,
|
||||
asset_dir_name=asset_dir_name,
|
||||
parse_metadata=metadata,
|
||||
)
|
||||
|
||||
def surface_warnings(
|
||||
self, warnings: dict[str, Any], source: Path
|
||||
) -> dict[str, Any] | None:
|
||||
relevant = {k: v for k, v in warnings.items() if v}
|
||||
return relevant or None
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Per-document download cache for native-markdown external images.
|
||||
|
||||
Mirrors the ``.mineru_raw`` / ``.docling_raw`` raw-bundle pattern, scoped to the
|
||||
one expensive native-markdown step: downloading external ``http(s)`` images.
|
||||
The bundle lives in a ``<file>.native_raw/`` directory that is a **sibling** of
|
||||
``<file>.parsed/`` so it survives the ``rmtree(parsed_dir)`` that
|
||||
:meth:`NativeParserBase.parse` performs before every re-extraction.
|
||||
|
||||
Bundle layout::
|
||||
|
||||
<file>.native_raw/
|
||||
_manifest.json # atomic success marker + cache key
|
||||
<sha256(url)[:16]>.png # one file per cached image (final bytes)
|
||||
...
|
||||
|
||||
The manifest is the cache key: a bundle is reused only when the source file
|
||||
content hash AND the download-options signature both still match, mirroring the
|
||||
external engines. On a hit the resolver reuses the stored bytes (already
|
||||
post-SVG-rasterization), skipping both the network fetch and the rasterization.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from lightrag.parser.external._common import (
|
||||
clear_dir_contents,
|
||||
compute_size_and_hash,
|
||||
)
|
||||
from lightrag.utils import logger
|
||||
|
||||
_MANIFEST_FILENAME = "_manifest.json"
|
||||
_MANIFEST_VERSION = "1"
|
||||
_CACHE_ENGINE = "native_md"
|
||||
|
||||
|
||||
def native_md_options_signature() -> str:
|
||||
"""A ``sha256`` over the download knobs that change an image's bytes.
|
||||
|
||||
Deliberately excludes ``NATIVE_MD_IMAGE_DOWNLOAD_ENABLED`` /
|
||||
``..._TIMEOUT`` / ``..._REQUIRED`` — those gate *whether* a fetch happens,
|
||||
not the resulting bytes — and includes the size / SVG-pixel ceilings and the
|
||||
SSRF allowlist (which govern what bytes are accepted at all)."""
|
||||
payload = {
|
||||
"signature_version": 1,
|
||||
"max_bytes": os.getenv("NATIVE_MD_IMAGE_MAX_BYTES", ""),
|
||||
"max_svg_pixels": os.getenv("NATIVE_MD_IMAGE_MAX_SVG_PIXELS", ""),
|
||||
"allowed_non_public_cidrs": os.getenv(
|
||||
"NATIVE_MD_IMAGE_ALLOWED_NON_PUBLIC_CIDRS", ""
|
||||
),
|
||||
}
|
||||
raw = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
return "sha256:" + hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _url_filename(url: str, fmt: str) -> str:
|
||||
digest = hashlib.sha256(url.encode("utf-8")).hexdigest()[:16]
|
||||
ext = fmt or "bin"
|
||||
return f"{digest}.{ext}"
|
||||
|
||||
|
||||
class NativeImageRawCache:
|
||||
"""Reuse already-downloaded external images across re-parses.
|
||||
|
||||
Per-document and single-writer: each parse owns one ``raw_dir`` and the
|
||||
parse queue serializes work per document, so no locking is needed.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
raw_dir: Path,
|
||||
*,
|
||||
source_path: Path,
|
||||
options_signature: str,
|
||||
force_reparse: bool,
|
||||
) -> None:
|
||||
self._raw_dir = raw_dir
|
||||
self._source_path = source_path
|
||||
self._options_signature = options_signature
|
||||
self._force_reparse = force_reparse
|
||||
self._source_hash = ""
|
||||
self._valid = False
|
||||
self._cleared = False
|
||||
# ``_dirty`` gates the manifest write: it is set only when a real
|
||||
# download is stored (:meth:`put`). A run that only reuses cached images
|
||||
# (a pure hit) leaves it ``False`` so :meth:`flush` touches nothing on
|
||||
# disk — the bundle's mtimes then reveal whether the cache was hit.
|
||||
self._dirty = False
|
||||
# Index of reusable entries from a valid prior bundle (url -> entry).
|
||||
self._index: dict[str, dict] = {}
|
||||
# Entries referenced this run (reused or freshly put) -> manifest output.
|
||||
self._entries: dict[str, dict] = {}
|
||||
|
||||
def load(self) -> None:
|
||||
"""Compute the current source hash and decide whether the on-disk
|
||||
bundle is a cache hit (valid) or must be rebuilt."""
|
||||
try:
|
||||
_, self._source_hash = compute_size_and_hash(self._source_path)
|
||||
except OSError as exc:
|
||||
logger.debug("[native_md_cache] source hash failed: %s", exc)
|
||||
self._source_hash = ""
|
||||
if self._force_reparse:
|
||||
return
|
||||
manifest = self._read_manifest()
|
||||
if manifest is None:
|
||||
return
|
||||
if (
|
||||
manifest.get("source_content_hash") != self._source_hash
|
||||
or manifest.get("options_signature") != self._options_signature
|
||||
):
|
||||
return
|
||||
images = manifest.get("images")
|
||||
if not isinstance(images, dict):
|
||||
return
|
||||
self._index = {k: v for k, v in images.items() if isinstance(v, dict)}
|
||||
self._valid = True
|
||||
|
||||
def get(self, url: str) -> tuple[bytes, str] | None:
|
||||
"""Return ``(bytes, fmt)`` for a cached image, or ``None`` on a miss /
|
||||
integrity failure (corrupt or tampered cache file)."""
|
||||
if not self._valid:
|
||||
return None
|
||||
entry = self._index.get(url)
|
||||
if not entry:
|
||||
return None
|
||||
file_name = str(entry.get("file") or "")
|
||||
if not file_name:
|
||||
return None
|
||||
path = self._raw_dir / file_name
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
if "sha256:" + hashlib.sha256(data).hexdigest() != entry.get("sha256"):
|
||||
logger.warning("[native_md_cache] cached file integrity mismatch: %s", url)
|
||||
return None
|
||||
fmt = str(entry.get("fmt") or "")
|
||||
self._entries[url] = entry
|
||||
return data, fmt
|
||||
|
||||
def put(self, url: str, data: bytes, fmt: str) -> None:
|
||||
"""Store freshly-downloaded image bytes and record them for the manifest."""
|
||||
self._ensure_writable_dir()
|
||||
file_name = _url_filename(url, fmt)
|
||||
try:
|
||||
(self._raw_dir / file_name).write_bytes(data)
|
||||
except OSError as exc:
|
||||
logger.warning("[native_md_cache] failed to write cache file: %s", exc)
|
||||
return
|
||||
self._entries[url] = {
|
||||
"file": file_name,
|
||||
"sha256": "sha256:" + hashlib.sha256(data).hexdigest(),
|
||||
"size": len(data),
|
||||
"fmt": fmt,
|
||||
}
|
||||
self._dirty = True
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Persist the bundle only if it actually changed this run.
|
||||
|
||||
Skipped entirely on a **pure cache hit** (every image reused, nothing
|
||||
downloaded) — the source is byte-identical, so the referenced image set
|
||||
equals the on-disk one and a rewrite would only be an idempotent write.
|
||||
Leaving the manifest and image files untouched means their mtimes flag a
|
||||
miss/update vs. a hit. Also a no-op for an image-less or
|
||||
download-disabled run, so a pre-existing valid bundle is left intact."""
|
||||
if not self._dirty:
|
||||
return
|
||||
self._ensure_writable_dir()
|
||||
referenced = {e["file"] for e in self._entries.values()}
|
||||
for child in self._raw_dir.iterdir():
|
||||
if child.name == _MANIFEST_FILENAME:
|
||||
continue
|
||||
if child.is_file() and child.name not in referenced:
|
||||
try:
|
||||
child.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
manifest = {
|
||||
"version": _MANIFEST_VERSION,
|
||||
"engine": _CACHE_ENGINE,
|
||||
"source_content_hash": self._source_hash,
|
||||
"options_signature": self._options_signature,
|
||||
"images": self._entries,
|
||||
}
|
||||
final = self._raw_dir / _MANIFEST_FILENAME
|
||||
tmp = final.with_suffix(".json.tmp")
|
||||
try:
|
||||
tmp.write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
os.replace(tmp, final)
|
||||
except OSError as exc:
|
||||
logger.warning("[native_md_cache] failed to write manifest: %s", exc)
|
||||
|
||||
def _ensure_writable_dir(self) -> None:
|
||||
"""Create ``raw_dir`` and, on the first write of an invalidated bundle,
|
||||
drop the stale contents so reused entries never mingle with old ones."""
|
||||
self._raw_dir.mkdir(parents=True, exist_ok=True)
|
||||
if not self._valid and not self._cleared:
|
||||
clear_dir_contents(self._raw_dir)
|
||||
self._cleared = True
|
||||
|
||||
def _read_manifest(self) -> dict | None:
|
||||
path = self._raw_dir / _MANIFEST_FILENAME
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
if payload.get("version") != _MANIFEST_VERSION:
|
||||
return None
|
||||
if payload.get("engine") != _CACHE_ENGINE:
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
__all__ = ["NativeImageRawCache", "native_md_options_signature"]
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Shared template for native (local, in-process) parser engines.
|
||||
|
||||
``NativeParserBase.parse`` fixes the common local-parse flow once:
|
||||
|
||||
resolve + validate source → compute parsed_dir/asset_dir
|
||||
→ pre-clean (rmtree parsed_dir + mkdir + mkdir asset_dir, with rollback)
|
||||
→ extract() in a thread → build_ir() → write_sidecar(clean_parsed_dir=False)
|
||||
→ persist full_docs (lightrag) → archive source
|
||||
|
||||
Subclasses implement ``extract`` (sync, runs in a thread) and ``build_ir``.
|
||||
Currently only :class:`NativeDocxParser`; xlsx/pptx/md land later as new
|
||||
subclasses implementing the same two hooks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
import time
|
||||
from abc import abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from lightrag.constants import FULL_DOCS_FORMAT_LIGHTRAG
|
||||
from lightrag.parser.base import BaseParser, ParseContext, ParseResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lightrag.sidecar.ir import IRDoc
|
||||
|
||||
|
||||
class NativeParserBase(BaseParser):
|
||||
"""Base for engines that parse a file locally into a sidecar."""
|
||||
|
||||
# ``write_sidecar`` block_drawing_path_style; docx keeps the legacy
|
||||
# "basename_only" shape for byte-equivalence.
|
||||
sidecar_path_style: str = "with_prefix"
|
||||
# Prefix used in the "empty content" error message.
|
||||
empty_content_label: str = "Native"
|
||||
|
||||
# --- engine-private hooks ------------------------------------------------
|
||||
def validate_source(self, source: Path, file_path: str) -> None:
|
||||
"""Validate the resolved source (default: must be an existing file)."""
|
||||
if not (source.exists() and source.is_file()):
|
||||
raise FileNotFoundError(
|
||||
f"{self.engine_name} source file not found: {source}"
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def extract(
|
||||
self, source: Path, *, parsed_dir: Path, asset_dir: Path, base_name: str
|
||||
) -> tuple[list[dict[str, Any]], dict[str, Any], dict[str, Any]]:
|
||||
"""Extract ``(blocks, warnings, metadata)`` (sync; runs in a thread).
|
||||
|
||||
``parsed_dir`` and ``asset_dir`` are pre-created by the template; the
|
||||
hook may write side artifacts (e.g. image bytes) into ``asset_dir``
|
||||
before :func:`write_sidecar` runs with ``clean_parsed_dir=False``.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def build_ir(
|
||||
self,
|
||||
blocks: list[dict[str, Any]],
|
||||
*,
|
||||
document_name: str,
|
||||
asset_dir_name: str,
|
||||
metadata: dict[str, Any],
|
||||
) -> "IRDoc": ...
|
||||
|
||||
def surface_warnings(
|
||||
self, warnings: dict[str, Any], source: Path
|
||||
) -> dict[str, Any] | None:
|
||||
"""Map parser warnings to the ``parse_warnings`` result field (opt)."""
|
||||
return None
|
||||
|
||||
# --- template ------------------------------------------------------------
|
||||
async def parse(self, ctx: ParseContext) -> ParseResult:
|
||||
from lightrag.sidecar import write_sidecar
|
||||
from lightrag.utils_pipeline import (
|
||||
make_lightrag_doc_content,
|
||||
sidecar_uri_for,
|
||||
)
|
||||
|
||||
rs = ctx.resolve(self.engine_name)
|
||||
source = rs.source_path
|
||||
self.validate_source(source, ctx.file_path)
|
||||
|
||||
document_name = rs.document_name
|
||||
base_name = Path(document_name).stem or document_name
|
||||
parsed_dir = rs.parsed_dir
|
||||
asset_dir = parsed_dir / f"{base_name}.blocks.assets"
|
||||
|
||||
def _extract_sync():
|
||||
# Pre-clean parsed_dir and pre-create asset_dir so the extractor
|
||||
# can write image bytes BEFORE write_sidecar (clean_parsed_dir=False
|
||||
# then keeps them). parsed_artifact_dir_for returns a unique dir per
|
||||
# source, so this rmtree only clobbers a prior attempt's artifacts.
|
||||
if parsed_dir.exists():
|
||||
shutil.rmtree(parsed_dir)
|
||||
parsed_dir.mkdir(parents=True, exist_ok=True)
|
||||
asset_dir.mkdir(parents=True, exist_ok=True)
|
||||
return self.extract(
|
||||
source, parsed_dir=parsed_dir, asset_dir=asset_dir, base_name=base_name
|
||||
)
|
||||
|
||||
try:
|
||||
blocks, warnings, metadata = await asyncio.to_thread(_extract_sync)
|
||||
except BaseException:
|
||||
# Roll back the pre-created (possibly partial) dirs on any failure.
|
||||
if parsed_dir.exists():
|
||||
shutil.rmtree(parsed_dir, ignore_errors=True)
|
||||
raise
|
||||
if not blocks:
|
||||
if parsed_dir.exists():
|
||||
shutil.rmtree(parsed_dir, ignore_errors=True)
|
||||
raise ValueError(
|
||||
f"{self.empty_content_label} parser returned empty content "
|
||||
f"for {ctx.file_path}"
|
||||
)
|
||||
|
||||
parse_warnings = self.surface_warnings(warnings, source)
|
||||
ir = self.build_ir(
|
||||
blocks,
|
||||
document_name=document_name,
|
||||
asset_dir_name=asset_dir.name,
|
||||
metadata=metadata,
|
||||
)
|
||||
parsed_data = write_sidecar(
|
||||
ir,
|
||||
parsed_dir=parsed_dir,
|
||||
doc_id=ctx.doc_id,
|
||||
engine=self.engine_name,
|
||||
clean_parsed_dir=False, # asset dir pre-populated above
|
||||
block_drawing_path_style=self.sidecar_path_style,
|
||||
)
|
||||
|
||||
await ctx.rag._persist_parsed_full_docs(
|
||||
ctx.doc_id,
|
||||
{
|
||||
"content": make_lightrag_doc_content(parsed_data["content"]),
|
||||
"file_path": ctx.file_path,
|
||||
"parse_format": FULL_DOCS_FORMAT_LIGHTRAG,
|
||||
"sidecar_location": sidecar_uri_for(parsed_dir),
|
||||
"parse_engine": self.engine_name,
|
||||
"update_time": int(time.time()),
|
||||
},
|
||||
)
|
||||
await ctx.archive_source(str(source))
|
||||
return ParseResult(
|
||||
doc_id=ctx.doc_id,
|
||||
file_path=ctx.file_path,
|
||||
parse_format=FULL_DOCS_FORMAT_LIGHTRAG,
|
||||
content=parsed_data["content"],
|
||||
blocks_path=parsed_data["blocks_path"],
|
||||
parse_engine=self.engine_name,
|
||||
parse_warnings=parse_warnings,
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Native engine entry point — dispatches by source suffix.
|
||||
|
||||
The registry exposes a single ``native`` engine with one ``impl``; routing
|
||||
yields the engine key and ``get_parser("native")`` returns this one instance.
|
||||
Suffix capabilities (``docx`` / ``md`` / ``textpack``) are declared on the
|
||||
registry spec but do NOT pick an implementation — that is this dispatcher's
|
||||
job.
|
||||
|
||||
It delegates the **entire** ``parse(ctx)`` to the matching concrete parser
|
||||
(rather than subclassing :class:`NativeParserBase` and only forwarding the
|
||||
``extract`` / ``build_ir`` hooks) so each concrete parser keeps its own
|
||||
``sidecar_path_style`` — docx stays ``basename_only`` (byte-equivalent golden
|
||||
output) while markdown uses ``with_prefix``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from lightrag.constants import PARSER_ENGINE_NATIVE
|
||||
from lightrag.parser.base import BaseParser, ParseContext, ParseResult
|
||||
|
||||
_MARKDOWN_SUFFIXES = {".md", ".textpack"}
|
||||
|
||||
|
||||
class NativeParser(BaseParser):
|
||||
"""Routes a document to the docx or markdown native parser by suffix."""
|
||||
|
||||
engine_name = PARSER_ENGINE_NATIVE
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._docx: BaseParser | None = None
|
||||
self._markdown: BaseParser | None = None
|
||||
|
||||
def _docx_parser(self) -> BaseParser:
|
||||
parser = self._docx
|
||||
if parser is None:
|
||||
from lightrag.parser.docx.parser import NativeDocxParser
|
||||
|
||||
parser = NativeDocxParser()
|
||||
self._docx = parser
|
||||
return parser
|
||||
|
||||
def _markdown_parser(self) -> BaseParser:
|
||||
parser = self._markdown
|
||||
if parser is None:
|
||||
from lightrag.parser.markdown.parser import NativeMarkdownParser
|
||||
|
||||
parser = NativeMarkdownParser()
|
||||
self._markdown = parser
|
||||
return parser
|
||||
|
||||
async def parse(self, ctx: ParseContext) -> ParseResult:
|
||||
suffix = Path(ctx.file_path).suffix.lower()
|
||||
if suffix in _MARKDOWN_SUFFIXES:
|
||||
return await self._markdown_parser().parse(ctx)
|
||||
# Default to docx; its validate_source raises a clear error for any
|
||||
# other suffix the native engine was (mis)routed for.
|
||||
return await self._docx_parser().parse(ctx)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""No-op format handlers for the two "nothing to parse" document formats.
|
||||
|
||||
These are internal (``user_selectable=False``) parsers dispatched by *format*,
|
||||
not by a user engine choice:
|
||||
|
||||
- :class:`ReuseParser` handles ``lightrag`` rows — a document already parsed
|
||||
by some engine whose sidecar exists. Reached only on resume/retry (a doc
|
||||
that finished parsing but failed/interrupted at a later stage and is pulled
|
||||
back through the parse queue). Re-uses the stored content + sidecar
|
||||
instead of re-parsing (the original source may already be archived).
|
||||
- :class:`PassthroughParser` handles ``raw`` rows — content supplied verbatim
|
||||
at insert time (e.g. ``ainsert`` of a plain string).
|
||||
|
||||
Both mirror the corresponding branches of the former ``parse_native`` exactly
|
||||
(no disk writes, ``parse_stage_skipped=True``, no ``parse_engine`` key).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from lightrag.constants import FULL_DOCS_FORMAT_LIGHTRAG, FULL_DOCS_FORMAT_RAW
|
||||
from lightrag.parser.base import BaseParser, ParseContext, ParseResult
|
||||
|
||||
|
||||
class ReuseParser(BaseParser):
|
||||
"""Reuse an already-parsed (``lightrag``-format) document's sidecar."""
|
||||
|
||||
engine_name = "reuse"
|
||||
|
||||
async def parse(self, ctx: ParseContext) -> ParseResult:
|
||||
from lightrag.utils_pipeline import (
|
||||
sidecar_blocks_path,
|
||||
strip_lightrag_doc_prefix,
|
||||
)
|
||||
|
||||
doc_format = ctx.content_data.get("parse_format", FULL_DOCS_FORMAT_LIGHTRAG)
|
||||
merged_text = strip_lightrag_doc_prefix(
|
||||
ctx.content_data.get("content"), doc_format
|
||||
)
|
||||
# ``sidecar_location`` may be absent on historical/abnormal rows; tolerate
|
||||
# it (blocks_path="") rather than failing or re-routing to extraction.
|
||||
blocks_path = (
|
||||
sidecar_blocks_path(ctx.content_data.get("sidecar_location")) or ""
|
||||
)
|
||||
return ParseResult(
|
||||
doc_id=ctx.doc_id,
|
||||
file_path=ctx.file_path,
|
||||
parse_format=doc_format,
|
||||
content=merged_text,
|
||||
blocks_path=blocks_path,
|
||||
parse_stage_skipped=True,
|
||||
)
|
||||
|
||||
|
||||
class PassthroughParser(BaseParser):
|
||||
"""Pass ``raw``-format content through verbatim (no parser ran)."""
|
||||
|
||||
engine_name = "passthrough"
|
||||
|
||||
async def parse(self, ctx: ParseContext) -> ParseResult:
|
||||
return ParseResult(
|
||||
doc_id=ctx.doc_id,
|
||||
file_path=ctx.file_path,
|
||||
parse_format=FULL_DOCS_FORMAT_RAW,
|
||||
content=ctx.content_data.get("content", ""),
|
||||
blocks_path="",
|
||||
parse_stage_skipped=True,
|
||||
)
|
||||
@@ -0,0 +1,642 @@
|
||||
"""Parameter schema + parenthesis-aware scanners for hint parameters.
|
||||
|
||||
This module is the single source of truth for the *parameters* that may be
|
||||
attached to chunk-strategy selectors inside a parser hint or a
|
||||
``LIGHTRAG_PARSER`` rule, e.g. ``[-R(chunk_ts=800,chunk_ol=80)]`` or
|
||||
``pdf:legacy-R(chunk_ts=800)``.
|
||||
|
||||
Design (see ``docs/FileProcessingPipeline.md``):
|
||||
|
||||
* Inside ``(...)`` a comma is **only** a parameter separator; a single
|
||||
parameter value never contains ``,`` ``(`` ``)`` or ``]``.
|
||||
* Parameter names use a readable ``canonical`` form with optional short
|
||||
``alias`` forms; everything is normalised to canonical before use, so the
|
||||
internal structures never carry an alias.
|
||||
* Parameters are declared per *target* (a chunk selector char ``F``/``R``/``V``/
|
||||
``P``) with a type and the set of selectors they apply to.
|
||||
|
||||
Chunk-parameter scope: the integer ``chunk_token_size`` (alias ``chunk_ts``)
|
||||
and ``chunk_overlap_token_size`` (alias ``chunk_ol``), plus the boolean
|
||||
``drop_references`` (alias ``drop_rf``, paragraph-semantic only), all flowing
|
||||
through the existing per-document ``chunk_options`` channel. Float/enum chunk
|
||||
parameters are still rejected with a friendly error; adding them is a matter of
|
||||
registering new :class:`ParamSpec` entries and an extra ``kind`` branch in
|
||||
:func:`parse_chunk_params`.
|
||||
|
||||
This module is import-cheap and has no dependency on
|
||||
:mod:`lightrag.parser.routing` so it can be reused without import cycles.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from lightrag.constants import (
|
||||
PARSER_ENGINE_DOCLING,
|
||||
PARSER_ENGINE_MINERU,
|
||||
PROCESS_OPTION_CHUNK_FIXED,
|
||||
PROCESS_OPTION_CHUNK_PARAGRAH,
|
||||
PROCESS_OPTION_CHUNK_RECURSIVE,
|
||||
PROCESS_OPTION_CHUNK_VECTOR,
|
||||
)
|
||||
|
||||
# Characters a parameter value may never contain. ``]`` can never appear
|
||||
# anyway (``_PARSER_HINT_RE`` terminates the bracket on ``]``) but is rejected
|
||||
# explicitly so the error is friendly rather than a silent truncation.
|
||||
_VALUE_FORBIDDEN = frozenset(",()]")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parenthesis-aware scanners (shared by routing's rule/engine/options splits)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def split_top_level(text: str, separators: str) -> list[str]:
|
||||
"""Split ``text`` on any char in ``separators`` at parenthesis depth 0.
|
||||
|
||||
Characters inside ``(...)`` are protected, so a comma used to separate
|
||||
parameters never splits the surrounding rule / options string. Always
|
||||
returns at least one element (the whole string when no separator is hit).
|
||||
"""
|
||||
parts: list[str] = []
|
||||
depth = 0
|
||||
start = 0
|
||||
for i, ch in enumerate(text):
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
elif ch == ")":
|
||||
if depth > 0:
|
||||
depth -= 1
|
||||
elif depth == 0 and ch in separators:
|
||||
parts.append(text[start:i])
|
||||
start = i + 1
|
||||
parts.append(text[start:])
|
||||
return parts
|
||||
|
||||
|
||||
def take_paren_block(text: str, i: int) -> tuple[str | None, int]:
|
||||
"""Read a balanced ``(...)`` block starting at ``text[i]``.
|
||||
|
||||
Returns ``(inner, index_just_after_closing_paren)`` when ``text[i]`` opens
|
||||
a balanced block, otherwise ``(None, i)`` — used for both "no block here"
|
||||
(``text[i] != '('``) and "unterminated block". Callers that need to flag
|
||||
an unterminated block compare the returned index / ``None`` accordingly.
|
||||
"""
|
||||
if i >= len(text) or text[i] != "(":
|
||||
return None, i
|
||||
depth = 0
|
||||
for j in range(i, len(text)):
|
||||
if text[j] == "(":
|
||||
depth += 1
|
||||
elif text[j] == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return text[i + 1 : j], j + 1
|
||||
return None, i # unterminated
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parameter schema registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParamSpec:
|
||||
"""Declares one tunable hint parameter for a set of chunk selectors.
|
||||
|
||||
``kind`` is ``"int"`` or ``"bool"`` today; the field exists so future
|
||||
types (``float``/``enum``/``range_list``) slot in without a structural
|
||||
change. ``targets`` is the set of chunk selector chars the parameter is
|
||||
valid for (e.g. overlap is invalid for ``V``, ``drop_references`` only
|
||||
applies to ``P``).
|
||||
"""
|
||||
|
||||
canonical: str
|
||||
aliases: frozenset[str]
|
||||
kind: str
|
||||
targets: frozenset[str]
|
||||
is_list: bool = False
|
||||
min_value: int | None = None
|
||||
names: frozenset[str] = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "names", frozenset({self.canonical}) | self.aliases)
|
||||
|
||||
|
||||
_ALL_CHUNK_SELECTORS = frozenset(
|
||||
{
|
||||
PROCESS_OPTION_CHUNK_FIXED,
|
||||
PROCESS_OPTION_CHUNK_RECURSIVE,
|
||||
PROCESS_OPTION_CHUNK_VECTOR,
|
||||
PROCESS_OPTION_CHUNK_PARAGRAH,
|
||||
}
|
||||
)
|
||||
|
||||
# Phase 1 registry — keep this list small; expand it (and only it) to grow
|
||||
# hint-parameter coverage.
|
||||
_CHUNK_PARAM_SPECS: tuple[ParamSpec, ...] = (
|
||||
ParamSpec(
|
||||
canonical="chunk_token_size",
|
||||
aliases=frozenset({"chunk_ts"}),
|
||||
kind="int",
|
||||
targets=_ALL_CHUNK_SELECTORS,
|
||||
min_value=1,
|
||||
),
|
||||
ParamSpec(
|
||||
canonical="chunk_overlap_token_size",
|
||||
aliases=frozenset({"chunk_ol"}),
|
||||
kind="int",
|
||||
# Semantic-vector (V) chunking has no overlap concept.
|
||||
targets=frozenset(
|
||||
{
|
||||
PROCESS_OPTION_CHUNK_FIXED,
|
||||
PROCESS_OPTION_CHUNK_RECURSIVE,
|
||||
PROCESS_OPTION_CHUNK_PARAGRAH,
|
||||
}
|
||||
),
|
||||
min_value=0,
|
||||
),
|
||||
# Paragraph-semantic only: drop the trailing reference section before
|
||||
# chunking. Detection-tuning knobs (tail window / heading prefixes) are
|
||||
# env-only and read live by the chunker, so only the switch is a hint param.
|
||||
ParamSpec(
|
||||
canonical="drop_references",
|
||||
aliases=frozenset({"drop_rf"}),
|
||||
kind="bool",
|
||||
targets=frozenset({PROCESS_OPTION_CHUNK_PARAGRAH}),
|
||||
),
|
||||
)
|
||||
|
||||
_CHUNK_PARAM_BY_NAME: dict[str, ParamSpec] = {}
|
||||
for _spec in _CHUNK_PARAM_SPECS:
|
||||
for _name in _spec.names:
|
||||
_CHUNK_PARAM_BY_NAME[_name] = _spec
|
||||
|
||||
|
||||
def supported_chunk_param_names() -> str:
|
||||
"""Comma-joined canonical names, for friendly error messages."""
|
||||
return ", ".join(sorted(spec.canonical for spec in _CHUNK_PARAM_SPECS))
|
||||
|
||||
|
||||
def _parse_int(value: str) -> int | None:
|
||||
try:
|
||||
return int(value, 10)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_chunk_params(
|
||||
text: str, *, selector: str, label: str
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
"""Parse one chunk-strategy parameter block into a canonical dict.
|
||||
|
||||
``text`` is the raw text inside ``(...)`` (parameter separators only —
|
||||
no surrounding parens). ``selector`` is the chunk char the block is
|
||||
attached to (``F``/``R``/``V``/``P``). Returns ``(canonical_dict,
|
||||
errors)``; ``errors`` is empty iff the block is fully valid. Aliases are
|
||||
normalised to their canonical name in the returned dict.
|
||||
|
||||
A boolean parameter may be written bare as a flag — ``P(drop_rf)`` is
|
||||
shorthand for ``P(drop_rf=true)``. Non-boolean parameters still require
|
||||
the explicit ``key=value`` form.
|
||||
"""
|
||||
result: dict[str, Any] = {}
|
||||
errors: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for raw in split_top_level(text, ","):
|
||||
segment = raw.strip()
|
||||
if not segment:
|
||||
errors.append(f"{label}: empty parameter")
|
||||
continue
|
||||
if "=" in segment:
|
||||
key, _, value = segment.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
flag_form = False
|
||||
else:
|
||||
# Bare flag form, e.g. ``drop_rf``. Only valid for boolean
|
||||
# parameters, where it is shorthand for ``drop_rf=true``.
|
||||
key = segment
|
||||
value = ""
|
||||
flag_form = True
|
||||
|
||||
spec = _CHUNK_PARAM_BY_NAME.get(key)
|
||||
if spec is None:
|
||||
errors.append(
|
||||
f"{label}: unknown parameter {key!r}; supported parameters: "
|
||||
f"{supported_chunk_param_names()}"
|
||||
)
|
||||
continue
|
||||
if selector not in spec.targets:
|
||||
errors.append(
|
||||
f"{label}: parameter {spec.canonical!r} is not supported for "
|
||||
f"chunk strategy {selector!r}"
|
||||
)
|
||||
continue
|
||||
if flag_form:
|
||||
if spec.kind != "bool":
|
||||
errors.append(
|
||||
f"{label}: parameter {spec.canonical!r} must be written as "
|
||||
"'key=value'; only boolean flags may be written bare"
|
||||
)
|
||||
continue
|
||||
value = "true" # bare boolean flag means True
|
||||
if any(ch in _VALUE_FORBIDDEN for ch in value):
|
||||
errors.append(
|
||||
f"{label}: value for {spec.canonical!r} may not contain any of "
|
||||
"',' '(' ')' ']'"
|
||||
)
|
||||
continue
|
||||
if spec.canonical in seen and not spec.is_list:
|
||||
errors.append(f"{label}: parameter {spec.canonical!r} may not be repeated")
|
||||
continue
|
||||
seen.add(spec.canonical)
|
||||
|
||||
if spec.kind == "int":
|
||||
parsed = _parse_int(value)
|
||||
if parsed is None:
|
||||
errors.append(
|
||||
f"{label}: value for {spec.canonical!r} must be an integer, "
|
||||
f"got {value!r}"
|
||||
)
|
||||
continue
|
||||
if spec.min_value is not None and parsed < spec.min_value:
|
||||
errors.append(
|
||||
f"{label}: value for {spec.canonical!r} must be >= "
|
||||
f"{spec.min_value}, got {parsed}"
|
||||
)
|
||||
continue
|
||||
result[spec.canonical] = parsed
|
||||
elif spec.kind == "bool":
|
||||
parsed_bool = _parse_bool(value)
|
||||
if parsed_bool is None:
|
||||
errors.append(
|
||||
f"{label}: value for {spec.canonical!r} must be a boolean "
|
||||
f"(true/false), got {value!r}"
|
||||
)
|
||||
continue
|
||||
result[spec.canonical] = parsed_bool
|
||||
else: # pragma: no cover - only int/bool kinds registered today
|
||||
errors.append(
|
||||
f"{label}: parameter {spec.canonical!r} has unsupported type "
|
||||
f"{spec.kind!r}"
|
||||
)
|
||||
|
||||
# Cross-field invariant: reject an explicit overlap >= size pair here so
|
||||
# every caller (rule startup validation AND filename-hint validation)
|
||||
# rejects it uniformly, instead of only failing later at enqueue.
|
||||
overlap_error = chunk_param_overlap_error(result)
|
||||
if overlap_error is not None:
|
||||
errors.append(f"{label}: {overlap_error}")
|
||||
|
||||
return result, errors
|
||||
|
||||
|
||||
def chunk_param_overlap_error(params: Mapping[str, Any]) -> str | None:
|
||||
"""Return an error string when a block sets an invalid overlap/size pair.
|
||||
|
||||
Only checks the cross-field invariant when **both** ``chunk_token_size`` and
|
||||
``chunk_overlap_token_size`` are explicitly present in ``params`` (a parsed
|
||||
canonical dict). When only one is given the effective value depends on
|
||||
env / ``addon_params`` and cannot be evaluated here — that case is left to
|
||||
the upload-time ``_validate_effective_chunk_overlap`` on the resolved
|
||||
snapshot. Returns ``None`` when the pair is valid or not fully specified.
|
||||
"""
|
||||
size = params.get("chunk_token_size")
|
||||
overlap = params.get("chunk_overlap_token_size")
|
||||
if size is not None and overlap is not None and overlap >= size:
|
||||
return (
|
||||
f"chunk_overlap_token_size ({overlap}) must be < chunk_token_size ({size})"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Engine parameters (Phase 2) — per-file params attached to the engine token,
|
||||
# e.g. ``mineru(page_range=1-3,language=en)`` / ``docling(force_ocr=true)``.
|
||||
# Keyed by engine name (unlike chunk params, which are keyed by F/R/V/P).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BOOL_TRUE = frozenset({"1", "true", "yes", "on", "t", "y"})
|
||||
_BOOL_FALSE = frozenset({"0", "false", "no", "off", "f", "n"})
|
||||
# A single page-range segment: ``N`` or ``N-M`` (validated for positivity /
|
||||
# ordering separately).
|
||||
_PAGE_SEGMENT_RE = re.compile(r"^\d+(?:-\d+)?$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EngineParamSpec:
|
||||
"""Declares one tunable engine parameter (Phase 2).
|
||||
|
||||
Separate from the chunk :class:`ParamSpec` (which requires a ``targets``
|
||||
set of F/R/V/P selectors that is meaningless for engines). ``kind`` is one
|
||||
of ``"str"`` / ``"enum"`` / ``"bool"``. ``is_list`` marks a repeated-key
|
||||
parameter (``page_range``) whose canonical value is a comma-joined string.
|
||||
``enum_values`` constrains an ``"enum"`` parameter.
|
||||
"""
|
||||
|
||||
canonical: str
|
||||
aliases: frozenset[str]
|
||||
kind: str
|
||||
is_list: bool = False
|
||||
enum_values: frozenset[str] | None = None
|
||||
names: frozenset[str] = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "names", frozenset({self.canonical}) | self.aliases)
|
||||
|
||||
|
||||
# Phase 2 registry — keyed by engine name; expand by adding specs (and, for a
|
||||
# new engine, a new dict entry). An engine absent here accepts NO parameters.
|
||||
_ENGINE_PARAM_SPECS: dict[str, tuple[EngineParamSpec, ...]] = {
|
||||
PARSER_ENGINE_MINERU: (
|
||||
EngineParamSpec(
|
||||
canonical="page_range",
|
||||
aliases=frozenset({"pr"}),
|
||||
kind="str",
|
||||
is_list=True,
|
||||
),
|
||||
EngineParamSpec(canonical="language", aliases=frozenset(), kind="str"),
|
||||
EngineParamSpec(
|
||||
canonical="local_parse_method",
|
||||
aliases=frozenset({"local_pm"}),
|
||||
kind="enum",
|
||||
enum_values=frozenset({"auto", "txt", "ocr"}),
|
||||
),
|
||||
),
|
||||
PARSER_ENGINE_DOCLING: (
|
||||
EngineParamSpec(canonical="force_ocr", aliases=frozenset({"ocr"}), kind="bool"),
|
||||
),
|
||||
}
|
||||
|
||||
_ENGINE_PARAM_BY_NAME: dict[str, dict[str, EngineParamSpec]] = {
|
||||
engine: {name: spec for spec in specs for name in spec.names}
|
||||
for engine, specs in _ENGINE_PARAM_SPECS.items()
|
||||
}
|
||||
|
||||
|
||||
def engine_params_supported(engine: str) -> bool:
|
||||
"""Whether ``engine`` declares any tunable hint parameters."""
|
||||
return engine in _ENGINE_PARAM_SPECS
|
||||
|
||||
|
||||
def supported_engine_param_names(engine: str) -> str:
|
||||
"""Comma-joined canonical engine-param names, for error messages."""
|
||||
specs = _ENGINE_PARAM_SPECS.get(engine, ())
|
||||
return ", ".join(sorted(spec.canonical for spec in specs))
|
||||
|
||||
|
||||
def _parse_bool(value: str) -> bool | None:
|
||||
low = value.strip().lower()
|
||||
if low in _BOOL_TRUE:
|
||||
return True
|
||||
if low in _BOOL_FALSE:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _mineru_api_mode_is_local() -> bool:
|
||||
"""True when MinerU runs in local mode (the default when unset).
|
||||
|
||||
Read here (rather than importing ``mineru.cache``) to keep this module a
|
||||
dependency-free leaf. Mirrors ``cache._normalize_api_mode``: anything that
|
||||
is not ``official`` is treated as local.
|
||||
"""
|
||||
return (os.getenv("MINERU_API_MODE", "") or "").strip().lower() != "official"
|
||||
|
||||
|
||||
def _validate_page_range_segments(parts: list[str]) -> list[str]:
|
||||
"""Validate page-range segment shape + the MinerU local single-segment rule.
|
||||
|
||||
``official`` mode forwards a multi-segment list verbatim; ``local`` accepts
|
||||
only a single page / range (mirrors ``cache.local_page_bounds``). The
|
||||
download-time ``local_page_bounds`` remains the final backstop.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
for seg in parts:
|
||||
if not _PAGE_SEGMENT_RE.match(seg):
|
||||
errors.append(
|
||||
f"page_range segment {seg!r} must be a page 'N' or range 'N-M'"
|
||||
)
|
||||
continue
|
||||
if "-" in seg:
|
||||
left, _, right = seg.partition("-")
|
||||
if int(left) < 1 or int(right) < 1:
|
||||
errors.append(f"page_range segment {seg!r} must use positive pages")
|
||||
elif int(right) < int(left):
|
||||
errors.append(f"page_range segment {seg!r} must have end >= start")
|
||||
elif int(seg) < 1:
|
||||
errors.append(f"page_range segment {seg!r} must be a positive page")
|
||||
if not errors and len(parts) > 1 and _mineru_api_mode_is_local():
|
||||
errors.append(
|
||||
"page_range with MINERU_API_MODE=local supports only a single page "
|
||||
"or range such as '1-10'; use MINERU_API_MODE=official for a "
|
||||
"multi-segment list"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def _coerce_engine_value(
|
||||
spec: EngineParamSpec, value: str, *, label: str
|
||||
) -> tuple[Any, str | None]:
|
||||
"""Validate + coerce a single engine-param value to its canonical type.
|
||||
|
||||
Returns ``(coerced, error)``; ``error`` is ``None`` when valid. Shared by
|
||||
the text path (:func:`parse_engine_params`) and the resolved-dict path
|
||||
(:func:`normalize_engine_params`) so both apply identical rules. For the
|
||||
list-type ``page_range`` the ``value`` is the already-joined comma string.
|
||||
"""
|
||||
# ``local_parse_method`` only feeds the local MinerU request + signature;
|
||||
# the official API neither sends it nor folds it into the cache key, so
|
||||
# accepting it under official mode would persist a directive that silently
|
||||
# does nothing. Reject it here (mirrors the page_range mode rule).
|
||||
if spec.canonical == "local_parse_method" and not _mineru_api_mode_is_local():
|
||||
return None, (
|
||||
f"{label}: 'local_parse_method' only applies to "
|
||||
"MINERU_API_MODE=local (the default); the official API ignores it"
|
||||
)
|
||||
if spec.kind == "bool":
|
||||
parsed = _parse_bool(value)
|
||||
if parsed is None:
|
||||
return None, (
|
||||
f"{label}: value for {spec.canonical!r} must be a boolean "
|
||||
f"(true/false), got {value!r}"
|
||||
)
|
||||
return parsed, None
|
||||
if spec.kind == "enum":
|
||||
if spec.enum_values is not None and value not in spec.enum_values:
|
||||
allowed = ", ".join(sorted(spec.enum_values))
|
||||
return None, (
|
||||
f"{label}: value for {spec.canonical!r} must be one of "
|
||||
f"{allowed}, got {value!r}"
|
||||
)
|
||||
return value, None
|
||||
# str (incl. the list-type page_range, whose value is a comma-joined string)
|
||||
if not value:
|
||||
return None, f"{label}: value for {spec.canonical!r} must be non-empty"
|
||||
if spec.is_list and spec.canonical == "page_range":
|
||||
segments = [p.strip() for p in value.split(",")]
|
||||
seg_errors = _validate_page_range_segments(segments)
|
||||
if seg_errors:
|
||||
return None, f"{label}: " + "; ".join(seg_errors)
|
||||
return ",".join(segments), None
|
||||
return value, None
|
||||
|
||||
|
||||
def parse_engine_params(
|
||||
text: str, *, engine: str, label: str
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
"""Parse one engine parameter block into a canonical, coerced dict.
|
||||
|
||||
``text`` is the raw text inside ``(...)`` for an engine token; ``engine`` is
|
||||
the (bare) engine the block is attached to. Returns ``(canonical_dict,
|
||||
errors)``; aliases are normalised to canonical and values are coerced to
|
||||
their declared type (so ``force_ocr`` is a real ``bool``). A list-type
|
||||
``page_range`` collects repeated keys and joins them with ``,``.
|
||||
"""
|
||||
by_name = _ENGINE_PARAM_BY_NAME.get(engine)
|
||||
if by_name is None:
|
||||
if text.strip():
|
||||
return {}, [f"{label}: parser engine {engine!r} does not accept parameters"]
|
||||
return {}, []
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
list_values: dict[str, list[str]] = {}
|
||||
errors: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for raw in split_top_level(text, ","):
|
||||
segment = raw.strip()
|
||||
if not segment:
|
||||
errors.append(f"{label}: empty parameter")
|
||||
continue
|
||||
if "=" not in segment:
|
||||
if _PAGE_SEGMENT_RE.match(segment) and (
|
||||
"page_range" in by_name or "pr" in by_name
|
||||
):
|
||||
errors.append(
|
||||
f"{label}: page lists must repeat the key, e.g. "
|
||||
"'page_range=1-3,page_range=5' (a comma only separates "
|
||||
"parameters)"
|
||||
)
|
||||
else:
|
||||
errors.append(
|
||||
f"{label}: parameter {segment!r} must be written as "
|
||||
"'key=value' (flag parameters are not supported yet)"
|
||||
)
|
||||
continue
|
||||
key, _, value = segment.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
|
||||
spec = by_name.get(key)
|
||||
if spec is None:
|
||||
errors.append(
|
||||
f"{label}: unknown parameter {key!r} for engine {engine!r}; "
|
||||
f"supported parameters: {supported_engine_param_names(engine)}"
|
||||
)
|
||||
continue
|
||||
if any(ch in _VALUE_FORBIDDEN for ch in value):
|
||||
errors.append(
|
||||
f"{label}: value for {spec.canonical!r} may not contain any of "
|
||||
"',' '(' ')' ']'"
|
||||
)
|
||||
continue
|
||||
if spec.is_list:
|
||||
list_values.setdefault(spec.canonical, []).append(value)
|
||||
continue
|
||||
if spec.canonical in seen:
|
||||
errors.append(f"{label}: parameter {spec.canonical!r} may not be repeated")
|
||||
continue
|
||||
seen.add(spec.canonical)
|
||||
coerced, error = _coerce_engine_value(spec, value, label=label)
|
||||
if error is not None:
|
||||
errors.append(error)
|
||||
continue
|
||||
result[spec.canonical] = coerced
|
||||
|
||||
# Join repeated-key list params, then coerce/validate the joined value.
|
||||
for canonical, values in list_values.items():
|
||||
spec = by_name[canonical]
|
||||
coerced, error = _coerce_engine_value(spec, ",".join(values), label=label)
|
||||
if error is not None:
|
||||
errors.append(error)
|
||||
continue
|
||||
result[canonical] = coerced
|
||||
|
||||
return result, errors
|
||||
|
||||
|
||||
def normalize_engine_params(
|
||||
engine: str, params: Mapping[str, Any]
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
"""Normalise + validate an already-resolved engine-param dict.
|
||||
|
||||
Used by the pipeline layer where direct (SDK/API) callers bypass routing's
|
||||
text parsing. Returns a **coerced** dict (e.g. ``force_ocr`` becomes a real
|
||||
``bool``, ``page_range`` a validated comma-joined string) so what gets
|
||||
persisted is exactly what the engine override seam consumes. Accepts a
|
||||
``page_range`` value as either a list or a comma-joined string.
|
||||
"""
|
||||
if not params:
|
||||
return {}, []
|
||||
by_name = _ENGINE_PARAM_BY_NAME.get(engine)
|
||||
if by_name is None:
|
||||
return {}, [f"parser engine {engine!r} does not accept parameters"]
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
errors: list[str] = []
|
||||
for key, value in params.items():
|
||||
spec = by_name.get(str(key))
|
||||
if spec is None:
|
||||
errors.append(
|
||||
f"unknown parameter {key!r} for engine {engine!r}; supported "
|
||||
f"parameters: {supported_engine_param_names(engine)}"
|
||||
)
|
||||
continue
|
||||
if spec.is_list and isinstance(value, (list, tuple)):
|
||||
value = ",".join(str(v).strip() for v in value)
|
||||
else:
|
||||
value = str(value).strip()
|
||||
coerced, error = _coerce_engine_value(
|
||||
spec, value, label=f"engine parameter {spec.canonical!r}"
|
||||
)
|
||||
if error is not None:
|
||||
errors.append(error)
|
||||
continue
|
||||
result[spec.canonical] = coerced
|
||||
return result, errors
|
||||
|
||||
|
||||
def render_engine_params(
|
||||
engine: str, params: Mapping[str, Any]
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Render a resolved engine-param dict to the canonical inner text.
|
||||
|
||||
Returns ``(inner_text, errors)`` where ``inner_text`` is the
|
||||
``key=value,...`` string that goes inside the ``parse_engine`` parens (e.g.
|
||||
``page_range=1-3,page_range=5,language=en``). Normalises first (so the
|
||||
output always round-trips through :func:`parse_engine_params`); a list-type
|
||||
value is emitted as **repeated keys**, a bool as ``true``/``false``. Keys
|
||||
are sorted for deterministic output.
|
||||
"""
|
||||
normalized, errors = normalize_engine_params(engine, params)
|
||||
if errors:
|
||||
return "", errors
|
||||
by_name = _ENGINE_PARAM_BY_NAME.get(engine, {})
|
||||
parts: list[str] = []
|
||||
for canonical in sorted(normalized):
|
||||
spec = by_name[canonical]
|
||||
value = normalized[canonical]
|
||||
if spec.is_list:
|
||||
parts.extend(f"{canonical}={seg}" for seg in str(value).split(","))
|
||||
elif spec.kind == "bool":
|
||||
parts.append(f"{canonical}={'true' if value else 'false'}")
|
||||
else:
|
||||
parts.append(f"{canonical}={value}")
|
||||
return ",".join(parts), []
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Third-party parser engine discovery (``lightrag.parsers`` entry points).
|
||||
|
||||
A third-party package exposes parser engines by declaring an entry point in
|
||||
the ``lightrag.parsers`` group::
|
||||
|
||||
# pyproject.toml of the third-party package
|
||||
[project.entry-points."lightrag.parsers"]
|
||||
myengine = "my_pkg.lightrag_plugin:register"
|
||||
|
||||
Each entry point must resolve to a **zero-argument callable** that performs
|
||||
its own :func:`lightrag.parser.registry.register_parser` call(s). The
|
||||
callable should be import-cheap: defer the parser implementation import to
|
||||
the ``ParserSpec.impl`` string (the registry already loads it lazily).
|
||||
|
||||
:func:`load_third_party_parsers` is invoked once per process by both
|
||||
entrypoints that drive parsers — the API server (``create_app``, before
|
||||
routing-rule validation so ``LIGHTRAG_PARSER`` may reference third-party
|
||||
engine names) and the debug CLI (``lightrag.parser.cli.main``, before
|
||||
``--engine`` choices are built). Library users embedding LightRAG directly
|
||||
can call it themselves before constructing pipelines.
|
||||
|
||||
See ``docs/ThirdPartyParser-zh.md`` for the full plugin authoring guide.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from importlib.metadata import entry_points
|
||||
|
||||
ENTRY_POINT_GROUP = "lightrag.parsers"
|
||||
|
||||
logger = logging.getLogger("lightrag")
|
||||
|
||||
_loaded = False
|
||||
|
||||
|
||||
def load_third_party_parsers(*, force: bool = False) -> list[str]:
|
||||
"""Discover and run all ``lightrag.parsers`` entry points.
|
||||
|
||||
Idempotent per process (``force=True`` re-runs, for tests). Returns the
|
||||
names of the entry points that loaded successfully. A failing plugin is
|
||||
logged with its origin and skipped — one broken third-party package must
|
||||
not take down server startup or the debug CLI; the built-in engines are
|
||||
registered statically and are never affected.
|
||||
"""
|
||||
global _loaded
|
||||
if _loaded and not force:
|
||||
return []
|
||||
_loaded = True
|
||||
|
||||
loaded: list[str] = []
|
||||
for ep in entry_points(group=ENTRY_POINT_GROUP):
|
||||
try:
|
||||
register = ep.load()
|
||||
register()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"[parser-plugins] failed to load parser plugin %r (%s): %s",
|
||||
ep.name,
|
||||
ep.value,
|
||||
e,
|
||||
)
|
||||
continue
|
||||
loaded.append(ep.name)
|
||||
logger.info("[parser-plugins] loaded parser plugin %r (%s)", ep.name, ep.value)
|
||||
return loaded
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Central registry for parser engines (mirrors the storage-layer convention).
|
||||
|
||||
Like :data:`lightrag.kg.STORAGES` / ``STORAGE_IMPLEMENTATIONS``, this module
|
||||
holds a module-level literal table of lightweight :class:`ParserSpec`
|
||||
metadata. Loading this module imports **no** parser implementation, so
|
||||
capability queries (suffixes / endpoint / supported engines) never trigger a
|
||||
heavy ``mineru``/``docling`` facade import (which would pull ``httpx`` etc.).
|
||||
The implementation class is imported lazily — only when a document is
|
||||
actually parsed — via :func:`get_parser`.
|
||||
|
||||
Capability data lives here (single source of truth); behaviour lives in the
|
||||
parser classes. ``constants.PARSER_ENGINE_*`` keeps only the bare name
|
||||
strings used as identifiers / registry keys.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
from lightrag.constants import (
|
||||
PARSER_ENGINE_DOCLING,
|
||||
PARSER_ENGINE_LEGACY,
|
||||
PARSER_ENGINE_MINERU,
|
||||
PARSER_ENGINE_NATIVE,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lightrag.parser.base import BaseParser
|
||||
|
||||
# Internal format-handler engine keys (not user-selectable).
|
||||
PARSER_ENGINE_REUSE = "reuse"
|
||||
PARSER_ENGINE_PASSTHROUGH = "passthrough"
|
||||
|
||||
_VALID_MINERU_API_MODES = {"official", "local"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoint capability closures (env-only; no network). Canonical home —
|
||||
# routing.py delegates here.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _mineru_endpoint_configured() -> bool:
|
||||
mode = os.getenv("MINERU_API_MODE", "local").strip().lower()
|
||||
if mode == "official":
|
||||
return bool(os.getenv("MINERU_API_TOKEN", "").strip())
|
||||
if mode == "local":
|
||||
return bool(os.getenv("MINERU_LOCAL_ENDPOINT", "").strip())
|
||||
return False
|
||||
|
||||
|
||||
def _mineru_endpoint_requirement() -> str | None:
|
||||
mode = os.getenv("MINERU_API_MODE", "local").strip().lower()
|
||||
if mode == "official":
|
||||
return "MINERU_API_TOKEN"
|
||||
if mode == "local":
|
||||
return "MINERU_LOCAL_ENDPOINT"
|
||||
allowed = ", ".join(sorted(_VALID_MINERU_API_MODES))
|
||||
return f"valid MINERU_API_MODE ({allowed})"
|
||||
|
||||
|
||||
def _env_endpoint_configured(env_name: str) -> Callable[[], bool]:
|
||||
return lambda: bool(os.getenv(env_name, "").strip())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParserSpec:
|
||||
"""Lightweight, import-cheap metadata for one parser engine.
|
||||
|
||||
Holds everything the pipeline needs to *route* and *gate* a document
|
||||
without importing the parser implementation. ``impl`` is a
|
||||
``"module:Class"`` string imported lazily by :func:`get_parser`.
|
||||
"""
|
||||
|
||||
engine_name: str
|
||||
impl: str
|
||||
suffixes: frozenset[str]
|
||||
user_selectable: bool = True
|
||||
queue_group: str = PARSER_ENGINE_NATIVE
|
||||
# Worker count for this spec's queue_group. The registrant bakes in any
|
||||
# env override at registration time (e.g.
|
||||
# ``concurrency=int(os.getenv("MAX_PARALLEL_PARSE_MYENGINE", "3"))``),
|
||||
# mirroring how the built-in ``max_parallel_parse_*`` LightRAG fields read
|
||||
# their env. ``None`` means this spec does not own its group's concurrency
|
||||
# (built-in groups are sized by the LightRAG instance field instead).
|
||||
concurrency: int | None = None
|
||||
endpoint_configured: Callable[[], bool] = field(default=lambda: True)
|
||||
endpoint_requirement: Callable[[], str | None] = field(default=lambda: None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Suffix capabilities (single source of truth; replaces
|
||||
# constants.PARSER_ENGINE_SUFFIX_CAPABILITIES).
|
||||
# ---------------------------------------------------------------------------
|
||||
_LEGACY_SUFFIXES = frozenset(
|
||||
{
|
||||
"txt",
|
||||
"md",
|
||||
"mdx",
|
||||
"pdf",
|
||||
"docx",
|
||||
"pptx",
|
||||
"xlsx",
|
||||
"rtf",
|
||||
"odt",
|
||||
"tex",
|
||||
"epub",
|
||||
"html",
|
||||
"htm",
|
||||
"csv",
|
||||
"json",
|
||||
"xml",
|
||||
"yaml",
|
||||
"yml",
|
||||
"log",
|
||||
"conf",
|
||||
"ini",
|
||||
"properties",
|
||||
"sql",
|
||||
"bat",
|
||||
"sh",
|
||||
"c",
|
||||
"h",
|
||||
"cpp",
|
||||
"hpp",
|
||||
"py",
|
||||
"java",
|
||||
"js",
|
||||
"ts",
|
||||
"swift",
|
||||
"go",
|
||||
"rb",
|
||||
"php",
|
||||
"css",
|
||||
"scss",
|
||||
"less",
|
||||
}
|
||||
)
|
||||
_MINERU_SUFFIXES = frozenset(
|
||||
{
|
||||
"pdf",
|
||||
"doc",
|
||||
"docx",
|
||||
"ppt",
|
||||
"pptx",
|
||||
"xls",
|
||||
"xlsx",
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"jp2",
|
||||
"webp",
|
||||
"gif",
|
||||
"bmp",
|
||||
}
|
||||
)
|
||||
_DOCLING_SUFFIXES = frozenset(
|
||||
{
|
||||
"pdf",
|
||||
"docx",
|
||||
"pptx",
|
||||
"xlsx",
|
||||
"md",
|
||||
"html",
|
||||
"xhtml",
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"tiff",
|
||||
"webp",
|
||||
"bmp",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
_REGISTRY: dict[str, ParserSpec] = {
|
||||
PARSER_ENGINE_NATIVE: ParserSpec(
|
||||
engine_name=PARSER_ENGINE_NATIVE,
|
||||
# Single ``native`` engine; the dispatcher picks docx vs markdown by
|
||||
# source suffix (see lightrag.parser.native_dispatch).
|
||||
impl="lightrag.parser.native_dispatch:NativeParser",
|
||||
suffixes=frozenset({"docx", "md", "textpack"}),
|
||||
queue_group=PARSER_ENGINE_NATIVE,
|
||||
# Built-in groups are sized by the LightRAG ``max_parallel_parse_*``
|
||||
# instance field (supports constructor override), so no spec-level
|
||||
# ``concurrency`` here.
|
||||
),
|
||||
PARSER_ENGINE_LEGACY: ParserSpec(
|
||||
engine_name=PARSER_ENGINE_LEGACY,
|
||||
impl="lightrag.parser.legacy.parser:LegacyParser",
|
||||
suffixes=_LEGACY_SUFFIXES,
|
||||
queue_group=PARSER_ENGINE_NATIVE, # shares native pool (local, no network)
|
||||
),
|
||||
PARSER_ENGINE_MINERU: ParserSpec(
|
||||
engine_name=PARSER_ENGINE_MINERU,
|
||||
impl="lightrag.parser.external.mineru.parser:MinerUParser",
|
||||
suffixes=_MINERU_SUFFIXES,
|
||||
queue_group=PARSER_ENGINE_MINERU, # sized by max_parallel_parse_mineru
|
||||
endpoint_configured=_mineru_endpoint_configured,
|
||||
endpoint_requirement=_mineru_endpoint_requirement,
|
||||
),
|
||||
PARSER_ENGINE_DOCLING: ParserSpec(
|
||||
engine_name=PARSER_ENGINE_DOCLING,
|
||||
impl="lightrag.parser.external.docling.parser:DoclingParser",
|
||||
suffixes=_DOCLING_SUFFIXES,
|
||||
queue_group=PARSER_ENGINE_DOCLING, # sized by max_parallel_parse_docling
|
||||
endpoint_configured=_env_endpoint_configured("DOCLING_ENDPOINT"),
|
||||
endpoint_requirement=lambda: "DOCLING_ENDPOINT",
|
||||
),
|
||||
PARSER_ENGINE_REUSE: ParserSpec(
|
||||
engine_name=PARSER_ENGINE_REUSE,
|
||||
impl="lightrag.parser.noop:ReuseParser",
|
||||
suffixes=frozenset(),
|
||||
user_selectable=False,
|
||||
),
|
||||
PARSER_ENGINE_PASSTHROUGH: ParserSpec(
|
||||
engine_name=PARSER_ENGINE_PASSTHROUGH,
|
||||
impl="lightrag.parser.noop:PassthroughParser",
|
||||
suffixes=frozenset(),
|
||||
user_selectable=False,
|
||||
),
|
||||
}
|
||||
|
||||
# (engine_name, impl) -> instance. Keyed on impl so a re-registration with a
|
||||
# different implementation is not served a stale cached instance.
|
||||
_INSTANCE_CACHE: dict[tuple[str, str], "BaseParser"] = {}
|
||||
|
||||
|
||||
def register_parser(spec: ParserSpec) -> None:
|
||||
"""Register (or override) a parser engine spec."""
|
||||
_REGISTRY[spec.engine_name] = spec
|
||||
|
||||
|
||||
def parser_specs_snapshot() -> dict[str, ParserSpec]:
|
||||
"""Return a shallow snapshot of the registry.
|
||||
|
||||
The pipeline takes one snapshot at batch start and threads it through
|
||||
queue construction, routing and the parse workers so a concurrent
|
||||
``register_parser`` cannot change the engine set mid-batch.
|
||||
"""
|
||||
return dict(_REGISTRY)
|
||||
|
||||
|
||||
def _table(specs: dict[str, ParserSpec] | None) -> dict[str, ParserSpec]:
|
||||
return specs if specs is not None else _REGISTRY
|
||||
|
||||
|
||||
def get_parser(engine: str, *, specs: dict[str, ParserSpec] | None = None):
|
||||
"""Return a (cached) parser instance for ``engine`` or ``None``.
|
||||
|
||||
Imports the implementation lazily via ``importlib`` — only here does the
|
||||
heavy engine package (and e.g. ``httpx``) get pulled in.
|
||||
"""
|
||||
spec = _table(specs).get(engine)
|
||||
if spec is None:
|
||||
return None
|
||||
cache_key = (engine, spec.impl)
|
||||
inst = _INSTANCE_CACHE.get(cache_key)
|
||||
if inst is None:
|
||||
module_path, _, cls_name = spec.impl.partition(":")
|
||||
cls = getattr(importlib.import_module(module_path), cls_name)
|
||||
inst = cls()
|
||||
_INSTANCE_CACHE[cache_key] = inst
|
||||
return inst
|
||||
|
||||
|
||||
def supported_parser_engines(
|
||||
specs: dict[str, ParserSpec] | None = None,
|
||||
) -> frozenset[str]:
|
||||
"""User-selectable engine names (replaces SUPPORTED_PARSER_ENGINES)."""
|
||||
return frozenset(
|
||||
name for name, spec in _table(specs).items() if spec.user_selectable
|
||||
)
|
||||
|
||||
|
||||
def available_engine_suffixes(
|
||||
specs: dict[str, ParserSpec] | None = None,
|
||||
) -> frozenset[str]:
|
||||
"""Suffixes (lowercase, no dot) parseable by a *currently usable* engine.
|
||||
|
||||
Union over user-selectable engines whose ``endpoint_configured()`` gate
|
||||
passes. This is the single source for the API upload allowlist and the
|
||||
input-directory scan (``DocumentManager.supported_extensions``): in a
|
||||
default deployment (no external endpoints) it equals the local engines'
|
||||
suffixes (legacy ∪ native); configuring e.g. ``MINERU_LOCAL_ENDPOINT``
|
||||
admits mineru's image/office suffixes; a registered third-party engine's
|
||||
suffixes join automatically (subject to its own endpoint gate).
|
||||
"""
|
||||
out: set[str] = set()
|
||||
for spec in _table(specs).values():
|
||||
if spec.user_selectable and spec.endpoint_configured():
|
||||
out |= spec.suffixes
|
||||
return frozenset(out)
|
||||
|
||||
|
||||
def suffix_capabilities(
|
||||
engine: str, specs: dict[str, ParserSpec] | None = None
|
||||
) -> frozenset[str]:
|
||||
spec = _table(specs).get(engine)
|
||||
return spec.suffixes if spec is not None else frozenset()
|
||||
|
||||
|
||||
def engine_endpoint_configured(
|
||||
engine: str, specs: dict[str, ParserSpec] | None = None
|
||||
) -> bool:
|
||||
spec = _table(specs).get(engine)
|
||||
return spec.endpoint_configured() if spec is not None else True
|
||||
|
||||
|
||||
def engine_endpoint_requirement(
|
||||
engine: str, specs: dict[str, ParserSpec] | None = None
|
||||
) -> str | None:
|
||||
spec = _table(specs).get(engine)
|
||||
return spec.endpoint_requirement() if spec is not None else None
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user