haiku.rag/evaluations/evaluations/capability_runner.py
Yiorgis Gozadinos 2bbd949a29
Record the database each citation came from
A run over several databases could report which documents were cited but
not which database grounded the answer: `_result_from_run` walked the
citation index for `document_uri` and dropped `Citation.source`. The
distribution is not recoverable from the report afterwards, so a sharded
run would have measured everything except attribution.

`cited_sources` is one entry per cited chunk, in citation order, empty
where the database is unnamed.
2026-08-24 10:03:47 +03:00

297 lines
10 KiB
Python

from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, NamedTuple
from pydantic_ai import Agent
from pydantic_ai.messages import (
ModelMessage,
ModelRequest,
ModelResponse,
RetryPromptPart,
TextPart,
ToolCallPart,
ToolReturnPart,
UserPromptPart,
)
from pydantic_ai.models import Model
from pydantic_ai.capabilities import AbstractCapability
from evaluations.config import Turn
from haiku.rag.capabilities import EvidenceState, RAGCapabilityBase
from haiku.rag.capabilities.compaction import create_capability as create_compaction
from haiku.rag.capabilities.ledger import citation_status
from haiku.rag.config.models import AppConfig
CapabilityFactory = Callable[..., RAGCapabilityBase[Any]]
def prefix_to_messages(turns: Iterable[Turn]) -> list[ModelMessage]:
"""Render a conversation prefix as pydantic-ai message history."""
messages: list[ModelMessage] = []
for turn in turns:
if turn.speaker == "user":
messages.append(ModelRequest(parts=[UserPromptPart(content=turn.text)]))
else:
messages.append(ModelResponse(parts=[TextPart(content=turn.text)]))
return messages
@dataclass
class CapabilityRunResult:
answer: str
cited_uris: list[str] = field(default_factory=list)
cited_chunk_ids: list[str] = field(default_factory=list)
# The database each cited chunk came from, in the order they were cited, so a
# run over several databases records which one grounded the answer. Empty
# strings where the database is unnamed, since one database names nothing.
cited_sources: list[str] = field(default_factory=list)
searched_uris: list[str] = field(default_factory=list)
n_searches: int = 0
n_executions: int = 0
n_search_calls: int = 0
n_rejected_searches: int = 0
n_failed_tools: int = 0
n_requests: int = 0
citation_status: str | None = None
class ToolTraffic(NamedTuple):
n_search_calls: int
n_rejected_searches: int
n_failed_tools: int
n_requests: int
def _count_tool_traffic(
messages: list[ModelMessage], namespace: str, tool_names: frozenset[str]
) -> ToolTraffic:
"""Count search calls, failed calls and model requests in a run.
The history is the only source: ``state.searches`` is keyed by query so it
hides repeats and refusals, and ``for_run`` hands the run a ``replace()``
copy, leaving the outer capability's counters at zero.
Only search failures mean an exhausted budget. A failed code call may be
either the execution budget or any error in model-written Python, so
``n_failed_tools`` covers both without claiming to tell them apart. It counts
``RetryPromptPart`` too, since ``_cite`` rejects with ``ModelRetry`` and only
``ToolFailed`` sets ``outcome="failed"``. Both are restricted to
``tool_names``, excluding host tools and output-validation retries.
``n_requests`` counts the run's requests, which matches the capability's own
budget only while it stays loaded — a deferred capability skips hooks until
it loads.
"""
search_tool = f"{namespace}_search"
search_calls = 0
rejected_searches = 0
failed_tools = 0
requests = 0
for message in messages:
if isinstance(message, ModelResponse):
requests += 1
search_calls += sum(
1
for part in message.parts
if isinstance(part, ToolCallPart) and part.tool_name == search_tool
)
continue
for part in message.parts:
if not isinstance(part, RetryPromptPart | ToolReturnPart):
continue
if part.tool_name not in tool_names:
continue
if isinstance(part, RetryPromptPart):
failed_tools += 1
elif part.outcome == "failed":
failed_tools += 1
if part.tool_name == search_tool:
rejected_searches += 1
return ToolTraffic(
n_search_calls=search_calls,
n_rejected_searches=rejected_searches,
n_failed_tools=failed_tools,
n_requests=requests,
)
@dataclass
class _EvalDeps:
state: dict[str, Any] = field(default_factory=dict)
def _prepare_agent(
capability_factory: CapabilityFactory,
db_path: Path | None,
config: AppConfig,
capability_model: str | Model,
document_filter: str | None,
request_limit: int | None,
compaction: bool = False,
) -> tuple[RAGCapabilityBase[Any], _EvalDeps, Agent[_EvalDeps, str]]:
capability = capability_factory(
db_path=db_path,
config=config,
defer_loading=False,
)
if request_limit is not None:
capability.request_limit = request_limit
state = capability.state_type()
if document_filter is not None:
state.document_filter = document_filter
capabilities: list[AbstractCapability] = [capability]
if compaction:
capabilities.append(create_compaction())
deps = _EvalDeps(state={capability.state_namespace: state.model_dump(mode="json")})
agent = Agent(
capability_model,
deps_type=_EvalDeps,
capabilities=capabilities,
)
return capability, deps, agent
def _state_after_run(
capability: RAGCapabilityBase[Any], deps: _EvalDeps
) -> EvidenceState:
return capability.state_type.model_validate(deps.state[capability.state_namespace])
async def run_capability_question(
capability_factory: CapabilityFactory,
db_path: Path | None,
config: AppConfig,
question: str,
capability_model: str | Model,
document_filter: str | None = None,
request_limit: int | None = None,
message_history: list[ModelMessage] | None = None,
) -> CapabilityRunResult:
"""Run a single question through a capability and return answer + retrieval data.
Builds a native capability via ``capability_factory(db_path=..., config=...)``.
After the run, citations and searched documents
are extracted from the state for downstream eval scoring.
The capability must produce a state with RAG-capability-shaped fields (citation
index, searches, optional document filter) — i.e. ``RAGState`` or
``AnalysisState`` from ``haiku.rag.capabilities``.
"""
capability, deps, agent = _prepare_agent(
capability_factory,
db_path,
config,
capability_model,
document_filter,
request_limit,
)
agent_result = await agent.run(question, deps=deps, message_history=message_history)
traffic = _count_tool_traffic(
agent_result.new_messages(), capability.state_namespace, capability.tool_names
)
return _result_from_run(
agent_result.output, _state_after_run(capability, deps), traffic
)
async def run_capability_conversation(
capability_factory: CapabilityFactory,
db_path: Path | None,
config: AppConfig,
questions: list[str],
capability_model: str | Model,
document_filter: str | None = None,
compaction: bool = False,
) -> list[CapabilityRunResult]:
"""Run a conversation's user turns sequentially through one capability.
Each turn runs with the previous turn's full ``all_messages()`` as history
(tool calls and returns included) and the same state dict, which is what
lets ``EvidenceCompactionCapability`` (registered when ``compaction`` is
True) replace earlier questions' evidence on the request. Per-invocation
state (citations, searches) is cleared by the capability on every run, so
each returned result reflects only its turn.
"""
capability, deps, agent = _prepare_agent(
capability_factory,
db_path,
config,
capability_model,
document_filter=document_filter,
request_limit=None,
compaction=compaction,
)
history: list[ModelMessage] | None = None
results: list[CapabilityRunResult] = []
for question in questions:
agent_result = await agent.run(question, deps=deps, message_history=history)
history = agent_result.all_messages()
traffic = _count_tool_traffic(
agent_result.new_messages(),
capability.state_namespace,
capability.tool_names,
)
results.append(
_result_from_run(
agent_result.output, _state_after_run(capability, deps), traffic
)
)
return results
def _result_from_run(
answer: str, typed: EvidenceState, traffic: ToolTraffic
) -> CapabilityRunResult:
cited_chunk_ids: list[str] = list(typed.citations)
seen_cited: set[str] = set()
cited_uris: list[str] = []
cited_sources: list[str] = []
for chunk_id in cited_chunk_ids:
citation = typed.citation_index.get(chunk_id)
if citation is None:
continue
cited_sources.append(citation.source or "")
if citation.document_uri not in seen_cited:
seen_cited.add(citation.document_uri)
cited_uris.append(citation.document_uri)
seen_searched: set[str] = set()
searched_uris: list[str] = []
for results in typed.searches.values():
for search_result in results:
uri = search_result.document_uri
if uri and uri not in seen_searched:
seen_searched.add(uri)
searched_uris.append(uri)
executions = getattr(typed, "executions", None)
n_executions = len(executions) if executions is not None else 0
record = typed.evidence
status = (
citation_status([record], question=record.question)
if record.question is not None
else None
)
return CapabilityRunResult(
answer=answer,
cited_uris=cited_uris,
cited_chunk_ids=cited_chunk_ids,
cited_sources=cited_sources,
searched_uris=searched_uris,
# Distinct search keys, not searches. Analysis files every in-code
# `search()` under one "_sandbox" key, so twenty sandbox searches read
# as one here; `n_search_calls` is the true count of search *tool*
# calls, and in-code searches are not counted anywhere.
n_searches=len(typed.searches),
n_executions=n_executions,
n_search_calls=traffic.n_search_calls,
n_rejected_searches=traffic.n_rejected_searches,
n_failed_tools=traffic.n_failed_tools,
n_requests=traffic.n_requests,
citation_status=status,
)