Stream research

This commit is contained in:
Yiorgis Gozadinos 2025-09-24 11:02:06 +03:00
parent 9769061f54
commit 086eb56d34
No known key found for this signature in database
11 changed files with 259 additions and 41 deletions

View file

@ -18,6 +18,7 @@ from haiku.rag.research.graph import (
ResearchState, ResearchState,
build_research_graph, build_research_graph,
) )
from haiku.rag.research.stream import stream_research_graph
from haiku.rag.store.models.chunk import Chunk from haiku.rag.store.models.chunk import Chunk
from haiku.rag.store.models.document import Document from haiku.rag.store.models.document import Document
@ -236,22 +237,20 @@ class HaikuRAGApp:
provider=Config.RESEARCH_PROVIDER or Config.QA_PROVIDER, provider=Config.RESEARCH_PROVIDER or Config.QA_PROVIDER,
model=Config.RESEARCH_MODEL or Config.QA_MODEL, model=Config.RESEARCH_MODEL or Config.QA_MODEL,
) )
# Prefer graph.run; fall back to iter if unavailable
report = None report = None
try: async for event in stream_research_graph(graph, start, state, deps):
result = await graph.run(start, state=state, deps=deps) if event.type == "report":
report = result.output report = event.report
except Exception: break
from pydantic_graph import End if event.type == "error":
self.console.print(
f"[red]Error during research: {event.message}[/red]"
)
return
async with graph.iter(start, state=state, deps=deps) as run:
node = run.next_node
while not isinstance(node, End):
node = await run.next(node)
if run.result:
report = run.result.output
if report is None: if report is None:
raise RuntimeError("Graph did not produce a report") self.console.print("[red]Research did not produce a report.[/red]")
return
# Display the report # Display the report
self.console.print("[bold green]Research Report[/bold green]") self.console.print("[bold green]Research Report[/bold green]")

View file

@ -6,6 +6,11 @@ from haiku.rag.research.graph import (
build_research_graph, build_research_graph,
) )
from haiku.rag.research.models import EvaluationResult, ResearchReport, SearchAnswer from haiku.rag.research.models import EvaluationResult, ResearchReport, SearchAnswer
from haiku.rag.research.stream import (
ResearchStateSnapshot,
ResearchStreamEvent,
stream_research_graph,
)
__all__ = [ __all__ = [
"ResearchDependencies", "ResearchDependencies",
@ -17,4 +22,7 @@ __all__ = [
"ResearchState", "ResearchState",
"PlanNode", "PlanNode",
"build_research_graph", "build_research_graph",
"stream_research_graph",
"ResearchStreamEvent",
"ResearchStateSnapshot",
] ]

View file

@ -1,4 +1,4 @@
from typing import Any from typing import TYPE_CHECKING, Any
from pydantic_ai import format_as_xml from pydantic_ai import format_as_xml
from pydantic_ai.models.openai import OpenAIChatModel from pydantic_ai.models.openai import OpenAIChatModel
@ -8,6 +8,9 @@ from pydantic_ai.providers.openai import OpenAIProvider
from haiku.rag.config import Config from haiku.rag.config import Config
from haiku.rag.research.dependencies import ResearchContext from haiku.rag.research.dependencies import ResearchContext
if TYPE_CHECKING: # pragma: no cover
from haiku.rag.research.state import ResearchDeps, ResearchState
def get_model(provider: str, model: str) -> Any: def get_model(provider: str, model: str) -> Any:
if provider == "ollama": if provider == "ollama":
@ -27,9 +30,8 @@ def get_model(provider: str, model: str) -> Any:
return f"{provider}:{model}" return f"{provider}:{model}"
def log(console, msg: str) -> None: def log(deps: "ResearchDeps", state: "ResearchState", msg: str) -> None:
if console: deps.emit_log(msg, state)
console.print(msg)
def format_context_for_prompt(context: ResearchContext) -> str: def format_context_for_prompt(context: ResearchContext) -> str:

View file

@ -3,6 +3,7 @@ from rich.console import Console
from haiku.rag.client import HaikuRAG from haiku.rag.client import HaikuRAG
from haiku.rag.research.models import SearchAnswer from haiku.rag.research.models import SearchAnswer
from haiku.rag.research.stream import ResearchStream
class ResearchContext(BaseModel): class ResearchContext(BaseModel):
@ -45,3 +46,6 @@ class ResearchDependencies(BaseModel):
client: HaikuRAG = Field(description="RAG client for document operations") client: HaikuRAG = Field(description="RAG client for document operations")
context: ResearchContext = Field(description="Shared research context") context: ResearchContext = Field(description="Shared research context")
console: Console | None = None console: Console | None = None
stream: ResearchStream | None = Field(
default=None, description="Optional research event stream"
)

View file

@ -25,7 +25,8 @@ class EvaluateNode(BaseNode[ResearchState, ResearchDeps, ResearchReport]):
deps = ctx.deps deps = ctx.deps
log( log(
deps.console, deps,
state,
"\n[bold cyan]📊 Analyzing and evaluating research progress...[/bold cyan]", "\n[bold cyan]📊 Analyzing and evaluating research progress...[/bold cyan]",
) )
@ -43,7 +44,10 @@ class EvaluateNode(BaseNode[ResearchState, ResearchDeps, ResearchReport]):
f"{context_xml}" f"{context_xml}"
) )
agent_deps = ResearchDependencies( agent_deps = ResearchDependencies(
client=deps.client, context=state.context, console=deps.console client=deps.client,
context=state.context,
console=deps.console,
stream=deps.stream,
) )
eval_result = await agent.run(prompt, deps=agent_deps) eval_result = await agent.run(prompt, deps=agent_deps)
output = eval_result.output output = eval_result.output
@ -58,15 +62,16 @@ class EvaluateNode(BaseNode[ResearchState, ResearchDeps, ResearchReport]):
state.iterations += 1 state.iterations += 1
if output.key_insights: if output.key_insights:
log(deps.console, " [bold]Key insights:[/bold]") log(deps, state, " [bold]Key insights:[/bold]")
for ins in output.key_insights: for ins in output.key_insights:
log(deps.console, f"{ins}") log(deps, state, f"{ins}")
log( log(
deps.console, deps,
state,
f" Confidence: [yellow]{output.confidence_score:.1%}[/yellow]", f" Confidence: [yellow]{output.confidence_score:.1%}[/yellow]",
) )
status = "[green]Yes[/green]" if output.is_sufficient else "[red]No[/red]" status = "[green]Yes[/green]" if output.is_sufficient else "[red]No[/red]"
log(deps.console, f" Sufficient: {status}") log(deps, state, f" Sufficient: {status}")
from haiku.rag.research.nodes.search import SearchDispatchNode from haiku.rag.research.nodes.search import SearchDispatchNode
@ -74,7 +79,7 @@ class EvaluateNode(BaseNode[ResearchState, ResearchDeps, ResearchReport]):
output.is_sufficient output.is_sufficient
and output.confidence_score >= state.confidence_threshold and output.confidence_score >= state.confidence_threshold
) or state.iterations >= state.max_iterations: ) or state.iterations >= state.max_iterations:
log(deps.console, "\n[bold green]✅ Stopping research.[/bold green]") log(deps, state, "\n[bold green]✅ Stopping research.[/bold green]")
return SynthesizeNode(self.provider, self.model) return SynthesizeNode(self.provider, self.model)
return SearchDispatchNode(self.provider, self.model) return SearchDispatchNode(self.provider, self.model)

View file

@ -22,7 +22,7 @@ class PlanNode(BaseNode[ResearchState, ResearchDeps, ResearchReport]):
state = ctx.state state = ctx.state
deps = ctx.deps deps = ctx.deps
log(deps.console, "\n[bold cyan]📋 Creating research plan...[/bold cyan]") log(deps, state, "\n[bold cyan]📋 Creating research plan...[/bold cyan]")
plan_agent = Agent( plan_agent = Agent(
model=get_model(self.provider, self.model), model=get_model(self.provider, self.model),
@ -49,15 +49,18 @@ class PlanNode(BaseNode[ResearchState, ResearchDeps, ResearchReport]):
) )
agent_deps = ResearchDependencies( agent_deps = ResearchDependencies(
client=deps.client, context=state.context, console=deps.console client=deps.client,
context=state.context,
console=deps.console,
stream=deps.stream,
) )
plan_result = await plan_agent.run(prompt, deps=agent_deps) plan_result = await plan_agent.run(prompt, deps=agent_deps)
state.sub_questions = list(plan_result.output.sub_questions) state.sub_questions = list(plan_result.output.sub_questions)
log(deps.console, "\n[bold green]✅ Research Plan Created:[/bold green]") log(deps, state, "\n[bold green]✅ Research Plan Created:[/bold green]")
log(deps.console, f" [bold]Main Question:[/bold] {state.question}") log(deps, state, f" [bold]Main Question:[/bold] {state.question}")
log(deps.console, " [bold]Sub-questions:[/bold]") log(deps, state, " [bold]Sub-questions:[/bold]")
for i, sq in enumerate(state.sub_questions, 1): for i, sq in enumerate(state.sub_questions, 1):
log(deps.console, f" {i}. {sq}") log(deps, state, f" {i}. {sq}")
return SearchDispatchNode(self.provider, self.model) return SearchDispatchNode(self.provider, self.model)

View file

@ -37,7 +37,8 @@ class SearchDispatchNode(BaseNode[ResearchState, ResearchDeps, ResearchReport]):
async def answer_one(sub_q: str) -> SearchAnswer | None: async def answer_one(sub_q: str) -> SearchAnswer | None:
log( log(
deps.console, deps,
state,
f"\n[bold cyan]🔍 Searching & Answering:[/bold cyan] {sub_q}", f"\n[bold cyan]🔍 Searching & Answering:[/bold cyan] {sub_q}",
) )
agent = Agent( agent = Agent(
@ -71,12 +72,15 @@ class SearchDispatchNode(BaseNode[ResearchState, ResearchDeps, ResearchReport]):
return format_as_xml(entries, root_tag="snippets") return format_as_xml(entries, root_tag="snippets")
agent_deps = ResearchDependencies( agent_deps = ResearchDependencies(
client=deps.client, context=state.context, console=deps.console client=deps.client,
context=state.context,
console=deps.console,
stream=deps.stream,
) )
try: try:
result = await agent.run(sub_q, deps=agent_deps) result = await agent.run(sub_q, deps=agent_deps)
except Exception as e: except Exception as e:
log(deps.console, f"[red]Search failed:[/red] {e}") log(deps, state, f"[red]Search failed:[/red] {e}")
return None return None
return result.output return result.output
@ -86,8 +90,7 @@ class SearchDispatchNode(BaseNode[ResearchState, ResearchDeps, ResearchReport]):
if ans is None: if ans is None:
continue continue
state.context.add_qa_response(ans) state.context.add_qa_response(ans)
if deps.console: preview = ans.answer[:150] + ("" if len(ans.answer) > 150 else "")
preview = ans.answer[:150] + ("" if len(ans.answer) > 150 else "") log(deps, state, f" [green]✓[/green] {preview}")
log(deps.console, f" [green]✓[/green] {preview}")
return SearchDispatchNode(self.provider, self.model) return SearchDispatchNode(self.provider, self.model)

View file

@ -24,7 +24,8 @@ class SynthesizeNode(BaseNode[ResearchState, ResearchDeps, ResearchReport]):
deps = ctx.deps deps = ctx.deps
log( log(
deps.console, deps,
state,
"\n[bold cyan]📝 Generating final research report...[/bold cyan]", "\n[bold cyan]📝 Generating final research report...[/bold cyan]",
) )
@ -43,9 +44,12 @@ class SynthesizeNode(BaseNode[ResearchState, ResearchDeps, ResearchReport]):
"Create a detailed report that synthesizes all findings into a coherent response." "Create a detailed report that synthesizes all findings into a coherent response."
) )
agent_deps = ResearchDependencies( agent_deps = ResearchDependencies(
client=deps.client, context=state.context, console=deps.console client=deps.client,
context=state.context,
console=deps.console,
stream=deps.stream,
) )
result = await agent.run(prompt, deps=agent_deps) result = await agent.run(prompt, deps=agent_deps)
log(deps.console, "[bold green]✅ Research complete![/bold green]") log(deps, state, "[bold green]✅ Research complete![/bold green]")
return End(result.output) return End(result.output)

View file

@ -5,12 +5,20 @@ from rich.console import Console
from haiku.rag.client import HaikuRAG from haiku.rag.client import HaikuRAG
from haiku.rag.research.dependencies import ResearchContext from haiku.rag.research.dependencies import ResearchContext
from haiku.rag.research.models import EvaluationResult from haiku.rag.research.models import EvaluationResult
from haiku.rag.research.stream import ResearchStream
@dataclass @dataclass
class ResearchDeps: class ResearchDeps:
client: HaikuRAG client: HaikuRAG
console: Console | None = None console: Console | None = None
stream: ResearchStream | None = None
def emit_log(self, message: str, state: "ResearchState | None" = None) -> None:
if self.console:
self.console.print(message)
if self.stream:
self.stream.log(message, state)
@dataclass @dataclass

View file

@ -0,0 +1,171 @@
import asyncio
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
from haiku.rag.research.models import ResearchReport
if TYPE_CHECKING: # pragma: no cover
from haiku.rag.research.state import ResearchState
@dataclass(slots=True)
class ResearchStateSnapshot:
question: str
sub_questions: list[str]
iterations: int
max_iterations: int
max_concurrency: int
confidence_threshold: float
pending_sub_questions: int
answered_questions: int
insights: list[str]
gaps: list[str]
last_confidence: float | None
last_sufficient: bool | None
@classmethod
def from_state(cls, state: "ResearchState") -> "ResearchStateSnapshot":
context = state.context
last_confidence: float | None = None
last_sufficient: bool | None = None
if state.last_eval:
last_confidence = state.last_eval.confidence_score
last_sufficient = state.last_eval.is_sufficient
return cls(
question=state.question,
sub_questions=list(state.sub_questions),
iterations=state.iterations,
max_iterations=state.max_iterations,
max_concurrency=state.max_concurrency,
confidence_threshold=state.confidence_threshold,
pending_sub_questions=len(state.sub_questions),
answered_questions=len(context.qa_responses),
insights=list(context.insights),
gaps=list(context.gaps),
last_confidence=last_confidence,
last_sufficient=last_sufficient,
)
@dataclass(slots=True)
class ResearchStreamEvent:
type: Literal["log", "report", "error"]
message: str | None = None
state: ResearchStateSnapshot | None = None
report: ResearchReport | None = None
error: str | None = None
class ResearchStream:
"""Queue-backed stream for research graph events."""
def __init__(self) -> None:
self._queue: asyncio.Queue[ResearchStreamEvent | None] = asyncio.Queue()
self._closed = False
def _snapshot(self, state: "ResearchState | None") -> ResearchStateSnapshot | None:
if state is None:
return None
return ResearchStateSnapshot.from_state(state)
def log(self, message: str, state: "ResearchState | None" = None) -> None:
if self._closed:
return
event = ResearchStreamEvent(
type="log", message=message, state=self._snapshot(state)
)
self._queue.put_nowait(event)
def report(self, report: ResearchReport, state: "ResearchState") -> None:
if self._closed:
return
event = ResearchStreamEvent(
type="report",
report=report,
state=self._snapshot(state),
)
self._queue.put_nowait(event)
def error(self, error: Exception, state: "ResearchState | None" = None) -> None:
if self._closed:
return
event = ResearchStreamEvent(
type="error",
message=str(error),
error=str(error),
state=self._snapshot(state),
)
self._queue.put_nowait(event)
async def close(self) -> None:
if self._closed:
return
self._closed = True
await self._queue.put(None)
def __aiter__(self) -> AsyncIterator[ResearchStreamEvent]:
return self._iter_events()
async def _iter_events(self) -> AsyncIterator[ResearchStreamEvent]:
while True:
event = await self._queue.get()
if event is None:
break
yield event
async def stream_research_graph(
graph,
start,
state: "ResearchState",
deps,
) -> AsyncIterator[ResearchStreamEvent]:
"""Run the research graph and yield streaming events as they occur."""
from contextlib import suppress
from haiku.rag.research.state import ResearchDeps # Local import to avoid cycle
if not isinstance(deps, ResearchDeps):
raise TypeError("deps must be an instance of ResearchDeps")
stream = ResearchStream()
deps.stream = stream
async def _execute() -> None:
try:
report = None
try:
result = await graph.run(start, state=state, deps=deps)
report = result.output
except Exception:
from pydantic_graph import End
async with graph.iter(start, state=state, deps=deps) as run:
node = run.next_node
while not isinstance(node, End):
node = await run.next(node)
if run.result:
report = run.result.output
if report is None:
raise RuntimeError("Graph did not produce a report")
stream.report(report, state)
except Exception as exc: # pragma: no cover - defensive path
stream.error(exc, state)
finally:
await stream.close()
runner = asyncio.create_task(_execute())
try:
async for event in stream:
yield event
finally:
if not runner.done():
runner.cancel()
with suppress(asyncio.CancelledError):
await runner

View file

@ -13,6 +13,7 @@ from haiku.rag.research.graph import (
build_research_graph, build_research_graph,
) )
from haiku.rag.research.models import EvaluationResult, ResearchReport, SearchAnswer from haiku.rag.research.models import EvaluationResult, ResearchReport, SearchAnswer
from haiku.rag.research.stream import stream_research_graph
@pytest.mark.asyncio @pytest.mark.asyncio
@ -35,6 +36,7 @@ async def test_graph_end_to_end_with_patched_nodes(monkeypatch):
"Describe haiku.rag in one sentence", "Describe haiku.rag in one sentence",
"List core components of haiku.rag", "List core components of haiku.rag",
] ]
ctx.deps.emit_log("planning", ctx.state)
return SearchDispatchNode(self.provider, self.model) return SearchDispatchNode(self.provider, self.model)
async def fake_search_dispatch_run(self, ctx) -> Any: async def fake_search_dispatch_run(self, ctx) -> Any:
@ -45,6 +47,7 @@ async def test_graph_end_to_end_with_patched_nodes(monkeypatch):
ctx.state.context.add_qa_response( ctx.state.context.add_qa_response(
SearchAnswer(query=q, answer="A", context=["x"], sources=["s"]) # pyright: ignore[reportCallIssue] SearchAnswer(query=q, answer="A", context=["x"], sources=["s"]) # pyright: ignore[reportCallIssue]
) )
ctx.deps.emit_log(f"answered:{q}", ctx.state)
return EvaluateNode(self.provider, self.model) return EvaluateNode(self.provider, self.model)
async def fake_evaluate_run(self, ctx) -> Any: async def fake_evaluate_run(self, ctx) -> Any:
@ -56,6 +59,7 @@ async def test_graph_end_to_end_with_patched_nodes(monkeypatch):
reasoning="done", reasoning="done",
) )
ctx.state.iterations += 1 ctx.state.iterations += 1
ctx.deps.emit_log("evaluated", ctx.state)
return SynthesizeNode(self.provider, self.model) return SynthesizeNode(self.provider, self.model)
async def fake_synthesize_run(self, ctx) -> Any: async def fake_synthesize_run(self, ctx) -> Any:
@ -81,9 +85,16 @@ async def test_graph_end_to_end_with_patched_nodes(monkeypatch):
start = PlanNode(provider="test", model="test") start = PlanNode(provider="test", model="test")
result = await graph.run(start, state=state, deps=deps) collected = []
report = result.output async for event in stream_research_graph(graph, start, state, deps):
collected.append(event)
if event.type == "report":
report = event.report
break
else: # pragma: no cover - defensive guard
report = None
assert isinstance(report, ResearchReport) assert isinstance(report, ResearchReport)
assert report.title == "Haiku RAG" assert report.title == "Haiku RAG"
assert len(state.context.qa_responses) == 2 assert len(state.context.qa_responses) == 2
assert any(evt.type == "log" for evt in collected)