Update deep ask graph

This commit is contained in:
Yiorgis Gozadinos 2025-11-11 14:18:52 +02:00
parent ed27acc1a2
commit 8f0597e89e
No known key found for this signature in database
8 changed files with 353 additions and 234 deletions

View file

@ -96,7 +96,7 @@ URLs are also supported for web content.
## AG-UI Server ## AG-UI Server
The AG-UI server provides HTTP streaming of research graph execution using Server-Sent Events (SSE). The AG-UI server provides HTTP streaming of both research and deep ask graph execution using Server-Sent Events (SSE).
### Starting the AG-UI Server ### Starting the AG-UI Server
@ -107,7 +107,8 @@ haiku-rag serve --agui
This starts an HTTP server (default: http://0.0.0.0:8000) that exposes: This starts an HTTP server (default: http://0.0.0.0:8000) that exposes:
- `GET /health` - Health check endpoint - `GET /health` - Health check endpoint
- `POST /v1/agent/stream` - Research graph streaming endpoint - `POST /v1/research/stream` - Research graph streaming endpoint
- `POST /v1/deep-ask/stream` - Deep ask graph streaming endpoint
### Configuration ### Configuration
@ -123,9 +124,9 @@ agui:
See [Configuration](configuration.md#ag-ui-server-configuration) for all available options. See [Configuration](configuration.md#ag-ui-server-configuration) for all available options.
### Using the Streaming Endpoint ### Using the Streaming Endpoints
The `/v1/agent/stream` endpoint accepts POST requests with research parameters and streams AG-UI events: Both endpoints accept POST requests with the same AG-UI RunAgentInput format and stream AG-UI events.
**Request format:** **Request format:**
```json ```json
@ -133,24 +134,33 @@ The `/v1/agent/stream` endpoint accepts POST requests with research parameters a
"threadId": "optional-thread-id", "threadId": "optional-thread-id",
"runId": "optional-run-id", "runId": "optional-run-id",
"state": { "state": {
"context": { "question": "What are the key features of haiku.rag?"
"original_question": "What are the key features of haiku.rag?"
}
}, },
"messages": [], "messages": [],
"config": {} "config": {}
} }
``` ```
**Example with curl:** **Research endpoint example:**
```bash ```bash
curl -X POST http://localhost:8000/v1/agent/stream \ curl -X POST http://localhost:8000/v1/research/stream \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"state": { "state": {
"context": { "question": "What are the key features of haiku.rag?"
"original_question": "What are the key features of haiku.rag?" }
} }' \
--no-buffer
```
**Deep ask endpoint example:**
```bash
curl -X POST http://localhost:8000/v1/deep-ask/stream \
-H "Content-Type: application/json" \
-d '{
"state": {
"question": "How does haiku.rag handle document chunking?",
"use_citations": true
} }
}' \ }' \
--no-buffer --no-buffer
@ -158,6 +168,10 @@ curl -X POST http://localhost:8000/v1/agent/stream \
The `--no-buffer` flag ensures curl displays events as they arrive instead of buffering them. The `--no-buffer` flag ensures curl displays events as they arrive instead of buffering them.
**Note:** The `state` object can include:
- `question`: The question to answer (required)
- `use_citations`: Enable citations in deep ask responses (optional, deep ask only)
**Response:** Server-Sent Events stream with AG-UI protocol events: **Response:** Server-Sent Events stream with AG-UI protocol events:
- `RUN_STARTED` - Graph execution started - `RUN_STARTED` - Graph execution started
- `STATE_SNAPSHOT` - Current state snapshot - `STATE_SNAPSHOT` - Current state snapshot

View file

@ -21,7 +21,7 @@ from haiku.rag.agui.events import (
from haiku.rag.agui.server import ( from haiku.rag.agui.server import (
RunAgentInput, RunAgentInput,
create_agui_app, create_agui_app,
create_research_server, create_agui_server,
format_sse_event, format_sse_event,
) )
from haiku.rag.agui.state import compute_state_delta from haiku.rag.agui.state import compute_state_delta
@ -34,7 +34,7 @@ __all__ = [
"RunAgentInput", "RunAgentInput",
"compute_state_delta", "compute_state_delta",
"create_agui_app", "create_agui_app",
"create_research_server", "create_agui_server",
"emit_activity", "emit_activity",
"emit_activity_delta", "emit_activity_delta",
"emit_run_error", "emit_run_error",

View file

@ -146,19 +146,20 @@ def format_sse_event(event: AGUIEvent) -> str:
return f"data: {event_json}\n\n" return f"data: {event_json}\n\n"
def create_research_server(config: Any, db_path: Any | None = None) -> Starlette: def create_agui_server(config: Any, db_path: Any | None = None) -> Starlette:
"""Create AG-UI server for research graph. """Create AG-UI server with both research and deep ask endpoints.
This is a convenience function specifically for the research graph.
Args: Args:
config: Application config with research settings config: Application config with research and qa settings
db_path: Optional database path override db_path: Optional database path override
Returns: Returns:
Starlette app configured for research graph Starlette app with research and deep ask endpoints
""" """
from haiku.rag.client import HaikuRAG from haiku.rag.client import HaikuRAG
from haiku.rag.qa.deep.dependencies import DeepQAContext
from haiku.rag.qa.deep.graph import build_deep_qa_graph
from haiku.rag.qa.deep.state import DeepQADeps, DeepQAState
from haiku.rag.research.dependencies import ResearchContext from haiku.rag.research.dependencies import ResearchContext
from haiku.rag.research.graph import build_research_graph from haiku.rag.research.graph import build_research_graph
from haiku.rag.research.state import ResearchDeps, ResearchState from haiku.rag.research.state import ResearchDeps, ResearchState
@ -166,44 +167,139 @@ def create_research_server(config: Any, db_path: Any | None = None) -> Starlette
# Store client reference for proper lifecycle management # Store client reference for proper lifecycle management
_client_cache: dict[str, HaikuRAG] = {} _client_cache: dict[str, HaikuRAG] = {}
def graph_factory() -> Graph: def get_client(effective_db_path: Any) -> HaikuRAG:
"""Create research graph instance.""" """Get or create cached client."""
path_key = str(effective_db_path)
if path_key not in _client_cache:
_client_cache[path_key] = HaikuRAG(db_path=effective_db_path, config=config)
return _client_cache[path_key]
# Research graph factories
def research_graph_factory() -> Graph:
return build_research_graph(config) return build_research_graph(config)
def state_factory(input_state: dict[str, Any]) -> ResearchState: def research_state_factory(input_state: dict[str, Any]) -> ResearchState:
"""Create research state from input."""
# Extract question from input state or messages
question = input_state.get("question", "") question = input_state.get("question", "")
if not question: if not question:
# Try to get from first message if available
messages = input_state.get("messages", []) messages = input_state.get("messages", [])
if messages: if messages:
question = messages[0].get("content", "") question = messages[0].get("content", "")
# Create context and state
context = ResearchContext(original_question=question) context = ResearchContext(original_question=question)
return ResearchState.from_config(context=context, config=config) return ResearchState.from_config(context=context, config=config)
def deps_factory(input_config: dict[str, Any]) -> ResearchDeps: def research_deps_factory(input_config: dict[str, Any]) -> ResearchDeps:
"""Create research dependencies."""
# Use provided db_path or fallback to config
effective_db_path = ( effective_db_path = (
db_path db_path
or input_config.get("db_path") or input_config.get("db_path")
or config.storage.data_dir / "haiku.rag.lancedb" or config.storage.data_dir / "haiku.rag.lancedb"
) )
return ResearchDeps(client=get_client(effective_db_path))
# Reuse existing client if available # Deep ask graph factories
path_key = str(effective_db_path) def deep_ask_graph_factory() -> Graph:
if path_key not in _client_cache: return build_deep_qa_graph(config)
_client_cache[path_key] = HaikuRAG(db_path=effective_db_path, config=config)
return ResearchDeps(client=_client_cache[path_key]) def deep_ask_state_factory(input_state: dict[str, Any]) -> DeepQAState:
question = input_state.get("question", "")
if not question:
messages = input_state.get("messages", [])
if messages:
question = messages[0].get("content", "")
use_citations = input_state.get("use_citations", False)
context = DeepQAContext(original_question=question, use_citations=use_citations)
return DeepQAState.from_config(context=context, config=config)
# Use AG-UI config from app config def deep_ask_deps_factory(input_config: dict[str, Any]) -> DeepQADeps:
return create_agui_app( effective_db_path = (
graph_factory=graph_factory, db_path
state_factory=state_factory, or input_config.get("db_path")
deps_factory=deps_factory, or config.storage.data_dir / "haiku.rag.lancedb"
config=config.agui, )
return DeepQADeps(client=get_client(effective_db_path))
# Create event stream functions for each graph type
async def research_event_stream(
input_data: RunAgentInput,
) -> AsyncIterator[str]:
"""Generate SSE event stream from research graph execution."""
graph = research_graph_factory()
initial_state = research_state_factory(input_data.state)
deps = research_deps_factory(input_data.config)
async for event in stream_graph(graph, initial_state, deps):
event_data = format_sse_event(event)
yield event_data
async def deep_ask_event_stream(
input_data: RunAgentInput,
) -> AsyncIterator[str]:
"""Generate SSE event stream from deep ask graph execution."""
graph = deep_ask_graph_factory()
initial_state = deep_ask_state_factory(input_data.state)
deps = deep_ask_deps_factory(input_data.config)
async for event in stream_graph(graph, initial_state, deps):
event_data = format_sse_event(event)
yield event_data
# Endpoint handlers
async def stream_research(request: Request) -> StreamingResponse:
"""Research graph streaming endpoint."""
body = await request.json()
input_data = RunAgentInput(**body)
return StreamingResponse(
research_event_stream(input_data),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
async def stream_deep_ask(request: Request) -> StreamingResponse:
"""Deep ask graph streaming endpoint."""
body = await request.json()
input_data = RunAgentInput(**body)
return StreamingResponse(
deep_ask_event_stream(input_data),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
async def health_check(_: Request) -> JSONResponse:
"""Health check endpoint."""
return JSONResponse({"status": "healthy"})
# Define routes
routes = [
Route("/v1/research/stream", stream_research, methods=["POST"]),
Route("/v1/deep-ask/stream", stream_deep_ask, methods=["POST"]),
Route("/health", health_check, methods=["GET"]),
]
# Configure CORS middleware
middleware = [
Middleware(
CORSMiddleware,
allow_origins=config.agui.cors_origins,
allow_credentials=config.agui.cors_credentials,
allow_methods=config.agui.cors_methods,
allow_headers=config.agui.cors_headers,
)
]
# Create Starlette app
app = Starlette(
routes=routes,
middleware=middleware,
debug=False,
) )
return app

View file

@ -216,8 +216,6 @@ class HaikuRAGApp:
async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client: async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client:
try: try:
if deep: if deep:
from rich.console import Console
from haiku.rag.qa.deep.dependencies import DeepQAContext from haiku.rag.qa.deep.dependencies import DeepQAContext
from haiku.rag.qa.deep.graph import build_deep_qa_graph from haiku.rag.qa.deep.graph import build_deep_qa_graph
from haiku.rag.qa.deep.state import DeepQADeps, DeepQAState from haiku.rag.qa.deep.state import DeepQADeps, DeepQAState
@ -227,12 +225,22 @@ class HaikuRAGApp:
original_question=question, use_citations=cite original_question=question, use_citations=cite
) )
state = DeepQAState.from_config(context=context, config=self.config) state = DeepQAState.from_config(context=context, config=self.config)
deps = DeepQADeps( deps = DeepQADeps(client=self.client)
client=self.client, console=Console() if verbose else None
)
result = await graph.run(state=state, deps=deps) if verbose:
answer = result.answer # Use AG-UI renderer to process and display events
from haiku.rag.agui import AGUIConsoleRenderer
renderer = AGUIConsoleRenderer(self.console)
result_dict = await renderer.render(
stream_graph(graph, state, deps)
)
# Result should be a dict with 'answer' key
answer = result_dict.get("answer", "") if result_dict else ""
else:
# Run without rendering events, just get the result
result = await graph.run(state=state, deps=deps)
answer = result.answer
else: else:
answer = await self.client.ask(question, cite=cite) answer = await self.client.ask(question, cite=cite)
@ -489,12 +497,12 @@ class HaikuRAGApp:
async def run_agui(): async def run_agui():
import uvicorn import uvicorn
from haiku.rag.agui import create_research_server from haiku.rag.agui import create_agui_server
logger.info( logger.info(
f"Starting AG-UI server on {self.config.agui.host}:{self.config.agui.port}" f"Starting AG-UI server on {self.config.agui.host}:{self.config.agui.port}"
) )
app = create_research_server(self.config, db_path=self.db_path) app = create_agui_server(self.config, db_path=self.db_path)
config = uvicorn.Config( config = uvicorn.Config(
app=app, app=app,
host=self.config.agui.host, host=self.config.agui.host,

View file

@ -8,7 +8,7 @@ from pydantic_graph.beta.join import reduce_list_append
from haiku.rag.config import Config from haiku.rag.config import Config
from haiku.rag.config.models import AppConfig from haiku.rag.config.models import AppConfig
from haiku.rag.graph_common import get_model, log from haiku.rag.graph_common import get_model
from haiku.rag.graph_common.models import ResearchPlan, SearchAnswer from haiku.rag.graph_common.models import ResearchPlan, SearchAnswer
from haiku.rag.graph_common.prompts import PLAN_PROMPT, SEARCH_AGENT_PROMPT from haiku.rag.graph_common.prompts import PLAN_PROMPT, SEARCH_AGENT_PROMPT
from haiku.rag.qa.deep.dependencies import DeepQADependencies from haiku.rag.qa.deep.dependencies import DeepQADependencies
@ -45,49 +45,52 @@ def build_deep_qa_graph(
state = ctx.state state = ctx.state
deps = ctx.deps deps = ctx.deps
log(deps, state, "\n[bold cyan]📋 Planning approach...[/bold cyan]") if deps.agui_emitter:
deps.agui_emitter.start_step("plan")
deps.agui_emitter.update_activity("planning", "Planning approach")
plan_agent = Agent( try:
model=get_model(provider, model), plan_agent = Agent(
output_type=ResearchPlan, model=get_model(provider, model),
instructions=( output_type=ResearchPlan,
PLAN_PROMPT instructions=(
+ "\n\nUse the gather_context tool once on the main question before planning." PLAN_PROMPT
), + "\n\nUse the gather_context tool once on the main question before planning."
retries=3, ),
deps_type=DeepQADependencies, retries=3,
) deps_type=DeepQADependencies,
)
@plan_agent.tool @plan_agent.tool
async def gather_context( async def gather_context(
ctx2: RunContext[DeepQADependencies], query: str, limit: int = 6 ctx2: RunContext[DeepQADependencies], query: str, limit: int = 6
) -> str: ) -> str:
results = await ctx2.deps.client.search(query, limit=limit) results = await ctx2.deps.client.search(query, limit=limit)
expanded = await ctx2.deps.client.expand_context(results) expanded = await ctx2.deps.client.expand_context(results)
return "\n\n".join(chunk.content for chunk, _ in expanded) return "\n\n".join(chunk.content for chunk, _ in expanded)
prompt = ( prompt = (
"Plan a focused approach for the main question.\n\n" "Plan a focused approach for the main question.\n\n"
f"Main question: {state.context.original_question}" f"Main question: {state.context.original_question}"
) )
agent_deps = DeepQADependencies( agent_deps = DeepQADependencies(
client=deps.client, client=deps.client,
context=state.context, context=state.context,
console=deps.console, console=None,
) )
plan_result = await plan_agent.run(prompt, deps=agent_deps) plan_result = await plan_agent.run(prompt, deps=agent_deps)
state.context.sub_questions = list(plan_result.output.sub_questions) state.context.sub_questions = list(plan_result.output.sub_questions)
log(deps, state, "\n[bold green]✅ Plan Created:[/bold green]") if deps.agui_emitter:
log( deps.agui_emitter.update_state(state)
deps, count = len(state.context.sub_questions)
state, deps.agui_emitter.update_activity(
f" [bold]Main Question:[/bold] {state.context.original_question}", "planning", f"Created plan with {count} sub-questions"
) )
log(deps, state, " [bold]Sub-questions:[/bold]") finally:
for i, sq in enumerate(state.context.sub_questions, 1): if deps.agui_emitter:
log(deps, state, f" {i}. {sq}") deps.agui_emitter.finish_step()
@g.step @g.step
async def search_one( async def search_one(
@ -112,11 +115,8 @@ def build_deep_qa_graph(
deps: DeepQADeps, deps: DeepQADeps,
sub_q: str, sub_q: str,
) -> SearchAnswer: ) -> SearchAnswer:
log( if deps.agui_emitter:
deps, deps.agui_emitter.update_activity("searching", f"Searching: {sub_q}")
state,
f"\n[bold cyan]🔍 Searching & Answering:[/bold cyan] {sub_q}",
)
agent = Agent( agent = Agent(
model=get_model(provider, model), model=get_model(provider, model),
@ -151,20 +151,18 @@ def build_deep_qa_graph(
agent_deps = DeepQADependencies( agent_deps = DeepQADependencies(
client=deps.client, client=deps.client,
context=state.context, context=state.context,
console=deps.console, console=None,
) )
try: try:
result = await agent.run(sub_q, deps=agent_deps) result = await agent.run(sub_q, deps=agent_deps)
answer = result.output answer = result.output
if answer: if answer:
state.context.add_qa_response(answer) state.context.add_qa_response(answer)
preview = answer.answer[:150] + ( if deps.agui_emitter:
"" if len(answer.answer) > 150 else "" deps.agui_emitter.update_state(state)
) deps.agui_emitter.update_activity("searching", f"Answered: {sub_q}")
log(deps, state, f" [green]✓[/green] {preview}")
return answer return answer
except Exception as e: except Exception as e:
log(deps, state, f"[red]Search failed:[/red] {e}")
failure_answer = SearchAnswer( failure_answer = SearchAnswer(
query=sub_q, query=sub_q,
answer=f"Search failed after retries: {str(e)}", answer=f"Search failed after retries: {str(e)}",
@ -194,81 +192,69 @@ def build_deep_qa_graph(
state = ctx.state state = ctx.state
deps = ctx.deps deps = ctx.deps
log( if deps.agui_emitter:
deps, deps.agui_emitter.start_step("decide")
state, deps.agui_emitter.update_activity(
"\n[bold cyan]📊 Evaluating information sufficiency...[/bold cyan]", "evaluating", "Evaluating information sufficiency"
)
agent = Agent(
model=get_model(provider, model),
output_type=DeepQAEvaluation,
instructions=DECISION_PROMPT,
retries=3,
deps_type=DeepQADependencies,
)
context_data = {
"original_question": state.context.original_question,
"gathered_answers": [
{
"question": qa.query,
"answer": qa.answer,
"sources": qa.sources,
}
for qa in state.context.qa_responses
],
}
context_xml = format_as_xml(context_data, root_tag="gathered_information")
prompt = (
"Evaluate whether we have sufficient information to answer the question.\n\n"
f"{context_xml}"
)
agent_deps = DeepQADependencies(
client=deps.client,
context=state.context,
console=deps.console,
)
result = await agent.run(prompt, deps=agent_deps)
evaluation = result.output
state.iterations += 1
log(deps, state, f" [bold]Assessment:[/bold] {evaluation.reasoning}")
status = "[green]Yes[/green]" if evaluation.is_sufficient else "[red]No[/red]"
log(deps, state, f" Sufficient: {status}")
for new_q in evaluation.new_questions:
if new_q not in state.context.sub_questions:
state.context.sub_questions.append(new_q)
if evaluation.new_questions:
log(deps, state, " [cyan]New questions:[/cyan]")
for question in evaluation.new_questions:
log(deps, state, f"{question}")
should_continue = (
not evaluation.is_sufficient and state.iterations < state.max_iterations
)
if not should_continue:
if state.iterations >= state.max_iterations:
log(
deps,
state,
f"\n[bold yellow]⚠️ Reached max iterations ({state.max_iterations})[/bold yellow]",
)
log(deps, state, "\n[bold green]✅ Moving to synthesis.[/bold green]")
else:
log(
deps,
state,
f"\n[bold cyan]🔄 Starting iteration {state.iterations + 1}...[/bold cyan]",
) )
return should_continue try:
agent = Agent(
model=get_model(provider, model),
output_type=DeepQAEvaluation,
instructions=DECISION_PROMPT,
retries=3,
deps_type=DeepQADependencies,
)
context_data = {
"original_question": state.context.original_question,
"gathered_answers": [
{
"question": qa.query,
"answer": qa.answer,
"sources": qa.sources,
}
for qa in state.context.qa_responses
],
}
context_xml = format_as_xml(context_data, root_tag="gathered_information")
prompt = (
"Evaluate whether we have sufficient information to answer the question.\n\n"
f"{context_xml}"
)
agent_deps = DeepQADependencies(
client=deps.client,
context=state.context,
console=None,
)
result = await agent.run(prompt, deps=agent_deps)
evaluation = result.output
state.iterations += 1
for new_q in evaluation.new_questions:
if new_q not in state.context.sub_questions:
state.context.sub_questions.append(new_q)
if deps.agui_emitter:
deps.agui_emitter.update_state(state)
status = "sufficient" if evaluation.is_sufficient else "insufficient"
deps.agui_emitter.update_activity(
"evaluating",
f"Information {status} after {state.iterations} iteration(s)",
)
should_continue = (
not evaluation.is_sufficient and state.iterations < state.max_iterations
)
return should_continue
finally:
if deps.agui_emitter:
deps.agui_emitter.finish_step()
@g.step @g.step
async def synthesize( async def synthesize(
@ -277,50 +263,56 @@ def build_deep_qa_graph(
state = ctx.state state = ctx.state
deps = ctx.deps deps = ctx.deps
log( if deps.agui_emitter:
deps, deps.agui_emitter.start_step("synthesize")
state, deps.agui_emitter.update_activity(
"\n[bold cyan]📝 Synthesizing final answer...[/bold cyan]", "synthesizing", "Synthesizing final answer"
) )
prompt_template = ( try:
SYNTHESIS_PROMPT_WITH_CITATIONS prompt_template = (
if state.context.use_citations SYNTHESIS_PROMPT_WITH_CITATIONS
else SYNTHESIS_PROMPT if state.context.use_citations
) else SYNTHESIS_PROMPT
)
agent = Agent( agent = Agent(
model=get_model(provider, model), model=get_model(provider, model),
output_type=DeepQAAnswer, output_type=DeepQAAnswer,
instructions=prompt_template, instructions=prompt_template,
retries=3, retries=3,
deps_type=DeepQADependencies, deps_type=DeepQADependencies,
) )
context_data = { context_data = {
"original_question": state.context.original_question, "original_question": state.context.original_question,
"sub_answers": [ "sub_answers": [
{ {
"question": qa.query, "question": qa.query,
"answer": qa.answer, "answer": qa.answer,
"sources": qa.sources, "sources": qa.sources,
} }
for qa in state.context.qa_responses for qa in state.context.qa_responses
], ],
} }
context_xml = format_as_xml(context_data, root_tag="gathered_information") context_xml = format_as_xml(context_data, root_tag="gathered_information")
prompt = f"Synthesize a comprehensive answer to the original question.\n\n{context_xml}" prompt = f"Synthesize a comprehensive answer to the original question.\n\n{context_xml}"
agent_deps = DeepQADependencies( agent_deps = DeepQADependencies(
client=deps.client, client=deps.client,
context=state.context, context=state.context,
console=deps.console, console=None,
) )
result = await agent.run(prompt, deps=agent_deps) result = await agent.run(prompt, deps=agent_deps)
log(deps, state, "[bold green]✅ Answer complete![/bold green]") if deps.agui_emitter:
return result.output deps.agui_emitter.update_activity("synthesizing", "Answer complete")
return result.output
finally:
if deps.agui_emitter:
deps.agui_emitter.finish_step()
# Build the graph structure # Build the graph structure
collect_answers = g.join( collect_answers = g.join(

View file

@ -1,8 +1,8 @@
import asyncio import asyncio
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any
from rich.console import Console from pydantic import BaseModel, Field
from haiku.rag.client import HaikuRAG from haiku.rag.client import HaikuRAG
from haiku.rag.qa.deep.dependencies import DeepQAContext from haiku.rag.qa.deep.dependencies import DeepQAContext
@ -14,21 +14,26 @@ if TYPE_CHECKING:
@dataclass @dataclass
class DeepQADeps: class DeepQADeps:
client: HaikuRAG client: HaikuRAG
console: Console | None = None agui_emitter: Any | None = None
semaphore: asyncio.Semaphore | None = None semaphore: asyncio.Semaphore | None = None
def emit_log(self, message: str, state: "DeepQAState | None" = None) -> None:
if self.console:
self.console.print(message)
class DeepQAState(BaseModel):
"""Deep QA state for multi-agent question answering."""
@dataclass model_config = {"arbitrary_types_allowed": True}
class DeepQAState:
context: DeepQAContext context: DeepQAContext = Field(description="Shared QA context")
max_sub_questions: int = 3 max_sub_questions: int = Field(
max_iterations: int = 2 default=3, description="Maximum number of sub-questions"
max_concurrency: int = 1 )
iterations: int = 0 max_iterations: int = Field(
default=2, description="Maximum number of QA iterations"
)
max_concurrency: int = Field(
default=1, description="Maximum parallel sub-question searches"
)
iterations: int = Field(default=0, description="Current iteration number")
@classmethod @classmethod
def from_config(cls, context: DeepQAContext, config: "AppConfig") -> "DeepQAState": def from_config(cls, context: DeepQAContext, config: "AppConfig") -> "DeepQAState":

View file

@ -401,12 +401,13 @@ async def test_ask_with_deep_and_cite(app: HaikuRAGApp, monkeypatch):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_ask_with_deep_and_verbose(app: HaikuRAGApp, monkeypatch): async def test_ask_with_deep_and_verbose(app: HaikuRAGApp, monkeypatch):
"""Test asking a question with deep QA and verbose output.""" """Test asking a question with deep QA and verbose output."""
from haiku.rag.qa.deep.models import DeepQAAnswer
mock_output = DeepQAAnswer(answer="Deep QA answer", sources=["test.md"]) mock_output = {"answer": "Deep QA answer", "sources": ["test.md"]}
mock_renderer = AsyncMock()
mock_renderer.render.return_value = mock_output
mock_graph = AsyncMock() mock_graph = AsyncMock()
mock_graph.run.return_value = mock_output
mock_client = AsyncMock() mock_client = AsyncMock()
mock_client.__aenter__.return_value = mock_client mock_client.__aenter__.return_value = mock_client
@ -418,8 +419,11 @@ async def test_ask_with_deep_and_verbose(app: HaikuRAGApp, monkeypatch):
with patch( with patch(
"haiku.rag.qa.deep.graph.build_deep_qa_graph", return_value=mock_graph "haiku.rag.qa.deep.graph.build_deep_qa_graph", return_value=mock_graph
): ):
await app.ask("test question", deep=True, verbose=True) with patch(
"haiku.rag.agui.AGUIConsoleRenderer", return_value=mock_renderer
):
await app.ask("test question", deep=True, verbose=True)
mock_graph.run.assert_called_once() # With verbose, it should use AGUIConsoleRenderer.render, not graph.run
call_kwargs = mock_graph.run.call_args[1] mock_renderer.render.assert_called_once()
assert call_kwargs["deps"].console is not None mock_graph.run.assert_not_called()

View file

@ -30,7 +30,7 @@ async def test_deep_qa_graph_end_to_end(monkeypatch, temp_db_path):
# Use real client but with TestModel for LLM calls # Use real client but with TestModel for LLM calls
client = HaikuRAG(temp_db_path) client = HaikuRAG(temp_db_path)
deps = DeepQADeps(client=client, console=None) deps = DeepQADeps(client=client)
result = await graph.run(state=state, deps=deps) result = await graph.run(state=state, deps=deps)
@ -62,7 +62,7 @@ async def test_deep_qa_with_citations(monkeypatch, temp_db_path):
# Use real client but with TestModel for LLM calls # Use real client but with TestModel for LLM calls
client = HaikuRAG(temp_db_path) client = HaikuRAG(temp_db_path)
deps = DeepQADeps(client=client, console=None) deps = DeepQADeps(client=client)
result = await graph.run(state=state, deps=deps) result = await graph.run(state=state, deps=deps)