Generation Page (complete rebuild): - Full production-parity exam generation wizard with 4 IELTS modules - Reading: AI passage gen, 5 exercise types (MCQ, Fill, Write, T/F, Match) - Listening: 4 section types, AI context gen, TTS audio gen (ElevenLabs) - Writing: Task 1/2, AI instruction gen, word limits, marks - Speaking: 3 parts, AI script gen, avatar video gen (7 avatars) - Per-module config: timer, CEFR difficulty, access, approval, rubrics - Exam submission workflow (draft/published) Exam Structures: - New encoach.exam.structure model + CRUD controller - ExamStructuresPage wired to real API AI Module (encoach_ai): - OpenAI service, ElevenLabs TTS, AWS Polly, ELAI avatars - AI settings model with Odoo config parameters - 7 generation endpoints (passage, exercises, instructions, scripts, context) Vector Module (encoach_vector): - pgvector integration for RAG-based content search - Embedding service with sentence-transformers Exam Session Fixes: - Fixed ExamSession.tsx field mapping (question_type→type, exam_title→title) - Fixed submit payload to include attempt_id and answers - Fixed normalizeType to handle null/undefined Tested: 12/12 API tests passed, browser-verified with real OpenAI calls Made-with: Cursor
317 lines
12 KiB
TypeScript
317 lines
12 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { useParams } from "react-router-dom";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Headphones,
|
|
Mic,
|
|
BookMarked,
|
|
PenLine,
|
|
CheckCircle2,
|
|
CircleDot,
|
|
Lock,
|
|
ClipboardCheck,
|
|
Award,
|
|
} from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Progress } from "@/components/ui/progress";
|
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { useAiCourse, useAiCourseTracks, useCreateIeltsCourse } from "@/hooks/queries/useAiCourse";
|
|
import { queryKeys } from "@/hooks/queries/keys";
|
|
import type { AICourseTrack, AIGenerationStep, AITrackModule } from "@/types";
|
|
import {
|
|
BarChart,
|
|
Bar,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
LineChart,
|
|
Line,
|
|
} from "recharts";
|
|
import { toast } from "sonner";
|
|
|
|
const SKILL_ICONS = [PenLine, BookMarked, Mic, Headphones];
|
|
|
|
export default function AiIeltsCourse() {
|
|
const { courseId: cid } = useParams<{ courseId: string }>();
|
|
const courseId = Number(cid);
|
|
const qc = useQueryClient();
|
|
const { data: course, isLoading } = useAiCourse(Number.isFinite(courseId) ? courseId : undefined);
|
|
const { data: tracksRaw } = useAiCourseTracks(Number.isFinite(courseId) ? courseId : undefined);
|
|
const createIelts = useCreateIeltsCourse();
|
|
|
|
const [targetBand, setTargetBand] = useState("");
|
|
const [readinessOpen, setReadinessOpen] = useState(false);
|
|
|
|
const skillsRanked =
|
|
course?.skills_ranked_by_gap && course.skills_ranked_by_gap.length > 0
|
|
? course.skills_ranked_by_gap
|
|
: [
|
|
{ skill: "Writing", gap: 1.2 },
|
|
{ skill: "Speaking", gap: 0.9 },
|
|
{ skill: "Reading", gap: 0.4 },
|
|
{ skill: "Listening", gap: 0.3 },
|
|
];
|
|
|
|
const weakTypes = course?.weak_question_types ?? {
|
|
Writing: ["Task 2 argument depth"],
|
|
Reading: ["True/False/Not Given"],
|
|
Speaking: ["Part 2 long turn"],
|
|
Listening: ["Form completion"],
|
|
};
|
|
|
|
const skillPanels: { skill: string; steps: AIGenerationStep[]; progress_percent: number }[] =
|
|
course?.ielts_generation_by_skill?.length
|
|
? course.ielts_generation_by_skill
|
|
: ["Writing", "Reading", "Speaking", "Listening"].map((skill, i) => ({
|
|
skill,
|
|
steps: [
|
|
{ name: "Blueprint", status: i === 0 ? ("in_progress" as const) : ("pending" as const) },
|
|
{ name: "Items", status: "pending" as const },
|
|
{ name: "Rubric alignment", status: "pending" as const },
|
|
],
|
|
progress_percent: [35, 20, 10, 5][i] ?? 0,
|
|
}));
|
|
|
|
const tracks: AICourseTrack[] = useMemo(() => {
|
|
if (tracksRaw?.length) return tracksRaw;
|
|
return ["Writing", "Reading", "Speaking", "Listening"].map((name, ti) => ({
|
|
name: `${name} skill`,
|
|
progress_percent: 15 + ti * 8,
|
|
modules: ieltsModules(name),
|
|
}));
|
|
}, [tracksRaw]);
|
|
|
|
const bandSeries = [
|
|
{ label: "L", v: course?.current_bands?.Listening ?? 6.0 },
|
|
{ label: "R", v: course?.current_bands?.Reading ?? 6.5 },
|
|
{ label: "W", v: course?.current_bands?.Writing ?? 5.5 },
|
|
{ label: "S", v: course?.current_bands?.Speaking ?? 6.0 },
|
|
];
|
|
|
|
if (isLoading) {
|
|
return <div className="flex justify-center py-24 text-muted-foreground">Loading…</div>;
|
|
}
|
|
|
|
const tgt = targetBand || String(course?.target_level ?? "6.5");
|
|
|
|
return (
|
|
<div className="space-y-8 p-6 max-w-7xl mx-auto">
|
|
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">AI IELTS course</h1>
|
|
<p className="text-muted-foreground text-sm">Band-focused practice generated for you</p>
|
|
</div>
|
|
<Button variant="secondary" size="sm" onClick={() => setReadinessOpen(true)}>
|
|
<ClipboardCheck className="h-4 w-4 mr-2" />
|
|
Practice exam readiness
|
|
</Button>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>IELTS gap profile</CardTitle>
|
|
<CardDescription>Exam context and targets</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
<div>
|
|
<Label className="text-muted-foreground">Exam type</Label>
|
|
<p className="text-lg font-semibold mt-1">{course?.exam_type ?? "Academic"}</p>
|
|
</div>
|
|
<div>
|
|
<Label className="text-muted-foreground">Current bands</Label>
|
|
<p className="text-sm mt-1">
|
|
{Object.entries(course?.current_bands ?? { L: 6, R: 6.5, W: 5.5, S: 6 })
|
|
.map(([k, v]) => `${k}: ${v}`)
|
|
.join(" · ")}
|
|
</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="tgt">Target band</Label>
|
|
<Input id="tgt" value={tgt} onChange={(e) => setTargetBand(e.target.value)} />
|
|
</div>
|
|
<div>
|
|
<Label className="text-muted-foreground">Ranked by gap</Label>
|
|
<ol className="mt-2 text-sm list-decimal list-inside space-y-1">
|
|
{skillsRanked.map((s) => (
|
|
<li key={s.skill}>
|
|
{s.skill} (Δ {s.gap.toFixed(1)})
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
</CardContent>
|
|
<CardContent className="pt-0 space-y-3">
|
|
<Label className="text-muted-foreground">Weak question types</Label>
|
|
<div className="grid sm:grid-cols-2 gap-3 text-sm">
|
|
{Object.entries(weakTypes).map(([skill, items]) => (
|
|
<div key={skill} className="rounded-md border p-3">
|
|
<div className="font-medium mb-1">{skill}</div>
|
|
<ul className="list-disc list-inside text-muted-foreground">
|
|
{items.map((x) => (
|
|
<li key={x}>{x}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<Button
|
|
disabled={createIelts.isPending}
|
|
onClick={() => {
|
|
const band = Number(targetBand || course?.target_level || 7);
|
|
createIelts.mutate(
|
|
{
|
|
skill: skillsRanked[0]?.skill ?? "writing",
|
|
target_band: Number.isFinite(band) ? band : 7,
|
|
},
|
|
{
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.course(courseId) });
|
|
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.tracks(courseId) });
|
|
toast.success("AI IELTS course started.");
|
|
},
|
|
onError: (e) => toast.error(e instanceof Error ? e.message : "Could not start"),
|
|
},
|
|
);
|
|
}}
|
|
>
|
|
Start AI IELTS Course
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div>
|
|
<h2 className="text-lg font-semibold mb-4">Per-skill generation</h2>
|
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
{skillPanels.map((panel, idx) => {
|
|
const Icon = SKILL_ICONS[idx % SKILL_ICONS.length];
|
|
return (
|
|
<Card key={panel.skill}>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-base flex items-center gap-2">
|
|
<Icon className="h-4 w-4" />
|
|
{panel.skill}
|
|
</CardTitle>
|
|
<CardDescription>{panel.progress_percent}%</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
<Progress value={panel.progress_percent} />
|
|
<ul className="text-sm space-y-1">
|
|
{panel.steps.map((s) => (
|
|
<li key={s.name} className="flex justify-between gap-2">
|
|
<span>{s.name}</span>
|
|
<span className="text-muted-foreground capitalize">{s.status.replace("_", " ")}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h2 className="text-lg font-semibold mb-4">Skill columns</h2>
|
|
<div className="grid gap-4 lg:grid-cols-4">
|
|
{tracks.map((track) => (
|
|
<Card key={track.name}>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm">{track.name}</CardTitle>
|
|
<CardDescription>{track.progress_percent}%</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
<Progress value={track.progress_percent} />
|
|
<Separator />
|
|
<ul className="space-y-2 text-sm">
|
|
{track.modules.map((m) => (
|
|
<li key={m.id} className="flex gap-2">
|
|
<ModIcon st={m.status} />
|
|
<span>{m.title}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Award className="h-5 w-5" />
|
|
Band improvement report
|
|
</CardTitle>
|
|
<CardDescription>Current skill snapshot vs trajectory</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-8 lg:grid-cols-2">
|
|
<div className="h-56">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={bandSeries}>
|
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
|
<XAxis dataKey="label" />
|
|
<YAxis domain={[0, 9]} />
|
|
<Tooltip />
|
|
<Bar dataKey="v" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
<div className="h-56">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<LineChart
|
|
data={[
|
|
{ w: 1, b: 5.6 },
|
|
{ w: 2, b: 6.1 },
|
|
{ w: 3, b: 6.4 },
|
|
{ w: 4, b: 6.8 },
|
|
]}
|
|
>
|
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
|
<XAxis dataKey="w" />
|
|
<YAxis domain={[5, 8]} />
|
|
<Tooltip />
|
|
<Line type="monotone" dataKey="b" stroke="hsl(var(--primary))" strokeWidth={2} dot />
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Dialog open={readinessOpen} onOpenChange={setReadinessOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Practice exam readiness</DialogTitle>
|
|
</DialogHeader>
|
|
<p className="text-sm text-muted-foreground">
|
|
When each skill column reaches the practice threshold, you unlock a full timed paper with authentic pacing and
|
|
AI feedback.
|
|
</p>
|
|
<DialogFooter>
|
|
<Button onClick={() => setReadinessOpen(false)}>Got it</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ModIcon({ st }: { st: AITrackModule["status"] }) {
|
|
if (st === "completed") return <CheckCircle2 className="h-4 w-4 text-green-600 shrink-0" />;
|
|
if (st === "in_progress") return <CircleDot className="h-4 w-4 text-primary shrink-0" />;
|
|
return <Lock className="h-4 w-4 text-muted-foreground shrink-0" />;
|
|
}
|
|
|
|
function ieltsModules(skill: string): AITrackModule[] {
|
|
return [
|
|
{ id: 1, title: `${skill} — Drill set A`, status: "completed", estimated_hours: 2 },
|
|
{ id: 2, title: `${skill} — Drill set B`, status: "in_progress", estimated_hours: 2 },
|
|
{ id: 3, title: `${skill} — Exam-style pack`, status: "locked", estimated_hours: 3 },
|
|
];
|
|
}
|