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
100 lines
3.3 KiB
TypeScript
100 lines
3.3 KiB
TypeScript
import { useEffect } from "react";
|
|
import { useMutation } from "@tanstack/react-query";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Sparkles, Loader2 } from "lucide-react";
|
|
import { analyticsService } from "@/services/analytics.service";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
|
|
interface Props {
|
|
onAccept: (marks: number, feedback: string) => void;
|
|
submissionId?: number;
|
|
submissionText?: string;
|
|
rubricId?: number;
|
|
}
|
|
|
|
const DEFAULT_TEXT =
|
|
"Sample submission for AI grading suggestion. Replace by passing submissionText when integrating with real submissions.";
|
|
|
|
export default function AiGradingAssistant({
|
|
onAccept,
|
|
submissionId = 1,
|
|
submissionText = DEFAULT_TEXT,
|
|
rubricId,
|
|
}: Props) {
|
|
const { toast } = useToast();
|
|
|
|
const gradeMutation = useMutation({
|
|
mutationFn: () =>
|
|
analyticsService.getGradingSuggestion({
|
|
submission_text: submissionText,
|
|
skill: "writing",
|
|
}),
|
|
onError: (err: Error) => {
|
|
toast({
|
|
variant: "destructive",
|
|
title: "Could not load AI grade",
|
|
description: err.message || "Try again later.",
|
|
});
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
gradeMutation.mutate();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- refetch when inputs change
|
|
}, [submissionId, submissionText, rubricId]);
|
|
|
|
const data = gradeMutation.data;
|
|
const marks = data ? Math.round(data.overall_band * 100 / 9) : 0;
|
|
const feedbackBlock = data
|
|
? [
|
|
data.feedback,
|
|
data.suggestions?.length
|
|
? `Suggestions:\n${data.suggestions.map((s) => `• ${s}`).join("\n")}`
|
|
: "",
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n\n")
|
|
: "";
|
|
|
|
return (
|
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
|
<p className="text-xs font-semibold text-primary flex items-center gap-1">
|
|
<Sparkles className="h-3 w-3" /> AI Suggested Grade
|
|
</p>
|
|
{gradeMutation.isPending ? (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
|
|
<Loader2 className="h-4 w-4 animate-spin text-primary" /> Analyzing submission...
|
|
</div>
|
|
) : gradeMutation.isError ? (
|
|
<p className="text-sm text-destructive">Could not load a suggestion. Check the console or try again.</p>
|
|
) : data ? (
|
|
<>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Suggested marks</p>
|
|
<p className="text-2xl font-bold">{marks}/100</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground mb-1">Suggested feedback</p>
|
|
<p className="text-sm whitespace-pre-wrap">{feedbackBlock}</p>
|
|
</div>
|
|
{data.scores && Object.keys(data.scores).length > 0 && (
|
|
<div className="space-y-1">
|
|
<p className="text-sm text-muted-foreground">Rubric scores</p>
|
|
<ul className="text-xs text-muted-foreground space-y-0.5">
|
|
{Object.entries(data.scores).map(([k, v]) => (
|
|
<li key={k}>
|
|
{k}: {v}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
<Button size="sm" onClick={() => onAccept(marks, feedbackBlock)}>
|
|
<Sparkles className="h-3.5 w-3.5 mr-1" /> Accept AI Grade
|
|
</Button>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|