Remove qa_history ranking in favor of SessionContext, planner can skip searching if context adequate
This commit is contained in:
parent
3b7b7c1bbc
commit
74eac57ffa
11 changed files with 39 additions and 2161 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -16,6 +16,7 @@
|
||||||
- Research graph receives compact context (~1,000-2,000 tokens) instead of raw qa_history (potentially thousands of tokens)
|
- Research graph receives compact context (~1,000-2,000 tokens) instead of raw qa_history (potentially thousands of tokens)
|
||||||
- New `session_context` field on `ChatSessionState` synced via AG-UI state protocol
|
- New `session_context` field on `ChatSessionState` synced via AG-UI state protocol
|
||||||
- Chat TUI: New context modal (`Ctrl+O`) to view current session context
|
- Chat TUI: New context modal (`Ctrl+O`) to view current session context
|
||||||
|
- Planner can now return empty sub_questions when context is sufficient to answer directly
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|
@ -28,6 +29,13 @@
|
||||||
- Removed `haiku.rag.embeddings.voyageai` module
|
- Removed `haiku.rag.embeddings.voyageai` module
|
||||||
- The `voyageai` extra now delegates to `pydantic-ai-slim[voyageai]`
|
- The `voyageai` extra now delegates to `pydantic-ai-slim[voyageai]`
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **Q&A History Functions**: Removed unused conversation history utilities
|
||||||
|
- `rank_qa_history_by_similarity()` - chat agent now uses `SessionContext` instead of ranked Q&A pairs
|
||||||
|
- `format_conversation_context()` - no longer needed with SessionContext approach
|
||||||
|
- Associated embedding cache and helper functions also removed
|
||||||
|
|
||||||
## [0.26.9] - 2026-01-22
|
## [0.26.9] - 2026-01-22
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
@ -35,6 +43,9 @@
|
||||||
- **v0.25.0 Migration Failure**: Fixed "Table 'documents' already exists" error during migration caused by held table references preventing `drop_table()` from succeeding. Added recovery logic to restore documents from staging table if a previous migration attempt failed mid-way.
|
- **v0.25.0 Migration Failure**: Fixed "Table 'documents' already exists" error during migration caused by held table references preventing `drop_table()` from succeeding. Added recovery logic to restore documents from staging table if a previous migration attempt failed mid-way.
|
||||||
|
|
||||||
## [0.26.8] - 2026-01-22
|
## [0.26.8] - 2026-01-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
- **Jina Reranker v3**: Added support for Jina reranking with API mode (`provider: jina`) and local inference (`provider: jina-local`, requires `[jina]` extra)
|
- **Jina Reranker v3**: Added support for Jina reranking with API mode (`provider: jina`) and local inference (`provider: jina-local`, requires `[jina]` extra)
|
||||||
- **Model Downloads**: `download-models` now pre-downloads HuggingFace models for `sentence-transformers`, `mxbai`, and `jina-local`
|
- **Model Downloads**: `download-models` now pre-downloads HuggingFace models for `sentence-transformers`, `mxbai`, and `jina-local`
|
||||||
- **Reranker Factory**: Removed unreliable `id(config)`-based caching from `get_reranker()`; factory now always instantiates fresh
|
- **Reranker Factory**: Removed unreliable `id(config)`-based caching from `get_reranker()`; factory now always instantiates fresh
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ from haiku.rag.agents.chat.state import (
|
||||||
SearchDeps,
|
SearchDeps,
|
||||||
SessionContext,
|
SessionContext,
|
||||||
build_document_filter,
|
build_document_filter,
|
||||||
format_conversation_context,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|
@ -24,7 +23,6 @@ __all__ = [
|
||||||
"SearchDeps",
|
"SearchDeps",
|
||||||
"SessionContext",
|
"SessionContext",
|
||||||
"build_document_filter",
|
"build_document_filter",
|
||||||
"format_conversation_context",
|
|
||||||
"summarize_session",
|
"summarize_session",
|
||||||
"update_session_context",
|
"update_session_context",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,9 @@ from haiku.rag.agents.chat.state import (
|
||||||
CitationInfo,
|
CitationInfo,
|
||||||
QAResponse,
|
QAResponse,
|
||||||
build_document_filter,
|
build_document_filter,
|
||||||
rank_qa_history_by_similarity,
|
|
||||||
)
|
)
|
||||||
from haiku.rag.agents.research.dependencies import ResearchContext
|
from haiku.rag.agents.research.dependencies import ResearchContext
|
||||||
from haiku.rag.agents.research.graph import build_conversational_graph
|
from haiku.rag.agents.research.graph import build_conversational_graph
|
||||||
from haiku.rag.agents.research.models import Citation, SearchAnswer
|
|
||||||
from haiku.rag.agents.research.state import ResearchDeps, ResearchState
|
from haiku.rag.agents.research.state import ResearchDeps, ResearchState
|
||||||
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
|
||||||
|
|
@ -181,61 +179,19 @@ def create_chat_agent(config: AppConfig) -> Agent[ChatDeps, str]:
|
||||||
# Build filter from document_name
|
# Build filter from document_name
|
||||||
doc_filter = build_document_filter(document_name) if document_name else None
|
doc_filter = build_document_filter(document_name) if document_name else None
|
||||||
|
|
||||||
# Filter and rank qa_history
|
|
||||||
ranked_history: list[QAResponse] = []
|
|
||||||
if ctx.deps.session_state and ctx.deps.session_state.qa_history:
|
|
||||||
# Step 1: Filter out low-confidence responses
|
|
||||||
filtered_history = [
|
|
||||||
qa for qa in ctx.deps.session_state.qa_history if qa.confidence >= 0.3
|
|
||||||
]
|
|
||||||
|
|
||||||
# Step 2: Rank filtered history by similarity to current question
|
|
||||||
embedder = ctx.deps.client.chunk_repository.embedder
|
|
||||||
ranked_history = await rank_qa_history_by_similarity(
|
|
||||||
current_question=question,
|
|
||||||
qa_history=filtered_history,
|
|
||||||
embedder=embedder,
|
|
||||||
top_k=5,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Convert ranked qa_history to SearchAnswers for context seeding
|
|
||||||
existing_qa: list[SearchAnswer] = []
|
|
||||||
if ranked_history:
|
|
||||||
for qa in ranked_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
|
# Build and run the conversational research graph
|
||||||
graph = build_conversational_graph(config=ctx.deps.config)
|
graph = build_conversational_graph(config=ctx.deps.config)
|
||||||
|
|
||||||
# Determine background context:
|
# Determine context strategy:
|
||||||
# 1. Use session_context summary if available (compressed history)
|
# 1. If session_context exists, use compressed summary (skip raw qa_history)
|
||||||
# 2. Fall back to explicit background_context if set
|
# 2. Otherwise, fall back to explicit background_context
|
||||||
background_context: str | None = None
|
background_context: str | None = None
|
||||||
if ctx.deps.session_state:
|
if ctx.deps.session_state:
|
||||||
if (
|
if (
|
||||||
ctx.deps.session_state.session_context
|
ctx.deps.session_state.session_context
|
||||||
and ctx.deps.session_state.session_context.summary
|
and ctx.deps.session_state.session_context.summary
|
||||||
):
|
):
|
||||||
|
# Use compressed SessionContext - no need for raw qa_history
|
||||||
background_context = (
|
background_context = (
|
||||||
ctx.deps.session_state.session_context.render_markdown()
|
ctx.deps.session_state.session_context.render_markdown()
|
||||||
)
|
)
|
||||||
|
|
@ -244,7 +200,6 @@ def create_chat_agent(config: AppConfig) -> Agent[ChatDeps, str]:
|
||||||
|
|
||||||
context = ResearchContext(
|
context = ResearchContext(
|
||||||
original_question=question,
|
original_question=question,
|
||||||
qa_responses=existing_qa,
|
|
||||||
background_context=background_context,
|
background_context=background_context,
|
||||||
)
|
)
|
||||||
state = ResearchState(
|
state = ResearchState(
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,17 @@
|
||||||
import hashlib
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
from numpy.typing import NDArray
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from pydantic_ai import format_as_xml
|
|
||||||
|
|
||||||
from haiku.rag.client import HaikuRAG
|
from haiku.rag.client import HaikuRAG
|
||||||
from haiku.rag.config.models import AppConfig
|
from haiku.rag.config.models import AppConfig
|
||||||
from haiku.rag.store.models import SearchResult
|
from haiku.rag.store.models import SearchResult
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from haiku.rag.embeddings import EmbedderWrapper
|
|
||||||
|
|
||||||
MAX_QA_HISTORY = 50
|
MAX_QA_HISTORY = 50
|
||||||
|
|
||||||
AGUI_STATE_KEY = "haiku.rag.chat"
|
AGUI_STATE_KEY = "haiku.rag.chat"
|
||||||
|
|
||||||
_embedding_cache: dict[str, list[float]] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _qa_cache_key(question: str, answer: str) -> str:
|
|
||||||
"""Generate cache key from Q/A content."""
|
|
||||||
return hashlib.sha256(f"Q: {question}\nA: {answer}".encode()).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
class CitationInfo(BaseModel):
|
class CitationInfo(BaseModel):
|
||||||
"""Citation info for frontend display."""
|
"""Citation info for frontend display."""
|
||||||
|
|
@ -64,9 +50,7 @@ class SessionContext(BaseModel):
|
||||||
|
|
||||||
def render_markdown(self) -> str:
|
def render_markdown(self) -> str:
|
||||||
"""Render context for injection into research graph."""
|
"""Render context for injection into research graph."""
|
||||||
if not self.summary:
|
return self.summary
|
||||||
return ""
|
|
||||||
return f"## Prior Conversation Context\n\n{self.summary}"
|
|
||||||
|
|
||||||
|
|
||||||
class ChatSessionState(BaseModel):
|
class ChatSessionState(BaseModel):
|
||||||
|
|
@ -79,96 +63,6 @@ class ChatSessionState(BaseModel):
|
||||||
session_context: SessionContext | None = None
|
session_context: SessionContext | None = None
|
||||||
|
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
def _cosine_similarity(a: NDArray[np.float64], b: NDArray[np.float64]) -> float:
|
|
||||||
"""Compute cosine similarity between two vectors."""
|
|
||||||
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
|
|
||||||
|
|
||||||
|
|
||||||
async def rank_qa_history_by_similarity(
|
|
||||||
current_question: str,
|
|
||||||
qa_history: list[QAResponse],
|
|
||||||
embedder: "EmbedderWrapper",
|
|
||||||
top_k: int = 5,
|
|
||||||
) -> list[QAResponse]:
|
|
||||||
"""Rank Q&A history by semantic similarity to current question.
|
|
||||||
|
|
||||||
Embeds question+answer pairs and returns the top-K most similar to the
|
|
||||||
current question. Falls back to returning the last top_k entries if
|
|
||||||
embedding fails.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
current_question: The current question to compare against.
|
|
||||||
qa_history: List of previous Q&A pairs.
|
|
||||||
embedder: Embedder instance to use for embedding.
|
|
||||||
top_k: Maximum number of entries to return.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Top-K Q&A pairs ranked by similarity to current question.
|
|
||||||
"""
|
|
||||||
if not qa_history:
|
|
||||||
return []
|
|
||||||
|
|
||||||
if len(qa_history) <= top_k:
|
|
||||||
return qa_history
|
|
||||||
|
|
||||||
# Embed current question
|
|
||||||
question_embedding = np.array(await embedder.embed_query(current_question))
|
|
||||||
|
|
||||||
# Check cache and collect uncached entries
|
|
||||||
qa_embeddings: list[list[float]] = []
|
|
||||||
uncached_indices: list[int] = []
|
|
||||||
uncached_texts: list[str] = []
|
|
||||||
|
|
||||||
for i, qa in enumerate(qa_history):
|
|
||||||
cache_key = _qa_cache_key(qa.question, qa.answer)
|
|
||||||
if cache_key in _embedding_cache:
|
|
||||||
qa_embeddings.append(_embedding_cache[cache_key])
|
|
||||||
else:
|
|
||||||
qa_embeddings.append([]) # placeholder
|
|
||||||
uncached_indices.append(i)
|
|
||||||
uncached_texts.append(f"Q: {qa.question}\nA: {qa.answer}")
|
|
||||||
|
|
||||||
# Embed only uncached entries
|
|
||||||
if uncached_texts:
|
|
||||||
new_embeddings = await embedder.embed_documents(uncached_texts)
|
|
||||||
for idx, embedding in zip(uncached_indices, new_embeddings):
|
|
||||||
qa = qa_history[idx]
|
|
||||||
cache_key = _qa_cache_key(qa.question, qa.answer)
|
|
||||||
_embedding_cache[cache_key] = embedding
|
|
||||||
qa_embeddings[idx] = embedding
|
|
||||||
|
|
||||||
# Compute similarities
|
|
||||||
similarities: list[tuple[int, float]] = []
|
|
||||||
for i, qa_emb in enumerate(qa_embeddings):
|
|
||||||
sim = _cosine_similarity(question_embedding, np.array(qa_emb))
|
|
||||||
similarities.append((i, sim))
|
|
||||||
|
|
||||||
# Sort by similarity (descending) and take top-K
|
|
||||||
similarities.sort(key=lambda x: x[1], reverse=True)
|
|
||||||
top_indices = sorted([idx for idx, _ in similarities[:top_k]])
|
|
||||||
|
|
||||||
# Return in original order
|
|
||||||
return [qa_history[i] for i in top_indices]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ChatDeps:
|
class ChatDeps:
|
||||||
"""Dependencies for chat agent.
|
"""Dependencies for chat agent.
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,6 @@ class ResearchPlan(BaseModel):
|
||||||
@field_validator("sub_questions")
|
@field_validator("sub_questions")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_sub_questions(cls, v: list[str]) -> list[str]:
|
def validate_sub_questions(cls, v: list[str]) -> list[str]:
|
||||||
if len(v) < 1:
|
|
||||||
raise ValueError("Must have at least 1 sub-question")
|
|
||||||
if len(v) > 12:
|
if len(v) > 12:
|
||||||
raise ValueError("Cannot have more than 12 sub-questions")
|
raise ValueError("Cannot have more than 12 sub-questions")
|
||||||
return v
|
return v
|
||||||
|
|
|
||||||
|
|
@ -201,75 +201,6 @@ Scanned documents were excluded to avoid rotation and skewing issues.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.vcr()
|
|
||||||
async def test_chat_agent_with_qa_history_ranking(allow_model_requests, temp_db_path):
|
|
||||||
"""Test chat agent uses similarity ranking for qa_history.
|
|
||||||
|
|
||||||
This test verifies that when qa_history has more than 5 entries,
|
|
||||||
the ranking function is applied and the agent can still process requests.
|
|
||||||
"""
|
|
||||||
async with HaikuRAG(temp_db_path, create=True) as client:
|
|
||||||
# Add a simple document
|
|
||||||
await client.create_document(
|
|
||||||
content=DOCLAYNET_CLASS_LABELS,
|
|
||||||
uri="doclaynet-labels",
|
|
||||||
title="DocLayNet Class Labels",
|
|
||||||
)
|
|
||||||
|
|
||||||
agent = create_chat_agent(Config)
|
|
||||||
|
|
||||||
# Build session state with pre-populated qa_history (>5 items to trigger ranking)
|
|
||||||
session_state = ChatSessionState(
|
|
||||||
session_id="test-ranking",
|
|
||||||
qa_history=[
|
|
||||||
QAResponse(
|
|
||||||
question="What are the 11 class labels in DocLayNet?",
|
|
||||||
answer="The 11 class labels are: Caption, Footnote, Formula, List-item, Page-footer, Page-header, Picture, Section-header, Table, Text, and Title.",
|
|
||||||
),
|
|
||||||
QAResponse(
|
|
||||||
question="How was the annotation process organized?",
|
|
||||||
answer="The annotation was organized into 4 phases.",
|
|
||||||
),
|
|
||||||
QAResponse(
|
|
||||||
question="What data sources were used?",
|
|
||||||
answer="Sources include arXiv and government offices.",
|
|
||||||
),
|
|
||||||
QAResponse(
|
|
||||||
question="How were pages selected?",
|
|
||||||
answer="By selective subsampling.",
|
|
||||||
),
|
|
||||||
QAResponse(
|
|
||||||
question="What is the agreement metric?",
|
|
||||||
answer="The mAP metric was used.",
|
|
||||||
),
|
|
||||||
QAResponse(
|
|
||||||
question="What is machine learning?",
|
|
||||||
answer="A field of AI.",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
deps = ChatDeps(
|
|
||||||
client=client,
|
|
||||||
config=Config,
|
|
||||||
session_state=session_state,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ask a question - the key test is that ranking is applied without error
|
|
||||||
result = await agent.run(
|
|
||||||
"What class labels are defined in the dataset?",
|
|
||||||
deps=deps,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify the agent produced a response (ranking didn't break anything)
|
|
||||||
assert result.output is not None
|
|
||||||
assert len(result.output) > 0
|
|
||||||
|
|
||||||
# Verify qa_history was updated (new Q&A was added)
|
|
||||||
assert len(session_state.qa_history) == 7 # 6 original + 1 new
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.vcr()
|
@pytest.mark.vcr()
|
||||||
async def test_chat_agent_search_tool(allow_model_requests, temp_db_path):
|
async def test_chat_agent_search_tool(allow_model_requests, temp_db_path):
|
||||||
|
|
|
||||||
|
|
@ -41,14 +41,10 @@ class TestSessionContext:
|
||||||
assert ctx.render_markdown() == ""
|
assert ctx.render_markdown() == ""
|
||||||
|
|
||||||
def test_render_markdown_with_summary(self):
|
def test_render_markdown_with_summary(self):
|
||||||
"""Test render_markdown returns formatted markdown."""
|
"""Test render_markdown returns the summary directly."""
|
||||||
ctx = SessionContext(
|
|
||||||
summary = "## Key Facts\n- Authentication uses JWT\n- Rate limit is 100/min"
|
summary = "## Key Facts\n- Authentication uses JWT\n- Rate limit is 100/min"
|
||||||
)
|
ctx = SessionContext(summary=summary)
|
||||||
result = ctx.render_markdown()
|
assert ctx.render_markdown() == summary
|
||||||
assert "## Prior Conversation Context" in result
|
|
||||||
assert "Authentication uses JWT" in result
|
|
||||||
assert "Rate limit is 100/min" in result
|
|
||||||
|
|
||||||
def test_session_context_serialization_roundtrip(self):
|
def test_session_context_serialization_roundtrip(self):
|
||||||
"""Test SessionContext serializes and deserializes correctly."""
|
"""Test SessionContext serializes and deserializes correctly."""
|
||||||
|
|
|
||||||
|
|
@ -1,224 +1,8 @@
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from haiku.rag.agents.chat.state import (
|
from haiku.rag.agents.chat.state import (
|
||||||
MAX_QA_HISTORY,
|
MAX_QA_HISTORY,
|
||||||
CitationInfo,
|
|
||||||
QAResponse,
|
QAResponse,
|
||||||
_embedding_cache,
|
|
||||||
_qa_cache_key,
|
|
||||||
build_document_filter,
|
build_document_filter,
|
||||||
format_conversation_context,
|
|
||||||
rank_qa_history_by_similarity,
|
|
||||||
)
|
)
|
||||||
from haiku.rag.client import HaikuRAG
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def vcr_cassette_dir():
|
|
||||||
return str(Path(__file__).parent.parent.parent / "cassettes" / "test_chat_state")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_rank_qa_history_empty():
|
|
||||||
"""Test empty history returns empty list."""
|
|
||||||
# Create a mock embedder - we won't actually call it
|
|
||||||
result = await rank_qa_history_by_similarity(
|
|
||||||
current_question="What is this?",
|
|
||||||
qa_history=[],
|
|
||||||
embedder=None, # type: ignore - won't be called for empty list
|
|
||||||
top_k=5,
|
|
||||||
)
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.vcr()
|
|
||||||
async def test_rank_qa_history_small_list(temp_db_path, allow_model_requests):
|
|
||||||
"""Test with history smaller than top_k returns all entries."""
|
|
||||||
async with HaikuRAG(temp_db_path, create=True) as client:
|
|
||||||
embedder = client.chunk_repository.embedder
|
|
||||||
|
|
||||||
# Create 3 Q&A pairs (less than top_k=5)
|
|
||||||
qa_history = [
|
|
||||||
QAResponse(question="What is Python?", answer="A programming language"),
|
|
||||||
QAResponse(question="What is Java?", answer="Another programming language"),
|
|
||||||
QAResponse(
|
|
||||||
question="What is Rust?", answer="A systems programming language"
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
result = await rank_qa_history_by_similarity(
|
|
||||||
current_question="Tell me about Python",
|
|
||||||
qa_history=qa_history,
|
|
||||||
embedder=embedder,
|
|
||||||
top_k=5,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should return all 3 entries since history < top_k
|
|
||||||
assert len(result) == 3
|
|
||||||
# All original entries should be present
|
|
||||||
assert set(qa.question for qa in result) == {
|
|
||||||
"What is Python?",
|
|
||||||
"What is Java?",
|
|
||||||
"What is Rust?",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.vcr()
|
|
||||||
async def test_rank_qa_history_returns_top_k(temp_db_path, allow_model_requests):
|
|
||||||
"""Test ranking returns top-K most similar entries and populates cache."""
|
|
||||||
async with HaikuRAG(temp_db_path, create=True) as client:
|
|
||||||
embedder = client.chunk_repository.embedder
|
|
||||||
|
|
||||||
# Create 10 Q&A pairs on different topics
|
|
||||||
qa_history = [
|
|
||||||
QAResponse(
|
|
||||||
question="What are the 11 class labels in DocLayNet?",
|
|
||||||
answer="Caption, Footnote, Formula, List-item, Page-footer, Page-header, Picture, Section-header, Table, Text, and Title",
|
|
||||||
),
|
|
||||||
QAResponse(
|
|
||||||
question="How was the annotation process organized?",
|
|
||||||
answer="The process had 4 phases with 40 dedicated annotators",
|
|
||||||
),
|
|
||||||
QAResponse(
|
|
||||||
question="What data sources were used?",
|
|
||||||
answer="arXiv, government offices, company websites, financial reports and patents",
|
|
||||||
),
|
|
||||||
QAResponse(
|
|
||||||
question="How were pages selected?",
|
|
||||||
answer="By selective subsampling with bias towards pages with figures or tables",
|
|
||||||
),
|
|
||||||
QAResponse(
|
|
||||||
question="What is the inter-annotator agreement?",
|
|
||||||
answer="Computed as mAP@0.5-0.95 metric between pairwise annotations",
|
|
||||||
),
|
|
||||||
QAResponse(
|
|
||||||
question="What is machine learning?",
|
|
||||||
answer="A field of AI that enables systems to learn from data",
|
|
||||||
),
|
|
||||||
QAResponse(
|
|
||||||
question="How does neural network training work?",
|
|
||||||
answer="Through backpropagation and gradient descent",
|
|
||||||
),
|
|
||||||
QAResponse(
|
|
||||||
question="What is deep learning?",
|
|
||||||
answer="A subset of ML using neural networks with many layers",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Clear cache to verify it gets populated
|
|
||||||
_embedding_cache.clear()
|
|
||||||
|
|
||||||
# Ask a question related to class labels (Q1)
|
|
||||||
result = await rank_qa_history_by_similarity(
|
|
||||||
current_question="Which class label has the highest count in DocLayNet?",
|
|
||||||
qa_history=qa_history,
|
|
||||||
embedder=embedder,
|
|
||||||
top_k=5,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should return exactly 5 entries
|
|
||||||
assert len(result) == 5
|
|
||||||
|
|
||||||
# The class labels Q&A should be in the top 5 (it's most semantically similar)
|
|
||||||
result_questions = [qa.question for qa in result]
|
|
||||||
assert "What are the 11 class labels in DocLayNet?" in result_questions
|
|
||||||
|
|
||||||
# Verify cache is populated for all Q/A pairs
|
|
||||||
for qa in qa_history:
|
|
||||||
cache_key = _qa_cache_key(qa.question, qa.answer)
|
|
||||||
assert cache_key in _embedding_cache
|
|
||||||
assert len(_embedding_cache[cache_key]) > 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.vcr()
|
|
||||||
async def test_rank_qa_history_preserves_order(temp_db_path, allow_model_requests):
|
|
||||||
"""Test that ranking preserves original order among selected items."""
|
|
||||||
async with HaikuRAG(temp_db_path, create=True) as client:
|
|
||||||
embedder = client.chunk_repository.embedder
|
|
||||||
|
|
||||||
# Create Q&A pairs where multiple are similar
|
|
||||||
qa_history = [
|
|
||||||
QAResponse(
|
|
||||||
question="What is Python?",
|
|
||||||
answer="A programming language",
|
|
||||||
citations=[
|
|
||||||
CitationInfo(
|
|
||||||
index=1,
|
|
||||||
document_id="doc1",
|
|
||||||
chunk_id="chunk1",
|
|
||||||
document_uri="python.md",
|
|
||||||
content="Python content",
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
QAResponse(
|
|
||||||
question="What is Java?",
|
|
||||||
answer="Another programming language",
|
|
||||||
),
|
|
||||||
QAResponse(
|
|
||||||
question="How to use Python for data science?",
|
|
||||||
answer="Use pandas, numpy, and scikit-learn",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
result = await rank_qa_history_by_similarity(
|
|
||||||
current_question="Tell me about Python programming",
|
|
||||||
qa_history=qa_history,
|
|
||||||
embedder=embedder,
|
|
||||||
top_k=3,
|
|
||||||
)
|
|
||||||
|
|
||||||
# All should be returned
|
|
||||||
assert len(result) == 3
|
|
||||||
|
|
||||||
# The two Python-related questions should be in the results
|
|
||||||
result_questions = [qa.question for qa in result]
|
|
||||||
assert "What is Python?" in result_questions
|
|
||||||
assert "How to use Python for data science?" in result_questions
|
|
||||||
|
|
||||||
|
|
||||||
def test_format_conversation_context_empty():
|
|
||||||
"""Test format_conversation_context with empty history."""
|
|
||||||
result = format_conversation_context([])
|
|
||||||
assert result == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_format_conversation_context_with_history():
|
|
||||||
"""Test format_conversation_context formats qa_history as XML."""
|
|
||||||
citation = CitationInfo(
|
|
||||||
index=1,
|
|
||||||
document_id="doc-123",
|
|
||||||
chunk_id="chunk-456",
|
|
||||||
document_uri="test.md",
|
|
||||||
document_title="Test Document",
|
|
||||||
content="Test content",
|
|
||||||
)
|
|
||||||
qa_history = [
|
|
||||||
QAResponse(
|
|
||||||
question="What is Python?",
|
|
||||||
answer="A programming language",
|
|
||||||
citations=[citation],
|
|
||||||
),
|
|
||||||
QAResponse(
|
|
||||||
question="What is Java?",
|
|
||||||
answer="Another programming language",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
result = format_conversation_context(qa_history)
|
|
||||||
|
|
||||||
assert "<conversation_context>" in result
|
|
||||||
assert "previous_qa" in result
|
|
||||||
assert "What is Python?" in result
|
|
||||||
assert "A programming language" in result
|
|
||||||
assert "What is Java?" in result
|
|
||||||
assert "Another programming language" in result
|
|
||||||
assert "Test Document" in result # source from first citation
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_document_filter_simple():
|
def test_build_document_filter_simple():
|
||||||
|
|
|
||||||
|
|
@ -103,3 +103,21 @@ def test_format_conversational_context_for_prompt_excludes_background_when_none(
|
||||||
context = ResearchContext(original_question="What is X?")
|
context = ResearchContext(original_question="What is X?")
|
||||||
result = format_conversational_context_for_prompt(context)
|
result = format_conversational_context_for_prompt(context)
|
||||||
assert "<background>" not in result
|
assert "<background>" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_research_plan_allows_empty_sub_questions():
|
||||||
|
"""Test ResearchPlan accepts empty sub_questions when context is sufficient."""
|
||||||
|
from haiku.rag.agents.research.models import ResearchPlan
|
||||||
|
|
||||||
|
plan = ResearchPlan(sub_questions=[])
|
||||||
|
assert plan.sub_questions == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_research_plan_rejects_too_many_sub_questions():
|
||||||
|
"""Test ResearchPlan rejects more than 12 sub_questions."""
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from haiku.rag.agents.research.models import ResearchPlan
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError, match="Cannot have more than 12"):
|
||||||
|
ResearchPlan(sub_questions=[f"q{i}" for i in range(13)])
|
||||||
|
|
|
||||||
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