409 lines
15 KiB
Python
409 lines
15 KiB
Python
"""Tests for parameterised parser hints (``[-R(chunk_ts=800,chunk_ol=80)]``).
|
|
|
|
Phase 1 wires only ``chunk_token_size``/``chunk_ts`` and
|
|
``chunk_overlap_token_size``/``chunk_ol`` through the existing per-document
|
|
``chunk_options`` channel. ``parser_rules=""`` is passed explicitly so the
|
|
assertions are independent of any ambient ``LIGHTRAG_PARSER``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
from lightrag.parser.param_schema import (
|
|
parse_chunk_params,
|
|
split_top_level,
|
|
take_paren_block,
|
|
)
|
|
from lightrag.parser.routing import (
|
|
FilenameParserHintError,
|
|
ParserRoutingConfigError,
|
|
canonicalize_parser_hinted_basename,
|
|
filename_parser_directives,
|
|
resolve_file_parser_directives,
|
|
resolve_parser_directives,
|
|
validate_parser_routing_config,
|
|
)
|
|
|
|
# Importing document_routes runs an argparse over sys.argv at import time;
|
|
# neutralise pytest's argv during that first import (subsequent imports hit the
|
|
# module cache and skip argparse). Mirrors tests/api/routes/test_document_routes_chunking.py.
|
|
_original_argv = sys.argv[:]
|
|
sys.argv = [sys.argv[0]]
|
|
importlib.import_module("lightrag.api.routers.document_routes")
|
|
sys.argv = _original_argv
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# param_schema: scanners
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_split_top_level_protects_commas_inside_parens():
|
|
# A comma inside a parameter block must not split the surrounding rules.
|
|
assert split_top_level(
|
|
"pdf:legacy-R(chunk_ts=800,chunk_ol=80);*:legacy-R", ";,"
|
|
) == ["pdf:legacy-R(chunk_ts=800,chunk_ol=80)", "*:legacy-R"]
|
|
# Legacy comma rule separator still works at depth 0.
|
|
assert split_top_level("a:native-F,b:legacy-R", ";,") == [
|
|
"a:native-F",
|
|
"b:legacy-R",
|
|
]
|
|
|
|
|
|
def test_take_paren_block():
|
|
assert take_paren_block("R(chunk_ts=800)", 1) == ("chunk_ts=800", 15)
|
|
assert take_paren_block("R", 1) == (None, 1) # no block
|
|
assert take_paren_block("R(unterminated", 1) == (None, 1) # unbalanced
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# param_schema: parse_chunk_params (alias / type / target / repeat)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_parse_chunk_params_alias_normalised_to_canonical():
|
|
parsed, errors = parse_chunk_params(
|
|
"chunk_ts=800,chunk_ol=80", selector="R", label="x"
|
|
)
|
|
assert errors == []
|
|
assert parsed == {"chunk_token_size": 800, "chunk_overlap_token_size": 80}
|
|
|
|
|
|
def test_parse_chunk_params_canonical_names_accepted():
|
|
parsed, errors = parse_chunk_params(
|
|
"chunk_token_size=1500", selector="V", label="x"
|
|
)
|
|
assert errors == [] and parsed == {"chunk_token_size": 1500}
|
|
|
|
|
|
def test_parse_chunk_params_overlap_rejected_for_vector():
|
|
parsed, errors = parse_chunk_params("chunk_ol=80", selector="V", label="x")
|
|
assert parsed == {}
|
|
assert errors and "not supported for chunk strategy 'V'" in errors[0]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"block,expected",
|
|
[
|
|
("drop_rf=true", True),
|
|
("drop_references=true", True),
|
|
("drop_rf=false", False),
|
|
("drop_rf=yes", True),
|
|
("drop_rf=0", False),
|
|
],
|
|
)
|
|
def test_parse_chunk_params_drop_references_bool(block, expected):
|
|
parsed, errors = parse_chunk_params(block, selector="P", label="x")
|
|
assert errors == []
|
|
assert parsed == {"drop_references": expected}
|
|
|
|
|
|
def test_parse_chunk_params_drop_references_invalid_bool():
|
|
parsed, errors = parse_chunk_params("drop_rf=maybe", selector="P", label="x")
|
|
assert parsed == {}
|
|
assert errors and "must be a boolean" in errors[0]
|
|
|
|
|
|
@pytest.mark.parametrize("block", ["drop_rf", "drop_references"])
|
|
def test_parse_chunk_params_drop_references_bare_flag_is_true(block):
|
|
# A bare boolean flag is shorthand for ``=true``.
|
|
parsed, errors = parse_chunk_params(block, selector="P", label="x")
|
|
assert errors == []
|
|
assert parsed == {"drop_references": True}
|
|
|
|
|
|
@pytest.mark.parametrize("selector", ["F", "R", "V"])
|
|
def test_parse_chunk_params_drop_references_rejected_off_paragraph(selector):
|
|
parsed, errors = parse_chunk_params("drop_rf=true", selector=selector, label="x")
|
|
assert parsed == {}
|
|
assert errors and f"not supported for chunk strategy {selector!r}" in errors[0]
|
|
|
|
|
|
def test_filename_hint_drop_references_extracted():
|
|
d = resolve_parser_directives("notes.[-P(drop_rf=true)].md", parser_rules="")
|
|
assert d.process_options == "P" # pure selector, no params
|
|
assert d.chunk_params == {"P": {"drop_references": True}}
|
|
|
|
|
|
def test_filename_hint_drop_references_bare_flag():
|
|
# ``P(drop_rf)`` with no value is shorthand for ``drop_rf=true``.
|
|
d = resolve_parser_directives("notes.[-P(drop_rf)].md", parser_rules="")
|
|
assert d.process_options == "P"
|
|
assert d.chunk_params == {"P": {"drop_references": True}}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"block,needle",
|
|
[
|
|
("chunk_ts=abc", "must be an integer"),
|
|
("chunk_ts=0", "must be >= 1"),
|
|
("foo=1", "unknown parameter 'foo'"),
|
|
("chunk_ts=1,chunk_ts=2", "may not be repeated"),
|
|
("correct_tl", "unknown parameter 'correct_tl'"),
|
|
# A non-boolean parameter cannot be written bare (only flags can).
|
|
("chunk_ts", "must be written as 'key=value'"),
|
|
("", "empty parameter"),
|
|
# Cross-field invariant: explicit overlap >= size is rejected here, so
|
|
# both rule and filename-hint validation reject it (not just enqueue).
|
|
("chunk_ts=50,chunk_ol=100", "must be < chunk_token_size"),
|
|
("chunk_ts=100,chunk_ol=100", "must be < chunk_token_size"),
|
|
],
|
|
)
|
|
def test_parse_chunk_params_errors(block, needle):
|
|
_parsed, errors = parse_chunk_params(block, selector="F", label="x")
|
|
assert errors and any(needle in e for e in errors)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# routing: filename hint params
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_filename_hint_selector_stays_pure_and_params_extracted():
|
|
d = resolve_parser_directives(
|
|
"notes.[-R(chunk_ts=800,chunk_ol=80)].md", parser_rules=""
|
|
)
|
|
assert d.engine == "legacy"
|
|
assert d.process_options == "R" # pure selector, no params
|
|
assert d.chunk_params == {
|
|
"R": {"chunk_token_size": 800, "chunk_overlap_token_size": 80}
|
|
}
|
|
assert d.engine_params == {}
|
|
|
|
|
|
def test_back_compat_two_tuple_and_directives_unaffected_by_params():
|
|
# The legacy 2-tuple accessor must keep returning (engine, pure-selector).
|
|
assert resolve_file_parser_directives(
|
|
"notes.[-R(chunk_ts=800)].md", parser_rules=""
|
|
) == ("legacy", "R")
|
|
assert filename_parser_directives("notes.[-R(chunk_ts=800)].md") == (None, "R")
|
|
# A param-free hint is completely unchanged.
|
|
assert filename_parser_directives("paper.[native-iet].docx") == ("native", "iet")
|
|
|
|
|
|
def test_canonicalize_strips_parameterised_hint():
|
|
assert (
|
|
canonicalize_parser_hinted_basename("notes.[-R(chunk_ts=800,chunk_ol=80)].md")
|
|
== "notes.md"
|
|
)
|
|
|
|
|
|
def test_engine_params_rejected_for_engine_that_declares_none():
|
|
# Chunk params on a chunk char still work...
|
|
d = resolve_parser_directives("doc.[native-F(chunk_ts=900)].docx", parser_rules="")
|
|
assert d.chunk_params == {"F": {"chunk_token_size": 900}}
|
|
# ...but native declares no engine params, so an engine-level block on it is
|
|
# a hard error on ingestion (Phase 2: only mineru/docling accept params).
|
|
with pytest.raises(FilenameParserHintError, match="does not accept parameters"):
|
|
resolve_file_parser_directives(
|
|
"doc.[native(do_ocr=true)-F].docx", parser_rules=""
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"name",
|
|
[
|
|
"x.[-V(chunk_ol=80)].md", # overlap invalid for V
|
|
"x.[-R(chunk_ts=abc)].md", # non-integer
|
|
"x.[native-R(foo=1)].md", # unknown parameter
|
|
"x.[-R(chunk_ts=50,chunk_ol=100)].md", # explicit overlap >= size
|
|
],
|
|
)
|
|
def test_upload_validation_raises_on_bad_filename_params(name):
|
|
with pytest.raises(FilenameParserHintError):
|
|
resolve_file_parser_directives(name, parser_rules="")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# routing: LIGHTRAG_PARSER rule params + overlay merge
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_rule_params_with_semicolon_separator_and_comma_in_parens():
|
|
d = resolve_parser_directives(
|
|
"a.md", parser_rules="md:legacy-R(chunk_ts=800,chunk_ol=80);*:legacy-R"
|
|
)
|
|
assert d.engine == "legacy"
|
|
assert d.chunk_params == {
|
|
"R": {"chunk_token_size": 800, "chunk_overlap_token_size": 80}
|
|
}
|
|
|
|
|
|
def test_rule_params_with_comma_separator_and_comma_in_parens():
|
|
# Rule splitting is parenthesis-aware: a comma inside a parameter block is
|
|
# NOT a rule separator, so ',' still separates rules even when a rule carries
|
|
# parameters (the docs recommend ';' but ',' must keep working).
|
|
assert split_top_level(
|
|
"md:legacy-R(chunk_ts=800,chunk_ol=80),*:legacy-R", ";,"
|
|
) == ["md:legacy-R(chunk_ts=800,chunk_ol=80)", "*:legacy-R"]
|
|
d = resolve_parser_directives(
|
|
"a.md", parser_rules="md:legacy-R(chunk_ts=800,chunk_ol=80),*:legacy-R"
|
|
)
|
|
assert d.engine == "legacy"
|
|
assert d.chunk_params == {
|
|
"R": {"chunk_token_size": 800, "chunk_overlap_token_size": 80}
|
|
}
|
|
|
|
|
|
def test_overlay_rule_then_filename_hint_wins_per_key():
|
|
# Design worked example: rule supplies chunk_ol on P, the filename hint
|
|
# supplies chunk_ts on P; the surviving selector is the filename's "P".
|
|
d = resolve_parser_directives(
|
|
"paper.[-P(chunk_ts=3000)].pdf", parser_rules="pdf:legacy-iteP(chunk_ol=100)"
|
|
)
|
|
assert d.process_options == "P" # filename options wholesale-override rule
|
|
assert d.chunk_params["P"] == {
|
|
"chunk_overlap_token_size": 100, # inherited from the rule
|
|
"chunk_token_size": 3000, # from the filename hint
|
|
}
|
|
|
|
|
|
def test_filename_hint_key_overrides_rule_key():
|
|
d = resolve_parser_directives(
|
|
"paper.[-R(chunk_ts=500)].md",
|
|
parser_rules="md:legacy-R(chunk_ts=900,chunk_ol=50)",
|
|
)
|
|
assert d.chunk_params["R"] == {
|
|
"chunk_token_size": 500, # filename wins over the rule's 900
|
|
"chunk_overlap_token_size": 50, # inherited from the rule
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# routing: startup validation
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_validate_parser_routing_config_accepts_good_params():
|
|
validate_parser_routing_config("pdf:legacy-R(chunk_ts=800,chunk_ol=80);*:legacy-R")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"rules,needle",
|
|
[
|
|
("pdf:legacy-V(chunk_ol=80)", "not supported for chunk strategy 'V'"),
|
|
("pdf:legacy-R(chunk_ts=abc)", "must be an integer"),
|
|
("pdf:legacy-R(foo=1)", "unknown parameter 'foo'"),
|
|
# Cross-field invariant: explicit overlap >= size is rejected at startup
|
|
# so a global rule cannot make every matching upload fail at enqueue.
|
|
("pdf:legacy-R(chunk_ts=50,chunk_ol=100)", "must be < chunk_token_size"),
|
|
("pdf:legacy-R(chunk_ts=100,chunk_ol=100)", "must be < chunk_token_size"),
|
|
],
|
|
)
|
|
def test_validate_parser_routing_config_rejects_bad_params(rules, needle):
|
|
with pytest.raises(ParserRoutingConfigError, match=needle):
|
|
validate_parser_routing_config(rules)
|
|
|
|
|
|
def test_validate_parser_routing_config_allows_one_sided_overlap():
|
|
# Only overlap is explicit: the effective size depends on env / addon_params
|
|
# and is unknown at config time, so startup accepts it and defers to the
|
|
# upload-time effective-overlap check on the resolved snapshot.
|
|
validate_parser_routing_config("pdf:legacy-R(chunk_ol=100);*:legacy-R")
|
|
|
|
|
|
def test_legacy_comma_rules_without_params_still_valid():
|
|
# Pure back-compat: legacy comma-separated, param-free rules unchanged.
|
|
validate_parser_routing_config("docx:native-iteP,pdf:legacy-R,*:legacy-R")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# upload path: filename-hint params reach the enqueued chunk_options
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _recording_rag():
|
|
import asyncio # noqa: F401 - imported for clarity at call sites
|
|
from types import SimpleNamespace
|
|
|
|
captured: dict = {}
|
|
|
|
async def fake_enqueue(content, **kwargs):
|
|
captured.update(kwargs)
|
|
return "track-xyz" # non-None -> success path
|
|
|
|
async def fake_error(files, track_id):
|
|
captured["error_files"] = files
|
|
|
|
rag = SimpleNamespace(
|
|
addon_params={},
|
|
apipeline_enqueue_documents=fake_enqueue,
|
|
apipeline_enqueue_error_documents=fake_error,
|
|
)
|
|
return rag, captured
|
|
|
|
|
|
def test_upload_path_applies_hint_chunk_params(tmp_path):
|
|
import asyncio
|
|
|
|
from lightrag.api.routers.document_routes import pipeline_enqueue_file
|
|
|
|
rag, captured = _recording_rag()
|
|
f = tmp_path / "notes.[-R(chunk_ts=800,chunk_ol=80)].md"
|
|
f.write_text("hello world", encoding="utf-8")
|
|
|
|
ok, _track = asyncio.run(pipeline_enqueue_file(rag, f))
|
|
|
|
assert ok is True
|
|
assert "error_files" not in captured
|
|
assert captured["parse_engine"] == "legacy"
|
|
assert captured["process_options"] == "R" # pure selector forwarded
|
|
sub = captured["chunk_options"]["recursive_character"]
|
|
assert sub["chunk_token_size"] == 800
|
|
assert sub["chunk_overlap_token_size"] == 80
|
|
|
|
|
|
def test_upload_path_without_params_omits_chunk_options(tmp_path):
|
|
import asyncio
|
|
|
|
from lightrag.api.routers.document_routes import pipeline_enqueue_file
|
|
|
|
rag, captured = _recording_rag()
|
|
f = tmp_path / "plain.[native-R].md"
|
|
f.write_text("hello world", encoding="utf-8")
|
|
|
|
ok, _track = asyncio.run(pipeline_enqueue_file(rag, f))
|
|
|
|
assert ok is True
|
|
# Legacy behaviour: no per-file chunk_options when the hint has no params.
|
|
assert "chunk_options" not in captured
|
|
|
|
|
|
def test_upload_path_rejects_bad_hint_params(tmp_path):
|
|
import asyncio
|
|
|
|
from lightrag.api.routers.document_routes import pipeline_enqueue_file
|
|
|
|
rag, captured = _recording_rag()
|
|
f = tmp_path / "bad.[-V(chunk_ol=80)].md"
|
|
f.write_text("hello world", encoding="utf-8")
|
|
|
|
ok, _track = asyncio.run(pipeline_enqueue_file(rag, f))
|
|
|
|
assert ok is False
|
|
assert captured["error_files"] # an error document was enqueued
|
|
assert "chunk_options" not in captured
|
|
|
|
|
|
def test_upload_path_rejects_effective_overlap_violation(tmp_path):
|
|
import asyncio
|
|
|
|
from lightrag.api.routers.document_routes import pipeline_enqueue_file
|
|
|
|
rag, captured = _recording_rag()
|
|
# An explicit overlap (100) >= size (50) pair is rejected up front by
|
|
# filename-hint validation (uniform with rule validation), so the file
|
|
# never enqueues and the overlap message is surfaced in the error document.
|
|
f = tmp_path / "tiny.[-R(chunk_ts=50,chunk_ol=100)].md"
|
|
f.write_text("hello world", encoding="utf-8")
|
|
|
|
ok, _track = asyncio.run(pipeline_enqueue_file(rag, f))
|
|
|
|
assert ok is False
|
|
assert captured["error_files"]
|
|
assert "must be < chunk_token_size" in captured["error_files"][0]["original_error"]
|