haiku.rag 0.10.2__py3-none-any.whl → 0.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of haiku.rag might be problematic. Click here for more details.
- haiku/rag/app.py +15 -16
- haiku/rag/research/__init__.py +8 -0
- haiku/rag/research/common.py +71 -6
- haiku/rag/research/dependencies.py +179 -11
- haiku/rag/research/graph.py +5 -3
- haiku/rag/research/models.py +134 -1
- haiku/rag/research/nodes/analysis.py +181 -0
- haiku/rag/research/nodes/plan.py +16 -9
- haiku/rag/research/nodes/search.py +14 -11
- haiku/rag/research/nodes/synthesize.py +7 -3
- haiku/rag/research/prompts.py +67 -28
- haiku/rag/research/state.py +11 -4
- haiku/rag/research/stream.py +177 -0
- {haiku_rag-0.10.2.dist-info → haiku_rag-0.11.0.dist-info}/METADATA +32 -13
- {haiku_rag-0.10.2.dist-info → haiku_rag-0.11.0.dist-info}/RECORD +18 -17
- haiku/rag/research/nodes/evaluate.py +0 -80
- {haiku_rag-0.10.2.dist-info → haiku_rag-0.11.0.dist-info}/WHEEL +0 -0
- {haiku_rag-0.10.2.dist-info → haiku_rag-0.11.0.dist-info}/entry_points.txt +0 -0
- {haiku_rag-0.10.2.dist-info → haiku_rag-0.11.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from pydantic_ai import Agent
|
|
4
|
+
from pydantic_graph import BaseNode, GraphRunContext
|
|
5
|
+
|
|
6
|
+
from haiku.rag.research.common import (
|
|
7
|
+
format_analysis_for_prompt,
|
|
8
|
+
format_context_for_prompt,
|
|
9
|
+
get_model,
|
|
10
|
+
log,
|
|
11
|
+
)
|
|
12
|
+
from haiku.rag.research.dependencies import ResearchDependencies
|
|
13
|
+
from haiku.rag.research.models import EvaluationResult, InsightAnalysis, ResearchReport
|
|
14
|
+
from haiku.rag.research.nodes.synthesize import SynthesizeNode
|
|
15
|
+
from haiku.rag.research.prompts import DECISION_AGENT_PROMPT, INSIGHT_AGENT_PROMPT
|
|
16
|
+
from haiku.rag.research.state import ResearchDeps, ResearchState
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class AnalyzeInsightsNode(BaseNode[ResearchState, ResearchDeps, ResearchReport]):
|
|
21
|
+
provider: str
|
|
22
|
+
model: str
|
|
23
|
+
|
|
24
|
+
async def run(
|
|
25
|
+
self, ctx: GraphRunContext[ResearchState, ResearchDeps]
|
|
26
|
+
) -> BaseNode[ResearchState, ResearchDeps, ResearchReport]:
|
|
27
|
+
state = ctx.state
|
|
28
|
+
deps = ctx.deps
|
|
29
|
+
|
|
30
|
+
log(
|
|
31
|
+
deps,
|
|
32
|
+
state,
|
|
33
|
+
"\n[bold cyan]🧭 Synthesizing new insights and gap status...[/bold cyan]",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
agent = Agent(
|
|
37
|
+
model=get_model(self.provider, self.model),
|
|
38
|
+
output_type=InsightAnalysis,
|
|
39
|
+
instructions=INSIGHT_AGENT_PROMPT,
|
|
40
|
+
retries=3,
|
|
41
|
+
deps_type=ResearchDependencies,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
context_xml = format_context_for_prompt(state.context)
|
|
45
|
+
prompt = (
|
|
46
|
+
"Review the latest research context and update the shared ledger of insights, gaps,"
|
|
47
|
+
" and follow-up questions.\n\n"
|
|
48
|
+
f"{context_xml}"
|
|
49
|
+
)
|
|
50
|
+
agent_deps = ResearchDependencies(
|
|
51
|
+
client=deps.client,
|
|
52
|
+
context=state.context,
|
|
53
|
+
console=deps.console,
|
|
54
|
+
stream=deps.stream,
|
|
55
|
+
)
|
|
56
|
+
result = await agent.run(prompt, deps=agent_deps)
|
|
57
|
+
analysis: InsightAnalysis = result.output
|
|
58
|
+
|
|
59
|
+
state.context.integrate_analysis(analysis)
|
|
60
|
+
state.last_analysis = analysis
|
|
61
|
+
|
|
62
|
+
if analysis.commentary:
|
|
63
|
+
log(deps, state, f" Summary: {analysis.commentary}")
|
|
64
|
+
if analysis.highlights:
|
|
65
|
+
log(deps, state, " [bold]Updated insights:[/bold]")
|
|
66
|
+
for insight in analysis.highlights:
|
|
67
|
+
label = insight.status.value
|
|
68
|
+
log(
|
|
69
|
+
deps,
|
|
70
|
+
state,
|
|
71
|
+
f" • ({label}) {insight.summary}",
|
|
72
|
+
)
|
|
73
|
+
if analysis.gap_assessments:
|
|
74
|
+
log(deps, state, " [bold yellow]Gap updates:[/bold yellow]")
|
|
75
|
+
for gap in analysis.gap_assessments:
|
|
76
|
+
status = "resolved" if gap.resolved else "open"
|
|
77
|
+
severity = gap.severity.value
|
|
78
|
+
log(
|
|
79
|
+
deps,
|
|
80
|
+
state,
|
|
81
|
+
f" • ({severity}/{status}) {gap.description}",
|
|
82
|
+
)
|
|
83
|
+
if analysis.resolved_gaps:
|
|
84
|
+
log(deps, state, " [green]Resolved gaps:[/green]")
|
|
85
|
+
for resolved in analysis.resolved_gaps:
|
|
86
|
+
log(deps, state, f" • {resolved}")
|
|
87
|
+
if analysis.new_questions:
|
|
88
|
+
log(deps, state, " [cyan]Proposed follow-ups:[/cyan]")
|
|
89
|
+
for question in analysis.new_questions:
|
|
90
|
+
log(deps, state, f" • {question}")
|
|
91
|
+
|
|
92
|
+
return DecisionNode(self.provider, self.model)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class DecisionNode(BaseNode[ResearchState, ResearchDeps, ResearchReport]):
|
|
97
|
+
provider: str
|
|
98
|
+
model: str
|
|
99
|
+
|
|
100
|
+
async def run(
|
|
101
|
+
self, ctx: GraphRunContext[ResearchState, ResearchDeps]
|
|
102
|
+
) -> BaseNode[ResearchState, ResearchDeps, ResearchReport]:
|
|
103
|
+
state = ctx.state
|
|
104
|
+
deps = ctx.deps
|
|
105
|
+
|
|
106
|
+
log(
|
|
107
|
+
deps,
|
|
108
|
+
state,
|
|
109
|
+
"\n[bold cyan]📊 Evaluating research sufficiency...[/bold cyan]",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
agent = Agent(
|
|
113
|
+
model=get_model(self.provider, self.model),
|
|
114
|
+
output_type=EvaluationResult,
|
|
115
|
+
instructions=DECISION_AGENT_PROMPT,
|
|
116
|
+
retries=3,
|
|
117
|
+
deps_type=ResearchDependencies,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
context_xml = format_context_for_prompt(state.context)
|
|
121
|
+
analysis_xml = format_analysis_for_prompt(state.last_analysis)
|
|
122
|
+
prompt_parts = [
|
|
123
|
+
"Assess whether the research now answers the original question with adequate confidence.",
|
|
124
|
+
context_xml,
|
|
125
|
+
analysis_xml,
|
|
126
|
+
]
|
|
127
|
+
if state.last_eval is not None:
|
|
128
|
+
prev = state.last_eval
|
|
129
|
+
prompt_parts.append(
|
|
130
|
+
"<previous_evaluation>"
|
|
131
|
+
f"<confidence>{prev.confidence_score:.2f}</confidence>"
|
|
132
|
+
f"<is_sufficient>{str(prev.is_sufficient).lower()}</is_sufficient>"
|
|
133
|
+
f"<reasoning>{prev.reasoning}</reasoning>"
|
|
134
|
+
"</previous_evaluation>"
|
|
135
|
+
)
|
|
136
|
+
prompt = "\n\n".join(part for part in prompt_parts if part)
|
|
137
|
+
|
|
138
|
+
agent_deps = ResearchDependencies(
|
|
139
|
+
client=deps.client,
|
|
140
|
+
context=state.context,
|
|
141
|
+
console=deps.console,
|
|
142
|
+
stream=deps.stream,
|
|
143
|
+
)
|
|
144
|
+
decision_result = await agent.run(prompt, deps=agent_deps)
|
|
145
|
+
output = decision_result.output
|
|
146
|
+
|
|
147
|
+
state.last_eval = output
|
|
148
|
+
state.iterations += 1
|
|
149
|
+
|
|
150
|
+
for new_q in output.new_questions:
|
|
151
|
+
if new_q not in state.context.sub_questions:
|
|
152
|
+
state.context.sub_questions.append(new_q)
|
|
153
|
+
|
|
154
|
+
if output.key_insights:
|
|
155
|
+
log(deps, state, " [bold]Key insights:[/bold]")
|
|
156
|
+
for insight in output.key_insights:
|
|
157
|
+
log(deps, state, f" • {insight}")
|
|
158
|
+
|
|
159
|
+
if output.gaps:
|
|
160
|
+
log(deps, state, " [bold yellow]Remaining gaps:[/bold yellow]")
|
|
161
|
+
for gap in output.gaps:
|
|
162
|
+
log(deps, state, f" • {gap}")
|
|
163
|
+
|
|
164
|
+
log(
|
|
165
|
+
deps,
|
|
166
|
+
state,
|
|
167
|
+
f" Confidence: [yellow]{output.confidence_score:.1%}[/yellow]",
|
|
168
|
+
)
|
|
169
|
+
status = "[green]Yes[/green]" if output.is_sufficient else "[red]No[/red]"
|
|
170
|
+
log(deps, state, f" Sufficient: {status}")
|
|
171
|
+
|
|
172
|
+
from haiku.rag.research.nodes.search import SearchDispatchNode
|
|
173
|
+
|
|
174
|
+
if (
|
|
175
|
+
output.is_sufficient
|
|
176
|
+
and output.confidence_score >= state.confidence_threshold
|
|
177
|
+
) or state.iterations >= state.max_iterations:
|
|
178
|
+
log(deps, state, "\n[bold green]✅ Stopping research.[/bold green]")
|
|
179
|
+
return SynthesizeNode(self.provider, self.model)
|
|
180
|
+
|
|
181
|
+
return SearchDispatchNode(self.provider, self.model)
|
haiku/rag/research/nodes/plan.py
CHANGED
|
@@ -22,7 +22,7 @@ class PlanNode(BaseNode[ResearchState, ResearchDeps, ResearchReport]):
|
|
|
22
22
|
state = ctx.state
|
|
23
23
|
deps = ctx.deps
|
|
24
24
|
|
|
25
|
-
log(deps
|
|
25
|
+
log(deps, state, "\n[bold cyan]📋 Creating research plan...[/bold cyan]")
|
|
26
26
|
|
|
27
27
|
plan_agent = Agent(
|
|
28
28
|
model=get_model(self.provider, self.model),
|
|
@@ -45,19 +45,26 @@ class PlanNode(BaseNode[ResearchState, ResearchDeps, ResearchReport]):
|
|
|
45
45
|
|
|
46
46
|
prompt = (
|
|
47
47
|
"Plan a focused research approach for the main question.\n\n"
|
|
48
|
-
f"Main question: {state.
|
|
48
|
+
f"Main question: {state.context.original_question}"
|
|
49
49
|
)
|
|
50
50
|
|
|
51
51
|
agent_deps = ResearchDependencies(
|
|
52
|
-
client=deps.client,
|
|
52
|
+
client=deps.client,
|
|
53
|
+
context=state.context,
|
|
54
|
+
console=deps.console,
|
|
55
|
+
stream=deps.stream,
|
|
53
56
|
)
|
|
54
57
|
plan_result = await plan_agent.run(prompt, deps=agent_deps)
|
|
55
|
-
state.sub_questions = list(plan_result.output.sub_questions)
|
|
58
|
+
state.context.sub_questions = list(plan_result.output.sub_questions)
|
|
56
59
|
|
|
57
|
-
log(deps
|
|
58
|
-
log(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
log(deps, state, "\n[bold green]✅ Research Plan Created:[/bold green]")
|
|
61
|
+
log(
|
|
62
|
+
deps,
|
|
63
|
+
state,
|
|
64
|
+
f" [bold]Main Question:[/bold] {state.context.original_question}",
|
|
65
|
+
)
|
|
66
|
+
log(deps, state, " [bold]Sub-questions:[/bold]")
|
|
67
|
+
for i, sq in enumerate(state.context.sub_questions, 1):
|
|
68
|
+
log(deps, state, f" {i}. {sq}")
|
|
62
69
|
|
|
63
70
|
return SearchDispatchNode(self.provider, self.model)
|
|
@@ -24,20 +24,21 @@ class SearchDispatchNode(BaseNode[ResearchState, ResearchDeps, ResearchReport]):
|
|
|
24
24
|
) -> BaseNode[ResearchState, ResearchDeps, ResearchReport]:
|
|
25
25
|
state = ctx.state
|
|
26
26
|
deps = ctx.deps
|
|
27
|
-
if not state.sub_questions:
|
|
28
|
-
from haiku.rag.research.nodes.
|
|
27
|
+
if not state.context.sub_questions:
|
|
28
|
+
from haiku.rag.research.nodes.analysis import AnalyzeInsightsNode
|
|
29
29
|
|
|
30
|
-
return
|
|
30
|
+
return AnalyzeInsightsNode(self.provider, self.model)
|
|
31
31
|
|
|
32
32
|
# Take up to max_concurrency questions and answer them concurrently
|
|
33
33
|
take = max(1, state.max_concurrency)
|
|
34
34
|
batch: list[str] = []
|
|
35
|
-
while state.sub_questions and len(batch) < take:
|
|
36
|
-
batch.append(state.sub_questions.pop(0))
|
|
35
|
+
while state.context.sub_questions and len(batch) < take:
|
|
36
|
+
batch.append(state.context.sub_questions.pop(0))
|
|
37
37
|
|
|
38
38
|
async def answer_one(sub_q: str) -> SearchAnswer | None:
|
|
39
39
|
log(
|
|
40
|
-
deps
|
|
40
|
+
deps,
|
|
41
|
+
state,
|
|
41
42
|
f"\n[bold cyan]🔍 Searching & Answering:[/bold cyan] {sub_q}",
|
|
42
43
|
)
|
|
43
44
|
agent = Agent(
|
|
@@ -71,12 +72,15 @@ class SearchDispatchNode(BaseNode[ResearchState, ResearchDeps, ResearchReport]):
|
|
|
71
72
|
return format_as_xml(entries, root_tag="snippets")
|
|
72
73
|
|
|
73
74
|
agent_deps = ResearchDependencies(
|
|
74
|
-
client=deps.client,
|
|
75
|
+
client=deps.client,
|
|
76
|
+
context=state.context,
|
|
77
|
+
console=deps.console,
|
|
78
|
+
stream=deps.stream,
|
|
75
79
|
)
|
|
76
80
|
try:
|
|
77
81
|
result = await agent.run(sub_q, deps=agent_deps)
|
|
78
82
|
except Exception as e:
|
|
79
|
-
log(deps
|
|
83
|
+
log(deps, state, f"[red]Search failed:[/red] {e}")
|
|
80
84
|
return None
|
|
81
85
|
|
|
82
86
|
return result.output
|
|
@@ -86,8 +90,7 @@ class SearchDispatchNode(BaseNode[ResearchState, ResearchDeps, ResearchReport]):
|
|
|
86
90
|
if ans is None:
|
|
87
91
|
continue
|
|
88
92
|
state.context.add_qa_response(ans)
|
|
89
|
-
if
|
|
90
|
-
|
|
91
|
-
log(deps.console, f" [green]✓[/green] {preview}")
|
|
93
|
+
preview = ans.answer[:150] + ("…" if len(ans.answer) > 150 else "")
|
|
94
|
+
log(deps, state, f" [green]✓[/green] {preview}")
|
|
92
95
|
|
|
93
96
|
return SearchDispatchNode(self.provider, self.model)
|
|
@@ -24,7 +24,8 @@ class SynthesizeNode(BaseNode[ResearchState, ResearchDeps, ResearchReport]):
|
|
|
24
24
|
deps = ctx.deps
|
|
25
25
|
|
|
26
26
|
log(
|
|
27
|
-
deps
|
|
27
|
+
deps,
|
|
28
|
+
state,
|
|
28
29
|
"\n[bold cyan]📝 Generating final research report...[/bold cyan]",
|
|
29
30
|
)
|
|
30
31
|
|
|
@@ -43,9 +44,12 @@ class SynthesizeNode(BaseNode[ResearchState, ResearchDeps, ResearchReport]):
|
|
|
43
44
|
"Create a detailed report that synthesizes all findings into a coherent response."
|
|
44
45
|
)
|
|
45
46
|
agent_deps = ResearchDependencies(
|
|
46
|
-
client=deps.client,
|
|
47
|
+
client=deps.client,
|
|
48
|
+
context=state.context,
|
|
49
|
+
console=deps.console,
|
|
50
|
+
stream=deps.stream,
|
|
47
51
|
)
|
|
48
52
|
result = await agent.run(prompt, deps=agent_deps)
|
|
49
53
|
|
|
50
|
-
log(deps
|
|
54
|
+
log(deps, state, "[bold green]✅ Research complete![/bold green]")
|
|
51
55
|
return End(result.output)
|
haiku/rag/research/prompts.py
CHANGED
|
@@ -44,38 +44,77 @@ Answering rules:
|
|
|
44
44
|
- Prefer concise phrasing; avoid copying long passages.
|
|
45
45
|
- When evidence is partial, state the limits explicitly in the answer."""
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
INSIGHT_AGENT_PROMPT = """You are the insight aggregation specialist for the
|
|
48
|
+
research workflow.
|
|
49
49
|
|
|
50
50
|
Inputs available:
|
|
51
|
-
- Original research question
|
|
52
|
-
- Question–answer pairs
|
|
53
|
-
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
3.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
51
|
+
- Original research question and sub-questions
|
|
52
|
+
- Question–answer pairs with supporting snippets and sources
|
|
53
|
+
- Existing insights and gaps (with status metadata)
|
|
54
|
+
|
|
55
|
+
Tasks:
|
|
56
|
+
1. Extract new or refined insights that advance understanding of the question.
|
|
57
|
+
2. Update gap status, creating new gap entries when necessary and marking
|
|
58
|
+
resolved ones explicitly.
|
|
59
|
+
3. Suggest up to 3 high-impact follow-up sub_questions that would close the
|
|
60
|
+
most important remaining gaps.
|
|
61
|
+
|
|
62
|
+
Output format (map directly to fields):
|
|
63
|
+
- highlights: list of insights with fields {summary, status, supporting_sources,
|
|
64
|
+
originating_questions, notes}. Use status one of {validated, open, tentative}.
|
|
65
|
+
- gap_assessments: list of gaps with fields {description, severity, blocking,
|
|
66
|
+
resolved, resolved_by, supporting_sources, notes}. Severity must be one of
|
|
67
|
+
{low, medium, high}. resolved_by may reference related insight summaries if no
|
|
68
|
+
stable identifier yet.
|
|
69
|
+
- resolved_gaps: list of identifiers or descriptions for gaps now closed.
|
|
70
|
+
- new_questions: up to 3 standalone, specific sub-questions (no duplicates with
|
|
71
|
+
existing ones).
|
|
72
|
+
- commentary: 1–3 sentences summarizing what changed this round.
|
|
73
|
+
|
|
74
|
+
Guidance:
|
|
75
|
+
- Be concise and avoid repeating previously recorded information unless it
|
|
76
|
+
changed materially.
|
|
77
|
+
- Tie supporting_sources to the evidence used; omit if unavailable.
|
|
78
|
+
- Only propose new sub_questions that directly address remaining gaps.
|
|
79
|
+
- When marking a gap as resolved, ensure the rationale is clear via
|
|
80
|
+
resolved_by or notes."""
|
|
81
|
+
|
|
82
|
+
DECISION_AGENT_PROMPT = """You are the research governor responsible for making
|
|
83
|
+
stop/go decisions.
|
|
84
|
+
|
|
85
|
+
Inputs available:
|
|
86
|
+
- Original research question and current plan
|
|
87
|
+
- Full insight ledger with status metadata
|
|
88
|
+
- Up-to-date gap tracker, including resolved indicators
|
|
89
|
+
- Latest insight analysis summary (highlights, gap changes, new questions)
|
|
90
|
+
- Previous evaluation decision (if any)
|
|
91
|
+
|
|
92
|
+
Tasks:
|
|
93
|
+
1. Determine whether the collected evidence now answers the original question.
|
|
94
|
+
2. Provide a confidence_score in [0,1] that reflects coverage, evidence quality,
|
|
95
|
+
and agreement across sources.
|
|
96
|
+
3. List the highest-priority gaps that still block a confident answer. Reference
|
|
97
|
+
existing gap descriptions rather than inventing new ones.
|
|
98
|
+
4. Optionally propose up to 3 new sub_questions only if they are not already in
|
|
99
|
+
the current backlog.
|
|
69
100
|
|
|
70
101
|
Strictness:
|
|
71
|
-
- Only mark research as sufficient when
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
-
|
|
77
|
-
|
|
78
|
-
-
|
|
102
|
+
- Only mark research as sufficient when every critical aspect of the main
|
|
103
|
+
question is addressed with reliable, corroborated evidence.
|
|
104
|
+
- Treat unresolved high-severity or blocking gaps as a hard stop.
|
|
105
|
+
|
|
106
|
+
Output fields must line up with EvaluationResult:
|
|
107
|
+
- key_insights: concise bullet-ready statements of the most decision-relevant
|
|
108
|
+
insights (cite status if helpful).
|
|
109
|
+
- new_questions: follow-up sub-questions (max 3) meeting the specificity rules.
|
|
110
|
+
- gaps: list remaining blockers; reuse wording from the tracked gaps when
|
|
111
|
+
possible to aid downstream reconciliation.
|
|
112
|
+
- confidence_score: numeric in [0,1].
|
|
113
|
+
- is_sufficient: true only when no blocking gaps remain.
|
|
114
|
+
- reasoning: short narrative tying the decision to evidence coverage.
|
|
115
|
+
|
|
116
|
+
Remember: prefer maintaining continuity with the structured context over
|
|
117
|
+
introducing new terminology."""
|
|
79
118
|
|
|
80
119
|
SYNTHESIS_AGENT_PROMPT = """You are a synthesis specialist producing the final
|
|
81
120
|
research report.
|
haiku/rag/research/state.py
CHANGED
|
@@ -1,25 +1,32 @@
|
|
|
1
|
-
from dataclasses import dataclass
|
|
1
|
+
from dataclasses import dataclass
|
|
2
2
|
|
|
3
3
|
from rich.console import Console
|
|
4
4
|
|
|
5
5
|
from haiku.rag.client import HaikuRAG
|
|
6
6
|
from haiku.rag.research.dependencies import ResearchContext
|
|
7
|
-
from haiku.rag.research.models import EvaluationResult
|
|
7
|
+
from haiku.rag.research.models import EvaluationResult, InsightAnalysis
|
|
8
|
+
from haiku.rag.research.stream import ResearchStream
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
@dataclass
|
|
11
12
|
class ResearchDeps:
|
|
12
13
|
client: HaikuRAG
|
|
13
14
|
console: Console | None = None
|
|
15
|
+
stream: ResearchStream | None = None
|
|
16
|
+
|
|
17
|
+
def emit_log(self, message: str, state: "ResearchState | None" = None) -> None:
|
|
18
|
+
if self.console:
|
|
19
|
+
self.console.print(message)
|
|
20
|
+
if self.stream:
|
|
21
|
+
self.stream.log(message, state)
|
|
14
22
|
|
|
15
23
|
|
|
16
24
|
@dataclass
|
|
17
25
|
class ResearchState:
|
|
18
|
-
question: str
|
|
19
26
|
context: ResearchContext
|
|
20
|
-
sub_questions: list[str] = field(default_factory=list)
|
|
21
27
|
iterations: int = 0
|
|
22
28
|
max_iterations: int = 3
|
|
23
29
|
max_concurrency: int = 1
|
|
24
30
|
confidence_threshold: float = 0.8
|
|
25
31
|
last_eval: EvaluationResult | None = None
|
|
32
|
+
last_analysis: InsightAnalysis | None = None
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import AsyncIterator
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING, Literal
|
|
5
|
+
|
|
6
|
+
from haiku.rag.research.models import ResearchReport
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
9
|
+
from haiku.rag.research.state import ResearchState
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True)
|
|
13
|
+
class ResearchStateSnapshot:
|
|
14
|
+
question: str
|
|
15
|
+
sub_questions: list[str]
|
|
16
|
+
iterations: int
|
|
17
|
+
max_iterations: int
|
|
18
|
+
max_concurrency: int
|
|
19
|
+
confidence_threshold: float
|
|
20
|
+
pending_sub_questions: int
|
|
21
|
+
answered_questions: int
|
|
22
|
+
insights: list[str]
|
|
23
|
+
gaps: list[str]
|
|
24
|
+
last_confidence: float | None
|
|
25
|
+
last_sufficient: bool | None
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_state(cls, state: "ResearchState") -> "ResearchStateSnapshot":
|
|
29
|
+
context = state.context
|
|
30
|
+
last_confidence: float | None = None
|
|
31
|
+
last_sufficient: bool | None = None
|
|
32
|
+
if state.last_eval:
|
|
33
|
+
last_confidence = state.last_eval.confidence_score
|
|
34
|
+
last_sufficient = state.last_eval.is_sufficient
|
|
35
|
+
|
|
36
|
+
return cls(
|
|
37
|
+
question=context.original_question,
|
|
38
|
+
sub_questions=list(context.sub_questions),
|
|
39
|
+
iterations=state.iterations,
|
|
40
|
+
max_iterations=state.max_iterations,
|
|
41
|
+
max_concurrency=state.max_concurrency,
|
|
42
|
+
confidence_threshold=state.confidence_threshold,
|
|
43
|
+
pending_sub_questions=len(context.sub_questions),
|
|
44
|
+
answered_questions=len(context.qa_responses),
|
|
45
|
+
insights=[
|
|
46
|
+
f"{insight.status.value}:{insight.summary}"
|
|
47
|
+
for insight in context.insights
|
|
48
|
+
],
|
|
49
|
+
gaps=[
|
|
50
|
+
f"{gap.severity.value}/{'resolved' if gap.resolved else 'open'}:{gap.description}"
|
|
51
|
+
for gap in context.gaps
|
|
52
|
+
],
|
|
53
|
+
last_confidence=last_confidence,
|
|
54
|
+
last_sufficient=last_sufficient,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(slots=True)
|
|
59
|
+
class ResearchStreamEvent:
|
|
60
|
+
type: Literal["log", "report", "error"]
|
|
61
|
+
message: str | None = None
|
|
62
|
+
state: ResearchStateSnapshot | None = None
|
|
63
|
+
report: ResearchReport | None = None
|
|
64
|
+
error: str | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ResearchStream:
|
|
68
|
+
"""Queue-backed stream for research graph events."""
|
|
69
|
+
|
|
70
|
+
def __init__(self) -> None:
|
|
71
|
+
self._queue: asyncio.Queue[ResearchStreamEvent | None] = asyncio.Queue()
|
|
72
|
+
self._closed = False
|
|
73
|
+
|
|
74
|
+
def _snapshot(self, state: "ResearchState | None") -> ResearchStateSnapshot | None:
|
|
75
|
+
if state is None:
|
|
76
|
+
return None
|
|
77
|
+
return ResearchStateSnapshot.from_state(state)
|
|
78
|
+
|
|
79
|
+
def log(self, message: str, state: "ResearchState | None" = None) -> None:
|
|
80
|
+
if self._closed:
|
|
81
|
+
return
|
|
82
|
+
event = ResearchStreamEvent(
|
|
83
|
+
type="log", message=message, state=self._snapshot(state)
|
|
84
|
+
)
|
|
85
|
+
self._queue.put_nowait(event)
|
|
86
|
+
|
|
87
|
+
def report(self, report: ResearchReport, state: "ResearchState") -> None:
|
|
88
|
+
if self._closed:
|
|
89
|
+
return
|
|
90
|
+
event = ResearchStreamEvent(
|
|
91
|
+
type="report",
|
|
92
|
+
report=report,
|
|
93
|
+
state=self._snapshot(state),
|
|
94
|
+
)
|
|
95
|
+
self._queue.put_nowait(event)
|
|
96
|
+
|
|
97
|
+
def error(self, error: Exception, state: "ResearchState | None" = None) -> None:
|
|
98
|
+
if self._closed:
|
|
99
|
+
return
|
|
100
|
+
event = ResearchStreamEvent(
|
|
101
|
+
type="error",
|
|
102
|
+
message=str(error),
|
|
103
|
+
error=str(error),
|
|
104
|
+
state=self._snapshot(state),
|
|
105
|
+
)
|
|
106
|
+
self._queue.put_nowait(event)
|
|
107
|
+
|
|
108
|
+
async def close(self) -> None:
|
|
109
|
+
if self._closed:
|
|
110
|
+
return
|
|
111
|
+
self._closed = True
|
|
112
|
+
await self._queue.put(None)
|
|
113
|
+
|
|
114
|
+
def __aiter__(self) -> AsyncIterator[ResearchStreamEvent]:
|
|
115
|
+
return self._iter_events()
|
|
116
|
+
|
|
117
|
+
async def _iter_events(self) -> AsyncIterator[ResearchStreamEvent]:
|
|
118
|
+
while True:
|
|
119
|
+
event = await self._queue.get()
|
|
120
|
+
if event is None:
|
|
121
|
+
break
|
|
122
|
+
yield event
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def stream_research_graph(
|
|
126
|
+
graph,
|
|
127
|
+
start,
|
|
128
|
+
state: "ResearchState",
|
|
129
|
+
deps,
|
|
130
|
+
) -> AsyncIterator[ResearchStreamEvent]:
|
|
131
|
+
"""Run the research graph and yield streaming events as they occur."""
|
|
132
|
+
|
|
133
|
+
from contextlib import suppress
|
|
134
|
+
|
|
135
|
+
from haiku.rag.research.state import ResearchDeps # Local import to avoid cycle
|
|
136
|
+
|
|
137
|
+
if not isinstance(deps, ResearchDeps):
|
|
138
|
+
raise TypeError("deps must be an instance of ResearchDeps")
|
|
139
|
+
|
|
140
|
+
stream = ResearchStream()
|
|
141
|
+
deps.stream = stream
|
|
142
|
+
|
|
143
|
+
async def _execute() -> None:
|
|
144
|
+
try:
|
|
145
|
+
report = None
|
|
146
|
+
try:
|
|
147
|
+
result = await graph.run(start, state=state, deps=deps)
|
|
148
|
+
report = result.output
|
|
149
|
+
except Exception:
|
|
150
|
+
from pydantic_graph import End
|
|
151
|
+
|
|
152
|
+
async with graph.iter(start, state=state, deps=deps) as run:
|
|
153
|
+
node = run.next_node
|
|
154
|
+
while not isinstance(node, End):
|
|
155
|
+
node = await run.next(node)
|
|
156
|
+
if run.result:
|
|
157
|
+
report = run.result.output
|
|
158
|
+
|
|
159
|
+
if report is None:
|
|
160
|
+
raise RuntimeError("Graph did not produce a report")
|
|
161
|
+
|
|
162
|
+
stream.report(report, state)
|
|
163
|
+
except Exception as exc: # pragma: no cover - defensive path
|
|
164
|
+
stream.error(exc, state)
|
|
165
|
+
finally:
|
|
166
|
+
await stream.close()
|
|
167
|
+
|
|
168
|
+
runner = asyncio.create_task(_execute())
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
async for event in stream:
|
|
172
|
+
yield event
|
|
173
|
+
finally:
|
|
174
|
+
if not runner.done():
|
|
175
|
+
runner.cancel()
|
|
176
|
+
with suppress(asyncio.CancelledError):
|
|
177
|
+
await runner
|