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
data/README.md ADDED
@@ -0,0 +1,1060 @@
1
+ # DecisionAgent
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/decision_agent.svg)](https://badge.fury.io/rb/decision_agent)
4
+ [![CI](https://github.com/samaswin87/decision_agent/actions/workflows/ci.yml/badge.svg)](https://github.com/samaswin87/decision_agent/actions/workflows/ci.yml)
5
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
6
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%202.7.0-red.svg)](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.**