prescient 0.2.0 → 0.4.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[: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)
@@ -42,7 +55,7 @@ class Prescient::Provider::Ollama < Prescient::Base
42
55
 
43
56
  {
44
57
  response: generated_text.strip,
45
- model: @options[:chat_model],
58
+ model: options[:model] || @options[:chat_model],
46
59
  provider: 'ollama',
47
60
  processing_time: response.parsed_response['total_duration']&./(1_000_000_000.0),
48
61
  metadata: {
@@ -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',
@@ -130,7 +158,7 @@ class Prescient::Provider::Ollama < Prescient::Base
130
158
  { root_key: 'response',
131
159
  headers: { 'Content-Type' => 'application/json' },
132
160
  body: {
133
- model: @options[:chat_model],
161
+ model: options[:model] || @options[:chat_model],
134
162
  prompt: formatted_prompt,
135
163
  stream: false,
136
164
  options: {
@@ -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,17 +20,21 @@ class Prescient::Provider::OpenAI < Prescient::Base
18
20
  self.class.default_timeout(@options[:timeout] || 60)
19
21
  end
20
22
 
21
- def generate_embedding(text, **_options)
23
+ # Generate an embedding through the OpenAI embeddings API.
24
+ # @param text [String] Text to embed
25
+ # @return [Array<Float>] Embedding vector
26
+ def generate_embedding(text, **options)
22
27
  handle_errors do
23
28
  clean_text_input = clean_text(text)
24
29
 
30
+ embedding_model = options[:model] || @options[:embedding_model]
25
31
  response = self.class.post('/v1/embeddings',
26
32
  headers: {
27
33
  'Content-Type' => 'application/json',
28
34
  'Authorization' => "Bearer #{@options[:api_key]}",
29
35
  },
30
36
  body: {
31
- model: @options[:embedding_model],
37
+ model: embedding_model,
32
38
  input: clean_text_input,
33
39
  encoding_format: 'float',
34
40
  }.to_json)
@@ -38,11 +44,20 @@ class Prescient::Provider::OpenAI < Prescient::Base
38
44
  embedding_data = response.parsed_response.dig('data', 0, 'embedding')
39
45
  raise Prescient::InvalidResponseError, 'No embedding returned' unless embedding_data
40
46
 
41
- expected_dimensions = EMBEDDING_DIMENSIONS[@options[:embedding_model]] || 1536
42
- normalize_embedding(embedding_data, expected_dimensions)
47
+ expected_dimensions = EMBEDDING_DIMENSIONS[embedding_model] || @options[:embedding_dimensions]
48
+ unless expected_dimensions
49
+ raise Prescient::Error,
50
+ "Embedding dimensions are required for model #{embedding_model}"
51
+ end
52
+
53
+ validate_embedding_dimensions(embedding_data, expected_dimensions)
43
54
  end
44
55
  end
45
56
 
57
+ # Generate a response through the OpenAI chat completions API.
58
+ # @param prompt [String] Prompt to send
59
+ # @param context_items [Array<Hash, String>] Optional context items
60
+ # @return [Hash] Normalized response data
46
61
  def generate_response(prompt, context_items = [], **options)
47
62
  handle_errors do
48
63
  formatted_prompt = build_prompt(prompt, context_items)
@@ -53,7 +68,7 @@ class Prescient::Provider::OpenAI < Prescient::Base
53
68
  'Authorization' => "Bearer #{@options[:api_key]}",
54
69
  },
55
70
  body: {
56
- model: @options[:chat_model],
71
+ model: options[:model] || @options[:chat_model],
57
72
  messages: [
58
73
  {
59
74
  role: 'user',
@@ -72,7 +87,7 @@ class Prescient::Provider::OpenAI < Prescient::Base
72
87
 
73
88
  {
74
89
  response: content.strip,
75
- model: @options[:chat_model],
90
+ model: options[:model] || @options[:chat_model],
76
91
  provider: 'openai',
77
92
  processing_time: nil,
78
93
  metadata: {
@@ -83,6 +98,12 @@ class Prescient::Provider::OpenAI < Prescient::Base
83
98
  end
84
99
  end
85
100
 
101
+ # Check OpenAI model availability via `/v1/models`.
102
+ #
103
+ # `reachable` indicates the API answered successfully. `ready` indicates that
104
+ # both configured models appear in the returned model list.
105
+ #
106
+ # @return [Hash] Provider health information
86
107
  def health_check
87
108
  handle_errors do
88
109
  response = self.class.get('/v1/models',
@@ -98,6 +119,7 @@ class Prescient::Provider::OpenAI < Prescient::Base
98
119
  {
99
120
  status: 'healthy',
100
121
  provider: 'openai',
122
+ reachable: true,
101
123
  models_available: models.map { |m| m['id'] },
102
124
  embedding_model: {
103
125
  name: @options[:embedding_model],
@@ -111,22 +133,29 @@ class Prescient::Provider::OpenAI < Prescient::Base
111
133
  }
112
134
  else
113
135
  {
114
- status: 'unhealthy',
115
- provider: 'openai',
116
- error: "HTTP #{response.code}",
117
- message: response.message,
136
+ status: 'unhealthy',
137
+ provider: 'openai',
138
+ reachable: true,
139
+ error: "HTTP #{response.code}",
140
+ message: response.message,
141
+ ready: false,
118
142
  }
119
143
  end
120
144
  end
121
145
  rescue Prescient::Error => e
122
146
  {
123
- status: 'unavailable',
124
- provider: 'openai',
125
- error: e.class.name,
126
- message: e.message,
147
+ status: 'unavailable',
148
+ provider: 'openai',
149
+ reachable: false,
150
+ error: e.class.name,
151
+ message: e.message,
152
+ ready: false,
127
153
  }
128
154
  end
129
155
 
156
+ # List models available to the configured OpenAI account.
157
+ #
158
+ # @return [Array<Hash>] Model descriptors
130
159
  def list_models
131
160
  handle_errors do
132
161
  response = self.class.get('/v1/models',
@@ -157,25 +186,4 @@ class Prescient::Provider::OpenAI < Prescient::Base
157
186
  raise Prescient::Error, "Missing required options: #{missing_options.join(', ')}"
158
187
  end
159
188
 
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
189
  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.4.0'
5
6
  end
data/lib/prescient.rb CHANGED
@@ -1,11 +1,20 @@
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'
12
+ require_relative 'prescient/cli'
4
13
 
5
14
  # Main Prescient module for AI provider abstraction
6
15
  #
7
16
  # Prescient provides a unified interface for working with multiple AI providers
8
- # including Ollama, OpenAI, Anthropic, and HuggingFace. It supports both
17
+ # including Ollama, OpenAI, Anthropic, and Hugging Face. It supports both
9
18
  # embedding generation and text completion with configurable context handling.
10
19
  #
11
20
  # @example Basic usage
@@ -20,44 +29,6 @@ require_relative 'prescient/version'
20
29
  # @example Embedding generation
21
30
  # embedding = client.generate_embedding("Some text to embed")
22
31
  # 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
32
  module Prescient
62
33
  # Configure Prescient with custom settings and providers
63
34
  #
@@ -95,6 +66,9 @@ module Prescient
95
66
  # Handles global settings like timeouts and retry behavior, as well as
96
67
  # provider registration and instantiation.
97
68
  class Configuration
69
+ # @return [Array<Symbol>] Built-in provider option keys removed from output
70
+ DEFAULT_SENSITIVE_KEYS = [:api_key, :password, :token, :secret].freeze
71
+
98
72
  # @return [Symbol] The default provider to use when none specified
99
73
  attr_accessor :default_provider
100
74
 
@@ -110,6 +84,9 @@ module Prescient
110
84
  # @return [Array<Symbol>] List of fallback providers to try when primary fails
111
85
  attr_accessor :fallback_providers
112
86
 
87
+ # @return [Array<Symbol>] Additional keys removed from provider information
88
+ attr_reader :sensitive_keys
89
+
113
90
  # @return [Hash] Registered providers configuration
114
91
  attr_reader :providers
115
92
 
@@ -120,7 +97,18 @@ module Prescient
120
97
  @retry_attempts = 3
121
98
  @retry_delay = 1.0
122
99
  @fallback_providers = []
100
+ @sensitive_keys = []
123
101
  @providers = {}
102
+ @provider_instances = {} # : Hash[Symbol, untyped]
103
+ end
104
+
105
+ # Configure additional keys to remove from provider information.
106
+ # Built-in sensitive keys are always sanitized.
107
+ #
108
+ # @param keys [Array<Symbol, String>] Additional sensitive option keys
109
+ # @return [Array<Symbol>] Normalized additional keys
110
+ def sensitive_keys=(keys)
111
+ @sensitive_keys = Array(keys).map(&:to_sym).uniq
124
112
  end
125
113
 
126
114
  # Register a new AI provider
@@ -137,12 +125,14 @@ module Prescient
137
125
  # @example Add OpenAI provider
138
126
  # config.add_provider(:openai, Prescient::Provider::OpenAI,
139
127
  # api_key: 'sk-...',
140
- # chat_model: 'gpt-4')
128
+ # chat_model: 'gpt-4.1-mini')
141
129
  def add_provider(name, provider_class, **options)
142
- @providers[name.to_sym] = {
130
+ provider_name = name.to_sym
131
+ @providers[provider_name] = {
143
132
  class: provider_class,
144
133
  options: options,
145
134
  }
135
+ @provider_instances.delete(provider_name)
146
136
  end
147
137
 
148
138
  # Instantiate a provider by name
@@ -150,15 +140,20 @@ module Prescient
150
140
  # @param name [Symbol] The provider name
151
141
  # @return [Base, nil] Provider instance or nil if not found
152
142
  def provider(name)
153
- provider_config = @providers[name.to_sym]
143
+ provider_name = name.to_sym
144
+ provider_config = @providers[provider_name]
154
145
  return nil unless provider_config
155
146
 
156
- provider_config[:class].new(**provider_config[:options])
147
+ provider_options = provider_config[:options] # : Hash[Symbol, untyped]
148
+ @provider_instances[provider_name] ||= provider_config[:class].new(**provider_options)
157
149
  end
158
150
 
159
- # Get list of available providers (those that are configured and healthy)
151
+ # Get list of providers that currently pass {Prescient::Base#available?}.
152
+ #
153
+ # Providers are included when their health check reports `reachable: true`,
154
+ # or, for legacy adapters, `status == "healthy"`.
160
155
  #
161
- # @return [Array<Symbol>] List of available provider names
156
+ # @return [Array<Symbol>] List of reachable provider names
162
157
  def available_providers
163
158
  @providers.keys.select do |name|
164
159
  provider(name)&.available?
@@ -168,25 +163,53 @@ module Prescient
168
163
  end
169
164
  end
170
165
 
166
+ class << self
167
+ private
168
+
169
+ def configure_default_providers(config, env)
170
+ config.add_provider(:ollama,
171
+ Prescient::Provider::Ollama,
172
+ url: env.fetch('OLLAMA_URL', 'http://localhost:11434'),
173
+ embedding_model: env.fetch('OLLAMA_EMBEDDING_MODEL', 'nomic-embed-text'),
174
+ chat_model: env.fetch('OLLAMA_CHAT_MODEL', 'llama3.2:3b'))
175
+ if env['OPENAI_API_KEY']
176
+ config.add_provider(
177
+ :openai,
178
+ Prescient::Provider::OpenAI,
179
+ api_key: env['OPENAI_API_KEY'],
180
+ embedding_model: env.fetch('OPENAI_EMBEDDING_MODEL', 'text-embedding-3-small'),
181
+ chat_model: env.fetch('OPENAI_CHAT_MODEL', 'gpt-4.1-mini'),
182
+ )
183
+ end
184
+ if env['ANTHROPIC_API_KEY']
185
+ config.add_provider(
186
+ :anthropic,
187
+ Prescient::Provider::Anthropic,
188
+ api_key: env['ANTHROPIC_API_KEY'],
189
+ model: env.fetch('ANTHROPIC_MODEL', 'claude-sonnet-4-20250514'),
190
+ )
191
+ end
192
+
193
+ return unless env['HUGGINGFACE_API_KEY']
194
+
195
+ config.add_provider(
196
+ :huggingface,
197
+ Prescient::Provider::HuggingFace,
198
+ api_key: env['HUGGINGFACE_API_KEY'],
199
+ embedding_model: env.fetch(
200
+ 'HUGGINGFACE_EMBEDDING_MODEL',
201
+ 'sentence-transformers/all-MiniLM-L6-v2',
202
+ ),
203
+ chat_model: env.fetch(
204
+ 'HUGGINGFACE_CHAT_MODEL',
205
+ 'google/gemma-2-2b-it',
206
+ ),
207
+ )
208
+ end
209
+ end
210
+
171
211
  # Default configuration
172
212
  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'))
213
+ configure_default_providers(config, ENV)
191
214
  end
192
215
  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 "$@"