You’ve built a program that finds prospects and writes sales emails. Before it sends anything, you need a definition of acceptable output.
Some requirements are deterministic: an address is present, an unsubscribe mechanism exists, or a claim appears in approved source data. Other judgments are harder to encode as rules: whether the prospect fits the campaign, whether personalization is supported by evidence, and whether the email sounds credible.
An LLM judge can score those qualitative dimensions. It should complement deterministic checks and human review, not replace either one.
What Rules Can And Cannot Tell You
A keyword metric can detect whether an email mentions a name or company:
def evaluate_personalization(email, prospect)
score = 0.0
score += 0.2 if email.include?(prospect.first_name)
score += 0.3 if email.include?(prospect.company)
score += 0.5 if email.match?(/recent|news|announcement/i)
score
end
It cannot establish that the reference is accurate or naturally connected to the offer. An LLM judge can inspect that context, but its answer is still a model prediction. It can vary between runs, inherit model biases, or confidently approve unsupported claims.
Engagement metrics answer a different question:
def evaluate_campaign_performance(campaign)
{
open_rate: campaign.opens.count.to_f / campaign.sends.count,
reply_rate: campaign.replies.count.to_f / campaign.sends.count,
positive_reply_rate: campaign.positive_replies.count.to_f / campaign.replies.count
}
end
Those measurements arrive after sending and are affected by deliverability, targeting, timing, and the offer itself. They are useful outcome evidence, but they do not make a pre-send judge correct.
Give The Judge Structured Evidence
The judge needs the targeting criteria, the selected prospect, the generated email, and the sender’s approved context. T::Struct makes those boundaries visible.
class TargetCriteria < T::Struct
const :role, String
const :company, String
const :industry, String
const :seniority_level, T.nilable(String)
const :department, T.nilable(String)
end
class ProspectProfile < T::Struct
const :first_name, String
const :last_name, String
const :title, String
const :company, String
const :industry, String
const :linkedin_url, T.nilable(String)
const :company_size, T.nilable(String)
end
class EmailCampaign < T::Struct
const :subject, String
const :body, String
const :sender_name, String
const :sender_email, String
const :signature, T.nilable(String)
end
class SenderContext < T::Struct
const :company, String
const :value_proposition, String
const :industry_focus, T.nilable(String)
const :case_studies, T.nilable(T::Array[String])
end
The output uses one structure per scored dimension. That keeps scores paired with the judge’s explanation and constrains the final recommendation.
class DimensionEvaluation < T::Struct
const :score, Float
const :reasoning, String
end
class SendRecommendation < T::Enum
enums do
Send = new("SEND")
Revise = new("REVISE")
Reject = new("REJECT")
end
end
class AISDRJudge < DSPy::Signature
description "Evaluate an AI-generated sales campaign against the supplied targeting and sender evidence"
input do
const :target_criteria, TargetCriteria
const :prospect_profile, ProspectProfile
const :email_campaign, EmailCampaign
const :sender_context, SenderContext
end
output do
const :prospect_relevance, DimensionEvaluation
const :personalization, DimensionEvaluation
const :value_proposition, DimensionEvaluation
const :professionalism, DimensionEvaluation
const :compliance, DimensionEvaluation
const :overall_quality_score, Float,
description: "Overall campaign quality from 0.0 to 1.0"
const :send_recommendation, SendRecommendation
end
end
Typed output prevents misspelled recommendation values and rejects malformed result shapes. It does not validate the substance of the judgment.
Turn The Judge Into A Metric
Configure the judge once, outside the metric. The metric then adapts each program prediction into judge inputs and returns the shape DSPy::Evals expects.
judge_lm = DSPy::LM.new(
"openai/gpt-4o-mini",
api_key: ENV["OPENAI_API_KEY"]
)
judge = DSPy::ChainOfThought.new(AISDRJudge)
judge.configure { |config| config.lm = judge_lm }
ai_sdr_judge_metric = lambda do |example, prediction|
next { passed: false, score: 0.0 } unless prediction
request = if example.respond_to?(:input_values)
example.input_values[:campaign_request]
else
example.dig(:input, :campaign_request)
end
output = prediction.sdr_output
judgment = judge.call(
target_criteria: TargetCriteria.new(
role: request.target_role,
company: request.target_company,
industry: request.target_industry,
seniority_level: request.seniority_level,
department: request.department
),
prospect_profile: output.prospect,
email_campaign: output.email,
sender_context: SenderContext.new(
company: output.sender_company,
value_proposition: request.value_proposition,
industry_focus: request.industry_focus,
case_studies: request.case_studies
)
)
weights = {
prospect_relevance: 0.25,
personalization: 0.25,
value_proposition: 0.20,
professionalism: 0.15,
compliance: 0.15
}
score = weights.sum do |name, weight|
judgment.public_send(name).score * weight
end
{
passed: judgment.send_recommendation == SendRecommendation::Send && score >= 0.7,
score: score,
recommendation: judgment.send_recommendation.serialize,
judgment: judgment
}
rescue StandardError => e
DSPy.logger.warn("LLM judge evaluation failed: #{e.message}")
{ passed: false, score: 0.0, error: e.message }
end
A bare numeric return value would be treated as truthy by DSPy::Evals; it would not become the batch’s numeric score. Returning { passed:, score: } preserves both the release decision and the continuous measurement.
The weights and the 0.7 threshold are application policy. Start with values you can explain, then calibrate them against reviewed examples.
Evaluate The Generator
The campaign generator remains a separate program. The judge metric evaluates its observable output.
class CampaignRequest < T::Struct
const :target_role, String
const :target_company, String
const :target_industry, String
const :value_proposition, String
const :seniority_level, T.nilable(String)
const :department, T.nilable(String)
const :industry_focus, T.nilable(String)
const :case_studies, T.nilable(T::Array[String])
end
class SDRCampaignOutput < T::Struct
const :prospect, ProspectProfile
const :email, EmailCampaign
const :sender_company, String
const :confidence_score, Float
const :reasoning, T.nilable(String)
end
class AISDRSignature < DSPy::Signature
description "Generate a prospect and sales email for the supplied campaign"
input do
const :campaign_request, CampaignRequest
end
output do
const :sdr_output, SDRCampaignOutput
end
end
The judge does not compare the generator with a reference SDRCampaignOutput, so these evaluation cases use the hash input format supported by DSPy::Evals:
requests = [
CampaignRequest.new(
target_role: "VP of Engineering",
target_company: "TechCorp",
target_industry: "Software",
value_proposition: "Reduce deployment time with our DevOps platform",
seniority_level: "Executive",
department: "Engineering",
industry_focus: "Enterprise Software",
case_studies: ["A reviewed customer case study"]
)
]
examples = requests.map do |request|
{ input: { campaign_request: request } }
end
program = DSPy::Predict.new(AISDRSignature)
result = DSPy::Evals.new(
program,
metric: ai_sdr_judge_metric
).evaluate(examples)
puts "Average judge score: #{result.score}%"
puts "Approved: #{result.passed_examples}/#{result.total_examples}"
The batch score is already a percentage. pass_rate is the fraction of examples that met the explicit send decision.
Keep The Reasons Inspectable
The metric stores the typed judgment with each result:
result.results.each do |example_result|
judgment = example_result.metrics[:judgment]
if judgment
puts "Recommendation: #{judgment.send_recommendation.serialize}"
puts "Prospect fit: #{judgment.prospect_relevance.score}"
puts judgment.prospect_relevance.reasoning
puts "Personalization: #{judgment.personalization.score}"
puts judgment.personalization.reasoning
else
puts "Judge error: #{example_result.metrics[:error]}"
end
end
Reasoning helps a reviewer inspect the decision. It should not be mistaken for proof that the score is correct. Store the source evidence, generated campaign, model configuration, metric version, and judgment together so disagreements can be reproduced.
Calibrate Before Automating A Send Decision
Build a set of campaigns reviewed by people responsible for sales quality, brand risk, and compliance. Then compare the judge with those decisions.
Calibration should measure more than correlation:
- Agreement on
SEND,REVISE, andREJECT. - False approvals, especially unsupported claims and compliance failures.
- Score variance across repeated judge calls.
- Performance across industries, languages, and prospect groups.
- Drift after changing the judge model, signature, or evidence shape.
There is no universal target such as correlation > 0.8 that makes a judge safe. The acceptable error rate depends on what happens after approval. For high-consequence categories, route the result to a person or a deterministic policy check.
Multiple Judges Add Cost, Not Automatic Consensus
Specialized judges can separate prospect fit, copy quality, and compliance. Run independent calls concurrently when the provider clients and application runtime support it, then combine explicit typed results.
Three similar prompts against the same model are not three independent experts. Majority vote can repeat the same bias with three invoices. Use multiple judges when they have distinct rubrics or models and when calibration shows that the combination improves decisions.
Optimize Against Human Evidence
Once you have human-rated campaigns and a stable metric, an optimizer can compile supported judge parameters such as instructions and demonstrations.
human_agreement = lambda do |example, prediction|
prediction.send_recommendation.serialize ==
example.expected_values[:send_recommendation]
end
optimizer = DSPy::Teleprompt::MIPROv2.new(metric: human_agreement)
result = optimizer.compile(
DSPy::ChainOfThought.new(AISDRJudge),
trainset: human_rated_train,
valset: human_rated_validation
)
optimized_judge = result.optimized_program
Compile the judge and generator in separate experiments. Otherwise a changed generator can hide a worse judge, or a changed judge can make an unchanged generator look better. Evaluate the selected artifacts on held-out human ratings before promoting them.
The evaluation guide covers result handling and metrics. See custom metrics for richer score shapes and prompt optimization for supported compilation workflows.