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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +212 -35
  3. data/bin/decision_agent +3 -8
  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 +11 -8
  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 +69 -33
  29. data/lib/decision_agent/versioning/adapter.rb +1 -3
  30. data/lib/decision_agent/versioning/file_storage_adapter.rb +143 -35
  31. data/lib/decision_agent/versioning/version_manager.rb +4 -12
  32. data/lib/decision_agent/web/public/index.html +1 -1
  33. data/lib/decision_agent/web/server.rb +19 -24
  34. data/lib/decision_agent.rb +7 -0
  35. data/lib/generators/decision_agent/install/install_generator.rb +5 -5
  36. data/lib/generators/decision_agent/install/templates/migration.rb +17 -6
  37. data/lib/generators/decision_agent/install/templates/rule.rb +3 -3
  38. data/lib/generators/decision_agent/install/templates/rule_version.rb +13 -7
  39. data/spec/activerecord_thread_safety_spec.rb +553 -0
  40. data/spec/agent_spec.rb +13 -13
  41. data/spec/api_contract_spec.rb +16 -16
  42. data/spec/audit_adapters_spec.rb +3 -3
  43. data/spec/comprehensive_edge_cases_spec.rb +86 -86
  44. data/spec/dsl_validation_spec.rb +83 -83
  45. data/spec/edge_cases_spec.rb +23 -23
  46. data/spec/examples/feedback_aware_evaluator_spec.rb +7 -7
  47. data/spec/examples.txt +548 -0
  48. data/spec/issue_verification_spec.rb +685 -0
  49. data/spec/json_rule_evaluator_spec.rb +15 -15
  50. data/spec/monitoring/alert_manager_spec.rb +378 -0
  51. data/spec/monitoring/metrics_collector_spec.rb +281 -0
  52. data/spec/monitoring/monitored_agent_spec.rb +222 -0
  53. data/spec/monitoring/prometheus_exporter_spec.rb +242 -0
  54. data/spec/replay_edge_cases_spec.rb +58 -58
  55. data/spec/replay_spec.rb +11 -11
  56. data/spec/rfc8785_canonicalization_spec.rb +215 -0
  57. data/spec/scoring_spec.rb +1 -1
  58. data/spec/spec_helper.rb +9 -0
  59. data/spec/thread_safety_spec.rb +482 -0
  60. data/spec/thread_safety_spec.rb.broken +878 -0
  61. data/spec/versioning_spec.rb +141 -37
  62. data/spec/web_ui_rack_spec.rb +135 -0
  63. 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 = ["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.2"
2
+ VERSION = "0.1.3".freeze
3
3
  end
@@ -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
- unless defined?(ActiveRecord)
10
- raise DecisionAgent::ConfigurationError,
11
- "ActiveRecord is not available. Please ensure Rails/ActiveRecord is loaded."
12
- end
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
- # Get the next version number for this rule
17
- last_version = rule_version_class.where(rule_id: rule_id)
18
- .order(version_number: :desc)
19
- .first
20
- next_version_number = last_version ? last_version.version_number + 1 : 1
21
-
22
- # Deactivate previous active versions
23
- rule_version_class.where(rule_id: rule_id, status: "active")
24
- .update_all(status: "archived")
25
-
26
- # Create new version
27
- version = rule_version_class.create!(
28
- rule_id: rule_id,
29
- version_number: next_version_number,
30
- content: content.to_json,
31
- created_by: metadata[:created_by] || "system",
32
- changelog: metadata[:changelog] || "Version #{next_version_number}",
33
- status: metadata[:status] || "active"
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 = rule_version_class.find(version_id)
67
-
68
- # Deactivate all other versions for this rule
69
- rule_version_class.where(rule_id: version.rule_id, status: "active")
70
- .where.not(id: version_id)
71
- .update_all(status: "archived")
72
-
73
- # Activate this version
74
- version.update!(status: "active")
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: JSON.parse(version.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