dspy-deep_research 1.0.0
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.
- checksums.yaml +7 -0
- data/LICENSE +45 -0
- data/README.md +313 -0
- data/lib/dspy/deep_research/README.md +5 -0
- data/lib/dspy/deep_research/errors.rb +10 -0
- data/lib/dspy/deep_research/module.rb +616 -0
- data/lib/dspy/deep_research/section_queue.rb +79 -0
- data/lib/dspy/deep_research/signatures.rb +103 -0
- data/lib/dspy/deep_research/version.rb +7 -0
- data/lib/dspy/deep_research.rb +18 -0
- data/lib/dspy/deep_search/clients/exa_client.rb +168 -0
- data/lib/dspy/deep_search/gap_queue.rb +46 -0
- data/lib/dspy/deep_search/module.rb +463 -0
- data/lib/dspy/deep_search/signatures.rb +68 -0
- data/lib/dspy/deep_search/token_budget.rb +43 -0
- data/lib/dspy/deep_search/version.rb +7 -0
- metadata +106 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DSPy
|
|
4
|
+
module DeepSearch
|
|
5
|
+
class Module < DSPy::Module
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
class Result < T::Struct
|
|
9
|
+
const :answer, String
|
|
10
|
+
const :notes, T::Array[String]
|
|
11
|
+
const :citations, T::Array[String]
|
|
12
|
+
const :budget_exhausted, T::Boolean, default: false
|
|
13
|
+
const :warning, T.nilable(String), default: nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class TokenBudgetExceeded < DSPy::Error; end
|
|
17
|
+
|
|
18
|
+
DEFAULT_SEARCH_RESULTS = 5
|
|
19
|
+
|
|
20
|
+
MODEL_ENV_KEYS = {
|
|
21
|
+
seed: 'DSPY_DEEP_SEARCH_SEED_MODEL',
|
|
22
|
+
reader: 'DSPY_DEEP_SEARCH_READER_MODEL',
|
|
23
|
+
reason: 'DSPY_DEEP_SEARCH_REASON_MODEL'
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
MODEL_PRIORITY = {
|
|
27
|
+
seed: [
|
|
28
|
+
'gemini/gemini-2.5-flash-lite',
|
|
29
|
+
'anthropic/claude-haiku-4-5'
|
|
30
|
+
],
|
|
31
|
+
reader: [
|
|
32
|
+
'anthropic/claude-sonnet-4-5',
|
|
33
|
+
'openai/gpt-4.1'
|
|
34
|
+
],
|
|
35
|
+
reason: [
|
|
36
|
+
'gemini/gemini-2.5-pro',
|
|
37
|
+
'openai/o4-mini',
|
|
38
|
+
'anthropic/claude-4.1-opus'
|
|
39
|
+
]
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
subscribe 'lm.tokens', :meter_tokens
|
|
43
|
+
|
|
44
|
+
sig do
|
|
45
|
+
params(
|
|
46
|
+
token_budget: DSPy::DeepSearch::TokenBudget,
|
|
47
|
+
seed_predictor: T.untyped,
|
|
48
|
+
search_predictor: T.nilable(T.untyped),
|
|
49
|
+
reader_predictor: T.untyped,
|
|
50
|
+
reason_predictor: T.untyped,
|
|
51
|
+
search_client: DSPy::DeepSearch::Clients::ExaClient
|
|
52
|
+
).void
|
|
53
|
+
end
|
|
54
|
+
def initialize(
|
|
55
|
+
token_budget: DSPy::DeepSearch::TokenBudget.new(limit: 20_000),
|
|
56
|
+
seed_predictor: DSPy::Predict.new(DSPy::DeepSearch::Signatures::SeedQuery),
|
|
57
|
+
search_predictor: nil,
|
|
58
|
+
reader_predictor: DSPy::Predict.new(DSPy::DeepSearch::Signatures::ReadSource),
|
|
59
|
+
reason_predictor: DSPy::Predict.new(DSPy::DeepSearch::Signatures::ReasonStep),
|
|
60
|
+
search_client: DSPy::DeepSearch::Clients::ExaClient.new
|
|
61
|
+
)
|
|
62
|
+
super()
|
|
63
|
+
|
|
64
|
+
@token_budget = token_budget
|
|
65
|
+
@token_budget_limit = token_budget.limit
|
|
66
|
+
@seed_predictor = seed_predictor
|
|
67
|
+
@search_predictor = search_predictor
|
|
68
|
+
@reader_predictor = reader_predictor
|
|
69
|
+
@reason_predictor = reason_predictor
|
|
70
|
+
@search_client = search_client
|
|
71
|
+
@gap_queue = DSPy::DeepSearch::GapQueue.new
|
|
72
|
+
|
|
73
|
+
configure_default_predictor_models
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def forward_untyped(**input_values)
|
|
77
|
+
question = input_values[:question]
|
|
78
|
+
unless question.is_a?(String)
|
|
79
|
+
raise ArgumentError, "DeepSearch expects keyword argument :question"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
reset_state!
|
|
83
|
+
process_question(question)
|
|
84
|
+
rescue DSPy::DeepSearch::TokenBudget::Exceeded => e
|
|
85
|
+
build_budget_exhausted_result(question, e)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
sig { override.returns(T::Array[[String, DSPy::Module]]) }
|
|
89
|
+
def named_predictors
|
|
90
|
+
pairs = []
|
|
91
|
+
pairs << ["seed_predictor", @seed_predictor] if @seed_predictor
|
|
92
|
+
pairs << ["search_predictor", T.must(@search_predictor)] if @search_predictor
|
|
93
|
+
pairs << ["reader_predictor", @reader_predictor] if @reader_predictor
|
|
94
|
+
pairs << ["reason_predictor", @reason_predictor] if @reason_predictor
|
|
95
|
+
pairs
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
sig { override.returns(T::Array[DSPy::Module]) }
|
|
99
|
+
def predictors
|
|
100
|
+
named_predictors.map { |(_, predictor)| predictor }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
sig { params(instruction: String).returns(Module) }
|
|
104
|
+
def with_instruction(instruction)
|
|
105
|
+
clone_with(
|
|
106
|
+
seed_predictor: apply_instruction(@seed_predictor, instruction),
|
|
107
|
+
search_predictor: apply_instruction(@search_predictor, instruction),
|
|
108
|
+
reader_predictor: apply_instruction(@reader_predictor, instruction),
|
|
109
|
+
reason_predictor: apply_instruction(@reason_predictor, instruction),
|
|
110
|
+
token_budget_limit: @token_budget_limit
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
sig { params(examples: T::Array[DSPy::FewShotExample]).returns(Module) }
|
|
115
|
+
def with_examples(examples)
|
|
116
|
+
examples_copy = examples.map { |example| example }
|
|
117
|
+
clone_with(
|
|
118
|
+
seed_predictor: apply_examples(@seed_predictor, examples_copy),
|
|
119
|
+
search_predictor: apply_examples(@search_predictor, examples_copy),
|
|
120
|
+
reader_predictor: apply_examples(@reader_predictor, examples_copy),
|
|
121
|
+
reason_predictor: apply_examples(@reason_predictor, examples_copy),
|
|
122
|
+
token_budget_limit: @token_budget_limit
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
sig { params(limit: Integer).returns(Module) }
|
|
126
|
+
def with_token_budget(limit)
|
|
127
|
+
clone_with(
|
|
128
|
+
seed_predictor: @seed_predictor,
|
|
129
|
+
search_predictor: @search_predictor,
|
|
130
|
+
reader_predictor: @reader_predictor,
|
|
131
|
+
reason_predictor: @reason_predictor,
|
|
132
|
+
token_budget_limit: limit
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
sig { params(question: String).returns(Result) }
|
|
139
|
+
def process_question(question)
|
|
140
|
+
query = @seed_predictor.call(question: question).query
|
|
141
|
+
loop do
|
|
142
|
+
emit_loop_started(question, query)
|
|
143
|
+
|
|
144
|
+
urls = fetch_search_urls(query)
|
|
145
|
+
break if urls.empty?
|
|
146
|
+
|
|
147
|
+
urls.each { |url| enqueue_url(url) }
|
|
148
|
+
collect_notes
|
|
149
|
+
|
|
150
|
+
decision = @reason_predictor.call(question: question, insights: @notes)
|
|
151
|
+
emit_reason_decision(question, decision)
|
|
152
|
+
|
|
153
|
+
case decision.decision
|
|
154
|
+
when DSPy::DeepSearch::Signatures::ReasonStep::Decision::Answer
|
|
155
|
+
answer_text = decision.draft_answer || synthesize_answer
|
|
156
|
+
return Result.new(answer: answer_text, notes: @notes.dup, citations: @citations.dup)
|
|
157
|
+
when DSPy::DeepSearch::Signatures::ReasonStep::Decision::ContinueSearch
|
|
158
|
+
query = decision.refined_query || query
|
|
159
|
+
next
|
|
160
|
+
when DSPy::DeepSearch::Signatures::ReasonStep::Decision::ReadMore
|
|
161
|
+
collect_notes if pending_urls?
|
|
162
|
+
next
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
Result.new(answer: synthesize_answer, notes: @notes.dup, citations: @citations.dup)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
sig { params(url: String).void }
|
|
170
|
+
def enqueue_url(url)
|
|
171
|
+
@gap_queue.enqueue(url)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
sig { returns(T::Boolean) }
|
|
175
|
+
def pending_urls?
|
|
176
|
+
!@gap_queue.empty?
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
sig { void }
|
|
180
|
+
def collect_notes
|
|
181
|
+
until @gap_queue.empty?
|
|
182
|
+
url = @gap_queue.dequeue
|
|
183
|
+
fetch_and_extract(url)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
sig { params(url: String).void }
|
|
188
|
+
def fetch_and_extract(url)
|
|
189
|
+
DSPy.event(
|
|
190
|
+
"deep_search.fetch.started",
|
|
191
|
+
url: url
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
notes_before = @notes.length
|
|
195
|
+
citations_before = @citations.length
|
|
196
|
+
|
|
197
|
+
contents = @search_client.contents(urls: [url])
|
|
198
|
+
record_notes(url, contents)
|
|
199
|
+
reader_notes = @reader_predictor.call(url: url).notes
|
|
200
|
+
@notes.concat(Array(reader_notes))
|
|
201
|
+
|
|
202
|
+
DSPy.event(
|
|
203
|
+
"deep_search.fetch.completed",
|
|
204
|
+
url: url,
|
|
205
|
+
notes_added: @notes.length - notes_before,
|
|
206
|
+
citations_added: @citations.length - citations_before,
|
|
207
|
+
token_budget_remaining: token_budget_remaining
|
|
208
|
+
)
|
|
209
|
+
rescue DSPy::DeepSearch::Clients::ExaClient::ApiError => e
|
|
210
|
+
DSPy.event(
|
|
211
|
+
"deep_search.fetch.failed",
|
|
212
|
+
url: url,
|
|
213
|
+
error: e.message
|
|
214
|
+
)
|
|
215
|
+
DSPy.logger&.warn("DeepSearch fetch failed", url: url, error: e.message)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
sig { params(url: String, contents: T::Array[DSPy::DeepSearch::Clients::ExaClient::Content]).void }
|
|
219
|
+
def record_notes(url, contents)
|
|
220
|
+
contents.each do |content|
|
|
221
|
+
if content.summary
|
|
222
|
+
@notes << content.summary
|
|
223
|
+
end
|
|
224
|
+
content.highlights.each do |highlight|
|
|
225
|
+
@notes << highlight
|
|
226
|
+
end
|
|
227
|
+
@citations << url unless @citations.include?(url)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
sig { returns(String) }
|
|
232
|
+
def synthesize_answer
|
|
233
|
+
return "" if @notes.empty?
|
|
234
|
+
|
|
235
|
+
@notes.first(5).join("\n")
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
sig { void }
|
|
239
|
+
def reset_state!
|
|
240
|
+
@notes = []
|
|
241
|
+
@citations = []
|
|
242
|
+
@gap_queue = DSPy::DeepSearch::GapQueue.new
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
sig { params(query: String).returns(T::Array[String]) }
|
|
246
|
+
def fetch_search_urls(query)
|
|
247
|
+
if @search_predictor
|
|
248
|
+
Array(@search_predictor.call(query: query).urls).compact
|
|
249
|
+
else
|
|
250
|
+
results = Array(
|
|
251
|
+
@search_client.search(
|
|
252
|
+
query: query,
|
|
253
|
+
num_results: DEFAULT_SEARCH_RESULTS,
|
|
254
|
+
autoprompt: true
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
results.map do |result|
|
|
258
|
+
if result.respond_to?(:url)
|
|
259
|
+
result.url
|
|
260
|
+
elsif result.is_a?(Hash)
|
|
261
|
+
result[:url] || result['url']
|
|
262
|
+
else
|
|
263
|
+
nil
|
|
264
|
+
end
|
|
265
|
+
end.compact.uniq
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
sig { params(_event_name: String, attrs: T::Hash[Symbol, T.untyped]).void }
|
|
270
|
+
def meter_tokens(_event_name, attrs)
|
|
271
|
+
@token_budget.track!(
|
|
272
|
+
prompt_tokens: attrs[:input_tokens].to_i,
|
|
273
|
+
completion_tokens: attrs[:output_tokens].to_i
|
|
274
|
+
)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
sig do
|
|
278
|
+
params(
|
|
279
|
+
seed_predictor: T.untyped,
|
|
280
|
+
search_predictor: T.nilable(T.untyped),
|
|
281
|
+
reader_predictor: T.untyped,
|
|
282
|
+
reason_predictor: T.untyped,
|
|
283
|
+
token_budget_limit: Integer
|
|
284
|
+
).returns(Module)
|
|
285
|
+
end
|
|
286
|
+
def clone_with(seed_predictor:, search_predictor:, reader_predictor:, reason_predictor:, token_budget_limit: @token_budget_limit)
|
|
287
|
+
self.class.new(
|
|
288
|
+
token_budget: DSPy::DeepSearch::TokenBudget.new(limit: token_budget_limit),
|
|
289
|
+
seed_predictor: seed_predictor,
|
|
290
|
+
search_predictor: search_predictor,
|
|
291
|
+
reader_predictor: reader_predictor,
|
|
292
|
+
reason_predictor: reason_predictor,
|
|
293
|
+
search_client: @search_client
|
|
294
|
+
)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
sig { params(predictor: T.nilable(T.untyped), instruction: String).returns(T.nilable(T.untyped)) }
|
|
298
|
+
def apply_instruction(predictor, instruction)
|
|
299
|
+
return nil if predictor.nil?
|
|
300
|
+
return predictor.with_instruction(instruction) if predictor.respond_to?(:with_instruction)
|
|
301
|
+
predictor
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
sig { params(predictor: T.nilable(T.untyped), examples: T::Array[DSPy::FewShotExample]).returns(T.nilable(T.untyped)) }
|
|
305
|
+
def apply_examples(predictor, examples)
|
|
306
|
+
return nil if predictor.nil?
|
|
307
|
+
return predictor.with_examples(examples) if predictor.respond_to?(:with_examples)
|
|
308
|
+
predictor
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
sig { params(question: String, query: String).void }
|
|
312
|
+
def emit_loop_started(question, query)
|
|
313
|
+
DSPy.event(
|
|
314
|
+
"deep_search.loop.started",
|
|
315
|
+
question: question,
|
|
316
|
+
query: query,
|
|
317
|
+
token_budget_remaining: token_budget_remaining
|
|
318
|
+
)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
sig { params(question: String, decision: T.untyped).void }
|
|
322
|
+
def emit_reason_decision(question, decision)
|
|
323
|
+
decision_enum = decision.respond_to?(:decision) ? decision.decision : nil
|
|
324
|
+
serialized_decision =
|
|
325
|
+
if decision_enum.respond_to?(:serialize)
|
|
326
|
+
decision_enum.serialize
|
|
327
|
+
elsif decision_enum.respond_to?(:to_s)
|
|
328
|
+
decision_enum.to_s
|
|
329
|
+
else
|
|
330
|
+
nil
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
DSPy.event(
|
|
334
|
+
"deep_search.reason.decision",
|
|
335
|
+
question: question,
|
|
336
|
+
decision: serialized_decision,
|
|
337
|
+
notes_count: @notes.length,
|
|
338
|
+
citations_count: @citations.length,
|
|
339
|
+
refined_query: decision.respond_to?(:refined_query) ? decision.refined_query : nil,
|
|
340
|
+
draft_answer: decision.respond_to?(:draft_answer) ? decision.draft_answer : nil,
|
|
341
|
+
token_budget_remaining: token_budget_remaining
|
|
342
|
+
)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
sig { returns(Integer) }
|
|
346
|
+
def token_budget_remaining
|
|
347
|
+
remaining = @token_budget_limit - @token_budget.total_tokens
|
|
348
|
+
remaining.negative? ? 0 : remaining
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def configure_default_predictor_models
|
|
352
|
+
@lm_cache = {}
|
|
353
|
+
assign_model(@seed_predictor, :seed)
|
|
354
|
+
assign_model(@reader_predictor, :reader)
|
|
355
|
+
assign_model(@reason_predictor, :reason)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def env_model(role)
|
|
359
|
+
key = MODEL_ENV_KEYS[role]
|
|
360
|
+
value = key ? ENV[key] : nil
|
|
361
|
+
return nil if value.nil?
|
|
362
|
+
|
|
363
|
+
trimmed = value.strip
|
|
364
|
+
trimmed.empty? ? nil : trimmed
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def assign_model(predictor, role)
|
|
368
|
+
return unless predictor
|
|
369
|
+
return if predictor.respond_to?(:config) && predictor.config.lm
|
|
370
|
+
|
|
371
|
+
candidates = []
|
|
372
|
+
env_override = env_model(role)
|
|
373
|
+
candidates << env_override if env_override
|
|
374
|
+
candidates.concat(Array(MODEL_PRIORITY[role]))
|
|
375
|
+
|
|
376
|
+
candidates.each do |model_id|
|
|
377
|
+
next unless model_id
|
|
378
|
+
lm = build_lm(model_id)
|
|
379
|
+
next unless lm
|
|
380
|
+
|
|
381
|
+
begin
|
|
382
|
+
predictor.configure { |config| config.lm = lm }
|
|
383
|
+
return
|
|
384
|
+
rescue StandardError => e
|
|
385
|
+
DSPy.logger&.warn(
|
|
386
|
+
"DeepSearch predictor LM assignment error",
|
|
387
|
+
role: role,
|
|
388
|
+
model: model_id,
|
|
389
|
+
error: e.message
|
|
390
|
+
)
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
DSPy.logger&.warn(
|
|
395
|
+
"DeepSearch predictor LM assignment skipped (no viable model)",
|
|
396
|
+
role: role
|
|
397
|
+
)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def build_lm(model_id)
|
|
401
|
+
@lm_cache ||= {}
|
|
402
|
+
return @lm_cache[model_id] if @lm_cache.key?(model_id)
|
|
403
|
+
|
|
404
|
+
provider = model_id.split('/', 2).first
|
|
405
|
+
api_key = api_key_for(provider)
|
|
406
|
+
unless api_key && !api_key.strip.empty?
|
|
407
|
+
DSPy.logger&.warn(
|
|
408
|
+
"DeepSearch skipped LM assignment due to missing API key",
|
|
409
|
+
model: model_id,
|
|
410
|
+
provider: provider
|
|
411
|
+
)
|
|
412
|
+
return nil
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
@lm_cache[model_id] = DSPy::LM.new(model_id, api_key: api_key)
|
|
416
|
+
rescue StandardError => e
|
|
417
|
+
DSPy.logger&.warn(
|
|
418
|
+
"DeepSearch failed to initialize LM",
|
|
419
|
+
model: model_id,
|
|
420
|
+
error: e.message
|
|
421
|
+
)
|
|
422
|
+
nil
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def api_key_for(provider)
|
|
426
|
+
case provider
|
|
427
|
+
when 'openai'
|
|
428
|
+
ENV['OPENAI_API_KEY']
|
|
429
|
+
when 'anthropic'
|
|
430
|
+
ENV['ANTHROPIC_API_KEY']
|
|
431
|
+
when 'gemini'
|
|
432
|
+
ENV['GEMINI_API_KEY']
|
|
433
|
+
when 'google'
|
|
434
|
+
ENV['GEMINI_API_KEY'] || ENV['GOOGLE_API_KEY']
|
|
435
|
+
else
|
|
436
|
+
nil
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
sig { params(question: String, error: DSPy::DeepSearch::TokenBudget::Exceeded).returns(Result) }
|
|
441
|
+
def build_budget_exhausted_result(question, error)
|
|
442
|
+
warning = error.message
|
|
443
|
+
DSPy.event(
|
|
444
|
+
"deep_search.budget.exhausted",
|
|
445
|
+
question: question,
|
|
446
|
+
notes_count: @notes.length,
|
|
447
|
+
citations_count: @citations.length,
|
|
448
|
+
token_budget_limit: @token_budget_limit,
|
|
449
|
+
total_tokens: @token_budget.total_tokens,
|
|
450
|
+
warning: warning
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
Result.new(
|
|
454
|
+
answer: synthesize_answer,
|
|
455
|
+
notes: @notes.dup,
|
|
456
|
+
citations: @citations.dup,
|
|
457
|
+
budget_exhausted: true,
|
|
458
|
+
warning: warning
|
|
459
|
+
)
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DSPy
|
|
4
|
+
module DeepSearch
|
|
5
|
+
module Signatures
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
class SeedQuery < DSPy::Signature
|
|
9
|
+
description "Seed the first search query for DeepSearch"
|
|
10
|
+
|
|
11
|
+
input do
|
|
12
|
+
const :question, String, description: "User research question"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
output do
|
|
16
|
+
const :query, String, description: "Initial search query"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class SearchSources < DSPy::Signature
|
|
21
|
+
description "Call the search provider and return candidate URLs"
|
|
22
|
+
|
|
23
|
+
input do
|
|
24
|
+
const :query, String, description: "Search engine query"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
output do
|
|
28
|
+
const :urls, T::Array[String], description: "Ranked URLs to read next"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class ReadSource < DSPy::Signature
|
|
33
|
+
description "Summarize a single source into bullet notes"
|
|
34
|
+
|
|
35
|
+
input do
|
|
36
|
+
const :url, String, description: "URL selected by the search step"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
output do
|
|
40
|
+
const :notes, T::Array[String], description: "Key takeaways from the page"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class ReasonStep < DSPy::Signature
|
|
45
|
+
description "Decide whether to keep searching, read more, or answer"
|
|
46
|
+
|
|
47
|
+
class Decision < T::Enum
|
|
48
|
+
enums do
|
|
49
|
+
ContinueSearch = new("continue_search")
|
|
50
|
+
ReadMore = new("read_more")
|
|
51
|
+
Answer = new("answer")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
input do
|
|
56
|
+
const :question, String, description: "Original user question"
|
|
57
|
+
const :insights, T::Array[String], description: "Accumulated notes"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
output do
|
|
61
|
+
const :decision, Decision, description: "Next action for the loop"
|
|
62
|
+
const :refined_query, T.nilable(String), description: "Follow-up search query"
|
|
63
|
+
const :draft_answer, T.nilable(String), description: "Candidate answer"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DSPy
|
|
4
|
+
module DeepSearch
|
|
5
|
+
class TokenBudget
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
class Exceeded < StandardError; end
|
|
9
|
+
|
|
10
|
+
sig { returns(Integer) }
|
|
11
|
+
attr_reader :limit
|
|
12
|
+
|
|
13
|
+
sig { returns(Integer) }
|
|
14
|
+
attr_reader :total_tokens
|
|
15
|
+
|
|
16
|
+
sig { params(limit: Integer).void }
|
|
17
|
+
def initialize(limit:)
|
|
18
|
+
@limit = limit
|
|
19
|
+
@total_tokens = T.let(0, Integer)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
sig do
|
|
23
|
+
params(
|
|
24
|
+
prompt_tokens: Integer,
|
|
25
|
+
completion_tokens: Integer
|
|
26
|
+
).void
|
|
27
|
+
end
|
|
28
|
+
def track!(prompt_tokens:, completion_tokens:)
|
|
29
|
+
prompt = T.must(prompt_tokens)
|
|
30
|
+
completion = T.must(completion_tokens)
|
|
31
|
+
|
|
32
|
+
increment = prompt + completion
|
|
33
|
+
new_total = @total_tokens + increment
|
|
34
|
+
|
|
35
|
+
if new_total >= limit
|
|
36
|
+
raise Exceeded, "Token budget exceeded: #{new_total}/#{limit}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
@total_tokens = new_total
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: dspy-deep_research
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Vicente Reig Rincón de Arellano
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: dspy
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.30'
|
|
19
|
+
- - ">="
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: 0.30.1
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - "~>"
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '0.30'
|
|
29
|
+
- - ">="
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: 0.30.1
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: dspy-deep_search
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - '='
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: 1.0.0
|
|
39
|
+
type: :runtime
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - '='
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: 1.0.0
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: sorbet-runtime
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - "~>"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '0.5'
|
|
53
|
+
type: :runtime
|
|
54
|
+
prerelease: false
|
|
55
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - "~>"
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '0.5'
|
|
60
|
+
description: Planner, queue, and coherence orchestration built on DSPy::DeepSearch.
|
|
61
|
+
email:
|
|
62
|
+
- oss@vicente.services
|
|
63
|
+
executables: []
|
|
64
|
+
extensions: []
|
|
65
|
+
extra_rdoc_files: []
|
|
66
|
+
files:
|
|
67
|
+
- LICENSE
|
|
68
|
+
- README.md
|
|
69
|
+
- lib/dspy/deep_research.rb
|
|
70
|
+
- lib/dspy/deep_research/README.md
|
|
71
|
+
- lib/dspy/deep_research/errors.rb
|
|
72
|
+
- lib/dspy/deep_research/module.rb
|
|
73
|
+
- lib/dspy/deep_research/section_queue.rb
|
|
74
|
+
- lib/dspy/deep_research/signatures.rb
|
|
75
|
+
- lib/dspy/deep_research/version.rb
|
|
76
|
+
- lib/dspy/deep_search/clients/exa_client.rb
|
|
77
|
+
- lib/dspy/deep_search/gap_queue.rb
|
|
78
|
+
- lib/dspy/deep_search/module.rb
|
|
79
|
+
- lib/dspy/deep_search/signatures.rb
|
|
80
|
+
- lib/dspy/deep_search/token_budget.rb
|
|
81
|
+
- lib/dspy/deep_search/version.rb
|
|
82
|
+
homepage: https://vicentereig.github.io/dspy.rb/
|
|
83
|
+
licenses:
|
|
84
|
+
- MIT
|
|
85
|
+
metadata:
|
|
86
|
+
homepage_uri: https://vicentereig.github.io/dspy.rb/
|
|
87
|
+
source_code_uri: https://github.com/vicentereig/dspy.rb
|
|
88
|
+
changelog_uri: https://github.com/vicentereig/dspy.rb/blob/main/CHANGELOG.md
|
|
89
|
+
rdoc_options: []
|
|
90
|
+
require_paths:
|
|
91
|
+
- lib
|
|
92
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - ">="
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '3.1'
|
|
97
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
98
|
+
requirements:
|
|
99
|
+
- - ">="
|
|
100
|
+
- !ruby/object:Gem::Version
|
|
101
|
+
version: '0'
|
|
102
|
+
requirements: []
|
|
103
|
+
rubygems_version: 3.6.9
|
|
104
|
+
specification_version: 4
|
|
105
|
+
summary: DeepResearch orchestration for DSPy
|
|
106
|
+
test_files: []
|