prescient 0.5.0 → 0.7.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.
data/docker-compose.yml CHANGED
@@ -91,6 +91,26 @@ services:
91
91
  retries: 3
92
92
  start_period: 30s
93
93
 
94
+ # Optional SearXNG instance for the external web-search example.
95
+ searxng:
96
+ image: searxng/searxng:latest
97
+ container_name: prescient-searxng
98
+ ports:
99
+ - "8080:8080"
100
+ environment:
101
+ - SEARXNG_BASE_URL=http://localhost:8080/
102
+ - SEARXNG_SECRET=${SEARXNG_SECRET:-prescient-development-secret}
103
+ volumes:
104
+ - ./searxng/settings.yml:/etc/searxng/settings.yml:ro
105
+ - searxng_cache:/var/cache/searxng
106
+ restart: unless-stopped
107
+ healthcheck:
108
+ test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/')"]
109
+ interval: 30s
110
+ timeout: 10s
111
+ retries: 3
112
+ start_period: 30s
113
+
94
114
  # Optional: Redis for caching embeddings (useful for development)
95
115
  redis:
96
116
  image: redis:7-alpine
@@ -145,6 +165,8 @@ volumes:
145
165
  driver: local
146
166
  redis_data:
147
167
  driver: local
168
+ searxng_cache:
169
+ driver: local
148
170
 
149
171
  networks:
150
172
  default:
data/examples/README.md CHANGED
@@ -17,6 +17,38 @@ bundle install
17
17
  and embedding field selection.
18
18
  - `vector_search.rb` — `Prescient::Pgvector::Store` PostgreSQL/pgvector storage
19
19
  and similarity search.
20
+ - `rest_api.ru` — a tiny Rack-compatible application that mounts
21
+ `Prescient::API` and lists its endpoints at `/`.
22
+ - `web_search.rb` — explicit SearXNG tool invocation with normalized JSON output.
23
+
24
+ The same `web_search` capability can use SearchApi instead of SearXNG when the
25
+ tool is configured with `type: searchapi` and `SEARCHAPI_API_KEY`.
26
+
27
+ Run the REST API example with a Rack server such as `rackup`:
28
+
29
+ ```bash
30
+ BUNDLE_WITH=rack_example bundle install
31
+ PRESCIENT_API_TOKEN=change-me BUNDLE_WITH=rack_example \
32
+ bundle exec rackup -s puma examples/rest_api.ru
33
+ curl http://localhost:9292/
34
+ ```
35
+
36
+ The endpoint catalog includes `POST /v1/search/generate`. With a configured
37
+ SearXNG tool and AI provider, call it explicitly to feed search results into
38
+ generation:
39
+
40
+ ```bash
41
+ curl -X POST http://localhost:9292/v1/search/generate \
42
+ -H 'Authorization: Bearer change-me' \
43
+ -H 'Content-Type: application/json' \
44
+ -d '{"query":"Ruby HTTP clients","provider":"openai","limit":5}'
45
+ ```
46
+
47
+ Running `bundle exec ruby examples/rest_api.ru` directly prints the same
48
+ endpoint catalog without starting a server.
49
+
50
+ The example does not add Rack as a Prescient runtime dependency; it only uses
51
+ the Rack-compatible `call` interface provided by `Prescient::API`.
20
52
 
21
53
  The first three examples use Ollama by default. Start Ollama and pull the
22
54
  current local models before running them:
@@ -40,6 +72,25 @@ configuration. The scripts are demonstrations rather than isolated test
40
72
  fixtures; they may make real provider requests when the configured service is
41
73
  available.
42
74
 
75
+ The web-search example requires a reachable SearXNG instance:
76
+
77
+ ```bash
78
+ docker compose up -d searxng
79
+ SEARXNG_URL=http://localhost:8080 bundle exec ruby examples/web_search.rb "Ruby HTTP clients"
80
+ ```
81
+
82
+ The example returns normalized search results directly by default. Opt in to
83
+ feeding those results to the configured AI provider with `--generate`:
84
+
85
+ ```bash
86
+ SEARXNG_URL=http://localhost:8080 PRESCIENT_PROVIDER=openai \
87
+ bundle exec ruby examples/web_search.rb --generate "Ruby HTTP clients"
88
+ ```
89
+
90
+ Omit `--generate` to keep the search results direct. `PRESCIENT_PROVIDER` is
91
+ only used with `--generate` and may be omitted when the default provider is
92
+ configured.
93
+
43
94
  See the [main README](../README.md) for configuration, fallback behavior,
44
95
  prompt templates, context exclusions, embeddings, and the public API. Rails
45
96
  applications can also use the [integration guide](../INTEGRATION_GUIDE.md),
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../lib/prescient'
5
+
6
+ api = Prescient::API.new(
7
+ authentication: ->(env) {
8
+ expected = ENV.fetch('PRESCIENT_API_TOKEN', nil)
9
+ expected && env['HTTP_AUTHORIZATION'] == "Bearer #{expected}"
10
+ },
11
+ )
12
+
13
+ endpoints = Prescient::API::ROUTES.keys.map { |method, path|
14
+ { method: method, path: path }
15
+ }
16
+
17
+ app = ->(env) {
18
+ if env['REQUEST_METHOD'] == 'GET' && env['PATH_INFO'] == '/'
19
+ payload = JSON.generate({ name: 'Prescient API', endpoints: endpoints })
20
+ [200, { 'content-type' => 'application/json', 'content-length' => payload.bytesize.to_s }, [payload]]
21
+ else
22
+ api.call(env)
23
+ end
24
+ }
25
+
26
+ if respond_to?(:run, true)
27
+ run app
28
+ else
29
+ puts JSON.pretty_generate({ name: 'Prescient API', endpoints: endpoints })
30
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../lib/prescient'
5
+
6
+ generate = ARGV.delete('--generate')
7
+ if ARGV.include?('--help')
8
+ puts 'Usage: ruby examples/web_search.rb [--generate] [QUERY]'
9
+ puts ' --generate Feed normalized search results to the configured AI provider'
10
+ puts ' PRESCIENT_PROVIDER Provider used with --generate (default: configured provider)'
11
+ exit
12
+ end
13
+
14
+ query = ARGV.empty? ? 'Ruby HTTP clients' : ARGV.join(' ')
15
+
16
+ Prescient.configure do |config|
17
+ config.add_tool(
18
+ :web_search,
19
+ Prescient::Tool::SearXNG,
20
+ url: ENV.fetch('SEARXNG_URL', 'http://localhost:8080'),
21
+ )
22
+ end
23
+
24
+ result = if generate
25
+ Prescient.search_and_generate(
26
+ query,
27
+ provider: ENV['PRESCIENT_PROVIDER']&.to_sym,
28
+ limit: 20,
29
+ )
30
+ else
31
+ Prescient.tool(:web_search).search(query, limit: 20)
32
+ end
33
+
34
+ puts JSON.pretty_generate(result)
@@ -0,0 +1,337 @@
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/search'] => :search_response,
31
+ ['POST', '/v1/search/generate'] => :search_generate_response,
32
+ ['POST', '/v1/embeddings'] => :embeddings_response,
33
+ ['POST', '/v1/embeddings/batch'] => :batch_embeddings_response,
34
+ }.freeze
35
+
36
+ # @param authentication [#call, nil] Optional authentication hook
37
+ # @param max_body_bytes [Integer] Maximum accepted request body size
38
+ # @return [void]
39
+ def initialize(authentication: nil, max_body_bytes: DEFAULT_MAX_BODY_BYTES)
40
+ @authentication = authentication
41
+ @max_body_bytes = validate_body_limit(max_body_bytes)
42
+ end
43
+
44
+ # Handle a Rack-style environment and return a Rack response tuple.
45
+ # @param env [Hash] Rack-compatible request environment
46
+ # @return [Array(Integer, Hash, Array<String>)] HTTP status, headers, body
47
+ def call(env)
48
+ request_id = request_id_for(env)
49
+ public_path = request_target(env).first
50
+ return dispatch(env, request_id) if ['/healthz', '/readyz'].include?(public_path)
51
+
52
+ unless authenticated?(env)
53
+ return response(401,
54
+ error_payload('authentication_required', 'authentication required',
55
+ request_id))
56
+ end
57
+
58
+ dispatch(env, request_id)
59
+ rescue StandardError => e
60
+ handle_exception(e, request_id)
61
+ end
62
+
63
+ private
64
+
65
+ def dispatch(env, request_id)
66
+ method = env.fetch('REQUEST_METHOD', 'GET').upcase
67
+ path, query = request_target(env)
68
+ handler = ROUTES[[method, path]]
69
+ return response(404, error_payload('not_found', 'route not found', request_id)) unless handler
70
+
71
+ send(handler, env, query, request_id)
72
+ end
73
+
74
+ def healthz_response(_env, _query, request_id)
75
+ json_response(200, { status: 'ok' }, request_id)
76
+ end
77
+
78
+ def version_response(_env, _query, request_id)
79
+ json_response(200, { version: Prescient::VERSION, api_version: API_VERSION }, request_id)
80
+ end
81
+
82
+ def generate_response(env, _query, request_id)
83
+ payload = request_payload(env)
84
+ prompt = required_string(payload, 'prompt')
85
+ context = payload.fetch('context', [])
86
+ raise ArgumentError, 'context must be an array' unless context.is_a?(Array)
87
+
88
+ client = client_for(payload)
89
+ result = client.generate_response(prompt, context, **generation_options(payload))
90
+ json_response(200, result, request_id)
91
+ end
92
+
93
+ def search_generate_response(env, _query, request_id)
94
+ payload = request_payload(env)
95
+ query = required_string(payload, 'query')
96
+ tool = search_tool_name(payload)
97
+ fallback = search_fallback(payload)
98
+ limit = search_limit(payload)
99
+
100
+ result = Prescient.search_and_generate(
101
+ query,
102
+ tool: tool,
103
+ provider: payload['provider']&.to_sym,
104
+ limit: limit,
105
+ enable_fallback: fallback,
106
+ **generation_options(payload),
107
+ )
108
+ json_response(200, result, request_id)
109
+ end
110
+
111
+ def search_response(env, _query, request_id)
112
+ payload = request_payload(env)
113
+ query = required_string(payload, 'query')
114
+ tool_name = search_tool_name(payload)
115
+ tool = Prescient.tool(tool_name)
116
+ raise Prescient::ToolConfigurationError, "tool not configured: #{tool_name}" unless tool
117
+
118
+ result = tool.search(query, limit: search_limit(payload))
119
+ json_response(200, result, request_id)
120
+ end
121
+
122
+ def search_tool_name(payload)
123
+ value = payload.fetch('tool', 'web_search')
124
+ raise ArgumentError, 'tool must be a non-empty string' unless value.is_a?(String) && !value.empty?
125
+
126
+ value.to_sym
127
+ end
128
+
129
+ def search_fallback(payload)
130
+ fallback = payload.key?('fallback') ? payload['fallback'] : true
131
+ raise ArgumentError, 'fallback must be boolean' unless [true, false].include?(fallback)
132
+
133
+ fallback
134
+ end
135
+
136
+ def search_limit(payload)
137
+ limit = payload['limit']
138
+ raise ArgumentError, 'limit must be a positive integer' if limit && (!limit.is_a?(Integer) || !limit.positive?)
139
+
140
+ limit
141
+ end
142
+
143
+ def embeddings_response(env, _query, request_id)
144
+ payload = request_payload(env)
145
+ input = required_string(payload, 'input')
146
+ client = client_for(payload)
147
+ result = client.generate_embedding(input, **model_options(payload))
148
+ json_response(200, embedding_payload(result, client), request_id)
149
+ end
150
+
151
+ def batch_embeddings_response(env, _query, request_id)
152
+ payload = request_payload(env)
153
+ inputs = payload['inputs']
154
+ raise ArgumentError, 'inputs must be a non-empty array' unless inputs.is_a?(Array) && inputs.any?
155
+ raise ArgumentError, "inputs cannot contain more than #{MAX_BATCH_SIZE} items" if inputs.length > MAX_BATCH_SIZE
156
+ raise ArgumentError, 'inputs must contain only strings' unless inputs.all?(String)
157
+
158
+ client = client_for(payload)
159
+ embeddings = inputs.map { |input| client.generate_embedding(input, **model_options(payload)) }
160
+ result = { embeddings: embeddings, dimensions: embeddings.first.length, provider: client.provider_name.to_s }
161
+ json_response(200,
162
+ result, request_id)
163
+ end
164
+
165
+ def readiness_response(_env, _query, request_id)
166
+ providers = Prescient.configuration.providers.keys
167
+ ready = providers.any? { |name|
168
+ begin
169
+ Prescient.health_check(provider: name)[:ready] == true
170
+ rescue Prescient::Error
171
+ false
172
+ end
173
+ }
174
+ json_response(ready ? 200 : 503, { status: ready ? 'ready' : 'not_ready' }, request_id)
175
+ end
176
+
177
+ def providers_response(_env, _query, request_id)
178
+ providers = Prescient.configuration.providers.map { |name, registration|
179
+ { name: name.to_s, class: registration[:class].name }
180
+ }
181
+ json_response(200, { providers: providers }, request_id)
182
+ end
183
+
184
+ def models_response(_env, query, request_id)
185
+ names = query['provider'] ? [query['provider'].to_sym] : Prescient.configuration.providers.keys
186
+ models = names.flat_map { |name|
187
+ provider = Prescient.configuration.provider(name)
188
+ raise Prescient::Error, "Provider not configured: #{name}" unless provider
189
+
190
+ records = if provider.respond_to?(:list_models)
191
+ provider.list_models
192
+ elsif provider.respond_to?(:available_models)
193
+ provider.available_models
194
+ else
195
+ []
196
+ end
197
+ records.map { |model| { provider: name.to_s, model: model } }
198
+ }
199
+ json_response(200, { models: models }, request_id)
200
+ end
201
+
202
+ def capabilities_response(_env, _query, request_id)
203
+ capabilities = Prescient.configuration.providers.map { |name, registration|
204
+ provider = registration[:class]
205
+ {
206
+ provider: name.to_s,
207
+ generation: provider.method_defined?(:generate_response),
208
+ embeddings: provider.method_defined?(:generate_embedding),
209
+ health: provider.method_defined?(:health_check),
210
+ model_listing: provider.method_defined?(:list_models) || provider.method_defined?(:available_models),
211
+ }
212
+ }
213
+ json_response(200, { capabilities: capabilities }, request_id)
214
+ end
215
+
216
+ def health_response(_env, query, request_id)
217
+ if query['provider']
218
+ json_response(200, Prescient.health_check(provider: query['provider'].to_sym), request_id)
219
+ else
220
+ results = Prescient.configuration.providers.keys.to_h { |name|
221
+ [name.to_s, Prescient.health_check(provider: name)]
222
+ }
223
+ json_response(200, results, request_id)
224
+ end
225
+ end
226
+
227
+ def client_for(payload)
228
+ provider = payload['provider']&.to_sym
229
+ fallback = payload.key?('fallback') ? payload['fallback'] : true
230
+ raise ArgumentError, 'fallback must be boolean' unless [true, false].include?(fallback)
231
+
232
+ Prescient.client(provider, enable_fallback: fallback)
233
+ end
234
+
235
+ def generation_options(payload)
236
+ options = model_options(payload)
237
+ ['temperature', 'max_tokens', 'top_p'].each do |key|
238
+ options[key.to_sym] = payload[key] if payload.key?(key)
239
+ end
240
+ options
241
+ end
242
+
243
+ def model_options(payload)
244
+ payload['model'] ? { model: payload['model'] } : {}
245
+ end
246
+
247
+ def embedding_payload(embedding, client)
248
+ { embedding: embedding, dimensions: embedding.length, provider: client.provider_name.to_s }
249
+ end
250
+
251
+ def request_payload(env)
252
+ content_length = env['CONTENT_LENGTH'].to_i
253
+ raise ArgumentError, 'request body exceeds configured limit' if content_length > @max_body_bytes
254
+
255
+ body = env.fetch('rack.input', StringIO.new).read(@max_body_bytes + 1)
256
+ raise ArgumentError, 'request body exceeds configured limit' if body.bytesize > @max_body_bytes
257
+
258
+ parsed = JSON.parse(body)
259
+ raise ArgumentError, 'request body must contain a JSON object' unless parsed.is_a?(Hash)
260
+
261
+ parsed
262
+ end
263
+
264
+ def required_string(payload, key)
265
+ value = payload[key]
266
+ raise ArgumentError, "#{key} must be a non-empty string" unless value.is_a?(String) && !value.empty?
267
+
268
+ value
269
+ end
270
+
271
+ def request_target(env)
272
+ target = env['REQUEST_URI'] || env['PATH_INFO'] || '/'
273
+ path, query = target.split('?', 2)
274
+ [path, URI.decode_www_form(query.to_s).to_h]
275
+ end
276
+
277
+ def authenticated?(env)
278
+ return true unless @authentication
279
+
280
+ @authentication.call(env) == true
281
+ end
282
+
283
+ def request_id_for(env)
284
+ supplied = env['HTTP_X_REQUEST_ID'].to_s
285
+ supplied.match?(/\A[a-zA-Z0-9._:-]{1,128}\z/) ? supplied : SecureRandom.uuid
286
+ end
287
+
288
+ def json_response(status, payload, request_id)
289
+ response(status, payload.merge(request_id: request_id))
290
+ end
291
+
292
+ def response(status, payload)
293
+ body = JSON.generate(payload)
294
+ headers = {
295
+ 'content-type' => 'application/json',
296
+ 'content-length' => body.bytesize.to_s,
297
+ }
298
+ headers['x-request-id'] = payload[:request_id] if payload[:request_id]
299
+ [status, headers, [body]]
300
+ end
301
+
302
+ def error_payload(type, message, request_id)
303
+ { error: { type: type, message: message }, request_id: request_id }
304
+ end
305
+
306
+ def error_type(error)
307
+ error.class.name.split('::').last.delete_suffix('Error').downcase
308
+ end
309
+
310
+ def error_status(error)
311
+ return 401 if error.is_a?(Prescient::AuthenticationError)
312
+ return 429 if error.is_a?(Prescient::RateLimitError)
313
+ return 503 if error.is_a?(Prescient::ConnectionError) || error.is_a?(Prescient::ProviderError)
314
+ return 422 if error.is_a?(Prescient::ModelNotAvailableError)
315
+
316
+ 500
317
+ end
318
+
319
+ def handle_exception(error, request_id)
320
+ case error
321
+ when JSON::ParserError
322
+ response(400, error_payload('invalid_json', 'request body must contain valid JSON', request_id))
323
+ when ArgumentError
324
+ response(400, error_payload('invalid_request', error.message, request_id))
325
+ when Prescient::Error
326
+ response(error_status(error), error_payload(error_type(error), error.message, request_id))
327
+ else
328
+ response(500, error_payload('internal_error', 'internal server error', request_id))
329
+ end
330
+ end
331
+
332
+ def validate_body_limit(value)
333
+ return value if value.is_a?(Integer) && value.positive?
334
+
335
+ raise ArgumentError, 'max_body_bytes must be a positive integer'
336
+ end
337
+ end