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.
- checksums.yaml +4 -4
- data/README.md +313 -8
- data/bin/decision_agent +104 -0
- data/lib/decision_agent/agent.rb +72 -1
- data/lib/decision_agent/context.rb +1 -0
- data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +86 -0
- data/lib/decision_agent/data_enrichment/cache_adapter.rb +49 -0
- data/lib/decision_agent/data_enrichment/circuit_breaker.rb +135 -0
- data/lib/decision_agent/data_enrichment/client.rb +220 -0
- data/lib/decision_agent/data_enrichment/config.rb +78 -0
- data/lib/decision_agent/data_enrichment/errors.rb +36 -0
- data/lib/decision_agent/decision.rb +102 -2
- data/lib/decision_agent/dmn/adapter.rb +135 -0
- data/lib/decision_agent/dmn/cache.rb +306 -0
- data/lib/decision_agent/dmn/decision_graph.rb +327 -0
- data/lib/decision_agent/dmn/decision_tree.rb +192 -0
- data/lib/decision_agent/dmn/errors.rb +30 -0
- data/lib/decision_agent/dmn/exporter.rb +217 -0
- data/lib/decision_agent/dmn/feel/evaluator.rb +819 -0
- data/lib/decision_agent/dmn/feel/functions.rb +420 -0
- data/lib/decision_agent/dmn/feel/parser.rb +349 -0
- data/lib/decision_agent/dmn/feel/simple_parser.rb +276 -0
- data/lib/decision_agent/dmn/feel/transformer.rb +372 -0
- data/lib/decision_agent/dmn/feel/types.rb +276 -0
- data/lib/decision_agent/dmn/importer.rb +77 -0
- data/lib/decision_agent/dmn/model.rb +197 -0
- data/lib/decision_agent/dmn/parser.rb +191 -0
- data/lib/decision_agent/dmn/testing.rb +333 -0
- data/lib/decision_agent/dmn/validator.rb +315 -0
- data/lib/decision_agent/dmn/versioning.rb +229 -0
- data/lib/decision_agent/dmn/visualizer.rb +513 -0
- data/lib/decision_agent/dsl/condition_evaluator.rb +984 -838
- data/lib/decision_agent/dsl/schema_validator.rb +53 -14
- data/lib/decision_agent/evaluators/dmn_evaluator.rb +308 -0
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +69 -9
- data/lib/decision_agent/explainability/condition_trace.rb +83 -0
- data/lib/decision_agent/explainability/explainability_result.rb +52 -0
- data/lib/decision_agent/explainability/rule_trace.rb +39 -0
- data/lib/decision_agent/explainability/trace_collector.rb +24 -0
- data/lib/decision_agent/monitoring/alert_manager.rb +5 -1
- data/lib/decision_agent/simulation/errors.rb +18 -0
- data/lib/decision_agent/simulation/impact_analyzer.rb +498 -0
- data/lib/decision_agent/simulation/monte_carlo_simulator.rb +635 -0
- data/lib/decision_agent/simulation/replay_engine.rb +486 -0
- data/lib/decision_agent/simulation/scenario_engine.rb +318 -0
- data/lib/decision_agent/simulation/scenario_library.rb +163 -0
- data/lib/decision_agent/simulation/shadow_test_engine.rb +287 -0
- data/lib/decision_agent/simulation/what_if_analyzer.rb +1002 -0
- data/lib/decision_agent/simulation.rb +17 -0
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +23 -8
- data/lib/decision_agent/web/dmn_editor.rb +426 -0
- data/lib/decision_agent/web/public/app.js +119 -0
- data/lib/decision_agent/web/public/dmn-editor.css +596 -0
- data/lib/decision_agent/web/public/dmn-editor.html +250 -0
- data/lib/decision_agent/web/public/dmn-editor.js +553 -0
- data/lib/decision_agent/web/public/index.html +52 -0
- data/lib/decision_agent/web/public/simulation.html +130 -0
- data/lib/decision_agent/web/public/simulation_impact.html +478 -0
- data/lib/decision_agent/web/public/simulation_replay.html +551 -0
- data/lib/decision_agent/web/public/simulation_shadow.html +546 -0
- data/lib/decision_agent/web/public/simulation_whatif.html +532 -0
- data/lib/decision_agent/web/public/styles.css +86 -0
- data/lib/decision_agent/web/server.rb +1059 -23
- data/lib/decision_agent.rb +60 -2
- metadata +105 -61
- data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
- data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
- data/spec/ab_testing/ab_test_spec.rb +0 -270
- data/spec/ab_testing/ab_testing_agent_spec.rb +0 -481
- data/spec/ab_testing/storage/adapter_spec.rb +0 -64
- data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
- data/spec/activerecord_thread_safety_spec.rb +0 -553
- data/spec/advanced_operators_spec.rb +0 -3150
- data/spec/agent_spec.rb +0 -289
- data/spec/api_contract_spec.rb +0 -430
- data/spec/audit_adapters_spec.rb +0 -92
- data/spec/auth/access_audit_logger_spec.rb +0 -394
- data/spec/auth/authenticator_spec.rb +0 -112
- data/spec/auth/password_reset_spec.rb +0 -294
- data/spec/auth/permission_checker_spec.rb +0 -207
- data/spec/auth/permission_spec.rb +0 -73
- data/spec/auth/rbac_adapter_spec.rb +0 -550
- data/spec/auth/rbac_config_spec.rb +0 -82
- data/spec/auth/role_spec.rb +0 -51
- data/spec/auth/session_manager_spec.rb +0 -172
- data/spec/auth/session_spec.rb +0 -112
- data/spec/auth/user_spec.rb +0 -130
- data/spec/comprehensive_edge_cases_spec.rb +0 -1777
- data/spec/context_spec.rb +0 -127
- data/spec/decision_agent_spec.rb +0 -96
- data/spec/decision_spec.rb +0 -423
- data/spec/dsl/condition_evaluator_spec.rb +0 -774
- data/spec/dsl_validation_spec.rb +0 -648
- data/spec/edge_cases_spec.rb +0 -353
- data/spec/evaluation_spec.rb +0 -364
- data/spec/evaluation_validator_spec.rb +0 -165
- data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
- data/spec/examples.txt +0 -1633
- data/spec/issue_verification_spec.rb +0 -759
- data/spec/json_rule_evaluator_spec.rb +0 -587
- data/spec/monitoring/alert_manager_spec.rb +0 -378
- data/spec/monitoring/metrics_collector_spec.rb +0 -499
- data/spec/monitoring/monitored_agent_spec.rb +0 -222
- data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
- data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
- data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
- data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
- data/spec/performance_optimizations_spec.rb +0 -486
- data/spec/replay_edge_cases_spec.rb +0 -699
- data/spec/replay_spec.rb +0 -210
- data/spec/rfc8785_canonicalization_spec.rb +0 -215
- data/spec/scoring_spec.rb +0 -225
- data/spec/spec_helper.rb +0 -60
- data/spec/testing/batch_test_importer_spec.rb +0 -693
- data/spec/testing/batch_test_runner_spec.rb +0 -307
- data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
- data/spec/testing/test_result_comparator_spec.rb +0 -392
- data/spec/testing/test_scenario_spec.rb +0 -113
- data/spec/thread_safety_spec.rb +0 -482
- data/spec/thread_safety_spec.rb.broken +0 -878
- data/spec/versioning/adapter_spec.rb +0 -156
- data/spec/versioning_spec.rb +0 -1030
- data/spec/web/middleware/auth_middleware_spec.rb +0 -133
- data/spec/web/middleware/permission_middleware_spec.rb +0 -247
- data/spec/web_ui_rack_spec.rb +0 -1840
|
@@ -20,6 +20,7 @@ module DecisionAgent
|
|
|
20
20
|
join length
|
|
21
21
|
contains_all contains_any intersects subset_of
|
|
22
22
|
within_radius in_polygon
|
|
23
|
+
fetch_from_api
|
|
23
24
|
].freeze
|
|
24
25
|
|
|
25
26
|
CONDITION_TYPES = %w[all any field].freeze
|
|
@@ -162,9 +163,10 @@ module DecisionAgent
|
|
|
162
163
|
end
|
|
163
164
|
|
|
164
165
|
def validate_field_condition(condition, path)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
166
|
+
# Use key? to properly handle false values (|| would treat false as falsy)
|
|
167
|
+
field = extract_key_value(condition, "field", :field)
|
|
168
|
+
operator = extract_key_value(condition, "op", :op)
|
|
169
|
+
value = extract_key_value(condition, "value", :value)
|
|
168
170
|
|
|
169
171
|
# Validate field
|
|
170
172
|
@errors << "#{path}: Field condition missing 'field' key" unless field
|
|
@@ -176,14 +178,24 @@ module DecisionAgent
|
|
|
176
178
|
end
|
|
177
179
|
|
|
178
180
|
validate_operator(operator, path)
|
|
181
|
+
validate_field_condition_value(operator, value, path)
|
|
182
|
+
validate_fetch_from_api_value(value, path) if (operator.to_s == "fetch_from_api") && value
|
|
183
|
+
validate_field_path(field, path) if field
|
|
184
|
+
end
|
|
179
185
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
end
|
|
186
|
+
def extract_key_value(hash, string_key, symbol_key)
|
|
187
|
+
return hash[string_key] if hash.key?(string_key)
|
|
188
|
+
return hash[symbol_key] if hash.key?(symbol_key)
|
|
184
189
|
|
|
185
|
-
|
|
186
|
-
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def validate_field_condition_value(operator, value, path)
|
|
194
|
+
# Validate value (not required for 'present', 'blank', and 'fetch_from_api' has special validation)
|
|
195
|
+
return if %w[present blank fetch_from_api].include?(operator.to_s)
|
|
196
|
+
return unless value.nil?
|
|
197
|
+
|
|
198
|
+
@errors << "#{path}: Field condition missing 'value' key for operator '#{operator}'"
|
|
187
199
|
end
|
|
188
200
|
|
|
189
201
|
def validate_operator(operator, path)
|
|
@@ -253,12 +265,20 @@ module DecisionAgent
|
|
|
253
265
|
return
|
|
254
266
|
end
|
|
255
267
|
|
|
256
|
-
|
|
257
|
-
|
|
268
|
+
validate_then_clause_decision(then_clause, rule_path)
|
|
269
|
+
validate_then_clause_weight(then_clause, rule_path)
|
|
270
|
+
validate_then_clause_reason(then_clause, rule_path)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def validate_then_clause_decision(then_clause, rule_path)
|
|
274
|
+
# Use key? to properly handle false values (|| would treat false as falsy)
|
|
275
|
+
decision = extract_key_value(then_clause, "decision", :decision)
|
|
258
276
|
|
|
259
|
-
|
|
277
|
+
# Check if decision exists (including false and 0, but not nil)
|
|
278
|
+
@errors << "#{rule_path}.then: Missing required field 'decision'" if decision.nil?
|
|
279
|
+
end
|
|
260
280
|
|
|
261
|
-
|
|
281
|
+
def validate_then_clause_weight(then_clause, rule_path)
|
|
262
282
|
weight = then_clause["weight"] || then_clause[:weight]
|
|
263
283
|
|
|
264
284
|
if weight && !weight.is_a?(Numeric)
|
|
@@ -266,8 +286,9 @@ module DecisionAgent
|
|
|
266
286
|
elsif weight && (weight < 0.0 || weight > 1.0)
|
|
267
287
|
@errors << "#{rule_path}.then.weight: Must be between 0.0 and 1.0, got #{weight}"
|
|
268
288
|
end
|
|
289
|
+
end
|
|
269
290
|
|
|
270
|
-
|
|
291
|
+
def validate_then_clause_reason(then_clause, rule_path)
|
|
271
292
|
reason = then_clause["reason"] || then_clause[:reason]
|
|
272
293
|
|
|
273
294
|
return unless reason && !reason.is_a?(String)
|
|
@@ -275,6 +296,24 @@ module DecisionAgent
|
|
|
275
296
|
@errors << "#{rule_path}.then.reason: Must be a string, got #{reason.class}"
|
|
276
297
|
end
|
|
277
298
|
|
|
299
|
+
def validate_fetch_from_api_value(value, path)
|
|
300
|
+
unless value.is_a?(Hash)
|
|
301
|
+
@errors << "#{path}: 'fetch_from_api' operator requires 'value' to be a hash with 'endpoint', 'params', and optional 'mapping'"
|
|
302
|
+
return
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
endpoint = value["endpoint"] || value[:endpoint]
|
|
306
|
+
@errors << "#{path}: 'fetch_from_api' operator requires 'endpoint' in value hash" unless endpoint
|
|
307
|
+
|
|
308
|
+
params = value["params"] || value[:params]
|
|
309
|
+
@errors << "#{path}: 'fetch_from_api' operator 'params' must be a hash if provided" unless params.nil? || params.is_a?(Hash)
|
|
310
|
+
|
|
311
|
+
mapping = value["mapping"] || value[:mapping]
|
|
312
|
+
return if mapping.nil? || mapping.is_a?(Hash)
|
|
313
|
+
|
|
314
|
+
@errors << "#{path}: 'fetch_from_api' operator 'mapping' must be a hash if provided"
|
|
315
|
+
end
|
|
316
|
+
|
|
278
317
|
def format_errors
|
|
279
318
|
header = "Rule DSL validation failed with #{@errors.size} error#{'s' if @errors.size > 1}:\n\n"
|
|
280
319
|
numbered_errors = @errors.map.with_index { |err, idx| " #{idx + 1}. #{err}" }.join("\n")
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
require_relative "../dmn/adapter"
|
|
2
|
+
require_relative "../dmn/errors"
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "json_rule_evaluator"
|
|
5
|
+
|
|
6
|
+
module DecisionAgent
|
|
7
|
+
module Evaluators
|
|
8
|
+
# Evaluates DMN decision models
|
|
9
|
+
class DmnEvaluator < Base
|
|
10
|
+
attr_reader :model, :decision_id
|
|
11
|
+
|
|
12
|
+
def initialize(model:, decision_id:, name: nil)
|
|
13
|
+
@model = model
|
|
14
|
+
@decision_id = decision_id.to_s
|
|
15
|
+
@name = name || "DmnEvaluator(#{@decision_id})"
|
|
16
|
+
|
|
17
|
+
# Find and validate decision
|
|
18
|
+
@decision = @model.find_decision(@decision_id)
|
|
19
|
+
raise Dmn::InvalidDmnModelError, "Decision '#{@decision_id}' not found" unless @decision
|
|
20
|
+
raise Dmn::InvalidDmnModelError, "Decision '#{@decision_id}' has no decision table" unless @decision.decision_table
|
|
21
|
+
|
|
22
|
+
# Convert to JSON rules for execution
|
|
23
|
+
adapter = Dmn::Adapter.new(@decision.decision_table)
|
|
24
|
+
@rules_json = adapter.to_json_rules
|
|
25
|
+
|
|
26
|
+
# Create internal JSON rule evaluator
|
|
27
|
+
@json_evaluator = JsonRuleEvaluator.new(
|
|
28
|
+
rules_json: @rules_json,
|
|
29
|
+
name: @name
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Freeze for thread safety
|
|
33
|
+
@model.freeze
|
|
34
|
+
@decision_id.freeze
|
|
35
|
+
@name.freeze
|
|
36
|
+
freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def evaluate(context, feedback: {})
|
|
40
|
+
hit_policy = @decision.decision_table.hit_policy
|
|
41
|
+
|
|
42
|
+
# Collect explainability traces
|
|
43
|
+
explainability_result = collect_explainability(context, hit_policy)
|
|
44
|
+
|
|
45
|
+
# Short-circuit for FIRST and PRIORITY policies
|
|
46
|
+
if %w[FIRST PRIORITY].include?(hit_policy)
|
|
47
|
+
first_match = find_first_matching_evaluation(context, explainability_result: explainability_result)
|
|
48
|
+
return first_match if first_match
|
|
49
|
+
|
|
50
|
+
# If no match found, return nil (consistent with apply_first_policy behavior)
|
|
51
|
+
return nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# For UNIQUE, ANY, COLLECT - need all matches
|
|
55
|
+
matching_evaluations = find_all_matching_evaluations(context, explainability_result: explainability_result)
|
|
56
|
+
|
|
57
|
+
# Apply hit policy to select the appropriate evaluation
|
|
58
|
+
result = apply_hit_policy(matching_evaluations)
|
|
59
|
+
|
|
60
|
+
# Add explainability to metadata by creating new Evaluation with updated metadata
|
|
61
|
+
if result && explainability_result
|
|
62
|
+
metadata = result.metadata.dup
|
|
63
|
+
metadata[:explainability] = explainability_result.to_h
|
|
64
|
+
result = Evaluation.new(
|
|
65
|
+
decision: result.decision,
|
|
66
|
+
weight: result.weight,
|
|
67
|
+
reason: result.reason,
|
|
68
|
+
evaluator_name: result.evaluator_name,
|
|
69
|
+
metadata: metadata
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
result
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def evaluator_name
|
|
79
|
+
@name
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Collect explainability traces for DMN evaluation
|
|
83
|
+
def collect_explainability(context, hit_policy)
|
|
84
|
+
ctx = context.is_a?(Context) ? context : Context.new(context)
|
|
85
|
+
rules = @rules_json["rules"] || []
|
|
86
|
+
rule_traces = []
|
|
87
|
+
|
|
88
|
+
rules.each do |rule|
|
|
89
|
+
rule_id = rule["id"] || "rule_#{rules.index(rule)}"
|
|
90
|
+
if_clause = rule["if"]
|
|
91
|
+
next unless if_clause
|
|
92
|
+
|
|
93
|
+
# Create trace collector for this rule
|
|
94
|
+
trace_collector = Explainability::TraceCollector.new
|
|
95
|
+
|
|
96
|
+
# Evaluate condition with tracing
|
|
97
|
+
matched = Dsl::ConditionEvaluator.evaluate(
|
|
98
|
+
if_clause,
|
|
99
|
+
ctx,
|
|
100
|
+
trace_collector: trace_collector
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
then_clause = rule["then"] || {}
|
|
104
|
+
rule_trace = Explainability::RuleTrace.new(
|
|
105
|
+
rule_id: rule_id,
|
|
106
|
+
matched: matched,
|
|
107
|
+
condition_traces: trace_collector.traces,
|
|
108
|
+
decision: then_clause["decision"],
|
|
109
|
+
weight: then_clause["weight"],
|
|
110
|
+
reason: then_clause["reason"]
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
rule_traces << rule_trace
|
|
114
|
+
|
|
115
|
+
# Stop after first match for FIRST/PRIORITY policies (short-circuit evaluation)
|
|
116
|
+
break if matched && %w[FIRST PRIORITY].include?(hit_policy)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
Explainability::ExplainabilityResult.new(
|
|
120
|
+
evaluator_name: @name,
|
|
121
|
+
rule_traces: rule_traces
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Find first matching rule (for short-circuiting)
|
|
126
|
+
def find_first_matching_evaluation(context, explainability_result: nil, feedback: {})
|
|
127
|
+
ctx = context.is_a?(Context) ? context : Context.new(context)
|
|
128
|
+
rules = @rules_json["rules"] || []
|
|
129
|
+
|
|
130
|
+
rules.each do |rule|
|
|
131
|
+
if_clause = rule["if"]
|
|
132
|
+
next unless if_clause
|
|
133
|
+
|
|
134
|
+
# If explainability is already collected, use the trace data
|
|
135
|
+
matched = if explainability_result
|
|
136
|
+
rule_trace = explainability_result.rule_traces.find { |rt| rt.rule_id == (rule["id"] || "rule_#{rules.index(rule)}") }
|
|
137
|
+
rule_trace&.matched
|
|
138
|
+
else
|
|
139
|
+
Dsl::ConditionEvaluator.evaluate(if_clause, ctx)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
next unless matched
|
|
143
|
+
|
|
144
|
+
then_clause = rule["then"]
|
|
145
|
+
metadata = {
|
|
146
|
+
type: "dmn_rule",
|
|
147
|
+
rule_id: rule["id"],
|
|
148
|
+
ruleset: @rules_json["ruleset"],
|
|
149
|
+
hit_policy: @decision.decision_table.hit_policy
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
# Add explainability data to metadata
|
|
153
|
+
metadata[:explainability] = explainability_result.to_h if explainability_result
|
|
154
|
+
|
|
155
|
+
return Evaluation.new(
|
|
156
|
+
decision: then_clause["decision"],
|
|
157
|
+
weight: then_clause["weight"] || 1.0,
|
|
158
|
+
reason: then_clause["reason"] || "Rule matched",
|
|
159
|
+
evaluator_name: @name,
|
|
160
|
+
metadata: metadata
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Find all matching rules (not just first)
|
|
168
|
+
def find_all_matching_evaluations(context, explainability_result: nil, feedback: {})
|
|
169
|
+
ctx = context.is_a?(Context) ? context : Context.new(context)
|
|
170
|
+
rules = @rules_json["rules"] || []
|
|
171
|
+
matching = []
|
|
172
|
+
|
|
173
|
+
rules.each do |rule|
|
|
174
|
+
if_clause = rule["if"]
|
|
175
|
+
next unless if_clause
|
|
176
|
+
|
|
177
|
+
# If explainability is already collected, use the trace data
|
|
178
|
+
matched = if explainability_result
|
|
179
|
+
rule_trace = explainability_result.rule_traces.find { |rt| rt.rule_id == (rule["id"] || "rule_#{rules.index(rule)}") }
|
|
180
|
+
rule_trace&.matched
|
|
181
|
+
else
|
|
182
|
+
Dsl::ConditionEvaluator.evaluate(if_clause, ctx)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
next unless matched
|
|
186
|
+
|
|
187
|
+
then_clause = rule["then"]
|
|
188
|
+
metadata = {
|
|
189
|
+
type: "dmn_rule",
|
|
190
|
+
rule_id: rule["id"],
|
|
191
|
+
ruleset: @rules_json["ruleset"],
|
|
192
|
+
hit_policy: @decision.decision_table.hit_policy
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
# Add explainability data to metadata (will be added to final result)
|
|
196
|
+
# For now, we'll add it to the final result after hit policy is applied
|
|
197
|
+
|
|
198
|
+
matching << Evaluation.new(
|
|
199
|
+
decision: then_clause["decision"],
|
|
200
|
+
weight: then_clause["weight"] || 1.0,
|
|
201
|
+
reason: then_clause["reason"] || "Rule matched",
|
|
202
|
+
evaluator_name: @name,
|
|
203
|
+
metadata: metadata
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
matching
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Apply hit policy to matching evaluations
|
|
211
|
+
def apply_hit_policy(matching_evaluations)
|
|
212
|
+
hit_policy = @decision.decision_table.hit_policy
|
|
213
|
+
|
|
214
|
+
case hit_policy
|
|
215
|
+
when "UNIQUE"
|
|
216
|
+
apply_unique_policy(matching_evaluations)
|
|
217
|
+
when "FIRST"
|
|
218
|
+
apply_first_policy(matching_evaluations)
|
|
219
|
+
when "PRIORITY"
|
|
220
|
+
apply_priority_policy(matching_evaluations)
|
|
221
|
+
when "ANY"
|
|
222
|
+
apply_any_policy(matching_evaluations)
|
|
223
|
+
when "COLLECT"
|
|
224
|
+
apply_collect_policy(matching_evaluations)
|
|
225
|
+
else
|
|
226
|
+
# Default to FIRST if unknown policy
|
|
227
|
+
apply_first_policy(matching_evaluations)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# UNIQUE: Exactly one rule must match
|
|
232
|
+
def apply_unique_policy(matching_evaluations)
|
|
233
|
+
case matching_evaluations.size
|
|
234
|
+
when 0
|
|
235
|
+
raise Dmn::InvalidDmnModelError,
|
|
236
|
+
"UNIQUE hit policy requires exactly one matching rule, but none matched"
|
|
237
|
+
when 1
|
|
238
|
+
matching_evaluations.first
|
|
239
|
+
else
|
|
240
|
+
rule_ids = matching_evaluations.map { |e| e.metadata[:rule_id] }.join(", ")
|
|
241
|
+
raise Dmn::InvalidDmnModelError,
|
|
242
|
+
"UNIQUE hit policy requires exactly one matching rule, but #{matching_evaluations.size} matched: #{rule_ids}"
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# FIRST: Return first matching rule (already in order)
|
|
247
|
+
def apply_first_policy(matching_evaluations)
|
|
248
|
+
return nil if matching_evaluations.empty?
|
|
249
|
+
|
|
250
|
+
matching_evaluations.first
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# PRIORITY: Return rule with highest priority
|
|
254
|
+
# For now, we use rule order as priority (first rule = highest priority)
|
|
255
|
+
# In full DMN spec, outputs can have priority values defined
|
|
256
|
+
def apply_priority_policy(matching_evaluations)
|
|
257
|
+
return nil if matching_evaluations.empty?
|
|
258
|
+
|
|
259
|
+
# For now, return first match (rules are already in priority order)
|
|
260
|
+
# Future enhancement: check output priority values if defined
|
|
261
|
+
matching_evaluations.first
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# ANY: All matching rules must have same output
|
|
265
|
+
def apply_any_policy(matching_evaluations)
|
|
266
|
+
return nil if matching_evaluations.empty?
|
|
267
|
+
|
|
268
|
+
# Check that all decisions are the same
|
|
269
|
+
first_decision = matching_evaluations.first.decision
|
|
270
|
+
all_same = matching_evaluations.all? { |e| e.decision == first_decision }
|
|
271
|
+
|
|
272
|
+
unless all_same
|
|
273
|
+
decisions = matching_evaluations.map(&:decision).uniq.join(", ")
|
|
274
|
+
rule_ids = matching_evaluations.map { |e| e.metadata[:rule_id] }.join(", ")
|
|
275
|
+
raise Dmn::InvalidDmnModelError,
|
|
276
|
+
"ANY hit policy requires all matching rules to have the same output, " \
|
|
277
|
+
"but found different outputs: #{decisions} (rules: #{rule_ids})"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
matching_evaluations.first
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# COLLECT: Return all matching rules
|
|
284
|
+
# Since Evaluation expects a single decision, we'll return the first one
|
|
285
|
+
# but include metadata about all matches
|
|
286
|
+
def apply_collect_policy(matching_evaluations)
|
|
287
|
+
return nil if matching_evaluations.empty?
|
|
288
|
+
|
|
289
|
+
# Return first evaluation but include all matches in metadata
|
|
290
|
+
first = matching_evaluations.first
|
|
291
|
+
all_decisions = matching_evaluations.map(&:decision)
|
|
292
|
+
all_rule_ids = matching_evaluations.map { |e| e.metadata[:rule_id] }
|
|
293
|
+
|
|
294
|
+
Evaluation.new(
|
|
295
|
+
decision: first.decision,
|
|
296
|
+
weight: first.weight,
|
|
297
|
+
reason: "COLLECT: #{matching_evaluations.size} rules matched",
|
|
298
|
+
evaluator_name: @name,
|
|
299
|
+
metadata: first.metadata.merge(
|
|
300
|
+
collect_count: matching_evaluations.size,
|
|
301
|
+
collect_decisions: all_decisions,
|
|
302
|
+
collect_rule_ids: all_rule_ids
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
@@ -19,37 +19,97 @@ module DecisionAgent
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def evaluate(context, feedback: {})
|
|
22
|
-
ctx = context.is_a?(Context) ? context : Context.new(context)
|
|
22
|
+
ctx = context.is_a?(DecisionAgent::Context) ? context : DecisionAgent::Context.new(context)
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
# Collect explainability traces (this also finds the matching rule)
|
|
25
|
+
explainability_result = collect_explainability(ctx)
|
|
25
26
|
|
|
27
|
+
# Find the matched rule from explainability result
|
|
28
|
+
matched_rule_trace = explainability_result&.matched_rules&.first
|
|
29
|
+
return nil unless matched_rule_trace
|
|
30
|
+
|
|
31
|
+
# Find the original rule to get the then clause
|
|
32
|
+
rules = @ruleset["rules"] || []
|
|
33
|
+
matched_rule = rules.find { |r| (r["id"] || "rule_#{rules.index(r)}") == matched_rule_trace.rule_id }
|
|
26
34
|
return nil unless matched_rule
|
|
27
35
|
|
|
28
36
|
then_clause = matched_rule["then"]
|
|
29
37
|
|
|
38
|
+
metadata = {
|
|
39
|
+
type: "json_rule",
|
|
40
|
+
rule_id: matched_rule["id"],
|
|
41
|
+
ruleset: @ruleset_name
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Add explainability data to metadata
|
|
45
|
+
metadata[:explainability] = explainability_result.to_h if explainability_result
|
|
46
|
+
|
|
30
47
|
Evaluation.new(
|
|
31
48
|
decision: then_clause["decision"],
|
|
32
49
|
weight: then_clause["weight"] || 1.0,
|
|
33
50
|
reason: then_clause["reason"] || "Rule matched",
|
|
34
51
|
evaluator_name: @name,
|
|
35
|
-
metadata:
|
|
36
|
-
type: "json_rule",
|
|
37
|
-
rule_id: matched_rule["id"],
|
|
38
|
-
ruleset: @ruleset_name
|
|
39
|
-
}
|
|
52
|
+
metadata: metadata
|
|
40
53
|
)
|
|
41
54
|
end
|
|
42
55
|
|
|
43
56
|
private
|
|
44
57
|
|
|
45
|
-
def
|
|
58
|
+
def collect_explainability(context)
|
|
59
|
+
rules = @ruleset["rules"] || []
|
|
60
|
+
rule_traces = []
|
|
61
|
+
|
|
62
|
+
rules.each do |rule|
|
|
63
|
+
rule_id = rule["id"] || "rule_#{rules.index(rule)}"
|
|
64
|
+
if_clause = rule["if"]
|
|
65
|
+
next unless if_clause
|
|
66
|
+
|
|
67
|
+
# Create trace collector for this rule
|
|
68
|
+
trace_collector = Explainability::TraceCollector.new
|
|
69
|
+
|
|
70
|
+
# Evaluate condition with tracing
|
|
71
|
+
matched = Dsl::ConditionEvaluator.evaluate(
|
|
72
|
+
if_clause,
|
|
73
|
+
context,
|
|
74
|
+
trace_collector: trace_collector
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
then_clause = rule["then"] || {}
|
|
78
|
+
rule_trace = Explainability::RuleTrace.new(
|
|
79
|
+
rule_id: rule_id,
|
|
80
|
+
matched: matched,
|
|
81
|
+
condition_traces: trace_collector.traces,
|
|
82
|
+
decision: then_clause["decision"],
|
|
83
|
+
weight: then_clause["weight"],
|
|
84
|
+
reason: then_clause["reason"]
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
rule_traces << rule_trace
|
|
88
|
+
|
|
89
|
+
# Stop after first match (short-circuit evaluation)
|
|
90
|
+
break if matched
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
Explainability::ExplainabilityResult.new(
|
|
94
|
+
evaluator_name: @name,
|
|
95
|
+
rule_traces: rule_traces
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def find_first_matching_rule(context, explainability_result = nil)
|
|
46
100
|
rules = @ruleset["rules"] || []
|
|
47
101
|
|
|
48
102
|
rules.find do |rule|
|
|
49
103
|
if_clause = rule["if"]
|
|
50
104
|
next false unless if_clause
|
|
51
105
|
|
|
52
|
-
|
|
106
|
+
# If explainability is already collected, use the trace data
|
|
107
|
+
if explainability_result
|
|
108
|
+
rule_trace = explainability_result.rule_traces.find { |rt| rt.rule_id == (rule["id"] || "rule_#{rules.index(rule)}") }
|
|
109
|
+
rule_trace&.matched
|
|
110
|
+
else
|
|
111
|
+
Dsl::ConditionEvaluator.evaluate(if_clause, context)
|
|
112
|
+
end
|
|
53
113
|
end
|
|
54
114
|
end
|
|
55
115
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module Explainability
|
|
3
|
+
# Represents a single condition evaluation with its result and values
|
|
4
|
+
class ConditionTrace
|
|
5
|
+
attr_reader :field, :operator, :expected_value, :actual_value, :result, :description
|
|
6
|
+
|
|
7
|
+
def initialize(field:, operator:, expected_value:, actual_value:, result:, description: nil)
|
|
8
|
+
@field = field.to_s.freeze
|
|
9
|
+
@operator = operator.to_s.freeze
|
|
10
|
+
@expected_value = expected_value
|
|
11
|
+
@actual_value = actual_value
|
|
12
|
+
@result = result
|
|
13
|
+
@description = description ? description.to_s.freeze : generate_description
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def passed?
|
|
18
|
+
@result == true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def failed?
|
|
22
|
+
@result == false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_s
|
|
26
|
+
@description
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_h
|
|
30
|
+
{
|
|
31
|
+
field: @field,
|
|
32
|
+
operator: @operator,
|
|
33
|
+
expected_value: @expected_value,
|
|
34
|
+
actual_value: @actual_value,
|
|
35
|
+
result: @result,
|
|
36
|
+
description: @description
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def generate_description
|
|
43
|
+
case @operator
|
|
44
|
+
when "eq"
|
|
45
|
+
"#{@field} = #{format_value(@actual_value)}"
|
|
46
|
+
when "neq"
|
|
47
|
+
"#{@field} != #{format_value(@expected_value)}"
|
|
48
|
+
when "gt"
|
|
49
|
+
"#{@field} > #{format_value(@expected_value)}"
|
|
50
|
+
when "gte"
|
|
51
|
+
"#{@field} >= #{format_value(@expected_value)}"
|
|
52
|
+
when "lt"
|
|
53
|
+
"#{@field} < #{format_value(@expected_value)}"
|
|
54
|
+
when "lte"
|
|
55
|
+
"#{@field} <= #{format_value(@expected_value)}"
|
|
56
|
+
when "in"
|
|
57
|
+
"#{@field} in #{format_value(@expected_value)}"
|
|
58
|
+
when "contains"
|
|
59
|
+
"#{@field} contains #{format_value(@expected_value)}"
|
|
60
|
+
when "present"
|
|
61
|
+
"#{@field} is present"
|
|
62
|
+
when "blank"
|
|
63
|
+
"#{@field} is blank"
|
|
64
|
+
else
|
|
65
|
+
"#{@field} #{@operator} #{format_value(@expected_value)}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def format_value(value)
|
|
70
|
+
case value
|
|
71
|
+
when String
|
|
72
|
+
value.inspect
|
|
73
|
+
when Array, Hash
|
|
74
|
+
value.inspect
|
|
75
|
+
when Time, Date, DateTime
|
|
76
|
+
value.to_s
|
|
77
|
+
else
|
|
78
|
+
value.to_s
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module Explainability
|
|
3
|
+
# Container for all explainability data from a decision evaluation
|
|
4
|
+
class ExplainabilityResult
|
|
5
|
+
attr_reader :rule_traces, :evaluator_name
|
|
6
|
+
|
|
7
|
+
def initialize(evaluator_name:, rule_traces: [])
|
|
8
|
+
@evaluator_name = evaluator_name.to_s.freeze
|
|
9
|
+
@rule_traces = Array(rule_traces).freeze
|
|
10
|
+
freeze
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def matched_rules
|
|
14
|
+
@rule_traces.select(&:matched)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def evaluated_rules
|
|
18
|
+
@rule_traces
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def all_passed_conditions
|
|
22
|
+
@rule_traces.flat_map(&:passed_conditions)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def all_failed_conditions
|
|
26
|
+
@rule_traces.flat_map(&:failed_conditions)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def because(verbose: false) # rubocop:disable Lint/UnusedMethodArgument
|
|
30
|
+
# verbose parameter kept for API compatibility, but currently both modes return same format
|
|
31
|
+
all_passed_conditions.map(&:description)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def failed_conditions(verbose: false)
|
|
35
|
+
if verbose
|
|
36
|
+
all_failed_conditions.map(&:to_h)
|
|
37
|
+
else
|
|
38
|
+
all_failed_conditions.map(&:description)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_h(verbose: false)
|
|
43
|
+
{
|
|
44
|
+
evaluator_name: @evaluator_name,
|
|
45
|
+
rule_traces: @rule_traces.map(&:to_h), # Always include full rule traces for reconstruction
|
|
46
|
+
because: because(verbose: verbose),
|
|
47
|
+
failed_conditions: failed_conditions(verbose: verbose)
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|