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.
1. Write the cases
Download the what-is-an-evaluation lab and extract its code, docs, tests and README together.
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.pyThe 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:
[
{"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 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:
"""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.
--- 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: noneFive 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 citedconfig.mdwhile saying the docs do not cover Kubernetes. v2 allowednull, 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.
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.