# Project export: Perch

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: CruzHacks 2026
- Tagline: The TA on your shoulder 👀
- Devpost: https://devpost.com/software/perch-eq5sim
- GitHub: https://github.com/alokthakrar/cruzhacks26
- Video: https://www.youtube.com/embed/7DLBXFDYcok?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Result: winner ([Sponsor - Opennote] Best Productivity (Runner Up))
- Team: 5 GitHub contributor(s) — Alok Thakrar (34 commits), mdamdinb (21 commits), Jay Timbol (17 commits), Jeffuz (16 commits), Claude (4 commits)

## Devpost submission (written by the team)

### Inspiration

We've all left that tedium of working through a heinous algebraic expression or integral, and discovering you veered totally off course. As our experiences as tutors, teaching assistants, and as students, one thing we've found to prevent this and encourage student learning is a second pair of eyes. If you had someone there to nudge you in the right direction as you made mistakes in sequential problems, you're able to gain more out of the problem and more quickly gain feedback as to your mistakes, placing you perfectly in the zone of proximal development. Furthermore, it's helpful to create follow-ups as tutors: given that students are making a particular mistake, how can you tailor future questions towards that mistake?

### What it does

Not everyone has a tutor alongside them, which is why we built Perch. Perch is a second pair of eyes that stays in the background as you're solving problems through natural handwriting, but is capable of understanding and reasoning through your mistakes. Whenever users make mistakes, they're "spell-checked" and offered a hint to double-check their work, and their mistakes are tracked. These mistakes are fed into a state-model to determine their understanding of a particular concept, and then to retrieve relevant questions from user-provided resources to probe those weaknesses.

### How we built it

We used FastAPI, Next.js, and MongoDB as the primary tools for our tech stack. Lots of tools were used for individual steps: Gemini was used in tandem with other tools for document extraction, secondary handwriting processing, and question concept map generation. We used the bundled ViTs from Pix2Text for a large part of our OCR pipeline, and built our symbolic math checker off Sympy. We also relied on (a depressing amount of) energy drinks and Hippeas Bohemian Barbecue chickpea puffs. Architecturally, we first process worksheets through Gemini 2.5 Flash, using it both as a segmentation model and OCR to extract LaTeX-formatted math equations. We coalesce these by subjects, for which a subject tree is also generated through 2.5 Flash to reference off of and traverse through. Upon PDF upload, each question is tagged with a difficulty score and a position in our subject tree which the question covers. Inspired by behaviour science and learning research, such as the following: https://act-r.psy.cmu.edu/wordpress/wp-content/uploads/2012/12/893CorbettAnderson1995.pdf, we modeled concepts as a directed acyclic graph, and modelled student understanding as a Hidden Markov Model updated by the correctness of their responses. We then use this Hidden Markov Model to retrieve new questions until students have completed their mastery topic trees, which you can visualize and interact with to track your own progress! For the handwriting recognition system and the feedback generation for the HMM, we use a vision feedback loop involving Gemini for reasoning & mistake bounding box identification, and a lighter Pix2Text ViT running locally in tandem with a sympy solver for validation and verification of any hallucinations. This provides a natural checking experience and allows for user hints in a pretty seamless fashion while simultaneously stashing mistakes to feed into our HMM.

### Challenges we ran into

Even seemingly simple tasks were quite complicated! For instance, we spent a while debugging and trialing document extraction techniques, from Donut to LayoutLVM3, but found through trial by fire that VLMs, such as Gemini, worked best with very specific prompting to get segmentation tasks accurate. Creating the interaction of AI tools through the vision feedback loop also took quite a while. We also put a lot of care into streamlining our design and user experience, and getting things to look right was a large part of our project!

### Accomplishments we're proud of

Our group was filled with members from lots of different experience levels, so it was a lot of fun to meet each other to try and ship something exciting! We're quite happy with how our design spec turned out, as well as how our canvas integration feels as a user :) Our job was to try to implement a lot of the little features: small things, like prompting a hint if the user types "hint" in the handwriting section, allowing for a better user experience, not necessarily being the biggest or most flashy feature.

### What we learned

Making things is hard, and it's easier to plan things out than to actually do them! Many of our members learned different things, from learning the essentials of responsive design to learning about basic graph theory for the backend implementation, but one thing we all improved on was in collaboration; we became much more adept at planning, splitting, and combining subtasks together by the end of the project.

### What's next

There were quite a few ways we hoped to expand this idea! Integration with voice models, question generation and prompting, and expanding to more subjects (like physics, algorithms, etc) were some ideas that were next on our horizon. Another extension our group was excited about was extended RAG capabililties: what if Perch could pull in sections of notes or YouTube lectures that would help refresh users of a particular concept they erred on? Ultimately, our hope was to make Perch a platform to seamlessly sync with users' existing work and to make their lives easier, more productive, and ultimately more enriching, and these features would allow us to do so.

## README (from the GitHub repository)

# Cruzhacks26

## Frontend Setup

```bash
cd frontend
npm i
npm run dev
```

The frontend will run on http://localhost:3000

## Backend Setup

```bash
cd backend
python -m venv venv
venv\Scripts\activate  # On Windows
# source venv/bin/activate  # On Mac/Linux
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000
```

The backend will run on http://localhost:8000


## Detected evidence (automated analysis)

Indexed codebase: 76 recognized source files, 639 KB.
- CSS (language) — detected in the code
- HTML (language) — detected in the code
- Next.js (technology) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- FastAPI (technology) — claimed on Devpost, not found in the code
- Google Gemini (technology) — claimed on Devpost, not found in the code
- MongoDB (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 (85 of 85)

```
.gitignore
backend/.env.example
backend/app/__init__.py
backend/app/api/bkt.py
backend/app/auth.py
backend/app/config.py
backend/app/database.py
backend/app/main.py
backend/app/models/__init__.py
backend/app/models/answer_submission.py
backend/app/models/knowledge_graph.py
backend/app/models/pdf.py
backend/app/models/question.py
backend/app/models/session.py
backend/app/models/subject.py
backend/app/models/user_mastery.py
backend/app/models/user.py
backend/app/routers/__init__.py
backend/app/routers/analyze.py
backend/app/routers/pdf.py
backend/app/routers/subjects.py
backend/app/routers/users.py
backend/app/services/__init__.py
backend/app/services/bkt_service.py
backend/app/services/graph_service.py
backend/app/services/knowledge_graph_generator.py
backend/app/services/ocr.py
backend/app/services/pdf_extraction.py
backend/app/services/pdf_extractor.py
backend/app/services/recommendation_engine.py
backend/app/services/symbolic_validator.py
backend/check_questions.py
backend/demo_bkt.html
backend/migrate_user_id_to_created_by.py
backend/PDF_EXTRACTION_GUIDE.md
backend/pytest.ini
backend/requirements.txt
backend/scripts/__init__.py
backend/scripts/backfill_graphs.py
backend/scripts/check_graph.py
backend/scripts/seed_algebra_basic.py
backend/scripts/seed_all_subjects.py
backend/scripts/seed_calculus_graph.py
backend/scripts/seed_quadratics_graph.py
backend/scripts/test_gemini_genai.py
backend/scripts/test_gemini.py
backend/TESTING.md
backend/tests/__init__.py
backend/tests/test_analyze_api.py
backend/tests/test_bkt_service.py
backend/tests/test_bkt_submission.py
backend/tests/test_graph_service.py
backend/tests/test_integration.py
backend/tests/test_ocr_service.py
backend/tests/test_pdf_api.py
backend/tests/test_recommendation_engine.py
backend/verify_setup.py
frontend/.gitignore
frontend/app/canvas/page.tsx
frontend/app/components/info.tsx
frontend/app/components/math-background.tsx
frontend/app/components/title.tsx
frontend/app/dashboard/[folderId]/page.tsx
frontend/app/dashboard/[folderId]/progress/page.tsx
frontend/app/dashboard/[folderId]/question/[questionId]/page.tsx
frontend/app/dashboard/[folderId]/study/page.tsx
frontend/app/dashboard/page.tsx
frontend/app/globals.css
frontend/app/layout.tsx
frontend/app/page.tsx
frontend/app/pdf-upload/page.tsx
frontend/app/test/page.tsx
frontend/components/MathLine.tsx
frontend/components/ProblemInput.tsx
frontend/components/ScratchPaper.tsx
frontend/eslint.config.mjs
frontend/hooks/useOCR.ts
frontend/lib/api.ts
frontend/next.config.ts
frontend/package.json
frontend/postcss.config.mjs
frontend/README.md
frontend/TESTING.md
frontend/tsconfig.json
README.md
```

### Dependencies

- frontend/package.json: @tailwindcss/postcss@^4, @types/node@^20, @types/react@^19, @types/react-dom@^19, autoprefixer@^10.4.23, eslint@^9, eslint-config-next@16.1.3, katex@^0.16.9, lucide-react@^0.562.0, next@16.1.3, postcss@^8.5.6, react@19.2.3, react-dom@19.2.3, react-katex@^3.0.1, react-sketch-canvas@^6.2.0, tailwindcss@^4.1.18, typescript@^5

### Recent commits (newest first)

- finished landing page
- Merge remote-tracking branch 'origin/main'
- ui tweaks
- go go go go
- make ui more consistent
- small style changes + fixing button
- highlight with tooltips that pop up only if you highlight ON the box
- Merge remote changes with progress UI fixes
- Fix question counts and update progress UI
- Merge branch 'dashboard_frontend'
- bounding boxes are tough
- reenable highlights.
- Merge branch 'main' of https://github.com/alokthakrar/cruzhacks26
- Merge branch 'main' of https://github.com/alokthakrar/cruzhacks26
- Merge branch 'dashboard_frontend'
- Apply teammate's dashboard design with View Graph button
- working knowledge trees
- Improve final answer detection to support fractions
- Merge branch 'main' of https://github.com/alokthakrar/cruzhacks26
- committing changes

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

### backend/TESTING.md

```markdown
# Testing Guide

This guide covers how to test the OCR-first analysis backend.

## Setup

Install test dependencies:
```bash
pip install -r requirements.txt
```

## Running Tests

### Unit Tests

Run all unit tests:
```bash
pytest
```

Run with verbose output:
```bash
pytest -v
```

Run specific test file:
```bash
pytest tests/test_ocr_service.py -v
pytest tests/test_analyze_api.py -v
```

### Test Coverage

Run tests with coverage report:
```bash
pytest --cov=app --cov-report=html
```

View coverage report:
```bash
open htmlcov/index.html
```

## Verification Script

The `verify_setup.py` script tests the complete system end-to-end.

**Prerequisites:**
1. Backend server must be running:
   ```bash
   uvicorn app.main:app --reload --port 8000
   ```

2. (Optional) Set GEMINI_API_KEY in `.env` for full functionality

**Run verification:**
```bash
python verify_setup.py
```

**Expected output:**
```
============================================================
OCR-First Analysis Setup Verification
============================================================
🔍 Checking API health...
✅ API is healthy

🔍 Verifying model initialization...
✅ Models loaded successfully

🔍 Testing OCR analysis endpoint...
  Testing: Simple equation (x^2 + 5)
    ✅ OCR Success
       LaTeX detected: x^2 + 5...
       Confidence: 95.0%
       Feedback: This is a quadratic equation...

============================================================
Verification Summary
============================================================
API Health.................................... ✅ PASSED
Model Loading................................. ✅ PASSED
OCR Endpoint.................................. ✅ PASSED
============================================================

✅ All checks passed! System is ready to use.
```

## Test Structure

```
tests/
├── __init__.py
├── test_ocr_service.py      # Unit tests for OCR service
├── test_analyze_api.py      # API endpoint tests
└── test_integration.py      # Integration tests
```

## Test Categories

### 1. OCR Service Tests (`test_ocr_service.py`)
- Model initialization
- LaTeX extraction (success, empty, errors)
- Gemini AI analysis (success, errors, missing API key)

### 2. API Endpoint Tests (`test_analyze_api.py`)
- Valid image upload and analysis
- Invalid file types
- Empty files
- Error handling
- Response format validation

### 3. Integration Tests (`test_integration.py`)
- Full OCR → AI pipeline
- Blank image handling
- Invalid requests

## Manual Testing

### Test the API Directly

Using curl:
```bash
# Create a test image (requires ImageMagick)
convert -size 400x200 xc:white -pointsize 40 -draw "text 100,100 'x^2 + 5'" test.png

# Test the endpoint
curl -X POST http://localhost:8000/api/analyze/ocr_first \
  -F "image=@test.png" | jq
```

Using httpx (Python):
```python
import httpx
from pathlib import Path

with open("test.png", "rb") as f:
    files = {"image": ("test.png", f, "image/png")}
    response = httpx.post("http://localhost:8
[truncated — 920 more characters]
```

### frontend/TESTING.md

```markdown
# Frontend Testing Guide

Manual verification guide for the OCR-first canvas interface.

## Prerequisites

1. **Backend server running:**
   ```bash
   cd ../backend
   uvicorn app.main:app --reload --port 8000
   ```

2. **Frontend dev server running:**
   ```bash
   npm run dev
   ```

3. **Backend dependencies installed and Gemini API configured** (see backend/TESTING.md)

## Manual Testing Checklist

### 1. Canvas Rendering ✓

**Navigate to:** http://localhost:3000/canvas

**Verify:**
- [ ] Canvas loads without errors
- [ ] No hydration warnings in console
- [ ] Canvas is interactive (can draw)
- [ ] Controls are visible and responsive

### 2. Drawing Controls ✓

**Test:**
- [ ] Draw on canvas with mouse/touch
- [ ] Change stroke color - verify color changes
- [ ] Adjust stroke width slider - verify width changes
- [ ] Click "Undo" - last stroke removed
- [ ] Click "Redo" - stroke restored
- [ ] Click "Clear" - entire canvas cleared

### 3. OCR Analysis Flow ✓

**Test Case 1: Simple Expression**

1. Draw a simple expression: `x^2 + 5`
2. Click "✓ Check My Work"
3. **Verify loading states:**
   - [ ] Button shows "📖 Reading handwriting..."
   - [ ] Then shows "🤔 Analyzing logic..."
   - [ ] Controls disabled during loading
4. **Verify results appear:**
   - [ ] "👁️ What AI Saw" section displays
   - [ ] LaTeX string shown in code box
   - [ ] LaTeX rendered mathematically (not plain text)
   - [ ] OCR confidence percentage displayed
   - [ ] AI feedback section appears
   - [ ] Feedback text is relevant

**Test Case 2: Math Problem**

1. Clear canvas
2. Draw: `∫ x^2 dx`
3. Click "✓ Check My Work"
4. **Verify:**
   - [ ] LaTeX detection works for integral symbol
   - [ ] AI suggests adding "+ C"
   - [ ] Hints section appears with suggestions
   - [ ] Error type tags shown (if applicable)

**Test Case 3: Correct Answer**

1. Clear canvas
2. Draw: `2 + 2 = 4`
3. Click "✓ Check My Work"
4. **Verify:**
   - [ ] "✅ Looking Good!" or positive feedback
   - [ ] Green-tinted feedback box
   - [ ] No hints (or minimal hints)

**Test Case 4: Blank Canvas**

1. Clear canvas (leave empty)
2. Click "✓ Check My Work"
3. **Verify:**
   - [ ] Error message or "No text detected" feedback
   - [ ] Hints suggest writing more clearly
   - [ ] No crash or 500 error

### 4. Error Handling ✓

**Test Case 1: Backend Down**

1. Stop backend server
2. Draw something and click "✓ Check My Work"
3. **Verify:**
   - [ ] Red error box appears
   - [ ] Error message describes connection issue
   - [ ] UI doesn't crash

**Test Case 2: Bad Handwriting**

1. Draw messy, illegible marks
2. Click "✓ Check My Work"
3. **Verify:**
   - [ ] OCR error message shows
   - [ ] Feedback suggests clearer writing
   - [ ] No crash

### 5. UI/UX Verification ✓

**Responsive Design:**
- [ ] Page looks good on desktop (1920x1080)
- [ ] Controls wrap properly on smaller screens
- [ ] Canvas remains usable on tablet size (768px)

**Accessibility:**
- [ ] Button states are clear (enabled/dis
[truncated — 2600 more characters]
```

### frontend/package.json

```
{
  "name": "frontend",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint"
  },
  "dependencies": {
    "katex": "^0.16.9",
    "lucide-react": "^0.562.0",
    "next": "16.1.3",
    "react": "19.2.3",
    "react-dom": "19.2.3",
    "react-katex": "^3.0.1",
    "react-sketch-canvas": "^6.2.0"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "autoprefixer": "^10.4.23",
    "eslint": "^9",
    "eslint-config-next": "16.1.3",
    "postcss": "^8.5.6",
    "tailwindcss": "^4.1.18",
    "typescript": "^5"
  }
}

```

### backend/requirements.txt

```
��a i o h a p p y e y e b a l l s = = 2 . 6 . 1  
 a i o h t t p = = 3 . 1 3 . 3  
 a i o s i g n a l = = 1 . 4 . 0  
 a l b u c o r e = = 0 . 0 . 2 3  
 a l b u m e n t a t i o n s = = 1 . 4 . 2 4  
 a n n o t a t e d - d o c = = 0 . 0 . 4  
 a n n o t a t e d - t y p e s = = 0 . 7 . 0  
 a n t l r 4 - p y t h o n 3 - r u n t i m e = = 4 . 9 . 3  
 a n y i o = = 4 . 1 2 . 1  
 a t t r s = = 2 5 . 4 . 0  
 c e r t i f i = = 2 0 2 6 . 1 . 4  
 c f f i = = 2 . 0 . 0  
 c h a r s e t - n o r m a l i z e r = = 3 . 4 . 4  
 c l i c k = = 8 . 3 . 1  
 c n o c r = = 2 . 3 . 2 . 2  
 c n s t d = = 1 . 2 . 6 . 1  
 c o l o r a m a = = 0 . 4 . 6  
 c o l o r e d l o g s = = 1 5 . 0 . 1  
 c o l o r l o g = = 6 . 1 0 . 1  
 c o n t o u r p y = = 1 . 3 . 3  
 c r y p t o g r a p h y = = 4 6 . 0 . 3  
 c y c l e r = = 0 . 1 2 . 1  
 d n s p y t h o n = = 2 . 8 . 0  
 d o c l a y o u t _ y o l o = = 0 . 0 . 4  
 e a s y o c r = = 1 . 7 . 2  
 e c d s a = = 0 . 1 9 . 1  
 e i n o p s = = 0 . 8 . 1  
 e n t m a x = = 1 . 3  
 f a s t a p i = = 0 . 1 2 8 . 0  
 f i l e l o c k = = 3 . 2 0 . 3  
 f l a t b u f f e r s = = 2 5 . 1 2 . 1 9  
 f o n t t o o l s = = 4 . 6 1 . 1  
 f r o z e n l i s t = = 1 . 8 . 0  
 f s s p e c = = 2 0 2 6 . 1 . 0  
 g i t d b = = 4 . 0 . 1 2  
 G i t P y t h o n = = 3 . 1 . 4 6  
 g o o g l e - a i - g e n e r a t i v e l a n g u a g e = = 0 . 6 . 1 5  
 g o o g l e - a p i - c o r e = = 2 . 2 9 . 0  
 g o o g l e - a p i - p y t h o n - c l i e n t = = 2 . 1 8 8 . 0  
 g o o g l e - a u t h = = 2 . 4 7 . 0  
 g o o g l e - a u t h - h t t p l i b 2 = = 0 . 3 . 0  
 g o o g l e - g e n e r a t i v e a i = = 0 . 8 . 6  
 g o o g l e a p i s - c o m m o n - p r o t o s = = 1 . 7 2 . 0  
 g r p c i o = = 1 . 7 6 . 0  
 g r p c i o - s t a t u s = = 1 . 7 1 . 2  
 h 1 1 = = 0 . 1 6 . 0  
 h t t p c o r e = = 1 . 0 . 9  
 h t t p l i b 2 = = 0 . 3 1 . 1  
 h t t p t o o l s = = 0 . 7 . 1  
 h t t p x = = 0 . 2 8 . 1  
 h u g g i n g f a c e - h u b = = 0 . 3 6 . 0  
 h u m a n f r i e n d l y = = 1 0 . 0  
 i d n a = = 3 . 1 1  
 I m a g e I O = = 2 . 3 7 . 2  
 i n i c o n f i g = = 2 . 3 . 0  
 J i n j a 2 = = 3 . 1 . 6  
 k i w i s o l v e r = = 1 . 4 . 9  
 l a z y _ l o a d e r = = 0 . 4  
 l i g h t n i n g - u t i l i t i e s = = 0 . 1 5 . 2  
 M a r k u p S a f e = = 3 . 0 . 3  
 m a t p l o t l i b = = 3 . 1 0 . 8  
 m l _ d t y p e s = = 0 . 5 . 4  
 m o t o r = = 3 . 7 . 1  
 m p m a t h = = 1 . 3 . 0  
 m u l t i d i c t = = 6 . 7 . 0  
 m u n c h = = 4 . 0 . 0  
 n e t w o r k x = = 3 . 6 . 1  
 n i n j a = = 1 . 1 3 . 0  
 n u m p y = = 2 . 2 . 6  
 o m e g a c o n f = = 2 . 3 . 0  
 o n n x = = 1 . 2 0 . 1  
 o n n x r u n t i m e = = 1 . 2 3 . 2  
 o p e n c v - p y t h o n = = 4 . 1 2 . 0 . 8 8  
 o p e n c v - p y t h o n - h e a d l e s s = = 4 . 1 2 . 0 . 8 8  
 o p t i m u m = = 2 . 1 . 0  
 o p t i m u m - o n n x = = 0 . 1 . 0  
 p a c k a g i n g = = 2 5 . 0  
 p a n d a s = = 2 . 3 . 3  
 p i l l o w = = 1 2 . 1 . 0  
 p i x 2 t e x = = 0 . 1 . 4  
 p i x 2 t e x t = = 1 . 1 . 4  
 p l a t f o r m d i r s = = 4 . 5 . 1  
 p l u g g y = = 1 . 6 . 0  
 p o l a r s = = 1 . 3 7 . 1  
 p o l a r s - r u n t i m e - 3 2 = = 1 . 3 7 . 1  
 p r o p c a c h e = = 0 . 4 . 1  
 p r o t o - p l u s = = 1 . 2 7 . 0  
 p r o t o b u f = = 5 . 2 9 . 5  
 p s u t i l = = 7 . 2 . 1  
 p y - c p u i n f o = = 9 . 0 . 0  
 p y a s n 1 = = 0 . 6 . 2  
 p y a s n 1 _ m o d u l e s = = 0 . 4 . 2  
 p y c l i p p e r = = 1 . 4 . 0  
 p y c p a r s e r = = 2 . 2 3  
 p y d a n t i c = = 2 . 1 2 . 5  
 p y d a n t i c - s e t t i n g s = = 2 . 1 2 . 0  
 p y d a n t i c _ c o r e = = 2 . 4 1 . 5  
 P y g m e n t s = = 2 . 1 9 . 2  
 p y m o n g o = = 4 . 1 6 . 0  
 P y M u P D F = = 1 . 2 6 . 7  
 p y p a r s i n g = = 3 . 3 . 1  
 p y r e a d l i n e 3 = = 3 . 5 . 4  
 p y s p e l l c h e c k e r = = 0 . 8 . 4  
 p y t e s t = = 9 . 0 . 2  
 p y t e s t - a s y n c i o = = 1 . 3 . 0  
 p y t e s t - m o c k = = 3 . 1 5 . 1  
 p y t h o n - b i d i = = 0 . 6 . 7  
 p y t h o n - d a t e u t i l = = 2 . 9 . 0 . p o s t 0  
 p y t h o n - d o t e n v = = 1 . 2 . 1  
 p y t h o n - j o s e = = 3 . 5 . 0  
 p y t h o n - m u l t i p a r t = = 0 . 0 . 2 1  
 p y t o r c h - l i g h t n i n g = = 2 . 6 . 0  
 p y t z = = 2 0 2 5 . 2  
 P y Y A M L = = 6 . 0 . 3  
 r a p i d o c r = = 3 . 5 . 0  
 r e g e x = = 2 0 2 6 . 1 . 1 5  
 r e q u e s t s = = 2 . 3 2 . 5  
 r s a = = 4 . 9 . 1  
 s a f e t e n s o r s = = 0 . 7 . 0  
 s c i k i t - i m a g e = = 0 . 2 6 . 0  
 s c i p y = = 1 . 1 7 . 0  
 s e a b o r n = = 0 . 1 3 . 2  
 s e n t r y - s d k = = 2 . 4 9 . 0  
 s e t u p t o o l s = = 8 0 . 9 . 0  
 s h a p e l y = = 2 . 1 . 2  
 s i m s i m d = = 6 . 5 . 1 2  
 s i x = = 1 . 1 7 . 0  
 s m m a p = = 5 . 0 . 2  
 s t a r l e t t e = = 0 . 5 0 . 0  
 s t r i n g z i l l a = = 4 . 6 . 0  
 s y m p y = = 1 . 1 4 . 0  
 t h o p = = 0 . 1 . 1 . p o s t 2 2 0 9 0 7 2 2 3 8  
 t i f f f i l e = = 2 0 2 6 . 1 . 1 4  
 t i m m = = 0 . 5 . 4  
 t o k e n i z e r s = = 0 . 2 2 . 2  
 t o r c h = = 2 . 9 . 1  
 t o r c h m e t r i c s = = 1 . 8 . 2  
 t o r c h v i s i o n = = 0 . 2 4 . 1  
 t q d m = = 4 . 6 7 . 1  
 t r a n s f o r m e r s = = 4 . 5 7 . 6  
 t y p i n g - i n s p e c t i o n = = 0 . 4 . 2  
 t y p i n g _ e x t e n s i o n s = = 4 . 1 5 . 0  
 t z d a t a = = 2 0 2 5 . 3  
 u l t r a l y t i c s = = 8 . 4 . 5  
 u l t r a l y t i c s - t h o p = = 2 . 0 . 1 8  
 U n i d e c o d e = = 1 . 4 . 0  
 u r i t e m p l a t e = = 4 . 2 . 0  
 u r l l i b 3 = = 2 . 6 . 3  
 u v i c o r n = = 0 . 4 0 . 0  
 w a n d b = = 0 . 2 4 . 0  
 w a t c h f i l e s = = 1 . 1 . 1  
 w e b s o c k e t s = = 1 6 . 0  
 x - t r a n s f o r m e r s = = 0 . 1 5 . 0  
 y a r l = = 1 . 2 2 . 0  
 
```

### frontend/app/layout.tsx

```typescript
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Archivo, Caveat } from "next/font/google";
import "./globals.css";

const archivo = Archivo({
  variable: "--font-archivo",
  subsets: ["latin"],
  weight: ["400", "500", "600", "700"],
});

const caveat = Caveat({
  variable: "--font-caveat",
  subsets: ["latin"],
  weight: ["400", "700"],
});

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

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body
        className={`${archivo.variable} antialiased`}
        style={{ fontFamily: "var(--font-archivo), sans-serif" }}
        suppressHydrationWarning
      >
        {children}
      </body>
    </html>
  );
}

```

### backend/app/main.py

```python
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from .database import connect_to_mongo, close_mongo_connection
from .routers import users, subjects, analyze, pdf
from .api import bkt
from .services.ocr import ocr_service
from .services.pdf_extractor import pdf_extractor_service
from .services.knowledge_graph_generator import knowledge_graph_generator


@asynccontextmanager
async def lifespan(app: FastAPI):
    """Manage application lifecycle - connect/disconnect from MongoDB and load ML models."""
    await connect_to_mongo()
    ocr_service.load_models()
    pdf_extractor_service.load_model()
    knowledge_graph_generator.load_model()
    yield
    await close_mongo_connection()


app = FastAPI(
    title="Adaptive AI Tutor API",
    description="Backend API for the Adaptive AI Tutor - manages user profiles, weakness tracking, and learning sessions.",
    version="0.1.0",
    lifespan=lifespan,
)

# CORS middleware for frontend
app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "http://localhost:3000",  # React dev server
        "http://localhost:5173",  # Vite dev server
        "*"  # Allow all origins for development (file:// protocol support)
    ],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# Include routers
app.include_router(users.router, prefix="/api")
app.include_router(subjects.router, prefix="/api")
app.include_router(pdf.router, prefix="/api")
app.include_router(analyze.router, prefix="/api")
app.include_router(bkt.router)


@app.get("/health")
async def health_check():
    """Health check endpoint."""
    return {"status": "healthy"}

```

### frontend/app/page.tsx

```typescript
"use client";

import { useState, useEffect } from "react";
import Title from "./components/title";
import Info from "./components/info";
import MathBackground from "./components/math-background";
import { Archivo } from "next/font/google";

const archivo = Archivo({ subsets: ["latin"] });

export default function Home() {
  const [showOwlMessage, setShowOwlMessage] = useState(false);
  const [owlClicked, setOwlClicked] = useState(false);

  const handleOwlClick = () => {
    if (!owlClicked) {
      setShowOwlMessage(true);
      setOwlClicked(true);
    }
  };

  useEffect(() => {
    const handleClickOutside = () => {
      setShowOwlMessage(false);
    };

    if (showOwlMessage) {
      document.addEventListener("click", handleClickOutside);
      return () => document.removeEventListener("click", handleClickOutside);
    }
  }, [showOwlMessage]);

  return (
    <main
      className="paper min-h-screen"
      style={{
        display: "flex",
        flexDirection: "column",
        gap: 16,
        position: "relative",
        zIndex: 1,
      }}
    >
      <MathBackground />
      <Title onOwlClick={handleOwlClick} />
      <div
        style={{
          position: "absolute",
          left: 0,
          right: 0,
          top: "calc(100vh - 400px)",
          textAlign: "center",
          height: 68,
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          pointerEvents: "none",
        }}
      >
        {showOwlMessage && (
          <div
            className={archivo.className}
            style={{
              fontSize: 18,
              color: "#000000",
              opacity: 0,
              animation: "fadeInQuick 2.4s ease forwards",
              pointerEvents: "auto",
            }}
            onClick={(e) => e.stopPropagation()}
          >
            Watching your reasoning, not your answers.
          </div>
        )}
      </div>
      <Info
        title="Upload your math problems. Perch will help you through them."
        blurb="Perch watches your reasoning and offers guidance as you work."
        imageSrc="/dashboard.png"
        imageAlt="Perch dashboard"
      />
      <Info
        title="Learn by doing, not by copying."
        blurb="When Perch spots a mistake, it guides you back on track without just giving the answer. You work through the problem, understand the concept, and own the solution."
        imageSrc="/problem.png"
        imageAlt="Perch problem solving"
      />
      <style>{`
        @keyframes fadeInQuick {
          to { opacity: 0.45; }
        }
      `}</style>
    </main>
  );
}

```

### frontend/app/test/page.tsx

```typescript


const page = () => {
    return (
        <div>page</div>
    )
}

export default page
```

### frontend/app/canvas/page.tsx

```typescript
"use client";

import { useState, useCallback, useEffect } from "react";
import MathLine from "@/components/MathLine";
import ProblemInput from "@/components/ProblemInput";

interface ValidationResult {
  is_valid: boolean;
  error: string | null;
  explanation: string;
  warning?: string | null;
}

export default function CanvasPage() {
  const [lines, setLines] = useState<number[]>([1]);
  const [strokeColor, setStrokeColor] = useState("#000000");
  const [strokeWidth, setStrokeWidth] = useState(4);
  const [problemText, setProblemText] = useState("2x + 5 = 13");
  const [lineTexts, setLineTexts] = useState<Map<number, string>>(new Map());
  const [validationResults, setValidationResults] = useState<Map<number, ValidationResult>>(new Map());
  const [showVisualFeedback, setShowVisualFeedback] = useState(true);

  const handleStrokeEnd = (lineNumber: number) => {
    // If writing on the last line, add a new line
    if (lineNumber === lines[lines.length - 1]) {
      setLines([...lines, lineNumber + 1]);
    }
  };

  const handleTextChange = useCallback((lineNumber: number, text: string) => {
    setLineTexts(prev => {
      const newMap = new Map(prev);
      if (text) {
        newMap.set(lineNumber, text);
      } else {
        newMap.delete(lineNumber);
      }
      return newMap;
    });
  }, []);

  const handleClearValidation = useCallback((lineNumber: number) => {
    setValidationResults(prev => {
      const newMap = new Map(prev);
      newMap.delete(lineNumber);
      return newMap;
    });
  }, []);

  // Automatic validation whenever line texts change
  useEffect(() => {
    const validateSequence = async () => {
      const userExpressions = lines
        .map(lineNum => lineTexts.get(lineNum) || "")
        .filter(text => text.trim() !== "");

      if (userExpressions.length < 1) {
        setValidationResults(new Map());
        return;
      }

      try {
        // Include problem text as the starting point
        const expressions = [problemText.replace(/\s+/g, ''), ...userExpressions];

        const response = await fetch("http://localhost:8000/api/analyze/validate_sequence", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({ expressions }),
        });

        if (!response.ok) {
          console.error("Validation failed:", response.statusText);
          return;
        }

        const data = await response.json();

        // Map results to line numbers
        const resultsMap = new Map<number, ValidationResult>();

        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        data.results.forEach((result: any) => {
          const lineNumber = result.step_number;
          resultsMap.set(lineNumber, {
            is_valid: result.is_valid,
            error: result.error,
            explanation: result.explanation,
            warning: result.warning,
          });
        });

        setValidationResults(resultsMap);
      } catch (err) {
        console.error("Validation error:", err);
      }
    };

    // Debounce validation to avoid too many requests
    const timer = setTimeout(validateSequence, 500);
    return () => clearTimeout(timer);
  }, [lineTexts, lines, problemText]);

  return (
    <div 
      className="min-h-screen p-8 relative"
      style={{
        backgroundImage: `linear-gradient(rgba(200,200,200,0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(200,200,200,0.1) 1px, transparent 1px)`,
        backgroundSize: '20px 20px',
        backgroundColor: '#fafafa',
        backgroundAttachment: 'fixed'
      }}
    >
      {/* Grain overlay */}
      <div
        style={{
          position: 'absolute',
          inset: 0,
          pointerEvents: 'none',
          backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)' opacity='.3'/%3E%3C/svg%3E")`,
          backgroundRepeat: 'repeat',
          opacity: 0.9,
          mixBlendMode: 'multiply'
        }}
      />
      <div className="max-w-4xl mx-auto relative z-10">
        {/* Problem Card */}
        <div className="bg-white border border-gray-200 rounded-xl shadow-sm p-6 mb-6">
          <ProblemInput value={problemText} onChange={setProblemText} />
        </div>

        {/* Work Section */}
        <div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-visible mb-6">
          <div className="px-6 py-3 border-b border-gray-200 flex items-center justify-between">
            {/* Drawing Tools */}
            <div className="flex items-center gap-6">
              {/* Color Picker */}
              <div className="flex items-center gap-2">
                <span className="text-sm font-medium text-gray-700">Color:</span>
                <div className="flex gap-1.5">
                  {['#000000', '#EF4444', '#3B82F6', '#10B981', '#F59E0B', '#8B5CF6'].map((color) => (
                    <button
                      key={color}
                      onClick={() => setStrokeColor(color)}
                      className={`w-7 h-7 rounded-full border-2 transition-all ${
                        strokeColor === color ? 'border-gray-900 scale-110' : 'border-gray-300 hover:scale-105'
                      }`}
                      style={{ backgroundColor: color }}
                      title={color}
                    />
                  ))}
                </div>
              </div>

              {/* Stroke Width */}
              <div className="flex items-center gap-2">
                <span className="text-sm font-medium text-gray-700">Size:</span>
                <div className="flex gap-1.5">
                  {[2, 4, 6, 8].map((width) => (
                    <button
                      key={width}
   
[truncated — 2211 more characters]
```

### frontend/app/pdf-upload/page.tsx

```typescript
"use client";

import { useState, useRef, useEffect } from "react";
import { BlockMath } from "react-katex";
import "katex/dist/katex.min.css";

interface BoundingBox {
  x: number;
  y: number;
  width: number;
  height: number;
}

interface Question {
  _id: string;
  pdf_id: string;
  subject_id: string | null;
  page_number: number;
  question_number: number;
  text_content: string;
  latex_content: string | null;
  question_type: string;
  difficulty_estimate: string | null;
  bounding_box: BoundingBox;
  cropped_image: string;
  extraction_confidence: number;
}

interface Subject {
  _id: string;
  name: string;
}

interface UploadResponse {
  pdf_id: string;
  filename: string;
  subject_id: string | null;
  status: string;
  message: string;
  total_pages: number;
  question_count: number;
}

interface QuestionsResponse {
  questions: Question[];
  total: number;
  page: number;
  limit: number;
}

type LoadingState = "idle" | "uploading" | "processing" | "loading_questions";

export default function PDFUploadPage() {
  const fileInputRef = useRef<HTMLInputElement>(null);
  const [loadingState, setLoadingState] = useState<LoadingState>("idle");
  const [uploadResult, setUploadResult] = useState<UploadResponse | null>(null);
  const [questions, setQuestions] = useState<Question[]>([]);
  const [error, setError] = useState<string | null>(null);
  const [dragActive, setDragActive] = useState(false);
  const [selectedQuestion, setSelectedQuestion] = useState<Question | null>(null);
  const [subjects, setSubjects] = useState<Subject[]>([]);
  const [selectedSubjectId, setSelectedSubjectId] = useState<string>("");

  // Fetch subjects on mount
  useEffect(() => {
    const fetchSubjects = async () => {
      try {
        const response = await fetch("http://localhost:8000/api/subjects");
        if (response.ok) {
          const data = await response.json();
          setSubjects(data);
        }
      } catch (err) {
        console.error("Failed to fetch subjects:", err);
      }
    };
    fetchSubjects();
  }, []);

  const handleDrag = (e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    if (e.type === "dragenter" || e.type === "dragover") {
      setDragActive(true);
    } else if (e.type === "dragleave") {
      setDragActive(false);
    }
  };

  const handleDrop = (e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    setDragActive(false);

    if (e.dataTransfer.files && e.dataTransfer.files[0]) {
      handleFile(e.dataTransfer.files[0]);
    }
  };

  const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files && e.target.files[0]) {
      handleFile(e.target.files[0]);
    }
  };

  const handleFile = async (file: File) => {
    if (file.type !== "application/pdf") {
      setError("Please upload a PDF file");
      return;
    }

    try {
      setLoadingState("uploading");
      setError(null);
      setUploadResult(null);
      setQuestions([]);
      setSelectedQuestion(null);

      const formData = new FormData();
      formData.append("pdf", file);
      if (selectedSubjectId) {
        formData.append("subject_id", selectedSubjectId);
      }

      setLoadingState("processing");

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

      if (!response.ok) {
        const errorData = await response.json();
        throw new Error(errorData.detail || `Upload failed: ${response.statusText}`);
      }

      const result: UploadResponse = await response.json();
      setUploadResult(result);

      if (result.status === "completed" && result.question_count > 0) {
        await fetchQuestions(result.pdf_id);
      }

      setLoadingState("idle");
    } catch (err) {
      setError(err instanceof Error ? err.message : "Failed to upload PDF");
      setLoadingState("idle");
    }
  };

  const fetchQuestions = async (pdfId: string) => {
    try {
      setLoadingState("loading_questions");

      const response = await fetch(
        `http://localhost:8000/api/pdf/${pdfId}/questions?limit=100`
      );

      if (!response.ok) {
        throw new Error("Failed to fetch questions");
      }

      const data: QuestionsResponse = await response.json();
      setQuestions(data.questions);
      setLoadingState("idle");
    } catch (err) {
      setError(err instanceof Error ? err.message : "Failed to load questions");
      setLoadingState("idle");
    }
  };

  const handleReset = () => {
    setUploadResult(null);
    setQuestions([]);
    setError(null);
    setSelectedQuestion(null);
    if (fileInputRef.current) {
      fileInputRef.current.value = "";
    }
  };

  const getDifficultyColor = (difficulty: string | null) => {
    switch (difficulty) {
      case "easy":
        return "bg-green-100 text-green-800";
      case "medium":
        return "bg-yellow-100 text-yellow-800";
      case "hard":
        return "bg-red-100 text-red-800";
      default:
        return "bg-gray-100 text-gray-800";
    }
  };

  const getTypeColor = (type: string) => {
    const colors: Record<string, string> = {
      integral: "bg-purple-100 text-purple-800",
      derivative: "bg-blue-100 text-blue-800",
      equation: "bg-indigo-100 text-indigo-800",
      limit: "bg-cyan-100 text-cyan-800",
      series: "bg-teal-100 text-teal-800",
      word_problem: "bg-orange-100 text-orange-800",
    };
    return colors[type] || "bg-gray-100 text-gray-800";
  };

  return (
    <div className="min-h-screen bg-gray-100 p-8">
      <div className="max-w-6xl mx-auto">
        <h1 className="text-3xl font-bold mb-2">PDF Question Extractor</h1>
        <p className="text-gray-600 mb-6">
          Upload a PDF with math problems and AI will extract each question
        </p>

        {/* Subject Selector */}
        {!uploadResult && subjects.length > 0 && (
          <div className="mb-6 p-4 bg-white border-2 border-gray-200 ro
[truncated — 10624 more characters]
```

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