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.
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DSPy
4
+ module DeepResearch
5
+ VERSION = "1.0.0"
6
+ end
7
+ 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