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
@@ -1,28 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FlowEngine
4
+ # Runtime session that drives flow navigation: holds definition, answers, and current step.
5
+ # Validates each answer via an optional {Validation::Adapter}, then advances using node transitions.
6
+ #
7
+ # @attr_reader definition [Definition] immutable flow definition
8
+ # @attr_reader answers [Hash] step_id => value (mutable as user answers)
9
+ # @attr_reader history [Array<Symbol>] ordered list of step ids visited (including current)
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
4
12
  class Engine
5
- attr_reader :definition, :answers, :history, :current_step_id
13
+ attr_reader :definition, :answers, :history, :current_step_id, :introduction_text
6
14
 
15
+ # @param definition [Definition] the flow to run
16
+ # @param validator [Validation::Adapter] validator for step answers (default: {Validation::NullAdapter})
7
17
  def initialize(definition, validator: Validation::NullAdapter.new)
8
18
  @definition = definition
9
19
  @answers = {}
10
20
  @history = []
11
21
  @current_step_id = definition.start_step_id
12
22
  @validator = validator
23
+ @introduction_text = nil
13
24
  @history << @current_step_id
14
25
  end
15
26
 
27
+ # @return [Node, nil] current step node, or nil if flow is finished
16
28
  def current_step
17
29
  return nil if finished?
18
30
 
19
31
  definition.step(@current_step_id)
20
32
  end
21
33
 
34
+ # @return [Boolean] true when there is no current step (flow ended)
22
35
  def finished?
23
36
  @current_step_id.nil?
24
37
  end
25
38
 
39
+ # Submits an answer for the current step, validates it, stores it, and advances to the next step.
40
+ #
41
+ # @param value [Object] user's answer for the current step
42
+ # @raise [AlreadyFinishedError] if the flow has already finished
43
+ # @raise [ValidationError] if the validator rejects the value
26
44
  def answer(value)
27
45
  raise AlreadyFinishedError, "Flow is already finished" if finished?
28
46
 
@@ -33,14 +51,41 @@ module FlowEngine
33
51
  advance_step
34
52
  end
35
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
+
71
+ # Serializable state for persistence or resumption.
72
+ #
73
+ # @return [Hash] current_step_id, answers, history, and introduction_text
36
74
  def to_state
37
75
  {
38
76
  current_step_id: @current_step_id,
39
77
  answers: @answers,
40
- history: @history
78
+ history: @history,
79
+ introduction_text: @introduction_text
41
80
  }
42
81
  end
43
82
 
83
+ # Rebuilds an engine from a previously saved state (e.g. from DB or session).
84
+ #
85
+ # @param definition [Definition] same definition used when state was captured
86
+ # @param state_hash [Hash] hash with :current_step_id, :answers, :history (keys may be strings)
87
+ # @param validator [Validation::Adapter] validator to use (default: NullAdapter)
88
+ # @return [Engine] restored engine instance
44
89
  def self.from_state(definition, state_hash, validator: Validation::NullAdapter.new)
45
90
  state = symbolize_state(state_hash)
46
91
  engine = allocate
@@ -48,6 +93,10 @@ module FlowEngine
48
93
  engine
49
94
  end
50
95
 
96
+ # Normalizes a state hash so step ids and history entries are symbols; answers keys are symbols.
97
+ #
98
+ # @param hash [Hash] raw state (e.g. from JSON)
99
+ # @return [Hash] symbolized state
51
100
  def self.symbolize_state(hash)
52
101
  return hash unless hash.is_a?(Hash)
53
102
 
@@ -66,6 +115,8 @@ module FlowEngine
66
115
  end
67
116
  end
68
117
 
118
+ # @param answers [Hash] answers map (keys may be strings)
119
+ # @return [Hash] same map with symbol keys
69
120
  def self.symbolize_answers(answers)
70
121
  return {} unless answers.is_a?(Hash)
71
122
 
@@ -84,6 +135,7 @@ module FlowEngine
84
135
  @current_step_id = state[:current_step_id]
85
136
  @answers = state[:answers] || {}
86
137
  @history = state[:history] || []
138
+ @introduction_text = state[:introduction_text]
87
139
  end
88
140
 
89
141
  def advance_step
@@ -93,5 +145,19 @@ module FlowEngine
93
145
  @current_step_id = next_id
94
146
  @history << next_id if next_id
95
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
96
162
  end
97
163
  end
@@ -1,10 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FlowEngine
4
+ # Base exception for all flowengine errors.
4
5
  class Error < StandardError; end
6
+
7
+ # Raised when a flow definition is invalid (e.g. missing start step, unknown step reference).
5
8
  class DefinitionError < Error; end
9
+
10
+ # Raised when navigating to or requesting a step id that does not exist in the definition.
6
11
  class UnknownStepError < Error; end
12
+
13
+ # Base exception for runtime engine errors (e.g. validation, already finished).
7
14
  class EngineError < Error; end
15
+
16
+ # Raised when {Engine#answer} is called after the flow has already finished.
8
17
  class AlreadyFinishedError < EngineError; end
18
+
19
+ # Raised when the validator rejects the user's answer for the current step.
9
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
10
27
  end
@@ -1,13 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FlowEngine
4
+ # Evaluates rule AST nodes against a given answer context.
5
+ # Used by transitions and visibility checks; rule objects implement {Rules::Base#evaluate}.
6
+ #
7
+ # @attr_reader answers [Hash] current answer state (step_id => value)
4
8
  class Evaluator
5
9
  attr_reader :answers
6
10
 
11
+ # @param answers [Hash] answer context to evaluate rules against
7
12
  def initialize(answers)
8
13
  @answers = answers
9
14
  end
10
15
 
16
+ # Evaluates a single rule (or returns true if rule is nil).
17
+ #
18
+ # @param rule [Rules::Base, nil] rule to evaluate
19
+ # @return [Boolean] result of rule#evaluate(answers), or true when rule is nil
11
20
  def evaluate(rule)
12
21
  return true if rule.nil?
13
22
 
@@ -2,15 +2,22 @@
2
2
 
3
3
  module FlowEngine
4
4
  module Graph
5
+ # Exports a flow {Definition} to Mermaid flowchart syntax for visualization.
6
+ # Nodes are labeled with truncated question text; edges show condition labels when present.
5
7
  class MermaidExporter
8
+ # Maximum characters for node labels in the diagram (longer text is truncated with "...").
6
9
  MAX_LABEL_LENGTH = 50
7
10
 
8
11
  attr_reader :definition
9
12
 
13
+ # @param definition [Definition] flow to export
10
14
  def initialize(definition)
11
15
  @definition = definition
12
16
  end
13
17
 
18
+ # Generates Mermaid flowchart TD (top-down) source.
19
+ #
20
+ # @return [String] Mermaid diagram source (e.g. for Mermaid.js or docs)
14
21
  def export
15
22
  lines = ["flowchart TD"]
16
23
 
@@ -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
@@ -1,9 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FlowEngine
4
+ # A single step in the flow: question metadata, input config, and conditional transitions.
5
+ # Used by {Engine} to determine the next step and by UI/export to render the step.
6
+ #
7
+ # @attr_reader id [Symbol] unique step identifier
8
+ # @attr_reader type [Symbol] input type (e.g. :multi_select, :number_matrix)
9
+ # @attr_reader question [String] prompt text for the step
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)
12
+ # @attr_reader fields [Array, nil] field names for number_matrix etc.; nil otherwise
13
+ # @attr_reader transitions [Array<Transition>] ordered list of conditional next-step rules
14
+ # @attr_reader visibility_rule [Rules::Base, nil] rule controlling whether this node is visible (DAG mode)
4
15
  class Node
5
- attr_reader :id, :type, :question, :options, :fields, :transitions, :visibility_rule
16
+ attr_reader :id, :type, :question, :options, :option_labels, :fields, :transitions, :visibility_rule
6
17
 
18
+ # @param id [Symbol] step id
19
+ # @param type [Symbol] step/input type
20
+ # @param question [String] label/prompt
21
+ # @param decorations [Object, nil] optional UI decorations (not used by engine)
22
+ # @param options [Array, Hash, nil] option list or key=>label hash for select steps
23
+ # @param fields [Array, nil] field list for matrix-style steps
24
+ # @param transitions [Array<Transition>] conditional next-step transitions (default: [])
25
+ # @param visibility_rule [Rules::Base, nil] optional rule for visibility (default: always visible)
7
26
  def initialize(id:, # rubocop:disable Metrics/ParameterLists
8
27
  type:,
9
28
  question:,
@@ -16,22 +35,48 @@ module FlowEngine
16
35
  @type = type
17
36
  @question = question
18
37
  @decorations = decorations
19
- @options = options&.freeze
38
+ extract_options(options)
20
39
  @fields = fields&.freeze
21
40
  @transitions = transitions.freeze
22
41
  @visibility_rule = visibility_rule
23
42
  freeze
24
43
  end
25
44
 
45
+ # Resolves the next step id from current answers by evaluating transitions in order.
46
+ #
47
+ # @param answers [Hash] current answer state (step_id => value)
48
+ # @return [Symbol, nil] id of the next step, or nil if no transition matches (flow end)
26
49
  def next_step_id(answers)
27
50
  match = transitions.find { |t| t.applies?(answers) }
28
51
  match&.target
29
52
  end
30
53
 
54
+ # Whether this node should be considered visible given current answers (for DAG/visibility).
55
+ #
56
+ # @param answers [Hash] current answer state
57
+ # @return [Boolean] true if no visibility_rule, else result of rule evaluation
31
58
  def visible?(answers)
32
59
  return true if visibility_rule.nil?
33
60
 
34
61
  visibility_rule.evaluate(answers)
35
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
36
81
  end
37
82
  end
@@ -2,19 +2,26 @@
2
2
 
3
3
  module FlowEngine
4
4
  module Rules
5
+ # Composite rule: logical AND of multiple sub-rules. All must be true.
6
+ #
7
+ # @attr_reader rules [Array<Base>] sub-rules to evaluate
5
8
  class All < Base
6
9
  attr_reader :rules
7
10
 
11
+ # @param rules [Array<Base>] one or more rules (arrays are flattened)
8
12
  def initialize(*rules)
9
13
  super()
10
14
  @rules = rules.flatten.freeze
11
15
  freeze
12
16
  end
13
17
 
18
+ # @param answers [Hash] current answers
19
+ # @return [Boolean] true if every sub-rule evaluates to true
14
20
  def evaluate(answers)
15
21
  rules.all? { |rule| rule.evaluate(answers) }
16
22
  end
17
23
 
24
+ # @return [String] e.g. "(rule1 AND rule2 AND rule3)"
18
25
  def to_s
19
26
  "(#{rules.join(" AND ")})"
20
27
  end
@@ -2,19 +2,26 @@
2
2
 
3
3
  module FlowEngine
4
4
  module Rules
5
+ # Composite rule: logical OR of multiple sub-rules. At least one must be true.
6
+ #
7
+ # @attr_reader rules [Array<Base>] sub-rules to evaluate
5
8
  class Any < Base
6
9
  attr_reader :rules
7
10
 
11
+ # @param rules [Array<Base>] one or more rules (arrays are flattened)
8
12
  def initialize(*rules)
9
13
  super()
10
14
  @rules = rules.flatten.freeze
11
15
  freeze
12
16
  end
13
17
 
18
+ # @param answers [Hash] current answers
19
+ # @return [Boolean] true if any sub-rule evaluates to true
14
20
  def evaluate(answers)
15
21
  rules.any? { |rule| rule.evaluate(answers) }
16
22
  end
17
23
 
24
+ # @return [String] e.g. "(rule1 OR rule2 OR rule3)"
18
25
  def to_s
19
26
  "(#{rules.join(" OR ")})"
20
27
  end
@@ -2,11 +2,20 @@
2
2
 
3
3
  module FlowEngine
4
4
  module Rules
5
+ # Abstract base for rule AST nodes. Subclasses implement {#evaluate} and {#to_s}
6
+ # for use in transitions and visibility conditions.
5
7
  class Base
8
+ # Evaluates the rule against the current answer context.
9
+ #
10
+ # @param _answers [Hash] step_id => value
11
+ # @return [Boolean]
6
12
  def evaluate(_answers)
7
13
  raise NotImplementedError, "#{self.class}#evaluate must be implemented"
8
14
  end
9
15
 
16
+ # Human-readable representation (e.g. for graph labels).
17
+ #
18
+ # @return [String]
10
19
  def to_s
11
20
  raise NotImplementedError, "#{self.class}#to_s must be implemented"
12
21
  end
@@ -2,9 +2,16 @@
2
2
 
3
3
  module FlowEngine
4
4
  module Rules
5
+ # Rule: the answer for the given field (as an array) includes the given value.
6
+ # Used for multi-select steps (e.g. "BusinessOwnership in earnings").
7
+ #
8
+ # @attr_reader field [Symbol] answer key (step id)
9
+ # @attr_reader value [Object] value that must be present in the array
5
10
  class Contains < Base
6
11
  attr_reader :field, :value
7
12
 
13
+ # @param field [Symbol] answer key
14
+ # @param value [Object] value to check for
8
15
  def initialize(field, value)
9
16
  super()
10
17
  @field = field
@@ -12,10 +19,13 @@ module FlowEngine
12
19
  freeze
13
20
  end
14
21
 
22
+ # @param answers [Hash] current answers
23
+ # @return [Boolean] true if answers[field] (as array) includes value
15
24
  def evaluate(answers)
16
25
  Array(answers[field]).include?(value)
17
26
  end
18
27
 
28
+ # @return [String] e.g. "BusinessOwnership in earnings"
19
29
  def to_s
20
30
  "#{value} in #{field}"
21
31
  end