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.
- checksums.yaml +4 -4
- data/.rubocop.yml +21 -268
- data/CHANGELOG.md +37 -0
- data/INTEGRATION_GUIDE.md +7 -1
- data/README.md +210 -1
- data/Steepfile +12 -12
- data/db/migrate/001_create_prescient_tables.rb +15 -16
- data/examples/README.md +2 -1
- data/examples/custom_contexts.rb +4 -4
- data/exe/prescient +2 -2
- data/exe/prescient-mcp +7 -0
- data/lib/prescient/agent/audit_log.rb +37 -0
- data/lib/prescient/agent/cli_adapter.rb +29 -0
- data/lib/prescient/agent/configuration.rb +57 -0
- data/lib/prescient/agent/context.rb +56 -0
- data/lib/prescient/agent/error_serializer.rb +47 -0
- data/lib/prescient/agent/errors.rb +25 -0
- data/lib/prescient/agent/parser.rb +49 -0
- data/lib/prescient/agent/prompt_builder.rb +31 -0
- data/lib/prescient/agent/result.rb +36 -0
- data/lib/prescient/agent/runtime.rb +175 -0
- data/lib/prescient/agent/schema_validator.rb +215 -0
- data/lib/prescient/agent/tool_registry.rb +89 -0
- data/lib/prescient/agent.rb +22 -0
- data/lib/prescient/api.rb +337 -274
- data/lib/prescient/base.rb +370 -372
- data/lib/prescient/cli.rb +586 -526
- data/lib/prescient/client.rb +7 -6
- data/lib/prescient/configuration_loader.rb +492 -488
- data/lib/prescient/document_source.rb +114 -0
- data/lib/prescient/errors.rb +1 -3
- data/lib/prescient/mcp/authentication.rb +39 -0
- data/lib/prescient/mcp/configuration.rb +38 -0
- data/lib/prescient/mcp/rack.rb +243 -0
- data/lib/prescient/mcp/server.rb +202 -0
- data/lib/prescient/mcp/stdio.rb +42 -0
- data/lib/prescient/mcp.rb +8 -0
- data/lib/prescient/pgvector.rb +193 -189
- data/lib/prescient/provider/anthropic.rb +129 -125
- data/lib/prescient/provider/deepseek.rb +122 -118
- data/lib/prescient/provider/gemini.rb +153 -149
- data/lib/prescient/provider/huggingface.rb +191 -187
- data/lib/prescient/provider/mistral.rb +151 -147
- data/lib/prescient/provider/ollama.rb +168 -165
- data/lib/prescient/provider/openai.rb +174 -169
- data/lib/prescient/provider/xai.rb +122 -118
- data/lib/prescient/tool/search_api.rb +125 -121
- data/lib/prescient/tool/searxng.rb +123 -119
- data/lib/prescient/tool.rb +100 -98
- data/lib/prescient/version.rb +1 -1
- data/lib/prescient.rb +68 -62
- data/sig/prescient.rbs +176 -1
- metadata +23 -1
|
@@ -1,202 +1,206 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
183
|
-
end
|
|
178
|
+
protected
|
|
184
179
|
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|