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.
- haiku/rag/__init__.py +0 -0
- haiku/rag/app.py +542 -0
- haiku/rag/chunker.py +65 -0
- haiku/rag/cli.py +466 -0
- haiku/rag/client.py +731 -0
- haiku/rag/config/__init__.py +74 -0
- haiku/rag/config/loader.py +94 -0
- haiku/rag/config/models.py +99 -0
- haiku/rag/embeddings/__init__.py +49 -0
- haiku/rag/embeddings/base.py +25 -0
- haiku/rag/embeddings/ollama.py +28 -0
- haiku/rag/embeddings/openai.py +26 -0
- haiku/rag/embeddings/vllm.py +29 -0
- haiku/rag/embeddings/voyageai.py +27 -0
- haiku/rag/graph/__init__.py +26 -0
- haiku/rag/graph/agui/__init__.py +53 -0
- haiku/rag/graph/agui/cli_renderer.py +135 -0
- haiku/rag/graph/agui/emitter.py +197 -0
- haiku/rag/graph/agui/events.py +254 -0
- haiku/rag/graph/agui/server.py +310 -0
- haiku/rag/graph/agui/state.py +34 -0
- haiku/rag/graph/agui/stream.py +86 -0
- haiku/rag/graph/common/__init__.py +5 -0
- haiku/rag/graph/common/models.py +42 -0
- haiku/rag/graph/common/nodes.py +265 -0
- haiku/rag/graph/common/prompts.py +46 -0
- haiku/rag/graph/common/utils.py +44 -0
- haiku/rag/graph/deep_qa/__init__.py +1 -0
- haiku/rag/graph/deep_qa/dependencies.py +27 -0
- haiku/rag/graph/deep_qa/graph.py +243 -0
- haiku/rag/graph/deep_qa/models.py +20 -0
- haiku/rag/graph/deep_qa/prompts.py +59 -0
- haiku/rag/graph/deep_qa/state.py +56 -0
- haiku/rag/graph/research/__init__.py +3 -0
- haiku/rag/graph/research/common.py +87 -0
- haiku/rag/graph/research/dependencies.py +151 -0
- haiku/rag/graph/research/graph.py +295 -0
- haiku/rag/graph/research/models.py +166 -0
- haiku/rag/graph/research/prompts.py +107 -0
- haiku/rag/graph/research/state.py +85 -0
- haiku/rag/logging.py +56 -0
- haiku/rag/mcp.py +245 -0
- haiku/rag/monitor.py +194 -0
- haiku/rag/qa/__init__.py +33 -0
- haiku/rag/qa/agent.py +93 -0
- haiku/rag/qa/prompts.py +60 -0
- haiku/rag/reader.py +135 -0
- haiku/rag/reranking/__init__.py +63 -0
- haiku/rag/reranking/base.py +13 -0
- haiku/rag/reranking/cohere.py +34 -0
- haiku/rag/reranking/mxbai.py +28 -0
- haiku/rag/reranking/vllm.py +44 -0
- haiku/rag/reranking/zeroentropy.py +59 -0
- haiku/rag/store/__init__.py +4 -0
- haiku/rag/store/engine.py +309 -0
- haiku/rag/store/models/__init__.py +4 -0
- haiku/rag/store/models/chunk.py +17 -0
- haiku/rag/store/models/document.py +17 -0
- haiku/rag/store/repositories/__init__.py +9 -0
- haiku/rag/store/repositories/chunk.py +442 -0
- haiku/rag/store/repositories/document.py +261 -0
- haiku/rag/store/repositories/settings.py +165 -0
- haiku/rag/store/upgrades/__init__.py +62 -0
- haiku/rag/store/upgrades/v0_10_1.py +64 -0
- haiku/rag/store/upgrades/v0_9_3.py +112 -0
- haiku/rag/utils.py +211 -0
- haiku_rag_slim-0.16.0.dist-info/METADATA +128 -0
- haiku_rag_slim-0.16.0.dist-info/RECORD +71 -0
- haiku_rag_slim-0.16.0.dist-info/WHEEL +4 -0
- haiku_rag_slim-0.16.0.dist-info/entry_points.txt +2 -0
- 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,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()
|