decision_agent 0.1.2 → 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/README.md +212 -35
- data/bin/decision_agent +3 -8
- 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/agent.rb +19 -26
- data/lib/decision_agent/audit/null_adapter.rb +1 -2
- data/lib/decision_agent/decision.rb +3 -1
- data/lib/decision_agent/dsl/condition_evaluator.rb +4 -3
- data/lib/decision_agent/dsl/rule_parser.rb +4 -6
- data/lib/decision_agent/dsl/schema_validator.rb +27 -31
- data/lib/decision_agent/errors.rb +11 -8
- data/lib/decision_agent/evaluation.rb +3 -1
- data/lib/decision_agent/evaluation_validator.rb +78 -0
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +26 -0
- data/lib/decision_agent/evaluators/static_evaluator.rb +2 -6
- data/lib/decision_agent/monitoring/alert_manager.rb +282 -0
- data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +381 -0
- data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +471 -0
- data/lib/decision_agent/monitoring/dashboard/public/index.html +161 -0
- data/lib/decision_agent/monitoring/dashboard_server.rb +340 -0
- data/lib/decision_agent/monitoring/metrics_collector.rb +423 -0
- data/lib/decision_agent/monitoring/monitored_agent.rb +71 -0
- data/lib/decision_agent/monitoring/prometheus_exporter.rb +247 -0
- 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/replay/replay.rb +12 -22
- data/lib/decision_agent/scoring/base.rb +1 -1
- data/lib/decision_agent/scoring/consensus.rb +5 -5
- data/lib/decision_agent/scoring/weighted_average.rb +1 -1
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +69 -33
- data/lib/decision_agent/versioning/adapter.rb +1 -3
- data/lib/decision_agent/versioning/file_storage_adapter.rb +143 -35
- data/lib/decision_agent/versioning/version_manager.rb +4 -12
- data/lib/decision_agent/web/public/index.html +1 -1
- data/lib/decision_agent/web/server.rb +19 -24
- data/lib/decision_agent.rb +14 -0
- data/lib/generators/decision_agent/install/install_generator.rb +42 -5
- 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/migration.rb +17 -6
- 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.rb +3 -3
- data/lib/generators/decision_agent/install/templates/rule_version.rb +13 -7
- data/spec/ab_testing/ab_test_manager_spec.rb +330 -0
- data/spec/ab_testing/ab_test_spec.rb +270 -0
- data/spec/activerecord_thread_safety_spec.rb +553 -0
- data/spec/agent_spec.rb +13 -13
- data/spec/api_contract_spec.rb +16 -16
- data/spec/audit_adapters_spec.rb +3 -3
- data/spec/comprehensive_edge_cases_spec.rb +86 -86
- data/spec/dsl_validation_spec.rb +83 -83
- data/spec/edge_cases_spec.rb +23 -23
- data/spec/examples/feedback_aware_evaluator_spec.rb +7 -7
- data/spec/examples.txt +612 -0
- data/spec/issue_verification_spec.rb +759 -0
- data/spec/json_rule_evaluator_spec.rb +15 -15
- data/spec/monitoring/alert_manager_spec.rb +378 -0
- data/spec/monitoring/metrics_collector_spec.rb +281 -0
- data/spec/monitoring/monitored_agent_spec.rb +222 -0
- data/spec/monitoring/prometheus_exporter_spec.rb +242 -0
- data/spec/monitoring/storage/activerecord_adapter_spec.rb +346 -0
- data/spec/monitoring/storage/memory_adapter_spec.rb +247 -0
- data/spec/replay_edge_cases_spec.rb +58 -58
- data/spec/replay_spec.rb +11 -11
- data/spec/rfc8785_canonicalization_spec.rb +215 -0
- data/spec/scoring_spec.rb +1 -1
- data/spec/spec_helper.rb +9 -0
- data/spec/thread_safety_spec.rb +482 -0
- data/spec/thread_safety_spec.rb.broken +878 -0
- data/spec/versioning_spec.rb +141 -37
- data/spec/web_ui_rack_spec.rb +135 -0
- metadata +93 -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
|
|
@@ -42,15 +42,11 @@ module DecisionAgent
|
|
|
42
42
|
replayed_result
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
private
|
|
46
|
-
|
|
47
45
|
def self.validate_payload!(payload)
|
|
48
|
-
required_keys = [
|
|
46
|
+
required_keys = %w[context evaluations decision confidence]
|
|
49
47
|
|
|
50
48
|
required_keys.each do |key|
|
|
51
|
-
unless payload.key?(key) || payload.key?(key.to_sym)
|
|
52
|
-
raise InvalidRuleDslError, "Audit payload missing required key: #{key}"
|
|
53
|
-
end
|
|
49
|
+
raise InvalidRuleDslError, "Audit payload missing required key: #{key}" unless payload.key?(key) || payload.key?(key.to_sym)
|
|
54
50
|
end
|
|
55
51
|
end
|
|
56
52
|
|
|
@@ -111,30 +107,24 @@ module DecisionAgent
|
|
|
111
107
|
end
|
|
112
108
|
|
|
113
109
|
conf_diff = (original_confidence.to_f - replayed_confidence.to_f).abs
|
|
114
|
-
if conf_diff > 0.0001
|
|
115
|
-
differences << "confidence mismatch (expected: #{original_confidence}, got: #{replayed_confidence})"
|
|
116
|
-
end
|
|
110
|
+
differences << "confidence mismatch (expected: #{original_confidence}, got: #{replayed_confidence})" if conf_diff > 0.0001
|
|
117
111
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
112
|
+
return unless differences.any?
|
|
113
|
+
|
|
114
|
+
raise ReplayMismatchError.new(
|
|
115
|
+
expected: { decision: original_decision, confidence: original_confidence },
|
|
116
|
+
actual: { decision: replayed_decision, confidence: replayed_confidence },
|
|
117
|
+
differences: differences
|
|
118
|
+
)
|
|
125
119
|
end
|
|
126
120
|
|
|
127
121
|
def self.log_differences(original_decision:, original_confidence:, replayed_decision:, replayed_confidence:)
|
|
128
122
|
differences = []
|
|
129
123
|
|
|
130
|
-
if original_decision.to_s != replayed_decision.to_s
|
|
131
|
-
differences << "Decision changed: #{original_decision} -> #{replayed_decision}"
|
|
132
|
-
end
|
|
124
|
+
differences << "Decision changed: #{original_decision} -> #{replayed_decision}" if original_decision.to_s != replayed_decision.to_s
|
|
133
125
|
|
|
134
126
|
conf_diff = (original_confidence.to_f - replayed_confidence.to_f).abs
|
|
135
|
-
if conf_diff > 0.0001
|
|
136
|
-
differences << "Confidence changed: #{original_confidence} -> #{replayed_confidence}"
|
|
137
|
-
end
|
|
127
|
+
differences << "Confidence changed: #{original_confidence} -> #{replayed_confidence}" if conf_diff > 0.0001
|
|
138
128
|
|
|
139
129
|
if differences.any?
|
|
140
130
|
warn "[DecisionAgent::Replay] Non-strict mode differences detected:"
|
|
@@ -24,11 +24,11 @@ module DecisionAgent
|
|
|
24
24
|
|
|
25
25
|
winning_decision, agreement, avg_weight = candidates.first
|
|
26
26
|
|
|
27
|
-
if agreement >= @minimum_agreement
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
confidence = if agreement >= @minimum_agreement
|
|
28
|
+
agreement * avg_weight
|
|
29
|
+
else
|
|
30
|
+
agreement * avg_weight * 0.5
|
|
31
|
+
end
|
|
32
32
|
|
|
33
33
|
{
|
|
34
34
|
decision: winning_decision,
|
|
@@ -14,7 +14,7 @@ module DecisionAgent
|
|
|
14
14
|
winning_decision, winning_weight = weighted_scores.max_by { |_, weight| weight }
|
|
15
15
|
|
|
16
16
|
total_weight = evaluations.sum(&:weight)
|
|
17
|
-
confidence = total_weight
|
|
17
|
+
confidence = total_weight.positive? ? winning_weight / total_weight : 0.0
|
|
18
18
|
|
|
19
19
|
{
|
|
20
20
|
decision: winning_decision,
|
|
@@ -1,37 +1,53 @@
|
|
|
1
1
|
require_relative "adapter"
|
|
2
|
+
require_relative "file_storage_adapter"
|
|
2
3
|
|
|
3
4
|
module DecisionAgent
|
|
4
5
|
module Versioning
|
|
5
6
|
# ActiveRecord-based version storage adapter for Rails applications
|
|
6
7
|
# Requires ActiveRecord models to be set up in the Rails app
|
|
7
8
|
class ActiveRecordAdapter < Adapter
|
|
9
|
+
include StatusValidator
|
|
10
|
+
|
|
8
11
|
def initialize
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
return if defined?(ActiveRecord)
|
|
13
|
+
|
|
14
|
+
raise DecisionAgent::ConfigurationError,
|
|
15
|
+
"ActiveRecord is not available. Please ensure Rails/ActiveRecord is loaded."
|
|
13
16
|
end
|
|
14
17
|
|
|
15
18
|
def create_version(rule_id:, content:, metadata: {})
|
|
16
|
-
#
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
rule_version_class.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
19
|
+
# Use a transaction with pessimistic locking to prevent race conditions
|
|
20
|
+
version = nil
|
|
21
|
+
|
|
22
|
+
# Validate status if provided
|
|
23
|
+
status = metadata[:status] || "active"
|
|
24
|
+
validate_status!(status)
|
|
25
|
+
|
|
26
|
+
rule_version_class.transaction do
|
|
27
|
+
# Lock the last version for this rule to prevent concurrent reads
|
|
28
|
+
# This ensures only one thread can calculate the next version number at a time
|
|
29
|
+
last_version = rule_version_class.where(rule_id: rule_id)
|
|
30
|
+
.order(version_number: :desc)
|
|
31
|
+
.lock
|
|
32
|
+
.first
|
|
33
|
+
next_version_number = last_version ? last_version.version_number + 1 : 1
|
|
34
|
+
|
|
35
|
+
# Deactivate previous active versions
|
|
36
|
+
# Use update! instead of update_all to trigger validations
|
|
37
|
+
rule_version_class.where(rule_id: rule_id, status: "active").find_each do |v|
|
|
38
|
+
v.update!(status: "archived")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Create new version
|
|
42
|
+
version = rule_version_class.create!(
|
|
43
|
+
rule_id: rule_id,
|
|
44
|
+
version_number: next_version_number,
|
|
45
|
+
content: content.to_json,
|
|
46
|
+
created_by: metadata[:created_by] || "system",
|
|
47
|
+
changelog: metadata[:changelog] || "Version #{next_version_number}",
|
|
48
|
+
status: status
|
|
49
|
+
)
|
|
50
|
+
end
|
|
35
51
|
|
|
36
52
|
serialize_version(version)
|
|
37
53
|
end
|
|
@@ -63,15 +79,24 @@ module DecisionAgent
|
|
|
63
79
|
end
|
|
64
80
|
|
|
65
81
|
def activate_version(version_id:)
|
|
66
|
-
version =
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
82
|
+
version = nil
|
|
83
|
+
|
|
84
|
+
rule_version_class.transaction do
|
|
85
|
+
# Find and lock the version to activate
|
|
86
|
+
version = rule_version_class.lock.find(version_id)
|
|
87
|
+
|
|
88
|
+
# Deactivate all other versions for this rule within the same transaction
|
|
89
|
+
# The lock ensures only one thread can perform this operation at a time
|
|
90
|
+
# Use update! instead of update_all to trigger validations
|
|
91
|
+
rule_version_class.where(rule_id: version.rule_id, status: "active")
|
|
92
|
+
.where.not(id: version_id)
|
|
93
|
+
.find_each do |v|
|
|
94
|
+
v.update!(status: "archived")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Activate this version
|
|
98
|
+
version.update!(status: "active")
|
|
99
|
+
end
|
|
75
100
|
|
|
76
101
|
serialize_version(version)
|
|
77
102
|
end
|
|
@@ -89,11 +114,22 @@ module DecisionAgent
|
|
|
89
114
|
end
|
|
90
115
|
|
|
91
116
|
def serialize_version(version)
|
|
117
|
+
# Parse JSON content with proper error handling
|
|
118
|
+
parsed_content = begin
|
|
119
|
+
JSON.parse(version.content)
|
|
120
|
+
rescue JSON::ParserError => e
|
|
121
|
+
raise DecisionAgent::ValidationError,
|
|
122
|
+
"Invalid JSON in version #{version.id} for rule #{version.rule_id}: #{e.message}"
|
|
123
|
+
rescue TypeError, NoMethodError
|
|
124
|
+
raise DecisionAgent::ValidationError,
|
|
125
|
+
"Invalid content in version #{version.id} for rule #{version.rule_id}: content is nil or not a string"
|
|
126
|
+
end
|
|
127
|
+
|
|
92
128
|
{
|
|
93
129
|
id: version.id,
|
|
94
130
|
rule_id: version.rule_id,
|
|
95
131
|
version_number: version.version_number,
|
|
96
|
-
content:
|
|
132
|
+
content: parsed_content,
|
|
97
133
|
created_by: version.created_by,
|
|
98
134
|
created_at: version.created_at,
|
|
99
135
|
changelog: version.changelog,
|
|
@@ -91,9 +91,7 @@ module DecisionAgent
|
|
|
91
91
|
changes = {}
|
|
92
92
|
hash1.each do |key, value1|
|
|
93
93
|
value2 = hash2[key]
|
|
94
|
-
if value1 != value2 && !value2.nil?
|
|
95
|
-
changes[key] = { old: value1, new: value2 }
|
|
96
|
-
end
|
|
94
|
+
changes[key] = { old: value1, new: value2 } if value1 != value2 && !value2.nil?
|
|
97
95
|
end
|
|
98
96
|
changes
|
|
99
97
|
end
|