Finalized the Speaking module exercise

This commit is contained in:
Tiago Ribeiro
2023-07-14 12:08:25 +01:00
parent 6a2fab4f88
commit 2c10a203a5
8 changed files with 128 additions and 24 deletions

View File

@@ -38,6 +38,7 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
const formData = new FormData(); const formData = new FormData();
formData.append("audio", audioFile, "audio.wav"); formData.append("audio", audioFile, "audio.wav");
formData.append("question", `${text.replaceAll("\n", "")} You should talk about: ${prompts.join(", ")}`);
const config = { const config = {
headers: { headers: {
@@ -51,7 +52,7 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
}; };
if (mediaBlob) uploadFile(); if (mediaBlob) uploadFile();
}, [mediaBlob]); }, [mediaBlob, text, prompts]);
return ( return (
<div className="flex flex-col h-full w-full gap-9"> <div className="flex flex-col h-full w-full gap-9">
@@ -200,14 +201,28 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
<Button <Button
color="green" color="green"
variant="outline" variant="outline"
onClick={() => onBack({exercise: id, solutions: [], score: {correct: 1, total: 1, missing: 0}, type})} onClick={() =>
onBack({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full"> className="max-w-[200px] self-end w-full">
Back Back
</Button> </Button>
<Button <Button
color="green" color="green"
disabled={!mediaBlob} disabled={!mediaBlob}
onClick={() => onNext({exercise: id, solutions: [], score: {correct: 1, total: 1, missing: 0}, type})} onClick={() =>
onNext({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full"> className="max-w-[200px] self-end w-full">
Next Next
</Button> </Button>

View File

@@ -100,7 +100,7 @@ export default function Writing({id, prompt, info, type, wordCounter, attachment
<Button <Button
color="green" color="green"
variant="outline" variant="outline"
onClick={() => onBack({exercise: id, solutions: [inputText], score: {correct: 1, total: 1, missing: 0}, type})} onClick={() => onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})}
className="max-w-[200px] self-end w-full"> className="max-w-[200px] self-end w-full">
Back Back
</Button> </Button>

View File

@@ -69,12 +69,11 @@ export type Exercise =
| WritingExercise | WritingExercise
| SpeakingExercise; | SpeakingExercise;
export interface WritingEvaluation { export interface Evaluation {
comment: string; comment: string;
overall: number; overall: number;
task_response: {[key: string]: number}; task_response: {[key: string]: number};
} }
export interface WritingExercise { export interface WritingExercise {
id: string; id: string;
type: "writing"; type: "writing";
@@ -85,11 +84,10 @@ export interface WritingExercise {
url: string; url: string;
description: string; description: string;
}; //* The url for an image to work as an attachment to show the user }; //* The url for an image to work as an attachment to show the user
evaluation?: WritingEvaluation;
userSolutions: { userSolutions: {
id: string; id: string;
solution: string; solution: string;
evaluation?: WritingEvaluation; evaluation?: Evaluation;
}[]; }[];
} }
@@ -102,6 +100,7 @@ export interface SpeakingExercise {
userSolutions: { userSolutions: {
id: string; id: string;
solution: string; solution: string;
evaluation?: Evaluation;
}[]; }[];
} }

View File

@@ -1,6 +1,5 @@
// 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 {getFirestore, doc, getDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import axios from "axios"; import axios from "axios";
@@ -8,6 +7,7 @@ import formidable from "formidable";
import PersistentFile from "formidable/PersistentFile"; import PersistentFile from "formidable/PersistentFile";
import {getStorage, ref, uploadBytes} from "firebase/storage"; import {getStorage, ref, uploadBytes} from "firebase/storage";
import fs from "fs"; import fs from "fs";
import {app} from "@/firebase";
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -17,7 +17,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
return; return;
} }
const storage = getStorage(); const storage = getStorage(app);
const form = formidable({keepExtensions: true, uploadDir: "./"}); const form = formidable({keepExtensions: true, uploadDir: "./"});
form.parse(req, (err, fields, files) => { form.parse(req, (err, fields, files) => {
@@ -26,17 +26,20 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const binary = fs.readFileSync((audioFile as any).filepath).buffer; const binary = fs.readFileSync((audioFile as any).filepath).buffer;
uploadBytes(audioFileRef, binary).then(async (snapshot) => { uploadBytes(audioFileRef, binary).then(async (snapshot) => {
// const backendRequest = await axios.post(`${process.env.BACKEND_URL}/speaking_task`, req.body as Body, { const backendRequest = await axios.post(
// headers: { `${process.env.BACKEND_URL}/speaking_task_1`,
// Authorization: `Bearer ${process.env.BACKEND_JWT}`, {question: (fields.question as string[]).join(""), answer: snapshot.metadata.fullPath},
// }, {
// }); headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
},
},
);
fs.rmSync((audioFile as any).filepath); fs.rmSync((audioFile as any).filepath);
res.status(200).json({...backendRequest.data, fullPath: snapshot.metadata.fullPath});
}); });
}); });
res.status(200).json({ok: true});
} }
export const config = { export const config = {

22
src/pages/api/speaking.ts Normal file
View File

@@ -0,0 +1,22 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {getDownloadURL, getStorage, ref} from "firebase/storage";
import {app} from "@/firebase";
// export default withIronSessionApiRoute(handler, sessionOptions);
export default handler;
async function handler(req: NextApiRequest, res: NextApiResponse) {
// if (!req.session.user) {
// res.status(401).json({ok: false});
// return;
// }
const storage = getStorage(app);
const {path} = req.body as {path: string};
const pathReference = ref(storage, path);
getDownloadURL(pathReference).then((url) => res.status(200).json({url}));
}

View File

@@ -5,7 +5,17 @@ import {Module} from "@/interfaces";
import Selection from "@/exams/Selection"; import Selection from "@/exams/Selection";
import Reading from "@/exams/Reading"; import Reading from "@/exams/Reading";
import {Exam, ListeningExam, ReadingExam, SpeakingExam, UserSolution, WritingEvaluation, WritingExam, WritingExercise} from "@/interfaces/exam"; import {
Exam,
ListeningExam,
ReadingExam,
SpeakingExam,
UserSolution,
Evaluation,
WritingExam,
WritingExercise,
SpeakingExercise,
} from "@/interfaces/exam";
import Listening from "@/exams/Listening"; import Listening from "@/exams/Listening";
import Writing from "@/exams/Writing"; import Writing from "@/exams/Writing";
import {ToastContainer, toast} from "react-toastify"; import {ToastContainer, toast} from "react-toastify";
@@ -19,7 +29,7 @@ import {v4 as uuidv4} from "uuid";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import {writingReverseMarking} from "@/utils/score"; import {speakingReverseMarking, writingReverseMarking} from "@/utils/score";
export const getServerSideProps = withIronSessionSsr(({req, res}) => { export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user; const user = req.session.user;
@@ -119,11 +129,47 @@ export default function Page() {
} }
}; };
const evaluateSpeakingAnswer = async (examId: string, exerciseId: string, solution: UserSolution) => {
const speakingExam = exams.find((x) => x.id === examId)!;
const exercise = speakingExam.exercises.find((x) => x.id === exerciseId)! as SpeakingExercise;
const blobResponse = await axios.get(exercise.userSolutions[0].solution.trim());
const audioBlob = Buffer.from(blobResponse.data, "binary");
const audioFile = new File([audioBlob], "audio.wav", {type: "audio/wav"});
const formData = new FormData();
formData.append("audio", audioFile, "audio.wav");
formData.append("question", `${exercise.text.replaceAll("\n", "")} You should talk about: ${exercise.prompts.join(", ")}`);
const config = {
headers: {
"Content-Type": "audio/mp3",
},
};
const response = await axios.post("/api/evaluate/speaking", formData, config);
if (response.status === 200) {
setUserSolutions([
...userSolutions.filter((x) => x.exercise !== exerciseId),
{
...solution,
score: {
correct: speakingReverseMarking[response.data.overall],
missing: 0,
total: 100,
},
solutions: [{id: exerciseId, solution: response.data.fullPath, evaluation: response.data}],
},
]);
}
};
const evaluateWritingAnswer = async (examId: string, exerciseId: string, solution: UserSolution) => { const evaluateWritingAnswer = async (examId: string, exerciseId: string, solution: UserSolution) => {
const writingExam = exams.find((x) => x.id === examId)!; const writingExam = exams.find((x) => x.id === examId)!;
const exercise = writingExam.exercises.find((x) => x.id === exerciseId)! as WritingExercise; const exercise = writingExam.exercises.find((x) => x.id === exerciseId)! as WritingExercise;
const response = await axios.post<WritingEvaluation>("/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", " "),
}); });
@@ -155,11 +201,17 @@ export default function Page() {
const onFinish = (solutions: UserSolution[]) => { const onFinish = (solutions: UserSolution[]) => {
const solutionIds = solutions.map((x) => x.exercise); const solutionIds = solutions.map((x) => x.exercise);
if (exam && exam.module === "writing" && solutions.length > 0 && !showSolutions) { if (exam && (exam.module === "writing" || exam.module === "speaking") && solutions.length > 0 && !showSolutions) {
setHasBeenUploaded(true); setHasBeenUploaded(true);
setIsEvaluationLoading(true); setIsEvaluationLoading(true);
Promise.all( Promise.all(
exam.exercises.map((exercise) => evaluateWritingAnswer(exam.id, exercise.id, solutions.find((x) => x.exercise === exercise.id)!)), exam.exercises.map((exercise) =>
(exam.module === "writing" ? evaluateWritingAnswer : evaluateSpeakingAnswer)(
exam.id,
exercise.id,
solutions.find((x) => x.exercise === exercise.id)!,
),
),
).finally(() => { ).finally(() => {
setIsEvaluationLoading(false); setIsEvaluationLoading(false);
setHasBeenUploaded(false); setHasBeenUploaded(false);

View File

@@ -6,7 +6,7 @@ import {Module} from "@/interfaces";
import Selection from "@/exams/Selection"; import Selection from "@/exams/Selection";
import Reading from "@/exams/Reading"; import Reading from "@/exams/Reading";
import {Exam, ListeningExam, ReadingExam, SpeakingExam, UserSolution, WritingEvaluation, WritingExam, WritingExercise} from "@/interfaces/exam"; import {Exam, ListeningExam, ReadingExam, SpeakingExam, UserSolution, Evaluation, WritingExam, WritingExercise} from "@/interfaces/exam";
import Listening from "@/exams/Listening"; import Listening from "@/exams/Listening";
import Writing from "@/exams/Writing"; import Writing from "@/exams/Writing";
import {ToastContainer, toast} from "react-toastify"; import {ToastContainer, toast} from "react-toastify";
@@ -126,7 +126,7 @@ export default function Page() {
const writingExam = exams.find((x) => x.id === examId)!; const writingExam = exams.find((x) => x.id === examId)!;
const exercise = writingExam.exercises.find((x) => x.id === exerciseId)! as WritingExercise; const exercise = writingExam.exercises.find((x) => x.id === exerciseId)! as WritingExercise;
const response = await axios.post<WritingEvaluation>("/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", " "),
}); });

View File

@@ -15,6 +15,19 @@ export const writingReverseMarking: {[key: number]: number} = {
0: 0, 0: 0,
}; };
export const speakingReverseMarking: {[key: number]: number} = {
9: 90,
8: 80,
7: 70,
6: 60,
5: 50,
4: 40,
3: 30,
2: 20,
1: 10,
0: 0,
};
const writingMarking: {[key: number]: number} = { const writingMarking: {[key: number]: number} = {
90: 9, 90: 9,
80: 8, 80: 8,