flowengine 0.3.0 → 0.3.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.
@@ -1,18 +1,80 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ruby_llm"
4
+
3
5
  module FlowEngine
4
6
  module LLM
5
- # Abstract adapter for LLM API calls. Subclass and implement {#chat}
6
- # to integrate with a specific provider (OpenAI, Anthropic, etc.).
7
+ # Abstract adapter for LLM API calls. Subclass and implement the class methods
8
+ # {.api_key_var_name} and {.default_model} to integrate with a specific provider.
9
+ # Thread-safe: RubyLLM configuration is protected by a mutex.
7
10
  class Adapter
11
+ CONFIGURE_MUTEX = Mutex.new
12
+
13
+ attr_reader :api_key, :model, :qualifier, :vendor
14
+
15
+ # @param api_key [String, nil] API key; falls back to env var from {.api_key_var_name}
16
+ # @param model [String, nil] model identifier; falls back to {.default_model}
17
+ # @param qualifier [Symbol] adapter qualifier (:top, :default, :fastest)
18
+ # @raise [Errors::NoAPIKeyFoundError] if no API key is available
19
+ def initialize(api_key: nil, model: nil, qualifier: :default)
20
+ @qualifier = qualifier
21
+ @api_key = api_key || ENV.fetch(self.class.api_key_var_name, nil)
22
+ @model = model || self.class.default_model
23
+ @vendor = self.class.provider
24
+
25
+ unless @api_key
26
+ raise ::FlowEngine::Errors::NoAPIKeyFoundError,
27
+ "#{vendor} API key not available ($#{self.class.api_key_var_name} not set)"
28
+ end
29
+
30
+ configure_ruby_llm!
31
+ freeze
32
+ end
33
+
8
34
  # Sends a system + user prompt pair to the LLM and returns the response text.
9
35
  #
10
36
  # @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"
37
+ # @param user_prompt [String] user's text
38
+ # @param model [String] model identifier (defaults to the adapter's model)
39
+ # @return [String] the LLM's response content
40
+ def chat(system_prompt:, user_prompt:, model: @model)
41
+ conversation = RubyLLM.chat(model: model)
42
+ response = conversation.with_instructions(system_prompt).ask(user_prompt)
43
+ response.content
44
+ end
45
+
46
+ # Derives the provider symbol from the class name.
47
+ # e.g. AnthropicAdapter => :anthropic, OpenAIAdapter => :openai
48
+ #
49
+ # @return [Symbol]
50
+ def self.provider
51
+ name.split("::").last.downcase.gsub("adapter", "").to_sym
52
+ end
53
+
54
+ # @return [String] name of the environment variable for this provider's API key
55
+ def self.api_key_var_name
56
+ raise NotImplementedError, "#{name}.api_key_var_name must be implemented"
57
+ end
58
+
59
+ # @return [String] default model identifier for this provider
60
+ def self.default_model
61
+ raise NotImplementedError, "#{name}.default_model must be implemented"
62
+ end
63
+
64
+ def inspect
65
+ "#<#{self.class.name} vendor=#{vendor} model=#{model} qualifier=#{qualifier}>"
66
+ end
67
+
68
+ alias to_s inspect
69
+
70
+ private
71
+
72
+ def configure_ruby_llm!
73
+ method_name = "#{vendor}_api_key="
74
+ key = api_key
75
+ CONFIGURE_MUTEX.synchronize do
76
+ RubyLLM.configure { |config| config.send(method_name, key) }
77
+ end
16
78
  end
17
79
  end
18
80
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ module LLM
5
+ # Adapter classes are dynamically generated from resources/models.yml.
6
+ # Each vendor entry produces a subclass of {Adapter} with the correct
7
+ # api_key_var_name and default_model. Adding a new LLM provider is just
8
+ # a YAML entry — no Ruby file needed.
9
+ module Adapters
10
+ LLM::VENDOR_CONFIG.each_value do |properties|
11
+ class_name = properties["adapter"].split("::").last
12
+ env_var = properties["var"]
13
+ default = properties["default"]
14
+
15
+ klass = Class.new(Adapter) do
16
+ define_singleton_method(:api_key_var_name) { env_var }
17
+ define_singleton_method(:default_model) { default }
18
+ end
19
+
20
+ const_set(class_name, klass)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ module LLM
5
+ # Client that picks its adapter from the pre-loaded ADAPTERS registry.
6
+ # Requires {LLM.load!} to have been called first.
7
+ class AutoClient < Client
8
+ # @param qualifier [Symbol] which model tier to use (:top, :default, :fastest)
9
+ # @raise [Errors::LLMError] if no adapters have been loaded
10
+ def initialize(qualifier: :default)
11
+ raise ::FlowEngine::Errors::LLMError, "No adapters loaded. Call FlowEngine::LLM.load! first." if ADAPTERS.empty?
12
+
13
+ entry = ADAPTERS.values.first
14
+ adapter = entry[qualifier.to_s]
15
+ super(adapter: adapter, model: adapter.model)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -10,11 +10,11 @@ module FlowEngine
10
10
  class Client
11
11
  attr_reader :adapter, :model
12
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")
13
+ # @param adapter [Adapter] LLM provider adapter (e.g. Adapters::OpenAIAdapter)
14
+ # @param model [String, nil] model identifier; defaults to the adapter's model
15
+ def initialize(adapter:, model: nil)
16
16
  @adapter = adapter
17
- @model = model
17
+ @model = model || adapter.model
18
18
  end
19
19
 
20
20
  # Sends the introduction text to the LLM with a system prompt built from
@@ -23,7 +23,7 @@ module FlowEngine
23
23
  # @param definition [Definition] flow definition (used to build system prompt)
24
24
  # @param introduction_text [String] user's free-form introduction
25
25
  # @return [Hash<Symbol, Object>] step_id => extracted value
26
- # @raise [LLMError] on response parsing failures
26
+ # @raise [Errors::LLMError] on response parsing failures
27
27
  def parse_introduction(definition:, introduction_text:)
28
28
  system_prompt = SystemPromptBuilder.new(definition).build
29
29
  response_text = adapter.chat(
@@ -34,6 +34,35 @@ module FlowEngine
34
34
  parse_response(response_text, definition)
35
35
  end
36
36
 
37
+ # Sends user text to the LLM for an AI intake step and returns both
38
+ # extracted answers and an optional follow-up question.
39
+ #
40
+ # @param definition [Definition] flow definition
41
+ # @param user_text [String] user's free-form text
42
+ # @param answered [Hash<Symbol, Object>] already-answered steps
43
+ # @param conversation_history [Array<Hash>] prior rounds [{role:, text:}]
44
+ # @return [Hash] { answers: Hash<Symbol, Object>, follow_up: String|nil }
45
+ # @raise [Errors::LLMError] on response parsing failures
46
+ def parse_ai_intake(definition:, user_text:, answered: {}, conversation_history: [])
47
+ system_prompt = IntakePromptBuilder.new(
48
+ definition,
49
+ answered: answered,
50
+ conversation_history: conversation_history
51
+ ).build
52
+
53
+ response_text = adapter.chat(
54
+ system_prompt: system_prompt,
55
+ user_prompt: user_text,
56
+ model: model
57
+ )
58
+
59
+ parse_intake_response(response_text, definition)
60
+ end
61
+
62
+ def to_s
63
+ "#<#{self.class.name} adapter=#{adapter} model=#{model}>"
64
+ end
65
+
37
66
  private
38
67
 
39
68
  def parse_response(text, definition)
@@ -47,7 +76,7 @@ module FlowEngine
47
76
  result[step_id] = coerce_value(value, node.type)
48
77
  end
49
78
  rescue JSON::ParserError => e
50
- raise LLMError, "Failed to parse LLM response as JSON: #{e.message}"
79
+ raise ::FlowEngine::Errors::LLMError, "Failed to parse LLM response as JSON: #{e.message}"
51
80
  end
52
81
 
53
82
  def extract_json(text)
@@ -70,6 +99,23 @@ module FlowEngine
70
99
  value
71
100
  end
72
101
  end
102
+
103
+ def parse_intake_response(text, definition)
104
+ json_str = extract_json(text)
105
+ raw = JSON.parse(json_str, symbolize_names: true)
106
+
107
+ answers_raw = raw[:answers] || {}
108
+ answers = answers_raw.each_with_object({}) do |(step_id, value), result|
109
+ next unless definition.steps.key?(step_id)
110
+
111
+ node = definition.step(step_id)
112
+ result[step_id] = coerce_value(value, node.type)
113
+ end
114
+
115
+ { answers: answers, follow_up: raw[:follow_up] }
116
+ rescue JSON::ParserError => e
117
+ raise ::FlowEngine::Errors::LLMError, "Failed to parse LLM response as JSON: #{e.message}"
118
+ end
73
119
  end
74
120
  end
75
121
  end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ module LLM
5
+ # Builds system prompts for AI intake steps. Unlike SystemPromptBuilder (used for
6
+ # the definition-level introduction), this builder is aware of already-answered steps,
7
+ # conversation history, and instructs the LLM to optionally ask follow-up questions.
8
+ class IntakePromptBuilder
9
+ # @param definition [Definition] flow definition
10
+ # @param answered [Hash<Symbol, Object>] already-answered step_id => value pairs
11
+ # @param conversation_history [Array<Hash>] prior rounds: [{role:, text:}, ...]
12
+ def initialize(definition, answered: {}, conversation_history: [])
13
+ @definition = definition
14
+ @answered = answered
15
+ @conversation_history = conversation_history
16
+ end
17
+
18
+ # @return [String] system prompt for the LLM
19
+ def build
20
+ sections = [
21
+ context_section,
22
+ unanswered_steps_section,
23
+ answered_steps_section,
24
+ conversation_history_section,
25
+ response_format_section
26
+ ].compact
27
+
28
+ sections.join("\n\n")
29
+ end
30
+
31
+ private
32
+
33
+ def context_section
34
+ <<~PROMPT
35
+ ## Context
36
+
37
+ You are an intake assistant. The user is providing free-form text to answer
38
+ questions in a structured intake form. Your job is to:
39
+
40
+ 1. Extract as many answers as you can from the user's text
41
+ 2. If critical information is still missing, ask ONE concise follow-up question
42
+ 3. If you have enough information or cannot reasonably ask more, return no follow-up
43
+
44
+ NEVER ask for sensitive information (SSN, ITIN, EIN, bank account numbers, date of birth).
45
+ Do not fabricate answers. Only extract what the user clearly stated.
46
+ PROMPT
47
+ end
48
+
49
+ def unanswered_steps_section
50
+ unanswered = @definition.steps.reject { |id, node| @answered.key?(id) || node.ai_intake? }
51
+ return nil if unanswered.empty?
52
+
53
+ lines = ["## Unanswered Steps (fill these from the user's text)\n"]
54
+ unanswered.each_value { |node| append_step_description(lines, node) }
55
+ lines.join("\n")
56
+ end
57
+
58
+ def answered_steps_section
59
+ return nil if @answered.empty?
60
+
61
+ lines = ["## Already Answered Steps (do not re-ask these)\n"]
62
+ @answered.each do |step_id, value|
63
+ next unless @definition.steps.key?(step_id)
64
+
65
+ node = @definition.step(step_id)
66
+ lines << "- **#{step_id}** (#{node.question}): `#{value.inspect}`"
67
+ end
68
+ lines << ""
69
+ lines.join("\n")
70
+ end
71
+
72
+ def conversation_history_section
73
+ return nil if @conversation_history.empty?
74
+
75
+ lines = ["## Conversation History\n"]
76
+ @conversation_history.each do |entry|
77
+ role = entry[:role] == :user ? "User" : "Assistant"
78
+ lines << "**#{role}**: #{entry[:text]}"
79
+ lines << ""
80
+ end
81
+ lines.join("\n")
82
+ end
83
+
84
+ def response_format_section
85
+ <<~PROMPT
86
+ ## Response Format
87
+
88
+ Respond with ONLY a valid JSON object with two keys:
89
+
90
+ 1. `"answers"` — a JSON object mapping step IDs to extracted values. Only include
91
+ steps where you can confidently extract an answer. Value types:
92
+ - `single_select`: one of the listed option strings
93
+ - `multi_select`: an array of matching option strings
94
+ - `number`: an integer
95
+ - `text`: extracted text string
96
+ - `number_matrix`: a hash mapping field names to integers
97
+
98
+ 2. `"follow_up"` — either a string with ONE concise clarifying question, or `null`
99
+ if you have no more questions. Focus on the most important unanswered step.
100
+
101
+ Example:
102
+ ```json
103
+ {
104
+ "answers": {"filing_status": "married_joint", "dependents": 2},
105
+ "follow_up": "What types of income do you have — W2, 1099, business, or investment?"
106
+ }
107
+ ```
108
+ PROMPT
109
+ end
110
+
111
+ def append_step_description(lines, node)
112
+ lines << "### Step: `#{node.id}`"
113
+ lines << "- **Type**: #{node.type}"
114
+ lines << "- **Question**: #{node.question}"
115
+ append_options(lines, node) if node.options&.any?
116
+ lines << "- **Fields**: #{node.fields.join(", ")}" if node.fields&.any?
117
+ lines << ""
118
+ end
119
+
120
+ def append_options(lines, node)
121
+ if node.option_labels
122
+ formatted = node.option_labels.map { |key, label| "#{key} (#{label})" }.join(", ")
123
+ lines << "- **Options**: #{formatted}"
124
+ lines << "- **Use the option keys in your response, not the labels**"
125
+ else
126
+ lines << "- **Options**: #{node.options.join(", ")}"
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ module LLM
5
+ # Immutable data class representing an LLM provider configuration.
6
+ Provider = Data.define(:name, :env_var, :adapter_class) do
7
+ def available?
8
+ !ENV.fetch(env_var, nil).nil?
9
+ end
10
+ end
11
+ end
12
+ end
@@ -36,7 +36,7 @@ module FlowEngine
36
36
 
37
37
  return if detected.empty?
38
38
 
39
- raise SensitiveDataError,
39
+ raise ::FlowEngine::Errors::SensitiveDataError,
40
40
  "Introduction contains sensitive information (#{detected.join(", ")}). " \
41
41
  "Please remove all SSN, ITIN, EIN, and account numbers before proceeding."
42
42
  end
@@ -1,48 +1,107 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "llm/adapter"
4
- require_relative "llm/openai_adapter"
5
- require_relative "llm/anthropic_adapter"
6
- require_relative "llm/gemini_adapter"
7
- require_relative "llm/sensitive_data_filter"
8
- require_relative "llm/system_prompt_builder"
9
- require_relative "llm/client"
3
+ require "yaml"
10
4
 
11
5
  module FlowEngine
12
6
  # Namespace for LLM integration: adapters, system prompt building,
13
7
  # sensitive data filtering, and the high-level Client.
14
8
  module LLM
15
- # Provider registry: ordered by priority (first match wins).
16
- # Each entry maps an explicit kwarg name to [env_var, adapter_class, default_model].
17
- PROVIDERS = [
18
- [:anthropic_api_key, "ANTHROPIC_API_KEY", AnthropicAdapter, AnthropicAdapter::DEFAULT_MODEL],
19
- [:openai_api_key, "OPENAI_API_KEY", OpenAIAdapter, "gpt-4o-mini"],
20
- [:gemini_api_key, "GEMINI_API_KEY", GeminiAdapter, GeminiAdapter::DEFAULT_MODEL]
21
- ].freeze
22
-
23
- # Builds an adapter and Client by detecting which API key is available
24
- # in the environment. Priority: Anthropic > OpenAI > Gemini.
25
- #
26
- # @param anthropic_api_key [String, nil] explicit Anthropic key
27
- # @param openai_api_key [String, nil] explicit OpenAI key
28
- # @param gemini_api_key [String, nil] explicit Gemini key
29
- # @param model [String, nil] override model; auto-selected if nil
30
- # @return [Client] configured client with the detected adapter
31
- # @raise [LLMError] if no API key is found for any provider
32
- def self.auto_client(anthropic_api_key: nil, openai_api_key: nil, gemini_api_key: nil, model: nil)
33
- explicit_keys = { anthropic_api_key: anthropic_api_key, openai_api_key: openai_api_key,
34
- gemini_api_key: gemini_api_key }
35
-
36
- PROVIDERS.each do |kwarg, env_var, adapter_class, default_model|
37
- key = explicit_keys[kwarg] || ENV.fetch(env_var, nil)
38
- next unless key
39
-
40
- adapter = adapter_class.new(api_key: key)
41
- return Client.new(adapter: adapter, model: model || default_model)
9
+ # Path to the models.yml file (overridable via env var)
10
+ MODELS_YAML_PATH = ENV.fetch(
11
+ "FLOWENGINE_LLM_MODELS_PATH",
12
+ File.join(::FlowEngine::ROOT, "resources", "models.yml")
13
+ )
14
+
15
+ # Vendor config loaded once from models.yml — used by Adapters module
16
+ # to dynamically generate adapter classes and by auto_client for detection.
17
+ VENDOR_CONFIG = (YAML.load_file(MODELS_YAML_PATH).dig("models", "vendors") || {}).freeze
18
+
19
+ # Provider priority for auto-detection: [kwarg_name, env_var, adapter_class_name]
20
+ PROVIDERS = VENDOR_CONFIG.map do |_vendor, properties|
21
+ [properties["var"].downcase.to_sym, properties["var"], properties["adapter"]]
22
+ end.freeze
23
+
24
+ # Pre-loaded adapters registry: { "vendor" => { "qualifier" => adapter } }
25
+ ADAPTERS = {} # rubocop:disable Style/MutableConstant -- intentionally mutable registry
26
+ ADAPTERS_MUTEX = Mutex.new
27
+ QUALIFIERS = %i[top default fastest].freeze
28
+
29
+ class << self
30
+ # Builds an adapter and Client by detecting which API key is available.
31
+ # Priority order matches models.yml vendor order (Anthropic > OpenAI > Gemini).
32
+ #
33
+ # @param anthropic_api_key [String, nil] explicit Anthropic key
34
+ # @param openai_api_key [String, nil] explicit OpenAI key
35
+ # @param gemini_api_key [String, nil] explicit Gemini key
36
+ # @param model [String, nil] override model; adapter default if nil
37
+ # @return [Client] configured client with the detected adapter
38
+ # @raise [Errors::LLMError] if no API key is found for any provider
39
+ def auto_client(anthropic_api_key: nil, openai_api_key: nil, gemini_api_key: nil, model: nil)
40
+ explicit_keys = {
41
+ anthropic_api_key: anthropic_api_key,
42
+ openai_api_key: openai_api_key,
43
+ gemini_api_key: gemini_api_key
44
+ }
45
+
46
+ PROVIDERS.each do |kwarg, env_var, adapter_class_name|
47
+ key = explicit_keys[kwarg] || ENV.fetch(env_var, nil)
48
+ next unless key
49
+
50
+ adapter_class = ::FlowEngine.constantize(adapter_class_name)
51
+ adapter = adapter_class.new(api_key: key)
52
+ return Client.new(adapter: adapter, model: model || adapter.model)
53
+ end
54
+
55
+ env_vars = PROVIDERS.map { |_, env_var, _| env_var }.join(", ")
56
+ raise ::FlowEngine::Errors::LLMError, "No LLM API key found. Set #{env_vars}"
57
+ end
58
+
59
+ # Pre-loads adapters from models.yml into ADAPTERS registry.
60
+ # Call explicitly when you want pre-instantiated adapters (e.g. in bin/ask).
61
+ def load!(file = MODELS_YAML_PATH)
62
+ return if @adapters_loaded
63
+
64
+ raise ::FlowEngine::Errors::ConfigurationError, "Models file #{file} not found" unless file && File.exist?(file)
65
+
66
+ count = 0
67
+ ::YAML.load_file(file)["models"]["vendors"].each_pair do |vendor, properties|
68
+ api_key = ENV.fetch(properties["var"], nil)
69
+ unless api_key
70
+ warn "API key for #{vendor} not found in environment, vendor disabled."
71
+ next
72
+ end
73
+
74
+ adapter_class = ::FlowEngine.constantize(properties["adapter"])
75
+ QUALIFIERS.each do |qualifier|
76
+ model = properties[qualifier.to_s]
77
+ adapter = adapter_class.new(api_key: api_key, model: model, qualifier: qualifier)
78
+ add_adapter(adapter)
79
+ count += 1
80
+ end
81
+ end
82
+ @adapters_loaded = true
83
+ warn "Loaded #{count} adapters for #{ADAPTERS.keys.size} vendors."
84
+ end
85
+
86
+ # Looks up a pre-loaded adapter by vendor and qualifier.
87
+ def [](vendor:, qualifier: :default)
88
+ ADAPTERS.dig(vendor.to_s, qualifier.to_s)
42
89
  end
43
90
 
44
- env_vars = PROVIDERS.map { |_, env_var, _, _| env_var }.join(", ")
45
- raise FlowEngine::LLMError, "No LLM API key found. Set #{env_vars}"
91
+ # Resets the loaded state (useful for testing).
92
+ def reset!
93
+ ADAPTERS_MUTEX.synchronize { ADAPTERS.clear }
94
+ @adapters_loaded = false
95
+ end
96
+
97
+ private
98
+
99
+ def add_adapter(adapter)
100
+ ADAPTERS_MUTEX.synchronize do
101
+ ADAPTERS[adapter.vendor.to_s] ||= {}
102
+ ADAPTERS[adapter.vendor.to_s][adapter.qualifier.to_s] = adapter
103
+ end
104
+ end
46
105
  end
47
106
  end
48
107
  end
@@ -5,15 +5,17 @@ module FlowEngine
5
5
  # Used by {Engine} to determine the next step and by UI/export to render the step.
6
6
  #
7
7
  # @attr_reader id [Symbol] unique step identifier
8
- # @attr_reader type [Symbol] input type (e.g. :multi_select, :number_matrix)
8
+ # @attr_reader type [Symbol] input type (e.g. :multi_select, :number_matrix, :ai_intake)
9
9
  # @attr_reader question [String] prompt text for the step
10
10
  # @attr_reader options [Array, nil] option keys for select steps; nil for other types
11
11
  # @attr_reader option_labels [Hash, nil] key => display label mapping (nil when options are plain strings)
12
12
  # @attr_reader fields [Array, nil] field names for number_matrix etc.; nil otherwise
13
13
  # @attr_reader transitions [Array<Transition>] ordered list of conditional next-step rules
14
14
  # @attr_reader visibility_rule [Rules::Base, nil] rule controlling whether this node is visible (DAG mode)
15
+ # @attr_reader max_clarifications [Integer] max follow-up rounds for :ai_intake steps (default: 0)
15
16
  class Node
16
- attr_reader :id, :type, :question, :options, :option_labels, :fields, :transitions, :visibility_rule
17
+ attr_reader :id, :type, :question, :options, :option_labels, :fields,
18
+ :transitions, :visibility_rule, :max_clarifications
17
19
 
18
20
  # @param id [Symbol] step id
19
21
  # @param type [Symbol] step/input type
@@ -23,6 +25,7 @@ module FlowEngine
23
25
  # @param fields [Array, nil] field list for matrix-style steps
24
26
  # @param transitions [Array<Transition>] conditional next-step transitions (default: [])
25
27
  # @param visibility_rule [Rules::Base, nil] optional rule for visibility (default: always visible)
28
+ # @param max_clarifications [Integer] max follow-up rounds for :ai_intake (default: 0)
26
29
  def initialize(id:, # rubocop:disable Metrics/ParameterLists
27
30
  type:,
28
31
  question:,
@@ -30,7 +33,8 @@ module FlowEngine
30
33
  options: nil,
31
34
  fields: nil,
32
35
  transitions: [],
33
- visibility_rule: nil)
36
+ visibility_rule: nil,
37
+ max_clarifications: 0)
34
38
  @id = id
35
39
  @type = type
36
40
  @question = question
@@ -39,9 +43,15 @@ module FlowEngine
39
43
  @fields = fields&.freeze
40
44
  @transitions = transitions.freeze
41
45
  @visibility_rule = visibility_rule
46
+ @max_clarifications = max_clarifications
42
47
  freeze
43
48
  end
44
49
 
50
+ # @return [Boolean] true if this is an AI intake step
51
+ def ai_intake?
52
+ type == :ai_intake
53
+ end
54
+
45
55
  # Resolves the next step id from current answers by evaluating transitions in order.
46
56
  #
47
57
  # @param answers [Hash] current answer state (step_id => value)
@@ -2,5 +2,5 @@
2
2
 
3
3
  module FlowEngine
4
4
  # Semantic version of the flowengine gem (major.minor.patch).
5
- VERSION = "0.3.0"
5
+ VERSION = "0.3.1"
6
6
  end
data/lib/flowengine.rb CHANGED
@@ -1,26 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "flowengine/version"
4
- require_relative "flowengine/errors"
5
- require_relative "flowengine/rules/base"
6
- require_relative "flowengine/rules/contains"
7
- require_relative "flowengine/rules/equals"
8
- require_relative "flowengine/rules/greater_than"
9
- require_relative "flowengine/rules/less_than"
10
- require_relative "flowengine/rules/not_empty"
11
- require_relative "flowengine/rules/all"
12
- require_relative "flowengine/rules/any"
13
- require_relative "flowengine/evaluator"
14
- require_relative "flowengine/transition"
15
- require_relative "flowengine/node"
16
- require_relative "flowengine/introduction"
17
- require_relative "flowengine/definition"
18
- require_relative "flowengine/validation/adapter"
19
- require_relative "flowengine/validation/null_adapter"
20
- require_relative "flowengine/engine"
21
- require_relative "flowengine/dsl"
22
- require_relative "flowengine/graph/mermaid_exporter"
23
- require_relative "flowengine/llm"
3
+ require "zeitwerk"
4
+
5
+ loader = Zeitwerk::Loader.for_gem
6
+ loader.inflector.inflect(
7
+ "flowengine" => "FlowEngine",
8
+ "llm" => "LLM",
9
+ "dsl" => "DSL"
10
+ )
11
+ loader.setup
24
12
 
25
13
  # Declarative flow definition and execution engine for wizards, intake forms, and
26
14
  # multi-step decision graphs. Separates flow logic, data schema, and UI rendering.
@@ -45,11 +33,14 @@ require_relative "flowengine/llm"
45
33
  # engine.current_step_id # => :business_details
46
34
  #
47
35
  module FlowEngine
36
+ # Root directory of the flowengine gem
37
+ ROOT = File.expand_path("..", __dir__)
38
+
48
39
  # Builds an immutable {Definition} from the declarative DSL block.
49
40
  #
50
41
  # @yield context of {DSL::FlowBuilder} (start, step, and rule helpers)
51
42
  # @return [Definition] frozen flow definition with start step and nodes
52
- # @raise [DefinitionError] if no start step or no steps are defined
43
+ # @raise [Errors::DefinitionError] if no start step or no steps are defined
53
44
  def self.define(&)
54
45
  builder = DSL::FlowBuilder.new
55
46
  builder.instance_eval(&)
@@ -61,14 +52,22 @@ module FlowEngine
61
52
  #
62
53
  # @param text [String] Ruby source containing FlowEngine.define { ... }
63
54
  # @return [Definition] the definition produced by evaluating the DSL
64
- # @raise [DefinitionError] on syntax or evaluation errors
55
+ # @raise [Errors::DefinitionError] on syntax or evaluation errors
65
56
  def self.load_dsl(text)
66
57
  # rubocop:disable Security/Eval
67
58
  eval(text, TOPLEVEL_BINDING.dup, "(dsl)", 1)
68
59
  # rubocop:enable Security/Eval
69
60
  rescue SyntaxError => e
70
- raise DefinitionError, "DSL syntax error: #{e.message}"
61
+ raise Errors::DefinitionError, "DSL syntax error: #{e.message}"
71
62
  rescue StandardError => e
72
- raise DefinitionError, "DSL evaluation error: #{e.message}"
63
+ raise Errors::DefinitionError, "DSL evaluation error: #{e.message}"
64
+ end
65
+
66
+ # Resolves a fully-qualified constant name string to the actual constant.
67
+ #
68
+ # @param name [String] e.g. "FlowEngine::LLM::Adapters::AnthropicAdapter"
69
+ # @return [Class, Module]
70
+ def self.constantize(name)
71
+ name.split("::").inject(Object) { |mod, const| mod.const_get(const) }
73
72
  end
74
73
  end