Remove SearchAgent & friends, consolidate duplicate models
This commit is contained in:
parent
84b320765a
commit
df57b0cf43
18 changed files with 81 additions and 3491 deletions
|
|
@ -56,7 +56,7 @@ The chat agent enables multi-turn conversational RAG. It maintains session state
|
|||
Key features:
|
||||
|
||||
- **Session memory**: Previous Q/A pairs are used as context for follow-up questions
|
||||
- **Query expansion**: SearchAgent generates multiple query variations for better recall
|
||||
- **Query expansion**: Search toolset generates multiple query variations for better recall
|
||||
- **Document filtering**: Natural language document filtering ("search in document X about...")
|
||||
- **Confidence filtering**: Low-confidence answers are flagged
|
||||
|
||||
|
|
@ -85,13 +85,14 @@ See [Applications](apps.md#chat-tui) for the full TUI interface guide.
|
|||
|
||||
```python
|
||||
from haiku.rag.client import HaikuRAG
|
||||
from haiku.rag.agents.chat import create_chat_agent, ChatDeps, ChatSessionState
|
||||
from haiku.rag.agents.chat import create_chat_agent, ChatDeps
|
||||
from haiku.rag.tools import ToolContext
|
||||
|
||||
async with HaikuRAG(path_to_db) as client:
|
||||
# Create agent and session
|
||||
agent = create_chat_agent(config)
|
||||
session = ChatSessionState()
|
||||
deps = ChatDeps(client=client, config=config, session_state=session)
|
||||
# Create agent with composed toolsets
|
||||
context = ToolContext()
|
||||
agent = create_chat_agent(config, client, context)
|
||||
deps = ChatDeps(config=config, tool_context=context)
|
||||
|
||||
# First question
|
||||
result = await agent.run("What is haiku.rag?", deps=deps)
|
||||
|
|
@ -134,14 +135,16 @@ Q/A history is used to:
|
|||
When using the chat agent with AG-UI streaming, state is emitted under a namespaced key to avoid conflicts with other agents:
|
||||
|
||||
```python
|
||||
from haiku.rag.agents.chat import AGUI_STATE_KEY, ChatDeps, ChatSessionState
|
||||
from haiku.rag.agents.chat import AGUI_STATE_KEY, ChatDeps
|
||||
from haiku.rag.tools import ToolContext
|
||||
|
||||
# AGUI_STATE_KEY = "haiku.rag.chat"
|
||||
|
||||
context = ToolContext()
|
||||
agent = create_chat_agent(config, client, context)
|
||||
deps = ChatDeps(
|
||||
client=client,
|
||||
config=config,
|
||||
session_state=ChatSessionState(),
|
||||
tool_context=context,
|
||||
state_key=AGUI_STATE_KEY, # Enables namespaced state emission
|
||||
)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
from haiku.rag.agents.chat import (
|
||||
ChatDeps,
|
||||
ChatSessionState,
|
||||
QAResponse,
|
||||
SearchAgent,
|
||||
SearchDeps,
|
||||
QAHistoryEntry,
|
||||
create_chat_agent,
|
||||
)
|
||||
from haiku.rag.agents.qa import QuestionAnswerAgent, get_qa_agent
|
||||
|
|
@ -34,9 +32,7 @@ __all__ = [
|
|||
"IterativePlanResult",
|
||||
# Chat
|
||||
"create_chat_agent",
|
||||
"SearchAgent",
|
||||
"ChatDeps",
|
||||
"ChatSessionState",
|
||||
"QAResponse",
|
||||
"SearchDeps",
|
||||
"QAHistoryEntry",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -8,30 +8,25 @@ from haiku.rag.agents.chat.context import (
|
|||
summarize_session,
|
||||
update_session_context,
|
||||
)
|
||||
from haiku.rag.agents.chat.search import SearchAgent
|
||||
from haiku.rag.agents.chat.state import (
|
||||
AGUI_STATE_KEY,
|
||||
ChatSessionState,
|
||||
DocumentInfo,
|
||||
DocumentListResponse,
|
||||
QAResponse,
|
||||
SearchDeps,
|
||||
SessionContext,
|
||||
)
|
||||
from haiku.rag.tools.context import ToolContext
|
||||
from haiku.rag.tools.document import DocumentInfo, DocumentListResponse
|
||||
from haiku.rag.tools.qa import QAHistoryEntry
|
||||
|
||||
__all__ = [
|
||||
"AGUI_STATE_KEY",
|
||||
"create_chat_agent",
|
||||
"run_chat_agent",
|
||||
"trigger_background_summarization",
|
||||
"SearchAgent",
|
||||
"ChatDeps",
|
||||
"ChatSessionState",
|
||||
"DocumentInfo",
|
||||
"DocumentListResponse",
|
||||
"QAResponse",
|
||||
"SearchDeps",
|
||||
"QAHistoryEntry",
|
||||
"SessionContext",
|
||||
"ToolContext",
|
||||
"summarize_session",
|
||||
|
|
|
|||
|
|
@ -14,16 +14,13 @@ from haiku.rag.agents.chat.prompts import CHAT_SYSTEM_PROMPT
|
|||
from haiku.rag.agents.chat.state import (
|
||||
AGUI_STATE_KEY,
|
||||
ChatSessionState,
|
||||
DocumentInfo,
|
||||
QAResponse,
|
||||
SearchDeps,
|
||||
SessionContext,
|
||||
emit_state_event,
|
||||
)
|
||||
from haiku.rag.client import HaikuRAG
|
||||
from haiku.rag.config.models import AppConfig
|
||||
from haiku.rag.tools.context import ToolContext
|
||||
from haiku.rag.tools.document import DocumentListResponse, create_document_toolset
|
||||
from haiku.rag.tools.document import create_document_toolset
|
||||
from haiku.rag.tools.qa import (
|
||||
QA_SESSION_NAMESPACE,
|
||||
QASessionState,
|
||||
|
|
@ -275,10 +272,6 @@ __all__ = [
|
|||
"trigger_background_summarization",
|
||||
"ChatDeps",
|
||||
"ChatSessionState",
|
||||
"DocumentInfo",
|
||||
"DocumentListResponse",
|
||||
"QAResponse",
|
||||
"SearchDeps",
|
||||
"SessionContext",
|
||||
"emit_state_event",
|
||||
"AGUI_STATE_KEY",
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ from typing import TYPE_CHECKING
|
|||
from pydantic_ai import Agent
|
||||
|
||||
from haiku.rag.agents.chat.prompts import SESSION_SUMMARY_PROMPT
|
||||
from haiku.rag.agents.chat.state import ChatSessionState, QAResponse, SessionContext
|
||||
from haiku.rag.agents.chat.state import ChatSessionState, SessionContext
|
||||
from haiku.rag.config.models import AppConfig
|
||||
from haiku.rag.utils import get_model
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from haiku.rag.tools.qa import QASessionState
|
||||
from haiku.rag.tools.qa import QAHistoryEntry, QASessionState
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -79,7 +79,7 @@ def get_cached_embedding(session_id: str, question: str) -> list[float] | None:
|
|||
|
||||
|
||||
async def summarize_session(
|
||||
qa_history: list[QAResponse],
|
||||
qa_history: list["QAHistoryEntry"],
|
||||
config: AppConfig,
|
||||
current_context: str | None = None,
|
||||
) -> str:
|
||||
|
|
@ -113,7 +113,7 @@ async def summarize_session(
|
|||
|
||||
|
||||
async def update_session_context(
|
||||
qa_history: list[QAResponse],
|
||||
qa_history: list["QAHistoryEntry"],
|
||||
config: AppConfig,
|
||||
session_state: ChatSessionState,
|
||||
) -> None:
|
||||
|
|
@ -143,7 +143,7 @@ async def update_session_context(
|
|||
cache_session_context(session_state.session_id, session_state.session_context)
|
||||
|
||||
|
||||
def _format_qa_history(qa_history: list[QAResponse]) -> str:
|
||||
def _format_qa_history(qa_history: list["QAHistoryEntry"]) -> str:
|
||||
"""Format qa_history for input to summarization."""
|
||||
lines: list[str] = []
|
||||
for i, qa in enumerate(qa_history, 1):
|
||||
|
|
@ -165,16 +165,7 @@ async def _update_context_background(
|
|||
) -> None:
|
||||
"""Background task to update session context after an ask."""
|
||||
try:
|
||||
# Convert QAHistoryEntry to QAResponse format for update_session_context
|
||||
qa_history = [
|
||||
QAResponse(
|
||||
question=entry.question,
|
||||
answer=entry.answer,
|
||||
confidence=entry.confidence,
|
||||
citations=list(entry.citations),
|
||||
)
|
||||
for entry in qa_session_state.qa_history
|
||||
]
|
||||
qa_history = list(qa_session_state.qa_history)
|
||||
|
||||
session_state = ChatSessionState(
|
||||
session_id=session_id,
|
||||
|
|
|
|||
|
|
@ -29,18 +29,6 @@ IMPORTANT - When user mentions a document in search/ask:
|
|||
|
||||
Be friendly and conversational. When you use the "ask" tool, summarize the key findings for the user."""
|
||||
|
||||
SEARCH_SYSTEM_PROMPT = """You are a search query optimizer. You MUST use the run_search tool to execute searches.
|
||||
|
||||
For each user request:
|
||||
1. Use the run_search tool with the original query
|
||||
2. Use run_search again with 1-2 alternative keyword queries
|
||||
3. Keep all queries SHORT (2-5 words)
|
||||
4. After all tool calls complete, respond "Search complete"
|
||||
|
||||
You can optionally specify a limit parameter (default 5).
|
||||
|
||||
IMPORTANT: You must make actual tool calls. Do not output "run_search(...)" as text."""
|
||||
|
||||
SESSION_SUMMARY_PROMPT = """You are a session summarizer. Given a conversation history of Q&A pairs (and optionally existing context), produce a structured summary that captures key information for future context.
|
||||
|
||||
If a "Current Context" section is provided at the start of the input, incorporate that context into your summary. This might be initial background context from the user or a previous summary - build upon it rather than discard it.
|
||||
|
|
@ -60,16 +48,3 @@ Rules:
|
|||
- Preserve document names/titles when mentioned in sources
|
||||
|
||||
Output the summary directly in markdown format. Do not include meta-commentary about the summary itself."""
|
||||
|
||||
DOCUMENT_SUMMARY_PROMPT = """Generate a summary of the document content provided below.
|
||||
|
||||
Start with a one-paragraph overview, then list the main topics covered, and highlight any key findings or conclusions.
|
||||
|
||||
Guidelines:
|
||||
- Aim for 1-2 paragraphs for short documents, 3-4 paragraphs for longer ones
|
||||
- Focus on factual content and key information
|
||||
- Do not include meta-commentary like "This document discusses..." or "The document covers..."
|
||||
- Do not speculate beyond what's in the content
|
||||
|
||||
Document content:
|
||||
{content}"""
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
from pydantic_ai import Agent, RunContext
|
||||
|
||||
from haiku.rag.agents.chat.prompts import SEARCH_SYSTEM_PROMPT
|
||||
from haiku.rag.agents.chat.state import SearchDeps
|
||||
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 SearchAgent:
|
||||
"""Agent that generates multiple queries and consolidates results."""
|
||||
|
||||
def __init__(self, client: HaikuRAG, config: AppConfig):
|
||||
self._client = client
|
||||
self._config = config
|
||||
|
||||
model = get_model(config.qa.model, config)
|
||||
self._agent: Agent[SearchDeps, str] = Agent(
|
||||
model,
|
||||
deps_type=SearchDeps,
|
||||
output_type=str,
|
||||
instructions=SEARCH_SYSTEM_PROMPT,
|
||||
retries=3,
|
||||
)
|
||||
|
||||
@self._agent.tool
|
||||
async def run_search(
|
||||
ctx: RunContext[SearchDeps],
|
||||
query: str,
|
||||
limit: int | None = None,
|
||||
) -> str:
|
||||
"""Run a single search query against the knowledge base.
|
||||
|
||||
Args:
|
||||
query: The search query
|
||||
limit: Number of results to fetch (default: 5)
|
||||
"""
|
||||
effective_limit = limit or 5
|
||||
results = await ctx.deps.client.search(
|
||||
query, limit=effective_limit, filter=ctx.deps.filter
|
||||
)
|
||||
results = await ctx.deps.client.expand_context(results)
|
||||
ctx.deps.search_results.extend(results)
|
||||
|
||||
if not results:
|
||||
return f"No results for: {query}"
|
||||
return f"Found {len(results)} results for: {query}"
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
context: str | None = None,
|
||||
filter: str | None = None,
|
||||
limit: int | None = None,
|
||||
) -> list[SearchResult]:
|
||||
"""Execute search with query expansion and deduplication.
|
||||
|
||||
Args:
|
||||
query: The user's search request
|
||||
context: Optional conversation context
|
||||
filter: Optional SQL WHERE clause to filter documents
|
||||
limit: Maximum number of results to return (default: config limit)
|
||||
|
||||
Returns:
|
||||
Deduplicated list of SearchResult sorted by score
|
||||
"""
|
||||
prompt = query
|
||||
if context:
|
||||
prompt = f"Context: {context}\n\nSearch request: {query}"
|
||||
|
||||
deps = SearchDeps(client=self._client, config=self._config, filter=filter)
|
||||
await self._agent.run(prompt, deps=deps)
|
||||
|
||||
# Deduplicate by chunk_id, keeping highest score
|
||||
seen: dict[str, SearchResult] = {}
|
||||
for result in deps.search_results:
|
||||
chunk_id = result.chunk_id or ""
|
||||
if chunk_id not in seen or result.score > seen[chunk_id].score:
|
||||
seen[chunk_id] = result
|
||||
|
||||
# Sort by score descending and apply limit
|
||||
effective_limit = limit or self._config.search.limit
|
||||
return sorted(seen.values(), key=lambda r: r.score, reverse=True)[
|
||||
:effective_limit
|
||||
]
|
||||
|
|
@ -1,65 +1,18 @@
|
|||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import jsonpatch
|
||||
from ag_ui.core import EventType, StateDeltaEvent
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from haiku.rag.agents.research.models import Citation, SearchAnswer
|
||||
from haiku.rag.client import HaikuRAG
|
||||
from haiku.rag.config.models import AppConfig
|
||||
from haiku.rag.store.models import SearchResult
|
||||
from haiku.rag.agents.research.models import Citation
|
||||
|
||||
MAX_QA_HISTORY = 50
|
||||
if TYPE_CHECKING:
|
||||
from haiku.rag.tools.qa import QAHistoryEntry
|
||||
|
||||
AGUI_STATE_KEY = "haiku.rag.chat"
|
||||
|
||||
|
||||
class QAResponse(BaseModel):
|
||||
"""A Q&A pair from conversation history with citations."""
|
||||
|
||||
question: str
|
||||
answer: str
|
||||
confidence: float = 0.9
|
||||
citations: list[Citation] = []
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
class DocumentInfo(BaseModel):
|
||||
"""Document info for list_documents response."""
|
||||
|
||||
title: str
|
||||
uri: str
|
||||
created: str
|
||||
|
||||
|
||||
class DocumentListResponse(BaseModel):
|
||||
"""Response from list_documents tool."""
|
||||
|
||||
documents: list[DocumentInfo]
|
||||
page: int
|
||||
total_pages: int
|
||||
total_documents: int
|
||||
|
||||
|
||||
class SessionContext(BaseModel):
|
||||
"""Compressed summary of conversation history for research graph."""
|
||||
|
||||
|
|
@ -77,7 +30,7 @@ class ChatSessionState(BaseModel):
|
|||
session_id: str = ""
|
||||
initial_context: str | None = None
|
||||
citations: list[Citation] = []
|
||||
qa_history: list[QAResponse] = []
|
||||
qa_history: list["QAHistoryEntry"] = []
|
||||
session_context: SessionContext | None = None
|
||||
document_filter: list[str] = []
|
||||
citation_registry: dict[str, int] = {}
|
||||
|
|
@ -97,69 +50,14 @@ class ChatSessionState(BaseModel):
|
|||
return new_index
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatDeps:
|
||||
"""Dependencies for chat agent.
|
||||
def _rebuild_models(qa_history_entry_cls: type) -> None:
|
||||
"""Resolve ChatSessionState forward reference to QAHistoryEntry.
|
||||
|
||||
Implements StateHandler protocol for AG-UI state management.
|
||||
Must be called after QAHistoryEntry is defined, passing the class.
|
||||
"""
|
||||
|
||||
client: HaikuRAG
|
||||
config: AppConfig
|
||||
search_results: list[SearchResult] | None = None
|
||||
session_state: ChatSessionState = field(
|
||||
default_factory=lambda: ChatSessionState(session_id="")
|
||||
ChatSessionState.model_rebuild(
|
||||
_types_namespace={"QAHistoryEntry": qa_history_entry_cls}
|
||||
)
|
||||
state_key: str | None = None
|
||||
|
||||
@property
|
||||
def state(self) -> dict[str, Any]:
|
||||
"""Get current state for AG-UI protocol."""
|
||||
snapshot = self.session_state.model_dump()
|
||||
if self.state_key:
|
||||
return {self.state_key: snapshot}
|
||||
return snapshot
|
||||
|
||||
@state.setter
|
||||
def state(self, value: dict[str, Any] | None) -> None:
|
||||
"""Set state from AG-UI protocol."""
|
||||
if value is None:
|
||||
return
|
||||
# Extract from namespaced key if present
|
||||
state_data: dict[str, Any] = value
|
||||
if self.state_key and self.state_key in value:
|
||||
nested = value[self.state_key]
|
||||
if isinstance(nested, dict):
|
||||
state_data = nested
|
||||
# Update session_state from incoming state
|
||||
if "qa_history" in state_data:
|
||||
self.session_state.qa_history = [
|
||||
QAResponse(**qa) if isinstance(qa, dict) else qa
|
||||
for qa in state_data.get("qa_history", [])
|
||||
]
|
||||
if "citations" in state_data:
|
||||
self.session_state.citations = [
|
||||
Citation(**c) if isinstance(c, dict) else c
|
||||
for c in state_data.get("citations", [])
|
||||
]
|
||||
if state_data.get("session_id"):
|
||||
self.session_state.session_id = state_data["session_id"]
|
||||
if "document_filter" in state_data:
|
||||
self.session_state.document_filter = state_data.get("document_filter", [])
|
||||
if "citation_registry" in state_data:
|
||||
self.session_state.citation_registry = state_data["citation_registry"]
|
||||
if "initial_context" in state_data:
|
||||
self.session_state.initial_context = state_data.get("initial_context")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchDeps:
|
||||
"""Dependencies for search agent."""
|
||||
|
||||
client: HaikuRAG
|
||||
config: AppConfig
|
||||
filter: str | None = None
|
||||
search_results: list[SearchResult] = field(default_factory=list)
|
||||
|
||||
|
||||
def emit_state_event(
|
||||
|
|
|
|||
|
|
@ -203,10 +203,10 @@ class ChatApp(App):
|
|||
for c in chat_state["citations"]
|
||||
]
|
||||
if "qa_history" in chat_state:
|
||||
from haiku.rag.agents.chat.state import QAResponse
|
||||
from haiku.rag.tools.qa import QAHistoryEntry
|
||||
|
||||
self.session_state.qa_history = [
|
||||
QAResponse(**qa) if isinstance(qa, dict) else qa
|
||||
QAHistoryEntry(**qa) if isinstance(qa, dict) else qa
|
||||
for qa in chat_state["qa_history"]
|
||||
]
|
||||
if "session_context" in chat_state:
|
||||
|
|
|
|||
|
|
@ -49,6 +49,13 @@ class QAHistoryEntry(BaseModel):
|
|||
citations: list[Citation] = []
|
||||
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(
|
||||
|
|
@ -60,6 +67,12 @@ class QAHistoryEntry(BaseModel):
|
|||
)
|
||||
|
||||
|
||||
# Resolve ChatSessionState forward reference to QAHistoryEntry
|
||||
from haiku.rag.agents.chat.state import _rebuild_models # noqa: E402
|
||||
|
||||
_rebuild_models(QAHistoryEntry)
|
||||
|
||||
|
||||
class QAState(BaseModel):
|
||||
"""State for QA toolset.
|
||||
|
||||
|
|
|
|||
|
|
@ -7,16 +7,15 @@ from haiku.rag.agents.chat import (
|
|||
AGUI_STATE_KEY,
|
||||
ChatDeps,
|
||||
ChatSessionState,
|
||||
QAResponse,
|
||||
SearchAgent,
|
||||
QAHistoryEntry,
|
||||
ToolContext,
|
||||
create_chat_agent,
|
||||
)
|
||||
from haiku.rag.agents.chat.context import get_cached_session_context
|
||||
from haiku.rag.agents.chat.state import MAX_QA_HISTORY
|
||||
from haiku.rag.agents.research.models import Citation
|
||||
from haiku.rag.client import HaikuRAG
|
||||
from haiku.rag.config import Config
|
||||
from haiku.rag.tools.qa import MAX_QA_HISTORY
|
||||
from haiku.rag.tools.session import SESSION_NAMESPACE, SessionState
|
||||
|
||||
|
||||
|
|
@ -226,7 +225,7 @@ def test_citation():
|
|||
|
||||
|
||||
def test_qa_response():
|
||||
"""Test QAResponse model."""
|
||||
"""Test QAHistoryEntry model."""
|
||||
citation = Citation(
|
||||
index=1,
|
||||
document_id="doc-123",
|
||||
|
|
@ -235,7 +234,7 @@ def test_qa_response():
|
|||
document_title="Test Document",
|
||||
content="Test content",
|
||||
)
|
||||
qa = QAResponse(
|
||||
qa = QAHistoryEntry(
|
||||
question="What is this?",
|
||||
answer="This is a test",
|
||||
confidence=0.95,
|
||||
|
|
@ -249,7 +248,7 @@ def test_qa_response():
|
|||
|
||||
|
||||
def test_qa_response_sources_with_uri_fallback():
|
||||
"""Test QAResponse.sources falls back to URI when title is None."""
|
||||
"""Test QAHistoryEntry.sources falls back to URI when title is None."""
|
||||
citation = Citation(
|
||||
index=1,
|
||||
document_id="doc-123",
|
||||
|
|
@ -258,7 +257,7 @@ def test_qa_response_sources_with_uri_fallback():
|
|||
document_title=None,
|
||||
content="Test content",
|
||||
)
|
||||
qa = QAResponse(
|
||||
qa = QAHistoryEntry(
|
||||
question="What is this?",
|
||||
answer="This is a test",
|
||||
citations=[citation],
|
||||
|
|
@ -267,7 +266,7 @@ def test_qa_response_sources_with_uri_fallback():
|
|||
|
||||
|
||||
def test_qa_response_to_search_answer():
|
||||
"""Test QAResponse.to_search_answer() converts to SearchAnswer for research graph."""
|
||||
"""Test QAHistoryEntry.to_search_answer() converts to SearchAnswer for research graph."""
|
||||
citation = Citation(
|
||||
index=1,
|
||||
document_id="doc-123",
|
||||
|
|
@ -276,7 +275,7 @@ def test_qa_response_to_search_answer():
|
|||
document_title="Test Document",
|
||||
content="Test content",
|
||||
)
|
||||
qa = QAResponse(
|
||||
qa = QAHistoryEntry(
|
||||
question="What is the answer?",
|
||||
answer="The answer is 42",
|
||||
confidence=0.95,
|
||||
|
|
@ -293,14 +292,6 @@ def test_qa_response_to_search_answer():
|
|||
assert search_answer.citations[0].chunk_id == "chunk-456"
|
||||
|
||||
|
||||
def test_search_agent_initialization(temp_db_path):
|
||||
"""Test SearchAgent can be initialized."""
|
||||
client = HaikuRAG(temp_db_path, create=True)
|
||||
search_agent = SearchAgent(client, Config)
|
||||
assert search_agent is not None
|
||||
client.close()
|
||||
|
||||
|
||||
# DocLayNet content for testing
|
||||
DOCLAYNET_CLASS_LABELS = """
|
||||
DocLayNet Dataset - Class Labels
|
||||
|
|
@ -467,108 +458,6 @@ async def test_chat_agent_get_document_not_found(allow_model_requests, temp_db_p
|
|||
assert result.output is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.vcr()
|
||||
async def test_search_agent_with_context(allow_model_requests, temp_db_path):
|
||||
"""Test SearchAgent's search method with context."""
|
||||
async with HaikuRAG(temp_db_path, create=True) as client:
|
||||
# Add test documents
|
||||
await client.create_document(
|
||||
content=DOCLAYNET_CLASS_LABELS,
|
||||
uri="doclaynet-labels",
|
||||
title="DocLayNet Class Labels",
|
||||
)
|
||||
await client.create_document(
|
||||
content=DOCLAYNET_ANNOTATION,
|
||||
uri="doclaynet-annotation",
|
||||
title="DocLayNet Annotation",
|
||||
)
|
||||
|
||||
search_agent = SearchAgent(client, Config)
|
||||
|
||||
# Search with context
|
||||
results = await search_agent.search(
|
||||
query="What are the class labels?",
|
||||
context="We're discussing document layout analysis",
|
||||
)
|
||||
|
||||
assert isinstance(results, list)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.vcr()
|
||||
async def test_search_agent_with_filter(allow_model_requests, temp_db_path):
|
||||
"""Test SearchAgent's search method with document filter."""
|
||||
async with HaikuRAG(temp_db_path, create=True) as client:
|
||||
# Add test documents
|
||||
await client.create_document(
|
||||
content=DOCLAYNET_CLASS_LABELS,
|
||||
uri="doclaynet-labels",
|
||||
title="DocLayNet Class Labels",
|
||||
)
|
||||
await client.create_document(
|
||||
content=DOCLAYNET_DATA_SOURCES,
|
||||
uri="doclaynet-sources",
|
||||
title="DocLayNet Sources",
|
||||
)
|
||||
|
||||
search_agent = SearchAgent(client, Config)
|
||||
|
||||
# Search with filter - only the labels document
|
||||
results = await search_agent.search(
|
||||
query="What information is available?",
|
||||
filter="uri LIKE '%labels%'",
|
||||
)
|
||||
|
||||
assert isinstance(results, list)
|
||||
# Results should only come from the labels document
|
||||
for r in results:
|
||||
assert "labels" in (r.document_uri or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.vcr()
|
||||
async def test_search_agent_deduplication(allow_model_requests, temp_db_path):
|
||||
"""Test SearchAgent deduplicates results by chunk_id."""
|
||||
async with HaikuRAG(temp_db_path, create=True) as client:
|
||||
# Add test documents
|
||||
await client.create_document(
|
||||
content=DOCLAYNET_CLASS_LABELS,
|
||||
uri="doclaynet-labels",
|
||||
title="DocLayNet Class Labels",
|
||||
)
|
||||
|
||||
search_agent = SearchAgent(client, Config)
|
||||
|
||||
# Search - the search agent will likely run multiple queries
|
||||
# that could return the same chunk, which should be deduplicated
|
||||
results = await search_agent.search(
|
||||
query="Tell me about class labels and their counts",
|
||||
)
|
||||
|
||||
assert isinstance(results, list)
|
||||
|
||||
# Verify no duplicate chunk_ids
|
||||
chunk_ids = [r.chunk_id for r in results if r.chunk_id]
|
||||
assert len(chunk_ids) == len(set(chunk_ids)), "Found duplicate chunk_ids"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.vcr()
|
||||
async def test_search_agent_no_results(allow_model_requests, temp_db_path):
|
||||
"""Test SearchAgent handles no results gracefully."""
|
||||
async with HaikuRAG(temp_db_path, create=True) as client:
|
||||
search_agent = SearchAgent(client, Config)
|
||||
|
||||
# Search in empty database
|
||||
results = await search_agent.search(
|
||||
query="Find information about nonexistent topic xyz123",
|
||||
)
|
||||
|
||||
assert isinstance(results, list)
|
||||
assert len(results) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.vcr()
|
||||
async def test_chat_agent_ask_adds_citations(allow_model_requests, temp_db_path):
|
||||
|
|
@ -728,7 +617,7 @@ def test_fifo_limit_enforcement():
|
|||
"""
|
||||
# Create a session state with MAX_QA_HISTORY + 1 entries
|
||||
qa_history = [
|
||||
QAResponse(
|
||||
QAHistoryEntry(
|
||||
question=f"Question {i}",
|
||||
answer=f"Answer {i}",
|
||||
confidence=0.9,
|
||||
|
|
@ -819,43 +708,6 @@ async def test_chat_agent_search_with_session_filter(
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.vcr()
|
||||
async def test_search_agent_with_session_filter(allow_model_requests, temp_db_path):
|
||||
"""Test SearchAgent respects session document filter."""
|
||||
async with HaikuRAG(temp_db_path, create=True) as client:
|
||||
# Add two distinct documents
|
||||
await client.create_document(
|
||||
content=DOCLAYNET_CLASS_LABELS,
|
||||
uri="doclaynet-labels",
|
||||
title="DocLayNet Class Labels",
|
||||
)
|
||||
await client.create_document(
|
||||
content=DOCLAYNET_DATA_SOURCES,
|
||||
uri="doclaynet-sources",
|
||||
title="DocLayNet Sources",
|
||||
)
|
||||
|
||||
from haiku.rag.tools.filters import build_multi_document_filter
|
||||
|
||||
search_agent = SearchAgent(client, Config)
|
||||
|
||||
# Build filter for only the labels document
|
||||
doc_filter = build_multi_document_filter(["DocLayNet Class Labels"])
|
||||
|
||||
results = await search_agent.search(
|
||||
query="What information is available?",
|
||||
filter=doc_filter,
|
||||
)
|
||||
|
||||
assert isinstance(results, list)
|
||||
# All results should be from the labels document
|
||||
for r in results:
|
||||
assert "labels" in (r.document_uri or "").lower() or "Labels" in (
|
||||
r.document_title or ""
|
||||
)
|
||||
|
||||
|
||||
def test_ask_tool_citation_registry_logic():
|
||||
"""Test the citation index assignment logic used by the ask tool.
|
||||
|
||||
|
|
@ -1033,9 +885,9 @@ def test_prior_answer_matching_below_threshold():
|
|||
|
||||
|
||||
def test_qa_response_embedding_cache():
|
||||
"""Test that QAResponse stores and retrieves question_embedding correctly."""
|
||||
"""Test that QAHistoryEntry stores and retrieves question_embedding correctly."""
|
||||
embedding = [0.1, 0.2, 0.3, 0.4]
|
||||
qa = QAResponse(
|
||||
qa = QAHistoryEntry(
|
||||
question="What is X?",
|
||||
answer="X is Y.",
|
||||
confidence=0.9,
|
||||
|
|
@ -1049,8 +901,8 @@ def test_qa_response_embedding_cache():
|
|||
|
||||
|
||||
def test_qa_response_embedding_default_none():
|
||||
"""Test that QAResponse.question_embedding defaults to None."""
|
||||
qa = QAResponse(
|
||||
"""Test that QAHistoryEntry.question_embedding defaults to None."""
|
||||
qa = QAHistoryEntry(
|
||||
question="What is X?",
|
||||
answer="X is Y.",
|
||||
confidence=0.9,
|
||||
|
|
|
|||
|
|
@ -3,12 +3,10 @@ from pathlib import Path
|
|||
|
||||
import pytest
|
||||
|
||||
from haiku.rag.agents.chat.state import (
|
||||
QAResponse,
|
||||
SessionContext,
|
||||
)
|
||||
from haiku.rag.agents.chat.state import SessionContext
|
||||
from haiku.rag.agents.research.models import Citation
|
||||
from haiku.rag.config import Config
|
||||
from haiku.rag.tools.qa import QAHistoryEntry
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
|
|
@ -82,7 +80,7 @@ class TestSummarizeSession:
|
|||
from haiku.rag.agents.chat.context import summarize_session
|
||||
|
||||
qa_history = [
|
||||
QAResponse(
|
||||
QAHistoryEntry(
|
||||
question="What is the authentication method?",
|
||||
answer="The API uses JWT tokens for authentication.",
|
||||
confidence=0.95,
|
||||
|
|
@ -115,7 +113,7 @@ class TestSummarizeSession:
|
|||
from haiku.rag.agents.chat.context import summarize_session
|
||||
|
||||
qa_history = [
|
||||
QAResponse(
|
||||
QAHistoryEntry(
|
||||
question="What is the authentication method?",
|
||||
answer="The API uses JWT tokens for authentication.",
|
||||
confidence=0.95,
|
||||
|
|
@ -130,7 +128,7 @@ class TestSummarizeSession:
|
|||
)
|
||||
],
|
||||
),
|
||||
QAResponse(
|
||||
QAHistoryEntry(
|
||||
question="What is the rate limit?",
|
||||
answer="Rate limiting is set to 100 requests per minute.",
|
||||
confidence=0.9,
|
||||
|
|
@ -145,7 +143,7 @@ class TestSummarizeSession:
|
|||
)
|
||||
],
|
||||
),
|
||||
QAResponse(
|
||||
QAHistoryEntry(
|
||||
question="How do I refresh tokens?",
|
||||
answer="Use the /refresh endpoint with your refresh token.",
|
||||
confidence=0.85,
|
||||
|
|
@ -178,7 +176,7 @@ class TestSummarizeSession:
|
|||
from haiku.rag.agents.chat.context import summarize_session
|
||||
|
||||
qa_history = [
|
||||
QAResponse(
|
||||
QAHistoryEntry(
|
||||
question="What's the rate limit?",
|
||||
answer="100 requests per minute.",
|
||||
confidence=0.9,
|
||||
|
|
@ -218,7 +216,7 @@ class TestUpdateSessionContext:
|
|||
session_state = ChatSessionState(session_id="test-session")
|
||||
|
||||
qa_history = [
|
||||
QAResponse(
|
||||
QAHistoryEntry(
|
||||
question="What is the authentication method?",
|
||||
answer="The API uses JWT tokens.",
|
||||
confidence=0.95,
|
||||
|
|
@ -339,7 +337,7 @@ class TestSessionContextCache:
|
|||
session_state = ChatSessionState(session_id="cache-test-session")
|
||||
|
||||
qa_history = [
|
||||
QAResponse(
|
||||
QAHistoryEntry(
|
||||
question="What is Python?",
|
||||
answer="A programming language.",
|
||||
confidence=0.95,
|
||||
|
|
@ -409,7 +407,7 @@ class TestSessionContextCache:
|
|||
)
|
||||
|
||||
qa_history = [
|
||||
QAResponse(
|
||||
QAHistoryEntry(
|
||||
question="What is JWT?",
|
||||
answer="JSON Web Token for authentication.",
|
||||
confidence=0.95,
|
||||
|
|
@ -461,7 +459,7 @@ class TestSessionContextCache:
|
|||
)
|
||||
|
||||
qa_history = [
|
||||
QAResponse(
|
||||
QAHistoryEntry(
|
||||
question="What is JWT?",
|
||||
answer="JSON Web Token.",
|
||||
confidence=0.95,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
from ag_ui.core import StateDeltaEvent
|
||||
|
||||
from haiku.rag.agents.chat.state import (
|
||||
MAX_QA_HISTORY,
|
||||
ChatSessionState,
|
||||
QAResponse,
|
||||
SessionContext,
|
||||
)
|
||||
from haiku.rag.tools.filters import (
|
||||
|
|
@ -11,6 +9,7 @@ from haiku.rag.tools.filters import (
|
|||
build_multi_document_filter,
|
||||
combine_filters,
|
||||
)
|
||||
from haiku.rag.tools.qa import QAHistoryEntry
|
||||
|
||||
|
||||
def test_build_document_filter_simple():
|
||||
|
|
@ -89,304 +88,11 @@ def test_combine_filters_both():
|
|||
|
||||
def test_max_qa_history_constant():
|
||||
"""Test MAX_QA_HISTORY constant value."""
|
||||
from haiku.rag.tools.qa import MAX_QA_HISTORY
|
||||
|
||||
assert MAX_QA_HISTORY == 50
|
||||
|
||||
|
||||
def test_chat_deps_state_getter_returns_namespaced_state():
|
||||
"""Test ChatDeps.state getter returns state under namespaced key."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from haiku.rag.agents.chat.state import AGUI_STATE_KEY, ChatDeps, ChatSessionState
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_config = MagicMock()
|
||||
|
||||
session_state = ChatSessionState(
|
||||
session_id="test-123",
|
||||
qa_history=[
|
||||
QAResponse(question="Q1", answer="A1", confidence=0.9),
|
||||
],
|
||||
)
|
||||
|
||||
deps = ChatDeps(
|
||||
client=mock_client,
|
||||
config=mock_config,
|
||||
session_state=session_state,
|
||||
state_key=AGUI_STATE_KEY,
|
||||
)
|
||||
|
||||
state = deps.state
|
||||
assert state is not None
|
||||
assert AGUI_STATE_KEY in state
|
||||
assert state[AGUI_STATE_KEY]["session_id"] == "test-123"
|
||||
assert len(state[AGUI_STATE_KEY]["qa_history"]) == 1
|
||||
assert state[AGUI_STATE_KEY]["qa_history"][0]["question"] == "Q1"
|
||||
|
||||
|
||||
def test_chat_deps_state_getter_without_namespace():
|
||||
"""Test ChatDeps.state getter returns flat state when no state_key."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from haiku.rag.agents.chat.state import ChatDeps, ChatSessionState
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_config = MagicMock()
|
||||
|
||||
session_state = ChatSessionState(session_id="test-123")
|
||||
deps = ChatDeps(
|
||||
client=mock_client,
|
||||
config=mock_config,
|
||||
session_state=session_state,
|
||||
state_key=None,
|
||||
)
|
||||
|
||||
state = deps.state
|
||||
assert state is not None
|
||||
assert "session_id" in state
|
||||
assert state["session_id"] == "test-123"
|
||||
|
||||
|
||||
def test_chat_deps_state_getter_returns_default_state():
|
||||
"""Test ChatDeps.state getter returns default state when not explicitly set."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from haiku.rag.agents.chat.state import ChatDeps
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_config = MagicMock()
|
||||
|
||||
deps = ChatDeps(
|
||||
client=mock_client,
|
||||
config=mock_config,
|
||||
)
|
||||
|
||||
state = deps.state
|
||||
assert state is not None
|
||||
assert "session_id" in state
|
||||
assert state["qa_history"] == []
|
||||
assert state["citations"] == []
|
||||
|
||||
|
||||
def test_chat_deps_state_setter_updates_from_namespaced_state():
|
||||
"""Test ChatDeps.state setter updates session_state from namespaced incoming state."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from haiku.rag.agents.chat.state import AGUI_STATE_KEY, ChatDeps, ChatSessionState
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_config = MagicMock()
|
||||
|
||||
session_state = ChatSessionState(session_id="initial")
|
||||
deps = ChatDeps(
|
||||
client=mock_client,
|
||||
config=mock_config,
|
||||
session_state=session_state,
|
||||
state_key=AGUI_STATE_KEY,
|
||||
)
|
||||
|
||||
# Simulate incoming AG-UI state with namespaced key
|
||||
incoming_state = {
|
||||
AGUI_STATE_KEY: {
|
||||
"session_id": "updated-123",
|
||||
"qa_history": [
|
||||
{"question": "Q1", "answer": "A1", "confidence": 0.9, "citations": []}
|
||||
],
|
||||
"citations": [],
|
||||
}
|
||||
}
|
||||
|
||||
deps.state = incoming_state
|
||||
|
||||
assert deps.session_state is not None
|
||||
assert deps.session_state.session_id == "updated-123"
|
||||
assert len(deps.session_state.qa_history) == 1
|
||||
assert deps.session_state.qa_history[0].question == "Q1"
|
||||
|
||||
|
||||
def test_chat_deps_state_setter_handles_none():
|
||||
"""Test ChatDeps.state setter handles None gracefully."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from haiku.rag.agents.chat.state import ChatDeps, ChatSessionState
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_config = MagicMock()
|
||||
|
||||
session_state = ChatSessionState(session_id="original")
|
||||
deps = ChatDeps(
|
||||
client=mock_client,
|
||||
config=mock_config,
|
||||
session_state=session_state,
|
||||
)
|
||||
|
||||
# Setting None should not raise and should not change state
|
||||
deps.state = None
|
||||
|
||||
assert deps.session_state is not None
|
||||
assert deps.session_state.session_id == "original"
|
||||
|
||||
|
||||
def test_chat_deps_state_setter_updates_default_state():
|
||||
"""Test ChatDeps.state setter updates the default session_state."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from haiku.rag.agents.chat.state import ChatDeps
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_config = MagicMock()
|
||||
|
||||
deps = ChatDeps(
|
||||
client=mock_client,
|
||||
config=mock_config,
|
||||
)
|
||||
|
||||
original_session_id = deps.session_state.session_id
|
||||
|
||||
# Update with incoming state
|
||||
deps.state = {"session_id": "updated-123", "qa_history": [], "citations": []}
|
||||
|
||||
assert deps.session_state.session_id == "updated-123"
|
||||
assert deps.session_state.session_id != original_session_id
|
||||
|
||||
|
||||
def test_chat_deps_state_setter_with_citation_dicts():
|
||||
"""Test ChatDeps.state setter converts citation dicts to Citation."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from haiku.rag.agents.chat.state import AGUI_STATE_KEY, ChatDeps, ChatSessionState
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_config = MagicMock()
|
||||
|
||||
session_state = ChatSessionState(session_id="test")
|
||||
deps = ChatDeps(
|
||||
client=mock_client,
|
||||
config=mock_config,
|
||||
session_state=session_state,
|
||||
state_key=AGUI_STATE_KEY,
|
||||
)
|
||||
|
||||
incoming_state = {
|
||||
AGUI_STATE_KEY: {
|
||||
"session_id": "test",
|
||||
"qa_history": [],
|
||||
"citations": [
|
||||
{
|
||||
"index": 1,
|
||||
"document_id": "doc-1",
|
||||
"chunk_id": "chunk-1",
|
||||
"document_uri": "test.md",
|
||||
"document_title": "Test Doc",
|
||||
"page_numbers": [1, 2],
|
||||
"headings": ["Intro"],
|
||||
"content": "Test content",
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
deps.state = incoming_state
|
||||
|
||||
assert deps.session_state is not None
|
||||
assert len(deps.session_state.citations) == 1
|
||||
citation = deps.session_state.citations[0]
|
||||
assert citation.document_id == "doc-1"
|
||||
assert citation.chunk_id == "chunk-1"
|
||||
assert citation.page_numbers == [1, 2]
|
||||
|
||||
|
||||
def test_chat_deps_state_getter_includes_session_context():
|
||||
"""Test ChatDeps.state getter includes session_context when present."""
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from haiku.rag.agents.chat.state import (
|
||||
AGUI_STATE_KEY,
|
||||
ChatDeps,
|
||||
ChatSessionState,
|
||||
SessionContext,
|
||||
)
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_config = MagicMock()
|
||||
|
||||
now = datetime.now()
|
||||
session_state = ChatSessionState(
|
||||
session_id="test-123",
|
||||
session_context=SessionContext(
|
||||
summary="User discussed authentication.",
|
||||
last_updated=now,
|
||||
),
|
||||
)
|
||||
|
||||
deps = ChatDeps(
|
||||
client=mock_client,
|
||||
config=mock_config,
|
||||
session_state=session_state,
|
||||
state_key=AGUI_STATE_KEY,
|
||||
)
|
||||
|
||||
state = deps.state
|
||||
assert state is not None
|
||||
assert AGUI_STATE_KEY in state
|
||||
assert state[AGUI_STATE_KEY]["session_context"] is not None
|
||||
assert (
|
||||
state[AGUI_STATE_KEY]["session_context"]["summary"]
|
||||
== "User discussed authentication."
|
||||
)
|
||||
|
||||
|
||||
def test_chat_deps_state_setter_ignores_session_context():
|
||||
"""Test ChatDeps.state setter ignores session_context from client.
|
||||
|
||||
The agent owns session_context via server-side cache, so client-provided
|
||||
session_context should be ignored to prevent stale state overwriting.
|
||||
"""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from haiku.rag.agents.chat.state import (
|
||||
AGUI_STATE_KEY,
|
||||
ChatDeps,
|
||||
ChatSessionState,
|
||||
SessionContext,
|
||||
)
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_config = MagicMock()
|
||||
|
||||
# Start with a session_context (e.g., from cache)
|
||||
session_state = ChatSessionState(
|
||||
session_id="test",
|
||||
session_context=SessionContext(summary="Server-side context"),
|
||||
)
|
||||
deps = ChatDeps(
|
||||
client=mock_client,
|
||||
config=mock_config,
|
||||
session_state=session_state,
|
||||
state_key=AGUI_STATE_KEY,
|
||||
)
|
||||
|
||||
# Client sends different session_context (stale)
|
||||
incoming_state = {
|
||||
AGUI_STATE_KEY: {
|
||||
"session_id": "test",
|
||||
"qa_history": [],
|
||||
"citations": [],
|
||||
"session_context": {
|
||||
"summary": "Client-provided stale context",
|
||||
"last_updated": "2025-01-15T10:30:00",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
deps.state = incoming_state
|
||||
|
||||
# session_context should NOT be overwritten
|
||||
assert deps.session_state is not None
|
||||
assert deps.session_state.session_context is not None
|
||||
assert deps.session_state.session_context.summary == "Server-side context"
|
||||
|
||||
|
||||
def test_citation_registry_index_assignment():
|
||||
"""Test get_or_assign_index basic index assignment behavior.
|
||||
|
||||
|
|
@ -395,8 +101,6 @@ def test_citation_registry_index_assignment():
|
|||
- Second unique chunk gets index 2
|
||||
- Same chunk_id always returns same index
|
||||
"""
|
||||
from haiku.rag.agents.chat.state import ChatSessionState
|
||||
|
||||
session_state = ChatSessionState(session_id="test")
|
||||
|
||||
# First chunk gets index 1
|
||||
|
|
@ -414,8 +118,6 @@ def test_citation_registry_index_assignment():
|
|||
|
||||
def test_citation_registry_stability():
|
||||
"""Test citation indices are stable across multiple calls in any order."""
|
||||
from haiku.rag.agents.chat.state import ChatSessionState
|
||||
|
||||
session_state = ChatSessionState(session_id="test")
|
||||
|
||||
# First round assigns indices 1, 2, 3
|
||||
|
|
@ -435,8 +137,6 @@ def test_citation_registry_stability():
|
|||
|
||||
def test_citation_registry_serialization_roundtrip():
|
||||
"""Test citation_registry serializes and deserializes correctly for AG-UI state."""
|
||||
from haiku.rag.agents.chat.state import ChatSessionState
|
||||
|
||||
# Create state and assign indices
|
||||
original = ChatSessionState(session_id="test")
|
||||
original.get_or_assign_index("chunk-a")
|
||||
|
|
@ -457,127 +157,6 @@ def test_citation_registry_serialization_roundtrip():
|
|||
assert restored.get_or_assign_index("chunk-c") == 3
|
||||
|
||||
|
||||
def test_chat_deps_state_getter_includes_citation_registry():
|
||||
"""Test ChatDeps.state getter includes citation_registry."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from haiku.rag.agents.chat.state import AGUI_STATE_KEY, ChatDeps, ChatSessionState
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_config = MagicMock()
|
||||
|
||||
session_state = ChatSessionState(session_id="test")
|
||||
session_state.get_or_assign_index("chunk-a")
|
||||
|
||||
deps = ChatDeps(
|
||||
client=mock_client,
|
||||
config=mock_config,
|
||||
session_state=session_state,
|
||||
state_key=AGUI_STATE_KEY,
|
||||
)
|
||||
|
||||
state = deps.state
|
||||
assert state is not None
|
||||
assert AGUI_STATE_KEY in state
|
||||
assert state[AGUI_STATE_KEY]["citation_registry"] == {"chunk-a": 1}
|
||||
|
||||
|
||||
def test_chat_deps_state_setter_restores_citation_registry():
|
||||
"""Test ChatDeps.state setter restores citation_registry from incoming state."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from haiku.rag.agents.chat.state import AGUI_STATE_KEY, ChatDeps, ChatSessionState
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_config = MagicMock()
|
||||
|
||||
session_state = ChatSessionState(session_id="test")
|
||||
deps = ChatDeps(
|
||||
client=mock_client,
|
||||
config=mock_config,
|
||||
session_state=session_state,
|
||||
state_key=AGUI_STATE_KEY,
|
||||
)
|
||||
|
||||
# Simulate incoming AG-UI state with citation_registry
|
||||
incoming_state = {
|
||||
AGUI_STATE_KEY: {
|
||||
"session_id": "test",
|
||||
"qa_history": [],
|
||||
"citations": [],
|
||||
"citation_registry": {"chunk-x": 1, "chunk-y": 2},
|
||||
}
|
||||
}
|
||||
|
||||
deps.state = incoming_state
|
||||
|
||||
assert deps.session_state is not None
|
||||
# Registry should be restored
|
||||
assert deps.session_state.get_or_assign_index("chunk-x") == 1
|
||||
assert deps.session_state.get_or_assign_index("chunk-y") == 2
|
||||
# New chunk gets next index
|
||||
assert deps.session_state.get_or_assign_index("chunk-z") == 3
|
||||
|
||||
|
||||
def test_chat_deps_state_setter_restores_document_filter():
|
||||
"""Test ChatDeps.state setter restores document_filter from incoming state."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from haiku.rag.agents.chat.state import AGUI_STATE_KEY, ChatDeps, ChatSessionState
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_config = MagicMock()
|
||||
|
||||
session_state = ChatSessionState(session_id="test")
|
||||
deps = ChatDeps(
|
||||
client=mock_client,
|
||||
config=mock_config,
|
||||
session_state=session_state,
|
||||
state_key=AGUI_STATE_KEY,
|
||||
)
|
||||
|
||||
incoming_state = {
|
||||
AGUI_STATE_KEY: {
|
||||
"session_id": "test",
|
||||
"qa_history": [],
|
||||
"citations": [],
|
||||
"document_filter": ["doc1.pdf", "doc2.pdf"],
|
||||
}
|
||||
}
|
||||
|
||||
deps.state = incoming_state
|
||||
|
||||
assert deps.session_state is not None
|
||||
assert deps.session_state.document_filter == ["doc1.pdf", "doc2.pdf"]
|
||||
|
||||
|
||||
def test_chat_deps_state_getter_includes_document_filter():
|
||||
"""Test ChatDeps.state getter includes document_filter."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from haiku.rag.agents.chat.state import AGUI_STATE_KEY, ChatDeps, ChatSessionState
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_config = MagicMock()
|
||||
|
||||
session_state = ChatSessionState(
|
||||
session_id="test-123",
|
||||
document_filter=["doc1.pdf", "doc2.pdf"],
|
||||
)
|
||||
|
||||
deps = ChatDeps(
|
||||
client=mock_client,
|
||||
config=mock_config,
|
||||
session_state=session_state,
|
||||
state_key=AGUI_STATE_KEY,
|
||||
)
|
||||
|
||||
state = deps.state
|
||||
assert state is not None
|
||||
assert AGUI_STATE_KEY in state
|
||||
assert state[AGUI_STATE_KEY]["document_filter"] == ["doc1.pdf", "doc2.pdf"]
|
||||
|
||||
|
||||
def test_chat_session_state_defaults_to_empty_session_id():
|
||||
"""New ChatSessionState should default to empty session_id.
|
||||
|
||||
|
|
@ -685,7 +264,7 @@ def test_emit_state_event_returns_delta_with_changes():
|
|||
current_state = ChatSessionState(session_id="test-123", qa_history=[], citations=[])
|
||||
new_state = ChatSessionState(
|
||||
session_id="test-123",
|
||||
qa_history=[QAResponse(question="Q1", answer="A1", confidence=0.9)],
|
||||
qa_history=[QAHistoryEntry(question="Q1", answer="A1", confidence=0.9)],
|
||||
citations=[],
|
||||
)
|
||||
|
||||
|
|
@ -709,7 +288,7 @@ def test_emit_state_event_delta_with_state_key():
|
|||
current_state = ChatSessionState(session_id="test-123", qa_history=[])
|
||||
new_state = ChatSessionState(
|
||||
session_id="test-123",
|
||||
qa_history=[QAResponse(question="Q1", answer="A1", confidence=0.9)],
|
||||
qa_history=[QAHistoryEntry(question="Q1", answer="A1", confidence=0.9)],
|
||||
)
|
||||
|
||||
event = emit_state_event(current_state, new_state, state_key=AGUI_STATE_KEY)
|
||||
|
|
@ -728,14 +307,14 @@ def test_emit_state_event_delta_produces_valid_patch():
|
|||
|
||||
current_state = ChatSessionState(
|
||||
session_id="test-123",
|
||||
qa_history=[QAResponse(question="Q1", answer="A1", confidence=0.9)],
|
||||
qa_history=[QAHistoryEntry(question="Q1", answer="A1", confidence=0.9)],
|
||||
citations=[],
|
||||
)
|
||||
new_state = ChatSessionState(
|
||||
session_id="test-123",
|
||||
qa_history=[
|
||||
QAResponse(question="Q1", answer="A1", confidence=0.9),
|
||||
QAResponse(question="Q2", answer="A2", confidence=0.8),
|
||||
QAHistoryEntry(question="Q1", answer="A1", confidence=0.9),
|
||||
QAHistoryEntry(question="Q2", answer="A2", confidence=0.8),
|
||||
],
|
||||
citations=[],
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue