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.
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'httparty'
4
+ require 'net/http'
5
+ require 'timeout'
6
+ require 'uri'
7
+
8
+ # SearXNG web-search tool adapter.
9
+ class Prescient::Tool::SearXNG < Prescient::Tool::Base
10
+ # Network failures that should be reported as connection errors.
11
+ NETWORK_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Timeout::Error, SocketError, Errno::ECONNREFUSED].freeze
12
+
13
+ # @param options [Hash] SearXNG configuration options
14
+ # @option options [String] :url SearXNG base URL
15
+ # @option options [String] :language Optional search language
16
+ # @option options [String, Array<String>] :categories Optional categories
17
+ def initialize(**options)
18
+ super
19
+ @base_url = normalized_base_url(options.fetch(:url))
20
+ end
21
+
22
+ # Search SearXNG and return normalized result metadata.
23
+ # @param query [String] Search query
24
+ # @param limit [Integer, nil] Maximum number of results
25
+ # @param timeout [Numeric, nil] Request timeout in seconds
26
+ # @return [Hash] Normalized search result envelope
27
+ def search(query, limit: nil, timeout: nil)
28
+ normalized_query = validate_query(query)
29
+ requested_limit = result_limit(limit)
30
+ requested_timeout = request_timeout(timeout)
31
+ response = HTTParty.get(
32
+ search_url,
33
+ query: request_parameters(normalized_query),
34
+ timeout: requested_timeout,
35
+ )
36
+ validate_response!(response)
37
+
38
+ {
39
+ tool: 'web_search',
40
+ query: normalized_query,
41
+ source: 'searxng',
42
+ results: normalize_results(response.parsed_response, requested_limit),
43
+ }
44
+ rescue Prescient::Error
45
+ raise
46
+ rescue *NETWORK_ERRORS => e
47
+ raise Prescient::ToolConnectionError, "SearXNG request failed: #{e.class}"
48
+ rescue StandardError => e
49
+ raise Prescient::ToolError, "SearXNG request failed: #{e.message}"
50
+ end
51
+
52
+ protected
53
+
54
+ # Validate SearXNG-specific configuration.
55
+ # @return [void]
56
+ def validate_configuration!
57
+ normalized_base_url(@options.fetch(:url))
58
+ max_response_bytes
59
+ result_limit(nil)
60
+ request_timeout(nil)
61
+ rescue KeyError
62
+ raise Prescient::ToolConfigurationError, 'SearXNG requires a url'
63
+ end
64
+
65
+ private
66
+
67
+ def normalized_base_url(value)
68
+ uri = URI.parse(value.to_s)
69
+ valid_scheme = ['http', 'https'].include?(uri.scheme)
70
+ if !valid_scheme || uri.host.nil? || uri.userinfo
71
+ raise Prescient::ToolConfigurationError, 'SearXNG url must be an HTTP(S) URL without credentials'
72
+ end
73
+
74
+ uri.to_s.delete_suffix('/')
75
+ rescue URI::InvalidURIError
76
+ raise Prescient::ToolConfigurationError, 'SearXNG url must be a valid HTTP(S) URL'
77
+ end
78
+
79
+ def search_url
80
+ "#{@base_url}/search"
81
+ end
82
+
83
+ def request_parameters(query)
84
+ parameters = { q: query, format: 'json' }
85
+ parameters[:language] = @options[:language] if @options[:language]
86
+ parameters[:categories] = Array(@options[:categories]).join(',') if @options[:categories]
87
+ parameters
88
+ end
89
+
90
+ def validate_response!(response)
91
+ if response.respond_to?(:body) && response.body.to_s.bytesize > max_response_bytes
92
+ raise Prescient::ToolInvalidResponseError, 'SearXNG response exceeds configured size limit'
93
+ end
94
+
95
+ return if response.success?
96
+
97
+ error_class = case response.code.to_i
98
+ when 401, 403 then Prescient::AuthenticationError
99
+ when 429 then Prescient::RateLimitError
100
+ when 500..599 then Prescient::ToolConnectionError
101
+ else Prescient::ToolError
102
+ end
103
+ raise error_class, "SearXNG returned HTTP #{response.code}"
104
+ end
105
+
106
+ def normalize_results(payload, limit)
107
+ raw_results = payload.is_a?(Hash) ? payload['results'] : nil
108
+ unless raw_results.is_a?(Array)
109
+ raise Prescient::ToolInvalidResponseError, 'SearXNG response did not contain results'
110
+ end
111
+
112
+ raw_results.filter_map { |result|
113
+ next unless result.is_a?(Hash)
114
+ next unless result['url'].is_a?(String) && !result['url'].empty?
115
+
116
+ {
117
+ title: result['title'].to_s,
118
+ url: result['url'],
119
+ snippet: result['content'].to_s,
120
+ source: 'searxng',
121
+ }
122
+ }.first(limit)
123
+ end
124
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Namespace for external capability adapters.
4
+ module Prescient::Tool
5
+ autoload :SearXNG, 'prescient/tool/searxng'
6
+ autoload :SearchApi, 'prescient/tool/search_api'
7
+
8
+ # Base contract for explicit external tool invocation.
9
+ class Base
10
+ # @return [Integer] Default request timeout in seconds
11
+ DEFAULT_TIMEOUT = 5
12
+ # @return [Integer] Default maximum number of returned results
13
+ DEFAULT_MAX_RESULTS = 5
14
+ # @return [Integer] Maximum accepted search query length
15
+ MAX_QUERY_LENGTH = 2_000
16
+ # @return [Integer] Maximum accepted tool response size in bytes
17
+ DEFAULT_MAX_RESPONSE_BYTES = 1_048_576
18
+
19
+ # @return [Hash<Symbol, untyped>] Tool configuration options
20
+ attr_reader :options
21
+
22
+ # @param options [Hash] Tool-specific configuration options
23
+ def initialize(**options)
24
+ @options = options
25
+ validate_configuration!
26
+ end
27
+
28
+ # Execute a search using the tool.
29
+ # @param query [String] Search query
30
+ # @param options [Hash] Per-request options
31
+ # @return [Hash] Normalized tool result
32
+ # @raise [NotImplementedError] If the adapter does not support searching
33
+ def search(query, **options)
34
+ raise NotImplementedError, "#{self.class} must implement #search"
35
+ end
36
+
37
+ protected
38
+
39
+ # Validate adapter configuration.
40
+ # @return [void]
41
+ def validate_configuration!
42
+ # Override in subclasses.
43
+ end
44
+
45
+ # @param query [String] Search query
46
+ # @return [String] Validated query
47
+ def validate_query(query)
48
+ unless query.is_a?(String) && !query.strip.empty?
49
+ raise Prescient::ToolConfigurationError, 'search query must be a non-empty string'
50
+ end
51
+
52
+ cleaned_query = query.strip
53
+ if cleaned_query.length > MAX_QUERY_LENGTH
54
+ raise Prescient::ToolConfigurationError,
55
+ "search query cannot contain more than #{MAX_QUERY_LENGTH} characters"
56
+ end
57
+
58
+ cleaned_query
59
+ end
60
+
61
+ # @param value [Object] Requested result limit
62
+ # @return [Integer] Validated result limit
63
+ def result_limit(value)
64
+ limit = value.nil? ? @options.fetch(:max_results, DEFAULT_MAX_RESULTS) : value
65
+ unless limit.is_a?(Integer) && limit.positive?
66
+ raise Prescient::ToolConfigurationError, 'max_results must be a positive integer'
67
+ end
68
+
69
+ [limit, 20].min
70
+ end
71
+
72
+ # @param value [Object] Requested timeout
73
+ # @return [Numeric] Validated timeout
74
+ def request_timeout(value)
75
+ timeout = value.nil? ? @options.fetch(:timeout, DEFAULT_TIMEOUT) : value
76
+ unless timeout.is_a?(Numeric) && timeout.positive?
77
+ raise Prescient::ToolConfigurationError, 'timeout must be a positive number'
78
+ end
79
+
80
+ timeout
81
+ end
82
+
83
+ # @return [Integer] Maximum accepted response size
84
+ def max_response_bytes
85
+ value = @options.fetch(:max_response_bytes, DEFAULT_MAX_RESPONSE_BYTES)
86
+ unless value.is_a?(Integer) && value.positive?
87
+ raise Prescient::ToolConfigurationError,
88
+ 'max_response_bytes must be a positive integer'
89
+ end
90
+
91
+ value
92
+ end
93
+ end
94
+
95
+ # Ordered implementations for one logical tool capability.
96
+ class Group < Base
97
+ # @return [Array<Base>] Ordered capability implementations
98
+ attr_reader :adapters
99
+
100
+ # @param adapters [Array<Base>] Ordered capability implementations
101
+ def initialize(adapters:)
102
+ super
103
+ @adapters = adapters
104
+ raise Prescient::ToolConfigurationError, 'tool group requires an adapter' if @adapters.empty?
105
+ end
106
+
107
+ # Execute the first successful adapter, falling back only for transient
108
+ # connection and rate-limit failures.
109
+ # @param query [String] Search query
110
+ # @param options [Hash] Per-request options
111
+ # @return [Hash] Normalized tool result
112
+ def search(query, **options)
113
+ failures = []
114
+ @adapters.each do |adapter|
115
+ return adapter.search(query, **options)
116
+ rescue Prescient::ToolConnectionError, Prescient::RateLimitError => e
117
+ failures << e
118
+ end
119
+
120
+ raise failures.last
121
+ end
122
+ end
123
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Prescient
4
4
  # Current Prescient gem version.
5
- VERSION = '0.5.0'
5
+ VERSION = '0.7.0'
6
6
  end
data/lib/prescient.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'prescient/version'
4
4
  require_relative 'prescient/errors'
5
+ Prescient.autoload :Tool, 'prescient/tool'
5
6
  require_relative 'prescient/pgvector'
6
7
  require_relative 'prescient/base'
7
8
  require_relative 'prescient/provider/ollama'
@@ -14,7 +15,6 @@ require_relative 'prescient/provider/deepseek'
14
15
  require_relative 'prescient/provider/xai'
15
16
  require_relative 'prescient/configuration_loader'
16
17
  require_relative 'prescient/client'
17
- require_relative 'prescient/cli'
18
18
 
19
19
  # Main Prescient module for AI provider abstraction
20
20
  #
@@ -35,6 +35,10 @@ require_relative 'prescient/cli'
35
35
  # embedding = client.generate_embedding("Some text to embed")
36
36
  # puts embedding.length # => 1536 (for OpenAI text-embedding-3-small)
37
37
  module Prescient
38
+ autoload :Tool, 'prescient/tool'
39
+ autoload :API, 'prescient/api'
40
+ autoload :CLI, 'prescient/cli'
41
+
38
42
  # Configure Prescient with custom settings and providers
39
43
  #
40
44
  # @example Configure with custom provider
@@ -59,6 +63,13 @@ module Prescient
59
63
  @_configuration ||= Configuration.new
60
64
  end
61
65
 
66
+ # Look up a configured external tool.
67
+ # @param name [Symbol, String] Tool name
68
+ # @return [Prescient::Tool::Base, nil] Configured tool instance
69
+ def self.tool(name)
70
+ configuration.tool(name)
71
+ end
72
+
62
73
  # Reset configuration to defaults
63
74
  #
64
75
  # @return [Configuration] New configuration instance
@@ -82,6 +93,7 @@ module Prescient
82
93
  else
83
94
  Configuration.new.tap do |config|
84
95
  configure_default_providers(config, env)
96
+ configure_default_tools(config, env)
85
97
  end
86
98
  end
87
99
 
@@ -117,6 +129,9 @@ module Prescient
117
129
  # @return [Hash] Registered providers configuration
118
130
  attr_reader :providers
119
131
 
132
+ # @return [Hash] Registered tools configuration
133
+ attr_reader :tools
134
+
120
135
  # Initialize configuration with default values
121
136
  def initialize
122
137
  @default_provider = :ollama
@@ -127,6 +142,8 @@ module Prescient
127
142
  @sensitive_keys = []
128
143
  @providers = {}
129
144
  @provider_instances = {} # : Hash[Symbol, untyped]
145
+ @tools = {}
146
+ @tool_instances = {} # : Hash[Symbol, untyped]
130
147
  end
131
148
 
132
149
  # Configure additional keys to remove from provider information.
@@ -175,6 +192,52 @@ module Prescient
175
192
  @provider_instances[provider_name] ||= provider_config[:class].new(**provider_options)
176
193
  end
177
194
 
195
+ # Register an external tool.
196
+ # @param name [Symbol] Unique identifier for the tool
197
+ # @param tool_class [Class] Tool adapter class
198
+ # @param options [Hash] Tool-specific configuration options
199
+ # @return [void]
200
+ def add_tool(name, tool_class, **options)
201
+ tool_name = name.to_sym
202
+ @tools[tool_name] = {
203
+ class: tool_class,
204
+ options: options,
205
+ }
206
+ @tool_instances.delete(tool_name)
207
+ end
208
+
209
+ # Register ordered implementations for one logical external tool.
210
+ # @param name [Symbol] Unique logical capability identifier
211
+ # @param adapters [Array<Hash>] Adapter class and option configurations
212
+ # @return [void]
213
+ def add_tool_group(name, adapters)
214
+ tool_name = name.to_sym
215
+ @tools[tool_name] = { adapters: adapters }
216
+ @tool_instances.delete(tool_name)
217
+ end
218
+
219
+ # Instantiate a tool by name.
220
+ # @param name [Symbol, String] Tool name
221
+ # @return [Prescient::Tool::Base, nil] Tool instance or nil
222
+ def tool(name)
223
+ tool_name = name.to_sym
224
+ tool_config = @tools[tool_name]
225
+ return nil unless tool_config
226
+
227
+ if tool_config[:adapters]
228
+ @tool_instances[tool_name] ||= Prescient::Tool::Group.new(
229
+ adapters: tool_config[:adapters].map do |adapter|
230
+ adapter_options = adapter[:options] # : Hash[Symbol, untyped]
231
+ adapter[:class].new(**adapter_options)
232
+ end,
233
+ )
234
+ return @tool_instances[tool_name]
235
+ end
236
+
237
+ tool_options = tool_config[:options] # : Hash[Symbol, untyped]
238
+ @tool_instances[tool_name] ||= tool_config[:class].new(**tool_options)
239
+ end
240
+
178
241
  # Get list of providers that currently pass {Prescient::Base#available?}.
179
242
  #
180
243
  # Providers are included when their health check reports `reachable: true`,
@@ -204,6 +267,12 @@ module Prescient
204
267
  configure_huggingface(config, env)
205
268
  end
206
269
 
270
+ def configure_default_tools(config, env)
271
+ return unless env['SEARXNG_URL']
272
+
273
+ config.add_tool(:web_search, Prescient::Tool::SearXNG, url: env['SEARXNG_URL'])
274
+ end
275
+
207
276
  def configure_ollama(config, env)
208
277
  config.add_provider(
209
278
  :ollama,
@@ -305,5 +374,6 @@ module Prescient
305
374
  # Default configuration
306
375
  configure do |config|
307
376
  configure_default_providers(config, ENV)
377
+ configure_default_tools(config, ENV)
308
378
  end
309
379
  end
@@ -58,6 +58,12 @@
58
58
  "additionalProperties": {
59
59
  "$ref": "#/$defs/provider"
60
60
  }
61
+ },
62
+ "tools": {
63
+ "type": "object",
64
+ "additionalProperties": {
65
+ "$ref": "#/$defs/tool"
66
+ }
61
67
  }
62
68
  },
63
69
  "$defs": {
@@ -148,6 +154,119 @@
148
154
  "type": "string"
149
155
  }
150
156
  }
157
+ },
158
+ "tool": {
159
+ "type": "object",
160
+ "additionalProperties": false,
161
+ "anyOf": [
162
+ {
163
+ "required": [
164
+ "type"
165
+ ]
166
+ },
167
+ {
168
+ "required": [
169
+ "adapters"
170
+ ]
171
+ }
172
+ ],
173
+ "properties": {
174
+ "adapters": {
175
+ "type": "array",
176
+ "minItems": 1,
177
+ "items": {
178
+ "$ref": "#/$defs/tool"
179
+ }
180
+ },
181
+ "type": {
182
+ "type": "string",
183
+ "enum": [
184
+ "searxng",
185
+ "searchapi"
186
+ ]
187
+ },
188
+ "api_key": {
189
+ "type": "string"
190
+ },
191
+ "api_key_env": {
192
+ "type": "string"
193
+ },
194
+ "engine": {
195
+ "type": "string"
196
+ },
197
+ "engine_env": {
198
+ "type": "string"
199
+ },
200
+ "location": {
201
+ "type": "string"
202
+ },
203
+ "location_env": {
204
+ "type": "string"
205
+ },
206
+ "hl": {
207
+ "type": "string"
208
+ },
209
+ "hl_env": {
210
+ "type": "string"
211
+ },
212
+ "gl": {
213
+ "type": "string"
214
+ },
215
+ "gl_env": {
216
+ "type": "string"
217
+ },
218
+ "url": {
219
+ "type": "string",
220
+ "format": "uri-reference"
221
+ },
222
+ "url_env": {
223
+ "type": "string"
224
+ },
225
+ "language": {
226
+ "type": "string"
227
+ },
228
+ "language_env": {
229
+ "type": "string"
230
+ },
231
+ "categories": {
232
+ "oneOf": [
233
+ {
234
+ "type": "string"
235
+ },
236
+ {
237
+ "type": "array",
238
+ "items": {
239
+ "type": "string"
240
+ }
241
+ }
242
+ ]
243
+ },
244
+ "categories_env": {
245
+ "type": "string"
246
+ },
247
+ "timeout": {
248
+ "type": "number",
249
+ "exclusiveMinimum": 0
250
+ },
251
+ "timeout_env": {
252
+ "type": "string"
253
+ },
254
+ "max_results": {
255
+ "type": "integer",
256
+ "minimum": 1,
257
+ "maximum": 20
258
+ },
259
+ "max_results_env": {
260
+ "type": "string"
261
+ },
262
+ "max_response_bytes": {
263
+ "type": "integer",
264
+ "minimum": 1
265
+ },
266
+ "max_response_bytes_env": {
267
+ "type": "string"
268
+ }
269
+ }
151
270
  }
152
271
  }
153
272
  }
@@ -0,0 +1,18 @@
1
+ # Development settings for the SearXNG service in docker-compose.yml.
2
+ use_default_settings: true
3
+
4
+ general:
5
+ debug: false
6
+ instance_name: Prescient SearXNG
7
+
8
+ search:
9
+ formats:
10
+ - html
11
+ - json
12
+
13
+ server:
14
+ secret_key: prescient-development-secret
15
+ bind_address: 0.0.0.0
16
+ base_url: http://localhost:8080/
17
+ limiter: false
18
+ image_proxy: false
data/sig/prescient.rbs CHANGED
@@ -33,6 +33,49 @@ module Prescient
33
33
  class ProviderError < Error
34
34
  end
35
35
 
36
+ class ToolError < Error
37
+ end
38
+
39
+ class ToolConfigurationError < ToolError
40
+ end
41
+
42
+ class ToolConnectionError < ToolError
43
+ end
44
+
45
+ class ToolInvalidResponseError < ToolError
46
+ end
47
+
48
+ module Tool
49
+ class Base
50
+ DEFAULT_TIMEOUT: Integer
51
+ DEFAULT_MAX_RESULTS: Integer
52
+ MAX_QUERY_LENGTH: Integer
53
+ DEFAULT_MAX_RESPONSE_BYTES: Integer
54
+
55
+ attr_reader options: Hash[Symbol, untyped]
56
+
57
+ def initialize: (**untyped) -> void
58
+ def search: (String, **untyped) -> Hash[Symbol, untyped]
59
+ end
60
+
61
+ class Group < Base
62
+ attr_reader adapters: Array[Base]
63
+ def initialize: (adapters: Array[Base]) -> void
64
+ def search: (String, **untyped) -> Hash[Symbol, untyped]
65
+ end
66
+
67
+ class SearXNG < Base
68
+ def initialize: (**untyped) -> void
69
+ def search: (String, ?limit: Integer?, ?timeout: Numeric?) -> Hash[Symbol, untyped]
70
+ end
71
+
72
+ class SearchApi < Base
73
+ API_URL: String
74
+ def initialize: (**untyped) -> void
75
+ def search: (String, ?limit: Integer?, ?timeout: Numeric?) -> Hash[Symbol, untyped]
76
+ end
77
+ end
78
+
36
79
  class Base
37
80
  attr_reader options: Hash[Symbol, untyped]
38
81
  attr_reader provider_name: String
@@ -257,11 +300,15 @@ module Prescient
257
300
  attr_accessor fallback_providers: Array[Symbol]
258
301
  attr_reader sensitive_keys: Array[Symbol]
259
302
  attr_reader providers: Hash[Symbol, Hash[Symbol, untyped]]
303
+ attr_reader tools: Hash[Symbol, Hash[Symbol, untyped]]
260
304
 
261
305
  def initialize: () -> void
262
306
  def sensitive_keys=: (Array[Symbol | String]) -> Array[Symbol]
263
307
  def add_provider: (Symbol | String, Class, **untyped) -> void
264
308
  def provider: (Symbol | String) -> untyped
309
+ def add_tool: (Symbol | String, Class, **untyped) -> void
310
+ def add_tool_group: (Symbol | String, Array[Hash[Symbol, untyped]]) -> void
311
+ def tool: (Symbol | String) -> untyped
265
312
  def available_providers: () -> Array[Symbol]
266
313
  end
267
314
 
@@ -298,11 +345,23 @@ module Prescient
298
345
  def run: () -> Integer
299
346
  end
300
347
 
348
+ class API
349
+ DEFAULT_MAX_BODY_BYTES: Integer
350
+ MAX_BATCH_SIZE: Integer
351
+ API_VERSION: String
352
+ ROUTES: Hash[Array[String], Symbol]
353
+
354
+ def initialize: (?authentication: untyped, ?max_body_bytes: Integer) -> void
355
+ def call: (Hash[String, untyped]) -> [Integer, Hash[String, String], Array[String]]
356
+ end
357
+
301
358
  class ConfigurationLoader
302
359
  CONFIGURATION_VERSION: Integer
303
360
  TOP_LEVEL_KEYS: Array[String]
304
361
  PROVIDER_TYPES: Hash[String, untyped]
305
362
  COMMON_PROVIDER_KEYS: Array[String]
363
+ TOOL_TYPES: Hash[String, untyped]
364
+ COMMON_TOOL_KEYS: Array[String]
306
365
  PROMPT_TEMPLATE_KEYS: Array[String]
307
366
  ATTR_KEYS: Array[String]
308
367
 
@@ -325,11 +384,14 @@ module Prescient
325
384
  def self.client: (?Symbol, ?enable_fallback: bool, ?provider_options: Hash[Symbol, untyped]) -> Client
326
385
  def self.generate_embedding: (String, ?provider: Symbol, ?enable_fallback: bool, **untyped) -> Array[Float]
327
386
  def self.generate_response: (String, ?Array[untyped], ?provider: Symbol, ?enable_fallback: bool, **untyped) -> Hash[Symbol, untyped]
387
+ def self.search_and_generate: (String, ?tool: Symbol | String, ?provider: Symbol, ?limit: Integer?, ?enable_fallback: bool, ?provider_options: Hash[Symbol, untyped], **untyped) -> Hash[Symbol, untyped]
328
388
  def self.health_check: (?provider: Symbol) -> Hash[Symbol, untyped]
389
+ def self.tool: (Symbol | String) -> untyped
329
390
 
330
391
  private
331
392
 
332
393
  def self.configure_default_providers: (Configuration, untyped) -> void
394
+ def self.configure_default_tools: (Configuration, untyped) -> void
333
395
  def self.configure_ollama: (Configuration, untyped) -> void
334
396
  def self.configure_openai: (Configuration, untyped) -> void
335
397
  def self.configure_anthropic: (Configuration, untyped) -> void