feat(generation): add exercise instructions, grouped display, and wizard improvements
- Add per-section instructions field to exercise wizard with sensible defaults - Group exercises by type with section headers showing instructions - Pass type_instructions to backend AI prompt for context-aware generation - Add instructions editing in exercise edit dialog - Update Exercise interface to include instructions field Made-with: Cursor
This commit is contained in:
@@ -409,14 +409,41 @@ class AIController(http.Controller):
|
||||
def _generate_exercises(self, ai, module, body):
|
||||
passage_text = body.get("passage_text", "")
|
||||
exercise_types = body.get("exercise_types", [])
|
||||
count = body.get("count_per_type", 5)
|
||||
types_str = ", ".join(exercise_types) if exercise_types else "multiple choice"
|
||||
type_counts = body.get("type_counts", {})
|
||||
type_instructions = body.get("type_instructions", {})
|
||||
default_count = body.get("count_per_type", 5)
|
||||
difficulty = body.get("difficulty", "B2")
|
||||
|
||||
type_specs = []
|
||||
total = 0
|
||||
for et in exercise_types:
|
||||
c = int(type_counts.get(et, default_count))
|
||||
instr = type_instructions.get(et, "")
|
||||
spec_line = f"- EXACTLY {c} questions of type \"{et}\""
|
||||
if instr:
|
||||
spec_line += f"\n Student instructions: \"{instr}\""
|
||||
type_specs.append(spec_line)
|
||||
total += c
|
||||
spec_str = "\n".join(type_specs) if type_specs else f"- {default_count} multiple choice questions"
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
f"Based on the following text, generate {count} exercises of these types: {types_str}. "
|
||||
"Return JSON: "
|
||||
'{"questions": [{"type": string, "prompt": string, "options": [string], '
|
||||
'"correct_answer": string, "explanation": string, "marks": number}]}'
|
||||
f"You are an exam question generator. Generate EXACTLY {total} exercises "
|
||||
f"at CEFR {difficulty} level based on the passage below.\n\n"
|
||||
f"REQUIRED question breakdown (you MUST follow these counts exactly):\n"
|
||||
f"{spec_str}\n\n"
|
||||
"CRITICAL RULES:\n"
|
||||
f"1. The total number of questions in your response MUST be exactly {total}.\n"
|
||||
"2. Each question MUST have a 'type' field set to one of the requested types.\n"
|
||||
"3. Each question MUST include an 'instructions' field with the student-facing instructions "
|
||||
"for that section (use the provided instructions, or write appropriate ones).\n"
|
||||
"4. For mcq/true_false types: include 'options' array and 'correct_answer'.\n"
|
||||
"5. For fill_blanks/write_blanks types: use '___' in the prompt for blanks, "
|
||||
"set correct_answer to the missing word(s), options can be empty.\n"
|
||||
"6. For paragraph_match: prompt describes what to match, options are paragraph labels.\n\n"
|
||||
"Return JSON:\n"
|
||||
'{"questions": [{"type": string, "instructions": string, "prompt": string, '
|
||||
'"options": [string], "correct_answer": string, "explanation": string, "marks": number}]}'
|
||||
)},
|
||||
{"role": "user", "content": passage_text[:3000]},
|
||||
]
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
Briefcase,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Minus,
|
||||
X,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
@@ -48,6 +50,8 @@ import {
|
||||
Play,
|
||||
Video,
|
||||
Sparkles,
|
||||
Settings2,
|
||||
ListChecks,
|
||||
} from "lucide-react";
|
||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
import { generationService } from "@/services/generation.service";
|
||||
@@ -110,13 +114,24 @@ const DEFAULT_AVATARS: Avatar[] = [
|
||||
{ id: "ethan", name: "Ethan", gender: "male" },
|
||||
];
|
||||
|
||||
interface Exercise {
|
||||
type: string;
|
||||
instructions: string;
|
||||
prompt: string;
|
||||
options: string[];
|
||||
correct_answer: string;
|
||||
explanation: string;
|
||||
marks: number;
|
||||
difficulty?: string;
|
||||
}
|
||||
|
||||
interface PassageState {
|
||||
text: string;
|
||||
category: string;
|
||||
type: string;
|
||||
divider: string;
|
||||
exerciseTypes: string[];
|
||||
exercises: unknown[];
|
||||
exercises: Exercise[];
|
||||
editing: boolean;
|
||||
}
|
||||
|
||||
@@ -127,7 +142,7 @@ interface ListeningSectionState {
|
||||
context: string;
|
||||
audioUrl: string;
|
||||
exerciseTypes: string[];
|
||||
exercises: unknown[];
|
||||
exercises: Exercise[];
|
||||
editing: boolean;
|
||||
}
|
||||
|
||||
@@ -198,6 +213,48 @@ export default function GenerationPage() {
|
||||
const [activeModule, setActiveModule] = useState<ModuleKey | null>(null);
|
||||
const [moduleStates, setModuleStates] = useState<Record<string, ModuleState>>({});
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [editingExercise, setEditingExercise] = useState<{ passageIndex: number; exerciseIndex: number } | null>(null);
|
||||
const [exerciseDraft, setExerciseDraft] = useState<Exercise | null>(null);
|
||||
|
||||
const [exerciseWizard, setExerciseWizard] = useState<{
|
||||
open: boolean;
|
||||
passageIndex: number;
|
||||
step: number; // 0 = configure, 1 = generating/done
|
||||
types: Record<string, { enabled: boolean; count: number; difficulty: string; instructions: string }>;
|
||||
}>({
|
||||
open: false,
|
||||
passageIndex: 0,
|
||||
step: 0,
|
||||
types: {},
|
||||
});
|
||||
|
||||
const DEFAULT_INSTRUCTIONS: Record<string, string> = {
|
||||
mcq: "Read the passage carefully and choose the best answer for each question.",
|
||||
fill_blanks: "Complete each sentence by selecting the correct word from the options provided.",
|
||||
write_blanks: "Fill in each blank with a suitable word or phrase based on the passage.",
|
||||
true_false: "Read each statement and decide whether it is True, False, or Not Given based on the passage.",
|
||||
paragraph_match: "Match each heading or statement to the correct paragraph from the passage.",
|
||||
};
|
||||
|
||||
const openExerciseWizard = (pi: number) => {
|
||||
const st = getModuleState("reading");
|
||||
const existing = st.passages[pi]?.exerciseTypes ?? [];
|
||||
const types: Record<string, { enabled: boolean; count: number; difficulty: string; instructions: string }> = {};
|
||||
for (const et of READING_EXERCISE_TYPES) {
|
||||
types[et.key] = {
|
||||
enabled: existing.includes(et.key) || existing.length === 0,
|
||||
count: 5,
|
||||
difficulty: st.difficulty[0] || "B2",
|
||||
instructions: DEFAULT_INSTRUCTIONS[et.key] || "",
|
||||
};
|
||||
}
|
||||
setExerciseWizard({ open: true, passageIndex: pi, step: 0, types });
|
||||
};
|
||||
|
||||
const wizardTotalQuestions = Object.values(exerciseWizard.types).reduce(
|
||||
(sum, t) => sum + (t.enabled ? t.count : 0), 0
|
||||
);
|
||||
const wizardEnabledTypes = Object.entries(exerciseWizard.types).filter(([, t]) => t.enabled);
|
||||
|
||||
const structuresQ = useQuery({
|
||||
queryKey: ["exam-structures"],
|
||||
@@ -253,23 +310,55 @@ export default function GenerationPage() {
|
||||
});
|
||||
|
||||
const generateExercisesMut = useMutation({
|
||||
mutationFn: (params: { module: ModuleKey; passageIndex: number; types: string[]; passageText: string }) =>
|
||||
generationService.generate(params.module === "level" || params.module === "industry" ? "reading" : params.module, {
|
||||
topic: params.passageText.slice(0, 200),
|
||||
difficulty: getModuleState(params.module).difficulty[0] ?? "B2",
|
||||
question_count: 5,
|
||||
}),
|
||||
mutationFn: (params: {
|
||||
module: ModuleKey;
|
||||
passageIndex: number;
|
||||
types: string[];
|
||||
typeCounts: Record<string, number>;
|
||||
typeInstructions: Record<string, string>;
|
||||
passageText: string;
|
||||
difficulty: string;
|
||||
}) => {
|
||||
const mod = params.module === "level" || params.module === "industry" ? "reading" : params.module;
|
||||
const totalCount = Object.values(params.typeCounts).reduce((s, n) => s + n, 0);
|
||||
return generationService.generateExercises(mod as "reading" | "listening" | "writing" | "speaking", {
|
||||
passage_index: params.passageIndex,
|
||||
exercise_types: params.types,
|
||||
count_per_type: totalCount,
|
||||
passage_text: params.passageText,
|
||||
type_counts: params.typeCounts,
|
||||
type_instructions: params.typeInstructions,
|
||||
difficulty: params.difficulty,
|
||||
} as Record<string, unknown> & { passage_index: number; exercise_types: string[] });
|
||||
},
|
||||
onSuccess: (res, vars) => {
|
||||
const st = getModuleState(vars.module);
|
||||
const items = Array.isArray((res as Record<string, unknown>).questions) ? (res as Record<string, unknown>).questions as unknown[] : [];
|
||||
const raw = Array.isArray((res as Record<string, unknown>).questions) ? (res as Record<string, unknown>).questions as Record<string, unknown>[] : [];
|
||||
const items: Exercise[] = raw.map((q) => ({
|
||||
type: String(q.type || vars.types?.[0] || "mcq"),
|
||||
instructions: String(q.instructions || vars.typeInstructions?.[String(q.type)] || ""),
|
||||
prompt: String(q.prompt || q.question || ""),
|
||||
options: Array.isArray(q.options) ? q.options.map(String) : [],
|
||||
correct_answer: String(q.correct_answer || q.answer || ""),
|
||||
explanation: String(q.explanation || ""),
|
||||
marks: Number(q.marks) || 1,
|
||||
difficulty: q.difficulty ? String(q.difficulty) : undefined,
|
||||
}));
|
||||
if (vars.module === "reading") {
|
||||
const passages = [...st.passages];
|
||||
passages[vars.passageIndex] = { ...passages[vars.passageIndex], exercises: items };
|
||||
passages[vars.passageIndex] = {
|
||||
...passages[vars.passageIndex],
|
||||
exercises: [...passages[vars.passageIndex].exercises, ...items],
|
||||
exerciseTypes: vars.types,
|
||||
};
|
||||
updateModuleState(vars.module, { passages });
|
||||
}
|
||||
setExerciseWizard((prev) => ({ ...prev, step: 1 }));
|
||||
toast({ title: `${items.length} exercises generated` });
|
||||
},
|
||||
onError: (err: Error) => toast({ variant: "destructive", title: "Generation failed", description: err.message }),
|
||||
onError: (err: Error) => {
|
||||
toast({ variant: "destructive", title: "Generation failed", description: err.message });
|
||||
},
|
||||
});
|
||||
|
||||
const generateAudioMut = useMutation({
|
||||
@@ -494,11 +583,21 @@ export default function GenerationPage() {
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Passages</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
{st.passages.map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<div key={i} className="flex items-center gap-1 bg-muted/50 rounded px-2 py-0.5">
|
||||
<Checkbox checked id={`p-${i}`} />
|
||||
<Label htmlFor={`p-${i}`} className="text-xs">Passage {i + 1}</Label>
|
||||
{st.passages.length > 1 && (
|
||||
<button
|
||||
className="ml-1 text-muted-foreground/60 hover:text-destructive transition-colors"
|
||||
onClick={() => {
|
||||
const passages = st.passages.filter((_, idx) => idx !== i);
|
||||
updateModuleState("reading", { passages });
|
||||
}}
|
||||
title={`Remove Passage ${i + 1}`}
|
||||
><X className="h-3 w-3" /></button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => {
|
||||
@@ -575,30 +674,13 @@ export default function GenerationPage() {
|
||||
</Button>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium w-full justify-between bg-blue-100 rounded px-2 py-1">
|
||||
Add Exercises <ChevronDown className="h-3 w-3" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2 space-y-1">
|
||||
{READING_EXERCISE_TYPES.map((et) => (
|
||||
<div key={et.key} className="flex items-center gap-2">
|
||||
<Checkbox id={`ex-${pi}-${et.key}`} disabled={!passage.text}
|
||||
checked={passage.exerciseTypes.includes(et.key)}
|
||||
onCheckedChange={(checked) => {
|
||||
const p = [...st.passages];
|
||||
const types = checked ? [...p[pi].exerciseTypes, et.key] : p[pi].exerciseTypes.filter((t) => t !== et.key);
|
||||
p[pi] = { ...p[pi], exerciseTypes: types };
|
||||
updateModuleState("reading", { passages: p });
|
||||
}} />
|
||||
<Label htmlFor={`ex-${pi}-${et.key}`} className="text-xs">Passage {pi + 1} - {et.label}</Label>
|
||||
</div>
|
||||
))}
|
||||
<Button size="sm" className="w-full mt-2 text-xs" disabled={!passage.text || passage.exerciseTypes.length === 0 || generateExercisesMut.isPending}
|
||||
onClick={() => generateExercisesMut.mutate({ module: "reading", passageIndex: pi, types: passage.exerciseTypes, passageText: passage.text })}>
|
||||
Set Up Exercises ({passage.exercises.length})
|
||||
<Button size="sm" className="w-full text-xs" variant="outline" disabled={!passage.text}
|
||||
onClick={() => openExerciseWizard(pi)}>
|
||||
<ListChecks className="h-3 w-3 mr-1" />
|
||||
{passage.exercises.length > 0
|
||||
? `Exercises (${passage.exercises.length}) — Add More`
|
||||
: "Set Up Exercises"}
|
||||
</Button>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -629,6 +711,107 @@ export default function GenerationPage() {
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{passage.exercises.length > 0 && (
|
||||
<Card className="border-blue-200 bg-blue-50/30">
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-blue-700">Exercises ({passage.exercises.length})</h4>
|
||||
<Button size="sm" variant="ghost" className="h-6 text-xs text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
const p = [...st.passages];
|
||||
p[pi] = { ...p[pi], exercises: [] };
|
||||
updateModuleState("reading", { passages: p });
|
||||
}}>
|
||||
<X className="h-3 w-3 mr-1" /> Clear All
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{(() => {
|
||||
const grouped: Record<string, { exercises: Exercise[]; indices: number[] }> = {};
|
||||
passage.exercises.forEach((ex, ei) => {
|
||||
if (!grouped[ex.type]) grouped[ex.type] = { exercises: [], indices: [] };
|
||||
grouped[ex.type].exercises.push(ex);
|
||||
grouped[ex.type].indices.push(ei);
|
||||
});
|
||||
return Object.entries(grouped).map(([type, group]) => {
|
||||
const typeLabel = READING_EXERCISE_TYPES.find((t) => t.key === type)?.label || type;
|
||||
const sectionInstructions = group.exercises[0]?.instructions;
|
||||
return (
|
||||
<div key={type} className="space-y-2">
|
||||
<div className="border-b border-blue-200 pb-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="text-xs font-semibold text-blue-700 flex items-center gap-1.5">
|
||||
{typeLabel}
|
||||
<Badge variant="outline" className="text-[10px] font-normal">{group.exercises.length}</Badge>
|
||||
</h5>
|
||||
</div>
|
||||
{sectionInstructions && (
|
||||
<p className="text-[11px] text-muted-foreground italic mt-0.5">{sectionInstructions}</p>
|
||||
)}
|
||||
</div>
|
||||
{group.exercises.map((ex, gi) => {
|
||||
const ei = group.indices[gi];
|
||||
return (
|
||||
<div key={ei} className="bg-white rounded-lg border p-3 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] font-medium text-muted-foreground">Q{gi + 1}</span>
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">{ex.marks} mark{ex.marks !== 1 ? "s" : ""}</Badge>
|
||||
</div>
|
||||
<p className="text-xs font-medium leading-relaxed">{ex.prompt}</p>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button size="sm" variant="ghost" className="h-6 w-6 p-0"
|
||||
title="Edit exercise"
|
||||
onClick={() => {
|
||||
setEditingExercise({ passageIndex: pi, exerciseIndex: ei });
|
||||
setExerciseDraft({ ...ex });
|
||||
}}>
|
||||
<PenTool className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||
title="Delete exercise"
|
||||
onClick={() => {
|
||||
const p = [...st.passages];
|
||||
p[pi] = { ...p[pi], exercises: p[pi].exercises.filter((_, idx) => idx !== ei) };
|
||||
updateModuleState("reading", { passages: p });
|
||||
}}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{ex.options.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{ex.options.map((opt, oi) => (
|
||||
<div key={oi} className={`text-[11px] px-2 py-1 rounded border ${opt === ex.correct_answer ? "bg-green-50 border-green-300 text-green-700 font-medium" : "bg-gray-50 border-gray-200"}`}>
|
||||
{String.fromCharCode(65 + oi)}. {opt}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!ex.options.length && ex.correct_answer && (
|
||||
<p className="text-[11px] text-green-700 bg-green-50 rounded px-2 py-1 border border-green-200">
|
||||
Answer: {ex.correct_answer}
|
||||
</p>
|
||||
)}
|
||||
{ex.explanation && (
|
||||
<p className="text-[11px] text-muted-foreground italic">
|
||||
{ex.explanation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -644,11 +827,21 @@ export default function GenerationPage() {
|
||||
{renderCommonConfig("listening")}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Sections</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
{st.listeningSections.map((sec, i) => (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<div key={i} className="flex items-center gap-1 bg-muted/50 rounded px-2 py-0.5">
|
||||
<Checkbox checked id={`ls-${i}`} />
|
||||
<Label htmlFor={`ls-${i}`} className="text-xs">{LISTENING_SECTION_TYPES.find((t) => t.key === sec.type)?.label || sec.type}</Label>
|
||||
{st.listeningSections.length > 1 && (
|
||||
<button
|
||||
className="ml-1 text-muted-foreground/60 hover:text-destructive transition-colors"
|
||||
onClick={() => {
|
||||
const sections = st.listeningSections.filter((_, idx) => idx !== i);
|
||||
updateModuleState("listening", { listeningSections: sections });
|
||||
}}
|
||||
title={`Remove Section ${i + 1}`}
|
||||
><X className="h-3 w-3" /></button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => {
|
||||
@@ -757,11 +950,21 @@ export default function GenerationPage() {
|
||||
{renderCommonConfig("writing")}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Tasks</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
{st.writingTasks.map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<div key={i} className="flex items-center gap-1 bg-muted/50 rounded px-2 py-0.5">
|
||||
<Checkbox checked id={`wt-${i}`} />
|
||||
<Label htmlFor={`wt-${i}`} className="text-xs">Task {i + 1}</Label>
|
||||
{st.writingTasks.length > 1 && (
|
||||
<button
|
||||
className="ml-1 text-muted-foreground/60 hover:text-destructive transition-colors"
|
||||
onClick={() => {
|
||||
const tasks = st.writingTasks.filter((_, idx) => idx !== i);
|
||||
updateModuleState("writing", { writingTasks: tasks });
|
||||
}}
|
||||
title={`Remove Task ${i + 1}`}
|
||||
><X className="h-3 w-3" /></button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{st.writingTasks.length < 2 && (
|
||||
@@ -860,11 +1063,21 @@ export default function GenerationPage() {
|
||||
{renderCommonConfig("speaking")}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Speaking Parts</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
{st.speakingParts.map((part, i) => (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<div key={i} className="flex items-center gap-1 bg-muted/50 rounded px-2 py-0.5">
|
||||
<Checkbox checked id={`sp-${i}`} />
|
||||
<Label htmlFor={`sp-${i}`} className="text-xs">{partTypes.find((t) => t.key === part.type)?.label || part.type}</Label>
|
||||
{st.speakingParts.length > 1 && (
|
||||
<button
|
||||
className="ml-1 text-muted-foreground/60 hover:text-destructive transition-colors"
|
||||
onClick={() => {
|
||||
const parts = st.speakingParts.filter((_, idx) => idx !== i);
|
||||
updateModuleState("speaking", { speakingParts: parts });
|
||||
}}
|
||||
title={`Remove ${partTypes.find((t) => t.key === part.type)?.label || part.type}`}
|
||||
><X className="h-3 w-3" /></button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => {
|
||||
@@ -1127,7 +1340,29 @@ export default function GenerationPage() {
|
||||
<p className="text-sm text-muted-foreground italic">No passage text yet</p>
|
||||
)}
|
||||
{p.exercises.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">{p.exercises.length} exercise(s) generated</p>
|
||||
<div className="space-y-2 mt-2">
|
||||
<p className="text-xs font-medium text-blue-700">{p.exercises.length} exercise(s)</p>
|
||||
{p.exercises.map((ex, ei) => (
|
||||
<div key={ei} className="bg-white rounded border p-2 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{READING_EXERCISE_TYPES.find((t) => t.key === ex.type)?.label || ex.type}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[10px]">{ex.marks} mark{ex.marks !== 1 ? "s" : ""}</Badge>
|
||||
</div>
|
||||
<p className="text-xs">{ex.prompt}</p>
|
||||
{ex.options.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{ex.options.map((opt, oi) => (
|
||||
<span key={oi} className={`text-[10px] px-1.5 py-0.5 rounded ${opt === ex.correct_answer ? "bg-green-100 text-green-700 font-medium" : "text-muted-foreground"}`}>
|
||||
{String.fromCharCode(65 + oi)}. {opt}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -1191,6 +1426,314 @@ export default function GenerationPage() {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={exerciseWizard.open} onOpenChange={(open) => {
|
||||
if (!open) setExerciseWizard((prev) => ({ ...prev, open: false, step: 0 }));
|
||||
}}>
|
||||
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Settings2 className="h-5 w-5 text-blue-600" />
|
||||
{exerciseWizard.step === 0 ? "Configure Exercises" : "Exercises Generated"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{exerciseWizard.step === 0
|
||||
? "Choose exercise types, set how many of each, and customize difficulty."
|
||||
: "Exercises have been generated and added to your passage."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{exerciseWizard.step === 0 && (
|
||||
<div className="space-y-3 pt-2">
|
||||
{READING_EXERCISE_TYPES.map((et) => {
|
||||
const cfg = exerciseWizard.types[et.key];
|
||||
if (!cfg) return null;
|
||||
return (
|
||||
<div key={et.key} className={`rounded-lg border p-3 transition-colors ${cfg.enabled ? "bg-blue-50/60 border-blue-200" : "bg-muted/30 border-muted"}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={cfg.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setExerciseWizard((prev) => ({
|
||||
...prev,
|
||||
types: { ...prev.types, [et.key]: { ...prev.types[et.key], enabled: !!checked } },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<p className={`text-sm font-medium ${cfg.enabled ? "text-foreground" : "text-muted-foreground"}`}>{et.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
{cfg.enabled && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-[10px] text-muted-foreground whitespace-nowrap">Questions</Label>
|
||||
<div className="flex items-center border rounded-md">
|
||||
<button className="px-1.5 py-0.5 hover:bg-muted transition-colors rounded-l-md"
|
||||
onClick={() => setExerciseWizard((prev) => ({
|
||||
...prev,
|
||||
types: { ...prev.types, [et.key]: { ...prev.types[et.key], count: Math.max(1, prev.types[et.key].count - 1) } },
|
||||
}))}>
|
||||
<Minus className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="px-2 text-xs font-medium min-w-[24px] text-center">{cfg.count}</span>
|
||||
<button className="px-1.5 py-0.5 hover:bg-muted transition-colors rounded-r-md"
|
||||
onClick={() => setExerciseWizard((prev) => ({
|
||||
...prev,
|
||||
types: { ...prev.types, [et.key]: { ...prev.types[et.key], count: Math.min(20, prev.types[et.key].count + 1) } },
|
||||
}))}>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{cfg.enabled && (
|
||||
<div className="mt-2 ml-8 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-[10px] text-muted-foreground">Difficulty</Label>
|
||||
<Select value={cfg.difficulty} onValueChange={(v) =>
|
||||
setExerciseWizard((prev) => ({
|
||||
...prev,
|
||||
types: { ...prev.types, [et.key]: { ...prev.types[et.key], difficulty: v } },
|
||||
}))
|
||||
}>
|
||||
<SelectTrigger className="h-6 w-16 text-[11px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{CEFR_LEVELS.map((l) => <SelectItem key={l} value={l}>{l}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-muted-foreground">Instructions for students</Label>
|
||||
<Textarea
|
||||
value={cfg.instructions}
|
||||
onChange={(e) =>
|
||||
setExerciseWizard((prev) => ({
|
||||
...prev,
|
||||
types: { ...prev.types, [et.key]: { ...prev.types[et.key], instructions: e.target.value } },
|
||||
}))
|
||||
}
|
||||
placeholder={`Instructions for ${et.label} section...`}
|
||||
className="min-h-[48px] text-[11px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="rounded-lg bg-muted/50 p-3 flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">Total questions to generate</span>
|
||||
<Badge variant="secondary" className="text-sm font-bold">{wizardTotalQuestions}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<button className="underline hover:text-foreground" onClick={() => {
|
||||
setExerciseWizard((prev) => {
|
||||
const types = { ...prev.types };
|
||||
for (const key of Object.keys(types)) types[key] = { ...types[key], enabled: true };
|
||||
return { ...prev, types };
|
||||
});
|
||||
}}>Select All</button>
|
||||
<span>·</span>
|
||||
<button className="underline hover:text-foreground" onClick={() => {
|
||||
setExerciseWizard((prev) => {
|
||||
const types = { ...prev.types };
|
||||
for (const key of Object.keys(types)) types[key] = { ...types[key], enabled: false };
|
||||
return { ...prev, types };
|
||||
});
|
||||
}}>Deselect All</button>
|
||||
<span>·</span>
|
||||
<button className="underline hover:text-foreground" onClick={() => {
|
||||
setExerciseWizard((prev) => {
|
||||
const types = { ...prev.types };
|
||||
for (const key of Object.keys(types)) types[key] = { ...types[key], count: 5 };
|
||||
return { ...prev, types };
|
||||
});
|
||||
}}>Reset to Default (5 each)</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{exerciseWizard.step === 1 && (
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="h-12 w-12 rounded-full bg-green-100 flex items-center justify-center mx-auto">
|
||||
<ListChecks className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">Exercises added successfully!</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You can now edit, delete, or preview individual exercises below the passage.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
{exerciseWizard.step === 0 && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setExerciseWizard((prev) => ({ ...prev, open: false }))}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" disabled={wizardEnabledTypes.length === 0 || generateExercisesMut.isPending}
|
||||
onClick={() => {
|
||||
const st = getModuleState("reading");
|
||||
const passage = st.passages[exerciseWizard.passageIndex];
|
||||
if (!passage?.text) return;
|
||||
const types = wizardEnabledTypes.map(([key]) => key);
|
||||
const typeCounts: Record<string, number> = {};
|
||||
const typeInstructions: Record<string, string> = {};
|
||||
for (const [key, cfg] of wizardEnabledTypes) {
|
||||
typeCounts[key] = cfg.count;
|
||||
if (cfg.instructions.trim()) typeInstructions[key] = cfg.instructions.trim();
|
||||
}
|
||||
const difficulty = wizardEnabledTypes[0]?.[1]?.difficulty || st.difficulty[0] || "B2";
|
||||
generateExercisesMut.mutate({
|
||||
module: "reading",
|
||||
passageIndex: exerciseWizard.passageIndex,
|
||||
types,
|
||||
typeCounts,
|
||||
typeInstructions,
|
||||
passageText: passage.text,
|
||||
difficulty,
|
||||
});
|
||||
}}>
|
||||
{generateExercisesMut.isPending ? (
|
||||
<><Loader2 className="h-3 w-3 mr-1 animate-spin" /> Generating...</>
|
||||
) : (
|
||||
<><Sparkles className="h-3 w-3 mr-1" /> Generate {wizardTotalQuestions} Exercises</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{exerciseWizard.step === 1 && (
|
||||
<Button size="sm" onClick={() => setExerciseWizard((prev) => ({ ...prev, open: false, step: 0 }))}>
|
||||
Done
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!editingExercise} onOpenChange={(open) => { if (!open) { setEditingExercise(null); setExerciseDraft(null); } }}>
|
||||
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Exercise</DialogTitle>
|
||||
<DialogDescription>Modify the exercise details below.</DialogDescription>
|
||||
</DialogHeader>
|
||||
{exerciseDraft && (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Exercise Type</Label>
|
||||
<Select value={exerciseDraft.type} onValueChange={(v) => setExerciseDraft({ ...exerciseDraft, type: v })}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{READING_EXERCISE_TYPES.map((et) => (
|
||||
<SelectItem key={et.key} value={et.key}>{et.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Section Instructions</Label>
|
||||
<Textarea value={exerciseDraft.instructions} className="min-h-[40px] text-xs"
|
||||
placeholder="Instructions shown to the student for this section..."
|
||||
onChange={(e) => setExerciseDraft({ ...exerciseDraft, instructions: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Question / Prompt</Label>
|
||||
<Textarea value={exerciseDraft.prompt} className="min-h-[60px] text-xs"
|
||||
onChange={(e) => setExerciseDraft({ ...exerciseDraft, prompt: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">Options</Label>
|
||||
<Button size="sm" variant="ghost" className="h-6 text-xs"
|
||||
onClick={() => setExerciseDraft({ ...exerciseDraft, options: [...exerciseDraft.options, ""] })}>
|
||||
<Plus className="h-3 w-3 mr-1" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
{exerciseDraft.options.map((opt, oi) => (
|
||||
<div key={oi} className="flex gap-2 items-center">
|
||||
<span className="text-xs font-medium w-5 text-center shrink-0">{String.fromCharCode(65 + oi)}.</span>
|
||||
<Input value={opt} className="h-8 text-xs flex-1"
|
||||
onChange={(e) => {
|
||||
const opts = [...exerciseDraft.options];
|
||||
opts[oi] = e.target.value;
|
||||
setExerciseDraft({ ...exerciseDraft, options: opts });
|
||||
}} />
|
||||
<button className={`shrink-0 h-6 w-6 rounded-full border-2 flex items-center justify-center text-xs transition-colors ${
|
||||
opt === exerciseDraft.correct_answer ? "bg-green-500 border-green-500 text-white" : "border-gray-300 hover:border-green-400"
|
||||
}`}
|
||||
title="Set as correct answer"
|
||||
onClick={() => setExerciseDraft({ ...exerciseDraft, correct_answer: opt })}>
|
||||
{opt === exerciseDraft.correct_answer ? "✓" : ""}
|
||||
</button>
|
||||
{exerciseDraft.options.length > 2 && (
|
||||
<button className="shrink-0 text-muted-foreground/60 hover:text-destructive transition-colors"
|
||||
onClick={() => {
|
||||
const opts = exerciseDraft.options.filter((_, idx) => idx !== oi);
|
||||
const corrected = opts.includes(exerciseDraft.correct_answer) ? exerciseDraft.correct_answer : "";
|
||||
setExerciseDraft({ ...exerciseDraft, options: opts, correct_answer: corrected });
|
||||
}}>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{exerciseDraft.options.length === 0 && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Correct Answer</Label>
|
||||
<Input value={exerciseDraft.correct_answer} className="h-8 text-xs"
|
||||
onChange={(e) => setExerciseDraft({ ...exerciseDraft, correct_answer: e.target.value })} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Explanation</Label>
|
||||
<Textarea value={exerciseDraft.explanation} className="min-h-[40px] text-xs"
|
||||
onChange={(e) => setExerciseDraft({ ...exerciseDraft, explanation: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Marks</Label>
|
||||
<Input type="number" value={exerciseDraft.marks} className="h-8 text-xs w-20"
|
||||
onChange={(e) => setExerciseDraft({ ...exerciseDraft, marks: Number(e.target.value) || 1 })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<Button variant="outline" size="sm" onClick={() => { setEditingExercise(null); setExerciseDraft(null); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => {
|
||||
if (!editingExercise || !exerciseDraft) return;
|
||||
const st = getModuleState("reading");
|
||||
const passages = [...st.passages];
|
||||
const exercises = [...passages[editingExercise.passageIndex].exercises];
|
||||
exercises[editingExercise.exerciseIndex] = exerciseDraft;
|
||||
passages[editingExercise.passageIndex] = { ...passages[editingExercise.passageIndex], exercises };
|
||||
updateModuleState("reading", { passages });
|
||||
setEditingExercise(null);
|
||||
setExerciseDraft(null);
|
||||
toast({ title: "Exercise updated" });
|
||||
}}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ export const generationService = {
|
||||
});
|
||||
},
|
||||
|
||||
generateExercises(module: ExamModule, config: ExerciseConfig & { passage_text?: string }): Promise<{ questions: unknown[] }> {
|
||||
generateExercises(module: ExamModule, config: ExerciseConfig & { passage_text?: string; type_counts?: Record<string, number>; difficulty?: string }): Promise<{ questions: unknown[] }> {
|
||||
return api.post(`/exam/${module}/generate`, {
|
||||
...config,
|
||||
generate_exercises: true,
|
||||
|
||||
Reference in New Issue
Block a user