flowengine 0.3.0 → 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,193 +47,20 @@ 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]
84
- ```
85
-
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
100
-
101
- ## LLM-parsed Introduction
102
-
103
- 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.
104
-
105
- ### Defining an Introduction
106
-
107
- Add the `introduction` command to your flow definition:
108
-
109
- ```ruby
110
- definition = FlowEngine.define do
111
- start :filing_status
112
-
113
- introduction label: "Tell us about your tax situation",
114
- placeholder: "e.g. I am married, filing jointly, with 2 dependents...",
115
- maxlength: 2000 # optional character limit
116
-
117
- step :filing_status do
118
- type :single_select
119
- question "What is your filing status?"
120
- options %w[single married_filing_jointly head_of_household]
121
- transition to: :dependents
122
- end
123
-
124
- step :dependents do
125
- type :number
126
- question "How many dependents?"
127
- transition to: :income_types
128
- end
129
-
130
- step :income_types do
131
- type :multi_select
132
- question "Select income types"
133
- options %w[W2 1099 Business Investment]
134
- end
135
- end
136
- ```
137
-
138
- | Parameter | Required | Description |
139
- |-----------|----------|-------------|
140
- | `label` | Yes | Text shown above the input field |
141
- | `placeholder` | No | Ghost text inside the text area (default: `""`) |
142
- | `maxlength` | No | Maximum character count (default: `nil` = unlimited) |
143
-
144
- ### Using the Introduction at Runtime
145
-
146
- ```ruby
147
- # 1. Auto-detect adapter from environment (checks ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY)
148
- client = FlowEngine::LLM.auto_client
149
-
150
- # Or explicitly choose a provider:
151
- # client = FlowEngine::LLM.auto_client(anthropic_api_key: "sk-ant-...")
152
- # client = FlowEngine::LLM.auto_client(openai_api_key: "sk-...", model: "gpt-4o")
153
- # client = FlowEngine::LLM.auto_client(gemini_api_key: "AIza...")
154
-
155
- # 2. Create the engine and submit the introduction
156
- engine = FlowEngine::Engine.new(definition)
157
- engine.submit_introduction(
158
- "I am married filing jointly with 2 dependents, W2 and business income",
159
- llm_client: client
160
- )
161
-
162
- # 3. The LLM pre-fills answers and the engine auto-advances
163
- engine.answers
164
- # => { filing_status: "married_filing_jointly", dependents: 2,
165
- # income_types: ["W2", "Business"] }
166
-
167
- engine.current_step_id # => nil (all steps pre-filled in this case)
168
- engine.introduction_text # => "I am married filing jointly with 2 dependents, ..."
169
- engine.finished? # => true
170
- ```
171
-
172
- 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.
173
-
174
- ### Sensitive Data Protection
175
-
176
- Before any text reaches the LLM, `submit_introduction` scans for sensitive data patterns:
177
-
178
- - **SSN**: `123-45-6789`
179
- - **ITIN**: `912-34-5678`
180
- - **EIN**: `12-3456789`
181
- - **Nine consecutive digits**: `123456789`
182
-
183
- If detected, a `FlowEngine::SensitiveDataError` is raised immediately. The introduction text is discarded and no LLM call is made.
184
-
185
- ```ruby
186
- engine.submit_introduction("My SSN is 123-45-6789", llm_client: client)
187
- # => raises FlowEngine::SensitiveDataError
188
- ```
189
-
190
- ### Custom LLM Adapters
191
-
192
- 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):
193
-
194
- | Adapter | Env Variable | Default Model |
195
- |---------|-------------|---------------|
196
- | `AnthropicAdapter` | `ANTHROPIC_API_KEY` | `claude-sonnet-4-20250514` |
197
- | `OpenAIAdapter` | `OPENAI_API_KEY` | `gpt-4o-mini` |
198
- | `GeminiAdapter` | `GEMINI_API_KEY` | `gemini-2.0-flash` |
199
-
200
- You can also create adapters for any other provider:
201
-
202
- ```ruby
203
- class MyCustomAdapter < FlowEngine::LLM::Adapter
204
- def initialize(api_key:)
205
- super()
206
- @api_key = api_key
207
- end
208
-
209
- def chat(system_prompt:, user_prompt:, model:)
210
- # Call your LLM API here
211
- # Must return the response text (expected to be a JSON string)
212
- end
213
- end
214
-
215
- adapter = MyCustomAdapter.new(api_key: ENV["MY_API_KEY"])
216
- client = FlowEngine::LLM::Client.new(adapter: adapter, model: "my-model")
57
+ engine.answers # => { name: "Alice", age: 25, beverage: "Wine", thanks: "ok" }
58
+ engine.history # => [:name, :age, :beverage, :thanks]
217
59
  ```
218
60
 
219
- ### State Persistence
220
-
221
- The `introduction_text` is included in state serialization:
222
-
223
- ```ruby
224
- state = engine.to_state
225
- # => { current_step_id: ..., answers: { ... }, history: [...], introduction_text: "..." }
226
-
227
- restored = FlowEngine::Engine.from_state(definition, state)
228
- restored.introduction_text # => "I am married filing jointly..."
229
- ```
61
+ If Alice were 18, the engine skips `:beverage` entirely — the first matching transition (`18 NOT > 20`) falls through to the unconditional `:thanks`.
230
62
 
231
- ## Architecture
232
-
233
- ![architecture](docs/floweingine-architecture.png)
234
-
235
- The core has **zero UI logic**, **zero DB logic**, and **zero framework dependencies**. Adapters translate input/output, persist state, and render UI.
236
-
237
- ### Core Components
238
-
239
- | Component | Responsibility |
240
- |-----------|---------------|
241
- | `FlowEngine.define` | DSL entry point; returns a frozen `Definition` |
242
- | `Introduction` | Immutable config for the introduction step (label, placeholder, maxlength) |
243
- | `Definition` | Immutable container of the flow graph (nodes + start step + introduction) |
244
- | `Node` | A single step: type, question, options/fields, transitions, visibility |
245
- | `Transition` | A directed edge with an optional rule condition |
246
- | `Rules::*` | AST nodes for conditional logic (`Contains`, `Equals`, `All`, etc.) |
247
- | `Evaluator` | Evaluates rules against the current answer store |
248
- | `Engine` | Stateful runtime: tracks current step, answers, history, and introduction |
249
- | `Validation::Adapter` | Interface for pluggable validation (dry-validation, JSON Schema, etc.) |
250
- | `LLM::Adapter` | Abstract interface for LLM API calls |
251
- | `LLM::AnthropicAdapter` | Anthropic/Claude implementation via `ruby_llm` gem |
252
- | `LLM::OpenAIAdapter` | OpenAI implementation via `ruby_llm` gem |
253
- | `LLM::GeminiAdapter` | Google Gemini implementation via `ruby_llm` gem |
254
- | `LLM::Client` | High-level: builds prompt, calls adapter, parses JSON response |
255
- | `LLM.auto_client` | Factory: auto-detects provider from environment API keys |
256
- | `LLM::SensitiveDataFilter` | Rejects text containing SSN, ITIN, EIN patterns |
257
- | `Graph::MermaidExporter` | Exports the flow definition as a Mermaid diagram |
63
+ ---
258
64
 
259
65
  ## The DSL
260
66
 
@@ -264,79 +70,37 @@ Every flow starts with `FlowEngine.define`, which returns a **frozen, immutable*
264
70
 
265
71
  ```ruby
266
72
  definition = FlowEngine.define do
267
- start :first_step # Required: which node to begin at
73
+ start :first_step
268
74
 
269
- # Optional: collect free-form text before the flow, parsed by LLM
75
+ # Optional: one-shot LLM pre-fill (see "Introduction" section)
270
76
  introduction label: "Describe your situation",
271
77
  placeholder: "Type here...",
272
78
  maxlength: 2000
273
79
 
274
80
  step :first_step do
275
- # step configuration...
276
- end
277
-
278
- step :second_step do
279
- # step configuration...
81
+ type :text
82
+ question "What is your name?"
83
+ transition to: :second_step
280
84
  end
281
85
  end
282
86
  ```
283
87
 
284
88
  ### Step Configuration
285
89
 
286
- Inside a `step` block, you have access to:
287
-
288
90
  | Method | Purpose | Example |
289
91
  |--------|---------|---------|
290
- | `type` | The input type (for UI adapters) | `:text`, `:number`, `:single_select`, `:multi_select`, `:number_matrix` |
291
- | `question` | The prompt shown to the user | `"What is your filing status?"` |
292
- | `options` | Available choices (for select types) | `%w[W2 1099 Business]` |
293
- | `fields` | Named fields (for matrix types) | `%w[RealEstate SCorp LLC]` |
294
- | `transition` | Where to go next (with optional condition) | `transition to: :next_step, if_rule: equals(:field, "value")` |
295
- | `visible_if` | Visibility rule (for DAG mode) | `visible_if contains(:income, "Rental")` |
296
-
297
- ### Step Types
298
-
299
- 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:
300
-
301
- ```ruby
302
- step :filing_status do
303
- type :single_select # One choice from a list
304
- question "What is your filing status?"
305
- options %w[single married_filing_jointly married_filing_separately head_of_household]
306
- transition to: :dependents
307
- end
308
-
309
- step :income_types do
310
- type :multi_select # Multiple choices from a list
311
- question "Select all income types."
312
- options %w[W2 1099 Business Investment Rental]
313
- transition to: :business, if_rule: contains(:income_types, "Business")
314
- transition to: :summary
315
- end
316
-
317
- step :dependents do
318
- type :number # A numeric value
319
- question "How many dependents?"
320
- transition to: :income_types
321
- end
322
-
323
- step :business_details do
324
- type :number_matrix # Multiple named numeric fields
325
- question "How many of each business type?"
326
- fields %w[RealEstate SCorp CCorp Trust LLC]
327
- transition to: :summary
328
- end
329
-
330
- step :notes do
331
- type :text # Free-form text
332
- question "Any additional notes?"
333
- transition to: :summary
334
- end
335
- ```
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` |
336
100
 
337
101
  ### Transitions
338
102
 
339
- 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):
340
104
 
341
105
  ```ruby
342
106
  step :income_types do
@@ -344,23 +108,15 @@ step :income_types do
344
108
  question "Select income types."
345
109
  options %w[W2 1099 Business Investment Rental]
346
110
 
347
- # Conditional transitions (checked in order)
348
- transition to: :business_count, if_rule: contains(:income_types, "Business")
349
- transition to: :investment_details, if_rule: contains(:income_types, "Investment")
350
- transition to: :rental_details, if_rule: contains(:income_types, "Rental")
351
-
352
- # Unconditional fallback (always matches)
353
- 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
354
114
  end
355
115
  ```
356
116
 
357
- **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`.
358
-
359
- A transition with no `if_rule:` always matches — use it as a fallback at the end of the list.
360
-
361
117
  ### Visibility Rules
362
118
 
363
- 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:
364
120
 
365
121
  ```ruby
366
122
  step :spouse_income do
@@ -371,700 +127,381 @@ step :spouse_income do
371
127
  end
372
128
  ```
373
129
 
374
- The engine exposes this via `node.visible?(answers)`, which returns `true` when the rule is satisfied (or when no visibility rule is set).
375
-
376
130
  ## Rule System
377
131
 
378
- 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.
379
133
 
380
134
  ### Atomic Rules
381
135
 
382
- | Rule | DSL Helper | Evaluates | String Representation |
383
- |------|------------|-----------|----------------------|
384
- | `Contains` | `contains(:field, "val")` | `Array(answers[:field]).include?("val")` | `val in field` |
385
- | `Equals` | `equals(:field, "val")` | `answers[:field] == "val"` | `field == val` |
386
- | `GreaterThan` | `greater_than(:field, 10)` | `answers[:field].to_i > 10` | `field > 10` |
387
- | `LessThan` | `less_than(:field, 5)` | `answers[:field].to_i < 5` | `field < 5` |
388
- | `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 |
389
143
 
390
144
  ### Composite Rules
391
145
 
392
- Combine atomic rules with boolean logic:
393
-
394
- ```ruby
395
- # AND — all conditions must be true
396
- transition to: :special_review,
397
- if_rule: all(
398
- equals(:filing_status, "married_filing_jointly"),
399
- contains(:income_types, "Business"),
400
- greater_than(:business_count, 2)
401
- )
402
-
403
- # OR — at least one condition must be true
404
- transition to: :alt_path,
405
- if_rule: any(
406
- contains(:income_types, "Investment"),
407
- contains(:income_types, "Rental")
408
- )
409
- ```
410
-
411
- Composites nest arbitrarily:
412
-
413
146
  ```ruby
414
- transition to: :complex_review,
415
- if_rule: all(
416
- equals(:filing_status, "married_filing_jointly"),
417
- any(
418
- greater_than(:business_count, 3),
419
- contains(:income_types, "Rental")
420
- ),
421
- not_empty(:dependents)
422
- )
423
- ```
424
-
425
- ### How Rules Evaluate
426
-
427
- 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
+ )
428
153
 
429
- ```ruby
430
- rule = FlowEngine::Rules::Contains.new(:income_types, "Business")
431
- rule.evaluate({ income_types: ["W2", "Business"] }) # => true
432
- 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
+ )
433
159
 
434
- rule = FlowEngine::Rules::All.new(
435
- FlowEngine::Rules::Equals.new(:status, "married"),
436
- 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)
437
165
  )
438
- rule.evaluate({ status: "married", dependents: 2 }) # => true
439
- rule.evaluate({ status: "single", dependents: 2 }) # => false
440
166
  ```
441
167
 
442
168
  ## Engine API
443
169
 
444
- ### Creating and Running
445
-
446
170
  ```ruby
447
- definition = FlowEngine.define { ... }
448
171
  engine = FlowEngine::Engine.new(definition)
449
172
  ```
450
173
 
451
- ### Methods
452
-
453
174
  | Method | Returns | Description |
454
175
  |--------|---------|-------------|
455
- | `engine.current_step_id` | `Symbol` or `nil` | The ID of the current step |
456
- | `engine.current_step` | `Node` or `nil` | The current Node object |
457
- | `engine.answer(value)` | `nil` | Records the answer and advances |
458
- | `engine.submit_introduction(text, llm_client:)` | `nil` | LLM-parses text, pre-fills answers, auto-advances |
459
- | `engine.finished?` | `Boolean` | `true` when there are no more steps |
460
- | `engine.answers` | `Hash` | All collected answers `{ step_id => value }` |
461
- | `engine.history` | `Array<Symbol>` | Ordered list of visited step IDs |
462
- | `engine.introduction_text` | `String` or `nil` | The raw introduction text submitted |
463
- | `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 |
464
190
 
465
191
  ### Error Handling
466
192
 
467
193
  ```ruby
468
- # Answering after the flow is complete
469
- engine.answer("extra")
470
- # => raises FlowEngine::AlreadyFinishedError
471
-
472
- # Referencing an unknown step in a definition
473
- definition.step(:nonexistent)
474
- # => raises FlowEngine::UnknownStepError
475
-
476
- # Invalid definition (start step doesn't exist)
477
- FlowEngine.define do
478
- start :missing
479
- step :other do
480
- type :text
481
- question "Hello"
482
- end
483
- end
484
- # => raises FlowEngine::DefinitionError
485
-
486
- # Sensitive data in introduction
487
- engine.submit_introduction("My SSN is 123-45-6789", llm_client: client)
488
- # => raises FlowEngine::SensitiveDataError
489
-
490
- # Introduction exceeds maxlength
491
- engine.submit_introduction("A" * 3000, llm_client: client)
492
- # => raises FlowEngine::ValidationError
493
-
494
- # Missing API key or LLM response parsing failure
495
- FlowEngine::LLM::OpenAIAdapter.new # without OPENAI_API_KEY
496
- # => raises FlowEngine::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)
497
199
  ```
498
200
 
499
- ## Validation
500
-
501
- 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:
201
+ ---
502
202
 
503
- ```ruby
504
- # The adapter interface
505
- class FlowEngine::Validation::Adapter
506
- def validate(node, input)
507
- # Must return a FlowEngine::Validation::Result
508
- raise NotImplementedError
509
- end
510
- end
203
+ ## LLM Integration
511
204
 
512
- # Result object
513
- FlowEngine::Validation::Result.new(valid: true, errors: [])
514
- FlowEngine::Validation::Result.new(valid: false, errors: ["must be a number"])
515
- ```
205
+ FlowEngine offers two ways to use LLMs for pre-filling answers from free-form text.
516
206
 
517
- ### Custom Validator Example
207
+ ### LLM Adapters & Configuration
518
208
 
519
- ```ruby
520
- class MyValidator < FlowEngine::Validation::Adapter
521
- def validate(node, input)
522
- errors = []
209
+ The gem ships with three adapters (all via [`ruby_llm`](https://github.com/crmne/ruby_llm)):
523
210
 
524
- case node.type
525
- when :number
526
- errors << "must be a number" unless input.is_a?(Numeric)
527
- when :single_select
528
- errors << "invalid option" unless node.options&.include?(input)
529
- when :multi_select
530
- unless input.is_a?(Array) && input.all? { |v| node.options&.include?(v) }
531
- errors << "invalid options"
532
- end
533
- end
211
+ | Adapter | Env Variable |
212
+ |---------|-------------|
213
+ | `AnthropicAdapter` | `ANTHROPIC_API_KEY` |
214
+ | `OpenAIAdapter` | `OPENAI_API_KEY` |
215
+ | `GeminiAdapter` | `GEMINI_API_KEY` |
534
216
 
535
- FlowEngine::Validation::Result.new(valid: errors.empty?, errors: errors)
536
- end
537
- 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`.
538
218
 
539
- engine = FlowEngine::Engine.new(definition, validator: MyValidator.new)
540
- engine.answer("not_a_number") # => raises FlowEngine::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"
541
237
  ```
542
238
 
543
- ## Mermaid Diagram Export
239
+ ```ruby
240
+ # Auto-detect from environment (checks Anthropic > OpenAI > Gemini)
241
+ client = FlowEngine::LLM.auto_client
544
242
 
545
- 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")
546
245
 
547
- ```ruby
548
- exporter = FlowEngine::Graph::MermaidExporter.new(definition)
549
- 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")
550
249
  ```
551
250
 
552
- Output:
553
-
554
- ```mermaid
555
- flowchart TD
556
- name["What is your name?"]
557
- name --> age
558
- age["How old are you?"]
559
- age -->|"age > 20"| beverage
560
- age --> thanks
561
- beverage["Pick a drink."]
562
- beverage --> thanks
563
- thanks["Thank you for your responses!"]
564
- ```
251
+ ### Sensitive Data Protection
565
252
 
566
- ## Complete Example: Tax Intake Flow
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.
567
254
 
568
- Here's a realistic 17-step tax intake wizard that demonstrates every feature of the DSL.
255
+ ---
569
256
 
570
- ### Flow Definition
257
+ ### Option 1: Introduction (One-Shot Pre-Fill)
258
+
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.
571
260
 
572
261
  ```ruby
573
- tax_intake = FlowEngine.define do
262
+ definition = FlowEngine.define do
574
263
  start :filing_status
575
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
+
576
269
  step :filing_status do
577
270
  type :single_select
578
- question "What is your filing status for 2025?"
579
- 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]
580
273
  transition to: :dependents
581
274
  end
582
275
 
583
276
  step :dependents do
584
277
  type :number
585
- question "How many dependents do you have?"
586
- transition to: :income_types
587
- end
588
-
589
- step :income_types do
590
- type :multi_select
591
- question "Select all income types that apply to you in 2025."
592
- options %w[W2 1099 Business Investment Rental Retirement]
593
- transition to: :business_count, if_rule: contains(:income_types, "Business")
594
- transition to: :investment_details, if_rule: contains(:income_types, "Investment")
595
- transition to: :rental_details, if_rule: contains(:income_types, "Rental")
596
- transition to: :state_filing
597
- end
598
-
599
- step :business_count do
600
- type :number
601
- question "How many total businesses do you own or are a partner in?"
602
- transition to: :complex_business_info, if_rule: greater_than(:business_count, 2)
603
- transition to: :business_details
278
+ question "How many dependents?"
604
279
  end
280
+ end
605
281
 
606
- step :complex_business_info do
607
- type :text
608
- question "With more than 2 businesses, please provide your primary EIN and a brief description of each entity."
609
- transition to: :business_details
610
- 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
+ ```
611
290
 
612
- step :business_details do
613
- type :number_matrix
614
- question "How many of each business type do you own?"
615
- fields %w[RealEstate SCorp CCorp Trust LLC]
616
- transition to: :investment_details, if_rule: contains(:income_types, "Investment")
617
- transition to: :rental_details, if_rule: contains(:income_types, "Rental")
618
- transition to: :state_filing
619
- end
291
+ ---
620
292
 
621
- step :investment_details do
622
- type :multi_select
623
- question "What types of investments do you hold?"
624
- options %w[Stocks Bonds Crypto RealEstate MutualFunds]
625
- transition to: :crypto_details, if_rule: contains(:investment_details, "Crypto")
626
- transition to: :rental_details, if_rule: contains(:income_types, "Rental")
627
- transition to: :state_filing
628
- end
293
+ ### Option 2: AI Intake Steps (Multi-Round Conversational)
629
294
 
630
- step :crypto_details do
631
- type :text
632
- question "Please describe your cryptocurrency transactions (exchanges used, approximate number of transactions)."
633
- transition to: :rental_details, if_rule: contains(:income_types, "Rental")
634
- transition to: :state_filing
635
- 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.
636
296
 
637
- step :rental_details do
638
- type :number_matrix
639
- question "Provide details about your rental properties."
640
- fields %w[Residential Commercial Vacation]
641
- transition to: :state_filing
642
- end
297
+ ```ruby
298
+ definition = FlowEngine.define do
299
+ start :personal_intake
643
300
 
644
- step :state_filing do
645
- type :multi_select
646
- question "Which states do you need to file in?"
647
- options %w[California NewYork Texas Florida Illinois Other]
648
- 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
649
307
  end
650
308
 
651
- step :foreign_accounts do
309
+ step :filing_status do
652
310
  type :single_select
653
- question "Do you have any foreign financial accounts (bank accounts, securities, or financial assets)?"
654
- options %w[yes no]
655
- transition to: :foreign_account_details, if_rule: equals(:foreign_accounts, "yes")
656
- 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
657
314
  end
658
315
 
659
- step :foreign_account_details do
316
+ step :dependents do
660
317
  type :number
661
- question "How many foreign accounts do you have?"
662
- transition to: :deduction_types
318
+ question "How many dependents do you claim?"
319
+ transition to: :income_types
663
320
  end
664
321
 
665
- step :deduction_types do
322
+ step :income_types do
666
323
  type :multi_select
667
- question "Which additional deductions apply to you?"
668
- options %w[Medical Charitable Education Mortgage None]
669
- transition to: :charitable_amount, if_rule: contains(:deduction_types, "Charitable")
670
- transition to: :contact_info
671
- end
672
-
673
- step :charitable_amount do
674
- type :number
675
- question "What is your total estimated charitable contribution amount for 2025?"
676
- transition to: :charitable_documentation, if_rule: greater_than(:charitable_amount, 5000)
677
- transition to: :contact_info
678
- end
679
-
680
- step :charitable_documentation do
681
- type :text
682
- question "For charitable contributions over $5,000, please list the organizations and amounts."
683
- transition to: :contact_info
684
- end
685
-
686
- step :contact_info do
687
- type :text
688
- question "Please provide your contact information (name, email, phone)."
689
- transition to: :review
690
- end
691
-
692
- step :review do
693
- type :text
694
- 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]
695
326
  end
696
327
  end
697
328
  ```
698
329
 
699
- ### Scenario 1: Maximum Path (17 steps visited)
700
-
701
- A married filer with all income types, 4 businesses, crypto, rentals, foreign accounts, and high charitable giving:
330
+ #### Running an AI Intake
702
331
 
703
332
  ```ruby
704
- engine = FlowEngine::Engine.new(tax_intake)
705
-
706
- engine.answer("married_filing_jointly")
707
- engine.answer(3)
708
- engine.answer(%w[W2 1099 Business Investment Rental Retirement])
709
- engine.answer(4)
710
- engine.answer("EIN: 12-3456789. Entities: Alpha LLC, Beta SCorp, Gamma LLC, Delta CCorp")
711
- engine.answer({ "RealEstate" => 1, "SCorp" => 1, "CCorp" => 1, "Trust" => 0, "LLC" => 2 })
712
- engine.answer(%w[Stocks Bonds Crypto RealEstate])
713
- engine.answer("Coinbase and Kraken, approximately 150 transactions in 2025")
714
- engine.answer({ "Residential" => 2, "Commercial" => 1, "Vacation" => 0 })
715
- engine.answer(%w[California NewYork])
716
- engine.answer("yes")
717
- engine.answer(3)
718
- engine.answer(%w[Medical Charitable Education Mortgage])
719
- engine.answer(12_000)
720
- engine.answer("Red Cross: $5,000; Habitat for Humanity: $4,000; Local Food Bank: $3,000")
721
- engine.answer("Jane Smith, jane.smith@example.com, 555-123-4567")
722
- engine.answer("confirm")
723
- ```
333
+ engine = FlowEngine::Engine.new(definition)
334
+ client = FlowEngine::LLM.auto_client
335
+
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"]
724
354
 
725
- **Collected data:**
726
-
727
- ```json
728
- {
729
- "filing_status": "married_filing_jointly",
730
- "dependents": 3,
731
- "income_types": ["W2", "1099", "Business", "Investment", "Rental", "Retirement"],
732
- "business_count": 4,
733
- "complex_business_info": "EIN: 12-3456789. Entities: Alpha LLC, Beta SCorp, Gamma LLC, Delta CCorp",
734
- "business_details": {
735
- "RealEstate": 1,
736
- "SCorp": 1,
737
- "CCorp": 1,
738
- "Trust": 0,
739
- "LLC": 2
740
- },
741
- "investment_details": ["Stocks", "Bonds", "Crypto", "RealEstate"],
742
- "crypto_details": "Coinbase and Kraken, approximately 150 transactions in 2025",
743
- "rental_details": {
744
- "Residential": 2,
745
- "Commercial": 1,
746
- "Vacation": 0
747
- },
748
- "state_filing": ["California", "NewYork"],
749
- "foreign_accounts": "yes",
750
- "foreign_account_details": 3,
751
- "deduction_types": ["Medical", "Charitable", "Education", "Mortgage"],
752
- "charitable_amount": 12000,
753
- "charitable_documentation": "Red Cross: $5,000; Habitat for Humanity: $4,000; Local Food Bank: $3,000",
754
- "contact_info": "Jane Smith, jane.smith@example.com, 555-123-4567",
755
- "review": "confirm"
756
- }
355
+ # Engine auto-advances past all pre-filled steps
356
+ engine.finished? # => true
757
357
  ```
758
358
 
759
- **Path taken** (all 17 steps):
359
+ #### ClarificationResult
760
360
 
761
- ```
762
- filing_status -> dependents -> income_types -> business_count ->
763
- complex_business_info -> business_details -> investment_details ->
764
- crypto_details -> rental_details -> state_filing -> foreign_accounts ->
765
- foreign_account_details -> deduction_types -> charitable_amount ->
766
- charitable_documentation -> contact_info -> review
767
- ```
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.
768
372
 
769
- ### Scenario 2: Minimum Path (8 steps visited)
373
+ #### Multiple AI Intakes in One Flow
770
374
 
771
- A single filer, W2 income only, no special deductions:
375
+ Place `:ai_intake` steps at multiple points to break up the conversation:
772
376
 
773
377
  ```ruby
774
- engine = FlowEngine::Engine.new(tax_intake)
775
-
776
- engine.answer("single")
777
- engine.answer(0)
778
- engine.answer(["W2"])
779
- engine.answer(["Texas"])
780
- engine.answer("no")
781
- engine.answer(["None"])
782
- engine.answer("John Doe, john.doe@example.com, 555-987-6543")
783
- engine.answer("confirm")
784
- ```
378
+ definition = FlowEngine.define do
379
+ start :personal_intake
785
380
 
786
- **Collected data:**
787
-
788
- ```json
789
- {
790
- "filing_status": "single",
791
- "dependents": 0,
792
- "income_types": ["W2"],
793
- "state_filing": ["Texas"],
794
- "foreign_accounts": "no",
795
- "deduction_types": ["None"],
796
- "contact_info": "John Doe, john.doe@example.com, 555-987-6543",
797
- "review": "confirm"
798
- }
799
- ```
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
800
387
 
801
- **Path taken** (8 steps — skipped 9 steps):
388
+ step :filing_status do
389
+ # ... personal info steps ...
390
+ transition to: :financial_intake
391
+ end
802
392
 
803
- ```
804
- filing_status -> dependents -> income_types -> state_filing ->
805
- foreign_accounts -> deduction_types -> contact_info -> review
806
- ```
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
807
400
 
808
- **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
+ ```
809
406
 
810
- ### 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.
811
408
 
812
- Married, with business + investment income, low charitable giving:
409
+ ### Custom LLM Adapters
813
410
 
814
411
  ```ruby
815
- engine = FlowEngine::Engine.new(tax_intake)
816
-
817
- engine.answer("married_filing_separately")
818
- engine.answer(1)
819
- engine.answer(%w[W2 Business Investment])
820
- engine.answer(2) # <= 2 businesses, no complex_business_info
821
- engine.answer({ "RealEstate" => 0, "SCorp" => 1, "CCorp" => 0, "Trust" => 0, "LLC" => 1 })
822
- engine.answer(%w[Stocks Bonds MutualFunds]) # no Crypto, no crypto_details
823
- engine.answer(%w[California Illinois])
824
- engine.answer("no") # no foreign accounts
825
- engine.answer(%w[Charitable Mortgage])
826
- engine.answer(3000) # <= 5000, no documentation needed
827
- engine.answer("Alice Johnson, alice.j@example.com, 555-555-0100")
828
- engine.answer("confirm")
829
- ```
412
+ class MyAdapter < FlowEngine::LLM::Adapter
413
+ def initialize(api_key:)
414
+ super()
415
+ @api_key = api_key
416
+ end
830
417
 
831
- **Collected data:**
832
-
833
- ```json
834
- {
835
- "filing_status": "married_filing_separately",
836
- "dependents": 1,
837
- "income_types": ["W2", "Business", "Investment"],
838
- "business_count": 2,
839
- "business_details": {
840
- "RealEstate": 0,
841
- "SCorp": 1,
842
- "CCorp": 0,
843
- "Trust": 0,
844
- "LLC": 1
845
- },
846
- "investment_details": ["Stocks", "Bonds", "MutualFunds"],
847
- "state_filing": ["California", "Illinois"],
848
- "foreign_accounts": "no",
849
- "deduction_types": ["Charitable", "Mortgage"],
850
- "charitable_amount": 3000,
851
- "contact_info": "Alice Johnson, alice.j@example.com, 555-555-0100",
852
- "review": "confirm"
853
- }
418
+ def chat(system_prompt:, user_prompt:, model:)
419
+ # Must return response text (expected to be JSON)
420
+ end
421
+ end
854
422
  ```
855
423
 
856
- **Path taken** (12 steps):
424
+ ---
857
425
 
858
- ```
859
- filing_status -> dependents -> income_types -> business_count ->
860
- business_details -> investment_details -> state_filing ->
861
- foreign_accounts -> deduction_types -> charitable_amount ->
862
- contact_info -> review
863
- ```
864
-
865
- ### Scenario 4: Rental + Foreign Accounts (10 steps visited)
426
+ ## State Persistence
866
427
 
867
- 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:
868
429
 
869
430
  ```ruby
870
- engine = FlowEngine::Engine.new(tax_intake)
871
-
872
- engine.answer("head_of_household")
873
- engine.answer(2)
874
- engine.answer(%w[1099 Rental])
875
- engine.answer({ "Residential" => 1, "Commercial" => 0, "Vacation" => 1 })
876
- engine.answer(["Florida"])
877
- engine.answer("yes")
878
- engine.answer(1)
879
- engine.answer(%w[Medical Education])
880
- engine.answer("Bob Lee, bob@example.com, 555-000-1111")
881
- engine.answer("confirm")
882
- ```
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 }
883
436
 
884
- **Collected data:**
885
-
886
- ```json
887
- {
888
- "filing_status": "head_of_household",
889
- "dependents": 2,
890
- "income_types": ["1099", "Rental"],
891
- "rental_details": {
892
- "Residential": 1,
893
- "Commercial": 0,
894
- "Vacation": 1
895
- },
896
- "state_filing": ["Florida"],
897
- "foreign_accounts": "yes",
898
- "foreign_account_details": 1,
899
- "deduction_types": ["Medical", "Education"],
900
- "contact_info": "Bob Lee, bob@example.com, 555-000-1111",
901
- "review": "confirm"
902
- }
437
+ restored = FlowEngine::Engine.from_state(definition, state)
903
438
  ```
904
439
 
905
- **Path taken** (10 steps):
440
+ Round-trips through JSON (string keys) are handled automatically.
906
441
 
907
- ```
908
- filing_status -> dependents -> income_types -> rental_details ->
909
- state_filing -> foreign_accounts -> foreign_account_details ->
910
- deduction_types -> contact_info -> review
911
- ```
442
+ ## Validation
912
443
 
913
- ### Comparing Collected Data Across Paths
914
-
915
- 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:
916
-
917
- | Answer Key | Max (17) | Min (8) | Medium (12) | Rental (10) |
918
- |---|:---:|:---:|:---:|:---:|
919
- | `filing_status` | x | x | x | x |
920
- | `dependents` | x | x | x | x |
921
- | `income_types` | x | x | x | x |
922
- | `business_count` | x | | x | |
923
- | `complex_business_info` | x | | | |
924
- | `business_details` | x | | x | |
925
- | `investment_details` | x | | x | |
926
- | `crypto_details` | x | | | |
927
- | `rental_details` | x | | | x |
928
- | `state_filing` | x | x | x | x |
929
- | `foreign_accounts` | x | x | x | x |
930
- | `foreign_account_details` | x | | | x |
931
- | `deduction_types` | x | x | x | x |
932
- | `charitable_amount` | x | | x | |
933
- | `charitable_documentation` | x | | | |
934
- | `contact_info` | x | x | x | x |
935
- | `review` | x | x | x | x |
936
-
937
- ## Composing Complex Rules
938
-
939
- ### Example: Multi-Condition Branching
444
+ Pluggable validators via the adapter pattern. Ships with `NullAdapter` (always passes):
940
445
 
941
446
  ```ruby
942
- definition = FlowEngine.define do
943
- start :status
944
-
945
- step :status do
946
- type :single_select
947
- question "Filing status?"
948
- options %w[single married]
949
- 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)
950
452
  end
453
+ end
951
454
 
952
- step :dependents do
953
- type :number
954
- question "How many dependents?"
955
- transition to: :income
956
- end
455
+ engine = FlowEngine::Engine.new(definition, validator: MyValidator.new)
456
+ ```
957
457
 
958
- step :income do
959
- type :multi_select
960
- question "Income types?"
961
- options %w[W2 Business]
962
-
963
- # All three conditions must be true
964
- transition to: :special_review,
965
- if_rule: all(
966
- equals(:status, "married"),
967
- contains(:income, "Business"),
968
- not_empty(:income)
969
- )
970
-
971
- # At least one condition must be true
972
- transition to: :alt_review,
973
- if_rule: any(
974
- less_than(:dependents, 2),
975
- contains(:income, "W2")
976
- )
977
-
978
- # Unconditional fallback
979
- transition to: :default_review
980
- end
458
+ ## Mermaid Diagram Export
981
459
 
982
- step :special_review do
983
- type :text
984
- question "Married with business income - special review required."
985
- end
460
+ ```ruby
461
+ exporter = FlowEngine::Graph::MermaidExporter.new(definition)
462
+ puts exporter.export
463
+ ```
986
464
 
987
- step :alt_review do
988
- type :text
989
- question "Alternative review path."
990
- end
465
+ ## Architecture
991
466
 
992
- step :default_review do
993
- type :text
994
- question "Default review."
995
- end
996
- end
997
- ```
467
+ ![architecture](docs/floweingine-architecture.png)
998
468
 
999
- The four possible outcomes:
1000
-
1001
- | Inputs | Path | Why |
1002
- |--------|------|-----|
1003
- | married, 0 deps, `[W2, Business]` | `:special_review` | `all()` satisfied: married + Business + not empty |
1004
- | single, 0 deps, `[W2]` | `:alt_review` | `all()` fails (not married); `any()` passes (has W2) |
1005
- | single, 1 dep, `[Business]` | `:alt_review` | `all()` fails; `any()` passes (deps < 2) |
1006
- | single, 3 deps, `[Business]` | `:default_review` | `all()` fails; `any()` fails (deps not < 2, no W2) |
1007
-
1008
- ## Mermaid Diagram of the Tax Intake Flow
1009
-
1010
- ![Example](docs/flowengine-example.png)
1011
-
1012
- <details>
1013
- <summary>Expand to see Mermaid source</summary>
1014
-
1015
- ```mermaid
1016
- flowchart BT
1017
- filing_status["What is your filing status for 2025?"] --> dependents["How many dependents do you have?"]
1018
- dependents --> income_types["Select all income types that apply to you in 2025."]
1019
- income_types -- Business in income_types --> business_count["How many total businesses do you own or are a part..."]
1020
- income_types -- Investment in income_types --> investment_details["What types of investments do you hold?"]
1021
- income_types -- Rental in income_types --> rental_details["Provide details about your rental properties."]
1022
- income_types --> state_filing["Which states do you need to file in?"]
1023
- business_count -- business_count > 2 --> complex_business_info["With more than 2 businesses, please provide your p..."]
1024
- business_count --> business_details["How many of each business type do you own?"]
1025
- complex_business_info --> business_details
1026
- business_details -- Investment in income_types --> investment_details
1027
- business_details -- Rental in income_types --> rental_details
1028
- business_details --> state_filing
1029
- investment_details -- Crypto in investment_details --> crypto_details["Please describe your cryptocurrency transactions (..."]
1030
- investment_details -- Rental in income_types --> rental_details
1031
- investment_details --> state_filing
1032
- crypto_details -- Rental in income_types --> rental_details
1033
- crypto_details --> state_filing
1034
- rental_details --> state_filing
1035
- state_filing --> foreign_accounts["Do you have any foreign financial accounts (bank a..."]
1036
- foreign_accounts -- "foreign_accounts == yes" --> foreign_account_details["How many foreign accounts do you have?"]
1037
- foreign_accounts --> deduction_types["Which additional deductions apply to you?"]
1038
- foreign_account_details --> deduction_types
1039
- deduction_types -- Charitable in deduction_types --> charitable_amount["What is your total estimated charitable contributi..."]
1040
- deduction_types --> contact_info["Please provide your contact information (name, ema..."]
1041
- charitable_amount -- charitable_amount > 5000 --> charitable_documentation["For charitable contributions over $5,000, please l..."]
1042
- charitable_amount --> contact_info
1043
- charitable_documentation --> contact_info
1044
- contact_info --> review@{ label: "Thank you! Please review your information. Type 'c..." }
1045
-
1046
- review@{ shape: rect}
1047
- ```
469
+ The core has **zero UI logic**, **zero DB logic**, and **zero framework dependencies**. Adapters translate input/output, persist state, and render UI.
1048
470
 
1049
- </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 |
1050
487
 
1051
488
  ## Ecosystem
1052
489
 
1053
- FlowEngine is the core of a three-gem architecture:
1054
-
1055
490
  | Gem | Purpose |
1056
491
  |-----|---------|
1057
- | **`flowengine`** (this gem) | Core engine + LLM introduction parsing (depends on `ruby_llm`) |
1058
- | **`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 |
1059
494
  | **`flowengine-rails`** | Rails Engine with ActiveRecord persistence and web views |
1060
495
 
1061
496
  ## Development
1062
497
 
1063
498
  ```bash
1064
499
  bundle install
1065
- bundle exec rspec
500
+ just test # RSpec + RuboCop
501
+ just lint # RuboCop only
502
+ just doc # Generate YARD docs
1066
503
  ```
1067
504
 
1068
505
  ## License
1069
506
 
1070
- 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).