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.
- checksums.yaml +4 -4
- data/README.md +41 -1
- data/bin/decision_agent +104 -0
- data/lib/decision_agent/dmn/adapter.rb +135 -0
- data/lib/decision_agent/dmn/cache.rb +306 -0
- data/lib/decision_agent/dmn/decision_graph.rb +327 -0
- data/lib/decision_agent/dmn/decision_tree.rb +192 -0
- data/lib/decision_agent/dmn/errors.rb +30 -0
- data/lib/decision_agent/dmn/exporter.rb +217 -0
- data/lib/decision_agent/dmn/feel/evaluator.rb +797 -0
- data/lib/decision_agent/dmn/feel/functions.rb +420 -0
- data/lib/decision_agent/dmn/feel/parser.rb +349 -0
- data/lib/decision_agent/dmn/feel/simple_parser.rb +276 -0
- data/lib/decision_agent/dmn/feel/transformer.rb +372 -0
- data/lib/decision_agent/dmn/feel/types.rb +276 -0
- data/lib/decision_agent/dmn/importer.rb +77 -0
- data/lib/decision_agent/dmn/model.rb +197 -0
- data/lib/decision_agent/dmn/parser.rb +191 -0
- data/lib/decision_agent/dmn/testing.rb +333 -0
- data/lib/decision_agent/dmn/validator.rb +315 -0
- data/lib/decision_agent/dmn/versioning.rb +229 -0
- data/lib/decision_agent/dmn/visualizer.rb +513 -0
- data/lib/decision_agent/dsl/condition_evaluator.rb +3 -0
- data/lib/decision_agent/dsl/schema_validator.rb +2 -1
- data/lib/decision_agent/evaluators/dmn_evaluator.rb +221 -0
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/web/dmn_editor.rb +426 -0
- data/lib/decision_agent/web/public/dmn-editor.css +596 -0
- data/lib/decision_agent/web/public/dmn-editor.html +250 -0
- data/lib/decision_agent/web/public/dmn-editor.js +553 -0
- data/lib/decision_agent/web/public/index.html +3 -0
- data/lib/decision_agent/web/public/styles.css +21 -0
- data/lib/decision_agent/web/server.rb +465 -0
- data/spec/ab_testing/ab_testing_agent_spec.rb +174 -0
- data/spec/auth/rbac_adapter_spec.rb +228 -0
- data/spec/dmn/decision_graph_spec.rb +282 -0
- data/spec/dmn/decision_tree_spec.rb +203 -0
- data/spec/dmn/feel/errors_spec.rb +18 -0
- data/spec/dmn/feel/functions_spec.rb +400 -0
- data/spec/dmn/feel/simple_parser_spec.rb +274 -0
- data/spec/dmn/feel/types_spec.rb +176 -0
- data/spec/dmn/feel_parser_spec.rb +489 -0
- data/spec/dmn/hit_policy_spec.rb +202 -0
- data/spec/dmn/integration_spec.rb +226 -0
- data/spec/examples.txt +1846 -1570
- data/spec/fixtures/dmn/complex_decision.dmn +81 -0
- data/spec/fixtures/dmn/invalid_structure.dmn +31 -0
- data/spec/fixtures/dmn/simple_decision.dmn +40 -0
- data/spec/monitoring/metrics_collector_spec.rb +37 -35
- data/spec/monitoring/monitored_agent_spec.rb +14 -11
- data/spec/performance_optimizations_spec.rb +10 -3
- data/spec/thread_safety_spec.rb +10 -2
- data/spec/web_ui_rack_spec.rb +294 -0
- 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
|