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,183 +1,186 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'httparty'
4
-
5
- # Ollama local or hosted API provider adapter.
6
- class Prescient::Provider::Ollama < Prescient::Base
7
- include HTTParty
8
-
9
- def initialize(**options)
10
- super
11
- self.class.base_uri(@options[:url])
12
- self.class.default_timeout(@options[:timeout] || 60)
13
- end
14
-
15
- # Generate an embedding through Ollama's `/api/embed` endpoint.
16
- # @param text [String] Text to embed
17
- # @return [Array<Float>] Embedding vector from the first returned input item
18
- def generate_embedding(text, **_options)
19
- handle_errors do
20
- embeddings = fetch_and_parse('post', '/api/embed',
21
- root_key: 'embeddings',
22
- headers: { 'Content-Type' => 'application/json' },
23
- body: {
24
- model: options[:model] || @options[:embedding_model],
25
- input: clean_text(text),
26
- }.to_json)
27
-
28
- embedding = embeddings.is_a?(Array) ? embeddings.first : nil # : Array[Float]?
29
- raise Prescient::InvalidResponseError, 'No embedding returned' unless embedding.is_a?(Array)
30
-
31
- expected_dimensions = @options[:embedding_dimensions]
32
- if expected_dimensions && embedding.length != expected_dimensions
33
- raise Prescient::InvalidResponseError,
34
- "Invalid embedding dimensions: expected #{expected_dimensions}, got #{embedding.length}"
3
+ require "httparty"
4
+
5
+ module Prescient
6
+ module Provider
7
+ # Ollama local or hosted API provider adapter.
8
+ class Ollama < Prescient::Base
9
+ include HTTParty
10
+
11
+ def initialize(**options)
12
+ super
13
+ self.class.base_uri(@options[:url])
14
+ self.class.default_timeout(@options[:timeout] || 60)
35
15
  end
36
16
 
37
- embedding
38
- end
39
- end
17
+ # Generate an embedding through Ollama's `/api/embed` endpoint.
18
+ # @param text [String] Text to embed
19
+ # @return [Array<Float>] Embedding vector from the first returned input item
20
+ def generate_embedding(text, **_options)
21
+ handle_errors do
22
+ embeddings = fetch_and_parse("post", "/api/embed",
23
+ root_key: "embeddings",
24
+ headers: { "Content-Type" => "application/json" },
25
+ body: {
26
+ model: options[:model] || @options[:embedding_model],
27
+ input: clean_text(text)
28
+ }.to_json)
29
+
30
+ embedding = embeddings.is_a?(Array) ? embeddings.first : nil # : Array[Float]?
31
+ raise Prescient::InvalidResponseError, "No embedding returned" unless embedding.is_a?(Array)
32
+
33
+ expected_dimensions = @options[:embedding_dimensions]
34
+ if expected_dimensions && embedding.length != expected_dimensions
35
+ raise Prescient::InvalidResponseError,
36
+ "Invalid embedding dimensions: expected #{expected_dimensions}, got #{embedding.length}"
37
+ end
38
+
39
+ embedding
40
+ end
41
+ end
40
42
 
41
- # Generate text through Ollama's generation endpoint.
42
- # @param prompt [String] Prompt to send
43
- # @param context_items [Array<Hash, String>] Optional context items
44
- # @return [Hash] Normalized response data
45
- def generate_response(prompt, context_items = [], **options)
46
- handle_errors do
47
- request_options = prepare_generate_response(prompt, context_items, **options)
48
-
49
- # Make the request and store both text and full response
50
- response = self.class.post('/api/generate', **request_options)
51
- validate_response!(response, 'POST /api/generate')
52
-
53
- generated_text = response.parsed_response['response']
54
- raise Prescient::InvalidResponseError, 'No response generated' unless generated_text
55
-
56
- {
57
- response: generated_text.strip,
58
- model: options[:model] || @options[:chat_model],
59
- provider: 'ollama',
60
- processing_time: response.parsed_response['total_duration']&./(1_000_000_000.0),
61
- metadata: {
62
- eval_count: response.parsed_response['eval_count'],
63
- eval_duration: response.parsed_response['eval_duration'],
64
- prompt_eval_count: response.parsed_response['prompt_eval_count'],
65
- },
66
- }
67
- end
68
- end
43
+ # Generate text through Ollama's generation endpoint.
44
+ # @param prompt [String] Prompt to send
45
+ # @param context_items [Array<Hash, String>] Optional context items
46
+ # @return [Hash] Normalized response data
47
+ def generate_response(prompt, context_items = [], **options)
48
+ handle_errors do
49
+ request_options = prepare_generate_response(prompt, context_items, **options)
50
+
51
+ # Make the request and store both text and full response
52
+ response = self.class.post("/api/generate", **request_options)
53
+ validate_response!(response, "POST /api/generate")
54
+
55
+ generated_text = response.parsed_response["response"]
56
+ raise Prescient::InvalidResponseError, "No response generated" unless generated_text
57
+
58
+ {
59
+ response: generated_text.strip,
60
+ model: options[:model] || @options[:chat_model],
61
+ provider: "ollama",
62
+ processing_time: response.parsed_response["total_duration"]&./(1_000_000_000.0),
63
+ metadata: {
64
+ eval_count: response.parsed_response["eval_count"],
65
+ eval_duration: response.parsed_response["eval_duration"],
66
+ prompt_eval_count: response.parsed_response["prompt_eval_count"]
67
+ }
68
+ }
69
+ end
70
+ end
69
71
 
70
- # Check whether the configured Ollama models are available locally.
71
- #
72
- # `reachable` indicates the Ollama API answered successfully. `ready`
73
- # indicates that both configured models are present in the local model list.
74
- #
75
- # @return [Hash] Provider health information
76
- def health_check
77
- handle_errors do
78
- models = available_models
79
- embedding_available = models.any? { |m| m[:embedding] }
80
- chat_available = models.any? { |m| m[:chat] }
81
-
82
- {
83
- status: 'healthy',
84
- provider: 'ollama',
85
- reachable: true,
86
- url: @options[:url],
87
- models_available: models.map { |m| m[:name] },
88
- embedding_model: {
89
- name: @options[:embedding_model],
90
- available: embedding_available,
91
- },
92
- chat_model: {
93
- name: @options[:chat_model],
94
- available: chat_available,
95
- },
96
- ready: embedding_available && chat_available,
97
- }
98
- end
99
- rescue Prescient::Error => e
100
- {
101
- status: 'unavailable',
102
- provider: 'ollama',
103
- reachable: false,
104
- error: e.class.name,
105
- message: e.message,
106
- url: @options[:url],
107
- ready: false,
108
- }
109
- end
72
+ # Check whether the configured Ollama models are available locally.
73
+ #
74
+ # `reachable` indicates the Ollama API answered successfully. `ready`
75
+ # indicates that both configured models are present in the local model list.
76
+ #
77
+ # @return [Hash] Provider health information
78
+ def health_check
79
+ handle_errors do
80
+ models = available_models
81
+ embedding_available = models.any? { |m| m[:embedding] }
82
+ chat_available = models.any? { |m| m[:chat] }
83
+
84
+ {
85
+ status: "healthy",
86
+ provider: "ollama",
87
+ reachable: true,
88
+ url: @options[:url],
89
+ models_available: models.map { |m| m[:name] },
90
+ embedding_model: {
91
+ name: @options[:embedding_model],
92
+ available: embedding_available
93
+ },
94
+ chat_model: {
95
+ name: @options[:chat_model],
96
+ available: chat_available
97
+ },
98
+ ready: embedding_available && chat_available
99
+ }
100
+ end
101
+ rescue Prescient::Error => e
102
+ {
103
+ status: "unavailable",
104
+ provider: "ollama",
105
+ reachable: false,
106
+ error: e.class.name,
107
+ message: e.message,
108
+ url: @options[:url],
109
+ ready: false
110
+ }
111
+ end
110
112
 
111
- # List models currently available from Ollama.
112
- # @return [Array<Hash>] Model descriptors including name, size, digest,
113
- # modified_at, and booleans for configured embedding/chat roles
114
- def available_models
115
- return @_available_models if defined?(@_available_models)
116
-
117
- handle_errors do
118
- @_available_models = (fetch_and_parse('get', '/api/tags', root_key: 'models') || []).map { |model|
119
- { embedding: model['name'] == @options[:embedding_model],
120
- chat: model['name'] == @options[:chat_model],
121
- name: model['name'], size: model['size'], modified_at: model['modified_at'], digest: model['digest'] }
122
- }
123
- end
124
- end
113
+ # List models currently available from Ollama.
114
+ # @return [Array<Hash>] Model descriptors including name, size, digest,
115
+ # modified_at, and booleans for configured embedding/chat roles
116
+ def available_models
117
+ return @_available_models if defined?(@_available_models)
118
+
119
+ handle_errors do
120
+ @_available_models = (fetch_and_parse("get", "/api/tags", root_key: "models") || []).map do |model|
121
+ { embedding: model["name"] == @options[:embedding_model],
122
+ chat: model["name"] == @options[:chat_model],
123
+ name: model["name"], size: model["size"], modified_at: model["modified_at"], digest: model["digest"] }
124
+ end
125
+ end
126
+ end
125
127
 
126
- # Pull a model into the Ollama installation.
127
- # @param model_name [String] Model identifier to download
128
- # @return [Hash] Pull result
129
- def pull_model(model_name)
130
- handle_errors do
131
- fetch_and_parse('post', '/api/pull',
132
- headers: { 'Content-Type' => 'application/json' },
133
- body: { name: model_name }.to_json,
134
- timeout: 300) # 5 minutes for model download
135
- {
136
- success: true,
137
- model: model_name,
138
- message: "Model #{model_name} pulled successfully",
139
- }
140
- end
141
- end
128
+ # Pull a model into the Ollama installation.
129
+ # @param model_name [String] Model identifier to download
130
+ # @return [Hash] Pull result
131
+ def pull_model(model_name)
132
+ handle_errors do
133
+ fetch_and_parse("post", "/api/pull",
134
+ headers: { "Content-Type" => "application/json" },
135
+ body: { name: model_name }.to_json,
136
+ timeout: 300) # 5 minutes for model download
137
+ {
138
+ success: true,
139
+ model: model_name,
140
+ message: "Model #{model_name} pulled successfully"
141
+ }
142
+ end
143
+ end
142
144
 
143
- protected
145
+ protected
144
146
 
145
- def validate_configuration!
146
- required_options = [:url, :embedding_model, :chat_model]
147
- missing_options = required_options.select { |opt| @options[opt].nil? }
147
+ def validate_configuration!
148
+ required_options = %i[url embedding_model chat_model]
149
+ missing_options = required_options.select { |opt| @options[opt].nil? }
148
150
 
149
- return unless missing_options.any?
151
+ return unless missing_options.any?
150
152
 
151
- raise Prescient::Error, "Missing required options: #{missing_options.join(', ')}"
152
- end
153
+ raise Prescient::Error, "Missing required options: #{missing_options.join(", ")}"
154
+ end
153
155
 
154
- private
155
-
156
- def prepare_generate_response(prompt, context_items = [], **options)
157
- formatted_prompt = build_prompt(prompt, context_items)
158
- { root_key: 'response',
159
- headers: { 'Content-Type' => 'application/json' },
160
- body: {
161
- model: options[:model] || @options[:chat_model],
162
- prompt: formatted_prompt,
163
- stream: false,
164
- options: {
165
- num_predict: options[:max_tokens] || 2000,
166
- temperature: options[:temperature] || 0.7,
167
- top_p: options[:top_p] || 0.9,
168
- },
169
- }.to_json }
170
- end
156
+ private
157
+
158
+ def prepare_generate_response(prompt, context_items = [], **options)
159
+ formatted_prompt = build_prompt(prompt, context_items)
160
+ { root_key: "response",
161
+ headers: { "Content-Type" => "application/json" },
162
+ body: {
163
+ model: options[:model] || @options[:chat_model],
164
+ prompt: formatted_prompt,
165
+ stream: false,
166
+ options: {
167
+ num_predict: options[:max_tokens] || 2000,
168
+ temperature: options[:temperature] || 0.7,
169
+ top_p: options[:top_p] || 0.9
170
+ }
171
+ }.to_json }
172
+ end
171
173
 
172
- def fetch_and_parse(htt_verb, endpoint, **options)
173
- options = options.dup
174
- root_key = options.delete(:root_key)
174
+ def fetch_and_parse(htt_verb, endpoint, **options)
175
+ options = options.dup
176
+ root_key = options.delete(:root_key)
175
177
 
176
- response = self.class.send(htt_verb, endpoint, **options)
177
- validate_response!(response, "#{htt_verb.upcase} #{endpoint}")
178
- return unless root_key
178
+ response = self.class.send(htt_verb, endpoint, **options)
179
+ validate_response!(response, "#{htt_verb.upcase} #{endpoint}")
180
+ return unless root_key
179
181
 
180
- response.parsed_response[root_key]
182
+ response.parsed_response[root_key]
183
+ end
184
+ end
181
185
  end
182
-
183
186
  end