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,4 +1,5 @@
import { Link, useParams, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
@@ -19,25 +20,107 @@ import {
PolarRadiusAxis,
ResponsiveContainer,
} from "recharts";
import { Award, BookOpen, Download, RefreshCw } from "lucide-react";
import { Award, BookOpen, Download, RefreshCw, Loader2 } from "lucide-react";
import { examSessionService } from "@/services/exam-session.service";
import { reportService } from "@/services/report.service";
import { useState } from "react";
const SKILLS = [
{ skill: "Listening", band: 7.5, cefr: "C1", gap: 0.5, target: 8 },
{ skill: "Reading", band: 8, cefr: "C1", gap: 0, target: 8 },
{ skill: "Writing", band: 6.5, cefr: "B2", gap: 1.5, target: 8 },
{ skill: "Speaking", band: 7, cefr: "C1", gap: 1, target: 8 },
];
interface ScoreEntry {
skill: string;
band_score: number;
raw_score: number;
max_score: number;
cefr_level: string;
}
const RADAR_DATA = SKILLS.map((s) => ({ skill: s.skill, band: s.band }));
interface FeedbackEntry {
question_id: number | null;
feedback_text: string;
source: string;
}
interface ExamResultsData {
attempt_id: number;
exam_id: number | null;
status: string;
completed_at: string;
released_at: string;
listening_band: number;
reading_band: number;
writing_band: number;
speaking_band: number;
overall_band: number;
cefr_level: string;
scores: ScoreEntry[];
feedback: FeedbackEntry[];
}
export default function ExamResults() {
const { examId } = useParams();
const [searchParams] = useSearchParams();
const practice = searchParams.get("mode") === "practice";
const overall = 7.5;
const cefr = "C1";
const [downloading, setDownloading] = useState(false);
const { data: results, isLoading, isError } = useQuery<ExamResultsData>({
queryKey: ["exam-results", examId],
queryFn: () => examSessionService.getResults(Number(examId)),
enabled: !!examId,
});
const handleDownloadPdf = async () => {
if (!results?.attempt_id) return;
setDownloading(true);
try {
await reportService.downloadPdf(results.attempt_id);
} catch {
// error handled silently
} finally {
setDownloading(false);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (isError || !results) {
return (
<div className="mx-auto max-w-5xl p-6 text-center">
<p className="text-lg font-medium text-destructive">Results not yet available</p>
<p className="text-muted-foreground mt-2">Your results may still be pending approval or grading.</p>
<Button variant="outline" className="mt-4" asChild>
<Link to={`/student/exam/${examId}/status`}>Check Status</Link>
</Button>
</div>
);
}
const overall = results.overall_band;
const cefr = results.cefr_level?.toUpperCase() || "N/A";
const passed = overall >= 7;
const skillScores = results.scores.filter((s) => s.skill !== "overall");
const SKILLS = skillScores.map((s) => ({
skill: s.skill.charAt(0).toUpperCase() + s.skill.slice(1),
band: s.band_score,
cefr: s.cefr_level?.toUpperCase() || "N/A",
gap: Math.max(0, 8 - s.band_score),
target: 8,
}));
const RADAR_DATA = SKILLS.map((s) => ({ skill: s.skill, band: s.band }));
const feedbackBySkill: Record<string, FeedbackEntry[]> = {};
for (const fb of results.feedback) {
const key = "General";
if (!feedbackBySkill[key]) feedbackBySkill[key] = [];
feedbackBySkill[key].push(fb);
}
return (
<div className="mx-auto max-w-5xl space-y-8 p-6">
<div className="text-center">
@@ -53,24 +136,26 @@ export default function ExamResults() {
{practice ? <Badge className="mt-2">Practice mode</Badge> : null}
</div>
<Card>
<CardHeader>
<CardTitle>Skill profile</CardTitle>
<CardDescription>Per-skill performance vs maximum scale</CardDescription>
</CardHeader>
<CardContent>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={RADAR_DATA} cx="50%" cy="50%" outerRadius="80%">
<PolarGrid />
<PolarAngleAxis dataKey="skill" />
<PolarRadiusAxis angle={30} domain={[0, 9]} tickCount={6} />
<Radar name="Band" dataKey="band" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.35} />
</RadarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{RADAR_DATA.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Skill profile</CardTitle>
<CardDescription>Per-skill performance vs maximum scale</CardDescription>
</CardHeader>
<CardContent>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={RADAR_DATA} cx="50%" cy="50%" outerRadius="80%">
<PolarGrid />
<PolarAngleAxis dataKey="skill" />
<PolarRadiusAxis angle={30} domain={[0, 9]} tickCount={6} />
<Radar name="Band" dataKey="band" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.35} />
</RadarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
@@ -100,19 +185,24 @@ export default function ExamResults() {
</CardContent>
</Card>
<div>
<h2 className="mb-3 text-lg font-semibold">Section feedback</h2>
<Accordion type="multiple" className="w-full">
{["Listening", "Reading", "Writing", "Speaking"].map((name) => (
<AccordionItem key={name} value={name}>
<AccordionTrigger>{name}</AccordionTrigger>
<AccordionContent className="text-muted-foreground">
Detailed feedback for {name} will appear here once released by your instructor.
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
{results.feedback.length > 0 && (
<div>
<h2 className="mb-3 text-lg font-semibold">Feedback</h2>
<Accordion type="multiple" className="w-full">
{results.feedback.map((fb, i) => (
<AccordionItem key={i} value={`fb-${i}`}>
<AccordionTrigger>
{fb.source === "ai" ? "AI Feedback" : fb.source === "teacher" ? "Teacher Feedback" : "Feedback"}{" "}
{fb.question_id ? `(Q${fb.question_id})` : ""}
</AccordionTrigger>
<AccordionContent className="text-muted-foreground">
{fb.feedback_text || "No detailed feedback available."}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
)}
<Card>
<CardHeader>
@@ -121,16 +211,21 @@ export default function ExamResults() {
</CardHeader>
<CardContent>
<ul className="list-inside list-disc space-y-2 text-sm">
<li>Strengthen task response structure in Writing Task 2.</li>
<li>Extend range of cohesive devices in argumentative essays.</li>
<li>Maintain fluency while reducing hesitation in Speaking Part 2.</li>
{SKILLS.filter((s) => s.gap > 0)
.sort((a, b) => b.gap - a.gap)
.map((s) => (
<li key={s.skill}>
Focus on <strong>{s.skill}</strong> current band {s.band}, target {s.target} (gap: {s.gap}).
</li>
))}
{SKILLS.every((s) => s.gap === 0) && <li>Excellent performance across all skills!</li>}
</ul>
</CardContent>
</Card>
<div className="flex flex-wrap gap-3">
<Button type="button" variant="outline">
<Download className="mr-2 h-4 w-4" />
<Button type="button" variant="outline" onClick={handleDownloadPdf} disabled={downloading}>
{downloading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
Download PDF Report
</Button>
<Button type="button" asChild>