Chunk 2 gave search a configured set to fan out over. ask and analyze
covered one database still: the RAG capability had no way to be told which
databases a question spanned, and the analysis sandbox mounted one
document tree.
The selection travels as sources on EvidenceState, beside the filter it
scopes with, so both capabilities read it the same way. clients_covering
is the one rule that turns a selection into clients, used by search, the
sandbox mount and the cite fallback, so a question scoped to some
databases cannot search, mount or cite another. Citations carry the
database they came from, and format_for_agent names it, so the model can
attribute evidence while it answers rather than only afterwards.
The sandbox keeps one flat /documents/{id}/ namespace and resolves each id
to the client holding it, which rests on ids being UUID4. A database
copied from another breaks that, so an id held twice is refused rather
than resolved to whichever arrived last.
On the CLI, search, ask and analyze cover the configured set and label
each result with its database. Every other command works on one, named
with --database NAME (a name reaches a database behind a URI, which --db
cannot) or --db PATH, and refuses a set it cannot choose from instead of
silently reading the default database. Cold databases open together, so a
first query costs the slowest open rather than their sum.
154 lines
4.9 KiB
Python
154 lines
4.9 KiB
Python
from collections.abc import Sequence
|
|
from dataclasses import dataclass, field
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from pydantic_ai import Agent
|
|
|
|
if TYPE_CHECKING:
|
|
from pydantic_ai.messages import BinaryContent
|
|
|
|
from haiku.rag.client import HaikuRAG
|
|
from haiku.rag.config.models import ModelConfig
|
|
from haiku.rag.sandbox import AnalysisResult
|
|
from haiku.rag.store.models.citation import Citation
|
|
|
|
|
|
@dataclass
|
|
class _AgentDeps:
|
|
state: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
def _build_user_prompt(
|
|
question: str,
|
|
images: Sequence[bytes] | None,
|
|
model_config: "ModelConfig",
|
|
) -> "str | list[str | BinaryContent]":
|
|
if not images:
|
|
return question
|
|
if not model_config.vision:
|
|
raise ValueError(
|
|
f"Model {model_config.provider}:{model_config.name} is not configured "
|
|
"for vision (set `vision: true` on the model config to pass images)."
|
|
)
|
|
from haiku.rag.utils import image_binary_content
|
|
|
|
return [question, *(image_binary_content(data) for data in images)]
|
|
|
|
|
|
async def ask(
|
|
client: "HaikuRAG",
|
|
question: str,
|
|
filter: str | None = None,
|
|
images: Sequence[bytes] | None = None,
|
|
sources: list[str] | None = None,
|
|
) -> "tuple[str, list[Citation]]":
|
|
"""Ask a question against the knowledge base via the RAG capability.
|
|
|
|
Args:
|
|
client: The HaikuRAG client.
|
|
question: The question to ask.
|
|
filter: SQL WHERE clause to filter documents.
|
|
images: Raw image bytes attached to the question (requires a
|
|
vision-capable QA model).
|
|
sources: Names of the databases to ask across. None asks across every
|
|
configured database.
|
|
|
|
Returns:
|
|
Tuple of (answer text, list of resolved citations).
|
|
"""
|
|
from haiku.rag.capabilities.rag import (
|
|
AGENT_PREAMBLE,
|
|
RAGState,
|
|
create_capability,
|
|
)
|
|
from haiku.rag.utils import get_model
|
|
|
|
capability = create_capability(
|
|
db_path=None if client._federated else client.store.db_path,
|
|
config=client._config,
|
|
rag=client,
|
|
defer_loading=False,
|
|
)
|
|
deps = _AgentDeps(
|
|
state={
|
|
"rag": RAGState(document_filter=filter, sources=sources).model_dump(
|
|
mode="json"
|
|
)
|
|
}
|
|
)
|
|
user_prompt = _build_user_prompt(question, images, client._config.qa.model)
|
|
model = get_model(client._config.qa.model, client._config)
|
|
agent = Agent(
|
|
model,
|
|
deps_type=_AgentDeps,
|
|
instructions=AGENT_PREAMBLE,
|
|
capabilities=[capability],
|
|
)
|
|
result = await agent.run(user_prompt, deps=deps)
|
|
state = RAGState.model_validate(deps.state["rag"])
|
|
citations = [
|
|
state.citation_index[cid]
|
|
for cid in state.citations
|
|
if cid in state.citation_index
|
|
]
|
|
return result.output, citations
|
|
|
|
|
|
async def analyze(
|
|
client: "HaikuRAG",
|
|
question: str,
|
|
filter: str | None = None,
|
|
images: Sequence[bytes] | None = None,
|
|
sources: list[str] | None = None,
|
|
) -> "AnalysisResult":
|
|
"""Answer a question using the analysis capability.
|
|
|
|
The capability exposes search, code execution, and citation tools.
|
|
The driving model decides when to reach for code (structural traversal,
|
|
computation, aggregation) versus a direct ``search → cite → answer``.
|
|
|
|
Args:
|
|
client: The HaikuRAG client.
|
|
question: The question to answer.
|
|
filter: SQL WHERE clause to filter documents during searches.
|
|
images: Raw image bytes attached to the question (requires a
|
|
vision-capable analysis model).
|
|
sources: Names of the databases to analyze across. None covers every
|
|
configured database.
|
|
|
|
Returns:
|
|
AnalysisResult with the answer and resolved citations.
|
|
"""
|
|
from haiku.rag.capabilities.analysis import AnalysisState, create_capability
|
|
from haiku.rag.sandbox import AnalysisResult
|
|
from haiku.rag.utils import get_model
|
|
|
|
capability = create_capability(
|
|
db_path=None if client._federated else client.store.db_path,
|
|
config=client._config,
|
|
rag=client,
|
|
defer_loading=False,
|
|
)
|
|
deps = _AgentDeps(
|
|
state={
|
|
"analysis": AnalysisState(
|
|
document_filter=filter, sources=sources
|
|
).model_dump(mode="json")
|
|
}
|
|
)
|
|
model_config = client._config.analysis.model or client._config.qa.model
|
|
user_prompt = _build_user_prompt(question, images, model_config)
|
|
model = get_model(model_config, client._config)
|
|
agent = Agent(
|
|
model,
|
|
deps_type=_AgentDeps,
|
|
capabilities=[capability],
|
|
)
|
|
result = await agent.run(user_prompt, deps=deps)
|
|
state = AnalysisState.model_validate(deps.state["analysis"])
|
|
citations = [
|
|
state.citation_index[cid]
|
|
for cid in state.citations
|
|
if cid in state.citation_index
|
|
]
|
|
return AnalysisResult(answer=result.output, citations=citations)
|