prescient 0.7.0 → 0.8.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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +21 -268
  3. data/CHANGELOG.md +37 -0
  4. data/INTEGRATION_GUIDE.md +7 -1
  5. data/README.md +210 -1
  6. data/Steepfile +12 -12
  7. data/db/migrate/001_create_prescient_tables.rb +15 -16
  8. data/examples/README.md +2 -1
  9. data/examples/custom_contexts.rb +4 -4
  10. data/exe/prescient +2 -2
  11. data/exe/prescient-mcp +7 -0
  12. data/lib/prescient/agent/audit_log.rb +37 -0
  13. data/lib/prescient/agent/cli_adapter.rb +29 -0
  14. data/lib/prescient/agent/configuration.rb +57 -0
  15. data/lib/prescient/agent/context.rb +56 -0
  16. data/lib/prescient/agent/error_serializer.rb +47 -0
  17. data/lib/prescient/agent/errors.rb +25 -0
  18. data/lib/prescient/agent/parser.rb +49 -0
  19. data/lib/prescient/agent/prompt_builder.rb +31 -0
  20. data/lib/prescient/agent/result.rb +36 -0
  21. data/lib/prescient/agent/runtime.rb +175 -0
  22. data/lib/prescient/agent/schema_validator.rb +215 -0
  23. data/lib/prescient/agent/tool_registry.rb +89 -0
  24. data/lib/prescient/agent.rb +22 -0
  25. data/lib/prescient/api.rb +337 -274
  26. data/lib/prescient/base.rb +370 -372
  27. data/lib/prescient/cli.rb +586 -526
  28. data/lib/prescient/client.rb +7 -6
  29. data/lib/prescient/configuration_loader.rb +492 -488
  30. data/lib/prescient/document_source.rb +114 -0
  31. data/lib/prescient/errors.rb +1 -3
  32. data/lib/prescient/mcp/authentication.rb +39 -0
  33. data/lib/prescient/mcp/configuration.rb +38 -0
  34. data/lib/prescient/mcp/rack.rb +243 -0
  35. data/lib/prescient/mcp/server.rb +202 -0
  36. data/lib/prescient/mcp/stdio.rb +42 -0
  37. data/lib/prescient/mcp.rb +8 -0
  38. data/lib/prescient/pgvector.rb +193 -189
  39. data/lib/prescient/provider/anthropic.rb +129 -125
  40. data/lib/prescient/provider/deepseek.rb +122 -118
  41. data/lib/prescient/provider/gemini.rb +153 -149
  42. data/lib/prescient/provider/huggingface.rb +191 -187
  43. data/lib/prescient/provider/mistral.rb +151 -147
  44. data/lib/prescient/provider/ollama.rb +168 -165
  45. data/lib/prescient/provider/openai.rb +174 -169
  46. data/lib/prescient/provider/xai.rb +122 -118
  47. data/lib/prescient/tool/search_api.rb +125 -121
  48. data/lib/prescient/tool/searxng.rb +123 -119
  49. data/lib/prescient/tool.rb +100 -98
  50. data/lib/prescient/version.rb +1 -1
  51. data/lib/prescient.rb +68 -62
  52. data/sig/prescient.rbs +176 -1
  53. metadata +23 -1
@@ -1,429 +1,427 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
4
- require 'net/http'
5
-
6
- # Base class for all AI provider implementations
7
- #
8
- # This abstract base class defines the common interface that all AI providers
9
- # must implement. It provides shared functionality for text processing, context
10
- # formatting, prompt building, and error handling.
11
- #
12
- # @abstract Subclass and implement {#generate_embedding}, {#generate_response},
13
- # {#health_check}, and any configuration validation required by
14
- # {#validate_configuration!}
15
- #
16
- # @example Creating a custom provider
17
- # class MyProvider < Prescient::Base
18
- # def generate_embedding(text, **options)
19
- # # Implementation here
20
- # end
21
- #
22
- # def generate_response(prompt, context_items = [], **options)
23
- # # Implementation here
24
- # end
25
- #
26
- # def health_check
27
- # # Implementation here
28
- # end
29
- # end
30
- #
31
- class Prescient::Base
32
- # @return [Hash] Configuration options for this provider instance
33
- attr_reader :options, :provider_name
34
-
35
- # Initialize the provider with configuration options
36
- #
37
- # @param options [Hash] Provider-specific configuration options
38
- # @option options [String] :api_key API key for authenticated providers
39
- # @option options [String] :url Base URL for self-hosted providers
40
- # @option options [Integer] :timeout Request timeout in seconds
41
- # @option options [Hash] :prompt_templates Custom prompt templates
42
- # @option options [Hash] :context_configs Context formatting configurations
43
- # @option options [Integer] :embedding_dimensions Expected custom embedding size
44
- # @option options [Array<Symbol, String>] :context_excluded_fields Additional
45
- # field names excluded from generic embedding text
46
- def initialize(**options)
47
- @options = options
48
- @provider_name = options.fetch(:provider_name, self.class.to_s.split('::').last).to_s.sub(/\A./, &:upcase)
49
- validate_configuration!
50
- end
51
-
52
- # Generate embeddings for the given text
53
- #
54
- # This method must be implemented by subclasses to provide embedding
55
- # generation functionality.
56
- #
57
- # @param text [String] The text to generate embeddings for
58
- # @param options [Hash] Provider-specific options
59
- # @return [Array<Float>] Array of embedding values
60
- # @raise [NotImplementedError] If not implemented by subclass
61
- # @abstract
62
- def generate_embedding(text, **options)
63
- raise NotImplementedError, "#{self.class} must implement #generate_embedding"
64
- end
3
+ require "json"
4
+ require "net/http"
65
5
 
66
- # Generate text response for the given prompt
6
+ module Prescient
7
+ # Base class for all AI provider implementations
67
8
  #
68
- # This method must be implemented by subclasses to provide text generation
69
- # functionality with optional context items.
9
+ # This abstract base class defines the common interface that all AI providers
10
+ # must implement. It provides shared functionality for text processing, context
11
+ # formatting, prompt building, and error handling.
70
12
  #
71
- # @param prompt [String] The prompt to generate a response for
72
- # @param context_items [Array<Hash, String>] Optional context items to include
73
- # @param options [Hash] Provider-specific generation options
74
- # @option options [Float] :temperature Sampling temperature (0.0-2.0)
75
- # @option options [Integer] :max_tokens Maximum tokens to generate
76
- # @option options [Float] :top_p Nucleus sampling parameter
77
- # @return [Hash] Response hash with :response, :model, :provider keys
78
- # @raise [NotImplementedError] If not implemented by subclass
79
- # @abstract
80
- def generate_response(prompt, context_items = [], **options)
81
- raise NotImplementedError, "#{self.class} must implement #generate_response"
82
- end
83
-
84
- # Check the health and availability of the provider
13
+ # @abstract Subclass and implement {#generate_embedding}, {#generate_response},
14
+ # {#health_check}, and any configuration validation required by
15
+ # {#validate_configuration!}
85
16
  #
86
- # This method must be implemented by subclasses to provide health check
87
- # functionality.
17
+ # @example Creating a custom provider
18
+ # class MyProvider < Prescient::Base
19
+ # def generate_embedding(text, **options)
20
+ # # Implementation here
21
+ # end
88
22
  #
89
- # @return [Hash] Health status with at least :status and :provider keys,
90
- # and typically :reachable and :ready for modern adapters
91
- # @raise [NotImplementedError] If not implemented by subclass
92
- # @abstract
93
- def health_check
94
- raise NotImplementedError, "#{self.class} must implement #health_check"
95
- end
96
-
97
- # Check if the provider is currently available
23
+ # def generate_response(prompt, context_items = [], **options)
24
+ # # Implementation here
25
+ # end
98
26
  #
99
- # Returns `true` when the health check reports `reachable: true`.
100
- # For legacy adapters that only return a status string, `status == "healthy"`
101
- # is also treated as available.
27
+ # def health_check
28
+ # # Implementation here
29
+ # end
30
+ # end
102
31
  #
103
- # @return [Boolean] true if the provider is currently reachable
104
- def available?
105
- health = health_check
106
- health.key?(:reachable) ? health[:reachable] == true : health[:status] == 'healthy'
107
- rescue StandardError
108
- false
109
- end
110
-
111
- protected
32
+ # rubocop:disable Metrics/ClassLength
33
+ class Base
34
+ # @return [Hash] Configuration options for this provider instance
35
+ attr_reader :options, :provider_name
36
+
37
+ # Initialize the provider with configuration options
38
+ #
39
+ # @param options [Hash] Provider-specific configuration options
40
+ # @option options [String] :api_key API key for authenticated providers
41
+ # @option options [String] :url Base URL for self-hosted providers
42
+ # @option options [Integer] :timeout Request timeout in seconds
43
+ # @option options [Hash] :prompt_templates Custom prompt templates
44
+ # @option options [Hash] :context_configs Context formatting configurations
45
+ # @option options [Integer] :embedding_dimensions Expected custom embedding size
46
+ # @option options [Array<Symbol, String>] :context_excluded_fields Additional
47
+ # field names excluded from generic embedding text
48
+ def initialize(**options)
49
+ @options = options
50
+ @provider_name = options.fetch(:provider_name, self.class.to_s.split("::").last).to_s.sub(/\A./, &:upcase)
51
+ validate_configuration!
52
+ end
112
53
 
113
- # Validate provider configuration
114
- #
115
- # Override this method in subclasses to validate required configuration
116
- # options and raise appropriate errors for missing or invalid settings.
117
- #
118
- # @return [void]
119
- # @raise [Prescient::Error] If configuration is invalid
120
- def validate_configuration!
121
- # Override in subclasses to validate required configuration
122
- end
54
+ # Generate embeddings for the given text
55
+ #
56
+ # This method must be implemented by subclasses to provide embedding
57
+ # generation functionality.
58
+ #
59
+ # @param text [String] The text to generate embeddings for
60
+ # @param options [Hash] Provider-specific options
61
+ # @return [Array<Float>] Array of embedding values
62
+ # @raise [NotImplementedError] If not implemented by subclass
63
+ # @abstract
64
+ def generate_embedding(text, **options)
65
+ raise NotImplementedError, "#{self.class} must implement #generate_embedding"
66
+ end
123
67
 
124
- # Handle and standardize errors from provider operations
125
- #
126
- # Wraps provider-specific operations and converts common exceptions
127
- # into standardized Prescient error types while preserving existing
128
- # Prescient errors.
129
- #
130
- # @yield The operation block to execute with error handling
131
- # @return [Object] The result of the yielded block
132
- # @raise [Prescient::ConnectionError] For network/timeout errors
133
- # @raise [Prescient::InvalidResponseError] For JSON parsing errors
134
- # @raise [Prescient::Error] For other unexpected errors
135
- def handle_errors
136
- yield
137
- rescue Prescient::Error
138
- # Re-raise Prescient errors without wrapping
139
- raise
140
- rescue Net::ReadTimeout, Net::OpenTimeout => e
141
- raise Prescient::ConnectionError, "Request timeout: #{e.message}"
142
- rescue Net::HTTPError => e
143
- raise Prescient::ConnectionError, "HTTP error: #{e.message}"
144
- rescue JSON::ParserError => e
145
- raise Prescient::InvalidResponseError, "Invalid JSON response: #{e.message}"
146
- rescue StandardError => e
147
- raise Prescient::Error, "Unexpected error: #{e.message}"
148
- end
68
+ # Generate text response for the given prompt
69
+ #
70
+ # This method must be implemented by subclasses to provide text generation
71
+ # functionality with optional context items.
72
+ #
73
+ # @param prompt [String] The prompt to generate a response for
74
+ # @param context_items [Array<Hash, String>] Optional context items to include
75
+ # @param options [Hash] Provider-specific generation options
76
+ # @option options [Float] :temperature Sampling temperature (0.0-2.0)
77
+ # @option options [Integer] :max_tokens Maximum tokens to generate
78
+ # @option options [Float] :top_p Nucleus sampling parameter
79
+ # @return [Hash] Response hash with :response, :model, :provider keys
80
+ # @raise [NotImplementedError] If not implemented by subclass
81
+ # @abstract
82
+ def generate_response(prompt, context_items = [], **options)
83
+ raise NotImplementedError, "#{self.class} must implement #generate_response"
84
+ end
149
85
 
150
- # Validate embedding dimensions against the configured model dimension.
151
- #
152
- # Embedding dimensions are part of the vector-storage contract. Vectors are
153
- # never padded or truncated because either operation changes their meaning.
154
- #
155
- # @param embedding [Array<Float>] The embedding vector to validate
156
- # @param target_dimensions [Integer] The required number of dimensions
157
- # @return [Array<Float>] The original embedding when dimensions are valid
158
- # @raise [Prescient::InvalidResponseError] If the vector is malformed or has
159
- # an unexpected dimension
160
- def validate_embedding_dimensions(embedding, target_dimensions)
161
- raise Prescient::InvalidResponseError, 'Embedding response is not an array' unless embedding.is_a?(Array)
162
-
163
- return embedding if embedding.length == target_dimensions
164
-
165
- raise Prescient::InvalidResponseError,
166
- "Invalid embedding dimensions: expected #{target_dimensions}, got #{embedding.length}"
167
- end
86
+ # Check the health and availability of the provider
87
+ #
88
+ # This method must be implemented by subclasses to provide health check
89
+ # functionality.
90
+ #
91
+ # @return [Hash] Health status with at least :status and :provider keys,
92
+ # and typically :reachable and :ready for modern adapters
93
+ # @raise [NotImplementedError] If not implemented by subclass
94
+ # @abstract
95
+ def health_check
96
+ raise NotImplementedError, "#{self.class} must implement #health_check"
97
+ end
168
98
 
169
- # Clean and preprocess text for AI processing
170
- #
171
- # Removes excess whitespace, normalizes spacing, and truncates to the
172
- # library's current 8,000-character input ceiling.
173
- #
174
- # @param text [String, nil] The text to clean
175
- # @return [String] Cleaned text, empty string if input was nil/empty
176
- def clean_text(text)
177
- # Limit length for most models
178
- text.to_s.gsub(/\s+/, ' ').strip.slice(0, 8000)
179
- end
99
+ # Check if the provider is currently available
100
+ #
101
+ # Returns `true` when the health check reports `reachable: true`.
102
+ # For legacy adapters that only return a status string, `status == "healthy"`
103
+ # is also treated as available.
104
+ #
105
+ # @return [Boolean] true if the provider is currently reachable
106
+ def available?
107
+ health = health_check
108
+ health.key?(:reachable) ? health[:reachable] == true : health[:status] == "healthy"
109
+ rescue StandardError
110
+ false
111
+ end
180
112
 
181
- # Get default prompt templates
182
- #
183
- # Provides standard templates for system prompts and context handling
184
- # that can be overridden via provider options.
185
- #
186
- # @return [Hash] Hash containing template strings with placeholders
187
- # @private
188
- def default_prompt_templates
189
- {
190
- system_prompt: 'You are a helpful AI assistant. Answer questions clearly and accurately.',
191
- no_context_template: <<~TEMPLATE.strip,
192
- %<system_prompt>s
113
+ protected
114
+
115
+ # Validate provider configuration
116
+ #
117
+ # Override this method in subclasses to validate required configuration
118
+ # options and raise appropriate errors for missing or invalid settings.
119
+ #
120
+ # @return [void]
121
+ # @raise [Prescient::Error] If configuration is invalid
122
+ def validate_configuration!
123
+ # Override in subclasses to validate required configuration
124
+ end
193
125
 
194
- Question: %<query>s
126
+ # Handle and standardize errors from provider operations
127
+ #
128
+ # Wraps provider-specific operations and converts common exceptions
129
+ # into standardized Prescient error types while preserving existing
130
+ # Prescient errors.
131
+ #
132
+ # @yield The operation block to execute with error handling
133
+ # @return [Object] The result of the yielded block
134
+ # @raise [Prescient::ConnectionError] For network/timeout errors
135
+ # @raise [Prescient::InvalidResponseError] For JSON parsing errors
136
+ # @raise [Prescient::Error] For other unexpected errors
137
+ def handle_errors
138
+ yield
139
+ rescue Prescient::Error
140
+ # Re-raise Prescient errors without wrapping
141
+ raise
142
+ rescue Net::ReadTimeout, Net::OpenTimeout => e
143
+ raise Prescient::ConnectionError, "Request timeout: #{e.message}"
144
+ rescue Net::HTTPError => e
145
+ raise Prescient::ConnectionError, "HTTP error: #{e.message}"
146
+ rescue JSON::ParserError => e
147
+ raise Prescient::InvalidResponseError, "Invalid JSON response: #{e.message}"
148
+ rescue StandardError => e
149
+ raise Prescient::Error, "Unexpected error: #{e.message}"
150
+ end
195
151
 
196
- Please provide a helpful response based on your knowledge.
197
- TEMPLATE
198
- with_context_template: <<~TEMPLATE.strip,
199
- %<system_prompt>s Use the following context to answer the question. If the context doesn't contain relevant information, say so clearly.
152
+ # Validate embedding dimensions against the configured model dimension.
153
+ #
154
+ # Embedding dimensions are part of the vector-storage contract. Vectors are
155
+ # never padded or truncated because either operation changes their meaning.
156
+ #
157
+ # @param embedding [Array<Float>] The embedding vector to validate
158
+ # @param target_dimensions [Integer] The required number of dimensions
159
+ # @return [Array<Float>] The original embedding when dimensions are valid
160
+ # @raise [Prescient::InvalidResponseError] If the vector is malformed or has
161
+ # an unexpected dimension
162
+ def validate_embedding_dimensions(embedding, target_dimensions)
163
+ raise Prescient::InvalidResponseError, "Embedding response is not an array" unless embedding.is_a?(Array)
164
+
165
+ return embedding if embedding.length == target_dimensions
166
+
167
+ raise Prescient::InvalidResponseError,
168
+ "Invalid embedding dimensions: expected #{target_dimensions}, got #{embedding.length}"
169
+ end
200
170
 
201
- Context:
202
- %<context>s
171
+ # Clean and preprocess text for AI processing
172
+ #
173
+ # Removes excess whitespace, normalizes spacing, and truncates to the
174
+ # library's current 8,000-character input ceiling.
175
+ #
176
+ # @param text [String, nil] The text to clean
177
+ # @return [String] Cleaned text, empty string if input was nil/empty
178
+ def clean_text(text)
179
+ # Limit length for most models
180
+ text.to_s.gsub(/\s+/, " ").strip.slice(0, 8000)
181
+ end
203
182
 
204
- Question: %<query>s
183
+ # Get default prompt templates
184
+ #
185
+ # Provides standard templates for system prompts and context handling
186
+ # that can be overridden via provider options.
187
+ #
188
+ # @return [Hash] Hash containing template strings with placeholders
189
+ # @private
190
+ def default_prompt_templates
191
+ {
192
+ system_prompt: "You are a helpful AI assistant. Answer questions clearly and accurately.",
193
+ no_context_template: <<~TEMPLATE.strip,
194
+ %<system_prompt>s
195
+
196
+ Question: %<query>s
197
+
198
+ Please provide a helpful response based on your knowledge.
199
+ TEMPLATE
200
+ with_context_template: <<~TEMPLATE.strip
201
+ %<system_prompt>s Use the following context to answer the question. If the context doesn't contain relevant information, say so clearly.
202
+
203
+ Context:
204
+ %<context>s
205
+
206
+ Question: %<query>s
207
+
208
+ Please provide a helpful response based on the context above.
209
+ TEMPLATE
210
+ }
211
+ end
205
212
 
206
- Please provide a helpful response based on the context above.
207
- TEMPLATE
208
- }
209
- end
213
+ # Build formatted prompt from query and context items
214
+ #
215
+ # Creates a properly formatted prompt using configurable templates,
216
+ # incorporating context items when provided.
217
+ #
218
+ # @param query [String] The user's question or prompt
219
+ # @param context_items [Array<Hash, String>] Optional context items
220
+ # @return [String] Formatted prompt ready for AI processing
221
+ def build_prompt(query, context_items = [])
222
+ templates = default_prompt_templates.merge(@options[:prompt_templates] || {})
223
+ system_prompt = templates[:system_prompt]
224
+
225
+ if context_items.empty?
226
+ format(templates[:no_context_template], system_prompt: system_prompt, query: query)
227
+ else
228
+ context_text = context_items.map.with_index(1) do |item, index|
229
+ "#{index}. #{format_context_item(item)}"
230
+ end.join("\n\n")
231
+
232
+ format(templates[:with_context_template], system_prompt: system_prompt, context: context_text,
233
+ query: query)
234
+ end
235
+ end
210
236
 
211
- # Build formatted prompt from query and context items
212
- #
213
- # Creates a properly formatted prompt using configurable templates,
214
- # incorporating context items when provided.
215
- #
216
- # @param query [String] The user's question or prompt
217
- # @param context_items [Array<Hash, String>] Optional context items
218
- # @return [String] Formatted prompt ready for AI processing
219
- def build_prompt(query, context_items = [])
220
- templates = default_prompt_templates.merge(@options[:prompt_templates] || {})
221
- system_prompt = templates[:system_prompt]
222
-
223
- if context_items.empty?
224
- templates[:no_context_template] % {
225
- system_prompt: system_prompt,
226
- query: query,
227
- }
228
- else
229
- context_text = context_items.map.with_index(1) { |item, index|
230
- "#{index}. #{format_context_item(item)}"
231
- }.join("\n\n")
232
-
233
- templates[:with_context_template] % {
234
- system_prompt: system_prompt,
235
- context: context_text,
236
- query: query,
237
+ # Minimal default context configuration - users should define their own contexts
238
+ def default_context_configs
239
+ embedding_fields = [] # : Array[untyped]
240
+ fields = [] # : Array[untyped]
241
+
242
+ {
243
+ # Generic fallback configuration - works with any hash structure
244
+ "default" => {
245
+ fields: fields, # Will be dynamically determined from item keys
246
+ format: nil, # Will use fallback formatting
247
+ embedding_fields: embedding_fields # Will use all string/text fields
248
+ }
237
249
  }
238
250
  end
239
- end
240
-
241
- # Minimal default context configuration - users should define their own contexts
242
- def default_context_configs
243
- embedding_fields = [] # : Array[untyped]
244
- fields = [] # : Array[untyped]
245
-
246
- {
247
- # Generic fallback configuration - works with any hash structure
248
- 'default' => {
249
- fields: fields, # Will be dynamically determined from item keys
250
- format: nil, # Will use fallback formatting
251
- embedding_fields: embedding_fields, # Will use all string/text fields
252
- },
253
- }
254
- end
255
251
 
256
- # Extract text for embedding generation based on context configuration
257
- def extract_embedding_text(item, context_type = nil)
258
- return item.to_s unless item.is_a?(Hash)
252
+ # Extract text for embedding generation based on context configuration
253
+ def extract_embedding_text(item, context_type = nil)
254
+ return item.to_s unless item.is_a?(Hash)
259
255
 
260
- config = resolve_context_config(item, context_type)
261
- text_values = extract_configured_fields(item, config) || extract_text_values(item)
262
- text_values.join(' ').strip
263
- end
256
+ config = resolve_context_config(item, context_type)
257
+ text_values = extract_configured_fields(item, config) || extract_text_values(item)
258
+ text_values.join(" ").strip
259
+ end
264
260
 
265
- # Extract text values from hash, excluding non-textual fields
266
- def extract_text_values(item)
267
- # Common fields to exclude from embedding text. Provider-specific fields can
268
- # be added with the :context_excluded_fields option.
269
- default_excluded_fields = ['id', '_id', 'uuid', 'created_at', 'updated_at', 'timestamp', 'version', 'status',
270
- 'active']
271
- configured_fields = Array(@options[:context_excluded_fields]) # : Array[untyped]
272
- configured_excluded_fields = configured_fields.map { |field| field.to_s.downcase }
273
- exclude_fields = default_excluded_fields | configured_excluded_fields
274
-
275
- item.filter_map { |key, value|
276
- next if exclude_fields.include?(key.to_s.downcase)
277
- next unless value.is_a?(String) || value.is_a?(Numeric)
278
- next if value.to_s.strip.empty?
279
-
280
- value.to_s
281
- }
282
- end
261
+ # Extract text values from hash, excluding non-textual fields
262
+ def extract_text_values(item)
263
+ # Common fields to exclude from embedding text. Provider-specific fields can
264
+ # be added with the :context_excluded_fields option.
265
+ default_excluded_fields = %w[id _id uuid created_at updated_at timestamp version status
266
+ active]
267
+ configured_fields = Array(@options[:context_excluded_fields]) # : Array[untyped]
268
+ configured_excluded_fields = configured_fields.map { |field| field.to_s.downcase }
269
+ exclude_fields = default_excluded_fields | configured_excluded_fields
270
+
271
+ item.filter_map do |key, value|
272
+ next if exclude_fields.include?(key.to_s.downcase)
273
+ next unless value.is_a?(String) || value.is_a?(Numeric)
274
+ next if value.to_s.strip.empty?
275
+
276
+ value.to_s
277
+ end
278
+ end
283
279
 
284
- # Generic context item formatting using configurable contexts
285
- def format_context_item(item)
286
- case item
287
- when Hash then format_hash_item(item)
288
- when String then item
289
- else item.to_s
280
+ # Generic context item formatting using configurable contexts
281
+ def format_context_item(item)
282
+ case item
283
+ when Hash then format_hash_item(item)
284
+ when String then item
285
+ else item.to_s
286
+ end
290
287
  end
291
- end
292
288
 
293
- private
289
+ private
294
290
 
295
- # Resolve context configuration for an item
296
- def resolve_context_config(item, context_type)
297
- context_configs = default_context_configs.merge(@options[:context_configs] || {})
298
- return context_configs['default'] if context_configs.empty?
291
+ # Resolve context configuration for an item
292
+ def resolve_context_config(item, context_type)
293
+ context_configs = default_context_configs.merge(@options[:context_configs] || {})
294
+ return context_configs["default"] if context_configs.empty?
299
295
 
300
- detected_type = context_type || detect_context_type(item)
301
- context_configs[detected_type] || context_configs['default']
302
- end
296
+ detected_type = context_type || detect_context_type(item)
297
+ context_configs[detected_type] || context_configs["default"]
298
+ end
303
299
 
304
- # Extract fields configured for embeddings
305
- def extract_configured_fields(item, config)
306
- return nil unless config[:embedding_fields]&.any?
300
+ # Extract fields configured for embeddings
301
+ def extract_configured_fields(item, config)
302
+ return nil unless config[:embedding_fields]&.any?
307
303
 
308
- config[:embedding_fields].filter_map { |field| item[field] || item[field.to_sym] }
309
- end
304
+ config[:embedding_fields].filter_map { |field| item[field] || item[field.to_sym] }
305
+ end
310
306
 
311
- # Format a hash item using context configuration
312
- def format_hash_item(item)
313
- config = resolve_context_config(item, nil)
314
- return fallback_format_hash(item) unless config[:format]
307
+ # Format a hash item using context configuration
308
+ def format_hash_item(item)
309
+ config = resolve_context_config(item, nil)
310
+ return fallback_format_hash(item) unless config[:format]
315
311
 
316
- format_data = build_format_data(item, config)
317
- return fallback_format_hash(item) unless format_data.any?
312
+ format_data = build_format_data(item, config)
313
+ return fallback_format_hash(item) unless format_data.any?
318
314
 
319
- apply_format_template(config[:format], format_data) || fallback_format_hash(item)
320
- end
315
+ apply_format_template(config[:format], format_data) || fallback_format_hash(item)
316
+ end
317
+
318
+ # Build format data from item fields
319
+ def build_format_data(item, config)
320
+ format_data = {} # : Hash[Symbol, untyped]
321
+ fields_to_check = config[:fields].any? ? config[:fields] : item.keys.map(&:to_s)
321
322
 
322
- # Build format data from item fields
323
- def build_format_data(item, config)
324
- format_data = {} # : Hash[Symbol, untyped]
325
- fields_to_check = config[:fields].any? ? config[:fields] : item.keys.map(&:to_s)
323
+ fields_to_check.each do |field|
324
+ value = item[field] || item[field.to_sym]
325
+ format_data[field.to_sym] = value if value
326
+ end
326
327
 
327
- fields_to_check.each do |field|
328
- value = item[field] || item[field.to_sym]
329
- format_data[field.to_sym] = value if value
328
+ format_data
330
329
  end
331
330
 
332
- format_data
333
- end
331
+ # Apply format template with error handling
332
+ def apply_format_template(template, format_data)
333
+ template % format_data
334
+ rescue KeyError
335
+ nil
336
+ end
334
337
 
335
- # Apply format template with error handling
336
- def apply_format_template(template, format_data)
337
- template % format_data
338
- rescue KeyError
339
- nil
340
- end
338
+ # Detect context type from item structure
339
+ def detect_context_type(item)
340
+ return "default" unless item.is_a?(Hash)
341
341
 
342
- # Detect context type from item structure
343
- def detect_context_type(item)
344
- return 'default' unless item.is_a?(Hash)
342
+ # Check for explicit type fields (user-defined)
343
+ return item["type"].to_s if item["type"]
344
+ return item["context_type"].to_s if item["context_type"]
345
+ return item["model_type"].to_s.downcase if item["model_type"]
345
346
 
346
- # Check for explicit type fields (user-defined)
347
- return item['type'].to_s if item['type']
348
- return item['context_type'].to_s if item['context_type']
349
- return item['model_type'].to_s.downcase if item['model_type']
347
+ # If no explicit type and user has configured contexts, try to match
348
+ context_configs = @options[:context_configs] || {}
349
+ return match_context_by_fields(item, context_configs) if context_configs.any?
350
350
 
351
- # If no explicit type and user has configured contexts, try to match
352
- context_configs = @options[:context_configs] || {}
353
- return match_context_by_fields(item, context_configs) if context_configs.any?
351
+ # Default fallback
352
+ "default"
353
+ end
354
354
 
355
- # Default fallback
356
- 'default'
357
- end
355
+ # Match context type based on configured field patterns
356
+ def match_context_by_fields(item, context_configs)
357
+ item_fields = item.keys.map(&:to_s)
358
+ best_match = find_best_field_match(item_fields, context_configs)
359
+ best_match || "default"
360
+ end
358
361
 
359
- # Match context type based on configured field patterns
360
- def match_context_by_fields(item, context_configs)
361
- item_fields = item.keys.map(&:to_s)
362
- best_match = find_best_field_match(item_fields, context_configs)
363
- best_match || 'default'
364
- end
362
+ # Find the best matching context configuration
363
+ def find_best_field_match(item_fields, context_configs)
364
+ best_match = nil
365
+ best_score = 0
365
366
 
366
- # Find the best matching context configuration
367
- def find_best_field_match(item_fields, context_configs)
368
- best_match = nil
369
- best_score = 0
367
+ context_configs.each do |context_type, config|
368
+ next unless config[:fields]&.any?
370
369
 
371
- context_configs.each do |context_type, config|
372
- next unless config[:fields]&.any?
370
+ score = calculate_field_match_score(item_fields, config[:fields])
371
+ next unless score >= 0.5 && score > best_score
373
372
 
374
- score = calculate_field_match_score(item_fields, config[:fields])
375
- next unless score >= 0.5 && score > best_score
373
+ best_match = context_type
374
+ best_score = score
375
+ end
376
376
 
377
- best_match = context_type
378
- best_score = score
377
+ best_match
379
378
  end
380
379
 
381
- best_match
382
- end
383
-
384
- # Calculate field matching score
385
- def calculate_field_match_score(item_fields, config_fields)
386
- return 0 if config_fields.empty?
380
+ # Calculate field matching score
381
+ def calculate_field_match_score(item_fields, config_fields)
382
+ return 0 if config_fields.empty?
387
383
 
388
- matching_fields = (item_fields & config_fields).size
389
- matching_fields.to_f / config_fields.size
390
- end
384
+ matching_fields = (item_fields & config_fields).size
385
+ matching_fields.to_f / config_fields.size
386
+ end
391
387
 
392
- # Fallback formatting for hash items
393
- def fallback_format_hash(item, format_data = nil)
394
- # Fallback: join key-value pairs
395
- (format_data || item).map { |k, v| "#{k}: #{v}" }.join(', ')
396
- end
388
+ # Fallback formatting for hash items
389
+ def fallback_format_hash(item, format_data = nil)
390
+ # Fallback: join key-value pairs
391
+ (format_data || item).map { |k, v| "#{k}: #{v}" }.join(", ")
392
+ end
397
393
 
398
- def validate_response!(response, operation)
399
- return if response.success?
400
-
401
- resp_message, error_class = case response.code
402
- when 400
403
- ['Bad Request', Prescient::Error]
404
- when 401
405
- ['Authentication Failure', Prescient::AuthenticationError]
406
- when 403
407
- ['Forbidden Access', Prescient::AuthenticationError]
408
- when 404
409
- ['Model Not Available', Prescient::ModelNotAvailableError]
410
- when 429
411
- ['Rate Limit Exceeded', Prescient::RateLimitError]
412
- when 500..599
413
- ["#{provider_name} Server Error", Prescient::ProviderError]
414
- else
415
- ["#{provider_name} Request Failure", Prescient::Error]
416
- end
417
-
418
- raise provider_error(resp_message, response, error_class:, operation:)
419
- end
394
+ def validate_response!(response, operation)
395
+ return if response.success?
396
+
397
+ resp_message, error_class = case response.code
398
+ when 400
399
+ ["Bad Request", Prescient::Error]
400
+ when 401
401
+ ["Authentication Failure", Prescient::AuthenticationError]
402
+ when 403
403
+ ["Forbidden Access", Prescient::AuthenticationError]
404
+ when 404
405
+ ["Model Not Available", Prescient::ModelNotAvailableError]
406
+ when 429
407
+ ["Rate Limit Exceeded", Prescient::RateLimitError]
408
+ when 500..599
409
+ ["#{provider_name} Server Error", Prescient::ProviderError]
410
+ else
411
+ ["#{provider_name} Request Failure", Prescient::Error]
412
+ end
413
+
414
+ raise provider_error(resp_message, response, error_class:, operation:)
415
+ end
420
416
 
421
- def provider_error(message, response, operation:, provider: nil, error_class: Prescient::ProviderError)
422
- error_class.new(
423
- message,
424
- provider: provider || provider_name,
425
- operation:,
426
- status: response.code,
427
- )
417
+ def provider_error(message, response, operation:, provider: nil, error_class: Prescient::ProviderError)
418
+ error_class.new(
419
+ message,
420
+ provider: provider || provider_name,
421
+ operation:,
422
+ status: response.code
423
+ )
424
+ end
428
425
  end
426
+ # rubocop:enable Metrics/ClassLength
429
427
  end