decision_agent 0.2.0 → 0.3.0

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -1
  3. data/bin/decision_agent +104 -0
  4. data/lib/decision_agent/dmn/adapter.rb +135 -0
  5. data/lib/decision_agent/dmn/cache.rb +306 -0
  6. data/lib/decision_agent/dmn/decision_graph.rb +327 -0
  7. data/lib/decision_agent/dmn/decision_tree.rb +192 -0
  8. data/lib/decision_agent/dmn/errors.rb +30 -0
  9. data/lib/decision_agent/dmn/exporter.rb +217 -0
  10. data/lib/decision_agent/dmn/feel/evaluator.rb +797 -0
  11. data/lib/decision_agent/dmn/feel/functions.rb +420 -0
  12. data/lib/decision_agent/dmn/feel/parser.rb +349 -0
  13. data/lib/decision_agent/dmn/feel/simple_parser.rb +276 -0
  14. data/lib/decision_agent/dmn/feel/transformer.rb +372 -0
  15. data/lib/decision_agent/dmn/feel/types.rb +276 -0
  16. data/lib/decision_agent/dmn/importer.rb +77 -0
  17. data/lib/decision_agent/dmn/model.rb +197 -0
  18. data/lib/decision_agent/dmn/parser.rb +191 -0
  19. data/lib/decision_agent/dmn/testing.rb +333 -0
  20. data/lib/decision_agent/dmn/validator.rb +315 -0
  21. data/lib/decision_agent/dmn/versioning.rb +229 -0
  22. data/lib/decision_agent/dmn/visualizer.rb +513 -0
  23. data/lib/decision_agent/dsl/condition_evaluator.rb +3 -0
  24. data/lib/decision_agent/dsl/schema_validator.rb +2 -1
  25. data/lib/decision_agent/evaluators/dmn_evaluator.rb +221 -0
  26. data/lib/decision_agent/version.rb +1 -1
  27. data/lib/decision_agent/web/dmn_editor.rb +426 -0
  28. data/lib/decision_agent/web/public/dmn-editor.css +596 -0
  29. data/lib/decision_agent/web/public/dmn-editor.html +250 -0
  30. data/lib/decision_agent/web/public/dmn-editor.js +553 -0
  31. data/lib/decision_agent/web/public/index.html +3 -0
  32. data/lib/decision_agent/web/public/styles.css +21 -0
  33. data/lib/decision_agent/web/server.rb +465 -0
  34. data/spec/ab_testing/ab_testing_agent_spec.rb +174 -0
  35. data/spec/auth/rbac_adapter_spec.rb +228 -0
  36. data/spec/dmn/decision_graph_spec.rb +282 -0
  37. data/spec/dmn/decision_tree_spec.rb +203 -0
  38. data/spec/dmn/feel/errors_spec.rb +18 -0
  39. data/spec/dmn/feel/functions_spec.rb +400 -0
  40. data/spec/dmn/feel/simple_parser_spec.rb +274 -0
  41. data/spec/dmn/feel/types_spec.rb +176 -0
  42. data/spec/dmn/feel_parser_spec.rb +489 -0
  43. data/spec/dmn/hit_policy_spec.rb +202 -0
  44. data/spec/dmn/integration_spec.rb +226 -0
  45. data/spec/examples.txt +1846 -1570
  46. data/spec/fixtures/dmn/complex_decision.dmn +81 -0
  47. data/spec/fixtures/dmn/invalid_structure.dmn +31 -0
  48. data/spec/fixtures/dmn/simple_decision.dmn +40 -0
  49. data/spec/monitoring/metrics_collector_spec.rb +37 -35
  50. data/spec/monitoring/monitored_agent_spec.rb +14 -11
  51. data/spec/performance_optimizations_spec.rb +10 -3
  52. data/spec/thread_safety_spec.rb +10 -2
  53. data/spec/web_ui_rack_spec.rb +294 -0
  54. metadata +65 -1
@@ -12,6 +12,10 @@ require_relative "../testing/test_coverage_analyzer"
12
12
  require_relative "../evaluators/json_rule_evaluator"
13
13
  require_relative "../agent"
14
14
 
15
+ # DMN components
16
+ require_relative "../dmn/importer"
17
+ require_relative "../dmn/exporter"
18
+
15
19
  # Auth components
16
20
  require_relative "../auth/user"
17
21
  require_relative "../auth/role"
@@ -1157,8 +1161,469 @@ module DecisionAgent
1157
1161
  "User management page not found"
1158
1162
  end
1159
1163
 
1164
+ # DMN Editor Routes
1165
+
1166
+ # GET /dmn/editor - DMN Editor UI page
1167
+ get "/dmn/editor" do
1168
+ send_file File.join(settings.public_folder, "dmn-editor.html")
1169
+ rescue StandardError
1170
+ status 404
1171
+ "DMN Editor page not found"
1172
+ end
1173
+
1174
+ # API: List all DMN models
1175
+ get "/api/dmn/models" do
1176
+ content_type :json
1177
+ dmn_editor.list_models.to_json
1178
+ end
1179
+
1180
+ # API: Create new DMN model
1181
+ post "/api/dmn/models" do
1182
+ content_type :json
1183
+
1184
+ begin
1185
+ request_body = request.body.read
1186
+ data = JSON.parse(request_body)
1187
+
1188
+ model = dmn_editor.create_model(
1189
+ name: data["name"],
1190
+ namespace: data["namespace"]
1191
+ )
1192
+
1193
+ status 201
1194
+ model.to_json
1195
+ rescue StandardError => e
1196
+ status 500
1197
+ { error: e.message }.to_json
1198
+ end
1199
+ end
1200
+
1201
+ # API: Get DMN model
1202
+ get "/api/dmn/models/:id" do
1203
+ content_type :json
1204
+
1205
+ model = dmn_editor.get_model(params[:id])
1206
+ if model
1207
+ model.to_json
1208
+ else
1209
+ status 404
1210
+ { error: "Model not found" }.to_json
1211
+ end
1212
+ end
1213
+
1214
+ # API: Update DMN model
1215
+ put "/api/dmn/models/:id" do
1216
+ content_type :json
1217
+
1218
+ begin
1219
+ request_body = request.body.read
1220
+ data = JSON.parse(request_body)
1221
+
1222
+ model = dmn_editor.update_model(
1223
+ params[:id],
1224
+ name: data["name"],
1225
+ namespace: data["namespace"]
1226
+ )
1227
+
1228
+ if model
1229
+ model.to_json
1230
+ else
1231
+ status 404
1232
+ { error: "Model not found" }.to_json
1233
+ end
1234
+ rescue StandardError => e
1235
+ status 500
1236
+ { error: e.message }.to_json
1237
+ end
1238
+ end
1239
+
1240
+ # API: Delete DMN model
1241
+ delete "/api/dmn/models/:id" do
1242
+ content_type :json
1243
+
1244
+ result = dmn_editor.delete_model(params[:id])
1245
+ { success: result }.to_json
1246
+ end
1247
+
1248
+ # API: Add decision to model
1249
+ post "/api/dmn/models/:model_id/decisions" do
1250
+ content_type :json
1251
+
1252
+ begin
1253
+ request_body = request.body.read
1254
+ data = JSON.parse(request_body)
1255
+
1256
+ decision = dmn_editor.add_decision(
1257
+ model_id: params[:model_id],
1258
+ decision_id: data["decision_id"],
1259
+ name: data["name"],
1260
+ type: data["type"] || "decision_table"
1261
+ )
1262
+
1263
+ if decision
1264
+ status 201
1265
+ decision.to_json
1266
+ else
1267
+ status 404
1268
+ { error: "Model not found" }.to_json
1269
+ end
1270
+ rescue StandardError => e
1271
+ status 500
1272
+ { error: e.message }.to_json
1273
+ end
1274
+ end
1275
+
1276
+ # API: Update decision
1277
+ put "/api/dmn/models/:model_id/decisions/:decision_id" do
1278
+ content_type :json
1279
+
1280
+ begin
1281
+ request_body = request.body.read
1282
+ data = JSON.parse(request_body)
1283
+
1284
+ decision = dmn_editor.update_decision(
1285
+ model_id: params[:model_id],
1286
+ decision_id: params[:decision_id],
1287
+ name: data["name"],
1288
+ logic: data["logic"]
1289
+ )
1290
+
1291
+ if decision
1292
+ decision.to_json
1293
+ else
1294
+ status 404
1295
+ { error: "Decision not found" }.to_json
1296
+ end
1297
+ rescue StandardError => e
1298
+ status 500
1299
+ { error: e.message }.to_json
1300
+ end
1301
+ end
1302
+
1303
+ # API: Delete decision
1304
+ delete "/api/dmn/models/:model_id/decisions/:decision_id" do
1305
+ content_type :json
1306
+
1307
+ result = dmn_editor.delete_decision(
1308
+ model_id: params[:model_id],
1309
+ decision_id: params[:decision_id]
1310
+ )
1311
+
1312
+ { success: result }.to_json
1313
+ end
1314
+
1315
+ # API: Add input column
1316
+ post "/api/dmn/models/:model_id/decisions/:decision_id/inputs" do
1317
+ content_type :json
1318
+
1319
+ begin
1320
+ request_body = request.body.read
1321
+ data = JSON.parse(request_body)
1322
+
1323
+ input = dmn_editor.add_input(
1324
+ model_id: params[:model_id],
1325
+ decision_id: params[:decision_id],
1326
+ input_id: data["input_id"],
1327
+ label: data["label"],
1328
+ type_ref: data["type_ref"],
1329
+ expression: data["expression"]
1330
+ )
1331
+
1332
+ if input
1333
+ status 201
1334
+ input.to_json
1335
+ else
1336
+ status 404
1337
+ { error: "Decision not found" }.to_json
1338
+ end
1339
+ rescue StandardError => e
1340
+ status 500
1341
+ { error: e.message }.to_json
1342
+ end
1343
+ end
1344
+
1345
+ # API: Add output column
1346
+ post "/api/dmn/models/:model_id/decisions/:decision_id/outputs" do
1347
+ content_type :json
1348
+
1349
+ begin
1350
+ request_body = request.body.read
1351
+ data = JSON.parse(request_body)
1352
+
1353
+ output = dmn_editor.add_output(
1354
+ model_id: params[:model_id],
1355
+ decision_id: params[:decision_id],
1356
+ output_id: data["output_id"],
1357
+ label: data["label"],
1358
+ type_ref: data["type_ref"],
1359
+ name: data["name"]
1360
+ )
1361
+
1362
+ if output
1363
+ status 201
1364
+ output.to_json
1365
+ else
1366
+ status 404
1367
+ { error: "Decision not found" }.to_json
1368
+ end
1369
+ rescue StandardError => e
1370
+ status 500
1371
+ { error: e.message }.to_json
1372
+ end
1373
+ end
1374
+
1375
+ # API: Add rule
1376
+ post "/api/dmn/models/:model_id/decisions/:decision_id/rules" do
1377
+ content_type :json
1378
+
1379
+ begin
1380
+ request_body = request.body.read
1381
+ data = JSON.parse(request_body)
1382
+
1383
+ rule = dmn_editor.add_rule(
1384
+ model_id: params[:model_id],
1385
+ decision_id: params[:decision_id],
1386
+ rule_id: data["rule_id"],
1387
+ input_entries: data["input_entries"],
1388
+ output_entries: data["output_entries"],
1389
+ description: data["description"]
1390
+ )
1391
+
1392
+ if rule
1393
+ status 201
1394
+ rule.to_json
1395
+ else
1396
+ status 404
1397
+ { error: "Decision not found" }.to_json
1398
+ end
1399
+ rescue StandardError => e
1400
+ status 500
1401
+ { error: e.message }.to_json
1402
+ end
1403
+ end
1404
+
1405
+ # API: Update rule
1406
+ put "/api/dmn/models/:model_id/decisions/:decision_id/rules/:rule_id" do
1407
+ content_type :json
1408
+
1409
+ begin
1410
+ request_body = request.body.read
1411
+ data = JSON.parse(request_body)
1412
+
1413
+ rule = dmn_editor.update_rule(
1414
+ model_id: params[:model_id],
1415
+ decision_id: params[:decision_id],
1416
+ rule_id: params[:rule_id],
1417
+ input_entries: data["input_entries"],
1418
+ output_entries: data["output_entries"],
1419
+ description: data["description"]
1420
+ )
1421
+
1422
+ if rule
1423
+ rule.to_json
1424
+ else
1425
+ status 404
1426
+ { error: "Rule not found" }.to_json
1427
+ end
1428
+ rescue StandardError => e
1429
+ status 500
1430
+ { error: e.message }.to_json
1431
+ end
1432
+ end
1433
+
1434
+ # API: Delete rule
1435
+ delete "/api/dmn/models/:model_id/decisions/:decision_id/rules/:rule_id" do
1436
+ content_type :json
1437
+
1438
+ result = dmn_editor.delete_rule(
1439
+ model_id: params[:model_id],
1440
+ decision_id: params[:decision_id],
1441
+ rule_id: params[:rule_id]
1442
+ )
1443
+
1444
+ { success: result }.to_json
1445
+ end
1446
+
1447
+ # API: Validate DMN model
1448
+ get "/api/dmn/models/:id/validate" do
1449
+ content_type :json
1450
+ dmn_editor.validate_model(params[:id]).to_json
1451
+ end
1452
+
1453
+ # API: Export DMN model to XML
1454
+ get "/api/dmn/models/:id/export" do
1455
+ content_type :xml
1456
+
1457
+ xml = dmn_editor.export_to_xml(params[:id])
1458
+ if xml
1459
+ xml
1460
+ else
1461
+ status 404
1462
+ "Model not found"
1463
+ end
1464
+ end
1465
+
1466
+ # API: Import DMN model from XML
1467
+ post "/api/dmn/models/import" do
1468
+ content_type :json
1469
+
1470
+ begin
1471
+ request_body = request.body.read
1472
+ data = JSON.parse(request_body)
1473
+
1474
+ model = dmn_editor.import_from_xml(data["xml"], name: data["name"])
1475
+
1476
+ if model
1477
+ status 201
1478
+ model.to_json
1479
+ else
1480
+ status 400
1481
+ { error: "Failed to import model" }.to_json
1482
+ end
1483
+ rescue StandardError => e
1484
+ status 500
1485
+ { error: e.message }.to_json
1486
+ end
1487
+ end
1488
+
1489
+ # API: Visualize decision tree
1490
+ get "/api/dmn/models/:model_id/decisions/:decision_id/visualize/tree" do
1491
+ format = params[:format] || "svg"
1492
+
1493
+ visualization = dmn_editor.visualize_tree(
1494
+ model_id: params[:model_id],
1495
+ decision_id: params[:decision_id],
1496
+ format: format
1497
+ )
1498
+
1499
+ if visualization
1500
+ content_type format == "svg" ? "image/svg+xml" : :text
1501
+ visualization
1502
+ else
1503
+ status 404
1504
+ "Decision not found or not a tree"
1505
+ end
1506
+ end
1507
+
1508
+ # API: Visualize decision graph
1509
+ get "/api/dmn/models/:id/visualize/graph" do
1510
+ format = params[:format] || "svg"
1511
+
1512
+ visualization = dmn_editor.visualize_graph(
1513
+ model_id: params[:id],
1514
+ format: format
1515
+ )
1516
+
1517
+ if visualization
1518
+ content_type format == "svg" ? "image/svg+xml" : :text
1519
+ visualization
1520
+ else
1521
+ status 404
1522
+ "Model not found"
1523
+ end
1524
+ end
1525
+
1526
+ # API: Import DMN file (uploads and imports to versioning system)
1527
+ post "/api/dmn/import" do
1528
+ content_type :json
1529
+
1530
+ begin
1531
+ # Check if request has multipart form data (file upload)
1532
+ if params[:file] && params[:file][:tempfile]
1533
+ # File upload
1534
+ file = params[:file][:tempfile]
1535
+ xml_content = file.read
1536
+ ruleset_name = params[:ruleset_name] || params[:file][:filename]&.gsub(/\.dmn$/i, "")
1537
+ created_by = @current_user ? @current_user.id.to_s : params[:created_by] || "system"
1538
+ elsif request.content_type&.include?("application/json")
1539
+ # JSON body with XML content
1540
+ request_body = request.body.read
1541
+ data = JSON.parse(request_body)
1542
+ xml_content = data["xml"] || data["content"]
1543
+ ruleset_name = data["ruleset_name"] || data["name"]
1544
+ created_by = @current_user ? @current_user.id.to_s : data["created_by"] || "system"
1545
+ elsif request.content_type&.include?("application/xml") || request.content_type&.include?("text/xml")
1546
+ # Direct XML upload
1547
+ xml_content = request.body.read
1548
+ ruleset_name = params[:ruleset_name] || "imported_dmn"
1549
+ created_by = @current_user ? @current_user.id.to_s : params[:created_by] || "system"
1550
+ else
1551
+ status 400
1552
+ return { error: "Invalid request. Expected file upload, JSON with 'xml' field, or XML content." }.to_json
1553
+ end
1554
+
1555
+ raise ArgumentError, "DMN XML content is required" if xml_content.nil? || xml_content.strip.empty?
1556
+
1557
+ # Import using DMN Importer
1558
+ importer = Dmn::Importer.new(version_manager: version_manager)
1559
+ result = importer.import_from_xml(
1560
+ xml_content,
1561
+ ruleset_name: ruleset_name,
1562
+ created_by: created_by
1563
+ )
1564
+
1565
+ status 201
1566
+ {
1567
+ success: true,
1568
+ ruleset_name: ruleset_name,
1569
+ decisions_imported: result[:decisions_imported],
1570
+ model: {
1571
+ id: result[:model].id,
1572
+ name: result[:model].name,
1573
+ namespace: result[:model].namespace,
1574
+ decisions: result[:model].decisions.map do |d|
1575
+ {
1576
+ id: d.id,
1577
+ name: d.name
1578
+ }
1579
+ end
1580
+ },
1581
+ versions: result[:versions].map do |v|
1582
+ {
1583
+ version: v[:version],
1584
+ rule_id: v[:rule_id],
1585
+ created_by: v[:created_by],
1586
+ created_at: v[:created_at]
1587
+ }
1588
+ end
1589
+ }.to_json
1590
+ rescue Dmn::InvalidDmnModelError, Dmn::DmnParseError => e
1591
+ status 400
1592
+ { error: "DMN validation error", message: e.message }.to_json
1593
+ rescue StandardError => e
1594
+ status 500
1595
+ { error: "Import failed", message: e.message }.to_json
1596
+ end
1597
+ end
1598
+
1599
+ # API: Export ruleset as DMN XML
1600
+ get "/api/dmn/export/:ruleset_id" do
1601
+ content_type :xml
1602
+
1603
+ begin
1604
+ ruleset_id = params[:ruleset_id]
1605
+ exporter = Dmn::Exporter.new(version_manager: version_manager)
1606
+ dmn_xml = exporter.export(ruleset_id)
1607
+
1608
+ headers["Content-Disposition"] = "attachment; filename=\"#{ruleset_id}.dmn\""
1609
+ dmn_xml
1610
+ rescue Dmn::InvalidDmnModelError => e
1611
+ status 404
1612
+ content_type :json
1613
+ { error: "Ruleset not found", message: e.message }.to_json
1614
+ rescue StandardError => e
1615
+ status 500
1616
+ content_type :json
1617
+ { error: "Export failed", message: e.message }.to_json
1618
+ end
1619
+ end
1620
+
1160
1621
  private
1161
1622
 
1623
+ def dmn_editor
1624
+ @dmn_editor ||= DmnEditor.new
1625
+ end
1626
+
1162
1627
  def version_manager
1163
1628
  @version_manager ||= DecisionAgent::Versioning::VersionManager.new
1164
1629
  end
@@ -1,4 +1,6 @@
1
1
  require "spec_helper"
2
+ require "decision_agent/versioning/file_storage_adapter"
3
+ require "decision_agent/ab_testing/storage/memory_adapter"
2
4
 
3
5
  RSpec.describe DecisionAgent::ABTesting::ABTestingAgent do
4
6
  let(:version_manager) { double("VersionManager") }
@@ -478,4 +480,176 @@ RSpec.describe DecisionAgent::ABTesting::ABTestingAgent do
478
480
  expect(evaluator.decision).to eq("reject")
479
481
  end
480
482
  end
483
+
484
+ describe "integration tests with real version manager" do
485
+ let(:temp_dir) { Dir.mktmpdir }
486
+ let(:file_storage_adapter) do
487
+ DecisionAgent::Versioning::FileStorageAdapter.new(storage_path: temp_dir)
488
+ end
489
+ let(:real_version_manager) do
490
+ DecisionAgent::Versioning::VersionManager.new(adapter: file_storage_adapter)
491
+ end
492
+ let(:storage_adapter) { DecisionAgent::ABTesting::Storage::MemoryAdapter.new }
493
+ let(:real_ab_test_manager) do
494
+ DecisionAgent::ABTesting::ABTestManager.new(
495
+ storage_adapter: storage_adapter,
496
+ version_manager: real_version_manager
497
+ )
498
+ end
499
+
500
+ before do
501
+ # Create test versions with real rules
502
+ @champion_version = real_version_manager.save_version(
503
+ rule_id: "approval_rule",
504
+ rule_content: {
505
+ version: "1.0",
506
+ ruleset: "approval",
507
+ rules: [{
508
+ id: "rule_1",
509
+ if: { field: "amount", op: "gt", value: 100 },
510
+ then: { decision: "approve", weight: 0.9, reason: "Champion rule" }
511
+ }]
512
+ },
513
+ created_by: "spec",
514
+ changelog: "Champion version"
515
+ )
516
+
517
+ @challenger_version = real_version_manager.save_version(
518
+ rule_id: "approval_rule",
519
+ rule_content: {
520
+ version: "1.0",
521
+ ruleset: "approval",
522
+ rules: [{
523
+ id: "rule_1",
524
+ if: { field: "amount", op: "gt", value: 200 },
525
+ then: { decision: "approve", weight: 0.95, reason: "Challenger rule" }
526
+ }]
527
+ },
528
+ created_by: "spec",
529
+ changelog: "Challenger version"
530
+ )
531
+
532
+ @ab_test = real_ab_test_manager.create_test(
533
+ name: "Approval Threshold Test",
534
+ champion_version_id: @champion_version[:id],
535
+ challenger_version_id: @challenger_version[:id],
536
+ traffic_split: { champion: 50, challenger: 50 }
537
+ )
538
+ end
539
+
540
+ after do
541
+ FileUtils.rm_rf(temp_dir)
542
+ end
543
+
544
+ it "uses real version manager to get version content" do
545
+ agent = described_class.new(
546
+ ab_test_manager: real_ab_test_manager,
547
+ version_manager: real_version_manager
548
+ )
549
+
550
+ result = agent.decide(
551
+ context: { amount: 150 },
552
+ ab_test_id: @ab_test.id,
553
+ user_id: "user_1"
554
+ )
555
+
556
+ expect(result[:decision]).to eq("approve")
557
+ expect(result[:ab_test]).not_to be_nil
558
+ expect(result[:ab_test][:test_id]).to eq(@ab_test.id)
559
+ end
560
+
561
+ it "builds real JsonRuleEvaluator from version content" do
562
+ agent = described_class.new(
563
+ ab_test_manager: real_ab_test_manager,
564
+ version_manager: real_version_manager
565
+ )
566
+
567
+ # Test with amount that matches champion (100 < amount < 200)
568
+ result = agent.decide(
569
+ context: { amount: 150 },
570
+ ab_test_id: @ab_test.id,
571
+ user_id: "user_1"
572
+ )
573
+
574
+ expect(result[:decision]).to eq("approve")
575
+ expect(result[:confidence]).to be > 0
576
+ end
577
+
578
+ it "handles variant assignment with real version manager" do
579
+ agent = described_class.new(
580
+ ab_test_manager: real_ab_test_manager,
581
+ version_manager: real_version_manager
582
+ )
583
+
584
+ # Make multiple decisions to test variant assignment
585
+ # Use amount 250 which will match both champion (> 100) and challenger (> 200) rules
586
+ results = []
587
+ 10.times do |i|
588
+ result = agent.decide(
589
+ context: { amount: 250 },
590
+ ab_test_id: @ab_test.id,
591
+ user_id: "user_#{i}"
592
+ )
593
+ results << result
594
+ end
595
+
596
+ # At least one decision should have been made
597
+ expect(results.size).to eq(10)
598
+ # All should have ab_test information
599
+ expect(results.all? { |r| r[:ab_test] }).to be true
600
+ end
601
+
602
+ it "records decisions with real ab_test_manager" do
603
+ agent = described_class.new(
604
+ ab_test_manager: real_ab_test_manager,
605
+ version_manager: real_version_manager
606
+ )
607
+
608
+ result = agent.decide(
609
+ context: { amount: 150 },
610
+ ab_test_id: @ab_test.id,
611
+ user_id: "user_1"
612
+ )
613
+
614
+ # Verify decision was recorded (check that results are available)
615
+ expect(result[:decision]).to eq("approve")
616
+ expect(result[:ab_test]).not_to be_nil
617
+ end
618
+
619
+ it "handles version with evaluators configuration" do
620
+ version_with_evaluators = real_version_manager.save_version(
621
+ rule_id: "test_evaluator_rule",
622
+ rule_content: {
623
+ evaluators: [{
624
+ type: "static",
625
+ decision: "approve",
626
+ weight: 0.8,
627
+ reason: "Static evaluator from version"
628
+ }]
629
+ },
630
+ created_by: "spec",
631
+ changelog: "Version with evaluators"
632
+ )
633
+
634
+ static_test = real_ab_test_manager.create_test(
635
+ name: "Static Evaluator Test",
636
+ champion_version_id: @champion_version[:id],
637
+ challenger_version_id: version_with_evaluators[:id],
638
+ traffic_split: { champion: 50, challenger: 50 }
639
+ )
640
+
641
+ agent = described_class.new(
642
+ ab_test_manager: real_ab_test_manager,
643
+ version_manager: real_version_manager
644
+ )
645
+
646
+ result = agent.decide(
647
+ context: { amount: 50 }, # Low amount that won't match champion rule
648
+ ab_test_id: static_test.id,
649
+ user_id: "user_1"
650
+ )
651
+
652
+ expect(result[:decision]).to eq("approve")
653
+ end
654
+ end
481
655
  end