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 +4 -4
- data/.env.example +1 -0
- data/.envrc +4 -0
- data/.rubocop_todo.yml +3 -8
- data/README.md +147 -4
- data/docs/badges/coverage_badge.svg +21 -0
- data/justfile +6 -0
- data/lib/flowengine/definition.rb +4 -2
- data/lib/flowengine/dsl/flow_builder.rb +12 -1
- data/lib/flowengine/engine.rb +38 -3
- data/lib/flowengine/errors.rb +6 -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 +23 -4
- data/lib/flowengine/version.rb +1 -1
- data/lib/flowengine.rb +2 -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
|
|
|
@@ -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
|
-
| `
|
|
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
|
|
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
|
|
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
|
@@ -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
|
data/lib/flowengine/engine.rb
CHANGED
|
@@ -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,
|
|
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
|
data/lib/flowengine/errors.rb
CHANGED
|
@@ -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
|
data/lib/flowengine/node.rb
CHANGED
|
@@ -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]
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/flowengine/version.rb
CHANGED
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
|
|
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:
|