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.
- checksums.yaml +4 -4
- data/.dockerignore +18 -0
- data/CHANGELOG.md +42 -0
- data/Dockerfile +45 -0
- data/INTEGRATION_GUIDE.md +122 -11
- data/README.md +243 -24
- data/docker-compose.api.yml +21 -0
- data/docker-compose.yml +22 -0
- data/examples/README.md +51 -0
- data/examples/rest_api.ru +30 -0
- data/examples/web_search.rb +34 -0
- data/lib/prescient/api.rb +337 -0
- data/lib/prescient/cli.rb +136 -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 +71 -1
- data/schema/prescient.configuration.schema.json +119 -0
- data/searxng/settings.yml +18 -0
- data/sig/prescient.rbs +62 -0
- metadata +16 -5
data/lib/prescient/cli.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'optparse'
|
|
5
5
|
require 'yaml'
|
|
6
|
+
require_relative '../prescient'
|
|
6
7
|
|
|
7
8
|
# Command-line interface for common Prescient operations.
|
|
8
9
|
class Prescient::CLI
|
|
@@ -46,8 +47,8 @@ class Prescient::CLI
|
|
|
46
47
|
chat_model: llama3.2:3b
|
|
47
48
|
# prompt_templates:
|
|
48
49
|
# system_prompt: You are a concise assistant.
|
|
49
|
-
# no_context_template: "%<system_prompt>s
|
|
50
|
-
# 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"
|
|
51
52
|
|
|
52
53
|
# Uncomment a cloud provider and set its credential in the environment.
|
|
53
54
|
# openai:
|
|
@@ -57,7 +58,7 @@ class Prescient::CLI
|
|
|
57
58
|
# chat_model: gpt-4.1-mini
|
|
58
59
|
# prompt_templates:
|
|
59
60
|
# system_prompt: You are a concise assistant.
|
|
60
|
-
# no_context_template: "%<system_prompt>s\
|
|
61
|
+
# no_context_template: "%<system_prompt>s\x5Cn\x5CnUser: %<query>s"
|
|
61
62
|
|
|
62
63
|
# anthropic:
|
|
63
64
|
# type: anthropic
|
|
@@ -92,6 +93,62 @@ class Prescient::CLI
|
|
|
92
93
|
# api_key_env: HUGGINGFACE_API_KEY
|
|
93
94
|
# embedding_model: sentence-transformers/all-MiniLM-L6-v2
|
|
94
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.
|
|
95
152
|
YAML
|
|
96
153
|
|
|
97
154
|
# Raised when command-line arguments are invalid or incomplete.
|
|
@@ -148,6 +205,7 @@ class Prescient::CLI
|
|
|
148
205
|
when 'health' then health
|
|
149
206
|
when 'generate' then generate
|
|
150
207
|
when 'embed' then embed
|
|
208
|
+
when 'search' then search
|
|
151
209
|
when 'config' then config
|
|
152
210
|
when 'help', '--help', '-h' then print_help(0)
|
|
153
211
|
else
|
|
@@ -213,6 +271,52 @@ class Prescient::CLI
|
|
|
213
271
|
0
|
|
214
272
|
end
|
|
215
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
|
+
|
|
216
320
|
def config
|
|
217
321
|
subcommand = @arguments.shift
|
|
218
322
|
case subcommand
|
|
@@ -250,16 +354,24 @@ class Prescient::CLI
|
|
|
250
354
|
raise Prescient::Error, 'default provider is not configured'
|
|
251
355
|
end
|
|
252
356
|
|
|
253
|
-
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
|
|
254
363
|
end
|
|
255
364
|
|
|
256
|
-
def parse_options(description, fallback: false)
|
|
365
|
+
def parse_options(description, fallback: false, tool: false, limit: false, generate: false)
|
|
257
366
|
options = { format: 'text', fallback: fallback }
|
|
258
367
|
parser = OptionParser.new do |parser|
|
|
259
368
|
parser.banner = "Usage: prescient #{@arguments.first || 'command'} [options]"
|
|
260
369
|
parser.separator description
|
|
370
|
+
parser.separator ''
|
|
371
|
+
parser.separator 'Global options:'
|
|
261
372
|
add_common_options(parser, options)
|
|
262
373
|
parser.on('--no-fallback', 'Disable provider fallback') { options[:fallback] = false } if fallback
|
|
374
|
+
add_tool_options(parser, options, tool:, limit:, generate:)
|
|
263
375
|
parser.on('-h', '--help', 'Show command help') do
|
|
264
376
|
@output.puts parser
|
|
265
377
|
throw :help_shown, 0
|
|
@@ -273,6 +385,18 @@ class Prescient::CLI
|
|
|
273
385
|
options
|
|
274
386
|
end
|
|
275
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
|
+
|
|
276
400
|
def add_common_options(parser, options)
|
|
277
401
|
parser.on('--config PATH', 'Load configuration from a YAML file') do |value|
|
|
278
402
|
options[:config] = value
|
|
@@ -424,10 +548,11 @@ class Prescient::CLI
|
|
|
424
548
|
health Check provider health
|
|
425
549
|
generate TEXT Generate a text response
|
|
426
550
|
embed TEXT Generate an embedding
|
|
551
|
+
search TEXT Search with a configured external tool
|
|
427
552
|
config validate Validate the current configuration
|
|
428
553
|
config example Generate an annotated YAML configuration example
|
|
429
554
|
|
|
430
|
-
|
|
555
|
+
Global options:
|
|
431
556
|
--config PATH Load configuration from a YAML file
|
|
432
557
|
--provider NAME Select a provider
|
|
433
558
|
--model NAME Override the selected operation's model
|
|
@@ -443,6 +568,11 @@ class Prescient::CLI
|
|
|
443
568
|
--api-key KEY Use an API key for the operation
|
|
444
569
|
--api-key-env NAME Read the API key from an environment variable
|
|
445
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
|
|
446
576
|
HELP
|
|
447
577
|
status
|
|
448
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
|