Files
classroomio/.cursor/plans/lesson_ai_features_8bcf8cf2.plan.md
T

22 KiB

name, overview, todos, isProject
name overview todos isProject
Lesson AI Features 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.
id content status
openai-dep Install openai package in apps/api and add OPENAI_API_KEY to env schema pending
id content status
db-tables Add lesson_ai_cache, organization_ai_usage, organization_ai_credit_purchase tables to schema.ts and generate migration pending
id content status
db-queries Create query functions for AI cache, AI usage tracking, and AI credit balance computation pending
id content status
ai-credits-constants Create AI credit costs and per-plan limits in packages/utils/src/plans/ai-credits.ts pending
id content status
error-codes Add AI-related error codes to ErrorCodes pending
id content status
ai-credit-service Create ai-credits.ts service for balance checks, deduction, and Polar purchase handling pending
id content status
ai-service Create lesson-ai.ts service with summarizeLesson and generateLessonAudio pending
id content status
validation Add Zod schemas for AI endpoints (summarize, audio, cost-estimate, credit-balance) pending
id content status
api-routes Add AI lesson routes and AI credit routes to the API pending
id content status
polar-webhook Extend Polar webhook to handle AI credit pack purchases pending
id content status
api-build Build API and verify RPC types propagate pending
id content status
fe-types Add request/response types to course/utils/types.ts pending
id content status
fe-api-methods Add AI methods to LessonApi class and create AiCreditsApi class pending
id content status
fe-ai-tab Create lesson AI tab component for edit mode (generate/regenerate TTS and summaries) pending
id content status
fe-student-view Create student-facing Listen and Summary components for view mode pending
id content status
fe-credit-ui Add AI credit balance display and buy-credits flow in settings pending
id content status
fe-translations Add translation keys for all AI feature strings pending
id content status
fe-integrate Wire everything into lesson.svelte, export from barrels, final integration pending
false

Lesson AI Features: Listen to Lesson and Summarize

Architecture

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

1b. Database tables

Add to packages/db/src/schema.ts and generate a migration.

Enum:

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:

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:

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<Record<string, unknown>>().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:

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<Record<string, unknown>>().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

export const AI_FEATURE_CREDIT_COST = {
  summary: 1,
  audio: 5,
} as const;

export const AI_CREDITS_BY_PLAN: Record<string, { monthlyCredits: number }> = {
  [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:

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:

  • 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 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:

export const AI_CREDIT_PACKS = {
  SMALL: { credits: 50, productId: '<polar-product-id>' },
  LARGE: { credits: 200, productId: '<polar-product-id>' },
};

2c. Polar webhook extension

Extend 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 -- 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:

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:

  • 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 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:

  • 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:

  • 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, 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 <audio> with custom play/pause/progress controls
  • Receives audioUrl from lessonApi.aiStatus

4c. Summary panel

New component: apps/dashboard/src/lib/features/course/components/lesson/ai/lesson-summary-panel.svelte

  • Styled card matching the screenshot: rounded corners, "AI SUMMARY" heading in gold/amber, bullet-point list
  • Renders markdown bullet points from the cached summary
  • Collapsible (toggled by the Summarize button)

4d. Auto-load AI status

In lesson.svelte, when in view mode and lesson is loaded, call lessonApi.getAiStatus(courseId, lessonId, locale) to determine which buttons to show.


Phase 5: Settings -- AI Credits Management

5a. AI credits settings section

Either add a section to the existing billing page at billing.svelte, or create a new settings tab. Shows:

  • Current AI credit balance (plan + purchased breakdown)
  • Monthly plan allowance and usage bar
  • "Buy Credits" section with credit pack cards (50 / 200) linking to Polar checkout
  • Usage history (recent AI generations with lesson name, type, credits, date)

Phase 6: Translations and Polish

Add keys to en.json:

  • course.navItem.lessons.materials.tabs.ai.title -- "AI"
  • course.navItem.lessons.ai.summary_status_* -- not generated / generated / stale
  • course.navItem.lessons.ai.audio_status_* -- same
  • course.navItem.lessons.ai.generate_summary -- "Generate Summary"
  • course.navItem.lessons.ai.generate_audio -- "Generate Audio"
  • course.navItem.lessons.ai.regenerate -- "Regenerate"
  • course.navItem.lessons.ai.credits_remaining -- "{count} credits remaining"
  • course.navItem.lessons.ai.buy_more -- "Buy more credits"
  • course.navItem.lessons.ai.credit_cost -- "{count} credit(s)"
  • course.navItem.lessons.ai.content_stale -- "Content has changed since last generation"
  • course.navItem.lessons.ai.listen_to_lesson -- "Listen to lesson"
  • course.navItem.lessons.ai.summarize -- "Summarize"
  • course.navItem.lessons.ai.summary_heading -- "AI Summary"
  • settings.billing.ai_credits.* -- balance, purchase, usage history strings

Key Files to Modify

New files:

  • packages/utils/src/plans/ai-credits.ts
  • packages/db/src/queries/organization/ai-credits.ts
  • packages/db/src/queries/lesson/ai-cache.ts
  • apps/api/src/services/organization/ai-credits.ts
  • apps/api/src/services/lesson/lesson-ai.ts
  • apps/dashboard/src/lib/features/course/api/ai-credits.svelte.ts
  • apps/dashboard/src/lib/features/course/components/lesson/ai/ai-tab.svelte
  • apps/dashboard/src/lib/features/course/components/lesson/ai/lesson-ai-toolbar.svelte
  • apps/dashboard/src/lib/features/course/components/lesson/ai/lesson-audio-player.svelte
  • apps/dashboard/src/lib/features/course/components/lesson/ai/lesson-summary-panel.svelte

Modified files:

  • packages/db/src/schema.ts -- 3 new tables, 2 new enums
  • packages/utils/src/constants/error-codes.ts -- new error codes
  • packages/utils/src/plans/index.ts -- re-export ai-credits
  • apps/api/src/config/env.ts -- OPENAI_API_KEY
  • apps/api/src/routes/course/lesson.ts -- 4 new routes
  • apps/api/src/routes/organization/organization.ts -- AI credit balance route
  • apps/dashboard/src/routes/api/polar/webhook/+server.ts -- credit purchase handler
  • apps/dashboard/src/lib/features/course/pages/lesson.svelte -- AI tab + student toolbar
  • apps/dashboard/src/lib/features/course/components/lesson/constants.ts -- new tab entry
  • apps/dashboard/src/lib/features/course/components/lesson/index.ts -- new exports
  • apps/dashboard/src/lib/features/course/api/lesson.svelte.ts -- AI methods
  • apps/dashboard/src/lib/features/course/utils/types.ts -- new request types
  • apps/dashboard/src/lib/utils/translations/en.json -- new keys