flowengine 0.3.1 → 0.4.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.
data/README.md CHANGED
@@ -2,44 +2,23 @@
2
2
 
3
3
  [![RSpec](https://github.com/kigster/flowengine/actions/workflows/rspec.yml/badge.svg)](https://github.com/kigster/flowengine/actions/workflows/rspec.yml)   [![RuboCop](https://github.com/kigster/flowengine/actions/workflows/rubocop.yml/badge.svg)](https://github.com/kigster/flowengine/actions/workflows/rubocop.yml)   ![Coverage](docs/badges/coverage_badge.svg)
4
4
 
5
- This gem is the foundation of collecting complex multi-branch information from a user using a flow definition written in Ruby DSL. It shouldn't take too long to learn the DSL even for a non-technical person.
6
-
7
- This gem does not have any UI or an interactive component. It is used as the foundation for additional gems that are built on top of this one, and provide various interactive interfaces for colleting information based on the DSL definition.
8
-
9
- The simplest way to see this in action is to use the companion gem [`flowengine-cli`](https://rubygems.org/gems/flowengine-cli), which, given the flow DSL will walk the user through the questioniare according to the DSL flow definition, but using terminal UI and ASCII-based flow.
10
-
11
- **A slightly different explanation is that it offere a declarative flow engine for building rules-driven wizards and intake forms in pure Ruby.**
12
-
13
- > [!NOTE]
14
- > FlowEngine lets you define multi-step flows as **directed graphs** with **conditional branching**, evaluate transitions using an **AST-based rule system**, and collect structured answers through a **stateful runtime engine** — all without framework dependencies.
5
+ A declarative flow engine for building rules-driven wizards and intake forms in pure Ruby. Define multi-step flows as **directed graphs** with **conditional branching**, evaluate transitions using an **AST-based rule system**, and collect structured answers through a **stateful runtime engine** all without framework dependencies.
15
6
 
16
7
  > [!CAUTION]
17
8
  > **This is not a form builder.** It's a *Form Definition Engine* that separates flow logic, data schema, and UI rendering into independent concerns.
18
9
 
19
10
  ## Installation
20
11
 
21
- Add to your Gemfile:
22
-
23
12
  ```ruby
24
13
  gem "flowengine"
25
14
  ```
26
15
 
27
- Or install directly:
28
-
29
- ```bash
30
- gem install flowengine
31
- ```
32
-
33
16
  ## Quick Start
34
17
 
35
18
  ```ruby
36
19
  require "flowengine"
37
20
 
38
- # 1. Define a flow
39
21
  definition = FlowEngine.define do
40
- introduction label: "What are your favorite cocktails?",
41
- placeholder: "Old Fashion, Whisky Sour, etc",
42
- maxlength: 2000
43
22
  start :name
44
23
 
45
24
  step :name do
@@ -68,257 +47,21 @@ definition = FlowEngine.define do
68
47
  end
69
48
  end
70
49
 
71
- # 2. Run the engine
72
50
  engine = FlowEngine::Engine.new(definition)
73
-
74
51
  engine.answer("Alice") # :name -> :age
75
52
  engine.answer(25) # :age -> :beverage (25 > 20)
76
53
  engine.answer("Wine") # :beverage -> :thanks
77
54
  engine.answer("ok") # :thanks -> finished
78
55
 
79
56
  engine.finished? # => true
80
- engine.answers
81
- # => { name: "Alice", age: 25, beverage: "Wine", thanks: "ok" }
82
- engine.history
83
- # => [:name, :age, :beverage, :thanks]
57
+ engine.answers # => { name: "Alice", age: 25, beverage: "Wine", thanks: "ok" }
58
+ engine.history # => [:name, :age, :beverage, :thanks]
84
59
  ```
85
60
 
86
- If Alice were 18 instead, the engine would skip `:beverage` entirely:
87
-
88
- ```ruby
89
- engine.answer("Alice") # :name -> :age
90
- engine.answer(18) # :age -> :thanks (18 is NOT > 20)
91
- engine.answer("ok") # :thanks -> finished
92
-
93
- engine.answers
94
- # => { name: "Alice", age: 18, thanks: "ok" }
95
- engine.history
96
- # => [:name, :age, :thanks]
97
- ```
98
-
99
- ### Using the `flowengine-cli` gem to Generate the JSON Answers File
61
+ If Alice were 18, the engine skips `:beverage` entirely — the first matching transition (`18 NOT > 20`) falls through to the unconditional `:thanks`.
100
62
 
101
63
  ---
102
64
 
103
- ## LLM-Based DSL Capabilities & Environment Variables
104
-
105
- There are several environment variables that define which vendor and which model you can talk to should you choose to engage LLM in your decision logic.
106
-
107
- There is a very special YAML file that's provided with this gem, which locks in the list of supported vendors and three types of models per vendor:
108
-
109
- - best bang for the buck models
110
- - deep thinking and hard mode models
111
- - fastest models
112
- - *at some point we might also add the "cheapest".*
113
-
114
- +The file [resources/models.yml](resources/models.yml) defines which models are available to the adapter. This file is used at the startup of the gem, to load and initialize all LLM Adapters for which we have the API Key defined in the environment. And for those we'll have at least three model names loaded:
115
-
116
- - `top:` — best results, likely the most expensive.
117
- - `default:` — default model, if the user of the adapter does not specify.
118
- - `fastest` — the fastest model from this vendor.
119
-
120
- Here is the contents of `resources/models.yml` verbatim:
121
-
122
- ```yaml
123
- models:
124
- version: "1.0"
125
- date: "Wed Mar 11 02:35:39 PDT 2026"
126
- vendors:
127
- anthropic:
128
- adapter: "FlowEngine::LLM::Adapters::AnthropicAdapter"
129
- var: "ANTHROPIC_API_KEY"
130
- top: "claude-opus-4-6"
131
- default: "claude-sonnet-4-6"
132
- fastest: "claude-haiku-4-5-20251001"
133
- openai:
134
- adapter: "FlowEngine::LLM::Adapters::OpenAIAdapter"
135
- var: "OPENAI_API_KEY"
136
- top: "gpt-5.4"
137
- default: "gpt-5-mini"
138
- fastest: "gpt-5-nano"
139
- gemini:
140
- adapter: "FlowEngine::LLM::Adapters::GeminiAdapters"
141
- var: "GEMINI_API_KEY"
142
- top: "gemini-3.1-pro-preview"
143
- default: "gemini-2.5-flash"
144
- fastest: "gemini-2.5-flash-lite"
145
- ```
146
-
147
- Notice how this file operates as almost a sort of glue for the gem: it explicitly tells you the names of variables to store your API keys, the class names of the corresponding Adapters, and the three models for each vendor:
148
-
149
- 1. `:top`
150
- 2. `:default`
151
- 3. `:fastest`
152
-
153
- > [!IMPORTANT]
154
- >
155
- > The reason these models are extracted into a separate YAML file should be obvious: the contents of this list seems to change every week, and gem can remain at the same version for years. For this reason, the gem honors the environment variable `${FLOWENGINE_LLM_MODELS_PATH}` and will read the models and vendors from the file pointed to by that path environment variable. This is your door to better models, and other LLM vendors that RubyLLM supports.
156
-
157
- When the gem is loading, one of the first things it does is load this YAML file and instantiate the hash of pre-initialized adapters.
158
-
159
- Need an adapter to throw with your API call?
160
-
161
- ```ruby
162
- FlowEngine::LLM[vendor: :anthropic, 'claude-opus-4-6']
163
-
164
- ## LLM-parsed Introduction
165
-
166
- FlowEngine supports an optional **introduction step** that collects free-form text from the user before the structured flow begins. An LLM parses this text to pre-fill answers, automatically skipping steps the user already answered in their introduction.
167
-
168
- ### Defining an Introduction
169
-
170
- Add the `introduction` command to your flow definition:
171
-
172
- ```ruby
173
- definition = FlowEngine.define do
174
- start :filing_status
175
-
176
- introduction label: "Tell us about your tax situation",
177
- placeholder: "e.g. I am married, filing jointly, with 2 dependents...",
178
- maxlength: 2000 # optional character limit
179
-
180
- step :filing_status do
181
- type :single_select
182
- question "What is your filing status?"
183
- options %w[single married_filing_jointly head_of_household]
184
- transition to: :dependents
185
- end
186
-
187
- step :dependents do
188
- type :number
189
- question "How many dependents?"
190
- transition to: :income_types
191
- end
192
-
193
- step :income_types do
194
- type :multi_select
195
- question "Select income types"
196
- options %w[W2 1099 Business Investment]
197
- end
198
- end
199
- ```
200
-
201
- | Parameter | Required | Description |
202
- |-----------|----------|-------------|
203
- | `label` | Yes | Text shown above the input field |
204
- | `placeholder` | No | Ghost text inside the text area (default: `""`) |
205
- | `maxlength` | No | Maximum character count (default: `nil` = unlimited) |
206
-
207
- ### Using the Introduction at Runtime
208
-
209
- ```ruby
210
- # 1. Auto-detect adapter from environment (checks ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY)
211
- client = FlowEngine::LLM.auto_client
212
-
213
- # Or explicitly choose a provider:
214
- # client = FlowEngine::LLM.auto_client(anthropic_api_key: "sk-ant-...")
215
- # client = FlowEngine::LLM.auto_client(openai_api_key: "sk-...", model: "gpt-4o")
216
- # client = FlowEngine::LLM.auto_client(gemini_api_key: "AIza...")
217
-
218
- # 2. Create the engine and submit the introduction
219
- engine = FlowEngine::Engine.new(definition)
220
- engine.submit_introduction(
221
- "I am married filing jointly with 2 dependents, W2 and business income",
222
- llm_client: client
223
- )
224
-
225
- # 3. The LLM pre-fills answers and the engine auto-advances
226
- engine.answers
227
- # => { filing_status: "married_filing_jointly", dependents: 2,
228
- # income_types: ["W2", "Business"] }
229
-
230
- engine.current_step_id # => nil (all steps pre-filled in this case)
231
- engine.introduction_text # => "I am married filing jointly with 2 dependents, ..."
232
- engine.finished? # => true
233
- ```
234
-
235
- If the LLM can only extract some answers, the engine stops at the first unanswered step and the user continues the flow normally from there.
236
-
237
- ### Sensitive Data Protection
238
-
239
- Before any text reaches the LLM, `submit_introduction` scans for sensitive data patterns:
240
-
241
- - **SSN**: `123-45-6789`
242
- - **ITIN**: `912-34-5678`
243
- - **EIN**: `12-3456789`
244
- - **Nine consecutive digits**: `123456789`
245
-
246
- If detected, a `FlowEngine::Errors::SensitiveDataError` is raised immediately. The introduction text is discarded and no LLM call is made.
247
-
248
- ```ruby
249
- engine.submit_introduction("My SSN is 123-45-6789", llm_client: client)
250
- # => raises FlowEngine::Errors::SensitiveDataError
251
- ```
252
-
253
- ### Custom LLM Adapters
254
-
255
- The LLM integration uses an adapter pattern. The gem ships with three adapters (all via the [`ruby_llm`](https://github.com/crmne/ruby_llm) gem):
256
-
257
- | Adapter | Env Variable | Default Model |
258
- |---------|-------------|---------------|
259
- | `AnthropicAdapter` | `ANTHROPIC_API_KEY` | `claude-sonnet-4-20250514` |
260
- | `OpenAIAdapter` | `OPENAI_API_KEY` | `gpt-4o-mini` |
261
- | `GeminiAdapter` | `GEMINI_API_KEY` | `gemini-2.0-flash` |
262
-
263
- You can also create adapters for any other provider:
264
-
265
- ```ruby
266
- class MyCustomAdapter < FlowEngine::LLM::Adapter
267
- def initialize(api_key:)
268
- super()
269
- @api_key = api_key
270
- end
271
-
272
- def chat(system_prompt:, user_prompt:, model:)
273
- # Call your LLM API here
274
- # Must return the response text (expected to be a JSON string)
275
- end
276
- end
277
-
278
- adapter = MyCustomAdapter.new(api_key: ENV["MY_API_KEY"])
279
- client = FlowEngine::LLM::Client.new(adapter: adapter, model: "my-model")
280
- ```
281
-
282
- ### State Persistence
283
-
284
- The `introduction_text` is included in state serialization:
285
-
286
- ```ruby
287
- state = engine.to_state
288
- # => { current_step_id: ..., answers: { ... }, history: [...], introduction_text: "..." }
289
-
290
- restored = FlowEngine::Engine.from_state(definition, state)
291
- restored.introduction_text # => "I am married filing jointly..."
292
- ```
293
-
294
- ## Architecture
295
-
296
- ![architecture](docs/floweingine-architecture.png)
297
-
298
- The core has **zero UI logic**, **zero DB logic**, and **zero framework dependencies**. Adapters translate input/output, persist state, and render UI.
299
-
300
- ### Core Components
301
-
302
- | Component | Responsibility |
303
- |-----------|---------------|
304
- | `FlowEngine.define` | DSL entry point; returns a frozen `Definition` |
305
- | `Introduction` | Immutable config for the introduction step (label, placeholder, maxlength) |
306
- | `Definition` | Immutable container of the flow graph (nodes + start step + introduction) |
307
- | `Node` | A single step: type, question, options/fields, transitions, visibility |
308
- | `Transition` | A directed edge with an optional rule condition |
309
- | `Rules::*` | AST nodes for conditional logic (`Contains`, `Equals`, `All`, etc.) |
310
- | `Evaluator` | Evaluates rules against the current answer store |
311
- | `Engine` | Stateful runtime: tracks current step, answers, history, and introduction |
312
- | `Validation::Adapter` | Interface for pluggable validation (dry-validation, JSON Schema, etc.) |
313
- | `LLM::Adapter` | Abstract interface for LLM API calls |
314
- | `LLM::AnthropicAdapter` | Anthropic/Claude implementation via `ruby_llm` gem |
315
- | `LLM::OpenAIAdapter` | OpenAI implementation via `ruby_llm` gem |
316
- | `LLM::GeminiAdapter` | Google Gemini implementation via `ruby_llm` gem |
317
- | `LLM::Client` | High-level: builds prompt, calls adapter, parses JSON response |
318
- | `LLM.auto_client` | Factory: auto-detects provider from environment API keys |
319
- | `LLM::SensitiveDataFilter` | Rejects text containing SSN, ITIN, EIN patterns |
320
- | `Graph::MermaidExporter` | Exports the flow definition as a Mermaid diagram |
321
-
322
65
  ## The DSL
323
66
 
324
67
  ### Defining a Flow
@@ -327,79 +70,37 @@ Every flow starts with `FlowEngine.define`, which returns a **frozen, immutable*
327
70
 
328
71
  ```ruby
329
72
  definition = FlowEngine.define do
330
- start :first_step # Required: which node to begin at
73
+ start :first_step
331
74
 
332
- # Optional: collect free-form text before the flow, parsed by LLM
75
+ # Optional: one-shot LLM pre-fill (see "Introduction" section)
333
76
  introduction label: "Describe your situation",
334
77
  placeholder: "Type here...",
335
78
  maxlength: 2000
336
79
 
337
80
  step :first_step do
338
- # step configuration...
339
- end
340
-
341
- step :second_step do
342
- # step configuration...
81
+ type :text
82
+ question "What is your name?"
83
+ transition to: :second_step
343
84
  end
344
85
  end
345
86
  ```
346
87
 
347
88
  ### Step Configuration
348
89
 
349
- Inside a `step` block, you have access to:
350
-
351
90
  | Method | Purpose | Example |
352
91
  |--------|---------|---------|
353
- | `type` | The input type (for UI adapters) | `:text`, `:number`, `:single_select`, `:multi_select`, `:number_matrix` |
354
- | `question` | The prompt shown to the user | `"What is your filing status?"` |
355
- | `options` | Available choices (for select types) | `%w[W2 1099 Business]` |
356
- | `fields` | Named fields (for matrix types) | `%w[RealEstate SCorp LLC]` |
357
- | `transition` | Where to go next (with optional condition) | `transition to: :next_step, if_rule: equals(:field, "value")` |
358
- | `visible_if` | Visibility rule (for DAG mode) | `visible_if contains(:income, "Rental")` |
359
-
360
- ### Step Types
361
-
362
- Step types are semantic labels consumed by UI adapters. The engine itself is type-agnostic — it stores whatever value you pass to `engine.answer(value)`. The types communicate intent to renderers:
363
-
364
- ```ruby
365
- step :filing_status do
366
- type :single_select # One choice from a list
367
- question "What is your filing status?"
368
- options %w[single married_filing_jointly married_filing_separately head_of_household]
369
- transition to: :dependents
370
- end
371
-
372
- step :income_types do
373
- type :multi_select # Multiple choices from a list
374
- question "Select all income types."
375
- options %w[W2 1099 Business Investment Rental]
376
- transition to: :business, if_rule: contains(:income_types, "Business")
377
- transition to: :summary
378
- end
379
-
380
- step :dependents do
381
- type :number # A numeric value
382
- question "How many dependents?"
383
- transition to: :income_types
384
- end
385
-
386
- step :business_details do
387
- type :number_matrix # Multiple named numeric fields
388
- question "How many of each business type?"
389
- fields %w[RealEstate SCorp CCorp Trust LLC]
390
- transition to: :summary
391
- end
392
-
393
- step :notes do
394
- type :text # Free-form text
395
- question "Any additional notes?"
396
- transition to: :summary
397
- end
398
- ```
92
+ | `type` | Input type (for UI adapters) | `:text`, `:number`, `:single_select`, `:multi_select`, `:number_matrix`, `:ai_intake` |
93
+ | `question` | Prompt shown to the user | `"What is your filing status?"` |
94
+ | `options` | Available choices (select types) | `%w[W2 1099 Business]` |
95
+ | `fields` | Named fields (matrix types) | `%w[RealEstate SCorp LLC]` |
96
+ | `decorations` | Opaque UI metadata | `{ hint: "metadata" }` |
97
+ | `transition` | Where to go next (with optional condition) | `transition to: :next, if_rule: equals(:field, "val")` |
98
+ | `visible_if` | Visibility rule (DAG mode) | `visible_if contains(:income, "Rental")` |
99
+ | `max_clarifications` | Max follow-up rounds for `:ai_intake` steps | `max_clarifications 3` |
399
100
 
400
101
  ### Transitions
401
102
 
402
- Transitions define the edges of the flow graph. They are evaluated **in order** the first matching transition wins:
103
+ Evaluated **in order** — the first matching transition wins. A transition with no `if_rule:` always matches (use as fallback):
403
104
 
404
105
  ```ruby
405
106
  step :income_types do
@@ -407,23 +108,15 @@ step :income_types do
407
108
  question "Select income types."
408
109
  options %w[W2 1099 Business Investment Rental]
409
110
 
410
- # Conditional transitions (checked in order)
411
- transition to: :business_count, if_rule: contains(:income_types, "Business")
412
- transition to: :investment_details, if_rule: contains(:income_types, "Investment")
413
- transition to: :rental_details, if_rule: contains(:income_types, "Rental")
414
-
415
- # Unconditional fallback (always matches)
416
- transition to: :state_filing
111
+ transition to: :business_count, if_rule: contains(:income_types, "Business")
112
+ transition to: :investment_details, if_rule: contains(:income_types, "Investment")
113
+ transition to: :state_filing # unconditional fallback
417
114
  end
418
115
  ```
419
116
 
420
- **Key behavior:** Only the *first* matching transition fires. If the user selects `["Business", "Investment", "Rental"]`, the engine goes to `:business_count` first. The subsequent steps must themselves include transitions to eventually reach `:investment_details` and `:rental_details`.
421
-
422
- A transition with no `if_rule:` always matches — use it as a fallback at the end of the list.
423
-
424
117
  ### Visibility Rules
425
118
 
426
- Nodes can have visibility conditions for DAG-mode rendering, where a UI adapter shows/hides steps dynamically:
119
+ Steps can have visibility conditions for DAG-mode rendering:
427
120
 
428
121
  ```ruby
429
122
  step :spouse_income do
@@ -434,700 +127,381 @@ step :spouse_income do
434
127
  end
435
128
  ```
436
129
 
437
- The engine exposes this via `node.visible?(answers)`, which returns `true` when the rule is satisfied (or when no visibility rule is set).
438
-
439
130
  ## Rule System
440
131
 
441
- Rules are **AST objects** — not hashes, not strings. They are immutable, composable, and evaluate polymorphically.
132
+ Rules are **immutable AST objects** — composable and evaluated polymorphically.
442
133
 
443
134
  ### Atomic Rules
444
135
 
445
- | Rule | DSL Helper | Evaluates | String Representation |
446
- |------|------------|-----------|----------------------|
447
- | `Contains` | `contains(:field, "val")` | `Array(answers[:field]).include?("val")` | `val in field` |
448
- | `Equals` | `equals(:field, "val")` | `answers[:field] == "val"` | `field == val` |
449
- | `GreaterThan` | `greater_than(:field, 10)` | `answers[:field].to_i > 10` | `field > 10` |
450
- | `LessThan` | `less_than(:field, 5)` | `answers[:field].to_i < 5` | `field < 5` |
451
- | `NotEmpty` | `not_empty(:field)` | `answers[:field]` is not nil and not empty | `field is not empty` |
136
+ | Helper | Evaluates |
137
+ |--------|-----------|
138
+ | `contains(:field, "val")` | `Array(answers[:field]).include?("val")` |
139
+ | `equals(:field, "val")` | `answers[:field] == "val"` |
140
+ | `greater_than(:field, 10)` | `answers[:field].to_i > 10` |
141
+ | `less_than(:field, 5)` | `answers[:field].to_i < 5` |
142
+ | `not_empty(:field)` | `answers[:field]` is not nil and not empty |
452
143
 
453
144
  ### Composite Rules
454
145
 
455
- Combine atomic rules with boolean logic:
456
-
457
- ```ruby
458
- # AND — all conditions must be true
459
- transition to: :special_review,
460
- if_rule: all(
461
- equals(:filing_status, "married_filing_jointly"),
462
- contains(:income_types, "Business"),
463
- greater_than(:business_count, 2)
464
- )
465
-
466
- # OR — at least one condition must be true
467
- transition to: :alt_path,
468
- if_rule: any(
469
- contains(:income_types, "Investment"),
470
- contains(:income_types, "Rental")
471
- )
472
- ```
473
-
474
- Composites nest arbitrarily:
475
-
476
146
  ```ruby
477
- transition to: :complex_review,
478
- if_rule: all(
479
- equals(:filing_status, "married_filing_jointly"),
480
- any(
481
- greater_than(:business_count, 3),
482
- contains(:income_types, "Rental")
483
- ),
484
- not_empty(:dependents)
485
- )
486
- ```
487
-
488
- ### How Rules Evaluate
489
-
490
- Every rule implements `evaluate(answers)` where `answers` is the engine's hash of `{ step_id => value }`:
147
+ # AND — all must be true
148
+ transition to: :special, if_rule: all(
149
+ equals(:status, "married"),
150
+ contains(:income, "Business"),
151
+ greater_than(:business_count, 2)
152
+ )
491
153
 
492
- ```ruby
493
- rule = FlowEngine::Rules::Contains.new(:income_types, "Business")
494
- rule.evaluate({ income_types: ["W2", "Business"] }) # => true
495
- rule.evaluate({ income_types: ["W2"] }) # => false
154
+ # OR — at least one must be true
155
+ transition to: :alt, if_rule: any(
156
+ contains(:income, "Investment"),
157
+ contains(:income, "Rental")
158
+ )
496
159
 
497
- rule = FlowEngine::Rules::All.new(
498
- FlowEngine::Rules::Equals.new(:status, "married"),
499
- FlowEngine::Rules::GreaterThan.new(:dependents, 0)
160
+ # Nest arbitrarily
161
+ transition to: :complex, if_rule: all(
162
+ equals(:status, "married"),
163
+ any(greater_than(:biz_count, 3), contains(:income, "Rental")),
164
+ not_empty(:dependents)
500
165
  )
501
- rule.evaluate({ status: "married", dependents: 2 }) # => true
502
- rule.evaluate({ status: "single", dependents: 2 }) # => false
503
166
  ```
504
167
 
505
168
  ## Engine API
506
169
 
507
- ### Creating and Running
508
-
509
170
  ```ruby
510
- definition = FlowEngine.define { ... }
511
171
  engine = FlowEngine::Engine.new(definition)
512
172
  ```
513
173
 
514
- ### Methods
515
-
516
174
  | Method | Returns | Description |
517
175
  |--------|---------|-------------|
518
- | `engine.current_step_id` | `Symbol` or `nil` | The ID of the current step |
519
- | `engine.current_step` | `Node` or `nil` | The current Node object |
520
- | `engine.answer(value)` | `nil` | Records the answer and advances |
521
- | `engine.submit_introduction(text, llm_client:)` | `nil` | LLM-parses text, pre-fills answers, auto-advances |
522
- | `engine.finished?` | `Boolean` | `true` when there are no more steps |
523
- | `engine.answers` | `Hash` | All collected answers `{ step_id => value }` |
524
- | `engine.history` | `Array<Symbol>` | Ordered list of visited step IDs |
525
- | `engine.introduction_text` | `String` or `nil` | The raw introduction text submitted |
526
- | `engine.definition` | `Definition` | The immutable flow definition |
176
+ | `current_step_id` | `Symbol?` | Current step ID |
177
+ | `current_step` | `Node?` | Current Node object |
178
+ | `answer(value)` | `nil` | Records answer and advances |
179
+ | `finished?` | `Boolean` | True when no more steps |
180
+ | `answers` | `Hash` | All collected `{ step_id => value }` |
181
+ | `history` | `Array<Symbol>` | Visited step IDs in order |
182
+ | `definition` | `Definition` | The immutable flow definition |
183
+ | `submit_introduction(text, llm_client:)` | `nil` | One-shot LLM pre-fill from free-form text |
184
+ | `submit_ai_intake(text, llm_client:)` | `ClarificationResult` | Multi-round AI intake for current `:ai_intake` step |
185
+ | `submit_clarification(text, llm_client:)` | `ClarificationResult` | Continue an active AI intake conversation |
186
+ | `introduction_text` | `String?` | Raw introduction text submitted |
187
+ | `clarification_round` | `Integer` | Current AI intake round (0 if none active) |
188
+ | `conversation_history` | `Array<Hash>` | AI intake conversation `[{role:, text:}]` |
189
+ | `to_state` / `.from_state` | `Hash` / `Engine` | State serialization for persistence |
527
190
 
528
191
  ### Error Handling
529
192
 
530
193
  ```ruby
531
- # Answering after the flow is complete
532
- engine.answer("extra")
533
- # => raises FlowEngine::Errors::AlreadyFinishedError
534
-
535
- # Referencing an unknown step in a definition
536
- definition.step(:nonexistent)
537
- # => raises FlowEngine::Errors::UnknownStepError
538
-
539
- # Invalid definition (start step doesn't exist)
540
- FlowEngine.define do
541
- start :missing
542
- step :other do
543
- type :text
544
- question "Hello"
545
- end
546
- end
547
- # => raises FlowEngine::Errors::DefinitionError
548
-
549
- # Sensitive data in introduction
550
- engine.submit_introduction("My SSN is 123-45-6789", llm_client: client)
551
- # => raises FlowEngine::Errors::SensitiveDataError
552
-
553
- # Introduction exceeds maxlength
554
- engine.submit_introduction("A" * 3000, llm_client: client)
555
- # => raises FlowEngine::Errors::ValidationError
556
-
557
- # Missing API key or LLM response parsing failure
558
- FlowEngine::LLM::Adapters::OpenAIAdapter.new # without OPENAI_API_KEY
559
- # => raises FlowEngine::Errors::LLMError
194
+ engine.answer("extra") # AlreadyFinishedError (flow finished)
195
+ definition.step(:nonexistent) # UnknownStepError
196
+ engine.submit_introduction("SSN: 123-45-6789", llm_client:) # SensitiveDataError
197
+ engine.submit_introduction("A" * 3000, llm_client:) # ValidationError (maxlength)
198
+ engine.submit_ai_intake("hi", llm_client:) # EngineError (not on an ai_intake step)
560
199
  ```
561
200
 
562
- ## Validation
201
+ ---
563
202
 
564
- The engine accepts a pluggable validator via the adapter pattern. The core gem ships with a `NullAdapter` (always passes) and defines the interface for custom adapters:
203
+ ## LLM Integration
565
204
 
566
- ```ruby
567
- # The adapter interface
568
- class FlowEngine::Validation::Adapter
569
- def validate(node, input)
570
- # Must return a FlowEngine::Validation::Result
571
- raise NotImplementedError
572
- end
573
- end
205
+ FlowEngine offers two ways to use LLMs for pre-filling answers from free-form text.
574
206
 
575
- # Result object
576
- FlowEngine::Errors::Validation::Result.new(valid: true, errors: [])
577
- FlowEngine::Errors::Validation::Result.new(valid: false, errors: ["must be a number"])
578
- ```
207
+ ### LLM Adapters & Configuration
579
208
 
580
- ### Custom Validator Example
209
+ The gem ships with three adapters (all via [`ruby_llm`](https://github.com/crmne/ruby_llm)):
581
210
 
582
- ```ruby
583
- class MyValidator < FlowEngine::Validation::Adapter
584
- def validate(node, input)
585
- errors = []
211
+ | Adapter | Env Variable |
212
+ |---------|-------------|
213
+ | `AnthropicAdapter` | `ANTHROPIC_API_KEY` |
214
+ | `OpenAIAdapter` | `OPENAI_API_KEY` |
215
+ | `GeminiAdapter` | `GEMINI_API_KEY` |
586
216
 
587
- case node.type
588
- when :number
589
- errors << "must be a number" unless input.is_a?(Numeric)
590
- when :single_select
591
- errors << "invalid option" unless node.options&.include?(input)
592
- when :multi_select
593
- unless input.is_a?(Array) && input.all? { |v| node.options&.include?(v) }
594
- errors << "invalid options"
595
- end
596
- end
597
-
598
- FlowEngine::Errors::Validation::Result.new(valid: errors.empty?, errors: errors)
599
- end
600
- end
217
+ The file [`resources/models.yml`](resources/models.yml) defines three model tiers per vendor (`top`, `default`, `fastest`). Override with `$FLOWENGINE_LLM_MODELS_PATH`.
601
218
 
602
- engine = FlowEngine::Engine.new(definition, validator: MyValidator.new)
603
- engine.answer("not_a_number") # => raises FlowEngine::Errors::ValidationError
219
+ ```yaml
220
+ models:
221
+ vendors:
222
+ anthropic:
223
+ var: "ANTHROPIC_API_KEY"
224
+ top: "claude-opus-4-6"
225
+ default: "claude-sonnet-4-6"
226
+ fastest: "claude-haiku-4-5-20251001"
227
+ openai:
228
+ var: "OPENAI_API_KEY"
229
+ top: "gpt-5.4"
230
+ default: "gpt-5-mini"
231
+ fastest: "gpt-5-nano"
232
+ gemini:
233
+ var: "GEMINI_API_KEY"
234
+ top: "gemini-3.1-pro-preview"
235
+ default: "gemini-2.5-flash"
236
+ fastest: "gemini-2.5-flash-lite"
604
237
  ```
605
238
 
606
- ## Mermaid Diagram Export
239
+ ```ruby
240
+ # Auto-detect from environment (checks Anthropic > OpenAI > Gemini)
241
+ client = FlowEngine::LLM.auto_client
607
242
 
608
- Export any flow definition as a [Mermaid](https://mermaid.js.org/) flowchart:
243
+ # Explicit provider / model override
244
+ client = FlowEngine::LLM.auto_client(anthropic_api_key: "sk-ant-...", model: "claude-haiku-4-5-20251001")
609
245
 
610
- ```ruby
611
- exporter = FlowEngine::Graph::MermaidExporter.new(definition)
612
- puts exporter.export
246
+ # Manual adapter
247
+ adapter = FlowEngine::LLM::Adapters::OpenAIAdapter.new(api_key: ENV["OPENAI_API_KEY"])
248
+ client = FlowEngine::LLM::Client.new(adapter: adapter, model: "gpt-5-mini")
613
249
  ```
614
250
 
615
- Output:
616
-
617
- ```mermaid
618
- flowchart TD
619
- name["What is your name?"]
620
- name --> age
621
- age["How old are you?"]
622
- age -->|"age > 20"| beverage
623
- age --> thanks
624
- beverage["Pick a drink."]
625
- beverage --> thanks
626
- thanks["Thank you for your responses!"]
627
- ```
251
+ ### Sensitive Data Protection
252
+
253
+ Before any text reaches the LLM, `SensitiveDataFilter` scans for SSN, ITIN, EIN, and nine-consecutive-digit patterns. If detected, a `SensitiveDataError` is raised immediately — no LLM call is made.
628
254
 
629
- ## Complete Example: Tax Intake Flow
255
+ ---
630
256
 
631
- Here's a realistic 17-step tax intake wizard that demonstrates every feature of the DSL.
257
+ ### Option 1: Introduction (One-Shot Pre-Fill)
632
258
 
633
- ### Flow Definition
259
+ A flow-level free-form text field parsed by the LLM in a single pass. Good for simple intake where one prompt is enough.
634
260
 
635
261
  ```ruby
636
- tax_intake = FlowEngine.define do
262
+ definition = FlowEngine.define do
637
263
  start :filing_status
638
264
 
265
+ introduction label: "Tell us about your tax situation",
266
+ placeholder: "e.g. I am married, filing jointly, with 2 dependents...",
267
+ maxlength: 2000
268
+
639
269
  step :filing_status do
640
270
  type :single_select
641
- question "What is your filing status for 2025?"
642
- options %w[single married_filing_jointly married_filing_separately head_of_household]
271
+ question "What is your filing status?"
272
+ options %w[single married_filing_jointly head_of_household]
643
273
  transition to: :dependents
644
274
  end
645
275
 
646
276
  step :dependents do
647
277
  type :number
648
- question "How many dependents do you have?"
649
- transition to: :income_types
650
- end
651
-
652
- step :income_types do
653
- type :multi_select
654
- question "Select all income types that apply to you in 2025."
655
- options %w[W2 1099 Business Investment Rental Retirement]
656
- transition to: :business_count, if_rule: contains(:income_types, "Business")
657
- transition to: :investment_details, if_rule: contains(:income_types, "Investment")
658
- transition to: :rental_details, if_rule: contains(:income_types, "Rental")
659
- transition to: :state_filing
660
- end
661
-
662
- step :business_count do
663
- type :number
664
- question "How many total businesses do you own or are a partner in?"
665
- transition to: :complex_business_info, if_rule: greater_than(:business_count, 2)
666
- transition to: :business_details
278
+ question "How many dependents?"
667
279
  end
280
+ end
668
281
 
669
- step :complex_business_info do
670
- type :text
671
- question "With more than 2 businesses, please provide your primary EIN and a brief description of each entity."
672
- transition to: :business_details
673
- end
282
+ engine = FlowEngine::Engine.new(definition)
283
+ engine.submit_introduction(
284
+ "I am married filing jointly with 2 dependents",
285
+ llm_client: FlowEngine::LLM.auto_client
286
+ )
287
+ engine.answers # => { filing_status: "married_filing_jointly", dependents: 2 }
288
+ engine.finished? # => true
289
+ ```
674
290
 
675
- step :business_details do
676
- type :number_matrix
677
- question "How many of each business type do you own?"
678
- fields %w[RealEstate SCorp CCorp Trust LLC]
679
- transition to: :investment_details, if_rule: contains(:income_types, "Investment")
680
- transition to: :rental_details, if_rule: contains(:income_types, "Rental")
681
- transition to: :state_filing
682
- end
291
+ ---
683
292
 
684
- step :investment_details do
685
- type :multi_select
686
- question "What types of investments do you hold?"
687
- options %w[Stocks Bonds Crypto RealEstate MutualFunds]
688
- transition to: :crypto_details, if_rule: contains(:investment_details, "Crypto")
689
- transition to: :rental_details, if_rule: contains(:income_types, "Rental")
690
- transition to: :state_filing
691
- end
293
+ ### Option 2: AI Intake Steps (Multi-Round Conversational)
692
294
 
693
- step :crypto_details do
694
- type :text
695
- question "Please describe your cryptocurrency transactions (exchanges used, approximate number of transactions)."
696
- transition to: :rental_details, if_rule: contains(:income_types, "Rental")
697
- transition to: :state_filing
698
- end
295
+ An `:ai_intake` step type that supports multi-round clarification. Place them anywhere in the flow — including multiple times. The LLM extracts answers for downstream steps and can ask follow-up questions.
699
296
 
700
- step :rental_details do
701
- type :number_matrix
702
- question "Provide details about your rental properties."
703
- fields %w[Residential Commercial Vacation]
704
- transition to: :state_filing
705
- end
297
+ ```ruby
298
+ definition = FlowEngine.define do
299
+ start :personal_intake
706
300
 
707
- step :state_filing do
708
- type :multi_select
709
- question "Which states do you need to file in?"
710
- options %w[California NewYork Texas Florida Illinois Other]
711
- transition to: :foreign_accounts
301
+ # AI intake: collects info for the steps that follow
302
+ step :personal_intake do
303
+ type :ai_intake
304
+ question "Tell us about yourself and your tax situation"
305
+ max_clarifications 2 # up to 2 follow-up rounds (0 = one-shot)
306
+ transition to: :filing_status
712
307
  end
713
308
 
714
- step :foreign_accounts do
309
+ step :filing_status do
715
310
  type :single_select
716
- question "Do you have any foreign financial accounts (bank accounts, securities, or financial assets)?"
717
- options %w[yes no]
718
- transition to: :foreign_account_details, if_rule: equals(:foreign_accounts, "yes")
719
- transition to: :deduction_types
311
+ question "What is your filing status?"
312
+ options %w[single married_joint married_separate head_of_household]
313
+ transition to: :dependents
720
314
  end
721
315
 
722
- step :foreign_account_details do
316
+ step :dependents do
723
317
  type :number
724
- question "How many foreign accounts do you have?"
725
- transition to: :deduction_types
318
+ question "How many dependents do you claim?"
319
+ transition to: :income_types
726
320
  end
727
321
 
728
- step :deduction_types do
322
+ step :income_types do
729
323
  type :multi_select
730
- question "Which additional deductions apply to you?"
731
- options %w[Medical Charitable Education Mortgage None]
732
- transition to: :charitable_amount, if_rule: contains(:deduction_types, "Charitable")
733
- transition to: :contact_info
734
- end
735
-
736
- step :charitable_amount do
737
- type :number
738
- question "What is your total estimated charitable contribution amount for 2025?"
739
- transition to: :charitable_documentation, if_rule: greater_than(:charitable_amount, 5000)
740
- transition to: :contact_info
741
- end
742
-
743
- step :charitable_documentation do
744
- type :text
745
- question "For charitable contributions over $5,000, please list the organizations and amounts."
746
- transition to: :contact_info
747
- end
748
-
749
- step :contact_info do
750
- type :text
751
- question "Please provide your contact information (name, email, phone)."
752
- transition to: :review
753
- end
754
-
755
- step :review do
756
- type :text
757
- question "Thank you! Please review your information. Type 'confirm' to submit."
324
+ question "Select all income types that apply"
325
+ options %w[W2 1099 Business Investment Rental]
758
326
  end
759
327
  end
760
328
  ```
761
329
 
762
- ### Scenario 1: Maximum Path (17 steps visited)
763
-
764
- A married filer with all income types, 4 businesses, crypto, rentals, foreign accounts, and high charitable giving:
330
+ #### Running an AI Intake
765
331
 
766
332
  ```ruby
767
- engine = FlowEngine::Engine.new(tax_intake)
768
-
769
- engine.answer("married_filing_jointly")
770
- engine.answer(3)
771
- engine.answer(%w[W2 1099 Business Investment Rental Retirement])
772
- engine.answer(4)
773
- engine.answer("EIN: 12-3456789. Entities: Alpha LLC, Beta SCorp, Gamma LLC, Delta CCorp")
774
- engine.answer({ "RealEstate" => 1, "SCorp" => 1, "CCorp" => 1, "Trust" => 0, "LLC" => 2 })
775
- engine.answer(%w[Stocks Bonds Crypto RealEstate])
776
- engine.answer("Coinbase and Kraken, approximately 150 transactions in 2025")
777
- engine.answer({ "Residential" => 2, "Commercial" => 1, "Vacation" => 0 })
778
- engine.answer(%w[California NewYork])
779
- engine.answer("yes")
780
- engine.answer(3)
781
- engine.answer(%w[Medical Charitable Education Mortgage])
782
- engine.answer(12_000)
783
- engine.answer("Red Cross: $5,000; Habitat for Humanity: $4,000; Local Food Bank: $3,000")
784
- engine.answer("Jane Smith, jane.smith@example.com, 555-123-4567")
785
- engine.answer("confirm")
786
- ```
333
+ engine = FlowEngine::Engine.new(definition)
334
+ client = FlowEngine::LLM.auto_client
787
335
 
788
- **Collected data:**
789
-
790
- ```json
791
- {
792
- "filing_status": "married_filing_jointly",
793
- "dependents": 3,
794
- "income_types": ["W2", "1099", "Business", "Investment", "Rental", "Retirement"],
795
- "business_count": 4,
796
- "complex_business_info": "EIN: 12-3456789. Entities: Alpha LLC, Beta SCorp, Gamma LLC, Delta CCorp",
797
- "business_details": {
798
- "RealEstate": 1,
799
- "SCorp": 1,
800
- "CCorp": 1,
801
- "Trust": 0,
802
- "LLC": 2
803
- },
804
- "investment_details": ["Stocks", "Bonds", "Crypto", "RealEstate"],
805
- "crypto_details": "Coinbase and Kraken, approximately 150 transactions in 2025",
806
- "rental_details": {
807
- "Residential": 2,
808
- "Commercial": 1,
809
- "Vacation": 0
810
- },
811
- "state_filing": ["California", "NewYork"],
812
- "foreign_accounts": "yes",
813
- "foreign_account_details": 3,
814
- "deduction_types": ["Medical", "Charitable", "Education", "Mortgage"],
815
- "charitable_amount": 12000,
816
- "charitable_documentation": "Red Cross: $5,000; Habitat for Humanity: $4,000; Local Food Bank: $3,000",
817
- "contact_info": "Jane Smith, jane.smith@example.com, 555-123-4567",
818
- "review": "confirm"
819
- }
336
+ # Round 1: initial submission
337
+ result = engine.submit_ai_intake(
338
+ "I'm married filing jointly, 2 kids, W2 and business income",
339
+ llm_client: client
340
+ )
341
+ result.done? # => false (LLM wants to ask more)
342
+ result.follow_up # => "Which state do you primarily reside in?"
343
+ result.round # => 1
344
+ result.pending_steps # => [:income_types] (steps still unanswered)
345
+ engine.answers # => { filing_status: "married_joint", dependents: 2 }
346
+
347
+ # Round 2: respond to follow-up
348
+ result = engine.submit_clarification(
349
+ "California. W2 from my job and a small LLC.",
350
+ llm_client: client
351
+ )
352
+ result.done? # => true (no more follow-ups or max reached)
353
+ engine.answers[:income_types] # => ["W2", "Business"]
354
+
355
+ # Engine auto-advances past all pre-filled steps
356
+ engine.finished? # => true
820
357
  ```
821
358
 
822
- **Path taken** (all 17 steps):
359
+ #### ClarificationResult
823
360
 
824
- ```
825
- filing_status -> dependents -> income_types -> business_count ->
826
- complex_business_info -> business_details -> investment_details ->
827
- crypto_details -> rental_details -> state_filing -> foreign_accounts ->
828
- foreign_account_details -> deduction_types -> charitable_amount ->
829
- charitable_documentation -> contact_info -> review
830
- ```
361
+ Each `submit_ai_intake` / `submit_clarification` call returns a `ClarificationResult`:
362
+
363
+ | Attribute | Type | Description |
364
+ |-----------|------|-------------|
365
+ | `answered` | `Hash` | Step answers filled this round |
366
+ | `pending_steps` | `Array<Symbol>` | Steps still unanswered |
367
+ | `follow_up` | `String?` | LLM's follow-up question, or `nil` if done |
368
+ | `round` | `Integer` | Current round number (1-based) |
369
+ | `done?` | `Boolean` | True when `follow_up` is nil |
370
+
371
+ When `max_clarifications` is reached, the intake finalizes even if the LLM wanted to ask more. Unanswered steps are presented normally to the user.
831
372
 
832
- ### Scenario 2: Minimum Path (8 steps visited)
373
+ #### Multiple AI Intakes in One Flow
833
374
 
834
- A single filer, W2 income only, no special deductions:
375
+ Place `:ai_intake` steps at multiple points to break up the conversation:
835
376
 
836
377
  ```ruby
837
- engine = FlowEngine::Engine.new(tax_intake)
838
-
839
- engine.answer("single")
840
- engine.answer(0)
841
- engine.answer(["W2"])
842
- engine.answer(["Texas"])
843
- engine.answer("no")
844
- engine.answer(["None"])
845
- engine.answer("John Doe, john.doe@example.com, 555-987-6543")
846
- engine.answer("confirm")
847
- ```
378
+ definition = FlowEngine.define do
379
+ start :personal_intake
848
380
 
849
- **Collected data:**
850
-
851
- ```json
852
- {
853
- "filing_status": "single",
854
- "dependents": 0,
855
- "income_types": ["W2"],
856
- "state_filing": ["Texas"],
857
- "foreign_accounts": "no",
858
- "deduction_types": ["None"],
859
- "contact_info": "John Doe, john.doe@example.com, 555-987-6543",
860
- "review": "confirm"
861
- }
862
- ```
381
+ step :personal_intake do
382
+ type :ai_intake
383
+ question "Tell us about yourself and your tax situation"
384
+ max_clarifications 2
385
+ transition to: :filing_status
386
+ end
863
387
 
864
- **Path taken** (8 steps — skipped 9 steps):
388
+ step :filing_status do
389
+ # ... personal info steps ...
390
+ transition to: :financial_intake
391
+ end
865
392
 
866
- ```
867
- filing_status -> dependents -> income_types -> state_filing ->
868
- foreign_accounts -> deduction_types -> contact_info -> review
869
- ```
393
+ # Second AI intake mid-flow
394
+ step :financial_intake do
395
+ type :ai_intake
396
+ question "Describe your financial situation: accounts, debts, investments"
397
+ max_clarifications 3
398
+ transition to: :annual_income
399
+ end
870
400
 
871
- **Skipped:** `business_count`, `complex_business_info`, `business_details`, `investment_details`, `crypto_details`, `rental_details`, `foreign_account_details`, `charitable_amount`, `charitable_documentation`.
401
+ step :annual_income do
402
+ # ... financial steps ...
403
+ end
404
+ end
405
+ ```
872
406
 
873
- ### Scenario 3: Medium Path (12 steps visited)
407
+ Each `:ai_intake` step maintains its own conversation history and round counter. State is fully serializable for persistence between requests.
874
408
 
875
- Married, with business + investment income, low charitable giving:
409
+ ### Custom LLM Adapters
876
410
 
877
411
  ```ruby
878
- engine = FlowEngine::Engine.new(tax_intake)
879
-
880
- engine.answer("married_filing_separately")
881
- engine.answer(1)
882
- engine.answer(%w[W2 Business Investment])
883
- engine.answer(2) # <= 2 businesses, no complex_business_info
884
- engine.answer({ "RealEstate" => 0, "SCorp" => 1, "CCorp" => 0, "Trust" => 0, "LLC" => 1 })
885
- engine.answer(%w[Stocks Bonds MutualFunds]) # no Crypto, no crypto_details
886
- engine.answer(%w[California Illinois])
887
- engine.answer("no") # no foreign accounts
888
- engine.answer(%w[Charitable Mortgage])
889
- engine.answer(3000) # <= 5000, no documentation needed
890
- engine.answer("Alice Johnson, alice.j@example.com, 555-555-0100")
891
- engine.answer("confirm")
892
- ```
412
+ class MyAdapter < FlowEngine::LLM::Adapter
413
+ def initialize(api_key:)
414
+ super()
415
+ @api_key = api_key
416
+ end
893
417
 
894
- **Collected data:**
895
-
896
- ```json
897
- {
898
- "filing_status": "married_filing_separately",
899
- "dependents": 1,
900
- "income_types": ["W2", "Business", "Investment"],
901
- "business_count": 2,
902
- "business_details": {
903
- "RealEstate": 0,
904
- "SCorp": 1,
905
- "CCorp": 0,
906
- "Trust": 0,
907
- "LLC": 1
908
- },
909
- "investment_details": ["Stocks", "Bonds", "MutualFunds"],
910
- "state_filing": ["California", "Illinois"],
911
- "foreign_accounts": "no",
912
- "deduction_types": ["Charitable", "Mortgage"],
913
- "charitable_amount": 3000,
914
- "contact_info": "Alice Johnson, alice.j@example.com, 555-555-0100",
915
- "review": "confirm"
916
- }
418
+ def chat(system_prompt:, user_prompt:, model:)
419
+ # Must return response text (expected to be JSON)
420
+ end
421
+ end
917
422
  ```
918
423
 
919
- **Path taken** (12 steps):
920
-
921
- ```
922
- filing_status -> dependents -> income_types -> business_count ->
923
- business_details -> investment_details -> state_filing ->
924
- foreign_accounts -> deduction_types -> charitable_amount ->
925
- contact_info -> review
926
- ```
424
+ ---
927
425
 
928
- ### Scenario 4: Rental + Foreign Accounts (10 steps visited)
426
+ ## State Persistence
929
427
 
930
- Head of household, 1099 + rental income, foreign accounts, no charitable:
428
+ The engine's full state including AI intake conversation history — can be serialized and restored:
931
429
 
932
430
  ```ruby
933
- engine = FlowEngine::Engine.new(tax_intake)
934
-
935
- engine.answer("head_of_household")
936
- engine.answer(2)
937
- engine.answer(%w[1099 Rental])
938
- engine.answer({ "Residential" => 1, "Commercial" => 0, "Vacation" => 1 })
939
- engine.answer(["Florida"])
940
- engine.answer("yes")
941
- engine.answer(1)
942
- engine.answer(%w[Medical Education])
943
- engine.answer("Bob Lee, bob@example.com, 555-000-1111")
944
- engine.answer("confirm")
945
- ```
431
+ state = engine.to_state
432
+ # => { current_step_id: :income_types, answers: { ... }, history: [...],
433
+ # introduction_text: "...", clarification_round: 1,
434
+ # conversation_history: [{role: :user, text: "..."}, ...],
435
+ # active_intake_step_id: :personal_intake }
946
436
 
947
- **Collected data:**
948
-
949
- ```json
950
- {
951
- "filing_status": "head_of_household",
952
- "dependents": 2,
953
- "income_types": ["1099", "Rental"],
954
- "rental_details": {
955
- "Residential": 1,
956
- "Commercial": 0,
957
- "Vacation": 1
958
- },
959
- "state_filing": ["Florida"],
960
- "foreign_accounts": "yes",
961
- "foreign_account_details": 1,
962
- "deduction_types": ["Medical", "Education"],
963
- "contact_info": "Bob Lee, bob@example.com, 555-000-1111",
964
- "review": "confirm"
965
- }
437
+ restored = FlowEngine::Engine.from_state(definition, state)
966
438
  ```
967
439
 
968
- **Path taken** (10 steps):
440
+ Round-trips through JSON (string keys) are handled automatically.
969
441
 
970
- ```
971
- filing_status -> dependents -> income_types -> rental_details ->
972
- state_filing -> foreign_accounts -> foreign_account_details ->
973
- deduction_types -> contact_info -> review
974
- ```
442
+ ## Validation
975
443
 
976
- ### Comparing Collected Data Across Paths
977
-
978
- The shape of the collected data depends entirely on which path the user takes through the graph. Here's a side-by-side of which keys appear in each scenario:
979
-
980
- | Answer Key | Max (17) | Min (8) | Medium (12) | Rental (10) |
981
- |---|:---:|:---:|:---:|:---:|
982
- | `filing_status` | x | x | x | x |
983
- | `dependents` | x | x | x | x |
984
- | `income_types` | x | x | x | x |
985
- | `business_count` | x | | x | |
986
- | `complex_business_info` | x | | | |
987
- | `business_details` | x | | x | |
988
- | `investment_details` | x | | x | |
989
- | `crypto_details` | x | | | |
990
- | `rental_details` | x | | | x |
991
- | `state_filing` | x | x | x | x |
992
- | `foreign_accounts` | x | x | x | x |
993
- | `foreign_account_details` | x | | | x |
994
- | `deduction_types` | x | x | x | x |
995
- | `charitable_amount` | x | | x | |
996
- | `charitable_documentation` | x | | | |
997
- | `contact_info` | x | x | x | x |
998
- | `review` | x | x | x | x |
999
-
1000
- ## Composing Complex Rules
1001
-
1002
- ### Example: Multi-Condition Branching
444
+ Pluggable validators via the adapter pattern. Ships with `NullAdapter` (always passes):
1003
445
 
1004
446
  ```ruby
1005
- definition = FlowEngine.define do
1006
- start :status
1007
-
1008
- step :status do
1009
- type :single_select
1010
- question "Filing status?"
1011
- options %w[single married]
1012
- transition to: :dependents
447
+ class MyValidator < FlowEngine::Validation::Adapter
448
+ def validate(node, input)
449
+ errors = []
450
+ errors << "must be a number" if node.type == :number && !input.is_a?(Numeric)
451
+ FlowEngine::Validation::Result.new(valid: errors.empty?, errors: errors)
1013
452
  end
453
+ end
1014
454
 
1015
- step :dependents do
1016
- type :number
1017
- question "How many dependents?"
1018
- transition to: :income
1019
- end
455
+ engine = FlowEngine::Engine.new(definition, validator: MyValidator.new)
456
+ ```
1020
457
 
1021
- step :income do
1022
- type :multi_select
1023
- question "Income types?"
1024
- options %w[W2 Business]
1025
-
1026
- # All three conditions must be true
1027
- transition to: :special_review,
1028
- if_rule: all(
1029
- equals(:status, "married"),
1030
- contains(:income, "Business"),
1031
- not_empty(:income)
1032
- )
1033
-
1034
- # At least one condition must be true
1035
- transition to: :alt_review,
1036
- if_rule: any(
1037
- less_than(:dependents, 2),
1038
- contains(:income, "W2")
1039
- )
1040
-
1041
- # Unconditional fallback
1042
- transition to: :default_review
1043
- end
458
+ ## Mermaid Diagram Export
1044
459
 
1045
- step :special_review do
1046
- type :text
1047
- question "Married with business income - special review required."
1048
- end
460
+ ```ruby
461
+ exporter = FlowEngine::Graph::MermaidExporter.new(definition)
462
+ puts exporter.export
463
+ ```
1049
464
 
1050
- step :alt_review do
1051
- type :text
1052
- question "Alternative review path."
1053
- end
465
+ ## Architecture
1054
466
 
1055
- step :default_review do
1056
- type :text
1057
- question "Default review."
1058
- end
1059
- end
1060
- ```
467
+ ![architecture](docs/floweingine-architecture.png)
1061
468
 
1062
- The four possible outcomes:
1063
-
1064
- | Inputs | Path | Why |
1065
- |--------|------|-----|
1066
- | married, 0 deps, `[W2, Business]` | `:special_review` | `all()` satisfied: married + Business + not empty |
1067
- | single, 0 deps, `[W2]` | `:alt_review` | `all()` fails (not married); `any()` passes (has W2) |
1068
- | single, 1 dep, `[Business]` | `:alt_review` | `all()` fails; `any()` passes (deps < 2) |
1069
- | single, 3 deps, `[Business]` | `:default_review` | `all()` fails; `any()` fails (deps not < 2, no W2) |
1070
-
1071
- ## Mermaid Diagram of the Tax Intake Flow
1072
-
1073
- ![Example](docs/flowengine-example.png)
1074
-
1075
- <details>
1076
- <summary>Expand to see Mermaid source</summary>
1077
-
1078
- ```mermaid
1079
- flowchart BT
1080
- filing_status["What is your filing status for 2025?"] --> dependents["How many dependents do you have?"]
1081
- dependents --> income_types["Select all income types that apply to you in 2025."]
1082
- income_types -- Business in income_types --> business_count["How many total businesses do you own or are a part..."]
1083
- income_types -- Investment in income_types --> investment_details["What types of investments do you hold?"]
1084
- income_types -- Rental in income_types --> rental_details["Provide details about your rental properties."]
1085
- income_types --> state_filing["Which states do you need to file in?"]
1086
- business_count -- business_count > 2 --> complex_business_info["With more than 2 businesses, please provide your p..."]
1087
- business_count --> business_details["How many of each business type do you own?"]
1088
- complex_business_info --> business_details
1089
- business_details -- Investment in income_types --> investment_details
1090
- business_details -- Rental in income_types --> rental_details
1091
- business_details --> state_filing
1092
- investment_details -- Crypto in investment_details --> crypto_details["Please describe your cryptocurrency transactions (..."]
1093
- investment_details -- Rental in income_types --> rental_details
1094
- investment_details --> state_filing
1095
- crypto_details -- Rental in income_types --> rental_details
1096
- crypto_details --> state_filing
1097
- rental_details --> state_filing
1098
- state_filing --> foreign_accounts["Do you have any foreign financial accounts (bank a..."]
1099
- foreign_accounts -- "foreign_accounts == yes" --> foreign_account_details["How many foreign accounts do you have?"]
1100
- foreign_accounts --> deduction_types["Which additional deductions apply to you?"]
1101
- foreign_account_details --> deduction_types
1102
- deduction_types -- Charitable in deduction_types --> charitable_amount["What is your total estimated charitable contributi..."]
1103
- deduction_types --> contact_info["Please provide your contact information (name, ema..."]
1104
- charitable_amount -- charitable_amount > 5000 --> charitable_documentation["For charitable contributions over $5,000, please l..."]
1105
- charitable_amount --> contact_info
1106
- charitable_documentation --> contact_info
1107
- contact_info --> review@{ label: "Thank you! Please review your information. Type 'c..." }
1108
-
1109
- review@{ shape: rect}
1110
- ```
469
+ The core has **zero UI logic**, **zero DB logic**, and **zero framework dependencies**. Adapters translate input/output, persist state, and render UI.
1111
470
 
1112
- </details>
471
+ | Component | Responsibility |
472
+ |-----------|---------------|
473
+ | `FlowEngine.define` | DSL entry point; returns a frozen `Definition` |
474
+ | `Definition` | Immutable flow graph (nodes + start step + introduction) |
475
+ | `Node` | Single step: type, question, options/fields, transitions, visibility |
476
+ | `Transition` | Directed edge with optional rule condition |
477
+ | `Rules::*` | AST nodes for conditional logic |
478
+ | `Evaluator` | Evaluates rules against the answer store |
479
+ | `Engine` | Stateful runtime: current step, answers, history, AI intake state |
480
+ | `ClarificationResult` | Immutable result from an AI intake round |
481
+ | `Introduction` | Immutable config for one-shot introduction (label, placeholder, maxlength) |
482
+ | `Validation::Adapter` | Interface for pluggable validation |
483
+ | `LLM::Client` | High-level: builds prompt, calls adapter, parses JSON |
484
+ | `LLM::Adapter` | Abstract LLM API interface (Anthropic, OpenAI, Gemini implementations) |
485
+ | `LLM::SensitiveDataFilter` | Rejects text containing SSN, ITIN, EIN patterns |
486
+ | `Graph::MermaidExporter` | Exports flow as a Mermaid diagram |
1113
487
 
1114
488
  ## Ecosystem
1115
489
 
1116
- FlowEngine is the core of a three-gem architecture:
1117
-
1118
490
  | Gem | Purpose |
1119
491
  |-----|---------|
1120
- | **`flowengine`** (this gem) | Core engine + LLM introduction parsing (depends on `ruby_llm`) |
1121
- | **`flowengine-cli`** | Terminal wizard adapter using [TTY Toolkit](https://ttytoolkit.org/) + Dry::CLI |
492
+ | **`flowengine`** (this gem) | Core engine + LLM integration (depends on `ruby_llm`) |
493
+ | **`flowengine-cli`** | Terminal wizard via [TTY Toolkit](https://ttytoolkit.org/) + Dry::CLI |
1122
494
  | **`flowengine-rails`** | Rails Engine with ActiveRecord persistence and web views |
1123
495
 
1124
496
  ## Development
1125
497
 
1126
498
  ```bash
1127
499
  bundle install
1128
- bundle exec rspec
500
+ just test # RSpec + RuboCop
501
+ just lint # RuboCop only
502
+ just doc # Generate YARD docs
1129
503
  ```
1130
504
 
1131
505
  ## License
1132
506
 
1133
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
507
+ MIT License. See [LICENSE](https://opensource.org/licenses/MIT).