Building Ruby-Idiomatic AI Applications with DSPy.rb
How DSPy.rb embraces Ruby conventions to make AI development feel natural. Learn about the design decisions that make DSPy.rb uniquely Ruby.
Vicente Reig
Fractional Engineering Lead • • 8 min read
When we started building DSPy.rb, we had a choice: create a direct port of the Python library or reimagine it through a Ruby lens. We chose the latter, and today I want to share how that decision shaped the library.
The Ruby Way vs The Python Way
Let’s start with a simple example. In Python’s DSPy, you might write:
class Sentiment(dspy.Signature):
"""Classify sentiment of text."""
sentence = dspy.InputField()
sentiment = dspy.OutputField()
In DSPy.rb, we embrace Ruby’s block-based DSL:
class Sentiment < DSPy::Signature
description "Classify sentiment of text"
input do
const :sentence, String
end
output do
const :sentiment, String
end
end
Notice how the Ruby version uses blocks for grouping related fields? This isn’t just aesthetic - it opens up possibilities for metaprogramming and dynamic field definitions that feel natural to Ruby developers.
Embracing Duck Typing
Ruby developers love duck typing, and DSPy.rb tools embrace this philosophy:
# Any object that responds to #call can be a tool
class WeatherService
def call(location:)
# Real implementation would call an API
{ temperature: 72, conditions: "sunny" }
end
end
# Lambda tools for simple operations
calculator = ->(expression:) { eval(expression) }
# Even a module with a class method works
module TimeHelper
def self.call(timezone: "UTC")
Time.now.in_time_zone(timezone)
end
end
# All work seamlessly with ReAct agents
agent = DSPy::ReAct.new(MySignature, tools: {
weather: WeatherService.new,
calculate: calculator,
current_time: TimeHelper
})
This flexibility means you can integrate DSPy.rb with existing Ruby code without wrapping everything in special adapter classes.
Enumerable All The Way Down
Ruby’s Enumerable module is one of its superpowers. DSPy.rb leverages this for batch processing:
class BatchClassifier < DSPy::Module
def initialize
@classifier = DSPy::Predict.new(Sentiment)
end
def process(texts)
texts.lazy # Process lazily for memory efficiency
.map { |text| @classifier.call(sentence: text) }
.select { |result| result.confidence > 0.8 }
.group_by(&:sentiment)
.transform_values(&:count)
end
end
# Process thousands of reviews efficiently
classifier = BatchClassifier.new
sentiment_counts = classifier.process(reviews)
# => { positive: 1823, negative: 423, neutral: 198 }
Configuration Blocks, Not YAML
While many libraries rely on YAML files, DSPy.rb uses Ruby blocks for configuration:
DSPy.configure do |config|
# LM configuration with nested options
config.lm = DSPy::LM.new('openai/gpt-4o-mini') do |lm|
lm.api_key = Rails.application.credentials.openai_api_key
lm.temperature = 0.7
lm.max_tokens = 1000
end
# Environment-aware instrumentation
config.instrumentation do |i|
i.enabled = Rails.env.production?
i.logger.level = Rails.env.development? ? :debug : :info
# Conditional subscribers
i.subscribers = [:logger]
i.subscribers << :newrelic if defined?(NewRelic)
i.subscribers << :otel if ENV['OTEL_ENDPOINT']
end
end
This approach provides full programmatic control and integrates naturally with Rails credentials and environment detection.
Method Chaining (Coming Soon)
We’re working on a chainable API that will feel right at home in Ruby:
# Future API - coming in v0.8.0
result = DSPy.predict(:question_answering)
.with_examples(training_data)
.with_instruction("Be concise and factual")
.with_temperature(0.3)
.optimize_for(:accuracy)
.cache_for(1.hour)
.call(question: "What is Ruby?")
This pattern is inspired by ActiveRecord’s query interface and will make complex configurations more readable.
Type Safety Without the Ceremony
We use Sorbet for type safety, but we keep it pragmatic:
class ArticleGenerator < DSPy::Signature
# Simple types just work
input do
const :topic, String
const :max_words, Integer, default: 500 # Defaults coming in v0.7.0
end
# Complex types are still readable
output do
const :title, String
const :sections, T::Array[String]
const :metadata, T::Hash[Symbol, T.untyped]
end
end
You get type checking where it matters without verbose annotations everywhere.
Rails Integration First-Class
DSPy.rb is designed to work seamlessly with Rails:
# app/services/content_moderator.rb
class ContentModerator < ApplicationService
def initialize
@classifier = DSPy::Predict.new(ToxicityCheck)
end
def call(comment)
Rails.cache.fetch(["toxicity", comment.cache_key], expires_in: 1.day) do
result = @classifier.call(text: comment.body)
# Integrate with ActiveRecord
comment.update!(
toxicity_score: result.score,
requires_moderation: result.score > 0.7
)
# Use Rails' ActiveJob for async processing
ModeratorNotificationJob.perform_later(comment) if result.toxic?
result
end
end
end
Introspection and Debugging
Ruby developers expect great introspection tools. DSPy.rb delivers:
# Inspect signature fields
ArticleGenerator.input_fields.each do |name, field|
puts "#{name}: #{field.type} (#{field.optional? ? 'optional' : 'required'})"
end
# Access full execution history
result = agent.forward(task: "Complex task")
result.history.each do |step|
puts "Step #{step.step}: #{step.thought}"
puts "Tools used: #{step.tool_calls.map(&:tool_name).join(', ')}"
end
# Enable detailed instrumentation
DSPy.config.instrumentation.logger.level = :debug
# Now you'll see every LLM call, tool execution, and timing info
What’s Next?
We’re continuing to make DSPy.rb more Ruby-like:
- Block-based signature definitions (experimental):
signature = DSPy.signature do description "Extract entities from text" input :text, String output :entities, Array[Entity] end
- ActiveModel integration for validations:
class UserQuery < DSPy::Signature include ActiveModel::Validations input do const :email, String validates :email, presence: true, format: /@/ end end
- Middleware stack for request/response processing:
DSPy.config.middleware do |m| m.use RateLimiter, requests_per_minute: 60 m.use ResponseCache, expires_in: 5.minutes m.use TokenCounter end
Try It Yourself
The best way to appreciate DSPy.rb’s Ruby-first design is to use it:
gem install dspy
Or in your Gemfile:
gem 'dspy', '~> 0.7'
We’d love to hear your thoughts on making DSPy.rb even more Ruby-idiomatic. What patterns from your favorite Ruby libraries should we adopt? Let us know in the GitHub discussions.
DSPy.rb is built by Rubyists, for Rubyists. We believe AI development should feel as natural as writing any other Ruby code.