prescient 0.1.0 → 0.3.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.
@@ -1,38 +1,137 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+ require 'net/http'
5
+
6
+ # Base class for all AI provider implementations
7
+ #
8
+ # This abstract base class defines the common interface that all AI providers
9
+ # must implement. It provides shared functionality for text processing, context
10
+ # formatting, prompt building, and error handling.
11
+ #
12
+ # @abstract Subclass and implement {#generate_embedding}, {#generate_response},
13
+ # {#health_check}, and any configuration validation required by
14
+ # {#validate_configuration!}
15
+ #
16
+ # @example Creating a custom provider
17
+ # class MyProvider < Prescient::Base
18
+ # def generate_embedding(text, **options)
19
+ # # Implementation here
20
+ # end
21
+ #
22
+ # def generate_response(prompt, context_items = [], **options)
23
+ # # Implementation here
24
+ # end
25
+ #
26
+ # def health_check
27
+ # # Implementation here
28
+ # end
29
+ # end
30
+ #
3
31
  class Prescient::Base
4
- attr_reader :options
5
-
32
+ # @return [Hash] Configuration options for this provider instance
33
+ attr_reader :options, :provider_name
34
+
35
+ # Initialize the provider with configuration options
36
+ #
37
+ # @param options [Hash] Provider-specific configuration options
38
+ # @option options [String] :api_key API key for authenticated providers
39
+ # @option options [String] :url Base URL for self-hosted providers
40
+ # @option options [Integer] :timeout Request timeout in seconds
41
+ # @option options [Hash] :prompt_templates Custom prompt templates
42
+ # @option options [Hash] :context_configs Context formatting configurations
43
+ # @option options [Integer] :embedding_dimensions Expected custom embedding size
44
+ # @option options [Array<Symbol, String>] :context_excluded_fields Additional
45
+ # field names excluded from generic embedding text
6
46
  def initialize(**options)
7
47
  @options = options
48
+ @provider_name = options.fetch(:provider_name, self.class.to_s.split('::').last).to_s.sub(/\A./, &:upcase)
8
49
  validate_configuration!
9
50
  end
10
51
 
11
- # Abstract methods that must be implemented by subclasses
12
- def generate_embedding(text)
52
+ # Generate embeddings for the given text
53
+ #
54
+ # This method must be implemented by subclasses to provide embedding
55
+ # generation functionality.
56
+ #
57
+ # @param text [String] The text to generate embeddings for
58
+ # @param options [Hash] Provider-specific options
59
+ # @return [Array<Float>] Array of embedding values
60
+ # @raise [NotImplementedError] If not implemented by subclass
61
+ # @abstract
62
+ def generate_embedding(text, **options)
13
63
  raise NotImplementedError, "#{self.class} must implement #generate_embedding"
14
64
  end
15
65
 
66
+ # Generate text response for the given prompt
67
+ #
68
+ # This method must be implemented by subclasses to provide text generation
69
+ # functionality with optional context items.
70
+ #
71
+ # @param prompt [String] The prompt to generate a response for
72
+ # @param context_items [Array<Hash, String>] Optional context items to include
73
+ # @param options [Hash] Provider-specific generation options
74
+ # @option options [Float] :temperature Sampling temperature (0.0-2.0)
75
+ # @option options [Integer] :max_tokens Maximum tokens to generate
76
+ # @option options [Float] :top_p Nucleus sampling parameter
77
+ # @return [Hash] Response hash with :response, :model, :provider keys
78
+ # @raise [NotImplementedError] If not implemented by subclass
79
+ # @abstract
16
80
  def generate_response(prompt, context_items = [], **options)
17
81
  raise NotImplementedError, "#{self.class} must implement #generate_response"
18
82
  end
19
83
 
84
+ # Check the health and availability of the provider
85
+ #
86
+ # This method must be implemented by subclasses to provide health check
87
+ # functionality.
88
+ #
89
+ # @return [Hash] Health status with at least :status and :provider keys,
90
+ # and typically :reachable and :ready for modern adapters
91
+ # @raise [NotImplementedError] If not implemented by subclass
92
+ # @abstract
20
93
  def health_check
21
94
  raise NotImplementedError, "#{self.class} must implement #health_check"
22
95
  end
23
96
 
97
+ # Check if the provider is currently available
98
+ #
99
+ # Returns `true` when the health check reports `reachable: true`.
100
+ # For legacy adapters that only return a status string, `status == "healthy"`
101
+ # is also treated as available.
102
+ #
103
+ # @return [Boolean] true if the provider is currently reachable
24
104
  def available?
25
- health_check[:status] == 'healthy'
105
+ health = health_check
106
+ health.key?(:reachable) ? health[:reachable] == true : health[:status] == 'healthy'
26
107
  rescue StandardError
27
108
  false
28
109
  end
29
110
 
30
111
  protected
31
112
 
113
+ # Validate provider configuration
114
+ #
115
+ # Override this method in subclasses to validate required configuration
116
+ # options and raise appropriate errors for missing or invalid settings.
117
+ #
118
+ # @return [void]
119
+ # @raise [Prescient::Error] If configuration is invalid
32
120
  def validate_configuration!
33
121
  # Override in subclasses to validate required configuration
34
122
  end
35
123
 
124
+ # Handle and standardize errors from provider operations
125
+ #
126
+ # Wraps provider-specific operations and converts common exceptions
127
+ # into standardized Prescient error types while preserving existing
128
+ # Prescient errors.
129
+ #
130
+ # @yield The operation block to execute with error handling
131
+ # @return [Object] The result of the yielded block
132
+ # @raise [Prescient::ConnectionError] For network/timeout errors
133
+ # @raise [Prescient::InvalidResponseError] For JSON parsing errors
134
+ # @raise [Prescient::Error] For other unexpected errors
36
135
  def handle_errors
37
136
  yield
38
137
  rescue Prescient::Error
@@ -48,31 +147,44 @@ class Prescient::Base
48
147
  raise Prescient::Error, "Unexpected error: #{e.message}"
49
148
  end
50
149
 
51
- def normalize_embedding(embedding, target_dimensions)
52
- return nil unless embedding.is_a?(Array)
150
+ # Validate embedding dimensions against the configured model dimension.
151
+ #
152
+ # Embedding dimensions are part of the vector-storage contract. Vectors are
153
+ # never padded or truncated because either operation changes their meaning.
154
+ #
155
+ # @param embedding [Array<Float>] The embedding vector to validate
156
+ # @param target_dimensions [Integer] The required number of dimensions
157
+ # @return [Array<Float>] The original embedding when dimensions are valid
158
+ # @raise [Prescient::InvalidResponseError] If the vector is malformed or has
159
+ # an unexpected dimension
160
+ def validate_embedding_dimensions(embedding, target_dimensions)
161
+ raise Prescient::InvalidResponseError, 'Embedding response is not an array' unless embedding.is_a?(Array)
162
+
53
163
  return embedding if embedding.length == target_dimensions
54
164
 
55
- if embedding.length > target_dimensions
56
- # Truncate
57
- embedding.first(target_dimensions)
58
- else
59
- # Pad with zeros
60
- embedding + Array.new(target_dimensions - embedding.length, 0.0)
61
- end
165
+ raise Prescient::InvalidResponseError,
166
+ "Invalid embedding dimensions: expected #{target_dimensions}, got #{embedding.length}"
62
167
  end
63
168
 
169
+ # Clean and preprocess text for AI processing
170
+ #
171
+ # Removes excess whitespace, normalizes spacing, and truncates to the
172
+ # library's current 8,000-character input ceiling.
173
+ #
174
+ # @param text [String, nil] The text to clean
175
+ # @return [String] Cleaned text, empty string if input was nil/empty
64
176
  def clean_text(text)
65
- return '' if text.nil? || text.to_s.strip.empty?
66
-
67
- cleaned = text.to_s
68
- .strip
69
- .gsub(/\s+/, ' ')
70
-
71
177
  # Limit length for most models
72
- cleaned.length > 8000 ? cleaned[0, 8000] : cleaned
178
+ text.to_s.gsub(/\s+/, ' ').strip.slice(0, 8000)
73
179
  end
74
180
 
75
- # Default prompt templates - can be overridden in provider options
181
+ # Get default prompt templates
182
+ #
183
+ # Provides standard templates for system prompts and context handling
184
+ # that can be overridden via provider options.
185
+ #
186
+ # @return [Hash] Hash containing template strings with placeholders
187
+ # @private
76
188
  def default_prompt_templates
77
189
  {
78
190
  system_prompt: 'You are a helpful AI assistant. Answer questions clearly and accurately.',
@@ -96,7 +208,14 @@ class Prescient::Base
96
208
  }
97
209
  end
98
210
 
99
- # Build prompt using configurable templates
211
+ # Build formatted prompt from query and context items
212
+ #
213
+ # Creates a properly formatted prompt using configurable templates,
214
+ # incorporating context items when provided.
215
+ #
216
+ # @param query [String] The user's question or prompt
217
+ # @param context_items [Array<Hash, String>] Optional context items
218
+ # @return [String] Formatted prompt ready for AI processing
100
219
  def build_prompt(query, context_items = [])
101
220
  templates = default_prompt_templates.merge(@options[:prompt_templates] || {})
102
221
  system_prompt = templates[:system_prompt]
@@ -121,12 +240,15 @@ class Prescient::Base
121
240
 
122
241
  # Minimal default context configuration - users should define their own contexts
123
242
  def default_context_configs
243
+ embedding_fields = [] # : Array[untyped]
244
+ fields = [] # : Array[untyped]
245
+
124
246
  {
125
247
  # Generic fallback configuration - works with any hash structure
126
248
  'default' => {
127
- fields: [], # Will be dynamically determined from item keys
249
+ fields: fields, # Will be dynamically determined from item keys
128
250
  format: nil, # Will use fallback formatting
129
- embedding_fields: [], # Will use all string/text fields
251
+ embedding_fields: embedding_fields, # Will use all string/text fields
130
252
  },
131
253
  }
132
254
  end
@@ -142,8 +264,13 @@ class Prescient::Base
142
264
 
143
265
  # Extract text values from hash, excluding non-textual fields
144
266
  def extract_text_values(item)
145
- # Common fields to exclude from embedding text
146
- exclude_fields = ['id', '_id', 'uuid', 'created_at', 'updated_at', 'timestamp', 'version', 'status', 'active']
267
+ # Common fields to exclude from embedding text. Provider-specific fields can
268
+ # be added with the :context_excluded_fields option.
269
+ default_excluded_fields = ['id', '_id', 'uuid', 'created_at', 'updated_at', 'timestamp', 'version', 'status',
270
+ 'active']
271
+ configured_fields = Array(@options[:context_excluded_fields]) # : Array[untyped]
272
+ configured_excluded_fields = configured_fields.map { |field| field.to_s.downcase }
273
+ exclude_fields = default_excluded_fields | configured_excluded_fields
147
274
 
148
275
  item.filter_map { |key, value|
149
276
  next if exclude_fields.include?(key.to_s.downcase)
@@ -194,7 +321,7 @@ class Prescient::Base
194
321
 
195
322
  # Build format data from item fields
196
323
  def build_format_data(item, config)
197
- format_data = {}
324
+ format_data = {} # : Hash[Symbol, untyped]
198
325
  fields_to_check = config[:fields].any? ? config[:fields] : item.keys.map(&:to_s)
199
326
 
200
327
  fields_to_check.each do |field|
@@ -267,4 +394,36 @@ class Prescient::Base
267
394
  # Fallback: join key-value pairs
268
395
  (format_data || item).map { |k, v| "#{k}: #{v}" }.join(', ')
269
396
  end
397
+
398
+ def validate_response!(response, operation)
399
+ return if response.success?
400
+
401
+ resp_message, error_class = case response.code
402
+ when 400
403
+ ['Bad Request', Prescient::Error]
404
+ when 401
405
+ ['Authentication Failure', Prescient::AuthenticationError]
406
+ when 403
407
+ ['Forbidden Access', Prescient::AuthenticationError]
408
+ when 404
409
+ ['Model Not Available', Prescient::ModelNotAvailableError]
410
+ when 429
411
+ ['Rate Limit Exceeded', Prescient::RateLimitError]
412
+ when 500..599
413
+ ["#{provider_name} Server Error", Prescient::ProviderError]
414
+ else
415
+ ["#{provider_name} Request Failure", Prescient::Error]
416
+ end
417
+
418
+ raise provider_error(resp_message, response, error_class:, operation:)
419
+ end
420
+
421
+ def provider_error(message, response, operation:, provider: nil, error_class: Prescient::ProviderError)
422
+ error_class.new(
423
+ message,
424
+ provider: provider || provider_name,
425
+ operation:,
426
+ status: response.code,
427
+ )
428
+ end
270
429
  end
@@ -1,45 +1,106 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Prescient
4
+ # Client class for interacting with AI providers
5
+ #
6
+ # The Client provides a high-level interface for working with AI providers,
7
+ # handling error recovery, retries, and method delegation. It acts as a
8
+ # facade over the configured providers.
9
+ #
10
+ # @example Basic usage
11
+ # client = Prescient::Client.new(:openai)
12
+ # response = client.generate_response("Hello, world!")
13
+ # embedding = client.generate_embedding("Text to embed")
14
+ #
15
+ # @example Using default provider
16
+ # client = Prescient::Client.new # Uses configured default
17
+ # puts client.provider_name # => :ollama (or configured default)
18
+ #
4
19
  class Client
20
+ # @return [Symbol] The name of the provider being used
5
21
  attr_reader :provider_name
22
+
23
+ # @return [Prescient::Base] The underlying provider instance
6
24
  attr_reader :provider
7
25
 
8
- def initialize(provider_name = nil)
26
+ # Initialize a new client with the specified provider
27
+ #
28
+ # @param provider_name [Symbol, nil] Name of provider to use, or nil for default
29
+ # @param enable_fallback [Boolean] Whether to enable automatic fallback to other providers
30
+ # @raise [Prescient::Error] If the specified provider is not configured
31
+ def initialize(provider_name = nil, enable_fallback: true)
9
32
  @provider_name = provider_name || Prescient.configuration.default_provider
10
33
  @provider = Prescient.configuration.provider(@provider_name)
34
+ @enable_fallback = enable_fallback
11
35
 
12
- raise Prescient::Error, "Provider not found: #{@provider_name}" unless @provider
36
+ raise Prescient::Error, "Provider not configured: #{@provider_name}" unless @provider
13
37
  end
14
38
 
39
+ # Generate embeddings for the given text
40
+ #
41
+ # Delegates to the underlying provider with automatic retry logic
42
+ # for transient failures. If fallback is enabled, tries other providers
43
+ # on persistent failures.
44
+ #
45
+ # @param text [String] The text to generate embeddings for
46
+ # @param options [Hash] Provider-specific options
47
+ # @return [Array<Float>] Array of embedding values
48
+ # @raise [Prescient::Error] If embedding generation fails on all providers
15
49
  def generate_embedding(text, **options)
16
- with_error_handling do
17
- if options.any?
50
+ if @enable_fallback
51
+ with_fallback_handling(:generate_embedding, text, **options)
52
+ else
53
+ with_error_handling do
18
54
  @provider.generate_embedding(text, **options)
19
- else
20
- @provider.generate_embedding(text)
21
55
  end
22
56
  end
23
57
  end
24
58
 
59
+ # Generate text response for the given prompt
60
+ #
61
+ # Delegates to the underlying provider with automatic retry logic
62
+ # for transient failures. Supports optional context items for RAG.
63
+ # If fallback is enabled, tries other providers on persistent failures.
64
+ #
65
+ # @param prompt [String] The prompt to generate a response for
66
+ # @param context_items [Array<Hash, String>] Optional context items
67
+ # @param options [Hash] Provider-specific generation options
68
+ # @option options [Float] :temperature Sampling temperature (0.0-2.0)
69
+ # @option options [Integer] :max_tokens Maximum tokens to generate
70
+ # @option options [Float] :top_p Nucleus sampling parameter
71
+ # @return [Hash] Response hash with :response, :model, :provider keys
72
+ # @raise [Prescient::Error] If response generation fails on all providers
25
73
  def generate_response(prompt, context_items = [], **options)
26
- with_error_handling do
27
- if options.any?
74
+ if @enable_fallback
75
+ with_fallback_handling(:generate_response, prompt, context_items, **options)
76
+ else
77
+ with_error_handling do
28
78
  @provider.generate_response(prompt, context_items, **options)
29
- else
30
- @provider.generate_response(prompt, context_items)
31
79
  end
32
80
  end
33
81
  end
34
82
 
83
+ # Check the health status of the provider
84
+ #
85
+ # @return [Hash] Health status information from the selected provider
35
86
  def health_check
36
87
  @provider.health_check
37
88
  end
38
89
 
90
+ # Check if the provider is currently available
91
+ #
92
+ # @return [Boolean] true if the provider currently passes its availability check
39
93
  def available?
40
94
  @provider.available?
41
95
  end
42
96
 
97
+ # Get comprehensive information about the provider
98
+ #
99
+ # Returns details about the provider including its availability
100
+ # and configuration options (with sensitive data removed).
101
+ #
102
+ # @return [Hash] Provider information including :name, :class, :available,
103
+ # and recursively sanitized :options
43
104
  def provider_info
44
105
  {
45
106
  name: @provider_name,
@@ -49,23 +110,25 @@ module Prescient
49
110
  }
50
111
  end
51
112
 
52
- def method_missing(method_name, ...)
53
- if @provider.respond_to?(method_name)
54
- @provider.send(method_name, ...)
55
- else
56
- super
57
- end
58
- end
59
-
60
- def respond_to_missing?(method_name, include_private = false)
61
- @provider.respond_to?(method_name, include_private) || super
62
- end
63
-
64
113
  private
65
114
 
66
115
  def sanitize_options(options)
67
- sensitive_keys = [:api_key, :password, :token, :secret]
68
- options.reject { |key, _| sensitive_keys.include?(key.to_sym) }
116
+ sensitive_keys = Prescient::Configuration::DEFAULT_SENSITIVE_KEYS + Prescient.configuration.sensitive_keys
117
+
118
+ case options
119
+ when Hash
120
+ sanitized = {} # : Hash[untyped, untyped]
121
+ options.each do |key, value|
122
+ next if key.respond_to?(:to_sym) && sensitive_keys.include?(key.to_sym)
123
+
124
+ sanitized[key] = sanitize_options(value)
125
+ end
126
+ sanitized
127
+ when Array
128
+ options.map { |value| sanitize_options(value) }
129
+ else
130
+ options
131
+ end
69
132
  end
70
133
 
71
134
  def with_error_handling
@@ -86,22 +149,97 @@ module Prescient
86
149
  retry
87
150
  end
88
151
  end
152
+
153
+ def with_fallback_handling(method_name, *args, **options)
154
+ last_error = nil
155
+
156
+ providers_to_try.each_with_index do |provider_name, index|
157
+ provider = provider_for(provider_name, index)
158
+ next unless provider
159
+
160
+ # Use the provider operation as the availability probe.
161
+ return with_error_handling do
162
+ provider.send(method_name, *args, **options)
163
+ end
164
+ rescue Prescient::Error => e
165
+ raise e unless fallback_eligible?(e)
166
+
167
+ last_error = e
168
+ next
169
+ end
170
+
171
+ # If we get here, all providers failed
172
+ raise last_error || Prescient::Error.new("No available providers for #{method_name}")
173
+ end
174
+
175
+ def provider_for(provider_name, index)
176
+ return @provider if index.zero? && provider_name == @provider_name
177
+
178
+ Prescient.configuration.provider(provider_name)
179
+ end
180
+
181
+ def providers_to_try
182
+ providers = [@provider_name]
183
+
184
+ # Add configured fallback providers
185
+ fallback_providers = Prescient.configuration.fallback_providers
186
+ additional_providers = if fallback_providers && !fallback_providers.empty?
187
+ fallback_providers.reject { |p| p == @provider_name }
188
+ else
189
+ # If no explicit fallbacks are configured, probe all configured providers
190
+ Prescient.configuration.providers.keys.reject { |p| p == @provider_name }
191
+ end
192
+ providers += additional_providers
193
+
194
+ providers.uniq
195
+ end
196
+
197
+ def fallback_eligible?(error)
198
+ [
199
+ Prescient::ConnectionError,
200
+ Prescient::RateLimitError,
201
+ Prescient::ModelNotAvailableError,
202
+ Prescient::ProviderError,
203
+ ].any? { |error_class| error.is_a?(error_class) }
204
+ end
89
205
  end
90
206
 
91
207
  # Convenience methods for quick access
92
- def self.client(provider_name = nil)
93
- Client.new(provider_name)
208
+ #
209
+ # @param provider_name [Symbol, nil] Provider to use, or the configured default
210
+ # @param enable_fallback [Boolean] Whether provider fallback is enabled
211
+ # @return [Client] A configured client instance
212
+ def self.client(provider_name = nil, enable_fallback: true)
213
+ Client.new(provider_name, enable_fallback: enable_fallback)
94
214
  end
95
215
 
96
- def self.generate_embedding(text, provider: nil, **options)
97
- client(provider).generate_embedding(text, **options)
216
+ # Generate an embedding through a configured provider.
217
+ #
218
+ # @param text [String] Text to embed
219
+ # @param provider [Symbol, nil] Provider to use
220
+ # @param enable_fallback [Boolean] Whether provider fallback is enabled
221
+ # @return [Array<Float>] Embedding vector
222
+ def self.generate_embedding(text, provider: nil, enable_fallback: true, **options)
223
+ client(provider, enable_fallback: enable_fallback).generate_embedding(text, **options)
98
224
  end
99
225
 
100
- def self.generate_response(prompt, context_items = [], provider: nil, **options)
101
- client(provider).generate_response(prompt, context_items, **options)
226
+ # Generate a response through a configured provider.
227
+ #
228
+ # @param prompt [String] Prompt to send
229
+ # @param context_items [Array<Hash, String>] Optional context items
230
+ # @param provider [Symbol, nil] Provider to use
231
+ # @param enable_fallback [Boolean] Whether provider fallback is enabled
232
+ # @return [Hash] Normalized provider response with :response, :model, :provider
233
+ # and optional metadata
234
+ def self.generate_response(prompt, context_items = [], provider: nil, enable_fallback: true, **options)
235
+ client(provider, enable_fallback: enable_fallback).generate_response(prompt, context_items, **options)
102
236
  end
103
237
 
238
+ # Return the health status of a configured provider.
239
+ #
240
+ # @param provider [Symbol, nil] Provider to check
241
+ # @return [Hash] Provider health information
104
242
  def self.health_check(provider: nil)
105
- client(provider).health_check
243
+ client(provider, enable_fallback: false).health_check
106
244
  end
107
245
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prescient
4
+ # Base error class for all Prescient-specific errors
5
+ class Error < StandardError
6
+ attr_reader :provider
7
+ attr_reader :operation
8
+ attr_reader :status
9
+
10
+ def initialize(message = nil, provider: nil, operation: nil, status: nil)
11
+ super(message)
12
+
13
+ @provider = provider
14
+ @operation = operation
15
+ @status = status
16
+ end
17
+ end
18
+
19
+ # Raised when there are connection issues with AI providers
20
+ class ConnectionError < Error; end
21
+
22
+ # Raised when API authentication fails
23
+ class AuthenticationError < Error; end
24
+
25
+ # Raised when API rate limits are exceeded
26
+ class RateLimitError < Error; end
27
+
28
+ # Raised when a requested model is not available
29
+ class ModelNotAvailableError < Error; end
30
+
31
+ # Raised when AI provider returns invalid or malformed responses
32
+ class InvalidResponseError < Error; end
33
+
34
+ # Raised when a vector cannot be stored or searched safely
35
+ class InvalidVectorError < Error; end
36
+
37
+ # Raised when an AI provider reports a transient service-side failure
38
+ class ProviderError < Error; end
39
+
40
+ # Container module for AI provider implementations
41
+ #
42
+ # All provider classes should be defined within this module and inherit
43
+ # from {Prescient::Base}.
44
+ module Provider
45
+ # Module for AI provider implementations
46
+ end
47
+
48
+ # Namespace for optional PostgreSQL pgvector integration
49
+ module Pgvector
50
+ end
51
+ end