can get exact answers

This commit is contained in:
2025-10-22 12:26:28 -06:00
parent 47c69251c8
commit d9f7e7b3e9
7 changed files with 291 additions and 242 deletions

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { quizMarkdownUtils } from "../../quizzes/models/utils/quizMarkdownUtils";
import { QuestionType } from "../../quizzes/models/localQuizQuestion";
describe("numerical answer questions", () => {
it("can parse question with numerical answers", () => {
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
---
What is 2+3?
= 5
`;
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
const question = quiz.questions[0];
expect(question.text).toBe("What is 2+3?");
expect(question.questionType).toBe(QuestionType.NUMERICAL);
expect(question.answers[0].numericAnswer).toBe(5);
});
});

View File

@@ -208,12 +208,6 @@ short_answer=
expect(firstQuestion.answers[1].text).toBe("other"); expect(firstQuestion.answers[1].text).toBe("other");
}); });
it("Has short_answer= type at the same position in types and zod types", () => {
expect(Object.values(zodQuestionType.Enum)).toEqual(
Object.values(QuestionType)
);
});
it("Associates short_answer= questions with short_answer_question canvas question type", () => { it("Associates short_answer= questions with short_answer_question canvas question type", () => {
const name = "Test Quiz"; const name = "Test Quiz";
const rawMarkdownQuiz = ` const rawMarkdownQuiz = `

View File

@@ -12,6 +12,7 @@ export enum QuestionType {
MATCHING = "matching", MATCHING = "matching",
NONE = "", NONE = "",
SHORT_ANSWER_WITH_ANSWERS = "short_answer=", SHORT_ANSWER_WITH_ANSWERS = "short_answer=",
NUMERICAL = "numerical",
} }
export const zodQuestionType = z.enum([ export const zodQuestionType = z.enum([

View File

@@ -1,13 +1,18 @@
import { z } from "zod"; import { z } from "zod";
export interface LocalQuizQuestionAnswer {
correct: boolean;
text: string;
matchedText?: string;
}
export const zodLocalQuizQuestionAnswer = z.object({ export const zodLocalQuizQuestionAnswer = z.object({
correct: z.boolean(), correct: z.boolean(),
text: z.string(), text: z.string(),
matchedText: z.string().optional(), matchedText: z.string().optional(),
numericalAnswerType: z
.enum(["exact_answer", "range_answer", "precision_answer"])
.optional(),
numericAnswer: z.number().optional(),
numericAnswerRangeMin: z.number().optional(),
numericAnswerRangeMax: z.number().optional(),
numericAnswerMargin: z.number().optional(),
}); });
export type LocalQuizQuestionAnswer = z.infer<
typeof zodLocalQuizQuestionAnswer
>;

View File

@@ -1,5 +1,29 @@
import { QuestionType } from "../localQuizQuestion"; import { LocalQuizQuestion, QuestionType } from "../localQuizQuestion";
import { LocalQuizQuestionAnswer } from "../localQuizQuestionAnswer"; import { LocalQuizQuestionAnswer } from "../localQuizQuestionAnswer";
const _validFirstAnswerDelimiters = [
"*a)",
"a)",
"*)",
")",
"[ ]",
"[]",
"[*]",
"^",
"=",
];
const _multipleChoicePrefix = ["a)", "*a)", "*)", ")"];
const _multipleAnswerPrefix = ["[ ]", "[*]", "[]"];
const parseNumericalAnswer = (input: string): LocalQuizQuestionAnswer => {
const numericValue = parseFloat(input.replace(/^=\s*/, "").trim());
const answer: LocalQuizQuestionAnswer = {
correct: true,
text: input.trim(),
numericalAnswerType: "exact_answer",
numericAnswer: numericValue,
};
return answer;
};
const parseMatchingAnswer = (input: string) => { const parseMatchingAnswer = (input: string) => {
const matchingPattern = /^\^?/; const matchingPattern = /^\^?/;
@@ -13,11 +37,51 @@ const parseMatchingAnswer = (input: string) => {
return answer; return answer;
}; };
const getAnswerStringsWithMultilineSupport = (
linesWithoutPoints: string[],
questionIndex: number
) => {
const indexOfAnswerStart = linesWithoutPoints.findIndex((l) =>
_validFirstAnswerDelimiters.some((prefix) =>
l.trimStart().startsWith(prefix)
)
);
if (indexOfAnswerStart === -1) {
const debugLine = linesWithoutPoints.find((l) => l.trim().length > 0);
throw Error(
`question ${
questionIndex + 1
}: no answers when detecting question type on ${debugLine}`
);
}
const answerLinesRaw = linesWithoutPoints.slice(indexOfAnswerStart);
const answerStartPattern = /^(\*?[a-z]?\))|(?<!\S)\[\s*\]|\[\*\]|\^/;
const answerLines = answerLinesRaw.reduce((acc: string[], line: string) => {
const isNewAnswer = answerStartPattern.test(line);
if (isNewAnswer) {
acc.push(line);
} else if (acc.length !== 0) {
acc[acc.length - 1] += "\n" + line;
} else {
acc.push(line);
}
return acc;
}, []);
return answerLines;
};
export const quizQuestionAnswerMarkdownUtils = { export const quizQuestionAnswerMarkdownUtils = {
parseMarkdown(
input: string,
questionType: QuestionType
): LocalQuizQuestionAnswer {
if (questionType === QuestionType.NUMERICAL) {
return parseNumericalAnswer(input);
}
parseMarkdown(input: string, questionType: string): LocalQuizQuestionAnswer {
const isCorrect = input.startsWith("*") || input[1] === "*"; const isCorrect = input.startsWith("*") || input[1] === "*";
if (questionType === QuestionType.MATCHING) { if (questionType === QuestionType.MATCHING) {
return parseMatchingAnswer(input); return parseMatchingAnswer(input);
} }
@@ -35,4 +99,112 @@ export const quizQuestionAnswerMarkdownUtils = {
}; };
return answer; return answer;
}, },
isAnswerLine: (trimmedLine: string): boolean => {
return _validFirstAnswerDelimiters.some((prefix) =>
trimmedLine.startsWith(prefix)
);
},
getQuestionType: (
linesWithoutPoints: string[],
questionIndex: number // needed for debug logging
): QuestionType => {
const lastLine = linesWithoutPoints[linesWithoutPoints.length - 1]
.toLowerCase()
.trim();
if (linesWithoutPoints.length === 0) return QuestionType.NONE;
if (lastLine === "essay") return QuestionType.ESSAY;
if (lastLine === "short answer") return QuestionType.SHORT_ANSWER;
if (lastLine === "short_answer") return QuestionType.SHORT_ANSWER;
if (lastLine === "short_answer=")
return QuestionType.SHORT_ANSWER_WITH_ANSWERS;
if (lastLine.startsWith("=")) return QuestionType.NUMERICAL;
const answerLines = getAnswerStringsWithMultilineSupport(
linesWithoutPoints,
questionIndex
);
const firstAnswerLine = answerLines[0];
const isMultipleChoice = _multipleChoicePrefix.some((prefix) =>
firstAnswerLine.startsWith(prefix)
);
if (isMultipleChoice) return QuestionType.MULTIPLE_CHOICE;
const isMultipleAnswer = _multipleAnswerPrefix.some((prefix) =>
firstAnswerLine.startsWith(prefix)
);
if (isMultipleAnswer) return QuestionType.MULTIPLE_ANSWERS;
const isMatching = firstAnswerLine.startsWith("^");
if (isMatching) return QuestionType.MATCHING;
return QuestionType.NONE;
},
getAnswers: (
linesWithoutPoints: string[],
questionIndex: number,
questionType: QuestionType
): { answers: LocalQuizQuestionAnswer[]; distractors: string[] } => {
const typesWithAnswers: QuestionType[] = [
QuestionType.MULTIPLE_CHOICE,
QuestionType.MULTIPLE_ANSWERS,
QuestionType.MATCHING,
QuestionType.SHORT_ANSWER_WITH_ANSWERS,
QuestionType.NUMERICAL,
];
if (!typesWithAnswers.includes(questionType)) {
return { answers: [], distractors: [] };
}
if (questionType == QuestionType.SHORT_ANSWER_WITH_ANSWERS)
linesWithoutPoints = linesWithoutPoints.slice(
0,
linesWithoutPoints.length - 1
);
const answerLines = getAnswerStringsWithMultilineSupport(
linesWithoutPoints,
questionIndex
);
const allAnswers = answerLines.map((a) =>
quizQuestionAnswerMarkdownUtils.parseMarkdown(a, questionType)
);
// For matching questions, separate answers from distractors
if (questionType === QuestionType.MATCHING) {
const answers = allAnswers.filter((a) => a.text);
const distractors = allAnswers
.filter((a) => !a.text)
.map((a) => a.matchedText ?? "");
return { answers, distractors };
}
return { answers: allAnswers, distractors: [] };
},
getAnswerMarkdown: (
question: LocalQuizQuestion,
answer: LocalQuizQuestionAnswer,
index: number
): string => {
const multilineMarkdownCompatibleText = answer.text.startsWith("```")
? "\n" + answer.text
: answer.text;
if (question.questionType === "multiple_answers") {
const correctIndicator = answer.correct ? "*" : " ";
const questionTypeIndicator = `[${correctIndicator}] `;
return `${questionTypeIndicator}${multilineMarkdownCompatibleText}`;
} else if (question.questionType === "matching") {
return `^ ${answer.text} - ${answer.matchedText}`;
} else {
const questionLetter = String.fromCharCode(97 + index);
const correctIndicator = answer.correct ? "*" : "";
const questionTypeIndicator = `${correctIndicator}${questionLetter}) `;
return `${questionTypeIndicator}${multilineMarkdownCompatibleText}`;
}
},
}; };

View File

@@ -1,150 +0,0 @@
import { QuestionType, LocalQuizQuestion } from "../localQuizQuestion";
import { LocalQuizQuestionAnswer } from "../localQuizQuestionAnswer";
import { quizQuestionAnswerMarkdownUtils } from "./quizQuestionAnswerMarkdownUtils";
const _validFirstAnswerDelimiters = [
"*a)",
"a)",
"*)",
")",
"[ ]",
"[]",
"[*]",
"^",
];
const _multipleChoicePrefix = ["a)", "*a)", "*)", ")"];
const _multipleAnswerPrefix = ["[ ]", "[*]", "[]"];
export const isAnswerLine = (trimmedLine: string): boolean => {
return _validFirstAnswerDelimiters.some((prefix) =>
trimmedLine.startsWith(prefix)
);
};
export const getAnswerStringsWithMultilineSupport = (
linesWithoutPoints: string[],
questionIndex: number
) => {
const indexOfAnswerStart = linesWithoutPoints.findIndex((l) =>
_validFirstAnswerDelimiters.some((prefix) =>
l.trimStart().startsWith(prefix)
)
);
if (indexOfAnswerStart === -1) {
const debugLine = linesWithoutPoints.find((l) => l.trim().length > 0);
throw Error(
`question ${
questionIndex + 1
}: no answers when detecting question type on ${debugLine}`
);
}
const answerLinesRaw = linesWithoutPoints.slice(indexOfAnswerStart);
const answerStartPattern = /^(\*?[a-z]?\))|(?<!\S)\[\s*\]|\[\*\]|\^/;
const answerLines = answerLinesRaw.reduce((acc: string[], line: string) => {
const isNewAnswer = answerStartPattern.test(line);
if (isNewAnswer) {
acc.push(line);
} else if (acc.length !== 0) {
acc[acc.length - 1] += "\n" + line;
} else {
acc.push(line);
}
return acc;
}, []);
return answerLines;
};
export const getQuestionType = (
linesWithoutPoints: string[],
questionIndex: number
): QuestionType => {
if (linesWithoutPoints.length === 0) return QuestionType.NONE;
if (
linesWithoutPoints[linesWithoutPoints.length - 1].toLowerCase() === "essay"
)
return QuestionType.ESSAY;
if (
linesWithoutPoints[linesWithoutPoints.length - 1].toLowerCase() ===
"short answer"
)
return QuestionType.SHORT_ANSWER;
if (
linesWithoutPoints[linesWithoutPoints.length - 1].toLowerCase() ===
"short_answer"
)
return QuestionType.SHORT_ANSWER;
if (
linesWithoutPoints[linesWithoutPoints.length - 1].toLowerCase().trim() ===
"short_answer="
)
return QuestionType.SHORT_ANSWER_WITH_ANSWERS;
const answerLines = getAnswerStringsWithMultilineSupport(
linesWithoutPoints,
questionIndex
);
const firstAnswerLine = answerLines[0];
const isMultipleChoice = _multipleChoicePrefix.some((prefix) =>
firstAnswerLine.startsWith(prefix)
);
if (isMultipleChoice) return QuestionType.MULTIPLE_CHOICE;
const isMultipleAnswer = _multipleAnswerPrefix.some((prefix) =>
firstAnswerLine.startsWith(prefix)
);
if (isMultipleAnswer) return QuestionType.MULTIPLE_ANSWERS;
const isMatching = firstAnswerLine.startsWith("^");
if (isMatching) return QuestionType.MATCHING;
return QuestionType.NONE;
};
export const getAnswers = (
linesWithoutPoints: string[],
questionIndex: number,
questionType: string
): LocalQuizQuestionAnswer[] => {
if (questionType == QuestionType.SHORT_ANSWER_WITH_ANSWERS)
linesWithoutPoints = linesWithoutPoints.slice(
0,
linesWithoutPoints.length - 1
);
const answerLines = getAnswerStringsWithMultilineSupport(
linesWithoutPoints,
questionIndex
);
const answers = answerLines.map((a) =>
quizQuestionAnswerMarkdownUtils.parseMarkdown(a, questionType)
);
return answers;
};
export const getAnswerMarkdown = (
question: LocalQuizQuestion,
answer: LocalQuizQuestionAnswer,
index: number
): string => {
const multilineMarkdownCompatibleText = answer.text.startsWith("```")
? "\n" + answer.text
: answer.text;
if (question.questionType === "multiple_answers") {
const correctIndicator = answer.correct ? "*" : " ";
const questionTypeIndicator = `[${correctIndicator}] `;
return `${questionTypeIndicator}${multilineMarkdownCompatibleText}`;
} else if (question.questionType === "matching") {
return `^ ${answer.text} - ${answer.matchedText}`;
} else {
const questionLetter = String.fromCharCode(97 + index);
const correctIndicator = answer.correct ? "*" : "";
const questionTypeIndicator = `${correctIndicator}${questionLetter}) `;
return `${questionTypeIndicator}${multilineMarkdownCompatibleText}`;
}
};

View File

@@ -1,16 +1,66 @@
import { LocalQuizQuestion, QuestionType } from "../localQuizQuestion"; import { LocalQuizQuestion, QuestionType } from "../localQuizQuestion";
import { quizFeedbackMarkdownUtils } from "./quizFeedbackMarkdownUtils"; import { quizFeedbackMarkdownUtils } from "./quizFeedbackMarkdownUtils";
import { import { quizQuestionAnswerMarkdownUtils } from "./quizQuestionAnswerMarkdownUtils";
getAnswerMarkdown,
getAnswers, const splitLinesAndPoints = (input: string[]) => {
getQuestionType, const firstLineIsPoints = input[0].toLowerCase().includes("points: ");
isAnswerLine,
} from "./quizQuestionAnswerParsingUtils"; const textHasPointsLine =
input.length > 0 &&
input[0].includes(": ") &&
input[0].split(": ").length > 1 &&
!isNaN(parseFloat(input[0].split(": ")[1]));
const points =
firstLineIsPoints && textHasPointsLine
? parseFloat(input[0].split(": ")[1])
: 1;
const linesWithoutPoints = firstLineIsPoints ? input.slice(1) : input;
return { points, lines: linesWithoutPoints };
};
const getLinesBeforeAnswerLines = (lines: string[]): string[] => {
const { linesWithoutAnswers } = lines.reduce(
({ linesWithoutAnswers, taking }, currentLine) => {
if (!taking)
return { linesWithoutAnswers: linesWithoutAnswers, taking: false };
const lineIsAnswer =
quizQuestionAnswerMarkdownUtils.isAnswerLine(currentLine);
if (lineIsAnswer)
return { linesWithoutAnswers: linesWithoutAnswers, taking: false };
return {
linesWithoutAnswers: [...linesWithoutAnswers, currentLine],
taking: true,
};
},
{ linesWithoutAnswers: [] as string[], taking: true }
);
return linesWithoutAnswers;
};
const removeQuestionTypeFromDescriptionLines = (
linesWithoutAnswers: string[],
questionType: QuestionType
): string[] => {
const questionTypesWithoutAnswers = ["essay", "short answer", "short_answer"];
const descriptionLines = questionTypesWithoutAnswers.includes(questionType)
? linesWithoutAnswers.filter(
(line) => !questionTypesWithoutAnswers.includes(line.toLowerCase())
)
: linesWithoutAnswers;
return descriptionLines;
};
export const quizQuestionMarkdownUtils = { export const quizQuestionMarkdownUtils = {
toMarkdown(question: LocalQuizQuestion): string { toMarkdown(question: LocalQuizQuestion): string {
const answerArray = question.answers.map((a, i) => const answerArray = question.answers.map((a, i) =>
getAnswerMarkdown(question, a, i) quizQuestionAnswerMarkdownUtils.getAnswerMarkdown(question, a, i)
); );
const distractorText = const distractorText =
@@ -38,89 +88,38 @@ export const quizQuestionMarkdownUtils = {
}, },
parseMarkdown(input: string, questionIndex: number): LocalQuizQuestion { parseMarkdown(input: string, questionIndex: number): LocalQuizQuestion {
const lines = input const { points, lines } = splitLinesAndPoints(input.trim().split("\n"));
.trim()
.split("\n");
const firstLineIsPoints = lines[0].toLowerCase().includes("points: ");
const textHasPoints = const linesWithoutAnswers = getLinesBeforeAnswerLines(lines);
lines.length > 0 &&
lines[0].includes(": ") &&
lines[0].split(": ").length > 1 &&
!isNaN(parseFloat(lines[0].split(": ")[1]));
const points = const questionType = quizQuestionAnswerMarkdownUtils.getQuestionType(
firstLineIsPoints && textHasPoints lines,
? parseFloat(lines[0].split(": ")[1]) questionIndex
: 1;
const linesWithoutPoints = firstLineIsPoints ? lines.slice(1) : lines;
const { linesWithoutAnswers } = linesWithoutPoints.reduce(
({ linesWithoutAnswers, taking }, currentLine) => {
if (!taking)
return { linesWithoutAnswers: linesWithoutAnswers, taking: false };
const lineIsAnswer = isAnswerLine(currentLine);
if (lineIsAnswer)
return { linesWithoutAnswers: linesWithoutAnswers, taking: false };
return {
linesWithoutAnswers: [...linesWithoutAnswers, currentLine],
taking: true,
};
},
{ linesWithoutAnswers: [] as string[], taking: true }
); );
const questionType = getQuestionType(lines, questionIndex); const linesWithoutAnswersAndTypes = removeQuestionTypeFromDescriptionLines(
linesWithoutAnswers,
const questionTypesWithoutAnswers = [ questionType
"essay", );
"short answer",
"short_answer",
];
const descriptionLines = questionTypesWithoutAnswers.includes(questionType)
? linesWithoutAnswers
.slice(0, linesWithoutPoints.length)
.filter(
(line) => !questionTypesWithoutAnswers.includes(line.toLowerCase())
)
: linesWithoutAnswers;
const { const {
correctComments, correctComments,
incorrectComments, incorrectComments,
neutralComments, neutralComments,
otherLines: descriptionWithoutFeedback, otherLines: descriptionLines,
} = quizFeedbackMarkdownUtils.extractFeedback(descriptionLines); } = quizFeedbackMarkdownUtils.extractFeedback(linesWithoutAnswersAndTypes);
const typesWithAnswers = [ const { answers, distractors } = quizQuestionAnswerMarkdownUtils.getAnswers(
"multiple_choice", lines,
"multiple_answers", questionIndex,
"matching", questionType
"short_answer=", );
];
const answers = typesWithAnswers.includes(questionType)
? getAnswers(lines, questionIndex, questionType)
: [];
const answersWithoutDistractors =
questionType === QuestionType.MATCHING
? answers.filter((a) => a.text)
: answers;
const distractors =
questionType === QuestionType.MATCHING
? answers.filter((a) => !a.text).map((a) => a.matchedText ?? "")
: [];
const question: LocalQuizQuestion = { const question: LocalQuizQuestion = {
text: descriptionWithoutFeedback.join("\n"), text: descriptionLines.join("\n"),
questionType, questionType,
points, points,
answers: answersWithoutDistractors, answers,
matchDistractors: distractors, matchDistractors: distractors,
correctComments, correctComments,
incorrectComments, incorrectComments,