// 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; }