decision_agent 0.1.3 → 0.1.6

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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +84 -233
  3. data/lib/decision_agent/ab_testing/ab_test.rb +197 -0
  4. data/lib/decision_agent/ab_testing/ab_test_assignment.rb +76 -0
  5. data/lib/decision_agent/ab_testing/ab_test_manager.rb +317 -0
  6. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +188 -0
  7. data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +155 -0
  8. data/lib/decision_agent/ab_testing/storage/adapter.rb +67 -0
  9. data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +116 -0
  10. data/lib/decision_agent/agent.rb +5 -3
  11. data/lib/decision_agent/auth/access_audit_logger.rb +122 -0
  12. data/lib/decision_agent/auth/authenticator.rb +127 -0
  13. data/lib/decision_agent/auth/password_reset_manager.rb +57 -0
  14. data/lib/decision_agent/auth/password_reset_token.rb +33 -0
  15. data/lib/decision_agent/auth/permission.rb +29 -0
  16. data/lib/decision_agent/auth/permission_checker.rb +43 -0
  17. data/lib/decision_agent/auth/rbac_adapter.rb +278 -0
  18. data/lib/decision_agent/auth/rbac_config.rb +51 -0
  19. data/lib/decision_agent/auth/role.rb +56 -0
  20. data/lib/decision_agent/auth/session.rb +33 -0
  21. data/lib/decision_agent/auth/session_manager.rb +57 -0
  22. data/lib/decision_agent/auth/user.rb +70 -0
  23. data/lib/decision_agent/context.rb +24 -4
  24. data/lib/decision_agent/decision.rb +10 -3
  25. data/lib/decision_agent/dsl/condition_evaluator.rb +378 -1
  26. data/lib/decision_agent/dsl/schema_validator.rb +8 -1
  27. data/lib/decision_agent/errors.rb +38 -0
  28. data/lib/decision_agent/evaluation.rb +10 -3
  29. data/lib/decision_agent/evaluation_validator.rb +8 -13
  30. data/lib/decision_agent/monitoring/dashboard_server.rb +1 -0
  31. data/lib/decision_agent/monitoring/metrics_collector.rb +164 -7
  32. data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +253 -0
  33. data/lib/decision_agent/monitoring/storage/base_adapter.rb +90 -0
  34. data/lib/decision_agent/monitoring/storage/memory_adapter.rb +222 -0
  35. data/lib/decision_agent/testing/batch_test_importer.rb +373 -0
  36. data/lib/decision_agent/testing/batch_test_runner.rb +244 -0
  37. data/lib/decision_agent/testing/test_coverage_analyzer.rb +191 -0
  38. data/lib/decision_agent/testing/test_result_comparator.rb +235 -0
  39. data/lib/decision_agent/testing/test_scenario.rb +42 -0
  40. data/lib/decision_agent/version.rb +10 -1
  41. data/lib/decision_agent/versioning/activerecord_adapter.rb +1 -1
  42. data/lib/decision_agent/versioning/file_storage_adapter.rb +96 -28
  43. data/lib/decision_agent/web/middleware/auth_middleware.rb +45 -0
  44. data/lib/decision_agent/web/middleware/permission_middleware.rb +94 -0
  45. data/lib/decision_agent/web/public/app.js +184 -29
  46. data/lib/decision_agent/web/public/batch_testing.html +640 -0
  47. data/lib/decision_agent/web/public/index.html +37 -9
  48. data/lib/decision_agent/web/public/login.html +298 -0
  49. data/lib/decision_agent/web/public/users.html +679 -0
  50. data/lib/decision_agent/web/server.rb +873 -7
  51. data/lib/decision_agent.rb +59 -0
  52. data/lib/generators/decision_agent/install/install_generator.rb +37 -0
  53. data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +45 -0
  54. data/lib/generators/decision_agent/install/templates/ab_test_model.rb +54 -0
  55. data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +43 -0
  56. data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +189 -0
  57. data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +114 -0
  58. data/lib/generators/decision_agent/install/templates/decision_log.rb +57 -0
  59. data/lib/generators/decision_agent/install/templates/error_metric.rb +53 -0
  60. data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +43 -0
  61. data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +109 -0
  62. data/lib/generators/decision_agent/install/templates/performance_metric.rb +76 -0
  63. data/lib/generators/decision_agent/install/templates/rule_version.rb +1 -1
  64. data/spec/ab_testing/ab_test_assignment_spec.rb +253 -0
  65. data/spec/ab_testing/ab_test_manager_spec.rb +612 -0
  66. data/spec/ab_testing/ab_test_spec.rb +270 -0
  67. data/spec/ab_testing/ab_testing_agent_spec.rb +481 -0
  68. data/spec/ab_testing/storage/adapter_spec.rb +64 -0
  69. data/spec/ab_testing/storage/memory_adapter_spec.rb +485 -0
  70. data/spec/advanced_operators_spec.rb +1003 -0
  71. data/spec/agent_spec.rb +40 -0
  72. data/spec/audit_adapters_spec.rb +18 -0
  73. data/spec/auth/access_audit_logger_spec.rb +394 -0
  74. data/spec/auth/authenticator_spec.rb +112 -0
  75. data/spec/auth/password_reset_spec.rb +294 -0
  76. data/spec/auth/permission_checker_spec.rb +207 -0
  77. data/spec/auth/permission_spec.rb +73 -0
  78. data/spec/auth/rbac_adapter_spec.rb +550 -0
  79. data/spec/auth/rbac_config_spec.rb +82 -0
  80. data/spec/auth/role_spec.rb +51 -0
  81. data/spec/auth/session_manager_spec.rb +172 -0
  82. data/spec/auth/session_spec.rb +112 -0
  83. data/spec/auth/user_spec.rb +130 -0
  84. data/spec/context_spec.rb +43 -0
  85. data/spec/decision_agent_spec.rb +96 -0
  86. data/spec/decision_spec.rb +423 -0
  87. data/spec/dsl/condition_evaluator_spec.rb +774 -0
  88. data/spec/evaluation_spec.rb +364 -0
  89. data/spec/evaluation_validator_spec.rb +165 -0
  90. data/spec/examples.txt +1542 -548
  91. data/spec/issue_verification_spec.rb +95 -21
  92. data/spec/monitoring/metrics_collector_spec.rb +221 -3
  93. data/spec/monitoring/monitored_agent_spec.rb +1 -1
  94. data/spec/monitoring/prometheus_exporter_spec.rb +1 -1
  95. data/spec/monitoring/storage/activerecord_adapter_spec.rb +498 -0
  96. data/spec/monitoring/storage/base_adapter_spec.rb +61 -0
  97. data/spec/monitoring/storage/memory_adapter_spec.rb +247 -0
  98. data/spec/performance_optimizations_spec.rb +486 -0
  99. data/spec/spec_helper.rb +23 -0
  100. data/spec/testing/batch_test_importer_spec.rb +693 -0
  101. data/spec/testing/batch_test_runner_spec.rb +307 -0
  102. data/spec/testing/test_coverage_analyzer_spec.rb +292 -0
  103. data/spec/testing/test_result_comparator_spec.rb +392 -0
  104. data/spec/testing/test_scenario_spec.rb +113 -0
  105. data/spec/versioning/adapter_spec.rb +156 -0
  106. data/spec/versioning_spec.rb +253 -0
  107. data/spec/web/middleware/auth_middleware_spec.rb +133 -0
  108. data/spec/web/middleware/permission_middleware_spec.rb +247 -0
  109. data/spec/web_ui_rack_spec.rb +1705 -0
  110. metadata +123 -6
@@ -1,5 +1,12 @@
1
1
  require "monitor"
2
2
  require "time"
3
+ require_relative "storage/memory_adapter"
4
+
5
+ begin
6
+ require_relative "storage/activerecord_adapter"
7
+ rescue LoadError, NameError
8
+ # ActiveRecord adapter not available
9
+ end
3
10
 
4
11
  module DecisionAgent
5
12
  module Monitoring
@@ -7,11 +14,16 @@ module DecisionAgent
7
14
  class MetricsCollector
8
15
  include MonitorMixin
9
16
 
10
- attr_reader :metrics, :window_size
17
+ attr_reader :metrics, :window_size, :storage_adapter
11
18
 
12
- def initialize(window_size: 3600)
19
+ def initialize(window_size: 3600, storage: :auto, cleanup_threshold: 100)
13
20
  super()
14
21
  @window_size = window_size # Default: 1 hour window
22
+ @cleanup_threshold = cleanup_threshold # Cleanup every N records
23
+ @cleanup_counter = 0
24
+ @storage_adapter = initialize_storage_adapter(storage, window_size)
25
+
26
+ # Legacy in-memory metrics for backward compatibility with observers
15
27
  @metrics = {
16
28
  decisions: [],
17
29
  evaluations: [],
@@ -35,8 +47,20 @@ module DecisionAgent
35
47
  evaluator_names: decision.evaluations.map(&:evaluator_name).uniq
36
48
  }
37
49
 
50
+ # Store in-memory for observers (backward compatibility)
38
51
  @metrics[:decisions] << metric
39
- cleanup_old_metrics!
52
+ maybe_cleanup_old_metrics!
53
+
54
+ # Persist to storage adapter
55
+ @storage_adapter.record_decision(
56
+ decision.decision,
57
+ context.to_h,
58
+ confidence: decision.confidence,
59
+ evaluations_count: decision.evaluations.size,
60
+ duration_ms: duration_ms,
61
+ status: determine_decision_status(decision)
62
+ )
63
+
40
64
  notify_observers(:decision, metric)
41
65
  metric
42
66
  end
@@ -52,8 +76,18 @@ module DecisionAgent
52
76
  evaluator_name: evaluation.evaluator_name
53
77
  }
54
78
 
79
+ # Store in-memory for observers (backward compatibility)
55
80
  @metrics[:evaluations] << metric
56
- cleanup_old_metrics!
81
+ maybe_cleanup_old_metrics!
82
+
83
+ # Persist to storage adapter
84
+ @storage_adapter.record_evaluation(
85
+ evaluation.evaluator_name,
86
+ score: evaluation.weight,
87
+ success: evaluation.weight.positive?,
88
+ details: { decision: evaluation.decision }
89
+ )
90
+
57
91
  notify_observers(:evaluation, metric)
58
92
  metric
59
93
  end
@@ -70,8 +104,18 @@ module DecisionAgent
70
104
  metadata: metadata
71
105
  }
72
106
 
107
+ # Store in-memory for observers (backward compatibility)
73
108
  @metrics[:performance] << metric
74
- cleanup_old_metrics!
109
+ maybe_cleanup_old_metrics!
110
+
111
+ # Persist to storage adapter
112
+ @storage_adapter.record_performance(
113
+ operation,
114
+ duration_ms: duration_ms,
115
+ status: success ? "success" : "failure",
116
+ metadata: metadata
117
+ )
118
+
75
119
  notify_observers(:performance, metric)
76
120
  metric
77
121
  end
@@ -87,8 +131,19 @@ module DecisionAgent
87
131
  context: context
88
132
  }
89
133
 
134
+ # Store in-memory for observers (backward compatibility)
90
135
  @metrics[:errors] << metric
91
- cleanup_old_metrics!
136
+ maybe_cleanup_old_metrics!
137
+
138
+ # Persist to storage adapter
139
+ @storage_adapter.record_error(
140
+ error.class.name,
141
+ message: error.message,
142
+ stack_trace: error.backtrace,
143
+ severity: determine_error_severity(error),
144
+ context: context
145
+ )
146
+
92
147
  notify_observers(:error, metric)
93
148
  metric
94
149
  end
@@ -97,6 +152,18 @@ module DecisionAgent
97
152
  # Get aggregated statistics
98
153
  def statistics(time_range: nil)
99
154
  synchronize do
155
+ # Use in-memory metrics for MemoryAdapter (to maintain backward compatibility)
156
+ # Only delegate to ActiveRecordAdapter for persistent storage
157
+ use_storage = time_range &&
158
+ @storage_adapter.respond_to?(:statistics) &&
159
+ !@storage_adapter.is_a?(Storage::MemoryAdapter)
160
+
161
+ if use_storage
162
+ stats = @storage_adapter.statistics(time_range: time_range)
163
+ return stats.merge(timestamp: Time.now.utc, storage: @storage_adapter.class.name) if stats
164
+ end
165
+
166
+ # Use in-memory metrics
100
167
  range_start = time_range ? Time.now.utc - time_range : nil
101
168
 
102
169
  decisions = filter_by_time(@metrics[:decisions], range_start)
@@ -115,7 +182,8 @@ module DecisionAgent
115
182
  evaluations: compute_evaluation_stats(evaluations),
116
183
  performance: compute_performance_stats(performance),
117
184
  errors: compute_error_stats(errors),
118
- timestamp: Time.now.utc
185
+ timestamp: Time.now.utc,
186
+ storage: "memory (fallback)"
119
187
  }
120
188
  end
121
189
  end
@@ -123,6 +191,17 @@ module DecisionAgent
123
191
  # Get time-series data for graphing
124
192
  def time_series(metric_type:, bucket_size: 60, time_range: 3600)
125
193
  synchronize do
194
+ # Use in-memory metrics for MemoryAdapter (to maintain backward compatibility)
195
+ # Only delegate to ActiveRecordAdapter for persistent storage
196
+ use_storage = @storage_adapter.respond_to?(:time_series) &&
197
+ !@storage_adapter.is_a?(Storage::MemoryAdapter)
198
+
199
+ if use_storage
200
+ series = @storage_adapter.time_series(metric_type, bucket_size: bucket_size, time_range: time_range)
201
+ return series if series && series[:timestamps]
202
+ end
203
+
204
+ # Use in-memory metrics
126
205
  data = @metrics[metric_type] || []
127
206
  range_start = Time.now.utc - time_range
128
207
 
@@ -156,22 +235,100 @@ module DecisionAgent
156
235
  def clear!
157
236
  synchronize do
158
237
  @metrics.each_value(&:clear)
238
+ # Also clear storage adapter if using MemoryAdapter
239
+ if @storage_adapter.is_a?(Storage::MemoryAdapter)
240
+ # Clear all by using a very large time period (100 years in seconds)
241
+ @storage_adapter.cleanup(older_than: 100 * 365 * 24 * 60 * 60)
242
+ end
159
243
  end
160
244
  end
161
245
 
162
246
  # Get current metrics count
163
247
  def metrics_count
164
248
  synchronize do
249
+ # Use in-memory metrics for MemoryAdapter (to maintain backward compatibility)
250
+ # Only delegate to ActiveRecordAdapter for persistent storage
251
+ use_storage = @storage_adapter.respond_to?(:metrics_count) &&
252
+ !@storage_adapter.is_a?(Storage::MemoryAdapter)
253
+
254
+ return @storage_adapter.metrics_count if use_storage
255
+
256
+ # Use in-memory
165
257
  @metrics.transform_values(&:size)
166
258
  end
167
259
  end
168
260
 
261
+ # Cleanup old metrics from persistent storage
262
+ def cleanup_old_metrics_from_storage(older_than:)
263
+ synchronize do
264
+ return 0 unless @storage_adapter.respond_to?(:cleanup)
265
+
266
+ @storage_adapter.cleanup(older_than: older_than)
267
+ end
268
+ end
269
+
169
270
  private
170
271
 
171
272
  def freeze_config
172
273
  @window_size.freeze
173
274
  end
174
275
 
276
+ def initialize_storage_adapter(storage_option, window_size)
277
+ case storage_option
278
+ when :auto
279
+ # Auto-detect: prefer ActiveRecord if available
280
+ if defined?(DecisionAgent::Monitoring::Storage::ActiveRecordAdapter) &&
281
+ DecisionAgent::Monitoring::Storage::ActiveRecordAdapter.available?
282
+ DecisionAgent::Monitoring::Storage::ActiveRecordAdapter.new
283
+ else
284
+ DecisionAgent::Monitoring::Storage::MemoryAdapter.new(window_size: window_size)
285
+ end
286
+ when :activerecord, :database
287
+ unless defined?(DecisionAgent::Monitoring::Storage::ActiveRecordAdapter)
288
+ raise "ActiveRecord adapter not available. Install models or use :memory storage."
289
+ end
290
+
291
+ DecisionAgent::Monitoring::Storage::ActiveRecordAdapter.new
292
+ when :memory
293
+ DecisionAgent::Monitoring::Storage::MemoryAdapter.new(window_size: window_size)
294
+ when Symbol
295
+ raise ArgumentError, "Unknown storage option: #{storage_option}. Use :auto, :activerecord, or :memory"
296
+ else
297
+ # Custom adapter instance provided
298
+ storage_option
299
+ end
300
+ end
301
+
302
+ def determine_decision_status(decision)
303
+ return "success" if decision.confidence >= 0.7
304
+ return "failure" if decision.confidence < 0.3
305
+
306
+ "success" # Default for medium confidence
307
+ end
308
+
309
+ def determine_error_severity(error)
310
+ case error
311
+ when ArgumentError, TypeError
312
+ "medium"
313
+ when StandardError
314
+ "low"
315
+ when Exception
316
+ "critical"
317
+ else
318
+ "low"
319
+ end
320
+ end
321
+
322
+ # Conditionally cleanup old metrics based on counter
323
+ # This reduces O(n) array scans from every record to every N records
324
+ def maybe_cleanup_old_metrics!
325
+ @cleanup_counter += 1
326
+ return unless @cleanup_counter >= @cleanup_threshold
327
+
328
+ @cleanup_counter = 0
329
+ cleanup_old_metrics!
330
+ end
331
+
175
332
  def cleanup_old_metrics!
176
333
  cutoff_time = Time.now.utc - @window_size
177
334
 
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_adapter"
4
+
5
+ module DecisionAgent
6
+ module Monitoring
7
+ module Storage
8
+ # ActiveRecord adapter for persistent database storage
9
+ class ActiveRecordAdapter < BaseAdapter
10
+ def initialize
11
+ super
12
+ validate_models!
13
+ end
14
+
15
+ def record_decision(decision, context, confidence: nil, evaluations_count: 0, duration_ms: nil, status: nil)
16
+ ::DecisionLog.create!(
17
+ decision: decision,
18
+ context: context.to_json,
19
+ confidence: confidence,
20
+ evaluations_count: evaluations_count,
21
+ duration_ms: duration_ms,
22
+ status: status
23
+ )
24
+ rescue StandardError => e
25
+ warn "Failed to record decision to database: #{e.message}"
26
+ end
27
+
28
+ def record_evaluation(evaluator_name, score: nil, success: nil, duration_ms: nil, details: {})
29
+ ::EvaluationMetric.create!(
30
+ evaluator_name: evaluator_name,
31
+ score: score,
32
+ success: success,
33
+ duration_ms: duration_ms,
34
+ details: details.to_json
35
+ )
36
+ rescue StandardError => e
37
+ warn "Failed to record evaluation to database: #{e.message}"
38
+ end
39
+
40
+ def record_performance(operation, duration_ms: nil, status: nil, metadata: {})
41
+ ::PerformanceMetric.create!(
42
+ operation: operation,
43
+ duration_ms: duration_ms,
44
+ status: status,
45
+ metadata: metadata.to_json
46
+ )
47
+ rescue StandardError => e
48
+ warn "Failed to record performance to database: #{e.message}"
49
+ end
50
+
51
+ def record_error(error_type, message: nil, stack_trace: nil, severity: nil, context: {})
52
+ ::ErrorMetric.create!(
53
+ error_type: error_type,
54
+ message: message,
55
+ stack_trace: stack_trace&.to_json,
56
+ severity: severity,
57
+ context: context.to_json
58
+ )
59
+ rescue StandardError => e
60
+ warn "Failed to record error to database: #{e.message}"
61
+ end
62
+
63
+ def statistics(time_range: 3600)
64
+ decisions = ::DecisionLog.recent(time_range)
65
+ evaluations = ::EvaluationMetric.recent(time_range)
66
+ performance = ::PerformanceMetric.recent(time_range)
67
+ errors = ::ErrorMetric.recent(time_range)
68
+
69
+ {
70
+ decisions: decision_statistics(decisions, time_range),
71
+ evaluations: evaluation_statistics(evaluations),
72
+ performance: performance_statistics(performance, time_range),
73
+ errors: error_statistics(errors)
74
+ }
75
+ rescue StandardError => e
76
+ warn "Failed to retrieve statistics from database: #{e.message}"
77
+ default_statistics
78
+ end
79
+
80
+ def time_series(metric_type, bucket_size: 60, time_range: 3600)
81
+ case metric_type
82
+ when :decisions
83
+ decisions_time_series(bucket_size, time_range)
84
+ when :evaluations
85
+ evaluations_time_series(bucket_size, time_range)
86
+ when :performance
87
+ performance_time_series(bucket_size, time_range)
88
+ when :errors
89
+ errors_time_series(bucket_size, time_range)
90
+ else
91
+ { data: [], timestamps: [] }
92
+ end
93
+ rescue StandardError => e
94
+ warn "Failed to retrieve time series from database: #{e.message}"
95
+ { data: [], timestamps: [] }
96
+ end
97
+
98
+ def metrics_count
99
+ {
100
+ decisions: ::DecisionLog.count,
101
+ evaluations: ::EvaluationMetric.count,
102
+ performance: ::PerformanceMetric.count,
103
+ errors: ::ErrorMetric.count
104
+ }
105
+ rescue StandardError => e
106
+ warn "Failed to get metrics count from database: #{e.message}"
107
+ { decisions: 0, evaluations: 0, performance: 0, errors: 0 }
108
+ end
109
+
110
+ def cleanup(older_than:)
111
+ cutoff_time = Time.now - older_than
112
+ count = 0
113
+
114
+ count += ::DecisionLog.where("created_at < ?", cutoff_time).delete_all
115
+ count += ::EvaluationMetric.where("created_at < ?", cutoff_time).delete_all
116
+ count += ::PerformanceMetric.where("created_at < ?", cutoff_time).delete_all
117
+ count += ::ErrorMetric.where("created_at < ?", cutoff_time).delete_all
118
+
119
+ count
120
+ rescue StandardError => e
121
+ warn "Failed to cleanup old metrics from database: #{e.message}"
122
+ 0
123
+ end
124
+
125
+ def self.available?
126
+ defined?(ActiveRecord) &&
127
+ defined?(::DecisionLog) &&
128
+ defined?(::EvaluationMetric) &&
129
+ defined?(::PerformanceMetric) &&
130
+ defined?(::ErrorMetric)
131
+ end
132
+
133
+ private
134
+
135
+ def decision_statistics(decisions, time_range)
136
+ {
137
+ total: decisions.count,
138
+ by_decision: decisions.group(:decision).count,
139
+ average_confidence: decisions.where.not(confidence: nil).average(:confidence).to_f,
140
+ success_rate: ::DecisionLog.success_rate(time_range: time_range)
141
+ }
142
+ end
143
+
144
+ def evaluation_statistics(evaluations)
145
+ {
146
+ total: evaluations.count,
147
+ by_evaluator: evaluations.group(:evaluator_name).count,
148
+ average_score: evaluations.where.not(score: nil).average(:score).to_f,
149
+ success_rate_by_evaluator: evaluations.successful.group(:evaluator_name).count
150
+ }
151
+ end
152
+
153
+ def performance_statistics(performance, time_range)
154
+ {
155
+ total: performance.count,
156
+ average_duration_ms: performance.average_duration(time_range: time_range),
157
+ p50: performance.p50(time_range: time_range),
158
+ p95: performance.p95(time_range: time_range),
159
+ p99: performance.p99(time_range: time_range),
160
+ success_rate: performance.success_rate(time_range: time_range)
161
+ }
162
+ end
163
+
164
+ def error_statistics(errors)
165
+ {
166
+ total: errors.count,
167
+ by_type: errors.group(:error_type).count,
168
+ by_severity: errors.group(:severity).count,
169
+ critical_count: errors.critical.count
170
+ }
171
+ end
172
+
173
+ def validate_models!
174
+ required_models = %w[DecisionLog EvaluationMetric PerformanceMetric ErrorMetric]
175
+ missing_models = required_models.reject { |model| Object.const_defined?(model) }
176
+
177
+ return if missing_models.empty?
178
+
179
+ raise "Missing required models: #{missing_models.join(', ')}. " \
180
+ "Run 'rails generate decision_agent:install --monitoring' to create them."
181
+ end
182
+
183
+ def decisions_time_series(bucket_size, time_range)
184
+ counts = ::DecisionLog.recent(time_range)
185
+ .group(time_bucket_sql(:created_at, bucket_size))
186
+ .count
187
+
188
+ format_time_series(counts)
189
+ end
190
+
191
+ def evaluations_time_series(bucket_size, time_range)
192
+ counts = ::EvaluationMetric.recent(time_range)
193
+ .group(time_bucket_sql(:created_at, bucket_size))
194
+ .count
195
+
196
+ format_time_series(counts)
197
+ end
198
+
199
+ def performance_time_series(bucket_size, time_range)
200
+ durations = ::PerformanceMetric.recent(time_range)
201
+ .where.not(duration_ms: nil)
202
+ .group(time_bucket_sql(:created_at, bucket_size))
203
+ .average(:duration_ms)
204
+
205
+ format_time_series(durations)
206
+ end
207
+
208
+ def errors_time_series(bucket_size, time_range)
209
+ counts = ::ErrorMetric.recent(time_range)
210
+ .group(time_bucket_sql(:created_at, bucket_size))
211
+ .count
212
+
213
+ format_time_series(counts)
214
+ end
215
+
216
+ def time_bucket_sql(column, bucket_size)
217
+ adapter = ActiveRecord::Base.connection.adapter_name.downcase
218
+
219
+ case adapter
220
+ when /postgres/
221
+ "(EXTRACT(EPOCH FROM #{column})::bigint / #{bucket_size}) * #{bucket_size}"
222
+ when /mysql/
223
+ "(UNIX_TIMESTAMP(#{column}) DIV #{bucket_size}) * #{bucket_size}"
224
+ when /sqlite/
225
+ "(CAST(strftime('%s', #{column}) AS INTEGER) / #{bucket_size}) * #{bucket_size}"
226
+ else
227
+ # Fallback: use group by timestamp truncated to bucket
228
+ column.to_s
229
+ end
230
+ end
231
+
232
+ def format_time_series(data)
233
+ timestamps = data.keys.sort
234
+ values = timestamps.map { |ts| data[ts] }
235
+
236
+ {
237
+ timestamps: timestamps.map { |ts| Time.at(ts).iso8601 },
238
+ data: values.map(&:to_f)
239
+ }
240
+ end
241
+
242
+ def default_statistics
243
+ {
244
+ decisions: { total: 0, by_decision: {}, average_confidence: 0.0, success_rate: 0.0 },
245
+ evaluations: { total: 0, by_evaluator: {}, average_score: 0.0, success_rate_by_evaluator: {} },
246
+ performance: { total: 0, average_duration_ms: 0.0, p50: 0.0, p95: 0.0, p99: 0.0, success_rate: 0.0 },
247
+ errors: { total: 0, by_type: {}, by_severity: {}, critical_count: 0 }
248
+ }
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DecisionAgent
4
+ module Monitoring
5
+ module Storage
6
+ # Base adapter interface for metrics storage
7
+ # Subclasses must implement all abstract methods
8
+ class BaseAdapter
9
+ # Record a decision
10
+ # @param decision [String] The decision made
11
+ # @param context [Hash] Decision context
12
+ # @param confidence [Float, nil] Confidence score (0-1)
13
+ # @param evaluations_count [Integer] Number of evaluations
14
+ # @param duration_ms [Float, nil] Decision duration in milliseconds
15
+ # @param status [String, nil] Decision status (success, failure, error)
16
+ # @return [void]
17
+ def record_decision(decision, context, confidence: nil, evaluations_count: 0, duration_ms: nil, status: nil)
18
+ raise NotImplementedError, "#{self.class} must implement #record_decision"
19
+ end
20
+
21
+ # Record an evaluation
22
+ # @param evaluator_name [String] Name of the evaluator
23
+ # @param score [Float, nil] Evaluation score
24
+ # @param success [Boolean, nil] Whether evaluation succeeded
25
+ # @param duration_ms [Float, nil] Evaluation duration
26
+ # @param details [Hash] Additional details
27
+ # @return [void]
28
+ def record_evaluation(evaluator_name, score: nil, success: nil, duration_ms: nil, details: {})
29
+ raise NotImplementedError, "#{self.class} must implement #record_evaluation"
30
+ end
31
+
32
+ # Record a performance metric
33
+ # @param operation [String] Operation name
34
+ # @param duration_ms [Float, nil] Duration in milliseconds
35
+ # @param status [String, nil] Status (success, failure, error)
36
+ # @param metadata [Hash] Additional metadata
37
+ # @return [void]
38
+ def record_performance(operation, duration_ms: nil, status: nil, metadata: {})
39
+ raise NotImplementedError, "#{self.class} must implement #record_performance"
40
+ end
41
+
42
+ # Record an error
43
+ # @param error_type [String] Type of error
44
+ # @param message [String, nil] Error message
45
+ # @param stack_trace [Array, nil] Stack trace
46
+ # @param severity [String, nil] Error severity (low, medium, high, critical)
47
+ # @param context [Hash] Error context
48
+ # @return [void]
49
+ def record_error(error_type, message: nil, stack_trace: nil, severity: nil, context: {})
50
+ raise NotImplementedError, "#{self.class} must implement #record_error"
51
+ end
52
+
53
+ # Get statistics for a time range
54
+ # @param time_range [Integer] Time range in seconds
55
+ # @return [Hash] Statistics summary
56
+ def statistics(time_range: 3600)
57
+ raise NotImplementedError, "#{self.class} must implement #statistics"
58
+ end
59
+
60
+ # Get time series data
61
+ # @param metric_type [Symbol] Type of metric (:decisions, :evaluations, :performance, :errors)
62
+ # @param bucket_size [Integer] Bucket size in seconds
63
+ # @param time_range [Integer] Time range in seconds
64
+ # @return [Hash] Time series data
65
+ def time_series(metric_type, bucket_size: 60, time_range: 3600)
66
+ raise NotImplementedError, "#{self.class} must implement #time_series"
67
+ end
68
+
69
+ # Get count of metrics stored
70
+ # @return [Hash] Count by metric type
71
+ def metrics_count
72
+ raise NotImplementedError, "#{self.class} must implement #metrics_count"
73
+ end
74
+
75
+ # Clean up old metrics
76
+ # @param older_than [Integer] Remove metrics older than this many seconds
77
+ # @return [Integer] Number of metrics removed
78
+ def cleanup(older_than:)
79
+ raise NotImplementedError, "#{self.class} must implement #cleanup"
80
+ end
81
+
82
+ # Check if adapter is available (dependencies installed)
83
+ # @return [Boolean]
84
+ def self.available?
85
+ raise NotImplementedError, "#{self} must implement .available?"
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end