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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +1 -0
  3. data/.rubocop.yml +55 -0
  4. data/CLAUDE.md +22 -0
  5. data/Gemfile +36 -0
  6. data/Gemfile.lock +255 -0
  7. data/LICENSE +21 -0
  8. data/README.md +343 -0
  9. data/Rakefile +18 -0
  10. data/desiru.gemspec +44 -0
  11. data/examples/README.md +55 -0
  12. data/examples/async_processing.rb +135 -0
  13. data/examples/few_shot_learning.rb +66 -0
  14. data/examples/graphql_api.rb +190 -0
  15. data/examples/graphql_integration.rb +114 -0
  16. data/examples/rag_retrieval.rb +80 -0
  17. data/examples/simple_qa.rb +31 -0
  18. data/examples/typed_signatures.rb +45 -0
  19. data/lib/desiru/async_capable.rb +170 -0
  20. data/lib/desiru/cache.rb +116 -0
  21. data/lib/desiru/configuration.rb +40 -0
  22. data/lib/desiru/field.rb +171 -0
  23. data/lib/desiru/graphql/data_loader.rb +210 -0
  24. data/lib/desiru/graphql/executor.rb +115 -0
  25. data/lib/desiru/graphql/schema_generator.rb +301 -0
  26. data/lib/desiru/jobs/async_predict.rb +52 -0
  27. data/lib/desiru/jobs/base.rb +53 -0
  28. data/lib/desiru/jobs/batch_processor.rb +71 -0
  29. data/lib/desiru/jobs/optimizer_job.rb +45 -0
  30. data/lib/desiru/models/base.rb +112 -0
  31. data/lib/desiru/models/raix_adapter.rb +210 -0
  32. data/lib/desiru/module.rb +204 -0
  33. data/lib/desiru/modules/chain_of_thought.rb +106 -0
  34. data/lib/desiru/modules/predict.rb +142 -0
  35. data/lib/desiru/modules/retrieve.rb +199 -0
  36. data/lib/desiru/optimizers/base.rb +130 -0
  37. data/lib/desiru/optimizers/bootstrap_few_shot.rb +212 -0
  38. data/lib/desiru/program.rb +106 -0
  39. data/lib/desiru/registry.rb +74 -0
  40. data/lib/desiru/signature.rb +322 -0
  41. data/lib/desiru/version.rb +5 -0
  42. data/lib/desiru.rb +67 -0
  43. 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