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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +1 -0
  3. data/.envrc +4 -0
  4. data/.rubocop_todo.yml +3 -8
  5. data/README.md +151 -6
  6. data/Rakefile +1 -1
  7. data/docs/badges/coverage_badge.svg +21 -0
  8. data/justfile +11 -0
  9. data/lib/flowengine/definition.rb +17 -2
  10. data/lib/flowengine/dsl/flow_builder.rb +25 -1
  11. data/lib/flowengine/dsl/rule_helpers.rb +22 -0
  12. data/lib/flowengine/dsl/step_builder.rb +23 -0
  13. data/lib/flowengine/dsl.rb +2 -0
  14. data/lib/flowengine/engine.rb +68 -2
  15. data/lib/flowengine/errors.rb +17 -0
  16. data/lib/flowengine/evaluator.rb +9 -0
  17. data/lib/flowengine/graph/mermaid_exporter.rb +7 -0
  18. data/lib/flowengine/introduction.rb +14 -0
  19. data/lib/flowengine/llm/adapter.rb +19 -0
  20. data/lib/flowengine/llm/client.rb +75 -0
  21. data/lib/flowengine/llm/openai_adapter.rb +38 -0
  22. data/lib/flowengine/llm/sensitive_data_filter.rb +45 -0
  23. data/lib/flowengine/llm/system_prompt_builder.rb +73 -0
  24. data/lib/flowengine/llm.rb +14 -0
  25. data/lib/flowengine/node.rb +47 -2
  26. data/lib/flowengine/rules/all.rb +7 -0
  27. data/lib/flowengine/rules/any.rb +7 -0
  28. data/lib/flowengine/rules/base.rb +9 -0
  29. data/lib/flowengine/rules/contains.rb +10 -0
  30. data/lib/flowengine/rules/equals.rb +9 -0
  31. data/lib/flowengine/rules/greater_than.rb +9 -0
  32. data/lib/flowengine/rules/less_than.rb +9 -0
  33. data/lib/flowengine/rules/not_empty.rb +7 -0
  34. data/lib/flowengine/transition.rb +14 -0
  35. data/lib/flowengine/validation/adapter.rb +13 -0
  36. data/lib/flowengine/validation/null_adapter.rb +4 -0
  37. data/lib/flowengine/version.rb +2 -1
  38. data/lib/flowengine.rb +35 -0
  39. data/resources/prompts/generic-dsl-intake.j2 +60 -0
  40. metadata +53 -2
  41. data/CHANGELOG.md +0 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50117723ca62a9851b0c8091e2619132d6e2f849e62b765dadd5c0d0d5c601ad
4
- data.tar.gz: 0024ae08e3d6068d0ed913067368ddd4764a83f9ffe1f44e4d9bdcef4f544757
3
+ metadata.gz: c6118d723586350e431080d822f01dbda45c9cc58257dd812e4d3dd7c30f8de6
4
+ data.tar.gz: 606d8ac60526863acb699c166b396d00baa29fdedb009df4c4a4b6bfb4a783a0
5
5
  SHA512:
6
- metadata.gz: 8d3e5eb92cf044bed6e4a5408ed70452dc755234030e3a39785714864c09d2ba804592cca29005f304dab0fab95058f142602f0239e2f68ffa9826ef15bd748d
7
- data.tar.gz: f889c3914f22d5a6cb4f439e202c318819e007a692f973bef4a288c4c4bbb34995aa65bf88bd5ddc799ee3ed684ae41066617300d7b3e67efe4e62437717149d
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
@@ -1,2 +1,6 @@
1
1
  PATH_add bin
2
2
  PATH_add exe
3
+
4
+ if [[ -f .env ]]; then
5
+ eval "$(cat .env | sed '/^#.*/d; /^$/d; s/^/export /g')"
6
+ fi
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-05 18:28:58 UTC using RuboCop version 1.85.0.
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: 3
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: 1
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
- [![RSpec](https://github.com/kigster/flowengine/actions/workflows/rspec.yml/badge.svg)](https://github.com/kigster/flowengine/actions/workflows/rspec.yml) [![RuboCop](https://github.com/kigster/flowengine/actions/workflows/rubocop.yml/badge.svg)](https://github.com/kigster/flowengine/actions/workflows/rubocop.yml)
3
+ [![RSpec](https://github.com/kigster/flowengine/actions/workflows/rspec.yml/badge.svg)](https://github.com/kigster/flowengine/actions/workflows/rspec.yml)   [![RuboCop](https://github.com/kigster/flowengine/actions/workflows/rubocop.yml/badge.svg)](https://github.com/kigster/flowengine/actions/workflows/rubocop.yml)   ![Coverage](docs/badges/coverage_badge.svg)
4
4
 
5
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
- 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.
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
- | `Definition` | Immutable container of the flow graph (nodes + start step) |
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 history |
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 pure Ruby, no Rails, no DB, no UI |
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
- def initialize(start_step_id:, nodes:)
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,
@@ -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