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
137 lines
4.9 KiB
TypeScript
137 lines
4.9 KiB
TypeScript
import { useState } from "react";
|
|
import { useParams } from "react-router-dom";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { useApproveQuality, useQualityGate, useRejectQuality } from "@/hooks/queries/useAiCourse";
|
|
import { CheckCircle2, XCircle } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
export default function AiEnglishQuality() {
|
|
const { courseId: cid } = useParams<{ courseId: string }>();
|
|
const courseId = Number(cid);
|
|
const { data, isLoading, refetch } = useQualityGate(Number.isFinite(courseId) ? courseId : undefined);
|
|
const approve = useApproveQuality();
|
|
const reject = useRejectQuality();
|
|
const [rejectOpen, setRejectOpen] = useState(false);
|
|
const [notes, setNotes] = useState("");
|
|
|
|
const checks = data?.checks ?? [];
|
|
|
|
if (isLoading) {
|
|
return <div className="p-6 text-muted-foreground">Loading quality gate…</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 p-6 max-w-5xl mx-auto">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">English course quality</h1>
|
|
<p className="text-muted-foreground text-sm mt-1">
|
|
Attempts {data?.attempts ?? 0} / {data?.max_attempts ?? 0}
|
|
</p>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Quality gate checks</CardTitle>
|
|
<CardDescription>Readability, calibration, grammar, and length</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="rounded-md border overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Check</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Detail</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{checks.map((c) => (
|
|
<TableRow key={c.name}>
|
|
<TableCell className="font-medium">{c.name}</TableCell>
|
|
<TableCell>
|
|
<span className="inline-flex items-center gap-1">
|
|
{c.status === "pass" ? (
|
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
|
) : (
|
|
<XCircle className="h-4 w-4 text-destructive" />
|
|
)}
|
|
{c.status}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground max-w-md">{c.detail}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-3">
|
|
<Button
|
|
onClick={() =>
|
|
approve.mutate(courseId, {
|
|
onSuccess: () => {
|
|
toast.success("Approved.");
|
|
refetch();
|
|
},
|
|
onError: (e) => toast.error(e instanceof Error ? e.message : "Failed"),
|
|
})
|
|
}
|
|
disabled={!Number.isFinite(courseId) || approve.isPending}
|
|
>
|
|
Approve
|
|
</Button>
|
|
<Button variant="destructive" onClick={() => setRejectOpen(true)}>
|
|
Reject & Regenerate
|
|
</Button>
|
|
<Button variant="outline" onClick={() => refetch()}>
|
|
Edit
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Dialog open={rejectOpen} onOpenChange={setRejectOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Regeneration notes</DialogTitle>
|
|
</DialogHeader>
|
|
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="What should change?" rows={4} />
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setRejectOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() =>
|
|
reject.mutate(
|
|
{ courseId, reason: notes },
|
|
{
|
|
onSuccess: () => {
|
|
toast.success("Rejected; regeneration requested.");
|
|
setRejectOpen(false);
|
|
refetch();
|
|
},
|
|
onError: (e) => toast.error(e instanceof Error ? e.message : "Failed"),
|
|
},
|
|
)
|
|
}
|
|
disabled={reject.isPending}
|
|
>
|
|
Submit
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|