docs: sp3 spec + plan (native quizzes)

This commit is contained in:
electron-rare
2026-06-10 23:58:34 +02:00
parent 3b34941772
commit 63302ce49e
2 changed files with 119 additions and 0 deletions
@@ -0,0 +1,54 @@
# SP3 — Native Quizzes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Learners take the 4 module quizzes inside the app (login required): question bank exported from Moodle into Grist at provisioning, graded server-side, attempts stored in Grist, pass → Moodle quiz-activity completion override.
**Architecture/Stack:** unchanged (Astro 6 SSR, zero new deps, vitest). Repo `electron-server:/home/electron/lelectron-rare/formations-app`, branch `feat/sp3-quizzes` off main. Docker runner as SP1/SP2. Deploy Tower.
**Conventions:** subjects ≤50 chars; files via scp (quote bracketed paths); secrets only in `/home/clems/formations-app.env` (no new vars needed — reuses GRIST_* and MOODLE_SYNC_TOKEN).
---
### Task 1: Export quiz bank → Grist (+ real fixture)
**Files:** Create `ops/export-quiz-bank.php`, `fixtures/quizbank-3.json` (SANITIZED: correct flags and feedback REMOVED — repo is public), update `ops/README.md`.
- [ ] **1.1** `ops/export-quiz-bank.php` (CLI in moodle container). For each course id>1: find quiz course-modules in section order (`course_modules` + `modules.name='quiz'` join, order by section sequence — simpler: order by quiz name which is "Quiz N : …"); for each quiz: cmid, name, `gradepass` from `grade_items` (itemmodule='quiz', iteminstance=quiz.id), and its slot questions via quiz_slots → question_references → question_bank_entries → latest question_versions → question; for each question: id, name, questiontext (html), single (qtype multichoice field `single` from `qtype_multichoice_options`), answers from `question_answers` (id, answer html, fraction, feedback). Output ONE JSON object to stdout: `{courses: {"<courseid>": {quizzes: [{quiz_n, cmid, name, gradepass, questions: [{id, text, single, choices: [{id, text, correct, feedback}]}]}]}}}` where `correct = fraction > 0`. Use `$DB` API (get_records_sql) — pure read.
- [ ] **1.2** Run on Tower: `docker cp … && docker exec moodle php /tmp/export-quiz-bank.php > /tmp/quizbank.json`; sanity with python (4 quizzes × 5 questions × 4 choices for course 3; every question has exactly one correct=true when single=1).
- [ ] **1.3** Push to Grist: new table `QuizBank` (course_slug Text, quiz_n Int, cmid Int, name Text, gradepass Numeric, payload Text) — one row per quiz, `payload` = JSON string of `{questions:[…]}` (full, WITH correct/feedback). Slug mapping from src/config/courses.ts (id→slug). Script the push with python from Tower (GRIST_API_KEY/DOC from env file). Verify: 6 courses × 4 = 24 rows; spot-check one payload parses and has 5 questions.
- [ ] **1.4** Commit fixture SANITIZED ONLY (strip correct+feedback via python before writing `fixtures/quizbank-3.json`) + the php + README section (export rerun procedure, QuizBank schema, sanitization warning). `git add ops fixtures && git commit -m 'feat: quiz bank export to grist'`
### Task 2: Quiz BFF + grading (TDD)
**Files:** Create `src/lib/quiz.ts`, `src/lib/quiz.test.ts`; modify `src/lib/grist.ts` (generic records helpers if needed).
- [ ] **2.1** Types: `QuizChoice {id,text,correct?,feedback?}`, `QuizQuestion {id,text,single,choices}`, `Quiz {quizN,cmid,name,gradepass,questions}`, `PublicQuiz` (choices without correct/feedback). Functions:
- `getQuizzes(slug): Promise<Quiz[]>` — Grist QuizBank rows filtered by course_slug (existing grist() helper; filter param), payload JSON.parse, sorted by quiz_n; SWR-cached 5 min.
- `toPublic(quiz): PublicQuiz` — deep-strip correct+feedback.
- `gradeQuiz(quiz, answers: Record<string, string[]>): {score,total,detail[]}` — per question: chosen ids set === correct ids set → 1 point else 0; detail {questionId, ok, correctIds, feedback of chosen}.
- `isPassed(quiz, score): boolean` — gradepass>0 ? score/total*100 ≥ gradepass : score/total ≥ 0.5.
- Grist `QuizAttempts` helpers: `addAttempt(...)` (fields per spec, moodle_synced=false), `getAttempts(sub, slug)`, `bestBySQuiz(attempts)` map quiz_n→best, `markAttemptsSynced(ids)`.
- [ ] **2.2** Tests FIRST (red→green): gradeQuiz single correct/incorrect/multi all-or-nothing/empty; toPublic leaves no `correct` or `feedback` anywhere (JSON.stringify scan); isPassed gradepass set vs default. ≥8 new tests.
- [ ] **2.3** Runner green → commit `feat: quiz bff and grading`
### Task 3: Quiz pages + API
**Files:** Create `src/pages/cours/[slug]/quiz-[n].astro`, `src/pages/api/quiz.ts`; modify `Sommaire.astro`, hub page.
- [ ] **3.1** `/cours/<slug>/quiz-<n>`: course published + n in 1..4 else 404-rewrite; login REQUIRED → `Astro.redirect('/connexion?next='+path)` if anon. GET: render PublicQuiz form (radio per question when single, checkboxes else; names `q<questionId>`), submit POST to same page? — NO: POST `/api/quiz` (PRG). If query `?fait=1`, SSR shows the LAST attempt result instead (read newest attempt from Grist for this user+quiz): score banner (copper celebration if passed: « Quiz réussi · X/5 ✓ »), per-question correction list (need correct ids: load full quiz server-side), button « Rejouer » + link back to hub.
- [ ] **3.2** `POST /api/quiz` (form-urlencoded): session required (401 JSON / redirect); fields slug, quiz (int), q<id> values; load full quiz, gradeQuiz, addAttempt, if isPassed → fire-and-forget `syncQuizToMoodle(user, slug, quiz)` ; 303 → `/cours/<slug>/quiz-<n>?fait=1`. Grist write failure → 303 `?erreur=progression`.
- [ ] **3.3** `syncQuizToMoodle` in moodle-sync.ts: reuse ensureMoodleUser/ensureEnrolled; completion override on quiz cmid; markAttemptsSynced. Hub retry: include unsynced passed attempts in the existing fire-and-forget.
- [ ] **3.4** Sommaire: per module section append `<a href=…/quiz-N>` line « Quiz du module → » with copper ✓ when passed (new prop `quizPassed?: Set<number>`). Hub page: load attempts (try/catch), compute passed set, pass to Sommaire; under the reads progress line add `Quiz : {passed}/{total} réussis` when user.
- [ ] **3.5** Runner green → commit `feat: native quiz pages` (split commits ok).
### Task 4: Deploy + E2E
- [ ] **4.1** Merge ff-only → main, push, Tower pull + `docker compose up -d --build`.
- [ ] **4.2** E2E: quiz page anon → 302 /connexion; `POST /api/quiz` anon → 401; logged-out hub unchanged (200, data-hub); regression suite of SP1/SP2 curls (catalogue 200, chapter 200, /api/lu 401 anon).
- [ ] **4.3** Owner manual: take a quiz logged-in, see correction + celebration, Grist QuizAttempts row, Moodle completion on the quiz cmid after pass.
## Self-review
- Public repo: answers only in Grist; fixture sanitized; toPublic tested by stringify-scan.
- Reuses: grist client, session, moodle-sync ensure*/override, SWR cache. No new deps/env.
- gradepass semantics decided (≥ gradepass% else ≥50%); retakes unlimited, best wins.
@@ -0,0 +1,65 @@
# Design: SP3 — native quizzes (Grist question bank, in-app grading)
Date: 2026-06-10
Status: approved direction (program brainstorm); technical defaults below
chosen autonomously and flagged to the owner.
## Ground truth (probed on the live DB)
125 questions, ALL `multichoice`; per course: 4 quizzes ("Quiz N : …",
course-module activities) × 5 slots, quiz grade scale 100, sumgrades 5.
Answers (choices + correct fraction + feedback) live in
`mdl_question_answers`.
## Why not the Moodle quiz API per attempt
WS quiz functions execute as the token's user. Learners are SSO-only (no
Moodle password → no per-user token via login/token.php). Running attempts
as `wssync` would corrupt attempt ownership. And the app repo is PUBLIC,
so question banks with answers cannot be committed to it.
## Architecture
1. **Provisioning export**: `ops/export-quiz-bank.php` (CLI in the moodle
container) dumps per course: quizzes (id, cmid, name, module order,
gradepass) and their questions (text html, choices, correct flag(s),
feedback) as JSON. Pushed into the SP2 Grist doc, new table
`QuizBank` (course_slug, quiz_n, cmid, name, payload JSON string).
Grist is API-key-protected → answers never public. Re-export = rerun.
2. **BFF** `src/lib/quiz.ts`: `getQuiz(slug, quizN)` reads QuizBank via
the existing Grist client (SWR-cached); strips `correct`/`feedback`
before anything reaches the browser. `gradeQuiz(quiz, answers)` is a
pure function (server-side only): 1 point per fully-correct question
(radio, single answer — bank is single-correct; multi-correct payloads
are graded all-or-nothing on the checkbox set).
3. **Pages**: `/cours/<slug>/quiz-<n>` (login REQUIRED — redirect to
/connexion?next=…): one form, 5 questions (radio groups), submit →
POST `/api/quiz` → grade server-side → store attempt in Grist table
`QuizAttempts` (user_sub, email, course_slug, quiz_n, score, total,
reponses JSON, date, moodle_synced) → PRG to a result view: score,
per-question correction (your answer vs right answer + feedback),
celebration (copper glow header) when passed.
4. **Pass rule**: passed = score ≥ gradepass when set (>0), else ≥ 50 %.
Retakes allowed, unlimited; best score wins for pass state.
5. **Moodle sync**: on pass, completion override on the QUIZ cmid via the
existing `formations_sync` machinery (`syncQuizToMoodle` mirrors
moodle-sync.ts: ensure user+enrol, override cmid, mark synced); also
best-effort retried from the hub. Grade values stay in Grist (Moodle
gradebook injection out of scope — certificates key off completion).
6. **Surfacing**: hub page — under the title progress line add quiz
state `Quiz : 2/4 réussis`; each module section in the Sommaire gets a
final line « Quiz du module → » linking the quiz page, with ✓ when
passed. Atom unchanged in SP3 (no new geometry; YAGNI).
## Out of scope
Certificates UI (SP4), Moodle gradebook grades, question types beyond
multichoice, anti-cheat/time limits.
## Error handling
Grist down → quiz page 503-friendly; grading idempotent re-submits OK;
Moodle sync best-effort (flag + retry) as SP2.
## Testing
vitest: gradeQuiz (single, multi, partial→0, empty), payload stripping
(no `correct` in client payload), pass rule. E2E: quiz page anon → redirect
connexion; POST /api/quiz anon → 401.