Week 5 — Evals I: error analysis¶
Why this week: you have an agent, tools and retrieval, and a 30-message comparison from week 1. That's not enough to say where the checker fails or whether a change helps. This week you build a proper test set, run the checker on all of it, read every mistake, and turn what you find into a ranked list of failure types. It's the core skill for evaluation roles, and the one where your fraud background counts most: you already know what a costly miss looks like.
By Friday you have: 100 messages you labelled yourself, including tricky genuine bank and government messages; a script that runs the agent on all 100, four at a time, and survives a crash; a viewer for reading results one at a time; a ranked list of failure categories; grades for the agent's tool use; precision and recall with honest error bars; and one fixed failure category, with before-and-after numbers.
Main reading: the AI Evals FAQ by Hamel Husain and Shreya Shankar, the error-analysis and annotation questions. Each day lists which ones.
Concepts¶
Labelled test set. A fixed set of inputs with the right answer written down by a person who knows the domain, here you. It's the ruler you measure every change against, so it must stay the same from run to run and be labelled before you see what the checker says.
Trace. The full record of one run: the message, every tool the agent called and what came back, and the final answer. Your runs/ files from week 3 are traces. Error analysis means reading them, not just counting verdicts.
Error analysis. Reading the checker's outputs and writing down what went wrong, before deciding what to measure or fix. Precision and recall tell you how often it's wrong; error analysis tells you how and why. Without it you fix whatever you happen to notice first.
Open coding. The first pass: for each failure, a short free-text note in your own words, such as "flagged a real DBS OTP message because it mentions a transfer". No categories yet. The term comes from qualitative research, the same idea as coding interview transcripts. Note the first thing that went wrong in each trace; later problems often follow from it.
Axial coding. The second pass: group the notes into a small set of categories (a "failure taxonomy"), such as "genuine bank message flagged" or "invented phone number". Then count them.
Frequency × harm. Not every failure costs the same. A missed scam can cost someone their savings; a false alarm costs some trust. You give each category a harm score and rank by count × harm. It's the cost-weighted confusion matrix you've used in fraud work, applied to failure types.
Concurrency and threads. Most of a call_claude() call is waiting for a reply. A thread is a separate line of work inside one Python program; with four threads, four calls wait at the same time, so 100 messages take about a quarter of the time. ThreadPoolExecutor hands out the work to a fixed number of threads (a "pool") and gives you each result as it finishes.
Why 4 and not 50. Every call counts against your subscription's usage limit, whether it runs alone or alongside others. Fifty at once would hit the limit within a minute; every call would then fail, retry and fail again, and you'd lock yourself out of Claude Code for a while too. Each call also starts its own claude program on your laptop. Four is fast enough and gentle on both.
Resumable batch job. A long run will be interrupted: a usage limit, a closed laptop, a bug in message 63. Writing each result to the CSV the moment it arrives, and skipping finished rows on the next start, means you never lose work or pay twice for the same call.
Trajectory grading. Checking how the agent reached its answer, not only the answer: did it call the right tools, and did it stop in a sensible number of steps? A right answer reached by skipping the pattern lookup is luck, and it won't cite anything.
Confidence intervals at n = 100. With about 50 scams, one more caught scam moves recall by two points. A 95% interval for recall of 0.80 on 50 scams runs from about 0.67 to 0.89. So "0.80 versus 0.84" is probably noise. The Wilson interval is a standard way to compute this that behaves well near 0 and 1, where the textbook formula doesn't.
Monday — Label 100 messages¶
Learn (75 min):
- AI Evals FAQ: skim the list of questions so you know what's there (10 min)
- Why is error analysis so important, and how is it performed? (20 min): open coding, axial coding, and why you start by reading
- How many examples do I need for an eval? (15 min): why about 100 diverse examples, and why you annotate at least the first 30 yourself
- Eugene Yan's Product Evals in Three Simple Steps (20 min): start with a spreadsheet and binary labels
- How can I do error analysis when production traces contain sensitive data? (10 min)
Build:
-
Write your labelling rules first, in the README, before any labels. A few lines is enough:
- Scam: tries to get money, credentials, codes or an app install under false pretences.
- Not a scam: genuine, even if it's marketing, spam or annoying. Spam isn't fraud.
- scam_type: the knowledge base id it matches (
job_scam,bank_phishingand so on),otherfor a scam your knowledge base doesn't cover,nonefor genuine messages. - When unsure: decide, and write why in
notes. A label you can't decide on is useful information; it often marks a message the checker will also struggle with.
-
Gather 100 messages. Aim for about half scams and half genuine, with at least 15 tricky genuine ones:
Where from How many Notes Your week 1 data/eval30.csvanddata/rag_queries.csv20–30 Already written; relabel with a scam_typeThe SMS Spam Collection from week 1 up to 10 Only a few: it's UK messages from 2012 Public advisories (ScamShield, police news) 20–30 Write messages based on the examples they describe, in your own words Genuine messages on your own phone 15–25 Rewrite them: change every name, number, amount and account detail Messages you write the rest Fill gaps: each pattern twice, plus scams your knowledge base doesn't cover Tricky genuine messages are the most valuable rows in the file. Think of: a real bank OTP that mentions a transfer amount; a card transaction alert; an SMS from the gov.sg sender ID; a real courier saying a parcel arrives today; a clinic asking you to reply to confirm; a friend who really did change their number, with no request attached.
Never real people's private data
Don't paste a real message from a friend, relative or stranger as it is, and never include a real person's name, phone number, NRIC, address or account number. Change every detail so no one could be identified. Your repo is public, and in week 9 these messages may pass through a cloud service. This is the same rule as working with customer data at your old job.
-
Save them as
data/labelled100.csvwith five columns:id,text,is_scam,scam_type,notes. Theidis a number you never change, so results can be matched back to labels even if you reorder the file. The first rows of the rehearsal file:id,text,is_scam,scam_type,notes 1,"Hi mum, I dropped my phone, this is my new number. Can you help me pay a bill urgently?",True,fake_friend_call,written from ScamShield advisory 2,"SingPost: your parcel is on hold due to unpaid fees. Pay $1.99 at singpost-redelivery.com",True,parcel_delivery_phishing,written from ScamShield advisory 3,"Part-time job: earn $300/day liking videos. Contact our HR on WhatsApp.",True,job_scam,week 1 set 6,"DBS: Your OTP for the PayLah! transfer of $25.00 is 482913. Never share your OTP with anyone. If you did not request this, call the number on the back of your card.",False,none,tricky: genuine bank OTP message with a money amount 9,"Hi it's Mei, I've changed my number, this is the new one. See you at Ah Ma's birthday dinner on Sat!",False,none,"tricky: 'changed my number' but no request" 10,"Your parcel from Shopee will be delivered today between 2pm and 6pm. Track it in the Shopee app.",False,none,"tricky: parcel message, no link, no fee"Put quotes around any text containing a comma. Write
nonein lower case: pandas readsNoneandNAas missing values. -
Check the file before spending any calls on it, with
scripts/check_labels.py:"""Sanity-check data/labelled100.csv before you spend calls on it.""" import pandas as pd from scam_checker.retrieval import load_patterns df = pd.read_csv("data/labelled100.csv") known = {p.pattern_id for p in load_patterns()} | {"other", "none"} problems = [] if list(df.columns) != ["id", "text", "is_scam", "scam_type", "notes"]: problems.append(f"columns are {list(df.columns)}") if df["id"].duplicated().any(): problems.append(f"duplicate ids: {df.loc[df['id'].duplicated(), 'id'].tolist()}") if df["text"].duplicated().any(): problems.append("some messages appear twice") unknown = set(df["scam_type"]) - known if unknown: problems.append(f"scam_type values not in the knowledge base: {unknown}") genuine_with_type = df[~df["is_scam"] & (df["scam_type"] != "none")] if len(genuine_with_type): problems.append( f"genuine messages with a scam_type: {genuine_with_type['id'].tolist()}" ) scam_without_type = df[df["is_scam"] & (df["scam_type"] == "none")] if len(scam_without_type): problems.append(f"scams with scam_type 'none': {scam_without_type['id'].tolist()}") print( f"{len(df)} messages: {df['is_scam'].sum()} scams, {(~df['is_scam']).sum()} genuine" ) print(df["scam_type"].value_counts().to_string()) print("\n".join(problems) or "No problems found.")Fix everything it reports, and look at the counts per type: any pattern with fewer than three messages needs more.
-
Your turn: check your own consistency. Tomorrow morning, before anything else, hide the
is_scamandscam_typecolumns, relabel 20 random rows, and compare. If you disagree with yourself on more than two or three, your rules in step 1 aren't clear enough; sharpen them and fix the labels. Write the agreement rate in the README.
Done when: check_labels.py prints No problems found. for 100 rows, with at least 15 tricky genuine messages marked in notes, and the file is committed.
Stuck?
KeyError: 'scam_type'or the columns check fails: a stray space in the header row, or the file was saved from Excel with a different separator. Open it in a text editor and check the first line.is_scamcomes back as text, so~df["is_scam"]fails: some rows sayTRUE,yesor1. Use exactlyTrueandFalse.scam_type values not in the knowledge base: {nan}: an emptyscam_type, or one written asNone. Usenone.- Running out of ideas for genuine messages: read your own SMS inbox for a week's worth of bank, delivery, clinic and government messages, and rewrite each with made-up details.
Tuesday — Run the checker on all 100, four at a time¶
Learn (45 min):
- Python's concurrent.futures docs, the
ThreadPoolExecutorsection and its example (25 min). IgnoreProcessPoolExecutor - Python's csv docs,
DictWriteronly (10 min) - What is a trace? (5 min)
- Re-read the "Concurrency and threads" and "Why 4 and not 50" concepts above
Build:
-
Work out why you need this. One message through the agent takes about 10–20 seconds (two or three
call_claude()calls). One at a time, 100 messages take around 25 minutes, and a single crash at message 80 throws it all away. The script below runs four at a time and writes each result to disk as it arrives. -
If your week 3 code saves runs by appending to one shared file in
runs/, make it safe for four threads at once. Two threads appending a long line at the same moment can, rarely, mix the lines together. A lock lets one thread write at a time:Change the file name and the
linevariable to whatever your save function uses. If it writes one file per run instead, you don't need this. -
The runner,
scripts/run_eval.py:"""Run the agent on every labelled message, a few at a time, saving as it goes. Usage: uv run python scripts/run_eval.py [results file] If it stops halfway, run the same command again: finished rows are skipped. """ import csv import sys import time from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import date from pathlib import Path import pandas as pd from scam_checker.agent import run_agent WORKERS = 4 # calls running at once; more just hits your usage limit sooner FIELDS = [ "id", "verdict", "matched_pattern", "red_flags", "advice", "tools", "n_steps", "seconds", ] labels = pd.read_csv("data/labelled100.csv") default_path = f"evals/results_{date.today().isoformat()}.csv" out_path = Path(sys.argv[1] if len(sys.argv) > 1 else default_path) out_path.parent.mkdir(exist_ok=True) done = set(pd.read_csv(out_path)["id"]) if out_path.exists() else set() todo = labels[~labels["id"].isin(done)] print(f"{out_path}: {len(done)} already done, {len(todo)} to run") def check(row_id: int, text: str) -> dict: """Run the agent on one message and flatten the answer into one CSV row.""" ... # your turn, step 4 write_header = not out_path.exists() with open(out_path, "a", newline="") as f, ThreadPoolExecutor(WORKERS) as pool: writer = csv.DictWriter(f, fieldnames=FIELDS) if write_header: writer.writeheader() futures = [pool.submit(check, row.id, row.text) for row in todo.itertuples()] try: for i, future in enumerate(as_completed(futures), start=1): try: row = future.result() # One bad message shouldn't stop the run, whatever went wrong. except Exception as exc: # noqa: BLE001 print(f"{i}/{len(todo)} failed: {exc!r}") continue writer.writerow(row) f.flush() # on disk now, so a crash can't lose it print( f"{i}/{len(todo)} id {row['id']}: {row['verdict']} ({row['seconds']}s)" ) except KeyboardInterrupt: print("Stopping. Run the same command again to carry on.") pool.shutdown(cancel_futures=True)How it works, top to bottom:
- Resume: if the results file exists, read the ids already in it and only run the rest.
pool.submit(check, ...)queues every message at once; the pool runs four at a time. Each call returns a future, a placeholder for a result that isn't ready yet.as_completed(futures)hands back each future as soon as it finishes, in whatever order. That's why the progress lines show ids out of order.- Only the main loop writes to the CSV, one row at a time, so the four threads never write to the file at once.
f.flush()pushes the row to disk immediately instead of waiting in memory. - A failed message is printed and left out of the file, so the next run tries it again. Ruff normally objects to catching every kind of
Exception; thenoqacomment says you mean it here, and the line above says why. - Ctrl-C cancels the queued messages, lets the four running ones finish, and exits. Run the same command to carry on.
-
Your turn: write
check(). It runsrun_agent(text), times it withtime.perf_counter(), and returns a dict with exactly the keys inFIELDS. Lists don't fit in a CSV cell, so joinred_flagswith" | "and the tool names fromrun.stepswith";". Use""whenmatched_patternisNone.Stuck? One version of check()
def check(row_id: int, text: str) -> dict: """Run the agent on one message and flatten the answer into one CSV row.""" start = time.perf_counter() run = run_agent(text) return { "id": row_id, "verdict": run.result.verdict, "matched_pattern": run.result.matched_pattern or "", "red_flags": " | ".join(run.result.red_flags), "advice": run.result.advice, "tools": ";".join(step.tool for step in run.steps), "n_steps": len(run.steps), "seconds": round(time.perf_counter() - start, 1), }step.toolassumes each step in your week 2AgentRun.stepsstores the tool name in a field calledtool. If yours is called something else, change that one place. -
Rehearse the crash. Make sure the database is up (
docker compose up -d), start the run, let about five messages finish, then press Ctrl-C:Run the same command again. The first line should now say
5 already done, 95 to run(or however many had finished). That's the resume working. -
Let it finish. In the rehearsal, 10 messages took 54 seconds with four workers, so 100 take about 10 minutes. If a usage limit stops it, wait and re-run; nothing is lost.
-
The shared code for this week's scripts,
src/scam_checker/eval_data.py. It joins your labels with one results file and names each mistake:"""Join your labels with one eval run, and name each mistake.""" from pathlib import Path import pandas as pd LABELS_PATH = Path("data/labelled100.csv") def latest_results() -> Path: """The newest evals/results_*.csv (the date in the name sorts correctly).""" return max(Path("evals").glob("results_*.csv")) def failure_type(row: pd.Series) -> str: """Name the mistake in one row, or return '' if the answer was right.""" if row["is_scam"]: if row["verdict"] == "not_scam": return "missed scam" if row["verdict"] == "unsure": return "unsure on scam" if row["scam_type"] != "other" and row["matched_pattern"] != row["scam_type"]: return "wrong pattern" return "" if row["verdict"] == "scam": return "false alarm" if row["verdict"] == "unsure": return "unsure on genuine" return "" def load_joined(results_path: Path) -> pd.DataFrame: """One row per labelled message that has a result, with a `failure` column.""" labels = pd.read_csv(LABELS_PATH) results = pd.read_csv(results_path, keep_default_na=False) df = labels.merge(results, on="id", how="inner") df["failure"] = df.apply(failure_type, axis=1) return dfkeep_default_na=Falsekeeps empty cells as""rather than turning them into NaN, so "no pattern" compares cleanly. "Wrong pattern" counts as a mistake: the verdict was right but the citation wasn't. -
Add a confidence interval to
src/scam_checker/metrics.py. Putimport mathat the top, then:def wilson_interval(successes: int, n: int, z: float = 1.96) -> tuple[float, float]: """95% confidence interval for a proportion such as recall, honest at small n.""" if n == 0: return 0.0, 1.0 p = successes / n centre = (p + z**2 / (2 * n)) / (1 + z**2 / n) half = z * math.sqrt(p * (1 - p) / n + z**2 / (4 * n**2)) / (1 + z**2 / n) return max(0.0, centre - half), min(1.0, centre + half)Check it:
wilson_interval(40, 50)should give about(0.67, 0.89). See Binomial proportion confidence interval if you want the derivation. -
Score the run with
scripts/score_eval.py:"""Precision, recall and mistake counts for one eval run. Usage: uv run python scripts/score_eval.py [results file] (default: the newest) """ import sys from pathlib import Path from scam_checker.eval_data import latest_results, load_joined from scam_checker.metrics import precision_recall, wilson_interval path = Path(sys.argv[1]) if len(sys.argv) > 1 else latest_results() df = load_joined(path) truth = df["is_scam"].tolist() flagged = (df["verdict"] == "scam").tolist() # "unsure" counts as not flagged p, r = precision_recall(truth, flagged) tp = sum(t and f for t, f in zip(truth, flagged)) p_low, p_high = wilson_interval(tp, sum(flagged)) r_low, r_high = wilson_interval(tp, sum(truth)) print(f"{path}: {len(df)} messages, {sum(truth)} scams") print( f"precision {p:.2f} (95% CI {p_low:.2f}-{p_high:.2f}, from {sum(flagged)} flagged)" ) print(f"recall {r:.2f} (95% CI {r_low:.2f}-{r_high:.2f}, from {sum(truth)} scams)") print(f"unsure on {(df['verdict'] == 'unsure').sum()} messages") print() print(df["failure"].replace("", "correct").value_counts().to_string())Write the numbers in the README. They're your "before". Note how wide the intervals are.
Done when: evals/results_<today>.csv has 100 rows, you've seen the resume work after a Ctrl-C, and the README has precision and recall with their intervals.
Stuck?
- Many rows fail with a usage-limit message: you've hit your subscription's limit. Stop (Ctrl-C), wait, and re-run later; finished rows are kept. Don't raise
WORKERSto make up time. psycopg.OperationalErroron every row: the database isn't running, solookup_scam_patternfails.docker compose up -d, then re-run.pandas.errors.EmptyDataErrorat the start: the results file exists but is empty (the run died before writing the header). Delete it and start again.AttributeError: 'Step' object has no attribute 'tool': your step objects name the field differently. Print onerun.steps[0]and use its field name incheck().- Ctrl-C seems to do nothing for a while: it's waiting for the four calls already running. Give it up to a minute.
- A second run on the same day says
100 already done, 0 to run: with no file name, the script treats it as a resume of today's file. Pass a new name, such asuv run python scripts/run_eval.py evals/results_2027-01-12_sonnet.csv.
Wednesday — Read every failure: open coding¶
Learn (45 min):
- What makes a good custom interface for reviewing LLM outputs? (15 min): show the whole trace, show progress, make notes quick
- Should I build a custom annotation tool or use something off-the-shelf? (10 min)
- Streamlit's session state and st.form pages (20 min): how a page remembers which message you're on
Build:
-
A viewer that shows one result at a time and saves your note,
evals/viewer.py:"""Read eval results one message at a time and write a note on each. Run with: uv run streamlit run evals/viewer.py """ from pathlib import Path import pandas as pd import streamlit as st from scam_checker.eval_data import latest_results, load_joined NOTES_PATH = Path("evals/annotations.csv") NOTE_COLUMNS = ["results_file", "id", "note", "category"] results_path = latest_results() df = load_joined(results_path) notes = ( pd.read_csv(NOTES_PATH, keep_default_na=False) if NOTES_PATH.exists() else pd.DataFrame(columns=NOTE_COLUMNS) ) notes = notes[notes["results_file"] == results_path.name] st.title("Eval viewer") show_all = st.toggle("Show correct answers too", value=False) rows = df if show_all else df[df["failure"] != ""] if rows.empty: st.success("No failures in this run.") st.stop() if "i" not in st.session_state: st.session_state.i = 0 st.session_state.i = min(st.session_state.i, len(rows) - 1) left, middle, right = st.columns([1, 2, 1]) if left.button("Previous") and st.session_state.i > 0: st.session_state.i -= 1 if right.button("Next") and st.session_state.i < len(rows) - 1: st.session_state.i += 1 row = rows.iloc[st.session_state.i] middle.write(f"{st.session_state.i + 1} of {len(rows)} · `{results_path.name}`") st.subheader(f"Message {row['id']}") st.text(row["text"]) st.write( f"**Your label:** {'scam' if row['is_scam'] else 'genuine'}" f" ({row['scam_type']}). {row['notes']}" ) st.write(f"**Checker said:** {row['verdict']}, pattern `{row['matched_pattern']}`") if row["failure"]: st.error(row["failure"]) st.write(f"**Red flags:** {row['red_flags']}") st.write(f"**Advice:** {row['advice']}") st.write(f"**Tools ({row['n_steps']} steps):** {row['tools'] or 'none'}") saved = notes[notes["id"] == row["id"]] with st.form(key=f"note-{row['id']}"): note = st.text_area( "What went wrong, in your own words", value=saved["note"].iloc[0] if len(saved) else "", ) category = st.text_input( "Category (fill in later)", value=saved["category"].iloc[0] if len(saved) else "", ) if st.form_submit_button("Save note"): all_notes = ( pd.read_csv(NOTES_PATH, keep_default_na=False) if NOTES_PATH.exists() else pd.DataFrame(columns=NOTE_COLUMNS) ) same = (all_notes["results_file"] == results_path.name) & ( all_notes["id"] == row["id"] ) new = pd.DataFrame([[results_path.name, row["id"], note, category]]) new.columns = NOTE_COLUMNS pd.concat([all_notes[~same], new]).to_csv(NOTES_PATH, index=False) st.success("Saved.")Streamlit reruns the whole script on every click, so
st.session_state.iis how it remembers which message you're on.st.formgroups the two boxes so typing doesn't trigger a rerun until you press Save. Notes are stored per results file, so Friday's re-run starts with a clean slate. -
Your turn: open coding. Go through every failure. For each, write one or two sentences in the note box about the first thing that went wrong, in plain words, as you'd explain it to a colleague. Don't fill in the category yet. Good notes are specific:
- Weak: "wrong".
- Good: "Flagged a genuine DBS OTP message as a scam because it mentions a transfer; it ignored 'never share your OTP', which is what real banks say."
- Good: "Right verdict, but the advice gives a police phone number that isn't in the message or the knowledge base, and it's wrong."
The
failurelabel in red is only the automatic kind of mistake. Your note is the why. -
Then switch on "Show correct answers too" and read at least 20 of the answers marked correct. Right verdicts hide problems the counts can't see. In the rehearsal, one "correct" answer told the reader to call a police number that was wrong by one digit, and another gave a SingPost number that appears nowhere in the message or the knowledge base, so nothing backs it up. Note those too.
-
For any trace that puzzles you, open the matching line in
runs/to see what each tool actually returned. A wrong pattern often turns out to be a retrieval problem, not a model problem.
Done when: every failure, plus at least 20 correct answers, has a note in evals/annotations.csv.
Stuck?
ValueError: max() iterable argument is empty: there's noevals/results_*.csvyet. Run Tuesday's script first, from the project root.- The page says "No failures in this run": either the checker is perfect (check the labels loaded) or the ids in the results don't match
labelled100.csv. Look at both files'idcolumns. - Notes vanish when you click Next: you didn't press "Save note" first. The form only saves on the button.
ModuleNotFoundError: scam_checker: run Streamlit from the project root withuv run, as above.- It's slow going: that's normal. Budget 2–3 minutes per failure. Reading is the work; the AI Evals FAQ calls error analysis the most important activity in evals.
Thursday — Categories, ranking and the agent's trajectory¶
Learn (40 min):
- Re-read the axial coding part of Why is error analysis so important? (10 min)
- How do I evaluate agentic workflows? (20 min): end-to-end success first, then step-level checks such as tool choice and efficiency
- Should I build automated evaluators for every failure mode I find? (10 min): fix the obvious ones; use cheap code checks where you can
Build:
-
Your turn: axial coding. Read all your notes in one go (open
evals/annotations.csvin a spreadsheet or the viewer). Group them into 4–8 categories with short, specific names, and type each row's category into the viewer or the CSV. In the rehearsal the categories were:- skipped pattern tool: answered without calling
lookup_scam_pattern, so no citation - invented contact details: advice gives a phone number that isn't in the message or the knowledge base
- genuine bank message not trusted: a real OTP or transaction alert marked unsure or scam
A good category points at a fix. "Model is bad at banks" doesn't; "treats any amount plus 'transfer' as a scam signal, even in an OTP message" does.
- skipped pattern tool: answered without calling
-
Rank them with
scripts/rank_failures.py. SetHARMto your own judgement for each of your categories:"""Rank your failure categories: how often each happens, times how much it hurts. Usage: uv run python scripts/rank_failures.py [results file] (default: the newest) """ import sys from pathlib import Path import pandas as pd from scam_checker.eval_data import latest_results, load_joined # Your judgement, per category: 3 = the reader could lose money, # 2 = the reader is misled but probably safe, 1 = annoying or untidy. HARM = { "skipped pattern tool": 1, "invented contact details": 3, "genuine bank message not trusted": 2, } path = Path(sys.argv[1]) if len(sys.argv) > 1 else latest_results() df = load_joined(path) notes = pd.read_csv("evals/annotations.csv", keep_default_na=False) notes = notes[notes["results_file"] == path.name] df = df.merge(notes[["id", "category"]], on="id", how="inner") df["category"] = df["category"].replace("", "(no category yet)") ranking = df.groupby("category").agg(count=("id", "size"), ids=("id", list)) ranking["harm"] = [HARM.get(c, 0) for c in ranking.index] ranking["priority"] = ranking["count"] * ranking["harm"] ranking = ranking.sort_values(["priority", "count"], ascending=False) print(f"{path.name}: {len(df)} annotated messages\n") print(ranking[["count", "harm", "priority", "ids"]].to_string()) missing = [c for c in ranking.index if c not in HARM] if missing: print(f"\nGive these a harm score in HARM: {missing}")On the 10-message rehearsal it printed:
results_2026-10-17.csv: 4 annotated messages count harm priority ids category invented contact details 1 3 3 [4] skipped pattern tool 2 1 2 [1, 2] genuine bank message not trusted 1 2 2 [6]Copy your table into the README with one sentence per category. Be ready to defend each harm score in an interview: why is an invented phone number worse than a missing citation?
-
Grade the trajectory with
scripts/grade_trajectory.py. It reads the tool names and step counts thatrun_eval.pysaved:"""Grade how the agent worked, not just what it answered. Usage: uv run python scripts/grade_trajectory.py [results file] (default: the newest) """ import sys from pathlib import Path from scam_checker.baseline import URL_PATTERN from scam_checker.eval_data import latest_results, load_joined LINK_TOOLS = {"extract_links"} # use the names from your own TOOLS registry MAX_STEPS = 4 path = Path(sys.argv[1]) if len(sys.argv) > 1 else latest_results() df = load_joined(path) tools = df["tools"].str.split(";").apply(lambda t: [x for x in t if x]) checks = { "looked up a scam pattern": tools.apply(lambda t: "lookup_scam_pattern" in t), "checked links when there were any": [ not URL_PATTERN.search(text) or bool(LINK_TOOLS & set(t)) for text, t in zip(df["text"], tools) ], f"stopped within {MAX_STEPS} steps": df["n_steps"] <= MAX_STEPS, "no tool called twice": tools.apply(lambda t: len(t) == len(set(t))), } print(f"{path.name}: {len(df)} runs\n") for name, passed in checks.items(): failed = df.loc[[not p for p in passed], "id"].tolist() print(f"{name:36} {len(df) - len(failed):3}/{len(df)} failed: {failed}") print(f"\nsteps per run: mean {df['n_steps'].mean():.1f}, max {df['n_steps'].max()}")Put your week 2 link tools (expanding short links, checking domain age, checking a blocklist) in
LINK_TOOLS.URL_PATTERNis your week 1 keyword baseline's link rule, reused. The rehearsal printed:results_2026-10-17.csv: 10 runs looked up a scam pattern 7/10 failed: [1, 2, 6] checked links when there were any 9/10 failed: [2] stopped within 4 steps 10/10 failed: [] no tool called twice 10/10 failed: [] steps per run: mean 0.7, max 1Your turn: add one check of your own that matters for your agent. For example: a message that claims to be from a government agency should trigger your government-sender tool. Add any category the checks reveal to your ranking.
-
Pick the top category and plan its fix. Write in the README: the category, why it's top, what you'll change, and what number should move. Start the fix this afternoon if there's time.
Done when: every annotated row has a category, the README has the ranked table and the trajectory results, and you've written down the fix you'll make.
Stuck?
FileNotFoundError: evals/annotations.csv: you haven't saved any notes yet. Do Wednesday's open coding first.- A category shows
harm 0and a message at the bottom: it isn't inHARM. Add it with a score. AttributeError: Can only use .str accessor with string values, not floating: thetoolscolumn was read as NaN.load_joinedreads results withkeep_default_na=False; check you didn't drop that.- Categories keep multiplying: merge them. More than eight usually means some are the same failure seen from different sides.
Friday — Fix the top failure, re-run, and demo day¶
Learn (30 min): re-read your own README table from yesterday and the "Confidence intervals at n = 100" concept. Then skim How do I know if I can trust my automated eval?, a preview of week 7.
Build (before 9:00 or after demo day):
-
Your turn: fix the top category. Prefer fixes in code over fixes in the prompt when the rule is clear-cut; code does the same thing every time. Typical fixes:
- Skipped a tool the agent must always use: call it in code before the loop and put its result in the first prompt, rather than asking the model to remember.
- Genuine bank or government messages flagged: add what genuine ones look like to the knowledge base or the prompt (for example, ScamShield says banks never send clickable links by SMS, so a bank SMS without a link isn't suspicious for that reason).
- Invented contact details: remove them in code. The worked example below.
-
Worked example from the rehearsal, where "invented contact details" ranked first. The check doesn't need a model, so it lives in
src/scam_checker/guards.py:"""Checks on the checker's answer that don't need a model.""" import re from scam_checker.models import CheckResult from scam_checker.retrieval import load_patterns # 4+ digits, maybe with spaces or hyphens (1799, 6123 4567, 1800-123-4567), # but not prices or decimals such as $1.99 or 12.50 PHONE = re.compile(r"(?<![\d$])(?<!\d\.)\+?\d(?:[\s-]?\d){3,}(?!\d|\.\d)") SAFE_ADVICE = "If you're unsure, call the ScamShield Helpline on 1799." def numbers_in(text: str) -> set[str]: return {re.sub(r"\D", "", m) for m in PHONE.findall(text)} def known_numbers(message: str) -> set[str]: """Numbers the advice may mention: ones in the message or the knowledge base.""" kb = " ".join(p.body for p in load_patterns()) return numbers_in(message) | numbers_in(kb) def unknown_numbers(advice: str, message: str) -> set[str]: return numbers_in(advice) - known_numbers(message) def remove_unknown_numbers(result: CheckResult, message: str) -> CheckResult: """Drop any advice sentence with a phone number we can't vouch for.""" allowed = known_numbers(message) sentences = re.split(r"(?<=[.!?])\s+", result.advice) kept = [s for s in sentences if numbers_in(s) <= allowed] if len(kept) == len(sentences): return result advice = " ".join(kept + [SAFE_ADVICE]) return result.model_copy(update={"advice": advice})1799 is the ScamShield Helpline, and it's in your knowledge base, so it's allowed. If you want the advice to be able to give another official number, such as the police hotline, add it to a knowledge base file with its source, and the guard will allow it too. In
run_agent, just before it returns, pass the final answer through the guard:And test it, in
tests/test_guards.py:from scam_checker.guards import numbers_in, remove_unknown_numbers from scam_checker.models import CheckResult def test_finds_phone_numbers_but_not_prices(): assert numbers_in("Call 6123 4567 or 1800-123-4567. Pay $1.99.") == { "61234567", "18001234567", } def test_drops_sentences_with_invented_numbers(): result = CheckResult( verdict="scam", red_flags=[], advice="Don't pay. Call the police on 1800-000-0000.", ) fixed = remove_unknown_numbers(result, "Pay $5 now") assert "1800" not in fixed.advice assert fixed.advice.startswith("Don't pay.") assert "1799" in fixed.advice def test_keeps_numbers_from_the_message(): result = CheckResult(verdict="unsure", red_flags=[], advice="Call 6123 4567.") assert remove_unknown_numbers(result, "Call us on 6123 4567") == resultThese tests caught a real bug in the first version of the pattern: a full stop straight after a number ("call 6123 4567.") cut the number short. That's why the end of
PHONEsays "not followed by a digit, or by a dot and a digit" rather than "not followed by a dot". -
Turn the category into a number you can track on every run. Add two lines to
score_eval.py, the import with the others and the count after the "unsure" line:invented = sum(bool(unknown_numbers(a, t)) for a, t in zip(df["advice"], df["text"])) print(f"advice with a phone number not in the message or knowledge base: {invented}")Whatever your top category is, do the same: if code can spot it, write the check, so the category becomes a count instead of something you have to reread.
-
Re-run into a new file and score both runs:
uv run python scripts/run_eval.py evals/results_2027-01-15_fix1.csv uv run python scripts/score_eval.py evals/results_2027-01-12.csv uv run python scripts/score_eval.py evals/results_2027-01-15_fix1.csv uv run python scripts/grade_trajectory.py evals/results_2027-01-15_fix1.csvUse your own dates. In the rehearsal (10 messages), the invented-number count went from 2 to 0 and precision and recall stayed at 1.00. But two other things moved even though the fix didn't touch them: "wrong pattern" went from 2 to 3, and the "unsure" verdict moved from one genuine message to another. Same code, different run. That's run-to-run noise, and it's why you judge a fix by the category it targeted, and treat small moves elsewhere with suspicion.
-
Write the before-and-after in the README:
Before After fix 1 Precision (95% CI) Recall (95% CI) Top category count Looked up a pattern Then one paragraph: did the target category drop, did anything else get worse, and is the change bigger than the noise? With overlapping intervals, the honest answer to "did recall improve?" is often "not measurably", and saying so is a strength.
Demo day (9:00–12:00):
- Record a 3-minute screen video: the labelled file and your labelling rules, the viewer with one interesting failure and your note on it, the ranked table, and the before-and-after numbers.
- Write five lines on what you learned this week.
- Send both to Claude with the Friday check-in. Under "My numbers" put precision and recall with their intervals, and the top category's count before and after.
Apply now: this is the evidence evaluation roles ask for, including the NTU evaluation role on your list. Send applications this afternoon. In the application or cover note, lead with the work, not the tools: "I labelled 100 messages including tricky genuine bank and government messages, read every failure, built a failure taxonomy ranked by frequency and harm, and fixed the top category with a code check; precision X and recall Y (95% CI ...)". Link the repo and the README section. Your ten years of deciding what counts as fraud is the domain expertise these roles need; say so.
Deliverable checklist¶
-
data/labelled100.csv: 100 messages withscam_typeand notes, at least 15 tricky genuine ones, labelling rules and a self-agreement check in the README -
scripts/run_eval.pyrunning four at a time, resuming after a stop, writingevals/results_<date>.csv -
evals/viewer.pyand notes on every failure plus 20 correct answers - A ranked table of failure categories (count × harm) in the README
- Trajectory grades: pattern lookup, link checks, step counts
- Precision and recall with 95% intervals, before and after fixing the top category
- A code check for the top category, with tests
- Demo video, five lines, the Friday check-in, and applications sent
If you have time left:
- Measure your noise floor: run the unchanged checker twice into two files and compare. Any difference between them is noise, and tells you how big a real improvement has to be.
- Run the 100 with
model="sonnet"passed through tocall_claudein your agent, into its own results file, and compare cost, time and the failure counts with Haiku. Watch your usage limit; that's at least 200 calls. - Fix the second category and re-run. Keep every results file; by week 7 they become the history your CI gate compares against.