inquirex 0.5.0 → 0.6.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/CHANGELOG.md +18 -0
- data/README.md +119 -0
- data/docs/badges/coverage_badge.svg +2 -2
- data/examples/03_send_email_actions.rb +97 -0
- data/lib/inquirex/accumulator.rb +29 -0
- data/lib/inquirex/actions/action.rb +68 -0
- data/lib/inquirex/actions/base.rb +41 -0
- data/lib/inquirex/actions/custom.rb +31 -0
- data/lib/inquirex/actions/outbox.rb +57 -0
- data/lib/inquirex/actions/runner.rb +52 -0
- data/lib/inquirex/actions/send_email.rb +174 -0
- data/lib/inquirex/actions/template.rb +95 -0
- data/lib/inquirex/actions/webhook.rb +139 -0
- data/lib/inquirex/actions.rb +57 -0
- data/lib/inquirex/answers.rb +13 -0
- data/lib/inquirex/completion_metadata.rb +82 -0
- data/lib/inquirex/definition.rb +67 -5
- data/lib/inquirex/dsl/action_builder.rb +53 -0
- data/lib/inquirex/dsl/flow_builder.rb +49 -6
- data/lib/inquirex/engine/state_serializer.rb +6 -4
- data/lib/inquirex/engine.rb +97 -5
- data/lib/inquirex/errors.rb +5 -0
- data/lib/inquirex/graph/mermaid_exporter.rb +2 -0
- data/lib/inquirex/node.rb +2 -0
- data/lib/inquirex/rules/all.rb +12 -0
- data/lib/inquirex/rules/any.rb +11 -0
- data/lib/inquirex/rules/base.rb +6 -0
- data/lib/inquirex/rules/contains.rb +12 -0
- data/lib/inquirex/rules/equals.rb +12 -0
- data/lib/inquirex/rules/greater_than.rb +12 -0
- data/lib/inquirex/rules/less_than.rb +12 -0
- data/lib/inquirex/rules/not_empty.rb +12 -0
- data/lib/inquirex/validation/adapter.rb +1 -0
- data/lib/inquirex/validation/null_adapter.rb +5 -0
- data/lib/inquirex/version.rb +1 -1
- data/lib/inquirex/widget_registry.rb +1 -0
- data/lib/inquirex.rb +13 -0
- metadata +30 -4
data/lib/inquirex/definition.rb
CHANGED
|
@@ -13,7 +13,14 @@ module Inquirex
|
|
|
13
13
|
# @attr_reader start_step_id [Symbol] id of the first step in the flow
|
|
14
14
|
# @attr_reader steps [Hash<Symbol, Node>] frozen map of step id => node
|
|
15
15
|
class Definition
|
|
16
|
-
attr_reader :id,
|
|
16
|
+
attr_reader :id,
|
|
17
|
+
:version,
|
|
18
|
+
:meta,
|
|
19
|
+
:start_step_id,
|
|
20
|
+
:steps,
|
|
21
|
+
:accumulators,
|
|
22
|
+
:actions,
|
|
23
|
+
:allowed_domains
|
|
17
24
|
|
|
18
25
|
# @param start_step_id [Symbol] id of the initial step
|
|
19
26
|
# @param nodes [Hash<Symbol, Node>] all steps keyed by id
|
|
@@ -21,14 +28,20 @@ module Inquirex
|
|
|
21
28
|
# @param version [String] semver
|
|
22
29
|
# @param meta [Hash] frontend metadata
|
|
23
30
|
# @param accumulators [Hash<Symbol, Accumulator>] named running totals
|
|
31
|
+
# @param actions [Array<Actions::Action>] post-completion actions, in order
|
|
32
|
+
# @param allowed_domains [Array<String>] hosts outbound effects (webhook)
|
|
33
|
+
# may send answers to; "example.com" exact, "*.example.com" subdomains
|
|
24
34
|
# @raise [Errors::DefinitionError] if start_step_id is not present in nodes
|
|
25
|
-
def initialize(start_step_id:, nodes:, id: nil, version: "1.0.0", meta: {},
|
|
35
|
+
def initialize(start_step_id:, nodes:, id: nil, version: "1.0.0", meta: {},
|
|
36
|
+
accumulators: {}, actions: [], allowed_domains: [])
|
|
26
37
|
@id = id
|
|
27
38
|
@version = version
|
|
28
39
|
@meta = meta.freeze
|
|
29
40
|
@start_step_id = start_step_id.to_sym
|
|
30
41
|
@steps = nodes.freeze
|
|
31
42
|
@accumulators = accumulators.freeze
|
|
43
|
+
@actions = actions.freeze
|
|
44
|
+
@allowed_domains = normalize_domains(allowed_domains)
|
|
32
45
|
validate!
|
|
33
46
|
freeze
|
|
34
47
|
end
|
|
@@ -50,6 +63,25 @@ module Inquirex
|
|
|
50
63
|
@steps.keys
|
|
51
64
|
end
|
|
52
65
|
|
|
66
|
+
# Whether a host is covered by the allowed_domains declaration.
|
|
67
|
+
# "example.com" matches that host exactly; "*.example.com" matches any
|
|
68
|
+
# subdomain but not the apex. Matching is case-insensitive; an empty
|
|
69
|
+
# allowlist allows nothing.
|
|
70
|
+
#
|
|
71
|
+
# @example With allowed_domains ["*.example.com"]
|
|
72
|
+
# definition.allowed_host?("api.example.com") # => true
|
|
73
|
+
# definition.allowed_host?("API.EXAMPLE.COM") # => true (case-insensitive)
|
|
74
|
+
# definition.allowed_host?("example.com") # => false (wildcard excludes the apex)
|
|
75
|
+
#
|
|
76
|
+
# @param host [String]
|
|
77
|
+
# @return [Boolean]
|
|
78
|
+
def allowed_host?(host)
|
|
79
|
+
target = host.to_s.downcase
|
|
80
|
+
@allowed_domains.any? do |entry|
|
|
81
|
+
entry.start_with?("*.") ? target.end_with?(entry[1..]) : target == entry
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
53
85
|
# Serializes the definition to a JSON string.
|
|
54
86
|
# Lambdas (default procs, compute blocks) are silently stripped.
|
|
55
87
|
#
|
|
@@ -66,6 +98,7 @@ module Inquirex
|
|
|
66
98
|
hash["id"] = @id if @id
|
|
67
99
|
hash["version"] = @version
|
|
68
100
|
hash["meta"] = @meta unless @meta.empty?
|
|
101
|
+
hash["allowed_domains"] = @allowed_domains unless @allowed_domains.empty?
|
|
69
102
|
hash["start"] = @start_step_id.to_s
|
|
70
103
|
unless @accumulators.empty?
|
|
71
104
|
hash["accumulators"] = @accumulators.each_with_object({}) do |(name, acc), h|
|
|
@@ -73,6 +106,8 @@ module Inquirex
|
|
|
73
106
|
end
|
|
74
107
|
end
|
|
75
108
|
hash["steps"] = @steps.transform_keys(&:to_s).transform_values(&:to_h)
|
|
109
|
+
serializable_actions = @actions.select(&:serializable?)
|
|
110
|
+
hash["actions"] = serializable_actions.map(&:to_h) unless serializable_actions.empty?
|
|
76
111
|
hash
|
|
77
112
|
end
|
|
78
113
|
|
|
@@ -97,6 +132,8 @@ module Inquirex
|
|
|
97
132
|
start = hash["start"] || hash[:start]
|
|
98
133
|
steps_data = hash["steps"] || hash[:steps] || {}
|
|
99
134
|
acc_data = hash["accumulators"] || hash[:accumulators] || {}
|
|
135
|
+
actions_data = hash["actions"] || hash[:actions] || []
|
|
136
|
+
domains = hash["allowed_domains"] || hash[:allowed_domains] || []
|
|
100
137
|
|
|
101
138
|
nodes = steps_data.each_with_object({}) do |(step_id, step_hash), acc|
|
|
102
139
|
sym_id = step_id.to_sym
|
|
@@ -108,15 +145,40 @@ module Inquirex
|
|
|
108
145
|
h[sym] = Accumulator.from_h(sym, entry)
|
|
109
146
|
end
|
|
110
147
|
|
|
111
|
-
|
|
148
|
+
actions = actions_data.map { |entry| Actions::Action.from_h(entry) }
|
|
149
|
+
|
|
150
|
+
new(start_step_id: start,
|
|
151
|
+
nodes:,
|
|
152
|
+
id:,
|
|
153
|
+
version:,
|
|
154
|
+
meta:,
|
|
155
|
+
accumulators:,
|
|
156
|
+
actions:,
|
|
157
|
+
allowed_domains: domains)
|
|
112
158
|
end
|
|
113
159
|
|
|
114
160
|
private
|
|
115
161
|
|
|
116
162
|
def validate!
|
|
117
|
-
|
|
163
|
+
unless @steps.key?(@start_step_id)
|
|
164
|
+
raise Errors::DefinitionError, "Start step #{@start_step_id.inspect} not found in steps"
|
|
165
|
+
end
|
|
118
166
|
|
|
119
|
-
|
|
167
|
+
@actions.each do |action|
|
|
168
|
+
action.effects.each { |effect| effect.validate_against(self) }
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def normalize_domains(domains)
|
|
173
|
+
domains.map do |entry|
|
|
174
|
+
domain = entry.to_s.strip.downcase
|
|
175
|
+
if domain.empty? || domain == "*" || domain.match?(%r{[/\s:@]})
|
|
176
|
+
raise Errors::DefinitionError,
|
|
177
|
+
"allowed_domains entries must be bare domains like \"example.com\" " \
|
|
178
|
+
"or \"*.example.com\", got #{entry.inspect}"
|
|
179
|
+
end
|
|
180
|
+
domain
|
|
181
|
+
end.freeze
|
|
120
182
|
end
|
|
121
183
|
end
|
|
122
184
|
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module DSL
|
|
5
|
+
# Builds an Actions::Action from an `action` DSL block. Every effect verb
|
|
6
|
+
# registered in Inquirex::Actions (send_email, plus anything host gems
|
|
7
|
+
# register) is available as a method automatically; `run` wraps arbitrary
|
|
8
|
+
# Ruby in an Actions::Custom effect.
|
|
9
|
+
#
|
|
10
|
+
# action :admin_alert do
|
|
11
|
+
# send_email to: "admin@example.com", subject: "New lead: {{name}}",
|
|
12
|
+
# html: "{{answers_summary}}"
|
|
13
|
+
# run { |answers, outbox| Metrics.count(:lead, answers.to_flat_h) }
|
|
14
|
+
# end
|
|
15
|
+
class ActionBuilder
|
|
16
|
+
def initialize
|
|
17
|
+
@effects = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Escape hatch: arbitrary server-side Ruby. Stripped from JSON.
|
|
21
|
+
#
|
|
22
|
+
# @yield [answers, outbox]
|
|
23
|
+
def run(&block)
|
|
24
|
+
raise Errors::DefinitionError, "run requires a block" unless block
|
|
25
|
+
|
|
26
|
+
@effects << Actions::Custom.new(block)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Registered effect verbs (send_email, ...) resolve dynamically so that
|
|
30
|
+
# newly registered effect types become DSL words without core changes.
|
|
31
|
+
def method_missing(name, *args, **params, &)
|
|
32
|
+
return super unless Actions.registered?(name)
|
|
33
|
+
|
|
34
|
+
raise Errors::DefinitionError, "#{name} takes keyword arguments only" unless args.empty?
|
|
35
|
+
|
|
36
|
+
@effects << Actions.lookup(name).new(**params)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def respond_to_missing?(name, include_private = false)
|
|
40
|
+
Actions.registered?(name) || super
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param id [Symbol]
|
|
44
|
+
# @param rule [Rules::Base, nil]
|
|
45
|
+
# @return [Actions::Action]
|
|
46
|
+
def build(id, rule: nil)
|
|
47
|
+
raise Errors::DefinitionError, "action #{id.inspect} declares no effects" if @effects.empty?
|
|
48
|
+
|
|
49
|
+
Actions::Action.new(id:, effects: @effects, rule:)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -16,6 +16,18 @@ module Inquirex
|
|
|
16
16
|
@nodes = {}
|
|
17
17
|
@meta = {}
|
|
18
18
|
@accumulators = {}
|
|
19
|
+
@actions = []
|
|
20
|
+
@allowed_domains = []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Declares the domains outbound effects (webhook) may send answers to.
|
|
24
|
+
# Conventionally the first declaration in a definition, so the flow's
|
|
25
|
+
# egress surface is auditable at a glance. "example.com" matches that
|
|
26
|
+
# host exactly; "*.example.com" matches its subdomains.
|
|
27
|
+
#
|
|
28
|
+
# @param domains [Array<String>]
|
|
29
|
+
def allowed_domains(*domains)
|
|
30
|
+
@allowed_domains.concat(domains.flatten)
|
|
19
31
|
end
|
|
20
32
|
|
|
21
33
|
# Declares a named running total the flow accumulates into as answers come in.
|
|
@@ -104,6 +116,35 @@ module Inquirex
|
|
|
104
116
|
add_step(id, :confirm, &)
|
|
105
117
|
end
|
|
106
118
|
|
|
119
|
+
# Declares a named post-completion action: effects (send_email, run, ...)
|
|
120
|
+
# executed server-side after the flow finishes, with the collected
|
|
121
|
+
# answers. Runs in declaration order; gate with a serializable rule via
|
|
122
|
+
# the if: option.
|
|
123
|
+
#
|
|
124
|
+
# @example Email the collected answers when business income was selected
|
|
125
|
+
# action :notify_sales, if: Rules::Contains.new(:income_types, "Business") do
|
|
126
|
+
# send_email to: "sales@example.com",
|
|
127
|
+
# subject: "New lead: {{name}}",
|
|
128
|
+
# html: "{{answers_summary}}"
|
|
129
|
+
# end
|
|
130
|
+
#
|
|
131
|
+
# @param id [Symbol] action identifier
|
|
132
|
+
# @param opts [Hash] only if: is recognized — a Rules::Base gate
|
|
133
|
+
# @yield block evaluated in ActionBuilder (send_email, run, ...)
|
|
134
|
+
def action(id, **opts, &block)
|
|
135
|
+
rule = opts.delete(:if)
|
|
136
|
+
raise Errors::DefinitionError, "Unknown action options: #{opts.keys.inspect}" unless opts.empty?
|
|
137
|
+
|
|
138
|
+
sym = id.to_sym
|
|
139
|
+
if @actions.any? { |a| a.id == sym }
|
|
140
|
+
raise Errors::DefinitionError, "Duplicate action id: #{sym.inspect}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
builder = ActionBuilder.new
|
|
144
|
+
builder.instance_eval(&block) if block
|
|
145
|
+
@actions << builder.build(sym, rule:)
|
|
146
|
+
end
|
|
147
|
+
|
|
107
148
|
# Produces the frozen Definition.
|
|
108
149
|
#
|
|
109
150
|
# @return [Definition]
|
|
@@ -113,12 +154,14 @@ module Inquirex
|
|
|
113
154
|
raise Errors::DefinitionError, "No steps defined" if @nodes.empty?
|
|
114
155
|
|
|
115
156
|
Definition.new(
|
|
116
|
-
start_step_id:
|
|
117
|
-
nodes:
|
|
118
|
-
id:
|
|
119
|
-
version:
|
|
120
|
-
meta:
|
|
121
|
-
accumulators:
|
|
157
|
+
start_step_id: @start_step_id,
|
|
158
|
+
nodes: @nodes,
|
|
159
|
+
id: @flow_id,
|
|
160
|
+
version: @flow_version,
|
|
161
|
+
meta: @meta,
|
|
162
|
+
accumulators: @accumulators,
|
|
163
|
+
actions: @actions,
|
|
164
|
+
allowed_domains: @allowed_domains
|
|
122
165
|
)
|
|
123
166
|
end
|
|
124
167
|
|
|
@@ -5,11 +5,13 @@ module Inquirex
|
|
|
5
5
|
# Handles state serialization for Engine persistence (DB, session store, etc.).
|
|
6
6
|
# Normalizes string-keyed hashes (from JSON round-trips) to symbol-keyed hashes.
|
|
7
7
|
module StateSerializer
|
|
8
|
+
# Per-key normalizers applied by .symbolize_state; unlisted keys pass through unchanged.
|
|
8
9
|
SYMBOLIZERS = {
|
|
9
|
-
current_step_id:
|
|
10
|
-
history:
|
|
11
|
-
answers:
|
|
12
|
-
totals:
|
|
10
|
+
current_step_id: ->(v) { v&.to_sym },
|
|
11
|
+
history: ->(v) { Array(v).map { |e| e&.to_sym } },
|
|
12
|
+
answers: ->(v) { symbolize_answers(v) },
|
|
13
|
+
totals: ->(v) { symbolize_answers(v) },
|
|
14
|
+
completion_metadata: ->(v) { symbolize_answers(v) }
|
|
13
15
|
}.freeze
|
|
14
16
|
|
|
15
17
|
# Normalizes a state hash so step ids and history entries are symbols.
|
data/lib/inquirex/engine.rb
CHANGED
|
@@ -12,6 +12,23 @@ module Inquirex
|
|
|
12
12
|
class Engine
|
|
13
13
|
attr_reader :definition, :answers, :history, :current_step_id, :totals
|
|
14
14
|
|
|
15
|
+
# Metadata describing how and where the flow was completed. Rendering
|
|
16
|
+
# front-ends (TTY, web, chat widget) attach a rich version from an
|
|
17
|
+
# after_completion hook; when no hook provides one, the engine stamps a
|
|
18
|
+
# minimal core version the moment the flow finishes. Only :engine and
|
|
19
|
+
# :engine_version are required members; everything else is free-form.
|
|
20
|
+
#
|
|
21
|
+
# @return [CompletionMetadata, nil] nil until the flow finishes
|
|
22
|
+
attr_accessor :completion_metadata
|
|
23
|
+
|
|
24
|
+
# Exceptions raised by after_completion hooks, in the order they were
|
|
25
|
+
# raised. Hooks are isolated from one another, so a raising hook is
|
|
26
|
+
# recorded here rather than propagated — callers that care can inspect
|
|
27
|
+
# this after the flow finishes. Empty when every hook succeeded.
|
|
28
|
+
#
|
|
29
|
+
# @return [Array<StandardError>]
|
|
30
|
+
attr_reader :completion_hook_errors
|
|
31
|
+
|
|
15
32
|
# @param definition [Definition] the flow to run
|
|
16
33
|
# @param validator [Validation::Adapter] optional (default: NullAdapter)
|
|
17
34
|
def initialize(definition, validator: Validation::NullAdapter.new)
|
|
@@ -21,6 +38,9 @@ module Inquirex
|
|
|
21
38
|
@current_step_id = definition.start_step_id
|
|
22
39
|
@validator = validator
|
|
23
40
|
@totals = init_totals
|
|
41
|
+
@completion_metadata = nil
|
|
42
|
+
@after_completion_hooks = []
|
|
43
|
+
@completion_hook_errors = []
|
|
24
44
|
@history << @current_step_id
|
|
25
45
|
skip_display_steps_if_needed
|
|
26
46
|
end
|
|
@@ -101,18 +121,62 @@ module Inquirex
|
|
|
101
121
|
@answers
|
|
102
122
|
end
|
|
103
123
|
|
|
124
|
+
# Registers a hook to run when the flow finishes. The block receives the
|
|
125
|
+
# engine; front-ends typically use it to attach a rich
|
|
126
|
+
# completion_metadata (host, user, ips, terminal, ...). Optional — after
|
|
127
|
+
# all hooks run, the engine fills in a minimal CompletionMetadata when
|
|
128
|
+
# none of them provided one. Registering on an already-finished engine
|
|
129
|
+
# invokes the block immediately.
|
|
130
|
+
#
|
|
131
|
+
# Any number of hooks may be registered; they run in registration order.
|
|
132
|
+
# Each is isolated from the others — a hook that raises a StandardError
|
|
133
|
+
# has it recorded in #completion_hook_errors, and the remaining hooks
|
|
134
|
+
# still run. Non-StandardError exceptions (Interrupt, SignalException)
|
|
135
|
+
# propagate, as they should.
|
|
136
|
+
#
|
|
137
|
+
# @example Stamp renderer-specific completion metadata
|
|
138
|
+
# engine.after_completion do |eng|
|
|
139
|
+
# eng.completion_metadata = Inquirex::CompletionMetadata.new(
|
|
140
|
+
# engine: "inquirex-tty", engine_version: "0.5.0", hostname: Socket.gethostname
|
|
141
|
+
# )
|
|
142
|
+
# end
|
|
143
|
+
#
|
|
144
|
+
# @yield [Engine] the engine, at completion time
|
|
145
|
+
# @return [Engine] self
|
|
146
|
+
def after_completion(&block)
|
|
147
|
+
raise ArgumentError, "after_completion requires a block" unless block
|
|
148
|
+
|
|
149
|
+
@after_completion_hooks << block
|
|
150
|
+
if finished?
|
|
151
|
+
invoke_completion_hook(block)
|
|
152
|
+
ensure_completion_metadata
|
|
153
|
+
end
|
|
154
|
+
self
|
|
155
|
+
end
|
|
156
|
+
|
|
104
157
|
# Serializable state snapshot for persistence or resumption.
|
|
105
158
|
#
|
|
106
159
|
# @return [Hash]
|
|
107
160
|
def to_state
|
|
108
161
|
{
|
|
109
|
-
current_step_id:
|
|
110
|
-
answers:
|
|
111
|
-
history:
|
|
112
|
-
totals:
|
|
162
|
+
current_step_id: @current_step_id,
|
|
163
|
+
answers: @answers,
|
|
164
|
+
history: @history,
|
|
165
|
+
totals: @totals,
|
|
166
|
+
completion_metadata: @completion_metadata&.to_h
|
|
113
167
|
}
|
|
114
168
|
end
|
|
115
169
|
|
|
170
|
+
# The collected answers with the completion metadata (when a renderer
|
|
171
|
+
# attached one) merged in under the :completion_metadata key.
|
|
172
|
+
#
|
|
173
|
+
# @return [Hash]
|
|
174
|
+
def answers_with_metadata
|
|
175
|
+
return @answers if @completion_metadata.nil?
|
|
176
|
+
|
|
177
|
+
@answers.merge(completion_metadata: @completion_metadata.to_h)
|
|
178
|
+
end
|
|
179
|
+
|
|
116
180
|
# Rebuilds an Engine from a previously saved state.
|
|
117
181
|
#
|
|
118
182
|
# @param definition [Definition] same definition used when state was captured
|
|
@@ -135,6 +199,9 @@ module Inquirex
|
|
|
135
199
|
@answers = state[:answers] || {}
|
|
136
200
|
@history = state[:history] || []
|
|
137
201
|
@totals = state[:totals] || init_totals
|
|
202
|
+
@completion_metadata = CompletionMetadata.from_h(state[:completion_metadata])
|
|
203
|
+
@after_completion_hooks = []
|
|
204
|
+
@completion_hook_errors = []
|
|
138
205
|
end
|
|
139
206
|
|
|
140
207
|
def init_totals
|
|
@@ -154,12 +221,37 @@ module Inquirex
|
|
|
154
221
|
node = @definition.step(@current_step_id)
|
|
155
222
|
next_id = node.next_step_id(@answers)
|
|
156
223
|
@current_step_id = next_id
|
|
157
|
-
return unless next_id
|
|
224
|
+
return run_after_completion_hooks unless next_id
|
|
158
225
|
|
|
159
226
|
@history << next_id
|
|
160
227
|
skip_if_needed
|
|
161
228
|
end
|
|
162
229
|
|
|
230
|
+
# Fires the moment the flow finishes: runs registered after_completion
|
|
231
|
+
# hooks in registration order, then guarantees completion_metadata exists
|
|
232
|
+
# — hooks may attach a rich version; absent that (including when the hook
|
|
233
|
+
# meant to supply it raised), a minimal core-stamped one is used.
|
|
234
|
+
def run_after_completion_hooks
|
|
235
|
+
@after_completion_hooks.each { |hook| invoke_completion_hook(hook) }
|
|
236
|
+
ensure_completion_metadata
|
|
237
|
+
nil
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Runs one hook in isolation. A StandardError is recorded rather than
|
|
241
|
+
# propagated so that one misbehaving hook cannot silently cancel the
|
|
242
|
+
# hooks registered after it, nor abort the completion of the flow itself.
|
|
243
|
+
def invoke_completion_hook(hook)
|
|
244
|
+
hook.call(self)
|
|
245
|
+
rescue StandardError => e
|
|
246
|
+
@completion_hook_errors << e
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def ensure_completion_metadata
|
|
250
|
+
return if @completion_metadata
|
|
251
|
+
|
|
252
|
+
@completion_metadata = CompletionMetadata.new(engine: "inquirex", engine_version: VERSION)
|
|
253
|
+
end
|
|
254
|
+
|
|
163
255
|
# Auto-skips the current step if its skip_if rule is satisfied.
|
|
164
256
|
def skip_if_needed
|
|
165
257
|
return if finished?
|
data/lib/inquirex/errors.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Inquirex
|
|
4
|
+
# Namespace for all Inquirex exception classes.
|
|
4
5
|
module Errors
|
|
5
6
|
# Base exception for all Inquirex errors.
|
|
6
7
|
class Error < StandardError; end
|
|
@@ -26,5 +27,9 @@ module Inquirex
|
|
|
26
27
|
|
|
27
28
|
# Raised when serializing or deserializing a Definition to/from JSON fails.
|
|
28
29
|
class SerializationError < Error; end
|
|
30
|
+
|
|
31
|
+
# Raised when a post-completion action cannot execute structurally,
|
|
32
|
+
# e.g. send_email is used without the mail gem installed.
|
|
33
|
+
class ActionError < Error; end
|
|
29
34
|
end
|
|
30
35
|
end
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Inquirex
|
|
4
|
+
# Namespace for flow graph export utilities (Mermaid, ...).
|
|
4
5
|
module Graph
|
|
5
6
|
# Exports a flow Definition to Mermaid flowchart syntax for visualization.
|
|
6
7
|
# Node labels show verb + truncated question/text; edges show condition labels.
|
|
7
8
|
class MermaidExporter
|
|
9
|
+
# Maximum characters of a node label before it is truncated with "...".
|
|
8
10
|
MAX_LABEL_LENGTH = 50
|
|
9
11
|
|
|
10
12
|
attr_reader :definition
|
data/lib/inquirex/node.rb
CHANGED
|
@@ -27,7 +27,9 @@ module Inquirex
|
|
|
27
27
|
class Node
|
|
28
28
|
# Valid DSL verbs and which ones collect input from the user.
|
|
29
29
|
VERBS = %i[ask say header btw warning confirm].freeze
|
|
30
|
+
# Verbs that collect input from the user.
|
|
30
31
|
COLLECTING_VERBS = %i[ask confirm].freeze
|
|
32
|
+
# Verbs that only display content and auto-advance.
|
|
31
33
|
DISPLAY_VERBS = %i[say header btw warning].freeze
|
|
32
34
|
|
|
33
35
|
# Valid data types for :ask steps.
|
data/lib/inquirex/rules/all.rb
CHANGED
|
@@ -1,29 +1,41 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Inquirex
|
|
4
|
+
# Namespace for rule AST nodes used in transitions and visibility conditions.
|
|
4
5
|
module Rules
|
|
5
6
|
# Composite rule: logical AND of multiple sub-rules. All must be true.
|
|
6
7
|
class All < Base
|
|
7
8
|
attr_reader :rules
|
|
8
9
|
|
|
10
|
+
# @param rules [Array<Rules::Base>] sub-rules that must all hold
|
|
9
11
|
def initialize(*rules)
|
|
10
12
|
super()
|
|
11
13
|
@rules = rules.flatten.freeze
|
|
12
14
|
freeze
|
|
13
15
|
end
|
|
14
16
|
|
|
17
|
+
# True when every sub-rule evaluates true (vacuously true when empty).
|
|
18
|
+
#
|
|
19
|
+
# @param answers [Hash{Symbol => Object}] answer context, step_id => value
|
|
20
|
+
# @return [Boolean]
|
|
15
21
|
def evaluate(answers)
|
|
16
22
|
@rules.all? { |rule| rule.evaluate(answers) }
|
|
17
23
|
end
|
|
18
24
|
|
|
25
|
+
# @return [Hash{String => Object}] wire format with nested rule hashes
|
|
19
26
|
def to_h
|
|
20
27
|
{ "op" => "all", "rules" => @rules.map(&:to_h) }
|
|
21
28
|
end
|
|
22
29
|
|
|
30
|
+
# @return [String] human-readable form, sub-rules joined with AND
|
|
23
31
|
def to_s
|
|
24
32
|
"(#{@rules.join(" AND ")})"
|
|
25
33
|
end
|
|
26
34
|
|
|
35
|
+
# Deserializes an All rule, recursively rehydrating its sub-rules.
|
|
36
|
+
#
|
|
37
|
+
# @param hash [Hash] rule hash with string or symbol keys
|
|
38
|
+
# @return [All]
|
|
27
39
|
def self.from_h(hash)
|
|
28
40
|
raw_rules = hash["rules"] || hash[:rules] || []
|
|
29
41
|
new(*raw_rules.map { |r| Base.from_h(r) })
|
data/lib/inquirex/rules/any.rb
CHANGED
|
@@ -6,24 +6,35 @@ module Inquirex
|
|
|
6
6
|
class Any < Base
|
|
7
7
|
attr_reader :rules
|
|
8
8
|
|
|
9
|
+
# @param rules [Array<Rules::Base>] sub-rules, at least one of which must hold
|
|
9
10
|
def initialize(*rules)
|
|
10
11
|
super()
|
|
11
12
|
@rules = rules.flatten.freeze
|
|
12
13
|
freeze
|
|
13
14
|
end
|
|
14
15
|
|
|
16
|
+
# True when at least one sub-rule evaluates true (false when empty).
|
|
17
|
+
#
|
|
18
|
+
# @param answers [Hash{Symbol => Object}] answer context, step_id => value
|
|
19
|
+
# @return [Boolean]
|
|
15
20
|
def evaluate(answers)
|
|
16
21
|
@rules.any? { |rule| rule.evaluate(answers) }
|
|
17
22
|
end
|
|
18
23
|
|
|
24
|
+
# @return [Hash{String => Object}] wire format with nested rule hashes
|
|
19
25
|
def to_h
|
|
20
26
|
{ "op" => "any", "rules" => @rules.map(&:to_h) }
|
|
21
27
|
end
|
|
22
28
|
|
|
29
|
+
# @return [String] human-readable form, sub-rules joined with OR
|
|
23
30
|
def to_s
|
|
24
31
|
"(#{@rules.join(" OR ")})"
|
|
25
32
|
end
|
|
26
33
|
|
|
34
|
+
# Deserializes an Any rule, recursively rehydrating its sub-rules.
|
|
35
|
+
#
|
|
36
|
+
# @param hash [Hash] rule hash with string or symbol keys
|
|
37
|
+
# @return [Any]
|
|
27
38
|
def self.from_h(hash)
|
|
28
39
|
raw_rules = hash["rules"] || hash[:rules] || []
|
|
29
40
|
new(*raw_rules.map { |r| Base.from_h(r) })
|
data/lib/inquirex/rules/base.rb
CHANGED
|
@@ -30,6 +30,12 @@ module Inquirex
|
|
|
30
30
|
|
|
31
31
|
# Deserializes a rule from a plain Hash (e.g. parsed from JSON).
|
|
32
32
|
#
|
|
33
|
+
# @example Rehydrate a rule from its wire format
|
|
34
|
+
# rule = Inquirex::Rules::Base.from_h(
|
|
35
|
+
# "op" => "equals", "field" => "filing_status", "value" => "single"
|
|
36
|
+
# )
|
|
37
|
+
# rule.evaluate(filing_status: "single") # => true
|
|
38
|
+
#
|
|
33
39
|
# @param hash [Hash] rule hash with string or symbol keys
|
|
34
40
|
# @return [Rules::Base] the deserialized rule
|
|
35
41
|
# @raise [Inquirex::Errors::SerializationError] on unknown operator
|
|
@@ -7,6 +7,8 @@ module Inquirex
|
|
|
7
7
|
class Contains < Base
|
|
8
8
|
attr_reader :field, :value
|
|
9
9
|
|
|
10
|
+
# @param field [Symbol, String] step id whose answer is inspected
|
|
11
|
+
# @param value [Object] value the answer must include
|
|
10
12
|
def initialize(field, value)
|
|
11
13
|
super()
|
|
12
14
|
@field = field.to_sym
|
|
@@ -14,18 +16,28 @@ module Inquirex
|
|
|
14
16
|
freeze
|
|
15
17
|
end
|
|
16
18
|
|
|
19
|
+
# True when the field's answer, coerced to an Array, includes the value.
|
|
20
|
+
#
|
|
21
|
+
# @param answers [Hash{Symbol => Object}] answer context, step_id => value
|
|
22
|
+
# @return [Boolean]
|
|
17
23
|
def evaluate(answers)
|
|
18
24
|
Array(answers[@field]).include?(@value)
|
|
19
25
|
end
|
|
20
26
|
|
|
27
|
+
# @return [Hash{String => Object}] wire format, same shape .from_h accepts
|
|
21
28
|
def to_h
|
|
22
29
|
{ "op" => "contains", "field" => @field.to_s, "value" => @value }
|
|
23
30
|
end
|
|
24
31
|
|
|
32
|
+
# @return [String] human-readable form, e.g. "Business in income_types"
|
|
25
33
|
def to_s
|
|
26
34
|
"#{@value} in #{@field}"
|
|
27
35
|
end
|
|
28
36
|
|
|
37
|
+
# Deserializes a Contains rule from a plain Hash.
|
|
38
|
+
#
|
|
39
|
+
# @param hash [Hash] rule hash with string or symbol keys
|
|
40
|
+
# @return [Contains]
|
|
29
41
|
def self.from_h(hash)
|
|
30
42
|
field = hash["field"] || hash[:field]
|
|
31
43
|
value = hash["value"] || hash[:value]
|
|
@@ -6,6 +6,8 @@ module Inquirex
|
|
|
6
6
|
class Equals < Base
|
|
7
7
|
attr_reader :field, :value
|
|
8
8
|
|
|
9
|
+
# @param field [Symbol, String] step id whose answer is compared
|
|
10
|
+
# @param value [Object] value the answer must equal
|
|
9
11
|
def initialize(field, value)
|
|
10
12
|
super()
|
|
11
13
|
@field = field.to_sym
|
|
@@ -13,18 +15,28 @@ module Inquirex
|
|
|
13
15
|
freeze
|
|
14
16
|
end
|
|
15
17
|
|
|
18
|
+
# True when the field's answer equals the value (Ruby ==).
|
|
19
|
+
#
|
|
20
|
+
# @param answers [Hash{Symbol => Object}] answer context, step_id => value
|
|
21
|
+
# @return [Boolean]
|
|
16
22
|
def evaluate(answers)
|
|
17
23
|
answers[@field] == @value
|
|
18
24
|
end
|
|
19
25
|
|
|
26
|
+
# @return [Hash{String => Object}] wire format, same shape .from_h accepts
|
|
20
27
|
def to_h
|
|
21
28
|
{ "op" => "equals", "field" => @field.to_s, "value" => @value }
|
|
22
29
|
end
|
|
23
30
|
|
|
31
|
+
# @return [String] human-readable form, e.g. "filing_status == single"
|
|
24
32
|
def to_s
|
|
25
33
|
"#{@field} == #{@value}"
|
|
26
34
|
end
|
|
27
35
|
|
|
36
|
+
# Deserializes an Equals rule from a plain Hash.
|
|
37
|
+
#
|
|
38
|
+
# @param hash [Hash] rule hash with string or symbol keys
|
|
39
|
+
# @return [Equals]
|
|
28
40
|
def self.from_h(hash)
|
|
29
41
|
field = hash["field"] || hash[:field]
|
|
30
42
|
value = hash["value"] || hash[:value]
|
|
@@ -6,6 +6,8 @@ module Inquirex
|
|
|
6
6
|
class GreaterThan < Base
|
|
7
7
|
attr_reader :field, :value
|
|
8
8
|
|
|
9
|
+
# @param field [Symbol, String] step id whose answer is compared
|
|
10
|
+
# @param value [Integer] threshold the answer must exceed
|
|
9
11
|
def initialize(field, value)
|
|
10
12
|
super()
|
|
11
13
|
@field = field.to_sym
|
|
@@ -13,18 +15,28 @@ module Inquirex
|
|
|
13
15
|
freeze
|
|
14
16
|
end
|
|
15
17
|
|
|
18
|
+
# True when the field's answer, coerced to an Integer, exceeds the threshold.
|
|
19
|
+
#
|
|
20
|
+
# @param answers [Hash{Symbol => Object}] answer context, step_id => value
|
|
21
|
+
# @return [Boolean]
|
|
16
22
|
def evaluate(answers)
|
|
17
23
|
answers[@field].to_i > @value
|
|
18
24
|
end
|
|
19
25
|
|
|
26
|
+
# @return [Hash{String => Object}] wire format, same shape .from_h accepts
|
|
20
27
|
def to_h
|
|
21
28
|
{ "op" => "greater_than", "field" => @field.to_s, "value" => @value }
|
|
22
29
|
end
|
|
23
30
|
|
|
31
|
+
# @return [String] human-readable form, e.g. "dependents > 2"
|
|
24
32
|
def to_s
|
|
25
33
|
"#{@field} > #{@value}"
|
|
26
34
|
end
|
|
27
35
|
|
|
36
|
+
# Deserializes a GreaterThan rule from a plain Hash.
|
|
37
|
+
#
|
|
38
|
+
# @param hash [Hash] rule hash with string or symbol keys
|
|
39
|
+
# @return [GreaterThan]
|
|
28
40
|
def self.from_h(hash)
|
|
29
41
|
field = hash["field"] || hash[:field]
|
|
30
42
|
value = (hash["value"] || hash[:value]).to_i
|