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,147 +1,151 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'httparty'
3
+ require "httparty"
4
4
 
5
- # Anthropic Messages API provider adapter.
6
- class Prescient::Provider::Anthropic < Prescient::Base
7
- include HTTParty
5
+ module Prescient
6
+ module Provider
7
+ # Anthropic Messages API provider adapter.
8
+ class Anthropic < Prescient::Base
9
+ include HTTParty
8
10
 
9
- base_uri 'https://api.anthropic.com'
11
+ base_uri "https://api.anthropic.com"
10
12
 
11
- def initialize(**options)
12
- super
13
- self.class.default_timeout(@options[:timeout] || 60)
14
- end
13
+ def initialize(**options)
14
+ super
15
+ self.class.default_timeout(@options[:timeout] || 60)
16
+ end
15
17
 
16
- # Anthropic does not provide embeddings through this adapter.
17
- #
18
- # @raise [Prescient::Error] Always, because Anthropic embeddings are not supported
19
- def generate_embedding(_text, **_options)
20
- # Anthropic doesn't provide embedding API, raise error
21
- raise Prescient::Error,
22
- 'Anthropic provider does not support embeddings. Use OpenAI or HuggingFace for embeddings.'
23
- end
18
+ # Anthropic does not provide embeddings through this adapter.
19
+ #
20
+ # @raise [Prescient::Error] Always, because Anthropic embeddings are not supported
21
+ def generate_embedding(_text, **_options)
22
+ # Anthropic doesn't provide embedding API, raise error
23
+ raise Prescient::Error,
24
+ "Anthropic provider does not support embeddings. Use OpenAI or HuggingFace for embeddings."
25
+ end
24
26
 
25
- # Generate a response using Anthropic's Messages 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
- formatted_prompt = build_prompt(prompt, context_items)
32
-
33
- response = self.class.post('/v1/messages',
34
- headers: {
35
- 'Content-Type' => 'application/json',
36
- 'x-api-key' => @options[:api_key],
37
- 'anthropic-version' => '2023-06-01',
38
- },
39
- body: {
40
- model: options[:model] || @options[:model],
41
- max_tokens: options[:max_tokens] || 2000,
42
- temperature: options[:temperature] || 0.7,
43
- messages: [
44
- {
45
- role: 'user',
46
- content: formatted_prompt,
27
+ # Generate a response using Anthropic's Messages API.
28
+ # @param prompt [String] Prompt to send
29
+ # @param context_items [Array<Hash, String>] Optional context items
30
+ # @return [Hash] Normalized response data
31
+ def generate_response(prompt, context_items = [], **options)
32
+ handle_errors do
33
+ formatted_prompt = build_prompt(prompt, context_items)
34
+
35
+ response = self.class.post("/v1/messages",
36
+ headers: {
37
+ "Content-Type" => "application/json",
38
+ "x-api-key" => @options[:api_key],
39
+ "anthropic-version" => "2023-06-01"
47
40
  },
48
- ],
49
- }.to_json)
50
-
51
- validate_response!(response, 'text generation')
52
-
53
- content = response.parsed_response.dig('content', 0, 'text')
54
- raise Prescient::InvalidResponseError, 'No response generated' unless content
55
-
56
- {
57
- response: content.strip,
58
- model: options[:model] || @options[:model],
59
- provider: 'anthropic',
60
- processing_time: nil,
61
- metadata: {
62
- usage: response.parsed_response['usage'],
63
- },
64
- }
65
- end
66
- end
41
+ body: {
42
+ model: options[:model] || @options[:model],
43
+ max_tokens: options[:max_tokens] || 2000,
44
+ temperature: options[:temperature] || 0.7,
45
+ messages: [
46
+ {
47
+ role: "user",
48
+ content: formatted_prompt
49
+ }
50
+ ]
51
+ }.to_json)
52
+
53
+ validate_response!(response, "text generation")
54
+
55
+ content = response.parsed_response.dig("content", 0, "text")
56
+ raise Prescient::InvalidResponseError, "No response generated" unless content
57
+
58
+ {
59
+ response: content.strip,
60
+ model: options[:model] || @options[:model],
61
+ provider: "anthropic",
62
+ processing_time: nil,
63
+ metadata: {
64
+ usage: response.parsed_response["usage"]
65
+ }
66
+ }
67
+ end
68
+ end
67
69
 
68
- # Check Anthropic API availability using the non-generating `/v1/models` endpoint.
69
- #
70
- # `reachable` indicates the API answered successfully. `ready` indicates that
71
- # the configured model appears in the returned model list.
72
- #
73
- # @return [Hash] Provider health information
74
- def health_check
75
- handle_errors do
76
- response = self.class.get('/v1/models', headers: api_headers)
77
-
78
- if response.success?
79
- models = response.parsed_response['data'] || []
80
- model_available = models.any? { |model| model['id'] == @options[:model] }
81
- {
82
- status: 'healthy',
83
- provider: 'anthropic',
84
- reachable: true,
85
- models_available: models.map { |model| model['id'] },
86
- model: { name: @options[:model], available: model_available },
87
- ready: model_available,
88
- }
89
- else
70
+ # Check Anthropic API availability using the non-generating `/v1/models` endpoint.
71
+ #
72
+ # `reachable` indicates the API answered successfully. `ready` indicates that
73
+ # the configured model appears in the returned model list.
74
+ #
75
+ # @return [Hash] Provider health information
76
+ def health_check
77
+ handle_errors do
78
+ response = self.class.get("/v1/models", headers: api_headers)
79
+
80
+ if response.success?
81
+ models = response.parsed_response["data"] || []
82
+ model_available = models.any? { |model| model["id"] == @options[:model] }
83
+ {
84
+ status: "healthy",
85
+ provider: "anthropic",
86
+ reachable: true,
87
+ models_available: models.map { |model| model["id"] },
88
+ model: { name: @options[:model], available: model_available },
89
+ ready: model_available
90
+ }
91
+ else
92
+ {
93
+ status: "unhealthy", provider: "anthropic", reachable: true,
94
+ error: "HTTP #{response.code}", message: response.message, ready: false
95
+ }
96
+ end
97
+ end
98
+ rescue Prescient::Error => e
90
99
  {
91
- status: 'unhealthy', provider: 'anthropic', reachable: true,
92
- error: "HTTP #{response.code}", message: response.message, ready: false
100
+ status: "unavailable",
101
+ provider: "anthropic",
102
+ reachable: false,
103
+ error: e.class.name,
104
+ message: e.message,
105
+ ready: false
93
106
  }
94
107
  end
95
- end
96
- rescue Prescient::Error => e
97
- {
98
- status: 'unavailable',
99
- provider: 'anthropic',
100
- reachable: false,
101
- error: e.class.name,
102
- message: e.message,
103
- ready: false,
104
- }
105
- end
106
108
 
107
- # Return models available to the configured Anthropic account.
108
- # @return [Array<Hash>] Model descriptors
109
- def list_models
110
- handle_errors do
111
- response = self.class.get('/v1/models', headers: api_headers)
112
- validate_response!(response, 'model listing')
113
-
114
- (response.parsed_response['data'] || []).map do |model|
115
- {
116
- name: model['id'],
117
- type: 'text',
118
- display_name: model['display_name'],
119
- created_at: model['created_at'],
120
- max_input_tokens: model['max_input_tokens'],
121
- max_tokens: model['max_tokens'],
122
- }.compact
109
+ # Return models available to the configured Anthropic account.
110
+ # @return [Array<Hash>] Model descriptors
111
+ def list_models
112
+ handle_errors do
113
+ response = self.class.get("/v1/models", headers: api_headers)
114
+ validate_response!(response, "model listing")
115
+
116
+ (response.parsed_response["data"] || []).map do |model|
117
+ {
118
+ name: model["id"],
119
+ type: "text",
120
+ display_name: model["display_name"],
121
+ created_at: model["created_at"],
122
+ max_input_tokens: model["max_input_tokens"],
123
+ max_tokens: model["max_tokens"]
124
+ }.compact
125
+ end
126
+ end
123
127
  end
124
- end
125
- end
126
128
 
127
- protected
129
+ protected
128
130
 
129
- def validate_configuration!
130
- required_options = [:api_key, :model]
131
- missing_options = required_options.select { |opt| @options[opt].nil? }
131
+ def validate_configuration!
132
+ required_options = %i[api_key model]
133
+ missing_options = required_options.select { |opt| @options[opt].nil? }
132
134
 
133
- return unless missing_options.any?
135
+ return unless missing_options.any?
134
136
 
135
- raise Prescient::Error, "Missing required options: #{missing_options.join(', ')}"
136
- end
137
+ raise Prescient::Error, "Missing required options: #{missing_options.join(", ")}"
138
+ end
137
139
 
138
- private
140
+ private
139
141
 
140
- def api_headers
141
- {
142
- 'Content-Type' => 'application/json',
143
- 'x-api-key' => @options[:api_key],
144
- 'anthropic-version' => '2023-06-01',
145
- }
142
+ def api_headers
143
+ {
144
+ "Content-Type" => "application/json",
145
+ "x-api-key" => @options[:api_key],
146
+ "anthropic-version" => "2023-06-01"
147
+ }
148
+ end
149
+ end
146
150
  end
147
151
  end
@@ -1,139 +1,143 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'httparty'
3
+ require "httparty"
4
4
 
5
- # DeepSeek API provider adapter.
6
- class Prescient::Provider::DeepSeek < Prescient::Base
7
- include HTTParty
5
+ module Prescient
6
+ module Provider
7
+ # DeepSeek API provider adapter.
8
+ class DeepSeek < Prescient::Base
9
+ include HTTParty
8
10
 
9
- base_uri 'https://api.deepseek.com'
11
+ base_uri "https://api.deepseek.com"
10
12
 
11
- def initialize(**options)
12
- super
13
- @provider_name = 'DeepSeek'
14
- self.class.default_timeout(@options[:timeout] || 60)
15
- end
16
-
17
- # DeepSeek does not currently provide an embeddings endpoint.
18
- # @raise [Prescient::Error] Always, because embeddings are unsupported
19
- def generate_embedding(_text, **_options)
20
- raise Prescient::Error, 'DeepSeek provider does not support embeddings.'
21
- end
22
-
23
- # Generate a response through DeepSeek'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
- '/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: 'deepseek',
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 = "DeepSeek"
16
+ self.class.default_timeout(@options[:timeout] || 60)
17
+ end
60
18
 
61
- # Check whether the configured DeepSeek model is available.
62
- # @return [Hash] Provider health information
63
- def health_check
64
- handle_errors do
65
- response = self.class.get('/models', headers: api_headers)
19
+ # DeepSeek does not currently provide an embeddings endpoint.
20
+ # @raise [Prescient::Error] Always, because embeddings are unsupported
21
+ def generate_embedding(_text, **_options)
22
+ raise Prescient::Error, "DeepSeek 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 DeepSeek'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
+ "/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: "deepseek",
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 DeepSeek model is available.
64
+ # @return [Hash] Provider health information
65
+ def health_check
66
+ handle_errors do
67
+ response = self.class.get("/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: "deepseek",
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: "deepseek",
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: 'deepseek',
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: 'deepseek',
83
- reachable: true,
84
- error: "HTTP #{response.code}",
85
- message: response.message,
86
- ready: false,
94
+ status: "unavailable",
95
+ provider: "deepseek",
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: 'deepseek',
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 DeepSeek API key.
102
- # @return [Array<Hash>] Model descriptors
103
- def list_models
104
- handle_errors do
105
- response = self.class.get('/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
- permission: model['permission'],
115
- }.compact
103
+ # List models available to the configured DeepSeek API key.
104
+ # @return [Array<Hash>] Model descriptors
105
+ def list_models
106
+ handle_errors do
107
+ response = self.class.get("/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
+ permission: model["permission"]
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