feat: complete exam lifecycle — AI generation, submission, student session, and results

- Backend: AI generation fallbacks when OpenAI not configured, full exam
  submission saving all params (difficulty, rubric, entity, grading system,
  approval workflow) and creating linked question records per section
- Backend: new exam session controller with get_session, autosave, submit,
  status, and results endpoints; student attempt/answer/score models
- Backend: new controllers for entities, approval workflows, exam schedules
- Frontend: exam session split-layout with passage panel, question types
  (MCQ, T/F/NG, gap-fill, writing, speaking), timer, and review dialog
- Frontend: results page with percentage score, per-answer breakdown table
- Frontend: generation page dynamic dropdowns, full payload submission
- Frontend: updated types for ExamSessionSection, ExamQuestion options

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-16 16:53:09 +04:00
parent b9df9b5299
commit 50f58dc995
40 changed files with 5982 additions and 503 deletions

View File

@@ -39,20 +39,35 @@ interface FeedbackEntry {
source: string;
}
interface AnswerEntry {
question_id: number;
answer: string;
score: number;
is_correct: boolean;
feedback: string;
}
interface ExamResultsData {
attempt_id: number;
exam_id: number | null;
exam_title?: string;
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[];
completed_at?: string;
released_at?: string;
started_at?: string | null;
finished_at?: string | null;
total_score?: number;
max_score?: number;
percentage?: number;
listening_band?: number;
reading_band?: number;
writing_band?: number;
speaking_band?: number;
overall_band?: number;
cefr_level?: string;
scores?: ScoreEntry[];
feedback?: FeedbackEntry[];
answers?: AnswerEntry[];
}
export default function ExamResults() {
@@ -99,11 +114,13 @@ export default function ExamResults() {
);
}
const overall = results.overall_band;
const hasDetailedScores = Array.isArray(results.scores) && results.scores.length > 0;
const overall = results.overall_band ?? 0;
const pct = results.percentage ?? (results.max_score ? Math.round((results.total_score ?? 0) / results.max_score * 100) : 0);
const cefr = results.cefr_level?.toUpperCase() || "N/A";
const passed = overall >= 7;
const passed = hasDetailedScores ? overall >= 7 : pct >= 70;
const skillScores = results.scores.filter((s) => s.skill !== "overall");
const skillScores = hasDetailedScores ? 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,
@@ -114,25 +131,39 @@ export default function ExamResults() {
const RADAR_DATA = SKILLS.map((s) => ({ skill: s.skill, band: s.band }));
const feedbackList = results.feedback ?? [];
const feedbackBySkill: Record<string, FeedbackEntry[]> = {};
for (const fb of results.feedback) {
for (const fb of feedbackList) {
const key = "General";
if (!feedbackBySkill[key]) feedbackBySkill[key] = [];
feedbackBySkill[key].push(fb);
}
const answerEntries = results.answers ?? [];
return (
<div className="mx-auto max-w-5xl space-y-8 p-6">
<div className="text-center">
{results.exam_title && (
<h1 className="text-xl font-semibold mb-1">{results.exam_title}</h1>
)}
<p className="text-sm text-muted-foreground">Your overall result</p>
<div className="mt-2 flex items-center justify-center gap-3">
<Award className="h-12 w-12 text-primary" />
<span className="text-5xl font-bold tabular-nums">{overall}</span>
<Badge variant="secondary" className="text-lg">
Band
</Badge>
</div>
<p className="mt-2 text-lg text-muted-foreground">CEFR equivalent: {cefr}</p>
{hasDetailedScores ? (
<div className="mt-2 flex items-center justify-center gap-3">
<Award className="h-12 w-12 text-primary" />
<span className="text-5xl font-bold tabular-nums">{overall}</span>
<Badge variant="secondary" className="text-lg">Band</Badge>
</div>
) : (
<div className="mt-2 flex items-center justify-center gap-3">
<Award className="h-12 w-12 text-primary" />
<span className="text-5xl font-bold tabular-nums">{pct}%</span>
</div>
)}
<p className="mt-1 text-sm text-muted-foreground">
{results.total_score ?? 0} / {results.max_score ?? 0} marks
</p>
{hasDetailedScores && <p className="mt-1 text-lg text-muted-foreground">CEFR equivalent: {cefr}</p>}
{practice ? <Badge className="mt-2">Practice mode</Badge> : null}
</div>
@@ -185,11 +216,48 @@ export default function ExamResults() {
</CardContent>
</Card>
{results.feedback.length > 0 && (
{answerEntries.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Your Answers</CardTitle>
<CardDescription>
{answerEntries.filter((a) => a.is_correct).length} correct out of {answerEntries.length}
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Your Answer</TableHead>
<TableHead>Score</TableHead>
<TableHead>Result</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{answerEntries.map((a, i) => (
<TableRow key={a.question_id}>
<TableCell>{i + 1}</TableCell>
<TableCell className="max-w-[200px] truncate">{a.answer || "—"}</TableCell>
<TableCell>{a.score}</TableCell>
<TableCell>
<Badge variant={a.is_correct ? "default" : "destructive"}>
{a.is_correct ? "Correct" : "Incorrect"}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{feedbackList.length > 0 && (
<div>
<h2 className="mb-3 text-lg font-semibold">Feedback</h2>
<Accordion type="multiple" className="w-full">
{results.feedback.map((fb, i) => (
{feedbackList.map((fb, i) => (
<AccordionItem key={i} value={`fb-${i}`}>
<AccordionTrigger>
{fb.source === "ai" ? "AI Feedback" : fb.source === "teacher" ? "Teacher Feedback" : "Feedback"}{" "}