"""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"]