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
@@ -0,0 +1,513 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "decision_tree"
4
+ require_relative "decision_graph"
5
+
6
+ module DecisionAgent
7
+ module Dmn
8
+ # Generates visual representations of decision trees and graphs
9
+ class Visualizer
10
+ # Generate SVG representation of a decision tree
11
+ def self.tree_to_svg(decision_tree)
12
+ svg_generator = TreeSvgGenerator.new(decision_tree)
13
+ svg_generator.generate
14
+ end
15
+
16
+ # Generate DOT (Graphviz) representation of a decision tree
17
+ def self.tree_to_dot(decision_tree)
18
+ dot_generator = TreeDotGenerator.new(decision_tree)
19
+ dot_generator.generate
20
+ end
21
+
22
+ # Generate SVG representation of a decision graph
23
+ def self.graph_to_svg(decision_graph)
24
+ svg_generator = GraphSvgGenerator.new(decision_graph)
25
+ svg_generator.generate
26
+ end
27
+
28
+ # Generate DOT (Graphviz) representation of a decision graph
29
+ def self.graph_to_dot(decision_graph)
30
+ dot_generator = GraphDotGenerator.new(decision_graph)
31
+ dot_generator.generate
32
+ end
33
+
34
+ # Generate Mermaid diagram syntax for a decision tree
35
+ def self.tree_to_mermaid(decision_tree)
36
+ mermaid_generator = TreeMermaidGenerator.new(decision_tree)
37
+ mermaid_generator.generate
38
+ end
39
+
40
+ # Generate Mermaid diagram syntax for a decision graph
41
+ def self.graph_to_mermaid(decision_graph)
42
+ mermaid_generator = GraphMermaidGenerator.new(decision_graph)
43
+ mermaid_generator.generate
44
+ end
45
+ end
46
+
47
+ # Generates SVG for decision trees
48
+ class TreeSvgGenerator
49
+ NODE_WIDTH = 150
50
+ NODE_HEIGHT = 60
51
+ HORIZONTAL_SPACING = 40
52
+ VERTICAL_SPACING = 100
53
+
54
+ def initialize(decision_tree)
55
+ @tree = decision_tree
56
+ @positions = {}
57
+ end
58
+
59
+ def generate
60
+ calculate_positions(@tree.root, 0, 0)
61
+
62
+ width = (@positions.values.map { |p| p[:x] }.max || 0) + NODE_WIDTH + 40
63
+ height = (@positions.values.map { |p| p[:y] }.max || 0) + NODE_HEIGHT + 40
64
+
65
+ svg = [
66
+ %(<svg xmlns="http://www.w3.org/2000/svg" width="#{width}" height="#{height}" viewBox="0 0 #{width} #{height}">),
67
+ "<defs>",
68
+ ' <marker id="arrowhead" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">',
69
+ ' <polygon points="0 0, 10 3, 0 6" fill="#666" />',
70
+ " </marker>",
71
+ "</defs>",
72
+ "<g>"
73
+ ]
74
+
75
+ # Draw edges first (so they appear behind nodes)
76
+ svg.concat(generate_edges)
77
+
78
+ # Draw nodes
79
+ svg.concat(generate_nodes)
80
+
81
+ svg << "</g>"
82
+ svg << "</svg>"
83
+ svg.join("\n")
84
+ end
85
+
86
+ private
87
+
88
+ def calculate_positions(node, depth, offset)
89
+ @positions[node.id] = {
90
+ x: offset + (NODE_WIDTH / 2),
91
+ y: (depth * (NODE_HEIGHT + VERTICAL_SPACING)) + 20
92
+ }
93
+
94
+ return unless node.children.any?
95
+
96
+ calculate_subtree_width(node)
97
+ child_offset = offset
98
+
99
+ node.children.each do |child|
100
+ calculate_positions(child, depth + 1, child_offset)
101
+ child_offset += calculate_subtree_width(child) + HORIZONTAL_SPACING
102
+ end
103
+ end
104
+
105
+ def calculate_subtree_width(node)
106
+ return NODE_WIDTH if node.leaf?
107
+
108
+ total_width = 0
109
+ node.children.each do |child|
110
+ total_width += calculate_subtree_width(child) + HORIZONTAL_SPACING
111
+ end
112
+ total_width - HORIZONTAL_SPACING
113
+ end
114
+
115
+ def generate_nodes
116
+ nodes = []
117
+ @positions.each do |node_id, pos|
118
+ node = find_node(@tree.root, node_id)
119
+ next unless node
120
+
121
+ x = pos[:x] - (NODE_WIDTH / 2)
122
+ y = pos[:y]
123
+
124
+ # Node background
125
+ color = node.leaf? ? "#e8f5e9" : "#e3f2fd"
126
+ nodes << %(<rect x="#{x}" y="#{y}" width="#{NODE_WIDTH}" height="#{NODE_HEIGHT}" )
127
+ nodes << %(fill="#{color}" stroke="#666" stroke-width="2" rx="5"/>)
128
+
129
+ # Node label
130
+ label = node.label || node.id
131
+ label = truncate(label, 20)
132
+ nodes << %(<text x="#{pos[:x]}" y="#{y + 25}" text-anchor="middle" )
133
+ nodes << %(font-family="Arial, sans-serif" font-size="12" font-weight="bold">#{escape_xml(label)}</text>)
134
+
135
+ # Node condition or decision
136
+ if node.condition
137
+ condition_text = truncate(node.condition, 18)
138
+ nodes << %(<text x="#{pos[:x]}" y="#{y + 45}" text-anchor="middle" )
139
+ nodes << %(font-family="Arial, sans-serif" font-size="10" fill="#666">#{escape_xml(condition_text)}</text>)
140
+ elsif node.decision
141
+ decision_text = truncate(node.decision.to_s, 18)
142
+ nodes << %(<text x="#{pos[:x]}" y="#{y + 45}" text-anchor="middle" )
143
+ nodes << %(font-family="Arial, sans-serif" font-size="10" fill="#2e7d32">#{escape_xml(decision_text)}</text>)
144
+ end
145
+ end
146
+ nodes
147
+ end
148
+
149
+ def generate_edges
150
+ edges = []
151
+ generate_edges_recursive(@tree.root, edges)
152
+ edges
153
+ end
154
+
155
+ def generate_edges_recursive(node, edges)
156
+ return if node.leaf?
157
+
158
+ from_pos = @positions[node.id]
159
+ node.children.each do |child|
160
+ to_pos = @positions[child.id]
161
+
162
+ # Draw line from center bottom of parent to center top of child
163
+ x1 = from_pos[:x]
164
+ y1 = from_pos[:y] + NODE_HEIGHT
165
+ x2 = to_pos[:x]
166
+ y2 = to_pos[:y]
167
+
168
+ edges << %(<line x1="#{x1}" y1="#{y1}" x2="#{x2}" y2="#{y2}" )
169
+ edges << %(stroke="#666" stroke-width="2" marker-end="url(#arrowhead)"/>)
170
+
171
+ generate_edges_recursive(child, edges)
172
+ end
173
+ end
174
+
175
+ def find_node(current, node_id)
176
+ return current if current.id == node_id
177
+
178
+ current.children.each do |child|
179
+ found = find_node(child, node_id)
180
+ return found if found
181
+ end
182
+
183
+ nil
184
+ end
185
+
186
+ def truncate(text, max_length)
187
+ text.to_s.length > max_length ? "#{text.to_s[0...max_length]}..." : text.to_s
188
+ end
189
+
190
+ def escape_xml(text)
191
+ text.to_s
192
+ .gsub("&", "&amp;")
193
+ .gsub("<", "&lt;")
194
+ .gsub(">", "&gt;")
195
+ .gsub('"', "&quot;")
196
+ .gsub("'", "&apos;")
197
+ end
198
+ end
199
+
200
+ # Generates DOT format for decision trees (for Graphviz)
201
+ class TreeDotGenerator
202
+ def initialize(decision_tree)
203
+ @tree = decision_tree
204
+ end
205
+
206
+ def generate
207
+ dot = ["digraph decision_tree {"]
208
+ dot << " graph [rankdir=TB, splines=ortho];"
209
+ dot << " node [shape=box, style=rounded];"
210
+
211
+ generate_nodes(@tree.root, dot)
212
+ generate_edges(@tree.root, dot)
213
+
214
+ dot << "}"
215
+ dot.join("\n")
216
+ end
217
+
218
+ private
219
+
220
+ def generate_nodes(node, dot)
221
+ label = escape_dot(node.label || node.id)
222
+
223
+ if node.leaf?
224
+ decision = escape_dot(node.decision.to_s)
225
+ dot << %( "#{node.id}" [label="#{label}\\n→ #{decision}", fillcolor=lightgreen, style="rounded,filled"];)
226
+ else
227
+ condition = node.condition ? escape_dot(node.condition) : ""
228
+ dot << %( "#{node.id}" [label="#{label}\\n#{condition}", fillcolor=lightblue, style="rounded,filled"];)
229
+ end
230
+
231
+ node.children.each { |child| generate_nodes(child, dot) }
232
+ end
233
+
234
+ def generate_edges(node, dot)
235
+ node.children.each do |child|
236
+ dot << %( "#{node.id}" -> "#{child.id}";)
237
+ generate_edges(child, dot)
238
+ end
239
+ end
240
+
241
+ def escape_dot(text)
242
+ text.to_s.gsub('"', '\\"').gsub("\n", '\\n')
243
+ end
244
+ end
245
+
246
+ # Generates SVG for decision graphs
247
+ class GraphSvgGenerator
248
+ NODE_WIDTH = 180
249
+ NODE_HEIGHT = 70
250
+ HORIZONTAL_SPACING = 100
251
+ VERTICAL_SPACING = 120
252
+
253
+ def initialize(decision_graph)
254
+ @graph = decision_graph
255
+ @positions = {}
256
+ end
257
+
258
+ def generate
259
+ calculate_graph_layout
260
+
261
+ width = (@positions.values.map { |p| p[:x] }.max || 0) + NODE_WIDTH + 40
262
+ height = (@positions.values.map { |p| p[:y] }.max || 0) + NODE_HEIGHT + 40
263
+
264
+ svg = [
265
+ %(<svg xmlns="http://www.w3.org/2000/svg" width="#{width}" height="#{height}" viewBox="0 0 #{width} #{height}">),
266
+ "<defs>",
267
+ ' <marker id="arrowhead" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">',
268
+ ' <polygon points="0 0, 10 3, 0 6" fill="#666" />',
269
+ " </marker>",
270
+ "</defs>",
271
+ "<g>"
272
+ ]
273
+
274
+ # Draw edges
275
+ svg.concat(generate_edges)
276
+
277
+ # Draw nodes
278
+ svg.concat(generate_nodes)
279
+
280
+ svg << "</g>"
281
+ svg << "</svg>"
282
+ svg.join("\n")
283
+ end
284
+
285
+ private
286
+
287
+ def calculate_graph_layout
288
+ # Use topological sort to arrange nodes in layers
289
+ begin
290
+ order = @graph.topological_order
291
+ rescue StandardError
292
+ # If circular, just use the order as-is
293
+ order = @graph.decisions.keys
294
+ end
295
+
296
+ # Group nodes by layer (based on dependency depth)
297
+ layers = assign_layers(order)
298
+
299
+ # Position nodes
300
+ layers.each_with_index do |layer_nodes, layer_index|
301
+ layer_nodes.each_with_index do |node_id, node_index|
302
+ @positions[node_id] = {
303
+ x: (node_index * (NODE_WIDTH + HORIZONTAL_SPACING)) + 40,
304
+ y: (layer_index * (NODE_HEIGHT + VERTICAL_SPACING)) + 40
305
+ }
306
+ end
307
+ end
308
+ end
309
+
310
+ def assign_layers(order)
311
+ layers = {}
312
+
313
+ order.each do |decision_id|
314
+ decision = @graph.get_decision(decision_id)
315
+
316
+ # Find max layer of dependencies
317
+ max_dep_layer = -1
318
+ decision.information_requirements.each do |req|
319
+ dep_layer = layers[req[:decision_id]]
320
+ max_dep_layer = [max_dep_layer, dep_layer].max if dep_layer
321
+ end
322
+
323
+ layers[decision_id] = max_dep_layer + 1
324
+ end
325
+
326
+ # Group by layer
327
+ grouped = {}
328
+ layers.each do |decision_id, layer|
329
+ grouped[layer] ||= []
330
+ grouped[layer] << decision_id
331
+ end
332
+
333
+ grouped.sort.map { |_layer, nodes| nodes }
334
+ end
335
+
336
+ def generate_nodes
337
+ nodes = []
338
+
339
+ @graph.decisions.each do |decision_id, decision|
340
+ pos = @positions[decision_id]
341
+ next unless pos
342
+
343
+ x = pos[:x]
344
+ y = pos[:y]
345
+
346
+ # Node background
347
+ nodes << %(<rect x="#{x}" y="#{y}" width="#{NODE_WIDTH}" height="#{NODE_HEIGHT}" )
348
+ nodes << %(fill="#fff3e0" stroke="#e65100" stroke-width="2" rx="5"/>)
349
+
350
+ # Decision name
351
+ name = truncate(decision.name, 22)
352
+ nodes << %(<text x="#{x + (NODE_WIDTH / 2)}" y="#{y + 25}" text-anchor="middle" )
353
+ nodes << %(font-family="Arial, sans-serif" font-size="12" font-weight="bold">#{escape_xml(name)}</text>)
354
+
355
+ # Decision ID
356
+ nodes << %(<text x="#{x + (NODE_WIDTH / 2)}" y="#{y + 45}" text-anchor="middle" )
357
+ nodes << %(font-family="Arial, sans-serif" font-size="10" fill="#666">ID: #{escape_xml(decision_id)}</text>)
358
+ end
359
+
360
+ nodes
361
+ end
362
+
363
+ def generate_edges
364
+ edges = []
365
+
366
+ @graph.decisions.each do |decision_id, decision|
367
+ from_pos = @positions[decision_id]
368
+ next unless from_pos
369
+
370
+ decision.information_requirements.each do |req|
371
+ to_pos = @positions[req[:decision_id]]
372
+ next unless to_pos
373
+
374
+ # Draw arrow from dependency to this decision
375
+ x1 = to_pos[:x] + (NODE_WIDTH / 2)
376
+ y1 = to_pos[:y] + NODE_HEIGHT
377
+ x2 = from_pos[:x] + (NODE_WIDTH / 2)
378
+ y2 = from_pos[:y]
379
+
380
+ edges << %(<line x1="#{x1}" y1="#{y1}" x2="#{x2}" y2="#{y2}" )
381
+ edges << %(stroke="#666" stroke-width="2" marker-end="url(#arrowhead)"/>)
382
+
383
+ # Add label for variable name if specified
384
+ next unless req[:variable_name] && req[:variable_name] != req[:decision_id]
385
+
386
+ mid_x = (x1 + x2) / 2
387
+ mid_y = (y1 + y2) / 2
388
+ edges << %(<text x="#{mid_x}" y="#{mid_y}" text-anchor="middle" )
389
+ edges << %(font-family="Arial, sans-serif" font-size="10" fill="#e65100">#{escape_xml(req[:variable_name])}</text>)
390
+ end
391
+ end
392
+
393
+ edges
394
+ end
395
+
396
+ def truncate(text, max_length)
397
+ text.to_s.length > max_length ? "#{text.to_s[0...max_length]}..." : text.to_s
398
+ end
399
+
400
+ def escape_xml(text)
401
+ text.to_s
402
+ .gsub("&", "&amp;")
403
+ .gsub("<", "&lt;")
404
+ .gsub(">", "&gt;")
405
+ .gsub('"', "&quot;")
406
+ .gsub("'", "&apos;")
407
+ end
408
+ end
409
+
410
+ # Generates DOT format for decision graphs
411
+ class GraphDotGenerator
412
+ def initialize(decision_graph)
413
+ @graph = decision_graph
414
+ end
415
+
416
+ def generate
417
+ dot = ["digraph decision_graph {"]
418
+ dot << " graph [rankdir=TB];"
419
+ dot << " node [shape=box, style=rounded];"
420
+
421
+ @graph.decisions.each do |decision_id, decision|
422
+ label = escape_dot("#{decision.name}\\n(#{decision_id})")
423
+ dot << %( "#{decision_id}" [label="#{label}", fillcolor=lightyellow, style="rounded,filled"];)
424
+
425
+ decision.information_requirements.each do |req|
426
+ label = req[:variable_name] == req[:decision_id] ? "" : escape_dot(req[:variable_name])
427
+ label_attr = label.empty? ? "" : %( [label="#{label}"])
428
+ dot << %( "#{req[:decision_id]}" -> "#{decision_id}"#{label_attr};)
429
+ end
430
+ end
431
+
432
+ dot << "}"
433
+ dot.join("\n")
434
+ end
435
+
436
+ private
437
+
438
+ def escape_dot(text)
439
+ text.to_s.gsub('"', '\\"').gsub("\n", '\\n')
440
+ end
441
+ end
442
+
443
+ # Generates Mermaid diagram syntax for decision trees
444
+ class TreeMermaidGenerator
445
+ def initialize(decision_tree)
446
+ @tree = decision_tree
447
+ end
448
+
449
+ def generate
450
+ mermaid = ["graph TD"]
451
+ generate_nodes(@tree.root, mermaid)
452
+ generate_edges(@tree.root, mermaid)
453
+ mermaid.join("\n")
454
+ end
455
+
456
+ private
457
+
458
+ def generate_nodes(node, mermaid)
459
+ label = escape_mermaid(node.label || node.id)
460
+
461
+ if node.leaf?
462
+ decision = escape_mermaid(node.decision.to_s)
463
+ mermaid << %( #{node.id}["#{label}<br/>→ #{decision}"])
464
+ else
465
+ condition = node.condition ? escape_mermaid(node.condition) : ""
466
+ mermaid << %( #{node.id}["#{label}<br/>#{condition}"])
467
+ end
468
+
469
+ node.children.each { |child| generate_nodes(child, mermaid) }
470
+ end
471
+
472
+ def generate_edges(node, mermaid)
473
+ node.children.each do |child|
474
+ mermaid << %( #{node.id} --> #{child.id})
475
+ generate_edges(child, mermaid)
476
+ end
477
+ end
478
+
479
+ def escape_mermaid(text)
480
+ text.to_s.gsub('"', "&quot;")
481
+ end
482
+ end
483
+
484
+ # Generates Mermaid diagram syntax for decision graphs
485
+ class GraphMermaidGenerator
486
+ def initialize(decision_graph)
487
+ @graph = decision_graph
488
+ end
489
+
490
+ def generate
491
+ mermaid = ["graph TD"]
492
+
493
+ @graph.decisions.each do |decision_id, decision|
494
+ label = escape_mermaid(decision.name.to_s)
495
+ mermaid << %( #{decision_id}["#{label}"])
496
+
497
+ decision.information_requirements.each do |req|
498
+ label = req[:variable_name] == req[:decision_id] ? "" : "|#{escape_mermaid(req[:variable_name])}|"
499
+ mermaid << %( #{req[:decision_id]} -->#{label} #{decision_id})
500
+ end
501
+ end
502
+
503
+ mermaid.join("\n")
504
+ end
505
+
506
+ private
507
+
508
+ def escape_mermaid(text)
509
+ text.to_s.gsub('"', "&quot;")
510
+ end
511
+ end
512
+ end
513
+ end
@@ -63,6 +63,9 @@ module DecisionAgent
63
63
  op = condition["op"]
64
64
  expected_value = condition["value"]
65
65
 
66
+ # Special handling for "don't care" conditions (from DMN "-" entries)
67
+ return true if field == "__always_match__" && op == "eq" && expected_value == true
68
+
66
69
  context_hash = context.to_h
67
70
  actual_value = get_nested_value(context_hash, field)
68
71
 
@@ -256,7 +256,8 @@ module DecisionAgent
256
256
  # Validate decision
257
257
  decision = then_clause["decision"] || then_clause[:decision]
258
258
 
259
- @errors << "#{rule_path}.then: Missing required field 'decision'" unless decision
259
+ # Check if decision exists (including false and 0, but not nil)
260
+ @errors << "#{rule_path}.then: Missing required field 'decision'" if decision.nil?
260
261
 
261
262
  # Validate optional weight
262
263
  weight = then_clause["weight"] || then_clause[:weight]