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,25 +1,39 @@
|
|
|
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
|
|
12
|
+
# This is necessary for thread-safety even if it adds some overhead
|
|
7
13
|
data_hash = data.is_a?(Hash) ? data : {}
|
|
8
14
|
@data = deep_freeze(deep_dup(data_hash))
|
|
9
15
|
end
|
|
10
16
|
|
|
17
|
+
# @param key [Object] Key to look up
|
|
18
|
+
# @return [Object, nil] Value for key, or nil if missing
|
|
11
19
|
def [](key)
|
|
12
20
|
@data[key]
|
|
13
21
|
end
|
|
14
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
|
|
15
26
|
def fetch(key, default = nil)
|
|
16
27
|
@data.fetch(key, default)
|
|
17
28
|
end
|
|
18
29
|
|
|
30
|
+
# @param key [Object] Key to check
|
|
31
|
+
# @return [Boolean] Whether the key exists
|
|
19
32
|
def key?(key)
|
|
20
33
|
@data.key?(key)
|
|
21
34
|
end
|
|
22
35
|
|
|
36
|
+
# @return [Hash] The underlying frozen data hash
|
|
23
37
|
def to_h
|
|
24
38
|
@data
|
|
25
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
|
|
|
@@ -14,16 +17,122 @@ module DecisionAgent
|
|
|
14
17
|
freeze
|
|
15
18
|
end
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
# Returns array of condition descriptions that led to this decision
|
|
21
|
+
# @param verbose [Boolean] If true, returns detailed condition information
|
|
22
|
+
# @return [Array<String>] Array of condition descriptions
|
|
23
|
+
def because(verbose: false)
|
|
24
|
+
all_explainability_results.flat_map { |er| er.because(verbose: verbose) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Returns array of condition descriptions that failed
|
|
28
|
+
# @param verbose [Boolean] If true, returns detailed condition information
|
|
29
|
+
# @return [Array<String>] Array of failed condition descriptions
|
|
30
|
+
def failed_conditions(verbose: false)
|
|
31
|
+
all_explainability_results.flat_map { |er| er.failed_conditions(verbose: verbose) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Returns explainability data in machine-readable format
|
|
35
|
+
# @param verbose [Boolean] If true, returns detailed explainability information
|
|
36
|
+
# @return [Hash] Explainability data
|
|
37
|
+
def explainability(verbose: false)
|
|
18
38
|
{
|
|
19
39
|
decision: @decision,
|
|
40
|
+
because: because(verbose: verbose),
|
|
41
|
+
failed_conditions: failed_conditions(verbose: verbose),
|
|
42
|
+
rule_traces: verbose ? all_explainability_results.map { |er| er.to_h(verbose: true) } : nil
|
|
43
|
+
}.compact
|
|
44
|
+
end
|
|
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
|
|
50
|
+
def to_h
|
|
51
|
+
# Structure decision result as explainability by default
|
|
52
|
+
# This makes explainability the primary format for decision results
|
|
53
|
+
explainability_data = explainability(verbose: false)
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
# Explainability fields (primary structure)
|
|
57
|
+
decision: explainability_data[:decision],
|
|
58
|
+
because: explainability_data[:because],
|
|
59
|
+
failed_conditions: explainability_data[:failed_conditions],
|
|
60
|
+
# Additional metadata for completeness
|
|
20
61
|
confidence: @confidence,
|
|
21
62
|
explanations: @explanations,
|
|
22
63
|
evaluations: @evaluations.map(&:to_h),
|
|
23
|
-
audit_payload: @audit_payload
|
|
64
|
+
audit_payload: @audit_payload,
|
|
65
|
+
# Full explainability data (includes rule_traces in verbose mode)
|
|
66
|
+
explainability: explainability_data
|
|
24
67
|
}
|
|
25
68
|
end
|
|
26
69
|
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def all_explainability_results
|
|
73
|
+
@evaluations.flat_map { |evaluation| extract_explainability_from_evaluation(evaluation) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def extract_explainability_from_evaluation(evaluation)
|
|
77
|
+
return [] unless evaluation.metadata.is_a?(Hash)
|
|
78
|
+
return [] unless evaluation.metadata[:explainability]
|
|
79
|
+
|
|
80
|
+
explainability_data = normalize_hash_keys(evaluation.metadata[:explainability])
|
|
81
|
+
rule_traces = reconstruct_rule_traces(explainability_data)
|
|
82
|
+
evaluator_name = explainability_data[:evaluator_name] || evaluation.evaluator_name
|
|
83
|
+
|
|
84
|
+
[Explainability::ExplainabilityResult.new(
|
|
85
|
+
evaluator_name: evaluator_name,
|
|
86
|
+
rule_traces: rule_traces
|
|
87
|
+
)]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def normalize_hash_keys(data)
|
|
91
|
+
return data unless data.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
data.transform_keys(&:to_sym)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def reconstruct_rule_traces(explainability_data)
|
|
97
|
+
rule_traces_data = explainability_data[:rule_traces] || []
|
|
98
|
+
rule_traces_data.map { |rt_data| reconstruct_rule_trace(rt_data) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def reconstruct_rule_trace(rt_data)
|
|
102
|
+
normalized_rt = normalize_hash_keys(rt_data)
|
|
103
|
+
condition_traces = reconstruct_condition_traces(normalized_rt)
|
|
104
|
+
|
|
105
|
+
Explainability::RuleTrace.new(
|
|
106
|
+
rule_id: normalized_rt[:rule_id],
|
|
107
|
+
matched: normalized_rt[:matched],
|
|
108
|
+
condition_traces: condition_traces,
|
|
109
|
+
decision: normalized_rt[:decision],
|
|
110
|
+
weight: normalized_rt[:weight],
|
|
111
|
+
reason: normalized_rt[:reason]
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def reconstruct_condition_traces(rule_trace_data)
|
|
116
|
+
condition_traces_data = rule_trace_data[:condition_traces] || []
|
|
117
|
+
condition_traces_data.map { |ct_data| reconstruct_condition_trace(ct_data) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def reconstruct_condition_trace(ct_data)
|
|
121
|
+
normalized_ct = normalize_hash_keys(ct_data)
|
|
122
|
+
|
|
123
|
+
Explainability::ConditionTrace.new(
|
|
124
|
+
field: normalized_ct[:field],
|
|
125
|
+
operator: normalized_ct[:operator],
|
|
126
|
+
expected_value: normalized_ct[:expected_value],
|
|
127
|
+
actual_value: normalized_ct[:actual_value],
|
|
128
|
+
result: normalized_ct[:result]
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
public
|
|
133
|
+
|
|
134
|
+
# @param other [Object] Object to compare
|
|
135
|
+
# @return [Boolean] true if other is a Decision with same decision, confidence, explanations, evaluations
|
|
27
136
|
def ==(other)
|
|
28
137
|
other.is_a?(Decision) &&
|
|
29
138
|
@decision == other.decision &&
|
|
@@ -35,8 +144,8 @@ module DecisionAgent
|
|
|
35
144
|
private
|
|
36
145
|
|
|
37
146
|
def validate_confidence!(confidence)
|
|
38
|
-
|
|
39
|
-
raise InvalidConfidenceError, confidence unless
|
|
147
|
+
confidence_value = confidence.to_f
|
|
148
|
+
raise InvalidConfidenceError, confidence unless confidence_value.between?(0.0, 1.0)
|
|
40
149
|
end
|
|
41
150
|
|
|
42
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_relative "../errors"
|
|
2
4
|
require_relative "simple_parser"
|
|
3
5
|
require_relative "parser"
|
|
@@ -30,108 +32,15 @@ module DecisionAgent
|
|
|
30
32
|
# @param field_name [String] The field name being evaluated
|
|
31
33
|
# @param context [Hash] Evaluation context
|
|
32
34
|
# @return [Boolean] Evaluation result
|
|
33
|
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
34
35
|
def evaluate(expression, field_name, context)
|
|
35
36
|
return true if expression == "-" # DMN "don't care" marker
|
|
36
37
|
|
|
37
38
|
# 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
|
|
39
|
+
parslet_result = try_parslet_evaluate(expression, context) if @use_parslet
|
|
40
|
+
return parslet_result unless parslet_result == :fallback
|
|
69
41
|
|
|
70
42
|
# 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
|
|
43
|
+
evaluate_phase2a(expression, field_name, context)
|
|
135
44
|
end
|
|
136
45
|
|
|
137
46
|
# Parse FEEL expression into operator and value (for internal use by Adapter)
|
|
@@ -167,6 +76,93 @@ module DecisionAgent
|
|
|
167
76
|
|
|
168
77
|
private
|
|
169
78
|
|
|
79
|
+
# Attempt evaluation via Parslet parser (Phase 2B)
|
|
80
|
+
# Returns :fallback if Parslet cannot handle this expression
|
|
81
|
+
def try_parslet_evaluate(expression, context)
|
|
82
|
+
ast = cached_parslet_ast(expression.to_s.strip)
|
|
83
|
+
result = evaluate_ast_node(ast, context)
|
|
84
|
+
|
|
85
|
+
# If result is nil for a missing field reference, fall back to Phase 2A
|
|
86
|
+
return :fallback if result.nil? && unresolved_field_reference?(ast, context)
|
|
87
|
+
|
|
88
|
+
result
|
|
89
|
+
rescue Parslet::ParseFailed, FeelParseError, FeelTransformError => e
|
|
90
|
+
warn "Parslet parse failed: #{e.message}, falling back" if ENV["DEBUG_FEEL"]
|
|
91
|
+
:fallback
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Fetch or build Parslet AST with thread-safe caching
|
|
95
|
+
def cached_parslet_ast(expr_key)
|
|
96
|
+
ast = @cache_mutex.synchronize { @ast_cache[expr_key] }
|
|
97
|
+
return ast if ast
|
|
98
|
+
|
|
99
|
+
parse_tree = @parslet_parser.parse(expr_key)
|
|
100
|
+
ast = @transformer.apply(parse_tree)
|
|
101
|
+
@cache_mutex.synchronize { @ast_cache[expr_key] = ast }
|
|
102
|
+
ast
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def unresolved_field_reference?(ast, context)
|
|
106
|
+
ast.is_a?(Hash) && ast[:type] == :field &&
|
|
107
|
+
!context.key?(ast[:name]) && !context.key?(ast[:name].to_s) &&
|
|
108
|
+
!context.key?(ast[:name].to_sym)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Phase 2A evaluation: regex-based patterns with condition structures
|
|
112
|
+
def evaluate_phase2a(expression, field_name, context)
|
|
113
|
+
cache_key = "#{expression}::#{field_name}"
|
|
114
|
+
condition = @cache_mutex.synchronize { @cache[cache_key] }
|
|
115
|
+
return Dsl::ConditionEvaluator.evaluate(condition, context) if condition
|
|
116
|
+
|
|
117
|
+
expr_str = expression.to_s.strip
|
|
118
|
+
is_supported = expression_supported?(expr_str)
|
|
119
|
+
condition = build_condition(expression, field_name, expr_str, context)
|
|
120
|
+
|
|
121
|
+
@cache_mutex.synchronize { @cache[cache_key] = condition }
|
|
122
|
+
|
|
123
|
+
return condition unless is_supported
|
|
124
|
+
|
|
125
|
+
evaluate_with_fallback(condition, context)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Check if expression matches any known parseable pattern
|
|
129
|
+
def expression_supported?(expr_str)
|
|
130
|
+
supported = literal?(expr_str) || comparison_expression?(expr_str) ||
|
|
131
|
+
list_expression?(expr_str) || range_expression?(expr_str)
|
|
132
|
+
|
|
133
|
+
return supported unless SimpleParser.can_parse?(expr_str)
|
|
134
|
+
|
|
135
|
+
@simple_parser.parse(expr_str)
|
|
136
|
+
true
|
|
137
|
+
rescue FeelParseError
|
|
138
|
+
false
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Build a condition structure from expression, with fallback to default equality
|
|
142
|
+
def build_condition(expression, field_name, expr_str, context)
|
|
143
|
+
condition = parse_expression_to_condition(expression, field_name, context)
|
|
144
|
+
return condition if condition.is_a?(Hash)
|
|
145
|
+
|
|
146
|
+
{ "field" => field_name, "op" => "eq", "value" => parse_value(expr_str) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Evaluate condition with fallback to condition structure when result is ambiguous
|
|
150
|
+
def evaluate_with_fallback(condition, context)
|
|
151
|
+
evaluation_result = Dsl::ConditionEvaluator.evaluate(condition, context)
|
|
152
|
+
|
|
153
|
+
return condition if evaluation_result.nil?
|
|
154
|
+
return condition if equality_on_missing_field?(evaluation_result, condition, context)
|
|
155
|
+
|
|
156
|
+
evaluation_result
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def equality_on_missing_field?(result, condition, context)
|
|
160
|
+
return false unless result == false && condition["op"] == "eq"
|
|
161
|
+
|
|
162
|
+
field_key = condition["field"]
|
|
163
|
+
!context.key?(field_key) && !context.key?(field_key.to_s) && !context.key?(field_key.to_sym)
|
|
164
|
+
end
|
|
165
|
+
|
|
170
166
|
def literal?(expr)
|
|
171
167
|
# Quoted string
|
|
172
168
|
return true if expr.start_with?('"') && expr.end_with?('"')
|
|
@@ -253,15 +249,37 @@ module DecisionAgent
|
|
|
253
249
|
max_val = parse_value(range_match[3])
|
|
254
250
|
inclusive_end = range_match[4] == "]"
|
|
255
251
|
|
|
256
|
-
# For Phase 2A, we
|
|
257
|
-
# Map to 'between' operator
|
|
252
|
+
# For Phase 2A, we support fully inclusive ranges with 'between' operator
|
|
258
253
|
if inclusive_start && inclusive_end
|
|
259
254
|
{ operator: "between", value: [min_val, max_val] }
|
|
260
255
|
else
|
|
261
|
-
#
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
256
|
+
# For half-open ranges, convert to inclusive by adjusting bounds
|
|
257
|
+
# [min..max) becomes [min..max-1] (if max is integer) or use compound conditions
|
|
258
|
+
# For simplicity, we'll convert to inclusive ranges with adjusted bounds
|
|
259
|
+
# This is a pragmatic approach for Phase 2A
|
|
260
|
+
adjusted_min = if inclusive_start
|
|
261
|
+
min_val
|
|
262
|
+
elsif min_val.is_a?(Integer)
|
|
263
|
+
min_val + 1
|
|
264
|
+
else
|
|
265
|
+
min_val + 0.0001
|
|
266
|
+
end
|
|
267
|
+
adjusted_max = if inclusive_end
|
|
268
|
+
max_val
|
|
269
|
+
elsif max_val.is_a?(Integer)
|
|
270
|
+
max_val - 1
|
|
271
|
+
else
|
|
272
|
+
max_val - 0.0001
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Ensure adjusted range is valid
|
|
276
|
+
if adjusted_min <= adjusted_max
|
|
277
|
+
{ operator: "between", value: [adjusted_min, adjusted_max] }
|
|
278
|
+
else
|
|
279
|
+
# Invalid range, fall back to error
|
|
280
|
+
raise FeelParseError,
|
|
281
|
+
"Invalid half-open range: #{expr}. Range would be empty after adjustment."
|
|
282
|
+
end
|
|
265
283
|
end
|
|
266
284
|
end
|
|
267
285
|
|
|
@@ -573,22 +591,20 @@ module DecisionAgent
|
|
|
573
591
|
|
|
574
592
|
# Evaluate function call
|
|
575
593
|
def evaluate_function_call(node, context)
|
|
576
|
-
|
|
577
|
-
function_name = if node[:name].is_a?(Hash)
|
|
578
|
-
if node[:name][:type] == :field
|
|
579
|
-
node[:name][:name]
|
|
580
|
-
else
|
|
581
|
-
node[:name][:name] || node[:name][:identifier] || node[:name].to_s
|
|
582
|
-
end
|
|
583
|
-
else
|
|
584
|
-
node[:name]
|
|
585
|
-
end
|
|
586
|
-
|
|
594
|
+
function_name = extract_function_name(node[:name])
|
|
587
595
|
args = Array(node[:arguments]).map { |arg| evaluate_ast_node(arg, context) }
|
|
588
596
|
|
|
589
597
|
Functions.execute(function_name.to_s, args, context)
|
|
590
598
|
end
|
|
591
599
|
|
|
600
|
+
# Extract function name from a string or structured field node
|
|
601
|
+
def extract_function_name(name_node)
|
|
602
|
+
return name_node unless name_node.is_a?(Hash)
|
|
603
|
+
return name_node[:name] if name_node[:type] == :field
|
|
604
|
+
|
|
605
|
+
name_node[:name] || name_node[:identifier] || name_node.to_s
|
|
606
|
+
end
|
|
607
|
+
|
|
592
608
|
# Evaluate property access
|
|
593
609
|
def evaluate_property_access(node, context)
|
|
594
610
|
object = evaluate_ast_node(node[:object], context)
|
|
@@ -600,7 +616,7 @@ module DecisionAgent
|
|
|
600
616
|
when Types::Context
|
|
601
617
|
object[property.to_sym]
|
|
602
618
|
else
|
|
603
|
-
object.respond_to?(property) ? object.
|
|
619
|
+
object.respond_to?(property) ? object.public_send(property) : nil
|
|
604
620
|
end
|
|
605
621
|
end
|
|
606
622
|
|