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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user