Rails Integration Guide
Place enum coercion, provider calls, caching, callbacks, and instrumentation behind explicit Rails service and job boundaries.
Enum Handling
DSPy.rb coerces returned enum strings into the T::Enum declared by the signature.
Recognize the enum mismatch
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
)
Compare the declared enum
When a signature declares a T::Enum, prediction coercion converts a compatible returned string to that enum:
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
Use a Rails service object to keep the provider call, returned value, and application mapping together:
# 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 before, after, and around lifecycle callbacks. For callback order and failure behavior, see Module Lifecycle Callbacks.
Callbacks with Service Objects
Use callbacks to add instrumentation, logging, or state management to service objects:
# app/services/content_analyzer.rb
class ContentAnalyzer < DSPy::Module
before :log_analysis_start
after :log_analysis_end
around :with_performance_tracking
def initialize
super # Required for DSPy::Module
@analyzer = DSPy::Predict.new(AnalysisSignature)
end
def forward(content:) # Use forward, not call - callbacks hook into forward
@content = content
result = @analyzer.call(content: content)
ContentAnalysis.create!(
content: content,
sentiment: result.sentiment.serialize,
topics: result.topics
)
result # Return result directly
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")
StatsD.timing('content_analyzer.duration', duration * 1000)
result
end
end
# Usage - call works because DSPy::Module aliases call to forward
analyzer = ContentAnalyzer.new
result = analyzer.call(content: "Some content")
Callbacks with ActiveRecord Integration
Use callbacks to load and save state through the ActiveRecord boundary around each module call:
# 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
Keep Rails Ownership Explicit
- Share callback logic with concerns - Extract repeated logging, metrics, and error handling
- Coordinate lifecycle hooks - Align module callbacks with Active Record callbacks
- Use Rails instrumentation - Publish application events through
ActiveSupport::Notifications - Report failures - Use
aroundcallbacks to send errors to the application’s tracker - Measure execution time - Report callback duration to the application’s APM service
For enum fields, compare the returned value with enum constants and call .serialize before storing it in a string column. Keep provider calls in service objects or jobs where timeout, retry, and telemetry policy are explicit.