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
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
require "nokogiri"
|
|
2
|
+
require "set"
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "../versioning/version_manager"
|
|
5
|
+
|
|
6
|
+
module DecisionAgent
|
|
7
|
+
module Dmn
|
|
8
|
+
# Exports DecisionAgent rules to DMN XML format
|
|
9
|
+
class Exporter
|
|
10
|
+
def initialize(version_manager: nil)
|
|
11
|
+
@version_manager = version_manager || Versioning::VersionManager.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Export ruleset to DMN XML
|
|
15
|
+
# @param rule_id [String] Rule ID to export
|
|
16
|
+
# @param output_path [String, nil] Optional file path to write
|
|
17
|
+
# @return [String] DMN XML content
|
|
18
|
+
def export(rule_id, output_path: nil)
|
|
19
|
+
# Get active version
|
|
20
|
+
version = @version_manager.get_active_version(rule_id: rule_id)
|
|
21
|
+
raise InvalidDmnModelError, "No active version found for '#{rule_id}'" unless version
|
|
22
|
+
|
|
23
|
+
# Convert JSON rules to DMN
|
|
24
|
+
dmn_xml = convert_to_dmn(version[:content], rule_id)
|
|
25
|
+
|
|
26
|
+
# Write to file if path provided
|
|
27
|
+
File.write(output_path, dmn_xml) if output_path
|
|
28
|
+
|
|
29
|
+
dmn_xml
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Helper to get hash value with both string and symbol key support
|
|
35
|
+
def hash_get(hash, key)
|
|
36
|
+
hash[key.to_s] || hash[key.to_sym]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# rubocop:disable Metrics/MethodLength
|
|
40
|
+
def convert_to_dmn(rules_json, rule_id)
|
|
41
|
+
# Handle both string and symbol keys
|
|
42
|
+
ruleset_name = rules_json["ruleset"] || rules_json[:ruleset] || rule_id
|
|
43
|
+
rules = rules_json["rules"] || rules_json[:rules] || []
|
|
44
|
+
|
|
45
|
+
builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
|
|
46
|
+
xml.definitions(
|
|
47
|
+
"xmlns" => "https://www.omg.org/spec/DMN/20191111/MODEL/",
|
|
48
|
+
"xmlns:dmndi" => "https://www.omg.org/spec/DMN/20191111/DMNDI/",
|
|
49
|
+
"xmlns:dc" => "http://www.omg.org/spec/DMN/20180521/DC/",
|
|
50
|
+
"id" => "definitions_#{rule_id}",
|
|
51
|
+
"name" => ruleset_name,
|
|
52
|
+
"namespace" => "http://decision_agent.local"
|
|
53
|
+
) do
|
|
54
|
+
xml.decision(id: rule_id, name: ruleset_name) do
|
|
55
|
+
xml.decisionTable(
|
|
56
|
+
id: "#{rule_id}_table",
|
|
57
|
+
hitPolicy: "FIRST",
|
|
58
|
+
outputLabel: "decision"
|
|
59
|
+
) do
|
|
60
|
+
# Extract unique inputs from rules
|
|
61
|
+
inputs = extract_inputs(rules)
|
|
62
|
+
inputs.each_with_index do |input, idx|
|
|
63
|
+
xml.input(id: "input_#{idx + 1}", label: input) do
|
|
64
|
+
xml.inputExpression(typeRef: "string") do
|
|
65
|
+
text_node = Nokogiri::XML::Node.new("text", xml.doc)
|
|
66
|
+
text_node.content = input
|
|
67
|
+
xml.parent.add_child(text_node)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Single output for decision
|
|
73
|
+
xml.output(id: "output_1", label: "decision", name: "decision", typeRef: "string")
|
|
74
|
+
|
|
75
|
+
# Convert rules
|
|
76
|
+
rules.each_with_index do |rule, idx|
|
|
77
|
+
convert_rule_to_xml(xml, rule, inputs, idx)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
builder.to_xml
|
|
85
|
+
end
|
|
86
|
+
# rubocop:enable Metrics/MethodLength
|
|
87
|
+
|
|
88
|
+
def extract_inputs(rules)
|
|
89
|
+
# Extract all unique field names used in conditions
|
|
90
|
+
inputs = Set.new
|
|
91
|
+
|
|
92
|
+
return [] unless rules.is_a?(Array)
|
|
93
|
+
|
|
94
|
+
rules.each do |rule|
|
|
95
|
+
condition = hash_get(rule, "if")
|
|
96
|
+
extract_fields_from_condition(condition, inputs)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
inputs.to_a.sort
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def extract_fields_from_condition(condition, inputs)
|
|
103
|
+
return unless condition.is_a?(Hash)
|
|
104
|
+
|
|
105
|
+
if hash_get(condition, "field")
|
|
106
|
+
inputs << hash_get(condition, "field")
|
|
107
|
+
elsif hash_get(condition, "all")
|
|
108
|
+
hash_get(condition, "all").each { |c| extract_fields_from_condition(c, inputs) }
|
|
109
|
+
elsif hash_get(condition, "any")
|
|
110
|
+
hash_get(condition, "any").each { |c| extract_fields_from_condition(c, inputs) }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def convert_rule_to_xml(xml, rule, inputs, idx)
|
|
115
|
+
rule_id = hash_get(rule, "id") || "rule_#{idx + 1}"
|
|
116
|
+
xml.rule(id: rule_id) do
|
|
117
|
+
# Input entries (in order of inputs array)
|
|
118
|
+
inputs.each do |input_name|
|
|
119
|
+
feel_expr = condition_to_feel(hash_get(rule, "if"), input_name)
|
|
120
|
+
xml.inputEntry(id: "entry_#{idx + 1}_#{input_name}") do
|
|
121
|
+
text_node = Nokogiri::XML::Node.new("text", xml.doc)
|
|
122
|
+
text_node.content = feel_expr
|
|
123
|
+
xml.parent.add_child(text_node)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Output entry
|
|
128
|
+
then_clause = hash_get(rule, "then")
|
|
129
|
+
decision_value = hash_get(then_clause, "decision") if then_clause
|
|
130
|
+
xml.outputEntry(id: "output_#{idx + 1}") do
|
|
131
|
+
text_node = Nokogiri::XML::Node.new("text", xml.doc)
|
|
132
|
+
text_node.content = format_feel_value(decision_value)
|
|
133
|
+
xml.parent.add_child(text_node)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Description
|
|
137
|
+
reason = hash_get(then_clause, "reason") if then_clause
|
|
138
|
+
description = hash_get(rule, "description")
|
|
139
|
+
if reason || description
|
|
140
|
+
xml.description do
|
|
141
|
+
xml.text reason || description
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def condition_to_feel(condition, target_field)
|
|
148
|
+
return "-" unless condition.is_a?(Hash)
|
|
149
|
+
|
|
150
|
+
# Find condition for this field
|
|
151
|
+
field_condition = find_field_condition(condition, target_field)
|
|
152
|
+
return "-" unless field_condition
|
|
153
|
+
|
|
154
|
+
# Convert operator and value to FEEL
|
|
155
|
+
op = hash_get(field_condition, "op")
|
|
156
|
+
value = hash_get(field_condition, "value")
|
|
157
|
+
|
|
158
|
+
convert_operator_to_feel(op, value)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def find_field_condition(condition, target_field)
|
|
162
|
+
if hash_get(condition, "field") == target_field
|
|
163
|
+
condition
|
|
164
|
+
elsif hash_get(condition, "all")
|
|
165
|
+
hash_get(condition, "all").each do |c|
|
|
166
|
+
result = find_field_condition(c, target_field)
|
|
167
|
+
return result if result
|
|
168
|
+
end
|
|
169
|
+
nil
|
|
170
|
+
elsif hash_get(condition, "any")
|
|
171
|
+
# For export, we pick first matching (Phase 2A limitation)
|
|
172
|
+
hash_get(condition, "any").each do |c|
|
|
173
|
+
result = find_field_condition(c, target_field)
|
|
174
|
+
return result if result
|
|
175
|
+
end
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def convert_operator_to_feel(op, value)
|
|
181
|
+
case op
|
|
182
|
+
when "eq"
|
|
183
|
+
format_feel_value(value)
|
|
184
|
+
when "neq"
|
|
185
|
+
"!= #{format_feel_value(value)}"
|
|
186
|
+
when "gt"
|
|
187
|
+
"> #{format_feel_value(value)}"
|
|
188
|
+
when "gte"
|
|
189
|
+
">= #{format_feel_value(value)}"
|
|
190
|
+
when "lt"
|
|
191
|
+
"< #{format_feel_value(value)}"
|
|
192
|
+
when "lte"
|
|
193
|
+
"<= #{format_feel_value(value)}"
|
|
194
|
+
when "in"
|
|
195
|
+
"[#{value.map { |v| format_feel_value(v) }.join(', ')}]"
|
|
196
|
+
when "between"
|
|
197
|
+
"[#{value[0]}..#{value[1]}]"
|
|
198
|
+
else
|
|
199
|
+
format_feel_value(value)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def format_feel_value(value)
|
|
204
|
+
case value
|
|
205
|
+
when String
|
|
206
|
+
"\"#{value}\""
|
|
207
|
+
when Numeric
|
|
208
|
+
value.to_s
|
|
209
|
+
when TrueClass, FalseClass
|
|
210
|
+
value.to_s
|
|
211
|
+
else
|
|
212
|
+
value.to_s
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|