Added the ability to generate custom level exams, still WIP in some parts
This commit is contained in:
@@ -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, /((<u>)\w+(<\/u>))/g, (match) => {
|
||||
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
|
||||
console.log(word);
|
||||
|
||||
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}. {question.prompt}{" "}
|
||||
<>
|
||||
{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) => (
|
||||
@@ -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 (
|
||||
<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 gap-4 items-end">
|
||||
<button
|
||||
onClick={generate}
|
||||
disabled={isLoading}
|
||||
className={clsx(
|
||||
"bg-ielts-level/70 border border-ielts-level text-white w-full px-6 py-6 rounded-xl h-[70px]",
|
||||
"hover:bg-ielts-level disabled:bg-ielts-level/40 disabled:cursor-not-allowed",
|
||||
"transition ease-in-out duration-300",
|
||||
)}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||
</div>
|
||||
) : (
|
||||
"Generate"
|
||||
)}
|
||||
</button>
|
||||
<div className="flex flex-col gap-4">
|
||||
<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">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" && (
|
||||
<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">
|
||||
@@ -137,9 +157,9 @@ const TaskTab = ({exam, difficulty, setExam}: {exam?: LevelPart; difficulty: Dif
|
||||
<span className={clsx("font-bold text-2xl text-ielts-level")}>Generating...</span>
|
||||
</div>
|
||||
)}
|
||||
{exam && (
|
||||
{section?.part && (
|
||||
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide h-full">
|
||||
{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
|
||||
</span>
|
||||
</div>
|
||||
<span>{exercise.prompt}</span>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{exercise.questions.map((question) => (
|
||||
<QuestionDisplay question={question} onUpdate={onUpdate} key={question.id} />
|
||||
@@ -167,10 +188,17 @@ const TaskTab = ({exam, difficulty, setExam}: {exam?: LevelPart; difficulty: Dif
|
||||
};
|
||||
|
||||
const LevelGeneration = () => {
|
||||
const [generatedExam, setGeneratedExam] = useState<LevelPart>();
|
||||
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"}]);
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div className="flex gap-4 w-1/2">
|
||||
<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">Difficulty</label>
|
||||
<Select
|
||||
@@ -240,23 +406,40 @@ const LevelGeneration = () => {
|
||||
value={{value: difficulty, label: capitalize(difficulty)}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<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-full">
|
||||
<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>
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
|
||||
<Tab
|
||||
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",
|
||||
)
|
||||
}>
|
||||
Exam
|
||||
</Tab>
|
||||
{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>
|
||||
<TaskTab difficulty={difficulty} exam={generatedExam} setExam={setGeneratedExam} />
|
||||
{Array.from(Array(numberOfParts), (_, index) => index).map((index) => (
|
||||
<TaskTab
|
||||
key={index}
|
||||
section={parts[index]}
|
||||
setSection={(part) => setParts((prev) => prev.map((x, i) => (i === index ? part : x)))}
|
||||
/>
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
<div className="w-full flex justify-end gap-4">
|
||||
@@ -272,6 +455,24 @@ const LevelGeneration = () => {
|
||||
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"
|
||||
|
||||
@@ -23,7 +23,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) return res.status(401).json({ok: false});
|
||||
if (req.session.user.type !== "developer") return res.status(403).json({ok: false});
|
||||
|
||||
const {endpoint, topic, exercises, difficulty} = req.query as {
|
||||
module: Module;
|
||||
@@ -48,7 +47,6 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) return res.status(401).json({ok: false});
|
||||
if (req.session.user.type !== "developer") return res.status(403).json({ok: false});
|
||||
|
||||
const {endpoint, topic, exercises} = req.query as {module: Module; endpoint: string[]; topic?: string; exercises?: string[]};
|
||||
const url = `${process.env.BACKEND_URL}/${endpoint.join("/")}`;
|
||||
|
||||
35
src/pages/api/exam/[module]/generate/level.ts
Normal file
35
src/pages/api/exam/[module]/generate/level.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type {NextApiRequest, NextApiResponse} from "next";
|
||||
import {app} from "@/firebase";
|
||||
import {getFirestore, collection, getDocs, query, where} from "firebase/firestore";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {shuffle} from "lodash";
|
||||
import {Difficulty, Exam} from "@/interfaces/exam";
|
||||
import {Stat} from "@/interfaces/user";
|
||||
import {Module} from "@/interfaces";
|
||||
import axios from "axios";
|
||||
|
||||
const db = getFirestore(app);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "POST") return post(req, res);
|
||||
|
||||
return res.status(404).json({ok: false});
|
||||
}
|
||||
|
||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) return res.status(401).json({ok: false});
|
||||
|
||||
const body = req.body;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
Object.keys(body).forEach((key) => params.append(key, body[key]));
|
||||
const result = await axios.get(`${process.env.BACKEND_URL}/custom_level?${params.toString()}`, {
|
||||
headers: {Authorization: `Bearer ${process.env.BACKEND_JWT}`},
|
||||
});
|
||||
|
||||
res.status(200).json(result.data);
|
||||
}
|
||||
Reference in New Issue
Block a user