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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +1060 -0
  4. data/bin/decision_agent +104 -0
  5. data/lib/decision_agent/agent.rb +147 -0
  6. data/lib/decision_agent/audit/adapter.rb +9 -0
  7. data/lib/decision_agent/audit/logger_adapter.rb +27 -0
  8. data/lib/decision_agent/audit/null_adapter.rb +8 -0
  9. data/lib/decision_agent/context.rb +42 -0
  10. data/lib/decision_agent/decision.rb +51 -0
  11. data/lib/decision_agent/dsl/condition_evaluator.rb +133 -0
  12. data/lib/decision_agent/dsl/rule_parser.rb +36 -0
  13. data/lib/decision_agent/dsl/schema_validator.rb +275 -0
  14. data/lib/decision_agent/errors.rb +62 -0
  15. data/lib/decision_agent/evaluation.rb +52 -0
  16. data/lib/decision_agent/evaluators/base.rb +15 -0
  17. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +51 -0
  18. data/lib/decision_agent/evaluators/static_evaluator.rb +31 -0
  19. data/lib/decision_agent/replay/replay.rb +147 -0
  20. data/lib/decision_agent/scoring/base.rb +19 -0
  21. data/lib/decision_agent/scoring/consensus.rb +40 -0
  22. data/lib/decision_agent/scoring/max_weight.rb +16 -0
  23. data/lib/decision_agent/scoring/threshold.rb +40 -0
  24. data/lib/decision_agent/scoring/weighted_average.rb +26 -0
  25. data/lib/decision_agent/version.rb +3 -0
  26. data/lib/decision_agent/web/public/app.js +580 -0
  27. data/lib/decision_agent/web/public/index.html +190 -0
  28. data/lib/decision_agent/web/public/styles.css +558 -0
  29. data/lib/decision_agent/web/server.rb +255 -0
  30. data/lib/decision_agent.rb +29 -0
  31. data/spec/agent_spec.rb +249 -0
  32. data/spec/api_contract_spec.rb +430 -0
  33. data/spec/audit_adapters_spec.rb +74 -0
  34. data/spec/comprehensive_edge_cases_spec.rb +1777 -0
  35. data/spec/context_spec.rb +84 -0
  36. data/spec/dsl_validation_spec.rb +648 -0
  37. data/spec/edge_cases_spec.rb +353 -0
  38. data/spec/examples/feedback_aware_evaluator_spec.rb +460 -0
  39. data/spec/json_rule_evaluator_spec.rb +587 -0
  40. data/spec/replay_edge_cases_spec.rb +699 -0
  41. data/spec/replay_spec.rb +210 -0
  42. data/spec/scoring_spec.rb +225 -0
  43. data/spec/spec_helper.rb +28 -0
  44. metadata +133 -0
@@ -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,9 @@
1
+ module DecisionAgent
2
+ module Audit
3
+ class Adapter
4
+ def record(decision, context)
5
+ raise NotImplementedError, "Subclasses must implement #record"
6
+ end
7
+ end
8
+ end
9
+ 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,8 @@
1
+ module DecisionAgent
2
+ module Audit
3
+ class NullAdapter < Adapter
4
+ def record(decision, context)
5
+ end
6
+ end
7
+ end
8
+ 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