decision_agent 0.1.2 → 0.1.3
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 +4 -4
- data/README.md +212 -35
- data/bin/decision_agent +3 -8
- data/lib/decision_agent/agent.rb +19 -26
- data/lib/decision_agent/audit/null_adapter.rb +1 -2
- data/lib/decision_agent/decision.rb +3 -1
- data/lib/decision_agent/dsl/condition_evaluator.rb +4 -3
- data/lib/decision_agent/dsl/rule_parser.rb +4 -6
- data/lib/decision_agent/dsl/schema_validator.rb +27 -31
- data/lib/decision_agent/errors.rb +11 -8
- data/lib/decision_agent/evaluation.rb +3 -1
- data/lib/decision_agent/evaluation_validator.rb +78 -0
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +26 -0
- data/lib/decision_agent/evaluators/static_evaluator.rb +2 -6
- data/lib/decision_agent/monitoring/alert_manager.rb +282 -0
- data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +381 -0
- data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +471 -0
- data/lib/decision_agent/monitoring/dashboard/public/index.html +161 -0
- data/lib/decision_agent/monitoring/dashboard_server.rb +340 -0
- data/lib/decision_agent/monitoring/metrics_collector.rb +278 -0
- data/lib/decision_agent/monitoring/monitored_agent.rb +71 -0
- data/lib/decision_agent/monitoring/prometheus_exporter.rb +247 -0
- data/lib/decision_agent/replay/replay.rb +12 -22
- data/lib/decision_agent/scoring/base.rb +1 -1
- data/lib/decision_agent/scoring/consensus.rb +5 -5
- data/lib/decision_agent/scoring/weighted_average.rb +1 -1
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +69 -33
- data/lib/decision_agent/versioning/adapter.rb +1 -3
- data/lib/decision_agent/versioning/file_storage_adapter.rb +143 -35
- data/lib/decision_agent/versioning/version_manager.rb +4 -12
- data/lib/decision_agent/web/public/index.html +1 -1
- data/lib/decision_agent/web/server.rb +19 -24
- data/lib/decision_agent.rb +7 -0
- data/lib/generators/decision_agent/install/install_generator.rb +5 -5
- data/lib/generators/decision_agent/install/templates/migration.rb +17 -6
- data/lib/generators/decision_agent/install/templates/rule.rb +3 -3
- data/lib/generators/decision_agent/install/templates/rule_version.rb +13 -7
- data/spec/activerecord_thread_safety_spec.rb +553 -0
- data/spec/agent_spec.rb +13 -13
- data/spec/api_contract_spec.rb +16 -16
- data/spec/audit_adapters_spec.rb +3 -3
- data/spec/comprehensive_edge_cases_spec.rb +86 -86
- data/spec/dsl_validation_spec.rb +83 -83
- data/spec/edge_cases_spec.rb +23 -23
- data/spec/examples/feedback_aware_evaluator_spec.rb +7 -7
- data/spec/examples.txt +548 -0
- data/spec/issue_verification_spec.rb +685 -0
- data/spec/json_rule_evaluator_spec.rb +15 -15
- data/spec/monitoring/alert_manager_spec.rb +378 -0
- data/spec/monitoring/metrics_collector_spec.rb +281 -0
- data/spec/monitoring/monitored_agent_spec.rb +222 -0
- data/spec/monitoring/prometheus_exporter_spec.rb +242 -0
- data/spec/replay_edge_cases_spec.rb +58 -58
- data/spec/replay_spec.rb +11 -11
- data/spec/rfc8785_canonicalization_spec.rb +215 -0
- data/spec/scoring_spec.rb +1 -1
- data/spec/spec_helper.rb +9 -0
- data/spec/thread_safety_spec.rb +482 -0
- data/spec/thread_safety_spec.rb.broken +878 -0
- data/spec/versioning_spec.rb +141 -37
- data/spec/web_ui_rack_spec.rb +135 -0
- metadata +69 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6c8a831b21d5bb62dbb4948827cc0b4bc9bcf021b52f04a2d9141d1339287274
|
|
4
|
+
data.tar.gz: 97d80cab6513385dc9e9b4bddb7555da1e8629488ce358319aa0842e74cf41e1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dcfa8f3cc0a5931a1a163b97caf7586c17eddfee38850b52b832fa1a94d720342868c6823158597d0dafa14bf7df82b9d9b394fdffac3c74d06a556eca2b5c18
|
|
7
|
+
data.tar.gz: f183fc1b8c021e589571d0d9a97c3b2679fec3b529d2e73a76a4b7752a9792dcba77f4914de876c5448711416179dcbd0ba35b515fe1c4f2a74bcab27c1a873e
|
data/README.md
CHANGED
|
@@ -59,7 +59,11 @@ puts result.explanations # => ["High value transaction"]
|
|
|
59
59
|
|
|
60
60
|
## Web UI - Visual Rule Builder
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
The DecisionAgent Web UI provides a visual interface for building and testing rules.
|
|
63
|
+
|
|
64
|
+
### Standalone Usage
|
|
65
|
+
|
|
66
|
+
Launch the visual rule builder:
|
|
63
67
|
|
|
64
68
|
```bash
|
|
65
69
|
decision_agent web
|
|
@@ -67,45 +71,119 @@ decision_agent web
|
|
|
67
71
|
|
|
68
72
|
Open [http://localhost:4567](http://localhost:4567) in your browser.
|
|
69
73
|
|
|
70
|
-
|
|
74
|
+
### Mount in Rails
|
|
71
75
|
|
|
72
|
-
|
|
76
|
+
Add to your `config/routes.rb`:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
require 'decision_agent/web/server'
|
|
80
|
+
|
|
81
|
+
Rails.application.routes.draw do
|
|
82
|
+
# Mount DecisionAgent Web UI
|
|
83
|
+
mount DecisionAgent::Web::Server, at: '/decision_agent'
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Then visit `http://localhost:3000/decision_agent` in your browser.
|
|
88
|
+
|
|
89
|
+
**With Authentication:**
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
authenticate :user, ->(user) { user.admin? } do
|
|
93
|
+
mount DecisionAgent::Web::Server, at: '/decision_agent'
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Mount in Rack/Sinatra Apps
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
# config.ru
|
|
101
|
+
require 'decision_agent/web/server'
|
|
102
|
+
|
|
103
|
+
map '/decision_agent' do
|
|
104
|
+
run DecisionAgent::Web::Server
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
<img width="1622" height="820" alt="Screenshot" src="https://github.com/user-attachments/assets/687e9ff6-669a-40f9-be27-085c614392d4" />
|
|
109
|
+
|
|
110
|
+
See [Web UI Rails Integration Guide](wiki/WEB_UI_RAILS_INTEGRATION.md) for detailed setup instructions.
|
|
111
|
+
|
|
112
|
+
## Monitoring & Analytics
|
|
113
|
+
|
|
114
|
+
Real-time monitoring, metrics, and alerting for production environments.
|
|
115
|
+
|
|
116
|
+
### Quick Start
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
require 'decision_agent/monitoring/metrics_collector'
|
|
120
|
+
require 'decision_agent/monitoring/dashboard_server'
|
|
121
|
+
|
|
122
|
+
# Initialize metrics collection
|
|
123
|
+
collector = DecisionAgent::Monitoring::MetricsCollector.new(window_size: 3600)
|
|
124
|
+
|
|
125
|
+
# Start real-time dashboard
|
|
126
|
+
DecisionAgent::Monitoring::DashboardServer.start!(
|
|
127
|
+
port: 4568,
|
|
128
|
+
metrics_collector: collector
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Record decisions
|
|
132
|
+
agent = DecisionAgent::Agent.new(evaluators: [evaluator])
|
|
133
|
+
result = agent.decide(context: { amount: 1500 })
|
|
134
|
+
collector.record_decision(result, context, duration_ms: 25.5)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Open [http://localhost:4568](http://localhost:4568) for the monitoring dashboard.
|
|
138
|
+
|
|
139
|
+
### Features
|
|
140
|
+
|
|
141
|
+
- **Real-time Dashboard** - Live metrics with WebSocket updates
|
|
142
|
+
- **Prometheus Export** - Industry-standard metrics format
|
|
143
|
+
- **Intelligent Alerting** - Anomaly detection with customizable rules
|
|
144
|
+
- **Grafana Integration** - Pre-built dashboards and alert rules
|
|
145
|
+
- **Custom KPIs** - Track business-specific metrics
|
|
146
|
+
- **Thread-Safe** - Production-ready performance
|
|
147
|
+
|
|
148
|
+
### Prometheus & Grafana
|
|
73
149
|
|
|
150
|
+
```yaml
|
|
151
|
+
# prometheus.yml
|
|
152
|
+
scrape_configs:
|
|
153
|
+
- job_name: 'decision_agent'
|
|
154
|
+
static_configs:
|
|
155
|
+
- targets: ['localhost:4568']
|
|
156
|
+
metrics_path: '/metrics'
|
|
74
157
|
```
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
│
|
|
100
|
-
├── 🎨 Web UI
|
|
101
|
-
│ ├── User Guide → wiki/WEB_UI.md
|
|
102
|
-
│ └── Setup Guide → wiki/WEB_UI_SETUP.md
|
|
103
|
-
│
|
|
104
|
-
└── 📝 Reference
|
|
105
|
-
├── Changelog → wiki/CHANGELOG.md
|
|
106
|
-
└── Full Wiki Index → wiki/README.md
|
|
158
|
+
|
|
159
|
+
Import the pre-built Grafana dashboard from [grafana/decision_agent_dashboard.json](grafana/decision_agent_dashboard.json).
|
|
160
|
+
|
|
161
|
+
### Alert Management
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
alert_manager = DecisionAgent::Monitoring::AlertManager.new(
|
|
165
|
+
metrics_collector: collector
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Add alert rules
|
|
169
|
+
alert_manager.add_rule(
|
|
170
|
+
name: 'High Error Rate',
|
|
171
|
+
condition: AlertManager.high_error_rate(threshold: 0.1),
|
|
172
|
+
severity: :critical
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Register alert handlers
|
|
176
|
+
alert_manager.add_handler do |alert|
|
|
177
|
+
SlackNotifier.notify("🚨 #{alert[:message]}")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Start monitoring
|
|
181
|
+
alert_manager.start_monitoring(interval: 60)
|
|
107
182
|
```
|
|
108
183
|
|
|
184
|
+
See [Monitoring & Analytics Guide](wiki/MONITORING_AND_ANALYTICS.md) for complete documentation.
|
|
185
|
+
|
|
186
|
+
|
|
109
187
|
## Key Features
|
|
110
188
|
|
|
111
189
|
### Decision Making
|
|
@@ -124,11 +202,21 @@ Open [http://localhost:4567](http://localhost:4567) in your browser.
|
|
|
124
202
|
- **JSON Rule DSL** - Non-technical users can write rules
|
|
125
203
|
- **Visual Rule Builder** - Web UI for rule management
|
|
126
204
|
|
|
205
|
+
### Monitoring & Observability
|
|
206
|
+
- **Real-time Metrics** - Live dashboard with WebSocket updates (<1 second latency)
|
|
207
|
+
- **Prometheus Export** - Industry-standard metrics format at `/metrics` endpoint
|
|
208
|
+
- **Intelligent Alerting** - Anomaly detection with customizable rules and severity levels
|
|
209
|
+
- **Grafana Integration** - Pre-built dashboards and alert configurations in `grafana/` directory
|
|
210
|
+
- **Custom KPIs** - Track business-specific metrics with thread-safe operations
|
|
211
|
+
- **MonitoredAgent** - Drop-in replacement that auto-records all metrics
|
|
212
|
+
- **AlertManager** - Built-in anomaly detection (error rates, latency spikes, low confidence)
|
|
213
|
+
|
|
127
214
|
### Production Ready
|
|
128
215
|
- **Comprehensive Testing** - 90%+ code coverage
|
|
129
216
|
- **Error Handling** - Clear, actionable error messages
|
|
130
217
|
- **Versioning** - Full rule version control and rollback
|
|
131
218
|
- **Performance** - Fast, zero external dependencies
|
|
219
|
+
- **Thread-Safe** - Safe for multi-threaded servers and background jobs
|
|
132
220
|
|
|
133
221
|
## Examples
|
|
134
222
|
|
|
@@ -162,6 +250,68 @@ rules = {
|
|
|
162
250
|
|
|
163
251
|
See [examples/](examples/) for complete working examples.
|
|
164
252
|
|
|
253
|
+
## Thread-Safety Guarantees
|
|
254
|
+
|
|
255
|
+
DecisionAgent is designed to be **thread-safe and FAST** for use in multi-threaded environments:
|
|
256
|
+
|
|
257
|
+
### Performance
|
|
258
|
+
- **10,000+ decisions/second** throughput
|
|
259
|
+
- **~0.1ms average latency** per decision
|
|
260
|
+
- **Zero performance overhead** from thread-safety
|
|
261
|
+
- **Linear scalability** with thread count
|
|
262
|
+
|
|
263
|
+
### Safe Concurrent Usage
|
|
264
|
+
- **Agent instances** can be shared across threads safely
|
|
265
|
+
- **Evaluators** are immutable after initialization
|
|
266
|
+
- **Decisions and Evaluations** are deeply frozen
|
|
267
|
+
- **File storage** uses mutex-protected operations
|
|
268
|
+
|
|
269
|
+
### Best Practices
|
|
270
|
+
```ruby
|
|
271
|
+
# Safe: Reuse agent instance across threads
|
|
272
|
+
agent = DecisionAgent::Agent.new(evaluators: [evaluator])
|
|
273
|
+
|
|
274
|
+
Thread.new { agent.decide(context: { user_id: 1 }) }
|
|
275
|
+
Thread.new { agent.decide(context: { user_id: 2 }) }
|
|
276
|
+
|
|
277
|
+
# Safe: Share evaluators across agent instances
|
|
278
|
+
evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
|
|
279
|
+
agent1 = DecisionAgent::Agent.new(evaluators: [evaluator])
|
|
280
|
+
agent2 = DecisionAgent::Agent.new(evaluators: [evaluator])
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### What's Frozen
|
|
284
|
+
All data structures are deeply frozen to prevent mutation:
|
|
285
|
+
- Decision objects (decision, confidence, explanations, evaluations)
|
|
286
|
+
- Evaluation objects (decision, weight, reason, metadata)
|
|
287
|
+
- Context data
|
|
288
|
+
- Rule definitions in evaluators
|
|
289
|
+
|
|
290
|
+
This ensures safe concurrent access without race conditions.
|
|
291
|
+
|
|
292
|
+
### RFC 8785 Canonical JSON
|
|
293
|
+
DecisionAgent uses **RFC 8785 (JSON Canonicalization Scheme)** for deterministic audit hashing:
|
|
294
|
+
|
|
295
|
+
- **Industry Standard** - Official IETF specification for canonical JSON
|
|
296
|
+
- **Cryptographically Sound** - Ensures deterministic hashing of decision payloads
|
|
297
|
+
- **Reproducible** - Same decision always produces same audit hash
|
|
298
|
+
- **Interoperable** - Compatible with other systems using RFC 8785
|
|
299
|
+
|
|
300
|
+
Every decision includes a deterministic SHA-256 hash in the audit payload, enabling:
|
|
301
|
+
- Tamper detection in audit logs
|
|
302
|
+
- Exact replay verification
|
|
303
|
+
- Regulatory compliance documentation
|
|
304
|
+
|
|
305
|
+
Learn more: [RFC 8785 Specification](https://datatracker.ietf.org/doc/html/rfc8785)
|
|
306
|
+
|
|
307
|
+
### Performance Benchmark
|
|
308
|
+
Run the included benchmark to verify zero overhead:
|
|
309
|
+
```bash
|
|
310
|
+
ruby examples/thread_safe_performance.rb
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
See [THREAD_SAFETY.md](wiki/THREAD_SAFETY.md) for detailed implementation guide and [PERFORMANCE_AND_THREAD_SAFETY.md](wiki/PERFORMANCE_AND_THREAD_SAFETY.md) for detailed performance analysis.
|
|
314
|
+
|
|
165
315
|
## When to Use DecisionAgent
|
|
166
316
|
|
|
167
317
|
✅ **Perfect for:**
|
|
@@ -176,6 +326,33 @@ See [examples/](examples/) for complete working examples.
|
|
|
176
326
|
- Pure AI/ML with no rules
|
|
177
327
|
- Single-step validations
|
|
178
328
|
|
|
329
|
+
## Documentation
|
|
330
|
+
|
|
331
|
+
**Getting Started**
|
|
332
|
+
- [Installation](#installation)
|
|
333
|
+
- [Quick Start](#quick-start)
|
|
334
|
+
- [Examples](examples/README.md)
|
|
335
|
+
|
|
336
|
+
**Core Features**
|
|
337
|
+
- [Versioning System](wiki/VERSIONING.md) - Version control for rules
|
|
338
|
+
- [Web UI](wiki/WEB_UI.md) - Visual rule builder
|
|
339
|
+
- [Web UI Setup](wiki/WEB_UI_SETUP.md) - Setup guide
|
|
340
|
+
- [Web UI Rails Integration](wiki/WEB_UI_RAILS_INTEGRATION.md) - Mount in Rails/Rack apps
|
|
341
|
+
- [Monitoring & Analytics](wiki/MONITORING_AND_ANALYTICS.md) - Real-time monitoring, metrics, and alerting
|
|
342
|
+
- [Monitoring Architecture](wiki/MONITORING_ARCHITECTURE.md) - System architecture and design
|
|
343
|
+
|
|
344
|
+
**Performance & Thread-Safety**
|
|
345
|
+
- [Performance & Thread-Safety Summary](wiki/PERFORMANCE_AND_THREAD_SAFETY.md) - Benchmarks and production readiness
|
|
346
|
+
- [Thread-Safety Implementation](wiki/THREAD_SAFETY.md) - Technical implementation guide
|
|
347
|
+
|
|
348
|
+
**Reference**
|
|
349
|
+
- [API Contract](wiki/API_CONTRACT.md) - Full API reference
|
|
350
|
+
- [Changelog](wiki/CHANGELOG.md) - Version history
|
|
351
|
+
|
|
352
|
+
**More Resources**
|
|
353
|
+
- [Wiki Home](wiki/README.md) - Documentation index
|
|
354
|
+
- [GitHub Issues](https://github.com/samaswin87/decision_agent/issues) - Report bugs or request features
|
|
355
|
+
|
|
179
356
|
## Contributing
|
|
180
357
|
|
|
181
358
|
1. Fork the repository
|
data/bin/decision_agent
CHANGED
|
@@ -24,15 +24,13 @@ def print_help
|
|
|
24
24
|
decision_agent version
|
|
25
25
|
|
|
26
26
|
For more information, visit:
|
|
27
|
-
https://github.com/
|
|
27
|
+
https://github.com/samaswin87/decision_agent
|
|
28
28
|
HELP
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
def start_web_ui(port = 4567)
|
|
32
32
|
# Ruby 4.0 compatibility: Puma expects Bundler::ORIGINAL_ENV which was removed
|
|
33
|
-
if defined?(Bundler) && !Bundler.const_defined?(:ORIGINAL_ENV)
|
|
34
|
-
Bundler.const_set(:ORIGINAL_ENV, ENV.to_h.dup)
|
|
35
|
-
end
|
|
33
|
+
Bundler.const_set(:ORIGINAL_ENV, ENV.to_h.dup) if defined?(Bundler) && !Bundler.const_defined?(:ORIGINAL_ENV)
|
|
36
34
|
|
|
37
35
|
puts "🎯 Starting DecisionAgent Rule Builder..."
|
|
38
36
|
puts "📍 Server: http://localhost:#{port}"
|
|
@@ -60,19 +58,16 @@ def validate_file(filepath)
|
|
|
60
58
|
puts " Version: #{data['version'] || data[:version]}"
|
|
61
59
|
puts " Ruleset: #{data['ruleset'] || data[:ruleset]}"
|
|
62
60
|
puts " Rules: #{data['rules']&.size || 0}"
|
|
63
|
-
|
|
64
61
|
rescue JSON::ParserError => e
|
|
65
62
|
puts "❌ JSON Parsing Error:"
|
|
66
63
|
puts " #{e.message}"
|
|
67
64
|
exit 1
|
|
68
|
-
|
|
69
65
|
rescue DecisionAgent::InvalidRuleDslError => e
|
|
70
66
|
puts "❌ Validation Failed:"
|
|
71
67
|
puts ""
|
|
72
68
|
puts e.message
|
|
73
69
|
exit 1
|
|
74
|
-
|
|
75
|
-
rescue => e
|
|
70
|
+
rescue StandardError => e
|
|
76
71
|
puts "❌ Unexpected Error:"
|
|
77
72
|
puts " #{e.message}"
|
|
78
73
|
exit 1
|
data/lib/decision_agent/agent.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "digest"
|
|
2
2
|
require "json"
|
|
3
|
+
require "json/canonicalization"
|
|
3
4
|
|
|
4
5
|
module DecisionAgent
|
|
5
6
|
class Agent
|
|
@@ -11,6 +12,9 @@ module DecisionAgent
|
|
|
11
12
|
@audit_adapter = audit_adapter || Audit::NullAdapter.new
|
|
12
13
|
|
|
13
14
|
validate_configuration!
|
|
15
|
+
|
|
16
|
+
# Freeze instance variables for thread-safety
|
|
17
|
+
@evaluators.freeze
|
|
14
18
|
end
|
|
15
19
|
|
|
16
20
|
def decide(context:, feedback: {})
|
|
@@ -20,6 +24,9 @@ module DecisionAgent
|
|
|
20
24
|
|
|
21
25
|
raise NoEvaluationsError if evaluations.empty?
|
|
22
26
|
|
|
27
|
+
# Validate all evaluations for correctness and thread-safety
|
|
28
|
+
EvaluationValidator.validate_all!(evaluations)
|
|
29
|
+
|
|
23
30
|
scored_result = @scoring_strategy.score(evaluations)
|
|
24
31
|
|
|
25
32
|
decision_value = scored_result[:decision]
|
|
@@ -51,32 +58,24 @@ module DecisionAgent
|
|
|
51
58
|
private
|
|
52
59
|
|
|
53
60
|
def validate_configuration!
|
|
54
|
-
if @evaluators.empty?
|
|
55
|
-
raise InvalidConfigurationError, "At least one evaluator is required"
|
|
56
|
-
end
|
|
61
|
+
raise InvalidConfigurationError, "At least one evaluator is required" if @evaluators.empty?
|
|
57
62
|
|
|
58
63
|
@evaluators.each do |evaluator|
|
|
59
|
-
unless evaluator.respond_to?(:evaluate)
|
|
60
|
-
raise InvalidEvaluatorError
|
|
61
|
-
end
|
|
64
|
+
raise InvalidEvaluatorError unless evaluator.respond_to?(:evaluate)
|
|
62
65
|
end
|
|
63
66
|
|
|
64
|
-
unless @scoring_strategy.respond_to?(:score)
|
|
65
|
-
raise InvalidScoringStrategyError
|
|
66
|
-
end
|
|
67
|
+
raise InvalidScoringStrategyError unless @scoring_strategy.respond_to?(:score)
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
return if @audit_adapter.respond_to?(:record)
|
|
70
|
+
|
|
71
|
+
raise InvalidAuditAdapterError
|
|
71
72
|
end
|
|
72
73
|
|
|
73
74
|
def collect_evaluations(context, feedback)
|
|
74
75
|
@evaluators.map do |evaluator|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
nil
|
|
79
|
-
end
|
|
76
|
+
evaluator.evaluate(context, feedback: feedback)
|
|
77
|
+
rescue StandardError
|
|
78
|
+
nil
|
|
80
79
|
end.compact
|
|
81
80
|
end
|
|
82
81
|
|
|
@@ -132,16 +131,10 @@ module DecisionAgent
|
|
|
132
131
|
Digest::SHA256.hexdigest(canonical)
|
|
133
132
|
end
|
|
134
133
|
|
|
134
|
+
# Uses RFC 8785 (JSON Canonicalization Scheme) for deterministic JSON serialization
|
|
135
|
+
# This is the industry standard for cryptographic hashing of JSON data
|
|
135
136
|
def canonical_json(obj)
|
|
136
|
-
|
|
137
|
-
when Hash
|
|
138
|
-
sorted = obj.keys.sort.map { |k| [k.to_s, canonical_json(obj[k])] }.to_h
|
|
139
|
-
JSON.generate(sorted, quirks_mode: false)
|
|
140
|
-
when Array
|
|
141
|
-
JSON.generate(obj.map { |v| canonical_json(v) }, quirks_mode: false)
|
|
142
|
-
else
|
|
143
|
-
obj.to_s
|
|
144
|
-
end
|
|
137
|
+
obj.to_json_c14n
|
|
145
138
|
end
|
|
146
139
|
end
|
|
147
140
|
end
|
|
@@ -10,6 +10,8 @@ module DecisionAgent
|
|
|
10
10
|
@explanations = Array(explanations).map(&:freeze).freeze
|
|
11
11
|
@evaluations = Array(evaluations).freeze
|
|
12
12
|
@audit_payload = deep_freeze(audit_payload)
|
|
13
|
+
|
|
14
|
+
freeze
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def to_h
|
|
@@ -34,7 +36,7 @@ module DecisionAgent
|
|
|
34
36
|
|
|
35
37
|
def validate_confidence!(confidence)
|
|
36
38
|
c = confidence.to_f
|
|
37
|
-
raise InvalidConfidenceError
|
|
39
|
+
raise InvalidConfidenceError, confidence unless c.between?(0.0, 1.0)
|
|
38
40
|
end
|
|
39
41
|
|
|
40
42
|
def deep_freeze(obj)
|
|
@@ -21,13 +21,12 @@ module DecisionAgent
|
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
private
|
|
25
|
-
|
|
26
24
|
# Evaluates 'all' condition - returns true only if ALL sub-conditions are true
|
|
27
25
|
# Empty array returns true (vacuous truth)
|
|
28
26
|
def self.evaluate_all(conditions, context)
|
|
29
27
|
return true if conditions.is_a?(Array) && conditions.empty?
|
|
30
28
|
return false unless conditions.is_a?(Array)
|
|
29
|
+
|
|
31
30
|
conditions.all? { |cond| evaluate(cond, context) }
|
|
32
31
|
end
|
|
33
32
|
|
|
@@ -35,6 +34,7 @@ module DecisionAgent
|
|
|
35
34
|
# Empty array returns false (no options to match)
|
|
36
35
|
def self.evaluate_any(conditions, context)
|
|
37
36
|
return false unless conditions.is_a?(Array)
|
|
37
|
+
|
|
38
38
|
conditions.any? { |cond| evaluate(cond, context) }
|
|
39
39
|
end
|
|
40
40
|
|
|
@@ -117,6 +117,7 @@ module DecisionAgent
|
|
|
117
117
|
keys = key_path.to_s.split(".")
|
|
118
118
|
keys.reduce(hash) do |memo, key|
|
|
119
119
|
return nil unless memo.is_a?(Hash)
|
|
120
|
+
|
|
120
121
|
memo[key] || memo[key.to_sym]
|
|
121
122
|
end
|
|
122
123
|
end
|
|
@@ -126,7 +127,7 @@ module DecisionAgent
|
|
|
126
127
|
def self.comparable?(val1, val2)
|
|
127
128
|
(val1.is_a?(Numeric) || val1.is_a?(String)) &&
|
|
128
129
|
(val2.is_a?(Numeric) || val2.is_a?(String)) &&
|
|
129
|
-
val1.
|
|
130
|
+
val1.instance_of?(val2.class)
|
|
130
131
|
end
|
|
131
132
|
end
|
|
132
133
|
end
|
|
@@ -13,14 +13,12 @@ module DecisionAgent
|
|
|
13
13
|
rescue JSON::ParserError => e
|
|
14
14
|
raise InvalidRuleDslError, "Invalid JSON syntax: #{e.message}\n\n" \
|
|
15
15
|
"Please ensure your JSON is properly formatted. " \
|
|
16
|
-
"Common issues:\n" \
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
16
|
+
"Common issues:\n " \
|
|
17
|
+
"- Missing or extra commas\n " \
|
|
18
|
+
"- Unquoted keys or values\n " \
|
|
19
|
+
"- Unmatched brackets or braces"
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
private
|
|
23
|
-
|
|
24
22
|
def self.parse_json(input)
|
|
25
23
|
if input.is_a?(String)
|
|
26
24
|
JSON.parse(input)
|
|
@@ -31,18 +31,18 @@ module DecisionAgent
|
|
|
31
31
|
private
|
|
32
32
|
|
|
33
33
|
def validate_root_structure
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
return if @data.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
@errors << "Root element must be a hash/object, got #{@data.class}"
|
|
37
|
+
nil
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def validate_version
|
|
41
41
|
return if @errors.any? # Skip if root structure is invalid
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
return if @data.key?("version") || @data.key?(:version)
|
|
44
|
+
|
|
45
|
+
@errors << "Missing required field 'version'. Example: { \"version\": \"1.0\", ... }"
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
def validate_rules_array
|
|
@@ -55,9 +55,9 @@ module DecisionAgent
|
|
|
55
55
|
return
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
return if rules.is_a?(Array)
|
|
59
|
+
|
|
60
|
+
@errors << "Field 'rules' must be an array, got #{rules.class}. Example: \"rules\": [...]"
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
def validate_each_rule
|
|
@@ -93,9 +93,9 @@ module DecisionAgent
|
|
|
93
93
|
return
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
return if rule_id.is_a?(String) || rule_id.is_a?(Symbol)
|
|
97
|
+
|
|
98
|
+
@errors << "#{rule_path}: Field 'id' must be a string, got #{rule_id.class}"
|
|
99
99
|
end
|
|
100
100
|
|
|
101
101
|
def validate_if_clause(rule, rule_path)
|
|
@@ -150,9 +150,7 @@ module DecisionAgent
|
|
|
150
150
|
value = condition["value"] || condition[:value]
|
|
151
151
|
|
|
152
152
|
# Validate field
|
|
153
|
-
unless field
|
|
154
|
-
@errors << "#{path}: Field condition missing 'field' key"
|
|
155
|
-
end
|
|
153
|
+
@errors << "#{path}: Field condition missing 'field' key" unless field
|
|
156
154
|
|
|
157
155
|
# Validate operator
|
|
158
156
|
unless operator
|
|
@@ -174,10 +172,10 @@ module DecisionAgent
|
|
|
174
172
|
def validate_operator(operator, path)
|
|
175
173
|
operator_str = operator.to_s
|
|
176
174
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
175
|
+
return if SUPPORTED_OPERATORS.include?(operator_str)
|
|
176
|
+
|
|
177
|
+
@errors << "#{path}: Unsupported operator '#{operator}'. " \
|
|
178
|
+
"Supported operators: #{SUPPORTED_OPERATORS.join(', ')}"
|
|
181
179
|
end
|
|
182
180
|
|
|
183
181
|
def validate_field_path(field, path)
|
|
@@ -191,11 +189,11 @@ module DecisionAgent
|
|
|
191
189
|
# Validate dot-notation
|
|
192
190
|
parts = field.split(".")
|
|
193
191
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
192
|
+
return unless parts.any?(&:empty?)
|
|
193
|
+
|
|
194
|
+
@errors << "#{path}: Invalid field path '#{field}'. " \
|
|
195
|
+
"Dot-notation paths cannot have empty segments. " \
|
|
196
|
+
"Example: 'user.profile.role'"
|
|
199
197
|
end
|
|
200
198
|
|
|
201
199
|
def validate_all_condition(condition, path)
|
|
@@ -241,9 +239,7 @@ module DecisionAgent
|
|
|
241
239
|
# Validate decision
|
|
242
240
|
decision = then_clause["decision"] || then_clause[:decision]
|
|
243
241
|
|
|
244
|
-
unless decision
|
|
245
|
-
@errors << "#{rule_path}.then: Missing required field 'decision'"
|
|
246
|
-
end
|
|
242
|
+
@errors << "#{rule_path}.then: Missing required field 'decision'" unless decision
|
|
247
243
|
|
|
248
244
|
# Validate optional weight
|
|
249
245
|
weight = then_clause["weight"] || then_clause[:weight]
|
|
@@ -257,9 +253,9 @@ module DecisionAgent
|
|
|
257
253
|
# Validate optional reason
|
|
258
254
|
reason = then_clause["reason"] || then_clause[:reason]
|
|
259
255
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
256
|
+
return unless reason && !reason.is_a?(String)
|
|
257
|
+
|
|
258
|
+
@errors << "#{rule_path}.then.reason: Must be a string, got #{reason.class}"
|
|
263
259
|
end
|
|
264
260
|
|
|
265
261
|
def format_errors
|