Feature: AI Course Generation
Metadata
- Issue ID: FEAT-84
- Status: Delivered (initial merge + post-merge hardening on
dev) - Owner: danrave1234
- Canonical feature doc:
docs/features/FEAT-84-ai-course-generation.md
Overview
Turn uploaded documents into a fully-structured, gated learning course: modules → lessons (with quizzes) → module exams → final exam → mastery report. Generation runs asynchronously on a self-chaining BullMQ pipeline (FEAT-33). The frontend at /ai-courses/[slug] shows live progress over Socket.IO and gates content behind quiz pass-thresholds.
The credit/subscription policy is in credits-subscription-plan. The queue infrastructure is in 33-job-queue-system.
Frontend Behavior
Routes
/ai-courses— user's course library + "Generate Course" panel./ai-courses/[id]— viewer (sidebar tree + main content + quiz panels). Accepts UUID or slug (title-words-xxxxxxxx).
Creation flow
- User clicks Generate Course, picks one of two scope toggles:
- Document Only — model uses only the supplied documents; no invented topics.
- Expanded — model may add complementary examples to round out the course.
- User selects 1+ documents.
POST /generated-courseswith{ document_ids, generation_mode }returns the new course row and the page navigates to/ai-courses/<slug>.- Page subscribes to the course's Socket.IO room and re-fetches on each completed pipeline step.
Viewer
- Sidebar lists modules with locked / in-progress / completed states. Locked items render as disabled buttons.
- Main panel renders the active lesson, module exam, final exam, or mastery report depending on
activeView. - Lesson body is Markdown (
react-markdown+remark-gfm). Inline HTML is parsed and sanitized viarehype-raw+rehype-sanitizeso historical lessons that contain HTML render safely. - Quiz panel: 4 options per question, ≥70% to pass. On pass shows a green "Continue: " button. The button is async — it awaits a fresh
fetchCourse(true)before navigating, so the next view reads the latest unlock state. The button is disabled (shows "Loading…") while that refetch is in flight. - Mastery view polls every 1.5 s (max 30 s) for non-zero scores after final exam pass, covering the post-pass mastery recompute.
- Initial render uses a full-page skeleton until either
courseorerroris populated — no more "Course not found" flash on first load.
Header / breadcrumb
URL segments are humanized by stripping a trailing 8-hex-char ID and replacing all hyphens. The slug introduction-to-statics-ebdc0820 renders as "introduction to statics" in the page banner and breadcrumb.
Backend Behavior
Module layout
backend/src/generated-courses/
├── dto/
│ ├── create-generated-course.dto.ts
│ ├── update-generated-course.dto.ts
│ └── submit-quiz.dto.ts
├── generated-courses.controller.ts
├── generated-courses.service.ts
└── generated-courses.module.ts
backend/src/job-queue/processors/
├── course-generation.processor.ts
└── course-generation.schemas.ts
Pipeline
course_generation_queue (single BullMQ queue) runs the pipeline. Each step self-enqueues the next.
outline
└─ module_lessons (one job per module, BATCHED in ONE Gemini call for all lessons + quizzes)
└─ module_exam (one per module)
├─ next module's module_lessons (until no modules remain)
└─ final_exam (last module only)
└─ mastery_analysis (terminal — marks course completed)
A 3-module × 3-lesson course = 8 jobs total. The previous per-lesson design used 15+; the batching refactor halved both Gemini round-trips and queue volume.
Step body shape (every step):
- Emit
step_updatesocket eventstatus: generating. - Call Gemini with
responseMimeType: 'application/json'and aresponseSchema(constrained decoding). - Parse via
parseAndValidate(raw, ZodSchema, context)→ triesJSON.parsefirst, thenjsonrepair, then Zod validation. - Persist results to Postgres.
- Emit
step_updatestatus: completed. - Deduct an AI credit (atomic conditional UPDATE).
- Enqueue the next step.
Schemas (constrained decoding)
course-generation.schemas.ts defines both a Zod schema and a Gemini JSON-Schema mirror for each step:
| Step | Zod / Gemini schema |
|---|---|
| outline | CourseOutlineSchema / CourseOutlineGeminiSchema |
| module_lessons | ModuleLessonsSchema / ModuleLessonsGeminiSchema |
| module_exam, final_exam | ExamSchema / ExamGeminiSchema |
| mastery_analysis | MasteryAnalysisSchema / MasteryAnalysisGeminiSchema |
Constrained decoding eliminates most "invalid JSON" output. jsonrepair is a belt-and-suspenders fallback for the rare cases the model still produces a malformed string (invalid backslash escapes, truncation, etc.). Zod catches shape issues the API constraint can't (range checks, enum values, etc.).
Generation mode
modeInstruction(mode) is injected into the system prompt of outline + module_lessons:
document_only(default): "Use ONLY information present in or directly supported by the provided documents. Do NOT invent topics, examples, statistics, or facts that are not in the source material."expanded: "Use the provided documents as the primary foundation. You MAY add complementary examples, definitions, or context, but the core content must align with the documents."
Gating
- Lesson unlock: previous lesson's quiz must be passed →
unlockNextLesson. - Module exam unlock: all lessons in the module passed →
maybeUnlockModuleExam. - Next-module unlock: module exam passed →
unlockNextModule(courseId, currentModuleId)sets the next module + its first lessonunlocked=true. This hop was added post-merge — without it the next module's first lesson stayed locked and the Quiz tab never appeared, blocking course progression until a manual DB tweak. - Final exam unlock: all module exams passed →
maybeUnlockFinalExam. - Course
completed: final exam passed, then mastery_analysis re-runs in the background.
Credit accounting
- Pre-flight on
POST /generated-courses:@RequireFeature('AI_CREDIT')guard — rejects ifused >= limit.- Service-level: rejects if
remaining < 5credits or if user already has 3 courses instatus='generating'.
- One credit per pipeline step, deducted AFTER the AI call but BEFORE the next-step enqueue.
- Atomic conditional UPDATE via
SubscriptionsService.useAiCredit(no read-then-write races). - On
BadRequestException("credit limit reached"):- Mark course
status='failed',generation_step='insufficient_credits'. - Emit
course_failedsocket event. - Throw
UnrecoverableError→ BullMQ does not retry. - Outer catch handler in the processor skips its fail-soft branch via
isTerminal = error instanceof UnrecoverableError.
- Mark course
API
| Method | Path | Behavior |
|---|---|---|
POST | /generated-courses | Create course. Body: { document_ids: string[]; generation_mode?: 'document_only' | 'expanded' }. |
GET | /generated-courses | List user's courses with module/lesson tree + quiz summary. |
GET | /generated-courses/:slug | Get one course (UUID or slug). |
PATCH | /generated-courses/:slug | Update course title. |
DELETE | /generated-courses/:slug | Delete the course (cascades). |
POST | /generated-courses/:slug/lessons/:lessonId/complete | Mark lesson complete (idempotent). |
POST | /generated-courses/:slug/lessons/:lessonId/quiz | Submit lesson quiz. |
POST | /generated-courses/:slug/modules/:moduleId/exam | Submit module exam. |
POST | /generated-courses/:slug/final-exam | Submit final exam. |
GET | /generated-courses/:slug/mastery | Get mastery rows. |
All endpoints sit behind CookieAuthGuard + PlanAccessGuard.
Access is free for everyone — gated by AI credits, not by paid plan. Every user (including free-tier) gets an auto-provisioned user_subscriptions row on signup/login. PlanAccessGuard checks is_active, not Stripe IDs. Course generation (POST /generated-courses) returns 400 — "AI credit limit reached" when the free-tier limit is exhausted. See credits-subscription-plan for limits.
Real-time events
| Event | When |
|---|---|
step_update { courseId, step, status, progress? } | Each pipeline step transition |
course_complete { courseId, status } | Mastery analysis finishes |
course_failed { courseId, status, error } | Outline/mastery error OR credit exhaustion |
Broadcast through the global RedisIoAdapter (FEAT-33) so multi-replica deploys work.
Database
| Table | Key fields |
|---|---|
generated_courses | id, user_id, title, slug, description, source_document_ids[], generation_mode, status, generation_step, structure_json |
generated_modules | id, course_id, title, order, status, unlocked |
generated_lessons | id, module_id, title, content, order, status, unlocked |
generated_lesson_quizzes | lesson_id, questions_json, attempts, passed |
generated_module_exams | module_id, questions_json, unlocked, attempts, passed |
generated_course_final_exams | course_id, questions_json, unlocked, attempts, passed |
generated_course_mastery | course_id, user_id, topic, score, level, recommended_document_ids |
Migration: backend/prisma/migrations/202605131546_add_ai_course_generation/.
Configuration
AI_MODEL_SUMMARY=gemini-2.5-flash-lite
GEMINI_API_KEY=...
JOB_QUEUE_COURSE_GEN_CONCURRENCY=2 # how many DIFFERENT courses advance in parallel
JOB_QUEUE_COURSE_GEN_LIMITER_MS=2000 # min gap between Gemini calls
DEV_BYPASS_SUBSCRIPTION=true # dev only — disables credit checks AND pre-flight gate
QA Test Scenarios
The canonical scenario list lives in the root feature doc (docs/features/FEAT-84-ai-course-generation.md, FEAT-84-01 through FEAT-84-20). Highlights:
| Scenario ID | What's tested |
|---|---|
| FEAT-84-01 | End-to-end generation with document_only. |
| FEAT-84-02 | Mode toggle changes prompt fidelity. |
| FEAT-84-03/04 | Lesson quiz pass unlocks next lesson, then module exam. |
| FEAT-84-05/06 | Module exam pass unlocks next module / final exam. |
| FEAT-84-07 | Final exam pass triggers mastery recompute; UI polls until fresh data appears. |
| FEAT-84-08 | Credit exhaustion mid-pipeline → course marked failed_insufficient_credits, no retries, no orphan jobs. |
| FEAT-84-09/10 | Pre-flight rejects too-few-credits / too-many-concurrent. |
| FEAT-84-11/12 | Lesson body renders Markdown + safe HTML. |
| FEAT-84-13 | No "Course not found" flash on initial load. |
| FEAT-84-14 | Banner shows humanized title, not slug+UUID. |
| FEAT-84-15 | "Continue" waits for fresh state. |
| FEAT-84-16 | Module-level fail-soft. |
| FEAT-84-17 | Mastery polling. |
| FEAT-84-18 | Multi-replica realtime via Socket.IO Redis adapter. |
| FEAT-84-19/20 | Long generation, title edit during generation. |
Edge Cases
- Tiny documents (<1 page) → 1-module 1-lesson course.
- Gemini quota / 429 → provider retries with exponential backoff + respects
retry in Xshints. - Invalid JSON from Gemini →
jsonrepairrecovers truncated / bad-escape output. Schema constraints make this rare. - Repeat enqueue → idempotency keys (
module_lessons:${moduleId}, etc.) reject duplicates at the queue layer. - User out of credits mid-pipeline → FEAT-84-08 behavior.
- Course deletion mid-pipeline → processor reads
nullfromfindUnique, skips gracefully. - Concurrent quiz submissions for same lesson → atomic credit deduction handles the race; one increment per submission.
Notes
- Build gate:
pnpm buildfor backend AND frontend before PR. - Tunables: bump
JOB_QUEUE_COURSE_GEN_CONCURRENCYand lowerJOB_QUEUE_COURSE_GEN_LIMITER_MSonce on paid Gemini tier. - Known follow-ups: token-level credit accounting, admin UI for
subscription_plan_prices+app_settings, rollover-cap enforcement.