haiku.rag-slim 0.16.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-slim might be problematic. Click here for more details.

Files changed (71) hide show
  1. haiku/rag/__init__.py +0 -0
  2. haiku/rag/app.py +542 -0
  3. haiku/rag/chunker.py +65 -0
  4. haiku/rag/cli.py +466 -0
  5. haiku/rag/client.py +731 -0
  6. haiku/rag/config/__init__.py +74 -0
  7. haiku/rag/config/loader.py +94 -0
  8. haiku/rag/config/models.py +99 -0
  9. haiku/rag/embeddings/__init__.py +49 -0
  10. haiku/rag/embeddings/base.py +25 -0
  11. haiku/rag/embeddings/ollama.py +28 -0
  12. haiku/rag/embeddings/openai.py +26 -0
  13. haiku/rag/embeddings/vllm.py +29 -0
  14. haiku/rag/embeddings/voyageai.py +27 -0
  15. haiku/rag/graph/__init__.py +26 -0
  16. haiku/rag/graph/agui/__init__.py +53 -0
  17. haiku/rag/graph/agui/cli_renderer.py +135 -0
  18. haiku/rag/graph/agui/emitter.py +197 -0
  19. haiku/rag/graph/agui/events.py +254 -0
  20. haiku/rag/graph/agui/server.py +310 -0
  21. haiku/rag/graph/agui/state.py +34 -0
  22. haiku/rag/graph/agui/stream.py +86 -0
  23. haiku/rag/graph/common/__init__.py +5 -0
  24. haiku/rag/graph/common/models.py +42 -0
  25. haiku/rag/graph/common/nodes.py +265 -0
  26. haiku/rag/graph/common/prompts.py +46 -0
  27. haiku/rag/graph/common/utils.py +44 -0
  28. haiku/rag/graph/deep_qa/__init__.py +1 -0
  29. haiku/rag/graph/deep_qa/dependencies.py +27 -0
  30. haiku/rag/graph/deep_qa/graph.py +243 -0
  31. haiku/rag/graph/deep_qa/models.py +20 -0
  32. haiku/rag/graph/deep_qa/prompts.py +59 -0
  33. haiku/rag/graph/deep_qa/state.py +56 -0
  34. haiku/rag/graph/research/__init__.py +3 -0
  35. haiku/rag/graph/research/common.py +87 -0
  36. haiku/rag/graph/research/dependencies.py +151 -0
  37. haiku/rag/graph/research/graph.py +295 -0
  38. haiku/rag/graph/research/models.py +166 -0
  39. haiku/rag/graph/research/prompts.py +107 -0
  40. haiku/rag/graph/research/state.py +85 -0
  41. haiku/rag/logging.py +56 -0
  42. haiku/rag/mcp.py +245 -0
  43. haiku/rag/monitor.py +194 -0
  44. haiku/rag/qa/__init__.py +33 -0
  45. haiku/rag/qa/agent.py +93 -0
  46. haiku/rag/qa/prompts.py +60 -0
  47. haiku/rag/reader.py +135 -0
  48. haiku/rag/reranking/__init__.py +63 -0
  49. haiku/rag/reranking/base.py +13 -0
  50. haiku/rag/reranking/cohere.py +34 -0
  51. haiku/rag/reranking/mxbai.py +28 -0
  52. haiku/rag/reranking/vllm.py +44 -0
  53. haiku/rag/reranking/zeroentropy.py +59 -0
  54. haiku/rag/store/__init__.py +4 -0
  55. haiku/rag/store/engine.py +309 -0
  56. haiku/rag/store/models/__init__.py +4 -0
  57. haiku/rag/store/models/chunk.py +17 -0
  58. haiku/rag/store/models/document.py +17 -0
  59. haiku/rag/store/repositories/__init__.py +9 -0
  60. haiku/rag/store/repositories/chunk.py +442 -0
  61. haiku/rag/store/repositories/document.py +261 -0
  62. haiku/rag/store/repositories/settings.py +165 -0
  63. haiku/rag/store/upgrades/__init__.py +62 -0
  64. haiku/rag/store/upgrades/v0_10_1.py +64 -0
  65. haiku/rag/store/upgrades/v0_9_3.py +112 -0
  66. haiku/rag/utils.py +211 -0
  67. haiku_rag_slim-0.16.0.dist-info/METADATA +128 -0
  68. haiku_rag_slim-0.16.0.dist-info/RECORD +71 -0
  69. haiku_rag_slim-0.16.0.dist-info/WHEEL +4 -0
  70. haiku_rag_slim-0.16.0.dist-info/entry_points.txt +2 -0
  71. haiku_rag_slim-0.16.0.dist-info/licenses/LICENSE +7 -0
@@ -0,0 +1,59 @@
1
+ """Deep QA specific prompts."""
2
+
3
+ SYNTHESIS_PROMPT = """You are an expert at synthesizing information into clear, concise answers.
4
+
5
+ Task:
6
+ - Combine the gathered information from sub-questions into a single comprehensive answer
7
+ - Answer the original question directly and completely
8
+ - Base your answer strictly on the provided evidence
9
+ - Be clear, accurate, and well-structured
10
+
11
+ Output format:
12
+ - answer: The complete answer to the original question (2-4 paragraphs)
13
+ - sources: List of document titles/URIs used (extract from the sub-answers)
14
+
15
+ Guidelines:
16
+ - Start directly with the answer - no preamble like "Based on the research..."
17
+ - Use a clear, professional tone
18
+ - Organize information logically
19
+ - If evidence is incomplete, state limitations clearly
20
+ - Do not include any claims not supported by the gathered information"""
21
+
22
+ SYNTHESIS_PROMPT_WITH_CITATIONS = """You are an expert at synthesizing information into clear, concise answers with proper citations.
23
+
24
+ Task:
25
+ - Combine the gathered information from sub-questions into a single comprehensive answer
26
+ - Answer the original question directly and completely
27
+ - Base your answer strictly on the provided evidence
28
+ - Include inline citations using [Source Title] format
29
+
30
+ Output format:
31
+ - answer: The complete answer with inline citations (2-4 paragraphs)
32
+ - sources: List of document titles/URIs used (extract from the sub-answers)
33
+
34
+ Guidelines:
35
+ - Start directly with the answer - no preamble like "Based on the research..."
36
+ - Add citations after each claim: [Source Title]
37
+ - Use a clear, professional tone
38
+ - Organize information logically
39
+ - If evidence is incomplete, state limitations clearly
40
+ - Do not include any claims not supported by the gathered information"""
41
+
42
+ DECISION_PROMPT = """You are an expert at evaluating whether gathered information is sufficient to answer a question.
43
+
44
+ Task:
45
+ - Review the original question and all gathered sub-question answers
46
+ - Determine if we have enough information to provide a comprehensive answer
47
+ - If insufficient, suggest specific new sub-questions to fill the gaps
48
+
49
+ Output format:
50
+ - is_sufficient: Boolean indicating if we can answer the question comprehensively
51
+ - reasoning: Clear explanation of your assessment
52
+ - new_questions: List of specific follow-up questions needed (empty if sufficient)
53
+
54
+ Guidelines:
55
+ - Be strict but reasonable in your assessment
56
+ - Focus on whether core aspects of the question are addressed
57
+ - New questions should be specific and distinct from what's been asked
58
+ - Limit new questions to 2-3 maximum
59
+ - Consider whether additional searches would meaningfully improve the answer"""
@@ -0,0 +1,56 @@
1
+ import asyncio
2
+ from dataclasses import dataclass
3
+ from typing import TYPE_CHECKING
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from haiku.rag.client import HaikuRAG
8
+ from haiku.rag.graph.deep_qa.dependencies import DeepQAContext
9
+ from haiku.rag.graph.deep_qa.models import DeepQAAnswer
10
+
11
+ if TYPE_CHECKING:
12
+ from haiku.rag.config.models import AppConfig
13
+ from haiku.rag.graph.agui.emitter import AGUIEmitter
14
+
15
+
16
+ @dataclass
17
+ class DeepQADeps:
18
+ client: HaikuRAG
19
+ agui_emitter: "AGUIEmitter[DeepQAState, DeepQAAnswer] | None" = None
20
+ semaphore: asyncio.Semaphore | None = None
21
+
22
+
23
+ class DeepQAState(BaseModel):
24
+ """Deep QA state for multi-agent question answering."""
25
+
26
+ model_config = {"arbitrary_types_allowed": True}
27
+
28
+ context: DeepQAContext = Field(description="Shared QA context")
29
+ max_sub_questions: int = Field(
30
+ default=3, description="Maximum number of sub-questions"
31
+ )
32
+ max_iterations: int = Field(
33
+ default=2, description="Maximum number of QA iterations"
34
+ )
35
+ max_concurrency: int = Field(
36
+ default=1, description="Maximum parallel sub-question searches"
37
+ )
38
+ iterations: int = Field(default=0, description="Current iteration number")
39
+
40
+ @classmethod
41
+ def from_config(cls, context: DeepQAContext, config: "AppConfig") -> "DeepQAState":
42
+ """Create a DeepQAState from an AppConfig.
43
+
44
+ Args:
45
+ context: The DeepQAContext containing the question and settings
46
+ config: The AppConfig object (uses config.qa for state parameters)
47
+
48
+ Returns:
49
+ A configured DeepQAState instance
50
+ """
51
+ return cls(
52
+ context=context,
53
+ max_sub_questions=config.qa.max_sub_questions,
54
+ max_iterations=config.qa.max_iterations,
55
+ max_concurrency=config.qa.max_concurrency,
56
+ )
@@ -0,0 +1,3 @@
1
+ from haiku.rag.graph.common.models import SearchAnswer
2
+ from haiku.rag.graph.research.dependencies import ResearchContext, ResearchDependencies
3
+ from haiku.rag.graph.research.models import EvaluationResult, ResearchReport
@@ -0,0 +1,87 @@
1
+ from pydantic_ai import format_as_xml
2
+
3
+ from haiku.rag.graph.research.dependencies import ResearchContext
4
+ from haiku.rag.graph.research.models import InsightAnalysis
5
+
6
+
7
+ def format_context_for_prompt(context: ResearchContext) -> str:
8
+ """Format the research context as XML for inclusion in prompts."""
9
+
10
+ context_data = {
11
+ "original_question": context.original_question,
12
+ "unanswered_questions": context.sub_questions,
13
+ "qa_responses": [
14
+ {
15
+ "question": qa.query,
16
+ "answer": qa.answer,
17
+ "context_snippets": qa.context,
18
+ "sources": qa.sources, # pyright: ignore[reportAttributeAccessIssue]
19
+ }
20
+ for qa in context.qa_responses
21
+ ],
22
+ "insights": [
23
+ {
24
+ "id": insight.id,
25
+ "summary": insight.summary,
26
+ "status": insight.status.value,
27
+ "supporting_sources": insight.supporting_sources,
28
+ "originating_questions": insight.originating_questions,
29
+ "notes": insight.notes,
30
+ }
31
+ for insight in context.insights
32
+ ],
33
+ "gaps": [
34
+ {
35
+ "id": gap.id,
36
+ "description": gap.description,
37
+ "severity": gap.severity.value,
38
+ "blocking": gap.blocking,
39
+ "resolved": gap.resolved,
40
+ "resolved_by": gap.resolved_by,
41
+ "supporting_sources": gap.supporting_sources,
42
+ "notes": gap.notes,
43
+ }
44
+ for gap in context.gaps
45
+ ],
46
+ }
47
+ return format_as_xml(context_data, root_tag="research_context")
48
+
49
+
50
+ def format_analysis_for_prompt(
51
+ analysis: InsightAnalysis | None,
52
+ ) -> str:
53
+ """Format the latest insight analysis as XML for prompts."""
54
+
55
+ if analysis is None:
56
+ return "<latest_analysis />"
57
+
58
+ data = {
59
+ "commentary": analysis.commentary,
60
+ "highlights": [
61
+ {
62
+ "id": insight.id,
63
+ "summary": insight.summary,
64
+ "status": insight.status.value,
65
+ "supporting_sources": insight.supporting_sources,
66
+ "originating_questions": insight.originating_questions,
67
+ "notes": insight.notes,
68
+ }
69
+ for insight in analysis.highlights
70
+ ],
71
+ "gap_assessments": [
72
+ {
73
+ "id": gap.id,
74
+ "description": gap.description,
75
+ "severity": gap.severity.value,
76
+ "blocking": gap.blocking,
77
+ "resolved": gap.resolved,
78
+ "resolved_by": gap.resolved_by,
79
+ "supporting_sources": gap.supporting_sources,
80
+ "notes": gap.notes,
81
+ }
82
+ for gap in analysis.gap_assessments
83
+ ],
84
+ "resolved_gaps": analysis.resolved_gaps,
85
+ "new_questions": analysis.new_questions,
86
+ }
87
+ return format_as_xml(data, root_tag="latest_analysis")
@@ -0,0 +1,151 @@
1
+ from collections.abc import Iterable
2
+
3
+ from pydantic import BaseModel, Field, PrivateAttr
4
+
5
+ from haiku.rag.client import HaikuRAG
6
+ from haiku.rag.graph.common.models import SearchAnswer
7
+ from haiku.rag.graph.research.models import (
8
+ GapRecord,
9
+ InsightAnalysis,
10
+ InsightRecord,
11
+ )
12
+
13
+
14
+ class ResearchContext(BaseModel):
15
+ """Context shared across research agents."""
16
+
17
+ original_question: str = Field(description="The original research question")
18
+ sub_questions: list[str] = Field(
19
+ default_factory=list, description="Decomposed sub-questions"
20
+ )
21
+ qa_responses: list[SearchAnswer] = Field(
22
+ default_factory=list, description="Structured QA pairs used during research"
23
+ )
24
+ insights: list[InsightRecord] = Field(
25
+ default_factory=list, description="Key insights discovered"
26
+ )
27
+ gaps: list[GapRecord] = Field(
28
+ default_factory=list, description="Identified information gaps"
29
+ )
30
+
31
+ # Private dict indexes for O(1) lookups
32
+ _insights_by_id: dict[str, InsightRecord] = PrivateAttr(default_factory=dict)
33
+ _gaps_by_id: dict[str, GapRecord] = PrivateAttr(default_factory=dict)
34
+
35
+ def model_post_init(self, __context: object) -> None:
36
+ """Build indexes after initialization."""
37
+ self._insights_by_id = {ins.id: ins for ins in self.insights}
38
+ self._gaps_by_id = {gap.id: gap for gap in self.gaps}
39
+
40
+ def add_qa_response(self, qa: SearchAnswer) -> None:
41
+ """Add a structured QA response (minimal context already included)."""
42
+ self.qa_responses.append(qa)
43
+
44
+ def upsert_insights(self, records: Iterable[InsightRecord]) -> list[InsightRecord]:
45
+ """Merge one or more insights into the shared context with deduplication."""
46
+ merged: list[InsightRecord] = []
47
+
48
+ for record in records:
49
+ candidate = InsightRecord.model_validate(record)
50
+ existing = self._insights_by_id.get(candidate.id)
51
+
52
+ if existing:
53
+ # Update existing insight
54
+ existing.summary = candidate.summary
55
+ existing.status = candidate.status
56
+ if candidate.notes:
57
+ existing.notes = candidate.notes
58
+ existing.supporting_sources = _merge_unique(
59
+ existing.supporting_sources, candidate.supporting_sources
60
+ )
61
+ existing.originating_questions = _merge_unique(
62
+ existing.originating_questions, candidate.originating_questions
63
+ )
64
+ merged.append(existing)
65
+ else:
66
+ # Add new insight
67
+ new_insight = candidate.model_copy(deep=True)
68
+ self.insights.append(new_insight)
69
+ self._insights_by_id[new_insight.id] = new_insight
70
+ merged.append(new_insight)
71
+
72
+ return merged
73
+
74
+ def upsert_gaps(self, records: Iterable[GapRecord]) -> list[GapRecord]:
75
+ """Merge one or more gap records into the shared context with deduplication."""
76
+ merged: list[GapRecord] = []
77
+
78
+ for record in records:
79
+ candidate = GapRecord.model_validate(record)
80
+ existing = self._gaps_by_id.get(candidate.id)
81
+
82
+ if existing:
83
+ # Update existing gap
84
+ existing.description = candidate.description
85
+ existing.severity = candidate.severity
86
+ existing.blocking = candidate.blocking
87
+ existing.resolved = candidate.resolved
88
+ if candidate.notes:
89
+ existing.notes = candidate.notes
90
+ existing.supporting_sources = _merge_unique(
91
+ existing.supporting_sources, candidate.supporting_sources
92
+ )
93
+ existing.resolved_by = _merge_unique(
94
+ existing.resolved_by, candidate.resolved_by
95
+ )
96
+ merged.append(existing)
97
+ else:
98
+ # Add new gap
99
+ new_gap = candidate.model_copy(deep=True)
100
+ self.gaps.append(new_gap)
101
+ self._gaps_by_id[new_gap.id] = new_gap
102
+ merged.append(new_gap)
103
+
104
+ return merged
105
+
106
+ def mark_gap_resolved(
107
+ self, identifier: str, resolved_by: Iterable[str] | None = None
108
+ ) -> GapRecord | None:
109
+ """Mark a gap as resolved by identifier."""
110
+ gap = self._gaps_by_id.get(identifier)
111
+ if gap is None:
112
+ return None
113
+
114
+ gap.resolved = True
115
+ gap.blocking = False
116
+ if resolved_by:
117
+ gap.resolved_by = _merge_unique(gap.resolved_by, list(resolved_by))
118
+ return gap
119
+
120
+ def integrate_analysis(self, analysis: InsightAnalysis) -> None:
121
+ """Apply an analysis result to the shared context."""
122
+ merged_insights: list[InsightRecord] = []
123
+ if analysis.highlights:
124
+ merged_insights = self.upsert_insights(analysis.highlights)
125
+ analysis.highlights = merged_insights
126
+ if analysis.gap_assessments:
127
+ merged_gaps = self.upsert_gaps(analysis.gap_assessments)
128
+ analysis.gap_assessments = merged_gaps
129
+ if analysis.resolved_gaps:
130
+ resolved_by_list = (
131
+ [ins.id for ins in merged_insights] if merged_insights else None
132
+ )
133
+ for resolved in analysis.resolved_gaps:
134
+ self.mark_gap_resolved(resolved, resolved_by=resolved_by_list)
135
+ for question in analysis.new_questions:
136
+ if question not in self.sub_questions:
137
+ self.sub_questions.append(question)
138
+
139
+
140
+ class ResearchDependencies(BaseModel):
141
+ """Dependencies for research agents with multi-agent context."""
142
+
143
+ model_config = {"arbitrary_types_allowed": True}
144
+
145
+ client: HaikuRAG = Field(description="RAG client for document operations")
146
+ context: ResearchContext = Field(description="Shared research context")
147
+
148
+
149
+ def _merge_unique(existing: list[str], incoming: Iterable[str]) -> list[str]:
150
+ """Merge two iterables preserving order while removing duplicates."""
151
+ return [k for k in dict.fromkeys([*existing, *incoming]) if k]
@@ -0,0 +1,295 @@
1
+ from pydantic_ai import Agent
2
+ from pydantic_graph.beta import Graph, GraphBuilder, StepContext
3
+ from pydantic_graph.beta.join import reduce_list_append
4
+
5
+ from haiku.rag.config import Config
6
+ from haiku.rag.config.models import AppConfig
7
+ from haiku.rag.graph.common import get_model
8
+ from haiku.rag.graph.common.models import SearchAnswer
9
+ from haiku.rag.graph.common.nodes import create_plan_node, create_search_node
10
+ from haiku.rag.graph.research.common import (
11
+ format_analysis_for_prompt,
12
+ format_context_for_prompt,
13
+ )
14
+ from haiku.rag.graph.research.dependencies import ResearchDependencies
15
+ from haiku.rag.graph.research.models import (
16
+ EvaluationResult,
17
+ InsightAnalysis,
18
+ ResearchReport,
19
+ )
20
+ from haiku.rag.graph.research.prompts import (
21
+ DECISION_AGENT_PROMPT,
22
+ INSIGHT_AGENT_PROMPT,
23
+ SYNTHESIS_AGENT_PROMPT,
24
+ )
25
+ from haiku.rag.graph.research.state import ResearchDeps, ResearchState
26
+
27
+
28
+ def build_research_graph(
29
+ config: AppConfig = Config,
30
+ ) -> Graph[ResearchState, ResearchDeps, None, ResearchReport]:
31
+ """Build the Research graph.
32
+
33
+ Args:
34
+ config: AppConfig object (uses config.research for provider, model, and graph parameters)
35
+
36
+ Returns:
37
+ Configured Research graph
38
+ """
39
+ provider = config.research.provider
40
+ model = config.research.model
41
+ g = GraphBuilder(
42
+ state_type=ResearchState,
43
+ deps_type=ResearchDeps,
44
+ output_type=ResearchReport,
45
+ )
46
+
47
+ # Create and register the plan node using the factory
48
+ plan = g.step(
49
+ create_plan_node(
50
+ provider=provider,
51
+ model=model,
52
+ deps_type=ResearchDependencies, # type: ignore[arg-type]
53
+ activity_message="Creating research plan",
54
+ output_retries=3,
55
+ )
56
+ ) # type: ignore[arg-type]
57
+
58
+ # Create and register the search_one node using the factory
59
+ search_one = g.step(
60
+ create_search_node(
61
+ provider=provider,
62
+ model=model,
63
+ deps_type=ResearchDependencies, # type: ignore[arg-type]
64
+ with_step_wrapper=True,
65
+ success_message_format="Found answer with {confidence:.0%} confidence",
66
+ handle_exceptions=True,
67
+ )
68
+ ) # type: ignore[arg-type]
69
+
70
+ @g.step
71
+ async def get_batch(
72
+ ctx: StepContext[ResearchState, ResearchDeps, None | bool],
73
+ ) -> list[str] | None:
74
+ """Get all remaining questions for this iteration."""
75
+ state = ctx.state
76
+
77
+ if not state.context.sub_questions:
78
+ return None
79
+
80
+ # Take ALL remaining questions and process them in parallel
81
+ batch = list(state.context.sub_questions)
82
+ state.context.sub_questions.clear()
83
+ return batch
84
+
85
+ @g.step
86
+ async def analyze_insights(
87
+ ctx: StepContext[ResearchState, ResearchDeps, list[SearchAnswer]],
88
+ ) -> None:
89
+ state = ctx.state
90
+ deps = ctx.deps
91
+
92
+ if deps.agui_emitter:
93
+ deps.agui_emitter.start_step("analyze_insights")
94
+ deps.agui_emitter.update_activity(
95
+ "analyzing", "Synthesizing insights and gaps"
96
+ )
97
+
98
+ try:
99
+ agent = Agent(
100
+ model=get_model(provider, model),
101
+ output_type=InsightAnalysis,
102
+ instructions=INSIGHT_AGENT_PROMPT,
103
+ retries=3,
104
+ output_retries=3,
105
+ deps_type=ResearchDependencies,
106
+ )
107
+
108
+ context_xml = format_context_for_prompt(state.context)
109
+ prompt = (
110
+ "Review the latest research context and update the shared ledger of insights, gaps,"
111
+ " and follow-up questions.\n\n"
112
+ f"{context_xml}"
113
+ )
114
+ agent_deps = ResearchDependencies(
115
+ client=deps.client,
116
+ context=state.context,
117
+ )
118
+ result = await agent.run(prompt, deps=agent_deps)
119
+ analysis: InsightAnalysis = result.output
120
+
121
+ state.context.integrate_analysis(analysis)
122
+ state.last_analysis = analysis
123
+
124
+ # State updated with insights/gaps - emit state update and narrate
125
+ if deps.agui_emitter:
126
+ deps.agui_emitter.update_state(state)
127
+ highlights = len(analysis.highlights) if analysis.highlights else 0
128
+ gaps = len(analysis.gap_assessments) if analysis.gap_assessments else 0
129
+ resolved = len(analysis.resolved_gaps) if analysis.resolved_gaps else 0
130
+ parts = []
131
+ if highlights:
132
+ parts.append(f"{highlights} insights")
133
+ if gaps:
134
+ parts.append(f"{gaps} gaps")
135
+ if resolved:
136
+ parts.append(f"{resolved} resolved")
137
+ summary = ", ".join(parts) if parts else "No updates"
138
+ deps.agui_emitter.update_activity("analyzing", f"Analysis: {summary}")
139
+ finally:
140
+ if deps.agui_emitter:
141
+ deps.agui_emitter.finish_step()
142
+
143
+ @g.step
144
+ async def decide(ctx: StepContext[ResearchState, ResearchDeps, None]) -> bool:
145
+ state = ctx.state
146
+ deps = ctx.deps
147
+
148
+ if deps.agui_emitter:
149
+ deps.agui_emitter.start_step("decide")
150
+ deps.agui_emitter.update_activity(
151
+ "evaluating", "Evaluating research sufficiency"
152
+ )
153
+
154
+ try:
155
+ agent = Agent(
156
+ model=get_model(provider, model),
157
+ output_type=EvaluationResult,
158
+ instructions=DECISION_AGENT_PROMPT,
159
+ retries=3,
160
+ output_retries=3,
161
+ deps_type=ResearchDependencies,
162
+ )
163
+
164
+ context_xml = format_context_for_prompt(state.context)
165
+ analysis_xml = format_analysis_for_prompt(state.last_analysis)
166
+ prompt_parts = [
167
+ "Assess whether the research now answers the original question with adequate confidence.",
168
+ context_xml,
169
+ analysis_xml,
170
+ ]
171
+ if state.last_eval is not None:
172
+ prev = state.last_eval
173
+ prompt_parts.append(
174
+ "<previous_evaluation>"
175
+ f"<confidence>{prev.confidence_score:.2f}</confidence>"
176
+ f"<is_sufficient>{str(prev.is_sufficient).lower()}</is_sufficient>"
177
+ f"<reasoning>{prev.reasoning}</reasoning>"
178
+ "</previous_evaluation>"
179
+ )
180
+ prompt = "\n\n".join(part for part in prompt_parts if part)
181
+
182
+ agent_deps = ResearchDependencies(
183
+ client=deps.client,
184
+ context=state.context,
185
+ )
186
+ decision_result = await agent.run(prompt, deps=agent_deps)
187
+ output = decision_result.output
188
+
189
+ state.last_eval = output
190
+ state.iterations += 1
191
+
192
+ for new_q in output.new_questions:
193
+ if new_q not in state.context.sub_questions:
194
+ state.context.sub_questions.append(new_q)
195
+
196
+ # State updated with evaluation - emit state update and narrate
197
+ if deps.agui_emitter:
198
+ deps.agui_emitter.update_state(state)
199
+ sufficient = "Yes" if output.is_sufficient else "No"
200
+ deps.agui_emitter.update_activity(
201
+ "evaluating",
202
+ f"Confidence: {output.confidence_score:.0%}, Sufficient: {sufficient}",
203
+ )
204
+
205
+ should_continue = (
206
+ not output.is_sufficient
207
+ or output.confidence_score < state.confidence_threshold
208
+ ) and state.iterations < state.max_iterations
209
+
210
+ return should_continue
211
+ finally:
212
+ if deps.agui_emitter:
213
+ deps.agui_emitter.finish_step()
214
+
215
+ @g.step
216
+ async def synthesize(
217
+ ctx: StepContext[ResearchState, ResearchDeps, None | bool],
218
+ ) -> ResearchReport:
219
+ state = ctx.state
220
+ deps = ctx.deps
221
+
222
+ if deps.agui_emitter:
223
+ deps.agui_emitter.start_step("synthesize")
224
+ deps.agui_emitter.update_activity(
225
+ "synthesizing", "Generating final research report"
226
+ )
227
+
228
+ try:
229
+ agent = Agent(
230
+ model=get_model(provider, model),
231
+ output_type=ResearchReport,
232
+ instructions=SYNTHESIS_AGENT_PROMPT,
233
+ retries=3,
234
+ output_retries=3,
235
+ deps_type=ResearchDependencies,
236
+ )
237
+
238
+ context_xml = format_context_for_prompt(state.context)
239
+ prompt = (
240
+ "Generate a comprehensive research report based on all gathered information.\n\n"
241
+ f"{context_xml}\n\n"
242
+ "Create a detailed report that synthesizes all findings into a coherent response."
243
+ )
244
+ agent_deps = ResearchDependencies(
245
+ client=deps.client,
246
+ context=state.context,
247
+ )
248
+ result = await agent.run(prompt, deps=agent_deps)
249
+ return result.output
250
+ finally:
251
+ if deps.agui_emitter:
252
+ deps.agui_emitter.finish_step()
253
+
254
+ # Build the graph structure
255
+ collect_answers = g.join(
256
+ reduce_list_append,
257
+ initial_factory=list[SearchAnswer],
258
+ )
259
+
260
+ g.add(
261
+ g.edge_from(g.start_node).to(plan),
262
+ g.edge_from(plan).to(get_batch),
263
+ )
264
+
265
+ # Branch based on whether we have questions
266
+ g.add(
267
+ g.edge_from(get_batch).to(
268
+ g.decision()
269
+ .branch(g.match(list).label("Has questions").map().to(search_one))
270
+ .branch(g.match(type(None)).label("No questions").to(synthesize))
271
+ ),
272
+ g.edge_from(search_one).to(collect_answers),
273
+ g.edge_from(collect_answers).to(analyze_insights),
274
+ g.edge_from(analyze_insights).to(decide),
275
+ )
276
+
277
+ # Branch based on decision
278
+ g.add(
279
+ g.edge_from(decide).to(
280
+ g.decision()
281
+ .branch(
282
+ g.match(bool, matches=lambda x: x)
283
+ .label("Continue research")
284
+ .to(get_batch)
285
+ )
286
+ .branch(
287
+ g.match(bool, matches=lambda x: not x)
288
+ .label("Done researching")
289
+ .to(synthesize)
290
+ )
291
+ ),
292
+ g.edge_from(synthesize).to(g.end_node),
293
+ )
294
+
295
+ return g.build()