prescient 0.2.0 → 0.4.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 +0 -2
- data/.yardopts +3 -6
- data/CHANGELOG.md +53 -4
- data/INTEGRATION_GUIDE.md +27 -29
- data/LICENSE.txt +1 -1
- data/README.md +217 -74
- data/Rakefile +82 -7
- data/Steepfile +17 -0
- data/VECTOR_SEARCH_GUIDE.md +32 -8
- data/docker-compose.yml +3 -5
- data/examples/README.md +45 -0
- data/examples/basic_usage.rb +2 -2
- data/examples/custom_contexts.rb +6 -6
- data/examples/custom_prompts.rb +5 -5
- data/examples/vector_search.rb +2 -2
- data/exe/prescient +7 -0
- data/lib/prescient/base.rb +81 -26
- data/lib/prescient/cli.rb +287 -0
- data/lib/prescient/client.rb +85 -39
- data/lib/prescient/errors.rb +51 -0
- data/lib/prescient/pgvector.rb +194 -0
- data/lib/prescient/provider/anthropic.rb +57 -56
- data/lib/prescient/provider/huggingface.rb +79 -78
- data/lib/prescient/provider/ollama.rb +48 -37
- data/lib/prescient/provider/openai.rb +43 -35
- data/lib/prescient/version.rb +2 -1
- data/lib/prescient.rb +86 -63
- data/scripts/setup-ollama-models.sh +2 -2
- data/sig/prescient.rbs +233 -1
- metadata +25 -218
- data/CHANGELOG.pdf +0 -0
- data/prescient.gemspec +0 -53
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
# PostgreSQL pgvector integration.
|
|
6
|
+
#
|
|
7
|
+
# The integration accepts a PG-compatible connection object, so applications
|
|
8
|
+
# choose and manage their own PostgreSQL driver and connection lifecycle.
|
|
9
|
+
# Stores provider embeddings and performs nearest-neighbor searches.
|
|
10
|
+
class Prescient::Pgvector::Store
|
|
11
|
+
# @return [Hash<Symbol, String>] Supported pgvector distance operators
|
|
12
|
+
METRICS = {
|
|
13
|
+
cosine: '<=>',
|
|
14
|
+
euclidean: '<->',
|
|
15
|
+
inner_product: '<#>',
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# @return [Integer] Required vector dimensions
|
|
19
|
+
attr_reader :dimensions
|
|
20
|
+
|
|
21
|
+
# @return [String] Embeddings table name
|
|
22
|
+
attr_reader :table
|
|
23
|
+
|
|
24
|
+
# @param connection [Object] PG-compatible object responding to `exec`
|
|
25
|
+
# and `exec_params`
|
|
26
|
+
# @param dimensions [Integer] Required dimensions for every embedding
|
|
27
|
+
# @param table [String, Symbol] Safe PostgreSQL table identifier
|
|
28
|
+
def initialize(connection:, dimensions:, table: 'prescient_embeddings')
|
|
29
|
+
@connection = connection
|
|
30
|
+
@dimensions = validate_dimensions(dimensions)
|
|
31
|
+
@table = validate_table(table)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Create the pgvector extension and the embeddings table.
|
|
35
|
+
#
|
|
36
|
+
# @return [void]
|
|
37
|
+
def install!
|
|
38
|
+
@connection.exec('CREATE EXTENSION IF NOT EXISTS vector')
|
|
39
|
+
@connection.exec(<<~SQL)
|
|
40
|
+
CREATE TABLE IF NOT EXISTS #{table} (
|
|
41
|
+
id text PRIMARY KEY,
|
|
42
|
+
provider text NOT NULL,
|
|
43
|
+
model text NOT NULL,
|
|
44
|
+
dimensions integer NOT NULL CHECK (dimensions = #{dimensions}),
|
|
45
|
+
embedding vector(#{dimensions}) NOT NULL,
|
|
46
|
+
content text,
|
|
47
|
+
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
48
|
+
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
49
|
+
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
50
|
+
)
|
|
51
|
+
SQL
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Create an HNSW index for the selected distance metric.
|
|
55
|
+
#
|
|
56
|
+
# @param metric [Symbol] `:cosine`, `:euclidean`, or `:inner_product`
|
|
57
|
+
# @return [void]
|
|
58
|
+
def create_index!(metric: :cosine)
|
|
59
|
+
metric_name = validate_metric(metric)
|
|
60
|
+
@connection.exec(<<~SQL)
|
|
61
|
+
CREATE INDEX IF NOT EXISTS #{table}_#{metric_name}_embedding_idx
|
|
62
|
+
ON #{table} USING hnsw (embedding #{metric_operator_class(metric_name)})
|
|
63
|
+
SQL
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Insert or replace an embedding record.
|
|
67
|
+
#
|
|
68
|
+
# @return [Hash] Stored record metadata
|
|
69
|
+
def upsert(id:, embedding:, provider:, model:, content: nil, metadata: {})
|
|
70
|
+
vector = serialize_vector(embedding)
|
|
71
|
+
parameters = [id.to_s, provider.to_s, model.to_s, dimensions, vector, content, JSON.generate(metadata)]
|
|
72
|
+
result = @connection.exec_params(<<~SQL, parameters)
|
|
73
|
+
INSERT INTO #{table} (id, provider, model, dimensions, embedding, content, metadata)
|
|
74
|
+
VALUES ($1, $2, $3, $4, $5::vector, $6, $7::jsonb)
|
|
75
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
76
|
+
provider = EXCLUDED.provider,
|
|
77
|
+
model = EXCLUDED.model,
|
|
78
|
+
dimensions = EXCLUDED.dimensions,
|
|
79
|
+
embedding = EXCLUDED.embedding,
|
|
80
|
+
content = EXCLUDED.content,
|
|
81
|
+
metadata = EXCLUDED.metadata,
|
|
82
|
+
updated_at = CURRENT_TIMESTAMP
|
|
83
|
+
RETURNING id, provider, model, dimensions, content, metadata
|
|
84
|
+
SQL
|
|
85
|
+
|
|
86
|
+
record_from(result.first)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Find the nearest stored embeddings.
|
|
90
|
+
#
|
|
91
|
+
# @param embedding [Array<Numeric>] Query vector
|
|
92
|
+
# @param limit [Integer] Maximum result count
|
|
93
|
+
# @param metric [Symbol] Distance metric
|
|
94
|
+
# @param provider [String, Symbol, nil] Optional provider filter
|
|
95
|
+
# @param model [String, nil] Optional model filter
|
|
96
|
+
# @return [Array<Hash>] Records ordered by ascending distance
|
|
97
|
+
def search(embedding:, limit: 10, metric: :cosine, provider: nil, model: nil)
|
|
98
|
+
vector = serialize_vector(embedding)
|
|
99
|
+
limit = validate_limit(limit)
|
|
100
|
+
metric = validate_metric(metric)
|
|
101
|
+
filters, parameters = search_filters(provider, model)
|
|
102
|
+
result = @connection.exec_params(search_query(metric, filters), [vector, limit, *parameters])
|
|
103
|
+
|
|
104
|
+
result.map { |row| record_from(row) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def validate_dimensions(value)
|
|
110
|
+
return value if value.is_a?(Integer) && value.positive?
|
|
111
|
+
|
|
112
|
+
raise ArgumentError, 'dimensions must be a positive integer'
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def validate_table(value)
|
|
116
|
+
table = value.to_s
|
|
117
|
+
return table if /\A[a-z_][a-z0-9_]*\z/.match?(table)
|
|
118
|
+
|
|
119
|
+
raise ArgumentError, 'table must be a lowercase PostgreSQL identifier'
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def serialize_vector(embedding)
|
|
123
|
+
unless embedding.is_a?(Array) && embedding.length == dimensions
|
|
124
|
+
raise Prescient::InvalidVectorError, "embedding must contain exactly #{dimensions} values"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
values = embedding.map { |value| Float(value) }
|
|
128
|
+
raise Prescient::InvalidVectorError, 'embedding values must be finite' unless values.all?(&:finite?)
|
|
129
|
+
|
|
130
|
+
"[#{values.join(',')}]"
|
|
131
|
+
rescue ArgumentError, TypeError
|
|
132
|
+
raise Prescient::InvalidVectorError, 'embedding values must be numeric'
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def validate_limit(value)
|
|
136
|
+
return value if value.is_a?(Integer) && value.positive?
|
|
137
|
+
|
|
138
|
+
raise ArgumentError, 'limit must be a positive integer'
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def validate_metric(value)
|
|
142
|
+
return value if METRICS.key?(value)
|
|
143
|
+
|
|
144
|
+
raise ArgumentError, "unsupported distance metric: #{value}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def metric_operator_class(metric)
|
|
148
|
+
{
|
|
149
|
+
cosine: 'vector_cosine_ops',
|
|
150
|
+
euclidean: 'vector_l2_ops',
|
|
151
|
+
inner_product: 'vector_ip_ops',
|
|
152
|
+
}.fetch(metric)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def search_filters(provider, model)
|
|
156
|
+
filters = []
|
|
157
|
+
parameters = []
|
|
158
|
+
if provider
|
|
159
|
+
filters << "provider = $#{parameters.length + 3}"
|
|
160
|
+
parameters << provider.to_s
|
|
161
|
+
end
|
|
162
|
+
if model
|
|
163
|
+
filters << "model = $#{parameters.length + 3}"
|
|
164
|
+
parameters << model
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
[filters, parameters]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def search_query(metric, filters)
|
|
171
|
+
where = filters.empty? ? '' : "WHERE #{filters.join(' AND ')}"
|
|
172
|
+
|
|
173
|
+
<<~SQL
|
|
174
|
+
SELECT id, provider, model, dimensions, content, metadata,
|
|
175
|
+
embedding #{METRICS.fetch(metric)} $1::vector AS distance
|
|
176
|
+
FROM #{table}
|
|
177
|
+
#{where}
|
|
178
|
+
ORDER BY embedding #{METRICS.fetch(metric)} $1::vector
|
|
179
|
+
LIMIT $2
|
|
180
|
+
SQL
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def record_from(row)
|
|
184
|
+
{
|
|
185
|
+
id: row.fetch('id'),
|
|
186
|
+
provider: row.fetch('provider'),
|
|
187
|
+
model: row.fetch('model'),
|
|
188
|
+
dimensions: Integer(row.fetch('dimensions')),
|
|
189
|
+
content: row.fetch('content'),
|
|
190
|
+
metadata: JSON.parse(row.fetch('metadata')),
|
|
191
|
+
distance: row['distance']&.to_f,
|
|
192
|
+
}
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'httparty'
|
|
4
4
|
|
|
5
|
+
# Anthropic Messages API provider adapter.
|
|
5
6
|
class Prescient::Provider::Anthropic < Prescient::Base
|
|
6
7
|
include HTTParty
|
|
7
8
|
|
|
@@ -12,12 +13,19 @@ class Prescient::Provider::Anthropic < Prescient::Base
|
|
|
12
13
|
self.class.default_timeout(@options[:timeout] || 60)
|
|
13
14
|
end
|
|
14
15
|
|
|
16
|
+
# Anthropic does not provide embeddings through this adapter.
|
|
17
|
+
#
|
|
18
|
+
# @raise [Prescient::Error] Always, because Anthropic embeddings are not supported
|
|
15
19
|
def generate_embedding(_text, **_options)
|
|
16
20
|
# Anthropic doesn't provide embedding API, raise error
|
|
17
21
|
raise Prescient::Error,
|
|
18
22
|
'Anthropic provider does not support embeddings. Use OpenAI or HuggingFace for embeddings.'
|
|
19
23
|
end
|
|
20
24
|
|
|
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
|
|
21
29
|
def generate_response(prompt, context_items = [], **options)
|
|
22
30
|
handle_errors do
|
|
23
31
|
formatted_prompt = build_prompt(prompt, context_items)
|
|
@@ -29,7 +37,7 @@ class Prescient::Provider::Anthropic < Prescient::Base
|
|
|
29
37
|
'anthropic-version' => '2023-06-01',
|
|
30
38
|
},
|
|
31
39
|
body: {
|
|
32
|
-
model: @options[:model],
|
|
40
|
+
model: options[:model] || @options[:model],
|
|
33
41
|
max_tokens: options[:max_tokens] || 2000,
|
|
34
42
|
temperature: options[:temperature] || 0.7,
|
|
35
43
|
messages: [
|
|
@@ -47,7 +55,7 @@ class Prescient::Provider::Anthropic < Prescient::Base
|
|
|
47
55
|
|
|
48
56
|
{
|
|
49
57
|
response: content.strip,
|
|
50
|
-
model: @options[:model],
|
|
58
|
+
model: options[:model] || @options[:model],
|
|
51
59
|
provider: 'anthropic',
|
|
52
60
|
processing_time: nil,
|
|
53
61
|
metadata: {
|
|
@@ -57,58 +65,63 @@ class Prescient::Provider::Anthropic < Prescient::Base
|
|
|
57
65
|
end
|
|
58
66
|
end
|
|
59
67
|
|
|
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
|
|
60
74
|
def health_check
|
|
61
75
|
handle_errors do
|
|
62
|
-
|
|
63
|
-
response = self.class.post('/v1/messages',
|
|
64
|
-
headers: {
|
|
65
|
-
'Content-Type' => 'application/json',
|
|
66
|
-
'x-api-key' => @options[:api_key],
|
|
67
|
-
'anthropic-version' => '2023-06-01',
|
|
68
|
-
},
|
|
69
|
-
body: {
|
|
70
|
-
model: @options[:model],
|
|
71
|
-
max_tokens: 10,
|
|
72
|
-
messages: [
|
|
73
|
-
{
|
|
74
|
-
role: 'user',
|
|
75
|
-
content: 'Test',
|
|
76
|
-
},
|
|
77
|
-
],
|
|
78
|
-
}.to_json)
|
|
76
|
+
response = self.class.get('/v1/models', headers: api_headers)
|
|
79
77
|
|
|
80
78
|
if response.success?
|
|
79
|
+
models = response.parsed_response['data'] || []
|
|
80
|
+
model_available = models.any? { |model| model['id'] == @options[:model] }
|
|
81
81
|
{
|
|
82
|
-
status:
|
|
83
|
-
provider:
|
|
84
|
-
|
|
85
|
-
|
|
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,
|
|
86
88
|
}
|
|
87
89
|
else
|
|
88
90
|
{
|
|
89
|
-
status:
|
|
90
|
-
|
|
91
|
-
error: "HTTP #{response.code}",
|
|
92
|
-
message: response.message,
|
|
91
|
+
status: 'unhealthy', provider: 'anthropic', reachable: true,
|
|
92
|
+
error: "HTTP #{response.code}", message: response.message, ready: false
|
|
93
93
|
}
|
|
94
94
|
end
|
|
95
95
|
end
|
|
96
|
-
rescue Prescient::
|
|
96
|
+
rescue Prescient::Error => e
|
|
97
97
|
{
|
|
98
|
-
status:
|
|
99
|
-
provider:
|
|
100
|
-
|
|
101
|
-
|
|
98
|
+
status: 'unavailable',
|
|
99
|
+
provider: 'anthropic',
|
|
100
|
+
reachable: false,
|
|
101
|
+
error: e.class.name,
|
|
102
|
+
message: e.message,
|
|
103
|
+
ready: false,
|
|
102
104
|
}
|
|
103
105
|
end
|
|
104
106
|
|
|
107
|
+
# Return models available to the configured Anthropic account.
|
|
108
|
+
# @return [Array<Hash>] Model descriptors
|
|
105
109
|
def list_models
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
123
|
+
end
|
|
124
|
+
end
|
|
112
125
|
end
|
|
113
126
|
|
|
114
127
|
protected
|
|
@@ -124,23 +137,11 @@ class Prescient::Provider::Anthropic < Prescient::Base
|
|
|
124
137
|
|
|
125
138
|
private
|
|
126
139
|
|
|
127
|
-
def
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
when 401
|
|
134
|
-
raise Prescient::AuthenticationError, "Authentication failed for #{operation}"
|
|
135
|
-
when 403
|
|
136
|
-
raise Prescient::AuthenticationError, "Forbidden access for #{operation}"
|
|
137
|
-
when 429
|
|
138
|
-
raise Prescient::RateLimitError, "Rate limit exceeded for #{operation}"
|
|
139
|
-
when 500..599
|
|
140
|
-
raise Prescient::Error, "Anthropic server error during #{operation}: #{response.body}"
|
|
141
|
-
else
|
|
142
|
-
raise Prescient::Error,
|
|
143
|
-
"Anthropic request failed for #{operation}: HTTP #{response.code} - #{response.message}"
|
|
144
|
-
end
|
|
140
|
+
def api_headers
|
|
141
|
+
{
|
|
142
|
+
'Content-Type' => 'application/json',
|
|
143
|
+
'x-api-key' => @options[:api_key],
|
|
144
|
+
'anthropic-version' => '2023-06-01',
|
|
145
|
+
}
|
|
145
146
|
end
|
|
146
147
|
end
|
|
@@ -2,11 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
require 'httparty'
|
|
4
4
|
|
|
5
|
+
# Hugging Face router-backed Inference Providers API adapter.
|
|
5
6
|
class Prescient::Provider::HuggingFace < Prescient::Base
|
|
6
7
|
include HTTParty
|
|
7
8
|
|
|
8
|
-
base_uri 'https://
|
|
9
|
+
base_uri 'https://router.huggingface.co'
|
|
9
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.
|
|
10
24
|
EMBEDDING_DIMENSIONS = {
|
|
11
25
|
'sentence-transformers/all-MiniLM-L6-v2' => 384,
|
|
12
26
|
'sentence-transformers/all-mpnet-base-v2' => 768,
|
|
@@ -18,21 +32,20 @@ class Prescient::Provider::HuggingFace < Prescient::Base
|
|
|
18
32
|
self.class.default_timeout(@options[:timeout] || 60)
|
|
19
33
|
end
|
|
20
34
|
|
|
21
|
-
|
|
35
|
+
# Generate an embedding through Hugging Face feature extraction.
|
|
36
|
+
# @param text [String] Text to embed
|
|
37
|
+
# @return [Array<Float>] Embedding vector
|
|
38
|
+
def generate_embedding(text, **options)
|
|
22
39
|
handle_errors do
|
|
23
40
|
clean_text_input = clean_text(text)
|
|
24
41
|
|
|
25
|
-
|
|
42
|
+
embedding_model = options[:model] || @options[:embedding_model]
|
|
43
|
+
response = self.class.post(FEATURE_EXTRACTION_PATH % { model: embedding_model },
|
|
26
44
|
headers: {
|
|
27
45
|
'Content-Type' => 'application/json',
|
|
28
46
|
'Authorization' => "Bearer #{@options[:api_key]}",
|
|
29
47
|
},
|
|
30
|
-
body: {
|
|
31
|
-
inputs: clean_text_input,
|
|
32
|
-
options: {
|
|
33
|
-
wait_for_model: true,
|
|
34
|
-
},
|
|
35
|
-
}.to_json)
|
|
48
|
+
body: { inputs: clean_text_input }.to_json)
|
|
36
49
|
|
|
37
50
|
validate_response!(response, 'embedding generation')
|
|
38
51
|
|
|
@@ -42,82 +55,79 @@ class Prescient::Provider::HuggingFace < Prescient::Base
|
|
|
42
55
|
|
|
43
56
|
raise Prescient::InvalidResponseError, 'No embedding returned' unless embedding_data.is_a?(Array)
|
|
44
57
|
|
|
45
|
-
expected_dimensions = EMBEDDING_DIMENSIONS[@options[:
|
|
46
|
-
|
|
58
|
+
expected_dimensions = EMBEDDING_DIMENSIONS[embedding_model] || @options[:embedding_dimensions]
|
|
59
|
+
unless expected_dimensions
|
|
60
|
+
raise Prescient::Error,
|
|
61
|
+
"Embedding dimensions are required for model #{embedding_model}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
validate_embedding_dimensions(embedding_data, expected_dimensions)
|
|
47
65
|
end
|
|
48
66
|
end
|
|
49
67
|
|
|
68
|
+
# Generate text through a Hugging Face text-generation model.
|
|
69
|
+
# @param prompt [String] Prompt to send
|
|
70
|
+
# @param context_items [Array<Hash, String>] Optional context items
|
|
71
|
+
# @return [Hash] Normalized response data
|
|
50
72
|
def generate_response(prompt, context_items = [], **options)
|
|
51
73
|
handle_errors do
|
|
52
74
|
formatted_prompt = build_prompt(prompt, context_items)
|
|
53
75
|
|
|
54
|
-
response = self.class.post(
|
|
76
|
+
response = self.class.post(CHAT_COMPLETIONS_PATH,
|
|
55
77
|
headers: {
|
|
56
78
|
'Content-Type' => 'application/json',
|
|
57
79
|
'Authorization' => "Bearer #{@options[:api_key]}",
|
|
58
80
|
},
|
|
59
81
|
body: {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return_full_text: false,
|
|
66
|
-
},
|
|
67
|
-
options: {
|
|
68
|
-
wait_for_model: true,
|
|
69
|
-
},
|
|
82
|
+
model: options[:model] || @options[:chat_model],
|
|
83
|
+
messages: [{ role: 'user', content: formatted_prompt }],
|
|
84
|
+
max_tokens: options[:max_tokens] || 2000,
|
|
85
|
+
temperature: options[:temperature] || 0.7,
|
|
86
|
+
top_p: options[:top_p] || 0.9,
|
|
70
87
|
}.to_json)
|
|
71
88
|
|
|
72
89
|
validate_response!(response, 'text generation')
|
|
73
90
|
|
|
74
|
-
# HuggingFace returns different formats depending on the model
|
|
75
|
-
generated_text = nil
|
|
76
91
|
parsed_response = response.parsed_response
|
|
77
|
-
|
|
78
|
-
if parsed_response.is_a?(Array) && parsed_response.first.is_a?(Hash)
|
|
79
|
-
generated_text = parsed_response.first['generated_text']
|
|
80
|
-
elsif parsed_response.is_a?(Hash)
|
|
81
|
-
generated_text = parsed_response['generated_text'] || parsed_response['text']
|
|
82
|
-
end
|
|
83
|
-
|
|
92
|
+
generated_text = parsed_response.dig('choices', 0, 'message', 'content') if parsed_response.is_a?(Hash)
|
|
84
93
|
raise Prescient::InvalidResponseError, 'No response generated' unless generated_text
|
|
85
94
|
|
|
86
95
|
{
|
|
87
96
|
response: generated_text.strip,
|
|
88
|
-
model: @options[:chat_model],
|
|
97
|
+
model: options[:model] || @options[:chat_model],
|
|
89
98
|
provider: 'huggingface',
|
|
90
99
|
processing_time: nil,
|
|
91
|
-
metadata: {
|
|
100
|
+
metadata: {
|
|
101
|
+
usage: parsed_response['usage'],
|
|
102
|
+
finish_reason: parsed_response.dig('choices', 0, 'finish_reason'),
|
|
103
|
+
},
|
|
92
104
|
}
|
|
93
105
|
end
|
|
94
106
|
end
|
|
95
107
|
|
|
108
|
+
# Check availability of the configured embedding and text models.
|
|
109
|
+
#
|
|
110
|
+
# The embedding model is checked against the model metadata API on
|
|
111
|
+
# `huggingface.co`, while the chat model is checked against the router's
|
|
112
|
+
# OpenAI-compatible `/v1/models` listing. `ready` requires both checks to
|
|
113
|
+
# succeed.
|
|
114
|
+
#
|
|
115
|
+
# @return [Hash] Provider health information
|
|
96
116
|
def health_check
|
|
97
117
|
handle_errors do
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
},
|
|
103
|
-
body: { inputs: 'test' }.to_json)
|
|
104
|
-
|
|
105
|
-
# Test chat model
|
|
106
|
-
chat_response = self.class.post("/models/#{@options[:chat_model]}",
|
|
107
|
-
headers: {
|
|
108
|
-
'Authorization' => "Bearer #{@options[:api_key]}",
|
|
109
|
-
},
|
|
110
|
-
body: {
|
|
111
|
-
inputs: 'test',
|
|
112
|
-
parameters: { max_new_tokens: 5 },
|
|
113
|
-
}.to_json)
|
|
118
|
+
embedding_response = self.class.get("https://huggingface.co/api/models/#{@options[:embedding_model]}",
|
|
119
|
+
headers: { 'Authorization' => "Bearer #{@options[:api_key]}" })
|
|
120
|
+
chat_response = self.class.get(MODEL_LIST_PATH,
|
|
121
|
+
headers: { 'Authorization' => "Bearer #{@options[:api_key]}" })
|
|
114
122
|
|
|
115
123
|
embedding_healthy = embedding_response.success?
|
|
116
|
-
|
|
124
|
+
chat_models = chat_response.parsed_response['data'] || []
|
|
125
|
+
chat_healthy = chat_response.success? && chat_models.any? { |model| model['id'] == @options[:chat_model] }
|
|
117
126
|
|
|
118
127
|
{
|
|
119
128
|
status: embedding_healthy && chat_healthy ? 'healthy' : 'partial',
|
|
120
129
|
provider: 'huggingface',
|
|
130
|
+
reachable: true,
|
|
121
131
|
embedding_model: {
|
|
122
132
|
name: @options[:embedding_model],
|
|
123
133
|
available: embedding_healthy,
|
|
@@ -129,15 +139,23 @@ class Prescient::Provider::HuggingFace < Prescient::Base
|
|
|
129
139
|
ready: embedding_healthy && chat_healthy,
|
|
130
140
|
}
|
|
131
141
|
end
|
|
132
|
-
rescue Prescient::
|
|
142
|
+
rescue Prescient::Error => e
|
|
133
143
|
{
|
|
134
|
-
status:
|
|
135
|
-
provider:
|
|
136
|
-
|
|
137
|
-
|
|
144
|
+
status: 'unavailable',
|
|
145
|
+
provider: 'huggingface',
|
|
146
|
+
reachable: false,
|
|
147
|
+
error: e.class.name,
|
|
148
|
+
message: e.message,
|
|
149
|
+
ready: false,
|
|
138
150
|
}
|
|
139
151
|
end
|
|
140
152
|
|
|
153
|
+
# Return the configured Hugging Face models.
|
|
154
|
+
#
|
|
155
|
+
# This method does not query the Hugging Face APIs. It reflects the current
|
|
156
|
+
# adapter configuration only.
|
|
157
|
+
#
|
|
158
|
+
# @return [Array<Hash>] Model descriptors
|
|
141
159
|
def list_models
|
|
142
160
|
# HuggingFace doesn't provide a simple API to list all models
|
|
143
161
|
# Return the configured models
|
|
@@ -165,36 +183,19 @@ class Prescient::Provider::HuggingFace < Prescient::Base
|
|
|
165
183
|
|
|
166
184
|
private
|
|
167
185
|
|
|
168
|
-
def
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
case response.code
|
|
172
|
-
when 400
|
|
173
|
-
raise Prescient::Error, "Bad request for #{operation}: #{response.body}"
|
|
174
|
-
when 401
|
|
175
|
-
raise Prescient::AuthenticationError, "Authentication failed for #{operation}"
|
|
176
|
-
when 403
|
|
177
|
-
raise Prescient::AuthenticationError, "Forbidden access for #{operation}"
|
|
178
|
-
when 429
|
|
179
|
-
raise Prescient::RateLimitError, "Rate limit exceeded for #{operation}"
|
|
180
|
-
when 503
|
|
186
|
+
def provider_error(message, response, operation:, provider: nil, error_class: Prescient::ProviderError)
|
|
187
|
+
if response.code == 503
|
|
181
188
|
# HuggingFace model loading
|
|
182
189
|
error_body = begin
|
|
183
190
|
response.parsed_response
|
|
184
191
|
rescue StandardError
|
|
185
|
-
|
|
192
|
+
nil
|
|
186
193
|
end
|
|
187
194
|
if error_body.is_a?(Hash) && error_body['error']&.include?('loading')
|
|
188
|
-
|
|
195
|
+
message = 'Model is loading, please try again later'
|
|
189
196
|
end
|
|
190
|
-
|
|
191
|
-
raise Prescient::Error, "HuggingFace service unavailable for #{operation}"
|
|
192
|
-
|
|
193
|
-
when 500..599
|
|
194
|
-
raise Prescient::Error, "HuggingFace server error during #{operation}: #{response.body}"
|
|
195
|
-
else
|
|
196
|
-
raise Prescient::Error,
|
|
197
|
-
"HuggingFace request failed for #{operation}: HTTP #{response.code} - #{response.message}"
|
|
198
197
|
end
|
|
198
|
+
|
|
199
|
+
super
|
|
199
200
|
end
|
|
200
201
|
end
|