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:
parent
2a6d72171d
commit
15afb97a6e
7 changed files with 391 additions and 103 deletions
|
|
@ -4,6 +4,9 @@
|
||||||
|
|
||||||
### Added
|
### 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
|
- MCP server `instructions`, `version`, and read-only `ToolAnnotations` on
|
||||||
every tool; every parameter carries a description. `filter` on
|
every tool; every parameter carries a description. `filter` on
|
||||||
`search_documents` and `search_documents_by_image`. `DocumentInfo.metadata`.
|
`search_documents` and `search_documents_by_image`. `DocumentInfo.metadata`.
|
||||||
|
|
@ -35,6 +38,7 @@
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
|
- `cite` on the MCP `ask_question` tool; citations are always appended.
|
||||||
- MCP write tools `add_document_from_file`, `add_document_from_url`,
|
- MCP write tools `add_document_from_file`, `add_document_from_url`,
|
||||||
`add_document_from_text` and `delete_document`. The server opens the
|
`add_document_from_text` and `delete_document`. The server opens the
|
||||||
database read-only; ingest with `haiku-rag add`, `add-src`, `delete` or
|
database read-only; ingest with `haiku-rag add`, `add-src`, `delete` or
|
||||||
|
|
|
||||||
22
docs/mcp.md
22
docs/mcp.md
|
|
@ -78,8 +78,10 @@ repeating it.
|
||||||
| `search_documents` | always | `query`, `limit`, `include_images`, `filter`, `sources` |
|
| `search_documents` | always | `query`, `limit`, `include_images`, `filter`, `sources` |
|
||||||
| `search_documents_by_image` | multimodal embedder only | `image_base64`, `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` | 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` |
|
| `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` |
|
| `analyze` | always | `question`, `filter`, `images_base64`, `sources` |
|
||||||
|
|
||||||
`search_documents` runs hybrid search, vector and full-text, and returns
|
`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
|
`image_data`. `search_documents_by_image` embeds the query image and searches
|
||||||
by vector similarity alone.
|
by vector similarity alone.
|
||||||
|
|
||||||
`get_document` returns a document whole, in reading order. `list_documents`
|
`get_document` returns a document whole, in reading order. For a long one,
|
||||||
returns titles, URIs and metadata, which is how a client learns what a filter
|
`get_document_outline` returns the heading tree with page numbers and
|
||||||
can match.
|
`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
|
`ask_question` runs the RAG agent on the server and returns an answer
|
||||||
citations when `cite` is set. `analyze` writes and runs Python in a sandbox
|
followed by its citations. `analyze` writes and runs Python in a sandbox
|
||||||
over the documents, for counting, aggregation and computation across
|
over the documents, for counting, aggregation and computation across
|
||||||
documents. Both cost a model call.
|
documents. Both cost a model call.
|
||||||
|
|
||||||
|
|
@ -112,8 +117,9 @@ title = 'Q3 report'
|
||||||
### Errors
|
### Errors
|
||||||
|
|
||||||
A failure is an MCP error, never an empty result. Expected failures carry a
|
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
|
message: a document or section id that matches nothing, a collection the
|
||||||
cover, a filter the query engine rejects (with its message), invalid base64,
|
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.
|
and an `ask_question` or `analyze` failure naming only the exception type.
|
||||||
Anything else reaches the client as `Error calling tool 'name'` and its
|
Anything else reaches the client as `Error calling tool 'name'` and its
|
||||||
traceback goes to the server log.
|
traceback goes to the server log.
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ In both cases:
|
||||||
- Results without doc_item_refs pass through unexpanded
|
- 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.chunk import SearchResult
|
||||||
from haiku.rag.store.models.document_item import DocumentItem
|
from haiku.rag.store.models.document_item import DocumentItem
|
||||||
|
|
||||||
|
|
@ -488,3 +490,78 @@ def expand_with_items(
|
||||||
final_results.append(built)
|
final_results.append(built)
|
||||||
|
|
||||||
return final_results + passthrough
|
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
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,17 @@ from pydantic import Field
|
||||||
|
|
||||||
from haiku.rag.client import HaikuRAG
|
from haiku.rag.client import HaikuRAG
|
||||||
from haiku.rag.config import AppConfig, get_config
|
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.exceptions import UnknownDatabaseError
|
||||||
from haiku.rag.store.models import Document, SearchResult
|
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.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
|
from haiku.rag.utils import format_citations
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from haiku.rag.client.scope import DatabaseScope
|
from haiku.rag.client.scope import DatabaseScope
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -91,8 +95,8 @@ def _instructions(scope: "DatabaseScope", config: AppConfig) -> str:
|
||||||
description from the listing."""
|
description from the listing."""
|
||||||
lines = [
|
lines = [
|
||||||
"haiku-rag is the user's knowledge base: documents they ingested, "
|
"haiku-rag is the user's knowledge base: documents they ingested, "
|
||||||
"searchable by meaning and keyword, readable whole, answered with "
|
"searchable by meaning and keyword, readable whole or section by "
|
||||||
"citations, or computed across documents.",
|
"section, answered with citations, or computed across documents.",
|
||||||
"Use it whenever a question could be answered from those documents, "
|
"Use it whenever a question could be answered from those documents, "
|
||||||
"before answering from memory, and say when it had nothing relevant.",
|
"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)
|
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(
|
def create_mcp_server(
|
||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
config: AppConfig | 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}")
|
raise ToolError(f"No document with id {document_id!r}")
|
||||||
return document
|
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"))
|
@mcp.tool(annotations=_read_only("List documents"))
|
||||||
async def list_documents(
|
async def list_documents(
|
||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
|
|
@ -313,7 +403,6 @@ def _covering(scope: "DatabaseScope", config: AppConfig) -> FastMCP:
|
||||||
@mcp.tool(annotations=_read_only("Ask a question"))
|
@mcp.tool(annotations=_read_only("Ask a question"))
|
||||||
async def ask_question(
|
async def ask_question(
|
||||||
question: str,
|
question: str,
|
||||||
cite: bool = False,
|
|
||||||
images_base64: list[str] | None = None,
|
images_base64: list[str] | None = None,
|
||||||
sources: Sources = None,
|
sources: Sources = None,
|
||||||
) -> str:
|
) -> 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.
|
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
|
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
|
the answer, followed by citations to the passages it rests on.
|
||||||
`cite` is set.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
question: The question, in natural language.
|
question: The question, in natural language.
|
||||||
cite: Append citations to the answer.
|
|
||||||
images_base64: Images to attach to the question, PNG or JPEG
|
images_base64: Images to attach to the question, PNG or JPEG
|
||||||
bytes as base64. Needs a vision-capable model on the server.
|
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:
|
except Exception as e:
|
||||||
logger.exception("ask_question failed")
|
logger.exception("ask_question failed")
|
||||||
raise ToolError(f"ask_question failed: {type(e).__name__}") from e
|
raise ToolError(f"ask_question failed: {type(e).__name__}") from e
|
||||||
if cite and citations:
|
if citations:
|
||||||
answer += "\n\n" + format_citations(
|
answer += "\n\n" + format_citations(
|
||||||
citations, include_source=rag.covers_multiple
|
citations, include_source=rag.covers_multiple
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from pydantic_monty import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from haiku.rag.config.models import AppConfig
|
from haiku.rag.config.models import AppConfig
|
||||||
|
from haiku.rag.context import build_toc
|
||||||
from haiku.rag.sandbox.dependencies import AnalysisContext
|
from haiku.rag.sandbox.dependencies import AnalysisContext
|
||||||
from haiku.rag.store.models.chunk import SearchResult
|
from haiku.rag.store.models.chunk import SearchResult
|
||||||
from haiku.rag.store.models.document_item import PICTURE_REF_PREFIX, DocumentItem
|
from haiku.rag.store.models.document_item import PICTURE_REF_PREFIX, DocumentItem
|
||||||
|
|
@ -38,81 +39,6 @@ class SandboxResult:
|
||||||
success: bool
|
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:
|
class Sandbox:
|
||||||
"""Execute code in a sandboxed Python interpreter.
|
"""Execute code in a sandboxed Python interpreter.
|
||||||
|
|
||||||
|
|
@ -520,7 +446,7 @@ class Sandbox:
|
||||||
{
|
{
|
||||||
"doc_id": did,
|
"doc_id": did,
|
||||||
"title": doc_titles.get(did),
|
"title": doc_titles.get(did),
|
||||||
"tree": _build_toc(items, chunk_index),
|
"tree": build_toc(items, chunk_index),
|
||||||
},
|
},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,25 @@ class DocumentInfo(BaseModel):
|
||||||
metadata: dict = {}
|
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):
|
class DocumentListResponse(BaseModel):
|
||||||
"""Response from list_documents tool."""
|
"""Response from list_documents tool."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -236,9 +236,7 @@ class TestMCPReadTools:
|
||||||
assert overview["metadata"] == {"author": "Ada"}
|
assert overview["metadata"] == {"author": "Ada"}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ask_question_appends_citations_when_requested(
|
async def test_ask_question_appends_the_citations(self, mcp_db, monkeypatch):
|
||||||
self, mcp_db, monkeypatch
|
|
||||||
):
|
|
||||||
from haiku.rag.store.models.citation import Citation
|
from haiku.rag.store.models.citation import Citation
|
||||||
|
|
||||||
citation = Citation(
|
citation = Citation(
|
||||||
|
|
@ -257,13 +255,182 @@ class TestMCPReadTools:
|
||||||
mcp = create_mcp_server(mcp_db)
|
mcp = create_mcp_server(mcp_db)
|
||||||
ask = await _get_tool(mcp, "ask_question")
|
ask = await _get_tool(mcp, "ask_question")
|
||||||
|
|
||||||
with_cite = await ask(question="q", cite=True)
|
answer = await ask(question="q")
|
||||||
assert with_cite.startswith("the answer")
|
assert answer.startswith("the answer")
|
||||||
assert "AI Overview" in with_cite
|
assert "AI Overview" in answer
|
||||||
# One database: its name adds nothing.
|
# 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")
|
@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:
|
async with Client(create_mcp_server(mcp_db)) as client:
|
||||||
tools = await client.list_tools()
|
tools = await client.list_tools()
|
||||||
|
|
||||||
assert len(tools) == 6
|
assert len(tools) == 8
|
||||||
for tool in tools:
|
for tool in tools:
|
||||||
assert tool.annotations is not None, tool.name
|
assert tool.annotations is not None, tool.name
|
||||||
assert tool.annotations.readOnlyHint is True, 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()
|
for name, schema in tool.inputSchema.get("properties", {}).items()
|
||||||
if not schema.get("description")
|
if not schema.get("description")
|
||||||
]
|
]
|
||||||
assert len(tools) == 6
|
assert len(tools) == 8
|
||||||
assert undescribed == []
|
assert undescribed == []
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -356,6 +523,8 @@ class TestMCPToolSet:
|
||||||
assert {t.name for t in await mcp.list_tools()} == {
|
assert {t.name for t in await mcp.list_tools()} == {
|
||||||
"search_documents",
|
"search_documents",
|
||||||
"get_document",
|
"get_document",
|
||||||
|
"get_document_outline",
|
||||||
|
"get_document_section",
|
||||||
"list_documents",
|
"list_documents",
|
||||||
"ask_question",
|
"ask_question",
|
||||||
"analyze",
|
"analyze",
|
||||||
|
|
@ -475,7 +644,7 @@ class TestMCPCoversTheConfiguredSet:
|
||||||
mcp = _covering_all(two_dbs)
|
mcp = _covering_all(two_dbs)
|
||||||
ask = await _get_tool(mcp, "ask_question")
|
ask = await _get_tool(mcp, "ask_question")
|
||||||
|
|
||||||
answer = await ask(question="q", cite=True)
|
answer = await ask(question="q")
|
||||||
|
|
||||||
assert "alpha" in answer
|
assert "alpha" in answer
|
||||||
assert "beta" in answer
|
assert "beta" in answer
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue