Navigate documents by outline and section from the MCP server

build_toc moves from the sandbox into haiku.rag.context; the sandbox
keeps its toc.json unchanged. get_document_outline returns the heading
tree with page numbers and get_document_section one section's text,
subsections included, both resolved in the database holding the
document. Chunk ids never leave the server. ask_question drops `cite`
and always appends its citations.

Refs #599
This commit is contained in:
Yiorgis Gozadinos 2026-09-04 12:16:00 +03:00
parent 2a6d72171d
commit 15afb97a6e
No known key found for this signature in database
7 changed files with 391 additions and 103 deletions

View file

@ -4,6 +4,9 @@
### Added
- MCP tools `get_document_outline` (heading tree with page numbers) and
`get_document_section` (one section's text, subsections included), built
on `document_items`. `build_toc` in `haiku.rag.context`.
- MCP server `instructions`, `version`, and read-only `ToolAnnotations` on
every tool; every parameter carries a description. `filter` on
`search_documents` and `search_documents_by_image`. `DocumentInfo.metadata`.
@ -35,6 +38,7 @@
### Removed
- `cite` on the MCP `ask_question` tool; citations are always appended.
- MCP write tools `add_document_from_file`, `add_document_from_url`,
`add_document_from_text` and `delete_document`. The server opens the
database read-only; ingest with `haiku-rag add`, `add-src`, `delete` or

View file

@ -78,8 +78,10 @@ repeating it.
| `search_documents` | always | `query`, `limit`, `include_images`, `filter`, `sources` |
| `search_documents_by_image` | multimodal embedder only | `image_base64`, `limit`, `include_images`, `filter`, `sources` |
| `get_document` | always | `document_id`, `source` |
| `get_document_outline` | always | `document_id`, `source` |
| `get_document_section` | always | `document_id`, `section_id`, `source` |
| `list_documents` | always | `limit`, `offset`, `filter` |
| `ask_question` | always | `question`, `cite`, `images_base64`, `sources` |
| `ask_question` | always | `question`, `images_base64`, `sources` |
| `analyze` | always | `question`, `filter`, `images_base64`, `sources` |
`search_documents` runs hybrid search, vector and full-text, and returns
@ -88,12 +90,15 @@ Rank is the signal. `include_images` attaches picture bytes as base64 PNG under
`image_data`. `search_documents_by_image` embeds the query image and searches
by vector similarity alone.
`get_document` returns a document whole, in reading order. `list_documents`
returns titles, URIs and metadata, which is how a client learns what a filter
can match.
`get_document` returns a document whole, in reading order. For a long one,
`get_document_outline` returns the heading tree with page numbers and
`get_document_section` the text of one section, subsections included; a
node's `id` in the outline is the `section_id`. A document without headings
has an empty outline. `list_documents` returns titles, URIs and metadata,
which is how a client learns what a filter can match.
`ask_question` runs the RAG agent on the server and returns an answer, with
citations when `cite` is set. `analyze` writes and runs Python in a sandbox
`ask_question` runs the RAG agent on the server and returns an answer
followed by its citations. `analyze` writes and runs Python in a sandbox
over the documents, for counting, aggregation and computation across
documents. Both cost a model call.
@ -112,8 +117,9 @@ title = 'Q3 report'
### Errors
A failure is an MCP error, never an empty result. Expected failures carry a
message: a document id that matches nothing, a collection the server does not
cover, a filter the query engine rejects (with its message), invalid base64,
message: a document or section id that matches nothing, a collection the
server does not cover, a filter the query engine rejects (with its message),
invalid base64,
and an `ask_question` or `analyze` failure naming only the exception type.
Anything else reaches the client as `Error calling tool 'name'` and its
traceback goes to the server log.

View file

@ -32,6 +32,8 @@ In both cases:
- Results without doc_item_refs pass through unexpanded
"""
from typing import Any
from haiku.rag.store.models.chunk import SearchResult
from haiku.rag.store.models.document_item import DocumentItem
@ -488,3 +490,78 @@ def expand_with_items(
final_results.append(built)
return final_results + passthrough
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.
Each ``section_header`` with ``heading_level > 0`` becomes a node. Nesting
follows the explicit levels: a header pops the stack until the top is at
a strictly shallower level, then becomes a child of that top (or a root).
``item_range = [position, end_exclusive]`` where ``end_exclusive`` is the
position of the next header whose level is the same or shallower (i.e.
the next sibling or ancestor that ends this section), or the total item
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
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
where every PDF section_header is emitted at level=1).
"""
# Defensive: every consumer is supposed to pass items in position order,
# but the end_exclusive lookahead below silently miscomputes section
# boundaries if it's not — better to sort once than trust the caller.
items = sorted(items, key=lambda i: i.position)
headers: list[DocumentItem] = [
i for i in items if i.label == "section_header" and i.heading_level > 0
]
if not headers:
return []
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] = []
for idx, h in enumerate(headers):
end = total
for j in range(idx + 1, len(headers)):
if headers[j].heading_level <= h.heading_level:
end = headers[j].position
break
ends.append(end)
roots: list[dict[str, Any]] = []
stack: list[tuple[int, dict[str, Any]]] = []
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] = {
"self_ref": h.self_ref,
"level": h.heading_level,
"title": h.text,
"page_numbers": list(h.page_numbers),
"item_range": [h.position, end],
"chunk_ids": chunk_ids,
"children": [],
}
while stack and stack[-1][0] >= h.heading_level:
stack.pop()
(stack[-1][1]["children"] if stack else roots).append(node)
stack.append((h.heading_level, node))
return roots

View file

@ -13,13 +13,17 @@ from pydantic import Field
from haiku.rag.client import HaikuRAG
from haiku.rag.config import AppConfig, get_config
from haiku.rag.context import build_toc
from haiku.rag.store.exceptions import UnknownDatabaseError
from haiku.rag.store.models import Document, SearchResult
from haiku.rag.store.models.document_item import DocumentItem
from haiku.rag.store.schema import DocumentMetaRecord
from haiku.rag.tools.document import DocumentInfo
from haiku.rag.tools.document import DocumentInfo, DocumentSection, OutlineNode
from haiku.rag.utils import format_citations
if TYPE_CHECKING:
from typing import Any
from haiku.rag.client.scope import DatabaseScope
logger = logging.getLogger(__name__)
@ -91,8 +95,8 @@ def _instructions(scope: "DatabaseScope", config: AppConfig) -> str:
description from the listing."""
lines = [
"haiku-rag is the user's knowledge base: documents they ingested, "
"searchable by meaning and keyword, readable whole, answered with "
"citations, or computed across documents.",
"searchable by meaning and keyword, readable whole or section by "
"section, answered with citations, or computed across documents.",
"Use it whenever a question could be answered from those documents, "
"before answering from memory, and say when it had nothing relevant.",
]
@ -106,6 +110,26 @@ def _instructions(scope: "DatabaseScope", config: AppConfig) -> str:
return "\n".join(lines)
def _node(toc: "dict[str, Any]") -> OutlineNode:
return OutlineNode(
id=toc["self_ref"],
title=toc["title"],
level=toc["level"],
page_numbers=toc["page_numbers"],
children=[_node(child) for child in toc["children"]],
)
def _find(toc: list["dict[str, Any]"], section_id: str) -> "dict[str, Any] | None":
for node in toc:
if node["self_ref"] == section_id:
return node
found = _find(node["children"], section_id)
if found is not None:
return found
return None
def create_mcp_server(
db_path: Path | None = None,
config: AppConfig | None = None,
@ -279,6 +303,72 @@ def _covering(scope: "DatabaseScope", config: AppConfig) -> FastMCP:
raise ToolError(f"No document with id {document_id!r}")
return document
async def _items_of(document_id: str, source: str | None) -> list[DocumentItem]:
"""A document's items in reading order, from the database holding it."""
rag = await _client()
try:
document = await rag.get_document_by_id(document_id, source)
if document is None:
raise ToolError(f"No document with id {document_id!r}")
owner = await rag.reader_for(source or document.source)
except UnknownDatabaseError as e:
raise ToolError(str(e)) from e
assert owner is not None, "a stored document names its database"
return await owner.document_item_repository.get_all_items(document_id)
@mcp.tool(annotations=_read_only("Document outline"))
async def get_document_outline(
document_id: str, source: str | None = None
) -> list[OutlineNode]:
"""The heading tree of a document, with page numbers.
Use this on a long document to see its structure before reading, then
pass a node's `id` to `get_document_section`. Returns the headings
nested by level; an empty list means the document has no headings,
so read it with `get_document`.
Args:
document_id: The document's id.
source: The collection holding it. Without one every collection
is asked.
"""
return [
_node(toc) for toc in build_toc(await _items_of(document_id, source), {})
]
@mcp.tool(annotations=_read_only("Document section"))
async def get_document_section(
document_id: str, section_id: str, source: str | None = None
) -> DocumentSection:
"""The text of one section of a document, subsections included.
Use this to read a part of a long document instead of the whole.
`section_id` is a node `id` from `get_document_outline`. Returns the
section's heading, page numbers and text in reading order, up to the
next heading of the same or a higher level.
Args:
document_id: The document's id.
section_id: The `id` of a node in the document's outline.
source: The collection holding it. Without one every collection
is asked.
"""
items = await _items_of(document_id, source)
node = _find(build_toc(items, {}), section_id)
if node is None:
raise ToolError(f"No section {section_id!r} in document {document_id!r}")
start, end = node["item_range"]
return DocumentSection(
id=node["self_ref"],
title=node["title"],
page_numbers=node["page_numbers"],
content="\n\n".join(
item.text
for item in items
if start <= item.position < end and item.text
),
)
@mcp.tool(annotations=_read_only("List documents"))
async def list_documents(
limit: int | None = None,
@ -313,7 +403,6 @@ def _covering(scope: "DatabaseScope", config: AppConfig) -> FastMCP:
@mcp.tool(annotations=_read_only("Ask a question"))
async def ask_question(
question: str,
cite: bool = False,
images_base64: list[str] | None = None,
sources: Sources = None,
) -> str:
@ -321,12 +410,10 @@ def _covering(scope: "DatabaseScope", config: AppConfig) -> FastMCP:
Use this when the user wants an answer rather than material to read.
It runs a model on the server and is slower than a search. Returns
the answer, followed by citations to the passages it rests on when
`cite` is set.
the answer, followed by citations to the passages it rests on.
Args:
question: The question, in natural language.
cite: Append citations to the answer.
images_base64: Images to attach to the question, PNG or JPEG
bytes as base64. Needs a vision-capable model on the server.
"""
@ -339,7 +426,7 @@ def _covering(scope: "DatabaseScope", config: AppConfig) -> FastMCP:
except Exception as e:
logger.exception("ask_question failed")
raise ToolError(f"ask_question failed: {type(e).__name__}") from e
if cite and citations:
if citations:
answer += "\n\n" + format_citations(
citations, include_source=rag.covers_multiple
)

View file

@ -17,6 +17,7 @@ from pydantic_monty import (
)
from haiku.rag.config.models import AppConfig
from haiku.rag.context import build_toc
from haiku.rag.sandbox.dependencies import AnalysisContext
from haiku.rag.store.models.chunk import SearchResult
from haiku.rag.store.models.document_item import PICTURE_REF_PREFIX, DocumentItem
@ -38,81 +39,6 @@ class SandboxResult:
success: bool
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.
Each ``section_header`` with ``heading_level > 0`` becomes a node. Nesting
follows the explicit levels: a header pops the stack until the top is at
a strictly shallower level, then becomes a child of that top (or a root).
``item_range = [position, end_exclusive]`` where ``end_exclusive`` is the
position of the next header whose level is the same or shallower (i.e.
the next sibling or ancestor that ends this section), or the total item
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
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
where every PDF section_header is emitted at level=1).
"""
# Defensive: every consumer is supposed to pass items in position order,
# but the end_exclusive lookahead below silently miscomputes section
# boundaries if it's not — better to sort once than trust the caller.
items = sorted(items, key=lambda i: i.position)
headers: list[DocumentItem] = [
i for i in items if i.label == "section_header" and i.heading_level > 0
]
if not headers:
return []
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] = []
for idx, h in enumerate(headers):
end = total
for j in range(idx + 1, len(headers)):
if headers[j].heading_level <= h.heading_level:
end = headers[j].position
break
ends.append(end)
roots: list[dict[str, Any]] = []
stack: list[tuple[int, dict[str, Any]]] = []
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] = {
"self_ref": h.self_ref,
"level": h.heading_level,
"title": h.text,
"page_numbers": list(h.page_numbers),
"item_range": [h.position, end],
"chunk_ids": chunk_ids,
"children": [],
}
while stack and stack[-1][0] >= h.heading_level:
stack.pop()
(stack[-1][1]["children"] if stack else roots).append(node)
stack.append((h.heading_level, node))
return roots
class Sandbox:
"""Execute code in a sandboxed Python interpreter.
@ -520,7 +446,7 @@ class Sandbox:
{
"doc_id": did,
"title": doc_titles.get(did),
"tree": _build_toc(items, chunk_index),
"tree": build_toc(items, chunk_index),
},
ensure_ascii=False,
)

View file

@ -31,6 +31,25 @@ class DocumentInfo(BaseModel):
metadata: dict = {}
class OutlineNode(BaseModel):
"""A heading in a document's outline. `id` is the heading item's self_ref."""
id: str
title: str
level: int
page_numbers: list[int] = []
children: list["OutlineNode"] = []
class DocumentSection(BaseModel):
"""One section's text in reading order, subsections included."""
id: str
title: str
page_numbers: list[int] = []
content: str
class DocumentListResponse(BaseModel):
"""Response from list_documents tool."""

View file

@ -236,9 +236,7 @@ class TestMCPReadTools:
assert overview["metadata"] == {"author": "Ada"}
@pytest.mark.asyncio
async def test_ask_question_appends_citations_when_requested(
self, mcp_db, monkeypatch
):
async def test_ask_question_appends_the_citations(self, mcp_db, monkeypatch):
from haiku.rag.store.models.citation import Citation
citation = Citation(
@ -257,13 +255,182 @@ class TestMCPReadTools:
mcp = create_mcp_server(mcp_db)
ask = await _get_tool(mcp, "ask_question")
with_cite = await ask(question="q", cite=True)
assert with_cite.startswith("the answer")
assert "AI Overview" in with_cite
answer = await ask(question="q")
assert answer.startswith("the answer")
assert "AI Overview" in answer
# One database: its name adds nothing.
assert "alpha" not in with_cite
assert "alpha" not in answer
assert await ask(question="q", cite=False) == "the answer"
@pytest.fixture
async def outlined_db(temp_db_path):
"""A database with one document whose items carry a heading hierarchy.
Rows are written through the repositories, so no embedder is involved.
Returns the path and the document id."""
from haiku.rag.store.models.document import Document as DocumentModel
from haiku.rag.store.models.document_item import DocumentItem
def header(pos, level, text):
return DocumentItem(
document_id="",
position=pos,
self_ref=f"#/texts/{pos}",
label="section_header",
text=text,
page_numbers=[pos // 4 + 1],
heading_level=level,
)
def para(pos):
return DocumentItem(
document_id="",
position=pos,
self_ref=f"#/texts/{pos}",
label="paragraph",
text=f"para{pos}",
page_numbers=[pos // 4 + 1],
)
async with HaikuRAG(temp_db_path, create=True) as rag:
doc = await rag.document_repository.create(
DocumentModel(content="x", uri="test://outlined", title="Outlined")
)
items = [
header(0, 1, "Intro"),
para(1),
header(2, 2, "Background"),
para(3),
header(4, 3, "Prior Work"),
para(5),
header(6, 2, "Approach"),
para(7),
header(8, 1, "Methods"),
para(9),
]
for item in items:
item.document_id = doc.id
await rag.document_item_repository.create_items(doc.id, items)
return temp_db_path, doc.id
class TestMCPDocumentNavigation:
@pytest.mark.asyncio
async def test_the_outline_nests_headings_by_level(self, outlined_db):
db, doc_id = outlined_db
outline = await _get_tool(create_mcp_server(db), "get_document_outline")
roots = await outline(document_id=doc_id)
assert [n.title for n in roots] == ["Intro", "Methods"]
intro = roots[0]
assert (intro.id, intro.level, intro.page_numbers) == ("#/texts/0", 1, [1])
assert [c.title for c in intro.children] == ["Background", "Approach"]
assert [c.title for c in intro.children[0].children] == ["Prior Work"]
assert intro.children[0].children[0].level == 3
assert roots[1].children == []
@pytest.mark.asyncio
async def test_a_document_without_headings_has_an_empty_outline(self, mcp_db):
mcp = create_mcp_server(mcp_db)
[doc] = await (await _get_tool(mcp, "list_documents"))(limit=1)
outline = await _get_tool(mcp, "get_document_outline")
assert await outline(document_id=doc.id) == []
@pytest.mark.asyncio
async def test_a_section_covers_its_subsections_and_stops_at_its_sibling(
self, outlined_db
):
db, doc_id = outlined_db
section = await _get_tool(create_mcp_server(db), "get_document_section")
background = await section(document_id=doc_id, section_id="#/texts/2")
assert background.title == "Background"
assert background.content.split("\n\n") == [
"Background",
"para3",
"Prior Work",
"para5",
]
assert background.page_numbers == [1]
intro = await section(document_id=doc_id, section_id="#/texts/0")
assert intro.content.startswith("Intro")
assert "para7" in intro.content
assert "Methods" not in intro.content
@pytest.mark.asyncio
async def test_an_unknown_section_or_document_is_an_error(self, outlined_db):
db, doc_id = outlined_db
mcp = create_mcp_server(db)
section = await _get_tool(mcp, "get_document_section")
outline = await _get_tool(mcp, "get_document_outline")
with pytest.raises(ToolError, match="#/texts/99"):
await section(document_id=doc_id, section_id="#/texts/99")
with pytest.raises(ToolError, match="nonexistent-id"):
await outline(document_id="nonexistent-id")
with pytest.raises(ToolError, match="nonexistent-id"):
await section(document_id="nonexistent-id", section_id="#/texts/0")
@pytest.mark.asyncio
@pytest.mark.filterwarnings("ignore:Found propagated trace context:RuntimeWarning")
async def test_outline_and_section_serialize_over_the_wire(self, outlined_db):
db, doc_id = outlined_db
mcp = create_mcp_server(db)
outline = await _call(mcp, "get_document_outline", document_id=doc_id)
section = await _call(
mcp, "get_document_section", document_id=doc_id, section_id="#/texts/8"
)
assert not outline.is_error and not section.is_error
[intro, methods] = outline.structured_content["result"]
assert set(intro) == {"id", "title", "level", "page_numbers", "children"}
assert intro["children"][0]["children"][0]["title"] == "Prior Work"
assert set(section.structured_content) == {
"id",
"title",
"page_numbers",
"content",
}
assert section.structured_content["content"] == "Methods\n\npara9"
@pytest.mark.asyncio
async def test_source_routes_to_the_database_holding_the_document(self, two_dbs):
from haiku.rag.store.models.document_item import DocumentItem
async with HaikuRAG(config=two_dbs, sources=["beta"]) as beta:
[doc] = await beta.list_documents()
await beta.document_item_repository.create_items(
doc.id,
[
DocumentItem(
document_id=doc.id,
position=0,
self_ref="#/texts/0",
label="section_header",
text="Only in beta",
heading_level=1,
)
],
)
mcp = _covering_all(two_dbs)
outline = await _get_tool(mcp, "get_document_outline")
section = await _get_tool(mcp, "get_document_section")
named = await outline(document_id=doc.id, source="beta")
found = await outline(document_id=doc.id)
assert [n.title for n in named] == [n.title for n in found] == ["Only in beta"]
assert (
await section(document_id=doc.id, section_id="#/texts/0", source="beta")
).title == "Only in beta"
with pytest.raises(ToolError, match="nope"):
await outline(document_id=doc.id, source="nope")
with pytest.raises(ToolError, match=doc.id):
await outline(document_id=doc.id, source="alpha")
@pytest.mark.filterwarnings("ignore:Found propagated trace context:RuntimeWarning")
@ -324,7 +491,7 @@ class TestMCPDescribesItself:
async with Client(create_mcp_server(mcp_db)) as client:
tools = await client.list_tools()
assert len(tools) == 6
assert len(tools) == 8
for tool in tools:
assert tool.annotations is not None, tool.name
assert tool.annotations.readOnlyHint is True, tool.name
@ -344,7 +511,7 @@ class TestMCPDescribesItself:
for name, schema in tool.inputSchema.get("properties", {}).items()
if not schema.get("description")
]
assert len(tools) == 6
assert len(tools) == 8
assert undescribed == []
@ -356,6 +523,8 @@ class TestMCPToolSet:
assert {t.name for t in await mcp.list_tools()} == {
"search_documents",
"get_document",
"get_document_outline",
"get_document_section",
"list_documents",
"ask_question",
"analyze",
@ -475,7 +644,7 @@ class TestMCPCoversTheConfiguredSet:
mcp = _covering_all(two_dbs)
ask = await _get_tool(mcp, "ask_question")
answer = await ask(question="q", cite=True)
answer = await ask(question="q")
assert "alpha" in answer
assert "beta" in answer