Class: Inquirex::Node

Inherits:
Object
  • Object
show all
Defined in:
lib/inquirex/node.rb

Overview

A single step in the flow. The verb determines the role of this node:

Collecting verbs (user provides input):

:ask     — typed question with one of the 11 data types
:confirm — yes/no boolean gate (shorthand for ask with type :boolean)

Display verbs (no input, auto-advance):

:say     — informational message
:header  — section heading or title card
:btw     — admonition, sidebar, or contextual note
:warning — important alert

Constant Summary collapse

VERBS =

Valid DSL verbs and which ones collect input from the user.

%i[ask say header btw warning confirm].freeze
COLLECTING_VERBS =

Verbs that collect input from the user.

%i[ask confirm].freeze
DISPLAY_VERBS =

Verbs that only display content and auto-advance.

%i[say header btw warning].freeze
TYPES =

Valid data types for :ask steps.

%i[
  string text integer decimal currency boolean
  enum multi_enum date email phone
].freeze
BOUNDED_TYPES =

Types for which #min, #max and #step_size carry meaning. Declaring a bound on anything else is a definition error rather than a silent no-op — a bounded :string almost always means the author picked the wrong type, and failing loudly is cheaper than shipping it.

%i[integer decimal currency].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(id:, verb:, type: nil, question: nil, text: nil, options: nil, transitions: [], skip_if: nil, default: nil, required: true, min: nil, max: nil, step_size: nil, widget_hints: nil, accumulations: []) ⇒ Node

Returns a new instance of Node.



66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/inquirex/node.rb', line 66

def initialize(id:,
  verb:,
  type: nil,
  question: nil,
  text: nil,
  options: nil,
  transitions: [],
  skip_if: nil,
  default: nil,
  required: true,
  min: nil,
  max: nil,
  step_size: nil,
  widget_hints: nil,
  accumulations: [])
  @id = id.to_sym
  @verb = verb.to_sym
  @type = type&.to_sym
  @question = question
  @text = text
  @transitions = transitions.freeze
  @skip_if = skip_if
  @default = default
  @required = required ? true : false
  @min = coerce_bound(min)
  @max = coerce_bound(max)
  @step_size = coerce_bound(step_size)
  @widget_hints = widget_hints&.freeze
  @accumulations = accumulations.freeze
  extract_options(options)
  validate_bounds!
  freeze
end

Instance Attribute Details

#accumulationsObject (readonly)

Returns the value of attribute accumulations.



49
50
51
# File 'lib/inquirex/node.rb', line 49

def accumulations
  @accumulations
end

#defaultObject? (readonly)

default value (pre-fill, user can change)

Returns:

  • (Object, nil)

    the current value of default



29
30
31
# File 'lib/inquirex/node.rb', line 29

def default
  @default
end

#idSymbol (readonly)

unique step identifier

Returns:

  • (Symbol)

    the current value of id



29
30
31
# File 'lib/inquirex/node.rb', line 29

def id
  @id
end

#maxObject (readonly)

Returns the value of attribute max.



49
50
51
# File 'lib/inquirex/node.rb', line 49

def max
  @max
end

#minObject (readonly)

Returns the value of attribute min.



49
50
51
# File 'lib/inquirex/node.rb', line 49

def min
  @min
end

#option_labelsHash? (readonly)

key => display label mapping

Returns:

  • (Hash, nil)

    the current value of option_labels



29
30
31
# File 'lib/inquirex/node.rb', line 29

def option_labels
  @option_labels
end

#optionsArray? (readonly)

option keys for :enum/:multi_enum steps

Returns:

  • (Array, nil)

    the current value of options



29
30
31
# File 'lib/inquirex/node.rb', line 29

def options
  @options
end

#questionString? (readonly)

prompt text for collecting steps

Returns:

  • (String, nil)

    the current value of question



29
30
31
# File 'lib/inquirex/node.rb', line 29

def question
  @question
end

#requiredBoolean (readonly)

whether the user must answer (true by default); required false steps render a Skip control and accept Engine#skip

Returns:

  • (Boolean)

    the current value of required



29
30
31
# File 'lib/inquirex/node.rb', line 29

def required
  @required
end

#skip_ifRules::Base? (readonly)

rule to skip this step entirely

Returns:



29
30
31
# File 'lib/inquirex/node.rb', line 29

def skip_if
  @skip_if
end

#step_sizeObject (readonly)

Returns the value of attribute step_size.



49
50
51
# File 'lib/inquirex/node.rb', line 49

def step_size
  @step_size
end

#textString? (readonly)

display text for non-collecting steps

Returns:

  • (String, nil)

    the current value of text



29
30
31
# File 'lib/inquirex/node.rb', line 29

def text
  @text
end

#transitionsArray<Transition> (readonly)

ordered conditional next-step edges

Returns:

  • (Array<Transition>)

    the current value of transitions



29
30
31
# File 'lib/inquirex/node.rb', line 29

def transitions
  @transitions
end

#typeSymbol? (readonly)

input type for :ask/:confirm (nil for display verbs)

Returns:

  • (Symbol, nil)

    the current value of type



29
30
31
# File 'lib/inquirex/node.rb', line 29

def type
  @type
end

#verbSymbol (readonly)

DSL verb (:ask, :say, :header, :btw, :warning, :confirm)

Returns:

  • (Symbol)

    the current value of verb



29
30
31
# File 'lib/inquirex/node.rb', line 29

def verb
  @verb
end

#widget_hintsHash{Symbol => WidgetHint}? (readonly)

rendering hints per target

Returns:

  • (Hash{Symbol => WidgetHint}, nil)

    the current value of widget_hints



29
30
31
# File 'lib/inquirex/node.rb', line 29

def widget_hints
  @widget_hints
end

Class Method Details

.from_h(id, hash) ⇒ Node

Deserializes a node from a plain Hash.

Parameters:

  • id (Symbol, String)

    step id

  • hash (Hash)

    node attributes

Returns:



257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/inquirex/node.rb', line 257

def self.from_h(id, hash)
  verb = hash["verb"] || hash[:verb]
  type = hash["type"] || hash[:type]
  question = hash["question"] || hash[:question]
  text = hash["text"] || hash[:text]
  raw_options = hash["options"] || hash[:options]
  transitions_data = hash["transitions"] || hash[:transitions] || []
  skip_if_data = hash["skip_if"] || hash[:skip_if]
  default = hash["default"] || hash[:default]
  # Fetch chain (not ||) so an explicit false survives; absent key means required.
  required = hash.fetch("required") { hash.fetch(:required, true) }
  min = hash["min"] || hash[:min]
  max = hash["max"] || hash[:max]
  step_size = hash["step_size"] || hash[:step_size]
  widget_data = hash["widget"] || hash[:widget]
  accumulate_data = hash["accumulate"] || hash[:accumulate]

  transitions = transitions_data.map { |t| Transition.from_h(t) }
  skip_if = skip_if_data ? Rules::Base.from_h(skip_if_data) : nil
  options = deserialize_options(raw_options)
  widget_hints = deserialize_widget_hints(widget_data)
  accumulations = deserialize_accumulations(accumulate_data)

  new(
    id:,
    verb:,
    type:,
    question:,
    text:,
    options:,
    transitions:,
    skip_if:,
    default:,
    required:,
    min:,
    max:,
    step_size:,
    widget_hints:,
    accumulations:
  )
end

Instance Method Details

#bounded?Boolean

Whether this step declares any numeric bound at all.

Returns:

  • (Boolean)


103
104
105
# File 'lib/inquirex/node.rb', line 103

def bounded?
  !@min.nil? || !@max.nil?
end

#clamp(value) ⇒ Object

Pulls a number inside this step's declared bounds.

The wire format carries min/max so a renderer can present them, but an HTML number input does not stop a visitor typing past them and an LLM extraction has no notion of them at all. Every path that stores an answer for a bounded step goes through here, so the bound holds regardless of which client produced the value.

Examples:

node.clamp(900)  # => 10, for a step declaring `max 10`

Parameters:

  • value (Object)

    candidate answer

Returns:

  • (Object)

    the clamped Numeric, or the value unchanged when it is not numeric or the step declares no bounds



121
122
123
124
125
126
127
128
129
# File 'lib/inquirex/node.rb', line 121

def clamp(value)
  return value unless bounded?
  return value unless value.is_a?(Numeric)

  clamped = value
  clamped = @min if @min && clamped < @min
  clamped = @max if @max && clamped > @max
  clamped
end

#collecting?Boolean

Returns true if this step collects input from the user.

Returns:

  • (Boolean)

    true if this step collects input from the user



132
133
134
# File 'lib/inquirex/node.rb', line 132

def collecting?
  COLLECTING_VERBS.include?(@verb)
end

#display?Boolean

Returns true if this step only displays content (no input).

Returns:

  • (Boolean)

    true if this step only displays content (no input)



166
167
168
# File 'lib/inquirex/node.rb', line 166

def display?
  DISPLAY_VERBS.include?(@verb)
end

#effective_widget_hint_for(target: :desktop) ⇒ WidgetHint?

Returns the explicit hint for the target, falling back to the registry default for this node's type when no explicit hint is set.

Parameters:

  • target (Symbol) (defaults to: :desktop)

    e.g. :desktop, :mobile, :tty

Returns:



192
193
194
# File 'lib/inquirex/node.rb', line 192

def effective_widget_hint_for(target: :desktop)
  widget_hint_for(target:) || WidgetRegistry.default_hint_for(@type, context: target)
end

#next_step_id(answers) ⇒ Symbol?

Resolves the next step id from current answers by evaluating transitions in order.

Parameters:

  • answers (Hash)

    current answer state

Returns:

  • (Symbol, nil)

    id of the next step, or nil if no transition matches (flow end)



200
201
202
203
# File 'lib/inquirex/node.rb', line 200

def next_step_id(answers)
  match = @transitions.find { |t| t.applies?(answers) }
  match&.target
end

#required?Boolean

Whether the user must answer this step. True by default; steps declared with required false render a Skip control and accept Engine#skip. Only meaningful for collecting steps.

Returns:

  • (Boolean)


175
176
177
# File 'lib/inquirex/node.rb', line 175

def required?
  @required
end

#resolve_option(raw) ⇒ String, ...

Canonicalizes a raw value against this step's options: an exact value match wins, then a case-insensitive value match, then a case-insensitive label match — LLM extractions and humans often answer with the friendly label ("US citizen or permanent resident") when the form value is the canonical key ("us_person"). Matching is always resolved TO the form value, never the label. Steps without options return the value unchanged; an unmatchable value returns nil rather than polluting answers with junk that would satisfy not_empty rules.

Examples:

node.options                                             # => ["us_person", "resident"]
node.resolve_option("us_person")                         # => "us_person"
node.resolve_option("US_PERSON")                         # => "us_person"
node.resolve_option("US citizen or permanent resident")  # => "us_person"
node.resolve_option("alien overlord")                    # => nil

Parameters:

  • raw (Object)

    candidate value (String, Symbol, ...)

Returns:

  • (String, Object, nil)

    the canonical option value; the raw value unchanged for steps without options; nil when nothing matches



155
156
157
158
159
160
161
162
163
# File 'lib/inquirex/node.rb', line 155

def resolve_option(raw)
  return raw if @options.nil? || @options.empty?
  return nil if raw.nil?

  candidate = raw.to_s
  @options.find { |value| value == candidate } ||
    @options.find { |value| value.casecmp?(candidate) } ||
    @option_labels&.find { |_value, label| label.casecmp?(candidate) }&.first
end

#skip?(answers) ⇒ Boolean

Whether this step should be skipped given current answers.

Parameters:

  • answers (Hash)

Returns:

  • (Boolean)


209
210
211
212
213
# File 'lib/inquirex/node.rb', line 209

def skip?(answers)
  return false if @skip_if.nil?

  @skip_if.evaluate(answers)
end

#to_hHash

Serializes the node to a plain Hash for JSON output. Lambda defaults/compute are stripped (server-side only).

Returns:

  • (Hash)


219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
# File 'lib/inquirex/node.rb', line 219

def to_h
  hash = { "verb" => @verb.to_s }

  if collecting?
    hash["type"] = @type.to_s if @type
    hash["question"] = @question if @question
    hash["options"] = serialize_options if @options
    hash["skip_if"] = @skip_if.to_h if @skip_if
    hash["default"] = @default unless @default.nil? || @default.is_a?(Proc)
    hash["required"] = false unless @required
    hash["min"] = @min unless @min.nil?
    hash["max"] = @max unless @max.nil?
    hash["step_size"] = @step_size unless @step_size.nil?
  elsif @text
    hash["text"] = @text
  end

  hash["transitions"] = @transitions.map(&:to_h) unless @transitions.empty?

  if @widget_hints && !@widget_hints.empty?
    hash["widget"] = @widget_hints.transform_keys(&:to_s)
                                  .transform_values(&:to_h)
  end

  unless @accumulations.empty?
    hash["accumulate"] = @accumulations.to_h do |acc|
      [acc.target.to_s, acc.to_h]
    end
  end

  hash
end

#widget_hint_for(target: :desktop) ⇒ WidgetHint?

Returns the explicit widget hint for the given target, or nil.

Parameters:

  • target (Symbol) (defaults to: :desktop)

    e.g. :desktop, :mobile, :tty

Returns:



183
184
185
# File 'lib/inquirex/node.rb', line 183

def widget_hint_for(target: :desktop)
  @widget_hints&.fetch(target.to_sym, nil)
end