Files
full_encoach_platform/frontend/src/components/coursePlan/PlanReader.tsx
Yamen Ahmad 3b62075d7e 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
2026-04-28 00:17:51 +04:00

391 lines
12 KiB
TypeScript

import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
BookOpen,
Calendar,
ClipboardList,
Headphones,
Image as ImageIcon,
Library,
Link2,
MessageSquare,
Mic,
Music,
PenSquare,
Sparkles,
Type,
Video,
type LucideIcon,
} from "lucide-react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { withAuthQuery } from "@/lib/api-client";
import MaterialBookView, {
SkillBadge,
} from "@/components/coursePlan/MaterialBookView";
import type { WorkbookMode } from "@/components/coursePlan/InteractiveWorkbook";
import type {
CoursePlan,
CoursePlanMaterial,
CoursePlanMedia,
CoursePlanWeek,
} from "@/types";
const SKILL_ICONS: Record<string, LucideIcon> = {
reading: BookOpen,
writing: PenSquare,
listening: Headphones,
speaking: Mic,
grammar: Type,
vocabulary: Library,
integrated: MessageSquare,
};
/**
* Read-only renderer for a course plan that BOTH the student page and
* the admin "View as Student" preview share. The only difference is
* the workbook `mode`:
*
* - `mode="student"` → answers persist server-side (real attempts).
* - `mode="preview"` → InteractiveWorkbook runs in preview mode (no
* persistence) and surfaces an "Show answer key" toggle so admins
* can verify the v2 generator's output.
*
* Materials are rendered through `MaterialBookView`, which dispatches
* `interactive_workbook` materials into the live workbook component.
*/
export interface PlanReaderProps {
plan: CoursePlan;
mode?: WorkbookMode;
/** Heading level for accessibility — admins use this inside their own
* Card; students use it as the page's main grid block. Defaults to 'h3'. */
headingClassName?: string;
}
export default function PlanReader({ plan, mode = "student" }: PlanReaderProps) {
const { t } = useTranslation();
const materialsByWeek = useMemo(() => {
const map = new Map<number, CoursePlanMaterial[]>();
if (!plan.materials) return map;
for (const m of plan.materials) {
const list = map.get(m.week_number) ?? [];
list.push(m);
map.set(m.week_number, list);
}
return map;
}, [plan.materials]);
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex-1 min-w-0">
<CardTitle className="text-2xl">{plan.name}</CardTitle>
{plan.description && (
<CardDescription className="max-w-3xl">
{plan.description}
</CardDescription>
)}
</div>
<div className="flex gap-2 flex-wrap">
<Badge variant="secondary" className="uppercase">
{plan.cefr_level}
</Badge>
<Badge variant="outline">
{t("coursePlan.weeksCount", { count: plan.total_weeks })}
</Badge>
<Badge variant="outline">
{t("coursePlan.hoursPerWeek", {
count: plan.contact_hours_per_week,
})}
</Badge>
</div>
</div>
{plan.assignment && (
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5" />
{plan.assignment.due_date
? t("coursePlan.student.due", { date: plan.assignment.due_date })
: t("coursePlan.student.noDue")}
{plan.assignment.assigned_by_name && (
<span>
·{" "}
{t("coursePlan.student.assignedBy", {
name: plan.assignment.assigned_by_name,
})}
</span>
)}
</p>
)}
{plan.assignment?.message && (
<p className="text-sm bg-muted/40 rounded-md px-3 py-2 mt-2">
{plan.assignment.message}
</p>
)}
</CardHeader>
</Card>
{plan.objectives && plan.objectives.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-primary" />
{t("coursePlan.sections.objectives")}
</CardTitle>
</CardHeader>
<CardContent>
<ol className="list-decimal list-inside space-y-1 text-sm">
{plan.objectives.map((o, i) => (
<li key={i}>{o}</li>
))}
</ol>
</CardContent>
</Card>
)}
{plan.weeks && plan.weeks.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
{t("coursePlan.sections.delivery")}
</CardTitle>
<CardDescription>{t("coursePlan.deliveryHint")}</CardDescription>
</CardHeader>
<CardContent>
<Accordion type="multiple" className="w-full">
{plan.weeks.map((w) => (
<ReaderWeek
key={w.id}
week={w}
materials={materialsByWeek.get(w.week_number) ?? []}
mode={mode}
/>
))}
</Accordion>
</CardContent>
</Card>
)}
</div>
);
}
function ReaderWeek({
week,
materials,
mode,
}: {
week: CoursePlanWeek;
materials: CoursePlanMaterial[];
mode: WorkbookMode;
}) {
const { t } = useTranslation();
const [skillFilter, setSkillFilter] = useState<string>("all");
const skills = useMemo(
() => Array.from(new Set(materials.map((m) => m.skill))).sort(),
[materials],
);
const filteredMaterials = useMemo(
() =>
materials.filter(
(m) => skillFilter === "all" || m.skill === skillFilter,
),
[materials, skillFilter],
);
return (
<AccordionItem value={String(week.week_number)}>
<AccordionTrigger className="text-left">
<div className="flex-1 flex items-center gap-3 min-w-0">
<Badge variant="outline" className="shrink-0">
{t("coursePlan.weekN", { n: week.week_number })}
</Badge>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{week.focus || week.unit || "—"}
</div>
{week.date_label && (
<div className="text-xs text-muted-foreground truncate">
{week.date_label}
</div>
)}
</div>
</div>
</AccordionTrigger>
<AccordionContent className="space-y-3">
{skills.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
<Button
size="sm"
variant={skillFilter === "all" ? "default" : "outline"}
onClick={() => setSkillFilter("all")}
>
{t("common.all", "All")}
</Button>
{skills.map((s) => (
<Button
key={s}
size="sm"
variant={skillFilter === s ? "default" : "outline"}
onClick={() => setSkillFilter(s)}
className="capitalize"
>
{s}
</Button>
))}
</div>
)}
{materials.length === 0 && (
<p className="text-sm italic text-muted-foreground">
{t("coursePlan.media.noMedia")}
</p>
)}
{filteredMaterials.map((m) => (
<ReaderMaterial key={m.id} material={m} mode={mode} />
))}
</AccordionContent>
</AccordionItem>
);
}
function ReaderMaterial({
material,
mode,
}: {
material: CoursePlanMaterial;
mode: WorkbookMode;
}) {
const { t } = useTranslation();
const Icon = SKILL_ICONS[material.skill] ?? ClipboardList;
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-2 flex-wrap">
<Icon className="h-4 w-4 text-primary" />
<CardTitle className="text-base flex-1 min-w-0">
{material.title}
</CardTitle>
<Badge variant="outline" className="text-[10px]">
{t(
`coursePlan.materialType.${material.material_type}`,
material.material_type,
)}
</Badge>
<SkillBadge skill={material.skill} />
<GroundingBadge material={material} />
</div>
{material.summary && (
<CardDescription>{material.summary}</CardDescription>
)}
{material.share_date && (
<CardDescription>
{t("coursePlan.shareDate", "Share date")}: {material.share_date}
</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-3">
{(material.media ?? []).map((m) => (
<ReaderMediaTile key={m.id} media={m} />
))}
<MaterialBookView material={material} mode={mode} />
</CardContent>
</Card>
);
}
function ReaderMediaTile({ media }: { media: CoursePlanMedia }) {
const url = withAuthQuery(media.preview_url || media.download_url || "");
if (!url) return null;
return (
<div className="rounded-md border bg-muted/20 p-2 space-y-2">
<div className="flex items-center gap-2">
{media.kind === "audio" && <Music className="h-4 w-4" />}
{media.kind === "image" && <ImageIcon className="h-4 w-4" />}
{media.kind === "video" && <Video className="h-4 w-4" />}
<span className="capitalize text-xs text-muted-foreground">
{media.kind}
</span>
</div>
{media.kind === "audio" && <audio src={url} controls className="w-full" />}
{media.kind === "image" && (
<img
src={url}
alt={media.title}
className="w-full rounded-md max-h-72 object-contain bg-muted/30"
/>
)}
{media.kind === "video" && (
<video src={url} controls className="w-full rounded-md max-h-72" />
)}
</div>
);
}
/** Pill that surfaces RAG / extraction provenance for one material. */
export function GroundingBadge({ material }: { material: CoursePlanMaterial }) {
const grounded = material.grounded_on ?? [];
const extracted = material.extracted_from ?? null;
if (!grounded.length && !extracted) return null;
const total =
grounded.reduce((acc, g) => acc + (g.chunks_used || 0), 0) +
(extracted ? 1 : 0);
return (
<HoverCard openDelay={150}>
<HoverCardTrigger asChild>
<Badge variant="secondary" className="cursor-help">
<Link2 className="mr-1 h-3 w-3" />
Grounded on {total} reference{total === 1 ? "" : "s"}
</Badge>
</HoverCardTrigger>
<HoverCardContent className="w-72 text-xs">
{grounded.length > 0 && (
<div className="space-y-1">
<div className="font-medium text-sm">RAG sources</div>
<ul className="space-y-1">
{grounded.map((g) => (
<li key={g.source_id} className="flex justify-between gap-2">
<span className="truncate">{g.title || `Source #${g.source_id}`}</span>
<span className="text-muted-foreground">{g.chunks_used} chunk(s)</span>
</li>
))}
</ul>
</div>
)}
{extracted && (
<div className={grounded.length ? "mt-3 border-t pt-2" : ""}>
<div className="font-medium text-sm">Extracted from</div>
<div className="text-muted-foreground">
{extracted.source_title || `Source #${extracted.source_id}`}
{extracted.page_hint ? ` · ${extracted.page_hint}` : ""}
</div>
{extracted.topic_keywords && extracted.topic_keywords.length > 0 && (
<div className="mt-1 text-muted-foreground">
Topics: {extracted.topic_keywords.slice(0, 5).join(", ")}
</div>
)}
</div>
)}
</HoverCardContent>
</HoverCard>
);
}