prescient 0.6.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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +21 -268
  3. data/CHANGELOG.md +59 -0
  4. data/INTEGRATION_GUIDE.md +19 -1
  5. data/README.md +369 -21
  6. data/Steepfile +12 -12
  7. data/db/migrate/001_create_prescient_tables.rb +15 -16
  8. data/docker-compose.yml +22 -0
  9. data/examples/README.md +36 -1
  10. data/examples/custom_contexts.rb +4 -4
  11. data/examples/web_search.rb +34 -0
  12. data/exe/prescient +2 -2
  13. data/exe/prescient-mcp +7 -0
  14. data/lib/prescient/agent/audit_log.rb +37 -0
  15. data/lib/prescient/agent/cli_adapter.rb +29 -0
  16. data/lib/prescient/agent/configuration.rb +57 -0
  17. data/lib/prescient/agent/context.rb +56 -0
  18. data/lib/prescient/agent/error_serializer.rb +47 -0
  19. data/lib/prescient/agent/errors.rb +25 -0
  20. data/lib/prescient/agent/parser.rb +49 -0
  21. data/lib/prescient/agent/prompt_builder.rb +31 -0
  22. data/lib/prescient/agent/result.rb +36 -0
  23. data/lib/prescient/agent/runtime.rb +175 -0
  24. data/lib/prescient/agent/schema_validator.rb +215 -0
  25. data/lib/prescient/agent/tool_registry.rb +89 -0
  26. data/lib/prescient/agent.rb +22 -0
  27. data/lib/prescient/api.rb +346 -231
  28. data/lib/prescient/base.rb +370 -372
  29. data/lib/prescient/cli.rb +591 -402
  30. data/lib/prescient/client.rb +31 -4
  31. data/lib/prescient/configuration_loader.rb +511 -336
  32. data/lib/prescient/document_source.rb +114 -0
  33. data/lib/prescient/errors.rb +13 -3
  34. data/lib/prescient/mcp/authentication.rb +39 -0
  35. data/lib/prescient/mcp/configuration.rb +38 -0
  36. data/lib/prescient/mcp/rack.rb +243 -0
  37. data/lib/prescient/mcp/server.rb +202 -0
  38. data/lib/prescient/mcp/stdio.rb +42 -0
  39. data/lib/prescient/mcp.rb +8 -0
  40. data/lib/prescient/pgvector.rb +193 -189
  41. data/lib/prescient/provider/anthropic.rb +129 -125
  42. data/lib/prescient/provider/deepseek.rb +122 -118
  43. data/lib/prescient/provider/gemini.rb +153 -149
  44. data/lib/prescient/provider/huggingface.rb +191 -187
  45. data/lib/prescient/provider/mistral.rb +151 -147
  46. data/lib/prescient/provider/ollama.rb +168 -165
  47. data/lib/prescient/provider/openai.rb +174 -171
  48. data/lib/prescient/provider/xai.rb +122 -118
  49. data/lib/prescient/tool/search_api.rb +130 -0
  50. data/lib/prescient/tool/searxng.rb +128 -0
  51. data/lib/prescient/tool.rb +125 -0
  52. data/lib/prescient/version.rb +1 -1
  53. data/lib/prescient.rb +129 -55
  54. data/schema/prescient.configuration.schema.json +119 -0
  55. data/searxng/settings.yml +18 -0
  56. data/sig/prescient.rbs +228 -1
  57. metadata +33 -5
@@ -1,192 +1,195 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'httparty'
3
+ require "httparty"
4
+
5
+ module Prescient
6
+ module Provider
7
+ # OpenAI API provider adapter.
8
+ class OpenAI < Prescient::Base
9
+ include HTTParty
10
+
11
+ base_uri "https://api.openai.com"
12
+
13
+ # Known embedding dimensions for OpenAI embedding models.
14
+ EMBEDDING_DIMENSIONS = {
15
+ "text-embedding-3-small" => 1536,
16
+ "text-embedding-3-large" => 3072,
17
+ "text-embedding-ada-002" => 1536
18
+ }.freeze
19
+
20
+ def initialize(**options)
21
+ super
22
+ @provider_name = "OpenAI"
23
+ self.class.default_timeout(@options[:timeout] || 60)
24
+ end
4
25
 
5
- # OpenAI API provider adapter.
6
- class Prescient::Provider::OpenAI < Prescient::Base
7
- include HTTParty
26
+ # Generate an embedding through the OpenAI embeddings API.
27
+ # @param text [String] Text to embed
28
+ # @return [Array<Float>] Embedding vector
29
+ def generate_embedding(text, **options)
30
+ handle_errors do
31
+ clean_text_input = clean_text(text)
32
+
33
+ embedding_model = options[:model] || @options[:embedding_model]
34
+ response = self.class.post("/v1/embeddings",
35
+ headers: {
36
+ "Content-Type" => "application/json",
37
+ "Authorization" => "Bearer #{@options[:api_key]}"
38
+ },
39
+ body: {
40
+ model: embedding_model,
41
+ input: clean_text_input,
42
+ encoding_format: "float"
43
+ }.to_json)
8
44
 
9
- base_uri 'https://api.openai.com'
45
+ validate_response!(response, "embedding generation")
10
46
 
11
- # Known embedding dimensions for OpenAI embedding models.
12
- EMBEDDING_DIMENSIONS = {
13
- 'text-embedding-3-small' => 1536,
14
- 'text-embedding-3-large' => 3072,
15
- 'text-embedding-ada-002' => 1536,
16
- }.freeze
47
+ embedding_data = response.parsed_response.dig("data", 0, "embedding")
48
+ raise Prescient::InvalidResponseError, "No embedding returned" unless embedding_data
17
49
 
18
- def initialize(**options)
19
- super
20
- @provider_name = 'OpenAI'
21
- self.class.default_timeout(@options[:timeout] || 60)
22
- end
50
+ expected_dimensions = EMBEDDING_DIMENSIONS[embedding_model] || @options[:embedding_dimensions]
51
+ unless expected_dimensions
52
+ raise Prescient::Error,
53
+ "Embedding dimensions are required for model #{embedding_model}"
54
+ end
23
55
 
24
- # Generate an embedding through the OpenAI embeddings API.
25
- # @param text [String] Text to embed
26
- # @return [Array<Float>] Embedding vector
27
- def generate_embedding(text, **options)
28
- handle_errors do
29
- clean_text_input = clean_text(text)
30
-
31
- embedding_model = options[:model] || @options[:embedding_model]
32
- response = self.class.post('/v1/embeddings',
33
- headers: {
34
- 'Content-Type' => 'application/json',
35
- 'Authorization' => "Bearer #{@options[:api_key]}",
36
- },
37
- body: {
38
- model: embedding_model,
39
- input: clean_text_input,
40
- encoding_format: 'float',
41
- }.to_json)
42
-
43
- validate_response!(response, 'embedding generation')
44
-
45
- embedding_data = response.parsed_response.dig('data', 0, 'embedding')
46
- raise Prescient::InvalidResponseError, 'No embedding returned' unless embedding_data
47
-
48
- expected_dimensions = EMBEDDING_DIMENSIONS[embedding_model] || @options[:embedding_dimensions]
49
- unless expected_dimensions
50
- raise Prescient::Error,
51
- "Embedding dimensions are required for model #{embedding_model}"
56
+ validate_embedding_dimensions(embedding_data, expected_dimensions)
57
+ end
52
58
  end
53
59
 
54
- validate_embedding_dimensions(embedding_data, expected_dimensions)
55
- end
56
- end
57
-
58
- # Generate a response through the OpenAI chat completions API.
59
- # @param prompt [String] Prompt to send
60
- # @param context_items [Array<Hash, String>] Optional context items
61
- # @return [Hash] Normalized response data
62
- def generate_response(prompt, context_items = [], **options)
63
- handle_errors do
64
- formatted_prompt = build_prompt(prompt, context_items)
65
-
66
- response = self.class.post('/v1/chat/completions',
67
- headers: {
68
- 'Content-Type' => 'application/json',
69
- 'Authorization' => "Bearer #{@options[:api_key]}",
70
- },
71
- body: {
72
- model: options[:model] || @options[:chat_model],
73
- messages: [
74
- {
75
- role: 'user',
76
- content: formatted_prompt,
60
+ # Generate a response through the OpenAI chat completions API.
61
+ # @param prompt [String] Prompt to send
62
+ # @param context_items [Array<Hash, String>] Optional context items
63
+ # @return [Hash] Normalized response data
64
+ def generate_response(prompt, context_items = [], **options)
65
+ handle_errors do
66
+ formatted_prompt = build_prompt(prompt, context_items)
67
+
68
+ response = self.class.post("/v1/chat/completions",
69
+ headers: {
70
+ "Content-Type" => "application/json",
71
+ "Authorization" => "Bearer #{@options[:api_key]}"
77
72
  },
78
- ],
79
- max_tokens: options[:max_tokens] || 2000,
80
- temperature: options[:temperature] || 0.7,
81
- top_p: options[:top_p] || 0.9,
82
- }.to_json)
83
-
84
- validate_response!(response, 'text generation')
85
-
86
- puts "response.parsed_response: #{response.parsed_response.inspect}"
87
-
88
- content = response.parsed_response.dig('choices', 0, 'message', 'content')
89
- raise Prescient::InvalidResponseError, 'No response generated' unless content
90
-
91
- {
92
- response: content.strip,
93
- model: options[:model] || @options[:chat_model],
94
- provider: 'openai',
95
- processing_time: nil,
96
- metadata: {
97
- usage: response.parsed_response['usage'],
98
- finish_reason: response.parsed_response.dig('choices', 0, 'finish_reason'),
99
- },
100
- }
101
- end
102
- end
103
-
104
- # Check OpenAI model availability via `/v1/models`.
105
- #
106
- # `reachable` indicates the API answered successfully. `ready` indicates that
107
- # both configured models appear in the returned model list.
108
- #
109
- # @return [Hash] Provider health information
110
- def health_check
111
- handle_errors do
112
- response = self.class.get('/v1/models',
113
- headers: {
114
- 'Authorization' => "Bearer #{@options[:api_key]}",
115
- })
116
-
117
- if response.success?
118
- models = response.parsed_response['data'] || []
119
- embedding_available = models.any? { |m| m['id'] == @options[:embedding_model] }
120
- chat_available = models.any? { |m| m['id'] == @options[:chat_model] }
121
-
122
- {
123
- status: 'healthy',
124
- provider: 'openai',
125
- reachable: true,
126
- models_available: models.map { |m| m['id'] },
127
- embedding_model: {
128
- name: @options[:embedding_model],
129
- available: embedding_available,
130
- },
131
- chat_model: {
132
- name: @options[:chat_model],
133
- available: chat_available,
134
- },
135
- ready: embedding_available && chat_available,
136
- }
137
- else
138
- {
139
- status: 'unhealthy',
140
- provider: 'openai',
141
- reachable: true,
142
- error: "HTTP #{response.code}",
143
- message: response.message,
144
- ready: false,
145
- }
73
+ body: {
74
+ model: options[:model] || @options[:chat_model],
75
+ messages: [
76
+ {
77
+ role: "user",
78
+ content: formatted_prompt
79
+ }
80
+ ],
81
+ max_tokens: options[:max_tokens] || 2000,
82
+ temperature: options[:temperature] || 0.7,
83
+ top_p: options[:top_p] || 0.9
84
+ }.to_json)
85
+
86
+ validate_response!(response, "text generation")
87
+
88
+ content = response.parsed_response.dig("choices", 0, "message", "content")
89
+ raise Prescient::InvalidResponseError, "No response generated" unless content
90
+
91
+ {
92
+ response: content.strip,
93
+ model: options[:model] || @options[:chat_model],
94
+ provider: "openai",
95
+ processing_time: nil,
96
+ metadata: {
97
+ usage: response.parsed_response["usage"],
98
+ finish_reason: response.parsed_response.dig("choices", 0, "finish_reason")
99
+ }
100
+ }
101
+ end
146
102
  end
147
- end
148
- rescue Prescient::Error => e
149
- {
150
- status: 'unavailable',
151
- provider: 'openai',
152
- reachable: false,
153
- error: e.class.name,
154
- message: e.message,
155
- ready: false,
156
- }
157
- end
158
103
 
159
- # List models available to the configured OpenAI account.
160
- #
161
- # @return [Array<Hash>] Model descriptors
162
- def list_models
163
- handle_errors do
164
- response = self.class.get('/v1/models',
165
- headers: {
166
- 'Authorization' => "Bearer #{@options[:api_key]}",
167
- })
168
- validate_response!(response, 'model listing')
169
-
170
- models = response.parsed_response['data'] || []
171
- models.map do |model|
104
+ # Check OpenAI model availability via `/v1/models`.
105
+ #
106
+ # `reachable` indicates the API answered successfully. `ready` indicates that
107
+ # both configured models appear in the returned model list.
108
+ #
109
+ # @return [Hash] Provider health information
110
+ # rubocop:disable Metrics/MethodLength
111
+ def health_check
112
+ handle_errors do
113
+ response = self.class.get("/v1/models",
114
+ headers: {
115
+ "Authorization" => "Bearer #{@options[:api_key]}"
116
+ })
117
+
118
+ if response.success?
119
+ models = response.parsed_response["data"] || []
120
+ embedding_available = models.any? { |m| m["id"] == @options[:embedding_model] }
121
+ chat_available = models.any? { |m| m["id"] == @options[:chat_model] }
122
+
123
+ {
124
+ status: "healthy",
125
+ provider: "openai",
126
+ reachable: true,
127
+ models_available: models.map { |m| m["id"] },
128
+ embedding_model: {
129
+ name: @options[:embedding_model],
130
+ available: embedding_available
131
+ },
132
+ chat_model: {
133
+ name: @options[:chat_model],
134
+ available: chat_available
135
+ },
136
+ ready: embedding_available && chat_available
137
+ }
138
+ else
139
+ {
140
+ status: "unhealthy",
141
+ provider: "openai",
142
+ reachable: true,
143
+ error: "HTTP #{response.code}",
144
+ message: response.message,
145
+ ready: false
146
+ }
147
+ end
148
+ end
149
+ rescue Prescient::Error => e
172
150
  {
173
- name: model['id'],
174
- created: model['created'],
175
- owned_by: model['owned_by'],
151
+ status: "unavailable",
152
+ provider: "openai",
153
+ reachable: false,
154
+ error: e.class.name,
155
+ message: e.message,
156
+ ready: false
176
157
  }
177
158
  end
178
- end
179
- end
159
+ # rubocop:enable Metrics/MethodLength
160
+
161
+ # List models available to the configured OpenAI account.
162
+ #
163
+ # @return [Array<Hash>] Model descriptors
164
+ def list_models
165
+ handle_errors do
166
+ response = self.class.get("/v1/models",
167
+ headers: {
168
+ "Authorization" => "Bearer #{@options[:api_key]}"
169
+ })
170
+ validate_response!(response, "model listing")
171
+
172
+ models = response.parsed_response["data"] || []
173
+ models.map do |model|
174
+ {
175
+ name: model["id"],
176
+ created: model["created"],
177
+ owned_by: model["owned_by"]
178
+ }
179
+ end
180
+ end
181
+ end
180
182
 
181
- protected
183
+ protected
182
184
 
183
- def validate_configuration!
184
- required_options = [:api_key, :embedding_model, :chat_model]
185
- missing_options = required_options.select { |opt| @options[opt].nil? }
185
+ def validate_configuration!
186
+ required_options = %i[api_key embedding_model chat_model]
187
+ missing_options = required_options.select { |opt| @options[opt].nil? }
186
188
 
187
- return unless missing_options.any?
189
+ return unless missing_options.any?
188
190
 
189
- raise Prescient::Error, "Missing required options: #{missing_options.join(', ')}"
191
+ raise Prescient::Error, "Missing required options: #{missing_options.join(", ")}"
192
+ end
193
+ end
190
194
  end
191
-
192
195
  end
@@ -1,139 +1,143 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'httparty'
3
+ require "httparty"
4
4
 
5
- # xAI API provider adapter.
6
- class Prescient::Provider::XAI < Prescient::Base
7
- include HTTParty
5
+ module Prescient
6
+ module Provider
7
+ # xAI API provider adapter.
8
+ class XAI < Prescient::Base
9
+ include HTTParty
8
10
 
9
- base_uri 'https://api.x.ai'
11
+ base_uri "https://api.x.ai"
10
12
 
11
- def initialize(**options)
12
- super
13
- @provider_name = 'xAI'
14
- self.class.default_timeout(@options[:timeout] || 60)
15
- end
16
-
17
- # xAI does not expose a standard embeddings API for this adapter.
18
- # @raise [Prescient::Error] Always, because embeddings are unsupported
19
- def generate_embedding(_text, **_options)
20
- raise Prescient::Error, 'xAI provider does not support embeddings.'
21
- end
22
-
23
- # Generate a response through xAI's OpenAI-compatible chat API.
24
- # @param prompt [String] Prompt to send
25
- # @param context_items [Array<Hash, String>] Optional context items
26
- # @return [Hash] Normalized response data
27
- def generate_response(prompt, context_items = [], **options)
28
- handle_errors do
29
- model = options[:model] || @options[:chat_model]
30
- response = self.class.post(
31
- '/v1/chat/completions',
32
- headers: api_headers,
33
- body: {
34
- model: model,
35
- messages: [{ role: 'user', content: build_prompt(prompt, context_items) }],
36
- max_tokens: options[:max_tokens] || 2000,
37
- temperature: options[:temperature] || 0.7,
38
- top_p: options[:top_p] || 0.9,
39
- }.to_json,
40
- )
41
-
42
- validate_response!(response, 'text generation')
43
-
44
- parsed_response = response.parsed_response
45
- content = parsed_response.dig('choices', 0, 'message', 'content')
46
- raise Prescient::InvalidResponseError, 'No response generated' unless content.is_a?(String) && !content.empty?
47
-
48
- {
49
- response: content.strip,
50
- model: model,
51
- provider: 'xai',
52
- processing_time: nil,
53
- metadata: {
54
- usage: parsed_response['usage'],
55
- finish_reason: parsed_response.dig('choices', 0, 'finish_reason'),
56
- },
57
- }
58
- end
59
- end
13
+ def initialize(**options)
14
+ super
15
+ @provider_name = "xAI"
16
+ self.class.default_timeout(@options[:timeout] || 60)
17
+ end
60
18
 
61
- # Check whether the configured xAI model is available.
62
- # @return [Hash] Provider health information
63
- def health_check
64
- handle_errors do
65
- response = self.class.get('/v1/models', headers: api_headers)
19
+ # xAI does not expose a standard embeddings API for this adapter.
20
+ # @raise [Prescient::Error] Always, because embeddings are unsupported
21
+ def generate_embedding(_text, **_options)
22
+ raise Prescient::Error, "xAI provider does not support embeddings."
23
+ end
66
24
 
67
- if response.success?
68
- models = response.parsed_response['data'] || []
69
- model_available = models.any? { |model| model['id'] == @options[:chat_model] }
25
+ # Generate a response through xAI's OpenAI-compatible chat API.
26
+ # @param prompt [String] Prompt to send
27
+ # @param context_items [Array<Hash, String>] Optional context items
28
+ # @return [Hash] Normalized response data
29
+ def generate_response(prompt, context_items = [], **options)
30
+ handle_errors do
31
+ model = options[:model] || @options[:chat_model]
32
+ response = self.class.post(
33
+ "/v1/chat/completions",
34
+ headers: api_headers,
35
+ body: {
36
+ model: model,
37
+ messages: [{ role: "user", content: build_prompt(prompt, context_items) }],
38
+ max_tokens: options[:max_tokens] || 2000,
39
+ temperature: options[:temperature] || 0.7,
40
+ top_p: options[:top_p] || 0.9
41
+ }.to_json
42
+ )
43
+
44
+ validate_response!(response, "text generation")
45
+
46
+ parsed_response = response.parsed_response
47
+ content = parsed_response.dig("choices", 0, "message", "content")
48
+ raise Prescient::InvalidResponseError, "No response generated" unless content.is_a?(String) && !content.empty?
49
+
50
+ {
51
+ response: content.strip,
52
+ model: model,
53
+ provider: "xai",
54
+ processing_time: nil,
55
+ metadata: {
56
+ usage: parsed_response["usage"],
57
+ finish_reason: parsed_response.dig("choices", 0, "finish_reason")
58
+ }
59
+ }
60
+ end
61
+ end
70
62
 
63
+ # Check whether the configured xAI model is available.
64
+ # @return [Hash] Provider health information
65
+ def health_check
66
+ handle_errors do
67
+ response = self.class.get("/v1/models", headers: api_headers)
68
+
69
+ if response.success?
70
+ models = response.parsed_response["data"] || []
71
+ model_available = models.any? { |model| model["id"] == @options[:chat_model] }
72
+
73
+ {
74
+ status: "healthy",
75
+ provider: "xai",
76
+ reachable: true,
77
+ models_available: models.map { |model| model["id"] },
78
+ chat_model: { name: @options[:chat_model], available: model_available },
79
+ ready: model_available
80
+ }
81
+ else
82
+ {
83
+ status: "unhealthy",
84
+ provider: "xai",
85
+ reachable: true,
86
+ error: "HTTP #{response.code}",
87
+ message: response.message,
88
+ ready: false
89
+ }
90
+ end
91
+ end
92
+ rescue Prescient::Error => e
71
93
  {
72
- status: 'healthy',
73
- provider: 'xai',
74
- reachable: true,
75
- models_available: models.map { |model| model['id'] },
76
- chat_model: { name: @options[:chat_model], available: model_available },
77
- ready: model_available,
78
- }
79
- else
80
- {
81
- status: 'unhealthy',
82
- provider: 'xai',
83
- reachable: true,
84
- error: "HTTP #{response.code}",
85
- message: response.message,
86
- ready: false,
94
+ status: "unavailable",
95
+ provider: "xai",
96
+ reachable: false,
97
+ error: e.class.name,
98
+ message: e.message,
99
+ ready: false
87
100
  }
88
101
  end
89
- end
90
- rescue Prescient::Error => e
91
- {
92
- status: 'unavailable',
93
- provider: 'xai',
94
- reachable: false,
95
- error: e.class.name,
96
- message: e.message,
97
- ready: false,
98
- }
99
- end
100
-
101
- # List models available to the configured xAI API key.
102
- # @return [Array<Hash>] Model descriptors
103
- def list_models
104
- handle_errors do
105
- response = self.class.get('/v1/models', headers: api_headers)
106
- validate_response!(response, 'model listing')
107
102
 
108
- (response.parsed_response['data'] || []).map do |model|
109
- {
110
- name: model['id'],
111
- object: model['object'],
112
- created: model['created'],
113
- owned_by: model['owned_by'],
114
- context_length: model['context_length'],
115
- }.compact
103
+ # List models available to the configured xAI API key.
104
+ # @return [Array<Hash>] Model descriptors
105
+ def list_models
106
+ handle_errors do
107
+ response = self.class.get("/v1/models", headers: api_headers)
108
+ validate_response!(response, "model listing")
109
+
110
+ (response.parsed_response["data"] || []).map do |model|
111
+ {
112
+ name: model["id"],
113
+ object: model["object"],
114
+ created: model["created"],
115
+ owned_by: model["owned_by"],
116
+ context_length: model["context_length"]
117
+ }.compact
118
+ end
119
+ end
116
120
  end
117
- end
118
- end
119
121
 
120
- protected
122
+ protected
121
123
 
122
- def validate_configuration!
123
- required_options = [:api_key, :chat_model]
124
- missing_options = required_options.select { |option| @options[option].nil? }
124
+ def validate_configuration!
125
+ required_options = %i[api_key chat_model]
126
+ missing_options = required_options.select { |option| @options[option].nil? }
125
127
 
126
- return unless missing_options.any?
128
+ return unless missing_options.any?
127
129
 
128
- raise Prescient::Error, "Missing required options: #{missing_options.join(', ')}"
129
- end
130
+ raise Prescient::Error, "Missing required options: #{missing_options.join(", ")}"
131
+ end
130
132
 
131
- private
133
+ private
132
134
 
133
- def api_headers
134
- {
135
- 'Content-Type' => 'application/json',
136
- 'Authorization' => "Bearer #{@options[:api_key]}",
137
- }
135
+ def api_headers
136
+ {
137
+ "Content-Type" => "application/json",
138
+ "Authorization" => "Bearer #{@options[:api_key]}"
139
+ }
140
+ end
141
+ end
138
142
  end
139
143
  end