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.
- checksums.yaml +4 -4
- data/.dockerignore +18 -0
- data/CHANGELOG.md +42 -0
- data/Dockerfile +45 -0
- data/INTEGRATION_GUIDE.md +121 -11
- data/README.md +261 -16
- data/Rakefile +1 -1
- data/VECTOR_SEARCH_GUIDE.md +7 -3
- data/docker-compose.api.yml +21 -0
- data/examples/README.md +19 -1
- data/examples/basic_usage.rb +1 -1
- data/examples/custom_contexts.rb +6 -21
- data/examples/rest_api.ru +30 -0
- data/examples/vector_search.rb +69 -305
- data/lib/prescient/api.rb +285 -0
- data/lib/prescient/cli.rb +197 -4
- data/lib/prescient/configuration_loader.rb +437 -0
- data/lib/prescient/provider/deepseek.rb +139 -0
- data/lib/prescient/provider/gemini.rb +173 -0
- data/lib/prescient/provider/huggingface.rb +1 -0
- data/lib/prescient/provider/mistral.rb +171 -0
- data/lib/prescient/provider/openai.rb +3 -0
- data/lib/prescient/provider/xai.rb +139 -0
- data/lib/prescient/version.rb +1 -1
- data/lib/prescient.rb +119 -23
- data/schema/prescient.configuration.schema.json +153 -0
- data/sig/prescient.rbs +115 -0
- metadata +12 -1
data/examples/vector_search.rb
CHANGED
|
@@ -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
|
|
5
|
-
#
|
|
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
|
|
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:
|
|
17
|
-
port:
|
|
18
|
-
dbname:
|
|
19
|
-
user:
|
|
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
|
-
|
|
25
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|