From 6058e510de698cdc3d3c6540c851d271c3dba091 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Fri, 26 Jul 2024 10:29:36 +0100 Subject: [PATCH] Added the ability to generate custom level exams, still WIP in some parts --- src/components/Exercises/MultipleChoice.tsx | 16 +- src/components/Solutions/MultipleChoice.tsx | 16 +- src/pages/(generation)/LevelGeneration.tsx | 339 +++++++++++--- .../exam/[module]/generate/[...endpoint].ts | 2 - src/pages/api/exam/[module]/generate/level.ts | 35 ++ yarn.lock | 427 +++++++++--------- 6 files changed, 535 insertions(+), 300 deletions(-) create mode 100644 src/pages/api/exam/[module]/generate/level.ts diff --git a/src/components/Exercises/MultipleChoice.tsx b/src/components/Exercises/MultipleChoice.tsx index 9cfd780b..c00222c9 100644 --- a/src/components/Exercises/MultipleChoice.tsx +++ b/src/components/Exercises/MultipleChoice.tsx @@ -3,6 +3,7 @@ import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam" import useExamStore from "@/stores/examStore"; import clsx from "clsx"; import {useEffect, useState} from "react"; +import reactStringReplace from "react-string-replace"; import {CommonProps} from "."; import Button from "../Low/Button"; @@ -14,13 +15,24 @@ function Question({ userSolution, onSelectOption, }: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) { + const renderPrompt = (prompt: string) => { + return reactStringReplace(prompt, /(()\w+(<\/u>))/g, (match) => { + const word = match.replaceAll("", "").replaceAll("", ""); + console.log(word); + + return word.length > 0 ? {word} : null; + }); + }; + return (
{isNaN(Number(id)) ? ( - {prompt} + {renderPrompt(prompt).filter((x) => x?.toString() !== "")} ) : ( - {id} - {prompt} + <> + {id} - {renderPrompt(prompt).filter((x) => x?.toString() !== "")} + )}
diff --git a/src/components/Solutions/MultipleChoice.tsx b/src/components/Solutions/MultipleChoice.tsx index ec5667db..bab2d45b 100644 --- a/src/components/Solutions/MultipleChoice.tsx +++ b/src/components/Solutions/MultipleChoice.tsx @@ -3,6 +3,7 @@ import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam" import useExamStore from "@/stores/examStore"; import clsx from "clsx"; import {useEffect, useState} from "react"; +import reactStringReplace from "react-string-replace"; import {CommonProps} from "."; import Button from "../Low/Button"; @@ -14,6 +15,15 @@ function Question({ options, userSolution, }: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) { + const renderPrompt = (prompt: string) => { + return reactStringReplace(prompt, /(()\w+(<\/u>))/g, (match) => { + const word = match.replaceAll("", "").replaceAll("", ""); + console.log(word); + + return word.length > 0 ? {word} : null; + }); + }; + const optionColor = (option: string) => { if (option === solution && !userSolution) { return "!border-mti-gray-davy !text-mti-gray-davy"; @@ -29,10 +39,12 @@ function Question({ return (
{isNaN(Number(id)) ? ( - {prompt} + {renderPrompt(prompt).filter((x) => x?.toString() !== "")} ) : ( - {id} - {prompt} + <> + {id} - {renderPrompt(prompt).filter((x) => x?.toString() !== "")} + )}
diff --git a/src/pages/(generation)/LevelGeneration.tsx b/src/pages/(generation)/LevelGeneration.tsx index fb692173..55aa0efb 100644 --- a/src/pages/(generation)/LevelGeneration.tsx +++ b/src/pages/(generation)/LevelGeneration.tsx @@ -1,5 +1,14 @@ +import Input from "@/components/Low/Input"; import Select from "@/components/Low/Select"; -import {Difficulty, LevelExam, MultipleChoiceExercise, MultipleChoiceQuestion, LevelPart} from "@/interfaces/exam"; +import { + Difficulty, + LevelExam, + MultipleChoiceExercise, + MultipleChoiceQuestion, + LevelPart, + FillBlanksExercise, + WriteBlanksExercise, +} from "@/interfaces/exam"; import useExamStore from "@/stores/examStore"; import {getExamById} from "@/utils/exams"; import {playSound} from "@/utils/sound"; @@ -8,22 +17,43 @@ import axios from "axios"; import clsx from "clsx"; import {capitalize, sample} from "lodash"; import {useRouter} from "next/router"; -import {useState} from "react"; +import {useEffect, useState} from "react"; import {BsArrowRepeat, BsCheck, BsPencilSquare, BsX} from "react-icons/bs"; +import reactStringReplace from "react-string-replace"; import {toast} from "react-toastify"; import {v4} from "uuid"; const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"]; +const TYPES: {[key: string]: string} = { + multiple_choice_4: "Multiple Choice", + multiple_choice_blank_space: "Multiple Choice - Blank Space", + multiple_choice_underlined: "Multiple Choice - Underlined", + blank_space_text: "Blank Space", + reading_passage_utas: "Reading Passage", +}; + +type LevelSection = {type: string; quantity: number; topic?: string; part?: LevelPart}; const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion; onUpdate: (question: MultipleChoiceQuestion) => void}) => { const [isEditing, setIsEditing] = useState(false); const [options, setOptions] = useState(question.options); const [answer, setAnswer] = useState(question.solution); + const renderPrompt = (prompt: string) => { + return reactStringReplace(prompt, /(()\w+(<\/u>))/g, (match) => { + const word = match.replaceAll("", "").replaceAll("", ""); + console.log(word); + + return word.length > 0 ? {word} : null; + }); + }; + return (
- {question.id}. {question.prompt}{" "} + <> + {question.id}. {renderPrompt(question.prompt).filter((x) => x?.toString() !== "")} +
{question.options.map((option, index) => ( @@ -75,61 +105,51 @@ const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion ); }; -const TaskTab = ({exam, difficulty, setExam}: {exam?: LevelPart; difficulty: Difficulty; setExam: (exam: LevelPart) => void}) => { +const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (section: LevelSection) => void}) => { const [isLoading, setIsLoading] = useState(false); - const generate = () => { - const url = new URLSearchParams(); - url.append("difficulty", difficulty); - - setIsLoading(true); - axios - .get(`/api/exam/level/generate/level?${url.toString()}`) - .then((result) => { - playSound(typeof result.data === "string" ? "error" : "check"); - if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again."); - setExam(result.data); - }) - .catch((error) => { - console.log(error); - toast.error("Something went wrong!"); - }) - .finally(() => setIsLoading(false)); - }; - const onUpdate = (question: MultipleChoiceQuestion) => { - if (!exam) return; + if (!section) return; const updatedExam = { - ...exam, - exercises: exam.exercises.map((x) => ({ + ...section, + exercises: section.part?.exercises.map((x) => ({ ...x, questions: (x as MultipleChoiceExercise).questions.map((q) => (q.id === question.id ? question : q)), })), }; console.log(updatedExam); - setExam(updatedExam as any); + setSection(updatedExam as any); }; return ( -
- +
+
+
+ + setSection({...section, quantity: parseInt(v)})} + value={section?.quantity || 10} + /> +
+
+ {section?.type === "reading_passage_utas" && ( +
+ + setSection({...section, topic: v})} value={section?.topic} /> +
+ )}
{isLoading && (
@@ -137,9 +157,9 @@ const TaskTab = ({exam, difficulty, setExam}: {exam?: LevelPart; difficulty: Dif Generating...
)} - {exam && ( + {section?.part && (
- {exam.exercises + {section.part.exercises .filter((x) => x.type === "multipleChoice") .map((ex) => { const exercise = ex as MultipleChoiceExercise; @@ -152,6 +172,7 @@ const TaskTab = ({exam, difficulty, setExam}: {exam?: LevelPart; difficulty: Dif {exercise.questions.length} questions
+ {exercise.prompt}
{exercise.questions.map((question) => ( @@ -167,10 +188,17 @@ const TaskTab = ({exam, difficulty, setExam}: {exam?: LevelPart; difficulty: Dif }; const LevelGeneration = () => { - const [generatedExam, setGeneratedExam] = useState(); + const [generatedExam, setGeneratedExam] = useState(); const [isLoading, setIsLoading] = useState(false); const [resultingExam, setResultingExam] = useState(); + const [timer, setTimer] = useState(10); const [difficulty, setDifficulty] = useState(sample(DIFFICULTIES)!); + const [numberOfParts, setNumberOfParts] = useState(1); + const [parts, setParts] = useState([{quantity: 10, type: "multiple_choice_4"}]); + + useEffect(() => { + setParts((prev) => Array.from(Array(numberOfParts)).map((_, i) => (!!prev.at(i) ? prev.at(i)! : {quantity: 10, type: "multiple_choice_4"}))); + }, [numberOfParts]); const router = useRouter(); @@ -193,6 +221,152 @@ const LevelGeneration = () => { router.push("/exercises"); }; + const generateExam = () => { + if (parts.length === 0) return; + setIsLoading(true); + + let body: any = {}; + parts.forEach((part, index) => { + body[`exercise_${index + 1}_type`] = part.type; + body[`exercise_${index + 1}_qty`] = part.quantity; + + if (part.topic) body[`exercise_${index + 1}_topic`] = part.topic; + if (part.type === "reading_passage_utas") { + body[`exercise_${index + 1}_sa_qty`] = Math.floor(part.quantity / 2); + body[`exercise_${index + 1}_mc_qty`] = Math.ceil(part.quantity / 2); + } + }); + + axios + .post<{exercises: {[key: string]: any}}>("/api/exam/level/generate/level", {nr_exercises: numberOfParts, ...body}) + .then((result) => { + playSound(typeof result.data === "string" ? "error" : "check"); + if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again."); + + const exam: LevelExam = { + id: v4(), + minTimer: timer, + module: "level", + difficulty, + variant: "full", + isDiagnostic: true, + parts: parts + .map((part, index) => { + const currentExercise = result.data.exercises[`exercise_${index + 1}`] as any; + + if ( + part.type === "multiple_choice_4" || + part.type === "multiple_choice_blank_space" || + part.type === "multiple_choice_underlined" + ) { + const exercise: MultipleChoiceExercise = { + id: v4(), + prompt: + part.type === "multiple_choice_underlined" + ? "Select the wrong part of the sentence." + : "Select the appropriate option.", + questions: currentExercise.questions, + type: "multipleChoice", + userSolutions: [], + }; + + setParts((prev) => + prev.map((p, i) => + i === index + ? { + ...p, + part: { + exercises: [exercise], + }, + } + : p, + ), + ); + + return { + exercises: [exercise], + }; + } + + if (part.type === "blank_space_text") { + const exercise: FillBlanksExercise = { + id: v4(), + prompt: "Complete the summary below. Click a blank to select the corresponding word for it.", + allowRepetition: false, + text: currentExercise.text, + words: currentExercise.words.map((x: any) => x.text), + solutions: currentExercise.words.map((x: any) => ({id: x.id, solution: x.text})), + type: "fillBlanks", + userSolutions: [], + }; + + setParts((prev) => + prev.map((p, i) => + i === index + ? { + ...p, + part: { + exercises: [exercise], + }, + } + : p, + ), + ); + + return { + exercises: [exercise], + }; + } + + const mcExercise: MultipleChoiceExercise = { + id: v4(), + prompt: "Select the appropriate option.", + questions: currentExercise.exercises.multipleChoice, + type: "multipleChoice", + userSolutions: [], + }; + + const wbExercise: WriteBlanksExercise = { + id: v4(), + prompt: "Complete the notes below.", + maxWords: 3, + text: currentExercise.exercises.shortAnswer.map((x: any) => `${x.question} {{${x.id}}}`).join("\n"), + solutions: currentExercise.exercises.shortAnswer.map((x: any) => ({ + id: x.id, + solution: x.possible_answers, + })), + type: "writeBlanks", + userSolutions: [], + }; + + setParts((prev) => + prev.map((p, i) => + i === index + ? { + ...p, + part: { + context: currentExercise.text.content, + exercises: [mcExercise, wbExercise], + }, + } + : p, + ), + ); + + return { + context: currentExercise.text.content, + exercises: [mcExercise, wbExercise], + }; + }) + .filter((x) => !!x) as LevelPart[], + }; + + console.log(exam); + setGeneratedExam(exam); + }) + .finally(() => setIsLoading(false)); + }; + const submitExam = () => { if (!generatedExam) { toast.error("Please generate all tasks before submitting"); @@ -201,16 +375,8 @@ const LevelGeneration = () => { setIsLoading(true); - const exam: LevelExam = { - isDiagnostic: false, - minTimer: 25, - module: "level", - id: v4(), - parts: [generatedExam], - }; - axios - .post(`/api/exam/level`, exam) + .post(`/api/exam/level`, generatedExam) .then((result) => { playSound("sent"); console.log(`Generated Exam ID: ${result.data.id}`); @@ -228,7 +394,7 @@ const LevelGeneration = () => { return ( <> -
+
setNumberOfParts(parseInt(v))} value={numberOfParts} /> +
+
+ + setTimer(parseInt(v))} value={timer} /> +
- - clsx( - "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-level/70", - "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-level focus:outline-none focus:ring-2", - "transition duration-300 ease-in-out", - selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-level", - ) - }> - Exam - + {Array.from(Array(numberOfParts), (_, index) => index).map((index) => ( + + clsx( + "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-level/70", + "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-level focus:outline-none focus:ring-2", + "transition duration-300 ease-in-out", + selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-level", + ) + }> + Part {index + 1} + + ))} - + {Array.from(Array(numberOfParts), (_, index) => index).map((index) => ( + setParts((prev) => prev.map((x, i) => (i === index ? part : x)))} + /> + ))}
@@ -272,6 +455,24 @@ const LevelGeneration = () => { Perform Exam )} +