From e906951b062e0977e2d2d70ac3e7f0c065080b96 Mon Sep 17 00:00:00 2001 From: LaurensBartelds <48316768+LaurensBartelds@users.noreply.github.com> Date: Sat, 2 May 2026 19:25:28 +0200 Subject: [PATCH] feat: add new question types and components for fill-in-the-blanks, true/false, code submission, dropdown completion, numeric range, dictation, grammar correction, and flashcards - Updated QuestionRenderer to support new question types. - Added FillInTheBlanks, TrueFalse, CodeSubmission, DropdownCompletion, NumericRange, Dictation, GrammarCorrection, and Flashcard components. - Enhanced grading logic to handle new question types. - Updated translations for new question types in English and Dutch. - Added CSS styles for new components and question types. - Extended Question and related types to accommodate new configurations and answers. --- backend/src/routes/ai.ts | 8 + backend/src/services/aiAssist.ts | 61 +++ backend/src/validators/examSchemas.test.ts | 9 +- backend/src/validators/examSchemas.ts | 43 +- frontend/public/favicon.svg | 5 +- frontend/src/components/ExamEditor.tsx | 408 ++++++++++++++++++ .../components/questions/CodeSubmission.tsx | 56 +++ .../src/components/questions/Dictation.tsx | 77 ++++ .../questions/DropdownCompletion.tsx | 75 ++++ .../components/questions/FillInTheBlanks.tsx | 76 ++++ .../src/components/questions/Flashcard.tsx | 72 ++++ .../questions/GrammarCorrection.tsx | 34 ++ .../src/components/questions/NumericRange.tsx | 51 +++ .../components/questions/QuestionRenderer.tsx | 24 ++ .../src/components/questions/TrueFalse.tsx | 39 ++ frontend/src/i18n/en.json | 19 +- frontend/src/i18n/nl.json | 19 +- frontend/src/index.css | 332 ++++++++++++++ frontend/src/types.ts | 45 +- frontend/src/utils/grading.ts | 75 +++- 20 files changed, 1518 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/questions/CodeSubmission.tsx create mode 100644 frontend/src/components/questions/Dictation.tsx create mode 100644 frontend/src/components/questions/DropdownCompletion.tsx create mode 100644 frontend/src/components/questions/FillInTheBlanks.tsx create mode 100644 frontend/src/components/questions/Flashcard.tsx create mode 100644 frontend/src/components/questions/GrammarCorrection.tsx create mode 100644 frontend/src/components/questions/NumericRange.tsx create mode 100644 frontend/src/components/questions/TrueFalse.tsx diff --git a/backend/src/routes/ai.ts b/backend/src/routes/ai.ts index efea805..ccdfa27 100644 --- a/backend/src/routes/ai.ts +++ b/backend/src/routes/ai.ts @@ -20,6 +20,14 @@ const generateSchema = z.object({ 'essay', 'match', 'order', + 'fill-in-the-blanks', + 'true-false', + 'code-submission', + 'dropdown-completion', + 'numeric-range', + 'dictation', + 'grammar-correction', + 'flashcard', ]), count: z.number().int().min(1).max(10), language: z.string().min(1).max(20), diff --git a/backend/src/services/aiAssist.ts b/backend/src/services/aiAssist.ts index e7dba6c..5aa119c 100644 --- a/backend/src/services/aiAssist.ts +++ b/backend/src/services/aiAssist.ts @@ -10,6 +10,14 @@ export type QuestionType = | 'essay' | 'match' | 'order' + | 'fill-in-the-blanks' + | 'true-false' + | 'code-submission' + | 'dropdown-completion' + | 'numeric-range' + | 'dictation' + | 'grammar-correction' + | 'flashcard' const choiceSchema = z.object({ id: z.string().min(1), @@ -31,6 +39,14 @@ const generatedQuestionSchema = z.object({ 'essay', 'match', 'order', + 'fill-in-the-blanks', + 'true-false', + 'code-submission', + 'dropdown-completion', + 'numeric-range', + 'dictation', + 'grammar-correction', + 'flashcard', ]), prompt: z.string().min(1), category: z.string().optional(), @@ -47,6 +63,35 @@ const generatedQuestionSchema = z.object({ left: z.array(matchItemSchema).optional(), right: z.array(matchItemSchema).optional(), items: z.array(matchItemSchema).optional(), + textSegments: z.array(z.string()).optional(), + blanks: z.array( + z.object({ + id: z.string().min(1), + acceptedAnswers: z.array(z.string()), + acceptedForms: z.array(z.string()).optional(), + caseSensitive: z.boolean().optional(), + ignorePunctuation: z.boolean().optional(), + ignoreAccents: z.boolean().optional(), + }), + ).optional(), + dropdowns: z.array( + z.object({ + id: z.string().min(1), + options: z.array(choiceSchema), + correctOptionId: z.string(), + }), + ).optional(), + codeConfig: z.object({ + language: z.string(), + starterCode: z.string().optional(), + }).optional(), + numericConfig: z.object({ + answer: z.number(), + tolerance: z.number(), + min: z.number().optional(), + max: z.number().optional(), + }).optional(), + textToCorrect: z.string().optional(), }) export type GeneratedQuestion = z.infer @@ -77,6 +122,22 @@ function shapeHintForType(type: QuestionType): string { return `Required fields: prompt (string), left (array of {id, text} 3-5 items), right (array of {id, text} 3-5 items), answer (object mapping left.id -> right.id).` case 'order': return `Required fields: prompt (string), items (array of {id, text} 3-6 items in any order), answer (array of item ids in the correct order).` + case 'fill-in-the-blanks': + return `Required fields: prompt (string), textSegments (array of text strings to interleave with blanks), blanks (array of {id, acceptedAnswers} where acceptedAnswers is an array of correct strings for that blank). The number of textSegments must be blanks.length + 1.` + case 'true-false': + return `Required fields: prompt (string), answer (boolean, true or false).` + case 'code-submission': + return `Required fields: prompt (string), codeConfig ({language: string, starterCode?: string}), rubric (array of 3-5 short rubric criteria strings).` + case 'dropdown-completion': + return `Required fields: prompt (string), textSegments (array of text strings to interleave with dropdowns), dropdowns (array of {id, options: [{id, text}], correctOptionId}). The number of textSegments must be dropdowns.length + 1.` + case 'numeric-range': + return `Required fields: prompt (string), numericConfig ({answer: number, tolerance: number, min?: number, max?: number}), answer (same number as numericConfig.answer).` + case 'dictation': + return `Required fields: prompt (string), audio ({src: string, a URL to an audio file}), answer (the correct text), acceptedForms (array of acceptable alternate strings), caseSensitive (boolean, usually false).` + case 'grammar-correction': + return `Required fields: prompt (string), textToCorrect (the sentence with errors to fix), answer (the corrected sentence), acceptedForms (array of acceptable corrected forms), caseSensitive (boolean, usually false).` + case 'flashcard': + return `Required fields: prompt (the front of the card, e.g. a word or phrase), answer (the back of the card, e.g. translation or definition), explanation (optional, extra info to show after reveal).` } } diff --git a/backend/src/validators/examSchemas.test.ts b/backend/src/validators/examSchemas.test.ts index 70f2edf..cca835a 100644 --- a/backend/src/validators/examSchemas.test.ts +++ b/backend/src/validators/examSchemas.test.ts @@ -62,6 +62,9 @@ describe('examSchema', () => { const types = [ 'multiple-choice', 'multiple-select', 'short-answer', 'translation', 'math-input', 'essay', 'match', 'order', + 'fill-in-the-blanks', 'true-false', 'code-submission', + 'dropdown-completion', 'numeric-range', + 'dictation', 'grammar-correction', 'flashcard', ] for (const type of types) { const result = examSchema.safeParse({ @@ -75,7 +78,7 @@ describe('examSchema', () => { it('rejects unsupported question type', () => { const result = examSchema.safeParse({ title: 'X', - questions: [{ id: '1', type: 'true-false', prompt: 'Q' }], + questions: [{ id: '1', type: 'file-upload', prompt: 'Q' }], }) expect(result.success).toBe(false) }) @@ -91,10 +94,10 @@ describe('examSchema', () => { } }) - it('rejects non-string/array/record answer', () => { + it('rejects non-string/array/record/number/boolean answer', () => { const result = examSchema.safeParse({ title: 'X', - questions: [{ id: '1', type: 'short-answer', prompt: 'Q', answer: 42 }], + questions: [{ id: '1', type: 'short-answer', prompt: 'Q', answer: null }], }) expect(result.success).toBe(false) }) diff --git a/backend/src/validators/examSchemas.ts b/backend/src/validators/examSchemas.ts index e3d2941..d7aae15 100644 --- a/backend/src/validators/examSchemas.ts +++ b/backend/src/validators/examSchemas.ts @@ -30,6 +30,33 @@ const questionTableSchema = z.object({ rows: z.array(z.array(z.string())), }) +const blankSchema = z.object({ + id: z.string(), + acceptedAnswers: z.array(z.string()), + acceptedForms: z.array(z.string()).optional(), + caseSensitive: z.boolean().optional(), + ignorePunctuation: z.boolean().optional(), + ignoreAccents: z.boolean().optional(), +}) + +const dropdownSchema = z.object({ + id: z.string(), + options: z.array(choiceSchema), + correctOptionId: z.string(), +}) + +const codeConfigSchema = z.object({ + language: z.string(), + starterCode: z.string().optional(), +}) + +const numericConfigSchema = z.object({ + answer: z.number(), + tolerance: z.number(), + min: z.number().optional(), + max: z.number().optional(), +}) + const questionSchema = z.object({ id: z.string(), type: z.enum([ @@ -41,6 +68,14 @@ const questionSchema = z.object({ 'essay', 'match', 'order', + 'fill-in-the-blanks', + 'true-false', + 'code-submission', + 'dropdown-completion', + 'numeric-range', + 'dictation', + 'grammar-correction', + 'flashcard', ]), category: z.string().optional(), points: z.number().optional(), @@ -51,7 +86,7 @@ const questionSchema = z.object({ code: questionCodeSchema.optional(), table: questionTableSchema.optional(), svg: z.string().optional(), - answer: z.union([z.string(), z.array(z.string()), z.record(z.string(), z.string())]).optional(), + answer: z.union([z.string(), z.array(z.string()), z.record(z.string(), z.string()), z.boolean(), z.number()]).optional(), acceptedForms: z.array(z.string()).optional(), caseSensitive: z.boolean().optional(), ignorePunctuation: z.boolean().optional(), @@ -64,6 +99,12 @@ const questionSchema = z.object({ left: z.array(matchItemSchema).optional(), right: z.array(matchItemSchema).optional(), items: z.array(matchItemSchema).optional(), + textSegments: z.array(z.string()).optional(), + blanks: z.array(blankSchema).optional(), + dropdowns: z.array(dropdownSchema).optional(), + codeConfig: codeConfigSchema.optional(), + numericConfig: numericConfigSchema.optional(), + textToCorrect: z.string().optional(), }) export const examSchema = z.object({ diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 6893eb1..a91e2f4 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1 +1,4 @@ - \ No newline at end of file + + + + diff --git a/frontend/src/components/ExamEditor.tsx b/frontend/src/components/ExamEditor.tsx index 1941793..3502d76 100644 --- a/frontend/src/components/ExamEditor.tsx +++ b/frontend/src/components/ExamEditor.tsx @@ -14,6 +14,14 @@ const QUESTION_TYPES: { value: QuestionType; labelKey: string }[] = [ { value: 'essay', labelKey: 'editor.essay' }, { value: 'match', labelKey: 'editor.match' }, { value: 'order', labelKey: 'editor.order' }, + { value: 'fill-in-the-blanks', labelKey: 'editor.fillInTheBlanks' }, + { value: 'true-false', labelKey: 'editor.trueFalse' }, + { value: 'code-submission', labelKey: 'editor.codeSubmission' }, + { value: 'dropdown-completion', labelKey: 'editor.dropdownCompletion' }, + { value: 'numeric-range', labelKey: 'editor.numericRange' }, + { value: 'dictation', labelKey: 'editor.dictation' }, + { value: 'grammar-correction', labelKey: 'editor.grammarCorrection' }, + { value: 'flashcard', labelKey: 'editor.flashcard' }, ] const BLANK_EXAM: Exam = { @@ -40,6 +48,22 @@ function makeBlankQuestion(type: QuestionType, index: number): Question { return { ...base, left: [], right: [], answer: {} } case 'order': return { ...base, items: [], answer: [] } + case 'true-false': + return { ...base, answer: true } + case 'numeric-range': + return { ...base, numericConfig: { answer: 0, tolerance: 0 } } + case 'fill-in-the-blanks': + return { ...base, textSegments: ['The capital of France is ', '.'], blanks: [{ id: 'b1', acceptedAnswers: ['Paris'] }] } + case 'dropdown-completion': + return { ...base, textSegments: ['The sky is ', ' during the day.'], dropdowns: [{ id: 'd1', options: [{ id: 'a', text: 'blue' }, { id: 'b', text: 'green' }], correctOptionId: 'a' }] } + case 'code-submission': + return { ...base, codeConfig: { language: 'python', starterCode: '' }, rubric: [] } + case 'dictation': + return { ...base, audio: { src: '' }, answer: '', acceptedForms: [], caseSensitive: false } + case 'grammar-correction': + return { ...base, textToCorrect: '', answer: '', acceptedForms: [], caseSensitive: false } + case 'flashcard': + return { ...base, answer: '' } default: return base } @@ -529,6 +553,14 @@ function QuestionEditor({ question, onChange }: QEProps) { {question.type === 'essay' && } {question.type === 'match' && } {question.type === 'order' && } + {question.type === 'true-false' && } + {question.type === 'numeric-range' && } + {question.type === 'fill-in-the-blanks' && } + {question.type === 'dropdown-completion' && } + {question.type === 'code-submission' && } + {question.type === 'dictation' && } + {question.type === 'grammar-correction' && } + {question.type === 'flashcard' && } ) } @@ -895,6 +927,323 @@ function MatchEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange ) } +/* ─── True/False ─── */ + +function TrueFalseEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) { + const { t } = useTranslation() + return ( +
+ +
+ + +
+
+ ) +} + +/* ─── Numeric Range ─── */ + +function NumericRangeEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) { + const { t } = useTranslation() + const cfg = q.numericConfig ?? { answer: 0, tolerance: 0 } + return ( +
+
+
+ + onChange((q) => { q.numericConfig = { ...cfg, answer: Number(e.target.value) } })} + /> +
+
+ + onChange((q) => { q.numericConfig = { ...cfg, tolerance: Math.max(0, Number(e.target.value)) } })} + /> +
+
+ + onChange((q) => { q.numericConfig = { ...cfg, min: e.target.value ? Number(e.target.value) : undefined } })} + /> +
+
+ + onChange((q) => { q.numericConfig = { ...cfg, max: e.target.value ? Number(e.target.value) : undefined } })} + /> +
+
+
+ ) +} + +/* ─── Fill in the Blanks ─── */ + +function FillInTheBlanksEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) { + const segments = q.textSegments ?? [] + const blanks = q.blanks ?? [] + + const updateSegment = (i: number, text: string) => { + const next = [...segments] + next[i] = text + onChange((q) => { q.textSegments = next }) + } + + const addBlank = () => { + const id = `b${blanks.length + 1}` + onChange((q) => { + q.textSegments = [...(q.textSegments ?? []), ''] + q.blanks = [...(q.blanks ?? []), { id, acceptedAnswers: [''] }] + }) + } + + const removeBlank = (idx: number) => { + onChange((q) => { + q.textSegments = segments.filter((_, i) => i !== idx) + q.blanks = blanks.filter((_, i) => i !== idx) + }) + } + + return ( +
+ +

Text before blank 1, then blank 1 answer, then text before blank 2, etc.

+ {segments.map((seg, i) => ( +
+
+ Text {i + 1} + updateSegment(i, e.target.value)} + placeholder={`Text before blank ${i + 1}`} + /> +
+ {i < blanks.length && ( +
+ Blank {i + 1} + { + const next = [...blanks] + next[i] = { ...next[i], acceptedAnswers: [e.target.value] } + onChange((q) => { q.blanks = next }) + }} + placeholder="Accepted answer" + /> + +
+ )} +
+ ))} + +
+ ) +} + +/* ─── Dropdown Completion ─── */ + +function DropdownCompletionEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) { + const segments = q.textSegments ?? [] + const dropdowns = q.dropdowns ?? [] + + const updateSegment = (i: number, text: string) => { + const next = [...segments] + next[i] = text + onChange((q) => { q.textSegments = next }) + } + + const addDropdown = () => { + const id = `d${dropdowns.length + 1}` + onChange((q) => { + q.textSegments = [...(q.textSegments ?? []), ''] + q.dropdowns = [...(q.dropdowns ?? []), { id, options: [{ id: 'a', text: '' }, { id: 'b', text: '' }], correctOptionId: 'a' }] + }) + } + + const removeDropdown = (idx: number) => { + onChange((q) => { + q.textSegments = segments.filter((_, i) => i !== idx) + q.dropdowns = dropdowns.filter((_, i) => i !== idx) + }) + } + + return ( +
+ +

Text before dropdown 1, then dropdown 1 options, etc.

+ {segments.map((seg, i) => ( +
+
+ Text {i + 1} + updateSegment(i, e.target.value)} + placeholder={`Text before dropdown ${i + 1}`} + /> +
+ {i < dropdowns.length && ( +
+
+ Dropdown {i + 1} + +
+ {dropdowns[i].options.map((opt, j) => ( +
+ { + const next = [...dropdowns] + next[i] = { ...next[i], correctOptionId: opt.id } + onChange((q) => { q.dropdowns = next }) + }} + title="Mark as correct" + /> + {opt.id} + { + const next = [...dropdowns] + const opts = [...next[i].options] + opts[j] = { ...opts[j], text: e.target.value } + next[i] = { ...next[i], options: opts } + onChange((q) => { q.dropdowns = next }) + }} + placeholder={`Option ${opt.id}`} + /> +
+ ))} + +
+ )} +
+ ))} + +
+ ) +} + +/* ─── Code Submission ─── */ + +function CodeSubmissionEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) { + const { t } = useTranslation() + const cfg = q.codeConfig ?? { language: 'python', starterCode: '' } + const rubric = q.rubric ?? [] + + return ( +
+
+
+ + onChange((q) => { q.codeConfig = { ...cfg, language: e.target.value } })} + placeholder="e.g. python, javascript" + /> +
+
+
+ +