flowengine 0.3.0 → 0.4.0

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.
@@ -0,0 +1,64 @@
1
+ # Project Structure
2
+
3
+ ```text
4
+ lib/flowengine/
5
+ flowengine.rb # Module entry: define() and load_dsl()
6
+ version.rb # VERSION = "0.3.1"
7
+ errors.rb # Error hierarchy (8 classes)
8
+ clarification_result.rb # Data.define for AI intake round results
9
+ introduction.rb # Data.define(:label, :placeholder, :maxlength)
10
+ definition.rb # Immutable flow graph container
11
+ node.rb # Single flow step (question, type, transitions, max_clarifications)
12
+ transition.rb # Directed edge with optional rule condition
13
+ evaluator.rb # Rule evaluation against answers
14
+ engine.rb # Runtime session (answers, history, navigation, AI intake state)
15
+ engine/
16
+ state_serializer.rb # Symbolizes string-keyed state from JSON round-trips
17
+ dsl/
18
+ flow_builder.rb # FlowEngine.define {} context (start, introduction, step)
19
+ step_builder.rb # step {} block builder (includes max_clarifications)
20
+ rule_helpers.rb # contains(), equals(), all(), any(), etc.
21
+ rules/
22
+ base.rb # Abstract rule (evaluate + to_s)
23
+ contains.rb # Array.include? semantics
24
+ equals.rb # Simple equality
25
+ greater_than.rb # Numeric > comparison (coerces to_i)
26
+ less_than.rb # Numeric < comparison (coerces to_i)
27
+ not_empty.rb # !nil && !empty?
28
+ all.rb # Composite AND
29
+ any.rb # Composite OR
30
+ validation/
31
+ adapter.rb # Abstract validator interface
32
+ null_adapter.rb # No-op default validator
33
+ llm/
34
+ adapter.rb # Abstract LLM adapter interface
35
+ adapters.rb # Requires all concrete adapters
36
+ auto_client.rb # FlowEngine::LLM.auto_client factory
37
+ provider.rb # Provider/model registry from models.yml
38
+ client.rb # High-level LLM client (prompt building + response parsing)
39
+ system_prompt_builder.rb # Builds system prompt for introduction pre-fill
40
+ intake_prompt_builder.rb # Builds system prompt for AI intake steps
41
+ sensitive_data_filter.rb # Rejects SSN, ITIN, EIN patterns
42
+ graph/
43
+ mermaid_exporter.rb # Exports Definition to Mermaid diagram
44
+ resources/
45
+ models.yml # Vendor/model registry (Anthropic, OpenAI, Gemini)
46
+ prompts/
47
+ generic-dsl-intake.j2 # Static system prompt template for LLM parsing
48
+ spec/
49
+ flowengine_spec.rb # Top-level define/load_dsl tests
50
+ flowengine/ # Mirrors lib/ structure
51
+ engine_spec.rb # Core engine tests
52
+ engine_ai_intake_spec.rb # AI intake step tests
53
+ engine_introduction_spec.rb # Introduction pre-fill tests
54
+ engine_state_spec.rb # State persistence tests
55
+ clarification_result_spec.rb
56
+ llm/ # LLM adapter, client, filter, prompt builder specs
57
+ integration/
58
+ introduction_flow_spec.rb # Introduction + LLM pre-fill integration
59
+ multi_ai_intake_spec.rb # Multi-AI intake integration test
60
+ tax_intake_flow_spec.rb # Real-world tax intake example
61
+ complex_flow_spec.rb # Complex branching tests
62
+ fixtures/
63
+ complex_tax_intake.rb # 17-step tax intake flow definition
64
+ ```
Binary file
data/lefthook.yml ADDED
@@ -0,0 +1,16 @@
1
+ pre-commit:
2
+ parallel: true
3
+ jobs:
4
+ - name: format
5
+ glob: "*.{js,ts,tsx}"
6
+ run: npx biome format --write {staged_files}
7
+ stage_fixed: true
8
+
9
+ - name: rubocop
10
+ glob: "*.{rb,rake}"
11
+ run: bundle exec rubocop -a --format progress {staged_files}
12
+ staged_fixed: true
13
+
14
+ - name: rspecs
15
+ glob: "spec/**/*_spec.rb"
16
+ run: bundle exec rspec --format progress
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ # Immutable result from an AI intake or clarification round.
5
+ #
6
+ # @attr_reader answered [Hash<Symbol, Object>] step_id => value pairs filled this round
7
+ # @attr_reader pending_steps [Array<Symbol>] step ids still unanswered after this round
8
+ # @attr_reader follow_up [String, nil] clarifying question from the LLM, or nil if done
9
+ # @attr_reader round [Integer] current clarification round (1-based)
10
+ ClarificationResult = Data.define(:answered, :pending_steps, :follow_up, :round) do
11
+ def initialize(answered: {}, pending_steps: [], follow_up: nil, round: 1)
12
+ super
13
+ freeze
14
+ end
15
+
16
+ # @return [Boolean] true when the LLM has no more questions or max rounds reached
17
+ def done?
18
+ follow_up.nil?
19
+ end
20
+ end
21
+ end
@@ -30,7 +30,7 @@ module FlowEngine
30
30
  # @return [Node] the node for that step
31
31
  # @raise [UnknownStepError] if id is not in steps
32
32
  def step(id)
33
- steps.fetch(id) { raise UnknownStepError, "Unknown step: #{id.inspect}" }
33
+ steps.fetch(id) { raise Errors::UnknownStepError, "Unknown step: #{id.inspect}" }
34
34
  end
35
35
 
36
36
  # @return [Array<Symbol>] all step ids in the definition
@@ -41,7 +41,9 @@ module FlowEngine
41
41
  private
42
42
 
43
43
  def validate!
44
- raise DefinitionError, "Start step #{start_step_id.inspect} not found in nodes" unless steps.key?(start_step_id)
44
+ return if steps.key?(start_step_id)
45
+
46
+ raise Errors::DefinitionError, "Start step #{start_step_id.inspect} not found in nodes"
45
47
  end
46
48
  end
47
49
  end
@@ -45,8 +45,8 @@ module FlowEngine
45
45
  # @return [Definition]
46
46
  # @raise [DefinitionError] if start was not set or no steps were defined
47
47
  def build
48
- raise DefinitionError, "No start step defined" if @start_step_id.nil?
49
- raise DefinitionError, "No steps defined" if @nodes.empty?
48
+ raise ::FlowEngine::Errors::DefinitionError, "No start step defined" if @start_step_id.nil?
49
+ raise ::FlowEngine::Errors::DefinitionError, "No steps defined" if @nodes.empty?
50
50
 
51
51
  Definition.new(start_step_id: @start_step_id, nodes: @nodes, introduction: @introduction)
52
52
  end
@@ -15,6 +15,7 @@ module FlowEngine
15
15
  @transitions = []
16
16
  @visibility_rule = nil
17
17
  @decorations = nil
18
+ @max_clarifications = 0
18
19
  end
19
20
 
20
21
  # Sets the step/input type (e.g. :multi_select, :number_matrix).
@@ -62,6 +63,13 @@ module FlowEngine
62
63
  @visibility_rule = rule
63
64
  end
64
65
 
66
+ # Sets the maximum number of clarification rounds for an :ai_intake step.
67
+ #
68
+ # @param count [Integer] max follow-up rounds (0 = one-shot, no clarifications)
69
+ def max_clarifications(count)
70
+ @max_clarifications = count
71
+ end
72
+
65
73
  # Builds the {Node} for the given step id from accumulated attributes.
66
74
  #
67
75
  # @param id [Symbol] step id
@@ -75,7 +83,8 @@ module FlowEngine
75
83
  fields: @fields,
76
84
  transitions: @transitions,
77
85
  visibility_rule: @visibility_rule,
78
- decorations: @decorations
86
+ decorations: @decorations,
87
+ max_clarifications: @max_clarifications
79
88
  )
80
89
  end
81
90
  end
@@ -1,9 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "dsl/rule_helpers"
4
- require_relative "dsl/step_builder"
5
- require_relative "dsl/flow_builder"
6
-
7
3
  module FlowEngine
8
4
  # Namespace for the declarative flow DSL: {FlowBuilder} builds a {Definition} from blocks,
9
5
  # {StepBuilder} builds individual {Node}s, and {RuleHelpers} provide rule factory methods.
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ class Engine
5
+ # Handles state serialization and deserialization for Engine persistence.
6
+ # Normalizes string-keyed hashes (from JSON) to symbol-keyed hashes.
7
+ module StateSerializer
8
+ SYMBOLIZERS = {
9
+ current_step_id: ->(v) { v&.to_sym },
10
+ active_intake_step_id: ->(v) { v&.to_sym },
11
+ history: ->(v) { Array(v).map { |e| e&.to_sym } },
12
+ answers: ->(v) { symbolize_answers(v) },
13
+ conversation_history: ->(v) { symbolize_conversation_history(v) }
14
+ }.freeze
15
+
16
+ # Normalizes a state hash so step ids and history entries are symbols.
17
+ def self.symbolize_state(hash)
18
+ return hash unless hash.is_a?(Hash)
19
+
20
+ hash.each_with_object({}) do |(key, value), result|
21
+ sym_key = key.to_sym
22
+ result[sym_key] = SYMBOLIZERS.fetch(sym_key, ->(v) { v }).call(value)
23
+ end
24
+ end
25
+
26
+ # @param answers [Hash] answers map (keys may be strings)
27
+ # @return [Hash] same map with symbol keys
28
+ def self.symbolize_answers(answers)
29
+ return {} unless answers.is_a?(Hash)
30
+
31
+ answers.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
32
+ end
33
+
34
+ # @param history [Array<Hash>] conversation history entries
35
+ # @return [Array<Hash>] same entries with symbolized keys and role
36
+ def self.symbolize_conversation_history(history)
37
+ return [] unless history.is_a?(Array)
38
+
39
+ history.map do |entry|
40
+ next entry unless entry.is_a?(Hash)
41
+
42
+ entry.each_with_object({}) do |(k, v), h|
43
+ sym_key = k.to_sym
44
+ h[sym_key] = sym_key == :role ? v.to_sym : v
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -3,14 +3,9 @@
3
3
  module FlowEngine
4
4
  # Runtime session that drives flow navigation: holds definition, answers, and current step.
5
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
12
- class Engine
13
- attr_reader :definition, :answers, :history, :current_step_id, :introduction_text
6
+ class Engine # rubocop:disable Metrics/ClassLength
7
+ attr_reader :definition, :answers, :history, :current_step_id, :introduction_text,
8
+ :clarification_round, :conversation_history
14
9
 
15
10
  # @param definition [Definition] the flow to run
16
11
  # @param validator [Validation::Adapter] validator for step answers (default: {Validation::NullAdapter})
@@ -21,6 +16,9 @@ module FlowEngine
21
16
  @current_step_id = definition.start_step_id
22
17
  @validator = validator
23
18
  @introduction_text = nil
19
+ @clarification_round = 0
20
+ @conversation_history = []
21
+ @active_intake_step_id = nil
24
22
  @history << @current_step_id
25
23
  end
26
24
 
@@ -42,10 +40,10 @@ module FlowEngine
42
40
  # @raise [AlreadyFinishedError] if the flow has already finished
43
41
  # @raise [ValidationError] if the validator rejects the value
44
42
  def answer(value)
45
- raise AlreadyFinishedError, "Flow is already finished" if finished?
43
+ raise Errors::AlreadyFinishedError, "Flow is already finished" if finished?
46
44
 
47
45
  result = @validator.validate(current_step, value)
48
- raise ValidationError, "Validation failed: #{result.errors.join(", ")}" unless result.valid?
46
+ raise Errors::ValidationError, "Validation failed: #{result.errors.join(", ")}" unless result.valid?
49
47
 
50
48
  answers[@current_step_id] = value
51
49
  advance_step
@@ -56,9 +54,6 @@ module FlowEngine
56
54
  #
57
55
  # @param text [String] user's free-form introduction
58
56
  # @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
57
  def submit_introduction(text, llm_client:)
63
58
  validate_introduction_length!(text)
64
59
  LLM::SensitiveDataFilter.check!(text)
@@ -68,65 +63,67 @@ module FlowEngine
68
63
  auto_advance_prefilled
69
64
  end
70
65
 
71
- # Serializable state for persistence or resumption.
66
+ # Submits free-form text for the current AI intake step. Returns a ClarificationResult.
72
67
  #
73
- # @return [Hash] current_step_id, answers, history, and introduction_text
68
+ # @param text [String] user's free-form text
69
+ # @param llm_client [LLM::Client] configured LLM client
70
+ # @return [ClarificationResult]
71
+ def submit_ai_intake(text, llm_client:)
72
+ node = current_step
73
+ raise Errors::EngineError, "Current step is not an AI intake step" unless node&.ai_intake?
74
+
75
+ LLM::SensitiveDataFilter.check!(text)
76
+
77
+ @active_intake_step_id = @current_step_id
78
+ @clarification_round = 1
79
+ @conversation_history = [{ role: :user, text: text }]
80
+
81
+ perform_intake_round(text, llm_client, node)
82
+ end
83
+
84
+ # Submits a clarification response for an ongoing AI intake conversation.
85
+ #
86
+ # @param text [String] user's response to the follow-up question
87
+ # @param llm_client [LLM::Client] configured LLM client
88
+ # @return [ClarificationResult]
89
+ def submit_clarification(text, llm_client:)
90
+ raise Errors::EngineError, "No active AI intake conversation to clarify" unless @active_intake_step_id
91
+
92
+ LLM::SensitiveDataFilter.check!(text)
93
+
94
+ node = @definition.step(@active_intake_step_id)
95
+ @clarification_round += 1
96
+ @conversation_history << { role: :user, text: text }
97
+
98
+ perform_intake_round(text, llm_client, node)
99
+ end
100
+
101
+ # Serializable state for persistence or resumption.
74
102
  def to_state
75
103
  {
76
104
  current_step_id: @current_step_id,
77
105
  answers: @answers,
78
106
  history: @history,
79
- introduction_text: @introduction_text
107
+ introduction_text: @introduction_text,
108
+ clarification_round: @clarification_round,
109
+ conversation_history: @conversation_history,
110
+ active_intake_step_id: @active_intake_step_id
80
111
  }
81
112
  end
82
113
 
83
- # Rebuilds an engine from a previously saved state (e.g. from DB or session).
114
+ # Rebuilds an engine from a previously saved state.
84
115
  #
85
116
  # @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)
117
+ # @param state_hash [Hash] hash with state keys (may be strings from JSON)
87
118
  # @param validator [Validation::Adapter] validator to use (default: NullAdapter)
88
119
  # @return [Engine] restored engine instance
89
120
  def self.from_state(definition, state_hash, validator: Validation::NullAdapter.new)
90
- state = symbolize_state(state_hash)
121
+ state = StateSerializer.symbolize_state(state_hash)
91
122
  engine = allocate
92
123
  engine.send(:restore_state, definition, state, validator)
93
124
  engine
94
125
  end
95
126
 
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
100
- def self.symbolize_state(hash)
101
- return hash unless hash.is_a?(Hash)
102
-
103
- hash.each_with_object({}) do |(key, value), result|
104
- sym_key = key.to_sym
105
- result[sym_key] = case sym_key
106
- when :current_step_id
107
- value&.to_sym
108
- when :history
109
- Array(value).map { |v| v&.to_sym }
110
- when :answers
111
- symbolize_answers(value)
112
- else
113
- value
114
- end
115
- end
116
- end
117
-
118
- # @param answers [Hash] answers map (keys may be strings)
119
- # @return [Hash] same map with symbol keys
120
- def self.symbolize_answers(answers)
121
- return {} unless answers.is_a?(Hash)
122
-
123
- answers.each_with_object({}) do |(key, value), result|
124
- result[key.to_sym] = value
125
- end
126
- end
127
-
128
- private_class_method :symbolize_state, :symbolize_answers
129
-
130
127
  private
131
128
 
132
129
  def restore_state(definition, state, validator)
@@ -136,12 +133,14 @@ module FlowEngine
136
133
  @answers = state[:answers] || {}
137
134
  @history = state[:history] || []
138
135
  @introduction_text = state[:introduction_text]
136
+ @clarification_round = state[:clarification_round] || 0
137
+ @conversation_history = state[:conversation_history] || []
138
+ @active_intake_step_id = state[:active_intake_step_id]
139
139
  end
140
140
 
141
141
  def advance_step
142
142
  node = definition.step(@current_step_id)
143
143
  next_id = node.next_step_id(answers)
144
-
145
144
  @current_step_id = next_id
146
145
  @history << next_id if next_id
147
146
  end
@@ -151,13 +150,58 @@ module FlowEngine
151
150
  return unless maxlength
152
151
  return if text.length <= maxlength
153
152
 
154
- raise ValidationError, "Introduction text exceeds maxlength (#{text.length}/#{maxlength})"
153
+ raise Errors::ValidationError, "Introduction text exceeds maxlength (#{text.length}/#{maxlength})"
155
154
  end
156
155
 
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
156
  def auto_advance_prefilled
160
157
  advance_step while @current_step_id && @answers.key?(@current_step_id)
161
158
  end
159
+
160
+ def perform_intake_round(user_text, llm_client, node)
161
+ result = llm_client.parse_ai_intake(
162
+ definition: @definition, user_text: user_text,
163
+ answered: @answers, conversation_history: @conversation_history
164
+ )
165
+ @answers.merge!(result[:answers])
166
+ follow_up = resolve_follow_up(result[:follow_up], node)
167
+
168
+ build_clarification_result(result[:answers], follow_up)
169
+ end
170
+
171
+ def resolve_follow_up(follow_up, node)
172
+ if follow_up && @clarification_round <= node.max_clarifications
173
+ @conversation_history << { role: :assistant, text: follow_up }
174
+ follow_up
175
+ else
176
+ finalize_intake
177
+ nil
178
+ end
179
+ end
180
+
181
+ def build_clarification_result(round_answers, follow_up)
182
+ ClarificationResult.new(
183
+ answered: round_answers,
184
+ pending_steps: pending_non_intake_steps,
185
+ follow_up: follow_up,
186
+ round: @clarification_round
187
+ )
188
+ end
189
+
190
+ def finalize_intake
191
+ @answers[@active_intake_step_id] = conversation_summary
192
+ @active_intake_step_id = nil
193
+ advance_step
194
+ auto_advance_prefilled
195
+ end
196
+
197
+ def conversation_summary
198
+ @conversation_history.map { |e| "#{e[:role]}: #{e[:text]}" }.join("\n")
199
+ end
200
+
201
+ def pending_non_intake_steps
202
+ @definition.steps.each_with_object([]) do |(id, node), pending|
203
+ pending << id unless node.ai_intake? || @answers.key?(id)
204
+ end
205
+ end
162
206
  end
163
207
  end
@@ -1,27 +1,50 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FlowEngine
4
- # Base exception for all flowengine errors.
5
- class Error < StandardError; end
4
+ module Errors
5
+ # Base exception for all flowengine errors.
6
+ class Error < StandardError; end
6
7
 
7
- # Raised when a flow definition is invalid (e.g. missing start step, unknown step reference).
8
- class DefinitionError < Error; end
8
+ # Raised when configuration is invalid (e.g. missing models.yml).
9
+ class ConfigurationError < Error; end
9
10
 
10
- # Raised when navigating to or requesting a step id that does not exist in the definition.
11
- class UnknownStepError < Error; end
11
+ # Raised when a flow definition is invalid (e.g. missing start step, unknown step reference).
12
+ class DefinitionError < Error; end
12
13
 
13
- # Base exception for runtime engine errors (e.g. validation, already finished).
14
- class EngineError < Error; end
14
+ # Raised when navigating to or requesting a step id that does not exist in the definition.
15
+ class UnknownStepError < Error; end
15
16
 
16
- # Raised when {Engine#answer} is called after the flow has already finished.
17
- class AlreadyFinishedError < EngineError; end
17
+ # Base exception for runtime engine errors (e.g. validation, already finished).
18
+ class EngineError < Error; end
18
19
 
19
- # Raised when the validator rejects the user's answer for the current step.
20
- class ValidationError < EngineError; end
20
+ # Raised when {Engine#answer} is called after the flow has already finished.
21
+ class AlreadyFinishedError < EngineError; end
21
22
 
22
- # Raised for LLM-related errors (missing API key, response parsing, etc.).
23
- class LLMError < Error; end
23
+ # Raised when the validator rejects the user's answer for the current step.
24
+ class ValidationError < EngineError; end
24
25
 
25
- # Raised when introduction text contains sensitive data (SSN, ITIN, EIN, etc.).
26
- class SensitiveDataError < EngineError; end
26
+ # Raised when introduction text contains sensitive data (SSN, ITIN, EIN, etc.).
27
+ class SensitiveDataError < EngineError; end
28
+
29
+ # Base exception for LLM-related errors (missing API key, response parsing, etc.).
30
+ class LLMError < Error; end
31
+
32
+ # Raised when no API key is found for any provider.
33
+ class NoAPIKeyFoundError < LLMError; end
34
+
35
+ # Raised when a requested provider does not exist.
36
+ class NoSuchProviderExists < LLMError; end
37
+
38
+ # Raised when a provider is missing its API key.
39
+ class ProviderMissingApiKey < LLMError; end
40
+
41
+ # Raised when a requested model is not available.
42
+ class ModelNotAvailable < LLMError; end
43
+
44
+ # Raised when the LLM provider rejects the request due to rate limits or budget.
45
+ class OutOfBudgetError < LLMError; end
46
+
47
+ # Raised when the LLM provider rejects authentication credentials.
48
+ class AuthorizationError < LLMError; end
49
+ end
27
50
  end
@@ -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,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ module LLM
5
+ module Adapters
6
+ class AnthropicAdapter < Adapter
7
+ def self.api_key_var_name
8
+ "ANTHROPIC_API_KEY"
9
+ end
10
+
11
+ def self.default_model
12
+ "claude-sonnet-4-6"
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ module LLM
5
+ module Adapters
6
+ class GeminiAdapter < Adapter
7
+ def self.api_key_var_name
8
+ "GEMINI_API_KEY"
9
+ end
10
+
11
+ def self.default_model
12
+ "gemini-2.5-flash"
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end