inquirex 0.11.0 → 0.12.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9a4701c7b2bb8ccf5a9da4a5a25e93be1cc4544371f2d9c1f48c478e04a9af75
4
- data.tar.gz: 3192f545c283e0cfedf7eadec341f02d7984c0551ee1454714b51eb7fb1db250
3
+ metadata.gz: fe9f05cd41bfb91eb6ebf5c75a4a1f8a7323e761516fa4ba303d1df1ad539c9e
4
+ data.tar.gz: bfc9557af719b4eb03ca651c84d72d9c3d3d7f477763422dbce679caa4c80b2f
5
5
  SHA512:
6
- metadata.gz: 66b1f3c05ad85be1bc8b70f05b669b540cc116b0f9a985393e7715614fa98a0974c631e7ca5cb709b29a2c000fd4f4eb535fea81c14959572e63be817069117d
7
- data.tar.gz: 957265be4973894ff8d2a5b219a89c68196a880c63179e103c652fafd67cdb71fe1809a68db23e2b403ff72e1d4fc78810694c03bf58cb45aed02dcf13051443
6
+ metadata.gz: 8bf741953e3400e1ce9ac050e5ee3bfc521c52db204d2c2d21c45ae5e80b0cdedff71c6fef3f82c86c5a80c98c2ce85aa0c4f52925047ec8d31c188284fec9fd
7
+ data.tar.gz: 1bf3e9c4fc15c4c4eb1e2f6efdc2fb41a50c39eb52f18f46bb2012f1377da58ab7e55f27f3300185095d2e0039e383d4c572ccc1b3e0239b8e2e65528bcb31f5
data/README.md CHANGED
@@ -311,7 +311,9 @@ Inquirex.define id: "depreciation-help" do
311
311
  accumulator :transcript, type: :text
312
312
  start :intro
313
313
 
314
- say(:intro) { text "Depreciation spreads an asset's cost over its useful life."; transition to: :asset_kind }
314
+ say(:intro) do
315
+ text "Depreciation spreads an asset's cost over its useful life."; transition to: :asset_kind
316
+ end
315
317
  ask :asset_kind do
316
318
  type :enum
317
319
  question "What kind of asset?"
@@ -471,6 +473,7 @@ Behavior:
471
473
  - Use `finished?` to detect completion
472
474
  - Use `total(:price)` / `totals` to read running totals
473
475
  - Use `to_state` / `.from_state` for persistence/resume (totals included)
476
+ - Use `back` to undo the last answer or skip and re-ask that question (see [Going Back](#going-back))
474
477
  - Use `prefill!(hash)` to merge externally-supplied answers into the state, e.g. fields extracted by an LLM from a free-text answer (see [inquirex-llm](#extension-gems)). Existing answers are preserved; `nil` and empty values are ignored so they don't spuriously satisfy `not_empty` rules. The engine auto-advances past any newly-skippable step.
475
478
 
476
479
  ```ruby
@@ -552,6 +555,57 @@ On the wire, step JSON carries `"required": false` (omitted when true, like othe
552
555
  > [!NOTE]
553
556
  > **`skip_if` is not the same thing.** A `skip_if` rule is *flow logic*: the definition elides the step from the path automatically, no default kicks in, and the step is **not** added to `engine.skipped` — it was never presented, so the user cannot have declined it. `Engine#skip` is a *user action* on a question that was presented. See [`docs/design/required-and-skip.md`](docs/design/required-and-skip.md) for the full design.
554
557
 
558
+ ## Going Back
559
+
560
+ `Engine#back` undoes the last `answer` or `skip` and returns the engine to that question, so a renderer can offer a **Back** control. Every `answer`, `skip` and `advance` pushes a snapshot of the full state before it mutates, and `back` restores the most recent snapshot taken at a *collecting* step — display steps (`say`, `header`, `btw`, `warning`) are stepped over, since there is nothing to re-answer there.
561
+
562
+ ```ruby
563
+ engine.answer("married") # :filing_status -> :dependents
564
+ engine.answer(2) # :dependents -> :state
565
+ engine.back # => true; back at :dependents
566
+ engine.previous_answer(:dependents) # => 2, pre-fill the input with it
567
+ engine.back # => true; back at :filing_status
568
+ engine.back # => false; nothing to undo
569
+ ```
570
+
571
+ What `back` restores is the snapshot, not a replay: answers, running totals (including `:text` transcripts), `skipped`, prefilled suggestions, history and completion metadata all come back exactly as they were. Replaying answers would not be equivalent — a Proc `default` or a `prefill!` suggestion does not re-derive.
572
+
573
+ - `engine.can_back?` says whether `back` would move; use it to decide whether to show the control at all.
574
+ - `engine.previous_answer(step_id)` returns the step's current answer if it has one, otherwise the last answer `back` took away from it — so the question can be re-asked with the old value filled in.
575
+ - `engine.current_default` resolves the current step's default as `skip` would (a Proc default is called with the answers so far), for pre-filling.
576
+ - `back` keeps the validator and any `after_completion` hooks registered on the engine.
577
+
578
+ > [!NOTE]
579
+ > The undo stack is **session-local** and is deliberately left out of `to_state`. An engine rebuilt with `.from_state` starts with nothing to go back to; persisting the stack would nest every earlier state inside each saved one.
580
+
581
+ ## Headless Runs
582
+
583
+ `Inquirex::Headless` runs a flow to the end from a hash of preset answers — no terminal, no prompt, no LLM adapter. It is the driver behind `inquirex-tty run --answers` and `--yes`, and the simplest way to exercise a flow in tests and CI.
584
+
585
+ ```ruby
586
+ run = Inquirex::Headless.new(
587
+ definition,
588
+ answers: { filing_status: "single", state: "CA" }, # string keys (from JSON/YAML) are fine too
589
+ yes: true # take each unanswered step's default
590
+ )
591
+ results = run.call
592
+
593
+ results[:path_taken] # => [:filing_status, :dependents, :state, :done]
594
+ results[:answers] # => answers_with_metadata (includes :skipped, :completion_metadata)
595
+ results[:steps_completed] # => 4 (steps visited, display steps included)
596
+ ```
597
+
598
+ For each step, in order:
599
+
600
+ 1. A display step advances silently.
601
+ 1. A step present in `answers:` is answered with that value verbatim — an LLM step included: the value stands in for the adapter's output and is **not** splatted into other answers, so put downstream answers in the hash directly.
602
+ 1. Otherwise, with `yes: true`, a step with a `default` is answered with it (Proc defaults are resolved against the answers so far).
603
+ 1. Otherwise an optional step is skipped, recording its default exactly as `Engine#skip` does.
604
+ 1. Otherwise, with `yes: true`, a required LLM step with no default is answered with `nil`.
605
+ 1. Otherwise the run stops with `Inquirex::Headless::MissingAnswer` (an `Errors::EngineError` carrying `#step_id`).
606
+
607
+ Answers reach the engine only through `answer` and `skip`, never `prefill!` — a prefilled multi-select is a *suggestion* that no `skip_if` rule can see, which would leave such a step waiting for an answer nobody is there to give. Register `after_completion` hooks on `run.engine` before `call` to attach completion metadata.
608
+
555
609
  ## Completion Metadata
556
610
 
557
611
  When a flow finishes, the engine guarantees a `CompletionMetadata` — an OpenStruct describing how the answers were collected. Only `engine` and `engine_version` are required members; rendering front-ends attach richer environment details from an `after_completion` hook:
@@ -735,6 +789,7 @@ Common exceptions under `Inquirex::Errors`:
735
789
  - `ValidationError`
736
790
  - `NonCollectingStepError`
737
791
  - `RequiredStepError`
792
+ - `Headless::MissingAnswer`
738
793
  - `SendEmailError`
739
794
 
740
795
  ## Development
@@ -62,6 +62,8 @@ module Inquirex
62
62
  @completion_hook_errors = []
63
63
  @suggestions = {}
64
64
  @skipped = []
65
+ @snapshots = []
66
+ @rewound_answers = {}
65
67
  @history << @current_step_id
66
68
  skip_display_steps_if_needed
67
69
  end
@@ -119,6 +121,7 @@ module Inquirex
119
121
  raise Errors::ValidationError, "Validation failed: #{result.errors.join(", ")}" unless result.valid?
120
122
 
121
123
  node = current_step
124
+ push_snapshot
122
125
  @answers[@current_step_id] = value
123
126
  @suggestions.delete(@current_step_id)
124
127
  apply_accumulations(node, value)
@@ -133,6 +136,7 @@ module Inquirex
133
136
  raise Errors::AlreadyFinishedError, "Flow is already finished" if finished?
134
137
 
135
138
  node = current_step
139
+ push_snapshot
136
140
  capture_transcript(Transcript.display_entry(node)) if node.display?
137
141
  advance_step
138
142
  end
@@ -166,6 +170,7 @@ module Inquirex
166
170
  if current_step.required?
167
171
 
168
172
  node = current_step
173
+ push_snapshot
169
174
  default = resolve_default(node)
170
175
  unless default.nil?
171
176
  @answers[@current_step_id] = default
@@ -177,6 +182,74 @@ module Inquirex
177
182
  advance_step
178
183
  end
179
184
 
185
+ # Undoes the user's last answer or skip: the engine returns to the most
186
+ # recent collecting step the user acted on, with answers, totals (including
187
+ # the :text transcript), skipped, suggestions, history and completion
188
+ # metadata exactly as they were before that step was answered.
189
+ #
190
+ # Display steps passed with #advance in between are stepped over, since
191
+ # there is nothing on them to change. Steps elided by skip_if or by a
192
+ # prefilled answer were never presented and so are never returned to.
193
+ #
194
+ # The undone answer stays available through #previous_answer, so a
195
+ # renderer can re-ask the question with it pre-filled.
196
+ #
197
+ # Rebuilding the earlier state by replaying answers is not equivalent: a
198
+ # Proc default or a prefill! suggestion does not re-derive. So #answer,
199
+ # #skip and #advance each push a #to_state snapshot before they mutate,
200
+ # and this restores one.
201
+ #
202
+ # The snapshot stack is deliberately excluded from #to_state: it is
203
+ # session-local undo, not flow state, and persisting it would nest every
204
+ # earlier state inside each saved one. An engine rebuilt by .from_state
205
+ # therefore starts with nothing to go back to.
206
+ #
207
+ # @example Re-ask the previous question with its answer filled in
208
+ # engine.answer("married")
209
+ # engine.back # => true
210
+ # engine.previous_answer(:filing_status) # => "married"
211
+ #
212
+ # @return [Boolean] true when the engine moved back; false, with nothing
213
+ # changed, when no collecting step has been answered yet
214
+ def back
215
+ index = @snapshots.rindex { |state| collecting_step?(state[:current_step_id]) }
216
+ return false unless index
217
+
218
+ @rewound_answers.merge!(@answers)
219
+ load_state(@snapshots[index])
220
+ @snapshots.slice!(index..)
221
+ true
222
+ end
223
+
224
+ # Whether #back would move the engine.
225
+ #
226
+ # @return [Boolean]
227
+ def can_back?
228
+ @snapshots.any? { |state| collecting_step?(state[:current_step_id]) }
229
+ end
230
+
231
+ # The answer to pre-fill when a step is asked again after #back: the
232
+ # step's current answer when it has one, otherwise the last answer #back
233
+ # took away from it.
234
+ #
235
+ # @param step_id [Symbol, String] step id
236
+ # @return [Object, nil] nil when the step was never answered
237
+ def previous_answer(step_id)
238
+ sym = step_id.to_sym
239
+ @answers.fetch(sym) { @rewound_answers[sym] }
240
+ end
241
+
242
+ # The current step's default as a concrete value, resolved exactly as
243
+ # #skip records it: a Proc default is called with the answers so far.
244
+ # Renderers pre-fill with it; a headless run under `yes` answers with it.
245
+ #
246
+ # @return [Object, nil] nil when the flow is finished or the step has no default
247
+ def current_default
248
+ return nil if finished?
249
+
250
+ resolve_default(current_step)
251
+ end
252
+
180
253
  # Whether the user explicitly skipped the given step via #skip.
181
254
  #
182
255
  # @param step_id [Symbol, String] step id
@@ -311,17 +384,55 @@ module Inquirex
311
384
  def restore_state(definition, state, validator)
312
385
  @definition = definition
313
386
  @validator = validator
387
+ @after_completion_hooks = []
388
+ @completion_hook_errors = []
389
+ @snapshots = []
390
+ @rewound_answers = {}
391
+ load_state(state)
392
+ end
393
+
394
+ # Loads the flow-state fields of a #to_state hash, leaving the session
395
+ # (validator, hooks, the #back stack) alone. Shared by .from_state and
396
+ # #back so the two cannot disagree about what a state contains.
397
+ #
398
+ # @param state [Hash] symbol-keyed state, as #to_state returns it
399
+ # @return [void]
400
+ def load_state(state)
314
401
  @current_step_id = state[:current_step_id]
315
402
  @answers = state[:answers] || {}
316
403
  @history = state[:history] || []
317
404
  @totals = state[:totals] || init_totals
318
405
  @completion_metadata = CompletionMetadata.from_h(state[:completion_metadata])
319
- @after_completion_hooks = []
320
- @completion_hook_errors = []
321
406
  @suggestions = state[:suggestions] || {}
322
407
  @skipped = state[:skipped] || []
323
408
  end
324
409
 
410
+ # Records the state before #answer, #skip or #advance changes it. The
411
+ # copy is deep: those methods mutate the answers, totals, history,
412
+ # suggestions and skipped collections in place.
413
+ #
414
+ # @return [void]
415
+ def push_snapshot
416
+ @snapshots << deep_copy(to_state)
417
+ end
418
+
419
+ # @param value [Object]
420
+ # @return [Object] a copy sharing no Hash, Array or String with +value+
421
+ def deep_copy(value)
422
+ case value
423
+ when Hash then value.to_h { |k, v| [k, deep_copy(v)] }
424
+ when Array then value.map { |v| deep_copy(v) }
425
+ when String then value.dup
426
+ else value
427
+ end
428
+ end
429
+
430
+ # @param step_id [Symbol, nil]
431
+ # @return [Boolean] whether +step_id+ names a step that collects an answer
432
+ def collecting_step?(step_id)
433
+ !step_id.nil? && @definition.step(step_id).collecting?
434
+ end
435
+
325
436
  # Whether +step_id+ names a multi-select step in the definition. Unknown
326
437
  # ids (e.g. an extract schema field that matches no step) are not.
327
438
  #
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ # Runs a flow to the end from a hash of preset answers, with no terminal,
5
+ # no prompt and no LLM: the driver behind `inquirex-tty run --answers` and
6
+ # `--yes`, and the way to run a flow in tests and CI.
7
+ #
8
+ # Answers reach the engine through Engine#answer and Engine#skip only,
9
+ # never Engine#prefill!. prefill! records a multi-select as a suggestion
10
+ # that no skip_if rule can see, so a flow whose multi-select is gated on
11
+ # its own answer would stop on that question with nobody to answer it.
12
+ #
13
+ # Step by step:
14
+ #
15
+ # - a display step advances silently;
16
+ # - a step present in +answers+ is answered with that value, as given (an
17
+ # LLM step included: the value stands in for the adapter's output, and
18
+ # nothing is splatted into other answers);
19
+ # - otherwise, with +yes+, a step with a default is answered with it (a
20
+ # Proc default resolved against the answers so far);
21
+ # - otherwise an optional step is skipped, recording its default as
22
+ # Engine#skip does;
23
+ # - otherwise, with +yes+, a required LLM step is answered with nil;
24
+ # - otherwise the run stops with MissingAnswer naming the step.
25
+ #
26
+ # No LLM adapter is ever called: core has none, and a run under +yes+ is
27
+ # meant to be offline and repeatable.
28
+ #
29
+ # @example Run a flow from an answers file's contents
30
+ # results = Inquirex::Headless.new(definition, answers: { filing_status: "single" }, yes: true).call
31
+ # results[:answers] # => { filing_status: "single", dependents: 0, ... }
32
+ # results[:path_taken] # => [:filing_status, :dependents, :done]
33
+ class Headless
34
+ # Raised when a step needs an answer that neither +answers+ nor (with
35
+ # +yes+) a default supplies.
36
+ class MissingAnswer < Errors::EngineError
37
+ # @return [Symbol] the step left without an answer
38
+ attr_reader :step_id
39
+
40
+ # @param step_id [Symbol]
41
+ def initialize(step_id)
42
+ @step_id = step_id
43
+ super("No answer for required step #{step_id.inspect}: give one in the answers, or a default with yes")
44
+ end
45
+ end
46
+
47
+ # The engine the run drives. Register Engine#after_completion hooks on it
48
+ # before #call to attach completion metadata.
49
+ #
50
+ # @return [Engine]
51
+ attr_reader :engine
52
+
53
+ # @param definition [Definition] the flow to run
54
+ # @param answers [Hash{Symbol, String => Object}] preset answers by step id;
55
+ # string keys, as JSON and YAML produce them, are accepted
56
+ # @param yes [Boolean] take each unanswered step's default
57
+ def initialize(definition, answers: {}, yes: false)
58
+ @engine = Engine.new(definition)
59
+ @answers = answers.to_h { |step_id, value| [step_id.to_sym, value] }
60
+ @yes = yes
61
+ end
62
+
63
+ # Drives the engine until the flow finishes.
64
+ #
65
+ # @return [Hash] +:path_taken+ (step ids, in order), +:answers+
66
+ # (Engine#answers_with_metadata) and +:steps_completed+. A caller adds
67
+ # whatever it knows that the engine does not, such as the flow file or a
68
+ # completion time.
69
+ # @raise [MissingAnswer] when a step can be given no answer
70
+ # @raise [Errors::ValidationError] when the validator rejects an answer
71
+ def call
72
+ take(@engine.current_step) until @engine.finished?
73
+ results
74
+ end
75
+
76
+ private
77
+
78
+ # Moves the engine past one step.
79
+ #
80
+ # @param node [Node]
81
+ # @return [void]
82
+ def take(node)
83
+ return @engine.advance if node.display?
84
+ return @engine.answer(@answers[node.id]) if @answers.key?(node.id)
85
+
86
+ # Only under yes: resolving a Proc default runs host code.
87
+ default = @yes ? @engine.current_default : nil
88
+ return @engine.answer(default) unless default.nil?
89
+ return @engine.skip unless node.required?
90
+ return @engine.answer(nil) if @yes && llm_step?(node)
91
+
92
+ raise MissingAnswer, node.id
93
+ end
94
+
95
+ # LLM steps come from inquirex-llm, which core does not load, so they are
96
+ # recognised by the predicate their node class answers.
97
+ #
98
+ # @param node [Node]
99
+ # @return [Boolean]
100
+ def llm_step?(node) = node.respond_to?(:llm_verb?) && node.llm_verb?
101
+
102
+ # @return [Hash]
103
+ def results
104
+ {
105
+ path_taken: @engine.history,
106
+ answers: @engine.answers_with_metadata,
107
+ steps_completed: @engine.history.length
108
+ }
109
+ end
110
+ end
111
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Inquirex
4
- VERSION = "0.11.0"
4
+ VERSION = "0.12.1"
5
5
  end
data/lib/inquirex.rb CHANGED
@@ -49,6 +49,7 @@ require_relative "inquirex/safe_source"
49
49
  # Engine
50
50
  require_relative "inquirex/engine/state_serializer"
51
51
  require_relative "inquirex/engine"
52
+ require_relative "inquirex/headless"
52
53
 
53
54
  # Graph utilities
54
55
  require_relative "inquirex/graph/mermaid_exporter"
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.11.0
4
+ version: 0.12.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Konstantin Gredeskoul
@@ -25,6 +25,30 @@ dependencies:
25
25
  - ~>
26
26
  - !ruby/object:Gem::Version
27
27
  version: "0.6"
28
+ - !ruby/object:Gem::Dependency
29
+ name: prism
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ -
33
+ - ">="
34
+ - !ruby/object:Gem::Version
35
+ version: "1.7"
36
+ -
37
+ - "<"
38
+ - !ruby/object:Gem::Version
39
+ version: "2"
40
+ type: :runtime
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ -
45
+ - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: "1.7"
48
+ -
49
+ - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: "2"
28
52
  description: "Inquirex lets you define multi-step questionnaires as directed graphs with conditional branching, using a conversational DSL (ask, say, mention) and an AST-based rule system (contains, equals, greater_than, all, any). The engine walks the graph, collects structured answers, and serializes everything to JSON — making it the ideal backbone for cross-platform intake forms where the frontend is a chat widget, a terminal, or a mobile app. Framework-agnostic, no third-party dependencies, thread-safe immutable definitions."
29
53
  email:
30
54
  - kigster@gmail.com
@@ -50,6 +74,7 @@ files:
50
74
  - lib/inquirex/errors.rb
51
75
  - lib/inquirex/evaluator.rb
52
76
  - lib/inquirex/graph/mermaid_exporter.rb
77
+ - lib/inquirex/headless.rb
53
78
  - lib/inquirex/node.rb
54
79
  - lib/inquirex/rules/all.rb
55
80
  - lib/inquirex/rules/any.rb