flowengine 0.3.1 → 0.4.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.
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,467 @@ 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
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)
199
+ ```
548
200
 
549
- # Sensitive data in introduction
550
- engine.submit_introduction("My SSN is 123-45-6789", llm_client: client)
551
- # => raises FlowEngine::Errors::SensitiveDataError
201
+ ---
552
202
 
553
- # Introduction exceeds maxlength
554
- engine.submit_introduction("A" * 3000, llm_client: client)
555
- # => raises FlowEngine::Errors::ValidationError
203
+ ## LLM Integration
556
204
 
557
- # Missing API key or LLM response parsing failure
558
- FlowEngine::LLM::Adapters::OpenAIAdapter.new # without OPENAI_API_KEY
559
- # => raises FlowEngine::Errors::LLMError
560
- ```
205
+ FlowEngine offers two ways to use LLMs for pre-filling answers from free-form text.
561
206
 
562
- ## Validation
207
+ ### LLM Adapters & Configuration
563
208
 
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:
209
+ The gem ships with three adapters (all via [`ruby_llm`](https://github.com/crmne/ruby_llm)):
565
210
 
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
211
+ | Adapter | Env Variable |
212
+ |---------|-------------|
213
+ | `AnthropicAdapter` | `ANTHROPIC_API_KEY` |
214
+ | `OpenAIAdapter` | `OPENAI_API_KEY` |
215
+ | `GeminiAdapter` | `GEMINI_API_KEY` |
574
216
 
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
- ```
217
+ The file [`resources/models.yml`](resources/models.yml) defines three model tiers per vendor (`top`, `default`, `fastest`). Override with `$FLOWENGINE_LLM_MODELS_PATH`.
579
218
 
580
- ### Custom Validator Example
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"
237
+ ```
581
238
 
582
239
  ```ruby
583
- class MyValidator < FlowEngine::Validation::Adapter
584
- def validate(node, input)
585
- errors = []
240
+ # Auto-detect from environment (checks Anthropic > OpenAI > Gemini)
241
+ client = FlowEngine::LLM.auto_client
586
242
 
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
243
+ # Explicit provider / model override
244
+ client = FlowEngine::LLM.auto_client(anthropic_api_key: "sk-ant-...", model: "claude-haiku-4-5-20251001")
601
245
 
602
- engine = FlowEngine::Engine.new(definition, validator: MyValidator.new)
603
- engine.answer("not_a_number") # => raises FlowEngine::Errors::ValidationError
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")
604
249
  ```
605
250
 
606
- ## Mermaid Diagram Export
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.
254
+
255
+ ---
256
+
257
+ ### Option 1: Introduction (One-Shot Pre-Fill)
607
258
 
608
- Export any flow definition as a [Mermaid](https://mermaid.js.org/) flowchart:
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.
609
260
 
610
261
  ```ruby
611
- exporter = FlowEngine::Graph::MermaidExporter.new(definition)
612
- puts exporter.export
613
- ```
262
+ definition = FlowEngine.define do
263
+ start :filing_status
614
264
 
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!"]
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
+
269
+ step :filing_status do
270
+ type :single_select
271
+ question "What is your filing status?"
272
+ options %w[single married_filing_jointly head_of_household]
273
+ transition to: :dependents
274
+ end
275
+
276
+ step :dependents do
277
+ type :number
278
+ question "How many dependents?"
279
+ end
280
+ end
281
+
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
627
289
  ```
628
290
 
629
- ## Complete Example: Tax Intake Flow
291
+ ---
630
292
 
631
- Here's a realistic 17-step tax intake wizard that demonstrates every feature of the DSL.
293
+ ### Option 2: AI Intake Steps (Multi-Round Conversational)
632
294
 
633
- ### Flow Definition
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.
634
296
 
635
297
  ```ruby
636
- tax_intake = FlowEngine.define do
637
- start :filing_status
298
+ definition = FlowEngine.define do
299
+ start :personal_intake
300
+
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
307
+ end
638
308
 
639
309
  step :filing_status do
640
310
  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]
311
+ question "What is your filing status?"
312
+ options %w[single married_joint married_separate head_of_household]
643
313
  transition to: :dependents
644
314
  end
645
315
 
646
316
  step :dependents do
647
317
  type :number
648
- question "How many dependents do you have?"
318
+ question "How many dependents do you claim?"
649
319
  transition to: :income_types
650
320
  end
651
321
 
652
322
  step :income_types do
653
323
  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
324
+ question "Select all income types that apply"
325
+ options %w[W2 1099 Business Investment Rental]
660
326
  end
327
+ end
328
+ ```
661
329
 
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
667
- end
330
+ #### Running an AI Intake
668
331
 
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
332
+ ```ruby
333
+ engine = FlowEngine::Engine.new(definition)
334
+ client = FlowEngine::LLM.auto_client
674
335
 
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
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"]
683
354
 
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
355
+ # Engine auto-advances past all pre-filled steps
356
+ engine.finished? # => true
357
+ ```
692
358
 
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
359
+ #### ClarificationResult
699
360
 
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
361
+ Each `submit_ai_intake` / `submit_clarification` call returns a `ClarificationResult`:
706
362
 
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
712
- end
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 |
713
370
 
714
- step :foreign_accounts do
715
- 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
720
- end
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.
721
372
 
722
- step :foreign_account_details do
723
- type :number
724
- question "How many foreign accounts do you have?"
725
- transition to: :deduction_types
726
- end
373
+ #### Multiple AI Intakes in One Flow
727
374
 
728
- step :deduction_types do
729
- 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
375
+ Place `:ai_intake` steps at multiple points to break up the conversation:
735
376
 
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
377
+ ```ruby
378
+ definition = FlowEngine.define do
379
+ start :personal_intake
380
+
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
741
386
  end
742
387
 
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
388
+ step :filing_status do
389
+ # ... personal info steps ...
390
+ transition to: :financial_intake
747
391
  end
748
392
 
749
- step :contact_info do
750
- type :text
751
- question "Please provide your contact information (name, email, phone)."
752
- transition to: :review
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
753
399
  end
754
400
 
755
- step :review do
756
- type :text
757
- question "Thank you! Please review your information. Type 'confirm' to submit."
401
+ step :annual_income do
402
+ # ... financial steps ...
758
403
  end
759
404
  end
760
405
  ```
761
406
 
762
- ### Scenario 1: Maximum Path (17 steps visited)
407
+ Each `:ai_intake` step maintains its own conversation history and round counter. State is fully serializable for persistence between requests.
763
408
 
764
- A married filer with all income types, 4 businesses, crypto, rentals, foreign accounts, and high charitable giving:
409
+ ### Custom LLM Adapters
765
410
 
766
411
  ```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
- ```
412
+ class MyAdapter < FlowEngine::LLM::Adapter
413
+ def initialize(api_key:)
414
+ super()
415
+ @api_key = api_key
416
+ end
787
417
 
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
- }
418
+ def chat(system_prompt:, user_prompt:, model:)
419
+ # Must return response text (expected to be JSON)
420
+ end
421
+ end
820
422
  ```
821
423
 
822
- **Path taken** (all 17 steps):
823
-
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
- ```
424
+ ---
831
425
 
832
- ### Scenario 2: Minimum Path (8 steps visited)
426
+ ## State Persistence
833
427
 
834
- A single filer, W2 income only, no special deductions:
428
+ The engine's full state including AI intake conversation history — can be serialized and restored:
835
429
 
836
430
  ```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
- ```
848
-
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
- ```
863
-
864
- **Path taken** (8 steps — skipped 9 steps):
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 }
865
436
 
866
- ```
867
- filing_status -> dependents -> income_types -> state_filing ->
868
- foreign_accounts -> deduction_types -> contact_info -> review
437
+ restored = FlowEngine::Engine.from_state(definition, state)
869
438
  ```
870
439
 
871
- **Skipped:** `business_count`, `complex_business_info`, `business_details`, `investment_details`, `crypto_details`, `rental_details`, `foreign_account_details`, `charitable_amount`, `charitable_documentation`.
440
+ Round-trips through JSON (string keys) are handled automatically.
872
441
 
873
- ### Scenario 3: Medium Path (12 steps visited)
442
+ ## Validation
874
443
 
875
- Married, with business + investment income, low charitable giving:
444
+ Pluggable validators via the adapter pattern. Ships with `NullAdapter` (always passes):
876
445
 
877
446
  ```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
- ```
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)
452
+ end
453
+ end
893
454
 
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
- }
455
+ engine = FlowEngine::Engine.new(definition, validator: MyValidator.new)
917
456
  ```
918
457
 
919
- **Path taken** (12 steps):
458
+ ## Mermaid Diagram Export
920
459
 
460
+ ```ruby
461
+ exporter = FlowEngine::Graph::MermaidExporter.new(definition)
462
+ puts exporter.export
921
463
  ```
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
- ```
927
-
928
- ### Scenario 4: Rental + Foreign Accounts (10 steps visited)
929
464
 
930
- Head of household, 1099 + rental income, foreign accounts, no charitable:
465
+ ## Architecture
931
466
 
932
- ```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
- ```
467
+ ![architecture](docs/floweingine-architecture.png)
946
468
 
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
- }
966
- ```
469
+ The core has **zero UI logic**, **zero DB logic**, and **zero framework dependencies**. Adapters translate input/output, persist state, and render UI.
967
470
 
968
- **Path taken** (10 steps):
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 |
969
487
 
970
- ```
971
- filing_status -> dependents -> income_types -> rental_details ->
972
- state_filing -> foreign_accounts -> foreign_account_details ->
973
- deduction_types -> contact_info -> review
974
- ```
488
+ ## Beyond the DSL: LLM-Driven Tools
975
489
 
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
490
+ The declarative DSL is the primary way to define flows, but it is not the only way to use FlowEngine. The gem's LLM integration layer — adapters, client, and sensitive data filter — can power conversational tools that don't use the DSL at all.
1003
491
 
1004
- ```ruby
1005
- definition = FlowEngine.define do
1006
- start :status
492
+ ### `bin/tax-estimate` (Experimental)
1007
493
 
1008
- step :status do
1009
- type :single_select
1010
- question "Filing status?"
1011
- options %w[single married]
1012
- transition to: :dependents
1013
- end
494
+ An interactive CLI tool that estimates tax return complexity and preparation cost entirely through LLM-driven conversation. Instead of defining steps in the DSL, the LLM decides what to ask, generates Ruby code for rich terminal prompts (`TTY::Prompt`), and produces a final YAML estimate when it has enough information.
1014
495
 
1015
- step :dependents do
1016
- type :number
1017
- question "How many dependents?"
1018
- transition to: :income
1019
- end
496
+ ```bash
497
+ bin/tax-estimate
498
+ ```
1020
499
 
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
500
+ How it works:
1044
501
 
1045
- step :special_review do
1046
- type :text
1047
- question "Married with business income - special review required."
1048
- end
502
+ 1. A system prompt describes tax complexity factors (filing status, income sources, deductions, special situations)
503
+ 2. The LLM returns JSON containing a natural-language question and a `question_eval` field with Ruby code that uses `TTY::Prompt` for multi-select, radio buttons, text input, etc.
504
+ 3. The generated code is evaluated to collect the user's answer, which is appended to the chat history
505
+ 4. The loop continues until the LLM produces a `final_estimate` — a YAML object with complexity score, cost range, and explanation
1049
506
 
1050
- step :alt_review do
1051
- type :text
1052
- question "Alternative review path."
1053
- end
507
+ This approach trades the safety and predictability of a predefined flow graph for maximum flexibility — the LLM adapts its questions based on prior answers without any step definitions.
1054
508
 
1055
- step :default_review do
1056
- type :text
1057
- question "Default review."
1058
- end
1059
- end
509
+ ```ruby
510
+ # The only FlowEngine dependency is the LLM client
511
+ client = FlowEngine::LLM.auto_client
512
+ response = client.adapter.chat(
513
+ system_prompt: tax_system_prompt,
514
+ user_prompt: "Chat history: #{chat_history.to_json}",
515
+ model: client.model
516
+ )
1060
517
  ```
1061
518
 
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
- ```
519
+ Here is an example of the final YAML estimate for a simple single-filer return:
1111
520
 
1112
- </details>
521
+ ```yaml
522
+ ---
523
+ client_profile:
524
+ filing_status: Single
525
+ dependents: 0
526
+ states_required: 1
527
+
528
+ income_sources:
529
+ w2_employers: 1
530
+ self_employment: false
531
+ rental_income: false
532
+ investment_income: false
533
+ retirement_distributions: false
534
+ foreign_income: false
535
+
536
+ deductions_and_credits:
537
+ itemized_deductions: false
538
+ business_expenses: false
539
+ education_credits: false
540
+ energy_credits: false
541
+ child_dependent_care_credits: false
542
+
543
+ special_situations:
544
+ stock_options: false
545
+ cryptocurrency: false
546
+ foreign_accounts: false
547
+ prior_year_carryforwards: false
548
+ amended_return: false
549
+ irs_notices: false
550
+
551
+ complexity_assessment:
552
+ score: 1
553
+ out_of: 10
554
+ rationale: >
555
+ This is a straightforward return. Single filer, no dependents,
556
+ one W-2 from a single employer, one state return, no itemized
557
+ deductions, no special income sources, and no special situations.
558
+
559
+ cost_estimate:
560
+ low: $150
561
+ high: $300
562
+ average: $200
563
+ currency: USD
564
+ notes: >
565
+ Cost reflects a simple federal Form 1040 with a single W-2 and
566
+ one state return. Rush fees may apply if filing close to the
567
+ deadline.
568
+
569
+ estimated_time_to_complete: 1-2 hours
570
+ ```
571
+
572
+ See `examples/` for more sample outputs including complex multi-income scenarios.
1113
573
 
1114
574
  ## Ecosystem
1115
575
 
1116
- FlowEngine is the core of a three-gem architecture:
1117
-
1118
576
  | Gem | Purpose |
1119
577
  |-----|---------|
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 |
578
+ | **`flowengine`** (this gem) | Core engine + LLM integration (depends on `ruby_llm`) |
579
+ | **`flowengine-cli`** | Terminal wizard via [TTY Toolkit](https://ttytoolkit.org/) + Dry::CLI |
1122
580
  | **`flowengine-rails`** | Rails Engine with ActiveRecord persistence and web views |
1123
581
 
1124
582
  ## Development
1125
583
 
1126
584
  ```bash
1127
585
  bundle install
1128
- bundle exec rspec
586
+ just test # RSpec + RuboCop
587
+ just lint # RuboCop only
588
+ just doc # Generate YARD docs
1129
589
  ```
1130
590
 
1131
591
  ## License
1132
592
 
1133
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
593
+ MIT License. See [LICENSE](https://opensource.org/licenses/MIT).