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 [dismissedIds, setDismissedIds] = useState<Set<string>>(() => new Set());
|
||||||
const [errorDismissed, setErrorDismissed] = useState(false);
|
const [errorDismissed, setErrorDismissed] = useState(false);
|
||||||
|
|
||||||
const { data: alerts, isLoading, isError, error } = useQuery({
|
const { data: resp, isLoading, isError, error } = useQuery({
|
||||||
queryKey: ["ai", "alerts"],
|
queryKey: ["ai", "alerts"],
|
||||||
queryFn: () => analyticsService.getAlerts(),
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -43,7 +44,7 @@ export default function AiAlertBanner() {
|
|||||||
|
|
||||||
if (isError && errorDismissed) return null;
|
if (isError && errorDismissed) return null;
|
||||||
|
|
||||||
if (!alerts?.length) {
|
if (!alerts.length) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-muted bg-muted/20 p-4 flex items-start gap-3">
|
<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" />
|
<Sparkles className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
|
||||||
@@ -56,8 +57,8 @@ export default function AiAlertBanner() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{visible.map((alert) => (
|
{visible.map((alert, idx) => (
|
||||||
<div key={alert.id} className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3">
|
<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" />
|
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium flex items-center gap-1">
|
<p className="text-sm font-medium flex items-center gap-1">
|
||||||
@@ -69,7 +70,7 @@ export default function AiAlertBanner() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 shrink-0"
|
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" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function AiAssistantDrawer() {
|
|||||||
mutationFn: (message: string) =>
|
mutationFn: (message: string) =>
|
||||||
coachingService.chat({ message, context: { page: location.pathname } }),
|
coachingService.chat({ message, context: { page: location.pathname } }),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setMessages((prev) => [...prev, { role: "ai", text: data.message }]);
|
setMessages((prev) => [...prev, { role: "ai", text: data.reply }]);
|
||||||
},
|
},
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export default function AiBatchOptimizer({ batchId }: Props) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type OptResult = Awaited<ReturnType<typeof analyticsService.getBatchOptimization>>;
|
||||||
|
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
if (batchId == null) {
|
if (batchId == null) {
|
||||||
toast({
|
toast({
|
||||||
@@ -39,9 +41,23 @@ export default function AiBatchOptimizer({ batchId }: Props) {
|
|||||||
mutation.mutate(batchId);
|
mutation.mutate(batchId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = () => {
|
const handleApply = () => {
|
||||||
toast({ title: "Suggestion Applied", description: "Batch split recommendation has been saved successfully." });
|
applyMutation.mutate();
|
||||||
setOpen(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onOpenChange = (next: boolean) => {
|
const onOpenChange = (next: boolean) => {
|
||||||
@@ -49,9 +65,10 @@ export default function AiBatchOptimizer({ batchId }: Props) {
|
|||||||
if (!next) mutation.reset();
|
if (!next) mutation.reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
const suggestions = mutation.data ?? [];
|
const optData = mutation.data as OptResult | undefined;
|
||||||
const showResults = !mutation.isPending && !mutation.isError && suggestions.length > 0;
|
const hasSuggestions = !!optData?.summary;
|
||||||
const showEmpty = !mutation.isPending && !mutation.isError && mutation.isSuccess && suggestions.length === 0;
|
const showResults = !mutation.isPending && !mutation.isError && hasSuggestions;
|
||||||
|
const showEmpty = !mutation.isPending && !mutation.isError && mutation.isSuccess && !hasSuggestions;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -71,20 +88,28 @@ export default function AiBatchOptimizer({ batchId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
) : mutation.isError ? (
|
) : mutation.isError ? (
|
||||||
<p className="text-sm text-muted-foreground py-4 text-center">Something went wrong. Try again.</p>
|
<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-4">
|
||||||
<div className="space-y-3 max-h-[50vh] overflow-y-auto">
|
<div className="rounded-lg bg-muted/30 p-4 border border-border/60">
|
||||||
{suggestions.map((s, i) => (
|
<p className="text-xs font-semibold text-primary uppercase tracking-wide mb-1">{optData.impact} impact</p>
|
||||||
<div key={i} className="rounded-lg bg-muted/30 p-4 border border-border/60">
|
<p className="text-sm font-medium">{optData.summary}</p>
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</div>
|
</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">
|
<div className="flex gap-2">
|
||||||
<Button className="flex-1" onClick={handleApply}>
|
<Button className="flex-1" onClick={handleApply} disabled={applyMutation.isPending}>
|
||||||
Apply Suggestion
|
{applyMutation.isPending ? (
|
||||||
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Applying...</>
|
||||||
|
) : (
|
||||||
|
"Apply Suggestion"
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
Dismiss
|
Dismiss
|
||||||
|
|||||||
@@ -40,8 +40,9 @@ export default function AiGeneratorModal() {
|
|||||||
difficulty,
|
difficulty,
|
||||||
count,
|
count,
|
||||||
}),
|
}),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res: Record<string, unknown>) => {
|
||||||
setLocalExercises(Array.isArray(res.exercises) ? res.exercises : []);
|
const items = Array.isArray(res.questions) ? res.questions : Array.isArray(res.exercises) ? res.exercises : [];
|
||||||
|
setLocalExercises(items);
|
||||||
},
|
},
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
toast({
|
toast({
|
||||||
@@ -57,6 +58,21 @@ export default function AiGeneratorModal() {
|
|||||||
generateMutation.mutate();
|
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 generated = localExercises;
|
||||||
|
|
||||||
const handleRemove = (index: number) => {
|
const handleRemove = (index: number) => {
|
||||||
@@ -188,7 +204,17 @@ export default function AiGeneratorModal() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div className="flex gap-2">
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export default function AiGradeExplainer({
|
|||||||
const explainMutation = useMutation({
|
const explainMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
coachingService.explain({
|
coachingService.explain({
|
||||||
context: `IELTS / course grades for student: ${studentName}. Summarize what the scores mean and what to focus on next.`,
|
score_data: scores ?? {},
|
||||||
scores,
|
student_context: `IELTS / course grades for student: ${studentName}. Summarize what the scores mean and what to focus on next.`,
|
||||||
}),
|
}),
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -26,9 +26,8 @@ export default function AiGradingAssistant({
|
|||||||
const gradeMutation = useMutation({
|
const gradeMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
analyticsService.getGradingSuggestion({
|
analyticsService.getGradingSuggestion({
|
||||||
submission_id: submissionId,
|
submission_text: submissionText,
|
||||||
text: submissionText,
|
skill: "writing",
|
||||||
...(rubricId !== undefined ? { rubric_id: rubricId } : {}),
|
|
||||||
}),
|
}),
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
toast({
|
toast({
|
||||||
@@ -45,7 +44,7 @@ export default function AiGradingAssistant({
|
|||||||
}, [submissionId, submissionText, rubricId]);
|
}, [submissionId, submissionText, rubricId]);
|
||||||
|
|
||||||
const data = gradeMutation.data;
|
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
|
const feedbackBlock = data
|
||||||
? [
|
? [
|
||||||
data.feedback,
|
data.feedback,
|
||||||
|
|||||||
@@ -1,32 +1,31 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Sparkles, TrendingUp, AlertTriangle, Trophy, Loader2 } from "lucide-react";
|
import { Sparkles, TrendingUp, AlertTriangle, Info, Loader2 } from "lucide-react";
|
||||||
import { analyticsService } from "@/services/analytics.service";
|
import { analyticsService, type AiInsightItem } from "@/services/analytics.service";
|
||||||
import type { AiInsight } from "@/types";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
const EMPTY_PAYLOAD: Record<string, unknown> = {};
|
const EMPTY_PAYLOAD: Record<string, unknown> = {};
|
||||||
|
|
||||||
function insightIcon(type: AiInsight["type"]) {
|
function insightIcon(severity: AiInsightItem["severity"]) {
|
||||||
switch (type) {
|
switch (severity) {
|
||||||
case "positive":
|
case "critical":
|
||||||
return Trophy;
|
|
||||||
case "warning":
|
|
||||||
return AlertTriangle;
|
return AlertTriangle;
|
||||||
default:
|
case "warning":
|
||||||
return TrendingUp;
|
return TrendingUp;
|
||||||
|
default:
|
||||||
|
return Info;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function insightColor(type: AiInsight["type"]) {
|
function insightColor(severity: AiInsightItem["severity"]) {
|
||||||
switch (type) {
|
switch (severity) {
|
||||||
case "positive":
|
case "critical":
|
||||||
return "text-primary";
|
return "text-destructive";
|
||||||
case "warning":
|
case "warning":
|
||||||
return "text-warning";
|
return "text-warning";
|
||||||
default:
|
default:
|
||||||
return "text-success";
|
return "text-primary";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,10 +50,10 @@ export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mutation.mutate(data);
|
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]);
|
}, [payloadKey]);
|
||||||
|
|
||||||
const items = mutation.data ?? [];
|
const items = mutation.data?.insights ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
@@ -79,19 +78,19 @@ export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
|
|||||||
)}
|
)}
|
||||||
{!mutation.isPending && items.length > 0 && (
|
{!mutation.isPending && items.length > 0 && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{items.map((item) => {
|
{items.map((item, idx) => {
|
||||||
const Icon = insightIcon(item.type);
|
const Icon = insightIcon(item.severity);
|
||||||
const color = insightColor(item.type);
|
const color = insightColor(item.severity);
|
||||||
return (
|
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">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Icon className={`h-4 w-4 ${color}`} />
|
<Icon className={`h-4 w-4 ${color}`} />
|
||||||
<span className="text-sm font-semibold">{item.title}</span>
|
<span className="text-sm font-semibold">{item.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{item.description}</p>
|
<p className="text-sm text-muted-foreground">{item.description}</p>
|
||||||
{item.metric != null && item.value != null && (
|
{item.recommendation && (
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<p className="text-xs text-muted-foreground mt-2 italic">
|
||||||
{item.metric}: {item.value}
|
{item.recommendation}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default function AiSearchBar() {
|
|||||||
searchMutation.mutate(query.trim());
|
searchMutation.mutate(query.trim());
|
||||||
};
|
};
|
||||||
|
|
||||||
const results = searchMutation.data;
|
const result = searchMutation.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative max-w-md w-full">
|
<div className="relative max-w-md w-full">
|
||||||
@@ -57,35 +57,43 @@ export default function AiSearchBar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="absolute top-full mt-1 left-0 right-0 z-50 rounded-lg border bg-popover p-3 shadow-md">
|
||||||
{searchMutation.isPending ? (
|
{searchMutation.isPending ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||||
AI is searching...
|
AI is searching...
|
||||||
</div>
|
</div>
|
||||||
) : results && results.length > 0 ? (
|
) : result?.answer ? (
|
||||||
<div className="text-sm space-y-2 max-h-64 overflow-y-auto">
|
<div className="text-sm space-y-2 max-h-64 overflow-y-auto">
|
||||||
{results.map((r, i) => (
|
<div className="flex items-start gap-2 pb-2">
|
||||||
<div
|
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||||
key={`${r.title}-${i}`}
|
<p className="text-muted-foreground">{result.answer}</p>
|
||||||
className="flex items-start gap-2 border-b border-border/60 pb-2 last:border-0 last:pb-0"
|
</div>
|
||||||
>
|
{result.suggestions?.length > 0 && (
|
||||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
<div className="border-t pt-2 space-y-1">
|
||||||
<div className="min-w-0">
|
<p className="text-xs font-semibold text-primary">Related queries</p>
|
||||||
<p className="font-medium">{r.title}</p>
|
{result.suggestions.map((s, i) => (
|
||||||
<p className="text-muted-foreground text-xs mt-0.5">{r.description}</p>
|
<button
|
||||||
{r.url && (
|
key={i}
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
className="block text-xs text-primary hover:underline"
|
||||||
className="text-xs text-primary mt-1 hover:underline"
|
onClick={() => { setQuery(s); searchMutation.mutate(s); }}
|
||||||
onClick={() => navigate(r.url!)}
|
>
|
||||||
>
|
{s}
|
||||||
Go to {r.url}
|
</button>
|
||||||
</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -29,8 +29,11 @@ export default function AiStudyCoach() {
|
|||||||
suggestMutation.mutate();
|
suggestMutation.mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
const suggestions = suggestMutation.data?.suggestions ?? [];
|
const d = suggestMutation.data;
|
||||||
const planTips = suggestMutation.data?.study_plan_tips ?? [];
|
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 (
|
return (
|
||||||
<Card className="border-0 shadow-sm bg-primary/5">
|
<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 (
|
return (
|
||||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`}>
|
<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" />
|
<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 (
|
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`}>
|
<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" />
|
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span className="text-xs font-semibold text-primary">
|
<span className="text-xs font-semibold text-primary">{label}</span>
|
||||||
{data.title?.trim() || `AI ${variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}`}
|
<p className="text-sm text-muted-foreground mt-0.5">{data.tip}</p>
|
||||||
</span>
|
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">{data.content}</p>
|
|
||||||
</div>
|
</div>
|
||||||
{dismissible && (
|
{dismissible && (
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={() => setDismissed(true)}>
|
<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({
|
const mutation = useMutation({
|
||||||
mutationFn: (mode: NonNullable<Mode>) =>
|
mutationFn: (mode: NonNullable<Mode>) =>
|
||||||
coachingService.writingHelp({
|
coachingService.writingHelp({
|
||||||
text: text.trim(),
|
task: task_type,
|
||||||
task_type: `${task_type}:${mode}`,
|
draft: text.trim(),
|
||||||
|
help_type: mode,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => setShowResult(true),
|
onSuccess: () => setShowResult(true),
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
@@ -84,20 +85,20 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
|
|||||||
|
|
||||||
{showResult && !loading && mutation.data && activeMode === "improve" && (
|
{showResult && !loading && mutation.data && activeMode === "improve" && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{mutation.data.feedback && (
|
{mutation.data.tips?.length > 0 && (
|
||||||
<div className="rounded-lg border bg-muted/30 p-3">
|
<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">
|
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
||||||
<Sparkles className="h-3 w-3" /> Feedback
|
<Sparkles className="h-3 w-3" /> Feedback
|
||||||
</p>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{mutation.data.improved && (
|
{mutation.data.improved_text && (
|
||||||
<div className="rounded-lg border bg-muted/30 p-3">
|
<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">
|
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
||||||
<Sparkles className="h-3 w-3" /> Improved Version
|
<Sparkles className="h-3 w-3" /> Improved Version
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm">{mutation.data.improved}</p>
|
<p className="text-sm">{mutation.data.improved_text}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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">
|
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
||||||
<Sparkles className="h-3 w-3" /> Grammar notes
|
<Sparkles className="h-3 w-3" /> Grammar notes
|
||||||
</p>
|
</p>
|
||||||
{(mutation.data.grammar_notes?.length ?? 0) > 0 ? (
|
{(mutation.data.changes?.length ?? 0) > 0 ? (
|
||||||
mutation.data.grammar_notes!.map((note, i) => (
|
mutation.data.changes.map((c, i) => (
|
||||||
<div key={i} className="text-sm border-l-2 border-warning pl-2">
|
<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>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">No grammar issues flagged.</p>
|
<p className="text-sm text-muted-foreground">No grammar issues flagged.</p>
|
||||||
)}
|
)}
|
||||||
{mutation.data.feedback ? (
|
{mutation.data.tips?.length > 0 ? (
|
||||||
<p className="text-xs text-muted-foreground pt-2 border-t">{mutation.data.feedback}</p>
|
<p className="text-xs text-muted-foreground pt-2 border-t">{mutation.data.tips.join("; ")}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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">
|
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
||||||
<Sparkles className="h-3 w-3" /> Estimated band / assessment
|
<Sparkles className="h-3 w-3" /> Estimated band / assessment
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">{mutation.data.feedback}</p>
|
<p className="text-sm text-muted-foreground">{mutation.data.tips?.join(" ") ?? ""}</p>
|
||||||
{mutation.data.improved ? (
|
{mutation.data.improved_text ? (
|
||||||
<p className="text-sm mt-2 pt-2 border-t">{mutation.data.improved}</p>
|
<p className="text-sm mt-2 pt-2 border-t">{mutation.data.improved_text}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { queryKeys } from "./keys";
|
import { queryKeys } from "./keys";
|
||||||
import { aiCourseService } from "@/services/ai-course.service";
|
import {
|
||||||
import type { ExaminerReview } from "@/types";
|
aiCourseService,
|
||||||
|
type AiCourseCreateEnglishRequest,
|
||||||
|
type AiCourseCreateIeltsRequest,
|
||||||
|
} from "@/services/ai-course.service";
|
||||||
|
|
||||||
export function useAiCourse(courseId: number | undefined) {
|
export function useAiCourse(courseId: number | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -22,7 +25,7 @@ export function useAiCourseTracks(courseId: number | undefined) {
|
|||||||
export function useCreateEnglishCourse() {
|
export function useCreateEnglishCourse() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: { current_level: string; target_level: string; learning_style: string[] }) =>
|
mutationFn: (data: AiCourseCreateEnglishRequest) =>
|
||||||
aiCourseService.createEnglish(data),
|
aiCourseService.createEnglish(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
||||||
@@ -33,7 +36,7 @@ export function useCreateEnglishCourse() {
|
|||||||
export function useCreateIeltsCourse() {
|
export function useCreateIeltsCourse() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: { exam_type: string; target_band: number; skills: string[] }) =>
|
mutationFn: (data: AiCourseCreateIeltsRequest) =>
|
||||||
aiCourseService.createIelts(data),
|
aiCourseService.createIelts(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
||||||
@@ -63,8 +66,8 @@ export function useApproveQuality() {
|
|||||||
export function useRejectQuality() {
|
export function useRejectQuality() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ courseId, notes }: { courseId: number; notes: string }) =>
|
mutationFn: ({ courseId, reason }: { courseId: number; reason: string }) =>
|
||||||
aiCourseService.rejectQuality(courseId, notes),
|
aiCourseService.rejectQuality(courseId, reason),
|
||||||
onSuccess: (_d, { courseId }) => {
|
onSuccess: (_d, { courseId }) => {
|
||||||
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.quality(courseId) });
|
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.quality(courseId) });
|
||||||
},
|
},
|
||||||
@@ -89,7 +92,8 @@ export function useIeltsValidation(courseId: number | undefined) {
|
|||||||
export function useSubmitExaminerReview() {
|
export function useSubmitExaminerReview() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
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: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function useExamAutoSave() {
|
|||||||
|
|
||||||
export function useExamSubmit() {
|
export function useExamSubmit() {
|
||||||
return useMutation({
|
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() {
|
export default function ExamPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-[70vh] gap-4 max-w-md mx-auto">
|
<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">
|
<Card className="border-0 shadow-sm w-full">
|
||||||
<CardContent className="p-8 text-center space-y-6">
|
<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>
|
<p className="text-muted-foreground">Master grammar rules essential for IELTS.</p>
|
||||||
</div>
|
</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="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="lg:col-span-2 space-y-4">
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function PaymentRecordPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<Tabs defaultValue="payments">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
@@ -61,7 +61,7 @@ export default function PaymentRecordPage() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="payments" className="mt-4 space-y-4">
|
<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">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<Table>
|
<Table>
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ export default function RecordPage() {
|
|||||||
<p className="text-muted-foreground">Browse assignment and exam attempt history.</p>
|
<p className="text-muted-foreground">Browse assignment and exam attempt history.</p>
|
||||||
</div>
|
</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">
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="Entity" /></SelectTrigger>
|
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="Entity" /></SelectTrigger>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function SettingsPage() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="codes" className="mt-4 space-y-4">
|
<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">
|
<div className="flex gap-2">
|
||||||
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Generate Single</Button>
|
<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>
|
<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>
|
||||||
|
|
||||||
<TabsContent value="packages" className="mt-4 space-y-4">
|
<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">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{packages.map((p) => (
|
{packages.map((p) => (
|
||||||
<Card key={p.id} className="border-0 shadow-sm">
|
<Card key={p.id} className="border-0 shadow-sm">
|
||||||
@@ -84,7 +84,7 @@ export default function SettingsPage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="grading" className="mt-4 space-y-4">
|
<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">
|
<Card className="border-0 shadow-sm max-w-lg">
|
||||||
<CardHeader><CardTitle className="text-base">Scoring Scale</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-base">Scoring Scale</CardTitle></CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<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 { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, PieChart, Pie, Cell } from "recharts";
|
||||||
import AiReportNarrative from "@/components/ai/AiReportNarrative";
|
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 thresholds = ["0%", "50%", "70%", "90%"];
|
||||||
|
|
||||||
const barData = [
|
const barData = [
|
||||||
@@ -69,7 +62,7 @@ export default function StatsCorporatePage() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="overview" className="mt-4">
|
<TabsContent value="overview" className="mt-4">
|
||||||
<AiReportNarrative narrative={tabNarratives.overview} />
|
<AiReportNarrative report_type="corporate-overview" data={{ modules: barData }} />
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardHeader><CardTitle className="text-base">Average Score by Module</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-base">Average Score by Module</CardTitle></CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -87,7 +80,7 @@ export default function StatsCorporatePage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="trends" className="mt-4">
|
<TabsContent value="trends" className="mt-4">
|
||||||
<AiReportNarrative narrative={tabNarratives.trends} />
|
<AiReportNarrative report_type="corporate-trends" data={{ trends: trendData }} />
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardHeader><CardTitle className="text-base">Score Trend Over Time</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-base">Score Trend Over Time</CardTitle></CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -105,7 +98,7 @@ export default function StatsCorporatePage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="distribution" className="mt-4">
|
<TabsContent value="distribution" className="mt-4">
|
||||||
<AiReportNarrative narrative={tabNarratives.distribution} />
|
<AiReportNarrative report_type="corporate-distribution" data={{ distribution: distData }} />
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardHeader><CardTitle className="text-base">Level Distribution</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-base">Level Distribution</CardTitle></CardHeader>
|
||||||
<CardContent className="flex justify-center">
|
<CardContent className="flex justify-center">
|
||||||
@@ -122,7 +115,7 @@ export default function StatsCorporatePage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="comparison" className="mt-4">
|
<TabsContent value="comparison" className="mt-4">
|
||||||
<AiReportNarrative narrative={tabNarratives.comparison} />
|
<AiReportNarrative report_type="corporate-comparison" data={{ threshold }} />
|
||||||
<Card className="border-0 shadow-sm">
|
<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>
|
<CardContent className="p-8 text-center text-muted-foreground">Entity comparison charts will appear here based on selected filters.</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export default function AiEnglishQuality() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
reject.mutate(
|
reject.mutate(
|
||||||
{ courseId, notes },
|
{ courseId, reason: notes },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Rejected; regeneration requested.");
|
toast.success("Rejected; regeneration requested.");
|
||||||
|
|||||||
@@ -38,13 +38,11 @@ export default function AiIeltsValidation() {
|
|||||||
|
|
||||||
const send = (approved: boolean) => {
|
const send = (approved: boolean) => {
|
||||||
if (!preview) return;
|
if (!preview) return;
|
||||||
const payload: Parameters<typeof submitReview.mutate>[0] = {
|
submitReview.mutate({
|
||||||
item_id: preview.id,
|
logId: preview.id,
|
||||||
approved,
|
action: approved ? "approve" : "reject",
|
||||||
notes: approved ? undefined : notes,
|
examiner_notes: approved ? undefined : notes,
|
||||||
checklist,
|
}, {
|
||||||
};
|
|
||||||
submitReview.mutate(payload, {
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(approved ? "Approved." : "Rejected with notes.");
|
toast.success(approved ? "Approved." : "Rejected with notes.");
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export default function AiEnglishCourse() {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
: course?.learning_style ?? ["visual"];
|
: course?.learning_style ?? ["visual"];
|
||||||
createEnglish.mutate(
|
createEnglish.mutate(
|
||||||
{ current_level: course?.current_level ?? "B1", target_level: tgt, learning_style: styles },
|
{ cefr_level: tgt || course?.current_level || "B1" },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.course(courseId) });
|
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.course(courseId) });
|
||||||
|
|||||||
@@ -166,9 +166,8 @@ export default function AiIeltsCourse() {
|
|||||||
const band = Number(targetBand || course?.target_level || 7);
|
const band = Number(targetBand || course?.target_level || 7);
|
||||||
createIelts.mutate(
|
createIelts.mutate(
|
||||||
{
|
{
|
||||||
exam_type: course?.exam_type ?? "academic",
|
skill: skillsRanked[0]?.skill ?? "writing",
|
||||||
target_band: Number.isFinite(band) ? band : 7,
|
target_band: Number.isFinite(band) ? band : 7,
|
||||||
skills: skillsRanked.map((s) => s.skill),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
|||||||
import { Flag, ChevronLeft, ChevronRight, Pause, Play } from "lucide-react";
|
import { Flag, ChevronLeft, ChevronRight, Pause, Play } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function normalizeType(t: string) {
|
function normalizeType(t: string | null | undefined) {
|
||||||
return t.toLowerCase().replace(/\s+/g, "_");
|
return (t ?? "").toLowerCase().replace(/\s+/g, "_");
|
||||||
}
|
}
|
||||||
|
|
||||||
function countWords(s: string) {
|
function countWords(s: string) {
|
||||||
@@ -49,10 +49,26 @@ export default function ExamSession() {
|
|||||||
const { examId: examIdParam } = useParams();
|
const { examId: examIdParam } = useParams();
|
||||||
const examId = Number(examIdParam);
|
const examId = Number(examIdParam);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: session, isLoading, isError } = useExamSession(examId);
|
const { data: rawSession, isLoading, isError } = useExamSession(examId);
|
||||||
const autoSave = useExamAutoSave();
|
const autoSave = useExamAutoSave();
|
||||||
const submitMut = useExamSubmit();
|
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 [sectionIdx, setSectionIdx] = useState(0);
|
||||||
const [questionIdx, setQuestionIdx] = useState(0);
|
const [questionIdx, setQuestionIdx] = useState(0);
|
||||||
const [answers, setAnswers] = useState<Map<number, ExamAnswer>>(new Map());
|
const [answers, setAnswers] = useState<Map<number, ExamAnswer>>(new Map());
|
||||||
@@ -121,10 +137,11 @@ export default function ExamSession() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session || !section) return;
|
if (!session || !section) return;
|
||||||
|
const attemptId = (session as any)?.attempt_id;
|
||||||
const id = window.setInterval(() => {
|
const id = window.setInterval(() => {
|
||||||
autoSave.mutate({
|
autoSave.mutate({
|
||||||
examId,
|
examId,
|
||||||
payload: { section_id: section.id, answers: currentSectionAnswers() },
|
payload: { attempt_id: attemptId, section_id: section.id, answers: currentSectionAnswers() },
|
||||||
});
|
});
|
||||||
}, 10000);
|
}, 10000);
|
||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
@@ -439,12 +456,20 @@ export default function ExamSession() {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
submitMut.mutate(examId, {
|
const attemptId = (session as any)?.attempt_id;
|
||||||
onSuccess: () => {
|
const allAnswers = Array.from(answers.entries()).map(([qId, a]) => ({
|
||||||
setReviewOpen(false);
|
question_id: qId,
|
||||||
navigate(`/student/exam/${examId}/status`);
|
answer: a.answer ?? "",
|
||||||
|
}));
|
||||||
|
submitMut.mutate(
|
||||||
|
{ examId, attempt_id: attemptId, answers: allAnswers },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setReviewOpen(false);
|
||||||
|
navigate(`/student/exam/${examId}/status`);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
}}
|
}}
|
||||||
disabled={submitMut.isPending}
|
disabled={submitMut.isPending}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default function StudentGrades() {
|
|||||||
<p className="text-muted-foreground">Track your academic performance.</p>
|
<p className="text-muted-foreground">Track your academic performance.</p>
|
||||||
</div>
|
</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">
|
<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>
|
<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 { api } from "@/lib/api-client";
|
||||||
import type {
|
|
||||||
AICourseConfig,
|
export interface AiCourseCreateEnglishRequest {
|
||||||
QualityGateResult,
|
cefr_level: string;
|
||||||
IELTSValidationResult,
|
gap_profile_id?: number;
|
||||||
ExaminerReview,
|
}
|
||||||
AICourseTrack,
|
|
||||||
} from "@/types";
|
export interface AiCourseCreateIeltsRequest {
|
||||||
import type { ApiSuccessResponse } from "@/types";
|
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 = {
|
export const aiCourseService = {
|
||||||
createEnglish: (data: { current_level: string; target_level: string; learning_style: string[] }) =>
|
createEnglish: (data: AiCourseCreateEnglishRequest) =>
|
||||||
api.post<{ course_id: number }>("/ai-course/english/create", data),
|
api.post<AiCourseCreateResponse>("/ai-course/english/create", data),
|
||||||
|
|
||||||
createIelts: (data: { exam_type: string; target_band: number; skills: string[] }) =>
|
createIelts: (data: AiCourseCreateIeltsRequest) =>
|
||||||
api.post<{ course_id: number }>("/ai-course/ielts/create", data),
|
api.post<AiCourseCreateResponse>("/ai-course/ielts/create", data),
|
||||||
|
|
||||||
getCourse: (courseId: number) =>
|
getCourse: (courseId: number) =>
|
||||||
api.get<AICourseConfig>(`/ai-course/${courseId}`),
|
api.get<Record<string, unknown>>(`/ai-course/${courseId}`),
|
||||||
|
|
||||||
getTracks: (courseId: number) =>
|
getTracks: (courseId: number) =>
|
||||||
api.get<AICourseTrack[]>(`/ai-course/${courseId}/tracks`),
|
api.get<unknown[]>(`/ai-course/${courseId}/tracks`),
|
||||||
|
|
||||||
getQualityGate: (courseId: number) =>
|
getQualityGate: (courseId: number) =>
|
||||||
api.get<QualityGateResult>(`/ai-course/${courseId}/quality`),
|
api.get<QualityGateResult>(`/ai-course/${courseId}/quality`),
|
||||||
|
|
||||||
approveQuality: (courseId: number) =>
|
approveQuality: (courseId: number) =>
|
||||||
api.post<ApiSuccessResponse>(`/ai-course/${courseId}/quality/approve`),
|
api.post<{ approved: boolean }>(`/ai-course/${courseId}/approve`),
|
||||||
|
|
||||||
rejectQuality: (courseId: number, notes: string) =>
|
rejectQuality: (courseId: number, reason: string) =>
|
||||||
api.post<ApiSuccessResponse>(`/ai-course/${courseId}/quality/reject`, { notes }),
|
api.post<{ rejected: boolean; can_retry: boolean }>(`/ai-course/${courseId}/reject`, { reason }),
|
||||||
|
|
||||||
getIeltsValidation: (courseId: number) =>
|
getIeltsValidation: (courseId: number) =>
|
||||||
api.get<IELTSValidationResult>(`/ai-course/${courseId}/validation`),
|
api.get<IELTSValidationResult>(`/ai-course/${courseId}/validation`),
|
||||||
|
|
||||||
submitExaminerReview: (data: ExaminerReview) =>
|
submitExaminerReview: (logId: number, data: { action: string; examiner_notes?: string }) =>
|
||||||
api.post<ApiSuccessResponse>(`/ai-course/examiner-review`, data),
|
api.post<{ status: string; log_id: number }>(`/ai-course/ielts-review/${logId}`, data),
|
||||||
|
|
||||||
getEnglishTaxonomy: () =>
|
getEnglishTaxonomy: () =>
|
||||||
api.get<Record<string, unknown>>("/ai-course/english/taxonomy"),
|
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 { 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 = {
|
export const analyticsService = {
|
||||||
async getStudentAnalytics(params?: Record<string, string | number | boolean | undefined>): Promise<unknown> {
|
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>);
|
return api.get("/analytics/content-gaps", params as Record<string, string | number | boolean | undefined>);
|
||||||
},
|
},
|
||||||
|
|
||||||
async search(query: string): Promise<AiSearchResult[]> {
|
async search(query: string): Promise<AiSearchResponse> {
|
||||||
return api.post<AiSearchResult[]>("/ai/search", { query });
|
return api.post<AiSearchResponse>("/ai/search", { query });
|
||||||
},
|
},
|
||||||
|
|
||||||
async getInsights(data: Record<string, unknown>): Promise<AiInsight[]> {
|
async getInsights(data: Record<string, unknown>): Promise<{ insights: AiInsightItem[] }> {
|
||||||
return api.post<AiInsight[]>("/ai/insights", data);
|
return api.post<{ insights: AiInsightItem[] }>("/ai/insights", { data, type: "general" });
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAlerts(): Promise<AiAlert[]> {
|
async getAlerts(): Promise<{ alerts: AiAlertItem[] }> {
|
||||||
return api.get<AiAlert[]>("/ai/alerts");
|
return api.get<{ alerts: AiAlertItem[] }>("/ai/alerts");
|
||||||
},
|
},
|
||||||
|
|
||||||
async getReportNarrative(data: { report_type: string; data: Record<string, unknown> }): Promise<{ narrative: string }> {
|
async getReportNarrative(data: { report_type: string; data: Record<string, unknown> }): Promise<{ narrative: string }> {
|
||||||
return api.post("/ai/report-narrative", data);
|
return api.post("/ai/report-narrative", data);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getBatchOptimization(batchId: number): Promise<AiBatchOptimization[]> {
|
async getBatchOptimization(batchId: number, items: unknown[] = [], type = "schedule"): Promise<BatchOptimizeResponse> {
|
||||||
return api.post<AiBatchOptimization[]>("/ai/batch-optimize", { batch_id: batchId });
|
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);
|
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 { 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 = {
|
export const coachingService = {
|
||||||
async chat(data: AiChatRequest): Promise<AiChatResponse> {
|
async chat(data: CoachChatRequest): Promise<CoachChatResponse> {
|
||||||
return api.post<AiChatResponse>("/coach/chat", data);
|
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);
|
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);
|
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);
|
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);
|
return api.post("/coach/writing-help", data);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getTip(context: string): Promise<AiTip> {
|
async getTip(context: string): Promise<CoachTipResponse> {
|
||||||
return api.get<AiTip>("/coach/tip", { context });
|
return api.get<CoachTipResponse>("/coach/tip", { context });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ export const examSessionService = {
|
|||||||
autoSave: (examId: number, data: ExamAutoSave) =>
|
autoSave: (examId: number, data: ExamAutoSave) =>
|
||||||
api.post<ApiSuccessResponse>(`/exam/${examId}/autosave`, data),
|
api.post<ApiSuccessResponse>(`/exam/${examId}/autosave`, data),
|
||||||
|
|
||||||
submit: (examId: number) =>
|
submit: (examId: number, data?: { attempt_id: number; answers: { question_id: number; answer: unknown }[] }) =>
|
||||||
api.post<ExamSubmitResponse>(`/exam/${examId}/submit`),
|
api.post<ExamSubmitResponse>(`/exam/${examId}/submit`, data),
|
||||||
|
|
||||||
getStatus: (examId: number) =>
|
getStatus: (examId: number) =>
|
||||||
api.get<{ status: string; scores_available: boolean }>(`/exam/${examId}/status`),
|
api.get<{ status: string; scores_available: boolean }>(`/exam/${examId}/status`),
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export interface ExamAnswer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ExamAutoSave {
|
export interface ExamAutoSave {
|
||||||
|
attempt_id?: number;
|
||||||
section_id: number;
|
section_id: number;
|
||||||
answers: ExamAnswer[];
|
answers: ExamAnswer[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user