prescient 0.4.0 → 0.6.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.
@@ -1,330 +1,94 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- # Example: Vector similarity search with Prescient gem and PostgreSQL pgvector
5
- # This example demonstrates how to store embeddings and perform similarity search
4
+ # Example: Vector similarity search with Prescient and PostgreSQL pgvector.
5
+ # The Store owns only its embedding table; applications own their documents.
6
6
 
7
7
  require_relative '../lib/prescient'
8
8
  require 'pg'
9
- require 'json'
10
9
 
11
- puts "=== Vector Similarity Search Example ==="
12
- puts "This example shows how to use Prescient with PostgreSQL pgvector for semantic search."
10
+ puts '=== Vector Similarity Search Example ==='
13
11
 
14
- # Database connection configuration
15
12
  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')
13
+ host: ENV.fetch('DB_HOST', 'localhost'),
14
+ port: ENV.fetch('DB_PORT', '5432'),
15
+ dbname: ENV.fetch('DB_NAME', 'prescient_development'),
16
+ user: ENV.fetch('DB_USER', 'prescient'),
17
+ password: ENV.fetch('DB_PASSWORD', 'prescient_password'),
21
18
  }.freeze
22
19
 
23
20
  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
21
+ EMBEDDING_DIMENSIONS = 768
22
+ EMBEDDING_MODEL = 'nomic-embed-text'
58
23
 
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
24
+ def initialize
25
+ @connection = PG.connect(DB_CONFIG)
26
+ @client = Prescient.client(:ollama, enable_fallback: false)
27
+ @store = Prescient::Pgvector::Store.new(
28
+ connection: @connection,
29
+ dimensions: EMBEDDING_DIMENSIONS,
30
+ )
92
31
  end
93
32
 
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
33
+ def run
34
+ @store.install!
35
+ %i[cosine euclidean inner_product].each { |metric| @store.create_index!(metric:) }
36
+
37
+ documents.each do |document|
38
+ embedding = @client.generate_embedding(document[:content], model: EMBEDDING_MODEL)
39
+ @store.upsert(
40
+ id: document[:id],
41
+ embedding:,
42
+ provider: 'ollama',
43
+ model: EMBEDDING_MODEL,
44
+ content: document[:content],
45
+ metadata: { title: document[:title] },
46
+ )
111
47
  end
112
48
 
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
49
+ query = 'How do I improve database performance?'
50
+ embedding = @client.generate_embedding(query, model: EMBEDDING_MODEL)
51
+ results = @store.search(
52
+ embedding:,
53
+ limit: 3,
54
+ metric: :cosine,
55
+ provider: 'ollama',
56
+ model: EMBEDDING_MODEL,
57
+ )
58
+
59
+ puts "\nQuery: #{query}"
60
+ results.each_with_index do |result, index|
61
+ title = result[:metadata].fetch('title')
62
+ puts "#{index + 1}. #{title} (distance: #{result[:distance].round(4)})"
63
+ puts " #{result[:content]}"
132
64
  end
65
+ rescue Prescient::Error, PG::Error => e
66
+ warn "Vector search failed: #{e.message}"
67
+ ensure
68
+ @connection&.close
133
69
  end
134
70
 
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
71
+ private
147
72
 
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"
73
+ def documents
74
+ [
75
+ {
76
+ id: 'postgres-indexes',
77
+ title: 'PostgreSQL indexes',
78
+ content: 'Indexes can reduce query latency when their columns match common filters and ordering.',
79
+ },
80
+ {
81
+ id: 'ruby-performance',
82
+ title: 'Ruby performance',
83
+ content: 'Measure allocations and repeated work before optimizing a Ruby application.',
84
+ },
85
+ {
86
+ id: 'api-security',
87
+ title: 'API security',
88
+ content: 'Protect API credentials, validate inputs, and avoid exposing provider response bodies.',
89
+ },
154
90
  ]
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
91
  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
92
  end
324
93
 
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"
94
+ VectorSearchExample.new.run
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+ require 'stringio'
6
+ require 'uri'
7
+ require_relative '../prescient'
8
+
9
+ # Dependency-free Rack-compatible HTTP application for Prescient operations.
10
+ #
11
+ # The application exposes only generic Prescient operations. It does not
12
+ # expose provider-specific methods, credentials, or raw provider responses.
13
+ class Prescient::API
14
+ # @return [Integer] Default maximum request body size in bytes
15
+ DEFAULT_MAX_BODY_BYTES = 1_048_576
16
+ # @return [Integer] Maximum number of inputs accepted by batch embeddings
17
+ MAX_BATCH_SIZE = 32
18
+ # @return [String] HTTP API version
19
+ API_VERSION = '1'
20
+ # @return [Hash<Array<String>, Symbol>] Generic HTTP route handlers
21
+ ROUTES = {
22
+ ['GET', '/healthz'] => :healthz_response,
23
+ ['GET', '/readyz'] => :readiness_response,
24
+ ['GET', '/v1/version'] => :version_response,
25
+ ['GET', '/v1/providers'] => :providers_response,
26
+ ['GET', '/v1/models'] => :models_response,
27
+ ['GET', '/v1/capabilities'] => :capabilities_response,
28
+ ['GET', '/v1/health'] => :health_response,
29
+ ['POST', '/v1/generate'] => :generate_response,
30
+ ['POST', '/v1/embeddings'] => :embeddings_response,
31
+ ['POST', '/v1/embeddings/batch'] => :batch_embeddings_response,
32
+ }.freeze
33
+
34
+ # @param authentication [#call, nil] Optional authentication hook
35
+ # @param max_body_bytes [Integer] Maximum accepted request body size
36
+ # @return [void]
37
+ def initialize(authentication: nil, max_body_bytes: DEFAULT_MAX_BODY_BYTES)
38
+ @authentication = authentication
39
+ @max_body_bytes = validate_body_limit(max_body_bytes)
40
+ end
41
+
42
+ # Handle a Rack-style environment and return a Rack response tuple.
43
+ # @param env [Hash] Rack-compatible request environment
44
+ # @return [Array(Integer, Hash, Array<String>)] HTTP status, headers, body
45
+ def call(env)
46
+ request_id = request_id_for(env)
47
+ public_path = request_target(env).first
48
+ return dispatch(env, request_id) if ['/healthz', '/readyz'].include?(public_path)
49
+
50
+ unless authenticated?(env)
51
+ return response(401,
52
+ error_payload('authentication_required', 'authentication required',
53
+ request_id))
54
+ end
55
+
56
+ dispatch(env, request_id)
57
+ rescue StandardError => e
58
+ handle_exception(e, request_id)
59
+ end
60
+
61
+ private
62
+
63
+ def dispatch(env, request_id)
64
+ method = env.fetch('REQUEST_METHOD', 'GET').upcase
65
+ path, query = request_target(env)
66
+ handler = ROUTES[[method, path]]
67
+ return response(404, error_payload('not_found', 'route not found', request_id)) unless handler
68
+
69
+ send(handler, env, query, request_id)
70
+ end
71
+
72
+ def healthz_response(_env, _query, request_id)
73
+ json_response(200, { status: 'ok' }, request_id)
74
+ end
75
+
76
+ def version_response(_env, _query, request_id)
77
+ json_response(200, { version: Prescient::VERSION, api_version: API_VERSION }, request_id)
78
+ end
79
+
80
+ def generate_response(env, _query, request_id)
81
+ payload = request_payload(env)
82
+ prompt = required_string(payload, 'prompt')
83
+ context = payload.fetch('context', [])
84
+ raise ArgumentError, 'context must be an array' unless context.is_a?(Array)
85
+
86
+ client = client_for(payload)
87
+ result = client.generate_response(prompt, context, **generation_options(payload))
88
+ json_response(200, result, request_id)
89
+ end
90
+
91
+ def embeddings_response(env, _query, request_id)
92
+ payload = request_payload(env)
93
+ input = required_string(payload, 'input')
94
+ client = client_for(payload)
95
+ result = client.generate_embedding(input, **model_options(payload))
96
+ json_response(200, embedding_payload(result, client), request_id)
97
+ end
98
+
99
+ def batch_embeddings_response(env, _query, request_id)
100
+ payload = request_payload(env)
101
+ inputs = payload['inputs']
102
+ raise ArgumentError, 'inputs must be a non-empty array' unless inputs.is_a?(Array) && inputs.any?
103
+ raise ArgumentError, "inputs cannot contain more than #{MAX_BATCH_SIZE} items" if inputs.length > MAX_BATCH_SIZE
104
+ raise ArgumentError, 'inputs must contain only strings' unless inputs.all?(String)
105
+
106
+ client = client_for(payload)
107
+ embeddings = inputs.map { |input| client.generate_embedding(input, **model_options(payload)) }
108
+ result = { embeddings: embeddings, dimensions: embeddings.first.length, provider: client.provider_name.to_s }
109
+ json_response(200,
110
+ result, request_id)
111
+ end
112
+
113
+ def readiness_response(_env, _query, request_id)
114
+ providers = Prescient.configuration.providers.keys
115
+ ready = providers.any? { |name|
116
+ begin
117
+ Prescient.health_check(provider: name)[:ready] == true
118
+ rescue Prescient::Error
119
+ false
120
+ end
121
+ }
122
+ json_response(ready ? 200 : 503, { status: ready ? 'ready' : 'not_ready' }, request_id)
123
+ end
124
+
125
+ def providers_response(_env, _query, request_id)
126
+ providers = Prescient.configuration.providers.map { |name, registration|
127
+ { name: name.to_s, class: registration[:class].name }
128
+ }
129
+ json_response(200, { providers: providers }, request_id)
130
+ end
131
+
132
+ def models_response(_env, query, request_id)
133
+ names = query['provider'] ? [query['provider'].to_sym] : Prescient.configuration.providers.keys
134
+ models = names.flat_map { |name|
135
+ provider = Prescient.configuration.provider(name)
136
+ raise Prescient::Error, "Provider not configured: #{name}" unless provider
137
+
138
+ records = if provider.respond_to?(:list_models)
139
+ provider.list_models
140
+ elsif provider.respond_to?(:available_models)
141
+ provider.available_models
142
+ else
143
+ []
144
+ end
145
+ records.map { |model| { provider: name.to_s, model: model } }
146
+ }
147
+ json_response(200, { models: models }, request_id)
148
+ end
149
+
150
+ def capabilities_response(_env, _query, request_id)
151
+ capabilities = Prescient.configuration.providers.map { |name, registration|
152
+ provider = registration[:class]
153
+ {
154
+ provider: name.to_s,
155
+ generation: provider.method_defined?(:generate_response),
156
+ embeddings: provider.method_defined?(:generate_embedding),
157
+ health: provider.method_defined?(:health_check),
158
+ model_listing: provider.method_defined?(:list_models) || provider.method_defined?(:available_models),
159
+ }
160
+ }
161
+ json_response(200, { capabilities: capabilities }, request_id)
162
+ end
163
+
164
+ def health_response(_env, query, request_id)
165
+ if query['provider']
166
+ json_response(200, Prescient.health_check(provider: query['provider'].to_sym), request_id)
167
+ else
168
+ results = Prescient.configuration.providers.keys.to_h { |name|
169
+ [name.to_s, Prescient.health_check(provider: name)]
170
+ }
171
+ json_response(200, results, request_id)
172
+ end
173
+ end
174
+
175
+ def client_for(payload)
176
+ provider = payload['provider']&.to_sym
177
+ fallback = payload.key?('fallback') ? payload['fallback'] : true
178
+ raise ArgumentError, 'fallback must be boolean' unless [true, false].include?(fallback)
179
+
180
+ Prescient.client(provider, enable_fallback: fallback)
181
+ end
182
+
183
+ def generation_options(payload)
184
+ options = model_options(payload)
185
+ ['temperature', 'max_tokens', 'top_p'].each do |key|
186
+ options[key.to_sym] = payload[key] if payload.key?(key)
187
+ end
188
+ options
189
+ end
190
+
191
+ def model_options(payload)
192
+ payload['model'] ? { model: payload['model'] } : {}
193
+ end
194
+
195
+ def embedding_payload(embedding, client)
196
+ { embedding: embedding, dimensions: embedding.length, provider: client.provider_name.to_s }
197
+ end
198
+
199
+ def request_payload(env)
200
+ content_length = env['CONTENT_LENGTH'].to_i
201
+ raise ArgumentError, 'request body exceeds configured limit' if content_length > @max_body_bytes
202
+
203
+ body = env.fetch('rack.input', StringIO.new).read(@max_body_bytes + 1)
204
+ raise ArgumentError, 'request body exceeds configured limit' if body.bytesize > @max_body_bytes
205
+
206
+ parsed = JSON.parse(body)
207
+ raise ArgumentError, 'request body must contain a JSON object' unless parsed.is_a?(Hash)
208
+
209
+ parsed
210
+ end
211
+
212
+ def required_string(payload, key)
213
+ value = payload[key]
214
+ raise ArgumentError, "#{key} must be a non-empty string" unless value.is_a?(String) && !value.empty?
215
+
216
+ value
217
+ end
218
+
219
+ def request_target(env)
220
+ target = env['REQUEST_URI'] || env['PATH_INFO'] || '/'
221
+ path, query = target.split('?', 2)
222
+ [path, URI.decode_www_form(query.to_s).to_h]
223
+ end
224
+
225
+ def authenticated?(env)
226
+ return true unless @authentication
227
+
228
+ @authentication.call(env) == true
229
+ end
230
+
231
+ def request_id_for(env)
232
+ supplied = env['HTTP_X_REQUEST_ID'].to_s
233
+ supplied.match?(/\A[a-zA-Z0-9._:-]{1,128}\z/) ? supplied : SecureRandom.uuid
234
+ end
235
+
236
+ def json_response(status, payload, request_id)
237
+ response(status, payload.merge(request_id: request_id))
238
+ end
239
+
240
+ def response(status, payload)
241
+ body = JSON.generate(payload)
242
+ headers = {
243
+ 'content-type' => 'application/json',
244
+ 'content-length' => body.bytesize.to_s,
245
+ }
246
+ headers['x-request-id'] = payload[:request_id] if payload[:request_id]
247
+ [status, headers, [body]]
248
+ end
249
+
250
+ def error_payload(type, message, request_id)
251
+ { error: { type: type, message: message }, request_id: request_id }
252
+ end
253
+
254
+ def error_type(error)
255
+ error.class.name.split('::').last.delete_suffix('Error').downcase
256
+ end
257
+
258
+ def error_status(error)
259
+ return 401 if error.is_a?(Prescient::AuthenticationError)
260
+ return 429 if error.is_a?(Prescient::RateLimitError)
261
+ return 503 if error.is_a?(Prescient::ConnectionError) || error.is_a?(Prescient::ProviderError)
262
+ return 422 if error.is_a?(Prescient::ModelNotAvailableError)
263
+
264
+ 500
265
+ end
266
+
267
+ def handle_exception(error, request_id)
268
+ case error
269
+ when JSON::ParserError
270
+ response(400, error_payload('invalid_json', 'request body must contain valid JSON', request_id))
271
+ when ArgumentError
272
+ response(400, error_payload('invalid_request', error.message, request_id))
273
+ when Prescient::Error
274
+ response(error_status(error), error_payload(error_type(error), error.message, request_id))
275
+ else
276
+ response(500, error_payload('internal_error', 'internal server error', request_id))
277
+ end
278
+ end
279
+
280
+ def validate_body_limit(value)
281
+ return value if value.is_a?(Integer) && value.positive?
282
+
283
+ raise ArgumentError, 'max_body_bytes must be a positive integer'
284
+ end
285
+ end