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
This commit is contained in:
@@ -1,36 +1,127 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import type { Classroom, ClassroomCreateRequest, PaginatedResponse, PaginationParams, ApiSuccessResponse } from "@/types";
|
||||
import { asPaginated, asRecordData } from "@/lib/odoo-api";
|
||||
import type {
|
||||
Classroom,
|
||||
ClassroomAssignCourseRequest,
|
||||
ClassroomBatchSummary,
|
||||
ClassroomCourse,
|
||||
ClassroomCreateRequest,
|
||||
ClassroomMember,
|
||||
ClassroomSection,
|
||||
ClassroomStudent,
|
||||
PaginatedResponse,
|
||||
PaginationParams,
|
||||
ApiSuccessResponse,
|
||||
} from "@/types";
|
||||
|
||||
export interface ClassroomListParams extends PaginationParams {
|
||||
entity_id?: number;
|
||||
branch_id?: number;
|
||||
search?: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export const classroomsService = {
|
||||
async list(params?: ClassroomListParams): Promise<PaginatedResponse<Classroom>> {
|
||||
return api.get<PaginatedResponse<Classroom>>("/groups", params as Record<string, string | number | boolean | undefined>);
|
||||
const raw = await api.get<unknown>(
|
||||
"/classrooms",
|
||||
params as Record<string, string | number | boolean | undefined>,
|
||||
);
|
||||
return asPaginated<Classroom>(raw);
|
||||
},
|
||||
|
||||
async getById(id: number): Promise<Classroom> {
|
||||
return api.get<Classroom>(`/groups/${id}`);
|
||||
const raw = await api.get<unknown>(`/classrooms/${id}`);
|
||||
return asRecordData<Classroom>(raw);
|
||||
},
|
||||
|
||||
async create(data: ClassroomCreateRequest): Promise<Classroom> {
|
||||
return api.post<Classroom>("/groups", data);
|
||||
const raw = await api.post<unknown>("/classrooms", data);
|
||||
return asRecordData<Classroom>(raw);
|
||||
},
|
||||
|
||||
async update(id: number, data: Partial<ClassroomCreateRequest>): Promise<Classroom> {
|
||||
const raw = await api.patch<unknown>(`/classrooms/${id}`, data);
|
||||
return asRecordData<Classroom>(raw);
|
||||
},
|
||||
|
||||
async delete(id: number): Promise<ApiSuccessResponse> {
|
||||
return api.delete<ApiSuccessResponse>(`/groups/${id}`);
|
||||
return api.delete<ApiSuccessResponse>(`/classrooms/${id}`);
|
||||
},
|
||||
|
||||
async transfer(data: { student_ids: number[]; from_id: number; to_id: number }): Promise<ApiSuccessResponse> {
|
||||
return api.post<ApiSuccessResponse>("/groups/transfer", data);
|
||||
// ---- Roster ----------------------------------------------------------
|
||||
async listStudents(id: number): Promise<{ items: ClassroomStudent[]; total: number }> {
|
||||
return api.get(`/classrooms/${id}/students`);
|
||||
},
|
||||
|
||||
async addMembers(id: number, userIds: number[]): Promise<ApiSuccessResponse> {
|
||||
return api.post<ApiSuccessResponse>(`/groups/${id}/members`, { user_ids: userIds });
|
||||
/** Replace or add to roster. ``mode='set'`` overwrites; ``mode='add'`` appends. */
|
||||
async setStudents(
|
||||
id: number,
|
||||
studentIds: number[],
|
||||
mode: "set" | "add" = "set",
|
||||
): Promise<Classroom> {
|
||||
const raw = await api.post<unknown>(`/classrooms/${id}/students`, {
|
||||
student_ids: studentIds,
|
||||
mode,
|
||||
});
|
||||
return asRecordData<Classroom>(raw);
|
||||
},
|
||||
|
||||
async removeMembers(id: number, userIds: number[]): Promise<ApiSuccessResponse> {
|
||||
return api.post<ApiSuccessResponse>(`/groups/${id}/members/remove`, { user_ids: userIds });
|
||||
async removeStudents(id: number, studentIds: number[]): Promise<Classroom> {
|
||||
const raw = await api.delete<unknown>(`/classrooms/${id}/students`, {
|
||||
student_ids: studentIds,
|
||||
});
|
||||
return asRecordData<Classroom>(raw);
|
||||
},
|
||||
|
||||
// ---- Homeroom teachers ----------------------------------------------
|
||||
async listTeachers(id: number): Promise<{ items: ClassroomMember[]; total: number }> {
|
||||
return api.get(`/classrooms/${id}/teachers`);
|
||||
},
|
||||
|
||||
async setTeachers(
|
||||
id: number,
|
||||
teacherIds: number[],
|
||||
mode: "set" | "add" = "set",
|
||||
): Promise<Classroom> {
|
||||
const raw = await api.post<unknown>(`/classrooms/${id}/teachers`, {
|
||||
teacher_ids: teacherIds,
|
||||
mode,
|
||||
});
|
||||
return asRecordData<Classroom>(raw);
|
||||
},
|
||||
|
||||
// ---- Courses + cascade ----------------------------------------------
|
||||
async listCourses(id: number): Promise<{ items: ClassroomCourse[]; total: number }> {
|
||||
return api.get(`/classrooms/${id}/courses`);
|
||||
},
|
||||
|
||||
async listSections(
|
||||
id: number,
|
||||
params?: { course_id?: number; active?: boolean },
|
||||
): Promise<{ items: ClassroomSection[]; total: number }> {
|
||||
return api.get(
|
||||
`/classrooms/${id}/sections`,
|
||||
params as Record<string, string | number | boolean | undefined>,
|
||||
);
|
||||
},
|
||||
|
||||
/** Assign a course → server creates (or reuses) the canonical batch and
|
||||
* enrolls every classroom student into it. */
|
||||
async assignCourse(
|
||||
id: number,
|
||||
payload: ClassroomAssignCourseRequest,
|
||||
): Promise<{ data: Classroom; batch: ClassroomBatchSummary }> {
|
||||
return api.post(`/classrooms/${id}/assign-course`, payload);
|
||||
},
|
||||
|
||||
async detachCourse(id: number, courseId: number): Promise<Classroom> {
|
||||
const raw = await api.delete<unknown>(`/classrooms/${id}/courses/${courseId}`);
|
||||
return asRecordData<Classroom>(raw);
|
||||
},
|
||||
|
||||
// ---- Batches --------------------------------------------------------
|
||||
async listBatches(id: number): Promise<{ items: ClassroomBatchSummary[]; total: number }> {
|
||||
return api.get(`/classrooms/${id}/batches`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,6 +8,9 @@ import type {
|
||||
CoursePlanMedia,
|
||||
CoursePlanSource,
|
||||
CoursePlanSourceKind,
|
||||
WorkbookAttempt,
|
||||
WorkbookExtractedFrom,
|
||||
WorkbookGroundedSource,
|
||||
} from "@/types";
|
||||
|
||||
/**
|
||||
@@ -54,6 +57,55 @@ export const coursePlanService = {
|
||||
return api.get(`/ai/course-plan/${planId}/weeks/${weekNumber}/materials`);
|
||||
},
|
||||
|
||||
/**
|
||||
* RAG-grounded richer-schema generator. Requires at least one
|
||||
* indexed source on the plan; backend returns 409 otherwise so the
|
||||
* UI can prompt for upload.
|
||||
*/
|
||||
async generateWeekMaterialsV2(
|
||||
planId: number,
|
||||
weekNumber: number,
|
||||
): Promise<{
|
||||
items: CoursePlanMaterial[];
|
||||
count: number;
|
||||
rag: { enabled: boolean; sources_used: number[] };
|
||||
}> {
|
||||
return api.post(
|
||||
`/ai/course-plan/${planId}/weeks/${weekNumber}/materials/v2`,
|
||||
);
|
||||
},
|
||||
|
||||
/** Mine indexed PDFs for original printed exercises. */
|
||||
async extractWorkbooks(
|
||||
planId: number,
|
||||
payload?: { max_batches?: number },
|
||||
): Promise<{
|
||||
data: {
|
||||
materials_created: number;
|
||||
exercises_total: number;
|
||||
batches_run: number;
|
||||
skipped: number;
|
||||
reason?: string;
|
||||
};
|
||||
plan_id: number;
|
||||
}> {
|
||||
return api.post(
|
||||
`/ai/course-plan/${planId}/extract-workbooks`,
|
||||
payload ?? {},
|
||||
);
|
||||
},
|
||||
|
||||
/** Source citations for one material — shown as the
|
||||
* "Grounded on N references" badge popover. */
|
||||
async materialGrounding(materialId: number): Promise<{
|
||||
material_id: number;
|
||||
grounded_on: WorkbookGroundedSource[];
|
||||
extracted_from: WorkbookExtractedFrom | null;
|
||||
sources: CoursePlanSource[];
|
||||
}> {
|
||||
return api.get(`/ai/course-plan/material/${materialId}/grounding`);
|
||||
},
|
||||
|
||||
async updateMaterial(
|
||||
materialId: number,
|
||||
payload: {
|
||||
@@ -193,6 +245,29 @@ export const coursePlanService = {
|
||||
return api.delete(`/ai/course-plan/media/${mediaId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Attach an admin-supplied media file (audio / image / video) to a
|
||||
* course-plan material. The backend tags the resulting row with
|
||||
* `provider='manual'` so the drawer can distinguish hand-curated
|
||||
* clips from AI-generated ones — and admins can swap a robotic
|
||||
* Polly recording for a real human voiceover whenever they want.
|
||||
*/
|
||||
async uploadMedia(
|
||||
materialId: number,
|
||||
kind: "audio" | "image" | "video",
|
||||
file: File,
|
||||
title?: string,
|
||||
): Promise<{ data: CoursePlanMedia }> {
|
||||
const fd = new FormData();
|
||||
fd.append("kind", kind);
|
||||
fd.append("file", file);
|
||||
if (title) fd.append("title", title);
|
||||
return api.upload(
|
||||
`/ai/course-plan/material/${materialId}/media/upload`,
|
||||
fd,
|
||||
);
|
||||
},
|
||||
|
||||
async generateWeekMedia(
|
||||
planId: number,
|
||||
weekNumber: number,
|
||||
@@ -257,4 +332,42 @@ export const coursePlanService = {
|
||||
async studentGet(planId: number): Promise<{ data: CoursePlan }> {
|
||||
return api.get(`/student/course-plans/${planId}`);
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Phase F — Interactive workbook attempts
|
||||
// ---------------------------------------------------------------------
|
||||
/** Save (or finalize) the current student's answers and return the
|
||||
* freshly-graded score. Used by InteractiveWorkbook.tsx on every
|
||||
* "Check answers" click (debounced) and on "Submit final". */
|
||||
async saveWorkbookAttempt(
|
||||
planId: number,
|
||||
materialId: number,
|
||||
payload: { answers: Record<string, unknown>; finalize?: boolean },
|
||||
): Promise<{ data: WorkbookAttempt }> {
|
||||
return api.post(
|
||||
`/student/course-plans/${planId}/materials/${materialId}/attempts`,
|
||||
payload,
|
||||
);
|
||||
},
|
||||
|
||||
/** Latest attempt the current user owns on this material — null when
|
||||
* the student hasn't started yet. */
|
||||
async myWorkbookAttempt(
|
||||
planId: number,
|
||||
materialId: number,
|
||||
): Promise<{ data: WorkbookAttempt | null }> {
|
||||
return api.get(
|
||||
`/student/course-plans/${planId}/materials/${materialId}/attempts/me`,
|
||||
);
|
||||
},
|
||||
|
||||
/** Teacher-side roster of latest attempts per student. */
|
||||
async listMaterialAttempts(
|
||||
planId: number,
|
||||
materialId: number,
|
||||
): Promise<{ items: WorkbookAttempt[]; count: number }> {
|
||||
return api.get(
|
||||
`/ai/course-plan/${planId}/materials/${materialId}/attempts`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -18,6 +18,8 @@ import type {
|
||||
MyEnrolledCourse,
|
||||
EnrollStudentRequest,
|
||||
BulkEnrollRequest,
|
||||
LmsBranch,
|
||||
CourseSection,
|
||||
} from "@/types";
|
||||
|
||||
function mapCourseRaw(r: Record<string, unknown>): Course {
|
||||
@@ -53,6 +55,12 @@ function mapCourseRaw(r: Record<string, unknown>): Course {
|
||||
chapter_count: Number(r.chapter_count ?? 0),
|
||||
resource_count: Number(r.resource_count ?? 0),
|
||||
objective_count: Number(r.objective_count ?? 0),
|
||||
section_count: Number(r.section_count ?? 0),
|
||||
sections: Array.isArray(r.sections)
|
||||
? (r.sections as Record<string, unknown>[]).map(mapSectionRaw)
|
||||
: [],
|
||||
branch_id: (r.branch_id as number | null) ?? null,
|
||||
branch_name: (r.branch_name as string) || "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,13 +71,17 @@ function mapBatchRaw(r: Record<string, unknown>): Batch {
|
||||
active: "active",
|
||||
completed: "completed",
|
||||
};
|
||||
const teacherIds = (r.teacher_ids as number[]) || [];
|
||||
const teacherNames = (r.teacher_names as string[]) || [];
|
||||
return {
|
||||
id: r.id as number,
|
||||
name: String(r.name || ""),
|
||||
course_id: Number(r.course_id || 0),
|
||||
course_name: String(r.course_name || ""),
|
||||
teacher_id: 0,
|
||||
teacher_name: "",
|
||||
teacher_id: teacherIds[0] || 0,
|
||||
teacher_name: teacherNames[0] || "",
|
||||
teacher_ids: teacherIds,
|
||||
teacher_names: teacherNames,
|
||||
student_ids: ((r.students as { id: number }[]) ?? []).map((s) => s.id),
|
||||
student_count: Number(r.student_count ?? 0),
|
||||
start_date: String(r.start_date || ""),
|
||||
@@ -77,6 +89,32 @@ function mapBatchRaw(r: Record<string, unknown>): Batch {
|
||||
schedule: "",
|
||||
status: statusMap[st] || "upcoming",
|
||||
capacity: Number(r.max_students ?? 0),
|
||||
branch_id: (r.branch_id as number | null) ?? null,
|
||||
branch_name: (r.branch_name as string) || "",
|
||||
classroom_id: (r.classroom_id as number | null) ?? null,
|
||||
classroom_name: (r.classroom_name as string) || "",
|
||||
course_section_id: (r.course_section_id as number | null) ?? null,
|
||||
course_section_name: (r.course_section_name as string) || "",
|
||||
course_section_code: (r.course_section_code as string) || "",
|
||||
term_key: (r.term_key as string) || "",
|
||||
};
|
||||
}
|
||||
|
||||
function mapSectionRaw(r: Record<string, unknown>): CourseSection {
|
||||
return {
|
||||
id: Number(r.id || 0),
|
||||
name: String(r.name || ""),
|
||||
code: String(r.code || ""),
|
||||
sequence: Number(r.sequence || 0),
|
||||
active: Boolean(r.active ?? true),
|
||||
notes: String(r.notes || ""),
|
||||
course_id: Number(r.course_id || 0),
|
||||
course_name: String(r.course_name || ""),
|
||||
entity_id: (r.entity_id as number | null) ?? null,
|
||||
entity_name: String(r.entity_name || ""),
|
||||
branch_id: (r.branch_id as number | null) ?? null,
|
||||
branch_name: String(r.branch_name || ""),
|
||||
batch_count: Number(r.batch_count || 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -136,6 +174,25 @@ export const lmsService = {
|
||||
return { items, total: p.total, page: p.page, size: p.size, pages: p.pages };
|
||||
},
|
||||
|
||||
async listBranches(params?: PaginationParams & { search?: string; entity_id?: number; active?: boolean }): Promise<PaginatedResponse<LmsBranch>> {
|
||||
const raw = await api.get<unknown>("/branches", params as Record<string, string | number | boolean | undefined>);
|
||||
return asPaginated<LmsBranch>(raw);
|
||||
},
|
||||
|
||||
async createBranch(data: Partial<LmsBranch>): Promise<LmsBranch> {
|
||||
const raw = await api.post<unknown>("/branches", data);
|
||||
return asRecordData<LmsBranch>(raw);
|
||||
},
|
||||
|
||||
async updateBranch(id: number, data: Partial<LmsBranch>): Promise<LmsBranch> {
|
||||
const raw = await api.patch<unknown>(`/branches/${id}`, data);
|
||||
return asRecordData<LmsBranch>(raw);
|
||||
},
|
||||
|
||||
async deleteBranch(id: number): Promise<ApiSuccessResponse> {
|
||||
return api.delete<ApiSuccessResponse>(`/branches/${id}`);
|
||||
},
|
||||
|
||||
async getCourse(id: number): Promise<Course> {
|
||||
const raw = await api.get<unknown>(`/courses/${id}`);
|
||||
return mapCourseRaw(asRecordData<Record<string, unknown>>(raw));
|
||||
@@ -179,6 +236,51 @@ export const lmsService = {
|
||||
return api.delete<ApiSuccessResponse>(`/courses/${id}`);
|
||||
},
|
||||
|
||||
async listCourseSections(courseId: number): Promise<{ items: CourseSection[]; total: number }> {
|
||||
const raw = await api.get<unknown>(`/courses/${courseId}/sections`);
|
||||
const p = asPaginated<Record<string, unknown>>(raw);
|
||||
return { ...p, items: p.items.map(mapSectionRaw) };
|
||||
},
|
||||
|
||||
async createCourseSection(
|
||||
courseId: number,
|
||||
data: { name: string; code: string; sequence?: number; active?: boolean; notes?: string; branch_id?: number | null },
|
||||
): Promise<CourseSection> {
|
||||
const raw = await api.post<unknown>(`/courses/${courseId}/sections`, data);
|
||||
return asRecordData<CourseSection>(raw);
|
||||
},
|
||||
|
||||
async updateCourseSection(
|
||||
courseId: number,
|
||||
sectionId: number,
|
||||
data: Partial<{ name: string; code: string; sequence: number; active: boolean; notes: string; branch_id: number | null }>,
|
||||
): Promise<CourseSection> {
|
||||
const raw = await api.patch<unknown>(`/courses/${courseId}/sections/${sectionId}`, data);
|
||||
return asRecordData<CourseSection>(raw);
|
||||
},
|
||||
|
||||
async deleteCourseSection(courseId: number, sectionId: number): Promise<ApiSuccessResponse> {
|
||||
return api.delete<ApiSuccessResponse>(`/courses/${courseId}/sections/${sectionId}`);
|
||||
},
|
||||
|
||||
async generateDefaultCourseSections(
|
||||
courseId: number,
|
||||
data?: { codes?: string[]; branch_id?: number | null },
|
||||
): Promise<{ items: CourseSection[]; created_count: number; created_ids: number[]; total: number }> {
|
||||
const raw = await api.post<{
|
||||
items: Record<string, unknown>[];
|
||||
created_count: number;
|
||||
created_ids: number[];
|
||||
total: number;
|
||||
}>(`/courses/${courseId}/sections/generate-defaults`, data ?? {});
|
||||
return {
|
||||
created_count: raw.created_count ?? 0,
|
||||
created_ids: raw.created_ids ?? [],
|
||||
total: raw.total ?? 0,
|
||||
items: (raw.items ?? []).map(mapSectionRaw),
|
||||
};
|
||||
},
|
||||
|
||||
async aiGenerateCourse(data: { title: string; subject_id?: number; level?: string }): Promise<{ outline: unknown }> {
|
||||
return api.post("/courses/ai-generate", data);
|
||||
},
|
||||
@@ -255,6 +357,25 @@ export const lmsService = {
|
||||
return api.post(`/batches/${batchId}/students/remove`, { student_ids: studentIds });
|
||||
},
|
||||
|
||||
async getBatchTeachers(batchId: number): Promise<{ items: { id: number; name: string; email: string }[]; total: number }> {
|
||||
return api.get(`/batches/${batchId}/teachers`);
|
||||
},
|
||||
|
||||
async setBatchTeachers(
|
||||
batchId: number,
|
||||
teacherIds: number[],
|
||||
mode: "set" | "add" = "set",
|
||||
): Promise<{ success: boolean; teacher_ids: number[] }> {
|
||||
return api.post(`/batches/${batchId}/teachers`, { teacher_ids: teacherIds, mode });
|
||||
},
|
||||
|
||||
async removeBatchTeachers(
|
||||
batchId: number,
|
||||
teacherIds: number[],
|
||||
): Promise<{ success: boolean; teacher_ids: number[] }> {
|
||||
return api.post(`/batches/${batchId}/teachers/remove`, { teacher_ids: teacherIds });
|
||||
},
|
||||
|
||||
async deleteStudent(id: number): Promise<ApiSuccessResponse> {
|
||||
return api.delete<ApiSuccessResponse>(`/students/${id}`);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user