Your First Structured AI Program
From prompt strings to reliable systems in 10 minutes
What We’re Building
Instead of throwing you into complex examples, let’s start with something simple but transformative: a Q&A system that actually works predictably.
By the end of this tutorial, you’ll have built an AI system that:
- Has a clear, typed interface
- Returns structured, predictable results
- Can be tested systematically
- Handles errors gracefully
Setting Up
First, let’s set up DSPy.rb in your project:
# Gemfile
gem 'dspy'
# In your code
require 'dspy'
# Configure your language model
DSPy.configure do |config|
config.lm = DSPy::LM.new('openai/gpt-4', api_key: ENV['OPENAI_API_KEY'])
end
The Old Way vs. The New Way
Let’s start by seeing the difference between prompt engineering and structured programming:
The Fragile Approach
# What most of us start with
def ask_question(question)
prompt = "Answer this question clearly and concisely: #{question}"
response = llm.complete(prompt)
# Hope it's in the format we expect...
response.strip
end
# Usage - crossing fingers
answer = ask_question("What is the capital of France?")
puts answer # "Paris" or "The capital of France is Paris." or "**Paris**" or...
The Structured Approach
# Define exactly what you want
class QuestionAnswering < DSPy::Signature
description "Answer questions accurately and concisely"
input do
const :question, String
end
output do
const :answer, String, desc: "A clear, concise answer"
const :confidence, Float, desc: "How confident are you? (0.0-1.0)"
end
end
# Create a reliable system
qa_system = DSPy::Predict.new(QuestionAnswering)
# Use it predictably
result = qa_system.call(question: "What is the capital of France?")
puts result.answer # "Paris"
puts result.confidence # 0.95
Understanding What Just Happened
1. Clear Interface Definition
The DSPy::Signature defines exactly what goes in and what comes out:
class QuestionAnswering < DSPy::Signature
description "Answer questions accurately and concisely"
input do
const :question, String # Input is always a string
end
output do
const :answer, String, desc: "A clear, concise answer"
const :confidence, Float, desc: "How confident are you? (0.0-1.0)"
end
end
This signature acts like a contract—the AI system knows exactly what it should produce.
2. Predictable Module Creation
qa_system = DSPy::Predict.new(QuestionAnswering)
DSPy::Predict takes your signature and creates a module that can reliably execute that reasoning pattern.
3. Structured Results
result = qa_system.call(question: "What is the capital of France?")
# You get structured data back
result.answer # Always a string
result.confidence # Always a float between 0.0 and 1.0
Making It More Sophisticated
Let’s enhance our Q&A system to handle different types of questions:
class SmartQuestionAnswering < DSPy::Signature
description "Answer questions with appropriate depth and context"
input do
const :question, String
const :context, String, desc: "Additional context if available"
end
output do
const :answer, String, desc: "A clear, appropriately detailed answer"
const :confidence, Float, desc: "Confidence level (0.0-1.0)"
const :question_type, String, enum: ["factual", "analytical", "creative", "unclear"]
const :sources_needed, T::Boolean, desc: "Would this benefit from external sources?"
end
end
smart_qa = DSPy::Predict.new(SmartQuestionAnswering)
# Try different types of questions
factual_result = smart_qa.call(
question: "What is the boiling point of water?",
context: ""
)
analytical_result = smart_qa.call(
question: "Why did the Roman Empire fall?",
context: "We're discussing historical patterns of civilizational decline"
)
puts factual_result.question_type # "factual"
puts factual_result.sources_needed # false
puts analytical_result.question_type # "analytical"
puts analytical_result.sources_needed # true
Making It More Sophisticated with Advanced Sorbet Types
Let’s enhance our Q&A system to handle different types of questions using more Sorbet types:
class SmartQuestionAnswering < DSPy::Signature
description "Answer questions with appropriate depth and context"
input do
const :question, String
const :context, T.nilable(String), desc: "Additional context if available"
const :max_length, T.nilable(Integer), default: 100
end
output do
const :answer, String, desc: "A clear, appropriately detailed answer"
const :confidence, Float, desc: "Confidence level (0.0-1.0)"
const :question_type, T.any(String, Symbol), enum: [:factual, :analytical, :creative, :unclear]
const :sources_needed, T::Boolean, desc: "Would this benefit from external sources?"
const :follow_up_questions, T::Array[String], desc: "Suggested follow-up questions"
end
end
smart_qa = DSPy::Predict.new(SmartQuestionAnswering)
# The Sorbet types provide runtime validation
result = smart_qa.call(
question: "Why did the Roman Empire fall?",
context: "We're discussing historical patterns of civilizational decline",
max_length: 200
)
puts result.question_type # :analytical
puts result.sources_needed # true
puts result.follow_up_questions # ["What were the economic factors?", "How did military issues contribute?"]
Notice how we’re using idiomatic Ruby with full Sorbet type support:
T.nilable(String)for optional fieldsT.any(String, Symbol)for flexible typesT::Array[String]for typed arraysT::Booleanfor boolean validationenum:for constrained valuesdefault:for optional parameters
This isn’t just type checking—it’s runtime validation that ensures your LLM responses conform to your Ruby interfaces.
Building ReAct Agents with Ruby Types
DSPy.rb’s ReAct agents also use idiomatic Ruby type definitions for tools:
# Define tools with clear Ruby interfaces and Sorbet type signatures
class WeatherTool < DSPy::Tools::Base
extend T::Sig
tool_name 'weather'
tool_description "Get current weather for a location"
# Define a response struct for type safety
class WeatherResponse < T::Struct
const :temperature, Float
const :condition, String
const :humidity, Float
const :forecast, T::Array[T.untyped]
end
sig { params(location: String, units: String).returns(WeatherResponse) }
def call(location:, units: "celsius")
# Your weather API logic here
WeatherResponse.new(
temperature: 22.5,
condition: "Partly cloudy",
humidity: 0.65,
forecast: []
)
end
end
class TravelPlanner < DSPy::Signature
description "Plan travel itineraries using available tools"
input do
const :destination, String
const :duration, Integer, desc: "Number of days"
const :budget, T.nilable(Float)
end
output do
const :itinerary, String
const :estimated_cost, Float
const :weather_considerations, String
end
end
# Create a ReAct agent with typed tools
planner = DSPy::ReAct.new(
signature: TravelPlanner,
tools: [WeatherTool.new]
)
result = planner.call(
destination: "Tokyo",
duration: 5,
budget: 2000.0
)
The beauty here is that everything is typed Ruby—no YAML configs, no JSON schemas, just Ruby classes with Sorbet types that provide both static analysis and runtime validation.
Adding Error Handling
Real systems need to handle edge cases gracefully:
class RobustQuestionAnswering < DSPy::Signature
description "Answer questions with error handling and uncertainty"
input do
const :question, String
end
output do
const :answer, String, desc: "Best available answer"
const :confidence, Float, desc: "Confidence level (0.0-1.0)"
const :status, String, enum: ["answered", "uncertain", "insufficient_info", "unclear_question"]
const :clarification_needed, T.nilable(String), desc: "What clarification would help?"
end
end
robust_qa = DSPy::Predict.new(RobustQuestionAnswering)
# Test with a vague question
vague_result = robust_qa.call(question: "What about that thing?")
puts vague_result.status # "unclear_question"
puts vague_result.clarification_needed # "Could you specify what 'thing' you're referring to?"
puts vague_result.confidence # 0.1
Testing Your System
Here’s the beautiful part—you can now test AI behavior systematically:
# spec/qa_system_spec.rb
RSpec.describe "Question Answering System" do
let(:qa_system) { DSPy::Predict.new(QuestionAnswering) }
describe "factual questions" do
it "answers basic facts confidently" do
result = qa_system.call(question: "What is 2 + 2?")
expect(result.answer).to eq("4")
expect(result.confidence).to be > 0.9
end
it "handles mathematical concepts" do
result = qa_system.call(question: "What is the square root of 16?")
expect(result.answer).to eq("4")
expect(result.confidence).to be > 0.8
end
end
describe "uncertain questions" do
it "expresses appropriate uncertainty" do
result = qa_system.call(question: "What will happen tomorrow?")
expect(result.confidence).to be < 0.5
expect(result.answer).to include("uncertain")
end
end
describe "invalid questions" do
it "handles nonsensical input gracefully" do
result = qa_system.call(question: "Colorless green ideas sleep furiously")
expect(result.confidence).to be < 0.3
end
end
end
What You’ve Accomplished
In just a few minutes, you’ve:
- Moved from strings to structure - Clear interfaces instead of prompt manipulation
- Gained predictability - Know exactly what format you’ll get back
- Enabled systematic testing - Can verify AI behavior like any other code
- Built error handling - System degrades gracefully with uncertain inputs
- Created transparency - Can see confidence levels and reasoning
Reflection Questions
Before moving on, take a moment to think about this transformation:
About Your Current Approach:
- How much time do you typically spend debugging prompt formatting?
- What AI systems have you built that feel fragile or unpredictable?
- How do you currently test AI behavior in your applications?
About This New Approach:
- What surprises you most about structured AI programming?
- How might this change your approach to building AI features?
- What kinds of AI systems would you build if reliability wasn’t a concern?
Your Next Steps
You’ve just experienced the foundation of structured AI programming. From here, you can:
🔧 Deepen Your Understanding
Core Concepts →
Learn about Chain of Thought, ReAct agents, and module composition
🏗️ Build More Complex Systems
System Building →
Chain multiple reasoning steps into powerful workflows
🤝 Create AI That Uses Tools
Collaboration Patterns →
Build agents that can interact with external systems
The Path Forward
This simple Q&A system demonstrates the fundamental shift from prompt engineering to AI programming. As you continue learning, you’ll discover how to:
- Chain reasoning steps for complex problems
- Build agents that use tools effectively
- Create self-improving systems that optimize over time
- Compose modules into sophisticated applications
But the core principle remains the same: clear interfaces, predictable behavior, systematic testing.
“Every complex AI system starts with a simple, reliable foundation. You’ve just built yours.”