The AI Can Say Anything. The Contract Can't.

StudySmart AI is one FastAPI core behind three clients - a Next.js app students use, a Laravel admin dashboard staff use, and an AI assistant that generates quizzes on demand. The assistant is also the one component in the system nobody can code-review before it runs. Here is the contract that exists because of that, and what it costs to keep.

The problem

An LLM does not return a quiz. It returns tokens that are usually shaped like one. Most of the time that difference doesn't show up. Sometimes a generated question drops a field, writes an answer key that points at an option it never listed, or hands back five options where the schema expects four. None of that is a bug in the model - it is what an unconstrained generator does when nothing downstream forces a shape on it.

That would be a minor annoyance with one client. StudySmart AI has three: the student-facing app that renders the quiz, the admin dashboard staff use to review and edit question banks, and the assistant chat that generated the quiz in the first place and can reference it again mid-conversation. All three read the same object.

Three clients can't each defend themselves

The obvious place to catch a malformed quiz is wherever it gets rendered. That fails specifically because there isn't one renderer. Writing defensive checks in the Next.js app, again in the Laravel admin, and again wherever the assistant surfaces a quiz inline means three chances to miss a case - and a fourth surface added next quarter that never learns the rule at all.

The alternative is to decide, once, in the FastAPI core, that a quiz object leaving the service is always the same shape - whether a person wrote it or a model did. Every client downstream gets to assume the contract instead of defending against it.

Decision: the contract wins, not the model's output

Quiz generation sits behind a strict response model: fixed fields, an enum for question type, and a validator that checks the answer key actually references an option that exists in the list the model wrote. When the raw generation fails that validation, the request does not return a half-broken quiz with a warning attached. It re-prompts once, with the validation error folded back into the model call, and only returns success once the object satisfies the schema on its own.

class QuizQuestion(BaseModel):
    prompt: str
    options: list[str] = Field(min_length=2, max_length=6)
    answer_index: int

    @field_validator("answer_index")
    def answer_in_range(cls, v, info):
        options = info.data.get("options") or []
        if not (0 <= v < len(options)):
            raise ValueError("answer_index outside options")
        return v

That validator isn't defensive programming against a hypothetical. It is the actual failure mode - generated answer keys pointing at options that don't exist happens often enough that it earns an explicit check rather than trusting the prompt to have handled it.

The tradeoff: latency for a guarantee

This costs something real. A regeneration round trip on a validation failure is a second model call sitting on the request path, and that is latency a student feels while a quiz is loading. The alternative - stream the raw output through and patch it up on the client - would put a validation-and-repair layer inside three different frontends in three different languages, none of which stay in sync the next time the schema changes.

The design accepts the occasional slow response because it converts an open-ended failure mode - anything a model can produce - into a closed, boring one: a slower response, sometimes. Slow-but-correct beats fast-but-sometimes-broken here specifically because "sometimes" would otherwise surface as a rendering crash weeks after ship, in whichever of the three clients happened to hit the malformed case first.

Sessions are a state machine, not a blob

The same posture governs quiz sessions once a quiz exists. A session isn't modeled as a JSON document that mutates over time - it is typed as a fixed set of states, created, in progress, submitted, graded, with each transition its own endpoint rather than one generic update that accepts whatever fields happen to be present. Submitting a session that is not in progress is rejected at the request boundary, not by an if-statement buried in a handler that someone eventually forgets to update when a new state gets added.

That matters more than it sounds like it should, because a session is exactly the kind of object that looks harmless to leave loosely typed - it's internal state, not user-facing content. But it is read by the same three clients as the quiz itself, and a session stuck in an impossible state is a support ticket, not a stack trace, which is a much slower way to find out something is wrong.

The same rule shows up in auth

StudySmart AI authenticates through both Google OAuth and OTP, because students arrive by either path depending on the client. Both routes end at the same JWT shape, issued the same way, carrying the same claims. The login method is an implementation detail of how a session started, not something any downstream endpoint needs to branch on. It is the same decision as the quiz contract, applied one layer down: multiple sources of unpredictable input, one typed shape that everything past the boundary is allowed to trust.

Where this generalizes

An AI-assisted feature is not an argument for a looser API - if anything it's the strongest argument for a stricter one, because a generative model is the one component in the system that never goes through review before it runs. Three clients pulling from one core made that concrete here: without a contract enforced once at the boundary, every client either trusts the model's output blindly or reimplements the same validation badly. The API paying a slower response in exchange for a guarantee every client can rely on is the trade that scales as the number of clients grows, not the one that scales as the model gets better.

StudySmart AI is a client engagement; the code referenced here describes the architecture rather than reproducing production source.

See the full project journey

Was this useful?

Corrections, counter-arguments and benchmark disagreements all welcome.

Protected by reCAPTCHA. Google's privacy policy and terms apply.