haiku.rag 0.10.2__tar.gz → 0.11.0__tar.gz
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-0.10.2 → haiku_rag-0.11.0}/PKG-INFO +32 -13
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/README.md +31 -12
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/docs/agents.md +59 -10
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/docs/cli.md +2 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/pyproject.toml +1 -1
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/app.py +15 -16
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/research/__init__.py +8 -0
- haiku_rag-0.11.0/src/haiku/rag/research/common.py +118 -0
- haiku_rag-0.11.0/src/haiku/rag/research/dependencies.py +215 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/research/graph.py +5 -3
- haiku_rag-0.11.0/src/haiku/rag/research/models.py +203 -0
- haiku_rag-0.11.0/src/haiku/rag/research/nodes/analysis.py +181 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/research/nodes/plan.py +16 -9
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/research/nodes/search.py +14 -11
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/research/nodes/synthesize.py +7 -3
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/research/prompts.py +67 -28
- haiku_rag-0.11.0/src/haiku/rag/research/state.py +32 -0
- haiku_rag-0.11.0/src/haiku/rag/research/stream.py +177 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_research_graph.py +1 -2
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_research_graph_integration.py +62 -13
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/uv.lock +1 -1
- haiku_rag-0.10.2/src/haiku/rag/research/common.py +0 -53
- haiku_rag-0.10.2/src/haiku/rag/research/dependencies.py +0 -47
- haiku_rag-0.10.2/src/haiku/rag/research/models.py +0 -70
- haiku_rag-0.10.2/src/haiku/rag/research/nodes/evaluate.py +0 -80
- haiku_rag-0.10.2/src/haiku/rag/research/state.py +0 -25
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/.github/FUNDING.yml +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/.github/workflows/build-docs.yml +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/.github/workflows/build-publish.yml +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/.gitignore +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/.pre-commit-config.yaml +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/.python-version +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/LICENSE +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/docs/benchmarks.md +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/docs/configuration.md +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/docs/index.md +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/docs/installation.md +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/docs/mcp.md +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/docs/python.md +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/docs/server.md +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/mkdocs.yml +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/__init__.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/chunker.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/cli.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/client.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/config.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/embeddings/__init__.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/embeddings/base.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/embeddings/ollama.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/embeddings/openai.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/embeddings/vllm.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/embeddings/voyageai.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/logging.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/mcp.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/migration.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/monitor.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/qa/__init__.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/qa/agent.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/qa/prompts.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/reader.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/reranking/__init__.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/reranking/base.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/reranking/cohere.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/reranking/mxbai.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/reranking/vllm.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/store/__init__.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/store/engine.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/store/models/__init__.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/store/models/chunk.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/store/models/document.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/store/repositories/__init__.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/store/repositories/chunk.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/store/repositories/document.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/store/repositories/settings.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/store/upgrades/__init__.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/store/upgrades/v0_10_1.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/store/upgrades/v0_9_3.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/utils.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/__init__.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/conftest.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/generate_benchmark_db.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/llm_judge.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_app.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_chunk.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_chunker.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_cli.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_client.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_document.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_embedder.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_info.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_lancedb_connection.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_monitor.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_preprocessor.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_qa.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_reader.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_rebuild.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_reranker.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_search.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_settings.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_utils.py +0 -0
- {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_versioning.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: haiku.rag
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: Agentic Retrieval Augmented Generation (RAG) with LanceDB
|
|
5
5
|
Author-email: Yiorgis Gozadinos <ggozadinos@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -102,11 +102,12 @@ haiku-rag serve
|
|
|
102
102
|
```python
|
|
103
103
|
from haiku.rag.client import HaikuRAG
|
|
104
104
|
from haiku.rag.research import (
|
|
105
|
+
PlanNode,
|
|
105
106
|
ResearchContext,
|
|
106
107
|
ResearchDeps,
|
|
107
108
|
ResearchState,
|
|
108
109
|
build_research_graph,
|
|
109
|
-
|
|
110
|
+
stream_research_graph,
|
|
110
111
|
)
|
|
111
112
|
|
|
112
113
|
async with HaikuRAG("database.lancedb") as client:
|
|
@@ -128,22 +129,40 @@ async with HaikuRAG("database.lancedb") as client:
|
|
|
128
129
|
|
|
129
130
|
# Multi‑agent research pipeline (Plan → Search → Evaluate → Synthesize)
|
|
130
131
|
graph = build_research_graph()
|
|
132
|
+
question = (
|
|
133
|
+
"What are the main drivers and trends of global temperature "
|
|
134
|
+
"anomalies since 1990?"
|
|
135
|
+
)
|
|
131
136
|
state = ResearchState(
|
|
132
|
-
|
|
133
|
-
"What are the main drivers and trends of global temperature "
|
|
134
|
-
"anomalies since 1990?"
|
|
135
|
-
),
|
|
136
|
-
context=ResearchContext(original_question="…"),
|
|
137
|
+
context=ResearchContext(original_question=question),
|
|
137
138
|
max_iterations=2,
|
|
138
139
|
confidence_threshold=0.8,
|
|
139
|
-
max_concurrency=
|
|
140
|
+
max_concurrency=2,
|
|
140
141
|
)
|
|
141
142
|
deps = ResearchDeps(client=client)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
143
|
+
|
|
144
|
+
# Blocking run (final result only)
|
|
145
|
+
result = await graph.run(
|
|
146
|
+
PlanNode(provider="openai", model="gpt-4o-mini"),
|
|
147
|
+
state=state,
|
|
148
|
+
deps=deps,
|
|
149
|
+
)
|
|
150
|
+
print(result.output.title)
|
|
151
|
+
|
|
152
|
+
# Streaming progress (log/report/error events)
|
|
153
|
+
async for event in stream_research_graph(
|
|
154
|
+
graph,
|
|
155
|
+
PlanNode(provider="openai", model="gpt-4o-mini"),
|
|
156
|
+
state,
|
|
157
|
+
deps,
|
|
158
|
+
):
|
|
159
|
+
if event.type == "log":
|
|
160
|
+
iteration = event.state.iterations if event.state else state.iterations
|
|
161
|
+
print(f"[{iteration}] {event.message}")
|
|
162
|
+
elif event.type == "report":
|
|
163
|
+
print("\nResearch complete!\n")
|
|
164
|
+
print(event.report.title)
|
|
165
|
+
print(event.report.executive_summary)
|
|
147
166
|
```
|
|
148
167
|
|
|
149
168
|
## MCP Server
|
|
@@ -64,11 +64,12 @@ haiku-rag serve
|
|
|
64
64
|
```python
|
|
65
65
|
from haiku.rag.client import HaikuRAG
|
|
66
66
|
from haiku.rag.research import (
|
|
67
|
+
PlanNode,
|
|
67
68
|
ResearchContext,
|
|
68
69
|
ResearchDeps,
|
|
69
70
|
ResearchState,
|
|
70
71
|
build_research_graph,
|
|
71
|
-
|
|
72
|
+
stream_research_graph,
|
|
72
73
|
)
|
|
73
74
|
|
|
74
75
|
async with HaikuRAG("database.lancedb") as client:
|
|
@@ -90,22 +91,40 @@ async with HaikuRAG("database.lancedb") as client:
|
|
|
90
91
|
|
|
91
92
|
# Multi‑agent research pipeline (Plan → Search → Evaluate → Synthesize)
|
|
92
93
|
graph = build_research_graph()
|
|
94
|
+
question = (
|
|
95
|
+
"What are the main drivers and trends of global temperature "
|
|
96
|
+
"anomalies since 1990?"
|
|
97
|
+
)
|
|
93
98
|
state = ResearchState(
|
|
94
|
-
|
|
95
|
-
"What are the main drivers and trends of global temperature "
|
|
96
|
-
"anomalies since 1990?"
|
|
97
|
-
),
|
|
98
|
-
context=ResearchContext(original_question="…"),
|
|
99
|
+
context=ResearchContext(original_question=question),
|
|
99
100
|
max_iterations=2,
|
|
100
101
|
confidence_threshold=0.8,
|
|
101
|
-
max_concurrency=
|
|
102
|
+
max_concurrency=2,
|
|
102
103
|
)
|
|
103
104
|
deps = ResearchDeps(client=client)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
105
|
+
|
|
106
|
+
# Blocking run (final result only)
|
|
107
|
+
result = await graph.run(
|
|
108
|
+
PlanNode(provider="openai", model="gpt-4o-mini"),
|
|
109
|
+
state=state,
|
|
110
|
+
deps=deps,
|
|
111
|
+
)
|
|
112
|
+
print(result.output.title)
|
|
113
|
+
|
|
114
|
+
# Streaming progress (log/report/error events)
|
|
115
|
+
async for event in stream_research_graph(
|
|
116
|
+
graph,
|
|
117
|
+
PlanNode(provider="openai", model="gpt-4o-mini"),
|
|
118
|
+
state,
|
|
119
|
+
deps,
|
|
120
|
+
):
|
|
121
|
+
if event.type == "log":
|
|
122
|
+
iteration = event.state.iterations if event.state else state.iterations
|
|
123
|
+
print(f"[{iteration}] {event.message}")
|
|
124
|
+
elif event.type == "report":
|
|
125
|
+
print("\nResearch complete!\n")
|
|
126
|
+
print(event.report.title)
|
|
127
|
+
print(event.report.executive_summary)
|
|
109
128
|
```
|
|
110
129
|
|
|
111
130
|
## MCP Server
|
|
@@ -47,9 +47,10 @@ title: Research graph
|
|
|
47
47
|
---
|
|
48
48
|
stateDiagram-v2
|
|
49
49
|
PlanNode --> SearchDispatchNode
|
|
50
|
-
SearchDispatchNode -->
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
SearchDispatchNode --> AnalyzeInsightsNode
|
|
51
|
+
AnalyzeInsightsNode --> DecisionNode
|
|
52
|
+
DecisionNode --> SearchDispatchNode
|
|
53
|
+
DecisionNode --> SynthesizeNode
|
|
53
54
|
SynthesizeNode --> [*]
|
|
54
55
|
```
|
|
55
56
|
|
|
@@ -57,12 +58,15 @@ Key nodes:
|
|
|
57
58
|
|
|
58
59
|
- Plan: builds up to 3 standalone sub‑questions (uses an internal presearch tool)
|
|
59
60
|
- Search (batched): answers sub‑questions using the KB with minimal, verbatim context
|
|
60
|
-
-
|
|
61
|
+
- Analyze: aggregates fresh insights, updates gaps, and suggests new sub-questions
|
|
62
|
+
- Decision: checks sufficiency/confidence thresholds and chooses whether to iterate
|
|
61
63
|
- Synthesize: generates a final structured report
|
|
62
64
|
|
|
63
65
|
Primary models:
|
|
64
66
|
|
|
65
67
|
- `SearchAnswer` — one per sub‑question (query, answer, context, sources)
|
|
68
|
+
- `InsightRecord` / `GapRecord` — structured tracking of findings and open issues
|
|
69
|
+
- `InsightAnalysis` — output of the analysis stage (insights, gaps, commentary)
|
|
66
70
|
- `EvaluationResult` — insights, new questions, sufficiency, confidence
|
|
67
71
|
- `ResearchReport` — final report (title, executive summary, findings, conclusions, …)
|
|
68
72
|
|
|
@@ -76,30 +80,75 @@ haiku-rag research "How does haiku.rag organize and query documents?" \
|
|
|
76
80
|
--verbose
|
|
77
81
|
```
|
|
78
82
|
|
|
79
|
-
Python usage:
|
|
83
|
+
Python usage (blocking result):
|
|
80
84
|
|
|
81
85
|
```python
|
|
82
86
|
from haiku.rag.client import HaikuRAG
|
|
83
87
|
from haiku.rag.research import (
|
|
88
|
+
PlanNode,
|
|
84
89
|
ResearchContext,
|
|
85
90
|
ResearchDeps,
|
|
86
91
|
ResearchState,
|
|
87
92
|
build_research_graph,
|
|
88
|
-
PlanNode,
|
|
89
93
|
)
|
|
90
94
|
|
|
91
95
|
async with HaikuRAG(path_to_db) as client:
|
|
92
96
|
graph = build_research_graph()
|
|
97
|
+
question = "What are the main drivers and trends of global temperature anomalies since 1990?"
|
|
93
98
|
state = ResearchState(
|
|
94
|
-
question
|
|
95
|
-
context=ResearchContext(original_question=... ),
|
|
99
|
+
context=ResearchContext(original_question=question),
|
|
96
100
|
max_iterations=2,
|
|
97
101
|
confidence_threshold=0.8,
|
|
98
|
-
max_concurrency=
|
|
102
|
+
max_concurrency=2,
|
|
99
103
|
)
|
|
100
104
|
deps = ResearchDeps(client=client)
|
|
101
|
-
|
|
105
|
+
|
|
106
|
+
result = await graph.run(
|
|
107
|
+
PlanNode(provider="openai", model="gpt-4o-mini"),
|
|
108
|
+
state=state,
|
|
109
|
+
deps=deps,
|
|
110
|
+
)
|
|
111
|
+
|
|
102
112
|
report = result.output
|
|
103
113
|
print(report.title)
|
|
104
114
|
print(report.executive_summary)
|
|
105
115
|
```
|
|
116
|
+
|
|
117
|
+
Python usage (streamed events):
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from haiku.rag.client import HaikuRAG
|
|
121
|
+
from haiku.rag.research import (
|
|
122
|
+
PlanNode,
|
|
123
|
+
ResearchContext,
|
|
124
|
+
ResearchDeps,
|
|
125
|
+
ResearchState,
|
|
126
|
+
build_research_graph,
|
|
127
|
+
stream_research_graph,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
async with HaikuRAG(path_to_db) as client:
|
|
131
|
+
graph = build_research_graph()
|
|
132
|
+
question = "What are the main drivers and trends of global temperature anomalies since 1990?"
|
|
133
|
+
state = ResearchState(
|
|
134
|
+
context=ResearchContext(original_question=question),
|
|
135
|
+
max_iterations=2,
|
|
136
|
+
confidence_threshold=0.8,
|
|
137
|
+
max_concurrency=2,
|
|
138
|
+
)
|
|
139
|
+
deps = ResearchDeps(client=client)
|
|
140
|
+
|
|
141
|
+
async for event in stream_research_graph(
|
|
142
|
+
graph,
|
|
143
|
+
PlanNode(provider="openai", model="gpt-4o-mini"),
|
|
144
|
+
state,
|
|
145
|
+
deps,
|
|
146
|
+
):
|
|
147
|
+
if event.type == "log":
|
|
148
|
+
iteration = event.state.iterations if event.state else state.iterations
|
|
149
|
+
print(f"[{iteration}] {event.message}")
|
|
150
|
+
elif event.type == "report":
|
|
151
|
+
print("\nResearch complete!\n")
|
|
152
|
+
print(event.report.title)
|
|
153
|
+
print(event.report.executive_summary)
|
|
154
|
+
```
|
|
@@ -113,6 +113,8 @@ Flags:
|
|
|
113
113
|
- `--max-concurrency`: number of sub-questions searched in parallel each iteration (default: 3)
|
|
114
114
|
- `--verbose`: show planning, searching previews, evaluation summary, and stop reason
|
|
115
115
|
|
|
116
|
+
When `--verbose` is set the CLI also consumes the internal research stream, printing every `log` event as agents progress through planning, search, evaluation, and synthesis. If you build your own integration, call `stream_research_graph` to access the same `log`, `report`, and `error` events and render them however you like while the graph is running.
|
|
117
|
+
|
|
116
118
|
## Server
|
|
117
119
|
|
|
118
120
|
Start the MCP server:
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
name = "haiku.rag"
|
|
4
4
|
description = "Agentic Retrieval Augmented Generation (RAG) with LanceDB"
|
|
5
|
-
version = "0.
|
|
5
|
+
version = "0.11.0"
|
|
6
6
|
authors = [{ name = "Yiorgis Gozadinos", email = "ggozadinos@gmail.com" }]
|
|
7
7
|
license = { text = "MIT" }
|
|
8
8
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
@@ -18,6 +18,7 @@ from haiku.rag.research.graph import (
|
|
|
18
18
|
ResearchState,
|
|
19
19
|
build_research_graph,
|
|
20
20
|
)
|
|
21
|
+
from haiku.rag.research.stream import stream_research_graph
|
|
21
22
|
from haiku.rag.store.models.chunk import Chunk
|
|
22
23
|
from haiku.rag.store.models.document import Document
|
|
23
24
|
|
|
@@ -221,9 +222,9 @@ class HaikuRAGApp:
|
|
|
221
222
|
self.console.print()
|
|
222
223
|
|
|
223
224
|
graph = build_research_graph()
|
|
225
|
+
context = ResearchContext(original_question=question)
|
|
224
226
|
state = ResearchState(
|
|
225
|
-
|
|
226
|
-
context=ResearchContext(original_question=question),
|
|
227
|
+
context=context,
|
|
227
228
|
max_iterations=max_iterations,
|
|
228
229
|
confidence_threshold=confidence_threshold,
|
|
229
230
|
max_concurrency=max_concurrency,
|
|
@@ -236,22 +237,20 @@ class HaikuRAGApp:
|
|
|
236
237
|
provider=Config.RESEARCH_PROVIDER or Config.QA_PROVIDER,
|
|
237
238
|
model=Config.RESEARCH_MODEL or Config.QA_MODEL,
|
|
238
239
|
)
|
|
239
|
-
# Prefer graph.run; fall back to iter if unavailable
|
|
240
240
|
report = None
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if run.result:
|
|
252
|
-
report = run.result.output
|
|
241
|
+
async for event in stream_research_graph(graph, start, state, deps):
|
|
242
|
+
if event.type == "report":
|
|
243
|
+
report = event.report
|
|
244
|
+
break
|
|
245
|
+
if event.type == "error":
|
|
246
|
+
self.console.print(
|
|
247
|
+
f"[red]Error during research: {event.message}[/red]"
|
|
248
|
+
)
|
|
249
|
+
return
|
|
250
|
+
|
|
253
251
|
if report is None:
|
|
254
|
-
|
|
252
|
+
self.console.print("[red]Research did not produce a report.[/red]")
|
|
253
|
+
return
|
|
255
254
|
|
|
256
255
|
# Display the report
|
|
257
256
|
self.console.print("[bold green]Research Report[/bold green]")
|
|
@@ -6,6 +6,11 @@ from haiku.rag.research.graph import (
|
|
|
6
6
|
build_research_graph,
|
|
7
7
|
)
|
|
8
8
|
from haiku.rag.research.models import EvaluationResult, ResearchReport, SearchAnswer
|
|
9
|
+
from haiku.rag.research.stream import (
|
|
10
|
+
ResearchStateSnapshot,
|
|
11
|
+
ResearchStreamEvent,
|
|
12
|
+
stream_research_graph,
|
|
13
|
+
)
|
|
9
14
|
|
|
10
15
|
__all__ = [
|
|
11
16
|
"ResearchDependencies",
|
|
@@ -17,4 +22,7 @@ __all__ = [
|
|
|
17
22
|
"ResearchState",
|
|
18
23
|
"PlanNode",
|
|
19
24
|
"build_research_graph",
|
|
25
|
+
"stream_research_graph",
|
|
26
|
+
"ResearchStreamEvent",
|
|
27
|
+
"ResearchStateSnapshot",
|
|
20
28
|
]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, Any
|
|
2
|
+
|
|
3
|
+
from pydantic_ai import format_as_xml
|
|
4
|
+
from pydantic_ai.models.openai import OpenAIChatModel
|
|
5
|
+
from pydantic_ai.providers.ollama import OllamaProvider
|
|
6
|
+
from pydantic_ai.providers.openai import OpenAIProvider
|
|
7
|
+
|
|
8
|
+
from haiku.rag.config import Config
|
|
9
|
+
from haiku.rag.research.dependencies import ResearchContext
|
|
10
|
+
from haiku.rag.research.models import InsightAnalysis
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
13
|
+
from haiku.rag.research.state import ResearchDeps, ResearchState
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_model(provider: str, model: str) -> Any:
|
|
17
|
+
if provider == "ollama":
|
|
18
|
+
return OpenAIChatModel(
|
|
19
|
+
model_name=model,
|
|
20
|
+
provider=OllamaProvider(base_url=f"{Config.OLLAMA_BASE_URL}/v1"),
|
|
21
|
+
)
|
|
22
|
+
elif provider == "vllm":
|
|
23
|
+
return OpenAIChatModel(
|
|
24
|
+
model_name=model,
|
|
25
|
+
provider=OpenAIProvider(
|
|
26
|
+
base_url=f"{Config.VLLM_RESEARCH_BASE_URL or Config.VLLM_QA_BASE_URL}/v1",
|
|
27
|
+
api_key="none",
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
else:
|
|
31
|
+
return f"{provider}:{model}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def log(deps: "ResearchDeps", state: "ResearchState", msg: str) -> None:
|
|
35
|
+
deps.emit_log(msg, state)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def format_context_for_prompt(context: ResearchContext) -> str:
|
|
39
|
+
"""Format the research context as XML for inclusion in prompts."""
|
|
40
|
+
|
|
41
|
+
context_data = {
|
|
42
|
+
"original_question": context.original_question,
|
|
43
|
+
"unanswered_questions": context.sub_questions,
|
|
44
|
+
"qa_responses": [
|
|
45
|
+
{
|
|
46
|
+
"question": qa.query,
|
|
47
|
+
"answer": qa.answer,
|
|
48
|
+
"context_snippets": qa.context,
|
|
49
|
+
"sources": qa.sources, # pyright: ignore[reportAttributeAccessIssue]
|
|
50
|
+
}
|
|
51
|
+
for qa in context.qa_responses
|
|
52
|
+
],
|
|
53
|
+
"insights": [
|
|
54
|
+
{
|
|
55
|
+
"id": insight.id,
|
|
56
|
+
"summary": insight.summary,
|
|
57
|
+
"status": insight.status.value,
|
|
58
|
+
"supporting_sources": insight.supporting_sources,
|
|
59
|
+
"originating_questions": insight.originating_questions,
|
|
60
|
+
"notes": insight.notes,
|
|
61
|
+
}
|
|
62
|
+
for insight in context.insights
|
|
63
|
+
],
|
|
64
|
+
"gaps": [
|
|
65
|
+
{
|
|
66
|
+
"id": gap.id,
|
|
67
|
+
"description": gap.description,
|
|
68
|
+
"severity": gap.severity.value,
|
|
69
|
+
"blocking": gap.blocking,
|
|
70
|
+
"resolved": gap.resolved,
|
|
71
|
+
"resolved_by": gap.resolved_by,
|
|
72
|
+
"supporting_sources": gap.supporting_sources,
|
|
73
|
+
"notes": gap.notes,
|
|
74
|
+
}
|
|
75
|
+
for gap in context.gaps
|
|
76
|
+
],
|
|
77
|
+
}
|
|
78
|
+
return format_as_xml(context_data, root_tag="research_context")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def format_analysis_for_prompt(
|
|
82
|
+
analysis: InsightAnalysis | None,
|
|
83
|
+
) -> str:
|
|
84
|
+
"""Format the latest insight analysis as XML for prompts."""
|
|
85
|
+
|
|
86
|
+
if analysis is None:
|
|
87
|
+
return "<latest_analysis />"
|
|
88
|
+
|
|
89
|
+
data = {
|
|
90
|
+
"commentary": analysis.commentary,
|
|
91
|
+
"highlights": [
|
|
92
|
+
{
|
|
93
|
+
"id": insight.id,
|
|
94
|
+
"summary": insight.summary,
|
|
95
|
+
"status": insight.status.value,
|
|
96
|
+
"supporting_sources": insight.supporting_sources,
|
|
97
|
+
"originating_questions": insight.originating_questions,
|
|
98
|
+
"notes": insight.notes,
|
|
99
|
+
}
|
|
100
|
+
for insight in analysis.highlights
|
|
101
|
+
],
|
|
102
|
+
"gap_assessments": [
|
|
103
|
+
{
|
|
104
|
+
"id": gap.id,
|
|
105
|
+
"description": gap.description,
|
|
106
|
+
"severity": gap.severity.value,
|
|
107
|
+
"blocking": gap.blocking,
|
|
108
|
+
"resolved": gap.resolved,
|
|
109
|
+
"resolved_by": gap.resolved_by,
|
|
110
|
+
"supporting_sources": gap.supporting_sources,
|
|
111
|
+
"notes": gap.notes,
|
|
112
|
+
}
|
|
113
|
+
for gap in analysis.gap_assessments
|
|
114
|
+
],
|
|
115
|
+
"resolved_gaps": analysis.resolved_gaps,
|
|
116
|
+
"new_questions": analysis.new_questions,
|
|
117
|
+
}
|
|
118
|
+
return format_as_xml(data, root_tag="latest_analysis")
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
from collections.abc import Iterable
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
from haiku.rag.client import HaikuRAG
|
|
7
|
+
from haiku.rag.research.models import (
|
|
8
|
+
GapRecord,
|
|
9
|
+
InsightAnalysis,
|
|
10
|
+
InsightRecord,
|
|
11
|
+
SearchAnswer,
|
|
12
|
+
)
|
|
13
|
+
from haiku.rag.research.stream import ResearchStream
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ResearchContext(BaseModel):
|
|
17
|
+
"""Context shared across research agents."""
|
|
18
|
+
|
|
19
|
+
original_question: str = Field(description="The original research question")
|
|
20
|
+
sub_questions: list[str] = Field(
|
|
21
|
+
default_factory=list, description="Decomposed sub-questions"
|
|
22
|
+
)
|
|
23
|
+
qa_responses: list[SearchAnswer] = Field(
|
|
24
|
+
default_factory=list, description="Structured QA pairs used during research"
|
|
25
|
+
)
|
|
26
|
+
insights: list[InsightRecord] = Field(
|
|
27
|
+
default_factory=list, description="Key insights discovered"
|
|
28
|
+
)
|
|
29
|
+
gaps: list[GapRecord] = Field(
|
|
30
|
+
default_factory=list, description="Identified information gaps"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def add_qa_response(self, qa: SearchAnswer) -> None:
|
|
34
|
+
"""Add a structured QA response (minimal context already included)."""
|
|
35
|
+
self.qa_responses.append(qa)
|
|
36
|
+
|
|
37
|
+
def upsert_insights(self, records: Iterable[InsightRecord]) -> list[InsightRecord]:
|
|
38
|
+
"""Merge one or more insights into the shared context with deduplication."""
|
|
39
|
+
|
|
40
|
+
merged: list[InsightRecord] = []
|
|
41
|
+
for record in records:
|
|
42
|
+
candidate = InsightRecord.model_validate(record)
|
|
43
|
+
existing = next(
|
|
44
|
+
(ins for ins in self.insights if ins.id == candidate.id), None
|
|
45
|
+
)
|
|
46
|
+
if not existing:
|
|
47
|
+
existing = next(
|
|
48
|
+
(ins for ins in self.insights if ins.summary == candidate.summary),
|
|
49
|
+
None,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if existing:
|
|
53
|
+
existing.summary = candidate.summary
|
|
54
|
+
existing.status = candidate.status
|
|
55
|
+
if candidate.notes:
|
|
56
|
+
existing.notes = candidate.notes
|
|
57
|
+
existing.supporting_sources = _merge_unique(
|
|
58
|
+
existing.supporting_sources, candidate.supporting_sources
|
|
59
|
+
)
|
|
60
|
+
existing.originating_questions = _merge_unique(
|
|
61
|
+
existing.originating_questions, candidate.originating_questions
|
|
62
|
+
)
|
|
63
|
+
merged.append(existing)
|
|
64
|
+
else:
|
|
65
|
+
candidate = candidate.model_copy(deep=True)
|
|
66
|
+
if candidate.id is None: # pragma: no cover - defensive
|
|
67
|
+
raise ValueError(
|
|
68
|
+
"InsightRecord.id must be populated after validation"
|
|
69
|
+
)
|
|
70
|
+
candidate_id: str = candidate.id
|
|
71
|
+
candidate.id = self._allocate_insight_id(candidate_id)
|
|
72
|
+
self.insights.append(candidate)
|
|
73
|
+
merged.append(candidate)
|
|
74
|
+
|
|
75
|
+
return merged
|
|
76
|
+
|
|
77
|
+
def upsert_gaps(self, records: Iterable[GapRecord]) -> list[GapRecord]:
|
|
78
|
+
"""Merge one or more gap records into the shared context with deduplication."""
|
|
79
|
+
|
|
80
|
+
merged: list[GapRecord] = []
|
|
81
|
+
for record in records:
|
|
82
|
+
candidate = GapRecord.model_validate(record)
|
|
83
|
+
existing = next((gap for gap in self.gaps if gap.id == candidate.id), None)
|
|
84
|
+
if not existing:
|
|
85
|
+
existing = next(
|
|
86
|
+
(
|
|
87
|
+
gap
|
|
88
|
+
for gap in self.gaps
|
|
89
|
+
if gap.description == candidate.description
|
|
90
|
+
),
|
|
91
|
+
None,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if existing:
|
|
95
|
+
existing.description = candidate.description
|
|
96
|
+
existing.severity = candidate.severity
|
|
97
|
+
existing.blocking = candidate.blocking
|
|
98
|
+
existing.resolved = candidate.resolved
|
|
99
|
+
if candidate.notes:
|
|
100
|
+
existing.notes = candidate.notes
|
|
101
|
+
existing.supporting_sources = _merge_unique(
|
|
102
|
+
existing.supporting_sources, candidate.supporting_sources
|
|
103
|
+
)
|
|
104
|
+
existing.resolved_by = _merge_unique(
|
|
105
|
+
existing.resolved_by, candidate.resolved_by
|
|
106
|
+
)
|
|
107
|
+
merged.append(existing)
|
|
108
|
+
else:
|
|
109
|
+
candidate = candidate.model_copy(deep=True)
|
|
110
|
+
if candidate.id is None: # pragma: no cover - defensive
|
|
111
|
+
raise ValueError("GapRecord.id must be populated after validation")
|
|
112
|
+
candidate_id: str = candidate.id
|
|
113
|
+
candidate.id = self._allocate_gap_id(candidate_id)
|
|
114
|
+
self.gaps.append(candidate)
|
|
115
|
+
merged.append(candidate)
|
|
116
|
+
|
|
117
|
+
return merged
|
|
118
|
+
|
|
119
|
+
def mark_gap_resolved(
|
|
120
|
+
self, identifier: str, resolved_by: Iterable[str] | None = None
|
|
121
|
+
) -> GapRecord | None:
|
|
122
|
+
"""Mark a gap as resolved by identifier (id or description)."""
|
|
123
|
+
|
|
124
|
+
gap = self._find_gap(identifier)
|
|
125
|
+
if gap is None:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
gap.resolved = True
|
|
129
|
+
gap.blocking = False
|
|
130
|
+
if resolved_by:
|
|
131
|
+
gap.resolved_by = _merge_unique(gap.resolved_by, list(resolved_by))
|
|
132
|
+
return gap
|
|
133
|
+
|
|
134
|
+
def integrate_analysis(self, analysis: InsightAnalysis) -> None:
|
|
135
|
+
"""Apply an analysis result to the shared context."""
|
|
136
|
+
|
|
137
|
+
merged_insights: list[InsightRecord] = []
|
|
138
|
+
if analysis.highlights:
|
|
139
|
+
merged_insights = self.upsert_insights(analysis.highlights)
|
|
140
|
+
analysis.highlights = merged_insights
|
|
141
|
+
if analysis.gap_assessments:
|
|
142
|
+
merged_gaps = self.upsert_gaps(analysis.gap_assessments)
|
|
143
|
+
analysis.gap_assessments = merged_gaps
|
|
144
|
+
if analysis.resolved_gaps:
|
|
145
|
+
resolved_by_list = (
|
|
146
|
+
[ins.id for ins in merged_insights if ins.id is not None]
|
|
147
|
+
if merged_insights
|
|
148
|
+
else None
|
|
149
|
+
)
|
|
150
|
+
for resolved in analysis.resolved_gaps:
|
|
151
|
+
self.mark_gap_resolved(resolved, resolved_by=resolved_by_list)
|
|
152
|
+
for question in analysis.new_questions:
|
|
153
|
+
if question not in self.sub_questions:
|
|
154
|
+
self.sub_questions.append(question)
|
|
155
|
+
|
|
156
|
+
def _allocate_insight_id(self, candidate_id: str) -> str:
|
|
157
|
+
taken: set[str] = set()
|
|
158
|
+
for ins in self.insights:
|
|
159
|
+
if ins.id is not None:
|
|
160
|
+
taken.add(ins.id)
|
|
161
|
+
return _allocate_sequential_id(candidate_id, taken)
|
|
162
|
+
|
|
163
|
+
def _allocate_gap_id(self, candidate_id: str) -> str:
|
|
164
|
+
taken: set[str] = set()
|
|
165
|
+
for gap in self.gaps:
|
|
166
|
+
if gap.id is not None:
|
|
167
|
+
taken.add(gap.id)
|
|
168
|
+
return _allocate_sequential_id(candidate_id, taken)
|
|
169
|
+
|
|
170
|
+
def _find_gap(self, identifier: str) -> GapRecord | None:
|
|
171
|
+
normalized = identifier.lower().strip()
|
|
172
|
+
for gap in self.gaps:
|
|
173
|
+
if gap.id is not None and gap.id == normalized:
|
|
174
|
+
return gap
|
|
175
|
+
if gap.description.lower().strip() == normalized:
|
|
176
|
+
return gap
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class ResearchDependencies(BaseModel):
|
|
181
|
+
"""Dependencies for research agents with multi-agent context."""
|
|
182
|
+
|
|
183
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
184
|
+
|
|
185
|
+
client: HaikuRAG = Field(description="RAG client for document operations")
|
|
186
|
+
context: ResearchContext = Field(description="Shared research context")
|
|
187
|
+
console: Console | None = None
|
|
188
|
+
stream: ResearchStream | None = Field(
|
|
189
|
+
default=None, description="Optional research event stream"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _merge_unique(existing: list[str], incoming: Iterable[str]) -> list[str]:
|
|
194
|
+
"""Merge two iterables preserving order while removing duplicates."""
|
|
195
|
+
|
|
196
|
+
merged = list(existing)
|
|
197
|
+
seen = {item for item in existing if item}
|
|
198
|
+
for item in incoming:
|
|
199
|
+
if item and item not in seen:
|
|
200
|
+
merged.append(item)
|
|
201
|
+
seen.add(item)
|
|
202
|
+
return merged
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _allocate_sequential_id(candidate: str, taken: set[str]) -> str:
|
|
206
|
+
slug = candidate
|
|
207
|
+
if slug not in taken:
|
|
208
|
+
return slug
|
|
209
|
+
base = slug
|
|
210
|
+
counter = 2
|
|
211
|
+
while True:
|
|
212
|
+
slug = f"{base}-{counter}"
|
|
213
|
+
if slug not in taken:
|
|
214
|
+
return slug
|
|
215
|
+
counter += 1
|