flowengine 0.1.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 758428572c105952cb5cf538db6ff0307b60c2a5cbf6187344664d2d063d680a
4
- data.tar.gz: e4b3db255b71a7faa3f54f024d96440dbf62aaa383d4a01d96ac5bff58517bcb
3
+ metadata.gz: c6118d723586350e431080d822f01dbda45c9cc58257dd812e4d3dd7c30f8de6
4
+ data.tar.gz: 606d8ac60526863acb699c166b396d00baa29fdedb009df4c4a4b6bfb4a783a0
5
5
  SHA512:
6
- metadata.gz: 6ebe4ebeaf8f47846fe4e831457de894a43e0e544c0f99453ecb9f6d1d535edcef2f8e5f83fc866984e731c865d1e0b6b8ecc3aa46e64b220fb44942441f3491
7
- data.tar.gz: 6447ed169da61924456f6f7007b6373ad8f42cc03a667aa3fff7a39198a42b458811e477401c2df385a588466cb85b783ffc8cbf6e187d6a8b2cd8b81cc28112
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
 
@@ -37,6 +37,9 @@ require "flowengine"
37
37
 
38
38
  # 1. Define a flow
39
39
  definition = FlowEngine.define do
40
+ introduction label: "What are your favorite cocktails?",
41
+ placeholder: "Old Fashion, Whisky Sour, etc",
42
+ maxlength: 2000
40
43
  start :name
41
44
 
42
45
  step :name do
@@ -95,7 +98,123 @@ engine.history
95
98
 
96
99
  ### Using the `flowengine-cli` gem to Generate the JSON Answers File
97
100
 
101
+ ## LLM-parsed Introduction
98
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
+ ```
99
218
 
100
219
  ## Architecture
101
220
 
@@ -108,13 +227,18 @@ The core has **zero UI logic**, **zero DB logic**, and **zero framework dependen
108
227
  | Component | Responsibility |
109
228
  |-----------|---------------|
110
229
  | `FlowEngine.define` | DSL entry point; returns a frozen `Definition` |
111
- | `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) |
112
232
  | `Node` | A single step: type, question, options/fields, transitions, visibility |
113
233
  | `Transition` | A directed edge with an optional rule condition |
114
234
  | `Rules::*` | AST nodes for conditional logic (`Contains`, `Equals`, `All`, etc.) |
115
235
  | `Evaluator` | Evaluates rules against the current answer store |
116
- | `Engine` | Stateful runtime: tracks current step, answers, and history |
236
+ | `Engine` | Stateful runtime: tracks current step, answers, history, and introduction |
117
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 |
118
242
  | `Graph::MermaidExporter` | Exports the flow definition as a Mermaid diagram |
119
243
 
120
244
  ## The DSL
@@ -127,6 +251,11 @@ Every flow starts with `FlowEngine.define`, which returns a **frozen, immutable*
127
251
  definition = FlowEngine.define do
128
252
  start :first_step # Required: which node to begin at
129
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
+
130
259
  step :first_step do
131
260
  # step configuration...
132
261
  end
@@ -311,9 +440,11 @@ engine = FlowEngine::Engine.new(definition)
311
440
  | `engine.current_step_id` | `Symbol` or `nil` | The ID of the current step |
312
441
  | `engine.current_step` | `Node` or `nil` | The current Node object |
313
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 |
314
444
  | `engine.finished?` | `Boolean` | `true` when there are no more steps |
315
445
  | `engine.answers` | `Hash` | All collected answers `{ step_id => value }` |
316
446
  | `engine.history` | `Array<Symbol>` | Ordered list of visited step IDs |
447
+ | `engine.introduction_text` | `String` or `nil` | The raw introduction text submitted |
317
448
  | `engine.definition` | `Definition` | The immutable flow definition |
318
449
 
319
450
  ### Error Handling
@@ -336,6 +467,18 @@ FlowEngine.define do
336
467
  end
337
468
  end
338
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
339
482
  ```
340
483
 
341
484
  ## Validation
@@ -896,7 +1039,7 @@ FlowEngine is the core of a three-gem architecture:
896
1039
 
897
1040
  | Gem | Purpose |
898
1041
  |-----|---------|
899
- | **`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`) |
900
1043
  | **`flowengine-cli`** | Terminal wizard adapter using [TTY Toolkit](https://ttytoolkit.org/) + Dry::CLI |
901
1044
  | **`flowengine-rails`** | Rails Engine with ActiveRecord persistence and web views |
902
1045
 
@@ -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
@@ -41,6 +41,12 @@ doc:
41
41
  @bundle exec rake doc
42
42
  @open ./doc/index.html
43
43
 
44
+ clean:
45
+ @rm -rf pkg
46
+ @rm -rf coverage
47
+
48
+ release:
49
+ @bundle exec rake release
44
50
 
45
51
  check-all: lint test
46
52
 
@@ -7,14 +7,16 @@ module FlowEngine
7
7
  # @attr_reader start_step_id [Symbol] id of the first step in the flow
8
8
  # @attr_reader steps [Hash<Symbol, Node>] frozen map of step id => node (read-only)
9
9
  class Definition
10
- attr_reader :start_step_id, :steps
10
+ attr_reader :start_step_id, :steps, :introduction
11
11
 
12
12
  # @param start_step_id [Symbol] id of the initial step
13
13
  # @param nodes [Hash<Symbol, Node>] all steps keyed by id
14
+ # @param introduction [Introduction, nil] optional introduction config (label + placeholder)
14
15
  # @raise [DefinitionError] if start_step_id is not present in nodes
15
- def initialize(start_step_id:, nodes:)
16
+ def initialize(start_step_id:, nodes:, introduction: nil)
16
17
  @start_step_id = start_step_id
17
18
  @steps = nodes.freeze
19
+ @introduction = introduction
18
20
  validate!
19
21
  freeze
20
22
  end
@@ -10,6 +10,7 @@ module FlowEngine
10
10
  def initialize
11
11
  @start_step_id = nil
12
12
  @nodes = {}
13
+ @introduction = nil
13
14
  end
14
15
 
15
16
  # Sets the entry step id for the flow.
@@ -19,6 +20,16 @@ module FlowEngine
19
20
  @start_step_id = step_id
20
21
  end
21
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
+
22
33
  # Defines one step by id; the block is evaluated in a {StepBuilder} context.
23
34
  #
24
35
  # @param id [Symbol] step id
@@ -37,7 +48,7 @@ module FlowEngine
37
48
  raise DefinitionError, "No start step defined" if @start_step_id.nil?
38
49
  raise DefinitionError, "No steps defined" if @nodes.empty?
39
50
 
40
- Definition.new(start_step_id: @start_step_id, nodes: @nodes)
51
+ Definition.new(start_step_id: @start_step_id, nodes: @nodes, introduction: @introduction)
41
52
  end
42
53
  end
43
54
  end
@@ -8,8 +8,9 @@ module FlowEngine
8
8
  # @attr_reader answers [Hash] step_id => value (mutable as user answers)
9
9
  # @attr_reader history [Array<Symbol>] ordered list of step ids visited (including current)
10
10
  # @attr_reader current_step_id [Symbol, nil] current step id, or nil when flow is finished
11
+ # @attr_reader introduction_text [String, nil] free-form text submitted before the flow began
11
12
  class Engine
12
- attr_reader :definition, :answers, :history, :current_step_id
13
+ attr_reader :definition, :answers, :history, :current_step_id, :introduction_text
13
14
 
14
15
  # @param definition [Definition] the flow to run
15
16
  # @param validator [Validation::Adapter] validator for step answers (default: {Validation::NullAdapter})
@@ -19,6 +20,7 @@ module FlowEngine
19
20
  @history = []
20
21
  @current_step_id = definition.start_step_id
21
22
  @validator = validator
23
+ @introduction_text = nil
22
24
  @history << @current_step_id
23
25
  end
24
26
 
@@ -49,14 +51,32 @@ module FlowEngine
49
51
  advance_step
50
52
  end
51
53
 
54
+ # Submits free-form introduction text, filters sensitive data, calls the LLM
55
+ # to extract answers, and auto-advances through pre-filled steps.
56
+ #
57
+ # @param text [String] user's free-form introduction
58
+ # @param llm_client [LLM::Client] configured LLM client for parsing
59
+ # @raise [SensitiveDataError] if text contains SSN, ITIN, EIN, etc.
60
+ # @raise [ValidationError] if text exceeds the introduction maxlength
61
+ # @raise [LLMError] on LLM communication or parsing failures
62
+ def submit_introduction(text, llm_client:)
63
+ validate_introduction_length!(text)
64
+ LLM::SensitiveDataFilter.check!(text)
65
+ @introduction_text = text
66
+ extracted = llm_client.parse_introduction(definition: @definition, introduction_text: text)
67
+ @answers.merge!(extracted)
68
+ auto_advance_prefilled
69
+ end
70
+
52
71
  # Serializable state for persistence or resumption.
53
72
  #
54
- # @return [Hash] current_step_id, answers, and history (string/symbol keys as stored)
73
+ # @return [Hash] current_step_id, answers, history, and introduction_text
55
74
  def to_state
56
75
  {
57
76
  current_step_id: @current_step_id,
58
77
  answers: @answers,
59
- history: @history
78
+ history: @history,
79
+ introduction_text: @introduction_text
60
80
  }
61
81
  end
62
82
 
@@ -115,6 +135,7 @@ module FlowEngine
115
135
  @current_step_id = state[:current_step_id]
116
136
  @answers = state[:answers] || {}
117
137
  @history = state[:history] || []
138
+ @introduction_text = state[:introduction_text]
118
139
  end
119
140
 
120
141
  def advance_step
@@ -124,5 +145,19 @@ module FlowEngine
124
145
  @current_step_id = next_id
125
146
  @history << next_id if next_id
126
147
  end
148
+
149
+ def validate_introduction_length!(text)
150
+ maxlength = @definition.introduction&.maxlength
151
+ return unless maxlength
152
+ return if text.length <= maxlength
153
+
154
+ raise ValidationError, "Introduction text exceeds maxlength (#{text.length}/#{maxlength})"
155
+ end
156
+
157
+ # Advances through consecutive steps that already have pre-filled answers.
158
+ # Stops at the first step without a pre-filled answer or when the flow ends.
159
+ def auto_advance_prefilled
160
+ advance_step while @current_step_id && @answers.key?(@current_step_id)
161
+ end
127
162
  end
128
163
  end
@@ -18,4 +18,10 @@ module FlowEngine
18
18
 
19
19
  # Raised when the validator rejects the user's answer for the current step.
20
20
  class ValidationError < EngineError; end
21
+
22
+ # Raised for LLM-related errors (missing API key, response parsing, etc.).
23
+ class LLMError < Error; end
24
+
25
+ # Raised when introduction text contains sensitive data (SSN, ITIN, EIN, etc.).
26
+ class SensitiveDataError < EngineError; end
21
27
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ # Immutable introduction configuration for a flow. When present in a Definition,
5
+ # indicates the UI should collect free-form text before the first step.
6
+ # The label is shown above the input field; the placeholder appears inside it.
7
+ # maxlength limits the character count of the free-form text (nil = unlimited).
8
+ Introduction = Data.define(:label, :placeholder, :maxlength) do
9
+ def initialize(label:, placeholder: "", maxlength: nil)
10
+ super
11
+ freeze
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ module LLM
5
+ # Abstract adapter for LLM API calls. Subclass and implement {#chat}
6
+ # to integrate with a specific provider (OpenAI, Anthropic, etc.).
7
+ class Adapter
8
+ # Sends a system + user prompt pair to the LLM and returns the response text.
9
+ #
10
+ # @param system_prompt [String] system instructions for the LLM
11
+ # @param user_prompt [String] user's introduction text
12
+ # @param model [String] model identifier (e.g. "gpt-4o-mini")
13
+ # @return [String] the LLM's response text (expected to be JSON)
14
+ def chat(system_prompt:, user_prompt:, model:)
15
+ raise NotImplementedError, "#{self.class}#chat must be implemented"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module FlowEngine
6
+ module LLM
7
+ # High-level LLM client that parses introduction text into pre-filled answers.
8
+ # Wraps an {Adapter} and a model name, builds the system prompt from the
9
+ # flow Definition, and parses the structured JSON response.
10
+ class Client
11
+ attr_reader :adapter, :model
12
+
13
+ # @param adapter [Adapter] LLM provider adapter (e.g. OpenAIAdapter)
14
+ # @param model [String] model identifier (default: "gpt-4o-mini")
15
+ def initialize(adapter:, model: "gpt-4o-mini")
16
+ @adapter = adapter
17
+ @model = model
18
+ end
19
+
20
+ # Sends the introduction text to the LLM with a system prompt built from
21
+ # the Definition, and returns a hash of extracted step answers.
22
+ #
23
+ # @param definition [Definition] flow definition (used to build system prompt)
24
+ # @param introduction_text [String] user's free-form introduction
25
+ # @return [Hash<Symbol, Object>] step_id => extracted value
26
+ # @raise [LLMError] on response parsing failures
27
+ def parse_introduction(definition:, introduction_text:)
28
+ system_prompt = SystemPromptBuilder.new(definition).build
29
+ response_text = adapter.chat(
30
+ system_prompt: system_prompt,
31
+ user_prompt: introduction_text,
32
+ model: model
33
+ )
34
+ parse_response(response_text, definition)
35
+ end
36
+
37
+ private
38
+
39
+ def parse_response(text, definition)
40
+ json_str = extract_json(text)
41
+ raw = JSON.parse(json_str, symbolize_names: true)
42
+
43
+ raw.each_with_object({}) do |(step_id, value), result|
44
+ next unless definition.steps.key?(step_id)
45
+
46
+ node = definition.step(step_id)
47
+ result[step_id] = coerce_value(value, node.type)
48
+ end
49
+ rescue JSON::ParserError => e
50
+ raise LLMError, "Failed to parse LLM response as JSON: #{e.message}"
51
+ end
52
+
53
+ def extract_json(text)
54
+ # LLM may wrap JSON in markdown code fences
55
+ match = text.match(/```(?:json)?\s*\n?(.*?)\n?\s*```/m)
56
+ match ? match[1].strip : text.strip
57
+ end
58
+
59
+ def coerce_value(value, type)
60
+ case type
61
+ when :number
62
+ value.is_a?(Numeric) ? value : value.to_i
63
+ when :multi_select
64
+ Array(value)
65
+ when :number_matrix
66
+ return {} unless value.is_a?(Hash)
67
+
68
+ value.transform_values { |v| v.is_a?(Numeric) ? v : v.to_i }
69
+ else
70
+ value
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ module FlowEngine
6
+ module LLM
7
+ # OpenAI adapter using the ruby_llm gem. Configures the API key
8
+ # and delegates chat calls to RubyLLM's conversation interface.
9
+ class OpenAIAdapter < Adapter
10
+ # @param api_key [String, nil] OpenAI API key; falls back to OPENAI_API_KEY env var
11
+ # @raise [LLMError] if no API key is available
12
+ def initialize(api_key: nil)
13
+ super()
14
+ @api_key = api_key || ENV.fetch("OPENAI_API_KEY", nil)
15
+ raise LLMError, "OpenAI API key not provided and OPENAI_API_KEY not set" unless @api_key
16
+ end
17
+
18
+ # @param system_prompt [String] system instructions
19
+ # @param user_prompt [String] user's text
20
+ # @param model [String] OpenAI model identifier
21
+ # @return [String] response content from the LLM
22
+ def chat(system_prompt:, user_prompt:, model: "gpt-4o-mini")
23
+ configure_ruby_llm!
24
+ conversation = RubyLLM.chat(model: model)
25
+ response = conversation.with_instructions(system_prompt).ask(user_prompt)
26
+ response.content
27
+ end
28
+
29
+ private
30
+
31
+ def configure_ruby_llm!
32
+ RubyLLM.configure do |config|
33
+ config.openai_api_key = @api_key
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ module LLM
5
+ # Scans introduction text for sensitive data patterns (SSN, ITIN, EIN,
6
+ # bank account numbers) and raises {SensitiveDataError} if any are found.
7
+ # This prevents sensitive information from being sent to an LLM.
8
+ module SensitiveDataFilter
9
+ # SSN: 3 digits, dash, 2 digits, dash, 4 digits (e.g. 123-45-6789)
10
+ SSN_PATTERN = /\b\d{3}-\d{2}-\d{4}\b/
11
+
12
+ # ITIN: 9XX-XX-XXXX where first digit is 9
13
+ ITIN_PATTERN = /\b9\d{2}-\d{2}-\d{4}\b/
14
+
15
+ # EIN: 2 digits, dash, 7 digits (e.g. 12-3456789)
16
+ EIN_PATTERN = /\b\d{2}-\d{7}\b/
17
+
18
+ # Nine consecutive digits (SSN/ITIN without dashes)
19
+ NINE_DIGITS_PATTERN = /\b\d{9}\b/
20
+
21
+ PATTERNS = {
22
+ "SSN" => SSN_PATTERN,
23
+ "ITIN" => ITIN_PATTERN,
24
+ "EIN" => EIN_PATTERN,
25
+ "SSN/ITIN (no dashes)" => NINE_DIGITS_PATTERN
26
+ }.freeze
27
+
28
+ # Checks text for sensitive data patterns.
29
+ #
30
+ # @param text [String] introduction text to scan
31
+ # @raise [SensitiveDataError] if any sensitive patterns are detected
32
+ def self.check!(text)
33
+ detected = PATTERNS.each_with_object([]) do |(label, pattern), found|
34
+ found << label if text.match?(pattern)
35
+ end
36
+
37
+ return if detected.empty?
38
+
39
+ raise SensitiveDataError,
40
+ "Introduction contains sensitive information (#{detected.join(", ")}). " \
41
+ "Please remove all SSN, ITIN, EIN, and account numbers before proceeding."
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ module LLM
5
+ # Builds the system prompt for the LLM from the static template
6
+ # and dynamic step metadata from the flow Definition.
7
+ class SystemPromptBuilder
8
+ TEMPLATE_PATH = File.expand_path("../../../resources/prompts/generic-dsl-intake.j2", __dir__)
9
+
10
+ # @param definition [Definition] flow definition to describe
11
+ # @param template_path [String] path to the static prompt template
12
+ def initialize(definition, template_path: TEMPLATE_PATH)
13
+ @definition = definition
14
+ @template_path = template_path
15
+ end
16
+
17
+ # @return [String] complete system prompt (static template + step descriptions + response format)
18
+ def build
19
+ [static_prompt, steps_description, response_format].join("\n\n")
20
+ end
21
+
22
+ private
23
+
24
+ def static_prompt
25
+ File.read(@template_path)
26
+ end
27
+
28
+ def steps_description
29
+ lines = ["## Flow Steps\n"]
30
+ @definition.steps.each_value { |node| append_step_description(lines, node) }
31
+ lines.join("\n")
32
+ end
33
+
34
+ def append_step_description(lines, node)
35
+ lines << "### Step: `#{node.id}`"
36
+ lines << "- **Type**: #{node.type}"
37
+ lines << "- **Question**: #{node.question}"
38
+ append_options(lines, node) if node.options&.any?
39
+ lines << "- **Fields**: #{node.fields.join(", ")}" if node.fields&.any?
40
+ lines << ""
41
+ end
42
+
43
+ def append_options(lines, node)
44
+ if node.option_labels
45
+ formatted = node.option_labels.map { |key, label| "#{key} (#{label})" }.join(", ")
46
+ lines << "- **Options**: #{formatted}"
47
+ lines << "- **Use the option keys in your response, not the labels**"
48
+ else
49
+ lines << "- **Options**: #{node.options.join(", ")}"
50
+ end
51
+ end
52
+
53
+ def response_format
54
+ <<~PROMPT
55
+ ## Response Format
56
+
57
+ Respond with ONLY a valid JSON object mapping step IDs (as strings) to extracted values.
58
+ Only include steps where you can confidently extract an answer from the user's text.
59
+ Do not guess or fabricate answers. If unsure, omit that step.
60
+
61
+ Value types by step type:
62
+ - `single_select`: one of the listed option strings
63
+ - `multi_select`: an array of matching option strings
64
+ - `number`: an integer
65
+ - `text`: extracted text string
66
+ - `number_matrix`: a hash mapping field names to integers (e.g. {"RealEstate": 2, "LLC": 1})
67
+
68
+ Example: {"filing_status": "single", "dependents": 2, "income_types": ["W2", "Business"]}
69
+ PROMPT
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "llm/adapter"
4
+ require_relative "llm/openai_adapter"
5
+ require_relative "llm/sensitive_data_filter"
6
+ require_relative "llm/system_prompt_builder"
7
+ require_relative "llm/client"
8
+
9
+ module FlowEngine
10
+ # Namespace for LLM integration: adapters, system prompt building,
11
+ # sensitive data filtering, and the high-level Client.
12
+ module LLM
13
+ end
14
+ end
@@ -7,18 +7,19 @@ module FlowEngine
7
7
  # @attr_reader id [Symbol] unique step identifier
8
8
  # @attr_reader type [Symbol] input type (e.g. :multi_select, :number_matrix)
9
9
  # @attr_reader question [String] prompt text for the step
10
- # @attr_reader options [Array, nil] choices for multi_select; nil for other types
10
+ # @attr_reader options [Array, nil] option keys for select steps; nil for other types
11
+ # @attr_reader option_labels [Hash, nil] key => display label mapping (nil when options are plain strings)
11
12
  # @attr_reader fields [Array, nil] field names for number_matrix etc.; nil otherwise
12
13
  # @attr_reader transitions [Array<Transition>] ordered list of conditional next-step rules
13
14
  # @attr_reader visibility_rule [Rules::Base, nil] rule controlling whether this node is visible (DAG mode)
14
15
  class Node
15
- attr_reader :id, :type, :question, :options, :fields, :transitions, :visibility_rule
16
+ attr_reader :id, :type, :question, :options, :option_labels, :fields, :transitions, :visibility_rule
16
17
 
17
18
  # @param id [Symbol] step id
18
19
  # @param type [Symbol] step/input type
19
20
  # @param question [String] label/prompt
20
21
  # @param decorations [Object, nil] optional UI decorations (not used by engine)
21
- # @param options [Array, nil] option list for multi_select
22
+ # @param options [Array, Hash, nil] option list or key=>label hash for select steps
22
23
  # @param fields [Array, nil] field list for matrix-style steps
23
24
  # @param transitions [Array<Transition>] conditional next-step transitions (default: [])
24
25
  # @param visibility_rule [Rules::Base, nil] optional rule for visibility (default: always visible)
@@ -34,7 +35,7 @@ module FlowEngine
34
35
  @type = type
35
36
  @question = question
36
37
  @decorations = decorations
37
- @options = options&.freeze
38
+ extract_options(options)
38
39
  @fields = fields&.freeze
39
40
  @transitions = transitions.freeze
40
41
  @visibility_rule = visibility_rule
@@ -59,5 +60,23 @@ module FlowEngine
59
60
 
60
61
  visibility_rule.evaluate(answers)
61
62
  end
63
+
64
+ private
65
+
66
+ # Normalizes options: a Hash is split into keys (options) and the full hash (option_labels);
67
+ # an Array is stored as-is with nil option_labels.
68
+ def extract_options(raw)
69
+ case raw
70
+ when Hash
71
+ @options = raw.keys.map(&:to_s).freeze
72
+ @option_labels = raw.transform_keys(&:to_s).freeze
73
+ when Array
74
+ @options = raw.freeze
75
+ @option_labels = nil
76
+ else
77
+ @options = nil
78
+ @option_labels = nil
79
+ end
80
+ end
62
81
  end
63
82
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module FlowEngine
4
4
  # Semantic version of the flowengine gem (major.minor.patch).
5
- VERSION = "0.1.2"
5
+ VERSION = "0.2.1"
6
6
  end
data/lib/flowengine.rb CHANGED
@@ -13,12 +13,14 @@ require_relative "flowengine/rules/any"
13
13
  require_relative "flowengine/evaluator"
14
14
  require_relative "flowengine/transition"
15
15
  require_relative "flowengine/node"
16
+ require_relative "flowengine/introduction"
16
17
  require_relative "flowengine/definition"
17
18
  require_relative "flowengine/validation/adapter"
18
19
  require_relative "flowengine/validation/null_adapter"
19
20
  require_relative "flowengine/engine"
20
21
  require_relative "flowengine/dsl"
21
22
  require_relative "flowengine/graph/mermaid_exporter"
23
+ require_relative "flowengine/llm"
22
24
 
23
25
  # Declarative flow definition and execution engine for wizards, intake forms, and
24
26
  # multi-step decision graphs. Separates flow logic, data schema, and UI rendering.
@@ -0,0 +1,60 @@
1
+ ## Context
2
+
3
+ You are a generic intake assistant for a professional services firm. You are given a Ruby DSL that defines the intake flow. You do not need to run the flow, but you need to understand the questions and it's structure.
4
+ The gem will follow the flow to ask the questions in the correct order and will fill out the JSON data structure that is defined by the DSL,
5
+ and keep asking question until all required questions are answered.
6
+
7
+ ## Instructions for LLM
8
+
9
+ I'd like to add a new DSL command called `introduction` with sub-arguments `label` (something that's shown above the input field) and
10
+ `placeholder` which is the text that will show up inside the text area before the user starts typing.
11
+
12
+ If this field is present in the DSL, we are to collect user's free-form text into a new field `engine.introduction()`.
13
+
14
+ Before the first step begins we must check if the introduction is non-empty, and if so the gem should take that response and via a AI Wrapper class that's instantiated with the name of the LLM model and API key, and adapter for different LLM APIs, should invoke whatever adapter is passed. For now let's create only OpenAI adapter. This class will use RubyLLM or any other gem that works to call OpenAI API. The user prompt will be the context of the user entry in `engine.introduction`. The system prompt is this file.
15
+
16
+ ## What is the purpose of this step?
17
+
18
+ The gem currently has:
19
+
20
+ 1. DSL → Ruby objects (FlowEngine.define { ... } → Definition/Node/Transition/Rule objects)
21
+ 2. DSL from string (FlowEngine.load_dsl(text) — evaluates Ruby source code, not JSON)
22
+ 3. Engine state serialization (Engine#to_state / Engine.from_state — a simple hash of current_step_id, answers, history)
23
+ 4. Mermaid export (Graph::MermaidExporter — outputs diagram syntax)
24
+
25
+ The answers the user provides are stored in memory only — in the Engine instance's @answers hash (Hash<Symbol, Object>).
26
+
27
+ ```ruby
28
+ engine = FlowEngine::Engine.new(definition)
29
+ engine.answer("Alice") # stores { name: "Alice" }
30
+ engine.answer(25) # stores { name: "Alice", age: 25 }
31
+ engine.answers # => { name: "Alice", age: 25 }
32
+ ```
33
+
34
+ ### How the Data is Stored
35
+
36
+ The gem provides `Engine#to_state` which returns a plain Ruby hash:
37
+
38
+ ```ruby
39
+ { current_step_id: :age, answers: { name: "Alice" }, history: [:name, :age] }`
40
+ ```
41
+
42
+ And `Engine.from_state(definition, hash)` to restore from it.
43
+
44
+ ### The job of the LLM
45
+
46
+ The job of the LLM is to parse the user's introduction and to identify the DSL steps that the user already provided the answers for, and fill them in.
47
+ If the answer can be extracted from the text, it should be stored in the engine, and that question should be skipped in the normal flow.
48
+
49
+ ## Rules
50
+
51
+ - NEVER ask for sensitive information: SSN, ITIN, full address, bank account numbers, or date of birth.
52
+ - REJECT any sensitive information, and repeat the introduction step if it contains SSN/EIN
53
+ - In other words, if the user volunteers sensitive information, immediately warn them and discard it
54
+ - Do not communicate with the user. Your job is to parse their response and place it into the appropriate answers within the DSL.
55
+
56
+ ## API KEY
57
+
58
+ Check environment variables such as OPENAI_API_KEY before calling LLM.
59
+
60
+
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flowengine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Konstantin Gredeskoul
@@ -9,6 +9,34 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ruby_llm
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rspec
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
12
40
  - !ruby/object:Gem::Dependency
13
41
  name: rspec-its
14
42
  requirement: !ruby/object:Gem::Requirement
@@ -23,6 +51,20 @@ dependencies:
23
51
  - - "~>"
24
52
  - !ruby/object:Gem::Version
25
53
  version: '2.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rubocop
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
26
68
  - !ruby/object:Gem::Dependency
27
69
  name: simplecov
28
70
  requirement: !ruby/object:Gem::Requirement
@@ -61,12 +103,13 @@ executables:
61
103
  extensions: []
62
104
  extra_rdoc_files: []
63
105
  files:
106
+ - ".env.example"
64
107
  - ".envrc"
65
108
  - ".rubocop_todo.yml"
66
- - CHANGELOG.md
67
109
  - LICENSE.txt
68
110
  - README.md
69
111
  - Rakefile
112
+ - docs/badges/coverage_badge.svg
70
113
  - docs/floweingine-architecture.png
71
114
  - docs/flowengine-example.png
72
115
  - exe/flowengine
@@ -81,6 +124,13 @@ files:
81
124
  - lib/flowengine/errors.rb
82
125
  - lib/flowengine/evaluator.rb
83
126
  - lib/flowengine/graph/mermaid_exporter.rb
127
+ - lib/flowengine/introduction.rb
128
+ - lib/flowengine/llm.rb
129
+ - lib/flowengine/llm/adapter.rb
130
+ - lib/flowengine/llm/client.rb
131
+ - lib/flowengine/llm/openai_adapter.rb
132
+ - lib/flowengine/llm/sensitive_data_filter.rb
133
+ - lib/flowengine/llm/system_prompt_builder.rb
84
134
  - lib/flowengine/node.rb
85
135
  - lib/flowengine/rules/all.rb
86
136
  - lib/flowengine/rules/any.rb
@@ -94,6 +144,7 @@ files:
94
144
  - lib/flowengine/validation/adapter.rb
95
145
  - lib/flowengine/validation/null_adapter.rb
96
146
  - lib/flowengine/version.rb
147
+ - resources/prompts/generic-dsl-intake.j2
97
148
  - sig/flowengine.rbs
98
149
  homepage: https://github.com/kigster/flowengine
99
150
  licenses:
data/CHANGELOG.md DELETED
@@ -1,5 +0,0 @@
1
- ## [Unreleased]
2
-
3
- ## [0.1.0] - 2026-02-26
4
-
5
- - Initial release