from dataclasses import dataclass from ag_ui.core import EventType, StateSnapshotEvent from pydantic import BaseModel from pydantic_ai import Agent, RunContext, ToolReturn, format_as_xml from haiku.rag.client import HaikuRAG from haiku.rag.config.models import AppConfig from haiku.rag.store.models import SearchResult from haiku.rag.utils import get_model class CitationInfo(BaseModel): """Citation info for frontend display.""" index: int document_id: str chunk_id: str document_uri: str document_title: str | None = None page_numbers: list[int] = [] headings: list[str] | None = None content: str class QAResponse(BaseModel): """A Q&A pair from conversation history with citations.""" question: str answer: str confidence: float = 0.9 citations: list[CitationInfo] = [] @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) ) class ChatSessionState(BaseModel): """State shared between frontend and agent via AG-UI.""" session_id: str = "" citations: list[CitationInfo] = [] qa_history: list[QAResponse] = [] def format_conversation_context(qa_history: list[QAResponse]) -> str: """Format conversation history as XML for inclusion in prompts.""" if not qa_history: return "" context_data = { "previous_qa": [ { "question": qa.question, "answer": qa.answer, "sources": qa.sources, } for qa in qa_history ], } return format_as_xml(context_data, root_tag="conversation_context") @dataclass class ChatDeps: """Dependencies for chat agent.""" client: HaikuRAG config: AppConfig search_results: list[SearchResult] | None = None session_state: ChatSessionState | None = None def build_document_filter(document_name: str) -> str: """Build SQL filter for document name matching.""" escaped = document_name.replace("'", "''") no_spaces = escaped.replace(" ", "") return ( f"LOWER(uri) LIKE LOWER('%{escaped}%') OR LOWER(title) LIKE LOWER('%{escaped}%') " f"OR LOWER(uri) LIKE LOWER('%{no_spaces}%') OR LOWER(title) LIKE LOWER('%{no_spaces}%')" ) CHAT_SYSTEM_PROMPT = """You are a helpful research assistant powered by haiku.rag, a knowledge base system. You have access to a knowledge base of documents. Use your tools to search and answer questions. CRITICAL RULES: 1. For greetings or casual chat: respond directly WITHOUT using any tools 2. For questions: Use the "ask" tool EXACTLY ONCE - it handles query expansion internally 3. For searches: Use the "search" tool EXACTLY ONCE - it handles multi-query expansion internally 4. NEVER call the same tool multiple times for a single user message 5. NEVER make up information - always use tools to get facts from the knowledge base How to decide which tool to use: - "get_document" - Use when the user references a SPECIFIC document by name, title, or URI (e.g., "summarize document X", "get the paper about Y", "fetch 2412.00566"). Retrieves the full document content. - "ask" - Use for general questions about topics in the knowledge base when no specific document is named. It searches across all documents and returns answers with citations. - "search" - Use when the user explicitly asks to search/find/explore documents. Call it ONCE. After calling search, copy the ENTIRE tool response to your output INCLUDING the content snippets. Do NOT shorten, summarize, or omit any part of the results. IMPORTANT - When user mentions a document in search/ask: - If user says "search in ", "find in ", "answer from ", or " in ": - Extract the TOPIC as `query`/`question` - Extract the DOCUMENT NAME as `document_name` - Examples for search: - "search for latrines in TB MED 593" → query="latrines", document_name="TB MED 593" - "find waste disposal in the army manual" → query="waste disposal", document_name="army manual" - Examples for ask: - "what does TB MED 593 say about latrines?" → question="what are the guidelines for latrines?", document_name="TB MED 593" - "answer from the army manual about sanitation" → question="what are the sanitation guidelines?", document_name="army manual" Be friendly and conversational. When you use the "ask" tool, summarize the key findings for the user.""" def create_chat_agent(config: AppConfig) -> Agent[ChatDeps, str]: """Create the chat agent with search and ask tools.""" model = get_model(config.qa.model, config) agent: Agent[ChatDeps, str] = Agent( model, deps_type=ChatDeps, output_type=str, instructions=CHAT_SYSTEM_PROMPT, ) @agent.tool async def search( ctx: RunContext[ChatDeps], query: str, document_name: str | None = None, ) -> ToolReturn: """Search the knowledge base for relevant documents. Use this when you need to find documents or explore the knowledge base. Results are displayed to the user - just list the titles found. Args: query: The search query (what to search for) document_name: Optional document name/title to search within (e.g., "tbmed593", "army manual") """ from search_agent import SearchAgent # Build context from conversation history context = None if ctx.deps.session_state and ctx.deps.session_state.qa_history: context = format_conversation_context(ctx.deps.session_state.qa_history) # Build filter from document_name doc_filter = build_document_filter(document_name) if document_name else None # Use search agent for query expansion and deduplication search_agent = SearchAgent(ctx.deps.client, ctx.deps.config) results = await search_agent.search(query, context=context, filter=doc_filter) # Store for potential citation resolution ctx.deps.search_results = results if not results: return ToolReturn(return_value="No results found.") # Build citation infos for frontend display citation_infos = [ CitationInfo( index=i + 1, document_id=r.document_id or "", chunk_id=r.chunk_id or "", document_uri=r.document_uri or "", document_title=r.document_title, page_numbers=r.page_numbers or [], headings=r.headings, content=r.content, ) for i, r in enumerate(results) ] # Build new state with citations new_state = ChatSessionState( session_id=( ctx.deps.session_state.session_id if ctx.deps.session_state else "" ), citations=citation_infos, qa_history=( ctx.deps.session_state.qa_history if ctx.deps.session_state else [] ), ) # Return detailed results for the agent to present result_lines = [] for i, r in enumerate(results): title = r.document_title or r.document_uri or "Unknown" # Truncate content for display snippet = r.content[:300].replace("\n", " ").strip() if len(r.content) > 300: snippet += "..." line = f"[{i + 1}] **{title}**" if r.page_numbers: line += f" (pages {', '.join(map(str, r.page_numbers))})" line += f"\n {snippet}" result_lines.append(line) return ToolReturn( return_value=f"Found {len(results)} results:\n\n" + "\n\n".join(result_lines), metadata=[ StateSnapshotEvent( type=EventType.STATE_SNAPSHOT, snapshot=new_state.model_dump(), ) ], ) @agent.tool async def ask( ctx: RunContext[ChatDeps], question: str, document_name: str | None = None, ) -> ToolReturn: """Answer a specific question using the knowledge base. Use this for direct questions that need a focused answer with citations. Uses a research graph for planning, searching, and synthesis. Args: question: The question to answer document_name: Optional document name/title to search within (e.g., "tbmed593", "army manual") """ from haiku.rag.graph.research.dependencies import ResearchContext from haiku.rag.graph.research.graph import build_conversational_graph from haiku.rag.graph.research.models import Citation, SearchAnswer from haiku.rag.graph.research.state import ResearchDeps, ResearchState # Build filter from document_name doc_filter = build_document_filter(document_name) if document_name else None # Convert existing qa_history to SearchAnswers for context seeding existing_qa: list[SearchAnswer] = [] if ctx.deps.session_state and ctx.deps.session_state.qa_history: for qa in ctx.deps.session_state.qa_history: citations = [ Citation( document_id=c.document_id, chunk_id=c.chunk_id, document_uri=c.document_uri, document_title=c.document_title, page_numbers=c.page_numbers, headings=c.headings, content=c.content, ) for c in qa.citations ] existing_qa.append( SearchAnswer( query=qa.question, answer=qa.answer, confidence=qa.confidence, cited_chunks=[c.chunk_id for c in qa.citations], citations=citations, ) ) # Build and run the conversational research graph graph = build_conversational_graph(config=ctx.deps.config) context = ResearchContext( original_question=question, qa_responses=existing_qa, ) state = ResearchState( context=context, max_iterations=1, confidence_threshold=0.0, search_filter=doc_filter, max_concurrency=ctx.deps.config.research.max_concurrency, ) deps = ResearchDeps( client=ctx.deps.client, ) result = await graph.run(state=state, deps=deps) # Build citation infos for frontend and history citation_infos = [ CitationInfo( index=i + 1, document_id=c.document_id, chunk_id=c.chunk_id, document_uri=c.document_uri, document_title=c.document_title, page_numbers=c.page_numbers, headings=c.headings, content=c.content, ) for i, c in enumerate(result.citations) ] # Accumulate Q&A in session state with full citation metadata if ctx.deps.session_state is not None: qa_response = QAResponse( question=question, answer=result.answer, confidence=result.confidence, citations=citation_infos, ) ctx.deps.session_state.qa_history.append(qa_response) # Build new state with citations AND accumulated qa_history new_state = ChatSessionState( session_id=( ctx.deps.session_state.session_id if ctx.deps.session_state else "" ), citations=citation_infos, qa_history=( ctx.deps.session_state.qa_history if ctx.deps.session_state else [] ), ) # Format answer with citation references and confidence answer_text = result.answer if citation_infos: citation_refs = " ".join(f"[{i + 1}]" for i in range(len(citation_infos))) answer_text = f"{answer_text}\n\nSources: {citation_refs}" return ToolReturn( return_value=answer_text, metadata=[ StateSnapshotEvent( type=EventType.STATE_SNAPSHOT, snapshot=new_state.model_dump(), ) ], ) @agent.tool async def get_document( ctx: RunContext[ChatDeps], query: str, ) -> str: """Retrieve a specific document by title or URI. Use this when the user wants to fetch/get/retrieve a specific document. Args: query: The document title or URI to look up """ # Try exact URI match first doc = await ctx.deps.client.get_document_by_uri(query) escaped_query = query.replace("'", "''") # Also try without spaces for matching "TB MED 593" to "tbmed593" no_spaces = escaped_query.replace(" ", "") # If not found, try partial URI match (with and without spaces) if doc is None: docs = await ctx.deps.client.list_documents( limit=1, filter=f"LOWER(uri) LIKE LOWER('%{escaped_query}%') OR LOWER(uri) LIKE LOWER('%{no_spaces}%')", ) if docs: doc = docs[0] # If still not found, try partial title match (with and without spaces) if doc is None: docs = await ctx.deps.client.list_documents( limit=1, filter=f"LOWER(title) LIKE LOWER('%{escaped_query}%') OR LOWER(title) LIKE LOWER('%{no_spaces}%')", ) if docs: doc = docs[0] if doc is None: return f"Document not found: {query}" return ( f"**{doc.title or 'Untitled'}**\n\n" f"- ID: {doc.id}\n" f"- URI: {doc.uri or 'N/A'}\n" f"- Created: {doc.created_at.strftime('%Y-%m-%d %H:%M')}\n\n" f"**Content:**\n{doc.content}" ) return agent