prescient 0.6.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/INTEGRATION_GUIDE.md +13 -1
- data/README.md +159 -20
- data/docker-compose.yml +22 -0
- data/examples/README.md +34 -0
- data/examples/web_search.rb +34 -0
- data/lib/prescient/api.rb +52 -0
- data/lib/prescient/cli.rb +135 -6
- data/lib/prescient/client.rb +26 -0
- data/lib/prescient/configuration_loader.rb +171 -0
- data/lib/prescient/errors.rb +12 -0
- data/lib/prescient/provider/openai.rb +0 -2
- data/lib/prescient/tool/search_api.rb +126 -0
- data/lib/prescient/tool/searxng.rb +124 -0
- data/lib/prescient/tool.rb +123 -0
- data/lib/prescient/version.rb +1 -1
- data/lib/prescient.rb +68 -0
- data/schema/prescient.configuration.schema.json +119 -0
- data/searxng/settings.yml +18 -0
- data/sig/prescient.rbs +52 -0
- metadata +11 -5
data/lib/prescient/cli.rb
CHANGED
|
@@ -47,8 +47,8 @@ class Prescient::CLI
|
|
|
47
47
|
chat_model: llama3.2:3b
|
|
48
48
|
# prompt_templates:
|
|
49
49
|
# system_prompt: You are a concise assistant.
|
|
50
|
-
# no_context_template: "%<system_prompt>s
|
|
51
|
-
# with_context_template: "%<system_prompt>s
|
|
50
|
+
# no_context_template: "%<system_prompt>s\x5Cn\x5CnUser: %<query>s"
|
|
51
|
+
# with_context_template: "%<system_prompt>s\x5Cn\x5CnContext:\x5Cn%<context>s\x5Cn\x5CnUser: %<query>s"
|
|
52
52
|
|
|
53
53
|
# Uncomment a cloud provider and set its credential in the environment.
|
|
54
54
|
# openai:
|
|
@@ -58,7 +58,7 @@ class Prescient::CLI
|
|
|
58
58
|
# chat_model: gpt-4.1-mini
|
|
59
59
|
# prompt_templates:
|
|
60
60
|
# system_prompt: You are a concise assistant.
|
|
61
|
-
# no_context_template: "%<system_prompt>s\
|
|
61
|
+
# no_context_template: "%<system_prompt>s\x5Cn\x5CnUser: %<query>s"
|
|
62
62
|
|
|
63
63
|
# anthropic:
|
|
64
64
|
# type: anthropic
|
|
@@ -93,6 +93,62 @@ class Prescient::CLI
|
|
|
93
93
|
# api_key_env: HUGGINGFACE_API_KEY
|
|
94
94
|
# embedding_model: sentence-transformers/all-MiniLM-L6-v2
|
|
95
95
|
# chat_model: google/gemma-2-2b-it
|
|
96
|
+
|
|
97
|
+
# External tools are opt-in and separate from AI providers. They can be
|
|
98
|
+
# used directly with `prescient search`, or as context with
|
|
99
|
+
# `prescient search --generate`.
|
|
100
|
+
#
|
|
101
|
+
# The CLI also registers `web_search` automatically when SEARXNG_URL is
|
|
102
|
+
# set and no YAML tool configuration is provided.
|
|
103
|
+
tools:
|
|
104
|
+
# Local SearXNG example. Uncomment this block to configure a tool in YAML.
|
|
105
|
+
# web_search:
|
|
106
|
+
# type: searxng
|
|
107
|
+
# url: http://localhost:8080
|
|
108
|
+
# timeout: 5
|
|
109
|
+
# max_results: 5
|
|
110
|
+
# language: en
|
|
111
|
+
# categories:
|
|
112
|
+
# - general
|
|
113
|
+
# - science
|
|
114
|
+
# max_response_bytes: 1048576
|
|
115
|
+
|
|
116
|
+
# SearchApi example. It uses SearchApi's Google engine by default and
|
|
117
|
+
# authenticates with a Bearer token from the environment.
|
|
118
|
+
# searchapi_web:
|
|
119
|
+
# type: searchapi
|
|
120
|
+
# api_key_env: SEARCHAPI_API_KEY
|
|
121
|
+
# engine: google
|
|
122
|
+
# location: New York
|
|
123
|
+
# hl: en
|
|
124
|
+
# gl: us
|
|
125
|
+
# timeout: 10
|
|
126
|
+
# max_results: 5
|
|
127
|
+
|
|
128
|
+
# Capability fallback. Adapters are tried in order, and fallback occurs
|
|
129
|
+
# only for transient connection or rate-limit failures.
|
|
130
|
+
# resilient_search:
|
|
131
|
+
# adapters:
|
|
132
|
+
# - type: searxng
|
|
133
|
+
# url_env: SEARXNG_URL
|
|
134
|
+
# - type: searchapi
|
|
135
|
+
# api_key_env: SEARCHAPI_API_KEY
|
|
136
|
+
# engine: google
|
|
137
|
+
|
|
138
|
+
# Prefer an environment reference when the URL differs by environment
|
|
139
|
+
# or should not be committed. Use `--tool research_search` to select a
|
|
140
|
+
# tool with a custom name.
|
|
141
|
+
# research_search:
|
|
142
|
+
# type: searxng
|
|
143
|
+
# url_env: SEARXNG_URL
|
|
144
|
+
# timeout_env: SEARXNG_TIMEOUT
|
|
145
|
+
# max_results_env: SEARXNG_MAX_RESULTS
|
|
146
|
+
# language_env: SEARXNG_LANGUAGE
|
|
147
|
+
# categories_env: SEARXNG_CATEGORIES
|
|
148
|
+
|
|
149
|
+
# Search results are returned directly by default. Add `--generate` to
|
|
150
|
+
# feed normalized results to the selected AI provider. Omit `--generate`
|
|
151
|
+
# when the caller should handle the search results itself.
|
|
96
152
|
YAML
|
|
97
153
|
|
|
98
154
|
# Raised when command-line arguments are invalid or incomplete.
|
|
@@ -149,6 +205,7 @@ class Prescient::CLI
|
|
|
149
205
|
when 'health' then health
|
|
150
206
|
when 'generate' then generate
|
|
151
207
|
when 'embed' then embed
|
|
208
|
+
when 'search' then search
|
|
152
209
|
when 'config' then config
|
|
153
210
|
when 'help', '--help', '-h' then print_help(0)
|
|
154
211
|
else
|
|
@@ -214,6 +271,52 @@ class Prescient::CLI
|
|
|
214
271
|
0
|
|
215
272
|
end
|
|
216
273
|
|
|
274
|
+
def search
|
|
275
|
+
options = parse_options(
|
|
276
|
+
'Search with a configured external tool',
|
|
277
|
+
fallback: true, tool: true, limit: true, generate: true,
|
|
278
|
+
)
|
|
279
|
+
return options if options.is_a?(Integer)
|
|
280
|
+
|
|
281
|
+
query = read_text(options[:arguments], 'query')
|
|
282
|
+
tool_name = (options[:tool] || 'web_search').to_sym
|
|
283
|
+
return generate_search_response(query, tool_name, options) if options[:generate]
|
|
284
|
+
|
|
285
|
+
tool = Prescient.tool(tool_name)
|
|
286
|
+
raise UsageError, "tool not configured: #{tool_name}" unless tool
|
|
287
|
+
|
|
288
|
+
result = tool.search(query, limit: options[:limit])
|
|
289
|
+
if options[:format] == 'json'
|
|
290
|
+
print_json(result)
|
|
291
|
+
else
|
|
292
|
+
print_search_results(result[:results])
|
|
293
|
+
end
|
|
294
|
+
0
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def generate_search_response(query, tool_name, options)
|
|
298
|
+
response = Prescient.search_and_generate(
|
|
299
|
+
query,
|
|
300
|
+
tool: tool_name,
|
|
301
|
+
provider: options[:provider]&.to_sym,
|
|
302
|
+
limit: options[:limit],
|
|
303
|
+
enable_fallback: options[:fallback],
|
|
304
|
+
provider_options: provider_options(options),
|
|
305
|
+
**model_options(options),
|
|
306
|
+
)
|
|
307
|
+
options[:format] == 'json' ? print_json(response) : @output.puts(response[:response])
|
|
308
|
+
0
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def print_search_results(results)
|
|
312
|
+
results.each do |item|
|
|
313
|
+
@output.puts item[:title]
|
|
314
|
+
@output.puts item[:url]
|
|
315
|
+
@output.puts item[:snippet] unless item[:snippet].empty?
|
|
316
|
+
@output.puts
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
217
320
|
def config
|
|
218
321
|
subcommand = @arguments.shift
|
|
219
322
|
case subcommand
|
|
@@ -251,16 +354,24 @@ class Prescient::CLI
|
|
|
251
354
|
raise Prescient::Error, 'default provider is not configured'
|
|
252
355
|
end
|
|
253
356
|
|
|
254
|
-
configuration.providers.each_key
|
|
357
|
+
configuration.providers.each_key do |name|
|
|
358
|
+
configuration.provider(name)
|
|
359
|
+
end
|
|
360
|
+
configuration.tools.each_key do |name|
|
|
361
|
+
configuration.tool(name)
|
|
362
|
+
end
|
|
255
363
|
end
|
|
256
364
|
|
|
257
|
-
def parse_options(description, fallback: false)
|
|
365
|
+
def parse_options(description, fallback: false, tool: false, limit: false, generate: false)
|
|
258
366
|
options = { format: 'text', fallback: fallback }
|
|
259
367
|
parser = OptionParser.new do |parser|
|
|
260
368
|
parser.banner = "Usage: prescient #{@arguments.first || 'command'} [options]"
|
|
261
369
|
parser.separator description
|
|
370
|
+
parser.separator ''
|
|
371
|
+
parser.separator 'Global options:'
|
|
262
372
|
add_common_options(parser, options)
|
|
263
373
|
parser.on('--no-fallback', 'Disable provider fallback') { options[:fallback] = false } if fallback
|
|
374
|
+
add_tool_options(parser, options, tool:, limit:, generate:)
|
|
264
375
|
parser.on('-h', '--help', 'Show command help') do
|
|
265
376
|
@output.puts parser
|
|
266
377
|
throw :help_shown, 0
|
|
@@ -274,6 +385,18 @@ class Prescient::CLI
|
|
|
274
385
|
options
|
|
275
386
|
end
|
|
276
387
|
|
|
388
|
+
def add_tool_options(parser, options, tool:, limit:, generate:)
|
|
389
|
+
return unless tool || limit || generate
|
|
390
|
+
|
|
391
|
+
parser.separator ''
|
|
392
|
+
parser.separator 'Search options:'
|
|
393
|
+
parser.on('--tool NAME', 'Use a configured external tool') { |value| options[:tool] = value } if tool
|
|
394
|
+
parser.on('--generate', 'Use search results as AI provider context') { options[:generate] = true } if generate
|
|
395
|
+
return unless limit
|
|
396
|
+
|
|
397
|
+
parser.on('--limit COUNT', Integer, 'Limit the number of results') { |value| options[:limit] = value }
|
|
398
|
+
end
|
|
399
|
+
|
|
277
400
|
def add_common_options(parser, options)
|
|
278
401
|
parser.on('--config PATH', 'Load configuration from a YAML file') do |value|
|
|
279
402
|
options[:config] = value
|
|
@@ -425,10 +548,11 @@ class Prescient::CLI
|
|
|
425
548
|
health Check provider health
|
|
426
549
|
generate TEXT Generate a text response
|
|
427
550
|
embed TEXT Generate an embedding
|
|
551
|
+
search TEXT Search with a configured external tool
|
|
428
552
|
config validate Validate the current configuration
|
|
429
553
|
config example Generate an annotated YAML configuration example
|
|
430
554
|
|
|
431
|
-
|
|
555
|
+
Global options:
|
|
432
556
|
--config PATH Load configuration from a YAML file
|
|
433
557
|
--provider NAME Select a provider
|
|
434
558
|
--model NAME Override the selected operation's model
|
|
@@ -444,6 +568,11 @@ class Prescient::CLI
|
|
|
444
568
|
--api-key KEY Use an API key for the operation
|
|
445
569
|
--api-key-env NAME Read the API key from an environment variable
|
|
446
570
|
--format FORMAT Use text or json output
|
|
571
|
+
|
|
572
|
+
Search options:
|
|
573
|
+
--tool NAME Select an external tool for search
|
|
574
|
+
--generate Use search results as AI provider context
|
|
575
|
+
--limit COUNT Limit search results
|
|
447
576
|
HELP
|
|
448
577
|
status
|
|
449
578
|
end
|
data/lib/prescient/client.rb
CHANGED
|
@@ -247,6 +247,32 @@ module Prescient
|
|
|
247
247
|
client(provider, enable_fallback: enable_fallback).generate_response(prompt, context_items, **options)
|
|
248
248
|
end
|
|
249
249
|
|
|
250
|
+
# Search with an explicit external tool and optionally use the normalized
|
|
251
|
+
# results as context for a configured AI provider.
|
|
252
|
+
#
|
|
253
|
+
# @param query [String] Search query and generation prompt
|
|
254
|
+
# @param tool [Symbol, String] Configured external tool name
|
|
255
|
+
# @param provider [Symbol, nil] Provider to use for generation
|
|
256
|
+
# @param limit [Integer, nil] Maximum number of search results
|
|
257
|
+
# @param enable_fallback [Boolean] Whether provider fallback is enabled
|
|
258
|
+
# @param provider_options [Hash] Temporary provider configuration overrides
|
|
259
|
+
# @return [Hash] Normalized provider response
|
|
260
|
+
def self.search_and_generate(query, tool: :web_search, provider: nil, limit: nil,
|
|
261
|
+
enable_fallback: true, provider_options: {}, **options)
|
|
262
|
+
search_tool = self.tool(tool)
|
|
263
|
+
raise Prescient::ToolConfigurationError, "tool not configured: #{tool}" unless search_tool
|
|
264
|
+
|
|
265
|
+
search_result = search_tool.search(query, limit: limit)
|
|
266
|
+
context_items = search_result[:results]
|
|
267
|
+
raise Prescient::ToolInvalidResponseError, 'tool results must be an array' unless context_items.is_a?(Array)
|
|
268
|
+
|
|
269
|
+
client(provider, enable_fallback:, provider_options:).generate_response(
|
|
270
|
+
query,
|
|
271
|
+
context_items,
|
|
272
|
+
**options,
|
|
273
|
+
)
|
|
274
|
+
end
|
|
275
|
+
|
|
250
276
|
# Return the health status of a configured provider.
|
|
251
277
|
#
|
|
252
278
|
# @param provider [Symbol, nil] Provider to check
|
|
@@ -16,6 +16,7 @@ class Prescient::ConfigurationLoader
|
|
|
16
16
|
'fallback_providers',
|
|
17
17
|
'fallback_providers_env',
|
|
18
18
|
'providers',
|
|
19
|
+
'tools',
|
|
19
20
|
'retry_attempts',
|
|
20
21
|
'retry_attempts_env',
|
|
21
22
|
'retry_delay',
|
|
@@ -39,6 +40,12 @@ class Prescient::ConfigurationLoader
|
|
|
39
40
|
'xai' => Prescient::Provider::XAI,
|
|
40
41
|
}.freeze
|
|
41
42
|
|
|
43
|
+
# Tool names mapped to lazily resolved adapter constants.
|
|
44
|
+
TOOL_TYPES = {
|
|
45
|
+
'searchapi' => :SearchApi,
|
|
46
|
+
'searxng' => :SearXNG,
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
42
49
|
# Provider-specific keys shared by all supported adapters.
|
|
43
50
|
COMMON_PROVIDER_KEYS = [
|
|
44
51
|
'api_key',
|
|
@@ -61,6 +68,32 @@ class Prescient::ConfigurationLoader
|
|
|
61
68
|
'url_env',
|
|
62
69
|
].freeze
|
|
63
70
|
|
|
71
|
+
# Tool-specific keys accepted by all configured adapters.
|
|
72
|
+
COMMON_TOOL_KEYS = [
|
|
73
|
+
'api_key',
|
|
74
|
+
'api_key_env',
|
|
75
|
+
'categories',
|
|
76
|
+
'categories_env',
|
|
77
|
+
'engine',
|
|
78
|
+
'engine_env',
|
|
79
|
+
'gl',
|
|
80
|
+
'gl_env',
|
|
81
|
+
'hl',
|
|
82
|
+
'hl_env',
|
|
83
|
+
'language',
|
|
84
|
+
'language_env',
|
|
85
|
+
'location',
|
|
86
|
+
'location_env',
|
|
87
|
+
'max_response_bytes',
|
|
88
|
+
'max_response_bytes_env',
|
|
89
|
+
'max_results',
|
|
90
|
+
'max_results_env',
|
|
91
|
+
'timeout',
|
|
92
|
+
'timeout_env',
|
|
93
|
+
'url',
|
|
94
|
+
'url_env',
|
|
95
|
+
].freeze
|
|
96
|
+
|
|
64
97
|
# Prompt template keys supported by provider configuration.
|
|
65
98
|
PROMPT_TEMPLATE_KEYS = ['system_prompt', 'no_context_template', 'with_context_template'].freeze
|
|
66
99
|
|
|
@@ -131,6 +164,7 @@ class Prescient::ConfigurationLoader
|
|
|
131
164
|
def load_hash(data, source: nil)
|
|
132
165
|
configuration = Prescient::Configuration.new
|
|
133
166
|
Prescient.send(:configure_default_providers, configuration, @env)
|
|
167
|
+
Prescient.send(:configure_default_tools, configuration, @env)
|
|
134
168
|
apply!(configuration, data, source:)
|
|
135
169
|
configuration
|
|
136
170
|
end
|
|
@@ -147,6 +181,7 @@ class Prescient::ConfigurationLoader
|
|
|
147
181
|
|
|
148
182
|
apply_scalar_settings(configuration, normalized, source:)
|
|
149
183
|
apply_provider_settings(configuration, normalized, source:)
|
|
184
|
+
apply_tool_settings(configuration, normalized, source:)
|
|
150
185
|
configuration
|
|
151
186
|
end
|
|
152
187
|
|
|
@@ -234,6 +269,28 @@ class Prescient::ConfigurationLoader
|
|
|
234
269
|
end
|
|
235
270
|
end
|
|
236
271
|
|
|
272
|
+
def apply_tool_settings(configuration, data, source:)
|
|
273
|
+
return unless key_present?(data, :tools)
|
|
274
|
+
|
|
275
|
+
tools = data[:tools]
|
|
276
|
+
unless tools.is_a?(Hash)
|
|
277
|
+
raise Prescient::Error, "Configuration#{" in #{source}" if source} tools must be a mapping"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
tools.each do |name, tool_data|
|
|
281
|
+
tool_name = name.to_sym
|
|
282
|
+
tool_settings = normalize_keys(tool_data)
|
|
283
|
+
validate_tool!(tool_name, tool_settings, source:)
|
|
284
|
+
|
|
285
|
+
if tool_settings.key?(:adapters)
|
|
286
|
+
configuration.add_tool_group(tool_name, resolve_tool_adapters(tool_settings, source:))
|
|
287
|
+
else
|
|
288
|
+
tool_options = resolve_tool_options(tool_settings, source:)
|
|
289
|
+
configuration.add_tool(tool_name, tool_class_for(tool_settings.fetch(:type).to_s), **tool_options)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
237
294
|
def validate_provider!(name, provider_data, source:)
|
|
238
295
|
validate_provider_shape!(name, provider_data, source:)
|
|
239
296
|
validate_provider_type!(name, provider_data, source:)
|
|
@@ -280,6 +337,58 @@ class Prescient::ConfigurationLoader
|
|
|
280
337
|
raise Prescient::Error, message
|
|
281
338
|
end
|
|
282
339
|
|
|
340
|
+
def validate_tool!(name, tool_data, source:)
|
|
341
|
+
validate_tool_shape!(name, tool_data, source:)
|
|
342
|
+
return validate_tool_group!(name, tool_data, source:) if tool_data.key?(:adapters)
|
|
343
|
+
|
|
344
|
+
validate_tool_type!(name, tool_data, source:)
|
|
345
|
+
validate_tool_keys!(name, tool_data, source:)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def validate_tool_group!(name, tool_data, source:)
|
|
349
|
+
unknown_keys = tool_data.keys.map(&:to_s) - ['adapters']
|
|
350
|
+
unless unknown_keys.empty?
|
|
351
|
+
source_suffix = " in #{source}" if source
|
|
352
|
+
raise Prescient::Error,
|
|
353
|
+
"Unknown tool group configuration key#{'s' if unknown_keys.length > 1} for #{name}: " \
|
|
354
|
+
"#{unknown_keys.join(', ')}#{source_suffix}"
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
adapters = tool_data[:adapters]
|
|
358
|
+
unless adapters.is_a?(Array) && adapters.any?
|
|
359
|
+
raise Prescient::Error, "Tool #{name} adapters must be a non-empty array"
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
adapters.each_with_index do |adapter_data, index|
|
|
363
|
+
adapter_name = "#{name} adapter #{index + 1}"
|
|
364
|
+
validate_tool_shape!(adapter_name, adapter_data, source:)
|
|
365
|
+
validate_tool_type!(adapter_name, adapter_data, source:)
|
|
366
|
+
validate_tool_keys!(adapter_name, adapter_data, source:)
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def validate_tool_shape!(name, tool_data, source:)
|
|
371
|
+
return if tool_data.is_a?(Hash)
|
|
372
|
+
|
|
373
|
+
raise Prescient::Error, "Tool #{name.inspect}#{" in #{source}" if source} must be a mapping"
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def validate_tool_type!(name, tool_data, source:)
|
|
377
|
+
return if tool_data.key?(:type)
|
|
378
|
+
|
|
379
|
+
raise Prescient::Error, "Tool #{name}#{" in #{source}" if source} must define type"
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def validate_tool_keys!(name, tool_data, source:)
|
|
383
|
+
unknown_keys = tool_data.keys.map(&:to_s) - (['type'] + COMMON_TOOL_KEYS)
|
|
384
|
+
return if unknown_keys.empty?
|
|
385
|
+
|
|
386
|
+
raise Prescient::Error,
|
|
387
|
+
"Unknown tool configuration key#{'s' if unknown_keys.length > 1} for #{name}: " \
|
|
388
|
+
"#{unknown_keys.join(', ')}" \
|
|
389
|
+
"#{" in #{source}" if source}"
|
|
390
|
+
end
|
|
391
|
+
|
|
283
392
|
def resolve_provider_options(provider_data, source:)
|
|
284
393
|
provider_type = provider_data[:type].to_s
|
|
285
394
|
provider_class = PROVIDER_TYPES[provider_type]
|
|
@@ -294,6 +403,57 @@ class Prescient::ConfigurationLoader
|
|
|
294
403
|
end
|
|
295
404
|
end
|
|
296
405
|
|
|
406
|
+
def resolve_tool_options(tool_data, source:)
|
|
407
|
+
tool_type = tool_data[:type].to_s
|
|
408
|
+
tool_class = tool_class_for(tool_type)
|
|
409
|
+
unless tool_class
|
|
410
|
+
raise Prescient::Error,
|
|
411
|
+
"Unknown tool type #{tool_type.inspect}#{" in #{source}" if source}"
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
tool_data.each_with_object({}) do |(key, value), options|
|
|
415
|
+
option = resolve_tool_option(tool_data, key, value, source:)
|
|
416
|
+
options[option.first] = option.last if option
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def resolve_tool_adapters(tool_data, source:)
|
|
421
|
+
tool_data[:adapters].map do |adapter_data|
|
|
422
|
+
tool_type = adapter_data[:type].to_s
|
|
423
|
+
{
|
|
424
|
+
class: tool_class_for(tool_type),
|
|
425
|
+
options: resolve_tool_options(adapter_data, source:),
|
|
426
|
+
}
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Resolve a configured tool adapter only when configuration uses it.
|
|
431
|
+
# @param tool_type [String] Configured tool type
|
|
432
|
+
# @return [Class, nil] Tool adapter class
|
|
433
|
+
def tool_class_for(tool_type)
|
|
434
|
+
tool_name = TOOL_TYPES[tool_type]
|
|
435
|
+
return unless tool_name
|
|
436
|
+
|
|
437
|
+
Prescient::Tool.const_get(tool_name, false)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def resolve_tool_option(tool_data, key, value, source:)
|
|
441
|
+
return if key == :type
|
|
442
|
+
return if key.to_s.end_with?('_env') && value.nil?
|
|
443
|
+
|
|
444
|
+
if key.to_s.end_with?('_env')
|
|
445
|
+
base_key = key.to_s.delete_suffix('_env').to_sym
|
|
446
|
+
if tool_data.key?(base_key)
|
|
447
|
+
raise Prescient::Error,
|
|
448
|
+
"Tool configuration cannot combine #{base_key} and #{key}"
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
[base_key, resolve_env_value(value, source:)]
|
|
452
|
+
else
|
|
453
|
+
[key, coerce_tool_option(key, resolve_value(value, source:))]
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
297
457
|
def resolve_provider_option(provider_data, key, value, source:)
|
|
298
458
|
return if key == :type
|
|
299
459
|
return if key.to_s.end_with?('_env') && value.nil?
|
|
@@ -425,6 +585,17 @@ class Prescient::ConfigurationLoader
|
|
|
425
585
|
end
|
|
426
586
|
end
|
|
427
587
|
|
|
588
|
+
def coerce_tool_option(key, value)
|
|
589
|
+
case key.to_sym
|
|
590
|
+
when :max_results, :max_response_bytes
|
|
591
|
+
coerce_integer(value, key.to_s)
|
|
592
|
+
when :timeout
|
|
593
|
+
coerce_float(value, 'timeout')
|
|
594
|
+
else
|
|
595
|
+
value
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
|
|
428
599
|
def validate_version!(data, source:)
|
|
429
600
|
return unless key_present?(data, :version)
|
|
430
601
|
|
data/lib/prescient/errors.rb
CHANGED
|
@@ -37,6 +37,18 @@ module Prescient
|
|
|
37
37
|
# Raised when an AI provider reports a transient service-side failure
|
|
38
38
|
class ProviderError < Error; end
|
|
39
39
|
|
|
40
|
+
# Base error class for external tool failures.
|
|
41
|
+
class ToolError < Error; end
|
|
42
|
+
|
|
43
|
+
# Raised when an external tool is configured incorrectly.
|
|
44
|
+
class ToolConfigurationError < ToolError; end
|
|
45
|
+
|
|
46
|
+
# Raised when an external tool cannot be reached.
|
|
47
|
+
class ToolConnectionError < ToolError; end
|
|
48
|
+
|
|
49
|
+
# Raised when an external tool returns an invalid response.
|
|
50
|
+
class ToolInvalidResponseError < ToolError; end
|
|
51
|
+
|
|
40
52
|
# Container module for AI provider implementations
|
|
41
53
|
#
|
|
42
54
|
# All provider classes should be defined within this module and inherit
|
|
@@ -83,8 +83,6 @@ class Prescient::Provider::OpenAI < Prescient::Base
|
|
|
83
83
|
|
|
84
84
|
validate_response!(response, 'text generation')
|
|
85
85
|
|
|
86
|
-
puts "response.parsed_response: #{response.parsed_response.inspect}"
|
|
87
|
-
|
|
88
86
|
content = response.parsed_response.dig('choices', 0, 'message', 'content')
|
|
89
87
|
raise Prescient::InvalidResponseError, 'No response generated' unless content
|
|
90
88
|
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'httparty'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'timeout'
|
|
6
|
+
|
|
7
|
+
# SearchApi web-search tool adapter.
|
|
8
|
+
class Prescient::Tool::SearchApi < Prescient::Tool::Base
|
|
9
|
+
# SearchApi's stable search endpoint.
|
|
10
|
+
API_URL = 'https://www.searchapi.io/api/v1/search'
|
|
11
|
+
# Network failures that should be reported as connection errors.
|
|
12
|
+
NETWORK_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Timeout::Error, SocketError,
|
|
13
|
+
Errno::ECONNREFUSED].freeze
|
|
14
|
+
|
|
15
|
+
# @param options [Hash] SearchApi configuration options
|
|
16
|
+
# @option options [String] :api_key SearchApi API key
|
|
17
|
+
# @option options [String] :engine SearchApi engine, defaulting to Google
|
|
18
|
+
# @option options [String] :location Optional search location
|
|
19
|
+
# @option options [String] :hl Optional interface language
|
|
20
|
+
# @option options [String] :gl Optional country code
|
|
21
|
+
def initialize(**options)
|
|
22
|
+
super
|
|
23
|
+
@api_key = required_api_key(options[:api_key])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Search SearchApi and return normalized result metadata.
|
|
27
|
+
# @param query [String] Search query
|
|
28
|
+
# @param limit [Integer, nil] Maximum number of results
|
|
29
|
+
# @param timeout [Numeric, nil] Request timeout in seconds
|
|
30
|
+
# @return [Hash] Normalized search result envelope
|
|
31
|
+
def search(query, limit: nil, timeout: nil)
|
|
32
|
+
normalized_query = validate_query(query)
|
|
33
|
+
requested_limit = result_limit(limit)
|
|
34
|
+
requested_timeout = request_timeout(timeout)
|
|
35
|
+
response = HTTParty.get(
|
|
36
|
+
API_URL,
|
|
37
|
+
headers: request_headers,
|
|
38
|
+
query: request_parameters(normalized_query, requested_limit),
|
|
39
|
+
timeout: requested_timeout,
|
|
40
|
+
)
|
|
41
|
+
validate_response!(response)
|
|
42
|
+
|
|
43
|
+
{
|
|
44
|
+
tool: 'web_search',
|
|
45
|
+
query: normalized_query,
|
|
46
|
+
source: 'searchapi',
|
|
47
|
+
results: normalize_results(response.parsed_response, requested_limit),
|
|
48
|
+
}
|
|
49
|
+
rescue Prescient::Error
|
|
50
|
+
raise
|
|
51
|
+
rescue *NETWORK_ERRORS => e
|
|
52
|
+
raise Prescient::ToolConnectionError, "SearchApi request failed: #{e.class}"
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
raise Prescient::ToolError, "SearchApi request failed: #{e.message}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
protected
|
|
58
|
+
|
|
59
|
+
# Validate SearchApi-specific configuration.
|
|
60
|
+
# @return [void]
|
|
61
|
+
def validate_configuration!
|
|
62
|
+
required_api_key(@options[:api_key])
|
|
63
|
+
max_response_bytes
|
|
64
|
+
result_limit(nil)
|
|
65
|
+
request_timeout(nil)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def required_api_key(value)
|
|
71
|
+
return value if value.is_a?(String) && !value.empty?
|
|
72
|
+
|
|
73
|
+
raise Prescient::ToolConfigurationError, 'SearchApi requires an api_key'
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def request_headers
|
|
77
|
+
{ 'Authorization' => "Bearer #{@api_key}" }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def request_parameters(query, limit)
|
|
81
|
+
parameters = {
|
|
82
|
+
engine: @options.fetch(:engine, 'google'),
|
|
83
|
+
q: query,
|
|
84
|
+
num: limit,
|
|
85
|
+
}
|
|
86
|
+
[:location, :hl, :gl].each do |key|
|
|
87
|
+
parameters[key] = @options[key] if @options[key]
|
|
88
|
+
end
|
|
89
|
+
parameters
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validate_response!(response)
|
|
93
|
+
if response.respond_to?(:body) && response.body.to_s.bytesize > max_response_bytes
|
|
94
|
+
raise Prescient::ToolInvalidResponseError, 'SearchApi response exceeds configured size limit'
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
return if response.success?
|
|
98
|
+
|
|
99
|
+
error_class = case response.code.to_i
|
|
100
|
+
when 401, 403 then Prescient::AuthenticationError
|
|
101
|
+
when 429 then Prescient::RateLimitError
|
|
102
|
+
when 500..599 then Prescient::ToolConnectionError
|
|
103
|
+
else Prescient::ToolError
|
|
104
|
+
end
|
|
105
|
+
raise error_class, "SearchApi returned HTTP #{response.code}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def normalize_results(payload, limit)
|
|
109
|
+
raw_results = payload.is_a?(Hash) ? payload['organic_results'] : nil
|
|
110
|
+
unless raw_results.is_a?(Array)
|
|
111
|
+
raise Prescient::ToolInvalidResponseError, 'SearchApi response did not contain organic_results'
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
raw_results.filter_map { |result|
|
|
115
|
+
next unless result.is_a?(Hash)
|
|
116
|
+
next unless result['link'].is_a?(String) && !result['link'].empty?
|
|
117
|
+
|
|
118
|
+
{
|
|
119
|
+
title: result['title'].to_s,
|
|
120
|
+
url: result['link'],
|
|
121
|
+
snippet: result['snippet'].to_s,
|
|
122
|
+
source: 'searchapi',
|
|
123
|
+
}
|
|
124
|
+
}.first(limit)
|
|
125
|
+
end
|
|
126
|
+
end
|