feat(platform): course-plan student visibility, multi-voice TTS, OCR sources, branches

Backend (encoach_ai_course):
- workbook_attempt model + scoring + REST endpoints for student attempts
- dialogue_parser splits scripts by speaker, classifies gender, strips labels
- media_service: multi-voice TTS via Polly/ElevenLabs, ffmpeg concatenation,
  manual media upload endpoint (audio/image/video) with size validation
- source_indexer: OCR fallback (pytesseract + pdf2image) for scanned PDFs,
  page-streaming to stay under memory limit
- exercise_extractor + rag_context for RAG-grounded interactive workbooks
- course_plan_pipeline: v2 generator that grounds week material on indexed
  sources and persists grounded_on_json metadata
- security: access rules for new models

Backend (encoach_lms_api):
- branches model + controller (entity-scoped LMS branches)
- classroom_ext + course_ext (assignment + section workflow)
- classrooms controller: students/teachers/assign-course endpoints

Frontend:
- StudentDashboard: surface assigned AI course plans alongside enrollments;
  enrolled-courses stat now counts plans+enrollments
- InteractiveWorkbook + PlanReader components
- AdminCoursePlanDetail: media drawer with upload buttons (audio/image/video),
  hidden file inputs, upload mutation
- AdminBranches page + sidebar entry
- coursePlan/lms/classrooms services + types updated for new endpoints
- i18n: studentDash.myCoursePlans/noCoursePlans (en + ar)

Infra & docs:
- odoo.conf: bump memory limits to 4G/5G for OCR + sentence-transformers
- .gitignore: ignore *.tsbuildinfo
- docs/ASSIGNMENT_WORKFLOW.{md,pdf}
- smoke_*.py end-to-end tests for assignment workflow, entity isolation,
  course-plan RAG pipeline

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-28 00:17:51 +04:00
parent a6f2140a9d
commit 3b62075d7e
55 changed files with 11150 additions and 878 deletions

View File

@@ -13,12 +13,12 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useCourses, useCreateCourse, useStudents, useBulkEnroll, useBatches } from "@/hooks/queries";
import { lmsService, taxonomyService, resourcesService } from "@/services";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Search, Plus, Trash2, UserPlus, FileEdit, Pencil, BookOpen, Target, Loader2, Users, GraduationCap } from "lucide-react";
import { Search, Plus, Trash2, UserPlus, FileEdit, Pencil, BookOpen, Target, Loader2, Users, GraduationCap, Layers, Sparkles, X } from "lucide-react";
import { Link } from "react-router-dom";
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
import AiTipBanner from "@/components/ai/AiTipBanner";
import { useToast } from "@/hooks/use-toast";
import type { Course, CourseCreateRequest } from "@/types";
import type { Course, CourseCreateRequest, CourseSection } from "@/types";
import type { ResourceTag } from "@/types/adaptive";
const DIFFICULTY_OPTIONS = [
@@ -409,10 +409,335 @@ function courseToFormData(c: Course): CourseFormData {
};
}
function CourseSectionsDialog({
open,
onOpenChange,
course,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
course: Course;
}) {
const { toast } = useToast();
const qc = useQueryClient();
const [newName, setNewName] = useState("");
const [newCode, setNewCode] = useState("");
const [newSeq, setNewSeq] = useState<number>(10);
const [editing, setEditing] = useState<CourseSection | null>(null);
const [editName, setEditName] = useState("");
const [editCode, setEditCode] = useState("");
const [editSeq, setEditSeq] = useState<number>(10);
const [editActive, setEditActive] = useState(true);
const [busy, setBusy] = useState(false);
const sectionsQ = useQuery({
queryKey: ["course-sections", course.id],
queryFn: () => lmsService.listCourseSections(course.id),
enabled: open,
});
const sections = sectionsQ.data?.items ?? [];
function refreshAll() {
qc.invalidateQueries({ queryKey: ["course-sections", course.id] });
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
qc.invalidateQueries({ queryKey: ["lms-classroom-detail"] });
}
async function handleGenerateDefaults() {
setBusy(true);
try {
const res = await lmsService.generateDefaultCourseSections(course.id);
toast({
title: res.created_count > 0 ? `Generated ${res.created_count} section(s)` : "Defaults already exist",
});
refreshAll();
} catch (e: unknown) {
toast({
title: "Generation failed",
description: e instanceof Error ? e.message : String(e),
variant: "destructive",
});
}
setBusy(false);
}
async function handleAdd() {
const code = newCode.trim().toUpperCase();
const name = newName.trim() || `Section ${code}`;
if (!code) {
toast({ title: "Code is required", variant: "destructive" });
return;
}
setBusy(true);
try {
await lmsService.createCourseSection(course.id, {
name,
code,
sequence: newSeq || 10,
active: true,
});
toast({ title: `Section ${code} added` });
setNewName("");
setNewCode("");
setNewSeq(10);
refreshAll();
} catch (e: unknown) {
toast({
title: "Add failed",
description: e instanceof Error ? e.message : String(e),
variant: "destructive",
});
}
setBusy(false);
}
function startEdit(s: CourseSection) {
setEditing(s);
setEditName(s.name);
setEditCode(s.code);
setEditSeq(s.sequence || 10);
setEditActive(s.active);
}
async function saveEdit() {
if (!editing) return;
setBusy(true);
try {
await lmsService.updateCourseSection(course.id, editing.id, {
name: editName.trim() || editing.name,
code: editCode.trim().toUpperCase() || editing.code,
sequence: editSeq || editing.sequence,
active: editActive,
});
toast({ title: "Section updated" });
setEditing(null);
refreshAll();
} catch (e: unknown) {
toast({
title: "Update failed",
description: e instanceof Error ? e.message : String(e),
variant: "destructive",
});
}
setBusy(false);
}
async function handleDelete(s: CourseSection) {
if ((s.batch_count ?? 0) > 0) {
toast({
title: "Cannot delete",
description: `Section "${s.code}" has ${s.batch_count} batch(es). Reassign or remove them first.`,
variant: "destructive",
});
return;
}
if (!window.confirm(`Delete section "${s.name}" (${s.code})?`)) return;
setBusy(true);
try {
await lmsService.deleteCourseSection(course.id, s.id);
toast({ title: "Section deleted" });
refreshAll();
} catch (e: unknown) {
toast({
title: "Delete failed",
description: e instanceof Error ? e.message : String(e),
variant: "destructive",
});
}
setBusy(false);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[640px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Layers className="h-5 w-5" /> Sections {course.title}
</DialogTitle>
<DialogDescription>
Course sections (A, B, C) are templates. Each section can be hosted by a classroom,
which auto-creates a batch and enrolls the classroom roster.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
<div className="text-sm">
<p className="font-medium">Quick start</p>
<p className="text-xs text-muted-foreground">
Create the standard <strong>A · B · C</strong> sections for this course in one click.
</p>
</div>
<Button
size="sm"
onClick={handleGenerateDefaults}
disabled={busy || sectionsQ.isLoading}
variant="secondary"
>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
Generate A / B / C
</Button>
</div>
<div>
<Label className="text-sm font-medium">Existing sections</Label>
{sectionsQ.isLoading ? (
<div className="flex items-center gap-2 py-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading
</div>
) : sections.length === 0 ? (
<p className="text-sm text-muted-foreground py-3">
No sections yet. Add one below or click <em>Generate A / B / C</em>.
</p>
) : (
<div className="mt-2 border rounded-md divide-y">
{sections.map((s) => (
<div key={s.id} className="flex items-center justify-between gap-3 px-3 py-2">
<div className="flex items-center gap-3 min-w-0">
<Badge variant="outline" className="font-mono">
{s.code}
</Badge>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{s.name}</p>
<p className="text-xs text-muted-foreground">
seq {s.sequence}
{s.batch_count != null && (
<span className="ml-2">· {s.batch_count} batch(es)</span>
)}
{!s.active && <span className="ml-2 text-amber-600">· inactive</span>}
</p>
</div>
</div>
<div className="flex gap-1 shrink-0">
<Button
size="icon"
variant="ghost"
title="Edit section"
onClick={() => startEdit(s)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
title="Delete section"
onClick={() => handleDelete(s)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
<div className="rounded-lg border p-3 space-y-3">
<Label className="text-sm font-medium">Add a new section</Label>
<div className="grid grid-cols-12 gap-2">
<div className="col-span-3">
<Label className="text-[11px] text-muted-foreground">Code *</Label>
<Input
placeholder="A"
value={newCode}
onChange={(e) => setNewCode(e.target.value)}
maxLength={8}
/>
</div>
<div className="col-span-7">
<Label className="text-[11px] text-muted-foreground">Name</Label>
<Input
placeholder="Section A"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
</div>
<div className="col-span-2">
<Label className="text-[11px] text-muted-foreground">Seq</Label>
<Input
type="number"
value={newSeq}
onChange={(e) => setNewSeq(Number(e.target.value) || 10)}
/>
</div>
</div>
<div className="flex justify-end">
<Button size="sm" onClick={handleAdd} disabled={busy || !newCode.trim()}>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Plus className="mr-2 h-4 w-4" />}
Add section
</Button>
</div>
</div>
</div>
{editing && (
<Dialog open={!!editing} onOpenChange={(open) => !open && setEditing(null)}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Pencil className="h-4 w-4" /> Edit section
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<div>
<Label>Code</Label>
<Input
value={editCode}
onChange={(e) => setEditCode(e.target.value)}
maxLength={8}
/>
</div>
<div>
<Label>Sequence</Label>
<Input
type="number"
value={editSeq}
onChange={(e) => setEditSeq(Number(e.target.value) || 10)}
/>
</div>
</div>
<div>
<Label>Name</Label>
<Input value={editName} onChange={(e) => setEditName(e.target.value)} />
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={editActive}
onCheckedChange={(v) => setEditActive(Boolean(v))}
/>
Active
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)}>
<X className="mr-2 h-4 w-4" />
Cancel
</Button>
<Button onClick={saveEdit} disabled={busy}>
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function AdminCourses() {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
const [sectionsCourse, setSectionsCourse] = useState<Course | null>(null);
const [enrollOpen, setEnrollOpen] = useState(false);
const [enrollCourseId, setEnrollCourseId] = useState<number | null>(null);
const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]);
@@ -539,10 +864,11 @@ export default function AdminCourses() {
<TableHead>Course</TableHead>
<TableHead>Subject / Tags</TableHead>
<TableHead>Difficulty</TableHead>
<TableHead>Sections</TableHead>
<TableHead>Chapters</TableHead>
<TableHead>Enrolled / Cap</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[150px]" />
<TableHead className="w-[180px]" />
</TableRow>
</TableHeader>
<TableBody>
@@ -594,6 +920,36 @@ export default function AdminCourses() {
</Badge>
)}
</TableCell>
<TableCell>
<button
type="button"
onClick={() => setSectionsCourse(c)}
className="group flex items-center gap-1 text-xs hover:underline"
title="Manage course sections (A/B/C…)"
>
<Layers className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium">{c.section_count ?? c.sections?.length ?? 0}</span>
<span className="text-muted-foreground">sections</span>
{c.sections && c.sections.length > 0 && (
<span className="ml-1 flex flex-wrap gap-0.5">
{c.sections.slice(0, 4).map((s) => (
<Badge
key={s.id}
variant="outline"
className="text-[10px] px-1 py-0 leading-none"
>
{s.code || s.name}
</Badge>
))}
{c.sections.length > 4 && (
<span className="text-[10px] text-muted-foreground">
+{c.sections.length - 4}
</span>
)}
</span>
)}
</button>
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-sm">
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
@@ -627,6 +983,14 @@ export default function AdminCourses() {
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Manage sections (A/B/C…)"
onClick={() => setSectionsCourse(c)}
>
<Layers className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -653,7 +1017,7 @@ export default function AdminCourses() {
))}
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
<TableCell colSpan={8} className="text-center text-muted-foreground py-8">
No courses found.
</TableCell>
</TableRow>
@@ -679,6 +1043,16 @@ export default function AdminCourses() {
/>
)}
{sectionsCourse && (
<CourseSectionsDialog
open={!!sectionsCourse}
onOpenChange={(open) => {
if (!open) setSectionsCourse(null);
}}
course={sectionsCourse}
/>
)}
<Dialog open={enrollOpen} onOpenChange={setEnrollOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>