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.
@@ -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.1.0'
4
+ # Current Prescient gem version.
5
+ VERSION = '0.3.0'
5
6
  end
data/lib/prescient.rb CHANGED
@@ -1,20 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'prescient/version'
4
-
5
- module Prescient
6
- class Error < StandardError; end
7
- class ConnectionError < Error; end
8
- class AuthenticationError < Error; end
9
- class RateLimitError < Error; end
10
- class ModelNotAvailableError < Error; end
11
- class InvalidResponseError < Error; end
12
-
13
- module Provider
14
- # Module for AI provider implementations
15
- end
16
- end
17
-
4
+ require_relative 'prescient/errors'
5
+ require_relative 'prescient/pgvector'
18
6
  require_relative 'prescient/base'
19
7
  require_relative 'prescient/provider/ollama'
20
8
  require_relative 'prescient/provider/anthropic'
@@ -22,69 +10,205 @@ require_relative 'prescient/provider/openai'
22
10
  require_relative 'prescient/provider/huggingface'
23
11
  require_relative 'prescient/client'
24
12
 
13
+ # Main Prescient module for AI provider abstraction
14
+ #
15
+ # Prescient provides a unified interface for working with multiple AI providers
16
+ # including Ollama, OpenAI, Anthropic, and Hugging Face. It supports both
17
+ # embedding generation and text completion with configurable context handling.
18
+ #
19
+ # @example Basic usage
20
+ # Prescient.configure do |config|
21
+ # config.add_provider(:openai, Prescient::Provider::OpenAI,
22
+ # api_key: 'your-api-key')
23
+ # end
24
+ #
25
+ # client = Prescient.client(:openai)
26
+ # response = client.generate_response("Hello, world!")
27
+ #
28
+ # @example Embedding generation
29
+ # embedding = client.generate_embedding("Some text to embed")
30
+ # puts embedding.length # => 1536 (for OpenAI text-embedding-3-small)
25
31
  module Prescient
26
- # Configure the gem
32
+ # Configure Prescient with custom settings and providers
33
+ #
34
+ # @example Configure with custom provider
35
+ # Prescient.configure do |config|
36
+ # config.default_provider = :openai
37
+ # config.timeout = 60
38
+ # config.add_provider(:openai, Prescient::Provider::OpenAI,
39
+ # api_key: 'your-key')
40
+ # end
41
+ #
42
+ # @yield [config] Configuration block
43
+ # @yieldparam config [Configuration] The configuration object
44
+ # @return [void]
27
45
  def self.configure
28
46
  yield(configuration)
29
47
  end
30
48
 
49
+ # Get the current configuration instance
50
+ #
51
+ # @return [Configuration] The current configuration
31
52
  def self.configuration
32
53
  @_configuration ||= Configuration.new
33
54
  end
34
55
 
56
+ # Reset configuration to defaults
57
+ #
58
+ # @return [Configuration] New configuration instance
35
59
  def self.reset_configuration!
36
60
  @_configuration = Configuration.new
37
61
  end
38
62
 
63
+ # Configuration class for managing Prescient settings and providers
64
+ #
65
+ # Handles global settings like timeouts and retry behavior, as well as
66
+ # provider registration and instantiation.
39
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
+
71
+ # @return [Symbol] The default provider to use when none specified
40
72
  attr_accessor :default_provider
73
+
74
+ # @return [Integer] Default timeout in seconds for API requests
41
75
  attr_accessor :timeout
76
+
77
+ # @return [Integer] Number of retry attempts for failed requests
42
78
  attr_accessor :retry_attempts
79
+
80
+ # @return [Float] Delay between retry attempts in seconds
43
81
  attr_accessor :retry_delay
82
+
83
+ # @return [Array<Symbol>] List of fallback providers to try when primary fails
84
+ attr_accessor :fallback_providers
85
+
86
+ # @return [Array<Symbol>] Additional keys removed from provider information
87
+ attr_reader :sensitive_keys
88
+
89
+ # @return [Hash] Registered providers configuration
44
90
  attr_reader :providers
45
91
 
92
+ # Initialize configuration with default values
46
93
  def initialize
47
94
  @default_provider = :ollama
48
95
  @timeout = 30
49
96
  @retry_attempts = 3
50
97
  @retry_delay = 1.0
98
+ @fallback_providers = []
99
+ @sensitive_keys = []
51
100
  @providers = {}
101
+ @provider_instances = {} # : Hash[Symbol, untyped]
52
102
  end
53
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
111
+ end
112
+
113
+ # Register a new AI provider
114
+ #
115
+ # @param name [Symbol] Unique identifier for the provider
116
+ # @param provider_class [Class] Provider class that inherits from Base
117
+ # @param options [Hash] Configuration options for the provider
118
+ # @option options [String] :api_key API key for authenticated providers
119
+ # @option options [String] :url Base URL for self-hosted providers
120
+ # @option options [String] :model, :chat_model Model name for text generation
121
+ # @option options [String] :embedding_model Model name for embeddings
122
+ # @return [void]
123
+ #
124
+ # @example Add OpenAI provider
125
+ # config.add_provider(:openai, Prescient::Provider::OpenAI,
126
+ # api_key: 'sk-...',
127
+ # chat_model: 'gpt-4.1-mini')
54
128
  def add_provider(name, provider_class, **options)
55
- @providers[name.to_sym] = {
129
+ provider_name = name.to_sym
130
+ @providers[provider_name] = {
56
131
  class: provider_class,
57
132
  options: options,
58
133
  }
134
+ @provider_instances.delete(provider_name)
59
135
  end
60
136
 
137
+ # Instantiate a provider by name
138
+ #
139
+ # @param name [Symbol] The provider name
140
+ # @return [Base, nil] Provider instance or nil if not found
61
141
  def provider(name)
62
- provider_config = @providers[name.to_sym]
142
+ provider_name = name.to_sym
143
+ provider_config = @providers[provider_name]
63
144
  return nil unless provider_config
64
145
 
65
- 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)
148
+ end
149
+
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"`.
154
+ #
155
+ # @return [Array<Symbol>] List of reachable provider names
156
+ def available_providers
157
+ @providers.keys.select do |name|
158
+ provider(name)&.available?
159
+ rescue StandardError
160
+ false
161
+ end
162
+ end
163
+ end
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
+ )
66
207
  end
67
208
  end
68
209
 
69
210
  # Default configuration
70
211
  configure do |config|
71
- config.add_provider(:ollama, Prescient::Provider::Ollama,
72
- url: ENV.fetch('OLLAMA_URL', 'http://localhost:11434'),
73
- embedding_model: ENV.fetch('OLLAMA_EMBEDDING_MODEL', 'nomic-embed-text'),
74
- chat_model: ENV.fetch('OLLAMA_CHAT_MODEL', 'llama3.1:8b'))
75
-
76
- config.add_provider(:anthropic, Prescient::Provider::Anthropic,
77
- api_key: ENV.fetch('ANTHROPIC_API_KEY', nil),
78
- model: ENV.fetch('ANTHROPIC_MODEL', 'claude-3-haiku-20240307'))
79
-
80
- config.add_provider(:openai, Prescient::Provider::OpenAI,
81
- api_key: ENV.fetch('OPENAI_API_KEY', nil),
82
- embedding_model: ENV.fetch('OPENAI_EMBEDDING_MODEL', 'text-embedding-3-small'),
83
- chat_model: ENV.fetch('OPENAI_CHAT_MODEL', 'gpt-3.5-turbo'))
84
-
85
- config.add_provider(:huggingface, Prescient::Provider::HuggingFace,
86
- api_key: ENV.fetch('HUGGINGFACE_API_KEY', nil),
87
- embedding_model: ENV.fetch('HUGGINGFACE_EMBEDDING_MODEL', 'sentence-transformers/all-MiniLM-L6-v2'),
88
- chat_model: ENV.fetch('HUGGINGFACE_CHAT_MODEL', 'microsoft/DialoGPT-medium'))
212
+ configure_default_providers(config, ENV)
89
213
  end
90
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 "$@"