llm_conductor 0.1.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.
- checksums.yaml +7 -0
- data/.DS_Store +0 -0
- data/.rspec +4 -0
- data/.rubocop.yml +103 -0
- data/.rubocop_todo.yml +54 -0
- data/.ruby-version +1 -0
- data/README.md +413 -0
- data/Rakefile +12 -0
- data/config/initializers/llm_conductor.rb +27 -0
- data/examples/data_builder_usage.rb +301 -0
- data/examples/prompt_registration.rb +133 -0
- data/examples/rag_usage.rb +108 -0
- data/examples/simple_usage.rb +48 -0
- data/lib/llm_conductor/client_factory.rb +33 -0
- data/lib/llm_conductor/clients/base_client.rb +98 -0
- data/lib/llm_conductor/clients/gpt_client.rb +24 -0
- data/lib/llm_conductor/clients/ollama_client.rb +24 -0
- data/lib/llm_conductor/clients/openrouter_client.rb +30 -0
- data/lib/llm_conductor/configuration.rb +97 -0
- data/lib/llm_conductor/data_builder.rb +211 -0
- data/lib/llm_conductor/prompt_manager.rb +72 -0
- data/lib/llm_conductor/prompts/base_prompt.rb +90 -0
- data/lib/llm_conductor/prompts.rb +127 -0
- data/lib/llm_conductor/response.rb +86 -0
- data/lib/llm_conductor/version.rb +5 -0
- data/lib/llm_conductor.rb +76 -0
- data/sig/llm_conductor.rbs +4 -0
- metadata +157 -0
@@ -0,0 +1,98 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'tiktoken_ruby'
|
4
|
+
require 'ollama-ai'
|
5
|
+
require 'openai'
|
6
|
+
|
7
|
+
module LlmConductor
|
8
|
+
module Clients
|
9
|
+
# Base client class providing common functionality for all LLM providers
|
10
|
+
# including prompt building, token counting, and response formatting.
|
11
|
+
class BaseClient
|
12
|
+
include Prompts
|
13
|
+
|
14
|
+
attr_reader :model, :type
|
15
|
+
|
16
|
+
def initialize(model:, type:)
|
17
|
+
@model = model
|
18
|
+
@type = type
|
19
|
+
end
|
20
|
+
|
21
|
+
def generate(data:)
|
22
|
+
prompt = build_prompt(data)
|
23
|
+
input_tokens = calculate_tokens(prompt)
|
24
|
+
output_text = generate_content(prompt)
|
25
|
+
output_tokens = calculate_tokens(output_text || '')
|
26
|
+
|
27
|
+
build_response(output_text, input_tokens, output_tokens, { prompt: })
|
28
|
+
rescue StandardError => e
|
29
|
+
build_error_response(e)
|
30
|
+
end
|
31
|
+
|
32
|
+
# Simple generation method that accepts a direct prompt and returns a Response object
|
33
|
+
def generate_simple(prompt:)
|
34
|
+
input_tokens = calculate_tokens(prompt)
|
35
|
+
output_text = generate_content(prompt)
|
36
|
+
output_tokens = calculate_tokens(output_text || '')
|
37
|
+
|
38
|
+
build_response(output_text, input_tokens, output_tokens)
|
39
|
+
rescue StandardError => e
|
40
|
+
build_error_response(e)
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def build_response(output_text, input_tokens, output_tokens, additional_metadata = {})
|
46
|
+
Response.new(
|
47
|
+
output: output_text,
|
48
|
+
model:,
|
49
|
+
input_tokens:,
|
50
|
+
output_tokens:,
|
51
|
+
metadata: build_metadata.merge(additional_metadata)
|
52
|
+
)
|
53
|
+
end
|
54
|
+
|
55
|
+
def build_error_response(error)
|
56
|
+
Response.new(
|
57
|
+
output: nil,
|
58
|
+
model:,
|
59
|
+
metadata: { error: error.message, error_class: error.class.name }
|
60
|
+
)
|
61
|
+
end
|
62
|
+
|
63
|
+
def build_prompt(data)
|
64
|
+
# Check if this is a registered prompt type
|
65
|
+
if PromptManager.registered?(type)
|
66
|
+
PromptManager.render(type, data)
|
67
|
+
else
|
68
|
+
# Fallback to legacy prompt methods
|
69
|
+
send(:"prompt_#{type}", data)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def generate_content(prompt)
|
74
|
+
raise NotImplementedError
|
75
|
+
end
|
76
|
+
|
77
|
+
def calculate_tokens(content)
|
78
|
+
encoder.encode(content).length
|
79
|
+
end
|
80
|
+
|
81
|
+
def encoder
|
82
|
+
@encoder ||= Tiktoken.get_encoding('cl100k_base')
|
83
|
+
end
|
84
|
+
|
85
|
+
def client
|
86
|
+
raise NotImplementedError
|
87
|
+
end
|
88
|
+
|
89
|
+
# Build metadata for the response
|
90
|
+
def build_metadata
|
91
|
+
{
|
92
|
+
vendor: self.class.name.split('::').last.gsub('Client', '').downcase.to_sym,
|
93
|
+
timestamp: Time.zone.now.iso8601
|
94
|
+
}
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LlmConductor
|
4
|
+
module Clients
|
5
|
+
# OpenAI GPT client implementation for accessing GPT models via OpenAI API
|
6
|
+
class GptClient < BaseClient
|
7
|
+
private
|
8
|
+
|
9
|
+
def generate_content(prompt)
|
10
|
+
client.chat(parameters: { model:, messages: [{ role: 'user', content: prompt }] })
|
11
|
+
.dig('choices', 0, 'message', 'content')
|
12
|
+
end
|
13
|
+
|
14
|
+
def client
|
15
|
+
@client ||= begin
|
16
|
+
config = LlmConductor.configuration.provider_config(:openai)
|
17
|
+
options = { access_token: config[:api_key] }
|
18
|
+
options[:organization_id] = config[:organization] if config[:organization]
|
19
|
+
OpenAI::Client.new(options)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LlmConductor
|
4
|
+
module Clients
|
5
|
+
# Ollama client implementation for accessing local or self-hosted Ollama models
|
6
|
+
class OllamaClient < BaseClient
|
7
|
+
private
|
8
|
+
|
9
|
+
def generate_content(prompt)
|
10
|
+
client.generate({ model:, prompt:, stream: false }).first['response']
|
11
|
+
end
|
12
|
+
|
13
|
+
def client
|
14
|
+
@client ||= begin
|
15
|
+
config = LlmConductor.configuration.provider_config(:ollama)
|
16
|
+
Ollama.new(
|
17
|
+
credentials: { address: config[:base_url] },
|
18
|
+
options: { server_sent_events: true }
|
19
|
+
)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LlmConductor
|
4
|
+
module Clients
|
5
|
+
# OpenRouter client implementation for accessing various LLM providers through OpenRouter API
|
6
|
+
class OpenrouterClient < BaseClient
|
7
|
+
private
|
8
|
+
|
9
|
+
def generate_content(prompt)
|
10
|
+
client.chat(
|
11
|
+
parameters: {
|
12
|
+
model:,
|
13
|
+
messages: [{ role: 'user', content: prompt }],
|
14
|
+
provider: { sort: 'throughput' }
|
15
|
+
}
|
16
|
+
).dig('choices', 0, 'message', 'content')
|
17
|
+
end
|
18
|
+
|
19
|
+
def client
|
20
|
+
@client ||= begin
|
21
|
+
config = LlmConductor.configuration.provider_config(:openrouter)
|
22
|
+
OpenAI::Client.new(
|
23
|
+
access_token: config[:api_key],
|
24
|
+
uri_base: config[:uri_base] || 'https://openrouter.ai/api/'
|
25
|
+
)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# LLM Conductor provides a unified interface for multiple Language Model providers
|
4
|
+
module LlmConductor
|
5
|
+
# Configuration class for managing API keys, endpoints, and default settings
|
6
|
+
class Configuration
|
7
|
+
attr_accessor :default_model, :default_vendor, :timeout, :max_retries, :retry_delay
|
8
|
+
attr_reader :providers
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
# Default settings
|
12
|
+
@default_model = 'gpt-5-mini'
|
13
|
+
@default_vendor = :openai
|
14
|
+
@timeout = 30
|
15
|
+
@max_retries = 3
|
16
|
+
@retry_delay = 1.0
|
17
|
+
|
18
|
+
# Provider configurations
|
19
|
+
@providers = {}
|
20
|
+
|
21
|
+
# Initialize with environment variables if available
|
22
|
+
setup_defaults_from_env
|
23
|
+
end
|
24
|
+
|
25
|
+
# Configure OpenAI provider
|
26
|
+
def openai(api_key: nil, organization: nil, **options)
|
27
|
+
@providers[:openai] = {
|
28
|
+
api_key: api_key || ENV['OPENAI_API_KEY'],
|
29
|
+
organization: organization || ENV['OPENAI_ORG_ID'],
|
30
|
+
**options
|
31
|
+
}
|
32
|
+
end
|
33
|
+
|
34
|
+
# Configure Ollama provider
|
35
|
+
def ollama(base_url: nil, **options)
|
36
|
+
@providers[:ollama] = {
|
37
|
+
base_url: base_url || ENV['OLLAMA_ADDRESS'] || 'http://localhost:11434',
|
38
|
+
**options
|
39
|
+
}
|
40
|
+
end
|
41
|
+
|
42
|
+
# Configure OpenRouter provider
|
43
|
+
def openrouter(api_key: nil, **options)
|
44
|
+
@providers[:openrouter] = {
|
45
|
+
api_key: api_key || ENV['OPENROUTER_API_KEY'],
|
46
|
+
**options
|
47
|
+
}
|
48
|
+
end
|
49
|
+
|
50
|
+
# Get provider configuration
|
51
|
+
def provider_config(provider)
|
52
|
+
@providers[provider.to_sym] || {}
|
53
|
+
end
|
54
|
+
|
55
|
+
# Legacy compatibility methods
|
56
|
+
def openai_api_key
|
57
|
+
provider_config(:openai)[:api_key]
|
58
|
+
end
|
59
|
+
|
60
|
+
def openai_api_key=(value)
|
61
|
+
openai(api_key: value)
|
62
|
+
end
|
63
|
+
|
64
|
+
def openrouter_api_key
|
65
|
+
provider_config(:openrouter)[:api_key]
|
66
|
+
end
|
67
|
+
|
68
|
+
def openrouter_api_key=(value)
|
69
|
+
openrouter(api_key: value)
|
70
|
+
end
|
71
|
+
|
72
|
+
def ollama_address
|
73
|
+
provider_config(:ollama)[:base_url]
|
74
|
+
end
|
75
|
+
|
76
|
+
def ollama_address=(value)
|
77
|
+
ollama(base_url: value)
|
78
|
+
end
|
79
|
+
|
80
|
+
private
|
81
|
+
|
82
|
+
def setup_defaults_from_env
|
83
|
+
# Auto-configure providers if environment variables are present
|
84
|
+
openai if ENV['OPENAI_API_KEY']
|
85
|
+
openrouter if ENV['OPENROUTER_API_KEY']
|
86
|
+
ollama # Always configure Ollama with default URL
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def self.configuration
|
91
|
+
@configuration ||= Configuration.new
|
92
|
+
end
|
93
|
+
|
94
|
+
def self.configure
|
95
|
+
yield(configuration)
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,211 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LlmConductor
|
4
|
+
# Base class for building structured data from source objects for LLM consumption.
|
5
|
+
# Provides helper methods for data extraction, formatting, and safe handling of nested data.
|
6
|
+
#
|
7
|
+
# @example Basic usage
|
8
|
+
# class CompanyDataBuilder < LlmConductor::DataBuilder
|
9
|
+
# def build
|
10
|
+
# {
|
11
|
+
# id: source_object.id,
|
12
|
+
# name: source_object.name,
|
13
|
+
# metrics: build_metrics
|
14
|
+
# }
|
15
|
+
# end
|
16
|
+
#
|
17
|
+
# private
|
18
|
+
#
|
19
|
+
# def build_metrics
|
20
|
+
# {
|
21
|
+
# employees: safe_extract(:employee_count, default: 'Unknown')
|
22
|
+
# }
|
23
|
+
# end
|
24
|
+
# end
|
25
|
+
#
|
26
|
+
# builder = CompanyDataBuilder.new(company)
|
27
|
+
# data = builder.build
|
28
|
+
class DataBuilder
|
29
|
+
attr_reader :source_object
|
30
|
+
|
31
|
+
def initialize(source_object)
|
32
|
+
@source_object = source_object
|
33
|
+
end
|
34
|
+
|
35
|
+
# Abstract method to be implemented by subclasses
|
36
|
+
# @return [Hash] The built data structure
|
37
|
+
def build
|
38
|
+
raise NotImplementedError, "#{self.class} must implement the #build method"
|
39
|
+
end
|
40
|
+
|
41
|
+
protected
|
42
|
+
|
43
|
+
# Safely extract a value from the source object with a default fallback
|
44
|
+
# @param attribute [Symbol, String] The attribute name to extract
|
45
|
+
# @param default [Object] Default value if extraction fails
|
46
|
+
# @return [Object] The extracted value or default
|
47
|
+
def safe_extract(attribute, default: nil)
|
48
|
+
return default if source_object.nil?
|
49
|
+
|
50
|
+
if source_object.respond_to?(attribute)
|
51
|
+
value = source_object.public_send(attribute)
|
52
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?) ? default : value
|
53
|
+
else
|
54
|
+
default
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# Extract nested data from a hash-like structure
|
59
|
+
# @param root_key [Symbol, String] The root key to start extraction from
|
60
|
+
# @param *path [Array<String, Symbol>] The path to navigate through nested structures
|
61
|
+
# @return [Object, nil] The extracted value or nil if not found
|
62
|
+
def extract_nested_data(root_key, *path)
|
63
|
+
return nil if source_object.nil?
|
64
|
+
|
65
|
+
data = safe_extract(root_key)
|
66
|
+
return nil unless hash_like?(data)
|
67
|
+
|
68
|
+
navigate_nested_path(data, path)
|
69
|
+
rescue StandardError
|
70
|
+
nil
|
71
|
+
end
|
72
|
+
|
73
|
+
# Format a value for LLM consumption with appropriate fallbacks
|
74
|
+
# @param value [Object] The value to format
|
75
|
+
# @param options [Hash] Formatting options
|
76
|
+
# @option options [String] :default ('N/A') Default value for nil/empty values
|
77
|
+
# @option options [String] :prefix ('') Prefix to add to non-empty values
|
78
|
+
# @option options [String] :suffix ('') Suffix to add to non-empty values
|
79
|
+
# @option options [Integer] :max_length (nil) Maximum length to truncate to
|
80
|
+
# @return [String] Formatted value suitable for LLM consumption
|
81
|
+
def format_for_llm(value, options = {})
|
82
|
+
default = options.fetch(:default, 'N/A')
|
83
|
+
prefix = options.fetch(:prefix, '')
|
84
|
+
suffix = options.fetch(:suffix, '')
|
85
|
+
max_length = options[:max_length]
|
86
|
+
|
87
|
+
# Handle nil or empty values
|
88
|
+
return default if value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
89
|
+
|
90
|
+
# Convert to string and apply formatting
|
91
|
+
formatted = "#{prefix}#{value}#{suffix}"
|
92
|
+
|
93
|
+
# Apply length limit if specified
|
94
|
+
formatted = "#{formatted[0...max_length]}..." if max_length && formatted.length > max_length
|
95
|
+
|
96
|
+
formatted
|
97
|
+
end
|
98
|
+
|
99
|
+
# Extract and format a list of items
|
100
|
+
# @param attribute [Symbol, String] The attribute containing the list
|
101
|
+
# @param options [Hash] Formatting options
|
102
|
+
# @option options [String] :separator (', ') Separator for joining items
|
103
|
+
# @option options [Integer] :limit (nil) Maximum number of items to include
|
104
|
+
# @option options [String] :default ('None') Default value for empty lists
|
105
|
+
# @return [String] Formatted list suitable for LLM consumption
|
106
|
+
def extract_list(attribute, options = {})
|
107
|
+
separator = options.fetch(:separator, ', ')
|
108
|
+
limit = options[:limit]
|
109
|
+
default = options.fetch(:default, 'None')
|
110
|
+
|
111
|
+
items = safe_extract(attribute, default: [])
|
112
|
+
return default unless items.respond_to?(:map) && !items.empty?
|
113
|
+
|
114
|
+
# Apply limit if specified
|
115
|
+
items = items.first(limit) if limit
|
116
|
+
|
117
|
+
items.map(&:to_s).join(separator)
|
118
|
+
end
|
119
|
+
|
120
|
+
# Build a summary string from multiple attributes
|
121
|
+
# @param attributes [Array<Symbol>] List of attributes to include
|
122
|
+
# @param options [Hash] Formatting options
|
123
|
+
# @option options [String] :separator (' | ') Separator between attributes
|
124
|
+
# @option options [Boolean] :skip_empty (true) Whether to skip empty values
|
125
|
+
# @return [String] Combined summary string
|
126
|
+
def build_summary(*attributes, **options)
|
127
|
+
separator = options.fetch(:separator, ' | ')
|
128
|
+
skip_empty = options.fetch(:skip_empty, true)
|
129
|
+
|
130
|
+
parts = attributes.map do |attr|
|
131
|
+
value = safe_extract(attr)
|
132
|
+
next if skip_empty && (value.nil? || (value.respond_to?(:empty?) && value.empty?))
|
133
|
+
|
134
|
+
format_for_llm(value)
|
135
|
+
end.compact
|
136
|
+
|
137
|
+
parts.join(separator)
|
138
|
+
end
|
139
|
+
|
140
|
+
# Helper method to safely convert numeric values with formatting
|
141
|
+
# @param value [Numeric, String] The value to format
|
142
|
+
# @param options [Hash] Formatting options
|
143
|
+
# @option options [String] :currency ('$') Currency symbol for monetary values
|
144
|
+
# @option options [Boolean] :as_currency (false) Whether to format as currency
|
145
|
+
# @option options [Integer] :precision (0) Decimal precision
|
146
|
+
# @return [String] Formatted numeric value
|
147
|
+
def format_number(value, options = {})
|
148
|
+
return format_for_llm(nil, options) if value.nil?
|
149
|
+
|
150
|
+
begin
|
151
|
+
numeric_value = Float(value)
|
152
|
+
formatted = format_numeric_value(numeric_value, options[:precision] || 0)
|
153
|
+
formatted = add_thousands_separators(formatted)
|
154
|
+
|
155
|
+
apply_currency_formatting(formatted, options)
|
156
|
+
rescue ArgumentError
|
157
|
+
format_for_llm(value, options)
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
private
|
162
|
+
|
163
|
+
# Helper to check if an object has a specific method
|
164
|
+
def attribute?(attribute)
|
165
|
+
source_object&.respond_to?(attribute)
|
166
|
+
end
|
167
|
+
|
168
|
+
# Check if data structure supports hash-like access
|
169
|
+
def hash_like?(data)
|
170
|
+
data.respond_to?(:[])
|
171
|
+
end
|
172
|
+
|
173
|
+
# Navigate through nested hash path
|
174
|
+
def navigate_nested_path(data, path)
|
175
|
+
path.reduce(data) do |current_data, key|
|
176
|
+
return nil unless hash_like?(current_data)
|
177
|
+
|
178
|
+
find_key_variant(current_data, key)
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
# Find key in various formats (string, symbol)
|
183
|
+
def find_key_variant(data, key)
|
184
|
+
data[key] || data[key.to_s] || data[key.to_sym]
|
185
|
+
end
|
186
|
+
|
187
|
+
# Format numeric value with precision
|
188
|
+
def format_numeric_value(numeric_value, precision)
|
189
|
+
if precision.positive?
|
190
|
+
format("%.#{precision}f", numeric_value)
|
191
|
+
else
|
192
|
+
numeric_value.to_i.to_s
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
# Add thousands separators to numeric string
|
197
|
+
def add_thousands_separators(formatted)
|
198
|
+
formatted.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
199
|
+
end
|
200
|
+
|
201
|
+
# Apply currency formatting if requested
|
202
|
+
def apply_currency_formatting(formatted, options)
|
203
|
+
if options.fetch(:as_currency, false)
|
204
|
+
currency = options.fetch(:currency, '$')
|
205
|
+
"#{currency}#{formatted}"
|
206
|
+
else
|
207
|
+
formatted
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LlmConductor
|
4
|
+
# Manages registration and creation of prompt classes
|
5
|
+
class PromptManager
|
6
|
+
class PromptNotFoundError < StandardError; end
|
7
|
+
class InvalidPromptClassError < StandardError; end
|
8
|
+
|
9
|
+
@registry = {}
|
10
|
+
|
11
|
+
class << self
|
12
|
+
attr_reader :registry
|
13
|
+
|
14
|
+
# Register a prompt class with a given type
|
15
|
+
def register(type, prompt_class)
|
16
|
+
validate_prompt_class!(prompt_class)
|
17
|
+
|
18
|
+
@registry[type.to_sym] = prompt_class
|
19
|
+
end
|
20
|
+
|
21
|
+
# Unregister a prompt type (useful for testing)
|
22
|
+
def unregister(type)
|
23
|
+
@registry.delete(type.to_sym)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Get a registered prompt class
|
27
|
+
def get(type)
|
28
|
+
@registry[type.to_sym] || raise(PromptNotFoundError, "Prompt type :#{type} not found")
|
29
|
+
end
|
30
|
+
|
31
|
+
# Check if a prompt type is registered
|
32
|
+
def registered?(type)
|
33
|
+
@registry.key?(type.to_sym)
|
34
|
+
end
|
35
|
+
|
36
|
+
# List all registered prompt types
|
37
|
+
def types
|
38
|
+
@registry.keys
|
39
|
+
end
|
40
|
+
|
41
|
+
# Clear all registrations (useful for testing)
|
42
|
+
def clear!
|
43
|
+
@registry.clear
|
44
|
+
end
|
45
|
+
|
46
|
+
# Create a prompt instance
|
47
|
+
def create(type, data = {})
|
48
|
+
prompt_class = get(type)
|
49
|
+
prompt_class.new(data)
|
50
|
+
end
|
51
|
+
|
52
|
+
# Create and render a prompt in one step
|
53
|
+
def render(type, data = {})
|
54
|
+
create(type, data).render
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
def validate_prompt_class!(prompt_class)
|
60
|
+
raise InvalidPromptClassError, 'Prompt must be a class' unless prompt_class.is_a?(Class)
|
61
|
+
|
62
|
+
unless prompt_class < Prompts::BasePrompt
|
63
|
+
raise InvalidPromptClassError, 'Prompt class must inherit from BasePrompt'
|
64
|
+
end
|
65
|
+
|
66
|
+
return if prompt_class.instance_methods(false).include?(:render)
|
67
|
+
|
68
|
+
raise InvalidPromptClassError, 'Prompt class must implement #render method'
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LlmConductor
|
4
|
+
module Prompts
|
5
|
+
# Base class for all prompt templates
|
6
|
+
# Provides common functionality and data access patterns
|
7
|
+
class BasePrompt
|
8
|
+
attr_reader :data
|
9
|
+
|
10
|
+
def initialize(data = {})
|
11
|
+
@data = data || {}
|
12
|
+
setup_data_methods
|
13
|
+
end
|
14
|
+
|
15
|
+
# Override this method in subclasses to define the prompt
|
16
|
+
def render
|
17
|
+
raise NotImplementedError, 'Subclasses must implement #render method'
|
18
|
+
end
|
19
|
+
|
20
|
+
# Get the rendered prompt as a string
|
21
|
+
def to_s
|
22
|
+
render.strip
|
23
|
+
end
|
24
|
+
|
25
|
+
# Safe access to nested hash values
|
26
|
+
def data_dig(*keys)
|
27
|
+
@data.dig(*keys) if @data.respond_to?(:dig)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Convenience alias for data_dig
|
31
|
+
alias dig data_dig
|
32
|
+
|
33
|
+
protected
|
34
|
+
|
35
|
+
# Truncate text to a maximum length with ellipsis
|
36
|
+
def truncate_text(text, max_length: 100)
|
37
|
+
return '' if text.nil?
|
38
|
+
|
39
|
+
text = text.to_s
|
40
|
+
return text if text.length <= max_length
|
41
|
+
|
42
|
+
"#{text[0...max_length]}..."
|
43
|
+
end
|
44
|
+
|
45
|
+
# Format a list as a numbered list
|
46
|
+
def numbered_list(items)
|
47
|
+
return '' if items.nil? || items.empty?
|
48
|
+
|
49
|
+
items.map.with_index(1) { |item, index| "#{index}. #{item}" }.join("\n")
|
50
|
+
end
|
51
|
+
|
52
|
+
# Format a list as a bulleted list
|
53
|
+
def bulleted_list(items)
|
54
|
+
return '' if items.nil? || items.empty?
|
55
|
+
|
56
|
+
items.map { |item| "• #{item}" }.join("\n")
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
# Create dynamic method accessors for data keys
|
62
|
+
def setup_data_methods
|
63
|
+
return unless @data.respond_to?(:each)
|
64
|
+
|
65
|
+
@data.each do |key, value|
|
66
|
+
next unless key.respond_to?(:to_sym)
|
67
|
+
|
68
|
+
define_singleton_method(key.to_sym) { value }
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# Handle missing methods by checking data hash
|
73
|
+
def method_missing(method_name, *args, &block)
|
74
|
+
if @data.respond_to?(:[]) && @data.key?(method_name)
|
75
|
+
@data[method_name]
|
76
|
+
elsif @data.respond_to?(:[]) && @data.key?(method_name.to_s)
|
77
|
+
@data[method_name.to_s]
|
78
|
+
else
|
79
|
+
super
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
# Support for respond_to? with dynamic methods
|
84
|
+
def respond_to_missing?(method_name, include_private = false)
|
85
|
+
(@data.respond_to?(:[]) &&
|
86
|
+
(@data.key?(method_name) || @data.key?(method_name.to_s))) || super
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|