boxcars 0.7.7 → 0.8.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 +4 -4
- data/.rubocop.yml +6 -3
- data/.ruby-version +1 -1
- data/Gemfile +3 -13
- data/Gemfile.lock +29 -25
- data/POSTHOG_TEST_README.md +118 -0
- data/README.md +305 -0
- data/boxcars.gemspec +1 -2
- data/lib/boxcars/boxcar/active_record.rb +9 -10
- data/lib/boxcars/boxcar/calculator.rb +2 -2
- data/lib/boxcars/boxcar/engine_boxcar.rb +4 -4
- data/lib/boxcars/boxcar/google_search.rb +2 -2
- data/lib/boxcars/boxcar/json_engine_boxcar.rb +1 -1
- data/lib/boxcars/boxcar/ruby_calculator.rb +1 -1
- data/lib/boxcars/boxcar/sql_base.rb +4 -4
- data/lib/boxcars/boxcar/swagger.rb +3 -3
- data/lib/boxcars/boxcar/vector_answer.rb +3 -3
- data/lib/boxcars/boxcar/xml_engine_boxcar.rb +1 -1
- data/lib/boxcars/boxcar.rb +6 -6
- data/lib/boxcars/conversation_prompt.rb +3 -3
- data/lib/boxcars/engine/anthropic.rb +121 -23
- data/lib/boxcars/engine/cerebras.rb +2 -2
- data/lib/boxcars/engine/cohere.rb +135 -9
- data/lib/boxcars/engine/gemini_ai.rb +151 -76
- data/lib/boxcars/engine/google.rb +2 -2
- data/lib/boxcars/engine/gpt4all_eng.rb +92 -34
- data/lib/boxcars/engine/groq.rb +124 -73
- data/lib/boxcars/engine/intelligence_base.rb +52 -17
- data/lib/boxcars/engine/ollama.rb +127 -47
- data/lib/boxcars/engine/openai.rb +186 -103
- data/lib/boxcars/engine/perplexityai.rb +116 -136
- data/lib/boxcars/engine/together.rb +2 -2
- data/lib/boxcars/engine/unified_observability.rb +430 -0
- data/lib/boxcars/engine.rb +4 -3
- data/lib/boxcars/engines.rb +74 -0
- data/lib/boxcars/observability.rb +44 -0
- data/lib/boxcars/observability_backend.rb +17 -0
- data/lib/boxcars/observability_backends/multi_backend.rb +42 -0
- data/lib/boxcars/observability_backends/posthog_backend.rb +89 -0
- data/lib/boxcars/observation.rb +8 -8
- data/lib/boxcars/prompt.rb +16 -4
- data/lib/boxcars/result.rb +7 -12
- data/lib/boxcars/ruby_repl.rb +1 -1
- data/lib/boxcars/train/train_action.rb +1 -1
- data/lib/boxcars/train/xml_train.rb +3 -3
- data/lib/boxcars/train/xml_zero_shot.rb +1 -1
- data/lib/boxcars/train/zero_shot.rb +3 -3
- data/lib/boxcars/train.rb +1 -1
- data/lib/boxcars/vector_search.rb +5 -5
- data/lib/boxcars/vector_store/pgvector/build_from_array.rb +116 -88
- data/lib/boxcars/vector_store/pgvector/build_from_files.rb +106 -80
- data/lib/boxcars/vector_store/pgvector/save_to_database.rb +148 -122
- data/lib/boxcars/vector_store/pgvector/search.rb +157 -131
- data/lib/boxcars/vector_store.rb +4 -4
- data/lib/boxcars/version.rb +1 -1
- data/lib/boxcars.rb +31 -20
- metadata +11 -21
@@ -4,6 +4,7 @@
|
|
4
4
|
module Boxcars
|
5
5
|
# A engine that uses Cohere's API.
|
6
6
|
class Cohere < Engine
|
7
|
+
include UnifiedObservability
|
7
8
|
attr_reader :prompts, :llm_params, :model_kwargs, :batch_size
|
8
9
|
|
9
10
|
# The default parameters to use when asking the engine.
|
@@ -29,7 +30,7 @@ module Boxcars
|
|
29
30
|
@llm_params = DEFAULT_PARAMS.merge(kwargs)
|
30
31
|
@prompts = prompts
|
31
32
|
@batch_size = 20
|
32
|
-
super(description
|
33
|
+
super(description:, name:)
|
33
34
|
end
|
34
35
|
|
35
36
|
def conversation_model?(_model)
|
@@ -59,20 +60,45 @@ module Boxcars
|
|
59
60
|
# Defaults to Boxcars.configuration.cohere_api_key.
|
60
61
|
# @param kwargs [Hash] Additional parameters to pass to the engine if wanted.
|
61
62
|
def client(prompt:, inputs: {}, **kwargs)
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
63
|
+
start_time = Time.now
|
64
|
+
response_data = { response_obj: nil, parsed_json: nil, success: false, error: nil, status_code: nil }
|
65
|
+
current_params = llm_params.merge(kwargs)
|
66
|
+
current_prompt_object = prompt.is_a?(Array) ? prompt.first : prompt
|
67
|
+
api_request_params = nil
|
68
|
+
|
69
|
+
begin
|
70
|
+
api_key = Boxcars.configuration.cohere_api_key(**kwargs)
|
71
|
+
api_request_params = current_prompt_object.as_prompt(inputs:, prefixes: default_prefixes,
|
72
|
+
show_roles: true).merge(current_params)
|
73
|
+
api_request_params[:message] = api_request_params.delete(:prompt)
|
74
|
+
api_request_params[:stop_sequences] = api_request_params.delete(:stop) if api_request_params.key?(:stop)
|
75
|
+
|
76
|
+
Boxcars.debug("Prompt after formatting:#{api_request_params[:message]}", :cyan) if Boxcars.configuration.log_prompts
|
77
|
+
|
78
|
+
raw_response = _cohere_api_call(api_request_params, api_key)
|
79
|
+
_process_cohere_response(raw_response, response_data)
|
80
|
+
rescue StandardError => e
|
81
|
+
_handle_cohere_error(e, response_data)
|
82
|
+
ensure
|
83
|
+
call_context = {
|
84
|
+
start_time:,
|
85
|
+
prompt_object: current_prompt_object,
|
86
|
+
inputs:,
|
87
|
+
api_request_params:,
|
88
|
+
current_params:
|
89
|
+
}
|
90
|
+
_track_cohere_observability(call_context, response_data)
|
91
|
+
end
|
92
|
+
|
93
|
+
_cohere_handle_call_outcome(response_data:)
|
68
94
|
end
|
69
95
|
|
70
96
|
# get an answer from the engine for a question.
|
71
97
|
# @param question [String] The question to ask the engine.
|
72
98
|
# @param kwargs [Hash] Additional parameters to pass to the engine if wanted.
|
73
|
-
def run(question, **
|
99
|
+
def run(question, **)
|
74
100
|
prompt = Prompt.new(template: question)
|
75
|
-
response = client(prompt
|
101
|
+
response = client(prompt:, **)
|
76
102
|
|
77
103
|
raise Error, "Cohere: No response from API" unless response
|
78
104
|
raise Error, "Cohere: #{response[:error]}" if response[:error]
|
@@ -131,5 +157,105 @@ module Boxcars
|
|
131
157
|
def default_prefixes
|
132
158
|
{ system: "SYSTEM: ", user: "USER: ", assistant: "CHATBOT: ", history: :history }
|
133
159
|
end
|
160
|
+
|
161
|
+
private
|
162
|
+
|
163
|
+
# Make the actual API call to Cohere
|
164
|
+
def _cohere_api_call(params, api_key)
|
165
|
+
raise Boxcars::Error, 'Cohere API key not set' if api_key.blank?
|
166
|
+
|
167
|
+
# Define the API endpoint and parameters
|
168
|
+
api_endpoint = 'https://api.cohere.ai/v1/chat'
|
169
|
+
|
170
|
+
connection = Faraday.new(api_endpoint) do |faraday|
|
171
|
+
faraday.request :url_encoded
|
172
|
+
faraday.headers['Authorization'] = "Bearer #{api_key}"
|
173
|
+
faraday.headers['Content-Type'] = 'application/json'
|
174
|
+
end
|
175
|
+
|
176
|
+
# Make the API call
|
177
|
+
connection.post { |req| req.body = params.to_json }
|
178
|
+
end
|
179
|
+
|
180
|
+
# Process the raw response from Cohere API
|
181
|
+
def _process_cohere_response(raw_response, response_data)
|
182
|
+
response_data[:response_obj] = raw_response
|
183
|
+
response_data[:status_code] = raw_response.status
|
184
|
+
|
185
|
+
if raw_response.status == 200
|
186
|
+
parsed_json = JSON.parse(raw_response.body, symbolize_names: true)
|
187
|
+
response_data[:parsed_json] = parsed_json
|
188
|
+
|
189
|
+
if parsed_json[:error]
|
190
|
+
response_data[:success] = false
|
191
|
+
response_data[:error] = Boxcars::Error.new("Cohere API Error: #{parsed_json[:error]}")
|
192
|
+
else
|
193
|
+
response_data[:success] = true
|
194
|
+
end
|
195
|
+
else
|
196
|
+
response_data[:success] = false
|
197
|
+
response_data[:error] = Boxcars::Error.new("HTTP #{raw_response.status}: #{raw_response.reason_phrase}")
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
# Handle errors from Cohere API calls
|
202
|
+
def _handle_cohere_error(error, response_data)
|
203
|
+
response_data[:error] = error
|
204
|
+
response_data[:success] = false
|
205
|
+
response_data[:status_code] = error.respond_to?(:response) && error.response ? error.response[:status] : nil
|
206
|
+
end
|
207
|
+
|
208
|
+
# Track observability using the unified system
|
209
|
+
def _track_cohere_observability(call_context, response_data)
|
210
|
+
duration_ms = ((Time.now - call_context[:start_time]) * 1000).round
|
211
|
+
request_context = {
|
212
|
+
prompt: call_context[:prompt_object],
|
213
|
+
inputs: call_context[:inputs],
|
214
|
+
conversation_for_api: call_context[:api_request_params]
|
215
|
+
}
|
216
|
+
|
217
|
+
track_ai_generation(
|
218
|
+
duration_ms:,
|
219
|
+
current_params: call_context[:current_params],
|
220
|
+
request_context:,
|
221
|
+
response_data:,
|
222
|
+
provider: :cohere
|
223
|
+
)
|
224
|
+
end
|
225
|
+
|
226
|
+
# Handle the final outcome of the API call
|
227
|
+
def _cohere_handle_call_outcome(response_data:)
|
228
|
+
if response_data[:error]
|
229
|
+
_handle_cohere_error_outcome(response_data[:error])
|
230
|
+
elsif !response_data[:success]
|
231
|
+
_handle_cohere_response_body_error(response_data[:response_obj])
|
232
|
+
else
|
233
|
+
response_data[:parsed_json] # Return the raw parsed JSON
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
# Handle error outcomes
|
238
|
+
def _handle_cohere_error_outcome(error_data)
|
239
|
+
detailed_error_message = error_data.message
|
240
|
+
if error_data.respond_to?(:response) && error_data.response
|
241
|
+
detailed_error_message += " - Details: #{error_data.response[:body]}"
|
242
|
+
end
|
243
|
+
Boxcars.error("Cohere Error: #{detailed_error_message} (#{error_data.class.name})", :red)
|
244
|
+
raise error_data
|
245
|
+
end
|
246
|
+
|
247
|
+
# Handle response body errors
|
248
|
+
def _handle_cohere_response_body_error(response_obj)
|
249
|
+
msg = "Unknown error from Cohere API"
|
250
|
+
if response_obj.respond_to?(:body)
|
251
|
+
begin
|
252
|
+
parsed_body = JSON.parse(response_obj.body)
|
253
|
+
msg = parsed_body["message"] || parsed_body["error"] || msg
|
254
|
+
rescue JSON::ParserError
|
255
|
+
msg = "HTTP #{response_obj.status}: #{response_obj.reason_phrase}"
|
256
|
+
end
|
257
|
+
end
|
258
|
+
raise Error, msg
|
259
|
+
end
|
134
260
|
end
|
135
261
|
end
|
@@ -1,118 +1,193 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
#
|
3
|
+
require 'openai' # Gemini uses the OpenAI gem with a custom URI base
|
4
|
+
require 'json'
|
5
|
+
|
4
6
|
module Boxcars
|
5
|
-
# A engine that uses GeminiAI's API.
|
7
|
+
# A engine that uses GeminiAI's API via an OpenAI-compatible interface.
|
6
8
|
class GeminiAi < Engine
|
7
|
-
|
9
|
+
include UnifiedObservability
|
10
|
+
attr_reader :prompts, :llm_params, :model_kwargs, :batch_size # Corrected typo llm_parmas to llm_params
|
8
11
|
|
9
|
-
# The default parameters to use when asking the engine.
|
10
12
|
DEFAULT_PARAMS = {
|
11
|
-
model: "gemini-1.5-flash-latest",
|
13
|
+
model: "gemini-1.5-flash-latest", # Default model for Gemini
|
12
14
|
temperature: 0.1
|
15
|
+
# max_tokens is often part of the request, not a fixed default here
|
13
16
|
}.freeze
|
14
|
-
|
15
|
-
# the default name of the engine
|
16
17
|
DEFAULT_NAME = "GeminiAI engine"
|
17
|
-
|
18
|
-
DEFAULT_DESCRIPTION = "useful for when you need to use AI to answer questions. " \
|
18
|
+
DEFAULT_DESCRIPTION = "useful for when you need to use Gemini AI to answer questions. " \
|
19
19
|
"You should ask targeted questions"
|
20
20
|
|
21
|
-
# A engine is a container for a single tool to run.
|
22
|
-
# @param name [String] The name of the engine. Defaults to "GeminiAI engine".
|
23
|
-
# @param description [String] A description of the engine. Defaults to:
|
24
|
-
# useful for when you need to use AI to answer questions. You should ask targeted questions".
|
25
|
-
# @param prompts [Array<String>] The prompts to use when asking the engine. Defaults to [].
|
26
|
-
# @param batch_size [Integer] The number of prompts to send to the engine at once. Defaults to 20.
|
27
21
|
def initialize(name: DEFAULT_NAME, description: DEFAULT_DESCRIPTION, prompts: [], batch_size: 20, **kwargs)
|
28
|
-
@
|
22
|
+
@llm_params = DEFAULT_PARAMS.merge(kwargs) # Corrected typo here
|
29
23
|
@prompts = prompts
|
30
24
|
@batch_size = batch_size
|
31
|
-
super(description
|
25
|
+
super(description:, name:)
|
32
26
|
end
|
33
27
|
|
34
|
-
#
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
access_token
|
40
|
-
|
28
|
+
# Renamed from open_ai_client to gemini_client for clarity
|
29
|
+
def self.gemini_client(gemini_api_key: nil)
|
30
|
+
access_token = Boxcars.configuration.gemini_api_key(gemini_api_key:)
|
31
|
+
# NOTE: The OpenAI gem might not support `log_errors: true` for custom uri_base.
|
32
|
+
# It's a param for OpenAI::Client specific to their setup.
|
33
|
+
::OpenAI::Client.new(access_token:, uri_base: "https://generativelanguage.googleapis.com/v1beta/")
|
34
|
+
# Removed /openai from uri_base as it's usually for OpenAI-specific paths on custom domains.
|
35
|
+
# The Gemini endpoint might be directly at /v1beta/models/gemini...:generateContent
|
36
|
+
# This might need adjustment based on how the OpenAI gem forms the full URL.
|
37
|
+
# For direct generateContent, a different client or HTTP call might be needed if OpenAI gem is too restrictive.
|
38
|
+
# Assuming for now it's an OpenAI-compatible chat endpoint.
|
41
39
|
end
|
42
40
|
|
43
|
-
|
41
|
+
# Gemini models are typically conversational.
|
42
|
+
def conversation_model?(_model_name)
|
44
43
|
true
|
45
44
|
end
|
46
45
|
|
47
|
-
# Get an answer from the engine.
|
48
|
-
# @param prompt [String] The prompt to use when asking the engine.
|
49
|
-
# @param gemini_api_key [String] The access token to use when asking the engine.
|
50
|
-
# Defaults to Boxcars.configuration.gemini_api_key.
|
51
|
-
# @param kwargs [Hash] Additional parameters to pass to the engine if wanted.
|
52
46
|
def client(prompt:, inputs: {}, gemini_api_key: nil, **kwargs)
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
47
|
+
start_time = Time.now
|
48
|
+
response_data = { response_obj: nil, parsed_json: nil, success: false, error: nil, status_code: nil }
|
49
|
+
current_params = @llm_params.merge(kwargs) # Use instance var @llm_params
|
50
|
+
api_request_params = nil
|
51
|
+
current_prompt_object = prompt.is_a?(Array) ? prompt.first : prompt
|
52
|
+
|
53
|
+
begin
|
54
|
+
clnt = GeminiAi.gemini_client(gemini_api_key:)
|
55
|
+
api_request_params = _prepare_gemini_request_params(current_prompt_object, inputs, current_params)
|
56
|
+
|
57
|
+
log_messages_debug(api_request_params[:messages]) if Boxcars.configuration.log_prompts && api_request_params[:messages]
|
58
|
+
_execute_and_process_gemini_call(clnt, api_request_params, response_data)
|
59
|
+
rescue ::OpenAI::Error => e # Catch OpenAI gem errors if they apply
|
60
|
+
response_data[:error] = e
|
61
|
+
response_data[:success] = false
|
62
|
+
response_data[:status_code] = e.http_status if e.respond_to?(:http_status)
|
63
|
+
rescue StandardError => e # Catch other errors
|
64
|
+
response_data[:error] = e
|
65
|
+
response_data[:success] = false
|
66
|
+
ensure
|
67
|
+
duration_ms = ((Time.now - start_time) * 1000).round
|
68
|
+
request_context = {
|
69
|
+
prompt: current_prompt_object,
|
70
|
+
inputs:,
|
71
|
+
conversation_for_api: api_request_params&.dig(:messages) || []
|
72
|
+
}
|
73
|
+
track_ai_generation(
|
74
|
+
duration_ms:,
|
75
|
+
current_params:,
|
76
|
+
request_context:,
|
77
|
+
response_data:,
|
78
|
+
provider: :gemini
|
79
|
+
)
|
59
80
|
end
|
60
|
-
|
61
|
-
|
62
|
-
Boxcars.error(e, :red)
|
63
|
-
raise
|
81
|
+
|
82
|
+
_gemini_handle_call_outcome(response_data:)
|
64
83
|
end
|
65
84
|
|
66
|
-
|
67
|
-
# @param question [String] The question to ask the engine.
|
68
|
-
# @param kwargs [Hash] Additional parameters to pass to the engine if wanted.
|
69
|
-
def run(question, **kwargs)
|
85
|
+
def run(question, **)
|
70
86
|
prompt = Prompt.new(template: question)
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
check_response(response)
|
75
|
-
response["choices"].map { |c| c.dig("message", "content") || c["text"] }.join("\n").strip
|
87
|
+
answer = client(prompt:, inputs: {}, **)
|
88
|
+
Boxcars.debug("Answer: #{answer}", :cyan)
|
89
|
+
answer
|
76
90
|
end
|
77
91
|
|
78
|
-
# Get the default parameters for the engine.
|
79
92
|
def default_params
|
80
|
-
llm_params
|
93
|
+
@llm_params # Use instance variable
|
81
94
|
end
|
82
95
|
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
96
|
+
private
|
97
|
+
|
98
|
+
def _execute_and_process_gemini_call(gemini_client_obj, prepared_api_params, current_response_data)
|
99
|
+
# The OpenAI gem's `chat` method might not work directly if Gemini's endpoint
|
100
|
+
# isn't perfectly OpenAI-compatible for chat completions.
|
101
|
+
# It might require calling a different method or using a more direct HTTP client.
|
102
|
+
# For this refactor, we'll assume `gemini_client_obj.chat` is the intended path.
|
103
|
+
raw_response = gemini_client_obj.chat(parameters: prepared_api_params)
|
104
|
+
|
105
|
+
current_response_data[:response_obj] = raw_response
|
106
|
+
current_response_data[:parsed_json] = raw_response # OpenAI gem returns Hash
|
107
|
+
|
108
|
+
if raw_response && !raw_response["error"] &&
|
109
|
+
(raw_response["choices"] || raw_response["candidates"]) # Combined check for OpenAI or Gemini success
|
110
|
+
current_response_data[:success] = true
|
111
|
+
current_response_data[:status_code] = 200 # Inferred
|
112
|
+
else
|
113
|
+
current_response_data[:success] = false
|
114
|
+
err_details = raw_response["error"] if raw_response
|
115
|
+
msg = if err_details
|
116
|
+
(err_details.is_a?(Hash) ? err_details['message'] : err_details).to_s
|
117
|
+
else
|
118
|
+
"Unknown Gemini API Error"
|
119
|
+
end
|
120
|
+
current_response_data[:error] = StandardError.new(msg)
|
95
121
|
end
|
122
|
+
end
|
123
|
+
|
124
|
+
def _prepare_gemini_request_params(current_prompt, current_inputs, current_engine_params)
|
125
|
+
# Gemini typically uses a chat-like interface.
|
126
|
+
# Prepare messages for the API
|
127
|
+
# current_prompt.as_messages(current_inputs) returns a hash like { messages: [...] }
|
128
|
+
# We need to extract the array part for the OpenAI client's :messages parameter.
|
129
|
+
message_hash = current_prompt.as_messages(current_inputs)
|
130
|
+
# Ensure roles are 'user' and 'model' for Gemini if needed, or transform them.
|
131
|
+
# OpenAI gem expects 'system', 'user', 'assistant'. Adapter logic might be needed.
|
132
|
+
# For now, assume as_messages produces compatible roles or Gemini endpoint handles them.
|
133
|
+
|
134
|
+
# Gemini might not use 'model' in the same way in request body if using generateContent directly.
|
135
|
+
# If using OpenAI gem's chat method, it expects 'model' for routing.
|
136
|
+
# Let's assume api_request_params are for OpenAI gem's chat method.
|
137
|
+
{ messages: message_hash[:messages] }.merge(current_engine_params)
|
138
|
+
end
|
139
|
+
|
140
|
+
def log_messages_debug(messages)
|
141
|
+
return unless messages.is_a?(Array)
|
142
|
+
|
143
|
+
Boxcars.debug(messages.last(2).map { |p| ">>>>>> Role: #{p[:role]} <<<<<<\n#{p[:content]}" }.join("\n"), :cyan)
|
144
|
+
end
|
96
145
|
|
97
|
-
|
98
|
-
|
146
|
+
def _gemini_handle_call_outcome(response_data:)
|
147
|
+
if response_data[:error]
|
148
|
+
Boxcars.error("GeminiAI Error: #{response_data[:error].message} (#{response_data[:error].class.name})", :red)
|
149
|
+
raise response_data[:error]
|
150
|
+
elsif !response_data[:success]
|
151
|
+
err_details = response_data.dig(:response_obj, "error")
|
152
|
+
msg = if err_details
|
153
|
+
err_details.is_a?(Hash) ? "#{err_details['type']}: #{err_details['message']}" : err_details.to_s
|
154
|
+
else
|
155
|
+
"Unknown error from GeminiAI API"
|
156
|
+
end
|
157
|
+
raise Error, msg
|
158
|
+
else
|
159
|
+
_extract_content_from_gemini_response(response_data[:parsed_json])
|
99
160
|
end
|
100
161
|
end
|
101
162
|
|
102
|
-
|
103
|
-
|
104
|
-
|
163
|
+
def _extract_content_from_gemini_response(parsed_json)
|
164
|
+
# Handle Gemini's specific response structure (candidates)
|
165
|
+
# or OpenAI-compatible structure if the endpoint behaves that way.
|
166
|
+
if parsed_json&.key?("candidates") # Native Gemini generateContent response
|
167
|
+
parsed_json["candidates"].map { |c| c.dig("content", "parts", 0, "text") }.join("\n").strip
|
168
|
+
elsif parsed_json&.key?("choices") # OpenAI-compatible response
|
169
|
+
parsed_json["choices"].map { |c| c.dig("message", "content") || c["text"] }.join("\n").strip
|
170
|
+
else
|
171
|
+
raise Error, "GeminiAI: Could not extract answer from response"
|
172
|
+
end
|
105
173
|
end
|
106
174
|
|
107
|
-
#
|
108
|
-
#
|
109
|
-
|
110
|
-
|
111
|
-
|
175
|
+
# check_response method might be partially covered by _gemini_handle_call_outcome
|
176
|
+
# Retaining it if run method still uses it explicitly.
|
177
|
+
def check_response(response, must_haves: %w[choices candidates])
|
178
|
+
if response['error'].is_a?(Hash)
|
179
|
+
code = response.dig('error', 'code')
|
180
|
+
msg = response.dig('error', 'message') || 'unknown error'
|
181
|
+
# GEMINI_API_TOKEN is not standard, usually it's an API key.
|
182
|
+
# This check might need to align with actual error codes from Gemini.
|
183
|
+
raise KeyError, "Gemini API Key not valid or permission issue" if ['invalid_api_key', 'permission_denied'].include?(code)
|
184
|
+
|
185
|
+
raise ValueError, "GeminiAI error: #{msg}"
|
186
|
+
end
|
112
187
|
|
113
|
-
#
|
114
|
-
|
115
|
-
|
188
|
+
# Check for either 'choices' (OpenAI style) or 'candidates' (Gemini native style)
|
189
|
+
has_valid_content = must_haves.any? { |key| response.key?(key) && !response[key].empty? }
|
190
|
+
raise ValueError, "Expecting key like 'choices' or 'candidates' in response" unless has_valid_content
|
116
191
|
end
|
117
192
|
end
|
118
193
|
end
|
@@ -21,8 +21,8 @@ module Boxcars
|
|
21
21
|
# @param prompts [Array<Prompt>] The prompts to use for the Engine.
|
22
22
|
# @param batch_size [Integer] The number of prompts to send to the Engine at a time.
|
23
23
|
# @param kwargs [Hash] Additional parameters to pass to the Engine.
|
24
|
-
def initialize(name: DEFAULT_NAME, description: DEFAULT_DESCRIPTION, prompts: [], batch_size: 20, **
|
25
|
-
super(provider: :google, description
|
24
|
+
def initialize(name: DEFAULT_NAME, description: DEFAULT_DESCRIPTION, prompts: [], batch_size: 20, **)
|
25
|
+
super(provider: :google, description:, name:, prompts:, batch_size:, **)
|
26
26
|
end
|
27
27
|
|
28
28
|
def default_model_params
|
@@ -1,56 +1,114 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'gpt4all'
|
4
|
-
|
4
|
+
require 'json' # For pretty_generate
|
5
|
+
|
5
6
|
module Boxcars
|
6
7
|
# A engine that uses local GPT4All API.
|
7
8
|
class Gpt4allEng < Engine
|
8
|
-
|
9
|
+
include UnifiedObservability
|
10
|
+
attr_reader :prompts, :model_kwargs, :batch_size, :gpt4all_params # Added gpt4all_params
|
9
11
|
|
10
|
-
# the default name of the engine
|
11
12
|
DEFAULT_NAME = "Gpt4all engine"
|
12
|
-
# the default description of the engine
|
13
13
|
DEFAULT_DESCRIPTION = "useful for when you need to use local AI to answer questions. " \
|
14
14
|
"You should ask targeted questions"
|
15
|
+
# GPT4All doesn't have typical API params like temperature or model selection via params in the same way.
|
16
|
+
# Model is usually pre-loaded. We can add a placeholder for model_name if needed for tracking.
|
17
|
+
DEFAULT_PARAMS = {
|
18
|
+
model_name: "gpt4all-j-v1.3-groovy" # Example, actual model depends on local setup
|
19
|
+
}.freeze
|
15
20
|
|
16
|
-
|
17
|
-
|
18
|
-
# @param description [String] A description of the engine. Defaults to:
|
19
|
-
# useful for when you need to use AI to answer questions. You should ask targeted questions".
|
20
|
-
# @param prompts [Array<String>] The prompts to use when asking the engine. Defaults to [].
|
21
|
-
# @param batch_size [Integer] The number of prompts to send to the engine at once. Defaults to 2.
|
22
|
-
def initialize(name: DEFAULT_NAME, description: DEFAULT_DESCRIPTION, prompts: [], batch_size: 2, **_kwargs)
|
21
|
+
def initialize(name: DEFAULT_NAME, description: DEFAULT_DESCRIPTION, prompts: [], batch_size: 2, **kwargs)
|
22
|
+
@gpt4all_params = DEFAULT_PARAMS.merge(kwargs) # Store merged params
|
23
23
|
@prompts = prompts
|
24
|
-
@batch_size = batch_size
|
25
|
-
super(description
|
24
|
+
@batch_size = batch_size # Retain if used by other methods
|
25
|
+
super(description:, name:)
|
26
26
|
end
|
27
27
|
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
28
|
+
def client(prompt:, inputs: {}, **kwargs)
|
29
|
+
start_time = Time.now
|
30
|
+
response_data = { response_obj: nil, parsed_json: nil, success: false, error: nil, status_code: nil }
|
31
|
+
# current_params are the effective parameters for this call, including defaults and overrides
|
32
|
+
current_params = @gpt4all_params.merge(kwargs)
|
33
|
+
# api_request_params for GPT4All is just the input text.
|
34
|
+
api_request_params = nil
|
35
|
+
current_prompt_object = prompt.is_a?(Array) ? prompt.first : prompt
|
36
|
+
gpt4all_instance = nil # To ensure it's in scope for ensure block
|
37
|
+
|
38
|
+
begin
|
39
|
+
gpt4all_instance = Gpt4all::ConversationalAI.new
|
40
|
+
# prepare_resources might download models, could take time.
|
41
|
+
# Consider if this setup should be outside the timed/tracked client call for long-running setup.
|
42
|
+
# For now, including it as it's part of the interaction.
|
43
|
+
gpt4all_instance.prepare_resources(force_download: false)
|
44
|
+
gpt4all_instance.start_bot
|
45
|
+
|
46
|
+
# GPT4All gem's prompt method takes a string.
|
47
|
+
prompt_text_for_api = current_prompt_object.as_prompt(inputs:)
|
48
|
+
prompt_text_for_api = prompt_text_for_api[:prompt] if prompt_text_for_api.is_a?(Hash) && prompt_text_for_api.key?(:prompt)
|
49
|
+
api_request_params = { prompt: prompt_text_for_api } # Store what's sent
|
50
|
+
|
51
|
+
Boxcars.debug("Prompt after formatting:\n#{prompt_text_for_api}", :cyan) if Boxcars.configuration.log_prompts
|
52
|
+
|
53
|
+
raw_response_text = gpt4all_instance.prompt(prompt_text_for_api) # Actual call
|
54
|
+
|
55
|
+
# GPT4All gem returns a string directly, or raises error.
|
56
|
+
response_data[:response_obj] = raw_response_text # Store the raw string
|
57
|
+
response_data[:parsed_json] = { "text" => raw_response_text } # Create a simple hash for consistency
|
58
|
+
response_data[:success] = true
|
59
|
+
response_data[:status_code] = 200 # Inferred for local success
|
60
|
+
rescue StandardError => e
|
61
|
+
response_data[:error] = e
|
62
|
+
response_data[:success] = false
|
63
|
+
# No HTTP status code for local errors typically, unless the gem provides one.
|
64
|
+
ensure
|
65
|
+
gpt4all_instance&.stop_bot # Ensure bot is stopped even if errors occur
|
66
|
+
|
67
|
+
duration_ms = ((Time.now - start_time) * 1000).round
|
68
|
+
request_context = {
|
69
|
+
prompt: current_prompt_object,
|
70
|
+
inputs:,
|
71
|
+
conversation_for_api: api_request_params&.dig(:prompt) # The text prompt
|
72
|
+
}
|
73
|
+
|
74
|
+
track_ai_generation(
|
75
|
+
duration_ms:,
|
76
|
+
current_params:,
|
77
|
+
request_context:,
|
78
|
+
response_data:,
|
79
|
+
provider: :gpt4all
|
80
|
+
)
|
81
|
+
end
|
82
|
+
|
83
|
+
_gpt4all_handle_call_outcome(response_data:)
|
44
84
|
end
|
45
85
|
|
46
|
-
|
47
|
-
# @param question [String] The question to ask the engine.
|
48
|
-
# @param kwargs [Hash] Additional parameters to pass to the engine if wanted.
|
49
|
-
def run(question, **kwargs)
|
86
|
+
def run(question, **)
|
50
87
|
prompt = Prompt.new(template: question)
|
51
|
-
answer = client(prompt:
|
88
|
+
answer = client(prompt:, inputs: {}, **)
|
52
89
|
Boxcars.debug("Answer: #{answer}", :cyan)
|
53
90
|
answer
|
54
91
|
end
|
92
|
+
|
93
|
+
# Added for consistency
|
94
|
+
def default_params
|
95
|
+
@gpt4all_params
|
96
|
+
end
|
97
|
+
|
98
|
+
private
|
99
|
+
|
100
|
+
def _gpt4all_handle_call_outcome(response_data:)
|
101
|
+
if response_data[:error]
|
102
|
+
# The original code had a specific error logging format.
|
103
|
+
Boxcars.error(["Error from gpt4all engine: #{response_data[:error].message}",
|
104
|
+
response_data[:error].backtrace&.first(5)&.join("\n ")].compact.join("\n "), :red)
|
105
|
+
raise response_data[:error]
|
106
|
+
elsif !response_data[:success]
|
107
|
+
# This case might be redundant if gpt4all gem always raises on error
|
108
|
+
raise Error, "Unknown error from Gpt4all"
|
109
|
+
else
|
110
|
+
response_data.dig(:parsed_json, "text") # Extract the text from our structured hash
|
111
|
+
end
|
112
|
+
end
|
55
113
|
end
|
56
114
|
end
|