decision_agent 0.3.0 → 1.1.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/README.md +234 -14
- 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 -13
- 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 +78 -9
- 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 +14 -0
- data/lib/decision_agent/decision.rb +113 -4
- 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 +2 -0
- data/lib/decision_agent/dmn/feel/evaluator.rb +130 -114
- 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 -2
- data/lib/decision_agent/dmn/validator.rb +5 -3
- data/lib/decision_agent/dmn/visualizer.rb +7 -6
- data/lib/decision_agent/dsl/condition_evaluator.rb +242 -1375
- 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 +37 -14
- data/lib/decision_agent/errors.rb +2 -0
- data/lib/decision_agent/evaluation.rb +14 -2
- data/lib/decision_agent/evaluators/base.rb +2 -0
- data/lib/decision_agent/evaluators/dmn_evaluator.rb +108 -19
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +56 -11
- data/lib/decision_agent/evaluators/static_evaluator.rb +2 -0
- data/lib/decision_agent/explainability/condition_trace.rb +85 -0
- data/lib/decision_agent/explainability/explainability_result.rb +50 -0
- data/lib/decision_agent/explainability/rule_trace.rb +41 -0
- data/lib/decision_agent/explainability/trace_collector.rb +26 -0
- data/lib/decision_agent/monitoring/alert_manager.rb +7 -16
- 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/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 +20 -0
- data/lib/decision_agent/simulation/impact_analyzer.rb +500 -0
- data/lib/decision_agent/simulation/monte_carlo_simulator.rb +638 -0
- data/lib/decision_agent/simulation/replay_engine.rb +488 -0
- data/lib/decision_agent/simulation/scenario_engine.rb +320 -0
- data/lib/decision_agent/simulation/scenario_library.rb +165 -0
- data/lib/decision_agent/simulation/shadow_test_engine.rb +274 -0
- data/lib/decision_agent/simulation/what_if_analyzer.rb +1008 -0
- data/lib/decision_agent/simulation.rb +19 -0
- data/lib/decision_agent/testing/batch_test_importer.rb +6 -2
- data/lib/decision_agent/testing/batch_test_runner.rb +5 -2
- data/lib/decision_agent/testing/test_coverage_analyzer.rb +2 -0
- data/lib/decision_agent/testing/test_result_comparator.rb +2 -0
- 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 +108 -43
- data/lib/decision_agent/versioning/adapter.rb +9 -0
- data/lib/decision_agent/versioning/file_storage_adapter.rb +19 -6
- data/lib/decision_agent/versioning/version_manager.rb +9 -0
- 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 -67
- 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 +186 -26
- data/lib/decision_agent/web/public/batch_testing.html +80 -6
- data/lib/decision_agent/web/public/dmn-editor.html +2 -2
- data/lib/decision_agent/web/public/dmn-editor.js +74 -8
- data/lib/decision_agent/web/public/index.html +69 -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 +146 -0
- data/lib/decision_agent/web/public/simulation_impact.html +495 -0
- data/lib/decision_agent/web/public/simulation_replay.html +547 -0
- data/lib/decision_agent/web/public/simulation_shadow.html +561 -0
- data/lib/decision_agent/web/public/simulation_whatif.html +549 -0
- data/lib/decision_agent/web/public/styles.css +65 -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 +2126 -1374
- data/lib/decision_agent.rb +19 -1
- data/lib/generators/decision_agent/install/install_generator.rb +2 -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/migration.rb +2 -0
- data/lib/generators/decision_agent/install/templates/rule.rb +2 -0
- data/lib/generators/decision_agent/install/templates/rule_version.rb +2 -0
- metadata +103 -89
- 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 -655
- 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 -778
- 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/dmn/decision_graph_spec.rb +0 -282
- data/spec/dmn/decision_tree_spec.rb +0 -203
- data/spec/dmn/feel/errors_spec.rb +0 -18
- data/spec/dmn/feel/functions_spec.rb +0 -400
- data/spec/dmn/feel/simple_parser_spec.rb +0 -274
- data/spec/dmn/feel/types_spec.rb +0 -176
- data/spec/dmn/feel_parser_spec.rb +0 -489
- data/spec/dmn/hit_policy_spec.rb +0 -202
- data/spec/dmn/integration_spec.rb +0 -226
- 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 -1909
- data/spec/fixtures/dmn/complex_decision.dmn +0 -81
- data/spec/fixtures/dmn/invalid_structure.dmn +0 -31
- data/spec/fixtures/dmn/simple_decision.dmn +0 -40
- 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 -501
- data/spec/monitoring/monitored_agent_spec.rb +0 -225
- 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 -493
- 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 -490
- 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 -2134
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require_relative "../dmn/adapter"
|
|
2
4
|
require_relative "../dmn/errors"
|
|
3
5
|
require_relative "base"
|
|
@@ -39,9 +41,12 @@ module DecisionAgent
|
|
|
39
41
|
def evaluate(context, feedback: {})
|
|
40
42
|
hit_policy = @decision.decision_table.hit_policy
|
|
41
43
|
|
|
44
|
+
# Collect explainability traces
|
|
45
|
+
explainability_result = collect_explainability(context, hit_policy)
|
|
46
|
+
|
|
42
47
|
# Short-circuit for FIRST and PRIORITY policies
|
|
43
48
|
if %w[FIRST PRIORITY].include?(hit_policy)
|
|
44
|
-
first_match = find_first_matching_evaluation(context,
|
|
49
|
+
first_match = find_first_matching_evaluation(context, explainability_result: explainability_result)
|
|
45
50
|
return first_match if first_match
|
|
46
51
|
|
|
47
52
|
# If no match found, return nil (consistent with apply_first_policy behavior)
|
|
@@ -49,10 +54,25 @@ module DecisionAgent
|
|
|
49
54
|
end
|
|
50
55
|
|
|
51
56
|
# For UNIQUE, ANY, COLLECT - need all matches
|
|
52
|
-
matching_evaluations = find_all_matching_evaluations(context,
|
|
57
|
+
matching_evaluations = find_all_matching_evaluations(context, explainability_result: explainability_result)
|
|
53
58
|
|
|
54
59
|
# Apply hit policy to select the appropriate evaluation
|
|
55
|
-
apply_hit_policy(matching_evaluations)
|
|
60
|
+
result = apply_hit_policy(matching_evaluations)
|
|
61
|
+
|
|
62
|
+
# Add explainability to metadata by creating new Evaluation with updated metadata
|
|
63
|
+
if result && explainability_result
|
|
64
|
+
metadata = result.metadata.dup
|
|
65
|
+
metadata[:explainability] = explainability_result.to_h
|
|
66
|
+
result = Evaluation.new(
|
|
67
|
+
decision: result.decision,
|
|
68
|
+
weight: result.weight,
|
|
69
|
+
reason: result.reason,
|
|
70
|
+
evaluator_name: result.evaluator_name,
|
|
71
|
+
metadata: metadata
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
result
|
|
56
76
|
end
|
|
57
77
|
|
|
58
78
|
private
|
|
@@ -61,8 +81,51 @@ module DecisionAgent
|
|
|
61
81
|
@name
|
|
62
82
|
end
|
|
63
83
|
|
|
84
|
+
# Collect explainability traces for DMN evaluation
|
|
85
|
+
def collect_explainability(context, hit_policy)
|
|
86
|
+
ctx = context.is_a?(Context) ? context : Context.new(context)
|
|
87
|
+
rules = @rules_json["rules"] || []
|
|
88
|
+
rule_traces = []
|
|
89
|
+
|
|
90
|
+
rules.each do |rule|
|
|
91
|
+
rule_id = rule["id"] || "rule_#{rules.index(rule)}"
|
|
92
|
+
if_clause = rule["if"]
|
|
93
|
+
next unless if_clause
|
|
94
|
+
|
|
95
|
+
# Create trace collector for this rule
|
|
96
|
+
trace_collector = Explainability::TraceCollector.new
|
|
97
|
+
|
|
98
|
+
# Evaluate condition with tracing
|
|
99
|
+
matched = Dsl::ConditionEvaluator.evaluate(
|
|
100
|
+
if_clause,
|
|
101
|
+
ctx,
|
|
102
|
+
trace_collector: trace_collector
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
then_clause = rule["then"] || {}
|
|
106
|
+
rule_trace = Explainability::RuleTrace.new(
|
|
107
|
+
rule_id: rule_id,
|
|
108
|
+
matched: matched,
|
|
109
|
+
condition_traces: trace_collector.traces,
|
|
110
|
+
decision: then_clause["decision"],
|
|
111
|
+
weight: then_clause["weight"],
|
|
112
|
+
reason: then_clause["reason"]
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
rule_traces << rule_trace
|
|
116
|
+
|
|
117
|
+
# Stop after first match for FIRST/PRIORITY policies (short-circuit evaluation)
|
|
118
|
+
break if matched && %w[FIRST PRIORITY].include?(hit_policy)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
Explainability::ExplainabilityResult.new(
|
|
122
|
+
evaluator_name: @name,
|
|
123
|
+
rule_traces: rule_traces
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
64
127
|
# Find first matching rule (for short-circuiting)
|
|
65
|
-
def find_first_matching_evaluation(context, feedback: {})
|
|
128
|
+
def find_first_matching_evaluation(context, explainability_result: nil, feedback: {})
|
|
66
129
|
ctx = context.is_a?(Context) ? context : Context.new(context)
|
|
67
130
|
rules = @rules_json["rules"] || []
|
|
68
131
|
|
|
@@ -70,20 +133,33 @@ module DecisionAgent
|
|
|
70
133
|
if_clause = rule["if"]
|
|
71
134
|
next unless if_clause
|
|
72
135
|
|
|
73
|
-
|
|
136
|
+
# If explainability is already collected, use the trace data
|
|
137
|
+
matched = if explainability_result
|
|
138
|
+
rule_trace = explainability_result.rule_traces.find { |rt| rt.rule_id == (rule["id"] || "rule_#{rules.index(rule)}") }
|
|
139
|
+
rule_trace&.matched
|
|
140
|
+
else
|
|
141
|
+
Dsl::ConditionEvaluator.evaluate(if_clause, ctx)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
next unless matched
|
|
74
145
|
|
|
75
146
|
then_clause = rule["then"]
|
|
147
|
+
metadata = {
|
|
148
|
+
type: "dmn_rule",
|
|
149
|
+
rule_id: rule["id"],
|
|
150
|
+
ruleset: @rules_json["ruleset"],
|
|
151
|
+
hit_policy: @decision.decision_table.hit_policy
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# Add explainability data to metadata
|
|
155
|
+
metadata[:explainability] = explainability_result.to_h if explainability_result
|
|
156
|
+
|
|
76
157
|
return Evaluation.new(
|
|
77
158
|
decision: then_clause["decision"],
|
|
78
159
|
weight: then_clause["weight"] || 1.0,
|
|
79
160
|
reason: then_clause["reason"] || "Rule matched",
|
|
80
161
|
evaluator_name: @name,
|
|
81
|
-
metadata:
|
|
82
|
-
type: "dmn_rule",
|
|
83
|
-
rule_id: rule["id"],
|
|
84
|
-
ruleset: @rules_json["ruleset"],
|
|
85
|
-
hit_policy: @decision.decision_table.hit_policy
|
|
86
|
-
}
|
|
162
|
+
metadata: metadata
|
|
87
163
|
)
|
|
88
164
|
end
|
|
89
165
|
|
|
@@ -91,7 +167,7 @@ module DecisionAgent
|
|
|
91
167
|
end
|
|
92
168
|
|
|
93
169
|
# Find all matching rules (not just first)
|
|
94
|
-
def find_all_matching_evaluations(context, feedback: {})
|
|
170
|
+
def find_all_matching_evaluations(context, explainability_result: nil, feedback: {})
|
|
95
171
|
ctx = context.is_a?(Context) ? context : Context.new(context)
|
|
96
172
|
rules = @rules_json["rules"] || []
|
|
97
173
|
matching = []
|
|
@@ -100,20 +176,33 @@ module DecisionAgent
|
|
|
100
176
|
if_clause = rule["if"]
|
|
101
177
|
next unless if_clause
|
|
102
178
|
|
|
103
|
-
|
|
179
|
+
# If explainability is already collected, use the trace data
|
|
180
|
+
matched = if explainability_result
|
|
181
|
+
rule_trace = explainability_result.rule_traces.find { |rt| rt.rule_id == (rule["id"] || "rule_#{rules.index(rule)}") }
|
|
182
|
+
rule_trace&.matched
|
|
183
|
+
else
|
|
184
|
+
Dsl::ConditionEvaluator.evaluate(if_clause, ctx)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
next unless matched
|
|
104
188
|
|
|
105
189
|
then_clause = rule["then"]
|
|
190
|
+
metadata = {
|
|
191
|
+
type: "dmn_rule",
|
|
192
|
+
rule_id: rule["id"],
|
|
193
|
+
ruleset: @rules_json["ruleset"],
|
|
194
|
+
hit_policy: @decision.decision_table.hit_policy
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
# Add explainability data to metadata (will be added to final result)
|
|
198
|
+
# For now, we'll add it to the final result after hit policy is applied
|
|
199
|
+
|
|
106
200
|
matching << Evaluation.new(
|
|
107
201
|
decision: then_clause["decision"],
|
|
108
202
|
weight: then_clause["weight"] || 1.0,
|
|
109
203
|
reason: then_clause["reason"] || "Rule matched",
|
|
110
204
|
evaluator_name: @name,
|
|
111
|
-
metadata:
|
|
112
|
-
type: "dmn_rule",
|
|
113
|
-
rule_id: rule["id"],
|
|
114
|
-
ruleset: @rules_json["ruleset"],
|
|
115
|
-
hit_policy: @decision.decision_table.hit_policy
|
|
116
|
-
}
|
|
205
|
+
metadata: metadata
|
|
117
206
|
)
|
|
118
207
|
end
|
|
119
208
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "json"
|
|
2
4
|
|
|
3
5
|
module DecisionAgent
|
|
@@ -19,38 +21,81 @@ module DecisionAgent
|
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
def evaluate(context, feedback: {})
|
|
22
|
-
ctx = context.is_a?(Context) ? context : Context.new(context)
|
|
24
|
+
ctx = context.is_a?(DecisionAgent::Context) ? context : DecisionAgent::Context.new(context)
|
|
25
|
+
|
|
26
|
+
# Collect explainability traces (this also finds the matching rule)
|
|
27
|
+
explainability_result = collect_explainability(ctx)
|
|
23
28
|
|
|
24
|
-
|
|
29
|
+
# Find the matched rule from explainability result
|
|
30
|
+
matched_rule_trace = explainability_result&.matched_rules&.first
|
|
31
|
+
return nil unless matched_rule_trace
|
|
25
32
|
|
|
33
|
+
# Find the original rule to get the then clause
|
|
34
|
+
rules = @ruleset["rules"] || []
|
|
35
|
+
matched_rule = rules.find { |r| (r["id"] || "rule_#{rules.index(r)}") == matched_rule_trace.rule_id }
|
|
26
36
|
return nil unless matched_rule
|
|
27
37
|
|
|
28
38
|
then_clause = matched_rule["then"]
|
|
29
39
|
|
|
40
|
+
metadata = {
|
|
41
|
+
type: "json_rule",
|
|
42
|
+
rule_id: matched_rule["id"],
|
|
43
|
+
ruleset: @ruleset_name
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# Add explainability data to metadata
|
|
47
|
+
metadata[:explainability] = explainability_result.to_h if explainability_result
|
|
48
|
+
|
|
30
49
|
Evaluation.new(
|
|
31
50
|
decision: then_clause["decision"],
|
|
32
51
|
weight: then_clause["weight"] || 1.0,
|
|
33
52
|
reason: then_clause["reason"] || "Rule matched",
|
|
34
53
|
evaluator_name: @name,
|
|
35
|
-
metadata:
|
|
36
|
-
type: "json_rule",
|
|
37
|
-
rule_id: matched_rule["id"],
|
|
38
|
-
ruleset: @ruleset_name
|
|
39
|
-
}
|
|
54
|
+
metadata: metadata
|
|
40
55
|
)
|
|
41
56
|
end
|
|
42
57
|
|
|
43
58
|
private
|
|
44
59
|
|
|
45
|
-
def
|
|
60
|
+
def collect_explainability(context)
|
|
46
61
|
rules = @ruleset["rules"] || []
|
|
62
|
+
rule_traces = []
|
|
47
63
|
|
|
48
|
-
rules.
|
|
64
|
+
rules.each do |rule|
|
|
65
|
+
rule_id = rule["id"] || "rule_#{rules.index(rule)}"
|
|
49
66
|
if_clause = rule["if"]
|
|
50
|
-
next
|
|
67
|
+
next unless if_clause
|
|
68
|
+
|
|
69
|
+
# Create trace collector for this rule
|
|
70
|
+
trace_collector = Explainability::TraceCollector.new
|
|
51
71
|
|
|
52
|
-
|
|
72
|
+
# Evaluate condition with tracing
|
|
73
|
+
matched = Dsl::ConditionEvaluator.evaluate(
|
|
74
|
+
if_clause,
|
|
75
|
+
context,
|
|
76
|
+
trace_collector: trace_collector
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
then_clause = rule["then"] || {}
|
|
80
|
+
rule_trace = Explainability::RuleTrace.new(
|
|
81
|
+
rule_id: rule_id,
|
|
82
|
+
matched: matched,
|
|
83
|
+
condition_traces: trace_collector.traces,
|
|
84
|
+
decision: then_clause["decision"],
|
|
85
|
+
weight: then_clause["weight"],
|
|
86
|
+
reason: then_clause["reason"]
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
rule_traces << rule_trace
|
|
90
|
+
|
|
91
|
+
# Stop after first match (short-circuit evaluation)
|
|
92
|
+
break if matched
|
|
53
93
|
end
|
|
94
|
+
|
|
95
|
+
Explainability::ExplainabilityResult.new(
|
|
96
|
+
evaluator_name: @name,
|
|
97
|
+
rule_traces: rule_traces
|
|
98
|
+
)
|
|
54
99
|
end
|
|
55
100
|
|
|
56
101
|
# Deep freeze helper method
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DecisionAgent
|
|
4
|
+
module Explainability
|
|
5
|
+
# Represents a single condition evaluation with its result and values
|
|
6
|
+
class ConditionTrace
|
|
7
|
+
attr_reader :field, :operator, :expected_value, :actual_value, :result, :description
|
|
8
|
+
|
|
9
|
+
def initialize(field:, operator:, expected_value:, actual_value:, result:, description: nil)
|
|
10
|
+
@field = field.to_s.freeze
|
|
11
|
+
@operator = operator.to_s.freeze
|
|
12
|
+
@expected_value = expected_value
|
|
13
|
+
@actual_value = actual_value
|
|
14
|
+
@result = result
|
|
15
|
+
@description = description ? description.to_s.freeze : generate_description
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def passed?
|
|
20
|
+
@result == true
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def failed?
|
|
24
|
+
@result == false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_s
|
|
28
|
+
@description
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_h
|
|
32
|
+
{
|
|
33
|
+
field: @field,
|
|
34
|
+
operator: @operator,
|
|
35
|
+
expected_value: @expected_value,
|
|
36
|
+
actual_value: @actual_value,
|
|
37
|
+
result: @result,
|
|
38
|
+
description: @description
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def generate_description
|
|
45
|
+
case @operator
|
|
46
|
+
when "eq"
|
|
47
|
+
"#{@field} = #{format_value(@actual_value)}"
|
|
48
|
+
when "neq"
|
|
49
|
+
"#{@field} != #{format_value(@expected_value)}"
|
|
50
|
+
when "gt"
|
|
51
|
+
"#{@field} > #{format_value(@expected_value)}"
|
|
52
|
+
when "gte"
|
|
53
|
+
"#{@field} >= #{format_value(@expected_value)}"
|
|
54
|
+
when "lt"
|
|
55
|
+
"#{@field} < #{format_value(@expected_value)}"
|
|
56
|
+
when "lte"
|
|
57
|
+
"#{@field} <= #{format_value(@expected_value)}"
|
|
58
|
+
when "in"
|
|
59
|
+
"#{@field} in #{format_value(@expected_value)}"
|
|
60
|
+
when "contains"
|
|
61
|
+
"#{@field} contains #{format_value(@expected_value)}"
|
|
62
|
+
when "present"
|
|
63
|
+
"#{@field} is present"
|
|
64
|
+
when "blank"
|
|
65
|
+
"#{@field} is blank"
|
|
66
|
+
else
|
|
67
|
+
"#{@field} #{@operator} #{format_value(@expected_value)}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def format_value(value)
|
|
72
|
+
case value
|
|
73
|
+
when String
|
|
74
|
+
value.inspect
|
|
75
|
+
when Array, Hash
|
|
76
|
+
value.inspect
|
|
77
|
+
when Time, Date, DateTime
|
|
78
|
+
value.to_s
|
|
79
|
+
else
|
|
80
|
+
value.to_s
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DecisionAgent
|
|
4
|
+
module Explainability
|
|
5
|
+
# Container for all explainability data from a decision evaluation
|
|
6
|
+
class ExplainabilityResult
|
|
7
|
+
attr_reader :rule_traces, :evaluator_name
|
|
8
|
+
|
|
9
|
+
def initialize(evaluator_name:, rule_traces: [])
|
|
10
|
+
@evaluator_name = evaluator_name.to_s.freeze
|
|
11
|
+
@rule_traces = Array(rule_traces).freeze
|
|
12
|
+
freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def matched_rules
|
|
16
|
+
@rule_traces.select(&:matched)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def all_passed_conditions
|
|
20
|
+
@rule_traces.flat_map(&:passed_conditions)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def all_failed_conditions
|
|
24
|
+
@rule_traces.flat_map(&:failed_conditions)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def because(verbose: false) # rubocop:disable Lint/UnusedMethodArgument
|
|
28
|
+
# verbose parameter kept for API compatibility, but currently both modes return same format
|
|
29
|
+
all_passed_conditions.map(&:description)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def failed_conditions(verbose: false)
|
|
33
|
+
if verbose
|
|
34
|
+
all_failed_conditions.map(&:to_h)
|
|
35
|
+
else
|
|
36
|
+
all_failed_conditions.map(&:description)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_h(verbose: false)
|
|
41
|
+
{
|
|
42
|
+
evaluator_name: @evaluator_name,
|
|
43
|
+
rule_traces: @rule_traces.map(&:to_h), # Always include full rule traces for reconstruction
|
|
44
|
+
because: because(verbose: verbose),
|
|
45
|
+
failed_conditions: failed_conditions(verbose: verbose)
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DecisionAgent
|
|
4
|
+
module Explainability
|
|
5
|
+
# Represents the trace of a rule evaluation including all conditions
|
|
6
|
+
class RuleTrace
|
|
7
|
+
attr_reader :rule_id, :matched, :condition_traces, :decision, :weight, :reason
|
|
8
|
+
|
|
9
|
+
def initialize(rule_id:, matched:, condition_traces: [], decision: nil, weight: nil, reason: nil)
|
|
10
|
+
@rule_id = rule_id.to_s.freeze
|
|
11
|
+
@matched = matched
|
|
12
|
+
@condition_traces = Array(condition_traces).freeze
|
|
13
|
+
@decision = decision ? decision.to_s.freeze : nil
|
|
14
|
+
@weight = weight
|
|
15
|
+
@reason = reason ? reason.to_s.freeze : nil
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def passed_conditions
|
|
20
|
+
@condition_traces.select(&:passed?)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def failed_conditions
|
|
24
|
+
@condition_traces.select(&:failed?)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_h
|
|
28
|
+
{
|
|
29
|
+
rule_id: @rule_id,
|
|
30
|
+
matched: @matched,
|
|
31
|
+
decision: @decision,
|
|
32
|
+
weight: @weight,
|
|
33
|
+
reason: @reason,
|
|
34
|
+
condition_traces: @condition_traces.map(&:to_h),
|
|
35
|
+
passed_conditions: passed_conditions.map(&:description),
|
|
36
|
+
failed_conditions: failed_conditions.map(&:description)
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DecisionAgent
|
|
4
|
+
module Explainability
|
|
5
|
+
# Collects condition traces during evaluation
|
|
6
|
+
class TraceCollector
|
|
7
|
+
attr_reader :traces
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@traces = []
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def add_trace(trace)
|
|
14
|
+
@traces << trace
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def clear
|
|
18
|
+
@traces.clear
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def empty?
|
|
22
|
+
@traces.empty?
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "monitor"
|
|
2
4
|
|
|
3
5
|
module DecisionAgent
|
|
@@ -16,6 +18,7 @@ module DecisionAgent
|
|
|
16
18
|
@alert_handlers = []
|
|
17
19
|
@check_interval = 60 # seconds
|
|
18
20
|
@monitoring_thread = nil
|
|
21
|
+
@rule_counter = 0
|
|
19
22
|
freeze_config
|
|
20
23
|
end
|
|
21
24
|
|
|
@@ -176,21 +179,6 @@ module DecisionAgent
|
|
|
176
179
|
end
|
|
177
180
|
end
|
|
178
181
|
|
|
179
|
-
def self.decision_anomaly(expected_rate: 100, variance: 0.3)
|
|
180
|
-
lambda do |stats|
|
|
181
|
-
total = stats.dig(:decisions, :total) || 0
|
|
182
|
-
time_range = stats.dig(:summary, :time_range)
|
|
183
|
-
|
|
184
|
-
# Simple anomaly detection based on rate
|
|
185
|
-
return false unless time_range
|
|
186
|
-
|
|
187
|
-
lower_bound = expected_rate * (1 - variance)
|
|
188
|
-
upper_bound = expected_rate * (1 + variance)
|
|
189
|
-
|
|
190
|
-
total < lower_bound || total > upper_bound
|
|
191
|
-
end
|
|
192
|
-
end
|
|
193
|
-
|
|
194
182
|
private
|
|
195
183
|
|
|
196
184
|
def freeze_config
|
|
@@ -198,7 +186,10 @@ module DecisionAgent
|
|
|
198
186
|
end
|
|
199
187
|
|
|
200
188
|
def generate_rule_id(name)
|
|
201
|
-
|
|
189
|
+
synchronize do
|
|
190
|
+
@rule_counter += 1
|
|
191
|
+
"#{sanitize_name(name)}_#{Time.now.to_i}_#{@rule_counter}"
|
|
192
|
+
end
|
|
202
193
|
end
|
|
203
194
|
|
|
204
195
|
def sanitize_name(name)
|