prescient 0.3.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.
@@ -0,0 +1,479 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'optparse'
5
+ require 'yaml'
6
+
7
+ # Command-line interface for common Prescient operations.
8
+ class Prescient::CLI
9
+ # Supported output formats.
10
+ # @return [Array<String>] Output format names
11
+ FORMATS = ['text', 'json'].freeze
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
+
97
+ # Raised when command-line arguments are invalid or incomplete.
98
+ class UsageError < StandardError; end
99
+
100
+ # Run the CLI and return a process exit status.
101
+ #
102
+ # @param arguments [Array<String>] Command-line arguments
103
+ # @param input [IO] Input stream used for stdin prompts
104
+ # @param output [IO] Output stream for command results
105
+ # @param errors [IO] Output stream for diagnostics
106
+ # @return [Integer] Process exit status
107
+ def self.run(arguments, input: $stdin, output: $stdout, errors: $stderr)
108
+ new(arguments, input:, output:, errors:).run
109
+ rescue UsageError, OptionParser::ParseError => e
110
+ errors.puts "prescient: #{e.message}"
111
+ 2
112
+ rescue Prescient::Error => e
113
+ errors.puts "prescient: #{e.message}"
114
+ 1
115
+ end
116
+
117
+ # Initialize a CLI runner with injectable streams.
118
+ #
119
+ # @param arguments [Array<String>] Command-line arguments
120
+ # @param input [IO] Input stream used for stdin prompts
121
+ # @param output [IO] Output stream for command results
122
+ # @param errors [IO] Output stream for diagnostics
123
+ def initialize(arguments, input:, output:, errors:)
124
+ @arguments = arguments.dup
125
+ @input = input
126
+ @output = output
127
+ @errors = errors
128
+ end
129
+
130
+ # Execute the CLI command and return its process status.
131
+ # @return [Integer] Process exit status
132
+ def run
133
+ config_path = extract_global_config_path
134
+ Prescient.load_configuration(config_path) if config_path || ENV['PRESCIENT_CONFIG']
135
+
136
+ command = @arguments.shift
137
+ return print_help(2) unless command
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)
146
+ case command
147
+ when 'providers' then providers
148
+ when 'health' then health
149
+ when 'generate' then generate
150
+ when 'embed' then embed
151
+ when 'config' then config
152
+ when 'help', '--help', '-h' then print_help(0)
153
+ else
154
+ raise UsageError, "unknown command #{command.inspect}; run 'prescient help'"
155
+ end
156
+ end
157
+
158
+ private
159
+
160
+ def providers
161
+ options = parse_options('List configured providers')
162
+ return options if options.is_a?(Integer)
163
+
164
+ provider_list = Prescient.configuration.providers.map { |name, registration|
165
+ { name: name.to_s, class: registration[:class].name }
166
+ }
167
+
168
+ if options[:format] == 'json'
169
+ print_json(providers: provider_list)
170
+ else
171
+ provider_list.each { |provider| @output.puts "#{provider[:name]}\t#{provider[:class]}" }
172
+ end
173
+ 0
174
+ end
175
+
176
+ def health
177
+ options = parse_options('Check provider health')
178
+ return options if options.is_a?(Integer)
179
+
180
+ names = options[:provider] ? [options[:provider].to_sym] : Prescient.configuration.providers.keys
181
+ raise UsageError, 'no providers are configured' if names.empty?
182
+
183
+ results = names.to_h { |name| [name.to_s, Prescient.health_check(provider: name)] }
184
+ output_health(results, options[:format])
185
+ results.values.all? { |result| result[:reachable] != false } ? 0 : 1
186
+ end
187
+
188
+ def generate
189
+ options = parse_options('Generate a text response', fallback: true)
190
+ return options if options.is_a?(Integer)
191
+
192
+ prompt = read_text(options[:arguments], 'prompt')
193
+ client = client_for(options)
194
+ response = client.generate_response(prompt, **model_options(options))
195
+
196
+ options[:format] == 'json' ? print_json(response) : @output.puts(response[:response])
197
+ 0
198
+ end
199
+
200
+ def embed
201
+ options = parse_options('Generate an embedding', fallback: true)
202
+ return options if options.is_a?(Integer)
203
+
204
+ text = read_text(options[:arguments], 'text')
205
+ client = client_for(options)
206
+ embedding = client.generate_embedding(text, **model_options(options))
207
+
208
+ if options[:format] == 'json'
209
+ print_json(embedding: embedding, dimensions: embedding.length, provider: client.provider_name.to_s)
210
+ else
211
+ @output.puts JSON.generate(embedding)
212
+ end
213
+ 0
214
+ end
215
+
216
+ def config
217
+ subcommand = @arguments.shift
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
225
+
226
+ def validate_config_command
227
+ options = parse_options('Validate the current configuration')
228
+ return options if options.is_a?(Integer)
229
+
230
+ validate_configuration
231
+ if options[:format] == 'json'
232
+ print_json(valid: true, providers: Prescient.configuration.providers.keys.map(&:to_s))
233
+ else
234
+ @output.puts 'configuration valid'
235
+ end
236
+ 0
237
+ end
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
+
247
+ def validate_configuration
248
+ configuration = Prescient.configuration
249
+ unless configuration.provider(configuration.default_provider)
250
+ raise Prescient::Error, 'default provider is not configured'
251
+ end
252
+
253
+ configuration.providers.each_key { |name| configuration.provider(name) }
254
+ end
255
+
256
+ def parse_options(description, fallback: false)
257
+ options = { format: 'text', fallback: fallback }
258
+ parser = OptionParser.new do |parser|
259
+ parser.banner = "Usage: prescient #{@arguments.first || 'command'} [options]"
260
+ parser.separator description
261
+ add_common_options(parser, options)
262
+ parser.on('--no-fallback', 'Disable provider fallback') { options[:fallback] = false } if fallback
263
+ parser.on('-h', '--help', 'Show command help') do
264
+ @output.puts parser
265
+ throw :help_shown, 0
266
+ end
267
+ end
268
+
269
+ result = catch(:help_shown) { parse_arguments(parser) }
270
+ return result unless result.nil?
271
+
272
+ options[:arguments] = @arguments
273
+ options
274
+ end
275
+
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
280
+ parser.on('--format FORMAT', FORMATS, "Output format (#{FORMATS.join(', ')})") do |value|
281
+ options[:format] = value
282
+ end
283
+ parser.on('--provider NAME', 'Use a specific provider') do |value|
284
+ options[:provider] = value
285
+ end
286
+ add_model_options(parser, options)
287
+ add_credential_options(parser, options)
288
+ end
289
+
290
+ # Parse command arguments and return nil when parsing completes.
291
+ #
292
+ # @param parser [OptionParser] Configured command option parser
293
+ # @return [nil]
294
+ def parse_arguments(parser)
295
+ parser.parse!(@arguments)
296
+ nil
297
+ end
298
+
299
+ def model_options(options)
300
+ options[:model] ? { model: options[:model] } : {}
301
+ end
302
+
303
+ def client_for(options)
304
+ validate_override_options(options)
305
+ Prescient.client(
306
+ options[:provider]&.to_sym,
307
+ enable_fallback: options[:fallback],
308
+ provider_options: provider_options(options),
309
+ )
310
+ rescue KeyError => e
311
+ raise UsageError, "environment variable not set: #{e.key}"
312
+ end
313
+
314
+ def add_model_options(parser, options)
315
+ parser.on('--model NAME', 'Override the configured model') do |value|
316
+ options[:model] = value
317
+ end
318
+ parser.on('--embedding-model NAME', 'Override the embedding model') do |value|
319
+ options[:embedding_model] = value
320
+ end
321
+ parser.on('--chat-model NAME', 'Override the chat model') do |value|
322
+ options[:chat_model] = value
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
336
+ end
337
+
338
+ def add_credential_options(parser, options)
339
+ parser.on('--api-key KEY', 'Use an API key for this operation') do |value|
340
+ options[:api_key] = value
341
+ end
342
+ parser.on('--api-key-env NAME', 'Read the API key from this environment variable') do |value|
343
+ options[:api_key_env] = value
344
+ end
345
+ end
346
+
347
+ def validate_override_options(options)
348
+ if options[:model] && (options[:embedding_model] || options[:chat_model])
349
+ raise UsageError, '--model cannot be combined with --embedding-model or --chat-model'
350
+ end
351
+ return unless options[:api_key] && options[:api_key_env]
352
+
353
+ raise UsageError, '--api-key cannot be combined with --api-key-env'
354
+ end
355
+
356
+ def provider_options(options)
357
+ {
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),
362
+ }.compact
363
+ end
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
+
390
+ def api_key_override(options)
391
+ return options[:api_key] if options[:api_key]
392
+ return ENV.fetch(options[:api_key_env]) if options[:api_key_env]
393
+
394
+ nil
395
+ end
396
+
397
+ def output_health(results, format)
398
+ if format == 'json'
399
+ print_json(results)
400
+ else
401
+ results.each do |name, result|
402
+ @output.puts '%<name>-12s %<status>s' % { name: name, status: result[:status] || 'unknown' }
403
+ end
404
+ end
405
+ end
406
+
407
+ def read_text(arguments, label)
408
+ return arguments.join(' ') unless arguments.empty?
409
+ return @input.read unless @input.tty?
410
+
411
+ raise UsageError, "missing #{label}; provide it as an argument or through stdin"
412
+ end
413
+
414
+ def print_json(value)
415
+ @output.puts JSON.generate(value)
416
+ end
417
+
418
+ def print_help(status)
419
+ @output.puts <<~HELP
420
+ Usage: prescient COMMAND [options]
421
+
422
+ Commands:
423
+ providers List configured providers
424
+ health Check provider health
425
+ generate TEXT Generate a text response
426
+ embed TEXT Generate an embedding
427
+ config validate Validate the current configuration
428
+ config example Generate an annotated YAML configuration example
429
+
430
+ Options:
431
+ --config PATH Load configuration from a YAML file
432
+ --provider NAME Select a provider
433
+ --model NAME Override the selected operation's model
434
+ --chat-model NAME Override the chat model
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
443
+ --api-key KEY Use an API key for the operation
444
+ --api-key-env NAME Read the API key from an environment variable
445
+ --format FORMAT Use text or json output
446
+ HELP
447
+ status
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
479
+ end
@@ -27,10 +27,11 @@ module Prescient
27
27
  #
28
28
  # @param provider_name [Symbol, nil] Name of provider to use, or nil for default
29
29
  # @param enable_fallback [Boolean] Whether to enable automatic fallback to other providers
30
+ # @param provider_options [Hash] Temporary options for the selected provider
30
31
  # @raise [Prescient::Error] If the specified provider is not configured
31
- def initialize(provider_name = nil, enable_fallback: true)
32
+ def initialize(provider_name = nil, enable_fallback: true, provider_options: {})
32
33
  @provider_name = provider_name || Prescient.configuration.default_provider
33
- @provider = Prescient.configuration.provider(@provider_name)
34
+ @provider = provider_with_options(@provider_name, provider_options)
34
35
  @enable_fallback = enable_fallback
35
36
 
36
37
  raise Prescient::Error, "Provider not configured: #{@provider_name}" unless @provider
@@ -131,6 +132,16 @@ module Prescient
131
132
  end
132
133
  end
133
134
 
135
+ def provider_with_options(provider_name, provider_options)
136
+ return Prescient.configuration.provider(provider_name) if provider_options.empty?
137
+
138
+ registration = Prescient.configuration.providers[provider_name.to_sym]
139
+ return unless registration
140
+
141
+ registration_options = registration[:options] # : Hash[Symbol, untyped]
142
+ registration[:class].new(**registration_options, **provider_options)
143
+ end
144
+
134
145
  def with_error_handling
135
146
  retries = 0
136
147
  begin
@@ -208,9 +219,10 @@ module Prescient
208
219
  #
209
220
  # @param provider_name [Symbol, nil] Provider to use, or the configured default
210
221
  # @param enable_fallback [Boolean] Whether provider fallback is enabled
222
+ # @param provider_options [Hash] Temporary options for the selected provider
211
223
  # @return [Client] A configured client instance
212
- def self.client(provider_name = nil, enable_fallback: true)
213
- Client.new(provider_name, enable_fallback: enable_fallback)
224
+ def self.client(provider_name = nil, enable_fallback: true, provider_options: {})
225
+ Client.new(provider_name, enable_fallback: enable_fallback, provider_options: provider_options)
214
226
  end
215
227
 
216
228
  # Generate an embedding through a configured provider.