# Project export: LoveLace

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: TreeHacks 2026
- Tagline: LoveLace is an AI-powered Teaching Assistant changing how students interact with math by building mathematical intuition, confidence, and passion from beautiful, automated animations.
- Devpost: https://devpost.com/software/lovelace-310caj
- GitHub: https://github.com/aanikat07/TreeHacks-Project
- Demo: https://tree-hacks-project.vercel.app/
- Video: https://www.youtube.com/embed/ho6sXro3hTQ?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 1 GitHub contributor(s) — Om Shah (39 commits)

## Devpost submission (written by the team)

### Inspiration

How many of you would see this equation: $$\int_0^\infty \left(\sum_{k=1}^{\infty} \frac{(-1)^k x^{2k}}{(2k)!}\right)e^{-\alpha x}\, dx$$ and immediately reach for ChatGPT? From algebra to multivariable calculus, many students in these classes find themselves relying on AI tools to finish assignments faster. This widens the gap between those who use AI to deepen understanding and those who rely on it to survive. Inspired by the way real learning happens in office hours—through messy thinking, dialogue, and visualization—we built a multimodal AI Teaching Assistant focused on intuition-building.

### What it does

Students can speak their reasoning, sketch ideas, generate graphs with natural language, or type traditionally, and the AI interprets partial understanding, corrects misconceptions, and responds with dynamic visual explanations—including automatically generated animated math videos. Users can also upload course-specific materials so explanations match their class’s notation and philosophy, creating private, personalized office hours anytime. In addition, learners can generate and interact with both 2D and 3D graphs using natural language, exploring mathematical concepts dynamically and intuitively in real time.

### How we built it

Using OpenAI’s real-time GPT-4o transcription and text-to-speech tools, we enabled natural spoken interaction, while rapidly iterating on the full-stack infrastructure despite having no prior full-stack experience. Sponsor tools and AI agents accelerated development dramatically, allowing us to integrate retrieval-based personalization and dynamic animation generation into a cohesive learning experience.

### Challenges we ran into

AI allowed us to scale and prototype incredibly fast, but that speed meant we had to learn new tools, architectures, and constraints just as quickly, often redesigning components in real time. One of the biggest obstacles we faced was grounding the math animations in step-by-step tutorial reasoning. We navigated token limits, extensive prompt engineering to regulate the AI’s level of autonomy, and the delicate balance between guiding animations and graphs toward well-behaved outputs while still preserving flexibility. Managing multiple input modalities and adapting our design to time and technical constraints required constant iteration and tight coordination across the team.

### Accomplishments we're proud of

We are especially proud of designing and deploying a fully multimodal, personalized learning system that couples a Retrieval-Augmented Generation (RAG) pipeline with generative visual reasoning. Our RAG architecture indexes and embeds course-specific artifacts and textbook resources, processes user query by retrieving semantically relevant chunks in real time, and conditions LLM outputs on this grounded context to produce syllabus-aligned, citation-aware “TA in Office Hours”-esque responses. Then, we developed an animation layer that translates this symbolic reasoning into stepwise, elegant visualizations, enabling students to see abstract transformations unfold dynamically and intuitively. The system highlights smooth, real-time interaction through a digital whiteboard and natural speech input, so students can sketch ideas, write out steps, and talk through their thinking naturally. In addition to a back-and-forth system, we developed a custom low-level graphing experience. Students can use natural language to directly customize functions ranging from the simple line to a sophisticated sinusoidal wave in 3D. To accomplish this, we had to develop a custom layer for the agent to interface with the Desmos API and generate reliable modifications to the graph from organic, sometimes imprecise, user input. We bridged the gap between math and natural language for students who want to learn the graphical relevance of function parameters. Together, LoveLace creates a fluid, back-and-forth experience that feels much closer to real office hours, allowing students to explore, make mistakes, and refine their understanding while still receiving clear, structured guidance.

### What we learned

Through this project, we learned how powerful AI agents can be—not just as tools, but as collaborators across design, infrastructure, and debugging. We discovered that the best project is built with a strong vision for how people want to interact with technology. We discovered how to work effectively with agents: breaking ambitious ideas into smaller components, iterating rapidly, and then stitching everything back together into a cohesive system. As first-time hackathon builders, we learned how to take a vision from concept to full-scale application and realized we’re no longer limited by unfamiliar tech stacks or lack of prior experience.

### What's next

Next, we plan to expand LoveLace’s accessibility by adding Spanish language support, making high-quality, conceptual math tutoring available to a broader community of learners. We also aim to optimize animation rendering time through GPU acceleration, improving responsiveness so visual explanations feel seamless and real-time.

## README (from the GitHub repository)

## Animation Pipeline (Claude -> Render Worker -> Vercel Blob)

This app now supports an async animation flow in the `Animation` tab:

1. User prompt goes to `POST /api/chat` with `mode: "animation"`.
2. Claude generates Python Manim code.
3. The app creates a render job in Blob (`manim-jobs/...json`).
4. The app enqueues your Render worker.
5. Worker renders video and calls `POST /api/animation/callback`.
6. Callback stores video in Blob (`manim-renders/<jobId>.mp4`) and marks the job completed.
7. Frontend polls `GET /api/animation/jobs/[id]` and displays the video.

### Required env vars (Next app)

Add these to `.env.local`:

```bash
ANTHROPIC_API_KEY=...
BLOB_READ_WRITE_TOKEN=...
RENDER_WORKER_URL=https://your-render-worker.onrender.com
RENDER_WORKER_SECRET=your-shared-secret
RENDER_CALLBACK_SECRET=your-callback-secret
# optional if callback origin differs from request origin
# RENDER_CALLBACK_URL=https://your-app-domain.com/api/animation/callback
```

### Worker scaffold

Worker code is in `worker/render_worker.py`.

Expected deployment env var for worker:

```bash
RENDER_WORKER_SECRET=your-shared-secret
```

Deploy on Render with:

1. Runtime: Python
2. Build command: `pip install -r worker/requirements.txt`
3. Start command: `python worker/render_worker.py`

### Run locally

```bash
npm run dev
```


## Detected evidence (automated analysis)

Indexed codebase: 30 recognized source files, 124 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- Flask (technology) — detected in the code
- Next.js (technology) — detected in the code
- OpenAI (technology) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- Supabase (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- Docker (technology) — claimed on Devpost, not found in the code
- JavaScript (language) — claimed on Devpost, not found in the code
- Vercel (technology) — claimed on Devpost, not found in the code
- AI coding agent: Claude Code — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (36 of 36)

```
.gitignore
app/api/animation/callback/route.ts
app/api/animation/jobs/[id]/route.ts
app/api/chat/route.ts
app/api/realtime/session/route.ts
app/api/tts/route.ts
app/api/upload/route.ts
app/globals.css
app/layout.tsx
app/page.tsx
app/session/page.tsx
app/workspace/page.tsx
biome.json
components/SpirographCanvas.tsx
components/Whiteboard.tsx
lib/animation-jobs.ts
lib/lecture/ingest.ts
lib/lecture/transcribe.ts
lib/rag/chunking.ts
lib/rag/hash.ts
lib/rag/openai.ts
lib/render-worker.ts
lib/security/rate-limit.ts
lib/session/animation-prompt.ts
lib/session/retrieve.ts
lib/supabase/server.ts
lib/upload/extract.ts
lib/whiteboard/vision.ts
next.config.ts
package.json
postcss.config.mjs
README.md
tsconfig.json
types/pdf-parse.d.ts
worker/render_worker.py
worker/requirements.txt
```

### Dependencies

- package.json: @anthropic-ai/sdk@^0.74.0, @biomejs/biome@2.2.0, @supabase/supabase-js@^2.95.3, @tailwindcss/postcss@^4, @types/node@^20, @types/react@^19, @types/react-dom@^19, @vercel/blob@^2.2.0, babel-plugin-react-compiler@1.0.0, lucide-react@^0.511.0, next@16.1.6, openai@^6.22.0, pdf-parse@^1.1.1, react@19.2.3, react-dom@19.2.3, tailwindcss@^4, typescript@^5
- worker/requirements.txt: flask@==3.1.0, manim@==0.19.0, requests@==2.32.3

### Recent commits (newest first)

- test
- sdf
- yay?
- sefsdf
- sdf
- sdf
- sdf
- sdf
- new
- Sdf
- Sdfnew
- new inference pattern
- Sdf
- new
- new
- sdaf
- sdaf
- sdaf
- new
- sdf

## Key source files (fetched from GitHub, selected and truncated for size)

### package.json

```
{
  "name": "treehacks",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "biome check",
    "format": "biome format --write"
  },
  "dependencies": {
    "@anthropic-ai/sdk": "^0.74.0",
    "@supabase/supabase-js": "^2.95.3",
    "@vercel/blob": "^2.2.0",
    "lucide-react": "^0.511.0",
    "next": "16.1.6",
    "openai": "^6.22.0",
    "pdf-parse": "^1.1.1",
    "react": "19.2.3",
    "react-dom": "19.2.3"
  },
  "devDependencies": {
    "@biomejs/biome": "2.2.0",
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "babel-plugin-react-compiler": "1.0.0",
    "tailwindcss": "^4",
    "typescript": "^5"
  }
}

```

### worker/requirements.txt

```
flask==3.1.0
requests==2.32.3
manim==0.19.0

```

### app/layout.tsx

```typescript
import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className="antialiased">{children}</body>
    </html>
  );
}

```

### app/page.tsx

```typescript
"use client";

import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import SpirographCanvas from "@/components/SpirographCanvas";

const symbols = ["∫", "∇", "π", "Σ", "∞", "θ", "λ", "Δ", "x²", "eᶦπ"];
const hearts = ["♥", "♡", "❤", "❥", "❣"];

export default function Home() {
  const router = useRouter();
  const [transitioning, setTransitioning] = useState(false);
  const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  const handleBegin = () => {
    if (transitioning) return;
    setTransitioning(true);
    transitionTimerRef.current = setTimeout(() => {
      router.push("/session");
    }, 2000);
  };

  useEffect(() => {
    return () => {
      if (transitionTimerRef.current) {
        clearTimeout(transitionTimerRef.current);
      }
    };
  }, []);

  return (
    <main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-[hsl(var(--background))] px-6">
      <div className="pointer-events-none absolute inset-0">
        <div className="absolute left-[-120px] top-[-160px] h-[420px] w-[420px] rounded-full bg-[hsl(var(--primary))]/15 blur-3xl" />
        <div className="absolute bottom-[-200px] right-[-120px] h-[500px] w-[500px] rounded-full bg-[hsl(var(--primary-strong))]/12 blur-3xl" />
      </div>

      <div className="pointer-events-none absolute inset-0">
        {symbols.map((symbol, index) => (
          <span
            key={symbol}
            className="absolute font-mono text-[hsl(var(--muted-foreground))]/18"
            style={{
              left: `${8 + ((index * 11) % 80)}%`,
              top: `${12 + ((index * 19) % 70)}%`,
              fontSize: `${20 + (index % 4) * 8}px`,
              animation: `float ${5 + (index % 3)}s ease-in-out infinite`,
              animationDelay: `${index * 0.25}s`,
            }}
          >
            {symbol}
          </span>
        ))}
        {hearts.map((heart, index) => (
          <span
            key={heart}
            className="absolute font-display text-[hsl(var(--primary))]/20"
            style={{
              left: `${14 + ((index * 17) % 74)}%`,
              top: `${8 + ((index * 23) % 78)}%`,
              fontSize: `${28 + (index % 3) * 10}px`,
              animation: `float ${6 + (index % 3)}s ease-in-out infinite`,
              animationDelay: `${index * 0.35 + 0.2}s`,
            }}
          >
            {heart}
          </span>
        ))}
      </div>

      <section className="relative z-10 mx-auto w-full max-w-3xl px-7 py-12 text-center sm:px-12">
        <h1 className="text-6xl font-semibold tracking-tight text-[hsl(var(--primary))] sm:text-8xl">
          LoveLace
        </h1>

        <p className="mx-auto mt-5 max-w-2xl text-lg text-[hsl(var(--muted-foreground))]">
          Ask questions, reason visually, and generate math animations with
          grounded support from your working context.
        </p>

        <div className="mt-10 flex items-center justify-center">
          <button
            type="button"
            onClick={handleBegin}
            disabled={transitioning}
            className="animate-pulse-glow rounded-bl-[14px] rounded-br-[6px] rounded-tl-[6px] rounded-tr-[14px] border-2 border-[hsl(var(--primary-strong))] bg-[hsl(var(--primary))] px-8 py-3 font-display text-base font-medium tracking-wide text-white transition hover:bg-[hsl(var(--primary-strong))]"
          >
            Begin
          </button>
        </div>
      </section>

      {transitioning && (
        <div className="transition-overlay fixed inset-0 z-50 flex items-center justify-center bg-[hsl(var(--background))]">
          <div className="spiro-shell flex flex-col items-center justify-center text-center">
            <div className="spiro-bounce">
              <SpirographCanvas animate size={500} />
            </div>
          </div>
        </div>
      )}
    </main>
  );
}

```

### lib/supabase/server.ts

```typescript
import { createClient } from "@supabase/supabase-js";

export function canUseSupabaseAdmin() {
  return Boolean(
    process.env.SUPABASE_URL && process.env.SUPABASE_SERVICE_ROLE_KEY,
  );
}

export function supabaseAdmin() {
  const url = process.env.SUPABASE_URL;
  const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
  if (!url || !key) {
    throw new Error("Supabase admin env vars are not configured.");
  }
  return createClient(url, key, { auth: { persistSession: false } });
}

```

### app/session/page.tsx

```typescript
"use client";

import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import SpirographCanvas from "@/components/SpirographCanvas";

function formatBytes(bytes: number) {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  if (bytes < 1024 * 1024 * 1024)
    return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}

export default function SessionPage() {
  const router = useRouter();
  const inputRef = useRef<HTMLInputElement | null>(null);
  const [files, setFiles] = useState<File[]>([]);
  const [isDragging, setIsDragging] = useState(false);
  const [isUploading, setIsUploading] = useState(false);
  const [uploadError, setUploadError] = useState<string | null>(null);
  const [transitioning, setTransitioning] = useState(false);
  const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  const addFiles = (incoming: FileList | null) => {
    if (!incoming) return;
    const next = Array.from(incoming);
    setFiles((prev) => {
      const seen = new Set(prev.map((file) => `${file.name}:${file.size}`));
      const merged = [...prev];
      for (const file of next) {
        const key = `${file.name}:${file.size}`;
        if (seen.has(key)) continue;
        seen.add(key);
        merged.push(file);
      }
      return merged;
    });
  };

  const handleStartSession = async () => {
    if (transitioning || isUploading) return;

    setUploadError(null);
    const lessonId = `lesson-${Date.now()}`;

    if (files.length > 0) {
      setIsUploading(true);
      try {
        const formData = new FormData();
        formData.set("lessonId", lessonId);
        for (const file of files) {
          formData.append("files", file);
        }

        const response = await fetch("/api/upload", {
          method: "POST",
          body: formData,
        });

        if (!response.ok) {
          const payload = (await response.json().catch(() => null)) as {
            error?: string;
          } | null;
          throw new Error(payload?.error || "File ingestion failed.");
        }

        const payload = (await response.json()) as { lessonId?: string };
        const resolvedLessonId = payload.lessonId || lessonId;
        window.localStorage.setItem("lovelace:lessonId", resolvedLessonId);
      } catch (error) {
        const message =
          error instanceof Error ? error.message : "File upload failed.";
        setUploadError(message);
        setIsUploading(false);
        return;
      } finally {
        setIsUploading(false);
      }
    } else {
      window.localStorage.setItem("lovelace:lessonId", lessonId);
    }

    setTransitioning(true);
    transitionTimerRef.current = setTimeout(() => {
      router.push("/workspace");
    }, 2000);
  };

  useEffect(() => {
    return () => {
      if (transitionTimerRef.current) {
        clearTimeout(transitionTimerRef.current);
      }
    };
  }, []);

  return (
    <main className="relative flex min-h-screen flex-col bg-[hsl(var(--background))]">
      <header className="flex items-center justify-between border-b border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-2">
        <p className="font-display text-lg font-medium tracking-tight text-[hsl(var(--primary))]">
          LoveLace
        </p>
        <Link
          href="/"
          className="rounded-bl-[8px] rounded-br-[4px] rounded-tl-[4px] rounded-tr-[8px] border-2 border-[hsl(var(--primary-strong))] bg-[hsl(var(--card))] px-3 py-1.5 font-display text-xs font-medium tracking-wide text-[hsl(var(--primary))] hover:bg-[hsl(var(--card-strong))]"
        >
          Back
        </Link>
      </header>

      <section className="flex flex-1 items-center justify-center px-6 py-12">
        <div className="mx-auto w-full max-w-xl">
          <div className="text-center">
            <h1 className="text-3xl font-semibold tracking-tight text-[hsl(var(--foreground))] sm:text-4xl">
              Customize Your Teaching Assistant
            </h1>
            <p className="mt-3 text-[hsl(var(--muted-foreground))]">
              Add lecture videos, audio, textbooks, or notes for better
              responses in your workspace.
            </p>
          </div>

          <button
            type="button"
            aria-label="Drop files here or click to pick files"
            onDragEnter={(event) => {
              event.preventDefault();
              setIsDragging(true);
            }}
            onDragOver={(event) => {
              event.preventDefault();
              setIsDragging(true);
            }}
            onDragLeave={(event) => {
              event.preventDefault();
              setIsDragging(false);
            }}
            onDrop={(event) => {
              event.preventDefault();
              setIsDragging(false);
              addFiles(event.dataTransfer.files);
            }}
            onClick={() => inputRef.current?.click()}
            className={`group relative mx-auto mt-10 flex h-72 w-72 cursor-pointer items-center justify-center rounded-full transition-all duration-300 ${
              isDragging
                ? "scale-105 bg-[hsl(var(--primary))]/20 shadow-[0_10px_40px_hsl(var(--primary)_/_0.28),inset_0_-4px_12px_hsl(var(--primary)_/_0.14)]"
                : "bg-[hsl(var(--primary))]/10 shadow-[0_4px_24px_hsl(var(--primary)_/_0.12),inset_0_-2px_8px_hsl(var(--primary)_/_0.08)] hover:scale-105 hover:bg-[hsl(var(--primary))]/15"
            }`}
          >
            <span className="absolute inset-0 overflow-hidden rounded-full">
              <span className="absolute left-6 right-6 top-3 h-[38%] rounded-full bg-gradient-to-b from-white/20 to-transparent" />
            </span>
            <span className="relative flex flex-col items-center">
              <span
                className={`font-semibold leading-none tran
[truncated — 3849 more characters]
```

### app/api/tts/route.ts

```typescript
import { type NextRequest, NextResponse } from "next/server";
import { getOpenAIForServer } from "../../../lib/rag/openai";
import { applyRateLimit } from "../../../lib/security/rate-limit";

export const runtime = "nodejs";
const MAX_TTS_TEXT_LENGTH = 1200;

interface TtsRequestBody {
  text?: string;
}

export async function POST(request: NextRequest) {
  const rateLimit = applyRateLimit(request, "api:tts", {
    windowMs: 60_000,
    maxRequests: 40,
  });
  if (!rateLimit.allowed) {
    return NextResponse.json(
      { error: "Too many text-to-speech requests. Please try again shortly." },
      {
        status: 429,
        headers: { "Retry-After": String(rateLimit.retryAfterSeconds) },
      },
    );
  }

  try {
    const body = (await request.json()) as TtsRequestBody;
    const text = body.text?.trim() || "";
    if (!text) {
      return NextResponse.json({ error: "Missing text." }, { status: 400 });
    }
    if (text.length > MAX_TTS_TEXT_LENGTH) {
      return NextResponse.json(
        {
          error: `Text too long for speech. Maximum is ${MAX_TTS_TEXT_LENGTH} characters.`,
        },
        { status: 400 },
      );
    }

    const model = process.env.OPENAI_TTS_MODEL || "gpt-4o-mini-tts";
    const voice = process.env.OPENAI_TTS_VOICE || "marin";

    const openai = getOpenAIForServer();
    const audioResponse = await openai.audio.speech.create({
      model,
      voice,
      input: text,
      response_format: "mp3",
    });

    const audioBuffer = Buffer.from(await audioResponse.arrayBuffer());
    return new NextResponse(audioBuffer, {
      status: 200,
      headers: {
        "Content-Type": "audio/mpeg",
        "Cache-Control": "no-store",
      },
    });
  } catch (error) {
    const message =
      error instanceof Error ? error.message : "Text-to-speech failed.";
    return NextResponse.json({ error: message }, { status: 500 });
  }
}

```

### app/api/upload/route.ts

```typescript
import { type NextRequest, NextResponse } from "next/server";
import { ingestLectureTranscript } from "../../../lib/lecture/ingest";
import { transcribeLectureFile } from "../../../lib/lecture/transcribe";
import { applyRateLimit } from "../../../lib/security/rate-limit";
import { extractTextFromFile } from "../../../lib/upload/extract";

export const runtime = "nodejs";

const MAX_FILES = 8;
const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024;
const MAX_FILENAME_LENGTH = 200;
const ALLOWED_DOC_EXTENSIONS = [".txt", ".md", ".pdf", ".doc", ".docx"];
const INGEST_CONCURRENCY = 2;

interface UploadResult {
  fileName: string;
  kind: "audio_video" | "document";
  chunksIndexed: number;
}

function isAudioOrVideo(file: File) {
  const fileName = file.name.toLowerCase();
  const mime = file.type || "";
  return (
    mime.startsWith("audio/") ||
    mime.startsWith("video/") ||
    fileName.endsWith(".mp3") ||
    fileName.endsWith(".wav") ||
    fileName.endsWith(".m4a") ||
    fileName.endsWith(".mp4") ||
    fileName.endsWith(".mov") ||
    fileName.endsWith(".webm")
  );
}

function isDocumentType(file: File) {
  const fileName = file.name.toLowerCase();
  return ALLOWED_DOC_EXTENSIONS.some((ext) => fileName.endsWith(ext));
}

function validateFile(file: File) {
  const fileName = file.name;
  if (!fileName || fileName.length > MAX_FILENAME_LENGTH) {
    throw new Error("Invalid file name.");
  }
  if (file.size <= 0) {
    throw new Error(`File is empty: ${fileName}`);
  }
  if (file.size > MAX_FILE_SIZE_BYTES) {
    throw new Error(
      `File too large: ${fileName}. Max size is ${Math.floor(MAX_FILE_SIZE_BYTES / (1024 * 1024))} MB.`,
    );
  }
}

async function processFile(
  file: File,
  lessonId: string,
): Promise<UploadResult> {
  const fileName = file.name;
  validateFile(file);

  if (isAudioOrVideo(file)) {
    const transcriptText = await transcribeLectureFile(file);
    const chunksIndexed = await ingestLectureTranscript({
      lessonId,
      sourceName: fileName,
      transcriptText,
      sourceType: "lecture_transcript",
    });
    return { fileName, kind: "audio_video", chunksIndexed };
  }

  if (isDocumentType(file) || file.type.startsWith("text/")) {
    const extractedText = await extractTextFromFile(file);
    const chunksIndexed = await ingestLectureTranscript({
      lessonId,
      sourceName: fileName,
      transcriptText: extractedText,
      sourceType: "notes_or_textbook",
    });
    return { fileName, kind: "document", chunksIndexed };
  }

  throw new Error(
    `Unsupported file type: ${fileName}. Supported: audio/video, txt, md, pdf, doc, docx.`,
  );
}

async function runWithConcurrency<T>(
  items: T[],
  concurrency: number,
  handler: (item: T) => Promise<UploadResult>,
) {
  const results: UploadResult[] = new Array(items.length);
  let nextIndex = 0;

  const worker = async () => {
    while (true) {
      const currentIndex = nextIndex;
      nextIndex += 1;
      if (currentIndex >= items.length) return;
      results[currentIndex] = await handler(items[currentIndex]);
    }
  };

  const workerCount = Math.min(concurrency, items.length);
  await Promise.all(Array.from({ length: workerCount }, () => worker()));
  return results;
}

export async function POST(request: NextRequest) {
  const rateLimit = applyRateLimit(request, "api:upload", {
    windowMs: 60_000,
    maxRequests: 10,
  });
  if (!rateLimit.allowed) {
    return NextResponse.json(
      { error: "Too many upload requests. Please try again shortly." },
      {
        status: 429,
        headers: { "Retry-After": String(rateLimit.retryAfterSeconds) },
      },
    );
  }

  try {
    const formData = await request.formData();
    const lessonId = (
      formData.get("lessonId") ??
      formData.get("courseId") ??
      `lesson-${Date.now()}`
    ).toString();
    const files = formData.getAll("files");

    if (files.length === 0) {
      return NextResponse.json(
        { error: "No files uploaded." },
        { status: 400 },
      );
    }
    if (files.length > MAX_FILES) {
      return NextResponse.json(
        { error: `Too many files. Maximum is ${MAX_FILES}.` },
        { status: 400 },
      );
    }

    const fileList = files as File[];
    const results = await runWithConcurrency(
      fileList,
      INGEST_CONCURRENCY,
      async (file) => processFile(file, lessonId),
    );

    return NextResponse.json({ success: true, lessonId, results });
  } catch (error) {
    const message = error instanceof Error ? error.message : "Upload failed.";
    const status =
      message.includes("Unsupported file type") ||
      message.includes("File too large") ||
      message.includes("File is empty") ||
      message.includes("Invalid file name")
        ? 400
        : 500;
    return NextResponse.json({ error: message }, { status });
  }
}

```

### app/api/chat/route.ts

```typescript
import { randomUUID } from "node:crypto";
import Anthropic from "@anthropic-ai/sdk";
import { type NextRequest, NextResponse } from "next/server";
import {
  saveAnimationJob,
  updateAnimationJob,
} from "../../../lib/animation-jobs";
import { enqueueRenderJob } from "../../../lib/render-worker";
import { applyRateLimit } from "../../../lib/security/rate-limit";
import {
  type RagChunk,
  retrieveRagContext,
} from "../../../lib/session/retrieve";
import { canUseSupabaseAdmin } from "../../../lib/supabase/server";
import { whiteboardImageToText } from "../../../lib/whiteboard/vision";

const client = new Anthropic();

interface DesmosExpression {
  id: string;
  latex: string;
}

interface DesmosAction {
  type: "add" | "remove" | "set";
  id?: string;
  latex?: string;
}

type AppMode = "graph" | "animation";

interface ChatRequestBody {
  query: string;
  currentExpressions?: DesmosExpression[];
  dimension?: "2d" | "3d";
  mode?: AppMode;
  lessonId?: string;
  whiteboardImageBase64?: string;
}

function buildGraphSystemPrompt(dimension: "2d" | "3d") {
  const modeContext =
    dimension === "3d"
      ? `The calculator is in 3D mode (Desmos Calculator3D). Use 3D-compatible expressions:
- Surfaces: z = f(x, y), e.g. "z = x^2 + y^2"
- Parametric surfaces: use parameters u, v
- 3D curves: parametric with parameter t
- Spheres: "x^2 + y^2 + z^2 = r^2"
- Do NOT use 2D-only forms like "y = f(x)" unless the user explicitly asks for a 2D cross-section.`
      : `The calculator is in 2D mode (Desmos GraphingCalculator). Use 2D expressions:
- Functions: "y = f(x)", e.g. "y = x^2"
- Implicit: "x^2 + y^2 = 9"
- Parametric: use parameter t
- Inequalities: "y > x"
- Do NOT use 3D forms like "z = f(x, y)".`;

  return `You are a math assistant that helps users interact with a Desmos graphing calculator.

You can add, remove, and modify expressions on the graph using the provided tools.

${modeContext}

Rules:
- Use Desmos-compatible LaTeX syntax (e.g. \\frac{}{}, \\sqrt{}, \\sin, \\cos, etc.)
- When the user asks to add something new, call desmos_add_expression DIRECTLY. Do NOT call desmos_get_expressions first for add-only requests.
- When the user asks to remove or change something, ALWAYS call desmos_get_expressions FIRST to see what is currently on the graph, then use the appropriate tool.
- You may call multiple tools in sequence to accomplish the user's request.
- After using tools, provide a very concise one-sentence explanation of what was graphed or changed (e.g. "Added y = x^2, a standard parabola.").
- If the user asks a question instead of requesting a graph action, respond concisely without using tools.`;
}

function buildAnimationSystemPrompt() {
  return `You are a Manim Community Edition code generator. Generate a single, complete Python script that renders correctly.

Critical Requirements:
- Output ONLY executable Python code—no markdown fences, quotes, or commentary
- Follow the official Manim Community docs: https://docs.manim.community/en/stable/
- Include all necessary imports: from manim import *
- Define exactly one Scene subclass with a construct() method

Animation Quality:
- Create a clear visual sequence where each transformation is distinct and purposeful
- Space objects to avoid visual clutter—ensure adequate margins between elements
- Use smooth transitions between animation stages (use Wait() when needed for pacing)
- Apply visual hierarchy: emphasize key concepts through size, color, or position
- Coordinate timing so objects don't animate simultaneously unless intentional

Technical Standards:
- Test that all method calls and class names match current Manim Community API
- Use proper coordinate positioning to keep all objects within frame
- Include appropriate run_time parameters for natural pacing
- Clean up objects with FadeOut or remove() when no longer needed

Output only the complete, working Python script.`;
}

function buildAnimationSummaryPrompt() {
  return `You are a mathematical narrator creating scripts for animated educational videos. Write conversational narration that builds intuition step-by-step, synchronized with the animation timing.

Core Principles:
- Explain WHY concepts work through visual intuition, not formal definitions
- Use everyday language and relatable analogies
- Maintain an enthusiastic but calm, thoughtful tone
- Let visuals do the heavy lifting—don't over-describe what's already visible

Script Requirements:
- Parse the provided Manim Python code to calculate animation duration
- Craft narration that fits within the animation length. Do not exceed the duration of the animation significantly.
- Align each sentence with a distinct visual transformation or element
- Reference on-screen elements directly: "notice this point...", "as this rotates..."
- Use short sentences during dynamic visuals, longer ones during static moments
- Do not include the # (hash) symbol or any code comments

Structure:
1. Build the concept alongside the animation
2. Conclude with the key insight or "aha" moment

Output the narration script only—no timestamps or stage directions.`;
}

function extractTextFromResponse(response: Anthropic.Message) {
  return response.content
    .filter((block): block is Anthropic.TextBlock => block.type === "text")
    .map((block) => block.text)
    .join("\n")
    .trim();
}

function sanitizePythonCode(raw: string) {
  let cleaned = raw.trim();

  // Handle fenced markdown output: ```python ... ```
  const fenced = cleaned.match(/^```(?:python)?\s*([\s\S]*?)\s*```$/i);
  if (fenced?.[1]) {
    cleaned = fenced[1].trim();
  }

  // Fallback: strip fence markers if they appear on separate lines.
  cleaned = cleaned
    .replace(/^```(?:python)?\s*$/gim, "")
    .replace(/^```\s*$/gim, "")
    .trim();

  // If the whole payload is wrapped in triple quotes, unwrap once.
  const tripleSingleQuoted = cleaned.match(/^'''[\r\n]?([\s\S]*?)[\r\n]?'''$/);
  if (tripleSingleQuoted?.[1]) {
    cleaned = tripleSingleQuoted[1].trim();
[truncated — 12516 more characters]
```

### app/api/animation/callback/route.ts

```typescript
import { put } from "@vercel/blob";
import { type NextRequest, NextResponse } from "next/server";
import { updateAnimationJob } from "../../../../lib/animation-jobs";

interface CallbackBody {
  jobId?: string;
  status?: "rendering" | "completed" | "failed";
  videoUrl?: string;
  videoBase64?: string;
  error?: string;
}

function isAuthorized(request: NextRequest) {
  const expected = process.env.RENDER_CALLBACK_SECRET;
  if (!expected) return true;

  const authHeader = request.headers.get("authorization");
  const token = authHeader?.replace(/^Bearer\s+/i, "").trim();
  return token === expected;
}

export async function POST(request: NextRequest) {
  if (!isAuthorized(request)) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const body = (await request.json()) as CallbackBody;
  if (!body.jobId || !body.status) {
    return NextResponse.json(
      { error: "Missing jobId or status" },
      { status: 400 },
    );
  }

  let completedVideoUrl = body.videoUrl;
  if (body.status === "completed" && !completedVideoUrl && body.videoBase64) {
    const binary = Buffer.from(body.videoBase64, "base64");
    const uploaded = await put(`manim-renders/${body.jobId}.mp4`, binary, {
      access: "public",
      addRandomSuffix: false,
      allowOverwrite: true,
      contentType: "video/mp4",
    });
    completedVideoUrl = uploaded.url;
  }

  const updates =
    body.status === "completed"
      ? {
          status: "completed" as const,
          videoUrl: completedVideoUrl,
          error: undefined,
        }
      : body.status === "failed"
        ? {
            status: "failed" as const,
            error: body.error || "Render failed",
          }
        : { status: "rendering" as const };

  const updated = await updateAnimationJob(body.jobId, updates);
  if (!updated) {
    return NextResponse.json({ error: "Job not found" }, { status: 404 });
  }

  return NextResponse.json({ ok: true });
}

```

[21 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]