decision_agent 0.1.1 → 0.1.3
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 -919
- data/bin/decision_agent +5 -5
- data/lib/decision_agent/agent.rb +19 -26
- data/lib/decision_agent/audit/null_adapter.rb +1 -2
- data/lib/decision_agent/decision.rb +3 -1
- data/lib/decision_agent/dsl/condition_evaluator.rb +4 -3
- data/lib/decision_agent/dsl/rule_parser.rb +4 -6
- data/lib/decision_agent/dsl/schema_validator.rb +27 -31
- data/lib/decision_agent/errors.rb +21 -6
- data/lib/decision_agent/evaluation.rb +3 -1
- data/lib/decision_agent/evaluation_validator.rb +78 -0
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +26 -0
- data/lib/decision_agent/evaluators/static_evaluator.rb +2 -6
- data/lib/decision_agent/monitoring/alert_manager.rb +282 -0
- data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +381 -0
- data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +471 -0
- data/lib/decision_agent/monitoring/dashboard/public/index.html +161 -0
- data/lib/decision_agent/monitoring/dashboard_server.rb +340 -0
- data/lib/decision_agent/monitoring/metrics_collector.rb +278 -0
- data/lib/decision_agent/monitoring/monitored_agent.rb +71 -0
- data/lib/decision_agent/monitoring/prometheus_exporter.rb +247 -0
- data/lib/decision_agent/replay/replay.rb +12 -22
- data/lib/decision_agent/scoring/base.rb +1 -1
- data/lib/decision_agent/scoring/consensus.rb +5 -5
- data/lib/decision_agent/scoring/weighted_average.rb +1 -1
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +141 -0
- data/lib/decision_agent/versioning/adapter.rb +100 -0
- data/lib/decision_agent/versioning/file_storage_adapter.rb +290 -0
- data/lib/decision_agent/versioning/version_manager.rb +127 -0
- data/lib/decision_agent/web/public/app.js +318 -0
- data/lib/decision_agent/web/public/index.html +56 -1
- data/lib/decision_agent/web/public/styles.css +219 -0
- data/lib/decision_agent/web/server.rb +169 -9
- data/lib/decision_agent.rb +11 -0
- data/lib/generators/decision_agent/install/install_generator.rb +40 -0
- data/lib/generators/decision_agent/install/templates/README +47 -0
- data/lib/generators/decision_agent/install/templates/migration.rb +37 -0
- data/lib/generators/decision_agent/install/templates/rule.rb +30 -0
- data/lib/generators/decision_agent/install/templates/rule_version.rb +66 -0
- data/spec/activerecord_thread_safety_spec.rb +553 -0
- data/spec/agent_spec.rb +13 -13
- data/spec/api_contract_spec.rb +16 -16
- data/spec/audit_adapters_spec.rb +3 -3
- data/spec/comprehensive_edge_cases_spec.rb +86 -86
- data/spec/dsl_validation_spec.rb +83 -83
- data/spec/edge_cases_spec.rb +23 -23
- data/spec/examples/feedback_aware_evaluator_spec.rb +7 -7
- data/spec/examples.txt +548 -0
- data/spec/issue_verification_spec.rb +685 -0
- data/spec/json_rule_evaluator_spec.rb +15 -15
- data/spec/monitoring/alert_manager_spec.rb +378 -0
- data/spec/monitoring/metrics_collector_spec.rb +281 -0
- data/spec/monitoring/monitored_agent_spec.rb +222 -0
- data/spec/monitoring/prometheus_exporter_spec.rb +242 -0
- data/spec/replay_edge_cases_spec.rb +58 -58
- data/spec/replay_spec.rb +11 -11
- data/spec/rfc8785_canonicalization_spec.rb +215 -0
- data/spec/scoring_spec.rb +1 -1
- data/spec/spec_helper.rb +9 -0
- data/spec/thread_safety_spec.rb +482 -0
- data/spec/thread_safety_spec.rb.broken +878 -0
- data/spec/versioning_spec.rb +777 -0
- data/spec/web_ui_rack_spec.rb +135 -0
- metadata +84 -11
data/bin/decision_agent
CHANGED
|
@@ -24,11 +24,14 @@ def print_help
|
|
|
24
24
|
decision_agent version
|
|
25
25
|
|
|
26
26
|
For more information, visit:
|
|
27
|
-
https://github.com/
|
|
27
|
+
https://github.com/samaswin87/decision_agent
|
|
28
28
|
HELP
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
def start_web_ui(port = 4567)
|
|
32
|
+
# Ruby 4.0 compatibility: Puma expects Bundler::ORIGINAL_ENV which was removed
|
|
33
|
+
Bundler.const_set(:ORIGINAL_ENV, ENV.to_h.dup) if defined?(Bundler) && !Bundler.const_defined?(:ORIGINAL_ENV)
|
|
34
|
+
|
|
32
35
|
puts "🎯 Starting DecisionAgent Rule Builder..."
|
|
33
36
|
puts "📍 Server: http://localhost:#{port}"
|
|
34
37
|
puts "⚡️ Press Ctrl+C to stop"
|
|
@@ -55,19 +58,16 @@ def validate_file(filepath)
|
|
|
55
58
|
puts " Version: #{data['version'] || data[:version]}"
|
|
56
59
|
puts " Ruleset: #{data['ruleset'] || data[:ruleset]}"
|
|
57
60
|
puts " Rules: #{data['rules']&.size || 0}"
|
|
58
|
-
|
|
59
61
|
rescue JSON::ParserError => e
|
|
60
62
|
puts "❌ JSON Parsing Error:"
|
|
61
63
|
puts " #{e.message}"
|
|
62
64
|
exit 1
|
|
63
|
-
|
|
64
65
|
rescue DecisionAgent::InvalidRuleDslError => e
|
|
65
66
|
puts "❌ Validation Failed:"
|
|
66
67
|
puts ""
|
|
67
68
|
puts e.message
|
|
68
69
|
exit 1
|
|
69
|
-
|
|
70
|
-
rescue => e
|
|
70
|
+
rescue StandardError => e
|
|
71
71
|
puts "❌ Unexpected Error:"
|
|
72
72
|
puts " #{e.message}"
|
|
73
73
|
exit 1
|
data/lib/decision_agent/agent.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "digest"
|
|
2
2
|
require "json"
|
|
3
|
+
require "json/canonicalization"
|
|
3
4
|
|
|
4
5
|
module DecisionAgent
|
|
5
6
|
class Agent
|
|
@@ -11,6 +12,9 @@ module DecisionAgent
|
|
|
11
12
|
@audit_adapter = audit_adapter || Audit::NullAdapter.new
|
|
12
13
|
|
|
13
14
|
validate_configuration!
|
|
15
|
+
|
|
16
|
+
# Freeze instance variables for thread-safety
|
|
17
|
+
@evaluators.freeze
|
|
14
18
|
end
|
|
15
19
|
|
|
16
20
|
def decide(context:, feedback: {})
|
|
@@ -20,6 +24,9 @@ module DecisionAgent
|
|
|
20
24
|
|
|
21
25
|
raise NoEvaluationsError if evaluations.empty?
|
|
22
26
|
|
|
27
|
+
# Validate all evaluations for correctness and thread-safety
|
|
28
|
+
EvaluationValidator.validate_all!(evaluations)
|
|
29
|
+
|
|
23
30
|
scored_result = @scoring_strategy.score(evaluations)
|
|
24
31
|
|
|
25
32
|
decision_value = scored_result[:decision]
|
|
@@ -51,32 +58,24 @@ module DecisionAgent
|
|
|
51
58
|
private
|
|
52
59
|
|
|
53
60
|
def validate_configuration!
|
|
54
|
-
if @evaluators.empty?
|
|
55
|
-
raise InvalidConfigurationError, "At least one evaluator is required"
|
|
56
|
-
end
|
|
61
|
+
raise InvalidConfigurationError, "At least one evaluator is required" if @evaluators.empty?
|
|
57
62
|
|
|
58
63
|
@evaluators.each do |evaluator|
|
|
59
|
-
unless evaluator.respond_to?(:evaluate)
|
|
60
|
-
raise InvalidEvaluatorError
|
|
61
|
-
end
|
|
64
|
+
raise InvalidEvaluatorError unless evaluator.respond_to?(:evaluate)
|
|
62
65
|
end
|
|
63
66
|
|
|
64
|
-
unless @scoring_strategy.respond_to?(:score)
|
|
65
|
-
raise InvalidScoringStrategyError
|
|
66
|
-
end
|
|
67
|
+
raise InvalidScoringStrategyError unless @scoring_strategy.respond_to?(:score)
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
return if @audit_adapter.respond_to?(:record)
|
|
70
|
+
|
|
71
|
+
raise InvalidAuditAdapterError
|
|
71
72
|
end
|
|
72
73
|
|
|
73
74
|
def collect_evaluations(context, feedback)
|
|
74
75
|
@evaluators.map do |evaluator|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
nil
|
|
79
|
-
end
|
|
76
|
+
evaluator.evaluate(context, feedback: feedback)
|
|
77
|
+
rescue StandardError
|
|
78
|
+
nil
|
|
80
79
|
end.compact
|
|
81
80
|
end
|
|
82
81
|
|
|
@@ -132,16 +131,10 @@ module DecisionAgent
|
|
|
132
131
|
Digest::SHA256.hexdigest(canonical)
|
|
133
132
|
end
|
|
134
133
|
|
|
134
|
+
# Uses RFC 8785 (JSON Canonicalization Scheme) for deterministic JSON serialization
|
|
135
|
+
# This is the industry standard for cryptographic hashing of JSON data
|
|
135
136
|
def canonical_json(obj)
|
|
136
|
-
|
|
137
|
-
when Hash
|
|
138
|
-
sorted = obj.keys.sort.map { |k| [k.to_s, canonical_json(obj[k])] }.to_h
|
|
139
|
-
JSON.generate(sorted, quirks_mode: false)
|
|
140
|
-
when Array
|
|
141
|
-
JSON.generate(obj.map { |v| canonical_json(v) }, quirks_mode: false)
|
|
142
|
-
else
|
|
143
|
-
obj.to_s
|
|
144
|
-
end
|
|
137
|
+
obj.to_json_c14n
|
|
145
138
|
end
|
|
146
139
|
end
|
|
147
140
|
end
|
|
@@ -10,6 +10,8 @@ module DecisionAgent
|
|
|
10
10
|
@explanations = Array(explanations).map(&:freeze).freeze
|
|
11
11
|
@evaluations = Array(evaluations).freeze
|
|
12
12
|
@audit_payload = deep_freeze(audit_payload)
|
|
13
|
+
|
|
14
|
+
freeze
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def to_h
|
|
@@ -34,7 +36,7 @@ module DecisionAgent
|
|
|
34
36
|
|
|
35
37
|
def validate_confidence!(confidence)
|
|
36
38
|
c = confidence.to_f
|
|
37
|
-
raise InvalidConfidenceError
|
|
39
|
+
raise InvalidConfidenceError, confidence unless c.between?(0.0, 1.0)
|
|
38
40
|
end
|
|
39
41
|
|
|
40
42
|
def deep_freeze(obj)
|
|
@@ -21,13 +21,12 @@ module DecisionAgent
|
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
private
|
|
25
|
-
|
|
26
24
|
# Evaluates 'all' condition - returns true only if ALL sub-conditions are true
|
|
27
25
|
# Empty array returns true (vacuous truth)
|
|
28
26
|
def self.evaluate_all(conditions, context)
|
|
29
27
|
return true if conditions.is_a?(Array) && conditions.empty?
|
|
30
28
|
return false unless conditions.is_a?(Array)
|
|
29
|
+
|
|
31
30
|
conditions.all? { |cond| evaluate(cond, context) }
|
|
32
31
|
end
|
|
33
32
|
|
|
@@ -35,6 +34,7 @@ module DecisionAgent
|
|
|
35
34
|
# Empty array returns false (no options to match)
|
|
36
35
|
def self.evaluate_any(conditions, context)
|
|
37
36
|
return false unless conditions.is_a?(Array)
|
|
37
|
+
|
|
38
38
|
conditions.any? { |cond| evaluate(cond, context) }
|
|
39
39
|
end
|
|
40
40
|
|
|
@@ -117,6 +117,7 @@ module DecisionAgent
|
|
|
117
117
|
keys = key_path.to_s.split(".")
|
|
118
118
|
keys.reduce(hash) do |memo, key|
|
|
119
119
|
return nil unless memo.is_a?(Hash)
|
|
120
|
+
|
|
120
121
|
memo[key] || memo[key.to_sym]
|
|
121
122
|
end
|
|
122
123
|
end
|
|
@@ -126,7 +127,7 @@ module DecisionAgent
|
|
|
126
127
|
def self.comparable?(val1, val2)
|
|
127
128
|
(val1.is_a?(Numeric) || val1.is_a?(String)) &&
|
|
128
129
|
(val2.is_a?(Numeric) || val2.is_a?(String)) &&
|
|
129
|
-
val1.
|
|
130
|
+
val1.instance_of?(val2.class)
|
|
130
131
|
end
|
|
131
132
|
end
|
|
132
133
|
end
|
|
@@ -13,14 +13,12 @@ module DecisionAgent
|
|
|
13
13
|
rescue JSON::ParserError => e
|
|
14
14
|
raise InvalidRuleDslError, "Invalid JSON syntax: #{e.message}\n\n" \
|
|
15
15
|
"Please ensure your JSON is properly formatted. " \
|
|
16
|
-
"Common issues:\n" \
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
16
|
+
"Common issues:\n " \
|
|
17
|
+
"- Missing or extra commas\n " \
|
|
18
|
+
"- Unquoted keys or values\n " \
|
|
19
|
+
"- Unmatched brackets or braces"
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
private
|
|
23
|
-
|
|
24
22
|
def self.parse_json(input)
|
|
25
23
|
if input.is_a?(String)
|
|
26
24
|
JSON.parse(input)
|
|
@@ -31,18 +31,18 @@ module DecisionAgent
|
|
|
31
31
|
private
|
|
32
32
|
|
|
33
33
|
def validate_root_structure
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
return if @data.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
@errors << "Root element must be a hash/object, got #{@data.class}"
|
|
37
|
+
nil
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def validate_version
|
|
41
41
|
return if @errors.any? # Skip if root structure is invalid
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
return if @data.key?("version") || @data.key?(:version)
|
|
44
|
+
|
|
45
|
+
@errors << "Missing required field 'version'. Example: { \"version\": \"1.0\", ... }"
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
def validate_rules_array
|
|
@@ -55,9 +55,9 @@ module DecisionAgent
|
|
|
55
55
|
return
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
return if rules.is_a?(Array)
|
|
59
|
+
|
|
60
|
+
@errors << "Field 'rules' must be an array, got #{rules.class}. Example: \"rules\": [...]"
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
def validate_each_rule
|
|
@@ -93,9 +93,9 @@ module DecisionAgent
|
|
|
93
93
|
return
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
return if rule_id.is_a?(String) || rule_id.is_a?(Symbol)
|
|
97
|
+
|
|
98
|
+
@errors << "#{rule_path}: Field 'id' must be a string, got #{rule_id.class}"
|
|
99
99
|
end
|
|
100
100
|
|
|
101
101
|
def validate_if_clause(rule, rule_path)
|
|
@@ -150,9 +150,7 @@ module DecisionAgent
|
|
|
150
150
|
value = condition["value"] || condition[:value]
|
|
151
151
|
|
|
152
152
|
# Validate field
|
|
153
|
-
unless field
|
|
154
|
-
@errors << "#{path}: Field condition missing 'field' key"
|
|
155
|
-
end
|
|
153
|
+
@errors << "#{path}: Field condition missing 'field' key" unless field
|
|
156
154
|
|
|
157
155
|
# Validate operator
|
|
158
156
|
unless operator
|
|
@@ -174,10 +172,10 @@ module DecisionAgent
|
|
|
174
172
|
def validate_operator(operator, path)
|
|
175
173
|
operator_str = operator.to_s
|
|
176
174
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
175
|
+
return if SUPPORTED_OPERATORS.include?(operator_str)
|
|
176
|
+
|
|
177
|
+
@errors << "#{path}: Unsupported operator '#{operator}'. " \
|
|
178
|
+
"Supported operators: #{SUPPORTED_OPERATORS.join(', ')}"
|
|
181
179
|
end
|
|
182
180
|
|
|
183
181
|
def validate_field_path(field, path)
|
|
@@ -191,11 +189,11 @@ module DecisionAgent
|
|
|
191
189
|
# Validate dot-notation
|
|
192
190
|
parts = field.split(".")
|
|
193
191
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
192
|
+
return unless parts.any?(&:empty?)
|
|
193
|
+
|
|
194
|
+
@errors << "#{path}: Invalid field path '#{field}'. " \
|
|
195
|
+
"Dot-notation paths cannot have empty segments. " \
|
|
196
|
+
"Example: 'user.profile.role'"
|
|
199
197
|
end
|
|
200
198
|
|
|
201
199
|
def validate_all_condition(condition, path)
|
|
@@ -241,9 +239,7 @@ module DecisionAgent
|
|
|
241
239
|
# Validate decision
|
|
242
240
|
decision = then_clause["decision"] || then_clause[:decision]
|
|
243
241
|
|
|
244
|
-
unless decision
|
|
245
|
-
@errors << "#{rule_path}.then: Missing required field 'decision'"
|
|
246
|
-
end
|
|
242
|
+
@errors << "#{rule_path}.then: Missing required field 'decision'" unless decision
|
|
247
243
|
|
|
248
244
|
# Validate optional weight
|
|
249
245
|
weight = then_clause["weight"] || then_clause[:weight]
|
|
@@ -257,9 +253,9 @@ module DecisionAgent
|
|
|
257
253
|
# Validate optional reason
|
|
258
254
|
reason = then_clause["reason"] || then_clause[:reason]
|
|
259
255
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
256
|
+
return unless reason && !reason.is_a?(String)
|
|
257
|
+
|
|
258
|
+
@errors << "#{rule_path}.then.reason: Must be a string, got #{reason.class}"
|
|
263
259
|
end
|
|
264
260
|
|
|
265
261
|
def format_errors
|
|
@@ -3,13 +3,13 @@ module DecisionAgent
|
|
|
3
3
|
|
|
4
4
|
class InvalidRuleDslError < Error
|
|
5
5
|
def initialize(message = "Invalid rule DSL structure")
|
|
6
|
-
super
|
|
6
|
+
super
|
|
7
7
|
end
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
class NoEvaluationsError < Error
|
|
11
11
|
def initialize(message = "No evaluators returned a decision")
|
|
12
|
-
super
|
|
12
|
+
super
|
|
13
13
|
end
|
|
14
14
|
end
|
|
15
15
|
|
|
@@ -26,25 +26,25 @@ module DecisionAgent
|
|
|
26
26
|
|
|
27
27
|
class InvalidConfigurationError < Error
|
|
28
28
|
def initialize(message = "Invalid agent configuration")
|
|
29
|
-
super
|
|
29
|
+
super
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
class InvalidEvaluatorError < Error
|
|
34
34
|
def initialize(message = "Evaluator must respond to #evaluate")
|
|
35
|
-
super
|
|
35
|
+
super
|
|
36
36
|
end
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
class InvalidScoringStrategyError < Error
|
|
40
40
|
def initialize(message = "Scoring strategy must respond to #score")
|
|
41
|
-
super
|
|
41
|
+
super
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
class InvalidAuditAdapterError < Error
|
|
46
46
|
def initialize(message = "Audit adapter must respond to #record")
|
|
47
|
-
super
|
|
47
|
+
super
|
|
48
48
|
end
|
|
49
49
|
end
|
|
50
50
|
|
|
@@ -59,4 +59,19 @@ module DecisionAgent
|
|
|
59
59
|
super("Weight must be between 0.0 and 1.0, got: #{weight}")
|
|
60
60
|
end
|
|
61
61
|
end
|
|
62
|
+
|
|
63
|
+
class NotFoundError < Error
|
|
64
|
+
def initialize(message = "Resource not found")
|
|
65
|
+
super
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class ValidationError < Error
|
|
70
|
+
def initialize(message = "Validation failed")
|
|
71
|
+
super
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Alias for backward compatibility and clearer naming
|
|
76
|
+
ConfigurationError = InvalidConfigurationError
|
|
62
77
|
end
|
|
@@ -10,6 +10,8 @@ module DecisionAgent
|
|
|
10
10
|
@reason = reason.to_s.freeze
|
|
11
11
|
@evaluator_name = evaluator_name.to_s.freeze
|
|
12
12
|
@metadata = deep_freeze(metadata)
|
|
13
|
+
|
|
14
|
+
freeze
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def to_h
|
|
@@ -35,7 +37,7 @@ module DecisionAgent
|
|
|
35
37
|
|
|
36
38
|
def validate_weight!(weight)
|
|
37
39
|
w = weight.to_f
|
|
38
|
-
raise InvalidWeightError
|
|
40
|
+
raise InvalidWeightError, weight unless w.between?(0.0, 1.0)
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
def deep_freeze(obj)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DecisionAgent
|
|
4
|
+
# Validates evaluation objects for correctness and thread-safety
|
|
5
|
+
class EvaluationValidator
|
|
6
|
+
class ValidationError < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Validates a single evaluation
|
|
9
|
+
# @param evaluation [Evaluation] the evaluation to validate
|
|
10
|
+
# @raise [ValidationError] if validation fails
|
|
11
|
+
def self.validate!(evaluation)
|
|
12
|
+
raise ValidationError, "Evaluation cannot be nil" if evaluation.nil?
|
|
13
|
+
raise ValidationError, "Evaluation must be an Evaluation instance" unless evaluation.is_a?(Evaluation)
|
|
14
|
+
|
|
15
|
+
validate_decision!(evaluation.decision)
|
|
16
|
+
validate_weight!(evaluation.weight)
|
|
17
|
+
validate_reason!(evaluation.reason)
|
|
18
|
+
validate_evaluator_name!(evaluation.evaluator_name)
|
|
19
|
+
validate_frozen!(evaluation)
|
|
20
|
+
|
|
21
|
+
true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Validates an array of evaluations
|
|
25
|
+
# @param evaluations [Array<Evaluation>] the evaluations to validate
|
|
26
|
+
# @raise [ValidationError] if validation fails
|
|
27
|
+
def self.validate_all!(evaluations)
|
|
28
|
+
raise ValidationError, "Evaluations must be an Array" unless evaluations.is_a?(Array)
|
|
29
|
+
raise ValidationError, "Evaluations array cannot be empty" if evaluations.empty?
|
|
30
|
+
|
|
31
|
+
evaluations.each_with_index do |evaluation, index|
|
|
32
|
+
validate!(evaluation)
|
|
33
|
+
rescue ValidationError => e
|
|
34
|
+
raise ValidationError, "Validation failed for evaluation at index #{index}: #{e.message}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private_class_method def self.validate_decision!(decision)
|
|
41
|
+
raise ValidationError, "Decision cannot be nil" if decision.nil?
|
|
42
|
+
raise ValidationError, "Decision must be a String" unless decision.is_a?(String)
|
|
43
|
+
raise ValidationError, "Decision cannot be empty" if decision.strip.empty?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private_class_method def self.validate_weight!(weight)
|
|
47
|
+
raise ValidationError, "Weight cannot be nil" if weight.nil?
|
|
48
|
+
raise ValidationError, "Weight must be a Numeric" unless weight.is_a?(Numeric)
|
|
49
|
+
raise ValidationError, "Weight must be between 0 and 1" unless weight.between?(0, 1)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private_class_method def self.validate_reason!(reason)
|
|
53
|
+
raise ValidationError, "Reason cannot be nil" if reason.nil?
|
|
54
|
+
raise ValidationError, "Reason must be a String" unless reason.is_a?(String)
|
|
55
|
+
raise ValidationError, "Reason cannot be empty" if reason.strip.empty?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private_class_method def self.validate_evaluator_name!(name)
|
|
59
|
+
raise ValidationError, "Evaluator name cannot be nil" if name.nil?
|
|
60
|
+
raise ValidationError, "Evaluator name must be a String or Symbol" unless name.is_a?(String) || name.is_a?(Symbol)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private_class_method def self.validate_frozen!(evaluation)
|
|
64
|
+
raise ValidationError, "Evaluation must be frozen for thread-safety (call .freeze)" unless evaluation.frozen?
|
|
65
|
+
|
|
66
|
+
# Verify nested structures are also frozen
|
|
67
|
+
raise ValidationError, "Evaluation decision must be frozen" unless evaluation.decision.frozen?
|
|
68
|
+
|
|
69
|
+
raise ValidationError, "Evaluation reason must be frozen" unless evaluation.reason.frozen?
|
|
70
|
+
|
|
71
|
+
raise ValidationError, "Evaluation evaluator_name must be frozen" unless evaluation.evaluator_name.frozen?
|
|
72
|
+
|
|
73
|
+
return unless evaluation.metadata && !evaluation.metadata.frozen?
|
|
74
|
+
|
|
75
|
+
raise ValidationError, "Evaluation metadata must be frozen"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -10,6 +10,12 @@ module DecisionAgent
|
|
|
10
10
|
@ruleset = Dsl::RuleParser.parse(@rules_json)
|
|
11
11
|
@ruleset_name = @ruleset["ruleset"] || "unknown"
|
|
12
12
|
@name = name || "JsonRuleEvaluator(#{@ruleset_name})"
|
|
13
|
+
|
|
14
|
+
# Freeze ruleset to ensure thread-safety
|
|
15
|
+
deep_freeze(@ruleset)
|
|
16
|
+
@rules_json.freeze
|
|
17
|
+
@ruleset_name.freeze
|
|
18
|
+
@name.freeze
|
|
13
19
|
end
|
|
14
20
|
|
|
15
21
|
def evaluate(context, feedback: {})
|
|
@@ -46,6 +52,26 @@ module DecisionAgent
|
|
|
46
52
|
Dsl::ConditionEvaluator.evaluate(if_clause, context)
|
|
47
53
|
end
|
|
48
54
|
end
|
|
55
|
+
|
|
56
|
+
# Deep freeze helper method
|
|
57
|
+
def deep_freeze(obj)
|
|
58
|
+
case obj
|
|
59
|
+
when Hash
|
|
60
|
+
obj.each do |k, v|
|
|
61
|
+
deep_freeze(k)
|
|
62
|
+
deep_freeze(v)
|
|
63
|
+
end
|
|
64
|
+
obj.freeze
|
|
65
|
+
when Array
|
|
66
|
+
obj.each { |item| deep_freeze(item) }
|
|
67
|
+
obj.freeze
|
|
68
|
+
when String, Symbol, Numeric, TrueClass, FalseClass, NilClass
|
|
69
|
+
obj.freeze
|
|
70
|
+
else
|
|
71
|
+
obj.freeze if obj.respond_to?(:freeze)
|
|
72
|
+
end
|
|
73
|
+
obj
|
|
74
|
+
end
|
|
49
75
|
end
|
|
50
76
|
end
|
|
51
77
|
end
|
|
@@ -11,12 +11,8 @@ module DecisionAgent
|
|
|
11
11
|
@custom_metadata = metadata
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
def evaluate(
|
|
15
|
-
metadata =
|
|
16
|
-
@custom_metadata
|
|
17
|
-
else
|
|
18
|
-
{ type: "static" }
|
|
19
|
-
end
|
|
14
|
+
def evaluate(_context, feedback: {})
|
|
15
|
+
metadata = @custom_metadata || { type: "static" }
|
|
20
16
|
|
|
21
17
|
Evaluation.new(
|
|
22
18
|
decision: @decision,
|