feat: QA fixes, new APIs (assignments, rubrics, custom exams), Generation page enhancements

- Fix ELAI video generation (correct render endpoint, script splitting for 60s limit)
- Fix speaking script generation error handling and empty response display
- Add custom exam list API (GET /api/exam/custom/list)
- Add assignments REST API (list, create, get)
- Add rubrics REST API (list, create)
- Enhance Generation page: dynamic exam structures, auto-module selection, preview dialog, audio player
- Improve submit feedback with exam ID and status in toast notifications
- Fix ExamsListPage to show both custom exams and exam sessions
- Connect RubricsPage to backend API with fallback data
- Add Dockerfile, docker-compose.yml, requirements.txt for deployment
- Fix placement, grading, scoring, and auth controllers
- Add ErrorBoundary component for frontend resilience
- Add QA report and credentials documentation

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-12 14:26:39 +04:00
parent 571a08d0f7
commit 82ec3debcc
38 changed files with 2324 additions and 243 deletions

View File

@@ -1,5 +1,5 @@
import { useCallback, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@@ -8,6 +8,13 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
@@ -45,6 +52,7 @@ import {
import AiTipBanner from "@/components/ai/AiTipBanner";
import { generationService } from "@/services/generation.service";
import { mediaService, type Avatar } from "@/services/media.service";
import { examsService } from "@/services/exams.service";
import { useToast } from "@/hooks/use-toast";
type ModuleKey = "reading" | "listening" | "writing" | "speaking" | "level" | "industry";
@@ -66,6 +74,7 @@ const MODULES: ModuleInfo[] = [
{ key: "industry", label: "Industry", icon: <Briefcase className="h-4 w-4" />, color: "text-amber-700", bgColor: "bg-amber-50 border-amber-200" },
];
const MODULE_KEYS: ModuleKey[] = MODULES.map((m) => m.key);
const CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"];
const READING_EXERCISE_TYPES = [
@@ -188,6 +197,13 @@ export default function GenerationPage() {
const [selectedModules, setSelectedModules] = useState<Set<ModuleKey>>(new Set());
const [activeModule, setActiveModule] = useState<ModuleKey | null>(null);
const [moduleStates, setModuleStates] = useState<Record<string, ModuleState>>({});
const [previewOpen, setPreviewOpen] = useState(false);
const structuresQ = useQuery({
queryKey: ["exam-structures"],
queryFn: () => examsService.listStructures({}),
});
const structures = structuresQ.data?.items ?? [];
const getModuleState = useCallback((mod: ModuleKey): ModuleState => {
return moduleStates[mod] ?? defaultModuleState(mod);
@@ -263,7 +279,12 @@ export default function GenerationPage() {
if (activeModule !== "listening") return;
const st = getModuleState("listening");
const sections = [...st.listeningSections];
sections[vars.sectionIndex] = { ...sections[vars.sectionIndex], audioUrl: res.audio_url || "" };
let url = res.audio_url || "";
if (!url && res.audio_base64) {
const ct = res.content_type || "audio/mpeg";
url = `data:${ct};base64,${res.audio_base64}`;
}
sections[vars.sectionIndex] = { ...sections[vars.sectionIndex], audioUrl: url };
updateModuleState("listening", { listeningSections: sections });
toast({ title: "Audio generated" });
},
@@ -289,10 +310,18 @@ export default function GenerationPage() {
mutationFn: (params: { topics: string[]; difficulty: string; partIndex: number }) =>
generationService.generateSpeakingScript({ topics: params.topics.filter(Boolean), difficulty: params.difficulty, part: "speaking_1" }),
onSuccess: (res, vars) => {
const r = res as Record<string, unknown>;
if (r.error) {
toast({ variant: "destructive", title: "Script generation failed", description: String(r.error) });
return;
}
const st = getModuleState("speaking");
const parts = [...st.speakingParts];
const r = res as Record<string, unknown>;
const script = (r.script as string) ?? JSON.stringify(r.questions ?? res);
const script = (r.script as string) || "";
if (!script) {
toast({ variant: "destructive", title: "No script returned", description: "AI returned empty response — try again." });
return;
}
parts[vars.partIndex] = { ...parts[vars.partIndex], script };
updateModuleState("speaking", { speakingParts: parts });
toast({ title: "Script generated" });
@@ -313,6 +342,45 @@ export default function GenerationPage() {
onError: (err: Error) => toast({ variant: "destructive", title: "Video generation failed", description: err.message }),
});
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (activeModule !== "speaking") return;
const st = getModuleState("speaking");
const pendingParts = st.speakingParts
.map((p, i) => ({ videoUrl: p.videoUrl, index: i }))
.filter((p) => p.videoUrl.startsWith("pending:"));
if (pendingParts.length === 0) {
if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; }
return;
}
if (pollTimerRef.current) return;
pollTimerRef.current = setInterval(async () => {
const currentSt = getModuleState("speaking");
let changed = false;
const updatedParts = [...currentSt.speakingParts];
for (const pp of pendingParts) {
const part = updatedParts[pp.index];
if (!part || !part.videoUrl.startsWith("pending:")) continue;
const videoId = part.videoUrl.replace("pending:", "");
try {
const status = await mediaService.getVideoStatus(videoId);
if (status.status === "done" || status.status === "completed" || status.video_url) {
updatedParts[pp.index] = { ...part, videoUrl: status.video_url || status.url || "" };
changed = true;
toast({ title: "Video ready!", description: "Avatar video has been generated." });
} else if (status.status === "error" || status.status === "failed") {
updatedParts[pp.index] = { ...part, videoUrl: "" };
changed = true;
toast({ variant: "destructive", title: "Video generation failed" });
}
} catch { /* poll again next interval */ }
}
if (changed) updateModuleState("speaking", { speakingParts: updatedParts });
}, 15000);
return () => { if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeModule, moduleStates]);
const submitMut = useMutation({
mutationFn: (skipApproval: boolean) => {
const modulesPayload: Record<string, unknown> = {};
@@ -332,7 +400,11 @@ export default function GenerationPage() {
}
return generationService.submitExam({ title, label: examLabel, modules: modulesPayload, skip_approval: skipApproval });
},
onSuccess: (res) => toast({ title: "Exam submitted", description: `Exam #${res.exam_id} created (${res.status})` }),
onSuccess: (res) => toast({
title: "Exam submitted successfully",
description: `Exam #${res.exam_id} created with status "${res.status}". ${res.status === "published" ? "The exam is now live." : "Pending approval."}`,
duration: 8000,
}),
onError: (err: Error) => toast({ variant: "destructive", title: "Submit failed", description: err.message }),
});
@@ -482,12 +554,21 @@ export default function GenerationPage() {
<Select><SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Passage Difficulty (Optional)" /></SelectTrigger>
<SelectContent>{CEFR_LEVELS.map((l) => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
</Select>
<Input placeholder="Topic (Optional)" className="h-8 text-xs" id={`topic-${pi}`} />
<Input placeholder="Approximate Word Count (Optional)" className="h-8 text-xs" type="number" id={`wc-${pi}`} />
<Input placeholder="Topic (Optional)" className="h-8 text-xs"
value={passage.category || ""}
onChange={(e) => {
const p = [...st.passages]; p[pi] = { ...p[pi], category: e.target.value };
updateModuleState("reading", { passages: p });
}} />
<Input placeholder="Approximate Word Count (Optional)" className="h-8 text-xs" type="number"
value={passage.divider || ""}
onChange={(e) => {
const p = [...st.passages]; p[pi] = { ...p[pi], divider: e.target.value };
updateModuleState("reading", { passages: p });
}} />
<Button size="sm" className="w-full text-xs" disabled={generatePassageMut.isPending}
onClick={() => {
const topicEl = document.getElementById(`topic-${pi}`) as HTMLInputElement;
generatePassageMut.mutate({ index: pi, topic: topicEl?.value || "", difficulty: st.difficulty[0] || "B2", wordCount: 300 });
generatePassageMut.mutate({ index: pi, topic: passage.category || "", difficulty: st.difficulty[0] || "B2", wordCount: Number(passage.divider) || 300 });
}}>
{generatePassageMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Sparkles className="h-3 w-3 mr-1" />}
Generate
@@ -588,11 +669,15 @@ export default function GenerationPage() {
Audio Context <ChevronDown className="h-3 w-3" />
</CollapsibleTrigger>
<CollapsibleContent className="pt-2 space-y-2">
<Input placeholder="Topic" className="h-8 text-xs" id={`ltopic-${si}`} />
<Input placeholder="Topic" className="h-8 text-xs"
value={sec.category || ""}
onChange={(e) => {
const sections = [...st.listeningSections]; sections[si] = { ...sections[si], category: e.target.value };
updateModuleState("listening", { listeningSections: sections });
}} />
<div className="flex gap-2">
<Button size="sm" className="flex-1 text-xs" onClick={() => {
const topicEl = document.getElementById(`ltopic-${si}`) as HTMLInputElement;
generationService.generateListeningContext({ topic: topicEl?.value || "general conversation", section_type: sec.type })
generationService.generateListeningContext({ topic: sec.category || "general conversation", section_type: sec.type })
.then((res) => {
const r = res as Record<string, unknown>;
const sections = [...st.listeningSections];
@@ -635,7 +720,12 @@ export default function GenerationPage() {
{generateAudioMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Play className="h-3 w-3 mr-1" />}
Generate Audio
</Button>
{sec.audioUrl && <p className="text-xs text-green-600 mt-1">Audio ready</p>}
{sec.audioUrl && (
<div className="mt-2 space-y-1">
<p className="text-xs text-green-600">Audio ready</p>
<audio controls src={sec.audioUrl} className="w-full h-8" />
</div>
)}
</CollapsibleContent>
</Collapsible>
</CardContent>
@@ -694,14 +784,18 @@ export default function GenerationPage() {
Generate Instructions <ChevronDown className="h-3 w-3" />
</CollapsibleTrigger>
<CollapsibleContent className="pt-2 space-y-2">
<Input placeholder="Topic" className="h-8 text-xs" id={`wtopic-${ti}`} />
<Input placeholder="Topic" className="h-8 text-xs"
value={task.category || ""}
onChange={(e) => {
const tasks = [...st.writingTasks]; tasks[ti] = { ...tasks[ti], category: e.target.value };
updateModuleState("writing", { writingTasks: tasks });
}} />
<Select><SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Difficulty (Optional)" /></SelectTrigger>
<SelectContent>{CEFR_LEVELS.map((l) => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
</Select>
<Button size="sm" className="w-full text-xs" disabled={generateWritingMut.isPending}
onClick={() => {
const topicEl = document.getElementById(`wtopic-${ti}`) as HTMLInputElement;
generateWritingMut.mutate({ topic: topicEl?.value || "general", difficulty: st.difficulty[0] || "A1", taskIndex: ti });
generateWritingMut.mutate({ topic: task.category || "general", difficulty: st.difficulty[0] || "A1", taskIndex: ti });
}}>
{generateWritingMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Sparkles className="h-3 w-3 mr-1" />}
Generate
@@ -831,7 +925,18 @@ export default function GenerationPage() {
{generateVideoMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Video className="h-3 w-3 mr-1" />}
Generate Video
</Button>
{part.videoUrl && <p className="text-xs text-green-600">Video: {part.videoUrl.startsWith("pending:") ? "Processing..." : "Ready"}</p>}
{part.videoUrl && part.videoUrl.startsWith("pending:") && (
<div className="flex items-center gap-1 mt-1">
<Loader2 className="h-3 w-3 animate-spin text-orange-500" />
<p className="text-xs text-orange-600">Video processing... (polling every 15s)</p>
</div>
)}
{part.videoUrl && !part.videoUrl.startsWith("pending:") && (
<div className="mt-2 space-y-1">
<p className="text-xs text-green-600">Video ready</p>
<video controls src={part.videoUrl} className="w-full rounded max-h-48" />
</div>
)}
</CollapsibleContent>
</Collapsible>
</CardContent>
@@ -893,13 +998,27 @@ export default function GenerationPage() {
</div>
<div className="space-y-1">
<Label>Exam Structure</Label>
<Select value={examStructure} onValueChange={setExamStructure}>
<Select value={examStructure} onValueChange={(val) => {
setExamStructure(val);
const s = structures.find((st) => String(st.id) === val);
if (s) {
const mods = (s as Record<string, unknown>).modules as string[] | undefined;
if (Array.isArray(mods) && mods.length) {
const next = new Set<ModuleKey>();
mods.forEach((m) => { if (MODULE_KEYS.includes(m as ModuleKey)) next.add(m as ModuleKey); });
setSelectedModules(next);
if (next.size > 0) setActiveModule([...next][0]);
}
}
}}>
<SelectTrigger><SelectValue placeholder="Select an exam structure" /></SelectTrigger>
<SelectContent>
<SelectItem value="standard_ielts">Standard IELTS Academic</SelectItem>
<SelectItem value="corporate">Corporate English Assessment</SelectItem>
<SelectItem value="hospitality">Hospitality English Test</SelectItem>
<SelectItem value="medical">Medical English Proficiency</SelectItem>
{structures.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
))}
{structures.length === 0 && (
<SelectItem value="_none" disabled>No structures available</SelectItem>
)}
</SelectContent>
</Select>
</div>
@@ -971,11 +1090,107 @@ export default function GenerationPage() {
<Button variant="outline" disabled={anyGenerating || !title} onClick={() => submitMut.mutate(true)}>
<SkipForward className="h-4 w-4 mr-2" /> Submit module as exam and skip approval
</Button>
<Button variant="outline" disabled>
<Button variant="outline" disabled={!activeModule} onClick={() => setPreviewOpen(true)}>
<Eye className="h-4 w-4 mr-2" /> Preview module
</Button>
</div>
)}
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{title || "Untitled Exam"} Preview</DialogTitle>
<DialogDescription>
Read-only preview of all configured modules and their content.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 pt-2">
{[...selectedModules].map((mod) => {
const st = getModuleState(mod);
return (
<div key={mod} className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-lg capitalize">{mod}</h3>
<div className="flex gap-2">
<Badge variant="outline">{st.timer} min</Badge>
{st.difficulty.map((d) => <Badge key={d} variant="secondary">{d}</Badge>)}
<Badge variant={st.accessType === "public" ? "default" : "outline"}>{st.accessType}</Badge>
</div>
</div>
{mod === "reading" && st.passages.map((p, i) => (
<div key={i} className="bg-muted/50 rounded p-3 space-y-2">
<h4 className="font-medium text-sm">Passage {i + 1} {p.category && `(${p.category})`}</h4>
{p.text ? (
<p className="text-sm whitespace-pre-wrap leading-relaxed">{p.text.slice(0, 500)}{p.text.length > 500 ? "…" : ""}</p>
) : (
<p className="text-sm text-muted-foreground italic">No passage text yet</p>
)}
{p.exercises.length > 0 && (
<p className="text-xs text-muted-foreground">{p.exercises.length} exercise(s) generated</p>
)}
</div>
))}
{mod === "listening" && st.listeningSections.map((s, i) => (
<div key={i} className="bg-muted/50 rounded p-3 space-y-2">
<h4 className="font-medium text-sm">Section {i + 1}: {s.type.replace(/_/g, " ")}</h4>
{s.context ? (
<p className="text-sm whitespace-pre-wrap leading-relaxed">{s.context.slice(0, 500)}{s.context.length > 500 ? "…" : ""}</p>
) : (
<p className="text-sm text-muted-foreground italic">No context yet</p>
)}
{s.audioUrl && <p className="text-xs text-green-600">Audio generated</p>}
{s.exercises.length > 0 && (
<p className="text-xs text-muted-foreground">{s.exercises.length} exercise(s) generated</p>
)}
</div>
))}
{mod === "writing" && st.writingTasks.map((t, i) => (
<div key={i} className="bg-muted/50 rounded p-3 space-y-2">
<div className="flex items-center justify-between">
<h4 className="font-medium text-sm">Task {i + 1}</h4>
<span className="text-xs text-muted-foreground">{t.wordLimit} words · {t.marks} marks</span>
</div>
{t.instructions ? (
<p className="text-sm whitespace-pre-wrap leading-relaxed">{t.instructions}</p>
) : (
<p className="text-sm text-muted-foreground italic">No instructions yet</p>
)}
</div>
))}
{mod === "speaking" && st.speakingParts.map((p, i) => (
<div key={i} className="bg-muted/50 rounded p-3 space-y-2">
<div className="flex items-center justify-between">
<h4 className="font-medium text-sm">Part {i + 1}: {p.type.replace(/_/g, " ")}</h4>
<span className="text-xs text-muted-foreground">{p.marks} marks</span>
</div>
{p.script ? (
<p className="text-sm whitespace-pre-wrap leading-relaxed">{p.script.slice(0, 600)}{p.script.length > 600 ? "…" : ""}</p>
) : (
<p className="text-sm text-muted-foreground italic">No script yet</p>
)}
{p.videoUrl && !p.videoUrl.startsWith("pending:") && (
<p className="text-xs text-green-600">Video ready</p>
)}
{p.videoUrl && p.videoUrl.startsWith("pending:") && (
<p className="text-xs text-orange-600">Video processing</p>
)}
</div>
))}
{st.shuffling && <p className="text-xs text-muted-foreground">Shuffling enabled</p>}
</div>
);
})}
{selectedModules.size === 0 && (
<p className="text-muted-foreground text-center py-8">No modules selected yet.</p>
)}
</div>
</DialogContent>
</Dialog>
</div>
);
}