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>
176 lines
7.8 KiB
JavaScript
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;
|
|
}
|