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,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DSPy
|
|
4
|
+
module DeepResearch
|
|
5
|
+
module Signatures
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
class BuildOutline < DSPy::Signature
|
|
9
|
+
description "Generate an outline of sections to investigate for the research brief"
|
|
10
|
+
|
|
11
|
+
class Mode < T::Enum
|
|
12
|
+
enums do
|
|
13
|
+
Light = new("light")
|
|
14
|
+
Medium = new("medium")
|
|
15
|
+
Hard = new("hard")
|
|
16
|
+
Ultra = new("ultra")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class SectionSpec < T::Struct
|
|
21
|
+
const :identifier, String
|
|
22
|
+
const :title, String
|
|
23
|
+
const :prompt, String
|
|
24
|
+
const :token_budget, Integer
|
|
25
|
+
const :attempt, Integer, default: 0
|
|
26
|
+
const :parent_identifier, T.nilable(String), default: nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
input do
|
|
30
|
+
const :brief, String, description: "Research brief or question to investigate"
|
|
31
|
+
const :mode, Mode, description: "Desired research intensity mode", default: Mode::Medium
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
output do
|
|
35
|
+
const :sections, T::Array[SectionSpec], description: "Ordered section specifications to investigate"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class SynthesizeSection < DSPy::Signature
|
|
40
|
+
description "Transform DeepSearch results into a coherent section draft"
|
|
41
|
+
|
|
42
|
+
input do
|
|
43
|
+
const :brief, String, description: "Original research brief"
|
|
44
|
+
const :section, BuildOutline::SectionSpec, description: "Section specification being drafted"
|
|
45
|
+
const :answer, String, description: "Candidate answer from DeepSearch"
|
|
46
|
+
const :notes, T::Array[String], description: "Supporting notes collected during DeepSearch"
|
|
47
|
+
const :citations, T::Array[String], description: "Citations gathered for this section"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
output do
|
|
51
|
+
const :draft, String, description: "Section draft ready for aggregation"
|
|
52
|
+
const :citations, T::Array[String], description: "Filtered citations supporting the draft"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
class QAReview < DSPy::Signature
|
|
57
|
+
description "Decide whether a section draft is ready or requires more evidence"
|
|
58
|
+
|
|
59
|
+
class Status < T::Enum
|
|
60
|
+
enums do
|
|
61
|
+
Approved = new("approved")
|
|
62
|
+
NeedsMoreEvidence = new("needs_more_evidence")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
input do
|
|
67
|
+
const :brief, String, description: "Research brief"
|
|
68
|
+
const :section, BuildOutline::SectionSpec, description: "Section specification with metadata"
|
|
69
|
+
const :draft, String, description: "Draft content for the section"
|
|
70
|
+
const :notes, T::Array[String], description: "Supporting evidence notes"
|
|
71
|
+
const :citations, T::Array[String], description: "Citations backing the draft"
|
|
72
|
+
const :attempt, Integer, description: "Number of attempts made for this section"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
output do
|
|
76
|
+
const :status, Status, description: "QA decision for the section"
|
|
77
|
+
const :follow_up_prompt, T.nilable(String), description: "Additional prompt if more evidence is required"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
class AssembleReport < DSPy::Signature
|
|
82
|
+
description "Aggregate accepted sections into the final deliverable"
|
|
83
|
+
|
|
84
|
+
class SectionDraft < T::Struct
|
|
85
|
+
const :identifier, String
|
|
86
|
+
const :title, String
|
|
87
|
+
const :draft, String
|
|
88
|
+
const :citations, T::Array[String]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
input do
|
|
92
|
+
const :brief, String, description: "Research brief"
|
|
93
|
+
const :sections, T::Array[SectionDraft], description: "Accepted section drafts"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
output do
|
|
97
|
+
const :report, String, description: "Final synthesized report"
|
|
98
|
+
const :citations, T::Array[String], description: "Consolidated citations for the report"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sorbet-runtime"
|
|
4
|
+
|
|
5
|
+
require_relative "deep_search"
|
|
6
|
+
|
|
7
|
+
require_relative "deep_research/version"
|
|
8
|
+
require_relative "deep_research/errors"
|
|
9
|
+
require_relative "deep_research/signatures"
|
|
10
|
+
require_relative "deep_research/section_queue"
|
|
11
|
+
require_relative "deep_research/module"
|
|
12
|
+
require_relative "deep_research_with_memory"
|
|
13
|
+
|
|
14
|
+
module DSPy
|
|
15
|
+
module DeepResearch
|
|
16
|
+
extend T::Sig
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DSPy
|
|
4
|
+
module DeepSearch
|
|
5
|
+
module Clients
|
|
6
|
+
class ExaClient
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
class ConfigurationError < Error; end
|
|
11
|
+
class ApiError < Error; end
|
|
12
|
+
|
|
13
|
+
class Result < T::Struct
|
|
14
|
+
const :url, String
|
|
15
|
+
const :title, T.nilable(String)
|
|
16
|
+
const :summary, T.nilable(String)
|
|
17
|
+
const :highlights, T::Array[String]
|
|
18
|
+
const :score, T.nilable(Float)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Content < T::Struct
|
|
22
|
+
const :url, String
|
|
23
|
+
const :text, T.nilable(String)
|
|
24
|
+
const :summary, T.nilable(String)
|
|
25
|
+
const :highlights, T::Array[String]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
sig { params(client: T.nilable(::Exa::Client)).void }
|
|
29
|
+
def initialize(client: nil)
|
|
30
|
+
@client = T.let(client || build_client, ::Exa::Client)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
sig do
|
|
34
|
+
params(
|
|
35
|
+
query: String,
|
|
36
|
+
num_results: Integer,
|
|
37
|
+
autoprompt: T::Boolean
|
|
38
|
+
).returns(T::Array[Result])
|
|
39
|
+
end
|
|
40
|
+
def search(query:, num_results: 5, autoprompt: true)
|
|
41
|
+
response = with_api_errors do
|
|
42
|
+
client.search.search(
|
|
43
|
+
query: query,
|
|
44
|
+
num_results: num_results,
|
|
45
|
+
use_autoprompt: autoprompt,
|
|
46
|
+
summary: true
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
response.results.filter_map do |result|
|
|
51
|
+
next if result.url.nil?
|
|
52
|
+
|
|
53
|
+
Result.new(
|
|
54
|
+
url: result.url,
|
|
55
|
+
title: result.title,
|
|
56
|
+
summary: result.summary,
|
|
57
|
+
highlights: normalize_highlights(result.highlights),
|
|
58
|
+
score: result.score
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
sig do
|
|
64
|
+
params(
|
|
65
|
+
urls: T::Array[String],
|
|
66
|
+
options: T::Hash[Symbol, T.untyped]
|
|
67
|
+
).returns(T::Array[Content])
|
|
68
|
+
end
|
|
69
|
+
def contents(urls:, **options)
|
|
70
|
+
raise ArgumentError, "urls must not be empty" if urls.empty?
|
|
71
|
+
|
|
72
|
+
defaults = {
|
|
73
|
+
text: true,
|
|
74
|
+
summary: true,
|
|
75
|
+
highlights: true,
|
|
76
|
+
filter_empty_results: true
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
payload = Exa::Types::ContentsRequest.new(**defaults.merge(options).merge(urls: urls)).to_payload
|
|
80
|
+
|
|
81
|
+
raw_response = with_api_errors do
|
|
82
|
+
client.request(
|
|
83
|
+
method: :post,
|
|
84
|
+
path: "contents",
|
|
85
|
+
body: payload,
|
|
86
|
+
response_model: nil
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
symbolized = symbolize_keys(raw_response)
|
|
91
|
+
|
|
92
|
+
check_content_statuses!(symbolized)
|
|
93
|
+
|
|
94
|
+
Array(symbolized[:results]).each_with_index.filter_map do |result, index|
|
|
95
|
+
result = symbolize_keys(result)
|
|
96
|
+
url = result[:url] || urls[index]
|
|
97
|
+
next if url.nil?
|
|
98
|
+
|
|
99
|
+
Content.new(
|
|
100
|
+
url: url,
|
|
101
|
+
text: result[:text],
|
|
102
|
+
summary: result[:summary],
|
|
103
|
+
highlights: normalize_highlights(result[:highlights])
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
sig { returns(::Exa::Client) }
|
|
111
|
+
def build_client
|
|
112
|
+
::Exa::Client.new
|
|
113
|
+
rescue ::Exa::Errors::ConfigurationError => e
|
|
114
|
+
raise ConfigurationError, e.message
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
sig { params(response: T::Hash[Symbol, T.untyped]).void }
|
|
118
|
+
def check_content_statuses!(response)
|
|
119
|
+
statuses = response[:statuses]
|
|
120
|
+
return if statuses.nil?
|
|
121
|
+
|
|
122
|
+
failure = Array(statuses).map { |status| symbolize_keys(status) }.find { |status| status[:status] != "success" }
|
|
123
|
+
return if failure.nil?
|
|
124
|
+
|
|
125
|
+
error_details = failure[:error] ? failure[:error].inspect : nil
|
|
126
|
+
message = [
|
|
127
|
+
"Exa contents request failed for #{failure[:id]}",
|
|
128
|
+
failure[:status],
|
|
129
|
+
error_details
|
|
130
|
+
].compact.join(" - ")
|
|
131
|
+
|
|
132
|
+
raise ApiError, message
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
sig { params(hash: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
|
|
136
|
+
def symbolize_keys(hash)
|
|
137
|
+
case hash
|
|
138
|
+
when Hash
|
|
139
|
+
hash.each_with_object({}) do |(key, value), acc|
|
|
140
|
+
acc[(key.is_a?(String) ? key.to_sym : key)] = value
|
|
141
|
+
end
|
|
142
|
+
else
|
|
143
|
+
{}
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
sig { params(highlights: T.nilable(T::Array[T.nilable(String)])).returns(T::Array[String]) }
|
|
148
|
+
def normalize_highlights(highlights)
|
|
149
|
+
Array(highlights).compact.map(&:to_s)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
sig { params(block: T.proc.returns(T.untyped)).returns(T.untyped) }
|
|
153
|
+
def with_api_errors(&block)
|
|
154
|
+
block.call
|
|
155
|
+
rescue ::Exa::Errors::ConfigurationError => e
|
|
156
|
+
raise ConfigurationError, e.message
|
|
157
|
+
rescue ::Exa::Errors::APIError => e
|
|
158
|
+
raise ApiError, e.message
|
|
159
|
+
rescue ::Exa::Error => e
|
|
160
|
+
raise ApiError, e.message
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
sig { returns(::Exa::Client) }
|
|
164
|
+
attr_reader :client
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module DSPy
|
|
6
|
+
module DeepSearch
|
|
7
|
+
class GapQueue
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
class Empty < StandardError; end
|
|
11
|
+
|
|
12
|
+
sig { void }
|
|
13
|
+
def initialize
|
|
14
|
+
@queue = T.let([], T::Array[T.untyped])
|
|
15
|
+
@seen = T.let(Set.new, T::Set[T.untyped])
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
sig { params(item: T.untyped).void }
|
|
19
|
+
def enqueue(item)
|
|
20
|
+
return if @seen.include?(item)
|
|
21
|
+
|
|
22
|
+
@queue << item
|
|
23
|
+
@seen << item
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
sig { returns(T.untyped) }
|
|
27
|
+
def dequeue
|
|
28
|
+
raise Empty, "No items remaining in gap queue" if @queue.empty?
|
|
29
|
+
|
|
30
|
+
item = @queue.shift
|
|
31
|
+
@seen.delete(item)
|
|
32
|
+
item
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
sig { returns(Integer) }
|
|
36
|
+
def size
|
|
37
|
+
@queue.length
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
sig { returns(T::Boolean) }
|
|
41
|
+
def empty?
|
|
42
|
+
@queue.empty?
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|