haiku.rag/tests/store/test_document_items.py

655 lines
25 KiB
Python

import pytest
from haiku.rag.client import HaikuRAG
from haiku.rag.client.documents import (
_store_document_with_chunks,
_update_document_with_chunks,
)
from haiku.rag.store.engine import Store
from haiku.rag.store.models.document_item import (
DocumentItem,
extract_item_text,
extract_items,
)
from haiku.rag.store.repositories.document_item import DocumentItemRepository
def _make_docling_doc():
"""Create a DoclingDocument with mixed item types for testing."""
from docling_core.types.doc.document import DoclingDocument, TableData
from docling_core.types.doc.labels import DocItemLabel
doc = DoclingDocument(name="test")
doc.add_text(label=DocItemLabel.SECTION_HEADER, text="Introduction")
doc.add_text(label=DocItemLabel.PARAGRAPH, text="This is the first paragraph.")
doc.add_text(label=DocItemLabel.PARAGRAPH, text="This is the second paragraph.")
doc.add_table(data=TableData(num_rows=2, num_cols=2, table_cells=[]))
doc.add_text(label=DocItemLabel.SECTION_HEADER, text="Conclusion")
doc.add_text(label=DocItemLabel.PARAGRAPH, text="Final thoughts here.")
return doc
class TestExtractItems:
def test_extracts_all_items(self):
doc = _make_docling_doc()
items = extract_items("doc-1", doc)
assert len(items) == 6
assert all(item.document_id == "doc-1" for item in items)
assert [item.position for item in items] == [0, 1, 2, 3, 4, 5]
def test_extracts_labels(self):
doc = _make_docling_doc()
items = extract_items("doc-1", doc)
assert items[0].label == "section_header"
assert items[1].label == "paragraph"
assert items[3].label == "table"
assert items[4].label == "section_header"
def test_extracts_text(self):
doc = _make_docling_doc()
items = extract_items("doc-1", doc)
assert items[0].text == "Introduction"
assert items[1].text == "This is the first paragraph."
assert items[5].text == "Final thoughts here."
def test_extracts_self_refs(self):
doc = _make_docling_doc()
items = extract_items("doc-1", doc)
assert all(item.self_ref.startswith("#/") for item in items)
def test_table_gets_markdown_text(self):
doc = _make_docling_doc()
items = extract_items("doc-1", doc)
table_item = items[3]
assert table_item.label == "table"
# Table should have some text from export_to_markdown
assert isinstance(table_item.text, str)
class TestExtractItemText:
def test_text_item(self):
from docling_core.types.doc.document import DoclingDocument
from docling_core.types.doc.labels import DocItemLabel
doc = DoclingDocument(name="test")
doc.add_text(label=DocItemLabel.PARAGRAPH, text="Hello world")
item, _ = next(iter(doc.iterate_items()))
assert extract_item_text(item, doc) == "Hello world"
def test_returns_none_for_empty_item(self):
from docling_core.types.doc.document import DoclingDocument
doc = DoclingDocument(name="test")
# An empty doc has no items to extract text from
items = extract_items("doc-1", doc)
assert items == []
@pytest.mark.asyncio
class TestDocumentItemRepository:
async def test_create_and_get_range(self, temp_db_path):
async with HaikuRAG(temp_db_path, create=True) as rag:
repo = DocumentItemRepository(rag.store)
items = [
DocumentItem(
document_id="doc-1",
position=i,
self_ref=f"#/texts/{i}",
label="paragraph",
text=f"Item {i}",
page_numbers=[1],
)
for i in range(10)
]
await repo.create_items("doc-1", items)
result = await repo.get_items_in_range("doc-1", 3, 7)
assert len(result) == 5
assert result[0].position == 3
assert result[-1].position == 7
assert result[0].text == "Item 3"
async def test_resolve_refs(self, temp_db_path):
async with HaikuRAG(temp_db_path, create=True) as rag:
repo = DocumentItemRepository(rag.store)
items = [
DocumentItem(
document_id="doc-1",
position=i,
self_ref=f"#/texts/{i}",
label="paragraph",
text=f"Item {i}",
)
for i in range(10)
]
await repo.create_items("doc-1", items)
refs = await repo.resolve_refs(
"doc-1", ["#/texts/2", "#/texts/7", "#/texts/999"]
)
assert refs == {"#/texts/2": 2, "#/texts/7": 7}
async def test_get_item_count(self, temp_db_path):
async with HaikuRAG(temp_db_path, create=True) as rag:
repo = DocumentItemRepository(rag.store)
items = [
DocumentItem(
document_id="doc-1",
position=i,
self_ref=f"#/texts/{i}",
label="paragraph",
text=f"Item {i}",
)
for i in range(15)
]
await repo.create_items("doc-1", items)
assert await repo.get_item_count("doc-1") == 15
assert await repo.get_item_count("nonexistent") == 0
async def test_delete_by_document_id(self, temp_db_path):
async with HaikuRAG(temp_db_path, create=True) as rag:
repo = DocumentItemRepository(rag.store)
for doc_id in ["doc-1", "doc-2"]:
items = [
DocumentItem(
document_id=doc_id,
position=i,
self_ref=f"#/texts/{i}",
label="paragraph",
text=f"Item {i}",
)
for i in range(5)
]
await repo.create_items(doc_id, items)
await repo.delete_by_document_id("doc-1")
assert await repo.get_item_count("doc-1") == 0
assert await repo.get_item_count("doc-2") == 5
async def test_empty_refs_returns_empty(self, temp_db_path):
async with HaikuRAG(temp_db_path, create=True) as rag:
repo = DocumentItemRepository(rag.store)
assert await repo.resolve_refs("doc-1", []) == {}
async def test_items_sorted_by_position(self, temp_db_path):
async with HaikuRAG(temp_db_path, create=True) as rag:
repo = DocumentItemRepository(rag.store)
# Insert in reverse order
items = [
DocumentItem(
document_id="doc-1",
position=9 - i,
self_ref=f"#/texts/{9 - i}",
label="paragraph",
text=f"Item {9 - i}",
)
for i in range(10)
]
await repo.create_items("doc-1", items)
result = await repo.get_items_in_range("doc-1", 0, 9)
positions = [item.position for item in result]
assert positions == sorted(positions)
@pytest.mark.asyncio
class TestDocumentItemPopulation:
async def test_store_document_populates_items(self, temp_db_path):
"""Test that _store_document_with_chunks populates items when given a docling_document."""
from haiku.rag.store.models.document import Document
docling_doc = _make_docling_doc()
async with HaikuRAG(temp_db_path, create=True) as rag:
document = Document(
content="test content",
uri="test://doc",
)
document.set_docling(docling_doc)
# Use _store_document_with_chunks directly with empty chunks
# to avoid needing embeddings
created = await _store_document_with_chunks(rag, document, [], docling_doc)
assert created.id is not None
count = await rag.document_item_repository.get_item_count(created.id)
assert count == 6
items = await rag.document_item_repository.get_items_in_range(
created.id, 0, count
)
assert items[0].label == "section_header"
assert items[0].text == "Introduction"
assert items[1].label == "paragraph"
async def test_update_document_replaces_items(self, temp_db_path):
"""Test that _update_document_with_chunks replaces items."""
from docling_core.types.doc.document import DoclingDocument
from docling_core.types.doc.labels import DocItemLabel
from haiku.rag.store.models.document import Document
docling_doc = _make_docling_doc()
async with HaikuRAG(temp_db_path, create=True) as rag:
document = Document(
content="test content",
uri="test://doc",
)
document.set_docling(docling_doc)
created = await _store_document_with_chunks(rag, document, [], docling_doc)
assert created.id is not None
assert await rag.document_item_repository.get_item_count(created.id) == 6
# Update with a simpler document
new_doc = DoclingDocument(name="updated")
new_doc.add_text(label=DocItemLabel.PARAGRAPH, text="Only one item now.")
created.set_docling(new_doc)
await _update_document_with_chunks(rag, created, [], new_doc)
assert await rag.document_item_repository.get_item_count(created.id) == 1
async def test_delete_document_cascades_items(self, temp_db_path):
"""Test that deleting a document also deletes its items."""
from haiku.rag.store.models.document import Document
docling_doc = _make_docling_doc()
async with HaikuRAG(temp_db_path, create=True) as rag:
document = Document(
content="test content",
uri="test://doc",
)
document.set_docling(docling_doc)
created = await _store_document_with_chunks(rag, document, [], docling_doc)
assert created.id is not None
assert await rag.document_item_repository.get_item_count(created.id) == 6
await rag.delete_document(created.id)
assert await rag.document_item_repository.get_item_count(created.id) == 0
@pytest.mark.asyncio
class TestDocumentItemMigration:
async def test_migration_populates_items_for_existing_documents(self, temp_db_path):
"""Test that the v0.40.0 migration populates items for pre-existing documents."""
from haiku.rag.store.compression import compress_docling_split
from haiku.rag.store.engine import DocumentRecord
docling_doc = _make_docling_doc()
json_str = docling_doc.model_dump_json()
structure, pages = compress_docling_split(json_str)
# Create a database at a pre-migration version with a document
async with Store(temp_db_path, create=True, skip_migration_check=True) as store:
await store.set_haiku_version("0.39.0")
doc_record = DocumentRecord(
id="test-doc-1",
content="test content",
uri="test://doc",
docling_document=structure,
docling_pages=pages,
docling_version=docling_doc.version,
)
await store.documents_table.add([doc_record])
# Verify no items exist yet
assert await store.document_items_table.count_rows() == 0
# Re-open with skip_migration_check and run migration
async with Store(temp_db_path, skip_migration_check=True) as store:
applied = await store.migrate()
# Should have applied the v0.40.0 migration
assert any("document_items" in desc for desc in applied)
# Items should now exist
item_count = await store.document_items_table.count_rows(
filter="document_id = 'test-doc-1'"
)
assert item_count == 6
# Verify item content
items = await (
store.document_items_table.query()
.where("document_id = 'test-doc-1'")
.to_list()
)
labels = {row["label"] for row in items}
assert "section_header" in labels
assert "paragraph" in labels
assert "table" in labels
async def test_migration_skips_documents_without_docling(self, temp_db_path):
"""Test that migration handles documents without docling data."""
from haiku.rag.store.engine import DocumentRecord
async with Store(temp_db_path, create=True, skip_migration_check=True) as store:
await store.set_haiku_version("0.39.0")
doc_record = DocumentRecord(
id="no-docling",
content="plain text document",
)
await store.documents_table.add([doc_record])
async with Store(temp_db_path, skip_migration_check=True) as store:
await store.migrate()
# No items should have been created
assert await store.document_items_table.count_rows() == 0
@pytest.mark.asyncio
class TestPictureDataStorage:
async def test_create_and_get_picture_bytes(self, temp_db_path):
"""Round-trip picture bytes through DocumentItem and the repository."""
async with HaikuRAG(temp_db_path, create=True) as rag:
repo = DocumentItemRepository(rag.store)
png_bytes = b"\x89PNG\r\n\x1a\nfake-picture-bytes"
items = [
DocumentItem(
document_id="doc-1",
position=0,
self_ref="#/texts/0",
label="paragraph",
text="Some text",
),
DocumentItem(
document_id="doc-1",
position=1,
self_ref="#/pictures/0",
label="picture",
text="",
picture_data=png_bytes,
),
]
await repo.create_items("doc-1", items)
# Single-ref lookup
got = await repo.get_picture_bytes("doc-1", "#/pictures/0")
assert got == png_bytes
# Missing ref returns None
assert await repo.get_picture_bytes("doc-1", "#/pictures/999") is None
# Non-picture row has no bytes
assert await repo.get_picture_bytes("doc-1", "#/texts/0") is None
# Batch lookup omits refs without bytes
batch = await repo.get_pictures_for_chunk(
"doc-1", ["#/pictures/0", "#/texts/0", "#/pictures/999"]
)
assert batch == {"#/pictures/0": png_bytes}
# Empty refs returns empty dict
assert await repo.get_pictures_for_chunk("doc-1", []) == {}
async def test_hot_paths_exclude_picture_data(self, temp_db_path):
"""Light read paths must NOT pull picture_data into memory."""
async with HaikuRAG(temp_db_path, create=True) as rag:
repo = DocumentItemRepository(rag.store)
heavy = b"x" * 1024
await repo.create_items(
"doc-1",
[
DocumentItem(
document_id="doc-1",
position=0,
self_ref="#/pictures/0",
label="picture",
text="",
picture_data=heavy,
),
],
)
for item in await repo.get_all_items("doc-1"):
assert item.picture_data is None
for item in await repo.get_items_in_range("doc-1", 0, 10):
assert item.picture_data is None
grouped = await repo.get_all_items_grouped(["doc-1"])
for item in grouped["doc-1"]:
assert item.picture_data is None
# But the picture-byte accessors still work
assert (await repo.get_picture_bytes("doc-1", "#/pictures/0")) == heavy
async def test_fresh_db_has_picture_data_column(self, temp_db_path):
"""A newly-created DB has picture_data on document_items via _init_tables."""
async with HaikuRAG(temp_db_path, create=True) as rag:
schema = await rag.store.document_items_table.schema()
assert "picture_data" in {f.name for f in schema}
def _docling_doc_with_picture():
"""Build a tiny DoclingDocument with one PictureItem carrying real PNG bytes
via ImageRef.from_pil. Used by the picture-extraction tests."""
from docling_core.types.doc.document import DoclingDocument, ImageRef
from docling_core.types.doc.labels import DocItemLabel
from PIL import Image as PilImageModule
img = PilImageModule.new("RGB", (8, 8), "red")
doc = DoclingDocument(name="test-with-picture")
doc.add_text(label=DocItemLabel.PARAGRAPH, text="Hello world")
doc.add_picture(image=ImageRef.from_pil(img, dpi=72))
return doc
class TestExtractItemsPictureBytes:
"""A2b: extract_items decodes picture image bytes from data URIs."""
def test_decodes_picture_bytes_from_live_doc(self):
doc = _docling_doc_with_picture()
items = extract_items("doc-1", doc)
picture_items = [i for i in items if i.label == "picture"]
assert len(picture_items) == 1
data = picture_items[0].picture_data
assert data is not None and len(data) > 0
# PNG magic header — confirms we round-tripped real bytes, not a mangled URI.
assert data.startswith(b"\x89PNG")
def test_existing_picture_data_used_when_image_stripped(self):
"""Rebuild round-trip: live docling has image=None, snapshot fills the gap."""
doc = _docling_doc_with_picture()
for picture in doc.pictures:
picture.image = None
snapshot = {"#/pictures/0": b"snapshot-picture-bytes"}
items = extract_items("doc-1", doc, existing_picture_data=snapshot)
picture_items = [i for i in items if i.label == "picture"]
assert picture_items[0].picture_data == b"snapshot-picture-bytes"
class TestExtractItemTextDescription:
"""A2b: extract_item_text returns VLM description text for PictureItems."""
def test_returns_description_text_when_present(self):
from docling_core.types.doc.document import (
DescriptionAnnotation,
DoclingDocument,
)
doc = DoclingDocument(name="t")
doc.add_picture(
annotations=[
DescriptionAnnotation(text="A small red square", provenance="test")
]
)
items = extract_items("doc-1", doc)
picture_items = [i for i in items if i.label == "picture"]
assert picture_items[0].text == "A small red square"
class TestCompressDoclingSplitStripsPictureUris:
"""A2b: compress_docling_split removes inline picture URIs from the structure."""
def test_picture_image_set_to_none_in_structure(self):
import json
from haiku.rag.store.compression import (
compress_docling_split,
decompress_json,
)
doc_json = {
"schema_name": "DoclingDocument",
"version": "1.10.0",
"name": "test",
"pictures": [
{
"self_ref": "#/pictures/0",
"image": {
"mimetype": "image/png",
"uri": "data:image/png;base64,abc",
},
},
{"self_ref": "#/pictures/1", "image": None},
],
"pages": {},
}
structure_bytes, pages_bytes = compress_docling_split(json.dumps(doc_json))
decoded = json.loads(decompress_json(structure_bytes))
for pic in decoded["pictures"]:
assert pic["image"] is None
assert pages_bytes is None # no pages in this fixture
@pytest.mark.asyncio
class TestPictureDataMigrationBackfill:
"""A2b: v0.45.0 migration backfills picture_data and strips URIs from blobs."""
async def test_backfill_populates_column_and_strips_blob(self, temp_db_path):
import base64
import json
from haiku.rag.store.compression import compress_json, decompress_json
from haiku.rag.store.engine import DocumentItemRecord, DocumentRecord
fake_png = b"\x89PNG\r\n\x1a\nlegacy-picture-bytes-for-test"
data_uri = "data:image/png;base64," + base64.b64encode(fake_png).decode("ascii")
blob_data = {
"schema_name": "DoclingDocument",
"version": "1.10.0",
"name": "legacy",
"pictures": [
{
"self_ref": "#/pictures/0",
"label": "picture",
"image": {"mimetype": "image/png", "uri": data_uri},
},
],
}
blob_bytes = compress_json(json.dumps(blob_data))
# Build a legacy-state DB at v0.44.0 *without* the picture_data column,
# mirroring users coming from main's 0.44.0 release. The 0.45.0
# migration must add the column AND backfill it from the blob in one
# pass.
async with Store(temp_db_path, create=True, skip_migration_check=True) as store:
await store.set_haiku_version("0.44.0")
await store.documents_table.add(
[
DocumentRecord(
id="legacy-doc",
content="legacy",
docling_document=blob_bytes,
)
]
)
# Items row exists (v0.40.0 would have placed it) but no picture_data yet.
await store.document_items_table.add(
[
DocumentItemRecord(
document_id="legacy-doc",
position=0,
self_ref="#/pictures/0",
label="picture",
text="",
page_numbers="[]",
)
]
)
# Drop the column so the migration's column-add path is exercised.
await store.document_items_table.drop_columns(["picture_data"])
schema_before = await store.document_items_table.schema()
assert "picture_data" not in {f.name for f in schema_before}
async with Store(temp_db_path, skip_migration_check=True) as store:
applied = await store.migrate()
assert any("picture" in d.lower() for d in applied)
# Column was added by the migration
schema_after = await store.document_items_table.schema()
assert "picture_data" in {f.name for f in schema_after}
# picture_data backfilled with the legacy bytes
rows = await (
store.document_items_table.query()
.select(["self_ref", "picture_data"])
.where("document_id = 'legacy-doc'")
.to_list()
)
picture_rows = [r for r in rows if r["self_ref"] == "#/pictures/0"]
assert len(picture_rows) == 1
assert picture_rows[0]["picture_data"] == fake_png
# docling_document blob has been re-compressed with image=None
doc_rows = await (
store.documents_table.query()
.select(["docling_document"])
.where("id = 'legacy-doc'")
.to_list()
)
decoded = json.loads(decompress_json(doc_rows[0]["docling_document"]))
assert decoded["pictures"][0]["image"] is None
@pytest.mark.asyncio
class TestPictureDataPreservedThroughRoundTrip:
"""A2b: snapshot/merge keeps picture bytes through update / rebuild cycles."""
async def test_update_preserves_picture_data_when_blob_round_tripped(
self, temp_db_path
):
"""update_document on a docling pulled from the (stripped) blob must
not clobber picture_data — the snapshot/merge in
_update_document_with_chunks handles it."""
from haiku.rag.client.documents import (
_store_document_with_chunks,
_update_document_with_chunks,
)
from haiku.rag.store.models.document import Document
docling_doc = _docling_doc_with_picture()
async with HaikuRAG(temp_db_path, create=True) as rag:
document = Document(content="Hello world", uri="test://doc")
document.set_docling(docling_doc)
created = await _store_document_with_chunks(rag, document, [], docling_doc)
assert created.id is not None
original = await rag.document_item_repository.get_all_picture_data(
created.id
)
assert original.get("#/pictures/0") is not None
# Re-load the docling from the stored blob — pictures now have image=None
from_blob = created.get_docling_document()
assert from_blob is not None
assert all(p.image is None for p in from_blob.pictures)
await _update_document_with_chunks(rag, created, [], from_blob)
after = await rag.document_item_repository.get_all_picture_data(created.id)
assert after.get("#/pictures/0") == original.get("#/pictures/0")