bizzle 5a51a0f112 Mr. Drew's Assignment Creator — Docker share build
Self-contained Dockerized build for end users. Run via docker compose;
see README.md for setup. Source-only, no sample data or build artifacts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:58:36 -04:00

176 lines
7.8 KiB
JavaScript

// lib/schema.js — shared constants + normalization that repairs whatever the model returns
// into a guaranteed-valid assignment structure. Runs on both server and client.
export const ASSIGNMENT_TYPES = [
{ id: "quiz", label: "Quiz", hint: "Short check for understanding" },
{ id: "test", label: "Test", hint: "Full exam with mixed sections" },
{ id: "worksheet", label: "Worksheet", hint: "Guided practice to work through" },
{ id: "discussion", label: "Discussion questions", hint: "Open prompts with facilitation notes" },
{ id: "case_study", label: "Case study", hint: "A scenario plus analysis questions" },
];
export const QUESTION_TYPES = [
{ id: "multiple_choice", label: "Multiple choice" },
{ id: "true_false", label: "True / False" },
{ id: "short_answer", label: "Short answer" },
{ id: "essay", label: "Essay" },
{ id: "fill_blank", label: "Fill in the blank" },
{ id: "matching", label: "Matching" },
];
export const GRADE_LEVELS = [
"Kindergarten", "Grade 1", "Grade 2", "Grade 3", "Grade 4", "Grade 5",
"Grade 6", "Grade 7", "Grade 8", "Grade 9", "Grade 10", "Grade 11", "Grade 12",
"College — introductory", "College — advanced", "Adult education",
];
export const DIFFICULTIES = ["Easy", "Medium", "Hard", "Mixed"];
export const DEFAULT_POINTS = {
multiple_choice: 2,
true_false: 1,
short_answer: 3,
essay: 10,
fill_blank: 2,
matching: 4,
discussion: 5,
};
export function questionTypeLabel(type) {
if (type === "discussion") return "Discussion prompt";
return QUESTION_TYPES.find((t) => t.id === type)?.label || type;
}
export function newId() {
return "q_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
function str(v, fallback = "") {
if (v == null) return fallback;
return String(v).trim() || fallback;
}
function num(v, fallback) {
const n = Number(v);
return Number.isFinite(n) && n >= 0 ? n : fallback;
}
function arr(v) {
return Array.isArray(v) ? v : [];
}
// Normalize one question object from the model (or the editor) into a valid shape.
// Returns null if the question is hopeless and should be dropped.
export function normalizeQuestion(raw) {
if (!raw || typeof raw !== "object") return null;
let type = str(raw.type).toLowerCase().replace(/[\s-]+/g, "_");
const aliases = {
multiplechoice: "multiple_choice", mcq: "multiple_choice", multiple_choice_question: "multiple_choice",
truefalse: "true_false", tf: "true_false",
shortanswer: "short_answer", short_response: "short_answer",
fillblank: "fill_blank", fill_in_the_blank: "fill_blank", fitb: "fill_blank", cloze: "fill_blank",
match: "matching", open_ended: "discussion", discussion_prompt: "discussion",
};
type = aliases[type] || type;
const known = ["multiple_choice", "true_false", "short_answer", "essay", "fill_blank", "matching", "discussion"];
if (!known.includes(type)) return null;
const q = {
id: str(raw.id) || newId(),
type,
question: str(raw.question || raw.prompt || raw.text),
points: num(raw.points, DEFAULT_POINTS[type]),
explanation: str(raw.explanation),
sourceRef: str(raw.sourceRef || raw.source_ref || raw.source),
verification: raw.verification && typeof raw.verification === "object"
? { status: raw.verification.status === "warn" ? "warn" : raw.verification.status === "pass" ? "pass" : "unchecked", note: str(raw.verification.note) }
: { status: "unchecked", note: "" },
};
if (!q.question) return null;
if (type === "multiple_choice") {
let options = arr(raw.options || raw.choices).map((o) => str(typeof o === "object" ? o?.text : o)).filter(Boolean);
if (options.length < 2) return null;
options = options.slice(0, 6);
let ci = raw.correctIndex ?? raw.correct_index;
if (ci == null && raw.correctAnswer != null) {
// Model may give the answer as a letter ("B") or the option text.
const ca = str(raw.correctAnswer);
const letter = ca.match(/^[A-F]$/i);
if (letter) ci = letter[0].toUpperCase().charCodeAt(0) - 65;
else {
const idx = options.findIndex((o) => o.toLowerCase() === ca.toLowerCase());
ci = idx >= 0 ? idx : 0;
}
}
ci = Math.min(Math.max(num(ci, 0), 0), options.length - 1);
q.options = options;
q.correctIndex = ci;
} else if (type === "true_false") {
let ans = raw.correctAnswer ?? raw.answer;
if (typeof ans === "string") ans = /^(t|true|yes)/i.test(ans.trim());
q.correctAnswer = Boolean(ans);
} else if (type === "short_answer") {
q.sampleAnswer = str(raw.sampleAnswer || raw.sample_answer || raw.answer || raw.correctAnswer);
q.keyPoints = arr(raw.keyPoints || raw.key_points).map((k) => str(k)).filter(Boolean);
} else if (type === "essay") {
q.sampleResponse = str(raw.sampleResponse || raw.sample_response || raw.sampleAnswer || raw.answer);
q.rubric = arr(raw.rubric).map((r) => {
if (typeof r === "string") return { criterion: str(r), points: 0, description: "" };
return { criterion: str(r?.criterion || r?.name), points: num(r?.points, 0), description: str(r?.description) };
}).filter((r) => r.criterion);
} else if (type === "fill_blank") {
// Ensure the question text actually contains blanks
let text = q.question.replace(/_{2,}/g, "______");
let answers = arr(raw.answers || raw.blanks).map((a) => str(typeof a === "object" ? a?.answer : a)).filter(Boolean);
if (!answers.length && raw.answer) answers = [str(raw.answer)];
if (!answers.length) return null;
const blanks = (text.match(/______/g) || []).length;
if (blanks === 0) return null;
q.question = text;
q.answers = answers.slice(0, blanks).concat(Array(Math.max(0, blanks - answers.length)).fill("")).map((a) => a || "(answer missing — edit me)");
} else if (type === "matching") {
const pairs = arr(raw.pairs || raw.items).map((p) => ({ left: str(p?.left || p?.term), right: str(p?.right || p?.definition || p?.match) }))
.filter((p) => p.left && p.right);
if (pairs.length < 2) return null;
q.pairs = pairs.slice(0, 10);
q.points = num(raw.points, q.pairs.length);
} else if (type === "discussion") {
q.talkingPoints = arr(raw.talkingPoints || raw.talking_points || raw.keyPoints).map((k) => str(k)).filter(Boolean);
q.followUps = arr(raw.followUps || raw.follow_ups).map((k) => str(k)).filter(Boolean);
q.sampleResponse = str(raw.sampleResponse || raw.sample_response);
}
return q;
}
export function normalizeAssignment(raw, config) {
const questions = arr(raw?.questions).map(normalizeQuestion).filter(Boolean);
return {
title: str(raw?.title, "Untitled assignment"),
instructions: str(raw?.instructions),
caseStudy: str(raw?.caseStudy || raw?.case_study || raw?.scenario),
assignmentType: config.assignmentType,
gradeLevel: config.gradeLevel,
subject: config.subject,
difficulty: config.difficulty,
questions,
};
}
export function totalPoints(questions) {
return (questions || []).reduce((s, q) => s + (Number(q.points) || 0), 0);
}
// Create a blank question of a given type for the "Add question" menu.
export function blankQuestion(type) {
const base = { id: newId(), type, question: "", points: DEFAULT_POINTS[type] || 2, explanation: "", sourceRef: "", verification: { status: "unchecked", note: "" } };
if (type === "multiple_choice") return { ...base, options: ["", "", "", ""], correctIndex: 0 };
if (type === "true_false") return { ...base, correctAnswer: true };
if (type === "short_answer") return { ...base, sampleAnswer: "", keyPoints: [] };
if (type === "essay") return { ...base, sampleResponse: "", rubric: [] };
if (type === "fill_blank") return { ...base, question: "______", answers: [""] };
if (type === "matching") return { ...base, pairs: [{ left: "", right: "" }, { left: "", right: "" }], points: 2 };
if (type === "discussion") return { ...base, talkingPoints: [], followUps: [], sampleResponse: "" };
return base;
}