From bf835caa370313198a22031e89a29f4ec6c2529a Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Fri, 10 Oct 2025 14:15:01 -0600 Subject: [PATCH] adding feedback --- docker-compose.yml | 16 +- .../[moduleName]/quiz/[quizName]/EditQuiz.tsx | 24 +- .../quiz/[quizName]/QuizPreview.tsx | 36 +++ .../quizMarkdown/quizMarkdown.test.ts | 294 ++++++++++++++++++ .../local/quizzes/models/localQuizQuestion.ts | 6 + .../models/utils/quizQuestionMarkdownUtils.ts | 128 +++++++- 6 files changed, 488 insertions(+), 16 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index b5cff1a..dada1d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,15 +19,15 @@ services: - ~/projects/facultyFiles:/app/public/images/facultyFiles - redis: - image: redis - container_name: redis - volumes: - - redis-data:/data - restart: unless-stopped +# redis: +# image: redis +# container_name: redis +# volumes: +# - redis-data:/data +# restart: unless-stopped -volumes: - redis-data: +# volumes: +# redis-data: # https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/ # https://github.com/jonas-merkle/container-cloudflare-tunnel diff --git a/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/EditQuiz.tsx b/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/EditQuiz.tsx index b703906..a7443d1 100644 --- a/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/EditQuiz.tsx +++ b/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/EditQuiz.tsx @@ -70,7 +70,29 @@ this is a matching question ^ left answer - right dropdown ^ other thing - another option ^ - distractor -^ - other distractor`; +^ - other distractor +--- +Points: 3 +FEEDBACK EXAMPLE +What is 2+3? ++ Correct! Good job +- Incorrect, try again +... This is general feedback shown regardless +*a) 4 +*b) 5 +c) 6 +--- +Points: 2 +FEEDBACK EXAMPLE +Multiline feedback example ++ +Great work! +You understand the concept. +- +Not quite right. +Review the material and try again. +*a) correct answer +b) wrong answer`; }; export default function EditQuiz({ diff --git a/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/QuizPreview.tsx b/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/QuizPreview.tsx index 5f25b37..6dbd701 100644 --- a/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/QuizPreview.tsx +++ b/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/QuizPreview.tsx @@ -80,6 +80,42 @@ function QuizQuestionPreview({ question }: { question: LocalQuizQuestion }) { + + {/* Feedback Section */} + {(question.correctComments || + question.incorrectComments || + question.neutralComments) && ( +
+
Feedback
+
+ {question.correctComments && ( +
+ + + + {question.correctComments} + +
+ )} + {question.incorrectComments && ( +
+ - + + {question.incorrectComments} + +
+ )} + {question.neutralComments && ( +
+ ... + + {question.neutralComments} + +
+ )} +
+
+ )} + {question.questionType === QuestionType.MATCHING && (
{question.answers.map((answer) => ( diff --git a/src/features/local/parsingTests/quizMarkdown/quizMarkdown.test.ts b/src/features/local/parsingTests/quizMarkdown/quizMarkdown.test.ts index 529fc2d..a99d1e5 100644 --- a/src/features/local/parsingTests/quizMarkdown/quizMarkdown.test.ts +++ b/src/features/local/parsingTests/quizMarkdown/quizMarkdown.test.ts @@ -281,4 +281,298 @@ b) false expect(quizHtml).toContain("x"); expect(quizHtml).not.toContain("x_2"); }); + + it("can parse question with correct feedback", () => { + const name = "Test Quiz"; + const rawMarkdownQuiz = ` +ShuffleAnswers: true +OneQuestionAtATime: false +DueAt: 08/21/2023 23:59:00 +LockAt: 08/21/2023 23:59:00 +AssignmentGroup: Assignments +AllowedAttempts: -1 +Description: quiz description +--- +Points: 3 +What is the purpose of a context switch? ++ Correct! The context switch is used to change the current process by swapping the registers and other state with a new process +*a) To change the current window you are on +b) To change the current process's status +*c) To swap the current process's registers for a new process's registers +`; + + const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); + const question = quiz.questions[0]; + + expect(question.correctComments).toBe( + "Correct! The context switch is used to change the current process by swapping the registers and other state with a new process" + ); + expect(question.incorrectComments).toBeUndefined(); + expect(question.neutralComments).toBeUndefined(); + }); + + it("can parse question with incorrect feedback", () => { + const name = "Test Quiz"; + const rawMarkdownQuiz = ` +ShuffleAnswers: true +OneQuestionAtATime: false +DueAt: 08/21/2023 23:59:00 +LockAt: 08/21/2023 23:59:00 +AssignmentGroup: Assignments +AllowedAttempts: -1 +Description: quiz description +--- +Points: 3 +What state does a process need to be in to be able to be scheduled? +- Incorrect! A process in ready state can be scheduled +*a) Ready +b) Running +c) Zombie +d) Embryo +`; + + const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); + const question = quiz.questions[0]; + + expect(question.incorrectComments).toBe( + "Incorrect! A process in ready state can be scheduled" + ); + expect(question.correctComments).toBeUndefined(); + expect(question.neutralComments).toBeUndefined(); + }); + + it("can parse question with correct and incorrect feedback", () => { + const name = "Test Quiz"; + const rawMarkdownQuiz = ` +ShuffleAnswers: true +OneQuestionAtATime: false +DueAt: 08/21/2023 23:59:00 +LockAt: 08/21/2023 23:59:00 +AssignmentGroup: Assignments +AllowedAttempts: -1 +Description: quiz description +--- +Points: 3 +What is the purpose of a context switch? ++ Correct! The context switch is used to change the current process +- Incorrect! The context switch is NOT used to change windows +*a) To change the current window you are on +b) To change the current process's status +*c) To swap the current process's registers for a new process's registers +`; + + const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); + const question = quiz.questions[0]; + + expect(question.correctComments).toBe( + "Correct! The context switch is used to change the current process" + ); + expect(question.incorrectComments).toBe( + "Incorrect! The context switch is NOT used to change windows" + ); + expect(question.neutralComments).toBeUndefined(); + }); + + it("can parse question with neutral feedback", () => { + const name = "Test Quiz"; + const rawMarkdownQuiz = ` +ShuffleAnswers: true +OneQuestionAtATime: false +DueAt: 08/21/2023 23:59:00 +LockAt: 08/21/2023 23:59:00 +AssignmentGroup: Assignments +AllowedAttempts: -1 +Description: quiz description +--- +Points: 3 +What is a prime number? +... This feedback will be shown regardless of the answer +*a) A number divisible only by 1 and itself +b) Any odd number +c) Any even number +`; + + const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); + const question = quiz.questions[0]; + + expect(question.neutralComments).toBe( + "This feedback will be shown regardless of the answer" + ); + expect(question.correctComments).toBeUndefined(); + expect(question.incorrectComments).toBeUndefined(); + }); + + it("can parse question with all three feedback types", () => { + const name = "Test Quiz"; + const rawMarkdownQuiz = ` +ShuffleAnswers: true +OneQuestionAtATime: false +DueAt: 08/21/2023 23:59:00 +LockAt: 08/21/2023 23:59:00 +AssignmentGroup: Assignments +AllowedAttempts: -1 +Description: quiz description +--- +Points: 3 +What is the purpose of a context switch? ++ Great job! You understand context switching +- Try reviewing the material on process management +... Context switches are a fundamental operating system concept +*a) To change the current window you are on +b) To change the current process's status +*c) To swap the current process's registers for a new process's registers +`; + + const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); + const question = quiz.questions[0]; + + expect(question.correctComments).toBe( + "Great job! You understand context switching" + ); + expect(question.incorrectComments).toBe( + "Try reviewing the material on process management" + ); + expect(question.neutralComments).toBe( + "Context switches are a fundamental operating system concept" + ); + }); + + it("can parse multiline feedback", () => { + const name = "Test Quiz"; + const rawMarkdownQuiz = ` +ShuffleAnswers: true +OneQuestionAtATime: false +DueAt: 08/21/2023 23:59:00 +LockAt: 08/21/2023 23:59:00 +AssignmentGroup: Assignments +AllowedAttempts: -1 +Description: quiz description +--- +Points: 3 +What is the purpose of a context switch? ++ Correct! The context switch is used to change the current process. +This is additional information on a new line. +- Incorrect! You should review the material. +Check your notes on process management. +*a) To change the current window you are on +b) To change the current process's status +*c) To swap the current process's registers for a new process's registers +`; + + const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); + const question = quiz.questions[0]; + + expect(question.correctComments).toBe( + "Correct! The context switch is used to change the current process.\nThis is additional information on a new line." + ); + expect(question.incorrectComments).toBe( + "Incorrect! You should review the material.\nCheck your notes on process management." + ); + }); + + it("feedback can serialize to markdown", () => { + const quiz: LocalQuiz = { + name: "Test Quiz", + description: "quiz description", + lockAt: new Date(8640000000000000).toISOString(), + dueAt: new Date(8640000000000000).toISOString(), + shuffleAnswers: true, + oneQuestionAtATime: false, + localAssignmentGroupName: "Assignments", + allowedAttempts: -1, + showCorrectAnswers: false, + questions: [ + { + text: "What is the purpose of a context switch?", + questionType: QuestionType.MULTIPLE_CHOICE, + points: 3, + correctComments: "Correct! Good job", + incorrectComments: "Incorrect! Try again", + neutralComments: "Context switches are important", + answers: [ + { correct: false, text: "To change the current window you are on" }, + { correct: true, text: "To swap registers" }, + ], + matchDistractors: [], + }, + ], + }; + + const markdown = quizMarkdownUtils.toMarkdown(quiz); + + expect(markdown).toContain("+ Correct! Good job"); + expect(markdown).toContain("- Incorrect! Try again"); + expect(markdown).toContain("... Context switches are important"); + }); + + it("can parse question with alternative format using ellipsis for general feedback", () => { + const name = "Test Quiz"; + const rawMarkdownQuiz = ` +ShuffleAnswers: true +OneQuestionAtATime: false +DueAt: 08/21/2023 23:59:00 +LockAt: 08/21/2023 23:59:00 +AssignmentGroup: Assignments +AllowedAttempts: -1 +Description: An addition question +--- +Points: 2 +What is 2+3? +... General question feedback. ++ Feedback for correct answer. +- Feedback for incorrect answer. +a) 6 +b) 1 +*c) 5 +`; + + const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); + const question = quiz.questions[0]; + + expect(question.text).toBe("What is 2+3?"); + expect(question.points).toBe(2); + expect(question.neutralComments).toBe("General question feedback."); + expect(question.correctComments).toBe("Feedback for correct answer."); + expect(question.incorrectComments).toBe("Feedback for incorrect answer."); + expect(question.answers).toHaveLength(3); + expect(question.answers[0].text).toBe("6"); + expect(question.answers[0].correct).toBe(false); + expect(question.answers[1].text).toBe("1"); + expect(question.answers[1].correct).toBe(false); + expect(question.answers[2].text).toBe("5"); + expect(question.answers[2].correct).toBe(true); + }); + + it("can parse multiline general feedback with ellipsis", () => { + const name = "Test Quiz"; + const rawMarkdownQuiz = ` +ShuffleAnswers: true +OneQuestionAtATime: false +DueAt: 08/21/2023 23:59:00 +LockAt: 08/21/2023 23:59:00 +AssignmentGroup: Assignments +AllowedAttempts: -1 +Description: quiz description +--- +Points: 2 +What is 2+3? +... +General question feedback. +This continues on multiple lines. ++ Feedback for correct answer. +- Feedback for incorrect answer. +a) 6 +b) 1 +*c) 5 +`; + + const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); + const question = quiz.questions[0]; + + expect(question.neutralComments).toBe( + "General question feedback.\nThis continues on multiple lines." + ); + expect(question.correctComments).toBe("Feedback for correct answer."); + expect(question.incorrectComments).toBe("Feedback for incorrect answer."); + }); }); diff --git a/src/features/local/quizzes/models/localQuizQuestion.ts b/src/features/local/quizzes/models/localQuizQuestion.ts index 6524baa..9024a5b 100644 --- a/src/features/local/quizzes/models/localQuizQuestion.ts +++ b/src/features/local/quizzes/models/localQuizQuestion.ts @@ -30,6 +30,9 @@ export interface LocalQuizQuestion { points: number; answers: LocalQuizQuestionAnswer[]; matchDistractors: string[]; + correctComments?: string; + incorrectComments?: string; + neutralComments?: string; } export const zodLocalQuizQuestion = z.object({ text: z.string(), @@ -37,4 +40,7 @@ export const zodLocalQuizQuestion = z.object({ points: z.number(), answers: zodLocalQuizQuestionAnswer.array(), matchDistractors: z.array(z.string()), + correctComments: z.string().optional(), + incorrectComments: z.string().optional(), + neutralComments: z.string().optional(), }); diff --git a/src/features/local/quizzes/models/utils/quizQuestionMarkdownUtils.ts b/src/features/local/quizzes/models/utils/quizQuestionMarkdownUtils.ts index 062a76f..c560e86 100644 --- a/src/features/local/quizzes/models/utils/quizQuestionMarkdownUtils.ts +++ b/src/features/local/quizzes/models/utils/quizQuestionMarkdownUtils.ts @@ -14,6 +14,98 @@ const _validFirstAnswerDelimiters = [ ]; const _multipleChoicePrefix = ["a)", "*a)", "*)", ")"]; const _multipleAnswerPrefix = ["[ ]", "[*]", "[]"]; +const _feedbackPrefixes = ["+", "-", "..."]; + +const extractFeedback = ( + linesWithoutPoints: string[] +): { + correctComments?: string; + incorrectComments?: string; + neutralComments?: string; + linesWithoutFeedback: string[]; +} => { + let correctComments: string | undefined; + let incorrectComments: string | undefined; + let neutralComments: string | undefined; + const linesWithoutFeedback: string[] = []; + + let currentFeedbackType: "+" | "-" | "..." | null = null; + let currentFeedbackLines: string[] = []; + + for (const line of linesWithoutPoints) { + const trimmed = line.trim(); + + // Check if this is a new feedback line + if (trimmed.startsWith("+ ") || trimmed === "+") { + // Save previous feedback if any + if (currentFeedbackType && currentFeedbackLines.length > 0) { + const feedbackText = currentFeedbackLines.join("\n"); + if (currentFeedbackType === "+") correctComments = feedbackText; + else if (currentFeedbackType === "-") incorrectComments = feedbackText; + else if (currentFeedbackType === "...") neutralComments = feedbackText; + } + + currentFeedbackType = "+"; + currentFeedbackLines = trimmed === "+" ? [] : [trimmed.substring(2)]; // Remove "+ " or handle standalone "+" + } else if (trimmed.startsWith("- ") || trimmed === "-") { + // Save previous feedback if any + if (currentFeedbackType && currentFeedbackLines.length > 0) { + const feedbackText = currentFeedbackLines.join("\n"); + if (currentFeedbackType === "+") correctComments = feedbackText; + else if (currentFeedbackType === "-") incorrectComments = feedbackText; + else if (currentFeedbackType === "...") neutralComments = feedbackText; + } + + currentFeedbackType = "-"; + currentFeedbackLines = trimmed === "-" ? [] : [trimmed.substring(2)]; // Remove "- " or handle standalone "-" + } else if (trimmed.startsWith("... ") || trimmed === "...") { + // Save previous feedback if any + if (currentFeedbackType && currentFeedbackLines.length > 0) { + const feedbackText = currentFeedbackLines.join("\n"); + if (currentFeedbackType === "+") correctComments = feedbackText; + else if (currentFeedbackType === "-") incorrectComments = feedbackText; + else if (currentFeedbackType === "...") neutralComments = feedbackText; + } + + currentFeedbackType = "..."; + currentFeedbackLines = trimmed === "..." ? [] : [trimmed.substring(4)]; // Remove "... " or handle standalone "..." + } else if ( + currentFeedbackType && + !_validFirstAnswerDelimiters.some((prefix) => trimmed.startsWith(prefix)) + ) { + // This is a continuation of the current feedback + currentFeedbackLines.push(line); + } else { + // Save any pending feedback + if (currentFeedbackType && currentFeedbackLines.length > 0) { + const feedbackText = currentFeedbackLines.join("\n"); + if (currentFeedbackType === "+") correctComments = feedbackText; + else if (currentFeedbackType === "-") incorrectComments = feedbackText; + else if (currentFeedbackType === "...") neutralComments = feedbackText; + currentFeedbackType = null; + currentFeedbackLines = []; + } + + // This is a regular line + linesWithoutFeedback.push(line); + } + } + + // Save any remaining feedback + if (currentFeedbackType && currentFeedbackLines.length > 0) { + const feedbackText = currentFeedbackLines.join("\n"); + if (currentFeedbackType === "+") correctComments = feedbackText; + else if (currentFeedbackType === "-") incorrectComments = feedbackText; + else if (currentFeedbackType === "...") neutralComments = feedbackText; + } + + return { + correctComments, + incorrectComments, + neutralComments, + linesWithoutFeedback, + }; +}; const getAnswerStringsWithMultilineSupport = ( linesWithoutPoints: string[], @@ -153,6 +245,18 @@ export const quizQuestionMarkdownUtils = { ? question.matchDistractors?.map((d) => `\n^ - ${d}`).join("") ?? "" : ""; + // Build feedback lines + let feedbackText = ""; + if (question.correctComments) { + feedbackText += `+ ${question.correctComments}\n`; + } + if (question.incorrectComments) { + feedbackText += `- ${question.incorrectComments}\n`; + } + if (question.neutralComments) { + feedbackText += `... ${question.neutralComments}\n`; + } + const answersText = answerArray.join("\n"); const questionTypeIndicator = question.questionType === "essay" || @@ -162,7 +266,7 @@ export const quizQuestionMarkdownUtils = { ? `\n${QuestionType.SHORT_ANSWER_WITH_ANSWERS}` : ""; - return `Points: ${question.points}\n${question.text}\n${answersText}${distractorText}${questionTypeIndicator}`; + return `Points: ${question.points}\n${question.text}\n${feedbackText}${answersText}${distractorText}${questionTypeIndicator}`; }, parseMarkdown(input: string, questionIndex: number): LocalQuizQuestion { @@ -182,7 +286,15 @@ export const quizQuestionMarkdownUtils = { const linesWithoutPoints = firstLineIsPoints ? lines.slice(1) : lines; - const { linesWithoutAnswers } = linesWithoutPoints.reduce( + // Extract feedback comments first + const { + correctComments, + incorrectComments, + neutralComments, + linesWithoutFeedback, + } = extractFeedback(linesWithoutPoints); + + const { linesWithoutAnswers } = linesWithoutFeedback.reduce( ({ linesWithoutAnswers, taking }, currentLine) => { if (!taking) return { linesWithoutAnswers: linesWithoutAnswers, taking: false }; @@ -200,7 +312,7 @@ export const quizQuestionMarkdownUtils = { }, { linesWithoutAnswers: [] as string[], taking: true } ); - const questionType = getQuestionType(linesWithoutPoints, questionIndex); + const questionType = getQuestionType(linesWithoutFeedback, questionIndex); const questionTypesWithoutAnswers = [ "essay", @@ -212,10 +324,9 @@ export const quizQuestionMarkdownUtils = { questionType.toLowerCase() ) ? linesWithoutAnswers - .slice(0, linesWithoutPoints.length) + .slice(0, linesWithoutFeedback.length) .filter( - (line) => - !questionTypesWithoutAnswers.includes(line.toLowerCase()) + (line) => !questionTypesWithoutAnswers.includes(line.toLowerCase()) ) : linesWithoutAnswers; @@ -228,7 +339,7 @@ export const quizQuestionMarkdownUtils = { "short_answer=", ]; const answers = typesWithAnswers.includes(questionType) - ? getAnswers(linesWithoutPoints, questionIndex, questionType) + ? getAnswers(linesWithoutFeedback, questionIndex, questionType) : []; const answersWithoutDistractors = @@ -247,6 +358,9 @@ export const quizQuestionMarkdownUtils = { points, answers: answersWithoutDistractors, matchDistractors: distractors, + correctComments, + incorrectComments, + neutralComments, }; return question; },