persist docling heading hierarchy on document_items
This commit is contained in:
parent
f208730a5b
commit
1360303edf
11 changed files with 466 additions and 9 deletions
|
|
@ -1,6 +1,10 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`heading_level` and `tree_depth` on `DocumentItem`.** `extract_items` now captures docling's `SectionHeaderItem.level` (H1–H6 for headers, `0` elsewhere) and the traversal depth from `iterate_items()` for every item, persisting both in the `document_items` table. Foundations for tree-based document navigation in the analysis sandbox. The 0.48.0 migration adds the columns to existing DBs and backfills them from each doc's docling blob.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Bump `docling>=2.93.0` and `docling-core>=2.75.0`.
|
- Bump `docling>=2.93.0` and `docling-core>=2.75.0`.
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,8 @@ class DocumentItemRecord(LanceModel):
|
||||||
text: str = Field(default="")
|
text: str = Field(default="")
|
||||||
page_numbers: str = Field(default="[]")
|
page_numbers: str = Field(default="[]")
|
||||||
picture_data: bytes | None = None
|
picture_data: bytes | None = None
|
||||||
|
heading_level: int = Field(default=0)
|
||||||
|
tree_depth: int = Field(default=0)
|
||||||
|
|
||||||
|
|
||||||
def get_document_items_arrow_schema() -> pa.Schema:
|
def get_document_items_arrow_schema() -> pa.Schema:
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ class DocumentItem(BaseModel):
|
||||||
text: str = ""
|
text: str = ""
|
||||||
page_numbers: list[int] = []
|
page_numbers: list[int] = []
|
||||||
picture_data: bytes | None = None
|
picture_data: bytes | None = None
|
||||||
|
heading_level: int = 0
|
||||||
|
tree_depth: int = 0
|
||||||
|
|
||||||
|
|
||||||
def _picture_description_text(item: "PictureItem") -> str | None:
|
def _picture_description_text(item: "PictureItem") -> str | None:
|
||||||
|
|
@ -110,12 +112,12 @@ def extract_items(
|
||||||
fall-back lookup against ``existing_picture_data`` (keyed by ``self_ref``)
|
fall-back lookup against ``existing_picture_data`` (keyed by ``self_ref``)
|
||||||
preserves the bytes that were captured at original ingest time.
|
preserves the bytes that were captured at original ingest time.
|
||||||
"""
|
"""
|
||||||
from docling_core.types.doc.document import PictureItem
|
from docling_core.types.doc.document import PictureItem, SectionHeaderItem
|
||||||
|
|
||||||
existing = existing_picture_data or {}
|
existing = existing_picture_data or {}
|
||||||
items: list[DocumentItem] = []
|
items: list[DocumentItem] = []
|
||||||
|
|
||||||
for position, (item, _level) in enumerate(docling_doc.iterate_items()):
|
for position, (item, level) in enumerate(docling_doc.iterate_items()):
|
||||||
label = getattr(item, "label", None)
|
label = getattr(item, "label", None)
|
||||||
label_str = str(label.value) if hasattr(label, "value") else str(label or "")
|
label_str = str(label.value) if hasattr(label, "value") else str(label or "")
|
||||||
|
|
||||||
|
|
@ -134,6 +136,8 @@ def extract_items(
|
||||||
if picture_data is None:
|
if picture_data is None:
|
||||||
picture_data = existing.get(item.self_ref)
|
picture_data = existing.get(item.self_ref)
|
||||||
|
|
||||||
|
heading_level = item.level if isinstance(item, SectionHeaderItem) else 0
|
||||||
|
|
||||||
items.append(
|
items.append(
|
||||||
DocumentItem(
|
DocumentItem(
|
||||||
document_id=document_id,
|
document_id=document_id,
|
||||||
|
|
@ -143,6 +147,8 @@ def extract_items(
|
||||||
text=text,
|
text=text,
|
||||||
page_numbers=sorted(page_numbers),
|
page_numbers=sorted(page_numbers),
|
||||||
picture_data=picture_data,
|
picture_data=picture_data,
|
||||||
|
heading_level=heading_level,
|
||||||
|
tree_depth=level,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ _METADATA_COLUMNS = [
|
||||||
"label",
|
"label",
|
||||||
"text",
|
"text",
|
||||||
"page_numbers",
|
"page_numbers",
|
||||||
|
"heading_level",
|
||||||
|
"tree_depth",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -31,6 +33,8 @@ class DocumentItemRepository:
|
||||||
label=row.get("label", ""),
|
label=row.get("label", ""),
|
||||||
text=row.get("text", ""),
|
text=row.get("text", ""),
|
||||||
page_numbers=json.loads(row.get("page_numbers", "[]")),
|
page_numbers=json.loads(row.get("page_numbers", "[]")),
|
||||||
|
heading_level=row.get("heading_level", 0) or 0,
|
||||||
|
tree_depth=row.get("tree_depth", 0) or 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def create_items(self, document_id: str, items: list[DocumentItem]) -> None:
|
async def create_items(self, document_id: str, items: list[DocumentItem]) -> None:
|
||||||
|
|
@ -48,6 +52,8 @@ class DocumentItemRepository:
|
||||||
text=item.text,
|
text=item.text,
|
||||||
page_numbers=json.dumps(item.page_numbers),
|
page_numbers=json.dumps(item.page_numbers),
|
||||||
picture_data=item.picture_data,
|
picture_data=item.picture_data,
|
||||||
|
heading_level=item.heading_level,
|
||||||
|
tree_depth=item.tree_depth,
|
||||||
)
|
)
|
||||||
for item in items
|
for item in items
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,9 @@ from haiku.rag.store.upgrades.v0_40_0 import (
|
||||||
from haiku.rag.store.upgrades.v0_45_0 import (
|
from haiku.rag.store.upgrades.v0_45_0 import (
|
||||||
upgrade_extract_picture_bytes as upgrade_0_45_0_extract_picture_bytes,
|
upgrade_extract_picture_bytes as upgrade_0_45_0_extract_picture_bytes,
|
||||||
)
|
)
|
||||||
|
from haiku.rag.store.upgrades.v0_48_0 import (
|
||||||
|
upgrade_backfill_heading_hierarchy as upgrade_0_48_0_heading_hierarchy,
|
||||||
|
)
|
||||||
|
|
||||||
upgrades.append(upgrade_0_20_0_docling)
|
upgrades.append(upgrade_0_20_0_docling)
|
||||||
upgrades.append(upgrade_0_23_1_contextualize)
|
upgrades.append(upgrade_0_23_1_contextualize)
|
||||||
|
|
@ -94,3 +97,4 @@ upgrades.append(upgrade_0_25_0_compress)
|
||||||
upgrades.append(upgrade_0_38_0_split_pages)
|
upgrades.append(upgrade_0_38_0_split_pages)
|
||||||
upgrades.append(upgrade_0_40_0_document_items)
|
upgrades.append(upgrade_0_40_0_document_items)
|
||||||
upgrades.append(upgrade_0_45_0_extract_picture_bytes)
|
upgrades.append(upgrade_0_45_0_extract_picture_bytes)
|
||||||
|
upgrades.append(upgrade_0_48_0_heading_hierarchy)
|
||||||
|
|
|
||||||
151
haiku_rag_slim/haiku/rag/store/upgrades/v0_48_0.py
Normal file
151
haiku_rag_slim/haiku/rag/store/upgrades/v0_48_0.py
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pyarrow as pa
|
||||||
|
|
||||||
|
from haiku.rag.store.engine import DocumentItemRecord, Store
|
||||||
|
from haiku.rag.store.upgrades import Upgrade
|
||||||
|
from haiku.rag.utils import escape_sql_string
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PROGRESS_INTERVAL = 10
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_columns(store: Store) -> None:
|
||||||
|
"""Add heading_level + tree_depth (int64) columns if missing. Idempotent."""
|
||||||
|
arrow_schema = await store.document_items_table.schema()
|
||||||
|
existing = {f.name for f in arrow_schema}
|
||||||
|
new_fields = []
|
||||||
|
if "heading_level" not in existing:
|
||||||
|
new_fields.append(pa.field("heading_level", pa.int64()))
|
||||||
|
if "tree_depth" not in existing:
|
||||||
|
new_fields.append(pa.field("tree_depth", pa.int64()))
|
||||||
|
if new_fields:
|
||||||
|
await store.document_items_table.add_columns(pa.schema(new_fields))
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_backfill_heading_hierarchy(store: Store) -> None:
|
||||||
|
"""Add heading_level + tree_depth columns and backfill from docling structure.
|
||||||
|
|
||||||
|
For each document with a docling_document blob, decompress it, re-run
|
||||||
|
extract_items, and update the existing items rows with the two new ints.
|
||||||
|
Documents without docling (plain-text adds) are skipped; their rows keep
|
||||||
|
the column-add default (NULL, materialised as 0 by the repository).
|
||||||
|
|
||||||
|
Idempotent: re-running on migrated rows yields identical values.
|
||||||
|
"""
|
||||||
|
from docling_core.types.doc.document import DoclingDocument
|
||||||
|
|
||||||
|
from haiku.rag.store.compression import decompress_json
|
||||||
|
from haiku.rag.store.models.document_item import extract_items
|
||||||
|
|
||||||
|
await _ensure_columns(store)
|
||||||
|
|
||||||
|
ids = (await store.documents_table.query().select(["id"]).to_arrow()).to_pylist()
|
||||||
|
ids = [row["id"] for row in ids]
|
||||||
|
total = len(ids)
|
||||||
|
if not total:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Backfilling heading_level + tree_depth across %d documents", total)
|
||||||
|
backfilled = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for idx, doc_id in enumerate(ids, 1):
|
||||||
|
safe_id = escape_sql_string(doc_id)
|
||||||
|
rows = await (
|
||||||
|
store.documents_table.query()
|
||||||
|
.select(["id", "docling_document"])
|
||||||
|
.where(f"id = '{safe_id}'")
|
||||||
|
.limit(1)
|
||||||
|
.to_list()
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
blob = rows[0].get("docling_document")
|
||||||
|
if not blob or not isinstance(blob, bytes):
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
docling_doc = DoclingDocument.model_validate_json(decompress_json(blob))
|
||||||
|
fresh_items = extract_items(doc_id, docling_doc)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to re-extract items for %s; skipping", doc_id)
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not fresh_items:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing_rows = await (
|
||||||
|
store.document_items_table.query()
|
||||||
|
.where(f"document_id = '{safe_id}'")
|
||||||
|
.to_list()
|
||||||
|
)
|
||||||
|
existing_by_ref = {r["self_ref"]: r for r in existing_rows}
|
||||||
|
|
||||||
|
# If the stored rows and a fresh extract disagree on count, the
|
||||||
|
# docling parser version has drifted. Skip and let `rebuild` reconcile.
|
||||||
|
if len(existing_rows) != len(fresh_items):
|
||||||
|
logger.warning(
|
||||||
|
"Item count drift for %s (stored=%d, fresh=%d); skipping",
|
||||||
|
doc_id,
|
||||||
|
len(existing_rows),
|
||||||
|
len(fresh_items),
|
||||||
|
)
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
records: list[DocumentItemRecord] = []
|
||||||
|
for item in fresh_items:
|
||||||
|
row = existing_by_ref.get(item.self_ref)
|
||||||
|
if row is None:
|
||||||
|
continue
|
||||||
|
records.append(
|
||||||
|
DocumentItemRecord(
|
||||||
|
document_id=doc_id,
|
||||||
|
position=row["position"],
|
||||||
|
self_ref=item.self_ref,
|
||||||
|
label=row.get("label", ""),
|
||||||
|
text=row.get("text", ""),
|
||||||
|
page_numbers=row.get("page_numbers", "[]"),
|
||||||
|
picture_data=row.get("picture_data"),
|
||||||
|
heading_level=item.heading_level,
|
||||||
|
tree_depth=item.tree_depth,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if records:
|
||||||
|
await (
|
||||||
|
store.document_items_table.merge_insert(["document_id", "self_ref"])
|
||||||
|
.when_matched_update_all()
|
||||||
|
.when_not_matched_insert_all()
|
||||||
|
.execute(records)
|
||||||
|
)
|
||||||
|
backfilled += 1
|
||||||
|
|
||||||
|
if idx % PROGRESS_INTERVAL == 0 or idx == total:
|
||||||
|
logger.info(
|
||||||
|
"Progress: %d/%d (%d backfilled, %d skipped)",
|
||||||
|
idx,
|
||||||
|
total,
|
||||||
|
backfilled,
|
||||||
|
skipped,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Backfill complete: %d backfilled, %d skipped of %d",
|
||||||
|
backfilled,
|
||||||
|
skipped,
|
||||||
|
total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
upgrade_backfill_heading_hierarchy = Upgrade(
|
||||||
|
version="0.48.0",
|
||||||
|
apply=_apply_backfill_heading_hierarchy,
|
||||||
|
description="Backfill heading_level + tree_depth on document_items",
|
||||||
|
)
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
name = "haiku.rag-slim"
|
name = "haiku.rag-slim"
|
||||||
description = "Opinionated agentic RAG powered by LanceDB, Pydantic AI, and Docling - Minimal dependencies"
|
description = "Opinionated agentic RAG powered by LanceDB, Pydantic AI, and Docling - Minimal dependencies"
|
||||||
version = "0.47.0"
|
version = "0.48.0"
|
||||||
authors = [{ name = "Yiorgis Gozadinos", email = "ggozadinos@gmail.com" }]
|
authors = [{ name = "Yiorgis Gozadinos", email = "ggozadinos@gmail.com" }]
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
readme = { file = "README.md", content-type = "text/markdown" }
|
readme = { file = "README.md", content-type = "text/markdown" }
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
name = "haiku.rag"
|
name = "haiku.rag"
|
||||||
description = "Opinionated agentic RAG powered by LanceDB, Pydantic AI, and Docling"
|
description = "Opinionated agentic RAG powered by LanceDB, Pydantic AI, and Docling"
|
||||||
version = "0.47.0"
|
version = "0.48.0"
|
||||||
authors = [{ name = "Yiorgis Gozadinos", email = "ggozadinos@gmail.com" }]
|
authors = [{ name = "Yiorgis Gozadinos", email = "ggozadinos@gmail.com" }]
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
readme = { file = "README.md", content-type = "text/markdown" }
|
readme = { file = "README.md", content-type = "text/markdown" }
|
||||||
|
|
@ -30,7 +30,7 @@ classifiers = [
|
||||||
]
|
]
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"haiku.rag-slim[docling,voyageai,mxbai,cohere,zeroentropy,tui,cross-encoder]==0.47.0",
|
"haiku.rag-slim[docling,voyageai,mxbai,cohere,zeroentropy,tui,cross-encoder]==0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|
@ -38,8 +38,8 @@ haiku-rag = "haiku.rag.cli:cli"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
tui = ["textual>=8.2.4"]
|
tui = ["textual>=8.2.4"]
|
||||||
s3 = ["haiku.rag-slim[s3]==0.47.0"]
|
s3 = ["haiku.rag-slim[s3]==0.48.0"]
|
||||||
cross-encoder = ["haiku.rag-slim[cross-encoder]==0.47.0"]
|
cross-encoder = ["haiku.rag-slim[cross-encoder]==0.48.0"]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,23 @@ def _make_docling_doc():
|
||||||
return doc
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
def _make_docling_doc_with_levels():
|
||||||
|
"""DoclingDocument with explicit multi-level headings for hierarchy tests."""
|
||||||
|
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 paragraph.")
|
||||||
|
doc.add_heading(text="Background", level=2)
|
||||||
|
doc.add_text(label=DocItemLabel.PARAGRAPH, text="Background paragraph.")
|
||||||
|
doc.add_heading(text="Prior Work", level=3)
|
||||||
|
doc.add_text(label=DocItemLabel.PARAGRAPH, text="Prior-work paragraph.")
|
||||||
|
doc.add_heading(text="Methods", level=1)
|
||||||
|
doc.add_text(label=DocItemLabel.PARAGRAPH, text="Methods paragraph.")
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
class TestExtractItems:
|
class TestExtractItems:
|
||||||
def test_extracts_all_items(self):
|
def test_extracts_all_items(self):
|
||||||
doc = _make_docling_doc()
|
doc = _make_docling_doc()
|
||||||
|
|
@ -71,6 +88,39 @@ class TestExtractItems:
|
||||||
assert isinstance(table_item.text, str)
|
assert isinstance(table_item.text, str)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractItemsHierarchy:
|
||||||
|
"""heading_level and tree_depth populated from docling structure."""
|
||||||
|
|
||||||
|
def test_heading_level_on_section_headers(self):
|
||||||
|
doc = _make_docling_doc_with_levels()
|
||||||
|
items = extract_items("doc-1", doc)
|
||||||
|
headers = [i for i in items if i.label == "section_header"]
|
||||||
|
assert [h.heading_level for h in headers] == [1, 2, 3, 1]
|
||||||
|
|
||||||
|
def test_heading_level_zero_on_non_headers(self):
|
||||||
|
doc = _make_docling_doc_with_levels()
|
||||||
|
items = extract_items("doc-1", doc)
|
||||||
|
non_headers = [i for i in items if i.label != "section_header"]
|
||||||
|
assert non_headers
|
||||||
|
assert all(i.heading_level == 0 for i in non_headers)
|
||||||
|
|
||||||
|
def test_tree_depth_set_for_all_items(self):
|
||||||
|
doc = _make_docling_doc_with_levels()
|
||||||
|
items = extract_items("doc-1", doc)
|
||||||
|
assert all(i.tree_depth > 0 for i in items)
|
||||||
|
|
||||||
|
def test_plain_doc_has_zero_heading_level(self):
|
||||||
|
from docling_core.types.doc.document import DoclingDocument
|
||||||
|
from docling_core.types.doc.labels import DocItemLabel
|
||||||
|
|
||||||
|
doc = DoclingDocument(name="plain")
|
||||||
|
doc.add_text(label=DocItemLabel.PARAGRAPH, text="One.")
|
||||||
|
doc.add_text(label=DocItemLabel.PARAGRAPH, text="Two.")
|
||||||
|
items = extract_items("doc-1", doc)
|
||||||
|
assert items
|
||||||
|
assert all(i.heading_level == 0 for i in items)
|
||||||
|
|
||||||
|
|
||||||
class TestExtractItemText:
|
class TestExtractItemText:
|
||||||
def test_text_item(self):
|
def test_text_item(self):
|
||||||
from docling_core.types.doc.document import DoclingDocument
|
from docling_core.types.doc.document import DoclingDocument
|
||||||
|
|
@ -177,6 +227,63 @@ class TestDocumentItemRepository:
|
||||||
assert await repo.get_item_count("doc-1") == 0
|
assert await repo.get_item_count("doc-1") == 0
|
||||||
assert await repo.get_item_count("doc-2") == 5
|
assert await repo.get_item_count("doc-2") == 5
|
||||||
|
|
||||||
|
async def test_round_trip_preserves_heading_level_and_tree_depth(
|
||||||
|
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=0,
|
||||||
|
self_ref="#/texts/0",
|
||||||
|
label="section_header",
|
||||||
|
text="Intro",
|
||||||
|
heading_level=1,
|
||||||
|
tree_depth=1,
|
||||||
|
),
|
||||||
|
DocumentItem(
|
||||||
|
document_id="doc-1",
|
||||||
|
position=1,
|
||||||
|
self_ref="#/texts/1",
|
||||||
|
label="section_header",
|
||||||
|
text="Background",
|
||||||
|
heading_level=2,
|
||||||
|
tree_depth=2,
|
||||||
|
),
|
||||||
|
DocumentItem(
|
||||||
|
document_id="doc-1",
|
||||||
|
position=2,
|
||||||
|
self_ref="#/texts/2",
|
||||||
|
label="paragraph",
|
||||||
|
text="A paragraph.",
|
||||||
|
heading_level=0,
|
||||||
|
tree_depth=2,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
await repo.create_items("doc-1", items)
|
||||||
|
|
||||||
|
got = await repo.get_all_items("doc-1")
|
||||||
|
assert [(i.heading_level, i.tree_depth) for i in got] == [
|
||||||
|
(1, 1),
|
||||||
|
(2, 2),
|
||||||
|
(0, 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
in_range = await repo.get_items_in_range("doc-1", 0, 2)
|
||||||
|
assert [(i.heading_level, i.tree_depth) for i in in_range] == [
|
||||||
|
(1, 1),
|
||||||
|
(2, 2),
|
||||||
|
(0, 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
grouped = await repo.get_all_items_grouped(["doc-1"])
|
||||||
|
assert [(i.heading_level, i.tree_depth) for i in grouped["doc-1"]] == [
|
||||||
|
(1, 1),
|
||||||
|
(2, 2),
|
||||||
|
(0, 2),
|
||||||
|
]
|
||||||
|
|
||||||
async def test_empty_refs_returns_empty(self, temp_db_path):
|
async def test_empty_refs_returns_empty(self, temp_db_path):
|
||||||
async with HaikuRAG(temp_db_path, create=True) as rag:
|
async with HaikuRAG(temp_db_path, create=True) as rag:
|
||||||
repo = DocumentItemRepository(rag.store)
|
repo = DocumentItemRepository(rag.store)
|
||||||
|
|
|
||||||
177
tests/store/test_v0_48_0_migration.py
Normal file
177
tests/store/test_v0_48_0_migration.py
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from haiku.rag.store.compression import compress_docling_split
|
||||||
|
from haiku.rag.store.engine import DocumentItemRecord, DocumentRecord, Store
|
||||||
|
|
||||||
|
|
||||||
|
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_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:
|
||||||
|
applied = await store.migrate()
|
||||||
|
assert any("0.48.0" in d for d in applied)
|
||||||
|
|
||||||
|
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_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:
|
||||||
|
await store.migrate()
|
||||||
|
|
||||||
|
from haiku.rag.store.upgrades.v0_48_0 import (
|
||||||
|
_apply_backfill_heading_hierarchy,
|
||||||
|
)
|
||||||
|
|
||||||
|
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 store.migrate()
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# Sanity: ensure module imports without side effects.
|
||||||
|
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
|
||||||
4
uv.lock
4
uv.lock
|
|
@ -1467,7 +1467,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "haiku-rag"
|
name = "haiku-rag"
|
||||||
version = "0.47.0"
|
version = "0.48.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "haiku-rag-slim", extra = ["cohere", "cross-encoder", "docling", "mxbai", "tui", "voyageai", "zeroentropy"] },
|
{ name = "haiku-rag-slim", extra = ["cohere", "cross-encoder", "docling", "mxbai", "tui", "voyageai", "zeroentropy"] },
|
||||||
|
|
@ -1559,7 +1559,7 @@ requires-dist = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "haiku-rag-slim"
|
name = "haiku-rag-slim"
|
||||||
version = "0.47.0"
|
version = "0.48.0"
|
||||||
source = { editable = "haiku_rag_slim" }
|
source = { editable = "haiku_rag_slim" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "docling-core" },
|
{ name = "docling-core" },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue