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.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +212 -35
  3. data/bin/decision_agent +3 -8
  4. data/lib/decision_agent/ab_testing/ab_test.rb +197 -0
  5. data/lib/decision_agent/ab_testing/ab_test_assignment.rb +76 -0
  6. data/lib/decision_agent/ab_testing/ab_test_manager.rb +317 -0
  7. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +152 -0
  8. data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +155 -0
  9. data/lib/decision_agent/ab_testing/storage/adapter.rb +67 -0
  10. data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +116 -0
  11. data/lib/decision_agent/agent.rb +19 -26
  12. data/lib/decision_agent/audit/null_adapter.rb +1 -2
  13. data/lib/decision_agent/decision.rb +3 -1
  14. data/lib/decision_agent/dsl/condition_evaluator.rb +4 -3
  15. data/lib/decision_agent/dsl/rule_parser.rb +4 -6
  16. data/lib/decision_agent/dsl/schema_validator.rb +27 -31
  17. data/lib/decision_agent/errors.rb +11 -8
  18. data/lib/decision_agent/evaluation.rb +3 -1
  19. data/lib/decision_agent/evaluation_validator.rb +78 -0
  20. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +26 -0
  21. data/lib/decision_agent/evaluators/static_evaluator.rb +2 -6
  22. data/lib/decision_agent/monitoring/alert_manager.rb +282 -0
  23. data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +381 -0
  24. data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +471 -0
  25. data/lib/decision_agent/monitoring/dashboard/public/index.html +161 -0
  26. data/lib/decision_agent/monitoring/dashboard_server.rb +340 -0
  27. data/lib/decision_agent/monitoring/metrics_collector.rb +423 -0
  28. data/lib/decision_agent/monitoring/monitored_agent.rb +71 -0
  29. data/lib/decision_agent/monitoring/prometheus_exporter.rb +247 -0
  30. data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +253 -0
  31. data/lib/decision_agent/monitoring/storage/base_adapter.rb +90 -0
  32. data/lib/decision_agent/monitoring/storage/memory_adapter.rb +222 -0
  33. data/lib/decision_agent/replay/replay.rb +12 -22
  34. data/lib/decision_agent/scoring/base.rb +1 -1
  35. data/lib/decision_agent/scoring/consensus.rb +5 -5
  36. data/lib/decision_agent/scoring/weighted_average.rb +1 -1
  37. data/lib/decision_agent/version.rb +1 -1
  38. data/lib/decision_agent/versioning/activerecord_adapter.rb +69 -33
  39. data/lib/decision_agent/versioning/adapter.rb +1 -3
  40. data/lib/decision_agent/versioning/file_storage_adapter.rb +143 -35
  41. data/lib/decision_agent/versioning/version_manager.rb +4 -12
  42. data/lib/decision_agent/web/public/index.html +1 -1
  43. data/lib/decision_agent/web/server.rb +19 -24
  44. data/lib/decision_agent.rb +14 -0
  45. data/lib/generators/decision_agent/install/install_generator.rb +42 -5
  46. data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +45 -0
  47. data/lib/generators/decision_agent/install/templates/ab_test_model.rb +54 -0
  48. data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +43 -0
  49. data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +189 -0
  50. data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +114 -0
  51. data/lib/generators/decision_agent/install/templates/decision_log.rb +57 -0
  52. data/lib/generators/decision_agent/install/templates/error_metric.rb +53 -0
  53. data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +43 -0
  54. data/lib/generators/decision_agent/install/templates/migration.rb +17 -6
  55. data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +109 -0
  56. data/lib/generators/decision_agent/install/templates/performance_metric.rb +76 -0
  57. data/lib/generators/decision_agent/install/templates/rule.rb +3 -3
  58. data/lib/generators/decision_agent/install/templates/rule_version.rb +13 -7
  59. data/spec/ab_testing/ab_test_manager_spec.rb +330 -0
  60. data/spec/ab_testing/ab_test_spec.rb +270 -0
  61. data/spec/activerecord_thread_safety_spec.rb +553 -0
  62. data/spec/agent_spec.rb +13 -13
  63. data/spec/api_contract_spec.rb +16 -16
  64. data/spec/audit_adapters_spec.rb +3 -3
  65. data/spec/comprehensive_edge_cases_spec.rb +86 -86
  66. data/spec/dsl_validation_spec.rb +83 -83
  67. data/spec/edge_cases_spec.rb +23 -23
  68. data/spec/examples/feedback_aware_evaluator_spec.rb +7 -7
  69. data/spec/examples.txt +612 -0
  70. data/spec/issue_verification_spec.rb +759 -0
  71. data/spec/json_rule_evaluator_spec.rb +15 -15
  72. data/spec/monitoring/alert_manager_spec.rb +378 -0
  73. data/spec/monitoring/metrics_collector_spec.rb +281 -0
  74. data/spec/monitoring/monitored_agent_spec.rb +222 -0
  75. data/spec/monitoring/prometheus_exporter_spec.rb +242 -0
  76. data/spec/monitoring/storage/activerecord_adapter_spec.rb +346 -0
  77. data/spec/monitoring/storage/memory_adapter_spec.rb +247 -0
  78. data/spec/replay_edge_cases_spec.rb +58 -58
  79. data/spec/replay_spec.rb +11 -11
  80. data/spec/rfc8785_canonicalization_spec.rb +215 -0
  81. data/spec/scoring_spec.rb +1 -1
  82. data/spec/spec_helper.rb +9 -0
  83. data/spec/thread_safety_spec.rb +482 -0
  84. data/spec/thread_safety_spec.rb.broken +878 -0
  85. data/spec/versioning_spec.rb +141 -37
  86. data/spec/web_ui_rack_spec.rb +135 -0
  87. metadata +93 -6
@@ -4,21 +4,41 @@ require "fileutils"
4
4
 
5
5
  module DecisionAgent
6
6
  module Versioning
7
+ # Status validation shared by adapters
8
+ module StatusValidator
9
+ VALID_STATUSES = %w[draft active archived].freeze
10
+
11
+ def validate_status!(status)
12
+ return if VALID_STATUSES.include?(status)
13
+
14
+ raise DecisionAgent::ValidationError,
15
+ "Invalid status '#{status}'. Must be one of: #{VALID_STATUSES.join(', ')}"
16
+ end
17
+ end
18
+
7
19
  # File-based version storage adapter for non-Rails applications
8
20
  # Stores versions as JSON files in a directory structure
9
21
  class FileStorageAdapter < Adapter
22
+ include StatusValidator
23
+
10
24
  attr_reader :storage_path
11
25
 
12
26
  # Initialize with a storage directory
13
27
  # @param storage_path [String] Path to store version files (default: ./versions)
14
28
  def initialize(storage_path: "./versions")
15
29
  @storage_path = storage_path
16
- @mutex = Mutex.new
30
+ # Per-rule mutex for better concurrency - allows different rules to be processed in parallel
31
+ @rule_mutexes = Hash.new { |h, k| h[k] = Mutex.new }
32
+ @rule_mutexes_lock = Mutex.new # Protects the hash itself
33
+ # Index cache: version_id => rule_id mapping for O(1) lookups
34
+ @version_index = {}
35
+ @version_index_lock = Mutex.new
17
36
  FileUtils.mkdir_p(@storage_path)
37
+ load_version_index
18
38
  end
19
39
 
20
40
  def create_version(rule_id:, content:, metadata: {})
21
- @mutex.synchronize do
41
+ with_rule_lock(rule_id) do
22
42
  create_version_unsafe(rule_id: rule_id, content: content, metadata: metadata)
23
43
  end
24
44
  end
@@ -27,14 +47,16 @@ module DecisionAgent
27
47
 
28
48
  def create_version_unsafe(rule_id:, content:, metadata: {})
29
49
  # Get the next version number
30
- versions = list_versions(rule_id: rule_id)
50
+ versions = list_versions_unsafe(rule_id: rule_id)
31
51
  next_version_number = versions.empty? ? 1 : versions.first[:version_number] + 1
32
52
 
53
+ # Validate status if provided
54
+ status = metadata[:status] || "active"
55
+ validate_status!(status)
56
+
33
57
  # Deactivate previous active versions
34
58
  versions.each do |v|
35
- if v[:status] == "active"
36
- update_version_status(v[:id], "archived")
37
- end
59
+ update_version_status_unsafe(v[:id], "archived", rule_id) if v[:status] == "active"
38
60
  end
39
61
 
40
62
  # Create version data
@@ -47,7 +69,7 @@ module DecisionAgent
47
69
  created_by: metadata[:created_by] || "system",
48
70
  created_at: Time.now.utc.iso8601,
49
71
  changelog: metadata[:changelog] || "Version #{next_version_number}",
50
- status: metadata[:status] || "active"
72
+ status: status
51
73
  }
52
74
 
53
75
  # Write to file
@@ -59,43 +81,53 @@ module DecisionAgent
59
81
  public
60
82
 
61
83
  def list_versions(rule_id:, limit: nil)
62
- versions = []
63
- rule_dir = File.join(@storage_path, sanitize_filename(rule_id))
64
-
65
- return versions unless Dir.exist?(rule_dir)
66
-
67
- Dir.glob(File.join(rule_dir, "*.json")).each do |file|
68
- versions << JSON.parse(File.read(file), symbolize_names: true)
84
+ with_rule_lock(rule_id) do
85
+ list_versions_unsafe(rule_id: rule_id, limit: limit)
69
86
  end
70
-
71
- versions.sort_by! { |v| -v[:version_number] }
72
- limit ? versions.take(limit) : versions
73
87
  end
74
88
 
75
89
  def get_version(version_id:)
76
- all_versions.find { |v| v[:id] == version_id }
90
+ # Use index to find rule_id quickly - O(1) instead of O(n)
91
+ rule_id = get_rule_id_from_index(version_id)
92
+ return nil unless rule_id
93
+
94
+ # Now lock on the specific rule
95
+ with_rule_lock(rule_id) do
96
+ # Read only this rule's versions
97
+ versions = list_versions_unsafe(rule_id: rule_id)
98
+ versions.find { |v| v[:id] == version_id }
99
+ end
77
100
  end
78
101
 
79
102
  def get_version_by_number(rule_id:, version_number:)
80
- versions = list_versions(rule_id: rule_id)
81
- versions.find { |v| v[:version_number] == version_number }
103
+ with_rule_lock(rule_id) do
104
+ versions = list_versions_unsafe(rule_id: rule_id)
105
+ versions.find { |v| v[:version_number] == version_number }
106
+ end
82
107
  end
83
108
 
84
109
  def get_active_version(rule_id:)
85
- versions = list_versions(rule_id: rule_id)
86
- versions.find { |v| v[:status] == "active" }
110
+ with_rule_lock(rule_id) do
111
+ versions = list_versions_unsafe(rule_id: rule_id)
112
+ versions.find { |v| v[:status] == "active" }
113
+ end
87
114
  end
88
115
 
89
116
  def activate_version(version_id:)
90
- @mutex.synchronize do
91
- version = get_version(version_id: version_id)
117
+ # Use index to find rule_id quickly - O(1) instead of O(n)
118
+ rule_id = get_rule_id_from_index(version_id)
119
+ raise DecisionAgent::NotFoundError, "Version not found: #{version_id}" unless rule_id
120
+
121
+ # Now lock on the specific rule
122
+ with_rule_lock(rule_id) do
123
+ # Read only this rule's versions
124
+ versions = list_versions_unsafe(rule_id: rule_id)
125
+ version = versions.find { |v| v[:id] == version_id }
92
126
  raise DecisionAgent::NotFoundError, "Version not found: #{version_id}" unless version
93
127
 
94
128
  # Deactivate all other versions for this rule
95
- list_versions(rule_id: version[:rule_id]).each do |v|
96
- if v[:id] != version_id && v[:status] == "active"
97
- update_version_status(v[:id], "archived")
98
- end
129
+ versions.each do |v|
130
+ update_version_status_unsafe(v[:id], "archived", rule_id) if v[:id] != version_id && v[:status] == "active"
99
131
  end
100
132
 
101
133
  # Activate this version
@@ -107,8 +139,15 @@ module DecisionAgent
107
139
  end
108
140
 
109
141
  def delete_version(version_id:)
110
- @mutex.synchronize do
111
- version = get_version(version_id: version_id)
142
+ # Use index to find rule_id quickly - O(1) instead of O(n)
143
+ rule_id = get_rule_id_from_index(version_id)
144
+ raise DecisionAgent::NotFoundError, "Version not found: #{version_id}" unless rule_id
145
+
146
+ # Now lock on the specific rule
147
+ with_rule_lock(rule_id) do
148
+ # Read only this rule's versions
149
+ versions = list_versions_unsafe(rule_id: rule_id)
150
+ version = versions.find { |v| v[:id] == version_id }
112
151
  raise DecisionAgent::NotFoundError, "Version not found: #{version_id}" unless version
113
152
 
114
153
  # Prevent deletion of active versions
@@ -117,12 +156,14 @@ module DecisionAgent
117
156
  end
118
157
 
119
158
  # Delete the file
120
- rule_dir = File.join(@storage_path, sanitize_filename(version[:rule_id]))
159
+ rule_dir = File.join(@storage_path, sanitize_filename(rule_id))
121
160
  filename = "#{version[:version_number]}.json"
122
161
  filepath = File.join(rule_dir, filename)
123
162
 
124
163
  if File.exist?(filepath)
125
164
  File.delete(filepath)
165
+ # Remove from index
166
+ remove_from_index(version_id)
126
167
  true
127
168
  else
128
169
  false
@@ -132,7 +173,21 @@ module DecisionAgent
132
173
 
133
174
  private
134
175
 
135
- def all_versions
176
+ def list_versions_unsafe(rule_id:, limit: nil)
177
+ versions = []
178
+ rule_dir = File.join(@storage_path, sanitize_filename(rule_id))
179
+
180
+ return versions unless Dir.exist?(rule_dir)
181
+
182
+ Dir.glob(File.join(rule_dir, "*.json")).each do |file|
183
+ versions << JSON.parse(File.read(file), symbolize_names: true)
184
+ end
185
+
186
+ versions.sort_by! { |v| -v[:version_number] }
187
+ limit ? versions.take(limit) : versions
188
+ end
189
+
190
+ def all_versions_unsafe
136
191
  versions = []
137
192
  return versions unless Dir.exist?(@storage_path)
138
193
 
@@ -143,8 +198,17 @@ module DecisionAgent
143
198
  versions
144
199
  end
145
200
 
146
- def update_version_status(version_id, status)
147
- version = get_version(version_id: version_id)
201
+ def update_version_status_unsafe(version_id, status, rule_id = nil)
202
+ # Validate status first
203
+ validate_status!(status)
204
+
205
+ # Use provided rule_id or look it up from index
206
+ rule_id ||= get_rule_id_from_index(version_id)
207
+ return unless rule_id
208
+
209
+ # Read only this rule's versions
210
+ versions = list_versions_unsafe(rule_id: rule_id)
211
+ version = versions.find { |v| v[:id] == version_id }
148
212
  return unless version
149
213
 
150
214
  version[:status] = status
@@ -164,9 +228,11 @@ module DecisionAgent
164
228
  begin
165
229
  File.write(temp_file, JSON.pretty_generate(version))
166
230
  File.rename(temp_file, filepath)
231
+ # Update index after successful write
232
+ add_to_index(version[:id], version[:rule_id])
167
233
  ensure
168
234
  # Clean up temp file if rename failed
169
- File.delete(temp_file) if File.exist?(temp_file)
235
+ FileUtils.rm_f(temp_file)
170
236
  end
171
237
  end
172
238
 
@@ -177,6 +243,48 @@ module DecisionAgent
177
243
  def sanitize_filename(name)
178
244
  name.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
179
245
  end
246
+
247
+ # Get or create a mutex for a specific rule_id
248
+ # This allows different rules to be processed in parallel
249
+ def with_rule_lock(rule_id, &block)
250
+ mutex = @rule_mutexes_lock.synchronize { @rule_mutexes[rule_id] }
251
+ mutex.synchronize(&block)
252
+ end
253
+
254
+ # Index management methods for O(1) version_id -> rule_id lookups
255
+ # This prevents the need to scan all 50,000 files when looking up a single version
256
+
257
+ def load_version_index
258
+ @version_index_lock.synchronize do
259
+ return unless Dir.exist?(@storage_path)
260
+
261
+ Dir.glob(File.join(@storage_path, "*", "*.json")).each do |file|
262
+ version = JSON.parse(File.read(file), symbolize_names: true)
263
+ @version_index[version[:id]] = version[:rule_id]
264
+ rescue JSON::ParserError
265
+ # Skip corrupted files
266
+ next
267
+ end
268
+ end
269
+ end
270
+
271
+ def get_rule_id_from_index(version_id)
272
+ @version_index_lock.synchronize do
273
+ @version_index[version_id]
274
+ end
275
+ end
276
+
277
+ def add_to_index(version_id, rule_id)
278
+ @version_index_lock.synchronize do
279
+ @version_index[version_id] = rule_id
280
+ end
281
+ end
282
+
283
+ def remove_from_index(version_id)
284
+ @version_index_lock.synchronize do
285
+ @version_index.delete(version_id)
286
+ end
287
+ end
180
288
  end
181
289
  end
182
290
  end
@@ -54,22 +54,14 @@ module DecisionAgent
54
54
  @adapter.get_active_version(rule_id: rule_id)
55
55
  end
56
56
 
57
- # Rollback to a previous version (activate it)
57
+ # Rollback to a previous version (activate it without creating a duplicate)
58
58
  # @param version_id [String, Integer] The version to rollback to
59
59
  # @param performed_by [String] User performing the rollback
60
60
  # @return [Hash] The activated version
61
61
  def rollback(version_id:, performed_by: "system")
62
- version = @adapter.activate_version(version_id: version_id)
63
-
64
- # Create an audit trail of the rollback
65
- save_version(
66
- rule_id: version[:rule_id],
67
- rule_content: version[:content],
68
- created_by: performed_by,
69
- changelog: "Rolled back to version #{version[:version_number]}"
70
- )
71
-
72
- version
62
+ # Simply activate the previous version without creating a duplicate
63
+ # The version history already contains the full record
64
+ @adapter.activate_version(version_id: version_id)
73
65
  end
74
66
 
75
67
  # Compare two versions
@@ -237,7 +237,7 @@
237
237
  </div>
238
238
 
239
239
  <footer class="footer">
240
- <p>DecisionAgent Rule Builder | <a href="https://github.com/yourusername/decision_agent" target="_blank">Documentation</a></p>
240
+ <p>DecisionAgent Rule Builder | <a href="https://github.com/samaswin87/decision_agent" target="_blank">Documentation</a></p>
241
241
  </footer>
242
242
 
243
243
  <script src="app.js"></script>
@@ -43,14 +43,12 @@ module DecisionAgent
43
43
  valid: true,
44
44
  message: "Rules are valid!"
45
45
  }.to_json
46
-
47
46
  rescue JSON::ParserError => e
48
47
  status 400
49
48
  {
50
49
  valid: false,
51
50
  errors: ["Invalid JSON: #{e.message}"]
52
51
  }.to_json
53
-
54
52
  rescue DecisionAgent::InvalidRuleDslError => e
55
53
  # Validation failed
56
54
  status 422
@@ -58,8 +56,7 @@ module DecisionAgent
58
56
  valid: false,
59
57
  errors: parse_validation_errors(e.message)
60
58
  }.to_json
61
-
62
- rescue => e
59
+ rescue StandardError => e
63
60
  # Unexpected error
64
61
  status 500
65
62
  {
@@ -102,8 +99,7 @@ module DecisionAgent
102
99
  message: "No rules matched the given context"
103
100
  }.to_json
104
101
  end
105
-
106
- rescue => e
102
+ rescue StandardError => e
107
103
  status 500
108
104
  {
109
105
  success: false,
@@ -136,7 +132,7 @@ module DecisionAgent
136
132
  },
137
133
  {
138
134
  id: "high_amount_review",
139
- if: { field: "amount", op: "gte", value: 10000 },
135
+ if: { field: "amount", op: "gte", value: 10_000 },
140
136
  then: { decision: "manual_review", weight: 0.9, reason: "High amount requires review" }
141
137
  }
142
138
  ]
@@ -247,8 +243,7 @@ module DecisionAgent
247
243
 
248
244
  status 201
249
245
  version.to_json
250
-
251
- rescue => e
246
+ rescue StandardError => e
252
247
  status 500
253
248
  { error: e.message }.to_json
254
249
  end
@@ -265,8 +260,7 @@ module DecisionAgent
265
260
  versions = version_manager.get_versions(rule_id: rule_id, limit: limit)
266
261
 
267
262
  versions.to_json
268
-
269
- rescue => e
263
+ rescue StandardError => e
270
264
  status 500
271
265
  { error: e.message }.to_json
272
266
  end
@@ -281,8 +275,7 @@ module DecisionAgent
281
275
  history = version_manager.get_history(rule_id: rule_id)
282
276
 
283
277
  history.to_json
284
-
285
- rescue => e
278
+ rescue StandardError => e
286
279
  status 500
287
280
  { error: e.message }.to_json
288
281
  end
@@ -302,8 +295,7 @@ module DecisionAgent
302
295
  status 404
303
296
  { error: "Version not found" }.to_json
304
297
  end
305
-
306
- rescue => e
298
+ rescue StandardError => e
307
299
  status 500
308
300
  { error: e.message }.to_json
309
301
  end
@@ -325,8 +317,7 @@ module DecisionAgent
325
317
  )
326
318
 
327
319
  version.to_json
328
-
329
- rescue => e
320
+ rescue StandardError => e
330
321
  status 500
331
322
  { error: e.message }.to_json
332
323
  end
@@ -351,8 +342,7 @@ module DecisionAgent
351
342
  status 404
352
343
  { error: "One or both versions not found" }.to_json
353
344
  end
354
-
355
- rescue => e
345
+ rescue StandardError => e
356
346
  status 500
357
347
  { error: e.message }.to_json
358
348
  end
@@ -369,16 +359,13 @@ module DecisionAgent
369
359
 
370
360
  status 200
371
361
  { success: true, message: "Version deleted successfully" }.to_json
372
-
373
362
  rescue DecisionAgent::NotFoundError => e
374
363
  status 404
375
364
  { error: e.message }.to_json
376
-
377
365
  rescue DecisionAgent::ValidationError => e
378
366
  status 422
379
367
  { error: e.message }.to_json
380
-
381
- rescue => e
368
+ rescue StandardError => e
382
369
  status 500
383
370
  { error: e.message }.to_json
384
371
  end
@@ -409,12 +396,20 @@ module DecisionAgent
409
396
  errors.empty? ? [error_message] : errors
410
397
  end
411
398
 
412
- # Class method to start the server
399
+ # Class method to start the server (for CLI usage)
413
400
  def self.start!(port: 4567, host: "0.0.0.0")
414
401
  set :port, port
415
402
  set :bind, host
416
403
  run!
417
404
  end
405
+
406
+ # Rack interface for mounting in Rails/Rack apps
407
+ # Example:
408
+ # # config/routes.rb
409
+ # mount DecisionAgent::Web::Server, at: "/decision_agent"
410
+ def self.call(env)
411
+ new.call(env)
412
+ end
418
413
  end
419
414
  end
420
415
  end
@@ -2,6 +2,7 @@ require_relative "decision_agent/version"
2
2
  require_relative "decision_agent/errors"
3
3
  require_relative "decision_agent/context"
4
4
  require_relative "decision_agent/evaluation"
5
+ require_relative "decision_agent/evaluation_validator"
5
6
  require_relative "decision_agent/decision"
6
7
  require_relative "decision_agent/agent"
7
8
 
@@ -29,5 +30,18 @@ require_relative "decision_agent/versioning/adapter"
29
30
  require_relative "decision_agent/versioning/file_storage_adapter"
30
31
  require_relative "decision_agent/versioning/version_manager"
31
32
 
33
+ require_relative "decision_agent/monitoring/metrics_collector"
34
+ require_relative "decision_agent/monitoring/prometheus_exporter"
35
+ require_relative "decision_agent/monitoring/alert_manager"
36
+ require_relative "decision_agent/monitoring/monitored_agent"
37
+ # dashboard_server has additional dependencies (faye/websocket) - require it explicitly when needed
38
+
39
+ require_relative "decision_agent/ab_testing/ab_test"
40
+ require_relative "decision_agent/ab_testing/ab_test_assignment"
41
+ require_relative "decision_agent/ab_testing/ab_test_manager"
42
+ require_relative "decision_agent/ab_testing/ab_testing_agent"
43
+ require_relative "decision_agent/ab_testing/storage/adapter"
44
+ require_relative "decision_agent/ab_testing/storage/memory_adapter"
45
+
32
46
  module DecisionAgent
33
47
  end
@@ -1,15 +1,23 @@
1
- require 'rails/generators'
2
- require 'rails/generators/migration'
1
+ require "rails/generators"
2
+ require "rails/generators/migration"
3
3
 
4
4
  module DecisionAgent
5
5
  module Generators
6
6
  class InstallGenerator < Rails::Generators::Base
7
7
  include Rails::Generators::Migration
8
8
 
9
- source_root File.expand_path('templates', __dir__)
9
+ source_root File.expand_path("templates", __dir__)
10
10
 
11
11
  desc "Installs DecisionAgent models and migrations for Rails"
12
12
 
13
+ class_option :monitoring, type: :boolean,
14
+ default: false,
15
+ desc: "Install monitoring tables and models for persistent metrics storage"
16
+
17
+ class_option :ab_testing, type: :boolean,
18
+ default: false,
19
+ desc: "Install A/B testing tables and models for variant testing"
20
+
13
21
  def self.next_migration_number(dirname)
14
22
  next_migration_number = current_migration_number(dirname) + 1
15
23
  ActiveRecord::Migration.next_migration_number(next_migration_number)
@@ -17,13 +25,42 @@ module DecisionAgent
17
25
 
18
26
  def copy_migration
19
27
  migration_template "migration.rb",
20
- "db/migrate/create_decision_agent_tables.rb",
21
- migration_version: migration_version
28
+ "db/migrate/create_decision_agent_tables.rb",
29
+ migration_version: migration_version
30
+
31
+ if options[:monitoring]
32
+ migration_template "monitoring_migration.rb",
33
+ "db/migrate/create_decision_agent_monitoring_tables.rb",
34
+ migration_version: migration_version
35
+ end
36
+
37
+ return unless options[:ab_testing]
38
+
39
+ migration_template "ab_testing_migration.rb",
40
+ "db/migrate/create_decision_agent_ab_testing_tables.rb",
41
+ migration_version: migration_version
22
42
  end
23
43
 
24
44
  def copy_models
25
45
  copy_file "rule.rb", "app/models/rule.rb"
26
46
  copy_file "rule_version.rb", "app/models/rule_version.rb"
47
+
48
+ if options[:monitoring]
49
+ copy_file "decision_log.rb", "app/models/decision_log.rb"
50
+ copy_file "evaluation_metric.rb", "app/models/evaluation_metric.rb"
51
+ copy_file "performance_metric.rb", "app/models/performance_metric.rb"
52
+ copy_file "error_metric.rb", "app/models/error_metric.rb"
53
+ end
54
+
55
+ return unless options[:ab_testing]
56
+
57
+ copy_file "ab_test_model.rb", "app/models/ab_test_model.rb"
58
+ copy_file "ab_test_assignment_model.rb", "app/models/ab_test_assignment_model.rb"
59
+ end
60
+
61
+ def copy_rake_tasks
62
+ copy_file "decision_agent_tasks.rake", "lib/tasks/decision_agent.rake" if options[:monitoring]
63
+ copy_file "ab_testing_tasks.rake", "lib/tasks/ab_testing.rake" if options[:ab_testing]
27
64
  end
28
65
 
29
66
  def show_readme
@@ -0,0 +1,45 @@
1
+ # A/B Test Assignment model for decision_agent gem
2
+ # Tracks individual variant assignments and their results
3
+ class ABTestAssignmentModel < ActiveRecord::Base
4
+ belongs_to :ab_test_model
5
+
6
+ validates :variant, presence: true, inclusion: { in: %w[champion challenger] }
7
+ validates :version_id, presence: true
8
+ validates :timestamp, presence: true
9
+
10
+ before_validation :set_defaults
11
+
12
+ # Scopes
13
+ scope :champion, -> { where(variant: "champion") }
14
+ scope :challenger, -> { where(variant: "challenger") }
15
+ scope :with_decisions, -> { where.not(decision_result: nil) }
16
+ scope :recent, -> { order(timestamp: :desc) }
17
+ scope :for_user, ->(user_id) { where(user_id: user_id) }
18
+
19
+ serialize :context, JSON
20
+
21
+ # Check if decision has been recorded
22
+ def decision_recorded?
23
+ decision_result.present?
24
+ end
25
+
26
+ # Get the test this assignment belongs to
27
+ def test
28
+ ab_test_model
29
+ end
30
+
31
+ # Record decision result
32
+ def record_decision!(decision, confidence_score)
33
+ update!(
34
+ decision_result: decision,
35
+ confidence: confidence_score
36
+ )
37
+ end
38
+
39
+ private
40
+
41
+ def set_defaults
42
+ self.timestamp ||= Time.current
43
+ self.context ||= {}
44
+ end
45
+ end
@@ -0,0 +1,54 @@
1
+ # A/B Test model for decision_agent gem
2
+ # Stores A/B test configurations
3
+ class ABTestModel < ActiveRecord::Base
4
+ has_many :ab_test_assignment_models, dependent: :destroy
5
+
6
+ validates :name, presence: true
7
+ validates :champion_version_id, presence: true
8
+ validates :challenger_version_id, presence: true
9
+ validates :status, presence: true, inclusion: { in: %w[scheduled running completed cancelled] }
10
+
11
+ serialize :traffic_split, JSON
12
+
13
+ before_validation :set_defaults
14
+
15
+ # Scopes
16
+ scope :active, -> { where(status: "running") }
17
+ scope :scheduled, -> { where(status: "scheduled") }
18
+ scope :completed, -> { where(status: "completed") }
19
+ scope :running_or_scheduled, -> { where(status: %w[running scheduled]) }
20
+
21
+ # Check if test is currently running
22
+ def running?
23
+ status == "running" &&
24
+ (start_date.nil? || start_date <= Time.current) &&
25
+ (end_date.nil? || end_date > Time.current)
26
+ end
27
+
28
+ # Get statistics for this test
29
+ def statistics
30
+ {
31
+ total_assignments: ab_test_assignment_models.count,
32
+ champion_count: ab_test_assignment_models.where(variant: "champion").count,
33
+ challenger_count: ab_test_assignment_models.where(variant: "challenger").count,
34
+ with_decisions: ab_test_assignment_models.where.not(decision_result: nil).count,
35
+ avg_confidence: ab_test_assignment_models.where.not(confidence: nil).average(:confidence)&.to_f
36
+ }
37
+ end
38
+
39
+ # Get assignments by variant
40
+ def champion_assignments
41
+ ab_test_assignment_models.where(variant: "champion")
42
+ end
43
+
44
+ def challenger_assignments
45
+ ab_test_assignment_models.where(variant: "challenger")
46
+ end
47
+
48
+ private
49
+
50
+ def set_defaults
51
+ self.traffic_split ||= { champion: 90, challenger: 10 }
52
+ self.status ||= "scheduled"
53
+ end
54
+ end