Files
encoach_frontend/src/pages/(generation)/LevelGeneration.tsx
Carlos Mesquita d8bf10eaea Small bug fix
2024-09-06 11:21:35 +01:00

761 lines
26 KiB
TypeScript

import FillBlanksEdit from "@/components/Generation/fill.blanks.edit";
import MultipleChoiceEdit from "@/components/Generation/multiple.choice.edit";
import WriteBlankEdits from "@/components/Generation/write.blanks.edit";
import Checkbox from "@/components/Low/Checkbox";
import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select";
import {
Difficulty,
LevelExam,
MultipleChoiceExercise,
MultipleChoiceQuestion,
LevelPart,
FillBlanksExercise,
WriteBlanksExercise,
Exercise,
} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import { getExamById } from "@/utils/exams";
import { playSound } from "@/utils/sound";
import { Tab } from "@headlessui/react";
import axios from "axios";
import clsx from "clsx";
import { capitalize, sample } from "lodash";
import { useRouter } from "next/router";
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";
interface Option {
[key: string]: any;
value: string | null;
label: string;
}
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",
fill_blanks_mc: "Multiple Choice - Fill Blanks",
};
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, /((<u>)[\w\s']+(<\/u>))/g, (match) => {
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
return word.length > 0 ? <u>{word}</u> : null;
});
};
return (
<div key={question.id} className="flex flex-col gap-1">
<span className="font-semibold">
<>
{question.id}. <span>{renderPrompt(question.prompt).filter((x) => x?.toString() !== "<u>")} </span>
</>
</span>
<div className="flex flex-col gap-1">
{question.options.map((option, index) => (
<span key={option.id} className={clsx(answer === option.id && "font-bold")}>
<span
className={clsx("font-semibold", answer === option.id ? "text-mti-green-light" : "text-ielts-level")}
onClick={() => setAnswer(option.id)}>
({option.id})
</span>{" "}
{isEditing ? (
<input
defaultValue={option.text}
className="w-60"
onChange={(e) => setOptions((prev) => prev.map((x, idx) => (idx === index ? { ...x, text: e.target.value } : x)))}
/>
) : (
<span>{option.text}</span>
)}
</span>
))}
</div>
<div className="flex gap-2 mt-2 w-full">
{!isEditing && (
<button
onClick={() => setIsEditing(true)}
className="p-2 border border-neutral-300 bg-white rounded-xl hover:drop-shadow transition ease-in-out duration-300">
<BsPencilSquare />
</button>
)}
{isEditing && (
<>
<button
onClick={() => {
onUpdate({ ...question, options, solution: answer });
setIsEditing(false);
}}
className="p-2 border border-neutral-300 bg-white rounded-xl hover:drop-shadow transition ease-in-out duration-300">
<BsCheck />
</button>
<button
onClick={() => setIsEditing(false)}
className="p-2 border border-neutral-300 bg-white rounded-xl hover:drop-shadow transition ease-in-out duration-300">
<BsX />
</button>
</>
)}
</div>
</div>
);
};
const TaskTab = ({ section, label, index, setSection }: { section: LevelSection; label: string, index: number, setSection: (section: LevelSection) => void }) => {
const [isLoading, setIsLoading] = useState(false);
const [category, setCategory] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [customDescription, setCustomDescription] = useState<string>("");
const [previousOption, setPreviousOption] = useState<Option>({ value: "None", label: "None" });
const [descriptionOption, setDescriptionOption] = useState<Option>({ value: "None", label: "None" });
const [updateIntro, setUpdateIntro] = useState<boolean>(false);
const onUpdate = (question: MultipleChoiceQuestion) => {
if (!section) return;
const updatedExam = {
...section,
exercises: section.part?.exercises.map((x) => ({
...x,
questions: (x as MultipleChoiceExercise).questions.map((q) => (q.id === question.id ? question : q)),
})),
};
setSection(updatedExam as any);
};
const defaultPresets: any = {
multiple_choice_4: "Welcome to {part} of the {label}. In this section, you'll be asked to select the correct word or group of words that best completes each sentence.\n\nFor each question, carefully read the sentence and click on the option (A, B, C, or D) that you believe is correct. After making your selection, you can proceed to the next question by clicking \"Next\". If you need to review or change your previous answers, you can go back at any time by clicking \"Back\".",
multiple_choice_blank_space: undefined,
multiple_choice_underlined: "Welcome to {part} of the {label}. In this section, you'll be asked to identify the underlined word or group of words that is not correct in each sentence.\n\nFor each question, carefully review the sentence and click on the option (A, B, C, or D) that you believe contains the incorrect word or group of words. After making your selection, you can proceed to the next question by clicking \"Next\". If needed, you can go back to previous questions by clicking \"Back\".",
blank_space_text: undefined,
reading_passage_utas: "Welcome to {part} of the {label}. In this section, you will read a text and answer the questions that follow.\n\nCarefully read the provided text, then select the correct answer (A, B, C, or D) for each question. After making your selection, you can proceed to the next question by clicking \"Next\". If you need to review or change your answers, you can go back at any time by clicking \"Back\".",
fill_blanks_mc: "Welcome to {part} of the {label}. In this section, you will read a text and choose the correct word to fill in each blank space.\n\nFor each question, carefully read the text and click on the option that you believe best fits the context."
};
const getDefaultPreset = () => {
return defaultPresets[section.type] ? defaultPresets[section.type].replace('{part}', `Part ${index + 1}`).replace('{label}', label) :
"No default preset is yet available for this type of exercise."
}
useEffect(() => {
if (descriptionOption.value === "Default" && section?.type) {
setDescription(getDefaultPreset())
}
if (descriptionOption.value === "Custom" && customDescription !== "") {
setDescription(customDescription);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [descriptionOption, section?.type, label])
useEffect(() => {
if (section?.type) {
const defaultPreset = getDefaultPreset();
if (descriptionOption.value === "Default" && previousOption.value === "Default" && description !== defaultPreset) {
setDescriptionOption({ value: "Custom", label: "Custom" });
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [descriptionOption, description, label])
useEffect(() => {
setPreviousOption(descriptionOption);
}, [descriptionOption])
useEffect(() => {
if (section?.part && ((descriptionOption.value === "Custom" || descriptionOption.value === "Default") && !section.part.intro)) {
setUpdateIntro(true);
}
}, [section?.part, descriptionOption, category])
useEffect(() => {
if (updateIntro && section.part) {
setSection({
...section,
part: {
...section.part!,
intro: descriptionOption.value === "Default" ? getDefaultPreset() : (descriptionOption.value === "Custom" ? customDescription : undefined),
category: category === "" ? undefined : category
}
})
setUpdateIntro(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [updateIntro, section?.part])
const renderExercise = (exercise: Exercise) => {
if (exercise.type === "multipleChoice")
return (
<div key={exercise.id} className="w-full h-full flex flex-col gap-2">
<div className="flex gap-2">
<span className="text-xl font-semibold">Multiple Choice</span>
<span className="rounded-xl bg-white border border-ielts-level p-1 px-4 w-fit">{exercise.questions.length} questions</span>
</div>
<MultipleChoiceEdit
exercise={exercise}
key={exercise.id}
updateExercise={(data: any) =>
setSection({
...section,
part: {
...section.part!,
exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? { ...x, ...data } : x)),
intro: descriptionOption.value === "Default" ? getDefaultPreset() : (descriptionOption.value === "Custom" ? customDescription : undefined),
category: category === "" ? undefined : category
}
})
}
/>
</div>
);
if (exercise.type === "fillBlanks")
return (
<div key={exercise.id} className="w-full h-full flex flex-col gap-2">
<div className="flex gap-2">
<span className="text-xl font-semibold">Fill Blanks</span>
</div>
<span>{exercise.prompt}</span>
<FillBlanksEdit
exercise={exercise}
key={exercise.id}
updateExercise={(data: any) =>
setSection({
...section,
part: {
...section.part!,
exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? { ...x, ...data } : x)),
intro: descriptionOption.value === "Default" ? getDefaultPreset() : (descriptionOption.value === "Custom" ? customDescription : undefined),
category: category === "" ? undefined : category
}
})
}
/>
</div>
);
if (exercise.type === "writeBlanks")
return (
<div key={exercise.id} className="w-full h-full flex flex-col gap-2">
<div className="flex gap-2">
<span className="text-xl font-semibold">Write Blanks</span>
</div>
<span>{exercise.prompt}</span>
<WriteBlankEdits
exercise={exercise}
key={exercise.id}
updateExercise={(data: any) =>
setSection({
...section,
part: {
...section.part!,
exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? { ...x, ...data } : x)),
intro: descriptionOption.value === "Default" ? getDefaultPreset() : (descriptionOption.value === "Custom" ? customDescription : undefined),
category: category === "" ? undefined : category
},
})
}
/>
</div>
);
};
return (
<Tab.Panel className="w-full bg-ielts-level/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
<div className="flex flex-col gap-8">
<div className="flex flex-row w-full gap-4">
<div className="flex flex-col gap-3 w-1/2">
<label className="font-normal text-base text-mti-gray-dim">Description</label>
<Select
options={["None", "Default", "Custom"].map((descriptionOption) => ({ value: descriptionOption, label: descriptionOption }))}
onChange={(o) => setDescriptionOption({ value: o!.value, label: o!.label })}
value={descriptionOption}
/>
</div>
<div className="flex flex-col gap-3 w-1/2">
<label className="font-normal text-base text-mti-gray-dim">Category</label>
<Input
type="text"
placeholder="Category"
name="category"
onChange={(e) => setCategory(e)}
roundness="full"
defaultValue={category}
/>
</div>
</div>
{descriptionOption.value !== "None" && (
<Input
type="textarea"
placeholder="Part Description"
name="category"
onChange={(e) => { setDescription(e); setCustomDescription(e); }}
roundness="full"
value={descriptionOption.value === "Default" ? description : customDescription}
/>
)}
<div className="flex gap-4 w-full">
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Exercise Type</label>
<Select
options={Object.keys(TYPES).map((key) => ({ value: key, label: TYPES[key] }))}
onChange={(e) => setSection({ ...section, type: e!.value! })}
value={{ value: section?.type || "multiple_choice_4", label: TYPES[section?.type || "multiple_choice_4"] }}
/>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">{section?.type && section.type === "fill_blanks_mc" ? "Number of Words" : "Number of Questions"}</label>
<Input
type="number"
name="Number of Questions"
onChange={(v) => setSection({ ...section, quantity: parseInt(v) })}
value={section?.quantity || 10}
/>
</div>
</div>
{section?.type === "reading_passage_utas" || section?.type === "fill_blanks_mc" && (
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Topic</label>
<Input type="text" name="Topic" onChange={(v) => setSection({ ...section, topic: v })} value={section?.topic} />
</div>
)}
</div>
{isLoading && (
<div className="w-fit h-fit mt-12 self-center animate-pulse flex flex-col gap-8 items-center">
<span className={clsx("loading loading-infinity w-32 text-ielts-level")} />
<span className={clsx("font-bold text-2xl text-ielts-level")}>Generating...</span>
</div>
)}
{section?.part && (
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide h-full">
{section.part.context && <div>{section.part.context}</div>}
{section.part.exercises.map(renderExercise)}
</div>
)}
</Tab.Panel>
);
};
interface Props {
id: string;
}
const LevelGeneration = ({ id }: Props) => {
const [generatedExam, setGeneratedExam] = useState<LevelExam>();
const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<LevelExam>();
const [timer, setTimer] = useState(10);
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
const [numberOfParts, setNumberOfParts] = useState(1);
const [parts, setParts] = useState<LevelSection[]>([{ quantity: 10, type: "multiple_choice_4" }]);
const [isPrivate, setPrivate] = useState<boolean>(false);
const [label, setLabel] = useState<string>("Placement Test");
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();
const setExams = useExamStore((state) => state.setExams);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const loadExam = async (examId: string) => {
const exam = await getExamById("level", examId.trim());
if (!exam) {
toast.error("Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID", {
toastId: "invalid-exam-id",
});
return;
}
setExams([exam]);
setSelectedModules(["level"]);
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);
}
});
let newParts = [...parts];
axios
.post<{ exercises: { [key: string]: any } }>("/api/exam/level/generate/level", { nr_exercises: numberOfParts, ...body })
.then((result) => {
console.log(result.data);
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: false,
private: isPrivate,
label: label,
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"
? "Choose the underlined word or group of words that is not correct.\nFor each question, select your choice (A, B, C or D)."
: "Choose the correct word or group of words that completes the sentences below.\nFor each question, select the correct letter (A, B, C or D).",
questions: currentExercise.questions.map((x: any) => ({ ...x, variant: "text" })),
type: "multipleChoice",
userSolutions: [],
};
const item = {
exercises: [exercise],
intro: parts[index].part?.intro,
category: parts[index].part?.category
};
newParts = newParts.map((p, i) =>
i === index
? {
...p,
part: item,
}
: p,
);
return item;
}
if (part.type === "fill_blanks_mc") {
const exercise: FillBlanksExercise = {
id: v4(),
prompt: "Read the text below and choose the correct word for each space.\nFor each question, select your choice (A, B, C or D). ",
text: currentExercise.text,
words: currentExercise.words,
solutions: currentExercise.solutions,
type: "fillBlanks",
variant: "mc",
userSolutions: [],
};
const item = {
exercises: [exercise],
intro: parts[index].part?.intro,
category: parts[index].part?.category
};
newParts = newParts.map((p, i) =>
i === index
? {
...p,
part: item,
}
: p,
);
return item;
}
if (part.type === "blank_space_text") {
const exercise: WriteBlanksExercise = {
id: v4(),
prompt: "Complete the text below.",
text: currentExercise.text,
maxWords: 3,
solutions: currentExercise.words.map((x: any) => ({ id: x.id, solution: [x.text] })),
type: "writeBlanks",
userSolutions: [],
};
const item = {
exercises: [exercise],
intro: parts[index].part?.intro,
category: parts[index].part?.category
};
newParts = newParts.map((p, i) =>
i === index
? {
...p,
part: item,
}
: p,
);
return item;
}
const mcExercise: MultipleChoiceExercise = {
id: v4(),
prompt: "Select the appropriate option.",
questions: currentExercise.exercises.multipleChoice.questions.map((x: any) => ({ ...x, variant: "text" })),
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: [],
};
const item = {
context: currentExercise.text.content,
exercises: [mcExercise, wbExercise],
intro: parts[index].part?.intro,
category: parts[index].part?.category
};
newParts = newParts.map((p, i) =>
i === index
? {
...p,
part: item,
}
: p,
);
return item;
})
.filter((x) => !!x) as LevelPart[],
};
setParts(newParts);
setGeneratedExam(exam);
})
.catch((error) => {
console.log(error);
playSound("error");
toast.error("Something went wrong, please try again later!");
})
.finally(() => setIsLoading(false));
};
const submitExam = () => {
if (!generatedExam) {
toast.error("Please generate all tasks before submitting");
return;
}
if (!id) {
toast.error("Please insert a title before submitting");
return;
}
setIsLoading(true);
parts.forEach((part) => {
part.part!.exercises.forEach((exercise, i) => {
switch(exercise.type) {
case 'fillBlanks':
exercise.prompt.replaceAll('\n', '\\n')
break;
case 'multipleChoice':
exercise.prompt.replaceAll('\n', '\\n')
break;
case 'writeBlanks':
exercise.prompt.replaceAll('\n', '\\n')
break;
}
});
})
const exam = {
...generatedExam,
id,
label: label,
parts: generatedExam.parts.map((p, i) => ({ ...p, exercises: parts[i].part!.exercises, category: parts[i].part?.category, intro: parts[i].part?.intro?.replaceAll('\n', '\\n') })),
};
axios
.post(`/api/exam/level`, exam)
.then((result) => {
playSound("sent");
console.log(`Generated Exam ID: ${result.data.id}`);
toast.success("This new exam has been generated successfully! Check the ID in our browser's console.");
setResultingExam(result.data);
setGeneratedExam(undefined);
})
.catch((error) => {
console.log(error);
toast.error("Something went wrong while generating, please try again later.");
})
.finally(() => setIsLoading(false));
};
return (
<>
<div className="flex gap-4 w-full items-center">
<div className="flex flex-col gap-3 w-1/2">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
options={DIFFICULTIES.map((x) => ({
value: x,
label: capitalize(x),
}))}
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
value={{ value: difficulty, label: capitalize(difficulty) }}
/>
</div>
<div className="flex flex-col gap-3 w-1/3">
<label className="font-normal text-base text-mti-gray-dim">Number of Parts</label>
<Input type="number" name="Number of Parts" onChange={(v) => setNumberOfParts(parseInt(v))} value={numberOfParts} />
</div>
<div className="flex flex-col gap-3 w-1/3">
<label className="font-normal text-base text-mti-gray-dim">Timer (in minutes)</label>
<Input type="number" name="Timer (in minutes)" onChange={(v) => setTimer(parseInt(v))} value={timer} />
</div>
<div className="flex flex-col gap-3 w-fit h-fit">
<div className="h-6" />
<Checkbox isChecked={isPrivate} onChange={setPrivate}>
Privacy (Only available for Assignments)
</Checkbox>
</div>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Exam Label *</label>
<Input
type="text"
placeholder="Label"
name="label"
onChange={(e) => setLabel(e)}
roundness="xl"
defaultValue={label}
required
/>
</div>
<Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
{Array.from(Array(numberOfParts), (_, index) => index).map((index) => (
<Tab
key={index}
className={({ selected }) =>
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}
</Tab>
))}
</Tab.List>
<Tab.Panels>
{Array.from(Array(numberOfParts), (_, index) => index).map((index) => (
<TaskTab
key={index}
label={label}
index={index}
section={parts[index]}
setSection={(part) => {
console.log(part);
setParts((prev) => prev.map((x, i) => (i === index ? part : x)));
}}
/>
))}
</Tab.Panels>
</Tab.Group>
<div className="w-full flex justify-end gap-4">
{resultingExam && (
<button
disabled={isLoading}
onClick={() => loadExam(resultingExam.id)}
className={clsx(
"bg-white border border-ielts-level text-ielts-level w-full max-w-[200px] rounded-xl h-[70px] self-end",
"hover:bg-ielts-level hover:text-white disabled:bg-ielts-level/40 disabled:cursor-not-allowed",
"transition ease-in-out duration-300",
)}>
Perform Exam
</button>
)}
<button
disabled={parts.length === 0 || isLoading}
data-tip="Please generate all three passages"
onClick={generateExam}
className={clsx(
"bg-ielts-level/70 border border-ielts-level text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
"hover:bg-ielts-level disabled:bg-ielts-level/40 disabled:cursor-not-allowed",
"transition ease-in-out duration-300",
parts.length === 0 && "tooltip",
)}>
{isLoading ? (
<div className="flex items-center justify-center">
<BsArrowRepeat className="text-white animate-spin" size={25} />
</div>
) : (
"Generate"
)}
</button>
<button
disabled={!generatedExam || isLoading}
data-tip="Please generate all three passages"
onClick={submitExam}
className={clsx(
"bg-ielts-level/70 border border-ielts-level text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
"hover:bg-ielts-level disabled:bg-ielts-level/40 disabled:cursor-not-allowed",
"transition ease-in-out duration-300",
!generatedExam && "tooltip",
)}>
{isLoading ? (
<div className="flex items-center justify-center">
<BsArrowRepeat className="text-white animate-spin" size={25} />
</div>
) : (
"Submit"
)}
</button>
</div>
</>
);
};
export default LevelGeneration;