The shape of the problem
A resume is a list of claims. "Built a RAG pipeline." "Cut grading time by ~80%." "Real-time agentic CRM." Each claim needs to survive five minutes of cross-examination from someone who has actually built one of those things. The failure mode isn't that I forget what I did — it's that I remember the headline and lose the texture.
So the unit of work isn't the resume; it's the claim. Each claim points to one or more underlying concepts (FAISS, RAG eval, WebRTC signalling, etc.), and each concept gets drilled, reviewed, and reinforced on its own schedule.
What I built
- Voice drill loop. Pick a claim → app asks an opening question → I answer in voice →
faster-whispertranscribes locally → an OpenAI mid-tier evaluator scores the answer per-question, surfaces misconceptions, and queues a sharper follow-up. Transcripts and reviews are written per session as plain Markdown + JSON on disk. - Tiered LLM substrate. Three OpenAI tiers behind a single
TieredLLMfacade — flagship (gpt-5.4) for synthesis and lab notebooks, mid (gpt-5.4-mini) for the questioner / evaluator / lab grader, nano (gpt-5.4-nano) for review verdicts and JD scoring. End-to-end cost is ~$0.04–0.09 per drill, capped at $1/day by aBudgetTrackerthat raises before the next call. - In-app interactive labs. Concepts come with executable lessons stored as Marimo
.pyfiles but rendered inside the app — lesson markdown, setup, exercise prose, hidden solution.POST /lab/<slug>/gradecalls the lab grader (~$0.001) and returns a verdict + feedback as an HTMX partial. Same grader does retrieval checks on plain concept cards. - SM-2 spaced repetition. Verdicts (wrong / partial / right) feed an SM-2 implementation with bounded ease (1.3–3.0) and bounded interval (1–90 days). Schedule is recomputed lazily on the home page; no live event hooks, no background queue, no drift.
- Daily 8 AM IST Gmail digest. Idempotent multipart HTML+text email — what's due today, sparkline of last 14 days, open misconceptions, prereq warnings. Sent via Gmail SMTP from a cron line, suppressed when there's nothing to say.
- Career-ops integration. The drill page lists JDs from my
career-opstracker.list_jds(auto_refresh=True)stat-checksapplications.md's mtime; if it's newer than the last pull, the JDs auto-rebuild on the next page load. No webhooks, no message bus — just a file timestamp and a function call.
Architecture highlights
What I learned
Three things this project taught me that I wouldn't have learned by reading about spaced repetition:
- The model isn't the bottleneck — the prompt is. The pedagogy bar lives in
prompts/notebook_generate.mdandprompts/concept_explain.md, not in template code. When a generated lab feels weak, the fix is almost always tightening one of those files; rarely the rendering layer. - Idempotency is what makes home-grown tools survive. The digest is idempotent (one row in
digests.jsonlper day per recipient). The SR sync is idempotent (replays only sessions not inlast_synced_session_ids). The JD pull is idempotent (mtime-gated). I can reboot the box, lose state, run the same command twice — nothing breaks. - One "easy" feature paid for the rest. Wiring the daily digest took half a Saturday. It's the single feature that keeps me coming back, because it ships review work to me instead of asking me to remember to log in.
This is a single-user, single-server tool with no users to delight, no roadmap, no tickets. It exists because it makes my interviews go better. It's the kind of project I think every applied engineer should keep around — a thing that's small enough to never break, useful enough that it doesn't.
Stack
FastAPI · HTMX · Jinja2 · Polars · Pydantic v2 · Typer · structlog · faster-whisper · OpenAI Python SDK · Gmail SMTP · cron · systemd · Tailscale