toc.json nodes carry chunk_ids; fix cite to accept DB-resolvable chunk_ids

This commit is contained in:
Yiorgis Gozadinos 2026-05-20 11:37:35 +03:00
parent 04eeec77d2
commit 3858ab905a
No known key found for this signature in database
5 changed files with 243 additions and 51 deletions

View file

@ -2,6 +2,7 @@ import asyncio
import atexit import atexit
import concurrent.futures import concurrent.futures
import json import json
import os
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@ -37,7 +38,10 @@ def _run_async(coro: Any) -> Any:
return _executor.submit(asyncio.run, coro).result() return _executor.submit(asyncio.run, coro).result()
def _build_toc(items: list["DocumentItem"]) -> list[dict[str, Any]]: def _build_toc(
items: list["DocumentItem"],
chunk_index: dict[str, list[str]],
) -> list[dict[str, Any]]:
"""Build a nested section tree from items in position order. """Build a nested section tree from items in position order.
Each ``section_header`` with ``heading_level > 0`` becomes a node. Nesting Each ``section_header`` with ``heading_level > 0`` becomes a node. Nesting
@ -49,6 +53,10 @@ def _build_toc(items: list["DocumentItem"]) -> list[dict[str, Any]]:
the next sibling or ancestor that ends this section), or the total item the next sibling or ancestor that ends this section), or the total item
count if no such header exists. count if no such header exists.
``chunk_ids`` aggregates the chunks covered by all items in the section's
``item_range`` (deduped, order preserved). Pass directly to ``cite()`` to
ground a section-scoped answer without a corpus-wide ``search()`` call.
Items without a section_header label (or with ``heading_level == 0``) are Items without a section_header label (or with ``heading_level == 0``) are
skipped. When all section_headers carry the same level the output is a skipped. When all section_headers carry the same level the output is a
flat sibling list (see docling-project/docling#2121 for an upstream case flat sibling list (see docling-project/docling#2121 for an upstream case
@ -61,6 +69,7 @@ def _build_toc(items: list["DocumentItem"]) -> list[dict[str, Any]]:
return [] return []
total = max((i.position for i in items), default=-1) + 1 total = max((i.position for i in items), default=-1) + 1
items_by_position: dict[int, DocumentItem] = {i.position: i for i in items}
ends: list[int] = [] ends: list[int] = []
for idx, h in enumerate(headers): for idx, h in enumerate(headers):
@ -74,13 +83,23 @@ def _build_toc(items: list["DocumentItem"]) -> list[dict[str, Any]]:
roots: list[dict[str, Any]] = [] roots: list[dict[str, Any]] = []
stack: list[tuple[int, dict[str, Any]]] = [] stack: list[tuple[int, dict[str, Any]]] = []
for h, end in zip(headers, ends, strict=True): for h, end in zip(headers, ends, strict=True):
seen: set[str] = set()
chunk_ids: list[str] = []
for pos in range(h.position, end):
item = items_by_position.get(pos)
if item is None:
continue
for cid in chunk_index.get(item.self_ref, []):
if cid not in seen:
seen.add(cid)
chunk_ids.append(cid)
node: dict[str, Any] = { node: dict[str, Any] = {
"self_ref": h.self_ref, "self_ref": h.self_ref,
"level": h.heading_level, "level": h.heading_level,
"title": h.text, "title": h.text,
"position": h.position,
"page_numbers": list(h.page_numbers), "page_numbers": list(h.page_numbers),
"item_range": [h.position, end], "item_range": [h.position, end],
"chunk_ids": chunk_ids,
"children": [], "children": [],
} }
while stack and stack[-1][0] >= h.heading_level: while stack and stack[-1][0] >= h.heading_level:
@ -111,6 +130,7 @@ class Sandbox:
_context: AnalysisContext _context: AnalysisContext
_search_results: "list[SearchResult]" _search_results: "list[SearchResult]"
_doc_items: dict[str, list["DocumentItem"]] _doc_items: dict[str, list["DocumentItem"]]
_doc_chunk_index: dict[str, dict[str, list[str]]]
_items_jsonl_cache: dict[str, str] _items_jsonl_cache: dict[str, str]
_toc_json_cache: dict[str, str] _toc_json_cache: dict[str, str]
_repl: MontyRepl | None _repl: MontyRepl | None
@ -127,6 +147,7 @@ class Sandbox:
self._context = context self._context = context
self._search_results = [] self._search_results = []
self._doc_items = {} self._doc_items = {}
self._doc_chunk_index = {}
self._items_jsonl_cache = {} self._items_jsonl_cache = {}
self._toc_json_cache = {} self._toc_json_cache = {}
self._repl = None self._repl = None
@ -233,6 +254,27 @@ class Sandbox:
sandbox._doc_items[did] = items sandbox._doc_items[did] = items
return items return items
def _get_chunk_index(did: str) -> dict[str, list[str]]:
"""Fetch the self_ref → chunk_ids index for one doc, cached."""
cached = sandbox._doc_chunk_index.get(did)
if cached is not None:
return cached
async def _fetch() -> dict[str, list[str]]:
from haiku.rag.client import HaikuRAG
async with HaikuRAG(db_path, config=config, read_only=True) as rag:
index = (
await rag.chunk_repository.get_chunk_ids_by_self_ref_grouped(
[did]
)
)
return index.get(did, {})
chunk_index = _run_async(_fetch())
sandbox._doc_chunk_index[did] = chunk_index
return chunk_index
def _make_items_reader( def _make_items_reader(
did: str, did: str,
) -> Callable[["PurePosixPath"], str]: ) -> Callable[["PurePosixPath"], str]:
@ -241,17 +283,7 @@ class Sandbox:
if cached is not None: if cached is not None:
return cached return cached
items = _get_items(did) items = _get_items(did)
chunk_index = _get_chunk_index(did)
async def _fetch_chunks() -> dict[str, list[str]]:
from haiku.rag.client import HaikuRAG
async with HaikuRAG(db_path, config=config, read_only=True) as rag:
index = await rag.chunk_repository.get_chunk_ids_by_self_ref_grouped(
[did]
)
return index.get(did, {})
doc_chunk_index = _run_async(_fetch_chunks())
jsonl = "\n".join( jsonl = "\n".join(
json.dumps( json.dumps(
{ {
@ -260,7 +292,7 @@ class Sandbox:
"text": item.text, "text": item.text,
"page_numbers": item.page_numbers, "page_numbers": item.page_numbers,
"heading_level": item.heading_level, "heading_level": item.heading_level,
"chunk_ids": doc_chunk_index.get(item.self_ref, []), "chunk_ids": chunk_index.get(item.self_ref, []),
}, },
ensure_ascii=False, ensure_ascii=False,
) )
@ -279,11 +311,12 @@ class Sandbox:
if cached is not None: if cached is not None:
return cached return cached
items = _get_items(did) items = _get_items(did)
chunk_index = _get_chunk_index(did)
toc = json.dumps( toc = json.dumps(
{ {
"doc_id": did, "doc_id": did,
"title": doc_titles.get(did), "title": doc_titles.get(did),
"tree": _build_toc(items), "tree": _build_toc(items, chunk_index),
}, },
ensure_ascii=False, ensure_ascii=False,
) )
@ -340,13 +373,17 @@ class Sandbox:
write=_deny_write, write=_deny_write,
) )
) )
files.append( # HAIKU_RAG_DISABLE_TOC is an evaluation-time toggle for measuring
CallbackFile( # whether toc.json's outline view earns its place in the VFS.
f"{doc_dir}/toc.json", # Production callers should leave it unset.
read=_make_toc_reader(doc_id), if not os.environ.get("HAIKU_RAG_DISABLE_TOC"):
write=_deny_write, files.append(
CallbackFile(
f"{doc_dir}/toc.json",
read=_make_toc_reader(doc_id),
write=_deny_write,
)
) )
)
return OSAccess(files) return OSAccess(files)

View file

@ -295,42 +295,64 @@ def create_skill_tools(
async def cite(ctx: RunContext[RAGRunDeps], chunk_ids: list[str]) -> str: async def cite(ctx: RunContext[RAGRunDeps], chunk_ids: list[str]) -> str:
"""Register chunk IDs as citations for your answer. """Register chunk IDs as citations for your answer.
Call this after searching, with the chunk_id values from search Accepts chunk_ids from search results AND from direct file reads
results that support your answer. (items.jsonl, toc.json). Verbatim copies only chunk_ids that
don't exist in the database trigger a retry.
Args: Args:
chunk_ids: List of chunk_id values from search results. chunk_ids: List of chunk_id values from search results or VFS reads.
""" """
from haiku.rag.agents.research.models import resolve_citations from haiku.rag.agents.research.models import resolve_citations
from haiku.rag.store.models.chunk import SearchResult
state = _get_state(ctx, state_type) state = _get_state(ctx, state_type)
if not state: if not state:
return "No state available." return "No state available."
all_results = [] if not chunk_ids:
return "Registered 0 citations (empty chunk_ids)."
all_results: list[SearchResult] = []
for results_list in state.searches.values(): for results_list in state.searches.values():
all_results.extend(results_list) all_results.extend(results_list)
citations = resolve_citations(chunk_ids, all_results) citations = resolve_citations(chunk_ids, all_results)
resolved_ids = {c.chunk_id for c in citations}
missing = [
cid.strip("[]")
for cid in chunk_ids
if cid.strip("[]") not in resolved_ids
]
rag = ctx.deps.rag if ctx.deps else None
if missing and rag is not None:
synthetic: list[SearchResult] = []
doc_cache: dict[str, Any] = {}
for cid in missing:
chunk = await rag.get_chunk_by_id(cid)
if chunk is None or not chunk.document_id:
continue
did = chunk.document_id
if did in doc_cache:
doc = doc_cache[did]
else:
doc = await rag.get_document_by_id(did)
doc_cache[did] = doc
chunk.document_uri = doc.uri if doc else None
chunk.document_title = doc.title if doc else None
synthetic.append(SearchResult.from_chunk(chunk, score=1.0))
if synthetic:
citations.extend(resolve_citations(missing, synthetic))
if citations: if citations:
_register_citations(state, citations) _register_citations(state, citations)
return f"Registered {len(citations)} citation(s)." return f"Registered {len(citations)} citation(s)."
if not chunk_ids:
return "Registered 0 citations (empty chunk_ids)."
if not any(r.chunk_id for r in all_results):
raise ModelRetry(
f"None of the supplied chunk_ids {list(chunk_ids)} can be "
"resolved: no search results have been recorded in this "
"session yet. Call `search` first, then cite chunk_ids "
"from its response."
)
raise ModelRetry( raise ModelRetry(
f"None of the supplied chunk_ids {list(chunk_ids)} match a " f"None of the supplied chunk_ids {list(chunk_ids)} could be "
"chunk_id from search results. Copy chunk_ids verbatim from " "resolved. Copy chunk_ids verbatim from `search` results or "
"the search response — never reconstruct, abbreviate, or " "from the `chunk_ids` field on items.jsonl / toc.json rows — "
"paraphrase them." "never reconstruct, abbreviate, or paraphrase them."
) )
tools["cite"] = cite tools["cite"] = cite

View file

@ -92,7 +92,7 @@ Each row carries:
- `heading_level`: H-level for `section_header` rows; `0` on non-header rows - `heading_level`: H-level for `section_header` rows; `0` on non-header rows
### toc.json ### toc.json
Section tree derived from `heading_level`: `{"doc_id", "title", "tree": [...]}` where each node has `{self_ref, level, title, position, page_numbers, item_range: [start, end_exclusive], children}`. `item_range` is a line slice into `items.jsonl``items[start:end]`. `tree: []` for docs with no headers. Section tree derived from `heading_level`: `{"doc_id", "title", "tree": [...]}` where each node has `{self_ref, level, title, page_numbers, item_range: [start, end_exclusive], chunk_ids, children}`. `item_range` is a line slice into `items.jsonl``items[start:end]`. `chunk_ids` aggregates the citable chunks across all items in the section — pass directly to `cite()` to ground a section-scoped answer without a corpus-wide `search()` call. `tree: []` for docs with no headers.
### Cross-referencing search results with items ### Cross-referencing search results with items
Search results include `doc_item_refs` (e.g. `["#/texts/48", "#/tables/0"]`) that correspond to `self_ref` values in `items.jsonl`. To find which section a hit lives in: locate the item by `self_ref`, take its line index, and walk `toc.json` to find the deepest node whose `item_range` contains that index. Search results include `doc_item_refs` (e.g. `["#/texts/48", "#/tables/0"]`) that correspond to `self_ref` values in `items.jsonl`. To find which section a hit lives in: locate the item by `self_ref`, take its line index, and walk `toc.json` to find the deepest node whose `item_range` contains that index.
@ -102,7 +102,8 @@ Search results include `doc_item_refs` (e.g. `["#/texts/48", "#/tables/0"]`) tha
1. Search first. 1. Search first.
2. If the top results contain the answer, call `cite` with the supporting chunk_ids and write a concise answer. 2. If the top results contain the answer, call `cite` with the supporting chunk_ids and write a concise answer.
3. Reach for `execute_code` when search results are insufficient or when the task requires computation, aggregation, traversal across documents, or section-scoped reading. From inside code you can search again with different terms, or read `items.jsonl` / `toc.json` / `content.txt` directly from the document filesystem. 3. Reach for `execute_code` when search results are insufficient or when the task requires computation, aggregation, traversal across documents, or section-scoped reading. From inside code you can search again with different terms, or read `items.jsonl` / `toc.json` / `content.txt` directly from the document filesystem.
4. Call `cite` with the chunk_ids that ground your answer before writing the final response. 4. For questions about a *known document's* structure ("which section contains X", "list the sections of doc Y", "summarise section Z"), read `/documents/{id}/toc.json` first. Each node carries `item_range` (a slice into `items.jsonl`) and `chunk_ids` (citable). Prefer this over `search()` for in-document navigation — `search()` ranks across the whole corpus and can return chunks from unrelated documents.
5. Call `cite` with the chunk_ids that ground your answer before writing the final response.
You MUST call `cite` with at least one chunk ID before producing your final answer, **unless** you are refusing for lack of information. Answers without citations are considered ungrounded. In a refusal case do **not** call `cite` — there is nothing to cite. You MUST call `cite` with at least one chunk ID before producing your final answer, **unless** you are refusing for lack of information. Answers without citations are considered ungrounded. In a refusal case do **not** call `cite` — there is nothing to cite.

View file

@ -186,12 +186,101 @@ class TestTocShape:
assert titles == ["Real H1"] assert titles == ["Real H1"]
assert toc["tree"][0]["item_range"] == [0, 4] 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 @pytest.mark.asyncio
class TestTocCaching: class TestTocCaching:
"""items + toc reads for a doc share one items fetch; repeat reads hit cache.""" """items + toc reads for a doc share one items fetch and one chunk-index fetch."""
async def test_items_fetched_once_per_doc(self, temp_db_path, monkeypatch): 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: async with HaikuRAG(temp_db_path, create=True) as client:
doc_id = await _empty_doc(client, uri="test://cache", title="Cache") doc_id = await _empty_doc(client, uri="test://cache", title="Cache")
await client.document_item_repository.create_items( await client.document_item_repository.create_items(
@ -201,24 +290,36 @@ class TestTocCaching:
sandbox = Sandbox(temp_db_path, AppConfig(), AnalysisContext()) sandbox = Sandbox(temp_db_path, AppConfig(), AnalysisContext())
from haiku.rag.store.repositories.chunk import ChunkRepository
from haiku.rag.store.repositories.document_item import DocumentItemRepository from haiku.rag.store.repositories.document_item import DocumentItemRepository
call_count = {"n": 0} items_calls = {"n": 0}
original = DocumentItemRepository.get_all_items chunk_calls = {"n": 0}
original_items = DocumentItemRepository.get_all_items
original_chunks = ChunkRepository.get_chunk_ids_by_self_ref_grouped
async def counting(self, document_id): async def counting_items(self, document_id):
call_count["n"] += 1 items_calls["n"] += 1
return await original(self, document_id) return await original_items(self, document_id)
monkeypatch.setattr(DocumentItemRepository, "get_all_items", counting) async def counting_chunks(self, document_ids):
chunk_calls["n"] += 1
return await original_chunks(self, document_ids)
# Items + toc share `_doc_items`. Four reads → one items fetch. 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_toc(sandbox, doc_id)
_ = await _read_items_jsonl(sandbox, doc_id) _ = await _read_items_jsonl(sandbox, doc_id)
_ = await _read_toc(sandbox, doc_id) _ = await _read_toc(sandbox, doc_id)
_ = await _read_items_jsonl(sandbox, doc_id) _ = await _read_items_jsonl(sandbox, doc_id)
assert call_count["n"] == 1 assert items_calls["n"] == 1
assert chunk_calls["n"] == 1
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -373,6 +373,37 @@ class TestCiteTool:
result = await cite(ctx, chunk_ids=[]) result = await cite(ctx, chunk_ids=[])
assert "0" in result assert "0" in result
async def test_cite_accepts_chunk_id_from_db_without_prior_search(
self, rag_db, rag_client
):
"""chunk_ids sourced from items.jsonl / toc.json are valid citations.
The skill calls cite directly with chunk_ids it read from the VFS;
no search() has been recorded in state.searches. cite must look the
chunk up in the DB and build a Citation with full document context.
"""
from haiku.rag.skills.rag import RAGState, create_skill
docs = await rag_client.list_documents(limit=1)
assert docs, "fixture should have at least one document"
doc_id = docs[0].id
chunks = await rag_client.chunk_repository.get_by_document_id(doc_id)
assert chunks, "fixture document should have chunks"
chunk_id = chunks[0].id
skill = create_skill(db_path=rag_db)
cite = _get_tool(skill, "cite")
state = RAGState()
ctx = _make_ctx(state, rag=rag_client)
assert not state.searches, "this test exercises the no-prior-search path"
result = await cite(ctx, chunk_ids=[chunk_id])
assert "Registered 1 citation(s)." == result
assert chunk_id in state.citations
registered = state.citation_index[chunk_id]
assert registered.document_id == doc_id
assert registered.document_uri # uri must be populated from doc lookup
class TestLifespan: class TestLifespan:
async def test_opens_one_client_per_invocation(self, rag_db): async def test_opens_one_client_per_invocation(self, rag_db):