Search filter in qa & research graphs
This commit is contained in:
parent
b70fd8b7b4
commit
5f4f5f70f0
4 changed files with 206 additions and 2 deletions
|
|
@ -34,6 +34,7 @@ class GraphState(Protocol):
|
||||||
|
|
||||||
context: GraphContext
|
context: GraphContext
|
||||||
max_concurrency: int
|
max_concurrency: int
|
||||||
|
search_filter: str | None
|
||||||
|
|
||||||
|
|
||||||
class GraphDeps(Protocol):
|
class GraphDeps(Protocol):
|
||||||
|
|
@ -99,11 +100,16 @@ def create_plan_node[AgentDepsT: GraphAgentDeps](
|
||||||
|
|
||||||
plan_agent = Agent(**agent_config)
|
plan_agent = Agent(**agent_config)
|
||||||
|
|
||||||
|
# Capture search filter for use in tool
|
||||||
|
search_filter = state.search_filter
|
||||||
|
|
||||||
@plan_agent.tool
|
@plan_agent.tool
|
||||||
async def gather_context(
|
async def gather_context(
|
||||||
ctx2: RunContext[AgentDepsT], query: str, limit: int | None = None
|
ctx2: RunContext[AgentDepsT], query: str, limit: int | None = None
|
||||||
) -> str:
|
) -> str:
|
||||||
results = await ctx2.deps.client.search(query, limit=limit)
|
results = await ctx2.deps.client.search(
|
||||||
|
query, limit=limit, filter=search_filter
|
||||||
|
)
|
||||||
results = await ctx2.deps.client.expand_context(results)
|
results = await ctx2.deps.client.expand_context(results)
|
||||||
return "\n\n".join(r.content for r in results)
|
return "\n\n".join(r.content for r in results)
|
||||||
|
|
||||||
|
|
@ -225,6 +231,9 @@ async def _do_search[AgentDepsT: GraphAgentDeps](
|
||||||
deps_type=deps_type,
|
deps_type=deps_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Capture search filter for use in tool
|
||||||
|
search_filter = state.search_filter
|
||||||
|
|
||||||
@agent.tool
|
@agent.tool
|
||||||
async def search_and_answer(
|
async def search_and_answer(
|
||||||
ctx2: RunContext[AgentDepsT], query: str, limit: int | None = None
|
ctx2: RunContext[AgentDepsT], query: str, limit: int | None = None
|
||||||
|
|
@ -234,7 +243,9 @@ async def _do_search[AgentDepsT: GraphAgentDeps](
|
||||||
Returns results with chunk IDs and relevance scores.
|
Returns results with chunk IDs and relevance scores.
|
||||||
Reference results by their chunk_id in cited_chunks.
|
Reference results by their chunk_id in cited_chunks.
|
||||||
"""
|
"""
|
||||||
results = await ctx2.deps.client.search(query, limit=limit)
|
results = await ctx2.deps.client.search(
|
||||||
|
query, limit=limit, filter=search_filter
|
||||||
|
)
|
||||||
results = await ctx2.deps.client.expand_context(results)
|
results = await ctx2.deps.client.expand_context(results)
|
||||||
# Store results for citation resolution
|
# Store results for citation resolution
|
||||||
ctx2.deps.search_results = results
|
ctx2.deps.search_results = results
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,9 @@ class DeepQAState(BaseModel):
|
||||||
default=1, description="Maximum parallel sub-question searches"
|
default=1, description="Maximum parallel sub-question searches"
|
||||||
)
|
)
|
||||||
iterations: int = Field(default=0, description="Current iteration number")
|
iterations: int = Field(default=0, description="Current iteration number")
|
||||||
|
search_filter: str | None = Field(
|
||||||
|
default=None, description="SQL WHERE clause to filter search results"
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_config(cls, context: DeepQAContext, config: "AppConfig") -> "DeepQAState":
|
def from_config(cls, context: DeepQAContext, config: "AppConfig") -> "DeepQAState":
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,9 @@ class ResearchState(BaseModel):
|
||||||
last_analysis: InsightAnalysis | None = Field(
|
last_analysis: InsightAnalysis | None = Field(
|
||||||
default=None, description="Last insight analysis"
|
default=None, description="Last insight analysis"
|
||||||
)
|
)
|
||||||
|
search_filter: str | None = Field(
|
||||||
|
default=None, description="SQL WHERE clause to filter search results"
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_config(
|
def from_config(
|
||||||
|
|
|
||||||
187
tests/graph/test_search_filter.py
Normal file
187
tests/graph/test_search_filter.py
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import pytest
|
||||||
|
from pydantic_ai.models.test import TestModel
|
||||||
|
|
||||||
|
from haiku.rag.client import HaikuRAG
|
||||||
|
from haiku.rag.graph.deep_qa.dependencies import DeepQAContext
|
||||||
|
from haiku.rag.graph.deep_qa.graph import build_deep_qa_graph
|
||||||
|
from haiku.rag.graph.deep_qa.state import DeepQADeps, DeepQAState
|
||||||
|
from haiku.rag.graph.research.dependencies import ResearchContext
|
||||||
|
from haiku.rag.graph.research.graph import build_research_graph
|
||||||
|
from haiku.rag.graph.research.state import ResearchDeps, ResearchState
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client_with_docs(temp_db_path):
|
||||||
|
"""Create a client with two distinct documents."""
|
||||||
|
client = HaikuRAG(temp_db_path, create=True)
|
||||||
|
|
||||||
|
# Add two documents with distinct content
|
||||||
|
doc1 = await client.create_document(
|
||||||
|
"Document about cats: Cats are small furry mammals that purr.",
|
||||||
|
title="Cat Facts",
|
||||||
|
)
|
||||||
|
doc2 = await client.create_document(
|
||||||
|
"Document about dogs: Dogs are loyal companions that bark.",
|
||||||
|
title="Dog Facts",
|
||||||
|
)
|
||||||
|
|
||||||
|
yield client, doc1.id, doc2.id
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_filter_restricts_results(client_with_docs):
|
||||||
|
"""Test that search_filter restricts search to specified documents."""
|
||||||
|
client, doc1_id, doc2_id = client_with_docs
|
||||||
|
|
||||||
|
# Search without filter - should find both
|
||||||
|
results_all = await client.search("animals mammals companions")
|
||||||
|
assert len(results_all) >= 1
|
||||||
|
|
||||||
|
# Search with filter for doc1 only
|
||||||
|
filter_doc1 = f"id = '{doc1_id}'"
|
||||||
|
results_filtered = await client.search(
|
||||||
|
"animals mammals companions", filter=filter_doc1
|
||||||
|
)
|
||||||
|
|
||||||
|
# All results should be from doc1
|
||||||
|
for result in results_filtered:
|
||||||
|
assert result.document_id == doc1_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_research_graph_uses_search_filter(monkeypatch, client_with_docs):
|
||||||
|
"""Test that research graph passes search_filter to search operations."""
|
||||||
|
client, doc1_id, doc2_id = client_with_docs
|
||||||
|
|
||||||
|
# Track search calls to verify filter is passed
|
||||||
|
search_calls = []
|
||||||
|
original_search = client.search
|
||||||
|
|
||||||
|
async def tracking_search(query, limit=None, search_type="hybrid", filter=None):
|
||||||
|
search_calls.append({"query": query, "filter": filter})
|
||||||
|
return await original_search(query, limit, search_type, filter)
|
||||||
|
|
||||||
|
client.search = tracking_search
|
||||||
|
|
||||||
|
# Mock get_model to return TestModel
|
||||||
|
def test_model_factory(_provider, _model, _config=None):
|
||||||
|
return TestModel()
|
||||||
|
|
||||||
|
monkeypatch.setattr("haiku.rag.utils.get_model", test_model_factory)
|
||||||
|
monkeypatch.setattr("haiku.rag.graph.common.get_model", test_model_factory)
|
||||||
|
monkeypatch.setattr("haiku.rag.graph.common.nodes.get_model", test_model_factory)
|
||||||
|
monkeypatch.setattr("haiku.rag.graph.research.graph.get_model", test_model_factory)
|
||||||
|
|
||||||
|
graph = build_research_graph()
|
||||||
|
|
||||||
|
# Create state with search_filter
|
||||||
|
filter_clause = f"id = '{doc1_id}'"
|
||||||
|
state = ResearchState(
|
||||||
|
context=ResearchContext(original_question="Tell me about animals"),
|
||||||
|
max_iterations=1,
|
||||||
|
confidence_threshold=0.5,
|
||||||
|
search_filter=filter_clause,
|
||||||
|
)
|
||||||
|
|
||||||
|
deps = ResearchDeps(client=client)
|
||||||
|
|
||||||
|
await graph.run(state=state, deps=deps)
|
||||||
|
|
||||||
|
# Verify search was called with the filter
|
||||||
|
assert len(search_calls) > 0, "Expected search to be called"
|
||||||
|
for call in search_calls:
|
||||||
|
assert call["filter"] == filter_clause, (
|
||||||
|
f"Expected filter '{filter_clause}', got '{call['filter']}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deep_qa_graph_uses_search_filter(monkeypatch, client_with_docs):
|
||||||
|
"""Test that deep QA graph passes search_filter to search operations."""
|
||||||
|
client, doc1_id, doc2_id = client_with_docs
|
||||||
|
|
||||||
|
# Track search calls to verify filter is passed
|
||||||
|
search_calls = []
|
||||||
|
original_search = client.search
|
||||||
|
|
||||||
|
async def tracking_search(query, limit=None, search_type="hybrid", filter=None):
|
||||||
|
search_calls.append({"query": query, "filter": filter})
|
||||||
|
return await original_search(query, limit, search_type, filter)
|
||||||
|
|
||||||
|
client.search = tracking_search
|
||||||
|
|
||||||
|
# Mock get_model to return TestModel
|
||||||
|
def test_model_factory(_provider, _model, _config=None):
|
||||||
|
return TestModel()
|
||||||
|
|
||||||
|
monkeypatch.setattr("haiku.rag.utils.get_model", test_model_factory)
|
||||||
|
monkeypatch.setattr("haiku.rag.graph.common.get_model", test_model_factory)
|
||||||
|
monkeypatch.setattr("haiku.rag.graph.common.nodes.get_model", test_model_factory)
|
||||||
|
monkeypatch.setattr("haiku.rag.graph.deep_qa.graph.get_model", test_model_factory)
|
||||||
|
|
||||||
|
graph = build_deep_qa_graph()
|
||||||
|
|
||||||
|
# Create state with search_filter
|
||||||
|
filter_clause = f"id = '{doc2_id}'"
|
||||||
|
state = DeepQAState(
|
||||||
|
context=DeepQAContext(original_question="Tell me about animals"),
|
||||||
|
max_sub_questions=2,
|
||||||
|
search_filter=filter_clause,
|
||||||
|
)
|
||||||
|
|
||||||
|
deps = DeepQADeps(client=client)
|
||||||
|
|
||||||
|
await graph.run(state=state, deps=deps)
|
||||||
|
|
||||||
|
# Verify search was called with the filter
|
||||||
|
assert len(search_calls) > 0, "Expected search to be called"
|
||||||
|
for call in search_calls:
|
||||||
|
assert call["filter"] == filter_clause, (
|
||||||
|
f"Expected filter '{filter_clause}', got '{call['filter']}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_filter_none_searches_all(monkeypatch, client_with_docs):
|
||||||
|
"""Test that search_filter=None searches all documents."""
|
||||||
|
client, doc1_id, doc2_id = client_with_docs
|
||||||
|
|
||||||
|
# Track search calls
|
||||||
|
search_calls = []
|
||||||
|
original_search = client.search
|
||||||
|
|
||||||
|
async def tracking_search(query, limit=None, search_type="hybrid", filter=None):
|
||||||
|
search_calls.append({"query": query, "filter": filter})
|
||||||
|
return await original_search(query, limit, search_type, filter)
|
||||||
|
|
||||||
|
client.search = tracking_search
|
||||||
|
|
||||||
|
# Mock get_model
|
||||||
|
def test_model_factory(_provider, _model, _config=None):
|
||||||
|
return TestModel()
|
||||||
|
|
||||||
|
monkeypatch.setattr("haiku.rag.utils.get_model", test_model_factory)
|
||||||
|
monkeypatch.setattr("haiku.rag.graph.common.get_model", test_model_factory)
|
||||||
|
monkeypatch.setattr("haiku.rag.graph.common.nodes.get_model", test_model_factory)
|
||||||
|
monkeypatch.setattr("haiku.rag.graph.research.graph.get_model", test_model_factory)
|
||||||
|
|
||||||
|
graph = build_research_graph()
|
||||||
|
|
||||||
|
# Create state without search_filter (None)
|
||||||
|
state = ResearchState(
|
||||||
|
context=ResearchContext(original_question="Tell me about animals"),
|
||||||
|
max_iterations=1,
|
||||||
|
confidence_threshold=0.5,
|
||||||
|
search_filter=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
deps = ResearchDeps(client=client)
|
||||||
|
|
||||||
|
await graph.run(state=state, deps=deps)
|
||||||
|
|
||||||
|
# Verify search was called with None filter
|
||||||
|
assert len(search_calls) > 0, "Expected search to be called"
|
||||||
|
for call in search_calls:
|
||||||
|
assert call["filter"] is None, f"Expected filter None, got '{call['filter']}'"
|
||||||
Loading…
Reference in a new issue