desiru 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/.rspec +1 -0
- data/.rubocop.yml +55 -0
- data/CLAUDE.md +22 -0
- data/Gemfile +36 -0
- data/Gemfile.lock +255 -0
- data/LICENSE +21 -0
- data/README.md +343 -0
- data/Rakefile +18 -0
- data/desiru.gemspec +44 -0
- data/examples/README.md +55 -0
- data/examples/async_processing.rb +135 -0
- data/examples/few_shot_learning.rb +66 -0
- data/examples/graphql_api.rb +190 -0
- data/examples/graphql_integration.rb +114 -0
- data/examples/rag_retrieval.rb +80 -0
- data/examples/simple_qa.rb +31 -0
- data/examples/typed_signatures.rb +45 -0
- data/lib/desiru/async_capable.rb +170 -0
- data/lib/desiru/cache.rb +116 -0
- data/lib/desiru/configuration.rb +40 -0
- data/lib/desiru/field.rb +171 -0
- data/lib/desiru/graphql/data_loader.rb +210 -0
- data/lib/desiru/graphql/executor.rb +115 -0
- data/lib/desiru/graphql/schema_generator.rb +301 -0
- data/lib/desiru/jobs/async_predict.rb +52 -0
- data/lib/desiru/jobs/base.rb +53 -0
- data/lib/desiru/jobs/batch_processor.rb +71 -0
- data/lib/desiru/jobs/optimizer_job.rb +45 -0
- data/lib/desiru/models/base.rb +112 -0
- data/lib/desiru/models/raix_adapter.rb +210 -0
- data/lib/desiru/module.rb +204 -0
- data/lib/desiru/modules/chain_of_thought.rb +106 -0
- data/lib/desiru/modules/predict.rb +142 -0
- data/lib/desiru/modules/retrieve.rb +199 -0
- data/lib/desiru/optimizers/base.rb +130 -0
- data/lib/desiru/optimizers/bootstrap_few_shot.rb +212 -0
- data/lib/desiru/program.rb +106 -0
- data/lib/desiru/registry.rb +74 -0
- data/lib/desiru/signature.rb +322 -0
- data/lib/desiru/version.rb +5 -0
- data/lib/desiru.rb +67 -0
- metadata +184 -0
@@ -0,0 +1,210 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'raix'
|
4
|
+
require 'faraday'
|
5
|
+
require 'faraday/retry'
|
6
|
+
require 'openai'
|
7
|
+
|
8
|
+
module Desiru
|
9
|
+
module Models
|
10
|
+
# Adapter for Raix gem integration
|
11
|
+
# Provides unified interface to OpenAI, Anthropic, and OpenRouter via Raix
|
12
|
+
# Uses modern Raix patterns with direct OpenAI::Client configuration
|
13
|
+
class RaixAdapter < Base
|
14
|
+
def initialize(api_key: nil, provider: :openai, uri_base: nil, **config)
|
15
|
+
@api_key = api_key || fetch_api_key(provider)
|
16
|
+
@provider = provider
|
17
|
+
@uri_base = uri_base || fetch_uri_base(provider)
|
18
|
+
|
19
|
+
super(config)
|
20
|
+
configure_raix!
|
21
|
+
end
|
22
|
+
|
23
|
+
def complete(prompt, **options)
|
24
|
+
opts = build_completion_options(prompt, options)
|
25
|
+
|
26
|
+
response = with_retry do
|
27
|
+
client.completions.create(**opts)
|
28
|
+
end
|
29
|
+
|
30
|
+
process_response(response)
|
31
|
+
end
|
32
|
+
|
33
|
+
def stream_complete(prompt, **options)
|
34
|
+
opts = build_completion_options(prompt, options).merge(stream: true)
|
35
|
+
|
36
|
+
with_retry do
|
37
|
+
client.completions.create(**opts) do |chunk|
|
38
|
+
yield process_stream_chunk(chunk)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def models
|
44
|
+
case provider
|
45
|
+
when :openai
|
46
|
+
%w[gpt-4-turbo gpt-4 gpt-3.5-turbo gpt-4o gpt-4o-mini]
|
47
|
+
when :anthropic
|
48
|
+
%w[claude-3-opus-20240229 claude-3-sonnet-20240229 claude-3-haiku-20240307]
|
49
|
+
when :openrouter
|
50
|
+
# OpenRouter supports many models with provider prefixes
|
51
|
+
%w[anthropic/claude-3-opus openai/gpt-4-turbo google/gemini-pro meta-llama/llama-3-70b]
|
52
|
+
else
|
53
|
+
[]
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
protected
|
58
|
+
|
59
|
+
def default_config
|
60
|
+
super.merge(
|
61
|
+
model: 'gpt-4-turbo-preview',
|
62
|
+
response_format: nil,
|
63
|
+
tools: nil,
|
64
|
+
tool_choice: nil
|
65
|
+
)
|
66
|
+
end
|
67
|
+
|
68
|
+
def build_client
|
69
|
+
# Modern Raix uses direct configuration, not separate client instances
|
70
|
+
# The client is accessed through Raix after configuration
|
71
|
+
::Raix
|
72
|
+
end
|
73
|
+
|
74
|
+
def configure_raix!
|
75
|
+
::Raix.configure do |raix_config|
|
76
|
+
raix_config.openai_client = build_openai_client
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def build_openai_client
|
81
|
+
::OpenAI::Client.new(
|
82
|
+
access_token: @api_key,
|
83
|
+
uri_base: @uri_base
|
84
|
+
) do |f|
|
85
|
+
# Add retry middleware
|
86
|
+
f.request(:retry, {
|
87
|
+
max: config[:max_retries] || 3,
|
88
|
+
interval: 0.05,
|
89
|
+
interval_randomness: 0.5,
|
90
|
+
backoff_factor: 2
|
91
|
+
})
|
92
|
+
|
93
|
+
# Add logging in debug mode
|
94
|
+
if ENV['DEBUG'] || config[:debug]
|
95
|
+
f.response(:logger, config[:logger] || Logger.new($stdout), {
|
96
|
+
headers: false,
|
97
|
+
bodies: true,
|
98
|
+
errors: true
|
99
|
+
}) do |logger|
|
100
|
+
logger.filter(/(Bearer) (\S+)/, '\1[REDACTED]')
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def fetch_api_key(provider)
|
107
|
+
case provider
|
108
|
+
when :openai
|
109
|
+
ENV.fetch('OPENAI_API_KEY', nil)
|
110
|
+
when :anthropic
|
111
|
+
ENV.fetch('ANTHROPIC_API_KEY', nil)
|
112
|
+
when :openrouter
|
113
|
+
ENV.fetch('OPENROUTER_API_KEY', nil)
|
114
|
+
else
|
115
|
+
ENV.fetch("#{provider.to_s.upcase}_API_KEY", nil)
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
def fetch_uri_base(provider)
|
120
|
+
case provider
|
121
|
+
when :openai
|
122
|
+
ENV['OPENAI_API_BASE'] || 'https://api.openai.com/v1'
|
123
|
+
when :anthropic
|
124
|
+
ENV['ANTHROPIC_API_BASE'] || 'https://api.anthropic.com/v1'
|
125
|
+
when :openrouter
|
126
|
+
ENV['OPENROUTER_API_BASE'] || 'https://openrouter.ai/api/v1'
|
127
|
+
else
|
128
|
+
ENV.fetch("#{provider.to_s.upcase}_API_BASE", nil)
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
def validate_config!
|
133
|
+
raise ConfigurationError, 'API key is required' if @api_key.nil? || @api_key.empty?
|
134
|
+
raise ConfigurationError, 'Model must be specified' if config[:model].nil?
|
135
|
+
end
|
136
|
+
|
137
|
+
private
|
138
|
+
|
139
|
+
attr_reader :provider
|
140
|
+
|
141
|
+
def build_completion_options(prompt, options)
|
142
|
+
messages = build_messages(prompt, options[:demos] || [])
|
143
|
+
|
144
|
+
{
|
145
|
+
model: options[:model] || config[:model],
|
146
|
+
messages: messages,
|
147
|
+
temperature: options[:temperature] || config[:temperature],
|
148
|
+
max_tokens: options[:max_tokens] || config[:max_tokens],
|
149
|
+
response_format: options[:response_format] || config[:response_format],
|
150
|
+
tools: options[:tools] || config[:tools],
|
151
|
+
tool_choice: options[:tool_choice] || config[:tool_choice]
|
152
|
+
}.compact
|
153
|
+
end
|
154
|
+
|
155
|
+
def build_messages(prompt, demos)
|
156
|
+
messages = []
|
157
|
+
|
158
|
+
# Add system message if provided
|
159
|
+
messages << { role: 'system', content: prompt[:system] } if prompt[:system]
|
160
|
+
|
161
|
+
# Add demonstrations
|
162
|
+
demos.each do |demo|
|
163
|
+
messages << { role: 'user', content: demo[:input] }
|
164
|
+
messages << { role: 'assistant', content: demo[:output] }
|
165
|
+
end
|
166
|
+
|
167
|
+
# Add current prompt
|
168
|
+
messages << { role: 'user', content: prompt[:user] || prompt[:content] || prompt }
|
169
|
+
|
170
|
+
messages
|
171
|
+
end
|
172
|
+
|
173
|
+
def process_response(response)
|
174
|
+
content = response.dig('choices', 0, 'message', 'content')
|
175
|
+
usage = response['usage']
|
176
|
+
|
177
|
+
increment_stats(usage['total_tokens']) if usage
|
178
|
+
|
179
|
+
{
|
180
|
+
content: content,
|
181
|
+
raw: response,
|
182
|
+
model: response['model'],
|
183
|
+
usage: usage
|
184
|
+
}
|
185
|
+
end
|
186
|
+
|
187
|
+
def process_stream_chunk(chunk)
|
188
|
+
content = chunk.dig('choices', 0, 'delta', 'content')
|
189
|
+
|
190
|
+
{
|
191
|
+
content: content,
|
192
|
+
finished: chunk.dig('choices', 0, 'finish_reason').present?
|
193
|
+
}
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
# Convenience classes for specific providers
|
198
|
+
class OpenAI < RaixAdapter
|
199
|
+
def initialize(api_key: nil, **config)
|
200
|
+
super(api_key: api_key, provider: :openai, **config)
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
class OpenRouter < RaixAdapter
|
205
|
+
def initialize(api_key: nil, **config)
|
206
|
+
super(api_key: api_key, provider: :openrouter, **config)
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
@@ -0,0 +1,204 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# require_relative 'async_capable'
|
4
|
+
|
5
|
+
module Desiru
|
6
|
+
# Base class for all Desiru modules
|
7
|
+
# Implements the core module pattern with service-oriented design
|
8
|
+
class Module
|
9
|
+
extend Forwardable
|
10
|
+
# include AsyncCapable
|
11
|
+
|
12
|
+
attr_reader :signature, :model, :config, :demos, :metadata
|
13
|
+
|
14
|
+
def initialize(signature, model: nil, config: {}, demos: [], metadata: {})
|
15
|
+
@signature = case signature
|
16
|
+
when Signature
|
17
|
+
signature
|
18
|
+
when String
|
19
|
+
Signature.new(signature)
|
20
|
+
else
|
21
|
+
raise ModuleError, 'Signature must be a String or Signature instance'
|
22
|
+
end
|
23
|
+
|
24
|
+
@model = model || Desiru.configuration.default_model
|
25
|
+
@config = default_config.merge(config)
|
26
|
+
@demos = demos
|
27
|
+
@metadata = metadata
|
28
|
+
@call_count = 0
|
29
|
+
|
30
|
+
# Raise error if no model available
|
31
|
+
raise ArgumentError, 'No model provided and no default model configured' if @model.nil?
|
32
|
+
|
33
|
+
validate_model!
|
34
|
+
register_module
|
35
|
+
end
|
36
|
+
|
37
|
+
def call(inputs = {})
|
38
|
+
@call_count += 1
|
39
|
+
@retry_count = 0
|
40
|
+
|
41
|
+
begin
|
42
|
+
# Validate inputs first, then coerce
|
43
|
+
signature.validate_inputs(inputs)
|
44
|
+
coerced_inputs = signature.coerce_inputs(inputs)
|
45
|
+
|
46
|
+
# Execute the module logic
|
47
|
+
result = forward(**coerced_inputs)
|
48
|
+
|
49
|
+
# Validate outputs first, then coerce
|
50
|
+
signature.validate_outputs(result)
|
51
|
+
coerced_outputs = signature.coerce_outputs(result)
|
52
|
+
|
53
|
+
# Return result object
|
54
|
+
ModuleResult.new(coerced_outputs, metadata: execution_metadata)
|
55
|
+
rescue StandardError => e
|
56
|
+
if config[:retry_on_failure] && @retry_count < Desiru.configuration.max_retries
|
57
|
+
@retry_count += 1
|
58
|
+
Desiru.configuration.logger&.warn("Retrying module execution (attempt #{@retry_count}/#{Desiru.configuration.max_retries})")
|
59
|
+
sleep(Desiru.configuration.retry_delay)
|
60
|
+
retry
|
61
|
+
else
|
62
|
+
handle_error(e)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def forward(_inputs)
|
68
|
+
raise NotImplementedError, 'Subclasses must implement #forward'
|
69
|
+
end
|
70
|
+
|
71
|
+
def reset
|
72
|
+
@demos = []
|
73
|
+
@call_count = 0
|
74
|
+
end
|
75
|
+
|
76
|
+
def with_demos(new_demos)
|
77
|
+
self.class.new(signature, model: model, config: config, demos: new_demos, metadata: metadata)
|
78
|
+
end
|
79
|
+
|
80
|
+
def to_h
|
81
|
+
{
|
82
|
+
class: self.class.name,
|
83
|
+
signature: signature.to_h,
|
84
|
+
config: config,
|
85
|
+
demos_count: demos.size,
|
86
|
+
call_count: @call_count,
|
87
|
+
metadata: metadata
|
88
|
+
}
|
89
|
+
end
|
90
|
+
|
91
|
+
protected
|
92
|
+
|
93
|
+
def default_config
|
94
|
+
{
|
95
|
+
temperature: 0.7,
|
96
|
+
max_tokens: 1000,
|
97
|
+
timeout: 30,
|
98
|
+
retry_on_failure: true
|
99
|
+
}
|
100
|
+
end
|
101
|
+
|
102
|
+
def execution_metadata
|
103
|
+
{
|
104
|
+
module: self.class.name,
|
105
|
+
call_count: @call_count,
|
106
|
+
demos_used: demos.size,
|
107
|
+
timestamp: Time.now
|
108
|
+
}
|
109
|
+
end
|
110
|
+
|
111
|
+
private
|
112
|
+
|
113
|
+
def validate_model!
|
114
|
+
return if model.nil? # Will use default
|
115
|
+
|
116
|
+
# Skip validation for test doubles/mocks
|
117
|
+
return if defined?(RSpec) && (model.is_a?(RSpec::Mocks::Double) || model.respond_to?(:_rspec_double))
|
118
|
+
|
119
|
+
return if model.respond_to?(:complete)
|
120
|
+
|
121
|
+
raise ConfigurationError, 'Model must respond to #complete'
|
122
|
+
end
|
123
|
+
|
124
|
+
def register_module
|
125
|
+
# Auto-register with the registry if configured
|
126
|
+
return unless Desiru.configuration.module_registry && metadata[:auto_register]
|
127
|
+
|
128
|
+
Desiru.configuration.module_registry.register(
|
129
|
+
self.class.name.split('::').last.downcase,
|
130
|
+
self.class,
|
131
|
+
metadata: metadata
|
132
|
+
)
|
133
|
+
end
|
134
|
+
|
135
|
+
def handle_error(error)
|
136
|
+
Desiru.configuration.logger&.error("Module execution failed: #{error.message}")
|
137
|
+
raise ModuleError, "Module execution failed: #{error.message}"
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
# Result object for module outputs
|
142
|
+
class ModuleResult
|
143
|
+
extend Forwardable
|
144
|
+
|
145
|
+
attr_reader :data, :metadata, :outputs
|
146
|
+
|
147
|
+
def_delegators :@data, :keys, :values, :each
|
148
|
+
|
149
|
+
def initialize(data = nil, metadata: {}, **kwargs)
|
150
|
+
# Support both positional and keyword arguments for backward compatibility
|
151
|
+
if data.nil? && !kwargs.empty?
|
152
|
+
@data = kwargs
|
153
|
+
@outputs = kwargs
|
154
|
+
else
|
155
|
+
@data = data || {}
|
156
|
+
@outputs = @data
|
157
|
+
end
|
158
|
+
@metadata = metadata
|
159
|
+
end
|
160
|
+
|
161
|
+
def [](key)
|
162
|
+
if @data.key?(key.to_sym)
|
163
|
+
@data[key.to_sym]
|
164
|
+
elsif @data.key?(key.to_s)
|
165
|
+
@data[key.to_s]
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
def method_missing(method_name, *args, &)
|
170
|
+
method_str = method_name.to_s
|
171
|
+
if method_str.end_with?('?')
|
172
|
+
# Handle predicate methods for boolean values
|
173
|
+
key = method_str[0..-2].to_sym
|
174
|
+
if data.key?(key)
|
175
|
+
return !!data[key]
|
176
|
+
elsif data.key?(key.to_s)
|
177
|
+
return !!data[key.to_s]
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
if data.key?(method_name.to_sym)
|
182
|
+
data[method_name.to_sym]
|
183
|
+
elsif data.key?(method_name.to_s)
|
184
|
+
data[method_name.to_s]
|
185
|
+
else
|
186
|
+
super
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
def respond_to_missing?(method_name, include_private = false)
|
191
|
+
method_str = method_name.to_s
|
192
|
+
if method_str.end_with?('?')
|
193
|
+
key = method_str[0..-2]
|
194
|
+
data.key?(key.to_sym) || data.key?(key)
|
195
|
+
else
|
196
|
+
data.key?(method_name.to_sym) || data.key?(method_name.to_s) || super
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
def to_h
|
201
|
+
data
|
202
|
+
end
|
203
|
+
end
|
204
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Desiru
|
4
|
+
module Modules
|
5
|
+
# Chain of Thought module - adds reasoning steps before producing outputs
|
6
|
+
class ChainOfThought < Predict
|
7
|
+
def initialize(signature, **)
|
8
|
+
# Extend signature to include reasoning field
|
9
|
+
extended_sig = extend_signature_with_reasoning(signature)
|
10
|
+
super(extended_sig, **)
|
11
|
+
@original_signature = signature
|
12
|
+
end
|
13
|
+
|
14
|
+
protected
|
15
|
+
|
16
|
+
def build_system_prompt
|
17
|
+
<<~PROMPT
|
18
|
+
You are a helpful AI assistant that thinks step by step. You will be given inputs and must produce outputs according to the following specification:
|
19
|
+
|
20
|
+
#{format_original_signature}
|
21
|
+
|
22
|
+
Before providing the final answer, you must show your reasoning process. Think through the problem step by step.
|
23
|
+
|
24
|
+
Format your response as:
|
25
|
+
reasoning: [Your step-by-step thought process]
|
26
|
+
[output fields]: [Your final answers]
|
27
|
+
|
28
|
+
#{format_descriptions}
|
29
|
+
PROMPT
|
30
|
+
end
|
31
|
+
|
32
|
+
def build_user_prompt(inputs)
|
33
|
+
lines = ['Given the following inputs:']
|
34
|
+
|
35
|
+
inputs.each do |key, value|
|
36
|
+
lines << "#{key}: #{format_value(value)}"
|
37
|
+
end
|
38
|
+
|
39
|
+
lines << "\nThink step by step and provide:"
|
40
|
+
lines << 'reasoning: (your thought process)'
|
41
|
+
|
42
|
+
@original_signature.output_fields.each_key do |key|
|
43
|
+
lines << "#{key}: (your answer)"
|
44
|
+
end
|
45
|
+
|
46
|
+
lines.join("\n")
|
47
|
+
end
|
48
|
+
|
49
|
+
def parse_response(content)
|
50
|
+
result = super
|
51
|
+
|
52
|
+
# Extract reasoning if not already captured
|
53
|
+
unless result[:reasoning]
|
54
|
+
reasoning_match = content.match(/reasoning:\s*(.+?)(?=\n\w+:|$)/mi)
|
55
|
+
result[:reasoning] = reasoning_match[1].strip if reasoning_match
|
56
|
+
end
|
57
|
+
|
58
|
+
# Ensure we have all original output fields
|
59
|
+
@original_signature.output_fields.each_key do |field|
|
60
|
+
result[field] ||= result[field.to_s]
|
61
|
+
end
|
62
|
+
|
63
|
+
result
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
def extend_signature_with_reasoning(signature)
|
69
|
+
sig_string = case signature
|
70
|
+
when Signature
|
71
|
+
signature.raw_signature
|
72
|
+
when String
|
73
|
+
signature
|
74
|
+
else
|
75
|
+
raise ModuleError, 'Invalid signature type'
|
76
|
+
end
|
77
|
+
|
78
|
+
# Parse the signature parts
|
79
|
+
parts = sig_string.split('->').map(&:strip)
|
80
|
+
inputs = parts[0]
|
81
|
+
outputs = parts[1]
|
82
|
+
|
83
|
+
# Add reasoning to outputs if not already present
|
84
|
+
outputs = "reasoning: string, #{outputs}" unless outputs.include?('reasoning')
|
85
|
+
|
86
|
+
Signature.new("#{inputs} -> #{outputs}")
|
87
|
+
end
|
88
|
+
|
89
|
+
def format_original_signature
|
90
|
+
case @original_signature
|
91
|
+
when Signature
|
92
|
+
"#{format_fields(@original_signature.input_fields)} -> #{format_fields(@original_signature.output_fields)}"
|
93
|
+
when String
|
94
|
+
@original_signature
|
95
|
+
else
|
96
|
+
signature.raw_signature
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
# Register in the main module namespace for convenience
|
104
|
+
module Desiru
|
105
|
+
ChainOfThought = Modules::ChainOfThought
|
106
|
+
end
|
@@ -0,0 +1,142 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Desiru
|
4
|
+
module Modules
|
5
|
+
# Basic prediction module - the fundamental building block
|
6
|
+
class Predict < Module
|
7
|
+
def forward(inputs)
|
8
|
+
prompt = build_prompt(inputs)
|
9
|
+
|
10
|
+
response = model.complete(
|
11
|
+
prompt,
|
12
|
+
temperature: config[:temperature],
|
13
|
+
max_tokens: config[:max_tokens],
|
14
|
+
demos: demos
|
15
|
+
)
|
16
|
+
|
17
|
+
parse_response(response[:content])
|
18
|
+
end
|
19
|
+
|
20
|
+
protected
|
21
|
+
|
22
|
+
def build_prompt(inputs)
|
23
|
+
{
|
24
|
+
system: build_system_prompt,
|
25
|
+
user: build_user_prompt(inputs)
|
26
|
+
}
|
27
|
+
end
|
28
|
+
|
29
|
+
def build_system_prompt
|
30
|
+
<<~PROMPT
|
31
|
+
You are a helpful AI assistant. You will be given inputs and must produce outputs according to the following specification:
|
32
|
+
|
33
|
+
#{format_signature}
|
34
|
+
|
35
|
+
Respond with only the requested output fields in a clear format.
|
36
|
+
#{format_descriptions}
|
37
|
+
PROMPT
|
38
|
+
end
|
39
|
+
|
40
|
+
def build_user_prompt(inputs)
|
41
|
+
lines = ['Given the following inputs:']
|
42
|
+
|
43
|
+
inputs.each do |key, value|
|
44
|
+
lines << "#{key}: #{format_value(value)}"
|
45
|
+
end
|
46
|
+
|
47
|
+
lines << "\nProvide the following outputs:"
|
48
|
+
signature.output_fields.each_key do |key|
|
49
|
+
lines << "#{key}:"
|
50
|
+
end
|
51
|
+
|
52
|
+
lines.join("\n")
|
53
|
+
end
|
54
|
+
|
55
|
+
def format_signature
|
56
|
+
"#{format_fields(signature.input_fields)} -> #{format_fields(signature.output_fields)}"
|
57
|
+
end
|
58
|
+
|
59
|
+
def format_fields(fields)
|
60
|
+
fields.map do |name, field|
|
61
|
+
type_str = field.type == :string ? '' : ": #{field.type}"
|
62
|
+
"#{name}#{type_str}"
|
63
|
+
end.join(', ')
|
64
|
+
end
|
65
|
+
|
66
|
+
def format_descriptions
|
67
|
+
descriptions = []
|
68
|
+
|
69
|
+
all_fields = signature.input_fields.merge(signature.output_fields)
|
70
|
+
all_fields.each do |name, field|
|
71
|
+
next unless field.description
|
72
|
+
|
73
|
+
descriptions << "- #{name}: #{field.description}"
|
74
|
+
end
|
75
|
+
|
76
|
+
return '' if descriptions.empty?
|
77
|
+
|
78
|
+
"\nField descriptions:\n#{descriptions.join("\n")}"
|
79
|
+
end
|
80
|
+
|
81
|
+
def format_value(value)
|
82
|
+
case value
|
83
|
+
when Array
|
84
|
+
value.map(&:to_s).join(', ')
|
85
|
+
when Hash
|
86
|
+
value.to_json
|
87
|
+
else
|
88
|
+
value.to_s
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def parse_response(content)
|
93
|
+
# Simple parser - looks for key: value patterns
|
94
|
+
result = {}
|
95
|
+
|
96
|
+
signature.output_fields.each_key do |field_name|
|
97
|
+
# Look for the field name followed by a colon
|
98
|
+
pattern = /#{Regexp.escape(field_name.to_s)}:\s*(.+?)(?=\n\w+:|$)/mi
|
99
|
+
match = content.match(pattern)
|
100
|
+
|
101
|
+
if match
|
102
|
+
value = match[1].strip
|
103
|
+
result[field_name] = parse_field_value(field_name, value)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
result
|
108
|
+
end
|
109
|
+
|
110
|
+
def parse_field_value(field_name, value_str)
|
111
|
+
field = signature.output_fields[field_name]
|
112
|
+
return value_str unless field
|
113
|
+
|
114
|
+
case field.type
|
115
|
+
when :int
|
116
|
+
value_str.to_i
|
117
|
+
when :float
|
118
|
+
value_str.to_f
|
119
|
+
when :bool
|
120
|
+
%w[true yes 1].include?(value_str.downcase)
|
121
|
+
when :list
|
122
|
+
# Simple list parsing - comma separated
|
123
|
+
value_str.split(',').map(&:strip)
|
124
|
+
when :hash
|
125
|
+
# Try to parse as JSON
|
126
|
+
begin
|
127
|
+
JSON.parse(value_str)
|
128
|
+
rescue StandardError
|
129
|
+
value_str
|
130
|
+
end
|
131
|
+
else
|
132
|
+
value_str
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
# Register in the main module namespace for convenience
|
140
|
+
module Desiru
|
141
|
+
Predict = Modules::Predict
|
142
|
+
end
|