client.analyze routes through the rag-analysis skill; drop documents= and AnalysisResult.program. Re-record all cassettes that are relevant

This commit is contained in:
Yiorgis Gozadinos 2026-05-19 10:46:32 +03:00
parent d96d2eeb0f
commit 947a26b391
No known key found for this signature in database
15 changed files with 69153 additions and 5383 deletions

View file

@ -13,12 +13,17 @@
- `llm()` from the analysis sandbox. Sandbox externals are now `search` and `list_documents` only.
- `list_documents` top-level tool from the analysis skill (still available as `await list_documents()` inside `execute_code`).
- `documents=` kwarg on `client.analyze` (and the `--document` flag on `haiku-rag analyze` / MCP `analyze` tool). The pre-loaded `documents` Python variable inside the sandbox is no longer populated. Use `filter=` (SQL WHERE clause) to scope analysis to specific documents.
- `AnalysisResult.program`. The per-execution programs are still tracked on `AnalysisState.executions` (the analysis skill's `execute_code` tool populates it); consumers that need the executed code should pull it from the skill state instead of the function return value.
- `--cite` flag on `haiku-rag ask`. Citations always render after the answer now.
- `system_prompt` kwarg on `client.ask`. No production caller used it; `config.prompts.domain_preamble` already covers the preamble use case.
### Changed
- `search.limit` default lowered from `10` to `5`. Reduces text + binary noise in vision-tool returns (picture count tracks result count after expansion + dedup); the cite path still selects from all returned chunks.
- Search result formatter surfaces picture captions on a labelled line when a chunk's expanded refs include pictures. The OpenAI vision API has no identifier field for binary parts, so the caption is the only signal a model can use to map a description to the figure it sees.
- `AnalysisConfig.model` defaults to `None` (was `ollama:gpt-oss/no-thinking/temp=0`). Consumers resolve via `config.analysis.model or config.qa.model`, so the analysis skill inherits the QA driving model when no `analysis` block is in YAML. Set `analysis.model` explicitly to keep the analysis pipeline on a different model from QA. **Note**: the type is now `ModelConfig | None`; external code reading `cfg.analysis.model.X` directly will need to handle the `None` case.
- `client.ask` and `client.analyze` now route through the rag and rag-analysis skills internally (via `haiku.skills.run_skill`). Return shapes preserved: `client.ask` still returns `tuple[str, list[Citation]]`; `client.analyze` returns `AnalysisResult(answer, citations)`. The standalone QA and analysis agents under `agents/qa/` and `agents/analysis/agent.py` remain for now but are scheduled for removal once GEPA optimisation and the `--target qa` eval CLI move to the skill path.
### Fixed

View file

@ -20,8 +20,12 @@ class RawAnalysisResult(BaseModel):
class AnalysisResult(BaseModel):
"""Result from analysis execution with resolved citations."""
"""Result from analysis execution with resolved citations.
The per-execution program(s) are tracked on ``AnalysisState.executions``
(populated by the analysis skill's ``execute_code`` tool), not on this
return value. Consumers that need the executed code should pull it from
the skill state."""
answer: str
program: str
citations: list[Citation] = Field(default_factory=list)

View file

@ -16,7 +16,6 @@ from rich.progress import (
TextColumn,
TransferSpeedColumn,
)
from rich.syntax import Syntax
from haiku.rag.client import HaikuRAG, RebuildMode
from haiku.rag.config import AppConfig, Config
@ -469,14 +468,12 @@ class HaikuRAGApp: # pragma: no cover
async def analyze(
self,
question: str,
document: str | None = None,
filter: str | None = None,
):
"""Answer a question using the analysis agent with code execution.
"""Answer a question using the rag-analysis skill.
Args:
question: The question to answer
document: Optional document ID or title to pre-load
filter: SQL WHERE clause to filter documents
"""
async with HaikuRAG(
@ -485,24 +482,19 @@ class HaikuRAGApp: # pragma: no cover
read_only=self.read_only,
before=self.before,
) as self.client:
documents = [document] if document else None
self.console.print(f"[bold blue]Question:[/bold blue] {question}")
self.console.print()
self.console.print(
"[dim]Running analysis agent with code execution...[/dim]"
"[dim]Running analysis skill with code execution...[/dim]"
)
self.console.print()
result = await self.client.analyze(
question, documents=documents, filter=filter
)
result = await self.client.analyze(question, filter=filter)
self.console.print("[bold yellow]Program:[/bold yellow]")
self.console.print(Syntax(result.program, "python"))
self.console.print()
self.console.print("[bold green]Answer:[/bold green]")
self.console.print(Markdown(result.answer))
for renderable in format_citations_rich(result.citations):
self.console.print(renderable)
async def research(
self,

View file

@ -385,7 +385,7 @@ def ask( # pragma: no cover
)
@_cli.command("analyze", help="Answer questions using code execution (analysis agent)")
@_cli.command("analyze", help="Answer questions using the rag-analysis skill")
def analyze( # pragma: no cover
question: str = typer.Argument(
help="The question to answer",
@ -395,12 +395,6 @@ def analyze( # pragma: no cover
"--db",
help="Path to the LanceDB database file",
),
document: str | None = typer.Option(
None,
"--document",
"-d",
help="Document ID or title to pre-load for analysis",
),
filter: str | None = typer.Option(
None,
"--filter",
@ -412,7 +406,6 @@ def analyze( # pragma: no cover
asyncio.run(
app.analyze(
question=question,
document=document,
filter=filter,
)
)

View file

@ -390,12 +390,11 @@ class HaikuRAG:
async def analyze(
self,
question: str,
documents: list[str] | None = None,
filter: str | None = None,
) -> "AnalysisResult":
from haiku.rag.client.agents import analyze
return await analyze(self, question, documents, filter)
return await analyze(self, question, filter)
async def visualize_chunk(self, chunk: Chunk) -> list:
from haiku.rag.client.search import visualize_chunk

View file

@ -73,76 +73,36 @@ async def research(
async def analyze(
client: "HaikuRAG",
question: str,
documents: list[str] | None = None,
filter: str | None = None,
) -> "AnalysisResult":
"""Answer a question using the analysis agent with code execution.
"""Answer a question against the knowledge base via the rag-analysis skill.
The analysis agent can write and execute Python code in a sandboxed
environment to solve problems that require computation, aggregation, or
complex traversal across documents.
The analysis skill exposes ``search``, ``execute_code``, and ``cite`` 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.
documents: Optional list of document IDs or titles to pre-load.
filter: SQL WHERE clause to filter documents during searches.
Returns:
AnalysisResult with the answer and the final consolidated program.
AnalysisResult with the answer and resolved citations.
"""
from haiku.rag.agents.analysis import (
AnalysisContext,
AnalysisDeps,
Sandbox,
create_analysis_agent,
)
from haiku.rag.agents.analysis.models import AnalysisResult
from haiku.rag.agents.research.models import Citation
from haiku.rag.skills.analysis import AnalysisState, create_skill
from haiku.rag.utils import get_model
from haiku.skills import run_skill
context = AnalysisContext(filter=filter)
if documents:
loaded_docs = []
for doc_ref in documents:
doc = await client.resolve_document(doc_ref)
if doc:
loaded_docs.append(doc)
context.documents = loaded_docs if loaded_docs else None
sandbox = Sandbox(
db_path=client.store.db_path,
config=client._config,
context=context,
)
deps = AnalysisDeps(
sandbox=sandbox,
context=context,
)
agent = create_analysis_agent(client._config)
result = await agent.run(question, deps=deps)
output = result.output
seen: set[str] = set()
citations: list[Citation] = []
for sr in sandbox._search_results:
if sr.chunk_id and sr.chunk_id not in seen:
seen.add(sr.chunk_id)
citations.append(
Citation(
index=len(seen),
document_id=sr.document_id or "",
chunk_id=sr.chunk_id,
document_uri=sr.document_uri or "",
document_title=sr.document_title,
page_numbers=sr.page_numbers,
headings=sr.headings,
content=sr.content,
)
)
return AnalysisResult(
answer=output.answer,
program=output.program,
citations=citations,
skill = create_skill(db_path=client.store.db_path, config=client._config)
state = AnalysisState(document_filter=filter)
model = get_model(
client._config.analysis.model or client._config.qa.model, client._config
)
answer, _, _ = await run_skill(model, skill, question, state=state)
citations = [
state.citation_index[cid]
for cid in state.citations
if cid in state.citation_index
]
return AnalysisResult(answer=answer, citations=citations)

View file

@ -226,18 +226,16 @@ def create_mcp_server(
@mcp.tool()
async def analyze(
question: str,
document: str | None = None,
filter: str | None = None,
) -> str:
"""Answer complex questions using code execution (analysis agent).
"""Answer complex questions using the rag-analysis skill.
Use this for questions requiring computation, aggregation, or
complex traversal across documents. The agent can write Python
code to search, analyze, and compute answers.
structural traversal across documents. The skill can write and
execute Python code in a sandboxed interpreter.
Args:
question: The question to answer.
document: Optional document ID or title to pre-load for analysis.
filter: Optional SQL WHERE clause to filter documents.
Returns:
@ -245,10 +243,9 @@ def create_mcp_server(
"""
try:
async with HaikuRAG(db_path, config=config, read_only=read_only) as rag:
documents = [document] if document else None
result = await rag.analyze(question, documents=documents, filter=filter)
result = await rag.analyze(question, filter=filter)
return result.answer
except Exception as e:
return f"Error running analysis agent: {e!s}"
return f"Error running analysis skill: {e!s}"
return mcp

View file

@ -216,43 +216,3 @@ class TestClientAnalysisIntegration:
assert len(found_labels) >= 6, (
f"Expected at least 6 labels, found {len(found_labels)}: {found_labels}"
)
@pytest.mark.asyncio
@pytest.mark.vcr()
async def test_analyze_with_preloaded_documents(
self, allow_model_requests, temp_db_path
):
"""Test analysis agent can use pre-loaded documents variable.
Agent program:
if 'documents' in dir():
for doc in documents:
print(doc['title'], len(doc['content']))
else:
print('No preloaded documents')
"""
from haiku.rag.client import HaikuRAG
config = AppConfig()
async with HaikuRAG(temp_db_path, config=config, create=True) as client:
await client.create_document(
"The company was founded in 1985 by Jane Smith.",
title="Company History",
)
await client.create_document(
"Our mission is to make technology accessible to everyone.",
title="Mission Statement",
)
result = await client.analyze(
"Using the pre-loaded documents variable, "
"tell me when was the company founded and what is their mission?",
documents=["Company History", "Mission Statement"],
)
assert "1985" in result.answer
assert (
"accessible" in result.answer.lower()
or "technology" in result.answer.lower()
)

View file

@ -27,6 +27,6 @@ class TestCodeExecution:
class TestAnalysisResult:
def test_create_result(self):
result = AnalysisResult(answer="The answer is 42", program="print(42)")
result = AnalysisResult(answer="The answer is 42")
assert result.answer == "The answer is 42"
assert result.program == "print(42)"
assert result.citations == []

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long