From a96d4c6e5293d0d8fdfbf1b7858ae03f6f8d9a59 Mon Sep 17 00:00:00 2001 From: Carlos-Mesquita Date: Wed, 27 Nov 2024 08:04:18 +0000 Subject: [PATCH] Speaking endpoints and polling fixed --- src/hooks/useEvaluationPolling.tsx | 95 ++++++++++++++++ src/pages/(exam)/ExamPage.tsx | 60 ++-------- src/pages/api/evaluate/interactiveSpeaking.ts | 107 ++++++++++++------ src/pages/api/evaluate/speaking.ts | 56 +++++---- src/utils/evaluation.ts | 4 +- 5 files changed, 215 insertions(+), 107 deletions(-) create mode 100644 src/hooks/useEvaluationPolling.tsx diff --git a/src/hooks/useEvaluationPolling.tsx b/src/hooks/useEvaluationPolling.tsx new file mode 100644 index 00000000..fe54da8f --- /dev/null +++ b/src/hooks/useEvaluationPolling.tsx @@ -0,0 +1,95 @@ +import { UserSolution } from '@/interfaces/exam'; +import useExamStore from '@/stores/exam'; +import { StateFlags } from '@/stores/exam/types'; +import axios from 'axios'; +import { SetStateAction, useEffect, useRef } from 'react'; + +type UseEvaluationPolling = (props: { + pendingExercises: string[], + setPendingExercises: React.Dispatch>, +}) => void; + +const useEvaluationPolling: UseEvaluationPolling = ({ + pendingExercises, + setPendingExercises, +}) => { + const { + flags, sessionId, user, + userSolutions, evaluated, + setEvaluated, setFlags + } = useExamStore(); + + const pollingTimeoutRef = useRef(); + + useEffect(() => { + return () => { + if (pollingTimeoutRef.current) { + clearTimeout(pollingTimeoutRef.current); + } + }; + }, []); + + useEffect(() => { + if (!flags.pendingEvaluation || pendingExercises.length === 0) { + + if (pollingTimeoutRef.current) { + clearTimeout(pollingTimeoutRef.current); + } + return; + } + + const pollStatus = async () => { + try { + const { data } = await axios.get('/api/evaluate/status', { + params: { + sessionId, + userId: user, + 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, + 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) { + pollingTimeoutRef.current = setTimeout(pollStatus, 5000); + } + } catch (error) { + console.error('Evaluation polling error:', error); + pollingTimeoutRef.current = setTimeout(pollStatus, 5000); + } + }; + + pollStatus(); + + return () => { + if (pollingTimeoutRef.current) { + clearTimeout(pollingTimeoutRef.current); + } + }; + }); +}; + +export default useEvaluationPolling; diff --git a/src/pages/(exam)/ExamPage.tsx b/src/pages/(exam)/ExamPage.tsx index bec5aed8..3a5130eb 100644 --- a/src/pages/(exam)/ExamPage.tsx +++ b/src/pages/(exam)/ExamPage.tsx @@ -11,17 +11,17 @@ import Reading from "@/exams/Reading"; import Selection from "@/exams/Selection"; import Speaking from "@/exams/Speaking"; import Writing from "@/exams/Writing"; -import { Exam, ExerciseOnlyExam, LevelExam, PartExam, UserSolution, Variant } from "@/interfaces/exam"; -import { Stat, User } from "@/interfaces/user"; +import { Exam, LevelExam, UserSolution, Variant } from "@/interfaces/exam"; +import { User } from "@/interfaces/user"; import { evaluateSpeakingAnswer, evaluateWritingAnswer } from "@/utils/evaluation"; -import { defaultExamUserSolutions, getExam } from "@/utils/exams"; +import { getExam } from "@/utils/exams"; import axios from "axios"; import { useRouter } from "next/router"; import { toast, ToastContainer } from "react-toastify"; -import { v4 as uuidv4 } from "uuid"; import ShortUniqueId from "short-unique-id"; import { ExamProps } from "@/exams/types"; import useExamStore from "@/stores/exam"; +import useEvaluationPolling from "@/hooks/useEvaluationPolling"; interface Props { page: "exams" | "exercises"; @@ -128,7 +128,8 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar = if (exercise.type === "writing") await evaluateWritingAnswer(user.id, sessionId, exercise, index + 1, userSolutions.find((x) => x.exercise === exercise.id)!); - if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking") + if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking"){ + console.log(userSolutions.find((x) => x.exercise === exercise.id)!); await evaluateSpeakingAnswer( user.id, sessionId, @@ -136,6 +137,7 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar = userSolutions.find((x) => x.exercise === exercise.id)!, index + 1, ); + } }), ) })(); @@ -143,53 +145,7 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar = } }, [exam, showSolutions, userSolutions, sessionId, user?.id, flags]); - useEffect(() => { - 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]); + useEvaluationPolling({pendingExercises, setPendingExercises}); useEffect(() => { if (flags.finalizeExam && moduleIndex !== -1) { diff --git a/src/pages/api/evaluate/interactiveSpeaking.ts b/src/pages/api/evaluate/interactiveSpeaking.ts index 6b76a78d..e217c7e5 100644 --- a/src/pages/api/evaluate/interactiveSpeaking.ts +++ b/src/pages/api/evaluate/interactiveSpeaking.ts @@ -12,51 +12,92 @@ export default withIronSessionApiRoute(handler, sessionOptions); async function handler(req: NextApiRequest, res: NextApiResponse) { if (!req.session.user) { - res.status(401).json({ ok: false }); - return; + res.status(401).json({ ok: false }); + return; } - + const form = formidable({ keepExtensions: true }); - + await form.parse(req, async (err: any, fields: any, files: any) => { - if (err) { - console.log(err); - res.status(500).json({ ok: false }); - return; - } - + if (err) { + console.error('Error parsing form:', err); + res.status(500).json({ ok: false, error: 'Failed to parse form data' }); + return; + } + + try { const formData = new FormData(); + + if (!fields.userId || !fields.sessionId || !fields.exerciseId || !fields.task) { + throw new Error('Missing required fields'); + } + formData.append('userId', fields.userId); formData.append('sessionId', fields.sessionId); formData.append('exerciseId', fields.exerciseId); - - Object.keys(files).forEach(fileKey => { - const index = fileKey.split('_')[1]; - const questionKey = `question_${index}`; - - const audioFile = files[fileKey]; - const binary = fs.readFileSync((audioFile as any).path); - formData.append(`audio_${index}`, binary, 'audio.wav'); + + for (const fileKey of Object.keys(files)) { + const indexMatch = fileKey.match(/^audio_(\d+)$/); + if (!indexMatch) { + console.warn(`Skipping invalid file key: ${fileKey}`); + continue; + } + + const index = indexMatch[1]; + const questionKey = `question_${index}`; + const audioFile = files[fileKey]; + + if (!audioFile || !audioFile.path) { + throw new Error(`Invalid audio file for ${fileKey}`); + } + + if (!fields[questionKey]) { + throw new Error(`Missing question for audio ${index}`); + } + + try { + const buffer = fs.readFileSync(audioFile.path); + formData.append(`audio_${index}`, buffer, `audio_${index}.wav`); formData.append(questionKey, fields[questionKey]); - - fs.rmSync((audioFile as any).path); - }); - + fs.rmSync(audioFile.path); + } catch (fileError) { + console.error(`Error processing file ${fileKey}:`, fileError); + throw new Error(`Failed to process audio file ${index}`); + } + } + await axios.post( - `${process.env.BACKEND_URL}/grade/speaking/${fields.task}`, - formData, - { - headers: { - ...formData.getHeaders(), - Authorization: `Bearer ${process.env.BACKEND_JWT}`, - }, - } + `${process.env.BACKEND_URL}/grade/speaking/${fields.task}`, + formData, + { + headers: { + ...formData.getHeaders(), + Authorization: `Bearer ${process.env.BACKEND_JWT}`, + }, + } ); - + res.status(200).json({ ok: true }); + } catch (error) { + console.error('Error processing request:', error); + res.status(500).json({ + ok: false, + error: 'Internal server error' + }); + + Object.keys(files).forEach(fileKey => { + const audioFile = files[fileKey]; + if (audioFile && audioFile.path && fs.existsSync(audioFile.path)) { + try { + fs.rmSync(audioFile.path); + } catch (cleanupError) { + console.error(`Failed to clean up temp file ${audioFile.path}:`, cleanupError); + } + } + }); + } }); -} - + } export const config = { api: { diff --git a/src/pages/api/evaluate/speaking.ts b/src/pages/api/evaluate/speaking.ts index ca303ed9..faebf428 100644 --- a/src/pages/api/evaluate/speaking.ts +++ b/src/pages/api/evaluate/speaking.ts @@ -25,29 +25,45 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { 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); + try { + 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 binary = fs.readFileSync((audioFile as any).path); - formData.append('audio_1', binary, 'audio.wav'); - fs.rmSync((audioFile as any).path); - - await axios.post( - `${process.env.BACKEND_URL}/grade/speaking/2`, - formData, - { - headers: { - ...formData.getHeaders(), - Authorization: `Bearer ${process.env.BACKEND_JWT}`, - }, + const audioFile = files.audio; + if (!audioFile || !audioFile.path) { + throw new Error('Audio file not found in request'); } - ); - res.status(200).json({ ok: true }); + const buffer = fs.readFileSync(audioFile.path); + formData.append('audio_1', buffer, 'audio_1.wav'); + fs.rmSync(audioFile.path); + + await axios.post( + `${process.env.BACKEND_URL}/grade/speaking/2`, + formData, + { + headers: { + ...formData.getHeaders(), + Authorization: `Bearer ${process.env.BACKEND_JWT}`, + }, + } + ); + + res.status(200).json({ ok: true }); + } catch (error) { + console.error('Error:', error); + if (files.audio?.path && fs.existsSync(files.audio.path)) { + try { + fs.rmSync(files.audio.path); + } catch (e) { + console.error('Failed to cleanup file:', e); + } + } + res.status(500).json({ ok: false }); + } }); } diff --git a/src/utils/evaluation.ts b/src/utils/evaluation.ts index 43e4efa2..19fdd4c1 100644 --- a/src/utils/evaluation.ts +++ b/src/utils/evaluation.ts @@ -64,8 +64,8 @@ const evaluateSpeakingExercise = async ( formData.append("exerciseId", exercise.id); const evaluationQuestion = `${exercise.text.replaceAll("\n", "")}` + (exercise.prompts.length > 0 ? `You should talk about: ${exercise.prompts.join(", ")}` : ""); - formData.append("question_1", evaluationQuestion); - formData.append("audio_1", audioFile, "audio.wav"); + formData.append("question", evaluationQuestion); + formData.append("audio", audioFile, "audio.wav"); const config = { headers: {