adding feedback

This commit is contained in:
2025-10-10 14:15:01 -06:00
parent e7e244222e
commit bf835caa37
6 changed files with 488 additions and 16 deletions

View File

@@ -19,15 +19,15 @@ services:
- ~/projects/facultyFiles:/app/public/images/facultyFiles - ~/projects/facultyFiles:/app/public/images/facultyFiles
redis: # redis:
image: redis # image: redis
container_name: redis # container_name: redis
volumes: # volumes:
- redis-data:/data # - redis-data:/data
restart: unless-stopped # restart: unless-stopped
volumes: # volumes:
redis-data: # redis-data:
# https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/ # https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/
# https://github.com/jonas-merkle/container-cloudflare-tunnel # https://github.com/jonas-merkle/container-cloudflare-tunnel

View File

@@ -70,7 +70,29 @@ this is a matching question
^ left answer - right dropdown ^ left answer - right dropdown
^ other thing - another option ^ other thing - another option
^ - distractor ^ - 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({ export default function EditQuiz({

View File

@@ -80,6 +80,42 @@ function QuizQuestionPreview({ question }: { question: LocalQuizQuestion }) {
</div> </div>
</div> </div>
<MarkdownDisplay markdown={question.text} className="ms-4 mb-2" /> <MarkdownDisplay markdown={question.text} className="ms-4 mb-2" />
{/* Feedback Section */}
{(question.correctComments ||
question.incorrectComments ||
question.neutralComments) && (
<div className=" m-2 ps-2 py-1 rounded flex bg-slate-950/50">
<div>Feedback</div>
<div className="mx-4 space-y-1">
{question.correctComments && (
<div className="border-l-2 border-green-700 pl-2 py-1">
<span className="text-green-500">+ </span>
<span className="text-slate-300">
{question.correctComments}
</span>
</div>
)}
{question.incorrectComments && (
<div className="border-l-2 border-red-700 pl-2 py-1">
<span className="text-red-500">- </span>
<span className="text-slate-300">
{question.incorrectComments}
</span>
</div>
)}
{question.neutralComments && (
<div className="border-l-2 border-blue-800 pl-2 py-1">
<span className="text-blue-500">... </span>
<span className="text-slate-300">
{question.neutralComments}
</span>
</div>
)}
</div>
</div>
)}
{question.questionType === QuestionType.MATCHING && ( {question.questionType === QuestionType.MATCHING && (
<div> <div>
{question.answers.map((answer) => ( {question.answers.map((answer) => (

View File

@@ -281,4 +281,298 @@ b) false
expect(quizHtml).toContain("<mi>x</mi>"); expect(quizHtml).toContain("<mi>x</mi>");
expect(quizHtml).not.toContain("x_2"); 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.");
});
}); });

View File

@@ -30,6 +30,9 @@ export interface LocalQuizQuestion {
points: number; points: number;
answers: LocalQuizQuestionAnswer[]; answers: LocalQuizQuestionAnswer[];
matchDistractors: string[]; matchDistractors: string[];
correctComments?: string;
incorrectComments?: string;
neutralComments?: string;
} }
export const zodLocalQuizQuestion = z.object({ export const zodLocalQuizQuestion = z.object({
text: z.string(), text: z.string(),
@@ -37,4 +40,7 @@ export const zodLocalQuizQuestion = z.object({
points: z.number(), points: z.number(),
answers: zodLocalQuizQuestionAnswer.array(), answers: zodLocalQuizQuestionAnswer.array(),
matchDistractors: z.array(z.string()), matchDistractors: z.array(z.string()),
correctComments: z.string().optional(),
incorrectComments: z.string().optional(),
neutralComments: z.string().optional(),
}); });

View File

@@ -14,6 +14,98 @@ const _validFirstAnswerDelimiters = [
]; ];
const _multipleChoicePrefix = ["a)", "*a)", "*)", ")"]; const _multipleChoicePrefix = ["a)", "*a)", "*)", ")"];
const _multipleAnswerPrefix = ["[ ]", "[*]", "[]"]; 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 = ( const getAnswerStringsWithMultilineSupport = (
linesWithoutPoints: string[], linesWithoutPoints: string[],
@@ -153,6 +245,18 @@ export const quizQuestionMarkdownUtils = {
? question.matchDistractors?.map((d) => `\n^ - ${d}`).join("") ?? "" ? 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 answersText = answerArray.join("\n");
const questionTypeIndicator = const questionTypeIndicator =
question.questionType === "essay" || question.questionType === "essay" ||
@@ -162,7 +266,7 @@ export const quizQuestionMarkdownUtils = {
? `\n${QuestionType.SHORT_ANSWER_WITH_ANSWERS}` ? `\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 { parseMarkdown(input: string, questionIndex: number): LocalQuizQuestion {
@@ -182,7 +286,15 @@ export const quizQuestionMarkdownUtils = {
const linesWithoutPoints = firstLineIsPoints ? lines.slice(1) : lines; 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) => { ({ linesWithoutAnswers, taking }, currentLine) => {
if (!taking) if (!taking)
return { linesWithoutAnswers: linesWithoutAnswers, taking: false }; return { linesWithoutAnswers: linesWithoutAnswers, taking: false };
@@ -200,7 +312,7 @@ export const quizQuestionMarkdownUtils = {
}, },
{ linesWithoutAnswers: [] as string[], taking: true } { linesWithoutAnswers: [] as string[], taking: true }
); );
const questionType = getQuestionType(linesWithoutPoints, questionIndex); const questionType = getQuestionType(linesWithoutFeedback, questionIndex);
const questionTypesWithoutAnswers = [ const questionTypesWithoutAnswers = [
"essay", "essay",
@@ -212,10 +324,9 @@ export const quizQuestionMarkdownUtils = {
questionType.toLowerCase() questionType.toLowerCase()
) )
? linesWithoutAnswers ? linesWithoutAnswers
.slice(0, linesWithoutPoints.length) .slice(0, linesWithoutFeedback.length)
.filter( .filter(
(line) => (line) => !questionTypesWithoutAnswers.includes(line.toLowerCase())
!questionTypesWithoutAnswers.includes(line.toLowerCase())
) )
: linesWithoutAnswers; : linesWithoutAnswers;
@@ -228,7 +339,7 @@ export const quizQuestionMarkdownUtils = {
"short_answer=", "short_answer=",
]; ];
const answers = typesWithAnswers.includes(questionType) const answers = typesWithAnswers.includes(questionType)
? getAnswers(linesWithoutPoints, questionIndex, questionType) ? getAnswers(linesWithoutFeedback, questionIndex, questionType)
: []; : [];
const answersWithoutDistractors = const answersWithoutDistractors =
@@ -247,6 +358,9 @@ export const quizQuestionMarkdownUtils = {
points, points,
answers: answersWithoutDistractors, answers: answersWithoutDistractors,
matchDistractors: distractors, matchDistractors: distractors,
correctComments,
incorrectComments,
neutralComments,
}; };
return question; return question;
}, },