decision_agent 0.1.7 → 0.3.0
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 +41 -1
- data/bin/decision_agent +104 -0
- data/lib/decision_agent/dmn/adapter.rb +135 -0
- data/lib/decision_agent/dmn/cache.rb +306 -0
- data/lib/decision_agent/dmn/decision_graph.rb +327 -0
- data/lib/decision_agent/dmn/decision_tree.rb +192 -0
- data/lib/decision_agent/dmn/errors.rb +30 -0
- data/lib/decision_agent/dmn/exporter.rb +217 -0
- data/lib/decision_agent/dmn/feel/evaluator.rb +797 -0
- data/lib/decision_agent/dmn/feel/functions.rb +420 -0
- data/lib/decision_agent/dmn/feel/parser.rb +349 -0
- data/lib/decision_agent/dmn/feel/simple_parser.rb +276 -0
- data/lib/decision_agent/dmn/feel/transformer.rb +372 -0
- data/lib/decision_agent/dmn/feel/types.rb +276 -0
- data/lib/decision_agent/dmn/importer.rb +77 -0
- data/lib/decision_agent/dmn/model.rb +197 -0
- data/lib/decision_agent/dmn/parser.rb +191 -0
- data/lib/decision_agent/dmn/testing.rb +333 -0
- data/lib/decision_agent/dmn/validator.rb +315 -0
- data/lib/decision_agent/dmn/versioning.rb +229 -0
- data/lib/decision_agent/dmn/visualizer.rb +513 -0
- data/lib/decision_agent/dsl/condition_evaluator.rb +1132 -12
- data/lib/decision_agent/dsl/schema_validator.rb +12 -1
- data/lib/decision_agent/evaluators/dmn_evaluator.rb +221 -0
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/web/dmn_editor.rb +426 -0
- data/lib/decision_agent/web/public/app.js +119 -1
- data/lib/decision_agent/web/public/dmn-editor.css +596 -0
- data/lib/decision_agent/web/public/dmn-editor.html +250 -0
- data/lib/decision_agent/web/public/dmn-editor.js +553 -0
- data/lib/decision_agent/web/public/index.html +71 -0
- data/lib/decision_agent/web/public/styles.css +21 -0
- data/lib/decision_agent/web/server.rb +465 -0
- data/spec/ab_testing/ab_testing_agent_spec.rb +174 -0
- data/spec/advanced_operators_spec.rb +2147 -0
- data/spec/auth/rbac_adapter_spec.rb +228 -0
- data/spec/dmn/decision_graph_spec.rb +282 -0
- data/spec/dmn/decision_tree_spec.rb +203 -0
- data/spec/dmn/feel/errors_spec.rb +18 -0
- data/spec/dmn/feel/functions_spec.rb +400 -0
- data/spec/dmn/feel/simple_parser_spec.rb +274 -0
- data/spec/dmn/feel/types_spec.rb +176 -0
- data/spec/dmn/feel_parser_spec.rb +489 -0
- data/spec/dmn/hit_policy_spec.rb +202 -0
- data/spec/dmn/integration_spec.rb +226 -0
- data/spec/examples.txt +1909 -0
- data/spec/fixtures/dmn/complex_decision.dmn +81 -0
- data/spec/fixtures/dmn/invalid_structure.dmn +31 -0
- data/spec/fixtures/dmn/simple_decision.dmn +40 -0
- data/spec/monitoring/metrics_collector_spec.rb +37 -35
- data/spec/monitoring/monitored_agent_spec.rb +14 -11
- data/spec/performance_optimizations_spec.rb +10 -3
- data/spec/thread_safety_spec.rb +10 -2
- data/spec/web_ui_rack_spec.rb +294 -0
- metadata +66 -1
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
require_relative "../errors"
|
|
2
|
+
require_relative "types"
|
|
3
|
+
|
|
4
|
+
module DecisionAgent
|
|
5
|
+
module Dmn
|
|
6
|
+
module Feel
|
|
7
|
+
# Built-in FEEL functions registry
|
|
8
|
+
# Functions either map to ConditionEvaluator operators or provide custom evaluation
|
|
9
|
+
module Functions
|
|
10
|
+
# Function registry
|
|
11
|
+
# rubocop:disable Style/MutableConstant
|
|
12
|
+
REGISTRY = {}
|
|
13
|
+
# rubocop:enable Style/MutableConstant
|
|
14
|
+
|
|
15
|
+
# Base class for all functions
|
|
16
|
+
class Base
|
|
17
|
+
class << self
|
|
18
|
+
# Register function with one or more names
|
|
19
|
+
def register(*names)
|
|
20
|
+
names.each { |name| REGISTRY[name.to_s] = self }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Execute function with arguments and context
|
|
24
|
+
# Returns either a value or a ConditionEvaluator condition structure
|
|
25
|
+
def call(args, context = {})
|
|
26
|
+
raise NotImplementedError, "Subclasses must implement call"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Validate argument count
|
|
30
|
+
def validate_arg_count(args, expected)
|
|
31
|
+
actual = args.length
|
|
32
|
+
valid = if expected.is_a?(Range)
|
|
33
|
+
expected.cover?(actual)
|
|
34
|
+
else
|
|
35
|
+
actual == expected
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
return if valid
|
|
39
|
+
|
|
40
|
+
raise FeelFunctionError, "Wrong number of arguments for #{name} (got #{actual}, expected #{expected})"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
#
|
|
46
|
+
# STRING FUNCTIONS
|
|
47
|
+
#
|
|
48
|
+
|
|
49
|
+
class Substring < Base
|
|
50
|
+
register "substring", "substr"
|
|
51
|
+
|
|
52
|
+
def self.call(args, _context = {})
|
|
53
|
+
validate_arg_count(args, 2..3)
|
|
54
|
+
str = args[0].to_s
|
|
55
|
+
start_pos = args[1].to_i - 1 # FEEL is 1-indexed
|
|
56
|
+
length = args[2]&.to_i
|
|
57
|
+
|
|
58
|
+
return str[start_pos..] if length.nil?
|
|
59
|
+
|
|
60
|
+
str[start_pos, length] || ""
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class StringLength < Base
|
|
65
|
+
register "string length", "length"
|
|
66
|
+
|
|
67
|
+
def self.call(args, _context = {})
|
|
68
|
+
validate_arg_count(args, 1)
|
|
69
|
+
args[0].to_s.length
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class UpperCase < Base
|
|
74
|
+
register "upper case", "upper"
|
|
75
|
+
|
|
76
|
+
def self.call(args, _context = {})
|
|
77
|
+
validate_arg_count(args, 1)
|
|
78
|
+
args[0].to_s.upcase
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class LowerCase < Base
|
|
83
|
+
register "lower case", "lower"
|
|
84
|
+
|
|
85
|
+
def self.call(args, _context = {})
|
|
86
|
+
validate_arg_count(args, 1)
|
|
87
|
+
args[0].to_s.downcase
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class Contains < Base
|
|
92
|
+
register "contains"
|
|
93
|
+
|
|
94
|
+
def self.call(args, _context = {})
|
|
95
|
+
validate_arg_count(args, 2)
|
|
96
|
+
args[0].to_s.include?(args[1].to_s)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
class StartsWith < Base
|
|
101
|
+
register "starts with"
|
|
102
|
+
|
|
103
|
+
def self.call(args, _context = {})
|
|
104
|
+
validate_arg_count(args, 2)
|
|
105
|
+
args[0].to_s.start_with?(args[1].to_s)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
class EndsWith < Base
|
|
110
|
+
register "ends with"
|
|
111
|
+
|
|
112
|
+
def self.call(args, _context = {})
|
|
113
|
+
validate_arg_count(args, 2)
|
|
114
|
+
args[0].to_s.end_with?(args[1].to_s)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
class SubstringBefore < Base
|
|
119
|
+
register "substring before"
|
|
120
|
+
|
|
121
|
+
def self.call(args, _context = {})
|
|
122
|
+
validate_arg_count(args, 2)
|
|
123
|
+
str = args[0].to_s
|
|
124
|
+
match = args[1].to_s
|
|
125
|
+
idx = str.index(match)
|
|
126
|
+
idx ? str[0...idx] : ""
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
class SubstringAfter < Base
|
|
131
|
+
register "substring after"
|
|
132
|
+
|
|
133
|
+
def self.call(args, _context = {})
|
|
134
|
+
validate_arg_count(args, 2)
|
|
135
|
+
str = args[0].to_s
|
|
136
|
+
match = args[1].to_s
|
|
137
|
+
idx = str.index(match)
|
|
138
|
+
idx ? str[(idx + match.length)..] : ""
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
class Replace < Base
|
|
143
|
+
register "replace"
|
|
144
|
+
|
|
145
|
+
def self.call(args, _context = {})
|
|
146
|
+
validate_arg_count(args, 3)
|
|
147
|
+
args[0].to_s.gsub(args[1].to_s, args[2].to_s)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
#
|
|
152
|
+
# NUMERIC FUNCTIONS
|
|
153
|
+
#
|
|
154
|
+
|
|
155
|
+
class Abs < Base
|
|
156
|
+
register "abs", "absolute"
|
|
157
|
+
|
|
158
|
+
def self.call(args, _context = {})
|
|
159
|
+
validate_arg_count(args, 1)
|
|
160
|
+
args[0].to_f.abs
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
class Floor < Base
|
|
165
|
+
register "floor"
|
|
166
|
+
|
|
167
|
+
def self.call(args, _context = {})
|
|
168
|
+
validate_arg_count(args, 1)
|
|
169
|
+
args[0].to_f.floor
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
class Ceiling < Base
|
|
174
|
+
register "ceiling", "ceil"
|
|
175
|
+
|
|
176
|
+
def self.call(args, _context = {})
|
|
177
|
+
validate_arg_count(args, 1)
|
|
178
|
+
args[0].to_f.ceil
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
class Round < Base
|
|
183
|
+
register "round"
|
|
184
|
+
|
|
185
|
+
def self.call(args, _context = {})
|
|
186
|
+
validate_arg_count(args, 1..2)
|
|
187
|
+
value = args[0].to_f
|
|
188
|
+
precision = args[1].to_i
|
|
189
|
+
value.round(precision)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
class Sqrt < Base
|
|
194
|
+
register "sqrt", "square root"
|
|
195
|
+
|
|
196
|
+
def self.call(args, _context = {})
|
|
197
|
+
validate_arg_count(args, 1)
|
|
198
|
+
Math.sqrt(args[0].to_f)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
class Modulo < Base
|
|
203
|
+
register "modulo", "mod"
|
|
204
|
+
|
|
205
|
+
def self.call(args, _context = {})
|
|
206
|
+
validate_arg_count(args, 2)
|
|
207
|
+
args[0].to_f % args[1].to_f
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
class Odd < Base
|
|
212
|
+
register "odd"
|
|
213
|
+
|
|
214
|
+
def self.call(args, _context = {})
|
|
215
|
+
validate_arg_count(args, 1)
|
|
216
|
+
args[0].to_i.odd?
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
class Even < Base
|
|
221
|
+
register "even"
|
|
222
|
+
|
|
223
|
+
def self.call(args, _context = {})
|
|
224
|
+
validate_arg_count(args, 1)
|
|
225
|
+
args[0].to_i.even?
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
#
|
|
230
|
+
# LIST FUNCTIONS
|
|
231
|
+
#
|
|
232
|
+
|
|
233
|
+
class Count < Base
|
|
234
|
+
register "count"
|
|
235
|
+
|
|
236
|
+
def self.call(args, _context = {})
|
|
237
|
+
validate_arg_count(args, 1)
|
|
238
|
+
list = args[0]
|
|
239
|
+
return 0 unless list.is_a?(Array) || list.is_a?(Types::List)
|
|
240
|
+
|
|
241
|
+
list.length
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
class Sum < Base
|
|
246
|
+
register "sum"
|
|
247
|
+
|
|
248
|
+
def self.call(args, _context = {})
|
|
249
|
+
validate_arg_count(args, 1)
|
|
250
|
+
list = args[0]
|
|
251
|
+
return 0 unless list.is_a?(Array) || list.is_a?(Types::List)
|
|
252
|
+
|
|
253
|
+
list.map(&:to_f).sum
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
class Mean < Base
|
|
258
|
+
register "mean", "average"
|
|
259
|
+
|
|
260
|
+
def self.call(args, _context = {})
|
|
261
|
+
validate_arg_count(args, 1)
|
|
262
|
+
list = args[0]
|
|
263
|
+
return 0 unless list.is_a?(Array) || list.is_a?(Types::List)
|
|
264
|
+
return 0 if list.empty?
|
|
265
|
+
|
|
266
|
+
list.map(&:to_f).sum / list.length.to_f
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
class Min < Base
|
|
271
|
+
register "min", "minimum"
|
|
272
|
+
|
|
273
|
+
def self.call(args, _context = {})
|
|
274
|
+
# Can accept multiple args or a single list
|
|
275
|
+
values = args.length == 1 && (args[0].is_a?(Array) || args[0].is_a?(Types::List)) ? args[0] : args
|
|
276
|
+
return nil if values.empty?
|
|
277
|
+
|
|
278
|
+
values.map(&:to_f).min
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
class Max < Base
|
|
283
|
+
register "max", "maximum"
|
|
284
|
+
|
|
285
|
+
def self.call(args, _context = {})
|
|
286
|
+
# Can accept multiple args or a single list
|
|
287
|
+
values = args.length == 1 && (args[0].is_a?(Array) || args[0].is_a?(Types::List)) ? args[0] : args
|
|
288
|
+
return nil if values.empty?
|
|
289
|
+
|
|
290
|
+
values.map(&:to_f).max
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
class Append < Base
|
|
295
|
+
register "append"
|
|
296
|
+
|
|
297
|
+
def self.call(args, _context = {})
|
|
298
|
+
validate_arg_count(args, 2..)
|
|
299
|
+
list = Array(args[0])
|
|
300
|
+
items = args[1..]
|
|
301
|
+
list + items
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
class Reverse < Base
|
|
306
|
+
register "reverse"
|
|
307
|
+
|
|
308
|
+
def self.call(args, _context = {})
|
|
309
|
+
validate_arg_count(args, 1)
|
|
310
|
+
Array(args[0]).reverse
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
class IndexOf < Base
|
|
315
|
+
register "index of"
|
|
316
|
+
|
|
317
|
+
def self.call(args, _context = {})
|
|
318
|
+
validate_arg_count(args, 2)
|
|
319
|
+
list = Array(args[0])
|
|
320
|
+
match = args[1]
|
|
321
|
+
# FEEL is 1-indexed, Ruby is 0-indexed
|
|
322
|
+
idx = list.index(match)
|
|
323
|
+
idx ? idx + 1 : -1
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
class DistinctValues < Base
|
|
328
|
+
register "distinct values", "unique"
|
|
329
|
+
|
|
330
|
+
def self.call(args, _context = {})
|
|
331
|
+
validate_arg_count(args, 1)
|
|
332
|
+
Array(args[0]).uniq
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
#
|
|
337
|
+
# BOOLEAN FUNCTIONS
|
|
338
|
+
#
|
|
339
|
+
|
|
340
|
+
class Not < Base
|
|
341
|
+
register "not"
|
|
342
|
+
|
|
343
|
+
def self.call(args, _context = {})
|
|
344
|
+
validate_arg_count(args, 1)
|
|
345
|
+
!args[0]
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
class All < Base
|
|
350
|
+
register "all"
|
|
351
|
+
|
|
352
|
+
def self.call(args, _context = {})
|
|
353
|
+
validate_arg_count(args, 1)
|
|
354
|
+
list = Array(args[0])
|
|
355
|
+
list.all? { |item| [true, "true"].include?(item) }
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
class Any < Base
|
|
360
|
+
register "any"
|
|
361
|
+
|
|
362
|
+
def self.call(args, _context = {})
|
|
363
|
+
validate_arg_count(args, 1)
|
|
364
|
+
list = Array(args[0])
|
|
365
|
+
list.any? { |item| [true, "true"].include?(item) }
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
#
|
|
370
|
+
# DATE/TIME FUNCTIONS (Basic implementations)
|
|
371
|
+
#
|
|
372
|
+
|
|
373
|
+
class DateFunction < Base
|
|
374
|
+
register "date"
|
|
375
|
+
|
|
376
|
+
def self.call(args, _context = {})
|
|
377
|
+
validate_arg_count(args, 1)
|
|
378
|
+
Types::Date.new(args[0])
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
class TimeFunction < Base
|
|
383
|
+
register "time"
|
|
384
|
+
|
|
385
|
+
def self.call(args, _context = {})
|
|
386
|
+
validate_arg_count(args, 1)
|
|
387
|
+
Types::Time.new(args[0])
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
class DurationFunction < Base
|
|
392
|
+
register "duration"
|
|
393
|
+
|
|
394
|
+
def self.call(args, _context = {})
|
|
395
|
+
validate_arg_count(args, 1)
|
|
396
|
+
Types::Duration.parse(args[0])
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Lookup function by name
|
|
401
|
+
def self.get(name)
|
|
402
|
+
REGISTRY[name.to_s]
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Execute a function
|
|
406
|
+
def self.execute(name, args, context = {})
|
|
407
|
+
func = get(name)
|
|
408
|
+
raise FeelFunctionError, "Unknown function: #{name}" unless func
|
|
409
|
+
|
|
410
|
+
func.call(args, context)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# List all registered functions
|
|
414
|
+
def self.list
|
|
415
|
+
REGISTRY.keys.sort
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
end
|