Files
ragflow/test/unit_test/rag/test_delimiter_case_sensitive.py
S d4ceeee4ed refactor(nlp): collapse 6 delimiter-parser implementations into one canonical helper (#17383) (#17387)
## Summary

Six sites used to read the same `parser_config.delimiter` field with
divergent grammars:

- `rag.nlp.get_delimiters` (PDF/DOCX/HTML/EPUB/JSON/CSV/XLSX/email/book)
- `rag.nlp.naive_merge` (custom-delimiter branch)
- `rag.nlp.naive_merge_with_images`
- `rag.nlp._build_cks`
- `deepdoc.parser.txt_parser.parser_txt` (.txt, code)
-
`deepdoc.parser.markdown_parser.MarkdownElementExtractor.get_delimiters`

The six implementations disagreed on bare-vs-wrapped chars, dedupe, sort
order, CRLF normalization, and `re.I` (#17384). The shipped default ``
`\n!?;。;!?` `` was a no-op for `.md` because the markdown path only
matched backtick-wrapped tokens.

## Changes

- **new:** `rag/nlp/delim.py` with `parse_delimiter_field` and
`compile_delimiter_pattern`. Single source of truth. CRLF normalization
at the top; longest-first stable sort; insertion-ordered dedupe; no
`re.I`.
- **refactor:** all six call sites delegate to the helper.
  - `rag/nlp/__init__.py::get_delimiters` becomes a thin shim.
- `deepdoc/parser/txt_parser.py::parser_txt` drops the
`[encode/decode/unicode_escape]` round-trip.
- `deepdoc/parser/markdown_parser.py::get_delimiters` honors bare chars
(fixes [1]).
- **tests:** `test/unit_test/rag/test_delim.py` (85 tests) — helper,
acceptance table, frontend parity, static guard against re-inlining.
- **tests:** `test/unit_test/rag/test_delimiter_case_sensitive.py` (from
#17386) updated to retarget the static check at the new helper +
AST-based broader scan.

## Acceptance criteria

- All six sites produce the same regex pattern for the same input.
- Shipped default keeps working for `.txt` / `.pdf` / `.docx`.
- Shipped default for `.md` now splits (was a silent no-op).
- Tooltip example `` `\n##;` `` produces three effective delimiters
regardless of file type.
- Bare whitespace inputs split on every occurrence.
- Backtick-wrapped whitespace splits only on the exact N-char sequence.
- CRLF-line-ending documents split identically to LF-line-ending
documents.
- 123 tests pass (85 new + 38 existing).

## Rebase protocol

As #17385 and #17386 evolve, this branch will be rebased on top. The
only overlap between this PR's diff and the other two is
`test_delimiter_case_sensitive.py`, where #17383 modifies the static
check to point at the new helper location.

---------

Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com>
2026-08-02 17:07:14 +08:00

216 lines
9.2 KiB
Python

#
# Copyright 2025 The InfiniFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""Regression tests for case-sensitive delimiter parsing.
Locks in case-sensitive matching for the canonical delimiter parser
(#17384, #17383). The flag is currently dead code — it does not propagate
from ``re.finditer`` to ``m.group(1)`` or to downstream ``re.split`` /
``re.match`` calls — but the inconsistency with the three sibling
implementations was misleading. After #17383 all six divergent
implementations were collapsed into ``rag.nlp.delim.parse_delimiter_field``,
which is the single site these tests guard against regressing.
Affected site (after #17383 consolidation)
------------------------------------------
* ``rag.nlp.delim.parse_delimiter_field`` (the only ``re.finditer`` call
in the canonical parser module)
Sibling sites that previously diverged (now consolidated)
--------------------------------------------------------
* ``rag.nlp.naive_merge`` custom-delimiter path (now delegates to ``delim``)
* ``rag.nlp.naive_merge_with_images`` custom-delimiter path (now delegates)
* ``rag.nlp._build_cks`` (now delegates)
* ``rag.nlp.get_delimiters`` (now a backwards-compat shim over ``delim``)
* ``deepdoc.parser.txt_parser.parser_txt`` (now delegates)
* ``deepdoc.parser.markdown_parser.MarkdownElementExtractor.get_delimiters``
(now delegates)
"""
from __future__ import annotations
import ast
import re
from pathlib import Path
import pytest
pytestmark = pytest.mark.usefixtures("pdf_parser_stub")
from rag import nlp
from rag.nlp import naive_merge
from rag.nlp.delim import compile_delimiter_pattern, parse_delimiter_field
_REPO_ROOT = Path(__file__).resolve().parents[3]
def _get_delim_pattern(field: str) -> str:
return compile_delimiter_pattern(parse_delimiter_field(field))
# --------------------------------------------------------------------------- #
# delim helper — direct pattern checks
# --------------------------------------------------------------------------- #
def test_get_delimiters_bare_char_a_returns_literal_pattern():
"""Bare-char delimiter ``a`` must produce the pattern ``a``, not ``a|A``."""
assert _get_delim_pattern("a") == "a"
def test_get_delimiters_bare_char_A_returns_literal_pattern():
assert _get_delim_pattern("A") == "A"
def test_get_delimiters_backtick_end_returns_exact_token():
"""Backtick-wrapped delimiter must preserve the captured group verbatim.
A regression that introduced case-insensitive alternation would produce
``end|End|END|eNd|...`` instead of the literal ``end``.
"""
assert _get_delim_pattern("`end`") == "end"
def test_get_delimiters_pattern_splits_case_sensitively():
"""The pattern returned by ``compile_delimiter_pattern`` must split case-sensitively
when fed to ``re.split`` without any flags."""
pat = _get_delim_pattern("a")
# Only the lowercase 'a' splits; uppercase 'A' is preserved intact.
assert re.split(f"({pat})", "AaBb") == ["A", "a", "Bb"]
# --------------------------------------------------------------------------- #
# naive_merge — end-to-end (exercises get_delimiters + re.split)
# --------------------------------------------------------------------------- #
@pytest.fixture(autouse=True)
def force_every_section_above_budget(monkeypatch):
"""Mock ``num_tokens_from_string`` so every section trips the chunk-size
guard and starts a fresh chunk. This isolates delimiter behavior from
chunk-size heuristics."""
def fake(_s):
return 9 if len(_s) >= 4 else 8
monkeypatch.setattr(nlp, "num_tokens_from_string", fake)
def test_naive_merge_bare_char_a_splits_only_at_lowercase_a():
"""Bare-char ``a`` must split only at lowercase ``a``, not at ``A``."""
chunks = naive_merge(["BaAb"], chunk_token_num=8, delimiter="a")
assert [c.strip() for c in chunks if c.strip()] == ["B", "Ab"]
def test_naive_merge_bare_char_A_splits_only_at_uppercase_A():
chunks = naive_merge(["BaAb"], chunk_token_num=8, delimiter="A")
assert [c.strip() for c in chunks if c.strip()] == ["Ba", "b"]
def test_naive_merge_backtick_end_splits_only_at_lowercase_end():
"""Backtick-wrapped ``end`` must split only at the exact lowercase
``end``, not at ``End`` / ``END`` / ``eNd`` / etc."""
chunks = naive_merge(
["the end and End and END come"],
chunk_token_num=8,
delimiter="`end`",
)
assert [c.strip() for c in chunks if c.strip()] == [
"the",
"and End and END come",
]
# --------------------------------------------------------------------------- #
# Static source checks — guard against re.I creeping back into the canonical
# delimiter parser.
#
# After #17383, the six divergent parser implementations were collapsed into
# ``rag/nlp/delim.py`` (one ``re.finditer`` site). The previous locations
# (``rag/nlp/__init__.py`` ~1633, ``deepdoc/parser/txt_parser.py`` ~51) no
# longer have ``re.finditer`` calls — they delegate to the helper. The
# single line-number-based check below therefore targets the new helper,
# and a broader check (over the whole module) guards against re.I leaking
# into any backtick-pattern regex in the parser module.
# --------------------------------------------------------------------------- #
_BACKTICK_RE_SOURCES = [
# The canonical helper. After #17383, this is the single place where
# ``re.finditer`` for the `` `[^`]+` `` pattern lives.
("rag/nlp/delim.py", "parse_delimiter_field"),
]
def _function_source(source: str, function_name: str) -> str:
"""Return the source text of a function by AST line range."""
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == function_name:
lines = source.splitlines(keepends=True)
return "".join(lines[node.lineno - 1 : node.end_lineno])
raise AssertionError(f"function {function_name!r} not found")
@pytest.mark.parametrize("rel_path, function_name", _BACKTICK_RE_SOURCES)
def test_no_re_I_on_re_finditer(rel_path, function_name):
"""The ``re.finditer`` calls in the canonical delimiter parser must not
pass ``re.I`` (or any case-insensitive flag) to the regex engine.
Why this matters even though the flag is currently dead code: keeping
the parser consistent makes a future refactor less likely to propagate
the flag to a downstream ``re.split`` / ``re.match`` call where it
would actually change behavior.
"""
source = (_REPO_ROOT / rel_path).read_text(encoding="utf-8")
body = _function_source(source, function_name)
# Either `re.finditer(...)` directly, or a precompiled regex with
# `.finditer(...)` (e.g. `_BACKTICK_RE.finditer(normalized)`).
has_finditer = "re.finditer" in body or ".finditer(" in body
assert has_finditer, f"expected at least one `re.finditer` (or `.finditer`) in {function_name} (see issue #17384)"
assert "re.I" not in body and "re.IGNORECASE" not in body, f"`re.I` / `re.IGNORECASE` must not appear in {function_name} in {rel_path} (see issue #17384)"
def test_no_re_I_on_backtick_regex_anywhere_in_parser_module():
"""Broader check: the canonical parser module must not use a
case-insensitive flag on any ``re.finditer`` / ``re.findall`` /
``re.compile`` that targets the backtick regex. Future refactors that
add a new ``re.finditer`` call elsewhere in the module would otherwise
silently regress the case-sensitive matching semantics.
"""
source = (_REPO_ROOT / "rag/nlp/delim.py").read_text(encoding="utf-8")
tree = ast.parse(source)
# Collect every `re.finditer` / `re.findall` / `re.compile` call and
# ensure none of them pass `re.I` / `re.IGNORECASE`.
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
# Match `re.finditer(...)` / `re.findall(...)` / `re.compile(...)`
if not (isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name) and func.value.id == "re"):
continue
if func.attr not in ("finditer", "findall", "compile"):
continue
for kw in node.keywords:
if kw.arg == "flags":
flag_node = kw.value
if isinstance(flag_node, ast.Attribute) and flag_node.attr in ("I", "IGNORECASE"):
raise AssertionError(f"`re.{flag_node.attr}` must not be passed as `flags=` to `re.{func.attr}` in rag/nlp/delim.py (see #17384)")
if isinstance(flag_node, ast.BinOp):
for sub in ast.walk(flag_node):
if isinstance(sub, ast.Attribute) and sub.attr in ("I", "IGNORECASE"):
raise AssertionError(f"`re.{sub.attr}` must not appear in a `flags=` expression passed to `re.{func.attr}` in rag/nlp/delim.py (see #17384)")