decision_agent 1.0.1 → 1.2.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 (176) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +0 -0
  3. data/README.md +64 -108
  4. data/lib/decision_agent/ab_testing/ab_test.rb +5 -1
  5. data/lib/decision_agent/ab_testing/ab_test_assignment.rb +2 -0
  6. data/lib/decision_agent/ab_testing/ab_test_manager.rb +2 -0
  7. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +2 -0
  8. data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +2 -16
  9. data/lib/decision_agent/ab_testing/storage/adapter.rb +2 -0
  10. data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +2 -0
  11. data/lib/decision_agent/agent.rb +49 -51
  12. data/lib/decision_agent/audit/adapter.rb +2 -0
  13. data/lib/decision_agent/audit/logger_adapter.rb +2 -0
  14. data/lib/decision_agent/audit/null_adapter.rb +2 -0
  15. data/lib/decision_agent/auth/access_audit_logger.rb +2 -0
  16. data/lib/decision_agent/auth/authenticator.rb +2 -0
  17. data/lib/decision_agent/auth/password_reset_manager.rb +2 -0
  18. data/lib/decision_agent/auth/password_reset_token.rb +2 -0
  19. data/lib/decision_agent/auth/permission.rb +2 -0
  20. data/lib/decision_agent/auth/permission_checker.rb +2 -0
  21. data/lib/decision_agent/auth/rbac_adapter.rb +2 -0
  22. data/lib/decision_agent/auth/rbac_config.rb +2 -0
  23. data/lib/decision_agent/auth/role.rb +2 -0
  24. data/lib/decision_agent/auth/session.rb +2 -0
  25. data/lib/decision_agent/auth/session_manager.rb +2 -0
  26. data/lib/decision_agent/auth/user.rb +2 -0
  27. data/lib/decision_agent/context.rb +13 -0
  28. data/lib/decision_agent/decision.rb +11 -2
  29. data/lib/decision_agent/dmn/adapter.rb +2 -0
  30. data/lib/decision_agent/dmn/cache.rb +2 -2
  31. data/lib/decision_agent/dmn/decision_graph.rb +7 -7
  32. data/lib/decision_agent/dmn/decision_tree.rb +16 -8
  33. data/lib/decision_agent/dmn/errors.rb +2 -0
  34. data/lib/decision_agent/dmn/exporter.rb +43 -2
  35. data/lib/decision_agent/dmn/feel/evaluator.rb +102 -112
  36. data/lib/decision_agent/dmn/feel/functions.rb +2 -0
  37. data/lib/decision_agent/dmn/feel/parser.rb +2 -0
  38. data/lib/decision_agent/dmn/feel/simple_parser.rb +98 -77
  39. data/lib/decision_agent/dmn/feel/transformer.rb +56 -102
  40. data/lib/decision_agent/dmn/feel/types.rb +2 -0
  41. data/lib/decision_agent/dmn/importer.rb +2 -0
  42. data/lib/decision_agent/dmn/model.rb +2 -4
  43. data/lib/decision_agent/dmn/parser.rb +2 -0
  44. data/lib/decision_agent/dmn/testing.rb +3 -6
  45. data/lib/decision_agent/dmn/validator.rb +8 -10
  46. data/lib/decision_agent/dmn/versioning.rb +41 -15
  47. data/lib/decision_agent/dmn/visualizer.rb +7 -6
  48. data/lib/decision_agent/dsl/condition_evaluator.rb +197 -1473
  49. data/lib/decision_agent/dsl/helpers/cache_helpers.rb +82 -0
  50. data/lib/decision_agent/dsl/helpers/comparison_helpers.rb +98 -0
  51. data/lib/decision_agent/dsl/helpers/date_helpers.rb +91 -0
  52. data/lib/decision_agent/dsl/helpers/geospatial_helpers.rb +85 -0
  53. data/lib/decision_agent/dsl/helpers/operator_evaluation_helpers.rb +160 -0
  54. data/lib/decision_agent/dsl/helpers/parameter_parsing_helpers.rb +206 -0
  55. data/lib/decision_agent/dsl/helpers/template_helpers.rb +39 -0
  56. data/lib/decision_agent/dsl/helpers/utility_helpers.rb +45 -0
  57. data/lib/decision_agent/dsl/operators/base.rb +70 -0
  58. data/lib/decision_agent/dsl/operators/basic_comparison_operators.rb +80 -0
  59. data/lib/decision_agent/dsl/operators/collection_operators.rb +60 -0
  60. data/lib/decision_agent/dsl/operators/date_arithmetic_operators.rb +206 -0
  61. data/lib/decision_agent/dsl/operators/date_time_operators.rb +47 -0
  62. data/lib/decision_agent/dsl/operators/duration_operators.rb +149 -0
  63. data/lib/decision_agent/dsl/operators/financial_operators.rb +237 -0
  64. data/lib/decision_agent/dsl/operators/geospatial_operators.rb +106 -0
  65. data/lib/decision_agent/dsl/operators/mathematical_operators.rb +234 -0
  66. data/lib/decision_agent/dsl/operators/moving_window_operators.rb +135 -0
  67. data/lib/decision_agent/dsl/operators/numeric_operators.rb +120 -0
  68. data/lib/decision_agent/dsl/operators/rate_operators.rb +65 -0
  69. data/lib/decision_agent/dsl/operators/statistical_aggregations.rb +187 -0
  70. data/lib/decision_agent/dsl/operators/string_aggregations.rb +84 -0
  71. data/lib/decision_agent/dsl/operators/string_operators.rb +72 -0
  72. data/lib/decision_agent/dsl/operators/time_component_operators.rb +72 -0
  73. data/lib/decision_agent/dsl/rule_parser.rb +2 -0
  74. data/lib/decision_agent/dsl/schema_validator.rb +9 -24
  75. data/lib/decision_agent/errors.rb +2 -0
  76. data/lib/decision_agent/evaluation.rb +14 -2
  77. data/lib/decision_agent/evaluation_validator.rb +0 -0
  78. data/lib/decision_agent/evaluators/base.rb +2 -0
  79. data/lib/decision_agent/evaluators/dmn_evaluator.rb +2 -0
  80. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +28 -41
  81. data/lib/decision_agent/evaluators/static_evaluator.rb +2 -0
  82. data/lib/decision_agent/explainability/condition_trace.rb +2 -0
  83. data/lib/decision_agent/explainability/explainability_result.rb +2 -4
  84. data/lib/decision_agent/explainability/rule_trace.rb +2 -0
  85. data/lib/decision_agent/explainability/trace_collector.rb +2 -0
  86. data/lib/decision_agent/monitoring/alert_manager.rb +2 -15
  87. data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +0 -0
  88. data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +0 -0
  89. data/lib/decision_agent/monitoring/dashboard/public/index.html +0 -0
  90. data/lib/decision_agent/monitoring/dashboard_server.rb +383 -250
  91. data/lib/decision_agent/monitoring/metrics_collector.rb +2 -0
  92. data/lib/decision_agent/monitoring/monitored_agent.rb +2 -0
  93. data/lib/decision_agent/monitoring/prometheus_exporter.rb +3 -1
  94. data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +0 -0
  95. data/lib/decision_agent/monitoring/storage/base_adapter.rb +0 -0
  96. data/lib/decision_agent/monitoring/storage/memory_adapter.rb +1 -1
  97. data/lib/decision_agent/replay/replay.rb +4 -1
  98. data/lib/decision_agent/scoring/base.rb +2 -0
  99. data/lib/decision_agent/scoring/consensus.rb +2 -0
  100. data/lib/decision_agent/scoring/max_weight.rb +2 -0
  101. data/lib/decision_agent/scoring/threshold.rb +2 -0
  102. data/lib/decision_agent/scoring/weighted_average.rb +2 -0
  103. data/lib/decision_agent/simulation/errors.rb +2 -0
  104. data/lib/decision_agent/simulation/impact_analyzer.rb +3 -3
  105. data/lib/decision_agent/simulation/monte_carlo_simulator.rb +11 -10
  106. data/lib/decision_agent/simulation/replay_engine.rb +3 -3
  107. data/lib/decision_agent/simulation/scenario_engine.rb +3 -1
  108. data/lib/decision_agent/simulation/scenario_library.rb +2 -0
  109. data/lib/decision_agent/simulation/shadow_test_engine.rb +3 -16
  110. data/lib/decision_agent/simulation/what_if_analyzer.rb +17 -13
  111. data/lib/decision_agent/simulation.rb +2 -0
  112. data/lib/decision_agent/testing/batch_test_importer.rb +6 -6
  113. data/lib/decision_agent/testing/batch_test_runner.rb +5 -4
  114. data/lib/decision_agent/testing/test_coverage_analyzer.rb +2 -0
  115. data/lib/decision_agent/testing/test_result_comparator.rb +56 -63
  116. data/lib/decision_agent/testing/test_scenario.rb +2 -0
  117. data/lib/decision_agent/version.rb +3 -1
  118. data/lib/decision_agent/versioning/activerecord_adapter.rb +159 -47
  119. data/lib/decision_agent/versioning/adapter.rb +42 -0
  120. data/lib/decision_agent/versioning/file_storage_adapter.rb +96 -13
  121. data/lib/decision_agent/versioning/version_manager.rb +49 -2
  122. data/lib/decision_agent/web/dmn_editor/serialization.rb +74 -0
  123. data/lib/decision_agent/web/dmn_editor/xml_builder.rb +107 -0
  124. data/lib/decision_agent/web/dmn_editor.rb +8 -73
  125. data/lib/decision_agent/web/middleware/auth_middleware.rb +2 -0
  126. data/lib/decision_agent/web/middleware/permission_middleware.rb +3 -1
  127. data/lib/decision_agent/web/public/app.js +67 -26
  128. data/lib/decision_agent/web/public/batch_testing.html +80 -6
  129. data/lib/decision_agent/web/public/dmn-editor.css +0 -0
  130. data/lib/decision_agent/web/public/dmn-editor.html +2 -2
  131. data/lib/decision_agent/web/public/dmn-editor.js +79 -8
  132. data/lib/decision_agent/web/public/index.html +20 -3
  133. data/lib/decision_agent/web/public/login.html +1 -1
  134. data/lib/decision_agent/web/public/sample_batch.csv +11 -0
  135. data/lib/decision_agent/web/public/sample_impact.csv +11 -0
  136. data/lib/decision_agent/web/public/sample_replay.csv +11 -0
  137. data/lib/decision_agent/web/public/sample_rules.json +118 -0
  138. data/lib/decision_agent/web/public/sample_shadow.csv +11 -0
  139. data/lib/decision_agent/web/public/sample_whatif.csv +11 -0
  140. data/lib/decision_agent/web/public/simulation.html +23 -7
  141. data/lib/decision_agent/web/public/simulation_impact.html +37 -20
  142. data/lib/decision_agent/web/public/simulation_replay.html +19 -23
  143. data/lib/decision_agent/web/public/simulation_shadow.html +36 -21
  144. data/lib/decision_agent/web/public/simulation_whatif.html +38 -21
  145. data/lib/decision_agent/web/public/styles.css +0 -0
  146. data/lib/decision_agent/web/public/users.html +1 -1
  147. data/lib/decision_agent/web/rack_helpers.rb +106 -0
  148. data/lib/decision_agent/web/rack_request_helpers.rb +196 -0
  149. data/lib/decision_agent/web/server.rb +2038 -1851
  150. data/lib/decision_agent.rb +3 -43
  151. data/lib/generators/decision_agent/install/install_generator.rb +2 -0
  152. data/lib/generators/decision_agent/install/templates/README +0 -0
  153. data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +2 -0
  154. data/lib/generators/decision_agent/install/templates/ab_test_model.rb +2 -0
  155. data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +2 -0
  156. data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +0 -0
  157. data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +0 -0
  158. data/lib/generators/decision_agent/install/templates/decision_log.rb +0 -0
  159. data/lib/generators/decision_agent/install/templates/error_metric.rb +0 -0
  160. data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +0 -0
  161. data/lib/generators/decision_agent/install/templates/migration.rb +16 -0
  162. data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +0 -2
  163. data/lib/generators/decision_agent/install/templates/performance_metric.rb +0 -0
  164. data/lib/generators/decision_agent/install/templates/rule.rb +2 -0
  165. data/lib/generators/decision_agent/install/templates/rule_version.rb +2 -0
  166. data/lib/generators/decision_agent/install/templates/rule_version_tag.rb +23 -0
  167. data/lib/generators/decision_agent/install/templates/versioning_migration.rb +44 -0
  168. data/lib/generators/decision_agent/monitoring_migration/monitoring_migration_generator.rb +67 -0
  169. data/lib/generators/decision_agent/versioning_migration/versioning_migration_generator.rb +57 -0
  170. metadata +66 -25
  171. data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +0 -86
  172. data/lib/decision_agent/data_enrichment/cache_adapter.rb +0 -49
  173. data/lib/decision_agent/data_enrichment/circuit_breaker.rb +0 -135
  174. data/lib/decision_agent/data_enrichment/client.rb +0 -220
  175. data/lib/decision_agent/data_enrichment/config.rb +0 -78
  176. data/lib/decision_agent/data_enrichment/errors.rb +0 -36
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DecisionAgent
2
4
  module Auth
3
5
  # Base adapter interface for RBAC integration
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DecisionAgent
2
4
  module Auth
3
5
  # Configuration class for RBAC adapter
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DecisionAgent
2
4
  module Auth
3
5
  class Role
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "securerandom"
2
4
 
3
5
  module DecisionAgent
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DecisionAgent
2
4
  module Auth
3
5
  class SessionManager
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "bcrypt"
2
4
  require "securerandom"
3
5
 
@@ -1,7 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DecisionAgent
4
+ # Immutable, thread-safe wrapper around input data passed to evaluators.
5
+ # Data is deep-copied and deep-frozen on construction.
2
6
  class Context
3
7
  attr_reader :data
4
8
 
9
+ # @param data [Hash, Object] Input data; non-Hash is treated as empty Hash
5
10
  def initialize(data)
6
11
  # Create a deep copy before freezing to avoid mutating the original
7
12
  # This is necessary for thread-safety even if it adds some overhead
@@ -9,18 +14,26 @@ module DecisionAgent
9
14
  @data = deep_freeze(deep_dup(data_hash))
10
15
  end
11
16
 
17
+ # @param key [Object] Key to look up
18
+ # @return [Object, nil] Value for key, or nil if missing
12
19
  def [](key)
13
20
  @data[key]
14
21
  end
15
22
 
23
+ # @param key [Object] Key to look up
24
+ # @param default [Object] Value returned when key is missing (default: nil)
25
+ # @return [Object] Value for key, or default
16
26
  def fetch(key, default = nil)
17
27
  @data.fetch(key, default)
18
28
  end
19
29
 
30
+ # @param key [Object] Key to check
31
+ # @return [Boolean] Whether the key exists
20
32
  def key?(key)
21
33
  @data.key?(key)
22
34
  end
23
35
 
36
+ # @return [Hash] The underlying frozen data hash
24
37
  def to_h
25
38
  @data
26
39
  end
@@ -1,4 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DecisionAgent
4
+ # Result of {Agent#decide}: the chosen decision, confidence, explanations, and audit data.
2
5
  class Decision
3
6
  attr_reader :decision, :confidence, :explanations, :evaluations, :audit_payload
4
7
 
@@ -40,6 +43,10 @@ module DecisionAgent
40
43
  }.compact
41
44
  end
42
45
 
46
+ # Returns the decision as a hash (explainability-shaped plus confidence, evaluations, audit).
47
+ #
48
+ # @return [Hash] Symbol-keyed hash with :decision, :because, :failed_conditions, :confidence,
49
+ # :explanations, :evaluations, :audit_payload, :explainability
43
50
  def to_h
44
51
  # Structure decision result as explainability by default
45
52
  # This makes explainability the primary format for decision results
@@ -124,6 +131,8 @@ module DecisionAgent
124
131
 
125
132
  public
126
133
 
134
+ # @param other [Object] Object to compare
135
+ # @return [Boolean] true if other is a Decision with same decision, confidence, explanations, evaluations
127
136
  def ==(other)
128
137
  other.is_a?(Decision) &&
129
138
  @decision == other.decision &&
@@ -135,8 +144,8 @@ module DecisionAgent
135
144
  private
136
145
 
137
146
  def validate_confidence!(confidence)
138
- c = confidence.to_f
139
- raise InvalidConfidenceError, confidence unless c.between?(0.0, 1.0)
147
+ confidence_value = confidence.to_f
148
+ raise InvalidConfidenceError, confidence unless confidence_value.between?(0.0, 1.0)
140
149
  end
141
150
 
142
151
  def deep_freeze(obj)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "feel/evaluator"
2
4
 
3
5
  module DecisionAgent
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
3
+ require "openssl"
4
4
  require "zlib"
5
5
 
6
6
  module DecisionAgent
@@ -144,7 +144,7 @@ module DecisionAgent
144
144
  end
145
145
 
146
146
  def generate_result_key(decision_id, context_hash)
147
- Digest::SHA256.hexdigest("#{decision_id}:#{context_hash}")
147
+ OpenSSL::Digest::SHA256.hexdigest("#{decision_id}:#{context_hash}")
148
148
  end
149
149
 
150
150
  def calculate_hit_rate(hits, misses)
@@ -251,7 +251,8 @@ module DecisionAgent
251
251
  "condition",
252
252
  context
253
253
  )
254
- rescue StandardError
254
+ rescue StandardError => e
255
+ warn "[DecisionAgent] FEEL condition evaluation failed: #{e.message}"
255
256
  false
256
257
  end
257
258
  end
@@ -277,13 +278,12 @@ module DecisionAgent
277
278
  decision_node = graph.get_decision(decision_id)
278
279
 
279
280
  # Find all information requirements
280
- info_reqs = decision_xml.xpath(".//dmn:informationRequirement")
281
- info_reqs.each do |req|
281
+ decision_xml.xpath(".//dmn:informationRequirement").each do |req|
282
282
  required_decision = req.at_xpath(".//dmn:requiredDecision")
283
- if required_decision
284
- required_id = required_decision["href"]&.sub("#", "")
285
- decision_node.add_dependency(required_id) if required_id
286
- end
283
+ next unless required_decision
284
+
285
+ required_id = required_decision["href"]&.sub("#", "")
286
+ decision_node.add_dependency(required_id) if required_id
287
287
  end
288
288
  end
289
289
 
@@ -105,15 +105,13 @@ module DecisionAgent
105
105
  any_condition_evaluated = true
106
106
 
107
107
  return traverse(child, context) if result
108
- # Condition matched, continue down this branch
109
-
110
- # Condition evaluated to false - check if this child has a false branch
111
- # If child has multiple leaf children with no conditions, take the second one
112
- if !child.leaf? && child.children.all? { |c| c.condition.nil? && c.leaf? } && child.children.size > 1
113
- return child.children[1].decision
114
- end
115
- rescue StandardError
108
+
109
+ # Condition evaluated to false - check for a false branch
110
+ false_branch = false_branch_decision(child)
111
+ return false_branch if false_branch
112
+ rescue StandardError => e
116
113
  # If condition evaluation fails, skip this branch
114
+ warn "[DecisionAgent] Decision tree condition evaluation failed: #{e.message}"
117
115
  next
118
116
  end
119
117
  end
@@ -129,6 +127,16 @@ module DecisionAgent
129
127
  nil
130
128
  end
131
129
 
130
+ # Check if a child node that evaluated to false has an explicit false branch
131
+ # (multiple leaf children with no conditions, take the second one)
132
+ def false_branch_decision(child)
133
+ return nil if child.leaf?
134
+ return nil unless child.children.size > 1
135
+ return nil unless child.children.all? { |c| c.condition.nil? && c.leaf? }
136
+
137
+ child.children[1].decision
138
+ end
139
+
132
140
  def self.build_node(hash)
133
141
  node = TreeNode.new(
134
142
  id: hash[:id],
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DecisionAgent
2
4
  module Dmn
3
5
  # Base error for all DMN-related errors
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "nokogiri"
2
4
  require "set"
3
5
  require_relative "errors"
@@ -11,6 +13,27 @@ module DecisionAgent
11
13
  @version_manager = version_manager || Versioning::VersionManager.new
12
14
  end
13
15
 
16
+ # Serialize an in-memory DMN Model object to DMN XML.
17
+ # Unlike #export, this does NOT look up any stored version — it converts
18
+ # the live model directly. Use this when saving a new version.
19
+ # @param model [DecisionAgent::Dmn::Model]
20
+ # @return [String] DMN XML
21
+ def serialize_model(model)
22
+ builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
23
+ xml.definitions(
24
+ "xmlns" => "https://www.omg.org/spec/DMN/20191111/MODEL/",
25
+ "xmlns:dmndi" => "https://www.omg.org/spec/DMN/20191111/DMNDI/",
26
+ "xmlns:dc" => "http://www.omg.org/spec/DMN/20180521/DC/",
27
+ "id" => "definitions_#{model.id}",
28
+ "name" => model.name,
29
+ "namespace" => model.namespace
30
+ ) do
31
+ model.decisions.each { |d| serialize_decision_node(xml, d) }
32
+ end
33
+ end
34
+ builder.to_xml
35
+ end
36
+
14
37
  # Export ruleset to DMN XML
15
38
  # @param rule_id [String] Rule ID to export
16
39
  # @param output_path [String, nil] Optional file path to write
@@ -31,12 +54,31 @@ module DecisionAgent
31
54
 
32
55
  private
33
56
 
57
+ def serialize_decision_node(xml, decision)
58
+ xml.decision(id: decision.id, name: decision.name) do
59
+ xml.description(decision.description) if decision.description
60
+ if (dt = decision.decision_table)
61
+ xml.decisionTable(id: dt.id, hitPolicy: dt.hit_policy) do
62
+ dt.inputs.each do |inp|
63
+ xml.input(id: inp.id, label: inp.label) do
64
+ xml.inputExpression(typeRef: inp.type_ref) do
65
+ xml.text_ inp.expression
66
+ end
67
+ end
68
+ end
69
+ dt.outputs.each do |out|
70
+ xml.output(id: out.id, label: out.label, name: out.name, typeRef: out.type_ref)
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+
34
77
  # Helper to get hash value with both string and symbol key support
35
78
  def hash_get(hash, key)
36
79
  hash[key.to_s] || hash[key.to_sym]
37
80
  end
38
81
 
39
- # rubocop:disable Metrics/MethodLength
40
82
  def convert_to_dmn(rules_json, rule_id)
41
83
  # Handle both string and symbol keys
42
84
  ruleset_name = rules_json["ruleset"] || rules_json[:ruleset] || rule_id
@@ -83,7 +125,6 @@ module DecisionAgent
83
125
 
84
126
  builder.to_xml
85
127
  end
86
- # rubocop:enable Metrics/MethodLength
87
128
 
88
129
  def extract_inputs(rules)
89
130
  # Extract all unique field names used in conditions
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "../errors"
2
4
  require_relative "simple_parser"
3
5
  require_relative "parser"
@@ -13,7 +15,6 @@ module DecisionAgent
13
15
  # Phase 2A: Basic comparisons, ranges, list membership (regex-based)
14
16
  # Phase 2B: Arithmetic, logical operators, functions (enhanced parser)
15
17
  # Maps FEEL expressions to DecisionAgent ConditionEvaluator
16
- # rubocop:disable Metrics/ClassLength
17
18
  class Evaluator
18
19
  def initialize
19
20
  @simple_parser = SimpleParser.new
@@ -30,108 +31,15 @@ module DecisionAgent
30
31
  # @param field_name [String] The field name being evaluated
31
32
  # @param context [Hash] Evaluation context
32
33
  # @return [Boolean] Evaluation result
33
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
34
34
  def evaluate(expression, field_name, context)
35
35
  return true if expression == "-" # DMN "don't care" marker
36
36
 
37
37
  # Try Parslet parser first (Phase 2B)
38
- if @use_parslet
39
- begin
40
- expr_key = expression.to_s.strip
41
-
42
- # Check AST cache first
43
- ast = @cache_mutex.synchronize do
44
- @ast_cache[expr_key]
45
- end
46
-
47
- if ast.nil?
48
- parse_tree = @parslet_parser.parse(expr_key)
49
- ast = @transformer.apply(parse_tree)
50
- @cache_mutex.synchronize do
51
- @ast_cache[expr_key] = ast
52
- end
53
- end
54
-
55
- result = evaluate_ast_node(ast, context)
56
- # If result is nil and AST is a simple field reference that doesn't exist in context,
57
- # fall back to Phase 2A approach to return condition structure
58
- unless result.nil? && ast.is_a?(Hash) && ast[:type] == :field &&
59
- !context.key?(ast[:name]) && !context.key?(ast[:name].to_s) &&
60
- !context.key?(ast[:name].to_sym)
61
- return result
62
- end
63
- # Fall through to Phase 2A
64
- rescue Parslet::ParseFailed, FeelParseError, FeelTransformError => e
65
- # Fall back to Phase 2A approach
66
- warn "Parslet parse failed: #{e.message}, falling back" if ENV["DEBUG_FEEL"]
67
- end
68
- end
38
+ parslet_result = try_parslet_evaluate(expression, context) if @use_parslet
39
+ return parslet_result unless parslet_result == :fallback
69
40
 
70
41
  # Phase 2A approach: use condition structures
71
- # Check cache first (thread-safe)
72
- cache_key = "#{expression}::#{field_name}"
73
- condition = @cache_mutex.synchronize do
74
- @cache[cache_key]
75
- end
76
-
77
- return Dsl::ConditionEvaluator.evaluate(condition, context) if condition
78
-
79
- # Parse and translate expression to condition structure
80
- expr_str = expression.to_s.strip
81
-
82
- # Check if expression matches any known pattern that can be successfully parsed
83
- is_supported = literal?(expr_str) ||
84
- comparison_expression?(expr_str) ||
85
- list_expression?(expr_str) ||
86
- range_expression?(expr_str)
87
-
88
- # For SimpleParser, check if it can actually parse successfully
89
- if SimpleParser.can_parse?(expr_str)
90
- begin
91
- @simple_parser.parse(expr_str)
92
- is_supported = true
93
- rescue FeelParseError
94
- # SimpleParser says it can parse, but actually can't - not supported
95
- is_supported = false
96
- end
97
- end
98
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
99
-
100
- condition = parse_expression_to_condition(expression, field_name, context)
101
-
102
- # If parse_expression_to_condition returned nil, create default condition structure
103
- unless condition.is_a?(Hash)
104
- condition = {
105
- "field" => field_name,
106
- "op" => "eq",
107
- "value" => parse_value(expr_str)
108
- }
109
- end
110
-
111
- # Store in cache (thread-safe)
112
- @cache_mutex.synchronize do
113
- @cache[cache_key] = condition
114
- end
115
-
116
- # For completely unsupported expressions (no patterns matched), return condition structure
117
- # This allows fallback to literal equality for unknown syntax
118
- return condition unless is_supported
119
-
120
- # Delegate to existing ConditionEvaluator for supported expressions
121
- evaluation_result = Dsl::ConditionEvaluator.evaluate(condition, context)
122
-
123
- # If evaluation returns false for a simple equality check and the field doesn't exist in context,
124
- # treat as unsupported expression and return condition structure (fallback behavior)
125
- if evaluation_result == false && condition["op"] == "eq"
126
- field_key = condition["field"]
127
- field_exists = context.key?(field_key) || context.key?(field_key.to_s) || context.key?(field_key.to_sym)
128
- return condition unless field_exists
129
- end
130
-
131
- # If evaluation returns nil, return condition structure as fallback
132
- return condition if evaluation_result.nil?
133
-
134
- evaluation_result
42
+ evaluate_phase2a(expression, field_name, context)
135
43
  end
136
44
 
137
45
  # Parse FEEL expression into operator and value (for internal use by Adapter)
@@ -167,6 +75,93 @@ module DecisionAgent
167
75
 
168
76
  private
169
77
 
78
+ # Attempt evaluation via Parslet parser (Phase 2B)
79
+ # Returns :fallback if Parslet cannot handle this expression
80
+ def try_parslet_evaluate(expression, context)
81
+ ast = cached_parslet_ast(expression.to_s.strip)
82
+ result = evaluate_ast_node(ast, context)
83
+
84
+ # If result is nil for a missing field reference, fall back to Phase 2A
85
+ return :fallback if result.nil? && unresolved_field_reference?(ast, context)
86
+
87
+ result
88
+ rescue Parslet::ParseFailed, FeelParseError, FeelTransformError => e
89
+ warn "Parslet parse failed: #{e.message}, falling back" if ENV["DEBUG_FEEL"]
90
+ :fallback
91
+ end
92
+
93
+ # Fetch or build Parslet AST with thread-safe caching
94
+ def cached_parslet_ast(expr_key)
95
+ ast = @cache_mutex.synchronize { @ast_cache[expr_key] }
96
+ return ast if ast
97
+
98
+ parse_tree = @parslet_parser.parse(expr_key)
99
+ ast = @transformer.apply(parse_tree)
100
+ @cache_mutex.synchronize { @ast_cache[expr_key] = ast }
101
+ ast
102
+ end
103
+
104
+ def unresolved_field_reference?(ast, context)
105
+ ast.is_a?(Hash) && ast[:type] == :field &&
106
+ !context.key?(ast[:name]) && !context.key?(ast[:name].to_s) &&
107
+ !context.key?(ast[:name].to_sym)
108
+ end
109
+
110
+ # Phase 2A evaluation: regex-based patterns with condition structures
111
+ def evaluate_phase2a(expression, field_name, context)
112
+ cache_key = "#{expression}::#{field_name}"
113
+ condition = @cache_mutex.synchronize { @cache[cache_key] }
114
+ return Dsl::ConditionEvaluator.evaluate(condition, context) if condition
115
+
116
+ expr_str = expression.to_s.strip
117
+ is_supported = expression_supported?(expr_str)
118
+ condition = build_condition(expression, field_name, expr_str, context)
119
+
120
+ @cache_mutex.synchronize { @cache[cache_key] = condition }
121
+
122
+ return condition unless is_supported
123
+
124
+ evaluate_with_fallback(condition, context)
125
+ end
126
+
127
+ # Check if expression matches any known parseable pattern
128
+ def expression_supported?(expr_str)
129
+ supported = literal?(expr_str) || comparison_expression?(expr_str) ||
130
+ list_expression?(expr_str) || range_expression?(expr_str)
131
+
132
+ return supported unless SimpleParser.can_parse?(expr_str)
133
+
134
+ @simple_parser.parse(expr_str)
135
+ true
136
+ rescue FeelParseError
137
+ false
138
+ end
139
+
140
+ # Build a condition structure from expression, with fallback to default equality
141
+ def build_condition(expression, field_name, expr_str, context)
142
+ condition = parse_expression_to_condition(expression, field_name, context)
143
+ return condition if condition.is_a?(Hash)
144
+
145
+ { "field" => field_name, "op" => "eq", "value" => parse_value(expr_str) }
146
+ end
147
+
148
+ # Evaluate condition with fallback to condition structure when result is ambiguous
149
+ def evaluate_with_fallback(condition, context)
150
+ evaluation_result = Dsl::ConditionEvaluator.evaluate(condition, context)
151
+
152
+ return condition if evaluation_result.nil?
153
+ return condition if equality_on_missing_field?(evaluation_result, condition, context)
154
+
155
+ evaluation_result
156
+ end
157
+
158
+ def equality_on_missing_field?(result, condition, context)
159
+ return false unless result == false && condition["op"] == "eq"
160
+
161
+ field_key = condition["field"]
162
+ !context.key?(field_key) && !context.key?(field_key.to_s) && !context.key?(field_key.to_sym)
163
+ end
164
+
170
165
  def literal?(expr)
171
166
  # Quoted string
172
167
  return true if expr.start_with?('"') && expr.end_with?('"')
@@ -497,7 +492,6 @@ module DecisionAgent
497
492
  end
498
493
 
499
494
  # Evaluate Parslet AST node (Phase 2B - full FEEL support)
500
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
501
495
  def evaluate_ast_node(node, context)
502
496
  return node unless node.is_a?(Hash)
503
497
 
@@ -553,7 +547,6 @@ module DecisionAgent
553
547
  raise FeelEvaluationError, "Unknown AST node type: #{node[:type]}"
554
548
  end
555
549
  end
556
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
557
550
 
558
551
  # Get field value from context
559
552
  def get_field_value(field_name, context)
@@ -595,22 +588,20 @@ module DecisionAgent
595
588
 
596
589
  # Evaluate function call
597
590
  def evaluate_function_call(node, context)
598
- # Extract function name - could be a string or a field node
599
- function_name = if node[:name].is_a?(Hash)
600
- if node[:name][:type] == :field
601
- node[:name][:name]
602
- else
603
- node[:name][:name] || node[:name][:identifier] || node[:name].to_s
604
- end
605
- else
606
- node[:name]
607
- end
608
-
591
+ function_name = extract_function_name(node[:name])
609
592
  args = Array(node[:arguments]).map { |arg| evaluate_ast_node(arg, context) }
610
593
 
611
594
  Functions.execute(function_name.to_s, args, context)
612
595
  end
613
596
 
597
+ # Extract function name from a string or structured field node
598
+ def extract_function_name(name_node)
599
+ return name_node unless name_node.is_a?(Hash)
600
+ return name_node[:name] if name_node[:type] == :field
601
+
602
+ name_node[:name] || name_node[:identifier] || name_node.to_s
603
+ end
604
+
614
605
  # Evaluate property access
615
606
  def evaluate_property_access(node, context)
616
607
  object = evaluate_ast_node(node[:object], context)
@@ -622,7 +613,7 @@ module DecisionAgent
622
613
  when Types::Context
623
614
  object[property.to_sym]
624
615
  else
625
- object.respond_to?(property) ? object.send(property) : nil
616
+ object.respond_to?(property) ? object.public_send(property) : nil
626
617
  end
627
618
  end
628
619
 
@@ -813,7 +804,6 @@ module DecisionAgent
813
804
  start_check && end_check
814
805
  end
815
806
  end
816
- # rubocop:enable Metrics/ClassLength
817
807
  end
818
808
  end
819
809
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "../errors"
2
4
  require_relative "types"
3
5
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "parslet"
2
4
  require_relative "../errors"
3
5