From b70fd8b7b46f141c34b57ffe3805f08c9d1d29e3 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Thu, 11 Dec 2025 12:44:14 +0200 Subject: [PATCH 1/6] Api for listing documents --- examples/ag-ui-research/backend/main.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/examples/ag-ui-research/backend/main.py b/examples/ag-ui-research/backend/main.py index 32a2fec0..48593415 100644 --- a/examples/ag-ui-research/backend/main.py +++ b/examples/ag-ui-research/backend/main.py @@ -189,6 +189,19 @@ async def health_check(_: Request) -> JSONResponse: ) +async def list_documents(_: Request) -> JSONResponse: + """List all documents in the database.""" + client = get_client(db_path) + docs = await client.document_repository.list_all() + return JSONResponse( + { + "documents": [ + {"id": doc.id, "title": doc.title, "uri": doc.uri} for doc in docs + ] + } + ) + + async def visualize_chunk(request: Request) -> JSONResponse: """Return visual grounding images for a chunk as base64.""" import base64 @@ -228,6 +241,7 @@ async def visualize_chunk(request: Request) -> JSONResponse: app = Starlette( routes=[ Route("/v1/research/stream", stream_research_agent, methods=["POST"]), + Route("/api/documents", list_documents, methods=["GET"]), Route("/api/visualize/{chunk_id}", visualize_chunk, methods=["GET"]), Route("/health", health_check, methods=["GET"]), ], From 5f4f5f70f0d3d769c78db29699344a868e8b1cf7 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Thu, 11 Dec 2025 14:37:43 +0200 Subject: [PATCH 2/6] Search filter in qa & research graphs --- .../haiku/rag/graph/common/nodes.py | 15 +- .../haiku/rag/graph/deep_qa/state.py | 3 + .../haiku/rag/graph/research/state.py | 3 + tests/graph/test_search_filter.py | 187 ++++++++++++++++++ 4 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 tests/graph/test_search_filter.py diff --git a/haiku_rag_slim/haiku/rag/graph/common/nodes.py b/haiku_rag_slim/haiku/rag/graph/common/nodes.py index 62d2e9e6..bfccf182 100644 --- a/haiku_rag_slim/haiku/rag/graph/common/nodes.py +++ b/haiku_rag_slim/haiku/rag/graph/common/nodes.py @@ -34,6 +34,7 @@ class GraphState(Protocol): context: GraphContext max_concurrency: int + search_filter: str | None class GraphDeps(Protocol): @@ -99,11 +100,16 @@ def create_plan_node[AgentDepsT: GraphAgentDeps]( plan_agent = Agent(**agent_config) + # Capture search filter for use in tool + search_filter = state.search_filter + @plan_agent.tool async def gather_context( ctx2: RunContext[AgentDepsT], query: str, limit: int | None = None ) -> 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) return "\n\n".join(r.content for r in results) @@ -225,6 +231,9 @@ async def _do_search[AgentDepsT: GraphAgentDeps]( deps_type=deps_type, ) + # Capture search filter for use in tool + search_filter = state.search_filter + @agent.tool async def search_and_answer( 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. 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) # Store results for citation resolution ctx2.deps.search_results = results diff --git a/haiku_rag_slim/haiku/rag/graph/deep_qa/state.py b/haiku_rag_slim/haiku/rag/graph/deep_qa/state.py index ee600874..24dfc1a2 100644 --- a/haiku_rag_slim/haiku/rag/graph/deep_qa/state.py +++ b/haiku_rag_slim/haiku/rag/graph/deep_qa/state.py @@ -36,6 +36,9 @@ class DeepQAState(BaseModel): default=1, description="Maximum parallel sub-question searches" ) 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 def from_config(cls, context: DeepQAContext, config: "AppConfig") -> "DeepQAState": diff --git a/haiku_rag_slim/haiku/rag/graph/research/state.py b/haiku_rag_slim/haiku/rag/graph/research/state.py index 8ac49c95..17e876ee 100644 --- a/haiku_rag_slim/haiku/rag/graph/research/state.py +++ b/haiku_rag_slim/haiku/rag/graph/research/state.py @@ -63,6 +63,9 @@ class ResearchState(BaseModel): last_analysis: InsightAnalysis | None = Field( default=None, description="Last insight analysis" ) + search_filter: str | None = Field( + default=None, description="SQL WHERE clause to filter search results" + ) @classmethod def from_config( diff --git a/tests/graph/test_search_filter.py b/tests/graph/test_search_filter.py new file mode 100644 index 00000000..0c9e99bd --- /dev/null +++ b/tests/graph/test_search_filter.py @@ -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']}'" From 39cbec1b88353036e293b201db36bcf99393464d Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Thu, 11 Dec 2025 14:38:23 +0200 Subject: [PATCH 3/6] Set search filters from ducment filter in example --- examples/ag-ui-research/backend/agent.py | 2 ++ examples/ag-ui-research/backend/main.py | 13 ++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/examples/ag-ui-research/backend/agent.py b/examples/ag-ui-research/backend/agent.py index 0182f1e6..3dce81ac 100644 --- a/examples/ag-ui-research/backend/agent.py +++ b/examples/ag-ui-research/backend/agent.py @@ -33,6 +33,7 @@ class AgentDeps: client: HaikuRAG agui_emitter: "AGUIEmitter[ResearchState, ResearchReport] | None" = None + search_filter: str | None = None model = get_model(Config.research.model, Config) @@ -74,6 +75,7 @@ async def run_research(ctx: RunContext[AgentDeps], question: str) -> str: graph = build_research_graph(Config) context = ResearchContext(original_question=question) state = ResearchState.from_config(context=context, config=Config) + state.search_filter = ctx.deps.search_filter graph_deps = ResearchDeps( client=ctx.deps.client, diff --git a/examples/ag-ui-research/backend/main.py b/examples/ag-ui-research/backend/main.py index 48593415..3909c496 100644 --- a/examples/ag-ui-research/backend/main.py +++ b/examples/ag-ui-research/backend/main.py @@ -92,8 +92,19 @@ async def stream_research_agent(request: Request) -> StreamingResponse: effective_db_path = Path(effective_db_path) client = get_client(effective_db_path) + # Build search filter from document IDs if provided + document_ids = input_data.state.get("documentFilter") + search_filter = None + if document_ids: + ids_str = ", ".join(f"'{id}'" for id in document_ids) + search_filter = f"id IN ({ids_str})" + # Create agent dependencies with shared emitter - agent_deps = AgentDeps(client=client, agui_emitter=emitter) + agent_deps = AgentDeps( + client=client, + agui_emitter=emitter, + search_filter=search_filter, + ) # Start run with empty initial state emitter.start_run( From 9fd33a2d8337a00ffbe44c700202c6c4c73cfd74 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Thu, 11 Dec 2025 14:56:18 +0200 Subject: [PATCH 4/6] DocumentSelector in frontend to select documents to do research on. --- examples/ag-ui-research/backend/main.py | 4 +- .../frontend/components/Agent.tsx | 17 +- .../frontend/components/DocumentSelector.tsx | 352 ++++++++++++++++++ 3 files changed, 370 insertions(+), 3 deletions(-) create mode 100644 examples/ag-ui-research/frontend/components/DocumentSelector.tsx diff --git a/examples/ag-ui-research/backend/main.py b/examples/ag-ui-research/backend/main.py index 3909c496..d432a8ba 100644 --- a/examples/ag-ui-research/backend/main.py +++ b/examples/ag-ui-research/backend/main.py @@ -92,8 +92,8 @@ async def stream_research_agent(request: Request) -> StreamingResponse: effective_db_path = Path(effective_db_path) client = get_client(effective_db_path) - # Build search filter from document IDs if provided - document_ids = input_data.state.get("documentFilter") + # Build search filter from document IDs (empty list = search all) + document_ids = input_data.state.get("documentFilter") or [] search_filter = None if document_ids: ids_str = ", ".join(f"'{id}'" for id in document_ids) diff --git a/examples/ag-ui-research/frontend/components/Agent.tsx b/examples/ag-ui-research/frontend/components/Agent.tsx index ac297cfc..a8dc5f59 100644 --- a/examples/ag-ui-research/frontend/components/Agent.tsx +++ b/examples/ag-ui-research/frontend/components/Agent.tsx @@ -3,6 +3,7 @@ import { CopilotKit, useCoAgent } from "@copilotkit/react-core"; import { CopilotChat } from "@copilotkit/react-ui"; import "@copilotkit/react-ui/styles.css"; +import DocumentSelector from "./DocumentSelector"; import StateDisplay from "./StateDisplay"; interface InsightRecord { @@ -82,10 +83,11 @@ interface ResearchState { result?: ResearchReport; current_activity?: string; current_activity_message?: string; + documentFilter?: string[]; } function AgentContent() { - const { state } = useCoAgent({ + const { state, setState, running } = useCoAgent({ name: "research_agent", initialState: { context: { @@ -101,9 +103,14 @@ function AgentContent() { max_concurrency: 1, last_eval: null, last_analysis: null, + documentFilter: [], }, }); + const handleDocumentFilterChange = (ids: string[]) => { + setState({ ...state, documentFilter: ids }); + }; + return ( <>