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,77 @@
1
+ require_relative "parser"
2
+ require_relative "validator"
3
+ require_relative "adapter"
4
+ require_relative "../versioning/version_manager"
5
+
6
+ module DecisionAgent
7
+ module Dmn
8
+ # Imports DMN XML files into DecisionAgent
9
+ class Importer
10
+ def initialize(version_manager: nil)
11
+ @version_manager = version_manager || Versioning::VersionManager.new
12
+ end
13
+
14
+ # Import DMN file
15
+ # @param file_path [String] Path to DMN XML file
16
+ # @param ruleset_name [String, nil] Optional custom ruleset name
17
+ # @param created_by [String] User who imported
18
+ # @return [Hash] Import result with model and version info
19
+ def import(file_path, ruleset_name: nil, created_by: "system")
20
+ xml_content = File.read(file_path)
21
+ import_from_xml(xml_content, ruleset_name: ruleset_name, created_by: created_by)
22
+ end
23
+
24
+ # Import from XML string
25
+ def import_from_xml(xml_content, ruleset_name: nil, created_by: "system")
26
+ # Parse DMN XML
27
+ parser = Parser.new(xml_content)
28
+ model = parser.parse
29
+
30
+ # Validate model
31
+ validator = Validator.new(model)
32
+ validator.validate!
33
+
34
+ # Convert to JSON rules
35
+ results = convert_model_to_rules(model)
36
+
37
+ # Store in versioning system
38
+ versions = store_in_versioning(results, ruleset_name, created_by)
39
+
40
+ {
41
+ model: model,
42
+ rules: results,
43
+ versions: versions,
44
+ decisions_imported: results.size
45
+ }
46
+ end
47
+
48
+ private
49
+
50
+ def convert_model_to_rules(model)
51
+ model.decisions.map do |decision|
52
+ next unless decision.decision_table
53
+
54
+ adapter = Adapter.new(decision.decision_table)
55
+ {
56
+ decision_id: decision.id,
57
+ decision_name: decision.name,
58
+ rules: adapter.to_json_rules
59
+ }
60
+ end.compact
61
+ end
62
+
63
+ def store_in_versioning(results, ruleset_name, created_by)
64
+ results.map do |result|
65
+ rule_id = ruleset_name || result[:decision_id]
66
+
67
+ @version_manager.save_version(
68
+ rule_id: rule_id,
69
+ rule_content: result[:rules],
70
+ created_by: created_by,
71
+ changelog: "Imported DMN decision: #{result[:decision_name]}"
72
+ )
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,197 @@
1
+ require_relative "errors"
2
+
3
+ module DecisionAgent
4
+ module Dmn
5
+ # Root DMN model containing all decisions
6
+ class Model
7
+ attr_reader :id, :name, :namespace, :decisions
8
+
9
+ def initialize(id:, name:, namespace: "http://decision_agent.local")
10
+ @id = id.to_s
11
+ @name = name.to_s
12
+ @namespace = namespace.to_s
13
+ @decisions = []
14
+ end
15
+
16
+ def add_decision(decision)
17
+ raise TypeError, "Expected Decision, got #{decision.class}" unless decision.is_a?(Decision)
18
+
19
+ @decisions << decision
20
+ end
21
+
22
+ def find_decision(decision_id)
23
+ @decisions.find { |d| d.id == decision_id.to_s }
24
+ end
25
+
26
+ def freeze
27
+ @id.freeze
28
+ @name.freeze
29
+ @namespace.freeze
30
+ @decisions.each(&:freeze)
31
+ @decisions.freeze
32
+ super
33
+ end
34
+ end
35
+
36
+ # Represents a single decision element
37
+ class Decision
38
+ attr_accessor :decision_tree
39
+ attr_reader :id, :name, :decision_table, :description, :information_requirements
40
+
41
+ def initialize(id:, name:, description: nil)
42
+ @id = id.to_s
43
+ @name = name.to_s
44
+ @description = description&.to_s
45
+ @decision_table = nil
46
+ @decision_tree = nil
47
+ @information_requirements = []
48
+ end
49
+
50
+ def decision_table=(table)
51
+ raise TypeError, "Expected DecisionTable, got #{table.class}" unless table.is_a?(DecisionTable)
52
+
53
+ @decision_table = table
54
+ end
55
+
56
+ def add_information_requirement(requirement)
57
+ @information_requirements << requirement
58
+ end
59
+
60
+ def freeze
61
+ @id.freeze
62
+ @name.freeze
63
+ @description.freeze
64
+ @decision_table&.freeze
65
+ @decision_tree&.freeze
66
+ @information_requirements.freeze
67
+ super
68
+ end
69
+ end
70
+
71
+ # Decision table with inputs, outputs, rules, and hit policy
72
+ class DecisionTable
73
+ attr_reader :id, :hit_policy, :inputs, :outputs, :rules
74
+
75
+ VALID_HIT_POLICIES = %w[UNIQUE FIRST PRIORITY ANY COLLECT].freeze
76
+
77
+ def initialize(id:, hit_policy: "UNIQUE")
78
+ @id = id.to_s
79
+ validate_hit_policy!(hit_policy)
80
+ @hit_policy = hit_policy.to_s
81
+ @inputs = []
82
+ @outputs = []
83
+ @rules = []
84
+ end
85
+
86
+ def add_input(input)
87
+ raise TypeError, "Expected Input, got #{input.class}" unless input.is_a?(Input)
88
+
89
+ @inputs << input
90
+ end
91
+
92
+ def add_output(output)
93
+ raise TypeError, "Expected Output, got #{output.class}" unless output.is_a?(Output)
94
+
95
+ @outputs << output
96
+ end
97
+
98
+ def add_rule(rule)
99
+ raise TypeError, "Expected Rule, got #{rule.class}" unless rule.is_a?(Rule)
100
+
101
+ @rules << rule
102
+ end
103
+
104
+ def freeze
105
+ @id.freeze
106
+ @hit_policy.freeze
107
+ @inputs.each(&:freeze)
108
+ @inputs.freeze
109
+ @outputs.each(&:freeze)
110
+ @outputs.freeze
111
+ @rules.each(&:freeze)
112
+ @rules.freeze
113
+ super
114
+ end
115
+
116
+ private
117
+
118
+ def validate_hit_policy!(policy)
119
+ return if VALID_HIT_POLICIES.include?(policy.to_s)
120
+
121
+ raise UnsupportedHitPolicyError,
122
+ "Hit policy '#{policy}' not supported. " \
123
+ "Supported: #{VALID_HIT_POLICIES.join(', ')}"
124
+ end
125
+ end
126
+
127
+ # Input clause (column) in decision table
128
+ class Input
129
+ attr_reader :id, :label, :expression, :type_ref
130
+
131
+ def initialize(id:, label:, expression: nil, type_ref: "string")
132
+ @id = id.to_s
133
+ @label = label.to_s
134
+ @expression = expression&.to_s || label.to_s
135
+ @type_ref = type_ref.to_s
136
+ end
137
+
138
+ def freeze
139
+ @id.freeze
140
+ @label.freeze
141
+ @expression.freeze
142
+ @type_ref.freeze
143
+ super
144
+ end
145
+ end
146
+
147
+ # Output clause (result column) in decision table
148
+ class Output
149
+ attr_reader :id, :label, :name, :type_ref
150
+
151
+ def initialize(id:, label:, name: nil, type_ref: "string")
152
+ @id = id.to_s
153
+ @label = label.to_s
154
+ @name = (name || label).to_s
155
+ @type_ref = type_ref.to_s
156
+ end
157
+
158
+ def freeze
159
+ @id.freeze
160
+ @label.freeze
161
+ @name.freeze
162
+ @type_ref.freeze
163
+ super
164
+ end
165
+ end
166
+
167
+ # Decision table rule (row)
168
+ class Rule
169
+ attr_reader :id, :input_entries, :output_entries, :description
170
+
171
+ def initialize(id:, description: nil)
172
+ @id = id.to_s
173
+ @description = description&.to_s
174
+ @input_entries = []
175
+ @output_entries = []
176
+ end
177
+
178
+ def add_input_entry(entry)
179
+ @input_entries << entry.to_s
180
+ end
181
+
182
+ def add_output_entry(entry)
183
+ @output_entries << entry
184
+ end
185
+
186
+ def freeze
187
+ @id.freeze
188
+ @description.freeze
189
+ @input_entries.each(&:freeze)
190
+ @input_entries.freeze
191
+ @output_entries.map { |e| e.freeze if e.respond_to?(:freeze) }
192
+ @output_entries.freeze
193
+ super
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,191 @@
1
+ require "nokogiri"
2
+ require "securerandom"
3
+ require_relative "model"
4
+ require_relative "errors"
5
+
6
+ module DecisionAgent
7
+ module Dmn
8
+ # Parses DMN 1.3 XML files into Ruby model objects
9
+ class Parser
10
+ NAMESPACES = {
11
+ "dmn" => "https://www.omg.org/spec/DMN/20191111/MODEL/",
12
+ "dmn11" => "http://www.omg.org/spec/DMN/20151101/dmn.xsd",
13
+ "dmn13" => "https://www.omg.org/spec/DMN/20191111/MODEL/"
14
+ }.freeze
15
+
16
+ def initialize(xml_content)
17
+ @xml_content = xml_content
18
+ @doc = nil
19
+ @namespace = nil
20
+ end
21
+
22
+ def parse
23
+ parse_xml
24
+ extract_model
25
+ end
26
+
27
+ private
28
+
29
+ def parse_xml
30
+ @doc = Nokogiri::XML(@xml_content)
31
+
32
+ if @doc.errors.any?
33
+ raise InvalidDmnXmlError,
34
+ "XML parsing failed: #{@doc.errors.map(&:to_s).join(', ')}"
35
+ end
36
+
37
+ # Detect namespace
38
+ @namespace = detect_namespace
39
+ end
40
+
41
+ def detect_namespace
42
+ if @doc.root.namespace&.href&.include?("20191111")
43
+ NAMESPACES["dmn13"]
44
+ elsif @doc.root.namespace&.href&.include?("20151101")
45
+ NAMESPACES["dmn11"]
46
+ else
47
+ # Default to DMN 1.3
48
+ NAMESPACES["dmn13"]
49
+ end
50
+ end
51
+
52
+ def extract_model
53
+ definitions = @doc.at_xpath("//dmn:definitions", NAMESPACES) ||
54
+ @doc.at_xpath("//*[local-name()='definitions']")
55
+
56
+ raise InvalidDmnXmlError, "No definitions element found" unless definitions
57
+
58
+ model = Model.new(
59
+ id: definitions["id"] || "model",
60
+ name: definitions["name"] || "DMN Model",
61
+ namespace: definitions["namespace"] || @namespace
62
+ )
63
+
64
+ # Parse all decisions
65
+ decisions = @doc.xpath("//dmn:decision", NAMESPACES)
66
+ decisions = @doc.xpath("//*[local-name()='decision']") if decisions.empty?
67
+
68
+ decisions.each do |decision_node|
69
+ decision = parse_decision(decision_node)
70
+ model.add_decision(decision)
71
+ end
72
+
73
+ model.freeze
74
+ model
75
+ end
76
+
77
+ def parse_decision(node)
78
+ decision = Decision.new(
79
+ id: node["id"] || SecureRandom.uuid,
80
+ name: node["name"] || "Unnamed Decision",
81
+ description: extract_description(node)
82
+ )
83
+
84
+ # Parse decision table if present
85
+ table_node = node.at_xpath(".//dmn:decisionTable", NAMESPACES) ||
86
+ node.at_xpath(".//*[local-name()='decisionTable']")
87
+
88
+ decision.decision_table = parse_decision_table(table_node) if table_node
89
+
90
+ decision.freeze
91
+ decision
92
+ end
93
+
94
+ def parse_decision_table(node)
95
+ table = DecisionTable.new(
96
+ id: node["id"] || SecureRandom.uuid,
97
+ hit_policy: node["hitPolicy"] || "UNIQUE"
98
+ )
99
+
100
+ # Parse inputs
101
+ inputs = node.xpath(".//dmn:input", NAMESPACES)
102
+ inputs = node.xpath(".//*[local-name()='input']") if inputs.empty?
103
+
104
+ inputs.each do |input_node|
105
+ table.add_input(parse_input(input_node))
106
+ end
107
+
108
+ # Parse outputs
109
+ outputs = node.xpath(".//dmn:output", NAMESPACES)
110
+ outputs = node.xpath(".//*[local-name()='output']") if outputs.empty?
111
+
112
+ outputs.each do |output_node|
113
+ table.add_output(parse_output(output_node))
114
+ end
115
+
116
+ # Parse rules
117
+ rules = node.xpath(".//dmn:rule", NAMESPACES)
118
+ rules = node.xpath(".//*[local-name()='rule']") if rules.empty?
119
+
120
+ rules.each do |rule_node|
121
+ table.add_rule(parse_rule(rule_node))
122
+ end
123
+
124
+ table.freeze
125
+ table
126
+ end
127
+
128
+ def parse_input(node)
129
+ input_expr = node.at_xpath(".//dmn:inputExpression", NAMESPACES) ||
130
+ node.at_xpath(".//*[local-name()='inputExpression']")
131
+
132
+ text_node = input_expr&.at_xpath(".//dmn:text", NAMESPACES) ||
133
+ input_expr&.at_xpath(".//*[local-name()='text']")
134
+
135
+ Input.new(
136
+ id: node["id"] || SecureRandom.uuid,
137
+ label: node["label"] || text_node&.text || "Input",
138
+ expression: text_node&.text,
139
+ type_ref: input_expr&.[]("typeRef") || "string"
140
+ ).freeze
141
+ end
142
+
143
+ def parse_output(node)
144
+ Output.new(
145
+ id: node["id"] || SecureRandom.uuid,
146
+ label: node["label"] || node["name"] || "Output",
147
+ name: node["name"] || node["label"] || "output",
148
+ type_ref: node["typeRef"] || "string"
149
+ ).freeze
150
+ end
151
+
152
+ def parse_rule(node)
153
+ rule = Rule.new(
154
+ id: node["id"] || SecureRandom.uuid,
155
+ description: extract_description(node)
156
+ )
157
+
158
+ # Parse input entries
159
+ input_entries = node.xpath(".//dmn:inputEntry", NAMESPACES)
160
+ input_entries = node.xpath(".//*[local-name()='inputEntry']") if input_entries.empty?
161
+
162
+ input_entries.each do |entry_node|
163
+ text_node = entry_node.at_xpath(".//dmn:text", NAMESPACES) ||
164
+ entry_node.at_xpath(".//*[local-name()='text']")
165
+ text = text_node&.text || "-"
166
+ rule.add_input_entry(text)
167
+ end
168
+
169
+ # Parse output entries
170
+ output_entries = node.xpath(".//dmn:outputEntry", NAMESPACES)
171
+ output_entries = node.xpath(".//*[local-name()='outputEntry']") if output_entries.empty?
172
+
173
+ output_entries.each do |entry_node|
174
+ text_node = entry_node.at_xpath(".//dmn:text", NAMESPACES) ||
175
+ entry_node.at_xpath(".//*[local-name()='text']")
176
+ text = text_node&.text
177
+ rule.add_output_entry(text)
178
+ end
179
+
180
+ rule.freeze
181
+ rule
182
+ end
183
+
184
+ def extract_description(node)
185
+ desc_node = node.at_xpath(".//dmn:description", NAMESPACES) ||
186
+ node.at_xpath(".//*[local-name()='description']")
187
+ desc_node&.text
188
+ end
189
+ end
190
+ end
191
+ end