decision_agent 0.1.1 → 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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +234 -919
  3. data/bin/decision_agent +5 -5
  4. data/lib/decision_agent/agent.rb +19 -26
  5. data/lib/decision_agent/audit/null_adapter.rb +1 -2
  6. data/lib/decision_agent/decision.rb +3 -1
  7. data/lib/decision_agent/dsl/condition_evaluator.rb +4 -3
  8. data/lib/decision_agent/dsl/rule_parser.rb +4 -6
  9. data/lib/decision_agent/dsl/schema_validator.rb +27 -31
  10. data/lib/decision_agent/errors.rb +21 -6
  11. data/lib/decision_agent/evaluation.rb +3 -1
  12. data/lib/decision_agent/evaluation_validator.rb +78 -0
  13. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +26 -0
  14. data/lib/decision_agent/evaluators/static_evaluator.rb +2 -6
  15. data/lib/decision_agent/monitoring/alert_manager.rb +282 -0
  16. data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +381 -0
  17. data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +471 -0
  18. data/lib/decision_agent/monitoring/dashboard/public/index.html +161 -0
  19. data/lib/decision_agent/monitoring/dashboard_server.rb +340 -0
  20. data/lib/decision_agent/monitoring/metrics_collector.rb +278 -0
  21. data/lib/decision_agent/monitoring/monitored_agent.rb +71 -0
  22. data/lib/decision_agent/monitoring/prometheus_exporter.rb +247 -0
  23. data/lib/decision_agent/replay/replay.rb +12 -22
  24. data/lib/decision_agent/scoring/base.rb +1 -1
  25. data/lib/decision_agent/scoring/consensus.rb +5 -5
  26. data/lib/decision_agent/scoring/weighted_average.rb +1 -1
  27. data/lib/decision_agent/version.rb +1 -1
  28. data/lib/decision_agent/versioning/activerecord_adapter.rb +141 -0
  29. data/lib/decision_agent/versioning/adapter.rb +100 -0
  30. data/lib/decision_agent/versioning/file_storage_adapter.rb +290 -0
  31. data/lib/decision_agent/versioning/version_manager.rb +127 -0
  32. data/lib/decision_agent/web/public/app.js +318 -0
  33. data/lib/decision_agent/web/public/index.html +56 -1
  34. data/lib/decision_agent/web/public/styles.css +219 -0
  35. data/lib/decision_agent/web/server.rb +169 -9
  36. data/lib/decision_agent.rb +11 -0
  37. data/lib/generators/decision_agent/install/install_generator.rb +40 -0
  38. data/lib/generators/decision_agent/install/templates/README +47 -0
  39. data/lib/generators/decision_agent/install/templates/migration.rb +37 -0
  40. data/lib/generators/decision_agent/install/templates/rule.rb +30 -0
  41. data/lib/generators/decision_agent/install/templates/rule_version.rb +66 -0
  42. data/spec/activerecord_thread_safety_spec.rb +553 -0
  43. data/spec/agent_spec.rb +13 -13
  44. data/spec/api_contract_spec.rb +16 -16
  45. data/spec/audit_adapters_spec.rb +3 -3
  46. data/spec/comprehensive_edge_cases_spec.rb +86 -86
  47. data/spec/dsl_validation_spec.rb +83 -83
  48. data/spec/edge_cases_spec.rb +23 -23
  49. data/spec/examples/feedback_aware_evaluator_spec.rb +7 -7
  50. data/spec/examples.txt +548 -0
  51. data/spec/issue_verification_spec.rb +685 -0
  52. data/spec/json_rule_evaluator_spec.rb +15 -15
  53. data/spec/monitoring/alert_manager_spec.rb +378 -0
  54. data/spec/monitoring/metrics_collector_spec.rb +281 -0
  55. data/spec/monitoring/monitored_agent_spec.rb +222 -0
  56. data/spec/monitoring/prometheus_exporter_spec.rb +242 -0
  57. data/spec/replay_edge_cases_spec.rb +58 -58
  58. data/spec/replay_spec.rb +11 -11
  59. data/spec/rfc8785_canonicalization_spec.rb +215 -0
  60. data/spec/scoring_spec.rb +1 -1
  61. data/spec/spec_helper.rb +9 -0
  62. data/spec/thread_safety_spec.rb +482 -0
  63. data/spec/thread_safety_spec.rb.broken +878 -0
  64. data/spec/versioning_spec.rb +777 -0
  65. data/spec/web_ui_rack_spec.rb +135 -0
  66. metadata +84 -11
@@ -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 = ["context", "evaluations", "decision", "confidence"]
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
- if differences.any?
119
- raise ReplayMismatchError.new(
120
- expected: { decision: original_decision, confidence: original_confidence },
121
- actual: { decision: replayed_decision, confidence: replayed_confidence },
122
- differences: differences
123
- )
124
- end
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:"
@@ -12,7 +12,7 @@ module DecisionAgent
12
12
  end
13
13
 
14
14
  def round_confidence(value)
15
- (value * 10000).round / 10000.0
15
+ (value * 10_000).round / 10_000.0
16
16
  end
17
17
  end
18
18
  end
@@ -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
- confidence = agreement * avg_weight
29
- else
30
- confidence = agreement * avg_weight * 0.5
31
- end
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 > 0 ? winning_weight / total_weight : 0.0
17
+ confidence = total_weight.positive? ? winning_weight / total_weight : 0.0
18
18
 
19
19
  {
20
20
  decision: winning_decision,
@@ -1,3 +1,3 @@
1
1
  module DecisionAgent
2
- VERSION = "0.1.1"
2
+ VERSION = "0.1.3".freeze
3
3
  end
@@ -0,0 +1,141 @@
1
+ require_relative "adapter"
2
+ require_relative "file_storage_adapter"
3
+
4
+ module DecisionAgent
5
+ module Versioning
6
+ # ActiveRecord-based version storage adapter for Rails applications
7
+ # Requires ActiveRecord models to be set up in the Rails app
8
+ class ActiveRecordAdapter < Adapter
9
+ include StatusValidator
10
+
11
+ def initialize
12
+ return if defined?(ActiveRecord)
13
+
14
+ raise DecisionAgent::ConfigurationError,
15
+ "ActiveRecord is not available. Please ensure Rails/ActiveRecord is loaded."
16
+ end
17
+
18
+ def create_version(rule_id:, content:, metadata: {})
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
51
+
52
+ serialize_version(version)
53
+ end
54
+
55
+ def list_versions(rule_id:, limit: nil)
56
+ query = rule_version_class.where(rule_id: rule_id)
57
+ .order(version_number: :desc)
58
+ query = query.limit(limit) if limit
59
+
60
+ query.map { |v| serialize_version(v) }
61
+ end
62
+
63
+ def get_version(version_id:)
64
+ version = rule_version_class.find_by(id: version_id)
65
+ version ? serialize_version(version) : nil
66
+ end
67
+
68
+ def get_version_by_number(rule_id:, version_number:)
69
+ version = rule_version_class.find_by(
70
+ rule_id: rule_id,
71
+ version_number: version_number
72
+ )
73
+ version ? serialize_version(version) : nil
74
+ end
75
+
76
+ def get_active_version(rule_id:)
77
+ version = rule_version_class.find_by(rule_id: rule_id, status: "active")
78
+ version ? serialize_version(version) : nil
79
+ end
80
+
81
+ def activate_version(version_id:)
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
100
+
101
+ serialize_version(version)
102
+ end
103
+
104
+ private
105
+
106
+ def rule_version_class
107
+ # Look for the RuleVersion model in the main app
108
+ if defined?(::RuleVersion)
109
+ ::RuleVersion
110
+ else
111
+ raise DecisionAgent::ConfigurationError,
112
+ "RuleVersion model not found. Please run the generator to create it."
113
+ end
114
+ end
115
+
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
+
128
+ {
129
+ id: version.id,
130
+ rule_id: version.rule_id,
131
+ version_number: version.version_number,
132
+ content: parsed_content,
133
+ created_by: version.created_by,
134
+ created_at: version.created_at,
135
+ changelog: version.changelog,
136
+ status: version.status
137
+ }
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,100 @@
1
+ module DecisionAgent
2
+ module Versioning
3
+ # Abstract base class for version storage adapters
4
+ # Allows framework-agnostic versioning with pluggable storage backends
5
+ class Adapter
6
+ # Create a new version for a rule
7
+ # @param rule_id [String] Unique identifier for the rule
8
+ # @param content [Hash] Rule definition as a hash
9
+ # @param metadata [Hash] Additional metadata (created_by, changelog, etc.)
10
+ # @return [Hash] The created version
11
+ def create_version(rule_id:, content:, metadata: {})
12
+ raise NotImplementedError, "#{self.class} must implement #create_version"
13
+ end
14
+
15
+ # List all versions for a specific rule
16
+ # @param rule_id [String] The rule identifier
17
+ # @param limit [Integer, nil] Optional limit for number of versions
18
+ # @return [Array<Hash>] Array of version hashes
19
+ def list_versions(rule_id:, limit: nil)
20
+ raise NotImplementedError, "#{self.class} must implement #list_versions"
21
+ end
22
+
23
+ # Get a specific version by ID
24
+ # @param version_id [String, Integer] The version identifier
25
+ # @return [Hash, nil] The version hash or nil if not found
26
+ def get_version(version_id:)
27
+ raise NotImplementedError, "#{self.class} must implement #get_version"
28
+ end
29
+
30
+ # Get a specific version by rule_id and version_number
31
+ # @param rule_id [String] The rule identifier
32
+ # @param version_number [Integer] The version number
33
+ # @return [Hash, nil] The version hash or nil if not found
34
+ def get_version_by_number(rule_id:, version_number:)
35
+ raise NotImplementedError, "#{self.class} must implement #get_version_by_number"
36
+ end
37
+
38
+ # Get the active version for a rule
39
+ # @param rule_id [String] The rule identifier
40
+ # @return [Hash, nil] The active version or nil
41
+ def get_active_version(rule_id:)
42
+ raise NotImplementedError, "#{self.class} must implement #get_active_version"
43
+ end
44
+
45
+ # Activate a specific version
46
+ # @param version_id [String, Integer] The version to activate
47
+ # @return [Hash] The activated version
48
+ def activate_version(version_id:)
49
+ raise NotImplementedError, "#{self.class} must implement #activate_version"
50
+ end
51
+
52
+ # Compare two versions
53
+ # @param version_id_1 [String, Integer] First version ID
54
+ # @param version_id_2 [String, Integer] Second version ID
55
+ # @return [Hash] Comparison result with differences
56
+ def compare_versions(version_id_1:, version_id_2:)
57
+ v1 = get_version(version_id: version_id_1)
58
+ v2 = get_version(version_id: version_id_2)
59
+
60
+ return nil if v1.nil? || v2.nil?
61
+
62
+ {
63
+ version_1: v1,
64
+ version_2: v2,
65
+ differences: calculate_diff(v1[:content], v2[:content])
66
+ }
67
+ end
68
+
69
+ # Delete a specific version
70
+ # @param version_id [String, Integer] The version to delete
71
+ # @return [Boolean] True if deleted successfully
72
+ def delete_version(version_id:)
73
+ raise NotImplementedError, "#{self.class} must implement #delete_version"
74
+ end
75
+
76
+ private
77
+
78
+ # Calculate differences between two content hashes
79
+ # @param content1 [Hash] First content
80
+ # @param content2 [Hash] Second content
81
+ # @return [Hash] Differences
82
+ def calculate_diff(content1, content2)
83
+ {
84
+ added: content2.to_a - content1.to_a,
85
+ removed: content1.to_a - content2.to_a,
86
+ changed: detect_changes(content1, content2)
87
+ }
88
+ end
89
+
90
+ def detect_changes(hash1, hash2)
91
+ changes = {}
92
+ hash1.each do |key, value1|
93
+ value2 = hash2[key]
94
+ changes[key] = { old: value1, new: value2 } if value1 != value2 && !value2.nil?
95
+ end
96
+ changes
97
+ end
98
+ end
99
+ end
100
+ end