2 Commits

Author SHA1 Message Date
Yamen Ahmad
c2a07a6e5c feat(course-plan): RAG sources + multi-modal media + assignments + student view
Builds the §24 product on top of the LangGraph runtime from §22:

Phase A (Sources / RAG)
  - encoach.course.plan.source model (file | url | text)
  - SourceIndexer extracts PDF (pypdf), DOCX (python-docx), HTML, plain
    text and embeds chunks via the existing pgvector pipeline scoped to
    plan_id, so resources.search only returns the plan's own corpus
  - Endpoints: list/create/upload/reindex/delete + plan-scoped retrieval

Phase B (Deliverables)
  - services.deliverables.compute_deliverables walks the plan, derives
    {planned, generated, ready} per week from material + media state
  - GET /api/ai/course-plan/<id>/deliverables drives the new wizard
    preview step and the live progress strip on the detail page

Phase C (Multi-modal media)
  - encoach.course.plan.media model + MediaService:
    audio: AWS Polly (default) or ElevenLabs
    image: OpenAI DALL-E 3, capped per plan via system parameter
    video: local ffmpeg subprocess (image + audio -> MP4 1280x720)
  - Three new agent tools (media.synthesize_audio / generate_image /
    compose_video), wired into course_week_materials and a new
    course_media_director agent
  - Endpoints per material + week-level batch generator

Phase D (Assignments)
  - encoach.course.plan.assignment supports mode='batch' (op.batch) or
    mode='students' (res.users), with due_date + message + state
  - REST endpoints to list / create / delete assignments

Phase E (Student view)
  - /api/student/course-plans + /api/student/course-plans/<id>
    enforce visibility via assignment.expand_user_ids()
  - New /student/course-plans list + read-only drilldown rendering
    audio/image/video tiles from /web/content/<attachment_id>

Cross-cutting
  - encoach.ai.tool.category: + media (so the new tools register)
  - encoach.embedding gains a plan_id filter for plan-scoped RAG
  - Wizard adds Sources + Multimedia steps; AdminCoursePlanDetail
    rewritten with DeliverablesStrip + SourcesCard + AssignmentsCard +
    per-material MediaDrawer
  - ~280 new EN + AR i18n keys (full RTL coverage)
  - smoke_course_plan.py exercises every phase via odoo-bin shell;
    last run: PASS A/B/D/E + DALL-E 3 image (753 KB), Polly audio
    fails cleanly when AWS creds aren't configured (expected)

Documentation: §24 added to docs/PROJECT_SUMMARY.md with phase-by-phase
artefact list, endpoints, smoke test, ops notes, and gotchas.

Made-with: Cursor
2026-04-25 17:13:01 +04:00
Yamen Ahmad
01a32e7785 ci(frontend): add Gitea staging deploy workflow under frontend/ (monorepo mirror of encoach_frontend New_v2 main)
Made-with: Cursor
2026-04-25 11:55:20 +04:00
10 changed files with 2522 additions and 65 deletions

View File

@@ -133,6 +133,8 @@ const StudentDiscussionBoard = lazy(() => import("@/pages/student/StudentDiscuss
const StudentAnnouncements = lazy(() => import("@/pages/student/StudentAnnouncements")); const StudentAnnouncements = lazy(() => import("@/pages/student/StudentAnnouncements"));
const StudentMessages = lazy(() => import("@/pages/student/StudentMessages")); const StudentMessages = lazy(() => import("@/pages/student/StudentMessages"));
const StudentJourney = lazy(() => import("@/pages/student/StudentJourney")); const StudentJourney = lazy(() => import("@/pages/student/StudentJourney"));
const StudentCoursePlans = lazy(() => import("@/pages/student/StudentCoursePlans"));
const StudentCoursePlanDetail = lazy(() => import("@/pages/student/StudentCoursePlanDetail"));
const AiEnglishCourse = lazy(() => import("@/pages/student/AiEnglishCourse")); const AiEnglishCourse = lazy(() => import("@/pages/student/AiEnglishCourse"));
const AiIeltsCourse = lazy(() => import("@/pages/student/AiIeltsCourse")); const AiIeltsCourse = lazy(() => import("@/pages/student/AiIeltsCourse"));
const ExamSession = lazy(() => import("@/pages/student/ExamSession")); const ExamSession = lazy(() => import("@/pages/student/ExamSession"));
@@ -269,6 +271,8 @@ const App = () => (
<Route path="/student/placement/access" element={<PlacementAccess />} /> <Route path="/student/placement/access" element={<PlacementAccess />} />
<Route path="/student/exam/:examId/results" element={<ExamResults />} /> <Route path="/student/exam/:examId/results" element={<ExamResults />} />
<Route path="/student/course/generate" element={<GapAnalysis />} /> <Route path="/student/course/generate" element={<GapAnalysis />} />
<Route path="/student/course-plans" element={<StudentCoursePlans />} />
<Route path="/student/course-plans/:planId" element={<StudentCoursePlanDetail />} />
<Route path="/student/course/ai-english/:courseId" element={<AiEnglishCourse />} /> <Route path="/student/course/ai-english/:courseId" element={<AiEnglishCourse />} />
<Route path="/student/course/ai-ielts/:courseId" element={<AiIeltsCourse />} /> <Route path="/student/course/ai-ielts/:courseId" element={<AiIeltsCourse />} />
<Route path="/student/course/:courseId" element={<CourseDelivery />} /> <Route path="/student/course/:courseId" element={<CourseDelivery />} />

View File

@@ -3,7 +3,7 @@ import ExamPopup from "./student/ExamPopup";
import { import {
LayoutDashboard, BookOpen, ClipboardList, BarChart3, LayoutDashboard, BookOpen, ClipboardList, BarChart3,
CalendarCheck, Calendar, User, Target, ListChecks, CalendarCheck, Calendar, User, Target, ListChecks,
MessageSquare, Megaphone, Mail, TrendingUp, MessageSquare, Megaphone, Mail, TrendingUp, Sparkles,
} from "lucide-react"; } from "lucide-react";
/** /**
@@ -17,6 +17,7 @@ const navGroups: NavGroup[] = [
items: [ items: [
{ titleKey: "nav.dashboard", url: "/student/dashboard", icon: LayoutDashboard }, { titleKey: "nav.dashboard", url: "/student/dashboard", icon: LayoutDashboard },
{ titleKey: "nav.myCourses", url: "/student/courses", icon: BookOpen }, { titleKey: "nav.myCourses", url: "/student/courses", icon: BookOpen },
{ titleKey: "nav.myCoursePlans", url: "/student/course-plans", icon: Sparkles },
{ titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks }, { titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks },
{ titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList }, { titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList },
], ],

View File

@@ -60,6 +60,7 @@ const ar: Translations = {
dashboard: "لوحة التحكم", dashboard: "لوحة التحكم",
courses: "الدورات", courses: "الدورات",
myCourses: "دوراتي", myCourses: "دوراتي",
myCoursePlans: "خططي الدراسية",
students: "الطلاب", students: "الطلاب",
teachers: "المعلمون", teachers: "المعلمون",
batches: "الدفعات", batches: "الدفعات",
@@ -739,8 +740,14 @@ const ar: Translations = {
basicsDesc: "سمِّ المقرر وحدّد المستوى والمدة وعدد الساعات الأسبوعية.", basicsDesc: "سمِّ المقرر وحدّد المستوى والمدة وعدد الساعات الأسبوعية.",
coverage: "التغطية", coverage: "التغطية",
coverageDesc: "أخبر الذكاء الاصطناعي بتوزيع الساعات على المهارات ومن هم المتعلّمون.", coverageDesc: "أخبر الذكاء الاصطناعي بتوزيع الساعات على المهارات ومن هم المتعلّمون.",
sources: "المصادر المرجعية",
sourcesDesc:
"ارفع ملفات PDF أو روابط أو نصوص. يستخدمها الذكاء الاصطناعي كمرجع لتوليد مواد كل أسبوع.",
scope: "النطاق", scope: "النطاق",
scopeDesc: "اختياري: تركيز القواعد، والمصادر، وملاحظات حرّة.", scopeDesc: "اختياري: تركيز القواعد، والمصادر، وملاحظات حرّة.",
media: "الوسائط",
mediaDesc:
"اختر أي وسائط يولّدها الذكاء الاصطناعي تلقائياً مع المواد النصّية.",
review: "المراجعة", review: "المراجعة",
reviewDesc: "راجع البيانات قبل بدء التوليد.", reviewDesc: "راجع البيانات قبل بدء التوليد.",
}, },
@@ -767,7 +774,155 @@ const ar: Translations = {
cefrRequired: "يرجى اختيار مستوى CEFR.", cefrRequired: "يرجى اختيار مستوى CEFR.",
weeksRange: "عدد الأسابيع يجب أن يكون 1 على الأقل.", weeksRange: "عدد الأسابيع يجب أن يكون 1 على الأقل.",
}, },
review: {
sourcesCount_one: "{{count}} مصدر",
sourcesCount_other: "{{count}} مصادر",
noSources: "لا توجد مصادر",
mediaNone: "لا يوجد توليد وسائط",
},
}, },
sections_extras: {
sources: "المصادر المرجعية",
progress: "تقدّم التوليد",
assignments: "الإسنادات",
media: "الوسائط المُولَّدة",
},
sources: {
sectionTitle: "المصادر المرجعية (RAG)",
sectionDesc:
"ارفع PDF أو أضف روابط أو نصوصاً. يستخدمها الذكاء الاصطناعي كمرجع عند توليد المواد الأسبوعية.",
collected_one: "{{count}} مصدر جاهز",
collected_other: "{{count}} مصادر جاهزة",
empty: "لا توجد مصادر مرجعية بعد.",
dropTitle: "أضف مواد مرجعية",
dropHint: "PDF أو DOCX أو TXT أو HTML أو نص — حتى بضعة ميغابايت لكل ملف.",
uploadFiles: "رفع ملفات",
urlLabel: "إضافة رابط",
textLabel: "أو ألصق نصاً مباشرة",
textPlaceholder: "ألصق فقرة لتوجيه الذكاء الاصطناعي…",
uploadFailed: "تعذّر رفع \"{{name}}\".",
reindex: "إعادة فهرسة",
reindexed: "أُعيدت فهرسة المصدر.",
reindexFailed: "فشلت إعادة الفهرسة.",
deleted: "تم حذف المصدر.",
deleteFailed: "تعذّر حذف المصدر.",
status: {
pending: "في الانتظار",
indexing: "يُفهرس…",
indexed: "مُفهرس",
failed: "فشل",
},
kindLabel: {
file: "ملف",
url: "رابط",
text: "نص",
},
chunks_one: "{{count}} جزء",
chunks_other: "{{count}} أجزاء",
},
sourceKind: {
file: "ملف",
url: "رابط",
text: "نص",
},
deliverables: {
title: "تقدّم التوليد",
subtitle:
"ما الذي سيُنتجه الذكاء الاصطناعي، وما تم إنتاجه، وما اكتمل تماماً (مواد + وسائط).",
summary: {
planned_one: "{{count}} مخطّط",
planned_other: "{{count}} مخطّط",
generated_one: "{{count}} مُولَّد",
generated_other: "{{count}} مُولَّد",
ready_one: "{{count}} جاهز",
ready_other: "{{count}} جاهز",
},
mediaCounts: "صوت {{audio}} · صورة {{image}} · فيديو {{video}}",
status: {
planned: "مخطّط",
generated: "مُولَّد",
ready: "جاهز",
},
perWeek: "تفصيل لكل أسبوع",
noWeeks: "لا توجد أسابيع — ولّد الخطة أولاً.",
},
media: {
audio: "صوت",
image: "صورة",
video: "فيديو",
audioTitle: "تعليق صوتي",
audioDesc:
"توليد صوت منطوق (Polly / ElevenLabs) لنصوص الاستماع ومحفّزات المحادثة.",
imageTitle: "صور تغطية",
imageDesc:
"توليد صور DALL·E 3 لنصوص القراءة والاستماع. يحتسب من ميزانية الصور للخطة.",
videoTitle: "فيديو شرائح",
videoDesc: "دمج الصوت + الصورة في فيديو MP4 قصير عبر ffmpeg. أبطأ الخيارات.",
hint:
"الصوت مفعّل افتراضياً. الفيديو معطّل افتراضياً — فعّله بعد مراجعة الصوت والصورة.",
drawerTitle: "وسائط \"{{title}}\"",
drawerSubtitle: "ولّد أو احذف الصوت والصورة والفيديو.",
generateAudio: "توليد صوت",
generateImage: "توليد صورة",
generateVideo: "توليد فيديو",
generating: "جاري التوليد…",
audioReady: "الصوت جاهز",
imageReady: "الصورة جاهزة",
videoReady: "الفيديو جاهز",
audioFailed: "فشل توليد الصوت.",
imageFailed: "فشل توليد الصورة.",
videoFailed: "فشل توليد الفيديو.",
deleted: "تم حذف الوسيط.",
deleteFailed: "تعذّر حذف الوسيط.",
open: "فتح",
download: "تنزيل",
noMedia: "لا توجد وسائط لهذه المادة بعد.",
mediaForMaterial: "وسائط",
bulk: "توليد وسائط الأسبوع",
bulkSuccess: "اكتمل توليد وسائط الأسبوع.",
bulkFailed: "تعذّر توليد وسائط الأسبوع.",
provider: "المزوّد",
},
assignments: {
title: "مُسندة إلى",
empty: "لم تُسند إلى أحد بعد.",
assign: "إسناد",
assignTitle: "إسناد خطة المقرر",
assignSubtitle:
"اختر شعبة (دفعة) للجميع، أو طلاباً محدّدين. ستظهر الخطة في لوحة الطالب.",
modeBatch: "دفعة كاملة",
modeStudents: "طلاب محدّدون",
pickBatch: "اختر دفعة",
pickStudents: "اختر طلاباً",
noBatches: "لا توجد دفعات.",
noStudents: "لا يوجد طلاب.",
dueDate: "تاريخ التسليم (اختياري)",
message: "رسالة للمتعلّمين (اختياري)",
messagePlaceholder: "ترحيب، توقّعات، مواعيد…",
created: "تم إسناد الخطة.",
createFailed: "تعذّر إسناد الخطة.",
removed: "تمت إزالة الإسناد.",
removeFailed: "تعذّر إزالة الإسناد.",
remove: "إزالة",
confirmRemove: "إزالة هذا الإسناد؟",
assignedBy: "أسندها {{name}}",
students_one: "{{count}} طالب",
students_other: "{{count}} طلاب",
cancel: "إلغاء",
save: "إسناد",
},
student: {
listTitle: "خططي الدراسية",
listSubtitle:
"خطط مناهج مخصّصة يُنشئها الذكاء الاصطناعي ويُسندها معلّمك. افتح خطة لرؤية الأسابيع والمواد والوسائط.",
empty: "لم تُسند إليك خطط بعد.",
open: "فتح المقرر",
assignedBy: "أسندها {{name}}",
due: "موعد التسليم {{date}}",
noDue: "بدون موعد",
backToList: "العودة إلى خططي",
},
wizardSourcesStep: "المصادر",
}, },
aiAdmin: { aiAdmin: {
title: "وكلاء الذكاء الاصطناعي والأدوات", title: "وكلاء الذكاء الاصطناعي والأدوات",

View File

@@ -105,6 +105,7 @@ const en: Translations = {
dashboard: "Dashboard", dashboard: "Dashboard",
courses: "Courses", courses: "Courses",
myCourses: "My Courses", myCourses: "My Courses",
myCoursePlans: "My Course Plans",
students: "Students", students: "Students",
teachers: "Teachers", teachers: "Teachers",
batches: "Batches", batches: "Batches",
@@ -796,8 +797,14 @@ const en: Translations = {
basicsDesc: "Name the course and set level, duration, and weekly hours.", basicsDesc: "Name the course and set level, duration, and weekly hours.",
coverage: "Coverage", coverage: "Coverage",
coverageDesc: "Tell the AI how hours split across skills and who the learners are.", coverageDesc: "Tell the AI how hours split across skills and who the learners are.",
sources: "Reference sources",
sourcesDesc:
"Drop PDFs, URLs, or pasted text. The AI uses them as grounding when generating each week's materials.",
scope: "Scope", scope: "Scope",
scopeDesc: "Optional: grammar focus, resources to reference, free-form notes.", scopeDesc: "Optional: grammar focus, resources to reference, free-form notes.",
media: "Multimedia",
mediaDesc:
"Pick which media the AI should auto-produce alongside the text materials.",
review: "Review", review: "Review",
reviewDesc: "Double-check your brief before kicking off the generation.", reviewDesc: "Double-check your brief before kicking off the generation.",
}, },
@@ -825,7 +832,156 @@ const en: Translations = {
cefrRequired: "Please pick a CEFR level.", cefrRequired: "Please pick a CEFR level.",
weeksRange: "Total weeks must be at least 1.", weeksRange: "Total weeks must be at least 1.",
}, },
review: {
sourcesCount_one: "{{count}} reference source",
sourcesCount_other: "{{count}} reference sources",
noSources: "No reference sources",
mediaNone: "No media generation",
},
}, },
sections_extras: {
sources: "Reference sources",
progress: "Generation progress",
assignments: "Assignments",
media: "Generated media",
},
sources: {
sectionTitle: "Reference sources (RAG)",
sectionDesc:
"Upload PDFs, paste URLs, or drop in raw text. The AI will use the indexed content as grounding when it writes weekly materials.",
collected_one: "{{count}} source ready",
collected_other: "{{count}} sources ready",
empty: "No reference sources yet.",
dropTitle: "Add reference materials",
dropHint: "PDF, DOCX, TXT, HTML, or plain text — up to a few MB each.",
uploadFiles: "Upload files",
urlLabel: "Add a URL",
textLabel: "Or paste text directly",
textPlaceholder: "Paste a passage to ground the AI on…",
uploadFailed: "Couldn't upload \"{{name}}\".",
reindex: "Reindex",
reindexed: "Source reindexed.",
reindexFailed: "Reindex failed.",
deleted: "Source deleted.",
deleteFailed: "Couldn't delete source.",
status: {
pending: "Pending",
indexing: "Indexing…",
indexed: "Indexed",
failed: "Failed",
},
kindLabel: {
file: "File",
url: "URL",
text: "Text",
},
chunks_one: "{{count}} chunk",
chunks_other: "{{count}} chunks",
},
sourceKind: {
file: "File",
url: "URL",
text: "Inline text",
},
deliverables: {
title: "Generation progress",
subtitle:
"What the AI is going to produce, what it has produced, and what is fully ready (materials + media).",
summary: {
planned_one: "{{count}} planned",
planned_other: "{{count}} planned",
generated_one: "{{count}} generated",
generated_other: "{{count}} generated",
ready_one: "{{count}} ready",
ready_other: "{{count}} ready",
},
mediaCounts: "Audio {{audio}} · Image {{image}} · Video {{video}}",
status: {
planned: "Planned",
generated: "Generated",
ready: "Ready",
},
perWeek: "Per-week breakdown",
noWeeks: "No weeks yet — generate the plan first.",
},
media: {
audio: "Audio",
image: "Image",
video: "Video",
audioTitle: "Narration audio",
audioDesc:
"Generate spoken audio (Polly / ElevenLabs) for listening scripts and speaking prompts.",
imageTitle: "Cover images",
imageDesc:
"Generate DALL·E 3 images for reading texts and listening scripts. Counts against the per-plan image budget.",
videoTitle: "Slideshow video",
videoDesc:
"Combine audio + image into a short MP4 with ffmpeg. Slowest option.",
hint:
"Audio is on by default. Video is off by default — turn it on once you've reviewed the audio + image.",
drawerTitle: "Media for \"{{title}}\"",
drawerSubtitle: "Generate or remove audio, images, and slideshow video.",
generateAudio: "Generate audio",
generateImage: "Generate image",
generateVideo: "Generate video",
generating: "Generating…",
audioReady: "Audio ready",
imageReady: "Image ready",
videoReady: "Video ready",
audioFailed: "Audio generation failed.",
imageFailed: "Image generation failed.",
videoFailed: "Video generation failed.",
deleted: "Media deleted.",
deleteFailed: "Couldn't delete media.",
open: "Open",
download: "Download",
noMedia: "No media yet for this material.",
mediaForMaterial: "Media",
bulk: "Generate week media",
bulkSuccess: "Week media generation done.",
bulkFailed: "Couldn't generate week media.",
provider: "Provider",
},
assignments: {
title: "Assigned to",
empty: "Not assigned to anyone yet.",
assign: "Assign",
assignTitle: "Assign this course plan",
assignSubtitle:
"Pick a class (batch) for everyone, or individual students. They'll see the plan in their student dashboard.",
modeBatch: "Whole class (batch)",
modeStudents: "Individual students",
pickBatch: "Select a batch",
pickStudents: "Pick students",
noBatches: "No batches available.",
noStudents: "No students available.",
dueDate: "Due date (optional)",
message: "Message to learners (optional)",
messagePlaceholder: "Welcome note, expectations, deadlines…",
created: "Plan assigned.",
createFailed: "Couldn't assign plan.",
removed: "Assignment removed.",
removeFailed: "Couldn't remove assignment.",
remove: "Remove",
confirmRemove: "Remove this assignment?",
assignedBy: "Assigned by {{name}}",
students_one: "{{count}} student",
students_other: "{{count}} students",
cancel: "Cancel",
save: "Assign",
},
student: {
listTitle: "My course plans",
listSubtitle:
"Personalised AI-generated curricula assigned by your teacher. Open one to see the weekly plan, materials, and media.",
empty: "No plans assigned to you yet.",
open: "Open course",
assignedBy: "Assigned by {{name}}",
due: "Due {{date}}",
noDue: "No deadline",
backToList: "Back to my plans",
},
wizardSourcesStep: "Sources",
}, },
aiAdmin: { aiAdmin: {
title: "AI Agents & Tools", title: "AI Agents & Tools",

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,19 @@
import { useState } from "react"; import { useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { X } from "lucide-react"; import {
FileText,
Image as ImageIcon,
Link2,
Mic,
Music,
Trash2,
Upload,
Video,
X,
} from "lucide-react";
import { StepWizard, type WizardStepDef } from "@/components/wizard/StepWizard"; import { StepWizard, type WizardStepDef } from "@/components/wizard/StepWizard";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -38,6 +48,33 @@ import type { CoursePlanGenerateBrief } from "@/types";
* materials immediately. * materials immediately.
*/ */
type DraftSourceKind = "file" | "url" | "text";
interface DraftSource {
/** Stable client-side id; not persisted. */
uid: string;
kind: DraftSourceKind;
name: string;
/** Only set when kind === "file". */
file?: File;
/** Only set when kind === "url". */
url?: string;
/** Only set when kind === "text". */
text?: string;
}
interface MediaToggleState {
/** Generate narration audio (Polly / ElevenLabs) for listening +
* speaking materials right after each week's materials are produced. */
audio: boolean;
/** Generate DALL-E images for reading texts, listening scripts, and
* vocabulary lists. Bound to the per-plan image budget. */
image: boolean;
/** Compose audio + image into a slideshow MP4 with ffmpeg. Slowest
* option; off by default. */
video: boolean;
}
interface CoursePlanWizardState { interface CoursePlanWizardState {
title: string; title: string;
cefr_level: string; cefr_level: string;
@@ -48,6 +85,8 @@ interface CoursePlanWizardState {
grammar_focus: string[]; grammar_focus: string[];
resources: string[]; resources: string[];
notes: string; notes: string;
sources: DraftSource[];
media: MediaToggleState;
} }
const CEFR_OPTIONS = [ const CEFR_OPTIONS = [
@@ -183,6 +222,17 @@ export default function CoursePlanWizard() {
</div> </div>
), ),
}, },
{
id: "sources",
titleKey: "coursePlan.wizard.steps.sources",
descriptionKey: "coursePlan.wizard.steps.sourcesDesc",
render: ({ state, update }) => (
<SourcesStep
sources={state.sources}
onChange={(sources) => update({ sources })}
/>
),
},
{ {
id: "scope", id: "scope",
titleKey: "coursePlan.wizard.steps.scope", titleKey: "coursePlan.wizard.steps.scope",
@@ -214,6 +264,17 @@ export default function CoursePlanWizard() {
</div> </div>
), ),
}, },
{
id: "media",
titleKey: "coursePlan.wizard.steps.media",
descriptionKey: "coursePlan.wizard.steps.mediaDesc",
render: ({ state, update }) => (
<MediaStep
media={state.media}
onChange={(media) => update({ media })}
/>
),
},
{ {
id: "review", id: "review",
titleKey: "coursePlan.wizard.steps.review", titleKey: "coursePlan.wizard.steps.review",
@@ -257,6 +318,26 @@ export default function CoursePlanWizard() {
label={t("coursePlan.wizard.fields.notes")} label={t("coursePlan.wizard.fields.notes")}
value={state.notes || "—"} value={state.notes || "—"}
/> />
<ReviewRow
label={t("coursePlan.wizard.steps.sources")}
value={
state.sources.length
? t("coursePlan.wizard.review.sourcesCount", {
count: state.sources.length,
})
: t("coursePlan.wizard.review.noSources")
}
/>
<ReviewRow
label={t("coursePlan.wizard.steps.media")}
value={[
state.media.audio ? t("coursePlan.media.audio") : null,
state.media.image ? t("coursePlan.media.image") : null,
state.media.video ? t("coursePlan.media.video") : null,
]
.filter(Boolean)
.join(", ") || t("coursePlan.wizard.review.mediaNone")}
/>
<div className="rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground"> <div className="rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
{t("coursePlan.wizard.reviewHint")} {t("coursePlan.wizard.reviewHint")}
</div> </div>
@@ -283,9 +364,11 @@ export default function CoursePlanWizard() {
grammar_focus: [], grammar_focus: [],
resources: [], resources: [],
notes: "", notes: "",
sources: [],
media: { audio: true, image: true, video: false },
}} }}
onFinish={async (state) => { onFinish={async (state) => {
await mutation.mutateAsync({ const resp = await mutation.mutateAsync({
title: state.title.trim(), title: state.title.trim(),
cefr_level: state.cefr_level, cefr_level: state.cefr_level,
total_weeks: state.total_weeks, total_weeks: state.total_weeks,
@@ -298,11 +381,284 @@ export default function CoursePlanWizard() {
resources: state.resources.length ? state.resources : undefined, resources: state.resources.length ? state.resources : undefined,
notes: state.notes.trim() || undefined, notes: state.notes.trim() || undefined,
}); });
const planId = resp?.data?.id;
if (planId && state.sources.length) {
// Best-effort upload — we keep going even if a single one fails so
// the user lands on the detail page with whatever did make it in.
for (const src of state.sources) {
try {
if (src.kind === "file" && src.file) {
await coursePlanService.uploadSource(planId, src.file, {
name: src.name || src.file.name,
});
} else if (src.kind === "url" && src.url) {
await coursePlanService.createSource(planId, {
kind: "url",
url: src.url,
name: src.name || src.url,
});
} else if (src.kind === "text" && src.text) {
await coursePlanService.createSource(planId, {
kind: "text",
inline_text: src.text,
name: src.name || t("coursePlan.sourceKind.text"),
});
}
} catch (err) {
toast.error(
describeApiError(err, t("coursePlan.sources.uploadFailed", {
name: src.name || src.kind,
})),
);
}
}
}
}} }}
/> />
); );
} }
function genUid() {
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
}
function SourcesStep({
sources,
onChange,
}: {
sources: DraftSource[];
onChange: (next: DraftSource[]) => void;
}) {
const { t } = useTranslation();
const fileRef = useRef<HTMLInputElement>(null);
const [draftUrl, setDraftUrl] = useState("");
const [draftText, setDraftText] = useState("");
const addFiles = (files: FileList | null) => {
if (!files || !files.length) return;
const additions: DraftSource[] = [];
for (const f of Array.from(files)) {
additions.push({
uid: genUid(),
kind: "file",
name: f.name,
file: f,
});
}
onChange([...sources, ...additions]);
};
const addUrl = () => {
const url = draftUrl.trim();
if (!url) return;
onChange([...sources, { uid: genUid(), kind: "url", name: url, url }]);
setDraftUrl("");
};
const addText = () => {
const text = draftText.trim();
if (!text) return;
onChange([
...sources,
{
uid: genUid(),
kind: "text",
name: text.slice(0, 60).replace(/\s+/g, " "),
text,
},
]);
setDraftText("");
};
const remove = (uid: string) => onChange(sources.filter((s) => s.uid !== uid));
return (
<div className="space-y-5">
<div className="rounded-md border-dashed border-2 px-4 py-6 text-center bg-muted/20">
<FileText className="mx-auto h-6 w-6 text-muted-foreground" />
<p className="mt-2 text-sm font-medium">
{t("coursePlan.sources.dropTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("coursePlan.sources.dropHint")}
</p>
<div className="mt-3">
<input
ref={fileRef}
type="file"
multiple
className="hidden"
accept=".pdf,.doc,.docx,.txt,.md,.html,.htm,.rtf"
onChange={(e) => {
addFiles(e.currentTarget.files);
if (fileRef.current) fileRef.current.value = "";
}}
/>
<Button
variant="outline"
size="sm"
onClick={() => fileRef.current?.click()}
>
<Upload className="mr-1 h-4 w-4" />
{t("coursePlan.sources.uploadFiles")}
</Button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label>{t("coursePlan.sources.urlLabel")}</Label>
<div className="mt-1 flex gap-2">
<Input
value={draftUrl}
onChange={(e) => setDraftUrl(e.target.value)}
placeholder="https://example.com/article"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addUrl();
}
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={addUrl}
disabled={!draftUrl.trim()}
>
<Link2 className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<Label>{t("coursePlan.sources.textLabel")}</Label>
<div className="mt-1 flex gap-2">
<Textarea
rows={3}
value={draftText}
onChange={(e) => setDraftText(e.target.value)}
placeholder={t("coursePlan.sources.textPlaceholder")}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={addText}
disabled={!draftText.trim()}
className="self-start"
>
<FileText className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{sources.length > 0 && (
<div className="rounded-md border">
<div className="px-3 py-2 border-b bg-muted/30 text-xs uppercase tracking-wide text-muted-foreground">
{t("coursePlan.sources.collected", { count: sources.length })}
</div>
<ul className="divide-y">
{sources.map((s) => (
<li
key={s.uid}
className="flex items-center gap-2 px-3 py-2 text-sm"
>
<Badge variant="outline" className="capitalize text-[10px]">
{s.kind}
</Badge>
<span className="flex-1 truncate">{s.name}</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => remove(s.uid)}
aria-label={t("common.remove", "Remove")}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
</div>
)}
</div>
);
}
function MediaStep({
media,
onChange,
}: {
media: MediaToggleState;
onChange: (next: MediaToggleState) => void;
}) {
const { t } = useTranslation();
return (
<div className="grid gap-3 sm:grid-cols-3">
<MediaToggleCard
icon={Music}
title={t("coursePlan.media.audioTitle")}
description={t("coursePlan.media.audioDesc")}
checked={media.audio}
onToggle={(v) => onChange({ ...media, audio: v })}
/>
<MediaToggleCard
icon={ImageIcon}
title={t("coursePlan.media.imageTitle")}
description={t("coursePlan.media.imageDesc")}
checked={media.image}
onToggle={(v) => onChange({ ...media, image: v })}
/>
<MediaToggleCard
icon={Video}
title={t("coursePlan.media.videoTitle")}
description={t("coursePlan.media.videoDesc")}
checked={media.video}
onToggle={(v) => onChange({ ...media, video: v })}
/>
<div className="sm:col-span-3 text-xs text-muted-foreground flex items-center gap-2">
<Mic className="h-3.5 w-3.5" />
{t("coursePlan.media.hint")}
</div>
</div>
);
}
function MediaToggleCard({
icon: Icon,
title,
description,
checked,
onToggle,
}: {
icon: typeof Music;
title: string;
description: string;
checked: boolean;
onToggle: (v: boolean) => void;
}) {
return (
<button
type="button"
onClick={() => onToggle(!checked)}
className={[
"rounded-md border p-3 text-left transition-colors",
checked
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/40",
].join(" ")}
>
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-primary" />
<div className="font-medium text-sm">{title}</div>
</div>
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
</button>
);
}
function ReviewRow({ label, value }: { label: string; value: string }) { function ReviewRow({ label, value }: { label: string; value: string }) {
return ( return (
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">

View File

@@ -0,0 +1,311 @@
import { useMemo } from "react";
import { Link, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
BookOpen,
Calendar,
ClipboardList,
Headphones,
Image as ImageIcon,
Library,
MessageSquare,
Mic,
Music,
PenSquare,
Sparkles,
Type,
Video,
type LucideIcon,
} from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { coursePlanService } from "@/services/coursePlan.service";
import { describeApiError } from "@/lib/api-client";
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,
};
/**
* Student detail view for an assigned AI course plan.
*
* Server-side authorisation: the `/api/student/course-plans/:id` endpoint
* returns 403 unless the current user is in the plan's assignment list,
* so we can render unconditionally as soon as the query resolves.
*
* The page is intentionally read-only — students can play audio, view
* images, and watch video, but they can't (re)generate content.
*/
export default function StudentCoursePlanDetail() {
const { t } = useTranslation();
const { planId: planIdRaw } = useParams<{ planId: string }>();
const planId = Number(planIdRaw);
const { data, isLoading, isError, error } = useQuery({
queryKey: ["student-course-plan", planId],
queryFn: () => coursePlanService.studentGet(planId),
enabled: Number.isFinite(planId) && planId > 0,
});
const plan = data?.data as CoursePlan | undefined;
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">
<Button variant="ghost" size="sm" asChild className="-ml-2 text-muted-foreground">
<Link to="/student/course-plans" className="flex items-center gap-1">
<ArrowLeft className="h-4 w-4" />
{t("coursePlan.student.backToList")}
</Link>
</Button>
{isLoading && (
<div className="space-y-3">
<Skeleton className="h-20" />
<Skeleton className="h-40" />
<Skeleton className="h-40" />
</div>
)}
{isError && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{describeApiError(error, t("coursePlan.loadFailed"))}
</div>
)}
{plan && (
<>
<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.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) => (
<StudentWeek
key={w.id}
week={w}
materials={materialsByWeek.get(w.week_number) ?? []}
/>
))}
</Accordion>
</CardContent>
</Card>
)}
</>
)}
</div>
);
}
function StudentWeek({
week,
materials,
}: {
week: CoursePlanWeek;
materials: CoursePlanMaterial[];
}) {
const { t } = useTranslation();
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">
{materials.length === 0 && (
<p className="text-sm italic text-muted-foreground">
{t("coursePlan.media.noMedia")}
</p>
)}
{materials.map((m) => (
<StudentMaterial key={m.id} material={m} />
))}
</AccordionContent>
</AccordionItem>
);
}
function StudentMaterial({ material }: { material: CoursePlanMaterial }) {
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>
</div>
{material.summary && <CardDescription>{material.summary}</CardDescription>}
</CardHeader>
<CardContent className="space-y-3">
{(material.media ?? []).map((m) => (
<StudentMediaTile key={m.id} media={m} />
))}
<pre className="whitespace-pre-wrap text-xs font-mono bg-muted/40 rounded-md p-3 max-h-80 overflow-auto">
{material.body_text || JSON.stringify(material.body, null, 2)}
</pre>
</CardContent>
</Card>
);
}
function StudentMediaTile({ media }: { media: CoursePlanMedia }) {
const 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>
);
}

View File

@@ -0,0 +1,147 @@
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
ArrowRight,
Calendar,
GraduationCap,
Sparkles,
} from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { coursePlanService } from "@/services/coursePlan.service";
import { describeApiError } from "@/lib/api-client";
import type { CoursePlan } from "@/types";
/**
* Student-facing list of AI course plans assigned to the current user.
*
* Reads from `GET /api/student/course-plans`, which only returns plans
* the user has been linked to either directly (Phase D `students` mode)
* or via the batch they're enrolled in. The card itself is intentionally
* lightweight: tap → drill into `/student/course-plans/:id` for the full
* weekly plan and embedded media.
*/
export default function StudentCoursePlans() {
const { t } = useTranslation();
const { data, isLoading, isError, error } = useQuery({
queryKey: ["student-course-plans"],
queryFn: () => coursePlanService.studentList(),
});
const items = data?.items ?? [];
return (
<div className="space-y-6">
<div>
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
<h1 className="text-2xl font-bold">{t("coursePlan.student.listTitle")}</h1>
</div>
<p className="text-muted-foreground mt-1 max-w-2xl">
{t("coursePlan.student.listSubtitle")}
</p>
</div>
{isLoading && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-44 rounded-lg" />
))}
</div>
)}
{isError && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{describeApiError(error, t("coursePlan.loadFailed"))}
</div>
)}
{!isLoading && !isError && items.length === 0 && (
<div className="text-center py-12 border rounded-lg bg-muted/20">
<GraduationCap className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p className="text-muted-foreground">
{t("coursePlan.student.empty")}
</p>
</div>
)}
{items.length > 0 && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{items.map((plan) => (
<PlanCard key={plan.id} plan={plan} />
))}
</div>
)}
</div>
);
}
function PlanCard({ plan }: { plan: CoursePlan }) {
const { t } = useTranslation();
const a = plan.assignment;
return (
<Card className="hover:shadow-md transition-shadow flex flex-col">
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base flex-1 min-w-0 line-clamp-2">
{plan.name}
</CardTitle>
<Badge variant="secondary" className="uppercase shrink-0">
{plan.cefr_level}
</Badge>
</div>
{plan.description && (
<CardDescription className="line-clamp-2">
{plan.description}
</CardDescription>
)}
</CardHeader>
<CardContent className="flex-1 flex flex-col gap-3">
<div className="flex flex-wrap gap-1.5">
<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>
{a && (
<div className="text-xs text-muted-foreground space-y-0.5">
{a.assigned_by_name && (
<div>
{t("coursePlan.student.assignedBy", { name: a.assigned_by_name })}
</div>
)}
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{a.due_date
? t("coursePlan.student.due", { date: a.due_date })
: t("coursePlan.student.noDue")}
</div>
</div>
)}
<div className="mt-auto pt-2">
<Button asChild size="sm" className="w-full">
<Link to={`/student/course-plans/${plan.id}`}>
{t("coursePlan.student.open")}
<ArrowRight className="ml-1 h-4 w-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,8 +1,13 @@
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import type { import type {
CoursePlan, CoursePlan,
CoursePlanAssignment,
CoursePlanDeliverables,
CoursePlanGenerateBrief, CoursePlanGenerateBrief,
CoursePlanMaterial, CoursePlanMaterial,
CoursePlanMedia,
CoursePlanSource,
CoursePlanSourceKind,
} from "@/types"; } from "@/types";
/** /**
@@ -12,6 +17,9 @@ import type {
* `backend/custom_addons/encoach_ai_course/controllers/course_plan.py`. * `backend/custom_addons/encoach_ai_course/controllers/course_plan.py`.
*/ */
export const coursePlanService = { export const coursePlanService = {
// ---------------------------------------------------------------------
// Plan lifecycle
// ---------------------------------------------------------------------
async list(params?: { async list(params?: {
page?: number; page?: number;
size?: number; size?: number;
@@ -45,4 +53,166 @@ export const coursePlanService = {
): Promise<{ items: CoursePlanMaterial[]; count: number }> { ): Promise<{ items: CoursePlanMaterial[]; count: number }> {
return api.get(`/ai/course-plan/${planId}/weeks/${weekNumber}/materials`); return api.get(`/ai/course-plan/${planId}/weeks/${weekNumber}/materials`);
}, },
// ---------------------------------------------------------------------
// Phase A — Sources
// ---------------------------------------------------------------------
async listSources(planId: number): Promise<{ items: CoursePlanSource[]; count: number }> {
return api.get(`/ai/course-plan/${planId}/sources`);
},
async createSource(
planId: number,
payload:
| { kind: "url"; url: string; name?: string; auto_index?: boolean }
| { kind: "text"; inline_text: string; name?: string; auto_index?: boolean },
): Promise<{ data: CoursePlanSource }> {
return api.post(`/ai/course-plan/${planId}/sources`, payload);
},
async uploadSource(
planId: number,
file: File,
opts?: { name?: string; auto_index?: boolean },
): Promise<{ data: CoursePlanSource }> {
const fd = new FormData();
fd.append("file", file);
fd.append("kind", "file" satisfies CoursePlanSourceKind);
if (opts?.name) fd.append("name", opts.name);
if (opts?.auto_index !== undefined) {
fd.append("auto_index", opts.auto_index ? "1" : "0");
}
return api.upload(`/ai/course-plan/${planId}/sources`, fd);
},
async reindexSource(
planId: number,
sourceId: number,
): Promise<{ data: CoursePlanSource }> {
return api.post(
`/ai/course-plan/${planId}/sources/${sourceId}/index`,
);
},
async deleteSource(
planId: number,
sourceId: number,
): Promise<{ success: boolean }> {
return api.delete(`/ai/course-plan/${planId}/sources/${sourceId}`);
},
// ---------------------------------------------------------------------
// Phase B — Deliverables / progress
// ---------------------------------------------------------------------
async deliverables(planId: number): Promise<CoursePlanDeliverables> {
return api.get(`/ai/course-plan/${planId}/deliverables`);
},
// ---------------------------------------------------------------------
// Phase C — Multimedia
// ---------------------------------------------------------------------
async listMaterialMedia(
materialId: number,
): Promise<{ items: CoursePlanMedia[]; count: number }> {
return api.get(`/ai/course-plan/material/${materialId}/media`);
},
async generateAudio(
materialId: number,
payload?: {
voice?: string;
language?: string;
gender?: "male" | "female";
provider?: "polly" | "elevenlabs";
},
): Promise<{ data: CoursePlanMedia }> {
return api.post(
`/ai/course-plan/material/${materialId}/media/audio`,
payload ?? {},
);
},
async generateImage(
materialId: number,
payload?: {
prompt?: string;
size?: "1024x1024" | "1792x1024" | "1024x1792";
style?: "natural" | "vivid";
quality?: "standard" | "hd";
},
): Promise<{ data: CoursePlanMedia }> {
return api.post(
`/ai/course-plan/material/${materialId}/media/image`,
payload ?? {},
);
},
async composeVideo(materialId: number): Promise<{ data: CoursePlanMedia }> {
return api.post(
`/ai/course-plan/material/${materialId}/media/video`,
);
},
async deleteMedia(mediaId: number): Promise<{ success: boolean }> {
return api.delete(`/ai/course-plan/media/${mediaId}`);
},
async generateWeekMedia(
planId: number,
weekNumber: number,
payload?: { kinds?: Array<"audio" | "image" | "video"> },
): Promise<{ items: Array<CoursePlanMedia | { error: string; material_id: number }>; count: number }> {
return api.post(
`/ai/course-plan/${planId}/weeks/${weekNumber}/media`,
payload ?? {},
);
},
// ---------------------------------------------------------------------
// Phase D — Assignments
// ---------------------------------------------------------------------
async listAssignments(
planId: number,
): Promise<{ items: CoursePlanAssignment[]; count: number }> {
return api.get(`/ai/course-plan/${planId}/assignments`);
},
async createAssignment(
planId: number,
payload:
| {
mode: "batch";
batch_id: number;
due_date?: string | null;
message?: string;
}
| {
mode: "students";
student_user_ids: number[];
due_date?: string | null;
message?: string;
},
): Promise<{ data: CoursePlanAssignment }> {
return api.post(`/ai/course-plan/${planId}/assignments`, payload);
},
async deleteAssignment(
planId: number,
assignmentId: number,
): Promise<{ success: boolean }> {
return api.delete(
`/ai/course-plan/${planId}/assignments/${assignmentId}`,
);
},
// ---------------------------------------------------------------------
// Phase E — Student-side
// ---------------------------------------------------------------------
async studentList(): Promise<{ items: CoursePlan[]; count: number }> {
return api.get("/student/course-plans");
},
async studentGet(planId: number): Promise<{ data: CoursePlan }> {
return api.get(`/student/course-plans/${planId}`);
},
}; };

View File

@@ -81,6 +81,142 @@ export interface CoursePlanMaterial {
/** Loose shape: depends on material_type. */ /** Loose shape: depends on material_type. */
body: Record<string, unknown>; body: Record<string, unknown>;
body_text: string; body_text: string;
media?: CoursePlanMedia[];
}
// ---------------------------------------------------------------------------
// Phase A — Reference sources
// ---------------------------------------------------------------------------
export type CoursePlanSourceKind = "file" | "url" | "text";
export type CoursePlanSourceStatus = "pending" | "indexing" | "indexed" | "failed";
export interface CoursePlanSource {
id: number;
plan_id: number;
name: string;
kind: CoursePlanSourceKind;
file_name: string;
mime_type: string;
url: string;
has_inline_text: boolean;
status: CoursePlanSourceStatus;
error: string;
chunks_count: number;
extracted_chars: number;
indexed_at: string | null;
created_at: string | null;
}
// ---------------------------------------------------------------------------
// Phase B — Deliverables preview / progress
// ---------------------------------------------------------------------------
export type DeliverableStatus = "planned" | "generated" | "ready";
export interface CoursePlanDeliverable {
skill: string;
material_type: string;
material_id: number | null;
title: string;
status: DeliverableStatus;
media: Array<{
id: number;
kind: "audio" | "image" | "video";
status: string;
provider: string;
}>;
}
export interface CoursePlanDeliverablesWeek {
week_number: number;
date_label: string;
unit: string;
focus: string;
items_total: number;
deliverables: CoursePlanDeliverable[];
}
export interface CoursePlanDeliverables {
summary: {
total: number;
planned: number;
generated: number;
ready: number;
percent_ready: number;
media: {
audio: number;
image: number;
video: number;
audio_ready: number;
image_ready: number;
video_ready: number;
};
};
weeks: CoursePlanDeliverablesWeek[];
}
// ---------------------------------------------------------------------------
// Phase C — Multimedia
// ---------------------------------------------------------------------------
export type CoursePlanMediaKind = "audio" | "image" | "video";
export type CoursePlanMediaStatus = "queued" | "generating" | "ready" | "failed";
export type CoursePlanMediaProvider =
| "polly"
| "elevenlabs"
| "openai_image"
| "ffmpeg"
| "elai"
| "manual";
export interface CoursePlanMedia {
id: number;
plan_id: number;
week_id: number | null;
material_id: number | null;
kind: CoursePlanMediaKind;
provider: CoursePlanMediaProvider | string;
title: string;
voice: string;
language: string;
style: string;
mime_type: string;
size_bytes: number;
duration_seconds: number;
width: number;
height: number;
attachment_id: number | null;
download_url: string;
status: CoursePlanMediaStatus;
error: string;
cost_cents: number;
created_at: string | null;
}
// ---------------------------------------------------------------------------
// Phase D — Assignments
// ---------------------------------------------------------------------------
export type CoursePlanAssignmentMode = "batch" | "students";
export type CoursePlanAssignmentState = "active" | "archived";
export interface CoursePlanAssignment {
id: number;
plan_id: number;
plan_name: string;
mode: CoursePlanAssignmentMode;
batch_id: number | null;
batch_name: string;
student_user_ids: number[];
student_user_names: string[];
student_count: number;
assigned_by_id: number | null;
assigned_by_name: string;
due_date: string | null;
message: string;
state: CoursePlanAssignmentState;
created_at: string | null;
} }
export interface CoursePlan { export interface CoursePlan {
@@ -101,9 +237,17 @@ export interface CoursePlan {
resources: CoursePlanResource[]; resources: CoursePlanResource[];
week_count: number; week_count: number;
material_count: number; material_count: number;
source_count?: number;
media_count?: number;
assignment_count?: number;
created_at: string | null; created_at: string | null;
weeks?: CoursePlanWeek[]; weeks?: CoursePlanWeek[];
materials?: CoursePlanMaterial[]; materials?: CoursePlanMaterial[];
sources?: CoursePlanSource[];
media?: CoursePlanMedia[];
assignments?: CoursePlanAssignment[];
/** Present on the student-list endpoint payload. */
assignment?: CoursePlanAssignment;
} }
export interface CoursePlanGenerateBrief { export interface CoursePlanGenerateBrief {