decision_agent 0.1.3 ā 0.1.4
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/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 +152 -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/monitoring/metrics_collector.rb +148 -3
- 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/version.rb +1 -1
- data/lib/decision_agent.rb +7 -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/spec/ab_testing/ab_test_manager_spec.rb +330 -0
- data/spec/ab_testing/ab_test_spec.rb +270 -0
- data/spec/examples.txt +612 -548
- data/spec/issue_verification_spec.rb +95 -21
- data/spec/monitoring/metrics_collector_spec.rb +2 -2
- 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 +346 -0
- data/spec/monitoring/storage/memory_adapter_spec.rb +247 -0
- metadata +26 -2
|
@@ -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
|
data/lib/decision_agent.rb
CHANGED
|
@@ -36,5 +36,12 @@ require_relative "decision_agent/monitoring/alert_manager"
|
|
|
36
36
|
require_relative "decision_agent/monitoring/monitored_agent"
|
|
37
37
|
# dashboard_server has additional dependencies (faye/websocket) - require it explicitly when needed
|
|
38
38
|
|
|
39
|
+
require_relative "decision_agent/ab_testing/ab_test"
|
|
40
|
+
require_relative "decision_agent/ab_testing/ab_test_assignment"
|
|
41
|
+
require_relative "decision_agent/ab_testing/ab_test_manager"
|
|
42
|
+
require_relative "decision_agent/ab_testing/ab_testing_agent"
|
|
43
|
+
require_relative "decision_agent/ab_testing/storage/adapter"
|
|
44
|
+
require_relative "decision_agent/ab_testing/storage/memory_adapter"
|
|
45
|
+
|
|
39
46
|
module DecisionAgent
|
|
40
47
|
end
|
|
@@ -10,6 +10,14 @@ module DecisionAgent
|
|
|
10
10
|
|
|
11
11
|
desc "Installs DecisionAgent models and migrations for Rails"
|
|
12
12
|
|
|
13
|
+
class_option :monitoring, type: :boolean,
|
|
14
|
+
default: false,
|
|
15
|
+
desc: "Install monitoring tables and models for persistent metrics storage"
|
|
16
|
+
|
|
17
|
+
class_option :ab_testing, type: :boolean,
|
|
18
|
+
default: false,
|
|
19
|
+
desc: "Install A/B testing tables and models for variant testing"
|
|
20
|
+
|
|
13
21
|
def self.next_migration_number(dirname)
|
|
14
22
|
next_migration_number = current_migration_number(dirname) + 1
|
|
15
23
|
ActiveRecord::Migration.next_migration_number(next_migration_number)
|
|
@@ -19,11 +27,40 @@ module DecisionAgent
|
|
|
19
27
|
migration_template "migration.rb",
|
|
20
28
|
"db/migrate/create_decision_agent_tables.rb",
|
|
21
29
|
migration_version: migration_version
|
|
30
|
+
|
|
31
|
+
if options[:monitoring]
|
|
32
|
+
migration_template "monitoring_migration.rb",
|
|
33
|
+
"db/migrate/create_decision_agent_monitoring_tables.rb",
|
|
34
|
+
migration_version: migration_version
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
return unless options[:ab_testing]
|
|
38
|
+
|
|
39
|
+
migration_template "ab_testing_migration.rb",
|
|
40
|
+
"db/migrate/create_decision_agent_ab_testing_tables.rb",
|
|
41
|
+
migration_version: migration_version
|
|
22
42
|
end
|
|
23
43
|
|
|
24
44
|
def copy_models
|
|
25
45
|
copy_file "rule.rb", "app/models/rule.rb"
|
|
26
46
|
copy_file "rule_version.rb", "app/models/rule_version.rb"
|
|
47
|
+
|
|
48
|
+
if options[:monitoring]
|
|
49
|
+
copy_file "decision_log.rb", "app/models/decision_log.rb"
|
|
50
|
+
copy_file "evaluation_metric.rb", "app/models/evaluation_metric.rb"
|
|
51
|
+
copy_file "performance_metric.rb", "app/models/performance_metric.rb"
|
|
52
|
+
copy_file "error_metric.rb", "app/models/error_metric.rb"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
return unless options[:ab_testing]
|
|
56
|
+
|
|
57
|
+
copy_file "ab_test_model.rb", "app/models/ab_test_model.rb"
|
|
58
|
+
copy_file "ab_test_assignment_model.rb", "app/models/ab_test_assignment_model.rb"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def copy_rake_tasks
|
|
62
|
+
copy_file "decision_agent_tasks.rake", "lib/tasks/decision_agent.rake" if options[:monitoring]
|
|
63
|
+
copy_file "ab_testing_tasks.rake", "lib/tasks/ab_testing.rake" if options[:ab_testing]
|
|
27
64
|
end
|
|
28
65
|
|
|
29
66
|
def show_readme
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# A/B Test Assignment model for decision_agent gem
|
|
2
|
+
# Tracks individual variant assignments and their results
|
|
3
|
+
class ABTestAssignmentModel < ActiveRecord::Base
|
|
4
|
+
belongs_to :ab_test_model
|
|
5
|
+
|
|
6
|
+
validates :variant, presence: true, inclusion: { in: %w[champion challenger] }
|
|
7
|
+
validates :version_id, presence: true
|
|
8
|
+
validates :timestamp, presence: true
|
|
9
|
+
|
|
10
|
+
before_validation :set_defaults
|
|
11
|
+
|
|
12
|
+
# Scopes
|
|
13
|
+
scope :champion, -> { where(variant: "champion") }
|
|
14
|
+
scope :challenger, -> { where(variant: "challenger") }
|
|
15
|
+
scope :with_decisions, -> { where.not(decision_result: nil) }
|
|
16
|
+
scope :recent, -> { order(timestamp: :desc) }
|
|
17
|
+
scope :for_user, ->(user_id) { where(user_id: user_id) }
|
|
18
|
+
|
|
19
|
+
serialize :context, JSON
|
|
20
|
+
|
|
21
|
+
# Check if decision has been recorded
|
|
22
|
+
def decision_recorded?
|
|
23
|
+
decision_result.present?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Get the test this assignment belongs to
|
|
27
|
+
def test
|
|
28
|
+
ab_test_model
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Record decision result
|
|
32
|
+
def record_decision!(decision, confidence_score)
|
|
33
|
+
update!(
|
|
34
|
+
decision_result: decision,
|
|
35
|
+
confidence: confidence_score
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def set_defaults
|
|
42
|
+
self.timestamp ||= Time.current
|
|
43
|
+
self.context ||= {}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# A/B Test model for decision_agent gem
|
|
2
|
+
# Stores A/B test configurations
|
|
3
|
+
class ABTestModel < ActiveRecord::Base
|
|
4
|
+
has_many :ab_test_assignment_models, dependent: :destroy
|
|
5
|
+
|
|
6
|
+
validates :name, presence: true
|
|
7
|
+
validates :champion_version_id, presence: true
|
|
8
|
+
validates :challenger_version_id, presence: true
|
|
9
|
+
validates :status, presence: true, inclusion: { in: %w[scheduled running completed cancelled] }
|
|
10
|
+
|
|
11
|
+
serialize :traffic_split, JSON
|
|
12
|
+
|
|
13
|
+
before_validation :set_defaults
|
|
14
|
+
|
|
15
|
+
# Scopes
|
|
16
|
+
scope :active, -> { where(status: "running") }
|
|
17
|
+
scope :scheduled, -> { where(status: "scheduled") }
|
|
18
|
+
scope :completed, -> { where(status: "completed") }
|
|
19
|
+
scope :running_or_scheduled, -> { where(status: %w[running scheduled]) }
|
|
20
|
+
|
|
21
|
+
# Check if test is currently running
|
|
22
|
+
def running?
|
|
23
|
+
status == "running" &&
|
|
24
|
+
(start_date.nil? || start_date <= Time.current) &&
|
|
25
|
+
(end_date.nil? || end_date > Time.current)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Get statistics for this test
|
|
29
|
+
def statistics
|
|
30
|
+
{
|
|
31
|
+
total_assignments: ab_test_assignment_models.count,
|
|
32
|
+
champion_count: ab_test_assignment_models.where(variant: "champion").count,
|
|
33
|
+
challenger_count: ab_test_assignment_models.where(variant: "challenger").count,
|
|
34
|
+
with_decisions: ab_test_assignment_models.where.not(decision_result: nil).count,
|
|
35
|
+
avg_confidence: ab_test_assignment_models.where.not(confidence: nil).average(:confidence)&.to_f
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Get assignments by variant
|
|
40
|
+
def champion_assignments
|
|
41
|
+
ab_test_assignment_models.where(variant: "champion")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def challenger_assignments
|
|
45
|
+
ab_test_assignment_models.where(variant: "challenger")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def set_defaults
|
|
51
|
+
self.traffic_split ||= { champion: 90, challenger: 10 }
|
|
52
|
+
self.status ||= "scheduled"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
class CreateDecisionAgentABTestingTables < ActiveRecord::Migration[7.0]
|
|
2
|
+
def change
|
|
3
|
+
# A/B Tests table
|
|
4
|
+
create_table :ab_test_models do |t|
|
|
5
|
+
t.string :name, null: false
|
|
6
|
+
t.string :champion_version_id, null: false
|
|
7
|
+
t.string :challenger_version_id, null: false
|
|
8
|
+
t.text :traffic_split, null: false # JSON: { champion: 90, challenger: 10 }
|
|
9
|
+
t.datetime :start_date
|
|
10
|
+
t.datetime :end_date
|
|
11
|
+
t.string :status, null: false, default: "scheduled" # scheduled, running, completed, cancelled
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
add_index :ab_test_models, :status
|
|
16
|
+
add_index :ab_test_models, :start_date
|
|
17
|
+
add_index :ab_test_models, %i[status start_date], name: "index_ab_tests_on_status_and_start_date"
|
|
18
|
+
|
|
19
|
+
# A/B Test Assignments table
|
|
20
|
+
create_table :ab_test_assignment_models do |t|
|
|
21
|
+
t.references :ab_test_model, null: false, foreign_key: true, index: true
|
|
22
|
+
t.string :user_id # Optional: for consistent assignment to same users
|
|
23
|
+
t.string :variant, null: false # "champion" or "challenger"
|
|
24
|
+
t.string :version_id, null: false
|
|
25
|
+
t.datetime :timestamp, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
26
|
+
|
|
27
|
+
# Decision results (populated after decision is made)
|
|
28
|
+
t.string :decision_result
|
|
29
|
+
t.float :confidence
|
|
30
|
+
t.text :context # JSON: additional context
|
|
31
|
+
|
|
32
|
+
t.timestamps
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
add_index :ab_test_assignment_models, :user_id
|
|
36
|
+
add_index :ab_test_assignment_models, :variant
|
|
37
|
+
add_index :ab_test_assignment_models, :timestamp
|
|
38
|
+
add_index :ab_test_assignment_models, %i[ab_test_model_id variant], name: "index_assignments_on_test_and_variant"
|
|
39
|
+
|
|
40
|
+
# Optional: Index for querying assignments with decisions
|
|
41
|
+
add_index :ab_test_assignment_models, :decision_result, where: "decision_result IS NOT NULL", name: "index_assignments_with_decisions"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# rubocop:disable Metrics/BlockLength
|
|
2
|
+
namespace :decision_agent do
|
|
3
|
+
namespace :ab_testing do
|
|
4
|
+
desc "List all A/B tests"
|
|
5
|
+
task list: :environment do
|
|
6
|
+
require "decision_agent/ab_testing/ab_test_manager"
|
|
7
|
+
|
|
8
|
+
manager = DecisionAgent::ABTesting::ABTestManager.new(
|
|
9
|
+
storage_adapter: DecisionAgent::ABTesting::Storage::ActiveRecordAdapter.new
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
tests = manager.list_tests
|
|
13
|
+
puts "\nš A/B Tests (Total: #{tests.size})\n"
|
|
14
|
+
puts "=" * 80
|
|
15
|
+
|
|
16
|
+
if tests.empty?
|
|
17
|
+
puts "No A/B tests found."
|
|
18
|
+
else
|
|
19
|
+
tests.each do |test|
|
|
20
|
+
puts "\nID: #{test.id}"
|
|
21
|
+
puts "Name: #{test.name}"
|
|
22
|
+
puts "Status: #{test.status}"
|
|
23
|
+
puts "Champion Version: #{test.champion_version_id}"
|
|
24
|
+
puts "Challenger Version: #{test.challenger_version_id}"
|
|
25
|
+
puts "Traffic Split: #{test.traffic_split[:champion]}% / #{test.traffic_split[:challenger]}%"
|
|
26
|
+
puts "Start Date: #{test.start_date}"
|
|
27
|
+
puts "End Date: #{test.end_date || 'N/A'}"
|
|
28
|
+
puts "-" * 80
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
desc "Show A/B test results - Usage: rake decision_agent:ab_testing:results[test_id]"
|
|
34
|
+
task :results, [:test_id] => :environment do |_t, args|
|
|
35
|
+
require "decision_agent/ab_testing/ab_test_manager"
|
|
36
|
+
|
|
37
|
+
test_id = args[:test_id]
|
|
38
|
+
raise "Test ID is required. Usage: rake decision_agent:ab_testing:results[123]" unless test_id
|
|
39
|
+
|
|
40
|
+
manager = DecisionAgent::ABTesting::ABTestManager.new(
|
|
41
|
+
storage_adapter: DecisionAgent::ABTesting::Storage::ActiveRecordAdapter.new
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
results = manager.get_results(test_id)
|
|
45
|
+
|
|
46
|
+
puts "\nš A/B Test Results"
|
|
47
|
+
puts "=" * 80
|
|
48
|
+
puts "\nTest: #{results[:test][:name]}"
|
|
49
|
+
puts "Status: #{results[:test][:status]}"
|
|
50
|
+
puts "Total Assignments: #{results[:total_assignments]}"
|
|
51
|
+
puts ""
|
|
52
|
+
|
|
53
|
+
puts "š Champion (Version #{results[:test][:champion_version_id]})"
|
|
54
|
+
puts " Assignments: #{results[:champion][:total_assignments]}"
|
|
55
|
+
puts " Decisions Recorded: #{results[:champion][:decisions_recorded]}"
|
|
56
|
+
if results[:champion][:avg_confidence]
|
|
57
|
+
puts " Avg Confidence: #{results[:champion][:avg_confidence]}"
|
|
58
|
+
puts " Min/Max Confidence: #{results[:champion][:min_confidence]} / #{results[:champion][:max_confidence]}"
|
|
59
|
+
end
|
|
60
|
+
puts ""
|
|
61
|
+
|
|
62
|
+
puts "š Challenger (Version #{results[:test][:challenger_version_id]})"
|
|
63
|
+
puts " Assignments: #{results[:challenger][:total_assignments]}"
|
|
64
|
+
puts " Decisions Recorded: #{results[:challenger][:decisions_recorded]}"
|
|
65
|
+
if results[:challenger][:avg_confidence]
|
|
66
|
+
puts " Avg Confidence: #{results[:challenger][:avg_confidence]}"
|
|
67
|
+
puts " Min/Max Confidence: #{results[:challenger][:min_confidence]} / #{results[:challenger][:max_confidence]}"
|
|
68
|
+
end
|
|
69
|
+
puts ""
|
|
70
|
+
|
|
71
|
+
if results[:comparison][:statistical_significance] == "insufficient_data"
|
|
72
|
+
puts "ā ļø Insufficient data for statistical comparison"
|
|
73
|
+
else
|
|
74
|
+
puts "š Statistical Comparison"
|
|
75
|
+
puts " Improvement: #{results[:comparison][:improvement_percentage]}%"
|
|
76
|
+
puts " Winner: #{results[:comparison][:winner]}"
|
|
77
|
+
puts " Statistical Significance: #{results[:comparison][:statistical_significance]}"
|
|
78
|
+
puts " Confidence Level: #{(results[:comparison][:confidence_level] * 100).round(0)}%"
|
|
79
|
+
puts ""
|
|
80
|
+
puts "š” Recommendation: #{results[:comparison][:recommendation]}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
puts "=" * 80
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
desc "Start an A/B test - Usage: rake decision_agent:ab_testing:start[test_id]"
|
|
87
|
+
task :start, [:test_id] => :environment do |_t, args|
|
|
88
|
+
require "decision_agent/ab_testing/ab_test_manager"
|
|
89
|
+
|
|
90
|
+
test_id = args[:test_id]
|
|
91
|
+
raise "Test ID is required. Usage: rake decision_agent:ab_testing:start[123]" unless test_id
|
|
92
|
+
|
|
93
|
+
manager = DecisionAgent::ABTesting::ABTestManager.new(
|
|
94
|
+
storage_adapter: DecisionAgent::ABTesting::Storage::ActiveRecordAdapter.new
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
manager.start_test(test_id)
|
|
98
|
+
puts "ā
A/B test #{test_id} started successfully!"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
desc "Complete an A/B test - Usage: rake decision_agent:ab_testing:complete[test_id]"
|
|
102
|
+
task :complete, [:test_id] => :environment do |_t, args|
|
|
103
|
+
require "decision_agent/ab_testing/ab_test_manager"
|
|
104
|
+
|
|
105
|
+
test_id = args[:test_id]
|
|
106
|
+
raise "Test ID is required. Usage: rake decision_agent:ab_testing:complete[123]" unless test_id
|
|
107
|
+
|
|
108
|
+
manager = DecisionAgent::ABTesting::ABTestManager.new(
|
|
109
|
+
storage_adapter: DecisionAgent::ABTesting::Storage::ActiveRecordAdapter.new
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
manager.complete_test(test_id)
|
|
113
|
+
puts "ā
A/B test #{test_id} completed successfully!"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
desc "Cancel an A/B test - Usage: rake decision_agent:ab_testing:cancel[test_id]"
|
|
117
|
+
task :cancel, [:test_id] => :environment do |_t, args|
|
|
118
|
+
require "decision_agent/ab_testing/ab_test_manager"
|
|
119
|
+
|
|
120
|
+
test_id = args[:test_id]
|
|
121
|
+
raise "Test ID is required. Usage: rake decision_agent:ab_testing:cancel[123]" unless test_id
|
|
122
|
+
|
|
123
|
+
manager = DecisionAgent::ABTesting::ABTestManager.new(
|
|
124
|
+
storage_adapter: DecisionAgent::ABTesting::Storage::ActiveRecordAdapter.new
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
manager.cancel_test(test_id)
|
|
128
|
+
puts "ā
A/B test #{test_id} cancelled successfully!"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
desc "Create a new A/B test - Usage: rake decision_agent:ab_testing:create[name,champion_id,challenger_id,split]"
|
|
132
|
+
task :create, %i[name champion_id challenger_id split] => :environment do |_t, args|
|
|
133
|
+
require "decision_agent/ab_testing/ab_test_manager"
|
|
134
|
+
|
|
135
|
+
name = args[:name]
|
|
136
|
+
champion_id = args[:champion_id]
|
|
137
|
+
challenger_id = args[:challenger_id]
|
|
138
|
+
split = args[:split] || "90,10"
|
|
139
|
+
|
|
140
|
+
unless name && champion_id && challenger_id
|
|
141
|
+
raise "Missing arguments. Usage: rake decision_agent:ab_testing:create[name,champion_id,challenger_id,split]"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
champion_pct, challenger_pct = split.split(",").map(&:to_i)
|
|
145
|
+
|
|
146
|
+
manager = DecisionAgent::ABTesting::ABTestManager.new(
|
|
147
|
+
storage_adapter: DecisionAgent::ABTesting::Storage::ActiveRecordAdapter.new
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
test = manager.create_test(
|
|
151
|
+
name: name,
|
|
152
|
+
champion_version_id: champion_id,
|
|
153
|
+
challenger_version_id: challenger_id,
|
|
154
|
+
traffic_split: { champion: champion_pct, challenger: challenger_pct }
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
puts "ā
A/B test created successfully!"
|
|
158
|
+
puts " ID: #{test.id}"
|
|
159
|
+
puts " Name: #{test.name}"
|
|
160
|
+
puts " Status: #{test.status}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
desc "Show active A/B tests"
|
|
164
|
+
task active: :environment do
|
|
165
|
+
require "decision_agent/ab_testing/ab_test_manager"
|
|
166
|
+
|
|
167
|
+
manager = DecisionAgent::ABTesting::ABTestManager.new(
|
|
168
|
+
storage_adapter: DecisionAgent::ABTesting::Storage::ActiveRecordAdapter.new
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
tests = manager.active_tests
|
|
172
|
+
puts "\nš Active A/B Tests (Total: #{tests.size})\n"
|
|
173
|
+
puts "=" * 80
|
|
174
|
+
|
|
175
|
+
if tests.empty?
|
|
176
|
+
puts "No active A/B tests found."
|
|
177
|
+
else
|
|
178
|
+
tests.each do |test|
|
|
179
|
+
puts "\nID: #{test.id}"
|
|
180
|
+
puts "Name: #{test.name}"
|
|
181
|
+
puts "Champion vs Challenger: #{test.champion_version_id} vs #{test.challenger_version_id}"
|
|
182
|
+
puts "Traffic Split: #{test.traffic_split[:champion]}% / #{test.traffic_split[:challenger]}%"
|
|
183
|
+
puts "-" * 80
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
# rubocop:enable Metrics/BlockLength
|