--- name: Lesson AI Features overview: Add credit-based "Listen to Lesson" (TTS) and "Summarize" (LLM) features to lessons, with a dedicated AI credit system, Polar.sh credit purchases, and a teacher-managed AI tab in edit mode. todos: - id: openai-dep content: Install openai package in apps/api and add OPENAI_API_KEY to env schema status: pending - id: db-tables content: Add lesson_ai_cache, organization_ai_usage, organization_ai_credit_purchase tables to schema.ts and generate migration status: pending - id: db-queries content: Create query functions for AI cache, AI usage tracking, and AI credit balance computation status: pending - id: ai-credits-constants content: Create AI credit costs and per-plan limits in packages/utils/src/plans/ai-credits.ts status: pending - id: error-codes content: Add AI-related error codes to ErrorCodes status: pending - id: ai-credit-service content: Create ai-credits.ts service for balance checks, deduction, and Polar purchase handling status: pending - id: ai-service content: Create lesson-ai.ts service with summarizeLesson and generateLessonAudio status: pending - id: validation content: Add Zod schemas for AI endpoints (summarize, audio, cost-estimate, credit-balance) status: pending - id: api-routes content: Add AI lesson routes and AI credit routes to the API status: pending - id: polar-webhook content: Extend Polar webhook to handle AI credit pack purchases status: pending - id: api-build content: Build API and verify RPC types propagate status: pending - id: fe-types content: Add request/response types to course/utils/types.ts status: pending - id: fe-api-methods content: Add AI methods to LessonApi class and create AiCreditsApi class status: pending - id: fe-ai-tab content: Create lesson AI tab component for edit mode (generate/regenerate TTS and summaries) status: pending - id: fe-student-view content: Create student-facing Listen and Summary components for view mode status: pending - id: fe-credit-ui content: Add AI credit balance display and buy-credits flow in settings status: pending - id: fe-translations content: Add translation keys for all AI feature strings status: pending - id: fe-integrate content: Wire everything into lesson.svelte, export from barrels, final integration status: pending isProject: false --- # Lesson AI Features: Listen to Lesson and Summarize ## Architecture ```mermaid flowchart TD subgraph teacherView [Teacher - Edit Mode] AITab["AI tab in lesson editor"] CostPreview["Credit cost per lesson"] GenerateBtn["Generate / Regenerate buttons"] end subgraph studentView [Student - View Mode] ListenBtn["Listen to lesson button"] SummaryPanel["AI Summary panel"] AudioPlayer["Audio player"] end subgraph creditSystem [AI Credit System] BalanceCheck["Balance check"] PlanCredits["Plan monthly credits"] PurchasedCredits["Purchased credit packs"] PolarCheckout["Polar.sh checkout"] end subgraph api [API - Hono] SummarizeRoute["POST lesson/:id/summarize"] AudioRoute["POST lesson/:id/audio"] CostRoute["GET lesson/:id/ai-cost"] BalanceRoute["GET org/ai-credits"] PurchaseRoute["Polar webhook"] AIService["lesson-ai.ts"] CreditService["ai-credits.ts"] end subgraph storage [Storage] Cache["lesson_ai_cache"] UsageTable["organization_ai_usage"] PurchaseTable["organization_ai_credit_purchase"] S3["S3 audio files"] end subgraph openai [OpenAI] ChatAPI["GPT-4o-mini"] TTSAPI["tts-1"] end AITab --> CostPreview AITab --> GenerateBtn GenerateBtn -->|RPC| SummarizeRoute GenerateBtn -->|RPC| AudioRoute ListenBtn --> AudioPlayer studentView -->|"reads cached"| Cache SummarizeRoute --> CreditService AudioRoute --> CreditService CreditService --> BalanceCheck BalanceCheck --> PlanCredits BalanceCheck --> PurchasedCredits PolarCheckout -->|webhook| PurchaseRoute PurchaseRoute --> PurchaseTable CreditService --> AIService AIService --> Cache AIService --> UsageTable AIService -->|summary| ChatAPI AIService -->|audio| TTSAPI AIService -->|upload| S3 ``` ## Credit System Design ### Credit Costs | Action | Credits | Approx. OpenAI cost | |--------|---------|---------------------| | Generate summary | 1 | ~$0.0003 | | Generate TTS audio | 5 | ~$0.05-0.08 | | Regenerate (same costs) | same | same | ### Per-Plan Monthly AI Credits | Plan | Monthly AI Credits | |------|-------------------| | BASIC (Free) | 5 | | EARLY_ADOPTER | 50 | | ENTERPRISE | 500 | ### Purchased Credits - One-time credit packs via Polar.sh (e.g., 50 credits, 200 credits) - Purchased credits never expire - Plan credits are consumed first each month; purchased credits are used only when plan credits run out - New Polar products need to be created for credit packs ### Balance Computation ``` planCreditsRemaining = planMonthlyLimit - SUM(ai_usage WHERE source='plan' AND this billing month) purchasedCreditsRemaining = SUM(ai_credit_purchase.credits) - SUM(ai_usage WHERE source='purchased') totalAvailable = planCreditsRemaining + purchasedCreditsRemaining ``` When consuming credits: deduct from plan credits first. If plan credits are exhausted, deduct from purchased pool. The `source` column on `organization_ai_usage` tracks which pool was charged. --- ## Phase 1: Backend Infrastructure ### 1a. OpenAI dependency and env config - Install `openai` (latest) in [apps/api/package.json](apps/api/package.json) - Add `OPENAI_API_KEY` (optional string) to the Zod env schema in [apps/api/src/config/env.ts](apps/api/src/config/env.ts) ### 1b. Database tables Add to [packages/db/src/schema.ts](packages/db/src/schema.ts) and generate a migration. **Enum:** ```typescript export const lessonAiFeatureType = pgEnum('LESSON_AI_FEATURE_TYPE', ['summary', 'audio']); export const aiCreditSource = pgEnum('AI_CREDIT_SOURCE', ['plan', 'purchased']); ``` **`lesson_ai_cache`** -- stores generated summaries and audio per lesson+locale: ```typescript export const lessonAiCache = pgTable('lesson_ai_cache', { id: uuid().default(sql`gen_random_uuid()`).primaryKey().notNull(), lessonId: uuid('lesson_id').notNull().references(() => lesson.id, { onDelete: 'cascade' }), locale: locale().default('en').notNull(), featureType: lessonAiFeatureType('feature_type').notNull(), content: text(), // markdown summary audioAssetKey: text('audio_asset_key'), // S3 key for TTS audio contentHash: text('content_hash').notNull(), // hash of source lesson HTML createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(), }, (table) => [ unique('unique_lesson_locale_feature').on(table.lessonId, table.locale, table.featureType) ]); ``` **`organization_ai_usage`** -- tracks each AI generation event: ```typescript export const organizationAiUsage = pgTable('organization_ai_usage', { id: uuid().default(sql`gen_random_uuid()`).primaryKey().notNull(), organizationId: uuid('organization_id').notNull().references(() => organization.id), lessonId: uuid('lesson_id').notNull().references(() => lesson.id, { onDelete: 'cascade' }), featureType: lessonAiFeatureType('feature_type').notNull(), creditsConsumed: integer('credits_consumed').default(0).notNull(), source: aiCreditSource('source').notNull(), // 'plan' or 'purchased' metadata: jsonb('metadata').default({}).$type>().notNull(), createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) .default(sql`timezone('utc'::text, now())`).notNull() }, (table) => [ index('idx_org_ai_usage_org_created').on(table.organizationId, table.createdAt) ]); ``` **`organization_ai_credit_purchase`** -- tracks Polar credit pack purchases: ```typescript export const organizationAiCreditPurchase = pgTable('organization_ai_credit_purchase', { id: uuid().default(sql`gen_random_uuid()`).primaryKey().notNull(), organizationId: uuid('organization_id').notNull().references(() => organization.id), credits: integer('credits').notNull(), polarOrderId: text('polar_order_id'), metadata: jsonb('metadata').default({}).$type>().notNull(), createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) .default(sql`timezone('utc'::text, now())`).notNull() }); ``` ### 1c. Database queries New file: `packages/db/src/queries/organization/ai-credits.ts` - `getAiCreditsUsedThisMonth(orgId, source)` -- SUM of `credits_consumed` from `organization_ai_usage` WHERE `source='plan'` AND `createdAt >= billingPeriodStart` - `getTotalPurchasedCredits(orgId)` -- SUM of `credits` from `organization_ai_credit_purchase` - `getTotalPurchasedCreditsUsed(orgId)` -- SUM of `credits_consumed` from `organization_ai_usage` WHERE `source='purchased'` - `recordAiUsage(data)` -- insert into `organization_ai_usage` - `recordAiCreditPurchase(data)` -- insert into `organization_ai_credit_purchase` New file: `packages/db/src/queries/lesson/ai-cache.ts` - `getLessonAiCache(lessonId, locale, featureType)` -- get cached entry - `upsertLessonAiCache(data)` -- insert or update on conflict ### 1d. AI credit constants New file: [packages/utils/src/plans/ai-credits.ts](packages/utils/src/plans/ai-credits.ts) ```typescript export const AI_FEATURE_CREDIT_COST = { summary: 1, audio: 5, } as const; export const AI_CREDITS_BY_PLAN: Record = { [PLAN.BASIC]: { monthlyCredits: 5 }, [PLAN.EARLY_ADOPTER]: { monthlyCredits: 50 }, [PLAN.ENTERPRISE]: { monthlyCredits: 500 }, }; ``` Re-export from `packages/utils/src/plans/index.ts`. ### 1e. Error codes Add to [packages/utils/src/constants/error-codes.ts](packages/utils/src/constants/error-codes.ts): ```typescript AI_CREDITS_EXHAUSTED: 'AI_CREDITS_EXHAUSTED', AI_FEATURE_UNAVAILABLE: 'AI_FEATURE_UNAVAILABLE', AI_GENERATION_FAILED: 'AI_GENERATION_FAILED', ``` ### 1f. AI credit service New file: `apps/api/src/services/organization/ai-credits.ts` - `getAiCreditBalance(orgId)` -- computes `planCreditsRemaining`, `purchasedCreditsRemaining`, `totalAvailable`, `monthlyLimit`, and returns the full balance object - `assertAiCreditsAvailable(orgId, featureType)` -- checks totalAvailable >= cost, throws `AI_CREDITS_EXHAUSTED` (429) if insufficient - `deductAiCredits(orgId, lessonId, featureType)` -- determines source (plan first, then purchased), inserts usage row - `addPurchasedCredits(orgId, credits, polarOrderId)` -- inserts into `organization_ai_credit_purchase` ### 1g. Lesson AI service New file: `apps/api/src/services/lesson/lesson-ai.ts` **`summarizeLesson(lessonId, locale, orgId)`** 1. Fetch lesson content from `lesson_language` for locale 2. Strip HTML to plain text, compute content hash 3. Check `lesson_ai_cache` -- if hash matches, return cached summary 4. Call `assertAiCreditsAvailable(orgId, 'summary')` 5. Call OpenAI Chat Completions (`gpt-4o-mini`) with summarization prompt 6. Upsert `lesson_ai_cache` with summary + hash 7. Call `deductAiCredits(orgId, lessonId, 'summary')` 8. Return summary **`generateLessonAudio(lessonId, locale, orgId)`** 1. Fetch lesson content, strip HTML, compute hash 2. Check cache -- if hash matches and audio key exists, return presigned URL 3. Call `assertAiCreditsAvailable(orgId, 'audio')` 4. Chunk text into segments under 4096 chars 5. Call OpenAI TTS (`tts-1`, voice `alloy`) for each chunk 6. Concatenate audio buffers, upload to S3 7. Upsert cache with audio key + hash 8. Call `deductAiCredits(orgId, lessonId, 'audio')` 9. Return presigned URL **`estimateLessonAiCost(lessonId, locale)`** 1. Fetch lesson content, check what's already cached 2. Return `{ summaryCost, audioCost, totalCost, isStale }` -- 0 if already cached and fresh **`getLessonAiStatus(lessonId, locale)`** 1. Check cache for both summary and audio 2. Return `{ hasSummary, hasAudio, isSummaryStale, isAudioStale, summary?, audioUrl? }` Shared util: `stripHtmlToText(html)` using regex or a lightweight lib. ### 1h. API routes **Extend lesson router** in [apps/api/src/routes/course/lesson.ts](apps/api/src/routes/course/lesson.ts): - `POST /:lessonId/summarize` -- auth + courseMember, role 1/2 only (teacher/admin), calls `summarizeLesson` - `POST /:lessonId/audio` -- auth + courseMember, role 1/2 only, calls `generateLessonAudio` - `GET /:lessonId/ai-status` -- auth + courseMember, calls `getLessonAiStatus` (used by both edit and view mode) - `GET /:lessonId/ai-cost` -- auth + courseMember, role 1/2 only, calls `estimateLessonAiCost` **New org AI credit routes** (add to [apps/api/src/routes/organization/organization.ts](apps/api/src/routes/organization/organization.ts) or a new `ai-credits` sub-router): - `GET /ai-credits` -- auth, returns `getAiCreditBalance(orgId)` ### 1i. Validation schemas Add to `packages/utils/src/validation/lesson/`: - `ZLessonAiParam` -- `{ lessonId: z.string().uuid() }` - `ZLessonAiQuery` -- `{ locale: z.enum([...]).optional().default('en') }` --- ## Phase 2: Polar Credit Purchases ### 2a. Create Polar products Create credit pack products in Polar dashboard (not code -- manual setup): - "50 AI Credits" -- one-time purchase - "200 AI Credits" -- one-time purchase ### 2b. Credit pack constants Add product IDs to [packages/utils/src/plans/ai-credits.ts](packages/utils/src/plans/ai-credits.ts): ```typescript export const AI_CREDIT_PACKS = { SMALL: { credits: 50, productId: '' }, LARGE: { credits: 200, productId: '' }, }; ``` ### 2c. Polar webhook extension Extend [apps/dashboard/src/routes/api/polar/webhook/+server.ts](apps/dashboard/src/routes/api/polar/webhook/+server.ts): - Add a new `order.created` (or `checkout.updated` with `status === 'succeeded'`) handler - Match product ID against `AI_CREDIT_PACKS` to determine credits - Call a new API route to add credits: `POST /organization/ai-credits/purchase` - That route calls `addPurchasedCredits(orgId, credits, polarOrderId)` ### 2d. Checkout flow Reuse the existing Polar checkout pattern from [apps/dashboard/src/routes/api/polar/subscribe/+server.ts](apps/dashboard/src/routes/api/polar/subscribe/+server.ts) -- create a similar `GET /api/polar/ai-credits` endpoint that creates a one-time checkout (not subscription) with `metadata: { orgId, type: 'ai-credits', credits: N }`. --- ## Phase 3: Frontend -- Teacher Edit Mode ### 3a. Types Add to [apps/dashboard/src/lib/features/course/utils/types.ts](apps/dashboard/src/lib/features/course/utils/types.ts): ```typescript export type SummarizeLessonRequest = typeof classroomio.course[':courseId'].lesson[':lessonId'].summarize.$post; export type GenerateLessonAudioRequest = typeof classroomio.course[':courseId'].lesson[':lessonId'].audio.$post; export type GetLessonAiStatusRequest = typeof classroomio.course[':courseId'].lesson[':lessonId']['ai-status']['$get']; export type GetLessonAiCostRequest = typeof classroomio.course[':courseId'].lesson[':lessonId']['ai-cost']['$get']; ``` ### 3b. LessonApi extensions Add to [apps/dashboard/src/lib/features/course/api/lesson.svelte.ts](apps/dashboard/src/lib/features/course/api/lesson.svelte.ts): - State: `aiStatus`, `aiCost`, `isSummarizing`, `isGeneratingAudio` - Methods: `getAiStatus(courseId, lessonId, locale)`, `getAiCost(courseId, lessonId, locale)`, `summarize(courseId, lessonId, locale)`, `generateAudio(courseId, lessonId, locale)` ### 3c. AI credit API class New file: `apps/dashboard/src/lib/features/course/api/ai-credits.svelte.ts` - State: `balance` (plan remaining, purchased remaining, total) - Methods: `getBalance()`, `purchaseCredits(packId)` (redirects to Polar checkout) ### 3d. Lesson AI tab (edit mode) New component: `apps/dashboard/src/lib/features/course/components/lesson/ai/ai-tab.svelte` This is a new tab (value `5`) added to the lesson edit tabs in [constants.ts](apps/dashboard/src/lib/features/course/components/lesson/constants.ts) alongside Note, Slide, Video, Document. Uses a sparkle/wand icon. Layout: - **Credit balance bar** at top: "AI Credits: 42 remaining (plan: 35, purchased: 7)" with a "Buy more" link to settings - **Summary section:** - Status: "Not generated" / "Generated" / "Stale (content changed)" - "Generate Summary" button showing cost (e.g., "1 credit") - If generated: preview of the summary text, "Regenerate" button - **Audio section:** - Status: same pattern - "Generate Audio" button showing cost (e.g., "5 credits") - If generated: inline audio player, "Regenerate" button - If stale: warning that content has changed since last generation ### 3e. Integrate AI tab In [lesson.svelte](apps/dashboard/src/lib/features/course/pages/lesson.svelte): - Add AI tab to the `UnderlineTabs` in edit mode - Load `aiStatus` and `aiCost` when entering the AI tab (lazy load) - Load `aiCredits.balance` when entering the AI tab In [constants.ts](apps/dashboard/src/lib/features/course/components/lesson/constants.ts): - Add `{ label: 'course.navItem.lessons.materials.tabs.ai.title', icon: SparklesIcon, value: 5, badgeValue: 0 }` --- ## Phase 4: Frontend -- Student View Mode ### 4a. Student toolbar New component: `apps/dashboard/src/lib/features/course/components/lesson/ai/lesson-ai-toolbar.svelte` Placed in the lesson view mode block in [lesson.svelte](apps/dashboard/src/lib/features/course/pages/lesson.svelte), between the note content and the Comments section. Shown only when `aiStatus` has generated content. - **"Listen to lesson"** button (headphones icon, dark bg) -- shown only when `aiStatus.hasAudio` is true. Toggles an inline audio player. - **"Summarize"** button (zap icon, outline) -- shown only when `aiStatus.hasSummary` is true. Toggles the summary panel below. ### 4b. Audio player New component: `apps/dashboard/src/lib/features/course/components/lesson/ai/lesson-audio-player.svelte` - Native `