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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +1 -6
- data/Brewfile +28 -0
- data/README.md +271 -834
- data/docs/PROJECT_STRUCTURE.md +64 -0
- data/docs/flowengine-processing.png +0 -0
- data/lefthook.yml +16 -0
- data/lib/flowengine/clarification_result.rb +21 -0
- data/lib/flowengine/definition.rb +4 -2
- data/lib/flowengine/dsl/flow_builder.rb +2 -2
- data/lib/flowengine/dsl/step_builder.rb +10 -1
- data/lib/flowengine/dsl.rb +0 -4
- data/lib/flowengine/engine/state_serializer.rb +50 -0
- data/lib/flowengine/engine.rb +101 -57
- data/lib/flowengine/errors.rb +39 -16
- data/lib/flowengine/llm/adapter.rb +69 -7
- data/lib/flowengine/llm/adapters/anthropic_adapter.rb +17 -0
- data/lib/flowengine/llm/adapters/gemini_adapter.rb +17 -0
- data/lib/flowengine/llm/adapters/openai_adapter.rb +17 -0
- data/lib/flowengine/llm/adapters.rb +24 -0
- data/lib/flowengine/llm/auto_client.rb +19 -0
- data/lib/flowengine/llm/client.rb +52 -6
- data/lib/flowengine/llm/intake_prompt_builder.rb +131 -0
- data/lib/flowengine/llm/provider.rb +12 -0
- data/lib/flowengine/llm/sensitive_data_filter.rb +1 -1
- data/lib/flowengine/llm.rb +95 -36
- data/lib/flowengine/node.rb +13 -3
- data/lib/flowengine/version.rb +1 -1
- data/lib/flowengine.rb +24 -25
- data/resources/models.yml +25 -0
- metadata +58 -5
- data/lib/flowengine/llm/anthropic_adapter.rb +0 -40
- data/lib/flowengine/llm/gemini_adapter.rb +0 -40
- data/lib/flowengine/llm/openai_adapter.rb +0 -38
- /data/docs/{floweingine-architecture.png → flowengine-architecture.png} +0 -0
data/README.md
CHANGED
|
@@ -2,44 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/kigster/flowengine/actions/workflows/rspec.yml) [](https://github.com/kigster/flowengine/actions/workflows/rubocop.yml) 
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
# =>
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-

|
|
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
|
|
73
|
+
start :first_step
|
|
268
74
|
|
|
269
|
-
# Optional:
|
|
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
|
-
|
|
276
|
-
|
|
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` |
|
|
291
|
-
| `question` |
|
|
292
|
-
| `options` | Available choices (
|
|
293
|
-
| `fields` | Named fields (
|
|
294
|
-
| `
|
|
295
|
-
| `
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
348
|
-
transition to: :
|
|
349
|
-
transition to: :
|
|
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
|
-
|
|
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** —
|
|
132
|
+
Rules are **immutable AST objects** — composable and evaluated polymorphically.
|
|
379
133
|
|
|
380
134
|
### Atomic Rules
|
|
381
135
|
|
|
382
|
-
|
|
|
383
|
-
|
|
384
|
-
| `
|
|
385
|
-
| `
|
|
386
|
-
| `
|
|
387
|
-
| `
|
|
388
|
-
| `
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
| `
|
|
456
|
-
| `
|
|
457
|
-
| `
|
|
458
|
-
| `
|
|
459
|
-
| `
|
|
460
|
-
| `
|
|
461
|
-
| `
|
|
462
|
-
| `
|
|
463
|
-
| `
|
|
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
|
-
#
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
207
|
+
### LLM Adapters & Configuration
|
|
518
208
|
|
|
519
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
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
|
-
|
|
540
|
-
|
|
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
|
-
|
|
239
|
+
```ruby
|
|
240
|
+
# Auto-detect from environment (checks Anthropic > OpenAI > Gemini)
|
|
241
|
+
client = FlowEngine::LLM.auto_client
|
|
544
242
|
|
|
545
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
255
|
+
---
|
|
569
256
|
|
|
570
|
-
###
|
|
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
|
-
|
|
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
|
|
579
|
-
options %w[single married_filing_jointly
|
|
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
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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 :
|
|
309
|
+
step :filing_status do
|
|
652
310
|
type :single_select
|
|
653
|
-
question "
|
|
654
|
-
options %w[
|
|
655
|
-
transition to: :
|
|
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 :
|
|
316
|
+
step :dependents do
|
|
660
317
|
type :number
|
|
661
|
-
question "How many
|
|
662
|
-
transition to: :
|
|
318
|
+
question "How many dependents do you claim?"
|
|
319
|
+
transition to: :income_types
|
|
663
320
|
end
|
|
664
321
|
|
|
665
|
-
step :
|
|
322
|
+
step :income_types do
|
|
666
323
|
type :multi_select
|
|
667
|
-
question "
|
|
668
|
-
options %w[
|
|
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
|
-
|
|
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(
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
engine.
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
engine.
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
engine.
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
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
|
-
|
|
359
|
+
#### ClarificationResult
|
|
760
360
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
-
|
|
373
|
+
#### Multiple AI Intakes in One Flow
|
|
770
374
|
|
|
771
|
-
|
|
375
|
+
Place `:ai_intake` steps at multiple points to break up the conversation:
|
|
772
376
|
|
|
773
377
|
```ruby
|
|
774
|
-
|
|
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
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
-
|
|
388
|
+
step :filing_status do
|
|
389
|
+
# ... personal info steps ...
|
|
390
|
+
transition to: :financial_intake
|
|
391
|
+
end
|
|
802
392
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
|
|
401
|
+
step :annual_income do
|
|
402
|
+
# ... financial steps ...
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
```
|
|
809
406
|
|
|
810
|
-
|
|
407
|
+
Each `:ai_intake` step maintains its own conversation history and round counter. State is fully serializable for persistence between requests.
|
|
811
408
|
|
|
812
|
-
|
|
409
|
+
### Custom LLM Adapters
|
|
813
410
|
|
|
814
411
|
```ruby
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
|
|
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
|
-
|
|
428
|
+
The engine's full state — including AI intake conversation history — can be serialized and restored:
|
|
868
429
|
|
|
869
430
|
```ruby
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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
|
-
|
|
953
|
-
|
|
954
|
-
question "How many dependents?"
|
|
955
|
-
transition to: :income
|
|
956
|
-
end
|
|
455
|
+
engine = FlowEngine::Engine.new(definition, validator: MyValidator.new)
|
|
456
|
+
```
|
|
957
457
|
|
|
958
|
-
|
|
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
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
460
|
+
```ruby
|
|
461
|
+
exporter = FlowEngine::Graph::MermaidExporter.new(definition)
|
|
462
|
+
puts exporter.export
|
|
463
|
+
```
|
|
986
464
|
|
|
987
|
-
|
|
988
|
-
type :text
|
|
989
|
-
question "Alternative review path."
|
|
990
|
-
end
|
|
465
|
+
## Architecture
|
|
991
466
|
|
|
992
|
-
|
|
993
|
-
type :text
|
|
994
|
-
question "Default review."
|
|
995
|
-
end
|
|
996
|
-
end
|
|
997
|
-
```
|
|
467
|
+

|
|
998
468
|
|
|
999
|
-
The
|
|
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
|
-

|
|
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
|
-
|
|
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
|
|
1058
|
-
| **`flowengine-cli`** | Terminal wizard
|
|
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
|
-
|
|
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
|
-
|
|
507
|
+
MIT License. See [LICENSE](https://opensource.org/licenses/MIT).
|