Extract run_qa_core() from ask closure in QA toolset. Remove redundant tests and cassettes for OpenAI and Anthropic QA tools, as they are now tested in the core function tests.

This commit is contained in:
Yiorgis Gozadinos 2026-02-11 11:12:09 +02:00
parent 2cc4a8a88f
commit 87bcb7a5dc
No known key found for this signature in database
15 changed files with 609 additions and 3251 deletions

View file

@ -81,7 +81,6 @@ class ChatDeps:
if isinstance(nested, dict):
state_data = nested
# Update SessionState from incoming state
session_state = self.tool_context.get(SESSION_NAMESPACE, SessionState)
if session_state is not None:
if "document_filter" in state_data:
@ -96,7 +95,6 @@ class ChatDeps:
for c in state_data.get("citations", [])
]
# Extract session_id if present, or generate one
# Track what the client sent (for delta computation)
incoming_session_id = state_data.get("session_id", "")
@ -111,7 +109,6 @@ class ChatDeps:
session_state.session_id = self.session_id
session_state.incoming_session_id = incoming_session_id
# Update QASessionState from incoming state
qa_session_state = self.tool_context.get(QA_SESSION_NAMESPACE, QASessionState)
if qa_session_state is not None:
if "qa_history" in state_data:
@ -181,19 +178,16 @@ def create_chat_agent(
if features is None:
features = DEFAULT_FEATURES
# SessionState is always registered (shared by all features)
existing = context.get(SESSION_NAMESPACE, SessionState)
if existing is None:
context.register(SESSION_NAMESPACE, SessionState(state_key=AGUI_STATE_KEY))
elif existing.state_key is None:
existing.state_key = AGUI_STATE_KEY
# QASessionState only when QA feature is active
if FEATURE_QA in features:
if context.get(QA_SESSION_NAMESPACE, QASessionState) is None:
context.register(QA_SESSION_NAMESPACE, QASessionState())
# Create toolsets conditionally based on features
toolsets = []
if FEATURE_SEARCH in features:
toolsets.append(create_search_toolset(client, config, context=context))
@ -206,7 +200,6 @@ def create_chat_agent(
toolsets.append(create_analysis_toolset(client, config, context=context))
# Create the agent with composed toolsets
model = get_model(config.qa.model, config)
return Agent(

View file

@ -18,6 +18,7 @@ from haiku.rag.tools.qa import (
QAHistoryEntry,
QASessionState,
create_qa_toolset,
run_qa_core,
)
from haiku.rag.tools.search import SEARCH_NAMESPACE, SearchState, create_search_toolset
from haiku.rag.tools.session import (
@ -46,6 +47,7 @@ __all__ = [
"QASessionState",
"QAHistoryEntry",
"create_qa_toolset",
"run_qa_core",
"create_analysis_toolset",
"SESSION_NAMESPACE",
"SessionState",

View file

@ -52,16 +52,13 @@ def create_analysis_toolset(
Returns:
AnalysisResult with answer and execution metadata.
"""
# Build filter from base_filter, session_filter, and document_name
doc_filter = build_document_filter(document_name) if document_name else None
effective_filter = combine_filters(
get_session_filter(context, base_filter), doc_filter
)
# Create RLM context
rlm_context = RLMContext(filter=effective_filter)
# Run RLM agent with Docker sandbox
async with DockerSandbox(
client=client,
config=config.rlm,

View file

@ -40,7 +40,6 @@ class DocumentListResponse(BaseModel):
async def find_document(client: HaikuRAG, query: str):
"""Find a document by exact URI, partial URI, or partial title match."""
# Try exact URI match first
doc = await client.get_document_by_uri(query)
if doc is not None:
return doc
@ -49,7 +48,6 @@ async def find_document(client: HaikuRAG, query: str):
# Also try without spaces for matching "TB MED 593" to "tbmed593"
no_spaces = escaped_query.replace(" ", "")
# Try partial URI match (with and without spaces)
docs = await client.list_documents(
limit=1,
filter=f"LOWER(uri) LIKE LOWER('%{escaped_query}%') OR LOWER(uri) LIKE LOWER('%{no_spaces}%')",
@ -57,7 +55,6 @@ async def find_document(client: HaikuRAG, query: str):
if docs and docs[0].id:
return await client.get_document_by_id(docs[0].id)
# Try partial title match (with and without spaces)
docs = await client.list_documents(
limit=1,
filter=f"LOWER(title) LIKE LOWER('%{escaped_query}%') OR LOWER(title) LIKE LOWER('%{no_spaces}%')",
@ -158,7 +155,6 @@ def create_document_toolset(
if doc is None:
return f"Document not found: {query}"
# Use LLM to generate summary
summary_model = get_model(config.qa.model, config)
summary_agent: Agent[None, str] = Agent(
summary_model,

View file

@ -92,6 +92,149 @@ QA_SESSION_NAMESPACE = "haiku.rag.qa_session"
MAX_QA_HISTORY = 50
async def run_qa_core(
client: HaikuRAG,
config: AppConfig,
question: str,
document_name: str | None = None,
*,
context: ToolContext | None = None,
base_filter: str | None = None,
session_context: str | None = None,
prior_answers: list[SearchAnswer] | None = None,
) -> QAResult:
"""Run the QA flow and return a QAResult.
This is the core QA implementation shared by toolsets and client APIs.
It updates session state and QA history when context is provided.
"""
session_state: SessionState | None = None
qa_session_state: QASessionState | None = None
if context is not None:
session_state = context.get(SESSION_NAMESPACE, SessionState)
qa_session_state = context.get(QA_SESSION_NAMESPACE, QASessionState)
doc_filter = build_document_filter(document_name) if document_name else None
effective_filter = combine_filters(
get_session_filter(context, base_filter), doc_filter
)
effective_session_context = session_context
if qa_session_state is not None and qa_session_state.session_context:
effective_session_context = qa_session_state.session_context
effective_prior_answers = prior_answers or []
session_id = session_state.session_id if session_state is not None else ""
if qa_session_state is not None and qa_session_state.qa_history:
embedder = get_embedder(config)
question_embedding = await embedder.embed_query(question)
to_embed = []
to_embed_indices = []
for i, qa in enumerate(qa_session_state.qa_history):
if qa.question_embedding is None:
if session_id:
cached = get_cached_embedding(session_id, qa.question)
if cached:
qa.question_embedding = cached
continue
to_embed.append(qa.question)
to_embed_indices.append(i)
if to_embed:
new_embeddings = await embedder.embed_documents(to_embed)
for i, idx in enumerate(to_embed_indices):
embedding = new_embeddings[i]
qa_session_state.qa_history[idx].question_embedding = embedding
if session_id:
cache_question_embedding(
session_id,
qa_session_state.qa_history[idx].question,
embedding,
)
matched_answers = []
for qa in qa_session_state.qa_history:
if qa.question_embedding is not None:
similarity = _cosine_similarity(
question_embedding, qa.question_embedding
)
if similarity >= PRIOR_ANSWER_RELEVANCE_THRESHOLD:
matched_answers.append(qa.to_search_answer())
if matched_answers:
effective_prior_answers = matched_answers
graph = build_research_graph(config=config, output_mode="conversational")
research_context = ResearchContext(
original_question=question,
session_context=effective_session_context,
qa_responses=effective_prior_answers,
)
research_state = ResearchState(
context=research_context,
max_iterations=1,
search_filter=effective_filter,
max_concurrency=config.research.max_concurrency,
)
deps = ResearchDeps(client=client)
result = await graph.run(state=research_state, deps=deps)
# Build citations with stable indices from session state
citations = []
for i, c in enumerate(result.citations):
if session_state is not None:
index = session_state.get_or_assign_index(c.chunk_id)
else:
index = i + 1
citations.append(
Citation(
index=index,
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,
)
)
qa_result = QAResult(
question=question,
answer=result.answer,
confidence=result.confidence,
citations=citations,
)
if session_state is not None:
session_state.citations = citations
if qa_session_state is not None:
qa_session_state.qa_history.append(
QAHistoryEntry(
question=question,
answer=result.answer,
confidence=result.confidence,
citations=citations,
)
)
# Enforce FIFO limit
if len(qa_session_state.qa_history) > MAX_QA_HISTORY:
qa_session_state.qa_history = qa_session_state.qa_history[-MAX_QA_HISTORY:]
trigger_background_summarization(
qa_session_state=qa_session_state,
config=config,
session_id=session_id,
)
return qa_result
def create_qa_toolset(
client: HaikuRAG,
config: AppConfig,
@ -135,7 +278,6 @@ def create_qa_toolset(
Returns:
QAResult with answer, confidence, and citations.
"""
# Get session states
session_state: SessionState | None = None
qa_session_state: QASessionState | None = None
old_state_snapshot: dict | None = None
@ -144,7 +286,6 @@ def create_qa_toolset(
session_state = context.get(SESSION_NAMESPACE, SessionState)
qa_session_state = context.get(QA_SESSION_NAMESPACE, QASessionState)
# Capture combined state snapshot before changes
# Use incoming values (what client sent) so delta shows server-side updates
if session_state is not None:
old_state_snapshot = build_chat_state_snapshot(
@ -153,140 +294,18 @@ def create_qa_toolset(
incoming=True,
)
# Build filter from session state, base_filter, and document_name
doc_filter = build_document_filter(document_name) if document_name else None
effective_filter = combine_filters(
get_session_filter(context, base_filter), doc_filter
)
# Determine session context
effective_session_context = session_context
if qa_session_state is not None and qa_session_state.session_context:
effective_session_context = qa_session_state.session_context
# Find relevant prior answers via similarity matching
effective_prior_answers = prior_answers or []
session_id = session_state.session_id if session_state is not None else ""
if qa_session_state is not None and qa_session_state.qa_history:
embedder = get_embedder(config)
question_embedding = await embedder.embed_query(question)
# Collect questions that need embedding
to_embed = []
to_embed_indices = []
for i, qa in enumerate(qa_session_state.qa_history):
if qa.question_embedding is None:
# Check per-session cache first
if session_id:
cached = get_cached_embedding(session_id, qa.question)
if cached:
qa.question_embedding = cached
continue
to_embed.append(qa.question)
to_embed_indices.append(i)
# Batch embed uncached questions
if to_embed:
new_embeddings = await embedder.embed_documents(to_embed)
for i, idx in enumerate(to_embed_indices):
embedding = new_embeddings[i]
qa_session_state.qa_history[idx].question_embedding = embedding
# Cache per-session for next request
if session_id:
cache_question_embedding(
session_id,
qa_session_state.qa_history[idx].question,
embedding,
)
# Find similar prior answers
matched_answers = []
for qa in qa_session_state.qa_history:
if qa.question_embedding is not None:
similarity = _cosine_similarity(
question_embedding, qa.question_embedding
)
if similarity >= PRIOR_ANSWER_RELEVANCE_THRESHOLD:
matched_answers.append(qa.to_search_answer())
if matched_answers:
effective_prior_answers = matched_answers
# Build and run the research graph
graph = build_research_graph(config=config, output_mode="conversational")
research_context = ResearchContext(
original_question=question,
session_context=effective_session_context,
qa_responses=effective_prior_answers,
)
research_state = ResearchState(
context=research_context,
max_iterations=1,
search_filter=effective_filter,
max_concurrency=config.research.max_concurrency,
)
deps = ResearchDeps(client=client)
result = await graph.run(state=research_state, deps=deps)
# Build citations with stable indices from session state
citations = []
for i, c in enumerate(result.citations):
if session_state is not None:
index = session_state.get_or_assign_index(c.chunk_id)
else:
index = i + 1
citations.append(
Citation(
index=index,
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,
)
)
qa_result = QAResult(
qa_result = await run_qa_core(
client=client,
config=config,
question=question,
answer=result.answer,
confidence=result.confidence,
citations=citations,
document_name=document_name,
context=context,
base_filter=base_filter,
session_context=session_context,
prior_answers=prior_answers,
)
# Update session state with citations
if session_state is not None:
session_state.citations = citations
# Update QA session state with history entry
if qa_session_state is not None:
qa_session_state.qa_history.append(
QAHistoryEntry(
question=question,
answer=result.answer,
confidence=result.confidence,
citations=citations,
)
)
# Enforce FIFO limit
if len(qa_session_state.qa_history) > MAX_QA_HISTORY:
qa_session_state.qa_history = qa_session_state.qa_history[
-MAX_QA_HISTORY:
]
# Trigger background summarization
trigger_background_summarization(
qa_session_state=qa_session_state,
config=config,
session_id=session_id,
)
# Compute and return state delta if session state changed
if session_state is not None and old_state_snapshot is not None:
# Build new combined state snapshot
new_state_snapshot = build_chat_state_snapshot(
session_state,
qa_session_state,
@ -299,10 +318,9 @@ def create_qa_toolset(
state_key=session_state.state_key,
)
# Format answer with citation references
answer_text = result.answer
if citations:
citation_refs = " ".join(f"[{c.index}]" for c in citations)
answer_text = qa_result.answer
if qa_result.citations:
citation_refs = " ".join(f"[{c.index}]" for c in qa_result.citations)
answer_text = f"{answer_text}\n\nSources: {citation_refs}"
metadata = [state_event] if state_event is not None else None

View file

@ -47,7 +47,6 @@ def create_search_toolset(
Returns:
FunctionToolset with a search tool.
"""
# Get or create search state if context provided
search_state: SearchState | None = None
if context is not None:
search_state = context.get_or_create(SEARCH_NAMESPACE, SearchState)
@ -67,7 +66,6 @@ def create_search_toolset(
Returns:
Formatted search results with content and metadata.
"""
# Get session state for dynamic filters and citation indexing
session_state: SessionState | None = None
old_session_state: SessionState | None = None
if context is not None:
@ -88,14 +86,12 @@ def create_search_toolset(
if expand_context:
results = await client.expand_context(results)
# Accumulate results in search state if context provided
if search_state is not None:
search_state.results.extend(results)
if not results:
return "No results found."
# Build citations if session state is available
if session_state is not None:
citations = []
for r in results:
@ -118,7 +114,6 @@ def create_search_toolset(
)
session_state.citations = citations
# Format results with citation indices
result_lines = []
for c in citations:
title = c.document_title or c.document_uri or "Unknown"
@ -134,7 +129,6 @@ def create_search_toolset(
formatted = f"Found {len(results)} results:\n\n" + "\n\n".join(result_lines)
# Compute state delta if session state changed
if old_session_state is not None:
state_event = compute_state_delta(old_session_state, session_state)
if state_event is not None:

View file

@ -11,7 +11,10 @@ from haiku.rag.agents.chat import (
ToolContext,
create_chat_agent,
)
from haiku.rag.agents.chat.context import get_cached_session_context
from haiku.rag.agents.chat.context import (
_summarization_tasks,
get_cached_session_context,
)
from haiku.rag.agents.research.models import Citation
from haiku.rag.client import HaikuRAG
from haiku.rag.config import Config
@ -563,8 +566,12 @@ async def test_chat_agent_multi_turn_with_context(allow_model_requests, temp_db_
2. First question triggers background summarization
3. Second related question uses prior answer recall and updated session context
4. Both qa_history entries are present after two turns
The ask tool internally fires background summarization (concurrent HTTP calls)
which causes VCR cassette mismatches. We patch it to a no-op and trigger
summarization explicitly after each turn to keep HTTP ordering deterministic.
"""
import asyncio
from unittest.mock import patch
from haiku.rag.agents.chat.agent import trigger_background_summarization
from haiku.rag.tools.qa import QA_SESSION_NAMESPACE, QASessionState
@ -614,22 +621,24 @@ async def test_chat_agent_multi_turn_with_context(allow_model_requests, temp_db_
== "The user is researching the DocLayNet dataset for a paper on document layout analysis."
)
# First question about class labels
result1 = await agent.run(
"What are the class labels defined in DocLayNet?",
deps=deps,
)
trigger_background_summarization(deps)
# Patch the internal summarization trigger in the ask tool to avoid
# concurrent HTTP calls that break VCR cassette replay ordering.
with patch(
"haiku.rag.tools.qa.trigger_background_summarization",
):
# First question about class labels
result1 = await agent.run(
"What are the class labels defined in DocLayNet?",
deps=deps,
)
assert result1.output is not None
# Wait for background summarization
cached_context = None
for _ in range(50):
cached_context = get_cached_session_context(session_id)
if cached_context is not None:
break
await asyncio.sleep(0.1)
# Trigger summarization explicitly (sequential, no concurrency)
trigger_background_summarization(deps)
if session_id in _summarization_tasks:
await _summarization_tasks[session_id]
cached_context = get_cached_session_context(session_id)
assert cached_context is not None
assert cached_context.summary != ""
@ -639,23 +648,20 @@ async def test_chat_agent_multi_turn_with_context(allow_model_requests, temp_db_
assert len(qa_session.qa_history) >= 1
# Second related question - uses prior answers and updated session context
result2 = await agent.run(
"How were the annotations created and how many annotators were involved?",
deps=deps,
message_history=result1.all_messages(),
)
trigger_background_summarization(deps)
with patch(
"haiku.rag.tools.qa.trigger_background_summarization",
):
result2 = await agent.run(
"How were the annotations created and how many annotators were involved?",
deps=deps,
message_history=result1.all_messages(),
)
assert result2.output is not None
# Wait for updated summarization
for _ in range(50):
updated = get_cached_session_context(session_id)
if (
updated is not None
and updated.last_updated != cached_context.last_updated
):
break
await asyncio.sleep(0.1)
# Trigger summarization explicitly
trigger_background_summarization(deps)
if session_id in _summarization_tasks:
await _summarization_tasks[session_id]
# qa_history should have two entries
qa_session = context.get(QA_SESSION_NAMESPACE, QASessionState)

View file

@ -1,4 +1,3 @@
import importlib.util
from pathlib import Path
import pytest
@ -10,8 +9,6 @@ from haiku.rag.client import HaikuRAG
from haiku.rag.config import Config
from haiku.rag.config.models import ModelConfig
HAS_ANTHROPIC = importlib.util.find_spec("anthropic") is not None
@pytest.fixture(scope="module")
def vcr_cassette_dir():
@ -73,52 +70,3 @@ async def test_qa_ollama(allow_model_requests, qa_corpus: Dataset, temp_db_path)
assert is_equivalent, (
f"Generated answer not equivalent to expected answer.\nQuestion: {question}\nGenerated: {answer}\nExpected: {expected_answer}"
)
@pytest.mark.vcr()
async def test_qa_openai(allow_model_requests, qa_corpus: Dataset, temp_db_path):
"""Test OpenAI QA with LLM judge (VCR recorded)."""
client = HaikuRAG(temp_db_path, create=True)
qa = QuestionAnswerAgent(client, ModelConfig(provider="openai", name="gpt-4o-mini"))
llm_judge = LLMJudge()
doc = qa_corpus[1]
await client.create_document(
content=doc["document_extracted"], uri=doc["document_id"]
)
question = doc["question"]
expected_answer = doc["answer"]
answer, _ = await qa.answer(question)
is_equivalent = await llm_judge.judge_answers(question, answer, expected_answer)
assert is_equivalent, (
f"Generated answer not equivalent to expected answer.\nQuestion: {question}\nGenerated: {answer}\nExpected: {expected_answer}"
)
@pytest.mark.vcr()
@pytest.mark.skipif(not HAS_ANTHROPIC, reason="Anthropic not installed")
async def test_qa_anthropic(allow_model_requests, qa_corpus: Dataset, temp_db_path):
"""Test Anthropic QA with LLM judge (VCR recorded)."""
client = HaikuRAG(temp_db_path, create=True)
qa = QuestionAnswerAgent(
client, ModelConfig(provider="anthropic", name="claude-3-5-haiku-20241022")
)
llm_judge = LLMJudge()
doc = qa_corpus[1]
await client.create_document(
content=doc["document_extracted"], uri=doc["document_id"]
)
question = doc["question"]
expected_answer = doc["answer"]
answer, _ = await qa.answer(question)
is_equivalent = await llm_judge.judge_answers(question, answer, expected_answer)
assert is_equivalent, (
f"Generated answer not equivalent to expected answer.\nQuestion: {question}\nGenerated: {answer}\nExpected: {expected_answer}"
)

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

View file

@ -63,88 +63,6 @@ async def test_ollama_embedder(allow_model_requests):
assert max(sims) == sims[1]
@pytest.mark.vcr()
async def test_openai_embedder(allow_model_requests):
"""Test OpenAI embedder via pydantic-ai."""
config = AppConfig(
embeddings=EmbeddingsConfig(
model=EmbeddingModelConfig(
provider="openai", name="text-embedding-3-small", vector_dim=1536
)
)
)
embedder = get_embedder(config)
phrases = [
"I enjoy eating great food.",
"Python is my favorite programming language.",
"I love to travel and see new places.",
]
# Test batch embedding (documents)
embeddings = await embedder.embed_documents(phrases)
assert isinstance(embeddings, list)
assert len(embeddings) == 3
assert all(isinstance(emb, list) for emb in embeddings)
embeddings = [np.array(emb) for emb in embeddings]
# Test query embedding
test_phrase = "I am going for a camping trip."
test_embedding = await embedder.embed_query(test_phrase)
sims = similarities(embeddings, test_embedding)
assert max(sims) == sims[2]
test_phrase = "When is dinner ready?"
test_embedding = await embedder.embed_query(test_phrase)
sims = similarities(embeddings, test_embedding)
assert max(sims) == sims[0]
test_phrase = "I work as a software developer."
test_embedding = await embedder.embed_query(test_phrase)
sims = similarities(embeddings, test_embedding)
assert max(sims) == sims[1]
@pytest.mark.vcr()
async def test_voyageai_embedder(allow_model_requests):
"""Test VoyageAI embedder."""
config = AppConfig(
embeddings=EmbeddingsConfig(
model=EmbeddingModelConfig(
provider="voyageai", name="voyage-3.5", vector_dim=1024
)
)
)
embedder = get_embedder(config)
phrases = [
"I enjoy eating great food.",
"Python is my favorite programming language.",
"I love to travel and see new places.",
]
# Test batch embedding (documents)
embeddings = await embedder.embed_documents(phrases)
assert isinstance(embeddings, list)
assert len(embeddings) == 3
assert all(isinstance(emb, list) for emb in embeddings)
embeddings = [np.array(emb) for emb in embeddings]
# Test query embedding
test_phrase = "I am going for a camping trip."
test_embedding = await embedder.embed_query(test_phrase)
sims = similarities(embeddings, test_embedding)
assert max(sims) == sims[2]
test_phrase = "When is dinner ready?"
test_embedding = await embedder.embed_query(test_phrase)
sims = similarities(embeddings, test_embedding)
assert max(sims) == sims[0]
test_phrase = "I work as a software developer."
test_embedding = await embedder.embed_query(test_phrase)
sims = similarities(embeddings, test_embedding)
assert max(sims) == sims[1]
def test_contextualize_with_headings():
"""Test that contextualize prepends headings to chunk content."""
chunks = [

View file

@ -28,21 +28,6 @@ def test_ollama_embedder_uses_config():
assert embedder._vector_dim == 512
def test_openai_embedder_uses_config():
"""Test that OpenAI embedder uses the config passed to get_embedder."""
custom_config = AppConfig(
embeddings=EmbeddingsConfig(
model=EmbeddingModelConfig(
provider="openai", name="text-embedding-3-large", vector_dim=3072
),
),
)
embedder = get_embedder(custom_config)
assert embedder._vector_dim == 3072
def test_openai_embedder_with_base_url():
"""Test that OpenAI embedder uses custom base_url for vLLM/LM Studio."""
custom_config = AppConfig(
@ -61,21 +46,6 @@ def test_openai_embedder_with_base_url():
assert embedder._vector_dim == 768
def test_cohere_embedder_uses_config():
"""Test that Cohere embedder uses the config passed to get_embedder."""
custom_config = AppConfig(
embeddings=EmbeddingsConfig(
model=EmbeddingModelConfig(
provider="cohere", name="embed-v4.0", vector_dim=1024
),
),
)
embedder = get_embedder(custom_config)
assert embedder._vector_dim == 1024
def test_sentence_transformers_embedder_uses_config():
"""Test that SentenceTransformers embedder uses the config."""
custom_config = AppConfig(