flowengine 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Brewfile +28 -0
- data/README.md +270 -896
- data/docs/PROJECT_STRUCTURE.md +64 -0
- data/docs/flowengine-processing.png +0 -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 +37 -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,381 @@ step :spouse_income do
|
|
|
434
127
|
end
|
|
435
128
|
```
|
|
436
129
|
|
|
437
|
-
The engine exposes this via `node.visible?(answers)`, which returns `true` when the rule is satisfied (or when no visibility rule is set).
|
|
438
|
-
|
|
439
130
|
## Rule System
|
|
440
131
|
|
|
441
|
-
Rules are **AST objects** —
|
|
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
|
-
definition.step(:nonexistent)
|
|
537
|
-
# => raises FlowEngine::Errors::UnknownStepError
|
|
538
|
-
|
|
539
|
-
# Invalid definition (start step doesn't exist)
|
|
540
|
-
FlowEngine.define do
|
|
541
|
-
start :missing
|
|
542
|
-
step :other do
|
|
543
|
-
type :text
|
|
544
|
-
question "Hello"
|
|
545
|
-
end
|
|
546
|
-
end
|
|
547
|
-
# => raises FlowEngine::Errors::DefinitionError
|
|
548
|
-
|
|
549
|
-
# Sensitive data in introduction
|
|
550
|
-
engine.submit_introduction("My SSN is 123-45-6789", llm_client: client)
|
|
551
|
-
# => raises FlowEngine::Errors::SensitiveDataError
|
|
552
|
-
|
|
553
|
-
# Introduction exceeds maxlength
|
|
554
|
-
engine.submit_introduction("A" * 3000, llm_client: client)
|
|
555
|
-
# => raises FlowEngine::Errors::ValidationError
|
|
556
|
-
|
|
557
|
-
# Missing API key or LLM response parsing failure
|
|
558
|
-
FlowEngine::LLM::Adapters::OpenAIAdapter.new # without OPENAI_API_KEY
|
|
559
|
-
# => raises FlowEngine::Errors::LLMError
|
|
194
|
+
engine.answer("extra") # AlreadyFinishedError (flow finished)
|
|
195
|
+
definition.step(:nonexistent) # UnknownStepError
|
|
196
|
+
engine.submit_introduction("SSN: 123-45-6789", llm_client:) # SensitiveDataError
|
|
197
|
+
engine.submit_introduction("A" * 3000, llm_client:) # ValidationError (maxlength)
|
|
198
|
+
engine.submit_ai_intake("hi", llm_client:) # EngineError (not on an ai_intake step)
|
|
560
199
|
```
|
|
561
200
|
|
|
562
|
-
|
|
201
|
+
---
|
|
563
202
|
|
|
564
|
-
|
|
203
|
+
## LLM Integration
|
|
565
204
|
|
|
566
|
-
|
|
567
|
-
# The adapter interface
|
|
568
|
-
class FlowEngine::Validation::Adapter
|
|
569
|
-
def validate(node, input)
|
|
570
|
-
# Must return a FlowEngine::Validation::Result
|
|
571
|
-
raise NotImplementedError
|
|
572
|
-
end
|
|
573
|
-
end
|
|
205
|
+
FlowEngine offers two ways to use LLMs for pre-filling answers from free-form text.
|
|
574
206
|
|
|
575
|
-
|
|
576
|
-
FlowEngine::Errors::Validation::Result.new(valid: true, errors: [])
|
|
577
|
-
FlowEngine::Errors::Validation::Result.new(valid: false, errors: ["must be a number"])
|
|
578
|
-
```
|
|
207
|
+
### LLM Adapters & Configuration
|
|
579
208
|
|
|
580
|
-
|
|
209
|
+
The gem ships with three adapters (all via [`ruby_llm`](https://github.com/crmne/ruby_llm)):
|
|
581
210
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
211
|
+
| Adapter | Env Variable |
|
|
212
|
+
|---------|-------------|
|
|
213
|
+
| `AnthropicAdapter` | `ANTHROPIC_API_KEY` |
|
|
214
|
+
| `OpenAIAdapter` | `OPENAI_API_KEY` |
|
|
215
|
+
| `GeminiAdapter` | `GEMINI_API_KEY` |
|
|
586
216
|
|
|
587
|
-
|
|
588
|
-
when :number
|
|
589
|
-
errors << "must be a number" unless input.is_a?(Numeric)
|
|
590
|
-
when :single_select
|
|
591
|
-
errors << "invalid option" unless node.options&.include?(input)
|
|
592
|
-
when :multi_select
|
|
593
|
-
unless input.is_a?(Array) && input.all? { |v| node.options&.include?(v) }
|
|
594
|
-
errors << "invalid options"
|
|
595
|
-
end
|
|
596
|
-
end
|
|
597
|
-
|
|
598
|
-
FlowEngine::Errors::Validation::Result.new(valid: errors.empty?, errors: errors)
|
|
599
|
-
end
|
|
600
|
-
end
|
|
217
|
+
The file [`resources/models.yml`](resources/models.yml) defines three model tiers per vendor (`top`, `default`, `fastest`). Override with `$FLOWENGINE_LLM_MODELS_PATH`.
|
|
601
218
|
|
|
602
|
-
|
|
603
|
-
|
|
219
|
+
```yaml
|
|
220
|
+
models:
|
|
221
|
+
vendors:
|
|
222
|
+
anthropic:
|
|
223
|
+
var: "ANTHROPIC_API_KEY"
|
|
224
|
+
top: "claude-opus-4-6"
|
|
225
|
+
default: "claude-sonnet-4-6"
|
|
226
|
+
fastest: "claude-haiku-4-5-20251001"
|
|
227
|
+
openai:
|
|
228
|
+
var: "OPENAI_API_KEY"
|
|
229
|
+
top: "gpt-5.4"
|
|
230
|
+
default: "gpt-5-mini"
|
|
231
|
+
fastest: "gpt-5-nano"
|
|
232
|
+
gemini:
|
|
233
|
+
var: "GEMINI_API_KEY"
|
|
234
|
+
top: "gemini-3.1-pro-preview"
|
|
235
|
+
default: "gemini-2.5-flash"
|
|
236
|
+
fastest: "gemini-2.5-flash-lite"
|
|
604
237
|
```
|
|
605
238
|
|
|
606
|
-
|
|
239
|
+
```ruby
|
|
240
|
+
# Auto-detect from environment (checks Anthropic > OpenAI > Gemini)
|
|
241
|
+
client = FlowEngine::LLM.auto_client
|
|
607
242
|
|
|
608
|
-
|
|
243
|
+
# Explicit provider / model override
|
|
244
|
+
client = FlowEngine::LLM.auto_client(anthropic_api_key: "sk-ant-...", model: "claude-haiku-4-5-20251001")
|
|
609
245
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
246
|
+
# Manual adapter
|
|
247
|
+
adapter = FlowEngine::LLM::Adapters::OpenAIAdapter.new(api_key: ENV["OPENAI_API_KEY"])
|
|
248
|
+
client = FlowEngine::LLM::Client.new(adapter: adapter, model: "gpt-5-mini")
|
|
613
249
|
```
|
|
614
250
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
flowchart TD
|
|
619
|
-
name["What is your name?"]
|
|
620
|
-
name --> age
|
|
621
|
-
age["How old are you?"]
|
|
622
|
-
age -->|"age > 20"| beverage
|
|
623
|
-
age --> thanks
|
|
624
|
-
beverage["Pick a drink."]
|
|
625
|
-
beverage --> thanks
|
|
626
|
-
thanks["Thank you for your responses!"]
|
|
627
|
-
```
|
|
251
|
+
### Sensitive Data Protection
|
|
252
|
+
|
|
253
|
+
Before any text reaches the LLM, `SensitiveDataFilter` scans for SSN, ITIN, EIN, and nine-consecutive-digit patterns. If detected, a `SensitiveDataError` is raised immediately — no LLM call is made.
|
|
628
254
|
|
|
629
|
-
|
|
255
|
+
---
|
|
630
256
|
|
|
631
|
-
|
|
257
|
+
### Option 1: Introduction (One-Shot Pre-Fill)
|
|
632
258
|
|
|
633
|
-
|
|
259
|
+
A flow-level free-form text field parsed by the LLM in a single pass. Good for simple intake where one prompt is enough.
|
|
634
260
|
|
|
635
261
|
```ruby
|
|
636
|
-
|
|
262
|
+
definition = FlowEngine.define do
|
|
637
263
|
start :filing_status
|
|
638
264
|
|
|
265
|
+
introduction label: "Tell us about your tax situation",
|
|
266
|
+
placeholder: "e.g. I am married, filing jointly, with 2 dependents...",
|
|
267
|
+
maxlength: 2000
|
|
268
|
+
|
|
639
269
|
step :filing_status do
|
|
640
270
|
type :single_select
|
|
641
|
-
question "What is your filing status
|
|
642
|
-
options %w[single married_filing_jointly
|
|
271
|
+
question "What is your filing status?"
|
|
272
|
+
options %w[single married_filing_jointly head_of_household]
|
|
643
273
|
transition to: :dependents
|
|
644
274
|
end
|
|
645
275
|
|
|
646
276
|
step :dependents do
|
|
647
277
|
type :number
|
|
648
|
-
question "How many dependents
|
|
649
|
-
transition to: :income_types
|
|
650
|
-
end
|
|
651
|
-
|
|
652
|
-
step :income_types do
|
|
653
|
-
type :multi_select
|
|
654
|
-
question "Select all income types that apply to you in 2025."
|
|
655
|
-
options %w[W2 1099 Business Investment Rental Retirement]
|
|
656
|
-
transition to: :business_count, if_rule: contains(:income_types, "Business")
|
|
657
|
-
transition to: :investment_details, if_rule: contains(:income_types, "Investment")
|
|
658
|
-
transition to: :rental_details, if_rule: contains(:income_types, "Rental")
|
|
659
|
-
transition to: :state_filing
|
|
660
|
-
end
|
|
661
|
-
|
|
662
|
-
step :business_count do
|
|
663
|
-
type :number
|
|
664
|
-
question "How many total businesses do you own or are a partner in?"
|
|
665
|
-
transition to: :complex_business_info, if_rule: greater_than(:business_count, 2)
|
|
666
|
-
transition to: :business_details
|
|
278
|
+
question "How many dependents?"
|
|
667
279
|
end
|
|
280
|
+
end
|
|
668
281
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
282
|
+
engine = FlowEngine::Engine.new(definition)
|
|
283
|
+
engine.submit_introduction(
|
|
284
|
+
"I am married filing jointly with 2 dependents",
|
|
285
|
+
llm_client: FlowEngine::LLM.auto_client
|
|
286
|
+
)
|
|
287
|
+
engine.answers # => { filing_status: "married_filing_jointly", dependents: 2 }
|
|
288
|
+
engine.finished? # => true
|
|
289
|
+
```
|
|
674
290
|
|
|
675
|
-
|
|
676
|
-
type :number_matrix
|
|
677
|
-
question "How many of each business type do you own?"
|
|
678
|
-
fields %w[RealEstate SCorp CCorp Trust LLC]
|
|
679
|
-
transition to: :investment_details, if_rule: contains(:income_types, "Investment")
|
|
680
|
-
transition to: :rental_details, if_rule: contains(:income_types, "Rental")
|
|
681
|
-
transition to: :state_filing
|
|
682
|
-
end
|
|
291
|
+
---
|
|
683
292
|
|
|
684
|
-
|
|
685
|
-
type :multi_select
|
|
686
|
-
question "What types of investments do you hold?"
|
|
687
|
-
options %w[Stocks Bonds Crypto RealEstate MutualFunds]
|
|
688
|
-
transition to: :crypto_details, if_rule: contains(:investment_details, "Crypto")
|
|
689
|
-
transition to: :rental_details, if_rule: contains(:income_types, "Rental")
|
|
690
|
-
transition to: :state_filing
|
|
691
|
-
end
|
|
293
|
+
### Option 2: AI Intake Steps (Multi-Round Conversational)
|
|
692
294
|
|
|
693
|
-
|
|
694
|
-
type :text
|
|
695
|
-
question "Please describe your cryptocurrency transactions (exchanges used, approximate number of transactions)."
|
|
696
|
-
transition to: :rental_details, if_rule: contains(:income_types, "Rental")
|
|
697
|
-
transition to: :state_filing
|
|
698
|
-
end
|
|
295
|
+
An `:ai_intake` step type that supports multi-round clarification. Place them anywhere in the flow — including multiple times. The LLM extracts answers for downstream steps and can ask follow-up questions.
|
|
699
296
|
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
fields %w[Residential Commercial Vacation]
|
|
704
|
-
transition to: :state_filing
|
|
705
|
-
end
|
|
297
|
+
```ruby
|
|
298
|
+
definition = FlowEngine.define do
|
|
299
|
+
start :personal_intake
|
|
706
300
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
301
|
+
# AI intake: collects info for the steps that follow
|
|
302
|
+
step :personal_intake do
|
|
303
|
+
type :ai_intake
|
|
304
|
+
question "Tell us about yourself and your tax situation"
|
|
305
|
+
max_clarifications 2 # up to 2 follow-up rounds (0 = one-shot)
|
|
306
|
+
transition to: :filing_status
|
|
712
307
|
end
|
|
713
308
|
|
|
714
|
-
step :
|
|
309
|
+
step :filing_status do
|
|
715
310
|
type :single_select
|
|
716
|
-
question "
|
|
717
|
-
options %w[
|
|
718
|
-
transition to: :
|
|
719
|
-
transition to: :deduction_types
|
|
311
|
+
question "What is your filing status?"
|
|
312
|
+
options %w[single married_joint married_separate head_of_household]
|
|
313
|
+
transition to: :dependents
|
|
720
314
|
end
|
|
721
315
|
|
|
722
|
-
step :
|
|
316
|
+
step :dependents do
|
|
723
317
|
type :number
|
|
724
|
-
question "How many
|
|
725
|
-
transition to: :
|
|
318
|
+
question "How many dependents do you claim?"
|
|
319
|
+
transition to: :income_types
|
|
726
320
|
end
|
|
727
321
|
|
|
728
|
-
step :
|
|
322
|
+
step :income_types do
|
|
729
323
|
type :multi_select
|
|
730
|
-
question "
|
|
731
|
-
options %w[
|
|
732
|
-
transition to: :charitable_amount, if_rule: contains(:deduction_types, "Charitable")
|
|
733
|
-
transition to: :contact_info
|
|
734
|
-
end
|
|
735
|
-
|
|
736
|
-
step :charitable_amount do
|
|
737
|
-
type :number
|
|
738
|
-
question "What is your total estimated charitable contribution amount for 2025?"
|
|
739
|
-
transition to: :charitable_documentation, if_rule: greater_than(:charitable_amount, 5000)
|
|
740
|
-
transition to: :contact_info
|
|
741
|
-
end
|
|
742
|
-
|
|
743
|
-
step :charitable_documentation do
|
|
744
|
-
type :text
|
|
745
|
-
question "For charitable contributions over $5,000, please list the organizations and amounts."
|
|
746
|
-
transition to: :contact_info
|
|
747
|
-
end
|
|
748
|
-
|
|
749
|
-
step :contact_info do
|
|
750
|
-
type :text
|
|
751
|
-
question "Please provide your contact information (name, email, phone)."
|
|
752
|
-
transition to: :review
|
|
753
|
-
end
|
|
754
|
-
|
|
755
|
-
step :review do
|
|
756
|
-
type :text
|
|
757
|
-
question "Thank you! Please review your information. Type 'confirm' to submit."
|
|
324
|
+
question "Select all income types that apply"
|
|
325
|
+
options %w[W2 1099 Business Investment Rental]
|
|
758
326
|
end
|
|
759
327
|
end
|
|
760
328
|
```
|
|
761
329
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
A married filer with all income types, 4 businesses, crypto, rentals, foreign accounts, and high charitable giving:
|
|
330
|
+
#### Running an AI Intake
|
|
765
331
|
|
|
766
332
|
```ruby
|
|
767
|
-
engine = FlowEngine::Engine.new(
|
|
768
|
-
|
|
769
|
-
engine.answer("married_filing_jointly")
|
|
770
|
-
engine.answer(3)
|
|
771
|
-
engine.answer(%w[W2 1099 Business Investment Rental Retirement])
|
|
772
|
-
engine.answer(4)
|
|
773
|
-
engine.answer("EIN: 12-3456789. Entities: Alpha LLC, Beta SCorp, Gamma LLC, Delta CCorp")
|
|
774
|
-
engine.answer({ "RealEstate" => 1, "SCorp" => 1, "CCorp" => 1, "Trust" => 0, "LLC" => 2 })
|
|
775
|
-
engine.answer(%w[Stocks Bonds Crypto RealEstate])
|
|
776
|
-
engine.answer("Coinbase and Kraken, approximately 150 transactions in 2025")
|
|
777
|
-
engine.answer({ "Residential" => 2, "Commercial" => 1, "Vacation" => 0 })
|
|
778
|
-
engine.answer(%w[California NewYork])
|
|
779
|
-
engine.answer("yes")
|
|
780
|
-
engine.answer(3)
|
|
781
|
-
engine.answer(%w[Medical Charitable Education Mortgage])
|
|
782
|
-
engine.answer(12_000)
|
|
783
|
-
engine.answer("Red Cross: $5,000; Habitat for Humanity: $4,000; Local Food Bank: $3,000")
|
|
784
|
-
engine.answer("Jane Smith, jane.smith@example.com, 555-123-4567")
|
|
785
|
-
engine.answer("confirm")
|
|
786
|
-
```
|
|
333
|
+
engine = FlowEngine::Engine.new(definition)
|
|
334
|
+
client = FlowEngine::LLM.auto_client
|
|
787
335
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
"
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
"Vacation": 0
|
|
810
|
-
},
|
|
811
|
-
"state_filing": ["California", "NewYork"],
|
|
812
|
-
"foreign_accounts": "yes",
|
|
813
|
-
"foreign_account_details": 3,
|
|
814
|
-
"deduction_types": ["Medical", "Charitable", "Education", "Mortgage"],
|
|
815
|
-
"charitable_amount": 12000,
|
|
816
|
-
"charitable_documentation": "Red Cross: $5,000; Habitat for Humanity: $4,000; Local Food Bank: $3,000",
|
|
817
|
-
"contact_info": "Jane Smith, jane.smith@example.com, 555-123-4567",
|
|
818
|
-
"review": "confirm"
|
|
819
|
-
}
|
|
336
|
+
# Round 1: initial submission
|
|
337
|
+
result = engine.submit_ai_intake(
|
|
338
|
+
"I'm married filing jointly, 2 kids, W2 and business income",
|
|
339
|
+
llm_client: client
|
|
340
|
+
)
|
|
341
|
+
result.done? # => false (LLM wants to ask more)
|
|
342
|
+
result.follow_up # => "Which state do you primarily reside in?"
|
|
343
|
+
result.round # => 1
|
|
344
|
+
result.pending_steps # => [:income_types] (steps still unanswered)
|
|
345
|
+
engine.answers # => { filing_status: "married_joint", dependents: 2 }
|
|
346
|
+
|
|
347
|
+
# Round 2: respond to follow-up
|
|
348
|
+
result = engine.submit_clarification(
|
|
349
|
+
"California. W2 from my job and a small LLC.",
|
|
350
|
+
llm_client: client
|
|
351
|
+
)
|
|
352
|
+
result.done? # => true (no more follow-ups or max reached)
|
|
353
|
+
engine.answers[:income_types] # => ["W2", "Business"]
|
|
354
|
+
|
|
355
|
+
# Engine auto-advances past all pre-filled steps
|
|
356
|
+
engine.finished? # => true
|
|
820
357
|
```
|
|
821
358
|
|
|
822
|
-
|
|
359
|
+
#### ClarificationResult
|
|
823
360
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
361
|
+
Each `submit_ai_intake` / `submit_clarification` call returns a `ClarificationResult`:
|
|
362
|
+
|
|
363
|
+
| Attribute | Type | Description |
|
|
364
|
+
|-----------|------|-------------|
|
|
365
|
+
| `answered` | `Hash` | Step answers filled this round |
|
|
366
|
+
| `pending_steps` | `Array<Symbol>` | Steps still unanswered |
|
|
367
|
+
| `follow_up` | `String?` | LLM's follow-up question, or `nil` if done |
|
|
368
|
+
| `round` | `Integer` | Current round number (1-based) |
|
|
369
|
+
| `done?` | `Boolean` | True when `follow_up` is nil |
|
|
370
|
+
|
|
371
|
+
When `max_clarifications` is reached, the intake finalizes even if the LLM wanted to ask more. Unanswered steps are presented normally to the user.
|
|
831
372
|
|
|
832
|
-
|
|
373
|
+
#### Multiple AI Intakes in One Flow
|
|
833
374
|
|
|
834
|
-
|
|
375
|
+
Place `:ai_intake` steps at multiple points to break up the conversation:
|
|
835
376
|
|
|
836
377
|
```ruby
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
engine.answer("single")
|
|
840
|
-
engine.answer(0)
|
|
841
|
-
engine.answer(["W2"])
|
|
842
|
-
engine.answer(["Texas"])
|
|
843
|
-
engine.answer("no")
|
|
844
|
-
engine.answer(["None"])
|
|
845
|
-
engine.answer("John Doe, john.doe@example.com, 555-987-6543")
|
|
846
|
-
engine.answer("confirm")
|
|
847
|
-
```
|
|
378
|
+
definition = FlowEngine.define do
|
|
379
|
+
start :personal_intake
|
|
848
380
|
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
"income_types": ["W2"],
|
|
856
|
-
"state_filing": ["Texas"],
|
|
857
|
-
"foreign_accounts": "no",
|
|
858
|
-
"deduction_types": ["None"],
|
|
859
|
-
"contact_info": "John Doe, john.doe@example.com, 555-987-6543",
|
|
860
|
-
"review": "confirm"
|
|
861
|
-
}
|
|
862
|
-
```
|
|
381
|
+
step :personal_intake do
|
|
382
|
+
type :ai_intake
|
|
383
|
+
question "Tell us about yourself and your tax situation"
|
|
384
|
+
max_clarifications 2
|
|
385
|
+
transition to: :filing_status
|
|
386
|
+
end
|
|
863
387
|
|
|
864
|
-
|
|
388
|
+
step :filing_status do
|
|
389
|
+
# ... personal info steps ...
|
|
390
|
+
transition to: :financial_intake
|
|
391
|
+
end
|
|
865
392
|
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
393
|
+
# Second AI intake mid-flow
|
|
394
|
+
step :financial_intake do
|
|
395
|
+
type :ai_intake
|
|
396
|
+
question "Describe your financial situation: accounts, debts, investments"
|
|
397
|
+
max_clarifications 3
|
|
398
|
+
transition to: :annual_income
|
|
399
|
+
end
|
|
870
400
|
|
|
871
|
-
|
|
401
|
+
step :annual_income do
|
|
402
|
+
# ... financial steps ...
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
```
|
|
872
406
|
|
|
873
|
-
|
|
407
|
+
Each `:ai_intake` step maintains its own conversation history and round counter. State is fully serializable for persistence between requests.
|
|
874
408
|
|
|
875
|
-
|
|
409
|
+
### Custom LLM Adapters
|
|
876
410
|
|
|
877
411
|
```ruby
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
engine.answer(2) # <= 2 businesses, no complex_business_info
|
|
884
|
-
engine.answer({ "RealEstate" => 0, "SCorp" => 1, "CCorp" => 0, "Trust" => 0, "LLC" => 1 })
|
|
885
|
-
engine.answer(%w[Stocks Bonds MutualFunds]) # no Crypto, no crypto_details
|
|
886
|
-
engine.answer(%w[California Illinois])
|
|
887
|
-
engine.answer("no") # no foreign accounts
|
|
888
|
-
engine.answer(%w[Charitable Mortgage])
|
|
889
|
-
engine.answer(3000) # <= 5000, no documentation needed
|
|
890
|
-
engine.answer("Alice Johnson, alice.j@example.com, 555-555-0100")
|
|
891
|
-
engine.answer("confirm")
|
|
892
|
-
```
|
|
412
|
+
class MyAdapter < FlowEngine::LLM::Adapter
|
|
413
|
+
def initialize(api_key:)
|
|
414
|
+
super()
|
|
415
|
+
@api_key = api_key
|
|
416
|
+
end
|
|
893
417
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
"filing_status": "married_filing_separately",
|
|
899
|
-
"dependents": 1,
|
|
900
|
-
"income_types": ["W2", "Business", "Investment"],
|
|
901
|
-
"business_count": 2,
|
|
902
|
-
"business_details": {
|
|
903
|
-
"RealEstate": 0,
|
|
904
|
-
"SCorp": 1,
|
|
905
|
-
"CCorp": 0,
|
|
906
|
-
"Trust": 0,
|
|
907
|
-
"LLC": 1
|
|
908
|
-
},
|
|
909
|
-
"investment_details": ["Stocks", "Bonds", "MutualFunds"],
|
|
910
|
-
"state_filing": ["California", "Illinois"],
|
|
911
|
-
"foreign_accounts": "no",
|
|
912
|
-
"deduction_types": ["Charitable", "Mortgage"],
|
|
913
|
-
"charitable_amount": 3000,
|
|
914
|
-
"contact_info": "Alice Johnson, alice.j@example.com, 555-555-0100",
|
|
915
|
-
"review": "confirm"
|
|
916
|
-
}
|
|
418
|
+
def chat(system_prompt:, user_prompt:, model:)
|
|
419
|
+
# Must return response text (expected to be JSON)
|
|
420
|
+
end
|
|
421
|
+
end
|
|
917
422
|
```
|
|
918
423
|
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
```
|
|
922
|
-
filing_status -> dependents -> income_types -> business_count ->
|
|
923
|
-
business_details -> investment_details -> state_filing ->
|
|
924
|
-
foreign_accounts -> deduction_types -> charitable_amount ->
|
|
925
|
-
contact_info -> review
|
|
926
|
-
```
|
|
424
|
+
---
|
|
927
425
|
|
|
928
|
-
|
|
426
|
+
## State Persistence
|
|
929
427
|
|
|
930
|
-
|
|
428
|
+
The engine's full state — including AI intake conversation history — can be serialized and restored:
|
|
931
429
|
|
|
932
430
|
```ruby
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
engine.answer({ "Residential" => 1, "Commercial" => 0, "Vacation" => 1 })
|
|
939
|
-
engine.answer(["Florida"])
|
|
940
|
-
engine.answer("yes")
|
|
941
|
-
engine.answer(1)
|
|
942
|
-
engine.answer(%w[Medical Education])
|
|
943
|
-
engine.answer("Bob Lee, bob@example.com, 555-000-1111")
|
|
944
|
-
engine.answer("confirm")
|
|
945
|
-
```
|
|
431
|
+
state = engine.to_state
|
|
432
|
+
# => { current_step_id: :income_types, answers: { ... }, history: [...],
|
|
433
|
+
# introduction_text: "...", clarification_round: 1,
|
|
434
|
+
# conversation_history: [{role: :user, text: "..."}, ...],
|
|
435
|
+
# active_intake_step_id: :personal_intake }
|
|
946
436
|
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
```json
|
|
950
|
-
{
|
|
951
|
-
"filing_status": "head_of_household",
|
|
952
|
-
"dependents": 2,
|
|
953
|
-
"income_types": ["1099", "Rental"],
|
|
954
|
-
"rental_details": {
|
|
955
|
-
"Residential": 1,
|
|
956
|
-
"Commercial": 0,
|
|
957
|
-
"Vacation": 1
|
|
958
|
-
},
|
|
959
|
-
"state_filing": ["Florida"],
|
|
960
|
-
"foreign_accounts": "yes",
|
|
961
|
-
"foreign_account_details": 1,
|
|
962
|
-
"deduction_types": ["Medical", "Education"],
|
|
963
|
-
"contact_info": "Bob Lee, bob@example.com, 555-000-1111",
|
|
964
|
-
"review": "confirm"
|
|
965
|
-
}
|
|
437
|
+
restored = FlowEngine::Engine.from_state(definition, state)
|
|
966
438
|
```
|
|
967
439
|
|
|
968
|
-
|
|
440
|
+
Round-trips through JSON (string keys) are handled automatically.
|
|
969
441
|
|
|
970
|
-
|
|
971
|
-
filing_status -> dependents -> income_types -> rental_details ->
|
|
972
|
-
state_filing -> foreign_accounts -> foreign_account_details ->
|
|
973
|
-
deduction_types -> contact_info -> review
|
|
974
|
-
```
|
|
442
|
+
## Validation
|
|
975
443
|
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
The shape of the collected data depends entirely on which path the user takes through the graph. Here's a side-by-side of which keys appear in each scenario:
|
|
979
|
-
|
|
980
|
-
| Answer Key | Max (17) | Min (8) | Medium (12) | Rental (10) |
|
|
981
|
-
|---|:---:|:---:|:---:|:---:|
|
|
982
|
-
| `filing_status` | x | x | x | x |
|
|
983
|
-
| `dependents` | x | x | x | x |
|
|
984
|
-
| `income_types` | x | x | x | x |
|
|
985
|
-
| `business_count` | x | | x | |
|
|
986
|
-
| `complex_business_info` | x | | | |
|
|
987
|
-
| `business_details` | x | | x | |
|
|
988
|
-
| `investment_details` | x | | x | |
|
|
989
|
-
| `crypto_details` | x | | | |
|
|
990
|
-
| `rental_details` | x | | | x |
|
|
991
|
-
| `state_filing` | x | x | x | x |
|
|
992
|
-
| `foreign_accounts` | x | x | x | x |
|
|
993
|
-
| `foreign_account_details` | x | | | x |
|
|
994
|
-
| `deduction_types` | x | x | x | x |
|
|
995
|
-
| `charitable_amount` | x | | x | |
|
|
996
|
-
| `charitable_documentation` | x | | | |
|
|
997
|
-
| `contact_info` | x | x | x | x |
|
|
998
|
-
| `review` | x | x | x | x |
|
|
999
|
-
|
|
1000
|
-
## Composing Complex Rules
|
|
1001
|
-
|
|
1002
|
-
### Example: Multi-Condition Branching
|
|
444
|
+
Pluggable validators via the adapter pattern. Ships with `NullAdapter` (always passes):
|
|
1003
445
|
|
|
1004
446
|
```ruby
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
question "Filing status?"
|
|
1011
|
-
options %w[single married]
|
|
1012
|
-
transition to: :dependents
|
|
447
|
+
class MyValidator < FlowEngine::Validation::Adapter
|
|
448
|
+
def validate(node, input)
|
|
449
|
+
errors = []
|
|
450
|
+
errors << "must be a number" if node.type == :number && !input.is_a?(Numeric)
|
|
451
|
+
FlowEngine::Validation::Result.new(valid: errors.empty?, errors: errors)
|
|
1013
452
|
end
|
|
453
|
+
end
|
|
1014
454
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
question "How many dependents?"
|
|
1018
|
-
transition to: :income
|
|
1019
|
-
end
|
|
455
|
+
engine = FlowEngine::Engine.new(definition, validator: MyValidator.new)
|
|
456
|
+
```
|
|
1020
457
|
|
|
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
|
|
458
|
+
## Mermaid Diagram Export
|
|
1044
459
|
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
460
|
+
```ruby
|
|
461
|
+
exporter = FlowEngine::Graph::MermaidExporter.new(definition)
|
|
462
|
+
puts exporter.export
|
|
463
|
+
```
|
|
1049
464
|
|
|
1050
|
-
|
|
1051
|
-
type :text
|
|
1052
|
-
question "Alternative review path."
|
|
1053
|
-
end
|
|
465
|
+
## Architecture
|
|
1054
466
|
|
|
1055
|
-
|
|
1056
|
-
type :text
|
|
1057
|
-
question "Default review."
|
|
1058
|
-
end
|
|
1059
|
-
end
|
|
1060
|
-
```
|
|
467
|
+

|
|
1061
468
|
|
|
1062
|
-
The
|
|
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
|
-
```
|
|
469
|
+
The core has **zero UI logic**, **zero DB logic**, and **zero framework dependencies**. Adapters translate input/output, persist state, and render UI.
|
|
1111
470
|
|
|
1112
|
-
|
|
471
|
+
| Component | Responsibility |
|
|
472
|
+
|-----------|---------------|
|
|
473
|
+
| `FlowEngine.define` | DSL entry point; returns a frozen `Definition` |
|
|
474
|
+
| `Definition` | Immutable flow graph (nodes + start step + introduction) |
|
|
475
|
+
| `Node` | Single step: type, question, options/fields, transitions, visibility |
|
|
476
|
+
| `Transition` | Directed edge with optional rule condition |
|
|
477
|
+
| `Rules::*` | AST nodes for conditional logic |
|
|
478
|
+
| `Evaluator` | Evaluates rules against the answer store |
|
|
479
|
+
| `Engine` | Stateful runtime: current step, answers, history, AI intake state |
|
|
480
|
+
| `ClarificationResult` | Immutable result from an AI intake round |
|
|
481
|
+
| `Introduction` | Immutable config for one-shot introduction (label, placeholder, maxlength) |
|
|
482
|
+
| `Validation::Adapter` | Interface for pluggable validation |
|
|
483
|
+
| `LLM::Client` | High-level: builds prompt, calls adapter, parses JSON |
|
|
484
|
+
| `LLM::Adapter` | Abstract LLM API interface (Anthropic, OpenAI, Gemini implementations) |
|
|
485
|
+
| `LLM::SensitiveDataFilter` | Rejects text containing SSN, ITIN, EIN patterns |
|
|
486
|
+
| `Graph::MermaidExporter` | Exports flow as a Mermaid diagram |
|
|
1113
487
|
|
|
1114
488
|
## Ecosystem
|
|
1115
489
|
|
|
1116
|
-
FlowEngine is the core of a three-gem architecture:
|
|
1117
|
-
|
|
1118
490
|
| Gem | Purpose |
|
|
1119
491
|
|-----|---------|
|
|
1120
|
-
| **`flowengine`** (this gem) | Core engine + LLM
|
|
1121
|
-
| **`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 |
|
|
1122
494
|
| **`flowengine-rails`** | Rails Engine with ActiveRecord persistence and web views |
|
|
1123
495
|
|
|
1124
496
|
## Development
|
|
1125
497
|
|
|
1126
498
|
```bash
|
|
1127
499
|
bundle install
|
|
1128
|
-
|
|
500
|
+
just test # RSpec + RuboCop
|
|
501
|
+
just lint # RuboCop only
|
|
502
|
+
just doc # Generate YARD docs
|
|
1129
503
|
```
|
|
1130
504
|
|
|
1131
505
|
## License
|
|
1132
506
|
|
|
1133
|
-
|
|
507
|
+
MIT License. See [LICENSE](https://opensource.org/licenses/MIT).
|