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