decision_agent 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +1060 -0
- data/bin/decision_agent +104 -0
- data/lib/decision_agent/agent.rb +147 -0
- data/lib/decision_agent/audit/adapter.rb +9 -0
- data/lib/decision_agent/audit/logger_adapter.rb +27 -0
- data/lib/decision_agent/audit/null_adapter.rb +8 -0
- data/lib/decision_agent/context.rb +42 -0
- data/lib/decision_agent/decision.rb +51 -0
- data/lib/decision_agent/dsl/condition_evaluator.rb +133 -0
- data/lib/decision_agent/dsl/rule_parser.rb +36 -0
- data/lib/decision_agent/dsl/schema_validator.rb +275 -0
- data/lib/decision_agent/errors.rb +62 -0
- data/lib/decision_agent/evaluation.rb +52 -0
- data/lib/decision_agent/evaluators/base.rb +15 -0
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +51 -0
- data/lib/decision_agent/evaluators/static_evaluator.rb +31 -0
- data/lib/decision_agent/replay/replay.rb +147 -0
- data/lib/decision_agent/scoring/base.rb +19 -0
- data/lib/decision_agent/scoring/consensus.rb +40 -0
- data/lib/decision_agent/scoring/max_weight.rb +16 -0
- data/lib/decision_agent/scoring/threshold.rb +40 -0
- data/lib/decision_agent/scoring/weighted_average.rb +26 -0
- data/lib/decision_agent/version.rb +3 -0
- data/lib/decision_agent/web/public/app.js +580 -0
- data/lib/decision_agent/web/public/index.html +190 -0
- data/lib/decision_agent/web/public/styles.css +558 -0
- data/lib/decision_agent/web/server.rb +255 -0
- data/lib/decision_agent.rb +29 -0
- data/spec/agent_spec.rb +249 -0
- data/spec/api_contract_spec.rb +430 -0
- data/spec/audit_adapters_spec.rb +74 -0
- data/spec/comprehensive_edge_cases_spec.rb +1777 -0
- data/spec/context_spec.rb +84 -0
- data/spec/dsl_validation_spec.rb +648 -0
- data/spec/edge_cases_spec.rb +353 -0
- data/spec/examples/feedback_aware_evaluator_spec.rb +460 -0
- data/spec/json_rule_evaluator_spec.rb +587 -0
- data/spec/replay_edge_cases_spec.rb +699 -0
- data/spec/replay_spec.rb +210 -0
- data/spec/scoring_spec.rb +225 -0
- data/spec/spec_helper.rb +28 -0
- metadata +133 -0
data/bin/decision_agent
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require_relative "../lib/decision_agent"
|
|
5
|
+
require_relative "../lib/decision_agent/web/server"
|
|
6
|
+
|
|
7
|
+
def print_help
|
|
8
|
+
puts <<~HELP
|
|
9
|
+
DecisionAgent CLI
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
decision_agent [command] [options]
|
|
13
|
+
|
|
14
|
+
Commands:
|
|
15
|
+
web [PORT] Start the web UI rule builder (default port: 4567)
|
|
16
|
+
validate FILE Validate a rules JSON file
|
|
17
|
+
version Show version
|
|
18
|
+
help Show this help message
|
|
19
|
+
|
|
20
|
+
Examples:
|
|
21
|
+
decision_agent web # Start web UI on port 4567
|
|
22
|
+
decision_agent web 8080 # Start web UI on port 8080
|
|
23
|
+
decision_agent validate rules.json
|
|
24
|
+
decision_agent version
|
|
25
|
+
|
|
26
|
+
For more information, visit:
|
|
27
|
+
https://github.com/yourusername/decision_agent
|
|
28
|
+
HELP
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def start_web_ui(port = 4567)
|
|
32
|
+
puts "🎯 Starting DecisionAgent Rule Builder..."
|
|
33
|
+
puts "📍 Server: http://localhost:#{port}"
|
|
34
|
+
puts "⚡️ Press Ctrl+C to stop"
|
|
35
|
+
puts ""
|
|
36
|
+
|
|
37
|
+
DecisionAgent::Web::Server.start!(port: port)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def validate_file(filepath)
|
|
41
|
+
unless File.exist?(filepath)
|
|
42
|
+
puts "❌ Error: File not found: #{filepath}"
|
|
43
|
+
exit 1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
begin
|
|
47
|
+
puts "🔍 Validating #{filepath}..."
|
|
48
|
+
|
|
49
|
+
json_content = File.read(filepath)
|
|
50
|
+
data = JSON.parse(json_content)
|
|
51
|
+
|
|
52
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(data)
|
|
53
|
+
|
|
54
|
+
puts "✅ Validation successful!"
|
|
55
|
+
puts " Version: #{data['version'] || data[:version]}"
|
|
56
|
+
puts " Ruleset: #{data['ruleset'] || data[:ruleset]}"
|
|
57
|
+
puts " Rules: #{data['rules']&.size || 0}"
|
|
58
|
+
|
|
59
|
+
rescue JSON::ParserError => e
|
|
60
|
+
puts "❌ JSON Parsing Error:"
|
|
61
|
+
puts " #{e.message}"
|
|
62
|
+
exit 1
|
|
63
|
+
|
|
64
|
+
rescue DecisionAgent::InvalidRuleDslError => e
|
|
65
|
+
puts "❌ Validation Failed:"
|
|
66
|
+
puts ""
|
|
67
|
+
puts e.message
|
|
68
|
+
exit 1
|
|
69
|
+
|
|
70
|
+
rescue => e
|
|
71
|
+
puts "❌ Unexpected Error:"
|
|
72
|
+
puts " #{e.message}"
|
|
73
|
+
exit 1
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Main CLI handler
|
|
78
|
+
command = ARGV[0] || "help"
|
|
79
|
+
|
|
80
|
+
case command
|
|
81
|
+
when "web"
|
|
82
|
+
port = ARGV[1] ? ARGV[1].to_i : 4567
|
|
83
|
+
start_web_ui(port)
|
|
84
|
+
|
|
85
|
+
when "validate"
|
|
86
|
+
if ARGV[1].nil?
|
|
87
|
+
puts "❌ Error: Please provide a file path"
|
|
88
|
+
puts "Usage: decision_agent validate FILE"
|
|
89
|
+
exit 1
|
|
90
|
+
end
|
|
91
|
+
validate_file(ARGV[1])
|
|
92
|
+
|
|
93
|
+
when "version"
|
|
94
|
+
puts "DecisionAgent version #{DecisionAgent::VERSION}"
|
|
95
|
+
|
|
96
|
+
when "help", "-h", "--help"
|
|
97
|
+
print_help
|
|
98
|
+
|
|
99
|
+
else
|
|
100
|
+
puts "❌ Unknown command: #{command}"
|
|
101
|
+
puts ""
|
|
102
|
+
print_help
|
|
103
|
+
exit 1
|
|
104
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module DecisionAgent
|
|
5
|
+
class Agent
|
|
6
|
+
attr_reader :evaluators, :scoring_strategy, :audit_adapter
|
|
7
|
+
|
|
8
|
+
def initialize(evaluators:, scoring_strategy: nil, audit_adapter: nil)
|
|
9
|
+
@evaluators = Array(evaluators)
|
|
10
|
+
@scoring_strategy = scoring_strategy || Scoring::WeightedAverage.new
|
|
11
|
+
@audit_adapter = audit_adapter || Audit::NullAdapter.new
|
|
12
|
+
|
|
13
|
+
validate_configuration!
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def decide(context:, feedback: {})
|
|
17
|
+
ctx = context.is_a?(Context) ? context : Context.new(context)
|
|
18
|
+
|
|
19
|
+
evaluations = collect_evaluations(ctx, feedback)
|
|
20
|
+
|
|
21
|
+
raise NoEvaluationsError if evaluations.empty?
|
|
22
|
+
|
|
23
|
+
scored_result = @scoring_strategy.score(evaluations)
|
|
24
|
+
|
|
25
|
+
decision_value = scored_result[:decision]
|
|
26
|
+
confidence_value = scored_result[:confidence]
|
|
27
|
+
|
|
28
|
+
explanations = build_explanations(evaluations, decision_value, confidence_value)
|
|
29
|
+
|
|
30
|
+
audit_payload = build_audit_payload(
|
|
31
|
+
context: ctx,
|
|
32
|
+
evaluations: evaluations,
|
|
33
|
+
decision: decision_value,
|
|
34
|
+
confidence: confidence_value,
|
|
35
|
+
feedback: feedback
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
decision = Decision.new(
|
|
39
|
+
decision: decision_value,
|
|
40
|
+
confidence: confidence_value,
|
|
41
|
+
explanations: explanations,
|
|
42
|
+
evaluations: evaluations,
|
|
43
|
+
audit_payload: audit_payload
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@audit_adapter.record(decision, ctx)
|
|
47
|
+
|
|
48
|
+
decision
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def validate_configuration!
|
|
54
|
+
if @evaluators.empty?
|
|
55
|
+
raise InvalidConfigurationError, "At least one evaluator is required"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
@evaluators.each do |evaluator|
|
|
59
|
+
unless evaluator.respond_to?(:evaluate)
|
|
60
|
+
raise InvalidEvaluatorError
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
unless @scoring_strategy.respond_to?(:score)
|
|
65
|
+
raise InvalidScoringStrategyError
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
unless @audit_adapter.respond_to?(:record)
|
|
69
|
+
raise InvalidAuditAdapterError
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def collect_evaluations(context, feedback)
|
|
74
|
+
@evaluators.map do |evaluator|
|
|
75
|
+
begin
|
|
76
|
+
evaluator.evaluate(context, feedback: feedback)
|
|
77
|
+
rescue => e
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
end.compact
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_explanations(evaluations, final_decision, confidence)
|
|
84
|
+
explanations = []
|
|
85
|
+
|
|
86
|
+
matching_evals = evaluations.select { |e| e.decision == final_decision }
|
|
87
|
+
|
|
88
|
+
explanations << "Decision: #{final_decision} (confidence: #{confidence.round(2)})"
|
|
89
|
+
|
|
90
|
+
if matching_evals.size == 1
|
|
91
|
+
eval = matching_evals.first
|
|
92
|
+
explanations << "#{eval.evaluator_name}: #{eval.reason} (weight: #{eval.weight})"
|
|
93
|
+
elsif matching_evals.size > 1
|
|
94
|
+
explanations << "Based on #{matching_evals.size} evaluators:"
|
|
95
|
+
matching_evals.each do |eval|
|
|
96
|
+
explanations << " - #{eval.evaluator_name}: #{eval.reason} (weight: #{eval.weight})"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
conflicting_evals = evaluations.reject { |e| e.decision == final_decision }
|
|
101
|
+
if conflicting_evals.any?
|
|
102
|
+
explanations << "Conflicting evaluations resolved by #{@scoring_strategy.class.name.split('::').last}:"
|
|
103
|
+
conflicting_evals.each do |eval|
|
|
104
|
+
explanations << " - #{eval.evaluator_name}: suggested '#{eval.decision}' (weight: #{eval.weight})"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
explanations
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def build_audit_payload(context:, evaluations:, decision:, confidence:, feedback:)
|
|
112
|
+
timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ")
|
|
113
|
+
|
|
114
|
+
payload = {
|
|
115
|
+
timestamp: timestamp,
|
|
116
|
+
context: context.to_h,
|
|
117
|
+
feedback: feedback,
|
|
118
|
+
evaluations: evaluations.map(&:to_h),
|
|
119
|
+
decision: decision,
|
|
120
|
+
confidence: confidence,
|
|
121
|
+
scoring_strategy: @scoring_strategy.class.name,
|
|
122
|
+
agent_version: DecisionAgent::VERSION
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
payload[:deterministic_hash] = compute_deterministic_hash(payload)
|
|
126
|
+
payload
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def compute_deterministic_hash(payload)
|
|
130
|
+
hashable = payload.slice(:context, :evaluations, :decision, :confidence, :scoring_strategy)
|
|
131
|
+
canonical = canonical_json(hashable)
|
|
132
|
+
Digest::SHA256.hexdigest(canonical)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def canonical_json(obj)
|
|
136
|
+
case obj
|
|
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
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require "logger"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module DecisionAgent
|
|
5
|
+
module Audit
|
|
6
|
+
class LoggerAdapter < Adapter
|
|
7
|
+
attr_reader :logger
|
|
8
|
+
|
|
9
|
+
def initialize(logger: nil, level: Logger::INFO)
|
|
10
|
+
@logger = logger || Logger.new($stdout)
|
|
11
|
+
@logger.level = level
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def record(decision, context)
|
|
15
|
+
log_entry = {
|
|
16
|
+
timestamp: decision.audit_payload[:timestamp],
|
|
17
|
+
decision: decision.decision,
|
|
18
|
+
confidence: decision.confidence,
|
|
19
|
+
context: context.to_h,
|
|
20
|
+
audit_hash: decision.audit_payload[:deterministic_hash]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@logger.info(JSON.generate(log_entry))
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
class Context
|
|
3
|
+
attr_reader :data
|
|
4
|
+
|
|
5
|
+
def initialize(data)
|
|
6
|
+
@data = deep_freeze(data.is_a?(Hash) ? data : {})
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def [](key)
|
|
10
|
+
@data[key]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def fetch(key, default = nil)
|
|
14
|
+
@data.fetch(key, default)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def key?(key)
|
|
18
|
+
@data.key?(key)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_h
|
|
22
|
+
@data
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def ==(other)
|
|
26
|
+
other.is_a?(Context) && @data == other.data
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def deep_freeze(obj)
|
|
32
|
+
case obj
|
|
33
|
+
when Hash
|
|
34
|
+
obj.transform_values { |v| deep_freeze(v) }.freeze
|
|
35
|
+
when Array
|
|
36
|
+
obj.map { |v| deep_freeze(v) }.freeze
|
|
37
|
+
else
|
|
38
|
+
obj.freeze
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
class Decision
|
|
3
|
+
attr_reader :decision, :confidence, :explanations, :evaluations, :audit_payload
|
|
4
|
+
|
|
5
|
+
def initialize(decision:, confidence:, explanations:, evaluations:, audit_payload:)
|
|
6
|
+
validate_confidence!(confidence)
|
|
7
|
+
|
|
8
|
+
@decision = decision.to_s.freeze
|
|
9
|
+
@confidence = confidence.to_f
|
|
10
|
+
@explanations = Array(explanations).map(&:freeze).freeze
|
|
11
|
+
@evaluations = Array(evaluations).freeze
|
|
12
|
+
@audit_payload = deep_freeze(audit_payload)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_h
|
|
16
|
+
{
|
|
17
|
+
decision: @decision,
|
|
18
|
+
confidence: @confidence,
|
|
19
|
+
explanations: @explanations,
|
|
20
|
+
evaluations: @evaluations.map(&:to_h),
|
|
21
|
+
audit_payload: @audit_payload
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def ==(other)
|
|
26
|
+
other.is_a?(Decision) &&
|
|
27
|
+
@decision == other.decision &&
|
|
28
|
+
(@confidence - other.confidence).abs < 0.0001 &&
|
|
29
|
+
@explanations == other.explanations &&
|
|
30
|
+
@evaluations == other.evaluations
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def validate_confidence!(confidence)
|
|
36
|
+
c = confidence.to_f
|
|
37
|
+
raise InvalidConfidenceError.new(confidence) unless c >= 0.0 && c <= 1.0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def deep_freeze(obj)
|
|
41
|
+
case obj
|
|
42
|
+
when Hash
|
|
43
|
+
obj.transform_values { |v| deep_freeze(v) }.freeze
|
|
44
|
+
when Array
|
|
45
|
+
obj.map { |v| deep_freeze(v) }.freeze
|
|
46
|
+
else
|
|
47
|
+
obj.freeze
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module Dsl
|
|
3
|
+
# Evaluates conditions in the rule DSL against a context
|
|
4
|
+
#
|
|
5
|
+
# Supports:
|
|
6
|
+
# - Field conditions with various operators
|
|
7
|
+
# - Nested field access via dot notation (e.g., "user.profile.role")
|
|
8
|
+
# - Logical operators (all/any)
|
|
9
|
+
class ConditionEvaluator
|
|
10
|
+
def self.evaluate(condition, context)
|
|
11
|
+
return false unless condition.is_a?(Hash)
|
|
12
|
+
|
|
13
|
+
if condition.key?("all")
|
|
14
|
+
evaluate_all(condition["all"], context)
|
|
15
|
+
elsif condition.key?("any")
|
|
16
|
+
evaluate_any(condition["any"], context)
|
|
17
|
+
elsif condition.key?("field")
|
|
18
|
+
evaluate_field_condition(condition, context)
|
|
19
|
+
else
|
|
20
|
+
false
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
# Evaluates 'all' condition - returns true only if ALL sub-conditions are true
|
|
27
|
+
# Empty array returns true (vacuous truth)
|
|
28
|
+
def self.evaluate_all(conditions, context)
|
|
29
|
+
return true if conditions.is_a?(Array) && conditions.empty?
|
|
30
|
+
return false unless conditions.is_a?(Array)
|
|
31
|
+
conditions.all? { |cond| evaluate(cond, context) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Evaluates 'any' condition - returns true if AT LEAST ONE sub-condition is true
|
|
35
|
+
# Empty array returns false (no options to match)
|
|
36
|
+
def self.evaluate_any(conditions, context)
|
|
37
|
+
return false unless conditions.is_a?(Array)
|
|
38
|
+
conditions.any? { |cond| evaluate(cond, context) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.evaluate_field_condition(condition, context)
|
|
42
|
+
field = condition["field"]
|
|
43
|
+
op = condition["op"]
|
|
44
|
+
expected_value = condition["value"]
|
|
45
|
+
|
|
46
|
+
actual_value = get_nested_value(context.to_h, field)
|
|
47
|
+
|
|
48
|
+
case op
|
|
49
|
+
when "eq"
|
|
50
|
+
# Equality - uses Ruby's == for comparison
|
|
51
|
+
actual_value == expected_value
|
|
52
|
+
|
|
53
|
+
when "neq"
|
|
54
|
+
# Not equal - inverse of ==
|
|
55
|
+
actual_value != expected_value
|
|
56
|
+
|
|
57
|
+
when "gt"
|
|
58
|
+
# Greater than - only for comparable types (numbers, strings)
|
|
59
|
+
comparable?(actual_value, expected_value) && actual_value > expected_value
|
|
60
|
+
|
|
61
|
+
when "gte"
|
|
62
|
+
# Greater than or equal - only for comparable types
|
|
63
|
+
comparable?(actual_value, expected_value) && actual_value >= expected_value
|
|
64
|
+
|
|
65
|
+
when "lt"
|
|
66
|
+
# Less than - only for comparable types
|
|
67
|
+
comparable?(actual_value, expected_value) && actual_value < expected_value
|
|
68
|
+
|
|
69
|
+
when "lte"
|
|
70
|
+
# Less than or equal - only for comparable types
|
|
71
|
+
comparable?(actual_value, expected_value) && actual_value <= expected_value
|
|
72
|
+
|
|
73
|
+
when "in"
|
|
74
|
+
# Array membership - checks if actual_value is in the expected array
|
|
75
|
+
Array(expected_value).include?(actual_value)
|
|
76
|
+
|
|
77
|
+
when "present"
|
|
78
|
+
# PRESENT SEMANTICS:
|
|
79
|
+
# Returns true if value exists AND is not empty
|
|
80
|
+
# - nil: false
|
|
81
|
+
# - Empty string "": false
|
|
82
|
+
# - Empty array []: false
|
|
83
|
+
# - Empty hash {}: false
|
|
84
|
+
# - Zero 0: true (zero is a valid value)
|
|
85
|
+
# - False boolean: true (false is a valid value)
|
|
86
|
+
# - Non-empty values: true
|
|
87
|
+
!actual_value.nil? && (actual_value.respond_to?(:empty?) ? !actual_value.empty? : true)
|
|
88
|
+
|
|
89
|
+
when "blank"
|
|
90
|
+
# BLANK SEMANTICS:
|
|
91
|
+
# Returns true if value is nil OR empty
|
|
92
|
+
# - nil: true
|
|
93
|
+
# - Empty string "": true
|
|
94
|
+
# - Empty array []: true
|
|
95
|
+
# - Empty hash {}: true
|
|
96
|
+
# - Zero 0: false (zero is a valid value)
|
|
97
|
+
# - False boolean: false (false is a valid value)
|
|
98
|
+
# - Non-empty values: false
|
|
99
|
+
actual_value.nil? || (actual_value.respond_to?(:empty?) ? actual_value.empty? : false)
|
|
100
|
+
|
|
101
|
+
else
|
|
102
|
+
# Unknown operator - returns false (fail-safe)
|
|
103
|
+
# Note: Validation should catch this earlier
|
|
104
|
+
false
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Retrieves nested values from a hash using dot notation
|
|
109
|
+
#
|
|
110
|
+
# Examples:
|
|
111
|
+
# get_nested_value({user: {role: "admin"}}, "user.role") # => "admin"
|
|
112
|
+
# get_nested_value({user: {role: "admin"}}, "user.missing") # => nil
|
|
113
|
+
# get_nested_value({user: nil}, "user.role") # => nil
|
|
114
|
+
#
|
|
115
|
+
# Supports both string and symbol keys in the hash
|
|
116
|
+
def self.get_nested_value(hash, key_path)
|
|
117
|
+
keys = key_path.to_s.split(".")
|
|
118
|
+
keys.reduce(hash) do |memo, key|
|
|
119
|
+
return nil unless memo.is_a?(Hash)
|
|
120
|
+
memo[key] || memo[key.to_sym]
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Checks if two values can be compared with <, >, <=, >=
|
|
125
|
+
# Only allows comparison between values of the same type
|
|
126
|
+
def self.comparable?(val1, val2)
|
|
127
|
+
(val1.is_a?(Numeric) || val1.is_a?(String)) &&
|
|
128
|
+
(val2.is_a?(Numeric) || val2.is_a?(String)) &&
|
|
129
|
+
val1.class == val2.class
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module DecisionAgent
|
|
4
|
+
module Dsl
|
|
5
|
+
class RuleParser
|
|
6
|
+
def self.parse(json_string)
|
|
7
|
+
data = parse_json(json_string)
|
|
8
|
+
|
|
9
|
+
# Use comprehensive schema validator
|
|
10
|
+
SchemaValidator.validate!(data)
|
|
11
|
+
|
|
12
|
+
data
|
|
13
|
+
rescue JSON::ParserError => e
|
|
14
|
+
raise InvalidRuleDslError, "Invalid JSON syntax: #{e.message}\n\n" \
|
|
15
|
+
"Please ensure your JSON is properly formatted. " \
|
|
16
|
+
"Common issues:\n" \
|
|
17
|
+
" - Missing or extra commas\n" \
|
|
18
|
+
" - Unquoted keys or values\n" \
|
|
19
|
+
" - Unmatched brackets or braces"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def self.parse_json(input)
|
|
25
|
+
if input.is_a?(String)
|
|
26
|
+
JSON.parse(input)
|
|
27
|
+
elsif input.is_a?(Hash)
|
|
28
|
+
# Already parsed, convert to string keys for consistency
|
|
29
|
+
JSON.parse(JSON.generate(input))
|
|
30
|
+
else
|
|
31
|
+
raise InvalidRuleDslError, "Expected JSON string or Hash, got #{input.class}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|