exaonruby 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.txt +21 -0
- data/README.md +614 -0
- data/exaonruby.gemspec +37 -0
- data/exe/exa +7 -0
- data/lib/exa/cli.rb +458 -0
- data/lib/exa/client.rb +210 -0
- data/lib/exa/configuration.rb +81 -0
- data/lib/exa/endpoints/answer.rb +109 -0
- data/lib/exa/endpoints/contents.rb +141 -0
- data/lib/exa/endpoints/events.rb +71 -0
- data/lib/exa/endpoints/find_similar.rb +154 -0
- data/lib/exa/endpoints/imports.rb +145 -0
- data/lib/exa/endpoints/monitors.rb +193 -0
- data/lib/exa/endpoints/research.rb +158 -0
- data/lib/exa/endpoints/search.rb +195 -0
- data/lib/exa/endpoints/webhooks.rb +161 -0
- data/lib/exa/endpoints/webset_enrichments.rb +162 -0
- data/lib/exa/endpoints/webset_items.rb +90 -0
- data/lib/exa/endpoints/webset_searches.rb +137 -0
- data/lib/exa/endpoints/websets.rb +214 -0
- data/lib/exa/errors.rb +180 -0
- data/lib/exa/resources/answer_response.rb +101 -0
- data/lib/exa/resources/base.rb +56 -0
- data/lib/exa/resources/contents_response.rb +123 -0
- data/lib/exa/resources/event.rb +84 -0
- data/lib/exa/resources/import.rb +137 -0
- data/lib/exa/resources/monitor.rb +205 -0
- data/lib/exa/resources/paginated_response.rb +87 -0
- data/lib/exa/resources/research_task.rb +165 -0
- data/lib/exa/resources/search_response.rb +111 -0
- data/lib/exa/resources/search_result.rb +95 -0
- data/lib/exa/resources/webhook.rb +152 -0
- data/lib/exa/resources/webset.rb +491 -0
- data/lib/exa/resources/webset_item.rb +256 -0
- data/lib/exa/utils/parameter_converter.rb +159 -0
- data/lib/exa/utils/webhook_handler.rb +239 -0
- data/lib/exa/version.rb +7 -0
- data/lib/exa.rb +130 -0
- metadata +146 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# typed: strict
|
|
4
|
+
|
|
5
|
+
module Exa
|
|
6
|
+
module Endpoints
|
|
7
|
+
module Websets
|
|
8
|
+
VALID_ENTITY_TYPES = %w[company person research_paper].freeze
|
|
9
|
+
VALID_ENRICHMENT_FORMATS = %w[text enum number boolean date].freeze
|
|
10
|
+
|
|
11
|
+
# Creates a new Webset with optional search, import, and enrichment configurations
|
|
12
|
+
#
|
|
13
|
+
# The Webset will automatically begin processing once created.
|
|
14
|
+
#
|
|
15
|
+
# @param search [Hash] Optional search configuration
|
|
16
|
+
# @option search [String] :query Search query
|
|
17
|
+
# @option search [Integer] :count Target number of results (default 10)
|
|
18
|
+
# @option search [Hash] :entity Entity type configuration { type: "company"|"person"|"research_paper" }
|
|
19
|
+
# @option search [Array<Hash>] :criteria Criteria for filtering [{ description: "..." }]
|
|
20
|
+
# @option search [Boolean] :recall Enable recall estimation
|
|
21
|
+
# @option search [Array<Hash>] :exclude Exclusion rules
|
|
22
|
+
# @option search [Array<Hash>] :scope Scope configuration
|
|
23
|
+
#
|
|
24
|
+
# @param import [Array<Hash>] Optional import sources to attach
|
|
25
|
+
# @param enrichments [Array<Hash>] Optional enrichments to add
|
|
26
|
+
# @option enrichments [String] :description What to extract
|
|
27
|
+
# @option enrichments [String] :format Format: text, enum, number, boolean, date
|
|
28
|
+
# @option enrichments [Array<Hash>] :options Options for enum format
|
|
29
|
+
#
|
|
30
|
+
# @param exclude [Array<Hash>] Global exclusion sources
|
|
31
|
+
# @param external_id [String] External identifier for the Webset
|
|
32
|
+
# @param metadata [Hash] Custom metadata key-value pairs
|
|
33
|
+
#
|
|
34
|
+
# @return [Exa::Resources::Webset] Created Webset
|
|
35
|
+
#
|
|
36
|
+
# @example Create a basic Webset with search
|
|
37
|
+
# client.create_webset(
|
|
38
|
+
# search: {
|
|
39
|
+
# query: "AI startups founded in 2024",
|
|
40
|
+
# count: 50,
|
|
41
|
+
# entity: { type: "company" },
|
|
42
|
+
# criteria: [{ description: "Company must be focused on artificial intelligence" }]
|
|
43
|
+
# }
|
|
44
|
+
# )
|
|
45
|
+
#
|
|
46
|
+
# @example Create a Webset with enrichments
|
|
47
|
+
# client.create_webset(
|
|
48
|
+
# search: { query: "Series A startups", entity: { type: "company" } },
|
|
49
|
+
# enrichments: [
|
|
50
|
+
# { description: "Company's total funding amount", format: "number" },
|
|
51
|
+
# { description: "Company's industry", format: "text" }
|
|
52
|
+
# ]
|
|
53
|
+
# )
|
|
54
|
+
def create_webset(**options)
|
|
55
|
+
validate_webset_options!(options)
|
|
56
|
+
|
|
57
|
+
params = build_webset_params(options)
|
|
58
|
+
response = websets_post("/websets", params)
|
|
59
|
+
|
|
60
|
+
Resources::Webset.new(
|
|
61
|
+
Utils::ParameterConverter.from_api_response(response)
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Retrieves a Webset by ID or external ID
|
|
66
|
+
#
|
|
67
|
+
# @param id [String] Webset ID or external ID
|
|
68
|
+
# @return [Exa::Resources::Webset] The Webset
|
|
69
|
+
#
|
|
70
|
+
# @raise [Exa::NotFoundError] if Webset not found
|
|
71
|
+
#
|
|
72
|
+
# @example Get a Webset
|
|
73
|
+
# webset = client.get_webset("webset_abc123")
|
|
74
|
+
def get_webset(id)
|
|
75
|
+
raise InvalidRequestError, "id must be a non-empty string" if !id.is_a?(String) || id.empty?
|
|
76
|
+
|
|
77
|
+
response = websets_get("/websets/#{id}")
|
|
78
|
+
|
|
79
|
+
Resources::Webset.new(
|
|
80
|
+
Utils::ParameterConverter.from_api_response(response)
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Lists all Websets with pagination
|
|
85
|
+
#
|
|
86
|
+
# @param cursor [String, nil] Cursor for pagination
|
|
87
|
+
# @param limit [Integer, nil] Number of results per page (1-100)
|
|
88
|
+
#
|
|
89
|
+
# @return [Exa::Resources::WebsetsListResponse] Paginated list of Websets
|
|
90
|
+
#
|
|
91
|
+
# @example List all Websets
|
|
92
|
+
# response = client.list_websets
|
|
93
|
+
# response.data.each { |webset| puts webset.title }
|
|
94
|
+
#
|
|
95
|
+
# @example Paginate through Websets
|
|
96
|
+
# response = client.list_websets(limit: 10)
|
|
97
|
+
# while response.has_more?
|
|
98
|
+
# response = client.list_websets(cursor: response.next_cursor, limit: 10)
|
|
99
|
+
# end
|
|
100
|
+
def list_websets(cursor: nil, limit: nil)
|
|
101
|
+
params = {}
|
|
102
|
+
params[:cursor] = cursor if cursor
|
|
103
|
+
params[:limit] = limit if limit
|
|
104
|
+
|
|
105
|
+
response = websets_get("/websets", params)
|
|
106
|
+
|
|
107
|
+
Resources::WebsetsListResponse.new(
|
|
108
|
+
Utils::ParameterConverter.from_api_response(response)
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Deletes a Webset and all its Items
|
|
113
|
+
#
|
|
114
|
+
# @param id [String] Webset ID to delete
|
|
115
|
+
# @return [Exa::Resources::Webset] The deleted Webset
|
|
116
|
+
#
|
|
117
|
+
# @raise [Exa::NotFoundError] if Webset not found
|
|
118
|
+
#
|
|
119
|
+
# @example Delete a Webset
|
|
120
|
+
# client.delete_webset("webset_abc123")
|
|
121
|
+
def delete_webset(id)
|
|
122
|
+
raise InvalidRequestError, "id must be a non-empty string" if !id.is_a?(String) || id.empty?
|
|
123
|
+
|
|
124
|
+
response = websets_delete("/websets/#{id}")
|
|
125
|
+
|
|
126
|
+
Resources::Webset.new(
|
|
127
|
+
Utils::ParameterConverter.from_api_response(response)
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
# @param options [Hash] Options to validate
|
|
134
|
+
# @raise [Exa::InvalidRequestError] if validation fails
|
|
135
|
+
def validate_webset_options!(options)
|
|
136
|
+
if options[:search]
|
|
137
|
+
validate_search_config!(options[:search])
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
if options[:enrichments]
|
|
141
|
+
options[:enrichments].each { |e| validate_enrichment_config!(e) }
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @param search [Hash] Search configuration to validate
|
|
146
|
+
def validate_search_config!(search)
|
|
147
|
+
if search[:entity] && search[:entity][:type]
|
|
148
|
+
entity_type = search[:entity][:type].to_s
|
|
149
|
+
unless VALID_ENTITY_TYPES.include?(entity_type)
|
|
150
|
+
raise InvalidRequestError, "Invalid entity type: #{entity_type}. Valid types: #{VALID_ENTITY_TYPES.join(", ")}"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
if search[:count] && (search[:count] < 1 || search[:count] > 10_000)
|
|
155
|
+
raise InvalidRequestError, "count must be between 1 and 10000"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @param enrichment [Hash] Enrichment configuration to validate
|
|
160
|
+
def validate_enrichment_config!(enrichment)
|
|
161
|
+
return unless enrichment[:format]
|
|
162
|
+
|
|
163
|
+
format = enrichment[:format].to_s
|
|
164
|
+
unless VALID_ENRICHMENT_FORMATS.include?(format)
|
|
165
|
+
raise InvalidRequestError, "Invalid enrichment format: #{format}. Valid formats: #{VALID_ENRICHMENT_FORMATS.join(", ")}"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @param options [Hash] Webset creation options
|
|
170
|
+
# @return [Hash] API-formatted parameters
|
|
171
|
+
def build_webset_params(options)
|
|
172
|
+
params = {}
|
|
173
|
+
|
|
174
|
+
params[:search] = build_search_config(options[:search]) if options[:search]
|
|
175
|
+
params[:import] = options[:import] if options[:import]
|
|
176
|
+
params[:enrichments] = options[:enrichments].map { |e| build_enrichment_config(e) } if options[:enrichments]
|
|
177
|
+
params[:exclude] = options[:exclude] if options[:exclude]
|
|
178
|
+
params[:externalId] = options[:external_id] if options[:external_id]
|
|
179
|
+
params[:metadata] = options[:metadata] if options[:metadata]
|
|
180
|
+
|
|
181
|
+
params
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# @param search [Hash] Search configuration
|
|
185
|
+
# @return [Hash] API-formatted search config
|
|
186
|
+
def build_search_config(search)
|
|
187
|
+
config = {}
|
|
188
|
+
|
|
189
|
+
config[:query] = search[:query] if search[:query]
|
|
190
|
+
config[:count] = search[:count] if search[:count]
|
|
191
|
+
config[:entity] = search[:entity] if search[:entity]
|
|
192
|
+
config[:criteria] = search[:criteria] if search[:criteria]
|
|
193
|
+
config[:recall] = search[:recall] if search.key?(:recall)
|
|
194
|
+
config[:exclude] = search[:exclude] if search[:exclude]
|
|
195
|
+
config[:scope] = search[:scope] if search[:scope]
|
|
196
|
+
|
|
197
|
+
config
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# @param enrichment [Hash] Enrichment configuration
|
|
201
|
+
# @return [Hash] API-formatted enrichment config
|
|
202
|
+
def build_enrichment_config(enrichment)
|
|
203
|
+
config = {}
|
|
204
|
+
|
|
205
|
+
config[:description] = enrichment[:description] if enrichment[:description]
|
|
206
|
+
config[:format] = enrichment[:format] if enrichment[:format]
|
|
207
|
+
config[:options] = enrichment[:options] if enrichment[:options]
|
|
208
|
+
config[:metadata] = enrichment[:metadata] if enrichment[:metadata]
|
|
209
|
+
|
|
210
|
+
config
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
data/lib/exa/errors.rb
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# typed: strict
|
|
4
|
+
|
|
5
|
+
module Exa
|
|
6
|
+
class Error < StandardError
|
|
7
|
+
# @return [Integer, nil] HTTP status code if applicable
|
|
8
|
+
attr_reader :status
|
|
9
|
+
|
|
10
|
+
# @return [Hash, nil] Response body if available
|
|
11
|
+
attr_reader :response_body
|
|
12
|
+
|
|
13
|
+
# @return [String, nil] Request ID for debugging
|
|
14
|
+
attr_reader :request_id
|
|
15
|
+
|
|
16
|
+
# @param message [String] Error message
|
|
17
|
+
# @param status [Integer, nil] HTTP status code
|
|
18
|
+
# @param response_body [Hash, nil] Parsed response body
|
|
19
|
+
# @param request_id [String, nil] Request ID from response
|
|
20
|
+
def initialize(message = nil, status: nil, response_body: nil, request_id: nil)
|
|
21
|
+
@status = status
|
|
22
|
+
@response_body = response_body
|
|
23
|
+
@request_id = request_id
|
|
24
|
+
super(message)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [String] Formatted error message with details
|
|
28
|
+
def to_s
|
|
29
|
+
parts = [super]
|
|
30
|
+
parts << "(status: #{status})" if status
|
|
31
|
+
parts << "(request_id: #{request_id})" if request_id
|
|
32
|
+
parts.join(" ")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class AuthenticationError < Error
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class RateLimitError < Error
|
|
40
|
+
# @return [Integer, nil] Seconds until rate limit resets
|
|
41
|
+
attr_reader :retry_after
|
|
42
|
+
|
|
43
|
+
# @param message [String] Error message
|
|
44
|
+
# @param retry_after [Integer, nil] Seconds until retry
|
|
45
|
+
# @param kwargs [Hash] Additional error options
|
|
46
|
+
def initialize(message = nil, retry_after: nil, **kwargs)
|
|
47
|
+
@retry_after = retry_after
|
|
48
|
+
super(message, **kwargs)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class InvalidRequestError < Error
|
|
53
|
+
# @return [Array<Hash>, nil] Validation errors if available
|
|
54
|
+
attr_reader :validation_errors
|
|
55
|
+
|
|
56
|
+
# @param message [String] Error message
|
|
57
|
+
# @param validation_errors [Array<Hash>, nil] List of validation errors
|
|
58
|
+
# @param kwargs [Hash] Additional error options
|
|
59
|
+
def initialize(message = nil, validation_errors: nil, **kwargs)
|
|
60
|
+
@validation_errors = validation_errors
|
|
61
|
+
super(message, **kwargs)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class NotFoundError < Error
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
class ConflictError < Error
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
class UnprocessableEntityError < Error
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
class ServerError < Error
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class ServiceUnavailableError < ServerError
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class TimeoutError < Error
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
class ConnectionError < Error
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
class << self
|
|
87
|
+
# Maps HTTP status codes to appropriate error classes
|
|
88
|
+
# @param status [Integer] HTTP status code
|
|
89
|
+
# @param message [String, nil] Error message
|
|
90
|
+
# @param response_body [Hash, nil] Response body
|
|
91
|
+
# @param headers [Hash, nil] Response headers
|
|
92
|
+
# @return [Exa::Error] Appropriate error instance
|
|
93
|
+
def error_for_status(status, message: nil, response_body: nil, headers: nil)
|
|
94
|
+
request_id = response_body&.dig("requestId")
|
|
95
|
+
error_message = message || extract_error_message(response_body)
|
|
96
|
+
|
|
97
|
+
case status
|
|
98
|
+
when 400
|
|
99
|
+
validation_errors = response_body&.dig("errors")
|
|
100
|
+
InvalidRequestError.new(
|
|
101
|
+
error_message,
|
|
102
|
+
status: status,
|
|
103
|
+
response_body: response_body,
|
|
104
|
+
request_id: request_id,
|
|
105
|
+
validation_errors: validation_errors
|
|
106
|
+
)
|
|
107
|
+
when 401
|
|
108
|
+
AuthenticationError.new(
|
|
109
|
+
error_message || "Invalid API key",
|
|
110
|
+
status: status,
|
|
111
|
+
response_body: response_body,
|
|
112
|
+
request_id: request_id
|
|
113
|
+
)
|
|
114
|
+
when 404
|
|
115
|
+
NotFoundError.new(
|
|
116
|
+
error_message || "Resource not found",
|
|
117
|
+
status: status,
|
|
118
|
+
response_body: response_body,
|
|
119
|
+
request_id: request_id
|
|
120
|
+
)
|
|
121
|
+
when 409
|
|
122
|
+
ConflictError.new(
|
|
123
|
+
error_message,
|
|
124
|
+
status: status,
|
|
125
|
+
response_body: response_body,
|
|
126
|
+
request_id: request_id
|
|
127
|
+
)
|
|
128
|
+
when 422
|
|
129
|
+
UnprocessableEntityError.new(
|
|
130
|
+
error_message,
|
|
131
|
+
status: status,
|
|
132
|
+
response_body: response_body,
|
|
133
|
+
request_id: request_id
|
|
134
|
+
)
|
|
135
|
+
when 429
|
|
136
|
+
retry_after = headers&.dig("retry-after")&.to_i
|
|
137
|
+
RateLimitError.new(
|
|
138
|
+
error_message || "Rate limit exceeded",
|
|
139
|
+
status: status,
|
|
140
|
+
response_body: response_body,
|
|
141
|
+
request_id: request_id,
|
|
142
|
+
retry_after: retry_after
|
|
143
|
+
)
|
|
144
|
+
when 500
|
|
145
|
+
ServerError.new(
|
|
146
|
+
error_message || "Internal server error",
|
|
147
|
+
status: status,
|
|
148
|
+
response_body: response_body,
|
|
149
|
+
request_id: request_id
|
|
150
|
+
)
|
|
151
|
+
when 503
|
|
152
|
+
ServiceUnavailableError.new(
|
|
153
|
+
error_message || "Service temporarily unavailable",
|
|
154
|
+
status: status,
|
|
155
|
+
response_body: response_body,
|
|
156
|
+
request_id: request_id
|
|
157
|
+
)
|
|
158
|
+
else
|
|
159
|
+
Error.new(
|
|
160
|
+
error_message || "HTTP #{status} error",
|
|
161
|
+
status: status,
|
|
162
|
+
response_body: response_body,
|
|
163
|
+
request_id: request_id
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
# @param response_body [Hash, nil] Response body to extract message from
|
|
171
|
+
# @return [String, nil] Extracted error message
|
|
172
|
+
def extract_error_message(response_body)
|
|
173
|
+
return nil unless response_body.is_a?(Hash)
|
|
174
|
+
|
|
175
|
+
response_body["message"] ||
|
|
176
|
+
response_body["error"] ||
|
|
177
|
+
response_body.dig("error", "message")
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# typed: strict
|
|
4
|
+
|
|
5
|
+
module Exa
|
|
6
|
+
module Resources
|
|
7
|
+
class AnswerResponse < Base
|
|
8
|
+
# @return [String] The generated answer based on search results
|
|
9
|
+
def answer
|
|
10
|
+
get(:answer)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @return [Array<Citation>] Search results used to generate the answer
|
|
14
|
+
def citations
|
|
15
|
+
@citations ||= (get(:citations, []) || []).map { |data| Citation.new(data) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [CostInfo, nil] Cost information for the request
|
|
19
|
+
def cost
|
|
20
|
+
cost_data = get(:cost_dollars)
|
|
21
|
+
return nil unless cost_data
|
|
22
|
+
|
|
23
|
+
CostInfo.new(cost_data)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Float] Total cost in dollars
|
|
27
|
+
def total_cost
|
|
28
|
+
cost&.total || 0.0
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Integer] Number of citations
|
|
32
|
+
def citation_count
|
|
33
|
+
citations.length
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Boolean] Whether answer has citations
|
|
37
|
+
def has_citations?
|
|
38
|
+
!citations.empty?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def inspectable_attributes
|
|
44
|
+
{ answer: answer&.slice(0, 50), citation_count: citation_count }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class Citation < Base
|
|
49
|
+
# @return [String] Unique identifier
|
|
50
|
+
def id
|
|
51
|
+
get(:id)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [String] URL of the source
|
|
55
|
+
def url
|
|
56
|
+
get(:url)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [String] Title of the source
|
|
60
|
+
def title
|
|
61
|
+
get(:title)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [String, nil] Author of the source
|
|
65
|
+
def author
|
|
66
|
+
get(:author)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [Time, nil] Published date
|
|
70
|
+
def published_date
|
|
71
|
+
date_str = get(:published_date)
|
|
72
|
+
return nil unless date_str
|
|
73
|
+
|
|
74
|
+
Time.parse(date_str)
|
|
75
|
+
rescue ArgumentError
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @return [String, nil] Text content from the source
|
|
80
|
+
def text
|
|
81
|
+
get(:text)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [String, nil] Image URL
|
|
85
|
+
def image
|
|
86
|
+
get(:image)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @return [String, nil] Favicon URL
|
|
90
|
+
def favicon
|
|
91
|
+
get(:favicon)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def inspectable_attributes
|
|
97
|
+
{ url: url, title: title }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# typed: strict
|
|
4
|
+
|
|
5
|
+
module Exa
|
|
6
|
+
module Resources
|
|
7
|
+
class Base
|
|
8
|
+
# @return [Hash] Raw response data
|
|
9
|
+
attr_reader :raw_data
|
|
10
|
+
|
|
11
|
+
# @param data [Hash] Response data from API
|
|
12
|
+
def initialize(data)
|
|
13
|
+
@raw_data = data.freeze
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [String] String representation
|
|
17
|
+
def to_s
|
|
18
|
+
"#<#{self.class.name}>"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [String] Inspect representation
|
|
22
|
+
def inspect
|
|
23
|
+
"#<#{self.class.name} #{inspectable_attributes.map { |k, v| "#{k}=#{v.inspect}" }.join(" ")}>"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Hash] Hash representation
|
|
27
|
+
def to_h
|
|
28
|
+
raw_data
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
alias to_hash to_h
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# @return [Hash] Attributes to include in inspect output
|
|
36
|
+
def inspectable_attributes
|
|
37
|
+
{}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Safely retrieves a value from raw_data
|
|
41
|
+
# @param key [Symbol] Key to retrieve
|
|
42
|
+
# @param default [Object] Default value if key not found
|
|
43
|
+
# @return [Object] Value or default
|
|
44
|
+
def get(key, default = nil)
|
|
45
|
+
raw_data.fetch(key, default)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Safely retrieves a nested value from raw_data
|
|
49
|
+
# @param keys [Array<Symbol>] Keys to traverse
|
|
50
|
+
# @return [Object, nil] Value or nil
|
|
51
|
+
def dig(*keys)
|
|
52
|
+
raw_data.dig(*keys)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# typed: strict
|
|
4
|
+
|
|
5
|
+
module Exa
|
|
6
|
+
module Resources
|
|
7
|
+
class ContentsResponse < Base
|
|
8
|
+
# @return [String] Unique request identifier
|
|
9
|
+
def request_id
|
|
10
|
+
get(:request_id)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @return [Array<SearchResult>] Content results
|
|
14
|
+
def results
|
|
15
|
+
@results ||= (get(:results, []) || []).map { |data| SearchResult.new(data) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [String, nil] Combined context string for LLM consumption
|
|
19
|
+
def context
|
|
20
|
+
get(:context)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Array<ContentStatus>] Status information for each URL
|
|
24
|
+
def statuses
|
|
25
|
+
@statuses ||= (get(:statuses, []) || []).map { |data| ContentStatus.new(data) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [CostInfo, nil] Cost information
|
|
29
|
+
def cost
|
|
30
|
+
cost_data = get(:cost_dollars)
|
|
31
|
+
return nil unless cost_data
|
|
32
|
+
|
|
33
|
+
CostInfo.new(cost_data)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Float] Total cost in dollars
|
|
37
|
+
def total_cost
|
|
38
|
+
cost&.total || 0.0
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Integer] Number of results
|
|
42
|
+
def count
|
|
43
|
+
results.length
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Boolean] Whether results are empty
|
|
47
|
+
def empty?
|
|
48
|
+
results.empty?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Iterates over each result
|
|
52
|
+
# @yield [SearchResult] Each result
|
|
53
|
+
# @return [Enumerator, self]
|
|
54
|
+
def each(&block)
|
|
55
|
+
return results.each unless block
|
|
56
|
+
|
|
57
|
+
results.each(&block)
|
|
58
|
+
self
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param index [Integer] Index of result
|
|
62
|
+
# @return [SearchResult, nil] Result at index
|
|
63
|
+
def [](index)
|
|
64
|
+
results[index]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [SearchResult, nil] First result
|
|
68
|
+
def first
|
|
69
|
+
results.first
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Checks if all content fetches were successful
|
|
73
|
+
# @return [Boolean]
|
|
74
|
+
def all_success?
|
|
75
|
+
statuses.all?(&:success?)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Returns results that failed to fetch
|
|
79
|
+
# @return [Array<ContentStatus>]
|
|
80
|
+
def failed_statuses
|
|
81
|
+
statuses.reject(&:success?)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def inspectable_attributes
|
|
87
|
+
{ request_id: request_id, count: count }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class ContentStatus < Base
|
|
92
|
+
# @return [String] URL/ID that was requested
|
|
93
|
+
def id
|
|
94
|
+
get(:id)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @return [String] Status of the fetch (success, error)
|
|
98
|
+
def status
|
|
99
|
+
get(:status)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @return [Boolean] Whether the fetch was successful
|
|
103
|
+
def success?
|
|
104
|
+
status == "success"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @return [Hash, nil] Error information if failed
|
|
108
|
+
def error
|
|
109
|
+
get(:error)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @return [String, nil] Error tag if failed
|
|
113
|
+
def error_tag
|
|
114
|
+
dig(:error, :tag)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @return [Integer, nil] HTTP status code if failed
|
|
118
|
+
def http_status_code
|
|
119
|
+
dig(:error, :http_status_code)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|