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,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
@@ -0,0 +1,381 @@
1
+ * {
2
+ margin: 0;
3
+ padding: 0;
4
+ box-sizing: border-box;
5
+ }
6
+
7
+ body {
8
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
9
+ background: #0f1419;
10
+ color: #e6edf3;
11
+ line-height: 1.6;
12
+ }
13
+
14
+ .dashboard {
15
+ max-width: 1600px;
16
+ margin: 0 auto;
17
+ padding: 20px;
18
+ }
19
+
20
+ .header {
21
+ display: flex;
22
+ justify-content: space-between;
23
+ align-items: center;
24
+ margin-bottom: 30px;
25
+ padding: 20px;
26
+ background: #161b22;
27
+ border-radius: 8px;
28
+ border: 1px solid #30363d;
29
+ }
30
+
31
+ .header h1 {
32
+ font-size: 28px;
33
+ color: #58a6ff;
34
+ }
35
+
36
+ .header-info {
37
+ display: flex;
38
+ gap: 20px;
39
+ align-items: center;
40
+ }
41
+
42
+ .status-badge {
43
+ padding: 6px 12px;
44
+ border-radius: 20px;
45
+ font-size: 14px;
46
+ font-weight: 600;
47
+ }
48
+
49
+ .status-badge.connected {
50
+ background: #238636;
51
+ color: #fff;
52
+ }
53
+
54
+ .status-badge.disconnected {
55
+ background: #da3633;
56
+ color: #fff;
57
+ }
58
+
59
+ #last-update {
60
+ color: #8b949e;
61
+ font-size: 14px;
62
+ }
63
+
64
+ .alert-bar {
65
+ background: #da3633;
66
+ color: #fff;
67
+ padding: 15px 20px;
68
+ margin-bottom: 20px;
69
+ border-radius: 8px;
70
+ display: flex;
71
+ justify-content: space-between;
72
+ align-items: center;
73
+ }
74
+
75
+ .alert-content {
76
+ display: flex;
77
+ align-items: center;
78
+ gap: 10px;
79
+ width: 100%;
80
+ }
81
+
82
+ .alert-icon {
83
+ font-size: 24px;
84
+ }
85
+
86
+ .alert-close {
87
+ background: none;
88
+ border: none;
89
+ color: #fff;
90
+ font-size: 24px;
91
+ cursor: pointer;
92
+ padding: 0 10px;
93
+ }
94
+
95
+ .metrics-grid {
96
+ display: grid;
97
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
98
+ gap: 20px;
99
+ margin-bottom: 30px;
100
+ }
101
+
102
+ .metric-card {
103
+ background: #161b22;
104
+ padding: 24px;
105
+ border-radius: 8px;
106
+ border: 1px solid #30363d;
107
+ transition: transform 0.2s, box-shadow 0.2s;
108
+ }
109
+
110
+ .metric-card:hover {
111
+ transform: translateY(-2px);
112
+ box-shadow: 0 4px 12px rgba(88, 166, 255, 0.1);
113
+ }
114
+
115
+ .metric-card.error-card {
116
+ border-color: #da3633;
117
+ }
118
+
119
+ .metric-label {
120
+ font-size: 14px;
121
+ color: #8b949e;
122
+ margin-bottom: 8px;
123
+ }
124
+
125
+ .metric-value {
126
+ font-size: 32px;
127
+ font-weight: 700;
128
+ color: #58a6ff;
129
+ margin-bottom: 8px;
130
+ }
131
+
132
+ .metric-change {
133
+ font-size: 12px;
134
+ color: #8b949e;
135
+ }
136
+
137
+ .metric-change.positive {
138
+ color: #3fb950;
139
+ }
140
+
141
+ .metric-change.negative {
142
+ color: #da3633;
143
+ }
144
+
145
+ .charts-grid {
146
+ display: grid;
147
+ grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
148
+ gap: 20px;
149
+ margin-bottom: 30px;
150
+ }
151
+
152
+ .chart-card {
153
+ background: #161b22;
154
+ padding: 24px;
155
+ border-radius: 8px;
156
+ border: 1px solid #30363d;
157
+ }
158
+
159
+ .chart-card h3 {
160
+ margin-bottom: 20px;
161
+ color: #e6edf3;
162
+ font-size: 18px;
163
+ }
164
+
165
+ .data-grid {
166
+ display: grid;
167
+ grid-template-columns: repeat(auto-fit, minmax(600px, 1fr));
168
+ gap: 20px;
169
+ margin-bottom: 30px;
170
+ }
171
+
172
+ .data-card {
173
+ background: #161b22;
174
+ padding: 24px;
175
+ border-radius: 8px;
176
+ border: 1px solid #30363d;
177
+ }
178
+
179
+ .data-header {
180
+ display: flex;
181
+ justify-content: space-between;
182
+ align-items: center;
183
+ margin-bottom: 20px;
184
+ }
185
+
186
+ .data-header h3 {
187
+ color: #e6edf3;
188
+ font-size: 18px;
189
+ }
190
+
191
+ .btn-small {
192
+ background: #238636;
193
+ color: #fff;
194
+ border: none;
195
+ padding: 6px 12px;
196
+ border-radius: 6px;
197
+ cursor: pointer;
198
+ font-size: 14px;
199
+ transition: background 0.2s;
200
+ }
201
+
202
+ .btn-small:hover {
203
+ background: #2ea043;
204
+ }
205
+
206
+ .btn-primary {
207
+ background: #58a6ff;
208
+ color: #0d1117;
209
+ border: none;
210
+ padding: 10px 20px;
211
+ border-radius: 6px;
212
+ cursor: pointer;
213
+ font-size: 14px;
214
+ font-weight: 600;
215
+ transition: background 0.2s;
216
+ }
217
+
218
+ .btn-primary:hover {
219
+ background: #79c0ff;
220
+ }
221
+
222
+ .table-container {
223
+ overflow-x: auto;
224
+ }
225
+
226
+ table {
227
+ width: 100%;
228
+ border-collapse: collapse;
229
+ }
230
+
231
+ thead {
232
+ background: #0d1117;
233
+ }
234
+
235
+ th {
236
+ padding: 12px;
237
+ text-align: left;
238
+ color: #8b949e;
239
+ font-weight: 600;
240
+ font-size: 12px;
241
+ text-transform: uppercase;
242
+ border-bottom: 1px solid #30363d;
243
+ }
244
+
245
+ td {
246
+ padding: 12px;
247
+ border-bottom: 1px solid #21262d;
248
+ color: #e6edf3;
249
+ }
250
+
251
+ tr:hover {
252
+ background: #0d1117;
253
+ }
254
+
255
+ .no-data {
256
+ text-align: center;
257
+ color: #8b949e;
258
+ padding: 40px !important;
259
+ }
260
+
261
+ .severity-badge {
262
+ padding: 4px 8px;
263
+ border-radius: 4px;
264
+ font-size: 12px;
265
+ font-weight: 600;
266
+ }
267
+
268
+ .severity-critical {
269
+ background: #da3633;
270
+ color: #fff;
271
+ }
272
+
273
+ .severity-warning {
274
+ background: #d29922;
275
+ color: #0d1117;
276
+ }
277
+
278
+ .severity-info {
279
+ background: #58a6ff;
280
+ color: #0d1117;
281
+ }
282
+
283
+ .alert-actions {
284
+ display: flex;
285
+ gap: 8px;
286
+ }
287
+
288
+ .alert-actions button {
289
+ background: #238636;
290
+ color: #fff;
291
+ border: none;
292
+ padding: 4px 10px;
293
+ border-radius: 4px;
294
+ cursor: pointer;
295
+ font-size: 12px;
296
+ }
297
+
298
+ .alert-actions button:hover {
299
+ background: #2ea043;
300
+ }
301
+
302
+ .system-metrics {
303
+ display: grid;
304
+ gap: 16px;
305
+ }
306
+
307
+ .system-metric {
308
+ display: flex;
309
+ justify-content: space-between;
310
+ padding: 12px 0;
311
+ border-bottom: 1px solid #21262d;
312
+ }
313
+
314
+ .system-metric:last-child {
315
+ border-bottom: none;
316
+ }
317
+
318
+ .metric-name {
319
+ color: #8b949e;
320
+ font-size: 14px;
321
+ }
322
+
323
+ .metric-val {
324
+ color: #58a6ff;
325
+ font-weight: 600;
326
+ font-size: 14px;
327
+ }
328
+
329
+ .kpi-section {
330
+ background: #161b22;
331
+ padding: 24px;
332
+ border-radius: 8px;
333
+ border: 1px solid #30363d;
334
+ }
335
+
336
+ .kpi-section h3 {
337
+ margin-bottom: 20px;
338
+ color: #e6edf3;
339
+ font-size: 18px;
340
+ }
341
+
342
+ .form-group {
343
+ display: flex;
344
+ gap: 12px;
345
+ align-items: center;
346
+ }
347
+
348
+ .form-group input {
349
+ flex: 1;
350
+ background: #0d1117;
351
+ border: 1px solid #30363d;
352
+ color: #e6edf3;
353
+ padding: 10px 12px;
354
+ border-radius: 6px;
355
+ font-size: 14px;
356
+ }
357
+
358
+ .form-group input:focus {
359
+ outline: none;
360
+ border-color: #58a6ff;
361
+ }
362
+
363
+ @media (max-width: 768px) {
364
+ .metrics-grid {
365
+ grid-template-columns: 1fr;
366
+ }
367
+
368
+ .charts-grid {
369
+ grid-template-columns: 1fr;
370
+ }
371
+
372
+ .data-grid {
373
+ grid-template-columns: 1fr;
374
+ }
375
+
376
+ .header {
377
+ flex-direction: column;
378
+ gap: 15px;
379
+ text-align: center;
380
+ }
381
+ }