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.
@@ -2,33 +2,46 @@
2
2
 
3
3
  require 'httparty'
4
4
 
5
+ # Ollama local or hosted API provider adapter.
5
6
  class Prescient::Provider::Ollama < Prescient::Base
6
7
  include HTTParty
7
8
 
8
- EMBEDDING_DIMENSIONS = 768 # nomic-embed-text dimensions
9
-
10
9
  def initialize(**options)
11
10
  super
12
11
  self.class.base_uri(@options[:url])
13
12
  self.class.default_timeout(@options[:timeout] || 60)
14
13
  end
15
14
 
15
+ # Generate an embedding through Ollama's `/api/embed` endpoint.
16
+ # @param text [String] Text to embed
17
+ # @return [Array<Float>] Embedding vector from the first returned input item
16
18
  def generate_embedding(text, **_options)
17
19
  handle_errors do
18
- embedding = fetch_and_parse('post', '/api/embeddings',
19
- root_key: 'embedding',
20
- headers: { 'Content-Type' => 'application/json' },
21
- body: {
22
- model: @options[:embedding_model],
23
- prompt: clean_text(text),
24
- }.to_json)
25
-
26
- raise Prescient::InvalidResponseError, 'No embedding returned' unless embedding
27
-
28
- normalize_embedding(embedding, EMBEDDING_DIMENSIONS)
20
+ embeddings = fetch_and_parse('post', '/api/embed',
21
+ root_key: 'embeddings',
22
+ headers: { 'Content-Type' => 'application/json' },
23
+ body: {
24
+ model: @options[:embedding_model],
25
+ input: clean_text(text),
26
+ }.to_json)
27
+
28
+ embedding = embeddings.is_a?(Array) ? embeddings.first : nil # : Array[Float]?
29
+ raise Prescient::InvalidResponseError, 'No embedding returned' unless embedding.is_a?(Array)
30
+
31
+ expected_dimensions = @options[:embedding_dimensions]
32
+ if expected_dimensions && embedding.length != expected_dimensions
33
+ raise Prescient::InvalidResponseError,
34
+ "Invalid embedding dimensions: expected #{expected_dimensions}, got #{embedding.length}"
35
+ end
36
+
37
+ embedding
29
38
  end
30
39
  end
31
40
 
41
+ # Generate text through Ollama's generation endpoint.
42
+ # @param prompt [String] Prompt to send
43
+ # @param context_items [Array<Hash, String>] Optional context items
44
+ # @return [Hash] Normalized response data
32
45
  def generate_response(prompt, context_items = [], **options)
33
46
  handle_errors do
34
47
  request_options = prepare_generate_response(prompt, context_items, **options)
@@ -54,6 +67,12 @@ class Prescient::Provider::Ollama < Prescient::Base
54
67
  end
55
68
  end
56
69
 
70
+ # Check whether the configured Ollama models are available locally.
71
+ #
72
+ # `reachable` indicates the Ollama API answered successfully. `ready`
73
+ # indicates that both configured models are present in the local model list.
74
+ #
75
+ # @return [Hash] Provider health information
57
76
  def health_check
58
77
  handle_errors do
59
78
  models = available_models
@@ -63,6 +82,7 @@ class Prescient::Provider::Ollama < Prescient::Base
63
82
  {
64
83
  status: 'healthy',
65
84
  provider: 'ollama',
85
+ reachable: true,
66
86
  url: @options[:url],
67
87
  models_available: models.map { |m| m[:name] },
68
88
  embedding_model: {
@@ -78,14 +98,19 @@ class Prescient::Provider::Ollama < Prescient::Base
78
98
  end
79
99
  rescue Prescient::Error => e
80
100
  {
81
- status: 'unavailable',
82
- provider: 'ollama',
83
- error: e.class.name,
84
- message: e.message,
85
- url: @options[:url],
101
+ status: 'unavailable',
102
+ provider: 'ollama',
103
+ reachable: false,
104
+ error: e.class.name,
105
+ message: e.message,
106
+ url: @options[:url],
107
+ ready: false,
86
108
  }
87
109
  end
88
110
 
111
+ # List models currently available from Ollama.
112
+ # @return [Array<Hash>] Model descriptors including name, size, digest,
113
+ # modified_at, and booleans for configured embedding/chat roles
89
114
  def available_models
90
115
  return @_available_models if defined?(@_available_models)
91
116
 
@@ -98,6 +123,9 @@ class Prescient::Provider::Ollama < Prescient::Base
98
123
  end
99
124
  end
100
125
 
126
+ # Pull a model into the Ollama installation.
127
+ # @param model_name [String] Model identifier to download
128
+ # @return [Hash] Pull result
101
129
  def pull_model(model_name)
102
130
  handle_errors do
103
131
  fetch_and_parse('post', '/api/pull',
@@ -152,21 +180,4 @@ class Prescient::Provider::Ollama < Prescient::Base
152
180
  response.parsed_response[root_key]
153
181
  end
154
182
 
155
- def validate_response!(response, operation)
156
- return if response.success?
157
-
158
- case response.code
159
- when 404
160
- raise Prescient::ModelNotAvailableError, "Model not available for #{operation}"
161
- when 429
162
- raise Prescient::RateLimitError, "Rate limit exceeded for #{operation}"
163
- when 401, 403
164
- raise Prescient::AuthenticationError, "Authentication failed for #{operation}"
165
- when 500..599
166
- raise Prescient::Error, "Ollama server error during #{operation}: #{response.body}"
167
- else
168
- raise Prescient::Error,
169
- "Ollama request failed for #{operation}: HTTP #{response.code} - #{response.message}"
170
- end
171
- end
172
183
  end
@@ -2,11 +2,13 @@
2
2
 
3
3
  require 'httparty'
4
4
 
5
+ # OpenAI API provider adapter.
5
6
  class Prescient::Provider::OpenAI < Prescient::Base
6
7
  include HTTParty
7
8
 
8
9
  base_uri 'https://api.openai.com'
9
10
 
11
+ # Known embedding dimensions for OpenAI embedding models.
10
12
  EMBEDDING_DIMENSIONS = {
11
13
  'text-embedding-3-small' => 1536,
12
14
  'text-embedding-3-large' => 3072,
@@ -18,6 +20,9 @@ class Prescient::Provider::OpenAI < Prescient::Base
18
20
  self.class.default_timeout(@options[:timeout] || 60)
19
21
  end
20
22
 
23
+ # Generate an embedding through the OpenAI embeddings API.
24
+ # @param text [String] Text to embed
25
+ # @return [Array<Float>] Embedding vector
21
26
  def generate_embedding(text, **_options)
22
27
  handle_errors do
23
28
  clean_text_input = clean_text(text)
@@ -38,11 +43,20 @@ class Prescient::Provider::OpenAI < Prescient::Base
38
43
  embedding_data = response.parsed_response.dig('data', 0, 'embedding')
39
44
  raise Prescient::InvalidResponseError, 'No embedding returned' unless embedding_data
40
45
 
41
- expected_dimensions = EMBEDDING_DIMENSIONS[@options[:embedding_model]] || 1536
42
- normalize_embedding(embedding_data, expected_dimensions)
46
+ expected_dimensions = EMBEDDING_DIMENSIONS[@options[:embedding_model]] || @options[:embedding_dimensions]
47
+ unless expected_dimensions
48
+ raise Prescient::Error,
49
+ "Embedding dimensions are required for model #{@options[:embedding_model]}"
50
+ end
51
+
52
+ validate_embedding_dimensions(embedding_data, expected_dimensions)
43
53
  end
44
54
  end
45
55
 
56
+ # Generate a response through the OpenAI chat completions API.
57
+ # @param prompt [String] Prompt to send
58
+ # @param context_items [Array<Hash, String>] Optional context items
59
+ # @return [Hash] Normalized response data
46
60
  def generate_response(prompt, context_items = [], **options)
47
61
  handle_errors do
48
62
  formatted_prompt = build_prompt(prompt, context_items)
@@ -83,6 +97,12 @@ class Prescient::Provider::OpenAI < Prescient::Base
83
97
  end
84
98
  end
85
99
 
100
+ # Check OpenAI model availability via `/v1/models`.
101
+ #
102
+ # `reachable` indicates the API answered successfully. `ready` indicates that
103
+ # both configured models appear in the returned model list.
104
+ #
105
+ # @return [Hash] Provider health information
86
106
  def health_check
87
107
  handle_errors do
88
108
  response = self.class.get('/v1/models',
@@ -98,6 +118,7 @@ class Prescient::Provider::OpenAI < Prescient::Base
98
118
  {
99
119
  status: 'healthy',
100
120
  provider: 'openai',
121
+ reachable: true,
101
122
  models_available: models.map { |m| m['id'] },
102
123
  embedding_model: {
103
124
  name: @options[:embedding_model],
@@ -111,22 +132,29 @@ class Prescient::Provider::OpenAI < Prescient::Base
111
132
  }
112
133
  else
113
134
  {
114
- status: 'unhealthy',
115
- provider: 'openai',
116
- error: "HTTP #{response.code}",
117
- message: response.message,
135
+ status: 'unhealthy',
136
+ provider: 'openai',
137
+ reachable: true,
138
+ error: "HTTP #{response.code}",
139
+ message: response.message,
140
+ ready: false,
118
141
  }
119
142
  end
120
143
  end
121
144
  rescue Prescient::Error => e
122
145
  {
123
- status: 'unavailable',
124
- provider: 'openai',
125
- error: e.class.name,
126
- message: e.message,
146
+ status: 'unavailable',
147
+ provider: 'openai',
148
+ reachable: false,
149
+ error: e.class.name,
150
+ message: e.message,
151
+ ready: false,
127
152
  }
128
153
  end
129
154
 
155
+ # List models available to the configured OpenAI account.
156
+ #
157
+ # @return [Array<Hash>] Model descriptors
130
158
  def list_models
131
159
  handle_errors do
132
160
  response = self.class.get('/v1/models',
@@ -157,25 +185,4 @@ class Prescient::Provider::OpenAI < Prescient::Base
157
185
  raise Prescient::Error, "Missing required options: #{missing_options.join(', ')}"
158
186
  end
159
187
 
160
- private
161
-
162
- def validate_response!(response, operation)
163
- return if response.success?
164
-
165
- case response.code
166
- when 400
167
- raise Prescient::Error, "Bad request for #{operation}: #{response.body}"
168
- when 401
169
- raise Prescient::AuthenticationError, "Authentication failed for #{operation}"
170
- when 403
171
- raise Prescient::AuthenticationError, "Forbidden access for #{operation}"
172
- when 429
173
- raise Prescient::RateLimitError, "Rate limit exceeded for #{operation}"
174
- when 500..599
175
- raise Prescient::Error, "OpenAI server error during #{operation}: #{response.body}"
176
- else
177
- raise Prescient::Error,
178
- "OpenAI request failed for #{operation}: HTTP #{response.code} - #{response.message}"
179
- end
180
- end
181
188
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Prescient
4
- VERSION = '0.2.0'
4
+ # Current Prescient gem version.
5
+ VERSION = '0.3.0'
5
6
  end
data/lib/prescient.rb CHANGED
@@ -1,11 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'prescient/version'
4
+ require_relative 'prescient/errors'
5
+ require_relative 'prescient/pgvector'
6
+ require_relative 'prescient/base'
7
+ require_relative 'prescient/provider/ollama'
8
+ require_relative 'prescient/provider/anthropic'
9
+ require_relative 'prescient/provider/openai'
10
+ require_relative 'prescient/provider/huggingface'
11
+ require_relative 'prescient/client'
4
12
 
5
13
  # Main Prescient module for AI provider abstraction
6
14
  #
7
15
  # Prescient provides a unified interface for working with multiple AI providers
8
- # including Ollama, OpenAI, Anthropic, and HuggingFace. It supports both
16
+ # including Ollama, OpenAI, Anthropic, and Hugging Face. It supports both
9
17
  # embedding generation and text completion with configurable context handling.
10
18
  #
11
19
  # @example Basic usage
@@ -20,44 +28,6 @@ require_relative 'prescient/version'
20
28
  # @example Embedding generation
21
29
  # embedding = client.generate_embedding("Some text to embed")
22
30
  # puts embedding.length # => 1536 (for OpenAI text-embedding-3-small)
23
- #
24
- # @author Claude Code
25
- # @since 1.0.0
26
- module Prescient
27
- # Base error class for all Prescient-specific errors
28
- class Error < StandardError; end
29
-
30
- # Raised when there are connection issues with AI providers
31
- class ConnectionError < Error; end
32
-
33
- # Raised when API authentication fails
34
- class AuthenticationError < Error; end
35
-
36
- # Raised when API rate limits are exceeded
37
- class RateLimitError < Error; end
38
-
39
- # Raised when a requested model is not available
40
- class ModelNotAvailableError < Error; end
41
-
42
- # Raised when AI provider returns invalid or malformed responses
43
- class InvalidResponseError < Error; end
44
-
45
- # Container module for AI provider implementations
46
- #
47
- # All provider classes should be defined within this module and inherit
48
- # from {Prescient::Base}.
49
- module Provider
50
- # Module for AI provider implementations
51
- end
52
- end
53
-
54
- require_relative 'prescient/base'
55
- require_relative 'prescient/provider/ollama'
56
- require_relative 'prescient/provider/anthropic'
57
- require_relative 'prescient/provider/openai'
58
- require_relative 'prescient/provider/huggingface'
59
- require_relative 'prescient/client'
60
-
61
31
  module Prescient
62
32
  # Configure Prescient with custom settings and providers
63
33
  #
@@ -95,6 +65,9 @@ module Prescient
95
65
  # Handles global settings like timeouts and retry behavior, as well as
96
66
  # provider registration and instantiation.
97
67
  class Configuration
68
+ # @return [Array<Symbol>] Built-in provider option keys removed from output
69
+ DEFAULT_SENSITIVE_KEYS = [:api_key, :password, :token, :secret].freeze
70
+
98
71
  # @return [Symbol] The default provider to use when none specified
99
72
  attr_accessor :default_provider
100
73
 
@@ -110,6 +83,9 @@ module Prescient
110
83
  # @return [Array<Symbol>] List of fallback providers to try when primary fails
111
84
  attr_accessor :fallback_providers
112
85
 
86
+ # @return [Array<Symbol>] Additional keys removed from provider information
87
+ attr_reader :sensitive_keys
88
+
113
89
  # @return [Hash] Registered providers configuration
114
90
  attr_reader :providers
115
91
 
@@ -120,7 +96,18 @@ module Prescient
120
96
  @retry_attempts = 3
121
97
  @retry_delay = 1.0
122
98
  @fallback_providers = []
99
+ @sensitive_keys = []
123
100
  @providers = {}
101
+ @provider_instances = {} # : Hash[Symbol, untyped]
102
+ end
103
+
104
+ # Configure additional keys to remove from provider information.
105
+ # Built-in sensitive keys are always sanitized.
106
+ #
107
+ # @param keys [Array<Symbol, String>] Additional sensitive option keys
108
+ # @return [Array<Symbol>] Normalized additional keys
109
+ def sensitive_keys=(keys)
110
+ @sensitive_keys = Array(keys).map(&:to_sym).uniq
124
111
  end
125
112
 
126
113
  # Register a new AI provider
@@ -137,12 +124,14 @@ module Prescient
137
124
  # @example Add OpenAI provider
138
125
  # config.add_provider(:openai, Prescient::Provider::OpenAI,
139
126
  # api_key: 'sk-...',
140
- # chat_model: 'gpt-4')
127
+ # chat_model: 'gpt-4.1-mini')
141
128
  def add_provider(name, provider_class, **options)
142
- @providers[name.to_sym] = {
129
+ provider_name = name.to_sym
130
+ @providers[provider_name] = {
143
131
  class: provider_class,
144
132
  options: options,
145
133
  }
134
+ @provider_instances.delete(provider_name)
146
135
  end
147
136
 
148
137
  # Instantiate a provider by name
@@ -150,15 +139,20 @@ module Prescient
150
139
  # @param name [Symbol] The provider name
151
140
  # @return [Base, nil] Provider instance or nil if not found
152
141
  def provider(name)
153
- provider_config = @providers[name.to_sym]
142
+ provider_name = name.to_sym
143
+ provider_config = @providers[provider_name]
154
144
  return nil unless provider_config
155
145
 
156
- provider_config[:class].new(**provider_config[:options])
146
+ provider_options = provider_config[:options] # : Hash[Symbol, untyped]
147
+ @provider_instances[provider_name] ||= provider_config[:class].new(**provider_options)
157
148
  end
158
149
 
159
- # Get list of available providers (those that are configured and healthy)
150
+ # Get list of providers that currently pass {Prescient::Base#available?}.
151
+ #
152
+ # Providers are included when their health check reports `reachable: true`,
153
+ # or, for legacy adapters, `status == "healthy"`.
160
154
  #
161
- # @return [Array<Symbol>] List of available provider names
155
+ # @return [Array<Symbol>] List of reachable provider names
162
156
  def available_providers
163
157
  @providers.keys.select do |name|
164
158
  provider(name)&.available?
@@ -168,25 +162,53 @@ module Prescient
168
162
  end
169
163
  end
170
164
 
165
+ class << self
166
+ private
167
+
168
+ def configure_default_providers(config, env)
169
+ config.add_provider(:ollama,
170
+ Prescient::Provider::Ollama,
171
+ url: env.fetch('OLLAMA_URL', 'http://localhost:11434'),
172
+ embedding_model: env.fetch('OLLAMA_EMBEDDING_MODEL', 'nomic-embed-text'),
173
+ chat_model: env.fetch('OLLAMA_CHAT_MODEL', 'llama3.2:3b'))
174
+ if env['OPENAI_API_KEY']
175
+ config.add_provider(
176
+ :openai,
177
+ Prescient::Provider::OpenAI,
178
+ api_key: env['OPENAI_API_KEY'],
179
+ embedding_model: env.fetch('OPENAI_EMBEDDING_MODEL', 'text-embedding-3-small'),
180
+ chat_model: env.fetch('OPENAI_CHAT_MODEL', 'gpt-4.1-mini'),
181
+ )
182
+ end
183
+ if env['ANTHROPIC_API_KEY']
184
+ config.add_provider(
185
+ :anthropic,
186
+ Prescient::Provider::Anthropic,
187
+ api_key: env['ANTHROPIC_API_KEY'],
188
+ model: env.fetch('ANTHROPIC_MODEL', 'claude-sonnet-4-20250514'),
189
+ )
190
+ end
191
+
192
+ return unless env['HUGGINGFACE_API_KEY']
193
+
194
+ config.add_provider(
195
+ :huggingface,
196
+ Prescient::Provider::HuggingFace,
197
+ api_key: env['HUGGINGFACE_API_KEY'],
198
+ embedding_model: env.fetch(
199
+ 'HUGGINGFACE_EMBEDDING_MODEL',
200
+ 'sentence-transformers/all-MiniLM-L6-v2',
201
+ ),
202
+ chat_model: env.fetch(
203
+ 'HUGGINGFACE_CHAT_MODEL',
204
+ 'google/gemma-2-2b-it',
205
+ ),
206
+ )
207
+ end
208
+ end
209
+
171
210
  # Default configuration
172
211
  configure do |config|
173
- config.add_provider(:ollama, Prescient::Provider::Ollama,
174
- url: ENV.fetch('OLLAMA_URL', 'http://localhost:11434'),
175
- embedding_model: ENV.fetch('OLLAMA_EMBEDDING_MODEL', 'nomic-embed-text'),
176
- chat_model: ENV.fetch('OLLAMA_CHAT_MODEL', 'llama3.1:8b'))
177
-
178
- config.add_provider(:anthropic, Prescient::Provider::Anthropic,
179
- api_key: ENV.fetch('ANTHROPIC_API_KEY', nil),
180
- model: ENV.fetch('ANTHROPIC_MODEL', 'claude-3-haiku-20240307'))
181
-
182
- config.add_provider(:openai, Prescient::Provider::OpenAI,
183
- api_key: ENV.fetch('OPENAI_API_KEY', nil),
184
- embedding_model: ENV.fetch('OPENAI_EMBEDDING_MODEL', 'text-embedding-3-small'),
185
- chat_model: ENV.fetch('OPENAI_CHAT_MODEL', 'gpt-3.5-turbo'))
186
-
187
- config.add_provider(:huggingface, Prescient::Provider::HuggingFace,
188
- api_key: ENV.fetch('HUGGINGFACE_API_KEY', nil),
189
- embedding_model: ENV.fetch('HUGGINGFACE_EMBEDDING_MODEL', 'sentence-transformers/all-MiniLM-L6-v2'),
190
- chat_model: ENV.fetch('HUGGINGFACE_CHAT_MODEL', 'microsoft/DialoGPT-medium'))
212
+ configure_default_providers(config, ENV)
191
213
  end
192
214
  end
@@ -5,7 +5,7 @@ set -e
5
5
 
6
6
  OLLAMA_URL=${OLLAMA_URL:-"http://localhost:11434"}
7
7
  EMBEDDING_MODEL=${OLLAMA_EMBEDDING_MODEL:-"nomic-embed-text"}
8
- CHAT_MODEL=${OLLAMA_CHAT_MODEL:-"llama3.1:8b"}
8
+ CHAT_MODEL=${OLLAMA_CHAT_MODEL:-"llama3.2:3b"}
9
9
 
10
10
  echo "🚀 Setting up Ollama models for Prescient gem..."
11
11
  echo "Ollama URL: $OLLAMA_URL"
@@ -74,4 +74,4 @@ main() {
74
74
  echo " OLLAMA_URL=$OLLAMA_URL ruby examples/custom_contexts.rb"
75
75
  }
76
76
 
77
- main "$@"
77
+ main "$@"