Some checks failed
Deploy Frontend to Staging / Deploy frontend to staging (push) Has been cancelled
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
219 lines
6.6 KiB
TypeScript
219 lines
6.6 KiB
TypeScript
import { api } from "@/lib/api-client";
|
|
import type {
|
|
CoursePlan,
|
|
CoursePlanAssignment,
|
|
CoursePlanDeliverables,
|
|
CoursePlanGenerateBrief,
|
|
CoursePlanMaterial,
|
|
CoursePlanMedia,
|
|
CoursePlanSource,
|
|
CoursePlanSourceKind,
|
|
} from "@/types";
|
|
|
|
/**
|
|
* REST helpers for the AI course plan generator.
|
|
*
|
|
* The backend routes are mounted under `/api/ai/course-plan`; see
|
|
* `backend/custom_addons/encoach_ai_course/controllers/course_plan.py`.
|
|
*/
|
|
export const coursePlanService = {
|
|
// ---------------------------------------------------------------------
|
|
// Plan lifecycle
|
|
// ---------------------------------------------------------------------
|
|
async list(params?: {
|
|
page?: number;
|
|
size?: number;
|
|
search?: string;
|
|
}): Promise<{ items: CoursePlan[]; page: { page: number; size: number; total: number } }> {
|
|
return api.get("/ai/course-plan", params);
|
|
},
|
|
|
|
async get(planId: number): Promise<{ data: CoursePlan }> {
|
|
return api.get(`/ai/course-plan/${planId}`);
|
|
},
|
|
|
|
async generate(brief: CoursePlanGenerateBrief): Promise<{ data: CoursePlan }> {
|
|
return api.post("/ai/course-plan", brief);
|
|
},
|
|
|
|
async remove(planId: number): Promise<{ success: boolean }> {
|
|
return api.delete(`/ai/course-plan/${planId}`);
|
|
},
|
|
|
|
async generateWeekMaterials(
|
|
planId: number,
|
|
weekNumber: number,
|
|
): Promise<{ items: CoursePlanMaterial[]; count: number }> {
|
|
return api.post(`/ai/course-plan/${planId}/weeks/${weekNumber}/materials`);
|
|
},
|
|
|
|
async listWeekMaterials(
|
|
planId: number,
|
|
weekNumber: number,
|
|
): Promise<{ items: CoursePlanMaterial[]; count: number }> {
|
|
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}`);
|
|
},
|
|
};
|