894 lines
29 KiB
Python
894 lines
29 KiB
Python
"""Unit tests for the native multimodal surrounding-context extractor.
|
|
|
|
See ``docs/NativeMultimodalSurroundingContextPlan-zh.md``.
|
|
|
|
These tests use a 1:1 character-token mapping so the expected token
|
|
budgets in each scenario stay obvious without coupling to tiktoken's
|
|
BPE. The helper functions exercised here are pure (no async, no
|
|
network), so the suite runs offline.
|
|
"""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from lightrag.multimodal_context import (
|
|
build_surrounding,
|
|
enrich_sidecars_with_surrounding,
|
|
find_target_span,
|
|
load_chunk_separators,
|
|
)
|
|
from lightrag.utils import Tokenizer, TokenizerInterface
|
|
|
|
|
|
class _CharTokenizer(TokenizerInterface):
|
|
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 _tokenizer() -> Tokenizer:
|
|
return Tokenizer(model_name="char", tokenizer=_CharTokenizer())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Target-tag locator
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_find_target_span_drawing_in_mixed_content():
|
|
content = (
|
|
"leading text. "
|
|
'<drawing id="im-abcd-0001" format="png" path="img.png" src="img" /> '
|
|
"trailing text."
|
|
)
|
|
span = find_target_span("drawings", "im-abcd-0001", content)
|
|
assert span is not None
|
|
start, end = span
|
|
assert content[start:end].startswith('<drawing id="im-abcd-0001"')
|
|
assert content[start:end].endswith("/>")
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_find_target_span_table_with_id_anywhere_in_attrs():
|
|
# id is not first attribute — locator must still find it.
|
|
content = (
|
|
'before <table format="json" id="tb-abcd-0007">[[1,2],[3,4]]</table> after'
|
|
)
|
|
span = find_target_span("tables", "tb-abcd-0007", content)
|
|
assert span is not None
|
|
snippet = content[span[0] : span[1]]
|
|
assert snippet.endswith("</table>")
|
|
assert 'id="tb-abcd-0007"' in snippet
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_find_target_span_table_cite_marker():
|
|
content = 'before <cite type="table" refid="tb-abcd-0007">表1</cite> after'
|
|
span = find_target_span("tables", "tb-abcd-0007", content)
|
|
assert span is not None
|
|
assert content[span[0] : span[1]].startswith("<cite")
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_find_target_span_equation():
|
|
content = 'A <equation id="eq-abcd-0002" format="latex">x^2</equation> B'
|
|
span = find_target_span("equations", "eq-abcd-0002", content)
|
|
assert span is not None
|
|
assert content[span[0] : span[1]].endswith("</equation>")
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_find_target_span_unknown_id_returns_none():
|
|
content = '<drawing id="im-1" />'
|
|
assert find_target_span("drawings", "im-other", content) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Drawings & equations surrounding
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_drawing_surrounding_kept_within_block_only():
|
|
tok = _tokenizer()
|
|
block = (
|
|
"paragraph one ends. paragraph two. "
|
|
'<drawing id="im-1" path="a.png" src="a" /> '
|
|
"paragraph three. paragraph four."
|
|
)
|
|
span = find_target_span("drawings", "im-1", block)
|
|
surr = build_surrounding(
|
|
kind="drawings",
|
|
block_content=block,
|
|
span=span,
|
|
tokenizer=tok,
|
|
leading_max_tokens=2000,
|
|
trailing_max_tokens=2000,
|
|
separators=load_chunk_separators(),
|
|
)
|
|
assert surr["leading"].endswith("paragraph two. ")
|
|
assert surr["trailing"].startswith(" paragraph three.")
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_equation_surrounding_protects_drawing_atom():
|
|
tok = _tokenizer()
|
|
block = (
|
|
'<drawing id="im-prev" path="a.png" src="a" caption="Fig 1" />'
|
|
" intro text. "
|
|
'<equation id="eq-1" format="latex">a+b=c</equation>'
|
|
" conclusion text."
|
|
)
|
|
span = find_target_span("equations", "eq-1", block)
|
|
surr = build_surrounding(
|
|
kind="equations",
|
|
block_content=block,
|
|
span=span,
|
|
tokenizer=tok,
|
|
leading_max_tokens=2000,
|
|
trailing_max_tokens=2000,
|
|
separators=load_chunk_separators(),
|
|
)
|
|
# Parser-internal id/path/src are stripped, but caption survives and
|
|
# the drawing tag stays atomic (not cut in half).
|
|
assert '<drawing caption="Fig 1" />' in surr["leading"]
|
|
assert "/>" in surr["leading"]
|
|
# No half-open drawing/equation tags
|
|
assert surr["leading"].count("<drawing") == surr["leading"].count("/>")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tables surrounding: other tables must be stripped before token counting.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_table_surrounding_strips_other_tables_before_counting():
|
|
tok = _tokenizer()
|
|
block = (
|
|
'<table id="tb-other" format="json">[["a","b"],["c","d"]]</table> '
|
|
"narrative text describing the report. "
|
|
'<table id="tb-target" format="json">[["x","y"]]</table>'
|
|
" concluding remarks."
|
|
)
|
|
span = find_target_span("tables", "tb-target", block)
|
|
surr = build_surrounding(
|
|
kind="tables",
|
|
block_content=block,
|
|
span=span,
|
|
tokenizer=tok,
|
|
leading_max_tokens=2000,
|
|
trailing_max_tokens=2000,
|
|
separators=load_chunk_separators(),
|
|
)
|
|
# Sibling table must NOT appear in surrounding.
|
|
assert "<table" not in surr["leading"]
|
|
assert "</table>" not in surr["leading"]
|
|
assert "<table" not in surr["trailing"]
|
|
assert "narrative text" in surr["leading"]
|
|
assert "concluding remarks" in surr["trailing"]
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_table_surrounding_supports_cite_marker_and_strips_sibling_cites():
|
|
tok = _tokenizer()
|
|
block = (
|
|
'prefix <cite type="table" refid="tb-other">表0</cite> '
|
|
'narrative <cite type="table" refid="tb-target">表1</cite> suffix'
|
|
)
|
|
span = find_target_span("tables", "tb-target", block)
|
|
surr = build_surrounding(
|
|
kind="tables",
|
|
block_content=block,
|
|
span=span,
|
|
tokenizer=tok,
|
|
leading_max_tokens=2000,
|
|
trailing_max_tokens=2000,
|
|
separators=load_chunk_separators(),
|
|
)
|
|
assert "tb-other" not in surr["leading"]
|
|
assert "表0" not in surr["leading"]
|
|
assert "narrative " in surr["leading"]
|
|
assert surr["trailing"] == " suffix"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Custom CHUNK_R_SEPARATORS via env
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_chunk_r_separators_env_drives_segment_boundary(monkeypatch):
|
|
# Only the pipe character is a separator: text must split at '|'.
|
|
monkeypatch.setenv("CHUNK_R_SEPARATORS", json.dumps(["|"]))
|
|
seps = load_chunk_separators()
|
|
assert seps == ["|"]
|
|
tok = _tokenizer()
|
|
# 3 segments separated by '|'; budget = 12 chars/tokens; each seg is
|
|
# 10 chars including the trailing '|', so 1 whole segment fits, 2 do not.
|
|
block = 'aaaaaaaaa|bbbbbbbbb|<drawing id="d" />|ccccccccc|ddddddddd'
|
|
span = find_target_span("drawings", "d", block)
|
|
surr = build_surrounding(
|
|
kind="drawings",
|
|
block_content=block,
|
|
span=span,
|
|
tokenizer=tok,
|
|
leading_max_tokens=12,
|
|
trailing_max_tokens=12,
|
|
separators=seps,
|
|
)
|
|
# Leading should end at a '|' boundary (one whole segment), not be
|
|
# char-truncated.
|
|
assert surr["leading"].endswith("|")
|
|
# And contain whole segment closest to target.
|
|
assert "bbbbbbbbb|" in surr["leading"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Char fallback when the closest segment alone exceeds the budget.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_oversized_closest_segment_char_truncated():
|
|
tok = _tokenizer()
|
|
# Single huge "segment" (no separator) right before the target.
|
|
big = "X" * 5000
|
|
block = big + '<drawing id="d" />'
|
|
span = find_target_span("drawings", "d", block)
|
|
surr = build_surrounding(
|
|
kind="drawings",
|
|
block_content=block,
|
|
span=span,
|
|
tokenizer=tok,
|
|
leading_max_tokens=200,
|
|
trailing_max_tokens=200,
|
|
separators=load_chunk_separators(),
|
|
)
|
|
assert len(tok.encode(surr["leading"])) <= 200
|
|
assert surr["trailing"] == ""
|
|
# The suffix should be a tail of the X-run.
|
|
assert surr["leading"].endswith("X")
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_oversized_trailing_char_truncated_at_head():
|
|
tok = _tokenizer()
|
|
big = "Y" * 5000
|
|
block = '<drawing id="d" />' + big
|
|
span = find_target_span("drawings", "d", block)
|
|
surr = build_surrounding(
|
|
kind="drawings",
|
|
block_content=block,
|
|
span=span,
|
|
tokenizer=tok,
|
|
leading_max_tokens=200,
|
|
trailing_max_tokens=200,
|
|
separators=load_chunk_separators(),
|
|
)
|
|
assert len(tok.encode(surr["trailing"])) <= 200
|
|
assert surr["trailing"].startswith("Y")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Drawings/equations surrounding: JSON / HTML table row trimming.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_drawing_surrounding_row_trims_oversized_json_table():
|
|
tok = _tokenizer()
|
|
# 10 rows of repeating cells; whole table is ~> budget.
|
|
rows = [[f"r{i}c0", f"r{i}c1"] for i in range(10)]
|
|
big_table = '<table id="tb-big" format="json">' + json.dumps(rows) + "</table>"
|
|
block = big_table + ' <drawing id="d" />'
|
|
span = find_target_span("drawings", "d", block)
|
|
# Budget chosen so only a few rows of the JSON table fit.
|
|
surr = build_surrounding(
|
|
kind="drawings",
|
|
block_content=block,
|
|
span=span,
|
|
tokenizer=tok,
|
|
leading_max_tokens=80,
|
|
trailing_max_tokens=80,
|
|
separators=load_chunk_separators(),
|
|
)
|
|
# Result must be a complete (smaller) <table>...</table>, contain
|
|
# closing tag, and fit within budget.
|
|
leading = surr["leading"]
|
|
assert "<table " in leading
|
|
assert (
|
|
leading.rstrip().endswith("</table>")
|
|
or leading.rstrip().endswith("</table> ")
|
|
or "</table>" in leading
|
|
)
|
|
assert len(tok.encode(leading)) <= 80
|
|
# Should keep tail rows (closest to target — last rows by index)
|
|
assert "r9c0" in leading
|
|
# Should not include rows from the far side.
|
|
assert "r0c0" not in leading
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_drawing_surrounding_row_trims_oversized_html_table():
|
|
tok = _tokenizer()
|
|
rows_html = "".join(f"<tr><td>r{i}c0</td><td>r{i}c1</td></tr>" for i in range(10))
|
|
body = f"<tbody>{rows_html}</tbody>"
|
|
big_table = f'<table id="tb-h" format="html">{body}</table>'
|
|
block = f'<drawing id="d" /> {big_table}'
|
|
span = find_target_span("drawings", "d", block)
|
|
surr = build_surrounding(
|
|
kind="drawings",
|
|
block_content=block,
|
|
span=span,
|
|
tokenizer=tok,
|
|
leading_max_tokens=120,
|
|
trailing_max_tokens=120,
|
|
separators=load_chunk_separators(),
|
|
)
|
|
trailing = surr["trailing"]
|
|
assert "<table " in trailing
|
|
assert "</table>" in trailing
|
|
assert "<tbody>" in trailing
|
|
assert "</tbody>" in trailing
|
|
assert len(tok.encode(trailing)) <= 120
|
|
# For trailing we keep head rows.
|
|
assert "r0c0" in trailing
|
|
assert "r9c0" not in trailing
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_drawing_surrounding_char_trims_oversized_single_json_row():
|
|
tok = _tokenizer()
|
|
row_text = "A" * 200 + "TAIL"
|
|
big_table = (
|
|
'<table id="tb-big" format="json">'
|
|
+ json.dumps([[row_text]], ensure_ascii=False)
|
|
+ "</table>"
|
|
)
|
|
block = big_table + '<drawing id="d" />'
|
|
span = find_target_span("drawings", "d", block)
|
|
surr = build_surrounding(
|
|
kind="drawings",
|
|
block_content=block,
|
|
span=span,
|
|
tokenizer=tok,
|
|
leading_max_tokens=90,
|
|
trailing_max_tokens=90,
|
|
separators=load_chunk_separators(),
|
|
)
|
|
|
|
leading = surr["leading"]
|
|
assert leading.startswith("<table ")
|
|
assert leading.endswith("</table>")
|
|
assert "TAIL" in leading
|
|
assert len(tok.encode(leading)) <= 90
|
|
|
|
body = leading[leading.index(">") + 1 : -len("</table>")]
|
|
parsed = json.loads(body)
|
|
assert isinstance(parsed, list)
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_drawing_surrounding_char_trims_oversized_single_html_row():
|
|
tok = _tokenizer()
|
|
row_text = "HEAD" + "B" * 200
|
|
big_table = (
|
|
'<table id="tb-h" format="html">'
|
|
f"<tbody><tr><td>{row_text}</td></tr></tbody>"
|
|
"</table>"
|
|
)
|
|
block = f'<drawing id="d" />{big_table}'
|
|
span = find_target_span("drawings", "d", block)
|
|
surr = build_surrounding(
|
|
kind="drawings",
|
|
block_content=block,
|
|
span=span,
|
|
tokenizer=tok,
|
|
leading_max_tokens=100,
|
|
trailing_max_tokens=100,
|
|
separators=load_chunk_separators(),
|
|
)
|
|
|
|
trailing = surr["trailing"]
|
|
assert trailing.startswith("<table ")
|
|
assert trailing.endswith("</table>")
|
|
assert "<tr><td>" in trailing
|
|
assert "HEAD" in trailing
|
|
assert len(tok.encode(trailing)) <= 100
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# enrich_sidecars_with_surrounding: idempotency + modality gating.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _write_blocks(tmp_path, base, blocks):
|
|
blocks_path = tmp_path / f"{base}.blocks.jsonl"
|
|
lines = [json.dumps({"type": "meta", "format": "lightrag"})]
|
|
for b in blocks:
|
|
lines.append(json.dumps(b, ensure_ascii=False))
|
|
blocks_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
return blocks_path
|
|
|
|
|
|
def _write_sidecar(path, root_key, items):
|
|
path.write_text(
|
|
json.dumps(
|
|
{"version": "1.0", root_key: items},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_enrich_only_updates_enabled_modalities(tmp_path):
|
|
base = "doc"
|
|
blockid = "b1"
|
|
content = (
|
|
"intro. "
|
|
'<drawing id="im-1" path="a.png" src="a" />'
|
|
" middle "
|
|
'<table id="tb-1" format="json">[["a"]]</table>'
|
|
" tail "
|
|
'<equation id="eq-1" format="latex">e</equation>'
|
|
" end."
|
|
)
|
|
_write_blocks(
|
|
tmp_path,
|
|
base,
|
|
[
|
|
{
|
|
"type": "content",
|
|
"blockid": blockid,
|
|
"format": "plain_text",
|
|
"content": content,
|
|
"heading": "h",
|
|
"parent_headings": [],
|
|
"level": 1,
|
|
}
|
|
],
|
|
)
|
|
drawings_path = tmp_path / f"{base}.drawings.json"
|
|
tables_path = tmp_path / f"{base}.tables.json"
|
|
equations_path = tmp_path / f"{base}.equations.json"
|
|
_write_sidecar(
|
|
drawings_path,
|
|
"drawings",
|
|
{"im-1": {"id": "im-1", "blockid": blockid, "heading": "h"}},
|
|
)
|
|
_write_sidecar(
|
|
tables_path,
|
|
"tables",
|
|
{"tb-1": {"id": "tb-1", "blockid": blockid, "heading": "h"}},
|
|
)
|
|
_write_sidecar(
|
|
equations_path,
|
|
"equations",
|
|
{"eq-1": {"id": "eq-1", "blockid": blockid, "heading": "h"}},
|
|
)
|
|
|
|
counts = enrich_sidecars_with_surrounding(
|
|
blocks_path=str(tmp_path / f"{base}.blocks.jsonl"),
|
|
enabled_modalities={"drawings"},
|
|
tokenizer=_tokenizer(),
|
|
leading_max_tokens=2000,
|
|
trailing_max_tokens=2000,
|
|
)
|
|
assert counts["drawings"] == 1
|
|
assert counts["tables"] == 0
|
|
assert counts["equations"] == 0
|
|
|
|
drawings = json.loads(drawings_path.read_text(encoding="utf-8"))
|
|
tables = json.loads(tables_path.read_text(encoding="utf-8"))
|
|
equations = json.loads(equations_path.read_text(encoding="utf-8"))
|
|
assert "surrounding" in drawings["drawings"]["im-1"]
|
|
assert drawings["drawings"]["im-1"]["surrounding"]["leading"].startswith("intro.")
|
|
assert "surrounding" not in tables["tables"]["tb-1"]
|
|
assert "surrounding" not in equations["equations"]["eq-1"]
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_enrich_runs_even_when_llm_analyze_result_present(tmp_path):
|
|
"""Idempotency: existing ``llm_analyze_result`` does not block
|
|
surrounding backfill — we treat the two fields as independent."""
|
|
base = "doc"
|
|
blockid = "b1"
|
|
content = 'prefix. <drawing id="im-1" path="a.png" src="a" /> suffix.'
|
|
_write_blocks(
|
|
tmp_path,
|
|
base,
|
|
[
|
|
{
|
|
"type": "content",
|
|
"blockid": blockid,
|
|
"format": "plain_text",
|
|
"content": content,
|
|
"heading": "h",
|
|
"parent_headings": [],
|
|
"level": 1,
|
|
}
|
|
],
|
|
)
|
|
drawings_path = tmp_path / f"{base}.drawings.json"
|
|
_write_sidecar(
|
|
drawings_path,
|
|
"drawings",
|
|
{
|
|
"im-1": {
|
|
"id": "im-1",
|
|
"blockid": blockid,
|
|
"heading": "h",
|
|
"llm_analyze_result": {
|
|
"name": "x",
|
|
"summary": "",
|
|
"detail_description": "",
|
|
},
|
|
}
|
|
},
|
|
)
|
|
|
|
counts = enrich_sidecars_with_surrounding(
|
|
blocks_path=str(tmp_path / f"{base}.blocks.jsonl"),
|
|
enabled_modalities={"drawings"},
|
|
tokenizer=_tokenizer(),
|
|
leading_max_tokens=2000,
|
|
trailing_max_tokens=2000,
|
|
)
|
|
assert counts["drawings"] == 1
|
|
payload = json.loads(drawings_path.read_text(encoding="utf-8"))
|
|
item = payload["drawings"]["im-1"]
|
|
assert item["llm_analyze_result"]["name"] == "x" # untouched
|
|
assert item["surrounding"]["leading"].startswith("prefix.")
|
|
assert item["surrounding"]["trailing"].startswith(" suffix.")
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_enrich_does_not_cross_block_boundaries(tmp_path):
|
|
base = "doc"
|
|
block_a = "earlier block content."
|
|
block_b = 'later block. <drawing id="im-1" path="a.png" src="a" /> tail.'
|
|
_write_blocks(
|
|
tmp_path,
|
|
base,
|
|
[
|
|
{
|
|
"type": "content",
|
|
"blockid": "bA",
|
|
"format": "plain_text",
|
|
"content": block_a,
|
|
"heading": "h1",
|
|
"parent_headings": [],
|
|
"level": 1,
|
|
},
|
|
{
|
|
"type": "content",
|
|
"blockid": "bB",
|
|
"format": "plain_text",
|
|
"content": block_b,
|
|
"heading": "h2",
|
|
"parent_headings": [],
|
|
"level": 1,
|
|
},
|
|
],
|
|
)
|
|
drawings_path = tmp_path / f"{base}.drawings.json"
|
|
_write_sidecar(
|
|
drawings_path,
|
|
"drawings",
|
|
{"im-1": {"id": "im-1", "blockid": "bB", "heading": "h2"}},
|
|
)
|
|
|
|
enrich_sidecars_with_surrounding(
|
|
blocks_path=str(tmp_path / f"{base}.blocks.jsonl"),
|
|
enabled_modalities={"drawings"},
|
|
tokenizer=_tokenizer(),
|
|
leading_max_tokens=2000,
|
|
trailing_max_tokens=2000,
|
|
)
|
|
payload = json.loads(drawings_path.read_text(encoding="utf-8"))
|
|
surr = payload["drawings"]["im-1"]["surrounding"]
|
|
# Must come from block B only — content of block A absent.
|
|
assert "earlier block content" not in surr["leading"]
|
|
assert surr["leading"].startswith("later block.")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Per-half token budgets via SURROUNDING_LEADING/TRAILING_MAX_TOKENS env vars.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_env_var_leading_and_trailing_budgets_apply_independently(
|
|
tmp_path, monkeypatch
|
|
):
|
|
# Asymmetric budgets must produce asymmetric leading / trailing sizes.
|
|
monkeypatch.setenv("SURROUNDING_LEADING_MAX_TOKENS", "5")
|
|
monkeypatch.setenv("SURROUNDING_TRAILING_MAX_TOKENS", "20")
|
|
|
|
base = "doc"
|
|
blockid = "b1"
|
|
content = "X" * 200 + '<drawing id="im-1" path="a.png" src="a" />' + "Y" * 200
|
|
_write_blocks(
|
|
tmp_path,
|
|
base,
|
|
[
|
|
{
|
|
"type": "content",
|
|
"blockid": blockid,
|
|
"format": "plain_text",
|
|
"content": content,
|
|
"heading": "h",
|
|
"parent_headings": [],
|
|
"level": 1,
|
|
}
|
|
],
|
|
)
|
|
drawings_path = tmp_path / f"{base}.drawings.json"
|
|
_write_sidecar(
|
|
drawings_path,
|
|
"drawings",
|
|
{"im-1": {"id": "im-1", "blockid": blockid, "heading": "h"}},
|
|
)
|
|
|
|
tok = _tokenizer()
|
|
enrich_sidecars_with_surrounding(
|
|
blocks_path=str(tmp_path / f"{base}.blocks.jsonl"),
|
|
enabled_modalities={"drawings"},
|
|
tokenizer=tok,
|
|
)
|
|
|
|
surr = json.loads(drawings_path.read_text(encoding="utf-8"))["drawings"]["im-1"][
|
|
"surrounding"
|
|
]
|
|
assert len(tok.encode(surr["leading"])) <= 5
|
|
assert len(tok.encode(surr["trailing"])) <= 20
|
|
# Trailing is allowed to use its larger budget, so it must be strictly
|
|
# longer than leading here.
|
|
assert len(surr["trailing"]) > len(surr["leading"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Parser-internal markup stripping inside surrounding (mirrors what
|
|
# ``strip_internal_multimodal_markup_for_extraction`` does for chunk
|
|
# content before entity extraction). The cleaning happens *before*
|
|
# token-budgeted truncation, so the saved budget reflects what the
|
|
# LLM actually receives and a truncation point can never land inside
|
|
# an unprocessed ``id="…"`` attribute.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_surrounding_strips_drawing_id_path_src():
|
|
tok = _tokenizer()
|
|
block = (
|
|
"leading prose. "
|
|
'<drawing id="im-x" path="figs/a.png" src="raw/a.png" caption="Fig 1" />'
|
|
" between. "
|
|
'<equation id="eq-target" format="latex">x=1</equation>'
|
|
" trailing prose."
|
|
)
|
|
span = find_target_span("equations", "eq-target", block)
|
|
surr = build_surrounding(
|
|
kind="equations",
|
|
block_content=block,
|
|
span=span,
|
|
tokenizer=tok,
|
|
leading_max_tokens=2000,
|
|
trailing_max_tokens=2000,
|
|
separators=load_chunk_separators(),
|
|
)
|
|
leading = surr["leading"]
|
|
assert '<drawing caption="Fig 1" />' in leading
|
|
assert 'id="im-x"' not in leading
|
|
assert "path=" not in leading
|
|
assert "src=" not in leading
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_surrounding_strips_table_internal_id():
|
|
tok = _tokenizer()
|
|
block = (
|
|
"prefix. "
|
|
'<table id="tb-x" format="json" caption="Sales">[[1,2],[3,4]]</table>'
|
|
" between. "
|
|
'<drawing id="im-target" caption="Fig 2" />'
|
|
" suffix."
|
|
)
|
|
span = find_target_span("drawings", "im-target", block)
|
|
surr = build_surrounding(
|
|
kind="drawings",
|
|
block_content=block,
|
|
span=span,
|
|
tokenizer=tok,
|
|
leading_max_tokens=2000,
|
|
trailing_max_tokens=2000,
|
|
separators=load_chunk_separators(),
|
|
)
|
|
leading = surr["leading"]
|
|
assert '<table format="json" caption="Sales">[[1,2],[3,4]]</table>' in leading
|
|
assert 'id="tb-x"' not in leading
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_surrounding_strips_cite_refid_keeping_visible_text():
|
|
tok = _tokenizer()
|
|
block = (
|
|
"see "
|
|
'<cite type="table" refid="tb-x">Table 1</cite>'
|
|
" for details. "
|
|
'<drawing id="im-target" caption="Fig 3" />'
|
|
" end."
|
|
)
|
|
span = find_target_span("drawings", "im-target", block)
|
|
surr = build_surrounding(
|
|
kind="drawings",
|
|
block_content=block,
|
|
span=span,
|
|
tokenizer=tok,
|
|
leading_max_tokens=2000,
|
|
trailing_max_tokens=2000,
|
|
separators=load_chunk_separators(),
|
|
)
|
|
leading = surr["leading"]
|
|
# Surrounding path uses keep_cite_tag=True: the cite wrapper survives
|
|
# (so the VLM/LLM can tell "Table 1" is a reference to an external
|
|
# table, not inline prose) but the parser-internal refid is gone.
|
|
assert '<cite type="table">Table 1</cite>' in leading
|
|
assert "refid=" not in leading
|
|
assert "tb-x" not in leading
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_surrounding_keeps_equation_cite_tag_and_strips_refid():
|
|
"""In production, equations without LaTeX content emit as
|
|
``<cite type="equation" refid="eq-…">公式 N</cite>`` rather than a
|
|
full ``<equation>`` tag. Surrounding must keep the wrapper so the
|
|
multimodal analyzer can recognize the visible label as an external
|
|
referent, not inline prose."""
|
|
tok = _tokenizer()
|
|
block = (
|
|
"see "
|
|
'<cite type="equation" refid="eq-y">公式 2</cite>'
|
|
" above. "
|
|
'<drawing id="im-target" caption="Fig 4" />'
|
|
" end."
|
|
)
|
|
span = find_target_span("drawings", "im-target", block)
|
|
surr = build_surrounding(
|
|
kind="drawings",
|
|
block_content=block,
|
|
span=span,
|
|
tokenizer=tok,
|
|
leading_max_tokens=2000,
|
|
trailing_max_tokens=2000,
|
|
separators=load_chunk_separators(),
|
|
)
|
|
leading = surr["leading"]
|
|
assert '<cite type="equation">公式 2</cite>' in leading
|
|
assert "refid=" not in leading
|
|
assert "eq-y" not in leading
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_strip_happens_before_budget_truncation():
|
|
"""Regression guard for the strip-before-truncate ordering.
|
|
|
|
Constructs a leading source whose raw form (with id/path/src) exceeds
|
|
the budget while its stripped form fits. If strip ran *after*
|
|
truncation, the budget would be measured against the bloated raw
|
|
string and the saved surrounding would be cut early (possibly mid-
|
|
attribute, leaving ``id="…`` residue).
|
|
"""
|
|
tok = _tokenizer()
|
|
# Raw drawing tag including attrs (~67 chars), stripped form is
|
|
# just '<drawing caption="C" />' (~24 chars). Budget at 30 sits
|
|
# between the two — raw is too big, stripped fits.
|
|
block = (
|
|
'<drawing id="im-prev" path="some/long/path.png" src="raw/long/path.png"'
|
|
' caption="C" />'
|
|
'<equation id="eq-1" format="latex">y</equation>'
|
|
" tail."
|
|
)
|
|
span = find_target_span("equations", "eq-1", block)
|
|
surr = build_surrounding(
|
|
kind="equations",
|
|
block_content=block,
|
|
span=span,
|
|
tokenizer=tok,
|
|
leading_max_tokens=30,
|
|
trailing_max_tokens=2000,
|
|
separators=load_chunk_separators(),
|
|
)
|
|
leading = surr["leading"]
|
|
# Whole stripped tag must be present — proves strip ran before
|
|
# the budget gate.
|
|
assert leading == '<drawing caption="C" />'
|
|
# And no parser-internal markers leaked through.
|
|
assert "id=" not in leading
|
|
assert "path=" not in leading
|
|
assert "src=" not in leading
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_enrich_overwrites_surrounding_when_budget_changes(tmp_path):
|
|
"""Idempotency: rerunning with a smaller budget overwrites the prior
|
|
surrounding, demonstrating that ``SURROUNDING_LEADING_MAX_TOKENS``
|
|
changes propagate without needing to clear sidecars first."""
|
|
base = "doc"
|
|
blockid = "b1"
|
|
content = "L" * 500 + '<drawing id="im-1" caption="C" />' + "T" * 500
|
|
_write_blocks(
|
|
tmp_path,
|
|
base,
|
|
[
|
|
{
|
|
"type": "content",
|
|
"blockid": blockid,
|
|
"format": "plain_text",
|
|
"content": content,
|
|
"heading": "h",
|
|
"parent_headings": [],
|
|
"level": 1,
|
|
}
|
|
],
|
|
)
|
|
drawings_path = tmp_path / f"{base}.drawings.json"
|
|
_write_sidecar(
|
|
drawings_path,
|
|
"drawings",
|
|
{"im-1": {"id": "im-1", "blockid": blockid, "heading": "h"}},
|
|
)
|
|
|
|
tok = _tokenizer()
|
|
enrich_sidecars_with_surrounding(
|
|
blocks_path=str(tmp_path / f"{base}.blocks.jsonl"),
|
|
enabled_modalities={"drawings"},
|
|
tokenizer=tok,
|
|
leading_max_tokens=300,
|
|
trailing_max_tokens=300,
|
|
)
|
|
first = json.loads(drawings_path.read_text(encoding="utf-8"))["drawings"]["im-1"][
|
|
"surrounding"
|
|
]
|
|
first_leading_len = len(first["leading"])
|
|
first_trailing_len = len(first["trailing"])
|
|
|
|
enrich_sidecars_with_surrounding(
|
|
blocks_path=str(tmp_path / f"{base}.blocks.jsonl"),
|
|
enabled_modalities={"drawings"},
|
|
tokenizer=tok,
|
|
leading_max_tokens=50,
|
|
trailing_max_tokens=50,
|
|
)
|
|
second = json.loads(drawings_path.read_text(encoding="utf-8"))["drawings"]["im-1"][
|
|
"surrounding"
|
|
]
|
|
# New budget is smaller, so saved surrounding must shrink — proving
|
|
# the previous value was overwritten, not preserved.
|
|
assert len(second["leading"]) < first_leading_len
|
|
assert len(second["trailing"]) < first_trailing_len
|
|
assert len(tok.encode(second["leading"])) <= 50
|
|
assert len(tok.encode(second["trailing"])) <= 50
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_env_var_invalid_value_falls_back_to_default(monkeypatch):
|
|
# An unparseable env value must not crash; it falls back to 2000.
|
|
monkeypatch.setenv("SURROUNDING_LEADING_MAX_TOKENS", "not-a-number")
|
|
monkeypatch.setenv("SURROUNDING_TRAILING_MAX_TOKENS", "not-a-number")
|
|
from lightrag.multimodal_context import (
|
|
DEFAULT_SURROUNDING_MAX_TOKENS,
|
|
_resolve_surrounding_budget,
|
|
)
|
|
|
|
leading, trailing = _resolve_surrounding_budget(None, None)
|
|
assert leading == DEFAULT_SURROUNDING_MAX_TOKENS
|
|
assert trailing == DEFAULT_SURROUNDING_MAX_TOKENS
|