/** * PBL v2 — Pre-PBL quiz snapshot helpers. * * The adaptive proficiency engine's Stage 2 (`source: 'pre-play'`) * recalibration consumes the learner's prior-quiz results so the * Instructor starts with a more accurate proficiency tier than the * Planner-time static signals alone could produce. * * Quiz results live in the learner-partitioned RuntimeStore. The snapshot is built * on the client (in `hero.tsx` right before the GREETING request * fires) and **piggybacked** on the existing `/api/pbl/v2/open-task` * POST body as `priorQuizResults?: PriorQuizResult[]`. This avoids * introducing a new endpoint just for recalibration. * * Two pure helpers live here: * - `buildQuizSnapshot(scenes)` — client-side, reads RuntimeStore * for each prior quiz scene and returns the aggregated * `PriorQuizResult[]`. * - `applyQuizSignalsToProject(project, results)` — server-side, * folds the snapshot into `project.proficiencyAssessment`. Used * by the `/api/pbl/v2/open-task` route handler. */ import { loadQuizAttemptState, type QuizAttemptState } from '@/lib/quiz/runtime'; import type { Scene } from '@/lib/types/stage'; import { createLogger } from '@/lib/logger'; import { applyQuizSnapshot, ensureAssessment } from '../kernel/proficiency'; import { appendProficiencyUpdatedRuntimeEvent } from '../kernel/runtime-events'; import type { PBLProjectV2, PriorQuizResult } from '../../types'; const log = createLogger('PBLQuizSnapshot'); /** Build a `PriorQuizResult[]` from the scenes preceding the PBL * scene. Reads the current learner's RuntimeStore partition. * * - Only scenes the learner has actually submitted contribute; * drafts/submissions without review and never-opened quizzes do * not move the signal. * - Short-answer questions without `hasAnswer` are counted as * `unscoredCount` rather than wrong — the engine excludes them * from the accuracy denominator. */ export async function buildQuizSnapshot(scenesBeforePbl: Scene[]): Promise { const out: PriorQuizResult[] = []; for (const scene of scenesBeforePbl) { if (scene.type !== 'quiz' || scene.content.type !== 'quiz' || !scene.stageId) continue; let state: QuizAttemptState | undefined; try { ({ state } = await loadQuizAttemptState({ stageId: scene.stageId, sceneId: scene.id })); } catch (error) { log.warn(`Failed to load quiz snapshot for scene ${scene.id}:`, error); continue; } if (state?.phase !== 'reviewed') continue; // `reviewed` means the learner has submitted AND seen the // graded results — that's the only case where we have a // trustworthy correctness signal. const questions = scene.content.questions ?? []; if (questions.length === 0) continue; let correct = 0; let incorrect = 0; let unscored = 0; for (const r of state.results ?? []) { // `correct === null` for short-answer / non-auto-gradable // questions: count as unscored, do not penalise. if (r.correct === null) { unscored++; } else if (r.correct) { correct++; } else { incorrect++; } } const scored = correct + incorrect; const accuracy = scored === 0 ? null : correct / scored; out.push({ sceneId: scene.id, sceneTitle: scene.title, totalQuestions: questions.length, correctCount: correct, incorrectCount: incorrect, unscoredCount: unscored, accuracy, }); } return out; } /** Server-side entry point: apply a freshly built quiz snapshot to * the project's proficiency assessment. Mutates the assessment in * place (and `project.proficiency` if the tier changes); returns * the assessment after the update for caller logging. No-op when * the snapshot is empty or no question was auto-graded. */ export function applyQuizSignalsToProject( project: PBLProjectV2, results: PriorQuizResult[], ): { updated: boolean; tierChanged: boolean } { if (!results || results.length === 0) { return { updated: false, tierChanged: false }; } const before = ensureAssessment(project); const next = applyQuizSnapshot(before, results); if (next === before) { // applyQuizSnapshot returns the same reference when no scored // questions were present. return { updated: false, tierChanged: false }; } project.proficiencyAssessment = next; const tierChanged = next.tier !== before.tier; if (tierChanged) { project.proficiency = next.tier; } appendProficiencyUpdatedRuntimeEvent(project); project.updatedAt = next.lastUpdatedAt; return { updated: true, tierChanged }; }