flowengine 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.envrc +1 -0
- data/Brewfile +28 -0
- data/README.md +348 -888
- data/docs/PROJECT_STRUCTURE.md +64 -0
- data/docs/flowengine-processing.png +0 -0
- data/docs/tty-prompt.md +1746 -0
- data/examples/README.md +14 -0
- data/examples/chat_history__complex.yml +22 -0
- data/examples/chat_history__simple.yml +1 -0
- data/examples/final_estimate__complex.yml +58 -0
- data/examples/final_estimate__simple.yml +50 -0
- data/lefthook.yml +16 -0
- 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/version.rb +1 -1
- metadata +43 -2
- /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,257 +47,21 @@ definition = FlowEngine.define do
|
|
|
68
47
|
end
|
|
69
48
|
end
|
|
70
49
|
|
|
71
|
-
# 2. Run the engine
|
|
72
50
|
engine = FlowEngine::Engine.new(definition)
|
|
73
|
-
|
|
74
51
|
engine.answer("Alice") # :name -> :age
|
|
75
52
|
engine.answer(25) # :age -> :beverage (25 > 20)
|
|
76
53
|
engine.answer("Wine") # :beverage -> :thanks
|
|
77
54
|
engine.answer("ok") # :thanks -> finished
|
|
78
55
|
|
|
79
56
|
engine.finished? # => true
|
|
80
|
-
engine.answers
|
|
81
|
-
# =>
|
|
82
|
-
engine.history
|
|
83
|
-
# => [:name, :age, :beverage, :thanks]
|
|
57
|
+
engine.answers # => { name: "Alice", age: 25, beverage: "Wine", thanks: "ok" }
|
|
58
|
+
engine.history # => [:name, :age, :beverage, :thanks]
|
|
84
59
|
```
|
|
85
60
|
|
|
86
|
-
If Alice were 18
|
|
87
|
-
|
|
88
|
-
```ruby
|
|
89
|
-
engine.answer("Alice") # :name -> :age
|
|
90
|
-
engine.answer(18) # :age -> :thanks (18 is NOT > 20)
|
|
91
|
-
engine.answer("ok") # :thanks -> finished
|
|
92
|
-
|
|
93
|
-
engine.answers
|
|
94
|
-
# => { name: "Alice", age: 18, thanks: "ok" }
|
|
95
|
-
engine.history
|
|
96
|
-
# => [:name, :age, :thanks]
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
### Using the `flowengine-cli` gem to Generate the JSON Answers File
|
|
61
|
+
If Alice were 18, the engine skips `:beverage` entirely — the first matching transition (`18 NOT > 20`) falls through to the unconditional `:thanks`.
|
|
100
62
|
|
|
101
63
|
---
|
|
102
64
|
|
|
103
|
-
## LLM-Based DSL Capabilities & Environment Variables
|
|
104
|
-
|
|
105
|
-
There are several environment variables that define which vendor and which model you can talk to should you choose to engage LLM in your decision logic.
|
|
106
|
-
|
|
107
|
-
There is a very special YAML file that's provided with this gem, which locks in the list of supported vendors and three types of models per vendor:
|
|
108
|
-
|
|
109
|
-
- best bang for the buck models
|
|
110
|
-
- deep thinking and hard mode models
|
|
111
|
-
- fastest models
|
|
112
|
-
- *at some point we might also add the "cheapest".*
|
|
113
|
-
|
|
114
|
-
+The file [resources/models.yml](resources/models.yml) defines which models are available to the adapter. This file is used at the startup of the gem, to load and initialize all LLM Adapters for which we have the API Key defined in the environment. And for those we'll have at least three model names loaded:
|
|
115
|
-
|
|
116
|
-
- `top:` — best results, likely the most expensive.
|
|
117
|
-
- `default:` — default model, if the user of the adapter does not specify.
|
|
118
|
-
- `fastest` — the fastest model from this vendor.
|
|
119
|
-
|
|
120
|
-
Here is the contents of `resources/models.yml` verbatim:
|
|
121
|
-
|
|
122
|
-
```yaml
|
|
123
|
-
models:
|
|
124
|
-
version: "1.0"
|
|
125
|
-
date: "Wed Mar 11 02:35:39 PDT 2026"
|
|
126
|
-
vendors:
|
|
127
|
-
anthropic:
|
|
128
|
-
adapter: "FlowEngine::LLM::Adapters::AnthropicAdapter"
|
|
129
|
-
var: "ANTHROPIC_API_KEY"
|
|
130
|
-
top: "claude-opus-4-6"
|
|
131
|
-
default: "claude-sonnet-4-6"
|
|
132
|
-
fastest: "claude-haiku-4-5-20251001"
|
|
133
|
-
openai:
|
|
134
|
-
adapter: "FlowEngine::LLM::Adapters::OpenAIAdapter"
|
|
135
|
-
var: "OPENAI_API_KEY"
|
|
136
|
-
top: "gpt-5.4"
|
|
137
|
-
default: "gpt-5-mini"
|
|
138
|
-
fastest: "gpt-5-nano"
|
|
139
|
-
gemini:
|
|
140
|
-
adapter: "FlowEngine::LLM::Adapters::GeminiAdapters"
|
|
141
|
-
var: "GEMINI_API_KEY"
|
|
142
|
-
top: "gemini-3.1-pro-preview"
|
|
143
|
-
default: "gemini-2.5-flash"
|
|
144
|
-
fastest: "gemini-2.5-flash-lite"
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
Notice how this file operates as almost a sort of glue for the gem: it explicitly tells you the names of variables to store your API keys, the class names of the corresponding Adapters, and the three models for each vendor:
|
|
148
|
-
|
|
149
|
-
1. `:top`
|
|
150
|
-
2. `:default`
|
|
151
|
-
3. `:fastest`
|
|
152
|
-
|
|
153
|
-
> [!IMPORTANT]
|
|
154
|
-
>
|
|
155
|
-
> The reason these models are extracted into a separate YAML file should be obvious: the contents of this list seems to change every week, and gem can remain at the same version for years. For this reason, the gem honors the environment variable `${FLOWENGINE_LLM_MODELS_PATH}` and will read the models and vendors from the file pointed to by that path environment variable. This is your door to better models, and other LLM vendors that RubyLLM supports.
|
|
156
|
-
|
|
157
|
-
When the gem is loading, one of the first things it does is load this YAML file and instantiate the hash of pre-initialized adapters.
|
|
158
|
-
|
|
159
|
-
Need an adapter to throw with your API call?
|
|
160
|
-
|
|
161
|
-
```ruby
|
|
162
|
-
FlowEngine::LLM[vendor: :anthropic, 'claude-opus-4-6']
|
|
163
|
-
|
|
164
|
-
## LLM-parsed Introduction
|
|
165
|
-
|
|
166
|
-
FlowEngine supports an optional **introduction step** that collects free-form text from the user before the structured flow begins. An LLM parses this text to pre-fill answers, automatically skipping steps the user already answered in their introduction.
|
|
167
|
-
|
|
168
|
-
### Defining an Introduction
|
|
169
|
-
|
|
170
|
-
Add the `introduction` command to your flow definition:
|
|
171
|
-
|
|
172
|
-
```ruby
|
|
173
|
-
definition = FlowEngine.define do
|
|
174
|
-
start :filing_status
|
|
175
|
-
|
|
176
|
-
introduction label: "Tell us about your tax situation",
|
|
177
|
-
placeholder: "e.g. I am married, filing jointly, with 2 dependents...",
|
|
178
|
-
maxlength: 2000 # optional character limit
|
|
179
|
-
|
|
180
|
-
step :filing_status do
|
|
181
|
-
type :single_select
|
|
182
|
-
question "What is your filing status?"
|
|
183
|
-
options %w[single married_filing_jointly head_of_household]
|
|
184
|
-
transition to: :dependents
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
step :dependents do
|
|
188
|
-
type :number
|
|
189
|
-
question "How many dependents?"
|
|
190
|
-
transition to: :income_types
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
step :income_types do
|
|
194
|
-
type :multi_select
|
|
195
|
-
question "Select income types"
|
|
196
|
-
options %w[W2 1099 Business Investment]
|
|
197
|
-
end
|
|
198
|
-
end
|
|
199
|
-
```
|
|
200
|
-
|
|
201
|
-
| Parameter | Required | Description |
|
|
202
|
-
|-----------|----------|-------------|
|
|
203
|
-
| `label` | Yes | Text shown above the input field |
|
|
204
|
-
| `placeholder` | No | Ghost text inside the text area (default: `""`) |
|
|
205
|
-
| `maxlength` | No | Maximum character count (default: `nil` = unlimited) |
|
|
206
|
-
|
|
207
|
-
### Using the Introduction at Runtime
|
|
208
|
-
|
|
209
|
-
```ruby
|
|
210
|
-
# 1. Auto-detect adapter from environment (checks ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY)
|
|
211
|
-
client = FlowEngine::LLM.auto_client
|
|
212
|
-
|
|
213
|
-
# Or explicitly choose a provider:
|
|
214
|
-
# client = FlowEngine::LLM.auto_client(anthropic_api_key: "sk-ant-...")
|
|
215
|
-
# client = FlowEngine::LLM.auto_client(openai_api_key: "sk-...", model: "gpt-4o")
|
|
216
|
-
# client = FlowEngine::LLM.auto_client(gemini_api_key: "AIza...")
|
|
217
|
-
|
|
218
|
-
# 2. Create the engine and submit the introduction
|
|
219
|
-
engine = FlowEngine::Engine.new(definition)
|
|
220
|
-
engine.submit_introduction(
|
|
221
|
-
"I am married filing jointly with 2 dependents, W2 and business income",
|
|
222
|
-
llm_client: client
|
|
223
|
-
)
|
|
224
|
-
|
|
225
|
-
# 3. The LLM pre-fills answers and the engine auto-advances
|
|
226
|
-
engine.answers
|
|
227
|
-
# => { filing_status: "married_filing_jointly", dependents: 2,
|
|
228
|
-
# income_types: ["W2", "Business"] }
|
|
229
|
-
|
|
230
|
-
engine.current_step_id # => nil (all steps pre-filled in this case)
|
|
231
|
-
engine.introduction_text # => "I am married filing jointly with 2 dependents, ..."
|
|
232
|
-
engine.finished? # => true
|
|
233
|
-
```
|
|
234
|
-
|
|
235
|
-
If the LLM can only extract some answers, the engine stops at the first unanswered step and the user continues the flow normally from there.
|
|
236
|
-
|
|
237
|
-
### Sensitive Data Protection
|
|
238
|
-
|
|
239
|
-
Before any text reaches the LLM, `submit_introduction` scans for sensitive data patterns:
|
|
240
|
-
|
|
241
|
-
- **SSN**: `123-45-6789`
|
|
242
|
-
- **ITIN**: `912-34-5678`
|
|
243
|
-
- **EIN**: `12-3456789`
|
|
244
|
-
- **Nine consecutive digits**: `123456789`
|
|
245
|
-
|
|
246
|
-
If detected, a `FlowEngine::Errors::SensitiveDataError` is raised immediately. The introduction text is discarded and no LLM call is made.
|
|
247
|
-
|
|
248
|
-
```ruby
|
|
249
|
-
engine.submit_introduction("My SSN is 123-45-6789", llm_client: client)
|
|
250
|
-
# => raises FlowEngine::Errors::SensitiveDataError
|
|
251
|
-
```
|
|
252
|
-
|
|
253
|
-
### Custom LLM Adapters
|
|
254
|
-
|
|
255
|
-
The LLM integration uses an adapter pattern. The gem ships with three adapters (all via the [`ruby_llm`](https://github.com/crmne/ruby_llm) gem):
|
|
256
|
-
|
|
257
|
-
| Adapter | Env Variable | Default Model |
|
|
258
|
-
|---------|-------------|---------------|
|
|
259
|
-
| `AnthropicAdapter` | `ANTHROPIC_API_KEY` | `claude-sonnet-4-20250514` |
|
|
260
|
-
| `OpenAIAdapter` | `OPENAI_API_KEY` | `gpt-4o-mini` |
|
|
261
|
-
| `GeminiAdapter` | `GEMINI_API_KEY` | `gemini-2.0-flash` |
|
|
262
|
-
|
|
263
|
-
You can also create adapters for any other provider:
|
|
264
|
-
|
|
265
|
-
```ruby
|
|
266
|
-
class MyCustomAdapter < FlowEngine::LLM::Adapter
|
|
267
|
-
def initialize(api_key:)
|
|
268
|
-
super()
|
|
269
|
-
@api_key = api_key
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
def chat(system_prompt:, user_prompt:, model:)
|
|
273
|
-
# Call your LLM API here
|
|
274
|
-
# Must return the response text (expected to be a JSON string)
|
|
275
|
-
end
|
|
276
|
-
end
|
|
277
|
-
|
|
278
|
-
adapter = MyCustomAdapter.new(api_key: ENV["MY_API_KEY"])
|
|
279
|
-
client = FlowEngine::LLM::Client.new(adapter: adapter, model: "my-model")
|
|
280
|
-
```
|
|
281
|
-
|
|
282
|
-
### State Persistence
|
|
283
|
-
|
|
284
|
-
The `introduction_text` is included in state serialization:
|
|
285
|
-
|
|
286
|
-
```ruby
|
|
287
|
-
state = engine.to_state
|
|
288
|
-
# => { current_step_id: ..., answers: { ... }, history: [...], introduction_text: "..." }
|
|
289
|
-
|
|
290
|
-
restored = FlowEngine::Engine.from_state(definition, state)
|
|
291
|
-
restored.introduction_text # => "I am married filing jointly..."
|
|
292
|
-
```
|
|
293
|
-
|
|
294
|
-
## Architecture
|
|
295
|
-
|
|
296
|
-

|
|
297
|
-
|
|
298
|
-
The core has **zero UI logic**, **zero DB logic**, and **zero framework dependencies**. Adapters translate input/output, persist state, and render UI.
|
|
299
|
-
|
|
300
|
-
### Core Components
|
|
301
|
-
|
|
302
|
-
| Component | Responsibility |
|
|
303
|
-
|-----------|---------------|
|
|
304
|
-
| `FlowEngine.define` | DSL entry point; returns a frozen `Definition` |
|
|
305
|
-
| `Introduction` | Immutable config for the introduction step (label, placeholder, maxlength) |
|
|
306
|
-
| `Definition` | Immutable container of the flow graph (nodes + start step + introduction) |
|
|
307
|
-
| `Node` | A single step: type, question, options/fields, transitions, visibility |
|
|
308
|
-
| `Transition` | A directed edge with an optional rule condition |
|
|
309
|
-
| `Rules::*` | AST nodes for conditional logic (`Contains`, `Equals`, `All`, etc.) |
|
|
310
|
-
| `Evaluator` | Evaluates rules against the current answer store |
|
|
311
|
-
| `Engine` | Stateful runtime: tracks current step, answers, history, and introduction |
|
|
312
|
-
| `Validation::Adapter` | Interface for pluggable validation (dry-validation, JSON Schema, etc.) |
|
|
313
|
-
| `LLM::Adapter` | Abstract interface for LLM API calls |
|
|
314
|
-
| `LLM::AnthropicAdapter` | Anthropic/Claude implementation via `ruby_llm` gem |
|
|
315
|
-
| `LLM::OpenAIAdapter` | OpenAI implementation via `ruby_llm` gem |
|
|
316
|
-
| `LLM::GeminiAdapter` | Google Gemini implementation via `ruby_llm` gem |
|
|
317
|
-
| `LLM::Client` | High-level: builds prompt, calls adapter, parses JSON response |
|
|
318
|
-
| `LLM.auto_client` | Factory: auto-detects provider from environment API keys |
|
|
319
|
-
| `LLM::SensitiveDataFilter` | Rejects text containing SSN, ITIN, EIN patterns |
|
|
320
|
-
| `Graph::MermaidExporter` | Exports the flow definition as a Mermaid diagram |
|
|
321
|
-
|
|
322
65
|
## The DSL
|
|
323
66
|
|
|
324
67
|
### Defining a Flow
|
|
@@ -327,79 +70,37 @@ Every flow starts with `FlowEngine.define`, which returns a **frozen, immutable*
|
|
|
327
70
|
|
|
328
71
|
```ruby
|
|
329
72
|
definition = FlowEngine.define do
|
|
330
|
-
start :first_step
|
|
73
|
+
start :first_step
|
|
331
74
|
|
|
332
|
-
# Optional:
|
|
75
|
+
# Optional: one-shot LLM pre-fill (see "Introduction" section)
|
|
333
76
|
introduction label: "Describe your situation",
|
|
334
77
|
placeholder: "Type here...",
|
|
335
78
|
maxlength: 2000
|
|
336
79
|
|
|
337
80
|
step :first_step do
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
step :second_step do
|
|
342
|
-
# step configuration...
|
|
81
|
+
type :text
|
|
82
|
+
question "What is your name?"
|
|
83
|
+
transition to: :second_step
|
|
343
84
|
end
|
|
344
85
|
end
|
|
345
86
|
```
|
|
346
87
|
|
|
347
88
|
### Step Configuration
|
|
348
89
|
|
|
349
|
-
Inside a `step` block, you have access to:
|
|
350
|
-
|
|
351
90
|
| Method | Purpose | Example |
|
|
352
91
|
|--------|---------|---------|
|
|
353
|
-
| `type` |
|
|
354
|
-
| `question` |
|
|
355
|
-
| `options` | Available choices (
|
|
356
|
-
| `fields` | Named fields (
|
|
357
|
-
| `
|
|
358
|
-
| `
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
Step types are semantic labels consumed by UI adapters. The engine itself is type-agnostic — it stores whatever value you pass to `engine.answer(value)`. The types communicate intent to renderers:
|
|
363
|
-
|
|
364
|
-
```ruby
|
|
365
|
-
step :filing_status do
|
|
366
|
-
type :single_select # One choice from a list
|
|
367
|
-
question "What is your filing status?"
|
|
368
|
-
options %w[single married_filing_jointly married_filing_separately head_of_household]
|
|
369
|
-
transition to: :dependents
|
|
370
|
-
end
|
|
371
|
-
|
|
372
|
-
step :income_types do
|
|
373
|
-
type :multi_select # Multiple choices from a list
|
|
374
|
-
question "Select all income types."
|
|
375
|
-
options %w[W2 1099 Business Investment Rental]
|
|
376
|
-
transition to: :business, if_rule: contains(:income_types, "Business")
|
|
377
|
-
transition to: :summary
|
|
378
|
-
end
|
|
379
|
-
|
|
380
|
-
step :dependents do
|
|
381
|
-
type :number # A numeric value
|
|
382
|
-
question "How many dependents?"
|
|
383
|
-
transition to: :income_types
|
|
384
|
-
end
|
|
385
|
-
|
|
386
|
-
step :business_details do
|
|
387
|
-
type :number_matrix # Multiple named numeric fields
|
|
388
|
-
question "How many of each business type?"
|
|
389
|
-
fields %w[RealEstate SCorp CCorp Trust LLC]
|
|
390
|
-
transition to: :summary
|
|
391
|
-
end
|
|
392
|
-
|
|
393
|
-
step :notes do
|
|
394
|
-
type :text # Free-form text
|
|
395
|
-
question "Any additional notes?"
|
|
396
|
-
transition to: :summary
|
|
397
|
-
end
|
|
398
|
-
```
|
|
92
|
+
| `type` | Input type (for UI adapters) | `:text`, `:number`, `:single_select`, `:multi_select`, `:number_matrix`, `:ai_intake` |
|
|
93
|
+
| `question` | Prompt shown to the user | `"What is your filing status?"` |
|
|
94
|
+
| `options` | Available choices (select types) | `%w[W2 1099 Business]` |
|
|
95
|
+
| `fields` | Named fields (matrix types) | `%w[RealEstate SCorp LLC]` |
|
|
96
|
+
| `decorations` | Opaque UI metadata | `{ hint: "metadata" }` |
|
|
97
|
+
| `transition` | Where to go next (with optional condition) | `transition to: :next, if_rule: equals(:field, "val")` |
|
|
98
|
+
| `visible_if` | Visibility rule (DAG mode) | `visible_if contains(:income, "Rental")` |
|
|
99
|
+
| `max_clarifications` | Max follow-up rounds for `:ai_intake` steps | `max_clarifications 3` |
|
|
399
100
|
|
|
400
101
|
### Transitions
|
|
401
102
|
|
|
402
|
-
|
|
103
|
+
Evaluated **in order** — the first matching transition wins. A transition with no `if_rule:` always matches (use as fallback):
|
|
403
104
|
|
|
404
105
|
```ruby
|
|
405
106
|
step :income_types do
|
|
@@ -407,23 +108,15 @@ step :income_types do
|
|
|
407
108
|
question "Select income types."
|
|
408
109
|
options %w[W2 1099 Business Investment Rental]
|
|
409
110
|
|
|
410
|
-
|
|
411
|
-
transition to: :
|
|
412
|
-
transition to: :
|
|
413
|
-
transition to: :rental_details, if_rule: contains(:income_types, "Rental")
|
|
414
|
-
|
|
415
|
-
# Unconditional fallback (always matches)
|
|
416
|
-
transition to: :state_filing
|
|
111
|
+
transition to: :business_count, if_rule: contains(:income_types, "Business")
|
|
112
|
+
transition to: :investment_details, if_rule: contains(:income_types, "Investment")
|
|
113
|
+
transition to: :state_filing # unconditional fallback
|
|
417
114
|
end
|
|
418
115
|
```
|
|
419
116
|
|
|
420
|
-
**Key behavior:** Only the *first* matching transition fires. If the user selects `["Business", "Investment", "Rental"]`, the engine goes to `:business_count` first. The subsequent steps must themselves include transitions to eventually reach `:investment_details` and `:rental_details`.
|
|
421
|
-
|
|
422
|
-
A transition with no `if_rule:` always matches — use it as a fallback at the end of the list.
|
|
423
|
-
|
|
424
117
|
### Visibility Rules
|
|
425
118
|
|
|
426
|
-
|
|
119
|
+
Steps can have visibility conditions for DAG-mode rendering:
|
|
427
120
|
|
|
428
121
|
```ruby
|
|
429
122
|
step :spouse_income do
|
|
@@ -434,700 +127,467 @@ step :spouse_income do
|
|
|
434
127
|
end
|
|
435
128
|
```
|
|
436
129
|
|
|
437
|
-
The engine exposes this via `node.visible?(answers)`, which returns `true` when the rule is satisfied (or when no visibility rule is set).
|
|
438
|
-
|
|
439
130
|
## Rule System
|
|
440
131
|
|
|
441
|
-
Rules are **AST objects** —
|
|
132
|
+
Rules are **immutable AST objects** — composable and evaluated polymorphically.
|
|
442
133
|
|
|
443
134
|
### Atomic Rules
|
|
444
135
|
|
|
445
|
-
|
|
|
446
|
-
|
|
447
|
-
| `
|
|
448
|
-
| `
|
|
449
|
-
| `
|
|
450
|
-
| `
|
|
451
|
-
| `
|
|
136
|
+
| Helper | Evaluates |
|
|
137
|
+
|--------|-----------|
|
|
138
|
+
| `contains(:field, "val")` | `Array(answers[:field]).include?("val")` |
|
|
139
|
+
| `equals(:field, "val")` | `answers[:field] == "val"` |
|
|
140
|
+
| `greater_than(:field, 10)` | `answers[:field].to_i > 10` |
|
|
141
|
+
| `less_than(:field, 5)` | `answers[:field].to_i < 5` |
|
|
142
|
+
| `not_empty(:field)` | `answers[:field]` is not nil and not empty |
|
|
452
143
|
|
|
453
144
|
### Composite Rules
|
|
454
145
|
|
|
455
|
-
Combine atomic rules with boolean logic:
|
|
456
|
-
|
|
457
|
-
```ruby
|
|
458
|
-
# AND — all conditions must be true
|
|
459
|
-
transition to: :special_review,
|
|
460
|
-
if_rule: all(
|
|
461
|
-
equals(:filing_status, "married_filing_jointly"),
|
|
462
|
-
contains(:income_types, "Business"),
|
|
463
|
-
greater_than(:business_count, 2)
|
|
464
|
-
)
|
|
465
|
-
|
|
466
|
-
# OR — at least one condition must be true
|
|
467
|
-
transition to: :alt_path,
|
|
468
|
-
if_rule: any(
|
|
469
|
-
contains(:income_types, "Investment"),
|
|
470
|
-
contains(:income_types, "Rental")
|
|
471
|
-
)
|
|
472
|
-
```
|
|
473
|
-
|
|
474
|
-
Composites nest arbitrarily:
|
|
475
|
-
|
|
476
146
|
```ruby
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
),
|
|
484
|
-
not_empty(:dependents)
|
|
485
|
-
)
|
|
486
|
-
```
|
|
487
|
-
|
|
488
|
-
### How Rules Evaluate
|
|
489
|
-
|
|
490
|
-
Every rule implements `evaluate(answers)` where `answers` is the engine's hash of `{ step_id => value }`:
|
|
147
|
+
# AND — all must be true
|
|
148
|
+
transition to: :special, if_rule: all(
|
|
149
|
+
equals(:status, "married"),
|
|
150
|
+
contains(:income, "Business"),
|
|
151
|
+
greater_than(:business_count, 2)
|
|
152
|
+
)
|
|
491
153
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
154
|
+
# OR — at least one must be true
|
|
155
|
+
transition to: :alt, if_rule: any(
|
|
156
|
+
contains(:income, "Investment"),
|
|
157
|
+
contains(:income, "Rental")
|
|
158
|
+
)
|
|
496
159
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
160
|
+
# Nest arbitrarily
|
|
161
|
+
transition to: :complex, if_rule: all(
|
|
162
|
+
equals(:status, "married"),
|
|
163
|
+
any(greater_than(:biz_count, 3), contains(:income, "Rental")),
|
|
164
|
+
not_empty(:dependents)
|
|
500
165
|
)
|
|
501
|
-
rule.evaluate({ status: "married", dependents: 2 }) # => true
|
|
502
|
-
rule.evaluate({ status: "single", dependents: 2 }) # => false
|
|
503
166
|
```
|
|
504
167
|
|
|
505
168
|
## Engine API
|
|
506
169
|
|
|
507
|
-
### Creating and Running
|
|
508
|
-
|
|
509
170
|
```ruby
|
|
510
|
-
definition = FlowEngine.define { ... }
|
|
511
171
|
engine = FlowEngine::Engine.new(definition)
|
|
512
172
|
```
|
|
513
173
|
|
|
514
|
-
### Methods
|
|
515
|
-
|
|
516
174
|
| Method | Returns | Description |
|
|
517
175
|
|--------|---------|-------------|
|
|
518
|
-
| `
|
|
519
|
-
| `
|
|
520
|
-
| `
|
|
521
|
-
| `
|
|
522
|
-
| `
|
|
523
|
-
| `
|
|
524
|
-
| `
|
|
525
|
-
| `
|
|
526
|
-
| `
|
|
176
|
+
| `current_step_id` | `Symbol?` | Current step ID |
|
|
177
|
+
| `current_step` | `Node?` | Current Node object |
|
|
178
|
+
| `answer(value)` | `nil` | Records answer and advances |
|
|
179
|
+
| `finished?` | `Boolean` | True when no more steps |
|
|
180
|
+
| `answers` | `Hash` | All collected `{ step_id => value }` |
|
|
181
|
+
| `history` | `Array<Symbol>` | Visited step IDs in order |
|
|
182
|
+
| `definition` | `Definition` | The immutable flow definition |
|
|
183
|
+
| `submit_introduction(text, llm_client:)` | `nil` | One-shot LLM pre-fill from free-form text |
|
|
184
|
+
| `submit_ai_intake(text, llm_client:)` | `ClarificationResult` | Multi-round AI intake for current `:ai_intake` step |
|
|
185
|
+
| `submit_clarification(text, llm_client:)` | `ClarificationResult` | Continue an active AI intake conversation |
|
|
186
|
+
| `introduction_text` | `String?` | Raw introduction text submitted |
|
|
187
|
+
| `clarification_round` | `Integer` | Current AI intake round (0 if none active) |
|
|
188
|
+
| `conversation_history` | `Array<Hash>` | AI intake conversation `[{role:, text:}]` |
|
|
189
|
+
| `to_state` / `.from_state` | `Hash` / `Engine` | State serialization for persistence |
|
|
527
190
|
|
|
528
191
|
### Error Handling
|
|
529
192
|
|
|
530
193
|
```ruby
|
|
531
|
-
#
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
#
|
|
536
|
-
|
|
537
|
-
# => raises FlowEngine::Errors::UnknownStepError
|
|
538
|
-
|
|
539
|
-
# Invalid definition (start step doesn't exist)
|
|
540
|
-
FlowEngine.define do
|
|
541
|
-
start :missing
|
|
542
|
-
step :other do
|
|
543
|
-
type :text
|
|
544
|
-
question "Hello"
|
|
545
|
-
end
|
|
546
|
-
end
|
|
547
|
-
# => raises FlowEngine::Errors::DefinitionError
|
|
194
|
+
engine.answer("extra") # AlreadyFinishedError (flow finished)
|
|
195
|
+
definition.step(:nonexistent) # UnknownStepError
|
|
196
|
+
engine.submit_introduction("SSN: 123-45-6789", llm_client:) # SensitiveDataError
|
|
197
|
+
engine.submit_introduction("A" * 3000, llm_client:) # ValidationError (maxlength)
|
|
198
|
+
engine.submit_ai_intake("hi", llm_client:) # EngineError (not on an ai_intake step)
|
|
199
|
+
```
|
|
548
200
|
|
|
549
|
-
|
|
550
|
-
engine.submit_introduction("My SSN is 123-45-6789", llm_client: client)
|
|
551
|
-
# => raises FlowEngine::Errors::SensitiveDataError
|
|
201
|
+
---
|
|
552
202
|
|
|
553
|
-
|
|
554
|
-
engine.submit_introduction("A" * 3000, llm_client: client)
|
|
555
|
-
# => raises FlowEngine::Errors::ValidationError
|
|
203
|
+
## LLM Integration
|
|
556
204
|
|
|
557
|
-
|
|
558
|
-
FlowEngine::LLM::Adapters::OpenAIAdapter.new # without OPENAI_API_KEY
|
|
559
|
-
# => raises FlowEngine::Errors::LLMError
|
|
560
|
-
```
|
|
205
|
+
FlowEngine offers two ways to use LLMs for pre-filling answers from free-form text.
|
|
561
206
|
|
|
562
|
-
|
|
207
|
+
### LLM Adapters & Configuration
|
|
563
208
|
|
|
564
|
-
The
|
|
209
|
+
The gem ships with three adapters (all via [`ruby_llm`](https://github.com/crmne/ruby_llm)):
|
|
565
210
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
raise NotImplementedError
|
|
572
|
-
end
|
|
573
|
-
end
|
|
211
|
+
| Adapter | Env Variable |
|
|
212
|
+
|---------|-------------|
|
|
213
|
+
| `AnthropicAdapter` | `ANTHROPIC_API_KEY` |
|
|
214
|
+
| `OpenAIAdapter` | `OPENAI_API_KEY` |
|
|
215
|
+
| `GeminiAdapter` | `GEMINI_API_KEY` |
|
|
574
216
|
|
|
575
|
-
|
|
576
|
-
FlowEngine::Errors::Validation::Result.new(valid: true, errors: [])
|
|
577
|
-
FlowEngine::Errors::Validation::Result.new(valid: false, errors: ["must be a number"])
|
|
578
|
-
```
|
|
217
|
+
The file [`resources/models.yml`](resources/models.yml) defines three model tiers per vendor (`top`, `default`, `fastest`). Override with `$FLOWENGINE_LLM_MODELS_PATH`.
|
|
579
218
|
|
|
580
|
-
|
|
219
|
+
```yaml
|
|
220
|
+
models:
|
|
221
|
+
vendors:
|
|
222
|
+
anthropic:
|
|
223
|
+
var: "ANTHROPIC_API_KEY"
|
|
224
|
+
top: "claude-opus-4-6"
|
|
225
|
+
default: "claude-sonnet-4-6"
|
|
226
|
+
fastest: "claude-haiku-4-5-20251001"
|
|
227
|
+
openai:
|
|
228
|
+
var: "OPENAI_API_KEY"
|
|
229
|
+
top: "gpt-5.4"
|
|
230
|
+
default: "gpt-5-mini"
|
|
231
|
+
fastest: "gpt-5-nano"
|
|
232
|
+
gemini:
|
|
233
|
+
var: "GEMINI_API_KEY"
|
|
234
|
+
top: "gemini-3.1-pro-preview"
|
|
235
|
+
default: "gemini-2.5-flash"
|
|
236
|
+
fastest: "gemini-2.5-flash-lite"
|
|
237
|
+
```
|
|
581
238
|
|
|
582
239
|
```ruby
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
errors = []
|
|
240
|
+
# Auto-detect from environment (checks Anthropic > OpenAI > Gemini)
|
|
241
|
+
client = FlowEngine::LLM.auto_client
|
|
586
242
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
errors << "must be a number" unless input.is_a?(Numeric)
|
|
590
|
-
when :single_select
|
|
591
|
-
errors << "invalid option" unless node.options&.include?(input)
|
|
592
|
-
when :multi_select
|
|
593
|
-
unless input.is_a?(Array) && input.all? { |v| node.options&.include?(v) }
|
|
594
|
-
errors << "invalid options"
|
|
595
|
-
end
|
|
596
|
-
end
|
|
597
|
-
|
|
598
|
-
FlowEngine::Errors::Validation::Result.new(valid: errors.empty?, errors: errors)
|
|
599
|
-
end
|
|
600
|
-
end
|
|
243
|
+
# Explicit provider / model override
|
|
244
|
+
client = FlowEngine::LLM.auto_client(anthropic_api_key: "sk-ant-...", model: "claude-haiku-4-5-20251001")
|
|
601
245
|
|
|
602
|
-
|
|
603
|
-
|
|
246
|
+
# Manual adapter
|
|
247
|
+
adapter = FlowEngine::LLM::Adapters::OpenAIAdapter.new(api_key: ENV["OPENAI_API_KEY"])
|
|
248
|
+
client = FlowEngine::LLM::Client.new(adapter: adapter, model: "gpt-5-mini")
|
|
604
249
|
```
|
|
605
250
|
|
|
606
|
-
|
|
251
|
+
### Sensitive Data Protection
|
|
252
|
+
|
|
253
|
+
Before any text reaches the LLM, `SensitiveDataFilter` scans for SSN, ITIN, EIN, and nine-consecutive-digit patterns. If detected, a `SensitiveDataError` is raised immediately — no LLM call is made.
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
### Option 1: Introduction (One-Shot Pre-Fill)
|
|
607
258
|
|
|
608
|
-
|
|
259
|
+
A flow-level free-form text field parsed by the LLM in a single pass. Good for simple intake where one prompt is enough.
|
|
609
260
|
|
|
610
261
|
```ruby
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
```
|
|
262
|
+
definition = FlowEngine.define do
|
|
263
|
+
start :filing_status
|
|
614
264
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
265
|
+
introduction label: "Tell us about your tax situation",
|
|
266
|
+
placeholder: "e.g. I am married, filing jointly, with 2 dependents...",
|
|
267
|
+
maxlength: 2000
|
|
268
|
+
|
|
269
|
+
step :filing_status do
|
|
270
|
+
type :single_select
|
|
271
|
+
question "What is your filing status?"
|
|
272
|
+
options %w[single married_filing_jointly head_of_household]
|
|
273
|
+
transition to: :dependents
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
step :dependents do
|
|
277
|
+
type :number
|
|
278
|
+
question "How many dependents?"
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
engine = FlowEngine::Engine.new(definition)
|
|
283
|
+
engine.submit_introduction(
|
|
284
|
+
"I am married filing jointly with 2 dependents",
|
|
285
|
+
llm_client: FlowEngine::LLM.auto_client
|
|
286
|
+
)
|
|
287
|
+
engine.answers # => { filing_status: "married_filing_jointly", dependents: 2 }
|
|
288
|
+
engine.finished? # => true
|
|
627
289
|
```
|
|
628
290
|
|
|
629
|
-
|
|
291
|
+
---
|
|
630
292
|
|
|
631
|
-
|
|
293
|
+
### Option 2: AI Intake Steps (Multi-Round Conversational)
|
|
632
294
|
|
|
633
|
-
|
|
295
|
+
An `:ai_intake` step type that supports multi-round clarification. Place them anywhere in the flow — including multiple times. The LLM extracts answers for downstream steps and can ask follow-up questions.
|
|
634
296
|
|
|
635
297
|
```ruby
|
|
636
|
-
|
|
637
|
-
start :
|
|
298
|
+
definition = FlowEngine.define do
|
|
299
|
+
start :personal_intake
|
|
300
|
+
|
|
301
|
+
# AI intake: collects info for the steps that follow
|
|
302
|
+
step :personal_intake do
|
|
303
|
+
type :ai_intake
|
|
304
|
+
question "Tell us about yourself and your tax situation"
|
|
305
|
+
max_clarifications 2 # up to 2 follow-up rounds (0 = one-shot)
|
|
306
|
+
transition to: :filing_status
|
|
307
|
+
end
|
|
638
308
|
|
|
639
309
|
step :filing_status do
|
|
640
310
|
type :single_select
|
|
641
|
-
question "What is your filing status
|
|
642
|
-
options %w[single
|
|
311
|
+
question "What is your filing status?"
|
|
312
|
+
options %w[single married_joint married_separate head_of_household]
|
|
643
313
|
transition to: :dependents
|
|
644
314
|
end
|
|
645
315
|
|
|
646
316
|
step :dependents do
|
|
647
317
|
type :number
|
|
648
|
-
question "How many dependents do you
|
|
318
|
+
question "How many dependents do you claim?"
|
|
649
319
|
transition to: :income_types
|
|
650
320
|
end
|
|
651
321
|
|
|
652
322
|
step :income_types do
|
|
653
323
|
type :multi_select
|
|
654
|
-
question "Select all income types that apply
|
|
655
|
-
options %w[W2 1099 Business Investment Rental
|
|
656
|
-
transition to: :business_count, if_rule: contains(:income_types, "Business")
|
|
657
|
-
transition to: :investment_details, if_rule: contains(:income_types, "Investment")
|
|
658
|
-
transition to: :rental_details, if_rule: contains(:income_types, "Rental")
|
|
659
|
-
transition to: :state_filing
|
|
324
|
+
question "Select all income types that apply"
|
|
325
|
+
options %w[W2 1099 Business Investment Rental]
|
|
660
326
|
end
|
|
327
|
+
end
|
|
328
|
+
```
|
|
661
329
|
|
|
662
|
-
|
|
663
|
-
type :number
|
|
664
|
-
question "How many total businesses do you own or are a partner in?"
|
|
665
|
-
transition to: :complex_business_info, if_rule: greater_than(:business_count, 2)
|
|
666
|
-
transition to: :business_details
|
|
667
|
-
end
|
|
330
|
+
#### Running an AI Intake
|
|
668
331
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
transition to: :business_details
|
|
673
|
-
end
|
|
332
|
+
```ruby
|
|
333
|
+
engine = FlowEngine::Engine.new(definition)
|
|
334
|
+
client = FlowEngine::LLM.auto_client
|
|
674
335
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
336
|
+
# Round 1: initial submission
|
|
337
|
+
result = engine.submit_ai_intake(
|
|
338
|
+
"I'm married filing jointly, 2 kids, W2 and business income",
|
|
339
|
+
llm_client: client
|
|
340
|
+
)
|
|
341
|
+
result.done? # => false (LLM wants to ask more)
|
|
342
|
+
result.follow_up # => "Which state do you primarily reside in?"
|
|
343
|
+
result.round # => 1
|
|
344
|
+
result.pending_steps # => [:income_types] (steps still unanswered)
|
|
345
|
+
engine.answers # => { filing_status: "married_joint", dependents: 2 }
|
|
346
|
+
|
|
347
|
+
# Round 2: respond to follow-up
|
|
348
|
+
result = engine.submit_clarification(
|
|
349
|
+
"California. W2 from my job and a small LLC.",
|
|
350
|
+
llm_client: client
|
|
351
|
+
)
|
|
352
|
+
result.done? # => true (no more follow-ups or max reached)
|
|
353
|
+
engine.answers[:income_types] # => ["W2", "Business"]
|
|
683
354
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
options %w[Stocks Bonds Crypto RealEstate MutualFunds]
|
|
688
|
-
transition to: :crypto_details, if_rule: contains(:investment_details, "Crypto")
|
|
689
|
-
transition to: :rental_details, if_rule: contains(:income_types, "Rental")
|
|
690
|
-
transition to: :state_filing
|
|
691
|
-
end
|
|
355
|
+
# Engine auto-advances past all pre-filled steps
|
|
356
|
+
engine.finished? # => true
|
|
357
|
+
```
|
|
692
358
|
|
|
693
|
-
|
|
694
|
-
type :text
|
|
695
|
-
question "Please describe your cryptocurrency transactions (exchanges used, approximate number of transactions)."
|
|
696
|
-
transition to: :rental_details, if_rule: contains(:income_types, "Rental")
|
|
697
|
-
transition to: :state_filing
|
|
698
|
-
end
|
|
359
|
+
#### ClarificationResult
|
|
699
360
|
|
|
700
|
-
|
|
701
|
-
type :number_matrix
|
|
702
|
-
question "Provide details about your rental properties."
|
|
703
|
-
fields %w[Residential Commercial Vacation]
|
|
704
|
-
transition to: :state_filing
|
|
705
|
-
end
|
|
361
|
+
Each `submit_ai_intake` / `submit_clarification` call returns a `ClarificationResult`:
|
|
706
362
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
363
|
+
| Attribute | Type | Description |
|
|
364
|
+
|-----------|------|-------------|
|
|
365
|
+
| `answered` | `Hash` | Step answers filled this round |
|
|
366
|
+
| `pending_steps` | `Array<Symbol>` | Steps still unanswered |
|
|
367
|
+
| `follow_up` | `String?` | LLM's follow-up question, or `nil` if done |
|
|
368
|
+
| `round` | `Integer` | Current round number (1-based) |
|
|
369
|
+
| `done?` | `Boolean` | True when `follow_up` is nil |
|
|
713
370
|
|
|
714
|
-
|
|
715
|
-
type :single_select
|
|
716
|
-
question "Do you have any foreign financial accounts (bank accounts, securities, or financial assets)?"
|
|
717
|
-
options %w[yes no]
|
|
718
|
-
transition to: :foreign_account_details, if_rule: equals(:foreign_accounts, "yes")
|
|
719
|
-
transition to: :deduction_types
|
|
720
|
-
end
|
|
371
|
+
When `max_clarifications` is reached, the intake finalizes even if the LLM wanted to ask more. Unanswered steps are presented normally to the user.
|
|
721
372
|
|
|
722
|
-
|
|
723
|
-
type :number
|
|
724
|
-
question "How many foreign accounts do you have?"
|
|
725
|
-
transition to: :deduction_types
|
|
726
|
-
end
|
|
373
|
+
#### Multiple AI Intakes in One Flow
|
|
727
374
|
|
|
728
|
-
|
|
729
|
-
type :multi_select
|
|
730
|
-
question "Which additional deductions apply to you?"
|
|
731
|
-
options %w[Medical Charitable Education Mortgage None]
|
|
732
|
-
transition to: :charitable_amount, if_rule: contains(:deduction_types, "Charitable")
|
|
733
|
-
transition to: :contact_info
|
|
734
|
-
end
|
|
375
|
+
Place `:ai_intake` steps at multiple points to break up the conversation:
|
|
735
376
|
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
377
|
+
```ruby
|
|
378
|
+
definition = FlowEngine.define do
|
|
379
|
+
start :personal_intake
|
|
380
|
+
|
|
381
|
+
step :personal_intake do
|
|
382
|
+
type :ai_intake
|
|
383
|
+
question "Tell us about yourself and your tax situation"
|
|
384
|
+
max_clarifications 2
|
|
385
|
+
transition to: :filing_status
|
|
741
386
|
end
|
|
742
387
|
|
|
743
|
-
step :
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
transition to: :contact_info
|
|
388
|
+
step :filing_status do
|
|
389
|
+
# ... personal info steps ...
|
|
390
|
+
transition to: :financial_intake
|
|
747
391
|
end
|
|
748
392
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
393
|
+
# Second AI intake mid-flow
|
|
394
|
+
step :financial_intake do
|
|
395
|
+
type :ai_intake
|
|
396
|
+
question "Describe your financial situation: accounts, debts, investments"
|
|
397
|
+
max_clarifications 3
|
|
398
|
+
transition to: :annual_income
|
|
753
399
|
end
|
|
754
400
|
|
|
755
|
-
step :
|
|
756
|
-
|
|
757
|
-
question "Thank you! Please review your information. Type 'confirm' to submit."
|
|
401
|
+
step :annual_income do
|
|
402
|
+
# ... financial steps ...
|
|
758
403
|
end
|
|
759
404
|
end
|
|
760
405
|
```
|
|
761
406
|
|
|
762
|
-
|
|
407
|
+
Each `:ai_intake` step maintains its own conversation history and round counter. State is fully serializable for persistence between requests.
|
|
763
408
|
|
|
764
|
-
|
|
409
|
+
### Custom LLM Adapters
|
|
765
410
|
|
|
766
411
|
```ruby
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
engine.answer(4)
|
|
773
|
-
engine.answer("EIN: 12-3456789. Entities: Alpha LLC, Beta SCorp, Gamma LLC, Delta CCorp")
|
|
774
|
-
engine.answer({ "RealEstate" => 1, "SCorp" => 1, "CCorp" => 1, "Trust" => 0, "LLC" => 2 })
|
|
775
|
-
engine.answer(%w[Stocks Bonds Crypto RealEstate])
|
|
776
|
-
engine.answer("Coinbase and Kraken, approximately 150 transactions in 2025")
|
|
777
|
-
engine.answer({ "Residential" => 2, "Commercial" => 1, "Vacation" => 0 })
|
|
778
|
-
engine.answer(%w[California NewYork])
|
|
779
|
-
engine.answer("yes")
|
|
780
|
-
engine.answer(3)
|
|
781
|
-
engine.answer(%w[Medical Charitable Education Mortgage])
|
|
782
|
-
engine.answer(12_000)
|
|
783
|
-
engine.answer("Red Cross: $5,000; Habitat for Humanity: $4,000; Local Food Bank: $3,000")
|
|
784
|
-
engine.answer("Jane Smith, jane.smith@example.com, 555-123-4567")
|
|
785
|
-
engine.answer("confirm")
|
|
786
|
-
```
|
|
412
|
+
class MyAdapter < FlowEngine::LLM::Adapter
|
|
413
|
+
def initialize(api_key:)
|
|
414
|
+
super()
|
|
415
|
+
@api_key = api_key
|
|
416
|
+
end
|
|
787
417
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
"filing_status": "married_filing_jointly",
|
|
793
|
-
"dependents": 3,
|
|
794
|
-
"income_types": ["W2", "1099", "Business", "Investment", "Rental", "Retirement"],
|
|
795
|
-
"business_count": 4,
|
|
796
|
-
"complex_business_info": "EIN: 12-3456789. Entities: Alpha LLC, Beta SCorp, Gamma LLC, Delta CCorp",
|
|
797
|
-
"business_details": {
|
|
798
|
-
"RealEstate": 1,
|
|
799
|
-
"SCorp": 1,
|
|
800
|
-
"CCorp": 1,
|
|
801
|
-
"Trust": 0,
|
|
802
|
-
"LLC": 2
|
|
803
|
-
},
|
|
804
|
-
"investment_details": ["Stocks", "Bonds", "Crypto", "RealEstate"],
|
|
805
|
-
"crypto_details": "Coinbase and Kraken, approximately 150 transactions in 2025",
|
|
806
|
-
"rental_details": {
|
|
807
|
-
"Residential": 2,
|
|
808
|
-
"Commercial": 1,
|
|
809
|
-
"Vacation": 0
|
|
810
|
-
},
|
|
811
|
-
"state_filing": ["California", "NewYork"],
|
|
812
|
-
"foreign_accounts": "yes",
|
|
813
|
-
"foreign_account_details": 3,
|
|
814
|
-
"deduction_types": ["Medical", "Charitable", "Education", "Mortgage"],
|
|
815
|
-
"charitable_amount": 12000,
|
|
816
|
-
"charitable_documentation": "Red Cross: $5,000; Habitat for Humanity: $4,000; Local Food Bank: $3,000",
|
|
817
|
-
"contact_info": "Jane Smith, jane.smith@example.com, 555-123-4567",
|
|
818
|
-
"review": "confirm"
|
|
819
|
-
}
|
|
418
|
+
def chat(system_prompt:, user_prompt:, model:)
|
|
419
|
+
# Must return response text (expected to be JSON)
|
|
420
|
+
end
|
|
421
|
+
end
|
|
820
422
|
```
|
|
821
423
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
```
|
|
825
|
-
filing_status -> dependents -> income_types -> business_count ->
|
|
826
|
-
complex_business_info -> business_details -> investment_details ->
|
|
827
|
-
crypto_details -> rental_details -> state_filing -> foreign_accounts ->
|
|
828
|
-
foreign_account_details -> deduction_types -> charitable_amount ->
|
|
829
|
-
charitable_documentation -> contact_info -> review
|
|
830
|
-
```
|
|
424
|
+
---
|
|
831
425
|
|
|
832
|
-
|
|
426
|
+
## State Persistence
|
|
833
427
|
|
|
834
|
-
|
|
428
|
+
The engine's full state — including AI intake conversation history — can be serialized and restored:
|
|
835
429
|
|
|
836
430
|
```ruby
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
engine.answer(["Texas"])
|
|
843
|
-
engine.answer("no")
|
|
844
|
-
engine.answer(["None"])
|
|
845
|
-
engine.answer("John Doe, john.doe@example.com, 555-987-6543")
|
|
846
|
-
engine.answer("confirm")
|
|
847
|
-
```
|
|
848
|
-
|
|
849
|
-
**Collected data:**
|
|
850
|
-
|
|
851
|
-
```json
|
|
852
|
-
{
|
|
853
|
-
"filing_status": "single",
|
|
854
|
-
"dependents": 0,
|
|
855
|
-
"income_types": ["W2"],
|
|
856
|
-
"state_filing": ["Texas"],
|
|
857
|
-
"foreign_accounts": "no",
|
|
858
|
-
"deduction_types": ["None"],
|
|
859
|
-
"contact_info": "John Doe, john.doe@example.com, 555-987-6543",
|
|
860
|
-
"review": "confirm"
|
|
861
|
-
}
|
|
862
|
-
```
|
|
863
|
-
|
|
864
|
-
**Path taken** (8 steps — skipped 9 steps):
|
|
431
|
+
state = engine.to_state
|
|
432
|
+
# => { current_step_id: :income_types, answers: { ... }, history: [...],
|
|
433
|
+
# introduction_text: "...", clarification_round: 1,
|
|
434
|
+
# conversation_history: [{role: :user, text: "..."}, ...],
|
|
435
|
+
# active_intake_step_id: :personal_intake }
|
|
865
436
|
|
|
866
|
-
|
|
867
|
-
filing_status -> dependents -> income_types -> state_filing ->
|
|
868
|
-
foreign_accounts -> deduction_types -> contact_info -> review
|
|
437
|
+
restored = FlowEngine::Engine.from_state(definition, state)
|
|
869
438
|
```
|
|
870
439
|
|
|
871
|
-
|
|
440
|
+
Round-trips through JSON (string keys) are handled automatically.
|
|
872
441
|
|
|
873
|
-
|
|
442
|
+
## Validation
|
|
874
443
|
|
|
875
|
-
|
|
444
|
+
Pluggable validators via the adapter pattern. Ships with `NullAdapter` (always passes):
|
|
876
445
|
|
|
877
446
|
```ruby
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
engine.answer(%w[Stocks Bonds MutualFunds]) # no Crypto, no crypto_details
|
|
886
|
-
engine.answer(%w[California Illinois])
|
|
887
|
-
engine.answer("no") # no foreign accounts
|
|
888
|
-
engine.answer(%w[Charitable Mortgage])
|
|
889
|
-
engine.answer(3000) # <= 5000, no documentation needed
|
|
890
|
-
engine.answer("Alice Johnson, alice.j@example.com, 555-555-0100")
|
|
891
|
-
engine.answer("confirm")
|
|
892
|
-
```
|
|
447
|
+
class MyValidator < FlowEngine::Validation::Adapter
|
|
448
|
+
def validate(node, input)
|
|
449
|
+
errors = []
|
|
450
|
+
errors << "must be a number" if node.type == :number && !input.is_a?(Numeric)
|
|
451
|
+
FlowEngine::Validation::Result.new(valid: errors.empty?, errors: errors)
|
|
452
|
+
end
|
|
453
|
+
end
|
|
893
454
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
```json
|
|
897
|
-
{
|
|
898
|
-
"filing_status": "married_filing_separately",
|
|
899
|
-
"dependents": 1,
|
|
900
|
-
"income_types": ["W2", "Business", "Investment"],
|
|
901
|
-
"business_count": 2,
|
|
902
|
-
"business_details": {
|
|
903
|
-
"RealEstate": 0,
|
|
904
|
-
"SCorp": 1,
|
|
905
|
-
"CCorp": 0,
|
|
906
|
-
"Trust": 0,
|
|
907
|
-
"LLC": 1
|
|
908
|
-
},
|
|
909
|
-
"investment_details": ["Stocks", "Bonds", "MutualFunds"],
|
|
910
|
-
"state_filing": ["California", "Illinois"],
|
|
911
|
-
"foreign_accounts": "no",
|
|
912
|
-
"deduction_types": ["Charitable", "Mortgage"],
|
|
913
|
-
"charitable_amount": 3000,
|
|
914
|
-
"contact_info": "Alice Johnson, alice.j@example.com, 555-555-0100",
|
|
915
|
-
"review": "confirm"
|
|
916
|
-
}
|
|
455
|
+
engine = FlowEngine::Engine.new(definition, validator: MyValidator.new)
|
|
917
456
|
```
|
|
918
457
|
|
|
919
|
-
|
|
458
|
+
## Mermaid Diagram Export
|
|
920
459
|
|
|
460
|
+
```ruby
|
|
461
|
+
exporter = FlowEngine::Graph::MermaidExporter.new(definition)
|
|
462
|
+
puts exporter.export
|
|
921
463
|
```
|
|
922
|
-
filing_status -> dependents -> income_types -> business_count ->
|
|
923
|
-
business_details -> investment_details -> state_filing ->
|
|
924
|
-
foreign_accounts -> deduction_types -> charitable_amount ->
|
|
925
|
-
contact_info -> review
|
|
926
|
-
```
|
|
927
|
-
|
|
928
|
-
### Scenario 4: Rental + Foreign Accounts (10 steps visited)
|
|
929
464
|
|
|
930
|
-
|
|
465
|
+
## Architecture
|
|
931
466
|
|
|
932
|
-
|
|
933
|
-
engine = FlowEngine::Engine.new(tax_intake)
|
|
934
|
-
|
|
935
|
-
engine.answer("head_of_household")
|
|
936
|
-
engine.answer(2)
|
|
937
|
-
engine.answer(%w[1099 Rental])
|
|
938
|
-
engine.answer({ "Residential" => 1, "Commercial" => 0, "Vacation" => 1 })
|
|
939
|
-
engine.answer(["Florida"])
|
|
940
|
-
engine.answer("yes")
|
|
941
|
-
engine.answer(1)
|
|
942
|
-
engine.answer(%w[Medical Education])
|
|
943
|
-
engine.answer("Bob Lee, bob@example.com, 555-000-1111")
|
|
944
|
-
engine.answer("confirm")
|
|
945
|
-
```
|
|
467
|
+

|
|
946
468
|
|
|
947
|
-
**
|
|
948
|
-
|
|
949
|
-
```json
|
|
950
|
-
{
|
|
951
|
-
"filing_status": "head_of_household",
|
|
952
|
-
"dependents": 2,
|
|
953
|
-
"income_types": ["1099", "Rental"],
|
|
954
|
-
"rental_details": {
|
|
955
|
-
"Residential": 1,
|
|
956
|
-
"Commercial": 0,
|
|
957
|
-
"Vacation": 1
|
|
958
|
-
},
|
|
959
|
-
"state_filing": ["Florida"],
|
|
960
|
-
"foreign_accounts": "yes",
|
|
961
|
-
"foreign_account_details": 1,
|
|
962
|
-
"deduction_types": ["Medical", "Education"],
|
|
963
|
-
"contact_info": "Bob Lee, bob@example.com, 555-000-1111",
|
|
964
|
-
"review": "confirm"
|
|
965
|
-
}
|
|
966
|
-
```
|
|
469
|
+
The core has **zero UI logic**, **zero DB logic**, and **zero framework dependencies**. Adapters translate input/output, persist state, and render UI.
|
|
967
470
|
|
|
968
|
-
|
|
471
|
+
| Component | Responsibility |
|
|
472
|
+
|-----------|---------------|
|
|
473
|
+
| `FlowEngine.define` | DSL entry point; returns a frozen `Definition` |
|
|
474
|
+
| `Definition` | Immutable flow graph (nodes + start step + introduction) |
|
|
475
|
+
| `Node` | Single step: type, question, options/fields, transitions, visibility |
|
|
476
|
+
| `Transition` | Directed edge with optional rule condition |
|
|
477
|
+
| `Rules::*` | AST nodes for conditional logic |
|
|
478
|
+
| `Evaluator` | Evaluates rules against the answer store |
|
|
479
|
+
| `Engine` | Stateful runtime: current step, answers, history, AI intake state |
|
|
480
|
+
| `ClarificationResult` | Immutable result from an AI intake round |
|
|
481
|
+
| `Introduction` | Immutable config for one-shot introduction (label, placeholder, maxlength) |
|
|
482
|
+
| `Validation::Adapter` | Interface for pluggable validation |
|
|
483
|
+
| `LLM::Client` | High-level: builds prompt, calls adapter, parses JSON |
|
|
484
|
+
| `LLM::Adapter` | Abstract LLM API interface (Anthropic, OpenAI, Gemini implementations) |
|
|
485
|
+
| `LLM::SensitiveDataFilter` | Rejects text containing SSN, ITIN, EIN patterns |
|
|
486
|
+
| `Graph::MermaidExporter` | Exports flow as a Mermaid diagram |
|
|
969
487
|
|
|
970
|
-
|
|
971
|
-
filing_status -> dependents -> income_types -> rental_details ->
|
|
972
|
-
state_filing -> foreign_accounts -> foreign_account_details ->
|
|
973
|
-
deduction_types -> contact_info -> review
|
|
974
|
-
```
|
|
488
|
+
## Beyond the DSL: LLM-Driven Tools
|
|
975
489
|
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
The shape of the collected data depends entirely on which path the user takes through the graph. Here's a side-by-side of which keys appear in each scenario:
|
|
979
|
-
|
|
980
|
-
| Answer Key | Max (17) | Min (8) | Medium (12) | Rental (10) |
|
|
981
|
-
|---|:---:|:---:|:---:|:---:|
|
|
982
|
-
| `filing_status` | x | x | x | x |
|
|
983
|
-
| `dependents` | x | x | x | x |
|
|
984
|
-
| `income_types` | x | x | x | x |
|
|
985
|
-
| `business_count` | x | | x | |
|
|
986
|
-
| `complex_business_info` | x | | | |
|
|
987
|
-
| `business_details` | x | | x | |
|
|
988
|
-
| `investment_details` | x | | x | |
|
|
989
|
-
| `crypto_details` | x | | | |
|
|
990
|
-
| `rental_details` | x | | | x |
|
|
991
|
-
| `state_filing` | x | x | x | x |
|
|
992
|
-
| `foreign_accounts` | x | x | x | x |
|
|
993
|
-
| `foreign_account_details` | x | | | x |
|
|
994
|
-
| `deduction_types` | x | x | x | x |
|
|
995
|
-
| `charitable_amount` | x | | x | |
|
|
996
|
-
| `charitable_documentation` | x | | | |
|
|
997
|
-
| `contact_info` | x | x | x | x |
|
|
998
|
-
| `review` | x | x | x | x |
|
|
999
|
-
|
|
1000
|
-
## Composing Complex Rules
|
|
1001
|
-
|
|
1002
|
-
### Example: Multi-Condition Branching
|
|
490
|
+
The declarative DSL is the primary way to define flows, but it is not the only way to use FlowEngine. The gem's LLM integration layer — adapters, client, and sensitive data filter — can power conversational tools that don't use the DSL at all.
|
|
1003
491
|
|
|
1004
|
-
|
|
1005
|
-
definition = FlowEngine.define do
|
|
1006
|
-
start :status
|
|
492
|
+
### `bin/tax-estimate` (Experimental)
|
|
1007
493
|
|
|
1008
|
-
|
|
1009
|
-
type :single_select
|
|
1010
|
-
question "Filing status?"
|
|
1011
|
-
options %w[single married]
|
|
1012
|
-
transition to: :dependents
|
|
1013
|
-
end
|
|
494
|
+
An interactive CLI tool that estimates tax return complexity and preparation cost entirely through LLM-driven conversation. Instead of defining steps in the DSL, the LLM decides what to ask, generates Ruby code for rich terminal prompts (`TTY::Prompt`), and produces a final YAML estimate when it has enough information.
|
|
1014
495
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
transition to: :income
|
|
1019
|
-
end
|
|
496
|
+
```bash
|
|
497
|
+
bin/tax-estimate
|
|
498
|
+
```
|
|
1020
499
|
|
|
1021
|
-
|
|
1022
|
-
type :multi_select
|
|
1023
|
-
question "Income types?"
|
|
1024
|
-
options %w[W2 Business]
|
|
1025
|
-
|
|
1026
|
-
# All three conditions must be true
|
|
1027
|
-
transition to: :special_review,
|
|
1028
|
-
if_rule: all(
|
|
1029
|
-
equals(:status, "married"),
|
|
1030
|
-
contains(:income, "Business"),
|
|
1031
|
-
not_empty(:income)
|
|
1032
|
-
)
|
|
1033
|
-
|
|
1034
|
-
# At least one condition must be true
|
|
1035
|
-
transition to: :alt_review,
|
|
1036
|
-
if_rule: any(
|
|
1037
|
-
less_than(:dependents, 2),
|
|
1038
|
-
contains(:income, "W2")
|
|
1039
|
-
)
|
|
1040
|
-
|
|
1041
|
-
# Unconditional fallback
|
|
1042
|
-
transition to: :default_review
|
|
1043
|
-
end
|
|
500
|
+
How it works:
|
|
1044
501
|
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
502
|
+
1. A system prompt describes tax complexity factors (filing status, income sources, deductions, special situations)
|
|
503
|
+
2. The LLM returns JSON containing a natural-language question and a `question_eval` field with Ruby code that uses `TTY::Prompt` for multi-select, radio buttons, text input, etc.
|
|
504
|
+
3. The generated code is evaluated to collect the user's answer, which is appended to the chat history
|
|
505
|
+
4. The loop continues until the LLM produces a `final_estimate` — a YAML object with complexity score, cost range, and explanation
|
|
1049
506
|
|
|
1050
|
-
|
|
1051
|
-
type :text
|
|
1052
|
-
question "Alternative review path."
|
|
1053
|
-
end
|
|
507
|
+
This approach trades the safety and predictability of a predefined flow graph for maximum flexibility — the LLM adapts its questions based on prior answers without any step definitions.
|
|
1054
508
|
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
509
|
+
```ruby
|
|
510
|
+
# The only FlowEngine dependency is the LLM client
|
|
511
|
+
client = FlowEngine::LLM.auto_client
|
|
512
|
+
response = client.adapter.chat(
|
|
513
|
+
system_prompt: tax_system_prompt,
|
|
514
|
+
user_prompt: "Chat history: #{chat_history.to_json}",
|
|
515
|
+
model: client.model
|
|
516
|
+
)
|
|
1060
517
|
```
|
|
1061
518
|
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
| Inputs | Path | Why |
|
|
1065
|
-
|--------|------|-----|
|
|
1066
|
-
| married, 0 deps, `[W2, Business]` | `:special_review` | `all()` satisfied: married + Business + not empty |
|
|
1067
|
-
| single, 0 deps, `[W2]` | `:alt_review` | `all()` fails (not married); `any()` passes (has W2) |
|
|
1068
|
-
| single, 1 dep, `[Business]` | `:alt_review` | `all()` fails; `any()` passes (deps < 2) |
|
|
1069
|
-
| single, 3 deps, `[Business]` | `:default_review` | `all()` fails; `any()` fails (deps not < 2, no W2) |
|
|
1070
|
-
|
|
1071
|
-
## Mermaid Diagram of the Tax Intake Flow
|
|
1072
|
-
|
|
1073
|
-

|
|
1074
|
-
|
|
1075
|
-
<details>
|
|
1076
|
-
<summary>Expand to see Mermaid source</summary>
|
|
1077
|
-
|
|
1078
|
-
```mermaid
|
|
1079
|
-
flowchart BT
|
|
1080
|
-
filing_status["What is your filing status for 2025?"] --> dependents["How many dependents do you have?"]
|
|
1081
|
-
dependents --> income_types["Select all income types that apply to you in 2025."]
|
|
1082
|
-
income_types -- Business in income_types --> business_count["How many total businesses do you own or are a part..."]
|
|
1083
|
-
income_types -- Investment in income_types --> investment_details["What types of investments do you hold?"]
|
|
1084
|
-
income_types -- Rental in income_types --> rental_details["Provide details about your rental properties."]
|
|
1085
|
-
income_types --> state_filing["Which states do you need to file in?"]
|
|
1086
|
-
business_count -- business_count > 2 --> complex_business_info["With more than 2 businesses, please provide your p..."]
|
|
1087
|
-
business_count --> business_details["How many of each business type do you own?"]
|
|
1088
|
-
complex_business_info --> business_details
|
|
1089
|
-
business_details -- Investment in income_types --> investment_details
|
|
1090
|
-
business_details -- Rental in income_types --> rental_details
|
|
1091
|
-
business_details --> state_filing
|
|
1092
|
-
investment_details -- Crypto in investment_details --> crypto_details["Please describe your cryptocurrency transactions (..."]
|
|
1093
|
-
investment_details -- Rental in income_types --> rental_details
|
|
1094
|
-
investment_details --> state_filing
|
|
1095
|
-
crypto_details -- Rental in income_types --> rental_details
|
|
1096
|
-
crypto_details --> state_filing
|
|
1097
|
-
rental_details --> state_filing
|
|
1098
|
-
state_filing --> foreign_accounts["Do you have any foreign financial accounts (bank a..."]
|
|
1099
|
-
foreign_accounts -- "foreign_accounts == yes" --> foreign_account_details["How many foreign accounts do you have?"]
|
|
1100
|
-
foreign_accounts --> deduction_types["Which additional deductions apply to you?"]
|
|
1101
|
-
foreign_account_details --> deduction_types
|
|
1102
|
-
deduction_types -- Charitable in deduction_types --> charitable_amount["What is your total estimated charitable contributi..."]
|
|
1103
|
-
deduction_types --> contact_info["Please provide your contact information (name, ema..."]
|
|
1104
|
-
charitable_amount -- charitable_amount > 5000 --> charitable_documentation["For charitable contributions over $5,000, please l..."]
|
|
1105
|
-
charitable_amount --> contact_info
|
|
1106
|
-
charitable_documentation --> contact_info
|
|
1107
|
-
contact_info --> review@{ label: "Thank you! Please review your information. Type 'c..." }
|
|
1108
|
-
|
|
1109
|
-
review@{ shape: rect}
|
|
1110
|
-
```
|
|
519
|
+
Here is an example of the final YAML estimate for a simple single-filer return:
|
|
1111
520
|
|
|
1112
|
-
|
|
521
|
+
```yaml
|
|
522
|
+
---
|
|
523
|
+
client_profile:
|
|
524
|
+
filing_status: Single
|
|
525
|
+
dependents: 0
|
|
526
|
+
states_required: 1
|
|
527
|
+
|
|
528
|
+
income_sources:
|
|
529
|
+
w2_employers: 1
|
|
530
|
+
self_employment: false
|
|
531
|
+
rental_income: false
|
|
532
|
+
investment_income: false
|
|
533
|
+
retirement_distributions: false
|
|
534
|
+
foreign_income: false
|
|
535
|
+
|
|
536
|
+
deductions_and_credits:
|
|
537
|
+
itemized_deductions: false
|
|
538
|
+
business_expenses: false
|
|
539
|
+
education_credits: false
|
|
540
|
+
energy_credits: false
|
|
541
|
+
child_dependent_care_credits: false
|
|
542
|
+
|
|
543
|
+
special_situations:
|
|
544
|
+
stock_options: false
|
|
545
|
+
cryptocurrency: false
|
|
546
|
+
foreign_accounts: false
|
|
547
|
+
prior_year_carryforwards: false
|
|
548
|
+
amended_return: false
|
|
549
|
+
irs_notices: false
|
|
550
|
+
|
|
551
|
+
complexity_assessment:
|
|
552
|
+
score: 1
|
|
553
|
+
out_of: 10
|
|
554
|
+
rationale: >
|
|
555
|
+
This is a straightforward return. Single filer, no dependents,
|
|
556
|
+
one W-2 from a single employer, one state return, no itemized
|
|
557
|
+
deductions, no special income sources, and no special situations.
|
|
558
|
+
|
|
559
|
+
cost_estimate:
|
|
560
|
+
low: $150
|
|
561
|
+
high: $300
|
|
562
|
+
average: $200
|
|
563
|
+
currency: USD
|
|
564
|
+
notes: >
|
|
565
|
+
Cost reflects a simple federal Form 1040 with a single W-2 and
|
|
566
|
+
one state return. Rush fees may apply if filing close to the
|
|
567
|
+
deadline.
|
|
568
|
+
|
|
569
|
+
estimated_time_to_complete: 1-2 hours
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
See `examples/` for more sample outputs including complex multi-income scenarios.
|
|
1113
573
|
|
|
1114
574
|
## Ecosystem
|
|
1115
575
|
|
|
1116
|
-
FlowEngine is the core of a three-gem architecture:
|
|
1117
|
-
|
|
1118
576
|
| Gem | Purpose |
|
|
1119
577
|
|-----|---------|
|
|
1120
|
-
| **`flowengine`** (this gem) | Core engine + LLM
|
|
1121
|
-
| **`flowengine-cli`** | Terminal wizard
|
|
578
|
+
| **`flowengine`** (this gem) | Core engine + LLM integration (depends on `ruby_llm`) |
|
|
579
|
+
| **`flowengine-cli`** | Terminal wizard via [TTY Toolkit](https://ttytoolkit.org/) + Dry::CLI |
|
|
1122
580
|
| **`flowengine-rails`** | Rails Engine with ActiveRecord persistence and web views |
|
|
1123
581
|
|
|
1124
582
|
## Development
|
|
1125
583
|
|
|
1126
584
|
```bash
|
|
1127
585
|
bundle install
|
|
1128
|
-
|
|
586
|
+
just test # RSpec + RuboCop
|
|
587
|
+
just lint # RuboCop only
|
|
588
|
+
just doc # Generate YARD docs
|
|
1129
589
|
```
|
|
1130
590
|
|
|
1131
591
|
## License
|
|
1132
592
|
|
|
1133
|
-
|
|
593
|
+
MIT License. See [LICENSE](https://opensource.org/licenses/MIT).
|