boxcars 0.7.6 → 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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +6 -3
  3. data/.ruby-version +1 -1
  4. data/CHANGELOG.md +41 -0
  5. data/Gemfile +3 -13
  6. data/Gemfile.lock +29 -25
  7. data/POSTHOG_TEST_README.md +118 -0
  8. data/README.md +305 -0
  9. data/boxcars.gemspec +1 -2
  10. data/lib/boxcars/boxcar/active_record.rb +9 -10
  11. data/lib/boxcars/boxcar/calculator.rb +2 -2
  12. data/lib/boxcars/boxcar/engine_boxcar.rb +4 -4
  13. data/lib/boxcars/boxcar/google_search.rb +2 -2
  14. data/lib/boxcars/boxcar/json_engine_boxcar.rb +1 -1
  15. data/lib/boxcars/boxcar/ruby_calculator.rb +1 -1
  16. data/lib/boxcars/boxcar/sql_base.rb +4 -4
  17. data/lib/boxcars/boxcar/swagger.rb +3 -3
  18. data/lib/boxcars/boxcar/vector_answer.rb +3 -3
  19. data/lib/boxcars/boxcar/xml_engine_boxcar.rb +1 -1
  20. data/lib/boxcars/boxcar.rb +6 -6
  21. data/lib/boxcars/conversation_prompt.rb +3 -3
  22. data/lib/boxcars/engine/anthropic.rb +121 -23
  23. data/lib/boxcars/engine/cerebras.rb +2 -2
  24. data/lib/boxcars/engine/cohere.rb +135 -9
  25. data/lib/boxcars/engine/gemini_ai.rb +151 -76
  26. data/lib/boxcars/engine/google.rb +2 -2
  27. data/lib/boxcars/engine/gpt4all_eng.rb +92 -34
  28. data/lib/boxcars/engine/groq.rb +124 -73
  29. data/lib/boxcars/engine/intelligence_base.rb +52 -17
  30. data/lib/boxcars/engine/ollama.rb +127 -47
  31. data/lib/boxcars/engine/openai.rb +186 -103
  32. data/lib/boxcars/engine/perplexityai.rb +116 -136
  33. data/lib/boxcars/engine/together.rb +2 -2
  34. data/lib/boxcars/engine/unified_observability.rb +430 -0
  35. data/lib/boxcars/engine.rb +4 -3
  36. data/lib/boxcars/engines.rb +74 -0
  37. data/lib/boxcars/observability.rb +44 -0
  38. data/lib/boxcars/observability_backend.rb +17 -0
  39. data/lib/boxcars/observability_backends/multi_backend.rb +42 -0
  40. data/lib/boxcars/observability_backends/posthog_backend.rb +89 -0
  41. data/lib/boxcars/observation.rb +8 -8
  42. data/lib/boxcars/prompt.rb +16 -4
  43. data/lib/boxcars/result.rb +7 -12
  44. data/lib/boxcars/ruby_repl.rb +1 -1
  45. data/lib/boxcars/train/train_action.rb +1 -1
  46. data/lib/boxcars/train/xml_train.rb +3 -3
  47. data/lib/boxcars/train/xml_zero_shot.rb +1 -1
  48. data/lib/boxcars/train/zero_shot.rb +3 -3
  49. data/lib/boxcars/train.rb +1 -1
  50. data/lib/boxcars/vector_search.rb +5 -5
  51. data/lib/boxcars/vector_store/pgvector/build_from_array.rb +116 -88
  52. data/lib/boxcars/vector_store/pgvector/build_from_files.rb +106 -80
  53. data/lib/boxcars/vector_store/pgvector/save_to_database.rb +148 -122
  54. data/lib/boxcars/vector_store/pgvector/search.rb +157 -131
  55. data/lib/boxcars/vector_store.rb +4 -4
  56. data/lib/boxcars/version.rb +1 -1
  57. data/lib/boxcars.rb +31 -20
  58. metadata +11 -21
@@ -1,173 +1,153 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Boxcars is a framework for running a series of tools to get an answer to a question.
3
+ require 'faraday'
4
+ require 'faraday/retry'
5
+ require 'json'
6
+
4
7
  module Boxcars
5
- # A engine that uses OpenAI's API.
8
+ # A engine that uses PerplexityAI's API.
6
9
  class Perplexityai < Engine
10
+ include UnifiedObservability
7
11
  attr_reader :prompts, :perplexity_params, :model_kwargs, :batch_size
8
12
 
9
- # The default parameters to use when asking the engine.
10
- DEFAULT_PER_PARAMS = {
11
- model: "'llama-3-sonar-large-32k-online'",
13
+ DEFAULT_PARAMS = { # Renamed from DEFAULT_PER_PARAMS for consistency
14
+ model: "llama-3-sonar-large-32k-online", # Removed extra quotes
12
15
  temperature: 0.1
16
+ # max_tokens can be part of kwargs if needed
13
17
  }.freeze
18
+ DEFAULT_NAME = "PerplexityAI engine" # Renamed from DEFAULT_PER_NAME
19
+ DEFAULT_DESCRIPTION = "useful for when you need to use Perplexity AI to answer questions. " \
20
+ "You should ask targeted questions"
14
21
 
15
- # the default name of the engine
16
- DEFAULT_PER_NAME = "PerplexityAI engine"
17
- # the default description of the engine
18
- DEFAULT_PER_DESCRIPTION = "useful for when you need to use AI to answer questions. " \
19
- "You should ask targeted questions"
20
-
21
- # A engine is a container for a single tool to run.
22
- # @param name [String] The name of the engine. Defaults to "PerplexityAI 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
- def initialize(name: DEFAULT_PER_NAME, description: DEFAULT_PER_DESCRIPTION, prompts: [], batch_size: 20, **kwargs)
28
- @perplexity_params = DEFAULT_PER_PARAMS.merge(kwargs)
22
+ def initialize(name: DEFAULT_NAME, description: DEFAULT_DESCRIPTION, prompts: [], batch_size: 20, **kwargs)
23
+ @perplexity_params = DEFAULT_PARAMS.merge(kwargs)
29
24
  @prompts = prompts
30
- @batch_size = batch_size
31
- super(description: description, name: name)
25
+ @batch_size = batch_size # Retain if used by generate
26
+ super(description:, name:)
32
27
  end
33
28
 
34
- def conversation_model?(_model)
29
+ # Perplexity models are conversational.
30
+ def conversation_model?(_model_name)
35
31
  true
36
32
  end
37
33
 
38
- def chat(parameters:)
39
- conn = Faraday.new(url: "https://api.perplexity.ai/chat/completions") do |faraday|
40
- faraday.request :json
41
- faraday.response :json
42
- faraday.response :raise_error
43
- # faraday.options.timeout = 180 # 3 minutes
44
- end
45
-
46
- response = conn.post do |req|
47
- req.headers['Authorization'] = "Bearer #{ENV.fetch('PERPLEXITY_API_KEY')}"
48
- req.body = {
49
- model: parameters[:model],
50
- messages: parameters[:messages]
34
+ # Main client method for interacting with the Perplexity API
35
+ # rubocop:disable Metrics/MethodLength
36
+ def client(prompt:, inputs: {}, perplexity_api_key: nil, **kwargs)
37
+ start_time = Time.now
38
+ response_data = { response_obj: nil, parsed_json: nil, success: false, error: nil, status_code: nil }
39
+ current_params = @perplexity_params.merge(kwargs)
40
+ api_request_params = nil # Parameters actually sent to Perplexity API
41
+ current_prompt_object = prompt.is_a?(Array) ? prompt.first : prompt
42
+
43
+ begin
44
+ api_key = perplexity_api_key || Boxcars.configuration.perplexity_api_key(**current_params.slice(:perplexity_api_key))
45
+ raise Boxcars::ConfigurationError, "Perplexity API key not set" if api_key.nil? || api_key.strip.empty?
46
+
47
+ conn = Faraday.new(url: "https://api.perplexity.ai") do |faraday|
48
+ faraday.request :json
49
+ faraday.response :json # Parse JSON response
50
+ faraday.response :raise_error # Raise exceptions on 4xx/5xx
51
+ faraday.adapter Faraday.default_adapter
52
+ end
53
+
54
+ messages_for_api = current_prompt_object.as_messages(inputs)[:messages]
55
+ # Perplexity expects a 'model' and 'messages' structure.
56
+ # Other params like temperature, max_tokens are top-level.
57
+ api_request_params = {
58
+ model: current_params[:model],
59
+ messages: messages_for_api
60
+ }.merge(current_params.except(:model, :messages, :perplexity_api_key)) # Add other relevant params
61
+
62
+ log_messages_debug(api_request_params[:messages]) if Boxcars.configuration.log_prompts && api_request_params[:messages]
63
+
64
+ response = conn.post('/chat/completions') do |req|
65
+ req.headers['Authorization'] = "Bearer #{api_key}"
66
+ req.body = api_request_params
67
+ end
68
+
69
+ response_data[:response_obj] = response # Faraday response object
70
+ response_data[:parsed_json] = response.body # Faraday with :json middleware parses body
71
+ response_data[:status_code] = response.status
72
+
73
+ if response.success? && response.body && response.body["choices"]
74
+ response_data[:success] = true
75
+ else
76
+ response_data[:success] = false
77
+ err_details = response.body["error"] if response.body.is_a?(Hash)
78
+ msg = if err_details
79
+ "#{err_details['type']}: #{err_details['message']}"
80
+ else
81
+ "Unknown Perplexity API Error (status: #{response.status})"
82
+ end
83
+ response_data[:error] = StandardError.new(msg)
84
+ end
85
+ rescue Faraday::Error => e # Catch Faraday specific errors (includes connection, timeout, 4xx/5xx)
86
+ response_data[:error] = e
87
+ response_data[:success] = false
88
+ response_data[:status_code] = e.response_status if e.respond_to?(:response_status)
89
+ response_data[:response_obj] = e.response if e.respond_to?(:response) # Store Faraday response if available
90
+ response_data[:parsed_json] = e.response[:body] if e.respond_to?(:response) && e.response[:body].is_a?(Hash)
91
+ rescue StandardError => e # Catch other unexpected errors
92
+ response_data[:error] = e
93
+ response_data[:success] = false
94
+ ensure
95
+ duration_ms = ((Time.now - start_time) * 1000).round
96
+ request_context = {
97
+ prompt: current_prompt_object,
98
+ inputs:,
99
+ conversation_for_api: api_request_params&.dig(:messages)
51
100
  }
101
+ track_ai_generation(
102
+ duration_ms:,
103
+ current_params:,
104
+ request_context:,
105
+ response_data:,
106
+ provider: :perplexity_ai
107
+ )
52
108
  end
53
109
 
54
- response.body
55
- end
56
-
57
- # Get an answer from the engine.
58
- # @param prompt [String] The prompt to use when asking the engine.
59
- # @param openai_access_token [String] The access token to use when asking the engine.
60
- # Defaults to Boxcars.configuration.openai_access_token.
61
- # @param kwargs [Hash] Additional parameters to pass to the engine if wanted.
62
- def client(prompt:, inputs: {}, **kwargs)
63
- prompt = prompt.first if prompt.is_a?(Array)
64
- params = prompt.as_messages(inputs).merge(default_params).merge(kwargs)
65
- if Boxcars.configuration.log_prompts
66
- Boxcars.debug(params[:messages].last(2).map { |p| ">>>>>> Role: #{p[:role]} <<<<<<\n#{p[:content]}" }.join("\n"), :cyan)
67
- end
68
- chat(parameters: params)
110
+ _perplexity_handle_call_outcome(response_data:)
69
111
  end
112
+ # rubocop:enable Metrics/MethodLength
70
113
 
71
- # get an answer from the engine for a question.
72
- # @param question [String] The question to ask the engine.
73
- # @param kwargs [Hash] Additional parameters to pass to the engine if wanted.
74
- def run(question, **kwargs)
114
+ def run(question, **)
75
115
  prompt = Prompt.new(template: question)
76
- response = client(prompt: prompt, **kwargs)
77
- raise Error, "PerplexityAI: No response from API" unless response
78
- raise Error, "PerplexityAI: #{response['error']}" if response["error"]
79
-
80
- answer = response["choices"].map { |c| c.dig("message", "content") || c["text"] }.join("\n").strip
116
+ answer = client(prompt:, inputs: {}, **)
81
117
  Boxcars.debug("Answer: #{answer}", :cyan)
82
118
  answer
83
119
  end
84
120
 
85
- # Get the default parameters for the engine.
86
121
  def default_params
87
- perplexity_params
88
- end
89
-
90
- # make sure we got a valid response
91
- # @param response [Hash] The response to check.
92
- # @param must_haves [Array<String>] The keys that must be in the response. Defaults to %w[choices].
93
- # @raise [KeyError] if there is an issue with the access token.
94
- # @raise [ValueError] if the response is not valid.
95
- def check_response(response, must_haves: %w[choices])
96
- if response['error']
97
- code = response.dig('error', 'code')
98
- msg = response.dig('error', 'message') || 'unknown error'
99
- raise KeyError, "PERPLEXITY_API_KEY not valid" if code == 'invalid_api_key'
100
-
101
- raise ValueError, "PerplexityAI error: #{msg}"
102
- end
103
-
104
- must_haves.each do |key|
105
- raise ValueError, "Expecting key #{key} in response" unless response.key?(key)
106
- end
122
+ @perplexity_params
107
123
  end
108
- end
109
-
110
- # the engine type
111
- def engine_type
112
- "perplexityai"
113
- end
114
124
 
115
- # calculate the number of tokens used
116
- def get_num_tokens(text:)
117
- text.split.length # TODO: hook up to token counting gem
118
- end
125
+ private
119
126
 
120
- # Calculate the maximum number of tokens possible to generate for a prompt.
121
- # @param prompt_text [String] The prompt text to use.
122
- # @return [Integer] the number of tokens possible to generate.
123
- def max_tokens_for_prompt(_prompt_text)
124
- 8096
125
- end
127
+ def log_messages_debug(messages)
128
+ return unless messages.is_a?(Array)
126
129
 
127
- # Get generation informaton
128
- # @param sub_choices [Array<Hash>] The choices to get generation info for.
129
- # @return [Array<Generation>] The generation information.
130
- def generation_info(sub_choices)
131
- sub_choices.map do |choice|
132
- Generation.new(
133
- text: choice.dig("message", "content") || choice["text"],
134
- generation_info: {
135
- finish_reason: choice.fetch("finish_reason", nil),
136
- logprobs: choice.fetch("logprobs", nil)
137
- }
138
- )
130
+ Boxcars.debug(messages.last(2).map { |p| ">>>>>> Role: #{p[:role]} <<<<<<\n#{p[:content]}" }.join("\n"), :cyan)
139
131
  end
140
- end
141
132
 
142
- # Call out to endpoint with k unique prompts.
143
- # @param prompts [Array<String>] The prompts to pass into the model.
144
- # @param inputs [Array<String>] The inputs to subsitite into the prompt.
145
- # @param stop [Array<String>] Optional list of stop words to use when generating.
146
- # @return [EngineResult] The full engine output.
147
- def generate(prompts:, stop: nil)
148
- params = {}
149
- params[:stop] = stop if stop
150
- choices = []
151
- token_usage = {}
152
- # Get the token usage from the response.
153
- # Includes prompt, completion, and total tokens used.
154
- inkeys = %w[completion_tokens prompt_tokens total_tokens].freeze
155
- prompts.each_slice(batch_size) do |sub_prompts|
156
- sub_prompts.each do |sprompts, inputs|
157
- response = client(prompt: sprompts, inputs: inputs, **params)
158
- check_response(response)
159
- choices.concat(response["choices"])
160
- usage_keys = inkeys & response["usage"].keys
161
- usage_keys.each { |key| token_usage[key] = token_usage[key].to_i + response["usage"][key] }
133
+ def _perplexity_handle_call_outcome(response_data:)
134
+ if response_data[:error]
135
+ Boxcars.error("PerplexityAI Error: #{response_data[:error].message} (#{response_data[:error].class.name})", :red)
136
+ raise response_data[:error]
137
+ elsif !response_data[:success]
138
+ err_details = response_data.dig(:parsed_json, "error")
139
+ msg = err_details ? "#{err_details['type']}: #{err_details['message']}" : "Unknown error from PerplexityAI API"
140
+ raise Error, msg
141
+ else
142
+ choices = response_data.dig(:parsed_json, "choices")
143
+ raise Error, "PerplexityAI: No choices found in response" unless choices.is_a?(Array) && !choices.empty?
144
+
145
+ choices.map { |c| c.dig("message", "content") }.join("\n").strip
162
146
  end
163
147
  end
164
148
 
165
- n = params.fetch(:n, 1)
166
- generations = []
167
- prompts.each_with_index do |_prompt, i|
168
- sub_choices = choices[i * n, (i + 1) * n]
169
- generations.push(generation_info(sub_choices))
170
- end
171
- EngineResult.new(generations: generations, engine_output: { token_usage: token_usage })
149
+ # Methods like `check_response`, `generate`, `generation_info` are removed or would need significant rework.
150
+ # `check_response` logic is now part of `_perplexity_handle_call_outcome`.
151
+ # `generate` would need to be re-implemented carefully if batching is desired with direct Faraday.
172
152
  end
173
153
  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, **kwargs)
25
- super(provider: :together_ai, description: description, name: name, prompts: prompts, batch_size: batch_size, **kwargs)
24
+ def initialize(name: DEFAULT_NAME, description: DEFAULT_DESCRIPTION, prompts: [], batch_size: 20, **)
25
+ super(provider: :together_ai, description:, name:, prompts:, batch_size:, **)
26
26
  end
27
27
 
28
28
  def default_model_params