From d9f7e7b3e9d3a7ca5fdba9139e3a780040bfb4de Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Wed, 22 Oct 2025 12:26:28 -0600 Subject: [PATCH] can get exact answers --- .../quizMarkdown/numericalQuestion.test.ts | 28 +++ .../quizMarkdown/testAnswer.test.ts | 6 - .../local/quizzes/models/localQuizQuestion.ts | 1 + .../quizzes/models/localQuizQuestionAnswer.ts | 19 +- .../utils/quizQuestionAnswerMarkdownUtils.ts | 178 +++++++++++++++++- .../utils/quizQuestionAnswerParsingUtils.ts | 150 --------------- .../models/utils/quizQuestionMarkdownUtils.ts | 151 ++++++++------- 7 files changed, 291 insertions(+), 242 deletions(-) create mode 100644 src/features/local/parsingTests/quizMarkdown/numericalQuestion.test.ts delete mode 100644 src/features/local/quizzes/models/utils/quizQuestionAnswerParsingUtils.ts diff --git a/src/features/local/parsingTests/quizMarkdown/numericalQuestion.test.ts b/src/features/local/parsingTests/quizMarkdown/numericalQuestion.test.ts new file mode 100644 index 0000000..989b923 --- /dev/null +++ b/src/features/local/parsingTests/quizMarkdown/numericalQuestion.test.ts @@ -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); + }); +}); diff --git a/src/features/local/parsingTests/quizMarkdown/testAnswer.test.ts b/src/features/local/parsingTests/quizMarkdown/testAnswer.test.ts index 66eabe5..37d9c95 100644 --- a/src/features/local/parsingTests/quizMarkdown/testAnswer.test.ts +++ b/src/features/local/parsingTests/quizMarkdown/testAnswer.test.ts @@ -208,12 +208,6 @@ short_answer= 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", () => { const name = "Test Quiz"; const rawMarkdownQuiz = ` diff --git a/src/features/local/quizzes/models/localQuizQuestion.ts b/src/features/local/quizzes/models/localQuizQuestion.ts index 9024a5b..fa6a50b 100644 --- a/src/features/local/quizzes/models/localQuizQuestion.ts +++ b/src/features/local/quizzes/models/localQuizQuestion.ts @@ -12,6 +12,7 @@ export enum QuestionType { MATCHING = "matching", NONE = "", SHORT_ANSWER_WITH_ANSWERS = "short_answer=", + NUMERICAL = "numerical", } export const zodQuestionType = z.enum([ diff --git a/src/features/local/quizzes/models/localQuizQuestionAnswer.ts b/src/features/local/quizzes/models/localQuizQuestionAnswer.ts index 6762fa9..9e92b4f 100644 --- a/src/features/local/quizzes/models/localQuizQuestionAnswer.ts +++ b/src/features/local/quizzes/models/localQuizQuestionAnswer.ts @@ -1,13 +1,18 @@ import { z } from "zod"; -export interface LocalQuizQuestionAnswer { - correct: boolean; - text: string; - matchedText?: string; -} - export const zodLocalQuizQuestionAnswer = z.object({ correct: z.boolean(), text: z.string(), matchedText: z.string().optional(), -}); \ No newline at end of file + 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 +>; diff --git a/src/features/local/quizzes/models/utils/quizQuestionAnswerMarkdownUtils.ts b/src/features/local/quizzes/models/utils/quizQuestionAnswerMarkdownUtils.ts index bb6afe1..0367eb2 100644 --- a/src/features/local/quizzes/models/utils/quizQuestionAnswerMarkdownUtils.ts +++ b/src/features/local/quizzes/models/utils/quizQuestionAnswerMarkdownUtils.ts @@ -1,5 +1,29 @@ -import { QuestionType } from "../localQuizQuestion"; +import { LocalQuizQuestion, QuestionType } from "../localQuizQuestion"; 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 matchingPattern = /^\^?/; @@ -13,11 +37,51 @@ const parseMatchingAnswer = (input: string) => { 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]?\))|(? { + 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 = { + 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] === "*"; - if (questionType === QuestionType.MATCHING) { return parseMatchingAnswer(input); } @@ -35,4 +99,112 @@ export const quizQuestionAnswerMarkdownUtils = { }; 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}`; + } + }, }; diff --git a/src/features/local/quizzes/models/utils/quizQuestionAnswerParsingUtils.ts b/src/features/local/quizzes/models/utils/quizQuestionAnswerParsingUtils.ts deleted file mode 100644 index 7ded066..0000000 --- a/src/features/local/quizzes/models/utils/quizQuestionAnswerParsingUtils.ts +++ /dev/null @@ -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]?\))|(? { - 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}`; - } -}; diff --git a/src/features/local/quizzes/models/utils/quizQuestionMarkdownUtils.ts b/src/features/local/quizzes/models/utils/quizQuestionMarkdownUtils.ts index 2c83ee2..7aac245 100644 --- a/src/features/local/quizzes/models/utils/quizQuestionMarkdownUtils.ts +++ b/src/features/local/quizzes/models/utils/quizQuestionMarkdownUtils.ts @@ -1,16 +1,66 @@ import { LocalQuizQuestion, QuestionType } from "../localQuizQuestion"; import { quizFeedbackMarkdownUtils } from "./quizFeedbackMarkdownUtils"; -import { - getAnswerMarkdown, - getAnswers, - getQuestionType, - isAnswerLine, -} from "./quizQuestionAnswerParsingUtils"; +import { quizQuestionAnswerMarkdownUtils } from "./quizQuestionAnswerMarkdownUtils"; + +const splitLinesAndPoints = (input: string[]) => { + const firstLineIsPoints = input[0].toLowerCase().includes("points: "); + + 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 = { toMarkdown(question: LocalQuizQuestion): string { const answerArray = question.answers.map((a, i) => - getAnswerMarkdown(question, a, i) + quizQuestionAnswerMarkdownUtils.getAnswerMarkdown(question, a, i) ); const distractorText = @@ -38,89 +88,38 @@ export const quizQuestionMarkdownUtils = { }, parseMarkdown(input: string, questionIndex: number): LocalQuizQuestion { - const lines = input - .trim() - .split("\n"); - const firstLineIsPoints = lines[0].toLowerCase().includes("points: "); + const { points, lines } = splitLinesAndPoints(input.trim().split("\n")); - const textHasPoints = - lines.length > 0 && - lines[0].includes(": ") && - lines[0].split(": ").length > 1 && - !isNaN(parseFloat(lines[0].split(": ")[1])); + const linesWithoutAnswers = getLinesBeforeAnswerLines(lines); - const points = - firstLineIsPoints && textHasPoints - ? parseFloat(lines[0].split(": ")[1]) - : 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 = quizQuestionAnswerMarkdownUtils.getQuestionType( + lines, + questionIndex ); - const questionType = getQuestionType(lines, questionIndex); - - const questionTypesWithoutAnswers = [ - "essay", - "short answer", - "short_answer", - ]; - - const descriptionLines = questionTypesWithoutAnswers.includes(questionType) - ? linesWithoutAnswers - .slice(0, linesWithoutPoints.length) - .filter( - (line) => !questionTypesWithoutAnswers.includes(line.toLowerCase()) - ) - : linesWithoutAnswers; + const linesWithoutAnswersAndTypes = removeQuestionTypeFromDescriptionLines( + linesWithoutAnswers, + questionType + ); const { correctComments, incorrectComments, neutralComments, - otherLines: descriptionWithoutFeedback, - } = quizFeedbackMarkdownUtils.extractFeedback(descriptionLines); + otherLines: descriptionLines, + } = quizFeedbackMarkdownUtils.extractFeedback(linesWithoutAnswersAndTypes); - const typesWithAnswers = [ - "multiple_choice", - "multiple_answers", - "matching", - "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 { answers, distractors } = quizQuestionAnswerMarkdownUtils.getAnswers( + lines, + questionIndex, + questionType + ); const question: LocalQuizQuestion = { - text: descriptionWithoutFeedback.join("\n"), + text: descriptionLines.join("\n"), questionType, points, - answers: answersWithoutDistractors, + answers, matchDistractors: distractors, correctComments, incorrectComments,