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,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
@@ -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