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/README.md
ADDED
|
@@ -0,0 +1,1060 @@
|
|
|
1
|
+
# DecisionAgent
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/rb/decision_agent)
|
|
4
|
+
[](https://github.com/samaswin87/decision_agent/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE.txt)
|
|
6
|
+
[](https://www.ruby-lang.org)
|
|
7
|
+
|
|
8
|
+
A production-grade, deterministic, explainable, and auditable decision engine for Ruby.
|
|
9
|
+
|
|
10
|
+
## The Problem
|
|
11
|
+
|
|
12
|
+
Enterprise applications need to make complex decisions based on business rules, but existing solutions fall short:
|
|
13
|
+
|
|
14
|
+
- **Traditional rule engines**: Often lack conflict resolution, confidence scoring, and audit replay capabilities
|
|
15
|
+
- **Framework-coupled solutions**: Tightly bound to specific frameworks (Rails, etc.), limiting portability
|
|
16
|
+
- **AI-first frameworks**: Non-deterministic, expensive, opaque, and unsuitable for regulated domains
|
|
17
|
+
|
|
18
|
+
**DecisionAgent** solves these problems by providing:
|
|
19
|
+
|
|
20
|
+
1. **Deterministic decisions** - Same input always produces same output
|
|
21
|
+
2. **Full explainability** - Every decision includes human-readable reasoning
|
|
22
|
+
3. **Audit replay** - Reproduce any historical decision exactly
|
|
23
|
+
4. **Conflict resolution** - Multiple evaluators with pluggable scoring strategies
|
|
24
|
+
5. **Framework-agnostic** - Pure Ruby, no Rails/ActiveRecord/Sidekiq dependencies
|
|
25
|
+
6. **AI-optional** - Rules first, AI enhancement optional
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
Add to your Gemfile:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
gem 'decision_agent'
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or install directly:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
gem install decision_agent
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Web UI - Visual Rule Builder 🎯
|
|
42
|
+
|
|
43
|
+
For non-technical users, DecisionAgent includes a web-based visual rule builder:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
decision_agent web
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Then open [http://localhost:4567](http://localhost:4567) in your browser.
|
|
50
|
+
|
|
51
|
+
The Web UI provides:
|
|
52
|
+
- 📝 **Visual rule creation** - Build rules using forms and dropdowns
|
|
53
|
+
- 🔍 **Live validation** - Instant feedback on rule correctness
|
|
54
|
+
- 📤 **Export/Import** - Download or upload rules as JSON
|
|
55
|
+
- 📚 **Example templates** - Pre-built rule sets to get started
|
|
56
|
+
- ✨ **No coding required** - Perfect for business analysts and domain experts
|
|
57
|
+
|
|
58
|
+
See [WEB_UI.md](WEB_UI.md) for detailed documentation.
|
|
59
|
+
|
|
60
|
+
<img width="1602" height="770" alt="Screenshot 2025-12-19 at 3 06 07 PM" src="https://github.com/user-attachments/assets/6ee6859c-f9f2-4f93-8bff-923986ccb1bc" />
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
## Quick Start
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
require 'decision_agent'
|
|
67
|
+
|
|
68
|
+
# Define evaluators
|
|
69
|
+
evaluator = DecisionAgent::Evaluators::StaticEvaluator.new(
|
|
70
|
+
decision: "approve",
|
|
71
|
+
weight: 0.8,
|
|
72
|
+
reason: "User meets basic criteria"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Create agent
|
|
76
|
+
agent = DecisionAgent::Agent.new(
|
|
77
|
+
evaluators: [evaluator],
|
|
78
|
+
scoring_strategy: DecisionAgent::Scoring::WeightedAverage.new,
|
|
79
|
+
audit_adapter: DecisionAgent::Audit::LoggerAdapter.new
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Make decision
|
|
83
|
+
result = agent.decide(
|
|
84
|
+
context: { user: "alice", priority: "high" }
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
puts result.decision # => "approve"
|
|
88
|
+
puts result.confidence # => 0.8
|
|
89
|
+
puts result.explanations # => ["Decision: approve (confidence: 0.8)", ...]
|
|
90
|
+
puts result.audit_payload # => Full audit trail
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Core Concepts
|
|
94
|
+
|
|
95
|
+
### 1. Agent
|
|
96
|
+
|
|
97
|
+
The orchestrator that coordinates evaluators, resolves conflicts, and produces decisions.
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
agent = DecisionAgent::Agent.new(
|
|
101
|
+
evaluators: [eval1, eval2, eval3],
|
|
102
|
+
scoring_strategy: DecisionAgent::Scoring::WeightedAverage.new,
|
|
103
|
+
audit_adapter: DecisionAgent::Audit::NullAdapter.new
|
|
104
|
+
)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### 2. Evaluators
|
|
108
|
+
|
|
109
|
+
Pluggable components that evaluate context and return decisions.
|
|
110
|
+
|
|
111
|
+
#### StaticEvaluator
|
|
112
|
+
|
|
113
|
+
Always returns the same decision:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
evaluator = DecisionAgent::Evaluators::StaticEvaluator.new(
|
|
117
|
+
decision: "approve",
|
|
118
|
+
weight: 0.7,
|
|
119
|
+
reason: "Static approval rule"
|
|
120
|
+
)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
#### JsonRuleEvaluator
|
|
124
|
+
|
|
125
|
+
Evaluates context against JSON-based business rules:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
rules = {
|
|
129
|
+
version: "1.0",
|
|
130
|
+
ruleset: "issue_triage",
|
|
131
|
+
rules: [
|
|
132
|
+
{
|
|
133
|
+
id: "high_priority_rule",
|
|
134
|
+
if: {
|
|
135
|
+
all: [
|
|
136
|
+
{ field: "priority", op: "eq", value: "high" },
|
|
137
|
+
{ field: "hours_inactive", op: "gte", value: 4 }
|
|
138
|
+
]
|
|
139
|
+
},
|
|
140
|
+
then: {
|
|
141
|
+
decision: "escalate",
|
|
142
|
+
weight: 0.9,
|
|
143
|
+
reason: "High priority issue inactive too long"
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(
|
|
150
|
+
rules_json: rules
|
|
151
|
+
)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### 3. Context
|
|
155
|
+
|
|
156
|
+
Immutable input data for decision-making:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
context = DecisionAgent::Context.new({
|
|
160
|
+
user: "alice",
|
|
161
|
+
priority: "high",
|
|
162
|
+
hours_inactive: 5
|
|
163
|
+
})
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### 4. Scoring Strategies
|
|
167
|
+
|
|
168
|
+
Resolve conflicts when multiple evaluators return different decisions.
|
|
169
|
+
|
|
170
|
+
#### WeightedAverage (Default)
|
|
171
|
+
|
|
172
|
+
Sums weights for each decision, selects winner:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
DecisionAgent::Scoring::WeightedAverage.new
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
#### MaxWeight
|
|
179
|
+
|
|
180
|
+
Selects decision with highest individual weight:
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
DecisionAgent::Scoring::MaxWeight.new
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
#### Consensus
|
|
187
|
+
|
|
188
|
+
Requires minimum agreement threshold:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
DecisionAgent::Scoring::Consensus.new(minimum_agreement: 0.6)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
#### Threshold
|
|
195
|
+
|
|
196
|
+
Requires minimum weight to accept decision:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
DecisionAgent::Scoring::Threshold.new(
|
|
200
|
+
threshold: 0.8,
|
|
201
|
+
fallback_decision: "manual_review"
|
|
202
|
+
)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### 5. Audit Adapters
|
|
206
|
+
|
|
207
|
+
Record decisions for compliance and debugging.
|
|
208
|
+
|
|
209
|
+
#### NullAdapter
|
|
210
|
+
|
|
211
|
+
No-op (default):
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
DecisionAgent::Audit::NullAdapter.new
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
#### LoggerAdapter
|
|
218
|
+
|
|
219
|
+
Logs to any Ruby logger:
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
DecisionAgent::Audit::LoggerAdapter.new(
|
|
223
|
+
logger: Rails.logger,
|
|
224
|
+
level: Logger::INFO
|
|
225
|
+
)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## JSON Rule DSL
|
|
229
|
+
|
|
230
|
+
### Supported Operators
|
|
231
|
+
|
|
232
|
+
| Operator | Description | Example |
|
|
233
|
+
|----------|-------------|---------|
|
|
234
|
+
| `eq` | Equal | `{ field: "status", op: "eq", value: "active" }` |
|
|
235
|
+
| `neq` | Not equal | `{ field: "status", op: "neq", value: "closed" }` |
|
|
236
|
+
| `gt` | Greater than | `{ field: "score", op: "gt", value: 80 }` |
|
|
237
|
+
| `gte` | Greater than or equal | `{ field: "hours", op: "gte", value: 4 }` |
|
|
238
|
+
| `lt` | Less than | `{ field: "temp", op: "lt", value: 32 }` |
|
|
239
|
+
| `lte` | Less than or equal | `{ field: "temp", op: "lte", value: 32 }` |
|
|
240
|
+
| `in` | Array membership | `{ field: "status", op: "in", value: ["open", "pending"] }` |
|
|
241
|
+
| `present` | Field exists and not empty | `{ field: "assignee", op: "present" }` |
|
|
242
|
+
| `blank` | Field missing, nil, or empty | `{ field: "description", op: "blank" }` |
|
|
243
|
+
|
|
244
|
+
### Condition Combinators
|
|
245
|
+
|
|
246
|
+
#### all
|
|
247
|
+
|
|
248
|
+
All sub-conditions must be true:
|
|
249
|
+
|
|
250
|
+
```json
|
|
251
|
+
{
|
|
252
|
+
"all": [
|
|
253
|
+
{ "field": "priority", "op": "eq", "value": "high" },
|
|
254
|
+
{ "field": "hours", "op": "gte", "value": 4 }
|
|
255
|
+
]
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
#### any
|
|
260
|
+
|
|
261
|
+
At least one sub-condition must be true:
|
|
262
|
+
|
|
263
|
+
```json
|
|
264
|
+
{
|
|
265
|
+
"any": [
|
|
266
|
+
{ "field": "escalated", "op": "eq", "value": true },
|
|
267
|
+
{ "field": "complaints", "op": "gte", "value": 3 }
|
|
268
|
+
]
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### Nested Fields
|
|
273
|
+
|
|
274
|
+
Use dot notation to access nested data:
|
|
275
|
+
|
|
276
|
+
```json
|
|
277
|
+
{
|
|
278
|
+
"field": "user.role",
|
|
279
|
+
"op": "eq",
|
|
280
|
+
"value": "admin"
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
context = DecisionAgent::Context.new({
|
|
286
|
+
user: { role: "admin" }
|
|
287
|
+
})
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### Complete Example
|
|
291
|
+
|
|
292
|
+
```json
|
|
293
|
+
{
|
|
294
|
+
"version": "1.0",
|
|
295
|
+
"ruleset": "redmine_triage",
|
|
296
|
+
"rules": [
|
|
297
|
+
{
|
|
298
|
+
"id": "critical_escalation",
|
|
299
|
+
"if": {
|
|
300
|
+
"all": [
|
|
301
|
+
{ "field": "priority", "op": "eq", "value": "critical" },
|
|
302
|
+
{
|
|
303
|
+
"any": [
|
|
304
|
+
{ "field": "hours_inactive", "op": "gte", "value": 2 },
|
|
305
|
+
{ "field": "customer_escalated", "op": "eq", "value": true }
|
|
306
|
+
]
|
|
307
|
+
}
|
|
308
|
+
]
|
|
309
|
+
},
|
|
310
|
+
"then": {
|
|
311
|
+
"decision": "escalate_immediately",
|
|
312
|
+
"weight": 1.0,
|
|
313
|
+
"reason": "Critical issue requires immediate attention"
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
"id": "auto_assign",
|
|
318
|
+
"if": {
|
|
319
|
+
"all": [
|
|
320
|
+
{ "field": "assignee", "op": "blank" },
|
|
321
|
+
{ "field": "priority", "op": "in", "value": ["high", "critical"] }
|
|
322
|
+
]
|
|
323
|
+
},
|
|
324
|
+
"then": {
|
|
325
|
+
"decision": "assign_to_team_lead",
|
|
326
|
+
"weight": 0.85,
|
|
327
|
+
"reason": "High priority issue needs assignment"
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
]
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
## Decision Replay
|
|
335
|
+
|
|
336
|
+
Critical for compliance and debugging - replay any historical decision exactly.
|
|
337
|
+
|
|
338
|
+
### Strict Mode
|
|
339
|
+
|
|
340
|
+
Fails if replayed decision differs from original:
|
|
341
|
+
|
|
342
|
+
```ruby
|
|
343
|
+
original_result = agent.decide(context: { user: "alice" })
|
|
344
|
+
|
|
345
|
+
# Later, replay the exact decision
|
|
346
|
+
replayed_result = DecisionAgent::Replay.run(
|
|
347
|
+
original_result.audit_payload,
|
|
348
|
+
strict: true
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# Raises ReplayMismatchError if decision changed
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
### Non-Strict Mode
|
|
355
|
+
|
|
356
|
+
Logs differences but allows evolution:
|
|
357
|
+
|
|
358
|
+
```ruby
|
|
359
|
+
replayed_result = DecisionAgent::Replay.run(
|
|
360
|
+
original_result.audit_payload,
|
|
361
|
+
strict: false # Logs differences but doesn't fail
|
|
362
|
+
)
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
### Audit Payload Structure
|
|
366
|
+
|
|
367
|
+
```ruby
|
|
368
|
+
{
|
|
369
|
+
timestamp: "2025-01-15T10:30:45.123456Z",
|
|
370
|
+
context: { user: "alice", priority: "high" },
|
|
371
|
+
feedback: {},
|
|
372
|
+
evaluations: [
|
|
373
|
+
{
|
|
374
|
+
decision: "approve",
|
|
375
|
+
weight: 0.8,
|
|
376
|
+
reason: "Rule matched",
|
|
377
|
+
evaluator_name: "JsonRuleEvaluator",
|
|
378
|
+
metadata: { rule_id: "high_priority_rule" }
|
|
379
|
+
}
|
|
380
|
+
],
|
|
381
|
+
decision: "approve",
|
|
382
|
+
confidence: 0.8,
|
|
383
|
+
scoring_strategy: "DecisionAgent::Scoring::WeightedAverage",
|
|
384
|
+
agent_version: "0.1.0",
|
|
385
|
+
deterministic_hash: "a3f2b9c..."
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
## Advanced Usage
|
|
390
|
+
|
|
391
|
+
### Multiple Evaluators with Conflict Resolution
|
|
392
|
+
|
|
393
|
+
```ruby
|
|
394
|
+
rule_evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(
|
|
395
|
+
rules_json: File.read("rules/triage.json")
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
ml_evaluator = DecisionAgent::Evaluators::StaticEvaluator.new(
|
|
399
|
+
decision: "review_manually",
|
|
400
|
+
weight: 0.6,
|
|
401
|
+
reason: "ML model suggests manual review"
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
agent = DecisionAgent::Agent.new(
|
|
405
|
+
evaluators: [rule_evaluator, ml_evaluator],
|
|
406
|
+
scoring_strategy: DecisionAgent::Scoring::Consensus.new(minimum_agreement: 0.7)
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
result = agent.decide(
|
|
410
|
+
context: { priority: "high", complexity: "high" }
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
# Explanations show how conflict was resolved
|
|
414
|
+
puts result.explanations
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
### Custom Evaluator
|
|
418
|
+
|
|
419
|
+
```ruby
|
|
420
|
+
class CustomBusinessLogicEvaluator < DecisionAgent::Evaluators::Base
|
|
421
|
+
def evaluate(context, feedback: {})
|
|
422
|
+
# Your custom logic here
|
|
423
|
+
if context[:revenue] > 100_000 && context[:customer_tier] == "enterprise"
|
|
424
|
+
DecisionAgent::Evaluation.new(
|
|
425
|
+
decision: "approve_immediately",
|
|
426
|
+
weight: 0.95,
|
|
427
|
+
reason: "High-value enterprise customer",
|
|
428
|
+
evaluator_name: "EnterpriseCustomerEvaluator",
|
|
429
|
+
metadata: { tier: "enterprise" }
|
|
430
|
+
)
|
|
431
|
+
else
|
|
432
|
+
nil # No decision
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
### Custom Scoring Strategy
|
|
439
|
+
|
|
440
|
+
```ruby
|
|
441
|
+
class VetoScoring < DecisionAgent::Scoring::Base
|
|
442
|
+
def score(evaluations)
|
|
443
|
+
# If any evaluator says "reject", veto everything
|
|
444
|
+
if evaluations.any? { |e| e.decision == "reject" }
|
|
445
|
+
return { decision: "reject", confidence: 1.0 }
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# Otherwise, use max weight
|
|
449
|
+
max_eval = evaluations.max_by(&:weight)
|
|
450
|
+
{
|
|
451
|
+
decision: max_eval.decision,
|
|
452
|
+
confidence: normalize_confidence(max_eval.weight)
|
|
453
|
+
}
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
agent = DecisionAgent::Agent.new(
|
|
458
|
+
evaluators: [...],
|
|
459
|
+
scoring_strategy: VetoScoring.new
|
|
460
|
+
)
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
### Custom Audit Adapter
|
|
464
|
+
|
|
465
|
+
```ruby
|
|
466
|
+
class DatabaseAuditAdapter < DecisionAgent::Audit::Adapter
|
|
467
|
+
def record(decision, context)
|
|
468
|
+
AuditLog.create!(
|
|
469
|
+
decision: decision.decision,
|
|
470
|
+
confidence: decision.confidence,
|
|
471
|
+
context_json: context.to_h.to_json,
|
|
472
|
+
audit_payload: decision.audit_payload.to_json,
|
|
473
|
+
deterministic_hash: decision.audit_payload[:deterministic_hash]
|
|
474
|
+
)
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
### Feedback Loop
|
|
480
|
+
|
|
481
|
+
The `feedback` parameter allows you to pass additional context about past decisions, manual overrides, or external signals that can influence decision-making in custom evaluators.
|
|
482
|
+
|
|
483
|
+
#### Built-in Evaluators and Feedback
|
|
484
|
+
|
|
485
|
+
**Built-in evaluators** (`JsonRuleEvaluator`, `StaticEvaluator`) **ignore feedback** to maintain determinism. This is intentional - the same context should always produce the same decision for auditability and replay purposes.
|
|
486
|
+
|
|
487
|
+
```ruby
|
|
488
|
+
# Feedback is accepted but not used by built-in evaluators
|
|
489
|
+
result = agent.decide(
|
|
490
|
+
context: { issue_id: 123 },
|
|
491
|
+
feedback: { source: "automated", past_accuracy: 0.95 }
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
# The feedback is stored in the audit trail for analysis
|
|
495
|
+
puts result.audit_payload[:feedback] # => { source: "automated", past_accuracy: 0.95 }
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
#### Custom Feedback-Aware Evaluators
|
|
499
|
+
|
|
500
|
+
For **adaptive behavior**, create custom evaluators that use feedback:
|
|
501
|
+
|
|
502
|
+
```ruby
|
|
503
|
+
# See examples/feedback_aware_evaluator.rb for a complete implementation
|
|
504
|
+
class FeedbackAwareEvaluator < DecisionAgent::Evaluators::Base
|
|
505
|
+
def evaluate(context, feedback: {})
|
|
506
|
+
# Use feedback to adjust decisions
|
|
507
|
+
if feedback[:override]
|
|
508
|
+
return Evaluation.new(
|
|
509
|
+
decision: feedback[:override],
|
|
510
|
+
weight: 0.9,
|
|
511
|
+
reason: feedback[:reason] || "Manual override",
|
|
512
|
+
evaluator_name: evaluator_name
|
|
513
|
+
)
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Or adjust confidence based on past accuracy
|
|
517
|
+
adjusted_weight = base_weight * feedback[:past_accuracy].to_f
|
|
518
|
+
|
|
519
|
+
Evaluation.new(
|
|
520
|
+
decision: base_decision,
|
|
521
|
+
weight: adjusted_weight,
|
|
522
|
+
reason: "Adjusted by past performance",
|
|
523
|
+
evaluator_name: evaluator_name
|
|
524
|
+
)
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
#### Common Feedback Patterns
|
|
530
|
+
|
|
531
|
+
1. **Manual Override**: Human-in-the-loop corrections
|
|
532
|
+
```ruby
|
|
533
|
+
agent.decide(
|
|
534
|
+
context: { user_id: 123 },
|
|
535
|
+
feedback: { override: "manual_review", reason: "Suspicious activity" }
|
|
536
|
+
)
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
2. **Historical Performance**: Adjust confidence based on past accuracy
|
|
540
|
+
```ruby
|
|
541
|
+
agent.decide(
|
|
542
|
+
context: { transaction: tx },
|
|
543
|
+
feedback: { past_accuracy: 0.87 } # This evaluator was 87% accurate historically
|
|
544
|
+
)
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
3. **Source Attribution**: Weight decisions differently based on origin
|
|
548
|
+
```ruby
|
|
549
|
+
agent.decide(
|
|
550
|
+
context: { issue: issue },
|
|
551
|
+
feedback: { source: "expert_review" } # Higher confidence for expert reviews
|
|
552
|
+
)
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
4. **Learning Signals**: Collect data for offline model training
|
|
556
|
+
```ruby
|
|
557
|
+
# Initial decision
|
|
558
|
+
result = agent.decide(context: { user: user })
|
|
559
|
+
|
|
560
|
+
# Later: user provides feedback
|
|
561
|
+
user_feedback = {
|
|
562
|
+
correct: false,
|
|
563
|
+
actual_decision: "escalate",
|
|
564
|
+
user_id: "manager_bob",
|
|
565
|
+
timestamp: Time.now.utc.iso8601
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
# Log for analysis and future rule adjustments
|
|
569
|
+
# (DecisionAgent doesn't auto-update rules - this is for your ML/analysis pipeline)
|
|
570
|
+
FeedbackLog.create(
|
|
571
|
+
decision_hash: result.audit_payload[:deterministic_hash],
|
|
572
|
+
predicted: result.decision,
|
|
573
|
+
actual: user_feedback[:actual_decision],
|
|
574
|
+
feedback: user_feedback
|
|
575
|
+
)
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
#### Example: Complete Feedback-Aware System
|
|
579
|
+
|
|
580
|
+
See [examples/feedback_aware_evaluator.rb](examples/feedback_aware_evaluator.rb) for a complete example that demonstrates:
|
|
581
|
+
- Manual overrides with high confidence
|
|
582
|
+
- Past accuracy-based weight adjustment
|
|
583
|
+
- Source-based confidence boosting
|
|
584
|
+
- Comprehensive metadata tracking
|
|
585
|
+
|
|
586
|
+
**Key Principle**: Use feedback for **human oversight** and **continuous improvement**, but keep the core decision logic deterministic and auditable.
|
|
587
|
+
|
|
588
|
+
## Integration Examples
|
|
589
|
+
|
|
590
|
+
### Rails Integration
|
|
591
|
+
|
|
592
|
+
```ruby
|
|
593
|
+
# app/services/issue_decision_service.rb
|
|
594
|
+
class IssueDecisionService
|
|
595
|
+
def self.decide_action(issue)
|
|
596
|
+
agent = build_agent
|
|
597
|
+
|
|
598
|
+
result = agent.decide(
|
|
599
|
+
context: {
|
|
600
|
+
priority: issue.priority,
|
|
601
|
+
hours_inactive: (Time.now - issue.updated_at) / 3600,
|
|
602
|
+
assignee: issue.assignee&.login,
|
|
603
|
+
status: issue.status
|
|
604
|
+
}
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
result
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
private
|
|
611
|
+
|
|
612
|
+
def self.build_agent
|
|
613
|
+
rules = JSON.parse(File.read(Rails.root.join("config/rules/issue_triage.json")))
|
|
614
|
+
|
|
615
|
+
DecisionAgent::Agent.new(
|
|
616
|
+
evaluators: [
|
|
617
|
+
DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
|
|
618
|
+
],
|
|
619
|
+
scoring_strategy: DecisionAgent::Scoring::WeightedAverage.new,
|
|
620
|
+
audit_adapter: DecisionAgent::Audit::LoggerAdapter.new(logger: Rails.logger)
|
|
621
|
+
)
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
### Redmine Plugin Integration
|
|
627
|
+
|
|
628
|
+
```ruby
|
|
629
|
+
# plugins/redmine_smart_triage/lib/decision_engine.rb
|
|
630
|
+
module RedmineSmartTriage
|
|
631
|
+
class DecisionEngine
|
|
632
|
+
def self.evaluate_issue(issue)
|
|
633
|
+
agent = build_agent
|
|
634
|
+
|
|
635
|
+
context = {
|
|
636
|
+
"priority" => issue.priority.name.downcase,
|
|
637
|
+
"status" => issue.status.name.downcase,
|
|
638
|
+
"hours_inactive" => hours_since_update(issue),
|
|
639
|
+
"assignee" => issue.assigned_to&.login,
|
|
640
|
+
"tracker" => issue.tracker.name.downcase
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
agent.decide(context: context)
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
private
|
|
647
|
+
|
|
648
|
+
def self.build_agent
|
|
649
|
+
rules_path = File.join(File.dirname(__FILE__), "../config/triage_rules.json")
|
|
650
|
+
rules = JSON.parse(File.read(rules_path))
|
|
651
|
+
|
|
652
|
+
DecisionAgent::Agent.new(
|
|
653
|
+
evaluators: [
|
|
654
|
+
DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
|
|
655
|
+
],
|
|
656
|
+
audit_adapter: RedmineAuditAdapter.new
|
|
657
|
+
)
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
def self.hours_since_update(issue)
|
|
661
|
+
((Time.now - issue.updated_on) / 3600).round
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
class RedmineAuditAdapter < DecisionAgent::Audit::Adapter
|
|
666
|
+
def record(decision, context)
|
|
667
|
+
# Store in Redmine custom field or separate table
|
|
668
|
+
Rails.logger.info "[DecisionAgent] #{decision.decision} (confidence: #{decision.confidence})"
|
|
669
|
+
end
|
|
670
|
+
end
|
|
671
|
+
end
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
### Standalone Service
|
|
675
|
+
|
|
676
|
+
```ruby
|
|
677
|
+
#!/usr/bin/env ruby
|
|
678
|
+
require 'decision_agent'
|
|
679
|
+
require 'json'
|
|
680
|
+
|
|
681
|
+
# Load rules
|
|
682
|
+
rules = JSON.parse(File.read("config/rules.json"))
|
|
683
|
+
|
|
684
|
+
# Build agent
|
|
685
|
+
agent = DecisionAgent::Agent.new(
|
|
686
|
+
evaluators: [
|
|
687
|
+
DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
|
|
688
|
+
],
|
|
689
|
+
scoring_strategy: DecisionAgent::Scoring::Threshold.new(
|
|
690
|
+
threshold: 0.75,
|
|
691
|
+
fallback_decision: "manual_review"
|
|
692
|
+
),
|
|
693
|
+
audit_adapter: DecisionAgent::Audit::LoggerAdapter.new
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
# Read context from stdin
|
|
697
|
+
context = JSON.parse(STDIN.read)
|
|
698
|
+
|
|
699
|
+
# Decide
|
|
700
|
+
result = agent.decide(context: context)
|
|
701
|
+
|
|
702
|
+
# Output decision
|
|
703
|
+
output = {
|
|
704
|
+
decision: result.decision,
|
|
705
|
+
confidence: result.confidence,
|
|
706
|
+
explanations: result.explanations
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
puts JSON.pretty_generate(output)
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
## Design Philosophy
|
|
713
|
+
|
|
714
|
+
### Why Deterministic > AI
|
|
715
|
+
|
|
716
|
+
1. **Regulatory Compliance**: Healthcare (HIPAA), finance (SOX), and government require auditable, explainable decisions
|
|
717
|
+
2. **Cost**: Rules are free to evaluate; LLM calls cost money and add latency
|
|
718
|
+
3. **Reliability**: Same input must produce same output for testing and legal defensibility
|
|
719
|
+
4. **Transparency**: Business rules are explicit and reviewable by domain experts
|
|
720
|
+
5. **AI Enhancement**: AI can suggest rule adjustments, but rules make final decisions
|
|
721
|
+
|
|
722
|
+
### When to Use DecisionAgent
|
|
723
|
+
|
|
724
|
+
- **Regulated domains**: Healthcare, finance, legal, government
|
|
725
|
+
- **Business rule engines**: Complex decision trees with multiple evaluators
|
|
726
|
+
- **Compliance requirements**: Need full audit trails and decision replay
|
|
727
|
+
- **Explainability required**: Humans must understand why decisions were made
|
|
728
|
+
- **Deterministic systems**: Same input must always produce same output
|
|
729
|
+
|
|
730
|
+
### When NOT to Use
|
|
731
|
+
|
|
732
|
+
- Simple if/else logic (just use Ruby)
|
|
733
|
+
- Purely AI-driven decisions with no rules
|
|
734
|
+
- Single-step validations (use standard validation libraries)
|
|
735
|
+
|
|
736
|
+
## Testing
|
|
737
|
+
|
|
738
|
+
```ruby
|
|
739
|
+
# spec/my_decision_spec.rb
|
|
740
|
+
RSpec.describe "My Decision Logic" do
|
|
741
|
+
it "escalates critical issues" do
|
|
742
|
+
rules = { ... }
|
|
743
|
+
evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
|
|
744
|
+
agent = DecisionAgent::Agent.new(evaluators: [evaluator])
|
|
745
|
+
|
|
746
|
+
result = agent.decide(
|
|
747
|
+
context: { priority: "critical", hours_inactive: 3 }
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
expect(result.decision).to eq("escalate")
|
|
751
|
+
expect(result.confidence).to be > 0.8
|
|
752
|
+
end
|
|
753
|
+
end
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
## Error Handling
|
|
757
|
+
|
|
758
|
+
All errors are namespaced under `DecisionAgent`:
|
|
759
|
+
|
|
760
|
+
### NoEvaluationsError
|
|
761
|
+
|
|
762
|
+
Raised when no evaluator returns a decision (all returned `nil` or raised exceptions).
|
|
763
|
+
|
|
764
|
+
```ruby
|
|
765
|
+
begin
|
|
766
|
+
agent.decide(context: {})
|
|
767
|
+
rescue DecisionAgent::NoEvaluationsError => e
|
|
768
|
+
# No evaluator returned a decision
|
|
769
|
+
puts e.message # => "No evaluators returned a decision"
|
|
770
|
+
|
|
771
|
+
# Handle gracefully
|
|
772
|
+
fallback_decision = "manual_review"
|
|
773
|
+
end
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
### InvalidRuleDslError
|
|
777
|
+
|
|
778
|
+
Raised when JSON rule DSL is malformed or invalid.
|
|
779
|
+
|
|
780
|
+
```ruby
|
|
781
|
+
begin
|
|
782
|
+
rules = { invalid: "structure" }
|
|
783
|
+
evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
|
|
784
|
+
rescue DecisionAgent::InvalidRuleDslError => e
|
|
785
|
+
# JSON rule DSL is malformed
|
|
786
|
+
puts e.message # => "Invalid rule DSL structure"
|
|
787
|
+
end
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
### ReplayMismatchError
|
|
791
|
+
|
|
792
|
+
Raised in strict replay mode when replayed decision differs from original.
|
|
793
|
+
|
|
794
|
+
```ruby
|
|
795
|
+
begin
|
|
796
|
+
replayed_result = DecisionAgent::Replay.run(audit_payload, strict: true)
|
|
797
|
+
rescue DecisionAgent::ReplayMismatchError => e
|
|
798
|
+
# Replay produced different result
|
|
799
|
+
puts "Expected: #{e.expected}" # => "approve"
|
|
800
|
+
puts "Actual: #{e.actual}" # => "reject"
|
|
801
|
+
puts "Differences: #{e.differences}" # => ["decision changed", "confidence changed"]
|
|
802
|
+
end
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
### InvalidConfidenceError
|
|
806
|
+
|
|
807
|
+
Raised when confidence value is outside [0.0, 1.0] range.
|
|
808
|
+
|
|
809
|
+
```ruby
|
|
810
|
+
begin
|
|
811
|
+
decision = DecisionAgent::Decision.new(
|
|
812
|
+
decision: "approve",
|
|
813
|
+
confidence: 1.5, # Invalid!
|
|
814
|
+
explanations: [],
|
|
815
|
+
evaluations: [],
|
|
816
|
+
audit_payload: {}
|
|
817
|
+
)
|
|
818
|
+
rescue DecisionAgent::InvalidConfidenceError => e
|
|
819
|
+
puts e.message # => "Confidence must be between 0.0 and 1.0, got: 1.5"
|
|
820
|
+
end
|
|
821
|
+
```
|
|
822
|
+
|
|
823
|
+
### InvalidWeightError
|
|
824
|
+
|
|
825
|
+
Raised when evaluation weight is outside [0.0, 1.0] range.
|
|
826
|
+
|
|
827
|
+
```ruby
|
|
828
|
+
begin
|
|
829
|
+
eval = DecisionAgent::Evaluation.new(
|
|
830
|
+
decision: "approve",
|
|
831
|
+
weight: -0.5, # Invalid!
|
|
832
|
+
reason: "Test",
|
|
833
|
+
evaluator_name: "Test"
|
|
834
|
+
)
|
|
835
|
+
rescue DecisionAgent::InvalidWeightError => e
|
|
836
|
+
puts e.message # => "Weight must be between 0.0 and 1.0, got: -0.5"
|
|
837
|
+
end
|
|
838
|
+
```
|
|
839
|
+
|
|
840
|
+
### Configuration Errors
|
|
841
|
+
|
|
842
|
+
Raised during agent initialization when configuration is invalid.
|
|
843
|
+
|
|
844
|
+
```ruby
|
|
845
|
+
begin
|
|
846
|
+
# No evaluators provided
|
|
847
|
+
agent = DecisionAgent::Agent.new(evaluators: [])
|
|
848
|
+
rescue DecisionAgent::InvalidConfigurationError => e
|
|
849
|
+
puts e.message # => "At least one evaluator is required"
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
begin
|
|
853
|
+
# Invalid evaluator
|
|
854
|
+
agent = DecisionAgent::Agent.new(evaluators: ["not an evaluator"])
|
|
855
|
+
rescue DecisionAgent::InvalidEvaluatorError => e
|
|
856
|
+
puts e.message # => "Evaluator must respond to #evaluate"
|
|
857
|
+
end
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
## API Reference
|
|
861
|
+
|
|
862
|
+
### Agent
|
|
863
|
+
|
|
864
|
+
Main orchestrator for decision-making.
|
|
865
|
+
|
|
866
|
+
**Constructor:**
|
|
867
|
+
```ruby
|
|
868
|
+
DecisionAgent::Agent.new(
|
|
869
|
+
evaluators: [evaluator1, evaluator2],
|
|
870
|
+
scoring_strategy: DecisionAgent::Scoring::WeightedAverage.new, # Optional, defaults to WeightedAverage
|
|
871
|
+
audit_adapter: DecisionAgent::Audit::NullAdapter.new # Optional, defaults to NullAdapter
|
|
872
|
+
)
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
**Public Methods:**
|
|
876
|
+
|
|
877
|
+
- `#decide(context:, feedback: {})` → `Decision`
|
|
878
|
+
- Makes a decision based on context and optional feedback
|
|
879
|
+
- Raises `NoEvaluationsError` if no evaluators return decisions
|
|
880
|
+
- Returns a `Decision` object with decision, confidence, and explanations
|
|
881
|
+
|
|
882
|
+
**Attributes:**
|
|
883
|
+
- `#evaluators` → `Array` - Read-only access to configured evaluators
|
|
884
|
+
- `#scoring_strategy` → `Scoring::Base` - Read-only access to scoring strategy
|
|
885
|
+
- `#audit_adapter` → `Audit::Adapter` - Read-only access to audit adapter
|
|
886
|
+
|
|
887
|
+
### Decision
|
|
888
|
+
|
|
889
|
+
Immutable result object representing a decision.
|
|
890
|
+
|
|
891
|
+
**Constructor:**
|
|
892
|
+
```ruby
|
|
893
|
+
DecisionAgent::Decision.new(
|
|
894
|
+
decision: "approve",
|
|
895
|
+
confidence: 0.85,
|
|
896
|
+
explanations: ["High priority rule matched"],
|
|
897
|
+
evaluations: [evaluation1, evaluation2],
|
|
898
|
+
audit_payload: {...}
|
|
899
|
+
)
|
|
900
|
+
```
|
|
901
|
+
|
|
902
|
+
**Attributes:**
|
|
903
|
+
- `#decision` → `String` - The final decision (frozen)
|
|
904
|
+
- `#confidence` → `Float` - Confidence score between 0.0 and 1.0
|
|
905
|
+
- `#explanations` → `Array<String>` - Human-readable explanations (frozen)
|
|
906
|
+
- `#evaluations` → `Array<Evaluation>` - All evaluations that contributed (frozen)
|
|
907
|
+
- `#audit_payload` → `Hash` - Complete audit trail for replay (frozen)
|
|
908
|
+
|
|
909
|
+
**Public Methods:**
|
|
910
|
+
|
|
911
|
+
- `#to_h` → `Hash` - Converts to hash representation
|
|
912
|
+
- `#==(other)` → `Boolean` - Equality comparison (compares decision, confidence, explanations, evaluations)
|
|
913
|
+
|
|
914
|
+
### Evaluation
|
|
915
|
+
|
|
916
|
+
Immutable result from a single evaluator.
|
|
917
|
+
|
|
918
|
+
**Constructor:**
|
|
919
|
+
```ruby
|
|
920
|
+
DecisionAgent::Evaluation.new(
|
|
921
|
+
decision: "approve",
|
|
922
|
+
weight: 0.8,
|
|
923
|
+
reason: "User meets criteria",
|
|
924
|
+
evaluator_name: "MyEvaluator",
|
|
925
|
+
metadata: { rule_id: "R1" } # Optional, defaults to {}
|
|
926
|
+
)
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
**Attributes:**
|
|
930
|
+
- `#decision` → `String` - The evaluator's decision (frozen)
|
|
931
|
+
- `#weight` → `Float` - Weight between 0.0 and 1.0
|
|
932
|
+
- `#reason` → `String` - Human-readable reason (frozen)
|
|
933
|
+
- `#evaluator_name` → `String` - Name of the evaluator (frozen)
|
|
934
|
+
- `#metadata` → `Hash` - Additional context (frozen)
|
|
935
|
+
|
|
936
|
+
**Public Methods:**
|
|
937
|
+
|
|
938
|
+
- `#to_h` → `Hash` - Converts to hash representation
|
|
939
|
+
- `#==(other)` → `Boolean` - Equality comparison
|
|
940
|
+
|
|
941
|
+
### Context
|
|
942
|
+
|
|
943
|
+
Immutable wrapper for decision context data.
|
|
944
|
+
|
|
945
|
+
**Constructor:**
|
|
946
|
+
```ruby
|
|
947
|
+
DecisionAgent::Context.new(
|
|
948
|
+
user: "alice",
|
|
949
|
+
priority: "high",
|
|
950
|
+
nested: { role: "admin" }
|
|
951
|
+
)
|
|
952
|
+
```
|
|
953
|
+
|
|
954
|
+
**Public Methods:**
|
|
955
|
+
|
|
956
|
+
- `#[]` → `Object` - Access context value by key (supports both string and symbol keys)
|
|
957
|
+
- `#to_h` → `Hash` - Returns underlying hash (frozen)
|
|
958
|
+
- `#==(other)` → `Boolean` - Equality comparison
|
|
959
|
+
|
|
960
|
+
### Evaluators::Base
|
|
961
|
+
|
|
962
|
+
Base class for custom evaluators.
|
|
963
|
+
|
|
964
|
+
**Public Methods:**
|
|
965
|
+
|
|
966
|
+
- `#evaluate(context, feedback: {})` → `Evaluation | nil`
|
|
967
|
+
- Must be implemented by subclasses
|
|
968
|
+
- Returns `Evaluation` if a decision is made, `nil` otherwise
|
|
969
|
+
- `context` is a `Context` object
|
|
970
|
+
- `feedback` is an optional hash
|
|
971
|
+
|
|
972
|
+
### Scoring::Base
|
|
973
|
+
|
|
974
|
+
Base class for custom scoring strategies.
|
|
975
|
+
|
|
976
|
+
**Public Methods:**
|
|
977
|
+
|
|
978
|
+
- `#score(evaluations)` → `{ decision: String, confidence: Float }`
|
|
979
|
+
- Must be implemented by subclasses
|
|
980
|
+
- Takes array of `Evaluation` objects
|
|
981
|
+
- Returns hash with `:decision` and `:confidence` keys
|
|
982
|
+
- Confidence must be between 0.0 and 1.0
|
|
983
|
+
|
|
984
|
+
**Protected Methods:**
|
|
985
|
+
- `#normalize_confidence(value)` → `Float` - Clamps value to [0.0, 1.0]
|
|
986
|
+
- `#round_confidence(value)` → `Float` - Rounds to 4 decimal places
|
|
987
|
+
|
|
988
|
+
### Audit::Adapter
|
|
989
|
+
|
|
990
|
+
Base class for custom audit adapters.
|
|
991
|
+
|
|
992
|
+
**Public Methods:**
|
|
993
|
+
|
|
994
|
+
- `#record(decision, context)` → `void`
|
|
995
|
+
- Must be implemented by subclasses
|
|
996
|
+
- Called after each decision is made
|
|
997
|
+
- `decision` is a `Decision` object
|
|
998
|
+
- `context` is a `Context` object
|
|
999
|
+
|
|
1000
|
+
### Replay
|
|
1001
|
+
|
|
1002
|
+
Utilities for replaying historical decisions.
|
|
1003
|
+
|
|
1004
|
+
**Class Methods:**
|
|
1005
|
+
|
|
1006
|
+
- `DecisionAgent::Replay.run(audit_payload, strict: true)` → `Decision`
|
|
1007
|
+
- Replays a decision from audit payload
|
|
1008
|
+
- `strict: true` raises `ReplayMismatchError` on differences
|
|
1009
|
+
- `strict: false` logs differences but allows evolution
|
|
1010
|
+
|
|
1011
|
+
## Versioning
|
|
1012
|
+
|
|
1013
|
+
DecisionAgent follows [Semantic Versioning 2.0.0](https://semver.org/):
|
|
1014
|
+
|
|
1015
|
+
- **MAJOR** version for incompatible API changes
|
|
1016
|
+
- **MINOR** version for backwards-compatible functionality additions
|
|
1017
|
+
- **PATCH** version for backwards-compatible bug fixes
|
|
1018
|
+
|
|
1019
|
+
### Stability Guarantees
|
|
1020
|
+
|
|
1021
|
+
- **Public API**: All classes and methods documented in this README are stable
|
|
1022
|
+
- **Audit Payload Format**: The structure of `audit_payload` is stable and will remain replayable across versions
|
|
1023
|
+
- **Deterministic Hash**: The algorithm for computing `deterministic_hash` is frozen to ensure replay compatibility
|
|
1024
|
+
- **Breaking Changes**: Will only occur in major version bumps, with clear migration guides
|
|
1025
|
+
|
|
1026
|
+
### Deprecation Policy
|
|
1027
|
+
|
|
1028
|
+
- Deprecated features will be marked in documentation and emit warnings
|
|
1029
|
+
- Deprecated features will be maintained for at least one minor version before removal
|
|
1030
|
+
- Breaking changes will be documented in CHANGELOG.md with migration instructions
|
|
1031
|
+
|
|
1032
|
+
## Contributing
|
|
1033
|
+
|
|
1034
|
+
1. Fork the repository
|
|
1035
|
+
2. Create a feature branch
|
|
1036
|
+
3. Add tests (maintain 90%+ coverage)
|
|
1037
|
+
4. Ensure all tests pass: `rspec`
|
|
1038
|
+
5. Submit a pull request
|
|
1039
|
+
|
|
1040
|
+
## License
|
|
1041
|
+
|
|
1042
|
+
MIT License. See [LICENSE.txt](LICENSE.txt).
|
|
1043
|
+
|
|
1044
|
+
## Roadmap
|
|
1045
|
+
|
|
1046
|
+
- [x] Rule validation CLI ✓
|
|
1047
|
+
- [x] Web UI for rule editing ✓
|
|
1048
|
+
- [ ] Performance benchmarks
|
|
1049
|
+
- [ ] Prometheus metrics adapter
|
|
1050
|
+
- [ ] Additional scoring strategies (Bayesian, etc.)
|
|
1051
|
+
- [ ] AI evaluator adapter (optional, non-deterministic mode)
|
|
1052
|
+
|
|
1053
|
+
## Support
|
|
1054
|
+
|
|
1055
|
+
- GitHub Issues: [https://github.com/samaswin87/decision_agent/issues](https://github.com/samaswin87/decision_agent/issues)
|
|
1056
|
+
- Documentation: [https://github.com/samaswin87/decision_agent](https://github.com/samaswin87/decision_agent)
|
|
1057
|
+
|
|
1058
|
+
---
|
|
1059
|
+
|
|
1060
|
+
**Built for regulated domains. Deterministic by design. AI-optional.**
|