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
@@ -2,9 +2,15 @@
2
2
 
3
3
  module FlowEngine
4
4
  module Rules
5
+ # Rule: the answer for the given field equals the given value.
6
+ #
7
+ # @attr_reader field [Symbol] answer key
8
+ # @attr_reader value [Object] expected value
5
9
  class Equals < Base
6
10
  attr_reader :field, :value
7
11
 
12
+ # @param field [Symbol] answer key
13
+ # @param value [Object] expected value
8
14
  def initialize(field, value)
9
15
  super()
10
16
  @field = field
@@ -12,10 +18,13 @@ module FlowEngine
12
18
  freeze
13
19
  end
14
20
 
21
+ # @param answers [Hash] current answers
22
+ # @return [Boolean] true if answers[field] == value
15
23
  def evaluate(answers)
16
24
  answers[field] == value
17
25
  end
18
26
 
27
+ # @return [String] e.g. "marital_status == Married"
19
28
  def to_s
20
29
  "#{field} == #{value}"
21
30
  end
@@ -2,9 +2,15 @@
2
2
 
3
3
  module FlowEngine
4
4
  module Rules
5
+ # Rule: the answer for the given field (coerced to integer) is greater than the threshold.
6
+ #
7
+ # @attr_reader field [Symbol] answer key
8
+ # @attr_reader value [Integer] threshold
5
9
  class GreaterThan < Base
6
10
  attr_reader :field, :value
7
11
 
12
+ # @param field [Symbol] answer key
13
+ # @param value [Integer] threshold
8
14
  def initialize(field, value)
9
15
  super()
10
16
  @field = field
@@ -12,10 +18,13 @@ module FlowEngine
12
18
  freeze
13
19
  end
14
20
 
21
+ # @param answers [Hash] current answers (field value is coerced with to_i)
22
+ # @return [Boolean] true if answers[field].to_i > value
15
23
  def evaluate(answers)
16
24
  answers[field].to_i > value
17
25
  end
18
26
 
27
+ # @return [String] e.g. "business_income > 100000"
19
28
  def to_s
20
29
  "#{field} > #{value}"
21
30
  end
@@ -2,9 +2,15 @@
2
2
 
3
3
  module FlowEngine
4
4
  module Rules
5
+ # Rule: the answer for the given field (coerced to integer) is less than the threshold.
6
+ #
7
+ # @attr_reader field [Symbol] answer key
8
+ # @attr_reader value [Integer] threshold
5
9
  class LessThan < Base
6
10
  attr_reader :field, :value
7
11
 
12
+ # @param field [Symbol] answer key
13
+ # @param value [Integer] threshold
8
14
  def initialize(field, value)
9
15
  super()
10
16
  @field = field
@@ -12,10 +18,13 @@ module FlowEngine
12
18
  freeze
13
19
  end
14
20
 
21
+ # @param answers [Hash] current answers (field value is coerced with to_i)
22
+ # @return [Boolean] true if answers[field].to_i < value
15
23
  def evaluate(answers)
16
24
  answers[field].to_i < value
17
25
  end
18
26
 
27
+ # @return [String] e.g. "age < 18"
19
28
  def to_s
20
29
  "#{field} < #{value}"
21
30
  end
@@ -2,15 +2,21 @@
2
2
 
3
3
  module FlowEngine
4
4
  module Rules
5
+ # Rule: the answer for the given field is present and not empty (nil or empty? => false).
6
+ #
7
+ # @attr_reader field [Symbol] answer key
5
8
  class NotEmpty < Base
6
9
  attr_reader :field
7
10
 
11
+ # @param field [Symbol] answer key
8
12
  def initialize(field)
9
13
  super()
10
14
  @field = field
11
15
  freeze
12
16
  end
13
17
 
18
+ # @param answers [Hash] current answers
19
+ # @return [Boolean] false if nil or empty, true otherwise
14
20
  def evaluate(answers)
15
21
  val = answers[field]
16
22
  return false if val.nil?
@@ -19,6 +25,7 @@ module FlowEngine
19
25
  true
20
26
  end
21
27
 
28
+ # @return [String] e.g. "name is not empty"
22
29
  def to_s
23
30
  "#{field} is not empty"
24
31
  end
@@ -1,19 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FlowEngine
4
+ # A single edge from one step to another, optionally guarded by a rule.
5
+ # Transitions are evaluated in order; the first whose rule is true determines the next step.
6
+ #
7
+ # @attr_reader target [Symbol] id of the step to go to when this transition applies
8
+ # @attr_reader rule [Rules::Base, nil] condition; nil means unconditional (always applies)
4
9
  class Transition
5
10
  attr_reader :target, :rule
6
11
 
12
+ # @param target [Symbol] next step id
13
+ # @param rule [Rules::Base, nil] optional condition (nil = always)
7
14
  def initialize(target:, rule: nil)
8
15
  @target = target
9
16
  @rule = rule
10
17
  freeze
11
18
  end
12
19
 
20
+ # Human-readable label for the condition (e.g. for graph export).
21
+ #
22
+ # @return [String] rule#to_s or "always" when rule is nil
13
23
  def condition_label
14
24
  rule ? rule.to_s : "always"
15
25
  end
16
26
 
27
+ # Whether this transition should be taken given current answers.
28
+ #
29
+ # @param answers [Hash] current answer state
30
+ # @return [Boolean] true if rule is nil or rule evaluates to true
17
31
  def applies?(answers)
18
32
  return true if rule.nil?
19
33
 
@@ -2,21 +2,34 @@
2
2
 
3
3
  module FlowEngine
4
4
  module Validation
5
+ # Result of validating a step answer: either valid or a list of error messages.
6
+ #
7
+ # @attr_reader errors [Array<String>] validation error messages (empty when valid)
5
8
  class Result
6
9
  attr_reader :errors
7
10
 
11
+ # @param valid [Boolean] whether the input passed validation
12
+ # @param errors [Array<String>] error messages (default: [])
8
13
  def initialize(valid:, errors: [])
9
14
  @valid = valid
10
15
  @errors = errors.freeze
11
16
  freeze
12
17
  end
13
18
 
19
+ # @return [Boolean] true if validation passed
14
20
  def valid?
15
21
  @valid
16
22
  end
17
23
  end
18
24
 
25
+ # Abstract adapter for step-level validation. Implement {#validate} to plug in
26
+ # dry-validation, JSON Schema, or other validators; the engine does not depend on a specific one.
19
27
  class Adapter
28
+ # Validates the user's input for the given step.
29
+ #
30
+ # @param _node [Node] the current step (for schema/constraints)
31
+ # @param _input [Object] the value submitted by the user
32
+ # @return [Result] valid: true/false and optional errors list
20
33
  def validate(_node, _input)
21
34
  raise NotImplementedError, "#{self.class}#validate must be implemented"
22
35
  end
@@ -2,7 +2,11 @@
2
2
 
3
3
  module FlowEngine
4
4
  module Validation
5
+ # No-op validator: always accepts any input. Used by default when no validation adapter is given.
5
6
  class NullAdapter < Adapter
7
+ # @param _node [Node] ignored
8
+ # @param _input [Object] ignored
9
+ # @return [Result] always valid with no errors
6
10
  def validate(_node, _input)
7
11
  Result.new(valid: true, errors: [])
8
12
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FlowEngine
4
- VERSION = "0.1.1"
4
+ # Semantic version of the flowengine gem (major.minor.patch).
5
+ VERSION = "0.2.1"
5
6
  end
data/lib/flowengine.rb CHANGED
@@ -13,20 +13,55 @@ 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
 
25
+ # Declarative flow definition and execution engine for wizards, intake forms, and
26
+ # multi-step decision graphs. Separates flow logic, data schema, and UI rendering.
27
+ #
28
+ # @example Define and run a flow
29
+ # definition = FlowEngine.define do
30
+ # start :earnings
31
+ # step :earnings do
32
+ # type :multi_select
33
+ # question "What are your main earnings?"
34
+ # options %w[W2 1099 BusinessOwnership]
35
+ # transition to: :business_details, if: contains(:earnings, "BusinessOwnership")
36
+ # end
37
+ # step :business_details do
38
+ # type :number_matrix
39
+ # question "How many business types?"
40
+ # fields %w[RealEstate SCorp CCorp]
41
+ # end
42
+ # end
43
+ # engine = FlowEngine::Engine.new(definition)
44
+ # engine.answer(["W2", "BusinessOwnership"])
45
+ # engine.current_step_id # => :business_details
46
+ #
23
47
  module FlowEngine
48
+ # Builds an immutable {Definition} from the declarative DSL block.
49
+ #
50
+ # @yield context of {DSL::FlowBuilder} (start, step, and rule helpers)
51
+ # @return [Definition] frozen flow definition with start step and nodes
52
+ # @raise [DefinitionError] if no start step or no steps are defined
24
53
  def self.define(&)
25
54
  builder = DSL::FlowBuilder.new
26
55
  builder.instance_eval(&)
27
56
  builder.build
28
57
  end
29
58
 
59
+ # Evaluates a string of DSL code and returns the resulting definition.
60
+ # Intended for loading flow definitions from files or stored text.
61
+ #
62
+ # @param text [String] Ruby source containing FlowEngine.define { ... }
63
+ # @return [Definition] the definition produced by evaluating the DSL
64
+ # @raise [DefinitionError] on syntax or evaluation errors
30
65
  def self.load_dsl(text)
31
66
  # rubocop:disable Security/Eval
32
67
  eval(text, TOPLEVEL_BINDING.dup, "(dsl)", 1)
@@ -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.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:
data/CHANGELOG.md DELETED
@@ -1,5 +0,0 @@
1
- ## [Unreleased]
2
-
3
- ## [0.1.0] - 2026-02-26
4
-
5
- - Initial release