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 vThat 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.