Updated the eval calls to the backend, passed the navigation logic of level to useExamNavigation hook
This commit is contained in:
@@ -19,18 +19,18 @@ import { typeCheckWordsMC } from "@/utils/type.check";
|
|||||||
import SectionNavbar from "../Navigation/SectionNavbar";
|
import SectionNavbar from "../Navigation/SectionNavbar";
|
||||||
import AudioPlayer from "@/components/Low/AudioPlayer";
|
import AudioPlayer from "@/components/Low/AudioPlayer";
|
||||||
import { ExamProps } from "../types";
|
import { ExamProps } from "../types";
|
||||||
import {answeredEveryQuestionInPart} from "../utils/answeredEveryQuestion";
|
import { answeredEveryQuestionInPart } from "../utils/answeredEveryQuestion";
|
||||||
import useExamTimer from "@/hooks/useExamTimer";
|
import useExamTimer from "@/hooks/useExamTimer";
|
||||||
import ProgressButtons from "../components/ProgressButtons";
|
import ProgressButtons from "../components/ProgressButtons";
|
||||||
|
import useExamNavigation from "../Navigation/useExamNavigation";
|
||||||
|
import { calculateExerciseIndex } from "../utils/calculateExerciseIndex";
|
||||||
|
|
||||||
|
|
||||||
const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
||||||
const levelBgColor = "bg-ielts-level-light";
|
|
||||||
|
|
||||||
const updateTimers = useExamTimer(exam.module, preview || showSolutions);
|
const updateTimers = useExamTimer(exam.module, preview || showSolutions);
|
||||||
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
|
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
|
||||||
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
|
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
|
||||||
|
|
||||||
const examState = useExamStore((state) => state);
|
const examState = useExamStore((state) => state);
|
||||||
const persistentExamState = usePersistentExamStore((state) => state);
|
const persistentExamState = usePersistentExamStore((state) => state);
|
||||||
|
|
||||||
@@ -55,6 +55,7 @@ const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, pr
|
|||||||
const { finalizeModule, timeIsUp } = flags;
|
const { finalizeModule, timeIsUp } = flags;
|
||||||
|
|
||||||
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
|
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
|
||||||
|
const [isFirstTimeRender, setIsFirstTimeRender] = useState(partIndex === 0 && exerciseIndex == 0 && !showSolutions);
|
||||||
|
|
||||||
// In case client want to switch back
|
// In case client want to switch back
|
||||||
const textRenderDisabled = true;
|
const textRenderDisabled = true;
|
||||||
@@ -66,7 +67,6 @@ const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, pr
|
|||||||
const [textRender, setTextRender] = useState(false);
|
const [textRender, setTextRender] = useState(false);
|
||||||
const [changedPrompt, setChangedPrompt] = useState(false);
|
const [changedPrompt, setChangedPrompt] = useState(false);
|
||||||
|
|
||||||
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.parts.map((_, index) => index) : [0]));
|
|
||||||
|
|
||||||
const [questionModalKwargs, setQuestionModalKwargs] = useState<{
|
const [questionModalKwargs, setQuestionModalKwargs] = useState<{
|
||||||
type?: "module" | "blankQuestions" | "submit"; unanswered?: boolean | undefined; onClose: (next?: boolean) => void | undefined;
|
type?: "module" | "blankQuestions" | "submit"; unanswered?: boolean | undefined; onClose: (next?: boolean) => void | undefined;
|
||||||
@@ -74,25 +74,39 @@ const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, pr
|
|||||||
type: "blankQuestions",
|
type: "blankQuestions",
|
||||||
onClose: function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } }
|
onClose: function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } }
|
||||||
});
|
});
|
||||||
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !showSolutions);
|
|
||||||
const [startNow, setStartNow] = useState<boolean>(!showSolutions);
|
|
||||||
|
|
||||||
|
const modalKwargs = () => {
|
||||||
|
const kwargs: { type: "module" | "blankQuestions" | "submit", unanswered: boolean, onClose: (next?: boolean) => void; } = {
|
||||||
|
type: "blankQuestions",
|
||||||
|
unanswered: false,
|
||||||
|
onClose: function (x: boolean | undefined) { if (x) { setContinueAnyways(true); setShowQuestionsModal(false); } else { setShowQuestionsModal(false) } }
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
if (partIndex === exam.parts.length - 1) {
|
||||||
if (!showSolutions && exam.parts[partIndex]?.intro !== undefined && exam.parts[partIndex]?.intro !== "" && !seenParts.has(partIndex)) {
|
kwargs.type = "submit"
|
||||||
setShowPartDivider(true);
|
kwargs.unanswered = !exam.parts.every((_, partIndex) => answeredEveryQuestionInPart(exam, partIndex, userSolutions));
|
||||||
setBgColor(levelBgColor);
|
kwargs.onClose = function (x: boolean | undefined) { if (x) { setShowSubmissionModal(true); setShowQuestionsModal(false); } else { setShowQuestionsModal(false) } };
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
setQuestionModalKwargs(kwargs);
|
||||||
}, [exerciseIndex]);
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
nextExercise, previousExercise,
|
||||||
|
showPartDivider, setShowPartDivider,
|
||||||
|
seenParts, setSeenParts,
|
||||||
|
} = useExamNavigation(
|
||||||
|
{
|
||||||
|
exam, module: "level", showBlankModal: showQuestionsModal,
|
||||||
|
setShowBlankModal: setShowQuestionsModal, showSolutions,
|
||||||
|
preview, disableBetweenParts: true, modalKwargs
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
|
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
|
||||||
userSolutionRef.current = updateSolution;
|
userSolutionRef.current = updateSolution;
|
||||||
setSolutionWasUpdated(true);
|
setSolutionWasUpdated(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
|
||||||
|
|
||||||
const [contextWords, setContextWords] = useState<{ match: string, originalLine: string }[] | undefined>(undefined);
|
const [contextWords, setContextWords] = useState<{ match: string, originalLine: string }[] | undefined>(undefined);
|
||||||
const [contextWordLines, setContextWordLines] = useState<number[] | undefined>(undefined);
|
const [contextWordLines, setContextWordLines] = useState<number[] | undefined>(undefined);
|
||||||
const [totalLines, setTotalLines] = useState<number>(0);
|
const [totalLines, setTotalLines] = useState<number>(0);
|
||||||
@@ -139,104 +153,6 @@ const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, pr
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [finalizeModule, timeIsUp])
|
}, [finalizeModule, timeIsUp])
|
||||||
|
|
||||||
const nextExercise = () => {
|
|
||||||
scrollToTop();
|
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length) {
|
|
||||||
setExerciseIndex(exerciseIndex + 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (partIndex + 1 === exam.parts.length && !showQuestionsModal && !showSolutions && !continueAnyways) {
|
|
||||||
modalKwargs();
|
|
||||||
setShowQuestionsModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (partIndex + 1 < exam.parts.length) {
|
|
||||||
if (!answeredEveryQuestionInPart(exam, partIndex, userSolutions) && !continueAnyways && !showSolutions && !seenParts.has(partIndex + 1)) {
|
|
||||||
modalKwargs();
|
|
||||||
setShowQuestionsModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!showSolutions && exam.parts[0].intro && !seenParts.has(partIndex + 1)) {
|
|
||||||
setShowPartDivider(true);
|
|
||||||
setBgColor(levelBgColor);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (partIndex < exam.parts.length - 1 && exam.parts[partIndex + 1].context && !textRenderDisabled) {
|
|
||||||
setTextRender(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimesListened(0);
|
|
||||||
setPartIndex(partIndex + 1);
|
|
||||||
setExerciseIndex(0);
|
|
||||||
setQuestionIndex(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (partIndex + 1 === exam.parts.length && exerciseIndex === exam.parts[partIndex].exercises.length - 1 && !continueAnyways && !showSolutions) {
|
|
||||||
modalKwargs();
|
|
||||||
setShowQuestionsModal(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!showSolutions) {
|
|
||||||
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: true } })
|
|
||||||
} else {
|
|
||||||
dispatch({ type: "FINALIZE_MODULE_SOLUTIONS"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousExercise = () => {
|
|
||||||
scrollToTop();
|
|
||||||
|
|
||||||
if (exam.parts[partIndex].context && questionIndex === 0 && !textRender && !textRenderDisabled) {
|
|
||||||
setTextRender(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (questionIndex == 0) {
|
|
||||||
setPartIndex(partIndex - 1);
|
|
||||||
if (!seenParts.has(partIndex - 1)) {
|
|
||||||
setBgColor(levelBgColor);
|
|
||||||
setShowPartDivider(true);
|
|
||||||
setQuestionIndex(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1;
|
|
||||||
const lastExercise = exam.parts[partIndex - 1].exercises[lastExerciseIndex];
|
|
||||||
setExerciseIndex(lastExerciseIndex);
|
|
||||||
|
|
||||||
if (lastExercise.type === "multipleChoice") {
|
|
||||||
setQuestionIndex(lastExercise.questions.length - 1)
|
|
||||||
} else {
|
|
||||||
setQuestionIndex(0)
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setExerciseIndex(exerciseIndex - 1);
|
|
||||||
if (exerciseIndex - 1 === -1) {
|
|
||||||
setPartIndex(partIndex - 1);
|
|
||||||
const lastPartExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1;
|
|
||||||
const previousExercise = exam.parts[partIndex - 1].exercises[lastPartExerciseIndex];
|
|
||||||
if (previousExercise.type === "multipleChoice") {
|
|
||||||
setQuestionIndex(previousExercise.questions.length - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateExerciseIndex = () => {
|
|
||||||
return exam.parts.reduce((acc, curr, index) => {
|
|
||||||
if (index < partIndex) {
|
|
||||||
return acc + countExercises(curr.exercises)
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, 0) + (questionIndex + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderAudioPlayer = () => (
|
const renderAudioPlayer = () => (
|
||||||
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
||||||
@@ -393,26 +309,11 @@ const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, pr
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (continueAnyways) {
|
if (continueAnyways) {
|
||||||
setContinueAnyways(false);
|
setContinueAnyways(false);
|
||||||
nextExercise();
|
nextExercise(true);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [continueAnyways]);
|
}, [continueAnyways]);
|
||||||
|
|
||||||
const modalKwargs = () => {
|
|
||||||
const kwargs: { type: "module" | "blankQuestions" | "submit", unanswered: boolean, onClose: (next?: boolean) => void; } = {
|
|
||||||
type: "blankQuestions",
|
|
||||||
unanswered: false,
|
|
||||||
onClose: function (x: boolean | undefined) { if (x) { setContinueAnyways(true); setShowQuestionsModal(false); } else { setShowQuestionsModal(false) } }
|
|
||||||
};
|
|
||||||
|
|
||||||
if (partIndex === exam.parts.length - 1) {
|
|
||||||
kwargs.type = "submit"
|
|
||||||
kwargs.unanswered = !exam.parts.every((_, partIndex) => answeredEveryQuestionInPart(exam, partIndex, userSolutions));
|
|
||||||
kwargs.onClose = function (x: boolean | undefined) { if (x) { setShowSubmissionModal(true); setShowQuestionsModal(false); } else { setShowQuestionsModal(false) } };
|
|
||||||
}
|
|
||||||
setQuestionModalKwargs(kwargs);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mcNavKwargs = {
|
const mcNavKwargs = {
|
||||||
userSolutions: userSolutions,
|
userSolutions: userSolutions,
|
||||||
exam: exam,
|
exam: exam,
|
||||||
@@ -423,27 +324,10 @@ const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, pr
|
|||||||
runOnClick: setQuestionIndex
|
runOnClick: setQuestionIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressButtons = <ProgressButtons handlePrevious={previousExercise} handleNext={nextExercise} />;
|
const progressButtons = useMemo(() =>
|
||||||
|
// Do not remove the ()=> in handle next
|
||||||
const memoizedRender = useMemo(() => {
|
<ProgressButtons handlePrevious={previousExercise} handleNext={() => nextExercise()} />
|
||||||
setChangedPrompt(false);
|
, [nextExercise, previousExercise]);
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{textRender && !textRenderDisabled ?
|
|
||||||
renderText() :
|
|
||||||
<>
|
|
||||||
{exam.parts[partIndex]?.context && renderText()}
|
|
||||||
{exam.parts[partIndex]?.audio && renderAudioPlayer()}
|
|
||||||
{(showSolutions) ?
|
|
||||||
currentExercise && renderSolution(currentExercise, progressButtons, progressButtons) :
|
|
||||||
currentExercise && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)
|
|
||||||
}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [textRender, currentExercise, changedPrompt]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -469,17 +353,17 @@ const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, pr
|
|||||||
</Modal>
|
</Modal>
|
||||||
<QuestionsModal isOpen={showQuestionsModal} {...questionModalKwargs} />
|
<QuestionsModal isOpen={showQuestionsModal} {...questionModalKwargs} />
|
||||||
{
|
{
|
||||||
!(partIndex === 0 && questionIndex === 0 && (showPartDivider || startNow)) &&
|
!(partIndex === 0 && questionIndex === 0 && (showPartDivider || isFirstTimeRender)) &&
|
||||||
<Timer minTimer={exam.minTimer} disableTimer={showSolutions || preview} standalone={true} />
|
<Timer minTimer={exam.minTimer} disableTimer={showSolutions || preview} standalone={true} />
|
||||||
}
|
}
|
||||||
{(showPartDivider || startNow) ?
|
{(showPartDivider || isFirstTimeRender) ?
|
||||||
<PartDivider
|
<PartDivider
|
||||||
module="level"
|
module="level"
|
||||||
sectionLabel="Part"
|
sectionLabel="Part"
|
||||||
defaultTitle="Placement Test"
|
defaultTitle="Placement Test"
|
||||||
section={exam.parts[partIndex]}
|
section={exam.parts[partIndex]}
|
||||||
sectionIndex={partIndex}
|
sectionIndex={partIndex}
|
||||||
onNext={() => { setShowPartDivider(false); setStartNow(false); setBgColor("bg-white"); setSeenParts(prev => new Set(prev).add(partIndex)); }}
|
onNext={() => { setShowPartDivider(false); setIsFirstTimeRender(false); setBgColor("bg-white"); setSeenParts(prev => new Set(prev).add(partIndex)); }}
|
||||||
/> : (
|
/> : (
|
||||||
<>
|
<>
|
||||||
<SectionNavbar
|
<SectionNavbar
|
||||||
@@ -494,7 +378,7 @@ const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, pr
|
|||||||
examLabel={exam.label}
|
examLabel={exam.label}
|
||||||
partLabel={partLabel()}
|
partLabel={partLabel()}
|
||||||
minTimer={timer.current}
|
minTimer={timer.current}
|
||||||
exerciseIndex={calculateExerciseIndex()}
|
exerciseIndex={calculateExerciseIndex(exam, partIndex, exerciseIndex, questionIndex)}
|
||||||
module="level"
|
module="level"
|
||||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||||
disableTimer={showSolutions}
|
disableTimer={showSolutions}
|
||||||
@@ -507,7 +391,17 @@ const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, pr
|
|||||||
"mb-20 w-full",
|
"mb-20 w-full",
|
||||||
!!exam.parts[partIndex].context && !textRender && "grid grid-cols-2 gap-4",
|
!!exam.parts[partIndex].context && !textRender && "grid grid-cols-2 gap-4",
|
||||||
)}>
|
)}>
|
||||||
{memoizedRender}
|
{textRender && !textRenderDisabled ?
|
||||||
|
renderText() :
|
||||||
|
<>
|
||||||
|
{exam.parts[partIndex]?.context && renderText()}
|
||||||
|
{exam.parts[partIndex]?.audio && renderAudioPlayer()}
|
||||||
|
{(showSolutions) ?
|
||||||
|
currentExercise && renderSolution(currentExercise, progressButtons, progressButtons) :
|
||||||
|
currentExercise && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const MC_PER_PAGE = 2;
|
|||||||
type UseExamNavigation = (props: {
|
type UseExamNavigation = (props: {
|
||||||
exam: ModuleExam;
|
exam: ModuleExam;
|
||||||
module: Module;
|
module: Module;
|
||||||
|
modalKwargs?: () => void;
|
||||||
showBlankModal?: boolean;
|
showBlankModal?: boolean;
|
||||||
setShowBlankModal?: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowBlankModal?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
showSolutions: boolean;
|
showSolutions: boolean;
|
||||||
@@ -31,14 +32,15 @@ const useExamNavigation: UseExamNavigation = ({
|
|||||||
exam,
|
exam,
|
||||||
module,
|
module,
|
||||||
setShowBlankModal,
|
setShowBlankModal,
|
||||||
|
modalKwargs,
|
||||||
showSolutions,
|
showSolutions,
|
||||||
preview,
|
preview,
|
||||||
disableBetweenParts = false,
|
disableBetweenParts = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
const examState = useExamStore((state) => state);
|
const examState = useExamStore((state) => state);
|
||||||
const persistentExamState = usePersistentExamStore((state) => state);
|
const persistentExamState = usePersistentExamStore((state) => state);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
exerciseIndex, setExerciseIndex,
|
exerciseIndex, setExerciseIndex,
|
||||||
partIndex, setPartIndex,
|
partIndex, setPartIndex,
|
||||||
@@ -101,11 +103,13 @@ const useExamNavigation: UseExamNavigation = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentExercise.type === "multipleChoice" && questionIndex < currentExercise.questions.length - 1) {
|
if (currentExercise.type === "multipleChoice") {
|
||||||
setQuestionIndex(questionIndex + MC_PER_PAGE);
|
const nextQuestionIndex = questionIndex + MC_PER_PAGE;
|
||||||
return;
|
if (nextQuestionIndex < currentExercise.questions!.length) {
|
||||||
|
setQuestionIndex(nextQuestionIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!reachedFinalExercise) {
|
if (!reachedFinalExercise) {
|
||||||
setExerciseIndex(exerciseIndex + 1);
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
setQuestionIndex(0);
|
setQuestionIndex(0);
|
||||||
@@ -121,6 +125,7 @@ const useExamNavigation: UseExamNavigation = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!answeredEveryQuestion(exam as PartExam, userSolutions) && !keepGoing && setShowBlankModal && !showSolutions && !preview) {
|
if (!answeredEveryQuestion(exam as PartExam, userSolutions) && !keepGoing && setShowBlankModal && !showSolutions && !preview) {
|
||||||
|
if (modalKwargs) modalKwargs()
|
||||||
setShowBlankModal(true);
|
setShowBlankModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -134,20 +139,17 @@ const useExamNavigation: UseExamNavigation = ({
|
|||||||
if (!showSolutions) {
|
if (!showSolutions) {
|
||||||
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: true } });
|
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: true } });
|
||||||
} else {
|
} else {
|
||||||
dispatch({ type: "FINALIZE_MODULE_SOLUTIONS"});
|
dispatch({ type: "FINALIZE_MODULE_SOLUTIONS" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousPartExam = () => {
|
const previousPartExam = () => {
|
||||||
if (partIndex !== 0) {
|
const currentExercise = (exam as PartExam).parts[partIndex].exercises[exerciseIndex];
|
||||||
setPartIndex(partIndex - 1);
|
if (currentExercise.type === "multipleChoice" && questionIndex > 0) {
|
||||||
setExerciseIndex((exam as PartExam).parts[partIndex].exercises.length - 1);
|
setQuestionIndex(Math.max(0, questionIndex - MC_PER_PAGE));
|
||||||
if (isBetweenParts) setIsBetweenParts(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setQuestionIndex(0);
|
|
||||||
|
|
||||||
if (exerciseIndex === 0 && !disableBetweenParts) {
|
if (exerciseIndex === 0 && !disableBetweenParts) {
|
||||||
setIsBetweenParts(true);
|
setIsBetweenParts(true);
|
||||||
return;
|
return;
|
||||||
@@ -155,7 +157,16 @@ const useExamNavigation: UseExamNavigation = ({
|
|||||||
|
|
||||||
if (exerciseIndex !== 0) {
|
if (exerciseIndex !== 0) {
|
||||||
setExerciseIndex(exerciseIndex - 1);
|
setExerciseIndex(exerciseIndex - 1);
|
||||||
|
setQuestionIndex(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (partIndex !== 0) {
|
||||||
|
setPartIndex(partIndex - 1);
|
||||||
|
setExerciseIndex((exam as PartExam).parts[partIndex].exercises.length - 1);
|
||||||
|
if (isBetweenParts) setIsBetweenParts(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextExerciseOnlyExam = () => {
|
const nextExerciseOnlyExam = () => {
|
||||||
@@ -166,7 +177,7 @@ const useExamNavigation: UseExamNavigation = ({
|
|||||||
if (currentExercise.type === "interactiveSpeaking" && questionIndex < currentExercise.prompts.length - 1) {
|
if (currentExercise.type === "interactiveSpeaking" && questionIndex < currentExercise.prompts.length - 1) {
|
||||||
setQuestionIndex(questionIndex + 1)
|
setQuestionIndex(questionIndex + 1)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!reachedFinalExercise) {
|
if (!reachedFinalExercise) {
|
||||||
setQuestionIndex(0);
|
setQuestionIndex(0);
|
||||||
@@ -183,7 +194,7 @@ const useExamNavigation: UseExamNavigation = ({
|
|||||||
if (!showSolutions) {
|
if (!showSolutions) {
|
||||||
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: true } });
|
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: true } });
|
||||||
} else {
|
} else {
|
||||||
dispatch({ type: "FINALIZE_MODULE_SOLUTIONS"});
|
dispatch({ type: "FINALIZE_MODULE_SOLUTIONS" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +205,7 @@ const useExamNavigation: UseExamNavigation = ({
|
|||||||
if (currentExercise.type === "interactiveSpeaking" && questionIndex !== 0) {
|
if (currentExercise.type === "interactiveSpeaking" && questionIndex !== 0) {
|
||||||
setQuestionIndex(questionIndex - 1);
|
setQuestionIndex(questionIndex - 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exerciseIndex !== 0) {
|
if (exerciseIndex !== 0) {
|
||||||
setExerciseIndex(exerciseIndex - 1);
|
setExerciseIndex(exerciseIndex - 1);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { calculateExerciseIndexSpeaking } from "./utils/calculateExerciseIndex";
|
|||||||
|
|
||||||
|
|
||||||
const Speaking: React.FC<ExamProps<SpeakingExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
const Speaking: React.FC<ExamProps<SpeakingExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
||||||
const updateTimers = useExamTimer(exam.module, preview);
|
const updateTimers = useExamTimer(exam.module, preview || showSolutions);
|
||||||
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
|
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
|
||||||
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
|
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
|
||||||
|
|
||||||
@@ -90,11 +90,8 @@ const Speaking: React.FC<ExamProps<SpeakingExam>> = ({ exam, showSolutions = fal
|
|||||||
setSeenParts((prev) => new Set(prev).add(exerciseIndex));
|
setSeenParts((prev) => new Set(prev).add(exerciseIndex));
|
||||||
}
|
}
|
||||||
|
|
||||||
const memoizedExerciseIndex = useMemo(() => {
|
const memoizedExerciseIndex = useMemo(() =>
|
||||||
const bruh = calculateExerciseIndexSpeaking(exam, exerciseIndex, questionIndex)
|
calculateExerciseIndexSpeaking(exam, exerciseIndex, questionIndex)
|
||||||
console.log(bruh);
|
|
||||||
return bruh;
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
, [exerciseIndex, questionIndex]
|
, [exerciseIndex, questionIndex]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import SectionNavbar from "./Navigation/SectionNavbar";
|
|||||||
import ProgressButtons from "./components/ProgressButtons";
|
import ProgressButtons from "./components/ProgressButtons";
|
||||||
|
|
||||||
const Writing: React.FC<ExamProps<WritingExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
const Writing: React.FC<ExamProps<WritingExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
||||||
const updateTimers = useExamTimer(exam.module, preview);
|
const updateTimers = useExamTimer(exam.module, preview || showSolutions);
|
||||||
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
|
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
|
||||||
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
|
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,8 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [variant, setVariant] = useState<Variant>("full");
|
const [variant, setVariant] = useState<Variant>("full");
|
||||||
const [avoidRepeated, setAvoidRepeated] = useState(false);
|
const [avoidRepeated, setAvoidRepeated] = useState(false);
|
||||||
const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
|
|
||||||
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
|
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
|
||||||
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
|
const [pendingExercises, setPendingExercises] = useState<string[]>([]);
|
||||||
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
exam, setExam,
|
exam, setExam,
|
||||||
@@ -59,11 +57,11 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
|
|||||||
saveStats,
|
saveStats,
|
||||||
saveSession,
|
saveSession,
|
||||||
setFlags,
|
setFlags,
|
||||||
setShuffles
|
setShuffles,
|
||||||
|
evaluated,
|
||||||
|
setEvaluated,
|
||||||
} = useExamStore();
|
} = useExamStore();
|
||||||
|
|
||||||
const { finalizeModule, finalizeExam } = flags;
|
|
||||||
|
|
||||||
const [isFetchingExams, setIsFetchingExams] = useState(false);
|
const [isFetchingExams, setIsFetchingExams] = useState(false);
|
||||||
const [isExamLoaded, setIsExamLoaded] = useState(moduleIndex < selectedModules.length);
|
const [isExamLoaded, setIsExamLoaded] = useState(moduleIndex < selectedModules.length);
|
||||||
|
|
||||||
@@ -114,99 +112,115 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
|
|||||||
resetStore();
|
resetStore();
|
||||||
setVariant("full");
|
setVariant("full");
|
||||||
setAvoidRepeated(false);
|
setAvoidRepeated(false);
|
||||||
setHasBeenUploaded(false);
|
|
||||||
setShowAbandonPopup(false);
|
setShowAbandonPopup(false);
|
||||||
setIsEvaluationLoading(false);
|
|
||||||
setStatsAwaitingEvaluation([]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (finalizeModule && !showSolutions) {
|
if (flags.finalizeModule && !showSolutions && flags.pendingEvaluation) {
|
||||||
/*if (exam && (exam.module === "writing" || exam.module === "speaking") && userSolutions.length > 0 && !showSolutions) {
|
if (exam && (exam.module === "writing" || exam.module === "speaking") && userSolutions.length > 0 && !showSolutions) {
|
||||||
setIsEvaluationLoading(true);
|
const exercisesToEvaluate = exam.exercises
|
||||||
|
.map(exercise => exercise.id);
|
||||||
|
|
||||||
|
setPendingExercises(exercisesToEvaluate);
|
||||||
(async () => {
|
(async () => {
|
||||||
const responses: UserSolution[] = (
|
await Promise.all(
|
||||||
await Promise.all(
|
exam.exercises.map(async (exercise, index) => {
|
||||||
exam.exercises.map(async (exercise, index) => {
|
if (exercise.type === "writing")
|
||||||
const evaluationID = uuidv4();
|
await evaluateWritingAnswer(user.id, sessionId, exercise, index + 1, userSolutions.find((x) => x.exercise === exercise.id)!);
|
||||||
if (exercise.type === "writing")
|
|
||||||
return await evaluateWritingAnswer(exercise, index + 1, userSolutions.find((x) => x.exercise === exercise.id)!, evaluationID);
|
|
||||||
|
|
||||||
if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking")
|
if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking")
|
||||||
return await evaluateSpeakingAnswer(
|
await evaluateSpeakingAnswer(
|
||||||
exercise,
|
user.id,
|
||||||
userSolutions.find((x) => x.exercise === exercise.id)!,
|
sessionId,
|
||||||
evaluationID,
|
exercise,
|
||||||
index + 1,
|
userSolutions.find((x) => x.exercise === exercise.id)!,
|
||||||
);
|
index + 1,
|
||||||
}),
|
);
|
||||||
)
|
}),
|
||||||
).filter((x) => !!x) as UserSolution[];
|
)
|
||||||
})();
|
})();
|
||||||
}*/
|
}
|
||||||
}
|
}
|
||||||
}, [exam, finalizeModule, showSolutions, userSolutions]);
|
}, [exam, showSolutions, userSolutions, sessionId, user?.id, flags]);
|
||||||
|
|
||||||
/*useEffect(() => {
|
|
||||||
// poll backend and setIsEvaluationLoading to false
|
|
||||||
|
|
||||||
}, []);*/
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (finalizeExam && !isEvaluationLoading) {
|
if (!flags.pendingEvaluation || pendingExercises.length === 0) return;
|
||||||
|
|
||||||
|
const pollStatus = async () => {
|
||||||
|
try {
|
||||||
|
// Will fetch evaluations that either were completed or had an error
|
||||||
|
const { data } = await axios.get('/api/evaluate/status', {
|
||||||
|
params: {
|
||||||
|
sessionId,
|
||||||
|
userId: user.id,
|
||||||
|
exerciseIds: pendingExercises.join(',')
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.finishedExerciseIds.length > 0) {
|
||||||
|
const remainingExercises = pendingExercises.filter(id => !data.finishedExerciseIds.includes(id));
|
||||||
|
|
||||||
|
setPendingExercises(remainingExercises);
|
||||||
|
if (remainingExercises.length === 0) {
|
||||||
|
const evaluatedData = await axios.post('/api/evaluate/fetchSolutions', {
|
||||||
|
sessionId,
|
||||||
|
userId: user.id,
|
||||||
|
userSolutions
|
||||||
|
});
|
||||||
|
|
||||||
|
const newEvaluations = evaluatedData.data.filter(
|
||||||
|
(newEval: UserSolution) => !evaluated.some(
|
||||||
|
existingEval => existingEval.exercise === newEval.exercise
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
setEvaluated([...evaluated, ...newEvaluations]);
|
||||||
|
setFlags({ pendingEvaluation: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pendingExercises.length > 0) {
|
||||||
|
setTimeout(pollStatus, 5000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setTimeout(pollStatus, 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pollStatus();
|
||||||
|
}, [sessionId, user.id, userSolutions, setFlags, setEvaluated, evaluated, flags, pendingExercises]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (flags.finalizeExam && moduleIndex !== -1) {
|
||||||
|
setModuleIndex(-1);
|
||||||
|
}
|
||||||
|
}, [flags.finalizeExam, moduleIndex, setModuleIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (flags.finalizeExam && !flags.pendingEvaluation && pendingExercises.length === 0) {
|
||||||
(async () => {
|
(async () => {
|
||||||
axios.get("/api/stats/update");
|
if (evaluated.length !== 0) {
|
||||||
|
setUserSolutions(
|
||||||
|
userSolutions.map(solution => {
|
||||||
|
const evaluatedSolution = evaluated.find(e => e.exercise === solution.exercise);
|
||||||
|
if (evaluatedSolution) {
|
||||||
|
return { ...solution, ...evaluatedSolution };
|
||||||
|
}
|
||||||
|
return solution;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
await saveStats();
|
await saveStats();
|
||||||
setModuleIndex(-1);
|
await axios.get("/api/stats/update");
|
||||||
|
setShowSolutions(true);
|
||||||
setFlags({ finalizeExam: false });
|
setFlags({ finalizeExam: false });
|
||||||
|
dispatch({type: "UPDATE_EXAMS"})
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
}, [finalizeExam, saveStats, setFlags, setModuleIndex, isEvaluationLoading]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [saveStats, setFlags, setModuleIndex, evaluated, pendingExercises, setUserSolutions]);
|
||||||
|
|
||||||
const onFinish = async (solutions: UserSolution[]) => {
|
|
||||||
const solutionIds = solutions.map((x) => x.exercise);
|
|
||||||
const solutionExams = solutions.map((x) => x.exam);
|
|
||||||
|
|
||||||
let newSolutions = [...solutions];
|
|
||||||
|
|
||||||
if (exam && !solutionExams.includes(exam.id)) return;
|
|
||||||
|
|
||||||
if (exam && (exam.module === "writing" || exam.module === "speaking") && solutions.length > 0 && !showSolutions) {
|
|
||||||
setHasBeenUploaded(true);
|
|
||||||
setIsEvaluationLoading(true);
|
|
||||||
|
|
||||||
const responses: UserSolution[] = (
|
|
||||||
await Promise.all(
|
|
||||||
exam.exercises.map(async (exercise, index) => {
|
|
||||||
const evaluationID = uuidv4();
|
|
||||||
if (exercise.type === "writing")
|
|
||||||
return await evaluateWritingAnswer(exercise, index + 1, solutions.find((x) => x.exercise === exercise.id)!, evaluationID);
|
|
||||||
|
|
||||||
if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking")
|
|
||||||
return await evaluateSpeakingAnswer(
|
|
||||||
exercise,
|
|
||||||
solutions.find((x) => x.exercise === exercise.id)!,
|
|
||||||
evaluationID,
|
|
||||||
index + 1,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
).filter((x) => !!x) as UserSolution[];
|
|
||||||
|
|
||||||
newSolutions = [...newSolutions.filter((x) => !responses.map((y) => y.exercise).includes(x.exercise)), ...responses];
|
|
||||||
setStatsAwaitingEvaluation((prev) => [...prev, ...responses.filter((x) => !!x).map((r) => (r as any).id)]);
|
|
||||||
setHasBeenUploaded(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
axios.get("/api/stats/update");
|
|
||||||
|
|
||||||
setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...newSolutions]);
|
|
||||||
setModuleIndex(moduleIndex + 1);
|
|
||||||
|
|
||||||
setPartIndex(0);
|
|
||||||
setExerciseIndex(0);
|
|
||||||
setQuestionIndex(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const aggregateScoresByModule = (): {
|
const aggregateScoresByModule = (): {
|
||||||
module: Module;
|
module: Module;
|
||||||
@@ -306,7 +320,7 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
|
|||||||
)}
|
)}
|
||||||
{(moduleIndex === -1 && selectedModules.length !== 0) &&
|
{(moduleIndex === -1 && selectedModules.length !== 0) &&
|
||||||
<Finish
|
<Finish
|
||||||
isLoading={isEvaluationLoading}
|
isLoading={flags.pendingEvaluation}
|
||||||
user={user!}
|
user={user!}
|
||||||
modules={selectedModules}
|
modules={selectedModules}
|
||||||
solutions={userSolutions}
|
solutions={userSolutions}
|
||||||
@@ -331,6 +345,7 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
|
|||||||
setUserSolutions(userSolutions);
|
setUserSolutions(userSolutions);
|
||||||
}
|
}
|
||||||
setShuffles([]);
|
setShuffles([]);
|
||||||
|
console.log(exam);
|
||||||
if (index === undefined) {
|
if (index === undefined) {
|
||||||
setFlags({ reviewAll: true });
|
setFlags({ reviewAll: true });
|
||||||
setModuleIndex(0);
|
setModuleIndex(0);
|
||||||
|
|||||||
80
src/pages/api/evaluate/fetchSolutions.ts
Normal file
80
src/pages/api/evaluate/fetchSolutions.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import client from "@/lib/mongodb";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import { UserSolution } from "@/interfaces/exam";
|
||||||
|
import { speakingReverseMarking, writingReverseMarking } from "@/utils/score";
|
||||||
|
|
||||||
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "POST") return post(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { sessionId, userId, userSolutions } = req.body;
|
||||||
|
const completedEvals = await db.collection("evaluation").find({
|
||||||
|
session_id: sessionId,
|
||||||
|
user: userId,
|
||||||
|
status: "completed"
|
||||||
|
}).toArray();
|
||||||
|
|
||||||
|
const evalsByExercise = new Map(
|
||||||
|
completedEvals.map(e => [e.exercise_id, e])
|
||||||
|
);
|
||||||
|
|
||||||
|
const solutionsWithEvals = userSolutions.filter((solution: UserSolution) =>
|
||||||
|
evalsByExercise.has(solution.exercise)
|
||||||
|
).map((solution: any) => {
|
||||||
|
const evaluation = evalsByExercise.get(solution.exercise)!;
|
||||||
|
|
||||||
|
if (solution.type === 'writing') {
|
||||||
|
return {
|
||||||
|
...solution,
|
||||||
|
solutions: [{
|
||||||
|
...solution.solutions[0],
|
||||||
|
evaluation: evaluation.result
|
||||||
|
}],
|
||||||
|
score: {
|
||||||
|
correct: writingReverseMarking[evaluation.result.overall],
|
||||||
|
total: 100,
|
||||||
|
missing: 0
|
||||||
|
},
|
||||||
|
isDisabled: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (solution.type === 'speaking' || solution.type === 'interactiveSpeaking') {
|
||||||
|
return {
|
||||||
|
...solution,
|
||||||
|
solutions: [{
|
||||||
|
...solution.solutions[0],
|
||||||
|
...(
|
||||||
|
solution.type === 'speaking'
|
||||||
|
? { fullPath: evaluation.result.fullPath }
|
||||||
|
: { answer: evaluation.result.answer }
|
||||||
|
),
|
||||||
|
evaluation: evaluation.result
|
||||||
|
}],
|
||||||
|
score: {
|
||||||
|
correct: speakingReverseMarking[evaluation.result.overall || 0] || 0,
|
||||||
|
total: 100,
|
||||||
|
missing: 0
|
||||||
|
},
|
||||||
|
isDisabled: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
solution,
|
||||||
|
evaluation
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json(solutionsWithEvals)
|
||||||
|
}
|
||||||
@@ -1,98 +1,62 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import axios, {AxiosResponse} from "axios";
|
import axios from "axios";
|
||||||
import formidable from "formidable-serverless";
|
import formidable from "formidable-serverless";
|
||||||
import {ref, uploadBytes} from "firebase/storage";
|
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import {storage} from "@/firebase";
|
import FormData from 'form-data';
|
||||||
import client from "@/lib/mongodb";
|
|
||||||
import {Stat} from "@/interfaces/user";
|
|
||||||
import {speakingReverseMarking} from "@/utils/score";
|
|
||||||
|
|
||||||
const db = client.db(process.env.MONGODB_DB);
|
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
function delay(ms: number) {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const form = formidable({keepExtensions: true});
|
const form = formidable({ keepExtensions: true });
|
||||||
|
|
||||||
await form.parse(req, async (err: any, fields: any, files: any) => {
|
await form.parse(req, async (err: any, fields: any, files: any) => {
|
||||||
if (err) console.log(err);
|
if (err) {
|
||||||
|
console.log(err);
|
||||||
|
res.status(500).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const uploadingAudios = await Promise.all(
|
const formData = new FormData();
|
||||||
Object.keys(files).map(async (fileID: string) => {
|
formData.append('userId', fields.userId);
|
||||||
const audioFile = files[fileID];
|
formData.append('sessionId', fields.sessionId);
|
||||||
const questionID = fileID.replace("answer_", "question_");
|
formData.append('exerciseId', fields.exerciseId);
|
||||||
|
|
||||||
const audioFileRef = ref(storage, `speaking_recordings/${(audioFile as any).path.replace("upload_", "")}`);
|
Object.keys(files).forEach(fileKey => {
|
||||||
|
const index = fileKey.split('_')[1];
|
||||||
|
const questionKey = `question_${index}`;
|
||||||
|
|
||||||
const binary = fs.readFileSync((audioFile as any).path).buffer;
|
const audioFile = files[fileKey];
|
||||||
const snapshot = await uploadBytes(audioFileRef, binary);
|
const binary = fs.readFileSync((audioFile as any).path);
|
||||||
|
formData.append(`audio_${index}`, binary, 'audio.wav');
|
||||||
|
formData.append(questionKey, fields[questionKey]);
|
||||||
|
|
||||||
fs.rmSync((audioFile as any).path);
|
fs.rmSync((audioFile as any).path);
|
||||||
|
});
|
||||||
|
|
||||||
return {question: fields[questionID], answer: snapshot.metadata.fullPath};
|
await axios.post(
|
||||||
}),
|
`${process.env.BACKEND_URL}/grade/speaking/${fields.task}`,
|
||||||
);
|
formData,
|
||||||
|
|
||||||
res.status(200).json(null);
|
|
||||||
|
|
||||||
console.log("🌱 - Still processing");
|
|
||||||
const backendRequest = await evaluate({answers: uploadingAudios}, fields.variant);
|
|
||||||
console.log("🌱 - Process complete");
|
|
||||||
|
|
||||||
const correspondingStat = await getCorrespondingStat(fields.id, 1);
|
|
||||||
|
|
||||||
const solutions = correspondingStat.solutions.map((x) => ({...x, evaluation: backendRequest.data, solution: uploadingAudios}));
|
|
||||||
await db.collection("stats").updateOne(
|
|
||||||
{ id: fields.id },
|
|
||||||
{
|
{
|
||||||
$set: {
|
headers: {
|
||||||
id: fields.id,
|
...formData.getHeaders(),
|
||||||
solutions,
|
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||||
score: {
|
|
||||||
correct: speakingReverseMarking[backendRequest.data.overall || 0] || 0,
|
|
||||||
missing: 0,
|
|
||||||
total: 100,
|
|
||||||
},
|
},
|
||||||
isDisabled: false
|
}
|
||||||
}
|
|
||||||
},
|
|
||||||
{ upsert: true }
|
|
||||||
);
|
);
|
||||||
console.log("🌱 - Updated the DB");
|
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCorrespondingStat(id: string, index: number): Promise<Stat> {
|
|
||||||
console.log(`🌱 - Try number ${index} - ${id}`);
|
|
||||||
const correspondingStat = await db.collection("stats").findOne<Stat>({ id: id });
|
|
||||||
|
|
||||||
if (correspondingStat) return correspondingStat;
|
|
||||||
await delay(3 * 10000);
|
|
||||||
return getCorrespondingStat(id, index + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function evaluate(body: {answers: object[]}, variant?: "initial" | "final"): Promise<AxiosResponse> {
|
|
||||||
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/grade/speaking/${variant === "initial" ? "1" : "3"}`, body, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (backendRequest.status !== 200) return evaluate(body);
|
|
||||||
return backendRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
api: {
|
api: {
|
||||||
|
|||||||
@@ -1,67 +1,56 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import axios, {AxiosResponse} from "axios";
|
import axios from "axios";
|
||||||
import formidable from "formidable-serverless";
|
import formidable from "formidable-serverless";
|
||||||
import {getDownloadURL, ref, uploadBytes} from "firebase/storage";
|
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import {storage} from "@/firebase";
|
import FormData from 'form-data';
|
||||||
import {Stat} from "@/interfaces/user";
|
|
||||||
|
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
function delay(ms: number) {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const form = formidable({keepExtensions: true});
|
const form = formidable({ keepExtensions: true });
|
||||||
|
|
||||||
await form.parse(req, async (err: any, fields: any, files: any) => {
|
await form.parse(req, async (err: any, fields: any, files: any) => {
|
||||||
if (err) console.log(err);
|
if (err) {
|
||||||
|
console.log(err);
|
||||||
|
res.status(500).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('userId', fields.userId);
|
||||||
|
formData.append('sessionId', fields.sessionId);
|
||||||
|
formData.append('exerciseId', fields.exerciseId);
|
||||||
|
formData.append('question_1', fields.question);
|
||||||
|
|
||||||
const audioFile = files.audio;
|
const audioFile = files.audio;
|
||||||
const audioFileRef = ref(storage, `speaking_recordings/${fields.id}.wav`);
|
const binary = fs.readFileSync((audioFile as any).path);
|
||||||
|
formData.append('audio_1', binary, 'audio.wav');
|
||||||
|
fs.rmSync((audioFile as any).path);
|
||||||
|
|
||||||
const binary = fs.readFileSync((audioFile as any).path).buffer;
|
await axios.post(
|
||||||
|
`${process.env.BACKEND_URL}/grade/speaking/2`,
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
...formData.getHeaders(),
|
||||||
|
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const snapshot = await uploadBytes(audioFileRef, binary);
|
res.status(200).json({ ok: true });
|
||||||
const url = await getDownloadURL(snapshot.ref);
|
|
||||||
const path = snapshot.metadata.fullPath;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/*const solutions = correspondingStat.solutions.map((x) => ({
|
|
||||||
...x,
|
|
||||||
evaluation: backendRequest.data,
|
|
||||||
solution: url,
|
|
||||||
}));*/
|
|
||||||
|
|
||||||
await axios.post(`${process.env.BACKEND_URL}/grade/speaking/2`, {answer: path, question: fields.question}, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function evaluate(body: {answer: string; question: string}, task: number): Promise<AxiosResponse> {
|
|
||||||
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/grade/speaking/2`, body, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (backendRequest.status !== 200) return evaluate(body, task);
|
|
||||||
return backendRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
api: {
|
api: {
|
||||||
bodyParser: false,
|
bodyParser: false,
|
||||||
|
|||||||
34
src/pages/api/evaluate/status.ts
Normal file
34
src/pages/api/evaluate/status.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
|
import client from "@/lib/mongodb";
|
||||||
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
|
||||||
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "GET") return get(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ok: false});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {sessionId, userId, exerciseIds} = req.query;
|
||||||
|
const exercises = (exerciseIds! as string).split(',');
|
||||||
|
const finishedEvaluations = await db.collection("evaluation").find({
|
||||||
|
session_id: sessionId,
|
||||||
|
user: userId,
|
||||||
|
$or: [
|
||||||
|
{ status: "completed" },
|
||||||
|
{ status: "error" }
|
||||||
|
],
|
||||||
|
exercise_id: { $in: exercises }
|
||||||
|
}).toArray();
|
||||||
|
|
||||||
|
const finishedExerciseIds = finishedEvaluations.map(evaluation => evaluation.exercise_id);
|
||||||
|
res.status(200).json({ finishedExerciseIds });
|
||||||
|
}
|
||||||
@@ -5,10 +5,12 @@ import { sessionOptions } from "@/lib/session";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
interface Body {
|
interface Body {
|
||||||
|
userId: string;
|
||||||
|
sessionId: string;
|
||||||
question: string;
|
question: string;
|
||||||
answer: string;
|
answer: string;
|
||||||
|
exerciseId: string;
|
||||||
task: 1 | 2;
|
task: 1 | 2;
|
||||||
id: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
@@ -20,13 +22,13 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = req.body as Body;
|
const { task, ...body} = req.body as Body;
|
||||||
const taskNumber = body.task.toString() !== "1" && body.task.toString() !== "2" ? "1" : body.task.toString();
|
const taskNumber = task.toString() !== "1" && task.toString() !== "2" ? "1" : task.toString();
|
||||||
|
|
||||||
await axios.post(`${process.env.BACKEND_URL}/grade/writing/${taskNumber}`, body, {
|
await axios.post(`${process.env.BACKEND_URL}/grade/writing/${taskNumber}`, body, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
res.status(200);
|
res.status(200).json({ok: true});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const initialState: ExamState = {
|
|||||||
inactivity: 0,
|
inactivity: 0,
|
||||||
shuffles: [],
|
shuffles: [],
|
||||||
bgColor: "bg-white",
|
bgColor: "bg-white",
|
||||||
currentSolution: undefined,
|
evaluated: [],
|
||||||
user: undefined,
|
user: undefined,
|
||||||
navigation: {
|
navigation: {
|
||||||
previousDisabled: false,
|
previousDisabled: false,
|
||||||
@@ -39,6 +39,7 @@ export const initialState: ExamState = {
|
|||||||
reviewAll: false,
|
reviewAll: false,
|
||||||
finalizeModule: false,
|
finalizeModule: false,
|
||||||
finalizeExam: false,
|
finalizeExam: false,
|
||||||
|
pendingEvaluation: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -61,6 +62,8 @@ const useExamStore = create<ExamState & ExamFunctions>((set, get) => ({
|
|||||||
setQuestionIndex: (questionIndex: number) => set(() => ({ questionIndex })),
|
setQuestionIndex: (questionIndex: number) => set(() => ({ questionIndex })),
|
||||||
setBgColor: (bgColor: string) => set(() => ({ bgColor })),
|
setBgColor: (bgColor: string) => set(() => ({ bgColor })),
|
||||||
|
|
||||||
|
setEvaluated: (evaluated: UserSolution[]) => set(() => ({ evaluated })),
|
||||||
|
|
||||||
setNavigation: (updates: Partial<Navigation>) => set((state) => ({
|
setNavigation: (updates: Partial<Navigation>) => set((state) => ({
|
||||||
navigation: {
|
navigation: {
|
||||||
...state.navigation,
|
...state.navigation,
|
||||||
@@ -75,7 +78,6 @@ const useExamStore = create<ExamState & ExamFunctions>((set, get) => ({
|
|||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
|
||||||
setTimeIsUp: (timeIsUp: boolean) => set((state) => ({ flags: { ...state.flags, timeIsUp } })),
|
setTimeIsUp: (timeIsUp: boolean) => set((state) => ({ flags: { ...state.flags, timeIsUp } })),
|
||||||
|
|
||||||
reset: () => set(() => initialState),
|
reset: () => set(() => initialState),
|
||||||
@@ -162,12 +164,12 @@ export const usePersistentExamStore = create<ExamState & ExamFunctions>()(
|
|||||||
|
|
||||||
setTimeIsUp: (timeIsUp: boolean) => set((state) => ({ flags: { ...state.flags, timeIsUp } })),
|
setTimeIsUp: (timeIsUp: boolean) => set((state) => ({ flags: { ...state.flags, timeIsUp } })),
|
||||||
|
|
||||||
saveStats: async () => {},
|
saveStats: async () => { },
|
||||||
saveSession: async () => {},
|
saveSession: async () => { },
|
||||||
|
setEvaluated: (evaluated: UserSolution[]) => {},
|
||||||
reset: () => set(() => initialState),
|
reset: () => set(() => initialState),
|
||||||
dispatch: (action) => set((state) => rootReducer(state, action))
|
dispatch: (action) => set((state) => rootReducer(state, action))
|
||||||
|
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
name: 'persistent-exam-store',
|
name: 'persistent-exam-store',
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import { convertToUserSolutions } from "@/utils/stats";
|
|||||||
export type RootActions =
|
export type RootActions =
|
||||||
{ type: 'INIT_EXAM'; payload: { exams: Exam[], modules: Module[], assignment?: Assignment } } |
|
{ type: 'INIT_EXAM'; payload: { exams: Exam[], modules: Module[], assignment?: Assignment } } |
|
||||||
{ type: 'INIT_SOLUTIONS'; payload: { exams: Exam[], modules: Module[], stats: Stat[], timeSpent?: number, inactivity?: number } } |
|
{ type: 'INIT_SOLUTIONS'; payload: { exams: Exam[], modules: Module[], stats: Stat[], timeSpent?: number, inactivity?: number } } |
|
||||||
{ type: 'UPDATE_TIMERS'; payload: { timeSpent: number; inactivity: number; timeSpentCurrentModule: number;} } |
|
{ type: 'UPDATE_TIMERS'; payload: { timeSpent: number; inactivity: number; timeSpentCurrentModule: number; } } |
|
||||||
{ type: 'FINALIZE_MODULE'; payload: { updateTimers: boolean } } |
|
{ type: 'FINALIZE_MODULE'; payload: { updateTimers: boolean } } |
|
||||||
{ type: 'FINALIZE_MODULE_SOLUTIONS' }
|
{ type: 'FINALIZE_MODULE_SOLUTIONS' } |
|
||||||
|
{ type: 'UPDATE_EXAMS'}
|
||||||
|
|
||||||
|
|
||||||
export type Action = RootActions | SessionActions;
|
export type Action = RootActions | SessionActions;
|
||||||
@@ -84,19 +85,28 @@ export const rootReducer = (
|
|||||||
case 'UPDATE_TIMERS': {
|
case 'UPDATE_TIMERS': {
|
||||||
// Just assigning the timers at once instead of two different calls
|
// Just assigning the timers at once instead of two different calls
|
||||||
const { timeSpent, inactivity, timeSpentCurrentModule } = action.payload;
|
const { timeSpent, inactivity, timeSpentCurrentModule } = action.payload;
|
||||||
return {
|
return {
|
||||||
timeSpentCurrentModule,
|
timeSpentCurrentModule,
|
||||||
timeSpent,
|
timeSpent,
|
||||||
inactivity
|
inactivity
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
case 'FINALIZE_MODULE': {
|
case 'FINALIZE_MODULE': {
|
||||||
const { updateTimers } = action.payload;
|
const { updateTimers } = action.payload;
|
||||||
|
const solutions = state.userSolutions;
|
||||||
|
const evaluated = state.evaluated;
|
||||||
|
|
||||||
|
const hasUnevaluatedSolutions = solutions.some(solution =>
|
||||||
|
(solution.type === 'speaking' ||
|
||||||
|
solution.type === 'writing' ||
|
||||||
|
solution.type === 'interactiveSpeaking') &&
|
||||||
|
!evaluated.some(evaluation => evaluation.exercise === solution.exercise)
|
||||||
|
);
|
||||||
|
|
||||||
// To finalize a module first flag the timers to be updated
|
// To finalize a module first flag the timers to be updated
|
||||||
if (updateTimers) {
|
if (updateTimers) {
|
||||||
return {
|
return {
|
||||||
flags: { ...state.flags, finalizeModule: true }
|
flags: { ...state.flags, finalizeModule: true, pendingEvaluation: hasUnevaluatedSolutions }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// then check whether there are more modules in the exam, if there are
|
// then check whether there are more modules in the exam, if there are
|
||||||
@@ -119,10 +129,12 @@ export const rootReducer = (
|
|||||||
// and the Finish view is set there, no need to
|
// and the Finish view is set there, no need to
|
||||||
// dispatch another init
|
// dispatch another init
|
||||||
return {
|
return {
|
||||||
|
showSolutions: true,
|
||||||
flags: {
|
flags: {
|
||||||
...state.flags,
|
...state.flags,
|
||||||
finalizeModule: false,
|
finalizeModule: false,
|
||||||
finalizeExam: true,
|
finalizeExam: true,
|
||||||
|
pendingEvaluation: hasUnevaluatedSolutions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,6 +167,14 @@ export const rootReducer = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case 'UPDATE_EXAMS': {
|
||||||
|
const exams = state.exams.map((e) => updateExamWithUserSolutions(e, state.userSolutions));
|
||||||
|
const exam = updateExamWithUserSolutions(state.exam!, state.userSolutions);
|
||||||
|
return {
|
||||||
|
exams,
|
||||||
|
exam
|
||||||
|
}
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface StateFlags {
|
|||||||
reviewAll: boolean;
|
reviewAll: boolean;
|
||||||
finalizeModule: boolean;
|
finalizeModule: boolean;
|
||||||
finalizeExam: boolean;
|
finalizeExam: boolean;
|
||||||
|
pendingEvaluation: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExamState {
|
export interface ExamState {
|
||||||
@@ -39,6 +40,7 @@ export interface ExamState {
|
|||||||
currentSolution?: UserSolution | undefined;
|
currentSolution?: UserSolution | undefined;
|
||||||
navigation: Navigation;
|
navigation: Navigation;
|
||||||
flags: StateFlags,
|
flags: StateFlags,
|
||||||
|
evaluated: UserSolution[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -63,6 +65,8 @@ export interface ExamFunctions {
|
|||||||
|
|
||||||
setTimeIsUp: (timeIsUp: boolean) => void;
|
setTimeIsUp: (timeIsUp: boolean) => void;
|
||||||
|
|
||||||
|
setEvaluated: (evaluated: UserSolution[]) => void,
|
||||||
|
|
||||||
saveSession: () => Promise<void>;
|
saveSession: () => Promise<void>;
|
||||||
|
|
||||||
saveStats: () => Promise<void>;
|
saveStats: () => Promise<void>;
|
||||||
|
|||||||
@@ -1,165 +1,112 @@
|
|||||||
import {
|
import {
|
||||||
Evaluation,
|
|
||||||
Exam,
|
|
||||||
InteractiveSpeakingExercise,
|
InteractiveSpeakingExercise,
|
||||||
SpeakingExam,
|
|
||||||
SpeakingExercise,
|
SpeakingExercise,
|
||||||
UserSolution,
|
UserSolution,
|
||||||
WritingExam,
|
|
||||||
WritingExercise,
|
WritingExercise,
|
||||||
} from "@/interfaces/exam";
|
} from "@/interfaces/exam";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {speakingReverseMarking, writingReverseMarking} from "./score";
|
|
||||||
|
|
||||||
export const evaluateWritingAnswer = async (
|
export const evaluateWritingAnswer = async (
|
||||||
|
userId: string,
|
||||||
|
sessionId: string,
|
||||||
exercise: WritingExercise,
|
exercise: WritingExercise,
|
||||||
task: number,
|
task: number,
|
||||||
solution: UserSolution,
|
solution: UserSolution,
|
||||||
id: string,
|
): Promise<void> => {
|
||||||
): Promise<UserSolution | undefined> => {
|
await axios.post("/api/evaluate/writing", {
|
||||||
const response = await axios.post<Evaluation>("/api/evaluate/writing", {
|
|
||||||
question: `${exercise.prompt} ${exercise.attachment ? exercise.attachment.description : ""}`.replaceAll("\n", ""),
|
question: `${exercise.prompt} ${exercise.attachment ? exercise.attachment.description : ""}`.replaceAll("\n", ""),
|
||||||
answer: solution.solutions[0].solution.trim().replaceAll("\n", " "),
|
answer: solution.solutions[0].solution.trim().replaceAll("\n", " "),
|
||||||
task,
|
task,
|
||||||
id,
|
userId,
|
||||||
|
sessionId,
|
||||||
|
exerciseId: exercise.id
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 200) {
|
|
||||||
return {
|
|
||||||
...solution,
|
|
||||||
id,
|
|
||||||
score: {
|
|
||||||
correct: response.data ? writingReverseMarking[response.data.overall] : 0,
|
|
||||||
missing: 0,
|
|
||||||
total: 100,
|
|
||||||
},
|
|
||||||
solutions: [{id: exercise.id, solution: solution.solutions[0].solution, evaluation: response.data}],
|
|
||||||
isDisabled: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const evaluateSpeakingAnswer = async (
|
export const evaluateSpeakingAnswer = async (
|
||||||
|
userId: string,
|
||||||
|
sessionId: string,
|
||||||
exercise: SpeakingExercise | InteractiveSpeakingExercise,
|
exercise: SpeakingExercise | InteractiveSpeakingExercise,
|
||||||
solution: UserSolution,
|
solution: UserSolution,
|
||||||
id: string,
|
|
||||||
task: number,
|
task: number,
|
||||||
): Promise<UserSolution | undefined> => {
|
): Promise<void> => {
|
||||||
switch (exercise?.type) {
|
switch (exercise?.type) {
|
||||||
case "speaking":
|
case "speaking":
|
||||||
return {...(await evaluateSpeakingExercise(exercise, exercise.id, solution, id, task)), id} as UserSolution;
|
await evaluateSpeakingExercise(userId, sessionId, exercise, solution);
|
||||||
case "interactiveSpeaking":
|
case "interactiveSpeaking":
|
||||||
return {...(await evaluateInteractiveSpeakingExercise(exercise.id, solution, id, task === 3 ? "final" : "initial")), id} as UserSolution;
|
await evaluateInteractiveSpeakingExercise(userId, sessionId, exercise.id, solution, task);
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const downloadBlob = async (url: string): Promise<Buffer> => {
|
export const downloadBlob = async (url: string): Promise<Buffer> => {
|
||||||
const blobResponse = await axios.get(url, {responseType: "arraybuffer"});
|
const blobResponse = await axios.get(url, { responseType: "arraybuffer" });
|
||||||
return Buffer.from(blobResponse.data, "binary");
|
return Buffer.from(blobResponse.data, "binary");
|
||||||
};
|
};
|
||||||
|
|
||||||
const evaluateSpeakingExercise = async (
|
const evaluateSpeakingExercise = async (
|
||||||
|
userId: string,
|
||||||
|
sessionId: string,
|
||||||
exercise: SpeakingExercise,
|
exercise: SpeakingExercise,
|
||||||
exerciseId: string,
|
|
||||||
solution: UserSolution,
|
solution: UserSolution,
|
||||||
id: string,
|
): Promise<void> => {
|
||||||
task: number,
|
|
||||||
): Promise<UserSolution | undefined> => {
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
const url = solution.solutions[0].solution.trim() as string;
|
const url = solution.solutions[0].solution.trim() as string;
|
||||||
const audioBlob = await downloadBlob(url);
|
const audioBlob = await downloadBlob(url);
|
||||||
const audioFile = new File([audioBlob], "audio.wav", {type: "audio/wav"});
|
const audioFile = new File([audioBlob], "audio.wav", { type: "audio/wav" });
|
||||||
|
|
||||||
if (url && !url.startsWith("blob")) await axios.post("/api/storage/delete", {path: url});
|
if (url && !url.startsWith("blob")) {
|
||||||
|
await axios.post("/api/storage/delete", { path: url });
|
||||||
|
}
|
||||||
|
|
||||||
formData.append("audio", audioFile, "audio.wav");
|
formData.append("userId", userId);
|
||||||
|
formData.append("sessionId", sessionId);
|
||||||
|
formData.append("exerciseId", exercise.id);
|
||||||
|
|
||||||
const evaluationQuestion = `${exercise.text.replaceAll("\n", "")}` + (exercise.prompts.length > 0 ? `You should talk about: ${exercise.prompts.join(", ")}` : "");
|
const evaluationQuestion = `${exercise.text.replaceAll("\n", "")}` + (exercise.prompts.length > 0 ? `You should talk about: ${exercise.prompts.join(", ")}` : "");
|
||||||
formData.append("question", evaluationQuestion);
|
formData.append("question_1", evaluationQuestion);
|
||||||
formData.append("id", id);
|
formData.append("audio_1", audioFile, "audio.wav");
|
||||||
formData.append("task", task.toString());
|
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "audio/wav",
|
"Content-Type": "multipart/form-data",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await axios.post("/api/evaluate/speaking", formData, config);
|
await axios.post(`/api/evaluate/speaking`, formData, config);
|
||||||
|
|
||||||
if (response.status === 200) {
|
|
||||||
return {
|
|
||||||
...solution,
|
|
||||||
id,
|
|
||||||
score: {
|
|
||||||
correct: 0,
|
|
||||||
missing: 0,
|
|
||||||
total: 100,
|
|
||||||
},
|
|
||||||
solutions: [{id: exerciseId, solution: response.data ? response.data.fullPath : null, evaluation: response.data}],
|
|
||||||
isDisabled: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const evaluateInteractiveSpeakingExercise = async (
|
const evaluateInteractiveSpeakingExercise = async (
|
||||||
|
userId: string,
|
||||||
|
sessionId: string,
|
||||||
exerciseId: string,
|
exerciseId: string,
|
||||||
solution: UserSolution,
|
solution: UserSolution,
|
||||||
id: string,
|
task: number,
|
||||||
variant?: "initial" | "final",
|
): Promise<void> => {
|
||||||
): Promise<UserSolution | undefined> => {
|
|
||||||
const promiseParts = solution.solutions.map(async (x: {prompt: string; blob: string}) => {
|
|
||||||
const blob = await downloadBlob(x.blob);
|
|
||||||
if (!x.blob.startsWith("blob")) await axios.post("/api/storage/delete", {path: x.blob});
|
|
||||||
|
|
||||||
return {
|
|
||||||
question: x.prompt,
|
|
||||||
answer: blob,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const body = await Promise.all(promiseParts);
|
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
body.forEach(({question, answer}) => {
|
formData.append("userId", userId);
|
||||||
const seed = Math.random().toString().replace("0.", "");
|
formData.append("sessionId", sessionId);
|
||||||
|
formData.append("exerciseId", exerciseId);
|
||||||
|
formData.append("task", task.toString());
|
||||||
|
|
||||||
const audioFile = new File([answer], `${seed}.wav`, {type: "audio/wav"});
|
const promiseParts = solution.solutions.map(async (x: { prompt: string; blob: string }, index: number) => {
|
||||||
|
const audioBlob = await downloadBlob(x.blob);
|
||||||
|
if (!x.blob.startsWith("blob")) {
|
||||||
|
await axios.post("/api/storage/delete", { path: x.blob });
|
||||||
|
}
|
||||||
|
const audioFile = new File([audioBlob], "audio.wav", { type: "audio/wav" });
|
||||||
|
|
||||||
formData.append(`question_${seed}`, question);
|
formData.append(`question_${index + 1}`, x.prompt);
|
||||||
formData.append(`answer_${seed}`, audioFile, `${seed}.wav`);
|
formData.append(`audio_${index + 1}`, audioFile, "audio.wav");
|
||||||
});
|
});
|
||||||
formData.append("id", id);
|
|
||||||
formData.append("variant", variant || "final");
|
await Promise.all(promiseParts);
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "audio/mp3",
|
"Content-Type": "multipart/form-data",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await axios.post("/api/evaluate/interactiveSpeaking", formData, config);
|
await axios.post(`/api/evaluate/interactiveSpeaking`, formData, config);
|
||||||
|
|
||||||
if (response.status === 200) {
|
|
||||||
return {
|
|
||||||
...solution,
|
|
||||||
id,
|
|
||||||
score: {
|
|
||||||
correct: 0,
|
|
||||||
missing: 0,
|
|
||||||
total: 100,
|
|
||||||
},
|
|
||||||
module: "speaking",
|
|
||||||
solutions: [{id: exerciseId, solution: response.data ? response.data.answer : null, evaluation: response.data}],
|
|
||||||
isDisabled: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user