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
This commit is contained in:
@@ -8,12 +8,13 @@ export default function AiAlertBanner() {
|
||||
const [dismissedIds, setDismissedIds] = useState<Set<string>>(() => new Set());
|
||||
const [errorDismissed, setErrorDismissed] = useState(false);
|
||||
|
||||
const { data: alerts, isLoading, isError, error } = useQuery({
|
||||
const { data: resp, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["ai", "alerts"],
|
||||
queryFn: () => analyticsService.getAlerts(),
|
||||
});
|
||||
|
||||
const visible = alerts?.filter((a) => !dismissedIds.has(a.id)) ?? [];
|
||||
const alerts = resp?.alerts ?? [];
|
||||
const visible = alerts.filter((a, i) => !dismissedIds.has(String(i)));
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -43,7 +44,7 @@ export default function AiAlertBanner() {
|
||||
|
||||
if (isError && errorDismissed) return null;
|
||||
|
||||
if (!alerts?.length) {
|
||||
if (!alerts.length) {
|
||||
return (
|
||||
<div className="rounded-lg border border-muted bg-muted/20 p-4 flex items-start gap-3">
|
||||
<Sparkles className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
|
||||
@@ -56,8 +57,8 @@ export default function AiAlertBanner() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{visible.map((alert) => (
|
||||
<div key={alert.id} className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3">
|
||||
{visible.map((alert, idx) => (
|
||||
<div key={idx} className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium flex items-center gap-1">
|
||||
@@ -69,7 +70,7 @@ export default function AiAlertBanner() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => setDismissedIds((prev) => new Set(prev).add(alert.id))}
|
||||
onClick={() => setDismissedIds((prev) => new Set(prev).add(String(idx)))}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function AiAssistantDrawer() {
|
||||
mutationFn: (message: string) =>
|
||||
coachingService.chat({ message, context: { page: location.pathname } }),
|
||||
onSuccess: (data) => {
|
||||
setMessages((prev) => [...prev, { role: "ai", text: data.message }]);
|
||||
setMessages((prev) => [...prev, { role: "ai", text: data.reply }]);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
|
||||
@@ -25,6 +25,8 @@ export default function AiBatchOptimizer({ batchId }: Props) {
|
||||
},
|
||||
});
|
||||
|
||||
type OptResult = Awaited<ReturnType<typeof analyticsService.getBatchOptimization>>;
|
||||
|
||||
const handleOpen = () => {
|
||||
if (batchId == null) {
|
||||
toast({
|
||||
@@ -39,9 +41,23 @@ export default function AiBatchOptimizer({ batchId }: Props) {
|
||||
mutation.mutate(batchId);
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
toast({ title: "Suggestion Applied", description: "Batch split recommendation has been saved successfully." });
|
||||
const applyMutation = useMutation({
|
||||
mutationFn: () => analyticsService.applyBatchOptimization(batchId!, mutation.data?.optimized ?? []),
|
||||
onSuccess: (res) => {
|
||||
toast({ title: "Suggestion Applied", description: `${res.applied} optimization(s) saved successfully.` });
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Apply failed",
|
||||
description: err.message || "Could not apply batch optimization.",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleApply = () => {
|
||||
applyMutation.mutate();
|
||||
};
|
||||
|
||||
const onOpenChange = (next: boolean) => {
|
||||
@@ -49,9 +65,10 @@ export default function AiBatchOptimizer({ batchId }: Props) {
|
||||
if (!next) mutation.reset();
|
||||
};
|
||||
|
||||
const suggestions = mutation.data ?? [];
|
||||
const showResults = !mutation.isPending && !mutation.isError && suggestions.length > 0;
|
||||
const showEmpty = !mutation.isPending && !mutation.isError && mutation.isSuccess && suggestions.length === 0;
|
||||
const optData = mutation.data as OptResult | undefined;
|
||||
const hasSuggestions = !!optData?.summary;
|
||||
const showResults = !mutation.isPending && !mutation.isError && hasSuggestions;
|
||||
const showEmpty = !mutation.isPending && !mutation.isError && mutation.isSuccess && !hasSuggestions;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -71,20 +88,28 @@ export default function AiBatchOptimizer({ batchId }: Props) {
|
||||
</div>
|
||||
) : mutation.isError ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">Something went wrong. Try again.</p>
|
||||
) : showResults ? (
|
||||
) : showResults && optData ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3 max-h-[50vh] overflow-y-auto">
|
||||
{suggestions.map((s, i) => (
|
||||
<div key={i} className="rounded-lg bg-muted/30 p-4 border border-border/60">
|
||||
<p className="text-xs font-semibold text-primary uppercase tracking-wide mb-1">{s.impact} impact</p>
|
||||
<p className="text-sm font-medium">{s.suggestion}</p>
|
||||
{s.details ? <p className="text-sm text-muted-foreground mt-2 leading-relaxed">{s.details}</p> : null}
|
||||
<div className="rounded-lg bg-muted/30 p-4 border border-border/60">
|
||||
<p className="text-xs font-semibold text-primary uppercase tracking-wide mb-1">{optData.impact} impact</p>
|
||||
<p className="text-sm font-medium">{optData.summary}</p>
|
||||
</div>
|
||||
{Array.isArray(optData.optimized) && optData.optimized.length > 0 && (
|
||||
<div className="space-y-2 max-h-[40vh] overflow-y-auto">
|
||||
{optData.optimized.map((item, i) => (
|
||||
<div key={i} className="rounded-lg bg-muted/20 p-3 border text-sm">
|
||||
{typeof item === "object" && item !== null ? JSON.stringify(item) : String(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button className="flex-1" onClick={handleApply}>
|
||||
Apply Suggestion
|
||||
<Button className="flex-1" onClick={handleApply} disabled={applyMutation.isPending}>
|
||||
{applyMutation.isPending ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Applying...</>
|
||||
) : (
|
||||
"Apply Suggestion"
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Dismiss
|
||||
|
||||
@@ -40,8 +40,9 @@ export default function AiGeneratorModal() {
|
||||
difficulty,
|
||||
count,
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
setLocalExercises(Array.isArray(res.exercises) ? res.exercises : []);
|
||||
onSuccess: (res: Record<string, unknown>) => {
|
||||
const items = Array.isArray(res.questions) ? res.questions : Array.isArray(res.exercises) ? res.exercises : [];
|
||||
setLocalExercises(items);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
@@ -57,6 +58,21 @@ export default function AiGeneratorModal() {
|
||||
generateMutation.mutate();
|
||||
};
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => generationService.saveGenerated(moduleType, localExercises ?? []),
|
||||
onSuccess: (res) => {
|
||||
toast({ title: "Saved", description: `${res.saved} assignments saved successfully.` });
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Save failed",
|
||||
description: err.message || "Could not save generated assignments.",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const generated = localExercises;
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
@@ -188,7 +204,17 @@ export default function AiGeneratorModal() {
|
||||
);
|
||||
})}
|
||||
<div className="flex gap-2">
|
||||
<Button className="flex-1">Save All</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending || !generated?.length}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Saving...</>
|
||||
) : (
|
||||
"Save All"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
|
||||
@@ -19,8 +19,8 @@ export default function AiGradeExplainer({
|
||||
const explainMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
coachingService.explain({
|
||||
context: `IELTS / course grades for student: ${studentName}. Summarize what the scores mean and what to focus on next.`,
|
||||
scores,
|
||||
score_data: scores ?? {},
|
||||
student_context: `IELTS / course grades for student: ${studentName}. Summarize what the scores mean and what to focus on next.`,
|
||||
}),
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
|
||||
@@ -26,9 +26,8 @@ export default function AiGradingAssistant({
|
||||
const gradeMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
analyticsService.getGradingSuggestion({
|
||||
submission_id: submissionId,
|
||||
text: submissionText,
|
||||
...(rubricId !== undefined ? { rubric_id: rubricId } : {}),
|
||||
submission_text: submissionText,
|
||||
skill: "writing",
|
||||
}),
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
@@ -45,7 +44,7 @@ export default function AiGradingAssistant({
|
||||
}, [submissionId, submissionText, rubricId]);
|
||||
|
||||
const data = gradeMutation.data;
|
||||
const marks = data ? Math.round(data.overall_score) : 0;
|
||||
const marks = data ? Math.round(data.overall_band * 100 / 9) : 0;
|
||||
const feedbackBlock = data
|
||||
? [
|
||||
data.feedback,
|
||||
|
||||
@@ -1,32 +1,31 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Sparkles, TrendingUp, AlertTriangle, Trophy, Loader2 } from "lucide-react";
|
||||
import { analyticsService } from "@/services/analytics.service";
|
||||
import type { AiInsight } from "@/types";
|
||||
import { Sparkles, TrendingUp, AlertTriangle, Info, Loader2 } from "lucide-react";
|
||||
import { analyticsService, type AiInsightItem } from "@/services/analytics.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const EMPTY_PAYLOAD: Record<string, unknown> = {};
|
||||
|
||||
function insightIcon(type: AiInsight["type"]) {
|
||||
switch (type) {
|
||||
case "positive":
|
||||
return Trophy;
|
||||
case "warning":
|
||||
function insightIcon(severity: AiInsightItem["severity"]) {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
return AlertTriangle;
|
||||
default:
|
||||
case "warning":
|
||||
return TrendingUp;
|
||||
default:
|
||||
return Info;
|
||||
}
|
||||
}
|
||||
|
||||
function insightColor(type: AiInsight["type"]) {
|
||||
switch (type) {
|
||||
case "positive":
|
||||
return "text-primary";
|
||||
function insightColor(severity: AiInsightItem["severity"]) {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
return "text-destructive";
|
||||
case "warning":
|
||||
return "text-warning";
|
||||
default:
|
||||
return "text-success";
|
||||
return "text-primary";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,10 +50,10 @@ export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
mutation.mutate(data);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- refetch when serialized payload changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [payloadKey]);
|
||||
|
||||
const items = mutation.data ?? [];
|
||||
const items = mutation.data?.insights ?? [];
|
||||
|
||||
return (
|
||||
<Card className="border-0 shadow-sm">
|
||||
@@ -79,19 +78,19 @@ export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
|
||||
)}
|
||||
{!mutation.isPending && items.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{items.map((item) => {
|
||||
const Icon = insightIcon(item.type);
|
||||
const color = insightColor(item.type);
|
||||
{items.map((item, idx) => {
|
||||
const Icon = insightIcon(item.severity);
|
||||
const color = insightColor(item.severity);
|
||||
return (
|
||||
<div key={item.id} className="rounded-lg border bg-muted/30 p-4">
|
||||
<div key={idx} className="rounded-lg border bg-muted/30 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className={`h-4 w-4 ${color}`} />
|
||||
<span className="text-sm font-semibold">{item.title}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{item.description}</p>
|
||||
{item.metric != null && item.value != null && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{item.metric}: {item.value}
|
||||
{item.recommendation && (
|
||||
<p className="text-xs text-muted-foreground mt-2 italic">
|
||||
{item.recommendation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function AiSearchBar() {
|
||||
searchMutation.mutate(query.trim());
|
||||
};
|
||||
|
||||
const results = searchMutation.data;
|
||||
const result = searchMutation.data;
|
||||
|
||||
return (
|
||||
<div className="relative max-w-md w-full">
|
||||
@@ -57,35 +57,43 @@ export default function AiSearchBar() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(searchMutation.isPending || results !== undefined) && (
|
||||
{(searchMutation.isPending || result !== undefined) && (
|
||||
<div className="absolute top-full mt-1 left-0 right-0 z-50 rounded-lg border bg-popover p-3 shadow-md">
|
||||
{searchMutation.isPending ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
AI is searching...
|
||||
</div>
|
||||
) : results && results.length > 0 ? (
|
||||
) : result?.answer ? (
|
||||
<div className="text-sm space-y-2 max-h-64 overflow-y-auto">
|
||||
{results.map((r, i) => (
|
||||
<div
|
||||
key={`${r.title}-${i}`}
|
||||
className="flex items-start gap-2 border-b border-border/60 pb-2 last:border-0 last:pb-0"
|
||||
>
|
||||
<div className="flex items-start gap-2 pb-2">
|
||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{r.title}</p>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">{r.description}</p>
|
||||
{r.url && (
|
||||
<p className="text-muted-foreground">{result.answer}</p>
|
||||
</div>
|
||||
{result.suggestions?.length > 0 && (
|
||||
<div className="border-t pt-2 space-y-1">
|
||||
<p className="text-xs font-semibold text-primary">Related queries</p>
|
||||
{result.suggestions.map((s, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className="text-xs text-primary mt-1 hover:underline"
|
||||
onClick={() => navigate(r.url!)}
|
||||
className="block text-xs text-primary hover:underline"
|
||||
onClick={() => { setQuery(s); searchMutation.mutate(s); }}
|
||||
>
|
||||
Go to {r.url}
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{result.related_actions?.map((a, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className="text-xs text-primary hover:underline"
|
||||
onClick={() => navigate(a.action)}
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -29,8 +29,11 @@ export default function AiStudyCoach() {
|
||||
suggestMutation.mutate();
|
||||
};
|
||||
|
||||
const suggestions = suggestMutation.data?.suggestions ?? [];
|
||||
const planTips = suggestMutation.data?.study_plan_tips ?? [];
|
||||
const d = suggestMutation.data;
|
||||
const suggestions = d ? [d.suggestion, ...(d.focus_areas ?? []).map((a: string) => `Focus area: ${a}`)].filter(Boolean) : [];
|
||||
const planTips = d?.daily_plan?.length
|
||||
? d.daily_plan.map((p: { activity: string; duration_min: number; skill: string }) => `${p.activity} (${p.duration_min}min — ${p.skill})`)
|
||||
: d?.motivation ? [d.motivation] : [];
|
||||
|
||||
return (
|
||||
<Card className="border-0 shadow-sm bg-primary/5">
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function AiTipBanner({ context = "dashboard", variant = "tip", di
|
||||
);
|
||||
}
|
||||
|
||||
if (!data.content?.trim() && !data.title?.trim()) {
|
||||
if (!data.tip?.trim()) {
|
||||
return (
|
||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`}>
|
||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||
@@ -62,14 +62,16 @@ export default function AiTipBanner({ context = "dashboard", variant = "tip", di
|
||||
);
|
||||
}
|
||||
|
||||
const label = data.category && data.category !== "general"
|
||||
? `AI ${data.category.charAt(0).toUpperCase() + data.category.slice(1)} Tip`
|
||||
: `AI ${variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}`;
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3 animate-in fade-in slide-in-from-top-2 duration-300`}>
|
||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<span className="text-xs font-semibold text-primary">
|
||||
{data.title?.trim() || `AI ${variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}`}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{data.content}</p>
|
||||
<span className="text-xs font-semibold text-primary">{label}</span>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{data.tip}</p>
|
||||
</div>
|
||||
{dismissible && (
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={() => setDismissed(true)}>
|
||||
|
||||
@@ -22,8 +22,9 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
|
||||
const mutation = useMutation({
|
||||
mutationFn: (mode: NonNullable<Mode>) =>
|
||||
coachingService.writingHelp({
|
||||
text: text.trim(),
|
||||
task_type: `${task_type}:${mode}`,
|
||||
task: task_type,
|
||||
draft: text.trim(),
|
||||
help_type: mode,
|
||||
}),
|
||||
onSuccess: () => setShowResult(true),
|
||||
onError: (err: Error) => {
|
||||
@@ -84,20 +85,20 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
|
||||
|
||||
{showResult && !loading && mutation.data && activeMode === "improve" && (
|
||||
<div className="space-y-3">
|
||||
{mutation.data.feedback && (
|
||||
{mutation.data.tips?.length > 0 && (
|
||||
<div className="rounded-lg border bg-muted/30 p-3">
|
||||
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
||||
<Sparkles className="h-3 w-3" /> Feedback
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{mutation.data.feedback}</p>
|
||||
<p className="text-sm text-muted-foreground">{mutation.data.tips.join(" ")}</p>
|
||||
</div>
|
||||
)}
|
||||
{mutation.data.improved && (
|
||||
{mutation.data.improved_text && (
|
||||
<div className="rounded-lg border bg-muted/30 p-3">
|
||||
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
||||
<Sparkles className="h-3 w-3" /> Improved Version
|
||||
</p>
|
||||
<p className="text-sm">{mutation.data.improved}</p>
|
||||
<p className="text-sm">{mutation.data.improved_text}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -108,17 +109,17 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
|
||||
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
||||
<Sparkles className="h-3 w-3" /> Grammar notes
|
||||
</p>
|
||||
{(mutation.data.grammar_notes?.length ?? 0) > 0 ? (
|
||||
mutation.data.grammar_notes!.map((note, i) => (
|
||||
{(mutation.data.changes?.length ?? 0) > 0 ? (
|
||||
mutation.data.changes.map((c, i) => (
|
||||
<div key={i} className="text-sm border-l-2 border-warning pl-2">
|
||||
<p className="text-muted-foreground">{note}</p>
|
||||
<p className="text-muted-foreground"><strong>{c.original}</strong> → {c.revised} — {c.reason}</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No grammar issues flagged.</p>
|
||||
)}
|
||||
{mutation.data.feedback ? (
|
||||
<p className="text-xs text-muted-foreground pt-2 border-t">{mutation.data.feedback}</p>
|
||||
{mutation.data.tips?.length > 0 ? (
|
||||
<p className="text-xs text-muted-foreground pt-2 border-t">{mutation.data.tips.join("; ")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
@@ -128,9 +129,9 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
|
||||
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
||||
<Sparkles className="h-3 w-3" /> Estimated band / assessment
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{mutation.data.feedback}</p>
|
||||
{mutation.data.improved ? (
|
||||
<p className="text-sm mt-2 pt-2 border-t">{mutation.data.improved}</p>
|
||||
<p className="text-sm text-muted-foreground">{mutation.data.tips?.join(" ") ?? ""}</p>
|
||||
{mutation.data.improved_text ? (
|
||||
<p className="text-sm mt-2 pt-2 border-t">{mutation.data.improved_text}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { queryKeys } from "./keys";
|
||||
import { aiCourseService } from "@/services/ai-course.service";
|
||||
import type { ExaminerReview } from "@/types";
|
||||
import {
|
||||
aiCourseService,
|
||||
type AiCourseCreateEnglishRequest,
|
||||
type AiCourseCreateIeltsRequest,
|
||||
} from "@/services/ai-course.service";
|
||||
|
||||
export function useAiCourse(courseId: number | undefined) {
|
||||
return useQuery({
|
||||
@@ -22,7 +25,7 @@ export function useAiCourseTracks(courseId: number | undefined) {
|
||||
export function useCreateEnglishCourse() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { current_level: string; target_level: string; learning_style: string[] }) =>
|
||||
mutationFn: (data: AiCourseCreateEnglishRequest) =>
|
||||
aiCourseService.createEnglish(data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
||||
@@ -33,7 +36,7 @@ export function useCreateEnglishCourse() {
|
||||
export function useCreateIeltsCourse() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { exam_type: string; target_band: number; skills: string[] }) =>
|
||||
mutationFn: (data: AiCourseCreateIeltsRequest) =>
|
||||
aiCourseService.createIelts(data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
||||
@@ -63,8 +66,8 @@ export function useApproveQuality() {
|
||||
export function useRejectQuality() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ courseId, notes }: { courseId: number; notes: string }) =>
|
||||
aiCourseService.rejectQuality(courseId, notes),
|
||||
mutationFn: ({ courseId, reason }: { courseId: number; reason: string }) =>
|
||||
aiCourseService.rejectQuality(courseId, reason),
|
||||
onSuccess: (_d, { courseId }) => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.quality(courseId) });
|
||||
},
|
||||
@@ -89,7 +92,8 @@ export function useIeltsValidation(courseId: number | undefined) {
|
||||
export function useSubmitExaminerReview() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: ExaminerReview) => aiCourseService.submitExaminerReview(data),
|
||||
mutationFn: (data: { logId: number; action: string; examiner_notes?: string }) =>
|
||||
aiCourseService.submitExaminerReview(data.logId, { action: data.action, examiner_notes: data.examiner_notes }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@ export function useExamAutoSave() {
|
||||
|
||||
export function useExamSubmit() {
|
||||
return useMutation({
|
||||
mutationFn: (examId: number) => examSessionService.submit(examId),
|
||||
mutationFn: (data: { examId: number; attempt_id: number; answers: { question_id: number; answer: unknown }[] }) =>
|
||||
examSessionService.submit(data.examId, { attempt_id: data.attempt_id, answers: data.answers }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
export default function ExamPage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[70vh] gap-4 max-w-md mx-auto">
|
||||
<AiTipBanner tip="Based on your practice history, focus on Reading Part 3 (sentence completion) — your accuracy there is 58% vs 82% average. Budget 20 min for the writing section." variant="recommendation" />
|
||||
<AiTipBanner context="exam" variant="recommendation" />
|
||||
|
||||
<Card className="border-0 shadow-sm w-full">
|
||||
<CardContent className="p-8 text-center space-y-6">
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function GrammarPage() {
|
||||
<p className="text-muted-foreground">Master grammar rules essential for IELTS.</p>
|
||||
</div>
|
||||
|
||||
<AiTipBanner tip="You've completed 50% of grammar topics. Focus on Passive Voice next — it appears in 73% of IELTS Writing Task 1 questions and will boost your band score." variant="recommendation" />
|
||||
<AiTipBanner context="grammar" variant="recommendation" />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function PaymentRecordPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AiTipBanner tip="PAY-003 (Tech Co) is unpaid and overdue. PMB-1004 failed — AI recommends sending an automated retry notification to Emma Brown." variant="recommendation" />
|
||||
<AiTipBanner context="payment-record" variant="recommendation" />
|
||||
|
||||
<Tabs defaultValue="payments">
|
||||
<TabsList>
|
||||
@@ -61,7 +61,7 @@ export default function PaymentRecordPage() {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="payments" className="mt-4 space-y-4">
|
||||
<AiReportNarrative narrative="Total revenue collected: $13,500 from 2 corporate payments. One commission of $2,000 remains unpaid. Collection rate: 67%. Trend: Q1 payments are on track but Tech Co requires follow-up." />
|
||||
<AiReportNarrative report_type="payments" data={{ payments }} />
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
|
||||
@@ -21,9 +21,9 @@ export default function RecordPage() {
|
||||
<p className="text-muted-foreground">Browse assignment and exam attempt history.</p>
|
||||
</div>
|
||||
|
||||
<AiTipBanner tip="The student's scores show an upward trend from 5.5 → 6.0 → 7.5 over the last 3 completed exams. Listening remains the weakest module — recommend targeted practice." variant="insight" />
|
||||
<AiTipBanner context="record" variant="insight" />
|
||||
|
||||
<AiReportNarrative narrative="3 of 4 attempts completed with an average score of 6.3. Time management is good — all exams finished within allocated time. The Full Mock Exam is still in progress (67% time used). Strongest area: Reading (7.5), weakest: Listening (5.5)." />
|
||||
<AiReportNarrative report_type="record" data={{ records }} />
|
||||
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="Entity" /></SelectTrigger>
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function SettingsPage() {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="codes" className="mt-4 space-y-4">
|
||||
<AiTipBanner tip="2 batch codes have been unused for over 30 days. Consider sending reminder emails to the assigned entities or recycling unused codes." variant="insight" />
|
||||
<AiTipBanner context="settings-codes" variant="insight" />
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Generate Single</Button>
|
||||
<Button size="sm" variant="outline"><Copy className="h-4 w-4 mr-1" /> Generate Batch</Button>
|
||||
@@ -66,7 +66,7 @@ export default function SettingsPage() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="packages" className="mt-4 space-y-4">
|
||||
<AiTipBanner tip="Based on conversion data, the IELTS Pro package has the highest ROI. Consider increasing the Corporate Bundle discount to 30% to boost enterprise sign-ups." variant="recommendation" />
|
||||
<AiTipBanner context="settings-packages" variant="recommendation" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{packages.map((p) => (
|
||||
<Card key={p.id} className="border-0 shadow-sm">
|
||||
@@ -84,7 +84,7 @@ export default function SettingsPage() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="grading" className="mt-4 space-y-4">
|
||||
<AiTipBanner tip="Current 0.5 increment scoring aligns with official IELTS band scoring. AI recommends keeping this configuration for standardised assessment." variant="tip" />
|
||||
<AiTipBanner context="settings-grading" variant="tip" />
|
||||
<Card className="border-0 shadow-sm max-w-lg">
|
||||
<CardHeader><CardTitle className="text-base">Scoring Scale</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
||||
@@ -6,13 +6,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, PieChart, Pie, Cell } from "recharts";
|
||||
import AiReportNarrative from "@/components/ai/AiReportNarrative";
|
||||
|
||||
const tabNarratives: Record<string, string> = {
|
||||
overview: "Writing scores (61%) are significantly lower than other modules. Consider allocating more teaching resources to writing workshops. Reading leads at 72%, suggesting current materials are effective.",
|
||||
trends: "Scores have shown a consistent upward trend of +14 points over 6 months. The plateau in April correlates with mid-term exam stress. June's 72% is the highest recorded average this year.",
|
||||
distribution: "B1 is the largest cohort at 30%, indicating most students are at intermediate level. Only 3% reach C2 — consider creating more advanced pathways to support progression from C1.",
|
||||
comparison: "Attendance dropped 8% in the second week of March, correlating with the mid-term assignment deadline. Consider spacing deadlines more evenly across the term.",
|
||||
};
|
||||
|
||||
const thresholds = ["0%", "50%", "70%", "90%"];
|
||||
|
||||
const barData = [
|
||||
@@ -69,7 +62,7 @@ export default function StatsCorporatePage() {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="mt-4">
|
||||
<AiReportNarrative narrative={tabNarratives.overview} />
|
||||
<AiReportNarrative report_type="corporate-overview" data={{ modules: barData }} />
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader><CardTitle className="text-base">Average Score by Module</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
@@ -87,7 +80,7 @@ export default function StatsCorporatePage() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="trends" className="mt-4">
|
||||
<AiReportNarrative narrative={tabNarratives.trends} />
|
||||
<AiReportNarrative report_type="corporate-trends" data={{ trends: trendData }} />
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader><CardTitle className="text-base">Score Trend Over Time</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
@@ -105,7 +98,7 @@ export default function StatsCorporatePage() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="distribution" className="mt-4">
|
||||
<AiReportNarrative narrative={tabNarratives.distribution} />
|
||||
<AiReportNarrative report_type="corporate-distribution" data={{ distribution: distData }} />
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader><CardTitle className="text-base">Level Distribution</CardTitle></CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
@@ -122,7 +115,7 @@ export default function StatsCorporatePage() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="comparison" className="mt-4">
|
||||
<AiReportNarrative narrative={tabNarratives.comparison} />
|
||||
<AiReportNarrative report_type="corporate-comparison" data={{ threshold }} />
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-8 text-center text-muted-foreground">Entity comparison charts will appear here based on selected filters.</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -113,7 +113,7 @@ export default function AiEnglishQuality() {
|
||||
<Button
|
||||
onClick={() =>
|
||||
reject.mutate(
|
||||
{ courseId, notes },
|
||||
{ courseId, reason: notes },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Rejected; regeneration requested.");
|
||||
|
||||
@@ -38,13 +38,11 @@ export default function AiIeltsValidation() {
|
||||
|
||||
const send = (approved: boolean) => {
|
||||
if (!preview) return;
|
||||
const payload: Parameters<typeof submitReview.mutate>[0] = {
|
||||
item_id: preview.id,
|
||||
approved,
|
||||
notes: approved ? undefined : notes,
|
||||
checklist,
|
||||
};
|
||||
submitReview.mutate(payload, {
|
||||
submitReview.mutate({
|
||||
logId: preview.id,
|
||||
action: approved ? "approve" : "reject",
|
||||
examiner_notes: approved ? undefined : notes,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(approved ? "Approved." : "Rejected with notes.");
|
||||
setPreview(null);
|
||||
|
||||
@@ -141,7 +141,7 @@ export default function AiEnglishCourse() {
|
||||
.filter(Boolean)
|
||||
: course?.learning_style ?? ["visual"];
|
||||
createEnglish.mutate(
|
||||
{ current_level: course?.current_level ?? "B1", target_level: tgt, learning_style: styles },
|
||||
{ cefr_level: tgt || course?.current_level || "B1" },
|
||||
{
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.course(courseId) });
|
||||
|
||||
@@ -166,9 +166,8 @@ export default function AiIeltsCourse() {
|
||||
const band = Number(targetBand || course?.target_level || 7);
|
||||
createIelts.mutate(
|
||||
{
|
||||
exam_type: course?.exam_type ?? "academic",
|
||||
skill: skillsRanked[0]?.skill ?? "writing",
|
||||
target_band: Number.isFinite(band) ? band : 7,
|
||||
skills: skillsRanked.map((s) => s.skill),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -22,8 +22,8 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Flag, ChevronLeft, ChevronRight, Pause, Play } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function normalizeType(t: string) {
|
||||
return t.toLowerCase().replace(/\s+/g, "_");
|
||||
function normalizeType(t: string | null | undefined) {
|
||||
return (t ?? "").toLowerCase().replace(/\s+/g, "_");
|
||||
}
|
||||
|
||||
function countWords(s: string) {
|
||||
@@ -49,10 +49,26 @@ export default function ExamSession() {
|
||||
const { examId: examIdParam } = useParams();
|
||||
const examId = Number(examIdParam);
|
||||
const navigate = useNavigate();
|
||||
const { data: session, isLoading, isError } = useExamSession(examId);
|
||||
const { data: rawSession, isLoading, isError } = useExamSession(examId);
|
||||
const autoSave = useExamAutoSave();
|
||||
const submitMut = useExamSubmit();
|
||||
|
||||
const session = useMemo(() => {
|
||||
if (!rawSession) return rawSession;
|
||||
const raw = rawSession as any;
|
||||
return {
|
||||
...raw,
|
||||
title: raw.title || raw.exam_title || "",
|
||||
sections: (raw.sections || []).map((s: any) => ({
|
||||
...s,
|
||||
questions: (s.questions || []).map((q: any) => ({
|
||||
...q,
|
||||
type: q.type || q.question_type || q.skill || "",
|
||||
})),
|
||||
})),
|
||||
} as typeof rawSession;
|
||||
}, [rawSession]);
|
||||
|
||||
const [sectionIdx, setSectionIdx] = useState(0);
|
||||
const [questionIdx, setQuestionIdx] = useState(0);
|
||||
const [answers, setAnswers] = useState<Map<number, ExamAnswer>>(new Map());
|
||||
@@ -121,10 +137,11 @@ export default function ExamSession() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || !section) return;
|
||||
const attemptId = (session as any)?.attempt_id;
|
||||
const id = window.setInterval(() => {
|
||||
autoSave.mutate({
|
||||
examId,
|
||||
payload: { section_id: section.id, answers: currentSectionAnswers() },
|
||||
payload: { attempt_id: attemptId, section_id: section.id, answers: currentSectionAnswers() },
|
||||
});
|
||||
}, 10000);
|
||||
return () => window.clearInterval(id);
|
||||
@@ -439,12 +456,20 @@ export default function ExamSession() {
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
submitMut.mutate(examId, {
|
||||
const attemptId = (session as any)?.attempt_id;
|
||||
const allAnswers = Array.from(answers.entries()).map(([qId, a]) => ({
|
||||
question_id: qId,
|
||||
answer: a.answer ?? "",
|
||||
}));
|
||||
submitMut.mutate(
|
||||
{ examId, attempt_id: attemptId, answers: allAnswers },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setReviewOpen(false);
|
||||
navigate(`/student/exam/${examId}/status`);
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
}}
|
||||
disabled={submitMut.isPending}
|
||||
>
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function StudentGrades() {
|
||||
<p className="text-muted-foreground">Track your academic performance.</p>
|
||||
</div>
|
||||
|
||||
<AiReportNarrative narrative={`Your average grade is ${avgGrade}%. Your strongest area is essay writing with consistent scores above 80%. Focus on improving speaking scores — your last mock test scored 72%, which is below your average. AI recommends practicing with the IELTS Speaking Masterclass materials.`} />
|
||||
<AiReportNarrative report_type="grades" data={{ avgGrade, highest, count: gradeRecords.length }} />
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Card><CardContent className="pt-6 text-center"><p className="text-sm text-muted-foreground">Average</p><p className="text-3xl font-bold text-primary">{avgGrade}%</p></CardContent></Card>
|
||||
|
||||
@@ -1,41 +1,71 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import type {
|
||||
AICourseConfig,
|
||||
QualityGateResult,
|
||||
IELTSValidationResult,
|
||||
ExaminerReview,
|
||||
AICourseTrack,
|
||||
} from "@/types";
|
||||
import type { ApiSuccessResponse } from "@/types";
|
||||
|
||||
export interface AiCourseCreateEnglishRequest {
|
||||
cefr_level: string;
|
||||
gap_profile_id?: number;
|
||||
}
|
||||
|
||||
export interface AiCourseCreateIeltsRequest {
|
||||
skill: "listening" | "reading" | "writing" | "speaking";
|
||||
target_band: number;
|
||||
brief?: string;
|
||||
}
|
||||
|
||||
export interface AiCourseCreateResponse {
|
||||
log_id: number;
|
||||
status: string;
|
||||
brief?: Record<string, unknown>;
|
||||
skill?: string;
|
||||
}
|
||||
|
||||
export interface QualityGateResult {
|
||||
status: string;
|
||||
readability_score: number;
|
||||
cefr_alignment: boolean;
|
||||
grammar_issues: string[];
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
export interface IELTSValidationResult {
|
||||
type: string;
|
||||
validation_results: Record<string, unknown>;
|
||||
overall_passed: boolean;
|
||||
}
|
||||
|
||||
export const aiCourseService = {
|
||||
createEnglish: (data: { current_level: string; target_level: string; learning_style: string[] }) =>
|
||||
api.post<{ course_id: number }>("/ai-course/english/create", data),
|
||||
createEnglish: (data: AiCourseCreateEnglishRequest) =>
|
||||
api.post<AiCourseCreateResponse>("/ai-course/english/create", data),
|
||||
|
||||
createIelts: (data: { exam_type: string; target_band: number; skills: string[] }) =>
|
||||
api.post<{ course_id: number }>("/ai-course/ielts/create", data),
|
||||
createIelts: (data: AiCourseCreateIeltsRequest) =>
|
||||
api.post<AiCourseCreateResponse>("/ai-course/ielts/create", data),
|
||||
|
||||
getCourse: (courseId: number) =>
|
||||
api.get<AICourseConfig>(`/ai-course/${courseId}`),
|
||||
api.get<Record<string, unknown>>(`/ai-course/${courseId}`),
|
||||
|
||||
getTracks: (courseId: number) =>
|
||||
api.get<AICourseTrack[]>(`/ai-course/${courseId}/tracks`),
|
||||
api.get<unknown[]>(`/ai-course/${courseId}/tracks`),
|
||||
|
||||
getQualityGate: (courseId: number) =>
|
||||
api.get<QualityGateResult>(`/ai-course/${courseId}/quality`),
|
||||
|
||||
approveQuality: (courseId: number) =>
|
||||
api.post<ApiSuccessResponse>(`/ai-course/${courseId}/quality/approve`),
|
||||
api.post<{ approved: boolean }>(`/ai-course/${courseId}/approve`),
|
||||
|
||||
rejectQuality: (courseId: number, notes: string) =>
|
||||
api.post<ApiSuccessResponse>(`/ai-course/${courseId}/quality/reject`, { notes }),
|
||||
rejectQuality: (courseId: number, reason: string) =>
|
||||
api.post<{ rejected: boolean; can_retry: boolean }>(`/ai-course/${courseId}/reject`, { reason }),
|
||||
|
||||
getIeltsValidation: (courseId: number) =>
|
||||
api.get<IELTSValidationResult>(`/ai-course/${courseId}/validation`),
|
||||
|
||||
submitExaminerReview: (data: ExaminerReview) =>
|
||||
api.post<ApiSuccessResponse>(`/ai-course/examiner-review`, data),
|
||||
submitExaminerReview: (logId: number, data: { action: string; examiner_notes?: string }) =>
|
||||
api.post<{ status: string; log_id: number }>(`/ai-course/ielts-review/${logId}`, data),
|
||||
|
||||
getEnglishTaxonomy: () =>
|
||||
api.get<Record<string, unknown>>("/ai-course/english/taxonomy"),
|
||||
|
||||
getReviewQueue: (page = 1, size = 20) =>
|
||||
api.get<{ total: number; page: number; size: number; items: unknown[] }>("/ai-course/review-queue", { page, size }),
|
||||
|
||||
getIeltsReviewQueue: (page = 1, size = 20) =>
|
||||
api.get<{ total: number; page: number; size: number; items: unknown[] }>("/ai-course/ielts-review-queue", { page, size }),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import type { AiInsight, AiAlert, AiSearchResult, AiBatchOptimization, AiGradingResult } from "@/types";
|
||||
|
||||
export interface AiSearchResponse {
|
||||
answer: string;
|
||||
suggestions: string[];
|
||||
related_actions?: { label: string; action: string }[];
|
||||
}
|
||||
|
||||
export interface AiInsightItem {
|
||||
title: string;
|
||||
description: string;
|
||||
severity: "info" | "warning" | "critical";
|
||||
recommendation: string;
|
||||
}
|
||||
|
||||
export interface AiAlertItem {
|
||||
title: string;
|
||||
description: string;
|
||||
severity: string;
|
||||
recommendation?: string;
|
||||
}
|
||||
|
||||
export interface BatchOptimizeResponse {
|
||||
optimized: unknown[];
|
||||
summary: string;
|
||||
impact: string;
|
||||
}
|
||||
|
||||
export interface AiGradingResult {
|
||||
scores: Record<string, number>;
|
||||
overall_band: number;
|
||||
feedback: string;
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
export const analyticsService = {
|
||||
async getStudentAnalytics(params?: Record<string, string | number | boolean | undefined>): Promise<unknown> {
|
||||
@@ -18,27 +50,44 @@ export const analyticsService = {
|
||||
return api.get("/analytics/content-gaps", params as Record<string, string | number | boolean | undefined>);
|
||||
},
|
||||
|
||||
async search(query: string): Promise<AiSearchResult[]> {
|
||||
return api.post<AiSearchResult[]>("/ai/search", { query });
|
||||
async search(query: string): Promise<AiSearchResponse> {
|
||||
return api.post<AiSearchResponse>("/ai/search", { query });
|
||||
},
|
||||
|
||||
async getInsights(data: Record<string, unknown>): Promise<AiInsight[]> {
|
||||
return api.post<AiInsight[]>("/ai/insights", data);
|
||||
async getInsights(data: Record<string, unknown>): Promise<{ insights: AiInsightItem[] }> {
|
||||
return api.post<{ insights: AiInsightItem[] }>("/ai/insights", { data, type: "general" });
|
||||
},
|
||||
|
||||
async getAlerts(): Promise<AiAlert[]> {
|
||||
return api.get<AiAlert[]>("/ai/alerts");
|
||||
async getAlerts(): Promise<{ alerts: AiAlertItem[] }> {
|
||||
return api.get<{ alerts: AiAlertItem[] }>("/ai/alerts");
|
||||
},
|
||||
|
||||
async getReportNarrative(data: { report_type: string; data: Record<string, unknown> }): Promise<{ narrative: string }> {
|
||||
return api.post("/ai/report-narrative", data);
|
||||
},
|
||||
|
||||
async getBatchOptimization(batchId: number): Promise<AiBatchOptimization[]> {
|
||||
return api.post<AiBatchOptimization[]>("/ai/batch-optimize", { batch_id: batchId });
|
||||
async getBatchOptimization(batchId: number, items: unknown[] = [], type = "schedule"): Promise<BatchOptimizeResponse> {
|
||||
return api.post<BatchOptimizeResponse>("/ai/batch-optimize", { items, type });
|
||||
},
|
||||
|
||||
async getGradingSuggestion(data: { submission_id: number; text: string; rubric_id?: number }): Promise<AiGradingResult> {
|
||||
async getGradingSuggestion(data: {
|
||||
submission_text: string;
|
||||
skill?: string;
|
||||
rubric?: string;
|
||||
task?: string;
|
||||
}): Promise<AiGradingResult> {
|
||||
return api.post<AiGradingResult>("/ai/grade-suggest", data);
|
||||
},
|
||||
|
||||
async applyBatchOptimization(batchId: number, optimized: unknown[]): Promise<{ applied: number }> {
|
||||
return api.post("/ai/batch-optimize/apply", { batch_id: batchId, optimized });
|
||||
},
|
||||
|
||||
async vectorSearch(query: string, options?: { content_type?: string; limit?: number }): Promise<{
|
||||
results: { content_type: string; content_id: number; text: string; metadata: Record<string, unknown>; similarity: number }[];
|
||||
query: string;
|
||||
count: number;
|
||||
}> {
|
||||
return api.get("/ai/vector-search", { q: query, ...options } as Record<string, string | number | boolean | undefined>);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,28 +1,55 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import type { AiChatRequest, AiChatResponse, AiTip } from "@/types";
|
||||
|
||||
interface CoachChatRequest {
|
||||
message: string;
|
||||
history?: { role: string; content: string }[];
|
||||
context?: unknown;
|
||||
}
|
||||
|
||||
interface CoachChatResponse {
|
||||
reply: string;
|
||||
}
|
||||
|
||||
interface CoachTipResponse {
|
||||
tip: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface CoachSuggestResponse {
|
||||
suggestion: string;
|
||||
focus_areas: string[];
|
||||
daily_plan: { activity: string; duration_min: number; skill: string }[];
|
||||
motivation: string;
|
||||
}
|
||||
|
||||
interface CoachWritingResponse {
|
||||
improved_text: string;
|
||||
changes: { original: string; revised: string; reason: string }[];
|
||||
tips: string[];
|
||||
}
|
||||
|
||||
export const coachingService = {
|
||||
async chat(data: AiChatRequest): Promise<AiChatResponse> {
|
||||
return api.post<AiChatResponse>("/coach/chat", data);
|
||||
async chat(data: CoachChatRequest): Promise<CoachChatResponse> {
|
||||
return api.post<CoachChatResponse>("/coach/chat", data);
|
||||
},
|
||||
|
||||
async getHint(data: { topic_id: number; question_id: string }): Promise<{ hint: string }> {
|
||||
async getHint(data: { topic_id: number; question_id: string }): Promise<{ hint: string; strategy: string }> {
|
||||
return api.post("/coach/hint", data);
|
||||
},
|
||||
|
||||
async explain(data: { context: string; scores?: Record<string, number> }): Promise<{ explanation: string }> {
|
||||
async explain(data: { score_data: Record<string, unknown>; student_context?: string }): Promise<{ explanation: string }> {
|
||||
return api.post("/coach/explain", data);
|
||||
},
|
||||
|
||||
async suggest(data?: { subject_id?: number }): Promise<{ suggestions: string[]; study_plan_tips: string[] }> {
|
||||
async suggest(data?: Record<string, unknown>): Promise<CoachSuggestResponse> {
|
||||
return api.post("/coach/suggest", data);
|
||||
},
|
||||
|
||||
async writingHelp(data: { text: string; task_type: string }): Promise<{ feedback: string; improved: string; grammar_notes: string[] }> {
|
||||
async writingHelp(data: { task: string; draft: string; help_type: string }): Promise<CoachWritingResponse> {
|
||||
return api.post("/coach/writing-help", data);
|
||||
},
|
||||
|
||||
async getTip(context: string): Promise<AiTip> {
|
||||
return api.get<AiTip>("/coach/tip", { context });
|
||||
async getTip(context: string): Promise<CoachTipResponse> {
|
||||
return api.get<CoachTipResponse>("/coach/tip", { context });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,8 +9,8 @@ export const examSessionService = {
|
||||
autoSave: (examId: number, data: ExamAutoSave) =>
|
||||
api.post<ApiSuccessResponse>(`/exam/${examId}/autosave`, data),
|
||||
|
||||
submit: (examId: number) =>
|
||||
api.post<ExamSubmitResponse>(`/exam/${examId}/submit`),
|
||||
submit: (examId: number, data?: { attempt_id: number; answers: { question_id: number; answer: unknown }[] }) =>
|
||||
api.post<ExamSubmitResponse>(`/exam/${examId}/submit`, data),
|
||||
|
||||
getStatus: (examId: number) =>
|
||||
api.get<{ status: string; scores_available: boolean }>(`/exam/${examId}/status`),
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface ExamAnswer {
|
||||
}
|
||||
|
||||
export interface ExamAutoSave {
|
||||
attempt_id?: number;
|
||||
section_id: number;
|
||||
answers: ExamAnswer[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user