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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +1 -6
- data/README.md +76 -13
- data/lib/flowengine/clarification_result.rb +21 -0
- data/lib/flowengine/definition.rb +4 -2
- data/lib/flowengine/dsl/flow_builder.rb +2 -2
- data/lib/flowengine/dsl/step_builder.rb +10 -1
- data/lib/flowengine/dsl.rb +0 -4
- data/lib/flowengine/engine/state_serializer.rb +50 -0
- data/lib/flowengine/engine.rb +101 -57
- data/lib/flowengine/errors.rb +39 -16
- data/lib/flowengine/llm/adapter.rb +69 -7
- data/lib/flowengine/llm/adapters.rb +24 -0
- data/lib/flowengine/llm/auto_client.rb +19 -0
- data/lib/flowengine/llm/client.rb +52 -6
- data/lib/flowengine/llm/intake_prompt_builder.rb +131 -0
- data/lib/flowengine/llm/provider.rb +12 -0
- data/lib/flowengine/llm/sensitive_data_filter.rb +1 -1
- data/lib/flowengine/llm.rb +95 -36
- data/lib/flowengine/node.rb +13 -3
- data/lib/flowengine/version.rb +1 -1
- data/lib/flowengine.rb +24 -25
- data/resources/models.yml +25 -0
- metadata +22 -4
- data/lib/flowengine/llm/anthropic_adapter.rb +0 -40
- data/lib/flowengine/llm/gemini_adapter.rb +0 -40
- data/lib/flowengine/llm/openai_adapter.rb +0 -38
|
@@ -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
|
|
6
|
-
# to integrate with a specific provider
|
|
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
|
|
12
|
-
# @param model [String] model identifier (
|
|
13
|
-
# @return [String] the LLM's response
|
|
14
|
-
def chat(system_prompt:, user_prompt:, model:)
|
|
15
|
-
|
|
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
|
|
15
|
-
def initialize(adapter:, model:
|
|
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
|
data/lib/flowengine/llm.rb
CHANGED
|
@@ -1,48 +1,107 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
#
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
#
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
data/lib/flowengine/node.rb
CHANGED
|
@@ -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,
|
|
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)
|
data/lib/flowengine/version.rb
CHANGED
data/lib/flowengine.rb
CHANGED
|
@@ -1,26 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|