Week 1 — Checker v0: rough but working¶
Why this week: day one is AI work. By Friday you have a checker that already does something useful, with numbers showing it beats a simple keyword rule.
By Friday you have: a scam-checker repository on GitHub with a keyword baseline, an LLM checker that returns structured answers and fails safely, a comparison on 30 messages, and a one-page app running on your laptop.
Main online course: Anthropic Academy's Building with the Claude API, up to and including structured output. The course uses an API key in its exercises; watch and read it for the ideas, then do the exercises with call_claude() from Calling Claude from Python.
Concepts¶
Repository and commit. A repository (repo) is a project folder whose history git records. A commit is a saved snapshot with a message saying what changed. Small, frequent commits let you undo mistakes and show your progress.
Virtual environment and uv. Each project gets its own private copy of Python packages, so projects can't break each other. uv creates that environment, installs packages and records exact versions in uv.lock, so the project works the same on another machine.
Linter and formatter. ruff check finds likely bugs and bad patterns; ruff format rewrites your code into one consistent layout. You stop arguing with yourself about style.
Baseline. The simplest reasonable solution, measured first. If an LLM can't beat a keyword list, it isn't worth its cost. Every claim you make in an interview should be "X beats baseline Y by Z".
Structured output. Instead of free text, you make the model reply in a fixed JSON shape (verdict, red flags, advice). Your code can then use the answer without guessing how to read it. Pydantic checks the shape for you.
Precision and recall, for scams. Precision: of the messages you flagged, how many were scams. Recall: of the real scams, how many you caught. For a scam checker, a missed scam (low recall) usually hurts more than a false alarm, but too many false alarms and people stop listening.
Retries, backoff and fallback. Calls fail: timeouts, usage limits, malformed replies. You retry a few times, waiting longer each time (backoff) with a little randomness (jitter) so retries don't pile up. If every attempt fails, you return a safe answer instead of crashing.
Monday — Set up the repo¶
Learn (75 min):
- uv: Creating projects, the "Packaged applications" section (20 min)
- Ruff tutorial (15 min)
- Pro Git, chapter 2.2 "Recording Changes to the Repository" (30 min), if commits are new to you
- Start the Building with the Claude API course
Build:
-
Make the project. In a terminal, in the folder where you keep projects:
--packagegives you thesrc/scam_checker/layout. It keeps your code separate from scripts and data, which matters once you add tests next week. -
Copy the helper from Calling Claude from Python into
src/scam_checker/llm.py, then check it works:You should see a short greeting and a line of token counts.
-
Lint and format everything:
-
Keep downloaded data out of git. Add this line to
.gitignore: -
Make the first commit and put the repo on GitHub:
git add . git commit -m "Set up project with uv, ruff and the Claude helper" gh repo create scam-checker --public --source . --pushA public repo is part of your portfolio. Never commit passwords or keys to it; there are none yet, and week 9 shows where secrets go.
-
Copy this week's Friday checklist (bottom of this page) into
README.mdas your plan for the week.
Done when: uv run ruff check . prints All checks passed! and your repo is visible at github.com/<your-username>/scam-checker.
Stuck?
uv: command not found: install uv withbrew install uv, then open a new terminal.gh: command not foundor not logged in:brew install gh, thengh auth login.ModuleNotFoundError: scam_checker: run commands from thescam-checkerfolder, withuv runin front.
Tuesday — Data and the keyword baseline¶
Learn (60 min): skim the SMS Spam Collection page: where the data comes from, and its licence. Then read pandas: Getting started only if read_csv and filtering feel rusty.
This dataset is 5,574 UK text messages from around 2012, labelled spam or ham (genuine). Spam isn't the same as a scam, and it isn't Singapore. It's a fine start for v0: week 5 replaces it with 100 messages you label yourself.
Build:
-
Download the data and add pandas:
-
A loader,
src/scam_checker/data.py. The file is tab-separated with no header, and some messages contain quote marks, so quoting is switched off:"""Load the SMS Spam Collection as a DataFrame.""" import csv from pathlib import Path import pandas as pd RAW_PATH = Path("data/raw/SMSSpamCollection") def load_sms() -> pd.DataFrame: df = pd.read_csv( RAW_PATH, sep="\t", header=None, names=["label", "text"], quoting=csv.QUOTE_NONE ) df["is_scam"] = df["label"] == "spam" return df -
Precision and recall, written out by hand in
src/scam_checker/metrics.py. You'll reuse this every week:"""Precision and recall, written out so you can see what they count.""" def precision_recall(truth: list[bool], predicted: list[bool]) -> tuple[float, float]: tp = sum(t and p for t, p in zip(truth, predicted)) fp = sum(p and not t for t, p in zip(truth, predicted)) fn = sum(t and not p for t, p in zip(truth, predicted)) precision = tp / (tp + fp) if tp + fp else 0.0 recall = tp / (tp + fn) if tp + fn else 0.0 return precision, recall -
Your turn: write the keyword baseline in
src/scam_checker/baseline.py. Start from this, then read 30 spam messages and grow the list:"""A keyword-rule checker: the number the LLM version has to beat.""" import re SUSPICIOUS_PHRASES = [ "you have won", "claim", "urgent", "verify", "click", ] URL_PATTERN = re.compile(r"https?://|www\.|\.com\b|\.ly\b", re.IGNORECASE) def red_flags(text: str) -> list[str]: lowered = text.lower() flags = [f"contains '{p}'" for p in SUSPICIOUS_PHRASES if p in lowered] if URL_PATTERN.search(text): flags.append("contains a link") return flags def keyword_check(text: str, threshold: int = 2) -> bool: """True if the message trips at least `threshold` rules.""" return len(red_flags(text)) >= threshold -
Score it on the whole dataset with
scripts/eval_baseline.py:from scam_checker.baseline import keyword_check from scam_checker.data import load_sms from scam_checker.metrics import precision_recall df = load_sms() predicted = [keyword_check(t) for t in df["text"]] p, r = precision_recall(df["is_scam"].tolist(), predicted) print(f"{len(df)} messages, {df['is_scam'].sum()} spam") print(f"keyword baseline: precision {p:.2f}, recall {r:.2f}")Change the phrase list and the threshold a few times and watch both numbers move. Notice the trade-off: catching more spam usually costs more false alarms.
-
Build the 30-message test set you'll use all week,
scripts/make_eval_set.py: 12 spam, 12 genuine, and 6 Singapore-style messages you write yourself from public advisories, such as the ScamShield site:"""Build data/eval30.csv: 12 spam and 12 ham from the dataset, plus 6 you write.""" import pandas as pd from scam_checker.data import load_sms df = load_sms() spam = df[df["is_scam"]].sample(12, random_state=42) ham = df[~df["is_scam"]].sample(12, random_state=42) own = pd.DataFrame( { "text": [ "Hi mum, I dropped my phone, this is my new number. Can you help me pay a bill urgently?", "SingPost: your parcel is on hold due to unpaid fees. Pay $1.99 at singpost-redelivery.com", "Part-time job: earn $300/day liking videos. Contact our HR on WhatsApp.", "DBS: your account has been suspended. Verify your details at dbs-secure-login.com", "Your Grab ride is arriving in 3 minutes. Driver: Tan, SJK1234Z.", "Reminder: your dental appointment is on Tue 10 Dec at 3pm. Reply C to confirm.", ], "is_scam": [True, True, True, True, False, False], } ) eval_set = pd.concat([spam[["text", "is_scam"]], ham[["text", "is_scam"]], own]) eval_set.to_csv("data/eval30.csv", index=False) print(eval_set["is_scam"].value_counts())Replace the six example messages with ones you write. Include at least one tricky genuine message, like the dental reminder that says "Reply C".
random_state=42makes the sample the same every run, so your numbers are comparable from day to day.
Done when: your baseline's precision and recall on the full dataset are written in README.md, data/eval30.csv exists, and both are committed.
Stuck? A fuller baseline
One version that gets roughly precision 0.98 and recall 0.52 on the full dataset:
SUSPICIOUS_PHRASES = [
"you have won", "you've won", "winner", "claim", "prize", "free",
"urgent", "act now", "verify", "account suspended", "account locked",
"click", "call now", "txt", "reply", "cash", "guaranteed",
]
MONEY_PATTERN = re.compile(r"[£$]\s?\d|\d+\s?(?:sgd|usd|dollars)", re.IGNORECASE)
Add if MONEY_PATTERN.search(text): flags.append("mentions money") inside red_flags. High precision, low recall is typical of keyword rules: they're sure when they fire, and they miss anything phrased differently.
Wednesday — One LLM call, with structured output¶
Learn (75 min):
- The structured output lessons in the Building with the Claude API course
- Structured outputs (docs, 30 min): read for the idea. Your
call_claude(..., schema=...)does the same job through the command line.
Build:
-
Add Pydantic, which turns a Python class into a JSON schema and checks replies against it:
-
The answer's shape,
src/scam_checker/models.py:"""The shape of every answer the checker gives.""" from typing import Literal from pydantic import BaseModel, Field class CheckResult(BaseModel): verdict: Literal["scam", "not_scam", "unsure"] red_flags: list[str] = Field(description="Specific warning signs found in the message") advice: str = Field(description="One or two plain sentences on what the reader should do")The
descriptiontext is sent to the model as part of the schema, so write it as an instruction. -
A first checker,
src/scam_checker/checker.py. No error handling yet; that's tomorrow:"""Check one message with Claude.""" from scam_checker.llm import call_claude from scam_checker.models import CheckResult SYSTEM_PROMPT = """You check text messages for scams, for ordinary people in Singapore. Decide whether the message is a scam, not a scam, or unclear. List the specific warning signs you see, quoting the words that worry you. Give one or two sentences of advice a non-technical reader can act on. Use "unsure" when the message could plausibly be genuine.""" def check_message(text: str) -> CheckResult: reply = call_claude( f"Message to check:\n\n{text}", system=SYSTEM_PROMPT, schema=CheckResult.model_json_schema(), ) return CheckResult.model_validate(reply.data) -
Try it on five messages from
eval30.csv, including two genuine ones: -
Your turn: read each answer as a sceptical user would. Are the red flags specific? Is the advice something your parents could act on? Did it invent facts, such as a phone number? Change the system prompt once to fix the worst problem, and note the before and after in your README.
Done when: check_message returns a CheckResult for any message you give it, and you've made one prompt change for a reason you can explain.
Stuck?
ValidationError: the reply didn't match the schema. Printreply.datato see what came back.reply.dataisNone: the schema wasn't passed. Check you usedschema=, notsystem=.- The model invents helpline numbers: tell it in the system prompt not to give phone numbers or links unless they are in the message.
Thursday — Fail safely, then compare¶
Learn (60 min):
- Claude API errors (docs, 15 min): which errors are worth retrying (overloaded, rate limited, timeouts) and which aren't (a bad request). The command line surfaces the same failures as a
ClaudeError. - Read your own
llm.pyagain and find each place it raisesClaudeError.
Build:
-
Your turn: make
check_messagesurvive failures. It should:- retry when
call_clauderaisesClaudeErroror Pydantic raisesValidationError - try at most 3 times, waiting about 2 seconds, then 4, plus a random fraction of a second
- log each failure with the
loggingmodule - return a safe fallback answer if every attempt fails, never crash
Write it yourself first; the hint below has one version.
- retry when
-
Test the failure path on purpose: temporarily call
call_claude(..., timeout=0.01)so every attempt times out. You should see three warnings, then the fallback answer. Put the timeout back afterwards. -
Compare the two checkers on the 30 messages,
scripts/compare.py:"""Run the baseline and the LLM checker on the 30-message set and compare.""" import pandas as pd from scam_checker.baseline import keyword_check from scam_checker.checker import check_message from scam_checker.metrics import precision_recall df = pd.read_csv("data/eval30.csv") truth = df["is_scam"].tolist() baseline = [keyword_check(t) for t in df["text"]] verdicts = [] for i, text in enumerate(df["text"], start=1): verdicts.append(check_message(text).verdict) print(f"{i}/{len(df)} {verdicts[-1]}") llm = [v == "scam" for v in verdicts] for name, predicted in [("keyword baseline", baseline), ("LLM checker", llm)]: p, r = precision_recall(truth, predicted) print(f"{name:17} precision {p:.2f} recall {r:.2f}") print(f"LLM said 'unsure' on {verdicts.count('unsure')} of {len(df)}") df["llm_verdict"] = verdicts df["baseline"] = baseline df.to_csv("data/eval30_results.csv", index=False)It takes about 4–5 minutes, one call at a time. Here "unsure" counts as not flagged; decide whether you agree, and say so in the README.
-
Open
data/eval30_results.csvand read every message where the two checkers disagree. Write two sentences in the README about what the LLM catches that keywords miss, and one thing it gets wrong.
Done when: a table in your README shows precision and recall for both checkers on the same 30 messages, and the failure test returned the fallback answer.
Stuck? One version of the retrying checker
import logging
import random
import time
from pydantic import ValidationError
from scam_checker.llm import ClaudeError, call_claude
from scam_checker.models import CheckResult
logger = logging.getLogger(__name__)
FALLBACK = CheckResult(
verdict="unsure",
red_flags=[],
advice=(
"We couldn't check this message. Don't click its links or share codes; "
"contact the organisation using a number you find yourself."
),
)
def check_message(text: str, max_attempts: int = 3) -> CheckResult:
"""Return a verdict, retrying failures and falling back to a safe answer."""
for attempt in range(1, max_attempts + 1):
try:
reply = call_claude(
f"Message to check:\n\n{text}",
system=SYSTEM_PROMPT,
schema=CheckResult.model_json_schema(),
)
return CheckResult.model_validate(reply.data)
except (ClaudeError, ValidationError) as exc:
logger.warning("attempt %d of %d failed: %s", attempt, max_attempts, exc)
if attempt < max_attempts:
time.sleep(2**attempt + random.random()) # 2s, 4s, ... plus jitter
return FALLBACK
Keep SYSTEM_PROMPT from Wednesday above this function. To see the warnings in a script, add logging.basicConfig(level=logging.INFO) at its top.
Friday — A page people can try, and demo day¶
Learn (45 min): Streamlit: Get started, the "Fundamentals" pages.
Build (before 9:00 or after demo day):
-
Add Streamlit and write
app.pyin the project root:"""A one-page checker. Run with: uv run streamlit run app.py""" import streamlit as st from scam_checker.checker import check_message st.title("Is this message a scam?") st.caption("A learning project. It can be wrong; when in doubt, don't click.") text = st.text_area("Paste the message here", height=150) if st.button("Check", type="primary") and text.strip(): with st.spinner("Checking..."): result = check_message(text) if result.verdict == "scam": st.error("Likely a scam") elif result.verdict == "not_scam": st.success("Probably genuine") else: st.warning("Not sure") if result.red_flags: st.subheader("Warning signs") for flag in result.red_flags: st.write(f"- {flag}") st.subheader("What to do") st.write(result.advice) -
Run it and try your six own messages:
It opens in your browser at
localhost:8501.
Why there's no public link yet
The original plan puts this page on Streamlit Community Cloud this week. That would need an API key, because a subscription login can't run an app for other people. You get a public link in week 9 instead, once the checker has a proper backend. Until then, demo it from your laptop.
Demo day (9:00–12:00):
- Record a 3-minute screen video: the README numbers, then the app catching a scam and passing a genuine message.
- Write five lines on what you learned this week.
- Send both to Claude with the Friday check-in.
Deliverable checklist¶
- Repo on GitHub, set up with uv and ruff, with a commit every weekday
- Keyword baseline with precision and recall on the full dataset
- A 30-message test set including 6 messages you wrote
- An LLM checker with structured output, retries and a safe fallback
- A README table comparing both checkers on the same 30 messages
- A Streamlit page running locally
- Demo video, five lines, and the Friday check-in sent
If you have time left: run compare.py with model="sonnet" passed through to call_claude and see whether the bigger model changes the numbers enough to justify being slower.