haiku.rag/tests/sandbox/test_sandbox_toc.py
Yiorgis Gozadinos 94befc0dfd
Recover from worker death and deny metadata writes
A crashed worker used to poison the rest of the run. execute() reported the
crash as a failed result, but kept the dead session, and every later call then
raised RuntimeError out of the tool. Clear the session so the next call checks
out a replacement. Keep the session for a syntax or runtime error, which leaves
the worker healthy, and say in the failure text that a restart loses the
variables.

A MemoryFile accepts writes, so metadata.json took them while the other three
document files refused. Mount it through the same read and deny pair. The
write-denial test now covers all four files.
2026-07-28 19:11:19 +03:00

452 lines
17 KiB
Python

"""Tests for the per-document toc.json view and the heading_level / tree_depth
fields surfaced in items.jsonl.
The TOC is derived from `DocumentItem.heading_level` (positive only) in
position order. PDF-style corpora (all section_headers at level 1) get a flat
list of siblings; HTML/markdown corpora with real heading hierarchy get a
nested tree. Items with no section_header at all produce `tree: []`.
"""
import asyncio
import json
from pathlib import PurePosixPath
import pytest
from haiku.rag.client import HaikuRAG
from haiku.rag.config.models import AppConfig
from haiku.rag.sandbox import AnalysisContext, Sandbox
from haiku.rag.store.models.document import Document
from haiku.rag.store.models.document_item import DocumentItem
async def _empty_doc(client, *, uri: str, title: str) -> str:
"""Create a Document row directly via the repository (no chunking, no
embedder) so these tests can run without a reachable embedding endpoint.
Returns the document id."""
doc = await client.document_repository.create(
Document(content="x", uri=uri, title=title)
)
return doc.id
def _para(doc_id: str, pos: int, depth: int = 1) -> DocumentItem:
return DocumentItem(
document_id=doc_id,
position=pos,
self_ref=f"#/texts/{pos}",
label="paragraph",
text=f"para{pos}",
page_numbers=[1],
tree_depth=depth,
)
def _header(
doc_id: str, pos: int, level: int, text: str, depth: int = 1, page: int = 1
) -> DocumentItem:
return DocumentItem(
document_id=doc_id,
position=pos,
self_ref=f"#/texts/{pos}",
label="section_header",
text=text,
page_numbers=[page],
heading_level=level,
tree_depth=depth,
)
async def _read_vfs_text(sandbox: Sandbox, path: str) -> str:
"""Read a VFS file the way execute() does: the synchronous reader runs on a
worker thread and bridges DB access back to the (free) calling loop."""
vfs = await sandbox._build_vfs()
sandbox._loop = asyncio.get_running_loop()
return await asyncio.to_thread(vfs.path_read_text, PurePosixPath(path))
async def _read_toc(sandbox: Sandbox, doc_id: str) -> dict:
raw = await _read_vfs_text(sandbox, f"/documents/{doc_id}/toc.json")
return json.loads(raw)
async def _read_items_jsonl(sandbox: Sandbox, doc_id: str) -> list[dict]:
raw = await _read_vfs_text(sandbox, f"/documents/{doc_id}/items.jsonl")
return [json.loads(line) for line in raw.strip().splitlines()] if raw else []
def _flatten(tree: list[dict]) -> list[dict]:
out = []
for node in tree:
out.append(node)
out.extend(_flatten(node["children"]))
return out
@pytest.mark.asyncio
class TestMetadataJson:
"""metadata.json is served by a reader callback, like the other VFS files."""
async def test_metadata_reader_returns_document_fields(self, temp_db_path):
async with HaikuRAG(temp_db_path, create=True) as client:
doc_id = await _empty_doc(client, uri="test://meta", title="Meta Doc")
sandbox = Sandbox(temp_db_path, AppConfig(), AnalysisContext())
raw = await _read_vfs_text(sandbox, f"/documents/{doc_id}/metadata.json")
meta = json.loads(raw)
assert meta["id"] == doc_id
assert meta["title"] == "Meta Doc"
assert meta["uri"] == "test://meta"
@pytest.mark.asyncio
class TestTocShape:
"""toc.json builds a section tree from heading_level + position."""
async def test_multilevel_tree(self, temp_db_path):
async with HaikuRAG(temp_db_path, create=True) as client:
doc_id = await _empty_doc(client, uri="test://multi", title="TOC Test Doc")
items = [
_header(doc_id, 0, 1, "Intro"),
_para(doc_id, 1),
_header(doc_id, 2, 2, "Background"),
_para(doc_id, 3),
_header(doc_id, 4, 3, "Prior Work"),
_para(doc_id, 5),
_header(doc_id, 6, 2, "Approach"),
_para(doc_id, 7),
_header(doc_id, 8, 1, "Methods"),
_para(doc_id, 9),
]
await client.document_item_repository.create_items(doc_id, items)
sandbox = Sandbox(temp_db_path, AppConfig(), AnalysisContext())
toc = await _read_toc(sandbox, doc_id)
assert toc["doc_id"] == doc_id
assert toc["title"] == "TOC Test Doc"
tree = toc["tree"]
# Two roots: Intro (children: Background>{Prior Work}, Approach) and Methods
assert [n["title"] for n in tree] == ["Intro", "Methods"]
intro = tree[0]
assert intro["level"] == 1
assert intro["item_range"] == [0, 8] # ends at "Methods" position
assert [c["title"] for c in intro["children"]] == ["Background", "Approach"]
background = intro["children"][0]
assert background["level"] == 2
# Background covers positions 2..5; "Approach" begins at 6 (same-level sibling)
assert background["item_range"] == [2, 6]
assert [c["title"] for c in background["children"]] == ["Prior Work"]
prior = background["children"][0]
assert prior["level"] == 3
# Prior Work has no descendants and the next same-or-shallower header is
# "Approach" at level 2, position 6.
assert prior["item_range"] == [4, 6]
assert prior["children"] == []
approach = intro["children"][1]
assert approach["item_range"] == [6, 8]
methods = tree[1]
assert methods["item_range"] == [8, 10] # to end of items
assert methods["children"] == []
async def test_flat_pdf_style(self, temp_db_path):
"""All section_headers at level 1 (PDF reality) -> flat sibling list."""
async with HaikuRAG(temp_db_path, create=True) as client:
doc_id = await _empty_doc(client, uri="test://pdf-style", title="Flat PDF")
items = [
_header(doc_id, 0, 1, "Chapter 1"),
_para(doc_id, 1),
_para(doc_id, 2),
_header(doc_id, 3, 1, "Chapter 2"),
_para(doc_id, 4),
_header(doc_id, 5, 1, "Chapter 3"),
_para(doc_id, 6),
]
await client.document_item_repository.create_items(doc_id, items)
sandbox = Sandbox(temp_db_path, AppConfig(), AnalysisContext())
toc = await _read_toc(sandbox, doc_id)
tree = toc["tree"]
assert [n["title"] for n in tree] == ["Chapter 1", "Chapter 2", "Chapter 3"]
assert all(n["level"] == 1 and n["children"] == [] for n in tree)
assert tree[0]["item_range"] == [0, 3]
assert tree[1]["item_range"] == [3, 5]
assert tree[2]["item_range"] == [5, 7]
async def test_no_headers(self, temp_db_path):
async with HaikuRAG(temp_db_path, create=True) as client:
doc_id = await _empty_doc(
client, uri="test://no-headers", title="No Headers"
)
await client.document_item_repository.create_items(
doc_id, [_para(doc_id, i) for i in range(5)]
)
sandbox = Sandbox(temp_db_path, AppConfig(), AnalysisContext())
toc = await _read_toc(sandbox, doc_id)
assert toc["tree"] == []
async def test_skip_header_with_zero_level(self, temp_db_path):
"""A section_header with ``heading_level == 0`` is skipped."""
async with HaikuRAG(temp_db_path, create=True) as client:
doc_id = await _empty_doc(client, uri="test://zero", title="Zero Level")
items = [
_header(doc_id, 0, 1, "Real H1"),
_para(doc_id, 1),
_header(doc_id, 2, 0, "Pre-migration ghost"),
_para(doc_id, 3),
]
await client.document_item_repository.create_items(doc_id, items)
sandbox = Sandbox(temp_db_path, AppConfig(), AnalysisContext())
toc = await _read_toc(sandbox, doc_id)
titles = [n["title"] for n in _flatten(toc["tree"])]
assert titles == ["Real H1"]
assert toc["tree"][0]["item_range"] == [0, 4]
async def test_node_shape_has_chunk_ids_not_position(self, temp_db_path):
async with HaikuRAG(temp_db_path, create=True) as client:
doc_id = await _empty_doc(client, uri="test://shape", title="Shape")
await client.document_item_repository.create_items(
doc_id, [_header(doc_id, 0, 1, "Only"), _para(doc_id, 1)]
)
sandbox = Sandbox(temp_db_path, AppConfig(), AnalysisContext())
toc = await _read_toc(sandbox, doc_id)
node = toc["tree"][0]
expected = {
"self_ref",
"level",
"title",
"page_numbers",
"item_range",
"chunk_ids",
"children",
}
assert expected <= set(node)
assert "position" not in node
assert node["chunk_ids"] == []
@pytest.mark.asyncio
class TestTocChunkIdsAggregation:
"""toc.json nodes carry the union of chunk_ids covered by their item_range."""
async def test_chunk_ids_union_over_item_range(self, temp_db_path, monkeypatch):
async with HaikuRAG(temp_db_path, create=True) as client:
doc_id = await _empty_doc(client, uri="test://chunks", title="Chunks")
await client.document_item_repository.create_items(
doc_id,
[
_header(doc_id, 0, 1, "Intro"),
_para(doc_id, 1),
_para(doc_id, 2),
_header(doc_id, 3, 2, "Background"),
_para(doc_id, 4),
_header(doc_id, 5, 1, "Methods"),
_para(doc_id, 6),
],
)
from haiku.rag.store.repositories.chunk import ChunkRepository
# self_ref → list[chunk_id]. Intro covers #/texts/0..2, Background
# covers #/texts/3..4, Methods covers #/texts/5..6. Item at #/texts/2
# belongs to two chunks (cA + cB) — verifying dedup-preserving-order.
fake_index = {
"#/texts/1": ["cA"],
"#/texts/2": ["cA", "cB"],
"#/texts/3": ["cB"],
"#/texts/4": ["cC"],
"#/texts/5": ["cD"],
"#/texts/6": ["cD"],
}
async def fake_grouped(self, document_ids):
return {doc_id: fake_index}
monkeypatch.setattr(
ChunkRepository,
"get_chunk_ids_by_self_ref_grouped",
fake_grouped,
)
sandbox = Sandbox(temp_db_path, AppConfig(), AnalysisContext())
toc = await _read_toc(sandbox, doc_id)
intro = toc["tree"][0]
assert intro["title"] == "Intro"
# Intro spans positions 0..2 (header) plus its child Background's
# range — item_range is [0, 5].
assert intro["item_range"] == [0, 5]
assert intro["chunk_ids"] == ["cA", "cB", "cC"]
background = intro["children"][0]
assert background["title"] == "Background"
assert background["item_range"] == [3, 5]
assert background["chunk_ids"] == ["cB", "cC"]
methods = toc["tree"][1]
assert methods["title"] == "Methods"
assert methods["item_range"] == [5, 7]
assert methods["chunk_ids"] == ["cD"]
@pytest.mark.asyncio
class TestTocCaching:
"""items + toc reads for a doc share one items fetch and one chunk-index fetch."""
async def test_items_and_chunk_index_fetched_once_per_doc(
self, temp_db_path, monkeypatch
):
async with HaikuRAG(temp_db_path, create=True) as client:
doc_id = await _empty_doc(client, uri="test://cache", title="Cache")
await client.document_item_repository.create_items(
doc_id,
[_header(doc_id, 0, 1, "Only"), _para(doc_id, 1), _para(doc_id, 2)],
)
sandbox = Sandbox(temp_db_path, AppConfig(), AnalysisContext())
from haiku.rag.store.repositories.chunk import ChunkRepository
from haiku.rag.store.repositories.document_item import DocumentItemRepository
items_calls = {"n": 0}
chunk_calls = {"n": 0}
original_items = DocumentItemRepository.get_all_items
original_chunks = ChunkRepository.get_chunk_ids_by_self_ref_grouped
async def counting_items(self, document_id):
items_calls["n"] += 1
return await original_items(self, document_id)
async def counting_chunks(self, document_ids):
chunk_calls["n"] += 1
return await original_chunks(self, document_ids)
monkeypatch.setattr(DocumentItemRepository, "get_all_items", counting_items)
monkeypatch.setattr(
ChunkRepository, "get_chunk_ids_by_self_ref_grouped", counting_chunks
)
# Items + toc share `_doc_items` and `_doc_chunk_index`. Four reads →
# one items fetch + one chunk-index fetch.
_ = await _read_toc(sandbox, doc_id)
_ = await _read_items_jsonl(sandbox, doc_id)
_ = await _read_toc(sandbox, doc_id)
_ = await _read_items_jsonl(sandbox, doc_id)
assert items_calls["n"] == 1
assert chunk_calls["n"] == 1
@pytest.mark.asyncio
class TestItemsJsonlSurfacesNewFields:
"""items.jsonl row shape: heading_level is always present (0 on non-headers);
chunk_ids surfaces each item's containing chunks; position and tree_depth
are not exposed."""
async def test_jsonl_row_shape(self, temp_db_path):
async with HaikuRAG(temp_db_path, create=True) as client:
doc_id = await _empty_doc(client, uri="test://jsonl-fields", title="Fields")
items = [
_header(doc_id, 0, 1, "H1", depth=2),
_para(doc_id, 1, depth=3),
_header(doc_id, 2, 2, "H2", depth=4),
]
await client.document_item_repository.create_items(doc_id, items)
sandbox = Sandbox(temp_db_path, AppConfig(), AnalysisContext())
rows = await _read_items_jsonl(sandbox, doc_id)
assert len(rows) == 3
assert rows[0]["heading_level"] == 1
assert rows[1]["heading_level"] == 0
assert rows[2]["heading_level"] == 2
for r in rows:
expected = {
"self_ref",
"label",
"text",
"page_numbers",
"heading_level",
"chunk_ids",
}
assert expected <= set(r)
assert "position" not in r
assert "tree_depth" not in r
@pytest.mark.asyncio
class TestVfsReadPaths:
"""The synchronous VFS readers bridge back to the event loop; drive them
through a worker thread the way execute() does."""
async def test_content_txt_is_read_lazily_per_document(self, temp_db_path):
async with HaikuRAG(temp_db_path, create=True) as client:
doc = await client.document_repository.create(
Document(content="the stored body", uri="test://body", title="Body")
)
doc_id = doc.id
sandbox = Sandbox(temp_db_path, AppConfig(), AnalysisContext())
# Build the VFS first, then change the stored content. A lazy
# CallbackFile reads through at access time and sees the new body;
# an eager MemoryFile mount would have captured the old one.
vfs = await sandbox._build_vfs()
sandbox._loop = asyncio.get_running_loop()
# Rewrite via the repository rather than client.update_document: the
# latter re-chunks and re-embeds, which this file deliberately avoids
# so these tests need no embedding endpoint.
async with HaikuRAG(temp_db_path, create=False) as client:
doc.content = "the rewritten body"
await client.document_repository.update(doc)
content = await asyncio.to_thread(
vfs.path_read_text, PurePosixPath(f"/documents/{doc_id}/content.txt")
)
assert content == "the rewritten body"
async def test_document_files_are_read_only(self, temp_db_path):
async with HaikuRAG(temp_db_path, create=True) as client:
doc = await client.document_repository.create(
Document(content="x", uri="test://ro", title="RO")
)
doc_id = doc.id
sandbox = Sandbox(temp_db_path, AppConfig(), AnalysisContext())
vfs = await sandbox._build_vfs()
sandbox._loop = asyncio.get_running_loop()
with pytest.raises(PermissionError, match="read-only"):
await asyncio.to_thread(
vfs.path_write_text,
PurePosixPath(f"/documents/{doc_id}/content.txt"),
"nope",
)
async def test_toc_skips_gaps_in_item_positions(self, temp_db_path):
"""Positions need not be contiguous — a heading's span may cover
positions that carry no item."""
async with HaikuRAG(temp_db_path, create=True) as client:
doc_id = await _empty_doc(client, uri="test://gaps", title="Gaps")
# Positions 1 and 2 are absent between the header and the paragraph.
items = [
_header(doc_id, 0, 1, "Intro"),
_para(doc_id, 3),
]
await client.document_item_repository.create_items(doc_id, items)
sandbox = Sandbox(temp_db_path, AppConfig(), AnalysisContext())
toc = await _read_toc(sandbox, doc_id)
assert [n["title"] for n in toc["tree"]] == ["Intro"]