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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +21 -268
  3. data/CHANGELOG.md +37 -0
  4. data/INTEGRATION_GUIDE.md +7 -1
  5. data/README.md +210 -1
  6. data/Steepfile +12 -12
  7. data/db/migrate/001_create_prescient_tables.rb +15 -16
  8. data/examples/README.md +2 -1
  9. data/examples/custom_contexts.rb +4 -4
  10. data/exe/prescient +2 -2
  11. data/exe/prescient-mcp +7 -0
  12. data/lib/prescient/agent/audit_log.rb +37 -0
  13. data/lib/prescient/agent/cli_adapter.rb +29 -0
  14. data/lib/prescient/agent/configuration.rb +57 -0
  15. data/lib/prescient/agent/context.rb +56 -0
  16. data/lib/prescient/agent/error_serializer.rb +47 -0
  17. data/lib/prescient/agent/errors.rb +25 -0
  18. data/lib/prescient/agent/parser.rb +49 -0
  19. data/lib/prescient/agent/prompt_builder.rb +31 -0
  20. data/lib/prescient/agent/result.rb +36 -0
  21. data/lib/prescient/agent/runtime.rb +175 -0
  22. data/lib/prescient/agent/schema_validator.rb +215 -0
  23. data/lib/prescient/agent/tool_registry.rb +89 -0
  24. data/lib/prescient/agent.rb +22 -0
  25. data/lib/prescient/api.rb +337 -274
  26. data/lib/prescient/base.rb +370 -372
  27. data/lib/prescient/cli.rb +586 -526
  28. data/lib/prescient/client.rb +7 -6
  29. data/lib/prescient/configuration_loader.rb +492 -488
  30. data/lib/prescient/document_source.rb +114 -0
  31. data/lib/prescient/errors.rb +1 -3
  32. data/lib/prescient/mcp/authentication.rb +39 -0
  33. data/lib/prescient/mcp/configuration.rb +38 -0
  34. data/lib/prescient/mcp/rack.rb +243 -0
  35. data/lib/prescient/mcp/server.rb +202 -0
  36. data/lib/prescient/mcp/stdio.rb +42 -0
  37. data/lib/prescient/mcp.rb +8 -0
  38. data/lib/prescient/pgvector.rb +193 -189
  39. data/lib/prescient/provider/anthropic.rb +129 -125
  40. data/lib/prescient/provider/deepseek.rb +122 -118
  41. data/lib/prescient/provider/gemini.rb +153 -149
  42. data/lib/prescient/provider/huggingface.rb +191 -187
  43. data/lib/prescient/provider/mistral.rb +151 -147
  44. data/lib/prescient/provider/ollama.rb +168 -165
  45. data/lib/prescient/provider/openai.rb +174 -169
  46. data/lib/prescient/provider/xai.rb +122 -118
  47. data/lib/prescient/tool/search_api.rb +125 -121
  48. data/lib/prescient/tool/searxng.rb +123 -119
  49. data/lib/prescient/tool.rb +100 -98
  50. data/lib/prescient/version.rb +1 -1
  51. data/lib/prescient.rb +68 -62
  52. data/sig/prescient.rbs +176 -1
  53. metadata +23 -1
@@ -1,190 +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
- content = response.parsed_response.dig('choices', 0, 'message', 'content')
87
- raise Prescient::InvalidResponseError, 'No response generated' unless content
88
-
89
- {
90
- response: content.strip,
91
- model: options[:model] || @options[:chat_model],
92
- provider: 'openai',
93
- processing_time: nil,
94
- metadata: {
95
- usage: response.parsed_response['usage'],
96
- finish_reason: response.parsed_response.dig('choices', 0, 'finish_reason'),
97
- },
98
- }
99
- end
100
- end
101
-
102
- # Check OpenAI model availability via `/v1/models`.
103
- #
104
- # `reachable` indicates the API answered successfully. `ready` indicates that
105
- # both configured models appear in the returned model list.
106
- #
107
- # @return [Hash] Provider health information
108
- def health_check
109
- handle_errors do
110
- response = self.class.get('/v1/models',
111
- headers: {
112
- 'Authorization' => "Bearer #{@options[:api_key]}",
113
- })
114
-
115
- if response.success?
116
- models = response.parsed_response['data'] || []
117
- embedding_available = models.any? { |m| m['id'] == @options[:embedding_model] }
118
- chat_available = models.any? { |m| m['id'] == @options[:chat_model] }
119
-
120
- {
121
- status: 'healthy',
122
- provider: 'openai',
123
- reachable: true,
124
- models_available: models.map { |m| m['id'] },
125
- embedding_model: {
126
- name: @options[:embedding_model],
127
- available: embedding_available,
128
- },
129
- chat_model: {
130
- name: @options[:chat_model],
131
- available: chat_available,
132
- },
133
- ready: embedding_available && chat_available,
134
- }
135
- else
136
- {
137
- status: 'unhealthy',
138
- provider: 'openai',
139
- reachable: true,
140
- error: "HTTP #{response.code}",
141
- message: response.message,
142
- ready: false,
143
- }
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
144
102
  end
145
- end
146
- rescue Prescient::Error => e
147
- {
148
- status: 'unavailable',
149
- provider: 'openai',
150
- reachable: false,
151
- error: e.class.name,
152
- message: e.message,
153
- ready: false,
154
- }
155
- end
156
103
 
157
- # List models available to the configured OpenAI account.
158
- #
159
- # @return [Array<Hash>] Model descriptors
160
- def list_models
161
- handle_errors do
162
- response = self.class.get('/v1/models',
163
- headers: {
164
- 'Authorization' => "Bearer #{@options[:api_key]}",
165
- })
166
- validate_response!(response, 'model listing')
167
-
168
- models = response.parsed_response['data'] || []
169
- 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
170
150
  {
171
- name: model['id'],
172
- created: model['created'],
173
- 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
174
157
  }
175
158
  end
176
- end
177
- 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
178
182
 
179
- protected
183
+ protected
180
184
 
181
- def validate_configuration!
182
- required_options = [:api_key, :embedding_model, :chat_model]
183
- 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? }
184
188
 
185
- return unless missing_options.any?
189
+ return unless missing_options.any?
186
190
 
187
- raise Prescient::Error, "Missing required options: #{missing_options.join(', ')}"
191
+ raise Prescient::Error, "Missing required options: #{missing_options.join(", ")}"
192
+ end
193
+ end
188
194
  end
189
-
190
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