feat: quiz bff and grading

This commit is contained in:
electron-rare
2026-06-11 00:12:08 +02:00
parent 2672cfce9b
commit 1fe2b18e97
4 changed files with 338 additions and 1 deletions
+10
View File
@@ -325,3 +325,13 @@ regeneration (must print 0):
```bash
grep -c '"correct"\|"feedback"' fixtures/quizbank-3.json
```
### Grist table `QuizAttempts` (doc `bPfitC7Poc4r2xGRx7FgHL`)
Created one-shot via the Grist API (`/apply` AddTable) on 2026-06-10 — the
app never creates tables. Columns: `user_sub` Text, `email` Text,
`course_slug` Text, `quiz_n` Int, `score` Int, `total` Int, `reponses`
Text (JSON `{questionId: [choiceIds]}`), `date` DateTime (epoch seconds),
`moodle_synced` Bool. One row per submitted attempt (retakes unlimited,
best wins); same Bool-filter caveat as Progression — filter `quiz_n` /
`moodle_synced` in app code, only Text equality filters via the API.
+1 -1
View File
@@ -10,7 +10,7 @@ export interface ReadMark {
id: number;
}
async function grist<T = unknown>(path: string, init?: RequestInit): Promise<T> {
export async function grist<T = unknown>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE()}/api/docs/${DOC()}${path}`, {
...init,
headers: { Authorization: `Bearer ${KEY()}`, 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
+164
View File
@@ -0,0 +1,164 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import {
mapQuizRecords, mapAttemptRecords, toPublic, gradeQuiz, isPassed, bestByQuiz,
type Quiz, type QuizQuestion, type QuizChoice, type QuizAttempt,
} from './quiz';
// --- helpers -------------------------------------------------------------
const choice = (id: number, correct = false, feedback = ''): QuizChoice =>
({ id, text: `<p>choice ${id}</p>`, correct, feedback });
const question = (id: number, choices: QuizChoice[], single = 1): QuizQuestion =>
({ id, text: `<p>Q${id} ?</p>`, single, choices });
const quiz = (questions: QuizQuestion[], gradepass = 60): Quiz =>
({ quizN: 1, cmid: 51, name: 'Quiz 1 : Découverte', gradepass, questions });
const fixture = JSON.parse(
readFileSync(new URL('../../fixtures/quizbank-3.json', import.meta.url), 'utf8'),
) as { courses: Record<string, { quizzes: { quiz_n: number; cmid: number; name: string; gradepass: number; questions: QuizQuestion[] }[] }> };
// --- mapQuizRecords ------------------------------------------------------
describe('mapQuizRecords', () => {
it('parses real QuizBank rows (sanitized fixture) and sorts by quiz_n', () => {
// Reverse order on purpose: Grist row order is not guaranteed.
const records = [...fixture.courses['3'].quizzes].reverse().map((q, i) => ({
id: i + 1,
fields: {
course_slug: 'kicad-makers', quiz_n: q.quiz_n, cmid: q.cmid,
name: q.name, gradepass: q.gradepass,
payload: JSON.stringify({ questions: q.questions }),
},
}));
const quizzes = mapQuizRecords({ records });
expect(quizzes.map((q) => q.quizN)).toEqual([1, 2, 3, 4]);
expect(quizzes[0]).toMatchObject({ cmid: 51, name: 'Quiz 1 : Découverte', gradepass: 60 });
for (const q of quizzes) {
expect(q.questions).toHaveLength(5);
for (const qu of q.questions) expect(qu.choices).toHaveLength(4);
}
});
it('skips rows whose payload is not valid JSON', () => {
const records = [
{ id: 1, fields: { course_slug: 's', quiz_n: 2, cmid: 9, name: 'Quiz 2', gradepass: 60, payload: '{broken' } },
{ id: 2, fields: { course_slug: 's', quiz_n: 1, cmid: 8, name: 'Quiz 1', gradepass: 60, payload: '{"questions":[]}' } },
];
const quizzes = mapQuizRecords({ records });
expect(quizzes).toHaveLength(1);
expect(quizzes[0].quizN).toBe(1);
});
});
// --- toPublic ------------------------------------------------------------
describe('toPublic', () => {
it('strips every correct flag and feedback (stringify scan)', () => {
const full = quiz([
question(106, [choice(421, true, 'Bravo !'), choice(422, false, 'Non : relisez le chapitre 2.')]),
question(107, [choice(425, false, 'Raté'), choice(426, true, 'Oui')], 0),
]);
const pub = toPublic(full);
const dump = JSON.stringify(pub);
expect(dump).not.toContain('correct');
expect(dump).not.toContain('feedback');
expect(dump).not.toContain('Bravo');
// structure preserved
expect(pub.questions).toHaveLength(2);
expect(pub.questions[0].choices.map((c) => c.id)).toEqual([421, 422]);
expect(pub.questions[0].text).toBe('<p>Q106 ?</p>');
// and the source quiz is untouched
expect(full.questions[0].choices[0].correct).toBe(true);
});
});
// --- gradeQuiz -----------------------------------------------------------
describe('gradeQuiz', () => {
const single = quiz([
question(1, [choice(11, true, 'Exact.'), choice(12, false, 'Faux : voir module 1.')]),
question(2, [choice(21, false, 'Non.'), choice(22, true, 'Oui.')]),
]);
it('scores a fully correct single-choice submission', () => {
const r = gradeQuiz(single, { '1': ['11'], '2': ['22'] });
expect(r.score).toBe(2);
expect(r.total).toBe(2);
expect(r.detail.every((d) => d.ok)).toBe(true);
expect(r.detail[0].correctIds).toEqual([11]);
});
it('scores 0 on a wrong choice and reports the chosen feedback', () => {
const r = gradeQuiz(single, { '1': ['12'], '2': ['22'] });
expect(r.score).toBe(1);
expect(r.detail[0]).toMatchObject({ questionId: 1, ok: false, chosenIds: [12], feedback: 'Faux : voir module 1.' });
});
it('is all-or-nothing on multi-answer questions', () => {
const multi = quiz([question(3, [choice(31, true), choice(32, true), choice(33, false)], 0)]);
expect(gradeQuiz(multi, { '3': ['31', '32'] }).score).toBe(1); // exact set
expect(gradeQuiz(multi, { '3': ['31'] }).score).toBe(0); // partial
expect(gradeQuiz(multi, { '3': ['31', '32', '33'] }).score).toBe(0); // superset
});
it('treats empty or missing answers as 0 points, never throws', () => {
const r = gradeQuiz(single, {});
expect(r.score).toBe(0);
expect(r.total).toBe(2);
expect(r.detail.map((d) => d.ok)).toEqual([false, false]);
expect(gradeQuiz(single, { '1': [] }).score).toBe(0);
});
});
// --- isPassed ------------------------------------------------------------
describe('isPassed', () => {
const five = quiz([1, 2, 3, 4, 5].map((i) => question(i, [choice(i * 10, true), choice(i * 10 + 1)])), 60);
it('passes at the 60 % boundary (3/5)', () => {
expect(isPassed(five, 3)).toBe(true);
});
it('fails below 60 % (2/5)', () => {
expect(isPassed(five, 2)).toBe(false);
});
it('defaults to 50 % when gradepass is 0', () => {
const noGate = quiz(five.questions, 0);
expect(isPassed(noGate, 3)).toBe(true); // 60 % ≥ 50 %
expect(isPassed(noGate, 2)).toBe(false); // 40 % < 50 %
});
it('honours an explicit attempt total over the question count', () => {
expect(isPassed(five, 3, 5)).toBe(true);
expect(isPassed(five, 3, 6)).toBe(false); // 50 % < 60 %
});
});
// --- attempts ------------------------------------------------------------
describe('mapAttemptRecords / bestByQuiz', () => {
it('maps raw Grist attempt rows with safe coercions', () => {
const raw = {
records: [
{ id: 4, fields: { user_sub: 'kc-1', email: 'a@b.c', course_slug: 'kicad-makers', quiz_n: 1, score: 4, total: 5, reponses: '{"106":["421"]}', date: 1760000000, moodle_synced: 0 } },
{ id: 5, fields: { quiz_n: '2', score: null, total: undefined, moodle_synced: true } },
],
};
expect(mapAttemptRecords(raw)).toEqual([
{ id: 4, quiz_n: 1, score: 4, total: 5, reponses: '{"106":["421"]}', date: 1760000000, moodle_synced: false },
{ id: 5, quiz_n: 2, score: 0, total: 0, reponses: '', date: 0, moodle_synced: true },
]);
});
it('keeps the best attempt per quiz, newest on tie', () => {
const att = (id: number, quizN: number, score: number, date: number): QuizAttempt =>
({ id, quiz_n: quizN, score, total: 5, reponses: '', date, moodle_synced: false });
const best = bestByQuiz([att(1, 1, 2, 100), att(2, 1, 4, 200), att(3, 2, 3, 300), att(4, 2, 3, 400)]);
expect(best.get(1)?.id).toBe(2); // higher score wins
expect(best.get(2)?.id).toBe(4); // tie → newest
});
});
+163
View File
@@ -0,0 +1,163 @@
import { swrCache } from './cache';
import { grist } from './grist';
export interface QuizChoice { id: number; text: string; correct?: boolean; feedback?: string; }
export interface QuizQuestion { id: number; text: string; single: number; choices: QuizChoice[]; }
export interface Quiz { quizN: number; cmid: number; name: string; gradepass: number; questions: QuizQuestion[]; }
export interface PublicChoice { id: number; text: string; }
export interface PublicQuestion { id: number; text: string; single: number; choices: PublicChoice[]; }
export interface PublicQuiz { quizN: number; cmid: number; name: string; gradepass: number; questions: PublicQuestion[]; }
type RawRecords = { records: { id: number; fields: Record<string, unknown> }[] };
const TTL = Number(process.env.GRIST_CACHE_TTL_MS || 5 * 60_000);
/** Map Grist QuizBank rows → Quiz[] sorted by quiz_n. Rows whose payload
* does not parse are skipped (logged) rather than poisoning the course. */
export function mapQuizRecords(raw: RawRecords): Quiz[] {
const out: Quiz[] = [];
for (const r of raw.records) {
try {
const payload = JSON.parse(String(r.fields.payload ?? '')) as { questions?: QuizQuestion[] };
out.push({
quizN: Number(r.fields.quiz_n ?? 0),
cmid: Number(r.fields.cmid ?? 0),
name: String(r.fields.name ?? ''),
gradepass: Number(r.fields.gradepass ?? 0),
questions: Array.isArray(payload.questions) ? payload.questions : [],
});
} catch (err) {
console.error(`[quiz] bad QuizBank payload (row ${r.id}): ${(err as Error).message}`);
}
}
return out.sort((a, b) => a.quizN - b.quizN);
}
const fetchQuizzes = async (slug: string): Promise<Quiz[]> => {
const filter = encodeURIComponent(JSON.stringify({ course_slug: [slug] }));
return mapQuizRecords(await grist<RawRecords>(`/tables/QuizBank/records?filter=${filter}`));
};
const cachedQuizzes = swrCache(fetchQuizzes, TTL);
/** Full quizzes of a course (WITH correct flags + feedback — server-side only). */
export const getQuizzes = (slug: string): Promise<Quiz[]> => cachedQuizzes(slug);
/** Deep-strip correct flags and feedback before anything reaches the client. */
export function toPublic(quiz: Quiz): PublicQuiz {
return {
quizN: quiz.quizN, cmid: quiz.cmid, name: quiz.name, gradepass: quiz.gradepass,
questions: quiz.questions.map((q) => ({
id: q.id, text: q.text, single: q.single,
choices: q.choices.map((c) => ({ id: c.id, text: c.text })),
})),
};
}
export interface QuestionResult {
questionId: number;
ok: boolean;
chosenIds: number[];
correctIds: number[];
/** Feedback of the chosen choice(s), '' when none. */
feedback: string;
}
/** All-or-nothing per question: chosen id set must equal the correct id set.
* `answers` keys are question ids (string), values are choice ids (string). */
export function gradeQuiz(quiz: Quiz, answers: Record<string, string[]>): { score: number; total: number; detail: QuestionResult[] } {
const detail = quiz.questions.map((q) => {
const chosenIds = (answers[String(q.id)] ?? []).map(Number).filter(Number.isInteger);
const correctIds = q.choices.filter((c) => c.correct).map((c) => c.id);
const chosen = new Set(chosenIds);
const ok = correctIds.length > 0 && chosen.size === correctIds.length && correctIds.every((id) => chosen.has(id));
const feedback = q.choices
.filter((c) => chosen.has(c.id) && c.feedback)
.map((c) => c.feedback as string)
.join(' ');
return { questionId: q.id, ok, chosenIds, correctIds, feedback };
});
return { score: detail.filter((d) => d.ok).length, total: quiz.questions.length, detail };
}
/** gradepass is a percentage (Moodle grade_items); 0/unset → 50 % default. */
export function isPassed(quiz: Pick<Quiz, 'gradepass' | 'questions'>, score: number, total = quiz.questions.length): boolean {
if (total <= 0) return false;
const pct = (score / total) * 100;
return quiz.gradepass > 0 ? pct >= quiz.gradepass : pct >= 50;
}
// --- QuizAttempts (Grist) --------------------------------------------------
export interface QuizAttempt {
id: number;
quiz_n: number;
score: number;
total: number;
reponses: string; // JSON Record<questionId, choiceIds[]>
date: number; // epoch seconds
moodle_synced: boolean;
}
export function mapAttemptRecords(raw: RawRecords): QuizAttempt[] {
return raw.records.map((r) => ({
id: r.id,
quiz_n: Number(r.fields.quiz_n ?? 0),
score: Number(r.fields.score ?? 0),
total: Number(r.fields.total ?? 0),
reponses: String(r.fields.reponses ?? ''),
date: Number(r.fields.date ?? 0),
moodle_synced: Boolean(r.fields.moodle_synced),
}));
}
/** All attempts of a user on a course. Only Text equality filters are
* reliable in the Grist API (see ops/README.md) — quiz_n and moodle_synced
* are filtered in app code. */
export async function getAttempts(sub: string, courseSlug: string): Promise<QuizAttempt[]> {
const filter = encodeURIComponent(JSON.stringify({ user_sub: [sub], course_slug: [courseSlug] }));
return mapAttemptRecords(await grist<RawRecords>(`/tables/QuizAttempts/records?filter=${filter}`));
}
export async function addAttempt(
user: { sub: string; email: string },
courseSlug: string,
quizN: number,
score: number,
total: number,
answers: Record<string, string[]>,
): Promise<void> {
await grist('/tables/QuizAttempts/records', {
method: 'POST',
body: JSON.stringify({
records: [{
fields: {
user_sub: user.sub, email: user.email, course_slug: courseSlug,
quiz_n: quizN, score, total,
reponses: JSON.stringify(answers),
date: Math.floor(Date.now() / 1000), // Grist DateTime accepts epoch seconds
moodle_synced: false,
},
}],
}),
});
}
/** Best attempt per quiz_n (highest score, newest on tie). */
export function bestByQuiz(attempts: QuizAttempt[]): Map<number, QuizAttempt> {
const best = new Map<number, QuizAttempt>();
for (const a of attempts) {
const cur = best.get(a.quiz_n);
if (!cur || a.score > cur.score || (a.score === cur.score && (a.date > cur.date || (a.date === cur.date && a.id > cur.id))))
best.set(a.quiz_n, a);
}
return best;
}
export async function markAttemptsSynced(ids: number[]): Promise<void> {
if (!ids.length) return;
await grist('/tables/QuizAttempts/records', {
method: 'PATCH',
body: JSON.stringify({ records: ids.map((id) => ({ id, fields: { moodle_synced: true } })) }),
});
}