add get_context() to analysis sandbox and improve prompt
This commit is contained in:
parent
d2b3ba1b59
commit
44fd0c9906
11 changed files with 187 additions and 162 deletions
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -1,6 +1,22 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
## [Unreleased]
|
## [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
|
## [0.40.1] - 2026-04-17
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
@ -21,21 +37,12 @@
|
||||||
- **`max_searches` default**: Raised from 3 to 5 — faster expansion makes additional searches inexpensive
|
- **`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 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
|
- **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
|
### Removed
|
||||||
|
|
||||||
- **`context_radius` config**: Replaced by automatic section-bounded expansion. Context expansion no longer requires configuration.
|
- **`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
|
- **DoclingDocument LRU cache**: No longer needed — the document_items table replaces in-memory caching for context expansion
|
||||||
- **`cachetools` dependency**: No longer used
|
- **`cachetools` dependency**: No longer used
|
||||||
- **`create_analysis_toolset()`**: Removed unused `tools/analysis.py` module.
|
|
||||||
|
|
||||||
## [0.39.0] - 2026-04-09
|
## [0.39.0] - 2026-04-09
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,9 +59,9 @@ The agent's code runs in a sandboxed Python interpreter ([pydantic-monty](https:
|
||||||
| Function | Description |
|
| Function | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `search(query, limit)` | Hybrid search (vector + full-text) returning matching chunks with scores |
|
| `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 |
|
| `list_documents(limit, offset)` | List documents in the knowledge base |
|
||||||
| `get_document(id_or_title)` | Get full text content of a document |
|
| `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) |
|
| `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 |
|
| `llm(prompt)` | Call an LLM for classification, summarization, or extraction |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,8 @@ def create_analysis_agent(config: AppConfig) -> Agent[AnalysisDeps, AnalysisResu
|
||||||
async def execute_code(ctx: RunContext[AnalysisDeps], code: str) -> CodeExecution:
|
async def execute_code(ctx: RunContext[AnalysisDeps], code: str) -> CodeExecution:
|
||||||
"""Execute Python code in a sandboxed interpreter.
|
"""Execute Python code in a sandboxed interpreter.
|
||||||
|
|
||||||
The code has access to haiku.rag functions (search, list_documents,
|
The code has access to haiku.rag functions (search, get_context,
|
||||||
get_document, get_chunk, llm).
|
list_documents, get_document, get_docling_document, llm).
|
||||||
|
|
||||||
Use print() to output results.
|
Use print() to output results.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
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
|
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]
|
### await list_documents(limit=10, offset=0) -> list[dict]
|
||||||
List available documents in the knowledge base.
|
List available documents in the knowledge base.
|
||||||
Returns list of dicts with keys: id, title, uri, created_at
|
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.
|
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.
|
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
|
### await get_docling_document(document_id) -> dict | None
|
||||||
Get the full document structure as a dict (DoclingDocument format).
|
Get the full document structure as a dict (DoclingDocument format).
|
||||||
Use `list_documents()` or search results to get document IDs first.
|
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`
|
- `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
|
- `pictures`: list of figures/images with metadata
|
||||||
- `pages`: page dimensions and metadata
|
|
||||||
|
|
||||||
### await llm(prompt) -> str
|
### await llm(prompt) -> str
|
||||||
Call an LLM directly with the given prompt. Returns the response as a string.
|
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
|
## 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.
|
1. **Search First**: Start with `search()` to find relevant content. Examine the results to understand what's available.
|
||||||
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.
|
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. **Iterative Refinement**: Run code, examine results, adjust your approach based on what you find.
|
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 llm() for Classification/Extraction**: When you need to classify, summarize, or extract structured data from content you already have, use `await llm()`.
|
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. **Cite Your Sources**: Use get_chunk() to retrieve chunk metadata for citations. Track which documents/chunks informed your answer.
|
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
|
## Example Patterns
|
||||||
|
|
||||||
### Counting documents matching a condition
|
### Search and expand context
|
||||||
```python
|
```python
|
||||||
docs = await list_documents(limit=100)
|
results = await search("revenue figures", limit=5)
|
||||||
count = 0
|
for r in results:
|
||||||
for doc in docs:
|
print(f"{r['document_title']}: {r['content'][:100]}")
|
||||||
content = await get_document(doc['id'])
|
|
||||||
if content and 'keyword' in content.lower():
|
# Get more context around the most relevant result
|
||||||
count += 1
|
expanded = await get_context(results[0]['chunk_id'])
|
||||||
print(f"Found in: {doc['title']}")
|
if expanded:
|
||||||
print(f"Total: {count}")
|
print(f"Expanded: {expanded[:500]}")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Extracting data with regex
|
### Extracting data with regex
|
||||||
|
|
@ -108,6 +108,15 @@ for d in docs:
|
||||||
print(f" Table {i}: {cells}")
|
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
|
## Output Format
|
||||||
|
|
||||||
Your final response MUST be valid JSON matching this exact schema:
|
Your final response MUST be valid JSON matching this exact schema:
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import pydantic_monty
|
||||||
from haiku.rag.agents.analysis.dependencies import AnalysisContext
|
from haiku.rag.agents.analysis.dependencies import AnalysisContext
|
||||||
from haiku.rag.config.models import AppConfig
|
from haiku.rag.config.models import AppConfig
|
||||||
from haiku.rag.store.compression import decompress_json
|
from haiku.rag.store.compression import decompress_json
|
||||||
|
from haiku.rag.store.models.chunk import SearchResult
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from haiku.rag.client import HaikuRAG
|
from haiku.rag.client import HaikuRAG
|
||||||
|
|
@ -88,25 +89,15 @@ class Sandbox:
|
||||||
doc = await client.resolve_document(id_or_title)
|
doc = await client.resolve_document(id_or_title)
|
||||||
return doc.content if doc else None
|
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)
|
chunk = await client.get_chunk_by_id(chunk_id)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
return None
|
return None
|
||||||
meta = chunk.get_chunk_metadata()
|
search_result = SearchResult.from_chunk(chunk, score=1.0)
|
||||||
doc_title = chunk.document_title
|
expanded = await client.expand_context([search_result])
|
||||||
if not doc_title and chunk.document_id:
|
if expanded:
|
||||||
doc = await client.get_document_by_id(chunk.document_id)
|
return expanded[0].content
|
||||||
if doc:
|
return chunk.content
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def get_docling_document(
|
async def get_docling_document(
|
||||||
document_id: str,
|
document_id: str,
|
||||||
|
|
@ -131,7 +122,7 @@ class Sandbox:
|
||||||
"search": search,
|
"search": search,
|
||||||
"list_documents": list_documents,
|
"list_documents": list_documents,
|
||||||
"get_document": get_document,
|
"get_document": get_document,
|
||||||
"get_chunk": get_chunk,
|
"get_context": get_context,
|
||||||
"get_docling_document": get_docling_document,
|
"get_docling_document": get_docling_document,
|
||||||
"llm": llm,
|
"llm": llm,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,17 +139,10 @@ class TestClientAnalysisIntegration:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.vcr()
|
@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
|
self, allow_model_requests, temp_db_path
|
||||||
):
|
):
|
||||||
"""Test analysis agent can search and use get_chunk for citations.
|
"""Test analysis agent can search and identify source documents."""
|
||||||
|
|
||||||
Agent program:
|
|
||||||
results = search("content", limit=5)
|
|
||||||
for r in results:
|
|
||||||
chunk = get_chunk(r['chunk_id'])
|
|
||||||
print(chunk['document_title'], chunk['chunk_id'])
|
|
||||||
"""
|
|
||||||
from haiku.rag.client import HaikuRAG
|
from haiku.rag.client import HaikuRAG
|
||||||
|
|
||||||
config = AppConfig()
|
config = AppConfig()
|
||||||
|
|
|
||||||
|
|
@ -162,41 +162,44 @@ class TestSandboxHaikuRAG:
|
||||||
assert result.success
|
assert result.success
|
||||||
assert "True" in result.stdout
|
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.asyncio
|
||||||
@pytest.mark.vcr()
|
@pytest.mark.vcr()
|
||||||
async def test_get_chunk(self, temp_db_path):
|
async def test_get_context_returns_expanded_content(self, temp_db_path):
|
||||||
"""Test get_chunk function returns chunk with metadata."""
|
"""get_context returns content for a valid chunk."""
|
||||||
config = AppConfig()
|
config = AppConfig()
|
||||||
async with HaikuRAG(temp_db_path, create=True) as client:
|
async with HaikuRAG(temp_db_path, create=True) as client:
|
||||||
await client.create_document(
|
await client.create_document(
|
||||||
content="Content about foxes and dogs.",
|
content="The quick brown fox jumps over the lazy dog.",
|
||||||
uri="test://doc",
|
uri="test://animals",
|
||||||
title="Fox Document",
|
title="Animals",
|
||||||
)
|
)
|
||||||
|
|
||||||
context = AnalysisContext()
|
context = AnalysisContext()
|
||||||
sb = Sandbox(client=client, config=config, context=context)
|
sb = Sandbox(client=client, config=config, context=context)
|
||||||
# First search to get a chunk_id
|
|
||||||
result = await sb.execute(
|
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_id = results[0]['chunk_id']\n"
|
||||||
"chunk = await get_chunk(chunk_id)\n"
|
"ctx = await get_context(chunk_id)\n"
|
||||||
"print(chunk['document_title'])\n"
|
"print(type(ctx).__name__)\n"
|
||||||
"print('content' in chunk)"
|
"print('fox' in ctx.lower())"
|
||||||
)
|
)
|
||||||
assert result.success
|
assert result.success
|
||||||
assert "Fox Document" in result.stdout
|
assert "str" in result.stdout
|
||||||
assert "True" 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:
|
class TestSandboxExternalFunctionEdgeCases:
|
||||||
"""Test edge cases in external function dispatch."""
|
"""Test edge cases in external function dispatch."""
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -155,7 +155,29 @@ class TestAnalyzeTool:
|
||||||
assert state.analyses[0].answer == "42"
|
assert state.analyses[0].answer == "42"
|
||||||
assert state.analyses[0].program == "print(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
|
from haiku.rag.skills.analysis import AnalysisState, create_skill
|
||||||
|
|
||||||
captured_kwargs = {}
|
captured_kwargs = {}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue