Rails Integration Guide
DSPy.rb is designed to work seamlessly with Ruby on Rails applications. This guide covers common integration patterns and solutions to potential issues.
Enum Handling
One common source of confusion is how DSPy.rb handles enums in Rails applications. The good news: DSPy automatically deserializes string values to T::Enum instances.
The Problem
You might see code like this in Rails applications:
# Workaround code (NOT NEEDED)
result = OpenStruct.new(
sub_queries: raw_result.sub_queries,
search_strategy: raw_result.search_strategy, # Manual enum handling
discovered_topics: raw_result.discovered_topics,
reasoning: raw_result.reasoning
)
The Solution
DSPy.rb automatically handles enum conversion:
class SearchStrategy < DSPy::Signature
class Strategy < T::Enum
enums do
Parallel = new('parallel')
Sequential = new('sequential')
Hybrid = new('hybrid')
end
end
output do
const :strategy, Strategy
end
end
# When LLM returns: { "strategy": "parallel" }
# DSPy automatically converts to: Strategy::Parallel
result = predictor.call(query: "search term")
puts result.strategy.class # => SearchStrategy::Strategy::Parallel
Working with ActiveRecord Enums
When integrating with ActiveRecord enums, you can map between DSPy enums and Rails enums:
# app/models/search_result.rb
class SearchResult < ApplicationRecord
enum :strategy, {
parallel: 0,
sequential: 1,
hybrid: 2
}
end
# app/services/search_service.rb
class SearchService
def perform(query)
# DSPy returns T::Enum instance
dspy_result = @predictor.call(query: query)
# Convert to Rails enum value
SearchResult.create!(
query: query,
strategy: dspy_result.strategy.serialize # Returns the string value
)
end
end
Debugging Enum Values
If you’re unsure about the enum value, use these debugging techniques:
# Check the actual class
puts result.strategy.class
# => SearchStrategy::Strategy::Parallel
# Get the string representation
puts result.strategy.serialize
# => "parallel"
# Compare with enum values
if result.strategy == SearchStrategy::Strategy::Parallel
# Handle parallel strategy
end
# Use case statements
case result.strategy
when SearchStrategy::Strategy::Parallel
# Parallel logic
when SearchStrategy::Strategy::Sequential
# Sequential logic
end
Service Object Pattern
DSPy.rb works great with Rails service objects:
# app/services/content_analyzer.rb
class ContentAnalyzer < ApplicationService
class Analysis < DSPy::Signature
description "Analyze content sentiment and topics"
class Sentiment < T::Enum
enums do
Positive = new('positive')
Negative = new('negative')
Neutral = new('neutral')
end
end
input do
const :content, String
end
output do
const :sentiment, Sentiment
const :topics, T::Array[String]
const :summary, String
end
end
def initialize
@analyzer = DSPy::Predict.new(Analysis)
end
def call(content)
result = @analyzer.call(content: content)
# Store in database
ContentAnalysis.create!(
content: content,
sentiment: result.sentiment.serialize,
topics: result.topics,
summary: result.summary
)
Success(result)
rescue DSPy::PredictionInvalidError => e
Failure(e.errors)
end
end
ActiveJob Integration
Process AI tasks asynchronously:
# app/jobs/analyze_content_job.rb
class AnalyzeContentJob < ApplicationJob
queue_as :ai_processing
def perform(article_id)
article = Article.find(article_id)
result = ContentAnalyzer.call(article.content)
if result.success?
article.update!(
sentiment: result.value.sentiment.serialize,
ai_summary: result.value.summary,
topics: result.value.topics
)
else
# Handle errors
Rails.logger.error "Analysis failed: #{result.failure}"
end
end
end
Rails Cache Integration
Cache AI responses to reduce API calls:
class CachedPredictor
def initialize(signature_class)
@predictor = DSPy::Predict.new(signature_class)
end
def call(**inputs)
cache_key = generate_cache_key(inputs)
Rails.cache.fetch(cache_key, expires_in: 1.hour) do
@predictor.call(**inputs)
end
end
private
def generate_cache_key(inputs)
[
'dspy',
@predictor.signature_class.name,
Digest::SHA256.hexdigest(inputs.to_json)
].join(':')
end
end
Configuration in Rails
Set up DSPy in an initializer:
# config/initializers/dspy.rb
Rails.application.config.after_initialize do
DSPy.configure do |config|
# Use Rails credentials for API keys
config.lm = DSPy::LM.new(
'openai/gpt-4o-mini',
api_key: Rails.application.credentials.openai_api_key
)
# Configure observability based on environment
config.logger = if Rails.env.production?
Dry.Logger(:dspy, formatter: :json) do |logger|
logger.add_backend(stream: Rails.root.join("log/dspy.log"))
end
else
Dry.Logger(:dspy) do |logger|
logger.add_backend(level: :debug, stream: $stdout)
end
end
end
end
Model Validations
Add validations for enum fields:
class Article < ApplicationRecord
VALID_SENTIMENTS = %w[positive negative neutral]
validates :sentiment, inclusion: { in: VALID_SENTIMENTS }, allow_nil: true
# Convert DSPy enum to Rails attribute
def sentiment_from_dspy=(enum_value)
self.sentiment = enum_value&.serialize
end
# Convert Rails attribute to DSPy enum
def sentiment_as_enum
return nil unless sentiment.present?
ArticleAnalyzer::Analysis::Sentiment.deserialize(sentiment)
end
end
Form Helpers
Create form helpers for enum fields:
# app/helpers/dspy_form_helper.rb
module DspyFormHelper
def dspy_enum_select(form, field, enum_class, options = {})
choices = enum_class.values.map do |enum_value|
[enum_value.serialize.humanize, enum_value.serialize]
end
form.select(field, choices, options)
end
end
# In your view
<%= form_with model: @article do |f| %>
<%= dspy_enum_select(f, :sentiment, ArticleAnalyzer::Analysis::Sentiment) %>
<% end %>
Testing with RSpec
Test your DSPy integrations:
# spec/services/content_analyzer_spec.rb
RSpec.describe ContentAnalyzer do
describe '#call' do
let(:content) { "This product is amazing!" }
it 'correctly deserializes enum values' do
VCR.use_cassette('content_analyzer/positive') do
result = described_class.call(content)
expect(result).to be_success
expect(result.value.sentiment).to be_a(ContentAnalyzer::Analysis::Sentiment)
expect(result.value.sentiment.serialize).to eq('positive')
end
end
it 'stores enum as string in database' do
VCR.use_cassette('content_analyzer/positive') do
expect {
described_class.call(content)
}.to change(ContentAnalysis, :count).by(1)
analysis = ContentAnalysis.last
expect(analysis.sentiment).to eq('positive')
end
end
end
end
Common Pitfalls and Solutions
1. Enum Comparison Issues
# WRONG - comparing enum instance with string
if result.strategy == "parallel"
# CORRECT - compare with enum value
if result.strategy == Strategy::Parallel
# ALSO CORRECT - serialize for string comparison
if result.strategy.serialize == "parallel"
2. JSON Serialization
# When returning DSPy results as JSON
class ArticlesController < ApplicationController
def analyze
result = ContentAnalyzer.call(params[:content])
render json: {
sentiment: result.sentiment.serialize, # Convert enum to string
topics: result.topics,
summary: result.summary
}
end
end
3. Strong Parameters
# Handle enum fields in strong parameters
def article_params
params.require(:article).permit(:content).tap do |p|
# Convert string to enum if needed
if p[:sentiment].is_a?(String)
p[:sentiment] = ArticleAnalyzer::Analysis::Sentiment.deserialize(p[:sentiment])
end
end
end
Using Lifecycle Callbacks with Rails Patterns
DSPy modules support Rails-style lifecycle callbacks (before
, after
, around
) that work seamlessly with Rails patterns. For complete callback documentation, see the Modules documentation.
Callbacks with Service Objects
Use callbacks to add instrumentation, logging, or state management to service objects:
# app/services/content_analyzer.rb
class ContentAnalyzer < ApplicationService
include DSPy::Module::Callbacks
before :log_analysis_start
after :log_analysis_end
around :with_performance_tracking
def initialize
@analyzer = DSPy::Predict.new(AnalysisSignature)
end
def call(content)
@content = content
result = @analyzer.call(content: content)
ContentAnalysis.create!(
content: content,
sentiment: result.sentiment.serialize,
topics: result.topics
)
Success(result)
end
private
def log_analysis_start
Rails.logger.info("Starting analysis for content: #{@content[0..50]}...")
end
def log_analysis_end
Rails.logger.info("Analysis completed")
end
def with_performance_tracking
start_time = Time.current
result = yield
duration = Time.current - start_time
Rails.logger.info("Analysis took #{duration} seconds")
# Track metrics in your APM
StatsD.timing('content_analyzer.duration', duration * 1000)
result
end
end
Callbacks with ActiveRecord Integration
Use callbacks to automatically load and save state from ActiveRecord:
# app/models/conversation.rb
class Conversation < ApplicationRecord
has_many :messages
# Store conversation state as JSON
serialize :context, JSON
end
# app/services/chatbot_service.rb
class ChatbotService < DSPy::Module
around :manage_conversation_state
def initialize(conversation_id:)
super()
@conversation_id = conversation_id
@conversation = nil
@predictor = DSPy::Predict.new(ChatSignature)
end
def forward(user_message:)
@predictor.call(
user_message: user_message,
context: @conversation.context
)
end
private
def manage_conversation_state
# Load conversation from database
@conversation = Conversation.find(@conversation_id)
# Execute the prediction
result = yield
# Update conversation state
@conversation.messages.create!(
role: 'user',
content: result.user_message
)
@conversation.messages.create!(
role: 'assistant',
content: result.response
)
@conversation.update!(
context: result.context,
updated_at: Time.current
)
result
end
end
# Usage in controller
class ChatController < ApplicationController
def reply
service = ChatbotService.new(conversation_id: params[:conversation_id])
result = service.call(user_message: params[:message])
render json: { response: result.response }
end
end
Callbacks with Rails Concerns
Extract reusable callback behavior into Rails concerns:
# app/concerns/observable_dspy_module.rb
module ObservableDspyModule
extend ActiveSupport::Concern
included do
before :start_observation
after :end_observation
end
private
def start_observation
@observation = {
started_at: Time.current,
module_name: self.class.name
}
Rails.logger.info("DSPy module started: #{@observation[:module_name]}")
end
def end_observation
duration = Time.current - @observation[:started_at]
Rails.logger.info(
"DSPy module completed: #{@observation[:module_name]} in #{duration}s"
)
# Send to APM
NewRelic::Agent.record_metric(
"DSPy/#{@observation[:module_name]}/Duration",
duration
)
end
end
# Use in your modules
class ArticleAnalyzer < DSPy::Module
include ObservableDspyModule # Adds before/after callbacks
def initialize
super
@predictor = DSPy::Predict.new(AnalysisSignature)
end
def forward(article:)
@predictor.call(content: article.content)
end
end
Callbacks with Background Jobs
Use callbacks to manage job state and error tracking:
# app/jobs/ai_analysis_job.rb
class AiAnalysisJob < ApplicationJob
queue_as :ai_processing
class AnalysisModule < DSPy::Module
before :mark_job_running
after :mark_job_completed
around :with_error_tracking
attr_accessor :article
def initialize(article:)
super()
@article = article
@predictor = DSPy::Predict.new(AnalysisSignature)
end
def forward
@predictor.call(content: @article.content)
end
private
def mark_job_running
@article.update!(analysis_status: 'running')
end
def mark_job_completed
@article.update!(analysis_status: 'completed')
end
def with_error_tracking
begin
yield
rescue StandardError => e
@article.update!(
analysis_status: 'failed',
error_message: e.message
)
# Report to error tracking service
Sentry.capture_exception(e, extra: {
article_id: @article.id,
module: self.class.name
})
raise e
end
end
end
def perform(article_id)
article = Article.find(article_id)
module_instance = AnalysisModule.new(article: article)
result = module_instance.call
article.update!(
sentiment: result.sentiment.serialize,
topics: result.topics,
summary: result.summary
)
end
end
Callbacks with Rails Instrumentation
Integrate with Rails’ ActiveSupport::Notifications:
# app/services/instrumented_analyzer.rb
class InstrumentedAnalyzer < DSPy::Module
around :with_rails_instrumentation
def initialize
super
@predictor = DSPy::Predict.new(AnalysisSignature)
end
def forward(content:)
@predictor.call(content: content)
end
private
def with_rails_instrumentation
ActiveSupport::Notifications.instrument(
'dspy.analysis',
module: self.class.name
) do |payload|
start_time = Time.current
result = yield
payload[:duration] = Time.current - start_time
payload[:success] = true
result
end
rescue StandardError => e
ActiveSupport::Notifications.instrument(
'dspy.analysis.error',
module: self.class.name,
error: e.class.name,
message: e.message
)
raise
end
end
# Subscribe to notifications
# config/initializers/dspy_instrumentation.rb
ActiveSupport::Notifications.subscribe('dspy.analysis') do |name, start, finish, id, payload|
Rails.logger.info("DSPy Analysis: #{payload[:module]} completed in #{payload[:duration]}s")
end
ActiveSupport::Notifications.subscribe('dspy.analysis.error') do |name, start, finish, id, payload|
Rails.logger.error("DSPy Analysis Error: #{payload[:module]} - #{payload[:error]}: #{payload[:message]}")
end
Best Practices for Rails Integration
- Use concerns for shared callback logic - Extract common patterns like logging, metrics, and error handling
- Integrate with ActiveRecord callbacks - Coordinate module callbacks with model lifecycle hooks
- Leverage Rails instrumentation - Use ActiveSupport::Notifications for observability
- Handle errors gracefully - Use
around
callbacks to catch and report errors to your error tracking service - Track performance - Use callbacks to measure and report execution time to APM tools
Conclusion
DSPy.rb’s automatic enum handling makes Rails integration straightforward. The key points:
- Enums are automatically deserialized - no manual parsing needed
- Use
.serialize
to get string values for database storage - Compare enums properly - use enum constants, not strings
- Cache AI responses to improve performance
- Use service objects for clean architecture
If you’re still seeing issues with enum handling, ensure you’re using the latest version of DSPy.rb (0.8.1+) which includes improved type coercion.