decision_agent 0.1.7 → 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 +1132 -12
- data/lib/decision_agent/dsl/schema_validator.rb +12 -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/app.js +119 -1
- 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 +71 -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/advanced_operators_spec.rb +2147 -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 +1909 -0
- 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 +66 -1
|
@@ -7,7 +7,17 @@ module DecisionAgent
|
|
|
7
7
|
eq neq gt gte lt lte in present blank
|
|
8
8
|
contains starts_with ends_with matches
|
|
9
9
|
between modulo
|
|
10
|
+
sin cos tan sqrt power exp log
|
|
11
|
+
round floor ceil abs
|
|
12
|
+
min max sum average mean median stddev standard_deviation variance percentile count
|
|
10
13
|
before_date after_date within_days day_of_week
|
|
14
|
+
duration_seconds duration_minutes duration_hours duration_days
|
|
15
|
+
add_days subtract_days add_hours subtract_hours add_minutes subtract_minutes
|
|
16
|
+
hour_of_day day_of_month month year week_of_year
|
|
17
|
+
rate_per_second rate_per_minute rate_per_hour
|
|
18
|
+
moving_average moving_sum moving_max moving_min
|
|
19
|
+
compound_interest present_value future_value payment
|
|
20
|
+
join length
|
|
11
21
|
contains_all contains_any intersects subset_of
|
|
12
22
|
within_radius in_polygon
|
|
13
23
|
].freeze
|
|
@@ -246,7 +256,8 @@ module DecisionAgent
|
|
|
246
256
|
# Validate decision
|
|
247
257
|
decision = then_clause["decision"] || then_clause[:decision]
|
|
248
258
|
|
|
249
|
-
|
|
259
|
+
# Check if decision exists (including false and 0, but not nil)
|
|
260
|
+
@errors << "#{rule_path}.then: Missing required field 'decision'" if decision.nil?
|
|
250
261
|
|
|
251
262
|
# Validate optional weight
|
|
252
263
|
weight = then_clause["weight"] || then_clause[:weight]
|
|
@@ -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
|