1352 lines
50 KiB
Python
1352 lines
50 KiB
Python
"""Regression tests for paragraph-semantic TableRowSplit oversized-table handling."""
|
|
|
|
import json
|
|
import logging
|
|
|
|
import pytest
|
|
|
|
from lightrag.chunker.paragraph_semantic import (
|
|
_detect_table_format,
|
|
_expand_block_with_table_splits,
|
|
_merge_small_blocks,
|
|
_new_block,
|
|
_split_html_rows,
|
|
_split_long_block,
|
|
_split_rows_by_tokens,
|
|
_split_table_text,
|
|
chunking_by_paragraph_semantic,
|
|
)
|
|
from lightrag.utils import Tokenizer, TokenizerInterface
|
|
|
|
|
|
class _CharTokenizer(TokenizerInterface):
|
|
"""1:1 character-to-token mapping — keeps math obvious in assertions."""
|
|
|
|
def encode(self, content: str):
|
|
return [ord(ch) for ch in content]
|
|
|
|
def decode(self, tokens):
|
|
return "".join(chr(t) for t in tokens)
|
|
|
|
|
|
def _make_tokenizer() -> Tokenizer:
|
|
return Tokenizer(model_name="char", tokenizer=_CharTokenizer())
|
|
|
|
|
|
@pytest.fixture
|
|
def _propagate_lightrag_logger(monkeypatch):
|
|
# The ``lightrag`` logger sets ``propagate=False`` (see lightrag.utils), so
|
|
# caplog's root handler captures nothing by default. Flip it on for the
|
|
# duration of a test that asserts on the degrade warning.
|
|
monkeypatch.setattr(logging.getLogger("lightrag"), "propagate", True)
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_split_rows_by_tokens_few_rows_huge_total_no_empty_slice():
|
|
# Reproduces the bug where target_chunks > len(rows) made target_rows
|
|
# < 1, so int((i+1)*target_rows) collapsed to start and the loop
|
|
# appended empty slices (which would later serialise as <table>[]…).
|
|
tokenizer = _make_tokenizer()
|
|
# 3 rows that each individually exceed target_max — forces
|
|
# math.ceil(total/target_ideal) and math.ceil(total/target_max) to
|
|
# both be much greater than len(rows).
|
|
rows = [
|
|
[{"col": "x" * 800}],
|
|
[{"col": "y" * 800}],
|
|
[{"col": "z" * 800}],
|
|
]
|
|
|
|
chunks = _split_rows_by_tokens(
|
|
rows,
|
|
tokenizer,
|
|
target_max=200,
|
|
target_ideal=150,
|
|
last_min=64,
|
|
)
|
|
|
|
assert chunks, "expected at least one chunk"
|
|
for chunk in chunks:
|
|
assert chunk, "TableRowSplit must never emit an empty row slice"
|
|
# Concatenation preserves all rows in order.
|
|
flat: list = []
|
|
for chunk in chunks:
|
|
flat.extend(chunk)
|
|
assert flat == rows
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_split_rows_by_tokens_balanced_split_yields_one_row_per_chunk():
|
|
# When target_chunks gets capped at len(rows), each chunk holds one
|
|
# row — verifies the cap kicks in and forward progress is preserved.
|
|
tokenizer = _make_tokenizer()
|
|
rows = [[{"col": "a" * 300}] for _ in range(4)]
|
|
|
|
chunks = _split_rows_by_tokens(
|
|
rows,
|
|
tokenizer,
|
|
target_max=200,
|
|
target_ideal=150,
|
|
last_min=10, # low enough that the tail-merge step doesn't fire
|
|
)
|
|
|
|
assert all(chunk for chunk in chunks)
|
|
# Each row appears exactly once across the chunks.
|
|
flat: list = []
|
|
for chunk in chunks:
|
|
flat.extend(chunk)
|
|
assert flat == rows
|
|
|
|
|
|
def _build_oversized_table_text(num_rows: int, row_payload_size: int) -> str:
|
|
rows = [[f"r{idx}-" + "x" * row_payload_size] for idx in range(num_rows)]
|
|
return f'<table id="tb-1" format="json">{json.dumps(rows)}</table>'
|
|
|
|
|
|
def _write_blocks_jsonl(tmp_path, content: str) -> str:
|
|
path = tmp_path / "doc.blocks.jsonl"
|
|
row = {
|
|
"type": "content",
|
|
"heading": "Section",
|
|
"parent_headings": [],
|
|
"level": 2,
|
|
"content": content,
|
|
}
|
|
path.write_text(json.dumps(row, ensure_ascii=False), encoding="utf-8")
|
|
return str(path)
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_expand_block_assigns_first_and_last_roles_to_glued_blocks():
|
|
# An oversized table sandwiched between leading and trailing paragraphs
|
|
# produces three slices: "first" (glued with leading paras),
|
|
# "middle" (standalone), "last" (glued with trailing paras). Before
|
|
# the fix, the first/last blocks defaulted to "none" and lost their
|
|
# directional merge-protection.
|
|
tokenizer = _make_tokenizer()
|
|
table_text = _build_oversized_table_text(num_rows=6, row_payload_size=200)
|
|
block = {
|
|
"heading": "Section",
|
|
"parent_headings": ["Doc"],
|
|
"level": 2,
|
|
"paragraphs": [
|
|
{"text": "lead paragraph", "is_table": False},
|
|
{"text": table_text, "is_table": True},
|
|
{"text": "trailing paragraph", "is_table": False},
|
|
],
|
|
}
|
|
|
|
out = _expand_block_with_table_splits(
|
|
block,
|
|
tokenizer=tokenizer,
|
|
table_max=400,
|
|
table_ideal=300,
|
|
table_min_last=128,
|
|
)
|
|
|
|
roles = [b["table_chunk_role"] for b in out]
|
|
assert roles[0] == "first", f"expected leading block role=first, got {roles}"
|
|
assert roles[-1] == "last", f"expected trailing block role=last, got {roles}"
|
|
assert all(r == "middle" for r in roles[1:-1]), (
|
|
f"expected middle slices between first/last, got {roles}"
|
|
)
|
|
|
|
# Boundary glue still works: leading text sits inside the first block,
|
|
# trailing text sits inside the last block.
|
|
assert any(p["text"] == "lead paragraph" for p in out[0]["paragraphs"]), (
|
|
"leading paragraph must glue with the first table slice"
|
|
)
|
|
assert any(p["text"] == "trailing paragraph" for p in out[-1]["paragraphs"]), (
|
|
"trailing paragraph must glue with the last table slice"
|
|
)
|
|
assert all("表格片段" not in b["heading"] for b in out), (
|
|
"TableRowSplit should not expose legacy table-fragment heading suffixes"
|
|
)
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_expand_block_two_oversized_tables_separates_last_and_first_roles():
|
|
# Two oversized tables in the same heading block: the tail of the first
|
|
# split must carry role="last" and not be silently merged into the
|
|
# head of the second split (which must carry role="first").
|
|
tokenizer = _make_tokenizer()
|
|
block = {
|
|
"heading": "Section",
|
|
"parent_headings": [],
|
|
"level": 2,
|
|
"paragraphs": [
|
|
{
|
|
"text": _build_oversized_table_text(num_rows=4, row_payload_size=200),
|
|
"is_table": True,
|
|
},
|
|
{"text": "between tables", "is_table": False},
|
|
{
|
|
"text": _build_oversized_table_text(num_rows=4, row_payload_size=200),
|
|
"is_table": True,
|
|
},
|
|
],
|
|
}
|
|
|
|
out = _expand_block_with_table_splits(
|
|
block,
|
|
tokenizer=tokenizer,
|
|
table_max=400,
|
|
table_ideal=300,
|
|
table_min_last=128,
|
|
)
|
|
|
|
roles = [b["table_chunk_role"] for b in out]
|
|
# We expect the role sequence to start with "first", end with "last",
|
|
# and contain at least one "last" -> "first" transition (the boundary
|
|
# between the two oversized tables) without any boundary block losing
|
|
# its role.
|
|
assert roles[0] == "first"
|
|
assert roles[-1] == "last"
|
|
assert "last" in roles
|
|
# The transition: there must be a "last" immediately followed by a
|
|
# "first" somewhere in the middle of the role sequence.
|
|
transitions = list(zip(roles, roles[1:]))
|
|
assert ("last", "first") in transitions, (
|
|
f"expected a last->first boundary between the two split tables, got {roles}"
|
|
)
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_expand_block_duplicates_short_text_between_oversized_tables():
|
|
tokenizer = _make_tokenizer()
|
|
bridge = "between tables"
|
|
block = {
|
|
"heading": "Section",
|
|
"parent_headings": [],
|
|
"level": 2,
|
|
"paragraphs": [
|
|
{
|
|
"text": _build_oversized_table_text(num_rows=4, row_payload_size=200),
|
|
"is_table": True,
|
|
},
|
|
{"text": bridge, "is_table": False},
|
|
{
|
|
"text": _build_oversized_table_text(num_rows=4, row_payload_size=200),
|
|
"is_table": True,
|
|
},
|
|
],
|
|
}
|
|
|
|
out = _expand_block_with_table_splits(
|
|
block,
|
|
tokenizer=tokenizer,
|
|
table_max=400,
|
|
table_ideal=300,
|
|
table_min_last=128,
|
|
target_max=800,
|
|
chunk_overlap_token_size=100,
|
|
)
|
|
|
|
roles = [b["table_chunk_role"] for b in out]
|
|
boundary_idx = next(
|
|
i
|
|
for i, (left, right) in enumerate(zip(roles, roles[1:]))
|
|
if (left, right) == ("last", "first")
|
|
)
|
|
assert bridge in out[boundary_idx]["content"]
|
|
assert bridge in out[boundary_idx + 1]["content"]
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_expand_block_emits_middle_text_when_table_bridge_is_long():
|
|
tokenizer = _make_tokenizer()
|
|
bridge = ("A" * 45) + ("B" * 50) + ("C" * 45)
|
|
block = {
|
|
"heading": "Section",
|
|
"parent_headings": [],
|
|
"level": 2,
|
|
"paragraphs": [
|
|
{
|
|
"text": _build_oversized_table_text(num_rows=6, row_payload_size=120),
|
|
"is_table": True,
|
|
},
|
|
{"text": bridge, "is_table": False},
|
|
{
|
|
"text": _build_oversized_table_text(num_rows=6, row_payload_size=120),
|
|
"is_table": True,
|
|
},
|
|
],
|
|
}
|
|
|
|
out = _expand_block_with_table_splits(
|
|
block,
|
|
tokenizer=tokenizer,
|
|
table_max=260,
|
|
table_ideal=180,
|
|
table_min_last=32,
|
|
target_max=400,
|
|
chunk_overlap_token_size=45,
|
|
)
|
|
|
|
# The standalone middle block carries R-style overlap with the text that
|
|
# went left (prefix) and right (suffix): because each side's slice is
|
|
# itself ≤ the overlap budget, the middle re-covers the whole bridge — the
|
|
# bridge is never fragmented, only its head/tail are *also* duplicated into
|
|
# the neighbouring table blocks.
|
|
middle_idx = next(
|
|
i
|
|
for i, blk in enumerate(out)
|
|
if blk["table_chunk_role"] == "none" and blk["content"] == bridge
|
|
)
|
|
assert out[middle_idx - 1]["table_chunk_role"] == "last"
|
|
assert "A" * 45 in out[middle_idx - 1]["content"]
|
|
assert "B" * 50 not in out[middle_idx - 1]["content"]
|
|
assert out[middle_idx + 1]["table_chunk_role"] == "first"
|
|
assert out[middle_idx + 1]["content"].startswith("C" * 45)
|
|
assert "B" * 50 not in out[middle_idx + 1]["content"]
|
|
# The overlap never drags table markup into the middle text block.
|
|
assert "<table" not in out[middle_idx]["content"]
|
|
assert all(b["tokens"] <= 400 for b in out), [b["tokens"] for b in out]
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_bridge_single_side_budget_capped_at_half_target_max():
|
|
# §7 guarantee: even with a huge configured overlap, the bridge text
|
|
# duplicated into each table boundary block is capped at target_max // 2
|
|
# so it can never dominate the block. The bridge is a long run of a
|
|
# character that never appears in the table payload, so counting it in
|
|
# each boundary block measures exactly how much bridge text was copied in.
|
|
tokenizer = _make_tokenizer()
|
|
bridge = "Z" * 1000
|
|
block = {
|
|
"heading": "Section",
|
|
"parent_headings": [],
|
|
"level": 2,
|
|
"paragraphs": [
|
|
{
|
|
"text": _build_oversized_table_text(num_rows=8, row_payload_size=30),
|
|
"is_table": True,
|
|
},
|
|
{"text": bridge, "is_table": False},
|
|
{
|
|
"text": _build_oversized_table_text(num_rows=8, row_payload_size=30),
|
|
"is_table": True,
|
|
},
|
|
],
|
|
}
|
|
target_max = 400
|
|
half = target_max // 2
|
|
|
|
out = _expand_block_with_table_splits(
|
|
block,
|
|
tokenizer=tokenizer,
|
|
table_max=200,
|
|
table_ideal=140,
|
|
table_min_last=48,
|
|
target_max=target_max,
|
|
chunk_overlap_token_size=10_000, # huge → only the half-cap can bind
|
|
)
|
|
|
|
# The long bridge yields [... "last"+prefix, "none" full-bridge middle,
|
|
# suffix+"first" ...]; locate the middle and read its neighbours.
|
|
middle_idx = next(
|
|
i
|
|
for i, blk in enumerate(out)
|
|
if blk["table_chunk_role"] == "none" and blk["content"] == bridge
|
|
)
|
|
left = out[middle_idx - 1]
|
|
right = out[middle_idx + 1]
|
|
assert left["table_chunk_role"] == "last"
|
|
assert right["table_chunk_role"] == "first"
|
|
left_z = left["content"].count("Z")
|
|
right_z = right["content"].count("Z")
|
|
# The huge overlap would have copied far more than `half` without the cap;
|
|
# the bridge (1000) and the small table slices leave ample headroom, so the
|
|
# half-cap is the binding constraint and each side gets exactly `half`.
|
|
assert left_z == half, left_z
|
|
assert right_z == half, right_z
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_public_chunking_adds_part_suffixes_to_all_table_split_fragments(tmp_path):
|
|
tokenizer = _make_tokenizer()
|
|
body = "\n".join(
|
|
[
|
|
"lead paragraph",
|
|
_build_oversized_table_text(num_rows=6, row_payload_size=200),
|
|
"trailing paragraph",
|
|
]
|
|
)
|
|
blocks_path = _write_blocks_jsonl(tmp_path, body)
|
|
|
|
chunks = chunking_by_paragraph_semantic(
|
|
tokenizer,
|
|
body,
|
|
chunk_token_size=800,
|
|
blocks_path=blocks_path,
|
|
chunk_overlap_token_size=0,
|
|
)
|
|
|
|
assert len(chunks) > 1
|
|
assert [chunk["heading"]["heading"] for chunk in chunks] == [
|
|
f"Section [part {idx}]" for idx in range(1, len(chunks) + 1)
|
|
]
|
|
assert all("表格片段" not in chunk["heading"]["heading"] for chunk in chunks)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Table-aware fallback tests (row-boundary first, character last).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_detect_table_format_explicit_attr():
|
|
assert _detect_table_format('id="t1" format="json"', "[]") == "json"
|
|
assert _detect_table_format("format='html'", "<tr></tr>") == "html"
|
|
# Unknown formats fall through (force the caller to use char fallback).
|
|
assert _detect_table_format('format="markdown"', "...") is None
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_detect_table_format_sniff_when_attrs_silent():
|
|
assert _detect_table_format("", '[{"a":1}]') == "json"
|
|
assert _detect_table_format("", "<tr><td>x</td></tr>") == "html"
|
|
# Body that doesn't look like JSON or HTML → unknown.
|
|
assert _detect_table_format("", "plain text rows") is None
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_split_html_rows_extracts_tr_elements():
|
|
body = (
|
|
"<thead><tr><th>h</th></tr></thead>"
|
|
"<tbody><tr><td>a</td></tr><tr><td>b</td></tr></tbody>"
|
|
)
|
|
rows = _split_html_rows(body)
|
|
assert rows is not None
|
|
assert len(rows) == 3
|
|
# Each row carries its parent wrapper so the chunk serialiser can
|
|
# rebuild <thead>/<tbody> instead of dropping them silently.
|
|
assert [w for w, _ in rows] == ["thead", "tbody", "tbody"]
|
|
assert all(tr.startswith("<tr") and tr.endswith("</tr>") for _, tr in rows)
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_split_html_rows_no_tr_returns_none():
|
|
assert _split_html_rows("just text, no rows") is None
|
|
assert _split_html_rows("") is None
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_split_table_text_single_row_oversized_falls_to_character_split():
|
|
# A 1-row table whose single cell is huge cannot be reduced via row
|
|
# boundary — the function must fall to character splitting and respect
|
|
# target_max on every output piece.
|
|
tokenizer = _make_tokenizer()
|
|
rows = [[{"col": "x" * 2000}]]
|
|
table_text = f'<table id="tb-1" format="json">{json.dumps(rows)}</table>'
|
|
|
|
pieces = _split_table_text(
|
|
table_text,
|
|
tokenizer=tokenizer,
|
|
target_max=500,
|
|
target_ideal=350,
|
|
last_min=128,
|
|
)
|
|
|
|
assert len(pieces) >= 2, "single-row oversized table must produce multiple pieces"
|
|
# Every piece honors the cap (this is the contract violation the user
|
|
# reported when the previous code emitted a single 2000-token table).
|
|
assert all(_count_tokens(tokenizer, p) <= 500 for p in pieces)
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_split_table_text_multirow_one_huge_row_degrades_whole_table_to_recursive():
|
|
# A multi-row table where most rows fit but one row is itself huge. A single
|
|
# row that exceeds the cap can no longer be expressed at row boundaries, so
|
|
# the WHOLE table degrades to a recursive character split (rather than
|
|
# emitting a mix of <table> slices and orphaned character fragments).
|
|
tokenizer = _make_tokenizer()
|
|
small_row = [{"col": "ok"}]
|
|
huge_row = [{"col": "z" * 2000}]
|
|
rows = [small_row, huge_row, small_row]
|
|
table_text = f'<table id="tb-1" format="json">{json.dumps(rows)}</table>'
|
|
|
|
pieces = _split_table_text(
|
|
table_text,
|
|
tokenizer=tokenizer,
|
|
target_max=500,
|
|
target_ideal=350,
|
|
last_min=64,
|
|
)
|
|
|
|
assert len(pieces) >= 2, "oversized row must force a multi-piece recursive split"
|
|
assert all(_count_tokens(tokenizer, p) <= 500 for p in pieces)
|
|
# The whole table degraded: it was shredded as one recursive split, so no
|
|
# piece survives as a complete re-wrapped <table>...</table> slice (the old
|
|
# mixed output kept legal per-row table slices alongside text fragments).
|
|
assert not any(p.startswith("<table ") and p.endswith("</table>") for p in pieces)
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_split_table_text_html_table_split_by_tr():
|
|
# HTML-format table: rows are <tr>...</tr>; each output fragment must
|
|
# remain a legal <table {attrs}>{rows}</table> string.
|
|
tokenizer = _make_tokenizer()
|
|
body = "".join(f"<tr><td>{'r' * 200}</td></tr>" for _ in range(5))
|
|
table_text = f'<table id="tb-h1" format="html">{body}</table>'
|
|
|
|
pieces = _split_table_text(
|
|
table_text,
|
|
tokenizer=tokenizer,
|
|
target_max=500,
|
|
target_ideal=350,
|
|
last_min=64,
|
|
)
|
|
|
|
assert len(pieces) >= 2
|
|
# All pieces should be legal <table>...</table> fragments (none of the
|
|
# rows individually exceeds target_max, so no character fallback).
|
|
assert all(p.startswith("<table ") and p.endswith("</table>") for p in pieces)
|
|
assert all(_count_tokens(tokenizer, p) <= 500 for p in pieces)
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_split_table_text_html_preserves_thead_tbody_wrappers():
|
|
# When an HTML table mixes <thead> and <tbody>, the row splitter
|
|
# used to drop the wrappers entirely — the chunked output came back
|
|
# as bare <tr> sequences. The fix re-emits each wrapper around its
|
|
# rows in every chunk so the table structure survives splitting.
|
|
tokenizer = _make_tokenizer()
|
|
head_row = "<tr><th>" + ("h" * 80) + "</th></tr>"
|
|
body_rows = "".join(f"<tr><td>{'b' * 80}{i}</td></tr>" for i in range(4))
|
|
body = f"<thead>{head_row}</thead><tbody>{body_rows}</tbody>"
|
|
table_text = f'<table id="tb-mixed" format="html">{body}</table>'
|
|
|
|
pieces = _split_table_text(
|
|
table_text,
|
|
tokenizer=tokenizer,
|
|
target_max=400,
|
|
target_ideal=280,
|
|
last_min=64,
|
|
)
|
|
|
|
# Multiple chunks expected and every chunk must remain a legal
|
|
# <table>-wrapped fragment.
|
|
assert len(pieces) >= 2
|
|
assert all(p.startswith("<table ") and p.endswith("</table>") for p in pieces)
|
|
# Every chunk that contains the header row must still wrap it in
|
|
# <thead>...</thead>; every chunk with body rows must wrap them in
|
|
# <tbody>...</tbody>. Before the fix, both wrappers vanished.
|
|
for piece in pieces:
|
|
if "<th>" in piece:
|
|
assert "<thead>" in piece and "</thead>" in piece, piece
|
|
if "<td>" in piece:
|
|
assert "<tbody>" in piece and "</tbody>" in piece, piece
|
|
# Round-trip: concatenating just the row payloads from every chunk
|
|
# recovers the original row sequence in order.
|
|
extracted_rows: list[str] = []
|
|
import re
|
|
|
|
for piece in pieces:
|
|
extracted_rows.extend(
|
|
re.findall(r"<tr\b[^>]*>.*?</tr>", piece, re.DOTALL | re.IGNORECASE)
|
|
)
|
|
expected_rows = re.findall(r"<tr\b[^>]*>.*?</tr>", body, re.DOTALL | re.IGNORECASE)
|
|
assert extracted_rows == expected_rows
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_split_table_text_html_injects_thead_into_nonfirst_slices():
|
|
"""A long HTML table with an HTML <thead> header_body: the first slice keeps
|
|
its own in-body <thead> (not double-injected); every middle/last slice has
|
|
the raw <thead> spliced in exactly once, with rowspan/colspan preserved;
|
|
every slice stays within the cap."""
|
|
tokenizer = _make_tokenizer()
|
|
header = (
|
|
'<thead><tr><th rowspan="2">Metric</th><th colspan="2">Group</th></tr></thead>'
|
|
)
|
|
head_row = '<tr><th rowspan="2">Metric</th><th colspan="2">Group</th></tr>'
|
|
body_rows = "".join(f"<tr><td>{'d' * 120}{i}</td></tr>" for i in range(6))
|
|
# Body carries its own <thead> (the source header) plus the data rows.
|
|
body = f"<thead>{head_row}</thead><tbody>{body_rows}</tbody>"
|
|
table_text = f'<table id="tb-h" format="html">{body}</table>'
|
|
|
|
pieces = _split_table_text(
|
|
table_text,
|
|
tokenizer=tokenizer,
|
|
target_max=500,
|
|
target_ideal=350,
|
|
last_min=64,
|
|
header_body=header,
|
|
)
|
|
|
|
assert len(pieces) >= 3 # first / middle(s) / last
|
|
assert all(p.startswith("<table ") and p.endswith("</table>") for p in pieces)
|
|
assert all(_count_tokens(tokenizer, p) <= 500 for p in pieces)
|
|
|
|
# First slice keeps its own header and is NOT injected with a second copy.
|
|
assert pieces[0].count("<thead>") == 1
|
|
# Every non-first slice gets the header spliced in exactly once, verbatim
|
|
# (rowspan/colspan preserved), immediately after the opening <table …>.
|
|
for piece in pieces[1:]:
|
|
assert piece.count("<thead>") == 1, piece
|
|
assert header in piece, piece
|
|
assert 'rowspan="2"' in piece and 'colspan="2"' in piece, piece
|
|
# The <thead> sits at the front of the body, ahead of the data rows.
|
|
assert piece.index("<thead>") < piece.index("<tbody>"), piece
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_split_table_text_raises_on_html_table_with_json_header():
|
|
"""Cross-format guard: an HTML table fed a JSON-array header_body must raise
|
|
(the sidecar header does not belong to this table)."""
|
|
tokenizer = _make_tokenizer()
|
|
body = "".join(f"<tr><td>{'r' * 200}</td></tr>" for _ in range(5))
|
|
table_text = f'<table id="tb-h1" format="html">{body}</table>'
|
|
with pytest.raises(ValueError):
|
|
_split_table_text(
|
|
table_text,
|
|
tokenizer=tokenizer,
|
|
target_max=500,
|
|
target_ideal=350,
|
|
last_min=64,
|
|
header_body='[["H1", "H2"]]',
|
|
)
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_split_table_text_unknown_format_falls_to_character():
|
|
# No format attr, body that doesn't look like JSON/HTML → unknown.
|
|
tokenizer = _make_tokenizer()
|
|
table_text = '<table id="weird">' + ("plain row text " * 300) + "</table>"
|
|
|
|
pieces = _split_table_text(
|
|
table_text,
|
|
tokenizer=tokenizer,
|
|
target_max=500,
|
|
target_ideal=350,
|
|
last_min=64,
|
|
)
|
|
|
|
assert len(pieces) >= 2
|
|
assert all(_count_tokens(tokenizer, p) <= 500 for p in pieces)
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_expand_block_single_row_table_no_longer_left_intact():
|
|
# TableRowSplit integration: previously a single-row oversized table was
|
|
# appended back to cur_paras unchanged, leading the block to reach
|
|
# AnchorSplit with the table whole and the character fallback shredding
|
|
# the <table> tag. After the fix, TableRowSplit itself produces multiple
|
|
# pieces for such a table.
|
|
tokenizer = _make_tokenizer()
|
|
rows = [[{"col": "x" * 2000}]] # single huge row
|
|
table_text = f'<table id="tb-1" format="json">{json.dumps(rows)}</table>'
|
|
block = {
|
|
"heading": "Section",
|
|
"parent_headings": [],
|
|
"level": 2,
|
|
"paragraphs": [
|
|
{"text": "lead", "is_table": False},
|
|
{"text": table_text, "is_table": True},
|
|
{"text": "trail", "is_table": False},
|
|
],
|
|
}
|
|
|
|
out = _expand_block_with_table_splits(
|
|
block,
|
|
tokenizer=tokenizer,
|
|
table_max=400,
|
|
table_ideal=300,
|
|
table_min_last=128,
|
|
)
|
|
|
|
# Multiple sub-blocks must be produced; the oversized table no longer
|
|
# passes through whole.
|
|
assert len(out) >= 2
|
|
# First/last role protection still fires when the table was reduced.
|
|
roles = [b["table_chunk_role"] for b in out]
|
|
assert "first" in roles or "last" in roles, (
|
|
f"expected first/last role assignment after table split, got {roles}"
|
|
)
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_split_long_block_table_dominant_no_anchor_keeps_some_table_markup():
|
|
# AnchorSplit integration: a block dominated by an oversized table with no
|
|
# anchor candidates used to be character-split end-to-end, destroying
|
|
# the <table> tag. After the fix, at least some output sub-blocks
|
|
# retain legal <table>...</table> markup for the rows that fit.
|
|
tokenizer = _make_tokenizer()
|
|
# Many small rows -> row-boundary split produces multiple legal
|
|
# <table> fragments, none of which individually exceed target_max.
|
|
rows = [[{"col": f"r{i}-" + "v" * 200}] for i in range(8)]
|
|
table_text = f'<table id="tb-1" format="json">{json.dumps(rows)}</table>'
|
|
|
|
paragraphs = [
|
|
{"text": "Sufficiently long lead paragraph " * 30, "is_table": False},
|
|
{"text": table_text, "is_table": True},
|
|
]
|
|
|
|
sub_blocks = _split_long_block(
|
|
paragraphs,
|
|
heading="Heading",
|
|
parent_headings=[],
|
|
level=2,
|
|
table_chunk_role="none",
|
|
tokenizer=tokenizer,
|
|
target_max=600,
|
|
target_ideal=450,
|
|
)
|
|
|
|
# Every sub-block respects the cap.
|
|
assert all(b["tokens"] <= 600 for b in sub_blocks)
|
|
# At least one sub-block keeps an unbroken <table> fragment somewhere
|
|
# in its content (proof that row-boundary preservation kicked in).
|
|
contents = [b["content"] for b in sub_blocks]
|
|
assert any(("<table " in c and "</table>" in c) for c in contents), (
|
|
"expected at least one sub-block to retain a legal <table> fragment"
|
|
)
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_split_table_text_budgets_wrapper_overhead_for_target_max():
|
|
# ``_split_rows_by_tokens`` measures only the body (json.dumps(rows));
|
|
# the surrounding ``<table {attrs}></table>`` wrapper costs tokens too.
|
|
# Without wrapper-aware budgeting, a chunk whose body just fits
|
|
# target_max would overflow once wrapped and trigger character
|
|
# fallback — shredding the row structure for no good reason.
|
|
tokenizer = _make_tokenizer()
|
|
# A long attrs string forces a non-trivial wrapper overhead so the
|
|
# body-only budget previously chosen (==target_max) overflows when
|
|
# the wrapper is added back in.
|
|
attrs_padding = "x" * 80
|
|
rows = [[{"col": "y" * 80}] for _ in range(4)]
|
|
table_text = f'<table id="{attrs_padding}" format="json">{json.dumps(rows)}</table>'
|
|
|
|
pieces = _split_table_text(
|
|
table_text,
|
|
tokenizer=tokenizer,
|
|
target_max=250,
|
|
target_ideal=180,
|
|
last_min=64,
|
|
)
|
|
|
|
# Every output piece honors the cap.
|
|
assert all(_count_tokens(tokenizer, p) <= 250 for p in pieces), [
|
|
_count_tokens(tokenizer, p) for p in pieces
|
|
]
|
|
# Row structure preserved — none of the pieces fell back to
|
|
# character fragments because of accidental wrapper overflow.
|
|
assert all(p.startswith("<table ") and p.endswith("</table>") for p in pieces)
|
|
|
|
|
|
def _count_tokens(tokenizer: Tokenizer, text: str) -> int:
|
|
return len(tokenizer.encode(text))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HeaderRecovery — the source table's repeating header is budgeted out of the
|
|
# per-slice cap and re-injected into the <table> body of every non-first slice
|
|
# AT SPLIT TIME (not backfilled at output), so a slice + its header always
|
|
# stays ≤ target_max, and split slices are frozen against LevelMerge so the
|
|
# header is never duplicated by a re-merge.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_HEADER_BODY = '[["H1", "H2"]]'
|
|
# After injection (or naturally on the first slice) a JSON slice's <table> body
|
|
# begins with the header row.
|
|
_INJECTED_PREFIX = '<table id="tb-1" format="json">[["H1", "H2"]'
|
|
|
|
# Sentinel distinguishing "write tables.json without a table_header" from
|
|
# "do not write tables.json at all".
|
|
_NO_SIDECAR = object()
|
|
|
|
|
|
def _write_tables_json(tmp_path, headers: dict) -> None:
|
|
"""Write a ``doc.tables.json`` beside ``doc.blocks.jsonl``.
|
|
|
|
``headers`` maps table id -> header string; a ``None`` value emits an entry
|
|
WITHOUT the ``table_header`` field (a table that has no repeating header).
|
|
"""
|
|
tables: dict = {}
|
|
for tid, header in headers.items():
|
|
entry = {"id": tid, "format": "json", "content": "[]", "caption": ""}
|
|
if header is not None:
|
|
entry["table_header"] = header
|
|
tables[tid] = entry
|
|
path = tmp_path / "doc.tables.json"
|
|
path.write_text(
|
|
json.dumps({"version": "1.0", "tables": tables}, ensure_ascii=False),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def _build_table_with_header(num_data_rows: int, payload_size: int) -> str:
|
|
# First row is the real header; the rest are data rows. After row-splitting,
|
|
# only the "first" slice keeps the header row; middle/last slices must have
|
|
# it re-injected.
|
|
rows = [["H1", "H2"]] + [
|
|
[f"r{idx}-" + "x" * payload_size, "y"] for idx in range(num_data_rows)
|
|
]
|
|
return f'<table id="tb-1" format="json">{json.dumps(rows)}</table>'
|
|
|
|
|
|
def _chunk_with_oversized_table(
|
|
tmp_path,
|
|
*,
|
|
heading: str = "Section",
|
|
sidecar=_HEADER_BODY,
|
|
num_data_rows: int = 6,
|
|
payload_size: int = 200,
|
|
trailing: str = "trailing paragraph",
|
|
chunk_token_size: int = 800,
|
|
):
|
|
tokenizer = _make_tokenizer()
|
|
body = "\n".join(
|
|
[
|
|
"lead paragraph",
|
|
_build_table_with_header(
|
|
num_data_rows=num_data_rows, payload_size=payload_size
|
|
),
|
|
trailing,
|
|
]
|
|
)
|
|
if sidecar is _NO_SIDECAR:
|
|
pass # leave the directory without a tables.json
|
|
elif sidecar is None:
|
|
_write_tables_json(tmp_path, {"tb-1": None}) # entry without table_header
|
|
else:
|
|
_write_tables_json(tmp_path, {"tb-1": sidecar})
|
|
path = tmp_path / "doc.blocks.jsonl"
|
|
row = {
|
|
"type": "content",
|
|
"heading": heading,
|
|
"parent_headings": [],
|
|
"level": 2 if heading else 0,
|
|
"content": body,
|
|
}
|
|
path.write_text(json.dumps(row, ensure_ascii=False), encoding="utf-8")
|
|
chunks = chunking_by_paragraph_semantic(
|
|
tokenizer,
|
|
body,
|
|
chunk_token_size=chunk_token_size,
|
|
blocks_path=str(path),
|
|
chunk_overlap_token_size=0,
|
|
)
|
|
return chunks
|
|
|
|
|
|
def _table_chunks(chunks):
|
|
return [c for c in chunks if '<table id="tb-1"' in c["content"]]
|
|
|
|
|
|
def _slice_starts_with_header(chunk) -> bool:
|
|
content = chunk["content"]
|
|
tbl = content[content.index('<table id="tb-1"') :]
|
|
return tbl.startswith(_INJECTED_PREFIX)
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_extract_table_id_variants():
|
|
from lightrag.table_markup import extract_table_id
|
|
|
|
assert extract_table_id('id="tb-1" format="json"') == "tb-1"
|
|
assert extract_table_id("format='html' id='tb-h'") == "tb-h"
|
|
assert extract_table_id('format="json"') is None
|
|
assert extract_table_id("") is None
|
|
# ``id`` that is only the tail of another attribute name must not match —
|
|
# neither a hyphen-joined one (``data-id``) nor a word-char prefix
|
|
# (``grid``); a standalone ``id`` alongside such an attribute still wins.
|
|
assert extract_table_id('data-id="x"') is None
|
|
assert extract_table_id('grid="y"') is None
|
|
assert extract_table_id('data-id="x" id="tb-9"') == "tb-9"
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_inject_header_into_table_slice_json_and_html():
|
|
from lightrag.chunker.paragraph_semantic import _inject_header_into_table_slice
|
|
|
|
# JSON: header rows are prepended to the row array, attrs (incl. id) kept.
|
|
json_slice = '<table id="tb-1" format="json">[["a", "b"]]</table>'
|
|
assert (
|
|
_inject_header_into_table_slice(json_slice, '[["H1", "H2"]]')
|
|
== '<table id="tb-1" format="json">[["H1", "H2"], ["a", "b"]]</table>'
|
|
)
|
|
|
|
# HTML: the raw <thead> header is spliced verbatim ahead of the body,
|
|
# preserving any rowspan/colspan markup it carries.
|
|
html_slice = (
|
|
'<table id="tb-h" format="html"><tbody><tr><td>a</td></tr></tbody></table>'
|
|
)
|
|
html_header = '<thead><tr><th colspan="2">H</th></tr></thead>'
|
|
assert _inject_header_into_table_slice(html_slice, html_header) == (
|
|
'<table id="tb-h" format="html">'
|
|
'<thead><tr><th colspan="2">H</th></tr></thead>'
|
|
"<tbody><tr><td>a</td></tr></tbody></table>"
|
|
)
|
|
|
|
# Unparseable header → no injection.
|
|
assert _inject_header_into_table_slice(json_slice, "not json") is None
|
|
# Non-<table> fragment → no injection.
|
|
assert _inject_header_into_table_slice("just text", '[["H1", "H2"]]') is None
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_inject_header_raises_on_cross_format():
|
|
"""A header whose format disagrees with the slice's content format means the
|
|
sidecar header does not belong to this table — inject must raise, not
|
|
silently emit a malformed slice."""
|
|
from lightrag.chunker.paragraph_semantic import _inject_header_into_table_slice
|
|
|
|
json_slice = '<table id="tb-1" format="json">[["a", "b"]]</table>'
|
|
html_slice = (
|
|
'<table id="tb-h" format="html"><tbody><tr><td>a</td></tr></tbody></table>'
|
|
)
|
|
html_header = "<thead><tr><th>H</th></tr></thead>"
|
|
|
|
# HTML header into a JSON slice → mismatch.
|
|
with pytest.raises(ValueError):
|
|
_inject_header_into_table_slice(json_slice, html_header)
|
|
# JSON header into an HTML slice → mismatch.
|
|
with pytest.raises(ValueError):
|
|
_inject_header_into_table_slice(html_slice, '[["H1", "H2"]]')
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_inject_header_skips_html_slice_that_already_has_thead():
|
|
from lightrag.chunker.paragraph_semantic import _inject_header_into_table_slice
|
|
|
|
# HTML: a slice that already carries a <thead> is left untouched rather
|
|
# than gaining a second one.
|
|
slice_with_thead = (
|
|
'<table id="tb-h" format="html">'
|
|
"<thead><tr><th>H2a</th><th>H2b</th></tr></thead>"
|
|
"<tbody><tr><td>d</td></tr></tbody></table>"
|
|
)
|
|
html_header = "<thead><tr><th>H1a</th><th>H1b</th></tr></thead>"
|
|
assert _inject_header_into_table_slice(slice_with_thead, html_header) is None
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_full_pipeline_injects_html_thead_into_split_html_table(tmp_path):
|
|
"""End-to-end through chunking_by_paragraph_semantic: an oversized HTML table
|
|
whose tables.json carries a raw <thead> header has that header re-injected
|
|
(verbatim, spans preserved) into every non-first table chunk."""
|
|
html_header = (
|
|
'<thead><tr><th rowspan="2">Metric</th><th colspan="2">Group</th></tr></thead>'
|
|
)
|
|
head_row = '<tr><th rowspan="2">Metric</th><th colspan="2">Group</th></tr>'
|
|
data_rows = "".join(f"<tr><td>{'d' * 160}{i}</td></tr>" for i in range(6))
|
|
table = (
|
|
'<table id="tb-h" format="html">'
|
|
f"<thead>{head_row}</thead><tbody>{data_rows}</tbody></table>"
|
|
)
|
|
body = "\n".join(["lead paragraph", table, "trailing paragraph"])
|
|
|
|
# tables.json sidecar with an HTML-format header.
|
|
(tmp_path / "doc.tables.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"version": "1.0",
|
|
"tables": {
|
|
"tb-h": {
|
|
"id": "tb-h",
|
|
"format": "html",
|
|
"content": table,
|
|
"caption": "",
|
|
"table_header": html_header,
|
|
}
|
|
},
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
blocks_path = tmp_path / "doc.blocks.jsonl"
|
|
blocks_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"type": "content",
|
|
"heading": "Section",
|
|
"parent_headings": [],
|
|
"level": 2,
|
|
"content": body,
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
tokenizer = _make_tokenizer()
|
|
chunks = chunking_by_paragraph_semantic(
|
|
tokenizer,
|
|
body,
|
|
chunk_token_size=900,
|
|
blocks_path=str(blocks_path),
|
|
chunk_overlap_token_size=0,
|
|
)
|
|
|
|
table_chunks = [c for c in chunks if '<table id="tb-h"' in c["content"]]
|
|
assert len(table_chunks) >= 2 # the table was split
|
|
# Every table chunk carries exactly one <thead> with spans intact; the
|
|
# first keeps its own, later ones got the sidecar header spliced in.
|
|
for chunk in table_chunks:
|
|
assert chunk["content"].count("<thead>") == 1, chunk["content"]
|
|
assert 'rowspan="2"' in chunk["content"]
|
|
assert 'colspan="2"' in chunk["content"]
|
|
# At least one non-first chunk literally contains the injected header.
|
|
assert any(html_header in c["content"] for c in table_chunks[1:])
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_split_table_text_multi_row_header_pinned_not_duplicated():
|
|
# A multi-row header is pinned out of the split body, so every slice gets
|
|
# the full header exactly once — never a cut remnant duplicated into a
|
|
# later slice (e.g. [H1, H2, H2, ...]).
|
|
from lightrag.table_markup import TABLE_TAG_RE
|
|
|
|
tokenizer = _make_tokenizer()
|
|
h1 = ["H1", "h1"]
|
|
h2 = ["H2", "h2"]
|
|
header_rows = [h1, h2]
|
|
header_body = json.dumps(header_rows)
|
|
rows = [h1, h2] + [[f"D{i}", "x" * 30] for i in range(6)]
|
|
table_text = f'<table id="tb-1" format="json">{json.dumps(rows)}</table>'
|
|
|
|
pieces = _split_table_text(
|
|
table_text,
|
|
tokenizer=tokenizer,
|
|
target_max=160,
|
|
target_ideal=120,
|
|
last_min=10,
|
|
header_body=header_body,
|
|
)
|
|
|
|
assert len(pieces) >= 2, "expected the data rows to be split across slices"
|
|
for piece in pieces:
|
|
body_rows = json.loads(TABLE_TAG_RE.match(piece).group("body"))
|
|
# Full header present exactly once at the top of every slice.
|
|
assert body_rows[:2] == header_rows, body_rows
|
|
assert body_rows[2:].count(h1) == 0 and body_rows[2:].count(h2) == 0, body_rows
|
|
assert _count_tokens(tokenizer, piece) <= 160
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_split_table_text_preserves_data_row_equal_to_header():
|
|
# A genuine data row whose values equal the (single-row) header must be
|
|
# preserved, not mistaken for a header remnant and dropped. Pinning the
|
|
# header out of the body guarantees this: the body is split as pure data
|
|
# and the header is prepended verbatim.
|
|
from lightrag.table_markup import TABLE_TAG_RE
|
|
|
|
tokenizer = _make_tokenizer()
|
|
header_row = ["Label", "Value"]
|
|
header_body = json.dumps([header_row])
|
|
# The 3rd data row is identical to the header (e.g. a repeated label row).
|
|
data_rows = [
|
|
["a", "x" * 40],
|
|
["b", "y" * 40],
|
|
["Label", "Value"],
|
|
["c", "z" * 40],
|
|
]
|
|
rows = [header_row] + data_rows
|
|
table_text = f'<table id="tb-1" format="json">{json.dumps(rows)}</table>'
|
|
|
|
pieces = _split_table_text(
|
|
table_text,
|
|
tokenizer=tokenizer,
|
|
target_max=130,
|
|
target_ideal=90,
|
|
last_min=10,
|
|
header_body=header_body,
|
|
)
|
|
|
|
assert len(pieces) >= 2, "expected the data to be split across slices"
|
|
# Reconstruct the data by stripping the one prepended header row from each
|
|
# slice; every original data row (including the header-lookalike) survives.
|
|
recovered: list = []
|
|
for piece in pieces:
|
|
body_rows = json.loads(TABLE_TAG_RE.match(piece).group("body"))
|
|
assert body_rows[0] == header_row, body_rows
|
|
recovered.extend(body_rows[1:])
|
|
assert recovered == data_rows, recovered
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_split_table_text_resplits_reducible_slice_to_keep_header():
|
|
# A reducible multi-row non-first slice whose wrapped table fits target_max
|
|
# but whose table+header would exceed it must be split further at row
|
|
# boundaries so each sub-slice still carries the header — rather than being
|
|
# accepted whole and then silently left header-less. Here the splitter
|
|
# emits two 2-row chunks at 119 tokens (target_max=120, effective cap=106),
|
|
# which must be re-split so every non-first slice keeps its header.
|
|
from lightrag.table_markup import TABLE_TAG_RE
|
|
|
|
tokenizer = _make_tokenizer()
|
|
header_row = ["HH", "HH"]
|
|
header_body = json.dumps([header_row])
|
|
rows = [header_row] + [[f"D{i}", "x" * 28] for i in range(4)]
|
|
table_text = f'<table id="tb-1" format="json">{json.dumps(rows)}</table>'
|
|
|
|
pieces = _split_table_text(
|
|
table_text,
|
|
tokenizer=tokenizer,
|
|
target_max=120,
|
|
target_ideal=120,
|
|
last_min=10,
|
|
header_body=header_body,
|
|
)
|
|
|
|
assert len(pieces) >= 3
|
|
# Every piece honors the hard cap.
|
|
assert all(_count_tokens(tokenizer, p) <= 120 for p in pieces), [
|
|
_count_tokens(tokenizer, p) for p in pieces
|
|
]
|
|
# No non-first slice is left header-less: each carries the recovered header
|
|
# (every data row here is small enough that header + row fits target_max).
|
|
for piece in pieces[1:]:
|
|
body_rows = json.loads(TABLE_TAG_RE.match(piece).group("body"))
|
|
assert body_rows[0] == header_row, body_rows
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_split_table_text_budgets_header_before_splitting():
|
|
# With a header supplied, every emitted slice must stay ≤ target_max even
|
|
# though the header is prepended into the non-first slices — proving the
|
|
# header tokens were budgeted out BEFORE the split, not backfilled after.
|
|
tokenizer = _make_tokenizer()
|
|
header_body = '[["' + "H" * 60 + '", "' + "K" * 60 + '"]]' # non-trivial header
|
|
rows = [["H" * 60, "K" * 60]] + [[f"r{i}-" + "y" * 60, "z"] for i in range(8)]
|
|
table_text = f'<table id="tb-1" format="json">{json.dumps(rows)}</table>'
|
|
|
|
pieces = _split_table_text(
|
|
table_text,
|
|
tokenizer=tokenizer,
|
|
target_max=300,
|
|
target_ideal=220,
|
|
last_min=64,
|
|
header_body=header_body,
|
|
)
|
|
|
|
assert len(pieces) >= 3, "expected the oversized table to split into 3+ slices"
|
|
# Every slice honors the cap including its (injected) header.
|
|
assert all(_count_tokens(tokenizer, p) <= 300 for p in pieces), [
|
|
_count_tokens(tokenizer, p) for p in pieces
|
|
]
|
|
# Non-first slices carry the recovered header row.
|
|
assert all(
|
|
p.startswith('<table id="tb-1" format="json">[["' + "H" * 60)
|
|
for p in pieces[1:]
|
|
)
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_split_table_text_degrades_when_header_would_exceed_cap(
|
|
caplog, _propagate_lightrag_logger
|
|
):
|
|
# Cap edge: a single-row slice whose wrapped <table> sits just under
|
|
# target_max yet leaves no room for the header it would need. Rather than
|
|
# silently keeping it header-less (the old size-guard behaviour), the whole
|
|
# table degrades to a recursive split so the header is preserved as text,
|
|
# the hard cap still holds, and a warning is logged.
|
|
tokenizer = _make_tokenizer()
|
|
target_max = 200
|
|
header_body = '[["HHHHHHHHHH", "KKKKKKKKKK"]]' # ~30 tokens of header
|
|
# row0 is the real header (pinned out of the body); row1 is small; row2 is a
|
|
# single big row whose wrapped <table> size sits just under target_max, so
|
|
# it fits the bare cap yet leaves no room for the ~30-token header.
|
|
rows = [["HHHHHHHHHH", "KKKKKKKKKK"], ["s", "y"], ["x" * 140, "y"]]
|
|
table_text = f'<table id="tb-1" format="json">{json.dumps(rows)}</table>'
|
|
|
|
with caplog.at_level(logging.WARNING, logger="lightrag"):
|
|
pieces = _split_table_text(
|
|
table_text,
|
|
tokenizer=tokenizer,
|
|
target_max=target_max,
|
|
target_ideal=150,
|
|
last_min=64,
|
|
header_body=header_body,
|
|
)
|
|
|
|
assert len(pieces) >= 2
|
|
# The cap holds for every piece, and the whole table degraded: no piece
|
|
# survives as a complete re-wrapped <table>...</table> slice.
|
|
assert all(_count_tokens(tokenizer, p) <= target_max for p in pieces), [
|
|
_count_tokens(tokenizer, p) for p in pieces
|
|
]
|
|
assert not any(p.startswith("<table ") and p.endswith("</table>") for p in pieces)
|
|
# The header content survives in the recursive fallback (not dropped).
|
|
assert "HHHHHHHHHH" in "".join(pieces)
|
|
assert any("degrading the whole table" in rec.message for rec in caplog.records), [
|
|
rec.message for rec in caplog.records
|
|
]
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_split_table_text_pinned_header_oversized_row_preserves_header(
|
|
caplog, _propagate_lightrag_logger
|
|
):
|
|
# Codex P2 regression: a pinned-header JSON table whose data portion holds a
|
|
# single row larger than target_max. The old code character-split the
|
|
# header-less ``wrapped`` body, so the later injection pass (which can't
|
|
# repair a non-<table> fragment) dropped the header entirely. The whole
|
|
# table must now degrade to a recursive split that keeps the header text.
|
|
tokenizer = _make_tokenizer()
|
|
target_max = 300
|
|
header_body = '[["HEADERCOL_A", "HEADERCOL_B"]]'
|
|
# row0 repeats the header (pinned out of the body); row1 is a single huge
|
|
# data row whose content alone blows past target_max.
|
|
rows = [["HEADERCOL_A", "HEADERCOL_B"], ["x" * 2000, "y"]]
|
|
table_text = f'<table id="tb-7" format="json">{json.dumps(rows)}</table>'
|
|
|
|
with caplog.at_level(logging.WARNING, logger="lightrag"):
|
|
pieces = _split_table_text(
|
|
table_text,
|
|
tokenizer=tokenizer,
|
|
target_max=target_max,
|
|
target_ideal=220,
|
|
last_min=64,
|
|
header_body=header_body,
|
|
)
|
|
|
|
assert len(pieces) >= 2
|
|
assert all(_count_tokens(tokenizer, p) <= target_max for p in pieces), [
|
|
_count_tokens(tokenizer, p) for p in pieces
|
|
]
|
|
# The header column names survive somewhere in the emitted fragments.
|
|
assert "HEADERCOL_A" in "".join(pieces)
|
|
assert any(
|
|
"tb-7" in rec.message and "degrading the whole table" in rec.message
|
|
for rec in caplog.records
|
|
), [rec.message for rec in caplog.records]
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_header_injected_into_every_table_slice(tmp_path):
|
|
chunks = _chunk_with_oversized_table(tmp_path)
|
|
table_chunks = _table_chunks(chunks)
|
|
|
|
assert len(table_chunks) >= 2, "expected the oversized table to be split"
|
|
# Every slice's <table> now begins with the header row: the "first" slice
|
|
# naturally (it kept the real header row), middle/last via re-injection.
|
|
assert all(_slice_starts_with_header(c) for c in table_chunks), [
|
|
c["content"][:90] for c in table_chunks
|
|
]
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_every_chunk_respects_cap_after_header_injection(tmp_path):
|
|
# The Codex P2 finding: a near-cap slice + a recovered header must not push
|
|
# the chunk past the hard cap. With split-time budgeting this holds for a
|
|
# table that has substantial trailing prose glued to its last slice too.
|
|
chunk_token_size = 800
|
|
long_trailing = " ".join(f"word{i}" for i in range(120)) # ~ hundreds of tokens
|
|
chunks = _chunk_with_oversized_table(
|
|
tmp_path,
|
|
num_data_rows=10,
|
|
payload_size=120,
|
|
trailing=long_trailing,
|
|
chunk_token_size=chunk_token_size,
|
|
)
|
|
tokenizer = _make_tokenizer()
|
|
assert _table_chunks(chunks), "expected a split header-bearing table"
|
|
for c in chunks:
|
|
assert _count_tokens(tokenizer, c["content"]) <= chunk_token_size, c["content"][
|
|
:120
|
|
]
|
|
assert c["tokens"] <= chunk_token_size
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_split_table_slices_never_remerge_no_duplicate_header(tmp_path):
|
|
# Split slices are frozen against LevelMerge. Two slices of one table are
|
|
# never recombined into a single chunk (which, post-injection, would carry
|
|
# the header twice). The header appears at most once per chunk.
|
|
chunks = _chunk_with_oversized_table(
|
|
tmp_path, num_data_rows=8, payload_size=120, trailing="", chunk_token_size=800
|
|
)
|
|
table_chunks = _table_chunks(chunks)
|
|
|
|
assert len(table_chunks) >= 2, "table must remain split, not reassembled"
|
|
for c in table_chunks:
|
|
# The header signature must not be duplicated inside any single chunk.
|
|
assert c["content"].count('"H1", "H2"') <= 1, c["content"]
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_levelmerge_freezes_first_and_last_slices():
|
|
# Deterministic freeze guard at the LevelMerge layer: a small "first" block
|
|
# immediately followed by a small "last" block of the SAME 2-slice table —
|
|
# both under target_ideal and same parent path — would have been reassembled
|
|
# into one whole table before the freeze. Now neither merge direction fires,
|
|
# so the two slices stay as two separate chunks (no duplicated header).
|
|
tokenizer = _make_tokenizer()
|
|
first = _new_block(
|
|
heading="Section",
|
|
parent_headings=["Doc"],
|
|
level=2,
|
|
paragraphs=[
|
|
{
|
|
"text": '<table id="tb-1" format="json">[["H1","H2"],["a","b"]]</table>',
|
|
"is_table": True,
|
|
}
|
|
],
|
|
table_chunk_role="first",
|
|
tokenizer=tokenizer,
|
|
)
|
|
last = _new_block(
|
|
heading="Section",
|
|
parent_headings=["Doc"],
|
|
level=2,
|
|
paragraphs=[
|
|
{
|
|
"text": '<table id="tb-1" format="json">[["H1","H2"],["c","d"]]</table>',
|
|
"is_table": True,
|
|
}
|
|
],
|
|
table_chunk_role="last",
|
|
tokenizer=tokenizer,
|
|
)
|
|
|
|
merged = _merge_small_blocks(
|
|
[first, last],
|
|
tokenizer=tokenizer,
|
|
target_max=10_000, # generous cap so size never blocks a merge
|
|
target_ideal=10_000, # both blocks count as below-ideal
|
|
small_tail_threshold=10_000,
|
|
)
|
|
|
|
assert len(merged) == 2, "frozen split slices must not be reassembled"
|
|
assert all(b["content"].count('"H1","H2"') == 1 for b in merged)
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_no_injection_when_source_table_has_no_header(tmp_path):
|
|
# tables.json lists the table but WITHOUT a table_header field → nothing is
|
|
# fabricated; only the first slice (which kept the real header) starts with it.
|
|
chunks = _chunk_with_oversized_table(tmp_path, sidecar=None)
|
|
table_chunks = _table_chunks(chunks)
|
|
|
|
assert len(table_chunks) >= 2
|
|
starts = [_slice_starts_with_header(c) for c in table_chunks]
|
|
assert starts[0] is True, "first slice keeps the table's own header row"
|
|
assert any(s is False for s in starts[1:]), "later slices must not be injected"
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_no_injection_when_tables_json_missing(tmp_path):
|
|
# No tables.json at all → silent degrade: no error, no injection.
|
|
chunks = _chunk_with_oversized_table(tmp_path, sidecar=_NO_SIDECAR)
|
|
table_chunks = _table_chunks(chunks)
|
|
|
|
assert len(table_chunks) >= 2
|
|
starts = [_slice_starts_with_header(c) for c in table_chunks]
|
|
assert starts[0] is True
|
|
assert any(s is False for s in starts[1:])
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_injection_works_when_source_heading_empty(tmp_path):
|
|
# A preamble block (empty source heading) still gets the header injected.
|
|
chunks = _chunk_with_oversized_table(tmp_path, heading="")
|
|
table_chunks = _table_chunks(chunks)
|
|
|
|
assert len(table_chunks) >= 2
|
|
assert all(_slice_starts_with_header(c) for c in table_chunks)
|