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
This commit is contained in:
@@ -1,9 +1,19 @@
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { Input } from "@/components/ui/input";
|
||||
@@ -38,6 +48,33 @@ import type { CoursePlanGenerateBrief } from "@/types";
|
||||
* 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 {
|
||||
title: string;
|
||||
cefr_level: string;
|
||||
@@ -48,6 +85,8 @@ interface CoursePlanWizardState {
|
||||
grammar_focus: string[];
|
||||
resources: string[];
|
||||
notes: string;
|
||||
sources: DraftSource[];
|
||||
media: MediaToggleState;
|
||||
}
|
||||
|
||||
const CEFR_OPTIONS = [
|
||||
@@ -183,6 +222,17 @@ export default function CoursePlanWizard() {
|
||||
</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",
|
||||
titleKey: "coursePlan.wizard.steps.scope",
|
||||
@@ -214,6 +264,17 @@ export default function CoursePlanWizard() {
|
||||
</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",
|
||||
titleKey: "coursePlan.wizard.steps.review",
|
||||
@@ -257,6 +318,26 @@ export default function CoursePlanWizard() {
|
||||
label={t("coursePlan.wizard.fields.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">
|
||||
{t("coursePlan.wizard.reviewHint")}
|
||||
</div>
|
||||
@@ -283,9 +364,11 @@ export default function CoursePlanWizard() {
|
||||
grammar_focus: [],
|
||||
resources: [],
|
||||
notes: "",
|
||||
sources: [],
|
||||
media: { audio: true, image: true, video: false },
|
||||
}}
|
||||
onFinish={async (state) => {
|
||||
await mutation.mutateAsync({
|
||||
const resp = await mutation.mutateAsync({
|
||||
title: state.title.trim(),
|
||||
cefr_level: state.cefr_level,
|
||||
total_weeks: state.total_weeks,
|
||||
@@ -298,11 +381,284 @@ export default function CoursePlanWizard() {
|
||||
resources: state.resources.length ? state.resources : 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 }) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
|
||||
Reference in New Issue
Block a user