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,221 @@
|
|
|
1
|
+
require_relative "../dmn/adapter"
|
|
2
|
+
require_relative "../dmn/errors"
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "json_rule_evaluator"
|
|
5
|
+
|
|
6
|
+
module DecisionAgent
|
|
7
|
+
module Evaluators
|
|
8
|
+
# Evaluates DMN decision models
|
|
9
|
+
class DmnEvaluator < Base
|
|
10
|
+
attr_reader :model, :decision_id
|
|
11
|
+
|
|
12
|
+
def initialize(model:, decision_id:, name: nil)
|
|
13
|
+
@model = model
|
|
14
|
+
@decision_id = decision_id.to_s
|
|
15
|
+
@name = name || "DmnEvaluator(#{@decision_id})"
|
|
16
|
+
|
|
17
|
+
# Find and validate decision
|
|
18
|
+
@decision = @model.find_decision(@decision_id)
|
|
19
|
+
raise Dmn::InvalidDmnModelError, "Decision '#{@decision_id}' not found" unless @decision
|
|
20
|
+
raise Dmn::InvalidDmnModelError, "Decision '#{@decision_id}' has no decision table" unless @decision.decision_table
|
|
21
|
+
|
|
22
|
+
# Convert to JSON rules for execution
|
|
23
|
+
adapter = Dmn::Adapter.new(@decision.decision_table)
|
|
24
|
+
@rules_json = adapter.to_json_rules
|
|
25
|
+
|
|
26
|
+
# Create internal JSON rule evaluator
|
|
27
|
+
@json_evaluator = JsonRuleEvaluator.new(
|
|
28
|
+
rules_json: @rules_json,
|
|
29
|
+
name: @name
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Freeze for thread safety
|
|
33
|
+
@model.freeze
|
|
34
|
+
@decision_id.freeze
|
|
35
|
+
@name.freeze
|
|
36
|
+
freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def evaluate(context, feedback: {})
|
|
40
|
+
hit_policy = @decision.decision_table.hit_policy
|
|
41
|
+
|
|
42
|
+
# Short-circuit for FIRST and PRIORITY policies
|
|
43
|
+
if %w[FIRST PRIORITY].include?(hit_policy)
|
|
44
|
+
first_match = find_first_matching_evaluation(context, feedback: feedback)
|
|
45
|
+
return first_match if first_match
|
|
46
|
+
|
|
47
|
+
# If no match found, return nil (consistent with apply_first_policy behavior)
|
|
48
|
+
return nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# For UNIQUE, ANY, COLLECT - need all matches
|
|
52
|
+
matching_evaluations = find_all_matching_evaluations(context, feedback: feedback)
|
|
53
|
+
|
|
54
|
+
# Apply hit policy to select the appropriate evaluation
|
|
55
|
+
apply_hit_policy(matching_evaluations)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def evaluator_name
|
|
61
|
+
@name
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Find first matching rule (for short-circuiting)
|
|
65
|
+
def find_first_matching_evaluation(context, feedback: {})
|
|
66
|
+
ctx = context.is_a?(Context) ? context : Context.new(context)
|
|
67
|
+
rules = @rules_json["rules"] || []
|
|
68
|
+
|
|
69
|
+
rules.each do |rule|
|
|
70
|
+
if_clause = rule["if"]
|
|
71
|
+
next unless if_clause
|
|
72
|
+
|
|
73
|
+
next unless Dsl::ConditionEvaluator.evaluate(if_clause, ctx)
|
|
74
|
+
|
|
75
|
+
then_clause = rule["then"]
|
|
76
|
+
return Evaluation.new(
|
|
77
|
+
decision: then_clause["decision"],
|
|
78
|
+
weight: then_clause["weight"] || 1.0,
|
|
79
|
+
reason: then_clause["reason"] || "Rule matched",
|
|
80
|
+
evaluator_name: @name,
|
|
81
|
+
metadata: {
|
|
82
|
+
type: "dmn_rule",
|
|
83
|
+
rule_id: rule["id"],
|
|
84
|
+
ruleset: @rules_json["ruleset"],
|
|
85
|
+
hit_policy: @decision.decision_table.hit_policy
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Find all matching rules (not just first)
|
|
94
|
+
def find_all_matching_evaluations(context, feedback: {})
|
|
95
|
+
ctx = context.is_a?(Context) ? context : Context.new(context)
|
|
96
|
+
rules = @rules_json["rules"] || []
|
|
97
|
+
matching = []
|
|
98
|
+
|
|
99
|
+
rules.each do |rule|
|
|
100
|
+
if_clause = rule["if"]
|
|
101
|
+
next unless if_clause
|
|
102
|
+
|
|
103
|
+
next unless Dsl::ConditionEvaluator.evaluate(if_clause, ctx)
|
|
104
|
+
|
|
105
|
+
then_clause = rule["then"]
|
|
106
|
+
matching << Evaluation.new(
|
|
107
|
+
decision: then_clause["decision"],
|
|
108
|
+
weight: then_clause["weight"] || 1.0,
|
|
109
|
+
reason: then_clause["reason"] || "Rule matched",
|
|
110
|
+
evaluator_name: @name,
|
|
111
|
+
metadata: {
|
|
112
|
+
type: "dmn_rule",
|
|
113
|
+
rule_id: rule["id"],
|
|
114
|
+
ruleset: @rules_json["ruleset"],
|
|
115
|
+
hit_policy: @decision.decision_table.hit_policy
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
matching
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Apply hit policy to matching evaluations
|
|
124
|
+
def apply_hit_policy(matching_evaluations)
|
|
125
|
+
hit_policy = @decision.decision_table.hit_policy
|
|
126
|
+
|
|
127
|
+
case hit_policy
|
|
128
|
+
when "UNIQUE"
|
|
129
|
+
apply_unique_policy(matching_evaluations)
|
|
130
|
+
when "FIRST"
|
|
131
|
+
apply_first_policy(matching_evaluations)
|
|
132
|
+
when "PRIORITY"
|
|
133
|
+
apply_priority_policy(matching_evaluations)
|
|
134
|
+
when "ANY"
|
|
135
|
+
apply_any_policy(matching_evaluations)
|
|
136
|
+
when "COLLECT"
|
|
137
|
+
apply_collect_policy(matching_evaluations)
|
|
138
|
+
else
|
|
139
|
+
# Default to FIRST if unknown policy
|
|
140
|
+
apply_first_policy(matching_evaluations)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# UNIQUE: Exactly one rule must match
|
|
145
|
+
def apply_unique_policy(matching_evaluations)
|
|
146
|
+
case matching_evaluations.size
|
|
147
|
+
when 0
|
|
148
|
+
raise Dmn::InvalidDmnModelError,
|
|
149
|
+
"UNIQUE hit policy requires exactly one matching rule, but none matched"
|
|
150
|
+
when 1
|
|
151
|
+
matching_evaluations.first
|
|
152
|
+
else
|
|
153
|
+
rule_ids = matching_evaluations.map { |e| e.metadata[:rule_id] }.join(", ")
|
|
154
|
+
raise Dmn::InvalidDmnModelError,
|
|
155
|
+
"UNIQUE hit policy requires exactly one matching rule, but #{matching_evaluations.size} matched: #{rule_ids}"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# FIRST: Return first matching rule (already in order)
|
|
160
|
+
def apply_first_policy(matching_evaluations)
|
|
161
|
+
return nil if matching_evaluations.empty?
|
|
162
|
+
|
|
163
|
+
matching_evaluations.first
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# PRIORITY: Return rule with highest priority
|
|
167
|
+
# For now, we use rule order as priority (first rule = highest priority)
|
|
168
|
+
# In full DMN spec, outputs can have priority values defined
|
|
169
|
+
def apply_priority_policy(matching_evaluations)
|
|
170
|
+
return nil if matching_evaluations.empty?
|
|
171
|
+
|
|
172
|
+
# For now, return first match (rules are already in priority order)
|
|
173
|
+
# Future enhancement: check output priority values if defined
|
|
174
|
+
matching_evaluations.first
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# ANY: All matching rules must have same output
|
|
178
|
+
def apply_any_policy(matching_evaluations)
|
|
179
|
+
return nil if matching_evaluations.empty?
|
|
180
|
+
|
|
181
|
+
# Check that all decisions are the same
|
|
182
|
+
first_decision = matching_evaluations.first.decision
|
|
183
|
+
all_same = matching_evaluations.all? { |e| e.decision == first_decision }
|
|
184
|
+
|
|
185
|
+
unless all_same
|
|
186
|
+
decisions = matching_evaluations.map(&:decision).uniq.join(", ")
|
|
187
|
+
rule_ids = matching_evaluations.map { |e| e.metadata[:rule_id] }.join(", ")
|
|
188
|
+
raise Dmn::InvalidDmnModelError,
|
|
189
|
+
"ANY hit policy requires all matching rules to have the same output, " \
|
|
190
|
+
"but found different outputs: #{decisions} (rules: #{rule_ids})"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
matching_evaluations.first
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# COLLECT: Return all matching rules
|
|
197
|
+
# Since Evaluation expects a single decision, we'll return the first one
|
|
198
|
+
# but include metadata about all matches
|
|
199
|
+
def apply_collect_policy(matching_evaluations)
|
|
200
|
+
return nil if matching_evaluations.empty?
|
|
201
|
+
|
|
202
|
+
# Return first evaluation but include all matches in metadata
|
|
203
|
+
first = matching_evaluations.first
|
|
204
|
+
all_decisions = matching_evaluations.map(&:decision)
|
|
205
|
+
all_rule_ids = matching_evaluations.map { |e| e.metadata[:rule_id] }
|
|
206
|
+
|
|
207
|
+
Evaluation.new(
|
|
208
|
+
decision: first.decision,
|
|
209
|
+
weight: first.weight,
|
|
210
|
+
reason: "COLLECT: #{matching_evaluations.size} rules matched",
|
|
211
|
+
evaluator_name: @name,
|
|
212
|
+
metadata: first.metadata.merge(
|
|
213
|
+
collect_count: matching_evaluations.size,
|
|
214
|
+
collect_decisions: all_decisions,
|
|
215
|
+
collect_rule_ids: all_rule_ids
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -3,7 +3,7 @@ module DecisionAgent
|
|
|
3
3
|
# MAJOR: Incremented for incompatible API changes
|
|
4
4
|
# MINOR: Incremented for backward-compatible functionality additions
|
|
5
5
|
# PATCH: Incremented for backward-compatible bug fixes
|
|
6
|
-
VERSION = "0.
|
|
6
|
+
VERSION = "0.3.0".freeze
|
|
7
7
|
|
|
8
8
|
# Validate version format (semantic versioning)
|
|
9
9
|
unless VERSION.match?(/\A\d+\.\d+\.\d+(-[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*)?(\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*)?\z/)
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../dmn/parser"
|
|
5
|
+
require_relative "../dmn/exporter"
|
|
6
|
+
require_relative "../dmn/importer"
|
|
7
|
+
require_relative "../dmn/validator"
|
|
8
|
+
require_relative "../dmn/model"
|
|
9
|
+
require_relative "../dmn/decision_tree"
|
|
10
|
+
require_relative "../dmn/decision_graph"
|
|
11
|
+
require_relative "../dmn/visualizer"
|
|
12
|
+
|
|
13
|
+
module DecisionAgent
|
|
14
|
+
module Web
|
|
15
|
+
# DMN Editor Backend
|
|
16
|
+
# Provides API endpoints for visual DMN modeling
|
|
17
|
+
class DmnEditor
|
|
18
|
+
attr_reader :storage
|
|
19
|
+
|
|
20
|
+
def initialize(storage: nil)
|
|
21
|
+
@storage = storage || {}
|
|
22
|
+
@storage_mutex = Mutex.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Create a new DMN model
|
|
26
|
+
def create_model(name:, namespace: nil)
|
|
27
|
+
model_id = generate_id
|
|
28
|
+
namespace ||= "http://decisonagent.com/dmn/#{model_id}"
|
|
29
|
+
|
|
30
|
+
model = Dmn::Model.new(
|
|
31
|
+
id: model_id,
|
|
32
|
+
name: name,
|
|
33
|
+
namespace: namespace
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
store_model(model_id, model)
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
id: model_id,
|
|
40
|
+
name: name,
|
|
41
|
+
namespace: namespace,
|
|
42
|
+
decisions: [],
|
|
43
|
+
created_at: Time.now.utc.iso8601
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Get a DMN model
|
|
48
|
+
def get_model(model_id)
|
|
49
|
+
model = retrieve_model(model_id)
|
|
50
|
+
return nil unless model
|
|
51
|
+
|
|
52
|
+
serialize_model(model)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Update DMN model metadata
|
|
56
|
+
def update_model(model_id, name: nil, namespace: nil)
|
|
57
|
+
model = retrieve_model(model_id)
|
|
58
|
+
return nil unless model
|
|
59
|
+
|
|
60
|
+
model.instance_variable_set(:@name, name) if name
|
|
61
|
+
model.instance_variable_set(:@namespace, namespace) if namespace
|
|
62
|
+
|
|
63
|
+
store_model(model_id, model)
|
|
64
|
+
serialize_model(model)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Delete a DMN model
|
|
68
|
+
# rubocop:disable Naming/PredicateMethod
|
|
69
|
+
def delete_model(model_id)
|
|
70
|
+
@storage_mutex.synchronize do
|
|
71
|
+
@storage.delete(model_id)
|
|
72
|
+
end
|
|
73
|
+
true
|
|
74
|
+
end
|
|
75
|
+
# rubocop:enable Naming/PredicateMethod
|
|
76
|
+
|
|
77
|
+
# Add a decision to a model
|
|
78
|
+
def add_decision(model_id:, decision_id:, name:, type: "decision_table")
|
|
79
|
+
model = retrieve_model(model_id)
|
|
80
|
+
return nil unless model
|
|
81
|
+
|
|
82
|
+
decision = Dmn::Decision.new(
|
|
83
|
+
id: decision_id,
|
|
84
|
+
name: name
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Initialize decision logic based on type
|
|
88
|
+
case type
|
|
89
|
+
when "decision_table"
|
|
90
|
+
decision.instance_variable_set(:@decision_table, Dmn::DecisionTable.new(
|
|
91
|
+
id: "#{decision_id}_table",
|
|
92
|
+
hit_policy: "FIRST"
|
|
93
|
+
))
|
|
94
|
+
when "decision_tree"
|
|
95
|
+
decision.instance_variable_set(:@decision_tree, Dmn::DecisionTree.new(
|
|
96
|
+
id: "#{decision_id}_tree",
|
|
97
|
+
name: name
|
|
98
|
+
))
|
|
99
|
+
when "literal"
|
|
100
|
+
decision.instance_variable_set(:@literal_expression, "")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
model.add_decision(decision)
|
|
104
|
+
store_model(model_id, model)
|
|
105
|
+
|
|
106
|
+
serialize_decision(decision)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Update a decision
|
|
110
|
+
def update_decision(model_id:, decision_id:, name: nil, logic: nil)
|
|
111
|
+
model = retrieve_model(model_id)
|
|
112
|
+
return nil unless model
|
|
113
|
+
|
|
114
|
+
decision = model.find_decision(decision_id)
|
|
115
|
+
return nil unless decision
|
|
116
|
+
|
|
117
|
+
decision.instance_variable_set(:@name, name) if name
|
|
118
|
+
|
|
119
|
+
update_decision_table(decision.decision_table, logic) if logic && decision.decision_table
|
|
120
|
+
|
|
121
|
+
store_model(model_id, model)
|
|
122
|
+
serialize_decision(decision)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Delete a decision
|
|
126
|
+
# rubocop:disable Naming/PredicateMethod
|
|
127
|
+
def delete_decision(model_id:, decision_id:)
|
|
128
|
+
model = retrieve_model(model_id)
|
|
129
|
+
return false unless model
|
|
130
|
+
|
|
131
|
+
model.decisions.reject! { |d| d.id == decision_id }
|
|
132
|
+
store_model(model_id, model)
|
|
133
|
+
true
|
|
134
|
+
end
|
|
135
|
+
# rubocop:enable Naming/PredicateMethod
|
|
136
|
+
|
|
137
|
+
# Add input to decision table
|
|
138
|
+
def add_input(model_id:, decision_id:, input_id:, label:, type_ref: nil, expression: nil)
|
|
139
|
+
model = retrieve_model(model_id)
|
|
140
|
+
return nil unless model
|
|
141
|
+
|
|
142
|
+
decision = model.find_decision(decision_id)
|
|
143
|
+
return nil unless decision&.decision_table
|
|
144
|
+
|
|
145
|
+
input = Dmn::Input.new(
|
|
146
|
+
id: input_id,
|
|
147
|
+
label: label,
|
|
148
|
+
type_ref: type_ref,
|
|
149
|
+
expression: expression
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
decision.decision_table.inputs << input
|
|
153
|
+
store_model(model_id, model)
|
|
154
|
+
|
|
155
|
+
serialize_input(input)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Add output to decision table
|
|
159
|
+
def add_output(model_id:, decision_id:, output_id:, label:, type_ref: nil, name: nil)
|
|
160
|
+
model = retrieve_model(model_id)
|
|
161
|
+
return nil unless model
|
|
162
|
+
|
|
163
|
+
decision = model.find_decision(decision_id)
|
|
164
|
+
return nil unless decision&.decision_table
|
|
165
|
+
|
|
166
|
+
output = Dmn::Output.new(
|
|
167
|
+
id: output_id,
|
|
168
|
+
label: label,
|
|
169
|
+
type_ref: type_ref,
|
|
170
|
+
name: name
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
decision.decision_table.outputs << output
|
|
174
|
+
store_model(model_id, model)
|
|
175
|
+
|
|
176
|
+
serialize_output(output)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Add rule to decision table
|
|
180
|
+
def add_rule(model_id:, decision_id:, rule_id:, input_entries:, output_entries:, description: nil)
|
|
181
|
+
model = retrieve_model(model_id)
|
|
182
|
+
return nil unless model
|
|
183
|
+
|
|
184
|
+
decision = model.find_decision(decision_id)
|
|
185
|
+
return nil unless decision&.decision_table
|
|
186
|
+
|
|
187
|
+
rule = Dmn::Rule.new(id: rule_id)
|
|
188
|
+
rule.instance_variable_set(:@input_entries, input_entries)
|
|
189
|
+
rule.instance_variable_set(:@output_entries, output_entries)
|
|
190
|
+
rule.instance_variable_set(:@description, description) if description
|
|
191
|
+
|
|
192
|
+
decision.decision_table.rules << rule
|
|
193
|
+
store_model(model_id, model)
|
|
194
|
+
|
|
195
|
+
serialize_rule(rule)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Update rule
|
|
199
|
+
def update_rule(model_id:, decision_id:, rule_id:, input_entries: nil, output_entries: nil, description: nil)
|
|
200
|
+
model = retrieve_model(model_id)
|
|
201
|
+
return nil unless model
|
|
202
|
+
|
|
203
|
+
decision = model.find_decision(decision_id)
|
|
204
|
+
return nil unless decision&.decision_table
|
|
205
|
+
|
|
206
|
+
rule = decision.decision_table.rules.find { |r| r.id == rule_id }
|
|
207
|
+
return nil unless rule
|
|
208
|
+
|
|
209
|
+
rule.instance_variable_set(:@input_entries, input_entries) if input_entries
|
|
210
|
+
rule.instance_variable_set(:@output_entries, output_entries) if output_entries
|
|
211
|
+
rule.instance_variable_set(:@description, description) if description
|
|
212
|
+
|
|
213
|
+
store_model(model_id, model)
|
|
214
|
+
serialize_rule(rule)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Delete rule
|
|
218
|
+
# rubocop:disable Naming/PredicateMethod
|
|
219
|
+
def delete_rule(model_id:, decision_id:, rule_id:)
|
|
220
|
+
model = retrieve_model(model_id)
|
|
221
|
+
return false unless model
|
|
222
|
+
|
|
223
|
+
decision = model.find_decision(decision_id)
|
|
224
|
+
return false unless decision&.decision_table
|
|
225
|
+
|
|
226
|
+
decision.decision_table.rules.reject! { |r| r.id == rule_id }
|
|
227
|
+
store_model(model_id, model)
|
|
228
|
+
true
|
|
229
|
+
end
|
|
230
|
+
# rubocop:enable Naming/PredicateMethod
|
|
231
|
+
|
|
232
|
+
# Validate a DMN model
|
|
233
|
+
def validate_model(model_id)
|
|
234
|
+
model = retrieve_model(model_id)
|
|
235
|
+
return { valid: false, errors: ["Model not found"] } unless model
|
|
236
|
+
|
|
237
|
+
validator = Dmn::Validator.new
|
|
238
|
+
validator.validate(model)
|
|
239
|
+
|
|
240
|
+
{
|
|
241
|
+
valid: validator.valid?,
|
|
242
|
+
errors: validator.errors,
|
|
243
|
+
warnings: validator.warnings
|
|
244
|
+
}
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Export DMN model to XML
|
|
248
|
+
def export_to_xml(model_id)
|
|
249
|
+
model = retrieve_model(model_id)
|
|
250
|
+
return nil unless model
|
|
251
|
+
|
|
252
|
+
exporter = Dmn::Exporter.new
|
|
253
|
+
exporter.export(model)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Import DMN model from XML
|
|
257
|
+
def import_from_xml(xml_content, name: nil)
|
|
258
|
+
parser = Dmn::Parser.new
|
|
259
|
+
model = parser.parse(xml_content)
|
|
260
|
+
|
|
261
|
+
# Generate new ID for imported model
|
|
262
|
+
model_id = generate_id
|
|
263
|
+
model.instance_variable_set(:@id, model_id)
|
|
264
|
+
model.instance_variable_set(:@name, name) if name
|
|
265
|
+
|
|
266
|
+
store_model(model_id, model)
|
|
267
|
+
|
|
268
|
+
serialize_model(model)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Generate visualization for decision tree
|
|
272
|
+
def visualize_tree(model_id:, decision_id:, format: "svg")
|
|
273
|
+
model = retrieve_model(model_id)
|
|
274
|
+
return nil unless model
|
|
275
|
+
|
|
276
|
+
decision = model.find_decision(decision_id)
|
|
277
|
+
return nil unless decision || !decision.decision_tree
|
|
278
|
+
|
|
279
|
+
case format.to_s.downcase
|
|
280
|
+
when "svg"
|
|
281
|
+
Dmn::Visualizer.tree_to_svg(decision.decision_tree)
|
|
282
|
+
when "dot"
|
|
283
|
+
Dmn::Visualizer.tree_to_dot(decision.decision_tree)
|
|
284
|
+
when "mermaid"
|
|
285
|
+
Dmn::Visualizer.tree_to_mermaid(decision.decision_tree)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Generate visualization for decision graph
|
|
290
|
+
def visualize_graph(model_id:, format: "svg")
|
|
291
|
+
model = retrieve_model(model_id)
|
|
292
|
+
return nil unless model
|
|
293
|
+
|
|
294
|
+
# Convert model to decision graph
|
|
295
|
+
graph = Dmn::DecisionGraph.new(id: model.id, name: model.name)
|
|
296
|
+
model.decisions.each do |decision|
|
|
297
|
+
node = Dmn::DecisionNode.new(
|
|
298
|
+
id: decision.id,
|
|
299
|
+
name: decision.name,
|
|
300
|
+
decision_logic: decision.decision_table || decision.decision_tree
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# Add dependencies from information requirements
|
|
304
|
+
decision.information_requirements.each do |req|
|
|
305
|
+
node.add_dependency(req[:decision_id], req[:variable_name])
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
graph.add_decision(node)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
case format.to_s.downcase
|
|
312
|
+
when "svg"
|
|
313
|
+
Dmn::Visualizer.graph_to_svg(graph)
|
|
314
|
+
when "dot"
|
|
315
|
+
Dmn::Visualizer.graph_to_dot(graph)
|
|
316
|
+
when "mermaid"
|
|
317
|
+
Dmn::Visualizer.graph_to_mermaid(graph)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# List all models
|
|
322
|
+
def list_models
|
|
323
|
+
@storage_mutex.synchronize do
|
|
324
|
+
@storage.map do |id, model|
|
|
325
|
+
{
|
|
326
|
+
id: id,
|
|
327
|
+
name: model.name,
|
|
328
|
+
namespace: model.namespace,
|
|
329
|
+
decision_count: model.decisions.size
|
|
330
|
+
}
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
private
|
|
336
|
+
|
|
337
|
+
def generate_id
|
|
338
|
+
"dmn_#{Time.now.to_i}_#{rand(10_000)}"
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def store_model(model_id, model)
|
|
342
|
+
@storage_mutex.synchronize do
|
|
343
|
+
@storage[model_id] = model
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def retrieve_model(model_id)
|
|
348
|
+
@storage_mutex.synchronize do
|
|
349
|
+
@storage[model_id]
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def update_decision_table(table, logic)
|
|
354
|
+
table.instance_variable_set(:@hit_policy, logic[:hit_policy]) if logic[:hit_policy]
|
|
355
|
+
table.instance_variable_set(:@inputs, logic[:inputs]) if logic[:inputs]
|
|
356
|
+
table.instance_variable_set(:@outputs, logic[:outputs]) if logic[:outputs]
|
|
357
|
+
table.instance_variable_set(:@rules, logic[:rules]) if logic[:rules]
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def serialize_model(model)
|
|
361
|
+
{
|
|
362
|
+
id: model.id,
|
|
363
|
+
name: model.name,
|
|
364
|
+
namespace: model.namespace,
|
|
365
|
+
decisions: model.decisions.map { |d| serialize_decision(d) }
|
|
366
|
+
}
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def serialize_decision(decision)
|
|
370
|
+
result = {
|
|
371
|
+
id: decision.id,
|
|
372
|
+
name: decision.name
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if decision.decision_table
|
|
376
|
+
result[:decision_table] = serialize_decision_table(decision.decision_table)
|
|
377
|
+
elsif decision.decision_tree
|
|
378
|
+
result[:decision_tree] = decision.decision_tree.to_h
|
|
379
|
+
elsif decision.instance_variable_get(:@literal_expression)
|
|
380
|
+
result[:literal_expression] = decision.instance_variable_get(:@literal_expression)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
result[:information_requirements] = decision.information_requirements if decision.information_requirements.any?
|
|
384
|
+
|
|
385
|
+
result
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def serialize_decision_table(table)
|
|
389
|
+
{
|
|
390
|
+
id: table.id,
|
|
391
|
+
hit_policy: table.hit_policy,
|
|
392
|
+
inputs: table.inputs.map { |i| serialize_input(i) },
|
|
393
|
+
outputs: table.outputs.map { |o| serialize_output(o) },
|
|
394
|
+
rules: table.rules.map { |r| serialize_rule(r) }
|
|
395
|
+
}
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def serialize_input(input)
|
|
399
|
+
{
|
|
400
|
+
id: input.id,
|
|
401
|
+
label: input.label,
|
|
402
|
+
type_ref: input.type_ref,
|
|
403
|
+
expression: input.expression
|
|
404
|
+
}
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def serialize_output(output)
|
|
408
|
+
{
|
|
409
|
+
id: output.id,
|
|
410
|
+
label: output.label,
|
|
411
|
+
type_ref: output.type_ref,
|
|
412
|
+
name: output.name
|
|
413
|
+
}
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def serialize_rule(rule)
|
|
417
|
+
{
|
|
418
|
+
id: rule.id,
|
|
419
|
+
input_entries: rule.input_entries,
|
|
420
|
+
output_entries: rule.output_entries,
|
|
421
|
+
description: rule.description
|
|
422
|
+
}
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|