Compiled on: 2026-10-22 — printable version
Recall: we assume the reader is familiar with structured outputs and with the running example, from the previous lecture
LLM-based software is hard to test:
Hence, rather than only unit tests (pass/fail on one run), we need evaluations (a.k.a. evals): measuring how often, and how well, the system behaves as expected, on a dataset of representative inputs
pytest), if assertions are on rates or thresholds, rather than on single exact outputsIngredients of an evaluation:
Structured outputs make evaluation much easier: most fields can be checked with code
Idea: use a (strong) LLM to grade outputs, according to some criteria (cf. Zheng et al. (2023), and the survey by Gu et al. (2024))
Known biases of judges:
Good practices:
| Tool | Style | Scorers / judges | Results in | Docs |
|---|---|---|---|---|
| DeepEval | unit-test like, on top of pytest |
many built-in metrics (G-Eval, hallucination, RAG, agents), custom ones | terminal; optional cloud dashboard | link |
| MLflow GenAI | experiment tracking: runs, datasets, traces | code scorers (@scorer), built-in and custom judges (make_judge) |
local (or remote) tracking server, with web UI | link |
| promptfoo | declarative (YAML), CLI, language-agnostic | assertions, model-graded rubrics, red-teaming | terminal, local web UI | link |
| OpenAI Evals | registry of evals + platform | templates, model-graded | OpenAI dashboard | link |
| Ragas | library of metrics | focused on RAG and agents | Python objects, dataframes | link |
| Inspect | framework for benchmarks and safety evals | solvers + scorers, model-graded | log viewer | link |
| LangSmith | tracing + evals, by LangChain | code and LLM evaluators | cloud dashboard | link |
Goal: set up a test suite for the LangChain letter-scoring system of Example 1 (bis) of the Prompt Engineering lecture, checking some fields of the
LetterInfoobjects it produces
test_data.yml) shared among test suites# Test dataset, designed by humans (the committee) BEFORE testing: known inputs (the letters), each with the results expected from a correct scoring.
# Some expectations are checked exactly (names, emails), some with tolerance (score ranges), some by an LLM-as-a-judge (descriptions).
- input: letter-mario-rossi.txt
expectations:
applicant_name: Mario Rossi # exact match
author_name: Alessandro Bianchi # exact match
author_email: alessandro.bianchi@example.edu # exact match
min_score: 4 # range
relationship_with_applicant: >- # LLM-as-a-judge
taught the applicant a Master's course (Machine Learning and Data Mining), then supervised their Master's thesis
- input: letter-jean-dupont.txt
expectations:
applicant_name: Jean Dupont
author_name: Claire Moreau
author_email: claire.moreau@example.fr
max_score: 4
relationship_with_applicant: >-
taught the applicant a Master's course (Apprentissage automatique), then interacted with them in a research-oriented module
- input: letter-mohammed-ali.txt
expectations:
applicant_name: Mohammed Ali
author_name: Kareem Al-Haddad
author_email: k.alhaddad@example.edu.jo
max_score: 3
relationship_with_applicant: >-
knew the applicant as one of the students of the Software Engineering programme, without supervising their final project
dicts
from pathlib import Path
import yaml
import data
TEST_DATA_FILE = Path(__file__).parent / "test_data.yml"
# list of test cases, e.g. [{"input": "letter-mario-rossi.txt", "expectations": {"applicant_name": "Mario Rossi", ...}}, ...]
TEST_CASES = yaml.safe_load(TEST_DATA_FILE.read_text())
yaml.safe_load (from PyYAML) parses YAML text into Python lists and dictsread_letter(case), in the same file, reads the text of the case’s letter from the running example’s data/ directoryImports: DeepEval runs on top of pytest, and the system under test is imported as is:
import functools
import os
import sys
import pytest
from deepeval import assert_test
from deepeval.metrics import GEval
from deepeval.models import OpenRouterModel
from deepeval.test_case import LLMTestCase, SingleTurnParams
from snippets.lecture_validating.dataset import TEST_CASES, read_letter
from snippets.lecture_prompting.example1bis.letter_scoring_langchain import score_letter, api_key, base_url
score = functools.cache(score_letter) # this is the scoring function under test! (cached: one LLM call per letter)
for_each_case = pytest.mark.parametrize("case", TEST_CASES, ids=lambda c: c["expectations"]["applicant_name"])
functools.cache accepts a Python function (score_letter) and returns a cached version of it (score):
score(l) is called, the cache checks if the same letter l has already been scoredscore_letter is called and the output is cachedpytest.mark.parametrize("case", TEST_CASES, ids=...) is a decorator (for_each_case) making a test run once per test case, passed as the case argument; ids names each run (after the applicant)api_key and base_url are re-used from the system under test, so the judge talks to the same provider; DeepEval’s classes (GEval, OpenRouterModel, SingleTurnParams, LLMTestCase, assert_test) are explained in steps 5–6Deterministic scorers are just pytest assertions on some fields of the structured output:
# 1. deterministic scorers: plain assertions on some fields of the structured output
@for_each_case
def test_names_and_email(case): # exact match
info, expected = score(read_letter(case)), case["expectations"]
assert info.applicant.name == expected["applicant_name"]
assert info.author.name == expected["author_name"]
assert info.author.email == expected["author_email"]
@for_each_case
def test_score_in_range(case): # tolerant match
info, expected = score(read_letter(case)), case["expectations"]
assert expected.get("min_score", 0) <= info.score <= expected.get("max_score", 5)
pytest collects and runs every test_* function: a failing assert (or exception) is a failing testRelational properties hold across inputs (a.k.a. metamorphic testing): they are robust to the variability of single scores
# 2. relational property: holds across inputs, even if single scores may vary
def test_best_letter_is_mario_rossi():
scores = {c["expectations"]["applicant_name"]: score(read_letter(c)).score for c in TEST_CASES}
assert scores["Mario Rossi"] == max(scores.values())
LLM-as-a-Judge, via DeepEval’s G-Eval metric: the judge scores (in $[0, 1]$) how much the output satisfies the criteria, and the test passes if the score is above the threshold
# 3. LLM-as-a-judge: a (different) model grades what is hard to check with code
judge = OpenRouterModel(model=os.environ.get("JUDGE_MODEL", "google/gemma-4-31b-it:free"), api_key=api_key, base_url=base_url)
relationship = GEval( # reference-based: the judge compares the actual output with the expected one
name="Relationship",
criteria="The actual output describes the relationship between the author of a recommendation letter and the applicant. "
"It must be consistent with the expected output: penalise contradictions, omitted key facts, and invented facts.",
evaluation_params=[SingleTurnParams.ACTUAL_OUTPUT, SingleTurnParams.EXPECTED_OUTPUT],
model=judge,
threshold=0.7, # GEval scores are in [0, 1]: below the threshold, the test fails
)
groundedness = GEval( # reference-free: the judge compares the actual output with the input letter
name="Groundedness",
criteria="Every skill, strength, and weakness listed in the actual output must be explicitly supported by the letter in the input. "
"Penalise any invented, exaggerated, or misattributed item.",
evaluation_params=[SingleTurnParams.INPUT, SingleTurnParams.ACTUAL_OUTPUT],
model=judge,
threshold=0.7,
)
JUDGE_MODEL to change it), wrapped in OpenRouterModel: DeepEval’s adapter for OpenRouter (or any OpenAI-compatible API, via base_url)
google/gemma-4-31b-it:free, of a different family than the system under test (avoiding self-preference), with comparable active parameters (31B dense vs. 12B active out of 120B), free, and able to see imagesevaluation_params selects, among the fields of a test case (SingleTurnParams.INPUT, .ACTUAL_OUTPUT, .EXPECTED_OUTPUT, …), which parts of the test case the judge can see:
relationship): compares the actual output with the expected one, written by humansgroundedness): compares the actual output with the input, no expectation neededJudges are used within tests, via assert_test, on test cases (LLMTestCase) built from some fields of the structured output:
@for_each_case
def test_relationship(case):
letter = read_letter(case)
actual, expected = score(letter).author.relationship_with_applicant, case["expectations"]["relationship_with_applicant"]
assert_test(LLMTestCase(input=letter, actual_output=str(actual), expected_output=expected), [relationship])
@for_each_case
def test_skills_strengths_weaknesses(case):
letter = read_letter(case)
actual = score(letter).applicant.model_dump_json(include={"skills", "strengths", "weaknesses"})
assert_test(LLMTestCase(input=letter, actual_output=actual), [groundedness])
LLMTestCase(input=..., actual_output=..., expected_output=...): what the judge may see (strings); assert_test(case, metrics) runs each metric, and fails the test if some score is below its thresholdmodel_dump_json(include={...}) serialises only the listed fields of the Pydantic objectrelationship_with_applicant is compared with the expected descriptionskills, strengths, and weaknesses are checked against the letter (are they grounded in it?)Files of this example, in the lab-snippets repository (cf. how to set it up, and run snippets):
lab-snippets/
├── data/
│ ├── letter-jean-dupont.txt # inputs (running example)
│ ├── letter-mario-rossi.txt
│ ├── letter-mohammed-ali.txt
│ └── __init__.py # helpers to locate data files
├── snippets/
│ ├── lecture_prompting/
│ │ └── example1bis/
│ │ └── letter_scoring_langchain.py # system under test
│ └── lecture_validating/
│ ├── example1/
│ │ └── test_letter_scoring.py # the test suite
│ ├── dataset.py # loads the test dataset
│ └── test_data.yml # test dataset
└── pyproject.toml # dependencies of all snippetsOPENAI_API_KEY (and, optionally, OPENAI_BASE_URL, OPENAI_MODEL, JUDGE_MODEL), cf. Free Access to LLMsLet’s run it (full code here), from the project’s root directory:
poetry run python -m snippets -l validating -e 1 -v # runs pytest on the test suite, with any option given (-v: one line per test)
Same test dataset, rearranged as MLflow wants it: inputs (for the system) + expectations (for the scorers)
import os
import sys
from datetime import datetime
from typing import Literal
import mlflow
from mlflow.genai import scorer
from mlflow.genai.judges import make_judge
from snippets.lecture_validating.dataset import TEST_CASES, read_letter
from snippets.lecture_prompting.example1bis.letter_scoring_langchain import score_letter, base_url, api_key
# 1. the dataset: "inputs" are passed to predict_fn, "expectations" to the scorers
data = [dict(inputs=dict(letter_text=read_letter(c)), expectations=c["expectations"]) for c in TEST_CASES]
# 2. the system under test (outputs must be serialisable)
def predict_fn(letter_text: str) -> dict:
return score_letter(letter_text).model_dump()
predict_fn), called with the inputs as keyword arguments
model_dump() turns the LetterInfo object into a plain dict (outputs must be serialisable, to be logged)Deterministic scorers are functions decorated with @scorer, whose parameters are named after what they need (inputs, outputs, expectations, trace):
# 3. deterministic scorers, on some fields of the structured output
@scorer
def names_and_email(outputs, expectations) -> bool: # exact match
return (outputs["applicant"]["name"] == expectations["applicant_name"]
and outputs["author"]["name"] == expectations["author_name"]
and outputs["author"]["email"] == expectations["author_email"])
@scorer
def score_in_range(outputs, expectations) -> bool: # tolerant match
return expectations.get("min_score", 0) <= outputs["score"] <= expectations.get("max_score", 5)
Judges are created via make_judge, with template variables in the instructions, and a structured feedback type:
os.environ.setdefault("OPENAI_API_KEY", api_key)
judge_options = dict(model=f"openai:/{os.environ.get('JUDGE_MODEL', 'google/gemma-4-31b-it:free')}", base_url=base_url,
feedback_value_type=Literal["yes", "no"], generate_rationale_first=True)
relationship = make_judge( # reference-based: the judge compares outputs with expectations
name="relationship",
instructions="The {{ outputs }} contain the information extracted from a recommendation letter, and the {{ expectations }} what a human expects. "
"Answer 'yes' if the author's relationship_with_applicant in the outputs is consistent with the relationship_with_applicant "
"in the expectations (no contradictions, no omitted key facts, no invented facts), 'no' otherwise.",
**judge_options,
)
groundedness = make_judge( # reference-free: the judge compares outputs with inputs
name="groundedness",
instructions="The {{ inputs }} contain a recommendation letter, and the {{ outputs }} the information extracted from it. "
"Answer 'yes' if every skill, strength, and weakness of the applicant in the outputs is explicitly supported by the letter, 'no' otherwise.",
**judge_options,
)
{{ expectations }} makes the judge reference-based (relationship), {{ inputs }} makes it reference-free (groundedness)generate_rationale_first=True: rationale before verdict (cf. chain-of-thought)model is a URI (<provider>:/<model>); base_url redirects it to any OpenAI-compatible API (the key, read from OPENAI_API_KEY, is the same as the system under test’s)feedback_value_type: the type of the verdict, here a Literal (cf. structured outputs) admitting only "yes" or "no"Evaluation is a single call, whose results are logged as an MLflow run (named after the current date and time, in ISO format):
if __name__ == "__main__":
mlflow.set_experiment("letter-scoring") # runs are grouped into experiments
with mlflow.start_run(run_name=datetime.now().isoformat(timespec="seconds")): # e.g. "2026-10-01T17:36:26"
results = mlflow.genai.evaluate(data=data, predict_fn=predict_fn, scorers=[names_and_email, score_in_range, relationship, groundedness])
print(results.metrics) # fraction of test cases passing each scorer, e.g. {'score_in_range/mean': 0.67, ...}
failed = [name for name, value in results.metrics.items() if value != 1] # also catches NaN (scorer errors)
if failed:
sys.exit(f"Failed scorers: {failed}") # non-zero exit code, as a failing test suite (e.g. to make CI fail)
mlflow.set_experiment(name): selects (or creates) the experiment, i.e. the group of runs to be comparedmlflow.start_run(run_name=...): a context manager opening a run, i.e. one execution whose parameters, traces, and results are loggedmlflow.genai.evaluate(...): calls predict_fn on each entry’s inputs, then applies every scorer, and logs everything in the runresults.metrics reports, for each scorer, the fraction of test cases passing it (judges’ "yes" count as 1)Files of this example, in the lab-snippets repository (cf. how to set it up, and run snippets):
lab-snippets/
├── data/
│ ├── letter-jean-dupont.txt # inputs (running example)
│ ├── letter-mario-rossi.txt
│ ├── letter-mohammed-ali.txt
│ └── __init__.py # helpers to locate data files
├── snippets/
│ ├── lecture_prompting/
│ │ └── example1bis/
│ │ └── letter_scoring_langchain.py # system under test
│ └── lecture_validating/
│ ├── example1bis/
│ │ └── evaluate_mlflow.py # the evaluation
│ ├── dataset.py # loads the test dataset
│ └── test_data.yml # test dataset
└── pyproject.toml # dependencies of all snippetsOPENAI_API_KEY (and, optionally, OPENAI_BASE_URL, OPENAI_MODEL, JUDGE_MODEL), cf. Free Access to LLMsLet’s run it (full code here), from the project’s root directory:
poetry run python -m snippets -l validating -e 1bis # prints metrics, e.g. {'score_in_range/mean': 0.67, ...}, then: Failed scorers: [...]
poetry run mlflow ui # then open http://localhost:5000, to browse runs, traces, and judges' rationales
| Aspect | DeepEval | MLflow GenAI |
|---|---|---|
| Mental model | tests: pass / fail, in CI | experiments: runs to be compared over time |
| Dataset | test cases (LLMTestCase), or pytest parameters |
list of dicts (inputs, expectations), dataframes, or managed datasets |
| System under test | called by you, inside the test | predict_fn, called by the framework (and traced) |
| Deterministic scorers | plain asserts |
@scorer functions, returning bool, numbers, or Feedback |
| LLM judges | GEval(criteria=..., threshold=...), plus many ready-made metrics |
make_judge(instructions=..., feedback_value_type=...), plus built-in judges |
| Judge model | DeepEvalBaseLLM subclasses (OpenAI, OpenRouter, Ollama, …) |
model URIs (openai:/..., anthropic:/...), plus base_url |
| Results | terminal (+ optional cloud platform) | tracking server + web UI (local, self-hosted, or managed) |
| Best for | regression tests in CI pipelines | comparing prompts / models, and monitoring in production |
Goal: the examples above only check some fields of
LetterInfo; extend one of the two test suites (DeepEval or MLflow, your choice) so that every field is checked
Code: put your solution in
snippets/lecture_validating/exercise1/oflab-snippets, and run it viapoetry run python -m snippets -l validating -x 1
pydantic classes in letter_scoring_langchain.py):
ApplicantInfo: degree, alma_mater, attendedAuthorInfo: affiliation, position, seniority, nationalityLetterInfo: application_fortest_data.yml with the expectations for each field of each letter@scorer functions, or judges), and run the suitenationality, alma_mater): what is the expected value when the letter does not mention it? How do you write it in YAML?seniority is an integer, defined by the field’s description: is the description unambiguous for every letter?attended): should every expected item be there? Should every extracted item be expected? Is the order relevant?test_data.yml (e.g. a wrong affiliation), and check that the suite catches itSolution: a walkthrough follows in the next (vertical) column — try on your own first!
⚠️ Spoiler alert: the walkthrough of the solution of Exercise 1 is about to start
master branch of lab-snippets (whereas you cloned the exercises branch)# Expectations for the fields of LetterInfo NOT checked by Example 1 (whose test_data.yml covers the others), written by reading the letters.
# Conventions (decided once, for all fields, and documented here, where the committee edits them):
# - strings are matched TOLERANTLY: case-, accent-, and punctuation-insensitive, the expected text must be CONTAINED in the actual one
# - a list of strings means: any of them is fine (e.g. a title, in the original language or translated)
# - `null` means: the letter does not say it, so the field must be empty (YAML's null = Python's None)
# - `attended` lists courses which must ALL be among the extracted ones (others may be there too, in any order)
- input: letter-mario-rossi.txt
expectations:
degree: master
alma_mater: null # the letter never says where Mario studied (Bologna is the author's affiliation!)
attended: [machine learning and data mining]
affiliation: university of bologna
position: associate professor
seniority: [3] # admissible values (a list, in case of ambiguity)
nationality: null # never stated
application_for: data science
null means “not stated in the letter”: the model must not guess
alma_mater: Bologna is the author’s affiliation, not the applicant’s university- input: letter-jean-dupont.txt
expectations:
degree: [master, informatique]
alma_mater: [universite de lyon, university of lyon]
attended: [apprentissage automatique, vision par ordinateur]
affiliation: [universite de lyon, university of lyon]
position: [maitresse de conferences, associate professor, lecturer] # a French title, maybe translated
seniority: [1, 2, 3] # ambiguous: "maîtresse de conférences" is a tenured lecturer, i.e. 1, 2, or 3 depending on the mapping
nationality: null
application_for: artificial intelligence
- input: letter-mohammed-ali.txt
expectations:
degree: software engineering
alma_mater: [university of amman, null] # implied ("our academic programme", the author works there), not stated: both are fine
attended: [databases, computer networks]
affiliation: university of amman
position: associate professor
seniority: [3]
nationality: [null, jordan] # "Amman, Jordan" is the author's address, not their nationality: a model may conflate them
application_for: cyber security
seniority 1, 2, or 3?alma_mater is implied, not stated: both null and Amman are finefrom snippets.lecture_validating.dataset import read_letter
# the tests of Example 1 (names, email, score, relationship, skills/strengths/weaknesses): imported, hence collected and run here too.
# `score` is the same cached scoring function, so each letter is still scored only ONCE, across old and new tests
from snippets.lecture_validating.example1.test_letter_scoring import (
score, test_names_and_email, test_score_in_range, test_best_letter_is_mario_rossi, test_relationship, test_skills_strengths_weaknesses,
)
# step 3: the new expectations (same inputs as Example 1's test data)
TEST_CASES = yaml.safe_load((Path(__file__).parent / "test_data.yml").read_text())
for_each_case = pytest.mark.parametrize("case", TEST_CASES, ids=lambda c: c["input"])
# step 2: tolerant matching, i.e. normalisation + containment (cf. the conventions in test_data.yml)
def normalise(text: str | None) -> str:
"""e.g. 'Maîtresse de Conférences.' -> 'maitresse de conferences'"""
text = unicodedata.normalize("NFKD", text or "").encode("ascii", "ignore").decode() # strips accents
return " ".join(text.lower().translate(str.maketrans(string.punctuation, " " * len(string.punctuation))).split())
def matches(actual: str | None, expected: str | list | None) -> bool:
"""Whether the actual value matches the expected one (or any of them, if a list), as per the conventions in test_data.yml."""
if isinstance(expected, list):
return any(matches(actual, e) for e in expected)
if expected is None: # the letter does not say it: nothing (or an explicit "unknown", which some models write) is expected
return not actual or normalise(actual) in {"none", "null", "unknown", "not specified", "not mentioned", "n a"}
return actual is not None and normalise(expected) in normalise(actual)
score: one LLM call per letter, for all testsnormalise handles case, accents, punctuation:
unicodedata.normalize("NFKD", ...) splits “é” into “e” + accent, then .encode("ascii", "ignore") drops the accentstr.maketrans + translate turn every string.punctuation char into a blankmatches adds containment (“University of Bologna” ⊇ “bologna”) and the list / null conventions"unknown" instead of None: accepted as empty (a choice: you may want to forbid it)# step 4: one test per group of fields, with the most appropriate kind of check
@for_each_case
@pytest.mark.parametrize("field", ["degree", "alma_mater"])
def test_applicant_studies(case, field): # tolerant match
actual = getattr(score(read_letter(case)).applicant, field)
assert matches(actual, case["expectations"][field]), f"{field}: {actual!r}"
@for_each_case
def test_attended_courses(case): # set inclusion: every expected course is among the extracted ones (order, and extra ones, do not matter)
actual = score(read_letter(case)).applicant.attended
missing = [course for course in case["expectations"]["attended"] if not any(matches(a, course) for a in actual)]
assert not missing, f"missing courses: {missing}, extracted: {actual}"
@for_each_case
@pytest.mark.parametrize("field", ["affiliation", "position", "nationality"])
def test_author_info(case, field): # tolerant match
actual = getattr(score(read_letter(case)).author, field)
assert matches(actual, case["expectations"][field]), f"{field}: {actual!r}"
@for_each_case
def test_author_seniority(case): # exact match, among admissible values (as the scale is ambiguous for some positions)
actual = score(read_letter(case)).author.seniority
assert actual in case["expectations"]["seniority"]
@for_each_case
def test_application_for(case): # tolerant match
actual = score(read_letter(case)).application_for
assert matches(actual, case["expectations"]["application_for"]), f"application_for: {actual!r}"
attended (order and extra courses don’t matter); exact match among admissible values for seniorityparametrize decorators = every letter $\times$ every field): a failure names the letter and the fieldFiles of this solution, in the lab-snippets repository (branch master):
lab-snippets/
├── data/
│ ├── letter-jean-dupont.txt # inputs (running example)
│ ├── letter-mario-rossi.txt
│ └── letter-mohammed-ali.txt
└── snippets/
├── lecture_prompting/
│ └── example1bis/
│ └── letter_scoring_langchain.py # system under test
└── lecture_validating/
├── example1/
│ └── test_letter_scoring.py # Example 1's tests (re-used)
├── exercise1/
│ ├── test_all_fields.py # the complete test suite
│ └── test_data.yml # expectations for the other fields
├── dataset.py # loads Example 1's test dataset
└── test_data.yml # Example 1's test datasetpoetry run python -m snippets -l validating -x 1 -v # runs pytest on the complete suite, with any option given
affiliation) and check that the suite catches itGoal: the committee wants evidence that the extractor of Exercise 2 of the Prompt Engineering lecture is reliable, and wants to be warned whenever a change in prompt or model makes it worse
Code: put your solution in
snippets/lecture_validating/exercise2/oflab-snippets, and run it viapoetry run python -m snippets -l validating -x 2
:free models (cf. Free Access to LLMs)Solution: a walkthrough follows in the next (vertical) column — try on your own first!
⚠️ Spoiler alert: the walkthrough of the solution of Exercise 2 is about to start
master branch of lab-snippets (whereas you cloned the exercises branch)# Test dataset of the ID-document extractor, designed by humans (the committee) by LOOKING at the pictures, BEFORE testing.
# It is a file (not code), so that the committee can review and extend it without touching the tests.
# Values are as written in the VISUAL zone of each passport (beware: the machine-readable zone may disagree, e.g. Mario Rossi's expiry date).
- input: passport-mario-rossi.png
expected:
name: Mario Rossi
nationality: Bananense
date_of_birth: 1985-03-15 # YAML parses ISO dates as dates
id_number: BN1234567
expiration_date: 2034-05-20
legible: true # my own judgement, to validate the LLM-as-a-judge against
# step 1: the test dataset (a file, not code: cf. passports.yml)
TEST_CASES = yaml.safe_load((Path(__file__).parent / "passports.yml").read_text())
for_each_case = pytest.mark.parametrize("case", TEST_CASES, ids=lambda c: c["input"])
SAMPLES = int(os.environ.get("SAMPLES", 3)) # cost: SAMPLES x 3 pictures requests (+ 3 for the judge): keep it small while developing
FIELDS = list(IDDocumentInfo.model_fields)
@functools.cache # NOT a cache of LLM responses across runs (that would hide variance), just one batch of samples per picture per run
def samples_of(picture: str) -> list[IDDocumentInfo]:
return sample(data.DIR / picture, SAMPLES)
legible: human judgement, used later to validate the judgeSAMPLES (env var, default 3) is $N$; IDDocumentInfo.model_fields lists the names of the fields of the Pydantic classsample (from Exercise 2 of the Prompt Engineering lecture) queries the model $N$ times on the picture in data.DIR (the running example’s data/ directory)samples_of caches samples within a run only: caching across runs would hide the variance we want to measure# step 2: deterministic scorers, i.e. exact match for IDs and dates, normalised match for names and nationality (case, accents, order of words)
def normalise_words(name: str) -> set[str]:
"""e.g. 'DUPONT Jean' -> {'jean', 'dupont'}"""
return set(unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode().lower().replace(",", " ").split())
def correct(info: IDDocumentInfo, expected: dict, field: str) -> bool:
actual = getattr(info, field)
if field in {"name", "nationality"}:
return normalise_words(actual) == normalise_words(expected[field])
if field == "id_number":
return actual.strip().upper() == expected[field]
return actual == expected[field] # dates: `date` objects on both sides, so the format does not matter
def accuracy(results: list[IDDocumentInfo], expected: dict) -> float:
"""Fraction of correct fields, over all fields of all results."""
return sum(correct(r, expected, f) for r in results for f in FIELDS) / (len(results) * len(FIELDS))
@for_each_case
@pytest.mark.parametrize("field", FIELDS)
def test_voted_field_is_correct(case, field): # the system, as the committee would use it, i.e. with voting
voted, agreement = vote(samples_of(case["input"]))
assert correct(voted, case["expected"], field), f"{field}: {getattr(voted, field)!r} (agreement: {agreement[field]:.0%})"
date objects on both sides: the format is irrelevant)vote(samples) returns the voted object, and the agreement rate of each field (cf. Prompt Engineering’s Exercise 2)agreement in the message tells how sure it was# invariants: checked on EVERY sample, as they must hold whatever the expected values are
@for_each_case
def test_invariants(case):
for info in samples_of(case["input"]):
assert info.date_of_birth < info.expiration_date
assert (date.today() - info.date_of_birth).days >= 18 * 365.25, "candidates must be of age"
assert info.id_number.strip(), "ID number must not be empty"
# step 3: consistency, i.e. agreement rate per field, asserted on RATES (not on single runs), to avoid flaky tests
@for_each_case
def test_consistency(case):
_, agreement = vote(samples_of(case["input"]))
print(f"\n{case['input']} agreement: {agreement}") # visible with -s
inconsistent = {field: rate for field, rate in agreement.items() if rate < 2 / 3}
assert not inconsistent, f"fields with less than 2/3 agreement: {inconsistent}"
# ... and does voting improve accuracy w.r.t. single queries? (a measure, rather than a requirement: it fails only if voting makes things WORSE)
def test_voting_vs_single_queries():
single = sum(accuracy(samples_of(c["input"]), c["expected"]) for c in TEST_CASES) / len(TEST_CASES)
voted = sum(accuracy([vote(samples_of(c["input"]))[0]], c["expected"]) for c in TEST_CASES) / len(TEST_CASES)
print(f"\n[{vision_model}] accuracy of single queries: {single:.0%}, of voted ones: {voted:.0%}")
assert voted >= single
-s (pytest option: do not capture print outputs)# step 4: LLM-as-a-judge, only for what code cannot check (legibility), with a (different) multimodal model
class Legibility(BaseModel):
reasoning: str = Field(description="Which parts of the document are hard to read, if any, and why (e.g. stamps, glare, blur).")
legible: bool = Field(description="Whether name, date of birth, document number, and expiry date are legible enough to trust an automatic extraction.")
# (better a DIFFERENT model than the system under test, set via JUDGE_MODEL: by default, the same is used, as few free models support images)
judge = ChatOpenAI(base_url=base_url, api_key=api_key, model=os.environ.get("JUDGE_MODEL", vision_model), temperature=0)
legibility_judge = judge.with_structured_output(Legibility)
@for_each_case
def test_legibility_judge_agrees_with_humans(case): # validates the judge against my own judgement (in passports.yml)
verdict = legibility_judge.invoke([
SystemMessage("You assess the quality of pictures of ID documents, before any information is extracted from them."),
message_with_image("Is this document legible enough?", data.DIR / case["input"]),
])
assert verdict.legible == case["expected"]["legible"], verdict.reasoning
reasoning comes before legible: the judge explains, then decides (and the explanation is the failure message)legible in the dataset)Files of this solution, in the lab-snippets repository (branch master):
lab-snippets/
├── data/
│ ├── passport-jean-dupont.png # inputs (running example)
│ ├── passport-mario-rossi.png
│ └── passport-mohammed-ali.png
└── snippets/
├── lecture_prompting/
│ └── exercise2/
│ └── id_extraction.py # system under test (sample, vote)
└── lecture_validating/
└── exercise2/
├── passports.yml # test dataset
└── test_id_extraction.py # the test suiteVISION_MODEL=<model 1> poetry run python -m snippets -l validating -x 2 -v -s # -s shows agreement rates and accuracies
VISION_MODEL=<model 2> poetry run python -m snippets -l validating -x 2 -v -s # step 5: run once per model, compare the reports
SAMPLES (default 3) sets $N$; JUDGE_MODEL the judge (must accept images)Answer in your own words, motivating your conclusions and using examples where useful.
Questions asking whether something is possible or desirable require more than a yes/no answer.
Implementation questions can be answered with an explanation or pseudocode unless executable code is explicitly requested.
Labels identify the primary learning target:
Practical answers should identify the technology used, without requiring memorisation of product-specific details (model names, prices, API signatures, versions, limits).
[notions, theoretical] What makes generative software difficult to test? Describe the ingredients of an evaluation and explain how evaluations relate to ordinary unit tests.
[know-how, theoretical] How would you construct a representative test dataset for a document-processing application? Discuss how to establish expectations when the source is ambiguous, incomplete, or internally contradictory.
[reasoning, theoretical] How would you choose between deterministic checks, similarity metrics, an LLM judge, and human assessment? Give examples where a convenient metric would measure the wrong thing.
[know-how, theoretical] How would you check extracted facts without rejecting harmless differences in their representation? Explain when exact matching, normalisation, ranges, or set-based comparisons are appropriate, and how excessive tolerance could hide errors.
[notions, theoretical] What are invariants and relational or metamorphic tests? Give examples useful for generative software, and explain what they can establish when no single expected answer is available.
[know-how, theoretical] How would you design an LLM judge for a quality criterion that is difficult to check with code? Explain what evidence it needs and how its rubric and output should be structured.
[reasoning, theoretical] What changes when a judge grades one answer rather than compares two, or judges with rather than without a reference answer? Discuss how these choices affect the conclusions you can draw.
[know-how, theoretical] How would you detect and reduce position bias, verbosity bias, and self-preference in an LLM judge? Would using a stronger model or requesting a rationale be enough to trust its verdicts?
[reasoning, theoretical] How would you validate an LLM judge against human assessments? Explain why raw agreement can be misleading and what a chance-corrected measure such as Cohen’s kappa adds.
[reasoning, theoretical] An application passes every case in a small test dataset. What can you conclude about its reliability? Discuss uncertainty and the difference between testing more inputs and repeating the same inputs more times.
[know-how, theoretical] How would you compare two versions of a generative application fairly? Explain why paired results on individual cases can tell you more than comparing two average scores.
[reasoning, theoretical] What is the difference between accuracy and consistency? How would you establish whether majority voting improves a document extractor rather than merely making it more consistent?
[reasoning, theoretical] When is caching generated outputs useful during evaluation, and when does it invalidate what you are trying to measure? Distinguish reusing a sample across several checks from treating repeated cache hits as independent runs.
[reasoning, practical] Compare the test-oriented approach of DeepEval with the experiment-tracking approach of MLflow. What concepts do they share, and when might you use either or both in a project?
[know-how, practical] How would you build an evaluation suite for an application that extracts facts and writes explanations? Using DeepEval or MLflow, describe how you would connect the dataset, system under test, scorers, and report so that failures can be investigated.
[know-how, theoretical] When an evaluation fails, how would you decide whether the application, the expectation, or the scorer is wrong? How would you check that a suite which always passes can actually detect defects?
[know-how, practical] How would you use evaluations to detect regressions in CI? Discuss intermittent failures, acceptance thresholds, API costs and credentials, and the information needed to compare runs over time.
Compiled on: 2026-10-22 — printable version