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
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
require "sinatra/base"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module DecisionAgent
|
|
5
|
+
module Web
|
|
6
|
+
class Server < Sinatra::Base
|
|
7
|
+
set :public_folder, File.expand_path("public", __dir__)
|
|
8
|
+
set :views, File.expand_path("views", __dir__)
|
|
9
|
+
set :bind, "0.0.0.0"
|
|
10
|
+
set :port, 4567
|
|
11
|
+
|
|
12
|
+
# Enable CORS for API calls
|
|
13
|
+
before do
|
|
14
|
+
headers["Access-Control-Allow-Origin"] = "*"
|
|
15
|
+
headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
|
16
|
+
headers["Access-Control-Allow-Headers"] = "Content-Type"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# OPTIONS handler for CORS preflight
|
|
20
|
+
options "*" do
|
|
21
|
+
200
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Main page - serve the rule builder UI
|
|
25
|
+
get "/" do
|
|
26
|
+
send_file File.join(settings.public_folder, "index.html")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# API: Validate rules
|
|
30
|
+
post "/api/validate" do
|
|
31
|
+
content_type :json
|
|
32
|
+
|
|
33
|
+
begin
|
|
34
|
+
# Parse request body
|
|
35
|
+
request_body = request.body.read
|
|
36
|
+
data = JSON.parse(request_body)
|
|
37
|
+
|
|
38
|
+
# Validate using DecisionAgent's SchemaValidator
|
|
39
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(data)
|
|
40
|
+
|
|
41
|
+
# If validation passes
|
|
42
|
+
{
|
|
43
|
+
valid: true,
|
|
44
|
+
message: "Rules are valid!"
|
|
45
|
+
}.to_json
|
|
46
|
+
|
|
47
|
+
rescue JSON::ParserError => e
|
|
48
|
+
status 400
|
|
49
|
+
{
|
|
50
|
+
valid: false,
|
|
51
|
+
errors: ["Invalid JSON: #{e.message}"]
|
|
52
|
+
}.to_json
|
|
53
|
+
|
|
54
|
+
rescue DecisionAgent::InvalidRuleDslError => e
|
|
55
|
+
# Validation failed
|
|
56
|
+
status 422
|
|
57
|
+
{
|
|
58
|
+
valid: false,
|
|
59
|
+
errors: parse_validation_errors(e.message)
|
|
60
|
+
}.to_json
|
|
61
|
+
|
|
62
|
+
rescue => e
|
|
63
|
+
# Unexpected error
|
|
64
|
+
status 500
|
|
65
|
+
{
|
|
66
|
+
valid: false,
|
|
67
|
+
errors: ["Server error: #{e.message}"]
|
|
68
|
+
}.to_json
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# API: Test rule evaluation (optional feature)
|
|
73
|
+
post "/api/evaluate" do
|
|
74
|
+
content_type :json
|
|
75
|
+
|
|
76
|
+
begin
|
|
77
|
+
request_body = request.body.read
|
|
78
|
+
data = JSON.parse(request_body)
|
|
79
|
+
|
|
80
|
+
rules_json = data["rules"]
|
|
81
|
+
context = data["context"] || {}
|
|
82
|
+
|
|
83
|
+
# Create evaluator
|
|
84
|
+
evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules_json)
|
|
85
|
+
|
|
86
|
+
# Evaluate
|
|
87
|
+
result = evaluator.evaluate(DecisionAgent::Context.new(context))
|
|
88
|
+
|
|
89
|
+
if result
|
|
90
|
+
{
|
|
91
|
+
success: true,
|
|
92
|
+
decision: result.decision,
|
|
93
|
+
weight: result.weight,
|
|
94
|
+
reason: result.reason,
|
|
95
|
+
evaluator_name: result.evaluator_name,
|
|
96
|
+
metadata: result.metadata
|
|
97
|
+
}.to_json
|
|
98
|
+
else
|
|
99
|
+
{
|
|
100
|
+
success: true,
|
|
101
|
+
decision: nil,
|
|
102
|
+
message: "No rules matched the given context"
|
|
103
|
+
}.to_json
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
rescue => e
|
|
107
|
+
status 500
|
|
108
|
+
{
|
|
109
|
+
success: false,
|
|
110
|
+
error: e.message
|
|
111
|
+
}.to_json
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# API: Get example rules
|
|
116
|
+
get "/api/examples" do
|
|
117
|
+
content_type :json
|
|
118
|
+
|
|
119
|
+
examples = [
|
|
120
|
+
{
|
|
121
|
+
name: "Approval Workflow",
|
|
122
|
+
description: "Basic approval rules for requests",
|
|
123
|
+
rules: {
|
|
124
|
+
version: "1.0",
|
|
125
|
+
ruleset: "approval_workflow",
|
|
126
|
+
rules: [
|
|
127
|
+
{
|
|
128
|
+
id: "admin_auto_approve",
|
|
129
|
+
if: { field: "user.role", op: "eq", value: "admin" },
|
|
130
|
+
then: { decision: "approve", weight: 0.95, reason: "Admin user" }
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: "low_amount_approve",
|
|
134
|
+
if: { field: "amount", op: "lt", value: 1000 },
|
|
135
|
+
then: { decision: "approve", weight: 0.8, reason: "Low amount" }
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: "high_amount_review",
|
|
139
|
+
if: { field: "amount", op: "gte", value: 10000 },
|
|
140
|
+
then: { decision: "manual_review", weight: 0.9, reason: "High amount requires review" }
|
|
141
|
+
}
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: "User Access Control",
|
|
147
|
+
description: "Role-based access control rules",
|
|
148
|
+
rules: {
|
|
149
|
+
version: "1.0",
|
|
150
|
+
ruleset: "access_control",
|
|
151
|
+
rules: [
|
|
152
|
+
{
|
|
153
|
+
id: "admin_full_access",
|
|
154
|
+
if: {
|
|
155
|
+
all: [
|
|
156
|
+
{ field: "user.role", op: "eq", value: "admin" },
|
|
157
|
+
{ field: "user.active", op: "eq", value: true }
|
|
158
|
+
]
|
|
159
|
+
},
|
|
160
|
+
then: { decision: "allow", weight: 1.0, reason: "Active admin user" }
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
id: "guest_read_only",
|
|
164
|
+
if: {
|
|
165
|
+
all: [
|
|
166
|
+
{ field: "user.role", op: "eq", value: "guest" },
|
|
167
|
+
{ field: "action", op: "eq", value: "read" }
|
|
168
|
+
]
|
|
169
|
+
},
|
|
170
|
+
then: { decision: "allow", weight: 0.7, reason: "Guest read access" }
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: "inactive_user_deny",
|
|
174
|
+
if: { field: "user.active", op: "eq", value: false },
|
|
175
|
+
then: { decision: "deny", weight: 1.0, reason: "Inactive user account" }
|
|
176
|
+
}
|
|
177
|
+
]
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: "Content Moderation",
|
|
182
|
+
description: "Automatic content moderation rules",
|
|
183
|
+
rules: {
|
|
184
|
+
version: "1.0",
|
|
185
|
+
ruleset: "content_moderation",
|
|
186
|
+
rules: [
|
|
187
|
+
{
|
|
188
|
+
id: "verified_user_approve",
|
|
189
|
+
if: {
|
|
190
|
+
all: [
|
|
191
|
+
{ field: "author.verified", op: "eq", value: true },
|
|
192
|
+
{ field: "content_length", op: "lt", value: 5000 }
|
|
193
|
+
]
|
|
194
|
+
},
|
|
195
|
+
then: { decision: "approve", weight: 0.85, reason: "Verified author with reasonable length" }
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
id: "missing_content_reject",
|
|
199
|
+
if: {
|
|
200
|
+
any: [
|
|
201
|
+
{ field: "content", op: "blank" },
|
|
202
|
+
{ field: "content_length", op: "eq", value: 0 }
|
|
203
|
+
]
|
|
204
|
+
},
|
|
205
|
+
then: { decision: "reject", weight: 1.0, reason: "Empty content" }
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
id: "flagged_content_review",
|
|
209
|
+
if: { field: "flags", op: "present" },
|
|
210
|
+
then: { decision: "manual_review", weight: 0.9, reason: "Content has been flagged" }
|
|
211
|
+
}
|
|
212
|
+
]
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
examples.to_json
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Health check
|
|
221
|
+
get "/health" do
|
|
222
|
+
content_type :json
|
|
223
|
+
{ status: "ok", version: DecisionAgent::VERSION }.to_json
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
def parse_validation_errors(error_message)
|
|
229
|
+
# Extract individual errors from the formatted error message
|
|
230
|
+
errors = []
|
|
231
|
+
|
|
232
|
+
# The error message is formatted with numbered errors
|
|
233
|
+
lines = error_message.split("\n")
|
|
234
|
+
|
|
235
|
+
lines.each do |line|
|
|
236
|
+
# Match lines like " 1. Error message"
|
|
237
|
+
if line.match?(/^\s*\d+\.\s+/)
|
|
238
|
+
error = line.gsub(/^\s*\d+\.\s+/, "").strip
|
|
239
|
+
errors << error unless error.empty?
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# If no errors were parsed, return the full message
|
|
244
|
+
errors.empty? ? [error_message] : errors
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Class method to start the server
|
|
248
|
+
def self.start!(port: 4567, host: "0.0.0.0")
|
|
249
|
+
set :port, port
|
|
250
|
+
set :bind, host
|
|
251
|
+
run!
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
require_relative "decision_agent/version"
|
|
2
|
+
require_relative "decision_agent/errors"
|
|
3
|
+
require_relative "decision_agent/context"
|
|
4
|
+
require_relative "decision_agent/evaluation"
|
|
5
|
+
require_relative "decision_agent/decision"
|
|
6
|
+
require_relative "decision_agent/agent"
|
|
7
|
+
|
|
8
|
+
require_relative "decision_agent/evaluators/base"
|
|
9
|
+
require_relative "decision_agent/evaluators/static_evaluator"
|
|
10
|
+
require_relative "decision_agent/evaluators/json_rule_evaluator"
|
|
11
|
+
|
|
12
|
+
require_relative "decision_agent/dsl/schema_validator"
|
|
13
|
+
require_relative "decision_agent/dsl/rule_parser"
|
|
14
|
+
require_relative "decision_agent/dsl/condition_evaluator"
|
|
15
|
+
|
|
16
|
+
require_relative "decision_agent/scoring/base"
|
|
17
|
+
require_relative "decision_agent/scoring/weighted_average"
|
|
18
|
+
require_relative "decision_agent/scoring/max_weight"
|
|
19
|
+
require_relative "decision_agent/scoring/consensus"
|
|
20
|
+
require_relative "decision_agent/scoring/threshold"
|
|
21
|
+
|
|
22
|
+
require_relative "decision_agent/audit/adapter"
|
|
23
|
+
require_relative "decision_agent/audit/null_adapter"
|
|
24
|
+
require_relative "decision_agent/audit/logger_adapter"
|
|
25
|
+
|
|
26
|
+
require_relative "decision_agent/replay/replay"
|
|
27
|
+
|
|
28
|
+
module DecisionAgent
|
|
29
|
+
end
|
data/spec/agent_spec.rb
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe DecisionAgent::Agent do
|
|
4
|
+
describe "#initialize" do
|
|
5
|
+
it "requires at least one evaluator" do
|
|
6
|
+
expect {
|
|
7
|
+
DecisionAgent::Agent.new(evaluators: [])
|
|
8
|
+
}.to raise_error(DecisionAgent::InvalidConfigurationError, /at least one evaluator/i)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it "validates evaluators respond to #evaluate" do
|
|
12
|
+
invalid_evaluator = Object.new
|
|
13
|
+
|
|
14
|
+
expect {
|
|
15
|
+
DecisionAgent::Agent.new(evaluators: [invalid_evaluator])
|
|
16
|
+
}.to raise_error(DecisionAgent::InvalidEvaluatorError)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it "validates scoring strategy responds to #score" do
|
|
20
|
+
evaluator = DecisionAgent::Evaluators::StaticEvaluator.new(decision: "approve")
|
|
21
|
+
invalid_strategy = Object.new
|
|
22
|
+
|
|
23
|
+
expect {
|
|
24
|
+
DecisionAgent::Agent.new(
|
|
25
|
+
evaluators: [evaluator],
|
|
26
|
+
scoring_strategy: invalid_strategy
|
|
27
|
+
)
|
|
28
|
+
}.to raise_error(DecisionAgent::InvalidScoringStrategyError)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it "validates audit adapter responds to #record" do
|
|
32
|
+
evaluator = DecisionAgent::Evaluators::StaticEvaluator.new(decision: "approve")
|
|
33
|
+
invalid_adapter = Object.new
|
|
34
|
+
|
|
35
|
+
expect {
|
|
36
|
+
DecisionAgent::Agent.new(
|
|
37
|
+
evaluators: [evaluator],
|
|
38
|
+
audit_adapter: invalid_adapter
|
|
39
|
+
)
|
|
40
|
+
}.to raise_error(DecisionAgent::InvalidAuditAdapterError)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it "uses defaults when optional parameters are omitted" do
|
|
44
|
+
evaluator = DecisionAgent::Evaluators::StaticEvaluator.new(decision: "approve")
|
|
45
|
+
|
|
46
|
+
agent = DecisionAgent::Agent.new(evaluators: [evaluator])
|
|
47
|
+
|
|
48
|
+
expect(agent.scoring_strategy).to be_a(DecisionAgent::Scoring::WeightedAverage)
|
|
49
|
+
expect(agent.audit_adapter).to be_a(DecisionAgent::Audit::NullAdapter)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
describe "#decide" do
|
|
54
|
+
it "returns a Decision object with all required fields" do
|
|
55
|
+
evaluator = DecisionAgent::Evaluators::StaticEvaluator.new(
|
|
56
|
+
decision: "approve",
|
|
57
|
+
weight: 0.8,
|
|
58
|
+
reason: "Test approval"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
agent = DecisionAgent::Agent.new(evaluators: [evaluator])
|
|
62
|
+
|
|
63
|
+
result = agent.decide(context: { user: "test" })
|
|
64
|
+
|
|
65
|
+
expect(result).to be_a(DecisionAgent::Decision)
|
|
66
|
+
expect(result.decision).to eq("approve")
|
|
67
|
+
expect(result.confidence).to be_between(0.0, 1.0)
|
|
68
|
+
expect(result.explanations).to be_an(Array)
|
|
69
|
+
expect(result.evaluations).to be_an(Array)
|
|
70
|
+
expect(result.audit_payload).to be_a(Hash)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it "accepts Context object or Hash for context parameter" do
|
|
74
|
+
evaluator = DecisionAgent::Evaluators::StaticEvaluator.new(decision: "approve")
|
|
75
|
+
agent = DecisionAgent::Agent.new(evaluators: [evaluator])
|
|
76
|
+
|
|
77
|
+
result1 = agent.decide(context: { user: "test" })
|
|
78
|
+
result2 = agent.decide(context: DecisionAgent::Context.new({ user: "test" }))
|
|
79
|
+
|
|
80
|
+
expect(result1.decision).to eq(result2.decision)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it "raises NoEvaluationsError when no evaluators return decisions" do
|
|
84
|
+
failing_evaluator = Class.new(DecisionAgent::Evaluators::Base) do
|
|
85
|
+
def evaluate(context, feedback: {})
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
agent = DecisionAgent::Agent.new(evaluators: [failing_evaluator.new])
|
|
91
|
+
|
|
92
|
+
expect {
|
|
93
|
+
agent.decide(context: {})
|
|
94
|
+
}.to raise_error(DecisionAgent::NoEvaluationsError)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "includes feedback in evaluation" do
|
|
98
|
+
feedback_evaluator = Class.new(DecisionAgent::Evaluators::Base) do
|
|
99
|
+
def evaluate(context, feedback: {})
|
|
100
|
+
decision = feedback[:override] ? "reject" : "approve"
|
|
101
|
+
DecisionAgent::Evaluation.new(
|
|
102
|
+
decision: decision,
|
|
103
|
+
weight: 1.0,
|
|
104
|
+
reason: "Feedback-based",
|
|
105
|
+
evaluator_name: "FeedbackEvaluator"
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
agent = DecisionAgent::Agent.new(evaluators: [feedback_evaluator.new])
|
|
111
|
+
|
|
112
|
+
result1 = agent.decide(context: {}, feedback: {})
|
|
113
|
+
result2 = agent.decide(context: {}, feedback: { override: true })
|
|
114
|
+
|
|
115
|
+
expect(result1.decision).to eq("approve")
|
|
116
|
+
expect(result2.decision).to eq("reject")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it "records decision via audit adapter" do
|
|
120
|
+
evaluator = DecisionAgent::Evaluators::StaticEvaluator.new(decision: "approve")
|
|
121
|
+
|
|
122
|
+
audit_adapter = Class.new(DecisionAgent::Audit::Adapter) do
|
|
123
|
+
attr_reader :recorded_decision, :recorded_context
|
|
124
|
+
|
|
125
|
+
def record(decision, context)
|
|
126
|
+
@recorded_decision = decision
|
|
127
|
+
@recorded_context = context
|
|
128
|
+
end
|
|
129
|
+
end.new
|
|
130
|
+
|
|
131
|
+
agent = DecisionAgent::Agent.new(
|
|
132
|
+
evaluators: [evaluator],
|
|
133
|
+
audit_adapter: audit_adapter
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
result = agent.decide(context: { user: "test" })
|
|
137
|
+
|
|
138
|
+
expect(audit_adapter.recorded_decision).to eq(result)
|
|
139
|
+
expect(audit_adapter.recorded_context.to_h).to eq({ user: "test" })
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it "includes deterministic hash in audit payload" do
|
|
143
|
+
evaluator = DecisionAgent::Evaluators::StaticEvaluator.new(decision: "approve", weight: 0.8)
|
|
144
|
+
agent = DecisionAgent::Agent.new(evaluators: [evaluator])
|
|
145
|
+
|
|
146
|
+
result1 = agent.decide(context: { user: "test" })
|
|
147
|
+
result2 = agent.decide(context: { user: "test" })
|
|
148
|
+
|
|
149
|
+
expect(result1.audit_payload[:deterministic_hash]).to be_a(String)
|
|
150
|
+
expect(result1.audit_payload[:deterministic_hash]).to eq(result2.audit_payload[:deterministic_hash])
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it "produces different hashes for different contexts" do
|
|
154
|
+
evaluator = DecisionAgent::Evaluators::StaticEvaluator.new(decision: "approve")
|
|
155
|
+
agent = DecisionAgent::Agent.new(evaluators: [evaluator])
|
|
156
|
+
|
|
157
|
+
result1 = agent.decide(context: { user: "alice" })
|
|
158
|
+
result2 = agent.decide(context: { user: "bob" })
|
|
159
|
+
|
|
160
|
+
expect(result1.audit_payload[:deterministic_hash]).not_to eq(result2.audit_payload[:deterministic_hash])
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
describe "conflict resolution" do
|
|
165
|
+
it "resolves conflicting evaluations using scoring strategy" do
|
|
166
|
+
evaluator1 = DecisionAgent::Evaluators::StaticEvaluator.new(
|
|
167
|
+
decision: "approve",
|
|
168
|
+
weight: 0.6,
|
|
169
|
+
name: "Evaluator1"
|
|
170
|
+
)
|
|
171
|
+
evaluator2 = DecisionAgent::Evaluators::StaticEvaluator.new(
|
|
172
|
+
decision: "reject",
|
|
173
|
+
weight: 0.9,
|
|
174
|
+
name: "Evaluator2"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
agent = DecisionAgent::Agent.new(
|
|
178
|
+
evaluators: [evaluator1, evaluator2],
|
|
179
|
+
scoring_strategy: DecisionAgent::Scoring::MaxWeight.new
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
result = agent.decide(context: {})
|
|
183
|
+
|
|
184
|
+
expect(result.decision).to eq("reject")
|
|
185
|
+
expect(result.explanations.join(" ")).to include("Conflicting evaluations")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
it "includes conflicting evaluations in explanations" do
|
|
189
|
+
evaluator1 = DecisionAgent::Evaluators::StaticEvaluator.new(
|
|
190
|
+
decision: "approve",
|
|
191
|
+
weight: 0.4,
|
|
192
|
+
name: "Evaluator1"
|
|
193
|
+
)
|
|
194
|
+
evaluator2 = DecisionAgent::Evaluators::StaticEvaluator.new(
|
|
195
|
+
decision: "reject",
|
|
196
|
+
weight: 0.7,
|
|
197
|
+
name: "Evaluator2"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
agent = DecisionAgent::Agent.new(evaluators: [evaluator1, evaluator2])
|
|
201
|
+
|
|
202
|
+
result = agent.decide(context: {})
|
|
203
|
+
|
|
204
|
+
explanations_text = result.explanations.join(" ")
|
|
205
|
+
expect(explanations_text).to include("Evaluator1")
|
|
206
|
+
expect(explanations_text).to include("Evaluator2")
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
describe "multiple evaluators agreeing" do
|
|
211
|
+
it "combines evaluations when all agree" do
|
|
212
|
+
evaluator1 = DecisionAgent::Evaluators::StaticEvaluator.new(
|
|
213
|
+
decision: "approve",
|
|
214
|
+
weight: 0.6,
|
|
215
|
+
name: "Evaluator1"
|
|
216
|
+
)
|
|
217
|
+
evaluator2 = DecisionAgent::Evaluators::StaticEvaluator.new(
|
|
218
|
+
decision: "approve",
|
|
219
|
+
weight: 0.8,
|
|
220
|
+
name: "Evaluator2"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
agent = DecisionAgent::Agent.new(evaluators: [evaluator1, evaluator2])
|
|
224
|
+
|
|
225
|
+
result = agent.decide(context: {})
|
|
226
|
+
|
|
227
|
+
expect(result.decision).to eq("approve")
|
|
228
|
+
expect(result.confidence).to be > 0.5
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
describe "graceful error handling" do
|
|
233
|
+
it "ignores evaluators that raise errors" do
|
|
234
|
+
good_evaluator = DecisionAgent::Evaluators::StaticEvaluator.new(decision: "approve")
|
|
235
|
+
|
|
236
|
+
bad_evaluator = Class.new(DecisionAgent::Evaluators::Base) do
|
|
237
|
+
def evaluate(context, feedback: {})
|
|
238
|
+
raise StandardError, "Intentional error"
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
agent = DecisionAgent::Agent.new(evaluators: [bad_evaluator.new, good_evaluator])
|
|
243
|
+
|
|
244
|
+
result = agent.decide(context: {})
|
|
245
|
+
|
|
246
|
+
expect(result.decision).to eq("approve")
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|