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
|
@@ -16,28 +16,24 @@ module Inquirex
|
|
|
16
16
|
@nodes = {}
|
|
17
17
|
@meta = {}
|
|
18
18
|
@accumulators = {}
|
|
19
|
-
@
|
|
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
|
+
@send_emails = []
|
|
31
20
|
end
|
|
32
21
|
|
|
33
22
|
# Declares a named running total the flow accumulates into as answers come in.
|
|
34
23
|
# The `:price` accumulator is the common lead-qualification use case; others
|
|
35
24
|
# (e.g. :complexity, :credit_score) work identically.
|
|
36
25
|
#
|
|
37
|
-
#
|
|
26
|
+
# A `:text` accumulator is filled by the engine rather than by
|
|
27
|
+
# `accumulate` declarations: it collects everything the user was shown
|
|
28
|
+
# and every answer they gave, which is what an LLM `summarize` step
|
|
29
|
+
# reads. See {Accumulator}.
|
|
30
|
+
#
|
|
31
|
+
# @param name [Symbol] e.g. :price, :transcript
|
|
38
32
|
# @param type [Symbol] one of Node::TYPES (default :currency-ish: :decimal)
|
|
39
|
-
# @param default [Numeric] starting value (default
|
|
40
|
-
|
|
33
|
+
# @param default [Numeric, String, nil] starting value; nil (the default)
|
|
34
|
+
# takes the type's own zero, so a :text accumulator starts at "" and
|
|
35
|
+
# every other kind starts at 0
|
|
36
|
+
def accumulator(name, type: :decimal, default: nil)
|
|
41
37
|
sym = name.to_sym
|
|
42
38
|
@accumulators[sym] = Accumulator.new(name: sym, type:, default:)
|
|
43
39
|
end
|
|
@@ -116,33 +112,45 @@ module Inquirex
|
|
|
116
112
|
add_step(id, :confirm, &)
|
|
117
113
|
end
|
|
118
114
|
|
|
119
|
-
# Declares
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
# the
|
|
115
|
+
# Declares an email the host application builds and delivers after the
|
|
116
|
+
# flow finishes, from the collected answers. Declarations run in order;
|
|
117
|
+
# gate with a serializable rule via the if: option. This is the only
|
|
118
|
+
# completion declaration the core DSL carries — richer post-completion
|
|
119
|
+
# behavior belongs to the host application.
|
|
120
|
+
#
|
|
121
|
+
# @example Receipt sent only when the visitor left an email address
|
|
122
|
+
# send_email if: not_empty(:email) do
|
|
123
|
+
# to "{{email}}"
|
|
124
|
+
# from "forms@agentica.group"
|
|
125
|
+
# subject "Thanks {{name}} — we got your inquiry"
|
|
126
|
+
# markdown_text <<~TEXT
|
|
127
|
+
# Hi {{name}},
|
|
123
128
|
#
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
-
# html: "{{answers_summary}}"
|
|
129
|
+
# We received your answers and will reply within one business day.
|
|
130
|
+
#
|
|
131
|
+
# {{answers_summary}}
|
|
132
|
+
# TEXT
|
|
129
133
|
# end
|
|
130
134
|
#
|
|
131
|
-
# @param
|
|
132
|
-
#
|
|
133
|
-
# @yield block evaluated in
|
|
134
|
-
|
|
135
|
+
# @param opts [Hash] if: takes a Rules::Base gate; any remaining keys are
|
|
136
|
+
# SendEmail fields (to:, subject:, text:, ...) for the inline form
|
|
137
|
+
# @yield block evaluated in SendEmailBuilder (to, from, subject, ...);
|
|
138
|
+
# block values override same-named inline keys
|
|
139
|
+
# @return [void]
|
|
140
|
+
# @raise [Errors::DefinitionError] on unknown fields or missing to:/subject:/body
|
|
141
|
+
def send_email(**opts, &block)
|
|
135
142
|
rule = opts.delete(:if)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
143
|
+
params = opts
|
|
144
|
+
if block
|
|
145
|
+
builder = SendEmailBuilder.new
|
|
146
|
+
builder.instance_eval(&block)
|
|
147
|
+
params = params.merge(builder.params)
|
|
148
|
+
end
|
|
149
|
+
begin
|
|
150
|
+
@send_emails << SendEmail.new(**params, rule:)
|
|
151
|
+
rescue ArgumentError => e
|
|
152
|
+
raise Errors::DefinitionError, "send_email: #{e.message}"
|
|
141
153
|
end
|
|
142
|
-
|
|
143
|
-
builder = ActionBuilder.new
|
|
144
|
-
builder.instance_eval(&block) if block
|
|
145
|
-
@actions << builder.build(sym, rule:)
|
|
146
154
|
end
|
|
147
155
|
|
|
148
156
|
# Produces the frozen Definition.
|
|
@@ -154,14 +162,13 @@ module Inquirex
|
|
|
154
162
|
raise Errors::DefinitionError, "No steps defined" if @nodes.empty?
|
|
155
163
|
|
|
156
164
|
Definition.new(
|
|
157
|
-
start_step_id:
|
|
158
|
-
nodes:
|
|
159
|
-
id:
|
|
160
|
-
version:
|
|
161
|
-
meta:
|
|
162
|
-
accumulators:
|
|
163
|
-
|
|
164
|
-
allowed_domains: @allowed_domains
|
|
165
|
+
start_step_id: @start_step_id,
|
|
166
|
+
nodes: @nodes,
|
|
167
|
+
id: @flow_id,
|
|
168
|
+
version: @flow_version,
|
|
169
|
+
meta: @meta,
|
|
170
|
+
accumulators: @accumulators,
|
|
171
|
+
send_emails: @send_emails
|
|
165
172
|
)
|
|
166
173
|
end
|
|
167
174
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module DSL
|
|
5
|
+
# Collects the fields of a `send_email` block. Every setter is an explicit
|
|
6
|
+
# builder method — the block form mirrors the step builders (`type`,
|
|
7
|
+
# `question`, ...) rather than a keyword-argument hash:
|
|
8
|
+
#
|
|
9
|
+
# send_email if: not_empty(:email) do
|
|
10
|
+
# to "{{email}}"
|
|
11
|
+
# from "forms@agentica.group"
|
|
12
|
+
# subject "Thanks {{name}}"
|
|
13
|
+
# markdown_text <<~TEXT
|
|
14
|
+
# Hi {{name}}, we got your answers:
|
|
15
|
+
#
|
|
16
|
+
# {{answers_summary}}
|
|
17
|
+
# TEXT
|
|
18
|
+
# end
|
|
19
|
+
class SendEmailBuilder
|
|
20
|
+
# @return [Hash{Symbol => Object}] collected SendEmail constructor params
|
|
21
|
+
attr_reader :params
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
@params = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param value [String] recipient template ({{field}} placeholders allowed)
|
|
28
|
+
# @return [void]
|
|
29
|
+
def to(value)
|
|
30
|
+
@params[:to] = value
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param value [String] sender template
|
|
34
|
+
# @return [void]
|
|
35
|
+
def from(value)
|
|
36
|
+
@params[:from] = value
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param value [String] carbon-copy template
|
|
40
|
+
# @return [void]
|
|
41
|
+
def cc(value)
|
|
42
|
+
@params[:cc] = value
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @param value [String] blind-carbon-copy template
|
|
46
|
+
# @return [void]
|
|
47
|
+
def bcc(value)
|
|
48
|
+
@params[:bcc] = value
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @param value [String] reply-to template
|
|
52
|
+
# @return [void]
|
|
53
|
+
def reply_to(value)
|
|
54
|
+
@params[:reply_to] = value
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @param value [String] subject template
|
|
58
|
+
# @return [void]
|
|
59
|
+
def subject(value)
|
|
60
|
+
@params[:subject] = value
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param value [Hash] extra headers (values support {{field}})
|
|
64
|
+
# @return [void]
|
|
65
|
+
def headers(value)
|
|
66
|
+
@params[:headers] = value
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @param value [String, Hash] plain-text body template or { file: "path" }
|
|
70
|
+
# @return [void]
|
|
71
|
+
def text(value)
|
|
72
|
+
@params[:text] = value
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @param value [String, Hash] Markdown body template or { file: "path" }
|
|
76
|
+
# @return [void]
|
|
77
|
+
def markdown_text(value)
|
|
78
|
+
@params[:markdown_text] = value
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @param value [String, Hash] HTML body template or { file: "path" }
|
|
82
|
+
# @return [void]
|
|
83
|
+
def html(value)
|
|
84
|
+
@params[:html] = value
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
data/lib/inquirex/engine.rb
CHANGED
|
@@ -74,6 +74,23 @@ module Inquirex
|
|
|
74
74
|
@totals[name.to_sym] || 0
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
+
# The running narrative of a `:text` accumulator — everything the user was
|
|
78
|
+
# shown and everything they answered, in the order it happened. This is
|
|
79
|
+
# what an LLM `summarize` step reads.
|
|
80
|
+
#
|
|
81
|
+
# @param name [Symbol] text accumulator name (e.g. :transcript)
|
|
82
|
+
# @return [String] empty when nothing has been captured yet
|
|
83
|
+
def text(name)
|
|
84
|
+
@totals[name.to_sym].to_s
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Every text accumulator's running narrative, keyed by name.
|
|
88
|
+
#
|
|
89
|
+
# @return [Hash{Symbol => String}]
|
|
90
|
+
def texts
|
|
91
|
+
text_accumulator_names.to_h { |name| [name, text(name)] }
|
|
92
|
+
end
|
|
93
|
+
|
|
77
94
|
# @return [Node, nil] current step node, or nil if flow is finished
|
|
78
95
|
def current_step
|
|
79
96
|
return nil if finished?
|
|
@@ -101,9 +118,11 @@ module Inquirex
|
|
|
101
118
|
result = @validator.validate(current_step, value)
|
|
102
119
|
raise Errors::ValidationError, "Validation failed: #{result.errors.join(", ")}" unless result.valid?
|
|
103
120
|
|
|
121
|
+
node = current_step
|
|
104
122
|
@answers[@current_step_id] = value
|
|
105
123
|
@suggestions.delete(@current_step_id)
|
|
106
|
-
apply_accumulations(
|
|
124
|
+
apply_accumulations(node, value)
|
|
125
|
+
capture_transcript(Transcript.answer_entry(node, value))
|
|
107
126
|
advance_step
|
|
108
127
|
end
|
|
109
128
|
|
|
@@ -113,6 +132,8 @@ module Inquirex
|
|
|
113
132
|
def advance
|
|
114
133
|
raise Errors::AlreadyFinishedError, "Flow is already finished" if finished?
|
|
115
134
|
|
|
135
|
+
node = current_step
|
|
136
|
+
capture_transcript(Transcript.display_entry(node)) if node.display?
|
|
116
137
|
advance_step
|
|
117
138
|
end
|
|
118
139
|
|
|
@@ -152,6 +173,7 @@ module Inquirex
|
|
|
152
173
|
end
|
|
153
174
|
@skipped << @current_step_id unless @skipped.include?(@current_step_id)
|
|
154
175
|
@suggestions.delete(@current_step_id)
|
|
176
|
+
capture_transcript(Transcript.skipped_entry(node))
|
|
155
177
|
advance_step
|
|
156
178
|
end
|
|
157
179
|
|
|
@@ -165,14 +187,21 @@ module Inquirex
|
|
|
165
187
|
|
|
166
188
|
# Merges a hash of { step_id => value } into the top-level answers without
|
|
167
189
|
# clobbering answers the user has already provided. Used by LLM clarify
|
|
168
|
-
# steps to populate downstream answers from free-text extraction
|
|
169
|
-
#
|
|
190
|
+
# steps to populate downstream answers from free-text extraction; a
|
|
191
|
+
# prefilled question is treated as answered and is never asked again.
|
|
170
192
|
#
|
|
171
193
|
# Nil/empty values in the hash are ignored so that "unknown" LLM outputs
|
|
172
194
|
# don't spuriously satisfy `not_empty` rules.
|
|
173
195
|
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
196
|
+
# Values for steps with options (enum / multi_enum) are canonicalized via
|
|
197
|
+
# Node#resolve_option — matching is against the option's form VALUE, with
|
|
198
|
+
# a case-insensitive fallback and a label fallback ("US citizen or
|
|
199
|
+
# permanent resident" resolves to "us_person"). A value that matches
|
|
200
|
+
# neither value nor label is dropped, so junk never enters the answers.
|
|
201
|
+
#
|
|
202
|
+
# Prefilled answers contribute to accumulators exactly like typed ones,
|
|
203
|
+
# and if the engine's current step becomes skippable as a result of the
|
|
204
|
+
# prefill, it auto-advances past it.
|
|
176
205
|
#
|
|
177
206
|
# @param hash [Hash] answers keyed by step id
|
|
178
207
|
# @return [Hash] the updated answers
|
|
@@ -185,13 +214,9 @@ module Inquirex
|
|
|
185
214
|
|
|
186
215
|
sym = key.to_sym
|
|
187
216
|
if multi_select_step?(sym)
|
|
188
|
-
|
|
189
|
-
# more selections in mind than the text revealed. Record it as a
|
|
190
|
-
# suggestion so renderers pre-check the choices while the question
|
|
191
|
-
# is still asked; skip_if rules see no answer and do not fire.
|
|
192
|
-
@suggestions[sym] = Array(value) unless @answers.key?(sym)
|
|
217
|
+
prefill_suggestion(sym, value)
|
|
193
218
|
else
|
|
194
|
-
|
|
219
|
+
prefill_answer(sym, value)
|
|
195
220
|
end
|
|
196
221
|
end
|
|
197
222
|
skip_if_needed unless finished?
|
|
@@ -319,6 +344,30 @@ module Inquirex
|
|
|
319
344
|
end
|
|
320
345
|
end
|
|
321
346
|
|
|
347
|
+
# @return [Array<Symbol>] names of the flow's :text accumulators
|
|
348
|
+
def text_accumulator_names
|
|
349
|
+
@definition.accumulators.filter_map { |name, acc| name if acc.text? }
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Appends one narrative entry to every text accumulator the flow declares.
|
|
353
|
+
#
|
|
354
|
+
# Called only from #answer, #skip, and #advance — the three points at
|
|
355
|
+
# which the user has actually seen or done something. Steps the engine
|
|
356
|
+
# elides on its own (skip_if, or a question already answered by an
|
|
357
|
+
# extraction) pass through #advance_step instead and are correctly absent
|
|
358
|
+
# from the narrative.
|
|
359
|
+
#
|
|
360
|
+
# @param entry [String, nil] formatted entry, or nil for nothing to record
|
|
361
|
+
# @return [void]
|
|
362
|
+
def capture_transcript(entry)
|
|
363
|
+
return if entry.nil? || entry.empty?
|
|
364
|
+
|
|
365
|
+
text_accumulator_names.each do |name|
|
|
366
|
+
existing = @totals[name].to_s
|
|
367
|
+
@totals[name] = existing.empty? ? entry : "#{existing}\n\n#{entry}"
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
322
371
|
# The step's default as a concrete value: a Proc default (server-side only,
|
|
323
372
|
# stripped from JSON) is called with the answers collected so far, exactly
|
|
324
373
|
# as a renderer pre-filling the field would resolve it.
|
|
@@ -367,12 +416,44 @@ module Inquirex
|
|
|
367
416
|
@completion_metadata = CompletionMetadata.new(engine: "inquirex", engine_version: VERSION)
|
|
368
417
|
end
|
|
369
418
|
|
|
370
|
-
#
|
|
419
|
+
# Multi-select extraction is a hint, not a fact: the user may have more
|
|
420
|
+
# selections in mind than the text revealed. Record it as a suggestion so
|
|
421
|
+
# renderers pre-check the choices while the question is still asked;
|
|
422
|
+
# skip_if rules see no answer and do not fire. Each entry is
|
|
423
|
+
# canonicalized against the step's option values; unmatchable entries
|
|
424
|
+
# are dropped, and an all-junk extraction records no suggestion.
|
|
425
|
+
def prefill_suggestion(sym, value)
|
|
426
|
+
return if @answers.key?(sym)
|
|
427
|
+
|
|
428
|
+
node = @definition.step(sym)
|
|
429
|
+
resolved = Array(value).filter_map { |entry| node.resolve_option(entry) }
|
|
430
|
+
@suggestions[sym] = resolved unless resolved.empty?
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# Single-value extraction is deterministic: the canonicalized value is
|
|
434
|
+
# recorded as the answer (feeding accumulators like a typed answer), and
|
|
435
|
+
# the question will be auto-skipped when reached. Unknown keys — schema
|
|
436
|
+
# fields with no matching step, e.g. a confidence score — are stored
|
|
437
|
+
# verbatim so rules can still read them.
|
|
438
|
+
def prefill_answer(sym, value)
|
|
439
|
+
return if @answers.key?(sym)
|
|
440
|
+
|
|
441
|
+
node = @definition.step_ids.include?(sym) ? @definition.step(sym) : nil
|
|
442
|
+
resolved = node ? node.resolve_option(value) : value
|
|
443
|
+
return if resolved.nil?
|
|
444
|
+
|
|
445
|
+
@answers[sym] = resolved
|
|
446
|
+
apply_accumulations(node, resolved) if node
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# Auto-skips the current step when its skip_if rule is satisfied, or when
|
|
450
|
+
# it is a collecting step whose answer already exists (prefilled by an
|
|
451
|
+
# LLM extraction) — an answered question is never asked again.
|
|
371
452
|
def skip_if_needed
|
|
372
453
|
return if finished?
|
|
373
454
|
|
|
374
455
|
node = @definition.step(@current_step_id)
|
|
375
|
-
return unless node.skip?(@answers)
|
|
456
|
+
return unless node.skip?(@answers) || (node.collecting? && @answers.key?(@current_step_id))
|
|
376
457
|
|
|
377
458
|
advance_step
|
|
378
459
|
end
|
data/lib/inquirex/errors.rb
CHANGED
|
@@ -32,8 +32,25 @@ module Inquirex
|
|
|
32
32
|
# Raised when serializing or deserializing a Definition to/from JSON fails.
|
|
33
33
|
class SerializationError < Error; end
|
|
34
34
|
|
|
35
|
-
# Raised when
|
|
36
|
-
# e.
|
|
37
|
-
|
|
35
|
+
# Raised when DSL source contains anything outside the flow-DSL allowlist,
|
|
36
|
+
# i.e. when it is not safe to `eval`. See Inquirex::SafeSource.
|
|
37
|
+
#
|
|
38
|
+
# Subclasses DefinitionError on purpose: hosts that already rescue that
|
|
39
|
+
# class and render the message keep working, and a rejected payload reads
|
|
40
|
+
# as "invalid DSL" rather than as a crash.
|
|
41
|
+
class UnsafeSourceError < DefinitionError
|
|
42
|
+
# @return [Array<String>] every violation found, most useful first
|
|
43
|
+
attr_reader :violations
|
|
44
|
+
|
|
45
|
+
# @param violations [Array<String>, String] human-readable violation messages
|
|
46
|
+
def initialize(violations)
|
|
47
|
+
@violations = Array(violations)
|
|
48
|
+
super("DSL rejected: #{@violations.join("; ")}")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Raised when a SendEmail cannot build its message structurally,
|
|
53
|
+
# e.g. SendEmail#to_mail is called without the mail gem installed.
|
|
54
|
+
class SendEmailError < Error; end
|
|
38
55
|
end
|
|
39
56
|
end
|
data/lib/inquirex/node.rb
CHANGED
|
@@ -86,6 +86,35 @@ module Inquirex
|
|
|
86
86
|
COLLECTING_VERBS.include?(@verb)
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
+
# Canonicalizes a raw value against this step's options: an exact value
|
|
90
|
+
# match wins, then a case-insensitive value match, then a case-insensitive
|
|
91
|
+
# label match — LLM extractions and humans often answer with the friendly
|
|
92
|
+
# label ("US citizen or permanent resident") when the form value is the
|
|
93
|
+
# canonical key ("us_person"). Matching is always resolved TO the form
|
|
94
|
+
# value, never the label. Steps without options return the value
|
|
95
|
+
# unchanged; an unmatchable value returns nil rather than polluting
|
|
96
|
+
# answers with junk that would satisfy not_empty rules.
|
|
97
|
+
#
|
|
98
|
+
# @example
|
|
99
|
+
# node.options # => ["us_person", "resident"]
|
|
100
|
+
# node.resolve_option("us_person") # => "us_person"
|
|
101
|
+
# node.resolve_option("US_PERSON") # => "us_person"
|
|
102
|
+
# node.resolve_option("US citizen or permanent resident") # => "us_person"
|
|
103
|
+
# node.resolve_option("alien overlord") # => nil
|
|
104
|
+
#
|
|
105
|
+
# @param raw [Object] candidate value (String, Symbol, ...)
|
|
106
|
+
# @return [String, Object, nil] the canonical option value; the raw value
|
|
107
|
+
# unchanged for steps without options; nil when nothing matches
|
|
108
|
+
def resolve_option(raw)
|
|
109
|
+
return raw if @options.nil? || @options.empty?
|
|
110
|
+
return nil if raw.nil?
|
|
111
|
+
|
|
112
|
+
candidate = raw.to_s
|
|
113
|
+
@options.find { |value| value == candidate } ||
|
|
114
|
+
@options.find { |value| value.casecmp?(candidate) } ||
|
|
115
|
+
@option_labels&.find { |_value, label| label.casecmp?(candidate) }&.first
|
|
116
|
+
end
|
|
117
|
+
|
|
89
118
|
# @return [Boolean] true if this step only displays content (no input)
|
|
90
119
|
def display?
|
|
91
120
|
DISPLAY_VERBS.include?(@verb)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module SafeSource
|
|
5
|
+
# The shape a single allowlisted DSL call may take, built by
|
|
6
|
+
# {Vocabulary.allow} and consumed by {Validator}. Its three members:
|
|
7
|
+
#
|
|
8
|
+
# - `positional` — accepted positional arguments. An Array names each slot's
|
|
9
|
+
# kind in order (`[:symbol]`); `{ repeat: kind, min: n }` accepts any
|
|
10
|
+
# number of arguments of one kind; `{ optional: kind }` accepts zero or one.
|
|
11
|
+
# - `keywords` — `nil` when the call takes no keyword arguments, or a Hash
|
|
12
|
+
# mapping each accepted keyword to its value kind. The key
|
|
13
|
+
# {Vocabulary::ANY_OTHER} sets the kind for every keyword not named
|
|
14
|
+
# explicitly, and is only legitimate when the real method takes `**rest`.
|
|
15
|
+
# - `block` — `:forbidden`; the name of the nested scope whose vocabulary
|
|
16
|
+
# the block's statements are validated against; or `{ optional: scope }`
|
|
17
|
+
# for a call that accepts that block but does not require it, mirroring
|
|
18
|
+
# the `positional` spelling. `send_email` is the optional case: it takes
|
|
19
|
+
# its fields either as keywords or from a block.
|
|
20
|
+
#
|
|
21
|
+
# Value kinds are `:literal`, `:string`, `:symbol`, `:type_name` and `:rule`.
|
|
22
|
+
#
|
|
23
|
+
# @example The spec behind `transition to: :next, if_rule: equals(:a, 1)`
|
|
24
|
+
# CallSpec.new(positional: [],
|
|
25
|
+
# keywords: { to: :symbol, if_rule: :rule, requires_server: :literal },
|
|
26
|
+
# block: :forbidden)
|
|
27
|
+
CallSpec = Data.define(:positional, :keywords, :block) do
|
|
28
|
+
# The scope this call's block opens, with `{ optional: scope }`
|
|
29
|
+
# unwrapped, or nil when the call takes no block at all. Callers that
|
|
30
|
+
# care whether the block is required read {#block} itself.
|
|
31
|
+
#
|
|
32
|
+
# @return [Symbol, nil]
|
|
33
|
+
def block_scope
|
|
34
|
+
case block
|
|
35
|
+
when :forbidden then nil
|
|
36
|
+
when Hash then block[:optional]
|
|
37
|
+
else block
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|