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.
- checksums.yaml +4 -4
- data/LICENSE.txt +0 -0
- data/README.md +64 -108
- data/lib/decision_agent/ab_testing/ab_test.rb +5 -1
- data/lib/decision_agent/ab_testing/ab_test_assignment.rb +2 -0
- data/lib/decision_agent/ab_testing/ab_test_manager.rb +2 -0
- data/lib/decision_agent/ab_testing/ab_testing_agent.rb +2 -0
- data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +2 -16
- data/lib/decision_agent/ab_testing/storage/adapter.rb +2 -0
- data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +2 -0
- data/lib/decision_agent/agent.rb +49 -51
- data/lib/decision_agent/audit/adapter.rb +2 -0
- data/lib/decision_agent/audit/logger_adapter.rb +2 -0
- data/lib/decision_agent/audit/null_adapter.rb +2 -0
- data/lib/decision_agent/auth/access_audit_logger.rb +2 -0
- data/lib/decision_agent/auth/authenticator.rb +2 -0
- data/lib/decision_agent/auth/password_reset_manager.rb +2 -0
- data/lib/decision_agent/auth/password_reset_token.rb +2 -0
- data/lib/decision_agent/auth/permission.rb +2 -0
- data/lib/decision_agent/auth/permission_checker.rb +2 -0
- data/lib/decision_agent/auth/rbac_adapter.rb +2 -0
- data/lib/decision_agent/auth/rbac_config.rb +2 -0
- data/lib/decision_agent/auth/role.rb +2 -0
- data/lib/decision_agent/auth/session.rb +2 -0
- data/lib/decision_agent/auth/session_manager.rb +2 -0
- data/lib/decision_agent/auth/user.rb +2 -0
- data/lib/decision_agent/context.rb +13 -0
- data/lib/decision_agent/decision.rb +11 -2
- data/lib/decision_agent/dmn/adapter.rb +2 -0
- data/lib/decision_agent/dmn/cache.rb +2 -2
- data/lib/decision_agent/dmn/decision_graph.rb +7 -7
- data/lib/decision_agent/dmn/decision_tree.rb +16 -8
- data/lib/decision_agent/dmn/errors.rb +2 -0
- data/lib/decision_agent/dmn/exporter.rb +43 -2
- data/lib/decision_agent/dmn/feel/evaluator.rb +102 -112
- data/lib/decision_agent/dmn/feel/functions.rb +2 -0
- data/lib/decision_agent/dmn/feel/parser.rb +2 -0
- data/lib/decision_agent/dmn/feel/simple_parser.rb +98 -77
- data/lib/decision_agent/dmn/feel/transformer.rb +56 -102
- data/lib/decision_agent/dmn/feel/types.rb +2 -0
- data/lib/decision_agent/dmn/importer.rb +2 -0
- data/lib/decision_agent/dmn/model.rb +2 -4
- data/lib/decision_agent/dmn/parser.rb +2 -0
- data/lib/decision_agent/dmn/testing.rb +3 -6
- data/lib/decision_agent/dmn/validator.rb +8 -10
- data/lib/decision_agent/dmn/versioning.rb +41 -15
- data/lib/decision_agent/dmn/visualizer.rb +7 -6
- data/lib/decision_agent/dsl/condition_evaluator.rb +197 -1473
- data/lib/decision_agent/dsl/helpers/cache_helpers.rb +82 -0
- data/lib/decision_agent/dsl/helpers/comparison_helpers.rb +98 -0
- data/lib/decision_agent/dsl/helpers/date_helpers.rb +91 -0
- data/lib/decision_agent/dsl/helpers/geospatial_helpers.rb +85 -0
- data/lib/decision_agent/dsl/helpers/operator_evaluation_helpers.rb +160 -0
- data/lib/decision_agent/dsl/helpers/parameter_parsing_helpers.rb +206 -0
- data/lib/decision_agent/dsl/helpers/template_helpers.rb +39 -0
- data/lib/decision_agent/dsl/helpers/utility_helpers.rb +45 -0
- data/lib/decision_agent/dsl/operators/base.rb +70 -0
- data/lib/decision_agent/dsl/operators/basic_comparison_operators.rb +80 -0
- data/lib/decision_agent/dsl/operators/collection_operators.rb +60 -0
- data/lib/decision_agent/dsl/operators/date_arithmetic_operators.rb +206 -0
- data/lib/decision_agent/dsl/operators/date_time_operators.rb +47 -0
- data/lib/decision_agent/dsl/operators/duration_operators.rb +149 -0
- data/lib/decision_agent/dsl/operators/financial_operators.rb +237 -0
- data/lib/decision_agent/dsl/operators/geospatial_operators.rb +106 -0
- data/lib/decision_agent/dsl/operators/mathematical_operators.rb +234 -0
- data/lib/decision_agent/dsl/operators/moving_window_operators.rb +135 -0
- data/lib/decision_agent/dsl/operators/numeric_operators.rb +120 -0
- data/lib/decision_agent/dsl/operators/rate_operators.rb +65 -0
- data/lib/decision_agent/dsl/operators/statistical_aggregations.rb +187 -0
- data/lib/decision_agent/dsl/operators/string_aggregations.rb +84 -0
- data/lib/decision_agent/dsl/operators/string_operators.rb +72 -0
- data/lib/decision_agent/dsl/operators/time_component_operators.rb +72 -0
- data/lib/decision_agent/dsl/rule_parser.rb +2 -0
- data/lib/decision_agent/dsl/schema_validator.rb +9 -24
- data/lib/decision_agent/errors.rb +2 -0
- data/lib/decision_agent/evaluation.rb +14 -2
- data/lib/decision_agent/evaluation_validator.rb +0 -0
- data/lib/decision_agent/evaluators/base.rb +2 -0
- data/lib/decision_agent/evaluators/dmn_evaluator.rb +2 -0
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +28 -41
- data/lib/decision_agent/evaluators/static_evaluator.rb +2 -0
- data/lib/decision_agent/explainability/condition_trace.rb +2 -0
- data/lib/decision_agent/explainability/explainability_result.rb +2 -4
- data/lib/decision_agent/explainability/rule_trace.rb +2 -0
- data/lib/decision_agent/explainability/trace_collector.rb +2 -0
- data/lib/decision_agent/monitoring/alert_manager.rb +2 -15
- data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +0 -0
- data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +0 -0
- data/lib/decision_agent/monitoring/dashboard/public/index.html +0 -0
- data/lib/decision_agent/monitoring/dashboard_server.rb +383 -250
- data/lib/decision_agent/monitoring/metrics_collector.rb +2 -0
- data/lib/decision_agent/monitoring/monitored_agent.rb +2 -0
- data/lib/decision_agent/monitoring/prometheus_exporter.rb +3 -1
- data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +0 -0
- data/lib/decision_agent/monitoring/storage/base_adapter.rb +0 -0
- data/lib/decision_agent/monitoring/storage/memory_adapter.rb +1 -1
- data/lib/decision_agent/replay/replay.rb +4 -1
- data/lib/decision_agent/scoring/base.rb +2 -0
- data/lib/decision_agent/scoring/consensus.rb +2 -0
- data/lib/decision_agent/scoring/max_weight.rb +2 -0
- data/lib/decision_agent/scoring/threshold.rb +2 -0
- data/lib/decision_agent/scoring/weighted_average.rb +2 -0
- data/lib/decision_agent/simulation/errors.rb +2 -0
- data/lib/decision_agent/simulation/impact_analyzer.rb +3 -3
- data/lib/decision_agent/simulation/monte_carlo_simulator.rb +11 -10
- data/lib/decision_agent/simulation/replay_engine.rb +3 -3
- data/lib/decision_agent/simulation/scenario_engine.rb +3 -1
- data/lib/decision_agent/simulation/scenario_library.rb +2 -0
- data/lib/decision_agent/simulation/shadow_test_engine.rb +3 -16
- data/lib/decision_agent/simulation/what_if_analyzer.rb +17 -13
- data/lib/decision_agent/simulation.rb +2 -0
- data/lib/decision_agent/testing/batch_test_importer.rb +6 -6
- data/lib/decision_agent/testing/batch_test_runner.rb +5 -4
- data/lib/decision_agent/testing/test_coverage_analyzer.rb +2 -0
- data/lib/decision_agent/testing/test_result_comparator.rb +56 -63
- data/lib/decision_agent/testing/test_scenario.rb +2 -0
- data/lib/decision_agent/version.rb +3 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +159 -47
- data/lib/decision_agent/versioning/adapter.rb +42 -0
- data/lib/decision_agent/versioning/file_storage_adapter.rb +96 -13
- data/lib/decision_agent/versioning/version_manager.rb +49 -2
- data/lib/decision_agent/web/dmn_editor/serialization.rb +74 -0
- data/lib/decision_agent/web/dmn_editor/xml_builder.rb +107 -0
- data/lib/decision_agent/web/dmn_editor.rb +8 -73
- data/lib/decision_agent/web/middleware/auth_middleware.rb +2 -0
- data/lib/decision_agent/web/middleware/permission_middleware.rb +3 -1
- data/lib/decision_agent/web/public/app.js +67 -26
- data/lib/decision_agent/web/public/batch_testing.html +80 -6
- data/lib/decision_agent/web/public/dmn-editor.css +0 -0
- data/lib/decision_agent/web/public/dmn-editor.html +2 -2
- data/lib/decision_agent/web/public/dmn-editor.js +79 -8
- data/lib/decision_agent/web/public/index.html +20 -3
- data/lib/decision_agent/web/public/login.html +1 -1
- data/lib/decision_agent/web/public/sample_batch.csv +11 -0
- data/lib/decision_agent/web/public/sample_impact.csv +11 -0
- data/lib/decision_agent/web/public/sample_replay.csv +11 -0
- data/lib/decision_agent/web/public/sample_rules.json +118 -0
- data/lib/decision_agent/web/public/sample_shadow.csv +11 -0
- data/lib/decision_agent/web/public/sample_whatif.csv +11 -0
- data/lib/decision_agent/web/public/simulation.html +23 -7
- data/lib/decision_agent/web/public/simulation_impact.html +37 -20
- data/lib/decision_agent/web/public/simulation_replay.html +19 -23
- data/lib/decision_agent/web/public/simulation_shadow.html +36 -21
- data/lib/decision_agent/web/public/simulation_whatif.html +38 -21
- data/lib/decision_agent/web/public/styles.css +0 -0
- data/lib/decision_agent/web/public/users.html +1 -1
- data/lib/decision_agent/web/rack_helpers.rb +106 -0
- data/lib/decision_agent/web/rack_request_helpers.rb +196 -0
- data/lib/decision_agent/web/server.rb +2038 -1851
- data/lib/decision_agent.rb +3 -43
- data/lib/generators/decision_agent/install/install_generator.rb +2 -0
- data/lib/generators/decision_agent/install/templates/README +0 -0
- data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +2 -0
- data/lib/generators/decision_agent/install/templates/ab_test_model.rb +2 -0
- data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +2 -0
- data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +0 -0
- data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +0 -0
- data/lib/generators/decision_agent/install/templates/decision_log.rb +0 -0
- data/lib/generators/decision_agent/install/templates/error_metric.rb +0 -0
- data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +0 -0
- data/lib/generators/decision_agent/install/templates/migration.rb +16 -0
- data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +0 -2
- data/lib/generators/decision_agent/install/templates/performance_metric.rb +0 -0
- data/lib/generators/decision_agent/install/templates/rule.rb +2 -0
- data/lib/generators/decision_agent/install/templates/rule_version.rb +2 -0
- data/lib/generators/decision_agent/install/templates/rule_version_tag.rb +23 -0
- data/lib/generators/decision_agent/install/templates/versioning_migration.rb +44 -0
- data/lib/generators/decision_agent/monitoring_migration/monitoring_migration_generator.rb +67 -0
- data/lib/generators/decision_agent/versioning_migration/versioning_migration_generator.rb +57 -0
- metadata +66 -25
- data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +0 -86
- data/lib/decision_agent/data_enrichment/cache_adapter.rb +0 -49
- data/lib/decision_agent/data_enrichment/circuit_breaker.rb +0 -135
- data/lib/decision_agent/data_enrichment/client.rb +0 -220
- data/lib/decision_agent/data_enrichment/config.rb +0 -78
- data/lib/decision_agent/data_enrichment/errors.rb +0 -36
|
@@ -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
|
-
|
|
139
|
-
raise InvalidConfidenceError, confidence unless
|
|
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,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|