Class: Inquirex::Engine

Inherits:
Object
  • Object
show all
Defined in:
lib/inquirex/engine.rb,
lib/inquirex/engine/state_serializer.rb

Overview

Runtime session that drives flow navigation. Holds the definition, collected answers, and current position in the flow graph.

Collecting steps (ask, confirm): call engine.answer(value) Display steps (say, header, btw, warning): call engine.advance Optional steps (declared required false): engine.skip is also allowed

Validates each answer via an optional Validation::Adapter, then advances using node transitions. Skips steps whose skip_if rule evaluates to true.

Defined Under Namespace

Modules: StateSerializer

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(definition, validator: Validation::NullAdapter.new) ⇒ Engine

Returns a new instance of Engine.

Parameters:

  • definition (Definition)

    the flow to run

  • validator (Validation::Adapter) (defaults to: Validation::NullAdapter.new)

    optional (default: NullAdapter)



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/inquirex/engine.rb', line 53

def initialize(definition, validator: Validation::NullAdapter.new)
  @definition = definition
  @answers = {}
  @history = []
  @current_step_id = definition.start_step_id
  @validator = validator
  @totals = init_totals
  @completion_metadata = nil
  @after_completion_hooks = []
  @completion_hook_errors = []
  @suggestions = {}
  @skipped = []
  @history << @current_step_id
  skip_display_steps_if_needed
end

Instance Attribute Details

#answersObject (readonly)

Returns the value of attribute answers.



14
15
16
# File 'lib/inquirex/engine.rb', line 14

def answers
  @answers
end

#completion_hook_errorsArray<StandardError> (readonly)

Exceptions raised by after_completion hooks, in the order they were raised. Hooks are isolated from one another, so a raising hook is recorded here rather than propagated — callers that care can inspect this after the flow finishes. Empty when every hook succeeded.

Returns:

  • (Array<StandardError>)


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

def completion_hook_errors
  @completion_hook_errors
end

#completion_metadataCompletionMetadata?

Metadata describing how and where the flow was completed. Rendering front-ends (TTY, web, chat widget) attach a rich version from an after_completion hook; when no hook provides one, the engine stamps a minimal core version the moment the flow finishes. Only :engine and :engine_version are required members; everything else is free-form.

Returns:



23
24
25
# File 'lib/inquirex/engine.rb', line 23

def 
  @completion_metadata
end

#current_step_idObject (readonly)

Returns the value of attribute current_step_id.



14
15
16
# File 'lib/inquirex/engine.rb', line 14

def current_step_id
  @current_step_id
end

#definitionObject (readonly)

Returns the value of attribute definition.



14
15
16
# File 'lib/inquirex/engine.rb', line 14

def definition
  @definition
end

#historyObject (readonly)

Returns the value of attribute history.



14
15
16
# File 'lib/inquirex/engine.rb', line 14

def history
  @history
end

#skippedArray<Symbol> (readonly)

Step ids the user explicitly skipped via #skip, in the order they were skipped. Distinguishes default-by-skip values in answers from values the user actually provided. Steps elided automatically by their skip_if rule are NOT listed here — they were never presented, so the user cannot have declined them.

Returns:

  • (Array<Symbol>)


41
42
43
# File 'lib/inquirex/engine.rb', line 41

def skipped
  @skipped
end

#suggestionsHash{Symbol => Array} (readonly)

Answer suggestions produced by prefill! for multi-select steps, keyed by step id. A suggestion pre-populates the step's choices in a renderer but — unlike an answer — never satisfies skip_if rules: multi-select extraction is treated as a hint the user confirms and may extend, not a deterministic fact. Cleared per step once the user answers it.

Returns:

  • (Hash{Symbol => Array})


32
33
34
# File 'lib/inquirex/engine.rb', line 32

def suggestions
  @suggestions
end

#totalsObject (readonly)

Returns the value of attribute totals.



14
15
16
# File 'lib/inquirex/engine.rb', line 14

def totals
  @totals
end

Class Method Details

.from_state(definition, state_hash, validator: Validation::NullAdapter.new) ⇒ Engine

Rebuilds an Engine from a previously saved state.

Parameters:

  • definition (Definition)

    same definition used when state was captured

  • state_hash (Hash)

    hash with state keys (may be strings from JSON)

  • validator (Validation::Adapter) (defaults to: Validation::NullAdapter.new)

Returns:



302
303
304
305
306
307
# File 'lib/inquirex/engine.rb', line 302

def self.from_state(definition, state_hash, validator: Validation::NullAdapter.new)
  state = StateSerializer.symbolize_state(state_hash)
  engine = allocate
  engine.send(:restore_state, definition, state, validator)
  engine
end

Instance Method Details

#advanceObject

Advances past the current non-collecting step (say/header/btw/warning).

Raises:



132
133
134
135
136
137
138
# File 'lib/inquirex/engine.rb', line 132

def advance
  raise Errors::AlreadyFinishedError, "Flow is already finished" if finished?

  node = current_step
  capture_transcript(Transcript.display_entry(node)) if node.display?
  advance_step
end

#after_completion {|Engine| ... } ⇒ Engine

Registers a hook to run when the flow finishes. The block receives the engine; front-ends typically use it to attach a rich completion_metadata (host, user, ips, terminal, ...). Optional — after all hooks run, the engine fills in a minimal CompletionMetadata when none of them provided one. Registering on an already-finished engine invokes the block immediately.

Any number of hooks may be registered; they run in registration order. Each is isolated from the others — a hook that raises a StandardError has it recorded in #completion_hook_errors, and the remaining hooks still run. Non-StandardError exceptions (Interrupt, SignalException) propagate, as they should.

Examples:

Stamp renderer-specific completion metadata

engine.after_completion do |eng|
  eng. = Inquirex::CompletionMetadata.new(
    engine: "inquirex-tty", engine_version: "0.5.0", hostname: Socket.gethostname
  )
end

Yields:

  • (Engine)

    the engine, at completion time

Returns:

Raises:

  • (ArgumentError)


256
257
258
259
260
261
262
263
264
265
# File 'lib/inquirex/engine.rb', line 256

def after_completion(&block)
  raise ArgumentError, "after_completion requires a block" unless block

  @after_completion_hooks << block
  if finished?
    invoke_completion_hook(block)
    
  end
  self
end

#answer(value) ⇒ Object

Submits an answer for the current collecting step (ask/confirm). Validates, stores, and advances to the next step.

Parameters:

  • value (Object)

    user's answer for the current step

Raises:



113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/inquirex/engine.rb', line 113

def answer(value)
  raise Errors::AlreadyFinishedError, "Flow is already finished" if finished?
  raise Errors::NonCollectingStepError, "Step #{@current_step_id} is a display step; use #advance instead" \
    unless current_step.collecting?

  result = @validator.validate(current_step, value)
  raise Errors::ValidationError, "Validation failed: #{result.errors.join(", ")}" unless result.valid?

  node = current_step
  @answers[@current_step_id] = value
  @suggestions.delete(@current_step_id)
  apply_accumulations(node, value)
  capture_transcript(Transcript.answer_entry(node, value))
  advance_step
end

#answers_with_metadataHash

The collected answers with the completion metadata (when a renderer attached one) merged in under the :completion_metadata key, and the user-skipped step ids (when any) under the :skipped key — so post-completion actions and API consumers can tell default-by-skip values apart from answers the user actually provided.

Returns:

  • (Hash)


289
290
291
292
293
294
# File 'lib/inquirex/engine.rb', line 289

def 
  extra = {}
  extra[:completion_metadata] = @completion_metadata.to_h if @completion_metadata
  extra[:skipped] = @skipped.dup unless @skipped.empty?
  extra.empty? ? @answers : @answers.merge(extra)
end

#current_stepNode?

Returns current step node, or nil if flow is finished.

Returns:

  • (Node, nil)

    current step node, or nil if flow is finished



95
96
97
98
99
# File 'lib/inquirex/engine.rb', line 95

def current_step
  return nil if finished?

  @definition.step(@current_step_id)
end

#finished?Boolean

Returns true when there is no current step (flow ended).

Returns:

  • (Boolean)

    true when there is no current step (flow ended)



102
103
104
# File 'lib/inquirex/engine.rb', line 102

def finished?
  @current_step_id.nil?
end

#prefill!(hash) ⇒ Hash

Merges a hash of { step_id => value } into the top-level answers without clobbering answers the user has already provided. Used by LLM clarify steps to populate downstream answers from free-text extraction; a prefilled question is treated as answered and is never asked again.

Nil/empty values in the hash are ignored so that "unknown" LLM outputs don't spuriously satisfy not_empty rules.

Values for steps with options (enum / multi_enum) are canonicalized via Node#resolve_option — matching is against the option's form VALUE, with a case-insensitive fallback and a label fallback ("US citizen or permanent resident" resolves to "us_person"). A value that matches neither value nor label is dropped, so junk never enters the answers.

Prefilled answers contribute to accumulators exactly like typed ones, and if the engine's current step becomes skippable as a result of the prefill, it auto-advances past it.

Parameters:

  • hash (Hash)

    answers keyed by step id

Returns:

  • (Hash)

    the updated answers



208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
# File 'lib/inquirex/engine.rb', line 208

def prefill!(hash)
  return @answers unless hash.is_a?(Hash)

  hash.each do |key, value|
    next if value.nil?
    next if value.respond_to?(:empty?) && value.empty?

    sym = key.to_sym
    if multi_select_step?(sym)
      prefill_suggestion(sym, value)
    else
      prefill_answer(sym, value)
    end
  end
  skip_if_needed unless finished?
  @answers
end

#skipvoid

This method returns an undefined value.

Skips the current optional collecting step at the user's request — the engine-side handler for a widget's Skip button. Only steps declared with required false may be skipped.

When the step has a default, the default is recorded into the answers and contributes to accumulators exactly as if the user had submitted it; the step id lands in #skipped so consumers can tell the value apart from one the user actually provided. Without a default, no answers entry is written (rules read a missing key as nil, so branching is unaffected). Advances through transitions exactly like #answer.

Examples:

Optional question, skipped by the user

engine.current_step.required?  # => false
engine.skip
engine.answers[:dependents]    # => 0 (the step's default)
engine.skipped                 # => [:dependents]

Raises:



161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'lib/inquirex/engine.rb', line 161

def skip
  raise Errors::AlreadyFinishedError, "Flow is already finished" if finished?
  raise Errors::NonCollectingStepError, "Step #{@current_step_id} is a display step; use #advance instead" \
    unless current_step.collecting?
  raise Errors::RequiredStepError, "Step #{@current_step_id} is required and cannot be skipped" \
    if current_step.required?

  node = current_step
  default = resolve_default(node)
  unless default.nil?
    @answers[@current_step_id] = default
    apply_accumulations(node, default)
  end
  @skipped << @current_step_id unless @skipped.include?(@current_step_id)
  @suggestions.delete(@current_step_id)
  capture_transcript(Transcript.skipped_entry(node))
  advance_step
end

#skipped?(step_id) ⇒ Boolean

Whether the user explicitly skipped the given step via #skip.

Parameters:

  • step_id (Symbol, String)

    step id

Returns:

  • (Boolean)


184
185
186
# File 'lib/inquirex/engine.rb', line 184

def skipped?(step_id)
  @skipped.include?(step_id.to_sym)
end

#suggestion_for(step_id) ⇒ Array?

The prefill suggestion for a step, or nil when none was recorded.

Parameters:

  • step_id (Symbol, String)

    step id

Returns:

  • (Array, nil)

    suggested selections for a multi-select step



230
231
232
# File 'lib/inquirex/engine.rb', line 230

def suggestion_for(step_id)
  @suggestions[step_id.to_sym]
end

#text(name) ⇒ String

The running narrative of a :text accumulator — everything the user was shown and everything they answered, in the order it happened. This is what an LLM summarize step reads.

Parameters:

  • name (Symbol)

    text accumulator name (e.g. :transcript)

Returns:

  • (String)

    empty when nothing has been captured yet



83
84
85
# File 'lib/inquirex/engine.rb', line 83

def text(name)
  @totals[name.to_sym].to_s
end

#textsHash{Symbol => String}

Every text accumulator's running narrative, keyed by name.

Returns:

  • (Hash{Symbol => String})


90
91
92
# File 'lib/inquirex/engine.rb', line 90

def texts
  text_accumulator_names.to_h { |name| [name, text(name)] }
end

#to_stateHash

Serializable state snapshot for persistence or resumption.

Returns:

  • (Hash)


270
271
272
273
274
275
276
277
278
279
280
# File 'lib/inquirex/engine.rb', line 270

def to_state
  {
    current_step_id:     @current_step_id,
    answers:             @answers,
    history:             @history,
    totals:              @totals,
    suggestions:         @suggestions,
    skipped:             @skipped,
    completion_metadata: @completion_metadata&.to_h
  }
end

#total(name) ⇒ Numeric

Convenience accessor for a single accumulator's running total.

Parameters:

  • name (Symbol)

    accumulator name (e.g. :price)

Returns:

  • (Numeric)


73
74
75
# File 'lib/inquirex/engine.rb', line 73

def total(name)
  @totals[name.to_sym] || 0
end