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,23 +2,46 @@
2
2
 
3
3
  This guide provides a comprehensive overview of using Prescient with PostgreSQL's pgvector extension for semantic search and similarity matching.
4
4
 
5
+ The runnable companion is [`examples/vector_search.rb`](examples/vector_search.rb);
6
+ see the [examples guide](examples/README.md) for setup and the [main README](README.md)
7
+ for the provider API.
8
+
5
9
  ## Quick Start
6
10
 
11
+ For a reusable embedding store, use the library boundary rather than copying
12
+ the example's application-specific SQL:
13
+
14
+ ```ruby
15
+ require 'prescient'
16
+ require 'pg'
17
+
18
+ store = Prescient::Pgvector::Store.new(
19
+ connection: PG.connect(dbname: 'my_app'),
20
+ dimensions: 1536,
21
+ )
22
+ store.install!
23
+ store.create_index!(metric: :cosine)
24
+ ```
25
+
26
+ `Store` owns only its `prescient_embeddings` table. It does not manage document
27
+ or chunk tables, connections, migrations outside that table, or the `pg` gem.
28
+ Use `#upsert` and `#search` with embeddings of exactly the configured dimension.
29
+
7
30
  ### 1. Start Services
8
31
 
9
32
  ```bash
10
33
  # Start PostgreSQL with pgvector and Ollama
11
- docker-compose up -d postgres ollama
34
+ docker compose up -d postgres ollama
12
35
 
13
36
  # Wait for services to be ready
14
- docker-compose logs -f postgres ollama
37
+ docker compose logs -f postgres ollama
15
38
  ```
16
39
 
17
40
  ### 2. Initialize Models
18
41
 
19
42
  ```bash
20
43
  # Pull required Ollama models
21
- docker-compose up ollama-init
44
+ docker compose run --rm ollama-init
22
45
 
23
46
  # Or manually:
24
47
  ./scripts/setup-ollama-models.sh
@@ -117,7 +140,7 @@ vector_str = "[#{embedding.join(',')}]"
117
140
 
118
141
  db.exec_params(
119
142
  "INSERT INTO document_embeddings (document_id, embedding_provider, embedding_model, embedding_dimensions, embedding, embedding_text) VALUES ($1, $2, $3, $4, $5, $6)",
120
- [document_id, 'ollama', 'nomic-embed-text', 768, vector_str, content]
143
+ [document_id, 'ollama', 'nomic-embed-text', embedding.length, vector_str, content]
121
144
  )
122
145
  ```
123
146
 
@@ -211,7 +234,7 @@ chunks.each do |chunk|
211
234
  # Store chunk embedding
212
235
  db.exec_params(
213
236
  "INSERT INTO chunk_embeddings (chunk_id, document_id, embedding_provider, embedding_model, embedding_dimensions, embedding) VALUES ($1, $2, $3, $4, $5, $6)",
214
- [chunk_id, document_id, 'ollama', 'nomic-embed-text', 768, chunk_vector]
237
+ [chunk_id, document_id, 'ollama', 'nomic-embed-text', chunk_embedding.length, chunk_vector]
215
238
  )
216
239
  end
217
240
  ```
@@ -280,7 +303,7 @@ db.transaction do
280
303
  vector_str = "[#{embedding.join(',')}]"
281
304
  db.exec_params(
282
305
  "INSERT INTO document_embeddings (...) VALUES (...)",
283
- [documents[index].id, 'ollama', 'nomic-embed-text', 768, vector_str, texts[index]]
306
+ [documents[index].id, 'ollama', 'nomic-embed-text', embedding.length, vector_str, texts[index]]
284
307
  )
285
308
  end
286
309
  end
@@ -322,14 +345,15 @@ Store embeddings from multiple providers for comparison:
322
345
 
323
346
  ```ruby
324
347
  providers = [
325
- { client: Prescient.client(:ollama), name: 'ollama', model: 'nomic-embed-text', dims: 768 },
326
- { client: Prescient.client(:openai), name: 'openai', model: 'text-embedding-3-small', dims: 1536 }
348
+ { client: Prescient.client(:ollama), name: 'ollama', model: 'nomic-embed-text' },
349
+ { client: Prescient.client(:openai), name: 'openai', model: 'text-embedding-3-small' }
327
350
  ]
328
351
 
329
352
  providers.each do |provider|
330
353
  next unless provider[:client].available?
331
354
 
332
355
  embedding = provider[:client].generate_embedding(text)
356
+ provider[:dims] = embedding.length
333
357
  vector_str = "[#{embedding.join(',')}]"
334
358
 
335
359
  db.exec_params(
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.1:8b\"}"
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.1:8b
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
@@ -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).
@@ -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.1:8b',
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!"
@@ -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.1:8b',
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.1:8b',
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.1:8b',
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.1:8b',
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.1:8b'
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"
@@ -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.1:8b',
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.1:8b',
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.1:8b',
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.1:8b',
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"
@@ -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-compose up -d"
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/exe/prescient ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'prescient'
5
+ require 'prescient/cli'
6
+
7
+ exit Prescient::CLI.run(ARGV)
@@ -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 {#validate_configuration!}
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, :provider keys and optional details
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
- # @return [Boolean] true if provider is healthy and available
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
- health_check[:status] == 'healthy'
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
- # Normalize embedding dimensions to match expected size
150
+ # Validate embedding dimensions against the configured model dimension.
139
151
  #
140
- # Ensures embedding vectors have consistent dimensions by truncating
141
- # longer vectors or padding shorter ones with zeros.
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 normalize
144
- # @param target_dimensions [Integer] The desired number of dimensions
145
- # @return [Array<Float>, nil] Normalized embedding or nil if input invalid
146
- def normalize_embedding(embedding, target_dimensions)
147
- return nil unless embedding.is_a?(Array)
148
- return embedding.first(target_dimensions) if embedding.length >= target_dimensions
149
-
150
- embedding + Array.new(target_dimensions - embedding.length, 0.0)
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 enforces length
156
- # limits suitable for most AI models.
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: [], # Will be dynamically determined from item keys
249
+ fields: fields, # Will be dynamically determined from item keys
231
250
  format: nil, # Will use fallback formatting
232
- embedding_fields: [], # Will use all string/text 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
- # TODO: configurable fields to exclude aside from the common ones below
250
- exclude_fields = ['id', '_id', 'uuid', 'created_at', 'updated_at', 'timestamp', 'version', 'status', 'active']
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