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,202 +1,206 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'httparty'
4
-
5
- # Hugging Face router-backed Inference Providers API adapter.
6
- class Prescient::Provider::HuggingFace < Prescient::Base
7
- include HTTParty
8
-
9
- base_uri 'https://router.huggingface.co'
10
-
11
- # Router path for the Hugging Face feature-extraction provider.
12
- # @return [String] Feature-extraction endpoint template
13
- FEATURE_EXTRACTION_PATH = '/hf-inference/models/%<model>s/pipeline/feature-extraction'
14
-
15
- # OpenAI-compatible router path for Hugging Face chat completions.
16
- # @return [String] Chat-completions endpoint path
17
- CHAT_COMPLETIONS_PATH = '/v1/chat/completions'
18
-
19
- # OpenAI-compatible router path for listing available chat models.
20
- # @return [String] Model-list endpoint path
21
- MODEL_LIST_PATH = '/v1/models'
22
-
23
- # Known embedding dimensions for commonly used models.
24
- EMBEDDING_DIMENSIONS = {
25
- 'sentence-transformers/all-MiniLM-L6-v2' => 384,
26
- 'sentence-transformers/all-mpnet-base-v2' => 768,
27
- 'sentence-transformers/all-roberta-large-v1' => 1024,
28
- }.freeze
29
-
30
- def initialize(**options)
31
- super
32
- @provider_name = 'Hugging Face'
33
- self.class.default_timeout(@options[:timeout] || 60)
34
- end
35
-
36
- # Generate an embedding through Hugging Face feature extraction.
37
- # @param text [String] Text to embed
38
- # @return [Array<Float>] Embedding vector
39
- def generate_embedding(text, **options)
40
- handle_errors do
41
- clean_text_input = clean_text(text)
42
-
43
- embedding_model = options[:model] || @options[:embedding_model]
44
- response = self.class.post(FEATURE_EXTRACTION_PATH % { model: embedding_model },
45
- headers: {
46
- 'Content-Type' => 'application/json',
47
- 'Authorization' => "Bearer #{@options[:api_key]}",
48
- },
49
- body: { inputs: clean_text_input }.to_json)
50
-
51
- validate_response!(response, 'embedding generation')
52
-
53
- # HuggingFace returns embeddings as nested arrays, get the first one
54
- embedding_data = response.parsed_response
55
- embedding_data = embedding_data.first if embedding_data.is_a?(Array) && embedding_data.first.is_a?(Array)
56
-
57
- raise Prescient::InvalidResponseError, 'No embedding returned' unless embedding_data.is_a?(Array)
58
-
59
- expected_dimensions = EMBEDDING_DIMENSIONS[embedding_model] || @options[:embedding_dimensions]
60
- unless expected_dimensions
61
- raise Prescient::Error,
62
- "Embedding dimensions are required for model #{embedding_model}"
3
+ require "httparty"
4
+
5
+ module Prescient
6
+ module Provider
7
+ # Hugging Face router-backed Inference Providers API adapter.
8
+ class HuggingFace < Prescient::Base
9
+ include HTTParty
10
+
11
+ base_uri "https://router.huggingface.co"
12
+
13
+ # Router path for the Hugging Face feature-extraction provider.
14
+ # @return [String] Feature-extraction endpoint template
15
+ FEATURE_EXTRACTION_PATH = "/hf-inference/models/%<model>s/pipeline/feature-extraction"
16
+
17
+ # OpenAI-compatible router path for Hugging Face chat completions.
18
+ # @return [String] Chat-completions endpoint path
19
+ CHAT_COMPLETIONS_PATH = "/v1/chat/completions"
20
+
21
+ # OpenAI-compatible router path for listing available chat models.
22
+ # @return [String] Model-list endpoint path
23
+ MODEL_LIST_PATH = "/v1/models"
24
+
25
+ # Known embedding dimensions for commonly used models.
26
+ EMBEDDING_DIMENSIONS = {
27
+ "sentence-transformers/all-MiniLM-L6-v2" => 384,
28
+ "sentence-transformers/all-mpnet-base-v2" => 768,
29
+ "sentence-transformers/all-roberta-large-v1" => 1024
30
+ }.freeze
31
+
32
+ def initialize(**options)
33
+ super
34
+ @provider_name = "Hugging Face"
35
+ self.class.default_timeout(@options[:timeout] || 60)
63
36
  end
64
37
 
65
- validate_embedding_dimensions(embedding_data, expected_dimensions)
66
- end
67
- end
68
-
69
- # Generate text through a Hugging Face text-generation model.
70
- # @param prompt [String] Prompt to send
71
- # @param context_items [Array<Hash, String>] Optional context items
72
- # @return [Hash] Normalized response data
73
- def generate_response(prompt, context_items = [], **options)
74
- handle_errors do
75
- formatted_prompt = build_prompt(prompt, context_items)
76
-
77
- response = self.class.post(CHAT_COMPLETIONS_PATH,
78
- headers: {
79
- 'Content-Type' => 'application/json',
80
- 'Authorization' => "Bearer #{@options[:api_key]}",
81
- },
82
- body: {
83
- model: options[:model] || @options[:chat_model],
84
- messages: [{ role: 'user', content: formatted_prompt }],
85
- max_tokens: options[:max_tokens] || 2000,
86
- temperature: options[:temperature] || 0.7,
87
- top_p: options[:top_p] || 0.9,
88
- }.to_json)
89
-
90
- validate_response!(response, 'text generation')
91
-
92
- parsed_response = response.parsed_response
93
- generated_text = parsed_response.dig('choices', 0, 'message', 'content') if parsed_response.is_a?(Hash)
94
- raise Prescient::InvalidResponseError, 'No response generated' unless generated_text
95
-
96
- {
97
- response: generated_text.strip,
98
- model: options[:model] || @options[:chat_model],
99
- provider: 'huggingface',
100
- processing_time: nil,
101
- metadata: {
102
- usage: parsed_response['usage'],
103
- finish_reason: parsed_response.dig('choices', 0, 'finish_reason'),
104
- },
105
- }
106
- end
107
- end
108
-
109
- # Check availability of the configured embedding and text models.
110
- #
111
- # The embedding model is checked against the model metadata API on
112
- # `huggingface.co`, while the chat model is checked against the router's
113
- # OpenAI-compatible `/v1/models` listing. `ready` requires both checks to
114
- # succeed.
115
- #
116
- # @return [Hash] Provider health information
117
- def health_check
118
- handle_errors do
119
- embedding_response = self.class.get("https://huggingface.co/api/models/#{@options[:embedding_model]}",
120
- headers: { 'Authorization' => "Bearer #{@options[:api_key]}" })
121
- chat_response = self.class.get(MODEL_LIST_PATH,
122
- headers: { 'Authorization' => "Bearer #{@options[:api_key]}" })
123
-
124
- embedding_healthy = embedding_response.success?
125
- chat_models = chat_response.parsed_response['data'] || []
126
- chat_healthy = chat_response.success? && chat_models.any? { |model| model['id'] == @options[:chat_model] }
127
-
128
- {
129
- status: embedding_healthy && chat_healthy ? 'healthy' : 'partial',
130
- provider: 'huggingface',
131
- reachable: true,
132
- embedding_model: {
133
- name: @options[:embedding_model],
134
- available: embedding_healthy,
135
- },
136
- chat_model: {
137
- name: @options[:chat_model],
138
- available: chat_healthy,
139
- },
140
- ready: embedding_healthy && chat_healthy,
141
- }
142
- end
143
- rescue Prescient::Error => e
144
- {
145
- status: 'unavailable',
146
- provider: 'huggingface',
147
- reachable: false,
148
- error: e.class.name,
149
- message: e.message,
150
- ready: false,
151
- }
152
- end
38
+ # Generate an embedding through Hugging Face feature extraction.
39
+ # @param text [String] Text to embed
40
+ # @return [Array<Float>] Embedding vector
41
+ def generate_embedding(text, **options)
42
+ handle_errors do
43
+ clean_text_input = clean_text(text)
44
+
45
+ embedding_model = options[:model] || @options[:embedding_model]
46
+ response = self.class.post(format(FEATURE_EXTRACTION_PATH, model: embedding_model),
47
+ headers: {
48
+ "Content-Type" => "application/json",
49
+ "Authorization" => "Bearer #{@options[:api_key]}"
50
+ },
51
+ body: { inputs: clean_text_input }.to_json)
52
+
53
+ validate_response!(response, "embedding generation")
54
+
55
+ # HuggingFace returns embeddings as nested arrays, get the first one
56
+ embedding_data = response.parsed_response
57
+ embedding_data = embedding_data.first if embedding_data.is_a?(Array) && embedding_data.first.is_a?(Array)
58
+
59
+ raise Prescient::InvalidResponseError, "No embedding returned" unless embedding_data.is_a?(Array)
60
+
61
+ expected_dimensions = EMBEDDING_DIMENSIONS[embedding_model] || @options[:embedding_dimensions]
62
+ unless expected_dimensions
63
+ raise Prescient::Error,
64
+ "Embedding dimensions are required for model #{embedding_model}"
65
+ end
66
+
67
+ validate_embedding_dimensions(embedding_data, expected_dimensions)
68
+ end
69
+ end
153
70
 
154
- # Return the configured Hugging Face models.
155
- #
156
- # This method does not query the Hugging Face APIs. It reflects the current
157
- # adapter configuration only.
158
- #
159
- # @return [Array<Hash>] Model descriptors
160
- def list_models
161
- # HuggingFace doesn't provide a simple API to list all models
162
- # Return the configured models
163
- [
164
- {
165
- name: @options[:embedding_model],
166
- type: 'embedding',
167
- dimensions: EMBEDDING_DIMENSIONS[@options[:embedding_model]],
168
- },
169
- {
170
- name: @options[:chat_model],
171
- type: 'text-generation',
172
- },
173
- ]
174
- end
71
+ # Generate text through a Hugging Face text-generation model.
72
+ # @param prompt [String] Prompt to send
73
+ # @param context_items [Array<Hash, String>] Optional context items
74
+ # @return [Hash] Normalized response data
75
+ def generate_response(prompt, context_items = [], **options)
76
+ handle_errors do
77
+ formatted_prompt = build_prompt(prompt, context_items)
78
+
79
+ response = self.class.post(CHAT_COMPLETIONS_PATH,
80
+ headers: {
81
+ "Content-Type" => "application/json",
82
+ "Authorization" => "Bearer #{@options[:api_key]}"
83
+ },
84
+ body: {
85
+ model: options[:model] || @options[:chat_model],
86
+ messages: [{ role: "user", content: formatted_prompt }],
87
+ max_tokens: options[:max_tokens] || 2000,
88
+ temperature: options[:temperature] || 0.7,
89
+ top_p: options[:top_p] || 0.9
90
+ }.to_json)
91
+
92
+ validate_response!(response, "text generation")
93
+
94
+ parsed_response = response.parsed_response
95
+ generated_text = parsed_response.dig("choices", 0, "message", "content") if parsed_response.is_a?(Hash)
96
+ raise Prescient::InvalidResponseError, "No response generated" unless generated_text
97
+
98
+ {
99
+ response: generated_text.strip,
100
+ model: options[:model] || @options[:chat_model],
101
+ provider: "huggingface",
102
+ processing_time: nil,
103
+ metadata: {
104
+ usage: parsed_response["usage"],
105
+ finish_reason: parsed_response.dig("choices", 0, "finish_reason")
106
+ }
107
+ }
108
+ end
109
+ end
175
110
 
176
- protected
111
+ # Check availability of the configured embedding and text models.
112
+ #
113
+ # The embedding model is checked against the model metadata API on
114
+ # `huggingface.co`, while the chat model is checked against the router's
115
+ # OpenAI-compatible `/v1/models` listing. `ready` requires both checks to
116
+ # succeed.
117
+ #
118
+ # @return [Hash] Provider health information
119
+ def health_check
120
+ handle_errors do
121
+ embedding_response = self.class.get("https://huggingface.co/api/models/#{@options[:embedding_model]}",
122
+ headers: { "Authorization" => "Bearer #{@options[:api_key]}" })
123
+ chat_response = self.class.get(MODEL_LIST_PATH,
124
+ headers: { "Authorization" => "Bearer #{@options[:api_key]}" })
125
+
126
+ embedding_healthy = embedding_response.success?
127
+ chat_models = chat_response.parsed_response["data"] || []
128
+ chat_healthy = chat_response.success? && chat_models.any? { |model| model["id"] == @options[:chat_model] }
129
+
130
+ {
131
+ status: embedding_healthy && chat_healthy ? "healthy" : "partial",
132
+ provider: "huggingface",
133
+ reachable: true,
134
+ embedding_model: {
135
+ name: @options[:embedding_model],
136
+ available: embedding_healthy
137
+ },
138
+ chat_model: {
139
+ name: @options[:chat_model],
140
+ available: chat_healthy
141
+ },
142
+ ready: embedding_healthy && chat_healthy
143
+ }
144
+ end
145
+ rescue Prescient::Error => e
146
+ {
147
+ status: "unavailable",
148
+ provider: "huggingface",
149
+ reachable: false,
150
+ error: e.class.name,
151
+ message: e.message,
152
+ ready: false
153
+ }
154
+ end
177
155
 
178
- def validate_configuration!
179
- missing_options = [:api_key, :embedding_model, :chat_model].select { |opt| @options[opt].nil? }
180
- return unless missing_options.any?
156
+ # Return the configured Hugging Face models.
157
+ #
158
+ # This method does not query the Hugging Face APIs. It reflects the current
159
+ # adapter configuration only.
160
+ #
161
+ # @return [Array<Hash>] Model descriptors
162
+ def list_models
163
+ # HuggingFace doesn't provide a simple API to list all models
164
+ # Return the configured models
165
+ [
166
+ {
167
+ name: @options[:embedding_model],
168
+ type: "embedding",
169
+ dimensions: EMBEDDING_DIMENSIONS[@options[:embedding_model]]
170
+ },
171
+ {
172
+ name: @options[:chat_model],
173
+ type: "text-generation"
174
+ }
175
+ ]
176
+ end
181
177
 
182
- raise Prescient::Error, "Missing required options: #{missing_options.join(', ')}"
183
- end
178
+ protected
184
179
 
185
- private
180
+ def validate_configuration!
181
+ missing_options = %i[api_key embedding_model chat_model].select { |opt| @options[opt].nil? }
182
+ return unless missing_options.any?
186
183
 
187
- def provider_error(message, response, operation:, provider: nil, error_class: Prescient::ProviderError)
188
- if response.code == 503
189
- # HuggingFace model loading
190
- error_body = begin
191
- response.parsed_response
192
- rescue StandardError
193
- nil
184
+ raise Prescient::Error, "Missing required options: #{missing_options.join(", ")}"
194
185
  end
195
- if error_body.is_a?(Hash) && error_body['error']&.include?('loading')
196
- message = 'Model is loading, please try again later'
186
+
187
+ private
188
+
189
+ def provider_error(message, response, operation:, provider: nil, error_class: Prescient::ProviderError)
190
+ if response.code == 503
191
+ # HuggingFace model loading
192
+ error_body = begin
193
+ response.parsed_response
194
+ rescue StandardError
195
+ nil
196
+ end
197
+ if error_body.is_a?(Hash) && error_body["error"]&.include?("loading")
198
+ message = "Model is loading, please try again later"
199
+ end
200
+ end
201
+
202
+ super
197
203
  end
198
204
  end
199
-
200
- super
201
205
  end
202
206
  end