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
|
@@ -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("&", "&")
|
|
193
|
+
.gsub("<", "<")
|
|
194
|
+
.gsub(">", ">")
|
|
195
|
+
.gsub('"', """)
|
|
196
|
+
.gsub("'", "'")
|
|
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("&", "&")
|
|
403
|
+
.gsub("<", "<")
|
|
404
|
+
.gsub(">", ">")
|
|
405
|
+
.gsub('"', """)
|
|
406
|
+
.gsub("'", "'")
|
|
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('"', """)
|
|
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('"', """)
|
|
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
|
-
|
|
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]
|