Files
hkuds--lightrag/lightrag/parser/param_schema.py
T
2026-07-13 12:08:54 +08:00

643 lines
24 KiB
Python

"""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), []