flowengine 0.1.1 → 0.2.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/.env.example +1 -0
- data/.envrc +4 -0
- data/.rubocop_todo.yml +3 -8
- data/README.md +151 -6
- data/Rakefile +1 -1
- data/docs/badges/coverage_badge.svg +21 -0
- data/justfile +11 -0
- data/lib/flowengine/definition.rb +17 -2
- data/lib/flowengine/dsl/flow_builder.rb +25 -1
- data/lib/flowengine/dsl/rule_helpers.rb +22 -0
- data/lib/flowengine/dsl/step_builder.rb +23 -0
- data/lib/flowengine/dsl.rb +2 -0
- data/lib/flowengine/engine.rb +68 -2
- data/lib/flowengine/errors.rb +17 -0
- data/lib/flowengine/evaluator.rb +9 -0
- data/lib/flowengine/graph/mermaid_exporter.rb +7 -0
- data/lib/flowengine/introduction.rb +14 -0
- data/lib/flowengine/llm/adapter.rb +19 -0
- data/lib/flowengine/llm/client.rb +75 -0
- data/lib/flowengine/llm/openai_adapter.rb +38 -0
- data/lib/flowengine/llm/sensitive_data_filter.rb +45 -0
- data/lib/flowengine/llm/system_prompt_builder.rb +73 -0
- data/lib/flowengine/llm.rb +14 -0
- data/lib/flowengine/node.rb +47 -2
- data/lib/flowengine/rules/all.rb +7 -0
- data/lib/flowengine/rules/any.rb +7 -0
- data/lib/flowengine/rules/base.rb +9 -0
- data/lib/flowengine/rules/contains.rb +10 -0
- data/lib/flowengine/rules/equals.rb +9 -0
- data/lib/flowengine/rules/greater_than.rb +9 -0
- data/lib/flowengine/rules/less_than.rb +9 -0
- data/lib/flowengine/rules/not_empty.rb +7 -0
- data/lib/flowengine/transition.rb +14 -0
- data/lib/flowengine/validation/adapter.rb +13 -0
- data/lib/flowengine/validation/null_adapter.rb +4 -0
- data/lib/flowengine/version.rb +2 -1
- data/lib/flowengine.rb +35 -0
- data/resources/prompts/generic-dsl-intake.j2 +60 -0
- metadata +53 -2
- data/CHANGELOG.md +0 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c6118d723586350e431080d822f01dbda45c9cc58257dd812e4d3dd7c30f8de6
|
|
4
|
+
data.tar.gz: 606d8ac60526863acb699c166b396d00baa29fdedb009df4c4a4b6bfb4a783a0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 00c92c2094931b5c4d427aec5e2de4bce98d733be459aadd367ce58f3d64564c7332c53d8810d37d1f5d95352439572fe2252eb6068a4fa9278c0e1cef223c78
|
|
7
|
+
data.tar.gz: d9bc34f2cfeb87ee7e6b6bc75e429903db85d4d2482a517240da2ab995bdd38202a03f3585f54a00e5e1d263751cf9f82225e34303889bbb67cdcbc0a7e47e92
|
data/.env.example
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
OPENAI_API_KEY="sk-proj-..."
|
data/.envrc
CHANGED
data/.rubocop_todo.yml
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# This configuration was generated by
|
|
2
2
|
# `rubocop --auto-gen-config`
|
|
3
|
-
# on 2026-03-
|
|
3
|
+
# on 2026-03-10 19:57:08 UTC using RuboCop version 1.85.1.
|
|
4
4
|
# The point is for the user to remove these configuration records
|
|
5
5
|
# one by one as the offenses are removed from the code base.
|
|
6
6
|
# Note that changes in the inspected code, or installation of new
|
|
7
7
|
# versions of RuboCop, may require this file to be generated again.
|
|
8
8
|
|
|
9
|
-
# Offense count:
|
|
9
|
+
# Offense count: 5
|
|
10
10
|
# Configuration parameters: EnforcedStyle, AllowedGems.
|
|
11
11
|
# SupportedStyles: Gemfile, gems.rb, gemspec
|
|
12
12
|
Gemspec/DevelopmentDependencies:
|
|
@@ -18,16 +18,11 @@ Gemspec/DevelopmentDependencies:
|
|
|
18
18
|
Metrics/AbcSize:
|
|
19
19
|
Max: 18
|
|
20
20
|
|
|
21
|
-
# Offense count:
|
|
21
|
+
# Offense count: 2
|
|
22
22
|
# Configuration parameters: AllowedMethods, AllowedPatterns.
|
|
23
23
|
Metrics/CyclomaticComplexity:
|
|
24
24
|
Max: 9
|
|
25
25
|
|
|
26
|
-
# Offense count: 1
|
|
27
|
-
# Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
|
|
28
|
-
Metrics/ParameterLists:
|
|
29
|
-
Max: 7
|
|
30
|
-
|
|
31
26
|
# Offense count: 5
|
|
32
27
|
# Configuration parameters: Mode, AllowedMethods, AllowedPatterns, AllowBangMethods, WaywardPredicates.
|
|
33
28
|
# AllowedMethods: call
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# FlowEngine
|
|
2
2
|
|
|
3
|
-
[](https://github.com/kigster/flowengine/actions/workflows/rspec.yml) [](https://github.com/kigster/flowengine/actions/workflows/rubocop.yml)
|
|
3
|
+
[](https://github.com/kigster/flowengine/actions/workflows/rspec.yml) [](https://github.com/kigster/flowengine/actions/workflows/rubocop.yml) 
|
|
4
4
|
|
|
5
5
|
This gem is the foundation of collecting complex multi-branch information from a user using a flow definition written in Ruby DSL. It shouldn't take too long to learn the DSL even for a non-technical person.
|
|
6
6
|
|
|
@@ -8,10 +8,12 @@ This gem does not have any UI or an interactive component. It is used as the fou
|
|
|
8
8
|
|
|
9
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
10
|
|
|
11
|
-
A declarative flow engine for building rules-driven wizards and intake forms in pure Ruby
|
|
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
12
|
|
|
13
|
-
|
|
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.
|
|
14
15
|
|
|
16
|
+
> [!CAUTION]
|
|
15
17
|
> **This is not a form builder.** It's a *Form Definition Engine* that separates flow logic, data schema, and UI rendering into independent concerns.
|
|
16
18
|
|
|
17
19
|
## Installation
|
|
@@ -35,6 +37,9 @@ require "flowengine"
|
|
|
35
37
|
|
|
36
38
|
# 1. Define a flow
|
|
37
39
|
definition = FlowEngine.define do
|
|
40
|
+
introduction label: "What are your favorite cocktails?",
|
|
41
|
+
placeholder: "Old Fashion, Whisky Sour, etc",
|
|
42
|
+
maxlength: 2000
|
|
38
43
|
start :name
|
|
39
44
|
|
|
40
45
|
step :name do
|
|
@@ -93,7 +98,123 @@ engine.history
|
|
|
93
98
|
|
|
94
99
|
### Using the `flowengine-cli` gem to Generate the JSON Answers File
|
|
95
100
|
|
|
101
|
+
## LLM-parsed Introduction
|
|
96
102
|
|
|
103
|
+
FlowEngine supports an optional **introduction step** that collects free-form text from the user before the structured flow begins. An LLM parses this text to pre-fill answers, automatically skipping steps the user already answered in their introduction.
|
|
104
|
+
|
|
105
|
+
### Defining an Introduction
|
|
106
|
+
|
|
107
|
+
Add the `introduction` command to your flow definition:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
definition = FlowEngine.define do
|
|
111
|
+
start :filing_status
|
|
112
|
+
|
|
113
|
+
introduction label: "Tell us about your tax situation",
|
|
114
|
+
placeholder: "e.g. I am married, filing jointly, with 2 dependents...",
|
|
115
|
+
maxlength: 2000 # optional character limit
|
|
116
|
+
|
|
117
|
+
step :filing_status do
|
|
118
|
+
type :single_select
|
|
119
|
+
question "What is your filing status?"
|
|
120
|
+
options %w[single married_filing_jointly head_of_household]
|
|
121
|
+
transition to: :dependents
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
step :dependents do
|
|
125
|
+
type :number
|
|
126
|
+
question "How many dependents?"
|
|
127
|
+
transition to: :income_types
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
step :income_types do
|
|
131
|
+
type :multi_select
|
|
132
|
+
question "Select income types"
|
|
133
|
+
options %w[W2 1099 Business Investment]
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
| Parameter | Required | Description |
|
|
139
|
+
|-----------|----------|-------------|
|
|
140
|
+
| `label` | Yes | Text shown above the input field |
|
|
141
|
+
| `placeholder` | No | Ghost text inside the text area (default: `""`) |
|
|
142
|
+
| `maxlength` | No | Maximum character count (default: `nil` = unlimited) |
|
|
143
|
+
|
|
144
|
+
### Using the Introduction at Runtime
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
# 1. Configure an LLM adapter and client
|
|
148
|
+
adapter = FlowEngine::LLM::OpenAIAdapter.new(api_key: ENV["OPENAI_API_KEY"])
|
|
149
|
+
client = FlowEngine::LLM::Client.new(adapter: adapter, model: "gpt-4o-mini")
|
|
150
|
+
|
|
151
|
+
# 2. Create the engine and submit the introduction
|
|
152
|
+
engine = FlowEngine::Engine.new(definition)
|
|
153
|
+
engine.submit_introduction(
|
|
154
|
+
"I am married filing jointly with 2 dependents, W2 and business income",
|
|
155
|
+
llm_client: client
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# 3. The LLM pre-fills answers and the engine auto-advances
|
|
159
|
+
engine.answers
|
|
160
|
+
# => { filing_status: "married_filing_jointly", dependents: 2,
|
|
161
|
+
# income_types: ["W2", "Business"] }
|
|
162
|
+
|
|
163
|
+
engine.current_step_id # => nil (all steps pre-filled in this case)
|
|
164
|
+
engine.introduction_text # => "I am married filing jointly with 2 dependents, ..."
|
|
165
|
+
engine.finished? # => true
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
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.
|
|
169
|
+
|
|
170
|
+
### Sensitive Data Protection
|
|
171
|
+
|
|
172
|
+
Before any text reaches the LLM, `submit_introduction` scans for sensitive data patterns:
|
|
173
|
+
|
|
174
|
+
- **SSN**: `123-45-6789`
|
|
175
|
+
- **ITIN**: `912-34-5678`
|
|
176
|
+
- **EIN**: `12-3456789`
|
|
177
|
+
- **Nine consecutive digits**: `123456789`
|
|
178
|
+
|
|
179
|
+
If detected, a `FlowEngine::SensitiveDataError` is raised immediately. The introduction text is discarded and no LLM call is made.
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
engine.submit_introduction("My SSN is 123-45-6789", llm_client: client)
|
|
183
|
+
# => raises FlowEngine::SensitiveDataError
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Custom LLM Adapters
|
|
187
|
+
|
|
188
|
+
The LLM integration uses an adapter pattern. The gem ships with an OpenAI adapter (via the [`ruby_llm`](https://github.com/crmne/ruby_llm) gem), but you can create adapters for any provider:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
class MyAnthropicAdapter < FlowEngine::LLM::Adapter
|
|
192
|
+
def initialize(api_key:)
|
|
193
|
+
super()
|
|
194
|
+
@api_key = api_key
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def chat(system_prompt:, user_prompt:, model:)
|
|
198
|
+
# Call your LLM API here
|
|
199
|
+
# Must return the response text (expected to be a JSON string)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
adapter = MyAnthropicAdapter.new(api_key: ENV["ANTHROPIC_API_KEY"])
|
|
204
|
+
client = FlowEngine::LLM::Client.new(adapter: adapter, model: "claude-sonnet-4-20250514")
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### State Persistence
|
|
208
|
+
|
|
209
|
+
The `introduction_text` is included in state serialization:
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
state = engine.to_state
|
|
213
|
+
# => { current_step_id: ..., answers: { ... }, history: [...], introduction_text: "..." }
|
|
214
|
+
|
|
215
|
+
restored = FlowEngine::Engine.from_state(definition, state)
|
|
216
|
+
restored.introduction_text # => "I am married filing jointly..."
|
|
217
|
+
```
|
|
97
218
|
|
|
98
219
|
## Architecture
|
|
99
220
|
|
|
@@ -106,13 +227,18 @@ The core has **zero UI logic**, **zero DB logic**, and **zero framework dependen
|
|
|
106
227
|
| Component | Responsibility |
|
|
107
228
|
|-----------|---------------|
|
|
108
229
|
| `FlowEngine.define` | DSL entry point; returns a frozen `Definition` |
|
|
109
|
-
| `
|
|
230
|
+
| `Introduction` | Immutable config for the introduction step (label, placeholder, maxlength) |
|
|
231
|
+
| `Definition` | Immutable container of the flow graph (nodes + start step + introduction) |
|
|
110
232
|
| `Node` | A single step: type, question, options/fields, transitions, visibility |
|
|
111
233
|
| `Transition` | A directed edge with an optional rule condition |
|
|
112
234
|
| `Rules::*` | AST nodes for conditional logic (`Contains`, `Equals`, `All`, etc.) |
|
|
113
235
|
| `Evaluator` | Evaluates rules against the current answer store |
|
|
114
|
-
| `Engine` | Stateful runtime: tracks current step, answers, and
|
|
236
|
+
| `Engine` | Stateful runtime: tracks current step, answers, history, and introduction |
|
|
115
237
|
| `Validation::Adapter` | Interface for pluggable validation (dry-validation, JSON Schema, etc.) |
|
|
238
|
+
| `LLM::Adapter` | Abstract interface for LLM API calls |
|
|
239
|
+
| `LLM::OpenAIAdapter` | OpenAI implementation via `ruby_llm` gem |
|
|
240
|
+
| `LLM::Client` | High-level: builds prompt, calls adapter, parses JSON response |
|
|
241
|
+
| `LLM::SensitiveDataFilter` | Rejects text containing SSN, ITIN, EIN patterns |
|
|
116
242
|
| `Graph::MermaidExporter` | Exports the flow definition as a Mermaid diagram |
|
|
117
243
|
|
|
118
244
|
## The DSL
|
|
@@ -125,6 +251,11 @@ Every flow starts with `FlowEngine.define`, which returns a **frozen, immutable*
|
|
|
125
251
|
definition = FlowEngine.define do
|
|
126
252
|
start :first_step # Required: which node to begin at
|
|
127
253
|
|
|
254
|
+
# Optional: collect free-form text before the flow, parsed by LLM
|
|
255
|
+
introduction label: "Describe your situation",
|
|
256
|
+
placeholder: "Type here...",
|
|
257
|
+
maxlength: 2000
|
|
258
|
+
|
|
128
259
|
step :first_step do
|
|
129
260
|
# step configuration...
|
|
130
261
|
end
|
|
@@ -309,9 +440,11 @@ engine = FlowEngine::Engine.new(definition)
|
|
|
309
440
|
| `engine.current_step_id` | `Symbol` or `nil` | The ID of the current step |
|
|
310
441
|
| `engine.current_step` | `Node` or `nil` | The current Node object |
|
|
311
442
|
| `engine.answer(value)` | `nil` | Records the answer and advances |
|
|
443
|
+
| `engine.submit_introduction(text, llm_client:)` | `nil` | LLM-parses text, pre-fills answers, auto-advances |
|
|
312
444
|
| `engine.finished?` | `Boolean` | `true` when there are no more steps |
|
|
313
445
|
| `engine.answers` | `Hash` | All collected answers `{ step_id => value }` |
|
|
314
446
|
| `engine.history` | `Array<Symbol>` | Ordered list of visited step IDs |
|
|
447
|
+
| `engine.introduction_text` | `String` or `nil` | The raw introduction text submitted |
|
|
315
448
|
| `engine.definition` | `Definition` | The immutable flow definition |
|
|
316
449
|
|
|
317
450
|
### Error Handling
|
|
@@ -334,6 +467,18 @@ FlowEngine.define do
|
|
|
334
467
|
end
|
|
335
468
|
end
|
|
336
469
|
# => raises FlowEngine::DefinitionError
|
|
470
|
+
|
|
471
|
+
# Sensitive data in introduction
|
|
472
|
+
engine.submit_introduction("My SSN is 123-45-6789", llm_client: client)
|
|
473
|
+
# => raises FlowEngine::SensitiveDataError
|
|
474
|
+
|
|
475
|
+
# Introduction exceeds maxlength
|
|
476
|
+
engine.submit_introduction("A" * 3000, llm_client: client)
|
|
477
|
+
# => raises FlowEngine::ValidationError
|
|
478
|
+
|
|
479
|
+
# Missing API key or LLM response parsing failure
|
|
480
|
+
FlowEngine::LLM::OpenAIAdapter.new # without OPENAI_API_KEY
|
|
481
|
+
# => raises FlowEngine::LLMError
|
|
337
482
|
```
|
|
338
483
|
|
|
339
484
|
## Validation
|
|
@@ -894,7 +1039,7 @@ FlowEngine is the core of a three-gem architecture:
|
|
|
894
1039
|
|
|
895
1040
|
| Gem | Purpose |
|
|
896
1041
|
|-----|---------|
|
|
897
|
-
| **`flowengine`** (this gem) | Core engine
|
|
1042
|
+
| **`flowengine`** (this gem) | Core engine + LLM introduction parsing (depends on `ruby_llm`) |
|
|
898
1043
|
| **`flowengine-cli`** | Terminal wizard adapter using [TTY Toolkit](https://ttytoolkit.org/) + Dry::CLI |
|
|
899
1044
|
| **`flowengine-rails`** | Rails Engine with ActiveRecord persistence and web views |
|
|
900
1045
|
|
data/Rakefile
CHANGED
|
@@ -26,7 +26,7 @@ end
|
|
|
26
26
|
task build: :permissions
|
|
27
27
|
|
|
28
28
|
YARD::Rake::YardocTask.new(:doc) do |t|
|
|
29
|
-
t.files = %w[lib/**/*.rb exe/*.rb - README.md LICENSE.txt]
|
|
29
|
+
t.files = %w[lib/**/*.rb exe/*.rb - README.md LICENSE.txt CHANGELOG.md]
|
|
30
30
|
t.options.unshift("--title", '"FlowEngine — DSL + AST for buildiong complex flows in Ruby."')
|
|
31
31
|
t.after = -> { exec("open doc/index.html") } if RUBY_PLATFORM =~ /darwin/
|
|
32
32
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="99" height="20">
|
|
3
|
+
<linearGradient id="b" x2="0" y2="100%">
|
|
4
|
+
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
|
5
|
+
<stop offset="1" stop-opacity=".1"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<mask id="a">
|
|
8
|
+
<rect width="99" height="20" rx="3" fill="#fff"/>
|
|
9
|
+
</mask>
|
|
10
|
+
<g mask="url(#a)">
|
|
11
|
+
<path fill="#555" d="M0 0h63v20H0z"/>
|
|
12
|
+
<path fill="#4c1" d="M63 0h36v20H63z"/>
|
|
13
|
+
<path fill="url(#b)" d="M0 0h99v20H0z"/>
|
|
14
|
+
</g>
|
|
15
|
+
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
|
|
16
|
+
<text x="31.5" y="15" fill="#010101" fill-opacity=".3">coverage</text>
|
|
17
|
+
<text x="31.5" y="14">coverage</text>
|
|
18
|
+
<text x="80" y="15" fill="#010101" fill-opacity=".3">100%</text>
|
|
19
|
+
<text x="80" y="14">100%</text>
|
|
20
|
+
</g>
|
|
21
|
+
</svg>
|
data/justfile
CHANGED
|
@@ -36,6 +36,17 @@ format:
|
|
|
36
36
|
lint:
|
|
37
37
|
@bundle exec rubocop
|
|
38
38
|
|
|
39
|
+
# Generates library documentation into ./doc folder and opens the browser
|
|
40
|
+
doc:
|
|
41
|
+
@bundle exec rake doc
|
|
42
|
+
@open ./doc/index.html
|
|
43
|
+
|
|
44
|
+
clean:
|
|
45
|
+
@rm -rf pkg
|
|
46
|
+
@rm -rf coverage
|
|
47
|
+
|
|
48
|
+
release:
|
|
49
|
+
@bundle exec rake release
|
|
39
50
|
|
|
40
51
|
check-all: lint test
|
|
41
52
|
|
|
@@ -1,24 +1,39 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
|
+
# Immutable, versionable flow graph: maps step ids to {Node} objects and defines the entry point.
|
|
5
|
+
# Built by {DSL::FlowBuilder}; consumed by {Engine} for navigation.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader start_step_id [Symbol] id of the first step in the flow
|
|
8
|
+
# @attr_reader steps [Hash<Symbol, Node>] frozen map of step id => node (read-only)
|
|
4
9
|
class Definition
|
|
5
|
-
attr_reader :start_step_id, :steps
|
|
10
|
+
attr_reader :start_step_id, :steps, :introduction
|
|
6
11
|
|
|
7
|
-
|
|
12
|
+
# @param start_step_id [Symbol] id of the initial step
|
|
13
|
+
# @param nodes [Hash<Symbol, Node>] all steps keyed by id
|
|
14
|
+
# @param introduction [Introduction, nil] optional introduction config (label + placeholder)
|
|
15
|
+
# @raise [DefinitionError] if start_step_id is not present in nodes
|
|
16
|
+
def initialize(start_step_id:, nodes:, introduction: nil)
|
|
8
17
|
@start_step_id = start_step_id
|
|
9
18
|
@steps = nodes.freeze
|
|
19
|
+
@introduction = introduction
|
|
10
20
|
validate!
|
|
11
21
|
freeze
|
|
12
22
|
end
|
|
13
23
|
|
|
24
|
+
# @return [Node] the node for the start step
|
|
14
25
|
def start_step
|
|
15
26
|
step(start_step_id)
|
|
16
27
|
end
|
|
17
28
|
|
|
29
|
+
# @param id [Symbol] step id
|
|
30
|
+
# @return [Node] the node for that step
|
|
31
|
+
# @raise [UnknownStepError] if id is not in steps
|
|
18
32
|
def step(id)
|
|
19
33
|
steps.fetch(id) { raise UnknownStepError, "Unknown step: #{id.inspect}" }
|
|
20
34
|
end
|
|
21
35
|
|
|
36
|
+
# @return [Array<Symbol>] all step ids in the definition
|
|
22
37
|
def step_ids
|
|
23
38
|
steps.keys
|
|
24
39
|
end
|
|
@@ -2,29 +2,53 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module DSL
|
|
5
|
+
# Builds a {Definition} from the declarative DSL used in {FlowEngine.define}.
|
|
6
|
+
# Provides {#start} and {#step}; each step block is evaluated by {StepBuilder} with {RuleHelpers}.
|
|
5
7
|
class FlowBuilder
|
|
6
8
|
include RuleHelpers
|
|
7
9
|
|
|
8
10
|
def initialize
|
|
9
11
|
@start_step_id = nil
|
|
10
12
|
@nodes = {}
|
|
13
|
+
@introduction = nil
|
|
11
14
|
end
|
|
12
15
|
|
|
16
|
+
# Sets the entry step id for the flow.
|
|
17
|
+
#
|
|
18
|
+
# @param step_id [Symbol] id of the first step
|
|
13
19
|
def start(step_id)
|
|
14
20
|
@start_step_id = step_id
|
|
15
21
|
end
|
|
16
22
|
|
|
23
|
+
# Configures an introduction step that collects free-form text before the flow begins.
|
|
24
|
+
# The LLM parses this text to pre-fill answers for subsequent steps.
|
|
25
|
+
#
|
|
26
|
+
# @param label [String] text shown above the input field
|
|
27
|
+
# @param placeholder [String] text shown inside the empty text area
|
|
28
|
+
# @param maxlength [Integer, nil] maximum character count for the text (nil = unlimited)
|
|
29
|
+
def introduction(label:, placeholder: "", maxlength: nil)
|
|
30
|
+
@introduction = Introduction.new(label: label, placeholder: placeholder, maxlength: maxlength)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Defines one step by id; the block is evaluated in a {StepBuilder} context.
|
|
34
|
+
#
|
|
35
|
+
# @param id [Symbol] step id
|
|
36
|
+
# @yield block evaluated in {StepBuilder} (type, question, options, transition, etc.)
|
|
17
37
|
def step(id, &)
|
|
18
38
|
builder = StepBuilder.new
|
|
19
39
|
builder.instance_eval(&)
|
|
20
40
|
@nodes[id] = builder.build(id)
|
|
21
41
|
end
|
|
22
42
|
|
|
43
|
+
# Produces the frozen {Definition} from the accumulated start and steps.
|
|
44
|
+
#
|
|
45
|
+
# @return [Definition]
|
|
46
|
+
# @raise [DefinitionError] if start was not set or no steps were defined
|
|
23
47
|
def build
|
|
24
48
|
raise DefinitionError, "No start step defined" if @start_step_id.nil?
|
|
25
49
|
raise DefinitionError, "No steps defined" if @nodes.empty?
|
|
26
50
|
|
|
27
|
-
Definition.new(start_step_id: @start_step_id, nodes: @nodes)
|
|
51
|
+
Definition.new(start_step_id: @start_step_id, nodes: @nodes, introduction: @introduction)
|
|
28
52
|
end
|
|
29
53
|
end
|
|
30
54
|
end
|
|
@@ -2,31 +2,53 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module DSL
|
|
5
|
+
# Factory methods for rule objects, included in {FlowBuilder} and {StepBuilder}.
|
|
6
|
+
# Use these inside step blocks for transition conditions and visible_if.
|
|
5
7
|
module RuleHelpers
|
|
8
|
+
# @param field [Symbol] answer key (step id)
|
|
9
|
+
# @param value [Object] value to check for in the array
|
|
10
|
+
# @return [Rules::Contains]
|
|
6
11
|
def contains(field, value)
|
|
7
12
|
Rules::Contains.new(field, value)
|
|
8
13
|
end
|
|
9
14
|
|
|
15
|
+
# @param field [Symbol] answer key
|
|
16
|
+
# @param value [Object] expected value
|
|
17
|
+
# @return [Rules::Equals]
|
|
10
18
|
def equals(field, value)
|
|
11
19
|
Rules::Equals.new(field, value)
|
|
12
20
|
end
|
|
13
21
|
|
|
22
|
+
# @param field [Symbol] answer key (value coerced to integer for comparison)
|
|
23
|
+
# @param value [Integer] threshold
|
|
24
|
+
# @return [Rules::GreaterThan]
|
|
14
25
|
def greater_than(field, value)
|
|
15
26
|
Rules::GreaterThan.new(field, value)
|
|
16
27
|
end
|
|
17
28
|
|
|
29
|
+
# @param field [Symbol] answer key (value coerced to integer for comparison)
|
|
30
|
+
# @param value [Integer] threshold
|
|
31
|
+
# @return [Rules::LessThan]
|
|
18
32
|
def less_than(field, value)
|
|
19
33
|
Rules::LessThan.new(field, value)
|
|
20
34
|
end
|
|
21
35
|
|
|
36
|
+
# @param field [Symbol] answer key
|
|
37
|
+
# @return [Rules::NotEmpty]
|
|
22
38
|
def not_empty(field)
|
|
23
39
|
Rules::NotEmpty.new(field)
|
|
24
40
|
end
|
|
25
41
|
|
|
42
|
+
# Logical AND of multiple rules.
|
|
43
|
+
# @param rules [Array<Rules::Base>] rules to combine
|
|
44
|
+
# @return [Rules::All]
|
|
26
45
|
def all(*rules)
|
|
27
46
|
Rules::All.new(*rules)
|
|
28
47
|
end
|
|
29
48
|
|
|
49
|
+
# Logical OR of multiple rules.
|
|
50
|
+
# @param rules [Array<Rules::Base>] rules to combine
|
|
51
|
+
# @return [Rules::Any]
|
|
30
52
|
def any(*rules)
|
|
31
53
|
Rules::Any.new(*rules)
|
|
32
54
|
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module DSL
|
|
5
|
+
# Builds a single {Node} from step DSL (type, question, options, transitions, visibility).
|
|
6
|
+
# Used by {FlowBuilder#step}; includes {RuleHelpers} for transition/visibility conditions.
|
|
5
7
|
class StepBuilder
|
|
6
8
|
include RuleHelpers
|
|
7
9
|
|
|
@@ -15,34 +17,55 @@ module FlowEngine
|
|
|
15
17
|
@decorations = nil
|
|
16
18
|
end
|
|
17
19
|
|
|
20
|
+
# Sets the step/input type (e.g. :multi_select, :number_matrix).
|
|
21
|
+
# @param value [Symbol]
|
|
18
22
|
def type(value)
|
|
19
23
|
@type = value
|
|
20
24
|
end
|
|
21
25
|
|
|
26
|
+
# Sets the prompt/label for the step.
|
|
27
|
+
# @param text [String]
|
|
22
28
|
def question(text)
|
|
23
29
|
@question = text
|
|
24
30
|
end
|
|
25
31
|
|
|
32
|
+
# Sets the list of options for multi-select or choice steps.
|
|
33
|
+
# @param list [Array]
|
|
26
34
|
def options(list)
|
|
27
35
|
@options = list
|
|
28
36
|
end
|
|
29
37
|
|
|
38
|
+
# Sets the list of field names for matrix-style steps (e.g. number_matrix).
|
|
39
|
+
# @param list [Array]
|
|
30
40
|
def fields(list)
|
|
31
41
|
@fields = list
|
|
32
42
|
end
|
|
33
43
|
|
|
44
|
+
# Optional UI decorations (opaque to the engine).
|
|
45
|
+
# @param decorations [Object]
|
|
34
46
|
def decorations(decorations)
|
|
35
47
|
@decorations = decorations
|
|
36
48
|
end
|
|
37
49
|
|
|
50
|
+
# Adds a conditional transition to another step. First matching transition wins.
|
|
51
|
+
#
|
|
52
|
+
# @param to [Symbol] target step id
|
|
53
|
+
# @param if_rule [Rules::Base, nil] condition (nil = unconditional)
|
|
38
54
|
def transition(to:, if_rule: nil)
|
|
39
55
|
@transitions << Transition.new(target: to, rule: if_rule)
|
|
40
56
|
end
|
|
41
57
|
|
|
58
|
+
# Sets the visibility rule for this step (DAG mode: step shown only when rule is true).
|
|
59
|
+
#
|
|
60
|
+
# @param rule [Rules::Base]
|
|
42
61
|
def visible_if(rule)
|
|
43
62
|
@visibility_rule = rule
|
|
44
63
|
end
|
|
45
64
|
|
|
65
|
+
# Builds the {Node} for the given step id from accumulated attributes.
|
|
66
|
+
#
|
|
67
|
+
# @param id [Symbol] step id
|
|
68
|
+
# @return [Node]
|
|
46
69
|
def build(id)
|
|
47
70
|
Node.new(
|
|
48
71
|
id: id,
|
data/lib/flowengine/dsl.rb
CHANGED
|
@@ -5,6 +5,8 @@ require_relative "dsl/step_builder"
|
|
|
5
5
|
require_relative "dsl/flow_builder"
|
|
6
6
|
|
|
7
7
|
module FlowEngine
|
|
8
|
+
# Namespace for the declarative flow DSL: {FlowBuilder} builds a {Definition} from blocks,
|
|
9
|
+
# {StepBuilder} builds individual {Node}s, and {RuleHelpers} provide rule factory methods.
|
|
8
10
|
module DSL
|
|
9
11
|
end
|
|
10
12
|
end
|