English · Español
Lab 04 — Quizzes, exámenes y el bucle de revisión SM-2¶
🇪🇸 El portal aprende a preguntar. Quizzes definidos en YAML por fase, con tipos de respuesta acotados al dominio §A13 (forma verbal, MCQ, código corto, ensayo corto). Cada error genera una tarjeta de repaso; el algoritmo SM-2 decide cuándo vuelve a aparecer. Lo que se falla, no se olvida.
Objetivo¶
Implementar la pipeline de quiz/examen: un loader YAML para data/quizzes/phase-NN-name.yaml, un grader para los cuatro tipos de respuesta (mcq, short, code, conjugation-form), una capa de persistencia quiz_attempts, creación automática de review_card en cada respuesta incorrecta, y la página diaria "Today's reviews" guiada por actualizaciones de ease/interval de SM-2. Los exámenes son ensayos más largos calificados por rúbrica — la rúbrica la corre el modelo en LYNX_TEST_MODE=1 y el profesor en producción.
Por qué existe este lab¶
El bucle pedagógico del portal no es "haz un quiz, mira tu puntuación, olvídala". Es "haz un quiz, marca los fallos, sácalos otra vez mañana". Sin la superficie SM-2, el quiz es decorativo. Con ella, una respuesta incorrecta se vuelve un contrato que el sistema honrará en nombre del learner hasta que la acierte dos veces seguidas.
El alcance §A13 (20 verbos × 5 tiempos × 3 personas) es lo bastante pequeño para que todo el universo revisable esté acotado. SM-2 aquí va sobrado en capacidad pero acierta en forma — Borja lo usará durante la Fase 41 y lo reutilizará para cualquier fase posterior que adopte el mismo patrón.
Prerrequisitos¶
- Labs 00–03 hechos.
- El harness de evaluación de la Fase 20 existe y expone
grade_conjugation(prompt, given) -> GradeResult. - Los datos de verbos A13 existen en
data/verbs/desde la Fase 12. markdown-it-py+ sanitizer del lab 03.
Entregables¶
data/quizzes/phase-00-onboarding.yaml(ejemplo).src/miniportal/quizzes/__init__.py— loader, modelos.src/miniportal/quizzes/grader.py— dispatch poranswer_type.src/miniportal/quizzes/sm2.py— actualización ease/interval SM-2.- Migraciones:
quiz_attempts,review_cards,exam_attempts. src/miniportal/routes/quizzes.py—GET /quizzes,GET /quizzes/{phase}/{slug},POST /quizzes/{...}/submit.src/miniportal/routes/review.py—GET /review(tarjetas pendientes hoy),POST /review/{card_id}/grade(botón de feedback SM-2).src/miniportal/routes/exams.py— rutas de examen (calificadas por rúbrica).- Plantillas:
quiz_view.html.jinja,quiz_result.html.jinja,review_today.html.jinja,exam_view.html.jinja. tests/portal/test_quiz_grading.py.tests/portal/test_review_card_creation.py.tests/portal/test_sm2_update.py.
Paso 1 — Formato YAML del quiz¶
data/quizzes/phase-00-onboarding.yaml:
phase: 0
slug: onboarding
title_en: "Phase 0 onboarding check"
title_es: "Comprobación de incorporación a la fase 0"
items:
- id: q1
answer_type: mcq
prompt_en: "Which command syncs the locked dependencies?"
prompt_es: "¿Qué comando sincroniza las dependencias bloqueadas?"
choices: ["pip install -r requirements.txt", "uv sync --frozen", "uv add fastapi", "poetry lock"]
correct: 1
rubric: "uv is mandatory per CLAUDE.md §2."
tags: [tooling, uv]
- id: q2
answer_type: conjugation-form
prompt_en: "Past simple of 'eat', 3rd singular?"
prompt_es: "Pasado simple de 'eat', 3ª persona del singular."
correct: "ate"
rubric: "Irregular verb; same form for all persons."
tags: [a13, irregular, past-simple]
- id: q3
answer_type: short
prompt_en: "Name the four answer types supported by the grader."
correct: ["mcq", "short", "code", "conjugation-form"]
rubric: "Order independent; case insensitive."
tags: [meta]
Restricción: prompt_en es obligatorio; prompt_es es opcional pero muy recomendado (política bilingüe A2). Cada ítem debe llevar al menos un tag — la cola de revisión usa tags para equilibrar la carga diaria.
Paso 2 — Loader + modelos¶
# src/miniportal/quizzes/__init__.py
from dataclasses import dataclass
from pathlib import Path
import yaml
@dataclass(frozen=True)
class QuizItem:
id: str
answer_type: str # "mcq" | "short" | "code" | "conjugation-form"
prompt_en: str
prompt_es: str | None
correct: object # int (mcq) | str | list[str]
rubric: str
tags: tuple[str, ...]
@dataclass(frozen=True)
class Quiz:
phase: int
slug: str
title_en: str
title_es: str | None
items: tuple[QuizItem, ...]
def load_quiz(path: Path) -> Quiz:
raise NotImplementedError("Lab 04 step 2 — parse YAML, validate against schema, return frozen Quiz.")
La validación del schema ocurre en tiempo de carga (no en tiempo de scoring). Un YAML malformado debe fallar ruidosamente al arrancar la app, no en el primer intento de quiz.
Paso 3 — El grader¶
# src/miniportal/quizzes/grader.py
from dataclasses import dataclass
from miniportal.quizzes import QuizItem
@dataclass(frozen=True)
class GradeResult:
correct: bool
given: object
expected: object
feedback: str # short, learner-facing
def grade(item: QuizItem, given: object) -> GradeResult:
if item.answer_type == "mcq":
raise NotImplementedError("Lab 04 step 3a — given is an int index; compare to item.correct.")
if item.answer_type == "short":
raise NotImplementedError(
"Lab 04 step 3b — given is str; item.correct is list[str]; case-insensitive set membership."
)
if item.answer_type == "conjugation-form":
raise NotImplementedError(
"Lab 04 step 3c — delegate to Phase 20's grade_conjugation; case-insensitive, strip whitespace."
)
if item.answer_type == "code":
raise NotImplementedError(
"Lab 04 step 3d — execute given snippet in a sandboxed subprocess (Phase 31 sandbox); "
"compare stdout to item.correct. Time limit 2 s, memory limit 64 MB."
)
raise ValueError(f"unknown answer_type: {item.answer_type}")
La rama code es la única que toca un subproceso. Reutiliza el helper de sandbox de la Fase 31 — no ruedes un sandbox nuevo aquí.
Paso 4 — quiz_attempts y review_cards¶
# Migration sketch (Lab 04)
# quiz_attempts:
# id PK, student_id, phase, slug, started_at, submitted_at,
# score_correct INT, score_total INT, payload_json TEXT (item id -> given)
# review_cards:
# id PK, student_id, quiz_phase, quiz_slug, item_id,
# ease_factor REAL DEFAULT 2.5,
# interval_days INT DEFAULT 0,
# repetitions INT DEFAULT 0,
# due_on DATE,
# created_at, last_reviewed_at
# UNIQUE(student_id, quiz_phase, quiz_slug, item_id)
# exam_attempts:
# id PK, student_id, exam_id, body_md, rubric_grade JSON, graded_by ('model' | 'teacher'), graded_at
En cada ítem fallado en una entrega de quiz:
INSERT OR IGNOREenreview_cardscon valores por defecto.- Si ya existe, déjalo — SM-2 es dueño del calendario a partir de aquí.
Paso 5 — Algoritmo SM-2¶
# src/miniportal/quizzes/sm2.py
from dataclasses import dataclass
@dataclass(frozen=True)
class ReviewState:
ease_factor: float
interval_days: int
repetitions: int
def update(state: ReviewState, quality: int) -> ReviewState:
"""Standard SM-2 (Wozniak 1990).
quality ∈ {0..5} (0 = forgot completely, 5 = perfect recall).
if quality < 3:
repetitions = 0
interval_days = 1
else:
repetitions += 1
if repetitions == 1: interval_days = 1
elif repetitions == 2: interval_days = 6
else: interval_days = round(interval_days * ease_factor)
ease_factor = max(1.3, ease_factor + 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02))
Returns new ReviewState; pure function.
"""
raise NotImplementedError("Lab 04 step 5 — implement the formula above; clamp ease_factor at 1.3.")
Los botones de calificación del portal están mapeados: Again=0, Hard=2, Good=4, Easy=5. Las calidades 1 y 3 son deliberadamente inalcanzables para mantener la UI a cuatro botones.
Paso 6 — Rutas¶
# src/miniportal/routes/quizzes.py
@router.get("/{phase}/{slug}")
async def show_quiz(phase: int, slug: str, student = Depends(current_student)):
raise NotImplementedError("Lab 04 step 6 — load YAML, render quiz_view with prompts (locale-aware).")
@router.post("/{phase}/{slug}/submit")
async def submit_quiz(phase: int, slug: str, ...):
"""Side effects:
1. INSERT quiz_attempt row.
2. For each wrong item, INSERT OR IGNORE review_card.
3. Update progress.status to 'in_progress' if not done.
4. Render quiz_result.html with per-item feedback.
"""
raise NotImplementedError("Lab 04 step 6 — implement the four side effects in a single transaction.")
# src/miniportal/routes/review.py
@router.get("")
async def review_today(student = Depends(current_student)):
"""Cards with due_on <= today, ordered by oldest due first.
Cap at 30 cards/day (configurable) to avoid burnout."""
raise NotImplementedError("Lab 04 step 6 — query due cards, render review_today.html.")
@router.post("/{card_id}/grade")
async def grade_review(card_id: int, quality: int = Form(...), student = Depends(current_student)):
"""quality ∈ {0,2,4,5} from the UI buttons. Computes SM-2 update, persists, returns next card."""
raise NotImplementedError("Lab 04 step 6 — owner check, validate quality, sm2.update, persist, return JSON.")
Paso 7 — Exámenes¶
# src/miniportal/routes/exams.py
@router.post("/{exam_id}/submit")
async def submit_exam(exam_id: int, body_md: str = Form(...), student = Depends(current_student)):
"""Rubric grading:
- LYNX_TEST_MODE=1: invoke the model with the rubric as a prompt, store JSON grade.
- Production: graded_by='teacher', status='pending'; teacher fills the rubric in lab 05's view.
"""
raise NotImplementedError("Lab 04 step 7 — branch on test mode; persist exam_attempts row.")
El camino calificado por modelo se restringe a LYNX_TEST_MODE=1 para mantener la calificación de producción anclada en humano. La plantilla de prompt de rúbrica vive en data/exams/rubric-template.md.
Paso 8 — Tests¶
# tests/portal/test_quiz_grading.py
def test_mcq_correct(): raise NotImplementedError("MCQ index match.")
def test_mcq_wrong(): raise NotImplementedError("MCQ index mismatch.")
def test_short_case_insensitive(): raise NotImplementedError("'ate' == 'Ate'.")
def test_conjugation_form_delegates(): raise NotImplementedError("Mock Phase 20 grader; assert it's called.")
def test_code_sandbox_used(): raise NotImplementedError("Mock Phase 31 sandbox; assert the snippet is dispatched through it.")
# tests/portal/test_review_card_creation.py
def test_wrong_answer_creates_card(): raise NotImplementedError("Submit a quiz with one wrong item; assert one review_card row.")
def test_idempotent_resubmission(): raise NotImplementedError("Submit twice; only one review_card per (student, quiz, item).")
def test_correct_answer_no_card(): raise NotImplementedError("Submit all-correct; zero review_cards inserted.")
# tests/portal/test_sm2_update.py
def test_first_pass_interval_1():
raise NotImplementedError("State(2.5, 0, 0) + quality 4 -> (≈2.5, 1, 1).")
def test_second_pass_interval_6():
raise NotImplementedError("State(2.5, 1, 1) + quality 4 -> (≈2.5, 6, 2).")
def test_failure_resets_repetitions():
raise NotImplementedError("State(2.5, 6, 2) + quality 0 -> (≈ slightly lower, 1, 0).")
def test_ease_floor_1_3():
raise NotImplementedError("Repeated quality 0 inputs: ease_factor never goes below 1.3.")
Cómo es "done"¶
- Al menos un quiz YAML commiteado (
phase-00-onboarding.yaml). - El loader rechaza YAML malformado al arrancar la app.
- Los cuatro tipos de respuesta puntúan correctamente;
codecorre a través del sandbox de la Fase 31. - Las respuestas incorrectas crean tarjetas de revisión; las correctas no.
-
/reviewlista las tarjetas pendientes acotadas a 30/día. - La fórmula SM-2 coincide con la implementación de referencia; suelo del ease 1.3 impuesto.
- La ruta de examen persiste; la calificación por modelo está controlada por
LYNX_TEST_MODE=1. - Las plantillas renderizan prompts bilingües cuando
student.locale != 'en'y existeprompt_es. -
mypy --strictybanditlimpios.
Trampas comunes¶
- Rodar tu propio sandbox para
code. La Fase 31 ya hizo el trabajo. Reutilizarlo mantiene una única superficie de hardening. - Cargar YAML en tiempo de request. Un parse de 50 ms en cada hit de quiz. Cachea los quizzes parseados al arrancar la app; recarga solo en cambio de mtime de archivo (o nunca recargues en producción).
- Dejar que la sensibilidad a mayúsculas varíe por tipo de respuesta.
conjugation-formes case-insensitive; el stdout decodees case-sensitive. Documenta explícitamente en el docstring del grader. - Almacenar el
payload_jsoncon input crudo de usuario. Sanitiza, o guarda y nunca renderices. Los snippets decodeson particularmente tentadores de volcar a una página de debug. - Una UI SM-2 de 5 botones. Cuatro botones (0/2/⅘). Las calidades 1 y 3 añaden carga cognitiva sin diferencia conductual.
- Recrear una tarjeta de revisión en cada respuesta incorrecta. Usa
INSERT OR IGNORE. SM-2 es dueño del calendario una vez existe la tarjeta. - Examen calificado por modelo sin control en producción. Los costes se disparan; el profesor pierde supervisión.
LYNX_TEST_MODE=1es el control.
Siguiente: lab/05-admin-teacher-view.md — el dashboard de admin.