decision_agent 0.1.2 → 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.
- checksums.yaml +4 -4
- data/README.md +212 -35
- data/bin/decision_agent +3 -8
- data/lib/decision_agent/ab_testing/ab_test.rb +197 -0
- data/lib/decision_agent/ab_testing/ab_test_assignment.rb +76 -0
- data/lib/decision_agent/ab_testing/ab_test_manager.rb +317 -0
- data/lib/decision_agent/ab_testing/ab_testing_agent.rb +152 -0
- data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +155 -0
- data/lib/decision_agent/ab_testing/storage/adapter.rb +67 -0
- data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +116 -0
- 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 +423 -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/monitoring/storage/activerecord_adapter.rb +253 -0
- data/lib/decision_agent/monitoring/storage/base_adapter.rb +90 -0
- data/lib/decision_agent/monitoring/storage/memory_adapter.rb +222 -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 +14 -0
- data/lib/generators/decision_agent/install/install_generator.rb +42 -5
- data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +45 -0
- data/lib/generators/decision_agent/install/templates/ab_test_model.rb +54 -0
- data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +43 -0
- data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +189 -0
- data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +114 -0
- data/lib/generators/decision_agent/install/templates/decision_log.rb +57 -0
- data/lib/generators/decision_agent/install/templates/error_metric.rb +53 -0
- data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +43 -0
- data/lib/generators/decision_agent/install/templates/migration.rb +17 -6
- data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +109 -0
- data/lib/generators/decision_agent/install/templates/performance_metric.rb +76 -0
- 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/ab_testing/ab_test_manager_spec.rb +330 -0
- data/spec/ab_testing/ab_test_spec.rb +270 -0
- 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 +612 -0
- data/spec/issue_verification_spec.rb +759 -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/monitoring/storage/activerecord_adapter_spec.rb +346 -0
- data/spec/monitoring/storage/memory_adapter_spec.rb +247 -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 +93 -6
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
require "decision_agent/monitoring/metrics_collector"
|
|
3
|
+
require "decision_agent/monitoring/prometheus_exporter"
|
|
4
|
+
|
|
5
|
+
RSpec.describe DecisionAgent::Monitoring::PrometheusExporter do
|
|
6
|
+
let(:collector) { DecisionAgent::Monitoring::MetricsCollector.new(storage: :memory) }
|
|
7
|
+
let(:exporter) { described_class.new(metrics_collector: collector, namespace: "test") }
|
|
8
|
+
|
|
9
|
+
let(:decision) do
|
|
10
|
+
double(
|
|
11
|
+
"Decision",
|
|
12
|
+
decision: "approve",
|
|
13
|
+
confidence: 0.85,
|
|
14
|
+
evaluations: [double("Evaluation", evaluator_name: "test_evaluator")]
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
let(:context) { double("Context", to_h: { user: "test" }) }
|
|
18
|
+
|
|
19
|
+
describe "#initialize" do
|
|
20
|
+
it "initializes with metrics collector" do
|
|
21
|
+
expect(exporter).to be_a(described_class)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it "uses default namespace" do
|
|
25
|
+
exporter = described_class.new(metrics_collector: collector)
|
|
26
|
+
output = exporter.export
|
|
27
|
+
expect(output).to include("decision_agent_")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it "uses custom namespace" do
|
|
31
|
+
output = exporter.export
|
|
32
|
+
expect(output).to include("test_")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
describe "#export" do
|
|
37
|
+
before do
|
|
38
|
+
# Record some metrics
|
|
39
|
+
3.times { collector.record_decision(decision, context, duration_ms: 10.0) }
|
|
40
|
+
collector.record_performance(operation: "decide", duration_ms: 15.0, success: true)
|
|
41
|
+
collector.record_error(StandardError.new("Test error"))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it "exports in Prometheus text format" do
|
|
45
|
+
output = exporter.export
|
|
46
|
+
|
|
47
|
+
expect(output).to be_a(String)
|
|
48
|
+
expect(output).to include("# DecisionAgent Metrics Export")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it "includes decision metrics" do
|
|
52
|
+
output = exporter.export
|
|
53
|
+
|
|
54
|
+
expect(output).to include("# HELP test_decisions_total")
|
|
55
|
+
expect(output).to include("# TYPE test_decisions_total counter")
|
|
56
|
+
expect(output).to include("test_decisions_total 3")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it "includes confidence metrics" do
|
|
60
|
+
output = exporter.export
|
|
61
|
+
|
|
62
|
+
expect(output).to include("# HELP test_decision_confidence_avg")
|
|
63
|
+
expect(output).to include("# TYPE test_decision_confidence_avg gauge")
|
|
64
|
+
expect(output).to include("test_decision_confidence_avg 0.85")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it "includes performance metrics" do
|
|
68
|
+
output = exporter.export
|
|
69
|
+
|
|
70
|
+
expect(output).to include("# HELP test_success_rate")
|
|
71
|
+
expect(output).to include("# TYPE test_success_rate gauge")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it "includes error metrics" do
|
|
75
|
+
output = exporter.export
|
|
76
|
+
|
|
77
|
+
expect(output).to include("# HELP test_errors_total")
|
|
78
|
+
expect(output).to include("# TYPE test_errors_total counter")
|
|
79
|
+
expect(output).to include("test_errors_total 1")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "includes system info" do
|
|
83
|
+
output = exporter.export
|
|
84
|
+
|
|
85
|
+
expect(output).to include("# HELP test_info")
|
|
86
|
+
expect(output).to include("# TYPE test_info gauge")
|
|
87
|
+
expect(output).to include("version=\"#{DecisionAgent::VERSION}\"")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it "includes decision distribution" do
|
|
91
|
+
output = exporter.export
|
|
92
|
+
|
|
93
|
+
expect(output).to include("# HELP test_decisions_by_type")
|
|
94
|
+
expect(output).to include("test_decisions_by_type{decision=\"approve\"} 3")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "includes error distribution by type" do
|
|
98
|
+
output = exporter.export
|
|
99
|
+
|
|
100
|
+
expect(output).to include("# HELP test_errors_by_type")
|
|
101
|
+
expect(output).to include("test_errors_by_type{error=\"StandardError\"} 1")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it "includes metrics count" do
|
|
105
|
+
output = exporter.export
|
|
106
|
+
|
|
107
|
+
expect(output).to include("# HELP test_metrics_stored")
|
|
108
|
+
expect(output).to include("test_metrics_stored{type=\"decisions\"} 3")
|
|
109
|
+
expect(output).to include("test_metrics_stored{type=\"errors\"} 1")
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
describe "#register_kpi" do
|
|
114
|
+
it "registers a custom KPI" do
|
|
115
|
+
exporter.register_kpi(
|
|
116
|
+
name: "custom_metric",
|
|
117
|
+
value: 42.5,
|
|
118
|
+
help: "A custom metric"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
output = exporter.export
|
|
122
|
+
expect(output).to include("# HELP test_custom_metric A custom metric")
|
|
123
|
+
expect(output).to include("# TYPE test_custom_metric gauge")
|
|
124
|
+
expect(output).to include("test_custom_metric 42.5")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it "registers KPI with labels" do
|
|
128
|
+
exporter.register_kpi(
|
|
129
|
+
name: "requests",
|
|
130
|
+
value: 100,
|
|
131
|
+
labels: { endpoint: "/api/v1", method: "GET" }
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
output = exporter.export
|
|
135
|
+
expect(output).to include("test_requests{endpoint=\"/api/v1\",method=\"GET\"} 100")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it "sanitizes metric names" do
|
|
139
|
+
exporter.register_kpi(name: "my-custom.metric!", value: 10)
|
|
140
|
+
|
|
141
|
+
output = exporter.export
|
|
142
|
+
expect(output).to include("test_my_custom_metric_")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it "escapes label values" do
|
|
146
|
+
exporter.register_kpi(
|
|
147
|
+
name: "metric",
|
|
148
|
+
value: 1,
|
|
149
|
+
labels: { message: 'Contains "quotes"' }
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
output = exporter.export
|
|
153
|
+
expect(output).to include('message="Contains \"quotes\""')
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
describe "#metrics_hash" do
|
|
158
|
+
before do
|
|
159
|
+
collector.record_decision(decision, context, duration_ms: 10.0)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it "returns metrics as hash" do
|
|
163
|
+
metrics = exporter.metrics_hash
|
|
164
|
+
|
|
165
|
+
expect(metrics).to be_a(Hash)
|
|
166
|
+
expect(metrics).to have_key(:decisions)
|
|
167
|
+
expect(metrics).to have_key(:performance)
|
|
168
|
+
expect(metrics).to have_key(:errors)
|
|
169
|
+
expect(metrics).to have_key(:system)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it "includes metric types" do
|
|
173
|
+
metrics = exporter.metrics_hash
|
|
174
|
+
|
|
175
|
+
expect(metrics[:decisions][:total][:type]).to eq("counter")
|
|
176
|
+
expect(metrics[:decisions][:avg_confidence][:type]).to eq("gauge")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
it "includes metric values" do
|
|
180
|
+
metrics = exporter.metrics_hash
|
|
181
|
+
|
|
182
|
+
expect(metrics[:decisions][:total][:value]).to eq(1)
|
|
183
|
+
expect(metrics[:decisions][:avg_confidence][:value]).to eq(0.85)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
describe "thread safety" do
|
|
188
|
+
it "handles concurrent KPI registration" do
|
|
189
|
+
threads = 10.times.map do |i|
|
|
190
|
+
Thread.new do
|
|
191
|
+
10.times do |j|
|
|
192
|
+
exporter.register_kpi(
|
|
193
|
+
name: "metric_#{i}_#{j}",
|
|
194
|
+
value: (i * 10) + j
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
expect { threads.each(&:join) }.not_to raise_error
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it "handles concurrent exports" do
|
|
204
|
+
threads = 5.times.map do
|
|
205
|
+
Thread.new do
|
|
206
|
+
10.times { exporter.export }
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
expect { threads.each(&:join) }.not_to raise_error
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
describe "performance metrics export" do
|
|
215
|
+
before do
|
|
216
|
+
5.times do |i|
|
|
217
|
+
collector.record_performance(
|
|
218
|
+
operation: "decide",
|
|
219
|
+
duration_ms: (i + 1) * 10.0,
|
|
220
|
+
success: true
|
|
221
|
+
)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
it "exports summary metrics" do
|
|
226
|
+
output = exporter.export
|
|
227
|
+
|
|
228
|
+
expect(output).to include("# TYPE test_operation_duration_ms summary")
|
|
229
|
+
expect(output).to include("test_operation_duration_ms{quantile=\"0.5\"}")
|
|
230
|
+
expect(output).to include("test_operation_duration_ms{quantile=\"0.95\"}")
|
|
231
|
+
expect(output).to include("test_operation_duration_ms{quantile=\"0.99\"}")
|
|
232
|
+
expect(output).to include("test_operation_duration_ms_sum")
|
|
233
|
+
expect(output).to include("test_operation_duration_ms_count")
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
describe "content type" do
|
|
238
|
+
it "defines Prometheus content type" do
|
|
239
|
+
expect(described_class::CONTENT_TYPE).to eq("text/plain; version=0.0.4")
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "active_record"
|
|
5
|
+
require "decision_agent/monitoring/storage/activerecord_adapter"
|
|
6
|
+
|
|
7
|
+
RSpec.describe DecisionAgent::Monitoring::Storage::ActiveRecordAdapter do
|
|
8
|
+
# Setup in-memory SQLite database for testing
|
|
9
|
+
before(:all) do
|
|
10
|
+
ActiveRecord::Base.establish_connection(
|
|
11
|
+
adapter: "sqlite3",
|
|
12
|
+
database: ":memory:"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# Create tables
|
|
16
|
+
ActiveRecord::Schema.define do
|
|
17
|
+
create_table :decision_logs, force: true do |t|
|
|
18
|
+
t.string :decision, null: false
|
|
19
|
+
t.float :confidence
|
|
20
|
+
t.integer :evaluations_count, default: 0
|
|
21
|
+
t.float :duration_ms
|
|
22
|
+
t.string :status
|
|
23
|
+
t.text :context
|
|
24
|
+
t.text :metadata
|
|
25
|
+
t.timestamps
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
create_table :evaluation_metrics, force: true do |t|
|
|
29
|
+
t.references :decision_log, foreign_key: true
|
|
30
|
+
t.string :evaluator_name, null: false
|
|
31
|
+
t.float :score
|
|
32
|
+
t.boolean :success
|
|
33
|
+
t.float :duration_ms
|
|
34
|
+
t.text :details
|
|
35
|
+
t.timestamps
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
create_table :performance_metrics, force: true do |t|
|
|
39
|
+
t.string :operation, null: false
|
|
40
|
+
t.float :duration_ms
|
|
41
|
+
t.string :status
|
|
42
|
+
t.text :metadata
|
|
43
|
+
t.timestamps
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
create_table :error_metrics, force: true do |t|
|
|
47
|
+
t.string :error_type, null: false
|
|
48
|
+
t.text :message
|
|
49
|
+
t.text :stack_trace
|
|
50
|
+
t.string :severity
|
|
51
|
+
t.text :context
|
|
52
|
+
t.timestamps
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Define models
|
|
57
|
+
# rubocop:disable Lint/ConstantDefinitionInBlock
|
|
58
|
+
class DecisionLog < ActiveRecord::Base
|
|
59
|
+
has_many :evaluation_metrics, dependent: :destroy
|
|
60
|
+
|
|
61
|
+
scope :recent, ->(time_range) { where("created_at >= ?", Time.now - time_range) }
|
|
62
|
+
|
|
63
|
+
def self.success_rate(time_range: 3600)
|
|
64
|
+
total = recent(time_range).where.not(status: nil).count
|
|
65
|
+
return 0.0 if total.zero?
|
|
66
|
+
|
|
67
|
+
recent(time_range).where(status: "success").count.to_f / total
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def parsed_context
|
|
71
|
+
JSON.parse(context, symbolize_names: true)
|
|
72
|
+
rescue StandardError
|
|
73
|
+
{}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class EvaluationMetric < ActiveRecord::Base
|
|
78
|
+
belongs_to :decision_log, optional: true
|
|
79
|
+
|
|
80
|
+
scope :recent, ->(time_range) { where("created_at >= ?", Time.now - time_range) }
|
|
81
|
+
scope :successful, -> { where(success: true) }
|
|
82
|
+
|
|
83
|
+
def parsed_details
|
|
84
|
+
JSON.parse(details, symbolize_names: true)
|
|
85
|
+
rescue StandardError
|
|
86
|
+
{}
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
class PerformanceMetric < ActiveRecord::Base
|
|
91
|
+
scope :recent, ->(time_range) { where("created_at >= ?", Time.now - time_range) }
|
|
92
|
+
|
|
93
|
+
def self.average_duration(time_range: 3600)
|
|
94
|
+
recent(time_range).average(:duration_ms).to_f
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.p50(time_range: 3600)
|
|
98
|
+
percentile(0.50, time_range: time_range)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.p95(time_range: 3600)
|
|
102
|
+
percentile(0.95, time_range: time_range)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.p99(time_range: 3600)
|
|
106
|
+
percentile(0.99, time_range: time_range)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def self.percentile(pct, time_range: 3600)
|
|
110
|
+
durations = recent(time_range).where.not(duration_ms: nil).order(:duration_ms).pluck(:duration_ms)
|
|
111
|
+
return 0.0 if durations.empty?
|
|
112
|
+
|
|
113
|
+
durations[(durations.length * pct).ceil - 1].to_f
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def self.success_rate(time_range: 3600)
|
|
117
|
+
total = recent(time_range).where.not(status: nil).count
|
|
118
|
+
return 0.0 if total.zero?
|
|
119
|
+
|
|
120
|
+
recent(time_range).where(status: "success").count.to_f / total
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
class ErrorMetric < ActiveRecord::Base
|
|
125
|
+
scope :recent, ->(time_range) { where("created_at >= ?", Time.now - time_range) }
|
|
126
|
+
scope :critical, -> { where(severity: "critical") }
|
|
127
|
+
|
|
128
|
+
def parsed_context
|
|
129
|
+
JSON.parse(context, symbolize_names: true)
|
|
130
|
+
rescue StandardError
|
|
131
|
+
{}
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
# rubocop:enable Lint/ConstantDefinitionInBlock
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
before do
|
|
138
|
+
DecisionLog.delete_all
|
|
139
|
+
EvaluationMetric.delete_all
|
|
140
|
+
PerformanceMetric.delete_all
|
|
141
|
+
ErrorMetric.delete_all
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
let(:adapter) { described_class.new }
|
|
145
|
+
|
|
146
|
+
describe ".available?" do
|
|
147
|
+
it "returns true when ActiveRecord and models are defined" do
|
|
148
|
+
expect(described_class.available?).to be_truthy
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
describe "#record_decision" do
|
|
153
|
+
it "creates a decision log record" do
|
|
154
|
+
expect do
|
|
155
|
+
adapter.record_decision(
|
|
156
|
+
"approve_payment",
|
|
157
|
+
{ user_id: 123, amount: 500 },
|
|
158
|
+
confidence: 0.85,
|
|
159
|
+
evaluations_count: 3,
|
|
160
|
+
duration_ms: 45.5,
|
|
161
|
+
status: "success"
|
|
162
|
+
)
|
|
163
|
+
end.to change(DecisionLog, :count).by(1)
|
|
164
|
+
|
|
165
|
+
log = DecisionLog.last
|
|
166
|
+
expect(log.decision).to eq("approve_payment")
|
|
167
|
+
expect(log.confidence).to eq(0.85)
|
|
168
|
+
expect(log.evaluations_count).to eq(3)
|
|
169
|
+
expect(log.duration_ms).to eq(45.5)
|
|
170
|
+
expect(log.status).to eq("success")
|
|
171
|
+
expect(log.parsed_context).to eq(user_id: 123, amount: 500)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
describe "#record_evaluation" do
|
|
176
|
+
it "creates an evaluation metric record" do
|
|
177
|
+
expect do
|
|
178
|
+
adapter.record_evaluation(
|
|
179
|
+
"FraudDetector",
|
|
180
|
+
score: 0.92,
|
|
181
|
+
success: true,
|
|
182
|
+
duration_ms: 12.3,
|
|
183
|
+
details: { risk_level: "low" }
|
|
184
|
+
)
|
|
185
|
+
end.to change(EvaluationMetric, :count).by(1)
|
|
186
|
+
|
|
187
|
+
metric = EvaluationMetric.last
|
|
188
|
+
expect(metric.evaluator_name).to eq("FraudDetector")
|
|
189
|
+
expect(metric.score).to eq(0.92)
|
|
190
|
+
expect(metric.success).to be true
|
|
191
|
+
expect(metric.duration_ms).to eq(12.3)
|
|
192
|
+
expect(metric.parsed_details).to eq(risk_level: "low")
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
describe "#record_performance" do
|
|
197
|
+
it "creates a performance metric record" do
|
|
198
|
+
expect do
|
|
199
|
+
adapter.record_performance(
|
|
200
|
+
"api_call",
|
|
201
|
+
duration_ms: 250.5,
|
|
202
|
+
status: "success",
|
|
203
|
+
metadata: { endpoint: "/api/v1/users" }
|
|
204
|
+
)
|
|
205
|
+
end.to change(PerformanceMetric, :count).by(1)
|
|
206
|
+
|
|
207
|
+
metric = PerformanceMetric.last
|
|
208
|
+
expect(metric.operation).to eq("api_call")
|
|
209
|
+
expect(metric.duration_ms).to eq(250.5)
|
|
210
|
+
expect(metric.status).to eq("success")
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
describe "#record_error" do
|
|
215
|
+
it "creates an error metric record" do
|
|
216
|
+
expect do
|
|
217
|
+
adapter.record_error(
|
|
218
|
+
"RuntimeError",
|
|
219
|
+
message: "Something went wrong",
|
|
220
|
+
stack_trace: ["line 1", "line 2"],
|
|
221
|
+
severity: "critical",
|
|
222
|
+
context: { user_id: 456 }
|
|
223
|
+
)
|
|
224
|
+
end.to change(ErrorMetric, :count).by(1)
|
|
225
|
+
|
|
226
|
+
error = ErrorMetric.last
|
|
227
|
+
expect(error.error_type).to eq("RuntimeError")
|
|
228
|
+
expect(error.message).to eq("Something went wrong")
|
|
229
|
+
expect(error.severity).to eq("critical")
|
|
230
|
+
expect(error.parsed_context).to eq(user_id: 456)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
describe "#statistics" do
|
|
235
|
+
before do
|
|
236
|
+
# Create test data
|
|
237
|
+
3.times do |i|
|
|
238
|
+
adapter.record_decision(
|
|
239
|
+
"decision_#{i}",
|
|
240
|
+
{ index: i },
|
|
241
|
+
confidence: 0.5 + (i * 0.1),
|
|
242
|
+
evaluations_count: 2,
|
|
243
|
+
duration_ms: 100 + (i * 10),
|
|
244
|
+
status: "success"
|
|
245
|
+
)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
2.times do |i|
|
|
249
|
+
adapter.record_evaluation(
|
|
250
|
+
"Evaluator#{i}",
|
|
251
|
+
score: 0.8,
|
|
252
|
+
success: true,
|
|
253
|
+
duration_ms: 50
|
|
254
|
+
)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
4.times do |i|
|
|
258
|
+
adapter.record_performance(
|
|
259
|
+
"operation_#{i}",
|
|
260
|
+
duration_ms: 100 + (i * 50),
|
|
261
|
+
status: i.even? ? "success" : "failure"
|
|
262
|
+
)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
adapter.record_error("TestError", severity: "critical")
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
it "returns comprehensive statistics" do
|
|
269
|
+
stats = adapter.statistics(time_range: 3600)
|
|
270
|
+
|
|
271
|
+
expect(stats[:decisions][:total]).to eq(3)
|
|
272
|
+
expect(stats[:decisions][:average_confidence]).to be_within(0.01).of(0.6)
|
|
273
|
+
expect(stats[:evaluations][:total]).to eq(2)
|
|
274
|
+
expect(stats[:performance][:total]).to eq(4)
|
|
275
|
+
expect(stats[:errors][:total]).to eq(1)
|
|
276
|
+
expect(stats[:errors][:critical_count]).to eq(1)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
describe "#time_series" do
|
|
281
|
+
before do
|
|
282
|
+
# Create metrics at different times
|
|
283
|
+
[10, 70, 130].each do |seconds_ago|
|
|
284
|
+
travel_back = Time.now - seconds_ago
|
|
285
|
+
DecisionLog.create!(
|
|
286
|
+
decision: "test",
|
|
287
|
+
confidence: 0.8,
|
|
288
|
+
created_at: travel_back
|
|
289
|
+
)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
it "returns time series data grouped by buckets" do
|
|
294
|
+
series = adapter.time_series(:decisions, bucket_size: 60, time_range: 200)
|
|
295
|
+
|
|
296
|
+
expect(series[:timestamps]).to be_an(Array)
|
|
297
|
+
expect(series[:data]).to be_an(Array)
|
|
298
|
+
expect(series[:data].sum).to eq(3)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
describe "#metrics_count" do
|
|
303
|
+
before do
|
|
304
|
+
adapter.record_decision("test", {}, confidence: 0.8)
|
|
305
|
+
adapter.record_evaluation("TestEval", score: 0.9)
|
|
306
|
+
adapter.record_performance("test_op", duration_ms: 100)
|
|
307
|
+
adapter.record_error("TestError")
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
it "returns count of all metric types" do
|
|
311
|
+
counts = adapter.metrics_count
|
|
312
|
+
|
|
313
|
+
expect(counts[:decisions]).to eq(1)
|
|
314
|
+
expect(counts[:evaluations]).to eq(1)
|
|
315
|
+
expect(counts[:performance]).to eq(1)
|
|
316
|
+
expect(counts[:errors]).to eq(1)
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
describe "#cleanup" do
|
|
321
|
+
before do
|
|
322
|
+
# Create old metrics
|
|
323
|
+
old_time = Time.now - 8.days
|
|
324
|
+
DecisionLog.create!(decision: "old", confidence: 0.8, created_at: old_time)
|
|
325
|
+
EvaluationMetric.create!(evaluator_name: "old", created_at: old_time)
|
|
326
|
+
PerformanceMetric.create!(operation: "old", created_at: old_time)
|
|
327
|
+
ErrorMetric.create!(error_type: "old", created_at: old_time)
|
|
328
|
+
|
|
329
|
+
# Create recent metrics
|
|
330
|
+
adapter.record_decision("recent", {}, confidence: 0.8)
|
|
331
|
+
adapter.record_evaluation("recent", score: 0.9)
|
|
332
|
+
adapter.record_performance("recent", duration_ms: 100)
|
|
333
|
+
adapter.record_error("recent")
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
it "removes old metrics and keeps recent ones" do
|
|
337
|
+
count = adapter.cleanup(older_than: 7.days.to_i)
|
|
338
|
+
|
|
339
|
+
expect(count).to eq(4) # 4 old metrics removed
|
|
340
|
+
expect(DecisionLog.count).to eq(1)
|
|
341
|
+
expect(EvaluationMetric.count).to eq(1)
|
|
342
|
+
expect(PerformanceMetric.count).to eq(1)
|
|
343
|
+
expect(ErrorMetric.count).to eq(1)
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|