Skip to content
Leandro FariaUTC−3
← SELECTED WORK

Estudador

AI-generated practice questions, checked against the source curriculum before a student ever sees them.

THE SHORT VERSION

Exam practice needs volume, and volume is where the economics break. Generating questions with a language model is easy; making sure the answer key is right is not, and paying for every single one is worse. So generated questions go into a pool that belongs to nobody, and the model is only called when someone exhausts it — which fixes the cost of a subject at its heaviest user and leaves every student after that one close to free. Today 43.5% of exercises are served straight from the pool at no cost, and that share climbs as the pool fills. This version has been in production for about two months.

WHEN
2025 — present
MY PART
Architecture, backend, frontend, payments, and the server it runs on.
BUILT WITH
Next.js · NestJS · MongoDB · Clerk · Asaas API · DeepSeek API

CONTEXT

Estudador gives students practice questions. You pick a level, a subject and a topic, it hands you an exercise, then marks it and explains the answer.

Generating questions with a language model is the easy part. A wrong answer key is the real product risk here, not latency — a student who learns something incorrect is worse off than one who waited half a minute. And every generated question costs money, so the obvious design pays the model again every time a second student asks for the same thing. Most of the engineering below exists because of that second problem.

WHAT CONSTRAINED IT

  • I am the only engineer on it, which means every design has to be one I can also operate.
  • It sells at R$24 and R$48 a month, which leaves very little room for a per-question API bill. The cost model had to be part of the architecture rather than something I looked at later.
  • I decided not to do behavioural advertising or build user profiles. That's the legal basis the product runs on, so the code has to keep the promise — it isn't a paragraph in a policy page.

HOW IT WORKS

How a question reaches a studentA student asks for an exercise. The system first looks in a shared pool for a question that student has not seen. When it finds one, it is served in under a second with no model call, which is what happens 43.5% of the time. When the pool is exhausted for that student, the model writes a new question, the output is checked against a schema before anything is stored, and the question then joins the pool, where it is available to every student who asks afterwards.43.5%OF REQUESTS, MEASURED IN PRODUCTIONA student asksfor an exerciseIs there one in the poolthis studenthas not seen?Served from the poolUNDER A SECONDNO MODEL CALLThe modelwrites oneChecked againsta schemaYESNOJOINS THE POOL, FOR EVERYONEThe pool has no owner: a question written for one student serves every student after them.So the model is paid once per question, and only when somebody reaches the end of a subject.
The whole cost argument is this one branch. Everything else in the system exists to make the top path happen more often.

WHAT IT LOOKS LIKE

An answered maths exercise showing the correct option, the option the student picked, and a written explanation with rendered equations.
A wrong answer, corrected. The explanation derives the result and then names the mistake behind each wrong option. This is the part that has to be right — a bad answer key teaches a student something false, which is worse than making them wait.
A performance dashboard with counters for exercises solved, accuracy and study time, above a grid of written insight cards.
The performance panel. Every card is written by a rule reading the student's own numbers — no model call, no cost, and nothing appears below a minimum sample, because "your weak spot is X" over one wrong answer destroys trust in the whole page. Screenshot from a demo account.

DECISIONS

  1. 01

    The question pool has no owner

    Generated questions go into a shared pool, so the model is only called when somebody exhausts a subject — which fixes that subject's cost at its heaviest user.

    The only personal filter when serving a question is whether that particular student has already seen it. With m students of similar appetite in the same subject, the reuse rate is 1 − 1/m: two students reuse half, ten reuse ninety percent, a hundred reuse ninety-nine.

    Production currently serves 43.5% of exercises straight from the pool. At this size, with students spread thin across many subjects, that is roughly what the formula predicts — which is the reason to measure it rather than assume it.

    INSTEAD OF
    A pool per student
    WHAT IT COST
    Two students in the same subject can be handed the same question, so anti-repetition has to be tracked per person instead of being a property of the pool. And a subject with exactly one student never reuses anything — that tail is the only place where the AI bill gets close to half of what the plan brings in. I mitigate it by not fragmenting the catalogue, which is a product decision, not an infrastructure one.
  2. 02

    Warm the pool while the student is still answering

    After serving a question I start generating the next one and don't wait for it, so the student's next question comes out of the pool in under a second instead of twenty-six.

    This costs nothing in quota and can't collide with the one-exercise-at-a-time lock, and not by accident: the daily quota counts solutions, and the lock is a unique index on a field that only exists while a solution is pending. Pre-generation creates neither. Not creating a solution is what makes it free.

    INSTEAD OF
    Generating on demand every time
    WHAT IT COST
    I pay for questions nobody may answer. I took that trade because the pool is shared — a student who abandons the session still leaves a piece behind that serves whoever asks next, including themselves tomorrow.
  3. 03

    Recent questions go in the user message, never the system prompt

    The examples that stop the model repeating itself go in the user message, because putting them in the system prompt would break the provider's cache and multiply the input cost by around thirty.

    The provider caches the prompt prefix, and the cache only works if the system prompt is byte-for-byte identical between calls. Anything that varies per request has to live after it.

    INSTEAD OF
    One prompt with everything in it
    WHAT IT COST
    The instructions live in two places, which is less obvious to read six months later.
  4. 04

    One usage row per call to the provider, not per exercise

    Every trip to the model writes its own row, so what the retries cost shows up instead of disappearing into an average.

    Cost is stored as an integer in micro-dollars, because the provider's price table is already micro-dollars per token and the conversion is exact.

    The price table is a list of eras with effective dates, picked by the date of the call. A price change lands on its own at the right moment with no deploy timed to it, and a row from last month stays explainable by the table that was in force when it happened.

    INSTEAD OF
    One row per exercise, with the cost averaged
    WHAT IT COST
    More rows, and the reported figure is higher than a per-operation average would show. That is precisely the reason to do it, but it remains the cost.
  5. 05

    Ads are contextual, derived on the server

    Ad targeting is derived on the server from the exercise the student just answered, which meant I never had to store a profile — or even their education level.

    The browser asks for a slot with the id of that exercise and nothing else. From it the server gets three things at once: the education level to target, whether the exercise belongs to the person asking, and a key to deduplicate the impression.

    The solution they just answered already says what level they're at, at the exact moment I need to know it, so there was no reason to keep a field on their account saying the same thing.

    INSTEAD OF
    Targeting from a stored profile
    WHAT IT COST
    Targeting is coarser than a profile would give, and worth less to an advertiser. In exchange I keep less data and the legal basis holds without a consent banner.

WHAT CAME OUT OF IT

43.5%
of exercises cost nothing to servereuse rises as the pool fills
0.97
model calls per question served
12.1%
call failure rateretries are counted, not averaged away
70%
of tokens are outputthe model's reasoning is the cost driver

WHAT I WOULD DO DIFFERENTLY

There are no automated tests. What holds the system together is database invariants, schema validation on the model's output, strict typing and a few assertion scripts. It is the first thing I would add.

There's no queue and no scheduler — background work is fired and forgotten inside the process. That's why pre-generation can't move to off-peak hours, which is the largest cost saving still on the table.

The failure I return to most often is the pre-generation guard, which began with "if this question came from the pool, the subject is warm, skip". But pre-generation produces exactly one question, and the reused one was usually that question. Consuming it emptied the pool, and the guard read its own previous success as a reason not to refill. A student alternated between an instant question and a twenty-second one forever. A cache guard has to measure what's left, never where the last one came from.