prescient 0.2.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.
data/docker-compose.yml CHANGED
@@ -1,8 +1,6 @@
1
1
  # Docker Compose configuration for running Ollama with Prescient gem
2
2
  # This provides a local AI environment for development and testing
3
3
 
4
- version: '3.8'
5
-
6
4
  services:
7
5
  ollama:
8
6
  image: ollama/ollama:latest
@@ -65,7 +63,7 @@ services:
65
63
  # Pull chat model
66
64
  curl -X POST http://ollama:11434/api/pull \
67
65
  -H "Content-Type: application/json" \
68
- -d "{\"name\": \"llama3.1:8b\"}"
66
+ -d "{\"name\": \"llama3.2:3b\"}"
69
67
 
70
68
  echo "Models pulled successfully!"
71
69
  '
@@ -122,7 +120,7 @@ services:
122
120
  # Ollama configuration
123
121
  - OLLAMA_URL=http://ollama:11434
124
122
  - OLLAMA_EMBEDDING_MODEL=nomic-embed-text
125
- - OLLAMA_CHAT_MODEL=llama3.1:8b
123
+ - OLLAMA_CHAT_MODEL=llama3.2:3b
126
124
 
127
125
  # Optional: Other AI provider configurations
128
126
  - OPENAI_API_KEY=${OPENAI_API_KEY:-}
@@ -150,4 +148,4 @@ volumes:
150
148
 
151
149
  networks:
152
150
  default:
153
- name: prescient-network
151
+ name: prescient-network
@@ -0,0 +1,45 @@
1
+ # Prescient examples
2
+
3
+ These scripts demonstrate the supported public API. They use the local
4
+ library checkout, so run them from the repository root after installing the
5
+ development dependencies:
6
+
7
+ ```bash
8
+ bundle install
9
+ ```
10
+
11
+ ## Examples
12
+
13
+ - `basic_usage.rb` — default-provider generation, embeddings, health checks,
14
+ provider selection, and custom configuration.
15
+ - `custom_prompts.rb` — system prompts and no-context/with-context templates.
16
+ - `custom_contexts.rb` — explicit context types, field matching, formatting,
17
+ and embedding field selection.
18
+ - `vector_search.rb` — PostgreSQL/pgvector storage and similarity search.
19
+
20
+ The first three examples use Ollama by default. Start Ollama and pull the
21
+ current local models before running them:
22
+
23
+ ```bash
24
+ docker compose up -d ollama
25
+ docker compose run --rm ollama-init
26
+ bundle exec ruby examples/basic_usage.rb
27
+ ```
28
+
29
+ The vector-search example additionally requires PostgreSQL with pgvector:
30
+
31
+ ```bash
32
+ docker compose up -d postgres ollama
33
+ docker compose run --rm ollama-init
34
+ DB_HOST=localhost bundle exec ruby examples/vector_search.rb
35
+ ```
36
+
37
+ Cloud-provider examples require the corresponding credentials and provider
38
+ configuration. The scripts are demonstrations rather than isolated test
39
+ fixtures; they may make real provider requests when the configured service is
40
+ available.
41
+
42
+ See the [main README](../README.md) for configuration, fallback behavior,
43
+ prompt templates, context exclusions, embeddings, and the public API. Rails
44
+ applications can also use the [integration guide](../INTEGRATION_GUIDE.md),
45
+ and PostgreSQL users should read the [pgvector guide](../VECTOR_SEARCH_GUIDE.md).
@@ -110,7 +110,7 @@ Prescient.configure do |config|
110
110
  config.add_provider(:custom_ollama, Prescient::Provider::Ollama,
111
111
  url: ENV.fetch('OLLAMA_URL', 'http://localhost:11434'),
112
112
  embedding_model: 'nomic-embed-text',
113
- chat_model: 'llama3.1:8b',
113
+ chat_model: 'llama3.2:3b',
114
114
  timeout: 60
115
115
  )
116
116
  end
@@ -120,4 +120,4 @@ puts " Timeout: #{Prescient.configuration.timeout}s"
120
120
  puts " Retry attempts: #{Prescient.configuration.retry_attempts}"
121
121
  puts " Providers: #{Prescient.configuration.providers.keys.join(', ')}"
122
122
 
123
- puts "\n🎉 Examples completed!"
123
+ puts "\n🎉 Examples completed!"
@@ -16,7 +16,7 @@ Prescient.configure do |config|
16
16
  config.add_provider(:ecommerce, Prescient::Provider::Ollama,
17
17
  url: ENV.fetch('OLLAMA_URL', 'http://localhost:11434'),
18
18
  embedding_model: 'nomic-embed-text',
19
- chat_model: 'llama3.1:8b',
19
+ chat_model: 'llama3.2:3b',
20
20
  # Define your own context types - no hardcoded assumptions!
21
21
  context_configs: {
22
22
  'product' => {
@@ -88,7 +88,7 @@ Prescient.configure do |config|
88
88
  config.add_provider(:healthcare, Prescient::Provider::Ollama,
89
89
  url: ENV.fetch('OLLAMA_URL', 'http://localhost:11434'),
90
90
  embedding_model: 'nomic-embed-text',
91
- chat_model: 'llama3.1:8b',
91
+ chat_model: 'llama3.2:3b',
92
92
  context_configs: {
93
93
  'patient' => {
94
94
  fields: %w[name age gender medical_conditions medications],
@@ -158,7 +158,7 @@ Prescient.configure do |config|
158
158
  config.add_provider(:project_mgmt, Prescient::Provider::Ollama,
159
159
  url: ENV.fetch('OLLAMA_URL', 'http://localhost:11434'),
160
160
  embedding_model: 'nomic-embed-text',
161
- chat_model: 'llama3.1:8b',
161
+ chat_model: 'llama3.2:3b',
162
162
  context_configs: {
163
163
  'issue' => {
164
164
  fields: %w[title description status priority assignee labels created_date],
@@ -237,7 +237,7 @@ begin
237
237
  config.add_provider(:embedding_demo, Prescient::Provider::Ollama,
238
238
  url: ENV.fetch('OLLAMA_URL', 'http://localhost:11434'),
239
239
  embedding_model: 'nomic-embed-text',
240
- chat_model: 'llama3.1:8b',
240
+ chat_model: 'llama3.2:3b',
241
241
  context_configs: {
242
242
  'blog_post' => {
243
243
  fields: %w[title content author tags category publish_date],
@@ -288,7 +288,7 @@ Prescient.configure do |config|
288
288
  config.add_provider(:no_config, Prescient::Provider::Ollama,
289
289
  url: ENV.fetch('OLLAMA_URL', 'http://localhost:11434'),
290
290
  embedding_model: 'nomic-embed-text',
291
- chat_model: 'llama3.1:8b'
291
+ chat_model: 'llama3.2:3b'
292
292
  # No context_configs defined - uses pure default behavior
293
293
  )
294
294
  end
@@ -352,4 +352,4 @@ puts "\n🎯 Best Practices:"
352
352
  puts " - Define context_configs for your specific domain"
353
353
  puts " - Use explicit 'type' field when context detection isn't reliable"
354
354
  puts " - Exclude sensitive/metadata fields from embedding_fields"
355
- puts " - Test with and without context configs to see the difference"
355
+ puts " - Test with and without context configs to see the difference"
@@ -15,7 +15,7 @@ Prescient.configure do |config|
15
15
  config.add_provider(:customer_service, Prescient::Provider::Ollama,
16
16
  url: ENV.fetch('OLLAMA_URL', 'http://localhost:11434'),
17
17
  embedding_model: 'nomic-embed-text',
18
- chat_model: 'llama3.1:8b',
18
+ chat_model: 'llama3.2:3b',
19
19
  prompt_templates: {
20
20
  system_prompt: 'You are a friendly customer service representative. Be helpful, empathetic, and professional.',
21
21
  no_context_template: <<~TEMPLATE.strip,
@@ -73,7 +73,7 @@ Prescient.configure do |config|
73
73
  config.add_provider(:tech_docs, Prescient::Provider::Ollama,
74
74
  url: ENV.fetch('OLLAMA_URL', 'http://localhost:11434'),
75
75
  embedding_model: 'nomic-embed-text',
76
- chat_model: 'llama3.1:8b',
76
+ chat_model: 'llama3.2:3b',
77
77
  prompt_templates: {
78
78
  system_prompt: 'You are a technical documentation assistant. Provide clear, accurate, and detailed technical explanations with code examples when relevant.',
79
79
  no_context_template: <<~TEMPLATE.strip,
@@ -125,7 +125,7 @@ Prescient.configure do |config|
125
125
  config.add_provider(:creative, Prescient::Provider::Ollama,
126
126
  url: ENV.fetch('OLLAMA_URL', 'http://localhost:11434'),
127
127
  embedding_model: 'nomic-embed-text',
128
- chat_model: 'llama3.1:8b',
128
+ chat_model: 'llama3.2:3b',
129
129
  prompt_templates: {
130
130
  system_prompt: 'You are a creative writing assistant. Help with storytelling, character development, and creative inspiration. Be imaginative and encouraging.',
131
131
  no_context_template: <<~TEMPLATE.strip,
@@ -182,7 +182,7 @@ Prescient.configure do |config|
182
182
  config.add_provider(:custom_default, Prescient::Provider::Ollama,
183
183
  url: ENV.fetch('OLLAMA_URL', 'http://localhost:11434'),
184
184
  embedding_model: 'nomic-embed-text',
185
- chat_model: 'llama3.1:8b',
185
+ chat_model: 'llama3.2:3b',
186
186
  prompt_templates: {
187
187
  # Only override the system prompt, keep default templates
188
188
  system_prompt: 'You are Sherlock Holmes. Approach every question with deductive reasoning and attention to detail.'
@@ -209,4 +209,4 @@ puts "\n💡 Tips:"
209
209
  puts " - Use %{system_prompt}, %{query}, and %{context} placeholders in templates"
210
210
  puts " - Templates use Ruby's % string formatting"
211
211
  puts " - Override any or all template parts (system_prompt, no_context_template, with_context_template)"
212
- puts " - Each provider can have completely different prompt behavior"
212
+ puts " - Each provider can have completely different prompt behavior"
@@ -31,7 +31,7 @@ class VectorSearchExample
31
31
 
32
32
  # Check if services are available
33
33
  unless check_services_available
34
- puts "❌ Required services not available. Please start with: docker-compose up -d"
34
+ puts "❌ Required services not available. Please start with: docker compose up -d"
35
35
  return
36
36
  end
37
37
 
@@ -327,4 +327,4 @@ puts " - Try different embedding models (OpenAI, HuggingFace)"
327
327
  puts " - Implement hybrid search (vector + keyword)"
328
328
  puts " - Add document chunking for large texts"
329
329
  puts " - Experiment with different similarity thresholds"
330
- puts " - Add result re-ranking and filtering"
330
+ puts " - Add result re-ranking and filtering"
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+ require 'net/http'
5
+
3
6
  # Base class for all AI provider implementations
4
7
  #
5
8
  # This abstract base class defines the common interface that all AI providers
@@ -7,7 +10,8 @@
7
10
  # formatting, prompt building, and error handling.
8
11
  #
9
12
  # @abstract Subclass and implement {#generate_embedding}, {#generate_response},
10
- # {#health_check}, and {#validate_configuration!}
13
+ # {#health_check}, and any configuration validation required by
14
+ # {#validate_configuration!}
11
15
  #
12
16
  # @example Creating a custom provider
13
17
  # class MyProvider < Prescient::Base
@@ -24,11 +28,9 @@
24
28
  # end
25
29
  # end
26
30
  #
27
- # @author Claude Code
28
- # @since 1.0.0
29
31
  class Prescient::Base
30
32
  # @return [Hash] Configuration options for this provider instance
31
- attr_reader :options
33
+ attr_reader :options, :provider_name
32
34
 
33
35
  # Initialize the provider with configuration options
34
36
  #
@@ -38,8 +40,12 @@ class Prescient::Base
38
40
  # @option options [Integer] :timeout Request timeout in seconds
39
41
  # @option options [Hash] :prompt_templates Custom prompt templates
40
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
41
46
  def initialize(**options)
42
47
  @options = options
48
+ @provider_name = options.fetch(:provider_name, self.class.to_s.split('::').last).to_s.sub(/\A./, &:upcase)
43
49
  validate_configuration!
44
50
  end
45
51
 
@@ -80,7 +86,8 @@ class Prescient::Base
80
86
  # This method must be implemented by subclasses to provide health check
81
87
  # functionality.
82
88
  #
83
- # @return [Hash] Health status with :status, :provider keys and optional details
89
+ # @return [Hash] Health status with at least :status and :provider keys,
90
+ # and typically :reachable and :ready for modern adapters
84
91
  # @raise [NotImplementedError] If not implemented by subclass
85
92
  # @abstract
86
93
  def health_check
@@ -89,9 +96,14 @@ class Prescient::Base
89
96
 
90
97
  # Check if the provider is currently available
91
98
  #
92
- # @return [Boolean] true if provider is healthy and available
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
93
104
  def available?
94
- health_check[:status] == 'healthy'
105
+ health = health_check
106
+ health.key?(:reachable) ? health[:reachable] == true : health[:status] == 'healthy'
95
107
  rescue StandardError
96
108
  false
97
109
  end
@@ -135,25 +147,29 @@ class Prescient::Base
135
147
  raise Prescient::Error, "Unexpected error: #{e.message}"
136
148
  end
137
149
 
138
- # Normalize embedding dimensions to match expected size
150
+ # Validate embedding dimensions against the configured model dimension.
139
151
  #
140
- # Ensures embedding vectors have consistent dimensions by truncating
141
- # longer vectors or padding shorter ones with zeros.
152
+ # Embedding dimensions are part of the vector-storage contract. Vectors are
153
+ # never padded or truncated because either operation changes their meaning.
142
154
  #
143
- # @param embedding [Array<Float>] The embedding vector to normalize
144
- # @param target_dimensions [Integer] The desired number of dimensions
145
- # @return [Array<Float>, nil] Normalized embedding or nil if input invalid
146
- def normalize_embedding(embedding, target_dimensions)
147
- return nil unless embedding.is_a?(Array)
148
- return embedding.first(target_dimensions) if embedding.length >= target_dimensions
149
-
150
- embedding + Array.new(target_dimensions - embedding.length, 0.0)
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
+
163
+ return embedding if embedding.length == target_dimensions
164
+
165
+ raise Prescient::InvalidResponseError,
166
+ "Invalid embedding dimensions: expected #{target_dimensions}, got #{embedding.length}"
151
167
  end
152
168
 
153
169
  # Clean and preprocess text for AI processing
154
170
  #
155
- # Removes excess whitespace, normalizes spacing, and enforces length
156
- # limits suitable for most AI models.
171
+ # Removes excess whitespace, normalizes spacing, and truncates to the
172
+ # library's current 8,000-character input ceiling.
157
173
  #
158
174
  # @param text [String, nil] The text to clean
159
175
  # @return [String] Cleaned text, empty string if input was nil/empty
@@ -224,12 +240,15 @@ class Prescient::Base
224
240
 
225
241
  # Minimal default context configuration - users should define their own contexts
226
242
  def default_context_configs
243
+ embedding_fields = [] # : Array[untyped]
244
+ fields = [] # : Array[untyped]
245
+
227
246
  {
228
247
  # Generic fallback configuration - works with any hash structure
229
248
  'default' => {
230
- fields: [], # Will be dynamically determined from item keys
249
+ fields: fields, # Will be dynamically determined from item keys
231
250
  format: nil, # Will use fallback formatting
232
- embedding_fields: [], # Will use all string/text fields
251
+ embedding_fields: embedding_fields, # Will use all string/text fields
233
252
  },
234
253
  }
235
254
  end
@@ -245,9 +264,13 @@ class Prescient::Base
245
264
 
246
265
  # Extract text values from hash, excluding non-textual fields
247
266
  def extract_text_values(item)
248
- # Common fields to exclude from embedding text
249
- # TODO: configurable fields to exclude aside from the common ones below
250
- 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
251
274
 
252
275
  item.filter_map { |key, value|
253
276
  next if exclude_fields.include?(key.to_s.downcase)
@@ -298,7 +321,7 @@ class Prescient::Base
298
321
 
299
322
  # Build format data from item fields
300
323
  def build_format_data(item, config)
301
- format_data = {}
324
+ format_data = {} # : Hash[Symbol, untyped]
302
325
  fields_to_check = config[:fields].any? ? config[:fields] : item.keys.map(&:to_s)
303
326
 
304
327
  fields_to_check.each do |field|
@@ -371,4 +394,36 @@ class Prescient::Base
371
394
  # Fallback: join key-value pairs
372
395
  (format_data || item).map { |k, v| "#{k}: #{v}" }.join(', ')
373
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
374
429
  end
@@ -16,13 +16,11 @@ module Prescient
16
16
  # client = Prescient::Client.new # Uses configured default
17
17
  # puts client.provider_name # => :ollama (or configured default)
18
18
  #
19
- # @author Claude Code
20
- # @since 1.0.0
21
19
  class Client
22
20
  # @return [Symbol] The name of the provider being used
23
21
  attr_reader :provider_name
24
22
 
25
- # @return [Base] The underlying provider instance
23
+ # @return [Prescient::Base] The underlying provider instance
26
24
  attr_reader :provider
27
25
 
28
26
  # Initialize a new client with the specified provider
@@ -35,7 +33,7 @@ module Prescient
35
33
  @provider = Prescient.configuration.provider(@provider_name)
36
34
  @enable_fallback = enable_fallback
37
35
 
38
- raise Prescient::Error, "Provider not found: #{@provider_name}" unless @provider
36
+ raise Prescient::Error, "Provider not configured: #{@provider_name}" unless @provider
39
37
  end
40
38
 
41
39
  # Generate embeddings for the given text
@@ -84,14 +82,14 @@ module Prescient
84
82
 
85
83
  # Check the health status of the provider
86
84
  #
87
- # @return [Hash] Health status information
85
+ # @return [Hash] Health status information from the selected provider
88
86
  def health_check
89
87
  @provider.health_check
90
88
  end
91
89
 
92
90
  # Check if the provider is currently available
93
91
  #
94
- # @return [Boolean] true if provider is healthy and available
92
+ # @return [Boolean] true if the provider currently passes its availability check
95
93
  def available?
96
94
  @provider.available?
97
95
  end
@@ -101,7 +99,8 @@ module Prescient
101
99
  # Returns details about the provider including its availability
102
100
  # and configuration options (with sensitive data removed).
103
101
  #
104
- # @return [Hash] Provider information including :name, :class, :available, :options
102
+ # @return [Hash] Provider information including :name, :class, :available,
103
+ # and recursively sanitized :options
105
104
  def provider_info
106
105
  {
107
106
  name: @provider_name,
@@ -111,20 +110,25 @@ module Prescient
111
110
  }
112
111
  end
113
112
 
114
- def method_missing(method_name, ...)
115
- @provider.respond_to?(method_name) ? @provider.send(method_name, ...) : super
116
- end
117
-
118
- def respond_to_missing?(method_name, include_private = false)
119
- @provider.respond_to?(method_name, include_private) || super
120
- end
121
-
122
113
  private
123
114
 
124
- # TODO: configurable keys to sanitize
125
115
  def sanitize_options(options)
126
- sensitive_keys = [:api_key, :password, :token, :secret]
127
- 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
128
132
  end
129
133
 
130
134
  def with_error_handling
@@ -150,24 +154,17 @@ module Prescient
150
154
  last_error = nil
151
155
 
152
156
  providers_to_try.each_with_index do |provider_name, index|
153
- # Use existing provider instance for primary provider, create new ones for fallbacks
154
- provider = if index.zero? && provider_name == @provider_name
155
- @provider
156
- else
157
- Prescient.configuration.provider(provider_name)
158
- end
157
+ provider = provider_for(provider_name, index)
159
158
  next unless provider
160
159
 
161
- # Check if provider is available before trying
162
- next unless provider.available?
163
-
164
- # Use retry logic for each provider
160
+ # Use the provider operation as the availability probe.
165
161
  return with_error_handling do
166
162
  provider.send(method_name, *args, **options)
167
163
  end
168
164
  rescue Prescient::Error => e
165
+ raise e unless fallback_eligible?(e)
166
+
169
167
  last_error = e
170
- # Log the error and continue to next provider
171
168
  next
172
169
  end
173
170
 
@@ -175,36 +172,73 @@ module Prescient
175
172
  raise last_error || Prescient::Error.new("No available providers for #{method_name}")
176
173
  end
177
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
+
178
181
  def providers_to_try
179
182
  providers = [@provider_name]
180
183
 
181
184
  # Add configured fallback providers
182
185
  fallback_providers = Prescient.configuration.fallback_providers
183
- if fallback_providers && !fallback_providers.empty?
184
- providers += fallback_providers.reject { |p| p == @provider_name }
185
- else
186
- # If no explicit fallbacks configured, try all available providers
187
- available = Prescient.configuration.available_providers
188
- providers += available.reject { |p| p == @provider_name }
189
- end
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
190
193
 
191
194
  providers.uniq
192
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
193
205
  end
194
206
 
195
207
  # Convenience methods for quick access
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
196
212
  def self.client(provider_name = nil, enable_fallback: true)
197
213
  Client.new(provider_name, enable_fallback: enable_fallback)
198
214
  end
199
215
 
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
200
222
  def self.generate_embedding(text, provider: nil, enable_fallback: true, **options)
201
223
  client(provider, enable_fallback: enable_fallback).generate_embedding(text, **options)
202
224
  end
203
225
 
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
204
234
  def self.generate_response(prompt, context_items = [], provider: nil, enable_fallback: true, **options)
205
235
  client(provider, enable_fallback: enable_fallback).generate_response(prompt, context_items, **options)
206
236
  end
207
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
208
242
  def self.health_check(provider: nil)
209
243
  client(provider, enable_fallback: false).health_check
210
244
  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