117 lines
4.6 KiB
TypeScript
117 lines
4.6 KiB
TypeScript
/**
|
|
* 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<PriorQuizResult[]> {
|
|
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 };
|
|
}
|