StudyBoost Docs

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

  1. 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.
  2. User selects 1+ documents.
  3. POST /generated-courses with { document_ids, generation_mode } returns the new course row and the page navigates to /ai-courses/<slug>.
  4. 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 via rehype-raw + rehype-sanitize so 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 course or error is 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):

  1. Emit step_update socket event status: generating.
  2. Call Gemini with responseMimeType: 'application/json' and a responseSchema (constrained decoding).
  3. Parse via parseAndValidate(raw, ZodSchema, context) → tries JSON.parse first, then jsonrepair, then Zod validation.
  4. Persist results to Postgres.
  5. Emit step_update status: completed.
  6. Deduct an AI credit (atomic conditional UPDATE).
  7. 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:

StepZod / Gemini schema
outlineCourseOutlineSchema / CourseOutlineGeminiSchema
module_lessonsModuleLessonsSchema / ModuleLessonsGeminiSchema
module_exam, final_examExamSchema / ExamGeminiSchema
mastery_analysisMasteryAnalysisSchema / 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 lesson unlocked=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 if used >= limit.
    • Service-level: rejects if remaining < 5 credits or if user already has 3 courses in status='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_failed socket event.
    • Throw UnrecoverableError → BullMQ does not retry.
    • Outer catch handler in the processor skips its fail-soft branch via isTerminal = error instanceof UnrecoverableError.

API

MethodPathBehavior
POST/generated-coursesCreate course. Body: { document_ids: string[]; generation_mode?: 'document_only' | 'expanded' }.
GET/generated-coursesList user's courses with module/lesson tree + quiz summary.
GET/generated-courses/:slugGet one course (UUID or slug).
PATCH/generated-courses/:slugUpdate course title.
DELETE/generated-courses/:slugDelete the course (cascades).
POST/generated-courses/:slug/lessons/:lessonId/completeMark lesson complete (idempotent).
POST/generated-courses/:slug/lessons/:lessonId/quizSubmit lesson quiz.
POST/generated-courses/:slug/modules/:moduleId/examSubmit module exam.
POST/generated-courses/:slug/final-examSubmit final exam.
GET/generated-courses/:slug/masteryGet 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

EventWhen
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

TableKey fields
generated_coursesid, user_id, title, slug, description, source_document_ids[], generation_mode, status, generation_step, structure_json
generated_modulesid, course_id, title, order, status, unlocked
generated_lessonsid, module_id, title, content, order, status, unlocked
generated_lesson_quizzeslesson_id, questions_json, attempts, passed
generated_module_examsmodule_id, questions_json, unlocked, attempts, passed
generated_course_final_examscourse_id, questions_json, unlocked, attempts, passed
generated_course_masterycourse_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 IDWhat's tested
FEAT-84-01End-to-end generation with document_only.
FEAT-84-02Mode toggle changes prompt fidelity.
FEAT-84-03/04Lesson quiz pass unlocks next lesson, then module exam.
FEAT-84-05/06Module exam pass unlocks next module / final exam.
FEAT-84-07Final exam pass triggers mastery recompute; UI polls until fresh data appears.
FEAT-84-08Credit exhaustion mid-pipeline → course marked failed_insufficient_credits, no retries, no orphan jobs.
FEAT-84-09/10Pre-flight rejects too-few-credits / too-many-concurrent.
FEAT-84-11/12Lesson body renders Markdown + safe HTML.
FEAT-84-13No "Course not found" flash on initial load.
FEAT-84-14Banner shows humanized title, not slug+UUID.
FEAT-84-15"Continue" waits for fresh state.
FEAT-84-16Module-level fail-soft.
FEAT-84-17Mastery polling.
FEAT-84-18Multi-replica realtime via Socket.IO Redis adapter.
FEAT-84-19/20Long 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 Xs hints.
  • Invalid JSON from Gemini → jsonrepair recovers 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 null from findUnique, skips gracefully.
  • Concurrent quiz submissions for same lesson → atomic credit deduction handles the race; one increment per submission.

Notes

  • Build gate: pnpm build for backend AND frontend before PR.
  • Tunables: bump JOB_QUEUE_COURSE_GEN_CONCURRENCY and lower JOB_QUEUE_COURSE_GEN_LIMITER_MS once on paid Gemini tier.
  • Known follow-ups: token-level credit accounting, admin UI for subscription_plan_prices + app_settings, rollover-cap enforcement.