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,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
@@ -37,7 +37,7 @@ class Prescient::Provider::Anthropic < Prescient::Base
37
37
  'anthropic-version' => '2023-06-01',
38
38
  },
39
39
  body: {
40
- model: @options[:model],
40
+ model: options[:model] || @options[:model],
41
41
  max_tokens: options[:max_tokens] || 2000,
42
42
  temperature: options[:temperature] || 0.7,
43
43
  messages: [
@@ -55,7 +55,7 @@ class Prescient::Provider::Anthropic < Prescient::Base
55
55
 
56
56
  {
57
57
  response: content.strip,
58
- model: @options[:model],
58
+ model: options[:model] || @options[:model],
59
59
  provider: 'anthropic',
60
60
  processing_time: nil,
61
61
  metadata: {
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'httparty'
4
+
5
+ # DeepSeek API provider adapter.
6
+ class Prescient::Provider::DeepSeek < Prescient::Base
7
+ include HTTParty
8
+
9
+ base_uri 'https://api.deepseek.com'
10
+
11
+ def initialize(**options)
12
+ super
13
+ @provider_name = 'DeepSeek'
14
+ self.class.default_timeout(@options[:timeout] || 60)
15
+ end
16
+
17
+ # DeepSeek does not currently provide an embeddings endpoint.
18
+ # @raise [Prescient::Error] Always, because embeddings are unsupported
19
+ def generate_embedding(_text, **_options)
20
+ raise Prescient::Error, 'DeepSeek provider does not support embeddings.'
21
+ end
22
+
23
+ # Generate a response through DeepSeek's OpenAI-compatible chat API.
24
+ # @param prompt [String] Prompt to send
25
+ # @param context_items [Array<Hash, String>] Optional context items
26
+ # @return [Hash] Normalized response data
27
+ def generate_response(prompt, context_items = [], **options)
28
+ handle_errors do
29
+ model = options[:model] || @options[:chat_model]
30
+ response = self.class.post(
31
+ '/chat/completions',
32
+ headers: api_headers,
33
+ body: {
34
+ model: model,
35
+ messages: [{ role: 'user', content: build_prompt(prompt, context_items) }],
36
+ max_tokens: options[:max_tokens] || 2000,
37
+ temperature: options[:temperature] || 0.7,
38
+ top_p: options[:top_p] || 0.9,
39
+ }.to_json,
40
+ )
41
+
42
+ validate_response!(response, 'text generation')
43
+
44
+ parsed_response = response.parsed_response
45
+ content = parsed_response.dig('choices', 0, 'message', 'content')
46
+ raise Prescient::InvalidResponseError, 'No response generated' unless content.is_a?(String) && !content.empty?
47
+
48
+ {
49
+ response: content.strip,
50
+ model: model,
51
+ provider: 'deepseek',
52
+ processing_time: nil,
53
+ metadata: {
54
+ usage: parsed_response['usage'],
55
+ finish_reason: parsed_response.dig('choices', 0, 'finish_reason'),
56
+ },
57
+ }
58
+ end
59
+ end
60
+
61
+ # Check whether the configured DeepSeek model is available.
62
+ # @return [Hash] Provider health information
63
+ def health_check
64
+ handle_errors do
65
+ response = self.class.get('/models', headers: api_headers)
66
+
67
+ if response.success?
68
+ models = response.parsed_response['data'] || []
69
+ model_available = models.any? { |model| model['id'] == @options[:chat_model] }
70
+
71
+ {
72
+ status: 'healthy',
73
+ provider: 'deepseek',
74
+ reachable: true,
75
+ models_available: models.map { |model| model['id'] },
76
+ chat_model: { name: @options[:chat_model], available: model_available },
77
+ ready: model_available,
78
+ }
79
+ else
80
+ {
81
+ status: 'unhealthy',
82
+ provider: 'deepseek',
83
+ reachable: true,
84
+ error: "HTTP #{response.code}",
85
+ message: response.message,
86
+ ready: false,
87
+ }
88
+ end
89
+ end
90
+ rescue Prescient::Error => e
91
+ {
92
+ status: 'unavailable',
93
+ provider: 'deepseek',
94
+ reachable: false,
95
+ error: e.class.name,
96
+ message: e.message,
97
+ ready: false,
98
+ }
99
+ end
100
+
101
+ # List models available to the configured DeepSeek API key.
102
+ # @return [Array<Hash>] Model descriptors
103
+ def list_models
104
+ handle_errors do
105
+ response = self.class.get('/models', headers: api_headers)
106
+ validate_response!(response, 'model listing')
107
+
108
+ (response.parsed_response['data'] || []).map do |model|
109
+ {
110
+ name: model['id'],
111
+ object: model['object'],
112
+ created: model['created'],
113
+ owned_by: model['owned_by'],
114
+ permission: model['permission'],
115
+ }.compact
116
+ end
117
+ end
118
+ end
119
+
120
+ protected
121
+
122
+ def validate_configuration!
123
+ required_options = [:api_key, :chat_model]
124
+ missing_options = required_options.select { |option| @options[option].nil? }
125
+
126
+ return unless missing_options.any?
127
+
128
+ raise Prescient::Error, "Missing required options: #{missing_options.join(', ')}"
129
+ end
130
+
131
+ private
132
+
133
+ def api_headers
134
+ {
135
+ 'Content-Type' => 'application/json',
136
+ 'Authorization' => "Bearer #{@options[:api_key]}",
137
+ }
138
+ end
139
+ end