decision_agent 0.1.3 → 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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +84 -233
  3. data/lib/decision_agent/ab_testing/ab_test.rb +197 -0
  4. data/lib/decision_agent/ab_testing/ab_test_assignment.rb +76 -0
  5. data/lib/decision_agent/ab_testing/ab_test_manager.rb +317 -0
  6. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +188 -0
  7. data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +155 -0
  8. data/lib/decision_agent/ab_testing/storage/adapter.rb +67 -0
  9. data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +116 -0
  10. data/lib/decision_agent/agent.rb +5 -3
  11. data/lib/decision_agent/auth/access_audit_logger.rb +122 -0
  12. data/lib/decision_agent/auth/authenticator.rb +127 -0
  13. data/lib/decision_agent/auth/password_reset_manager.rb +57 -0
  14. data/lib/decision_agent/auth/password_reset_token.rb +33 -0
  15. data/lib/decision_agent/auth/permission.rb +29 -0
  16. data/lib/decision_agent/auth/permission_checker.rb +43 -0
  17. data/lib/decision_agent/auth/rbac_adapter.rb +278 -0
  18. data/lib/decision_agent/auth/rbac_config.rb +51 -0
  19. data/lib/decision_agent/auth/role.rb +56 -0
  20. data/lib/decision_agent/auth/session.rb +33 -0
  21. data/lib/decision_agent/auth/session_manager.rb +57 -0
  22. data/lib/decision_agent/auth/user.rb +70 -0
  23. data/lib/decision_agent/context.rb +24 -4
  24. data/lib/decision_agent/decision.rb +10 -3
  25. data/lib/decision_agent/dsl/condition_evaluator.rb +378 -1
  26. data/lib/decision_agent/dsl/schema_validator.rb +8 -1
  27. data/lib/decision_agent/errors.rb +38 -0
  28. data/lib/decision_agent/evaluation.rb +10 -3
  29. data/lib/decision_agent/evaluation_validator.rb +8 -13
  30. data/lib/decision_agent/monitoring/dashboard_server.rb +1 -0
  31. data/lib/decision_agent/monitoring/metrics_collector.rb +164 -7
  32. data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +253 -0
  33. data/lib/decision_agent/monitoring/storage/base_adapter.rb +90 -0
  34. data/lib/decision_agent/monitoring/storage/memory_adapter.rb +222 -0
  35. data/lib/decision_agent/testing/batch_test_importer.rb +373 -0
  36. data/lib/decision_agent/testing/batch_test_runner.rb +244 -0
  37. data/lib/decision_agent/testing/test_coverage_analyzer.rb +191 -0
  38. data/lib/decision_agent/testing/test_result_comparator.rb +235 -0
  39. data/lib/decision_agent/testing/test_scenario.rb +42 -0
  40. data/lib/decision_agent/version.rb +10 -1
  41. data/lib/decision_agent/versioning/activerecord_adapter.rb +1 -1
  42. data/lib/decision_agent/versioning/file_storage_adapter.rb +96 -28
  43. data/lib/decision_agent/web/middleware/auth_middleware.rb +45 -0
  44. data/lib/decision_agent/web/middleware/permission_middleware.rb +94 -0
  45. data/lib/decision_agent/web/public/app.js +184 -29
  46. data/lib/decision_agent/web/public/batch_testing.html +640 -0
  47. data/lib/decision_agent/web/public/index.html +37 -9
  48. data/lib/decision_agent/web/public/login.html +298 -0
  49. data/lib/decision_agent/web/public/users.html +679 -0
  50. data/lib/decision_agent/web/server.rb +873 -7
  51. data/lib/decision_agent.rb +59 -0
  52. data/lib/generators/decision_agent/install/install_generator.rb +37 -0
  53. data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +45 -0
  54. data/lib/generators/decision_agent/install/templates/ab_test_model.rb +54 -0
  55. data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +43 -0
  56. data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +189 -0
  57. data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +114 -0
  58. data/lib/generators/decision_agent/install/templates/decision_log.rb +57 -0
  59. data/lib/generators/decision_agent/install/templates/error_metric.rb +53 -0
  60. data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +43 -0
  61. data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +109 -0
  62. data/lib/generators/decision_agent/install/templates/performance_metric.rb +76 -0
  63. data/lib/generators/decision_agent/install/templates/rule_version.rb +1 -1
  64. data/spec/ab_testing/ab_test_assignment_spec.rb +253 -0
  65. data/spec/ab_testing/ab_test_manager_spec.rb +612 -0
  66. data/spec/ab_testing/ab_test_spec.rb +270 -0
  67. data/spec/ab_testing/ab_testing_agent_spec.rb +481 -0
  68. data/spec/ab_testing/storage/adapter_spec.rb +64 -0
  69. data/spec/ab_testing/storage/memory_adapter_spec.rb +485 -0
  70. data/spec/advanced_operators_spec.rb +1003 -0
  71. data/spec/agent_spec.rb +40 -0
  72. data/spec/audit_adapters_spec.rb +18 -0
  73. data/spec/auth/access_audit_logger_spec.rb +394 -0
  74. data/spec/auth/authenticator_spec.rb +112 -0
  75. data/spec/auth/password_reset_spec.rb +294 -0
  76. data/spec/auth/permission_checker_spec.rb +207 -0
  77. data/spec/auth/permission_spec.rb +73 -0
  78. data/spec/auth/rbac_adapter_spec.rb +550 -0
  79. data/spec/auth/rbac_config_spec.rb +82 -0
  80. data/spec/auth/role_spec.rb +51 -0
  81. data/spec/auth/session_manager_spec.rb +172 -0
  82. data/spec/auth/session_spec.rb +112 -0
  83. data/spec/auth/user_spec.rb +130 -0
  84. data/spec/context_spec.rb +43 -0
  85. data/spec/decision_agent_spec.rb +96 -0
  86. data/spec/decision_spec.rb +423 -0
  87. data/spec/dsl/condition_evaluator_spec.rb +774 -0
  88. data/spec/evaluation_spec.rb +364 -0
  89. data/spec/evaluation_validator_spec.rb +165 -0
  90. data/spec/examples.txt +1542 -548
  91. data/spec/issue_verification_spec.rb +95 -21
  92. data/spec/monitoring/metrics_collector_spec.rb +221 -3
  93. data/spec/monitoring/monitored_agent_spec.rb +1 -1
  94. data/spec/monitoring/prometheus_exporter_spec.rb +1 -1
  95. data/spec/monitoring/storage/activerecord_adapter_spec.rb +498 -0
  96. data/spec/monitoring/storage/base_adapter_spec.rb +61 -0
  97. data/spec/monitoring/storage/memory_adapter_spec.rb +247 -0
  98. data/spec/performance_optimizations_spec.rb +486 -0
  99. data/spec/spec_helper.rb +23 -0
  100. data/spec/testing/batch_test_importer_spec.rb +693 -0
  101. data/spec/testing/batch_test_runner_spec.rb +307 -0
  102. data/spec/testing/test_coverage_analyzer_spec.rb +292 -0
  103. data/spec/testing/test_result_comparator_spec.rb +392 -0
  104. data/spec/testing/test_scenario_spec.rb +113 -0
  105. data/spec/versioning/adapter_spec.rb +156 -0
  106. data/spec/versioning_spec.rb +253 -0
  107. data/spec/web/middleware/auth_middleware_spec.rb +133 -0
  108. data/spec/web/middleware/permission_middleware_spec.rb +247 -0
  109. data/spec/web_ui_rack_spec.rb +1705 -0
  110. metadata +123 -6
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_adapter"
4
+ require "monitor"
5
+
6
+ module DecisionAgent
7
+ module Monitoring
8
+ module Storage
9
+ # In-memory adapter for metrics storage (default, no dependencies)
10
+ class MemoryAdapter < BaseAdapter
11
+ include MonitorMixin
12
+
13
+ def initialize(window_size: 3600)
14
+ super()
15
+ @window_size = window_size
16
+ @metrics = {
17
+ decisions: [],
18
+ evaluations: [],
19
+ performance: [],
20
+ errors: []
21
+ }
22
+ end
23
+
24
+ def record_decision(decision, context, confidence: nil, evaluations_count: 0, duration_ms: nil, status: nil)
25
+ synchronize do
26
+ @metrics[:decisions] << {
27
+ decision: decision,
28
+ context: context,
29
+ confidence: confidence,
30
+ evaluations_count: evaluations_count,
31
+ duration_ms: duration_ms,
32
+ status: status,
33
+ timestamp: Time.now
34
+ }
35
+ cleanup_old_metrics
36
+ end
37
+ end
38
+
39
+ def record_evaluation(evaluator_name, score: nil, success: nil, duration_ms: nil, details: {})
40
+ synchronize do
41
+ @metrics[:evaluations] << {
42
+ evaluator_name: evaluator_name,
43
+ score: score,
44
+ success: success,
45
+ duration_ms: duration_ms,
46
+ details: details,
47
+ timestamp: Time.now
48
+ }
49
+ cleanup_old_metrics
50
+ end
51
+ end
52
+
53
+ def record_performance(operation, duration_ms: nil, status: nil, metadata: {})
54
+ synchronize do
55
+ @metrics[:performance] << {
56
+ operation: operation,
57
+ duration_ms: duration_ms,
58
+ status: status,
59
+ metadata: metadata,
60
+ timestamp: Time.now
61
+ }
62
+ cleanup_old_metrics
63
+ end
64
+ end
65
+
66
+ def record_error(error_type, message: nil, stack_trace: nil, severity: nil, context: {})
67
+ synchronize do
68
+ @metrics[:errors] << {
69
+ error_type: error_type,
70
+ message: message,
71
+ stack_trace: stack_trace,
72
+ severity: severity,
73
+ context: context,
74
+ timestamp: Time.now
75
+ }
76
+ cleanup_old_metrics
77
+ end
78
+ end
79
+
80
+ def statistics(time_range: 3600)
81
+ synchronize do
82
+ cutoff = Time.now - time_range
83
+ recent_decisions = @metrics[:decisions].select { |m| m[:timestamp] >= cutoff }
84
+ recent_evaluations = @metrics[:evaluations].select { |m| m[:timestamp] >= cutoff }
85
+ recent_performance = @metrics[:performance].select { |m| m[:timestamp] >= cutoff }
86
+ recent_errors = @metrics[:errors].select { |m| m[:timestamp] >= cutoff }
87
+
88
+ {
89
+ decisions: decision_statistics(recent_decisions),
90
+ evaluations: evaluation_statistics(recent_evaluations),
91
+ performance: performance_statistics(recent_performance),
92
+ errors: error_statistics(recent_errors)
93
+ }
94
+ end
95
+ end
96
+
97
+ def time_series(metric_type, bucket_size: 60, time_range: 3600)
98
+ synchronize do
99
+ cutoff = Time.now - time_range
100
+ metrics = @metrics[metric_type].select { |m| m[:timestamp] >= cutoff }
101
+
102
+ buckets = Hash.new(0)
103
+ metrics.each do |metric|
104
+ bucket = (metric[:timestamp].to_i / bucket_size) * bucket_size
105
+ buckets[bucket] += 1
106
+ end
107
+
108
+ timestamps = buckets.keys.sort
109
+ {
110
+ timestamps: timestamps.map { |ts| Time.at(ts).iso8601 },
111
+ data: timestamps.map { |ts| buckets[ts] }
112
+ }
113
+ end
114
+ end
115
+
116
+ def metrics_count
117
+ synchronize do
118
+ {
119
+ decisions: @metrics[:decisions].size,
120
+ evaluations: @metrics[:evaluations].size,
121
+ performance: @metrics[:performance].size,
122
+ errors: @metrics[:errors].size
123
+ }
124
+ end
125
+ end
126
+
127
+ def cleanup(older_than:)
128
+ synchronize do
129
+ cutoff = Time.now - older_than
130
+ count = 0
131
+
132
+ @metrics.each_value do |metric_array|
133
+ before_size = metric_array.size
134
+ metric_array.reject! { |m| m[:timestamp] < cutoff }
135
+ count += before_size - metric_array.size
136
+ end
137
+
138
+ count
139
+ end
140
+ end
141
+
142
+ def self.available?
143
+ true # Always available, no dependencies
144
+ end
145
+
146
+ private
147
+
148
+ def cleanup_old_metrics
149
+ cutoff = Time.now - @window_size
150
+ @metrics.each_value do |metric_array|
151
+ metric_array.reject! { |m| m[:timestamp] < cutoff }
152
+ end
153
+ end
154
+
155
+ def decision_statistics(decisions)
156
+ total = decisions.size
157
+ confidences = decisions.map { |d| d[:confidence] }.compact
158
+ statuses = decisions.map { |d| d[:status] }.compact
159
+
160
+ {
161
+ total: total,
162
+ by_decision: decisions.group_by { |d| d[:decision] }.transform_values(&:count),
163
+ average_confidence: confidences.empty? ? 0.0 : confidences.sum / confidences.size.to_f,
164
+ success_rate: calculate_success_rate(statuses)
165
+ }
166
+ end
167
+
168
+ def evaluation_statistics(evaluations)
169
+ total = evaluations.size
170
+ scores = evaluations.map { |e| e[:score] }.compact
171
+
172
+ {
173
+ total: total,
174
+ by_evaluator: evaluations.group_by { |e| e[:evaluator_name] }.transform_values(&:count),
175
+ average_score: scores.empty? ? 0.0 : scores.sum / scores.size.to_f,
176
+ success_rate_by_evaluator: evaluations.select { |e| e[:success] }
177
+ .group_by { |e| e[:evaluator_name] }
178
+ .transform_values(&:count)
179
+ }
180
+ end
181
+
182
+ def performance_statistics(performance_metrics)
183
+ total = performance_metrics.size
184
+ durations = performance_metrics.map { |p| p[:duration_ms] }.compact.sort
185
+ statuses = performance_metrics.map { |p| p[:status] }.compact
186
+
187
+ {
188
+ total: total,
189
+ average_duration_ms: durations.empty? ? 0.0 : durations.sum / durations.size.to_f,
190
+ p50: percentile(durations, 0.50),
191
+ p95: percentile(durations, 0.95),
192
+ p99: percentile(durations, 0.99),
193
+ success_rate: calculate_success_rate(statuses)
194
+ }
195
+ end
196
+
197
+ def error_statistics(errors)
198
+ {
199
+ total: errors.size,
200
+ by_type: errors.group_by { |e| e[:error_type] }.transform_values(&:count),
201
+ by_severity: errors.group_by { |e| e[:severity] }.transform_values(&:count),
202
+ critical_count: errors.count { |e| e[:severity] == "critical" }
203
+ }
204
+ end
205
+
206
+ def percentile(sorted_array, pct)
207
+ return 0.0 if sorted_array.empty?
208
+
209
+ index = ((sorted_array.length - 1) * pct).ceil
210
+ sorted_array[index].to_f
211
+ end
212
+
213
+ def calculate_success_rate(statuses)
214
+ return 0.0 if statuses.empty?
215
+
216
+ successful = statuses.count { |s| s == "success" }
217
+ successful.to_f / statuses.size
218
+ end
219
+ end
220
+ end
221
+ end
222
+ end
@@ -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