Files
encoach_backend_new_v3/frontend/src/pages/student/AiIeltsCourse.tsx
Yamen Ahmad b02ee8b6b7 feat: Generation Page AI workflows + AI/Vector modules + exam session fixes
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
2026-04-11 14:27:03 +04:00

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 },
];
}