decision_agent 0.3.0 → 1.0.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 +4 -4
- data/README.md +272 -7
- data/lib/decision_agent/agent.rb +72 -1
- data/lib/decision_agent/context.rb +1 -0
- data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +86 -0
- data/lib/decision_agent/data_enrichment/cache_adapter.rb +49 -0
- data/lib/decision_agent/data_enrichment/circuit_breaker.rb +135 -0
- data/lib/decision_agent/data_enrichment/client.rb +220 -0
- data/lib/decision_agent/data_enrichment/config.rb +78 -0
- data/lib/decision_agent/data_enrichment/errors.rb +36 -0
- data/lib/decision_agent/decision.rb +102 -2
- data/lib/decision_agent/dmn/feel/evaluator.rb +28 -6
- data/lib/decision_agent/dsl/condition_evaluator.rb +982 -839
- data/lib/decision_agent/dsl/schema_validator.rb +51 -13
- data/lib/decision_agent/evaluators/dmn_evaluator.rb +106 -19
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +69 -9
- data/lib/decision_agent/explainability/condition_trace.rb +83 -0
- data/lib/decision_agent/explainability/explainability_result.rb +52 -0
- data/lib/decision_agent/explainability/rule_trace.rb +39 -0
- data/lib/decision_agent/explainability/trace_collector.rb +24 -0
- data/lib/decision_agent/monitoring/alert_manager.rb +5 -1
- data/lib/decision_agent/simulation/errors.rb +18 -0
- data/lib/decision_agent/simulation/impact_analyzer.rb +498 -0
- data/lib/decision_agent/simulation/monte_carlo_simulator.rb +635 -0
- data/lib/decision_agent/simulation/replay_engine.rb +486 -0
- data/lib/decision_agent/simulation/scenario_engine.rb +318 -0
- data/lib/decision_agent/simulation/scenario_library.rb +163 -0
- data/lib/decision_agent/simulation/shadow_test_engine.rb +287 -0
- data/lib/decision_agent/simulation/what_if_analyzer.rb +1002 -0
- data/lib/decision_agent/simulation.rb +17 -0
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +23 -8
- data/lib/decision_agent/web/public/app.js +119 -0
- data/lib/decision_agent/web/public/index.html +49 -0
- data/lib/decision_agent/web/public/simulation.html +130 -0
- data/lib/decision_agent/web/public/simulation_impact.html +478 -0
- data/lib/decision_agent/web/public/simulation_replay.html +551 -0
- data/lib/decision_agent/web/public/simulation_shadow.html +546 -0
- data/lib/decision_agent/web/public/simulation_whatif.html +532 -0
- data/lib/decision_agent/web/public/styles.css +65 -0
- data/lib/decision_agent/web/server.rb +594 -23
- data/lib/decision_agent.rb +60 -2
- metadata +53 -73
- data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
- data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
- data/spec/ab_testing/ab_test_spec.rb +0 -270
- data/spec/ab_testing/ab_testing_agent_spec.rb +0 -655
- data/spec/ab_testing/storage/adapter_spec.rb +0 -64
- data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
- data/spec/activerecord_thread_safety_spec.rb +0 -553
- data/spec/advanced_operators_spec.rb +0 -3150
- data/spec/agent_spec.rb +0 -289
- data/spec/api_contract_spec.rb +0 -430
- data/spec/audit_adapters_spec.rb +0 -92
- data/spec/auth/access_audit_logger_spec.rb +0 -394
- data/spec/auth/authenticator_spec.rb +0 -112
- data/spec/auth/password_reset_spec.rb +0 -294
- data/spec/auth/permission_checker_spec.rb +0 -207
- data/spec/auth/permission_spec.rb +0 -73
- data/spec/auth/rbac_adapter_spec.rb +0 -778
- data/spec/auth/rbac_config_spec.rb +0 -82
- data/spec/auth/role_spec.rb +0 -51
- data/spec/auth/session_manager_spec.rb +0 -172
- data/spec/auth/session_spec.rb +0 -112
- data/spec/auth/user_spec.rb +0 -130
- data/spec/comprehensive_edge_cases_spec.rb +0 -1777
- data/spec/context_spec.rb +0 -127
- data/spec/decision_agent_spec.rb +0 -96
- data/spec/decision_spec.rb +0 -423
- data/spec/dmn/decision_graph_spec.rb +0 -282
- data/spec/dmn/decision_tree_spec.rb +0 -203
- data/spec/dmn/feel/errors_spec.rb +0 -18
- data/spec/dmn/feel/functions_spec.rb +0 -400
- data/spec/dmn/feel/simple_parser_spec.rb +0 -274
- data/spec/dmn/feel/types_spec.rb +0 -176
- data/spec/dmn/feel_parser_spec.rb +0 -489
- data/spec/dmn/hit_policy_spec.rb +0 -202
- data/spec/dmn/integration_spec.rb +0 -226
- data/spec/dsl/condition_evaluator_spec.rb +0 -774
- data/spec/dsl_validation_spec.rb +0 -648
- data/spec/edge_cases_spec.rb +0 -353
- data/spec/evaluation_spec.rb +0 -364
- data/spec/evaluation_validator_spec.rb +0 -165
- data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
- data/spec/examples.txt +0 -1909
- data/spec/fixtures/dmn/complex_decision.dmn +0 -81
- data/spec/fixtures/dmn/invalid_structure.dmn +0 -31
- data/spec/fixtures/dmn/simple_decision.dmn +0 -40
- data/spec/issue_verification_spec.rb +0 -759
- data/spec/json_rule_evaluator_spec.rb +0 -587
- data/spec/monitoring/alert_manager_spec.rb +0 -378
- data/spec/monitoring/metrics_collector_spec.rb +0 -501
- data/spec/monitoring/monitored_agent_spec.rb +0 -225
- data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
- data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
- data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
- data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
- data/spec/performance_optimizations_spec.rb +0 -493
- data/spec/replay_edge_cases_spec.rb +0 -699
- data/spec/replay_spec.rb +0 -210
- data/spec/rfc8785_canonicalization_spec.rb +0 -215
- data/spec/scoring_spec.rb +0 -225
- data/spec/spec_helper.rb +0 -60
- data/spec/testing/batch_test_importer_spec.rb +0 -693
- data/spec/testing/batch_test_runner_spec.rb +0 -307
- data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
- data/spec/testing/test_result_comparator_spec.rb +0 -392
- data/spec/testing/test_scenario_spec.rb +0 -113
- data/spec/thread_safety_spec.rb +0 -490
- data/spec/thread_safety_spec.rb.broken +0 -878
- data/spec/versioning/adapter_spec.rb +0 -156
- data/spec/versioning_spec.rb +0 -1030
- data/spec/web/middleware/auth_middleware_spec.rb +0 -133
- data/spec/web/middleware/permission_middleware_spec.rb +0 -247
- data/spec/web_ui_rack_spec.rb +0 -2134
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module DecisionAgent
|
|
6
|
+
module DataEnrichment
|
|
7
|
+
# Circuit breaker pattern for resilience
|
|
8
|
+
#
|
|
9
|
+
# Prevents cascading failures by opening the circuit after N failures
|
|
10
|
+
class CircuitBreaker
|
|
11
|
+
include MonitorMixin
|
|
12
|
+
|
|
13
|
+
# Circuit states
|
|
14
|
+
CLOSED = :closed # Normal operation
|
|
15
|
+
OPEN = :open # Circuit is open, failing fast
|
|
16
|
+
HALF_OPEN = :half_open # Testing if service recovered
|
|
17
|
+
|
|
18
|
+
def initialize(failure_threshold: 5, timeout: 60, success_threshold: 2)
|
|
19
|
+
super()
|
|
20
|
+
@failure_threshold = failure_threshold
|
|
21
|
+
@timeout = timeout
|
|
22
|
+
@success_threshold = success_threshold
|
|
23
|
+
@state = CLOSED
|
|
24
|
+
@failure_count = 0
|
|
25
|
+
@success_count = 0
|
|
26
|
+
@last_failure_time = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Execute block with circuit breaker protection
|
|
30
|
+
#
|
|
31
|
+
# @yield Block to execute
|
|
32
|
+
# @return [Object] Result of block execution
|
|
33
|
+
# @raise [CircuitOpenError] If circuit is open
|
|
34
|
+
def call
|
|
35
|
+
synchronize do
|
|
36
|
+
check_state
|
|
37
|
+
raise CircuitOpenError, "Circuit is open" if @state == OPEN
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
begin
|
|
41
|
+
result = yield
|
|
42
|
+
record_success
|
|
43
|
+
result
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
record_failure
|
|
46
|
+
raise e
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Check if circuit is open
|
|
51
|
+
#
|
|
52
|
+
# @return [Boolean]
|
|
53
|
+
def open?
|
|
54
|
+
synchronize do
|
|
55
|
+
check_state
|
|
56
|
+
@state == OPEN
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Reset circuit breaker to closed state
|
|
61
|
+
def reset
|
|
62
|
+
synchronize do
|
|
63
|
+
@state = CLOSED
|
|
64
|
+
@failure_count = 0
|
|
65
|
+
@success_count = 0
|
|
66
|
+
@last_failure_time = nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Get current state
|
|
71
|
+
#
|
|
72
|
+
# @return [Symbol] Current state
|
|
73
|
+
def state
|
|
74
|
+
synchronize do
|
|
75
|
+
check_state
|
|
76
|
+
@state
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def check_state
|
|
83
|
+
case @state
|
|
84
|
+
when OPEN
|
|
85
|
+
# Check if timeout has elapsed, move to half-open
|
|
86
|
+
if @last_failure_time && (Time.now - @last_failure_time) >= @timeout
|
|
87
|
+
@state = HALF_OPEN
|
|
88
|
+
@success_count = 0
|
|
89
|
+
end
|
|
90
|
+
when HALF_OPEN
|
|
91
|
+
# State remains half-open until success threshold is met
|
|
92
|
+
when CLOSED
|
|
93
|
+
# State remains closed until failure threshold is met
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def record_success
|
|
98
|
+
synchronize do
|
|
99
|
+
case @state
|
|
100
|
+
when HALF_OPEN
|
|
101
|
+
@success_count += 1
|
|
102
|
+
if @success_count >= @success_threshold
|
|
103
|
+
@state = CLOSED
|
|
104
|
+
@failure_count = 0
|
|
105
|
+
@success_count = 0
|
|
106
|
+
end
|
|
107
|
+
when CLOSED
|
|
108
|
+
@failure_count = 0 # Reset failure count on success
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def record_failure
|
|
114
|
+
synchronize do
|
|
115
|
+
@failure_count += 1
|
|
116
|
+
@last_failure_time = Time.now
|
|
117
|
+
|
|
118
|
+
case @state
|
|
119
|
+
when HALF_OPEN
|
|
120
|
+
# Failures in half-open state immediately open the circuit
|
|
121
|
+
@state = OPEN
|
|
122
|
+
@success_count = 0
|
|
123
|
+
when CLOSED
|
|
124
|
+
# Open circuit if failure threshold is met
|
|
125
|
+
@state = OPEN if @failure_count >= @failure_threshold
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Error raised when circuit is open
|
|
131
|
+
class CircuitOpenError < StandardError
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require "base64"
|
|
7
|
+
require_relative "cache_adapter"
|
|
8
|
+
require_relative "cache/memory_adapter"
|
|
9
|
+
require_relative "circuit_breaker"
|
|
10
|
+
require_relative "errors"
|
|
11
|
+
|
|
12
|
+
module DecisionAgent
|
|
13
|
+
module DataEnrichment
|
|
14
|
+
# HTTP client for data enrichment requests
|
|
15
|
+
class Client
|
|
16
|
+
# Error classes
|
|
17
|
+
class RequestError < StandardError
|
|
18
|
+
attr_reader :status_code, :response_body
|
|
19
|
+
|
|
20
|
+
def initialize(message, status_code: nil, response_body: nil)
|
|
21
|
+
super(message)
|
|
22
|
+
@status_code = status_code
|
|
23
|
+
@response_body = response_body
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class TimeoutError < RequestError
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class NetworkError < RequestError
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
attr_reader :config, :cache_adapter, :circuit_breaker
|
|
34
|
+
|
|
35
|
+
def initialize(config:, cache_adapter: nil, circuit_breaker: nil)
|
|
36
|
+
@config = config
|
|
37
|
+
@cache_adapter = cache_adapter || Cache::MemoryAdapter.new
|
|
38
|
+
@circuit_breakers = {}
|
|
39
|
+
@circuit_breaker_default = circuit_breaker || CircuitBreaker.new
|
|
40
|
+
@circuit_breaker = @circuit_breaker_default
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Fetch data from configured endpoint
|
|
44
|
+
#
|
|
45
|
+
# @param endpoint_name [Symbol] Endpoint identifier
|
|
46
|
+
# @param params [Hash] Request parameters
|
|
47
|
+
# @param use_cache [Boolean] Whether to use cache (default: true)
|
|
48
|
+
# @return [Hash] Response data
|
|
49
|
+
# @raise [RequestError] If request fails
|
|
50
|
+
def fetch(endpoint_name, params: {}, use_cache: true)
|
|
51
|
+
endpoint_config = @config.endpoint(endpoint_name)
|
|
52
|
+
raise ArgumentError, "Unknown endpoint: #{endpoint_name}" unless endpoint_config
|
|
53
|
+
|
|
54
|
+
# Generate cache key
|
|
55
|
+
cache_key = @cache_adapter.cache_key(endpoint_name, params)
|
|
56
|
+
|
|
57
|
+
# Check cache first
|
|
58
|
+
if use_cache
|
|
59
|
+
cached = @cache_adapter.get(cache_key)
|
|
60
|
+
return cached if cached
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Execute request with circuit breaker
|
|
64
|
+
circuit_breaker = get_circuit_breaker(endpoint_name)
|
|
65
|
+
begin
|
|
66
|
+
response_data = circuit_breaker.call do
|
|
67
|
+
execute_request(endpoint_name, endpoint_config, params)
|
|
68
|
+
end
|
|
69
|
+
rescue CircuitBreaker::CircuitOpenError => e
|
|
70
|
+
# Try to return cached data on circuit open
|
|
71
|
+
cached = @cache_adapter.get(cache_key) if use_cache
|
|
72
|
+
return cached if cached
|
|
73
|
+
|
|
74
|
+
raise RequestError, "Circuit breaker is open for #{endpoint_name}: #{e.message}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Cache response
|
|
78
|
+
@cache_adapter.set(cache_key, response_data, endpoint_config[:cache][:ttl]) if use_cache && endpoint_config[:cache][:ttl].positive?
|
|
79
|
+
|
|
80
|
+
response_data
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Clear cache for endpoint
|
|
84
|
+
#
|
|
85
|
+
# @param endpoint_name [Symbol, nil] Endpoint identifier, or nil to clear all
|
|
86
|
+
def clear_cache(endpoint_name = nil)
|
|
87
|
+
if endpoint_name
|
|
88
|
+
# Clear cache entries for this endpoint (requires cache adapter support)
|
|
89
|
+
# For now, just clear all if endpoint-specific clearing isn't supported
|
|
90
|
+
end
|
|
91
|
+
@cache_adapter.clear
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def execute_request(endpoint_name, endpoint_config, params)
|
|
97
|
+
uri = URI(endpoint_config[:url])
|
|
98
|
+
method = endpoint_config[:method]
|
|
99
|
+
timeout = endpoint_config[:timeout]
|
|
100
|
+
|
|
101
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
102
|
+
http.use_ssl = (uri.scheme == "https")
|
|
103
|
+
http.read_timeout = timeout
|
|
104
|
+
http.open_timeout = timeout
|
|
105
|
+
|
|
106
|
+
request = build_request(uri, method, endpoint_config, params)
|
|
107
|
+
|
|
108
|
+
response = http.request(request)
|
|
109
|
+
|
|
110
|
+
handle_response(response, endpoint_name)
|
|
111
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
112
|
+
raise TimeoutError, "Request timeout for #{endpoint_name}: #{e.message}"
|
|
113
|
+
rescue StandardError => e
|
|
114
|
+
raise NetworkError, "Network error for #{endpoint_name}: #{e.message}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def build_request(uri, method, endpoint_config, params)
|
|
118
|
+
headers = endpoint_config[:headers].dup || {}
|
|
119
|
+
apply_auth(headers, endpoint_config[:auth])
|
|
120
|
+
|
|
121
|
+
case method
|
|
122
|
+
when :get
|
|
123
|
+
# Add params to query string for GET requests
|
|
124
|
+
if params.any?
|
|
125
|
+
query_string = URI.encode_www_form(params)
|
|
126
|
+
uri_with_query = URI("#{uri}?#{query_string}")
|
|
127
|
+
request = Net::HTTP::Get.new(uri_with_query)
|
|
128
|
+
else
|
|
129
|
+
request = Net::HTTP::Get.new(uri)
|
|
130
|
+
end
|
|
131
|
+
when :post
|
|
132
|
+
request = Net::HTTP::Post.new(uri)
|
|
133
|
+
request.body = params.to_json
|
|
134
|
+
headers["Content-Type"] ||= "application/json"
|
|
135
|
+
when :put
|
|
136
|
+
request = Net::HTTP::Put.new(uri)
|
|
137
|
+
request.body = params.to_json
|
|
138
|
+
headers["Content-Type"] ||= "application/json"
|
|
139
|
+
when :delete
|
|
140
|
+
request = Net::HTTP::Delete.new(uri)
|
|
141
|
+
else
|
|
142
|
+
raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
headers.each { |k, v| request[k] = v }
|
|
146
|
+
request
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def apply_auth(headers, auth_config)
|
|
150
|
+
return unless auth_config
|
|
151
|
+
|
|
152
|
+
case auth_config[:type]
|
|
153
|
+
when :api_key
|
|
154
|
+
header_name = auth_config[:header] || "X-API-Key"
|
|
155
|
+
api_key = get_secret(auth_config[:secret_key] || "API_KEY")
|
|
156
|
+
headers[header_name] = api_key
|
|
157
|
+
when :basic
|
|
158
|
+
username = get_secret(auth_config[:username_key] || "USERNAME")
|
|
159
|
+
password = get_secret(auth_config[:password_key] || "PASSWORD")
|
|
160
|
+
credentials = Base64.strict_encode64("#{username}:#{password}")
|
|
161
|
+
headers["Authorization"] = "Basic #{credentials}"
|
|
162
|
+
when :bearer
|
|
163
|
+
token = get_secret(auth_config[:token_key] || "TOKEN")
|
|
164
|
+
headers["Authorization"] = "Bearer #{token}"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def get_secret(key)
|
|
169
|
+
# Try environment variable first
|
|
170
|
+
return ENV[key] if ENV.key?(key)
|
|
171
|
+
return ENV[key.upcase] if ENV.key?(key.upcase)
|
|
172
|
+
|
|
173
|
+
# Could extend to support vault integration here
|
|
174
|
+
raise ArgumentError, "Secret not found: #{key}. Set environment variable #{key.upcase}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def handle_response(response, endpoint_name)
|
|
178
|
+
case response.code.to_i
|
|
179
|
+
when 200..299
|
|
180
|
+
parse_response(response)
|
|
181
|
+
when 400..499
|
|
182
|
+
raise RequestError.new(
|
|
183
|
+
"Client error for #{endpoint_name}: HTTP #{response.code}",
|
|
184
|
+
status_code: response.code.to_i,
|
|
185
|
+
response_body: response.body
|
|
186
|
+
)
|
|
187
|
+
when 500..599
|
|
188
|
+
raise RequestError.new(
|
|
189
|
+
"Server error for #{endpoint_name}: HTTP #{response.code}",
|
|
190
|
+
status_code: response.code.to_i,
|
|
191
|
+
response_body: response.body
|
|
192
|
+
)
|
|
193
|
+
else
|
|
194
|
+
raise RequestError.new(
|
|
195
|
+
"Unexpected response for #{endpoint_name}: HTTP #{response.code}",
|
|
196
|
+
status_code: response.code.to_i,
|
|
197
|
+
response_body: response.body
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def parse_response(response)
|
|
203
|
+
content_type = response["Content-Type"] || ""
|
|
204
|
+
body = response.body
|
|
205
|
+
|
|
206
|
+
return {} if body.nil? || body.empty?
|
|
207
|
+
|
|
208
|
+
if content_type.include?("application/json")
|
|
209
|
+
JSON.parse(body, symbolize_names: true)
|
|
210
|
+
else
|
|
211
|
+
{ body: body, content_type: content_type }
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def get_circuit_breaker(endpoint_name)
|
|
216
|
+
@circuit_breakers[endpoint_name] ||= @circuit_breaker_default.dup
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DecisionAgent
|
|
4
|
+
module DataEnrichment
|
|
5
|
+
# Configuration for data enrichment endpoints
|
|
6
|
+
class Config
|
|
7
|
+
attr_accessor :endpoints, :default_timeout, :default_retry, :default_cache
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@endpoints = {}
|
|
11
|
+
@default_timeout = 5 # seconds
|
|
12
|
+
@default_retry = { max_attempts: 3, backoff: :exponential }
|
|
13
|
+
@default_cache = { ttl: 3600, adapter: :memory }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Add or update an endpoint configuration
|
|
17
|
+
#
|
|
18
|
+
# @param name [Symbol] Endpoint identifier
|
|
19
|
+
# @param url [String] Base URL for the endpoint
|
|
20
|
+
# @param method [Symbol] HTTP method (:get, :post, :put, :delete)
|
|
21
|
+
# @param auth [Hash] Authentication configuration
|
|
22
|
+
# @param cache [Hash] Cache configuration
|
|
23
|
+
# @param retry_config [Hash] Retry configuration
|
|
24
|
+
# @param timeout [Integer] Request timeout in seconds
|
|
25
|
+
# @param headers [Hash] Default headers
|
|
26
|
+
# @param rate_limit [Hash] Rate limiting configuration
|
|
27
|
+
#
|
|
28
|
+
# @example
|
|
29
|
+
# config.add_endpoint(:credit_bureau,
|
|
30
|
+
# url: "https://api.creditbureau.com/v1/score",
|
|
31
|
+
# method: :post,
|
|
32
|
+
# auth: { type: :api_key, header: "X-API-Key" },
|
|
33
|
+
# cache: { ttl: 3600, adapter: :redis },
|
|
34
|
+
# retry_config: { max_attempts: 3, backoff: :exponential }
|
|
35
|
+
# )
|
|
36
|
+
def add_endpoint(name, url:, method: :get, auth: nil, cache: nil, retry_config: nil, timeout: nil, headers: {}, rate_limit: nil) # rubocop:disable Metrics/ParameterLists
|
|
37
|
+
@endpoints[name.to_sym] = {
|
|
38
|
+
url: url,
|
|
39
|
+
method: method.to_sym,
|
|
40
|
+
auth: auth,
|
|
41
|
+
cache: cache || @default_cache.dup,
|
|
42
|
+
retry: retry_config || @default_retry.dup,
|
|
43
|
+
timeout: timeout || @default_timeout,
|
|
44
|
+
headers: headers,
|
|
45
|
+
rate_limit: rate_limit
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Get endpoint configuration
|
|
50
|
+
#
|
|
51
|
+
# @param name [Symbol] Endpoint identifier
|
|
52
|
+
# @return [Hash, nil] Endpoint configuration or nil if not found
|
|
53
|
+
def endpoint(name)
|
|
54
|
+
@endpoints[name.to_sym]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Check if endpoint exists
|
|
58
|
+
#
|
|
59
|
+
# @param name [Symbol] Endpoint identifier
|
|
60
|
+
# @return [Boolean]
|
|
61
|
+
def endpoint?(name)
|
|
62
|
+
@endpoints.key?(name.to_sym)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Remove endpoint configuration
|
|
66
|
+
#
|
|
67
|
+
# @param name [Symbol] Endpoint identifier
|
|
68
|
+
def remove_endpoint(name)
|
|
69
|
+
@endpoints.delete(name.to_sym)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Clear all endpoint configurations
|
|
73
|
+
def clear
|
|
74
|
+
@endpoints.clear
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DecisionAgent
|
|
4
|
+
module DataEnrichment
|
|
5
|
+
# Base error class for data enrichment
|
|
6
|
+
class Error < StandardError
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Error raised when endpoint is not configured
|
|
10
|
+
class EndpointNotFoundError < Error
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Error raised when request fails
|
|
14
|
+
class RequestError < Error
|
|
15
|
+
attr_reader :status_code, :response_body
|
|
16
|
+
|
|
17
|
+
def initialize(message, status_code: nil, response_body: nil)
|
|
18
|
+
super(message)
|
|
19
|
+
@status_code = status_code
|
|
20
|
+
@response_body = response_body
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Error raised when request times out
|
|
25
|
+
class TimeoutError < RequestError
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Error raised when network error occurs
|
|
29
|
+
class NetworkError < RequestError
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Error raised when circuit breaker is open
|
|
33
|
+
class CircuitOpenError < Error
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -14,16 +14,116 @@ module DecisionAgent
|
|
|
14
14
|
freeze
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
# Returns array of condition descriptions that led to this decision
|
|
18
|
+
# @param verbose [Boolean] If true, returns detailed condition information
|
|
19
|
+
# @return [Array<String>] Array of condition descriptions
|
|
20
|
+
def because(verbose: false)
|
|
21
|
+
all_explainability_results.flat_map { |er| er.because(verbose: verbose) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns array of condition descriptions that failed
|
|
25
|
+
# @param verbose [Boolean] If true, returns detailed condition information
|
|
26
|
+
# @return [Array<String>] Array of failed condition descriptions
|
|
27
|
+
def failed_conditions(verbose: false)
|
|
28
|
+
all_explainability_results.flat_map { |er| er.failed_conditions(verbose: verbose) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns explainability data in machine-readable format
|
|
32
|
+
# @param verbose [Boolean] If true, returns detailed explainability information
|
|
33
|
+
# @return [Hash] Explainability data
|
|
34
|
+
def explainability(verbose: false)
|
|
18
35
|
{
|
|
19
36
|
decision: @decision,
|
|
37
|
+
because: because(verbose: verbose),
|
|
38
|
+
failed_conditions: failed_conditions(verbose: verbose),
|
|
39
|
+
rule_traces: verbose ? all_explainability_results.map { |er| er.to_h(verbose: true) } : nil
|
|
40
|
+
}.compact
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def to_h
|
|
44
|
+
# Structure decision result as explainability by default
|
|
45
|
+
# This makes explainability the primary format for decision results
|
|
46
|
+
explainability_data = explainability(verbose: false)
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
# Explainability fields (primary structure)
|
|
50
|
+
decision: explainability_data[:decision],
|
|
51
|
+
because: explainability_data[:because],
|
|
52
|
+
failed_conditions: explainability_data[:failed_conditions],
|
|
53
|
+
# Additional metadata for completeness
|
|
20
54
|
confidence: @confidence,
|
|
21
55
|
explanations: @explanations,
|
|
22
56
|
evaluations: @evaluations.map(&:to_h),
|
|
23
|
-
audit_payload: @audit_payload
|
|
57
|
+
audit_payload: @audit_payload,
|
|
58
|
+
# Full explainability data (includes rule_traces in verbose mode)
|
|
59
|
+
explainability: explainability_data
|
|
24
60
|
}
|
|
25
61
|
end
|
|
26
62
|
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def all_explainability_results
|
|
66
|
+
@evaluations.flat_map { |evaluation| extract_explainability_from_evaluation(evaluation) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def extract_explainability_from_evaluation(evaluation)
|
|
70
|
+
return [] unless evaluation.metadata.is_a?(Hash)
|
|
71
|
+
return [] unless evaluation.metadata[:explainability]
|
|
72
|
+
|
|
73
|
+
explainability_data = normalize_hash_keys(evaluation.metadata[:explainability])
|
|
74
|
+
rule_traces = reconstruct_rule_traces(explainability_data)
|
|
75
|
+
evaluator_name = explainability_data[:evaluator_name] || evaluation.evaluator_name
|
|
76
|
+
|
|
77
|
+
[Explainability::ExplainabilityResult.new(
|
|
78
|
+
evaluator_name: evaluator_name,
|
|
79
|
+
rule_traces: rule_traces
|
|
80
|
+
)]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def normalize_hash_keys(data)
|
|
84
|
+
return data unless data.is_a?(Hash)
|
|
85
|
+
|
|
86
|
+
data.transform_keys(&:to_sym)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def reconstruct_rule_traces(explainability_data)
|
|
90
|
+
rule_traces_data = explainability_data[:rule_traces] || []
|
|
91
|
+
rule_traces_data.map { |rt_data| reconstruct_rule_trace(rt_data) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def reconstruct_rule_trace(rt_data)
|
|
95
|
+
normalized_rt = normalize_hash_keys(rt_data)
|
|
96
|
+
condition_traces = reconstruct_condition_traces(normalized_rt)
|
|
97
|
+
|
|
98
|
+
Explainability::RuleTrace.new(
|
|
99
|
+
rule_id: normalized_rt[:rule_id],
|
|
100
|
+
matched: normalized_rt[:matched],
|
|
101
|
+
condition_traces: condition_traces,
|
|
102
|
+
decision: normalized_rt[:decision],
|
|
103
|
+
weight: normalized_rt[:weight],
|
|
104
|
+
reason: normalized_rt[:reason]
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def reconstruct_condition_traces(rule_trace_data)
|
|
109
|
+
condition_traces_data = rule_trace_data[:condition_traces] || []
|
|
110
|
+
condition_traces_data.map { |ct_data| reconstruct_condition_trace(ct_data) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def reconstruct_condition_trace(ct_data)
|
|
114
|
+
normalized_ct = normalize_hash_keys(ct_data)
|
|
115
|
+
|
|
116
|
+
Explainability::ConditionTrace.new(
|
|
117
|
+
field: normalized_ct[:field],
|
|
118
|
+
operator: normalized_ct[:operator],
|
|
119
|
+
expected_value: normalized_ct[:expected_value],
|
|
120
|
+
actual_value: normalized_ct[:actual_value],
|
|
121
|
+
result: normalized_ct[:result]
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
public
|
|
126
|
+
|
|
27
127
|
def ==(other)
|
|
28
128
|
other.is_a?(Decision) &&
|
|
29
129
|
@decision == other.decision &&
|
|
@@ -253,15 +253,37 @@ module DecisionAgent
|
|
|
253
253
|
max_val = parse_value(range_match[3])
|
|
254
254
|
inclusive_end = range_match[4] == "]"
|
|
255
255
|
|
|
256
|
-
# For Phase 2A, we
|
|
257
|
-
# Map to 'between' operator
|
|
256
|
+
# For Phase 2A, we support fully inclusive ranges with 'between' operator
|
|
258
257
|
if inclusive_start && inclusive_end
|
|
259
258
|
{ operator: "between", value: [min_val, max_val] }
|
|
260
259
|
else
|
|
261
|
-
#
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
260
|
+
# For half-open ranges, convert to inclusive by adjusting bounds
|
|
261
|
+
# [min..max) becomes [min..max-1] (if max is integer) or use compound conditions
|
|
262
|
+
# For simplicity, we'll convert to inclusive ranges with adjusted bounds
|
|
263
|
+
# This is a pragmatic approach for Phase 2A
|
|
264
|
+
adjusted_min = if inclusive_start
|
|
265
|
+
min_val
|
|
266
|
+
elsif min_val.is_a?(Integer)
|
|
267
|
+
min_val + 1
|
|
268
|
+
else
|
|
269
|
+
min_val + 0.0001
|
|
270
|
+
end
|
|
271
|
+
adjusted_max = if inclusive_end
|
|
272
|
+
max_val
|
|
273
|
+
elsif max_val.is_a?(Integer)
|
|
274
|
+
max_val - 1
|
|
275
|
+
else
|
|
276
|
+
max_val - 0.0001
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Ensure adjusted range is valid
|
|
280
|
+
if adjusted_min <= adjusted_max
|
|
281
|
+
{ operator: "between", value: [adjusted_min, adjusted_max] }
|
|
282
|
+
else
|
|
283
|
+
# Invalid range, fall back to error
|
|
284
|
+
raise FeelParseError,
|
|
285
|
+
"Invalid half-open range: #{expr}. Range would be empty after adjustment."
|
|
286
|
+
end
|
|
265
287
|
end
|
|
266
288
|
end
|
|
267
289
|
|