decision_agent 0.1.4 → 0.1.6

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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +84 -233
  3. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +46 -10
  4. data/lib/decision_agent/agent.rb +5 -3
  5. data/lib/decision_agent/auth/access_audit_logger.rb +122 -0
  6. data/lib/decision_agent/auth/authenticator.rb +127 -0
  7. data/lib/decision_agent/auth/password_reset_manager.rb +57 -0
  8. data/lib/decision_agent/auth/password_reset_token.rb +33 -0
  9. data/lib/decision_agent/auth/permission.rb +29 -0
  10. data/lib/decision_agent/auth/permission_checker.rb +43 -0
  11. data/lib/decision_agent/auth/rbac_adapter.rb +278 -0
  12. data/lib/decision_agent/auth/rbac_config.rb +51 -0
  13. data/lib/decision_agent/auth/role.rb +56 -0
  14. data/lib/decision_agent/auth/session.rb +33 -0
  15. data/lib/decision_agent/auth/session_manager.rb +57 -0
  16. data/lib/decision_agent/auth/user.rb +70 -0
  17. data/lib/decision_agent/context.rb +24 -4
  18. data/lib/decision_agent/decision.rb +10 -3
  19. data/lib/decision_agent/dsl/condition_evaluator.rb +378 -1
  20. data/lib/decision_agent/dsl/schema_validator.rb +8 -1
  21. data/lib/decision_agent/errors.rb +38 -0
  22. data/lib/decision_agent/evaluation.rb +10 -3
  23. data/lib/decision_agent/evaluation_validator.rb +8 -13
  24. data/lib/decision_agent/monitoring/dashboard_server.rb +1 -0
  25. data/lib/decision_agent/monitoring/metrics_collector.rb +17 -5
  26. data/lib/decision_agent/testing/batch_test_importer.rb +373 -0
  27. data/lib/decision_agent/testing/batch_test_runner.rb +244 -0
  28. data/lib/decision_agent/testing/test_coverage_analyzer.rb +191 -0
  29. data/lib/decision_agent/testing/test_result_comparator.rb +235 -0
  30. data/lib/decision_agent/testing/test_scenario.rb +42 -0
  31. data/lib/decision_agent/version.rb +10 -1
  32. data/lib/decision_agent/versioning/activerecord_adapter.rb +1 -1
  33. data/lib/decision_agent/versioning/file_storage_adapter.rb +96 -28
  34. data/lib/decision_agent/web/middleware/auth_middleware.rb +45 -0
  35. data/lib/decision_agent/web/middleware/permission_middleware.rb +94 -0
  36. data/lib/decision_agent/web/public/app.js +184 -29
  37. data/lib/decision_agent/web/public/batch_testing.html +640 -0
  38. data/lib/decision_agent/web/public/index.html +37 -9
  39. data/lib/decision_agent/web/public/login.html +298 -0
  40. data/lib/decision_agent/web/public/users.html +679 -0
  41. data/lib/decision_agent/web/server.rb +873 -7
  42. data/lib/decision_agent.rb +52 -0
  43. data/lib/generators/decision_agent/install/templates/rule_version.rb +1 -1
  44. data/spec/ab_testing/ab_test_assignment_spec.rb +253 -0
  45. data/spec/ab_testing/ab_test_manager_spec.rb +282 -0
  46. data/spec/ab_testing/ab_testing_agent_spec.rb +481 -0
  47. data/spec/ab_testing/storage/adapter_spec.rb +64 -0
  48. data/spec/ab_testing/storage/memory_adapter_spec.rb +485 -0
  49. data/spec/advanced_operators_spec.rb +1003 -0
  50. data/spec/agent_spec.rb +40 -0
  51. data/spec/audit_adapters_spec.rb +18 -0
  52. data/spec/auth/access_audit_logger_spec.rb +394 -0
  53. data/spec/auth/authenticator_spec.rb +112 -0
  54. data/spec/auth/password_reset_spec.rb +294 -0
  55. data/spec/auth/permission_checker_spec.rb +207 -0
  56. data/spec/auth/permission_spec.rb +73 -0
  57. data/spec/auth/rbac_adapter_spec.rb +550 -0
  58. data/spec/auth/rbac_config_spec.rb +82 -0
  59. data/spec/auth/role_spec.rb +51 -0
  60. data/spec/auth/session_manager_spec.rb +172 -0
  61. data/spec/auth/session_spec.rb +112 -0
  62. data/spec/auth/user_spec.rb +130 -0
  63. data/spec/context_spec.rb +43 -0
  64. data/spec/decision_agent_spec.rb +96 -0
  65. data/spec/decision_spec.rb +423 -0
  66. data/spec/dsl/condition_evaluator_spec.rb +774 -0
  67. data/spec/evaluation_spec.rb +364 -0
  68. data/spec/evaluation_validator_spec.rb +165 -0
  69. data/spec/examples.txt +1542 -612
  70. data/spec/monitoring/metrics_collector_spec.rb +220 -2
  71. data/spec/monitoring/storage/activerecord_adapter_spec.rb +153 -1
  72. data/spec/monitoring/storage/base_adapter_spec.rb +61 -0
  73. data/spec/performance_optimizations_spec.rb +486 -0
  74. data/spec/spec_helper.rb +23 -0
  75. data/spec/testing/batch_test_importer_spec.rb +693 -0
  76. data/spec/testing/batch_test_runner_spec.rb +307 -0
  77. data/spec/testing/test_coverage_analyzer_spec.rb +292 -0
  78. data/spec/testing/test_result_comparator_spec.rb +392 -0
  79. data/spec/testing/test_scenario_spec.rb +113 -0
  80. data/spec/versioning/adapter_spec.rb +156 -0
  81. data/spec/versioning_spec.rb +253 -0
  82. data/spec/web/middleware/auth_middleware_spec.rb +133 -0
  83. data/spec/web/middleware/permission_middleware_spec.rb +247 -0
  84. data/spec/web_ui_rack_spec.rb +1705 -0
  85. metadata +99 -6
@@ -40,7 +40,8 @@ module DecisionAgent
40
40
  private_class_method def self.validate_decision!(decision)
41
41
  raise ValidationError, "Decision cannot be nil" if decision.nil?
42
42
  raise ValidationError, "Decision must be a String" unless decision.is_a?(String)
43
- raise ValidationError, "Decision cannot be empty" if decision.strip.empty?
43
+ # Fast path: skip strip if string is clearly not empty (length > 0)
44
+ raise ValidationError, "Decision cannot be empty" if decision.empty? || decision.strip.empty?
44
45
  end
45
46
 
46
47
  private_class_method def self.validate_weight!(weight)
@@ -52,7 +53,8 @@ module DecisionAgent
52
53
  private_class_method def self.validate_reason!(reason)
53
54
  raise ValidationError, "Reason cannot be nil" if reason.nil?
54
55
  raise ValidationError, "Reason must be a String" unless reason.is_a?(String)
55
- raise ValidationError, "Reason cannot be empty" if reason.strip.empty?
56
+ # Fast path: skip strip if string is clearly not empty (length > 0)
57
+ raise ValidationError, "Reason cannot be empty" if reason.empty? || reason.strip.empty?
56
58
  end
57
59
 
58
60
  private_class_method def self.validate_evaluator_name!(name)
@@ -61,18 +63,11 @@ module DecisionAgent
61
63
  end
62
64
 
63
65
  private_class_method def self.validate_frozen!(evaluation)
64
- raise ValidationError, "Evaluation must be frozen for thread-safety (call .freeze)" unless evaluation.frozen?
66
+ # Fast path: if evaluation is frozen, assume nested structures are also frozen
67
+ # (they are frozen in Evaluation#initialize)
68
+ return true if evaluation.frozen?
65
69
 
66
- # Verify nested structures are also frozen
67
- raise ValidationError, "Evaluation decision must be frozen" unless evaluation.decision.frozen?
68
-
69
- raise ValidationError, "Evaluation reason must be frozen" unless evaluation.reason.frozen?
70
-
71
- raise ValidationError, "Evaluation evaluator_name must be frozen" unless evaluation.evaluator_name.frozen?
72
-
73
- return unless evaluation.metadata && !evaluation.metadata.frozen?
74
-
75
- raise ValidationError, "Evaluation metadata must be frozen"
70
+ raise ValidationError, "Evaluation must be frozen for thread-safety (call .freeze)"
76
71
  end
77
72
  end
78
73
  end
@@ -69,6 +69,7 @@ module DecisionAgent
69
69
 
70
70
  def broadcast_to_clients(message)
71
71
  return unless WEBSOCKET_AVAILABLE
72
+ return if @websocket_clients.empty? # Skip if no clients connected
72
73
 
73
74
  json_message = message.to_json
74
75
  @websocket_clients.each do |client|
@@ -16,9 +16,11 @@ module DecisionAgent
16
16
 
17
17
  attr_reader :metrics, :window_size, :storage_adapter
18
18
 
19
- def initialize(window_size: 3600, storage: :auto)
19
+ def initialize(window_size: 3600, storage: :auto, cleanup_threshold: 100)
20
20
  super()
21
21
  @window_size = window_size # Default: 1 hour window
22
+ @cleanup_threshold = cleanup_threshold # Cleanup every N records
23
+ @cleanup_counter = 0
22
24
  @storage_adapter = initialize_storage_adapter(storage, window_size)
23
25
 
24
26
  # Legacy in-memory metrics for backward compatibility with observers
@@ -47,7 +49,7 @@ module DecisionAgent
47
49
 
48
50
  # Store in-memory for observers (backward compatibility)
49
51
  @metrics[:decisions] << metric
50
- cleanup_old_metrics!
52
+ maybe_cleanup_old_metrics!
51
53
 
52
54
  # Persist to storage adapter
53
55
  @storage_adapter.record_decision(
@@ -76,7 +78,7 @@ module DecisionAgent
76
78
 
77
79
  # Store in-memory for observers (backward compatibility)
78
80
  @metrics[:evaluations] << metric
79
- cleanup_old_metrics!
81
+ maybe_cleanup_old_metrics!
80
82
 
81
83
  # Persist to storage adapter
82
84
  @storage_adapter.record_evaluation(
@@ -104,7 +106,7 @@ module DecisionAgent
104
106
 
105
107
  # Store in-memory for observers (backward compatibility)
106
108
  @metrics[:performance] << metric
107
- cleanup_old_metrics!
109
+ maybe_cleanup_old_metrics!
108
110
 
109
111
  # Persist to storage adapter
110
112
  @storage_adapter.record_performance(
@@ -131,7 +133,7 @@ module DecisionAgent
131
133
 
132
134
  # Store in-memory for observers (backward compatibility)
133
135
  @metrics[:errors] << metric
134
- cleanup_old_metrics!
136
+ maybe_cleanup_old_metrics!
135
137
 
136
138
  # Persist to storage adapter
137
139
  @storage_adapter.record_error(
@@ -317,6 +319,16 @@ module DecisionAgent
317
319
  end
318
320
  end
319
321
 
322
+ # Conditionally cleanup old metrics based on counter
323
+ # This reduces O(n) array scans from every record to every N records
324
+ def maybe_cleanup_old_metrics!
325
+ @cleanup_counter += 1
326
+ return unless @cleanup_counter >= @cleanup_threshold
327
+
328
+ @cleanup_counter = 0
329
+ cleanup_old_metrics!
330
+ end
331
+
320
332
  def cleanup_old_metrics!
321
333
  cutoff_time = Time.now.utc - @window_size
322
334
 
@@ -0,0 +1,373 @@
1
+ require "csv"
2
+ require "roo"
3
+
4
+ module DecisionAgent
5
+ module Testing
6
+ # Imports test scenarios from CSV or Excel files
7
+ class BatchTestImporter
8
+ attr_reader :errors, :warnings
9
+
10
+ def initialize
11
+ @errors = []
12
+ @warnings = []
13
+ end
14
+
15
+ # Import test scenarios from a CSV file
16
+ # @param file_path [String] Path to CSV file
17
+ # @param options [Hash] Import options
18
+ # - :context_columns [Array<String>] Column names to use as context (default: all except id, expected_decision, expected_confidence)
19
+ # - :id_column [String] Column name for test ID (default: 'id')
20
+ # - :expected_decision_column [String] Column name for expected decision (default: 'expected_decision')
21
+ # - :expected_confidence_column [String] Column name for expected confidence (default: 'expected_confidence')
22
+ # - :skip_header [Boolean] Skip first row (default: true)
23
+ # - :progress_callback [Proc] Callback for progress updates (called with { processed: N, total: M, percentage: X })
24
+ # @return [Array<TestScenario>] Array of test scenarios
25
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
26
+ def import_csv(file_path, options = {})
27
+ @errors = []
28
+ @warnings = []
29
+
30
+ options = {
31
+ context_columns: nil,
32
+ id_column: "id",
33
+ expected_decision_column: "expected_decision",
34
+ expected_confidence_column: "expected_confidence",
35
+ skip_header: true,
36
+ progress_callback: nil
37
+ }.merge(options)
38
+
39
+ scenarios = []
40
+ row_number = 0
41
+
42
+ # Count total rows for progress tracking (if callback provided)
43
+ total_rows = nil
44
+ if options[:progress_callback]
45
+ begin
46
+ total_rows = count_csv_rows(file_path, options[:skip_header])
47
+ rescue StandardError
48
+ # If counting fails, continue without progress tracking
49
+ total_rows = nil
50
+ end
51
+ end
52
+
53
+ if options[:skip_header]
54
+ CSV.foreach(file_path, headers: true) do |row|
55
+ row_number += 1
56
+ begin
57
+ scenario = parse_csv_row(row, row_number, options)
58
+ scenarios << scenario if scenario
59
+ rescue StandardError => e
60
+ @errors << "Row #{row_number}: #{e.message}"
61
+ end
62
+
63
+ # Call progress callback if provided
64
+ if options[:progress_callback] && total_rows
65
+ options[:progress_callback].call(
66
+ processed: row_number,
67
+ total: total_rows,
68
+ percentage: (row_number.to_f / total_rows * 100).round(2)
69
+ )
70
+ end
71
+ end
72
+ else
73
+ # Without headers, we need to use numeric indices
74
+ # This is a simplified case - in practice, users should provide headers
75
+ CSV.foreach(file_path, headers: false) do |row|
76
+ row_number += 1
77
+ begin
78
+ # Convert array to hash with numeric keys
79
+ row_hash = row.each_with_index.to_h { |val, idx| [idx.to_s, val] }
80
+ scenario = parse_hash_row(row_hash, row_number, options.merge(id_column: "0"))
81
+ scenarios << scenario if scenario
82
+ rescue StandardError => e
83
+ @errors << "Row #{row_number}: #{e.message}"
84
+ end
85
+
86
+ # Call progress callback if provided
87
+ if options[:progress_callback] && total_rows
88
+ options[:progress_callback].call(
89
+ processed: row_number,
90
+ total: total_rows,
91
+ percentage: (row_number.to_f / total_rows * 100).round(2)
92
+ )
93
+ end
94
+ end
95
+ end
96
+
97
+ raise ImportError, "Failed to import: #{@errors.join('; ')}" if @errors.any? && scenarios.empty?
98
+
99
+ scenarios
100
+ end
101
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
102
+
103
+ # Import test scenarios from an Excel file (.xlsx, .xls)
104
+ # @param file_path [String] Path to Excel file
105
+ # @param options [Hash] Import options (same as import_csv)
106
+ # - :sheet [String|Integer] Sheet name or index (default: first sheet)
107
+ # - :progress_callback [Proc] Callback for progress updates
108
+ # @return [Array<TestScenario>] Array of test scenarios
109
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
110
+ def import_excel(file_path, options = {})
111
+ @errors = []
112
+ @warnings = []
113
+
114
+ options = {
115
+ context_columns: nil,
116
+ id_column: "id",
117
+ expected_decision_column: "expected_decision",
118
+ expected_confidence_column: "expected_confidence",
119
+ skip_header: true,
120
+ sheet: 0,
121
+ progress_callback: nil
122
+ }.merge(options)
123
+
124
+ begin
125
+ spreadsheet = Roo::Spreadsheet.open(file_path)
126
+
127
+ # Select sheet by name or index
128
+ spreadsheet.default_sheet = if options[:sheet].is_a?(Integer)
129
+ spreadsheet.sheets[options[:sheet]] || spreadsheet.sheets.first
130
+ elsif options[:sheet].is_a?(String)
131
+ options[:sheet]
132
+ else
133
+ spreadsheet.sheets.first
134
+ end
135
+
136
+ scenarios = []
137
+ row_number = 0
138
+
139
+ # Get total rows for progress tracking
140
+ first_row = spreadsheet.first_row
141
+ last_row = spreadsheet.last_row
142
+ return [] unless first_row && last_row && first_row <= last_row
143
+
144
+ total_rows = last_row - first_row + 1
145
+ total_rows -= 1 if options[:skip_header] && total_rows.positive?
146
+
147
+ # Read header row if skip_header is true
148
+ header_row = nil
149
+ if options[:skip_header] && first_row
150
+ header_data = spreadsheet.row(first_row)
151
+ # Handle different return types from Roo (including Proc/lambda)
152
+ header_row = if header_data.is_a?(Array)
153
+ header_data
154
+ elsif header_data.is_a?(Proc)
155
+ header_data.call
156
+ elsif header_data.respond_to?(:to_a)
157
+ header_data.to_a
158
+ elsif header_data.respond_to?(:to_ary)
159
+ header_data.to_ary
160
+ else
161
+ # Fallback: try to convert to array
162
+ [header_data].flatten
163
+ end
164
+ row_number = 1 # Start from row 2 (after header)
165
+ end
166
+
167
+ # Process data rows
168
+ start_row = options[:skip_header] ? (first_row + 1) : first_row
169
+ return [] unless start_row && last_row && start_row <= last_row
170
+
171
+ (start_row..last_row).each do |row_index|
172
+ row_number += 1
173
+ row_data_raw = spreadsheet.row(row_index)
174
+ # Handle different return types from Roo (including Proc/lambda)
175
+ row_data = if row_data_raw.is_a?(Array)
176
+ row_data_raw
177
+ elsif row_data_raw.is_a?(Proc)
178
+ row_data_raw.call
179
+ elsif row_data_raw.respond_to?(:to_a)
180
+ row_data_raw.to_a
181
+ elsif row_data_raw.respond_to?(:to_ary)
182
+ row_data_raw.to_ary
183
+ else
184
+ # Fallback: try to convert to array
185
+ [row_data_raw].flatten
186
+ end
187
+
188
+ begin
189
+ # Convert row data to hash using headers
190
+ row_hash = if header_row
191
+ header_row.each_with_index.to_h { |header, idx| [header.to_s, row_data[idx]] }
192
+ else
193
+ # Use numeric indices if no headers
194
+ row_data.each_with_index.to_h { |val, idx| [idx.to_s, val] }
195
+ end
196
+
197
+ scenario = parse_hash_row(row_hash, row_number, options)
198
+ scenarios << scenario if scenario
199
+ rescue StandardError => e
200
+ @errors << "Row #{row_number}: #{e.message}"
201
+ end
202
+
203
+ # Call progress callback if provided
204
+ next unless options[:progress_callback] && total_rows.positive?
205
+
206
+ processed = row_number - (options[:skip_header] ? 1 : 0)
207
+ options[:progress_callback].call(
208
+ processed: processed,
209
+ total: total_rows,
210
+ percentage: (processed.to_f / total_rows * 100).round(2)
211
+ )
212
+ end
213
+
214
+ raise ImportError, "Failed to import: #{@errors.join('; ')}" if @errors.any? && scenarios.empty?
215
+
216
+ scenarios
217
+ rescue Roo::HeaderRowNotFoundError => e
218
+ raise ImportError, "Excel file has no header row: #{e.message}"
219
+ rescue StandardError => e
220
+ raise ImportError, "Failed to read Excel file: #{e.message}"
221
+ end
222
+ end
223
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
224
+
225
+ # Import test scenarios from an array of hashes (for programmatic use)
226
+ # @param data [Array<Hash>] Array of hashes with test data
227
+ # @param options [Hash] Same as import_csv
228
+ # @return [Array<TestScenario>] Array of test scenarios
229
+ def import_from_array(data, options = {})
230
+ @errors = []
231
+ @warnings = []
232
+
233
+ options = {
234
+ id_column: "id",
235
+ expected_decision_column: "expected_decision",
236
+ expected_confidence_column: "expected_confidence"
237
+ }.merge(options)
238
+
239
+ scenarios = []
240
+ row_number = 0
241
+
242
+ data.each do |row|
243
+ row_number += 1
244
+ begin
245
+ scenario = parse_hash_row(row, row_number, options)
246
+ scenarios << scenario if scenario
247
+ rescue StandardError => e
248
+ @errors << "Row #{row_number}: #{e.message}"
249
+ end
250
+ end
251
+
252
+ raise ImportError, "Failed to import: #{@errors.join('; ')}" if @errors.any? && scenarios.empty?
253
+
254
+ scenarios
255
+ end
256
+
257
+ private
258
+
259
+ def parse_csv_row(row, row_number, options)
260
+ # Convert CSV::Row to hash
261
+ row_hash = row.to_h
262
+
263
+ # Extract ID
264
+ id = extract_value(row_hash, options[:id_column], row_number, required: true)
265
+
266
+ # Extract expected results (only if column names are provided)
267
+ expected_decision = nil
268
+ expected_confidence = nil
269
+
270
+ if options[:expected_decision_column]
271
+ expected_decision = extract_value(row_hash, options[:expected_decision_column], row_number, required: false)
272
+ end
273
+
274
+ if options[:expected_confidence_column]
275
+ expected_confidence = extract_value(row_hash, options[:expected_confidence_column], row_number, required: false)
276
+ end
277
+
278
+ # Build context from remaining columns
279
+ context_columns = options[:context_columns] || determine_context_columns(
280
+ row_hash.keys,
281
+ options[:id_column],
282
+ options[:expected_decision_column],
283
+ options[:expected_confidence_column]
284
+ )
285
+
286
+ context = {}
287
+ context_columns.each do |col|
288
+ next if col.nil?
289
+
290
+ context[col.to_sym] = row_hash[col] if row_hash.key?(col)
291
+ end
292
+
293
+ # Validate context is not empty
294
+ raise InvalidTestDataError.new("Context is empty", row_number: row_number) if context.empty?
295
+
296
+ # Parse expected_confidence as float if present
297
+ expected_confidence = expected_confidence.to_f if expected_confidence && !expected_confidence.to_s.strip.empty?
298
+
299
+ TestScenario.new(
300
+ id: id,
301
+ context: context,
302
+ expected_decision: expected_decision,
303
+ expected_confidence: expected_confidence,
304
+ metadata: { row_number: row_number }
305
+ )
306
+ end
307
+
308
+ def parse_hash_row(row, row_number, options)
309
+ # Ensure row is a hash
310
+ row_hash = row.is_a?(Hash) ? row : row.to_h
311
+
312
+ # Extract ID
313
+ id = extract_value(row_hash, options[:id_column], row_number, required: true)
314
+
315
+ # Extract expected results
316
+ expected_decision = extract_value(row_hash, options[:expected_decision_column], row_number, required: false)
317
+ expected_confidence = extract_value(row_hash, options[:expected_confidence_column], row_number, required: false)
318
+
319
+ # Build context from remaining keys
320
+ context_keys = row_hash.keys.reject do |key|
321
+ key_str = key.to_s
322
+ [options[:id_column], options[:expected_decision_column], options[:expected_confidence_column]].include?(key_str)
323
+ end
324
+
325
+ context = {}
326
+ context_keys.each do |key|
327
+ context[key.is_a?(Symbol) ? key : key.to_sym] = row_hash[key]
328
+ end
329
+
330
+ # Validate context is not empty
331
+ raise InvalidTestDataError.new("Context is empty", row_number: row_number) if context.empty?
332
+
333
+ # Parse expected_confidence as float if present
334
+ expected_confidence = expected_confidence.to_f if expected_confidence && !expected_confidence.to_s.strip.empty?
335
+
336
+ TestScenario.new(
337
+ id: id,
338
+ context: context,
339
+ expected_decision: expected_decision,
340
+ expected_confidence: expected_confidence,
341
+ metadata: { row_number: row_number }
342
+ )
343
+ end
344
+
345
+ def extract_value(row_hash, column_name, row_number, required: false)
346
+ # Try both string and symbol keys
347
+ value = row_hash[column_name] || row_hash[column_name.to_sym] || row_hash[column_name.to_s]
348
+
349
+ if required && (value.nil? || value.to_s.strip.empty?)
350
+ raise InvalidTestDataError.new("Missing required column: #{column_name}", row_number: row_number)
351
+ end
352
+
353
+ value
354
+ end
355
+
356
+ def determine_context_columns(all_columns, id_column, expected_decision_column, expected_confidence_column)
357
+ excluded = [id_column, expected_decision_column, expected_confidence_column].map(&:to_s)
358
+ all_columns.reject { |col| excluded.include?(col.to_s) }
359
+ end
360
+
361
+ def count_csv_rows(file_path, skip_header)
362
+ count = 0
363
+ CSV.foreach(file_path, headers: skip_header) do |_row|
364
+ count += 1
365
+ end
366
+ count
367
+ rescue StandardError
368
+ # If we can't count, return nil (progress tracking will be disabled)
369
+ nil
370
+ end
371
+ end
372
+ end
373
+ end