From 499a843a43023962ecd34701af623f8cd6422f05 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Thu, 9 Apr 2026 13:56:09 +0300 Subject: [PATCH 01/24] remove unused create_analysis_toolset and AnalysisResult --- CHANGELOG.md | 1 + docs/tools.md | 16 ---- haiku_rag_slim/haiku/rag/tools/__init__.py | 3 - haiku_rag_slim/haiku/rag/tools/analysis.py | 87 ---------------------- tests/tools/test_analysis.py | 42 ----------- tests/tools/test_models.py | 17 ----- 6 files changed, 1 insertion(+), 165 deletions(-) delete mode 100644 haiku_rag_slim/haiku/rag/tools/analysis.py delete mode 100644 tests/tools/test_analysis.py delete mode 100644 tests/tools/test_models.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cebaa24..4fa6289d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - **`context_radius` config**: Replaced by automatic section-bounded expansion. Context expansion no longer requires configuration. - **DoclingDocument LRU cache**: No longer needed — the document_items table replaces in-memory caching for context expansion - **`cachetools` dependency**: No longer used +- **`create_analysis_toolset()`**: Removed unused `tools/analysis.py` module. ## [0.39.0] - 2026-04-09 diff --git a/docs/tools.md b/docs/tools.md index 378d967d..b552a6fe 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -59,22 +59,6 @@ docs = create_document_toolset(config) - `get_document(query)` — Retrieve a document by title or URI. - `summarize_document(query)` — Generate an LLM summary of a document's content. -### Analysis Toolset - -`create_analysis_toolset()` provides computational analysis via the RLM agent. - -```python -from haiku.rag.tools import create_analysis_toolset - -analysis = create_analysis_toolset(config) -``` - -| Parameter | Default | Description | -|-----------|---------|-------------| -| `config` | required | `AppConfig` | -| `base_filter` | `None` | SQL WHERE clause applied to searches | -| `tool_name` | `"analyze"` | Name of the tool exposed to the agent | - ## Filter Helpers `haiku.rag.tools.filters` provides utilities for building SQL filters: diff --git a/haiku_rag_slim/haiku/rag/tools/__init__.py b/haiku_rag_slim/haiku/rag/tools/__init__.py index a9237d80..def2e88c 100644 --- a/haiku_rag_slim/haiku/rag/tools/__init__.py +++ b/haiku_rag_slim/haiku/rag/tools/__init__.py @@ -1,4 +1,3 @@ -from haiku.rag.tools.analysis import AnalysisResult, create_analysis_toolset from haiku.rag.tools.context import RAGDeps from haiku.rag.tools.document import create_document_toolset from haiku.rag.tools.filters import ( @@ -10,14 +9,12 @@ from haiku.rag.tools.qa import PRIOR_ANSWER_RELEVANCE_THRESHOLD, QAHistoryEntry from haiku.rag.tools.search import create_search_toolset __all__ = [ - "AnalysisResult", "PRIOR_ANSWER_RELEVANCE_THRESHOLD", "QAHistoryEntry", "RAGDeps", "build_document_filter", "build_multi_document_filter", "combine_filters", - "create_analysis_toolset", "create_document_toolset", "create_search_toolset", ] diff --git a/haiku_rag_slim/haiku/rag/tools/analysis.py b/haiku_rag_slim/haiku/rag/tools/analysis.py deleted file mode 100644 index fd9b54a5..00000000 --- a/haiku_rag_slim/haiku/rag/tools/analysis.py +++ /dev/null @@ -1,87 +0,0 @@ -from pydantic import BaseModel, Field -from pydantic_ai import FunctionToolset, RunContext - -from haiku.rag.agents.rlm.agent import create_rlm_agent -from haiku.rag.agents.rlm.dependencies import RLMContext, RLMDeps -from haiku.rag.agents.rlm.sandbox import Sandbox -from haiku.rag.config.models import AppConfig -from haiku.rag.tools.context import RAGDeps -from haiku.rag.tools.filters import ( - build_document_filter, - combine_filters, -) - - -class AnalysisResult(BaseModel): - """Result from the analysis toolset (RLM execution).""" - - answer: str = Field(description="The answer produced by analysis") - code_executed: bool = Field( - default=True, - description="Whether code was executed to produce this answer", - ) - - -def create_analysis_toolset( - config: AppConfig, - base_filter: str | None = None, - tool_name: str = "analyze", -) -> FunctionToolset[RAGDeps]: - """Create a toolset with code analysis capabilities via RLM agent. - - Args: - config: Application configuration. - base_filter: Optional base SQL WHERE clause applied to searches. - tool_name: Name for the analyze tool. Defaults to "analyze". - - Returns: - FunctionToolset with an analyze tool. - """ - - async def analyze( # pragma: no cover - ctx: RunContext[RAGDeps], - task: str, - document_name: str | None = None, - ) -> AnalysisResult: - """Execute a computational task via code execution. - - Uses the RLM (Recursive Language Model) agent to write and execute - Python code to answer the task. - - Args: - task: A specific, actionable instruction describing what to compute. - document_name: Optional document name/title to focus on. - - Returns: - AnalysisResult with answer and execution metadata. - """ - client = ctx.deps.client - - doc_filter = build_document_filter(document_name) if document_name else None - effective_filter = combine_filters(base_filter, doc_filter) - - rlm_context = RLMContext(filter=effective_filter) - - sandbox = Sandbox( - client=client, - config=config, - context=rlm_context, - ) - deps = RLMDeps( - sandbox=sandbox, - context=rlm_context, - ) - - rlm_agent = create_rlm_agent(config) - result = await rlm_agent.run(task, deps=deps) - - program = result.output.program - - return AnalysisResult( - answer=result.output.answer, - code_executed=bool(program), - ) - - toolset: FunctionToolset[RAGDeps] = FunctionToolset() - toolset.add_function(analyze, name=tool_name) - return toolset diff --git a/tests/tools/test_analysis.py b/tests/tools/test_analysis.py deleted file mode 100644 index 30dd6bbb..00000000 --- a/tests/tools/test_analysis.py +++ /dev/null @@ -1,42 +0,0 @@ -import pytest - -from haiku.rag.tools.analysis import create_analysis_toolset - - -class TestAnalysisToolset: - """Tests for create_analysis_toolset.""" - - def test_create_analysis_toolset_returns_function_toolset(self, analysis_config): - """create_analysis_toolset returns a FunctionToolset.""" - from pydantic_ai import FunctionToolset - - toolset = create_analysis_toolset(analysis_config) - assert isinstance(toolset, FunctionToolset) - - def test_analysis_toolset_has_analyze_tool(self, analysis_config): - """The toolset includes an 'analyze' tool.""" - toolset = create_analysis_toolset(analysis_config) - assert "analyze" in toolset.tools - - def test_analysis_toolset_custom_tool_name(self, analysis_config): - """Toolset supports custom tool name.""" - toolset = create_analysis_toolset(analysis_config, tool_name="run_code") - assert "run_code" in toolset.tools - assert "analyze" not in toolset.tools - - -@pytest.fixture -async def analysis_client(temp_db_path): - """Create a HaikuRAG client for analysis tests.""" - from haiku.rag.client import HaikuRAG - - async with HaikuRAG(temp_db_path, create=True) as rag: - yield rag - - -@pytest.fixture -def analysis_config(): - """Default AppConfig for analysis tests.""" - from haiku.rag.config import Config - - return Config diff --git a/tests/tools/test_models.py b/tests/tools/test_models.py deleted file mode 100644 index 9d055dcc..00000000 --- a/tests/tools/test_models.py +++ /dev/null @@ -1,17 +0,0 @@ -from haiku.rag.tools.analysis import AnalysisResult - - -def test_analysis_result_defaults(): - """Test AnalysisResult has sensible defaults.""" - result = AnalysisResult(answer="The result is 42") - assert result.code_executed is True - - -def test_analysis_result_with_values(): - """Test AnalysisResult with explicit values.""" - result = AnalysisResult( - answer="The result is 42", - code_executed=True, - ) - assert result.answer == "The result is 42" - assert result.code_executed is True From d2b3ba1b59c80472316b34b53a4905a466852bf4 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Thu, 9 Apr 2026 15:34:21 +0300 Subject: [PATCH 02/24] rename RLM agent to analysis throughout the codebase --- CHANGELOG.md | 8 ++ README.md | 8 +- docs/agents/{rlm.md => analysis.md} | 22 ++-- docs/agents/index.md | 2 +- docs/architecture.md | 6 +- docs/cli.md | 10 +- docs/configuration/providers.md | 2 +- docs/configuration/qa-research.md | 8 +- docs/index.md | 4 +- docs/mcp.md | 2 +- docs/python.md | 12 +- docs/skills/{rlm.md => analysis.md} | 18 +-- docs/skills/index.md | 6 +- .../haiku/rag/agents/analysis/__init__.py | 16 +++ .../haiku/rag/agents/analysis/agent.py | 59 ++++++++++ .../haiku/rag/agents/analysis/dependencies.py | 23 ++++ .../rag/agents/{rlm => analysis}/models.py | 6 +- .../rag/agents/{rlm => analysis}/prompts.py | 2 +- .../rag/agents/{rlm => analysis}/sandbox.py | 12 +- .../haiku/rag/agents/rlm/__init__.py | 16 --- haiku_rag_slim/haiku/rag/agents/rlm/agent.py | 59 ---------- .../haiku/rag/agents/rlm/dependencies.py | 23 ---- haiku_rag_slim/haiku/rag/app.py | 12 +- haiku_rag_slim/haiku/rag/cli.py | 6 +- haiku_rag_slim/haiku/rag/client.py | 30 ++--- haiku_rag_slim/haiku/rag/config/models.py | 4 +- haiku_rag_slim/haiku/rag/mcp.py | 8 +- haiku_rag_slim/haiku/rag/skills/_tools.py | 2 +- .../haiku/rag/skills/{rlm.py => analysis.py} | 12 +- .../skills/{rag-rlm => rag-analysis}/SKILL.md | 4 +- haiku_rag_slim/pyproject.toml | 2 +- mkdocs.yml | 4 +- tests/agents/{rlm => analysis}/__init__.py | 0 tests/agents/{rlm => analysis}/conftest.py | 6 +- tests/agents/{rlm => analysis}/test_agent.py | 66 +++++------ tests/agents/{rlm => analysis}/test_models.py | 6 +- .../agents/{rlm => analysis}/test_sandbox.py | 28 ++--- ...Integration.test_analyze_aggregation.yaml} | 0 ...gration.test_analyze_count_documents.yaml} | 0 ...tion.test_analyze_search_and_extract.yaml} | 0 ...on.test_analyze_search_and_get_chunk.yaml} | 0 ...t_analyze_semantic_analysis_with_llm.yaml} | 0 ...Integration.test_analyze_with_filter.yaml} | 0 ...est_analyze_with_preloaded_documents.yaml} | 0 .../skills/{test_rlm.py => test_analysis.py} | 106 +++++++----------- 45 files changed, 306 insertions(+), 314 deletions(-) rename docs/agents/{rlm.md => analysis.md} (85%) rename docs/skills/{rlm.md => analysis.md} (71%) create mode 100644 haiku_rag_slim/haiku/rag/agents/analysis/__init__.py create mode 100644 haiku_rag_slim/haiku/rag/agents/analysis/agent.py create mode 100644 haiku_rag_slim/haiku/rag/agents/analysis/dependencies.py rename haiku_rag_slim/haiku/rag/agents/{rlm => analysis}/models.py (78%) rename haiku_rag_slim/haiku/rag/agents/{rlm => analysis}/prompts.py (97%) rename haiku_rag_slim/haiku/rag/agents/{rlm => analysis}/sandbox.py (95%) delete mode 100644 haiku_rag_slim/haiku/rag/agents/rlm/__init__.py delete mode 100644 haiku_rag_slim/haiku/rag/agents/rlm/agent.py delete mode 100644 haiku_rag_slim/haiku/rag/agents/rlm/dependencies.py rename haiku_rag_slim/haiku/rag/skills/{rlm.py => analysis.py} (88%) rename haiku_rag_slim/haiku/rag/skills/{rag-rlm => rag-analysis}/SKILL.md (95%) rename tests/agents/{rlm => analysis}/__init__.py (100%) rename tests/agents/{rlm => analysis}/conftest.py (76%) rename tests/agents/{rlm => analysis}/test_agent.py (83%) rename tests/agents/{rlm => analysis}/test_models.py (82%) rename tests/agents/{rlm => analysis}/test_sandbox.py (95%) rename tests/cassettes/{test_rlm/TestClientRLMIntegration.test_rlm_aggregation.yaml => test_analysis/TestClientAnalysisIntegration.test_analyze_aggregation.yaml} (100%) rename tests/cassettes/{test_rlm/TestClientRLMIntegration.test_rlm_count_documents.yaml => test_analysis/TestClientAnalysisIntegration.test_analyze_count_documents.yaml} (100%) rename tests/cassettes/{test_rlm/TestClientRLMIntegration.test_rlm_search_and_extract.yaml => test_analysis/TestClientAnalysisIntegration.test_analyze_search_and_extract.yaml} (100%) rename tests/cassettes/{test_rlm/TestClientRLMIntegration.test_rlm_search_and_get_chunk.yaml => test_analysis/TestClientAnalysisIntegration.test_analyze_search_and_get_chunk.yaml} (100%) rename tests/cassettes/{test_rlm/TestClientRLMIntegration.test_rlm_semantic_analysis_with_llm.yaml => test_analysis/TestClientAnalysisIntegration.test_analyze_semantic_analysis_with_llm.yaml} (100%) rename tests/cassettes/{test_rlm/TestClientRLMIntegration.test_rlm_with_filter.yaml => test_analysis/TestClientAnalysisIntegration.test_analyze_with_filter.yaml} (100%) rename tests/cassettes/{test_rlm/TestClientRLMIntegration.test_rlm_with_preloaded_documents.yaml => test_analysis/TestClientAnalysisIntegration.test_analyze_with_preloaded_documents.yaml} (100%) rename tests/skills/{test_rlm.py => test_analysis.py} (65%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fa6289d..75494c84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,14 @@ - **`max_searches` default**: Raised from 3 to 5 — faster expansion makes additional searches inexpensive - **Improved QA prompt**: Stronger instruction to refuse answering from tangentially related content - **Improved judge prompt**: Asymmetric evaluation — generated answers that are more comprehensive than expected are not penalized +- **BREAKING**: Rename RLM agent to analysis agent throughout: + - `agents/rlm/` → `agents/analysis/`, all classes renamed (`RLMResult` → `AnalysisResult`, etc.) + - `client.rlm()` → `client.analyze()` + - CLI: `haiku-rag rlm` → `haiku-rag analyze` + - MCP: `rlm_question` → `analyze` + - Config: `rlm:` → `analysis:` in YAML, `RLMConfig` → `AnalysisConfig` + - Skill: `rag-rlm` → `rag-analysis`, `skills/rlm.py` → `skills/analysis.py` + - State namespace: `"rlm"` → `"analysis"` ### Removed diff --git a/README.md b/README.md index 1f09a20d..10231bfb 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Agentic RAG built on [LanceDB](https://lancedb.com/), [Pydantic AI](https://ai.p - **Question answering** — QA agents with citations (page numbers, section headings) - **Reranking** — MxBAI, Cohere, Zero Entropy, or vLLM - **Research agents** — Multi-agent workflows via pydantic-graph: plan, search, evaluate, synthesize -- **RLM agent** — Complex analytical tasks via sandboxed Python code execution (aggregation, computation, multi-document analysis) +- **Analysis agent** — Complex analytical tasks via sandboxed Python code execution (aggregation, computation, multi-document analysis) - **Conversational RAG** — Chat TUI and web application for multi-turn conversations with session memory - **Document structure** — Stores full [DoclingDocument](https://docling-project.github.io/docling/concepts/docling_document/), enabling structure-aware context expansion - **Multiple providers** — Embeddings: Ollama, OpenAI, VoyageAI, LM Studio, vLLM. QA/Research: any model supported by Pydantic AI @@ -62,8 +62,8 @@ haiku-rag ask "What datasets were used for evaluation?" --cite # Research mode — iterative planning and search haiku-rag research "What are the limitations of the approach?" -# RLM mode — complex analytical tasks via code execution -haiku-rag rlm "How many documents mention transformers?" +# Analyze — complex analytical tasks via code execution +haiku-rag analyze "How many documents mention transformers?" # Interactive chat — multi-turn conversations with memory haiku-rag chat @@ -138,7 +138,7 @@ Full documentation at: https://ggozad.github.io/haiku.rag/ - [CLI](https://ggozad.github.io/haiku.rag/cli/) - Command reference - [Python API](https://ggozad.github.io/haiku.rag/python/) - Complete API docs - [Agents](https://ggozad.github.io/haiku.rag/agents/) - QA and research agents -- [RLM Agent](https://ggozad.github.io/haiku.rag/rlm/) - Complex analytical tasks via code execution +- [Analysis Agent](https://ggozad.github.io/haiku.rag/agents/analysis/) - Complex analytical tasks via code execution - [Applications](https://ggozad.github.io/haiku.rag/apps/) - Chat TUI, web app, and inspector - [Server](https://ggozad.github.io/haiku.rag/server/) - File monitoring and MCP - [MCP](https://ggozad.github.io/haiku.rag/mcp/) - Model Context Protocol integration diff --git a/docs/agents/rlm.md b/docs/agents/analysis.md similarity index 85% rename from docs/agents/rlm.md rename to docs/agents/analysis.md index ea39c453..80cda9bc 100644 --- a/docs/agents/rlm.md +++ b/docs/agents/analysis.md @@ -1,6 +1,6 @@ -# RLM Agent (Recursive Language Model) +# Analysis Agent -The RLM agent enables complex analytical tasks by writing and executing Python code in a sandboxed environment. It solves problems that traditional RAG struggles with: +The analysis agent enables complex analytical tasks by writing and executing Python code in a sandboxed environment. It solves problems that traditional RAG struggles with: - **Aggregation**: "How many documents mention security vulnerabilities?" - **Computation**: "What's the average revenue across all quarterly reports?" @@ -19,13 +19,13 @@ The RLM agent enables complex analytical tasks by writing and executing Python c ```bash # Basic usage -haiku-rag rlm "How many documents are in the database?" +haiku-rag analyze "How many documents are in the database?" # With document filter (restricts what the agent can access) -haiku-rag rlm "Summarize the key points" --filter "uri LIKE '%report%'" +haiku-rag analyze "Summarize the key points" --filter "uri LIKE '%report%'" # Pre-load specific documents -haiku-rag rlm "Compare these two reports" --document "Q1 Report" --document "Q2 Report" +haiku-rag analyze "Compare these two reports" --document "Q1 Report" --document "Q2 Report" ``` ## Python Usage @@ -35,18 +35,18 @@ from haiku.rag.client import HaikuRAG async with HaikuRAG(path_to_db) as client: # Basic question - result = await client.rlm("How many documents mention 'security'?") + result = await client.analyze("How many documents mention 'security'?") print(result.answer) # The answer print(result.program) # The final consolidated program # With filter (agent can only see filtered documents) - result = await client.rlm( + result = await client.analyze( "What is the total revenue?", filter="title LIKE '%Financial%'" ) # Pre-load specific documents - result = await client.rlm( + result = await client.analyze( "Compare the conclusions", documents=["Report A", "Report B"] ) @@ -89,7 +89,7 @@ The `filter` parameter restricts what documents the agent can access. Unlike too ```python # Agent can only see documents with "confidential" in the URI -result = await client.rlm( +result = await client.analyze( "Summarize all findings", filter="uri LIKE '%confidential%'" ) @@ -99,10 +99,10 @@ This is useful for scoping to specific document sets, enforcing access control, ## Configuration -RLM settings can be configured in `haiku.rag.yaml`: +Analysis settings can be configured in `haiku.rag.yaml`: ```yaml -rlm: +analysis: model: provider: anthropic name: claude-sonnet-4-20250514 diff --git a/docs/agents/index.md b/docs/agents/index.md index 611a7ca7..fb95c234 100644 --- a/docs/agents/index.md +++ b/docs/agents/index.md @@ -4,7 +4,7 @@ Three agentic flows are provided by haiku.rag: - **Simple QA Agent** — a focused question answering agent - **Research Graph** — a multi-step research workflow with question decomposition -- **RLM Agent** — complex analytical tasks via sandboxed Python code execution (see [RLM Agent](rlm.md)) +- **Analysis Agent** — complex analytical tasks via sandboxed Python code execution (see [Analysis Agent](analysis.md)) For multi-turn conversational RAG, haiku.rag provides [skills](../skills/index.md) built on [haiku.skills](https://github.com/ggozad/haiku.skills). The skills bundle search, Q&A, analysis, and research tools with session state management. diff --git a/docs/architecture.md b/docs/architecture.md index 4c9ed1ff..541c1ff1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,7 +26,7 @@ flowchart TB QA[QA Agent] Skill[RAG Skill] Research[Research Graph] - RLM[RLM Agent] + Analysis[Analysis Agent] end subgraph Apps["Applications"] @@ -123,7 +123,7 @@ flowchart TB Eval -->|Done| Synthesize[Synthesize] end - subgraph RLM["RLM Agent"] + subgraph AnalysisAgent["Analysis Agent"] Q4[Question] --> Code[Write Code] Code --> Execute[Execute] Execute --> Examine[Examine Results] @@ -151,7 +151,7 @@ flowchart TB - Prior answers let the planner skip redundant searches - Synthesizes structured report -**RLM Agent** - Complex analytical tasks via code execution: +**Analysis Agent** - Complex analytical tasks via code execution: - Writes Python code to explore the knowledge base - Executes in sandboxed environment diff --git a/docs/cli.md b/docs/cli.md index 56d1435c..842e9293 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -220,24 +220,24 @@ Flags: Research parameters like `max_iterations` and `max_concurrency` are configured in your [configuration file](configuration/index.md) under the `research` section. -## RLM (Recursive Language Model) +## Analyze Answer complex analytical questions via code execution: ```bash -haiku-rag rlm "How many documents mention security?" +haiku-rag analyze "How many documents mention security?" ``` Filter to specific documents: ```bash -haiku-rag rlm "What is the total revenue?" --filter "title LIKE '%Financial%'" +haiku-rag analyze "What is the total revenue?" --filter "title LIKE '%Financial%'" ``` Pre-load specific documents for comparison: ```bash -haiku-rag rlm "Compare the conclusions" --document "Report A" --document "Report B" +haiku-rag analyze "Compare the conclusions" --document "Report A" --document "Report B" ``` Flags: @@ -245,7 +245,7 @@ Flags: - `--filter` / `-f`: SQL WHERE clause to restrict document access - `--document` / `-d`: Pre-load a document by title or ID (can repeat) -See [RLM Agent](agents/rlm.md) for details on capabilities and configuration. +See [Analysis Agent](agents/analysis.md) for details on capabilities and configuration. ## Create Skill diff --git a/docs/configuration/providers.md b/docs/configuration/providers.md index cef725e6..7ab86f6e 100644 --- a/docs/configuration/providers.md +++ b/docs/configuration/providers.md @@ -22,7 +22,7 @@ qa: **Available options:** -- **temperature**: Sampling temperature (0.0-1.0+). Defaults vary by task: 0.3 for QA, research, and title generation; 0.0 for RLM and picture description. +- **temperature**: Sampling temperature (0.0-1.0+). Defaults vary by task: 0.3 for QA, research, and title generation; 0.0 for analysis and picture description. - Lower (0.0-0.3): Deterministic, focused responses - Medium (0.4-0.7): Balanced - Higher (0.8-1.0+): Creative, varied responses diff --git a/docs/configuration/qa-research.md b/docs/configuration/qa-research.md index 667e56ce..522fc203 100644 --- a/docs/configuration/qa-research.md +++ b/docs/configuration/qa-research.md @@ -58,12 +58,12 @@ research: The research workflow uses an iterative feedback loop: the planner proposes one question at a time, sees the answer, then decides whether to continue or synthesize. This continues until the planner marks research as complete or `max_iterations` is reached. -## RLM Configuration +## Analysis Configuration -Configure the RLM (Recursive Language Model) agent: +Configure the analysis agent: ```yaml -rlm: +analysis: model: provider: anthropic name: claude-sonnet-4-20250514 @@ -76,4 +76,4 @@ rlm: - **code_timeout**: Maximum seconds for each code execution (default: 60) - **max_output_chars**: Truncate code output after this many characters (default: 50000) -See [RLM Agent](../agents/rlm.md) for usage details. +See [Analysis Agent](../agents/analysis.md) for usage details. diff --git a/docs/index.md b/docs/index.md index 3f5d7d80..c9e44afa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,7 +8,7 @@ Agentic RAG built on [LanceDB](https://lancedb.com/), [Pydantic AI](https://ai.p - **Question answering** — QA agents with citations (page numbers, section headings) - **Reranking** — MxBAI, Cohere, Zero Entropy, or vLLM - **Research agents** — Multi-agent workflows via pydantic-graph: plan, search, evaluate, synthesize -- **RLM agent** — Complex analytical tasks via sandboxed Python code execution (aggregation, computation, multi-document analysis) +- **Analysis agent** — Complex analytical tasks via sandboxed Python code execution (aggregation, computation, multi-document analysis) - **Conversational RAG** — Chat TUI and web application for multi-turn conversations with session memory - **Document structure** — Stores full [DoclingDocument](https://docling-project.github.io/docling/concepts/docling_document/), enabling structure-aware context expansion - **Multiple providers** — Embeddings: Ollama, OpenAI, VoyageAI, LM Studio, vLLM. QA/Research: any model supported by Pydantic AI @@ -65,7 +65,7 @@ haiku-rag chat # Interactive conversation mode - [Python](python.md) - Python API reference - [Custom Pipelines](custom-pipelines.md) - Build custom processing workflows - [Agents](agents/index.md) - QA, chat, and research agents -- [RLM Agent](agents/rlm.md) - Complex analytical tasks via code execution +- [Analysis Agent](agents/analysis.md) - Complex analytical tasks via code execution - [Applications](apps.md) - Chat TUI, web app, and inspector - [Server](server.md) - File monitoring and server mode - [MCP](mcp.md) - Model Context Protocol integration diff --git a/docs/mcp.md b/docs/mcp.md index 219100da..dc1328a0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -50,7 +50,7 @@ The MCP server exposes `haiku.rag` as MCP tools for compatible MCP clients like - `question` (required): The research question - Returns a structured research report with findings, conclusions, and sources -- **`rlm_question`** - Answer complex analytical questions via code execution +- **`analyze`** - Answer complex analytical questions via code execution - `question` (required): The question to answer - `filter` (optional): SQL WHERE clause to restrict document access - `document` (optional): Document title/ID to pre-load (can repeat) diff --git a/docs/python.md b/docs/python.md index 81679215..7d91a282 100644 --- a/docs/python.md +++ b/docs/python.md @@ -420,32 +420,32 @@ The QA provider and model are configured in `haiku.rag.yaml` or can be passed di See also: [Agents](agents/index.md) for details on the QA agent and the multi‑agent research workflow. -## RLM (Recursive Language Model) +## Analysis Answer complex analytical questions via code execution: ```python # Aggregation across documents -result = await client.rlm("Which quarter had the highest revenue?") +result = await client.analyze("Which quarter had the highest revenue?") print(result.answer) # The answer print(result.program) # The final consolidated program # Computation within a document set -result = await client.rlm( +result = await client.analyze( "What is the average deal size mentioned in these contracts?", filter="uri LIKE '%contracts%'" ) # Multi-document comparison -result = await client.rlm( +result = await client.analyze( "What changed between these two versions of the policy?", documents=["Policy v1.0", "Policy v2.0"] ) ``` -The RLM agent writes and executes Python code in a sandboxed environment to solve problems that traditional RAG struggles with: aggregation, computation, and multi-document analysis. +The analysis agent writes and executes Python code in a sandboxed environment to solve problems that traditional RAG struggles with: aggregation, computation, and multi-document analysis. -See [RLM Agent](agents/rlm.md) for details on capabilities and configuration. +See [Analysis Agent](agents/analysis.md) for details on capabilities and configuration. ## Building Custom Agents diff --git a/docs/skills/rlm.md b/docs/skills/analysis.md similarity index 71% rename from docs/skills/rlm.md rename to docs/skills/analysis.md index 7df3d162..ab1b6a5b 100644 --- a/docs/skills/rlm.md +++ b/docs/skills/analysis.md @@ -1,11 +1,11 @@ -# RLM Skill +# Analysis Skill -The RLM (Recursive Language Model) skill provides computational analysis via code execution. It writes and runs Python code in a sandboxed interpreter to answer questions that require computation, aggregation, or data traversal. +The analysis skill provides computational analysis via code execution. It writes and runs Python code in a sandboxed interpreter to answer questions that require computation, aggregation, or data traversal. ## `create_skill(db_path?, config?)` ```python -from haiku.rag.skills.rlm import create_skill +from haiku.rag.skills.analysis import create_skill skill = create_skill(db_path=db_path, config=config) ``` @@ -29,10 +29,10 @@ skill = create_skill(db_path=db_path, config=config) ## State -The skill manages an `RLMState` under the `"rlm"` namespace: +The skill manages an `AnalysisState` under the `"analysis"` namespace: ```python -class RLMState(BaseModel): +class AnalysisState(BaseModel): document_filter: str | None = None analyses: list[AnalysisEntry] = [] @@ -51,14 +51,14 @@ Combine both skills to give the agent full RAG + analysis capabilities: ```python from haiku.rag.skills.rag import create_skill as create_rag_skill -from haiku.rag.skills.rlm import create_skill as create_rlm_skill +from haiku.rag.skills.analysis import create_skill as create_analysis_skill from haiku.skills.agent import SkillToolset from haiku.skills.prompts import build_system_prompt from pydantic_ai import Agent rag = create_rag_skill(db_path=db_path) -rlm = create_rlm_skill(db_path=db_path) -toolset = SkillToolset(skills=[rag, rlm]) +analysis = create_analysis_skill(db_path=db_path) +toolset = SkillToolset(skills=[rag, analysis]) agent = Agent( "openai:gpt-4o", @@ -67,4 +67,4 @@ agent = Agent( ) ``` -See the [RLM Agent](../agents/rlm.md) documentation for details on how the underlying agent works. +See the [Analysis Agent](../agents/analysis.md) documentation for details on how the underlying agent works. diff --git a/docs/skills/index.md b/docs/skills/index.md index 4d59c49f..0d6665cd 100644 --- a/docs/skills/index.md +++ b/docs/skills/index.md @@ -7,7 +7,7 @@ haiku.rag exposes its RAG capabilities as [haiku.skills](https://github.com/ggoz | Skill | Description | |-------|-------------| | [`rag`](rag.md) | Search, retrieve, and answer questions from the knowledge base | -| [`rag-rlm`](rlm.md) | Computational analysis via code execution | +| [`rag-analysis`](analysis.md) | Computational analysis via code execution | ## Discovery @@ -16,7 +16,7 @@ Skills are registered as Python entrypoints under `haiku.skills`. They are disco ```bash haiku-skills list --use-entrypoints # rag — Search, retrieve and analyze documents using RAG. -# rag-rlm — Analyze documents using code execution in a sandboxed interpreter. +# rag-analysis — Analyze documents using code execution in a sandboxed interpreter. ``` ## Usage @@ -88,7 +88,7 @@ Each skill manages its own state under a dedicated namespace. State is automatic ```python rag_state = toolset.get_namespace("rag") -rlm_state = toolset.get_namespace("rlm") +analysis_state = toolset.get_namespace("analysis") ``` See the individual skill pages for state model details. diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/__init__.py b/haiku_rag_slim/haiku/rag/agents/analysis/__init__.py new file mode 100644 index 00000000..0d0cca81 --- /dev/null +++ b/haiku_rag_slim/haiku/rag/agents/analysis/__init__.py @@ -0,0 +1,16 @@ +from haiku.rag.agents.analysis.agent import create_analysis_agent +from haiku.rag.agents.analysis.dependencies import AnalysisContext, AnalysisDeps +from haiku.rag.agents.analysis.models import AnalysisResult, CodeExecution +from haiku.rag.agents.analysis.prompts import ANALYSIS_SYSTEM_PROMPT +from haiku.rag.agents.analysis.sandbox import Sandbox, SandboxResult + +__all__ = [ + "ANALYSIS_SYSTEM_PROMPT", + "AnalysisContext", + "AnalysisDeps", + "AnalysisResult", + "CodeExecution", + "Sandbox", + "SandboxResult", + "create_analysis_agent", +] diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/agent.py b/haiku_rag_slim/haiku/rag/agents/analysis/agent.py new file mode 100644 index 00000000..5dfb03c2 --- /dev/null +++ b/haiku_rag_slim/haiku/rag/agents/analysis/agent.py @@ -0,0 +1,59 @@ +from pydantic_ai import Agent, RunContext + +from haiku.rag.agents.analysis.dependencies import AnalysisDeps +from haiku.rag.agents.analysis.models import AnalysisResult, CodeExecution +from haiku.rag.agents.analysis.prompts import ANALYSIS_SYSTEM_PROMPT +from haiku.rag.config.models import AppConfig +from haiku.rag.utils import get_model + + +def create_analysis_agent(config: AppConfig) -> Agent[AnalysisDeps, AnalysisResult]: + """Create an analysis agent with code execution capability. + + 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. + + Args: + config: Application configuration. + + Returns: + A pydantic-ai Agent configured for analysis execution. + """ + model = get_model(config.analysis.model, config) + + agent: Agent[AnalysisDeps, AnalysisResult] = Agent( # type: ignore[assignment] # ty: ignore[invalid-assignment] + model, + deps_type=AnalysisDeps, + output_type=AnalysisResult, + instructions=ANALYSIS_SYSTEM_PROMPT, + retries=3, + ) + + @agent.tool + async def execute_code(ctx: RunContext[AnalysisDeps], code: str) -> CodeExecution: + """Execute Python code in a sandboxed interpreter. + + The code has access to haiku.rag functions (search, list_documents, + get_document, get_chunk, llm). + + Use print() to output results. + + Args: + code: Python code to execute. + + Returns: + Structured result with success status, stdout, and stderr. + """ + result = await ctx.deps.sandbox.execute(code) + + execution = CodeExecution( + code=code, + stdout=result.stdout, + stderr=result.stderr, + success=result.success, + ) + + return execution + + return agent diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/dependencies.py b/haiku_rag_slim/haiku/rag/agents/analysis/dependencies.py new file mode 100644 index 00000000..c1d5a4e2 --- /dev/null +++ b/haiku_rag_slim/haiku/rag/agents/analysis/dependencies.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from haiku.rag.store.models import Document + +if TYPE_CHECKING: + from haiku.rag.agents.analysis.sandbox import Sandbox + + +@dataclass +class AnalysisContext: + """Mutable context accumulating data during analysis execution.""" + + documents: list[Document] | None = None + filter: str | None = None + + +@dataclass +class AnalysisDeps: + """Dependencies for analysis agent.""" + + sandbox: "Sandbox" + context: AnalysisContext = field(default_factory=AnalysisContext) diff --git a/haiku_rag_slim/haiku/rag/agents/rlm/models.py b/haiku_rag_slim/haiku/rag/agents/analysis/models.py similarity index 78% rename from haiku_rag_slim/haiku/rag/agents/rlm/models.py rename to haiku_rag_slim/haiku/rag/agents/analysis/models.py index c0864a7f..0c8e86b4 100644 --- a/haiku_rag_slim/haiku/rag/agents/rlm/models.py +++ b/haiku_rag_slim/haiku/rag/agents/analysis/models.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, Field class CodeExecution(BaseModel): - """Result of executing a code block in the RLM sandbox.""" + """Result of executing a code block in the analysis sandbox.""" code: str = Field(description="The Python code that was executed") stdout: str = Field(description="Standard output captured during execution") @@ -10,8 +10,8 @@ class CodeExecution(BaseModel): success: bool = Field(description="Whether execution completed without error") -class RLMResult(BaseModel): - """Result from RLM agent execution.""" +class AnalysisResult(BaseModel): + """Result from analysis agent execution.""" answer: str = Field(description="The answer to the user's question") program: str = Field(description="The final consolidated program") diff --git a/haiku_rag_slim/haiku/rag/agents/rlm/prompts.py b/haiku_rag_slim/haiku/rag/agents/analysis/prompts.py similarity index 97% rename from haiku_rag_slim/haiku/rag/agents/rlm/prompts.py rename to haiku_rag_slim/haiku/rag/agents/analysis/prompts.py index 7608a6f1..bb99626e 100644 --- a/haiku_rag_slim/haiku/rag/agents/rlm/prompts.py +++ b/haiku_rag_slim/haiku/rag/agents/analysis/prompts.py @@ -1,4 +1,4 @@ -RLM_SYSTEM_PROMPT = """You are a Recursive Language Model (RLM) agent that solves complex research questions by writing and executing Python code. +ANALYSIS_SYSTEM_PROMPT = """You are an analysis agent that solves complex research questions by writing and executing Python code. You MUST use the `execute_code` tool to run Python code. The functions described below are ONLY available inside execute_code. Always execute code to answer questions; do not just describe what code would do. diff --git a/haiku_rag_slim/haiku/rag/agents/rlm/sandbox.py b/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py similarity index 95% rename from haiku_rag_slim/haiku/rag/agents/rlm/sandbox.py rename to haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py index bf6880a4..ddf4e3fb 100644 --- a/haiku_rag_slim/haiku/rag/agents/rlm/sandbox.py +++ b/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any, Literal import pydantic_monty -from haiku.rag.agents.rlm.dependencies import RLMContext +from haiku.rag.agents.analysis.dependencies import AnalysisContext from haiku.rag.config.models import AppConfig from haiku.rag.store.compression import decompress_json @@ -34,13 +34,13 @@ class Sandbox: _client: "HaikuRAG" _config: AppConfig - _context: RLMContext + _context: AnalysisContext def __init__( self, client: "HaikuRAG", config: AppConfig, - context: RLMContext, + context: AnalysisContext, ): self._client = client self._config = config @@ -122,7 +122,7 @@ class Sandbox: from haiku.rag.utils import get_model - model = get_model(config.rlm.model, config) + model = get_model(config.analysis.model, config) agent: Agent[None, str] = Agent(model, output_type=str) result = await agent.run(prompt) return result.output @@ -172,9 +172,9 @@ class Sandbox: def print_callback(_stream: Literal["stdout"], text: str) -> None: stdout_lines.append(text) - max_chars = self._config.rlm.max_output_chars + max_chars = self._config.analysis.max_output_chars limits: pydantic_monty.ResourceLimits = { - "max_duration_secs": self._config.rlm.code_timeout, + "max_duration_secs": self._config.analysis.code_timeout, } try: diff --git a/haiku_rag_slim/haiku/rag/agents/rlm/__init__.py b/haiku_rag_slim/haiku/rag/agents/rlm/__init__.py deleted file mode 100644 index 82779408..00000000 --- a/haiku_rag_slim/haiku/rag/agents/rlm/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -from haiku.rag.agents.rlm.agent import create_rlm_agent -from haiku.rag.agents.rlm.dependencies import RLMContext, RLMDeps -from haiku.rag.agents.rlm.models import CodeExecution, RLMResult -from haiku.rag.agents.rlm.prompts import RLM_SYSTEM_PROMPT -from haiku.rag.agents.rlm.sandbox import Sandbox, SandboxResult - -__all__ = [ - "CodeExecution", - "RLMContext", - "RLMDeps", - "RLMResult", - "RLM_SYSTEM_PROMPT", - "Sandbox", - "SandboxResult", - "create_rlm_agent", -] diff --git a/haiku_rag_slim/haiku/rag/agents/rlm/agent.py b/haiku_rag_slim/haiku/rag/agents/rlm/agent.py deleted file mode 100644 index 8d2a13bb..00000000 --- a/haiku_rag_slim/haiku/rag/agents/rlm/agent.py +++ /dev/null @@ -1,59 +0,0 @@ -from pydantic_ai import Agent, RunContext - -from haiku.rag.agents.rlm.dependencies import RLMDeps -from haiku.rag.agents.rlm.models import CodeExecution, RLMResult -from haiku.rag.agents.rlm.prompts import RLM_SYSTEM_PROMPT -from haiku.rag.config.models import AppConfig -from haiku.rag.utils import get_model - - -def create_rlm_agent(config: AppConfig) -> Agent[RLMDeps, RLMResult]: - """Create an RLM agent with code execution capability. - - The RLM (Recursive Language Model) agent can write and execute Python code - in a sandboxed environment to solve problems that require computation, - aggregation, or complex traversal across documents. - - Args: - config: Application configuration. - - Returns: - A pydantic-ai Agent configured for RLM execution. - """ - model = get_model(config.rlm.model, config) - - agent: Agent[RLMDeps, RLMResult] = Agent( # type: ignore[assignment] # ty: ignore[invalid-assignment] - model, - deps_type=RLMDeps, - output_type=RLMResult, - instructions=RLM_SYSTEM_PROMPT, - retries=3, - ) - - @agent.tool - async def execute_code(ctx: RunContext[RLMDeps], code: str) -> CodeExecution: - """Execute Python code in a sandboxed interpreter. - - The code has access to haiku.rag functions (search, list_documents, - get_document, get_chunk, llm). - - Use print() to output results. - - Args: - code: Python code to execute. - - Returns: - Structured result with success status, stdout, and stderr. - """ - result = await ctx.deps.sandbox.execute(code) - - execution = CodeExecution( - code=code, - stdout=result.stdout, - stderr=result.stderr, - success=result.success, - ) - - return execution - - return agent diff --git a/haiku_rag_slim/haiku/rag/agents/rlm/dependencies.py b/haiku_rag_slim/haiku/rag/agents/rlm/dependencies.py deleted file mode 100644 index 02f0fdba..00000000 --- a/haiku_rag_slim/haiku/rag/agents/rlm/dependencies.py +++ /dev/null @@ -1,23 +0,0 @@ -from dataclasses import dataclass, field -from typing import TYPE_CHECKING - -from haiku.rag.store.models import Document - -if TYPE_CHECKING: - from haiku.rag.agents.rlm.sandbox import Sandbox - - -@dataclass -class RLMContext: - """Mutable context accumulating data during RLM execution.""" - - documents: list[Document] | None = None - filter: str | None = None - - -@dataclass -class RLMDeps: - """Dependencies for RLM agent.""" - - sandbox: "Sandbox" - context: RLMContext = field(default_factory=RLMContext) diff --git a/haiku_rag_slim/haiku/rag/app.py b/haiku_rag_slim/haiku/rag/app.py index dba8aba6..ec98136c 100644 --- a/haiku_rag_slim/haiku/rag/app.py +++ b/haiku_rag_slim/haiku/rag/app.py @@ -446,13 +446,13 @@ class HaikuRAGApp: # pragma: no cover for renderable in format_citations_rich(citations): self.console.print(renderable) - async def rlm( + async def analyze( self, question: str, document: str | None = None, filter: str | None = None, ): - """Answer a question using the RLM agent with code execution. + """Answer a question using the analysis agent with code execution. Args: question: The question to answer @@ -469,10 +469,14 @@ class HaikuRAGApp: # pragma: no cover self.console.print(f"[bold blue]Question:[/bold blue] {question}") self.console.print() - self.console.print("[dim]Running RLM agent with code execution...[/dim]") + self.console.print( + "[dim]Running analysis agent with code execution...[/dim]" + ) self.console.print() - result = await self.client.rlm(question, documents=documents, filter=filter) + result = await self.client.analyze( + question, documents=documents, filter=filter + ) self.console.print("[bold yellow]Program:[/bold yellow]") self.console.print(Syntax(result.program, "python")) diff --git a/haiku_rag_slim/haiku/rag/cli.py b/haiku_rag_slim/haiku/rag/cli.py index 08c4ed7b..30d14657 100644 --- a/haiku_rag_slim/haiku/rag/cli.py +++ b/haiku_rag_slim/haiku/rag/cli.py @@ -368,8 +368,8 @@ def ask( # pragma: no cover ) -@_cli.command("rlm", help="Answer questions using code execution (RLM agent)") -def rlm( # pragma: no cover +@_cli.command("analyze", help="Answer questions using code execution (analysis agent)") +def analyze( # pragma: no cover question: str = typer.Argument( help="The question to answer", ), @@ -393,7 +393,7 @@ def rlm( # pragma: no cover ): app = create_app(db) asyncio.run( - app.rlm( + app.analyze( question=question, document=document, filter=filter, diff --git a/haiku_rag_slim/haiku/rag/client.py b/haiku_rag_slim/haiku/rag/client.py index 2ff5b509..5f76736c 100644 --- a/haiku_rag_slim/haiku/rag/client.py +++ b/haiku_rag_slim/haiku/rag/client.py @@ -30,11 +30,11 @@ from haiku.rag.utils import escape_sql_string if TYPE_CHECKING: from docling_core.types.doc.document import DoclingDocument + from haiku.rag.agents.analysis.models import AnalysisResult from haiku.rag.agents.research.models import ( Citation, ResearchReport, ) - from haiku.rag.agents.rlm.models import RLMResult logger = logging.getLogger(__name__) @@ -1192,17 +1192,17 @@ class HaikuRAG: return await graph.run(state=state, deps=deps) - async def rlm( + async def analyze( self, question: str, documents: list[str] | None = None, filter: str | None = None, - ) -> "RLMResult": - """Answer a question using the RLM agent with code execution. + ) -> "AnalysisResult": + """Answer a question using the analysis agent with code execution. - The RLM (Recursive Language Model) 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 agent can write and execute Python code in a sandboxed + environment to solve problems that require computation, aggregation, + or complex traversal across documents. Args: question: The question to answer. @@ -1210,16 +1210,16 @@ class HaikuRAG: filter: SQL WHERE clause to filter documents during searches. Returns: - RLMResult with the answer and the final consolidated program. + AnalysisResult with the answer and the final consolidated program. """ - from haiku.rag.agents.rlm import ( - RLMContext, - RLMDeps, + from haiku.rag.agents.analysis import ( + AnalysisContext, + AnalysisDeps, Sandbox, - create_rlm_agent, + create_analysis_agent, ) - context = RLMContext(filter=filter) + context = AnalysisContext(filter=filter) if documents: loaded_docs = [] @@ -1234,12 +1234,12 @@ class HaikuRAG: config=self._config, context=context, ) - deps = RLMDeps( + deps = AnalysisDeps( sandbox=sandbox, context=context, ) - agent = create_rlm_agent(self._config) + agent = create_analysis_agent(self._config) result = await agent.run(question, deps=deps) return result.output diff --git a/haiku_rag_slim/haiku/rag/config/models.py b/haiku_rag_slim/haiku/rag/config/models.py index 1dbde073..5c23302e 100644 --- a/haiku_rag_slim/haiku/rag/config/models.py +++ b/haiku_rag_slim/haiku/rag/config/models.py @@ -96,7 +96,7 @@ class ResearchConfig(BaseModel): max_concurrency: int = 1 -class RLMConfig(BaseModel): +class AnalysisConfig(BaseModel): model: ModelConfig = Field( default_factory=lambda: ModelConfig( provider="ollama", @@ -219,7 +219,7 @@ class AppConfig(BaseModel): reranking: RerankingConfig = Field(default_factory=RerankingConfig) qa: QAConfig = Field(default_factory=QAConfig) research: ResearchConfig = Field(default_factory=ResearchConfig) - rlm: RLMConfig = Field(default_factory=RLMConfig) + analysis: AnalysisConfig = Field(default_factory=AnalysisConfig) processing: ProcessingConfig = Field(default_factory=ProcessingConfig) search: SearchConfig = Field(default_factory=SearchConfig) providers: ProvidersConfig = Field(default_factory=ProvidersConfig) diff --git a/haiku_rag_slim/haiku/rag/mcp.py b/haiku_rag_slim/haiku/rag/mcp.py index 90056476..11871dd5 100644 --- a/haiku_rag_slim/haiku/rag/mcp.py +++ b/haiku_rag_slim/haiku/rag/mcp.py @@ -183,12 +183,12 @@ def create_mcp_server( return None @mcp.tool() - async def rlm_question( + async def analyze( question: str, document: str | None = None, filter: str | None = None, ) -> str: - """Answer complex questions using code execution (RLM agent). + """Answer complex questions using code execution (analysis agent). Use this for questions requiring computation, aggregation, or complex traversal across documents. The agent can write Python @@ -205,9 +205,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.rlm(question, documents=documents, filter=filter) + result = await rag.analyze(question, documents=documents, filter=filter) return result.answer except Exception as e: - return f"Error running RLM agent: {e!s}" + return f"Error running analysis agent: {e!s}" return mcp diff --git a/haiku_rag_slim/haiku/rag/skills/_tools.py b/haiku_rag_slim/haiku/rag/skills/_tools.py index 6ba2ed51..c27c8472 100644 --- a/haiku_rag_slim/haiku/rag/skills/_tools.py +++ b/haiku_rag_slim/haiku/rag/skills/_tools.py @@ -216,7 +216,7 @@ async def skill_analyze( async with HaikuRAG(db_path, config=config, read_only=True) as rag: documents = [document] if document else None - result = await rag.rlm(question, documents=documents, filter=filter) + result = await rag.analyze(question, documents=documents, filter=filter) output = result.answer if result.program: output += f"\n\nProgram:\n{result.program}" diff --git a/haiku_rag_slim/haiku/rag/skills/rlm.py b/haiku_rag_slim/haiku/rag/skills/analysis.py similarity index 88% rename from haiku_rag_slim/haiku/rag/skills/rlm.py rename to haiku_rag_slim/haiku/rag/skills/analysis.py index c913db78..9648d65f 100644 --- a/haiku_rag_slim/haiku/rag/skills/rlm.py +++ b/haiku_rag_slim/haiku/rag/skills/analysis.py @@ -10,15 +10,15 @@ from haiku.skills.models import Skill, SkillMetadata, SkillSource, StateMetadata from haiku.skills.parser import parse_skill_md -class RLMState(BaseModel): +class AnalysisState(BaseModel): document_filter: str | None = None analyses: list[AnalysisEntry] = [] -STATE_TYPE = RLMState -STATE_NAMESPACE = "rlm" +STATE_TYPE = AnalysisState +STATE_NAMESPACE = "analysis" -_skill_path = Path(__file__).parent / "rag-rlm" +_skill_path = Path(__file__).parent / "rag-analysis" @cache @@ -45,7 +45,7 @@ def create_skill( db_path: Path | None = None, config: AppConfig | None = None, ) -> Skill: - """Create an RLM analysis skill for computational document analysis. + """Create an analysis skill for computational document analysis. Args: db_path: Path to the LanceDB database. Resolved from: @@ -67,7 +67,7 @@ def create_skill( else: db_path = config.storage.data_dir / "haiku.rag.lancedb" - tools = create_skill_tools(db_path, config, RLMState, ["analyze"]) + tools = create_skill_tools(db_path, config, AnalysisState, ["analyze"]) extras = create_skill_extras(db_path, config) skill_instructions = instructions() diff --git a/haiku_rag_slim/haiku/rag/skills/rag-rlm/SKILL.md b/haiku_rag_slim/haiku/rag/skills/rag-analysis/SKILL.md similarity index 95% rename from haiku_rag_slim/haiku/rag/skills/rag-rlm/SKILL.md rename to haiku_rag_slim/haiku/rag/skills/rag-analysis/SKILL.md index f59a148f..cf590817 100644 --- a/haiku_rag_slim/haiku/rag/skills/rag-rlm/SKILL.md +++ b/haiku_rag_slim/haiku/rag/skills/rag-analysis/SKILL.md @@ -1,5 +1,5 @@ --- -name: rag-rlm +name: rag-analysis description: > Computational analysis of the knowledge base via code execution in a sandboxed Python interpreter. Use for questions requiring counting, aggregation, statistics, data traversal, @@ -8,6 +8,6 @@ description: > "calculate average word count", "extract all email addresses". --- -# RLM Analysis +# Analysis Use the `analyze` tool for complex analytical questions. It writes and executes Python code against the knowledge base in a sandboxed Python interpreter. diff --git a/haiku_rag_slim/pyproject.toml b/haiku_rag_slim/pyproject.toml index aa40d7fd..b176939c 100644 --- a/haiku_rag_slim/pyproject.toml +++ b/haiku_rag_slim/pyproject.toml @@ -62,7 +62,7 @@ vertexai = ["pydantic-ai-slim[vertexai]"] [project.entry-points."haiku.skills"] rag = "haiku.rag.skills.rag:create_skill" -rag-rlm = "haiku.rag.skills.rlm:create_skill" +rag-analysis = "haiku.rag.skills.analysis:create_skill" [project.scripts] haiku-rag = "haiku.rag.cli:cli" diff --git a/mkdocs.yml b/mkdocs.yml index 681e49d0..7ebd59d8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -73,11 +73,11 @@ nav: - Tuning: tuning.md - Agents: - agents/index.md - - RLM Agent: agents/rlm.md + - Analysis Agent: agents/analysis.md - Skills: - skills/index.md - RAG: skills/rag.md - - RLM: skills/rlm.md + - Analysis: skills/analysis.md - Toolsets: tools.md - Applications: apps.md - Server: server.md diff --git a/tests/agents/rlm/__init__.py b/tests/agents/analysis/__init__.py similarity index 100% rename from tests/agents/rlm/__init__.py rename to tests/agents/analysis/__init__.py diff --git a/tests/agents/rlm/conftest.py b/tests/agents/analysis/conftest.py similarity index 76% rename from tests/agents/rlm/conftest.py rename to tests/agents/analysis/conftest.py index b1df0f4c..55810a53 100644 --- a/tests/agents/rlm/conftest.py +++ b/tests/agents/analysis/conftest.py @@ -1,7 +1,7 @@ import pytest -from haiku.rag.agents.rlm.dependencies import RLMContext -from haiku.rag.agents.rlm.sandbox import Sandbox +from haiku.rag.agents.analysis.dependencies import AnalysisContext +from haiku.rag.agents.analysis.sandbox import Sandbox from haiku.rag.client import HaikuRAG from haiku.rag.config.models import AppConfig @@ -17,5 +17,5 @@ async def empty_client(temp_db_path): async def sandbox(empty_client): """Create a Monty sandbox for testing.""" config = AppConfig() - context = RLMContext() + context = AnalysisContext() return Sandbox(client=empty_client, config=config, context=context) diff --git a/tests/agents/rlm/test_agent.py b/tests/agents/analysis/test_agent.py similarity index 83% rename from tests/agents/rlm/test_agent.py rename to tests/agents/analysis/test_agent.py index 60e567c1..5cfe10ff 100644 --- a/tests/agents/rlm/test_agent.py +++ b/tests/agents/analysis/test_agent.py @@ -3,26 +3,26 @@ from pathlib import Path import pytest from pydantic_ai import Agent -from haiku.rag.agents.rlm.agent import create_rlm_agent -from haiku.rag.agents.rlm.dependencies import RLMDeps -from haiku.rag.agents.rlm.models import CodeExecution, RLMResult +from haiku.rag.agents.analysis.agent import create_analysis_agent +from haiku.rag.agents.analysis.dependencies import AnalysisDeps +from haiku.rag.agents.analysis.models import AnalysisResult, CodeExecution from haiku.rag.config import AppConfig, Config @pytest.fixture(scope="module") def vcr_cassette_dir(): - return str(Path(__file__).parent.parent.parent / "cassettes" / "test_rlm") + return str(Path(__file__).parent.parent.parent / "cassettes" / "test_analysis") -class TestCreateRLMAgent: +class TestCreateAnalysisAgent: def test_creates_agent(self): - agent = create_rlm_agent(Config) + agent = create_analysis_agent(Config) assert isinstance(agent, Agent) - assert agent.deps_type is RLMDeps - assert agent.output_type is RLMResult + assert agent.deps_type is AnalysisDeps + assert agent.output_type is AnalysisResult def test_agent_has_execute_code_tool(self): - agent = create_rlm_agent(Config) + agent = create_analysis_agent(Config) tool_names = list(agent._function_toolset.tools.keys()) assert "execute_code" in tool_names @@ -42,13 +42,13 @@ class TestCodeExecutionModel: assert execution.success is True -class TestClientRLMIntegration: - """Integration tests for client.rlm() method.""" +class TestClientAnalysisIntegration: + """Integration tests for client.analyze() method.""" @pytest.mark.asyncio @pytest.mark.vcr() - async def test_rlm_count_documents(self, allow_model_requests, temp_db_path): - """Test RLM agent can count documents. + async def test_analyze_count_documents(self, allow_model_requests, temp_db_path): + """Test analysis agent can count documents. Agent program: docs = list_documents(limit=1000) @@ -63,14 +63,14 @@ class TestClientRLMIntegration: await client.create_document("Second document about dogs.", title="Doc 2") await client.create_document("Third document about birds.", title="Doc 3") - result = await client.rlm("How many documents are in the database?") + result = await client.analyze("How many documents are in the database?") assert "3" in result.answer @pytest.mark.asyncio @pytest.mark.vcr() - async def test_rlm_aggregation(self, allow_model_requests, temp_db_path): - """Test RLM agent can perform aggregation across documents. + async def test_analyze_aggregation(self, allow_model_requests, temp_db_path): + """Test analysis agent can perform aggregation across documents. Agent program: import re @@ -103,7 +103,7 @@ class TestClientRLMIntegration: "Sales report Q3: Revenue was $200,000.", title="Q3 Report" ) - result = await client.rlm( + result = await client.analyze( "What is the total revenue across all quarterly reports?" ) @@ -111,8 +111,8 @@ class TestClientRLMIntegration: @pytest.mark.asyncio @pytest.mark.vcr() - async def test_rlm_with_filter(self, allow_model_requests, temp_db_path): - """Test RLM agent respects filter parameter. + async def test_analyze_with_filter(self, allow_model_requests, temp_db_path): + """Test analysis agent respects filter parameter. Agent program: docs = list_documents(limit=1000) @@ -130,7 +130,7 @@ class TestClientRLMIntegration: await client.create_document("Dog document.", title="Dogs") await client.create_document("Bird document.", title="Birds") - result = await client.rlm( + result = await client.analyze( "How many documents are available?", filter="title = 'Cats'", ) @@ -139,8 +139,10 @@ class TestClientRLMIntegration: @pytest.mark.asyncio @pytest.mark.vcr() - async def test_rlm_search_and_get_chunk(self, allow_model_requests, temp_db_path): - """Test RLM agent can search and use get_chunk for citations. + async def test_analyze_search_and_get_chunk( + self, allow_model_requests, temp_db_path + ): + """Test analysis agent can search and use get_chunk for citations. Agent program: results = search("content", limit=5) @@ -158,7 +160,7 @@ class TestClientRLMIntegration: title="Animal Facts", ) - result = await client.rlm( + result = await client.analyze( "Search for content about animals and tell me " "which document it came from." ) @@ -167,10 +169,10 @@ class TestClientRLMIntegration: @pytest.mark.asyncio @pytest.mark.vcr() - async def test_rlm_semantic_analysis_with_llm( + async def test_analyze_semantic_analysis_with_llm( self, allow_model_requests, temp_db_path ): - """Test RLM agent can use llm() for semantic analysis combined with computation. + """Test analysis agent can use llm() for semantic analysis combined with computation. Agent program: docs = list_documents(limit=100) @@ -209,7 +211,7 @@ class TestClientRLMIntegration: title="Q3 Update", ) - result = await client.rlm( + result = await client.analyze( "Analyze the sentiment of each quarterly update. " "How many quarters were positive, negative, and mixed?" ) @@ -220,8 +222,8 @@ class TestClientRLMIntegration: @pytest.mark.asyncio @pytest.mark.vcr() - async def test_rlm_search_and_extract(self, allow_model_requests, temp_db_path): - """Test RLM agent can use search() to find content and extract information. + async def test_analyze_search_and_extract(self, allow_model_requests, temp_db_path): + """Test analysis agent can use search() to find content and extract information. Agent program: results = search("document element types", limit=20) @@ -242,7 +244,7 @@ class TestClientRLMIntegration: async with HaikuRAG(temp_db_path, config=config, create=True) as client: await client.create_document_from_source(pdf_path) - result = await client.rlm( + result = await client.analyze( "Search for content about document element types or labels. " "What are all the different document element types mentioned? " "List them all." @@ -277,10 +279,10 @@ class TestClientRLMIntegration: @pytest.mark.asyncio @pytest.mark.vcr() - async def test_rlm_with_preloaded_documents( + async def test_analyze_with_preloaded_documents( self, allow_model_requests, temp_db_path ): - """Test RLM agent can use pre-loaded documents variable. + """Test analysis agent can use pre-loaded documents variable. Agent program: if 'documents' in dir(): @@ -303,7 +305,7 @@ class TestClientRLMIntegration: title="Mission Statement", ) - result = await client.rlm( + 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"], diff --git a/tests/agents/rlm/test_models.py b/tests/agents/analysis/test_models.py similarity index 82% rename from tests/agents/rlm/test_models.py rename to tests/agents/analysis/test_models.py index 8d1ec6c8..41c902ee 100644 --- a/tests/agents/rlm/test_models.py +++ b/tests/agents/analysis/test_models.py @@ -1,4 +1,4 @@ -from haiku.rag.agents.rlm.models import CodeExecution, RLMResult +from haiku.rag.agents.analysis.models import AnalysisResult, CodeExecution class TestCodeExecution: @@ -25,8 +25,8 @@ class TestCodeExecution: assert "ZeroDivisionError" in execution.stderr -class TestRLMResult: +class TestAnalysisResult: def test_create_result(self): - result = RLMResult(answer="The answer is 42", program="print(42)") + result = AnalysisResult(answer="The answer is 42", program="print(42)") assert result.answer == "The answer is 42" assert result.program == "print(42)" diff --git a/tests/agents/rlm/test_sandbox.py b/tests/agents/analysis/test_sandbox.py similarity index 95% rename from tests/agents/rlm/test_sandbox.py rename to tests/agents/analysis/test_sandbox.py index adc34daa..12b50508 100644 --- a/tests/agents/rlm/test_sandbox.py +++ b/tests/agents/analysis/test_sandbox.py @@ -2,8 +2,8 @@ from pathlib import Path import pytest -from haiku.rag.agents.rlm.dependencies import RLMContext -from haiku.rag.agents.rlm.sandbox import Sandbox, SandboxResult +from haiku.rag.agents.analysis.dependencies import AnalysisContext +from haiku.rag.agents.analysis.sandbox import Sandbox, SandboxResult from haiku.rag.client import HaikuRAG from haiku.rag.config.models import AppConfig from haiku.rag.store.models import Document @@ -98,7 +98,7 @@ class TestSandboxHaikuRAG: title="Test Document", ) - context = RLMContext() + context = AnalysisContext() sb = Sandbox(client=client, config=config, context=context) result = await sb.execute( "docs = await list_documents()\n" @@ -121,7 +121,7 @@ class TestSandboxHaikuRAG: title="Animals", ) - context = RLMContext() + context = AnalysisContext() sb = Sandbox(client=client, config=config, context=context) result = await sb.execute( "results = await search('fox', limit=5)\n" @@ -144,7 +144,7 @@ class TestSandboxHaikuRAG: title="Fox Document", ) - context = RLMContext() + context = AnalysisContext() sb = Sandbox(client=client, config=config, context=context) result = await sb.execute( f"content = await get_document('{doc.id}')\n" @@ -174,7 +174,7 @@ class TestSandboxHaikuRAG: title="Fox Document", ) - context = RLMContext() + context = AnalysisContext() sb = Sandbox(client=client, config=config, context=context) # First search to get a chunk_id result = await sb.execute( @@ -257,8 +257,8 @@ class TestSandboxOutputTruncation: async def test_truncate_stdout_on_runtime_error(self, empty_client): """Test stdout is truncated when a runtime error occurs after large output.""" config = AppConfig() - config.rlm.max_output_chars = 20 - context = RLMContext() + config.analysis.max_output_chars = 20 + context = AnalysisContext() sb = Sandbox(client=empty_client, config=config, context=context) result = await sb.execute("print('a' * 100)\nx = 1/0") assert not result.success @@ -270,8 +270,8 @@ class TestSandboxOutputTruncation: async def test_truncate_successful_output(self, empty_client): """Test output is truncated on successful execution with large output.""" config = AppConfig() - config.rlm.max_output_chars = 20 - context = RLMContext() + config.analysis.max_output_chars = 20 + context = AnalysisContext() sb = Sandbox(client=empty_client, config=config, context=context) result = await sb.execute("print('b' * 100)") assert result.success @@ -299,7 +299,7 @@ class TestSandboxContextFilter: title="Private Doc", ) - context = RLMContext(filter="uri LIKE 'public://%'") + context = AnalysisContext(filter="uri LIKE 'public://%'") sb = Sandbox(client=client, config=config, context=context) result = await sb.execute( "docs = await list_documents()\n" @@ -331,7 +331,7 @@ class TestSandboxPreloadedDocuments: Document(id="1", content="Content A", title="Doc A", uri="a://1"), Document(id="2", content="Content B", title="Doc B", uri="b://2"), ] - context = RLMContext(documents=docs) + context = AnalysisContext(documents=docs) sb = Sandbox(client=empty_client, config=config, context=context) result = await sb.execute( "print(len(documents))\n" @@ -368,7 +368,7 @@ class TestSandboxDoclingDocument: title="Docling Doc", ) - context = RLMContext() + context = AnalysisContext() sb = Sandbox(client=client, config=config, context=context) result = await sb.execute( f"doc = await get_docling_document('{doc.id}')\n" @@ -389,7 +389,7 @@ class TestSandboxLLM: async def test_llm_function(self, allow_model_requests, empty_client): """Test llm() calls the model and returns a string.""" config = AppConfig() - context = RLMContext() + context = AnalysisContext() sb = Sandbox(client=empty_client, config=config, context=context) result = await sb.execute( "answer = await llm('What is 2 + 2? Reply with just the number.')\n" diff --git a/tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_aggregation.yaml b/tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_aggregation.yaml similarity index 100% rename from tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_aggregation.yaml rename to tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_aggregation.yaml diff --git a/tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_count_documents.yaml b/tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_count_documents.yaml similarity index 100% rename from tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_count_documents.yaml rename to tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_count_documents.yaml diff --git a/tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_search_and_extract.yaml b/tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_search_and_extract.yaml similarity index 100% rename from tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_search_and_extract.yaml rename to tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_search_and_extract.yaml diff --git a/tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_search_and_get_chunk.yaml b/tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_search_and_get_chunk.yaml similarity index 100% rename from tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_search_and_get_chunk.yaml rename to tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_search_and_get_chunk.yaml diff --git a/tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_semantic_analysis_with_llm.yaml b/tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_semantic_analysis_with_llm.yaml similarity index 100% rename from tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_semantic_analysis_with_llm.yaml rename to tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_semantic_analysis_with_llm.yaml diff --git a/tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_with_filter.yaml b/tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_with_filter.yaml similarity index 100% rename from tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_with_filter.yaml rename to tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_with_filter.yaml diff --git a/tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_with_preloaded_documents.yaml b/tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_with_preloaded_documents.yaml similarity index 100% rename from tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_with_preloaded_documents.yaml rename to tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_with_preloaded_documents.yaml diff --git a/tests/skills/test_rlm.py b/tests/skills/test_analysis.py similarity index 65% rename from tests/skills/test_rlm.py rename to tests/skills/test_analysis.py index 82cf41af..62e450a5 100644 --- a/tests/skills/test_rlm.py +++ b/tests/skills/test_analysis.py @@ -1,12 +1,12 @@ from unittest.mock import AsyncMock -from haiku.rag.agents.rlm.models import RLMResult +from haiku.rag.agents.analysis.models import AnalysisResult from haiku.rag.client import HaikuRAG from haiku.rag.config.models import AppConfig -from haiku.rag.skills.rlm import ( +from haiku.rag.skills.analysis import ( STATE_NAMESPACE, STATE_TYPE, - RLMState, + AnalysisState, instructions, skill_metadata, state_metadata, @@ -16,24 +16,24 @@ from haiku.skills.models import SkillMetadata, StateMetadata from .conftest import _get_tool, _make_ctx -class TestRLMModuleAPI: - def test_state_type_is_rlm_state(self): - assert STATE_TYPE is RLMState +class TestAnalysisModuleAPI: + def test_state_type_is_analysis_state(self): + assert STATE_TYPE is AnalysisState def test_state_namespace(self): - assert STATE_NAMESPACE == "rlm" + assert STATE_NAMESPACE == "analysis" def test_state_metadata_returns_state_metadata(self): result = state_metadata() assert isinstance(result, StateMetadata) - assert result.namespace == "rlm" - assert result.type is RLMState - assert result.schema == RLMState.model_json_schema() + assert result.namespace == "analysis" + assert result.type is AnalysisState + assert result.schema == AnalysisState.model_json_schema() def test_skill_metadata_returns_skill_metadata(self): result = skill_metadata() assert isinstance(result, SkillMetadata) - assert result.name == "rag-rlm" + assert result.name == "rag-analysis" def test_instructions_returns_string(self): result = instructions() @@ -41,7 +41,7 @@ class TestRLMModuleAPI: assert len(result) > 0 def test_constants_match_create_skill(self, test_app_config, temp_db_path): - from haiku.rag.skills.rlm import create_skill + from haiku.rag.skills.analysis import create_skill skill = create_skill(config=test_app_config, db_path=temp_db_path) assert skill.state_type is STATE_TYPE @@ -50,31 +50,31 @@ class TestRLMModuleAPI: assert skill.instructions == instructions() -class TestRLMSkillCreation: +class TestAnalysisSkillCreation: def test_create_skill_returns_valid_skill(self, test_app_config, temp_db_path): - from haiku.rag.skills.rlm import create_skill + from haiku.rag.skills.analysis import create_skill skill = create_skill(config=test_app_config, db_path=temp_db_path) - assert skill.metadata.name == "rag-rlm" + assert skill.metadata.name == "rag-analysis" assert skill.metadata.description assert skill.instructions def test_create_skill_has_expected_tools(self, test_app_config, temp_db_path): - from haiku.rag.skills.rlm import create_skill + from haiku.rag.skills.analysis import create_skill skill = create_skill(config=test_app_config, db_path=temp_db_path) tool_names = {getattr(t, "__name__") for t in skill.tools if callable(t)} assert tool_names == {"analyze"} def test_create_skill_has_state(self, test_app_config, temp_db_path): - from haiku.rag.skills.rlm import RLMState, create_skill + from haiku.rag.skills.analysis import AnalysisState, create_skill skill = create_skill(config=test_app_config, db_path=temp_db_path) - assert skill._state_type is RLMState - assert skill._state_namespace == "rlm" + assert skill._state_type is AnalysisState + assert skill._state_namespace == "analysis" def test_create_skill_has_extras(self, test_app_config, temp_db_path): - from haiku.rag.skills.rlm import create_skill + from haiku.rag.skills.analysis import create_skill skill = create_skill(config=test_app_config, db_path=temp_db_path) assert skill.extras["config"] is test_app_config @@ -86,22 +86,22 @@ class TestRLMSkillCreation: def test_create_skill_from_env(self, monkeypatch, temp_db_path): monkeypatch.setenv("HAIKU_RAG_DB", str(temp_db_path)) - from haiku.rag.skills.rlm import create_skill + from haiku.rag.skills.analysis import create_skill skill = create_skill() - assert skill.metadata.name == "rag-rlm" + assert skill.metadata.name == "rag-analysis" -class TestDomainPreambleInRLMSkillInstructions: +class TestDomainPreambleInAnalysisSkillInstructions: def test_create_skill_without_domain_preamble(self, test_app_config, temp_db_path): - from haiku.rag.skills.rlm import create_skill, instructions + from haiku.rag.skills.analysis import create_skill, instructions skill = create_skill(config=test_app_config, db_path=temp_db_path) assert skill.instructions == instructions() def test_create_skill_with_domain_preamble(self, temp_db_path): from haiku.rag.config.models import PromptsConfig - from haiku.rag.skills.rlm import create_skill, instructions + from haiku.rag.skills.analysis import create_skill, instructions config = AppConfig( prompts=PromptsConfig( @@ -120,12 +120,12 @@ class TestDomainPreambleInRLMSkillInstructions: class TestAnalyzeTool: async def test_analyze_returns_result(self, rag_db, monkeypatch): - from haiku.rag.skills.rlm import create_skill + from haiku.rag.skills.analysis import create_skill monkeypatch.setattr( HaikuRAG, - "rlm", - AsyncMock(return_value=RLMResult(answer="42", program="print(42)")), + "analyze", + AsyncMock(return_value=AnalysisResult(answer="42", program="print(42)")), ) skill = create_skill(db_path=rag_db) @@ -137,17 +137,17 @@ class TestAnalyzeTool: assert "print(42)" in result async def test_analyze_updates_state(self, rag_db, monkeypatch): - from haiku.rag.skills.rlm import RLMState, create_skill + from haiku.rag.skills.analysis import AnalysisState, create_skill monkeypatch.setattr( HaikuRAG, - "rlm", - AsyncMock(return_value=RLMResult(answer="42", program="print(42)")), + "analyze", + AsyncMock(return_value=AnalysisResult(answer="42", program="print(42)")), ) skill = create_skill(db_path=rag_db) analyze = _get_tool(skill, "analyze") - state = RLMState() + state = AnalysisState() ctx = _make_ctx(state) await analyze(ctx, question="How many documents?") assert len(state.analyses) == 1 @@ -155,42 +155,20 @@ class TestAnalyzeTool: assert state.analyses[0].answer == "42" assert state.analyses[0].program == "print(42)" - async def test_analyze_applies_document_filter_from_state( - self, rag_db, monkeypatch - ): - from haiku.rag.skills.rlm import RLMState, create_skill + async def test_analyze_with_document_filter_in_state(self, rag_db, monkeypatch): + from haiku.rag.skills.analysis import AnalysisState, create_skill captured_kwargs = {} - async def mock_rlm(self, question, **kwargs): + async def mock_analyze(self, question, **kwargs): captured_kwargs.update(kwargs) - return RLMResult(answer="42", program="print(42)") + return AnalysisResult(answer="Result", program="code()") - monkeypatch.setattr(HaikuRAG, "rlm", mock_rlm) + monkeypatch.setattr(HaikuRAG, "analyze", mock_analyze) skill = create_skill(db_path=rag_db) analyze = _get_tool(skill, "analyze") - state = RLMState(document_filter="title = 'AI Overview'") - ctx = _make_ctx(state) - await analyze(ctx, question="How many documents?") - assert captured_kwargs.get("filter") == "title = 'AI Overview'" - - async def test_analyze_combines_state_filter_with_explicit_filter( - self, rag_db, monkeypatch - ): - from haiku.rag.skills.rlm import RLMState, create_skill - - captured_kwargs = {} - - async def mock_rlm(self, question, **kwargs): - captured_kwargs.update(kwargs) - return RLMResult(answer="Result", program="code()") - - monkeypatch.setattr(HaikuRAG, "rlm", mock_rlm) - - skill = create_skill(db_path=rag_db) - analyze = _get_tool(skill, "analyze") - state = RLMState(document_filter="title = 'AI Overview'") + state = AnalysisState(document_filter="title = 'AI Overview'") ctx = _make_ctx(state) await analyze( ctx, @@ -203,15 +181,15 @@ class TestAnalyzeTool: assert "uri LIKE '%test%'" in result_filter async def test_analyze_with_document_and_filter(self, rag_db, monkeypatch): - from haiku.rag.skills.rlm import create_skill + from haiku.rag.skills.analysis import create_skill captured_kwargs = {} - async def mock_rlm(self, question, **kwargs): + async def mock_analyze(self, question, **kwargs): captured_kwargs.update(kwargs) - return RLMResult(answer="Result", program="code()") + return AnalysisResult(answer="Result", program="code()") - monkeypatch.setattr(HaikuRAG, "rlm", mock_rlm) + monkeypatch.setattr(HaikuRAG, "analyze", mock_analyze) skill = create_skill(db_path=rag_db) analyze = _get_tool(skill, "analyze") From 44fd0c9906f9565135b9ed790981cb3e1149e700 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Tue, 14 Apr 2026 11:19:29 +0300 Subject: [PATCH 03/24] add get_context() to analysis sandbox and improve prompt --- CHANGELOG.md | 25 ++++-- docs/agents/analysis.md | 2 +- .../haiku/rag/agents/analysis/agent.py | 4 +- .../haiku/rag/agents/analysis/prompts.py | 51 +++++++----- .../haiku/rag/agents/analysis/sandbox.py | 25 ++---- tests/agents/analysis/test_agent.py | 11 +-- tests/agents/analysis/test_sandbox.py | 43 +++++----- ...t_analyze_search_and_identify_source.yaml} | 0 ..._get_context_returns_expanded_content.yaml | 82 +++++++++++++++++++ .../TestSandboxHaikuRAG.test_get_chunk.yaml | 82 ------------------- tests/skills/test_analysis.py | 24 +++++- 11 files changed, 187 insertions(+), 162 deletions(-) rename tests/cassettes/test_analysis/{TestClientAnalysisIntegration.test_analyze_search_and_get_chunk.yaml => TestClientAnalysisIntegration.test_analyze_search_and_identify_source.yaml} (100%) create mode 100644 tests/cassettes/test_sandbox/TestSandboxGetContext.test_get_context_returns_expanded_content.yaml delete mode 100644 tests/cassettes/test_sandbox/TestSandboxHaikuRAG.test_get_chunk.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 75494c84..583c2faf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,22 @@ # Changelog ## [Unreleased] +### Changed + +- **BREAKING**: Rename RLM agent to analysis agent throughout: + - `agents/rlm/` → `agents/analysis/`, all classes renamed (`RLMResult` → `AnalysisResult`, etc.) + - `client.rlm()` → `client.analyze()` + - CLI: `haiku-rag rlm` → `haiku-rag analyze` + - MCP: `rlm_question` → `analyze` + - Config: `rlm:` → `analysis:` in YAML, `RLMConfig` → `AnalysisConfig` + - Skill: `rag-rlm` → `rag-analysis`, `skills/rlm.py` → `skills/analysis.py` + - State namespace: `"rlm"` → `"analysis"` + +### Removed + +- **`get_chunk()`**: Removed from analysis sandbox +- **`create_analysis_toolset()`**: Removed unused `tools/analysis.py` module. + ## [0.40.1] - 2026-04-17 ### Fixed @@ -21,21 +37,12 @@ - **`max_searches` default**: Raised from 3 to 5 — faster expansion makes additional searches inexpensive - **Improved QA prompt**: Stronger instruction to refuse answering from tangentially related content - **Improved judge prompt**: Asymmetric evaluation — generated answers that are more comprehensive than expected are not penalized -- **BREAKING**: Rename RLM agent to analysis agent throughout: - - `agents/rlm/` → `agents/analysis/`, all classes renamed (`RLMResult` → `AnalysisResult`, etc.) - - `client.rlm()` → `client.analyze()` - - CLI: `haiku-rag rlm` → `haiku-rag analyze` - - MCP: `rlm_question` → `analyze` - - Config: `rlm:` → `analysis:` in YAML, `RLMConfig` → `AnalysisConfig` - - Skill: `rag-rlm` → `rag-analysis`, `skills/rlm.py` → `skills/analysis.py` - - State namespace: `"rlm"` → `"analysis"` ### Removed - **`context_radius` config**: Replaced by automatic section-bounded expansion. Context expansion no longer requires configuration. - **DoclingDocument LRU cache**: No longer needed — the document_items table replaces in-memory caching for context expansion - **`cachetools` dependency**: No longer used -- **`create_analysis_toolset()`**: Removed unused `tools/analysis.py` module. ## [0.39.0] - 2026-04-09 diff --git a/docs/agents/analysis.md b/docs/agents/analysis.md index 80cda9bc..455eb7f9 100644 --- a/docs/agents/analysis.md +++ b/docs/agents/analysis.md @@ -59,9 +59,9 @@ The agent's code runs in a sandboxed Python interpreter ([pydantic-monty](https: | Function | Description | |----------|-------------| | `search(query, limit)` | Hybrid search (vector + full-text) returning matching chunks with scores | +| `get_context(chunk_id)` | Expand a chunk with surrounding content (adjacent paragraphs, complete tables) | | `list_documents(limit, offset)` | List documents in the knowledge base | | `get_document(id_or_title)` | Get full text content of a document | -| `get_chunk(chunk_id)` | Get a chunk with metadata (headings, page numbers, labels) for citations | | `get_docling_document(document_id)` | Get the DoclingDocument structure as a dict (texts, tables, pictures) | | `llm(prompt)` | Call an LLM for classification, summarization, or extraction | diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/agent.py b/haiku_rag_slim/haiku/rag/agents/analysis/agent.py index 5dfb03c2..66d11b4d 100644 --- a/haiku_rag_slim/haiku/rag/agents/analysis/agent.py +++ b/haiku_rag_slim/haiku/rag/agents/analysis/agent.py @@ -34,8 +34,8 @@ def create_analysis_agent(config: AppConfig) -> Agent[AnalysisDeps, AnalysisResu async def execute_code(ctx: RunContext[AnalysisDeps], code: str) -> CodeExecution: """Execute Python code in a sandboxed interpreter. - The code has access to haiku.rag functions (search, list_documents, - get_document, get_chunk, llm). + The code has access to haiku.rag functions (search, get_context, + list_documents, get_document, get_docling_document, llm). Use print() to output results. diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/prompts.py b/haiku_rag_slim/haiku/rag/agents/analysis/prompts.py index bb99626e..cee51c01 100644 --- a/haiku_rag_slim/haiku/rag/agents/analysis/prompts.py +++ b/haiku_rag_slim/haiku/rag/agents/analysis/prompts.py @@ -13,6 +13,10 @@ Inside execute_code, these functions are ALREADY available in the namespace. Do Search the knowledge base using hybrid search (vector + full-text). Returns list of dicts with keys: chunk_id, content, document_id, document_title, document_uri, score, page_numbers, headings +### await get_context(chunk_id) -> str | None +Get expanded content around a chunk, including surrounding paragraphs, complete tables, and adjacent sections from the same document. +Use this after search() when a result looks relevant but you need more context to understand it fully. + ### await list_documents(limit=10, offset=0) -> list[dict] List available documents in the knowledge base. Returns list of dicts with keys: id, title, uri, created_at @@ -21,18 +25,12 @@ Returns list of dicts with keys: id, title, uri, created_at Get the full text content of a document by ID, title, or URI. Returns the document content as a string, or None if not found. -### await get_chunk(chunk_id) -> dict | None -Get a specific chunk by its ID (from search results). -Returns dict with keys: chunk_id, content, document_id, document_title, headings, page_numbers, labels -Use this to retrieve full chunk details and metadata for citation. - ### await get_docling_document(document_id) -> dict | None Get the full document structure as a dict (DoclingDocument format). Use `list_documents()` or search results to get document IDs first. -- `texts`: list of text items, each with `text`, `label` (e.g. "title", "text", "section_header", "list_item"), and `prov` (provenance with page/bounding box) +- `texts`: list of text items, each with `text`, `label` (e.g. "title", "text", "section_header", "list_item") - `tables`: list of tables, each with `data` containing `grid` (list of rows, each row a list of cells with `text`), `num_rows`, `num_cols` - `pictures`: list of figures/images with metadata -- `pages`: page dimensions and metadata ### await llm(prompt) -> str Call an LLM directly with the given prompt. Returns the response as a string. @@ -59,24 +57,26 @@ For pattern matching or text extraction, use `import re`, string methods (`str.s ## Strategy Guide -1. **Explore First**: Start by listing documents or searching to understand what's available. Document `title` is often None — use `uri` or `id` to identify documents instead. -2. **If get_document returns None**: Use `await list_documents()` to see available documents (check `uri` and `id`), or `await search()` to find relevant content. -3. **Iterative Refinement**: Run code, examine results, adjust your approach based on what you find. -4. **Use llm() for Classification/Extraction**: When you need to classify, summarize, or extract structured data from content you already have, use `await llm()`. -5. **Cite Your Sources**: Use get_chunk() to retrieve chunk metadata for citations. Track which documents/chunks informed your answer. +1. **Search First**: Start with `search()` to find relevant content. Examine the results to understand what's available. +2. **Expand When Needed**: If a search result looks relevant but incomplete, use `get_context(chunk_id)` to get surrounding content from the same document. +3. **Use get_document for Full Text**: When you need a document's complete text (e.g., for regex across the whole document), use `get_document(id_or_title)`. +4. **Use get_docling_document for Structure**: When you need structured data like table grids, document hierarchy, or section labels, use `get_docling_document(document_id)`. +5. **Iterate**: Run code, examine results, refine your approach. Don't try to solve everything in one execution. +6. **Use llm() for Reasoning**: When you have content and need classification, summarization, or extraction, use `llm()` rather than writing complex parsing logic. +7. **Document Titles Are Often None**: Use `uri` or `id` to identify documents. Use `list_documents()` to discover what's available. ## Example Patterns -### Counting documents matching a condition +### Search and expand context ```python -docs = await list_documents(limit=100) -count = 0 -for doc in docs: - content = await get_document(doc['id']) - if content and 'keyword' in content.lower(): - count += 1 - print(f"Found in: {doc['title']}") -print(f"Total: {count}") +results = await search("revenue figures", limit=5) +for r in results: + print(f"{r['document_title']}: {r['content'][:100]}") + +# Get more context around the most relevant result +expanded = await get_context(results[0]['chunk_id']) +if expanded: + print(f"Expanded: {expanded[:500]}") ``` ### Extracting data with regex @@ -108,6 +108,15 @@ for d in docs: print(f" Table {i}: {cells}") ``` +### Regex search across a full document +```python +import re +content = await get_document("Policy Document") +if content: + emails = re.findall(r'[\\w.+-]+@[\\w-]+\\.[\\w.]+', content) + print(f"Found {len(emails)} email addresses: {emails}") +``` + ## Output Format Your final response MUST be valid JSON matching this exact schema: diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py b/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py index ddf4e3fb..e0f43547 100644 --- a/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py +++ b/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py @@ -7,6 +7,7 @@ import pydantic_monty from haiku.rag.agents.analysis.dependencies import AnalysisContext from haiku.rag.config.models import AppConfig from haiku.rag.store.compression import decompress_json +from haiku.rag.store.models.chunk import SearchResult if TYPE_CHECKING: from haiku.rag.client import HaikuRAG @@ -88,25 +89,15 @@ class Sandbox: doc = await client.resolve_document(id_or_title) return doc.content if doc else None - async def get_chunk(chunk_id: str) -> dict[str, Any] | None: + async def get_context(chunk_id: str) -> str | None: chunk = await client.get_chunk_by_id(chunk_id) if not chunk: return None - meta = chunk.get_chunk_metadata() - doc_title = chunk.document_title - if not doc_title and chunk.document_id: - doc = await client.get_document_by_id(chunk.document_id) - if doc: - doc_title = doc.title - return { - "chunk_id": chunk.id, - "content": chunk.content, - "document_id": chunk.document_id, - "document_title": doc_title, - "headings": meta.headings, - "page_numbers": meta.page_numbers, - "labels": meta.labels, - } + search_result = SearchResult.from_chunk(chunk, score=1.0) + expanded = await client.expand_context([search_result]) + if expanded: + return expanded[0].content + return chunk.content async def get_docling_document( document_id: str, @@ -131,7 +122,7 @@ class Sandbox: "search": search, "list_documents": list_documents, "get_document": get_document, - "get_chunk": get_chunk, + "get_context": get_context, "get_docling_document": get_docling_document, "llm": llm, } diff --git a/tests/agents/analysis/test_agent.py b/tests/agents/analysis/test_agent.py index 5cfe10ff..98e0eb51 100644 --- a/tests/agents/analysis/test_agent.py +++ b/tests/agents/analysis/test_agent.py @@ -139,17 +139,10 @@ class TestClientAnalysisIntegration: @pytest.mark.asyncio @pytest.mark.vcr() - async def test_analyze_search_and_get_chunk( + async def test_analyze_search_and_identify_source( self, allow_model_requests, temp_db_path ): - """Test analysis agent can search and use get_chunk for citations. - - Agent program: - results = search("content", limit=5) - for r in results: - chunk = get_chunk(r['chunk_id']) - print(chunk['document_title'], chunk['chunk_id']) - """ + """Test analysis agent can search and identify source documents.""" from haiku.rag.client import HaikuRAG config = AppConfig() diff --git a/tests/agents/analysis/test_sandbox.py b/tests/agents/analysis/test_sandbox.py index 12b50508..f40b0f6e 100644 --- a/tests/agents/analysis/test_sandbox.py +++ b/tests/agents/analysis/test_sandbox.py @@ -162,41 +162,44 @@ class TestSandboxHaikuRAG: assert result.success assert "True" in result.stdout + +class TestSandboxGetContext: + """Test get_context() external function.""" + + @pytest.mark.asyncio + async def test_get_context_missing_chunk(self, sandbox): + """get_context returns None for a non-existent chunk.""" + result = await sandbox.execute( + "ctx = await get_context('nonexistent-id')\nprint(ctx is None)" + ) + assert result.success + assert "True" in result.stdout + @pytest.mark.asyncio @pytest.mark.vcr() - async def test_get_chunk(self, temp_db_path): - """Test get_chunk function returns chunk with metadata.""" + async def test_get_context_returns_expanded_content(self, temp_db_path): + """get_context returns content for a valid chunk.""" config = AppConfig() async with HaikuRAG(temp_db_path, create=True) as client: await client.create_document( - content="Content about foxes and dogs.", - uri="test://doc", - title="Fox Document", + content="The quick brown fox jumps over the lazy dog.", + uri="test://animals", + title="Animals", ) context = AnalysisContext() sb = Sandbox(client=client, config=config, context=context) - # First search to get a chunk_id result = await sb.execute( - "results = await search('foxes', limit=1)\n" + "results = await search('fox', limit=1)\n" "chunk_id = results[0]['chunk_id']\n" - "chunk = await get_chunk(chunk_id)\n" - "print(chunk['document_title'])\n" - "print('content' in chunk)" + "ctx = await get_context(chunk_id)\n" + "print(type(ctx).__name__)\n" + "print('fox' in ctx.lower())" ) assert result.success - assert "Fox Document" in result.stdout + assert "str" in result.stdout assert "True" in result.stdout - @pytest.mark.asyncio - async def test_get_chunk_not_found(self, sandbox): - """Test get_chunk returns None for missing chunk.""" - result = await sandbox.execute( - "chunk = await get_chunk('nonexistent-id')\nprint(chunk is None)" - ) - assert result.success - assert "True" in result.stdout - class TestSandboxExternalFunctionEdgeCases: """Test edge cases in external function dispatch.""" diff --git a/tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_search_and_get_chunk.yaml b/tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_search_and_identify_source.yaml similarity index 100% rename from tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_search_and_get_chunk.yaml rename to tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_search_and_identify_source.yaml diff --git a/tests/cassettes/test_sandbox/TestSandboxGetContext.test_get_context_returns_expanded_content.yaml b/tests/cassettes/test_sandbox/TestSandboxGetContext.test_get_context_returns_expanded_content.yaml new file mode 100644 index 00000000..4ca2fa50 --- /dev/null +++ b/tests/cassettes/test_sandbox/TestSandboxGetContext.test_get_context_returns_expanded_content.yaml @@ -0,0 +1,82 @@ +interactions: +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '114' + content-type: + - application/json + host: + - localhost:11434 + method: POST + parsed_body: + encoding_format: base64 + input: + - The quick brown fox jumps over the lazy dog. + model: qwen3-embedding:4b + uri: http://localhost:11434/v1/embeddings + response: + headers: + content-type: + - application/json + transfer-encoding: + - chunked + parsed_body: + data: + - embedding: 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 + index: 0 + object: embedding + model: qwen3-embedding:4b + object: list + usage: + prompt_tokens: 11 + total_tokens: 11 + status: + code: 200 + message: OK +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '73' + content-type: + - application/json + host: + - localhost:11434 + method: POST + parsed_body: + encoding_format: base64 + input: + - fox + model: qwen3-embedding:4b + uri: http://localhost:11434/v1/embeddings + response: + headers: + content-type: + - application/json + transfer-encoding: + - chunked + parsed_body: + data: + - embedding: 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 + index: 0 + object: embedding + model: qwen3-embedding:4b + object: list + usage: + prompt_tokens: 2 + total_tokens: 2 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/cassettes/test_sandbox/TestSandboxHaikuRAG.test_get_chunk.yaml b/tests/cassettes/test_sandbox/TestSandboxHaikuRAG.test_get_chunk.yaml deleted file mode 100644 index 6977ce26..00000000 --- a/tests/cassettes/test_sandbox/TestSandboxHaikuRAG.test_get_chunk.yaml +++ /dev/null @@ -1,82 +0,0 @@ -interactions: -- request: - headers: - accept: - - application/json - accept-encoding: - - gzip, deflate, zstd - connection: - - keep-alive - content-length: - - '99' - content-type: - - application/json - host: - - localhost:11434 - method: POST - parsed_body: - encoding_format: base64 - input: - - Content about foxes and dogs. - model: qwen3-embedding:4b - uri: http://localhost:11434/v1/embeddings - response: - headers: - content-type: - - application/json - transfer-encoding: - - chunked - parsed_body: - data: - - embedding: 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 - index: 0 - object: embedding - model: qwen3-embedding:4b - object: list - usage: - prompt_tokens: 8 - total_tokens: 8 - status: - code: 200 - message: OK -- request: - headers: - accept: - - application/json - accept-encoding: - - gzip, deflate, zstd - connection: - - keep-alive - content-length: - - '75' - content-type: - - application/json - host: - - localhost:11434 - method: POST - parsed_body: - encoding_format: base64 - input: - - foxes - model: qwen3-embedding:4b - uri: http://localhost:11434/v1/embeddings - response: - headers: - content-type: - - application/json - transfer-encoding: - - chunked - parsed_body: - data: - - embedding: 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 - index: 0 - object: embedding - model: qwen3-embedding:4b - object: list - usage: - prompt_tokens: 3 - total_tokens: 3 - status: - code: 200 - message: OK -version: 1 diff --git a/tests/skills/test_analysis.py b/tests/skills/test_analysis.py index 62e450a5..f647abc4 100644 --- a/tests/skills/test_analysis.py +++ b/tests/skills/test_analysis.py @@ -155,7 +155,29 @@ class TestAnalyzeTool: assert state.analyses[0].answer == "42" assert state.analyses[0].program == "print(42)" - async def test_analyze_with_document_filter_in_state(self, rag_db, monkeypatch): + async def test_analyze_applies_document_filter_from_state( + self, rag_db, monkeypatch + ): + from haiku.rag.skills.analysis import AnalysisState, create_skill + + captured_kwargs = {} + + async def mock_analyze(self, question, **kwargs): + captured_kwargs.update(kwargs) + return AnalysisResult(answer="42", program="print(42)") + + monkeypatch.setattr(HaikuRAG, "analyze", mock_analyze) + + skill = create_skill(db_path=rag_db) + analyze = _get_tool(skill, "analyze") + state = AnalysisState(document_filter="title = 'AI Overview'") + ctx = _make_ctx(state) + await analyze(ctx, question="How many documents?") + assert captured_kwargs.get("filter") == "title = 'AI Overview'" + + async def test_analyze_combines_state_filter_with_explicit_filter( + self, rag_db, monkeypatch + ): from haiku.rag.skills.analysis import AnalysisState, create_skill captured_kwargs = {} From 4118533db195aab072667b1065befe4ab00468d3 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Thu, 16 Apr 2026 13:59:56 +0300 Subject: [PATCH 04/24] fold context expansion into sandbox search and remove get_context --- CHANGELOG.md | 4 ++- docs/agents/analysis.md | 3 +- .../haiku/rag/agents/analysis/agent.py | 2 +- .../haiku/rag/agents/analysis/prompts.py | 28 +++++++------------ .../haiku/rag/agents/analysis/sandbox.py | 15 ++-------- tests/agents/analysis/test_sandbox.py | 25 +++++++---------- ...test_search_returns_expanded_content.yaml} | 0 7 files changed, 27 insertions(+), 50 deletions(-) rename tests/cassettes/test_sandbox/{TestSandboxGetContext.test_get_context_returns_expanded_content.yaml => TestSandboxSearchExpandsContext.test_search_returns_expanded_content.yaml} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 583c2faf..2fc6327c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ### Changed +- **Analysis sandbox `search()` now returns expanded results**: Search results automatically include surrounding context (adjacent paragraphs, complete tables, section content) via the document_items table + - **BREAKING**: Rename RLM agent to analysis agent throughout: - `agents/rlm/` → `agents/analysis/`, all classes renamed (`RLMResult` → `AnalysisResult`, etc.) - `client.rlm()` → `client.analyze()` @@ -14,7 +16,7 @@ ### Removed -- **`get_chunk()`**: Removed from analysis sandbox +- **`get_chunk()`**: Removed from analysis sandbox — search results now include expanded context automatically - **`create_analysis_toolset()`**: Removed unused `tools/analysis.py` module. ## [0.40.1] - 2026-04-17 diff --git a/docs/agents/analysis.md b/docs/agents/analysis.md index 455eb7f9..333e44d0 100644 --- a/docs/agents/analysis.md +++ b/docs/agents/analysis.md @@ -58,8 +58,7 @@ The agent's code runs in a sandboxed Python interpreter ([pydantic-monty](https: | Function | Description | |----------|-------------| -| `search(query, limit)` | Hybrid search (vector + full-text) returning matching chunks with scores | -| `get_context(chunk_id)` | Expand a chunk with surrounding content (adjacent paragraphs, complete tables) | +| `search(query, limit)` | Hybrid search (vector + full-text) with automatic context expansion | | `list_documents(limit, offset)` | List documents in the knowledge base | | `get_document(id_or_title)` | Get full text content of a document | | `get_docling_document(document_id)` | Get the DoclingDocument structure as a dict (texts, tables, pictures) | diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/agent.py b/haiku_rag_slim/haiku/rag/agents/analysis/agent.py index 66d11b4d..2662fca5 100644 --- a/haiku_rag_slim/haiku/rag/agents/analysis/agent.py +++ b/haiku_rag_slim/haiku/rag/agents/analysis/agent.py @@ -34,7 +34,7 @@ def create_analysis_agent(config: AppConfig) -> Agent[AnalysisDeps, AnalysisResu async def execute_code(ctx: RunContext[AnalysisDeps], code: str) -> CodeExecution: """Execute Python code in a sandboxed interpreter. - The code has access to haiku.rag functions (search, get_context, + The code has access to haiku.rag functions (search, list_documents, get_document, get_docling_document, llm). Use print() to output results. diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/prompts.py b/haiku_rag_slim/haiku/rag/agents/analysis/prompts.py index cee51c01..00ca88cd 100644 --- a/haiku_rag_slim/haiku/rag/agents/analysis/prompts.py +++ b/haiku_rag_slim/haiku/rag/agents/analysis/prompts.py @@ -11,12 +11,9 @@ Inside execute_code, these functions are ALREADY available in the namespace. Do ### await search(query, limit=10) -> list[dict] Search the knowledge base using hybrid search (vector + full-text). +Results are automatically expanded with surrounding context (adjacent paragraphs, complete tables, section content). Returns list of dicts with keys: chunk_id, content, document_id, document_title, document_uri, score, page_numbers, headings -### await get_context(chunk_id) -> str | None -Get expanded content around a chunk, including surrounding paragraphs, complete tables, and adjacent sections from the same document. -Use this after search() when a result looks relevant but you need more context to understand it fully. - ### await list_documents(limit=10, offset=0) -> list[dict] List available documents in the knowledge base. Returns list of dicts with keys: id, title, uri, created_at @@ -57,26 +54,21 @@ For pattern matching or text extraction, use `import re`, string methods (`str.s ## Strategy Guide -1. **Search First**: Start with `search()` to find relevant content. Examine the results to understand what's available. -2. **Expand When Needed**: If a search result looks relevant but incomplete, use `get_context(chunk_id)` to get surrounding content from the same document. -3. **Use get_document for Full Text**: When you need a document's complete text (e.g., for regex across the whole document), use `get_document(id_or_title)`. -4. **Use get_docling_document for Structure**: When you need structured data like table grids, document hierarchy, or section labels, use `get_docling_document(document_id)`. -5. **Iterate**: Run code, examine results, refine your approach. Don't try to solve everything in one execution. -6. **Use llm() for Reasoning**: When you have content and need classification, summarization, or extraction, use `llm()` rather than writing complex parsing logic. -7. **Document Titles Are Often None**: Use `uri` or `id` to identify documents. Use `list_documents()` to discover what's available. +1. **Search First**: Start with `search()` to find relevant content. Results already include expanded context (surrounding paragraphs, complete tables, section content). +2. **Use get_document for Full Text**: When you need a document's complete text (e.g., for regex across the whole document), use `get_document(id_or_title)`. +3. **Use get_docling_document for Structure**: When you need structured data like table grids, document hierarchy, or section labels, use `get_docling_document(document_id)`. +4. **Iterate**: Run code, examine results, refine your approach. Don't try to solve everything in one execution. +5. **Use llm() for Reasoning**: When you have content and need classification, summarization, or extraction, use `llm()` rather than writing complex parsing logic. +6. **Document Titles Are Often None**: Use `uri` or `id` to identify documents. Use `list_documents()` to discover what's available. ## Example Patterns -### Search and expand context +### Search (results include expanded context) ```python results = await search("revenue figures", limit=5) for r in results: - print(f"{r['document_title']}: {r['content'][:100]}") - -# Get more context around the most relevant result -expanded = await get_context(results[0]['chunk_id']) -if expanded: - print(f"Expanded: {expanded[:500]}") + print(f"{r['document_title']} (score={r['score']:.2f}):") + print(r['content'][:200]) ``` ### Extracting data with regex diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py b/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py index e0f43547..3f5ca223 100644 --- a/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py +++ b/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py @@ -7,7 +7,6 @@ import pydantic_monty from haiku.rag.agents.analysis.dependencies import AnalysisContext from haiku.rag.config.models import AppConfig from haiku.rag.store.compression import decompress_json -from haiku.rag.store.models.chunk import SearchResult if TYPE_CHECKING: from haiku.rag.client import HaikuRAG @@ -55,6 +54,7 @@ class Sandbox: async def search(query: str, limit: int = 10) -> list[dict[str, Any]]: results = await client.search(query, limit=limit, filter=context.filter) + expanded = await client.expand_context(results) return [ { "chunk_id": r.chunk_id, @@ -66,7 +66,7 @@ class Sandbox: "page_numbers": r.page_numbers, "headings": r.headings, } - for r in results + for r in expanded ] async def list_documents( @@ -89,16 +89,6 @@ class Sandbox: doc = await client.resolve_document(id_or_title) return doc.content if doc else None - async def get_context(chunk_id: str) -> str | None: - chunk = await client.get_chunk_by_id(chunk_id) - if not chunk: - return None - search_result = SearchResult.from_chunk(chunk, score=1.0) - expanded = await client.expand_context([search_result]) - if expanded: - return expanded[0].content - return chunk.content - async def get_docling_document( document_id: str, ) -> dict[str, Any] | None: @@ -122,7 +112,6 @@ class Sandbox: "search": search, "list_documents": list_documents, "get_document": get_document, - "get_context": get_context, "get_docling_document": get_docling_document, "llm": llm, } diff --git a/tests/agents/analysis/test_sandbox.py b/tests/agents/analysis/test_sandbox.py index f40b0f6e..2e975f1f 100644 --- a/tests/agents/analysis/test_sandbox.py +++ b/tests/agents/analysis/test_sandbox.py @@ -163,22 +163,19 @@ class TestSandboxHaikuRAG: assert "True" in result.stdout -class TestSandboxGetContext: - """Test get_context() external function.""" +class TestSandboxSearchExpandsContext: + """Test that search() returns expanded results.""" @pytest.mark.asyncio - async def test_get_context_missing_chunk(self, sandbox): - """get_context returns None for a non-existent chunk.""" - result = await sandbox.execute( - "ctx = await get_context('nonexistent-id')\nprint(ctx is None)" - ) - assert result.success - assert "True" in result.stdout + async def test_get_context_not_available(self, sandbox): + """get_context is no longer a sandbox function.""" + result = await sandbox.execute("await get_context('x')") + assert not result.success @pytest.mark.asyncio @pytest.mark.vcr() - async def test_get_context_returns_expanded_content(self, temp_db_path): - """get_context returns content for a valid chunk.""" + async def test_search_returns_expanded_content(self, temp_db_path): + """search() returns context-expanded results.""" config = AppConfig() async with HaikuRAG(temp_db_path, create=True) as client: await client.create_document( @@ -191,10 +188,8 @@ class TestSandboxGetContext: sb = Sandbox(client=client, config=config, context=context) result = await sb.execute( "results = await search('fox', limit=1)\n" - "chunk_id = results[0]['chunk_id']\n" - "ctx = await get_context(chunk_id)\n" - "print(type(ctx).__name__)\n" - "print('fox' in ctx.lower())" + "print(type(results[0]['content']).__name__)\n" + "print('fox' in results[0]['content'].lower())" ) assert result.success assert "str" in result.stdout diff --git a/tests/cassettes/test_sandbox/TestSandboxGetContext.test_get_context_returns_expanded_content.yaml b/tests/cassettes/test_sandbox/TestSandboxSearchExpandsContext.test_search_returns_expanded_content.yaml similarity index 100% rename from tests/cassettes/test_sandbox/TestSandboxGetContext.test_get_context_returns_expanded_content.yaml rename to tests/cassettes/test_sandbox/TestSandboxSearchExpandsContext.test_search_returns_expanded_content.yaml From bacc21b38bb30fc36c7cf24889a61596445dd65f Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Thu, 16 Apr 2026 14:53:29 +0300 Subject: [PATCH 05/24] expose doc_item_refs and labels in sandbox search results --- .../haiku/rag/agents/analysis/sandbox.py | 2 + tests/agents/analysis/test_sandbox.py | 26 ++++++ ...arch_returns_doc_item_refs_and_labels.yaml | 82 +++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 tests/cassettes/test_sandbox/TestSandboxHaikuRAG.test_search_returns_doc_item_refs_and_labels.yaml diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py b/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py index 3f5ca223..9d8cb40d 100644 --- a/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py +++ b/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py @@ -65,6 +65,8 @@ class Sandbox: "score": r.score, "page_numbers": r.page_numbers, "headings": r.headings, + "doc_item_refs": r.doc_item_refs, + "labels": r.labels, } for r in expanded ] diff --git a/tests/agents/analysis/test_sandbox.py b/tests/agents/analysis/test_sandbox.py index 2e975f1f..30c627de 100644 --- a/tests/agents/analysis/test_sandbox.py +++ b/tests/agents/analysis/test_sandbox.py @@ -132,6 +132,32 @@ class TestSandboxHaikuRAG: assert result.success assert "True" in result.stdout or "1" in result.stdout + @pytest.mark.asyncio + @pytest.mark.vcr() + async def test_search_returns_doc_item_refs_and_labels(self, temp_db_path): + """Search results include doc_item_refs and labels.""" + config = AppConfig() + async with HaikuRAG(temp_db_path, create=True) as client: + await client.create_document( + content="The quick brown fox jumps over the lazy dog.", + uri="test://animals", + title="Animals", + ) + + context = AnalysisContext() + sb = Sandbox(client=client, config=config, context=context) + result = await sb.execute( + "results = await search('fox', limit=1)\n" + "r = results[0]\n" + "print('doc_item_refs' in r)\n" + "print('labels' in r)\n" + "print(type(r['doc_item_refs']).__name__)\n" + "print(type(r['labels']).__name__)" + ) + assert result.success + assert "True\nTrue" in result.stdout + assert "list\nlist" in result.stdout + @pytest.mark.asyncio @pytest.mark.vcr() async def test_get_document(self, temp_db_path): diff --git a/tests/cassettes/test_sandbox/TestSandboxHaikuRAG.test_search_returns_doc_item_refs_and_labels.yaml b/tests/cassettes/test_sandbox/TestSandboxHaikuRAG.test_search_returns_doc_item_refs_and_labels.yaml new file mode 100644 index 00000000..4ca2fa50 --- /dev/null +++ b/tests/cassettes/test_sandbox/TestSandboxHaikuRAG.test_search_returns_doc_item_refs_and_labels.yaml @@ -0,0 +1,82 @@ +interactions: +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '114' + content-type: + - application/json + host: + - localhost:11434 + method: POST + parsed_body: + encoding_format: base64 + input: + - The quick brown fox jumps over the lazy dog. + model: qwen3-embedding:4b + uri: http://localhost:11434/v1/embeddings + response: + headers: + content-type: + - application/json + transfer-encoding: + - chunked + parsed_body: + data: + - embedding: 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 + index: 0 + object: embedding + model: qwen3-embedding:4b + object: list + usage: + prompt_tokens: 11 + total_tokens: 11 + status: + code: 200 + message: OK +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '73' + content-type: + - application/json + host: + - localhost:11434 + method: POST + parsed_body: + encoding_format: base64 + input: + - fox + model: qwen3-embedding:4b + uri: http://localhost:11434/v1/embeddings + response: + headers: + content-type: + - application/json + transfer-encoding: + - chunked + parsed_body: + data: + - embedding: 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 + index: 0 + object: embedding + model: qwen3-embedding:4b + object: list + usage: + prompt_tokens: 2 + total_tokens: 2 + status: + code: 200 + message: OK +version: 1 From a45820dbf7bcc6639af6760143f7345ab02e45d9 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Thu, 16 Apr 2026 15:29:53 +0300 Subject: [PATCH 06/24] add document virtual filesystem to analysis sandbox Replace get_document() and get_docling_document() with a VFS at /documents/{id}/ with metadata.json (eager), content.txt (lazy), and items.jsonl (lazy). Keep search(), list_documents() (now returns all), and llm() as external functions. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 7 +- docs/agents/analysis.md | 41 +++- .../haiku/rag/agents/analysis/agent.py | 4 +- .../haiku/rag/agents/analysis/prompts.py | 153 ++++++------ .../haiku/rag/agents/analysis/sandbox.py | 141 +++++++++-- tests/agents/analysis/test_sandbox.py | 218 +++++++++++------- ...uments.test_list_documents_with_data.yaml} | 0 ...rch_returns_doc_item_refs_and_labels.yaml} | 0 ...test_search_returns_expanded_content.yaml} | 0 ...tSandboxSearch.test_search_with_data.yaml} | 0 ...l => TestSandboxVFS.test_content_txt.yaml} | 0 ...oxVFS.test_context_filter_limits_vfs.yaml} | 0 .../TestSandboxVFS.test_items_jsonl.yaml | 82 +++++++ ...xVFS.test_iterdir_discovers_documents.yaml | 42 ++++ .../TestSandboxVFS.test_metadata_json.yaml | 42 ++++ 15 files changed, 527 insertions(+), 203 deletions(-) rename tests/cassettes/test_sandbox/{TestSandboxHaikuRAG.test_list_documents_with_data.yaml => TestSandboxListDocuments.test_list_documents_with_data.yaml} (100%) rename tests/cassettes/test_sandbox/{TestSandboxHaikuRAG.test_search_returns_doc_item_refs_and_labels.yaml => TestSandboxSearch.test_search_returns_doc_item_refs_and_labels.yaml} (100%) rename tests/cassettes/test_sandbox/{TestSandboxHaikuRAG.test_search_with_data.yaml => TestSandboxSearch.test_search_returns_expanded_content.yaml} (100%) rename tests/cassettes/test_sandbox/{TestSandboxSearchExpandsContext.test_search_returns_expanded_content.yaml => TestSandboxSearch.test_search_with_data.yaml} (100%) rename tests/cassettes/test_sandbox/{TestSandboxHaikuRAG.test_get_document.yaml => TestSandboxVFS.test_content_txt.yaml} (100%) rename tests/cassettes/test_sandbox/{TestSandboxContextFilter.test_filter_applied_to_list_documents.yaml => TestSandboxVFS.test_context_filter_limits_vfs.yaml} (100%) create mode 100644 tests/cassettes/test_sandbox/TestSandboxVFS.test_items_jsonl.yaml create mode 100644 tests/cassettes/test_sandbox/TestSandboxVFS.test_iterdir_discovers_documents.yaml create mode 100644 tests/cassettes/test_sandbox/TestSandboxVFS.test_metadata_json.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fc6327c..51c3e281 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,14 @@ # Changelog ## [Unreleased] +### Added + +- **Document virtual filesystem in analysis sandbox**: Documents are mounted at `/documents/{id}/` with `metadata.json` (eager), `content.txt` (lazy), and `items.jsonl` (lazy). The agent uses standard Python `pathlib.Path` to browse and read document content and structure. +- **`doc_item_refs` and `labels` in search results**: Search results now include document item references and labels for cross-referencing with `items.jsonl`. + ### Changed - **Analysis sandbox `search()` now returns expanded results**: Search results automatically include surrounding context (adjacent paragraphs, complete tables, section content) via the document_items table - - **BREAKING**: Rename RLM agent to analysis agent throughout: - `agents/rlm/` → `agents/analysis/`, all classes renamed (`RLMResult` → `AnalysisResult`, etc.) - `client.rlm()` → `client.analyze()` @@ -16,6 +20,7 @@ ### Removed +- **`get_document()`, `get_docling_document()`**: Removed from analysis sandbox — replaced by the document virtual filesystem - **`get_chunk()`**: Removed from analysis sandbox — search results now include expanded context automatically - **`create_analysis_toolset()`**: Removed unused `tools/analysis.py` module. diff --git a/docs/agents/analysis.md b/docs/agents/analysis.md index 333e44d0..5f04f7f7 100644 --- a/docs/agents/analysis.md +++ b/docs/agents/analysis.md @@ -11,7 +11,7 @@ The analysis agent enables complex analytical tasks by writing and executing Pyt 1. The agent receives a question 2. It writes Python code to explore the knowledge base -3. Code executes in a sandboxed Python interpreter with access to knowledge base functions +3. Code executes in a sandboxed Python interpreter with access to search, LLM, and a virtual filesystem of documents 4. The agent iterates: run code, examine results, refine approach 5. Final answer is synthesized from the gathered data @@ -54,37 +54,54 @@ async with HaikuRAG(path_to_db) as client: ## Sandbox Capabilities -The agent's code runs in a sandboxed Python interpreter ([pydantic-monty](https://github.com/pydantic/monty)) with access to these knowledge base functions: +The agent's code runs in a sandboxed Python interpreter ([pydantic-monty](https://github.com/pydantic/monty)) with: + +### Functions | Function | Description | |----------|-------------| -| `search(query, limit)` | Hybrid search (vector + full-text) with automatic context expansion | -| `list_documents(limit, offset)` | List documents in the knowledge base | -| `get_document(id_or_title)` | Get full text content of a document | -| `get_docling_document(document_id)` | Get the DoclingDocument structure as a dict (texts, tables, pictures) | +| `search(query, limit)` | Hybrid search (vector + full-text) with automatic context expansion. Returns `doc_item_refs` for cross-referencing with `items.jsonl` | +| `list_documents()` | List all documents in the knowledge base | | `llm(prompt)` | Call an LLM for classification, summarization, or extraction | -When documents are pre-loaded via the `documents` parameter, they are injected as a `documents` variable accessible in the sandbox code. +### Document Filesystem + +All documents are mounted as a virtual filesystem at `/documents/`. The agent uses standard Python `pathlib.Path` to browse and read files: + +``` +/documents/{document_id}/ + metadata.json # {id, title, uri, created_at} + content.txt # Full document text + items.jsonl # Structured items: position, self_ref, label, text, page_numbers +``` + +- **`metadata.json`** — Loaded eagerly (small). Use `Path('/documents').iterdir()` to discover documents. +- **`content.txt`** — Lazy-loaded on first read. Full document text for regex or keyword search. +- **`items.jsonl`** — Lazy-loaded on first read. One JSON object per line with structured document elements. Tables are pre-rendered as markdown. Labels include `section_header`, `text`, `table`, `list_item`, `caption`, `formula`, `picture`, `code`, `footnote`, etc. + +Search results include `doc_item_refs` (e.g. `["#/texts/5", "#/tables/0"]`) that match `self_ref` values in `items.jsonl`, enabling navigation from search hits to document structure. + +When documents are pre-loaded via the `documents` parameter, they are also injected as a `documents` variable accessible in the sandbox code. ### Python Features -The interpreter supports a subset of Python: variables, arithmetic, strings, f-strings, lists, dicts, tuples, sets, loops, conditionals, comprehensions, functions, async/await, `filter()`, `getattr()`, try/except, and the `json`, `re`, `math` modules. +The interpreter supports a subset of Python: variables, arithmetic, strings, f-strings, lists, dicts, tuples, sets, loops, conditionals, comprehensions, functions, async/await, `filter()`, `getattr()`, try/except, file I/O via `pathlib.Path`, and the `json`, `re`, `math` modules. -Not supported: most imports (only `json`, `re`, `math` are available), class definitions, generators/yield, match statements, decorators, `with` statements. For pattern matching, the agent can use `import re`, string methods, or the `llm()` function. +Not supported: most imports (only `json`, `re`, `math`, `pathlib` are available), class definitions, generators/yield, match statements, decorators, `with` statements. For pattern matching, the agent can use `import re`, string methods, or the `llm()` function. ### Security Code executes in an isolated interpreter with: -- **No filesystem access**: Code cannot read or write files +- **Virtual filesystem only**: The `/documents/` filesystem is sandboxed — no access to the real filesystem - **No network access**: Code cannot make HTTP requests or open sockets -- **No imports**: Only `json`, `re`, and `math` modules are available +- **No imports**: Only `json`, `re`, `math`, and `pathlib` modules are available - **Execution timeout**: Configurable limit (default 60s) - **Output truncation**: Large outputs are truncated to prevent memory issues ## Context Filter -The `filter` parameter restricts what documents the agent can access. Unlike tool parameters, the filter is applied automatically and cannot be bypassed by the LLM: +The `filter` parameter restricts what documents the agent can access. Unlike tool parameters, the filter is applied automatically and cannot be bypassed by the LLM — both the VFS and search results are scoped to the filter: ```python # Agent can only see documents with "confidential" in the URI diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/agent.py b/haiku_rag_slim/haiku/rag/agents/analysis/agent.py index 2662fca5..76ef9140 100644 --- a/haiku_rag_slim/haiku/rag/agents/analysis/agent.py +++ b/haiku_rag_slim/haiku/rag/agents/analysis/agent.py @@ -34,8 +34,8 @@ def create_analysis_agent(config: AppConfig) -> Agent[AnalysisDeps, AnalysisResu async def execute_code(ctx: RunContext[AnalysisDeps], code: str) -> CodeExecution: """Execute Python code in a sandboxed interpreter. - The code has access to haiku.rag functions (search, - list_documents, get_document, get_docling_document, llm). + The code has access to search() and llm() functions, and a + virtual filesystem at /documents/ with document content and structure. Use print() to output results. diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/prompts.py b/haiku_rag_slim/haiku/rag/agents/analysis/prompts.py index 00ca88cd..de4a253b 100644 --- a/haiku_rag_slim/haiku/rag/agents/analysis/prompts.py +++ b/haiku_rag_slim/haiku/rag/agents/analysis/prompts.py @@ -1,39 +1,92 @@ ANALYSIS_SYSTEM_PROMPT = """You are an analysis agent that solves complex research questions by writing and executing Python code. -You MUST use the `execute_code` tool to run Python code. The functions described below are ONLY available inside execute_code. Always execute code to answer questions; do not just describe what code would do. +You MUST use the `execute_code` tool to run Python code. The functions and filesystem described below are ONLY available inside execute_code. Always execute code to answer questions; do not just describe what code would do. + +## Available Functions Inside execute_code, these functions are ALREADY available in the namespace. Do NOT import them - just call them with `await`: - results = await search("query") ✓ CORRECT - import search ✗ WRONG - will fail - results = search("query") ✗ WRONG - must use await -## Available Functions - ### await search(query, limit=10) -> list[dict] Search the knowledge base using hybrid search (vector + full-text). Results are automatically expanded with surrounding context (adjacent paragraphs, complete tables, section content). -Returns list of dicts with keys: chunk_id, content, document_id, document_title, document_uri, score, page_numbers, headings +Returns list of dicts with keys: chunk_id, content, document_id, document_title, document_uri, score, page_numbers, headings, doc_item_refs, labels -### await list_documents(limit=10, offset=0) -> list[dict] -List available documents in the knowledge base. +### await list_documents() -> list[dict] +List all documents in the knowledge base. Returns list of dicts with keys: id, title, uri, created_at -### await get_document(id_or_title) -> str | None -Get the full text content of a document by ID, title, or URI. -Returns the document content as a string, or None if not found. - -### await get_docling_document(document_id) -> dict | None -Get the full document structure as a dict (DoclingDocument format). -Use `list_documents()` or search results to get document IDs first. -- `texts`: list of text items, each with `text`, `label` (e.g. "title", "text", "section_header", "list_item") -- `tables`: list of tables, each with `data` containing `grid` (list of rows, each row a list of cells with `text`), `num_rows`, `num_cols` -- `pictures`: list of figures/images with metadata - ### await llm(prompt) -> str Call an LLM directly with the given prompt. Returns the response as a string. Use this for classification, summarization, extraction, or any task where you already have the content and just need LLM reasoning. +## Document Filesystem + +All documents in the knowledge base are available as files under `/documents/`. Use `from pathlib import Path` and standard file I/O to access them. + +### Directory structure +``` +/documents/ + {document_id}/ + metadata.json # {"id", "title", "uri", "created_at"} + content.txt # Full document text + items.jsonl # Structured document items (one JSON object per line) +``` + +### metadata.json +Small file with document metadata. Use to discover and identify documents. +```python +from pathlib import Path +import json +for doc_dir in Path('/documents').iterdir(): + meta = json.loads((doc_dir / 'metadata.json').read_text()) + print(meta['title'], meta['uri']) +``` + +### content.txt +Full text content of the document. Use for regex, keyword search, or full-text analysis. +```python +content = Path(f'/documents/{doc_id}/content.txt').read_text() +``` + +### items.jsonl +Structured document items as JSONL. Each line is a JSON object with: +- `position`: sequential position in the document +- `self_ref`: item reference (e.g. "#/texts/5", "#/tables/0") +- `label`: item type — "section_header", "text", "table", "list_item", "caption", "formula", "picture", "code", "footnote", etc. +- `text`: rendered content (tables are markdown with `|` columns) +- `page_numbers`: list of page numbers where the item appears + +Use items.jsonl to find tables, section headers, or specific structural elements: +```python +import json +items_text = Path(f'/documents/{doc_id}/items.jsonl').read_text() +for line in items_text.strip().split(chr(10)): + item = json.loads(line) + if item['label'] == 'table': + print(f"Table on page {item['page_numbers']}: {item['text'][:100]}") +``` + +## Cross-referencing search results with items + +Search results include `doc_item_refs` (e.g. `["#/texts/48", "#/tables/0"]`) that correspond to `self_ref` values in items.jsonl. Use this to navigate from a search hit to the surrounding document structure: +```python +results = await search("revenue", limit=5) +r = results[0] +doc_id = r['document_id'] +refs = set(r['doc_item_refs']) + +import json +items_text = Path(f'/documents/{doc_id}/items.jsonl').read_text() +for line in items_text.strip().split(chr(10)): + item = json.loads(line) + if item['self_ref'] in refs: + print(f"Matched: {item['label']} on page {item['page_numbers']}") +``` + ## Pre-loaded Documents Variable If documents were pre-loaded for this session, a `documents` variable is available: @@ -46,68 +99,18 @@ Check if it exists with: `try: documents ... except NameError: ...` ## Available Python Features -The interpreter supports: variables, arithmetic, strings, f-strings, lists, dicts, tuples, sets, loops, conditionals, comprehensions, functions, async/await, `map()`, `filter()`, `getattr()`, `sorted()`/`.sort(key=...)`, try/except, and the `json`, `re`, `math` modules. +The interpreter supports: variables, arithmetic, strings, f-strings, lists, dicts, tuples, sets, loops, conditionals, comprehensions, functions, async/await, `map()`, `filter()`, `getattr()`, `sorted()`/`.sort(key=...)`, try/except, and the `json`, `re`, `math` modules. File I/O via `pathlib.Path` is supported for the `/documents/` filesystem. -Not supported: most imports (only `json`, `re`, `math` are available), class definitions, generators/yield, match statements, decorators, `with` statements. - -For pattern matching or text extraction, use `import re`, string methods (`str.split`, `str.find`, `str.startswith`, `in` operator), or the `llm()` function. +Not supported: most imports (only `json`, `re`, `math`, `pathlib` are available), class definitions, generators/yield, match statements, decorators, `with` statements. ## Strategy Guide -1. **Search First**: Start with `search()` to find relevant content. Results already include expanded context (surrounding paragraphs, complete tables, section content). -2. **Use get_document for Full Text**: When you need a document's complete text (e.g., for regex across the whole document), use `get_document(id_or_title)`. -3. **Use get_docling_document for Structure**: When you need structured data like table grids, document hierarchy, or section labels, use `get_docling_document(document_id)`. -4. **Iterate**: Run code, examine results, refine your approach. Don't try to solve everything in one execution. -5. **Use llm() for Reasoning**: When you have content and need classification, summarization, or extraction, use `llm()` rather than writing complex parsing logic. -6. **Document Titles Are Often None**: Use `uri` or `id` to identify documents. Use `list_documents()` to discover what's available. - -## Example Patterns - -### Search (results include expanded context) -```python -results = await search("revenue figures", limit=5) -for r in results: - print(f"{r['document_title']} (score={r['score']:.2f}):") - print(r['content'][:200]) -``` - -### Extracting data with regex -```python -import re -numbers = [] -results = await search("financial data", limit=20) -for r in results: - amounts = re.findall(r'\\$([\\d,]+)', r['content']) - for a in amounts: - numbers.append(int(a.replace(',', ''))) -if numbers: - print(f"Average: {sum(numbers) / len(numbers)}") -``` - -### Extracting tables from a document -```python -docs = await list_documents(limit=10) -for d in docs: - doc = await get_docling_document(d['id']) - if doc: - tables = doc.get('tables', []) - if tables: - print(f"{d['title']}: {len(tables)} table(s)") - for i, table in enumerate(tables): - grid = table.get('data', {}).get('grid', []) - for row in grid: - cells = [cell.get('text', '') for cell in row] - print(f" Table {i}: {cells}") -``` - -### Regex search across a full document -```python -import re -content = await get_document("Policy Document") -if content: - emails = re.findall(r'[\\w.+-]+@[\\w-]+\\.[\\w.]+', content) - print(f"Found {len(emails)} email addresses: {emails}") -``` +1. **Search First**: Start with `search()` to find relevant content. Results include expanded context and `doc_item_refs` for cross-referencing. +2. **Discover Documents**: Use `list_documents()` to see what's in the knowledge base. +3. **Use items.jsonl for Structure**: Find tables, section headers, or specific elements by label and page number. Tables are pre-rendered as markdown. +4. **Use content.txt for Full Text**: When you need the complete document text (e.g., for regex across the whole document). +5. **Iterate**: Run code, examine results, refine your approach. Don't try to solve everything in one execution. +6. **Use llm() for Reasoning**: When you have content and need classification, summarization, or extraction, use `llm()` rather than writing complex parsing logic. ## Output Format diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py b/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py index 9d8cb40d..77a4dc87 100644 --- a/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py +++ b/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py @@ -1,14 +1,19 @@ +import asyncio +import concurrent.futures import json +from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal import pydantic_monty +from pydantic_monty import CallbackFile, MemoryFile, OSAccess from haiku.rag.agents.analysis.dependencies import AnalysisContext from haiku.rag.config.models import AppConfig -from haiku.rag.store.compression import decompress_json if TYPE_CHECKING: + from pathlib import PurePosixPath + from haiku.rag.client import HaikuRAG @@ -21,12 +26,19 @@ class SandboxResult: success: bool +def _run_async(coro: Any) -> Any: + """Run an async coroutine from a sync context (CallbackFile read).""" + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + return pool.submit(asyncio.run, coro).result() + + class Sandbox: """Execute code in a sandboxed Python interpreter. Uses pydantic-monty, a minimal secure Python interpreter written in Rust. - External functions (search, list_documents, etc.) are called by Monty code - using ``await`` and resolved asynchronously on the host. + External functions (search, llm) are called by Monty code using ``await`` + and resolved asynchronously on the host. + Documents are exposed via a virtual filesystem at ``/documents/{id}/``. sandbox = Sandbox(client, config, context) result = await sandbox.execute("print('hello')") @@ -71,12 +83,8 @@ class Sandbox: for r in expanded ] - async def list_documents( - limit: int = 10, offset: int = 0 - ) -> list[dict[str, Any]]: - docs = await client.list_documents( - limit=limit, offset=offset, filter=context.filter - ) + async def list_documents() -> list[dict[str, Any]]: + docs = await client.list_documents(filter=context.filter) return [ { "id": d.id, @@ -87,19 +95,6 @@ class Sandbox: for d in docs ] - async def get_document(id_or_title: str) -> str | None: - doc = await client.resolve_document(id_or_title) - return doc.content if doc else None - - async def get_docling_document( - document_id: str, - ) -> dict[str, Any] | None: - doc = await client.get_document_by_id(document_id) - if not doc or not doc.docling_document: - return None - json_str = decompress_json(doc.docling_document) - return json.loads(json_str) - async def llm(prompt: str) -> str: from pydantic_ai import Agent @@ -113,14 +108,111 @@ class Sandbox: return { "search": search, "list_documents": list_documents, - "get_document": get_document, - "get_docling_document": get_docling_document, "llm": llm, } + async def _build_vfs(self) -> OSAccess: + """Build the virtual filesystem with document data. + + Mounts per-document directories with: + - metadata.json: MemoryFile (eager, small) + - content.txt: CallbackFile (lazy, can be large) + - items.jsonl: CallbackFile (lazy, can be large) + """ + client = self._client + files: list[MemoryFile | CallbackFile] = [] + + docs = await client.list_documents(filter=self._context.filter) + + for doc in docs: + if not doc.id: + continue + doc_id: str = doc.id + doc_dir = f"/documents/{doc_id}" + + metadata = json.dumps( + { + "id": doc_id, + "title": doc.title, + "uri": doc.uri, + "created_at": str(doc.created_at), + }, + ensure_ascii=False, + ) + files.append(MemoryFile(f"{doc_dir}/metadata.json", metadata)) + + def _make_content_reader( + did: str, + ) -> Callable[["PurePosixPath"], str]: + def read_content(_path: "PurePosixPath") -> str: + async def _fetch() -> str: + from haiku.rag.utils import escape_sql_string + + safe_id = escape_sql_string(did) + rows = list( + client.store.documents_table.search() + .select(["content"]) + .where(f"id = '{safe_id}'") + .limit(1) + .to_list() + ) + return rows[0]["content"] if rows else "" + + return _run_async(_fetch()) + + return read_content + + def _make_items_reader( + did: str, + ) -> Callable[["PurePosixPath"], str]: + def read_items(_path: "PurePosixPath") -> str: + async def _fetch() -> str: + items = ( + await client.document_item_repository.get_items_in_range( + did, 0, 999999 + ) + ) + lines = [] + for item in items: + lines.append( + json.dumps( + { + "position": item.position, + "self_ref": item.self_ref, + "label": item.label, + "text": item.text, + "page_numbers": item.page_numbers, + }, + ensure_ascii=False, + ) + ) + return "\n".join(lines) + + return _run_async(_fetch()) + + return read_items + + files.append( + CallbackFile( + f"{doc_dir}/content.txt", + read=_make_content_reader(doc_id), + write=lambda _p, _c: None, + ) + ) + files.append( + CallbackFile( + f"{doc_dir}/items.jsonl", + read=_make_items_reader(doc_id), + write=lambda _p, _c: None, + ) + ) + + return OSAccess(files) + async def execute(self, code: str) -> SandboxResult: """Execute Python code in the Monty interpreter.""" external_fns = self._build_external_functions() + vfs = await self._build_vfs() input_names: list[str] = [] inputs: dict[str, Any] | None = None @@ -166,6 +258,7 @@ class Sandbox: external_functions=external_fns, limits=limits, print_callback=print_callback, + os=vfs, ) except pydantic_monty.MontyRuntimeError as e: stdout = "".join(stdout_lines) diff --git a/tests/agents/analysis/test_sandbox.py b/tests/agents/analysis/test_sandbox.py index 30c627de..4b846f6c 100644 --- a/tests/agents/analysis/test_sandbox.py +++ b/tests/agents/analysis/test_sandbox.py @@ -74,12 +74,12 @@ class TestSandboxErrors: assert result.stderr != "" -class TestSandboxHaikuRAG: - """Test haiku.rag functions in sandbox.""" +class TestSandboxListDocuments: + """Test list_documents function in sandbox.""" @pytest.mark.asyncio async def test_list_documents_empty(self, sandbox): - """Test list_documents returns empty list for empty database.""" + """list_documents returns empty list for empty database.""" result = await sandbox.execute( "docs = await list_documents()\nprint(type(docs).__name__, len(docs))" ) @@ -89,7 +89,7 @@ class TestSandboxHaikuRAG: @pytest.mark.asyncio @pytest.mark.vcr() async def test_list_documents_with_data(self, temp_db_path): - """Test list_documents returns documents when populated.""" + """list_documents returns documents when populated.""" config = AppConfig() async with HaikuRAG(temp_db_path, create=True) as client: await client.create_document( @@ -109,6 +109,10 @@ class TestSandboxHaikuRAG: assert "1" in result.stdout assert "Test Document" in result.stdout + +class TestSandboxSearch: + """Test search function in sandbox.""" + @pytest.mark.asyncio @pytest.mark.vcr() async def test_search_with_data(self, temp_db_path): @@ -158,46 +162,6 @@ class TestSandboxHaikuRAG: assert "True\nTrue" in result.stdout assert "list\nlist" in result.stdout - @pytest.mark.asyncio - @pytest.mark.vcr() - async def test_get_document(self, temp_db_path): - """Test get_document function.""" - config = AppConfig() - async with HaikuRAG(temp_db_path, create=True) as client: - doc = await client.create_document( - content="Content about foxes and dogs.", - uri="test://doc", - title="Fox Document", - ) - - context = AnalysisContext() - sb = Sandbox(client=client, config=config, context=context) - result = await sb.execute( - f"content = await get_document('{doc.id}')\n" - "print('foxes' in content.lower() if content else 'None')" - ) - assert result.success - assert "True" in result.stdout - - @pytest.mark.asyncio - async def test_get_document_not_found(self, sandbox): - """Test get_document returns None for missing document.""" - result = await sandbox.execute( - "content = await get_document('nonexistent-id')\nprint(content is None)" - ) - assert result.success - assert "True" in result.stdout - - -class TestSandboxSearchExpandsContext: - """Test that search() returns expanded results.""" - - @pytest.mark.asyncio - async def test_get_context_not_available(self, sandbox): - """get_context is no longer a sandbox function.""" - result = await sandbox.execute("await get_context('x')") - assert not result.success - @pytest.mark.asyncio @pytest.mark.vcr() async def test_search_returns_expanded_content(self, temp_db_path): @@ -303,13 +267,124 @@ class TestSandboxOutputTruncation: assert len(result.stdout) < 100 -class TestSandboxContextFilter: - """Test context filter is applied.""" +class TestSandboxVFS: + """Test virtual filesystem for document access.""" + + @pytest.mark.asyncio + async def test_empty_database_has_no_documents(self, sandbox): + """Empty database has no document directories.""" + result = await sandbox.execute( + "from pathlib import Path\nprint(Path('/documents').exists())" + ) + assert result.success + # /documents dir may or may not exist when empty, both are valid + # The key is it doesn't error @pytest.mark.asyncio @pytest.mark.vcr() - async def test_filter_applied_to_list_documents(self, temp_db_path): - """Test that context filter is passed to list_documents.""" + async def test_iterdir_discovers_documents(self, temp_db_path): + """Path('/documents').iterdir() lists document directories.""" + config = AppConfig() + async with HaikuRAG(temp_db_path, create=True) as client: + await client.create_document( + content="Test content", + uri="test://doc1", + title="Test Document", + ) + + context = AnalysisContext() + sb = Sandbox(client=client, config=config, context=context) + result = await sb.execute( + "from pathlib import Path\n" + "dirs = list(Path('/documents').iterdir())\n" + "print(len(dirs))\n" + "print(dirs[0].is_dir())" + ) + assert result.success + assert "1" in result.stdout + assert "True" in result.stdout + + @pytest.mark.asyncio + @pytest.mark.vcr() + async def test_metadata_json(self, temp_db_path): + """metadata.json contains document title and uri.""" + config = AppConfig() + async with HaikuRAG(temp_db_path, create=True) as client: + doc = await client.create_document( + content="Test content", + uri="test://doc1", + title="Test Document", + ) + + context = AnalysisContext() + sb = Sandbox(client=client, config=config, context=context) + result = await sb.execute( + "from pathlib import Path\n" + "import json\n" + f"meta = json.loads(Path('/documents/{doc.id}/metadata.json').read_text())\n" + "print(meta['title'])\n" + "print(meta['uri'])" + ) + assert result.success + assert "Test Document" in result.stdout + assert "test://doc1" in result.stdout + + @pytest.mark.asyncio + @pytest.mark.vcr() + async def test_content_txt(self, temp_db_path): + """content.txt returns full document text (lazy loaded).""" + config = AppConfig() + async with HaikuRAG(temp_db_path, create=True) as client: + doc = await client.create_document( + content="Content about foxes and dogs.", + uri="test://doc", + title="Fox Document", + ) + + context = AnalysisContext() + sb = Sandbox(client=client, config=config, context=context) + result = await sb.execute( + "from pathlib import Path\n" + f"content = Path('/documents/{doc.id}/content.txt').read_text()\n" + "print('foxes' in content.lower())" + ) + assert result.success + assert "True" in result.stdout + + @pytest.mark.asyncio + @pytest.mark.vcr() + async def test_items_jsonl(self, temp_db_path): + """items.jsonl returns document items as JSONL (lazy loaded).""" + config = AppConfig() + async with HaikuRAG(temp_db_path, create=True) as client: + doc = await client.create_document( + content="The quick brown fox jumps over the lazy dog.", + uri="test://animals", + title="Animals", + ) + + context = AnalysisContext() + sb = Sandbox(client=client, config=config, context=context) + result = await sb.execute( + "from pathlib import Path\n" + "import json\n" + f"text = Path('/documents/{doc.id}/items.jsonl').read_text()\n" + "lines = text.strip().split('\\n')\n" + "print(len(lines) > 0)\n" + "item = json.loads(lines[0])\n" + "print('position' in item)\n" + "print('self_ref' in item)\n" + "print('label' in item)\n" + "print('text' in item)\n" + "print('page_numbers' in item)" + ) + assert result.success + assert result.stdout.count("True") == 6 + + @pytest.mark.asyncio + @pytest.mark.vcr() + async def test_context_filter_limits_vfs(self, temp_db_path): + """Context filter restricts which documents appear in VFS.""" config = AppConfig() async with HaikuRAG(temp_db_path, create=True) as client: await client.create_document( @@ -326,10 +401,12 @@ class TestSandboxContextFilter: context = AnalysisContext(filter="uri LIKE 'public://%'") sb = Sandbox(client=client, config=config, context=context) result = await sb.execute( - "docs = await list_documents()\n" - "print(len(docs))\n" - "if docs:\n" - " print(docs[0]['title'])" + "from pathlib import Path\n" + "import json\n" + "dirs = list(Path('/documents').iterdir())\n" + "print(len(dirs))\n" + "meta = json.loads((dirs[0] / 'metadata.json').read_text())\n" + "print(meta['title'])" ) assert result.success assert "1" in result.stdout @@ -368,43 +445,6 @@ class TestSandboxPreloadedDocuments: assert "Doc B" in result.stdout -class TestSandboxDoclingDocument: - """Test get_docling_document() external function.""" - - @pytest.mark.asyncio - async def test_returns_none_for_missing_document(self, sandbox): - """get_docling_document returns None for a non-existent document.""" - result = await sandbox.execute( - "doc = await get_docling_document('nonexistent-id')\nprint(doc is None)" - ) - assert result.success - assert "True" in result.stdout - - @pytest.mark.asyncio - @pytest.mark.vcr() - async def test_returns_dict_for_document_with_docling_data(self, temp_db_path): - """get_docling_document returns a dict for a document with docling data.""" - config = AppConfig() - async with HaikuRAG(temp_db_path, create=True) as client: - doc = await client.create_document( - content="Docling processed content", - uri="test://docling", - title="Docling Doc", - ) - - context = AnalysisContext() - sb = Sandbox(client=client, config=config, context=context) - result = await sb.execute( - f"doc = await get_docling_document('{doc.id}')\n" - "print(type(doc).__name__)\n" - "print(doc['name'])\n" - "print('texts' in doc)" - ) - assert result.success - assert "dict" in result.stdout - assert "True" in result.stdout - - class TestSandboxLLM: """Test llm() external function.""" diff --git a/tests/cassettes/test_sandbox/TestSandboxHaikuRAG.test_list_documents_with_data.yaml b/tests/cassettes/test_sandbox/TestSandboxListDocuments.test_list_documents_with_data.yaml similarity index 100% rename from tests/cassettes/test_sandbox/TestSandboxHaikuRAG.test_list_documents_with_data.yaml rename to tests/cassettes/test_sandbox/TestSandboxListDocuments.test_list_documents_with_data.yaml diff --git a/tests/cassettes/test_sandbox/TestSandboxHaikuRAG.test_search_returns_doc_item_refs_and_labels.yaml b/tests/cassettes/test_sandbox/TestSandboxSearch.test_search_returns_doc_item_refs_and_labels.yaml similarity index 100% rename from tests/cassettes/test_sandbox/TestSandboxHaikuRAG.test_search_returns_doc_item_refs_and_labels.yaml rename to tests/cassettes/test_sandbox/TestSandboxSearch.test_search_returns_doc_item_refs_and_labels.yaml diff --git a/tests/cassettes/test_sandbox/TestSandboxHaikuRAG.test_search_with_data.yaml b/tests/cassettes/test_sandbox/TestSandboxSearch.test_search_returns_expanded_content.yaml similarity index 100% rename from tests/cassettes/test_sandbox/TestSandboxHaikuRAG.test_search_with_data.yaml rename to tests/cassettes/test_sandbox/TestSandboxSearch.test_search_returns_expanded_content.yaml diff --git a/tests/cassettes/test_sandbox/TestSandboxSearchExpandsContext.test_search_returns_expanded_content.yaml b/tests/cassettes/test_sandbox/TestSandboxSearch.test_search_with_data.yaml similarity index 100% rename from tests/cassettes/test_sandbox/TestSandboxSearchExpandsContext.test_search_returns_expanded_content.yaml rename to tests/cassettes/test_sandbox/TestSandboxSearch.test_search_with_data.yaml diff --git a/tests/cassettes/test_sandbox/TestSandboxHaikuRAG.test_get_document.yaml b/tests/cassettes/test_sandbox/TestSandboxVFS.test_content_txt.yaml similarity index 100% rename from tests/cassettes/test_sandbox/TestSandboxHaikuRAG.test_get_document.yaml rename to tests/cassettes/test_sandbox/TestSandboxVFS.test_content_txt.yaml diff --git a/tests/cassettes/test_sandbox/TestSandboxContextFilter.test_filter_applied_to_list_documents.yaml b/tests/cassettes/test_sandbox/TestSandboxVFS.test_context_filter_limits_vfs.yaml similarity index 100% rename from tests/cassettes/test_sandbox/TestSandboxContextFilter.test_filter_applied_to_list_documents.yaml rename to tests/cassettes/test_sandbox/TestSandboxVFS.test_context_filter_limits_vfs.yaml diff --git a/tests/cassettes/test_sandbox/TestSandboxVFS.test_items_jsonl.yaml b/tests/cassettes/test_sandbox/TestSandboxVFS.test_items_jsonl.yaml new file mode 100644 index 00000000..4ca2fa50 --- /dev/null +++ b/tests/cassettes/test_sandbox/TestSandboxVFS.test_items_jsonl.yaml @@ -0,0 +1,82 @@ +interactions: +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '114' + content-type: + - application/json + host: + - localhost:11434 + method: POST + parsed_body: + encoding_format: base64 + input: + - The quick brown fox jumps over the lazy dog. + model: qwen3-embedding:4b + uri: http://localhost:11434/v1/embeddings + response: + headers: + content-type: + - application/json + transfer-encoding: + - chunked + parsed_body: + data: + - embedding: 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 + index: 0 + object: embedding + model: qwen3-embedding:4b + object: list + usage: + prompt_tokens: 11 + total_tokens: 11 + status: + code: 200 + message: OK +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '73' + content-type: + - application/json + host: + - localhost:11434 + method: POST + parsed_body: + encoding_format: base64 + input: + - fox + model: qwen3-embedding:4b + uri: http://localhost:11434/v1/embeddings + response: + headers: + content-type: + - application/json + transfer-encoding: + - chunked + parsed_body: + data: + - embedding: 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 + index: 0 + object: embedding + model: qwen3-embedding:4b + object: list + usage: + prompt_tokens: 2 + total_tokens: 2 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/cassettes/test_sandbox/TestSandboxVFS.test_iterdir_discovers_documents.yaml b/tests/cassettes/test_sandbox/TestSandboxVFS.test_iterdir_discovers_documents.yaml new file mode 100644 index 00000000..b6252ae6 --- /dev/null +++ b/tests/cassettes/test_sandbox/TestSandboxVFS.test_iterdir_discovers_documents.yaml @@ -0,0 +1,42 @@ +interactions: +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '82' + content-type: + - application/json + host: + - localhost:11434 + method: POST + parsed_body: + encoding_format: base64 + input: + - Test content + model: qwen3-embedding:4b + uri: http://localhost:11434/v1/embeddings + response: + headers: + content-type: + - application/json + transfer-encoding: + - chunked + parsed_body: + data: + - embedding: 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 + index: 0 + object: embedding + model: qwen3-embedding:4b + object: list + usage: + prompt_tokens: 3 + total_tokens: 3 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/cassettes/test_sandbox/TestSandboxVFS.test_metadata_json.yaml b/tests/cassettes/test_sandbox/TestSandboxVFS.test_metadata_json.yaml new file mode 100644 index 00000000..b6252ae6 --- /dev/null +++ b/tests/cassettes/test_sandbox/TestSandboxVFS.test_metadata_json.yaml @@ -0,0 +1,42 @@ +interactions: +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '82' + content-type: + - application/json + host: + - localhost:11434 + method: POST + parsed_body: + encoding_format: base64 + input: + - Test content + model: qwen3-embedding:4b + uri: http://localhost:11434/v1/embeddings + response: + headers: + content-type: + - application/json + transfer-encoding: + - chunked + parsed_body: + data: + - embedding: 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 + index: 0 + object: embedding + model: qwen3-embedding:4b + object: list + usage: + prompt_tokens: 3 + total_tokens: 3 + status: + code: 200 + message: OK +version: 1 From 2a0e89bbe7021ab7c7b354138ac229c0546c4591 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Fri, 17 Apr 2026 12:43:14 +0300 Subject: [PATCH 07/24] add --skill flag to chat TUI for rag and analysis skills --- CHANGELOG.md | 1 + docs/apps.md | 6 ++++++ docs/cli.md | 7 +++++++ haiku_rag_slim/haiku/rag/chat/__init__.py | 19 ++++++++++++++++--- haiku_rag_slim/haiku/rag/chat/app.py | 6 +++--- haiku_rag_slim/haiku/rag/cli.py | 8 ++++++++ tests/chat/test_chat_app.py | 4 ++-- 7 files changed, 43 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51c3e281..0dcd837a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - **Document virtual filesystem in analysis sandbox**: Documents are mounted at `/documents/{id}/` with `metadata.json` (eager), `content.txt` (lazy), and `items.jsonl` (lazy). The agent uses standard Python `pathlib.Path` to browse and read document content and structure. - **`doc_item_refs` and `labels` in search results**: Search results now include document item references and labels for cross-referencing with `items.jsonl`. +- **`--skill` flag for chat TUI**: `haiku-rag chat -s rag -s analysis` to enable specific skills. Defaults to `rag`. Use `-s analysis` for code execution, or both for the full toolset. ### Changed diff --git a/docs/apps.md b/docs/apps.md index 570c78bb..b15ef5db 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -14,6 +14,12 @@ Conversational RAG from the terminal with streaming responses and session memory ```bash haiku-rag chat haiku-rag chat --db /path/to/database.lancedb + +# Enable analysis skill (code execution) +haiku-rag chat -s rag -s analysis + +# Analysis only +haiku-rag chat -s analysis ``` ### Interface diff --git a/docs/cli.md b/docs/cli.md index 842e9293..835597b8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -165,11 +165,18 @@ Launch an interactive chat session for multi-turn conversations: ```bash haiku-rag chat haiku-rag chat --db /path/to/database.lancedb + +# Enable analysis skill (code execution) +haiku-rag chat -s rag -s analysis ``` !!! note Requires the `tui` extra: `pip install haiku.rag-slim[tui]` (included in full `haiku.rag` package) +Flags: + +- `--skill` / `-s`: Skills to enable — `rag` (default), `analysis`. Can be repeated for multiple skills. + The chat interface provides: - Streaming responses with real-time tool execution diff --git a/haiku_rag_slim/haiku/rag/chat/__init__.py b/haiku_rag_slim/haiku/rag/chat/__init__.py index 78a1d341..d635c10d 100644 --- a/haiku_rag_slim/haiku/rag/chat/__init__.py +++ b/haiku_rag_slim/haiku/rag/chat/__init__.py @@ -7,6 +7,7 @@ def run_chat( read_only: bool = False, before: datetime | None = None, model: str | None = None, + skills: list[str] | None = None, ) -> None: """Run the chat TUI. @@ -15,6 +16,7 @@ def run_chat( read_only: Whether to open the database in read-only mode. before: Query database as it existed before this datetime. model: Model to use for the chat. + skills: Skills to enable ("rag", "analysis"). Defaults to ["rag"]. """ try: from haiku.rag.chat.app import ChatApp @@ -24,18 +26,29 @@ def run_chat( ) from e from haiku.rag.config import get_config - from haiku.rag.skills.rag import create_skill from haiku.rag.utils import get_model + from haiku.skills.models import Skill config = get_config() if db_path is None: db_path = config.storage.data_dir / "haiku.rag.lancedb" - skill = create_skill(db_path=db_path, config=config) + enabled = skills or ["rag"] + skill_list: list[Skill] = [] + + if "rag" in enabled: + from haiku.rag.skills.rag import create_skill as create_rag_skill + + skill_list.append(create_rag_skill(db_path=db_path, config=config)) + + if "analysis" in enabled: + from haiku.rag.skills.analysis import create_skill as create_analysis_skill + + skill_list.append(create_analysis_skill(db_path=db_path, config=config)) app = ChatApp( db_path, - skill=skill, + skills=skill_list, read_only=read_only, before=before, model=model or get_model(config.qa.model, config), diff --git a/haiku_rag_slim/haiku/rag/chat/app.py b/haiku_rag_slim/haiku/rag/chat/app.py index 5d6bcb14..834b66a0 100644 --- a/haiku_rag_slim/haiku/rag/chat/app.py +++ b/haiku_rag_slim/haiku/rag/chat/app.py @@ -86,14 +86,14 @@ class ChatApp(App): def __init__( self, db_path: Path, - skill: Skill, + skills: list[Skill], read_only: bool = False, before: datetime | None = None, model: str | None = None, ) -> None: super().__init__() self.db_path = db_path - self._skill = skill + self._skills = skills self.read_only = read_only self.before = before self._model = model @@ -153,7 +153,7 @@ class ChatApp(App): ) await self.client.__aenter__() - self._toolset = SkillToolset(skills=[self._skill]) + self._toolset = SkillToolset(skills=self._skills) self._agent = Agent( self._model, instructions=build_system_prompt( diff --git a/haiku_rag_slim/haiku/rag/cli.py b/haiku_rag_slim/haiku/rag/cli.py index 30d14657..1210d73b 100644 --- a/haiku_rag_slim/haiku/rag/cli.py +++ b/haiku_rag_slim/haiku/rag/cli.py @@ -645,17 +645,25 @@ def chat( # pragma: no cover "--model", help="Model to use for the chat (e.g. openai:gpt-4o)", ), + skill: list[str] | None = typer.Option( + None, + "--skill", + "-s", + help="Skills to enable: rag, analysis (can repeat, default: rag)", + ), ): """Launch the chat TUI for conversational RAG.""" from haiku.rag.chat import run_chat db_path = db if db else get_config().storage.data_dir / "haiku.rag.lancedb" + skills = skill if skill else ["rag"] run_chat( db_path, read_only=_read_only, before=_before, model=model, + skills=skills, ) diff --git a/tests/chat/test_chat_app.py b/tests/chat/test_chat_app.py index dcfd572a..84324bb1 100644 --- a/tests/chat/test_chat_app.py +++ b/tests/chat/test_chat_app.py @@ -57,7 +57,7 @@ def _make_app(db_path: Path, mock_client: AsyncMock | None = None): return ChatApp( db_path=db_path, - skill=skill, + skills=[skill], read_only=True, ), mock_client @@ -80,7 +80,7 @@ def _make_app_with_state(db_path: Path, mock_client: AsyncMock | None = None): return ChatApp( db_path=db_path, - skill=skill, + skills=[skill], read_only=True, ), mock_client From aa4ce07dc92be05b05e4c51cafce2bd89db3a8c1 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Fri, 17 Apr 2026 13:03:29 +0300 Subject: [PATCH 08/24] remove filter parameter from analyze skill tool and clean up unused filter helpers --- docs/tools.md | 4 +- haiku_rag_slim/haiku/rag/skills/_tools.py | 16 ++++---- haiku_rag_slim/haiku/rag/tools/__init__.py | 8 +--- haiku_rag_slim/haiku/rag/tools/filters.py | 17 +------- tests/skills/test_analysis.py | 31 +-------------- tests/tools/test_filters.py | 46 ++++------------------ 6 files changed, 21 insertions(+), 101 deletions(-) diff --git a/docs/tools.md b/docs/tools.md index b552a6fe..f6aca748 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -63,6 +63,4 @@ docs = create_document_toolset(config) `haiku.rag.tools.filters` provides utilities for building SQL filters: -- **`build_document_filter(document_name)`** — Builds a LIKE filter matching against both `uri` and `title`, case-insensitive. Also matches without spaces (e.g., "TB MED 593" matches "tbmed593"). -- **`build_multi_document_filter(document_names)`** — Combines multiple document name filters with OR logic. -- **`combine_filters(filter1, filter2)`** — Combines two filters with AND logic. Returns `None` if both are `None`. +- **`build_multi_document_filter(document_names)`** — Combines multiple document name filters with OR logic. Matches against both `uri` and `title`, case-insensitive. diff --git a/haiku_rag_slim/haiku/rag/skills/_tools.py b/haiku_rag_slim/haiku/rag/skills/_tools.py index c27c8472..e675c563 100644 --- a/haiku_rag_slim/haiku/rag/skills/_tools.py +++ b/haiku_rag_slim/haiku/rag/skills/_tools.py @@ -8,7 +8,6 @@ from haiku.rag.agents.research.models import Citation from haiku.rag.config.models import AppConfig from haiku.rag.store.models.chunk import SearchResult from haiku.rag.tools.document import DocumentInfo -from haiku.rag.tools.filters import combine_filters from haiku.rag.tools.qa import QAHistoryEntry from haiku.skills.state import SkillRunDeps @@ -210,13 +209,15 @@ async def skill_analyze( config: AppConfig, question: str, document: str | None = None, - filter: str | None = None, + document_filter: str | None = None, ) -> tuple[str, str, str | None]: from haiku.rag.client import HaikuRAG async with HaikuRAG(db_path, config=config, read_only=True) as rag: documents = [document] if document else None - result = await rag.analyze(question, documents=documents, filter=filter) + result = await rag.analyze( + question, documents=documents, filter=document_filter + ) output = result.answer if result.program: output += f"\n\nProgram:\n{result.program}" @@ -465,7 +466,6 @@ def create_skill_tools( ctx: RunContext[SkillRunDeps], question: str, document: str | None = None, - filter: str | None = None, ) -> str: """Answer complex analytical questions using code execution. @@ -475,13 +475,15 @@ def create_skill_tools( 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. """ state = _get_state(ctx, state_type) state_filter = state.document_filter if state else None - effective_filter = combine_filters(state_filter, filter) output, answer, program = await skill_analyze( - db_path, config, question, document=document, filter=effective_filter + db_path, + config, + question, + document=document, + document_filter=state_filter, ) if state: state.analyses.append( diff --git a/haiku_rag_slim/haiku/rag/tools/__init__.py b/haiku_rag_slim/haiku/rag/tools/__init__.py index def2e88c..384ead52 100644 --- a/haiku_rag_slim/haiku/rag/tools/__init__.py +++ b/haiku_rag_slim/haiku/rag/tools/__init__.py @@ -1,10 +1,6 @@ from haiku.rag.tools.context import RAGDeps from haiku.rag.tools.document import create_document_toolset -from haiku.rag.tools.filters import ( - build_document_filter, - build_multi_document_filter, - combine_filters, -) +from haiku.rag.tools.filters import build_multi_document_filter from haiku.rag.tools.qa import PRIOR_ANSWER_RELEVANCE_THRESHOLD, QAHistoryEntry from haiku.rag.tools.search import create_search_toolset @@ -12,9 +8,7 @@ __all__ = [ "PRIOR_ANSWER_RELEVANCE_THRESHOLD", "QAHistoryEntry", "RAGDeps", - "build_document_filter", "build_multi_document_filter", - "combine_filters", "create_document_toolset", "create_search_toolset", ] diff --git a/haiku_rag_slim/haiku/rag/tools/filters.py b/haiku_rag_slim/haiku/rag/tools/filters.py index b09d14db..66d7f5b9 100644 --- a/haiku_rag_slim/haiku/rag/tools/filters.py +++ b/haiku_rag_slim/haiku/rag/tools/filters.py @@ -1,4 +1,4 @@ -def build_document_filter(document_name: str) -> str: +def _build_document_filter(document_name: str) -> str: """Build SQL filter for document name matching. Matches against both uri and title fields, case-insensitive. @@ -19,20 +19,7 @@ def build_multi_document_filter(document_names: list[str]) -> str | None: """ if not document_names: return None - filters = [build_document_filter(name) for name in document_names] + filters = [_build_document_filter(name) for name in document_names] if len(filters) == 1: return filters[0] return " OR ".join(f"({f})" for f in filters) - - -def combine_filters(filter1: str | None, filter2: str | None) -> str | None: - """Combine two SQL filters with AND logic. - - Returns None if both filters are None. - """ - filters = [f for f in [filter1, filter2] if f] - if not filters: - return None - if len(filters) == 1: - return filters[0] - return f"({filters[0]}) AND ({filters[1]})" diff --git a/tests/skills/test_analysis.py b/tests/skills/test_analysis.py index f647abc4..0cada1f9 100644 --- a/tests/skills/test_analysis.py +++ b/tests/skills/test_analysis.py @@ -175,34 +175,7 @@ class TestAnalyzeTool: await analyze(ctx, question="How many documents?") assert captured_kwargs.get("filter") == "title = 'AI Overview'" - async def test_analyze_combines_state_filter_with_explicit_filter( - self, rag_db, monkeypatch - ): - from haiku.rag.skills.analysis import AnalysisState, create_skill - - captured_kwargs = {} - - async def mock_analyze(self, question, **kwargs): - captured_kwargs.update(kwargs) - return AnalysisResult(answer="Result", program="code()") - - monkeypatch.setattr(HaikuRAG, "analyze", mock_analyze) - - skill = create_skill(db_path=rag_db) - analyze = _get_tool(skill, "analyze") - state = AnalysisState(document_filter="title = 'AI Overview'") - ctx = _make_ctx(state) - await analyze( - ctx, - question="Count pages", - filter="uri LIKE '%test%'", - ) - result_filter = captured_kwargs["filter"] - assert isinstance(result_filter, str) - assert "title = 'AI Overview'" in result_filter - assert "uri LIKE '%test%'" in result_filter - - async def test_analyze_with_document_and_filter(self, rag_db, monkeypatch): + async def test_analyze_with_document(self, rag_db, monkeypatch): from haiku.rag.skills.analysis import create_skill captured_kwargs = {} @@ -220,7 +193,5 @@ class TestAnalyzeTool: ctx, question="Count pages", document="AI Overview", - filter="title = 'AI Overview'", ) assert captured_kwargs.get("documents") == ["AI Overview"] - assert captured_kwargs.get("filter") == "title = 'AI Overview'" diff --git a/tests/tools/test_filters.py b/tests/tools/test_filters.py index de449dd8..85d02290 100644 --- a/tests/tools/test_filters.py +++ b/tests/tools/test_filters.py @@ -1,21 +1,16 @@ -from haiku.rag.tools.filters import ( - build_document_filter, - build_multi_document_filter, - combine_filters, -) +from haiku.rag.tools.filters import _build_document_filter, build_multi_document_filter def test_build_document_filter_simple(): - """Test build_document_filter with simple name.""" - result = build_document_filter("mytest") + """Test _build_document_filter with simple name.""" + result = _build_document_filter("mytest") assert "LOWER(uri) LIKE LOWER('%mytest%')" in result assert "LOWER(title) LIKE LOWER('%mytest%')" in result def test_build_document_filter_with_spaces(): - """Test build_document_filter handles spaces correctly.""" - result = build_document_filter("TB MED 593") - # Should include both the original (with spaces) and without spaces + """Test _build_document_filter handles spaces correctly.""" + result = _build_document_filter("TB MED 593") assert "LOWER(uri) LIKE LOWER('%TB MED 593%')" in result assert "LOWER(uri) LIKE LOWER('%TBMED593%')" in result assert "LOWER(title) LIKE LOWER('%TB MED 593%')" in result @@ -23,9 +18,8 @@ def test_build_document_filter_with_spaces(): def test_build_document_filter_escapes_quotes(): - """Test build_document_filter escapes single quotes.""" - result = build_document_filter("O'Reilly") - # Single quotes should be doubled for SQL escaping + """Test _build_document_filter escapes single quotes.""" + result = _build_document_filter("O'Reilly") assert "O''Reilly" in result @@ -41,7 +35,6 @@ def test_build_multi_document_filter_single(): assert result is not None assert "LOWER(uri) LIKE LOWER('%mytest%')" in result assert "LOWER(title) LIKE LOWER('%mytest%')" in result - # Single document should not have extra wrapping parentheses assert " OR (" not in result @@ -49,31 +42,6 @@ def test_build_multi_document_filter_multiple(): """Test build_multi_document_filter with multiple documents.""" result = build_multi_document_filter(["doc1", "doc2"]) assert result is not None - # Should have OR-combined filters assert "doc1" in result assert "doc2" in result assert " OR (" in result - - -def test_combine_filters_both_none(): - """Test combine_filters with both None.""" - result = combine_filters(None, None) - assert result is None - - -def test_combine_filters_first_only(): - """Test combine_filters with only first filter.""" - result = combine_filters("uri = 'test'", None) - assert result == "uri = 'test'" - - -def test_combine_filters_second_only(): - """Test combine_filters with only second filter.""" - result = combine_filters(None, "title = 'doc'") - assert result == "title = 'doc'" - - -def test_combine_filters_both(): - """Test combine_filters combines with AND.""" - result = combine_filters("uri = 'test'", "title = 'doc'") - assert result == "(uri = 'test') AND (title = 'doc')" From cf89ff55cd7d49c18ff84c1c1d16846f8936071d Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Fri, 17 Apr 2026 13:20:08 +0300 Subject: [PATCH 09/24] When setting --model, set all subagents as well --- haiku_rag_slim/haiku/rag/chat/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/haiku_rag_slim/haiku/rag/chat/__init__.py b/haiku_rag_slim/haiku/rag/chat/__init__.py index d635c10d..981eb295 100644 --- a/haiku_rag_slim/haiku/rag/chat/__init__.py +++ b/haiku_rag_slim/haiku/rag/chat/__init__.py @@ -26,13 +26,19 @@ def run_chat( ) from e from haiku.rag.config import get_config - from haiku.rag.utils import get_model + from haiku.rag.utils import get_model, parse_model_option from haiku.skills.models import Skill config = get_config() if db_path is None: db_path = config.storage.data_dir / "haiku.rag.lancedb" + if model: + model_config = parse_model_option(model) + config.qa.model = model_config + config.research.model = model_config + config.analysis.model = model_config + enabled = skills or ["rag"] skill_list: list[Skill] = [] From 7d98d0ec57087be1958fe5c392d26fe7912d253e Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Fri, 17 Apr 2026 14:04:14 +0300 Subject: [PATCH 10/24] add citation support to analysis agent --- .../haiku/rag/agents/analysis/__init__.py | 7 ++++- .../haiku/rag/agents/analysis/agent.py | 8 +++--- .../haiku/rag/agents/analysis/models.py | 18 ++++++++++-- .../haiku/rag/agents/analysis/prompts.py | 5 ++-- .../haiku/rag/agents/analysis/sandbox.py | 4 +++ haiku_rag_slim/haiku/rag/chat/app.py | 24 +++++++++------- haiku_rag_slim/haiku/rag/client.py | 11 +++++++- haiku_rag_slim/haiku/rag/skills/_tools.py | 28 +++++++++++++------ haiku_rag_slim/haiku/rag/skills/analysis.py | 4 ++- tests/agents/analysis/test_agent.py | 4 +-- 10 files changed, 82 insertions(+), 31 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/__init__.py b/haiku_rag_slim/haiku/rag/agents/analysis/__init__.py index 0d0cca81..f7507d7d 100644 --- a/haiku_rag_slim/haiku/rag/agents/analysis/__init__.py +++ b/haiku_rag_slim/haiku/rag/agents/analysis/__init__.py @@ -1,6 +1,10 @@ from haiku.rag.agents.analysis.agent import create_analysis_agent from haiku.rag.agents.analysis.dependencies import AnalysisContext, AnalysisDeps -from haiku.rag.agents.analysis.models import AnalysisResult, CodeExecution +from haiku.rag.agents.analysis.models import ( + AnalysisResult, + CodeExecution, + RawAnalysisResult, +) from haiku.rag.agents.analysis.prompts import ANALYSIS_SYSTEM_PROMPT from haiku.rag.agents.analysis.sandbox import Sandbox, SandboxResult @@ -8,6 +12,7 @@ __all__ = [ "ANALYSIS_SYSTEM_PROMPT", "AnalysisContext", "AnalysisDeps", + "RawAnalysisResult", "AnalysisResult", "CodeExecution", "Sandbox", diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/agent.py b/haiku_rag_slim/haiku/rag/agents/analysis/agent.py index 76ef9140..47e49d5c 100644 --- a/haiku_rag_slim/haiku/rag/agents/analysis/agent.py +++ b/haiku_rag_slim/haiku/rag/agents/analysis/agent.py @@ -1,13 +1,13 @@ from pydantic_ai import Agent, RunContext from haiku.rag.agents.analysis.dependencies import AnalysisDeps -from haiku.rag.agents.analysis.models import AnalysisResult, CodeExecution +from haiku.rag.agents.analysis.models import CodeExecution, RawAnalysisResult from haiku.rag.agents.analysis.prompts import ANALYSIS_SYSTEM_PROMPT from haiku.rag.config.models import AppConfig from haiku.rag.utils import get_model -def create_analysis_agent(config: AppConfig) -> Agent[AnalysisDeps, AnalysisResult]: +def create_analysis_agent(config: AppConfig) -> Agent[AnalysisDeps, RawAnalysisResult]: """Create an analysis agent with code execution capability. The analysis agent can write and execute Python code in a sandboxed @@ -22,10 +22,10 @@ def create_analysis_agent(config: AppConfig) -> Agent[AnalysisDeps, AnalysisResu """ model = get_model(config.analysis.model, config) - agent: Agent[AnalysisDeps, AnalysisResult] = Agent( # type: ignore[assignment] # ty: ignore[invalid-assignment] + agent: Agent[AnalysisDeps, RawAnalysisResult] = Agent( # type: ignore[assignment] # ty: ignore[invalid-assignment] model, deps_type=AnalysisDeps, - output_type=AnalysisResult, + output_type=RawAnalysisResult, instructions=ANALYSIS_SYSTEM_PROMPT, retries=3, ) diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/models.py b/haiku_rag_slim/haiku/rag/agents/analysis/models.py index 0c8e86b4..413c25b0 100644 --- a/haiku_rag_slim/haiku/rag/agents/analysis/models.py +++ b/haiku_rag_slim/haiku/rag/agents/analysis/models.py @@ -1,5 +1,7 @@ from pydantic import BaseModel, Field +from haiku.rag.agents.research.models import Citation + class CodeExecution(BaseModel): """Result of executing a code block in the analysis sandbox.""" @@ -10,8 +12,20 @@ class CodeExecution(BaseModel): success: bool = Field(description="Whether execution completed without error") -class AnalysisResult(BaseModel): - """Result from analysis agent execution.""" +class RawAnalysisResult(BaseModel): + """Raw result from the analysis agent (LLM output).""" answer: str = Field(description="The answer to the user's question") program: str = Field(description="The final consolidated program") + cited_chunks: list[str] = Field( + default_factory=list, + description="Chunk IDs from search results that informed the answer. Copy full UUIDs from search result chunk_id fields.", + ) + + +class AnalysisResult(BaseModel): + """Result from analysis execution with resolved citations.""" + + answer: str + program: str + citations: list[Citation] = Field(default_factory=list) diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/prompts.py b/haiku_rag_slim/haiku/rag/agents/analysis/prompts.py index de4a253b..a26b70cf 100644 --- a/haiku_rag_slim/haiku/rag/agents/analysis/prompts.py +++ b/haiku_rag_slim/haiku/rag/agents/analysis/prompts.py @@ -116,12 +116,13 @@ Not supported: most imports (only `json`, `re`, `math`, `pathlib` are available) Your final response MUST be valid JSON matching this exact schema: ```json -{"answer": "Your complete answer here as a string", "program": "Your final consolidated program here as a string"} +{"answer": "Your answer here", "program": "Your final program here", "cited_chunks": ["chunk-id-1", "chunk-id-2"]} ``` - `answer`: A clear answer to the user's question with key findings and references to specific documents/chunks. - `program`: A single, self-contained Python program that produces the answer. Consolidate your exploratory code executions into one clean script. +- `cited_chunks`: List of chunk_id values from search results that informed your answer. Copy the full UUID strings from the `chunk_id` field of search results you used. -Do NOT return arbitrary JSON structures. Always use the exact format: {"answer": "...", "program": "..."} +Do NOT return arbitrary JSON structures. Always use the exact format above. You MUST call execute_code at least once before providing your answer. Never give up without trying to execute code first.""" diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py b/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py index 77a4dc87..c39ec1b3 100644 --- a/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py +++ b/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py @@ -10,6 +10,7 @@ from pydantic_monty import CallbackFile, MemoryFile, OSAccess from haiku.rag.agents.analysis.dependencies import AnalysisContext from haiku.rag.config.models import AppConfig +from haiku.rag.store.models.chunk import SearchResult if TYPE_CHECKING: from pathlib import PurePosixPath @@ -47,6 +48,7 @@ class Sandbox: _client: "HaikuRAG" _config: AppConfig _context: AnalysisContext + _search_results: "list[SearchResult]" def __init__( self, @@ -57,6 +59,7 @@ class Sandbox: self._client = client self._config = config self._context = context + self._search_results = [] def _build_external_functions(self) -> dict[str, Any]: """Build async external functions for the Monty interpreter.""" @@ -67,6 +70,7 @@ class Sandbox: async def search(query: str, limit: int = 10) -> list[dict[str, Any]]: results = await client.search(query, limit=limit, filter=context.filter) expanded = await client.expand_context(results) + self._search_results.extend(expanded) return [ { "chunk_id": r.chunk_id, diff --git a/haiku_rag_slim/haiku/rag/chat/app.py b/haiku_rag_slim/haiku/rag/chat/app.py index 834b66a0..6e0d36ca 100644 --- a/haiku_rag_slim/haiku/rag/chat/app.py +++ b/haiku_rag_slim/haiku/rag/chat/app.py @@ -30,6 +30,7 @@ from textual.worker import Worker from haiku.rag.chat.widgets.chat_history import ChatHistory, CitationWidget from haiku.rag.client import HaikuRAG from haiku.rag.config import get_config +from haiku.rag.skills.analysis import AnalysisState from haiku.rag.skills.rag import RAGState, get_agent_preamble from haiku.skills.agent import ( SkillToolset, @@ -51,6 +52,7 @@ if TYPE_CHECKING: RAG_STATE_NAMESPACE = "rag" +ANALYSIS_STATE_NAMESPACE = "analysis" class ChatApp(App): @@ -319,15 +321,15 @@ class ChatApp(App): chat_input.focus() async def _show_citations(self, chat_history: "ChatHistory") -> None: - """Show citations from the RAG state after an agent response.""" + """Show citations from skill states after an agent response.""" if not self._toolset: return - rag_state = self._toolset.get_namespace(RAG_STATE_NAMESPACE) - if rag_state is None: - return - citations = getattr(rag_state, "citations", []) + citations = [] + for namespace in (RAG_STATE_NAMESPACE, ANALYSIS_STATE_NAMESPACE): + state = self._toolset.get_namespace(namespace) + if state: + citations.extend(getattr(state, "citations", [])) if citations: - # Show only new citations (since last response) await chat_history.add_citations(citations) async def action_clear_chat(self) -> None: @@ -420,9 +422,11 @@ class ChatApp(App): self._document_filter = event.selected if self._toolset: + doc_filter = build_multi_document_filter(self._document_filter) rag_state = self._toolset.get_namespace(RAG_STATE_NAMESPACE) if isinstance(rag_state, RAGState): - rag_state.document_filter = build_multi_document_filter( - self._document_filter - ) - self._state = self._toolset.build_state_snapshot() + rag_state.document_filter = doc_filter + analysis_state = self._toolset.get_namespace(ANALYSIS_STATE_NAMESPACE) + if isinstance(analysis_state, AnalysisState): + analysis_state.document_filter = doc_filter + self._state = self._toolset.build_state_snapshot() diff --git a/haiku_rag_slim/haiku/rag/client.py b/haiku_rag_slim/haiku/rag/client.py index 5f76736c..c8473dd3 100644 --- a/haiku_rag_slim/haiku/rag/client.py +++ b/haiku_rag_slim/haiku/rag/client.py @@ -1239,10 +1239,19 @@ class HaikuRAG: context=context, ) + from haiku.rag.agents.analysis.models import AnalysisResult + from haiku.rag.agents.research.models import resolve_citations + agent = create_analysis_agent(self._config) result = await agent.run(question, deps=deps) - return result.output + output = result.output + citations = resolve_citations(output.cited_chunks, sandbox._search_results) + return AnalysisResult( + answer=output.answer, + program=output.program, + citations=citations, + ) async def visualize_chunk(self, chunk: Chunk) -> list: """Render page images with bounding box highlights for a chunk. diff --git a/haiku_rag_slim/haiku/rag/skills/_tools.py b/haiku_rag_slim/haiku/rag/skills/_tools.py index e675c563..36e9eb32 100644 --- a/haiku_rag_slim/haiku/rag/skills/_tools.py +++ b/haiku_rag_slim/haiku/rag/skills/_tools.py @@ -210,7 +210,7 @@ async def skill_analyze( question: str, document: str | None = None, document_filter: str | None = None, -) -> tuple[str, str, str | None]: +) -> tuple[str, str, str | None, "list[Citation]"]: from haiku.rag.client import HaikuRAG async with HaikuRAG(db_path, config=config, read_only=True) as rag: @@ -222,7 +222,7 @@ async def skill_analyze( if result.program: output += f"\n\nProgram:\n{result.program}" - return output, result.answer, result.program + return output, result.answer, result.program, result.citations def update_documents_state( @@ -246,6 +246,15 @@ def _get_state(ctx: RunContext[SkillRunDeps], state_type: type[BaseModel]) -> An return None +def _append_citations(state: Any, citations: "list[Citation]") -> None: + """Index and append citations to a skill state's citations list.""" + next_index = len(state.citations) + 1 + for citation in citations: + citation.index = next_index + next_index += 1 + state.citations.extend(citations) + + def create_skill_extras( db_path: Path, config: AppConfig, @@ -406,11 +415,7 @@ def create_skill_tools( ) if state: - next_index = len(state.citations) + 1 - for citation in citations: - citation.index = next_index - next_index += 1 - state.citations.extend(citations) + _append_citations(state, citations) state.qa_history.append( QAHistoryEntry( question=question, answer=answer, citations=citations @@ -476,9 +481,11 @@ def create_skill_tools( question: The question to answer. document: Optional document ID or title to pre-load for analysis. """ + from haiku.rag.utils import format_citations + state = _get_state(ctx, state_type) state_filter = state.document_filter if state else None - output, answer, program = await skill_analyze( + output, answer, program, citations = await skill_analyze( db_path, config, question, @@ -493,6 +500,11 @@ def create_skill_tools( program=program, ) ) + if citations: + _append_citations(state, citations) + + if citations: + output += "\n\n" + format_citations(citations) return output diff --git a/haiku_rag_slim/haiku/rag/skills/analysis.py b/haiku_rag_slim/haiku/rag/skills/analysis.py index 9648d65f..022ce778 100644 --- a/haiku_rag_slim/haiku/rag/skills/analysis.py +++ b/haiku_rag_slim/haiku/rag/skills/analysis.py @@ -2,8 +2,9 @@ import os from functools import cache from pathlib import Path -from pydantic import BaseModel +from pydantic import BaseModel, Field +from haiku.rag.agents.research.models import Citation from haiku.rag.config.models import AppConfig from haiku.rag.skills._tools import AnalysisEntry from haiku.skills.models import Skill, SkillMetadata, SkillSource, StateMetadata @@ -13,6 +14,7 @@ from haiku.skills.parser import parse_skill_md class AnalysisState(BaseModel): document_filter: str | None = None analyses: list[AnalysisEntry] = [] + citations: list[Citation] = Field(default_factory=list) STATE_TYPE = AnalysisState diff --git a/tests/agents/analysis/test_agent.py b/tests/agents/analysis/test_agent.py index 98e0eb51..3f2ec6ff 100644 --- a/tests/agents/analysis/test_agent.py +++ b/tests/agents/analysis/test_agent.py @@ -5,7 +5,7 @@ from pydantic_ai import Agent from haiku.rag.agents.analysis.agent import create_analysis_agent from haiku.rag.agents.analysis.dependencies import AnalysisDeps -from haiku.rag.agents.analysis.models import AnalysisResult, CodeExecution +from haiku.rag.agents.analysis.models import CodeExecution, RawAnalysisResult from haiku.rag.config import AppConfig, Config @@ -19,7 +19,7 @@ class TestCreateAnalysisAgent: agent = create_analysis_agent(Config) assert isinstance(agent, Agent) assert agent.deps_type is AnalysisDeps - assert agent.output_type is AnalysisResult + assert agent.output_type is RawAnalysisResult def test_agent_has_execute_code_tool(self): agent = create_analysis_agent(Config) From 20e40d75f99a12e06b44e6e7c65beeb1159b2b44 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Fri, 17 Apr 2026 16:31:23 +0300 Subject: [PATCH 11/24] add collapsible program display to chat TUI Co-Authored-By: Claude Opus 4.6 (1M context) --- .../haiku/rag/chat/widgets/chat_history.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/haiku_rag_slim/haiku/rag/chat/widgets/chat_history.py b/haiku_rag_slim/haiku/rag/chat/widgets/chat_history.py index fdd4f940..d619f76e 100644 --- a/haiku_rag_slim/haiku/rag/chat/widgets/chat_history.py +++ b/haiku_rag_slim/haiku/rag/chat/widgets/chat_history.py @@ -133,6 +133,25 @@ class CitationWidget(Collapsible): event.stop() +class ProgramWidget(Collapsible): + """Inline expandable program code block.""" + + def __init__(self, program: str, **kwargs) -> None: + content = f"```python\n{program}\n```" + super().__init__( + Markdown(content), + title="Program", + collapsed=True, + **kwargs, + ) + + def on_key(self, event: "Key") -> None: + """Handle Enter to toggle expand/collapse.""" + if event.key == "enter": + self.collapsed = not self.collapsed + event.stop() + + class ThinkingWidget(Static): """Thinking indicator shown while agent is processing.""" @@ -290,6 +309,22 @@ class ChatHistory(VerticalScroll): text-style: italic; } + /* Program */ + ProgramWidget { + margin: 0 0 0 2; + background: $surface; + } + + ProgramWidget > CollapsibleTitle { + padding: 0 1; + color: $text-muted; + } + + ProgramWidget Contents { + padding: 1 2; + background: $panel; + } + /* Thinking indicator */ ThinkingWidget { margin: 1 0 0 4; @@ -365,6 +400,13 @@ class ChatHistory(VerticalScroll): await self.mount(widget) self.scroll_end(animate=False) + async def add_program(self, program: str) -> None: + """Add a collapsible program block after a response.""" + if not program: + return + await self.mount(ProgramWidget(program)) + self.scroll_end(animate=False) + async def show_thinking(self, text: str = "Thinking...") -> None: """Show the thinking indicator.""" try: From d52f453c449f1305c869d033c3ec30350626b80d Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Fri, 17 Apr 2026 18:31:11 +0300 Subject: [PATCH 12/24] flatten skill architecture: replace ask/analyze/research with direct tools --- app/frontend/components/Chat.tsx | 25 +- app/frontend/lib/sessionStorage.ts | 32 +- haiku_rag_slim/haiku/rag/chat/app.py | 25 +- .../haiku/rag/skill_generator/__init__.py | 5 +- .../rag/skill_generator/templates/SKILL.md.j2 | 62 +-- .../skill_generator/templates/__init__.py.j2 | 27 +- haiku_rag_slim/haiku/rag/skills/_tools.py | 356 ++++---------- haiku_rag_slim/haiku/rag/skills/analysis.py | 18 +- .../haiku/rag/skills/rag-analysis/SKILL.md | 67 ++- haiku_rag_slim/haiku/rag/skills/rag.py | 9 +- haiku_rag_slim/haiku/rag/skills/rag/SKILL.md | 58 ++- ...s_dict_for_document_with_docling_data.yaml | 42 -- tests/skills/test_analysis.py | 108 ++--- tests/skills/test_rag.py | 433 ++---------------- 14 files changed, 375 insertions(+), 892 deletions(-) delete mode 100644 tests/cassettes/test_sandbox/TestSandboxDoclingDocument.test_returns_dict_for_document_with_docling_data.yaml diff --git a/app/frontend/components/Chat.tsx b/app/frontend/components/Chat.tsx index d0c24f48..f820da4d 100644 --- a/app/frontend/components/Chat.tsx +++ b/app/frontend/components/Chat.tsx @@ -21,8 +21,8 @@ import { FilterIcon } from "../lib/icons"; import type { RAGState } from "../lib/sessionStorage"; import { createSession, - deriveCitationsHistory, getActiveSessionId, + getLatestCitations, getSession, normalizeRAGState, updateSessionMessages, @@ -292,7 +292,7 @@ function MessageViewWithCitations({ isRunning?: boolean; }) { const ragState = useContext(ChatStateContext); - const citationsHistory = ragState ? deriveCitationsHistory(ragState) : []; + const latestCitations = ragState ? getLatestCitations(ragState) : []; // Collect completed tool_call_ids from skill_tool_result activity messages const completedToolCallIds = useMemo(() => { @@ -326,7 +326,6 @@ function MessageViewWithCitations({ {({ messageElements }) => { const result: React.ReactNode[] = []; let elemIdx = 0; - let citIdx = 0; let seenToolCalls = false; for (const msg of messages) { @@ -368,19 +367,15 @@ function MessageViewWithCitations({ } // After an assistant text response that followed tool calls, - // inject the next citations entry (one per turn) + // show citations from the latest turn if (msg.role === "assistant" && msg.content && seenToolCalls) { - if (citIdx < citationsHistory.length) { - const citations = citationsHistory[citIdx]; - if (citations?.length) { - result.push( - , - ); - } - citIdx++; + if (latestCitations.length > 0) { + result.push( + , + ); } seenToolCalls = false; } diff --git a/app/frontend/lib/sessionStorage.ts b/app/frontend/lib/sessionStorage.ts index bf1f39c2..47737433 100644 --- a/app/frontend/lib/sessionStorage.ts +++ b/app/frontend/lib/sessionStorage.ts @@ -9,12 +9,6 @@ export interface Citation { content: string; } -export interface QAHistoryEntry { - question: string; - answer: string; - citations: Citation[]; -} - export interface DocumentInfo { id: string; title: string; @@ -22,20 +16,13 @@ export interface DocumentInfo { created: string; } -export interface ResearchEntry { - question: string; - title: string; - executive_summary: string; -} - // Matches RAGState from the backend skill export interface RAGState { - citations: Citation[]; - qa_history: QAHistoryEntry[]; + citation_index: Record; + citations: string[][]; document_filter: string | null; searches: Record; documents: DocumentInfo[]; - reports: ResearchEntry[]; } export interface StoredMessage { @@ -59,20 +46,21 @@ const ACTIVE_SESSION_KEY = "haiku.rag.activeSession"; export function normalizeRAGState(state?: Partial): RAGState { return { + citation_index: state?.citation_index ?? {}, citations: state?.citations ?? [], - qa_history: state?.qa_history ?? [], document_filter: state?.document_filter ?? null, searches: state?.searches ?? {}, documents: state?.documents ?? [], - reports: state?.reports ?? [], }; } -// Derive per-turn citation arrays from qa_history -export function deriveCitationsHistory(state: RAGState): Citation[][] { - return state.qa_history - .filter((entry) => entry.citations?.length > 0) - .map((entry) => entry.citations); +export function getLatestCitations(state: RAGState): Citation[] { + const turns = state.citations; + if (turns.length === 0) return []; + const latestIds = turns[turns.length - 1]; + return latestIds + .map((id) => state.citation_index[id]) + .filter((c): c is Citation => c !== undefined); } export function getAllSessions(): StoredSession[] { diff --git a/haiku_rag_slim/haiku/rag/chat/app.py b/haiku_rag_slim/haiku/rag/chat/app.py index 6e0d36ca..b0f1a1ee 100644 --- a/haiku_rag_slim/haiku/rag/chat/app.py +++ b/haiku_rag_slim/haiku/rag/chat/app.py @@ -243,8 +243,7 @@ class ChatApp(App): content=accumulated_text, ) ) - # Show citations from RAG state - await self._show_citations(chat_history) + await self._show_citations_and_programs(chat_history) elif event.type == EventType.TOOL_CALL_START: assert isinstance(event, ToolCallStartEvent) chat_history.hide_thinking() @@ -320,18 +319,32 @@ class ChatApp(App): chat_input.disabled = False chat_input.focus() - async def _show_citations(self, chat_history: "ChatHistory") -> None: - """Show citations from skill states after an agent response.""" + async def _show_citations_and_programs(self, chat_history: "ChatHistory") -> None: + """Show citations and programs from skill states after an agent response.""" if not self._toolset: return citations = [] for namespace in (RAG_STATE_NAMESPACE, ANALYSIS_STATE_NAMESPACE): state = self._toolset.get_namespace(namespace) - if state: - citations.extend(getattr(state, "citations", [])) + if not state: + continue + citation_turns = getattr(state, "citations", []) + citation_index = getattr(state, "citation_index", {}) + if citation_turns: + latest_ids = citation_turns[-1] + for cid in latest_ids: + if cid in citation_index: + citations.append(citation_index[cid]) if citations: await chat_history.add_citations(citations) + analysis_state = self._toolset.get_namespace(ANALYSIS_STATE_NAMESPACE) + if analysis_state: + executions = getattr(analysis_state, "executions", []) + successful = [e for e in executions if e.success] + if successful: + await chat_history.add_program(successful[-1].code) + async def action_clear_chat(self) -> None: """Clear the chat history and reset session.""" chat_history = self.query_one(ChatHistory) diff --git a/haiku_rag_slim/haiku/rag/skill_generator/__init__.py b/haiku_rag_slim/haiku/rag/skill_generator/__init__.py index 50c50019..2d284bfe 100644 --- a/haiku_rag_slim/haiku/rag/skill_generator/__init__.py +++ b/haiku_rag_slim/haiku/rag/skill_generator/__init__.py @@ -8,9 +8,8 @@ AVAILABLE_TOOLS: set[str] = { "list_documents", "get_document", "search", - "ask", - "research", - "analyze", + "execute_code", + "cite", } DEFAULT_PREAMBLE = ( diff --git a/haiku_rag_slim/haiku/rag/skill_generator/templates/SKILL.md.j2 b/haiku_rag_slim/haiku/rag/skill_generator/templates/SKILL.md.j2 index 8bf07475..fc25efe6 100644 --- a/haiku_rag_slim/haiku/rag/skill_generator/templates/SKILL.md.j2 +++ b/haiku_rag_slim/haiku/rag/skill_generator/templates/SKILL.md.j2 @@ -7,48 +7,58 @@ description: {{ description }} {{ preamble }} -## How to decide which tool to use -{% if "ask" in tool_names %} +## Tools +{% if "search" in tool_names %} -**Default rule:** If the user is asking a question, use **ask**. Only use **search** when the user explicitly wants to browse or find passages. +### search +Search the knowledge base using hybrid search (vector + full-text). Returns ranked results with context-expanded content. Use for answering questions, finding passages, exploring topics. {% endif %} {% if "list_documents" in tool_names %} -- **list_documents** — Use when the user wants to browse or see what documents are available (e.g., "what documents do you have?", "show me the documents", "list available docs"). + +### list_documents +List all documents in the knowledge base. {% endif %} {% if "get_document" in tool_names %} -- **get_document** — Use when the user wants the full content of a specific document (e.g., "get the paper about X", "show me document Y"). Accepts a document ID, title, or URI — partial matches work. + +### get_document +Retrieve a document by ID, title, or URI. Partial matches work. {% endif %} -{% if "search" in tool_names %} -- **search** — Use when the user wants to browse, explore, or find specific passages across documents (e.g., "search for embeddings", "find mentions of transformers"). Returns all matching results as sources. +{% if "execute_code" in tool_names %} + +### execute_code +Execute Python code in a sandboxed interpreter. Inside the code you have access to `await search()`, `await list_documents()`, `await llm()`, and a virtual filesystem at `/documents/` with document content and structure. {% endif %} -{% if "ask" in tool_names %} -- **ask** — Use for factual questions that need a synthesized answer (e.g., "what is DocLayNet?", "explain the methodology"). Searches, synthesizes, and returns only the chunks actually used as citations. Always include the citations in your response. -{% endif %} -{% if "research" in tool_names %} -- **research** — Deep multi-agent research that produces comprehensive reports. **Only use when the user explicitly requests deep research** (e.g., "do a deep research on X", "research this topic thoroughly"). Never call this tool on your own — it is slow and expensive. -{% endif %} -{% if "analyze" in tool_names %} -- **analyze** — Use for complex analytical questions that require computation, aggregation, or data traversal across documents (e.g., "how many pages?", "compare table 3 across documents", "calculate average word count"). Executes Python code in a sandboxed interpreter. +{% if "cite" in tool_names %} + +### cite +Register chunk IDs as citations. Call after formulating your answer with chunk_id values from search results that support it. Do NOT include chunk IDs in your answer text. {% endif %} {% if "search" in tool_names %} -## When search returns irrelevant results +## How to answer questions -If your first search returns results that clearly don't match the question, **do not keep searching with variations**. Instead: -{% if "ask" in tool_names %} -- Use **ask** if the question is factual +1. Call `search` with relevant keywords from the question +2. Review results — they are ordered by relevance (rank 1 = best match) +3. If needed, search again with different keywords (up to 3-4 searches total) +4. Synthesize a concise answer based strictly on the retrieved content +{% if "cite" in tool_names %} +5. Call `cite` with the chunk IDs you referenced +{% endif %} + +## Guidelines + +- Base answers strictly on retrieved content — do not use external knowledge +- Be concise and direct — avoid elaboration unless asked +- If results don't match the question, report that the knowledge base lacks the information +{% if "cite" in tool_names %} +- Do NOT include chunk IDs or UUIDs in your answer text — use the `cite` tool separately {% endif %} -- Report that the knowledge base doesn't contain relevant information {% endif %} {% if "get_document" in tool_names %} ## When the user mentions a specific document If the user says "search in [doc]", "find in [doc]", or "answer from [doc]": -- Extract the **topic** as the `query`/`question` parameter -- Use **get_document** or **list_documents** first to identify the document, then search/ask with a filter - -Examples: -- "search for embeddings in the ML paper" -> first identify "ML paper", then search for "embeddings" -- "what does the DocLayNet paper say about annotations?" -> ask with question="what are the annotation methods?" +- Use **get_document** or **list_documents** first to identify the document +- Then search for the topic {% endif %} diff --git a/haiku_rag_slim/haiku/rag/skill_generator/templates/__init__.py.j2 b/haiku_rag_slim/haiku/rag/skill_generator/templates/__init__.py.j2 index a82437af..829d006b 100644 --- a/haiku_rag_slim/haiku/rag/skill_generator/templates/__init__.py.j2 +++ b/haiku_rag_slim/haiku/rag/skill_generator/templates/__init__.py.j2 @@ -5,23 +5,17 @@ from pydantic import BaseModel, Field from haiku.rag.config.models import AppConfig from haiku.skills.models import Skill from haiku.skills.parser import parse_skill_md -{% if "ask" in tool_names or "research" in tool_names %} +{% if "cite" in tool_names %} from haiku.rag.agents.research.models import Citation {% endif %} {% if "search" in tool_names or "list_documents" in tool_names or "get_document" in tool_names %} from haiku.rag.tools.document import DocumentInfo {% endif %} -{% if "ask" in tool_names %} -from haiku.rag.tools.qa import QAHistoryEntry -{% endif %} {% if "search" in tool_names %} from haiku.rag.store.models.chunk import SearchResult {% endif %} -{% if "research" in tool_names %} -from haiku.rag.skills._tools import ResearchEntry -{% endif %} -{% if "analyze" in tool_names %} -from haiku.rag.skills._tools import AnalysisEntry +{% if "execute_code" in tool_names %} +from haiku.rag.skills._tools import CodeExecutionEntry {% endif %} _TOOL_NAMES = {{ tool_names | tojson }} @@ -36,11 +30,9 @@ _CONFIG_PATH = _ASSETS_DIR / "haiku.rag.yaml" class SkillState(BaseModel): -{% if "ask" in tool_names or "research" in tool_names %} - citations: list[Citation] = Field(default_factory=list) -{% endif %} -{% if "ask" in tool_names %} - qa_history: list[QAHistoryEntry] = Field(default_factory=list) +{% if "cite" in tool_names %} + citation_index: dict[str, Citation] = Field(default_factory=dict) + citations: list[list[str]] = Field(default_factory=list) {% endif %} document_filter: str | None = None {% if "search" in tool_names %} @@ -49,11 +41,8 @@ class SkillState(BaseModel): {% if "search" in tool_names or "list_documents" in tool_names or "get_document" in tool_names %} documents: list[DocumentInfo] = Field(default_factory=list) {% endif %} -{% if "research" in tool_names %} - reports: list[ResearchEntry] = Field(default_factory=list) -{% endif %} -{% if "analyze" in tool_names %} - analyses: list[AnalysisEntry] = Field(default_factory=list) +{% if "execute_code" in tool_names %} + executions: list[CodeExecutionEntry] = Field(default_factory=list) {% endif %} diff --git a/haiku_rag_slim/haiku/rag/skills/_tools.py b/haiku_rag_slim/haiku/rag/skills/_tools.py index 36e9eb32..de37cf4a 100644 --- a/haiku_rag_slim/haiku/rag/skills/_tools.py +++ b/haiku_rag_slim/haiku/rag/skills/_tools.py @@ -8,57 +8,14 @@ from haiku.rag.agents.research.models import Citation from haiku.rag.config.models import AppConfig from haiku.rag.store.models.chunk import SearchResult from haiku.rag.tools.document import DocumentInfo -from haiku.rag.tools.qa import QAHistoryEntry from haiku.skills.state import SkillRunDeps -class ResearchEntry(BaseModel): - question: str - title: str - executive_summary: str - - -class AnalysisEntry(BaseModel): - question: str - answer: str - program: str | None = None - - -async def find_relevant_prior_qa( - qa_history: list[QAHistoryEntry], - query: str, - config: AppConfig, -) -> list[QAHistoryEntry]: - from haiku.rag.embeddings import get_embedder - from haiku.rag.tools.qa import PRIOR_ANSWER_RELEVANCE_THRESHOLD - from haiku.rag.utils import cosine_similarity - - if not qa_history: - return [] - - embedder = get_embedder(config) - query_embedding = await embedder.embed_query(query) - - to_embed = [] - to_embed_indices = [] - for i, qa in enumerate(qa_history): - if qa.question_embedding is None: - to_embed.append(qa.question) - to_embed_indices.append(i) - - if to_embed: - new_embeddings = await embedder.embed_documents(to_embed) - for i, idx in enumerate(to_embed_indices): - qa_history[idx].question_embedding = new_embeddings[i] - - matches = [] - for qa in qa_history: - if qa.question_embedding is not None: - similarity = cosine_similarity(query_embedding, qa.question_embedding) - if similarity >= PRIOR_ANSWER_RELEVANCE_THRESHOLD: - matches.append(qa) - - return matches +class CodeExecutionEntry(BaseModel): + code: str + stdout: str + stderr: str = "" + success: bool = True async def skill_search( @@ -88,14 +45,12 @@ async def skill_search( async def skill_list_documents( db_path: Path, config: AppConfig, - limit: int | None = None, - offset: int | None = None, filter: str | None = None, ) -> list[dict[str, Any]]: from haiku.rag.client import HaikuRAG async with HaikuRAG(db_path, config=config, read_only=True) as rag: - documents = await rag.list_documents(limit, offset, filter=filter) + documents = await rag.list_documents(filter=filter) return [ { "id": doc.id, @@ -131,100 +86,6 @@ async def skill_get_document( } -async def skill_ask( - db_path: Path, - config: AppConfig, - question: str, - qa_history: list[QAHistoryEntry] | None = None, - document_filter: str | None = None, -) -> tuple[str, list[Citation]]: - from haiku.rag.client import HaikuRAG - from haiku.rag.utils import format_citations - - ask_question = question - if qa_history: - matches = await find_relevant_prior_qa(qa_history, question, config) - if matches: - prior_parts = [] - for qa in matches: - part = f"Q: {qa.question}\nA: {qa.answer}" - if qa.citations: - part += "\n" + format_citations(qa.citations) - prior_parts.append(part) - ask_question = ( - "Context from prior questions in this session:\n\n" - + "\n\n---\n\n".join(prior_parts) - + "\n\n---\n\nCurrent question: " - + question - ) - - async with HaikuRAG(db_path, config=config, read_only=True) as rag: - answer, citations = await rag.ask( - ask_question, - filter=document_filter, - ) - - return answer, citations - - -async def skill_research( - db_path: Path, - config: AppConfig, - question: str, - document_filter: str | None = None, -) -> tuple[str, str, str]: - from haiku.rag.client import HaikuRAG - - async with HaikuRAG(db_path, config=config, read_only=True) as rag: - report = await rag.research(question, filter=document_filter) - - parts = [ - f"# {report.title}", - f"\n## Executive Summary\n{report.executive_summary}", - ] - if report.main_findings: - parts.append("\n## Main Findings") - for finding in report.main_findings: - parts.append(f"- {finding}") - if report.conclusions: - parts.append("\n## Conclusions") - for conclusion in report.conclusions: - parts.append(f"- {conclusion}") - if report.limitations: - parts.append("\n## Limitations") - for limitation in report.limitations: - parts.append(f"- {limitation}") - if report.recommendations: - parts.append("\n## Recommendations") - for rec in report.recommendations: - parts.append(f"- {rec}") - parts.append(f"\n## Sources\n{report.sources_summary}") - - formatted = "\n".join(parts) - return formatted, report.title, report.executive_summary - - -async def skill_analyze( - db_path: Path, - config: AppConfig, - question: str, - document: str | None = None, - document_filter: str | None = None, -) -> tuple[str, str, str | None, "list[Citation]"]: - from haiku.rag.client import HaikuRAG - - async with HaikuRAG(db_path, config=config, read_only=True) as rag: - documents = [document] if document else None - result = await rag.analyze( - question, documents=documents, filter=document_filter - ) - output = result.answer - if result.program: - output += f"\n\nProgram:\n{result.program}" - - return output, result.answer, result.program, result.citations - - def update_documents_state( documents_state: list[DocumentInfo], doc_dicts: list[dict[str, Any]], @@ -246,13 +107,18 @@ def _get_state(ctx: RunContext[SkillRunDeps], state_type: type[BaseModel]) -> An return None -def _append_citations(state: Any, citations: "list[Citation]") -> None: - """Index and append citations to a skill state's citations list.""" - next_index = len(state.citations) + 1 +def _register_citations(state: Any, citations: "list[Citation]") -> None: + """Add citations to the index and record the turn's chunk IDs.""" + chunk_ids = [] + next_index = len(state.citation_index) + 1 for citation in citations: - citation.index = next_index - next_index += 1 - state.citations.extend(citations) + cid = citation.chunk_id + if cid not in state.citation_index: + citation.index = next_index + next_index += 1 + state.citation_index[cid] = citation + chunk_ids.append(cid) + state.citations.append(chunk_ids) def create_skill_extras( @@ -323,6 +189,8 @@ def create_skill_tools( tools: dict[str, Any] = {} if "search" in tool_names: + max_searches = config.qa.max_searches + search_counts: dict[str, int] = {} async def search( ctx: RunContext[SkillRunDeps], query: str, limit: int | None = None @@ -335,6 +203,14 @@ def create_skill_tools( query: The search query. limit: Maximum number of results. """ + rid = ctx.run_id or "" + search_counts[rid] = search_counts.get(rid, 0) + 1 + if search_counts[rid] > max_searches: + return ( + "Search limit reached. Answer the question using " + "the results you already have." + ) + state = _get_state(ctx, state_type) formatted, results = await skill_search( db_path, @@ -353,21 +229,12 @@ def create_skill_tools( async def list_documents( ctx: RunContext[SkillRunDeps], - limit: int | None = None, - offset: int | None = None, ) -> list[dict[str, Any]]: - """List documents in the knowledge base with optional pagination. - - Args: - limit: Maximum number of documents to return. - offset: Number of documents to skip. - """ + """List all documents in the knowledge base.""" state = _get_state(ctx, state_type) result = await skill_list_documents( db_path, config, - limit, - offset, filter=state.document_filter if state else None, ) if state: @@ -395,119 +262,84 @@ def create_skill_tools( tools["get_document"] = get_document - if "ask" in tool_names: + if "execute_code" in tool_names: - async def ask(ctx: RunContext[SkillRunDeps], question: str) -> str: - """Ask a question and get an answer with citations from the knowledge base. + async def execute_code(ctx: RunContext[SkillRunDeps], code: str) -> str: + """Execute Python code in a sandboxed interpreter. + + The code has access to search(), list_documents(), llm() functions + and a virtual filesystem at /documents/ with document content and + structure (metadata.json, content.txt, items.jsonl per document). + + Use print() to output results. Each call runs in a fresh + interpreter — variables do not persist between calls. Args: - question: The question to ask. + code: Python code to execute. """ - from haiku.rag.utils import format_citations + from haiku.rag.agents.analysis.dependencies import AnalysisContext + from haiku.rag.agents.analysis.sandbox import Sandbox + from haiku.rag.client import HaikuRAG state = _get_state(ctx, state_type) - answer, citations = await skill_ask( - db_path, - config, - question, - qa_history=state.qa_history if state else None, - document_filter=state.document_filter if state else None, - ) + doc_filter = state.document_filter if state else None + context = AnalysisContext(filter=doc_filter) + + async with HaikuRAG(db_path, config=config, read_only=True) as rag: + sandbox = Sandbox(client=rag, config=config, context=context) + result = await sandbox.execute(code) + + if state and sandbox._search_results: + existing = state.searches.get("_sandbox", []) + seen = {r.chunk_id for r in existing} + for sr in sandbox._search_results: + if sr.chunk_id not in seen: + existing.append(sr) + seen.add(sr.chunk_id) + state.searches["_sandbox"] = existing if state: - _append_citations(state, citations) - state.qa_history.append( - QAHistoryEntry( - question=question, answer=answer, citations=citations + state.executions.append( + CodeExecutionEntry( + code=code, + stdout=result.stdout, + stderr=result.stderr, + success=result.success, ) ) + if result.success: + return result.stdout if result.stdout else "No output." + return f"Error: {result.stderr}\n\nOutput: {result.stdout}" + + tools["execute_code"] = execute_code + + if "cite" in tool_names: + + async def cite(ctx: RunContext[SkillRunDeps], chunk_ids: list[str]) -> str: + """Register chunk IDs as citations for your answer. + + Call this after searching, with the chunk_id values from search + results that support your answer. + + Args: + chunk_ids: List of chunk_id values from search results. + """ + from haiku.rag.agents.research.models import resolve_citations + + state = _get_state(ctx, state_type) + if not state: + return "No state available." + + all_results = [] + for results_list in state.searches.values(): + all_results.extend(results_list) + + citations = resolve_citations(chunk_ids, all_results) if citations: - answer += "\n\n" + format_citations(citations) + _register_citations(state, citations) + return f"Registered {len(citations)} citation(s)." - return answer - - tools["ask"] = ask - - if "research" in tool_names: - - async def research(ctx: RunContext[SkillRunDeps], question: str) -> str: - """Conduct deep multi-agent research on a question. - - Iteratively searches, analyzes, and synthesizes information from the - knowledge base to produce a comprehensive research report. - Only use when the user explicitly requests deep research. - - Args: - question: The research question to investigate. - """ - state = _get_state(ctx, state_type) - formatted, title, executive_summary = await skill_research( - db_path, - config, - question, - document_filter=state.document_filter if state else None, - ) - - if state: - state.reports.append( - ResearchEntry( - question=question, - title=title, - executive_summary=executive_summary, - ) - ) - state.qa_history.append( - QAHistoryEntry(question=question, answer=executive_summary) - ) - - return formatted - - tools["research"] = research - - if "analyze" in tool_names: - - async def analyze( - ctx: RunContext[SkillRunDeps], - question: str, - document: str | None = None, - ) -> str: - """Answer complex analytical questions using code execution. - - Use this for questions requiring computation, aggregation, or - data traversal across documents. - - Args: - question: The question to answer. - document: Optional document ID or title to pre-load for analysis. - """ - from haiku.rag.utils import format_citations - - state = _get_state(ctx, state_type) - state_filter = state.document_filter if state else None - output, answer, program, citations = await skill_analyze( - db_path, - config, - question, - document=document, - document_filter=state_filter, - ) - if state: - state.analyses.append( - AnalysisEntry( - question=question, - answer=answer, - program=program, - ) - ) - if citations: - _append_citations(state, citations) - - if citations: - output += "\n\n" + format_citations(citations) - - return output - - tools["analyze"] = analyze + tools["cite"] = cite return tools diff --git a/haiku_rag_slim/haiku/rag/skills/analysis.py b/haiku_rag_slim/haiku/rag/skills/analysis.py index 022ce778..97f099cc 100644 --- a/haiku_rag_slim/haiku/rag/skills/analysis.py +++ b/haiku_rag_slim/haiku/rag/skills/analysis.py @@ -6,15 +6,20 @@ from pydantic import BaseModel, Field from haiku.rag.agents.research.models import Citation from haiku.rag.config.models import AppConfig -from haiku.rag.skills._tools import AnalysisEntry +from haiku.rag.skills._tools import CodeExecutionEntry +from haiku.rag.store.models.chunk import SearchResult +from haiku.rag.tools.document import DocumentInfo from haiku.skills.models import Skill, SkillMetadata, SkillSource, StateMetadata from haiku.skills.parser import parse_skill_md class AnalysisState(BaseModel): document_filter: str | None = None - analyses: list[AnalysisEntry] = [] - citations: list[Citation] = Field(default_factory=list) + executions: list[CodeExecutionEntry] = Field(default_factory=list) + citation_index: dict[str, Citation] = Field(default_factory=dict) + citations: list[list[str]] = Field(default_factory=list) + searches: dict[str, list[SearchResult]] = Field(default_factory=dict) + documents: list[DocumentInfo] = Field(default_factory=list) STATE_TYPE = AnalysisState @@ -69,7 +74,12 @@ def create_skill( else: db_path = config.storage.data_dir / "haiku.rag.lancedb" - tools = create_skill_tools(db_path, config, AnalysisState, ["analyze"]) + tools = create_skill_tools( + db_path, + config, + AnalysisState, + ["search", "list_documents", "execute_code", "cite"], + ) extras = create_skill_extras(db_path, config) skill_instructions = instructions() diff --git a/haiku_rag_slim/haiku/rag/skills/rag-analysis/SKILL.md b/haiku_rag_slim/haiku/rag/skills/rag-analysis/SKILL.md index cf590817..31be509d 100644 --- a/haiku_rag_slim/haiku/rag/skills/rag-analysis/SKILL.md +++ b/haiku_rag_slim/haiku/rag/skills/rag-analysis/SKILL.md @@ -10,4 +10,69 @@ description: > # Analysis -Use the `analyze` tool for complex analytical questions. It writes and executes Python code against the knowledge base in a sandboxed Python interpreter. +You solve complex analytical questions by writing and executing Python code against the knowledge base. + +## Tools + +### execute_code +Execute Python code in a sandboxed interpreter. Each call runs in a fresh interpreter — variables do not persist between calls. Use `print()` to output results. + +Inside the code, these functions are available (use `await`): +- `await search(query, limit=10)` → list of dicts with keys: chunk_id, content, document_id, document_title, document_uri, score, page_numbers, headings, doc_item_refs, labels +- `await list_documents()` → list of dicts with keys: id, title, uri, created_at +- `await llm(prompt)` → string response from an LLM (for classification, summarization, extraction) + +Available modules: `json`, `re`, `math`, `pathlib` +Not supported: class definitions, generators/yield, match statements, decorators, `with` statements + +### search +Search the knowledge base directly (outside code execution). Use for initial exploration before writing code. + +### list_documents +List available documents. Use to discover what's in the knowledge base. + +### cite +Register chunk IDs as citations. Call after your analysis with chunk_id values from search results that support your answer. + +## Document Filesystem (inside execute_code) + +All documents are mounted as a virtual filesystem at `/documents/`: + +``` +/documents/{document_id}/ + metadata.json # {"id", "title", "uri", "created_at"} + content.txt # Full document text + items.jsonl # Structured items (one JSON object per line) +``` + +### metadata.json +Document metadata. Use `Path('/documents').iterdir()` to discover documents. + +### content.txt +Full text content. Use for regex or keyword search across a whole document. + +### items.jsonl +Structured document items. Each line is a JSON object with: +- `position`: sequential position in the document +- `self_ref`: item reference (e.g. "#/texts/5", "#/tables/0") +- `label`: item type — "section_header", "text", "table", "list_item", "caption", "formula", "picture", "code", "footnote" +- `text`: rendered content (tables are markdown with `|` columns) +- `page_numbers`: list of page numbers where the item appears + +### Cross-referencing search results with items +Search results include `doc_item_refs` (e.g. `["#/texts/48", "#/tables/0"]`) that correspond to `self_ref` values in items.jsonl. + +## Strategy + +1. Use `search` tool first to understand what's in the knowledge base +2. Use `execute_code` to write analysis code +3. Iterate: run code, examine output, refine approach +4. Call `cite` with chunk IDs from search results you referenced + +## Important + +- Each `execute_code` call runs in a fresh interpreter (no persistent variables between calls) +- Use `print()` to output results — the output is your only feedback +- Always execute code to answer questions — don't just describe what code would do +- Use `await` for all async functions inside execute_code (search, list_documents, llm) +- Do NOT include chunk IDs or UUIDs in your answer text — your answer should read naturally. Use the `cite` tool separately to register citations. diff --git a/haiku_rag_slim/haiku/rag/skills/rag.py b/haiku_rag_slim/haiku/rag/skills/rag.py index e9e52772..c1044831 100644 --- a/haiku_rag_slim/haiku/rag/skills/rag.py +++ b/haiku_rag_slim/haiku/rag/skills/rag.py @@ -6,10 +6,8 @@ from pydantic import BaseModel, Field from haiku.rag.agents.research.models import Citation from haiku.rag.config.models import AppConfig -from haiku.rag.skills._tools import ResearchEntry from haiku.rag.store.models.chunk import SearchResult from haiku.rag.tools.document import DocumentInfo -from haiku.rag.tools.qa import QAHistoryEntry from haiku.skills.models import Skill, SkillMetadata, SkillSource, StateMetadata from haiku.skills.parser import parse_skill_md @@ -21,7 +19,7 @@ CRITICAL RULES: 3. When a skill returns citations, always include them in your response """ -_RAG_TOOLS = ["search", "list_documents", "get_document", "ask", "research"] +_RAG_TOOLS = ["search", "list_documents", "get_document", "cite"] def get_agent_preamble(config: AppConfig) -> str: @@ -32,12 +30,11 @@ def get_agent_preamble(config: AppConfig) -> str: class RAGState(BaseModel): - citations: list[Citation] = Field(default_factory=list) - qa_history: list[QAHistoryEntry] = Field(default_factory=list) + citation_index: dict[str, Citation] = Field(default_factory=dict) + citations: list[list[str]] = Field(default_factory=list) document_filter: str | None = None searches: dict[str, list[SearchResult]] = Field(default_factory=dict) documents: list[DocumentInfo] = Field(default_factory=list) - reports: list[ResearchEntry] = Field(default_factory=list) STATE_TYPE = RAGState diff --git a/haiku_rag_slim/haiku/rag/skills/rag/SKILL.md b/haiku_rag_slim/haiku/rag/skills/rag/SKILL.md index a1c90206..77cb2939 100644 --- a/haiku_rag_slim/haiku/rag/skills/rag/SKILL.md +++ b/haiku_rag_slim/haiku/rag/skills/rag/SKILL.md @@ -5,31 +5,55 @@ description: Search, retrieve and analyze documents using RAG (Retrieval Augment # RAG -You are a RAG (Retrieval Augmented Generation) assistant with access to a document knowledge base. +You are a RAG assistant with access to a document knowledge base. Use your tools to search and answer questions. Never make up information — always use tools to get facts from the knowledge base. -## How to decide which tool to use +## Tools -**Default rule:** If the user is asking a question, use **ask**. Only use **search** when the user explicitly wants to browse or find passages. +### search +Search the knowledge base using hybrid search (vector + full-text). Returns ranked results with context-expanded content. -- **list_documents** — Use when the user wants to browse or see what documents are available (e.g., "what documents do you have?", "show me the documents", "list available docs"). -- **get_document** — Use when the user wants the full content of a specific document (e.g., "get the paper about X", "show me document Y"). Accepts a document ID, title, or URI — partial matches work. -- **search** — Use when the user wants to browse, explore, or find specific passages across documents (e.g., "search for embeddings", "find mentions of transformers"). Returns all matching results as sources. -- **ask** — Use for factual questions that need a synthesized answer (e.g., "what is DocLayNet?", "explain the methodology"). Searches, synthesizes, and returns only the chunks actually used as citations. Always include the citations in your response. -- **research** — Deep multi-agent research that produces comprehensive reports. **Only use when the user explicitly requests deep research** (e.g., "do a deep research on X", "research this topic thoroughly"). Never call this tool on your own — it is slow and expensive. +Each result includes: +- `chunk_id` in brackets and rank position (rank 1 = most relevant) +- Source: document title and section hierarchy +- Type: content type (paragraph, table, code, list_item) +- Content: the actual text -## When search returns irrelevant results +### list_documents +List available documents in the knowledge base. Use when the user wants to browse what's available. -If your first search returns results that clearly don't match the question, **do not keep searching with variations**. Instead: -- Use **ask** if the question is factual -- Report that the knowledge base doesn't contain relevant information +### get_document +Retrieve a document by ID, title, or URI. Partial matches work. Use when the user wants the full content of a specific document. + +### cite +Register chunk IDs as citations for your answer. Call this AFTER formulating your answer, with the `chunk_id` values from search results that support it. + +## How to answer questions + +1. Call `search` with relevant keywords from the question +2. Review the results — they are ordered by relevance (rank 1 = best match) +3. If needed, search again with different keywords (you have a limited number of searches) +4. Synthesize a concise answer based strictly on the retrieved content +5. Call `cite` with the chunk IDs of search results that informed your answer + +## Guidelines + +- Base answers strictly on retrieved content — do not use external knowledge +- Use the Source and Type metadata to understand context +- If multiple results are relevant, synthesize them coherently +- Be concise and direct — avoid elaboration unless asked +- If the search tool tells you the search limit is reached, stop searching and answer with what you have +- If the retrieved documents do not directly address the question, say: "I cannot find enough information in the knowledge base to answer this question." Do not guess or infer from tangentially related content. +- Do NOT include chunk IDs or UUIDs in your answer text — your answer should read naturally. Use the `cite` tool separately to register citations. ## When the user mentions a specific document If the user says "search in [doc]", "find in [doc]", or "answer from [doc]": -- Extract the **topic** as the `query`/`question` parameter -- Use **get_document** or **list_documents** first to identify the document, then search/ask with a filter +- Use `get_document` or `list_documents` first to identify the document +- Then search for the topic -Examples: -- "search for embeddings in the ML paper" -> first identify "ML paper", then search for "embeddings" -- "what does the DocLayNet paper say about annotations?" -> ask with question="what are the annotation methods?" +## When search returns irrelevant results + +If your first search returns results that clearly don't match the question: +- Try one more search with different keywords +- If still irrelevant, report that the knowledge base doesn't contain relevant information diff --git a/tests/cassettes/test_sandbox/TestSandboxDoclingDocument.test_returns_dict_for_document_with_docling_data.yaml b/tests/cassettes/test_sandbox/TestSandboxDoclingDocument.test_returns_dict_for_document_with_docling_data.yaml deleted file mode 100644 index 29228880..00000000 --- a/tests/cassettes/test_sandbox/TestSandboxDoclingDocument.test_returns_dict_for_document_with_docling_data.yaml +++ /dev/null @@ -1,42 +0,0 @@ -interactions: -- request: - headers: - accept: - - application/json - accept-encoding: - - gzip, deflate, zstd - connection: - - keep-alive - content-length: - - '95' - content-type: - - application/json - host: - - localhost:11434 - method: POST - parsed_body: - encoding_format: base64 - input: - - Docling processed content - model: qwen3-embedding:4b - uri: http://localhost:11434/v1/embeddings - response: - headers: - content-type: - - application/json - transfer-encoding: - - chunked - parsed_body: - data: - - embedding: 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 - index: 0 - object: embedding - model: qwen3-embedding:4b - object: list - usage: - prompt_tokens: 5 - total_tokens: 5 - status: - code: 200 - message: OK -version: 1 diff --git a/tests/skills/test_analysis.py b/tests/skills/test_analysis.py index 0cada1f9..040ad023 100644 --- a/tests/skills/test_analysis.py +++ b/tests/skills/test_analysis.py @@ -1,7 +1,3 @@ -from unittest.mock import AsyncMock - -from haiku.rag.agents.analysis.models import AnalysisResult -from haiku.rag.client import HaikuRAG from haiku.rag.config.models import AppConfig from haiku.rag.skills.analysis import ( STATE_NAMESPACE, @@ -64,7 +60,7 @@ class TestAnalysisSkillCreation: skill = create_skill(config=test_app_config, db_path=temp_db_path) tool_names = {getattr(t, "__name__") for t in skill.tools if callable(t)} - assert tool_names == {"analyze"} + assert tool_names == {"search", "list_documents", "execute_code", "cite"} def test_create_skill_has_state(self, test_app_config, temp_db_path): from haiku.rag.skills.analysis import AnalysisState, create_skill @@ -81,8 +77,6 @@ class TestAnalysisSkillCreation: assert skill.extras["db_path"] is temp_db_path assert "visualize_chunk" in skill.extras assert "list_documents" in skill.extras - assert callable(skill.extras["visualize_chunk"]) - assert callable(skill.extras["list_documents"]) def test_create_skill_from_env(self, monkeypatch, temp_db_path): monkeypatch.setenv("HAIKU_RAG_DB", str(temp_db_path)) @@ -118,80 +112,50 @@ class TestDomainPreambleInAnalysisSkillInstructions: assert base_instructions in skill.instructions -class TestAnalyzeTool: - async def test_analyze_returns_result(self, rag_db, monkeypatch): +class TestExecuteCodeTool: + async def test_execute_code_returns_output(self, rag_db): from haiku.rag.skills.analysis import create_skill - monkeypatch.setattr( - HaikuRAG, - "analyze", - AsyncMock(return_value=AnalysisResult(answer="42", program="print(42)")), - ) - skill = create_skill(db_path=rag_db) - analyze = _get_tool(skill, "analyze") - ctx = _make_ctx() - result = await analyze(ctx, question="How many documents?") - assert isinstance(result, str) - assert "42" in result - assert "print(42)" in result - - async def test_analyze_updates_state(self, rag_db, monkeypatch): - from haiku.rag.skills.analysis import AnalysisState, create_skill - - monkeypatch.setattr( - HaikuRAG, - "analyze", - AsyncMock(return_value=AnalysisResult(answer="42", program="print(42)")), - ) - - skill = create_skill(db_path=rag_db) - analyze = _get_tool(skill, "analyze") + execute_code = _get_tool(skill, "execute_code") state = AnalysisState() ctx = _make_ctx(state) - await analyze(ctx, question="How many documents?") - assert len(state.analyses) == 1 - assert state.analyses[0].question == "How many documents?" - assert state.analyses[0].answer == "42" - assert state.analyses[0].program == "print(42)" + result = await execute_code(ctx, code="print('hello')") + assert "hello" in result - async def test_analyze_applies_document_filter_from_state( - self, rag_db, monkeypatch - ): - from haiku.rag.skills.analysis import AnalysisState, create_skill - - captured_kwargs = {} - - async def mock_analyze(self, question, **kwargs): - captured_kwargs.update(kwargs) - return AnalysisResult(answer="42", program="print(42)") - - monkeypatch.setattr(HaikuRAG, "analyze", mock_analyze) - - skill = create_skill(db_path=rag_db) - analyze = _get_tool(skill, "analyze") - state = AnalysisState(document_filter="title = 'AI Overview'") - ctx = _make_ctx(state) - await analyze(ctx, question="How many documents?") - assert captured_kwargs.get("filter") == "title = 'AI Overview'" - - async def test_analyze_with_document(self, rag_db, monkeypatch): + async def test_execute_code_updates_state(self, rag_db): from haiku.rag.skills.analysis import create_skill - captured_kwargs = {} + skill = create_skill(db_path=rag_db) + execute_code = _get_tool(skill, "execute_code") + state = AnalysisState() + ctx = _make_ctx(state) + await execute_code(ctx, code="print('hello')") + assert len(state.executions) == 1 + assert state.executions[0].code == "print('hello')" + assert state.executions[0].success is True + assert "hello" in state.executions[0].stdout - async def mock_analyze(self, question, **kwargs): - captured_kwargs.update(kwargs) - return AnalysisResult(answer="Result", program="code()") - - monkeypatch.setattr(HaikuRAG, "analyze", mock_analyze) + async def test_execute_code_reports_errors(self, rag_db): + from haiku.rag.skills.analysis import create_skill skill = create_skill(db_path=rag_db) - analyze = _get_tool(skill, "analyze") - ctx = _make_ctx() - await analyze( - ctx, - question="Count pages", - document="AI Overview", + execute_code = _get_tool(skill, "execute_code") + state = AnalysisState() + ctx = _make_ctx(state) + result = await execute_code(ctx, code="x = 1/0") + assert "Error" in result + assert "ZeroDivisionError" in result + assert state.executions[0].success is False + + async def test_execute_code_applies_document_filter(self, rag_db): + from haiku.rag.skills.analysis import create_skill + + skill = create_skill(db_path=rag_db) + execute_code = _get_tool(skill, "execute_code") + state = AnalysisState(document_filter="title = 'AI Overview'") + ctx = _make_ctx(state) + result = await execute_code( + ctx, code="docs = await list_documents()\nprint(len(docs))" ) - assert captured_kwargs.get("documents") == ["AI Overview"] + assert "1" in result diff --git a/tests/skills/test_rag.py b/tests/skills/test_rag.py index 85ea7448..10184bb6 100644 --- a/tests/skills/test_rag.py +++ b/tests/skills/test_rag.py @@ -1,7 +1,3 @@ -from unittest.mock import AsyncMock - -from haiku.rag.agents.research.models import Citation, ResearchReport -from haiku.rag.client import HaikuRAG from haiku.rag.config.models import AppConfig from haiku.rag.skills.rag import ( STATE_NAMESPACE, @@ -13,7 +9,6 @@ from haiku.rag.skills.rag import ( ) from haiku.rag.store.models.chunk import SearchResult from haiku.rag.tools.document import DocumentInfo -from haiku.rag.tools.qa import QAHistoryEntry from haiku.skills.models import SkillMetadata, StateMetadata from .conftest import _get_tool, _make_ctx @@ -116,13 +111,7 @@ class TestRAGSkillCreation: skill = create_skill(config=test_app_config, db_path=temp_db_path) tool_names = {getattr(t, "__name__") for t in skill.tools if callable(t)} - assert tool_names == { - "search", - "list_documents", - "get_document", - "ask", - "research", - } + assert tool_names == {"search", "list_documents", "get_document", "cite"} def test_create_skill_has_state(self, test_app_config, temp_db_path): from haiku.rag.skills.rag import RAGState, create_skill @@ -139,8 +128,6 @@ class TestRAGSkillCreation: assert skill.extras["db_path"] is temp_db_path assert "visualize_chunk" in skill.extras assert "list_documents" in skill.extras - assert callable(skill.extras["visualize_chunk"]) - assert callable(skill.extras["list_documents"]) def test_create_skill_from_env(self, monkeypatch, temp_db_path): monkeypatch.setenv("HAIKU_RAG_DB", str(temp_db_path)) @@ -169,44 +156,6 @@ class TestSkillExtras: assert len(results) == 1 assert results[0]["title"] == "AI Overview" - async def test_visualize_chunk_unknown_returns_empty( - self, - test_app_config, - rag_db, - ): - from haiku.rag.skills.rag import create_skill - - skill = create_skill(config=test_app_config, db_path=rag_db) - visualize = skill.extras["visualize_chunk"] - result = await visualize("nonexistent-chunk-id") - assert result == [] - - async def test_visualize_chunk_returns_images( - self, - test_app_config, - rag_db, - monkeypatch, - ): - from haiku.rag.client import HaikuRAG - from haiku.rag.skills.rag import create_skill - - monkeypatch.setattr( - HaikuRAG, "visualize_chunk", AsyncMock(return_value=["img1"]) - ) - - skill = create_skill(config=test_app_config, db_path=rag_db) - visualize = skill.extras["visualize_chunk"] - - # Get a real chunk_id from the db - async with HaikuRAG(rag_db, read_only=True) as rag: - docs = await rag.list_documents() - doc = await rag.get_document_by_id(docs[0].id) - chunks = await rag.chunk_repository.get_by_document_id(doc.id) - chunk_id = str(chunks[0].id) - - result = await visualize(chunk_id) - assert result == ["img1"] - class TestSearchTool: async def test_search_returns_formatted_string(self, rag_db): @@ -274,7 +223,6 @@ class TestListDocumentsTool: await list_docs(ctx) assert len(state.documents) == 2 assert isinstance(state.documents[0], DocumentInfo) - assert state.documents[0].id is not None async def test_list_documents_applies_document_filter_from_state(self, rag_db): from haiku.rag.skills.rag import RAGState, create_skill @@ -287,17 +235,6 @@ class TestListDocumentsTool: assert len(results) == 1 assert results[0]["title"] == "AI Overview" - async def test_list_documents_no_duplicates_in_state(self, rag_db): - from haiku.rag.skills.rag import RAGState, create_skill - - skill = create_skill(db_path=rag_db) - list_docs = _get_tool(skill, "list_documents") - state = RAGState() - ctx = _make_ctx(state) - await list_docs(ctx) - await list_docs(ctx) - assert len(state.documents) == 2 - class TestGetDocumentTool: async def test_get_document_by_title(self, rag_db): @@ -310,18 +247,6 @@ class TestGetDocumentTool: assert result is not None assert result["title"] == "AI Overview" - async def test_get_document_updates_state(self, rag_db): - from haiku.rag.skills.rag import RAGState, create_skill - - skill = create_skill(db_path=rag_db) - get_doc = _get_tool(skill, "get_document") - state = RAGState() - ctx = _make_ctx(state) - await get_doc(ctx, query="AI Overview") - assert len(state.documents) == 1 - assert isinstance(state.documents[0], DocumentInfo) - assert state.documents[0].title == "AI Overview" - async def test_get_document_not_found(self, rag_db): from haiku.rag.skills.rag import create_skill @@ -332,343 +257,57 @@ class TestGetDocumentTool: assert result is None -class TestAskTool: - async def test_ask_returns_answer_with_citations(self, rag_db, monkeypatch): - from haiku.rag.skills.rag import create_skill - - citations = [ - Citation( - document_id="d1", - chunk_id="c1", - document_uri="test://ai-overview", - document_title="AI Overview", - content="AI is transforming industries.", - ) - ] - monkeypatch.setattr( - HaikuRAG, - "ask", - AsyncMock(return_value=("AI transforms industries worldwide.", citations)), - ) - - skill = create_skill(db_path=rag_db) - ask = _get_tool(skill, "ask") - ctx = _make_ctx() - result = await ask(ctx, question="What is AI?") - assert isinstance(result, str) - assert "AI transforms industries" in result - - async def test_ask_updates_state(self, rag_db, monkeypatch): +class TestCiteTool: + async def test_cite_registers_citations(self, rag_db): from haiku.rag.skills.rag import RAGState, create_skill - citations = [ - Citation( - document_id="d1", - chunk_id="c1", - document_uri="test://ai-overview", - content="AI content", - ) - ] - monkeypatch.setattr( - HaikuRAG, - "ask", - AsyncMock(return_value=("AI transforms industries.", citations)), - ) - skill = create_skill(db_path=rag_db) - ask = _get_tool(skill, "ask") + search = _get_tool(skill, "search") + cite = _get_tool(skill, "cite") state = RAGState() ctx = _make_ctx(state) - await ask(ctx, question="What is AI?") + + await search(ctx, query="artificial intelligence") + chunk_ids = [ + sr.chunk_id + for results in state.searches.values() + for sr in results + if sr.chunk_id + ][:2] + + result = await cite(ctx, chunk_ids=chunk_ids) + assert "Registered" in result assert len(state.citations) == 1 - assert len(state.qa_history) == 1 - assert isinstance(state.qa_history[0], QAHistoryEntry) - assert state.qa_history[0].question == "What is AI?" + assert len(state.citations[0]) == 2 + assert all(cid in state.citation_index for cid in chunk_ids) - async def test_ask_assigns_citation_indices(self, rag_db, monkeypatch): + async def test_cite_deduplicates_in_index(self, rag_db): from haiku.rag.skills.rag import RAGState, create_skill - first_citations = [ - Citation( - document_id="d1", - chunk_id="c1", - document_uri="test://doc1", - content="First.", - ), - Citation( - document_id="d2", - chunk_id="c2", - document_uri="test://doc2", - content="Second.", - ), - ] - second_citations = [ - Citation( - document_id="d3", - chunk_id="c3", - document_uri="test://doc3", - content="Third.", - ), - ] - - call_count = 0 - - async def mock_ask(self, question, **kwargs): - nonlocal call_count - call_count += 1 - if call_count == 1: - return ("Answer 1", first_citations) - return ("Answer 2", second_citations) - - monkeypatch.setattr(HaikuRAG, "ask", mock_ask) - skill = create_skill(db_path=rag_db) - ask = _get_tool(skill, "ask") + search = _get_tool(skill, "search") + cite = _get_tool(skill, "cite") state = RAGState() ctx = _make_ctx(state) - await ask(ctx, question="First question") - assert state.citations[0].index == 1 - assert state.citations[1].index == 2 + await search(ctx, query="artificial intelligence") + chunk_ids = [ + sr.chunk_id + for results in state.searches.values() + for sr in results + if sr.chunk_id + ][:1] - await ask(ctx, question="Second question") - assert state.citations[2].index == 3 + await cite(ctx, chunk_ids=chunk_ids) + await cite(ctx, chunk_ids=chunk_ids) + assert len(state.citation_index) == 1 + assert len(state.citations) == 2 - async def test_ask_applies_document_filter_from_state(self, rag_db, monkeypatch): - from haiku.rag.skills.rag import RAGState, create_skill - - captured_kwargs = {} - - async def mock_ask(self, question, **kwargs): - captured_kwargs.update(kwargs) - return ("Answer.", []) - - monkeypatch.setattr(HaikuRAG, "ask", mock_ask) - - skill = create_skill(db_path=rag_db) - ask = _get_tool(skill, "ask") - state = RAGState(document_filter="title = 'AI Overview'") - ctx = _make_ctx(state) - await ask(ctx, question="What is AI?") - assert captured_kwargs.get("filter") == "title = 'AI Overview'" - - async def test_ask_includes_prior_qa_context(self, rag_db, monkeypatch): - import random - - from haiku.rag.skills.rag import RAGState, create_skill - from tests.skills.conftest import VECTOR_DIM - - captured_questions = [] - - async def mock_ask(self, question, **kwargs): - captured_questions.append(question) - return ("Answer about AI.", []) - - monkeypatch.setattr(HaikuRAG, "ask", mock_ask) - - skill = create_skill(db_path=rag_db) - ask = _get_tool(skill, "ask") - - # Pre-compute the embedding the fake embedder will produce for "Tell me about AI" - query_text = "Tell me about AI" - random.seed(hash(query_text) % (2**32)) - query_embedding = [random.random() for _ in range(VECTOR_DIM)] - - prior_citations = [ - Citation( - document_id="d1", - chunk_id="c1", - document_uri="test://ai-overview", - document_title="AI Overview", - content="AI content from source.", - ) - ] - state = RAGState( - qa_history=[ - QAHistoryEntry( - question="What is artificial intelligence?", - answer="AI is the simulation of human intelligence by machines.", - question_embedding=query_embedding, - citations=prior_citations, - ), - ] - ) - ctx = _make_ctx(state) - await ask(ctx, question=query_text) - - # rag.ask() should receive augmented question with prior context - assert len(captured_questions) == 1 - augmented = captured_questions[0] - assert "Context from prior questions" in augmented - assert "What is artificial intelligence?" in augmented - assert "AI is the simulation" in augmented - assert "AI Overview" in augmented - assert query_text in augmented - - # State should store the original question, not the augmented one - assert state.qa_history[-1].question == query_text - - async def test_ask_embeds_prior_qa_on_demand(self, rag_db, monkeypatch): - from haiku.rag.skills.rag import RAGState, create_skill - from tests.skills.conftest import VECTOR_DIM - - captured_questions = [] - - async def mock_ask(self, question, **kwargs): - captured_questions.append(question) - return ("Answer about AI.", []) - - monkeypatch.setattr(HaikuRAG, "ask", mock_ask) - - skill = create_skill(db_path=rag_db) - ask = _get_tool(skill, "ask") - - # Use the same question text for the prior QA entry and query so - # their fake embeddings are identical (cosine similarity = 1.0). - prior_question = "Tell me about AI" - query_text = prior_question - - # Leave question_embedding=None to exercise the lazy embedding path - state = RAGState( - qa_history=[ - QAHistoryEntry( - question=prior_question, - answer="AI is the simulation of human intelligence by machines.", - question_embedding=None, - ), - ] - ) - ctx = _make_ctx(state) - await ask(ctx, question=query_text) - - # The lazy embedding should have populated question_embedding - assert state.qa_history[0].question_embedding is not None - assert len(state.qa_history[0].question_embedding) == VECTOR_DIM - - # The augmented question should include prior context - assert len(captured_questions) == 1 - assert "Context from prior questions" in captured_questions[0] - assert prior_question in captured_questions[0] - - async def test_ask_no_prior_qa_context_when_irrelevant(self, rag_db, monkeypatch): - from haiku.rag.skills.rag import RAGState, create_skill - from tests.skills.conftest import VECTOR_DIM - - captured_questions = [] - - async def mock_ask(self, question, **kwargs): - captured_questions.append(question) - return ("Answer.", []) - - monkeypatch.setattr(HaikuRAG, "ask", mock_ask) - - skill = create_skill(db_path=rag_db) - ask = _get_tool(skill, "ask") - - # Use orthogonal embedding — won't match the fake embedder's output - orthogonal = [1.0 if i % 2 == 0 else -1.0 for i in range(VECTOR_DIM)] - state = RAGState( - qa_history=[ - QAHistoryEntry( - question="What is the weather?", - answer="It is sunny today.", - question_embedding=orthogonal, - ), - ] - ) - ctx = _make_ctx(state) - await ask(ctx, question="Explain quantum computing") - - # rag.ask() should receive the original question unchanged - assert len(captured_questions) == 1 - assert captured_questions[0] == "Explain quantum computing" - - -class TestResearchTool: - async def test_research_returns_report(self, rag_db, monkeypatch): + async def test_cite_without_state(self, rag_db): from haiku.rag.skills.rag import create_skill - report = ResearchReport( - title="AI Research", - executive_summary="AI is transforming industries.", - main_findings=["Finding 1"], - conclusions=["Conclusion 1"], - sources_summary="Multiple sources consulted.", - ) - monkeypatch.setattr(HaikuRAG, "research", AsyncMock(return_value=report)) - skill = create_skill(db_path=rag_db) - research = _get_tool(skill, "research") - ctx = _make_ctx() - result = await research(ctx, question="What is AI?") - assert isinstance(result, str) - assert "AI Research" in result - - async def test_research_updates_state(self, rag_db, monkeypatch): - from haiku.rag.skills.rag import RAGState, create_skill - - report = ResearchReport( - title="AI Research", - executive_summary="AI is transforming industries.", - main_findings=["Finding 1"], - conclusions=["Conclusion 1"], - sources_summary="Multiple sources consulted.", - ) - monkeypatch.setattr(HaikuRAG, "research", AsyncMock(return_value=report)) - - skill = create_skill(db_path=rag_db) - research = _get_tool(skill, "research") - state = RAGState() - ctx = _make_ctx(state) - await research(ctx, question="What is AI?") - assert len(state.reports) == 1 - assert state.reports[0].question == "What is AI?" - assert len(state.qa_history) == 1 - assert state.qa_history[0].question == "What is AI?" - assert state.qa_history[0].answer == "AI is transforming industries." - - async def test_research_applies_document_filter_from_state( - self, rag_db, monkeypatch - ): - from haiku.rag.skills.rag import RAGState, create_skill - - captured_kwargs = {} - - report = ResearchReport( - title="AI Research", - executive_summary="Summary.", - main_findings=["Finding"], - conclusions=["Conclusion"], - sources_summary="Sources.", - ) - - async def mock_research(self, question, **kwargs): - captured_kwargs.update(kwargs) - return report - - monkeypatch.setattr(HaikuRAG, "research", mock_research) - - skill = create_skill(db_path=rag_db) - research = _get_tool(skill, "research") - state = RAGState(document_filter="title = 'AI Overview'") - ctx = _make_ctx(state) - await research(ctx, question="What is AI?") - assert captured_kwargs.get("filter") == "title = 'AI Overview'" - - async def test_research_without_state(self, rag_db, monkeypatch): - from haiku.rag.skills.rag import create_skill - - report = ResearchReport( - title="AI Research", - executive_summary="Summary.", - main_findings=["Finding"], - conclusions=["Conclusion"], - sources_summary="Sources.", - ) - monkeypatch.setattr(HaikuRAG, "research", AsyncMock(return_value=report)) - - skill = create_skill(db_path=rag_db) - research = _get_tool(skill, "research") + cite = _get_tool(skill, "cite") ctx = _make_ctx(state=None) - result = await research(ctx, question="What is AI?") - assert isinstance(result, str) + result = await cite(ctx, chunk_ids=["nonexistent"]) + assert "No state" in result From 68a9f191d2a063e43e9d266114d0078d0d12fb9a Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Mon, 20 Apr 2026 09:48:18 +0300 Subject: [PATCH 13/24] Remove documents from state, fix tests --- app/frontend/lib/sessionStorage.ts | 9 ---- .../skill_generator/templates/__init__.py.j2 | 6 --- haiku_rag_slim/haiku/rag/skills/_tools.py | 25 +--------- haiku_rag_slim/haiku/rag/skills/analysis.py | 2 - haiku_rag_slim/haiku/rag/skills/rag.py | 2 - tests/skills/test_rag.py | 12 ----- tests/test_skill_generator.py | 48 +++++++++---------- 7 files changed, 24 insertions(+), 80 deletions(-) diff --git a/app/frontend/lib/sessionStorage.ts b/app/frontend/lib/sessionStorage.ts index 47737433..22a6b75f 100644 --- a/app/frontend/lib/sessionStorage.ts +++ b/app/frontend/lib/sessionStorage.ts @@ -9,20 +9,12 @@ export interface Citation { content: string; } -export interface DocumentInfo { - id: string; - title: string; - uri: string; - created: string; -} - // Matches RAGState from the backend skill export interface RAGState { citation_index: Record; citations: string[][]; document_filter: string | null; searches: Record; - documents: DocumentInfo[]; } export interface StoredMessage { @@ -50,7 +42,6 @@ export function normalizeRAGState(state?: Partial): RAGState { citations: state?.citations ?? [], document_filter: state?.document_filter ?? null, searches: state?.searches ?? {}, - documents: state?.documents ?? [], }; } diff --git a/haiku_rag_slim/haiku/rag/skill_generator/templates/__init__.py.j2 b/haiku_rag_slim/haiku/rag/skill_generator/templates/__init__.py.j2 index 829d006b..f342e6ee 100644 --- a/haiku_rag_slim/haiku/rag/skill_generator/templates/__init__.py.j2 +++ b/haiku_rag_slim/haiku/rag/skill_generator/templates/__init__.py.j2 @@ -8,9 +8,6 @@ from haiku.skills.parser import parse_skill_md {% if "cite" in tool_names %} from haiku.rag.agents.research.models import Citation {% endif %} -{% if "search" in tool_names or "list_documents" in tool_names or "get_document" in tool_names %} -from haiku.rag.tools.document import DocumentInfo -{% endif %} {% if "search" in tool_names %} from haiku.rag.store.models.chunk import SearchResult {% endif %} @@ -38,9 +35,6 @@ class SkillState(BaseModel): {% if "search" in tool_names %} searches: dict[str, list[SearchResult]] = Field(default_factory=dict) {% endif %} -{% if "search" in tool_names or "list_documents" in tool_names or "get_document" in tool_names %} - documents: list[DocumentInfo] = Field(default_factory=list) -{% endif %} {% if "execute_code" in tool_names %} executions: list[CodeExecutionEntry] = Field(default_factory=list) {% endif %} diff --git a/haiku_rag_slim/haiku/rag/skills/_tools.py b/haiku_rag_slim/haiku/rag/skills/_tools.py index de37cf4a..8e7f9270 100644 --- a/haiku_rag_slim/haiku/rag/skills/_tools.py +++ b/haiku_rag_slim/haiku/rag/skills/_tools.py @@ -7,7 +7,6 @@ from pydantic_ai import RunContext from haiku.rag.agents.research.models import Citation from haiku.rag.config.models import AppConfig from haiku.rag.store.models.chunk import SearchResult -from haiku.rag.tools.document import DocumentInfo from haiku.skills.state import SkillRunDeps @@ -86,21 +85,6 @@ async def skill_get_document( } -def update_documents_state( - documents_state: list[DocumentInfo], - doc_dicts: list[dict[str, Any]], -) -> None: - for doc_dict in doc_dicts: - doc_info = DocumentInfo( - id=str(doc_dict["id"]), - title=doc_dict["title"] or "Untitled", - uri=doc_dict.get("uri") or "", - created=doc_dict.get("created_at", ""), - ) - if not any(d.id == doc_info.id for d in documents_state): - documents_state.append(doc_info) - - def _get_state(ctx: RunContext[SkillRunDeps], state_type: type[BaseModel]) -> Any: if ctx.deps and ctx.deps.state and isinstance(ctx.deps.state, state_type): return ctx.deps.state @@ -237,8 +221,6 @@ def create_skill_tools( config, filter=state.document_filter if state else None, ) - if state: - update_documents_state(state.documents, result) return result tools["list_documents"] = list_documents @@ -253,12 +235,7 @@ def create_skill_tools( Args: query: Document ID, title, or URI to look up. """ - result = await skill_get_document(db_path, config, query) - if result is not None: - state = _get_state(ctx, state_type) - if state: - update_documents_state(state.documents, [result]) - return result + return await skill_get_document(db_path, config, query) tools["get_document"] = get_document diff --git a/haiku_rag_slim/haiku/rag/skills/analysis.py b/haiku_rag_slim/haiku/rag/skills/analysis.py index 97f099cc..63d2ea2c 100644 --- a/haiku_rag_slim/haiku/rag/skills/analysis.py +++ b/haiku_rag_slim/haiku/rag/skills/analysis.py @@ -8,7 +8,6 @@ from haiku.rag.agents.research.models import Citation from haiku.rag.config.models import AppConfig from haiku.rag.skills._tools import CodeExecutionEntry from haiku.rag.store.models.chunk import SearchResult -from haiku.rag.tools.document import DocumentInfo from haiku.skills.models import Skill, SkillMetadata, SkillSource, StateMetadata from haiku.skills.parser import parse_skill_md @@ -19,7 +18,6 @@ class AnalysisState(BaseModel): citation_index: dict[str, Citation] = Field(default_factory=dict) citations: list[list[str]] = Field(default_factory=list) searches: dict[str, list[SearchResult]] = Field(default_factory=dict) - documents: list[DocumentInfo] = Field(default_factory=list) STATE_TYPE = AnalysisState diff --git a/haiku_rag_slim/haiku/rag/skills/rag.py b/haiku_rag_slim/haiku/rag/skills/rag.py index c1044831..a660c723 100644 --- a/haiku_rag_slim/haiku/rag/skills/rag.py +++ b/haiku_rag_slim/haiku/rag/skills/rag.py @@ -7,7 +7,6 @@ from pydantic import BaseModel, Field from haiku.rag.agents.research.models import Citation from haiku.rag.config.models import AppConfig from haiku.rag.store.models.chunk import SearchResult -from haiku.rag.tools.document import DocumentInfo from haiku.skills.models import Skill, SkillMetadata, SkillSource, StateMetadata from haiku.skills.parser import parse_skill_md @@ -34,7 +33,6 @@ class RAGState(BaseModel): citations: list[list[str]] = Field(default_factory=list) document_filter: str | None = None searches: dict[str, list[SearchResult]] = Field(default_factory=dict) - documents: list[DocumentInfo] = Field(default_factory=list) STATE_TYPE = RAGState diff --git a/tests/skills/test_rag.py b/tests/skills/test_rag.py index 10184bb6..d39f080b 100644 --- a/tests/skills/test_rag.py +++ b/tests/skills/test_rag.py @@ -8,7 +8,6 @@ from haiku.rag.skills.rag import ( state_metadata, ) from haiku.rag.store.models.chunk import SearchResult -from haiku.rag.tools.document import DocumentInfo from haiku.skills.models import SkillMetadata, StateMetadata from .conftest import _get_tool, _make_ctx @@ -213,17 +212,6 @@ class TestListDocumentsTool: assert isinstance(results, list) assert len(results) == 2 - async def test_list_documents_updates_state(self, rag_db): - from haiku.rag.skills.rag import RAGState, create_skill - - skill = create_skill(db_path=rag_db) - list_docs = _get_tool(skill, "list_documents") - state = RAGState() - ctx = _make_ctx(state) - await list_docs(ctx) - assert len(state.documents) == 2 - assert isinstance(state.documents[0], DocumentInfo) - async def test_list_documents_applies_document_filter_from_state(self, rag_db): from haiku.rag.skills.rag import RAGState, create_skill diff --git a/tests/test_skill_generator.py b/tests/test_skill_generator.py index d76f053b..40fde312 100644 --- a/tests/test_skill_generator.py +++ b/tests/test_skill_generator.py @@ -23,9 +23,8 @@ class TestAvailableTools: "list_documents", "get_document", "search", - "ask", - "research", - "analyze", + "execute_code", + "cite", } @@ -43,7 +42,7 @@ class TestValidateTools: validate_tools(["search"]) def test_valid_multiple_tools(self): - validate_tools(["list_documents", "get_document", "search", "ask"]) + validate_tools(["list_documents", "get_document", "search", "cite"]) def test_valid_all_tools(self): validate_tools(list(AVAILABLE_TOOLS)) @@ -97,7 +96,7 @@ class TestRenderTemplates: output_dir=tmp_path, name="recipes", description="A recipe skill.", - tool_names=["list_documents", "get_document", "search", "ask"], + tool_names=["list_documents", "get_document", "search", "cite"], ) assert result == tmp_path / "recipes-skill" assert result.is_dir() @@ -112,7 +111,7 @@ class TestRenderTemplates: output_dir=tmp_path, name="my-recipes", description="A recipe skill.", - tool_names=["search", "ask"], + tool_names=["search", "cite"], ) assert result == tmp_path / "my-recipes-skill" pkg = result / "my_recipes_skill" @@ -129,11 +128,11 @@ class TestRenderTemplates: output_dir=tmp_path, name="docs", description="A docs skill.", - tool_names=["search", "ask"], + tool_names=["search", "cite"], ) init = tmp_path / "docs-skill" / "docs_skill" / "__init__.py" content = init.read_text() - assert '["search", "ask"]' in content + assert '["search", "cite"]' in content def test_create_skill_tools_called(self, tmp_path): render_templates( @@ -151,12 +150,12 @@ class TestRenderTemplates: output_dir=tmp_path, name="recipes", description="A recipe skill.", - tool_names=["search", "ask"], + tool_names=["search", "cite"], ) init = tmp_path / "recipes-skill" / "recipes_skill" / "__init__.py" content = init.read_text() assert '"search"' in content - assert '"ask"' in content + assert '"cite"' in content def test_pyproject_toml(self, tmp_path): render_templates( @@ -184,24 +183,23 @@ class TestRenderTemplates: ) skill_md = tmp_path / "docs-skill" / "docs_skill" / "SKILL.md" content = skill_md.read_text() - assert "**search**" in content - assert "**ask**" not in content - assert "**list_documents**" not in content - assert "**research**" not in content - assert "**analyze**" not in content + assert "### search" in content + assert "### cite" not in content + assert "### list_documents" not in content + assert "### execute_code" not in content def test_skill_md_includes_all_selected(self, tmp_path): render_templates( output_dir=tmp_path, name="docs", description="A docs skill.", - tool_names=["search", "ask", "analyze"], + tool_names=["search", "execute_code", "cite"], ) skill_md = tmp_path / "docs-skill" / "docs_skill" / "SKILL.md" content = skill_md.read_text() - assert "**search**" in content - assert "**ask**" in content - assert "**analyze**" in content + assert "search" in content + assert "execute_code" in content + assert "cite" in content def test_custom_preamble(self, tmp_path): render_templates( @@ -226,23 +224,23 @@ class TestRenderTemplates: content = init.read_text() assert 'state_namespace="recipes"' in content - def test_analyze_state_fields(self, tmp_path): + def test_execute_code_state_fields(self, tmp_path): render_templates( output_dir=tmp_path, name="docs", description="A docs skill.", - tool_names=["search", "analyze"], + tool_names=["search", "execute_code"], ) init = tmp_path / "docs-skill" / "docs_skill" / "__init__.py" content = init.read_text() - assert "analyses" in content + assert "executions" in content def test_imports_from_shared_tools(self, tmp_path): render_templates( output_dir=tmp_path, name="recipes", description="A recipe skill.", - tool_names=["search", "ask", "analyze"], + tool_names=["search", "execute_code", "cite"], ) init = tmp_path / "recipes-skill" / "recipes_skill" / "__init__.py" content = init.read_text() @@ -329,7 +327,7 @@ class TestGenerateSkill: output_dir=tmp_path, name="recipes", description="A recipe skill.", - tool_names=["search", "ask"], + tool_names=["search", "cite"], ) assert result == tmp_path / "recipes-skill" assets = result / "recipes_skill" / "assets" @@ -479,7 +477,7 @@ class TestGenerateSkillRemote: output_dir=tmp_path, name="recipes", description="A recipe skill.", - tool_names=["search", "ask"], + tool_names=["search", "cite"], config_path=config_file, ) assets = result / "recipes_skill" / "assets" From fa87cf79c5a290fbce7511e013092b52c7eed853 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Mon, 20 Apr 2026 10:00:34 +0300 Subject: [PATCH 14/24] Docs & cl --- CHANGELOG.md | 31 ++-- app/frontend/components/Chat.tsx | 32 ++--- docs/apps.md | 3 +- docs/architecture.md | 233 ------------------------------- docs/cli.md | 4 +- docs/index.md | 1 - docs/skills/analysis.md | 29 ++-- docs/skills/index.md | 2 +- docs/skills/rag.md | 21 ++- mkdocs.yml | 1 - 10 files changed, 62 insertions(+), 295 deletions(-) delete mode 100644 docs/architecture.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dcd837a..c5ab3b8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,27 +3,38 @@ ### Added -- **Document virtual filesystem in analysis sandbox**: Documents are mounted at `/documents/{id}/` with `metadata.json` (eager), `content.txt` (lazy), and `items.jsonl` (lazy). The agent uses standard Python `pathlib.Path` to browse and read document content and structure. -- **`doc_item_refs` and `labels` in search results**: Search results now include document item references and labels for cross-referencing with `items.jsonl`. -- **`--skill` flag for chat TUI**: `haiku-rag chat -s rag -s analysis` to enable specific skills. Defaults to `rag`. Use `-s analysis` for code execution, or both for the full toolset. +- **Document virtual filesystem in analysis sandbox**: Documents mounted at `/documents/{id}/` with `metadata.json` (eager), `content.txt` (lazy), and `items.jsonl` (lazy). Standard Python `pathlib.Path` for browsing and reading document content and structure. +- **`execute_code` skill tool**: Direct code execution in the sandbox, surfaced as individual AG-UI events in the chat TUI +- **`cite` skill tool**: Explicit citation registration with per-turn tracking via `citation_index` and `citations` fields in state +- **`--skill` flag for chat TUI**: `haiku-rag chat -s rag -s analysis` to enable specific skills +- **`--model` overrides all agents**: Chat, QA, research, and analysis agents all use the specified model +- **Collapsible program display in chat TUI**: Analysis code execution results shown as expandable code blocks ### Changed -- **Analysis sandbox `search()` now returns expanded results**: Search results automatically include surrounding context (adjacent paragraphs, complete tables, section content) via the document_items table -- **BREAKING**: Rename RLM agent to analysis agent throughout: +- **BREAKING: Flatten skill architecture**: Skill sub-agents now call `search`, `execute_code`, `cite`, `list_documents`, `get_document` directly — every tool call surfaces as an AG-UI event. Removes the 3rd agent layer where `ask`/`analyze`/`research` spawned inner agents whose tool calls were invisible. +- **BREAKING: Rename RLM agent to analysis agent** throughout: - `agents/rlm/` → `agents/analysis/`, all classes renamed (`RLMResult` → `AnalysisResult`, etc.) - `client.rlm()` → `client.analyze()` - CLI: `haiku-rag rlm` → `haiku-rag analyze` - MCP: `rlm_question` → `analyze` - Config: `rlm:` → `analysis:` in YAML, `RLMConfig` → `AnalysisConfig` - - Skill: `rag-rlm` → `rag-analysis`, `skills/rlm.py` → `skills/analysis.py` - - State namespace: `"rlm"` → `"analysis"` + - Skill entrypoint: `rag-rlm` → `rag-analysis` +- **Analysis sandbox `search()` returns expanded results** with `doc_item_refs` and `labels` for cross-referencing with `items.jsonl` +- **`list_documents` skill tool** takes no parameters — returns all documents +- **Per-turn citation tracking**: `citation_index: dict[str, Citation]` (deduplicated) + `citations: list[list[str]]` (per-turn chunk IDs) replaces flat citation list +- **Search rate limiting**: Skill search tool enforces `config.qa.max_searches` ### Removed -- **`get_document()`, `get_docling_document()`**: Removed from analysis sandbox — replaced by the document virtual filesystem -- **`get_chunk()`**: Removed from analysis sandbox — search results now include expanded context automatically -- **`create_analysis_toolset()`**: Removed unused `tools/analysis.py` module. +- **`ask` skill tool**: Replaced by direct `search` + `cite` — the skill sub-agent searches and answers directly +- **`analyze` skill tool**: Replaced by direct `execute_code` + `search` + `cite` +- **`research` skill tool**: Removed from skill layer (still available via CLI `haiku-rag research` and MCP) +- **`get_document()`, `get_docling_document()`**: Removed from analysis sandbox — replaced by VFS +- **`get_chunk()`**: Removed from analysis sandbox — search results include expanded context +- **`create_analysis_toolset()`**: Removed unused `tools/analysis.py` module +- **`qa_history`, `reports` from skill state**: Conversational context handled by the outer chat agent +- **`combine_filters`, `build_document_filter`**: Removed from public API ## [0.40.1] - 2026-04-17 diff --git a/app/frontend/components/Chat.tsx b/app/frontend/components/Chat.tsx index f820da4d..9fbb3028 100644 --- a/app/frontend/components/Chat.tsx +++ b/app/frontend/components/Chat.tsx @@ -148,11 +148,11 @@ function ToolCallIndicator({ switch (toolName) { case "search": return ; - case "ask": - return ; case "get_document": return ; case "execute_skill": + case "execute_code": + case "cite": return ; default: return ; @@ -163,16 +163,16 @@ function ToolCallIndicator({ switch (toolName) { case "search": return "Search"; - case "ask": - return "Ask"; case "get_document": return "Document"; case "execute_skill": return "Skill"; - case "analyze": - return "Analyze"; - case "research": - return "Research"; + case "execute_code": + return "Code"; + case "cite": + return "Cite"; + case "list_documents": + return "Documents"; default: return toolName; } @@ -194,16 +194,16 @@ function ToolCallIndicator({ const query = args.query as string; return {query}; } - case "ask": { - const question = args.question as string; - return {question}; - } case "get_document": return {args.query as string}; - case "analyze": - return {args.question as string}; - case "research": - return {args.question as string}; + case "execute_code": { + const code = args.code as string | undefined; + return ( + + {code ? code.slice(0, 80) : "Running code..."} + + ); + } default: return Processing...; } diff --git a/docs/apps.md b/docs/apps.md index b15ef5db..4da9dcdf 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -52,7 +52,6 @@ Press `Ctrl+P` to open the command palette: ### Session Management - Conversation history is maintained in memory for the session -- Previous Q/A pairs are automatically used as context for follow-up questions via the `ask` tool - Citations are tracked per response and can be inspected - Document filter restricts all searches to selected documents - Clearing chat resets session state @@ -67,7 +66,7 @@ Browser-based conversational RAG with a CopilotKit frontend. - Expandable citations with source documents, pages, and headings - Visual grounding to view chunk source locations in documents - Document filter to restrict searches to selected documents -- Session state view for inspecting accumulated Q&A history, citations, and documents +- Session state view for inspecting citations and search results ### Quick Start diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index 541c1ff1..00000000 --- a/docs/architecture.md +++ /dev/null @@ -1,233 +0,0 @@ -# Architecture - -High-level overview of haiku.rag components and data flow. - -## System Overview - -```mermaid -flowchart TB - subgraph Sources["Document Sources"] - Files[Files] - URLs[URLs] - Text[Text] - end - - subgraph Processing["Processing Pipeline"] - Converter[Converter] - Chunker[Chunker] - Embedder[Embedder] - end - - subgraph Storage["Storage Layer"] - LanceDB[(LanceDB)] - end - - subgraph Agents["Agent Layer"] - QA[QA Agent] - Skill[RAG Skill] - Research[Research Graph] - Analysis[Analysis Agent] - end - - subgraph Apps["Applications"] - CLI[CLI] - ChatTUI[Chat TUI] - WebApp[Web App] - Inspector[Inspector] - MCP[MCP Server] - end - - Sources --> Converter - Converter --> Chunker - Chunker --> Embedder - Embedder --> LanceDB - - LanceDB --> Agents - Agents --> Apps -``` - -## Core Components - -### Storage Layer - -LanceDB provides vector storage with full-text search capabilities: - -- **DocumentRecord** - Document metadata and full content -- **ChunkRecord** - Text chunks with embeddings and structural metadata -- **SettingsRecord** - Database configuration and version info - -Repositories handle CRUD operations: - -- `DocumentRepository` - Create, read, update, delete documents -- `ChunkRepository` - Chunk management and hybrid search -- `SettingsRepository` - Configuration persistence - -### Processing Pipeline - -```mermaid -flowchart LR - Source[Source] --> Converter - Converter --> DoclingDoc[DoclingDocument] - DoclingDoc --> Chunker - Chunker --> Chunks[Chunks] - Chunks --> Embedder - Embedder --> Vectors[Vectors] - Vectors --> DB[(LanceDB)] -``` - -**Converters** transform sources into DoclingDocuments: - -- `docling-local` - Local Docling processing -- `docling-serve` - Remote processing via docling-serve - -**Chunkers** split documents into semantic chunks: - -- Preserves document structure (tables, lists, code blocks) -- Maintains provenance (page numbers, headings) -- Configurable chunk size - -**Embedders** generate vector representations: - -| Provider | Models | -|----------|--------| -| Ollama | nomic-embed-text, mxbai-embed-large | -| OpenAI | text-embedding-3-small, text-embedding-3-large | -| VoyageAI | voyage-3, voyage-code-3 | -| vLLM | Any compatible model | -| LM Studio | Any compatible model | - -### Agent Layer - -Three agent types and a RAG skill for different use cases: - -```mermaid -flowchart TB - subgraph QA["QA Agent"] - Q1[Question] --> S1[Search] - S1 --> A1[Answer] - end - - subgraph Skill["RAG Skill"] - Q2[Question] --> Tools[Tool Selection] - Tools --> S2[Search / Ask / Analyze] - S2 --> A2[Answer] - A2 --> State[RAG State] - State -.-> Q2 - end - - subgraph Research["Research Graph"] - Q3[Question] --> Plan[Plan Next] - Plan --> SearchOne[Search One] - SearchOne --> Eval[Evaluate] - Eval -->|Continue| Plan - Eval -->|Done| Synthesize[Synthesize] - end - - subgraph AnalysisAgent["Analysis Agent"] - Q4[Question] --> Code[Write Code] - Code --> Execute[Execute] - Execute --> Examine[Examine Results] - Examine -->|Iterate| Code - Examine -->|Done| A4[Answer] - end -``` - -**QA Agent** - Single-turn question answering: - -- Searches for relevant chunks -- Expands context around results -- Generates answer with optional citations - -**RAG Skill** - Multi-turn conversational RAG via [haiku.skills](https://github.com/ggozad/haiku.skills): - -- Bundles search, list_documents, get_document, ask, analyze, and research tools -- Managed `RAGState` for session state (citations, QA history, document filters) -- Integrates with any pydantic-ai agent via `SkillToolset` -- Powers both the Chat TUI and web application - -**Research Graph** - Iterative research workflow: - -- Proposes one question at a time, evaluates the answer, then decides whether to continue -- Prior answers let the planner skip redundant searches -- Synthesizes structured report - -**Analysis Agent** - Complex analytical tasks via code execution: - -- Writes Python code to explore the knowledge base -- Executes in sandboxed environment -- Handles aggregation, computation, multi-document analysis -- Iterates until answer is found - -### Applications - -| Application | Interface | Use Case | -|-------------|-----------|----------| -| CLI | Command line | Scripts, one-off queries, batch processing | -| Chat TUI | Terminal | Interactive conversations | -| Web App | Browser | Team collaboration, visual interface | -| Inspector | Terminal | Database exploration, debugging | -| MCP Server | Protocol | AI assistant integration | - -## Data Flow - -### Document Ingestion - -```mermaid -sequenceDiagram - participant User - participant CLI - participant Converter - participant Chunker - participant Embedder - participant DB as LanceDB - - User->>CLI: add-src document.pdf - CLI->>Converter: Convert to DoclingDocument - Converter-->>CLI: DoclingDocument - CLI->>Chunker: Split into chunks - Chunker-->>CLI: Chunks with metadata - CLI->>Embedder: Generate embeddings - Embedder-->>CLI: Vectors - CLI->>DB: Store document + chunks - DB-->>User: Document ID -``` - -### Search and QA - -```mermaid -sequenceDiagram - participant User - participant Agent - participant Embedder - participant DB as LanceDB - participant LLM - - User->>Agent: Ask question - Agent->>Embedder: Embed query - Embedder-->>Agent: Query vector - Agent->>DB: Hybrid search - DB-->>Agent: Relevant chunks - Agent->>Agent: Expand context - Agent->>LLM: Generate answer - LLM-->>Agent: Answer + citations - Agent-->>User: Response -``` - -## Configuration - -Configuration flows through the system: - -``` -CLI args → Environment variables → haiku.rag.yaml → Defaults -``` - -Key configuration areas: - -- **Storage** - Database path, vacuum settings -- **Embeddings** - Provider, model, dimensions -- **Processing** - Chunk size, converter, chunker -- **Search** - Limits, context expansion -- **QA/Research** - Model, iterations, concurrency -- **Providers** - Ollama, vLLM, docling-serve URLs - -See [Configuration](configuration/index.md) for details. diff --git a/docs/cli.md b/docs/cli.md index 835597b8..211ce3b9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -278,7 +278,7 @@ The generated package is a pip-installable Python package that registers as a `h ### Available Tools -`analyze`, `ask`, `get_document`, `list_documents`, `research`, `search` +`cite`, `execute_code`, `get_document`, `list_documents`, `search` ### Example @@ -287,7 +287,7 @@ The generated package is a pip-installable Python package that registers as a `h haiku-rag create-skill \ --name medic \ --db /path/to/medic.lancedb \ - --tools search,ask \ + --tools search,cite \ --config-file /path/to/haiku.rag.yaml \ --description "Military medic knowledge base" \ --preamble "You are a military medic expert." diff --git a/docs/index.md b/docs/index.md index c9e44afa..73f6a846 100644 --- a/docs/index.md +++ b/docs/index.md @@ -59,7 +59,6 @@ haiku-rag chat # Interactive conversation mode - [Getting started](tutorial.md) - Tutorial - [Installation](installation.md) - Install haiku.rag with different providers -- [Architecture](architecture.md) - System overview and data flow - [Configuration](configuration/index.md) - Environment variables and settings - [CLI](cli.md) - Command line interface usage - [Python](python.md) - Python API reference diff --git a/docs/skills/analysis.md b/docs/skills/analysis.md index ab1b6a5b..00723540 100644 --- a/docs/skills/analysis.md +++ b/docs/skills/analysis.md @@ -19,13 +19,10 @@ skill = create_skill(db_path=db_path, config=config) | Tool | Purpose | |------|---------| -| `analyze(question, document?, filter?)` | Answer analytical questions using code execution | - -**Parameters:** - -- `question` — The analytical question to answer. -- `document` — Optional document ID or title to pre-load for analysis. -- `filter` — Optional SQL WHERE clause to filter documents. +| `search(query, limit?)` | Hybrid search (vector + full-text) with context expansion | +| `list_documents()` | List all documents in the knowledge base | +| `execute_code(code)` | Execute Python code in a sandboxed interpreter with VFS access | +| `cite(chunk_ids)` | Register chunk IDs as citations for the current answer | ## State @@ -34,16 +31,16 @@ The skill manages an `AnalysisState` under the `"analysis"` namespace: ```python class AnalysisState(BaseModel): document_filter: str | None = None - analyses: list[AnalysisEntry] = [] - -class AnalysisEntry(BaseModel): - question: str - answer: str - program: str | None = None + executions: list[CodeExecutionEntry] = [] + citation_index: dict[str, Citation] = {} + citations: list[list[str]] = [] + searches: dict[str, list[SearchResult]] = {} ``` -- **document_filter** — SQL WHERE clause applied to `analyze` calls (combined with any explicit `filter` parameter). Set this to scope analysis to specific documents. -- **analyses** — Each `analyze` call appends an `AnalysisEntry` with the question, answer, and executed program. +- **document_filter** — SQL WHERE clause applied to `search` and `list_documents` calls. +- **executions** — Each `execute_code` call appends a `CodeExecutionEntry` with code, stdout, stderr, and success status. +- **citation_index** / **citations** — Same per-turn citation tracking as the RAG skill. +- **searches** — Search results from both the `search` tool and sandbox-internal searches. ## Usage with RAG Skill @@ -67,4 +64,4 @@ agent = Agent( ) ``` -See the [Analysis Agent](../agents/analysis.md) documentation for details on how the underlying agent works. +See the [Analysis Agent](../agents/analysis.md) documentation for details on how the underlying sandbox works. diff --git a/docs/skills/index.md b/docs/skills/index.md index 0d6665cd..5811ace8 100644 --- a/docs/skills/index.md +++ b/docs/skills/index.md @@ -47,7 +47,7 @@ Use `create-skill` to generate a standalone skill package with an embedded datab haiku-rag create-skill \ --name recipes \ --db /path/to/recipes.lancedb \ - --tools search,ask \ + --tools search,cite \ --description "Recipe knowledge base" \ --preamble "You are a recipe expert." ``` diff --git a/docs/skills/rag.md b/docs/skills/rag.md index def50f98..064ea86c 100644 --- a/docs/skills/rag.md +++ b/docs/skills/rag.md @@ -1,6 +1,6 @@ # RAG Skill -The RAG skill is the primary way to use haiku.rag tools. It bundles search, Q&A, document browsing, and research into a single skill with managed state. +The RAG skill is the primary way to use haiku.rag tools. It bundles search, document browsing, and citation management into a single skill with managed state. ## `create_skill(db_path?, config?)` @@ -20,10 +20,9 @@ skill = create_skill(db_path=db_path, config=config) | Tool | Purpose | |------|---------| | `search(query, limit?)` | Hybrid search (vector + full-text) with context expansion | -| `list_documents(limit?, offset?, filter?)` | Paginated document listing | +| `list_documents()` | List all documents in the knowledge base | | `get_document(query)` | Retrieve a document by ID, title, or URI | -| `ask(question)` | Q&A with citations via the QA agent | -| `research(question)` | Deep multi-agent research producing comprehensive reports | +| `cite(chunk_ids)` | Register chunk IDs as citations for the current answer | ## State @@ -31,17 +30,13 @@ The skill manages a `RAGState` under the `"rag"` namespace: ```python class RAGState(BaseModel): - citations: list[Citation] = [] - qa_history: list[QAHistoryEntry] = [] + citation_index: dict[str, Citation] = {} + citations: list[list[str]] = [] document_filter: str | None = None searches: dict[str, list[SearchResult]] = {} - documents: list[DocumentInfo] = [] - reports: list[ResearchEntry] = [] ``` -- **citations** — Accumulated citations from `ask` calls, with sequential indexing across calls. -- **qa_history** — Questions and answers from `ask` calls. Prior Q&A is used as context for follow-up questions when embeddings are similar. -- **document_filter** — SQL WHERE clause applied to `search`, `list_documents`, `ask`, and `research` calls. Set this to scope queries to specific documents. +- **citation_index** — All citations indexed by chunk ID (deduplicated across turns). +- **citations** — Per-turn lists of chunk IDs registered via the `cite` tool. +- **document_filter** — SQL WHERE clause applied to `search` and `list_documents` calls. Set this to scope queries to specific documents. - **searches** — Search results keyed by query string. -- **documents** — Documents seen via `list_documents` or `get_document` (deduplicated by ID). -- **reports** — Research reports from `research` calls. diff --git a/mkdocs.yml b/mkdocs.yml index 7ebd59d8..3a282ca8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -59,7 +59,6 @@ nav: - index.md - Getting started: tutorial.md - Installation: installation.md - - Architecture: architecture.md - Configuration: - configuration/index.md - Providers: configuration/providers.md From 4d75943df5ae80cb3bb537d946e31e69d46ca97f Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Mon, 20 Apr 2026 10:47:50 +0300 Subject: [PATCH 15/24] use MontyRepl for persistent variables across execute_code calls --- .../haiku/rag/agents/analysis/sandbox.py | 164 +++++++++++------- haiku_rag_slim/haiku/rag/client.py | 2 +- haiku_rag_slim/haiku/rag/skills/_tools.py | 39 +++-- .../haiku/rag/skills/rag-analysis/SKILL.md | 4 +- tests/agents/analysis/conftest.py | 9 +- tests/agents/analysis/test_sandbox.py | 114 ++++++------ 6 files changed, 186 insertions(+), 146 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py b/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py index c39ec1b3..635e9caa 100644 --- a/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py +++ b/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py @@ -3,10 +3,11 @@ import concurrent.futures import json from collections.abc import Callable from dataclasses import dataclass +from pathlib import Path from typing import TYPE_CHECKING, Any, Literal import pydantic_monty -from pydantic_monty import CallbackFile, MemoryFile, OSAccess +from pydantic_monty import CallbackFile, MemoryFile, MontyRepl, OSAccess from haiku.rag.agents.analysis.dependencies import AnalysisContext from haiku.rag.config.models import AppConfig @@ -15,8 +16,6 @@ from haiku.rag.store.models.chunk import SearchResult if TYPE_CHECKING: from pathlib import PurePosixPath - from haiku.rag.client import HaikuRAG - @dataclass class SandboxResult: @@ -41,35 +40,46 @@ class Sandbox: and resolved asynchronously on the host. Documents are exposed via a virtual filesystem at ``/documents/{id}/``. - sandbox = Sandbox(client, config, context) - result = await sandbox.execute("print('hello')") + The interpreter uses a REPL session — variables persist across + ``execute()`` calls within the same Sandbox instance. + + sandbox = Sandbox(db_path, config, context) + result = await sandbox.execute("x = await search('query')") + result = await sandbox.execute("print(x[0]['content'])") # x persists """ - _client: "HaikuRAG" + _db_path: Path _config: AppConfig _context: AnalysisContext _search_results: "list[SearchResult]" + _repl: MontyRepl | None + _vfs: OSAccess | None def __init__( self, - client: "HaikuRAG", + db_path: Path, config: AppConfig, context: AnalysisContext, ): - self._client = client + self._db_path = db_path self._config = config self._context = context self._search_results = [] + self._repl = None + self._vfs = None def _build_external_functions(self) -> dict[str, Any]: """Build async external functions for the Monty interpreter.""" - client = self._client + db_path = self._db_path config = self._config context = self._context async def search(query: str, limit: int = 10) -> list[dict[str, Any]]: - results = await client.search(query, limit=limit, filter=context.filter) - expanded = await client.expand_context(results) + from haiku.rag.client import HaikuRAG + + async with HaikuRAG(db_path, config=config, read_only=True) as rag: + results = await rag.search(query, limit=limit, filter=context.filter) + expanded = await rag.expand_context(results) self._search_results.extend(expanded) return [ { @@ -88,7 +98,10 @@ class Sandbox: ] async def list_documents() -> list[dict[str, Any]]: - docs = await client.list_documents(filter=context.filter) + from haiku.rag.client import HaikuRAG + + async with HaikuRAG(db_path, config=config, read_only=True) as rag: + docs = await rag.list_documents(filter=context.filter) return [ { "id": d.id, @@ -123,10 +136,14 @@ class Sandbox: - content.txt: CallbackFile (lazy, can be large) - items.jsonl: CallbackFile (lazy, can be large) """ - client = self._client + from haiku.rag.client import HaikuRAG + + db_path = self._db_path + config = self._config files: list[MemoryFile | CallbackFile] = [] - docs = await client.list_documents(filter=self._context.filter) + async with HaikuRAG(db_path, config=config, read_only=True) as rag: + docs = await rag.list_documents(filter=self._context.filter) for doc in docs: if not doc.id: @@ -150,17 +167,21 @@ class Sandbox: ) -> Callable[["PurePosixPath"], str]: def read_content(_path: "PurePosixPath") -> str: async def _fetch() -> str: + from haiku.rag.client import HaikuRAG from haiku.rag.utils import escape_sql_string - safe_id = escape_sql_string(did) - rows = list( - client.store.documents_table.search() - .select(["content"]) - .where(f"id = '{safe_id}'") - .limit(1) - .to_list() - ) - return rows[0]["content"] if rows else "" + async with HaikuRAG( + db_path, config=config, read_only=True + ) as rag: + safe_id = escape_sql_string(did) + rows = list( + rag.store.documents_table.search() + .select(["content"]) + .where(f"id = '{safe_id}'") + .limit(1) + .to_list() + ) + return rows[0]["content"] if rows else "" return _run_async(_fetch()) @@ -171,11 +192,16 @@ class Sandbox: ) -> Callable[["PurePosixPath"], str]: def read_items(_path: "PurePosixPath") -> str: async def _fetch() -> str: - items = ( - await client.document_item_repository.get_items_in_range( - did, 0, 999999 + from haiku.rag.client import HaikuRAG + + async with HaikuRAG( + db_path, config=config, read_only=True + ) as rag: + items = ( + await rag.document_item_repository.get_items_in_range( + did, 0, 999999 + ) ) - ) lines = [] for item in items: lines.append( @@ -213,37 +239,44 @@ class Sandbox: return OSAccess(files) - async def execute(self, code: str) -> SandboxResult: - """Execute Python code in the Monty interpreter.""" - external_fns = self._build_external_functions() - vfs = await self._build_vfs() - - input_names: list[str] = [] - inputs: dict[str, Any] | None = None - if self._context.documents: - input_names.append("documents") - inputs = { - "documents": [ - { - "id": d.id, - "title": d.title, - "uri": d.uri, - "content": d.content, - } - for d in self._context.documents - ] - } - - try: - monty = pydantic_monty.Monty( - code, - inputs=input_names, + async def _ensure_initialized(self) -> None: + """Initialize the REPL session and VFS on first use.""" + if self._repl is None: + self._vfs = await self._build_vfs() + self._repl = MontyRepl( + limits={ + "max_duration_secs": self._config.analysis.code_timeout, + }, ) - except ( - pydantic_monty.MontySyntaxError, - pydantic_monty.MontyRuntimeError, - ) as e: - return SandboxResult(stdout="", stderr=str(e), success=False) + if self._context.documents: + await pydantic_monty.run_repl_async( + self._repl, + "pass", + inputs={ + "documents": [ + { + "id": d.id, + "title": d.title, + "uri": d.uri, + "content": d.content, + } + for d in self._context.documents + ] + }, + external_functions=self._build_external_functions(), + os=self._vfs, + ) + + async def execute(self, code: str) -> SandboxResult: + """Execute Python code in the Monty REPL. + + Variables persist across calls within the same Sandbox instance. + """ + await self._ensure_initialized() + assert self._repl is not None + assert self._vfs is not None + + external_fns = self._build_external_functions() stdout_lines: list[str] = [] @@ -251,20 +284,19 @@ class Sandbox: stdout_lines.append(text) max_chars = self._config.analysis.max_output_chars - limits: pydantic_monty.ResourceLimits = { - "max_duration_secs": self._config.analysis.code_timeout, - } try: - output = await pydantic_monty.run_monty_async( - monty, - inputs=inputs, + output = await pydantic_monty.run_repl_async( + self._repl, + code, external_functions=external_fns, - limits=limits, print_callback=print_callback, - os=vfs, + os=self._vfs, ) - except pydantic_monty.MontyRuntimeError as e: + except ( + pydantic_monty.MontySyntaxError, + pydantic_monty.MontyRuntimeError, + ) as e: stdout = "".join(stdout_lines) if len(stdout) > max_chars: stdout = stdout[:max_chars] + "\n... (output truncated)" diff --git a/haiku_rag_slim/haiku/rag/client.py b/haiku_rag_slim/haiku/rag/client.py index c8473dd3..d83ce3fc 100644 --- a/haiku_rag_slim/haiku/rag/client.py +++ b/haiku_rag_slim/haiku/rag/client.py @@ -1230,7 +1230,7 @@ class HaikuRAG: context.documents = loaded_docs if loaded_docs else None sandbox = Sandbox( - client=self, + db_path=self.store.db_path, config=self._config, context=context, ) diff --git a/haiku_rag_slim/haiku/rag/skills/_tools.py b/haiku_rag_slim/haiku/rag/skills/_tools.py index 8e7f9270..22c17b8b 100644 --- a/haiku_rag_slim/haiku/rag/skills/_tools.py +++ b/haiku_rag_slim/haiku/rag/skills/_tools.py @@ -240,6 +240,7 @@ def create_skill_tools( tools["get_document"] = get_document if "execute_code" in tool_names: + _sandbox: list[Any] = [] # mutable container for closure; holds [Sandbox] or [] async def execute_code(ctx: RunContext[SkillRunDeps], code: str) -> str: """Execute Python code in a sandboxed interpreter. @@ -248,32 +249,34 @@ def create_skill_tools( and a virtual filesystem at /documents/ with document content and structure (metadata.json, content.txt, items.jsonl per document). - Use print() to output results. Each call runs in a fresh - interpreter — variables do not persist between calls. + Use print() to output results. Variables persist between calls. Args: code: Python code to execute. """ from haiku.rag.agents.analysis.dependencies import AnalysisContext from haiku.rag.agents.analysis.sandbox import Sandbox - from haiku.rag.client import HaikuRAG + + if not _sandbox: + state = _get_state(ctx, state_type) + doc_filter = state.document_filter if state else None + context = AnalysisContext(filter=doc_filter) + _sandbox.append( + Sandbox(db_path=db_path, config=config, context=context) + ) + + sandbox = _sandbox[0] + result = await sandbox.execute(code) state = _get_state(ctx, state_type) - doc_filter = state.document_filter if state else None - context = AnalysisContext(filter=doc_filter) - - async with HaikuRAG(db_path, config=config, read_only=True) as rag: - sandbox = Sandbox(client=rag, config=config, context=context) - result = await sandbox.execute(code) - - if state and sandbox._search_results: - existing = state.searches.get("_sandbox", []) - seen = {r.chunk_id for r in existing} - for sr in sandbox._search_results: - if sr.chunk_id not in seen: - existing.append(sr) - seen.add(sr.chunk_id) - state.searches["_sandbox"] = existing + if state and sandbox._search_results: + existing = state.searches.get("_sandbox", []) + seen = {r.chunk_id for r in existing} + for sr in sandbox._search_results: + if sr.chunk_id not in seen: + existing.append(sr) + seen.add(sr.chunk_id) + state.searches["_sandbox"] = existing if state: state.executions.append( diff --git a/haiku_rag_slim/haiku/rag/skills/rag-analysis/SKILL.md b/haiku_rag_slim/haiku/rag/skills/rag-analysis/SKILL.md index 31be509d..784e1e39 100644 --- a/haiku_rag_slim/haiku/rag/skills/rag-analysis/SKILL.md +++ b/haiku_rag_slim/haiku/rag/skills/rag-analysis/SKILL.md @@ -15,7 +15,7 @@ You solve complex analytical questions by writing and executing Python code agai ## Tools ### execute_code -Execute Python code in a sandboxed interpreter. Each call runs in a fresh interpreter — variables do not persist between calls. Use `print()` to output results. +Execute Python code in a sandboxed interpreter. Variables persist between calls — you can build state incrementally. Use `print()` to output results. Inside the code, these functions are available (use `await`): - `await search(query, limit=10)` → list of dicts with keys: chunk_id, content, document_id, document_title, document_uri, score, page_numbers, headings, doc_item_refs, labels @@ -71,7 +71,7 @@ Search results include `doc_item_refs` (e.g. `["#/texts/48", "#/tables/0"]`) tha ## Important -- Each `execute_code` call runs in a fresh interpreter (no persistent variables between calls) +- Variables persist between `execute_code` calls — you can search in one call and process results in the next - Use `print()` to output results — the output is your only feedback - Always execute code to answer questions — don't just describe what code would do - Use `await` for all async functions inside execute_code (search, list_documents, llm) diff --git a/tests/agents/analysis/conftest.py b/tests/agents/analysis/conftest.py index 55810a53..ca2f3712 100644 --- a/tests/agents/analysis/conftest.py +++ b/tests/agents/analysis/conftest.py @@ -14,8 +14,9 @@ async def empty_client(temp_db_path): @pytest.fixture -async def sandbox(empty_client): +async def sandbox(temp_db_path): """Create a Monty sandbox for testing.""" - config = AppConfig() - context = AnalysisContext() - return Sandbox(client=empty_client, config=config, context=context) + async with HaikuRAG(temp_db_path, create=True): + config = AppConfig() + context = AnalysisContext() + return Sandbox(db_path=temp_db_path, config=config, context=context) diff --git a/tests/agents/analysis/test_sandbox.py b/tests/agents/analysis/test_sandbox.py index 4b846f6c..26bc4679 100644 --- a/tests/agents/analysis/test_sandbox.py +++ b/tests/agents/analysis/test_sandbox.py @@ -99,7 +99,7 @@ class TestSandboxListDocuments: ) context = AnalysisContext() - sb = Sandbox(client=client, config=config, context=context) + sb = Sandbox(db_path=temp_db_path, config=config, context=context) result = await sb.execute( "docs = await list_documents()\n" "print(len(docs))\n" @@ -126,7 +126,7 @@ class TestSandboxSearch: ) context = AnalysisContext() - sb = Sandbox(client=client, config=config, context=context) + sb = Sandbox(db_path=temp_db_path, config=config, context=context) result = await sb.execute( "results = await search('fox', limit=5)\n" "print(len(results))\n" @@ -149,7 +149,7 @@ class TestSandboxSearch: ) context = AnalysisContext() - sb = Sandbox(client=client, config=config, context=context) + sb = Sandbox(db_path=temp_db_path, config=config, context=context) result = await sb.execute( "results = await search('fox', limit=1)\n" "r = results[0]\n" @@ -175,7 +175,7 @@ class TestSandboxSearch: ) context = AnalysisContext() - sb = Sandbox(client=client, config=config, context=context) + sb = Sandbox(db_path=temp_db_path, config=config, context=context) result = await sb.execute( "results = await search('fox', limit=1)\n" "print(type(results[0]['content']).__name__)\n" @@ -242,29 +242,31 @@ class TestSandboxOutputTruncation: """Test output truncation behavior.""" @pytest.mark.asyncio - async def test_truncate_stdout_on_runtime_error(self, empty_client): + async def test_truncate_stdout_on_runtime_error(self, temp_db_path): """Test stdout is truncated when a runtime error occurs after large output.""" - config = AppConfig() - config.analysis.max_output_chars = 20 - context = AnalysisContext() - sb = Sandbox(client=empty_client, config=config, context=context) - result = await sb.execute("print('a' * 100)\nx = 1/0") - assert not result.success - assert "ZeroDivisionError" in result.stderr - assert result.stdout.endswith("... (output truncated)") - assert len(result.stdout) < 100 + async with HaikuRAG(temp_db_path, create=True): + config = AppConfig() + config.analysis.max_output_chars = 20 + context = AnalysisContext() + sb = Sandbox(db_path=temp_db_path, config=config, context=context) + result = await sb.execute("print('a' * 100)\nx = 1/0") + assert not result.success + assert "ZeroDivisionError" in result.stderr + assert result.stdout.endswith("... (output truncated)") + assert len(result.stdout) < 100 @pytest.mark.asyncio - async def test_truncate_successful_output(self, empty_client): + async def test_truncate_successful_output(self, temp_db_path): """Test output is truncated on successful execution with large output.""" - config = AppConfig() - config.analysis.max_output_chars = 20 - context = AnalysisContext() - sb = Sandbox(client=empty_client, config=config, context=context) - result = await sb.execute("print('b' * 100)") - assert result.success - assert result.stdout.endswith("... (output truncated)") - assert len(result.stdout) < 100 + async with HaikuRAG(temp_db_path, create=True): + config = AppConfig() + config.analysis.max_output_chars = 20 + context = AnalysisContext() + sb = Sandbox(db_path=temp_db_path, config=config, context=context) + result = await sb.execute("print('b' * 100)") + assert result.success + assert result.stdout.endswith("... (output truncated)") + assert len(result.stdout) < 100 class TestSandboxVFS: @@ -293,7 +295,7 @@ class TestSandboxVFS: ) context = AnalysisContext() - sb = Sandbox(client=client, config=config, context=context) + sb = Sandbox(db_path=temp_db_path, config=config, context=context) result = await sb.execute( "from pathlib import Path\n" "dirs = list(Path('/documents').iterdir())\n" @@ -317,7 +319,7 @@ class TestSandboxVFS: ) context = AnalysisContext() - sb = Sandbox(client=client, config=config, context=context) + sb = Sandbox(db_path=temp_db_path, config=config, context=context) result = await sb.execute( "from pathlib import Path\n" "import json\n" @@ -342,7 +344,7 @@ class TestSandboxVFS: ) context = AnalysisContext() - sb = Sandbox(client=client, config=config, context=context) + sb = Sandbox(db_path=temp_db_path, config=config, context=context) result = await sb.execute( "from pathlib import Path\n" f"content = Path('/documents/{doc.id}/content.txt').read_text()\n" @@ -364,7 +366,7 @@ class TestSandboxVFS: ) context = AnalysisContext() - sb = Sandbox(client=client, config=config, context=context) + sb = Sandbox(db_path=temp_db_path, config=config, context=context) result = await sb.execute( "from pathlib import Path\n" "import json\n" @@ -399,7 +401,7 @@ class TestSandboxVFS: ) context = AnalysisContext(filter="uri LIKE 'public://%'") - sb = Sandbox(client=client, config=config, context=context) + sb = Sandbox(db_path=temp_db_path, config=config, context=context) result = await sb.execute( "from pathlib import Path\n" "import json\n" @@ -425,24 +427,25 @@ class TestSandboxPreloadedDocuments: assert "NameError" in result.stderr @pytest.mark.asyncio - async def test_documents_variable_available_with_preload(self, empty_client): + async def test_documents_variable_available_with_preload(self, temp_db_path): """documents variable is available when context.documents is set.""" - config = AppConfig() - docs = [ - Document(id="1", content="Content A", title="Doc A", uri="a://1"), - Document(id="2", content="Content B", title="Doc B", uri="b://2"), - ] - context = AnalysisContext(documents=docs) - sb = Sandbox(client=empty_client, config=config, context=context) - result = await sb.execute( - "print(len(documents))\n" - "print(documents[0]['title'])\n" - "print(documents[1]['title'])" - ) - assert result.success - assert "2" in result.stdout - assert "Doc A" in result.stdout - assert "Doc B" in result.stdout + async with HaikuRAG(temp_db_path, create=True): + config = AppConfig() + docs = [ + Document(id="1", content="Content A", title="Doc A", uri="a://1"), + Document(id="2", content="Content B", title="Doc B", uri="b://2"), + ] + context = AnalysisContext(documents=docs) + sb = Sandbox(db_path=temp_db_path, config=config, context=context) + result = await sb.execute( + "print(len(documents))\n" + "print(documents[0]['title'])\n" + "print(documents[1]['title'])" + ) + assert result.success + assert "2" in result.stdout + assert "Doc A" in result.stdout + assert "Doc B" in result.stdout class TestSandboxLLM: @@ -450,14 +453,15 @@ class TestSandboxLLM: @pytest.mark.asyncio @pytest.mark.vcr() - async def test_llm_function(self, allow_model_requests, empty_client): + async def test_llm_function(self, allow_model_requests, temp_db_path): """Test llm() calls the model and returns a string.""" - config = AppConfig() - context = AnalysisContext() - sb = Sandbox(client=empty_client, config=config, context=context) - result = await sb.execute( - "answer = await llm('What is 2 + 2? Reply with just the number.')\n" - "print(answer)" - ) - assert result.success - assert "4" in result.stdout + async with HaikuRAG(temp_db_path, create=True): + config = AppConfig() + context = AnalysisContext() + sb = Sandbox(db_path=temp_db_path, config=config, context=context) + result = await sb.execute( + "answer = await llm('What is 2 + 2? Reply with just the number.')\n" + "print(answer)" + ) + assert result.success + assert "4" in result.stdout From 27c5defdbbfa66dcdbffebfae8416317a9b15834 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Mon, 20 Apr 2026 11:38:09 +0300 Subject: [PATCH 16/24] Improve analysis SKILL.md --- .../haiku/rag/skills/rag-analysis/SKILL.md | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/skills/rag-analysis/SKILL.md b/haiku_rag_slim/haiku/rag/skills/rag-analysis/SKILL.md index 784e1e39..65738896 100644 --- a/haiku_rag_slim/haiku/rag/skills/rag-analysis/SKILL.md +++ b/haiku_rag_slim/haiku/rag/skills/rag-analysis/SKILL.md @@ -45,8 +45,30 @@ All documents are mounted as a virtual filesystem at `/documents/`: items.jsonl # Structured items (one JSON object per line) ``` +### Reading files +Always use `Path.read_text()` — do NOT use `open()` or `with` statements (they are not supported). + +```python +from pathlib import Path +import json + +# Discover documents +for doc_dir in Path('/documents').iterdir(): + meta = json.loads((doc_dir / 'metadata.json').read_text()) + print(meta['title']) + +# Read full text +content = Path(f'/documents/{doc_id}/content.txt').read_text() + +# Read and parse items +for line in Path(f'/documents/{doc_id}/items.jsonl').read_text().strip().split(chr(10)): + item = json.loads(line) + if item['label'] == 'table': + print(item['text'][:200]) +``` + ### metadata.json -Document metadata. Use `Path('/documents').iterdir()` to discover documents. +Document metadata: `id`, `title`, `uri`, `created_at`. ### content.txt Full text content. Use for regex or keyword search across a whole document. @@ -75,4 +97,5 @@ Search results include `doc_item_refs` (e.g. `["#/texts/48", "#/tables/0"]`) tha - Use `print()` to output results — the output is your only feedback - Always execute code to answer questions — don't just describe what code would do - Use `await` for all async functions inside execute_code (search, list_documents, llm) -- Do NOT include chunk IDs or UUIDs in your answer text — your answer should read naturally. Use the `cite` tool separately to register citations. +- Use `Path.read_text()` to read files — do NOT use `open()`, `with` statements, or `collections` module +- Do NOT include chunk IDs or UUIDs in your answer text — use the `cite` tool separately From 167c837514f65749d81a720e5211e099f4422ce1 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Mon, 20 Apr 2026 11:57:39 +0300 Subject: [PATCH 17/24] repository methods, module-level executor, read-only VFS --- .../haiku/rag/agents/analysis/sandbox.py | 48 +++++++++---------- .../haiku/rag/store/repositories/document.py | 14 ++++++ .../rag/store/repositories/document_item.py | 12 +++++ 3 files changed, 49 insertions(+), 25 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py b/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py index 635e9caa..bf44dd04 100644 --- a/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py +++ b/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py @@ -26,10 +26,12 @@ class SandboxResult: success: bool +_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) + + def _run_async(coro: Any) -> Any: """Run an async coroutine from a sync context (CallbackFile read).""" - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - return pool.submit(asyncio.run, coro).result() + return _executor.submit(asyncio.run, coro).result() class Sandbox: @@ -168,20 +170,12 @@ class Sandbox: def read_content(_path: "PurePosixPath") -> str: async def _fetch() -> str: from haiku.rag.client import HaikuRAG - from haiku.rag.utils import escape_sql_string async with HaikuRAG( db_path, config=config, read_only=True ) as rag: - safe_id = escape_sql_string(did) - rows = list( - rag.store.documents_table.search() - .select(["content"]) - .where(f"id = '{safe_id}'") - .limit(1) - .to_list() - ) - return rows[0]["content"] if rows else "" + content = await rag.document_repository.get_content(did) + return content or "" return _run_async(_fetch()) @@ -197,10 +191,8 @@ class Sandbox: async with HaikuRAG( db_path, config=config, read_only=True ) as rag: - items = ( - await rag.document_item_repository.get_items_in_range( - did, 0, 999999 - ) + items = await rag.document_item_repository.get_all_items( + did ) lines = [] for item in items: @@ -222,24 +214,27 @@ class Sandbox: return read_items + def _deny_write(_path: "PurePosixPath", _content: str | bytes) -> None: + raise PermissionError(f"Document files are read-only: {_path}") + files.append( CallbackFile( f"{doc_dir}/content.txt", read=_make_content_reader(doc_id), - write=lambda _p, _c: None, + write=_deny_write, ) ) files.append( CallbackFile( f"{doc_dir}/items.jsonl", read=_make_items_reader(doc_id), - write=lambda _p, _c: None, + write=_deny_write, ) ) return OSAccess(files) - async def _ensure_initialized(self) -> None: + async def _ensure_initialized(self) -> tuple[MontyRepl, OSAccess]: """Initialize the REPL session and VFS on first use.""" if self._repl is None: self._vfs = await self._build_vfs() @@ -266,16 +261,19 @@ class Sandbox: external_functions=self._build_external_functions(), os=self._vfs, ) + # Both are guaranteed non-None after initialization + repl = self._repl + vfs = self._vfs + if repl is None or vfs is None: + raise RuntimeError("Sandbox initialization failed") + return repl, vfs async def execute(self, code: str) -> SandboxResult: """Execute Python code in the Monty REPL. Variables persist across calls within the same Sandbox instance. """ - await self._ensure_initialized() - assert self._repl is not None - assert self._vfs is not None - + repl, vfs = await self._ensure_initialized() external_fns = self._build_external_functions() stdout_lines: list[str] = [] @@ -287,11 +285,11 @@ class Sandbox: try: output = await pydantic_monty.run_repl_async( - self._repl, + repl, code, external_functions=external_fns, print_callback=print_callback, - os=self._vfs, + os=vfs, ) except ( pydantic_monty.MontySyntaxError, diff --git a/haiku_rag_slim/haiku/rag/store/repositories/document.py b/haiku_rag_slim/haiku/rag/store/repositories/document.py index 0c9de830..4f5e89e1 100644 --- a/haiku_rag_slim/haiku/rag/store/repositories/document.py +++ b/haiku_rag_slim/haiku/rag/store/repositories/document.py @@ -100,6 +100,20 @@ class DocumentRepository: return self._record_to_document(results[0]) + async def get_content(self, entity_id: str) -> str | None: + """Get only the text content of a document (skips docling blobs).""" + safe_id = escape_sql_string(entity_id) + results = list( + self.store.documents_table.search() + .select(["content"]) + .where(f"id = '{safe_id}'") + .limit(1) + .to_list() + ) + if not results: + return None + return results[0]["content"] + _DOCLING_COLUMNS = ["id", "docling_document", "docling_version"] async def get_docling_data(self, entity_id: str) -> Document | None: diff --git a/haiku_rag_slim/haiku/rag/store/repositories/document_item.py b/haiku_rag_slim/haiku/rag/store/repositories/document_item.py index 9b30a271..eeb5523d 100644 --- a/haiku_rag_slim/haiku/rag/store/repositories/document_item.py +++ b/haiku_rag_slim/haiku/rag/store/repositories/document_item.py @@ -40,6 +40,18 @@ class DocumentItemRepository: ] self.store.document_items_table.add(records) + async def get_all_items(self, document_id: str) -> list[DocumentItem]: + """Get all items for a document, sorted by position.""" + safe_id = escape_sql_string(document_id) + rows = ( + self.store.document_items_table.search() + .where(f"document_id = '{safe_id}'") + .to_list() + ) + items = [self._record_to_item(row) for row in rows] + items.sort(key=lambda x: x.position) + return items + async def get_items_in_range( self, document_id: str, start: int, end: int ) -> list[DocumentItem]: From 9d921b13fe61aefd342825f713ea10a18a986c63 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Mon, 20 Apr 2026 12:18:14 +0300 Subject: [PATCH 18/24] reset sandbox per skill invocation to prevent state leaks --- haiku_rag_slim/haiku/rag/skills/_tools.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/skills/_tools.py b/haiku_rag_slim/haiku/rag/skills/_tools.py index 22c17b8b..28821d00 100644 --- a/haiku_rag_slim/haiku/rag/skills/_tools.py +++ b/haiku_rag_slim/haiku/rag/skills/_tools.py @@ -240,7 +240,7 @@ def create_skill_tools( tools["get_document"] = get_document if "execute_code" in tool_names: - _sandbox: list[Any] = [] # mutable container for closure; holds [Sandbox] or [] + _sandbox_state: dict[str, Any] = {} # {run_id, sandbox} — reset per invocation async def execute_code(ctx: RunContext[SkillRunDeps], code: str) -> str: """Execute Python code in a sandboxed interpreter. @@ -249,7 +249,8 @@ def create_skill_tools( and a virtual filesystem at /documents/ with document content and structure (metadata.json, content.txt, items.jsonl per document). - Use print() to output results. Variables persist between calls. + Use print() to output results. Variables persist between calls + within the same skill invocation. Args: code: Python code to execute. @@ -257,15 +258,17 @@ def create_skill_tools( from haiku.rag.agents.analysis.dependencies import AnalysisContext from haiku.rag.agents.analysis.sandbox import Sandbox - if not _sandbox: + rid = ctx.run_id or "" + if _sandbox_state.get("run_id") != rid: state = _get_state(ctx, state_type) doc_filter = state.document_filter if state else None context = AnalysisContext(filter=doc_filter) - _sandbox.append( - Sandbox(db_path=db_path, config=config, context=context) + _sandbox_state["run_id"] = rid + _sandbox_state["sandbox"] = Sandbox( + db_path=db_path, config=config, context=context ) - sandbox = _sandbox[0] + sandbox = _sandbox_state["sandbox"] result = await sandbox.execute(code) state = _get_state(ctx, state_type) From 579e609ae14d559e1c29b1e9f9dc0b0930df6bae Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Mon, 20 Apr 2026 12:48:05 +0300 Subject: [PATCH 19/24] revert REPL persistence: fresh sandbox per execute_code call --- .../haiku/rag/agents/analysis/sandbox.py | 98 ++++++++----------- haiku_rag_slim/haiku/rag/skills/_tools.py | 20 ++-- .../haiku/rag/skills/rag-analysis/SKILL.md | 4 +- 3 files changed, 51 insertions(+), 71 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py b/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py index bf44dd04..ad5288fa 100644 --- a/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py +++ b/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py @@ -1,4 +1,5 @@ import asyncio +import atexit import concurrent.futures import json from collections.abc import Callable @@ -7,7 +8,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Literal import pydantic_monty -from pydantic_monty import CallbackFile, MemoryFile, MontyRepl, OSAccess +from pydantic_monty import CallbackFile, MemoryFile, OSAccess from haiku.rag.agents.analysis.dependencies import AnalysisContext from haiku.rag.config.models import AppConfig @@ -27,6 +28,7 @@ class SandboxResult: _executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) +atexit.register(_executor.shutdown, wait=False) def _run_async(coro: Any) -> Any: @@ -42,20 +44,17 @@ class Sandbox: and resolved asynchronously on the host. Documents are exposed via a virtual filesystem at ``/documents/{id}/``. - The interpreter uses a REPL session — variables persist across - ``execute()`` calls within the same Sandbox instance. + Each ``execute()`` call runs in a fresh interpreter — variables do not + persist between calls. sandbox = Sandbox(db_path, config, context) - result = await sandbox.execute("x = await search('query')") - result = await sandbox.execute("print(x[0]['content'])") # x persists + result = await sandbox.execute("print('hello')") """ _db_path: Path _config: AppConfig _context: AnalysisContext _search_results: "list[SearchResult]" - _repl: MontyRepl | None - _vfs: OSAccess | None def __init__( self, @@ -67,8 +66,6 @@ class Sandbox: self._config = config self._context = context self._search_results = [] - self._repl = None - self._vfs = None def _build_external_functions(self) -> dict[str, Any]: """Build async external functions for the Monty interpreter.""" @@ -234,47 +231,37 @@ class Sandbox: return OSAccess(files) - async def _ensure_initialized(self) -> tuple[MontyRepl, OSAccess]: - """Initialize the REPL session and VFS on first use.""" - if self._repl is None: - self._vfs = await self._build_vfs() - self._repl = MontyRepl( - limits={ - "max_duration_secs": self._config.analysis.code_timeout, - }, - ) - if self._context.documents: - await pydantic_monty.run_repl_async( - self._repl, - "pass", - inputs={ - "documents": [ - { - "id": d.id, - "title": d.title, - "uri": d.uri, - "content": d.content, - } - for d in self._context.documents - ] - }, - external_functions=self._build_external_functions(), - os=self._vfs, - ) - # Both are guaranteed non-None after initialization - repl = self._repl - vfs = self._vfs - if repl is None or vfs is None: - raise RuntimeError("Sandbox initialization failed") - return repl, vfs - async def execute(self, code: str) -> SandboxResult: - """Execute Python code in the Monty REPL. - - Variables persist across calls within the same Sandbox instance. - """ - repl, vfs = await self._ensure_initialized() + """Execute Python code in the Monty interpreter.""" external_fns = self._build_external_functions() + vfs = await self._build_vfs() + + input_names: list[str] = [] + inputs: dict[str, Any] | None = None + if self._context.documents: + input_names.append("documents") + inputs = { + "documents": [ + { + "id": d.id, + "title": d.title, + "uri": d.uri, + "content": d.content, + } + for d in self._context.documents + ] + } + + try: + monty = pydantic_monty.Monty( + code, + inputs=input_names, + ) + except ( + pydantic_monty.MontySyntaxError, + pydantic_monty.MontyRuntimeError, + ) as e: + return SandboxResult(stdout="", stderr=str(e), success=False) stdout_lines: list[str] = [] @@ -282,19 +269,20 @@ class Sandbox: stdout_lines.append(text) max_chars = self._config.analysis.max_output_chars + limits: pydantic_monty.ResourceLimits = { + "max_duration_secs": self._config.analysis.code_timeout, + } try: - output = await pydantic_monty.run_repl_async( - repl, - code, + output = await pydantic_monty.run_monty_async( + monty, + inputs=inputs, external_functions=external_fns, + limits=limits, print_callback=print_callback, os=vfs, ) - except ( - pydantic_monty.MontySyntaxError, - pydantic_monty.MontyRuntimeError, - ) as e: + except pydantic_monty.MontyRuntimeError as e: stdout = "".join(stdout_lines) if len(stdout) > max_chars: stdout = stdout[:max_chars] + "\n... (output truncated)" diff --git a/haiku_rag_slim/haiku/rag/skills/_tools.py b/haiku_rag_slim/haiku/rag/skills/_tools.py index 28821d00..34bd977d 100644 --- a/haiku_rag_slim/haiku/rag/skills/_tools.py +++ b/haiku_rag_slim/haiku/rag/skills/_tools.py @@ -240,7 +240,6 @@ def create_skill_tools( tools["get_document"] = get_document if "execute_code" in tool_names: - _sandbox_state: dict[str, Any] = {} # {run_id, sandbox} — reset per invocation async def execute_code(ctx: RunContext[SkillRunDeps], code: str) -> str: """Execute Python code in a sandboxed interpreter. @@ -249,8 +248,8 @@ def create_skill_tools( and a virtual filesystem at /documents/ with document content and structure (metadata.json, content.txt, items.jsonl per document). - Use print() to output results. Variables persist between calls - within the same skill invocation. + Use print() to output results. Each call runs in a fresh + interpreter — variables do not persist between calls. Args: code: Python code to execute. @@ -258,17 +257,10 @@ def create_skill_tools( from haiku.rag.agents.analysis.dependencies import AnalysisContext from haiku.rag.agents.analysis.sandbox import Sandbox - rid = ctx.run_id or "" - if _sandbox_state.get("run_id") != rid: - state = _get_state(ctx, state_type) - doc_filter = state.document_filter if state else None - context = AnalysisContext(filter=doc_filter) - _sandbox_state["run_id"] = rid - _sandbox_state["sandbox"] = Sandbox( - db_path=db_path, config=config, context=context - ) - - sandbox = _sandbox_state["sandbox"] + state = _get_state(ctx, state_type) + doc_filter = state.document_filter if state else None + context = AnalysisContext(filter=doc_filter) + sandbox = Sandbox(db_path=db_path, config=config, context=context) result = await sandbox.execute(code) state = _get_state(ctx, state_type) diff --git a/haiku_rag_slim/haiku/rag/skills/rag-analysis/SKILL.md b/haiku_rag_slim/haiku/rag/skills/rag-analysis/SKILL.md index 65738896..80d9c099 100644 --- a/haiku_rag_slim/haiku/rag/skills/rag-analysis/SKILL.md +++ b/haiku_rag_slim/haiku/rag/skills/rag-analysis/SKILL.md @@ -15,7 +15,7 @@ You solve complex analytical questions by writing and executing Python code agai ## Tools ### execute_code -Execute Python code in a sandboxed interpreter. Variables persist between calls — you can build state incrementally. Use `print()` to output results. +Execute Python code in a sandboxed interpreter. Each call runs in a fresh interpreter — write self-contained code. Use `print()` to output results. Inside the code, these functions are available (use `await`): - `await search(query, limit=10)` → list of dicts with keys: chunk_id, content, document_id, document_title, document_uri, score, page_numbers, headings, doc_item_refs, labels @@ -93,7 +93,7 @@ Search results include `doc_item_refs` (e.g. `["#/texts/48", "#/tables/0"]`) tha ## Important -- Variables persist between `execute_code` calls — you can search in one call and process results in the next +- Each `execute_code` call runs in a fresh interpreter — write self-contained code blocks - Use `print()` to output results — the output is your only feedback - Always execute code to answer questions — don't just describe what code would do - Use `await` for all async functions inside execute_code (search, list_documents, llm) From cd0c21c9965a8997a319b5a834c72967efcb55ab Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Mon, 20 Apr 2026 14:05:10 +0300 Subject: [PATCH 20/24] remove cited_chunks from analysis agent, hoist _deny_write out of loop --- .../haiku/rag/agents/analysis/models.py | 4 ---- .../haiku/rag/agents/analysis/prompts.py | 3 +-- .../haiku/rag/agents/analysis/sandbox.py | 6 +++--- haiku_rag_slim/haiku/rag/client.py | 20 +++++++++++++++++-- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/models.py b/haiku_rag_slim/haiku/rag/agents/analysis/models.py index 413c25b0..6b88e027 100644 --- a/haiku_rag_slim/haiku/rag/agents/analysis/models.py +++ b/haiku_rag_slim/haiku/rag/agents/analysis/models.py @@ -17,10 +17,6 @@ class RawAnalysisResult(BaseModel): answer: str = Field(description="The answer to the user's question") program: str = Field(description="The final consolidated program") - cited_chunks: list[str] = Field( - default_factory=list, - description="Chunk IDs from search results that informed the answer. Copy full UUIDs from search result chunk_id fields.", - ) class AnalysisResult(BaseModel): diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/prompts.py b/haiku_rag_slim/haiku/rag/agents/analysis/prompts.py index a26b70cf..53147c9b 100644 --- a/haiku_rag_slim/haiku/rag/agents/analysis/prompts.py +++ b/haiku_rag_slim/haiku/rag/agents/analysis/prompts.py @@ -116,12 +116,11 @@ Not supported: most imports (only `json`, `re`, `math`, `pathlib` are available) Your final response MUST be valid JSON matching this exact schema: ```json -{"answer": "Your answer here", "program": "Your final program here", "cited_chunks": ["chunk-id-1", "chunk-id-2"]} +{"answer": "Your answer here", "program": "Your final program here"} ``` - `answer`: A clear answer to the user's question with key findings and references to specific documents/chunks. - `program`: A single, self-contained Python program that produces the answer. Consolidate your exploratory code executions into one clean script. -- `cited_chunks`: List of chunk_id values from search results that informed your answer. Copy the full UUID strings from the `chunk_id` field of search results you used. Do NOT return arbitrary JSON structures. Always use the exact format above. diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py b/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py index ad5288fa..90976a49 100644 --- a/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py +++ b/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py @@ -141,6 +141,9 @@ class Sandbox: config = self._config files: list[MemoryFile | CallbackFile] = [] + def _deny_write(_path: "PurePosixPath", _content: str | bytes) -> None: + raise PermissionError(f"Document files are read-only: {_path}") + async with HaikuRAG(db_path, config=config, read_only=True) as rag: docs = await rag.list_documents(filter=self._context.filter) @@ -211,9 +214,6 @@ class Sandbox: return read_items - def _deny_write(_path: "PurePosixPath", _content: str | bytes) -> None: - raise PermissionError(f"Document files are read-only: {_path}") - files.append( CallbackFile( f"{doc_dir}/content.txt", diff --git a/haiku_rag_slim/haiku/rag/client.py b/haiku_rag_slim/haiku/rag/client.py index d83ce3fc..3f0639eb 100644 --- a/haiku_rag_slim/haiku/rag/client.py +++ b/haiku_rag_slim/haiku/rag/client.py @@ -1240,13 +1240,29 @@ class HaikuRAG: ) from haiku.rag.agents.analysis.models import AnalysisResult - from haiku.rag.agents.research.models import resolve_citations + from haiku.rag.agents.research.models import Citation agent = create_analysis_agent(self._config) result = await agent.run(question, deps=deps) output = result.output - citations = resolve_citations(output.cited_chunks, sandbox._search_results) + 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, From af7731f4e10b0cfdb172e54e8f16e04dbeca2765 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Mon, 20 Apr 2026 14:25:16 +0300 Subject: [PATCH 21/24] bulk-fetch items.jsonl via lazy cache to avoid per-document query timeout --- .../haiku/rag/agents/analysis/sandbox.py | 80 +++++++++++-------- .../rag/store/repositories/document_item.py | 26 ++++++ 2 files changed, 73 insertions(+), 33 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py b/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py index 90976a49..88a61ea9 100644 --- a/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py +++ b/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py @@ -55,6 +55,7 @@ class Sandbox: _config: AppConfig _context: AnalysisContext _search_results: "list[SearchResult]" + _items_cache: dict[str, str] | None def __init__( self, @@ -66,6 +67,7 @@ class Sandbox: self._config = config self._context = context self._search_results = [] + self._items_cache = None def _build_external_functions(self) -> dict[str, Any]: """Build async external functions for the Monty interpreter.""" @@ -147,6 +149,51 @@ class Sandbox: async with HaikuRAG(db_path, config=config, read_only=True) as rag: docs = await rag.list_documents(filter=self._context.filter) + doc_ids = [doc.id for doc in docs if doc.id] + + def _load_items_cache() -> dict[str, str]: + """Bulk-fetch all document items in one query, serialize to JSONL.""" + + async def _fetch() -> dict[str, str]: + from haiku.rag.client import HaikuRAG + + async with HaikuRAG(db_path, config=config, read_only=True) as rag: + grouped = await rag.document_item_repository.get_all_items_grouped( + doc_ids + ) + result: dict[str, str] = {} + for did, items in grouped.items(): + lines = [] + for item in items: + lines.append( + json.dumps( + { + "position": item.position, + "self_ref": item.self_ref, + "label": item.label, + "text": item.text, + "page_numbers": item.page_numbers, + }, + ensure_ascii=False, + ) + ) + result[did] = "\n".join(lines) + return result + + return _run_async(_fetch()) + + sandbox = self + + def _make_items_reader( + did: str, + ) -> Callable[["PurePosixPath"], str]: + def read_items(_path: "PurePosixPath") -> str: + if sandbox._items_cache is None: + sandbox._items_cache = _load_items_cache() + return sandbox._items_cache.get(did, "") + + return read_items + for doc in docs: if not doc.id: continue @@ -181,39 +228,6 @@ class Sandbox: return read_content - def _make_items_reader( - did: str, - ) -> Callable[["PurePosixPath"], str]: - def read_items(_path: "PurePosixPath") -> str: - async def _fetch() -> str: - from haiku.rag.client import HaikuRAG - - async with HaikuRAG( - db_path, config=config, read_only=True - ) as rag: - items = await rag.document_item_repository.get_all_items( - did - ) - lines = [] - for item in items: - lines.append( - json.dumps( - { - "position": item.position, - "self_ref": item.self_ref, - "label": item.label, - "text": item.text, - "page_numbers": item.page_numbers, - }, - ensure_ascii=False, - ) - ) - return "\n".join(lines) - - return _run_async(_fetch()) - - return read_items - files.append( CallbackFile( f"{doc_dir}/content.txt", diff --git a/haiku_rag_slim/haiku/rag/store/repositories/document_item.py b/haiku_rag_slim/haiku/rag/store/repositories/document_item.py index eeb5523d..1e68c736 100644 --- a/haiku_rag_slim/haiku/rag/store/repositories/document_item.py +++ b/haiku_rag_slim/haiku/rag/store/repositories/document_item.py @@ -52,6 +52,32 @@ class DocumentItemRepository: items.sort(key=lambda x: x.position) return items + async def get_all_items_grouped( + self, document_ids: list[str] | None = None + ) -> dict[str, list[DocumentItem]]: + """Get all items grouped by document_id in a single query. + + Args: + document_ids: If provided, only fetch items for these documents. + If None, fetches all items. + + Returns: + Dict mapping document_id to sorted list of DocumentItem. + """ + query = self.store.document_items_table.search() + if document_ids is not None: + safe_ids = ", ".join(f"'{escape_sql_string(did)}'" for did in document_ids) + query = query.where(f"document_id IN ({safe_ids})") + rows = query.to_list() + + grouped: dict[str, list[DocumentItem]] = {} + for row in rows: + item = self._record_to_item(row) + grouped.setdefault(item.document_id, []).append(item) + for items in grouped.values(): + items.sort(key=lambda x: x.position) + return grouped + async def get_items_in_range( self, document_id: str, start: int, end: int ) -> list[DocumentItem]: From ff8ad0879c9dc71cd354f857182b1e68a47937e5 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Mon, 20 Apr 2026 14:53:36 +0300 Subject: [PATCH 22/24] fix context expansion: respect section boundaries, remove max_context_items --- docs/configuration/index.md | 1 - docs/configuration/qa-research.md | 2 - docs/configuration/storage.md | 2 +- docs/python.md | 1 - docs/tuning.md | 2 +- evaluations/evaluations/benchmark.py | 1 - haiku_rag_slim/haiku/rag/client.py | 28 +++++-- haiku_rag_slim/haiku/rag/config/models.py | 1 - haiku_rag_slim/haiku/rag/context.py | 72 +++++++++++------- tests/test_context.py | 93 ++++++++++++----------- tests/test_context_enhancement.py | 28 ------- 11 files changed, 114 insertions(+), 117 deletions(-) diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 83a13ded..9db9e9cc 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -99,7 +99,6 @@ research: search: limit: 10 # Default number of results to return - max_context_items: 10 # Maximum items in expanded context max_context_chars: 10000 # Maximum characters in expanded context vector_index_metric: cosine # cosine, l2, or dot vector_refine_factor: 30 diff --git a/docs/configuration/qa-research.md b/docs/configuration/qa-research.md index 522fc203..9911ae29 100644 --- a/docs/configuration/qa-research.md +++ b/docs/configuration/qa-research.md @@ -7,12 +7,10 @@ Configure search behavior and context expansion: ```yaml search: limit: 10 # Default number of results to return - max_context_items: 10 # Maximum items in expanded context max_context_chars: 10000 # Maximum characters in expanded context ``` - **limit**: Default number of search results to return when no limit is specified. Used by CLI, MCP server, QA, and research workflows. Default: 10 -- **max_context_items**: Limits how many document items (paragraphs, list items, etc.) can be included in expanded context. Default: 10. - **max_context_chars**: Hard limit on total characters in expanded content. Default: 10000. Context expansion is automatic and section-aware. For structured documents (with section headers), expansion includes the entire section containing the match. For sections that exceed the budget or are too small (e.g., a title+authors area), expansion grows outward item-by-item from the match center, skipping noise labels (footnotes, page headers) — this naturally crosses into adjacent sections until the budget is filled. For unstructured documents, expansion grows outward item-by-item. Results without `doc_item_refs` (e.g., custom chunks passed to `import_document`) pass through unexpanded. diff --git a/docs/configuration/storage.md b/docs/configuration/storage.md index b57227b5..ebb233bb 100644 --- a/docs/configuration/storage.md +++ b/docs/configuration/storage.md @@ -112,7 +112,7 @@ search: vector_refine_factor: 30 # Re-ranking factor for accuracy ``` -For search behavior settings (`limit`, `max_context_items`, `max_context_chars`), see [QA and Research](qa-research.md#search-settings). +For search behavior settings (`limit`, `max_context_chars`), see [QA and Research](qa-research.md#search-settings). - **vector_index_metric**: Distance metric for vector similarity: - `cosine`: Cosine similarity (default, best for most embeddings) diff --git a/docs/python.md b/docs/python.md index 7d91a282..e0605c94 100644 --- a/docs/python.md +++ b/docs/python.md @@ -376,7 +376,6 @@ Context expansion is automatic and section-aware. For structured documents (with Configuration: -- **search.max_context_items**: Maximum items in expanded context. Default: 10. - **search.max_context_chars**: Maximum characters in expanded context. Default: 10000. **Smart Merging**: When expanded results overlap within the same document, they are automatically merged into a single result with continuous content and the highest relevance score. diff --git a/docs/tuning.md b/docs/tuning.md index d1a8d40f..c5ac4441 100644 --- a/docs/tuning.md +++ b/docs/tuning.md @@ -26,7 +26,7 @@ When configured, a cross-encoder reranker re-scores 10x the requested candidates `limit` controls how many results reach the LLM. More candidates improve recall but increase token usage. See [Search Settings](configuration/qa-research.md#search-settings). -Context expansion is automatic and section-aware — search results are expanded to include surrounding content from the same document section. For structured documents, expansion stays within section boundaries and filters noise (footnotes, page headers). For unstructured documents, expansion grows outward until the character budget is filled. `max_context_items` and `max_context_chars` cap expansion to prevent context bloat. +Context expansion is automatic and section-aware — search results are expanded to include surrounding content from the same document section. For structured documents, expansion stays within section boundaries and filters noise (footnotes, page headers). For unstructured documents, expansion grows outward until the character budget is filled. `max_context_chars` caps expansion to prevent context bloat. ## Tuning Generation diff --git a/evaluations/evaluations/benchmark.py b/evaluations/evaluations/benchmark.py index dacfa33f..d6626ce7 100644 --- a/evaluations/evaluations/benchmark.py +++ b/evaluations/evaluations/benchmark.py @@ -49,7 +49,6 @@ def build_experiment_metadata( "embedder_dim": config.embeddings.model.vector_dim, "chunk_size": config.processing.chunk_size, "search_limit": config.search.limit, - "max_context_items": config.search.max_context_items, "max_context_chars": config.search.max_context_chars, "rerank_provider": config.reranking.model.provider if config.reranking.model diff --git a/haiku_rag_slim/haiku/rag/client.py b/haiku_rag_slim/haiku/rag/client.py index 3f0639eb..17b906a3 100644 --- a/haiku_rag_slim/haiku/rag/client.py +++ b/haiku_rag_slim/haiku/rag/client.py @@ -1105,7 +1105,6 @@ class HaikuRAG: """ from haiku.rag.context import expand_with_items - max_items = self._config.search.max_context_items max_chars = self._config.search.max_context_chars # Group by document_id for efficient processing @@ -1132,7 +1131,6 @@ class HaikuRAG: self.document_item_repository, doc_id, doc_results, - max_items, max_chars, ) expanded_results.extend(expanded) @@ -1272,9 +1270,9 @@ class HaikuRAG: async def visualize_chunk(self, chunk: Chunk) -> list: """Render page images with bounding box highlights for a chunk. - Gets the DoclingDocument from the chunk's document, resolves bounding boxes - from chunk metadata, and renders all pages that contain bounding boxes with - yellow/orange highlight overlays. + Expands the chunk's context to find the full section, then resolves + bounding boxes from all items in the expanded range. This ensures + visualization covers all pages the expanded content spans. Args: chunk: The chunk to visualize. @@ -1287,6 +1285,8 @@ class HaikuRAG: from PIL import ImageDraw + from haiku.rag.store.models.chunk import ChunkMetadata + # Get the document structure (from cache if available) if not chunk.document_id: return [] @@ -1299,9 +1299,23 @@ class HaikuRAG: if not docling_doc: return [] - # Resolve bounding boxes from chunk metadata + # Expand context to get all doc_item_refs in the section chunk_meta = chunk.get_chunk_metadata() - bounding_boxes = chunk_meta.resolve_bounding_boxes(docling_doc) + if chunk_meta.doc_item_refs: + search_result = SearchResult( + content=chunk.content, + score=1.0, + chunk_id=chunk.id, + document_id=chunk.document_id, + doc_item_refs=chunk_meta.doc_item_refs, + page_numbers=chunk_meta.page_numbers, + ) + expanded = await self.expand_context([search_result]) + refs = expanded[0].doc_item_refs if expanded else chunk_meta.doc_item_refs + meta = ChunkMetadata(doc_item_refs=refs) + else: + meta = chunk_meta + bounding_boxes = meta.resolve_bounding_boxes(docling_doc) if not bounding_boxes: return [] diff --git a/haiku_rag_slim/haiku/rag/config/models.py b/haiku_rag_slim/haiku/rag/config/models.py index 5c23302e..207441fa 100644 --- a/haiku_rag_slim/haiku/rag/config/models.py +++ b/haiku_rag_slim/haiku/rag/config/models.py @@ -174,7 +174,6 @@ class ProcessingConfig(BaseModel): class SearchConfig(BaseModel): limit: int = 10 - max_context_items: int = 10 max_context_chars: int = 10000 vector_index_metric: Literal["cosine", "l2", "dot"] = "cosine" vector_refine_factor: int = 30 diff --git a/haiku_rag_slim/haiku/rag/context.py b/haiku_rag_slim/haiku/rag/context.py index a66c8b84..2ee03ebd 100644 --- a/haiku_rag_slim/haiku/rag/context.py +++ b/haiku_rag_slim/haiku/rag/context.py @@ -6,12 +6,16 @@ the document_items table. The algorithm adapts to document structure: For STRUCTURED documents (containing section_header or title labels): 1. Resolve matched doc_item_refs to positions in the items table 2. Find section boundaries around each match (section_header/title labels) - 3. If the section fits within the budget, include it entirely - 4. If the section exceeds the budget, OR the section is too small (under - 20% of max_context_chars), expand item-by-item from the match center - outward, skipping noise labels. This lets small sections (e.g., a - title+authors area) grow into the next section's content. - 5. Merge overlapping ranges from multiple results in the same document + 3. If the section fits within the char budget, include it entirely + 4. If the section exceeds the char budget, expand item-by-item from the + match center outward, bounded by section edges + 5. If the section is too small (under 20% of max_context_chars), expand + item-by-item crossing into adjacent sections until the budget is filled. + This lets small sections (e.g., title+authors) grow into neighboring + content. + 6. Merge overlapping ranges from multiple results in the same document. + Adjacent but non-overlapping ranges stay separate to preserve section + independence. For UNSTRUCTURED documents (no section headers): Expand outward item-by-item from the match center until the character @@ -20,7 +24,6 @@ For UNSTRUCTURED documents (no section headers): In both cases: - max_context_chars caps total characters per expanded result - - max_context_items caps total items per expanded result - Noise labels (footnote, page_header, page_footer, document_index) are excluded from content AND budget counting in structured documents - Results without doc_item_refs pass through unexpanded @@ -42,7 +45,7 @@ _MIN_SECTION_BUDGET_RATIO = 0.2 def _merge_ranges( ranges: list[tuple[int, int, SearchResult]], ) -> list[tuple[int, int, list[SearchResult]]]: - """Merge overlapping or adjacent ranges.""" + """Merge overlapping ranges. Adjacent but non-overlapping ranges stay separate.""" if not ranges: return [] @@ -55,7 +58,7 @@ def _merge_ranges( ) for min_idx, max_idx, result in sorted_ranges[1:]: - if cur_max >= min_idx - 1: # Overlapping or adjacent + if cur_max >= min_idx: # Truly overlapping cur_max = max(cur_max, max_idx) cur_results.append(result) else: @@ -69,27 +72,32 @@ def _merge_ranges( def _expand_outward( items: list[DocumentItem], center_idx: int, - max_items: int, max_chars: int, skip_noise: bool = False, + lo_bound: int = 0, + hi_bound: int | None = None, ) -> tuple[int, int]: - """Expand item-by-item outward from center until budget is filled. + """Expand item-by-item outward from center until char budget is filled. When skip_noise is True, noise labels are excluded from char counting (used in structured documents so footnotes don't consume budget). + + lo_bound and hi_bound constrain expansion (e.g., to section edges). """ + if hi_bound is None: + hi_bound = len(items) - 1 lo = hi = center_idx center_is_noise = skip_noise and items[center_idx].label in _NOISE_LABELS char_count = 0 if center_is_noise else len(items[center_idx].text) - while char_count < max_chars and hi - lo + 1 < max_items: + while char_count < max_chars: grew = False - if lo > 0: + if lo > lo_bound: lo -= 1 if not (skip_noise and items[lo].label in _NOISE_LABELS): char_count += len(items[lo].text) grew = True - if hi < len(items) - 1 and char_count < max_chars: + if hi < hi_bound and char_count < max_chars: hi += 1 if not (skip_noise and items[hi].label in _NOISE_LABELS): char_count += len(items[hi].text) @@ -104,7 +112,6 @@ def _find_expansion_range( items: list[DocumentItem], matched_positions: set[int], has_sections: bool, - max_items: int, max_chars: int, ) -> tuple[int, int]: """Find the expansion range for matched positions within a window of items.""" @@ -113,7 +120,7 @@ def _find_expansion_range( center_idx = matched_indices[len(matched_indices) // 2] if not has_sections: - return _expand_outward(items, center_idx, max_items, max_chars) + return _expand_outward(items, center_idx, max_chars) # Build section spans: [(start_idx, end_idx), ...] headers = [ @@ -140,23 +147,34 @@ def _find_expansion_range( if items[i].label not in _NOISE_LABELS ) - # Section fits nicely in the budget — return it as-is min_useful = int(max_chars * _MIN_SECTION_BUDGET_RATIO) - if min_useful <= sec_chars <= max_chars and sec_end - sec_start + 1 <= max_items: + + if sec_chars <= max_chars and sec_chars >= min_useful: + # Section fits in char budget — return it regardless of item count return (items[sec_start].position, items[sec_end].position) - # Section is too large or too small — expand item-by-item from center. - # For too-large sections this stays within budget. - # For too-small sections (e.g., title+authors) this naturally grows - # into adjacent sections until the budget is filled. - return _expand_outward(items, center_idx, max_items, max_chars, skip_noise=True) + if sec_chars > max_chars: + # Section too large — expand outward bounded by section edges + return _expand_outward( + items, + center_idx, + max_chars, + skip_noise=True, + lo_bound=sec_start, + hi_bound=sec_end, + ) + + # Section too small (e.g., title+authors) — expand across boundaries + return _expand_outward(items, center_idx, max_chars, skip_noise=True) + + +_WINDOW_MARGIN = 100 async def expand_with_items( document_item_repository: DocumentItemRepository, document_id: str, results: list[SearchResult], - max_items: int, max_chars: int, ) -> list[SearchResult]: """Expand results using the document_items table.""" @@ -172,7 +190,7 @@ async def expand_with_items( # wide enough to find section boundaries (the nearest section_header/title # above and below the match). all_positions = sorted(ref_positions.values()) - window_margin = max_items * 10 + window_margin = _WINDOW_MARGIN window_start = max(0, min(all_positions) - window_margin) window_end = max(all_positions) + window_margin window_items = await document_item_repository.get_items_in_range( @@ -194,9 +212,7 @@ async def expand_with_items( passthrough.append(result) continue - lo, hi = _find_expansion_range( - window_items, matched, has_sections, max_items, max_chars - ) + lo, hi = _find_expansion_range(window_items, matched, has_sections, max_chars) ranges.append((lo, hi, result)) merged = _merge_ranges(ranges) diff --git a/tests/test_context.py b/tests/test_context.py index 94e062a0..7eb5f1f0 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -49,11 +49,12 @@ class TestMergeRanges: assert len(merged) == 1 assert merged[0] == (0, 15, [r1, r2]) - def test_adjacent(self): + def test_adjacent_stay_separate(self): r1, r2 = _result(), _result() merged = _merge_ranges([(0, 5, r1), (6, 10, r2)]) - assert len(merged) == 1 - assert merged[0] == (0, 10, [r1, r2]) + assert len(merged) == 2 + assert merged[0] == (0, 5, [r1]) + assert merged[1] == (6, 10, [r2]) def test_sorts_by_position(self): r1, r2 = _result(), _result() @@ -65,7 +66,7 @@ class TestMergeRanges: class TestExpandOutward: def test_basic_expansion(self): items = [_item(i, text=f"{'x' * 100}") for i in range(10)] - lo, hi = _expand_outward(items, 5, max_items=10, max_chars=500) + lo, hi = _expand_outward(items, 5, max_chars=500) assert lo <= 5 assert hi >= 5 total = sum( @@ -76,16 +77,9 @@ class TestExpandOutward: # Should be around 500 chars (may overshoot by one item) assert total >= 400 - def test_respects_max_items(self): - items = [_item(i, text="x") for i in range(100)] - lo, hi = _expand_outward(items, 50, max_items=5, max_chars=999999) - count = hi - lo + 1 - # May overshoot by 1-2 items due to alternating expansion - assert count <= 7 - def test_respects_max_chars(self): items = [_item(i, text=f"{'x' * 200}") for i in range(20)] - lo, hi = _expand_outward(items, 10, max_items=999, max_chars=500) + lo, hi = _expand_outward(items, 10, max_chars=500) total = sum( len(items[i].text) for i in range(lo, hi + 1) @@ -96,12 +90,12 @@ class TestExpandOutward: def test_center_at_start(self): items = [_item(i) for i in range(10)] - lo, hi = _expand_outward(items, 0, max_items=5, max_chars=999999) + lo, hi = _expand_outward(items, 0, max_chars=999999) assert lo == 0 def test_center_at_end(self): items = [_item(i) for i in range(10)] - lo, hi = _expand_outward(items, 9, max_items=5, max_chars=999999) + lo, hi = _expand_outward(items, 9, max_chars=999999) assert hi == 9 def test_skip_noise_excludes_from_char_count(self): @@ -113,7 +107,7 @@ class TestExpandOutward: _item(4, label="footnote", text="f" * 5000), _item(5, text="d" * 100), ] - lo, hi = _expand_outward(items, 2, max_items=10, max_chars=500, skip_noise=True) + lo, hi = _expand_outward(items, 2, max_chars=500, skip_noise=True) # Footnotes (5000 chars each) should NOT count toward budget # So we should expand past them assert lo <= 0 @@ -125,11 +119,17 @@ class TestExpandOutward: _item(1, label="document_index", text="x" * 10000), _item(2, text="b" * 200), ] - lo, hi = _expand_outward(items, 1, max_items=10, max_chars=500, skip_noise=True) + lo, hi = _expand_outward(items, 1, max_chars=500, skip_noise=True) # Center is noise, should start at 0 chars and expand outward assert lo == 0 assert hi == 2 + def test_respects_bounds(self): + items = [_item(i, text="x" * 100) for i in range(20)] + lo, hi = _expand_outward(items, 10, max_chars=999999, lo_bound=8, hi_bound=12) + assert lo == 8 + assert hi == 12 + class TestFindExpansionRange: def _structured_items(self): @@ -146,36 +146,47 @@ class TestFindExpansionRange: def test_structured_returns_section(self): items = self._structured_items() - lo, hi = _find_expansion_range( - items, {1}, has_sections=True, max_items=20, max_chars=5000 - ) + lo, hi = _find_expansion_range(items, {1}, has_sections=True, max_chars=5000) # Should return the Introduction section (items 0-3) assert lo == 0 assert hi == 3 def test_structured_different_section(self): items = self._structured_items() - lo, hi = _find_expansion_range( - items, {5}, has_sections=True, max_items=20, max_chars=5000 - ) + lo, hi = _find_expansion_range(items, {5}, has_sections=True, max_chars=5000) # Should return the Methods section (items 4-6) assert lo == 4 assert hi == 6 - def test_structured_large_section_falls_back_to_outward(self): + def test_structured_large_section_bounded_by_section(self): items = [ _item(0, label="section_header", text="Big Section"), ] + [_item(i, text="x" * 1000) for i in range(1, 20)] # Section has 19 * 1000 = 19000 chars, way over 5000 budget - lo, hi = _find_expansion_range( - items, {10}, has_sections=True, max_items=50, max_chars=5000 - ) - # Should NOT return the full section + lo, hi = _find_expansion_range(items, {10}, has_sections=True, max_chars=5000) + # Should NOT return the full section, but should stay within it total = sum( len(items[i].text) for i in range(lo, hi + 1) if items[i].position >= lo ) assert total < 10000 + def test_structured_section_with_many_items_returned_whole(self): + """A section that fits in char budget is returned even with many items.""" + items = ( + [ + _item(0, label="section_header", text="Section"), + ] + + [_item(i, text="x" * 200) for i in range(1, 20)] + + [ + _item(20, label="section_header", text="Next"), + ] + ) + # Section has 19 * 200 = 3800 chars + header, under 5000 and over min_useful + lo, hi = _find_expansion_range(items, {10}, has_sections=True, max_chars=5000) + # Should return entire section despite 20 items + assert lo == 0 + assert hi == 19 + def test_structured_small_section_expands_outward(self): items = [ _item(0, label="title", text="Paper Title"), @@ -186,26 +197,20 @@ class TestFindExpansionRange: _item(5, text="Intro content. " * 50), ] # Title section (items 0-1) is tiny (~25 chars) < 20% of 5000 - lo, hi = _find_expansion_range( - items, {0}, has_sections=True, max_items=20, max_chars=5000 - ) + lo, hi = _find_expansion_range(items, {0}, has_sections=True, max_chars=5000) # Should expand past the title section into the abstract assert hi >= 3 def test_unstructured_expands_outward(self): items = [_item(i, text=f"Paragraph {i}. " * 10) for i in range(10)] - lo, hi = _find_expansion_range( - items, {5}, has_sections=False, max_items=20, max_chars=5000 - ) + lo, hi = _find_expansion_range(items, {5}, has_sections=False, max_chars=5000) assert lo < 5 assert hi > 5 def test_multiple_matched_positions_uses_center(self): items = [_item(i, text="x" * 100) for i in range(20)] - # Match at positions 3 and 7, center should be index for position 5 (median) - lo, hi = _find_expansion_range( - items, {3, 7}, has_sections=False, max_items=5, max_chars=999999 - ) + # Use a char budget that forces partial expansion so center matters + lo, hi = _find_expansion_range(items, {3, 7}, has_sections=False, max_chars=500) center = (lo + hi) // 2 # Center should be around position 5 assert 3 <= center <= 7 @@ -219,9 +224,7 @@ class TestFindExpansionRange: ] # Section non-noise chars: ~260 chars (items 0,1,3). Under 5000 budget. # The footnote's 10000 chars should NOT count. - lo, hi = _find_expansion_range( - items, {1}, has_sections=True, max_items=20, max_chars=5000 - ) + lo, hi = _find_expansion_range(items, {1}, has_sections=True, max_chars=5000) # Should return full section (it fits in budget excluding noise) assert lo == 0 assert hi == 3 @@ -233,9 +236,7 @@ class TestFindExpansionRange: _item(2, label="section_header", text="First Section"), _item(3, text="Section content."), ] - lo, hi = _find_expansion_range( - items, {0}, has_sections=True, max_items=20, max_chars=5000 - ) + lo, hi = _find_expansion_range(items, {0}, has_sections=True, max_chars=5000) # Match is in preamble section (items 0-1), which is small # Should expand outward into the first section assert hi >= 2 @@ -262,7 +263,7 @@ class TestExpandWithItems: doc_item_refs=["#/texts/999999"], ) expanded = await expand_with_items( - rag.document_item_repository, doc.id, [result], 10, 5000 + rag.document_item_repository, doc.id, [result], 5000 ) assert len(expanded) == 1 assert expanded[0].content == "original" @@ -312,7 +313,7 @@ class TestExpandWithItems: doc_item_refs=["#/texts/1"], ) expanded = await expand_with_items( - rag.document_item_repository, "doc-1", [result], 10, 5000 + rag.document_item_repository, "doc-1", [result], 5000 ) assert len(expanded) == 1 # The TOC section's only non-header item is document_index (noise). @@ -376,7 +377,7 @@ class TestExpandWithItems: doc_item_refs=["#/texts/1", "#/texts/2", "#/texts/3", "#/texts/4"], ) expanded = await expand_with_items( - rag.document_item_repository, "doc-1", [result], 10, 5000 + rag.document_item_repository, "doc-1", [result], 5000 ) assert len(expanded) == 1 # Expansion produces "Steps\n\nClick\n\n+\n\nAdd a New Service" = 38 chars diff --git a/tests/test_context_enhancement.py b/tests/test_context_enhancement.py index 021c8b50..6f59ec7c 100644 --- a/tests/test_context_enhancement.py +++ b/tests/test_context_enhancement.py @@ -80,7 +80,6 @@ def small_chunk_config() -> AppConfig: """Config with small chunk size to force splitting.""" config = AppConfig() config.processing.chunk_size = 32 - config.search.max_context_items = 25 config.search.max_context_chars = 10000 return config @@ -305,33 +304,6 @@ async def test_format_for_agent_output(temp_db_path, small_chunk_config): assert "Content:" in formatted -@pytest.mark.vcr() -async def test_max_items_limit_caps_expansion(temp_db_path): - """Expansion should respect max_context_items limit.""" - config = AppConfig() - config.processing.chunk_size = 32 - config.search.max_context_items = 2 # Very restrictive - - docling_doc = create_list_document() - - async with HaikuRAG(temp_db_path, config=config, create=True) as client: - doc = await create_document_with_docling(client, docling_doc, "Limit Test") - assert doc.id is not None - - results = await client.search("grapes", limit=1) - assert len(results) > 0 - - expanded = await client.expand_context(results) - - # With max_items=2, expansion should be limited - content = expanded[0].content.lower() - item_count = sum( - 1 for item in ["apples", "bananas", "oranges", "grapes"] if item in content - ) - # Should have at most 2 items (the limit) - assert item_count <= 2, f"Expected at most 2 items, got {item_count}" - - async def test_expand_context_single_item_document(temp_db_path): """Test expand_context with a single-item document.""" from haiku.rag.store.models.document import Document From eb4e1721cedb742428abe803e8a9f3f02e362b3a Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Mon, 20 Apr 2026 15:38:17 +0300 Subject: [PATCH 23/24] remove dead code, add tool descriptions in frontend, update changelog --- CHANGELOG.md | 6 +- app/frontend/components/Chat.tsx | 4 + .../haiku/rag/chat/widgets/context_modal.py | 95 ------------------- haiku_rag_slim/haiku/rag/tools/__init__.py | 3 - haiku_rag_slim/haiku/rag/tools/qa.py | 32 ------- tests/tools/test_qa.py | 95 ------------------- 6 files changed, 9 insertions(+), 226 deletions(-) delete mode 100644 haiku_rag_slim/haiku/rag/chat/widgets/context_modal.py delete mode 100644 haiku_rag_slim/haiku/rag/tools/qa.py delete mode 100644 tests/tools/test_qa.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c5ab3b8d..756b971d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added - **Document virtual filesystem in analysis sandbox**: Documents mounted at `/documents/{id}/` with `metadata.json` (eager), `content.txt` (lazy), and `items.jsonl` (lazy). Standard Python `pathlib.Path` for browsing and reading document content and structure. -- **`execute_code` skill tool**: Direct code execution in the sandbox, surfaced as individual AG-UI events in the chat TUI +- **`execute_code` skill tool**: Direct code execution in the sandbox, surfaced as individual AG-UI events in the chat TUI. Items VFS uses a lazy bulk cache (~1s for 1000 documents vs 60s+ per-document queries). - **`cite` skill tool**: Explicit citation registration with per-turn tracking via `citation_index` and `citations` fields in state - **`--skill` flag for chat TUI**: `haiku-rag chat -s rag -s analysis` to enable specific skills - **`--model` overrides all agents**: Chat, QA, research, and analysis agents all use the specified model @@ -24,6 +24,8 @@ - **`list_documents` skill tool** takes no parameters — returns all documents - **Per-turn citation tracking**: `citation_index: dict[str, Citation]` (deduplicated) + `citations: list[list[str]]` (per-turn chunk IDs) replaces flat citation list - **Search rate limiting**: Skill search tool enforces `config.qa.max_searches` +- **Context expansion respects section boundaries**: Sections within the char budget are returned whole regardless of item count. Too-large sections expand bounded by section edges. Adjacent sections no longer merge — only overlapping ranges do. +- **Visualization shows full expanded section**: `visualize_chunk` expands context before resolving bounding boxes, so all pages the section spans get highlighted. ### Removed @@ -35,6 +37,8 @@ - **`create_analysis_toolset()`**: Removed unused `tools/analysis.py` module - **`qa_history`, `reports` from skill state**: Conversational context handled by the outer chat agent - **`combine_filters`, `build_document_filter`**: Removed from public API +- **`max_context_items`**: Removed from `SearchConfig` — `max_context_chars` is the sole expansion constraint +- **`QAHistoryEntry`, `tools/qa.py`**: Removed unused QA history model and relevance threshold ## [0.40.1] - 2026-04-17 diff --git a/app/frontend/components/Chat.tsx b/app/frontend/components/Chat.tsx index 9fbb3028..17a50eca 100644 --- a/app/frontend/components/Chat.tsx +++ b/app/frontend/components/Chat.tsx @@ -204,6 +204,10 @@ function ToolCallIndicator({ ); } + case "cite": + return Registering citations; + case "list_documents": + return Listing documents; default: return Processing...; } diff --git a/haiku_rag_slim/haiku/rag/chat/widgets/context_modal.py b/haiku_rag_slim/haiku/rag/chat/widgets/context_modal.py deleted file mode 100644 index 3dc74659..00000000 --- a/haiku_rag_slim/haiku/rag/chat/widgets/context_modal.py +++ /dev/null @@ -1,95 +0,0 @@ -from textual.app import ComposeResult -from textual.binding import Binding -from textual.containers import Horizontal, Vertical, VerticalScroll -from textual.screen import ModalScreen -from textual.widgets import Button, Markdown, Static - - -class ContextModal(ModalScreen): - """Modal screen for viewing session Q&A history.""" - - BINDINGS = [ - Binding("escape", "cancel", "Close", show=False), - Binding("ctrl+o", "cancel", "Close", show=False), - ] - - CSS = """ - ContextModal { - align: center middle; - background: rgba(0, 0, 0, 0.5); - } - - #context-container { - width: 70; - height: auto; - max-height: 32; - background: $surface; - border: tall $primary; - padding: 1 2; - } - - #context-header { - height: auto; - margin-bottom: 1; - } - - #context-description { - height: auto; - margin-bottom: 1; - color: $text-muted; - } - - #context-content { - height: 1fr; - max-height: 16; - scrollbar-gutter: stable; - } - - #button-row { - height: auto; - margin-top: 1; - align: right middle; - } - - #button-row Button { - margin-left: 1; - min-width: 10; - } - """ - - def __init__(self, qa_history: list | None = None) -> None: - super().__init__() - self._qa_history = qa_history or [] - - def compose(self) -> ComposeResult: - with Vertical(id="context-container"): - yield Static("[bold]Session Context[/bold]", id="context-header") - yield Static( - "Questions and answers from this session.", - id="context-description", - ) - with VerticalScroll(id="context-content"): - yield Markdown(self._get_content()) - with Horizontal(id="button-row"): - yield Button("Close", id="cancel-btn", variant="primary") - - def _get_content(self) -> str: - if not self._qa_history: - return "*No questions asked yet.*" - - parts = [] - for entry in self._qa_history: - q = getattr(entry, "question", str(entry)) - a = getattr(entry, "answer", "") - parts.append(f"**Q:** {q}\n\n**A:** {a}") - - return "\n\n---\n\n".join(parts) - - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle button presses.""" - if event.button.id == "cancel-btn": - self.action_cancel() - - def action_cancel(self) -> None: - """Cancel and close.""" - self.app.pop_screen() diff --git a/haiku_rag_slim/haiku/rag/tools/__init__.py b/haiku_rag_slim/haiku/rag/tools/__init__.py index 384ead52..156a003e 100644 --- a/haiku_rag_slim/haiku/rag/tools/__init__.py +++ b/haiku_rag_slim/haiku/rag/tools/__init__.py @@ -1,12 +1,9 @@ from haiku.rag.tools.context import RAGDeps from haiku.rag.tools.document import create_document_toolset from haiku.rag.tools.filters import build_multi_document_filter -from haiku.rag.tools.qa import PRIOR_ANSWER_RELEVANCE_THRESHOLD, QAHistoryEntry from haiku.rag.tools.search import create_search_toolset __all__ = [ - "PRIOR_ANSWER_RELEVANCE_THRESHOLD", - "QAHistoryEntry", "RAGDeps", "build_multi_document_filter", "create_document_toolset", diff --git a/haiku_rag_slim/haiku/rag/tools/qa.py b/haiku_rag_slim/haiku/rag/tools/qa.py deleted file mode 100644 index 116377ea..00000000 --- a/haiku_rag_slim/haiku/rag/tools/qa.py +++ /dev/null @@ -1,32 +0,0 @@ -from pydantic import BaseModel, Field - -from haiku.rag.agents.research.models import Citation, SearchAnswer - -PRIOR_ANSWER_RELEVANCE_THRESHOLD = 0.7 - - -class QAHistoryEntry(BaseModel): - """A Q&A pair with optional cached embedding for similarity matching.""" - - question: str - answer: str - confidence: float = 0.9 - citations: list[Citation] = Field(default_factory=list) - question_embedding: list[float] | None = Field(default=None, exclude=True) - - @property - def sources(self) -> list[str]: - """Source names for display.""" - return list( - dict.fromkeys(c.document_title or c.document_uri for c in self.citations) - ) - - def to_search_answer(self) -> SearchAnswer: - """Convert to SearchAnswer for research graph context.""" - return SearchAnswer( - query=self.question, - answer=self.answer, - confidence=self.confidence, - cited_chunks=[c.chunk_id for c in self.citations], - citations=self.citations, - ) diff --git a/tests/tools/test_qa.py b/tests/tools/test_qa.py deleted file mode 100644 index f21aee3a..00000000 --- a/tests/tools/test_qa.py +++ /dev/null @@ -1,95 +0,0 @@ -from haiku.rag.agents.research.models import Citation -from haiku.rag.tools.qa import PRIOR_ANSWER_RELEVANCE_THRESHOLD, QAHistoryEntry - - -class TestQAHistoryEntry: - """Tests for QAHistoryEntry model.""" - - def test_defaults(self): - """QAHistoryEntry has sensible defaults.""" - entry = QAHistoryEntry(question="What is X?", answer="X is Y.") - assert entry.confidence == 0.9 - assert entry.citations == [] - assert entry.question_embedding is None - - def test_sources_property(self): - """sources returns unique document titles.""" - citations = [ - Citation( - document_id="d1", - chunk_id="c1", - document_uri="doc1.md", - document_title="Document One", - content="Content 1", - ), - Citation( - document_id="d1", - chunk_id="c2", - document_uri="doc1.md", - document_title="Document One", - content="Content 2", - ), - Citation( - document_id="d2", - chunk_id="c3", - document_uri="doc2.md", - document_title="Document Two", - content="Content 3", - ), - ] - entry = QAHistoryEntry(question="Q", answer="A", citations=citations) - sources = entry.sources - assert len(sources) == 2 - assert "Document One" in sources - assert "Document Two" in sources - - def test_sources_uses_uri_as_fallback(self): - """sources uses uri when title is None.""" - citations = [ - Citation( - document_id="d1", - chunk_id="c1", - document_uri="test.md", - document_title=None, - content="Content", - ), - ] - entry = QAHistoryEntry(question="Q", answer="A", citations=citations) - assert entry.sources == ["test.md"] - - def test_to_search_answer(self): - """to_search_answer converts to SearchAnswer.""" - citation = Citation( - document_id="d1", - chunk_id="c1", - document_uri="doc1.md", - document_title="Doc", - content="Content", - ) - entry = QAHistoryEntry( - question="What is X?", - answer="X is Y.", - confidence=0.85, - citations=[citation], - ) - sa = entry.to_search_answer() - assert sa.query == "What is X?" - assert sa.answer == "X is Y." - assert sa.confidence == 0.85 - assert sa.cited_chunks == ["c1"] - assert len(sa.citations) == 1 - - def test_question_embedding_excluded_from_serialization(self): - """question_embedding is excluded from model_dump.""" - entry = QAHistoryEntry( - question="Q", - answer="A", - question_embedding=[0.1, 0.2], - ) - data = entry.model_dump() - assert "question_embedding" not in data - - -def test_prior_answer_relevance_threshold(): - """PRIOR_ANSWER_RELEVANCE_THRESHOLD is a sensible value.""" - assert 0 < PRIOR_ANSWER_RELEVANCE_THRESHOLD < 1 From 8e555ab76f95aa94a38cecc27a1febaee42ea338 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Mon, 20 Apr 2026 15:45:27 +0300 Subject: [PATCH 24/24] Improve coverage --- tests/skills/test_analysis.py | 33 +++++++++++++++++++++++++++++++++ tests/skills/test_rag.py | 17 +++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/tests/skills/test_analysis.py b/tests/skills/test_analysis.py index 040ad023..e2b66799 100644 --- a/tests/skills/test_analysis.py +++ b/tests/skills/test_analysis.py @@ -159,3 +159,36 @@ class TestExecuteCodeTool: ctx, code="docs = await list_documents()\nprint(len(docs))" ) assert "1" in result + + async def test_execute_code_accumulates_search_results(self, rag_db): + from haiku.rag.skills.analysis import create_skill + + skill = create_skill(db_path=rag_db) + execute_code = _get_tool(skill, "execute_code") + state = AnalysisState() + ctx = _make_ctx(state) + await execute_code( + ctx, code="results = await search('intelligence')\nprint(len(results))" + ) + assert "_sandbox" in state.searches + assert len(state.searches["_sandbox"]) > 0 + + async def test_execute_code_vfs_write_denied(self, rag_db): + from haiku.rag.skills.analysis import create_skill + + skill = create_skill(db_path=rag_db) + execute_code = _get_tool(skill, "execute_code") + state = AnalysisState() + ctx = _make_ctx(state) + result = await execute_code( + ctx, + code=( + "from pathlib import Path\n" + "import json\n" + "dirs = list(Path('/documents').iterdir())\n" + "p = dirs[0] / 'content.txt'\n" + "p.write_text('hacked')" + ), + ) + assert "Error" in result + assert "read-only" in result diff --git a/tests/skills/test_rag.py b/tests/skills/test_rag.py index d39f080b..6fee0262 100644 --- a/tests/skills/test_rag.py +++ b/tests/skills/test_rag.py @@ -200,6 +200,23 @@ class TestSearchTool: result = await search(ctx, query="artificial intelligence") assert isinstance(result, str) + async def test_search_rate_limited(self, rag_db): + from haiku.rag.skills.rag import RAGState, create_skill + + config = AppConfig() + config.qa.max_searches = 2 + skill = create_skill(db_path=rag_db, config=config) + search = _get_tool(skill, "search") + state = RAGState() + ctx = _make_ctx(state) + ctx.run_id = "test-run" + + await search(ctx, query="first") + await search(ctx, query="second") + result = await search(ctx, query="third") + assert "Search limit reached" in result + assert len(state.searches) == 2 + class TestListDocumentsTool: async def test_list_documents_returns_results(self, rag_db):