stellwerk-ruby 0.1.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 +7 -0
- data/CHANGELOG.md +27 -0
- data/LICENSE.txt +21 -0
- data/README.md +164 -0
- data/lib/stellwerk/errors.rb +18 -0
- data/lib/stellwerk/evaluator.rb +748 -0
- data/lib/stellwerk/flow.rb +88 -0
- data/lib/stellwerk/functions.rb +318 -0
- data/lib/stellwerk/result.rb +35 -0
- data/lib/stellwerk/version.rb +5 -0
- data/lib/stellwerk.rb +60 -0
- metadata +115 -0
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "dentaku"
|
|
5
|
+
require "yaml"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module Stellwerk
|
|
9
|
+
# Evaluates flows using pre-compiled JSON representation for maximum performance.
|
|
10
|
+
# Avoids all database queries by working directly with the compiled JSON structure.
|
|
11
|
+
#
|
|
12
|
+
# The compiled JSON format:
|
|
13
|
+
# {
|
|
14
|
+
# "version": "1.0",
|
|
15
|
+
# "compiled_at": "...",
|
|
16
|
+
# "entry_node_ids": [...],
|
|
17
|
+
# "nodes": { "node_id": { id, name, type, config, ... } },
|
|
18
|
+
# "adjacency": { "node_id": [{ "to": "...", "branch": "..." }] },
|
|
19
|
+
# "reverse_adjacency": { "node_id": ["parent_id", ...] },
|
|
20
|
+
# "sub_flows": { "flow_id": { ... embedded sub-flow ... } }
|
|
21
|
+
# }
|
|
22
|
+
class Evaluator
|
|
23
|
+
# --------------------------------------------------
|
|
24
|
+
# Public API
|
|
25
|
+
# --------------------------------------------------
|
|
26
|
+
def self.call(compiled_json:, params:, sub_flows: {}, metadata: {})
|
|
27
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
28
|
+
result = new(compiled_json: compiled_json, params: params, sub_flows: sub_flows, metadata: metadata).evaluate
|
|
29
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
|
|
30
|
+
|
|
31
|
+
# Track metrics for observability if Metrics is available
|
|
32
|
+
if defined?(Metrics)
|
|
33
|
+
status = result.errors.any? ? "error" : "success"
|
|
34
|
+
Metrics.increment("stellwerk.flow.executions", tags: { status: status })
|
|
35
|
+
Metrics.timing("stellwerk.flow.execution_time", duration_ms)
|
|
36
|
+
Metrics.histogram("stellwerk.flow.nodes_evaluated", result.applied_nodes.size)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
result
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# --------------------------------------------------
|
|
43
|
+
# Implementation
|
|
44
|
+
# --------------------------------------------------
|
|
45
|
+
def initialize(compiled_json:, params:, sub_flows: {}, metadata: {})
|
|
46
|
+
@compiled = deep_symbolize_keys(compiled_json)
|
|
47
|
+
@nodes = (@compiled[:nodes] || {}).transform_keys(&:to_s)
|
|
48
|
+
@adjacency = @compiled[:adjacency] || {}
|
|
49
|
+
@reverse_adjacency = @compiled[:reverse_adjacency] || {}
|
|
50
|
+
@entry_node_ids = @compiled[:entry_node_ids] || []
|
|
51
|
+
@embedded_sub_flows = @compiled[:sub_flows] || {}
|
|
52
|
+
@external_sub_flows = sub_flows
|
|
53
|
+
|
|
54
|
+
@context = seed_initial_context(metadata, params)
|
|
55
|
+
@calculator = Dentaku::Calculator.new
|
|
56
|
+
register_collection_functions!
|
|
57
|
+
@applied_nodes = []
|
|
58
|
+
@errors = []
|
|
59
|
+
@merge_buffers = Hash.new { |h, k| h[k] = [] }
|
|
60
|
+
@outputs = nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def evaluate
|
|
64
|
+
return build_result if @nodes.empty?
|
|
65
|
+
|
|
66
|
+
if @entry_node_ids.empty?
|
|
67
|
+
@errors << "No start nodes found in flow"
|
|
68
|
+
return build_result
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
evaluate_graph
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def deep_symbolize_keys(obj)
|
|
77
|
+
case obj
|
|
78
|
+
when Hash
|
|
79
|
+
obj.each_with_object({}) do |(key, value), result|
|
|
80
|
+
result[key.to_sym] = deep_symbolize_keys(value)
|
|
81
|
+
end
|
|
82
|
+
when Array
|
|
83
|
+
obj.map { |item| deep_symbolize_keys(item) }
|
|
84
|
+
else
|
|
85
|
+
obj
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def seed_initial_context(metadata, params)
|
|
90
|
+
ctx = {}
|
|
91
|
+
|
|
92
|
+
# Flow metadata (medium priority)
|
|
93
|
+
if metadata && !metadata.empty?
|
|
94
|
+
evaluation_metadata = metadata.reject { |k, _| k.to_s == "sticky_notes" }
|
|
95
|
+
ctx.merge!(deep_symbolize_keys(evaluation_metadata))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Runtime params (highest priority)
|
|
99
|
+
ctx.merge!(deep_symbolize_keys(params.to_h))
|
|
100
|
+
|
|
101
|
+
ctx
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def evaluate_graph
|
|
105
|
+
# Use pre-computed adjacency lists
|
|
106
|
+
children_by = @adjacency.transform_keys(&:to_s)
|
|
107
|
+
parents_by = @reverse_adjacency.transform_keys(&:to_s)
|
|
108
|
+
|
|
109
|
+
# Token-based traversal
|
|
110
|
+
token_struct = Struct.new(:node_id, :context, :branch_taken)
|
|
111
|
+
queue = []
|
|
112
|
+
|
|
113
|
+
# Seed queue with entry nodes
|
|
114
|
+
@entry_node_ids.each do |node_id|
|
|
115
|
+
queue << token_struct.new(node_id, @context.dup, nil)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
visited = Set.new
|
|
119
|
+
final_context = @context.dup
|
|
120
|
+
|
|
121
|
+
while (token = queue.shift)
|
|
122
|
+
node = @nodes[token.node_id]
|
|
123
|
+
next unless node
|
|
124
|
+
|
|
125
|
+
node_type = node[:type] || node["type"]
|
|
126
|
+
node_id = node[:id] || node["id"]
|
|
127
|
+
|
|
128
|
+
# Handle merge nodes specially
|
|
129
|
+
if node_type == "merge"
|
|
130
|
+
@merge_buffers[node_id] << { context: token.context, branch: token.branch_taken }
|
|
131
|
+
|
|
132
|
+
expected_count = (parents_by[node_id] || []).size
|
|
133
|
+
if @merge_buffers[node_id].size >= expected_count
|
|
134
|
+
result = execute_merge_node_with_tokens(node, @merge_buffers[node_id])
|
|
135
|
+
@merge_buffers.delete(node_id)
|
|
136
|
+
|
|
137
|
+
if result[:error]
|
|
138
|
+
@errors << { node_id: node_id, error: result[:error] }
|
|
139
|
+
next
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
@applied_nodes << { id: node_id, name: node[:name] || node["name"], type: node_type }
|
|
143
|
+
visited << node_id
|
|
144
|
+
|
|
145
|
+
updated_context = result[:context]
|
|
146
|
+
final_context.merge!(updated_context)
|
|
147
|
+
|
|
148
|
+
(children_by[node_id] || []).each do |child|
|
|
149
|
+
child_data = child.is_a?(Hash) ? child : deep_symbolize_keys(child)
|
|
150
|
+
next if (child_data[:branch] || child_data["branch"]).to_s != ""
|
|
151
|
+
queue << token_struct.new(child_data[:to] || child_data["to"], updated_context.dup, nil)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
next
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Execute node
|
|
158
|
+
result = execute_node(node, token.context)
|
|
159
|
+
|
|
160
|
+
next if result[:skip]
|
|
161
|
+
|
|
162
|
+
if result[:error]
|
|
163
|
+
@errors << { node_id: node_id, error: result[:error] }
|
|
164
|
+
next
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
@applied_nodes << { id: node_id, name: node[:name] || node["name"], type: node_type }
|
|
168
|
+
visited << node_id
|
|
169
|
+
|
|
170
|
+
updated_context = result[:context] || token.context
|
|
171
|
+
final_context.merge!(updated_context)
|
|
172
|
+
|
|
173
|
+
# Handle branching
|
|
174
|
+
if result[:branch]
|
|
175
|
+
children = (children_by[node_id] || []).select do |c|
|
|
176
|
+
c_data = c.is_a?(Hash) ? c : deep_symbolize_keys(c)
|
|
177
|
+
(c_data[:branch] || c_data["branch"]) == result[:branch]
|
|
178
|
+
end
|
|
179
|
+
children.each do |child|
|
|
180
|
+
child_data = child.is_a?(Hash) ? child : deep_symbolize_keys(child)
|
|
181
|
+
queue << token_struct.new(child_data[:to] || child_data["to"], updated_context.dup, result[:branch])
|
|
182
|
+
end
|
|
183
|
+
else
|
|
184
|
+
(children_by[node_id] || []).each do |child|
|
|
185
|
+
child_data = child.is_a?(Hash) ? child : deep_symbolize_keys(child)
|
|
186
|
+
next if (child_data[:branch] || child_data["branch"]).to_s != ""
|
|
187
|
+
queue << token_struct.new(child_data[:to] || child_data["to"], updated_context.dup, nil)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
@context = final_context
|
|
193
|
+
build_result
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def execute_node(node, context)
|
|
197
|
+
node_type = node[:type] || node["type"]
|
|
198
|
+
|
|
199
|
+
case node_type
|
|
200
|
+
when "start" then execute_start_node(node, context)
|
|
201
|
+
when "calculate" then execute_calculate_node(node, context)
|
|
202
|
+
when "condition" then execute_condition_node(node, context)
|
|
203
|
+
when "merge" then execute_merge_node(node, context)
|
|
204
|
+
when "end" then execute_end_node(node, context)
|
|
205
|
+
when "map" then execute_map_node(node, context)
|
|
206
|
+
when "switch" then execute_switch_node(node, context)
|
|
207
|
+
else
|
|
208
|
+
{ error: "Unknown node type: #{node_type}" }
|
|
209
|
+
end
|
|
210
|
+
rescue StandardError => e
|
|
211
|
+
Stellwerk.logger.error("Error executing compiled node #{node[:id]}: #{e.message}")
|
|
212
|
+
{ error: e.message }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# --------------------------------------------------
|
|
216
|
+
# Node Executors
|
|
217
|
+
# --------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def execute_start_node(node, context)
|
|
220
|
+
input_schema = node[:inputs] || node["inputs"] || node.dig(:config, "inputs") || []
|
|
221
|
+
|
|
222
|
+
updated_context = context.dup
|
|
223
|
+
|
|
224
|
+
input_schema.each do |input_def|
|
|
225
|
+
input_def = input_def.transform_keys(&:to_s) if input_def.is_a?(Hash)
|
|
226
|
+
name = input_def["name"]
|
|
227
|
+
sym_name = name.to_sym
|
|
228
|
+
|
|
229
|
+
if !updated_context.key?(sym_name) && input_def.key?("default")
|
|
230
|
+
updated_context[sym_name] = input_def["default"]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
if input_def["required"] && !updated_context.key?(sym_name)
|
|
234
|
+
return { error: "Required input missing: #{name}" }
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
{ context: updated_context }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def execute_calculate_node(node, context)
|
|
242
|
+
formula = node[:formula] || node["formula"] || node.dig(:config, "formula")
|
|
243
|
+
return { context: context } if formula.nil? || formula.to_s.strip.empty?
|
|
244
|
+
|
|
245
|
+
processed_formula = preprocess_collections(normalize_expression(formula))
|
|
246
|
+
flat_context = flatten_context(context)
|
|
247
|
+
|
|
248
|
+
begin
|
|
249
|
+
result_context = apply_formula(processed_formula, flat_context)
|
|
250
|
+
|
|
251
|
+
outputs = {}
|
|
252
|
+
declared = node[:declared_outputs] || node["declared_outputs"] || []
|
|
253
|
+
declared.each do |var_name|
|
|
254
|
+
outputs[var_name.to_sym] = result_context[var_name.to_sym] if result_context.key?(var_name.to_sym)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
{ context: context.merge(outputs) }
|
|
258
|
+
rescue StandardError => e
|
|
259
|
+
Stellwerk.logger.warn("Calculate node formula error: #{e.message}")
|
|
260
|
+
{ error: e.message }
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def execute_condition_node(node, context)
|
|
265
|
+
condition = node[:condition] || node["condition"] || node.dig(:config, "condition")
|
|
266
|
+
return { skip: true } if condition.nil? || condition.to_s.strip.empty?
|
|
267
|
+
|
|
268
|
+
processed_condition = preprocess_collections(normalize_expression(condition))
|
|
269
|
+
flat_context = flatten_context(context)
|
|
270
|
+
|
|
271
|
+
begin
|
|
272
|
+
result = @calculator.evaluate!(processed_condition, flat_context)
|
|
273
|
+
branch = result ? "true" : "false"
|
|
274
|
+
{ context: context, branch: branch }
|
|
275
|
+
rescue StandardError => e
|
|
276
|
+
Stellwerk.logger.warn("Condition node error: #{e.message}")
|
|
277
|
+
{ error: e.message }
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def execute_switch_node(node, context)
|
|
282
|
+
cases = node[:cases] || node["cases"] || node.dig(:config, "cases") || []
|
|
283
|
+
|
|
284
|
+
flat_context = flatten_context(context)
|
|
285
|
+
match_id = nil
|
|
286
|
+
|
|
287
|
+
cases.each do |case_def|
|
|
288
|
+
case_def = case_def.transform_keys(&:to_s) if case_def.is_a?(Hash)
|
|
289
|
+
condition = case_def["condition"]
|
|
290
|
+
next if condition.nil? || condition.to_s.strip.empty?
|
|
291
|
+
|
|
292
|
+
begin
|
|
293
|
+
processed_condition = preprocess_collections(normalize_expression(condition))
|
|
294
|
+
is_match = @calculator.evaluate!(processed_condition, flat_context)
|
|
295
|
+
|
|
296
|
+
if is_match
|
|
297
|
+
match_id = case_def["id"]
|
|
298
|
+
break
|
|
299
|
+
end
|
|
300
|
+
rescue Dentaku::UnboundVariableError => e
|
|
301
|
+
return { error: "Switch case '#{case_def['label']}' error: #{e.message}" }
|
|
302
|
+
rescue StandardError => e
|
|
303
|
+
Stellwerk.logger.warn("Switch node case error: #{e.message}")
|
|
304
|
+
return { error: "Switch case '#{case_def['label']}' error: #{e.message}" }
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
branch = match_id || "default"
|
|
309
|
+
{ context: context, branch: branch }
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def execute_merge_node(node, context)
|
|
313
|
+
{ context: context }
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def execute_merge_node_with_tokens(node, tokens)
|
|
317
|
+
strategy = node[:strategy] || node["strategy"] || node.dig(:config, "strategy") || "wait_for_all"
|
|
318
|
+
|
|
319
|
+
case strategy
|
|
320
|
+
when "wait_for_all", "merge_all"
|
|
321
|
+
merged_context = {}
|
|
322
|
+
tokens.each { |t| merged_context.merge!(t[:context]) }
|
|
323
|
+
{ context: merged_context }
|
|
324
|
+
|
|
325
|
+
when "first_arrival"
|
|
326
|
+
{ context: tokens.first[:context] }
|
|
327
|
+
|
|
328
|
+
when "pick_highest", "pick_lowest"
|
|
329
|
+
selection_expr = node[:join_selection_expr] || node["join_selection_expr"] || node.dig(:config, "join_selection_expr")
|
|
330
|
+
|
|
331
|
+
if selection_expr.nil? || selection_expr.to_s.strip.empty?
|
|
332
|
+
return { error: "Merge strategy '#{strategy}' requires join_selection_expr" }
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
scored_tokens = tokens.map do |token_data|
|
|
336
|
+
begin
|
|
337
|
+
processed_expr = preprocess_collections(normalize_expression(selection_expr))
|
|
338
|
+
flat_context = flatten_context(token_data[:context])
|
|
339
|
+
score = @calculator.evaluate!(processed_expr, flat_context)
|
|
340
|
+
{ context: token_data[:context], score: score }
|
|
341
|
+
rescue StandardError => e
|
|
342
|
+
Stellwerk.logger.warn("Merge selection error: #{e.message}")
|
|
343
|
+
{ context: token_data[:context], score: nil }
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
valid_tokens = scored_tokens.reject { |t| t[:score].nil? }
|
|
348
|
+
|
|
349
|
+
if valid_tokens.empty?
|
|
350
|
+
return { error: "No valid scores for merge selection" }
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
winner = strategy == "pick_highest" ? valid_tokens.max_by { |t| t[:score] } : valid_tokens.min_by { |t| t[:score] }
|
|
354
|
+
{ context: winner[:context] }
|
|
355
|
+
|
|
356
|
+
else
|
|
357
|
+
merged_context = {}
|
|
358
|
+
tokens.each { |t| merged_context.merge!(t[:context]) }
|
|
359
|
+
{ context: merged_context }
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def execute_end_node(node, context)
|
|
364
|
+
output_template = node[:output_template] || node["output_template"] || node.dig(:config, "output_template")
|
|
365
|
+
yaml_template = node[:yaml_template] || node["yaml_template"] || node.dig(:config, "yaml_template")
|
|
366
|
+
output_mapping = node[:output_mapping] || node["output_mapping"] || node.dig(:config, "output_mapping") || []
|
|
367
|
+
|
|
368
|
+
if output_template && !output_template.to_s.strip.empty?
|
|
369
|
+
@outputs = process_json_template(output_template, context)
|
|
370
|
+
elsif yaml_template && !yaml_template.to_s.strip.empty?
|
|
371
|
+
@outputs = process_yaml_template(yaml_template, context)
|
|
372
|
+
else
|
|
373
|
+
@outputs = {}
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
outputs_empty = @outputs.nil? || (@outputs.is_a?(Hash) && @outputs.empty?)
|
|
377
|
+
if outputs_empty && !output_mapping.empty?
|
|
378
|
+
mapped_outputs = {}
|
|
379
|
+
output_mapping.each do |mapping|
|
|
380
|
+
mapping = mapping.transform_keys(&:to_s) if mapping.is_a?(Hash)
|
|
381
|
+
source = mapping["from"] || mapping["name"]
|
|
382
|
+
target = mapping["name"]
|
|
383
|
+
mapped_outputs[target.to_sym] = context[source.to_sym] if context.key?(source.to_sym)
|
|
384
|
+
end
|
|
385
|
+
@outputs = mapped_outputs
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
{ context: context }
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def execute_map_node(node, context)
|
|
392
|
+
source_var = node[:source_variable] || node["source_variable"] || node.dig(:config, "source_variable")
|
|
393
|
+
item_alias = node[:item_alias] || node["item_alias"] || node.dig(:config, "item_alias") || "item"
|
|
394
|
+
index_alias = node[:index_alias] || node["index_alias"] || node.dig(:config, "index_alias")
|
|
395
|
+
output_var = node[:output_variable] || node["output_variable"] || node.dig(:config, "output_variable") || "results"
|
|
396
|
+
sub_flow_id = node[:sub_flow_id] || node["sub_flow_id"] || node.dig(:config, "sub_flow_id")
|
|
397
|
+
enrich_source = node[:enrich_source_array] || node["enrich_source_array"] || node.dig(:config, "enrich_source_array") == true
|
|
398
|
+
|
|
399
|
+
source_array = context[source_var.to_sym]
|
|
400
|
+
unless source_array.is_a?(Array)
|
|
401
|
+
return { error: "MapNode source '#{source_var}' is not an array" }
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
if sub_flow_id && sub_flow_id.to_s.strip != ""
|
|
405
|
+
# Look up sub-flow from embedded sub_flows or external sub_flows
|
|
406
|
+
sub_flow_json = @embedded_sub_flows[sub_flow_id.to_s] ||
|
|
407
|
+
@embedded_sub_flows[sub_flow_id.to_sym] ||
|
|
408
|
+
@external_sub_flows[sub_flow_id.to_s] ||
|
|
409
|
+
@external_sub_flows[sub_flow_id.to_sym]
|
|
410
|
+
|
|
411
|
+
unless sub_flow_json
|
|
412
|
+
return { error: "MapNode referenced sub-flow #{sub_flow_id} not found. Embed sub-flows in compiled JSON or pass via sub_flows parameter." }
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
results = []
|
|
416
|
+
source_array.each_with_index do |item, index|
|
|
417
|
+
item_context = item.is_a?(Hash) ? deep_symbolize_keys(item) : { item_alias.to_sym => item }
|
|
418
|
+
item_context[index_alias.to_sym] = index if index_alias && index_alias.to_s.strip != ""
|
|
419
|
+
|
|
420
|
+
# Recursively evaluate sub-flow
|
|
421
|
+
subflow_result = Evaluator.call(
|
|
422
|
+
compiled_json: sub_flow_json,
|
|
423
|
+
params: item_context,
|
|
424
|
+
sub_flows: @external_sub_flows.merge(@embedded_sub_flows)
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
if subflow_result.errors.any?
|
|
428
|
+
results << { error: subflow_result.errors.map { |e| e.is_a?(Hash) ? e[:error] : e.to_s }.join("; ") }
|
|
429
|
+
else
|
|
430
|
+
if enrich_source
|
|
431
|
+
results << deep_merge_hashes(item, subflow_result.outputs)
|
|
432
|
+
else
|
|
433
|
+
results << subflow_result.outputs
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
output_context = context.dup
|
|
439
|
+
output_context[output_var.to_sym] = results
|
|
440
|
+
{ context: output_context }
|
|
441
|
+
else
|
|
442
|
+
{ error: "MapNode requires a sub-flow" }
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# --------------------------------------------------
|
|
447
|
+
# Helper Methods
|
|
448
|
+
# --------------------------------------------------
|
|
449
|
+
|
|
450
|
+
def apply_formula(formula, context)
|
|
451
|
+
statements = join_multiline_statements(formula)
|
|
452
|
+
temp_ctx = context.dup
|
|
453
|
+
|
|
454
|
+
statements.each do |statement|
|
|
455
|
+
next if statement.start_with?("#")
|
|
456
|
+
break if statement.match?(/^\s*(export|return)\b/i)
|
|
457
|
+
|
|
458
|
+
if statement =~ /^(\w+)\s*:?=\s*(.+)$/m
|
|
459
|
+
var_name = $1.to_sym
|
|
460
|
+
expr = $2
|
|
461
|
+
processed_expr = preprocess_collections(expr)
|
|
462
|
+
|
|
463
|
+
begin
|
|
464
|
+
value = @calculator.evaluate!(processed_expr, temp_ctx)
|
|
465
|
+
# Convert BigDecimal to Float for proper JSON serialization
|
|
466
|
+
value = value.to_f if value.is_a?(BigDecimal)
|
|
467
|
+
temp_ctx[var_name] = value
|
|
468
|
+
rescue StandardError => e
|
|
469
|
+
Stellwerk.logger.warn("Formula statement error: #{e.message}")
|
|
470
|
+
raise e
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
temp_ctx
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def join_multiline_statements(formula)
|
|
479
|
+
lines = formula.to_s.gsub("\r", "\n").split("\n").map(&:strip)
|
|
480
|
+
statements = []
|
|
481
|
+
current_statement = ""
|
|
482
|
+
paren_depth = 0
|
|
483
|
+
|
|
484
|
+
lines.each do |line|
|
|
485
|
+
next if line.empty?
|
|
486
|
+
|
|
487
|
+
if line.start_with?("#") && paren_depth == 0
|
|
488
|
+
statements << line
|
|
489
|
+
next
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
line_without_comment = line.gsub(/#.*$/, "").strip
|
|
493
|
+
|
|
494
|
+
if current_statement.empty?
|
|
495
|
+
current_statement = line_without_comment
|
|
496
|
+
else
|
|
497
|
+
current_statement += " " + line_without_comment
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
paren_depth += line_without_comment.count("(") - line_without_comment.count(")")
|
|
501
|
+
|
|
502
|
+
if paren_depth <= 0
|
|
503
|
+
statements << current_statement unless current_statement.empty?
|
|
504
|
+
current_statement = ""
|
|
505
|
+
paren_depth = 0
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
statements << current_statement unless current_statement.empty?
|
|
510
|
+
statements
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def normalize_expression(expr)
|
|
514
|
+
expr.to_s.gsub("==", "=")
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def preprocess_collections(expr_str)
|
|
518
|
+
result = expr_str.dup
|
|
519
|
+
|
|
520
|
+
result = result.gsub(/\[([^\[\]*]+)\]/) do |match|
|
|
521
|
+
inner = $1
|
|
522
|
+
inner.strip == "*" ? match : "ARRAY(#{inner})"
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
result.gsub(/(\w+)(\[\*\])+(\.\w+)+/) do
|
|
526
|
+
root = $1
|
|
527
|
+
path_str = $3
|
|
528
|
+
fields = path_str.scan(/\.\w+/).map { |f| f[1..-1] }
|
|
529
|
+
|
|
530
|
+
if fields.size == 1
|
|
531
|
+
"project(#{root}, \"#{fields.first}\")"
|
|
532
|
+
else
|
|
533
|
+
"deep_project(#{root}, #{fields.map { |f| "\"#{f}\"" }.join(', ')})"
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def flatten_context(ctx)
|
|
539
|
+
result = {}
|
|
540
|
+
ctx.each do |key, value|
|
|
541
|
+
if value.is_a?(Hash)
|
|
542
|
+
value.each do |nested_key, nested_value|
|
|
543
|
+
result["#{key}.#{nested_key}".to_sym] = nested_value
|
|
544
|
+
end
|
|
545
|
+
else
|
|
546
|
+
result[key] = value
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
result
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def deep_merge_hashes(base, overlay)
|
|
553
|
+
return overlay unless base.is_a?(Hash)
|
|
554
|
+
return base unless overlay.is_a?(Hash)
|
|
555
|
+
|
|
556
|
+
base_sym = deep_symbolize_keys(base)
|
|
557
|
+
overlay_sym = deep_symbolize_keys(overlay)
|
|
558
|
+
|
|
559
|
+
base_sym.merge(overlay_sym) do |_key, old_val, new_val|
|
|
560
|
+
if old_val.is_a?(Hash) && new_val.is_a?(Hash)
|
|
561
|
+
deep_merge_hashes(old_val, new_val)
|
|
562
|
+
else
|
|
563
|
+
new_val
|
|
564
|
+
end
|
|
565
|
+
end
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
# --------------------------------------------------
|
|
569
|
+
# Template Processing (from EndNode)
|
|
570
|
+
# --------------------------------------------------
|
|
571
|
+
|
|
572
|
+
def process_json_template(template, context)
|
|
573
|
+
interpolate_value(template, context)
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
def process_yaml_template(yaml_template, context)
|
|
577
|
+
return {} if yaml_template.nil? || yaml_template.to_s.strip.empty?
|
|
578
|
+
|
|
579
|
+
single_placeholder_match = yaml_template.strip.match(/\A\{\{\s*([\w.]+)\s*\}\}\z/)
|
|
580
|
+
if single_placeholder_match
|
|
581
|
+
var_path = single_placeholder_match[1]
|
|
582
|
+
return resolve_path(var_path, context)
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
begin
|
|
586
|
+
template = YAML.safe_load(yaml_template)
|
|
587
|
+
interpolate_value(template, context)
|
|
588
|
+
rescue Psych::SyntaxError
|
|
589
|
+
{}
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def interpolate_value(value, context)
|
|
594
|
+
case value
|
|
595
|
+
when Hash
|
|
596
|
+
value.transform_values { |v| interpolate_value(v, context) }
|
|
597
|
+
when Array
|
|
598
|
+
value.map { |v| interpolate_value(v, context) }
|
|
599
|
+
when String
|
|
600
|
+
interpolate_string(value, context)
|
|
601
|
+
else
|
|
602
|
+
value
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def interpolate_string(value, context)
|
|
607
|
+
jsonpath_single_match = value.match(/\A\s*\$\.([\w.]+)\s*\z/)
|
|
608
|
+
mustache_single_match = value.match(/\A\s*\{\{\s*([\w.]+)\s*\}\}\s*\z/)
|
|
609
|
+
|
|
610
|
+
if jsonpath_single_match
|
|
611
|
+
resolve_path(jsonpath_single_match[1], context)
|
|
612
|
+
elsif mustache_single_match
|
|
613
|
+
resolve_path(mustache_single_match[1], context)
|
|
614
|
+
else
|
|
615
|
+
result = value.dup
|
|
616
|
+
result = result.gsub(/\$\.([\w.]+)/) do |match|
|
|
617
|
+
var_path = Regexp.last_match(1)
|
|
618
|
+
val = resolve_path(var_path, context)
|
|
619
|
+
format_interpolated_value(val, match)
|
|
620
|
+
end
|
|
621
|
+
result = result.gsub(/\{\{\s*([\w.]+)\s*\}\}/) do |match|
|
|
622
|
+
var_path = Regexp.last_match(1)
|
|
623
|
+
val = resolve_path(var_path, context)
|
|
624
|
+
format_interpolated_value(val, match)
|
|
625
|
+
end
|
|
626
|
+
result
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def format_interpolated_value(val, original_match)
|
|
631
|
+
if val.nil?
|
|
632
|
+
original_match
|
|
633
|
+
elsif val.is_a?(Hash) || val.is_a?(Array)
|
|
634
|
+
val.to_json
|
|
635
|
+
else
|
|
636
|
+
val.to_s
|
|
637
|
+
end
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def resolve_path(path, context)
|
|
641
|
+
parts = path.split(".")
|
|
642
|
+
current = context
|
|
643
|
+
|
|
644
|
+
parts.each do |part|
|
|
645
|
+
return nil if current.nil?
|
|
646
|
+
|
|
647
|
+
current = if current.is_a?(Hash)
|
|
648
|
+
current[part.to_sym] || current[part]
|
|
649
|
+
elsif current.respond_to?(part)
|
|
650
|
+
current.public_send(part)
|
|
651
|
+
else
|
|
652
|
+
nil
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
current
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
# --------------------------------------------------
|
|
660
|
+
# Collection Functions Registration
|
|
661
|
+
# --------------------------------------------------
|
|
662
|
+
|
|
663
|
+
def register_collection_functions!
|
|
664
|
+
@calculator.add_function(:ARRAY, :numeric, ->(*args) { args })
|
|
665
|
+
|
|
666
|
+
@calculator.add_function(:PROJECT, :numeric, ->(collection, field_name) {
|
|
667
|
+
Stellwerk::Functions.PROJECT(collection, field_name)
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
@calculator.add_function(:DEEP_PROJECT, :numeric, ->(root, path_string, field_name) {
|
|
671
|
+
Stellwerk::Functions.DEEP_PROJECT(root, path_string, field_name)
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
@calculator.add_function(:SUM, :numeric, ->(*args) {
|
|
675
|
+
Stellwerk::Functions.SUM(*args)
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
@calculator.add_function(:COUNT, :numeric, ->(*args) {
|
|
679
|
+
Stellwerk::Functions.COUNT(*args)
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
@calculator.add_function(:MIN, :numeric, ->(*args) {
|
|
683
|
+
Stellwerk::Functions.MIN(*args)
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
@calculator.add_function(:MAX, :numeric, ->(*args) {
|
|
687
|
+
Stellwerk::Functions.MAX(*args)
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
@calculator.add_function(:AVERAGE, :numeric, ->(*args) {
|
|
691
|
+
Stellwerk::Functions.AVERAGE(*args)
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
@calculator.add_function(:AVG, :numeric, ->(*args) {
|
|
695
|
+
Stellwerk::Functions.AVERAGE(*args)
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
@calculator.add_function(:FIRST, :numeric, ->(arr) {
|
|
699
|
+
Stellwerk::Functions.FIRST(arr)
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
@calculator.add_function(:LAST, :numeric, ->(arr) {
|
|
703
|
+
Stellwerk::Functions.LAST(arr)
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
@calculator.add_function(:TAKE, :numeric, ->(arr, n) {
|
|
707
|
+
Stellwerk::Functions.TAKE(arr, n)
|
|
708
|
+
})
|
|
709
|
+
|
|
710
|
+
@calculator.add_function(:DISTINCT, :numeric, ->(arr) {
|
|
711
|
+
Stellwerk::Functions.DISTINCT(arr)
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
@calculator.add_function(:MAP, :numeric, ->(collection, expr) {
|
|
715
|
+
Stellwerk::Functions.MAP(collection, expr, calculator: @calculator)
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
@calculator.add_function(:FILTER, :numeric, ->(collection, predicate_expr) {
|
|
719
|
+
Stellwerk::Functions.FILTER(collection, predicate_expr, calculator: @calculator)
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
@calculator.add_function(:REDUCE, :numeric, ->(collection, initial, expr) {
|
|
723
|
+
Stellwerk::Functions.REDUCE(collection, initial, expr, calculator: @calculator)
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
@calculator.add_function(:SUMIF, :numeric, ->(collection, predicate_expr, projection_expr = nil) {
|
|
727
|
+
Stellwerk::Functions.SUMIF(collection, predicate_expr, projection_expr, calculator: @calculator)
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
@calculator.add_function(:COUNTIF, :numeric, ->(collection, predicate_expr) {
|
|
731
|
+
Stellwerk::Functions.COUNTIF(collection, predicate_expr, calculator: @calculator)
|
|
732
|
+
})
|
|
733
|
+
|
|
734
|
+
@calculator.add_function(:TRACE, :numeric, ->(value, label = nil) {
|
|
735
|
+
Stellwerk::Functions.TRACE(value, label)
|
|
736
|
+
})
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
def build_result
|
|
740
|
+
Result.new(
|
|
741
|
+
applied_nodes: @applied_nodes,
|
|
742
|
+
context: @context,
|
|
743
|
+
outputs: @outputs || {},
|
|
744
|
+
errors: @errors
|
|
745
|
+
)
|
|
746
|
+
end
|
|
747
|
+
end
|
|
748
|
+
end
|