exa-ai 0.1.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 +21 -0
- data/README.md +562 -0
- data/exe/exa-ai +95 -0
- data/exe/exa-ai-answer +131 -0
- data/exe/exa-ai-context +104 -0
- data/exe/exa-ai-get-contents +114 -0
- data/exe/exa-ai-research-get +110 -0
- data/exe/exa-ai-research-list +95 -0
- data/exe/exa-ai-research-start +175 -0
- data/exe/exa-ai-search +134 -0
- data/lib/exa/cli/base.rb +51 -0
- data/lib/exa/cli/error_handler.rb +98 -0
- data/lib/exa/cli/formatters/answer_formatter.rb +63 -0
- data/lib/exa/cli/formatters/contents_formatter.rb +50 -0
- data/lib/exa/cli/formatters/context_formatter.rb +58 -0
- data/lib/exa/cli/formatters/research_formatter.rb +120 -0
- data/lib/exa/cli/formatters/search_formatter.rb +44 -0
- data/lib/exa/cli/polling.rb +46 -0
- data/lib/exa/client.rb +132 -0
- data/lib/exa/connection.rb +32 -0
- data/lib/exa/error.rb +31 -0
- data/lib/exa/middleware/raise_error.rb +55 -0
- data/lib/exa/resources/answer.rb +20 -0
- data/lib/exa/resources/contents_result.rb +29 -0
- data/lib/exa/resources/context_result.rb +37 -0
- data/lib/exa/resources/find_similar_result.rb +28 -0
- data/lib/exa/resources/research_list.rb +18 -0
- data/lib/exa/resources/research_task.rb +39 -0
- data/lib/exa/resources/search_result.rb +30 -0
- data/lib/exa/services/answer.rb +23 -0
- data/lib/exa/services/context.rb +27 -0
- data/lib/exa/services/find_similar.rb +26 -0
- data/lib/exa/services/get_contents.rb +25 -0
- data/lib/exa/services/research_get.rb +30 -0
- data/lib/exa/services/research_list.rb +37 -0
- data/lib/exa/services/research_start.rb +26 -0
- data/lib/exa/services/search.rb +26 -0
- data/lib/exa/version.rb +5 -0
- data/lib/exa.rb +54 -0
- metadata +174 -0
@@ -0,0 +1,120 @@
|
|
1
|
+
module Exa
|
2
|
+
module CLI
|
3
|
+
module Formatters
|
4
|
+
class ResearchFormatter
|
5
|
+
def self.format_task(task, format, show_events: false)
|
6
|
+
case format
|
7
|
+
when "json"
|
8
|
+
JSON.pretty_generate(task.to_h)
|
9
|
+
when "pretty"
|
10
|
+
format_task_pretty(task, show_events: show_events)
|
11
|
+
when "text"
|
12
|
+
format_task_text(task, show_events: show_events)
|
13
|
+
else
|
14
|
+
JSON.pretty_generate(task.to_h)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.format_list(list, format)
|
19
|
+
case format
|
20
|
+
when "json"
|
21
|
+
JSON.pretty_generate(list.to_h)
|
22
|
+
when "pretty"
|
23
|
+
format_list_pretty(list)
|
24
|
+
when "text"
|
25
|
+
format_list_text(list)
|
26
|
+
else
|
27
|
+
JSON.pretty_generate(list.to_h)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def self.format_task_pretty(task, show_events: false)
|
34
|
+
output = []
|
35
|
+
output << "Research Task: #{task.research_id}"
|
36
|
+
output << "Status: #{task.status.upcase}"
|
37
|
+
output << "Created: #{task.created_at}"
|
38
|
+
output << ""
|
39
|
+
|
40
|
+
case task.status
|
41
|
+
when "pending"
|
42
|
+
output << "Task is pending execution..."
|
43
|
+
when "running"
|
44
|
+
output << "Task is running... ⚙️"
|
45
|
+
when "completed"
|
46
|
+
output << "Output:"
|
47
|
+
output << "--------"
|
48
|
+
output << task.output.to_s
|
49
|
+
output << ""
|
50
|
+
output << "Cost: $#{task.cost_dollars}" if task.cost_dollars
|
51
|
+
when "failed"
|
52
|
+
output << "Error: #{task.error}"
|
53
|
+
when "canceled"
|
54
|
+
output << "Task was canceled"
|
55
|
+
output << "Finished: #{task.finished_at}" if task.finished_at
|
56
|
+
end
|
57
|
+
|
58
|
+
if show_events && task.events && !task.events.empty?
|
59
|
+
output << ""
|
60
|
+
output << "Events:"
|
61
|
+
output << "-------"
|
62
|
+
task.events.each do |event|
|
63
|
+
output << "- #{event}"
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
output.join("\n")
|
68
|
+
end
|
69
|
+
|
70
|
+
def self.format_list_pretty(list)
|
71
|
+
output = []
|
72
|
+
output << "Research Tasks (#{list.data.length}):"
|
73
|
+
output << ""
|
74
|
+
|
75
|
+
if list.data.empty?
|
76
|
+
output << "No tasks found."
|
77
|
+
else
|
78
|
+
# Simple table format
|
79
|
+
output << "%-40s %-15s %s" % ["Task ID", "Status", "Created"]
|
80
|
+
output << "-" * 70
|
81
|
+
|
82
|
+
list.data.each do |task|
|
83
|
+
task_id = task.research_id.to_s[0..38]
|
84
|
+
status = task.status.upcase[0..14]
|
85
|
+
created = task.created_at.to_s[0..19]
|
86
|
+
output << "%-40s %-15s %s" % [task_id, status, created]
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
output << ""
|
91
|
+
if list.has_more
|
92
|
+
output << "More results available. Use --cursor #{list.next_cursor} for next page."
|
93
|
+
else
|
94
|
+
output << "End of results."
|
95
|
+
end
|
96
|
+
|
97
|
+
output.join("\n")
|
98
|
+
end
|
99
|
+
|
100
|
+
def self.format_task_text(task, show_events: false)
|
101
|
+
output = []
|
102
|
+
output << "#{task.research_id} #{task.status.upcase} #{task.created_at}"
|
103
|
+
if task.status == "completed"
|
104
|
+
output << task.output.to_s
|
105
|
+
elsif task.status == "failed"
|
106
|
+
output << "Error: #{task.error}"
|
107
|
+
end
|
108
|
+
output.join("\n")
|
109
|
+
end
|
110
|
+
|
111
|
+
def self.format_list_text(list)
|
112
|
+
output = list.data.map do |task|
|
113
|
+
"#{task.research_id} #{task.status.upcase} #{task.created_at}"
|
114
|
+
end
|
115
|
+
output.join("\n")
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Exa
|
4
|
+
module CLI
|
5
|
+
module Formatters
|
6
|
+
class SearchFormatter
|
7
|
+
def self.format(result, format)
|
8
|
+
case format
|
9
|
+
when "json"
|
10
|
+
JSON.pretty_generate(result.to_h)
|
11
|
+
when "pretty"
|
12
|
+
format_pretty(result)
|
13
|
+
when "text"
|
14
|
+
format_text(result)
|
15
|
+
else
|
16
|
+
JSON.pretty_generate(result.to_h)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def self.format_pretty(result)
|
23
|
+
output = []
|
24
|
+
result.results.each_with_index do |item, idx|
|
25
|
+
output << "--- Result #{idx + 1} ---"
|
26
|
+
output << "Title: #{item['title']}"
|
27
|
+
output << "URL: #{item['url']}"
|
28
|
+
output << "Score: #{item['score']}" if item['score']
|
29
|
+
output << ""
|
30
|
+
end
|
31
|
+
output.join("\n")
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.format_text(result)
|
35
|
+
output = []
|
36
|
+
result.results.each do |item|
|
37
|
+
output << "#{item['title']}\n#{item['url']}"
|
38
|
+
end
|
39
|
+
output.join("\n\n")
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Exa
|
4
|
+
module CLI
|
5
|
+
class Polling
|
6
|
+
class TimeoutError < StandardError; end
|
7
|
+
|
8
|
+
# Poll a block until it returns done: true
|
9
|
+
# Implements exponential backoff with jitter
|
10
|
+
#
|
11
|
+
# Options:
|
12
|
+
# max_duration: Maximum time to poll (default: 300 seconds / 5 minutes)
|
13
|
+
# initial_delay: Initial delay in seconds (default: 1)
|
14
|
+
# max_delay: Maximum delay between polls (default: 30)
|
15
|
+
#
|
16
|
+
# Block should return a hash:
|
17
|
+
# { done: boolean, result: any, status: string }
|
18
|
+
#
|
19
|
+
# Returns the result value when done is true
|
20
|
+
def self.poll(max_duration: 300, initial_delay: 1, max_delay: 30)
|
21
|
+
start_time = Time.now
|
22
|
+
current_delay = initial_delay
|
23
|
+
attempt = 0
|
24
|
+
|
25
|
+
loop do
|
26
|
+
response = yield
|
27
|
+
return response[:result] if response[:done]
|
28
|
+
|
29
|
+
elapsed_time = Time.now - start_time
|
30
|
+
if elapsed_time > max_duration
|
31
|
+
raise TimeoutError,
|
32
|
+
"Polling timed out after #{elapsed_time.round(2)} seconds"
|
33
|
+
end
|
34
|
+
|
35
|
+
# Sleep before next attempt
|
36
|
+
sleep [current_delay, max_delay].min
|
37
|
+
|
38
|
+
# Exponential backoff: multiply by 2 for next iteration
|
39
|
+
current_delay *= 2
|
40
|
+
|
41
|
+
attempt += 1
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
data/lib/exa/client.rb
ADDED
@@ -0,0 +1,132 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Exa
|
4
|
+
# Main client for interacting with the Exa.ai API
|
5
|
+
#
|
6
|
+
# Provides methods for all supported Exa.ai operations including search,
|
7
|
+
# content retrieval, answer generation, and async research tasks.
|
8
|
+
#
|
9
|
+
# @example Basic usage
|
10
|
+
# client = Exa::Client.new(api_key: "your-key")
|
11
|
+
# results = client.search("ruby on rails")
|
12
|
+
# results.results.each { |r| puts r.title }
|
13
|
+
#
|
14
|
+
# @example Using configuration
|
15
|
+
# Exa.configure { |config| config.api_key = "your-key" }
|
16
|
+
# client = Exa::Client.new
|
17
|
+
class Client
|
18
|
+
# Initialize a new Exa client
|
19
|
+
#
|
20
|
+
# @param api_key [String, nil] API key for authentication. Falls back to Exa.api_key if not provided
|
21
|
+
# @param options [Hash] Connection options
|
22
|
+
# @option options [String] :base_url Custom API base URL (default: https://api.exa.ai)
|
23
|
+
# @option options [Integer] :timeout Request timeout in seconds (default: 30)
|
24
|
+
# @raise [ConfigurationError] If API key is not provided and not configured
|
25
|
+
def initialize(api_key: nil, **options)
|
26
|
+
@api_key = api_key || Exa.api_key
|
27
|
+
@options = options
|
28
|
+
|
29
|
+
validate_api_key!
|
30
|
+
end
|
31
|
+
|
32
|
+
# Execute a search query
|
33
|
+
#
|
34
|
+
# @param query [String] Search query
|
35
|
+
# @param params [Hash] Additional search parameters
|
36
|
+
# @option params [String] :type Search type (web, news, etc.)
|
37
|
+
# @option params [Array<String>] :include_domains Domains to include in results
|
38
|
+
# @option params [Array<String>] :exclude_domains Domains to exclude from results
|
39
|
+
# @option params [Integer] :num_results Number of results to return
|
40
|
+
# @return [Resources::SearchResult] Search results with metadata
|
41
|
+
def search(query, **params)
|
42
|
+
Services::Search.new(connection, query: query, **params).call
|
43
|
+
end
|
44
|
+
|
45
|
+
# Find similar content to a given URL
|
46
|
+
#
|
47
|
+
# @param url [String] URL to find similar content for
|
48
|
+
# @param options [Hash] Search options
|
49
|
+
# @option options [Integer] :num_results Number of results to return
|
50
|
+
# @return [Resources::SearchResult] Similar results
|
51
|
+
def find_similar(url, **options)
|
52
|
+
Services::FindSimilar.new(connection, url: url, **options).call
|
53
|
+
end
|
54
|
+
|
55
|
+
# Get full page contents for URLs
|
56
|
+
#
|
57
|
+
# @param urls [Array<String>, String] URL or URLs to fetch contents for
|
58
|
+
# @param options [Hash] Fetch options
|
59
|
+
# @return [Resources::ContentCollection] Collection of page contents
|
60
|
+
def get_contents(urls, **options)
|
61
|
+
Services::GetContents.new(connection, urls: urls, **options).call
|
62
|
+
end
|
63
|
+
|
64
|
+
# Get AI-generated answers to a query
|
65
|
+
#
|
66
|
+
# @param query [String] Question or query
|
67
|
+
# @param options [Hash] Answer options
|
68
|
+
# @option options [String] :format Response format (default: standard)
|
69
|
+
# @return [Resources::Answer] AI-generated answer with sources
|
70
|
+
def answer(query, **options)
|
71
|
+
Services::Answer.new(connection, query: query, **options).call
|
72
|
+
end
|
73
|
+
|
74
|
+
# Start an asynchronous research task
|
75
|
+
#
|
76
|
+
# @param params [Hash] Research parameters
|
77
|
+
# @return [Resources::Research] Research task with ID
|
78
|
+
def research_start(**params)
|
79
|
+
Services::ResearchStart.new(connection, **params).call
|
80
|
+
end
|
81
|
+
|
82
|
+
# List all research tasks
|
83
|
+
#
|
84
|
+
# @param params [Hash] Listing parameters
|
85
|
+
# @option params [Integer] :limit Maximum number of tasks to return
|
86
|
+
# @return [Resources::ResearchList] List of research tasks
|
87
|
+
def research_list(**params)
|
88
|
+
Services::ResearchList.new(connection, **params).call
|
89
|
+
end
|
90
|
+
|
91
|
+
# Get status and results of a research task
|
92
|
+
#
|
93
|
+
# @param research_id [String] Research task ID
|
94
|
+
# @param params [Hash] Fetch options
|
95
|
+
# @return [Resources::Research] Research task with current status and results
|
96
|
+
def research_get(research_id, **params)
|
97
|
+
Services::ResearchGet.new(connection, research_id: research_id, **params).call
|
98
|
+
end
|
99
|
+
|
100
|
+
# Search code repositories
|
101
|
+
#
|
102
|
+
# @param query [String] Code search query
|
103
|
+
# @param params [Hash] Search parameters
|
104
|
+
# @option params [Array<String>] :languages Programming languages to search
|
105
|
+
# @return [Resources::SearchResult] Code search results
|
106
|
+
def context(query, **params)
|
107
|
+
Services::Context.new(connection, query: query, **params).call
|
108
|
+
end
|
109
|
+
|
110
|
+
private
|
111
|
+
|
112
|
+
def connection
|
113
|
+
@connection ||= Connection.build(
|
114
|
+
api_key: @api_key,
|
115
|
+
**connection_options
|
116
|
+
)
|
117
|
+
end
|
118
|
+
|
119
|
+
def connection_options
|
120
|
+
options = {}
|
121
|
+
options[:base_url] = @options[:base_url] if @options[:base_url]
|
122
|
+
options[:timeout] = @options[:timeout] if @options[:timeout]
|
123
|
+
options
|
124
|
+
end
|
125
|
+
|
126
|
+
def validate_api_key!
|
127
|
+
return if @api_key && !@api_key.empty?
|
128
|
+
|
129
|
+
raise ConfigurationError, "API key is required. Set it with Exa.configure or pass it to Client.new"
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "faraday"
|
4
|
+
|
5
|
+
module Exa
|
6
|
+
class Connection
|
7
|
+
def self.build(api_key:, **options, &block)
|
8
|
+
Faraday.new(url: options[:base_url] || DEFAULT_BASE_URL) do |conn|
|
9
|
+
# Authentication
|
10
|
+
conn.request :authorization, "Bearer", api_key
|
11
|
+
|
12
|
+
# Request/Response JSON encoding
|
13
|
+
conn.request :json
|
14
|
+
|
15
|
+
# Custom error handling (registered before JSON so it runs after in response chain)
|
16
|
+
conn.response :raise_error
|
17
|
+
conn.response :json, content_type: /\bjson$/
|
18
|
+
|
19
|
+
# Timeouts
|
20
|
+
conn.options.timeout = options[:timeout] || 30
|
21
|
+
conn.options.open_timeout = options[:open_timeout] || 10
|
22
|
+
|
23
|
+
# Adapter (allow override for testing)
|
24
|
+
if block_given?
|
25
|
+
conn.adapter options[:adapter] || Faraday.default_adapter, &block
|
26
|
+
else
|
27
|
+
conn.adapter options[:adapter] || Faraday.default_adapter
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
data/lib/exa/error.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Exa
|
4
|
+
class Error < StandardError
|
5
|
+
attr_reader :response
|
6
|
+
|
7
|
+
def initialize(message = nil, response = nil)
|
8
|
+
@response = response
|
9
|
+
super(message)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
# Client errors (4xx)
|
14
|
+
class ClientError < Error; end
|
15
|
+
class BadRequest < ClientError; end # 400
|
16
|
+
class Unauthorized < ClientError; end # 401
|
17
|
+
class Forbidden < ClientError; end # 403
|
18
|
+
class NotFound < ClientError; end # 404
|
19
|
+
class UnprocessableEntity < ClientError; end # 422
|
20
|
+
class TooManyRequests < ClientError; end # 429
|
21
|
+
|
22
|
+
# Server errors (5xx)
|
23
|
+
class ServerError < Error; end
|
24
|
+
class InternalServerError < ServerError; end # 500
|
25
|
+
class BadGateway < ServerError; end # 502
|
26
|
+
class ServiceUnavailable < ServerError; end # 503
|
27
|
+
class GatewayTimeout < ServerError; end # 504
|
28
|
+
|
29
|
+
# Configuration errors
|
30
|
+
class ConfigurationError < Error; end
|
31
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "faraday"
|
4
|
+
|
5
|
+
module Exa
|
6
|
+
module Middleware
|
7
|
+
class RaiseError < Faraday::Middleware
|
8
|
+
def on_complete(env)
|
9
|
+
case env[:status]
|
10
|
+
when 400
|
11
|
+
handle_error(env, Exa::BadRequest)
|
12
|
+
when 401
|
13
|
+
handle_error(env, Exa::Unauthorized)
|
14
|
+
when 403
|
15
|
+
handle_error(env, Exa::Forbidden)
|
16
|
+
when 404
|
17
|
+
handle_error(env, Exa::NotFound)
|
18
|
+
when 422
|
19
|
+
handle_error(env, Exa::UnprocessableEntity)
|
20
|
+
when 429
|
21
|
+
handle_error(env, Exa::TooManyRequests)
|
22
|
+
when 500
|
23
|
+
handle_error(env, Exa::InternalServerError)
|
24
|
+
when 502
|
25
|
+
handle_error(env, Exa::BadGateway)
|
26
|
+
when 503
|
27
|
+
handle_error(env, Exa::ServiceUnavailable)
|
28
|
+
when 504
|
29
|
+
handle_error(env, Exa::GatewayTimeout)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def handle_error(env, error_class)
|
36
|
+
message = extract_error_message(env)
|
37
|
+
raise error_class.new(message, env.response)
|
38
|
+
end
|
39
|
+
|
40
|
+
def extract_error_message(env)
|
41
|
+
body = env[:body]
|
42
|
+
|
43
|
+
if body.is_a?(Hash)
|
44
|
+
return body["error"] if body["error"]
|
45
|
+
return body[:error] if body[:error]
|
46
|
+
end
|
47
|
+
|
48
|
+
"HTTP #{env[:status]}"
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# Register the middleware with Faraday
|
55
|
+
Faraday::Response.register_middleware raise_error: Exa::Middleware::RaiseError
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Exa
|
4
|
+
module Resources
|
5
|
+
# Represents an answer response from the Exa API
|
6
|
+
#
|
7
|
+
# This class wraps the JSON response from the /answer endpoint and provides
|
8
|
+
# a Ruby-friendly interface for accessing the generated answer and citations.
|
9
|
+
class Answer < Struct.new(:answer, :citations, :cost_dollars, keyword_init: true)
|
10
|
+
def initialize(answer:, citations: [], cost_dollars: nil)
|
11
|
+
super
|
12
|
+
freeze
|
13
|
+
end
|
14
|
+
|
15
|
+
def to_h
|
16
|
+
{ answer: answer, citations: citations, cost_dollars: cost_dollars }
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Exa
|
2
|
+
module Resources
|
3
|
+
# Represents a contents response from the Exa API
|
4
|
+
#
|
5
|
+
# This class wraps the JSON response from the /contents endpoint and provides
|
6
|
+
# a Ruby-friendly interface for accessing content results and metadata.
|
7
|
+
class ContentsResult < Struct.new(
|
8
|
+
:results,
|
9
|
+
:request_id,
|
10
|
+
:context,
|
11
|
+
:statuses,
|
12
|
+
:cost_dollars,
|
13
|
+
keyword_init: true
|
14
|
+
)
|
15
|
+
def initialize(results:, request_id: nil, context: nil, statuses: nil, cost_dollars: nil)
|
16
|
+
super
|
17
|
+
freeze
|
18
|
+
end
|
19
|
+
|
20
|
+
def empty?
|
21
|
+
results.empty?
|
22
|
+
end
|
23
|
+
|
24
|
+
def first
|
25
|
+
results.first
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Exa
|
4
|
+
module Resources
|
5
|
+
# Represents a Context API response from the Exa API
|
6
|
+
#
|
7
|
+
# This class wraps the JSON response from the /context endpoint and provides
|
8
|
+
# a Ruby-friendly interface for accessing code snippets and metadata.
|
9
|
+
class ContextResult < Struct.new(
|
10
|
+
:request_id,
|
11
|
+
:query,
|
12
|
+
:response,
|
13
|
+
:results_count,
|
14
|
+
:cost_dollars,
|
15
|
+
:search_time,
|
16
|
+
:output_tokens,
|
17
|
+
keyword_init: true
|
18
|
+
)
|
19
|
+
def initialize(**)
|
20
|
+
super
|
21
|
+
freeze
|
22
|
+
end
|
23
|
+
|
24
|
+
def to_h
|
25
|
+
{
|
26
|
+
request_id: request_id,
|
27
|
+
query: query,
|
28
|
+
response: response,
|
29
|
+
results_count: results_count,
|
30
|
+
cost_dollars: cost_dollars,
|
31
|
+
search_time: search_time,
|
32
|
+
output_tokens: output_tokens
|
33
|
+
}
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module Exa
|
2
|
+
module Resources
|
3
|
+
# Represents a find similar response from the Exa API
|
4
|
+
#
|
5
|
+
# This class wraps the JSON response from the /findSimilar endpoint and provides
|
6
|
+
# a Ruby-friendly interface for accessing similar results and metadata.
|
7
|
+
class FindSimilarResult < Struct.new(
|
8
|
+
:results,
|
9
|
+
:request_id,
|
10
|
+
:context,
|
11
|
+
:cost_dollars,
|
12
|
+
keyword_init: true
|
13
|
+
)
|
14
|
+
def initialize(results:, request_id: nil, context: nil, cost_dollars: nil)
|
15
|
+
super
|
16
|
+
freeze
|
17
|
+
end
|
18
|
+
|
19
|
+
def empty?
|
20
|
+
results.empty?
|
21
|
+
end
|
22
|
+
|
23
|
+
def first
|
24
|
+
results.first
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Exa
|
2
|
+
module Resources
|
3
|
+
class ResearchList < Struct.new(:data, :has_more, :next_cursor, keyword_init: true)
|
4
|
+
def initialize(data:, has_more:, next_cursor: nil)
|
5
|
+
super
|
6
|
+
freeze
|
7
|
+
end
|
8
|
+
|
9
|
+
def to_h
|
10
|
+
{
|
11
|
+
data: data.map { |item| item.respond_to?(:to_h) ? item.to_h : item },
|
12
|
+
has_more: has_more,
|
13
|
+
next_cursor: next_cursor
|
14
|
+
}
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module Exa
|
2
|
+
module Resources
|
3
|
+
class ResearchTask < Struct.new(
|
4
|
+
:research_id, :created_at, :status, :model, :instructions,
|
5
|
+
:output_schema, :events, :output, :cost_dollars, :finished_at,
|
6
|
+
:error, keyword_init: true
|
7
|
+
)
|
8
|
+
def initialize(research_id:, created_at:, status:, instructions:, model: nil, output_schema: nil, events: nil, output: nil, cost_dollars: nil, finished_at: nil, error: nil)
|
9
|
+
super
|
10
|
+
freeze
|
11
|
+
end
|
12
|
+
|
13
|
+
def pending? = status == 'pending'
|
14
|
+
def running? = status == 'running'
|
15
|
+
def completed? = status == 'completed'
|
16
|
+
def failed? = status == 'failed'
|
17
|
+
def canceled? = status == 'canceled'
|
18
|
+
|
19
|
+
def finished? = !running? && !pending?
|
20
|
+
|
21
|
+
def to_h
|
22
|
+
result = {
|
23
|
+
research_id: research_id,
|
24
|
+
created_at: created_at,
|
25
|
+
status: status,
|
26
|
+
instructions: instructions
|
27
|
+
}
|
28
|
+
result[:model] = model if model
|
29
|
+
result[:output_schema] = output_schema if output_schema
|
30
|
+
result[:events] = events if events
|
31
|
+
result[:output] = output if output
|
32
|
+
result[:cost_dollars] = cost_dollars if cost_dollars
|
33
|
+
result[:finished_at] = finished_at if finished_at
|
34
|
+
result[:error] = error if error
|
35
|
+
result
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Exa
|
2
|
+
module Resources
|
3
|
+
# Represents a search response from the Exa API
|
4
|
+
#
|
5
|
+
# This class wraps the JSON response from the /search endpoint and provides
|
6
|
+
# a Ruby-friendly interface for accessing search results and metadata.
|
7
|
+
class SearchResult < Struct.new(
|
8
|
+
:results,
|
9
|
+
:request_id,
|
10
|
+
:resolved_search_type,
|
11
|
+
:search_type,
|
12
|
+
:context,
|
13
|
+
:cost_dollars,
|
14
|
+
keyword_init: true
|
15
|
+
)
|
16
|
+
def initialize(results:, request_id: nil, resolved_search_type: nil, search_type: nil, context: nil, cost_dollars: nil)
|
17
|
+
super
|
18
|
+
freeze
|
19
|
+
end
|
20
|
+
|
21
|
+
def empty?
|
22
|
+
results.empty?
|
23
|
+
end
|
24
|
+
|
25
|
+
def first
|
26
|
+
results.first
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Exa
|
4
|
+
module Services
|
5
|
+
class Answer
|
6
|
+
def initialize(connection, **params)
|
7
|
+
@connection = connection
|
8
|
+
@params = params
|
9
|
+
end
|
10
|
+
|
11
|
+
def call
|
12
|
+
response = @connection.post("/answer", @params)
|
13
|
+
body = response.body
|
14
|
+
|
15
|
+
Resources::Answer.new(
|
16
|
+
answer: body["answer"],
|
17
|
+
citations: body["citations"] || [],
|
18
|
+
cost_dollars: body["costDollars"]
|
19
|
+
)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|