inquirex 0.7.0 → 0.9.4
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/CHANGELOG.md +48 -18
- data/README.md +115 -48
- data/lib/inquirex/accumulator.rb +48 -6
- data/lib/inquirex/answers.rb +0 -10
- data/lib/inquirex/definition.rb +11 -53
- data/lib/inquirex/dsl/flow_builder.rb +52 -45
- data/lib/inquirex/dsl/send_email_builder.rb +88 -0
- data/lib/inquirex/engine.rb +94 -13
- data/lib/inquirex/errors.rb +20 -3
- data/lib/inquirex/node.rb +29 -0
- data/lib/inquirex/safe_source/call_spec.rb +42 -0
- data/lib/inquirex/safe_source/validator.rb +559 -0
- data/lib/inquirex/safe_source/vocabulary.rb +322 -0
- data/lib/inquirex/safe_source.rb +111 -0
- data/lib/inquirex/send_email.rb +206 -0
- data/lib/inquirex/template.rb +93 -0
- data/lib/inquirex/transcript.rb +93 -0
- data/lib/inquirex/version.rb +1 -1
- data/lib/inquirex.rb +48 -19
- metadata +12 -13
- data/lib/inquirex/actions/action.rb +0 -68
- data/lib/inquirex/actions/base.rb +0 -41
- data/lib/inquirex/actions/custom.rb +0 -31
- data/lib/inquirex/actions/outbox.rb +0 -57
- data/lib/inquirex/actions/runner.rb +0 -52
- data/lib/inquirex/actions/send_email.rb +0 -174
- data/lib/inquirex/actions/template.rb +0 -95
- data/lib/inquirex/actions/webhook.rb +0 -139
- data/lib/inquirex/actions.rb +0 -57
- data/lib/inquirex/dsl/action_builder.rb +0 -53
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi/escape"
|
|
4
|
+
|
|
5
|
+
module Inquirex
|
|
6
|
+
# Renders {{field}} placeholders against collected answers.
|
|
7
|
+
#
|
|
8
|
+
# Placeholders are dot-notation keys resolved via Answers#to_flat_h
|
|
9
|
+
# ("{{email}}", "{{business.count}}"). The syntax is deliberately inert —
|
|
10
|
+
# no code execution — so flow definitions stored in a database can be
|
|
11
|
+
# rendered server-side safely and a visual editor can preview templates
|
|
12
|
+
# client-side. It matches the {{field}} placeholder convention used by
|
|
13
|
+
# inquirex-llm prompts.
|
|
14
|
+
#
|
|
15
|
+
# Rendering modes:
|
|
16
|
+
# - render_text — values inserted verbatim
|
|
17
|
+
# - render_html — every interpolated value is HTML-escaped
|
|
18
|
+
#
|
|
19
|
+
# The built-in {{answers_summary}} placeholder expands to all collected
|
|
20
|
+
# answers: "key: value" lines in text mode, a simple table in HTML mode.
|
|
21
|
+
# Unknown fields render as empty strings; array values join with ", ".
|
|
22
|
+
module Template
|
|
23
|
+
# Matches one {{field}} placeholder; capture 1 is the dot-notation key.
|
|
24
|
+
PLACEHOLDER = /\{\{\s*([\w.]+)\s*\}\}/
|
|
25
|
+
# Reserved placeholder key that expands to a summary of all collected answers.
|
|
26
|
+
SUMMARY_KEY = "answers_summary"
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# @param string [String] template with {{field}} placeholders
|
|
31
|
+
# @param answers [Answers]
|
|
32
|
+
# @return [String]
|
|
33
|
+
def render_text(string, answers)
|
|
34
|
+
render(string, answers, html: false)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @example Interpolated values are HTML-escaped
|
|
38
|
+
# answers = Inquirex::Answers.new(name: "Bob & Sons")
|
|
39
|
+
# Inquirex::Template.render_html("<p>{{name}}</p>", answers)
|
|
40
|
+
# # => "<p>Bob & Sons</p>"
|
|
41
|
+
#
|
|
42
|
+
# @param string [String] template with {{field}} placeholders
|
|
43
|
+
# @param answers [Answers]
|
|
44
|
+
# @return [String]
|
|
45
|
+
def render_html(string, answers)
|
|
46
|
+
render(string, answers, html: true)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Shared implementation behind render_text and render_html.
|
|
50
|
+
#
|
|
51
|
+
# @param string [String] template with {{field}} placeholders
|
|
52
|
+
# @param answers [Answers]
|
|
53
|
+
# @param html [Boolean] whether to HTML-escape interpolated values
|
|
54
|
+
# @return [String]
|
|
55
|
+
def render(string, answers, html:)
|
|
56
|
+
flat = answers.to_flat_h
|
|
57
|
+
string.gsub(PLACEHOLDER) do
|
|
58
|
+
key = Regexp.last_match(1)
|
|
59
|
+
next(html ? summary_html(flat) : summary_text(flat)) if key == SUMMARY_KEY
|
|
60
|
+
|
|
61
|
+
value = format_value(flat[key])
|
|
62
|
+
html ? CGI.escapeHTML(value) : value
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Formats a single answer value for interpolation.
|
|
67
|
+
#
|
|
68
|
+
# @param value [Object] raw answer value (nil renders as "")
|
|
69
|
+
# @return [String] arrays joined with ", ", everything else via #to_s
|
|
70
|
+
def format_value(value)
|
|
71
|
+
value.is_a?(Array) ? value.join(", ") : value.to_s
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Expands {{answers_summary}} in text mode.
|
|
75
|
+
#
|
|
76
|
+
# @param flat [Hash{String => Object}] flat answers, dot-notation keys
|
|
77
|
+
# @return [String] one "key: value" line per answer
|
|
78
|
+
def summary_text(flat)
|
|
79
|
+
flat.map { |key, value| "#{key}: #{format_value(value)}" }.join("\n")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Expands {{answers_summary}} in HTML mode.
|
|
83
|
+
#
|
|
84
|
+
# @param flat [Hash{String => Object}] flat answers, dot-notation keys
|
|
85
|
+
# @return [String] a simple HTML table with one row per answer, fully escaped
|
|
86
|
+
def summary_html(flat)
|
|
87
|
+
rows = flat.map do |key, value|
|
|
88
|
+
"<tr><th>#{CGI.escapeHTML(key)}</th><td>#{CGI.escapeHTML(format_value(value))}</td></tr>"
|
|
89
|
+
end
|
|
90
|
+
%(<table class="inquirex-answers">#{rows.join}</table>)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
# Formats what the user actually saw into the prose entries a `:text`
|
|
5
|
+
# accumulator collects.
|
|
6
|
+
#
|
|
7
|
+
# The Engine appends one entry per *interaction* — an answered question, a
|
|
8
|
+
# skipped question, a display step advanced past — and never for a step the
|
|
9
|
+
# engine elided on its own. A step removed by `skip_if`, or auto-skipped
|
|
10
|
+
# because an extraction already answered it, was never on screen, so it must
|
|
11
|
+
# not appear in a narrative that claims to be a record of the session.
|
|
12
|
+
#
|
|
13
|
+
# Entries are plain prose rather than JSON because their only consumer is an
|
|
14
|
+
# LLM prompt: `Q:` / `A:` reads as dialogue to a model, where a serialized
|
|
15
|
+
# answers hash reads as data and produces a summary that sounds like one.
|
|
16
|
+
module Transcript
|
|
17
|
+
# Shown instead of an answer for a question the user declined.
|
|
18
|
+
SKIPPED = "(skipped)"
|
|
19
|
+
|
|
20
|
+
# Shown instead of an answer when a step somehow carries none.
|
|
21
|
+
NO_ANSWER = "(no answer)"
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# The entry for a display step (say/header/btw/warning) the user has
|
|
25
|
+
# just advanced past: its text, verbatim.
|
|
26
|
+
#
|
|
27
|
+
# @param node [Node] the display step
|
|
28
|
+
# @return [String, nil] nil when the step carries no text
|
|
29
|
+
def display_entry(node)
|
|
30
|
+
text = node.text.to_s.strip
|
|
31
|
+
text.empty? ? nil : text
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The entry for a question the user answered.
|
|
35
|
+
#
|
|
36
|
+
# @param node [Node] the collecting step
|
|
37
|
+
# @param answer [Object] the value the user submitted
|
|
38
|
+
# @return [String, nil] nil when the step carries no question text
|
|
39
|
+
def answer_entry(node, answer)
|
|
40
|
+
exchange(node, format_answer(answer, node))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The entry for an optional question the user declined.
|
|
44
|
+
#
|
|
45
|
+
# @param node [Node] the collecting step
|
|
46
|
+
# @return [String, nil] nil when the step carries no question text
|
|
47
|
+
def skipped_entry(node)
|
|
48
|
+
exchange(node, SKIPPED)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Renders an answer the way it was shown, not the way it is stored:
|
|
52
|
+
# option values become their labels where the step declares them, so the
|
|
53
|
+
# narrative reads "Married filing jointly" rather than
|
|
54
|
+
# "married_filing_jointly".
|
|
55
|
+
#
|
|
56
|
+
# @param answer [Object] a stored answer value
|
|
57
|
+
# @param node [Node, nil] the step it belongs to, for label lookup
|
|
58
|
+
# @return [String]
|
|
59
|
+
def format_answer(answer, node = nil)
|
|
60
|
+
case answer
|
|
61
|
+
when nil then NO_ANSWER
|
|
62
|
+
when true then "Yes"
|
|
63
|
+
when false then "No"
|
|
64
|
+
when Array then format_array(answer, node)
|
|
65
|
+
else label_for(answer, node)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# @return [String, nil]
|
|
72
|
+
def exchange(node, rendered)
|
|
73
|
+
question = node.question.to_s.strip
|
|
74
|
+
return nil if question.empty?
|
|
75
|
+
|
|
76
|
+
"Q: #{question}\nA: #{rendered}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @return [String]
|
|
80
|
+
def format_array(answer, node)
|
|
81
|
+
return NO_ANSWER if answer.empty?
|
|
82
|
+
|
|
83
|
+
answer.map { |entry| label_for(entry, node) }.join(", ")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @return [String]
|
|
87
|
+
def label_for(value, node)
|
|
88
|
+
labels = node&.option_labels
|
|
89
|
+
labels&.fetch(value.to_s, nil) || value.to_s
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
data/lib/inquirex/version.rb
CHANGED
data/lib/inquirex.rb
CHANGED
|
@@ -21,6 +21,7 @@ require_relative "inquirex/widget_registry"
|
|
|
21
21
|
require_relative "inquirex/transition"
|
|
22
22
|
require_relative "inquirex/evaluator"
|
|
23
23
|
require_relative "inquirex/accumulator"
|
|
24
|
+
require_relative "inquirex/transcript"
|
|
24
25
|
require_relative "inquirex/node"
|
|
25
26
|
require_relative "inquirex/definition"
|
|
26
27
|
require_relative "inquirex/answers"
|
|
@@ -30,23 +31,20 @@ require_relative "inquirex/completion_metadata"
|
|
|
30
31
|
require_relative "inquirex/validation/adapter"
|
|
31
32
|
require_relative "inquirex/validation/null_adapter"
|
|
32
33
|
|
|
33
|
-
#
|
|
34
|
-
require_relative "inquirex/
|
|
35
|
-
require_relative "inquirex/
|
|
36
|
-
require_relative "inquirex/actions/outbox"
|
|
37
|
-
require_relative "inquirex/actions/base"
|
|
38
|
-
require_relative "inquirex/actions/send_email"
|
|
39
|
-
require_relative "inquirex/actions/webhook"
|
|
40
|
-
require_relative "inquirex/actions/custom"
|
|
41
|
-
require_relative "inquirex/actions/action"
|
|
42
|
-
require_relative "inquirex/actions/runner"
|
|
34
|
+
# Completion emails
|
|
35
|
+
require_relative "inquirex/template"
|
|
36
|
+
require_relative "inquirex/send_email"
|
|
43
37
|
|
|
44
38
|
# DSL
|
|
45
39
|
require_relative "inquirex/dsl"
|
|
46
40
|
require_relative "inquirex/dsl/rule_helpers"
|
|
47
41
|
require_relative "inquirex/dsl/step_builder"
|
|
48
42
|
require_relative "inquirex/dsl/flow_builder"
|
|
49
|
-
require_relative "inquirex/dsl/
|
|
43
|
+
require_relative "inquirex/dsl/send_email_builder"
|
|
44
|
+
|
|
45
|
+
# Source validation — loads after the DSL builders, whose public methods the
|
|
46
|
+
# allowlist reflects on so it cannot drift from the real vocabulary.
|
|
47
|
+
require_relative "inquirex/safe_source"
|
|
50
48
|
|
|
51
49
|
# Engine
|
|
52
50
|
require_relative "inquirex/engine/state_serializer"
|
|
@@ -108,16 +106,47 @@ module Inquirex
|
|
|
108
106
|
# Evaluates a string of DSL code and returns the resulting definition.
|
|
109
107
|
# Intended for loading flow definitions from files or stored text.
|
|
110
108
|
#
|
|
109
|
+
# This is an `eval`. Whenever the text comes from anywhere other than your
|
|
110
|
+
# own repository — a database column a customer edits, an upload, an LLM, a
|
|
111
|
+
# visual builder — evaluating it unguarded is arbitrary code execution in
|
|
112
|
+
# the loading process. {SafeSource.validate!} therefore runs **before** the
|
|
113
|
+
# eval unless you opt out; the ordering is the whole point.
|
|
114
|
+
#
|
|
115
|
+
# `unsafe: true` is for source you control as code: a `.rb` file in your own
|
|
116
|
+
# repository, a fixture, the file a CLI was pointed at. It is named `unsafe`
|
|
117
|
+
# rather than `safe: false` so the dangerous call is the conspicuous one.
|
|
118
|
+
#
|
|
119
|
+
# @example Stored, customer-authored DSL (validated)
|
|
120
|
+
# Inquirex.load_dsl(qualifier.flow_dsl)
|
|
121
|
+
#
|
|
122
|
+
# @example Your own file (validation skipped)
|
|
123
|
+
# Inquirex.load_dsl(File.read("flow.rb"), unsafe: true)
|
|
124
|
+
#
|
|
111
125
|
# @param text [String] Ruby source containing Inquirex.define { ... }
|
|
126
|
+
# @param unsafe [Boolean] skip validation — only for source you authored
|
|
127
|
+
# @param max_bytes [Integer] source size ceiling for validation
|
|
128
|
+
# @param max_depth [Integer] AST nesting ceiling for validation
|
|
112
129
|
# @return [Definition]
|
|
130
|
+
# @raise [Errors::UnsafeSourceError] when the source leaves the allowlist
|
|
113
131
|
# @raise [Errors::DefinitionError] on syntax or evaluation errors
|
|
114
|
-
def self.load_dsl(text
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
132
|
+
def self.load_dsl(text,
|
|
133
|
+
unsafe: false,
|
|
134
|
+
max_bytes: SafeSource.max_source_bytes,
|
|
135
|
+
max_depth: SafeSource.max_depth)
|
|
136
|
+
SafeSource.validate!(text, max_bytes:, max_depth:) unless unsafe
|
|
137
|
+
|
|
138
|
+
begin
|
|
139
|
+
# Deliberate eval: the flow DSL *is* Ruby, so there is nothing else to
|
|
140
|
+
# evaluate it with. Reaching this line means either SafeSource accepted
|
|
141
|
+
# the source against a default-deny allowlist (above), or the caller
|
|
142
|
+
# asserted `unsafe: true` for source it authored itself.
|
|
143
|
+
# rubocop:disable Security/Eval
|
|
144
|
+
eval(text, TOPLEVEL_BINDING.dup, "(dsl)", 1)
|
|
145
|
+
# rubocop:enable Security/Eval
|
|
146
|
+
rescue SyntaxError => e
|
|
147
|
+
raise Errors::DefinitionError, "DSL syntax error: #{e.message}"
|
|
148
|
+
rescue StandardError => e
|
|
149
|
+
raise Errors::DefinitionError, "DSL evaluation error: #{e.message}"
|
|
150
|
+
end
|
|
122
151
|
end
|
|
123
152
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: inquirex
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Konstantin Gredeskoul
|
|
@@ -42,22 +42,13 @@ files:
|
|
|
42
42
|
- lib/.DS_Store
|
|
43
43
|
- lib/inquirex.rb
|
|
44
44
|
- lib/inquirex/accumulator.rb
|
|
45
|
-
- lib/inquirex/actions.rb
|
|
46
|
-
- lib/inquirex/actions/action.rb
|
|
47
|
-
- lib/inquirex/actions/base.rb
|
|
48
|
-
- lib/inquirex/actions/custom.rb
|
|
49
|
-
- lib/inquirex/actions/outbox.rb
|
|
50
|
-
- lib/inquirex/actions/runner.rb
|
|
51
|
-
- lib/inquirex/actions/send_email.rb
|
|
52
|
-
- lib/inquirex/actions/template.rb
|
|
53
|
-
- lib/inquirex/actions/webhook.rb
|
|
54
45
|
- lib/inquirex/answers.rb
|
|
55
46
|
- lib/inquirex/completion_metadata.rb
|
|
56
47
|
- lib/inquirex/definition.rb
|
|
57
48
|
- lib/inquirex/dsl.rb
|
|
58
|
-
- lib/inquirex/dsl/action_builder.rb
|
|
59
49
|
- lib/inquirex/dsl/flow_builder.rb
|
|
60
50
|
- lib/inquirex/dsl/rule_helpers.rb
|
|
51
|
+
- lib/inquirex/dsl/send_email_builder.rb
|
|
61
52
|
- lib/inquirex/dsl/step_builder.rb
|
|
62
53
|
- lib/inquirex/engine.rb
|
|
63
54
|
- lib/inquirex/engine/state_serializer.rb
|
|
@@ -73,6 +64,13 @@ files:
|
|
|
73
64
|
- lib/inquirex/rules/greater_than.rb
|
|
74
65
|
- lib/inquirex/rules/less_than.rb
|
|
75
66
|
- lib/inquirex/rules/not_empty.rb
|
|
67
|
+
- lib/inquirex/safe_source.rb
|
|
68
|
+
- lib/inquirex/safe_source/call_spec.rb
|
|
69
|
+
- lib/inquirex/safe_source/validator.rb
|
|
70
|
+
- lib/inquirex/safe_source/vocabulary.rb
|
|
71
|
+
- lib/inquirex/send_email.rb
|
|
72
|
+
- lib/inquirex/template.rb
|
|
73
|
+
- lib/inquirex/transcript.rb
|
|
76
74
|
- lib/inquirex/transition.rb
|
|
77
75
|
- lib/inquirex/validation/adapter.rb
|
|
78
76
|
- lib/inquirex/validation/null_adapter.rb
|
|
@@ -102,8 +100,9 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
102
100
|
- !ruby/object:Gem::Version
|
|
103
101
|
version: '0'
|
|
104
102
|
requirements: []
|
|
105
|
-
rubygems_version: 4.0.
|
|
103
|
+
rubygems_version: 4.0.17
|
|
106
104
|
specification_version: 4
|
|
107
105
|
summary: A declarative, rules-driven questionnaire engine for building conditionally-branching
|
|
108
|
-
intake forms, qualification wizards, and surveys in pure Ruby
|
|
106
|
+
intake forms, qualification wizards, and surveys in pure Ruby, with additional plugins
|
|
107
|
+
providing LLM/AI support. See inquirex-llm.
|
|
109
108
|
test_files: []
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Inquirex
|
|
4
|
-
module Actions
|
|
5
|
-
# A named post-completion unit: an optional rule gating execution and an
|
|
6
|
-
# ordered list of effects. Declared with the DSL word `action`:
|
|
7
|
-
#
|
|
8
|
-
# action :client_receipt, if: not_empty(:email) do
|
|
9
|
-
# send_email to: "{{email}}", subject: "Thanks {{name}}!", text: "..."
|
|
10
|
-
# end
|
|
11
|
-
#
|
|
12
|
-
# Rules reuse the same serializable AST as transitions, so conditions
|
|
13
|
-
# survive the JSON round-trip. Non-serializable effects (run blocks) are
|
|
14
|
-
# stripped on serialization; an action left with no serializable effects
|
|
15
|
-
# is omitted from JSON entirely.
|
|
16
|
-
class Action
|
|
17
|
-
attr_reader :id, :rule, :effects
|
|
18
|
-
|
|
19
|
-
# @param id [Symbol] action identifier
|
|
20
|
-
# @param effects [Array<Actions::Base>] executed in declaration order
|
|
21
|
-
# @param rule [Rules::Base, nil] gate — action runs only when true
|
|
22
|
-
def initialize(id:, effects:, rule: nil)
|
|
23
|
-
@id = id.to_sym
|
|
24
|
-
@effects = effects.freeze
|
|
25
|
-
@rule = rule
|
|
26
|
-
freeze
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# @param answers_hash [Hash] step_id => value context for rule evaluation
|
|
30
|
-
# @return [Boolean]
|
|
31
|
-
def applicable?(answers_hash)
|
|
32
|
-
@rule.nil? || @rule.evaluate(answers_hash)
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
# @return [Boolean] whether anything survives JSON serialization
|
|
36
|
-
def serializable?
|
|
37
|
-
@effects.any?(&:serializable?)
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# @return [Hash] wire format; run blocks are stripped
|
|
41
|
-
def to_h
|
|
42
|
-
hash = { "id" => @id.to_s }
|
|
43
|
-
hash["if"] = @rule.to_h if @rule
|
|
44
|
-
hash["effects"] = @effects.select(&:serializable?).map(&:to_h)
|
|
45
|
-
hash
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# @param hash [Hash] string or symbol keys
|
|
49
|
-
# @return [Action]
|
|
50
|
-
def self.from_h(hash)
|
|
51
|
-
id = hash["id"] || hash[:id]
|
|
52
|
-
rule_data = hash["if"] || hash[:if]
|
|
53
|
-
effects_data = hash["effects"] || hash[:effects] || []
|
|
54
|
-
|
|
55
|
-
effects = effects_data.map do |effect_hash|
|
|
56
|
-
type = effect_hash["type"] || effect_hash[:type]
|
|
57
|
-
Actions.lookup(type).from_h(effect_hash)
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
new(
|
|
61
|
-
id: id.to_sym,
|
|
62
|
-
effects: effects,
|
|
63
|
-
rule: rule_data ? Rules::Base.from_h(rule_data) : nil
|
|
64
|
-
)
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
end
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Inquirex
|
|
4
|
-
module Actions
|
|
5
|
-
# Abstract base for action effects — the executable units inside an
|
|
6
|
-
# `action` block. Subclasses implement #call and, when serializable,
|
|
7
|
-
# #to_h / .from_h following the same round-trip pattern as Rules::Base.
|
|
8
|
-
#
|
|
9
|
-
# Effects that wrap Ruby procs (Actions::Custom) return false from
|
|
10
|
-
# #serializable? and are stripped from JSON, consistent with how
|
|
11
|
-
# lambdas are handled everywhere else in Inquirex.
|
|
12
|
-
class Base
|
|
13
|
-
# Executes the effect. Email-building effects append Mail::Message
|
|
14
|
-
# objects to the outbox; custom effects may do anything server-side.
|
|
15
|
-
#
|
|
16
|
-
# @param answers [Answers] completed answers
|
|
17
|
-
# @param outbox [Outbox] collector for built messages
|
|
18
|
-
# @return [void]
|
|
19
|
-
def call(answers, outbox)
|
|
20
|
-
raise NotImplementedError, "#{self.class}#call must be implemented"
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# @return [Boolean] whether this effect survives JSON serialization
|
|
24
|
-
def serializable? = true
|
|
25
|
-
|
|
26
|
-
# Hook for effects that must be checked against the definition carrying
|
|
27
|
-
# them (e.g. Webhook vs allowed_domains). Runs inside
|
|
28
|
-
# Definition#validate!, which both DSL-built and JSON-rehydrated
|
|
29
|
-
# definitions pass through — so violations fail at load time.
|
|
30
|
-
#
|
|
31
|
-
# @param _definition [Definition]
|
|
32
|
-
# @raise [Errors::DefinitionError] on violation
|
|
33
|
-
def validate_against(_definition); end
|
|
34
|
-
|
|
35
|
-
# @return [Hash]
|
|
36
|
-
def to_h
|
|
37
|
-
raise NotImplementedError, "#{self.class}#to_h must be implemented"
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
end
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Inquirex
|
|
4
|
-
module Actions
|
|
5
|
-
# Escape-hatch effect wrapping an arbitrary Ruby block, declared in the
|
|
6
|
-
# DSL as `run { |answers, outbox| ... }`. Full language power — build a
|
|
7
|
-
# Mail::Message by hand with outbox.add_message, call a service, record
|
|
8
|
-
# metrics — at the cost of serialization: like every lambda in Inquirex,
|
|
9
|
-
# the block is stripped from JSON and exists only in Ruby-authored
|
|
10
|
-
# definitions.
|
|
11
|
-
class Custom < Base
|
|
12
|
-
# @param block [Proc] receives (answers, outbox)
|
|
13
|
-
def initialize(block)
|
|
14
|
-
super()
|
|
15
|
-
@block = block
|
|
16
|
-
freeze
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
# Invokes the wrapped block with the collected answers and the outbox.
|
|
20
|
-
#
|
|
21
|
-
# @param answers [Answers] completed answers
|
|
22
|
-
# @param outbox [Outbox] collector for messages the block may build
|
|
23
|
-
# @return [Object] whatever the block returns (recorded, not interpreted)
|
|
24
|
-
def call(answers, outbox)
|
|
25
|
-
@block.call(answers, outbox)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def serializable? = false
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Inquirex
|
|
4
|
-
module Actions
|
|
5
|
-
# Collected output of running a definition's actions: the Mail::Message
|
|
6
|
-
# objects built by send_email effects, plus a per-execution result trail.
|
|
7
|
-
#
|
|
8
|
-
# The outbox is attached to Answers#outbox and is intentionally excluded
|
|
9
|
-
# from Answers serialization — mail objects never leak into persisted
|
|
10
|
-
# answer data. Delivery is the host application's job:
|
|
11
|
-
#
|
|
12
|
-
# answers.outbox.each do |message|
|
|
13
|
-
# ActionMailer::Base.wrap_delivery_behavior(message)
|
|
14
|
-
# message.deliver
|
|
15
|
-
# end
|
|
16
|
-
class Outbox
|
|
17
|
-
include Enumerable
|
|
18
|
-
|
|
19
|
-
# One entry per effect execution (or per skipped action).
|
|
20
|
-
# status is :ok, :skipped (action rule was false), or :failed.
|
|
21
|
-
Result = Data.define(:action_id, :status, :error)
|
|
22
|
-
|
|
23
|
-
attr_reader :messages, :results
|
|
24
|
-
|
|
25
|
-
def initialize
|
|
26
|
-
@messages = []
|
|
27
|
-
@results = []
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
# @param mail [Mail::Message]
|
|
31
|
-
# @return [Mail::Message]
|
|
32
|
-
def add_message(mail)
|
|
33
|
-
@messages << mail
|
|
34
|
-
mail
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# @param action_id [Symbol]
|
|
38
|
-
# @param status [Symbol] :ok, :skipped, or :failed
|
|
39
|
-
# @param error [StandardError, nil]
|
|
40
|
-
def record(action_id, status, error: nil)
|
|
41
|
-
@results << Result.new(action_id:, status:, error:)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
# Iterates over built Mail::Message objects.
|
|
45
|
-
def each(&) = @messages.each(&)
|
|
46
|
-
|
|
47
|
-
# @return [Integer] number of built messages
|
|
48
|
-
def size = @messages.size
|
|
49
|
-
|
|
50
|
-
# @return [Boolean]
|
|
51
|
-
def empty? = @messages.empty?
|
|
52
|
-
|
|
53
|
-
# @return [Array<Result>] executions that raised
|
|
54
|
-
def failures = @results.select { |result| result.status == :failed }
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Inquirex
|
|
4
|
-
module Actions
|
|
5
|
-
# Executes a definition's actions against completed answers, in
|
|
6
|
-
# declaration order, populating Answers#outbox with built messages and a
|
|
7
|
-
# result trail.
|
|
8
|
-
#
|
|
9
|
-
# Deliberately separate from the Engine: in the cross-site architecture
|
|
10
|
-
# the frontend collects answers and a different server process
|
|
11
|
-
# post-processes them, so the runner is a pure function of
|
|
12
|
-
# (definition, answers) — which also makes rebuilding messages inside a
|
|
13
|
-
# background job trivial.
|
|
14
|
-
#
|
|
15
|
-
# A failing effect records a :failed result and never aborts the other
|
|
16
|
-
# actions; the host inspects outbox.failures and decides what to do.
|
|
17
|
-
class Runner
|
|
18
|
-
# @param definition [Definition]
|
|
19
|
-
def initialize(definition)
|
|
20
|
-
@definition = definition
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# @param answers [Answers, Hash] completed answers (a Hash is wrapped)
|
|
24
|
-
# @return [Answers] the (possibly wrapped) answers with #outbox populated
|
|
25
|
-
def call(answers)
|
|
26
|
-
answers = Answers.new(answers) unless answers.is_a?(Answers)
|
|
27
|
-
context = answers.to_h
|
|
28
|
-
|
|
29
|
-
@definition.actions.each do |action|
|
|
30
|
-
unless action.applicable?(context)
|
|
31
|
-
answers.outbox.record(action.id, :skipped)
|
|
32
|
-
next
|
|
33
|
-
end
|
|
34
|
-
execute(action, answers)
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
answers
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
private
|
|
41
|
-
|
|
42
|
-
def execute(action, answers)
|
|
43
|
-
action.effects.each do |effect|
|
|
44
|
-
effect.call(answers, answers.outbox)
|
|
45
|
-
answers.outbox.record(action.id, :ok)
|
|
46
|
-
rescue StandardError => e
|
|
47
|
-
answers.outbox.record(action.id, :failed, error: e)
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
end
|