haiku.rag/tests/skills/test_rag.py

421 lines
15 KiB
Python

from unittest.mock import AsyncMock
from haiku.rag.agents.research.models import Citation, ResearchReport
from haiku.rag.agents.rlm.models import RLMResult
from haiku.rag.client import HaikuRAG
from haiku.rag.store.models.chunk import SearchResult
from haiku.rag.tools.document import DocumentInfo
from haiku.rag.tools.qa import QAHistoryEntry
from .conftest import _get_tool, _make_ctx
class TestRAGSkillCreation:
def test_create_skill_returns_valid_skill(self, temp_db_path):
from haiku.rag.skills.rag import create_skill
skill = create_skill(db_path=temp_db_path)
assert skill.metadata.name == "rag"
assert skill.metadata.description
assert skill.instructions
def test_create_skill_has_expected_tools(self, temp_db_path):
from haiku.rag.skills.rag import create_skill
skill = create_skill(db_path=temp_db_path)
tool_names = {getattr(t, "__name__") for t in skill.tools if callable(t)}
assert tool_names == {
"search",
"list_documents",
"get_document",
"ask",
"analyze",
"research",
"get_session_context",
}
def test_create_skill_has_state(self, temp_db_path):
from haiku.rag.skills.rag import RAGState, create_skill
skill = create_skill(db_path=temp_db_path)
assert skill._state_type is RAGState
assert skill._state_namespace == "rag"
def test_create_skill_from_env(self, monkeypatch, temp_db_path):
monkeypatch.setenv("HAIKU_RAG_DB", str(temp_db_path))
from haiku.rag.skills.rag import create_skill
skill = create_skill()
assert skill.metadata.name == "rag"
class TestSearchTool:
async def test_search_returns_formatted_string(self, rag_db):
from haiku.rag.skills.rag import create_skill
skill = create_skill(db_path=rag_db)
search = _get_tool(skill, "search")
ctx = _make_ctx()
result = await search(ctx, query="artificial intelligence")
assert isinstance(result, str)
assert len(result) > 0
async def test_search_updates_state(self, rag_db):
from haiku.rag.skills.rag import RAGState, create_skill
skill = create_skill(db_path=rag_db)
search = _get_tool(skill, "search")
state = RAGState()
ctx = _make_ctx(state)
await search(ctx, query="artificial intelligence")
assert "artificial intelligence" in state.searches
results = state.searches["artificial intelligence"]
assert len(results) > 0
assert isinstance(results[0], SearchResult)
async def test_search_without_state(self, rag_db):
from haiku.rag.skills.rag import create_skill
skill = create_skill(db_path=rag_db)
search = _get_tool(skill, "search")
ctx = _make_ctx(state=None)
result = await search(ctx, query="artificial intelligence")
assert isinstance(result, str)
class TestListDocumentsTool:
async def test_list_documents_returns_results(self, rag_db):
from haiku.rag.skills.rag import create_skill
skill = create_skill(db_path=rag_db)
list_docs = _get_tool(skill, "list_documents")
ctx = _make_ctx()
results = await list_docs(ctx)
assert isinstance(results, list)
assert len(results) == 2
async def test_list_documents_updates_state(self, rag_db):
from haiku.rag.skills.rag import RAGState, create_skill
skill = create_skill(db_path=rag_db)
list_docs = _get_tool(skill, "list_documents")
state = RAGState()
ctx = _make_ctx(state)
await list_docs(ctx)
assert len(state.documents) == 2
assert isinstance(state.documents[0], DocumentInfo)
assert state.documents[0].id is not None
async def test_list_documents_no_duplicates_in_state(self, rag_db):
from haiku.rag.skills.rag import RAGState, create_skill
skill = create_skill(db_path=rag_db)
list_docs = _get_tool(skill, "list_documents")
state = RAGState()
ctx = _make_ctx(state)
await list_docs(ctx)
await list_docs(ctx)
assert len(state.documents) == 2
class TestGetDocumentTool:
async def test_get_document_by_title(self, rag_db):
from haiku.rag.skills.rag import create_skill
skill = create_skill(db_path=rag_db)
get_doc = _get_tool(skill, "get_document")
ctx = _make_ctx()
result = await get_doc(ctx, query="AI Overview")
assert result is not None
assert result["title"] == "AI Overview"
async def test_get_document_updates_state(self, rag_db):
from haiku.rag.skills.rag import RAGState, create_skill
skill = create_skill(db_path=rag_db)
get_doc = _get_tool(skill, "get_document")
state = RAGState()
ctx = _make_ctx(state)
await get_doc(ctx, query="AI Overview")
assert len(state.documents) == 1
assert isinstance(state.documents[0], DocumentInfo)
assert state.documents[0].title == "AI Overview"
async def test_get_document_not_found(self, rag_db):
from haiku.rag.skills.rag import create_skill
skill = create_skill(db_path=rag_db)
get_doc = _get_tool(skill, "get_document")
ctx = _make_ctx()
result = await get_doc(ctx, query="nonexistent document xyz")
assert result is None
class TestAskTool:
async def test_ask_returns_answer_with_citations(self, rag_db, monkeypatch):
from haiku.rag.skills.rag import create_skill
citations = [
Citation(
document_id="d1",
chunk_id="c1",
document_uri="test://ai-overview",
document_title="AI Overview",
content="AI is transforming industries.",
)
]
monkeypatch.setattr(
HaikuRAG,
"ask",
AsyncMock(return_value=("AI transforms industries worldwide.", citations)),
)
skill = create_skill(db_path=rag_db)
ask = _get_tool(skill, "ask")
ctx = _make_ctx()
result = await ask(ctx, question="What is AI?")
assert isinstance(result, str)
assert "AI transforms industries" in result
async def test_ask_updates_state(self, rag_db, monkeypatch):
from haiku.rag.skills.rag import RAGState, create_skill
citations = [
Citation(
document_id="d1",
chunk_id="c1",
document_uri="test://ai-overview",
content="AI content",
)
]
monkeypatch.setattr(
HaikuRAG,
"ask",
AsyncMock(return_value=("AI transforms industries.", citations)),
)
skill = create_skill(db_path=rag_db)
ask = _get_tool(skill, "ask")
state = RAGState()
ctx = _make_ctx(state)
await ask(ctx, question="What is AI?")
assert len(state.citations) == 1
assert len(state.qa_history) == 1
assert isinstance(state.qa_history[0], QAHistoryEntry)
assert state.qa_history[0].question == "What is AI?"
async def test_ask_assigns_citation_indices(self, rag_db, monkeypatch):
from haiku.rag.skills.rag import RAGState, create_skill
first_citations = [
Citation(
document_id="d1",
chunk_id="c1",
document_uri="test://doc1",
content="First.",
),
Citation(
document_id="d2",
chunk_id="c2",
document_uri="test://doc2",
content="Second.",
),
]
second_citations = [
Citation(
document_id="d3",
chunk_id="c3",
document_uri="test://doc3",
content="Third.",
),
]
call_count = 0
async def mock_ask(self, question):
nonlocal call_count
call_count += 1
if call_count == 1:
return ("Answer 1", first_citations)
return ("Answer 2", second_citations)
monkeypatch.setattr(HaikuRAG, "ask", mock_ask)
skill = create_skill(db_path=rag_db)
ask = _get_tool(skill, "ask")
state = RAGState()
ctx = _make_ctx(state)
await ask(ctx, question="First question")
assert state.citations[0].index == 1
assert state.citations[1].index == 2
await ask(ctx, question="Second question")
assert state.citations[2].index == 3
class TestAnalyzeTool:
async def test_analyze_returns_result(self, rag_db, monkeypatch):
from haiku.rag.skills.rag import create_skill
monkeypatch.setattr(
HaikuRAG,
"rlm",
AsyncMock(return_value=RLMResult(answer="42", program="print(42)")),
)
skill = create_skill(db_path=rag_db)
analyze = _get_tool(skill, "analyze")
ctx = _make_ctx()
result = await analyze(ctx, question="How many documents?")
assert isinstance(result, str)
assert "42" in result
async def test_analyze_updates_state(self, rag_db, monkeypatch):
from haiku.rag.skills.rag import RAGState, create_skill
monkeypatch.setattr(
HaikuRAG,
"rlm",
AsyncMock(return_value=RLMResult(answer="42", program="print(42)")),
)
skill = create_skill(db_path=rag_db)
analyze = _get_tool(skill, "analyze")
state = RAGState()
ctx = _make_ctx(state)
await analyze(ctx, question="How many documents?")
assert len(state.qa_history) == 1
assert state.qa_history[0].question == "How many documents?"
class TestGetSessionContextTool:
async def test_no_prior_questions(self, rag_db):
from haiku.rag.skills.rag import RAGState, create_skill
skill = create_skill(db_path=rag_db)
get_ctx = _get_tool(skill, "get_session_context")
state = RAGState()
ctx = _make_ctx(state)
result = await get_ctx(ctx, query="What is AI?")
assert "no prior" in result.lower()
async def test_returns_relevant_prior_qa(self, rag_db):
import random
from haiku.rag.skills.rag import RAGState, create_skill
from tests.skills.conftest import VECTOR_DIM
skill = create_skill(db_path=rag_db)
get_ctx = _get_tool(skill, "get_session_context")
# Pre-compute the embedding that the fake embedder will produce
# for the query, so we can set it on the prior entry for high similarity
query_text = "Tell me about artificial intelligence"
random.seed(hash(query_text) % (2**32))
query_embedding = [random.random() for _ in range(VECTOR_DIM)]
state = RAGState(
qa_history=[
QAHistoryEntry(
question="What is artificial intelligence?",
answer="AI is the simulation of human intelligence by machines.",
question_embedding=query_embedding,
),
]
)
ctx = _make_ctx(state)
result = await get_ctx(ctx, query=query_text)
assert "artificial intelligence" in result.lower()
assert "simulation" in result.lower()
async def test_no_relevant_matches(self, rag_db):
from haiku.rag.skills.rag import RAGState, create_skill
from tests.skills.conftest import VECTOR_DIM
skill = create_skill(db_path=rag_db)
get_ctx = _get_tool(skill, "get_session_context")
# Use alternating ±1 embedding which is near-orthogonal to the
# all-positive vectors produced by the fake embedder
orthogonal = [1.0 if i % 2 == 0 else -1.0 for i in range(VECTOR_DIM)]
state = RAGState(
qa_history=[
QAHistoryEntry(
question="What is the weather?",
answer="It is sunny today.",
question_embedding=orthogonal,
),
]
)
ctx = _make_ctx(state)
result = await get_ctx(ctx, query="Explain quantum computing")
assert "no relevant" in result.lower()
async def test_without_state(self, rag_db):
from haiku.rag.skills.rag import create_skill
skill = create_skill(db_path=rag_db)
get_ctx = _get_tool(skill, "get_session_context")
ctx = _make_ctx(state=None)
result = await get_ctx(ctx, query="What is AI?")
assert "no prior" in result.lower()
class TestResearchTool:
async def test_research_returns_report(self, rag_db, monkeypatch):
from haiku.rag.skills.rag import create_skill
report = ResearchReport(
title="AI Research",
executive_summary="AI is transforming industries.",
main_findings=["Finding 1"],
conclusions=["Conclusion 1"],
sources_summary="Multiple sources consulted.",
)
monkeypatch.setattr(HaikuRAG, "research", AsyncMock(return_value=report))
skill = create_skill(db_path=rag_db)
research = _get_tool(skill, "research")
ctx = _make_ctx()
result = await research(ctx, question="What is AI?")
assert isinstance(result, str)
assert "AI Research" in result
async def test_research_updates_state(self, rag_db, monkeypatch):
from haiku.rag.skills.rag import RAGState, create_skill
report = ResearchReport(
title="AI Research",
executive_summary="AI is transforming industries.",
main_findings=["Finding 1"],
conclusions=["Conclusion 1"],
sources_summary="Multiple sources consulted.",
)
monkeypatch.setattr(HaikuRAG, "research", AsyncMock(return_value=report))
skill = create_skill(db_path=rag_db)
research = _get_tool(skill, "research")
state = RAGState()
ctx = _make_ctx(state)
await research(ctx, question="What is AI?")
assert len(state.reports) == 1
assert state.reports[0].question == "What is AI?"
assert len(state.qa_history) == 1
assert state.qa_history[0].question == "What is AI?"
assert state.qa_history[0].answer == "AI is transforming industries."
async def test_research_without_state(self, rag_db, monkeypatch):
from haiku.rag.skills.rag import create_skill
report = ResearchReport(
title="AI Research",
executive_summary="Summary.",
main_findings=["Finding"],
conclusions=["Conclusion"],
sources_summary="Sources.",
)
monkeypatch.setattr(HaikuRAG, "research", AsyncMock(return_value=report))
skill = create_skill(db_path=rag_db)
research = _get_tool(skill, "research")
ctx = _make_ctx(state=None)
result = await research(ctx, question="What is AI?")
assert isinstance(result, str)