prescient 0.0.0 → 0.1.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.
@@ -0,0 +1,330 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Example: Vector similarity search with Prescient gem and PostgreSQL pgvector
5
+ # This example demonstrates how to store embeddings and perform similarity search
6
+
7
+ require_relative '../lib/prescient'
8
+ require 'pg'
9
+ require 'json'
10
+
11
+ puts "=== Vector Similarity Search Example ==="
12
+ puts "This example shows how to use Prescient with PostgreSQL pgvector for semantic search."
13
+
14
+ # Database connection configuration
15
+ DB_CONFIG = {
16
+ host: ENV.fetch('DB_HOST', 'localhost'),
17
+ port: ENV.fetch('DB_PORT', '5432'),
18
+ dbname: ENV.fetch('DB_NAME', 'prescient_development'),
19
+ user: ENV.fetch('DB_USER', 'prescient'),
20
+ password: ENV.fetch('DB_PASSWORD', 'prescient_password')
21
+ }.freeze
22
+
23
+ class VectorSearchExample
24
+ def initialize
25
+ @db = PG.connect(DB_CONFIG)
26
+ @client = Prescient.client(:ollama)
27
+ end
28
+
29
+ def run_example
30
+ puts "\n--- Setting up vector search example ---"
31
+
32
+ # Check if services are available
33
+ unless check_services_available
34
+ puts "āŒ Required services not available. Please start with: docker-compose up -d"
35
+ return
36
+ end
37
+
38
+ # 1. Generate and store embeddings for existing documents
39
+ puts "\nšŸ“Š Generating embeddings for sample documents..."
40
+ generate_document_embeddings
41
+
42
+ # 2. Perform similarity search
43
+ puts "\nšŸ” Performing similarity searches..."
44
+ search_examples
45
+
46
+ # 3. Advanced search with filtering
47
+ puts "\nšŸŽÆ Advanced search with metadata filtering..."
48
+ advanced_search_examples
49
+
50
+ # 4. Demonstrate different distance functions
51
+ puts "\nšŸ“ Comparing different distance functions..."
52
+ compare_distance_functions
53
+
54
+ puts "\nšŸŽ‰ Vector search example completed!"
55
+ end
56
+
57
+ private
58
+
59
+ def check_services_available
60
+ # Check database connection
61
+ begin
62
+ result = @db.exec("SELECT 1")
63
+ puts "āœ… PostgreSQL connected"
64
+ rescue PG::Error => e
65
+ puts "āŒ PostgreSQL connection failed: #{e.message}"
66
+ return false
67
+ end
68
+
69
+ # Check pgvector extension
70
+ begin
71
+ result = @db.exec("SELECT * FROM pg_extension WHERE extname = 'vector'")
72
+ if result.ntuples > 0
73
+ puts "āœ… pgvector extension available"
74
+ else
75
+ puts "āŒ pgvector extension not found"
76
+ return false
77
+ end
78
+ rescue PG::Error => e
79
+ puts "āŒ pgvector check failed: #{e.message}"
80
+ return false
81
+ end
82
+
83
+ # Check Ollama connection
84
+ if @client.available?
85
+ puts "āœ… Ollama connected"
86
+ else
87
+ puts "āŒ Ollama not available"
88
+ return false
89
+ end
90
+
91
+ true
92
+ end
93
+
94
+ def generate_document_embeddings
95
+ # Get documents that don't have embeddings yet
96
+ query = <<~SQL
97
+ SELECT d.id, d.title, d.content
98
+ FROM documents d
99
+ LEFT JOIN document_embeddings de ON d.id = de.document_id
100
+ AND de.embedding_provider = 'ollama'
101
+ AND de.embedding_model = 'nomic-embed-text'
102
+ WHERE de.id IS NULL
103
+ LIMIT 10
104
+ SQL
105
+
106
+ result = @db.exec(query)
107
+
108
+ if result.ntuples == 0
109
+ puts " All documents already have embeddings"
110
+ return
111
+ end
112
+
113
+ result.each do |row|
114
+ document_id = row['id']
115
+ title = row['title']
116
+ content = row['content']
117
+
118
+ puts " Generating embedding for: #{title}"
119
+
120
+ begin
121
+ # Generate embedding using Prescient
122
+ embedding = @client.generate_embedding(content)
123
+
124
+ # Store in database
125
+ insert_embedding(document_id, embedding, content, 'ollama', 'nomic-embed-text', 768)
126
+
127
+ puts " āœ… Stored embedding (#{embedding.length} dimensions)"
128
+
129
+ rescue Prescient::Error => e
130
+ puts " āŒ Failed to generate embedding: #{e.message}"
131
+ end
132
+ end
133
+ end
134
+
135
+ def insert_embedding(document_id, embedding, text, provider, model, dimensions)
136
+ # Convert Ruby array to PostgreSQL vector format
137
+ vector_str = "[#{embedding.join(',')}]"
138
+
139
+ query = <<~SQL
140
+ INSERT INTO document_embeddings
141
+ (document_id, embedding_provider, embedding_model, embedding_dimensions, embedding, embedding_text)
142
+ VALUES ($1, $2, $3, $4, $5, $6)
143
+ SQL
144
+
145
+ @db.exec_params(query, [document_id, provider, model, dimensions, vector_str, text])
146
+ end
147
+
148
+ def search_examples
149
+ search_queries = [
150
+ "How to learn programming?",
151
+ "What is machine learning?",
152
+ "Database optimization techniques",
153
+ "API security best practices"
154
+ ]
155
+
156
+ search_queries.each do |query_text|
157
+ puts "\nšŸ” Searching for: '#{query_text}'"
158
+ perform_similarity_search(query_text, limit: 3)
159
+ end
160
+ end
161
+
162
+ def perform_similarity_search(query_text, limit: 5, distance_function: 'cosine')
163
+ begin
164
+ # Generate embedding for query
165
+ query_embedding = @client.generate_embedding(query_text)
166
+ query_vector = "[#{query_embedding.join(',')}]"
167
+
168
+ # Choose distance operator based on function
169
+ distance_op = case distance_function
170
+ when 'cosine' then '<=>'
171
+ when 'l2' then '<->'
172
+ when 'inner_product' then '<#>'
173
+ else '<=>'
174
+ end
175
+
176
+ # Perform similarity search
177
+ search_query = <<~SQL
178
+ SELECT
179
+ d.title,
180
+ d.content,
181
+ d.metadata,
182
+ de.embedding #{distance_op} $1::vector AS distance,
183
+ 1 - (de.embedding <=> $1::vector) AS cosine_similarity
184
+ FROM documents d
185
+ JOIN document_embeddings de ON d.id = de.document_id
186
+ WHERE de.embedding_provider = 'ollama'
187
+ AND de.embedding_model = 'nomic-embed-text'
188
+ ORDER BY de.embedding #{distance_op} $1::vector
189
+ LIMIT $2
190
+ SQL
191
+
192
+ result = @db.exec_params(search_query, [query_vector, limit])
193
+
194
+ if result.ntuples == 0
195
+ puts " No results found"
196
+ return
197
+ end
198
+
199
+ result.each_with_index do |row, index|
200
+ similarity = (row['cosine_similarity'].to_f * 100).round(1)
201
+ puts " #{index + 1}. #{row['title']} (#{similarity}% similar)"
202
+ puts " #{row['content'][0..100]}..."
203
+
204
+ # Show metadata if available
205
+ if row['metadata'] && !row['metadata'].empty?
206
+ metadata = JSON.parse(row['metadata'])
207
+ tags = metadata['tags']&.join(', ')
208
+ puts " Tags: #{tags}" if tags
209
+ end
210
+ puts
211
+ end
212
+
213
+ rescue Prescient::Error => e
214
+ puts " āŒ Search failed: #{e.message}"
215
+ rescue PG::Error => e
216
+ puts " āŒ Database error: #{e.message}"
217
+ end
218
+ end
219
+
220
+ def advanced_search_examples
221
+ # Search with metadata filtering
222
+ puts "\nšŸŽÆ Search for programming content with beginner difficulty:"
223
+ advanced_search("programming basics", tags: ["programming"], difficulty: "beginner")
224
+
225
+ puts "\nšŸŽÆ Search for AI/ML content:"
226
+ advanced_search("artificial intelligence", tags: ["ai", "machine-learning"])
227
+ end
228
+
229
+ def advanced_search(query_text, filters = {})
230
+ begin
231
+ query_embedding = @client.generate_embedding(query_text)
232
+ query_vector = "[#{query_embedding.join(',')}]"
233
+
234
+ # Build WHERE clause for metadata filtering
235
+ where_conditions = ["de.embedding_provider = 'ollama'", "de.embedding_model = 'nomic-embed-text'"]
236
+ params = [query_vector]
237
+ param_index = 2
238
+
239
+ filters.each do |key, value|
240
+ case key
241
+ when :tags
242
+ # Filter by tags array overlap
243
+ where_conditions << "d.metadata->'tags' ?| $#{param_index}::text[]"
244
+ params << value
245
+ param_index += 1
246
+ when :difficulty
247
+ # Filter by exact difficulty match
248
+ where_conditions << "d.metadata->>'difficulty' = $#{param_index}"
249
+ params << value
250
+ param_index += 1
251
+ when :source_type
252
+ # Filter by source type
253
+ where_conditions << "d.source_type = $#{param_index}"
254
+ params << value
255
+ param_index += 1
256
+ end
257
+ end
258
+
259
+ search_query = <<~SQL
260
+ SELECT
261
+ d.title,
262
+ d.content,
263
+ d.metadata,
264
+ de.embedding <=> $1::vector AS cosine_distance,
265
+ 1 - (de.embedding <=> $1::vector) AS cosine_similarity
266
+ FROM documents d
267
+ JOIN document_embeddings de ON d.id = de.document_id
268
+ WHERE #{where_conditions.join(' AND ')}
269
+ ORDER BY de.embedding <=> $1::vector
270
+ LIMIT 3
271
+ SQL
272
+
273
+ result = @db.exec_params(search_query, params)
274
+
275
+ if result.ntuples == 0
276
+ puts " No results found with the specified filters"
277
+ return
278
+ end
279
+
280
+ result.each_with_index do |row, index|
281
+ similarity = (row['cosine_similarity'].to_f * 100).round(1)
282
+ puts " #{index + 1}. #{row['title']} (#{similarity}% similar)"
283
+
284
+ metadata = JSON.parse(row['metadata'])
285
+ puts " Difficulty: #{metadata['difficulty']}"
286
+ puts " Tags: #{metadata['tags']&.join(', ')}"
287
+ puts " #{row['content'][0..80]}..."
288
+ puts
289
+ end
290
+
291
+ rescue Prescient::Error => e
292
+ puts " āŒ Search failed: #{e.message}"
293
+ rescue PG::Error => e
294
+ puts " āŒ Database error: #{e.message}"
295
+ end
296
+ end
297
+
298
+ def compare_distance_functions
299
+ query_text = "programming languages and development"
300
+
301
+ puts "\nšŸ“ Comparing distance functions for: '#{query_text}'"
302
+
303
+ %w[cosine l2 inner_product].each do |distance_func|
304
+ puts "\n #{distance_func.upcase} Distance:"
305
+ perform_similarity_search(query_text, limit: 2, distance_function: distance_func)
306
+ end
307
+ end
308
+
309
+ def cleanup
310
+ @db.close if @db
311
+ end
312
+ end
313
+
314
+ # Run the example
315
+ begin
316
+ example = VectorSearchExample.new
317
+ example.run_example
318
+ rescue StandardError => e
319
+ puts "āŒ Example failed: #{e.message}"
320
+ puts e.backtrace.first(5).join("\n")
321
+ ensure
322
+ example&.cleanup
323
+ end
324
+
325
+ puts "\nšŸ’” Next steps:"
326
+ puts " - Try different embedding models (OpenAI, HuggingFace)"
327
+ puts " - Implement hybrid search (vector + keyword)"
328
+ puts " - Add document chunking for large texts"
329
+ puts " - Experiment with different similarity thresholds"
330
+ puts " - Add result re-ranking and filtering"
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Prescient::Base
4
+ attr_reader :options
5
+
6
+ def initialize(**options)
7
+ @options = options
8
+ validate_configuration!
9
+ end
10
+
11
+ # Abstract methods that must be implemented by subclasses
12
+ def generate_embedding(text)
13
+ raise NotImplementedError, "#{self.class} must implement #generate_embedding"
14
+ end
15
+
16
+ def generate_response(prompt, context_items = [], **options)
17
+ raise NotImplementedError, "#{self.class} must implement #generate_response"
18
+ end
19
+
20
+ def health_check
21
+ raise NotImplementedError, "#{self.class} must implement #health_check"
22
+ end
23
+
24
+ def available?
25
+ health_check[:status] == 'healthy'
26
+ rescue StandardError
27
+ false
28
+ end
29
+
30
+ protected
31
+
32
+ def validate_configuration!
33
+ # Override in subclasses to validate required configuration
34
+ end
35
+
36
+ def handle_errors
37
+ yield
38
+ rescue Prescient::Error
39
+ # Re-raise Prescient errors without wrapping
40
+ raise
41
+ rescue Net::ReadTimeout, Net::OpenTimeout => e
42
+ raise Prescient::ConnectionError, "Request timeout: #{e.message}"
43
+ rescue Net::HTTPError => e
44
+ raise Prescient::ConnectionError, "HTTP error: #{e.message}"
45
+ rescue JSON::ParserError => e
46
+ raise Prescient::InvalidResponseError, "Invalid JSON response: #{e.message}"
47
+ rescue StandardError => e
48
+ raise Prescient::Error, "Unexpected error: #{e.message}"
49
+ end
50
+
51
+ def normalize_embedding(embedding, target_dimensions)
52
+ return nil unless embedding.is_a?(Array)
53
+ return embedding if embedding.length == target_dimensions
54
+
55
+ if embedding.length > target_dimensions
56
+ # Truncate
57
+ embedding.first(target_dimensions)
58
+ else
59
+ # Pad with zeros
60
+ embedding + Array.new(target_dimensions - embedding.length, 0.0)
61
+ end
62
+ end
63
+
64
+ def clean_text(text)
65
+ return '' if text.nil? || text.to_s.strip.empty?
66
+
67
+ cleaned = text.to_s
68
+ .strip
69
+ .gsub(/\s+/, ' ')
70
+
71
+ # Limit length for most models
72
+ cleaned.length > 8000 ? cleaned[0, 8000] : cleaned
73
+ end
74
+
75
+ # Default prompt templates - can be overridden in provider options
76
+ def default_prompt_templates
77
+ {
78
+ system_prompt: 'You are a helpful AI assistant. Answer questions clearly and accurately.',
79
+ no_context_template: <<~TEMPLATE.strip,
80
+ %<system_prompt>s
81
+
82
+ Question: %<query>s
83
+
84
+ Please provide a helpful response based on your knowledge.
85
+ TEMPLATE
86
+ with_context_template: <<~TEMPLATE.strip,
87
+ %<system_prompt>s Use the following context to answer the question. If the context doesn't contain relevant information, say so clearly.
88
+
89
+ Context:
90
+ %<context>s
91
+
92
+ Question: %<query>s
93
+
94
+ Please provide a helpful response based on the context above.
95
+ TEMPLATE
96
+ }
97
+ end
98
+
99
+ # Build prompt using configurable templates
100
+ def build_prompt(query, context_items = [])
101
+ templates = default_prompt_templates.merge(@options[:prompt_templates] || {})
102
+ system_prompt = templates[:system_prompt]
103
+
104
+ if context_items.empty?
105
+ templates[:no_context_template] % {
106
+ system_prompt: system_prompt,
107
+ query: query,
108
+ }
109
+ else
110
+ context_text = context_items.map.with_index(1) { |item, index|
111
+ "#{index}. #{format_context_item(item)}"
112
+ }.join("\n\n")
113
+
114
+ templates[:with_context_template] % {
115
+ system_prompt: system_prompt,
116
+ context: context_text,
117
+ query: query,
118
+ }
119
+ end
120
+ end
121
+
122
+ # Minimal default context configuration - users should define their own contexts
123
+ def default_context_configs
124
+ {
125
+ # Generic fallback configuration - works with any hash structure
126
+ 'default' => {
127
+ fields: [], # Will be dynamically determined from item keys
128
+ format: nil, # Will use fallback formatting
129
+ embedding_fields: [], # Will use all string/text fields
130
+ },
131
+ }
132
+ end
133
+
134
+ # Extract text for embedding generation based on context configuration
135
+ def extract_embedding_text(item, context_type = nil)
136
+ return item.to_s unless item.is_a?(Hash)
137
+
138
+ config = resolve_context_config(item, context_type)
139
+ text_values = extract_configured_fields(item, config) || extract_text_values(item)
140
+ text_values.join(' ').strip
141
+ end
142
+
143
+ # Extract text values from hash, excluding non-textual fields
144
+ def extract_text_values(item)
145
+ # Common fields to exclude from embedding text
146
+ exclude_fields = ['id', '_id', 'uuid', 'created_at', 'updated_at', 'timestamp', 'version', 'status', 'active']
147
+
148
+ item.filter_map { |key, value|
149
+ next if exclude_fields.include?(key.to_s.downcase)
150
+ next unless value.is_a?(String) || value.is_a?(Numeric)
151
+ next if value.to_s.strip.empty?
152
+
153
+ value.to_s
154
+ }
155
+ end
156
+
157
+ # Generic context item formatting using configurable contexts
158
+ def format_context_item(item)
159
+ case item
160
+ when Hash then format_hash_item(item)
161
+ when String then item
162
+ else item.to_s
163
+ end
164
+ end
165
+
166
+ private
167
+
168
+ # Resolve context configuration for an item
169
+ def resolve_context_config(item, context_type)
170
+ context_configs = default_context_configs.merge(@options[:context_configs] || {})
171
+ return context_configs['default'] if context_configs.empty?
172
+
173
+ detected_type = context_type || detect_context_type(item)
174
+ context_configs[detected_type] || context_configs['default']
175
+ end
176
+
177
+ # Extract fields configured for embeddings
178
+ def extract_configured_fields(item, config)
179
+ return nil unless config[:embedding_fields]&.any?
180
+
181
+ config[:embedding_fields].filter_map { |field| item[field] || item[field.to_sym] }
182
+ end
183
+
184
+ # Format a hash item using context configuration
185
+ def format_hash_item(item)
186
+ config = resolve_context_config(item, nil)
187
+ return fallback_format_hash(item) unless config[:format]
188
+
189
+ format_data = build_format_data(item, config)
190
+ return fallback_format_hash(item) unless format_data.any?
191
+
192
+ apply_format_template(config[:format], format_data) || fallback_format_hash(item)
193
+ end
194
+
195
+ # Build format data from item fields
196
+ def build_format_data(item, config)
197
+ format_data = {}
198
+ fields_to_check = config[:fields].any? ? config[:fields] : item.keys.map(&:to_s)
199
+
200
+ fields_to_check.each do |field|
201
+ value = item[field] || item[field.to_sym]
202
+ format_data[field.to_sym] = value if value
203
+ end
204
+
205
+ format_data
206
+ end
207
+
208
+ # Apply format template with error handling
209
+ def apply_format_template(template, format_data)
210
+ template % format_data
211
+ rescue KeyError
212
+ nil
213
+ end
214
+
215
+ # Detect context type from item structure
216
+ def detect_context_type(item)
217
+ return 'default' unless item.is_a?(Hash)
218
+
219
+ # Check for explicit type fields (user-defined)
220
+ return item['type'].to_s if item['type']
221
+ return item['context_type'].to_s if item['context_type']
222
+ return item['model_type'].to_s.downcase if item['model_type']
223
+
224
+ # If no explicit type and user has configured contexts, try to match
225
+ context_configs = @options[:context_configs] || {}
226
+ return match_context_by_fields(item, context_configs) if context_configs.any?
227
+
228
+ # Default fallback
229
+ 'default'
230
+ end
231
+
232
+ # Match context type based on configured field patterns
233
+ def match_context_by_fields(item, context_configs)
234
+ item_fields = item.keys.map(&:to_s)
235
+ best_match = find_best_field_match(item_fields, context_configs)
236
+ best_match || 'default'
237
+ end
238
+
239
+ # Find the best matching context configuration
240
+ def find_best_field_match(item_fields, context_configs)
241
+ best_match = nil
242
+ best_score = 0
243
+
244
+ context_configs.each do |context_type, config|
245
+ next unless config[:fields]&.any?
246
+
247
+ score = calculate_field_match_score(item_fields, config[:fields])
248
+ next unless score >= 0.5 && score > best_score
249
+
250
+ best_match = context_type
251
+ best_score = score
252
+ end
253
+
254
+ best_match
255
+ end
256
+
257
+ # Calculate field matching score
258
+ def calculate_field_match_score(item_fields, config_fields)
259
+ return 0 if config_fields.empty?
260
+
261
+ matching_fields = (item_fields & config_fields).size
262
+ matching_fields.to_f / config_fields.size
263
+ end
264
+
265
+ # Fallback formatting for hash items
266
+ def fallback_format_hash(item, format_data = nil)
267
+ # Fallback: join key-value pairs
268
+ (format_data || item).map { |k, v| "#{k}: #{v}" }.join(', ')
269
+ end
270
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prescient
4
+ class Client
5
+ attr_reader :provider_name
6
+ attr_reader :provider
7
+
8
+ def initialize(provider_name = nil)
9
+ @provider_name = provider_name || Prescient.configuration.default_provider
10
+ @provider = Prescient.configuration.provider(@provider_name)
11
+
12
+ raise Prescient::Error, "Provider not found: #{@provider_name}" unless @provider
13
+ end
14
+
15
+ def generate_embedding(text, **options)
16
+ with_error_handling do
17
+ if options.any?
18
+ @provider.generate_embedding(text, **options)
19
+ else
20
+ @provider.generate_embedding(text)
21
+ end
22
+ end
23
+ end
24
+
25
+ def generate_response(prompt, context_items = [], **options)
26
+ with_error_handling do
27
+ if options.any?
28
+ @provider.generate_response(prompt, context_items, **options)
29
+ else
30
+ @provider.generate_response(prompt, context_items)
31
+ end
32
+ end
33
+ end
34
+
35
+ def health_check
36
+ @provider.health_check
37
+ end
38
+
39
+ def available?
40
+ @provider.available?
41
+ end
42
+
43
+ def provider_info
44
+ {
45
+ name: @provider_name,
46
+ class: @provider.class.name.split('::').last,
47
+ available: available?,
48
+ options: sanitize_options(@provider.options),
49
+ }
50
+ end
51
+
52
+ def method_missing(method_name, ...)
53
+ if @provider.respond_to?(method_name)
54
+ @provider.send(method_name, ...)
55
+ else
56
+ super
57
+ end
58
+ end
59
+
60
+ def respond_to_missing?(method_name, include_private = false)
61
+ @provider.respond_to?(method_name, include_private) || super
62
+ end
63
+
64
+ private
65
+
66
+ def sanitize_options(options)
67
+ sensitive_keys = [:api_key, :password, :token, :secret]
68
+ options.reject { |key, _| sensitive_keys.include?(key.to_sym) }
69
+ end
70
+
71
+ def with_error_handling
72
+ retries = 0
73
+ begin
74
+ yield
75
+ rescue Prescient::RateLimitError => e
76
+ raise e unless retries < Prescient.configuration.retry_attempts
77
+
78
+ retries += 1
79
+ sleep(Prescient.configuration.retry_delay * retries)
80
+ retry
81
+ rescue Prescient::ConnectionError => e
82
+ raise e unless retries < Prescient.configuration.retry_attempts
83
+
84
+ retries += 1
85
+ sleep(Prescient.configuration.retry_delay)
86
+ retry
87
+ end
88
+ end
89
+ end
90
+
91
+ # Convenience methods for quick access
92
+ def self.client(provider_name = nil)
93
+ Client.new(provider_name)
94
+ end
95
+
96
+ def self.generate_embedding(text, provider: nil, **options)
97
+ client(provider).generate_embedding(text, **options)
98
+ end
99
+
100
+ def self.generate_response(prompt, context_items = [], provider: nil, **options)
101
+ client(provider).generate_response(prompt, context_items, **options)
102
+ end
103
+
104
+ def self.health_check(provider: nil)
105
+ client(provider).health_check
106
+ end
107
+ end