feat: quiz bff and grading
This commit is contained in:
@@ -325,3 +325,13 @@ regeneration (must print 0):
|
|||||||
```bash
|
```bash
|
||||||
grep -c '"correct"\|"feedback"' fixtures/quizbank-3.json
|
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
@@ -10,7 +10,7 @@ export interface ReadMark {
|
|||||||
id: number;
|
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}`, {
|
const res = await fetch(`${BASE()}/api/docs/${DOC()}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
headers: { Authorization: `Bearer ${KEY()}`, 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
|
headers: { Authorization: `Bearer ${KEY()}`, 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
|
||||||
|
|||||||
@@ -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
@@ -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 } })) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user