prescient 0.4.0 ā 0.5.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/CHANGELOG.md +22 -0
- data/INTEGRATION_GUIDE.md +12 -1
- data/README.md +177 -12
- data/Rakefile +1 -1
- data/VECTOR_SEARCH_GUIDE.md +7 -3
- data/examples/README.md +2 -1
- data/examples/basic_usage.rb +1 -1
- data/examples/custom_contexts.rb +6 -21
- data/examples/vector_search.rb +69 -305
- data/lib/prescient/cli.rb +196 -4
- data/lib/prescient/configuration_loader.rb +437 -0
- data/lib/prescient/provider/deepseek.rb +139 -0
- data/lib/prescient/provider/gemini.rb +173 -0
- data/lib/prescient/provider/huggingface.rb +1 -0
- data/lib/prescient/provider/mistral.rb +171 -0
- data/lib/prescient/provider/openai.rb +3 -0
- data/lib/prescient/provider/xai.rb +139 -0
- data/lib/prescient/version.rb +1 -1
- data/lib/prescient.rb +116 -22
- data/schema/prescient.configuration.schema.json +153 -0
- data/sig/prescient.rbs +105 -0
- metadata +7 -1
data/examples/vector_search.rb
CHANGED
|
@@ -1,330 +1,94 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
# Example: Vector similarity search with Prescient
|
|
5
|
-
#
|
|
4
|
+
# Example: Vector similarity search with Prescient and PostgreSQL pgvector.
|
|
5
|
+
# The Store owns only its embedding table; applications own their documents.
|
|
6
6
|
|
|
7
7
|
require_relative '../lib/prescient'
|
|
8
8
|
require 'pg'
|
|
9
|
-
require 'json'
|
|
10
9
|
|
|
11
|
-
puts
|
|
12
|
-
puts "This example shows how to use Prescient with PostgreSQL pgvector for semantic search."
|
|
10
|
+
puts '=== Vector Similarity Search Example ==='
|
|
13
11
|
|
|
14
|
-
# Database connection configuration
|
|
15
12
|
DB_CONFIG = {
|
|
16
|
-
host:
|
|
17
|
-
port:
|
|
18
|
-
dbname:
|
|
19
|
-
user:
|
|
20
|
-
password: ENV.fetch('DB_PASSWORD', 'prescient_password')
|
|
13
|
+
host: ENV.fetch('DB_HOST', 'localhost'),
|
|
14
|
+
port: ENV.fetch('DB_PORT', '5432'),
|
|
15
|
+
dbname: ENV.fetch('DB_NAME', 'prescient_development'),
|
|
16
|
+
user: ENV.fetch('DB_USER', 'prescient'),
|
|
17
|
+
password: ENV.fetch('DB_PASSWORD', 'prescient_password'),
|
|
21
18
|
}.freeze
|
|
22
19
|
|
|
23
20
|
class VectorSearchExample
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
@client = Prescient.client(:ollama)
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def run_example
|
|
30
|
-
puts "\n--- Setting up vector search example ---"
|
|
31
|
-
|
|
32
|
-
# Check if services are available
|
|
33
|
-
unless check_services_available
|
|
34
|
-
puts "ā Required services not available. Please start with: docker compose up -d"
|
|
35
|
-
return
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# 1. Generate and store embeddings for existing documents
|
|
39
|
-
puts "\nš Generating embeddings for sample documents..."
|
|
40
|
-
generate_document_embeddings
|
|
41
|
-
|
|
42
|
-
# 2. Perform similarity search
|
|
43
|
-
puts "\nš Performing similarity searches..."
|
|
44
|
-
search_examples
|
|
45
|
-
|
|
46
|
-
# 3. Advanced search with filtering
|
|
47
|
-
puts "\nšÆ Advanced search with metadata filtering..."
|
|
48
|
-
advanced_search_examples
|
|
49
|
-
|
|
50
|
-
# 4. Demonstrate different distance functions
|
|
51
|
-
puts "\nš Comparing different distance functions..."
|
|
52
|
-
compare_distance_functions
|
|
53
|
-
|
|
54
|
-
puts "\nš Vector search example completed!"
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
private
|
|
21
|
+
EMBEDDING_DIMENSIONS = 768
|
|
22
|
+
EMBEDDING_MODEL = 'nomic-embed-text'
|
|
58
23
|
|
|
59
|
-
def
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return false
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# Check pgvector extension
|
|
70
|
-
begin
|
|
71
|
-
result = @db.exec("SELECT * FROM pg_extension WHERE extname = 'vector'")
|
|
72
|
-
if result.ntuples > 0
|
|
73
|
-
puts "ā
pgvector extension available"
|
|
74
|
-
else
|
|
75
|
-
puts "ā pgvector extension not found"
|
|
76
|
-
return false
|
|
77
|
-
end
|
|
78
|
-
rescue PG::Error => e
|
|
79
|
-
puts "ā pgvector check failed: #{e.message}"
|
|
80
|
-
return false
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
# Check Ollama connection
|
|
84
|
-
if @client.available?
|
|
85
|
-
puts "ā
Ollama connected"
|
|
86
|
-
else
|
|
87
|
-
puts "ā Ollama not available"
|
|
88
|
-
return false
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
true
|
|
24
|
+
def initialize
|
|
25
|
+
@connection = PG.connect(DB_CONFIG)
|
|
26
|
+
@client = Prescient.client(:ollama, enable_fallback: false)
|
|
27
|
+
@store = Prescient::Pgvector::Store.new(
|
|
28
|
+
connection: @connection,
|
|
29
|
+
dimensions: EMBEDDING_DIMENSIONS,
|
|
30
|
+
)
|
|
92
31
|
end
|
|
93
32
|
|
|
94
|
-
def
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if result.ntuples == 0
|
|
109
|
-
puts " All documents already have embeddings"
|
|
110
|
-
return
|
|
33
|
+
def run
|
|
34
|
+
@store.install!
|
|
35
|
+
%i[cosine euclidean inner_product].each { |metric| @store.create_index!(metric:) }
|
|
36
|
+
|
|
37
|
+
documents.each do |document|
|
|
38
|
+
embedding = @client.generate_embedding(document[:content], model: EMBEDDING_MODEL)
|
|
39
|
+
@store.upsert(
|
|
40
|
+
id: document[:id],
|
|
41
|
+
embedding:,
|
|
42
|
+
provider: 'ollama',
|
|
43
|
+
model: EMBEDDING_MODEL,
|
|
44
|
+
content: document[:content],
|
|
45
|
+
metadata: { title: document[:title] },
|
|
46
|
+
)
|
|
111
47
|
end
|
|
112
48
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
rescue Prescient::Error => e
|
|
130
|
-
puts " ā Failed to generate embedding: #{e.message}"
|
|
131
|
-
end
|
|
49
|
+
query = 'How do I improve database performance?'
|
|
50
|
+
embedding = @client.generate_embedding(query, model: EMBEDDING_MODEL)
|
|
51
|
+
results = @store.search(
|
|
52
|
+
embedding:,
|
|
53
|
+
limit: 3,
|
|
54
|
+
metric: :cosine,
|
|
55
|
+
provider: 'ollama',
|
|
56
|
+
model: EMBEDDING_MODEL,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
puts "\nQuery: #{query}"
|
|
60
|
+
results.each_with_index do |result, index|
|
|
61
|
+
title = result[:metadata].fetch('title')
|
|
62
|
+
puts "#{index + 1}. #{title} (distance: #{result[:distance].round(4)})"
|
|
63
|
+
puts " #{result[:content]}"
|
|
132
64
|
end
|
|
65
|
+
rescue Prescient::Error, PG::Error => e
|
|
66
|
+
warn "Vector search failed: #{e.message}"
|
|
67
|
+
ensure
|
|
68
|
+
@connection&.close
|
|
133
69
|
end
|
|
134
70
|
|
|
135
|
-
|
|
136
|
-
# Convert Ruby array to PostgreSQL vector format
|
|
137
|
-
vector_str = "[#{embedding.join(',')}]"
|
|
138
|
-
|
|
139
|
-
query = <<~SQL
|
|
140
|
-
INSERT INTO document_embeddings
|
|
141
|
-
(document_id, embedding_provider, embedding_model, embedding_dimensions, embedding, embedding_text)
|
|
142
|
-
VALUES ($1, $2, $3, $4, $5, $6)
|
|
143
|
-
SQL
|
|
144
|
-
|
|
145
|
-
@db.exec_params(query, [document_id, provider, model, dimensions, vector_str, text])
|
|
146
|
-
end
|
|
71
|
+
private
|
|
147
72
|
|
|
148
|
-
def
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
73
|
+
def documents
|
|
74
|
+
[
|
|
75
|
+
{
|
|
76
|
+
id: 'postgres-indexes',
|
|
77
|
+
title: 'PostgreSQL indexes',
|
|
78
|
+
content: 'Indexes can reduce query latency when their columns match common filters and ordering.',
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: 'ruby-performance',
|
|
82
|
+
title: 'Ruby performance',
|
|
83
|
+
content: 'Measure allocations and repeated work before optimizing a Ruby application.',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: 'api-security',
|
|
87
|
+
title: 'API security',
|
|
88
|
+
content: 'Protect API credentials, validate inputs, and avoid exposing provider response bodies.',
|
|
89
|
+
},
|
|
154
90
|
]
|
|
155
|
-
|
|
156
|
-
search_queries.each do |query_text|
|
|
157
|
-
puts "\nš Searching for: '#{query_text}'"
|
|
158
|
-
perform_similarity_search(query_text, limit: 3)
|
|
159
|
-
end
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
def perform_similarity_search(query_text, limit: 5, distance_function: 'cosine')
|
|
163
|
-
begin
|
|
164
|
-
# Generate embedding for query
|
|
165
|
-
query_embedding = @client.generate_embedding(query_text)
|
|
166
|
-
query_vector = "[#{query_embedding.join(',')}]"
|
|
167
|
-
|
|
168
|
-
# Choose distance operator based on function
|
|
169
|
-
distance_op = case distance_function
|
|
170
|
-
when 'cosine' then '<=>'
|
|
171
|
-
when 'l2' then '<->'
|
|
172
|
-
when 'inner_product' then '<#>'
|
|
173
|
-
else '<=>'
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
# Perform similarity search
|
|
177
|
-
search_query = <<~SQL
|
|
178
|
-
SELECT
|
|
179
|
-
d.title,
|
|
180
|
-
d.content,
|
|
181
|
-
d.metadata,
|
|
182
|
-
de.embedding #{distance_op} $1::vector AS distance,
|
|
183
|
-
1 - (de.embedding <=> $1::vector) AS cosine_similarity
|
|
184
|
-
FROM documents d
|
|
185
|
-
JOIN document_embeddings de ON d.id = de.document_id
|
|
186
|
-
WHERE de.embedding_provider = 'ollama'
|
|
187
|
-
AND de.embedding_model = 'nomic-embed-text'
|
|
188
|
-
ORDER BY de.embedding #{distance_op} $1::vector
|
|
189
|
-
LIMIT $2
|
|
190
|
-
SQL
|
|
191
|
-
|
|
192
|
-
result = @db.exec_params(search_query, [query_vector, limit])
|
|
193
|
-
|
|
194
|
-
if result.ntuples == 0
|
|
195
|
-
puts " No results found"
|
|
196
|
-
return
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
result.each_with_index do |row, index|
|
|
200
|
-
similarity = (row['cosine_similarity'].to_f * 100).round(1)
|
|
201
|
-
puts " #{index + 1}. #{row['title']} (#{similarity}% similar)"
|
|
202
|
-
puts " #{row['content'][0..100]}..."
|
|
203
|
-
|
|
204
|
-
# Show metadata if available
|
|
205
|
-
if row['metadata'] && !row['metadata'].empty?
|
|
206
|
-
metadata = JSON.parse(row['metadata'])
|
|
207
|
-
tags = metadata['tags']&.join(', ')
|
|
208
|
-
puts " Tags: #{tags}" if tags
|
|
209
|
-
end
|
|
210
|
-
puts
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
rescue Prescient::Error => e
|
|
214
|
-
puts " ā Search failed: #{e.message}"
|
|
215
|
-
rescue PG::Error => e
|
|
216
|
-
puts " ā Database error: #{e.message}"
|
|
217
|
-
end
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
def advanced_search_examples
|
|
221
|
-
# Search with metadata filtering
|
|
222
|
-
puts "\nšÆ Search for programming content with beginner difficulty:"
|
|
223
|
-
advanced_search("programming basics", tags: ["programming"], difficulty: "beginner")
|
|
224
|
-
|
|
225
|
-
puts "\nšÆ Search for AI/ML content:"
|
|
226
|
-
advanced_search("artificial intelligence", tags: ["ai", "machine-learning"])
|
|
227
91
|
end
|
|
228
|
-
|
|
229
|
-
def advanced_search(query_text, filters = {})
|
|
230
|
-
begin
|
|
231
|
-
query_embedding = @client.generate_embedding(query_text)
|
|
232
|
-
query_vector = "[#{query_embedding.join(',')}]"
|
|
233
|
-
|
|
234
|
-
# Build WHERE clause for metadata filtering
|
|
235
|
-
where_conditions = ["de.embedding_provider = 'ollama'", "de.embedding_model = 'nomic-embed-text'"]
|
|
236
|
-
params = [query_vector]
|
|
237
|
-
param_index = 2
|
|
238
|
-
|
|
239
|
-
filters.each do |key, value|
|
|
240
|
-
case key
|
|
241
|
-
when :tags
|
|
242
|
-
# Filter by tags array overlap
|
|
243
|
-
where_conditions << "d.metadata->'tags' ?| $#{param_index}::text[]"
|
|
244
|
-
params << value
|
|
245
|
-
param_index += 1
|
|
246
|
-
when :difficulty
|
|
247
|
-
# Filter by exact difficulty match
|
|
248
|
-
where_conditions << "d.metadata->>'difficulty' = $#{param_index}"
|
|
249
|
-
params << value
|
|
250
|
-
param_index += 1
|
|
251
|
-
when :source_type
|
|
252
|
-
# Filter by source type
|
|
253
|
-
where_conditions << "d.source_type = $#{param_index}"
|
|
254
|
-
params << value
|
|
255
|
-
param_index += 1
|
|
256
|
-
end
|
|
257
|
-
end
|
|
258
|
-
|
|
259
|
-
search_query = <<~SQL
|
|
260
|
-
SELECT
|
|
261
|
-
d.title,
|
|
262
|
-
d.content,
|
|
263
|
-
d.metadata,
|
|
264
|
-
de.embedding <=> $1::vector AS cosine_distance,
|
|
265
|
-
1 - (de.embedding <=> $1::vector) AS cosine_similarity
|
|
266
|
-
FROM documents d
|
|
267
|
-
JOIN document_embeddings de ON d.id = de.document_id
|
|
268
|
-
WHERE #{where_conditions.join(' AND ')}
|
|
269
|
-
ORDER BY de.embedding <=> $1::vector
|
|
270
|
-
LIMIT 3
|
|
271
|
-
SQL
|
|
272
|
-
|
|
273
|
-
result = @db.exec_params(search_query, params)
|
|
274
|
-
|
|
275
|
-
if result.ntuples == 0
|
|
276
|
-
puts " No results found with the specified filters"
|
|
277
|
-
return
|
|
278
|
-
end
|
|
279
|
-
|
|
280
|
-
result.each_with_index do |row, index|
|
|
281
|
-
similarity = (row['cosine_similarity'].to_f * 100).round(1)
|
|
282
|
-
puts " #{index + 1}. #{row['title']} (#{similarity}% similar)"
|
|
283
|
-
|
|
284
|
-
metadata = JSON.parse(row['metadata'])
|
|
285
|
-
puts " Difficulty: #{metadata['difficulty']}"
|
|
286
|
-
puts " Tags: #{metadata['tags']&.join(', ')}"
|
|
287
|
-
puts " #{row['content'][0..80]}..."
|
|
288
|
-
puts
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
rescue Prescient::Error => e
|
|
292
|
-
puts " ā Search failed: #{e.message}"
|
|
293
|
-
rescue PG::Error => e
|
|
294
|
-
puts " ā Database error: #{e.message}"
|
|
295
|
-
end
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
def compare_distance_functions
|
|
299
|
-
query_text = "programming languages and development"
|
|
300
|
-
|
|
301
|
-
puts "\nš Comparing distance functions for: '#{query_text}'"
|
|
302
|
-
|
|
303
|
-
%w[cosine l2 inner_product].each do |distance_func|
|
|
304
|
-
puts "\n #{distance_func.upcase} Distance:"
|
|
305
|
-
perform_similarity_search(query_text, limit: 2, distance_function: distance_func)
|
|
306
|
-
end
|
|
307
|
-
end
|
|
308
|
-
|
|
309
|
-
def cleanup
|
|
310
|
-
@db.close if @db
|
|
311
|
-
end
|
|
312
|
-
end
|
|
313
|
-
|
|
314
|
-
# Run the example
|
|
315
|
-
begin
|
|
316
|
-
example = VectorSearchExample.new
|
|
317
|
-
example.run_example
|
|
318
|
-
rescue StandardError => e
|
|
319
|
-
puts "ā Example failed: #{e.message}"
|
|
320
|
-
puts e.backtrace.first(5).join("\n")
|
|
321
|
-
ensure
|
|
322
|
-
example&.cleanup
|
|
323
92
|
end
|
|
324
93
|
|
|
325
|
-
|
|
326
|
-
puts " - Try different embedding models (OpenAI, HuggingFace)"
|
|
327
|
-
puts " - Implement hybrid search (vector + keyword)"
|
|
328
|
-
puts " - Add document chunking for large texts"
|
|
329
|
-
puts " - Experiment with different similarity thresholds"
|
|
330
|
-
puts " - Add result re-ranking and filtering"
|
|
94
|
+
VectorSearchExample.new.run
|
data/lib/prescient/cli.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'optparse'
|
|
5
|
+
require 'yaml'
|
|
5
6
|
|
|
6
7
|
# Command-line interface for common Prescient operations.
|
|
7
8
|
class Prescient::CLI
|
|
@@ -9,6 +10,90 @@ class Prescient::CLI
|
|
|
9
10
|
# @return [Array<String>] Output format names
|
|
10
11
|
FORMATS = ['text', 'json'].freeze
|
|
11
12
|
|
|
13
|
+
# Schema URL and annotated starter configuration for `config example`.
|
|
14
|
+
CONFIGURATION_EXAMPLE = <<~YAML
|
|
15
|
+
# yaml-language-server: $schema=https://raw.githubusercontent.com/kanutocd/prescient/refs/heads/main/schema/prescient.configuration.schema.json
|
|
16
|
+
#
|
|
17
|
+
# Prescient configuration example.
|
|
18
|
+
#
|
|
19
|
+
# Precedence, from lowest to highest:
|
|
20
|
+
# 1. Built-in defaults and provider environment variables.
|
|
21
|
+
# 2. Values in this YAML file.
|
|
22
|
+
# 3. Per-operation CLI overrides such as --provider and --chat-model.
|
|
23
|
+
#
|
|
24
|
+
# Use `prescient config validate` after editing this file.
|
|
25
|
+
# Keep credentials out of source control; use *_env references instead.
|
|
26
|
+
version: 1
|
|
27
|
+
|
|
28
|
+
# Global behavior.
|
|
29
|
+
default_provider: ollama
|
|
30
|
+
timeout: 30
|
|
31
|
+
retry_attempts: 3
|
|
32
|
+
retry_delay: 1.0
|
|
33
|
+
fallback_providers: []
|
|
34
|
+
sensitive_keys:
|
|
35
|
+
- api_key
|
|
36
|
+
- password
|
|
37
|
+
- token
|
|
38
|
+
- secret
|
|
39
|
+
|
|
40
|
+
providers:
|
|
41
|
+
# Local Ollama requires no API key.
|
|
42
|
+
ollama:
|
|
43
|
+
type: ollama
|
|
44
|
+
url: http://localhost:11434
|
|
45
|
+
embedding_model: nomic-embed-text
|
|
46
|
+
chat_model: llama3.2:3b
|
|
47
|
+
# prompt_templates:
|
|
48
|
+
# system_prompt: You are a concise assistant.
|
|
49
|
+
# no_context_template: "%<system_prompt>s\\n\\nUser: %<query>s"
|
|
50
|
+
# with_context_template: "%<system_prompt>s\\n\\nContext:\\n%<context>s\\n\\nUser: %<query>s"
|
|
51
|
+
|
|
52
|
+
# Uncomment a cloud provider and set its credential in the environment.
|
|
53
|
+
# openai:
|
|
54
|
+
# type: openai
|
|
55
|
+
# api_key_env: OPENAI_API_KEY
|
|
56
|
+
# embedding_model: text-embedding-3-small
|
|
57
|
+
# chat_model: gpt-4.1-mini
|
|
58
|
+
# prompt_templates:
|
|
59
|
+
# system_prompt: You are a concise assistant.
|
|
60
|
+
# no_context_template: "%<system_prompt>s\n\nUser: %<query>s"
|
|
61
|
+
|
|
62
|
+
# anthropic:
|
|
63
|
+
# type: anthropic
|
|
64
|
+
# api_key_env: ANTHROPIC_API_KEY
|
|
65
|
+
# model: claude-sonnet-4-20250514
|
|
66
|
+
|
|
67
|
+
# gemini:
|
|
68
|
+
# type: gemini
|
|
69
|
+
# api_key_env: GEMINI_API_KEY
|
|
70
|
+
# embedding_model: gemini-embedding-001
|
|
71
|
+
# chat_model: gemini-2.5-flash
|
|
72
|
+
|
|
73
|
+
# mistral:
|
|
74
|
+
# type: mistral
|
|
75
|
+
# api_key_env: MISTRAL_API_KEY
|
|
76
|
+
# embedding_model: mistral-embed
|
|
77
|
+
# chat_model: mistral-large-latest
|
|
78
|
+
|
|
79
|
+
# DeepSeek supports text generation, but not embeddings.
|
|
80
|
+
# deepseek:
|
|
81
|
+
# type: deepseek
|
|
82
|
+
# api_key_env: DEEPSEEK_API_KEY
|
|
83
|
+
# chat_model: deepseek-v4-flash
|
|
84
|
+
|
|
85
|
+
# xai:
|
|
86
|
+
# type: xai
|
|
87
|
+
# api_key_env: XAI_API_KEY
|
|
88
|
+
# chat_model: grok-4.5
|
|
89
|
+
|
|
90
|
+
# huggingface:
|
|
91
|
+
# type: huggingface
|
|
92
|
+
# api_key_env: HUGGINGFACE_API_KEY
|
|
93
|
+
# embedding_model: sentence-transformers/all-MiniLM-L6-v2
|
|
94
|
+
# chat_model: google/gemma-2-2b-it
|
|
95
|
+
YAML
|
|
96
|
+
|
|
12
97
|
# Raised when command-line arguments are invalid or incomplete.
|
|
13
98
|
class UsageError < StandardError; end
|
|
14
99
|
|
|
@@ -42,10 +127,22 @@ class Prescient::CLI
|
|
|
42
127
|
@errors = errors
|
|
43
128
|
end
|
|
44
129
|
|
|
130
|
+
# Execute the CLI command and return its process status.
|
|
131
|
+
# @return [Integer] Process exit status
|
|
45
132
|
def run
|
|
133
|
+
config_path = extract_global_config_path
|
|
134
|
+
Prescient.load_configuration(config_path) if config_path || ENV['PRESCIENT_CONFIG']
|
|
135
|
+
|
|
46
136
|
command = @arguments.shift
|
|
47
137
|
return print_help(2) unless command
|
|
48
138
|
|
|
139
|
+
run_command(command)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Dispatch a parsed command to its handler.
|
|
143
|
+
# @param command [String] Command name
|
|
144
|
+
# @return [Integer] Process exit status
|
|
145
|
+
def run_command(command)
|
|
49
146
|
case command
|
|
50
147
|
when 'providers' then providers
|
|
51
148
|
when 'health' then health
|
|
@@ -118,8 +215,15 @@ class Prescient::CLI
|
|
|
118
215
|
|
|
119
216
|
def config
|
|
120
217
|
subcommand = @arguments.shift
|
|
121
|
-
|
|
218
|
+
case subcommand
|
|
219
|
+
when 'validate' then validate_config_command
|
|
220
|
+
when 'example' then configuration_example_command
|
|
221
|
+
else
|
|
222
|
+
raise UsageError, "unknown config command #{subcommand.inspect}"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
122
225
|
|
|
226
|
+
def validate_config_command
|
|
123
227
|
options = parse_options('Validate the current configuration')
|
|
124
228
|
return options if options.is_a?(Integer)
|
|
125
229
|
|
|
@@ -132,6 +236,14 @@ class Prescient::CLI
|
|
|
132
236
|
0
|
|
133
237
|
end
|
|
134
238
|
|
|
239
|
+
def configuration_example_command
|
|
240
|
+
options = parse_options('Generate an annotated YAML configuration example')
|
|
241
|
+
return options if options.is_a?(Integer)
|
|
242
|
+
|
|
243
|
+
@output.write(CONFIGURATION_EXAMPLE)
|
|
244
|
+
0
|
|
245
|
+
end
|
|
246
|
+
|
|
135
247
|
def validate_configuration
|
|
136
248
|
configuration = Prescient.configuration
|
|
137
249
|
unless configuration.provider(configuration.default_provider)
|
|
@@ -162,6 +274,9 @@ class Prescient::CLI
|
|
|
162
274
|
end
|
|
163
275
|
|
|
164
276
|
def add_common_options(parser, options)
|
|
277
|
+
parser.on('--config PATH', 'Load configuration from a YAML file') do |value|
|
|
278
|
+
options[:config] = value
|
|
279
|
+
end
|
|
165
280
|
parser.on('--format FORMAT', FORMATS, "Output format (#{FORMATS.join(', ')})") do |value|
|
|
166
281
|
options[:format] = value
|
|
167
282
|
end
|
|
@@ -206,6 +321,18 @@ class Prescient::CLI
|
|
|
206
321
|
parser.on('--chat-model NAME', 'Override the chat model') do |value|
|
|
207
322
|
options[:chat_model] = value
|
|
208
323
|
end
|
|
324
|
+
parser.on('--system-prompt TEXT', 'Override the system prompt') do |value|
|
|
325
|
+
options[:system_prompt] = value
|
|
326
|
+
end
|
|
327
|
+
parser.on('--no-context-template TEXT', 'Override the no-context prompt template') do |value|
|
|
328
|
+
options[:no_context_template] = value
|
|
329
|
+
end
|
|
330
|
+
parser.on('--with-context-template TEXT', 'Override the with-context prompt template') do |value|
|
|
331
|
+
options[:with_context_template] = value
|
|
332
|
+
end
|
|
333
|
+
parser.on('--prompt-templates-file PATH', 'Load prompt templates from a YAML file') do |value|
|
|
334
|
+
options[:prompt_templates_file] = value
|
|
335
|
+
end
|
|
209
336
|
end
|
|
210
337
|
|
|
211
338
|
def add_credential_options(parser, options)
|
|
@@ -228,12 +355,38 @@ class Prescient::CLI
|
|
|
228
355
|
|
|
229
356
|
def provider_options(options)
|
|
230
357
|
{
|
|
231
|
-
api_key:
|
|
232
|
-
embedding_model:
|
|
233
|
-
chat_model:
|
|
358
|
+
api_key: api_key_override(options),
|
|
359
|
+
embedding_model: options[:embedding_model],
|
|
360
|
+
chat_model: options[:chat_model],
|
|
361
|
+
prompt_templates: prompt_templates(options),
|
|
234
362
|
}.compact
|
|
235
363
|
end
|
|
236
364
|
|
|
365
|
+
def prompt_templates(options)
|
|
366
|
+
templates = if options[:prompt_templates_file]
|
|
367
|
+
data = YAML.safe_load_file(
|
|
368
|
+
options[:prompt_templates_file],
|
|
369
|
+
permitted_classes: [],
|
|
370
|
+
permitted_symbols: [],
|
|
371
|
+
aliases: true,
|
|
372
|
+
)
|
|
373
|
+
raise UsageError, 'prompt templates file must contain a mapping' unless data.is_a?(Hash)
|
|
374
|
+
|
|
375
|
+
data.transform_keys(&:to_sym)
|
|
376
|
+
else
|
|
377
|
+
{}
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
[:system_prompt, :no_context_template, :with_context_template].each do |key|
|
|
381
|
+
templates[key] = options[key] if options[key]
|
|
382
|
+
end
|
|
383
|
+
templates.empty? ? nil : templates
|
|
384
|
+
rescue Errno::ENOENT
|
|
385
|
+
raise UsageError, "prompt templates file not found: #{options[:prompt_templates_file]}"
|
|
386
|
+
rescue Psych::SyntaxError => e
|
|
387
|
+
raise UsageError, "invalid prompt templates YAML: #{e.message}"
|
|
388
|
+
end
|
|
389
|
+
|
|
237
390
|
def api_key_override(options)
|
|
238
391
|
return options[:api_key] if options[:api_key]
|
|
239
392
|
return ENV.fetch(options[:api_key_env]) if options[:api_key_env]
|
|
@@ -272,16 +425,55 @@ class Prescient::CLI
|
|
|
272
425
|
generate TEXT Generate a text response
|
|
273
426
|
embed TEXT Generate an embedding
|
|
274
427
|
config validate Validate the current configuration
|
|
428
|
+
config example Generate an annotated YAML configuration example
|
|
275
429
|
|
|
276
430
|
Options:
|
|
431
|
+
--config PATH Load configuration from a YAML file
|
|
277
432
|
--provider NAME Select a provider
|
|
278
433
|
--model NAME Override the selected operation's model
|
|
279
434
|
--chat-model NAME Override the chat model
|
|
280
435
|
--embedding-model NAME Override the embedding model
|
|
436
|
+
--system-prompt TEXT Override the system prompt
|
|
437
|
+
--no-context-template TEXT
|
|
438
|
+
Override the no-context prompt template
|
|
439
|
+
--with-context-template TEXT
|
|
440
|
+
Override the with-context prompt template
|
|
441
|
+
--prompt-templates-file PATH
|
|
442
|
+
Load prompt templates from a YAML file
|
|
281
443
|
--api-key KEY Use an API key for the operation
|
|
282
444
|
--api-key-env NAME Read the API key from an environment variable
|
|
283
445
|
--format FORMAT Use text or json output
|
|
284
446
|
HELP
|
|
285
447
|
status
|
|
286
448
|
end
|
|
449
|
+
|
|
450
|
+
def extract_global_config_path
|
|
451
|
+
config_path = nil
|
|
452
|
+
filtered_arguments = []
|
|
453
|
+
index = 0
|
|
454
|
+
|
|
455
|
+
while index < @arguments.length
|
|
456
|
+
argument = @arguments[index]
|
|
457
|
+
if argument == '--config'
|
|
458
|
+
value = @arguments[index + 1]
|
|
459
|
+
raise UsageError, '--config requires a path' unless value
|
|
460
|
+
|
|
461
|
+
config_path = value
|
|
462
|
+
index += 2
|
|
463
|
+
next
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
if argument.start_with?('--config=')
|
|
467
|
+
config_path = argument.split('=', 2).last
|
|
468
|
+
index += 1
|
|
469
|
+
next
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
filtered_arguments << argument
|
|
473
|
+
index += 1
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
@arguments = filtered_arguments
|
|
477
|
+
config_path
|
|
478
|
+
end
|
|
287
479
|
end
|