prescient 0.4.0 → 0.6.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/.dockerignore +18 -0
- data/CHANGELOG.md +42 -0
- data/Dockerfile +45 -0
- data/INTEGRATION_GUIDE.md +121 -11
- data/README.md +261 -16
- data/Rakefile +1 -1
- data/VECTOR_SEARCH_GUIDE.md +7 -3
- data/docker-compose.api.yml +21 -0
- data/examples/README.md +19 -1
- data/examples/basic_usage.rb +1 -1
- data/examples/custom_contexts.rb +6 -21
- data/examples/rest_api.ru +30 -0
- data/examples/vector_search.rb +69 -305
- data/lib/prescient/api.rb +285 -0
- data/lib/prescient/cli.rb +197 -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 +119 -23
- data/schema/prescient.configuration.schema.json +153 -0
- data/sig/prescient.rbs +115 -0
- metadata +12 -1
data/lib/prescient/cli.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'optparse'
|
|
5
|
+
require 'yaml'
|
|
6
|
+
require_relative '../prescient'
|
|
5
7
|
|
|
6
8
|
# Command-line interface for common Prescient operations.
|
|
7
9
|
class Prescient::CLI
|
|
@@ -9,6 +11,90 @@ class Prescient::CLI
|
|
|
9
11
|
# @return [Array<String>] Output format names
|
|
10
12
|
FORMATS = ['text', 'json'].freeze
|
|
11
13
|
|
|
14
|
+
# Schema URL and annotated starter configuration for `config example`.
|
|
15
|
+
CONFIGURATION_EXAMPLE = <<~YAML
|
|
16
|
+
# yaml-language-server: $schema=https://raw.githubusercontent.com/kanutocd/prescient/refs/heads/main/schema/prescient.configuration.schema.json
|
|
17
|
+
#
|
|
18
|
+
# Prescient configuration example.
|
|
19
|
+
#
|
|
20
|
+
# Precedence, from lowest to highest:
|
|
21
|
+
# 1. Built-in defaults and provider environment variables.
|
|
22
|
+
# 2. Values in this YAML file.
|
|
23
|
+
# 3. Per-operation CLI overrides such as --provider and --chat-model.
|
|
24
|
+
#
|
|
25
|
+
# Use `prescient config validate` after editing this file.
|
|
26
|
+
# Keep credentials out of source control; use *_env references instead.
|
|
27
|
+
version: 1
|
|
28
|
+
|
|
29
|
+
# Global behavior.
|
|
30
|
+
default_provider: ollama
|
|
31
|
+
timeout: 30
|
|
32
|
+
retry_attempts: 3
|
|
33
|
+
retry_delay: 1.0
|
|
34
|
+
fallback_providers: []
|
|
35
|
+
sensitive_keys:
|
|
36
|
+
- api_key
|
|
37
|
+
- password
|
|
38
|
+
- token
|
|
39
|
+
- secret
|
|
40
|
+
|
|
41
|
+
providers:
|
|
42
|
+
# Local Ollama requires no API key.
|
|
43
|
+
ollama:
|
|
44
|
+
type: ollama
|
|
45
|
+
url: http://localhost:11434
|
|
46
|
+
embedding_model: nomic-embed-text
|
|
47
|
+
chat_model: llama3.2:3b
|
|
48
|
+
# prompt_templates:
|
|
49
|
+
# system_prompt: You are a concise assistant.
|
|
50
|
+
# no_context_template: "%<system_prompt>s\\n\\nUser: %<query>s"
|
|
51
|
+
# with_context_template: "%<system_prompt>s\\n\\nContext:\\n%<context>s\\n\\nUser: %<query>s"
|
|
52
|
+
|
|
53
|
+
# Uncomment a cloud provider and set its credential in the environment.
|
|
54
|
+
# openai:
|
|
55
|
+
# type: openai
|
|
56
|
+
# api_key_env: OPENAI_API_KEY
|
|
57
|
+
# embedding_model: text-embedding-3-small
|
|
58
|
+
# chat_model: gpt-4.1-mini
|
|
59
|
+
# prompt_templates:
|
|
60
|
+
# system_prompt: You are a concise assistant.
|
|
61
|
+
# no_context_template: "%<system_prompt>s\n\nUser: %<query>s"
|
|
62
|
+
|
|
63
|
+
# anthropic:
|
|
64
|
+
# type: anthropic
|
|
65
|
+
# api_key_env: ANTHROPIC_API_KEY
|
|
66
|
+
# model: claude-sonnet-4-20250514
|
|
67
|
+
|
|
68
|
+
# gemini:
|
|
69
|
+
# type: gemini
|
|
70
|
+
# api_key_env: GEMINI_API_KEY
|
|
71
|
+
# embedding_model: gemini-embedding-001
|
|
72
|
+
# chat_model: gemini-2.5-flash
|
|
73
|
+
|
|
74
|
+
# mistral:
|
|
75
|
+
# type: mistral
|
|
76
|
+
# api_key_env: MISTRAL_API_KEY
|
|
77
|
+
# embedding_model: mistral-embed
|
|
78
|
+
# chat_model: mistral-large-latest
|
|
79
|
+
|
|
80
|
+
# DeepSeek supports text generation, but not embeddings.
|
|
81
|
+
# deepseek:
|
|
82
|
+
# type: deepseek
|
|
83
|
+
# api_key_env: DEEPSEEK_API_KEY
|
|
84
|
+
# chat_model: deepseek-v4-flash
|
|
85
|
+
|
|
86
|
+
# xai:
|
|
87
|
+
# type: xai
|
|
88
|
+
# api_key_env: XAI_API_KEY
|
|
89
|
+
# chat_model: grok-4.5
|
|
90
|
+
|
|
91
|
+
# huggingface:
|
|
92
|
+
# type: huggingface
|
|
93
|
+
# api_key_env: HUGGINGFACE_API_KEY
|
|
94
|
+
# embedding_model: sentence-transformers/all-MiniLM-L6-v2
|
|
95
|
+
# chat_model: google/gemma-2-2b-it
|
|
96
|
+
YAML
|
|
97
|
+
|
|
12
98
|
# Raised when command-line arguments are invalid or incomplete.
|
|
13
99
|
class UsageError < StandardError; end
|
|
14
100
|
|
|
@@ -42,10 +128,22 @@ class Prescient::CLI
|
|
|
42
128
|
@errors = errors
|
|
43
129
|
end
|
|
44
130
|
|
|
131
|
+
# Execute the CLI command and return its process status.
|
|
132
|
+
# @return [Integer] Process exit status
|
|
45
133
|
def run
|
|
134
|
+
config_path = extract_global_config_path
|
|
135
|
+
Prescient.load_configuration(config_path) if config_path || ENV['PRESCIENT_CONFIG']
|
|
136
|
+
|
|
46
137
|
command = @arguments.shift
|
|
47
138
|
return print_help(2) unless command
|
|
48
139
|
|
|
140
|
+
run_command(command)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Dispatch a parsed command to its handler.
|
|
144
|
+
# @param command [String] Command name
|
|
145
|
+
# @return [Integer] Process exit status
|
|
146
|
+
def run_command(command)
|
|
49
147
|
case command
|
|
50
148
|
when 'providers' then providers
|
|
51
149
|
when 'health' then health
|
|
@@ -118,8 +216,15 @@ class Prescient::CLI
|
|
|
118
216
|
|
|
119
217
|
def config
|
|
120
218
|
subcommand = @arguments.shift
|
|
121
|
-
|
|
219
|
+
case subcommand
|
|
220
|
+
when 'validate' then validate_config_command
|
|
221
|
+
when 'example' then configuration_example_command
|
|
222
|
+
else
|
|
223
|
+
raise UsageError, "unknown config command #{subcommand.inspect}"
|
|
224
|
+
end
|
|
225
|
+
end
|
|
122
226
|
|
|
227
|
+
def validate_config_command
|
|
123
228
|
options = parse_options('Validate the current configuration')
|
|
124
229
|
return options if options.is_a?(Integer)
|
|
125
230
|
|
|
@@ -132,6 +237,14 @@ class Prescient::CLI
|
|
|
132
237
|
0
|
|
133
238
|
end
|
|
134
239
|
|
|
240
|
+
def configuration_example_command
|
|
241
|
+
options = parse_options('Generate an annotated YAML configuration example')
|
|
242
|
+
return options if options.is_a?(Integer)
|
|
243
|
+
|
|
244
|
+
@output.write(CONFIGURATION_EXAMPLE)
|
|
245
|
+
0
|
|
246
|
+
end
|
|
247
|
+
|
|
135
248
|
def validate_configuration
|
|
136
249
|
configuration = Prescient.configuration
|
|
137
250
|
unless configuration.provider(configuration.default_provider)
|
|
@@ -162,6 +275,9 @@ class Prescient::CLI
|
|
|
162
275
|
end
|
|
163
276
|
|
|
164
277
|
def add_common_options(parser, options)
|
|
278
|
+
parser.on('--config PATH', 'Load configuration from a YAML file') do |value|
|
|
279
|
+
options[:config] = value
|
|
280
|
+
end
|
|
165
281
|
parser.on('--format FORMAT', FORMATS, "Output format (#{FORMATS.join(', ')})") do |value|
|
|
166
282
|
options[:format] = value
|
|
167
283
|
end
|
|
@@ -206,6 +322,18 @@ class Prescient::CLI
|
|
|
206
322
|
parser.on('--chat-model NAME', 'Override the chat model') do |value|
|
|
207
323
|
options[:chat_model] = value
|
|
208
324
|
end
|
|
325
|
+
parser.on('--system-prompt TEXT', 'Override the system prompt') do |value|
|
|
326
|
+
options[:system_prompt] = value
|
|
327
|
+
end
|
|
328
|
+
parser.on('--no-context-template TEXT', 'Override the no-context prompt template') do |value|
|
|
329
|
+
options[:no_context_template] = value
|
|
330
|
+
end
|
|
331
|
+
parser.on('--with-context-template TEXT', 'Override the with-context prompt template') do |value|
|
|
332
|
+
options[:with_context_template] = value
|
|
333
|
+
end
|
|
334
|
+
parser.on('--prompt-templates-file PATH', 'Load prompt templates from a YAML file') do |value|
|
|
335
|
+
options[:prompt_templates_file] = value
|
|
336
|
+
end
|
|
209
337
|
end
|
|
210
338
|
|
|
211
339
|
def add_credential_options(parser, options)
|
|
@@ -228,12 +356,38 @@ class Prescient::CLI
|
|
|
228
356
|
|
|
229
357
|
def provider_options(options)
|
|
230
358
|
{
|
|
231
|
-
api_key:
|
|
232
|
-
embedding_model:
|
|
233
|
-
chat_model:
|
|
359
|
+
api_key: api_key_override(options),
|
|
360
|
+
embedding_model: options[:embedding_model],
|
|
361
|
+
chat_model: options[:chat_model],
|
|
362
|
+
prompt_templates: prompt_templates(options),
|
|
234
363
|
}.compact
|
|
235
364
|
end
|
|
236
365
|
|
|
366
|
+
def prompt_templates(options)
|
|
367
|
+
templates = if options[:prompt_templates_file]
|
|
368
|
+
data = YAML.safe_load_file(
|
|
369
|
+
options[:prompt_templates_file],
|
|
370
|
+
permitted_classes: [],
|
|
371
|
+
permitted_symbols: [],
|
|
372
|
+
aliases: true,
|
|
373
|
+
)
|
|
374
|
+
raise UsageError, 'prompt templates file must contain a mapping' unless data.is_a?(Hash)
|
|
375
|
+
|
|
376
|
+
data.transform_keys(&:to_sym)
|
|
377
|
+
else
|
|
378
|
+
{}
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
[:system_prompt, :no_context_template, :with_context_template].each do |key|
|
|
382
|
+
templates[key] = options[key] if options[key]
|
|
383
|
+
end
|
|
384
|
+
templates.empty? ? nil : templates
|
|
385
|
+
rescue Errno::ENOENT
|
|
386
|
+
raise UsageError, "prompt templates file not found: #{options[:prompt_templates_file]}"
|
|
387
|
+
rescue Psych::SyntaxError => e
|
|
388
|
+
raise UsageError, "invalid prompt templates YAML: #{e.message}"
|
|
389
|
+
end
|
|
390
|
+
|
|
237
391
|
def api_key_override(options)
|
|
238
392
|
return options[:api_key] if options[:api_key]
|
|
239
393
|
return ENV.fetch(options[:api_key_env]) if options[:api_key_env]
|
|
@@ -272,16 +426,55 @@ class Prescient::CLI
|
|
|
272
426
|
generate TEXT Generate a text response
|
|
273
427
|
embed TEXT Generate an embedding
|
|
274
428
|
config validate Validate the current configuration
|
|
429
|
+
config example Generate an annotated YAML configuration example
|
|
275
430
|
|
|
276
431
|
Options:
|
|
432
|
+
--config PATH Load configuration from a YAML file
|
|
277
433
|
--provider NAME Select a provider
|
|
278
434
|
--model NAME Override the selected operation's model
|
|
279
435
|
--chat-model NAME Override the chat model
|
|
280
436
|
--embedding-model NAME Override the embedding model
|
|
437
|
+
--system-prompt TEXT Override the system prompt
|
|
438
|
+
--no-context-template TEXT
|
|
439
|
+
Override the no-context prompt template
|
|
440
|
+
--with-context-template TEXT
|
|
441
|
+
Override the with-context prompt template
|
|
442
|
+
--prompt-templates-file PATH
|
|
443
|
+
Load prompt templates from a YAML file
|
|
281
444
|
--api-key KEY Use an API key for the operation
|
|
282
445
|
--api-key-env NAME Read the API key from an environment variable
|
|
283
446
|
--format FORMAT Use text or json output
|
|
284
447
|
HELP
|
|
285
448
|
status
|
|
286
449
|
end
|
|
450
|
+
|
|
451
|
+
def extract_global_config_path
|
|
452
|
+
config_path = nil
|
|
453
|
+
filtered_arguments = []
|
|
454
|
+
index = 0
|
|
455
|
+
|
|
456
|
+
while index < @arguments.length
|
|
457
|
+
argument = @arguments[index]
|
|
458
|
+
if argument == '--config'
|
|
459
|
+
value = @arguments[index + 1]
|
|
460
|
+
raise UsageError, '--config requires a path' unless value
|
|
461
|
+
|
|
462
|
+
config_path = value
|
|
463
|
+
index += 2
|
|
464
|
+
next
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
if argument.start_with?('--config=')
|
|
468
|
+
config_path = argument.split('=', 2).last
|
|
469
|
+
index += 1
|
|
470
|
+
next
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
filtered_arguments << argument
|
|
474
|
+
index += 1
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
@arguments = filtered_arguments
|
|
478
|
+
config_path
|
|
479
|
+
end
|
|
287
480
|
end
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
6
|
+
# Load and validate Prescient configuration data from YAML.
|
|
7
|
+
class Prescient::ConfigurationLoader
|
|
8
|
+
# Supported YAML configuration schema version.
|
|
9
|
+
CONFIGURATION_VERSION = 1
|
|
10
|
+
|
|
11
|
+
# Allowed top-level configuration keys.
|
|
12
|
+
TOP_LEVEL_KEYS = [
|
|
13
|
+
'$schema',
|
|
14
|
+
'default_provider',
|
|
15
|
+
'default_provider_env',
|
|
16
|
+
'fallback_providers',
|
|
17
|
+
'fallback_providers_env',
|
|
18
|
+
'providers',
|
|
19
|
+
'retry_attempts',
|
|
20
|
+
'retry_attempts_env',
|
|
21
|
+
'retry_delay',
|
|
22
|
+
'retry_delay_env',
|
|
23
|
+
'sensitive_keys',
|
|
24
|
+
'sensitive_keys_env',
|
|
25
|
+
'timeout',
|
|
26
|
+
'timeout_env',
|
|
27
|
+
'version',
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
# Provider names mapped to their adapter classes.
|
|
31
|
+
PROVIDER_TYPES = {
|
|
32
|
+
'ollama' => Prescient::Provider::Ollama,
|
|
33
|
+
'anthropic' => Prescient::Provider::Anthropic,
|
|
34
|
+
'openai' => Prescient::Provider::OpenAI,
|
|
35
|
+
'huggingface' => Prescient::Provider::HuggingFace,
|
|
36
|
+
'gemini' => Prescient::Provider::Gemini,
|
|
37
|
+
'mistral' => Prescient::Provider::Mistral,
|
|
38
|
+
'deepseek' => Prescient::Provider::DeepSeek,
|
|
39
|
+
'xai' => Prescient::Provider::XAI,
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# Provider-specific keys shared by all supported adapters.
|
|
43
|
+
COMMON_PROVIDER_KEYS = [
|
|
44
|
+
'api_key',
|
|
45
|
+
'api_key_env',
|
|
46
|
+
'chat_model',
|
|
47
|
+
'chat_model_env',
|
|
48
|
+
'context_configs',
|
|
49
|
+
'context_configs_env',
|
|
50
|
+
'embedding_dimensions',
|
|
51
|
+
'embedding_dimensions_env',
|
|
52
|
+
'embedding_model',
|
|
53
|
+
'embedding_model_env',
|
|
54
|
+
'model',
|
|
55
|
+
'model_env',
|
|
56
|
+
'prompt_templates',
|
|
57
|
+
'prompt_templates_env',
|
|
58
|
+
'timeout',
|
|
59
|
+
'timeout_env',
|
|
60
|
+
'url',
|
|
61
|
+
'url_env',
|
|
62
|
+
].freeze
|
|
63
|
+
|
|
64
|
+
# Prompt template keys supported by provider configuration.
|
|
65
|
+
PROMPT_TEMPLATE_KEYS = ['system_prompt', 'no_context_template', 'with_context_template'].freeze
|
|
66
|
+
|
|
67
|
+
# Configuration attributes supported by the loader.
|
|
68
|
+
ATTR_KEYS = [
|
|
69
|
+
'default_provider',
|
|
70
|
+
'fallback_providers',
|
|
71
|
+
'retry_attempts',
|
|
72
|
+
'retry_delay',
|
|
73
|
+
'sensitive_keys',
|
|
74
|
+
'timeout',
|
|
75
|
+
].freeze
|
|
76
|
+
|
|
77
|
+
class << self
|
|
78
|
+
# Load and validate configuration from a YAML file.
|
|
79
|
+
# @param path [String] Configuration file path
|
|
80
|
+
# @param env [Hash] Environment variables used during expansion
|
|
81
|
+
# @return [Prescient::Configuration] Loaded configuration
|
|
82
|
+
def load_file(path, env: ENV)
|
|
83
|
+
new(env).load_file(path)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Load and validate configuration from YAML content.
|
|
87
|
+
# @param content [String] YAML configuration content
|
|
88
|
+
# @param env [Hash] Environment variables used during expansion
|
|
89
|
+
# @return [Prescient::Configuration] Loaded configuration
|
|
90
|
+
def load_yaml(content, env: ENV)
|
|
91
|
+
new(env).load_yaml(content)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Load and validate configuration from a Ruby hash.
|
|
95
|
+
# @param data [Hash] Configuration data
|
|
96
|
+
# @param env [Hash] Environment variables used during expansion
|
|
97
|
+
# @return [Prescient::Configuration] Loaded configuration
|
|
98
|
+
def load_hash(data, env: ENV)
|
|
99
|
+
new(env).load_hash(data)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def initialize(env = ENV)
|
|
104
|
+
@env = env
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Load configuration from a YAML file.
|
|
108
|
+
# @param path [String] Configuration file path
|
|
109
|
+
# @return [Prescient::Configuration] Loaded configuration
|
|
110
|
+
def load_file(path)
|
|
111
|
+
load_yaml(File.read(path), source: path)
|
|
112
|
+
rescue Errno::ENOENT
|
|
113
|
+
raise Prescient::Error, "Configuration file not found: #{path}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Load configuration from YAML content.
|
|
117
|
+
# @param content [String] YAML configuration content
|
|
118
|
+
# @param source [String, nil] Source label used in validation errors
|
|
119
|
+
# @return [Prescient::Configuration] Loaded configuration
|
|
120
|
+
def load_yaml(content, source: nil)
|
|
121
|
+
data = YAML.safe_load(content, permitted_classes: [], permitted_symbols: [], aliases: true)
|
|
122
|
+
load_hash(data || {}, source:)
|
|
123
|
+
rescue Psych::SyntaxError => e
|
|
124
|
+
raise Prescient::Error, "Invalid YAML configuration#{" in #{source}" if source}: #{e.message}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Load configuration from a Ruby hash.
|
|
128
|
+
# @param data [Hash] Configuration data
|
|
129
|
+
# @param source [String, nil] Source label used in validation errors
|
|
130
|
+
# @return [Prescient::Configuration] Loaded configuration
|
|
131
|
+
def load_hash(data, source: nil)
|
|
132
|
+
configuration = Prescient::Configuration.new
|
|
133
|
+
Prescient.send(:configure_default_providers, configuration, @env)
|
|
134
|
+
apply!(configuration, data, source:)
|
|
135
|
+
configuration
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Apply validated configuration data to an existing configuration object.
|
|
139
|
+
# @param configuration [Prescient::Configuration] Target configuration
|
|
140
|
+
# @param data [Hash] Configuration data
|
|
141
|
+
# @param source [String, nil] Source label used in validation errors
|
|
142
|
+
# @return [Prescient::Configuration] Updated configuration
|
|
143
|
+
def apply!(configuration, data, source: nil)
|
|
144
|
+
normalized = normalize_keys(data)
|
|
145
|
+
validate_root!(normalized, source:)
|
|
146
|
+
validate_version!(normalized, source:)
|
|
147
|
+
|
|
148
|
+
apply_scalar_settings(configuration, normalized, source:)
|
|
149
|
+
apply_provider_settings(configuration, normalized, source:)
|
|
150
|
+
configuration
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Return the packaged JSON Schema path.
|
|
154
|
+
# @return [String] Absolute path to the configuration schema
|
|
155
|
+
def self.schema_path
|
|
156
|
+
File.expand_path('../../schema/prescient.configuration.schema.json', __dir__)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def validate_root!(data, source:)
|
|
162
|
+
unless data.is_a?(Hash)
|
|
163
|
+
raise Prescient::Error, "Configuration#{" in #{source}" if source} must be a mapping"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
unknown_keys = data.keys.map(&:to_s) - TOP_LEVEL_KEYS
|
|
167
|
+
return if unknown_keys.empty?
|
|
168
|
+
|
|
169
|
+
raise Prescient::Error,
|
|
170
|
+
"Unknown configuration key#{'s' if unknown_keys.length > 1}: #{unknown_keys.join(', ')}" \
|
|
171
|
+
"#{" in #{source}" if source}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def apply_scalar_settings(configuration, data, source:)
|
|
175
|
+
apply_default_provider(configuration, data, source:)
|
|
176
|
+
apply_numeric_settings(configuration, data, source:)
|
|
177
|
+
apply_collection_settings(configuration, data, source:)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def apply_default_provider(configuration, data, source:)
|
|
181
|
+
return unless scalar_present?(data, :default_provider)
|
|
182
|
+
|
|
183
|
+
default_provider = resolve_scalar(data, :default_provider, source:)
|
|
184
|
+
if default_provider.nil? || default_provider.to_s.empty?
|
|
185
|
+
raise Prescient::Error, 'default_provider must be a non-empty string'
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
configuration.default_provider = default_provider.to_sym
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def apply_numeric_settings(configuration, data, source:)
|
|
192
|
+
numeric_settings = {
|
|
193
|
+
timeout: [:coerce_integer, 'timeout'],
|
|
194
|
+
retry_attempts: [:coerce_integer, 'retry_attempts'],
|
|
195
|
+
retry_delay: [:coerce_float, 'retry_delay'],
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
numeric_settings.each do |key, (coercer, name)|
|
|
199
|
+
next unless scalar_present?(data, key)
|
|
200
|
+
|
|
201
|
+
value = resolve_scalar(data, key, source:)
|
|
202
|
+
setter = "#{key}="
|
|
203
|
+
configuration.public_send(setter, send(coercer, value, name))
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def apply_collection_settings(configuration, data, source:)
|
|
208
|
+
if scalar_present?(data, :fallback_providers)
|
|
209
|
+
value = resolve_scalar(data, :fallback_providers, source:)
|
|
210
|
+
configuration.fallback_providers = Array(value).map(&:to_sym)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
return unless scalar_present?(data, :sensitive_keys)
|
|
214
|
+
|
|
215
|
+
configuration.sensitive_keys = Array(resolve_scalar(data, :sensitive_keys, source:))
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def apply_provider_settings(configuration, data, source:)
|
|
219
|
+
return unless key_present?(data, :providers)
|
|
220
|
+
|
|
221
|
+
providers = data[:providers]
|
|
222
|
+
unless providers.is_a?(Hash)
|
|
223
|
+
raise Prescient::Error, "Configuration#{" in #{source}" if source} providers must be a mapping"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
providers.each do |name, provider_data|
|
|
227
|
+
provider_name = name.to_sym
|
|
228
|
+
provider_settings = normalize_keys(provider_data)
|
|
229
|
+
validate_provider!(provider_name, provider_settings, source:)
|
|
230
|
+
|
|
231
|
+
provider_options = resolve_provider_options(provider_settings, source:)
|
|
232
|
+
configuration.add_provider(provider_name, PROVIDER_TYPES[provider_settings.fetch(:type).to_s],
|
|
233
|
+
**provider_options)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def validate_provider!(name, provider_data, source:)
|
|
238
|
+
validate_provider_shape!(name, provider_data, source:)
|
|
239
|
+
validate_provider_type!(name, provider_data, source:)
|
|
240
|
+
validate_provider_keys!(name, provider_data, source:)
|
|
241
|
+
validate_prompt_templates!(name, provider_data, source:)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def validate_provider_shape!(name, provider_data, source:)
|
|
245
|
+
return if provider_data.is_a?(Hash)
|
|
246
|
+
|
|
247
|
+
raise Prescient::Error, "Provider #{name.inspect}#{" in #{source}" if source} must be a mapping"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def validate_provider_type!(name, provider_data, source:)
|
|
251
|
+
return if provider_data.key?(:type)
|
|
252
|
+
|
|
253
|
+
raise Prescient::Error, "Provider #{name}#{" in #{source}" if source} must define type"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def validate_provider_keys!(name, provider_data, source:)
|
|
257
|
+
unknown_keys = provider_data.keys.map(&:to_s) - (['type'] + COMMON_PROVIDER_KEYS)
|
|
258
|
+
return if unknown_keys.empty?
|
|
259
|
+
|
|
260
|
+
raise Prescient::Error,
|
|
261
|
+
"Unknown provider configuration key#{'s' if unknown_keys.length > 1} for #{name}: " \
|
|
262
|
+
"#{unknown_keys.join(', ')}" \
|
|
263
|
+
"#{" in #{source}" if source}"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def validate_prompt_templates!(name, provider_data, source:)
|
|
267
|
+
templates = provider_data[:prompt_templates]
|
|
268
|
+
return if templates.nil?
|
|
269
|
+
|
|
270
|
+
source_suffix = " in #{source}" if source
|
|
271
|
+
unless templates.is_a?(Hash)
|
|
272
|
+
raise Prescient::Error, "prompt_templates for #{name} must be a mapping#{source_suffix}"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
unknown_keys = templates.keys.map(&:to_s) - PROMPT_TEMPLATE_KEYS
|
|
276
|
+
return if unknown_keys.empty?
|
|
277
|
+
|
|
278
|
+
message = "Unknown prompt template key#{'s' if unknown_keys.length > 1} for #{name}: " \
|
|
279
|
+
"#{unknown_keys.join(', ')}#{source_suffix}"
|
|
280
|
+
raise Prescient::Error, message
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def resolve_provider_options(provider_data, source:)
|
|
284
|
+
provider_type = provider_data[:type].to_s
|
|
285
|
+
provider_class = PROVIDER_TYPES[provider_type]
|
|
286
|
+
unless provider_class
|
|
287
|
+
raise Prescient::Error,
|
|
288
|
+
"Unknown provider type #{provider_type.inspect}#{" in #{source}" if source}"
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
provider_data.each_with_object({}) do |(key, value), options|
|
|
292
|
+
option = resolve_provider_option(provider_data, key, value, source:)
|
|
293
|
+
options[option.first] = option.last if option
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def resolve_provider_option(provider_data, key, value, source:)
|
|
298
|
+
return if key == :type
|
|
299
|
+
return if key.to_s.end_with?('_env') && value.nil?
|
|
300
|
+
|
|
301
|
+
if key.to_s.end_with?('_env')
|
|
302
|
+
base_key = key.to_s.delete_suffix('_env').to_sym
|
|
303
|
+
if provider_data.key?(base_key)
|
|
304
|
+
raise Prescient::Error,
|
|
305
|
+
"Provider configuration cannot combine #{base_key} and #{key}"
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
[base_key, resolve_env_value(value, source:)]
|
|
309
|
+
else
|
|
310
|
+
[key, coerce_provider_option(key, resolve_value(value, source:))]
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def resolve_scalar(data, key, source:)
|
|
315
|
+
env_key = :"#{key}_env"
|
|
316
|
+
if key_present?(data, key) && key_present?(data, env_key)
|
|
317
|
+
raise Prescient::Error, "Configuration cannot combine #{key} and #{key}_env"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
return resolve_env_value(data[env_key], source:) if key_present?(data, env_key)
|
|
321
|
+
|
|
322
|
+
value = data[key]
|
|
323
|
+
return nil if value.nil?
|
|
324
|
+
|
|
325
|
+
resolve_value(value, source:)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def resolve_value(value, source:)
|
|
329
|
+
case value
|
|
330
|
+
when Hash
|
|
331
|
+
resolve_hash_value(value, source:)
|
|
332
|
+
when Array
|
|
333
|
+
value.map { |item| resolve_value(item, source:) }
|
|
334
|
+
when String
|
|
335
|
+
interpolate_env(value, source:)
|
|
336
|
+
else
|
|
337
|
+
value
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def resolve_hash_value(value, source:)
|
|
342
|
+
value.each_with_object({}) do |(key, nested_value), result|
|
|
343
|
+
key_name = key.to_s
|
|
344
|
+
if key_name.end_with?('_env')
|
|
345
|
+
base_key = key_name.delete_suffix('_env').to_sym
|
|
346
|
+
if value.key?(base_key) || value.key?(base_key.to_s)
|
|
347
|
+
raise Prescient::Error, "Configuration cannot combine #{base_key} and #{key_name}"
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
result[base_key] = resolve_env_value(nested_value, source:)
|
|
351
|
+
else
|
|
352
|
+
result[key.to_sym] = resolve_value(nested_value, source:)
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def resolve_env_value(value, source:)
|
|
358
|
+
env_name = resolve_value(value, source:)
|
|
359
|
+
unless env_name.is_a?(String) && !env_name.empty?
|
|
360
|
+
raise Prescient::Error, 'Environment variable name must be a non-empty string'
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
raw_value = @env.fetch(env_name)
|
|
364
|
+
parsed_value = YAML.safe_load(raw_value, permitted_classes: [], permitted_symbols: [], aliases: true)
|
|
365
|
+
parsed_value.nil? ? raw_value : parsed_value
|
|
366
|
+
rescue KeyError
|
|
367
|
+
raise Prescient::Error, "Environment variable not set: #{env_name}#{" in #{source}" if source}"
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def interpolate_env(value, source:)
|
|
371
|
+
value.gsub(/\$\{([A-Z0-9_]+)\}/) do
|
|
372
|
+
env_name = Regexp.last_match(1)
|
|
373
|
+
@env.fetch(env_name)
|
|
374
|
+
rescue KeyError
|
|
375
|
+
raise Prescient::Error, "Environment variable not set: #{env_name}#{" in #{source}" if source}"
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def normalize_keys(value)
|
|
380
|
+
case value
|
|
381
|
+
when Hash
|
|
382
|
+
value.each_with_object({}) do |(key, nested_value), result|
|
|
383
|
+
result[key.to_sym] = normalize_keys(nested_value)
|
|
384
|
+
end
|
|
385
|
+
when Array
|
|
386
|
+
value.map { |item| normalize_keys(item) }
|
|
387
|
+
else
|
|
388
|
+
value
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def key_present?(data, key)
|
|
393
|
+
data.key?(key) || data.key?(key.to_s)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def scalar_present?(data, key)
|
|
397
|
+
key_present?(data, key) || key_present?(data, :"#{key}_env")
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def coerce_integer(value, name)
|
|
401
|
+
return value if value.is_a?(Integer)
|
|
402
|
+
return value.to_i if value.is_a?(Numeric)
|
|
403
|
+
|
|
404
|
+
Integer(value)
|
|
405
|
+
rescue ArgumentError, TypeError
|
|
406
|
+
raise Prescient::Error, "#{name} must be an integer"
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def coerce_float(value, name)
|
|
410
|
+
return value.to_f if value.is_a?(Numeric)
|
|
411
|
+
|
|
412
|
+
Float(value)
|
|
413
|
+
rescue ArgumentError, TypeError
|
|
414
|
+
raise Prescient::Error, "#{name} must be a number"
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def coerce_provider_option(key, value)
|
|
418
|
+
case key.to_sym
|
|
419
|
+
when :embedding_dimensions
|
|
420
|
+
coerce_integer(value, 'embedding_dimensions')
|
|
421
|
+
when :timeout
|
|
422
|
+
coerce_integer(value, 'timeout')
|
|
423
|
+
else
|
|
424
|
+
value
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def validate_version!(data, source:)
|
|
429
|
+
return unless key_present?(data, :version)
|
|
430
|
+
|
|
431
|
+
version = resolve_value(data[:version], source:)
|
|
432
|
+
return if version == CONFIGURATION_VERSION
|
|
433
|
+
|
|
434
|
+
raise Prescient::Error,
|
|
435
|
+
"Unsupported configuration version #{version.inspect}#{" in #{source}" if source}"
|
|
436
|
+
end
|
|
437
|
+
end
|