Back to deblo
deblo

Interactive Quizzes With LaTeX: Testing Students Inside a Chat

Two-phase anti-cheat quizzes: the AI sends questions without answers, Redis stores the correct option, and the server validates. With full LaTeX math rendering.

Thales & Claude | March 25, 2026 15 min deblo
debloquizlatexkatexredisanti-cheateducation

By Thales & Claude -- CEO & AI CTO, ZeroSuite, Inc.

A Terminale student in Abidjan is reviewing limits and continuity. She asks Deblo: "Teste-moi sur les limites de fonctions." The AI generates a multiple-choice question:

Quelle est la valeur de $\lim_{x \to +\infty} \frac{3x^2 + 2x - 1}{x^2 - 4}$ ? > A. $3$ B. $+\infty$ C. $0$ D. $-3$

The question renders inline with properly typeset mathematical notation -- fraction bars, limit notation, infinity symbols. The student taps option A. The interface instantly shows green feedback: correct. The explanation appears: "On divise numerateur et denominateur par $x^2$, le terme dominant. Les termes en $\frac{1}{x}$ tendent vers 0, il reste $\frac{3}{1} = 3$."

This interaction looks simple. Behind it, four systems coordinate: the AI tool that generates quiz structure, the Redis store that holds the correct answer server-side, the validation endpoint that grades the response, and the rendering layer that turns LaTeX strings into typeset mathematics on both web and mobile.

---

The Anti-Cheat Architecture

The most important design decision in the quiz system is one of absence: the correct answer is never sent to the client.

This sounds obvious, but it is the opposite of how most educational chatbots work. The typical pattern is: the LLM generates a question and its answer, sends both to the frontend, and the frontend handles display and grading locally. This is fast, simple, and completely insecure. Any student who opens browser developer tools can read the correct answer from the SSE stream or the JavaScript state.

Deblo students are resourceful. Within the first week of testing, a Terminale student in our pilot group discovered that he could inspect the network tab in Chrome to read upcoming quiz answers. He did not tell us. His teacher noticed his perfect quiz scores and investigated.

We rebuilt the quiz system the next day.

The new architecture splits the quiz into two phases. In phase one, the AI generates the question and options, but the correct answer is stored server-side in Redis, never transmitted to the client. In phase two, the student submits their answer, the server retrieves the correct answer from Redis, compares, and returns the result.

The LLM tool definition enforces this split:

{
  "type": "function",
  "function": {
    "name": "interactive_quiz",
    "description": "Present an interactive multiple-choice question to the student. The correct answer is stored server-side and NEVER sent to the client. The student selects an option, and the server validates their answer. Use this to test comprehension during tutoring sessions.",
    "parameters": {
      "type": "object",
      "properties": {
        "question": {
          "type": "string",
          "description": "The question text. May include LaTeX math notation wrapped in $ or $$ delimiters."
        },
        "options": {
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "label": { "type": "string", "description": "Option label: A, B, C, or D" },
              "text": { "type": "string", "description": "Option text. May include LaTeX." }
            },
            "required": ["label", "text"]
          },
          "minItems": 2,
          "maxItems": 4
        },
        "correct_answer": {
          "type": "string",
          "description": "The label of the correct option (e.g. 'A'). This is stored server-side only."
        },
        "explanation": {
          "type": "string",
          "description": "Explanation revealed after the student answers. May include LaTeX."
        },
        "difficulty": {
          "type": "string",
          "enum": ["easy", "medium", "hard"],
          "description": "Difficulty level for tracking."
        },
        "topic": {
          "type": "string",
          "description": "The specific topic being tested (e.g. 'limites de fonctions')."
        }
      },
      "required": ["question", "options", "correct_answer", "explanation"]
    }
  }
}

When the tool executor processes this tool call, it intercepts the correct_answer and explanation fields before the result reaches the SSE stream. These fields are stored in Redis and replaced with a quiz_id reference in the client-facing payload.

---

Redis Ephemeral State

The correct answer lives in Redis with a one-hour TTL. This is deliberately ephemeral -- quiz state does not need to persist beyond the active session. If a student comes back two hours later, the quiz is expired and the AI generates a new one.

import redis.asyncio as redis
from uuid import uuid4
import json

redis_client = redis.from_url(settings.REDIS_URL)

async def store_quiz_state( conversation_id: str, quiz_data: dict, ) -> str: """Store quiz correct answer in Redis. Return quiz_id."""

quiz_id = uuid4().hex[:12] key = f"quiz:{conversation_id}:{quiz_id}"

state = { "correct_answer": quiz_data["correct_answer"], "explanation": quiz_data["explanation"], "options": quiz_data["options"], "question": quiz_data["question"], "difficulty": quiz_data.get("difficulty", "medium"), "topic": quiz_data.get("topic", ""), }

await redis_client.set( key, json.dumps(state), ex=3600, # 1-hour TTL )

return quiz_id ```

The key format -- quiz:{conversation_id}:{quiz_id} -- scopes the quiz to a specific conversation. A student cannot use a quiz ID from one conversation to look up answers in another. The conversation ID is validated on the answer submission endpoint: the requesting user must own the conversation.

Why Redis instead of PostgreSQL? Three reasons. First, quiz state is transient. Writing it to a durable database creates unnecessary I/O for data that expires in an hour. Second, Redis TTL handles cleanup automatically. We do not need a cron job to delete expired quiz records. Third, Redis reads are sub-millisecond, which matters for the answer validation endpoint -- the student expects instant feedback when they tap an option.

---

The Answer Validation Endpoint

When the student selects an option, the frontend sends a POST request to the quiz answer endpoint:

@router.post("/api/quiz-answer")
async def submit_quiz_answer(
    request: QuizAnswerRequest,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Validate a quiz answer against the stored correct answer.

Flow: 1. Retrieve quiz state from Redis. 2. Compare submitted answer to correct answer. 3. Record result in ExerciseResult table. 4. Return correctness + explanation. 5. Optionally award bonus credits. """ # Verify conversation ownership conversation = await get_conversation(request.conversation_id, current_user.id, db) if not conversation: raise HTTPException(status_code=404, detail="Conversation introuvable")

# Retrieve quiz state from Redis key = f"quiz:{request.conversation_id}:{request.quiz_id}" raw = await redis_client.get(key)

if not raw: raise HTTPException( status_code=410, detail="Ce quiz a expire. Demandez-en un nouveau." )

state = json.loads(raw) is_correct = request.answer.upper() == state["correct_answer"].upper()

# Record in ExerciseResult for learning analytics exercise_result = ExerciseResult( user_id=current_user.id, conversation_id=conversation.id, subject=conversation.subject or "general", class_id=current_user.class_id, correct=is_correct, difficulty=state.get("difficulty", "medium"), topic=state.get("topic", ""), question_type="mcq", ) db.add(exercise_result) await db.flush()

# Delete quiz state (one attempt only) await redis_client.delete(key)

return { "correct": is_correct, "correct_answer": state["correct_answer"], "explanation": state["explanation"], } ```

Several details matter here.

The quiz state is deleted after a single answer attempt. A student cannot submit multiple answers to brute-force the correct option. Once they tap A, B, C, or D, the quiz is consumed. If they want to try again, the AI generates a new question. This is both an anti-cheat measure and a pedagogical choice: in a real exam, you do not get to change your answer.

The HTTP 410 (Gone) status code for expired quizzes is semantically precise. The resource existed but is no longer available. The frontend handles this by displaying "Ce quiz a expire" and suggesting the student ask for a new one. This is better than a generic 404, which could be confused with a bug.

The ExerciseResult record is created regardless of correctness. Every quiz attempt is tracked: user, conversation, subject, class, difficulty, topic, and whether the answer was correct. This data feeds the learning analytics dashboard. A teacher can see that her CM2 class scores 85% on addition but only 52% on fractions. The parent dashboard shows a child's progress over time. The AI itself uses past ExerciseResult data to adapt difficulty -- if a student consistently gets "easy" questions right, the AI escalates to "medium."

---

LaTeX Rendering

Mathematics education without proper notation is like music education without sound. A fraction must look like a fraction. A summation must have proper sigma notation. An integral must have limits. Deblo serves students from CP (6 years old, basic arithmetic) to Terminale (18 years old, calculus and statistics). LaTeX rendering is not optional.

On the web, we use KaTeX -- the fastest LaTeX rendering library available. KaTeX parses LaTeX strings and produces HTML/CSS that renders identically across browsers. It is synchronous (no layout reflow), supports most LaTeX math notation, and weighs 200KB gzipped. The rendering happens at the component level using marked's inline parsing:

<script lang="ts">
  import { onMount } from 'svelte';
  import katex from 'katex';

let { content }: { content: string } = $props(); let rendered = $state('');

function renderLatex(text: string): string { // Replace display math: $$...$$ text = text.replace(/\$\$([\s\S]+?)\$\$/g, (_, tex) => { try { return katex.renderToString(tex.trim(), { displayMode: true, throwOnError: false, strict: false, }); } catch { return ${tex}; } });

// Replace inline math: $...$ text = text.replace(/\$([^\$]+?)\$/g, (_, tex) => { try { return katex.renderToString(tex.trim(), { displayMode: false, throwOnError: false, strict: false, }); } catch { return ${tex}; } });

return text; }

$effect(() => { rendered = renderLatex(content); });

{@html rendered}
```

The throwOnError: false and strict: false options are critical. LLMs occasionally produce slightly malformed LaTeX -- an unclosed brace, a misused command, an unsupported symbol. Without these options, a single malformed expression would crash the entire rendering pipeline and display nothing. With them, the malformed expression falls back to a block showing the raw LaTeX source. The student sees the intent even if the rendering fails. In practice, DeepSeek V3 produces valid LaTeX approximately 97% of the time, so the fallback triggers rarely.

On mobile (React Native), LaTeX rendering is more complex. There is no browser DOM to inject HTML into. We use a custom MathBlock component that renders LaTeX to SVG using react-native-svg. The SVG approach produces crisp output at any screen resolution and avoids the WebView overhead that plagues other React Native LaTeX solutions.

---

The Quiz Widget

The quiz UI is an interactive widget embedded in the chat stream. On web, QuizWidget.svelte renders the question and options as tappable cards:

<script lang="ts">
  import { createEventDispatcher } from 'svelte';
  import LatexContent from './LatexContent.svelte';

let { quizId, conversationId, question, options, }: { quizId: string; conversationId: string; question: string; options: Array<{ label: string; text: string }>; } = $props();

let selectedAnswer = $state(null); let result = $state<{ correct: boolean; explanation: string } | null>(null); let submitting = $state(false);

async function submitAnswer(label: string) { if (selectedAnswer || submitting) return; // One attempt only selectedAnswer = label; submitting = true;

try { const res = await fetch('/api/quiz-answer', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ quiz_id: quizId, conversation_id: conversationId, answer: label, }), }); result = await res.json(); } catch { result = { correct: false, explanation: 'Erreur de connexion. Reessayez.' }; } finally { submitting = false; } }

{#each options as option} {/each}

{#if result}

{result.correct ? 'Bonne reponse !' : 'Mauvaise reponse.'}

{/if}
```

The widget enforces the single-attempt rule on the frontend as well. Once selectedAnswer is set, all buttons are disabled. The selected option shows green (correct) or red (incorrect) background. Unselected options fade to 60% opacity. The explanation appears below with LaTeX rendering.

The visual feedback is immediate. The student taps, the border color changes, the result appears. No page navigation, no modal, no loading screen. The quiz lives inside the conversation flow, as natural as a text message.

---

True/False Quizzes

The true_false_quiz tool is a simpler variant for factual recall and conceptual understanding. Instead of 2-4 options, it presents a statement and asks whether it is true or false.

The anti-cheat architecture is identical: the correct answer is stored in Redis, the client only receives the statement, and the validation happens server-side. The UI is even simpler -- two buttons, "Vrai" and "Faux," styled as large tappable cards.

True/false quizzes are particularly effective for younger students (CP through CE2) who find multiple-choice cognitively demanding. A 7-year-old can evaluate "2 + 3 = 6, vrai ou faux ?" more easily than selecting from four options. The AI adapts the quiz format to the student's grade level: younger students see more true/false, older students see more MCQ.

---

Bonus Credits and Gamification

After a quiz, the AI can award bonus credits using the award_bonus_credits tool. The amount depends on difficulty and performance:

  • Easy question, correct: 1 credit
  • Medium question, correct: 2 credits
  • Hard question, correct: 5 credits
  • Any question, incorrect but attempted: 1 credit (effort reward)

The effort reward is deliberate. We want students to attempt quizzes even when unsure. A student who consistently avoids quizzes out of fear of failure learns nothing. A student who attempts and fails at least sees the explanation and earns a token reward for engagement.

The credit award is announced in the chat: "Tu as gagne 2 credits pour ta bonne reponse !" This creates a positive feedback loop. Credits are both a reward mechanism (immediate dopamine) and a practical resource (credits fund future AI conversations). The student is simultaneously rewarded and reinvesting in their own learning.

On mobile, correct answers trigger a CelebrationBubbles animation -- small colorful circles that float upward from the quiz widget. The animation is brief (800ms), subtle, and does not block interaction. It is a small touch that makes the learning experience feel responsive and alive, without descending into the garish gamification that plagues many edtech products.

---

Exercise Tracking and Analytics

Every quiz attempt populates the ExerciseResult table, which feeds three downstream systems.

First, the student's personal progress dashboard. A student can see their quiz history: total attempts, accuracy rate by subject, improvement over time. The data is broken down by topic (fractions, algebra, geometry) and difficulty level. A student who scores 90% on easy algebra but 40% on hard algebra can see exactly where to focus.

Second, the teacher/parent dashboard (for organization members). A teacher sees aggregate class performance: average score by topic, weakest areas, students who have not attempted quizzes this week. A parent sees their child's individual performance. Both dashboards surface actionable insights, not just raw numbers.

Third, the AI's own adaptation logic. When the AI generates a quiz, it queries recent ExerciseResult records for the student. If the student has answered five "easy" questions on fractions correctly in a row, the AI escalates to "medium." If the student fails three "medium" questions on a topic, the AI drops back to "easy" and provides additional explanation before quizzing again. This adaptive difficulty is implicit -- the AI's system prompt instructs it to consider past performance when generating quizzes -- rather than a hardcoded algorithm.

---

Edge Cases and Lessons

LaTeX in quiz options creates unique rendering challenges. An option like "$\frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$" (the quadratic formula) can overflow its container on a narrow mobile screen. We handle this with CSS overflow-x: auto on the option card and a max-width constraint on rendered KaTeX output. On mobile, long expressions are rendered in display mode (centered, larger) rather than inline mode.

LLM-generated LaTeX sometimes contains inconsistencies. The same variable might appear as x in one option and X in another. The AI might use \frac in the question but \div in an option. We addressed this by adding explicit instructions in the system prompt: "Use consistent LaTeX notation across all quiz options. Use \frac{}{} for fractions, never the \div operator in mathematical expressions." The consistency improved from roughly 80% to 96% after adding these instructions.

Quiz generation frequency is rate-limited per conversation. A student cannot spam "donne-moi un quiz" to farm bonus credits. The AI is instructed to space quizzes naturally within tutoring sessions -- typically one quiz per topic after explanation, not on demand. If a student explicitly requests more quizzes, the AI complies but reduces the credit reward to prevent farming.

The one-hour Redis TTL occasionally causes confusion. A student starts a quiz before lunch, returns after lunch, and finds it expired. The error message explains the situation, and the AI generates a fresh question. We considered extending the TTL but decided against it: a longer TTL increases the window for answer-sharing between students in the same class. One hour is a reasonable compromise.

---

This is Part 15 of a 20-part series on building Deblo.ai.

1. AI Tutoring for 250 Million African Students 2. 100 Sessions Later: The Architecture of an AI Education Platform 3. The Agentic Loop: 24 AI Tools in a Single Chat 4. System Prompts That Teach: Anti-Cheating, Socratic Method, and Grade-Level Adaptation 5. WhatsApp OTP and the African Authentication Problem 6. Credits, FCFA, and 6 African Payment Gateways 7. SSE Streaming: Real-Time AI Responses in SvelteKit 8. Voice Calls With AI: Ultravox, LiveKit, and WebRTC 9. Building a React Native K12 App in 7 Days 10. 101 AI Advisors: Professional Intelligence for Africa 11. Background Jobs: When AI Takes 30 Minutes to Think 12. From Abidjan to 250 Million: The Deblo.ai Story 13. Generating PDFs, Spreadsheets, and Slide Decks From a Chat Message 14. Organizations: Families, Schools, and Companies on One Platform 15. Interactive Quizzes With LaTeX: Testing Students Inside a Chat (you are here) 16. RAG Pipeline: Document Search With pgvector and Semantic Chunking 17. Six Languages, One Platform: i18n for Africa 18. Tasks, Goals, and Recurring Reminders 19. AI Memory and Context Compression 20. Observability: Tracking Every LLM Call in Production

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles