add citation support to analysis agent

This commit is contained in:
Yiorgis Gozadinos 2026-04-17 14:04:14 +03:00
parent cf89ff55cd
commit 7d98d0ec57
No known key found for this signature in database
10 changed files with 82 additions and 31 deletions

View file

@ -1,6 +1,10 @@
from haiku.rag.agents.analysis.agent import create_analysis_agent from haiku.rag.agents.analysis.agent import create_analysis_agent
from haiku.rag.agents.analysis.dependencies import AnalysisContext, AnalysisDeps 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.prompts import ANALYSIS_SYSTEM_PROMPT
from haiku.rag.agents.analysis.sandbox import Sandbox, SandboxResult from haiku.rag.agents.analysis.sandbox import Sandbox, SandboxResult
@ -8,6 +12,7 @@ __all__ = [
"ANALYSIS_SYSTEM_PROMPT", "ANALYSIS_SYSTEM_PROMPT",
"AnalysisContext", "AnalysisContext",
"AnalysisDeps", "AnalysisDeps",
"RawAnalysisResult",
"AnalysisResult", "AnalysisResult",
"CodeExecution", "CodeExecution",
"Sandbox", "Sandbox",

View file

@ -1,13 +1,13 @@
from pydantic_ai import Agent, RunContext from pydantic_ai import Agent, RunContext
from haiku.rag.agents.analysis.dependencies import AnalysisDeps 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.agents.analysis.prompts import ANALYSIS_SYSTEM_PROMPT
from haiku.rag.config.models import AppConfig from haiku.rag.config.models import AppConfig
from haiku.rag.utils import get_model 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. """Create an analysis agent with code execution capability.
The analysis agent can write and execute Python code in a sandboxed 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) 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, model,
deps_type=AnalysisDeps, deps_type=AnalysisDeps,
output_type=AnalysisResult, output_type=RawAnalysisResult,
instructions=ANALYSIS_SYSTEM_PROMPT, instructions=ANALYSIS_SYSTEM_PROMPT,
retries=3, retries=3,
) )

View file

@ -1,5 +1,7 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from haiku.rag.agents.research.models import Citation
class CodeExecution(BaseModel): class CodeExecution(BaseModel):
"""Result of executing a code block in the analysis sandbox.""" """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") success: bool = Field(description="Whether execution completed without error")
class AnalysisResult(BaseModel): class RawAnalysisResult(BaseModel):
"""Result from analysis agent execution.""" """Raw result from the analysis agent (LLM output)."""
answer: str = Field(description="The answer to the user's question") answer: str = Field(description="The answer to the user's question")
program: str = Field(description="The final consolidated program") 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)

View file

@ -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: Your final response MUST be valid JSON matching this exact schema:
```json ```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. - `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. - `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.""" You MUST call execute_code at least once before providing your answer. Never give up without trying to execute code first."""

View file

@ -10,6 +10,7 @@ from pydantic_monty import CallbackFile, MemoryFile, OSAccess
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.models.chunk import SearchResult
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import PurePosixPath from pathlib import PurePosixPath
@ -47,6 +48,7 @@ class Sandbox:
_client: "HaikuRAG" _client: "HaikuRAG"
_config: AppConfig _config: AppConfig
_context: AnalysisContext _context: AnalysisContext
_search_results: "list[SearchResult]"
def __init__( def __init__(
self, self,
@ -57,6 +59,7 @@ class Sandbox:
self._client = client self._client = client
self._config = config self._config = config
self._context = context self._context = context
self._search_results = []
def _build_external_functions(self) -> dict[str, Any]: def _build_external_functions(self) -> dict[str, Any]:
"""Build async external functions for the Monty interpreter.""" """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]]: async def search(query: str, limit: int = 10) -> list[dict[str, Any]]:
results = await client.search(query, limit=limit, filter=context.filter) results = await client.search(query, limit=limit, filter=context.filter)
expanded = await client.expand_context(results) expanded = await client.expand_context(results)
self._search_results.extend(expanded)
return [ return [
{ {
"chunk_id": r.chunk_id, "chunk_id": r.chunk_id,

View file

@ -30,6 +30,7 @@ from textual.worker import Worker
from haiku.rag.chat.widgets.chat_history import ChatHistory, CitationWidget from haiku.rag.chat.widgets.chat_history import ChatHistory, CitationWidget
from haiku.rag.client import HaikuRAG from haiku.rag.client import HaikuRAG
from haiku.rag.config import get_config 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.rag.skills.rag import RAGState, get_agent_preamble
from haiku.skills.agent import ( from haiku.skills.agent import (
SkillToolset, SkillToolset,
@ -51,6 +52,7 @@ if TYPE_CHECKING:
RAG_STATE_NAMESPACE = "rag" RAG_STATE_NAMESPACE = "rag"
ANALYSIS_STATE_NAMESPACE = "analysis"
class ChatApp(App): class ChatApp(App):
@ -319,15 +321,15 @@ class ChatApp(App):
chat_input.focus() chat_input.focus()
async def _show_citations(self, chat_history: "ChatHistory") -> None: 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: if not self._toolset:
return return
rag_state = self._toolset.get_namespace(RAG_STATE_NAMESPACE) citations = []
if rag_state is None: for namespace in (RAG_STATE_NAMESPACE, ANALYSIS_STATE_NAMESPACE):
return state = self._toolset.get_namespace(namespace)
citations = getattr(rag_state, "citations", []) if state:
citations.extend(getattr(state, "citations", []))
if citations: if citations:
# Show only new citations (since last response)
await chat_history.add_citations(citations) await chat_history.add_citations(citations)
async def action_clear_chat(self) -> None: async def action_clear_chat(self) -> None:
@ -420,9 +422,11 @@ class ChatApp(App):
self._document_filter = event.selected self._document_filter = event.selected
if self._toolset: if self._toolset:
doc_filter = build_multi_document_filter(self._document_filter)
rag_state = self._toolset.get_namespace(RAG_STATE_NAMESPACE) rag_state = self._toolset.get_namespace(RAG_STATE_NAMESPACE)
if isinstance(rag_state, RAGState): if isinstance(rag_state, RAGState):
rag_state.document_filter = build_multi_document_filter( rag_state.document_filter = doc_filter
self._document_filter analysis_state = self._toolset.get_namespace(ANALYSIS_STATE_NAMESPACE)
) if isinstance(analysis_state, AnalysisState):
self._state = self._toolset.build_state_snapshot() analysis_state.document_filter = doc_filter
self._state = self._toolset.build_state_snapshot()

View file

@ -1239,10 +1239,19 @@ class HaikuRAG:
context=context, 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) agent = create_analysis_agent(self._config)
result = await agent.run(question, deps=deps) 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: async def visualize_chunk(self, chunk: Chunk) -> list:
"""Render page images with bounding box highlights for a chunk. """Render page images with bounding box highlights for a chunk.

View file

@ -210,7 +210,7 @@ async def skill_analyze(
question: str, question: str,
document: str | None = None, document: str | None = None,
document_filter: 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 from haiku.rag.client import HaikuRAG
async with HaikuRAG(db_path, config=config, read_only=True) as rag: async with HaikuRAG(db_path, config=config, read_only=True) as rag:
@ -222,7 +222,7 @@ async def skill_analyze(
if result.program: if result.program:
output += f"\n\nProgram:\n{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( def update_documents_state(
@ -246,6 +246,15 @@ def _get_state(ctx: RunContext[SkillRunDeps], state_type: type[BaseModel]) -> An
return None 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( def create_skill_extras(
db_path: Path, db_path: Path,
config: AppConfig, config: AppConfig,
@ -406,11 +415,7 @@ def create_skill_tools(
) )
if state: if state:
next_index = len(state.citations) + 1 _append_citations(state, citations)
for citation in citations:
citation.index = next_index
next_index += 1
state.citations.extend(citations)
state.qa_history.append( state.qa_history.append(
QAHistoryEntry( QAHistoryEntry(
question=question, answer=answer, citations=citations question=question, answer=answer, citations=citations
@ -476,9 +481,11 @@ def create_skill_tools(
question: The question to answer. question: The question to answer.
document: Optional document ID or title to pre-load for analysis. 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 = _get_state(ctx, state_type)
state_filter = state.document_filter if state else None state_filter = state.document_filter if state else None
output, answer, program = await skill_analyze( output, answer, program, citations = await skill_analyze(
db_path, db_path,
config, config,
question, question,
@ -493,6 +500,11 @@ def create_skill_tools(
program=program, program=program,
) )
) )
if citations:
_append_citations(state, citations)
if citations:
output += "\n\n" + format_citations(citations)
return output return output

View file

@ -2,8 +2,9 @@ import os
from functools import cache from functools import cache
from pathlib import Path 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.config.models import AppConfig
from haiku.rag.skills._tools import AnalysisEntry from haiku.rag.skills._tools import AnalysisEntry
from haiku.skills.models import Skill, SkillMetadata, SkillSource, StateMetadata from haiku.skills.models import Skill, SkillMetadata, SkillSource, StateMetadata
@ -13,6 +14,7 @@ from haiku.skills.parser import parse_skill_md
class AnalysisState(BaseModel): class AnalysisState(BaseModel):
document_filter: str | None = None document_filter: str | None = None
analyses: list[AnalysisEntry] = [] analyses: list[AnalysisEntry] = []
citations: list[Citation] = Field(default_factory=list)
STATE_TYPE = AnalysisState STATE_TYPE = AnalysisState

View file

@ -5,7 +5,7 @@ from pydantic_ai import Agent
from haiku.rag.agents.analysis.agent import create_analysis_agent from haiku.rag.agents.analysis.agent import create_analysis_agent
from haiku.rag.agents.analysis.dependencies import AnalysisDeps 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 from haiku.rag.config import AppConfig, Config
@ -19,7 +19,7 @@ class TestCreateAnalysisAgent:
agent = create_analysis_agent(Config) agent = create_analysis_agent(Config)
assert isinstance(agent, Agent) assert isinstance(agent, Agent)
assert agent.deps_type is AnalysisDeps 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): def test_agent_has_execute_code_tool(self):
agent = create_analysis_agent(Config) agent = create_analysis_agent(Config)