199 lines
8.6 KiB
Markdown
199 lines
8.6 KiB
Markdown
# Analysis Skill
|
|
|
|
Plain RAG (search → cite → answer) works for questions whose answer sits in a chunk or two: "Who wrote this?", "What does X say about Y?". It struggles when the answer requires touching the whole corpus, reading a specific section in full, or doing arithmetic on the data.
|
|
|
|
The analysis skill (`rag-analysis`) gives the agent a second tool (`execute_code`) that runs Python in a sandboxed interpreter against a structured view of your documents. The agent can search, read, count, slice, and compare without leaving the tool call. Citations work the same way as the rag skill.
|
|
|
|
`client.analyze`, `haiku-rag analyze`, the MCP `analyze` tool, and the chat TUI (when `-s analysis` is enabled) all run through this skill.
|
|
|
|
## When to use it
|
|
|
|
Reach for the analysis skill when the question needs more than a search:
|
|
|
|
- **Aggregation across the corpus.** "How many documents mention security vulnerabilities?"
|
|
- **Section-scoped reading.** "Summarize Section 5 of paper Y."
|
|
- **Structural comparison.** "Do both papers have an Experimental Results section?"
|
|
- **Computation on retrieved data.** "What's the average revenue across these quarterly reports?"
|
|
- **Multi-step chains.** Search, filter the results in Python, search again, aggregate, all in one tool call.
|
|
|
|
For everyday Q&A, the [RAG skill](rag.md) is faster and cheaper. Attach both and the agent routes.
|
|
|
|
## How it works
|
|
|
|
Two things make the agent's programs short and the resulting analyses tractable:
|
|
|
|
1. **Search and document listing are awaitable inside the code.** `await search(query)` returns the same hits the rag skill sees: chunk IDs, text, source metadata, picture refs. The agent can immediately filter, sort, count, or follow up with another search without exiting the tool call.
|
|
|
|
2. **Every document is mounted as a virtual filesystem at `/documents/{id}/`.** The agent reads four files per document: identifiers and metadata, full text, a list of structured items (paragraphs, tables, figures, headings), and a section tree built from the document's headings. The structure exposes what search alone hides. The agent can navigate from a search hit to the section it lives in, slice a single section instead of pulling the whole document, or scan a document's text directly when keyword precision matters.
|
|
|
|
A search hit is always a starting point. The agent reads structure around it, drills into the right section, and cites the chunks it actually used. Chunk IDs from search results and chunk IDs surfaced through the VFS are both accepted by `cite`.
|
|
|
|
### Sandbox guarantees
|
|
|
|
The interpreter is [pydantic-monty](https://github.com/pydantic/monty), isolated from the host:
|
|
|
|
- **Virtual filesystem only.** `/documents/` is the entire FS.
|
|
- **No network.** HTTP, sockets, and the `requests` family are unavailable.
|
|
- **Limited imports.** Only `json`, `re`, `math`, `pathlib`.
|
|
- **Execution timeout** (default 60s, configurable via `analysis.code_timeout`).
|
|
- **Output truncation** (default 50000 chars, configurable via `analysis.max_output_chars`).
|
|
- **Execution budget** (default 15 calls, configurable via `analysis.max_executions`). Past the budget, `execute_code` returns a notice telling the skill to answer from what it has instead of running more code.
|
|
|
|
Variables persist between `execute_code` calls within one invocation, so the agent can build state step by step. A fresh sandbox is built per `client.analyze` call.
|
|
|
|
## Tools
|
|
|
|
| Tool | Purpose |
|
|
|------|---------|
|
|
| `search(query, limit?)` | Hybrid search with context expansion. Same as the RAG skill's `search`. |
|
|
| `execute_code(code)` | Run Python in a sandboxed interpreter with VFS access. |
|
|
| `cite(chunk_ids)` | Register chunk IDs as citations. Call before producing the final answer. |
|
|
|
|
`list_documents` isn't exposed as a top-level tool but is available inside `execute_code` as `await list_documents()`.
|
|
|
|
## State
|
|
|
|
`AnalysisState` lives under the `"analysis"` namespace:
|
|
|
|
```python
|
|
class AnalysisState(BaseModel):
|
|
document_filter: str | None = None
|
|
executions: list[CodeExecutionEntry] = []
|
|
citation_index: dict[str, Citation] = {}
|
|
citations: list[str] = []
|
|
searches: dict[str, list[SearchResult]] = {}
|
|
```
|
|
|
|
- **document_filter** — SQL WHERE clause applied to `search` and the VFS. The LLM can't bypass it: both views are scoped.
|
|
- **executions** — Each `execute_code` call appends an entry with code, stdout, stderr, success. Cleared at the start of each invocation.
|
|
- **citation_index** — All citations indexed by chunk ID. Accumulates across invocations.
|
|
- **citations** — Chunk IDs registered via `cite` during the current invocation. Deduplicated, cleared per-invocation.
|
|
- **searches** — Search results from both the `search` tool and sandbox-internal searches. Cleared per-invocation.
|
|
|
|
## `create_skill(db_path?, config?)`
|
|
|
|
```python
|
|
from haiku.rag.skills.analysis import create_skill
|
|
|
|
skill = create_skill(db_path="my.lancedb")
|
|
```
|
|
|
|
| Parameter | Default | Description |
|
|
|-----------|---------|-------------|
|
|
| `db_path` | `None` | Path to LanceDB database. Falls back to `HAIKU_RAG_DB` env var, then config default. |
|
|
| `config` | `None` | `AppConfig` instance. Falls back to `get_config()`. |
|
|
|
|
## Use it
|
|
|
|
### From `client.analyze`
|
|
|
|
```python
|
|
from haiku.rag.client import HaikuRAG
|
|
|
|
async with HaikuRAG("my.lancedb") as client:
|
|
result = await client.analyze("How many documents mention 'security'?")
|
|
print(result.answer)
|
|
for citation in result.citations:
|
|
print(citation.uri, citation.title)
|
|
```
|
|
|
|
`client.analyze` runs the skill end-to-end and returns an `AnalysisResult` with `answer` and `citations`. The executed Python programs live on `AnalysisState.executions` during the run, not on the returned result.
|
|
|
|
### Combine with the RAG skill
|
|
|
|
```python
|
|
from haiku.rag.skills.rag import create_skill as create_rag_skill
|
|
from haiku.rag.skills.analysis import create_skill as create_analysis_skill
|
|
from haiku.skills.agent import SkillToolset
|
|
|
|
rag = create_rag_skill(db_path="my.lancedb")
|
|
analysis = create_analysis_skill(db_path="my.lancedb")
|
|
toolset = SkillToolset(skills=[rag, analysis])
|
|
```
|
|
|
|
The agent routes Q&A to the rag skill and computational questions to rag-analysis.
|
|
|
|
## What the agent actually writes
|
|
|
|
You don't write these programs yourself. The agent does, inside `execute_code`. Seeing the shape helps when you tune prompts, debug a run via `AnalysisState.executions`, or design a custom skill.
|
|
|
|
**Aggregate across the corpus.** *"How many documents mention security vulnerabilities?"*
|
|
|
|
```python
|
|
hits = await search("security vulnerability", limit=50)
|
|
|
|
doc_ids = {h['document_id'] for h in hits}
|
|
print(f"{len(doc_ids)} documents mention security vulnerabilities")
|
|
|
|
# Cite the top hit per document
|
|
seen = set()
|
|
for hit in hits:
|
|
if hit['document_id'] not in seen:
|
|
seen.add(hit['document_id'])
|
|
await cite(hit['chunk_id'])
|
|
```
|
|
|
|
**Read one section in depth.** *"Summarize Section 5."*
|
|
|
|
```python
|
|
from pathlib import Path
|
|
import json
|
|
|
|
doc_id = "..." # from a prior search or list_documents
|
|
toc = json.loads(Path(f'/documents/{doc_id}/toc.json').read_text())
|
|
|
|
section = next(n for n in toc['tree'] if n['title'].startswith('5'))
|
|
start, end = section['item_range']
|
|
lines = Path(f'/documents/{doc_id}/items.jsonl').read_text().splitlines()[start:end]
|
|
|
|
for line in lines:
|
|
print(json.loads(line)['text'])
|
|
|
|
await cite(section['chunk_ids'])
|
|
```
|
|
|
|
The section node already aggregates the chunks underneath it, so the agent cites the whole section without a separate search.
|
|
|
|
**Compare structure across documents.** *"Do both papers have an Experimental Results section?"*
|
|
|
|
```python
|
|
from pathlib import Path
|
|
import json
|
|
|
|
for doc_id in ["doc-a-id", "doc-b-id"]:
|
|
toc = json.loads(Path(f'/documents/{doc_id}/toc.json').read_text())
|
|
print(f"\n=== {toc['title']} ===")
|
|
for node in toc['tree']:
|
|
if 'experiment' in node['title'].lower():
|
|
print(f" {node['title']} (pages {node['page_numbers']})")
|
|
await cite(node['chunk_ids'])
|
|
```
|
|
|
|
## Context filter
|
|
|
|
The `filter` parameter is enforced at the deps layer. The LLM can't bypass it: both the VFS and search results are scoped to the filter.
|
|
|
|
```python
|
|
result = await client.analyze(
|
|
"Summarize all findings",
|
|
filter="uri LIKE '%confidential%'"
|
|
)
|
|
```
|
|
|
|
Useful for scoping to a corpus subset, enforcing access control, or restricting context.
|
|
|
|
## Configuration
|
|
|
|
```yaml
|
|
analysis:
|
|
model:
|
|
provider: anthropic
|
|
name: claude-sonnet-4-20250514
|
|
code_timeout: 60.0 # Max seconds per code execution
|
|
max_output_chars: 50000 # Truncate output after this many chars
|
|
max_executions: 15 # Max execute_code calls per question
|
|
```
|
|
|
|
When `analysis.model` is unset, the skill falls back to `qa.model`.
|
|
|
|
See [Search and question answering](../configuration/qa.md#analysis-configuration) for the full set.
|