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
391 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|