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
|
@@ -3,13 +3,13 @@ module DecisionAgent
|
|
|
3
3
|
|
|
4
4
|
class InvalidRuleDslError < Error
|
|
5
5
|
def initialize(message = "Invalid rule DSL structure")
|
|
6
|
-
super
|
|
6
|
+
super
|
|
7
7
|
end
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
class NoEvaluationsError < Error
|
|
11
11
|
def initialize(message = "No evaluators returned a decision")
|
|
12
|
-
super
|
|
12
|
+
super
|
|
13
13
|
end
|
|
14
14
|
end
|
|
15
15
|
|
|
@@ -26,25 +26,25 @@ module DecisionAgent
|
|
|
26
26
|
|
|
27
27
|
class InvalidConfigurationError < Error
|
|
28
28
|
def initialize(message = "Invalid agent configuration")
|
|
29
|
-
super
|
|
29
|
+
super
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
class InvalidEvaluatorError < Error
|
|
34
34
|
def initialize(message = "Evaluator must respond to #evaluate")
|
|
35
|
-
super
|
|
35
|
+
super
|
|
36
36
|
end
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
class InvalidScoringStrategyError < Error
|
|
40
40
|
def initialize(message = "Scoring strategy must respond to #score")
|
|
41
|
-
super
|
|
41
|
+
super
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
class InvalidAuditAdapterError < Error
|
|
46
46
|
def initialize(message = "Audit adapter must respond to #record")
|
|
47
|
-
super
|
|
47
|
+
super
|
|
48
48
|
end
|
|
49
49
|
end
|
|
50
50
|
|
|
@@ -62,13 +62,16 @@ module DecisionAgent
|
|
|
62
62
|
|
|
63
63
|
class NotFoundError < Error
|
|
64
64
|
def initialize(message = "Resource not found")
|
|
65
|
-
super
|
|
65
|
+
super
|
|
66
66
|
end
|
|
67
67
|
end
|
|
68
68
|
|
|
69
69
|
class ValidationError < Error
|
|
70
70
|
def initialize(message = "Validation failed")
|
|
71
|
-
super
|
|
71
|
+
super
|
|
72
72
|
end
|
|
73
73
|
end
|
|
74
|
+
|
|
75
|
+
# Alias for backward compatibility and clearer naming
|
|
76
|
+
ConfigurationError = InvalidConfigurationError
|
|
74
77
|
end
|
|
@@ -10,6 +10,8 @@ module DecisionAgent
|
|
|
10
10
|
@reason = reason.to_s.freeze
|
|
11
11
|
@evaluator_name = evaluator_name.to_s.freeze
|
|
12
12
|
@metadata = deep_freeze(metadata)
|
|
13
|
+
|
|
14
|
+
freeze
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def to_h
|
|
@@ -35,7 +37,7 @@ module DecisionAgent
|
|
|
35
37
|
|
|
36
38
|
def validate_weight!(weight)
|
|
37
39
|
w = weight.to_f
|
|
38
|
-
raise InvalidWeightError
|
|
40
|
+
raise InvalidWeightError, weight unless w.between?(0.0, 1.0)
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
def deep_freeze(obj)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DecisionAgent
|
|
4
|
+
# Validates evaluation objects for correctness and thread-safety
|
|
5
|
+
class EvaluationValidator
|
|
6
|
+
class ValidationError < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Validates a single evaluation
|
|
9
|
+
# @param evaluation [Evaluation] the evaluation to validate
|
|
10
|
+
# @raise [ValidationError] if validation fails
|
|
11
|
+
def self.validate!(evaluation)
|
|
12
|
+
raise ValidationError, "Evaluation cannot be nil" if evaluation.nil?
|
|
13
|
+
raise ValidationError, "Evaluation must be an Evaluation instance" unless evaluation.is_a?(Evaluation)
|
|
14
|
+
|
|
15
|
+
validate_decision!(evaluation.decision)
|
|
16
|
+
validate_weight!(evaluation.weight)
|
|
17
|
+
validate_reason!(evaluation.reason)
|
|
18
|
+
validate_evaluator_name!(evaluation.evaluator_name)
|
|
19
|
+
validate_frozen!(evaluation)
|
|
20
|
+
|
|
21
|
+
true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Validates an array of evaluations
|
|
25
|
+
# @param evaluations [Array<Evaluation>] the evaluations to validate
|
|
26
|
+
# @raise [ValidationError] if validation fails
|
|
27
|
+
def self.validate_all!(evaluations)
|
|
28
|
+
raise ValidationError, "Evaluations must be an Array" unless evaluations.is_a?(Array)
|
|
29
|
+
raise ValidationError, "Evaluations array cannot be empty" if evaluations.empty?
|
|
30
|
+
|
|
31
|
+
evaluations.each_with_index do |evaluation, index|
|
|
32
|
+
validate!(evaluation)
|
|
33
|
+
rescue ValidationError => e
|
|
34
|
+
raise ValidationError, "Validation failed for evaluation at index #{index}: #{e.message}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private_class_method def self.validate_decision!(decision)
|
|
41
|
+
raise ValidationError, "Decision cannot be nil" if decision.nil?
|
|
42
|
+
raise ValidationError, "Decision must be a String" unless decision.is_a?(String)
|
|
43
|
+
raise ValidationError, "Decision cannot be empty" if decision.strip.empty?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private_class_method def self.validate_weight!(weight)
|
|
47
|
+
raise ValidationError, "Weight cannot be nil" if weight.nil?
|
|
48
|
+
raise ValidationError, "Weight must be a Numeric" unless weight.is_a?(Numeric)
|
|
49
|
+
raise ValidationError, "Weight must be between 0 and 1" unless weight.between?(0, 1)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private_class_method def self.validate_reason!(reason)
|
|
53
|
+
raise ValidationError, "Reason cannot be nil" if reason.nil?
|
|
54
|
+
raise ValidationError, "Reason must be a String" unless reason.is_a?(String)
|
|
55
|
+
raise ValidationError, "Reason cannot be empty" if reason.strip.empty?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private_class_method def self.validate_evaluator_name!(name)
|
|
59
|
+
raise ValidationError, "Evaluator name cannot be nil" if name.nil?
|
|
60
|
+
raise ValidationError, "Evaluator name must be a String or Symbol" unless name.is_a?(String) || name.is_a?(Symbol)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private_class_method def self.validate_frozen!(evaluation)
|
|
64
|
+
raise ValidationError, "Evaluation must be frozen for thread-safety (call .freeze)" unless evaluation.frozen?
|
|
65
|
+
|
|
66
|
+
# Verify nested structures are also frozen
|
|
67
|
+
raise ValidationError, "Evaluation decision must be frozen" unless evaluation.decision.frozen?
|
|
68
|
+
|
|
69
|
+
raise ValidationError, "Evaluation reason must be frozen" unless evaluation.reason.frozen?
|
|
70
|
+
|
|
71
|
+
raise ValidationError, "Evaluation evaluator_name must be frozen" unless evaluation.evaluator_name.frozen?
|
|
72
|
+
|
|
73
|
+
return unless evaluation.metadata && !evaluation.metadata.frozen?
|
|
74
|
+
|
|
75
|
+
raise ValidationError, "Evaluation metadata must be frozen"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -10,6 +10,12 @@ module DecisionAgent
|
|
|
10
10
|
@ruleset = Dsl::RuleParser.parse(@rules_json)
|
|
11
11
|
@ruleset_name = @ruleset["ruleset"] || "unknown"
|
|
12
12
|
@name = name || "JsonRuleEvaluator(#{@ruleset_name})"
|
|
13
|
+
|
|
14
|
+
# Freeze ruleset to ensure thread-safety
|
|
15
|
+
deep_freeze(@ruleset)
|
|
16
|
+
@rules_json.freeze
|
|
17
|
+
@ruleset_name.freeze
|
|
18
|
+
@name.freeze
|
|
13
19
|
end
|
|
14
20
|
|
|
15
21
|
def evaluate(context, feedback: {})
|
|
@@ -46,6 +52,26 @@ module DecisionAgent
|
|
|
46
52
|
Dsl::ConditionEvaluator.evaluate(if_clause, context)
|
|
47
53
|
end
|
|
48
54
|
end
|
|
55
|
+
|
|
56
|
+
# Deep freeze helper method
|
|
57
|
+
def deep_freeze(obj)
|
|
58
|
+
case obj
|
|
59
|
+
when Hash
|
|
60
|
+
obj.each do |k, v|
|
|
61
|
+
deep_freeze(k)
|
|
62
|
+
deep_freeze(v)
|
|
63
|
+
end
|
|
64
|
+
obj.freeze
|
|
65
|
+
when Array
|
|
66
|
+
obj.each { |item| deep_freeze(item) }
|
|
67
|
+
obj.freeze
|
|
68
|
+
when String, Symbol, Numeric, TrueClass, FalseClass, NilClass
|
|
69
|
+
obj.freeze
|
|
70
|
+
else
|
|
71
|
+
obj.freeze if obj.respond_to?(:freeze)
|
|
72
|
+
end
|
|
73
|
+
obj
|
|
74
|
+
end
|
|
49
75
|
end
|
|
50
76
|
end
|
|
51
77
|
end
|
|
@@ -11,12 +11,8 @@ module DecisionAgent
|
|
|
11
11
|
@custom_metadata = metadata
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
def evaluate(
|
|
15
|
-
metadata =
|
|
16
|
-
@custom_metadata
|
|
17
|
-
else
|
|
18
|
-
{ type: "static" }
|
|
19
|
-
end
|
|
14
|
+
def evaluate(_context, feedback: {})
|
|
15
|
+
metadata = @custom_metadata || { type: "static" }
|
|
20
16
|
|
|
21
17
|
Evaluation.new(
|
|
22
18
|
decision: @decision,
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
require "monitor"
|
|
2
|
+
|
|
3
|
+
module DecisionAgent
|
|
4
|
+
module Monitoring
|
|
5
|
+
# Alert manager for anomaly detection and notifications
|
|
6
|
+
class AlertManager
|
|
7
|
+
include MonitorMixin
|
|
8
|
+
|
|
9
|
+
attr_reader :rules, :alerts
|
|
10
|
+
|
|
11
|
+
def initialize(metrics_collector:)
|
|
12
|
+
super()
|
|
13
|
+
@metrics_collector = metrics_collector
|
|
14
|
+
@rules = []
|
|
15
|
+
@alerts = []
|
|
16
|
+
@alert_handlers = []
|
|
17
|
+
@check_interval = 60 # seconds
|
|
18
|
+
@monitoring_thread = nil
|
|
19
|
+
freeze_config
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Define an alert rule
|
|
23
|
+
def add_rule(name:, condition:, severity: :warning, threshold: nil, message: nil, cooldown: 300)
|
|
24
|
+
synchronize do
|
|
25
|
+
rule = {
|
|
26
|
+
id: generate_rule_id(name),
|
|
27
|
+
name: name,
|
|
28
|
+
condition: condition,
|
|
29
|
+
severity: severity,
|
|
30
|
+
threshold: threshold,
|
|
31
|
+
message: message || "Alert: #{name}",
|
|
32
|
+
cooldown: cooldown,
|
|
33
|
+
last_triggered: nil,
|
|
34
|
+
enabled: true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@rules << rule
|
|
38
|
+
rule
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Remove a rule
|
|
43
|
+
def remove_rule(rule_id)
|
|
44
|
+
synchronize do
|
|
45
|
+
@rules.reject! { |r| r[:id] == rule_id }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Enable/disable rule
|
|
50
|
+
def toggle_rule(rule_id, enabled)
|
|
51
|
+
synchronize do
|
|
52
|
+
rule = @rules.find { |r| r[:id] == rule_id }
|
|
53
|
+
rule[:enabled] = enabled if rule
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Register alert handler
|
|
58
|
+
def add_handler(&block)
|
|
59
|
+
synchronize do
|
|
60
|
+
@alert_handlers << block
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Start monitoring
|
|
65
|
+
def start_monitoring(interval: 60)
|
|
66
|
+
synchronize do
|
|
67
|
+
return if @monitoring_thread&.alive?
|
|
68
|
+
|
|
69
|
+
@check_interval = interval
|
|
70
|
+
@monitoring_thread = Thread.new do
|
|
71
|
+
loop do
|
|
72
|
+
check_rules
|
|
73
|
+
sleep @check_interval
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
warn "Alert monitoring error: #{e.message}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Stop monitoring
|
|
82
|
+
def stop_monitoring
|
|
83
|
+
synchronize do
|
|
84
|
+
@monitoring_thread&.kill
|
|
85
|
+
@monitoring_thread = nil
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Manually check all rules
|
|
90
|
+
def check_rules
|
|
91
|
+
stats = @metrics_collector.statistics
|
|
92
|
+
|
|
93
|
+
@rules.each do |rule|
|
|
94
|
+
next unless rule[:enabled]
|
|
95
|
+
next if in_cooldown?(rule)
|
|
96
|
+
|
|
97
|
+
trigger_alert(rule, stats) if evaluate_condition(rule[:condition], stats)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Get active alerts
|
|
102
|
+
def active_alerts
|
|
103
|
+
synchronize do
|
|
104
|
+
@alerts.select { |a| a[:status] == :active }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Get all alerts
|
|
109
|
+
def all_alerts(limit: 100)
|
|
110
|
+
synchronize do
|
|
111
|
+
@alerts.last(limit)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Acknowledge alert
|
|
116
|
+
def acknowledge_alert(alert_id, acknowledged_by: "system")
|
|
117
|
+
synchronize do
|
|
118
|
+
alert = @alerts.find { |a| a[:id] == alert_id }
|
|
119
|
+
if alert
|
|
120
|
+
alert[:status] = :acknowledged
|
|
121
|
+
alert[:acknowledged_by] = acknowledged_by
|
|
122
|
+
alert[:acknowledged_at] = Time.now.utc
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Resolve alert
|
|
128
|
+
def resolve_alert(alert_id, resolved_by: "system")
|
|
129
|
+
synchronize do
|
|
130
|
+
alert = @alerts.find { |a| a[:id] == alert_id }
|
|
131
|
+
if alert
|
|
132
|
+
alert[:status] = :resolved
|
|
133
|
+
alert[:resolved_by] = resolved_by
|
|
134
|
+
alert[:resolved_at] = Time.now.utc
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Clear old alerts
|
|
140
|
+
def clear_old_alerts(older_than: 86_400)
|
|
141
|
+
synchronize do
|
|
142
|
+
cutoff = Time.now.utc - older_than
|
|
143
|
+
@alerts.reject! { |a| a[:triggered_at] < cutoff && a[:status] != :active }
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Built-in alert conditions
|
|
148
|
+
def self.high_error_rate(threshold: 0.1)
|
|
149
|
+
lambda do |stats|
|
|
150
|
+
total_ops = stats.dig(:performance, :total_operations) || 0
|
|
151
|
+
return false if total_ops.zero?
|
|
152
|
+
|
|
153
|
+
success_rate = stats.dig(:performance, :success_rate) || 1.0
|
|
154
|
+
(1.0 - success_rate) > threshold
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def self.low_confidence(threshold: 0.5)
|
|
159
|
+
lambda do |stats|
|
|
160
|
+
avg_confidence = stats.dig(:decisions, :avg_confidence)
|
|
161
|
+
avg_confidence && avg_confidence < threshold
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def self.high_latency(threshold_ms: 1000)
|
|
166
|
+
lambda do |stats|
|
|
167
|
+
p95 = stats.dig(:performance, :p95_duration_ms)
|
|
168
|
+
p95 && p95 > threshold_ms
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def self.error_spike(threshold: 10, time_window: 300)
|
|
173
|
+
lambda do |stats|
|
|
174
|
+
recent_errors = stats.dig(:errors, :total) || 0
|
|
175
|
+
recent_errors > threshold
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def self.decision_anomaly(expected_rate: 100, variance: 0.3)
|
|
180
|
+
lambda do |stats|
|
|
181
|
+
total = stats.dig(:decisions, :total) || 0
|
|
182
|
+
time_range = stats.dig(:summary, :time_range)
|
|
183
|
+
|
|
184
|
+
# Simple anomaly detection based on rate
|
|
185
|
+
return false unless time_range
|
|
186
|
+
|
|
187
|
+
lower_bound = expected_rate * (1 - variance)
|
|
188
|
+
upper_bound = expected_rate * (1 + variance)
|
|
189
|
+
|
|
190
|
+
total < lower_bound || total > upper_bound
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
def freeze_config
|
|
197
|
+
# No immutable config to freeze yet
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def generate_rule_id(name)
|
|
201
|
+
"#{sanitize_name(name)}_#{Time.now.to_i}_#{rand(1000)}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def sanitize_name(name)
|
|
205
|
+
name.to_s.downcase.gsub(/[^a-z0-9]/, "_")
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def in_cooldown?(rule)
|
|
209
|
+
return false unless rule[:last_triggered]
|
|
210
|
+
|
|
211
|
+
Time.now.utc - rule[:last_triggered] < rule[:cooldown]
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def evaluate_condition(condition, stats)
|
|
215
|
+
case condition
|
|
216
|
+
when Proc
|
|
217
|
+
condition.call(stats)
|
|
218
|
+
when Hash
|
|
219
|
+
evaluate_hash_condition(condition, stats)
|
|
220
|
+
else
|
|
221
|
+
false
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def evaluate_hash_condition(condition, stats)
|
|
226
|
+
# Support simple hash-based conditions
|
|
227
|
+
# Example: { metric: "decisions.avg_confidence", op: "lt", value: 0.5 }
|
|
228
|
+
metric_path = condition[:metric]&.split(".")
|
|
229
|
+
return false unless metric_path
|
|
230
|
+
|
|
231
|
+
value = stats.dig(*metric_path.map(&:to_sym))
|
|
232
|
+
return false if value.nil?
|
|
233
|
+
|
|
234
|
+
case condition[:op]
|
|
235
|
+
when "gt", ">"
|
|
236
|
+
value > condition[:value]
|
|
237
|
+
when "lt", "<"
|
|
238
|
+
value < condition[:value]
|
|
239
|
+
when "eq", "=="
|
|
240
|
+
value == condition[:value]
|
|
241
|
+
when "gte", ">="
|
|
242
|
+
value >= condition[:value]
|
|
243
|
+
when "lte", "<="
|
|
244
|
+
value <= condition[:value]
|
|
245
|
+
else
|
|
246
|
+
false
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def trigger_alert(rule, stats)
|
|
251
|
+
alert = {
|
|
252
|
+
id: "alert_#{Time.now.to_i}_#{rand(10_000)}",
|
|
253
|
+
rule_id: rule[:id],
|
|
254
|
+
rule_name: rule[:name],
|
|
255
|
+
severity: rule[:severity],
|
|
256
|
+
message: rule[:message],
|
|
257
|
+
triggered_at: Time.now.utc,
|
|
258
|
+
status: :active,
|
|
259
|
+
context: {
|
|
260
|
+
stats_snapshot: stats
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
@alerts << alert
|
|
265
|
+
rule[:last_triggered] = Time.now.utc
|
|
266
|
+
|
|
267
|
+
# Notify handlers
|
|
268
|
+
notify_handlers(alert)
|
|
269
|
+
|
|
270
|
+
alert
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def notify_handlers(alert)
|
|
274
|
+
@alert_handlers.each do |handler|
|
|
275
|
+
handler.call(alert)
|
|
276
|
+
rescue StandardError => e
|
|
277
|
+
warn "Alert handler failed: #{e.message}"
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|