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.
- checksums.yaml +4 -4
- data/README.md +84 -233
- data/lib/decision_agent/ab_testing/ab_test.rb +197 -0
- data/lib/decision_agent/ab_testing/ab_test_assignment.rb +76 -0
- data/lib/decision_agent/ab_testing/ab_test_manager.rb +317 -0
- data/lib/decision_agent/ab_testing/ab_testing_agent.rb +188 -0
- data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +155 -0
- data/lib/decision_agent/ab_testing/storage/adapter.rb +67 -0
- data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +116 -0
- 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 +164 -7
- data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +253 -0
- data/lib/decision_agent/monitoring/storage/base_adapter.rb +90 -0
- data/lib/decision_agent/monitoring/storage/memory_adapter.rb +222 -0
- 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 +59 -0
- data/lib/generators/decision_agent/install/install_generator.rb +37 -0
- data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +45 -0
- data/lib/generators/decision_agent/install/templates/ab_test_model.rb +54 -0
- data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +43 -0
- data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +189 -0
- data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +114 -0
- data/lib/generators/decision_agent/install/templates/decision_log.rb +57 -0
- data/lib/generators/decision_agent/install/templates/error_metric.rb +53 -0
- data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +43 -0
- data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +109 -0
- data/lib/generators/decision_agent/install/templates/performance_metric.rb +76 -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 +612 -0
- data/spec/ab_testing/ab_test_spec.rb +270 -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 -548
- data/spec/issue_verification_spec.rb +95 -21
- data/spec/monitoring/metrics_collector_spec.rb +221 -3
- data/spec/monitoring/monitored_agent_spec.rb +1 -1
- data/spec/monitoring/prometheus_exporter_spec.rb +1 -1
- data/spec/monitoring/storage/activerecord_adapter_spec.rb +498 -0
- data/spec/monitoring/storage/base_adapter_spec.rb +61 -0
- data/spec/monitoring/storage/memory_adapter_spec.rb +247 -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 +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
|