Judge the Output: Pick the Better Prompt with Numbers You Can Defend
Hands-on lab · IDE in your browser

Judge the Output: Pick the Better Prompt with Numbers You Can Defend

Build an evaluation the way AI teams do: a stratified test set with the edge cases on purpose, rule checks that need no model, an LLM judge measured against human labels before it is trusted, a pairwise comparison that cancels position bias by swapping the order, and a paired bootstrap interval that says whether the new prompt is really better or you need more data.

Time
60 min
Checked steps
5
Level
Beginner
Setup
None
Read step 1

Hands-on labs require Pro · $29.99/mo · cancel anytime

Lab cockpit60 min · 5 stepsSession running
4 / 5 steps passingDecide with an interval, not a hunch · step 5 of 5
evals.py▶ Run✓ Check
# ---------- Step 5: decide with an interval, not a hunch ----------def bootstrap_diff(a, b, n=2000, seed=0):    """Paired bootstrap of mean(b) - mean(a): resample case indices with replacement n times    (the SAME indices for a and b), and return the 2.5th and 97.5th percentiles, rounded to 3 places."""         def decide(a, b, n=2000, seed=0):    """"B" if the whole interval is above 0, "A" if it is below 0, otherwise "tie"."""  
TerminalOutput

The job

Brightline's manager reads a one-line summary of every customer review each morning. A quick prompt writes them today, and a colleague claims a better one. Opinions differ, so you settle it with an evaluation: a test set that covers every kind of review, three checks that need no model, an LLM judge you first test against a person's labels, a head-to-head comparison that is not fooled by which answer comes first, and a confidence interval that says whether the difference is real.

5 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Build a test set that covers the cases that matter

    Brightline's manager reads a one-line summary of every review each morning.

    You writebuild_testset()
  2. 2

    Rules first: the checks that need no model

    Some qualities need no model to judge them, and those should always be checked by plain code: it is instant, free and never changes its mind.

    You writerule_checks()
  3. 3

    An LLM judge, checked against people

    Rules cannot tell whether a summary is *true to the review*.

    You writejudge_faithful()agreement()
  4. 4

    Compare two prompts without favouring a position

    Now write the challenger and compare.

    You writepairwise()
  5. 5

    Decide with an interval, not a hunch

    Now score every summary on everything you trust: it passes the rules and the judge finds it faithful (1), or not (0).

    You writebootstrap_diff()decide()

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Build a test set that covers the cases that matter

Brightline's manager reads a one-line summary of every review each morning. Someone wrote a quick prompt (prompt_a.txt); someone else says they can do better. By the end of this lab you will decide between two prompts with numbers you can defend.

Everything starts with the test set. Twenty random reviews could easily be all positive and none about price, and a prompt that fails on complaints would look perfect. So you stratify: take a few reviews from every kind that matters, and add the extremes on purpose, here the shortest and longest reviews, where summaries break most often.

reviews.jsonl has 24 reviews, each labelled with sentiment (positive, negative, mixed) and mentions_price.

Do this

1. Write build_testset(reviews, per_group=2, seed=0):

  1. rng = random.Random(seed): a random generator with a fixed seed, so the same test set comes out every time. A test set that changes between runs makes every comparison meaningless.
  2. Group the reviews by (sentiment, mentions_price), a dict of lists.
  3. For each group, rng.sample(members, min(per_group, len(members))). Go through the groups in a fixed order (sorted(groups, key=str)).
  4. Add the shortest and the longest review (by len(r["text"])).
  5. Return the chosen reviews sorted by id, each once.

2. Run. It prints the test set and saves testset.jsonl, which every later step uses.

evals.py, the file you edit100 lines
"""Judge two prompts with numbers: a test set, rule checks, an LLM judge you can trust, and a decision."""
import json
import random
import re
from concurrent.futures import ThreadPoolExecutor

from openai import OpenAI

CLIENT = OpenAI()
WRITER = "meta/llama-3.1-8b-instruct"    # writes the summaries
JUDGE = "meta/llama-3.3-70b-instruct"    # grades them: a stronger model than the one being graded


def load_jsonl(path):
    return [json.loads(line) for line in open(path, encoding="utf-8") if line.strip()]


# ---------- Step 1: a test set that covers the cases that matter ----------
def build_testset(reviews, per_group=2, seed=0):
    """Up to per_group reviews from every (sentiment, mentions_price) group, chosen with random.Random(seed),
    plus the shortest and the longest review. Return the chosen reviews sorted by id, no duplicates."""
    # TODO (Step 1):
    #   rng = random.Random(seed)
    #   group the reviews by (r["sentiment"], r["mentions_price"]) into a dict of lists
    #   for each group (in sorted(groups, key=str) order), sorted by id: rng.sample(members, min(per_group, len(members)))
    #   also add the shortest and the longest review (by len(text))
    #   return the chosen reviews sorted by id, each once (a dict keyed by id avoids duplicates)
    raise NotImplementedError("Step 1: write build_testset()")


# ---------- Step 2: checks that need no model ----------
NUMBER = re.compile(r"\d+(?:\.\d+)?")


def rule_checks(summary, review_text):
    """{"one_sentence", "max_20_words", "numbers_grounded"} as booleans."""
    raise NotImplementedError("rule_checks() arrives in Step 2")


def passes_rules(summary, review_text):
    return all(rule_checks(summary, review_text).values())


# ---------- Step 3: an LLM judge, checked against people ----------
JUDGE_PROMPT = """You check summaries of customer reviews for faithfulness.
A summary is faithful if every claim in it is supported by the review. It is unfaithful if it adds a detail
the review does not contain (a name, a number, a gender, an outcome) or reverses what the review says.
Leaving details out is fine. Reply with JSON only: {"faithful": true or false, "reason": "<one short sentence>"}"""


def judge_faithful(review_text, summary, client=None):
    """True if the judge model finds the summary faithful to the review."""
    client = client or CLIENT
    raise NotImplementedError("judge_faithful() arrives in Step 3")


def agreement(labels, reviews_by_id, client=None):
    """Share of human-labelled examples where the judge agrees with the person (0..1), and the disagreements."""
    raise NotImplementedError("agreement() arrives in Step 3")


# ---------- Step 4: compare two outputs without favouring a position ----------
PAIR_PROMPT = """Two summaries of the same customer review follow, labelled A and B. Which is more useful to a
shop manager: accurate, short, and keeps the customer's main point? Reply with one letter only: A or B."""


def prefer(review_text, first, second, client=None):
    """Ask the judge once; return "A" if it prefers `first`, "B" if it prefers `second`."""
    client = client or CLIENT
    r = client.chat.completions.create(
        model=JUDGE, temperature=0, max_tokens=3,
        messages=[{"role": "system", "content": PAIR_PROMPT},
                  {"role": "user", "content": f"Review:\n{review_text}\n\nA: {first}\n\nB: {second}"}],
    )
    return "B" if r.choices[0].message.content.strip().upper().startswith("B") else "A"


def pairwise(review_text, s1, s2, client=None):
    """"1" if s1 wins in BOTH orders, "2" if s2 wins in both, otherwise "tie"."""
    raise NotImplementedError("pairwise() arrives in Step 4")


def summarise(prompt, review_text, client=None):
    client = client or CLIENT
    r = client.chat.completions.create(model=WRITER, temperature=0, max_tokens=120,
                                       messages=[{"role": "system", "content": prompt},
                                                 {"role": "user", "content": review_text}])
    return r.choices[0].message.content.strip()


# ---------- Step 5: decide with an interval, not a hunch ----------
def bootstrap_diff(a, b, n=2000, seed=0):
    """Paired bootstrap of mean(b) - mean(a): resample case indices with replacement n times
    (the SAME indices for a and b), and return the 2.5th and 97.5th percentiles, rounded to 3 places."""
    raise NotImplementedError("bootstrap_diff() arrives in Step 5")


def decide(a, b, n=2000, seed=0):
    """"B" if the whole interval is above 0, "A" if it is below 0, otherwise "tie"."""
    raise NotImplementedError("decide() arrives in Step 5")
Provided for you:human_labels.jsonlprompt_a.txtprompt_b.txtreviews.jsonltry_it.py

Frequently asked questions

What is LLM-as-a-judge?

Using a language model to grade another model's output against a rubric, for qualities code cannot check, such as whether a summary is faithful to its source. A judge must be validated against human labels before its scores are used; the lab measures that agreement.

What is position bias in pairwise evaluation?

The tendency of a judge model to prefer the answer shown first, or second, regardless of content. Asking twice with the order swapped and counting a win only when both verdicts agree removes it; disagreements become ties.

Why use a bootstrap interval instead of comparing pass rates?

With a small test set, a gap in pass rates can be luck. Resampling the test cases thousands of times shows how much the gap moves. If the 95% interval excludes zero, the difference is unlikely to be noise; if it includes zero, you need more cases.

Why a paired bootstrap?

Both prompts ran on the same cases, and some cases are harder for everyone. Resampling the same case indices for both prompts cancels that shared difficulty and gives a much tighter interval than treating the two score lists as independent.

How to evaluate LLM output you cannot check with a string match

Classification can be scored against a label. Summaries, answers and explanations cannot, so teams combine three tools: deterministic checks for what code can verify, an LLM judge for what needs reading, and a statistical comparison to decide between versions. Each has a failure mode, and a good evaluation guards against all three. In this lab you build the whole chain in Python. You sample a stratified test set with a fixed seed and the extremes included, write rule checks for length, sentence count and invented numbers, measure an LLM judge's agreement with human faithfulness labels, implement order-swapped pairwise comparison to cancel position bias, and decide between two prompts with a paired bootstrap confidence interval rather than a raw win count.