An AI learning platform built on event-driven architecture with Retrieval-Augmented Generation (RAG), embedding-based semantic caching, and an adaptive remediation loop. The system generates personalized courses, notes, flashcards, and quizzes -- orchestrated through async background workflows with full lifecycle control, retry safety, and fallback resilience.
- System Overview
- Architecture
- Request Lifecycle
- Retrieval-Augmented Generation (RAG)
- Semantic Caching Layer
- Adaptive Learning Loop
- Practice Arena
- Quiz Intelligence System
- Weak Areas System
- Practice Insights
- AI Tutor
- Async Workflow Engine (Inngest)
- AI Pipeline Design
- State Machine and Lifecycle Control
- AI Metrics Dashboard
- End-to-End System Flows
- Data Model
- Failure Handling and Resilience
- Security Model
- Frontend Integration
- Why This Is Not a Simple GPT Wrapper
- How to Think About This System
- Engineering Challenges Solved
- Tech Stack
- How to Run
LearnForge is a modular monolith with event-driven processing. Users submit a topic and preferences; the system generates a structured course outline, per-topic study notes, flashcards, quizzes, and adaptive remediation plans. Every content generation request follows a deterministic pipeline:
User Request
|
v
API Layer (Next.js Route Handler)
|--- Auth (Clerk server-side identity)
|--- Input validation (Zod schema)
|--- Quota enforcement (daily course limits)
|--- State guard (prevent duplicate generation)
|
v
+------------------------------------------+
| Async Workflow Engine |
| (Inngest) |
| |
| 1. Identity guard (skip if completed) |
| 2. Semantic cache check |
| +-- HIT --> return cached content |
| +-- MISS --> continue |
| 3. RAG context retrieval (pgvector) |
| 4. LLM call (Gemini) with RAG context |
| 5. JSON validation + parsing |
| 6. Persist result to PostgreSQL |
| 7. Store in semantic cache |
| 8. Ingest as RAG chunks for future use |
+------------------------------------------+
|
v
PostgreSQL (Neon)
|--- studyTypeContent row updated to "completed"
|--- documentChunks populated with embeddings
|--- semanticCache entry stored
|
v
Frontend Polling --> UI renders result
Every step in this pipeline is a discrete, idempotent Inngest step. If any step fails, Inngest retries from that step -- not from the beginning. Post-generation steps (cache storage, RAG ingestion) are non-blocking: their failure does not affect the generation lifecycle.
+------------------------------------------------------------------+
| Client (Next.js 15) |
| Course Creation | Notes | Flashcards | Quiz | Weak Areas |
+------------------------------------------------------------------+
| | ^
| POST | POST | GET (polling)
v v |
+------------------------------------------------------------------+
| API Layer (Route Handlers) |
| Auth | Validation | Quota | State Guards | Event Dispatch |
+------------------------------------------------------------------+
| |
| Sync (outline, | Async (inngest.send)
| notes, quiz save) |
v v
+-------------------+ +--------------------------------------+
| PostgreSQL | | Inngest Workflow Engine |
| (Neon + pgvector)| | |
| | | CreateNewUser |
| - users | | GenerateStudyTypeContent |
| - studyMaterial | | GenerateRemedialContent |
| - topics | | |
| - studyTypeContent| | Each function: |
| - documentChunks | | step.run("identity guard") |
| - semanticCache | | step.run("cache check") |
| - remedialContent| | step.run("RAG retrieval") |
| - quizAttempt | | step.run("AI generation") |
| - paymentRecord | | step.run("persist result") |
+-------------------+ | step.run("cache store") |
^ | step.run("RAG ingest") |
| +--------------------------------------+
| |
| v
| +--------------------+
| | Gemini API |
| | (Google AI) |
| | |
| | Primary key |
+---------+ Fallback key |
| Embedding model |
+--------------------+
Embedding Model : gemini-embedding-001 (768-dim)
Generation Model: gemini-3-flash-preview (high reasoning)
gemini-3.1-flash-lite-preview (high volume)
Vector Index : HNSW (cosine, m=16, ef_construction=64)
Modular monolith with event-driven processing. Not microservices. Domain logic is separated into services (ragService, cacheService, embeddingService, userStatsService, metricsService, quizIntelligenceService) while the application and database remain shared. All AI-heavy work is offloaded to Inngest background workers, keeping API response times fast.
POST /api/generate-course-outline
--> Zod validation (courseId, topic, courseType, difficultyLevel)
--> Clerk currentUser() identity extraction
--> UserStatsService.incrementDailyCourseCount() (quota gate, returns 429 on exceed)
--> Gemini call (SMART model, HIGH_REASONING_CONFIG)
--> parseAiJson() with tolerant extraction (fenced blocks, nested JSON)
--> normalizeCourseLayout() (sanitize chapter titles, enforce exactly 3 chapters)
--> DB transaction: insert studyMaterial + batch insert topics
--> On topic insert failure: rollback course insert
--> Initialize progress counters (totalTopics, completedTopics, progressPercentage)
--> Update user stats (streak, daily activity)
POST /api/study-type-content
--> Normalize type to lowercase
--> Validate against ALLOWED_TYPES ["flashcard", "quiz"]
--> Extract granular topics from chapter objects (not chapter titles)
--> Check for existing (courseId, type) record
- "completed" or "generating" --> return existing ID (no duplicate job)
- "failed" --> reuse row, reset to "generating"
- None --> insert new row (handle unique constraint race via onConflict)
--> inngest.send("studyType.content")
Inngest Worker (GenerateStudyTypeContent):
--> Identity guard: skip if row already "completed"
--> Mark row as "generating", clear prior error
--> Build RAG query from course title + chapters + topics
--> Check semantic cache
- HIT: retrieve RAG context anyway + run LLM refinement (hybrid cache hit)
- NEAR HIT (similarity 0.80-0.88): hold as fallback
- MISS: proceed to RAG + generation
--> Build enriched prompt (context prepended to original prompt)
--> Call Gemini (FAST model, MEDIUM_REASONING_CONFIG)
--> parseAiJson() for structured output
--> Persist to studyTypeContent (status: "completed")
--> Store result in semantic cache (non-blocking)
--> Track observability metrics (non-blocking)
--> On failure: increment retryCount, store error message, set status "failed"
--> On AI failure with near-hit available: use approximate cache as fallback
POST /api/generate-topic-notes
--> Load topic row by topicId
--> Status guards:
- "completed" --> return cached content
- "generating" --> return 202
- "failed" --> allow retry
- other --> return 409 Conflict
--> Mark topic as "generating"
--> Gemini call (SMART model, markdown output)
--> On success: store notesContent, set "completed"
--> Recompute course progress (totalTopics, completedTopics, progressPercentage)
--> On failure: set "failed", store error
The RAG pipeline has two phases: ingestion (write path) and retrieval (read path).
Ingestion (ragService.ingestChunks):
- After AI generates content (notes), the raw output is converted into semantic text chunks.
- Plain text (notes, markdown) uses sentence-aware character-window chunking (500 chars, 60 char overlap) without splitting mid-sentence.
- Each chunk is embedded using
gemini-embedding-001(768-dimensional vectors) via batch processing with rate-limit-aware throttling. - Chunks are stored in the
documentChunkstable with an HNSW index for fast approximate nearest-neighbor search. - Ingestion is idempotent: existing chunks for the same
(courseId, sourceType)are deleted before re-inserting.
Retrieval (ragService.retrieveContext):
- The incoming query is stripped of prompt boilerplate ("generate", "exactly", "json format") to produce a clean semantic query.
- The cleaned query is embedded using the same model.
- Three-tier similarity search executes against pgvector:
| Step | Scope | Similarity Threshold | Limit | Purpose |
|---|---|---|---|---|
| 1 | Same-course | >= 0.60 (configurable) | 10 | High-confidence, course-scoped |
| 2 | Same-course (relaxed) | >= 0.55 | 10 | Broader match within course |
| 3 | Cross-course | >= 0.65 | 3 | Global fallback (e.g., "Java OOP" reuses chunks from "Java Basics") |
- Steps 1 and 2 only run when a
courseIdis provided. Step 3 runs only when both return zero results. - Results are deduplicated by topic, sliced to top-K (default 3), and formatted as a context block prepended to the original prompt.
Prompt Enrichment (ragService.buildRAGPrompt):
The original prompt is preserved exactly. The retrieved context block is prepended:
--- Relevant Course Context (same-course) ---
[Chunk 1]
Topic: Inheritance. Question: What is method overriding? ...
[Chunk 2]
Topic: Polymorphism. Explanation: Polymorphism allows ...
--- End of Context ---
Using the context above as reference, Generate exactly 10 flashcards ...
This grounds the LLM's generation in actual course content, reducing hallucination and improving topical consistency.
The semantic cache sits in front of every Gemini call for flashcards, quizzes, and remedial content. It uses a two-gate hit detection strategy to prevent false positives.
Gate 1 -- Embedding Similarity:
The incoming query is embedded and compared against stored query embeddings using cosine similarity via pgvector. Threshold: >= 0.88.
Gate 2 -- Keyword Overlap: Even when vectors are close, embedding similarity alone treats "Java basics" and "Java OOP" as near-identical (same domain space). The keyword gate catches intent drift:
- Extract keywords from both current and cached queries (strip words <= 3 chars, ignore generic terms like "basics", "concepts", and study-type words like "flashcard", "quiz")
- Require overlap ratio >= 0.50 AND absolute overlap count >= 4
Both gates must pass for a cache HIT.
| Similarity | Keyword Match | Result |
|---|---|---|
| >= 0.88 | passes | HIT -- retrieve live RAG context + LLM refinement (Hybrid HIT) |
| 0.80 - 0.88 | any | NEAR HIT -- logged, held as fallback if AI fails |
| < 0.80 | any | MISS -- proceed to RAG + full generation |
On HIT: the cached response is refined to ensure fresh context accuracy and written directly to the studyTypeContent row. Hit count and last-accessed timestamp are updated.
On MISS: after successful generation, the query + response pair is stored in semanticCache with its embedding for future lookups.
Near-hit fallback: If AI generation fails and a near-hit exists (similarity 0.80-0.88), the approximate cached content is used as a degraded-but-functional response instead of failing the request entirely.
LearnForge implements a full closed-loop adaptive learning system that identifies knowledge gaps and continuously adjusts future content based on mastery.
- Quiz Execution: The user takes a course quiz or practice quiz.
- Wrong Topic Extraction: The system compares user answers to correct answers and extracts the exact topics the user struggled with.
- Trigger: If the overall score falls below the
ADAPTIVE_SCORE_THRESHOLD(default: 60%), theadaptive.remediationInngest event is fired. - Mastery Filtering: The system filters out any topics the user has previously marked as "mastered".
- Remediation Generation: The system generates a targeted, highly structured study plan for the remaining weak topics, adjusting difficulty based on the exact score (e.g. <30% gets simpler, step-by-step breakdowns, >60% gets advanced challenges).
- Learning: The user reviews the generated remedial study plan in their Weak Areas dashboard.
- Mark Mastered: Once confident, the user can mark specific weak topics as "mastered".
- Future Quiz Intelligence: The Quiz Intelligence System injects remaining unmastered weak topics into the prompts of future quizzes (via semantic injection), closing the adaptive loop.
The Practice Arena serves as an active testing ground where users can validate their knowledge across multiple courses or drill down into specific subjects. It supports two independent modes:
- Why it exists: To test knowledge retention globally across all courses a user has created.
- How it works: Pulls pre-generated, completed quiz questions from all of the user's courses, deduplicates them, and returns a randomized subset (capped at 10).
- Remediation: Does NOT trigger remediation. Mixed practice is for global recall, not deep course remediation.
- Request Flow:
GET /api/practice-quiz→ FetchesSTUDY_TYPE_CONTENT_TABLEfor all user course IDs → Aggregates, shuffles, returns.
- Why it exists: To provide infinite, highly varied test questions for a specific course without repeating the standard course quiz.
- How it works: Bypasses the cache entirely to generate a completely fresh quiz in real-time. It uses an anti-reuse prompt instruction combined with the Quiz Intelligence system to aggressively test unmastered topics.
- Remediation: FULLY supports adaptive remediation if the user scores poorly.
- Request Flow:
GET /api/practice-quiz?courseId=<id>→ Retrieve course layout → Call LLM directly (RAG-enriched + intelligence injection) → Parse/Validate (strict topic normalization) → Deduplicate → Return to UI.
The Quiz Intelligence System (quizIntelligenceService) proactively biases AI quiz generation to focus on the user's documented knowledge gaps.
When generating a quiz for Course A, the system fetches recent weaknesses from Course A (weighing recent mistakes higher). It filters out mastered topics. If gaps exist, it injects a strict instruction instructing the LLM to generate a quiz with a 60/40 bias (60% focused on weak sub-concepts, 40% general coverage).
If no same-course weaknesses exist, the system looks for weaknesses in other courses that belong to the same domain (e.g., programming, science, history).
- Semantic Heading Matching: It generates an embedding for the current course title and compares it against past courses where weaknesses occurred (threshold >= 0.70).
- Topic Verification: It extracts keywords from the candidate weak topics and ensures they semantically overlap with the current course's outline keywords. This prevents nonsensical leaks (e.g. injecting Python weaknesses into a World History quiz).
- Application: Injects a lighter bias (20–30%) into the prompt.
During generation, the AI is heavily constrained to map each question's topic field back to the exact list of topics provided in the course outline, ensuring clean data extraction.
The Weak Areas system manages the targeted remediation data structures.
- Storage: Targeted study plans are stored in the
REMEDIAL_CONTENT_TABLE. Each row maps to a specific quiz attempt. - Replacement Strategy: For course-specific weaknesses, new remediation jobs explicitly replace (delete and insert) old remediation rows for that course. This ensures the user's Weak Areas dashboard always displays the latest, most relevant struggles, preventing clutter.
- Mastered Topic Filtering: When calculating which weak topics need remediation, the system checks the
USER_TOPIC_MASTERY_TABLE. Topics explicitly marked "mastered" by the user are stripped from the pipeline, ensuring the AI focuses only on active struggles. - Integration: Remedial content is also synced into
STUDY_TYPE_CONTENT_TABLE(type:remedial) so it conforms to the existing frontend polling and UI state machines.
Practice Insights are micro-evaluations generated instantaneously when a user completes a practice quiz and gets questions wrong.
- Why it exists: To provide immediate, actionable feedback on why a user is failing (e.g., logic errors vs syntax errors), without waiting for a full remediation plan.
- How it works: A lightweight, fast LLM call (
gemini-3.1-flash-lite-preview) processes the exact question, the correct answer, and the user's wrong answer. It groups the mistakes into patterns and outputs a 1-2 sentence encouraging insight. - When it is generated: Only generated at the end of practice quizzes where
wrongAnswers> 0. - Difference from Remedial Plans: Insights are 1-2 sentence summaries of error patterns. Remedial plans are structured, comprehensive study guides containing explanations, key points, and practice questions.
The AI Tutor (/api/ai-tutor) provides synchronous, course-aware conversational support to students. It heavily prioritizes course context to prevent hallucination.
- Remedial Content: (Highest Priority) The user's most recent completed remediation for this course.
- RAG Notes:
retrieveContext(["notes"])from thedocumentChunkstable. - Course Outline: Chapter/topic titles from
studyMaterial.courseLayoutas a last resort.
- Learning Memory: The tutor actively queries the user's historic weak topics (from
remedialContent) within the last 14 days. If the student's question relates to these historical weaknesses, it dynamically injects instructions to break the topic down into finer sub-concepts. - Cache Repetition Insight: If the system detects the user asking semantically similar questions multiple times (cache hitCount > 3), it injects a behavioral instruction telling the LLM: "The user is struggling. Simplify the concept significantly."
auth → validate → cache check → build context → LLM → cache store → metrics → respond
Flashcard, quiz, and remedial generation involve multiple sequential steps: cache lookup, RAG retrieval, embedding generation, LLM inference, and post-processing. These are long-running operations (5-30 seconds) that would block the HTTP connection and degrade API responsiveness.
Inngest functions are composed of discrete step.run() blocks. Each step is:
- Individually retryable: if step 4 fails, Inngest retries from step 4, not step 1.
- Durable: step results are persisted between retries.
- Observable: each step has a human-readable label visible in the Inngest dashboard.
| Function | Event | Retries | Purpose |
|---|---|---|---|
CreateNewUser |
user.create |
1 | Idempotent user provisioning (upsert via onConflictDoNothing) |
GenerateStudyTypeContent |
studyType.content |
1 | Flashcard/quiz generation with RAG + cache pipeline |
GenerateRemedialContent |
adaptive.remediation |
2 | Targeted remediation for low quiz scores |
Every worker checks if the target row is already completed before doing any work. This prevents re-generation on Inngest retries when the original attempt actually succeeded but the acknowledgment was lost.
| Task | Model | Config | Reasoning |
|---|---|---|---|
| Course outline | gemini-3-flash-preview |
HIGH_REASONING | Structural quality matters for course design |
| Topic notes | gemini-3-flash-preview |
HIGH_REASONING (text output) | Explanatory depth requires stronger reasoning |
| Flashcards | gemini-3.1-flash-lite-preview |
MEDIUM_REASONING | High-volume, lower-cost; quality is sufficient |
| Quizzes | gemini-3.1-flash-lite-preview |
MEDIUM_REASONING | Same rationale as flashcards |
| Remedial plans | gemini-3.1-flash-lite-preview |
MEDIUM_REASONING | Shorter output, actionable content |
| Tutor/Insights | gemini-3.1-flash-lite-preview |
FAST_REASONING | Low latency, conversational capability |
| Embeddings | gemini-embedding-001 |
REST API (768-dim) | Direct REST call bypasses SDK routing issues |
Every AI call creates a fresh model.startChat() instance. This prevents shared chat history across concurrent requests -- a concurrency bug that would cause cross-user context leakage.
All prompts are defined in configs/prompts.js. Each prompt is a function that accepts parameters (topic, chapters, difficulty) and returns a structured instruction string. Prompts explicitly request JSON output with strict schemas, key constraints (e.g., "EXACTLY 3 chapters", "correctAnswer MUST match one option"), and quality rules (e.g., "at least 4 application-based questions").
AI output is parsed through parseAiJson(), a tolerant parser that handles:
- Clean JSON strings
- Markdown fenced code blocks (
```json ... ```) - JSON embedded within surrounding text (brace/bracket depth tracking)
This prevents generation failures from minor formatting inconsistencies in LLM output.
Primary Gemini API key
|
+-- 429 / 503 / "Resource exhausted" --> switch to fallback key
| |
| +-- success --> reset to primary
| +-- failure --> throw
|
+-- Other errors --> throw immediately
Course outline has an additional manual-outline fallback:
if both keys fail, a generic 3-chapter structure is generated locally.
The fallback mechanism is implemented as a singleton (GeminiWithFallback) that swaps the API key at the GoogleGenerativeAI instance level, preserving the same model configuration and generation parameters.
pending --> generating --> completed
|
v
failed (retryable)
generating --> completed
|
v
failed (retryCount incremented, error stored)
pending --> generating --> completed
|
v
failed (retryCount incremented)
| Layer | Mechanism |
|---|---|
| API route | Check existing (courseId, type) row before insert |
| DB | Unique constraint on (courseId, type) in studyTypeContent |
| Race condition | onConflictDoNothing + fallback fetch on constraint violation |
| Worker | Identity guard: skip if row already completed |
| Notes route | Return cached content for completed, return 202 for generating |
LearnForge implements a non-blocking observability layer (metricsService) to track LLM and RAG performance across the platform.
- Latency: Wall-clock milliseconds for every LLM call (0 on cache hits).
- Cache Hit Rate: Percentage of requests intercepted by the Semantic Cache.
- Provider Status: Which provider served the request (
gemini-primary,gemini-fallback). - RAG Usage: Tracks
ragUsed,ragChunksincluded in the prompt,ragAvgSimilarityscore, and if it requiredragFallbackto a looser threshold. - Failures/Errors: Monitors success vs failure rates.
These metrics are surfaced in the AI Metrics Dashboard (AiMetricsCard). They are critical for monitoring production health:
- Cost optimization: Validating the exact API reduction provided by the Semantic Cache.
- Performance profiling: Identifying latency spikes by tracking response times.
- Resilience monitoring: Real-time visibility into Provider Fallback events when the primary API exhausts quotas.
- Retrieval quality: Tracking
ragAvgSimilarityensures context injected into the LLM remains highly relevant.
sequenceDiagram
participant User
participant NextJS API
participant PostgreSQL
participant RAG Service
participant Gemini LLM
User->>NextJS API: POST /api/generate-course-outline
NextJS API->>NextJS API: Auth & Quota Check
NextJS API->>Gemini LLM: Generate Course Structure
Gemini LLM-->>NextJS API: JSON Outline
NextJS API->>PostgreSQL: Insert studyMaterial & topics
NextJS API-->>User: 200 OK
User->>NextJS API: POST /api/generate-topic-notes
NextJS API->>Gemini LLM: Generate Topic Content
Gemini LLM-->>NextJS API: Markdown Notes
NextJS API->>PostgreSQL: Save notesContent
NextJS API->>RAG Service: Ingest into chunks
RAG Service->>Gemini LLM: Generate Embeddings
RAG Service->>PostgreSQL: Save documentChunks (HNSW)
NextJS API-->>User: 200 OK
sequenceDiagram
participant User
participant API
participant Inngest
participant DB
participant LLM
User->>API: Submit Course Quiz Attempt
API->>DB: Save Quiz Attempt
API->>API: Score < 60%?
API->>Inngest: Event: "adaptive.remediation"
Inngest->>DB: Fetch Mastered Topics
Inngest->>Inngest: Filter weakTopics
Inngest->>DB: Delete old remediation (replace)
Inngest->>LLM: Generate Study Plan (RAG Context)
LLM-->>Inngest: JSON Remediation Plan
Inngest->>DB: Save to remedialContent
Inngest->>DB: Save to studyTypeContent
User->>API: View Weak Areas
API->>DB: Fetch Remedial Plans
API-->>User: Display Targeted Study Plan
flowchart TD
A[Start Quiz Gen Request] --> B{Same Course Weaknesses?}
B -- Yes --> C[Filter Mastered Topics]
C --> D[Inject 60/40 Bias Instruction]
B -- No --> E{Cross-Course Match?}
E -- Yes --> F[Semantic Domain check >= 0.70]
F --> G[Verify overlap with Course Outline]
G --> H[Inject 20-30% Bias Instruction]
E -- No --> I[No bias instruction]
D --> J[Generate RAG Prompt]
H --> J
I --> J
J --> K[LLM Request]
users
- id, name, email (unique, indexed)
- isMember, customerId (Stripe)
- streak, studyTime, completedCourses, progress, lastStudyDate
- dailyCoursesCreated, lastCourseDate (quota tracking)
- quizTotalAttempts, quizBestScore, quizAverageScore, quizLastScore
- quizTotalPercentageSum (atomic average recomputation)
studyMaterial (Course)
- courseId (indexed), courseType, topic, difficultyLevel
- courseLayout (JSON: chapters, topics, summaries)
- createdBy (indexed), status
- totalTopics, completedTopics, progressPercentage (denormalized counters)
topics
- courseId (indexed), chapterIndex, topicIndex
- chapterTitle, topicTitle
- notesContent, status, error
- embedding (vector(768)) -- per-topic embedding for semantic search
studyTypeContent
- courseId, type (unique constraint: courseId + type)
- content (JSON: flashcards/quiz/remedial payload)
- status, retryCount, error
documentChunks (RAG)
- courseId (indexed), sourceType (indexed: 'notes' | 'outline' | 'flashcard' | 'quiz')
- chunkIndex, content
- embedding (vector(768), HNSW indexed)
semanticCache
- queryText, queryEmbedding (vector(768), HNSW indexed)
- response (JSON), studyType
- hitCount, createdAt, lastAccessedAt
remedialContent
- userId (indexed), courseId, quizAttemptId (indexed)
- percentage, weakTopics (JSON: string[])
- status, error, retryCount
quizAttempt
- userId (indexed), courseId, quizType ('course'|'practice'|'practice_course')
- score, totalQuestions, percentage, timeTaken
aiMetrics & ai_metrics_daily
- Tracks latency, cache hits, provider usage, and RAG stats for observability
userTopicMastery
- userId, courseId, topic, status ('learning' | 'mastered')
- Filters out mastered topics from adaptive injections
paymentRecord
- customerId, sessionId (Stripe identifiers)
- Rate limiting (429) / Service unavailable (503): Switch to fallback API key. If fallback succeeds, reset to primary on next call.
- Malformed JSON output:
parseAiJson()applies tolerant extraction (fenced blocks, depth-tracked slicing) before throwing. - Both keys exhausted: Course outline uses a locally-generated manual fallback. Other content types mark the row as
failedwithretryCountincremented anderrorstored for diagnostics. - Near-hit cache fallback: If generation fails and a near-hit cache entry exists (similarity 0.80-0.88), approximate cached content is used instead of failing entirely.
- User stats reads retry with exponential backoff (3 attempts, 1s delay).
- Course creation cleans up partial state: if topic insert fails after course insert, the course row is deleted.
- All quiz stat updates use atomic SQL increments (
sql\column + 1``) to prevent read-modify-write race conditions between concurrent requests. - Multi-table writes (course + topics) use transactional semantics.
- Stripe webhooks verify signatures when
STRIPE_WEB_HOOK_KEYis configured. - Each event handler checks affected rows and logs warnings for missing users.
- Null-checks on sensitive fields (
customer_details.email,customer_email) prevent crashes on incomplete webhook data.
generateEmbedding()retries 3 times with exponential backoff (500ms, 1000ms).- On the final attempt, switches to fallback API key.
batchGenerateEmbeddings()processes sequentially in batches of 5 with 300ms pauses between batches to stay under rate limits.
- IDOR prevention: All authenticated routes use
currentUser()from Clerk server-side context. Client-provided identity parameters are ignored. - Route protection: Clerk middleware guards
/dashboard,/create, and/courseroutes. - Webhook integrity: Stripe webhooks use signature verification via
stripe.webhooks.constructEvent(). - Server-only secrets: Database connection string, API keys, and Stripe secrets are never exposed to the client bundle.
- Input validation: Course creation uses Zod schema validation. Study type content validates against an allowlist of types.
- Quota enforcement: Daily course creation limits (free: 10, premium: unlimited) are enforced server-side before any AI call.
The frontend reflects backend processing through lifecycle-aware UI states:
- Polling: After triggering async generation, the UI polls
/api/study-statusat intervals until the record transitions tocompletedorfailed. - Status-driven rendering: Components check the
statusfield to show loading spinners, error messages with retry actions, or the final content. - Practice quiz aggregation:
/api/practice-quizaggregates completed quiz content across all user courses, shuffles questions, and returns a cross-course mixed quiz (up to 10 questions). - Weak areas dashboard: Surfaces low-score quiz attempts with linked remediation plans for targeted review.
| Dimension | GPT Wrapper | LearnForge |
|---|---|---|
| Context | Sends raw user input to LLM | Retrieves semantically relevant course chunks via RAG and injects them as grounding context |
| Caching | Every request hits the LLM API | Embedding-based semantic cache with two-gate hit detection avoids redundant calls |
| Processing | Synchronous request-response | Long-running generation offloaded to durable async workflows with per-step retryability |
| Resilience | Single API key, no fallback | Primary/fallback key switching, near-hit cache fallback, manual outline fallback |
| Data pipeline | No ingestion or indexing | Generated content is chunked, embedded, and indexed in pgvector for future retrieval |
| Feedback loop | Static output | Quiz scores trigger adaptive remediation: mistakes drive targeted content generation |
| Lifecycle | Stateless | Full state machine (pending/generating/completed/failed) with duplicate prevention, idempotent retries, and progress tracking |
| Concurrency | Shared model state | Per-request AI sessions prevent cross-user context leakage |
| Component | Role |
|---|---|
| RAG pipeline | Context provider -- retrieves relevant course material to ground LLM generation |
| Semantic cache | Cost and latency optimizer -- eliminates redundant LLM calls for equivalent queries |
| LLM (Gemini) | Reasoning engine -- generates structured educational content from enriched prompts |
| Inngest | Orchestration layer -- manages long-running, multi-step workflows with durability and retry |
| pgvector | Similarity search engine -- enables both RAG retrieval and cache lookups via HNSW-indexed cosine similarity |
| PostgreSQL | Source of truth -- stores all state, content, embeddings, and lifecycle metadata |
| State machine | Consistency enforcer -- explicit status transitions prevent duplicate work and enable safe retries |
Reducing hallucination: Raw LLM calls produce content disconnected from the actual course material. The RAG pipeline retrieves semantically relevant chunks from previously generated course content and injects them into the prompt, grounding the LLM's output in real course data.
Reducing cost: Repeated or semantically equivalent queries (e.g., regenerating flashcards after a page refresh) previously required full LLM round-trips. The semantic cache layer intercepts these at the embedding level, serving cached responses in milliseconds instead of seconds.
Handling long-running tasks: AI generation with RAG retrieval and embedding generation takes 5-30 seconds. Inngest moves this off the request path into durable background workers with individually retryable steps, keeping API response times under 200ms.
Preventing duplicate generation: Concurrent requests, page refreshes, and Inngest retries all risk creating duplicate content. A multi-layer defense (status guards, unique constraints, race-safe upserts, identity guards in workers) ensures each (courseId, type) combination produces exactly one output.
Ensuring data consistency: Quiz stats use atomic SQL increments (quizTotalAttempts + 1, GREATEST(quizBestScore, newScore)) to prevent read-modify-write races. Course + topic creation uses transactional semantics with cleanup on partial failure.
Preventing cross-user data leakage: Shared startChat() instances would carry conversation history across requests. Per-request model factories ensure each AI call starts with a clean context.
| Layer | Technology |
|---|---|
| Framework | Next.js 15 (App Router) |
| Runtime | React 19 |
| Database | PostgreSQL (Neon serverless) |
| Vector search | pgvector with HNSW indexing |
| ORM | Drizzle ORM |
| Background jobs | Inngest (event-driven, step functions) |
| AI generation | Google Gemini (gemini-3-flash-preview, gemini-3.1-flash-lite-preview) |
| Embeddings | gemini-embedding-001 (768-dim, REST API) |
| Authentication | Clerk |
| Payments | Stripe (webhooks + checkout) |
| Validation | Zod |
| Styling | Tailwind CSS + Radix UI |
npm install
node scripts/enable-pgvector.js # One-time: enable pgvector + create RAG/cache tables
npx drizzle-kit push # Push schema to database
npm run dev # Start Next.js dev server
npx inngest-cli dev # Start Inngest dev server (background workers)Required environment variables:
DATABASE_CONNECTION_STRING # Neon PostgreSQL connection string
NEXT_PUBLIC_GEMINI_API_KEY # Primary Gemini API key
GEMINI_FALLBACK_API_KEY # Fallback Gemini API key (optional)
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY # Clerk publishable key
CLERK_SECRET_KEY # Clerk secret key
STRIPE_SECRET_KEY # Stripe secret key
STRIPE_WEB_HOOK_KEY # Stripe webhook signing secret
Optional tuning:
GEMINI_MAX_OUTPUT_TOKENS # Max tokens per generation (default: 8192)
RAG_TOP_K # Max RAG chunks per query (default: 3)
RAG_MIN_SIMILARITY # Primary retrieval threshold (default: 0.60)
RAG_FALLBACK_SIMILARITY # Relaxed retrieval threshold (default: 0.55)
RAG_CROSS_COURSE_SIMILARITY # Cross-course threshold (default: 0.65)
RAG_SIMILARITY_THRESHOLD # Cache hit threshold (default: 0.88)
CACHE_MIN_OVERLAP_RATIO # Keyword overlap ratio for cache (default: 0.50)
ADAPTIVE_SCORE_THRESHOLD # Quiz score triggering remediation (default: 60)