decision_agent 0.2.0 → 1.0.1

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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +313 -8
  3. data/bin/decision_agent +104 -0
  4. data/lib/decision_agent/agent.rb +72 -1
  5. data/lib/decision_agent/context.rb +1 -0
  6. data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +86 -0
  7. data/lib/decision_agent/data_enrichment/cache_adapter.rb +49 -0
  8. data/lib/decision_agent/data_enrichment/circuit_breaker.rb +135 -0
  9. data/lib/decision_agent/data_enrichment/client.rb +220 -0
  10. data/lib/decision_agent/data_enrichment/config.rb +78 -0
  11. data/lib/decision_agent/data_enrichment/errors.rb +36 -0
  12. data/lib/decision_agent/decision.rb +102 -2
  13. data/lib/decision_agent/dmn/adapter.rb +135 -0
  14. data/lib/decision_agent/dmn/cache.rb +306 -0
  15. data/lib/decision_agent/dmn/decision_graph.rb +327 -0
  16. data/lib/decision_agent/dmn/decision_tree.rb +192 -0
  17. data/lib/decision_agent/dmn/errors.rb +30 -0
  18. data/lib/decision_agent/dmn/exporter.rb +217 -0
  19. data/lib/decision_agent/dmn/feel/evaluator.rb +819 -0
  20. data/lib/decision_agent/dmn/feel/functions.rb +420 -0
  21. data/lib/decision_agent/dmn/feel/parser.rb +349 -0
  22. data/lib/decision_agent/dmn/feel/simple_parser.rb +276 -0
  23. data/lib/decision_agent/dmn/feel/transformer.rb +372 -0
  24. data/lib/decision_agent/dmn/feel/types.rb +276 -0
  25. data/lib/decision_agent/dmn/importer.rb +77 -0
  26. data/lib/decision_agent/dmn/model.rb +197 -0
  27. data/lib/decision_agent/dmn/parser.rb +191 -0
  28. data/lib/decision_agent/dmn/testing.rb +333 -0
  29. data/lib/decision_agent/dmn/validator.rb +315 -0
  30. data/lib/decision_agent/dmn/versioning.rb +229 -0
  31. data/lib/decision_agent/dmn/visualizer.rb +513 -0
  32. data/lib/decision_agent/dsl/condition_evaluator.rb +984 -838
  33. data/lib/decision_agent/dsl/schema_validator.rb +53 -14
  34. data/lib/decision_agent/evaluators/dmn_evaluator.rb +308 -0
  35. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +69 -9
  36. data/lib/decision_agent/explainability/condition_trace.rb +83 -0
  37. data/lib/decision_agent/explainability/explainability_result.rb +52 -0
  38. data/lib/decision_agent/explainability/rule_trace.rb +39 -0
  39. data/lib/decision_agent/explainability/trace_collector.rb +24 -0
  40. data/lib/decision_agent/monitoring/alert_manager.rb +5 -1
  41. data/lib/decision_agent/simulation/errors.rb +18 -0
  42. data/lib/decision_agent/simulation/impact_analyzer.rb +498 -0
  43. data/lib/decision_agent/simulation/monte_carlo_simulator.rb +635 -0
  44. data/lib/decision_agent/simulation/replay_engine.rb +486 -0
  45. data/lib/decision_agent/simulation/scenario_engine.rb +318 -0
  46. data/lib/decision_agent/simulation/scenario_library.rb +163 -0
  47. data/lib/decision_agent/simulation/shadow_test_engine.rb +287 -0
  48. data/lib/decision_agent/simulation/what_if_analyzer.rb +1002 -0
  49. data/lib/decision_agent/simulation.rb +17 -0
  50. data/lib/decision_agent/version.rb +1 -1
  51. data/lib/decision_agent/versioning/activerecord_adapter.rb +23 -8
  52. data/lib/decision_agent/web/dmn_editor.rb +426 -0
  53. data/lib/decision_agent/web/public/app.js +119 -0
  54. data/lib/decision_agent/web/public/dmn-editor.css +596 -0
  55. data/lib/decision_agent/web/public/dmn-editor.html +250 -0
  56. data/lib/decision_agent/web/public/dmn-editor.js +553 -0
  57. data/lib/decision_agent/web/public/index.html +52 -0
  58. data/lib/decision_agent/web/public/simulation.html +130 -0
  59. data/lib/decision_agent/web/public/simulation_impact.html +478 -0
  60. data/lib/decision_agent/web/public/simulation_replay.html +551 -0
  61. data/lib/decision_agent/web/public/simulation_shadow.html +546 -0
  62. data/lib/decision_agent/web/public/simulation_whatif.html +532 -0
  63. data/lib/decision_agent/web/public/styles.css +86 -0
  64. data/lib/decision_agent/web/server.rb +1059 -23
  65. data/lib/decision_agent.rb +60 -2
  66. metadata +105 -61
  67. data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
  68. data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
  69. data/spec/ab_testing/ab_test_spec.rb +0 -270
  70. data/spec/ab_testing/ab_testing_agent_spec.rb +0 -481
  71. data/spec/ab_testing/storage/adapter_spec.rb +0 -64
  72. data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
  73. data/spec/activerecord_thread_safety_spec.rb +0 -553
  74. data/spec/advanced_operators_spec.rb +0 -3150
  75. data/spec/agent_spec.rb +0 -289
  76. data/spec/api_contract_spec.rb +0 -430
  77. data/spec/audit_adapters_spec.rb +0 -92
  78. data/spec/auth/access_audit_logger_spec.rb +0 -394
  79. data/spec/auth/authenticator_spec.rb +0 -112
  80. data/spec/auth/password_reset_spec.rb +0 -294
  81. data/spec/auth/permission_checker_spec.rb +0 -207
  82. data/spec/auth/permission_spec.rb +0 -73
  83. data/spec/auth/rbac_adapter_spec.rb +0 -550
  84. data/spec/auth/rbac_config_spec.rb +0 -82
  85. data/spec/auth/role_spec.rb +0 -51
  86. data/spec/auth/session_manager_spec.rb +0 -172
  87. data/spec/auth/session_spec.rb +0 -112
  88. data/spec/auth/user_spec.rb +0 -130
  89. data/spec/comprehensive_edge_cases_spec.rb +0 -1777
  90. data/spec/context_spec.rb +0 -127
  91. data/spec/decision_agent_spec.rb +0 -96
  92. data/spec/decision_spec.rb +0 -423
  93. data/spec/dsl/condition_evaluator_spec.rb +0 -774
  94. data/spec/dsl_validation_spec.rb +0 -648
  95. data/spec/edge_cases_spec.rb +0 -353
  96. data/spec/evaluation_spec.rb +0 -364
  97. data/spec/evaluation_validator_spec.rb +0 -165
  98. data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
  99. data/spec/examples.txt +0 -1633
  100. data/spec/issue_verification_spec.rb +0 -759
  101. data/spec/json_rule_evaluator_spec.rb +0 -587
  102. data/spec/monitoring/alert_manager_spec.rb +0 -378
  103. data/spec/monitoring/metrics_collector_spec.rb +0 -499
  104. data/spec/monitoring/monitored_agent_spec.rb +0 -222
  105. data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
  106. data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
  107. data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
  108. data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
  109. data/spec/performance_optimizations_spec.rb +0 -486
  110. data/spec/replay_edge_cases_spec.rb +0 -699
  111. data/spec/replay_spec.rb +0 -210
  112. data/spec/rfc8785_canonicalization_spec.rb +0 -215
  113. data/spec/scoring_spec.rb +0 -225
  114. data/spec/spec_helper.rb +0 -60
  115. data/spec/testing/batch_test_importer_spec.rb +0 -693
  116. data/spec/testing/batch_test_runner_spec.rb +0 -307
  117. data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
  118. data/spec/testing/test_result_comparator_spec.rb +0 -392
  119. data/spec/testing/test_scenario_spec.rb +0 -113
  120. data/spec/thread_safety_spec.rb +0 -482
  121. data/spec/thread_safety_spec.rb.broken +0 -878
  122. data/spec/versioning/adapter_spec.rb +0 -156
  123. data/spec/versioning_spec.rb +0 -1030
  124. data/spec/web/middleware/auth_middleware_spec.rb +0 -133
  125. data/spec/web/middleware/permission_middleware_spec.rb +0 -247
  126. data/spec/web_ui_rack_spec.rb +0 -1840
@@ -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