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.
- checksums.yaml +4 -4
- data/README.md +84 -233
- data/lib/decision_agent/ab_testing/ab_testing_agent.rb +46 -10
- data/lib/decision_agent/agent.rb +5 -3
- data/lib/decision_agent/auth/access_audit_logger.rb +122 -0
- data/lib/decision_agent/auth/authenticator.rb +127 -0
- data/lib/decision_agent/auth/password_reset_manager.rb +57 -0
- data/lib/decision_agent/auth/password_reset_token.rb +33 -0
- data/lib/decision_agent/auth/permission.rb +29 -0
- data/lib/decision_agent/auth/permission_checker.rb +43 -0
- data/lib/decision_agent/auth/rbac_adapter.rb +278 -0
- data/lib/decision_agent/auth/rbac_config.rb +51 -0
- data/lib/decision_agent/auth/role.rb +56 -0
- data/lib/decision_agent/auth/session.rb +33 -0
- data/lib/decision_agent/auth/session_manager.rb +57 -0
- data/lib/decision_agent/auth/user.rb +70 -0
- data/lib/decision_agent/context.rb +24 -4
- data/lib/decision_agent/decision.rb +10 -3
- data/lib/decision_agent/dsl/condition_evaluator.rb +378 -1
- data/lib/decision_agent/dsl/schema_validator.rb +8 -1
- data/lib/decision_agent/errors.rb +38 -0
- data/lib/decision_agent/evaluation.rb +10 -3
- data/lib/decision_agent/evaluation_validator.rb +8 -13
- data/lib/decision_agent/monitoring/dashboard_server.rb +1 -0
- data/lib/decision_agent/monitoring/metrics_collector.rb +17 -5
- data/lib/decision_agent/testing/batch_test_importer.rb +373 -0
- data/lib/decision_agent/testing/batch_test_runner.rb +244 -0
- data/lib/decision_agent/testing/test_coverage_analyzer.rb +191 -0
- data/lib/decision_agent/testing/test_result_comparator.rb +235 -0
- data/lib/decision_agent/testing/test_scenario.rb +42 -0
- data/lib/decision_agent/version.rb +10 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +1 -1
- data/lib/decision_agent/versioning/file_storage_adapter.rb +96 -28
- data/lib/decision_agent/web/middleware/auth_middleware.rb +45 -0
- data/lib/decision_agent/web/middleware/permission_middleware.rb +94 -0
- data/lib/decision_agent/web/public/app.js +184 -29
- data/lib/decision_agent/web/public/batch_testing.html +640 -0
- data/lib/decision_agent/web/public/index.html +37 -9
- data/lib/decision_agent/web/public/login.html +298 -0
- data/lib/decision_agent/web/public/users.html +679 -0
- data/lib/decision_agent/web/server.rb +873 -7
- data/lib/decision_agent.rb +52 -0
- data/lib/generators/decision_agent/install/templates/rule_version.rb +1 -1
- data/spec/ab_testing/ab_test_assignment_spec.rb +253 -0
- data/spec/ab_testing/ab_test_manager_spec.rb +282 -0
- data/spec/ab_testing/ab_testing_agent_spec.rb +481 -0
- data/spec/ab_testing/storage/adapter_spec.rb +64 -0
- data/spec/ab_testing/storage/memory_adapter_spec.rb +485 -0
- data/spec/advanced_operators_spec.rb +1003 -0
- data/spec/agent_spec.rb +40 -0
- data/spec/audit_adapters_spec.rb +18 -0
- data/spec/auth/access_audit_logger_spec.rb +394 -0
- data/spec/auth/authenticator_spec.rb +112 -0
- data/spec/auth/password_reset_spec.rb +294 -0
- data/spec/auth/permission_checker_spec.rb +207 -0
- data/spec/auth/permission_spec.rb +73 -0
- data/spec/auth/rbac_adapter_spec.rb +550 -0
- data/spec/auth/rbac_config_spec.rb +82 -0
- data/spec/auth/role_spec.rb +51 -0
- data/spec/auth/session_manager_spec.rb +172 -0
- data/spec/auth/session_spec.rb +112 -0
- data/spec/auth/user_spec.rb +130 -0
- data/spec/context_spec.rb +43 -0
- data/spec/decision_agent_spec.rb +96 -0
- data/spec/decision_spec.rb +423 -0
- data/spec/dsl/condition_evaluator_spec.rb +774 -0
- data/spec/evaluation_spec.rb +364 -0
- data/spec/evaluation_validator_spec.rb +165 -0
- data/spec/examples.txt +1542 -612
- data/spec/monitoring/metrics_collector_spec.rb +220 -2
- data/spec/monitoring/storage/activerecord_adapter_spec.rb +153 -1
- data/spec/monitoring/storage/base_adapter_spec.rb +61 -0
- data/spec/performance_optimizations_spec.rb +486 -0
- data/spec/spec_helper.rb +23 -0
- data/spec/testing/batch_test_importer_spec.rb +693 -0
- data/spec/testing/batch_test_runner_spec.rb +307 -0
- data/spec/testing/test_coverage_analyzer_spec.rb +292 -0
- data/spec/testing/test_result_comparator_spec.rb +392 -0
- data/spec/testing/test_scenario_spec.rb +113 -0
- data/spec/versioning/adapter_spec.rb +156 -0
- data/spec/versioning_spec.rb +253 -0
- data/spec/web/middleware/auth_middleware_spec.rb +133 -0
- data/spec/web/middleware/permission_middleware_spec.rb +247 -0
- data/spec/web_ui_rack_spec.rb +1705 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|