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.

Files changed (101) hide show
  1. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/PKG-INFO +32 -13
  2. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/README.md +31 -12
  3. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/docs/agents.md +59 -10
  4. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/docs/cli.md +2 -0
  5. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/pyproject.toml +1 -1
  6. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/app.py +15 -16
  7. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/research/__init__.py +8 -0
  8. haiku_rag-0.11.0/src/haiku/rag/research/common.py +118 -0
  9. haiku_rag-0.11.0/src/haiku/rag/research/dependencies.py +215 -0
  10. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/research/graph.py +5 -3
  11. haiku_rag-0.11.0/src/haiku/rag/research/models.py +203 -0
  12. haiku_rag-0.11.0/src/haiku/rag/research/nodes/analysis.py +181 -0
  13. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/research/nodes/plan.py +16 -9
  14. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/research/nodes/search.py +14 -11
  15. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/research/nodes/synthesize.py +7 -3
  16. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/research/prompts.py +67 -28
  17. haiku_rag-0.11.0/src/haiku/rag/research/state.py +32 -0
  18. haiku_rag-0.11.0/src/haiku/rag/research/stream.py +177 -0
  19. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_research_graph.py +1 -2
  20. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_research_graph_integration.py +62 -13
  21. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/uv.lock +1 -1
  22. haiku_rag-0.10.2/src/haiku/rag/research/common.py +0 -53
  23. haiku_rag-0.10.2/src/haiku/rag/research/dependencies.py +0 -47
  24. haiku_rag-0.10.2/src/haiku/rag/research/models.py +0 -70
  25. haiku_rag-0.10.2/src/haiku/rag/research/nodes/evaluate.py +0 -80
  26. haiku_rag-0.10.2/src/haiku/rag/research/state.py +0 -25
  27. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/.github/FUNDING.yml +0 -0
  28. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/.github/workflows/build-docs.yml +0 -0
  29. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/.github/workflows/build-publish.yml +0 -0
  30. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/.gitignore +0 -0
  31. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/.pre-commit-config.yaml +0 -0
  32. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/.python-version +0 -0
  33. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/LICENSE +0 -0
  34. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/docs/benchmarks.md +0 -0
  35. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/docs/configuration.md +0 -0
  36. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/docs/index.md +0 -0
  37. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/docs/installation.md +0 -0
  38. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/docs/mcp.md +0 -0
  39. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/docs/python.md +0 -0
  40. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/docs/server.md +0 -0
  41. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/mkdocs.yml +0 -0
  42. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/__init__.py +0 -0
  43. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/chunker.py +0 -0
  44. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/cli.py +0 -0
  45. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/client.py +0 -0
  46. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/config.py +0 -0
  47. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/embeddings/__init__.py +0 -0
  48. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/embeddings/base.py +0 -0
  49. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/embeddings/ollama.py +0 -0
  50. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/embeddings/openai.py +0 -0
  51. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/embeddings/vllm.py +0 -0
  52. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/embeddings/voyageai.py +0 -0
  53. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/logging.py +0 -0
  54. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/mcp.py +0 -0
  55. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/migration.py +0 -0
  56. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/monitor.py +0 -0
  57. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/qa/__init__.py +0 -0
  58. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/qa/agent.py +0 -0
  59. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/qa/prompts.py +0 -0
  60. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/reader.py +0 -0
  61. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/reranking/__init__.py +0 -0
  62. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/reranking/base.py +0 -0
  63. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/reranking/cohere.py +0 -0
  64. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/reranking/mxbai.py +0 -0
  65. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/reranking/vllm.py +0 -0
  66. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/store/__init__.py +0 -0
  67. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/store/engine.py +0 -0
  68. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/store/models/__init__.py +0 -0
  69. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/store/models/chunk.py +0 -0
  70. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/store/models/document.py +0 -0
  71. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/store/repositories/__init__.py +0 -0
  72. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/store/repositories/chunk.py +0 -0
  73. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/store/repositories/document.py +0 -0
  74. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/store/repositories/settings.py +0 -0
  75. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/store/upgrades/__init__.py +0 -0
  76. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/store/upgrades/v0_10_1.py +0 -0
  77. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/store/upgrades/v0_9_3.py +0 -0
  78. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/src/haiku/rag/utils.py +0 -0
  79. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/__init__.py +0 -0
  80. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/conftest.py +0 -0
  81. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/generate_benchmark_db.py +0 -0
  82. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/llm_judge.py +0 -0
  83. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_app.py +0 -0
  84. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_chunk.py +0 -0
  85. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_chunker.py +0 -0
  86. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_cli.py +0 -0
  87. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_client.py +0 -0
  88. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_document.py +0 -0
  89. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_embedder.py +0 -0
  90. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_info.py +0 -0
  91. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_lancedb_connection.py +0 -0
  92. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_monitor.py +0 -0
  93. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_preprocessor.py +0 -0
  94. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_qa.py +0 -0
  95. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_reader.py +0 -0
  96. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_rebuild.py +0 -0
  97. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_reranker.py +0 -0
  98. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_search.py +0 -0
  99. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_settings.py +0 -0
  100. {haiku_rag-0.10.2 → haiku_rag-0.11.0}/tests/test_utils.py +0 -0
  101. {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.10.2
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
- PlanNode,
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
- question=(
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=3,
140
+ max_concurrency=2,
140
141
  )
141
142
  deps = ResearchDeps(client=client)
142
- start = PlanNode(provider=None, model=None)
143
- result = await graph.run(start, state=state, deps=deps)
144
- report = result.output
145
- print(report.title)
146
- print(report.executive_summary)
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
- PlanNode,
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
- question=(
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=3,
102
+ max_concurrency=2,
102
103
  )
103
104
  deps = ResearchDeps(client=client)
104
- start = PlanNode(provider=None, model=None)
105
- result = await graph.run(start, state=state, deps=deps)
106
- report = result.output
107
- print(report.title)
108
- print(report.executive_summary)
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 --> EvaluateNode
51
- EvaluateNode --> SearchDispatchNode
52
- EvaluateNode --> SynthesizeNode
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
- - Evaluate: extracts insights, proposes new questions, and checks sufficiency/confidence
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="What are the main drivers and trends of global temperature anomalies since 1990?",
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=3,
102
+ max_concurrency=2,
99
103
  )
100
104
  deps = ResearchDeps(client=client)
101
- result = await graph.run(PlanNode(provider=None, model=None), state=state, deps=deps)
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.10.2"
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
- question=question,
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
- try:
242
- result = await graph.run(start, state=state, deps=deps)
243
- report = result.output
244
- except Exception:
245
- from pydantic_graph import End
246
-
247
- async with graph.iter(start, state=state, deps=deps) as run:
248
- node = run.next_node
249
- while not isinstance(node, End):
250
- node = await run.next(node)
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
- raise RuntimeError("Graph did not produce a report")
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