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:
@@ -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"}{" "}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useExamSession, useExamAutoSave, useExamSubmit } from "@/hooks/queries/useExamSession";
|
||||
import type { ExamAnswer, ExamQuestion, ExamSessionSection } from "@/types";
|
||||
import type { ExamAnswer, ExamOptionItem, ExamQuestion, ExamSessionSection } from "@/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
@@ -27,6 +27,11 @@ function normalizeType(t: string | null | undefined) {
|
||||
return (t ?? "").toLowerCase().replace(/\s+/g, "_");
|
||||
}
|
||||
|
||||
function normalizeOption(o: ExamOptionItem): { label: string; text: string } {
|
||||
if (typeof o === "string") return { label: o, text: o };
|
||||
return { label: o.label ?? o.text ?? "", text: o.text ?? o.label ?? "" };
|
||||
}
|
||||
|
||||
function countWords(s: string) {
|
||||
return s.trim() ? s.trim().split(/\s+/).length : 0;
|
||||
}
|
||||
@@ -222,18 +227,19 @@ export default function ExamSession() {
|
||||
const a = answers.get(q.id);
|
||||
if (!a) return null;
|
||||
const nt = normalizeType(q.type);
|
||||
const opts = (q.options ?? []).map(normalizeOption);
|
||||
|
||||
if (nt.includes("listen") || q.audio_url) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ListeningPlayer audioUrl={q.audio_url} audioBase64={q.audio_base64} />
|
||||
{q.options?.length ? (
|
||||
{opts.length ? (
|
||||
<RadioGroup
|
||||
value={typeof a.answer === "string" ? a.answer : ""}
|
||||
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
|
||||
className="space-y-2"
|
||||
>
|
||||
{q.options.map((o) => (
|
||||
{opts.map((o) => (
|
||||
<div key={o.label} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
|
||||
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
|
||||
@@ -249,7 +255,7 @@ export default function ExamSession() {
|
||||
const selected = Array.isArray(a.answer) ? a.answer : [];
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{q.options?.map((o) => (
|
||||
{opts.map((o) => (
|
||||
<div key={o.label} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`${q.id}-${o.label}`}
|
||||
@@ -267,7 +273,7 @@ export default function ExamSession() {
|
||||
);
|
||||
}
|
||||
|
||||
if (nt.includes("gap")) {
|
||||
if (nt.includes("gap") || nt.includes("fill") || nt.includes("short_answer") || nt.includes("summary")) {
|
||||
return (
|
||||
<Input
|
||||
value={typeof a.answer === "string" ? a.answer : ""}
|
||||
@@ -279,12 +285,12 @@ export default function ExamSession() {
|
||||
}
|
||||
|
||||
if (nt.includes("true") || nt.includes("tfng") || nt === "yes_no_not_given") {
|
||||
const opts = q.options?.length
|
||||
? q.options
|
||||
const tfOpts = opts.length
|
||||
? opts
|
||||
: [
|
||||
{ label: "T", text: "True" },
|
||||
{ label: "F", text: "False" },
|
||||
{ label: "NG", text: "Not Given" },
|
||||
{ label: "TRUE", text: "TRUE" },
|
||||
{ label: "FALSE", text: "FALSE" },
|
||||
{ label: "NOT GIVEN", text: "NOT GIVEN" },
|
||||
];
|
||||
return (
|
||||
<RadioGroup
|
||||
@@ -292,7 +298,7 @@ export default function ExamSession() {
|
||||
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
|
||||
className="space-y-2"
|
||||
>
|
||||
{opts.map((o) => (
|
||||
{tfOpts.map((o) => (
|
||||
<div key={o.label} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
|
||||
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
|
||||
@@ -338,7 +344,7 @@ export default function ExamSession() {
|
||||
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
|
||||
className="space-y-2"
|
||||
>
|
||||
{q.options?.map((o) => (
|
||||
{opts.map((o) => (
|
||||
<div key={o.label} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
|
||||
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
|
||||
@@ -389,13 +395,39 @@ export default function ExamSession() {
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 min-h-0 flex-col md:flex-row">
|
||||
<ScrollArea className="flex-1 p-6">
|
||||
{section?.passage_text ? (
|
||||
<ScrollArea className="w-full md:w-1/2 border-r p-6">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<h3 className="text-base font-semibold mb-2">{section.title}</h3>
|
||||
{section.difficulty && (
|
||||
<span className="inline-block text-xs font-medium bg-primary/10 text-primary px-2 py-0.5 rounded mb-3">
|
||||
Level: {section.difficulty}
|
||||
</span>
|
||||
)}
|
||||
<div className="rounded-lg bg-muted/50 p-4 text-sm leading-relaxed whitespace-pre-line">
|
||||
{section.passage_text}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : null}
|
||||
<ScrollArea className={cn("flex-1 p-6", section?.passage_text && "md:w-1/2")}>
|
||||
{question ? (
|
||||
<Card className="border-none shadow-none">
|
||||
<CardContent className="space-y-6 p-0">
|
||||
{question.passage_text ? (
|
||||
<div className="rounded-lg bg-muted/50 p-4 text-sm leading-relaxed">{question.passage_text}</div>
|
||||
{section?.instructions_text ? (
|
||||
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 text-sm leading-relaxed border border-blue-200 dark:border-blue-800">
|
||||
<p className="font-medium text-blue-800 dark:text-blue-200 mb-1">Instructions</p>
|
||||
{section.instructions_text}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center justify-center h-7 w-7 rounded-full bg-primary text-primary-foreground text-xs font-bold">
|
||||
{questionIdx + 1}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
of {section?.questions.length ?? 0} · {question.marks} mark{question.marks !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<p className="font-medium">{question.stem}</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user