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.
- checksums.yaml +4 -4
- data/.rubocop.yml +6 -4
- data/.yardopts +11 -0
- data/CHANGELOG.md +101 -0
- data/INTEGRATION_GUIDE.md +361 -0
- data/LICENSE.txt +1 -1
- data/README.md +217 -100
- data/Rakefile +84 -8
- data/Steepfile +17 -0
- data/VECTOR_SEARCH_GUIDE.md +74 -47
- data/docker-compose.yml +3 -5
- data/examples/README.md +45 -0
- data/examples/basic_usage.rb +2 -2
- data/examples/custom_contexts.rb +6 -6
- data/examples/custom_prompts.rb +5 -5
- data/examples/vector_search.rb +2 -2
- data/lib/prescient/base.rb +187 -28
- data/lib/prescient/client.rb +169 -31
- data/lib/prescient/errors.rb +51 -0
- data/lib/prescient/pgvector.rb +194 -0
- data/lib/prescient/provider/anthropic.rb +55 -54
- data/lib/prescient/provider/huggingface.rb +77 -79
- data/lib/prescient/provider/ollama.rb +46 -35
- data/lib/prescient/provider/openai.rb +38 -31
- data/lib/prescient/version.rb +2 -1
- data/lib/prescient.rb +160 -36
- data/scripts/setup-ollama-models.sh +2 -2
- data/sig/prescient.rbs +221 -1
- metadata +23 -184
- data/prescient.gemspec +0 -51
data/lib/prescient/base.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
178
|
+
text.to_s.gsub(/\s+/, ' ').strip.slice(0, 8000)
|
|
73
179
|
end
|
|
74
180
|
|
|
75
|
-
#
|
|
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
|
|
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:
|
|
249
|
+
fields: fields, # Will be dynamically determined from item keys
|
|
128
250
|
format: nil, # Will use fallback formatting
|
|
129
|
-
embedding_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
|
-
|
|
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
|
data/lib/prescient/client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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 =
|
|
68
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|