431 lines
15 KiB
Python
431 lines
15 KiB
Python
"""End-to-end test: native docx → LightRAG Document → stable cache key.
|
|
|
|
The original bug this guards against: ``parse_native`` used to write a
|
|
runtime-stamped structured parser payload into ``full_docs.content``, so
|
|
re-parsing the same docx produced different
|
|
chunk-0 content and therefore different LLM cache keys.
|
|
|
|
After the fix, ``parse_native`` writes ``.blocks.jsonl`` + sidecars and
|
|
``full_docs`` is in LIGHTRAG format. ``_load_lightrag_document_content``
|
|
skips the ``meta`` line (which contains ``parse_time``) and concatenates
|
|
only ``"type": "content"`` rows, so re-parsing must yield byte-identical
|
|
``merged_text`` and stable downstream chunk-0 content.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from lightrag import LightRAG
|
|
from lightrag.constants import (
|
|
FULL_DOCS_FORMAT_PENDING_PARSE,
|
|
PARSED_DIR_NAME,
|
|
)
|
|
from lightrag.parser.base import ParseContext
|
|
from lightrag.parser.registry import get_parser
|
|
from lightrag.utils import Tokenizer, TokenizerInterface, compute_args_hash
|
|
|
|
|
|
async def _parse_via_registry(rag, engine, doc_id, file_path, content_data):
|
|
"""Drive a parser the way the pipeline worker does (registry dispatch)."""
|
|
result = await get_parser(engine).parse(
|
|
ParseContext(rag, doc_id, file_path, content_data)
|
|
)
|
|
return result.to_dict()
|
|
|
|
|
|
def _block(content, *, heading="", level=0, parent=None, uuid="p1"):
|
|
"""Build a synthetic block dict matching extract_docx_blocks output."""
|
|
return {
|
|
"uuid": uuid,
|
|
"uuid_end": uuid,
|
|
"heading": heading,
|
|
"content": content,
|
|
"type": "text",
|
|
"parent_headings": list(parent or []),
|
|
"level": level,
|
|
"table_chunk_role": "none",
|
|
}
|
|
|
|
|
|
class _MiniFullDocs:
|
|
def __init__(self):
|
|
self.data = {}
|
|
|
|
async def upsert(self, payload):
|
|
self.data.update(payload)
|
|
|
|
async def get_by_id(self, doc_id):
|
|
return self.data.get(doc_id)
|
|
|
|
async def index_done_callback(self):
|
|
return None
|
|
|
|
|
|
class _MiniDocStatus:
|
|
async def get_by_id(self, doc_id):
|
|
return None
|
|
|
|
async def upsert(self, data):
|
|
return None
|
|
|
|
|
|
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)
|
|
|
|
|
|
class _MiniRag:
|
|
"""Just enough surface for parse_native + parser/docx adapter."""
|
|
|
|
_persist_parsed_full_docs = LightRAG._persist_parsed_full_docs
|
|
|
|
def __init__(self, working_dir):
|
|
self.working_dir = str(working_dir)
|
|
self.full_docs = _MiniFullDocs()
|
|
self.doc_status = _MiniDocStatus()
|
|
self.tokenizer = Tokenizer(model_name="char", tokenizer=_CharTokenizer())
|
|
|
|
def _resolve_source_file_for_parser(self, file_path):
|
|
return file_path
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_native_lightrag_path_produces_stable_merged_text(tmp_path, monkeypatch):
|
|
"""Re-parsing the same docx must yield byte-identical merged_text and
|
|
therefore identical chunk_args_hash on chunk-0."""
|
|
|
|
async def _run():
|
|
input_dir = tmp_path / "input"
|
|
input_dir.mkdir()
|
|
monkeypatch.setenv("INPUT_DIR", str(input_dir))
|
|
|
|
source_path = input_dir / "stable.docx"
|
|
source_path.write_bytes(b"fake docx bytes")
|
|
|
|
# Stub extract_docx_blocks at the adapter so the upstream DOCX
|
|
# parser is never invoked. The adapter still does all the
|
|
# LightRAG-specific writing — that is what we want under test.
|
|
stable_blocks = [
|
|
_block(
|
|
"Title\nFirst paragraph body.\nSecond paragraph body.",
|
|
heading="Title",
|
|
level=1,
|
|
),
|
|
]
|
|
|
|
def _stub_extract(file_path, drawing_context=None, **kwargs):
|
|
return [dict(b) for b in stable_blocks]
|
|
|
|
monkeypatch.setattr(
|
|
"lightrag.parser.docx.parse_document.extract_docx_blocks",
|
|
_stub_extract,
|
|
)
|
|
|
|
rag = _MiniRag(tmp_path / "work")
|
|
|
|
# ---- First parse ----
|
|
# parse_native archives the source after writing, so re-create it
|
|
# before the second parse for a fair comparison.
|
|
result1 = await _parse_via_registry(
|
|
rag,
|
|
"native",
|
|
"doc-stable",
|
|
str(source_path),
|
|
{"parse_format": FULL_DOCS_FORMAT_PENDING_PARSE, "content": ""},
|
|
)
|
|
merged1 = result1["content"]
|
|
assert merged1, "first parse produced empty merged_text"
|
|
|
|
# ---- Second parse ----
|
|
# Restore the source file (archive moved it), reset the in-memory
|
|
# full_docs row, and remove the parsed_dir so the writer rewrites
|
|
# both meta (with a fresh parse_time) and content lines.
|
|
source_path.write_bytes(b"fake docx bytes")
|
|
rag.full_docs.data.clear()
|
|
parsed_artifact_dir = input_dir / PARSED_DIR_NAME / f"{source_path.name}.parsed"
|
|
if parsed_artifact_dir.exists():
|
|
import shutil
|
|
|
|
shutil.rmtree(parsed_artifact_dir)
|
|
|
|
result2 = await _parse_via_registry(
|
|
rag,
|
|
"native",
|
|
"doc-stable",
|
|
str(source_path),
|
|
{"parse_format": FULL_DOCS_FORMAT_PENDING_PARSE, "content": ""},
|
|
)
|
|
merged2 = result2["content"]
|
|
|
|
# Core invariant: merged_text byte-identical across runs even
|
|
# though parse_time in the .blocks.jsonl meta line differs.
|
|
assert merged1 == merged2
|
|
|
|
# And: a hash computed over a chunk-0 derived from merged_text
|
|
# must also be identical — that is what powers LLM cache hits.
|
|
prompt_template = "EXTRACT_PROMPT::{text}"
|
|
chunk0_a = prompt_template.format(text=merged1[:200])
|
|
chunk0_b = prompt_template.format(text=merged2[:200])
|
|
assert chunk0_a == chunk0_b
|
|
assert compute_args_hash(chunk0_a) == compute_args_hash(chunk0_b)
|
|
|
|
# And: full_docs.content uses the {{LRdoc}} marker plus a leading
|
|
# summary derived from merged_text (not the legacy placeholder).
|
|
record = rag.full_docs.data["doc-stable"]
|
|
assert record["parse_format"] == "lightrag"
|
|
assert record["content"].startswith("{{LRdoc}}")
|
|
assert merged1[:40] in record["content"]
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_native_lightrag_path_writes_blocks_jsonl_and_skips_meta_on_load(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""Sanity check: ``_load_lightrag_document_content`` must skip the
|
|
meta line (where the runtime ``parse_time`` lives) and only return
|
|
body content. This is what lets re-parsing produce stable text."""
|
|
|
|
async def _run():
|
|
input_dir = tmp_path / "input"
|
|
input_dir.mkdir()
|
|
monkeypatch.setenv("INPUT_DIR", str(input_dir))
|
|
|
|
source_path = input_dir / "skipmeta.docx"
|
|
source_path.write_bytes(b"fake")
|
|
|
|
def _stub_extract(file_path, drawing_context=None, **kwargs):
|
|
return [_block("the body")]
|
|
|
|
monkeypatch.setattr(
|
|
"lightrag.parser.docx.parse_document.extract_docx_blocks",
|
|
_stub_extract,
|
|
)
|
|
|
|
rag = _MiniRag(tmp_path / "work")
|
|
result = await _parse_via_registry(
|
|
rag,
|
|
"native",
|
|
"doc-skip",
|
|
str(source_path),
|
|
{"parse_format": FULL_DOCS_FORMAT_PENDING_PARSE, "content": ""},
|
|
)
|
|
|
|
# The .blocks.jsonl on disk DOES contain "parse_time" inside the
|
|
# meta line; the merged_text returned by parse_native MUST NOT.
|
|
blocks_path = result["blocks_path"]
|
|
on_disk = open(blocks_path, "r", encoding="utf-8").read()
|
|
assert "parse_time" in on_disk
|
|
assert "parse_time" not in result["content"]
|
|
assert result["content"].strip() == "the body"
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_native_lightrag_path_leaves_unknown_table_caption_empty(tmp_path, monkeypatch):
|
|
"""The native DOCX parser does not infer table titles, so its table
|
|
sidecar must not synthesize captions like ``表1``.
|
|
"""
|
|
|
|
async def _run():
|
|
input_dir = tmp_path / "input"
|
|
input_dir.mkdir()
|
|
monkeypatch.setenv("INPUT_DIR", str(input_dir))
|
|
|
|
source_path = input_dir / "table.docx"
|
|
source_path.write_bytes(b"fake")
|
|
|
|
def _stub_extract(file_path, drawing_context=None, **kwargs):
|
|
return [_block('before\n<table>[["A"]]</table>\nafter')]
|
|
|
|
monkeypatch.setattr(
|
|
"lightrag.parser.docx.parse_document.extract_docx_blocks",
|
|
_stub_extract,
|
|
)
|
|
|
|
rag = _MiniRag(tmp_path / "work")
|
|
result = await _parse_via_registry(
|
|
rag,
|
|
"native",
|
|
"doc-table",
|
|
str(source_path),
|
|
{"parse_format": FULL_DOCS_FORMAT_PENDING_PARSE, "content": ""},
|
|
)
|
|
|
|
blocks_path = Path(result["blocks_path"])
|
|
lines = blocks_path.read_text(encoding="utf-8").splitlines()
|
|
block = json.loads(lines[1])
|
|
assert "caption=" not in block["content"]
|
|
assert "表1" not in block["content"]
|
|
|
|
tables_path = blocks_path.with_suffix("").with_suffix(".tables.json")
|
|
tables = json.loads(tables_path.read_text(encoding="utf-8"))
|
|
table_entry = tables["tables"]["tb-table-0001"]
|
|
assert table_entry["caption"] == ""
|
|
|
|
# Surrounding is now backfilled at analyze_multimodal entry, not in
|
|
# parse_native — invoke the same routine directly to mirror that.
|
|
from lightrag.multimodal_context import enrich_sidecars_with_surrounding
|
|
|
|
enrich_sidecars_with_surrounding(
|
|
blocks_path=str(blocks_path),
|
|
enabled_modalities={"tables"},
|
|
tokenizer=rag.tokenizer,
|
|
)
|
|
tables = json.loads(tables_path.read_text(encoding="utf-8"))
|
|
table_entry = tables["tables"]["tb-table-0001"]
|
|
assert table_entry["surrounding"] == {
|
|
"leading": "before\n",
|
|
"trailing": "\nafter",
|
|
}
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_analyze_entrypoint_backfills_surrounding_for_all_sidecars(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""Surrounding is backfilled at analyze_multimodal entry, covering native
|
|
parse output as well as any other sidecar-producing engine."""
|
|
|
|
async def _run():
|
|
input_dir = tmp_path / "input"
|
|
input_dir.mkdir()
|
|
monkeypatch.setenv("INPUT_DIR", str(input_dir))
|
|
|
|
source_path = input_dir / "all_modalities.docx"
|
|
source_path.write_bytes(b"fake")
|
|
|
|
def _stub_extract(file_path, drawing_context=None, **kwargs):
|
|
assert drawing_context is not None
|
|
assert drawing_context.export_dir_path is not None
|
|
(drawing_context.export_dir_path / "pic.png").write_bytes(b"PNG")
|
|
return [
|
|
_block(
|
|
'alpha <drawing id="1" format="png" '
|
|
'path="all_modalities.blocks.assets/pic.png" /> beta\n'
|
|
'<table>[["A"]]</table> gamma\n'
|
|
"<equation>E=mc^2</equation>\n"
|
|
"delta"
|
|
)
|
|
]
|
|
|
|
monkeypatch.setattr(
|
|
"lightrag.parser.docx.parse_document.extract_docx_blocks",
|
|
_stub_extract,
|
|
)
|
|
|
|
rag = _MiniRag(tmp_path / "work")
|
|
result = await _parse_via_registry(
|
|
rag,
|
|
"native",
|
|
"doc-mm",
|
|
str(source_path),
|
|
{"parse_format": FULL_DOCS_FORMAT_PENDING_PARSE, "content": ""},
|
|
)
|
|
|
|
blocks_path = Path(result["blocks_path"])
|
|
base = str(blocks_path)[: -len(".blocks.jsonl")]
|
|
|
|
# Parse-time sidecars must NOT contain surrounding — that field is
|
|
# now produced at analyze_multimodal entry.
|
|
for root in ("drawings", "tables", "equations"):
|
|
payload = json.loads(Path(base + f".{root}.json").read_text("utf-8"))
|
|
for item in payload[root].values():
|
|
assert "surrounding" not in item
|
|
|
|
# Now invoke the same routine analyze_multimodal calls and verify
|
|
# all modalities get populated.
|
|
from lightrag.multimodal_context import enrich_sidecars_with_surrounding
|
|
|
|
enrich_sidecars_with_surrounding(
|
|
blocks_path=str(blocks_path),
|
|
enabled_modalities={"drawings", "tables", "equations"},
|
|
tokenizer=rag.tokenizer,
|
|
)
|
|
for root in ("drawings", "tables", "equations"):
|
|
payload = json.loads(Path(base + f".{root}.json").read_text("utf-8"))
|
|
items = payload[root]
|
|
assert items
|
|
for item in items.values():
|
|
assert "surrounding" in item
|
|
assert set(item["surrounding"]) == {"leading", "trailing"}
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_native_lightrag_path_writes_image_assets_to_blocks_assets_dir(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""Native parsing must drop image bytes into ``<base>.blocks.assets/``
|
|
after the adapter creates the parsed dir (which it wipes at the start),
|
|
and the drawings sidecar must reference the rewritten ids.
|
|
"""
|
|
from pathlib import Path
|
|
|
|
async def _run():
|
|
input_dir = tmp_path / "input"
|
|
input_dir.mkdir()
|
|
monkeypatch.setenv("INPUT_DIR", str(input_dir))
|
|
|
|
source_path = input_dir / "with_pics.docx"
|
|
source_path.write_bytes(b"fake")
|
|
|
|
def _stub_extract(file_path, drawing_context=None, **kwargs):
|
|
# The adapter already created the asset dir before calling us;
|
|
# write the fake image bytes there as a side-effect, then return
|
|
# a block whose content references that asset via <drawing .../>.
|
|
assert drawing_context is not None
|
|
assert drawing_context.export_dir_path is not None
|
|
(drawing_context.export_dir_path / "pic.png").write_bytes(b"PNG-BYTES")
|
|
return [
|
|
_block(
|
|
"intro\n"
|
|
'<drawing id="1" name="pic" format="png" '
|
|
'path="with_pics.blocks.assets/pic.png" />\n'
|
|
"outro",
|
|
heading="intro",
|
|
level=1,
|
|
),
|
|
]
|
|
|
|
monkeypatch.setattr(
|
|
"lightrag.parser.docx.parse_document.extract_docx_blocks",
|
|
_stub_extract,
|
|
)
|
|
|
|
rag = _MiniRag(tmp_path / "work")
|
|
result = await _parse_via_registry(
|
|
rag,
|
|
"native",
|
|
"doc-pic",
|
|
str(source_path),
|
|
{"parse_format": FULL_DOCS_FORMAT_PENDING_PARSE, "content": ""},
|
|
)
|
|
|
|
blocks_path = Path(result["blocks_path"])
|
|
parsed_dir = blocks_path.parent
|
|
asset_dir = parsed_dir / "with_pics.blocks.assets"
|
|
# Asset dir must exist alongside .blocks.jsonl and survive the
|
|
# adapter's parsed_dir cleanup step.
|
|
assert asset_dir.is_dir(), (
|
|
f"asset dir not created at {asset_dir}; parsed_dir contents: "
|
|
f"{list(parsed_dir.iterdir())}"
|
|
)
|
|
assert (asset_dir / "pic.png").read_bytes() == b"PNG-BYTES"
|
|
# And drawings.json sidecar should also be there since the block
|
|
# contained a <drawing .../> markup the adapter had to record.
|
|
assert (parsed_dir / "with_pics.drawings.json").is_file()
|
|
|
|
asyncio.run(_run())
|