decision_agent 0.1.3 → 0.1.4

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/lib/decision_agent/ab_testing/ab_test.rb +197 -0
  3. data/lib/decision_agent/ab_testing/ab_test_assignment.rb +76 -0
  4. data/lib/decision_agent/ab_testing/ab_test_manager.rb +317 -0
  5. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +152 -0
  6. data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +155 -0
  7. data/lib/decision_agent/ab_testing/storage/adapter.rb +67 -0
  8. data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +116 -0
  9. data/lib/decision_agent/monitoring/metrics_collector.rb +148 -3
  10. data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +253 -0
  11. data/lib/decision_agent/monitoring/storage/base_adapter.rb +90 -0
  12. data/lib/decision_agent/monitoring/storage/memory_adapter.rb +222 -0
  13. data/lib/decision_agent/version.rb +1 -1
  14. data/lib/decision_agent.rb +7 -0
  15. data/lib/generators/decision_agent/install/install_generator.rb +37 -0
  16. data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +45 -0
  17. data/lib/generators/decision_agent/install/templates/ab_test_model.rb +54 -0
  18. data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +43 -0
  19. data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +189 -0
  20. data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +114 -0
  21. data/lib/generators/decision_agent/install/templates/decision_log.rb +57 -0
  22. data/lib/generators/decision_agent/install/templates/error_metric.rb +53 -0
  23. data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +43 -0
  24. data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +109 -0
  25. data/lib/generators/decision_agent/install/templates/performance_metric.rb +76 -0
  26. data/spec/ab_testing/ab_test_manager_spec.rb +330 -0
  27. data/spec/ab_testing/ab_test_spec.rb +270 -0
  28. data/spec/examples.txt +612 -548
  29. data/spec/issue_verification_spec.rb +95 -21
  30. data/spec/monitoring/metrics_collector_spec.rb +2 -2
  31. data/spec/monitoring/monitored_agent_spec.rb +1 -1
  32. data/spec/monitoring/prometheus_exporter_spec.rb +1 -1
  33. data/spec/monitoring/storage/activerecord_adapter_spec.rb +346 -0
  34. data/spec/monitoring/storage/memory_adapter_spec.rb +247 -0
  35. metadata +26 -2
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :decision_agent do
4
+ namespace :monitoring do
5
+ desc "Cleanup old monitoring metrics (default: older than 30 days)"
6
+ task cleanup: :environment do
7
+ older_than = ENV.fetch("OLDER_THAN", 30 * 24 * 3600).to_i # Default: 30 days
8
+
9
+ puts "Cleaning up metrics older than #{older_than / 86_400} days..."
10
+
11
+ count = 0
12
+ count += DecisionLog.where("created_at < ?", Time.now - older_than).delete_all
13
+ count += EvaluationMetric.where("created_at < ?", Time.now - older_than).delete_all
14
+ count += PerformanceMetric.where("created_at < ?", Time.now - older_than).delete_all
15
+ count += ErrorMetric.where("created_at < ?", Time.now - older_than).delete_all
16
+
17
+ puts "Deleted #{count} old metric records"
18
+ end
19
+
20
+ desc "Show monitoring statistics"
21
+ task stats: :environment do
22
+ time_range = ENV.fetch("TIME_RANGE", 3600).to_i # Default: 1 hour
23
+
24
+ puts "\n=== Decision Agent Monitoring Statistics ==="
25
+ puts "Time range: Last #{time_range / 3600.0} hours\n\n"
26
+
27
+ decisions = DecisionLog.recent(time_range)
28
+ evaluations = EvaluationMetric.recent(time_range)
29
+ performance = PerformanceMetric.recent(time_range)
30
+ errors = ErrorMetric.recent(time_range)
31
+
32
+ puts "Decisions:"
33
+ puts " Total: #{decisions.count}"
34
+ puts " Average confidence: #{decisions.average(:confidence)&.round(4) || 'N/A'}"
35
+ puts " Success rate: #{(DecisionLog.success_rate(time_range: time_range) * 100).round(2)}%"
36
+ puts " By decision: #{decisions.group(:decision).count}"
37
+
38
+ puts "\nEvaluations:"
39
+ puts " Total: #{evaluations.count}"
40
+ puts " By evaluator: #{evaluations.group(:evaluator_name).count}"
41
+
42
+ puts "\nPerformance:"
43
+ puts " Total operations: #{performance.count}"
44
+ puts " Average duration: #{performance.average_duration(time_range: time_range)&.round(2) || 'N/A'} ms"
45
+ puts " P95 latency: #{performance.p95(time_range: time_range).round(2)} ms"
46
+ puts " P99 latency: #{performance.p99(time_range: time_range).round(2)} ms"
47
+ puts " Success rate: #{(performance.success_rate(time_range: time_range) * 100).round(2)}%"
48
+
49
+ puts "\nErrors:"
50
+ puts " Total: #{errors.count}"
51
+ puts " By type: #{errors.group(:error_type).count}"
52
+ puts " By severity: #{errors.group(:severity).count}"
53
+ puts " Critical: #{errors.critical.count}"
54
+
55
+ puts "\n=== End of Statistics ===\n"
56
+ end
57
+
58
+ desc "Archive old metrics to JSON file"
59
+ task :archive, [:output_file] => :environment do |_t, args|
60
+ output_file = args[:output_file] || "metrics_archive_#{Time.now.to_i}.json"
61
+ older_than = ENV.fetch("OLDER_THAN", 30 * 24 * 3600).to_i
62
+ cutoff_time = Time.now - older_than
63
+
64
+ puts "Archiving metrics older than #{older_than / 86_400} days to #{output_file}..."
65
+
66
+ archive_data = {
67
+ archived_at: Time.now.iso8601,
68
+ cutoff_time: cutoff_time.iso8601,
69
+ decisions: DecisionLog.where("created_at < ?", cutoff_time).map do |d|
70
+ {
71
+ decision: d.decision,
72
+ confidence: d.confidence,
73
+ context: d.parsed_context,
74
+ status: d.status,
75
+ created_at: d.created_at.iso8601
76
+ }
77
+ end,
78
+ evaluations: EvaluationMetric.where("created_at < ?", cutoff_time).map do |e|
79
+ {
80
+ evaluator_name: e.evaluator_name,
81
+ score: e.score,
82
+ success: e.success,
83
+ details: e.parsed_details,
84
+ created_at: e.created_at.iso8601
85
+ }
86
+ end,
87
+ performance: PerformanceMetric.where("created_at < ?", cutoff_time).map do |p|
88
+ {
89
+ operation: p.operation,
90
+ duration_ms: p.duration_ms,
91
+ status: p.status,
92
+ metadata: p.parsed_metadata,
93
+ created_at: p.created_at.iso8601
94
+ }
95
+ end,
96
+ errors: ErrorMetric.where("created_at < ?", cutoff_time).map do |e|
97
+ {
98
+ error_type: e.error_type,
99
+ message: e.message,
100
+ severity: e.severity,
101
+ context: e.parsed_context,
102
+ created_at: e.created_at.iso8601
103
+ }
104
+ end
105
+ }
106
+
107
+ File.write(output_file, JSON.pretty_generate(archive_data))
108
+
109
+ total = archive_data.values.sum { |v| v.is_a?(Array) ? v.size : 0 }
110
+ puts "Archived #{total} metrics to #{output_file}"
111
+ puts "Run 'rake decision_agent:monitoring:cleanup' to delete these records from the database"
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DecisionLog < ApplicationRecord
4
+ has_many :evaluation_metrics, dependent: :destroy
5
+
6
+ validates :decision, presence: true
7
+ validates :confidence, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1 }, allow_nil: true
8
+ validates :status, inclusion: { in: %w[success failure error] }, allow_nil: true
9
+
10
+ scope :recent, ->(time_range = 3600) { where("created_at >= ?", Time.now - time_range) }
11
+ scope :successful, -> { where(status: "success") }
12
+ scope :failed, -> { where(status: "failure") }
13
+ scope :with_errors, -> { where(status: "error") }
14
+ scope :by_decision, ->(decision) { where(decision: decision) }
15
+ scope :low_confidence, ->(threshold = 0.5) { where("confidence < ?", threshold) }
16
+ scope :high_confidence, ->(threshold = 0.8) { where("confidence >= ?", threshold) }
17
+
18
+ # Time series aggregation helpers
19
+ def self.count_by_time_bucket(bucket_size: 60, time_range: 3600)
20
+ recent(time_range)
21
+ .group("(EXTRACT(EPOCH FROM created_at)::bigint / #{bucket_size}) * #{bucket_size}")
22
+ .count
23
+ end
24
+
25
+ def self.average_confidence_by_time(bucket_size: 60, time_range: 3600)
26
+ recent(time_range)
27
+ .where.not(confidence: nil)
28
+ .group("(EXTRACT(EPOCH FROM created_at)::bigint / #{bucket_size}) * #{bucket_size}")
29
+ .average(:confidence)
30
+ end
31
+
32
+ def self.success_rate(time_range: 3600)
33
+ total = recent(time_range).where.not(status: nil).count
34
+ return 0.0 if total.zero?
35
+
36
+ successful_count = recent(time_range).successful.count
37
+ successful_count.to_f / total
38
+ end
39
+
40
+ # Parse JSON context field
41
+ def parsed_context
42
+ return {} if context.nil?
43
+
44
+ JSON.parse(context, symbolize_names: true)
45
+ rescue JSON::ParserError
46
+ {}
47
+ end
48
+
49
+ # Parse JSON metadata field
50
+ def parsed_metadata
51
+ return {} if metadata.nil?
52
+
53
+ JSON.parse(metadata, symbolize_names: true)
54
+ rescue JSON::ParserError
55
+ {}
56
+ end
57
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ErrorMetric < ApplicationRecord
4
+ validates :error_type, presence: true
5
+ validates :severity, inclusion: { in: %w[low medium high critical] }, allow_nil: true
6
+
7
+ scope :recent, ->(time_range = 3600) { where("created_at >= ?", Time.now - time_range) }
8
+ scope :by_type, ->(error_type) { where(error_type: error_type) }
9
+ scope :by_severity, ->(severity) { where(severity: severity) }
10
+ scope :critical, -> { where(severity: "critical") }
11
+ scope :high_severity, -> { where(severity: %w[high critical]) }
12
+
13
+ # Aggregation helpers
14
+ def self.count_by_type(time_range: 3600)
15
+ recent(time_range).group(:error_type).count
16
+ end
17
+
18
+ def self.count_by_severity(time_range: 3600)
19
+ recent(time_range).group(:severity).count
20
+ end
21
+
22
+ def self.error_rate(time_range: 3600, total_operations: nil)
23
+ error_count = recent(time_range).count
24
+ return 0.0 if total_operations.nil? || total_operations.zero?
25
+
26
+ error_count.to_f / total_operations
27
+ end
28
+
29
+ # Time series aggregation
30
+ def self.count_by_time_bucket(bucket_size: 60, time_range: 3600)
31
+ recent(time_range)
32
+ .group("(EXTRACT(EPOCH FROM created_at)::bigint / #{bucket_size}) * #{bucket_size}")
33
+ .count
34
+ end
35
+
36
+ # Parse JSON context field
37
+ def parsed_context
38
+ return {} if context.nil?
39
+
40
+ JSON.parse(context, symbolize_names: true)
41
+ rescue JSON::ParserError
42
+ {}
43
+ end
44
+
45
+ # Parse JSON stack_trace field
46
+ def parsed_stack_trace
47
+ return [] if stack_trace.nil?
48
+
49
+ JSON.parse(stack_trace)
50
+ rescue JSON::ParserError
51
+ []
52
+ end
53
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ class EvaluationMetric < ApplicationRecord
4
+ belongs_to :decision_log, optional: true
5
+
6
+ validates :evaluator_name, presence: true
7
+ validates :score, numericality: true, allow_nil: true
8
+
9
+ scope :recent, ->(time_range = 3600) { where("created_at >= ?", Time.now - time_range) }
10
+ scope :by_evaluator, ->(evaluator) { where(evaluator_name: evaluator) }
11
+ scope :successful, -> { where(success: true) }
12
+ scope :failed, -> { where(success: false) }
13
+
14
+ # Aggregation helpers
15
+ def self.average_score_by_evaluator(time_range: 3600)
16
+ recent(time_range)
17
+ .where.not(score: nil)
18
+ .group(:evaluator_name)
19
+ .average(:score)
20
+ end
21
+
22
+ def self.success_rate_by_evaluator(time_range: 3600)
23
+ recent(time_range)
24
+ .where.not(success: nil)
25
+ .group(:evaluator_name)
26
+ .select("evaluator_name, AVG(CASE WHEN success THEN 1.0 ELSE 0.0 END) as success_rate")
27
+ end
28
+
29
+ def self.count_by_evaluator(time_range: 3600)
30
+ recent(time_range)
31
+ .group(:evaluator_name)
32
+ .count
33
+ end
34
+
35
+ # Parse JSON details field
36
+ def parsed_details
37
+ return {} if details.nil?
38
+
39
+ JSON.parse(details, symbolize_names: true)
40
+ rescue JSON::ParserError
41
+ {}
42
+ end
43
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateDecisionAgentMonitoringTables < ActiveRecord::Migration[7.0]
4
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
5
+ def change
6
+ # Decision logs table
7
+ create_table :decision_logs do |t|
8
+ t.string :decision, null: false
9
+ t.float :confidence
10
+ t.integer :evaluations_count, default: 0
11
+ t.float :duration_ms
12
+ t.string :status # success, failure, error
13
+ t.text :context # JSON
14
+ t.text :metadata # JSON
15
+
16
+ t.timestamps
17
+ end
18
+
19
+ add_index :decision_logs, :decision
20
+ add_index :decision_logs, :status
21
+ add_index :decision_logs, :confidence
22
+ add_index :decision_logs, :created_at
23
+ add_index :decision_logs, %i[decision created_at]
24
+ add_index :decision_logs, %i[status created_at]
25
+
26
+ # Evaluation metrics table
27
+ create_table :evaluation_metrics do |t|
28
+ t.references :decision_log, foreign_key: true, index: true
29
+ t.string :evaluator_name, null: false
30
+ t.float :score
31
+ t.boolean :success
32
+ t.float :duration_ms
33
+ t.text :details # JSON
34
+
35
+ t.timestamps
36
+ end
37
+
38
+ add_index :evaluation_metrics, :evaluator_name
39
+ add_index :evaluation_metrics, :success
40
+ add_index :evaluation_metrics, :created_at
41
+ add_index :evaluation_metrics, %i[evaluator_name created_at]
42
+ add_index :evaluation_metrics, %i[evaluator_name success]
43
+
44
+ # Performance metrics table
45
+ create_table :performance_metrics do |t|
46
+ t.string :operation, null: false
47
+ t.float :duration_ms
48
+ t.string :status # success, failure, error
49
+ t.text :metadata # JSON
50
+
51
+ t.timestamps
52
+ end
53
+
54
+ add_index :performance_metrics, :operation
55
+ add_index :performance_metrics, :status
56
+ add_index :performance_metrics, :duration_ms
57
+ add_index :performance_metrics, :created_at
58
+ add_index :performance_metrics, %i[operation created_at]
59
+ add_index :performance_metrics, %i[status created_at]
60
+
61
+ # Error metrics table
62
+ create_table :error_metrics do |t|
63
+ t.string :error_type, null: false
64
+ t.text :message
65
+ t.text :stack_trace # JSON array
66
+ t.string :severity # low, medium, high, critical
67
+ t.text :context # JSON
68
+
69
+ t.timestamps
70
+ end
71
+
72
+ add_index :error_metrics, :error_type
73
+ add_index :error_metrics, :severity
74
+ add_index :error_metrics, :created_at
75
+ add_index :error_metrics, %i[error_type created_at]
76
+ add_index :error_metrics, %i[severity created_at]
77
+
78
+ # PostgreSQL-specific optimizations (optional)
79
+ return unless adapter_name == "PostgreSQL"
80
+
81
+ # Partial indexes for active records (recent data)
82
+ execute <<-SQL
83
+ CREATE INDEX index_decision_logs_on_recent
84
+ ON decision_logs (created_at DESC)
85
+ WHERE created_at >= NOW() - INTERVAL '7 days';
86
+ SQL
87
+
88
+ execute <<-SQL
89
+ CREATE INDEX index_performance_metrics_on_recent
90
+ ON performance_metrics (created_at DESC)
91
+ WHERE created_at >= NOW() - INTERVAL '7 days';
92
+ SQL
93
+
94
+ execute <<-SQL
95
+ CREATE INDEX index_error_metrics_on_recent_critical
96
+ ON error_metrics (created_at DESC)
97
+ WHERE severity IN ('high', 'critical') AND created_at >= NOW() - INTERVAL '7 days';
98
+ SQL
99
+
100
+ # Consider table partitioning for large-scale deployments
101
+ # Example: Partition by month for decision_logs
102
+ # This is commented out by default - enable if needed
103
+ # execute <<-SQL
104
+ # CREATE TABLE decision_logs_partitioned (LIKE decision_logs INCLUDING ALL)
105
+ # PARTITION BY RANGE (created_at);
106
+ # SQL
107
+ end
108
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
109
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ class PerformanceMetric < ApplicationRecord
4
+ validates :operation, presence: true
5
+ validates :duration_ms, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
6
+ validates :status, inclusion: { in: %w[success failure error] }, allow_nil: true
7
+
8
+ scope :recent, ->(time_range = 3600) { where("created_at >= ?", Time.now - time_range) }
9
+ scope :by_operation, ->(operation) { where(operation: operation) }
10
+ scope :successful, -> { where(status: "success") }
11
+ scope :failed, -> { where(status: "failure") }
12
+ scope :with_errors, -> { where(status: "error") }
13
+ scope :slow, ->(threshold_ms = 1000) { where("duration_ms > ?", threshold_ms) }
14
+
15
+ # Performance statistics
16
+ def self.average_duration(time_range: 3600)
17
+ recent(time_range).where.not(duration_ms: nil).average(:duration_ms).to_f
18
+ end
19
+
20
+ def self.percentile(pct, time_range: 3600)
21
+ durations = recent(time_range).where.not(duration_ms: nil).order(:duration_ms).pluck(:duration_ms)
22
+ return 0.0 if durations.empty?
23
+
24
+ index = ((durations.length - 1) * pct).ceil
25
+ durations[index].to_f
26
+ end
27
+
28
+ def self.p50(time_range: 3600)
29
+ percentile(0.50, time_range: time_range)
30
+ end
31
+
32
+ def self.p95(time_range: 3600)
33
+ percentile(0.95, time_range: time_range)
34
+ end
35
+
36
+ def self.p99(time_range: 3600)
37
+ percentile(0.99, time_range: time_range)
38
+ end
39
+
40
+ def self.max_duration(time_range: 3600)
41
+ recent(time_range).maximum(:duration_ms).to_f
42
+ end
43
+
44
+ def self.min_duration(time_range: 3600)
45
+ recent(time_range).minimum(:duration_ms).to_f
46
+ end
47
+
48
+ def self.success_rate(time_range: 3600)
49
+ total = recent(time_range).where.not(status: nil).count
50
+ return 0.0 if total.zero?
51
+
52
+ successful_count = recent(time_range).successful.count
53
+ successful_count.to_f / total
54
+ end
55
+
56
+ def self.count_by_operation(time_range: 3600)
57
+ recent(time_range).group(:operation).count
58
+ end
59
+
60
+ # Time series aggregation
61
+ def self.average_duration_by_time(bucket_size: 60, time_range: 3600)
62
+ recent(time_range)
63
+ .where.not(duration_ms: nil)
64
+ .group("(EXTRACT(EPOCH FROM created_at)::bigint / #{bucket_size}) * #{bucket_size}")
65
+ .average(:duration_ms)
66
+ end
67
+
68
+ # Parse JSON metadata field
69
+ def parsed_metadata
70
+ return {} if metadata.nil?
71
+
72
+ JSON.parse(metadata, symbolize_names: true)
73
+ rescue JSON::ParserError
74
+ {}
75
+ end
76
+ end