# AI Course Assistant ## Status - Done (shipped) ## Purpose Deliver an AI assistant inside the course UI that teachers can use to: - **Upload a document** (PDF/DOCX) and have the AI propose a full course structure - **Review a course plan** with sections, lessons, and short descriptions before any content is created - **Execute the plan** — the agent creates sections, lessons, and populates content automatically - **Update content**: "Rewrite the introduction paragraph to be more engaging" - **Generate questions**: "Given this lesson, come up with 5 MCQ questions" - **Draft lessons**: Ask the agent to write the initial draft of a lesson The assistant has two modes: - **Plan Mode** — AI analyzes input (document or prompt) and proposes a course structure. The teacher reviews, edits, and approves before anything is created. - **Agent Mode** — AI executes approved actions: creating sections, lessons, exercises, and populating content. Shows real-time progress. ## Goals - Help teachers go from a source document to a fully structured course in minutes - Operate strictly within the current org/course context (no cross-org access) - Integrate natively with existing ClassroomIO services (sections, lessons, exercises, content) - Give teachers full control — always review before destructive or bulk actions ## Non-Goals (v1) - Student-facing AI (teachers/admins only) - Cross-course or cross-org operations - Custom model selection or fine-tuning - Real-time collaboration (multi-user editing via agent) - Internet/web search from the agent --- ## Architecture Overview ### Execution Model: Hybrid (Client Context + API Writes) The client already has course content loaded in Svelte stores when the teacher is on a page. The agent: - **Reads context from the client** — current lesson text, exercise questions, course structure are passed with each message. No extra API calls to "read" content. - **Writes through the API** — all mutations route through existing service functions with the full middleware stack (`authMiddleware` -> `orgMemberMiddleware` -> `courseTeamMemberMiddleware`). Permission enforcement, single source of truth, and auditability for free. - **Previews before bulk changes** — Plan Mode shows the full proposed structure before creating anything. For individual edits, the agent presents changes for review before applying. ### Agent Architecture: Tool-Calling Pattern The agent uses LLM function/tool calling: ``` Teacher -> Chat UI -> Agent API -> LLM -> tool_call -> Existing Services -> result -> LLM -> streamed response -> Chat UI ``` The LLM sees a set of **tools** (mapped to existing service functions) and decides which to call based on the teacher's message. The agent API executes the tool with full permission checks, then feeds the result back to the LLM for a human-friendly summary. ### SDK: Vercel AI SDK - `ai` — core (`streamText`, `tool`, types) — in `packages/ai-assistant` and `apps/api` - `@ai-sdk/svelte` — Svelte 5 `Chat` class (runes-based, matches project's `LessonApi` / `BaseApiWithErrors` pattern) - `@ai-sdk/openai` + `@ai-sdk/anthropic` + `@ai-sdk/google` — provider packages - `streamText()` returns `toUIMessageStreamResponse()` which works directly as a Hono response - Tool calling is built-in with Zod schemas; SDK handles function-calling protocol, multi-step execution, streaming tool results - Provider abstraction lets us swap between OpenAI, Google, and Anthropic per request via the dashboard model picker --- ## Plan Mode & Agent Mode The two modes represent different phases of a workflow, not a toggle. A typical flow: ### Plan Mode **Trigger**: Teacher uploads a document or asks the agent to design a course structure. **What happens**: 1. Teacher uploads a PDF/DOCX (or pastes text, or describes a topic) 2. Agent parses the document, extracts text content 3. Agent analyzes the content and generates a **course plan** — a structured proposal: ```json { "title": "Introduction to Biology", "sections": [ { "title": "Cell Biology", "order": 1, "lessons": [ { "title": "Cell Structure and Function", "description": "Overview of cell organelles, their roles, and how they work together", "order": 1, "hasExercise": true }, { "title": "Cell Division: Mitosis and Meiosis", "description": "The processes of cell division, their stages, and biological significance", "order": 2, "hasExercise": true } ] } ] } ``` 4. The chat UI renders this as a **structured plan view** — a tree with sections and lessons, each showing title and description 5. Teacher can: - **Approve as-is**: "Looks good, implement it" - **Request changes**: "Add a section on genetics" / "Merge the first two lessons" / "Remove the exercise from lesson 3" - **Edit inline**: Modify titles/descriptions directly in the plan view - **Reject**: "Start over with a different structure" 6. The agent refines the plan based on feedback (still in Plan Mode) 7. Once approved, transitions to Agent Mode **Key constraint**: Plan Mode is purely conversational. Nothing is created in the database until the teacher explicitly approves. ### Agent Mode **Trigger**: Teacher approves a plan, or asks the agent to perform a specific action (create lesson, update content, generate questions). **What happens for plan execution**: A typical plan with 4 sections and 12 lessons needs ~40+ tool calls (create section + create lesson + write content + create exercise for each). This exceeds what a single `streamText()` call can handle in one pass. Plan execution uses a **sequential multi-round approach**: 1. Agent receives the approved plan 2. The first `streamText()` round creates all sections and the first few lessons (within `maxSteps: 15`) 3. When `maxSteps` is reached, the `onFinish` callback automatically sends a continuation message: "Continue implementing the plan from where you left off" 4. The agent sees the conversation history (including tool results from prior rounds) and picks up where it stopped 5. This repeats until the plan is fully executed 6. Each round checks the token balance before starting — if tokens run out mid-plan, the agent stops cleanly and reports what was completed Within each round, the agent: - Creates sections first (in order), capturing the returned section IDs - Creates lessons within each section (in order), capturing lesson IDs - For each lesson, generates full content based on the source document + lesson description, then calls `update_lesson_content` to populate it - Optionally creates exercises with generated questions for lessons marked `hasExercise` **What happens for individual actions** (update lesson, generate questions, etc.): Same as the standard agent flow — LLM decides which tool to call, executes it, streams the result. Single-round, `maxSteps: 15` is sufficient. **Progress tracking**: During plan execution, the agent emits structured progress events that the UI renders as a checklist. This persists across multi-round execution: ``` [x] Section 1: Cell Biology (created) [x] Lesson 1: Cell Structure and Function (created) [x] Lesson 1: Content written (2,400 words) [ ] Lesson 1: Exercise (5 questions) — in progress... [ ] Lesson 2: Cell Division [ ] Section 2: Genetics ``` ### Partial Failure & Resume Plan execution can fail partway through — tokens run out, LLM rate limit, network error, or teacher hits Stop. When this happens: - **Already-created content is kept.** Sections and lessons created before the failure exist in the database and are visible in the course content tree. - **The progress card shows what was completed vs. what remains.** Completed items have checkmarks; the failed/stopped item shows an error or stop icon; remaining items show empty circles. - **The teacher can resume.** After the failure, the chat shows a "Resume" button. Clicking it sends a message: "Continue implementing the plan." The agent calls `get_course_structure` to see what already exists, compares it against the plan, and picks up from the first uncreated item. - **Token exhaustion is explicit.** If the failure was due to running out of tokens, the progress card shows: "Paused — token limit reached. [Buy more credits] or wait for reset on [date]." The plan state is preserved in the conversation — the teacher can resume after purchasing credits or next month. ### Existing Course Content Before executing a plan, the agent checks if the course already has content via `get_course_structure`. If sections or lessons already exist: - The agent reports what it found: "This course already has 3 sections and 8 lessons." - It asks the teacher how to proceed: **"Should I add the new content after the existing sections, or would you like to start fresh?"** - **Append** (default): new sections are created with `order` values starting after the last existing section. Existing content is untouched. - **Start fresh**: the agent warns that this is destructive ("This will not delete existing content, but the new sections will be added alongside it. You can remove old content manually."). In v1, the agent cannot delete content — it can only append. Deletion tools are added in Phase 4. ### Token Cost Estimation Before executing a plan, the agent estimates the token cost and warns the teacher: - The estimate is based on plan size: `(sectionCount * ~500) + (lessonCount * ~3,000) + (exerciseCount * ~1,500)` tokens per item (rough heuristic based on typical generation lengths + tool call overhead) - Displayed in the plan card before "Implement Plan": *"Estimated cost: ~45,000 tokens (9% of your monthly allowance)"* - If the estimate exceeds 50% of remaining tokens: stronger warning — *"This plan will use a significant portion of your remaining tokens (estimated ~45,000 of 62,000 remaining). Proceed?"* - This is a best-effort estimate, not a guarantee. Actual usage varies based on content complexity. ### Mode Detection The mode is implicit based on context, not explicitly toggled by the user: - If the agent is analyzing a document or proposing structure -> **Plan Mode** behavior (present plan, wait for approval) - If the agent is executing a tool or the user said "implement it" -> **Agent Mode** behavior (execute and report) - Regular chat (explain something, answer a question about the course) -> neither mode, just conversation The system prompt instructs the LLM on when to propose vs. when to execute. --- ## Document Upload & Parsing ### Plan Gating: EARLY_ADOPTER+ Only Document upload is a premium feature — only available to orgs on the **EARLY_ADOPTER** plan or higher. BASIC (free) plan users cannot upload documents. **Client-side behavior**: - The attachment icon in the chat input is visible to all teachers (so they discover the feature) - When a BASIC plan user clicks it, the file picker does NOT open. Instead, the **upgrade modal** is triggered immediately (same `openUpgradeModal()` pattern used by certificates, team invites, etc.) - For EARLY_ADOPTER+ users, the file picker opens normally **Server-side enforcement**: - `POST /agent/upload` checks the org's plan before processing - If the org is on the BASIC plan, the endpoint returns `403 { error: "document_upload_requires_upgrade", upgradeRequired: true }` - The client handles this 403 by calling `openUpgradeModal()` - This double-gating (client + server) prevents bypassing the restriction ### File Size Limit Maximum upload size: **5MB** (aligns with the existing `MAX_DOCUMENT_SIZE` constant in `apps/api/src/constants/upload.ts`). - Server validates file size before parsing. Files over 5MB are rejected with `413 { error: "file_too_large", maxSize: 5242880 }` - Client validates before upload as well (check `file.size` on selection) and shows an inline error: "File is too large. Maximum size is 5MB." - 5MB is sufficient for most textbook chapters, syllabi, and course outlines. Full textbook PDFs (50MB+) are out of scope — teachers should upload individual chapters. ### One Document Per Conversation Only one document can be uploaded per conversation. If the teacher uploads a second file, it replaces the first — the previous `documentId` in Redis is deleted and the new one takes its place. This keeps the context simple and avoids confusion about which document the agent is referencing. If a teacher needs to combine multiple sources, they should merge them into one document before uploading. ### Upload Flow 1. Teacher clicks the attachment icon in the chat input 2. **Plan check**: if BASIC plan, trigger upgrade modal and stop. If EARLY_ADOPTER+, continue. 3. Selects a PDF, DOCX, or PPTX file 4. **Size check**: if file > 5MB, show inline error and stop 5. File is uploaded to `POST /agent/upload` which: - Validates plan (EARLY_ADOPTER+ required) - Validates file type (PDF/DOCX/PPTX only) and size (max 5MB) - Extracts text content server-side - **Truncates** extracted text to a maximum of 100,000 characters (~75K tokens) to stay within LLM context limits. If truncated, the response includes `truncated: true` so the UI can show: "Document was large — only the first portion was analyzed." - Returns `{ documentId, fileName, pageCount, textPreview, truncated }` (first ~200 chars for preview) - Stores extracted text in Redis with a TTL (keyed by `documentId`, expires after 1 hour) 6. The `documentId` is attached to the next chat message 7. When the agent processes the message, it retrieves the full extracted text from Redis and includes it in the LLM context ### Supported File Types & Text Extraction **PDF**: Use `pdf-parse` (lightweight, pure JS, no native dependencies). Extracts text content from all pages. Handles multi-column layouts reasonably well for structured content like textbooks/syllabi. **DOCX**: Use `mammoth` (pure JS). Extracts text content preserving heading structure (H1, H2, etc.) which helps the LLM understand document hierarchy for course structure generation. **PPTX**: Use `pptx-parser` (pure JS). Extracts text from slides, preserving slide order as a natural section boundary. Slide titles become potential lesson titles; slide body text becomes content context. This covers the common case of teachers converting existing slide decks into courses. ### Why Server-Side Extraction - Keeps document parsing out of the client bundle - Text is extracted once and cached in Redis — subsequent agent turns reference the same extracted text without re-uploading - The LLM never sees the raw binary file — only clean text, which is more token-efficient - File validation and size limits are enforced server-side ### Upload API ``` POST /agent/upload Content-Type: multipart/form-data Body: file (PDF, DOCX, or PPTX, max 5MB) Auth: authMiddleware + orgMemberMiddleware + courseTeamMemberMiddleware Plan: EARLY_ADOPTER+ required (BASIC returns 403) Success Response (200): { documentId: string, fileName: string, mimeType: string, pageCount: number | null, // PDF and PPTX (slide count) wordCount: number, textPreview: string, // first ~200 chars truncated: boolean // true if text exceeded 100K char limit } Error Responses: 403: { error: "document_upload_requires_upgrade", upgradeRequired: true } // BASIC plan 413: { error: "file_too_large", maxSize: 5242880 } // > 5MB 415: { error: "unsupported_file_type", allowed: ["application/pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.presentationml.presentation"] } ``` The extracted text is NOT returned in the upload response (it can be very large). It lives in Redis and is fetched internally by the agent chat endpoint when `documentId` is provided in the chat message. ### Redis Key ``` agent:document:{documentId} -> { text, fileName, mimeType, uploadedAt } TTL: 3600 seconds (1 hour) ``` Uses the existing Redis infrastructure (`apps/api/src/utils/redis/`). --- ## Package: `packages/ai-assistant` A thin, backend-focused package consumed by `apps/api`. No UI code. The heavy lifting is done by the Vercel AI SDK; this package provides project-specific configuration on top. ### Responsibilities - **Provider factory** — creates the right AI SDK `LanguageModel` for a given provider (`createModel`); `getProviderConfigForProvider(provider)` reads the per-provider key (`OPENAI_API_KEY` / `GOOGLE_API_KEY` / `ANTHROPIC_API_KEY`); `pickAnyConfiguredProvider()` returns the first provider with a key set, used by the status route and title generation - **Role-aware tool registry** — `getToolSchemas(role)` returns Zod schemas per role - **Role-aware system prompt** — `buildSystemPrompt(context)` generates different LLM instructions for teachers vs students, including Plan Mode vs Agent Mode behavior - **Course plan types** — `CoursePlan`, `CoursePlanSection`, `CoursePlanLesson` types used by both API and dashboard - **Shared types** — `AgentRole`, `AgentContext`, tool names, config types ### Structure ``` packages/ai-assistant/ src/ index.ts # Public exports types.ts # AgentRole, AgentContext, CoursePlan, AIProviderConfig providers/ index.ts # createModel(config) factory tools/ index.ts # getToolSchemas(role) registry shared.ts # Read-only tool schemas (both roles) teacher.ts # Mutation tool schemas (teacher only, v1) student.ts # Learning tool schemas (student only, v2 stubs) prompt/ system-prompt.ts # buildSystemPrompt(context) — role-aware teacher.ts # Teacher prompt template (includes Plan/Agent mode instructions) student.ts # Student prompt template (v2 stub) package.json tsconfig.json ``` The agent loop itself is NOT in this package — `streamText()` from `ai` core handles the full loop (prompt -> LLM -> tool call -> execute -> respond -> repeat). The Hono route assembles tools (schema from package + execute from services) and calls `streamText`. --- ## Foundation Layer: Role-Aware Design ### AgentRole — the core discriminator - `'teacher'` — can read AND mutate course content (create sections, update lessons, generate questions, execute plans) - `'student'` — can only read and discuss content (explain concepts, answer questions, quiz on material). Gated off in v1. ### Role detection — server-side, automatic - API route checks user's course membership via existing `isCourseTeamMemberOrOrgAdmin` - Team members (ADMIN/TUTOR role in `groupmember`) get `role: 'teacher'` - Regular members (STUDENT role) get `role: 'student'` - In v1, `role: 'student'` returns 403 ### Tool registry — `getToolSchemas(role)` - `shared` tools (read-only): available to both roles - `teacher` tools (mutations): teacher only — includes section/lesson creation, content updates, plan execution - `student` tools (v2): student only, stubs for now Teachers get `shared + teacher`. Students get `shared + student`. Students can never access mutation tools, even via prompt injection. ### Single API endpoint `/agent/chat` serves both roles. Role is detected server-side (not passed by the client, preventing spoofing). The endpoint loads tools and prompt based on the detected role. --- ## Agent Tools ### V1 Core Tools (9 tools) | Tool | Type | Description | Service | | ----------------------- | ----- | ---------------------------------------------------- | ----------------------------------------------------- | | `get_course_structure` | read | Get sections, lessons, exercises tree | `getCourseContentItems` + `listCourseSections` | | `get_lesson_content` | read | Get a specific lesson's content (HTML) | `getLesson` + lesson language query | | `get_exercise_details` | read | Get exercise with all questions and options | `getExercise` | | `create_section` | write | Create a new section in the course | `createCourseSection(courseId, { title, order })` | | `create_lesson` | write | Create a new lesson in a section | `createLesson(courseId, { title, sectionId, order })` | | `update_lesson_content` | write | Update lesson text content | `upsertLessonLanguageService(lessonId, { locale, content })` | | `create_exercise` | write | Create exercise with questions and options | `createExercise({ courseId, lessonId, sectionId, title, questions })` | | `add_questions` | write | Add questions to an existing exercise | `updateExerciseService` (diff-based) | | `generate_course_plan` | agent | Analyze text and return structured CoursePlan JSON | No service call — LLM generates the plan structure | **Note on `generate_course_plan`**: This is a special tool. Unlike other tools that call services, this tool's "execution" is the LLM itself generating a structured JSON response conforming to the `CoursePlan` schema. The tool schema defines the output format so the LLM returns valid, parseable JSON rather than free-form text. The chat UI detects this tool's result and renders the plan view component. ### Phase 2+ Tools **Lessons**: `update_lesson_metadata`, `delete_lesson`, `reorder_lessons` **Sections**: `update_section`, `reorder_sections`, `delete_section` **Exercises**: `update_exercise_metadata`, `update_question`, `delete_question`, `delete_exercise` **Other**: `update_course_tags`, `create_newsfeed_post` ### Phase 4 Student Tools (v2) `explain_concept`, `quiz_me`, `clarify_exercise`, `get_my_progress`, `get_my_submissions` ### Tool Execution Rules Each tool handler in the API will: 1. Extract IDs from tool arguments 2. Verify the target resource belongs to the course/org in context 3. Verify user is course team member or org admin 4. Check token balance before LLM call (see Token Usage section) 5. Call the existing service function 6. Return structured result to the agent ### No-Hallucination Boundary The system prompt includes an explicit list of what the agent CANNOT do. The agent must **never**: - Pretend to perform an action it doesn't have a tool for - Fabricate data it hasn't retrieved via a tool call - Claim to have made a change unless a tool call succeeded - Create content without presenting it for review first (in Plan Mode) **Explicit CANNOT-do list (included verbatim in system prompt):** - Cannot create, delete, or clone courses - Cannot update course-level settings (title, description, logo, status) - Cannot manage course members, invitations, or roles - Cannot grade submissions or assign marks - Cannot handle payments or attendance - Cannot upload files, images, videos, or documents to lessons (the agent can receive documents for analysis, but cannot attach media to course content) - Cannot delete sections, lessons, or exercises (v1 — added in Phase 2) - Cannot reorder content (v1 — added in Phase 2) - Cannot manage org settings, members, or billing - Cannot access data from other courses or organizations - Cannot browse the internet or fetch external URLs - Cannot send emails or notifications --- ## Locale Detection `upsertLessonLanguageService` requires a `locale` parameter. The agent determines the locale as follows: 1. **Check existing course content first.** If the course already has lessons with content in a specific locale (e.g., lessons with `lesson_language` rows in `fr`), use that locale. This ensures consistency with existing content. 2. **If the course is empty**, default to `en`. 3. **For document uploads**, the agent does NOT auto-detect the document's language. The content is generated in the course's locale regardless of the source document language. If a teacher uploads a French textbook but the course is in English, the agent generates English content based on the French source material. This is a feature, not a bug — teachers often want to create courses in a different language than their source material. 4. **The teacher can override.** If they say "Write the content in Spanish," the agent uses `es`. The system prompt instructs: "Default to the course's existing locale. If the teacher requests a specific language, use that locale instead." Supported locales match the existing `lesson_language.locale` enum: `en`, `hi`, `fr`, `pt`, `de`, `vi`, `ru`, `es`, `pl`, `da`. --- ## Lesson Content Format The lesson editor uses **TipTap** (ProseMirror-based) and expects `HTMLContent` — standard HTML that TipTap can parse. The agent must generate content that renders correctly in the editor. ### Allowed HTML Tags The system prompt includes explicit formatting guidance: ``` When generating lesson content, use only these HTML elements: -
for paragraphs -
for callouts or important notes
- for inline code, for code blocks
- for links (only if referencing a specific URL the teacher provided)
Do NOT use: , , ,
,