Part of the pre-revamp quiz repair batch under epic #537 (2026-08-12 quiz product audit). Workstream A — the only piece that ships standalone, today.
Verified defects
POST /api/quiz/generate rejects adaptive: routes/quiz.py:39 (VALID_DIFFICULTIES = {"easy","medium","hard"}) → 400 at routes/quiz.py:298-303. Generation is already adaptive server-side (agents/quiz.py:116-127 — difficulty mix shifts ±1 step from recent_attempts.accuracy), so the option is dead UI, not missing capability.num_questions capped ge=1, le=10 at models/__init__.py:49; the UI offers "15 questions" (frontend/src/components/QuizPanel.tsx:61) → guaranteed 422.- Errors reach the client as raw FastAPI/Pydantic JSON with
request_id (main.py:186-213) — no stable machine codes, exc.errors() shown to students.
Work
A1 — make adaptive a real difficulty. Accept it at the route; resolve to a concrete per-question mix inside the agent call (the agent contract already carries per-question difficulty — agents/quiz.py:58 — so this is expressible today). Response adds resolved_difficulty (overall) and keeps per-question difficulty. Constraint: quiz_attempts.difficulty CHECK allows only easy/medium/hard (db/migrations/0025_study_integrity.sql:99) — needs a small append-only migration to store the requested value.
A2 — one source of truth for option lists.GET /api/quiz/config returning allowed num_questions values, min/max, difficulties, question types (mcq only). Cap moves to a named constant read by both the Pydantic model and the endpoint. Decide the real cap deliberately with measured latency/token cost of 15- and 20-question generations on gemini-2.5-flash-lite. Note: the current 10-question cap is not arbitrary — Quiz.questions is max_length=10 because larger schemas tripped Gemini's constrained-decoding "too many states for serving" on flash-lite (agents/quiz.py:77-80); the cap decision must account for that.
A3 — stable error envelope. Every quiz-route 4xx/5xx returns { error: { code, message, detail?, request_id } }; codes in one enum module (QUIZ_DIFFICULTY_INVALID, QUIZ_COUNT_OUT_OF_RANGE, QUIZ_GENERATION_FAILED, QUIZ_ATTEMPT_ALREADY_COMPLETED, …). Keep the legacy detail key alongside so the current client keeps working.
Acceptance
- Client can never offer a value the route rejects (config-driven).
adaptive generates, stores, and echoes honestly what was chosen.- Subcutaneous HTTP→real-DB tests for each difficulty incl. adaptive + boundary counts (Chapter 1 lanes, no MagicMock on DB assertions).
Part of the pre-revamp quiz repair batch under epic #537 (2026-08-12 quiz product audit). Workstream A — the only piece that ships standalone, today.
Verified defects
POST /api/quiz/generaterejectsadaptive:routes/quiz.py:39(VALID_DIFFICULTIES = {"easy","medium","hard"}) → 400 atroutes/quiz.py:298-303. Generation is already adaptive server-side (agents/quiz.py:116-127— difficulty mix shifts ±1 step fromrecent_attempts.accuracy), so the option is dead UI, not missing capability.num_questionscappedge=1, le=10atmodels/__init__.py:49; the UI offers "15 questions" (frontend/src/components/QuizPanel.tsx:61) → guaranteed 422.request_id(main.py:186-213) — no stable machine codes,exc.errors()shown to students.Work
A1 — make
adaptivea real difficulty. Accept it at the route; resolve to a concrete per-question mix inside the agent call (the agent contract already carries per-questiondifficulty—agents/quiz.py:58— so this is expressible today). Response addsresolved_difficulty(overall) and keeps per-questiondifficulty. Constraint:quiz_attempts.difficultyCHECK allows only easy/medium/hard (db/migrations/0025_study_integrity.sql:99) — needs a small append-only migration to store the requested value.A2 — one source of truth for option lists.
GET /api/quiz/configreturning allowednum_questionsvalues, min/max, difficulties, question types (mcqonly). Cap moves to a named constant read by both the Pydantic model and the endpoint. Decide the real cap deliberately with measured latency/token cost of 15- and 20-question generations ongemini-2.5-flash-lite. Note: the current 10-question cap is not arbitrary —Quiz.questionsismax_length=10because larger schemas tripped Gemini's constrained-decoding "too many states for serving" on flash-lite (agents/quiz.py:77-80); the cap decision must account for that.A3 — stable error envelope. Every quiz-route 4xx/5xx returns
{ error: { code, message, detail?, request_id } }; codes in one enum module (QUIZ_DIFFICULTY_INVALID,QUIZ_COUNT_OUT_OF_RANGE,QUIZ_GENERATION_FAILED,QUIZ_ATTEMPT_ALREADY_COMPLETED, …). Keep the legacydetailkey alongside so the current client keeps working.Acceptance
adaptivegenerates, stores, and echoes honestly what was chosen.