aiml
SearchMy learning
Menu

Lesson 11 of 11Module 3

Write your first eval for an LLM app

Write fixed cases for the docuctl docs assistant, grade every answer in code, run three trials per case, and use the results to judge two attempted fixes.

Prerequisites

Python and pytest. The structured output lesson's DocsAnswer. Ollama with gemma4:e2b pulled.

By the end, you can

  • Write eval cases with an input, an expected source and the facts an answer must contain.
  • Grade outputs in code, repeat trials, and report results per case against a baseline.
  • Tell an eval of the model's behavior from a unit test of your own code.

Progress is saved in this browser.

You grade three versions of the docs assistant on seven fixed cases, three trials each. In the recording, neither proposed fix makes the unanswerable question pass: each version remains at zero of three for that case.

A plausible answer can still omit the command a developer needs. An eval is how you find those cases before users do: run the real application on a fixed set of inputs, grade each output against what it should contain, and keep the results so that the next change can be compared with them. Here you write one for the docs assistant from the structured output lesson.

When you need this. A teammate changes the docs assistant’s prompt to fix one bad answer and asks you to review it. Reading two replies by hand tells you nothing about the other questions it already answered well, or whether the fix works more than once.

Recorded example. The reported scores grade the original shipctl answers; running the current docuctl cases produces a separate evaluation.

Same cases, every version, every trial kept
cases.json7 cases: question,source, must_includequestionDocs assistantv1, v2 or v3,3 trials per caseanswergrade()source andphrasesexpected source and phrasesresults.jsonpass + reasonscases.json7 casesDocs assistant3 trials per casegrade()source and phrasesresults.json
The evaluator compares each answer with its case’s expected source and required phrases, then records failures. These fixture checks do not establish correctness on unseen questions.

1. Write the cases

Download the what-is-an-evaluation lab and extract its code, docs, tests and README together.

Prepare and run the labTerminal · bash
unzip what-is-an-evaluation.zip
cd what-is-an-evaluation
python3.12 -m venv .venv
.venv/bin/python -m pip install -r requirements-lock.txt

# Offline tests: no model calls
.venv/bin/python -m pytest -q

# Run after reading the implementation below
ollama pull gemma4:e2b
.venv/bin/python run_eval.py

The tests use fixed assistant outputs. run_eval.py requests new local-model answers and writes runs/local-docuctl/results.json; the root results.json remains the original recording. assistant.py is the structured-output assistant, unchanged except for the two options this lesson adds.

An eval case is one input with what a correct output must have. Each case names the page the answer must cite and phrases it must contain. Six cases cover questions the docs answer, and one, not-in-docs, covers a question they do not:

cases.jsonSource · jsonDownload
[
 {"id": "rollback-command", "question": "How do I roll back production to release r41?", "source": "rollback.md", "must_include": ["docuctl rollback --env production --to r41"]},
 {"id": "migrations", "question": "Does a rollback undo database migrations?", "source": "rollback.md", "must_include": ["not rolled back"]},
 {"id": "revert-replaced", "question": "What replaced `docuctl release revert`?", "source": "rollback.md", "must_include": ["docuctl rollback"]},
 {"id": "token-expiry", "question": "How long is a CI deploy token valid?", "source": "tokens.md", "must_include": ["90 days"]},
 {"id": "config-file", "question": "Which file does docuctl read its configuration from?", "source": "config.md", "must_include": ["ship.toml"]},
 {"id": "prod-approval", "question": "What has to happen before a production deploy goes out?", "source": "deploy.md", "must_include": ["docuctl approve"]},
 {"id": "not-in-docs", "question": "How do I deploy to Kubernetes with docuctl?", "source": null, "must_include": ["not"]}
]

Good cases come from real use: questions users asked, answers that were wrong, and the edges of what the docs cover. Seven is enough to learn the mechanics; a production eval grows a case every time a bug is found.

The schema leaves no honest answer

v1’s DocsAnswer required source to be one of four pages, so the only valid reply to an unanswerable question was a wrong citation. The eval made that visible; reading the schema had not. When a case fails in every trial, check whether your output format allows the correct answer at all.

2. Grade in code

run_eval.pySource · pythonDownload
"""Run every eval case three times against the docs assistant and grade each answer. Run: python run_eval.py"""
import json
from pathlib import Path

import ollama
from pydantic import ValidationError

from assistant import NULL_RULE, DocsAnswer, DocsAnswerV2, ask

HERE = Path(__file__).parent
CASES = json.loads((HERE / "cases.json").read_text())


def grade(case: dict, answer: DocsAnswer) -> list[str]:
    """Reasons the answer fails this case; an empty list is a pass."""
    text = " ".join(filter(None, [answer.answer, answer.command])).replace("`", "").lower()
    problems = [f"cited {answer.source}, expected {case['source']}"] if answer.source != case["source"] else []
    problems += [f"missing {phrase!r}" for phrase in case["must_include"] if phrase.lower() not in text]
    return problems


def run_case(client: ollama.Client, case: dict, seed: int, answer_type=DocsAnswer, rules: str = "") -> dict:
    try:
        answer = ask(client, case["question"], answer_type=answer_type, rules=rules, temperature=0.7, seed=seed)
    except ValidationError as error:
        return {"case": case["id"], "seed": seed, "passed": False, "problems": [f"invalid output: {error.errors()[0]['type']}"]}
    problems = grade(case, answer)
    return {"case": case["id"], "seed": seed, "passed": not problems, "problems": problems,
            "answer": answer.model_dump()}


def report(label: str, results: list[dict]) -> dict:
    print(f"--- {label}")
    counts = {}
    for case in CASES:
        rows = [r for r in results if r["case"] == case["id"]]
        counts[case["id"]] = sum(r["passed"] for r in rows)
        problems = sorted({p for r in rows for p in r["problems"]})
        print(f"{case['id']:<17} {counts[case['id']]}/3  {'; '.join(problems)}")
    print(f"passed {sum(counts.values())} of {len(results)} trials\n")
    return counts


def main() -> None:
    client = ollama.Client()
    runs = {}
    versions = [("v1: source is required", DocsAnswer, ""),
                ("v2: source may be null", DocsAnswerV2, ""),
                ("v3: source may be null, prompt says when", DocsAnswerV2, NULL_RULE)]
    for label, answer_type, rules in versions:
        results = [run_case(client, case, seed, answer_type, rules) for case in CASES for seed in (1, 2, 3)]
        runs[label] = {"results": results, "passed": report(label, results)}
    output = HERE / "runs/local-docuctl"
    output.mkdir(parents=True, exist_ok=True)
    (output / "results.json").write_text(json.dumps(runs, indent=1) + "\n")
    baseline, *_, latest = (run["passed"] for run in runs.values())
    print("v3 regressions against v1:", [case for case in baseline if latest[case] < baseline[case]] or "none")


if __name__ == "__main__":
    main()

grade() is the grader: it compares one answer with its case and returns the reasons it fails, or an empty list. It checks the cited source exactly and the phrases loosely, ignoring case and backticks, because DOCUCTL ROLLBACK in backticks is still the right command. A grader that is stricter than the requirement fails correct answers; one that is looser passes wrong ones. The grader’s own unit tests, in test_eval.py, pin both edges:

test_eval.pySource · pythonDownload
"""Unit tests for the grader, and checks on the recorded eval run. Run: python -m pytest -q"""
import json
from pathlib import Path

from assistant import DocsAnswer, DocsAnswerV2
from run_eval import CASES, grade

RUNS = json.loads(Path(__file__).with_name("results.json").read_text())
CASE = {c["id"]: c for c in CASES}


def test_a_correct_answer_passes_even_with_backticks_and_case_changes():
    answer = DocsAnswer(answer="Run `DOCUCTL ROLLBACK --env production --to r41`.", source="rollback.md")
    assert grade(CASE["rollback-command"], answer) == []


def test_the_command_field_counts_toward_the_required_phrases():
    answer = DocsAnswer(answer="Use the rollback command.", source="rollback.md", command="docuctl rollback --to <release>")
    assert grade(CASE["revert-replaced"], answer) == []


def test_a_wrong_source_and_a_missing_phrase_are_both_reported():
    answer = DocsAnswer(answer="It was removed in 2.0.", source="deploy.md")
    assert grade(CASE["revert-replaced"], answer) == ["cited deploy.md, expected rollback.md", "missing 'docuctl rollback'"]


def test_only_version_2_can_say_no_page_covers_the_question():
    assert DocsAnswerV2(answer="The docs do not cover it.", source=None).source is None
    assert "null" not in json.dumps(DocsAnswer.model_json_schema()["properties"]["source"])


def test_every_version_ran_every_case_three_times():
    for run in RUNS.values():
        assert sorted(r["case"] for r in run["results"]) == sorted(c["id"] for c in CASES for _ in range(3))


def test_neither_fix_made_the_not_in_docs_case_pass():
    assert [run["passed"]["not-in-docs"] for run in RUNS.values()] == [0, 0, 0]
    assert all(run["passed"]["revert-replaced"] == 0 for run in RUNS.values())


def test_fresh_eval_leaves_original_recording_unchanged(tmp_path, monkeypatch):
    import run_eval as app
    original = tmp_path / "results.json"
    original.write_text("original recording")
    monkeypatch.setattr(app, "HERE", tmp_path)
    monkeypatch.setattr(app.ollama, "Client", lambda: object())
    monkeypatch.setattr(app, "run_case", lambda _client, case, *_args: {"case": case["id"], "passed": False, "problems": []})
    app.main()
    assert original.read_text() == "original recording"
    current = json.loads((tmp_path / "runs/local-docuctl/results.json").read_text())
    assert len(current) == 3
    assert all(len(run["results"]) == 3 * len(app.CASES) for run in current.values())

run_case() runs one trial. Outputs that fail validation count as failures with their error type, not as crashes, so one bad reply cannot stop the eval.

3. Run trials and compare versions

The model samples at temperature 0.7, so each case runs three times with different seeds. main() runs three versions of the assistant on the same cases: v1 as in the structured output lesson, v2 with source allowed to be null, and v3 with null allowed and a prompt rule saying when to use it.

python run_eval.pyRecorded output (excerpt) · textFull output
--- v1: source is required
rollback-command  3/3
migrations        3/3
revert-replaced   0/3  missing 'shipctl rollback'
token-expiry      3/3
config-file       3/3
prod-approval     3/3
not-in-docs       0/3  cited config.md, expected None
passed 15 of 21 trials

--- v2: source may be null
rollback-command  3/3
migrations        3/3
revert-replaced   0/3  missing 'shipctl rollback'
token-expiry      3/3
config-file       3/3
prod-approval     3/3
not-in-docs       0/3  cited config.md, expected None
passed 15 of 21 trials

--- v3: source may be null, prompt says when
rollback-command  3/3
migrations        3/3
revert-replaced   0/3  missing 'shipctl rollback'
token-expiry      3/3
config-file       3/3
prod-approval     3/3
not-in-docs       0/3  cited config.md, expected None; cited tokens.md, expected None
passed 15 of 21 trials

v3 regressions against v1: none

Five cases pass every trial in every version. Two fail every trial, and the reasons say why:

  • revert-replaced: the assistant answered “The command `shipctl release revert <release>` was removed in shipctl 2.0.”, which is true and leaves out what replaced the command. The answer cites the right page and still fails the user.
  • not-in-docs: in v1 the schema requires a page, so the assistant cited config.md while saying the docs do not cover Kubernetes. v2 allowed null, and the model still cited a page. v3 added a rule to the prompt, and the model still cited a page, sometimes a different one.

Both fixes were reasonable, and neither worked. Without the eval, v3 would have looked right after reading the prompt.

A fix that looks right changes nothing

v2 and v3 matched v1 exactly on not-in-docs: 0 of 3 each. A spot check of one answer after the change could have caught a lucky run and called it fixed. Decide which result would show the fix works before you run it, run every case, and review per-case changes and remaining failures before shipping.

Check each answer against the case

An eval differs from a unit test in what it tests. A unit test checks your code against a fixed input and expected output, and it must pass every time; test_eval.py holds unit tests for grade(). An eval checks a model’s behavior, which varies between runs, so it reports how often each case passes, and this lab reports per-case results. An application can also use eval thresholds to block a release.

Three rules keep the results meaningful:

  • Keep the cases fixed between versions, or the comparison measures the cases instead of the change.
  • Keep every trial and reason, so that a case that passes two of three is visible and a regression can be traced to an answer.
  • Compare against a baseline. main() checks v3 against v1 per case: no case got worse, and none got better.

Graders come in three kinds: code checks like this one, which are cheap and exact; a second model acting as a judge, for qualities code cannot check; and people, for the cases that matter most. The LLM Evals course builds the larger versions.

Try it: add a case from a real failure

The prompt injection lesson found an answer that recommended deploy:production for CI. Write a case, “Which scope should a CI deploy token have?”, with source tokens.md and must_include deploy:staging, add it to cases.json and run the eval. Then try one more fix for not-in-docs, such as a fifth source value "none", and keep it only if the eval improves with no regressions. The grader’s unit tests run offline: .venv/bin/python -m pytest -q.

Version 3 lets `source` be null and tells the model when to use it. The eval still shows `not-in-docs` at 0/3, and no case regressed against version 1. What do you do next?

Choose one answer, then check your reasoning.

Read the explanation

An eval runs the real system on fixed cases, grades each output and reports results per case, against a baseline. It tells you whether a change worked; a passing spot check or a sensible-looking prompt does not.

You now have the pieces the engineering courses build on: requests and context, structured output, embeddings, tools, prompt injection and evals. The Model Context Protocol course starts by building a full MCP server with typed tools.

References

Checked October 21, 2026.

Written by Arjun Prabhulal.

Module 3

Tools, safety and evals