prescient 0.7.0 → 0.8.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/.rubocop.yml +21 -268
- data/CHANGELOG.md +37 -0
- data/INTEGRATION_GUIDE.md +7 -1
- data/README.md +210 -1
- data/Steepfile +12 -12
- data/db/migrate/001_create_prescient_tables.rb +15 -16
- data/examples/README.md +2 -1
- data/examples/custom_contexts.rb +4 -4
- data/exe/prescient +2 -2
- data/exe/prescient-mcp +7 -0
- data/lib/prescient/agent/audit_log.rb +37 -0
- data/lib/prescient/agent/cli_adapter.rb +29 -0
- data/lib/prescient/agent/configuration.rb +57 -0
- data/lib/prescient/agent/context.rb +56 -0
- data/lib/prescient/agent/error_serializer.rb +47 -0
- data/lib/prescient/agent/errors.rb +25 -0
- data/lib/prescient/agent/parser.rb +49 -0
- data/lib/prescient/agent/prompt_builder.rb +31 -0
- data/lib/prescient/agent/result.rb +36 -0
- data/lib/prescient/agent/runtime.rb +175 -0
- data/lib/prescient/agent/schema_validator.rb +215 -0
- data/lib/prescient/agent/tool_registry.rb +89 -0
- data/lib/prescient/agent.rb +22 -0
- data/lib/prescient/api.rb +337 -274
- data/lib/prescient/base.rb +370 -372
- data/lib/prescient/cli.rb +586 -526
- data/lib/prescient/client.rb +7 -6
- data/lib/prescient/configuration_loader.rb +492 -488
- data/lib/prescient/document_source.rb +114 -0
- data/lib/prescient/errors.rb +1 -3
- data/lib/prescient/mcp/authentication.rb +39 -0
- data/lib/prescient/mcp/configuration.rb +38 -0
- data/lib/prescient/mcp/rack.rb +243 -0
- data/lib/prescient/mcp/server.rb +202 -0
- data/lib/prescient/mcp/stdio.rb +42 -0
- data/lib/prescient/mcp.rb +8 -0
- data/lib/prescient/pgvector.rb +193 -189
- data/lib/prescient/provider/anthropic.rb +129 -125
- data/lib/prescient/provider/deepseek.rb +122 -118
- data/lib/prescient/provider/gemini.rb +153 -149
- data/lib/prescient/provider/huggingface.rb +191 -187
- data/lib/prescient/provider/mistral.rb +151 -147
- data/lib/prescient/provider/ollama.rb +168 -165
- data/lib/prescient/provider/openai.rb +174 -169
- data/lib/prescient/provider/xai.rb +122 -118
- data/lib/prescient/tool/search_api.rb +125 -121
- data/lib/prescient/tool/searxng.rb +123 -119
- data/lib/prescient/tool.rb +100 -98
- data/lib/prescient/version.rb +1 -1
- data/lib/prescient.rb +68 -62
- data/sig/prescient.rbs +176 -1
- metadata +23 -1
|
@@ -1,126 +1,130 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
3
|
+
require "httparty"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "timeout"
|
|
6
|
+
|
|
7
|
+
module Prescient
|
|
8
|
+
module Tool
|
|
9
|
+
# SearchApi web-search tool adapter.
|
|
10
|
+
class SearchApi < Prescient::Tool::Base
|
|
11
|
+
# SearchApi's stable search endpoint.
|
|
12
|
+
API_URL = "https://www.searchapi.io/api/v1/search"
|
|
13
|
+
# Network failures that should be reported as connection errors.
|
|
14
|
+
NETWORK_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Timeout::Error, SocketError,
|
|
15
|
+
Errno::ECONNREFUSED].freeze
|
|
16
|
+
|
|
17
|
+
# @param options [Hash] SearchApi configuration options
|
|
18
|
+
# @option options [String] :api_key SearchApi API key
|
|
19
|
+
# @option options [String] :engine SearchApi engine, defaulting to Google
|
|
20
|
+
# @option options [String] :location Optional search location
|
|
21
|
+
# @option options [String] :hl Optional interface language
|
|
22
|
+
# @option options [String] :gl Optional country code
|
|
23
|
+
def initialize(**options)
|
|
24
|
+
super
|
|
25
|
+
@api_key = required_api_key(options[:api_key])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Search SearchApi and return normalized result metadata.
|
|
29
|
+
# @param query [String] Search query
|
|
30
|
+
# @param limit [Integer, nil] Maximum number of results
|
|
31
|
+
# @param timeout [Numeric, nil] Request timeout in seconds
|
|
32
|
+
# @return [Hash] Normalized search result envelope
|
|
33
|
+
def search(query, limit: nil, timeout: nil)
|
|
34
|
+
normalized_query = validate_query(query)
|
|
35
|
+
requested_limit = result_limit(limit)
|
|
36
|
+
requested_timeout = request_timeout(timeout)
|
|
37
|
+
response = HTTParty.get(
|
|
38
|
+
API_URL,
|
|
39
|
+
headers: request_headers,
|
|
40
|
+
query: request_parameters(normalized_query, requested_limit),
|
|
41
|
+
timeout: requested_timeout
|
|
42
|
+
)
|
|
43
|
+
validate_response!(response)
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
tool: "web_search",
|
|
47
|
+
query: normalized_query,
|
|
48
|
+
source: "searchapi",
|
|
49
|
+
results: normalize_results(response.parsed_response, requested_limit)
|
|
50
|
+
}
|
|
51
|
+
rescue Prescient::Error
|
|
52
|
+
raise
|
|
53
|
+
rescue *NETWORK_ERRORS => e
|
|
54
|
+
raise Prescient::ToolConnectionError, "SearchApi request failed: #{e.class}"
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
raise Prescient::ToolError, "SearchApi request failed: #{e.message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
protected
|
|
60
|
+
|
|
61
|
+
# Validate SearchApi-specific configuration.
|
|
62
|
+
# @return [void]
|
|
63
|
+
def validate_configuration!
|
|
64
|
+
required_api_key(@options[:api_key])
|
|
65
|
+
max_response_bytes
|
|
66
|
+
result_limit(nil)
|
|
67
|
+
request_timeout(nil)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def required_api_key(value)
|
|
73
|
+
return value if value.is_a?(String) && !value.empty?
|
|
74
|
+
|
|
75
|
+
raise Prescient::ToolConfigurationError, "SearchApi requires an api_key"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def request_headers
|
|
79
|
+
{ "Authorization" => "Bearer #{@api_key}" }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def request_parameters(query, limit)
|
|
83
|
+
parameters = {
|
|
84
|
+
engine: @options.fetch(:engine, "google"),
|
|
85
|
+
q: query,
|
|
86
|
+
num: limit
|
|
87
|
+
}
|
|
88
|
+
%i[location hl gl].each do |key|
|
|
89
|
+
parameters[key] = @options[key] if @options[key]
|
|
90
|
+
end
|
|
91
|
+
parameters
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_response!(response)
|
|
95
|
+
if response.respond_to?(:body) && response.body.to_s.bytesize > max_response_bytes
|
|
96
|
+
raise Prescient::ToolInvalidResponseError, "SearchApi response exceeds configured size limit"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
return if response.success?
|
|
100
|
+
|
|
101
|
+
error_class = case response.code.to_i
|
|
102
|
+
when 401, 403 then Prescient::AuthenticationError
|
|
103
|
+
when 429 then Prescient::RateLimitError
|
|
104
|
+
when 500..599 then Prescient::ToolConnectionError
|
|
105
|
+
else Prescient::ToolError
|
|
106
|
+
end
|
|
107
|
+
raise error_class, "SearchApi returned HTTP #{response.code}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def normalize_results(payload, limit)
|
|
111
|
+
raw_results = payload.is_a?(Hash) ? payload["organic_results"] : nil
|
|
112
|
+
unless raw_results.is_a?(Array)
|
|
113
|
+
raise Prescient::ToolInvalidResponseError, "SearchApi response did not contain organic_results"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
raw_results.filter_map do |result|
|
|
117
|
+
next unless result.is_a?(Hash)
|
|
118
|
+
next unless result["link"].is_a?(String) && !result["link"].empty?
|
|
119
|
+
|
|
120
|
+
{
|
|
121
|
+
title: result["title"].to_s,
|
|
122
|
+
url: result["link"],
|
|
123
|
+
snippet: result["snippet"].to_s,
|
|
124
|
+
source: "searchapi"
|
|
125
|
+
}
|
|
126
|
+
end.first(limit)
|
|
127
|
+
end
|
|
95
128
|
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
129
|
end
|
|
126
130
|
end
|
|
@@ -1,124 +1,128 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
6
|
-
require
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
3
|
+
require "httparty"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Prescient
|
|
9
|
+
module Tool
|
|
10
|
+
# SearXNG web-search tool adapter.
|
|
11
|
+
class SearXNG < Prescient::Tool::Base
|
|
12
|
+
# Network failures that should be reported as connection errors.
|
|
13
|
+
NETWORK_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Timeout::Error, SocketError, Errno::ECONNREFUSED].freeze
|
|
14
|
+
|
|
15
|
+
# @param options [Hash] SearXNG configuration options
|
|
16
|
+
# @option options [String] :url SearXNG base URL
|
|
17
|
+
# @option options [String] :language Optional search language
|
|
18
|
+
# @option options [String, Array<String>] :categories Optional categories
|
|
19
|
+
def initialize(**options)
|
|
20
|
+
super
|
|
21
|
+
@base_url = normalized_base_url(options.fetch(:url))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Search SearXNG and return normalized result metadata.
|
|
25
|
+
# @param query [String] Search query
|
|
26
|
+
# @param limit [Integer, nil] Maximum number of results
|
|
27
|
+
# @param timeout [Numeric, nil] Request timeout in seconds
|
|
28
|
+
# @return [Hash] Normalized search result envelope
|
|
29
|
+
def search(query, limit: nil, timeout: nil)
|
|
30
|
+
normalized_query = validate_query(query)
|
|
31
|
+
requested_limit = result_limit(limit)
|
|
32
|
+
requested_timeout = request_timeout(timeout)
|
|
33
|
+
response = HTTParty.get(
|
|
34
|
+
search_url,
|
|
35
|
+
query: request_parameters(normalized_query),
|
|
36
|
+
timeout: requested_timeout
|
|
37
|
+
)
|
|
38
|
+
validate_response!(response)
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
tool: "web_search",
|
|
42
|
+
query: normalized_query,
|
|
43
|
+
source: "searxng",
|
|
44
|
+
results: normalize_results(response.parsed_response, requested_limit)
|
|
45
|
+
}
|
|
46
|
+
rescue Prescient::Error
|
|
47
|
+
raise
|
|
48
|
+
rescue *NETWORK_ERRORS => e
|
|
49
|
+
raise Prescient::ToolConnectionError, "SearXNG request failed: #{e.class}"
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
raise Prescient::ToolError, "SearXNG request failed: #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
protected
|
|
55
|
+
|
|
56
|
+
# Validate SearXNG-specific configuration.
|
|
57
|
+
# @return [void]
|
|
58
|
+
def validate_configuration!
|
|
59
|
+
normalized_base_url(@options.fetch(:url))
|
|
60
|
+
max_response_bytes
|
|
61
|
+
result_limit(nil)
|
|
62
|
+
request_timeout(nil)
|
|
63
|
+
rescue KeyError
|
|
64
|
+
raise Prescient::ToolConfigurationError, "SearXNG requires a url"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def normalized_base_url(value)
|
|
70
|
+
uri = URI.parse(value.to_s)
|
|
71
|
+
valid_scheme = %w[http https].include?(uri.scheme)
|
|
72
|
+
if !valid_scheme || uri.host.nil? || uri.userinfo
|
|
73
|
+
raise Prescient::ToolConfigurationError, "SearXNG url must be an HTTP(S) URL without credentials"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
uri.to_s.delete_suffix("/")
|
|
77
|
+
rescue URI::InvalidURIError
|
|
78
|
+
raise Prescient::ToolConfigurationError, "SearXNG url must be a valid HTTP(S) URL"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def search_url
|
|
82
|
+
"#{@base_url}/search"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def request_parameters(query)
|
|
86
|
+
parameters = { q: query, format: "json" }
|
|
87
|
+
parameters[:language] = @options[:language] if @options[:language]
|
|
88
|
+
parameters[:categories] = Array(@options[:categories]).join(",") if @options[:categories]
|
|
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, "SearXNG 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, "SearXNG returned HTTP #{response.code}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def normalize_results(payload, limit)
|
|
109
|
+
raw_results = payload.is_a?(Hash) ? payload["results"] : nil
|
|
110
|
+
unless raw_results.is_a?(Array)
|
|
111
|
+
raise Prescient::ToolInvalidResponseError, "SearXNG response did not contain results"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
raw_results.filter_map do |result|
|
|
115
|
+
next unless result.is_a?(Hash)
|
|
116
|
+
next unless result["url"].is_a?(String) && !result["url"].empty?
|
|
117
|
+
|
|
118
|
+
{
|
|
119
|
+
title: result["title"].to_s,
|
|
120
|
+
url: result["url"],
|
|
121
|
+
snippet: result["content"].to_s,
|
|
122
|
+
source: "searxng"
|
|
123
|
+
}
|
|
124
|
+
end.first(limit)
|
|
125
|
+
end
|
|
72
126
|
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
127
|
end
|
|
124
128
|
end
|