exa-rb 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ba7c3d6444e98f398c76d223c995409c7b835df4c42c543c0ce67b7e1b059137
4
+ data.tar.gz: c5480dc30c83a9d4510261dbfbbd8d3b5fbd28be6adaf4a109b8a289b7f37799
5
+ SHA512:
6
+ metadata.gz: f8742c3096bac80fbd171db29c67c5ac2fc40bd181f27fcc6de5135e7e1a8cca005b63f26f96c799130a75fc01421b8d2026adc81c932a10ef8bfe2a2a33f9c8
7
+ data.tar.gz: 9a48a2842a556c4407cb826e5748eff67b8d4dc83c8657d40a7c9bbd094e3e87edfa67550988c2931117da2d68e6fd20a2f364fbf4a5c627b2a3d587cc558e48
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Endless International
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,286 @@
1
+ # Exa
2
+
3
+ The **Exa** gem provides a Ruby interface to the [Exa API](https://exa.ai), enabling powerful neural and keyword-based web search with content retrieval capabilities. Exa goes beyond traditional keyword matching to understand semantic meaning and context, making it particularly useful for applications that need intelligent search, including those using Large Language Models.
4
+
5
+ ```ruby
6
+ require 'exa'
7
+
8
+ Exa.api_key ENV[ 'EXA_API_KEY' ]
9
+
10
+ response = Exa.search( 'best practices for Ruby API design' )
11
+ if response.success?
12
+ response.result.results.each do | result |
13
+ puts result.title
14
+ puts result.url
15
+ end
16
+ end
17
+ ```
18
+
19
+ ---
20
+
21
+ ## Table of Contents
22
+
23
+ - [Installation](#installation)
24
+ - [Command Line](#command-line)
25
+ - [Quick Start](#quick-start)
26
+ - [Endpoints](#endpoints)
27
+ - [Search](#search)
28
+ - [Find Similar](#find-similar)
29
+ - [Answer](#answer)
30
+ - [Contents](#contents)
31
+ - [Responses and Errors](#responses-and-errors)
32
+ - [Connections](#connections)
33
+ - [License](#license)
34
+
35
+ ---
36
+
37
+ ## Installation
38
+
39
+ Add this line to your application's Gemfile:
40
+
41
+ ```ruby
42
+ gem 'exa-rb'
43
+ ```
44
+
45
+ Then execute:
46
+
47
+ ```bash
48
+ bundle install
49
+ ```
50
+
51
+ Or install it directly:
52
+
53
+ ```bash
54
+ gem install exa-rb
55
+ ```
56
+
57
+ ## Command Line
58
+
59
+ The gem includes an `exa` command for quick searches from the terminal:
60
+
61
+ ```bash
62
+ exa search "Ruby programming best practices"
63
+ exa answer "What is the Ruby programming language?"
64
+ exa similar https://www.ruby-lang.org/en/
65
+ ```
66
+
67
+ Set your API key via environment variable:
68
+
69
+ ```bash
70
+ export EXA_API_KEY=your_api_key
71
+ ```
72
+
73
+ ## Quick Start
74
+
75
+ The simplest way to use the gem is through the module-level convenience methods. Set your API key once, then call any endpoint:
76
+
77
+ ```ruby
78
+ require 'exa'
79
+
80
+ Exa.api_key ENV[ 'EXA_API_KEY' ]
81
+
82
+ response = Exa.search( 'machine learning tutorials' )
83
+ if response.success?
84
+ response.result.results.each do | result |
85
+ puts result.title
86
+ puts result.url
87
+ end
88
+ end
89
+ ```
90
+
91
+ For more control, instantiate request objects directly. This allows you to configure options using a block-based DSL and reuse request instances:
92
+
93
+ ```ruby
94
+ request = Exa::SearchRequest.new( api_key: ENV[ 'EXA_API_KEY' ] )
95
+
96
+ options = Exa::SearchOptions.build do
97
+ num_results 10
98
+ type :neural
99
+ contents do
100
+ text { max_characters 1000 }
101
+ highlights { num_sentences 2 }
102
+ end
103
+ end
104
+
105
+ response = request.submit( 'Ruby web frameworks', options )
106
+ ```
107
+
108
+ ---
109
+
110
+ ## Endpoints
111
+
112
+ ### Search
113
+
114
+ The search endpoint performs web searches using neural or keyword-based methods and returns relevant results with optional content retrieval.
115
+
116
+ ```ruby
117
+ options = Exa::SearchOptions.build do
118
+ num_results 5
119
+ type :neural
120
+ include_domains [ 'github.com', 'stackoverflow.com' ]
121
+ contents do
122
+ summary { query 'Summarize the key points' }
123
+ end
124
+ end
125
+
126
+ response = Exa.search( 'Ruby dependency injection patterns', options )
127
+
128
+ if response.success?
129
+ response.result.results.each do | result |
130
+ puts result.title
131
+ puts result.url
132
+ puts result.summary
133
+ end
134
+ end
135
+ ```
136
+
137
+ For complete documentation of all search options and response fields, see [Search Documentation](readme/search.md).
138
+
139
+ ### Find Similar
140
+
141
+ The find similar endpoint finds pages that are similar to a given URL:
142
+
143
+ ```ruby
144
+ options = Exa::FindSimilarOptions.build do
145
+ num_results 5
146
+ exclude_source_domain true
147
+ contents do
148
+ summary { query 'Summarize the content' }
149
+ end
150
+ end
151
+
152
+ response = Exa.find_similar( 'https://www.ruby-lang.org/en/', options )
153
+
154
+ if response.success?
155
+ response.result.results.each do | result |
156
+ puts result.title
157
+ puts result.url
158
+ puts result.summary
159
+ end
160
+ end
161
+ ```
162
+
163
+ For complete documentation of all find similar options and response fields, see [Find Similar Documentation](readme/find_similar.md).
164
+
165
+ ### Answer
166
+
167
+ The answer endpoint provides AI-generated answers to questions using web sources:
168
+
169
+ ```ruby
170
+ response = Exa.answer( 'What is the Ruby programming language?' )
171
+
172
+ if response.success?
173
+ puts response.result.answer
174
+
175
+ puts "\nCitations:"
176
+ response.result.citations.each do | citation |
177
+ puts "- #{ citation.title }: #{ citation.url }"
178
+ end
179
+ end
180
+ ```
181
+
182
+ To include full text from citations:
183
+
184
+ ```ruby
185
+ options = Exa::AnswerOptions.build do
186
+ text true
187
+ end
188
+
189
+ response = Exa.answer( 'How does garbage collection work in Ruby?', options )
190
+ ```
191
+
192
+ For complete documentation of all answer options and response fields, see [Answer Documentation](readme/answer.md).
193
+
194
+ ### Contents
195
+
196
+ The contents endpoint retrieves full page content, summaries, and highlights for a list of URLs:
197
+
198
+ ```ruby
199
+ urls = [
200
+ 'https://ruby-doc.org/core/Array.html',
201
+ 'https://ruby-doc.org/core/Hash.html'
202
+ ]
203
+
204
+ options = Exa::ContentsOptions.build do
205
+ text { max_characters 2000 }
206
+ highlights do
207
+ num_sentences 3
208
+ highlights_per_url 5
209
+ end
210
+ end
211
+
212
+ response = Exa.contents( urls, options )
213
+
214
+ if response.success?
215
+ response.result.results.each do | result |
216
+ puts result.title
217
+ puts result.text
218
+ end
219
+ end
220
+ ```
221
+
222
+ For complete documentation of all contents options and response fields, see [Contents Documentation](readme/contents.md).
223
+
224
+ ---
225
+
226
+ ## Responses and Errors
227
+
228
+ All request methods return a `Faraday::Response` object. Check `response.success?` to determine if the HTTP request succeeded. When successful, `response.result` contains the parsed result object specific to the endpoint.
229
+
230
+ ```ruby
231
+ response = Exa.search( query, options )
232
+
233
+ if response.success?
234
+ result = response.result
235
+ result.results.each do | item |
236
+ puts item.title
237
+ end
238
+ else
239
+ error = response.result
240
+ puts error.error_type # :authentication_error, :rate_limit_error, etc.
241
+ puts error.error_description # human-readable message
242
+ end
243
+ ```
244
+
245
+ The gem maps HTTP status codes to error types:
246
+
247
+ | Status | Error Type | Description |
248
+ |--------|------------|-------------|
249
+ | 400 | `:invalid_request_error` | Invalid request parameters or malformed JSON |
250
+ | 401 | `:authentication_error` | Missing or invalid API key |
251
+ | 403 | `:forbidden_error` | Insufficient permissions or rate limit exceeded |
252
+ | 404 | `:not_found_error` | The requested resource was not found |
253
+ | 409 | `:conflict_error` | Resource already exists |
254
+ | 429 | `:rate_limit_error` | Rate limit exceeded |
255
+ | 500-599 | `:server_error` | A server error occurred |
256
+
257
+ ---
258
+
259
+ ## Connections
260
+
261
+ The gem uses Faraday for HTTP requests, which means you can customize the connection configuration. To use a custom connection:
262
+
263
+ ```ruby
264
+ connection = Faraday.new do | faraday |
265
+ faraday.request :json
266
+ faraday.response :logger
267
+ faraday.adapter :net_http
268
+ end
269
+
270
+ Exa.connection connection
271
+ ```
272
+
273
+ Or pass it directly to a request:
274
+
275
+ ```ruby
276
+ request = Exa::SearchRequest.new(
277
+ api_key: ENV[ 'EXA_API_KEY' ],
278
+ connection: connection
279
+ )
280
+ ```
281
+
282
+ ---
283
+
284
+ ## License
285
+
286
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/bin/exa ADDED
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'exa'
4
+
5
+ COMMANDS = %w[ search answer similar help ]
6
+
7
+ def usage
8
+ puts "Usage: exa <command> [arguments]"
9
+ puts
10
+ puts "Commands:"
11
+ puts " search <query> Search the web and show results with summaries"
12
+ puts " answer <question> Get an AI-generated answer with citations"
13
+ puts " similar <url> Find pages similar to a URL"
14
+ puts " help Show this help message"
15
+ puts
16
+ puts "Environment:"
17
+ puts " EXA_API_KEY Your Exa API key (required)"
18
+ puts
19
+ puts "Options:"
20
+ puts " -n, --num <n> Number of results (default: 5)"
21
+ puts
22
+ puts "Examples:"
23
+ puts " exa search \"Ruby programming best practices\""
24
+ puts " exa answer \"What is the Ruby programming language?\""
25
+ puts " exa similar https://www.ruby-lang.org/en/"
26
+ puts " exa search -n 10 \"machine learning tutorials\""
27
+ end
28
+
29
+ def check_api_key!
30
+ unless ENV[ 'EXA_API_KEY' ]
31
+ $stderr.puts "Error: An EXA_API_KEY environment variable is required."
32
+ exit 1
33
+ end
34
+ Exa.api_key ENV[ 'EXA_API_KEY' ]
35
+ end
36
+
37
+ def parse_options( args )
38
+ options = { num_results: 5 }
39
+
40
+ while args.first&.start_with?( '-' )
41
+ case args.shift
42
+ when '-n', '--num'
43
+ options[ :num_results ] = args.shift.to_i
44
+ end
45
+ end
46
+
47
+ options
48
+ end
49
+
50
+ def cmd_search( args )
51
+ options = parse_options( args )
52
+ query = args.join( ' ' )
53
+
54
+ if query.empty?
55
+ $stderr.puts "Error: search query required"
56
+ $stderr.puts "Usage: exa search <query>"
57
+ exit 1
58
+ end
59
+
60
+ check_api_key!
61
+
62
+ search_options = Exa::SearchOptions.build do
63
+ num_results options[ :num_results ]
64
+ contents do
65
+ summary { query 'Summarize in a single paragraph' }
66
+ end
67
+ end
68
+
69
+ response = Exa.search( query, search_options )
70
+
71
+ unless response.success?
72
+ $stderr.puts "Error: #{ response.result.error_description }"
73
+ exit 1
74
+ end
75
+
76
+ response.result.results.each_with_index do | result, index |
77
+ puts if index > 0
78
+ puts result.title
79
+ puts result.url
80
+ puts result.summary if result.summary
81
+ end
82
+ end
83
+
84
+ def cmd_answer( args )
85
+ options = parse_options( args )
86
+ question = args.join( ' ' )
87
+
88
+ if question.empty?
89
+ $stderr.puts "Error: question required"
90
+ $stderr.puts "Usage: exa answer <question>"
91
+ exit 1
92
+ end
93
+
94
+ check_api_key!
95
+
96
+ response = Exa.answer( question )
97
+
98
+ unless response.success?
99
+ $stderr.puts "Error: #{ response.result.error_description }"
100
+ exit 1
101
+ end
102
+
103
+ result = response.result
104
+ puts result.answer
105
+ puts
106
+ puts "Sources:"
107
+ result.citations.each do | citation |
108
+ puts " #{ citation.title }"
109
+ puts " #{ citation.url }"
110
+ end
111
+ end
112
+
113
+ def cmd_similar( args )
114
+ options = parse_options( args )
115
+ url = args.first
116
+
117
+ if url.nil? || url.empty?
118
+ $stderr.puts "Error: URL required"
119
+ $stderr.puts "Usage: exa similar <url>"
120
+ exit 1
121
+ end
122
+
123
+ check_api_key!
124
+
125
+ similar_options = Exa::FindSimilarOptions.build do
126
+ num_results options[ :num_results ]
127
+ exclude_source_domain true
128
+ contents do
129
+ summary { query 'Summarize in a single paragraph' }
130
+ end
131
+ end
132
+
133
+ response = Exa.find_similar( url, similar_options )
134
+
135
+ unless response.success?
136
+ $stderr.puts "Error: #{ response.result.error_description }"
137
+ exit 1
138
+ end
139
+
140
+ response.result.results.each_with_index do | result, index |
141
+ puts if index > 0
142
+ puts result.title
143
+ puts result.url
144
+ puts result.summary if result.summary
145
+ end
146
+ end
147
+
148
+ # Main
149
+ command = ARGV.shift
150
+
151
+ case command
152
+ when 'search'
153
+ cmd_search( ARGV )
154
+ when 'answer'
155
+ cmd_answer( ARGV )
156
+ when 'similar'
157
+ cmd_similar( ARGV )
158
+ when 'help', '-h', '--help', nil
159
+ usage
160
+ else
161
+ $stderr.puts "Unknown command: #{ command }"
162
+ $stderr.puts "Run 'exa help' for usage"
163
+ exit 1
164
+ end
data/exa-rb.gemspec ADDED
@@ -0,0 +1,41 @@
1
+ require_relative 'lib/exa/version'
2
+
3
+ Gem::Specification.new do | spec |
4
+
5
+ spec.name = 'exa-rb'
6
+ spec.version = Exa::VERSION
7
+ spec.authors = [ 'Kristoph Cichocki-Romanov' ]
8
+ spec.email = [ 'rubygems.org@kristoph.net' ]
9
+
10
+ spec.summary =
11
+ "The Exa gem implements a lightweight interface to the Exa.ai API for neural " \
12
+ "and keyword-based web search with content retrieval capabilities."
13
+ spec.description =
14
+ "The Exa gem implements a lightweight interface to the Exa.ai API. Exa provides " \
15
+ "powerful neural search capabilities that go beyond traditional keyword matching " \
16
+ "to understand semantic meaning and context.\n" \
17
+ "\n" \
18
+ "This gem supports search, find similar, answer, and contents endpoints. It " \
19
+ "includes both a Ruby API and a command-line interface (exa) for quick searches " \
20
+ "from the terminal."
21
+ spec.license = 'MIT'
22
+ spec.homepage = 'https://github.com/EndlessInternational/exa-rb'
23
+ spec.metadata = {
24
+ 'source_code_uri' => 'https://github.com/EndlessInternational/exa-rb',
25
+ 'bug_tracker_uri' => 'https://github.com/EndlessInternational/exa-rb/issues',
26
+ }
27
+
28
+ spec.required_ruby_version = '>= 3.0'
29
+ spec.files = Dir[ "lib/**/*.rb", "bin/exa", "LICENSE", "README.md", "exa-rb.gemspec" ]
30
+ spec.bindir = 'bin'
31
+ spec.executables = [ 'exa' ]
32
+ spec.require_paths = [ "lib" ]
33
+
34
+ spec.add_runtime_dependency 'faraday', '~> 2'
35
+ spec.add_runtime_dependency 'dynamicschema', '~> 2'
36
+
37
+ spec.add_development_dependency 'minitest', '~> 5.25'
38
+ spec.add_development_dependency 'debug', '~> 1.9'
39
+ spec.add_development_dependency 'vcr', '~> 6.3'
40
+
41
+ end
@@ -0,0 +1,29 @@
1
+ module Exa
2
+ class AnswerOptions
3
+ include DynamicSchema::Definable
4
+ include Helpers
5
+
6
+ schema do
7
+ text [ TrueClass, FalseClass ]
8
+ stream [ TrueClass, FalseClass ]
9
+ end
10
+
11
+ def self.build( options = nil, &block )
12
+ new( api_options: builder.build( options, &block ) )
13
+ end
14
+
15
+ def self.build!( options = nil, &block )
16
+ new( api_options: builder.build!( options, &block ) )
17
+ end
18
+
19
+ def initialize( options = {}, api_options: nil )
20
+ @options = self.class.builder.build( options || {} )
21
+ @options = api_options.merge( @options ) if api_options
22
+ end
23
+
24
+ def to_h
25
+ @options.to_h
26
+ end
27
+
28
+ end
29
+ end
@@ -0,0 +1,26 @@
1
+ module Exa
2
+ class AnswerRequest < Request
3
+
4
+ def submit( query, options = nil, &block )
5
+ if options
6
+ options = options.is_a?( AnswerOptions ) ? options : AnswerOptions.build!( options.to_h )
7
+ options = options.to_h
8
+ else
9
+ options = {}
10
+ end
11
+ options[ :query ] = query.to_s
12
+
13
+ response = post( "#{ BASE_URI }/answer", options, &block )
14
+ attributes = ( JSON.parse( response.body, symbolize_names: true ) rescue nil )
15
+
16
+ result = if response.success?
17
+ AnswerResult.new( attributes )
18
+ else
19
+ ErrorResult.new( response.status, attributes )
20
+ end
21
+
22
+ ResponseMethods.install( response, result )
23
+ end
24
+
25
+ end
26
+ end
@@ -0,0 +1,29 @@
1
+ module Exa
2
+
3
+ CitationSchema = DynamicSchema::Struct.define do
4
+ id String
5
+ url String
6
+ title String
7
+ author String
8
+ published_date String, as: :publishedDate
9
+ text String
10
+ image String
11
+ favicon String
12
+ end
13
+
14
+ class Citation < CitationSchema
15
+ end
16
+
17
+ AnswerResultSchema = DynamicSchema::Struct.define do
18
+ request_id String, as: :requestId
19
+ answer String
20
+ citations Citation, array: true
21
+ end
22
+
23
+ class AnswerResult < AnswerResultSchema
24
+ def success?
25
+ true
26
+ end
27
+ end
28
+
29
+ end
@@ -0,0 +1,56 @@
1
+ module Exa
2
+ class ContentsOptions
3
+ include DynamicSchema::Definable
4
+ include Helpers
5
+
6
+ LIVECRAWL_OPTIONS = [ :never, :fallback, :always ]
7
+
8
+ schema do
9
+
10
+ # text retrieval
11
+ text do
12
+ max_characters Integer, as: :maxCharacters
13
+ include_html_tags [ TrueClass, FalseClass ], as: :includeHtmlTags
14
+ end
15
+
16
+ # highlights
17
+ highlights do
18
+ num_sentences Integer, as: :numSentences
19
+ highlights_per_url Integer, as: :highlightsPerUrl
20
+ query String
21
+ end
22
+
23
+ # summary
24
+ summary do
25
+ query String
26
+ end
27
+
28
+ # crawling control
29
+ livecrawl Symbol, in: LIVECRAWL_OPTIONS
30
+ livecrawl_timeout Integer, as: :livecrawlTimeout
31
+
32
+ # subpages
33
+ subpages Integer
34
+ subpage_target String, as: :subpageTarget
35
+
36
+ end
37
+
38
+ def self.build( options = nil, &block )
39
+ new( api_options: builder.build( options, &block ) )
40
+ end
41
+
42
+ def self.build!( options = nil, &block )
43
+ new( api_options: builder.build!( options, &block ) )
44
+ end
45
+
46
+ def initialize( options = {}, api_options: nil )
47
+ @options = self.class.builder.build( options || {} )
48
+ @options = api_options.merge( @options ) if api_options
49
+ end
50
+
51
+ def to_h
52
+ @options.to_h
53
+ end
54
+
55
+ end
56
+ end
@@ -0,0 +1,28 @@
1
+ module Exa
2
+ class ContentsRequest < Request
3
+
4
+ def submit( urls, options = nil, &block )
5
+ urls = Array( urls ).map( &:to_s )
6
+
7
+ if options
8
+ options = options.is_a?( ContentsOptions ) ? options : ContentsOptions.build!( options.to_h )
9
+ options = options.to_h
10
+ else
11
+ options = {}
12
+ end
13
+ options[ :urls ] = urls
14
+
15
+ response = post( "#{ BASE_URI }/contents", options, &block )
16
+ attributes = ( JSON.parse( response.body, symbolize_names: true ) rescue nil )
17
+
18
+ result = if response.success?
19
+ ContentsResult.new( attributes )
20
+ else
21
+ ErrorResult.new( response.status, attributes )
22
+ end
23
+
24
+ ResponseMethods.install( response, result )
25
+ end
26
+
27
+ end
28
+ end
@@ -0,0 +1,30 @@
1
+ module Exa
2
+
3
+ ContentsResultItemSchema = DynamicSchema::Struct.define do
4
+ id String
5
+ url String
6
+ title String
7
+ author String
8
+ text String
9
+ highlights String, array: true
10
+ highlight_scores Float, array: true, as: :highlightScores
11
+ summary String
12
+ image String
13
+ favicon String
14
+ end
15
+
16
+ class ContentsResultItem < ContentsResultItemSchema
17
+ end
18
+
19
+ ContentsResultSchema = DynamicSchema::Struct.define do
20
+ request_id String, as: :requestId
21
+ results ContentsResultItem, array: true
22
+ end
23
+
24
+ class ContentsResult < ContentsResultSchema
25
+ def success?
26
+ true
27
+ end
28
+ end
29
+
30
+ end
@@ -0,0 +1,46 @@
1
+ module Exa
2
+ class ErrorResult
3
+
4
+ attr_reader :error_type, :error_description
5
+
6
+ def initialize( status_code, attributes = nil )
7
+ @error_type, @error_description = status_code_to_error( status_code )
8
+ @error_description = attributes[ :error ] if attributes&.respond_to?( :[] ) && attributes[ :error ]
9
+ end
10
+
11
+ private
12
+
13
+ def status_code_to_error( status_code )
14
+ case status_code
15
+ when 200
16
+ [ :unexpected_error,
17
+ "The response was successful but it did not include a valid payload." ]
18
+ when 400
19
+ [ :invalid_request_error,
20
+ "Invalid request parameters, malformed JSON, or missing required fields." ]
21
+ when 401
22
+ [ :authentication_error,
23
+ "Missing or invalid API key." ]
24
+ when 403
25
+ [ :forbidden_error,
26
+ "Valid API key but insufficient permissions or rate limit exceeded." ]
27
+ when 404
28
+ [ :not_found_error,
29
+ "The requested resource was not found." ]
30
+ when 409
31
+ [ :conflict_error,
32
+ "Resource already exists." ]
33
+ when 429
34
+ [ :rate_limit_error,
35
+ "Rate limit exceeded." ]
36
+ when 500..599
37
+ [ :server_error,
38
+ "An error occurred on the Exa servers." ]
39
+ else
40
+ [ :unknown_error,
41
+ "The Exa service returned an unexpected status code: '#{ status_code }'." ]
42
+ end
43
+ end
44
+
45
+ end
46
+ end
@@ -0,0 +1,66 @@
1
+ module Exa
2
+ class FindSimilarOptions
3
+ include DynamicSchema::Definable
4
+ include Helpers
5
+
6
+ LIVECRAWL_OPTIONS = [ :never, :fallback, :always ]
7
+
8
+ schema do
9
+
10
+ # result control
11
+ num_results Integer, as: :numResults
12
+ exclude_source_domain [ TrueClass, FalseClass ], as: :excludeSourceDomain
13
+
14
+ # domain filtering
15
+ include_domains String, array: true, as: :includeDomains
16
+ exclude_domains String, array: true, as: :excludeDomains
17
+
18
+ # date filtering
19
+ start_crawl_date String, as: :startCrawlDate
20
+ end_crawl_date String, as: :endCrawlDate
21
+ start_published_date String, as: :startPublishedDate
22
+ end_published_date String, as: :endPublishedDate
23
+
24
+ # content filtering
25
+ include_text String, array: true, as: :includeText
26
+ exclude_text String, array: true, as: :excludeText
27
+
28
+ # content retrieval options
29
+ contents do
30
+ text do
31
+ max_characters Integer, as: :maxCharacters
32
+ include_html_tags [ TrueClass, FalseClass ], as: :includeHtmlTags
33
+ end
34
+ highlights do
35
+ num_sentences Integer, as: :numSentences
36
+ highlights_per_url Integer, as: :highlightsPerUrl
37
+ query String
38
+ end
39
+ summary do
40
+ query String
41
+ end
42
+ livecrawl Symbol, in: LIVECRAWL_OPTIONS
43
+ livecrawl_timeout Integer, as: :livecrawlTimeout
44
+ end
45
+
46
+ end
47
+
48
+ def self.build( options = nil, &block )
49
+ new( api_options: builder.build( options, &block ) )
50
+ end
51
+
52
+ def self.build!( options = nil, &block )
53
+ new( api_options: builder.build!( options, &block ) )
54
+ end
55
+
56
+ def initialize( options = {}, api_options: nil )
57
+ @options = self.class.builder.build( options || {} )
58
+ @options = api_options.merge( @options ) if api_options
59
+ end
60
+
61
+ def to_h
62
+ @options.to_h
63
+ end
64
+
65
+ end
66
+ end
@@ -0,0 +1,26 @@
1
+ module Exa
2
+ class FindSimilarRequest < Request
3
+
4
+ def submit( url, options = nil, &block )
5
+ if options
6
+ options = options.is_a?( FindSimilarOptions ) ? options : FindSimilarOptions.build!( options.to_h )
7
+ options = options.to_h
8
+ else
9
+ options = {}
10
+ end
11
+ options[ :url ] = url.to_s
12
+
13
+ response = post( "#{ BASE_URI }/findSimilar", options, &block )
14
+ attributes = ( JSON.parse( response.body, symbolize_names: true ) rescue nil )
15
+
16
+ result = if response.success?
17
+ FindSimilarResult.new( attributes )
18
+ else
19
+ ErrorResult.new( response.status, attributes )
20
+ end
21
+
22
+ ResponseMethods.install( response, result )
23
+ end
24
+
25
+ end
26
+ end
@@ -0,0 +1,32 @@
1
+ module Exa
2
+
3
+ FindSimilarResultItemSchema = DynamicSchema::Struct.define do
4
+ id String
5
+ url String
6
+ title String
7
+ score Float
8
+ published_date String, as: :publishedDate
9
+ author String
10
+ text String
11
+ highlights String, array: true
12
+ highlight_scores Float, array: true, as: :highlightScores
13
+ summary String
14
+ image String
15
+ favicon String
16
+ end
17
+
18
+ class FindSimilarResultItem < FindSimilarResultItemSchema
19
+ end
20
+
21
+ FindSimilarResultSchema = DynamicSchema::Struct.define do
22
+ request_id String, as: :requestId
23
+ results FindSimilarResultItem, array: true
24
+ end
25
+
26
+ class FindSimilarResult < FindSimilarResultSchema
27
+ def success?
28
+ true
29
+ end
30
+ end
31
+
32
+ end
@@ -0,0 +1,8 @@
1
+ module Exa
2
+ module Helpers
3
+ def string_camelize( string )
4
+ words = string.split( /[\s_\-]/ )
5
+ words.map.with_index { | word, index | index.zero? ? word.downcase : word.capitalize }.join
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,31 @@
1
+ module Exa
2
+ module ModuleMethods
3
+
4
+ def connection( connection = nil )
5
+ @connection = connection if connection
6
+ @connection ||= Faraday.new { | builder | builder.adapter Faraday.default_adapter }
7
+ end
8
+
9
+ def api_key( api_key = nil )
10
+ @api_key = api_key || @api_key
11
+ @api_key
12
+ end
13
+
14
+ def search( query, options = nil, &block )
15
+ Exa::SearchRequest.new.submit( query, options, &block )
16
+ end
17
+
18
+ def contents( urls, options = nil, &block )
19
+ Exa::ContentsRequest.new.submit( urls, options, &block )
20
+ end
21
+
22
+ def find_similar( url, options = nil, &block )
23
+ Exa::FindSimilarRequest.new.submit( url, options, &block )
24
+ end
25
+
26
+ def answer( query, options = nil, &block )
27
+ Exa::AnswerRequest.new.submit( query, options, &block )
28
+ end
29
+
30
+ end
31
+ end
@@ -0,0 +1,41 @@
1
+ module Exa
2
+ class Request
3
+
4
+ BASE_URI = 'https://api.exa.ai'
5
+
6
+ def initialize( connection: nil, api_key: nil )
7
+ @connection = connection || Exa.connection
8
+ @api_key = api_key || Exa.api_key
9
+ raise ArgumentError, "An 'api_key' is required unless configured using 'Exa.api_key'." \
10
+ unless @api_key
11
+ end
12
+
13
+ protected
14
+
15
+ def post( uri, body, &block )
16
+ headers = {
17
+ 'x-api-key' => @api_key,
18
+ 'Content-Type' => 'application/json'
19
+ }
20
+
21
+ @connection.post( uri ) do | request |
22
+ headers.each { | key, value | request.headers[ key ] = value }
23
+ request.body = body.is_a?( String ) ? body : JSON.generate( body )
24
+ block.call( request ) if block
25
+ end
26
+ end
27
+
28
+ def get( uri, &block )
29
+ headers = {
30
+ 'x-api-key' => @api_key,
31
+ 'Content-Type' => 'application/json'
32
+ }
33
+
34
+ @connection.get( uri ) do | request |
35
+ headers.each { | key, value | request.headers[ key ] = value }
36
+ block.call( request ) if block
37
+ end
38
+ end
39
+
40
+ end
41
+ end
@@ -0,0 +1,12 @@
1
+ module Exa
2
+ module ResponseMethods
3
+ def self.install( response, result )
4
+ response.instance_variable_set( "@_exa_result", result )
5
+ response.extend( ResponseMethods )
6
+ end
7
+
8
+ def result
9
+ @_exa_result
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,74 @@
1
+ module Exa
2
+ class SearchOptions
3
+ include DynamicSchema::Definable
4
+ include Helpers
5
+
6
+ SEARCH_TYPES = [ :neural, :keyword, :auto ]
7
+ CATEGORIES = [ :company, :research_paper, :news, :pdf, :github, :tweet, :personal_site,
8
+ :linkedin_profile, :financial_report ]
9
+ LIVECRAWL_OPTIONS = [ :never, :fallback, :always ]
10
+
11
+ schema do
12
+
13
+ # search configuration
14
+ type Symbol, in: SEARCH_TYPES
15
+ category Symbol, in: CATEGORIES
16
+ use_autoprompt [ TrueClass, FalseClass ], as: :useAutoprompt
17
+ user_location String, as: :userLocation
18
+
19
+ # result control
20
+ num_results Integer, as: :numResults
21
+
22
+ # domain filtering
23
+ include_domains String, array: true, as: :includeDomains
24
+ exclude_domains String, array: true, as: :excludeDomains
25
+
26
+ # date filtering
27
+ start_crawl_date String, as: :startCrawlDate
28
+ end_crawl_date String, as: :endCrawlDate
29
+ start_published_date String, as: :startPublishedDate
30
+ end_published_date String, as: :endPublishedDate
31
+
32
+ # content filtering
33
+ include_text String, array: true, as: :includeText
34
+ exclude_text String, array: true, as: :excludeText
35
+
36
+ # content retrieval options
37
+ contents do
38
+ text do
39
+ max_characters Integer, as: :maxCharacters
40
+ include_html_tags [ TrueClass, FalseClass ], as: :includeHtmlTags
41
+ end
42
+ highlights do
43
+ num_sentences Integer, as: :numSentences
44
+ highlights_per_url Integer, as: :highlightsPerUrl
45
+ query String
46
+ end
47
+ summary do
48
+ query String
49
+ end
50
+ livecrawl Symbol, in: LIVECRAWL_OPTIONS
51
+ livecrawl_timeout Integer, as: :livecrawlTimeout
52
+ end
53
+
54
+ end
55
+
56
+ def self.build( options = nil, &block )
57
+ new( api_options: builder.build( options, &block ) )
58
+ end
59
+
60
+ def self.build!( options = nil, &block )
61
+ new( api_options: builder.build!( options, &block ) )
62
+ end
63
+
64
+ def initialize( options = {}, api_options: nil )
65
+ @options = self.class.builder.build( options || {} )
66
+ @options = api_options.merge( @options ) if api_options
67
+ end
68
+
69
+ def to_h
70
+ @options.to_h
71
+ end
72
+
73
+ end
74
+ end
@@ -0,0 +1,26 @@
1
+ module Exa
2
+ class SearchRequest < Request
3
+
4
+ def submit( query, options = nil, &block )
5
+ if options
6
+ options = options.is_a?( SearchOptions ) ? options : SearchOptions.build!( options.to_h )
7
+ options = options.to_h
8
+ else
9
+ options = {}
10
+ end
11
+ options[ :query ] = query.to_s
12
+
13
+ response = post( "#{ BASE_URI }/search", options, &block )
14
+ attributes = ( JSON.parse( response.body, symbolize_names: true ) rescue nil )
15
+
16
+ result = if response.success?
17
+ SearchResult.new( attributes )
18
+ else
19
+ ErrorResult.new( response.status, attributes )
20
+ end
21
+
22
+ ResponseMethods.install( response, result )
23
+ end
24
+
25
+ end
26
+ end
@@ -0,0 +1,42 @@
1
+ module Exa
2
+
3
+ SearchResultItemSchema = DynamicSchema::Struct.define do
4
+ id String
5
+ url String
6
+ title String
7
+ score Float
8
+ published_date String, as: :publishedDate
9
+ author String
10
+ text String
11
+ highlights String, array: true
12
+ highlight_scores Float, array: true, as: :highlightScores
13
+ summary String
14
+ image String
15
+ favicon String
16
+ end
17
+
18
+ class SearchResultItem < SearchResultItemSchema
19
+ end
20
+
21
+ AutopromptSchema = DynamicSchema::Struct.define do
22
+ query String
23
+ end
24
+
25
+ class Autoprompt < AutopromptSchema
26
+ end
27
+
28
+ SearchResultSchema = DynamicSchema::Struct.define do
29
+ request_id String, as: :requestId
30
+ resolved_search_type String, as: :resolvedSearchType
31
+ autoprompt_string String, as: :autopromptString
32
+ autoprompt Autoprompt
33
+ results SearchResultItem, array: true
34
+ end
35
+
36
+ class SearchResult < SearchResultSchema
37
+ def success?
38
+ true
39
+ end
40
+ end
41
+
42
+ end
@@ -0,0 +1,3 @@
1
+ module Exa
2
+ VERSION = '1.0.0'
3
+ end
data/lib/exa.rb ADDED
@@ -0,0 +1,34 @@
1
+ require 'json'
2
+ require 'uri'
3
+
4
+ require 'faraday'
5
+ require 'dynamic_schema'
6
+
7
+ require_relative 'exa/version'
8
+
9
+ require_relative 'exa/helpers'
10
+ require_relative 'exa/error_result'
11
+ require_relative 'exa/request'
12
+ require_relative 'exa/response_methods'
13
+
14
+ require_relative 'exa/search_options'
15
+ require_relative 'exa/search_result'
16
+ require_relative 'exa/search_request'
17
+
18
+ require_relative 'exa/contents_options'
19
+ require_relative 'exa/contents_result'
20
+ require_relative 'exa/contents_request'
21
+
22
+ require_relative 'exa/find_similar_options'
23
+ require_relative 'exa/find_similar_result'
24
+ require_relative 'exa/find_similar_request'
25
+
26
+ require_relative 'exa/answer_options'
27
+ require_relative 'exa/answer_result'
28
+ require_relative 'exa/answer_request'
29
+
30
+ require_relative 'exa/module_methods'
31
+
32
+ module Exa
33
+ extend ModuleMethods
34
+ end
metadata ADDED
@@ -0,0 +1,140 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: exa-rb
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Kristoph Cichocki-Romanov
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: faraday
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: dynamicschema
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: minitest
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '5.25'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '5.25'
54
+ - !ruby/object:Gem::Dependency
55
+ name: debug
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.9'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.9'
68
+ - !ruby/object:Gem::Dependency
69
+ name: vcr
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '6.3'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '6.3'
82
+ description: |-
83
+ The Exa gem implements a lightweight interface to the Exa.ai API. Exa provides powerful neural search capabilities that go beyond traditional keyword matching to understand semantic meaning and context.
84
+
85
+ This gem supports search, find similar, answer, and contents endpoints. It includes both a Ruby API and a command-line interface (exa) for quick searches from the terminal.
86
+ email:
87
+ - rubygems.org@kristoph.net
88
+ executables:
89
+ - exa
90
+ extensions: []
91
+ extra_rdoc_files: []
92
+ files:
93
+ - LICENSE
94
+ - README.md
95
+ - bin/exa
96
+ - exa-rb.gemspec
97
+ - lib/exa.rb
98
+ - lib/exa/answer_options.rb
99
+ - lib/exa/answer_request.rb
100
+ - lib/exa/answer_result.rb
101
+ - lib/exa/contents_options.rb
102
+ - lib/exa/contents_request.rb
103
+ - lib/exa/contents_result.rb
104
+ - lib/exa/error_result.rb
105
+ - lib/exa/find_similar_options.rb
106
+ - lib/exa/find_similar_request.rb
107
+ - lib/exa/find_similar_result.rb
108
+ - lib/exa/helpers.rb
109
+ - lib/exa/module_methods.rb
110
+ - lib/exa/request.rb
111
+ - lib/exa/response_methods.rb
112
+ - lib/exa/search_options.rb
113
+ - lib/exa/search_request.rb
114
+ - lib/exa/search_result.rb
115
+ - lib/exa/version.rb
116
+ homepage: https://github.com/EndlessInternational/exa-rb
117
+ licenses:
118
+ - MIT
119
+ metadata:
120
+ source_code_uri: https://github.com/EndlessInternational/exa-rb
121
+ bug_tracker_uri: https://github.com/EndlessInternational/exa-rb/issues
122
+ rdoc_options: []
123
+ require_paths:
124
+ - lib
125
+ required_ruby_version: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - ">="
128
+ - !ruby/object:Gem::Version
129
+ version: '3.0'
130
+ required_rubygems_version: !ruby/object:Gem::Requirement
131
+ requirements:
132
+ - - ">="
133
+ - !ruby/object:Gem::Version
134
+ version: '0'
135
+ requirements: []
136
+ rubygems_version: 3.6.7
137
+ specification_version: 4
138
+ summary: The Exa gem implements a lightweight interface to the Exa.ai API for neural
139
+ and keyword-based web search with content retrieval capabilities.
140
+ test_files: []