Adaptive Learning · In active development
CramAI
A study app that generates its own material and schedules it with FSRS-4.5. I built it for my Discrete Mathematics for Computing class and I'm still using it for coursework, which is how most of the interesting problems found me.
- Role
- Full-stack design & build
- Status
- In active development
- Stack
- Python · FastAPI · OpenAI · React · Supabase
- Scheduler
- FSRS-4.5 (SM-2 fallback)
Why I built it
I was using NotebookLM to study, and it was good at the thing it does: answer questions about documents you give it. But it was a tool, not a tutor. It didn't know me. It couldn't tell you whether I was getting better at anything, because it wasn't tracking anything. Every session started from zero.
Studying isn't a series of independent lookups; it's a process with state. What I needed to know was which concepts I kept failing, when I was about to forget something, and whether three weeks of work had actually moved anything. None of that exists if the tool has no memory of you.
So CramAI is built around the state rather than the generation. Generation is the part everyone can do now; the interesting question is what you do with the results over eight weeks of a semester.
Scheduling: FSRS-4.5, not a correctness ratio
The naive version of "adaptive" is a running percentage: get it right, see it less. I didn't write that, because the problem is well-studied and the good answers are public.
Reviews run on FSRS-4.5, which models every card as a pair
of values: stability (roughly, how many days until recall
decays to target) and difficulty (1–10, how hard this specific
card is for this specific user), and schedules each review to land
at the moment recall probability drops to 90%. Compared to SM-2 it reaches
the same retention with markedly fewer reviews, which for a student the week
before an exam is the entire value proposition.
SM-2 is still in the codebase, deliberately. It backs the legacy ease-factor bookkeeping shown in the card-history chart, and it bootstraps decks created before FSRS existed. Ratings are the familiar four-point scale: Again, Hard, Good, Easy, and both schedulers are pure functions with no I/O, which is why they're straightforward to test.
Topic mastery is then the share of a topic's cards that have reached maturity, which is what drives the progression dashboard and the learning path: the "am I actually improving" signal that NotebookLM couldn't give me.
The real problem was cost
Not accuracy, not latency: cost. A study app's whole premise is that generating material is cheap enough to do casually. If a single textbook chapter costs real money to turn into a deck, the product doesn't work, because the behaviour it depends on is students generating decks without thinking about it.
I originally built on the Claude API. Moving generation to OpenAI's
gpt-5.6-luna cut cost by roughly 5–9× at
comparable reasoning quality for this workload. That migration then opened up
three further optimisations that mattered more than the swap itself.
Reasoning effort is a per-call-site decision
Reasoning tokens bill as output tokens and count against
max_output_tokens, so leaving effort at the family default of
medium everywhere means paying for deliberation on calls that
don't need it, and worse, letting reasoning eat the budget the model needs
to emit its actual JSON.
Effort is now pinned per call site. Straight extraction and latency-sensitive
paths run at none. Document generation runs at low,
because spreading N cards evenly across a whole document while holding a
basic/cloze mix is planning work, not extraction. When the learning-path call
started truncating, the fix was recognising that its reasoning was starving
its own output; the cap moved from 1,024 to 4,096 tokens.
The bug that cost 10× and looked like nothing
This is my favourite bug in the project, because the application behaved perfectly correctly the entire time it was happening.
Quiz generation runs in rounds, and rounds 2 and 3 resend the same source document along with an "avoid" list of questions already asked. The document is the expensive part of that request, and prompt caching exists precisely to stop you paying full price for it twice.
But the cache matches on a byte-identical prefix. I had put
the avoid-list in instructions, ahead of the source text. That
list changes every round by definition. So every round presented a different
prefix, every round missed the cache, and every round re-paid full input price
on a document the cache was already holding.
The fix
Move the avoid-list so it trails the document. Anything that varies between two requests has to sit after everything they share. That one reordering took roughly 47% off an additional round on a 30-page deck, and 70% on an 86-page one.
The part I actually learned wasn't about caching. It was about failure modes that don't announce themselves. Nothing threw. No output changed. No test failed. The app worked exactly as designed and quietly cost ten times more on input than it should have. A comment saying "keep this last" would have been deleted by the next person to touch the file, including me.
So it's pinned by test_prompt_cache.py, which asserts the
ordering contract directly. If someone moves the avoid-list back into
instructions, the suite fails loudly instead of the bill rising
silently. Bugs that are invisible in behaviour need tests, not
comments. Comments document intent, tests enforce it.
The cache key itself is a SHA-256 hash of the stable prefix rather than the raw text, so no document content travels in a request metadata field. It's also deliberately not set on the explain and learning-path calls: their entire prefix sits under the 1,024-token cache minimum and can never produce a hit, so adding it would be theatre.
What an 86-page PDF broke
A real document from my own coursework got rejected outright: the generate route capped source text at 50,000 characters. Raising it to 500,000 (~125k tokens, roughly 200 pages) was the obvious fix, and on its own it would have accomplished nothing.
The prompt hardcoded "~10 flashcards." A textbook chapter produced exactly the same tiny deck as a two-page handout: technically successful, completely useless. Card targets now scale with document length at roughly one card per 2,000 characters, clamped to 10–120, with quizzes at a third of that. The prompt also asks for coverage spread across the whole document, because without that instruction generation front-loads on the opening pages and silently ignores the back half.
Generating that much output needs room: the combined call's budget went from
16,384 to 64,000 tokens, which in turn forced a move to streaming. And the
global two-minute client timeout would abort a large generation mid-flight, so
/generate/all got its own ten-minute timeout.
The pattern
One cap was the visible failure. Fixing it exposed three more limits sitting behind it (prompt, output budget, and timeout), none of which announced themselves until the one in front had moved. Raising a limit usually means finding the next one.
The cheapest call is the one you don't make
Cutting the price of a generation is only half the problem. The other half is noticing how many features people assume need a model and then checking whether they actually do.
Practice quizzes were the clearest case. Asking the API for another round of multiple-choice questions is the obvious implementation, and it's wrong: the deck already contains everything a question needs. A card's question becomes the prompt, its answer becomes the correct option, and the distractors are borrowed from other cards in the deck, preferring the same topic so the wrong answers are plausible rather than absurd. Unlimited rounds, zero tokens, and it works on decks imported from Anki that never had an AI-generated quiz at all.
It refuses rather than fakes. If a deck has fewer than two distinct answers there's no honest distractor to offer, so the builder returns nothing and the UI says so, instead of producing a question with one real option.
The same principle covers CSV import and export, deck shuffling and topic filtering, and every review and scheduling flow: none of them touch the API. The model is metered to the parts that genuinely require one, which is what keeps the app usable during the exam week when usage, and the bill, spike together.
Sharing it
I've demoed CramAI to other students at RIT AI Club meetings and tabling events. Watching someone use it for thirty seconds surfaces things that months of using it yourself will not; you stop seeing your own interface after a while, and the questions people ask are a much better bug report than anything I'd have written down alone.
The thing standing between this and launch
I want to open CramAI up to other students. The obstacle isn't technical: it's that every generation is billed to my personal card.
A study app has an awkward cost profile. Usage isn't spread evenly; it spikes hard in the two weeks around midterms and finals, which is exactly when the app is most useful and exactly when a free tier is most expensive. Success and cost are the same curve. "It got popular" and "I can't afford it" would arrive in the same week.
So a paid tier is likely a requirement rather than an ambition, not to build a business, but because unbounded free generation on a student budget isn't a thing that can exist. That reframes the cost work on this page: the 5–9× provider saving and the caching fix aren't optimisation for its own sake, they directly set how much free usage is survivable and how low a paid tier could reasonably start.
It's the clearest lesson the project has taught me. Beyond a certain point, architecture decisions and business-model decisions stop being separate conversations: the unit economics are a design constraint, and one that shows up in the code long before it shows up on a balance sheet.