decision_agent 0.1.2 → 0.1.3
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/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 +278 -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/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 +7 -0
- data/lib/generators/decision_agent/install/install_generator.rb +5 -5
- data/lib/generators/decision_agent/install/templates/migration.rb +17 -6
- 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/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 +548 -0
- data/spec/issue_verification_spec.rb +685 -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/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 +69 -6
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
require "monitor"
|
|
2
|
+
|
|
3
|
+
module DecisionAgent
|
|
4
|
+
module Monitoring
|
|
5
|
+
# Prometheus-compatible metrics exporter
|
|
6
|
+
class PrometheusExporter
|
|
7
|
+
include MonitorMixin
|
|
8
|
+
|
|
9
|
+
CONTENT_TYPE = "text/plain; version=0.0.4".freeze
|
|
10
|
+
|
|
11
|
+
def initialize(metrics_collector:, namespace: "decision_agent")
|
|
12
|
+
super()
|
|
13
|
+
@metrics_collector = metrics_collector
|
|
14
|
+
@namespace = namespace
|
|
15
|
+
@custom_metrics = {}
|
|
16
|
+
freeze_config
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Export metrics in Prometheus format
|
|
20
|
+
def export
|
|
21
|
+
synchronize do
|
|
22
|
+
lines = []
|
|
23
|
+
|
|
24
|
+
# Add header
|
|
25
|
+
lines << "# DecisionAgent Metrics Export"
|
|
26
|
+
lines << "# Timestamp: #{Time.now.utc.iso8601}"
|
|
27
|
+
lines << ""
|
|
28
|
+
|
|
29
|
+
# Decision metrics
|
|
30
|
+
lines.concat(export_decision_metrics)
|
|
31
|
+
|
|
32
|
+
# Performance metrics
|
|
33
|
+
lines.concat(export_performance_metrics)
|
|
34
|
+
|
|
35
|
+
# Error metrics
|
|
36
|
+
lines.concat(export_error_metrics)
|
|
37
|
+
|
|
38
|
+
# Custom KPI metrics
|
|
39
|
+
lines.concat(export_custom_metrics)
|
|
40
|
+
|
|
41
|
+
# System info
|
|
42
|
+
lines.concat(export_system_metrics)
|
|
43
|
+
|
|
44
|
+
lines.join("\n")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Register custom KPI
|
|
49
|
+
def register_kpi(name:, value:, labels: {}, help: nil)
|
|
50
|
+
synchronize do
|
|
51
|
+
metric_name = sanitize_name(name)
|
|
52
|
+
@custom_metrics[metric_name] = {
|
|
53
|
+
value: value,
|
|
54
|
+
labels: labels,
|
|
55
|
+
help: help || "Custom KPI: #{name}",
|
|
56
|
+
timestamp: Time.now.utc
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Get metrics in hash format
|
|
62
|
+
def metrics_hash
|
|
63
|
+
synchronize do
|
|
64
|
+
stats = @metrics_collector.statistics
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
decisions: {
|
|
68
|
+
total: counter_metric("decisions_total", stats.dig(:decisions, :total) || 0),
|
|
69
|
+
avg_confidence: gauge_metric("decision_confidence_avg", stats.dig(:decisions, :avg_confidence) || 0),
|
|
70
|
+
avg_duration_ms: gauge_metric("decision_duration_ms_avg", stats.dig(:decisions, :avg_duration_ms) || 0)
|
|
71
|
+
},
|
|
72
|
+
performance: {
|
|
73
|
+
success_rate: gauge_metric("success_rate", stats.dig(:performance, :success_rate) || 0),
|
|
74
|
+
avg_duration_ms: gauge_metric("operation_duration_ms_avg",
|
|
75
|
+
stats.dig(:performance, :avg_duration_ms) || 0),
|
|
76
|
+
p95_duration_ms: gauge_metric("operation_duration_ms_p95",
|
|
77
|
+
stats.dig(:performance, :p95_duration_ms) || 0),
|
|
78
|
+
p99_duration_ms: gauge_metric("operation_duration_ms_p99", stats.dig(:performance, :p99_duration_ms) || 0)
|
|
79
|
+
},
|
|
80
|
+
errors: {
|
|
81
|
+
total: counter_metric("errors_total", stats.dig(:errors, :total) || 0)
|
|
82
|
+
},
|
|
83
|
+
system: {
|
|
84
|
+
version: info_metric("version", DecisionAgent::VERSION)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def freeze_config
|
|
93
|
+
@namespace.freeze
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def export_decision_metrics
|
|
97
|
+
stats = @metrics_collector.statistics
|
|
98
|
+
lines = []
|
|
99
|
+
|
|
100
|
+
# Total decisions
|
|
101
|
+
lines << "# HELP #{metric_name('decisions_total')} Total number of decisions made"
|
|
102
|
+
lines << "# TYPE #{metric_name('decisions_total')} counter"
|
|
103
|
+
lines << "#{metric_name('decisions_total')} #{stats.dig(:decisions, :total) || 0}"
|
|
104
|
+
lines << ""
|
|
105
|
+
|
|
106
|
+
# Average confidence
|
|
107
|
+
lines << "# HELP #{metric_name('decision_confidence_avg')} Average decision confidence"
|
|
108
|
+
lines << "# TYPE #{metric_name('decision_confidence_avg')} gauge"
|
|
109
|
+
lines << "#{metric_name('decision_confidence_avg')} #{stats.dig(:decisions, :avg_confidence) || 0}"
|
|
110
|
+
lines << ""
|
|
111
|
+
|
|
112
|
+
# Decision distribution
|
|
113
|
+
if stats.dig(:decisions, :decision_distribution)
|
|
114
|
+
lines << "# HELP #{metric_name('decisions_by_type')} Decisions grouped by type"
|
|
115
|
+
lines << "# TYPE #{metric_name('decisions_by_type')} counter"
|
|
116
|
+
stats[:decisions][:decision_distribution].each do |decision, count|
|
|
117
|
+
lines << "#{metric_name('decisions_by_type')}{decision=\"#{decision}\"} #{count}"
|
|
118
|
+
end
|
|
119
|
+
lines << ""
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Average duration
|
|
123
|
+
if stats.dig(:decisions, :avg_duration_ms)
|
|
124
|
+
lines << "# HELP #{metric_name('decision_duration_ms_avg')} Average decision duration in milliseconds"
|
|
125
|
+
lines << "# TYPE #{metric_name('decision_duration_ms_avg')} gauge"
|
|
126
|
+
lines << "#{metric_name('decision_duration_ms_avg')} #{stats[:decisions][:avg_duration_ms]}"
|
|
127
|
+
lines << ""
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
lines
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def export_performance_metrics
|
|
134
|
+
stats = @metrics_collector.statistics
|
|
135
|
+
lines = []
|
|
136
|
+
|
|
137
|
+
# Success rate
|
|
138
|
+
lines << "# HELP #{metric_name('success_rate')} Operation success rate (0-1)"
|
|
139
|
+
lines << "# TYPE #{metric_name('success_rate')} gauge"
|
|
140
|
+
lines << "#{metric_name('success_rate')} #{stats.dig(:performance, :success_rate) || 0}"
|
|
141
|
+
lines << ""
|
|
142
|
+
|
|
143
|
+
# Duration metrics
|
|
144
|
+
if stats.dig(:performance, :avg_duration_ms)
|
|
145
|
+
lines << "# HELP #{metric_name('operation_duration_ms')} Operation duration in milliseconds"
|
|
146
|
+
lines << "# TYPE #{metric_name('operation_duration_ms')} summary"
|
|
147
|
+
lines << "#{metric_name('operation_duration_ms')}{quantile=\"0.5\"} #{stats[:performance][:avg_duration_ms]}"
|
|
148
|
+
lines << "#{metric_name('operation_duration_ms')}{quantile=\"0.95\"} #{stats[:performance][:p95_duration_ms]}"
|
|
149
|
+
lines << "#{metric_name('operation_duration_ms')}{quantile=\"0.99\"} #{stats[:performance][:p99_duration_ms]}"
|
|
150
|
+
lines << "#{metric_name('operation_duration_ms_sum')} #{stats[:performance][:avg_duration_ms] * stats[:performance][:total_operations]}"
|
|
151
|
+
lines << "#{metric_name('operation_duration_ms_count')} #{stats[:performance][:total_operations]}"
|
|
152
|
+
lines << ""
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
lines
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def export_error_metrics
|
|
159
|
+
stats = @metrics_collector.statistics
|
|
160
|
+
lines = []
|
|
161
|
+
|
|
162
|
+
# Total errors
|
|
163
|
+
lines << "# HELP #{metric_name('errors_total')} Total number of errors"
|
|
164
|
+
lines << "# TYPE #{metric_name('errors_total')} counter"
|
|
165
|
+
lines << "#{metric_name('errors_total')} #{stats.dig(:errors, :total) || 0}"
|
|
166
|
+
lines << ""
|
|
167
|
+
|
|
168
|
+
# Errors by type
|
|
169
|
+
if stats.dig(:errors, :by_type)
|
|
170
|
+
lines << "# HELP #{metric_name('errors_by_type')} Errors grouped by type"
|
|
171
|
+
lines << "# TYPE #{metric_name('errors_by_type')} counter"
|
|
172
|
+
stats[:errors][:by_type].each do |error_type, count|
|
|
173
|
+
lines << "#{metric_name('errors_by_type')}{error=\"#{sanitize_label(error_type)}\"} #{count}"
|
|
174
|
+
end
|
|
175
|
+
lines << ""
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
lines
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def export_custom_metrics
|
|
182
|
+
lines = []
|
|
183
|
+
|
|
184
|
+
@custom_metrics.each do |name, metric|
|
|
185
|
+
full_name = metric_name(name)
|
|
186
|
+
lines << "# HELP #{full_name} #{metric[:help]}"
|
|
187
|
+
lines << "# TYPE #{full_name} gauge"
|
|
188
|
+
|
|
189
|
+
if metric[:labels].empty?
|
|
190
|
+
lines << "#{full_name} #{metric[:value]}"
|
|
191
|
+
else
|
|
192
|
+
label_str = metric[:labels].map { |k, v| "#{k}=\"#{sanitize_label(v)}\"" }.join(",")
|
|
193
|
+
lines << "#{full_name}{#{label_str}} #{metric[:value]}"
|
|
194
|
+
end
|
|
195
|
+
lines << ""
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
lines
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def export_system_metrics
|
|
202
|
+
lines = []
|
|
203
|
+
|
|
204
|
+
# Version info
|
|
205
|
+
lines << "# HELP #{metric_name('info')} DecisionAgent version info"
|
|
206
|
+
lines << "# TYPE #{metric_name('info')} gauge"
|
|
207
|
+
lines << "#{metric_name('info')}{version=\"#{DecisionAgent::VERSION}\"} 1"
|
|
208
|
+
lines << ""
|
|
209
|
+
|
|
210
|
+
# Metrics count
|
|
211
|
+
counts = @metrics_collector.metrics_count
|
|
212
|
+
lines << "# HELP #{metric_name('metrics_stored')} Number of metrics stored in memory"
|
|
213
|
+
lines << "# TYPE #{metric_name('metrics_stored')} gauge"
|
|
214
|
+
counts.each do |type, count|
|
|
215
|
+
lines << "#{metric_name('metrics_stored')}{type=\"#{type}\"} #{count}"
|
|
216
|
+
end
|
|
217
|
+
lines << ""
|
|
218
|
+
|
|
219
|
+
lines
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def metric_name(name)
|
|
223
|
+
"#{@namespace}_#{sanitize_name(name)}"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def sanitize_name(name)
|
|
227
|
+
name.to_s.gsub(/[^a-zA-Z0-9_]/, "_")
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def sanitize_label(value)
|
|
231
|
+
value.to_s.gsub("\\", "\\\\").gsub('"', '\\"').gsub("\n", "\\n")
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def counter_metric(name, value)
|
|
235
|
+
{ name: name, type: "counter", value: value }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def gauge_metric(name, value)
|
|
239
|
+
{ name: name, type: "gauge", value: value }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def info_metric(name, value)
|
|
243
|
+
{ name: name, type: "info", value: value }
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
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
|