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.
- checksums.yaml +4 -4
- data/.rubocop.yml +0 -2
- data/.yardopts +3 -6
- data/CHANGELOG.md +41 -4
- data/INTEGRATION_GUIDE.md +27 -29
- data/LICENSE.txt +1 -1
- data/README.md +133 -74
- data/Rakefile +82 -7
- data/Steepfile +17 -0
- data/VECTOR_SEARCH_GUIDE.md +32 -8
- 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 +81 -26
- data/lib/prescient/client.rb +70 -36
- 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 +76 -76
- 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 +85 -63
- data/scripts/setup-ollama-models.sh +2 -2
- data/sig/prescient.rbs +221 -1
- metadata +21 -217
- data/CHANGELOG.pdf +0 -0
- data/prescient.gemspec +0 -53
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.
|
|
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.
|
|
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
|
data/examples/README.md
ADDED
|
@@ -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).
|
data/examples/basic_usage.rb
CHANGED
|
@@ -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.
|
|
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!"
|
data/examples/custom_contexts.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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"
|
data/examples/custom_prompts.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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"
|
data/examples/vector_search.rb
CHANGED
|
@@ -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
|
|
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"
|
data/lib/prescient/base.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
150
|
+
# Validate embedding dimensions against the configured model dimension.
|
|
139
151
|
#
|
|
140
|
-
#
|
|
141
|
-
#
|
|
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
|
|
144
|
-
# @param target_dimensions [Integer] The
|
|
145
|
-
# @return [Array<Float
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
156
|
-
#
|
|
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:
|
|
249
|
+
fields: fields, # Will be dynamically determined from item keys
|
|
231
250
|
format: nil, # Will use fallback formatting
|
|
232
|
-
embedding_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
|
-
#
|
|
250
|
-
|
|
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
|
data/lib/prescient/client.rb
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
|
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 =
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|