From 2f450a7f268f4e42de62542806bdd2883c0408fe Mon Sep 17 00:00:00 2001 From: Yamen Ahmad Date: Mon, 13 Apr 2026 00:54:19 +0400 Subject: [PATCH] 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 --- src/pages/GenerationPage.tsx | 631 +++++++++++++++++++++++++++-- src/services/generation.service.ts | 2 +- 2 files changed, 588 insertions(+), 45 deletions(-) diff --git a/src/pages/GenerationPage.tsx b/src/pages/GenerationPage.tsx index da11e82..f49a81c 100644 --- a/src/pages/GenerationPage.tsx +++ b/src/pages/GenerationPage.tsx @@ -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(null); const [moduleStates, setModuleStates] = useState>({}); const [previewOpen, setPreviewOpen] = useState(false); + const [editingExercise, setEditingExercise] = useState<{ passageIndex: number; exerciseIndex: number } | null>(null); + const [exerciseDraft, setExerciseDraft] = useState(null); + + const [exerciseWizard, setExerciseWizard] = useState<{ + open: boolean; + passageIndex: number; + step: number; // 0 = configure, 1 = generating/done + types: Record; + }>({ + open: false, + passageIndex: 0, + step: 0, + types: {}, + }); + + const DEFAULT_INSTRUCTIONS: Record = { + 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 = {}; + 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; + typeInstructions: Record; + 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 & { passage_index: number; exercise_types: string[] }); + }, onSuccess: (res, vars) => { const st = getModuleState(vars.module); - const items = Array.isArray((res as Record).questions) ? (res as Record).questions as unknown[] : []; + const raw = Array.isArray((res as Record).questions) ? (res as Record).questions as Record[] : []; + 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() {
-
+
{st.passages.map((_, i) => ( -
+
+ {st.passages.length > 1 && ( + + )}
))} - - - Add Exercises - - - {READING_EXERCISE_TYPES.map((et) => ( -
- { - 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 }); - }} /> - -
- ))} - -
-
+ @@ -629,6 +711,107 @@ export default function GenerationPage() { /> + + {passage.exercises.length > 0 && ( + + +
+

Exercises ({passage.exercises.length})

+ +
+
+ {(() => { + const grouped: Record = {}; + 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 ( +
+
+
+
+ {typeLabel} + {group.exercises.length} +
+
+ {sectionInstructions && ( +

{sectionInstructions}

+ )} +
+ {group.exercises.map((ex, gi) => { + const ei = group.indices[gi]; + return ( +
+
+
+
+ Q{gi + 1} + {ex.marks} mark{ex.marks !== 1 ? "s" : ""} +
+

{ex.prompt}

+
+
+ + +
+
+ {ex.options.length > 0 && ( +
+ {ex.options.map((opt, oi) => ( +
+ {String.fromCharCode(65 + oi)}. {opt} +
+ ))} +
+ )} + {!ex.options.length && ex.correct_answer && ( +

+ Answer: {ex.correct_answer} +

+ )} + {ex.explanation && ( +

+ {ex.explanation} +

+ )} +
+ ); + })} +
+ ); + }); + })()} +
+
+
+ )}
))}
@@ -644,11 +827,21 @@ export default function GenerationPage() { {renderCommonConfig("listening")}
-
+
{st.listeningSections.map((sec, i) => ( -
+
+ {st.listeningSections.length > 1 && ( + + )}
))} + )}
))} {st.writingTasks.length < 2 && ( @@ -860,11 +1063,21 @@ export default function GenerationPage() { {renderCommonConfig("speaking")}
-
+
{st.speakingParts.map((part, i) => ( -
+
+ {st.speakingParts.length > 1 && ( + + )}
))}
))} @@ -1191,6 +1426,314 @@ export default function GenerationPage() {
+ + { + if (!open) setExerciseWizard((prev) => ({ ...prev, open: false, step: 0 })); + }}> + + + + + {exerciseWizard.step === 0 ? "Configure Exercises" : "Exercises Generated"} + + + {exerciseWizard.step === 0 + ? "Choose exercise types, set how many of each, and customize difficulty." + : "Exercises have been generated and added to your passage."} + + + + {exerciseWizard.step === 0 && ( +
+ {READING_EXERCISE_TYPES.map((et) => { + const cfg = exerciseWizard.types[et.key]; + if (!cfg) return null; + return ( +
+
+
+ + setExerciseWizard((prev) => ({ + ...prev, + types: { ...prev.types, [et.key]: { ...prev.types[et.key], enabled: !!checked } }, + })) + } + /> +
+

{et.label}

+
+
+ {cfg.enabled && ( +
+ +
+ + {cfg.count} + +
+
+ )} +
+ {cfg.enabled && ( +
+
+ + +
+
+ +