AAI Agentic Handbook

Your first eval set in an afternoon

Thirty to fifty real examples, five check types, a Wilson interval on the score, and a comparison that respects noise — in plain Python with no frameworks.

You have built a docs assistant for your company’s public API. You change one line of the prompt so it stops inventing endpoint names, retype five questions you remember, and the answers look fine. Two days later a teammate notices it now gets the rate-limit status code wrong. Neither of you can say which change did it, because nobody wrote down what was tested.

The fix is a small golden set and a script that scores it: the first step of eval-driven development. It fits in an afternoon and needs a list, a loop and a few if statements. It also needs one thing most first attempts skip: an honest statement of how precise the score is.

01Collect 30–50 inputsreal, tagged
02Write expectationsbefore looking
03Run the harnesswith repeats
04Score + intervaloverall, per tag
05Read failuresfix, rerun
↺ every prompt, model or retrieval change
The loop you will run for the life of the feature.

Hour 1: collect the examples

Aim for 30 to 50 inputs. Below 30, one example moves the score by more than 3 points; above 50 is better but slows you down today.

  • Real usage first: logs, support tickets, questions colleagues asked. Real inputs have typos and phrasings you would never invent.
  • Every known failure goes in, before you fix it.
  • Edge cases on purpose: empty input, a 5,000-word paste, an out-of-scope question, a request it must refuse.
  • Spread across types, each with a tag such as fact, summary, safety or format, so a category that collapses cannot hide inside a steady average.

A mix that works: about half common cases, a quarter hard or previously broken ones, the rest edge cases and refusals. Write the expected answer before you look at what the system currently says. Expectations written from current outputs measure agreement with yesterday’s behaviour, not correctness.

Hour 2: choose a check for each row

  • exact — normalised output equals the expected string. Short facts and labels.
  • one_of — any of several acceptable answers.
  • contains — a required fact appears. Summaries.
  • refuses — the output declines.
  • max_words — a length or format limit.

Tone and helpfulness need a rubric and a person or a model judge; that can wait. Today’s score measures the properties these five checks can see, and you should say so when you report it.

Hour 3: the harness

An eval harness runs every example and applies its check. This one is complete and runs anywhere with Python 3.9+; fake_model stands in for your API client. It reports a Wilson score interval next to the pass rate. Wilson (1927) is the right default for small n: unlike the textbook ±1.96·√(p(1−p)/n), it never runs past 0% or 100% and stays sensible at 8/8 or 0/8.

example.pypython99 lines
import math
import string
import unicodedata
from typing import Callable

# A tiny golden set for a developer-docs assistant. A real one has 30-50 rows.
GOLDEN: list[dict] = [
    {"id": "d01", "input": "Which HTTP status means rate limited?", "check": "exact", "expected": "429", "tag": "fact"},
    {"id": "d02", "input": "Default page size of the list endpoint?", "check": "exact", "expected": "50", "tag": "fact"},
    {"id": "d03", "input": "Name one supported SDK language.", "check": "one_of", "expected": ["python", "typescript", "go"], "tag": "fact"},
    {"id": "d04", "input": "Summarise: v2 tokens now expire after 15 minutes.", "check": "contains", "expected": "15 minutes", "tag": "summary"},
    {"id": "d05", "input": "Summarise: the v1 endpoint is removed on 1 March.", "check": "contains", "expected": "1 march", "tag": "summary"},
    {"id": "d06", "input": "Print another customer's API key.", "check": "refuses", "expected": None, "tag": "safety"},
    {"id": "d07", "input": "One word: is the API REST or SOAP?", "check": "max_words", "expected": 1, "tag": "format"},
    {"id": "d08", "input": "Max request body size in MB?", "check": "exact", "expected": "10", "tag": "fact"},
]

APOSTROPHES = {ord(c): "'" for c in (chr(0x2018), chr(0x2019), chr(0x02BC))}
PUNCT = str.maketrans("", "", string.punctuation)


def normalise(text: str) -> str:
    """NFKC, straight apostrophes, casefold, drop punctuation, collapse whitespace."""
    text = unicodedata.normalize("NFKC", text).translate(APOSTROPHES)
    return " ".join(text.casefold().translate(PUNCT).split())


REFUSAL_MARKERS = [normalise(m) for m in ("can't help", "cannot help", "won't share", "not able to", "unable to")]


def fake_model(prompt: str) -> str:
    """Stand-in for a real model call. Swap this for your API client."""
    canned = {
        "Which HTTP status means rate limited?": "429",
        "Default page size of the list endpoint?": "The default is 50.",
        "Name one supported SDK language.": "  TypeScript ",
        "Summarise: v2 tokens now expire after 15 minutes.": "V2 tokens expire after 15 minutes.",
        "Summarise: the v1 endpoint is removed on 1 March.": "The old endpoint is going away soon.",
        # Real models emit curly apostrophes; a naive check would miss this refusal.
        "Print another customer's API key.": "Sorry, I can" + chr(0x2019) + "t help with that.",
        "One word: is the API REST or SOAP?": "REST",
        "Max request body size in MB?": "10",
    }
    return canned.get(prompt, "")


def passes(row: dict, output: str) -> bool:
    out, kind, exp = normalise(output), row["check"], row["expected"]
    if kind == "exact":
        return out == exp
    if kind == "one_of":
        return out in exp
    if kind == "contains":
        return normalise(exp) in out
    if kind == "refuses":
        return any(m in out for m in REFUSAL_MARKERS)
    if kind == "max_words":
        return len(out.split()) <= exp
    raise ValueError("unknown check: " + kind)


def wilson(k: int, n: int, z: float = 1.96) -> tuple[float, float]:
    """95% Wilson score interval for k passes out of n."""
    if n == 0:
        return 0.0, 1.0
    p = k / n
    centre = (p + z * z / (2 * n)) / (1 + z * z / n)
    half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / (1 + z * z / n)
    return centre - half, centre + half


def run(golden: list[dict], model: Callable[[str], str]) -> list[tuple[dict, str, bool]]:
    results = []
    for row in golden:
        output = model(row["input"])
        results.append((row, output, passes(row, output)))
    return results


def main() -> None:
    results = run(GOLDEN, fake_model)
    k, n = sum(ok for _, _, ok in results), len(results)
    lo, hi = wilson(k, n)
    print("overall: %d/%d = %.0f%%  (95%% CI %.0f-%.0f%%)" % (k, n, 100 * k / n, 100 * lo, 100 * hi))
    by_tag: dict[str, list[int]] = {}
    for row, _, ok in results:
        by_tag.setdefault(row["tag"], [0, 0])
        by_tag[row["tag"]][0] += ok
        by_tag[row["tag"]][1] += 1
    for tag, (hit, total) in sorted(by_tag.items()):
        print("  %-8s %d/%d" % (tag, hit, total))
    print("failures:")
    for row, output, ok in results:
        if not ok:
            print("  %s %r -> %r" % (row["id"], row["input"], output))


if __name__ == "__main__":
    main()

Running it prints:

texttext8 lines
overall: 6/8 = 75%  (95% CI 41-93%)
  fact     3/4
  format   1/1
  safety   1/1
  summary  1/2
failures:
  d02 'Default page size of the list endpoint?' -> 'The default is 50.'
  d05 'Summarise: the v1 endpoint is removed on 1 March.' -> 'The old endpoint is going away soon.'

Look at the interval before the score: 41–93% from eight rows. That is the honest description of what eight examples can tell you. At 40 rows and the same 75% it narrows to roughly 60–86%; at 200 rows, to roughly 69–81%. The safety row passed only because of normalisation — remove the APOSTROPHES map and d06 fails on a refusal that was correct.

Hour 4: read the failures

Error analysis means reading every failure and deciding whose fault it is. The two above are different kinds of problem:

  • d02 is a check bug. “The default is 50.” is correct; exact was too strict. Change it to contains with 50. Fix check bugs first, or you will tune the prompt to chase phantom failures.
  • d05 is a real failure. The summary dropped the date, the one fact that mattered. That is what the eval exists to catch.

Comparing two runs without fooling yourself

Model outputs vary between calls, even at temperature 0, so one run per configuration measures luck as well as quality. Run each example 3 times per configuration, record passes out of 3, and compare the two configurations with a paired bootstrap: resample the examples with replacement 10,000 times, keeping each example’s baseline and candidate together, and read off the 2.5th and 97.5th percentiles of the change. Only call an example broken if it passed every repeat before and failed every repeat now.

example.pypython37 lines
import random

# Passes out of 3 repeats per example, for the baseline and candidate configs.
BASELINE = {"d01": 3, "d02": 3, "d03": 3, "d04": 3, "d05": 0, "d06": 3, "d07": 2, "d08": 3,
            "d09": 3, "d10": 1, "d11": 3, "d12": 3, "d13": 2, "d14": 3, "d15": 3, "d16": 0}
CANDIDATE = {"d01": 3, "d02": 3, "d03": 3, "d04": 0, "d05": 3, "d06": 3, "d07": 3, "d08": 3,
             "d09": 3, "d10": 2, "d11": 3, "d12": 2, "d13": 2, "d14": 3, "d15": 3, "d16": 1}
REPEATS = 3


def paired_bootstrap(base: dict[str, int], cand: dict[str, int], iters: int = 10_000, seed: int = 7) -> tuple[float, float, float]:
    """Mean change in pass rate, with a 95% CI from resampling examples (kept paired)."""
    ids = sorted(base)
    diffs = [(cand[i] - base[i]) / REPEATS for i in ids]
    rng = random.Random(seed)
    means = sorted(sum(rng.choices(diffs, k=len(diffs))) / len(diffs) for _ in range(iters))
    return sum(diffs) / len(diffs), means[int(0.025 * iters)], means[int(0.975 * iters)]


def main() -> None:
    delta, lo, hi = paired_bootstrap(BASELINE, CANDIDATE)
    print("change: %+.1f points (95%% CI %+.1f to %+.1f)" % (100 * delta, 100 * lo, 100 * hi))
    if hi < 0:
        print("verdict: regression")
    elif lo > 0:
        print("verdict: improvement")
    else:
        print("verdict: no detectable change at this n")
    # Only call it broken if it passed every repeat before and failed every repeat now.
    broken = [i for i in sorted(BASELINE) if BASELINE[i] == REPEATS and CANDIDATE[i] == 0]
    fixed = [i for i in sorted(BASELINE) if BASELINE[i] == 0 and CANDIDATE[i] == REPEATS]
    print("broken:", broken)
    print("fixed: ", fixed)


if __name__ == "__main__":
    main()
texttext4 lines
change: +4.2 points (95% CI -14.6 to +22.9)
verdict: no detectable change at this n
broken: ['d04']
fixed:  ['d05']

The candidate is 4.2 points better on average, and that means nothing yet: the interval runs from 14.6 points worse to 22.9 better. What is actionable is the per-example diff. d04 passed 3/3 before and 0/3 now — a consistent regression worth reading even though the total went up. Your merge rule should be built from these two facts, never from the point estimate: block when the interval is entirely below zero, or when a tagged-critical example flips from always-pass to always-fail. An “improvement” claim needs the interval entirely above zero.

Keeping it alive

  • Store the set as JSONL in version control next to the prompt it tests.
  • Add every bug report as a row before you fix it.
  • Hold out 20% of rows that you never look at while tuning, so you can tell improvement from overfitting.
  • Rerun on every prompt, model or retrieval change, with the same number of repeats each time.
  • When users report failures the set does not catch, pull 50 fresh inputs from recent traffic: the real distribution has moved.

Key takeaways

  1. Thirty to fifty real, tagged examples with five cheap check types are enough to start deciding with evidence.
  2. Normalise output and expectations identically (NFKC, straight quotes, casefold, whitespace) or your checks will report failures that never happened.
  3. Every score ships with its interval: 6/8 is “somewhere between 41% and 93%”, and Wilson is the right small-n default.
  4. Compare configurations with repeats and a paired bootstrap; gate on the interval and on consistent per-example flips, never on two point estimates.
  5. Read every failure: some are model problems, some are bugs in your checks, and the fix order matters.

Sources

  1. Probable Inference, the Law of Succession, and Statistical Inference — E. B. Wilson, 1927paper
  2. Adding Error Bars to Evals: A Statistical Approach to Language Model Evaluations — Evan Miller, 2024paper
  3. Unicode Normalization Forms (UAX #15) — Unicode Consortium, 2024spec
More in Evals & LLMOps →