import json import pytest from haiku.rag.store.compression import compress_docling_split from haiku.rag.store.engine import DocumentItemRecord, DocumentRecord, Store from haiku.rag.store.upgrades.v0_48_0 import _apply_backfill_heading_hierarchy def _docling_with_levels(): from docling_core.types.doc.document import DoclingDocument from docling_core.types.doc.labels import DocItemLabel doc = DoclingDocument(name="leveled") doc.add_heading(text="Introduction", level=1) doc.add_text(label=DocItemLabel.PARAGRAPH, text="Opening.") doc.add_heading(text="Background", level=2) doc.add_text(label=DocItemLabel.PARAGRAPH, text="Bg paragraph.") doc.add_heading(text="Methods", level=1) return doc @pytest.mark.asyncio class TestV0_48_0Migration: """v0.48.0 backfills heading_level + tree_depth on existing items rows.""" async def test_backfill_populates_levels(self, temp_db_path): docling_doc = _docling_with_levels() structure, pages = compress_docling_split(docling_doc.model_dump(mode="json")) async with Store(temp_db_path, create=True, skip_migration_check=True) as store: await store.set_haiku_version("0.45.0") await store.documents_table.add( [ DocumentRecord( id="doc-1", content="legacy", docling_document=structure, docling_pages=pages, docling_version=docling_doc.version, ) ] ) # Pre-migration items: heading_level / tree_depth default to 0. # Match what v0.40.0 produced (label, text, page_numbers, no levels). items = list(docling_doc.iterate_items()) await store.document_items_table.add( [ DocumentItemRecord( document_id="doc-1", position=pos, self_ref=item.self_ref, label=str(getattr(item.label, "value", item.label) or ""), text=getattr(item, "text", "") or "", page_numbers="[]", ) for pos, (item, _depth) in enumerate(items) ] ) async with Store(temp_db_path, skip_migration_check=True) as store: # Apply the migration in isolation: store.migrate() would run the # whole chain (incl. v0.50.0/v0.58.0 which touch documents.metadata, # absent from this docling-only fixture). await _apply_backfill_heading_hierarchy(store) rows = await ( store.document_items_table.query() .where("document_id = 'doc-1'") .to_list() ) rows.sort(key=lambda r: r["position"]) headers = [r for r in rows if r["label"] == "section_header"] assert [r["heading_level"] for r in headers] == [1, 2, 1] non_headers = [r for r in rows if r["label"] != "section_header"] assert non_headers assert all(r["heading_level"] == 0 for r in non_headers) assert all(r["tree_depth"] > 0 for r in rows) async def test_backfill_idempotent(self, temp_db_path): docling_doc = _docling_with_levels() structure, pages = compress_docling_split(docling_doc.model_dump(mode="json")) async with Store(temp_db_path, create=True, skip_migration_check=True) as store: await store.set_haiku_version("0.45.0") await store.documents_table.add( [ DocumentRecord( id="doc-1", content="legacy", docling_document=structure, docling_pages=pages, docling_version=docling_doc.version, ) ] ) items = list(docling_doc.iterate_items()) await store.document_items_table.add( [ DocumentItemRecord( document_id="doc-1", position=pos, self_ref=item.self_ref, label=str(getattr(item.label, "value", item.label) or ""), text=getattr(item, "text", "") or "", page_numbers="[]", ) for pos, (item, _depth) in enumerate(items) ] ) async with Store(temp_db_path, skip_migration_check=True) as store: # Apply twice to prove idempotency (in isolation from the chain). await _apply_backfill_heading_hierarchy(store) await _apply_backfill_heading_hierarchy(store) rows = await ( store.document_items_table.query() .where("document_id = 'doc-1'") .to_list() ) rows.sort(key=lambda r: r["position"]) headers = [r for r in rows if r["label"] == "section_header"] assert [r["heading_level"] for r in headers] == [1, 2, 1] async def test_skips_documents_without_docling(self, temp_db_path): async with Store(temp_db_path, create=True, skip_migration_check=True) as store: await store.set_haiku_version("0.45.0") await store.documents_table.add( [DocumentRecord(id="plain", content="no docling")] ) await store.document_items_table.add( [ DocumentItemRecord( document_id="plain", position=0, self_ref="#/texts/0", label="paragraph", text="Hi", page_numbers="[]", ) ] ) async with Store(temp_db_path, skip_migration_check=True) as store: await _apply_backfill_heading_hierarchy(store) rows = await ( store.document_items_table.query() .where("document_id = 'plain'") .to_list() ) # Plain doc — no levels to backfill; defaults stay zero. assert rows[0]["heading_level"] == 0 assert rows[0]["tree_depth"] == 0 @pytest.mark.asyncio class TestV0_48_0FreshSchema: """A newly-created DB has the new columns from the start.""" async def test_fresh_db_has_heading_level_and_tree_depth(self, temp_db_path): from haiku.rag.client import HaikuRAG async with HaikuRAG(temp_db_path, create=True) as rag: schema = await rag.store.document_items_table.schema() names = {f.name for f in schema} assert "heading_level" in names assert "tree_depth" in names def test_module_imports(): from haiku.rag.store.upgrades import v0_48_0 # noqa: F401 assert hasattr(v0_48_0, "upgrade_backfill_heading_hierarchy") assert json.dumps # silence unused-import flake on json