chih.yikuan commited on
Commit
054d73a
·
1 Parent(s): b0fbfe1

🚀 ExamInsight: AI-powered exam analysis for teachers

Browse files

Features:
- Google Forms/Sheets integration
- AI grading with GPT-4.1-mini
- Beautiful HTML report generation (bilingual EN/中文)
- Chart.js visualizations
- Real-time tool call logging
- Email reports via Gmail SMTP
- Docker deployment for HF Spaces

chatkit/.dockerignore ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules/
3
+ .venv/
4
+ __pycache__/
5
+ *.egg-info/
6
+
7
+ # Build artifacts
8
+ dist/
9
+ build/
10
+ *.pyc
11
+
12
+ # Local files
13
+ .env
14
+ .env.local
15
+ *.db
16
+ *.sqlite
17
+
18
+ # IDE
19
+ .vscode/
20
+ .idea/
21
+ *.swp
22
+
23
+ # Git
24
+ .git/
25
+ .gitignore
26
+
27
+ # Logs
28
+ *.log
29
+ npm-debug.log*
30
+
31
+ # Test files
32
+ coverage/
33
+ .pytest_cache/
chatkit/.gitignore CHANGED
@@ -1,3 +1,40 @@
 
1
  node_modules/
2
- .env*
3
- !.env.example
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
  node_modules/
3
+ .venv/
4
+ __pycache__/
5
+ *.egg-info/
6
+
7
+ # Environment
8
+ .env
9
+ .env.local
10
+ .env.*.local
11
+
12
+ # Database
13
+ *.db
14
+ *.sqlite
15
+ *.sqlite3
16
+
17
+ # Build
18
+ dist/
19
+ build/
20
+ *.pyc
21
+ .cache/
22
+
23
+ # IDE
24
+ .vscode/
25
+ .idea/
26
+ *.swp
27
+ *.swo
28
+ .DS_Store
29
+
30
+ # Logs
31
+ *.log
32
+ npm-debug.log*
33
+
34
+ # Test
35
+ coverage/
36
+ .pytest_cache/
37
+ .nyc_output/
38
+
39
+ # Local dev files
40
+ backend/.python-version
chatkit/DEPLOY.md ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🚀 Deploy ExamInsight to Hugging Face Spaces
2
+
3
+ ## Prerequisites
4
+
5
+ 1. Hugging Face account with access to [taboola-cz](https://huggingface.co/taboola-cz) organization
6
+ 2. Git installed
7
+ 3. OpenAI API key
8
+
9
+ ## Step 1: Create the Space
10
+
11
+ ### Option A: Via Hugging Face Web UI
12
+
13
+ 1. Go to https://huggingface.co/new-space?owner=taboola-cz
14
+ 2. Fill in:
15
+ - **Space name**: `examinsight`
16
+ - **License**: MIT
17
+ - **SDK**: Docker
18
+ - **Visibility**: Public (or Private)
19
+ 3. Click "Create Space"
20
+
21
+ ### Option B: Via Git
22
+
23
+ ```bash
24
+ # Clone the empty space
25
+ git clone https://huggingface.co/spaces/taboola-cz/examinsight
26
+ cd examinsight
27
+ ```
28
+
29
+ ## Step 2: Push the Code
30
+
31
+ ```bash
32
+ # From the examinsight-app/chatkit directory
33
+ cd /path/to/ClassLens/examinsight-app/chatkit
34
+
35
+ # Add HF as remote
36
+ git remote add hf https://huggingface.co/spaces/taboola-cz/examinsight
37
+
38
+ # Push to HF Spaces
39
+ git push hf main
40
+ ```
41
+
42
+ Or copy files manually:
43
+
44
+ ```bash
45
+ # Copy necessary files to the HF Space repo
46
+ cp -r backend frontend Dockerfile README.md .dockerignore /path/to/examinsight-space/
47
+ ```
48
+
49
+ ## Step 3: Configure Secrets
50
+
51
+ Go to your Space settings: https://huggingface.co/spaces/taboola-cz/examinsight/settings
52
+
53
+ Add these **Repository secrets**:
54
+
55
+ | Secret Name | Description | Required |
56
+ |-------------|-------------|----------|
57
+ | `OPENAI_API_KEY` | Your OpenAI API key | ✅ Yes |
58
+ | `ENCRYPTION_KEY` | Fernet key for token encryption | ✅ Yes |
59
+ | `GOOGLE_CLIENT_ID` | Google OAuth client ID | Optional |
60
+ | `GOOGLE_CLIENT_SECRET` | Google OAuth secret | Optional |
61
+ | `GOOGLE_REDIRECT_URI` | `https://taboola-cz-examinsight.hf.space/auth/callback` | Optional |
62
+ | `GMAIL_USER` | Gmail address for sending reports | Optional |
63
+ | `GMAIL_APP_PASSWORD` | Gmail App Password | Optional |
64
+ | `VITE_CHATKIT_API_DOMAIN_KEY` | ChatKit domain key | ✅ Yes |
65
+
66
+ ### Generate Encryption Key
67
+
68
+ ```bash
69
+ python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
70
+ ```
71
+
72
+ ### Get ChatKit Domain Key
73
+
74
+ 1. Go to https://platform.openai.com/settings/organization/security/domain-allowlist
75
+ 2. Add domain: `taboola-cz-examinsight.hf.space`
76
+ 3. Copy the generated domain key
77
+
78
+ ## Step 4: Update Google OAuth (if using)
79
+
80
+ 1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
81
+ 2. Edit your OAuth 2.0 Client
82
+ 3. Add to **Authorized redirect URIs**:
83
+ ```
84
+ https://taboola-cz-examinsight.hf.space/auth/callback
85
+ ```
86
+ 4. Add to **Authorized JavaScript origins**:
87
+ ```
88
+ https://taboola-cz-examinsight.hf.space
89
+ ```
90
+
91
+ ## Step 5: Deploy
92
+
93
+ The Space will automatically build when you push. Watch the build logs at:
94
+ https://huggingface.co/spaces/taboola-cz/examinsight/logs
95
+
96
+ ## File Structure for HF Spaces
97
+
98
+ ```
99
+ examinsight/
100
+ ├── Dockerfile # Multi-stage build
101
+ ├── README.md # Space config (YAML frontmatter)
102
+ ├── .dockerignore # Files to exclude from build
103
+ ├── backend/
104
+ │ ├── pyproject.toml
105
+ │ └── app/
106
+ │ ├── main.py # FastAPI + static file serving
107
+ │ ├── server.py # ChatKit agent
108
+ │ └── ...
109
+ └── frontend/
110
+ ├── package.json
111
+ └── src/
112
+ └── ...
113
+ ```
114
+
115
+ ## Troubleshooting
116
+
117
+ ### Build Failed
118
+
119
+ Check the build logs for errors:
120
+ - Missing dependencies → Update `pyproject.toml`
121
+ - Node version issues → Update Dockerfile
122
+
123
+ ### ChatKit Not Working
124
+
125
+ 1. Verify `OPENAI_API_KEY` is set in secrets
126
+ 2. Verify `VITE_CHATKIT_API_DOMAIN_KEY` is set and valid
127
+ 3. Check that domain is allowlisted in OpenAI dashboard
128
+
129
+ ### Google OAuth Not Working
130
+
131
+ 1. Verify redirect URI matches exactly
132
+ 2. Check that test users are added (if app is in testing mode)
133
+ 3. Verify Google Cloud APIs are enabled
134
+
135
+ ### Static Files Not Serving
136
+
137
+ 1. Check that frontend build succeeded (look for `/static/index.html` in container)
138
+ 2. Verify `STATIC_DIR` path in `main.py`
139
+
140
+ ## Local Testing with Docker
141
+
142
+ ```bash
143
+ # Build the image
144
+ docker build -t examinsight .
145
+
146
+ # Run with env file
147
+ docker run -p 7860:7860 --env-file .env examinsight
148
+
149
+ # Open http://localhost:7860
150
+ ```
151
+
152
+ ## Updating the Space
153
+
154
+ ```bash
155
+ # Make changes locally
156
+ # Commit and push
157
+ git add .
158
+ git commit -m "Update feature X"
159
+ git push hf main
160
+ ```
161
+
162
+ The Space will automatically rebuild.
163
+
164
+ ---
165
+
166
+ ## Quick Reference
167
+
168
+ | URL | Description |
169
+ |-----|-------------|
170
+ | https://huggingface.co/spaces/taboola-cz/examinsight | Live Space |
171
+ | https://huggingface.co/spaces/taboola-cz/examinsight/settings | Settings & Secrets |
172
+ | https://huggingface.co/spaces/taboola-cz/examinsight/logs | Build & Runtime Logs |
173
+ | https://platform.openai.com/settings/organization/security/domain-allowlist | ChatKit Domain Keys |
chatkit/Dockerfile ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ExamInsight - Hugging Face Spaces Docker Deployment
2
+ # Multi-stage build for React frontend + FastAPI backend
3
+
4
+ # =============================================================================
5
+ # Stage 1: Build React Frontend
6
+ # =============================================================================
7
+ FROM node:20-slim AS frontend-builder
8
+
9
+ WORKDIR /app/frontend
10
+
11
+ # Copy package files
12
+ COPY frontend/package*.json ./
13
+
14
+ # Install dependencies
15
+ RUN npm ci --legacy-peer-deps
16
+
17
+ # Copy source files
18
+ COPY frontend/ ./
19
+
20
+ # Build for production (output to dist/)
21
+ RUN npm run build
22
+
23
+ # =============================================================================
24
+ # Stage 2: Python Backend + Serve Frontend
25
+ # =============================================================================
26
+ FROM python:3.11-slim
27
+
28
+ # Set environment variables
29
+ ENV PYTHONUNBUFFERED=1
30
+ ENV PYTHONDONTWRITEBYTECODE=1
31
+
32
+ # Create non-root user for HF Spaces
33
+ RUN useradd -m -u 1000 user
34
+ USER user
35
+ ENV HOME=/home/user
36
+ ENV PATH=/home/user/.local/bin:$PATH
37
+
38
+ WORKDIR $HOME/app
39
+
40
+ # Install Python dependencies
41
+ COPY --chown=user backend/pyproject.toml ./
42
+ RUN pip install --no-cache-dir --upgrade pip && \
43
+ pip install --no-cache-dir .
44
+
45
+ # Copy backend code
46
+ COPY --chown=user backend/app ./app
47
+
48
+ # Copy built frontend from Stage 1
49
+ COPY --from=frontend-builder --chown=user /app/frontend/dist ./static
50
+
51
+ # Expose port (HF Spaces uses 7860)
52
+ EXPOSE 7860
53
+
54
+ # Health check
55
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
56
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health')"
57
+
58
+ # Run the server
59
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
chatkit/README.md CHANGED
@@ -1,32 +1,67 @@
1
- # ChatKit Starter
 
 
 
 
 
 
 
 
 
 
2
 
3
- Minimal Vite + React UI paired with a FastAPI backend that forwards chat
4
- requests to OpenAI through the ChatKit server library.
5
 
6
- ## Quick start
7
 
8
- ```bash
9
- npm install
10
- npm run dev
11
- ```
12
 
13
- What happens:
 
 
 
 
 
 
14
 
15
- - `npm run dev` starts the FastAPI backend on `127.0.0.1:8000` and the Vite
16
- frontend on `127.0.0.1:3000` with a proxy at `/chatkit`.
17
 
18
- ## Required environment
 
 
 
19
 
20
- - `OPENAI_API_KEY` (backend)
21
- - `VITE_CHATKIT_API_URL` (optional, defaults to `/chatkit`)
22
- - `VITE_CHATKIT_API_DOMAIN_KEY` (optional, defaults to `domain_pk_localhost_dev`)
23
 
24
- Set `OPENAI_API_KEY` in your shell or in `.env.local` at the repo root before
25
- running the backend. Register a production domain key in the OpenAI dashboard
26
- and set `VITE_CHATKIT_API_DOMAIN_KEY` when deploying.
27
 
28
- ## Customize
29
 
30
- - Update UI and connection settings in `frontend/src/lib/config.ts`.
31
- - Adjust layout in `frontend/src/components/ChatKitPanel.tsx`.
32
- - Swap the in-memory store in `backend/app/server.py` for persistence.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: ExamInsight
3
+ emoji: 📊
4
+ colorFrom: teal
5
+ colorTo: pink
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mit
10
+ short_description: AI-powered exam analysis for teachers
11
+ ---
12
 
13
+ # 📊 ExamInsight
 
14
 
15
+ **AI-powered exam analysis that transforms Google Form quiz responses into beautiful, actionable reports for teachers.**
16
 
17
+ ## ✨ Features
 
 
 
18
 
19
+ - 🔗 **Google Forms Integration**: Paste your Google Form response sheet URL
20
+ - 📈 **Automatic Grading**: Compare student answers to your answer key
21
+ - 📊 **Visual Statistics**: Interactive charts showing score distribution and per-question accuracy
22
+ - 🎯 **Detailed Explanations**: Bilingual (English + 中文) explanations for each question
23
+ - 👥 **Peer Learning Groups**: AI-suggested groupings for collaborative learning
24
+ - 📧 **Email Reports**: Send beautiful HTML reports directly to your inbox
25
+ - 🎨 **Beautiful Reports**: Dark theme with Chart.js visualizations
26
 
27
+ ## 🚀 How to Use
 
28
 
29
+ 1. **Paste your Google Form URL** (the response spreadsheet, not the form itself)
30
+ 2. **Provide your email** (for receiving the report)
31
+ 3. **Optionally add answer key** if not embedded in the form
32
+ 4. **Click analyze** and watch the AI work!
33
 
34
+ ## 🔐 Privacy
 
 
35
 
36
+ - Student names are anonymized in reports (e.g., 李X恩)
37
+ - No data is stored permanently
38
+ - OAuth tokens are encrypted
39
 
40
+ ## 🛠️ Tech Stack
41
 
42
+ - **Frontend**: React + Vite + TailwindCSS
43
+ - **Backend**: FastAPI + OpenAI ChatKit
44
+ - **Charts**: Chart.js
45
+ - **AI**: GPT-4.1-mini
46
+
47
+ ## 📝 Example Report Sections
48
+
49
+ ### 📝 Q&A Analysis (題目詳解)
50
+ - Reading passages with highlighted key terms
51
+ - Per-question explanations with concept tags
52
+ - Common mistakes and solving strategies
53
+
54
+ ### 📊 Statistics (成績統計)
55
+ - Score distribution bar chart
56
+ - Question accuracy doughnut chart
57
+ - Individual student performance table
58
+
59
+ ### 👩‍🏫 Teacher Insights (教師建議)
60
+ - Overall performance analysis
61
+ - Teaching recommendations
62
+ - AI prompt for next quiz generation
63
+ - Individual student support suggestions
64
+
65
+ ---
66
+
67
+ Built with ❤️ by [taboola-cz](https://huggingface.co/taboola-cz) for educators
chatkit/backend/app/__init__.py CHANGED
@@ -1 +1,3 @@
1
- """Minimal ChatKit backend package."""
 
 
 
1
+ """ExamInsight Backend - AI-Powered Exam Analysis for Teachers."""
2
+
3
+ __version__ = "1.0.0"
chatkit/backend/app/config.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration management for ExamInsight backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from functools import lru_cache
8
+
9
+ from dotenv import load_dotenv
10
+ from pydantic import BaseModel
11
+
12
+ # Load .env file from the chatkit directory (parent of backend)
13
+ env_path = Path(__file__).parent.parent.parent / ".env"
14
+ load_dotenv(env_path)
15
+
16
+
17
+ class Settings(BaseModel):
18
+ """Application settings loaded from environment variables."""
19
+
20
+ # OpenAI
21
+ openai_api_key: str = ""
22
+
23
+ # Google OAuth
24
+ google_client_id: str = ""
25
+ google_client_secret: str = ""
26
+ google_redirect_uri: str = "http://localhost:8000/auth/callback"
27
+
28
+ # SendGrid
29
+ sendgrid_api_key: str = ""
30
+ sendgrid_from_email: str = "examinsight@example.com"
31
+
32
+ # Database
33
+ database_url: str = "sqlite+aiosqlite:///./examinsight.db"
34
+
35
+ # Encryption key for tokens (32 bytes, base64 encoded)
36
+ encryption_key: str = ""
37
+
38
+ # Frontend URL for redirects
39
+ frontend_url: str = "http://localhost:3000"
40
+
41
+ class Config:
42
+ env_file = ".env"
43
+
44
+
45
+ @lru_cache
46
+ def get_settings() -> Settings:
47
+ """Get cached settings instance."""
48
+ return Settings(
49
+ openai_api_key=os.getenv("OPENAI_API_KEY", ""),
50
+ google_client_id=os.getenv("GOOGLE_CLIENT_ID", ""),
51
+ google_client_secret=os.getenv("GOOGLE_CLIENT_SECRET", ""),
52
+ google_redirect_uri=os.getenv("GOOGLE_REDIRECT_URI", "http://localhost:8000/auth/callback"),
53
+ sendgrid_api_key=os.getenv("SENDGRID_API_KEY", ""),
54
+ sendgrid_from_email=os.getenv("SENDGRID_FROM_EMAIL", "examinsight@example.com"),
55
+ database_url=os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./examinsight.db"),
56
+ encryption_key=os.getenv("ENCRYPTION_KEY", ""),
57
+ frontend_url=os.getenv("FRONTEND_URL", "http://localhost:3000"),
58
+ )
59
+
60
+
61
+ # Google OAuth scopes required for the application
62
+ GOOGLE_SCOPES = [
63
+ "https://www.googleapis.com/auth/spreadsheets.readonly",
64
+ "https://www.googleapis.com/auth/drive.readonly",
65
+ "https://www.googleapis.com/auth/userinfo.email",
66
+ "openid",
67
+ ]
chatkit/backend/app/database.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SQLite database for token storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import aiosqlite
6
+ import json
7
+ import base64
8
+ import os
9
+ from pathlib import Path
10
+ from datetime import datetime
11
+ from typing import Optional
12
+
13
+ from cryptography.fernet import Fernet
14
+
15
+
16
+ DATABASE_PATH = Path(__file__).parent.parent / "examinsight.db"
17
+
18
+
19
+ def get_encryption_key() -> bytes:
20
+ """Get or generate encryption key."""
21
+ key = os.getenv("ENCRYPTION_KEY", "")
22
+ if not key:
23
+ # Generate a new key for development (should be set in production)
24
+ key = Fernet.generate_key().decode()
25
+ print(f"⚠️ No ENCRYPTION_KEY set. Generated temporary key: {key}")
26
+ print("⚠️ Set this in your .env file for persistent token storage.")
27
+ return key.encode() if isinstance(key, str) else key
28
+
29
+
30
+ def encrypt_token(token_data: dict) -> str:
31
+ """Encrypt token data for storage."""
32
+ key = get_encryption_key()
33
+ f = Fernet(key)
34
+ json_data = json.dumps(token_data)
35
+ encrypted = f.encrypt(json_data.encode())
36
+ return base64.b64encode(encrypted).decode()
37
+
38
+
39
+ def decrypt_token(encrypted_data: str) -> dict:
40
+ """Decrypt stored token data."""
41
+ key = get_encryption_key()
42
+ f = Fernet(key)
43
+ encrypted = base64.b64decode(encrypted_data.encode())
44
+ decrypted = f.decrypt(encrypted)
45
+ return json.loads(decrypted.decode())
46
+
47
+
48
+ async def init_database():
49
+ """Initialize the database with required tables."""
50
+ async with aiosqlite.connect(DATABASE_PATH) as db:
51
+ await db.execute("""
52
+ CREATE TABLE IF NOT EXISTS oauth_tokens (
53
+ teacher_email TEXT PRIMARY KEY,
54
+ encrypted_tokens TEXT NOT NULL,
55
+ created_at TEXT NOT NULL,
56
+ updated_at TEXT NOT NULL
57
+ )
58
+ """)
59
+
60
+ await db.execute("""
61
+ CREATE TABLE IF NOT EXISTS analysis_reports (
62
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
63
+ teacher_email TEXT NOT NULL,
64
+ exam_title TEXT,
65
+ report_markdown TEXT,
66
+ report_json TEXT,
67
+ created_at TEXT NOT NULL
68
+ )
69
+ """)
70
+
71
+ await db.commit()
72
+
73
+
74
+ async def save_oauth_tokens(teacher_email: str, tokens: dict):
75
+ """Save encrypted OAuth tokens for a teacher."""
76
+ encrypted = encrypt_token(tokens)
77
+ now = datetime.utcnow().isoformat()
78
+
79
+ async with aiosqlite.connect(DATABASE_PATH) as db:
80
+ await db.execute("""
81
+ INSERT INTO oauth_tokens (teacher_email, encrypted_tokens, created_at, updated_at)
82
+ VALUES (?, ?, ?, ?)
83
+ ON CONFLICT(teacher_email) DO UPDATE SET
84
+ encrypted_tokens = excluded.encrypted_tokens,
85
+ updated_at = excluded.updated_at
86
+ """, (teacher_email, encrypted, now, now))
87
+ await db.commit()
88
+
89
+
90
+ async def get_oauth_tokens(teacher_email: str) -> Optional[dict]:
91
+ """Retrieve OAuth tokens for a teacher."""
92
+ async with aiosqlite.connect(DATABASE_PATH) as db:
93
+ async with db.execute(
94
+ "SELECT encrypted_tokens FROM oauth_tokens WHERE teacher_email = ?",
95
+ (teacher_email,)
96
+ ) as cursor:
97
+ row = await cursor.fetchone()
98
+ if row:
99
+ return decrypt_token(row[0])
100
+ return None
101
+
102
+
103
+ async def delete_oauth_tokens(teacher_email: str):
104
+ """Delete OAuth tokens for a teacher."""
105
+ async with aiosqlite.connect(DATABASE_PATH) as db:
106
+ await db.execute(
107
+ "DELETE FROM oauth_tokens WHERE teacher_email = ?",
108
+ (teacher_email,)
109
+ )
110
+ await db.commit()
111
+
112
+
113
+ async def save_report(teacher_email: str, exam_title: str, report_markdown: str, report_json: str):
114
+ """Save an analysis report."""
115
+ now = datetime.utcnow().isoformat()
116
+
117
+ async with aiosqlite.connect(DATABASE_PATH) as db:
118
+ await db.execute("""
119
+ INSERT INTO analysis_reports (teacher_email, exam_title, report_markdown, report_json, created_at)
120
+ VALUES (?, ?, ?, ?, ?)
121
+ """, (teacher_email, exam_title, report_markdown, report_json, now))
122
+ await db.commit()
123
+
124
+
125
+ async def get_teacher_reports(teacher_email: str, limit: int = 10) -> list[dict]:
126
+ """Get recent reports for a teacher."""
127
+ async with aiosqlite.connect(DATABASE_PATH) as db:
128
+ db.row_factory = aiosqlite.Row
129
+ async with db.execute("""
130
+ SELECT id, exam_title, report_markdown, report_json, created_at
131
+ FROM analysis_reports
132
+ WHERE teacher_email = ?
133
+ ORDER BY created_at DESC
134
+ LIMIT ?
135
+ """, (teacher_email, limit)) as cursor:
136
+ rows = await cursor.fetchall()
137
+ return [dict(row) for row in rows]
chatkit/backend/app/email_service.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Email service for sending exam reports to teachers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import smtplib
7
+ import ssl
8
+ from email.mime.text import MIMEText
9
+ from email.mime.multipart import MIMEMultipart
10
+ from typing import Optional
11
+
12
+ import markdown
13
+
14
+ from .config import get_settings
15
+
16
+
17
+ def markdown_to_html(md_content: str) -> str:
18
+ """Convert markdown to styled HTML for email."""
19
+ # Convert markdown to HTML
20
+ html_body = markdown.markdown(
21
+ md_content,
22
+ extensions=['tables', 'fenced_code', 'nl2br']
23
+ )
24
+
25
+ # Wrap in email template
26
+ return f"""
27
+ <!DOCTYPE html>
28
+ <html>
29
+ <head>
30
+ <meta charset="utf-8">
31
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
32
+ <style>
33
+ body {{
34
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
35
+ line-height: 1.6;
36
+ color: #333;
37
+ max-width: 800px;
38
+ margin: 0 auto;
39
+ padding: 20px;
40
+ background-color: #f5f5f5;
41
+ }}
42
+ .container {{
43
+ background-color: white;
44
+ padding: 30px;
45
+ border-radius: 10px;
46
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
47
+ }}
48
+ h1 {{
49
+ color: #2563eb;
50
+ border-bottom: 2px solid #2563eb;
51
+ padding-bottom: 10px;
52
+ }}
53
+ h2 {{
54
+ color: #1e40af;
55
+ margin-top: 30px;
56
+ }}
57
+ h3 {{
58
+ color: #3b82f6;
59
+ }}
60
+ table {{
61
+ width: 100%;
62
+ border-collapse: collapse;
63
+ margin: 20px 0;
64
+ }}
65
+ th, td {{
66
+ border: 1px solid #ddd;
67
+ padding: 12px;
68
+ text-align: left;
69
+ }}
70
+ th {{
71
+ background-color: #2563eb;
72
+ color: white;
73
+ }}
74
+ tr:nth-child(even) {{
75
+ background-color: #f8fafc;
76
+ }}
77
+ code {{
78
+ background-color: #f1f5f9;
79
+ padding: 2px 6px;
80
+ border-radius: 4px;
81
+ font-family: monospace;
82
+ }}
83
+ .footer {{
84
+ margin-top: 40px;
85
+ padding-top: 20px;
86
+ border-top: 1px solid #ddd;
87
+ text-align: center;
88
+ color: #666;
89
+ font-size: 0.9em;
90
+ }}
91
+ </style>
92
+ </head>
93
+ <body>
94
+ <div class="container">
95
+ {html_body}
96
+ <div class="footer">
97
+ <p>Generated by ExamInsight • AI-Powered Exam Analysis for Teachers</p>
98
+ </div>
99
+ </div>
100
+ </body>
101
+ </html>
102
+ """
103
+
104
+
105
+ async def send_email_via_gmail(
106
+ to_email: str,
107
+ subject: str,
108
+ body_markdown: str,
109
+ gmail_user: str,
110
+ gmail_app_password: str
111
+ ) -> dict:
112
+ """
113
+ Send email using Gmail SMTP.
114
+
115
+ Requires:
116
+ - Gmail account
117
+ - App Password (not your regular password)
118
+
119
+ To get an App Password:
120
+ 1. Enable 2-Step Verification on your Google account
121
+ 2. Go to https://myaccount.google.com/apppasswords
122
+ 3. Generate an app password for "Mail"
123
+ """
124
+ try:
125
+ html_content = markdown_to_html(body_markdown)
126
+
127
+ # Create message
128
+ message = MIMEMultipart("alternative")
129
+ message["Subject"] = subject
130
+ message["From"] = gmail_user
131
+ message["To"] = to_email
132
+
133
+ # Add plain text and HTML versions
134
+ part1 = MIMEText(body_markdown, "plain")
135
+ part2 = MIMEText(html_content, "html")
136
+ message.attach(part1)
137
+ message.attach(part2)
138
+
139
+ # Create secure connection and send
140
+ context = ssl.create_default_context()
141
+ with smtplib.SMTP_SSL("smtp.gmail.com", 465, context=context) as server:
142
+ server.login(gmail_user, gmail_app_password)
143
+ server.sendmail(gmail_user, to_email, message.as_string())
144
+
145
+ return {"status": "ok", "message": "Email sent via Gmail"}
146
+
147
+ except smtplib.SMTPAuthenticationError:
148
+ return {
149
+ "status": "error",
150
+ "message": "Gmail authentication failed. Check your email and app password."
151
+ }
152
+ except Exception as e:
153
+ return {
154
+ "status": "error",
155
+ "message": f"Failed to send email: {str(e)}"
156
+ }
157
+
158
+
159
+ async def send_email_via_sendgrid(
160
+ to_email: str,
161
+ subject: str,
162
+ body_markdown: str
163
+ ) -> dict:
164
+ """Send email using SendGrid API."""
165
+ settings = get_settings()
166
+
167
+ try:
168
+ from sendgrid import SendGridAPIClient
169
+ from sendgrid.helpers.mail import Mail, Email, To, Content, HtmlContent
170
+
171
+ html_content = markdown_to_html(body_markdown)
172
+
173
+ message = Mail(
174
+ from_email=Email(settings.sendgrid_from_email, "ExamInsight"),
175
+ to_emails=To(to_email),
176
+ subject=subject,
177
+ html_content=HtmlContent(html_content)
178
+ )
179
+ message.add_content(Content("text/plain", body_markdown))
180
+
181
+ sg = SendGridAPIClient(settings.sendgrid_api_key)
182
+ response = sg.send(message)
183
+
184
+ if response.status_code in (200, 201, 202):
185
+ return {"status": "ok", "message": "Email sent via SendGrid"}
186
+ else:
187
+ return {
188
+ "status": "error",
189
+ "message": f"SendGrid returned status {response.status_code}"
190
+ }
191
+
192
+ except Exception as e:
193
+ return {
194
+ "status": "error",
195
+ "message": str(e)
196
+ }
197
+
198
+
199
+ async def send_email_report(
200
+ email: str,
201
+ subject: str,
202
+ body_markdown: str
203
+ ) -> dict:
204
+ """
205
+ Send an email report to a teacher.
206
+
207
+ Tries in order:
208
+ 1. Gmail SMTP (if configured)
209
+ 2. SendGrid (if configured)
210
+ 3. Logs to console (fallback)
211
+
212
+ Returns:
213
+ {"status": "ok"} on success
214
+ {"status": "error", "message": str} on failure
215
+ """
216
+ # Try Gmail SMTP first
217
+ gmail_user = os.getenv("GMAIL_USER", "")
218
+ gmail_app_password = os.getenv("GMAIL_APP_PASSWORD", "")
219
+
220
+ if gmail_user and gmail_app_password:
221
+ result = await send_email_via_gmail(email, subject, body_markdown, gmail_user, gmail_app_password)
222
+ if result["status"] == "ok":
223
+ print(f"📧 Email sent to {email} via Gmail")
224
+ return result
225
+ else:
226
+ print(f"⚠️ Gmail failed: {result['message']}, trying fallback...")
227
+
228
+ # Try SendGrid
229
+ settings = get_settings()
230
+ if settings.sendgrid_api_key:
231
+ result = await send_email_via_sendgrid(email, subject, body_markdown)
232
+ if result["status"] == "ok":
233
+ print(f"📧 Email sent to {email} via SendGrid")
234
+ return result
235
+ else:
236
+ print(f"⚠️ SendGrid failed: {result['message']}")
237
+
238
+ # Fallback: print to console
239
+ print(f"\n{'='*60}")
240
+ print(f"📧 EMAIL (not sent - no email service configured)")
241
+ print(f"To: {email}")
242
+ print(f"Subject: {subject}")
243
+ print(f"{'='*60}")
244
+ print(body_markdown[:500] + "..." if len(body_markdown) > 500 else body_markdown)
245
+ print(f"{'='*60}\n")
246
+
247
+ return {
248
+ "status": "ok",
249
+ "message": "Email logged to console (configure GMAIL_USER/GMAIL_APP_PASSWORD or SENDGRID_API_KEY to send real emails)"
250
+ }
chatkit/backend/app/google_sheets.py ADDED
@@ -0,0 +1,408 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Google Sheets API service for fetching form responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import csv
7
+ from io import StringIO
8
+ from typing import Optional
9
+ from datetime import datetime
10
+
11
+ import httpx
12
+
13
+ from google.oauth2.credentials import Credentials
14
+ from google.auth.transport.requests import Request
15
+ from googleapiclient.discovery import build
16
+ from googleapiclient.errors import HttpError
17
+
18
+ from .database import get_oauth_tokens, save_oauth_tokens
19
+ from .config import get_settings, GOOGLE_SCOPES
20
+
21
+
22
+ def extract_sheet_id(url: str) -> Optional[str]:
23
+ """Extract the spreadsheet ID from a Google Sheets or Forms URL."""
24
+ # Google Sheets URL pattern
25
+ sheets_pattern = r'/spreadsheets/d/([a-zA-Z0-9-_]+)'
26
+ sheets_match = re.search(sheets_pattern, url)
27
+ if sheets_match:
28
+ return sheets_match.group(1)
29
+
30
+ # Google Forms URL pattern - need to find linked response sheet
31
+ forms_pattern = r'/forms/d/e?/?([a-zA-Z0-9-_]+)'
32
+ forms_match = re.search(forms_pattern, url)
33
+ if forms_match:
34
+ # For forms, we need to handle this differently
35
+ # The user should provide the response sheet URL directly
36
+ return None
37
+
38
+ return None
39
+
40
+
41
+ async def fetch_public_sheet(sheet_id: str, answer_key: Optional[dict] = None) -> dict:
42
+ """
43
+ Fetch data from a PUBLIC Google Sheet (no OAuth needed).
44
+ The sheet must be shared as "Anyone with the link can view".
45
+
46
+ Uses the CSV export endpoint which works for public sheets.
47
+ """
48
+ # Google Sheets public CSV export URL
49
+ csv_url = f"https://docs.google.com/spreadsheets/d/{sheet_id}/export?format=csv"
50
+
51
+ try:
52
+ async with httpx.AsyncClient(follow_redirects=True) as client:
53
+ response = await client.get(csv_url, timeout=30.0)
54
+
55
+ if response.status_code == 200:
56
+ csv_content = response.text
57
+
58
+ # Check if we got actual CSV data
59
+ if csv_content.startswith('<!DOCTYPE') or '<html' in csv_content[:100].lower():
60
+ return {
61
+ "error": "This sheet is not public. Please make it 'Anyone with the link can view' or use CSV export.",
62
+ "needs_auth": False
63
+ }
64
+
65
+ # Parse the CSV
66
+ result = parse_csv_responses(csv_content, answer_key)
67
+ if "error" not in result:
68
+ result["exam_title"] = f"Google Sheet {sheet_id[:8]}..."
69
+ return result
70
+
71
+ elif response.status_code == 404:
72
+ return {
73
+ "error": "Sheet not found. Please check the URL.",
74
+ "needs_auth": False
75
+ }
76
+ else:
77
+ return {
78
+ "error": f"Could not access sheet (status {response.status_code}). Make sure it's set to 'Anyone with the link can view'.",
79
+ "needs_auth": False
80
+ }
81
+
82
+ except httpx.TimeoutException:
83
+ return {
84
+ "error": "Request timed out. Please try again.",
85
+ "needs_auth": False
86
+ }
87
+ except Exception as e:
88
+ return {
89
+ "error": f"Error fetching sheet: {str(e)}",
90
+ "needs_auth": False
91
+ }
92
+
93
+
94
+ async def get_google_credentials(teacher_email: str) -> Optional[Credentials]:
95
+ """Get valid Google credentials for a teacher."""
96
+ tokens = await get_oauth_tokens(teacher_email)
97
+ if not tokens:
98
+ return None
99
+
100
+ settings = get_settings()
101
+
102
+ credentials = Credentials(
103
+ token=tokens.get("access_token"),
104
+ refresh_token=tokens.get("refresh_token"),
105
+ token_uri="https://oauth2.googleapis.com/token",
106
+ client_id=settings.google_client_id,
107
+ client_secret=settings.google_client_secret,
108
+ scopes=GOOGLE_SCOPES,
109
+ )
110
+
111
+ # Refresh if expired
112
+ if credentials.expired and credentials.refresh_token:
113
+ try:
114
+ credentials.refresh(Request())
115
+ # Save updated tokens
116
+ new_tokens = {
117
+ "access_token": credentials.token,
118
+ "refresh_token": credentials.refresh_token,
119
+ }
120
+ await save_oauth_tokens(teacher_email, new_tokens)
121
+ except Exception as e:
122
+ print(f"Error refreshing credentials: {e}")
123
+ return None
124
+
125
+ return credentials
126
+
127
+
128
+ def normalize_question_type(header: str, sample_answers: list[str]) -> str:
129
+ """Infer question type from header and sample answers."""
130
+ header_lower = header.lower()
131
+
132
+ # Check for common patterns
133
+ if any(word in header_lower for word in ['multiple choice', 'mcq', 'select']):
134
+ return 'mcq'
135
+
136
+ # Check if answers are numeric
137
+ numeric_count = 0
138
+ for answer in sample_answers[:5]: # Check first 5 answers
139
+ if answer:
140
+ try:
141
+ float(answer.replace(',', ''))
142
+ numeric_count += 1
143
+ except ValueError:
144
+ pass
145
+
146
+ if numeric_count >= len([a for a in sample_answers[:5] if a]) * 0.8:
147
+ return 'numeric'
148
+
149
+ # Check for short answers (likely MCQ) vs long answers (open)
150
+ avg_length = sum(len(a) for a in sample_answers if a) / max(len([a for a in sample_answers if a]), 1)
151
+ if avg_length < 20:
152
+ return 'mcq'
153
+
154
+ return 'open'
155
+
156
+
157
+ def extract_question_text(header: str) -> str:
158
+ """Extract clean question text from a header."""
159
+ # Remove common prefixes like "Q1:", "1.", etc.
160
+ cleaned = re.sub(r'^[Q]?\d+[\.\:\)]\s*', '', header)
161
+ # Remove parenthetical scoring info like "(2 points)"
162
+ cleaned = re.sub(r'\s*\(\d+\s*(?:points?|pts?|marks?)\)\s*$', '', cleaned, flags=re.IGNORECASE)
163
+ return cleaned.strip() or header
164
+
165
+
166
+ async def fetch_google_form_responses(
167
+ google_form_url: str,
168
+ teacher_email: str,
169
+ answer_key: Optional[dict] = None
170
+ ) -> dict:
171
+ """
172
+ Fetch responses from a Google Form's response spreadsheet.
173
+
174
+ Returns normalized exam JSON format:
175
+ {
176
+ "exam_title": str,
177
+ "questions": [{"question_id", "question_text", "type", "choices", "correct_answer"}],
178
+ "responses": [{"student_id", "student_name", "answers": {...}}]
179
+ }
180
+ """
181
+ credentials = await get_google_credentials(teacher_email)
182
+ if not credentials:
183
+ return {
184
+ "error": "Not authorized. Please connect your Google account first.",
185
+ "needs_auth": True
186
+ }
187
+
188
+ sheet_id = extract_sheet_id(google_form_url)
189
+ if not sheet_id:
190
+ return {
191
+ "error": "Could not extract spreadsheet ID from URL. Please provide a valid Google Sheets response URL.",
192
+ "needs_auth": False
193
+ }
194
+
195
+ try:
196
+ # Build the Sheets API service
197
+ service = build('sheets', 'v4', credentials=credentials)
198
+
199
+ # Get spreadsheet metadata
200
+ spreadsheet = service.spreadsheets().get(spreadsheetId=sheet_id).execute()
201
+ title = spreadsheet.get('properties', {}).get('title', 'Untitled Exam')
202
+
203
+ # Try to find "Form Responses 1" sheet, or use first sheet
204
+ sheet_name = None
205
+ for sheet in spreadsheet.get('sheets', []):
206
+ props = sheet.get('properties', {})
207
+ name = props.get('title', '')
208
+ if 'response' in name.lower() or 'form' in name.lower():
209
+ sheet_name = name
210
+ break
211
+
212
+ if not sheet_name:
213
+ sheet_name = spreadsheet['sheets'][0]['properties']['title']
214
+
215
+ # Fetch all values
216
+ result = service.spreadsheets().values().get(
217
+ spreadsheetId=sheet_id,
218
+ range=f"'{sheet_name}'!A:ZZ"
219
+ ).execute()
220
+
221
+ values = result.get('values', [])
222
+ if not values or len(values) < 2:
223
+ return {
224
+ "error": "No responses found in the spreadsheet.",
225
+ "needs_auth": False
226
+ }
227
+
228
+ # First row is headers
229
+ headers = values[0]
230
+ responses_data = values[1:]
231
+
232
+ # Identify columns
233
+ # Typically: Timestamp, Email, Name, Q1, Q2, ...
234
+ timestamp_col = None
235
+ email_col = None
236
+ name_col = None
237
+ question_cols = []
238
+
239
+ for idx, header in enumerate(headers):
240
+ header_lower = header.lower()
241
+ if 'timestamp' in header_lower or 'time' in header_lower:
242
+ timestamp_col = idx
243
+ elif 'email' in header_lower and email_col is None:
244
+ email_col = idx
245
+ elif any(word in header_lower for word in ['name', 'student', 'your name']):
246
+ name_col = idx
247
+ else:
248
+ # This is likely a question
249
+ question_cols.append((idx, header))
250
+
251
+ # Build questions list
252
+ questions = []
253
+ for q_idx, (col_idx, header) in enumerate(question_cols):
254
+ question_id = f"Q{q_idx + 1}"
255
+
256
+ # Sample answers for type detection
257
+ sample_answers = [row[col_idx] if col_idx < len(row) else "" for row in responses_data[:10]]
258
+
259
+ question = {
260
+ "question_id": question_id,
261
+ "question_text": extract_question_text(header),
262
+ "type": normalize_question_type(header, sample_answers),
263
+ "choices": [], # Would need to parse from form structure
264
+ "correct_answer": ""
265
+ }
266
+
267
+ # Apply answer key if provided
268
+ if answer_key and question_id in answer_key:
269
+ question["correct_answer"] = answer_key[question_id]
270
+
271
+ questions.append(question)
272
+
273
+ # Build responses list
274
+ responses = []
275
+ for row_idx, row in enumerate(responses_data):
276
+ student_id = f"S{row_idx + 1:02d}"
277
+
278
+ # Get student name
279
+ if name_col is not None and name_col < len(row):
280
+ student_name = row[name_col]
281
+ elif email_col is not None and email_col < len(row):
282
+ # Use email prefix as name
283
+ email = row[email_col]
284
+ student_name = email.split('@')[0] if '@' in email else email
285
+ else:
286
+ student_name = f"Student {row_idx + 1}"
287
+
288
+ # Get answers
289
+ answers = {}
290
+ for q_idx, (col_idx, _) in enumerate(question_cols):
291
+ question_id = f"Q{q_idx + 1}"
292
+ answer = row[col_idx] if col_idx < len(row) else ""
293
+ answers[question_id] = answer
294
+
295
+ responses.append({
296
+ "student_id": student_id,
297
+ "student_name": student_name,
298
+ "answers": answers
299
+ })
300
+
301
+ return {
302
+ "exam_title": title,
303
+ "questions": questions,
304
+ "responses": responses
305
+ }
306
+
307
+ except HttpError as e:
308
+ if e.resp.status == 403:
309
+ return {
310
+ "error": "Access denied. Please ensure the spreadsheet is shared with your Google account.",
311
+ "needs_auth": False
312
+ }
313
+ elif e.resp.status == 404:
314
+ return {
315
+ "error": "Spreadsheet not found. Please check the URL.",
316
+ "needs_auth": False
317
+ }
318
+ else:
319
+ return {
320
+ "error": f"Google API error: {str(e)}",
321
+ "needs_auth": False
322
+ }
323
+ except Exception as e:
324
+ return {
325
+ "error": f"Error fetching responses: {str(e)}",
326
+ "needs_auth": False
327
+ }
328
+
329
+
330
+ def parse_csv_responses(csv_content: str, answer_key: Optional[dict] = None) -> dict:
331
+ """
332
+ Parse CSV content into normalized exam format.
333
+ Fallback when OAuth is not available.
334
+ """
335
+ import csv
336
+ from io import StringIO
337
+
338
+ reader = csv.reader(StringIO(csv_content))
339
+ rows = list(reader)
340
+
341
+ if not rows or len(rows) < 2:
342
+ return {"error": "CSV must have at least a header row and one response."}
343
+
344
+ headers = rows[0]
345
+ responses_data = rows[1:]
346
+
347
+ # Same logic as Google Sheets parsing
348
+ timestamp_col = None
349
+ email_col = None
350
+ name_col = None
351
+ question_cols = []
352
+
353
+ for idx, header in enumerate(headers):
354
+ header_lower = header.lower()
355
+ if 'timestamp' in header_lower:
356
+ timestamp_col = idx
357
+ elif 'email' in header_lower and email_col is None:
358
+ email_col = idx
359
+ elif any(word in header_lower for word in ['name', 'student']):
360
+ name_col = idx
361
+ else:
362
+ question_cols.append((idx, header))
363
+
364
+ # Build questions
365
+ questions = []
366
+ for q_idx, (col_idx, header) in enumerate(question_cols):
367
+ question_id = f"Q{q_idx + 1}"
368
+ sample_answers = [row[col_idx] if col_idx < len(row) else "" for row in responses_data[:10]]
369
+
370
+ question = {
371
+ "question_id": question_id,
372
+ "question_text": extract_question_text(header),
373
+ "type": normalize_question_type(header, sample_answers),
374
+ "choices": [],
375
+ "correct_answer": answer_key.get(question_id, "") if answer_key else ""
376
+ }
377
+ questions.append(question)
378
+
379
+ # Build responses
380
+ responses = []
381
+ for row_idx, row in enumerate(responses_data):
382
+ student_id = f"S{row_idx + 1:02d}"
383
+
384
+ if name_col is not None and name_col < len(row):
385
+ student_name = row[name_col]
386
+ elif email_col is not None and email_col < len(row):
387
+ email = row[email_col]
388
+ student_name = email.split('@')[0] if '@' in email else email
389
+ else:
390
+ student_name = f"Student {row_idx + 1}"
391
+
392
+ answers = {}
393
+ for q_idx, (col_idx, _) in enumerate(question_cols):
394
+ question_id = f"Q{q_idx + 1}"
395
+ answer = row[col_idx] if col_idx < len(row) else ""
396
+ answers[question_id] = answer
397
+
398
+ responses.append({
399
+ "student_id": student_id,
400
+ "student_name": student_name,
401
+ "answers": answers
402
+ })
403
+
404
+ return {
405
+ "exam_title": "Uploaded Exam",
406
+ "questions": questions,
407
+ "responses": responses
408
+ }
chatkit/backend/app/main.py CHANGED
@@ -1,15 +1,45 @@
1
- """FastAPI entrypoint for the ChatKit starter backend."""
2
 
3
  from __future__ import annotations
4
 
 
 
 
 
 
 
 
5
  from chatkit.server import StreamingResult
6
  from fastapi import FastAPI, Request
7
  from fastapi.middleware.cors import CORSMiddleware
8
- from fastapi.responses import JSONResponse, Response, StreamingResponse
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
- from .server import StarterChatServer
11
 
12
- app = FastAPI(title="ChatKit Starter API")
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  app.add_middleware(
15
  CORSMiddleware,
@@ -19,9 +49,17 @@ app.add_middleware(
19
  allow_headers=["*"],
20
  )
21
 
22
- chatkit_server = StarterChatServer()
 
 
 
 
23
 
24
 
 
 
 
 
25
  @app.post("/chatkit")
26
  async def chatkit_endpoint(request: Request) -> Response:
27
  """Proxy the ChatKit web component payload to the server implementation."""
@@ -33,3 +71,175 @@ async def chatkit_endpoint(request: Request) -> Response:
33
  if hasattr(result, "json"):
34
  return Response(content=result.json, media_type="application/json")
35
  return JSONResponse(result)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI entrypoint for the ExamInsight backend."""
2
 
3
  from __future__ import annotations
4
 
5
+ import os
6
+ import json
7
+ import asyncio
8
+ from pathlib import Path
9
+ from contextlib import asynccontextmanager
10
+ from typing import Optional, AsyncIterator
11
+
12
  from chatkit.server import StreamingResult
13
  from fastapi import FastAPI, Request
14
  from fastapi.middleware.cors import CORSMiddleware
15
+ from fastapi.responses import JSONResponse, Response, StreamingResponse, FileResponse
16
+ from fastapi.staticfiles import StaticFiles
17
+ from pydantic import BaseModel
18
+
19
+ from .server import ExamInsightChatServer
20
+ from .oauth import router as oauth_router
21
+ from .database import init_database
22
+ from .google_sheets import fetch_google_form_responses, parse_csv_responses
23
+ from .email_service import send_email_report
24
+ from .status_tracker import get_status, subscribe, unsubscribe
25
+
26
+ # Static files directory (for production deployment)
27
+ STATIC_DIR = Path(__file__).parent.parent / "static"
28
 
 
29
 
30
+ @asynccontextmanager
31
+ async def lifespan(app: FastAPI):
32
+ """Initialize database on startup."""
33
+ await init_database()
34
+ yield
35
+
36
+
37
+ app = FastAPI(
38
+ title="ExamInsight API",
39
+ description="AI-powered exam analysis for teachers",
40
+ version="1.0.0",
41
+ lifespan=lifespan,
42
+ )
43
 
44
  app.add_middleware(
45
  CORSMiddleware,
 
49
  allow_headers=["*"],
50
  )
51
 
52
+ # Include OAuth routes
53
+ app.include_router(oauth_router)
54
+
55
+ # ChatKit server instance
56
+ chatkit_server = ExamInsightChatServer()
57
 
58
 
59
+ # =============================================================================
60
+ # ChatKit Endpoint
61
+ # =============================================================================
62
+
63
  @app.post("/chatkit")
64
  async def chatkit_endpoint(request: Request) -> Response:
65
  """Proxy the ChatKit web component payload to the server implementation."""
 
71
  if hasattr(result, "json"):
72
  return Response(content=result.json, media_type="application/json")
73
  return JSONResponse(result)
74
+
75
+
76
+ # =============================================================================
77
+ # Workflow Status Endpoints
78
+ # =============================================================================
79
+
80
+ @app.get("/api/status/{session_id}")
81
+ async def get_workflow_status(session_id: str):
82
+ """Get current workflow status for a session."""
83
+ return get_status(session_id)
84
+
85
+
86
+ async def status_stream(session_id: str) -> AsyncIterator[str]:
87
+ """Generate SSE events for status updates."""
88
+ queue = await subscribe(session_id)
89
+ try:
90
+ # Send initial status
91
+ status = get_status(session_id)
92
+ yield f"data: {json.dumps(status)}\n\n"
93
+
94
+ while True:
95
+ try:
96
+ # Wait for updates with timeout
97
+ status = await asyncio.wait_for(queue.get(), timeout=30.0)
98
+ yield f"data: {json.dumps(status)}\n\n"
99
+ except asyncio.TimeoutError:
100
+ # Send keepalive
101
+ yield f": keepalive\n\n"
102
+ except asyncio.CancelledError:
103
+ pass
104
+ finally:
105
+ unsubscribe(session_id, queue)
106
+
107
+
108
+ @app.get("/api/status/{session_id}/stream")
109
+ async def stream_workflow_status(session_id: str):
110
+ """Stream workflow status updates via Server-Sent Events (SSE)."""
111
+ return StreamingResponse(
112
+ status_stream(session_id),
113
+ media_type="text/event-stream",
114
+ headers={
115
+ "Cache-Control": "no-cache",
116
+ "Connection": "keep-alive",
117
+ "X-Accel-Buffering": "no",
118
+ }
119
+ )
120
+
121
+
122
+ # =============================================================================
123
+ # Direct API Endpoints (for non-ChatKit integrations)
124
+ # =============================================================================
125
+
126
+ class FetchResponsesRequest(BaseModel):
127
+ google_form_url: str
128
+ teacher_email: str
129
+ answer_key: Optional[dict] = None
130
+
131
+
132
+ class ParseCSVRequest(BaseModel):
133
+ csv_content: str
134
+ answer_key: Optional[dict] = None
135
+
136
+
137
+ class SendEmailRequest(BaseModel):
138
+ email: str
139
+ subject: str
140
+ body_markdown: str
141
+
142
+
143
+ @app.post("/api/fetch_google_form_responses")
144
+ async def api_fetch_responses(request: FetchResponsesRequest):
145
+ """
146
+ Fetch and normalize responses from a Google Form/Sheets URL.
147
+
148
+ This endpoint requires the teacher to have connected their Google account.
149
+ """
150
+ result = await fetch_google_form_responses(
151
+ request.google_form_url,
152
+ request.teacher_email,
153
+ request.answer_key
154
+ )
155
+ return result
156
+
157
+
158
+ @app.post("/api/parse_csv")
159
+ async def api_parse_csv(request: ParseCSVRequest):
160
+ """
161
+ Parse CSV content directly (fallback when Google OAuth is not available).
162
+ """
163
+ result = parse_csv_responses(request.csv_content, request.answer_key)
164
+ return result
165
+
166
+
167
+ @app.post("/api/send_email_report")
168
+ async def api_send_email(request: SendEmailRequest):
169
+ """
170
+ Send an exam analysis report via email.
171
+ """
172
+ result = await send_email_report(
173
+ request.email,
174
+ request.subject,
175
+ request.body_markdown
176
+ )
177
+ return result
178
+
179
+
180
+ @app.get("/api/health")
181
+ @app.get("/health")
182
+ async def health_check():
183
+ """Health check endpoint for HF Spaces."""
184
+ return {"status": "healthy", "service": "ExamInsight"}
185
+
186
+
187
+ # =============================================================================
188
+ # Static File Serving (Production)
189
+ # =============================================================================
190
+
191
+ # Mount static files if directory exists (production Docker build)
192
+ if STATIC_DIR.exists():
193
+ # Serve static assets (JS, CSS, images)
194
+ app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
195
+
196
+ @app.get("/favicon.ico")
197
+ async def favicon():
198
+ favicon_path = STATIC_DIR / "favicon.ico"
199
+ if favicon_path.exists():
200
+ return FileResponse(favicon_path)
201
+ return Response(status_code=404)
202
+
203
+ @app.get("/")
204
+ async def serve_spa():
205
+ """Serve the React SPA."""
206
+ return FileResponse(STATIC_DIR / "index.html")
207
+
208
+ @app.get("/{full_path:path}")
209
+ async def serve_spa_routes(full_path: str):
210
+ """Catch-all route to serve React SPA for client-side routing."""
211
+ # Don't serve SPA for API routes
212
+ if full_path.startswith(("api/", "auth/", "chatkit")):
213
+ return Response(status_code=404)
214
+
215
+ # Check if it's a static file
216
+ file_path = STATIC_DIR / full_path
217
+ if file_path.exists() and file_path.is_file():
218
+ return FileResponse(file_path)
219
+
220
+ # Otherwise serve index.html for SPA routing
221
+ return FileResponse(STATIC_DIR / "index.html")
222
+ else:
223
+ # Development mode - show API info
224
+ @app.get("/")
225
+ async def root():
226
+ """Root endpoint with API information (dev mode)."""
227
+ return {
228
+ "name": "ExamInsight API",
229
+ "version": "1.0.0",
230
+ "mode": "development",
231
+ "description": "AI-powered exam analysis for teachers",
232
+ "note": "Frontend served separately on port 3000",
233
+ "endpoints": {
234
+ "chatkit": "/chatkit",
235
+ "oauth_start": "/auth/start?teacher_email=...",
236
+ "oauth_callback": "/auth/callback",
237
+ "auth_status": "/auth/status?teacher_email=...",
238
+ "workflow_status": "/api/status/{session_id}",
239
+ "workflow_stream": "/api/status/{session_id}/stream",
240
+ "fetch_responses": "/api/fetch_google_form_responses",
241
+ "parse_csv": "/api/parse_csv",
242
+ "send_email": "/api/send_email_report",
243
+ "health": "/api/health",
244
+ }
245
+ }
chatkit/backend/app/oauth.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Google OAuth2 authentication endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from urllib.parse import urlencode
7
+ from typing import Optional
8
+
9
+ from fastapi import APIRouter, Request, HTTPException
10
+ from fastapi.responses import RedirectResponse
11
+ import httpx
12
+
13
+ from .config import get_settings, GOOGLE_SCOPES
14
+ from .database import save_oauth_tokens, get_oauth_tokens, delete_oauth_tokens
15
+
16
+ router = APIRouter(prefix="/auth", tags=["authentication"])
17
+
18
+
19
+ # Store state tokens temporarily (in production, use Redis or similar)
20
+ _state_store: dict[str, str] = {}
21
+
22
+
23
+ @router.get("/start")
24
+ async def start_auth(teacher_email: str, request: Request):
25
+ """
26
+ Start OAuth flow by redirecting to Google consent screen.
27
+
28
+ Query params:
29
+ teacher_email: The teacher's email address
30
+ """
31
+ settings = get_settings()
32
+
33
+ if not settings.google_client_id or not settings.google_client_secret:
34
+ raise HTTPException(
35
+ status_code=500,
36
+ detail="Google OAuth not configured. Please set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET."
37
+ )
38
+
39
+ # Generate state token for CSRF protection
40
+ import secrets
41
+ state = secrets.token_urlsafe(32)
42
+ _state_store[state] = teacher_email
43
+
44
+ # Build authorization URL
45
+ auth_params = {
46
+ "client_id": settings.google_client_id,
47
+ "redirect_uri": settings.google_redirect_uri,
48
+ "response_type": "code",
49
+ "scope": " ".join(GOOGLE_SCOPES),
50
+ "access_type": "offline", # Get refresh token
51
+ "prompt": "consent", # Always show consent screen to get refresh token
52
+ "state": state,
53
+ "login_hint": teacher_email, # Pre-fill email if possible
54
+ }
55
+
56
+ auth_url = f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(auth_params)}"
57
+ return RedirectResponse(url=auth_url)
58
+
59
+
60
+ @router.get("/callback")
61
+ async def auth_callback(code: str = None, state: str = None, error: str = None):
62
+ """
63
+ Handle OAuth callback from Google.
64
+
65
+ Query params:
66
+ code: Authorization code from Google
67
+ state: State token for CSRF verification
68
+ error: Error message if authorization failed
69
+ """
70
+ settings = get_settings()
71
+
72
+ if error:
73
+ return RedirectResponse(
74
+ url=f"{settings.frontend_url}?auth_error={error}"
75
+ )
76
+
77
+ if not code or not state:
78
+ return RedirectResponse(
79
+ url=f"{settings.frontend_url}?auth_error=missing_params"
80
+ )
81
+
82
+ # Verify state
83
+ teacher_email = _state_store.pop(state, None)
84
+ if not teacher_email:
85
+ return RedirectResponse(
86
+ url=f"{settings.frontend_url}?auth_error=invalid_state"
87
+ )
88
+
89
+ # Exchange code for tokens
90
+ try:
91
+ async with httpx.AsyncClient() as client:
92
+ token_response = await client.post(
93
+ "https://oauth2.googleapis.com/token",
94
+ data={
95
+ "client_id": settings.google_client_id,
96
+ "client_secret": settings.google_client_secret,
97
+ "code": code,
98
+ "grant_type": "authorization_code",
99
+ "redirect_uri": settings.google_redirect_uri,
100
+ },
101
+ )
102
+
103
+ if token_response.status_code != 200:
104
+ return RedirectResponse(
105
+ url=f"{settings.frontend_url}?auth_error=token_exchange_failed"
106
+ )
107
+
108
+ tokens = token_response.json()
109
+
110
+ # Verify the user's email matches
111
+ userinfo_response = await client.get(
112
+ "https://www.googleapis.com/oauth2/v2/userinfo",
113
+ headers={"Authorization": f"Bearer {tokens['access_token']}"}
114
+ )
115
+
116
+ if userinfo_response.status_code == 200:
117
+ userinfo = userinfo_response.json()
118
+ verified_email = userinfo.get("email", "")
119
+
120
+ # Use the verified email from Google
121
+ if verified_email:
122
+ teacher_email = verified_email
123
+
124
+ # Save tokens
125
+ await save_oauth_tokens(teacher_email, {
126
+ "access_token": tokens.get("access_token"),
127
+ "refresh_token": tokens.get("refresh_token"),
128
+ "expires_in": tokens.get("expires_in"),
129
+ })
130
+
131
+ return RedirectResponse(
132
+ url=f"{settings.frontend_url}?auth_success=true&email={teacher_email}"
133
+ )
134
+
135
+ except Exception as e:
136
+ return RedirectResponse(
137
+ url=f"{settings.frontend_url}?auth_error={str(e)}"
138
+ )
139
+
140
+
141
+ @router.get("/status")
142
+ async def auth_status(teacher_email: str):
143
+ """
144
+ Check if a teacher is authenticated.
145
+
146
+ Query params:
147
+ teacher_email: The teacher's email address
148
+ """
149
+ tokens = await get_oauth_tokens(teacher_email)
150
+ return {
151
+ "authenticated": tokens is not None,
152
+ "email": teacher_email if tokens else None
153
+ }
154
+
155
+
156
+ @router.post("/disconnect")
157
+ async def disconnect(teacher_email: str):
158
+ """
159
+ Disconnect a teacher's Google account.
160
+
161
+ Query params:
162
+ teacher_email: The teacher's email address
163
+ """
164
+ await delete_oauth_tokens(teacher_email)
165
+ return {"status": "ok", "message": "Google account disconnected"}
chatkit/backend/app/server.py CHANGED
@@ -1,35 +1,406 @@
1
- """ChatKit server that streams responses from a single assistant."""
2
 
3
  from __future__ import annotations
4
 
 
5
  from typing import Any, AsyncIterator
6
 
7
- from agents import Runner
8
  from chatkit.agents import AgentContext, simple_to_agent_input, stream_agent_response
9
  from chatkit.server import ChatKitServer
10
  from chatkit.types import ThreadMetadata, ThreadStreamEvent, UserMessageItem
11
 
12
  from .memory_store import MemoryStore
13
- from agents import Agent
 
 
 
14
 
15
 
16
- MAX_RECENT_ITEMS = 30
17
- MODEL = "gpt-4.1-mini"
18
 
 
 
19
 
20
- assistant_agent = Agent[AgentContext[dict[str, Any]]](
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  model=MODEL,
22
- name="Starter Assistant",
23
- instructions=(
24
- "You are a concise, helpful assistant. "
25
- "Keep replies short and focus on directly answering "
26
- "the user's request."
27
- ),
 
 
 
28
  )
29
 
30
 
31
- class StarterChatServer(ChatKitServer[dict[str, Any]]):
32
- """Server implementation that keeps conversation state in memory."""
 
 
 
 
33
 
34
  def __init__(self) -> None:
35
  self.store: MemoryStore = MemoryStore()
@@ -41,6 +412,10 @@ class StarterChatServer(ChatKitServer[dict[str, Any]]):
41
  item: UserMessageItem | None,
42
  context: dict[str, Any],
43
  ) -> AsyncIterator[ThreadStreamEvent]:
 
 
 
 
44
  items_page = await self.store.load_thread_items(
45
  thread.id,
46
  after=None,
@@ -58,7 +433,7 @@ class StarterChatServer(ChatKitServer[dict[str, Any]]):
58
  )
59
 
60
  result = Runner.run_streamed(
61
- assistant_agent,
62
  agent_input,
63
  context=agent_context,
64
  )
 
1
+ """ExamInsight ChatKit server with exam analysis agent and tools."""
2
 
3
  from __future__ import annotations
4
 
5
+ import json
6
  from typing import Any, AsyncIterator
7
 
8
+ from agents import Runner, Agent, function_tool
9
  from chatkit.agents import AgentContext, simple_to_agent_input, stream_agent_response
10
  from chatkit.server import ChatKitServer
11
  from chatkit.types import ThreadMetadata, ThreadStreamEvent, UserMessageItem
12
 
13
  from .memory_store import MemoryStore
14
+ from .google_sheets import fetch_google_form_responses, parse_csv_responses, fetch_public_sheet, extract_sheet_id
15
+ from .email_service import send_email_report
16
+ from .database import save_report
17
+ from .status_tracker import update_status, add_reasoning_step, WorkflowStep, reset_status
18
 
19
 
20
+ MAX_RECENT_ITEMS = 50
21
+ MODEL = "gpt-4.1-mini" # Using mini for cost efficiency (~10x cheaper)
22
 
23
+ # Current session ID (simplified - in production use proper session management)
24
+ _current_session_id = "default"
25
 
26
+
27
+ def set_session_id(session_id: str):
28
+ global _current_session_id
29
+ _current_session_id = session_id
30
+
31
+
32
+ # =============================================================================
33
+ # Tool Definitions with Status Tracking
34
+ # =============================================================================
35
+
36
+ @function_tool
37
+ async def fetch_responses(
38
+ google_form_url: str,
39
+ teacher_email: str = "",
40
+ answer_key_json: str = ""
41
+ ) -> str:
42
+ """
43
+ Fetch student responses from a Google Form/Sheets URL.
44
+ First tries to fetch as a public sheet (no auth needed).
45
+ If that fails and teacher_email is provided, tries OAuth.
46
+
47
+ Args:
48
+ google_form_url: The URL of the Google Form response spreadsheet
49
+ teacher_email: The teacher's email address for authentication (optional for public sheets)
50
+ answer_key_json: Optional JSON string with correct answers, e.g. {"Q1": "4", "Q2": "acceleration"}
51
+
52
+ Returns:
53
+ JSON string with normalized exam data including questions and student responses
54
+ """
55
+ # Log tool call
56
+ await add_reasoning_step(_current_session_id, "tool", f"🔧 Tool: fetch_responses(url={google_form_url[:50]}...)", "active")
57
+
58
+ answer_key = None
59
+ if answer_key_json:
60
+ try:
61
+ answer_key = json.loads(answer_key_json)
62
+ except json.JSONDecodeError:
63
+ pass
64
+
65
+ # First, try to fetch as a public sheet (no OAuth needed)
66
+ sheet_id = extract_sheet_id(google_form_url)
67
+ if sheet_id:
68
+ await add_reasoning_step(_current_session_id, "tool", "📥 Downloading spreadsheet data...", "active")
69
+ public_result = await fetch_public_sheet(sheet_id, answer_key)
70
+ if "error" not in public_result:
71
+ await add_reasoning_step(_current_session_id, "result", "✅ Data fetched successfully", "completed")
72
+ return json.dumps(public_result, indent=2)
73
+
74
+ # If public fetch failed and we have teacher email, try OAuth
75
+ if teacher_email:
76
+ await add_reasoning_step(_current_session_id, "tool", "🔐 Using OAuth to access private sheet...", "active")
77
+ result = await fetch_google_form_responses(google_form_url, teacher_email, answer_key)
78
+ if "error" not in result:
79
+ await add_reasoning_step(_current_session_id, "result", "✅ Data fetched via OAuth", "completed")
80
+ return json.dumps(result, indent=2)
81
+ else:
82
+ # Return the public sheet error with helpful message
83
+ public_result["hint"] = "To access private sheets, provide your email and connect your Google account."
84
+ await add_reasoning_step(_current_session_id, "error", "❌ Could not access sheet", "completed")
85
+ return json.dumps(public_result, indent=2)
86
+
87
+ await add_reasoning_step(_current_session_id, "error", "❌ Invalid URL format", "completed")
88
+ return json.dumps({
89
+ "error": "Could not extract sheet ID from URL. Please provide a valid Google Sheets URL.",
90
+ "hint": "URL should look like: https://docs.google.com/spreadsheets/d/SHEET_ID/edit"
91
+ }, indent=2)
92
+
93
+
94
+ @function_tool
95
+ async def parse_csv_data(
96
+ csv_content: str,
97
+ answer_key_json: str = ""
98
+ ) -> str:
99
+ """
100
+ Parse CSV content directly (for manual upload fallback).
101
+
102
+ Args:
103
+ csv_content: The raw CSV content with headers and student responses
104
+ answer_key_json: Optional JSON string with correct answers
105
+
106
+ Returns:
107
+ JSON string with normalized exam data
108
+ """
109
+ await add_reasoning_step(_current_session_id, "tool", "🔧 Tool: parse_csv_data()", "active")
110
+
111
+ answer_key = None
112
+ if answer_key_json:
113
+ try:
114
+ answer_key = json.loads(answer_key_json)
115
+ except json.JSONDecodeError:
116
+ pass
117
+
118
+ result = parse_csv_responses(csv_content, answer_key)
119
+
120
+ await add_reasoning_step(_current_session_id, "result", "✅ CSV parsed successfully", "completed")
121
+
122
+ return json.dumps(result, indent=2)
123
+
124
+
125
+ @function_tool
126
+ async def send_report_email(
127
+ email: str,
128
+ subject: str,
129
+ body_markdown: str
130
+ ) -> str:
131
+ """
132
+ Send the exam analysis report to the teacher via email.
133
+
134
+ Args:
135
+ email: The teacher's email address
136
+ subject: Email subject line
137
+ body_markdown: The full report in markdown format
138
+
139
+ Returns:
140
+ JSON string with status of the email send operation
141
+ """
142
+ await add_reasoning_step(_current_session_id, "tool", f"🔧 Tool: send_report_email(to={email})", "active")
143
+
144
+ result = await send_email_report(email, subject, body_markdown)
145
+
146
+ if result.get("status") == "ok":
147
+ await add_reasoning_step(_current_session_id, "result", "✅ Email sent successfully!", "completed")
148
+ else:
149
+ await add_reasoning_step(_current_session_id, "error", f"❌ Email failed: {result.get('message', 'unknown error')}", "completed")
150
+
151
+ return json.dumps(result)
152
+
153
+
154
+ @function_tool
155
+ async def save_analysis_report(
156
+ teacher_email: str,
157
+ exam_title: str,
158
+ report_markdown: str,
159
+ report_json: str
160
+ ) -> str:
161
+ """
162
+ Save the analysis report to the database for future reference.
163
+
164
+ Args:
165
+ teacher_email: The teacher's email address
166
+ exam_title: Title of the exam
167
+ report_markdown: The report in markdown format
168
+ report_json: The structured report data in JSON format
169
+
170
+ Returns:
171
+ Confirmation message
172
+ """
173
+ await add_reasoning_step(_current_session_id, "tool", f"🔧 Tool: save_analysis_report(exam={exam_title})", "active")
174
+ await add_reasoning_step(_current_session_id, "result", "✅ Report saved to database", "completed")
175
+
176
+ await save_report(teacher_email, exam_title, report_markdown, report_json)
177
+ return json.dumps({"status": "saved", "message": "Report saved successfully"})
178
+
179
+
180
+ @function_tool
181
+ async def log_reasoning(thought: str) -> str:
182
+ """
183
+ Log your current thinking or reasoning step.
184
+ Use this to show the user what you're analyzing or planning.
185
+
186
+ Args:
187
+ thought: Your current thought, reasoning, or task breakdown
188
+
189
+ Returns:
190
+ Confirmation
191
+ """
192
+ await add_reasoning_step(_current_session_id, "thinking", thought, "completed")
193
+ return json.dumps({"status": "logged"})
194
+
195
+
196
+ # =============================================================================
197
+ # ExamInsight Agent Definition
198
+ # =============================================================================
199
+
200
+ EXAMINSIGHT_INSTRUCTIONS = """You are ExamInsight, an AI teaching assistant that creates beautiful, comprehensive exam analysis reports for teachers.
201
+
202
+ ## Your Core Mission
203
+
204
+ Transform raw Google Form quiz responses into professional, teacher-ready HTML reports with:
205
+ 1. Detailed question analysis with bilingual explanations (English + 中文)
206
+ 2. Visual statistics with Chart.js charts
207
+ 3. Actionable teaching recommendations
208
+ 4. Individual student support suggestions
209
+
210
+ ## IMPORTANT: Show Your Reasoning
211
+
212
+ Use `log_reasoning` to show your thinking process. Call it at key decision points:
213
+ - `log_reasoning("Task: Analyze 12 students × 5 questions, generate HTML report")`
214
+ - `log_reasoning("Grading Q1: 5 correct (42%), Q2: 10 correct (83%)")`
215
+ - `log_reasoning("Pattern: Many students confused 'is' vs 'wants to be'")`
216
+
217
+ ## Workflow
218
+
219
+ ### Step 1: Fetch & Analyze Data
220
+ Use `fetch_responses` with the Google Form URL.
221
+ Log observations: `log_reasoning("Found 12 students, 5 questions. Q1-Q4 multiple choice, Q5 writing.")`
222
+
223
+ ### Step 2: Grade & Calculate Statistics
224
+ - Calculate per-question accuracy rates
225
+ - Compute class average, highest, lowest scores
226
+ - Identify score distribution bands
227
+ Log: `log_reasoning("Stats: Avg=20.8/70, Q1=42%, Q2=83%, Q3=50%, Q4=83%")`
228
+
229
+ ### Step 3: Identify Patterns & Misconceptions
230
+ Analyze common mistakes and their root causes.
231
+ Log: `log_reasoning("Misconception: Students confused Bella (IS teacher) with Eddie (WANTS TO BE teacher)")`
232
+
233
+ ### Step 4: Generate HTML Report
234
+
235
+ **CRITICAL: Generate a complete, self-contained HTML file** that includes:
236
+
237
+ #### Required HTML Structure:
238
+
239
+ ```html
240
+ <!DOCTYPE html>
241
+ <html lang="zh-TW">
242
+ <head>
243
+ <meta charset="UTF-8">
244
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
245
+ <title>[Quiz Title] Report</title>
246
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
247
+ <style>
248
+ /* Dark theme with coral/teal accents */
249
+ :root {
250
+ --bg-primary: #0f1419;
251
+ --bg-secondary: #1a2332;
252
+ --bg-card: #212d3b;
253
+ --accent-coral: #ff6b6b;
254
+ --accent-teal: #4ecdc4;
255
+ --accent-gold: #ffd93d;
256
+ --accent-purple: #a855f7;
257
+ --text-primary: #f1f5f9;
258
+ --text-secondary: #94a3b8;
259
+ --border-color: #334155;
260
+ }
261
+ /* Include comprehensive CSS for all elements */
262
+ </style>
263
+ </head>
264
+ <body>
265
+ <!-- Header with quiz title and Google Form link -->
266
+ <!-- Navigation tabs: Q&A, Statistics, Teacher Insights -->
267
+ <!-- Main content sections -->
268
+ <!-- Chart.js scripts -->
269
+ </body>
270
+ </html>
271
+ ```
272
+
273
+ #### Section 1: 📝 Q&A Analysis (題目詳解)
274
+
275
+ For EACH question, include:
276
+ 1. **Question number badge** (gradient circle)
277
+ 2. **Question text** (bilingual: English + Chinese)
278
+ 3. **Answer box** with correct answer highlighted
279
+ 4. **Concept tags** showing skills tested (e.g., 🎯 細節理解, 🔍 推論能力)
280
+ 5. **Detailed explanation** (詳解) with:
281
+ - What the question tests (這題測試什麼?)
282
+ - Key solving strategy (解題關鍵)
283
+ - Common mistakes (常見錯誤)
284
+ - Learning points (學習重點)
285
+
286
+ If there's a reading passage, include it with `<span class="highlight">` for key terms.
287
+
288
+ #### Section 2: 📊 Statistics (成績統計)
289
+
290
+ Include:
291
+ 1. **Stats grid**: Total students, Average, Highest, Lowest
292
+ 2. **Score distribution bar chart** (Chart.js)
293
+ 3. **Question accuracy doughnut chart** (Chart.js)
294
+ 4. **Student details table** with columns:
295
+ - Name (姓名), Class (班級), Score, Q1-Qn status (✅/❌)
296
+ - Color-coded score badges: high (teal), mid (gold), low (coral)
297
+
298
+ #### Section 3: 👩‍🏫 Teacher Insights (教師分析與建議)
299
+
300
+ Include:
301
+ 1. **Overall Performance Analysis** (整體表現分析)
302
+ - Summary paragraph
303
+ - Per-question breakdown with accuracy % and issues identified
304
+
305
+ 2. **Teaching Recommendations** (教學改進建議)
306
+ - Specific, actionable suggestions based on data
307
+ - Priority areas to address
308
+
309
+ 3. **Next Exam Prompt** (下次出題 Prompt 建議)
310
+ - Ready-to-use AI prompt for generating the next quiz
311
+ - Include specific areas to reinforce based on this quiz's results
312
+
313
+ 4. **Individual Support** (個別輔導建議)
314
+ - 🔴 Students needing attention (0 or very low scores)
315
+ - 🟡 Students needing reinforcement
316
+ - 🟢 High performers (potential peer tutors)
317
+
318
+ #### Chart.js Implementation
319
+
320
+ Include these chart configurations:
321
+ ```javascript
322
+ // Score Distribution Chart
323
+ new Chart(document.getElementById('scoreChart').getContext('2d'), {
324
+ type: 'bar',
325
+ data: {
326
+ labels: ['0分', '10分', '20分', '30分', '40分+'],
327
+ datasets: [{
328
+ data: [/* distribution counts */],
329
+ backgroundColor: ['rgba(255,107,107,0.7)', ...],
330
+ borderRadius: 8
331
+ }]
332
+ },
333
+ options: { /* dark theme styling */ }
334
+ });
335
+
336
+ // Question Accuracy Chart
337
+ new Chart(document.getElementById('questionChart').getContext('2d'), {
338
+ type: 'doughnut',
339
+ data: {
340
+ labels: ['Q1 (XX%)', 'Q2 (XX%)', ...],
341
+ datasets: [{ data: [/* accuracy rates */] }]
342
+ }
343
+ });
344
+ ```
345
+
346
+ ### Step 5: Send Report
347
+ Use `send_report_email` with the complete HTML as the body.
348
+
349
+ ## Output Format
350
+
351
+ When generating the report:
352
+ 1. First display a brief summary in chat
353
+ 2. Then output the complete HTML in a code block
354
+ 3. Offer to email it to the teacher
355
+
356
+ ## Design Principles
357
+
358
+ - **Dark theme** with coral (#ff6b6b) and teal (#4ecdc4) accents
359
+ - **Bilingual** content (English + Traditional Chinese)
360
+ - **Responsive** layout for mobile viewing
361
+ - **Smooth scrolling** navigation
362
+ - **Hover effects** on cards and tables
363
+ - **Gradient accents** for visual interest
364
+
365
+ ## Handling Edge Cases
366
+
367
+ - No answer key: Infer patterns or ask teacher for correct answers
368
+ - Private sheet: Guide teacher through OAuth connection
369
+ - Writing questions: Provide rubric and sample excellent responses
370
+ - Few students: Adjust charts to prevent visual distortion
371
+
372
+ ## Privacy
373
+
374
+ - Use partial names (e.g., 李X恩) in reports
375
+ - Never expose full student identifiers
376
+ - Group low performers sensitively
377
+
378
+ Start by greeting the teacher and asking for:
379
+ 1. Google Form/Sheet URL
380
+ 2. Their email (for sending the report)
381
+ 3. Optionally: correct answers if not embedded in the form"""
382
+
383
+
384
+ examinsight_agent = Agent[AgentContext[dict[str, Any]]](
385
  model=MODEL,
386
+ name="ExamInsight",
387
+ instructions=EXAMINSIGHT_INSTRUCTIONS,
388
+ tools=[
389
+ fetch_responses,
390
+ parse_csv_data,
391
+ send_report_email,
392
+ save_analysis_report,
393
+ log_reasoning,
394
+ ],
395
  )
396
 
397
 
398
+ # =============================================================================
399
+ # ChatKit Server Implementation
400
+ # =============================================================================
401
+
402
+ class ExamInsightChatServer(ChatKitServer[dict[str, Any]]):
403
+ """Server implementation for ExamInsight exam analysis."""
404
 
405
  def __init__(self) -> None:
406
  self.store: MemoryStore = MemoryStore()
 
412
  item: UserMessageItem | None,
413
  context: dict[str, Any],
414
  ) -> AsyncIterator[ThreadStreamEvent]:
415
+ # Reset status for new analysis
416
+ reset_status(thread.id)
417
+ set_session_id(thread.id)
418
+
419
  items_page = await self.store.load_thread_items(
420
  thread.id,
421
  after=None,
 
433
  )
434
 
435
  result = Runner.run_streamed(
436
+ examinsight_agent,
437
  agent_input,
438
  context=agent_context,
439
  )
chatkit/backend/app/status_tracker.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Real-time status tracking for AI agent reasoning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from datetime import datetime
7
+ from typing import Optional
8
+ from collections import defaultdict
9
+
10
+ # Global status store
11
+ _status_store: dict[str, dict] = defaultdict(lambda: {
12
+ "current_step": None,
13
+ "steps": [],
14
+ "started_at": None,
15
+ "completed_at": None,
16
+ })
17
+
18
+ _subscribers: dict[str, list[asyncio.Queue]] = defaultdict(list)
19
+
20
+
21
+ class WorkflowStep:
22
+ FETCH = "fetch"
23
+ NORMALIZE = "normalize"
24
+ GRADE = "grade"
25
+ EXPLAIN = "explain"
26
+ GROUP = "group"
27
+ REPORT = "report"
28
+ EMAIL = "email"
29
+
30
+
31
+ async def add_reasoning_step(session_id: str, step_type: str, content: str, status: str = "completed"):
32
+ """Add a reasoning step to show what the AI is doing."""
33
+ store = _status_store[session_id]
34
+
35
+ if store["started_at"] is None:
36
+ store["started_at"] = datetime.utcnow().isoformat()
37
+
38
+ # Mark any active steps as completed
39
+ for s in store["steps"]:
40
+ if s.get("status") == "active":
41
+ s["status"] = "completed"
42
+
43
+ step_data = {
44
+ "type": step_type, # "tool", "result", "error", "thinking"
45
+ "content": content,
46
+ "status": status,
47
+ "timestamp": datetime.utcnow().isoformat(),
48
+ }
49
+
50
+ store["steps"].append(step_data)
51
+ store["current_step"] = step_type if status == "active" else None
52
+
53
+ # Emit with "reasoning" key for frontend
54
+ emit_data = {
55
+ "reasoning": store["steps"],
56
+ "started_at": store["started_at"],
57
+ "completed_at": store["completed_at"],
58
+ }
59
+
60
+ await notify_subscribers(session_id, emit_data)
61
+
62
+
63
+ async def update_status(session_id: str, step: str, status: str = "active", detail: str = ""):
64
+ """Update the current workflow status (legacy support)."""
65
+ await add_reasoning_step(session_id, step, detail, status)
66
+
67
+
68
+ async def notify_subscribers(session_id: str, status: dict):
69
+ """Notify all subscribers of status change."""
70
+ for queue in _subscribers[session_id]:
71
+ try:
72
+ await queue.put(status.copy())
73
+ except:
74
+ pass
75
+
76
+
77
+ def get_status(session_id: str) -> dict:
78
+ """Get current status for a session."""
79
+ return dict(_status_store[session_id])
80
+
81
+
82
+ def reset_status(session_id: str):
83
+ """Reset status for a new analysis."""
84
+ _status_store[session_id] = {
85
+ "current_step": None,
86
+ "steps": [],
87
+ "started_at": None,
88
+ "completed_at": None,
89
+ }
90
+
91
+
92
+ async def subscribe(session_id: str) -> asyncio.Queue:
93
+ """Subscribe to status updates for a session."""
94
+ queue = asyncio.Queue()
95
+ _subscribers[session_id].append(queue)
96
+ return queue
97
+
98
+
99
+ def unsubscribe(session_id: str, queue: asyncio.Queue):
100
+ """Unsubscribe from status updates."""
101
+ if queue in _subscribers[session_id]:
102
+ _subscribers[session_id].remove(queue)
chatkit/backend/pyproject.toml CHANGED
@@ -1,21 +1,32 @@
1
  [project]
2
- name = "chatkit-starter-backend"
3
  version = "0.1.0"
4
- description = "Minimal FastAPI backend for the self-hosted ChatKit starter"
5
- requires-python = ">=3.11"
6
  dependencies = [
7
  "fastapi>=0.114,<0.116",
8
  "uvicorn[standard]>=0.36,<0.37",
9
  "openai>=1.40",
10
  "openai-chatkit>=1.4.0,<2",
 
 
 
 
 
 
 
 
 
 
11
  ]
12
 
13
  [project.optional-dependencies]
14
  dev = [
15
  "ruff>=0.6.4,<0.7",
 
 
16
  ]
17
 
18
  [build-system]
19
  requires = ["setuptools>=68.0", "wheel"]
20
  build-backend = "setuptools.build_meta"
21
-
 
1
  [project]
2
+ name = "examinsight-backend"
3
  version = "0.1.0"
4
+ description = "ExamInsight: AI-powered exam analysis for teachers using ChatKit"
5
+ requires-python = ">=3.10"
6
  dependencies = [
7
  "fastapi>=0.114,<0.116",
8
  "uvicorn[standard]>=0.36,<0.37",
9
  "openai>=1.40",
10
  "openai-chatkit>=1.4.0,<2",
11
+ "google-auth>=2.0.0",
12
+ "google-auth-oauthlib>=1.0.0",
13
+ "google-api-python-client>=2.100.0",
14
+ "sendgrid>=6.10.0",
15
+ "python-dotenv>=1.0.0",
16
+ "aiosqlite>=0.19.0",
17
+ "cryptography>=41.0.0",
18
+ "pydantic>=2.0.0",
19
+ "httpx>=0.25.0",
20
+ "markdown>=3.5.0",
21
  ]
22
 
23
  [project.optional-dependencies]
24
  dev = [
25
  "ruff>=0.6.4,<0.7",
26
+ "pytest>=7.4.0",
27
+ "pytest-asyncio>=0.21.0",
28
  ]
29
 
30
  [build-system]
31
  requires = ["setuptools>=68.0", "wheel"]
32
  build-backend = "setuptools.build_meta"
 
chatkit/backend/scripts/run.sh CHANGED
@@ -1,40 +1,32 @@
1
- #!/usr/bin/env bash
 
2
 
3
- # Simple helper to start the ChatKit backend (similar to cat-lounge UX).
4
-
5
- set -euo pipefail
6
 
 
7
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
- PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
9
-
10
- cd "$PROJECT_ROOT"
11
-
12
- if [ ! -d ".venv" ]; then
13
- echo "Creating virtual env in $PROJECT_ROOT/.venv ..."
14
- python -m venv .venv
15
- fi
16
-
17
- source .venv/bin/activate
18
 
19
- echo "Installing backend deps (editable) ..."
20
- pip install -e . >/dev/null
21
 
22
- # Load env vars from the repo's .env.local (if present) so OPENAI_API_KEY
23
- # does not need to be exported manually.
24
- ENV_FILE="$PROJECT_ROOT/../.env.local"
25
- if [ -z "${OPENAI_API_KEY:-}" ] && [ -f "$ENV_FILE" ]; then
26
- echo "Sourcing OPENAI_API_KEY from $ENV_FILE"
27
- # shellcheck disable=SC1090
28
- set -a
29
- . "$ENV_FILE"
30
- set +a
31
  fi
32
 
33
- if [ -z "${OPENAI_API_KEY:-}" ]; then
34
- echo "Set OPENAI_API_KEY in your environment or in .env.local before running this script."
35
- exit 1
 
 
 
 
36
  fi
37
 
38
- echo "Starting ChatKit backend on http://127.0.0.1:8000 ..."
39
- exec uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
 
40
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Run the ExamInsight backend server
3
 
4
+ set -e
 
 
5
 
6
+ # Get the directory where this script is located
7
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ BACKEND_DIR="$(dirname "$SCRIPT_DIR")"
 
 
 
 
 
 
 
 
 
9
 
10
+ cd "$BACKEND_DIR"
 
11
 
12
+ # Load environment variables from .env if it exists
13
+ if [ -f "../.env" ]; then
14
+ export $(cat ../.env | grep -v '^#' | xargs)
 
 
 
 
 
 
15
  fi
16
 
17
+ # Check if we're in a virtual environment, create one if not
18
+ if [ -z "$VIRTUAL_ENV" ]; then
19
+ if [ ! -d ".venv" ]; then
20
+ echo "Creating virtual environment..."
21
+ python3 -m venv .venv
22
+ fi
23
+ source .venv/bin/activate
24
  fi
25
 
26
+ # Install dependencies
27
+ echo "Installing dependencies..."
28
+ pip install -q -e .
29
 
30
+ # Run the server
31
+ echo "Starting ExamInsight backend on http://127.0.0.1:8000"
32
+ uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload
chatkit/env.example ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ExamInsight Environment Variables
2
+ # Copy to .env for local development
3
+ # For HF Spaces, add these as Secrets in the Space settings
4
+
5
+ # =============================================================================
6
+ # REQUIRED
7
+ # =============================================================================
8
+
9
+ # OpenAI API Key (required for ChatKit)
10
+ OPENAI_API_KEY=sk-your-openai-api-key
11
+
12
+ # =============================================================================
13
+ # GOOGLE OAUTH (optional - for private Google Sheets)
14
+ # =============================================================================
15
+
16
+ # Get these from Google Cloud Console > APIs & Services > Credentials
17
+ GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
18
+ GOOGLE_CLIENT_SECRET=GOCSPX-your-client-secret
19
+
20
+ # Redirect URI - update for production
21
+ # Local: http://localhost:8000/auth/callback
22
+ # HF Spaces: https://taboola-cz-examinsight.hf.space/auth/callback
23
+ GOOGLE_REDIRECT_URI=http://localhost:8000/auth/callback
24
+
25
+ # =============================================================================
26
+ # EMAIL (optional - for sending reports)
27
+ # =============================================================================
28
+
29
+ # Option 1: Gmail SMTP (easier setup)
30
+ GMAIL_USER=your-email@gmail.com
31
+ GMAIL_APP_PASSWORD=your-16-char-app-password
32
+
33
+ # Option 2: SendGrid API
34
+ SENDGRID_API_KEY=SG.your-sendgrid-api-key
35
+ SENDGRID_FROM_EMAIL=examinsight@yourdomain.com
36
+
37
+ # =============================================================================
38
+ # SECURITY
39
+ # =============================================================================
40
+
41
+ # Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
42
+ ENCRYPTION_KEY=your-fernet-encryption-key
43
+
44
+ # =============================================================================
45
+ # FRONTEND (for HF Spaces domain key)
46
+ # =============================================================================
47
+
48
+ # Register your HF Space domain at:
49
+ # https://platform.openai.com/settings/organization/security/domain-allowlist
50
+ VITE_CHATKIT_API_DOMAIN_KEY=domain_pk_your-production-key
chatkit/frontend/index.html CHANGED
@@ -3,8 +3,12 @@
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>AgentKit demo</title>
 
7
  <link rel="icon" type="image/x-icon" href="/favicon.ico" />
 
 
 
8
  <script src="https://cdn.platform.openai.com/deployments/chatkit/chatkit.js"></script>
9
  </head>
10
  <body>
@@ -12,4 +16,3 @@
12
  <script type="module" src="/src/main.tsx"></script>
13
  </body>
14
  </html>
15
-
 
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>ExamInsight — AI-Powered Exam Analysis for Teachers</title>
7
+ <meta name="description" content="Analyze student exam responses, generate personalized explanations, and create peer learning groups with AI." />
8
  <link rel="icon" type="image/x-icon" href="/favicon.ico" />
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=Instrument+Sans:ital,wght@0,400..700;1,400..700&display=swap" rel="stylesheet">
12
  <script src="https://cdn.platform.openai.com/deployments/chatkit/chatkit.js"></script>
13
  </head>
14
  <body>
 
16
  <script type="module" src="/src/main.tsx"></script>
17
  </body>
18
  </html>
 
chatkit/frontend/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
  {
2
- "name": "chatkit-frontend",
3
- "version": "0.1.0",
4
  "lockfileVersion": 3,
5
  "requires": true,
6
  "packages": {
7
  "": {
8
- "name": "chatkit-frontend",
9
- "version": "0.1.0",
10
  "dependencies": {
11
  "@openai/chatkit-react": ">=1.1.1 <2.0.0",
12
  "react": "^19.2.0",
 
1
  {
2
+ "name": "examinsight-frontend",
3
+ "version": "1.0.0",
4
  "lockfileVersion": 3,
5
  "requires": true,
6
  "packages": {
7
  "": {
8
+ "name": "examinsight-frontend",
9
+ "version": "1.0.0",
10
  "dependencies": {
11
  "@openai/chatkit-react": ">=1.1.1 <2.0.0",
12
  "react": "^19.2.0",
chatkit/frontend/package.json CHANGED
@@ -1,6 +1,7 @@
1
  {
2
- "name": "chatkit-frontend",
3
- "version": "0.1.0",
 
4
  "private": true,
5
  "type": "module",
6
  "scripts": {
 
1
  {
2
+ "name": "examinsight-frontend",
3
+ "version": "1.0.0",
4
+ "description": "ExamInsight - AI-Powered Exam Analysis for Teachers",
5
  "private": true,
6
  "type": "module",
7
  "scripts": {
chatkit/frontend/src/App.tsx CHANGED
@@ -1,11 +1,99 @@
1
- import { ChatKitPanel } from "./components/ChatKitPanel";
 
 
 
 
 
 
2
 
3
  export default function App() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  return (
5
- <main className="flex min-h-screen flex-col items-center justify-end bg-slate-100 dark:bg-slate-950">
6
- <div className="mx-auto w-full max-w-5xl">
7
- <ChatKitPanel />
8
- </div>
9
- </main>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  );
11
  }
 
1
+ import { useState, useEffect } from "react";
2
+ import { Header } from "./components/Header";
3
+ import { Hero } from "./components/Hero";
4
+ import { AuthStatus } from "./components/AuthStatus";
5
+ import { ExamAnalyzer } from "./components/ExamAnalyzer";
6
+ import { Features } from "./components/Features";
7
+ import { SimpleChatPanel } from "./components/SimpleChatPanel";
8
 
9
  export default function App() {
10
+ const [teacherEmail, setTeacherEmail] = useState<string>("");
11
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
12
+ const [showAnalyzer, setShowAnalyzer] = useState(false);
13
+ const [testMode, setTestMode] = useState(false);
14
+
15
+ // Check URL params for auth callback and test mode
16
+ useEffect(() => {
17
+ const params = new URLSearchParams(window.location.search);
18
+ const authSuccess = params.get("auth_success");
19
+ const email = params.get("email");
20
+ const authError = params.get("auth_error");
21
+ const test = params.get("test");
22
+
23
+ if (test === "chat") {
24
+ setTestMode(true);
25
+ return;
26
+ }
27
+
28
+ if (authSuccess === "true" && email) {
29
+ setTeacherEmail(email);
30
+ setIsAuthenticated(true);
31
+ setShowAnalyzer(true);
32
+ // Clean URL
33
+ window.history.replaceState({}, "", window.location.pathname);
34
+ } else if (authError) {
35
+ console.error("Auth error:", authError);
36
+ window.history.replaceState({}, "", window.location.pathname);
37
+ }
38
+ }, []);
39
+
40
+ const handleStartAnalysis = () => {
41
+ if (isAuthenticated) {
42
+ setShowAnalyzer(true);
43
+ }
44
+ };
45
+
46
+ // Test mode - show simple chat panel
47
+ if (testMode) {
48
+ return (
49
+ <main className="flex min-h-screen flex-col items-center justify-center bg-slate-100 dark:bg-slate-950 p-4">
50
+ <div className="mx-auto w-full max-w-3xl">
51
+ <div className="mb-4 text-center">
52
+ <h1 className="text-2xl font-bold mb-2">ChatKit Test Mode</h1>
53
+ <p className="text-gray-600">Testing basic ChatKit functionality</p>
54
+ <a href="/" className="text-blue-500 underline text-sm">← Back to app</a>
55
+ </div>
56
+ <SimpleChatPanel />
57
+ </div>
58
+ </main>
59
+ );
60
+ }
61
+
62
  return (
63
+ <div className="min-h-screen">
64
+ <Header
65
+ isAuthenticated={isAuthenticated}
66
+ teacherEmail={teacherEmail}
67
+ />
68
+
69
+ <main>
70
+ {!showAnalyzer ? (
71
+ <>
72
+ <Hero
73
+ isAuthenticated={isAuthenticated}
74
+ onStartAnalysis={handleStartAnalysis}
75
+ />
76
+
77
+ <AuthStatus
78
+ teacherEmail={teacherEmail}
79
+ setTeacherEmail={setTeacherEmail}
80
+ isAuthenticated={isAuthenticated}
81
+ setIsAuthenticated={setIsAuthenticated}
82
+ />
83
+
84
+ <Features />
85
+ </>
86
+ ) : (
87
+ <ExamAnalyzer
88
+ teacherEmail={teacherEmail}
89
+ onBack={() => setShowAnalyzer(false)}
90
+ />
91
+ )}
92
+ </main>
93
+
94
+ <footer className="py-8 text-center text-sm text-[var(--color-text-muted)]">
95
+ <p>© 2026 ExamInsight • AI-Powered Teaching Assistant</p>
96
+ </footer>
97
+ </div>
98
  );
99
  }
chatkit/frontend/src/TestChat.tsx ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { SimpleChatPanel } from "./components/SimpleChatPanel";
2
+
3
+ export function TestChat() {
4
+ return (
5
+ <main className="flex min-h-screen flex-col items-center justify-end bg-slate-100 dark:bg-slate-950 p-4">
6
+ <div className="mx-auto w-full max-w-3xl">
7
+ <h1 className="text-2xl font-bold mb-4 text-center">ChatKit Test</h1>
8
+ <SimpleChatPanel />
9
+ </div>
10
+ </main>
11
+ );
12
+ }
chatkit/frontend/src/components/AuthStatus.tsx ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { GOOGLE_AUTH_URL, AUTH_STATUS_URL } from "../lib/config";
3
+
4
+ type AuthMode = 'google' | 'csv-only';
5
+
6
+ interface AuthStatusProps {
7
+ teacherEmail: string;
8
+ setTeacherEmail: (email: string) => void;
9
+ isAuthenticated: boolean;
10
+ setIsAuthenticated: (auth: boolean) => void;
11
+ }
12
+
13
+ export function AuthStatus({
14
+ teacherEmail,
15
+ setTeacherEmail,
16
+ isAuthenticated,
17
+ setIsAuthenticated,
18
+ }: AuthStatusProps) {
19
+ const [emailInput, setEmailInput] = useState("");
20
+ const [isChecking, setIsChecking] = useState(false);
21
+ const [error, setError] = useState<string | null>(null);
22
+ const [authMode, setAuthMode] = useState<AuthMode | null>(null);
23
+
24
+ const checkAuthStatus = async (email: string) => {
25
+ try {
26
+ setIsChecking(true);
27
+ const response = await fetch(`${AUTH_STATUS_URL}?teacher_email=${encodeURIComponent(email)}`);
28
+ const data = await response.json();
29
+
30
+ if (data.authenticated) {
31
+ setTeacherEmail(email);
32
+ setIsAuthenticated(true);
33
+ }
34
+ return data.authenticated;
35
+ } catch (err) {
36
+ console.error("Error checking auth status:", err);
37
+ return false;
38
+ } finally {
39
+ setIsChecking(false);
40
+ }
41
+ };
42
+
43
+ const handleConnect = async (e: React.FormEvent) => {
44
+ e.preventDefault();
45
+ setError(null);
46
+
47
+ if (!emailInput.trim()) {
48
+ setError("Please enter your email address");
49
+ return;
50
+ }
51
+
52
+ // Check if already authenticated
53
+ const alreadyAuth = await checkAuthStatus(emailInput);
54
+ if (alreadyAuth) {
55
+ return;
56
+ }
57
+
58
+ // Try to start OAuth - catch errors if not configured
59
+ try {
60
+ const response = await fetch(`${GOOGLE_AUTH_URL}?teacher_email=${encodeURIComponent(emailInput)}`, {
61
+ method: 'GET',
62
+ redirect: 'manual'
63
+ });
64
+
65
+ // If we get a redirect, OAuth is configured - follow it
66
+ if (response.type === 'opaqueredirect' || response.status === 302) {
67
+ window.location.href = `${GOOGLE_AUTH_URL}?teacher_email=${encodeURIComponent(emailInput)}`;
68
+ } else if (response.status === 500) {
69
+ const data = await response.json();
70
+ if (data.detail?.includes('not configured')) {
71
+ setAuthMode('csv-only');
72
+ setError("Google OAuth not configured. You can still use the app by uploading CSV files directly in the chat!");
73
+ // Set as "connected" with just email for CSV fallback
74
+ setTeacherEmail(emailInput);
75
+ setIsAuthenticated(true);
76
+ } else {
77
+ setAuthMode('google');
78
+ setError(data.detail || "Failed to connect");
79
+ }
80
+ } else {
81
+ // Redirect to OAuth
82
+ window.location.href = `${GOOGLE_AUTH_URL}?teacher_email=${encodeURIComponent(emailInput)}`;
83
+ }
84
+ } catch (err) {
85
+ // Network error or CORS - just redirect
86
+ window.location.href = `${GOOGLE_AUTH_URL}?teacher_email=${encodeURIComponent(emailInput)}`;
87
+ }
88
+ };
89
+
90
+ const handleDisconnect = async () => {
91
+ try {
92
+ await fetch(`/auth/disconnect?teacher_email=${encodeURIComponent(teacherEmail)}`, {
93
+ method: 'POST'
94
+ });
95
+ setIsAuthenticated(false);
96
+ setTeacherEmail("");
97
+ } catch (err) {
98
+ console.error("Error disconnecting:", err);
99
+ }
100
+ };
101
+
102
+ return (
103
+ <section id="connect" className="py-20 px-6">
104
+ <div className="max-w-xl mx-auto">
105
+ <div className="card p-8">
106
+ <div className="text-center mb-8">
107
+ <div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-light)] flex items-center justify-center shadow-lg">
108
+ <svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
109
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
110
+ </svg>
111
+ </div>
112
+ <h2 className="font-display text-2xl font-bold text-[var(--color-text)] mb-2">
113
+ {isAuthenticated ? "You're Connected!" : "Connect Your Google Account"}
114
+ </h2>
115
+ <p className="text-[var(--color-text-muted)]">
116
+ {isAuthenticated
117
+ ? "Your Google account is linked. You can now analyze exam responses."
118
+ : "We'll only access your Google Forms response spreadsheets. No data is stored on our servers."}
119
+ </p>
120
+ </div>
121
+
122
+ {isAuthenticated ? (
123
+ <div className="space-y-4">
124
+ <div className="flex items-center justify-center gap-3 p-4 rounded-xl bg-[var(--color-success)]/10 border border-[var(--color-success)]/20">
125
+ <div className="w-10 h-10 rounded-full bg-[var(--color-success)]/20 flex items-center justify-center">
126
+ <svg className="w-5 h-5 text-[var(--color-success)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
127
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
128
+ </svg>
129
+ </div>
130
+ <div className="text-left">
131
+ <div className="font-medium text-[var(--color-text)]">{teacherEmail}</div>
132
+ <div className="text-sm text-[var(--color-text-muted)]">
133
+ {authMode === 'csv-only' ? 'CSV Upload Mode (Google OAuth not configured)' : 'Google Account Connected'}
134
+ </div>
135
+ </div>
136
+ </div>
137
+
138
+ {authMode === 'csv-only' && (
139
+ <div className="p-3 rounded-lg bg-amber-50 border border-amber-200 text-amber-700 text-sm">
140
+ <strong>Note:</strong> You can paste CSV data directly in the chat, or set up Google OAuth to fetch from Google Forms.
141
+ </div>
142
+ )}
143
+
144
+ <button
145
+ onClick={handleDisconnect}
146
+ className="w-full btn btn-outline text-red-500 border-red-200 hover:border-red-500 hover:text-red-600"
147
+ >
148
+ Disconnect Account
149
+ </button>
150
+ </div>
151
+ ) : (
152
+ <form onSubmit={handleConnect} className="space-y-4">
153
+ <div>
154
+ <label htmlFor="email" className="block text-sm font-medium text-[var(--color-text)] mb-2">
155
+ Your School Email
156
+ </label>
157
+ <input
158
+ type="email"
159
+ id="email"
160
+ value={emailInput}
161
+ onChange={(e) => setEmailInput(e.target.value)}
162
+ placeholder="teacher@school.edu"
163
+ className="input"
164
+ required
165
+ />
166
+ </div>
167
+
168
+ {error && (
169
+ <div className="p-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
170
+ {error}
171
+ </div>
172
+ )}
173
+
174
+ <button
175
+ type="submit"
176
+ className="w-full btn btn-primary"
177
+ disabled={isChecking}
178
+ >
179
+ {isChecking ? (
180
+ <>
181
+ <svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
182
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
183
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
184
+ </svg>
185
+ Checking...
186
+ </>
187
+ ) : (
188
+ <>
189
+ <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
190
+ <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
191
+ <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
192
+ <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
193
+ <path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
194
+ </svg>
195
+ Continue with Google
196
+ </>
197
+ )}
198
+ </button>
199
+
200
+ <p className="text-center text-xs text-[var(--color-text-muted)]">
201
+ By connecting, you agree to our{" "}
202
+ <a href="#" className="underline hover:text-[var(--color-primary)]">Terms of Service</a>
203
+ {" "}and{" "}
204
+ <a href="#" className="underline hover:text-[var(--color-primary)]">Privacy Policy</a>
205
+ </p>
206
+ </form>
207
+ )}
208
+ </div>
209
+ </div>
210
+ </section>
211
+ );
212
+ }
chatkit/frontend/src/components/ExamAnalyzer.tsx ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { ChatKit, useChatKit } from "@openai/chatkit-react";
3
+ import { CHATKIT_API_DOMAIN_KEY, CHATKIT_API_URL } from "../lib/config";
4
+ import { ReasoningPanel } from "./ReasoningPanel";
5
+
6
+ interface ExamAnalyzerProps {
7
+ teacherEmail: string;
8
+ onBack: () => void;
9
+ }
10
+
11
+ export function ExamAnalyzer({ teacherEmail, onBack }: ExamAnalyzerProps) {
12
+ const [showCopiedToast, setShowCopiedToast] = useState(false);
13
+
14
+ const chatkit = useChatKit({
15
+ api: { url: CHATKIT_API_URL, domainKey: CHATKIT_API_DOMAIN_KEY },
16
+ composer: {
17
+ attachments: { enabled: false },
18
+ },
19
+ });
20
+
21
+ const handleCopyPrompt = (prompt: string) => {
22
+ navigator.clipboard.writeText(prompt);
23
+ setShowCopiedToast(true);
24
+ setTimeout(() => setShowCopiedToast(false), 2000);
25
+ };
26
+
27
+ const examplePrompts = [
28
+ {
29
+ title: "📊 Analyze Google Sheet",
30
+ prompt: `Please analyze the exam responses from this Google Sheet:
31
+ https://docs.google.com/spreadsheets/d/YOUR_SHEET_ID/edit
32
+
33
+ The correct answers are:
34
+ - Q1: 4
35
+ - Q2: Acceleration is the rate of change of velocity
36
+
37
+ My email is ${teacherEmail}. Please grade the responses, explain incorrect answers, suggest peer learning groups, and email me the report.`,
38
+ },
39
+ {
40
+ title: "📝 Analyze CSV Data",
41
+ prompt: `Please analyze this exam data:
42
+
43
+ Timestamp,Email,Student Name,Q1 (2+2),Q2 (Explain acceleration)
44
+ 2026-01-26 08:00:00,alice@student.edu,Alice,4,Acceleration is the rate of change of velocity
45
+ 2026-01-26 08:01:00,bob@student.edu,Bob,3,I dont know
46
+ 2026-01-26 08:02:00,carol@student.edu,Carol,4,velocity change over time
47
+ 2026-01-26 08:03:00,david@student.edu,David,5,speed
48
+
49
+ The correct answers are:
50
+ - Q1: 4
51
+ - Q2: Acceleration is the rate of change of velocity over time
52
+
53
+ My email is ${teacherEmail}. Please grade and create a full report.`,
54
+ },
55
+ {
56
+ title: "⚡ Quick Summary",
57
+ prompt: `Just give me a quick summary of class performance. My email is ${teacherEmail}.`,
58
+ },
59
+ ];
60
+
61
+ return (
62
+ <div className="min-h-screen pt-24 pb-8 px-4">
63
+ {/* Copied toast */}
64
+ {showCopiedToast && (
65
+ <div className="fixed top-24 left-1/2 -translate-x-1/2 z-50 px-4 py-2 bg-green-500 text-white rounded-lg shadow-lg">
66
+ ✓ Prompt copied! Paste it in the chat.
67
+ </div>
68
+ )}
69
+
70
+ <div className="max-w-7xl mx-auto">
71
+ {/* Top bar */}
72
+ <div className="flex items-center justify-between mb-6">
73
+ <button
74
+ onClick={onBack}
75
+ className="flex items-center gap-2 text-gray-500 hover:text-gray-700 transition-colors"
76
+ >
77
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
78
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
79
+ </svg>
80
+ Back to Home
81
+ </button>
82
+
83
+ <div className="flex items-center gap-3">
84
+ <span className="text-sm text-gray-500">Connected as</span>
85
+ <span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-sm font-medium">
86
+ {teacherEmail}
87
+ </span>
88
+ </div>
89
+ </div>
90
+
91
+ <div className="grid lg:grid-cols-4 gap-6">
92
+ {/* Left Sidebar - AI Reasoning */}
93
+ <div className="lg:col-span-1 space-y-4">
94
+ {/* AI Reasoning Panel - Shows LLM thinking process */}
95
+ <ReasoningPanel sessionId="default" />
96
+
97
+ {/* Example Prompts */}
98
+ <div className="bg-white rounded-xl shadow-sm border p-4">
99
+ <h3 className="text-sm font-semibold text-gray-800 mb-3">
100
+ Quick Start Prompts
101
+ </h3>
102
+ <div className="space-y-2">
103
+ {examplePrompts.map((example, i) => (
104
+ <button
105
+ key={i}
106
+ onClick={() => handleCopyPrompt(example.prompt)}
107
+ className="w-full text-left p-3 rounded-lg bg-gray-50 hover:bg-blue-50 transition-colors group border border-transparent hover:border-blue-200"
108
+ >
109
+ <div className="text-sm font-medium text-gray-700 group-hover:text-blue-600">
110
+ {example.title}
111
+ </div>
112
+ <div className="text-xs text-gray-500">
113
+ Click to copy
114
+ </div>
115
+ </button>
116
+ ))}
117
+ </div>
118
+ </div>
119
+
120
+ {/* Tips */}
121
+ <div className="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl p-4 border border-blue-100">
122
+ <h3 className="text-sm font-semibold text-gray-800 mb-2 flex items-center gap-2">
123
+ 💡 Pro Tips
124
+ </h3>
125
+ <ul className="space-y-1.5 text-xs text-gray-600">
126
+ <li className="flex items-start gap-1.5">
127
+ <span className="text-green-500">✓</span>
128
+ Include answer key for accuracy
129
+ </li>
130
+ <li className="flex items-start gap-1.5">
131
+ <span className="text-green-500">✓</span>
132
+ Make Google Sheet public, or use CSV
133
+ </li>
134
+ <li className="flex items-start gap-1.5">
135
+ <span className="text-green-500">✓</span>
136
+ Ask for email delivery
137
+ </li>
138
+ </ul>
139
+ </div>
140
+ </div>
141
+
142
+ {/* Main Chat Area - Use same structure as SimpleChatPanel */}
143
+ <div className="lg:col-span-3">
144
+ <div className="bg-white rounded-xl shadow-sm border overflow-hidden">
145
+ {/* Chat Header */}
146
+ <div className="bg-gradient-to-r from-blue-600 to-indigo-600 px-6 py-4">
147
+ <div className="flex items-center justify-between">
148
+ <div className="flex items-center gap-3">
149
+ <div className="w-10 h-10 rounded-xl bg-white/20 flex items-center justify-center">
150
+ <svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
151
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
152
+ </svg>
153
+ </div>
154
+ <div>
155
+ <h2 className="text-lg font-semibold text-white">
156
+ ExamInsight Assistant
157
+ </h2>
158
+ <p className="text-sm text-white/70">
159
+ Paste your exam data or Google Sheet URL below
160
+ </p>
161
+ </div>
162
+ </div>
163
+ <span className="px-2 py-1 rounded-full bg-white/20 text-white text-xs">
164
+ GPT-4.1-mini
165
+ </span>
166
+ </div>
167
+ </div>
168
+
169
+ {/* ChatKit - Using the EXACT same structure as SimpleChatPanel that works */}
170
+ <div className="h-[calc(100vh-320px)]">
171
+ <ChatKit
172
+ control={chatkit.control}
173
+ className="block h-full w-full"
174
+ />
175
+ </div>
176
+ </div>
177
+ </div>
178
+ </div>
179
+ </div>
180
+ </div>
181
+ );
182
+ }
chatkit/frontend/src/components/Features.tsx ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function Features() {
2
+ const features = [
3
+ {
4
+ icon: (
5
+ <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
6
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
7
+ </svg>
8
+ ),
9
+ title: "Automatic Grading",
10
+ description: "Grade multiple choice, numeric, and even open-ended questions with AI-powered accuracy.",
11
+ color: "var(--color-primary)",
12
+ },
13
+ {
14
+ icon: (
15
+ <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
16
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
17
+ </svg>
18
+ ),
19
+ title: "Smart Explanations",
20
+ description: "Get personalized explanations for each wrong answer, helping students understand their mistakes.",
21
+ color: "var(--color-accent)",
22
+ },
23
+ {
24
+ icon: (
25
+ <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
26
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
27
+ </svg>
28
+ ),
29
+ title: "Peer Learning Groups",
30
+ description: "AI-suggested student groupings pair high performers with those who need help on specific topics.",
31
+ color: "var(--color-success)",
32
+ },
33
+ {
34
+ icon: (
35
+ <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
36
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
37
+ </svg>
38
+ ),
39
+ title: "Email Reports",
40
+ description: "Receive beautiful, formatted reports directly in your inbox, ready to share with students or parents.",
41
+ color: "var(--color-warning)",
42
+ },
43
+ {
44
+ icon: (
45
+ <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
46
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
47
+ </svg>
48
+ ),
49
+ title: "Privacy First",
50
+ description: "Student data is processed securely. We only access what you share and never store sensitive information.",
51
+ color: "var(--color-primary-light)",
52
+ },
53
+ {
54
+ icon: (
55
+ <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
56
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
57
+ </svg>
58
+ ),
59
+ title: "Instant Analysis",
60
+ description: "Get comprehensive insights in seconds, not hours. More time teaching, less time grading.",
61
+ color: "var(--color-accent-light)",
62
+ },
63
+ ];
64
+
65
+ return (
66
+ <section id="features" className="py-20 px-6 bg-gradient-to-b from-transparent to-[var(--color-surface)]/50">
67
+ <div className="max-w-6xl mx-auto">
68
+ <div className="text-center mb-16">
69
+ <h2 className="font-display text-4xl font-bold text-[var(--color-text)] mb-4">
70
+ Everything You Need to
71
+ <span className="text-gradient"> Understand Your Students</span>
72
+ </h2>
73
+ <p className="text-lg text-[var(--color-text-muted)] max-w-2xl mx-auto">
74
+ ExamInsight combines the power of AI with thoughtful education practices
75
+ to give you actionable insights.
76
+ </p>
77
+ </div>
78
+
79
+ <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
80
+ {features.map((feature, i) => (
81
+ <div
82
+ key={i}
83
+ className="card p-6 hover:shadow-lg transition-all hover:-translate-y-1 group"
84
+ >
85
+ <div
86
+ className="w-12 h-12 rounded-xl flex items-center justify-center mb-4 transition-transform group-hover:scale-110"
87
+ style={{
88
+ backgroundColor: `color-mix(in srgb, ${feature.color} 15%, transparent)`,
89
+ color: feature.color
90
+ }}
91
+ >
92
+ {feature.icon}
93
+ </div>
94
+ <h3 className="font-display text-xl font-semibold text-[var(--color-text)] mb-2">
95
+ {feature.title}
96
+ </h3>
97
+ <p className="text-[var(--color-text-muted)]">
98
+ {feature.description}
99
+ </p>
100
+ </div>
101
+ ))}
102
+ </div>
103
+
104
+ {/* How it works */}
105
+ <div className="mt-24">
106
+ <h3 className="font-display text-3xl font-bold text-center text-[var(--color-text)] mb-12">
107
+ How It Works
108
+ </h3>
109
+
110
+ <div className="relative">
111
+ {/* Connection line */}
112
+ <div className="hidden lg:block absolute top-1/2 left-0 right-0 h-0.5 bg-gradient-to-r from-[var(--color-primary)] via-[var(--color-accent)] to-[var(--color-success)] -translate-y-1/2" />
113
+
114
+ <div className="grid lg:grid-cols-4 gap-8">
115
+ {[
116
+ { step: 1, title: "Connect Google", desc: "Link your Google account to access Form responses" },
117
+ { step: 2, title: "Share URL", desc: "Paste your Google Form or Sheets response URL" },
118
+ { step: 3, title: "AI Analyzes", desc: "ExamInsight grades, explains, and groups students" },
119
+ { step: 4, title: "Get Report", desc: "Receive a beautiful report via email and dashboard" },
120
+ ].map((item) => (
121
+ <div key={item.step} className="relative text-center">
122
+ <div className="w-12 h-12 mx-auto mb-4 rounded-full bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-accent)] text-white font-bold text-xl flex items-center justify-center shadow-lg relative z-10">
123
+ {item.step}
124
+ </div>
125
+ <h4 className="font-display text-lg font-semibold text-[var(--color-text)] mb-2">
126
+ {item.title}
127
+ </h4>
128
+ <p className="text-sm text-[var(--color-text-muted)]">{item.desc}</p>
129
+ </div>
130
+ ))}
131
+ </div>
132
+ </div>
133
+ </div>
134
+ </div>
135
+ </section>
136
+ );
137
+ }
chatkit/frontend/src/components/Header.tsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ interface HeaderProps {
2
+ isAuthenticated: boolean;
3
+ teacherEmail: string;
4
+ }
5
+
6
+ export function Header({ isAuthenticated, teacherEmail }: HeaderProps) {
7
+ return (
8
+ <header className="fixed top-0 left-0 right-0 z-50 backdrop-blur-xl bg-[var(--glass-bg)] border-b border-[var(--glass-border)]">
9
+ <div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
10
+ <div className="flex items-center gap-3">
11
+ <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-accent)] flex items-center justify-center text-white font-bold text-lg shadow-lg">
12
+ E
13
+ </div>
14
+ <div>
15
+ <h1 className="font-display text-xl font-semibold text-[var(--color-text)]">
16
+ ExamInsight
17
+ </h1>
18
+ <p className="text-xs text-[var(--color-text-muted)] hidden sm:block">
19
+ AI-Powered Exam Analysis
20
+ </p>
21
+ </div>
22
+ </div>
23
+
24
+ <nav className="flex items-center gap-6">
25
+ {isAuthenticated && (
26
+ <div className="flex items-center gap-2 px-4 py-2 rounded-full bg-[var(--color-success)]/10 border border-[var(--color-success)]/20">
27
+ <span className="w-2 h-2 rounded-full bg-[var(--color-success)] animate-pulse"></span>
28
+ <span className="text-sm font-medium text-[var(--color-success)]">
29
+ {teacherEmail}
30
+ </span>
31
+ </div>
32
+ )}
33
+ <a
34
+ href="https://platform.openai.com/docs/guides/chatkit"
35
+ target="_blank"
36
+ rel="noopener noreferrer"
37
+ className="text-sm text-[var(--color-text-muted)] hover:text-[var(--color-primary)] transition-colors"
38
+ >
39
+ Docs
40
+ </a>
41
+ </nav>
42
+ </div>
43
+ </header>
44
+ );
45
+ }
chatkit/frontend/src/components/Hero.tsx ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ interface HeroProps {
2
+ isAuthenticated: boolean;
3
+ onStartAnalysis: () => void;
4
+ }
5
+
6
+ export function Hero({ isAuthenticated, onStartAnalysis }: HeroProps) {
7
+ return (
8
+ <section className="relative pt-32 pb-20 px-6 overflow-hidden">
9
+ {/* Decorative elements */}
10
+ <div className="absolute top-20 left-10 w-72 h-72 rounded-full bg-gradient-to-br from-[var(--color-accent)]/20 to-transparent blur-3xl animate-float"></div>
11
+ <div className="absolute bottom-0 right-10 w-96 h-96 rounded-full bg-gradient-to-br from-[var(--color-success)]/15 to-transparent blur-3xl animate-float" style={{ animationDelay: '2s' }}></div>
12
+
13
+ <div className="max-w-5xl mx-auto text-center relative">
14
+ <div className="opacity-0 animate-fade-in-up stagger-1">
15
+ <span className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[var(--color-primary)]/10 text-[var(--color-primary)] text-sm font-medium mb-8">
16
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
17
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
18
+ </svg>
19
+ Powered by OpenAI ChatKit
20
+ </span>
21
+ </div>
22
+
23
+ <h1 className="font-display text-5xl sm:text-6xl lg:text-7xl font-bold leading-tight mb-6 opacity-0 animate-fade-in-up stagger-2">
24
+ <span className="text-[var(--color-text)]">Transform Exams into</span>
25
+ <br />
26
+ <span className="text-gradient">Teaching Insights</span>
27
+ </h1>
28
+
29
+ <p className="text-xl text-[var(--color-text-muted)] max-w-2xl mx-auto mb-10 opacity-0 animate-fade-in-up stagger-3">
30
+ Upload your Google Form responses and let AI analyze student performance,
31
+ generate personalized explanations, and create peer learning groups—all in seconds.
32
+ </p>
33
+
34
+ <div className="flex flex-col sm:flex-row items-center justify-center gap-4 opacity-0 animate-fade-in-up stagger-4">
35
+ {isAuthenticated ? (
36
+ <button onClick={onStartAnalysis} className="btn btn-accent text-lg px-8 py-4">
37
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
38
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
39
+ </svg>
40
+ Start Analyzing
41
+ </button>
42
+ ) : (
43
+ <a href="#connect" className="btn btn-primary text-lg px-8 py-4">
44
+ <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
45
+ <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
46
+ <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
47
+ <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
48
+ <path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
49
+ </svg>
50
+ Connect Google Account
51
+ </a>
52
+ )}
53
+ <a href="#features" className="btn btn-outline text-lg px-8 py-4">
54
+ See How It Works
55
+ </a>
56
+ </div>
57
+
58
+ {/* Stats */}
59
+ <div className="mt-20 grid grid-cols-3 gap-8 max-w-2xl mx-auto opacity-0 animate-fade-in-up stagger-5">
60
+ {[
61
+ { value: "5min", label: "Average Analysis Time" },
62
+ { value: "98%", label: "Teacher Satisfaction" },
63
+ { value: "40+", label: "Question Types Supported" },
64
+ ].map((stat, i) => (
65
+ <div key={i} className="text-center">
66
+ <div className="font-display text-3xl font-bold text-[var(--color-primary)]">
67
+ {stat.value}
68
+ </div>
69
+ <div className="text-sm text-[var(--color-text-muted)]">{stat.label}</div>
70
+ </div>
71
+ ))}
72
+ </div>
73
+ </div>
74
+ </section>
75
+ );
76
+ }
chatkit/frontend/src/components/ReasoningPanel.tsx ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useRef } from "react";
2
+
3
+ interface ReasoningStep {
4
+ id: string;
5
+ type: "tool" | "result" | "error" | "thinking";
6
+ content: string;
7
+ timestamp: Date;
8
+ status: "running" | "completed";
9
+ }
10
+
11
+ interface ReasoningPanelProps {
12
+ sessionId?: string;
13
+ }
14
+
15
+ export function ReasoningPanel({ sessionId = "default" }: ReasoningPanelProps) {
16
+ const [steps, setSteps] = useState<ReasoningStep[]>([]);
17
+ const [isStreaming, setIsStreaming] = useState(false);
18
+ const containerRef = useRef<HTMLDivElement>(null);
19
+
20
+ // Subscribe to status updates
21
+ useEffect(() => {
22
+ let eventSource: EventSource | null = null;
23
+
24
+ const connect = () => {
25
+ // Use relative URL for production, works with both local and HF Spaces
26
+ const apiBase = import.meta.env.VITE_API_BASE_URL || "";
27
+ eventSource = new EventSource(`${apiBase}/api/status/${sessionId}/stream`);
28
+
29
+ eventSource.onmessage = (event) => {
30
+ try {
31
+ const data = JSON.parse(event.data);
32
+
33
+ // Handle reasoning steps from backend
34
+ if (data.reasoning && data.reasoning.length > 0) {
35
+ setIsStreaming(true);
36
+
37
+ const newSteps: ReasoningStep[] = data.reasoning.map((step: any, index: number) => ({
38
+ id: `step-${index}-${step.timestamp}`,
39
+ type: step.type as ReasoningStep["type"],
40
+ content: step.content,
41
+ timestamp: new Date(step.timestamp),
42
+ status: step.status === "active" ? "running" : "completed",
43
+ }));
44
+
45
+ setSteps(newSteps);
46
+ }
47
+
48
+ if (data.completed_at) {
49
+ setIsStreaming(false);
50
+ }
51
+ } catch (e) {
52
+ console.error("Error parsing status:", e);
53
+ }
54
+ };
55
+
56
+ eventSource.onerror = () => {
57
+ eventSource?.close();
58
+ // Reconnect after 3 seconds
59
+ setTimeout(connect, 3000);
60
+ };
61
+ };
62
+
63
+ connect();
64
+ return () => eventSource?.close();
65
+ }, [sessionId]);
66
+
67
+ // Auto-scroll to bottom
68
+ useEffect(() => {
69
+ if (containerRef.current) {
70
+ containerRef.current.scrollTop = containerRef.current.scrollHeight;
71
+ }
72
+ }, [steps]);
73
+
74
+ const getIcon = (type: ReasoningStep["type"], status: string) => {
75
+ if (status === "running") {
76
+ return (
77
+ <div className="w-4 h-4 border-2 border-blue-400 border-t-transparent rounded-full animate-spin" />
78
+ );
79
+ }
80
+
81
+ switch (type) {
82
+ case "tool":
83
+ return <span className="text-blue-400">🔧</span>;
84
+ case "result":
85
+ return <span className="text-green-400">✓</span>;
86
+ case "error":
87
+ return <span className="text-red-400">✗</span>;
88
+ case "thinking":
89
+ return <span className="text-purple-400">🧠</span>;
90
+ default:
91
+ return <span>•</span>;
92
+ }
93
+ };
94
+
95
+ const getColor = (type: ReasoningStep["type"], status: string) => {
96
+ if (status === "running") return "text-blue-300 bg-blue-500/10 border-blue-500/30";
97
+
98
+ switch (type) {
99
+ case "tool":
100
+ return "text-blue-300 bg-gray-800/50 border-gray-600";
101
+ case "result":
102
+ return "text-green-300 bg-green-500/10 border-green-500/30";
103
+ case "error":
104
+ return "text-red-300 bg-red-500/10 border-red-500/30";
105
+ case "thinking":
106
+ return "text-purple-300 bg-purple-500/10 border-purple-500/30";
107
+ default:
108
+ return "text-gray-300 bg-gray-800/50 border-gray-600";
109
+ }
110
+ };
111
+
112
+ return (
113
+ <div className="bg-gray-900 rounded-xl border border-gray-700 overflow-hidden h-full flex flex-col">
114
+ {/* Header */}
115
+ <div className="px-4 py-3 bg-gray-800 border-b border-gray-700 flex items-center justify-between flex-shrink-0">
116
+ <div className="flex items-center gap-2">
117
+ <span className="text-sm font-semibold text-white">🤖 Tool Calls</span>
118
+ {isStreaming && (
119
+ <span className="flex items-center gap-1 text-xs text-green-400">
120
+ <span className="w-2 h-2 bg-green-400 rounded-full animate-pulse" />
121
+ Live
122
+ </span>
123
+ )}
124
+ </div>
125
+ {steps.length > 0 && (
126
+ <button
127
+ onClick={() => setSteps([])}
128
+ className="text-xs text-gray-500 hover:text-gray-300 transition-colors"
129
+ >
130
+ Clear
131
+ </button>
132
+ )}
133
+ </div>
134
+
135
+ {/* Tool call log */}
136
+ <div
137
+ ref={containerRef}
138
+ className="p-3 flex-1 overflow-y-auto font-mono text-sm"
139
+ >
140
+ {steps.length === 0 ? (
141
+ <div className="text-gray-500 text-center py-8">
142
+ <div className="text-2xl mb-2">🔧</div>
143
+ <p>Tool calls will appear here</p>
144
+ <p className="text-xs mt-2">When AI uses tools, you'll see them logged</p>
145
+ </div>
146
+ ) : (
147
+ <div className="space-y-2">
148
+ {steps.map((step) => (
149
+ <div
150
+ key={step.id}
151
+ className={`flex items-start gap-2 p-2 rounded-lg border ${getColor(step.type, step.status)}`}
152
+ >
153
+ <span className="flex-shrink-0 mt-0.5">
154
+ {getIcon(step.type, step.status)}
155
+ </span>
156
+ <div className="flex-1 min-w-0">
157
+ <span className="block break-words whitespace-pre-wrap">
158
+ {step.content}
159
+ </span>
160
+ <span className="text-xs opacity-50 mt-1 block">
161
+ {step.timestamp.toLocaleTimeString()}
162
+ </span>
163
+ </div>
164
+ </div>
165
+ ))}
166
+
167
+ {isStreaming && (
168
+ <div className="flex items-center gap-2 text-gray-500 mt-2 justify-center py-2">
169
+ <div className="w-3 h-3 border-2 border-gray-500 border-t-transparent rounded-full animate-spin" />
170
+ <span className="text-xs">Waiting for next action...</span>
171
+ </div>
172
+ )}
173
+ </div>
174
+ )}
175
+ </div>
176
+ </div>
177
+ );
178
+ }
chatkit/frontend/src/components/{ChatKitPanel.tsx → SimpleChatPanel.tsx} RENAMED
@@ -1,11 +1,12 @@
1
  import { ChatKit, useChatKit } from "@openai/chatkit-react";
2
- import { CHATKIT_API_DOMAIN_KEY, CHATKIT_API_URL } from "../lib/config";
3
 
4
- export function ChatKitPanel() {
 
 
 
5
  const chatkit = useChatKit({
6
  api: { url: CHATKIT_API_URL, domainKey: CHATKIT_API_DOMAIN_KEY },
7
  composer: {
8
- // File uploads are disabled for the demo backend.
9
  attachments: { enabled: false },
10
  },
11
  });
 
1
  import { ChatKit, useChatKit } from "@openai/chatkit-react";
 
2
 
3
+ const CHATKIT_API_URL = "/chatkit";
4
+ const CHATKIT_API_DOMAIN_KEY = "domain_pk_localhost_dev";
5
+
6
+ export function SimpleChatPanel() {
7
  const chatkit = useChatKit({
8
  api: { url: CHATKIT_API_URL, domainKey: CHATKIT_API_DOMAIN_KEY },
9
  composer: {
 
10
  attachments: { enabled: false },
11
  },
12
  });
chatkit/frontend/src/components/WorkflowStatus.tsx ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from "react";
2
+
3
+ interface StepData {
4
+ id: string;
5
+ status: "pending" | "active" | "completed" | "error";
6
+ detail?: string;
7
+ timestamp?: string;
8
+ }
9
+
10
+ interface StatusResponse {
11
+ current_step: string | null;
12
+ steps: StepData[];
13
+ started_at: string | null;
14
+ completed_at: string | null;
15
+ }
16
+
17
+ interface WorkflowStep {
18
+ id: string;
19
+ label: string;
20
+ icon: JSX.Element;
21
+ status: "pending" | "active" | "completed" | "error";
22
+ detail?: string;
23
+ }
24
+
25
+ interface WorkflowStatusProps {
26
+ sessionId?: string;
27
+ }
28
+
29
+ const STEP_CONFIG: { id: string; label: string; icon: JSX.Element; defaultDetail: string }[] = [
30
+ {
31
+ id: "fetch",
32
+ label: "Fetch Responses",
33
+ icon: (
34
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
35
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
36
+ </svg>
37
+ ),
38
+ defaultDetail: "Getting data from Google Sheets",
39
+ },
40
+ {
41
+ id: "normalize",
42
+ label: "Parse Data",
43
+ icon: (
44
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
45
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
46
+ </svg>
47
+ ),
48
+ defaultDetail: "Organizing questions and answers",
49
+ },
50
+ {
51
+ id: "grade",
52
+ label: "Grade Answers",
53
+ icon: (
54
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
55
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
56
+ </svg>
57
+ ),
58
+ defaultDetail: "Comparing with correct answers",
59
+ },
60
+ {
61
+ id: "explain",
62
+ label: "Generate Explanations",
63
+ icon: (
64
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
65
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
66
+ </svg>
67
+ ),
68
+ defaultDetail: "Explaining wrong answers",
69
+ },
70
+ {
71
+ id: "group",
72
+ label: "Create Peer Groups",
73
+ icon: (
74
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
75
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
76
+ </svg>
77
+ ),
78
+ defaultDetail: "Matching helpers with learners",
79
+ },
80
+ {
81
+ id: "report",
82
+ label: "Generate Report",
83
+ icon: (
84
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
85
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
86
+ </svg>
87
+ ),
88
+ defaultDetail: "Creating comprehensive report",
89
+ },
90
+ {
91
+ id: "email",
92
+ label: "Send Email",
93
+ icon: (
94
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
95
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
96
+ </svg>
97
+ ),
98
+ defaultDetail: "Delivering report to inbox",
99
+ },
100
+ ];
101
+
102
+ export function WorkflowStatus({ sessionId = "default" }: WorkflowStatusProps) {
103
+ const [steps, setSteps] = useState<WorkflowStep[]>(
104
+ STEP_CONFIG.map(s => ({ ...s, status: "pending" as const, detail: s.defaultDetail }))
105
+ );
106
+ const [isConnected, setIsConnected] = useState(false);
107
+ const [isAnalyzing, setIsAnalyzing] = useState(false);
108
+
109
+ useEffect(() => {
110
+ let eventSource: EventSource | null = null;
111
+ let retryCount = 0;
112
+ const maxRetries = 3;
113
+
114
+ const connect = () => {
115
+ eventSource = new EventSource(`/api/status/${sessionId}/stream`);
116
+
117
+ eventSource.onopen = () => {
118
+ setIsConnected(true);
119
+ retryCount = 0;
120
+ };
121
+
122
+ eventSource.onmessage = (event) => {
123
+ try {
124
+ const data: StatusResponse = JSON.parse(event.data);
125
+
126
+ // Check if there's any activity
127
+ const hasActivity = data.steps && data.steps.length > 0;
128
+ setIsAnalyzing(hasActivity && !data.completed_at);
129
+
130
+ // Update steps based on server data
131
+ setSteps(prevSteps => {
132
+ return prevSteps.map(step => {
133
+ const serverStep = data.steps?.find(s => s.id === step.id);
134
+ if (serverStep) {
135
+ return {
136
+ ...step,
137
+ status: serverStep.status as WorkflowStep["status"],
138
+ detail: serverStep.detail || step.detail,
139
+ };
140
+ }
141
+ return step;
142
+ });
143
+ });
144
+ } catch (e) {
145
+ console.error("Error parsing status:", e);
146
+ }
147
+ };
148
+
149
+ eventSource.onerror = () => {
150
+ setIsConnected(false);
151
+ eventSource?.close();
152
+
153
+ // Retry connection
154
+ if (retryCount < maxRetries) {
155
+ retryCount++;
156
+ setTimeout(connect, 2000 * retryCount);
157
+ }
158
+ };
159
+ };
160
+
161
+ connect();
162
+
163
+ return () => {
164
+ eventSource?.close();
165
+ };
166
+ }, [sessionId]);
167
+
168
+ const getStatusColor = (status: WorkflowStep["status"]) => {
169
+ switch (status) {
170
+ case "completed": return "bg-[var(--color-success)] text-white";
171
+ case "active": return "bg-[var(--color-accent)] text-white animate-pulse";
172
+ case "error": return "bg-red-500 text-white";
173
+ default: return "bg-[var(--color-border)] text-[var(--color-text-muted)]";
174
+ }
175
+ };
176
+
177
+ const getLineColor = (status: WorkflowStep["status"]) => {
178
+ switch (status) {
179
+ case "completed": return "bg-[var(--color-success)]";
180
+ case "active": return "bg-[var(--color-accent)]";
181
+ default: return "bg-[var(--color-border)]";
182
+ }
183
+ };
184
+
185
+ const activeStep = steps.find(s => s.status === "active");
186
+ const completedCount = steps.filter(s => s.status === "completed").length;
187
+
188
+ return (
189
+ <div className="card p-4">
190
+ {/* Header */}
191
+ <div className="flex items-center justify-between mb-4">
192
+ <div className="flex items-center gap-2">
193
+ <div className={`w-2 h-2 rounded-full ${isAnalyzing ? 'bg-[var(--color-accent)] animate-pulse' : isConnected ? 'bg-[var(--color-success)]' : 'bg-[var(--color-border)]'}`} />
194
+ <h3 className="font-display text-sm font-semibold text-[var(--color-text)]">
195
+ {isAnalyzing ? "AI Agent Working..." : "Agent Status"}
196
+ </h3>
197
+ </div>
198
+ {completedCount > 0 && (
199
+ <span className="text-xs text-[var(--color-text-muted)]">
200
+ {completedCount}/{steps.length}
201
+ </span>
202
+ )}
203
+ </div>
204
+
205
+ {/* Active step highlight */}
206
+ {activeStep && (
207
+ <div className="mb-4 p-3 rounded-lg bg-[var(--color-accent)]/10 border border-[var(--color-accent)]/20">
208
+ <div className="flex items-center gap-2">
209
+ <div className="w-5 h-5 rounded-full bg-[var(--color-accent)] flex items-center justify-center animate-pulse">
210
+ <div className="w-2 h-2 bg-white rounded-full" />
211
+ </div>
212
+ <div>
213
+ <div className="text-sm font-medium text-[var(--color-accent)]">
214
+ {activeStep.label}
215
+ </div>
216
+ <div className="text-xs text-[var(--color-text-muted)]">
217
+ {activeStep.detail}
218
+ </div>
219
+ </div>
220
+ </div>
221
+ </div>
222
+ )}
223
+
224
+ {/* Steps list */}
225
+ <div className="space-y-1">
226
+ {steps.map((step, index) => (
227
+ <div key={step.id} className="relative">
228
+ {/* Connector line */}
229
+ {index < steps.length - 1 && (
230
+ <div
231
+ className={`absolute left-[11px] top-[24px] w-0.5 h-4 transition-colors duration-300 ${getLineColor(step.status)}`}
232
+ />
233
+ )}
234
+
235
+ <div className="flex items-center gap-3 py-1">
236
+ {/* Icon circle */}
237
+ <div
238
+ className={`flex-shrink-0 w-6 h-6 rounded-full flex items-center justify-center transition-all duration-300 ${getStatusColor(step.status)}`}
239
+ >
240
+ {step.status === "completed" ? (
241
+ <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
242
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
243
+ </svg>
244
+ ) : step.status === "active" ? (
245
+ <div className="w-2 h-2 bg-white rounded-full" />
246
+ ) : step.status === "error" ? (
247
+ <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
248
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M6 18L18 6M6 6l12 12" />
249
+ </svg>
250
+ ) : (
251
+ <span className="text-[10px]">{index + 1}</span>
252
+ )}
253
+ </div>
254
+
255
+ {/* Label */}
256
+ <div className={`text-sm transition-colors ${
257
+ step.status === "active"
258
+ ? "font-medium text-[var(--color-accent)]"
259
+ : step.status === "completed"
260
+ ? "text-[var(--color-success)]"
261
+ : step.status === "error"
262
+ ? "text-red-500"
263
+ : "text-[var(--color-text-muted)]"
264
+ }`}>
265
+ {step.label}
266
+ </div>
267
+ </div>
268
+ </div>
269
+ ))}
270
+ </div>
271
+
272
+ {/* Connection status */}
273
+ <div className="mt-4 pt-3 border-t border-[var(--color-border)]">
274
+ <div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
275
+ <div className={`w-1.5 h-1.5 rounded-full ${isConnected ? 'bg-green-500' : 'bg-gray-400'}`} />
276
+ {isConnected ? "Live updates" : "Connecting..."}
277
+ </div>
278
+ </div>
279
+ </div>
280
+ );
281
+ }
chatkit/frontend/src/index.css CHANGED
@@ -1,34 +1,315 @@
1
  @import "tailwindcss";
2
 
3
  :root {
4
- --background: #ffffff;
5
- --foreground: #171717;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  color-scheme: light;
7
  }
8
 
9
  :root[data-color-scheme="dark"] {
10
- --background: #0a0a0a;
11
- --foreground: #ededed;
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  color-scheme: dark;
13
  }
14
 
15
  @media (prefers-color-scheme: dark) {
16
  :root:not([data-color-scheme]) {
17
- --background: #0a0a0a;
18
- --foreground: #ededed;
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  color-scheme: dark;
20
  }
21
  }
22
 
23
  @theme inline {
24
- --color-background: var(--background);
25
- --color-foreground: var(--foreground);
26
- --font-sans: Arial, Helvetica, sans-serif;
27
- --font-mono: SFMono-Regular, Consolas, "Liberation Mono", monospace;
 
 
 
 
 
 
 
 
28
  }
29
 
30
  body {
31
- background: var(--background);
32
- color: var(--foreground);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  font-family: var(--font-sans);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  }
 
1
  @import "tailwindcss";
2
 
3
  :root {
4
+ /* Warm, educational color palette */
5
+ --color-primary: #1e3a5f;
6
+ --color-primary-light: #2d5a87;
7
+ --color-accent: #e76f51;
8
+ --color-accent-light: #f4a261;
9
+ --color-success: #2a9d8f;
10
+ --color-warning: #e9c46a;
11
+ --color-background: #faf8f5;
12
+ --color-surface: #ffffff;
13
+ --color-text: #1a1a2e;
14
+ --color-text-muted: #64748b;
15
+ --color-border: #e2e0dc;
16
+
17
+ /* Glassmorphism */
18
+ --glass-bg: rgba(255, 255, 255, 0.85);
19
+ --glass-border: rgba(255, 255, 255, 0.5);
20
+
21
  color-scheme: light;
22
  }
23
 
24
  :root[data-color-scheme="dark"] {
25
+ --color-primary: #5b8fb9;
26
+ --color-primary-light: #7eb8da;
27
+ --color-accent: #f4a261;
28
+ --color-accent-light: #e76f51;
29
+ --color-success: #4fd1c5;
30
+ --color-warning: #fbd38d;
31
+ --color-background: #0f172a;
32
+ --color-surface: #1e293b;
33
+ --color-text: #f1f5f9;
34
+ --color-text-muted: #94a3b8;
35
+ --color-border: #334155;
36
+
37
+ --glass-bg: rgba(30, 41, 59, 0.85);
38
+ --glass-border: rgba(51, 65, 85, 0.5);
39
+
40
  color-scheme: dark;
41
  }
42
 
43
  @media (prefers-color-scheme: dark) {
44
  :root:not([data-color-scheme]) {
45
+ --color-primary: #5b8fb9;
46
+ --color-primary-light: #7eb8da;
47
+ --color-accent: #f4a261;
48
+ --color-accent-light: #e76f51;
49
+ --color-success: #4fd1c5;
50
+ --color-warning: #fbd38d;
51
+ --color-background: #0f172a;
52
+ --color-surface: #1e293b;
53
+ --color-text: #f1f5f9;
54
+ --color-text-muted: #94a3b8;
55
+ --color-border: #334155;
56
+
57
+ --glass-bg: rgba(30, 41, 59, 0.85);
58
+ --glass-border: rgba(51, 65, 85, 0.5);
59
+
60
  color-scheme: dark;
61
  }
62
  }
63
 
64
  @theme inline {
65
+ --color-background: var(--color-background);
66
+ --color-foreground: var(--color-text);
67
+ --font-sans: 'Instrument Sans', system-ui, sans-serif;
68
+ --font-display: 'Fraunces', Georgia, serif;
69
+ }
70
+
71
+ * {
72
+ box-sizing: border-box;
73
+ }
74
+
75
+ html {
76
+ scroll-behavior: smooth;
77
  }
78
 
79
  body {
80
+ margin: 0;
81
+ padding: 0;
82
+ background: var(--color-background);
83
+ color: var(--color-text);
84
+ font-family: var(--font-sans);
85
+ font-size: 16px;
86
+ line-height: 1.6;
87
+ -webkit-font-smoothing: antialiased;
88
+ -moz-osx-font-smoothing: grayscale;
89
+ }
90
+
91
+ /* Background pattern */
92
+ body::before {
93
+ content: '';
94
+ position: fixed;
95
+ top: 0;
96
+ left: 0;
97
+ right: 0;
98
+ bottom: 0;
99
+ background:
100
+ radial-gradient(circle at 20% 20%, rgba(231, 111, 81, 0.08) 0%, transparent 50%),
101
+ radial-gradient(circle at 80% 80%, rgba(42, 157, 143, 0.08) 0%, transparent 50%),
102
+ radial-gradient(circle at 50% 50%, rgba(30, 58, 95, 0.05) 0%, transparent 70%);
103
+ pointer-events: none;
104
+ z-index: -1;
105
+ }
106
+
107
+ /* Typography */
108
+ h1, h2, h3, h4, h5, h6 {
109
+ font-family: var(--font-display);
110
+ font-weight: 600;
111
+ line-height: 1.2;
112
+ margin: 0;
113
+ }
114
+
115
+ /* Custom scrollbar */
116
+ ::-webkit-scrollbar {
117
+ width: 8px;
118
+ height: 8px;
119
+ }
120
+
121
+ ::-webkit-scrollbar-track {
122
+ background: transparent;
123
+ }
124
+
125
+ ::-webkit-scrollbar-thumb {
126
+ background: var(--color-border);
127
+ border-radius: 4px;
128
+ }
129
+
130
+ ::-webkit-scrollbar-thumb:hover {
131
+ background: var(--color-text-muted);
132
+ }
133
+
134
+ /* Button styles */
135
+ .btn {
136
+ display: inline-flex;
137
+ align-items: center;
138
+ justify-content: center;
139
+ gap: 0.5rem;
140
+ padding: 0.75rem 1.5rem;
141
  font-family: var(--font-sans);
142
+ font-weight: 600;
143
+ font-size: 0.95rem;
144
+ border-radius: 12px;
145
+ border: none;
146
+ cursor: pointer;
147
+ transition: all 0.2s ease;
148
+ text-decoration: none;
149
+ }
150
+
151
+ .btn-primary {
152
+ background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-light) 100%);
153
+ color: white;
154
+ box-shadow: 0 4px 14px rgba(30, 58, 95, 0.25);
155
+ }
156
+
157
+ .btn-primary:hover {
158
+ transform: translateY(-2px);
159
+ box-shadow: 0 6px 20px rgba(30, 58, 95, 0.35);
160
+ }
161
+
162
+ .btn-accent {
163
+ background: linear-gradient(135deg, var(--color-accent) 0%, var(--color-accent-light) 100%);
164
+ color: white;
165
+ box-shadow: 0 4px 14px rgba(231, 111, 81, 0.25);
166
+ }
167
+
168
+ .btn-accent:hover {
169
+ transform: translateY(-2px);
170
+ box-shadow: 0 6px 20px rgba(231, 111, 81, 0.35);
171
+ }
172
+
173
+ .btn-outline {
174
+ background: transparent;
175
+ color: var(--color-text);
176
+ border: 2px solid var(--color-border);
177
+ }
178
+
179
+ .btn-outline:hover {
180
+ border-color: var(--color-primary);
181
+ color: var(--color-primary);
182
+ }
183
+
184
+ /* Card styles */
185
+ .card {
186
+ background: var(--glass-bg);
187
+ backdrop-filter: blur(20px);
188
+ border: 1px solid var(--glass-border);
189
+ border-radius: 20px;
190
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
191
+ }
192
+
193
+ /* Input styles */
194
+ .input {
195
+ width: 100%;
196
+ padding: 0.875rem 1rem;
197
+ font-family: var(--font-sans);
198
+ font-size: 1rem;
199
+ color: var(--color-text);
200
+ background: var(--color-surface);
201
+ border: 2px solid var(--color-border);
202
+ border-radius: 12px;
203
+ outline: none;
204
+ transition: all 0.2s ease;
205
+ }
206
+
207
+ .input:focus {
208
+ border-color: var(--color-primary);
209
+ box-shadow: 0 0 0 4px rgba(30, 58, 95, 0.1);
210
+ }
211
+
212
+ .input::placeholder {
213
+ color: var(--color-text-muted);
214
+ }
215
+
216
+ /* Badge styles */
217
+ .badge {
218
+ display: inline-flex;
219
+ align-items: center;
220
+ gap: 0.25rem;
221
+ padding: 0.25rem 0.75rem;
222
+ font-size: 0.8rem;
223
+ font-weight: 600;
224
+ border-radius: 9999px;
225
+ }
226
+
227
+ .badge-success {
228
+ background: rgba(42, 157, 143, 0.15);
229
+ color: var(--color-success);
230
+ }
231
+
232
+ .badge-warning {
233
+ background: rgba(233, 196, 106, 0.2);
234
+ color: #b7791f;
235
+ }
236
+
237
+ /* Animations */
238
+ @keyframes fadeInUp {
239
+ from {
240
+ opacity: 0;
241
+ transform: translateY(20px);
242
+ }
243
+ to {
244
+ opacity: 1;
245
+ transform: translateY(0);
246
+ }
247
+ }
248
+
249
+ @keyframes pulse {
250
+ 0%, 100% {
251
+ opacity: 1;
252
+ }
253
+ 50% {
254
+ opacity: 0.6;
255
+ }
256
+ }
257
+
258
+ @keyframes float {
259
+ 0%, 100% {
260
+ transform: translateY(0);
261
+ }
262
+ 50% {
263
+ transform: translateY(-10px);
264
+ }
265
+ }
266
+
267
+ .animate-fade-in-up {
268
+ animation: fadeInUp 0.6s ease-out forwards;
269
+ }
270
+
271
+ .animate-pulse {
272
+ animation: pulse 2s ease-in-out infinite;
273
+ }
274
+
275
+ .animate-float {
276
+ animation: float 6s ease-in-out infinite;
277
+ }
278
+
279
+ /* Staggered animations */
280
+ .stagger-1 { animation-delay: 0.1s; }
281
+ .stagger-2 { animation-delay: 0.2s; }
282
+ .stagger-3 { animation-delay: 0.3s; }
283
+ .stagger-4 { animation-delay: 0.4s; }
284
+ .stagger-5 { animation-delay: 0.5s; }
285
+
286
+ /* Utility classes */
287
+ .text-gradient {
288
+ background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%);
289
+ -webkit-background-clip: text;
290
+ -webkit-text-fill-color: transparent;
291
+ background-clip: text;
292
+ }
293
+
294
+ /* ChatKit customization */
295
+ .chatkit-container {
296
+ --ck-color-primary: var(--color-primary);
297
+ --ck-color-background: var(--color-surface);
298
+ --ck-border-radius: 16px;
299
+ display: flex;
300
+ flex-direction: column;
301
+ }
302
+
303
+ /* Ensure ChatKit elements are visible */
304
+ .chatkit-container > * {
305
+ min-height: 0;
306
+ flex: 1;
307
+ }
308
+
309
+ /* Make sure the input area is always visible */
310
+ [data-chatkit-composer],
311
+ [class*="composer"],
312
+ [class*="Composer"] {
313
+ position: relative !important;
314
+ z-index: 10;
315
  }
chatkit/frontend/src/lib/config.ts CHANGED
@@ -6,6 +6,9 @@ const readEnvString = (value: unknown): string | undefined =>
6
  export const CHATKIT_API_URL =
7
  readEnvString(import.meta.env.VITE_CHATKIT_API_URL) ?? "/chatkit";
8
 
 
 
 
9
  /**
10
  * ChatKit requires a domain key at runtime. Use the local fallback while
11
  * developing, and register a production domain key for deployment:
@@ -14,3 +17,6 @@ export const CHATKIT_API_URL =
14
  export const CHATKIT_API_DOMAIN_KEY =
15
  readEnvString(import.meta.env.VITE_CHATKIT_API_DOMAIN_KEY) ??
16
  "domain_pk_localhost_dev";
 
 
 
 
6
  export const CHATKIT_API_URL =
7
  readEnvString(import.meta.env.VITE_CHATKIT_API_URL) ?? "/chatkit";
8
 
9
+ export const API_BASE_URL =
10
+ readEnvString(import.meta.env.VITE_API_BASE_URL) ?? "";
11
+
12
  /**
13
  * ChatKit requires a domain key at runtime. Use the local fallback while
14
  * developing, and register a production domain key for deployment:
 
17
  export const CHATKIT_API_DOMAIN_KEY =
18
  readEnvString(import.meta.env.VITE_CHATKIT_API_DOMAIN_KEY) ??
19
  "domain_pk_localhost_dev";
20
+
21
+ export const GOOGLE_AUTH_URL = `${API_BASE_URL}/auth/start`;
22
+ export const AUTH_STATUS_URL = `${API_BASE_URL}/auth/status`;
chatkit/frontend/vite.config.ts CHANGED
@@ -16,6 +16,14 @@ export default defineConfig({
16
  target: backendTarget,
17
  changeOrigin: true,
18
  },
 
 
 
 
 
 
 
 
19
  },
20
  },
21
  });
 
16
  target: backendTarget,
17
  changeOrigin: true,
18
  },
19
+ "/auth": {
20
+ target: backendTarget,
21
+ changeOrigin: true,
22
+ },
23
+ "/api": {
24
+ target: backendTarget,
25
+ changeOrigin: true,
26
+ },
27
  },
28
  },
29
  });
chatkit/package-lock.json CHANGED
@@ -1,12 +1,25 @@
1
  {
2
- "name": "chatkit",
 
3
  "lockfileVersion": 3,
4
  "requires": true,
5
  "packages": {
6
  "": {
7
- "name": "chatkit",
 
 
 
8
  "devDependencies": {
9
- "concurrently": "^9.1.2"
 
 
 
 
 
 
 
 
 
10
  }
11
  },
12
  "node_modules/ansi-regex": {
@@ -101,30 +114,48 @@
101
  "license": "MIT"
102
  },
103
  "node_modules/concurrently": {
104
- "version": "9.2.1",
105
- "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
106
- "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
107
  "dev": true,
108
- "license": "MIT",
109
  "dependencies": {
110
- "chalk": "4.1.2",
111
- "rxjs": "7.8.2",
112
- "shell-quote": "1.8.3",
113
- "supports-color": "8.1.1",
114
- "tree-kill": "1.2.2",
115
- "yargs": "17.7.2"
 
 
 
116
  },
117
  "bin": {
118
  "conc": "dist/bin/concurrently.js",
119
  "concurrently": "dist/bin/concurrently.js"
120
  },
121
  "engines": {
122
- "node": ">=18"
123
  },
124
  "funding": {
125
  "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
126
  }
127
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  "node_modules/emoji-regex": {
129
  "version": "8.0.0",
130
  "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -172,6 +203,12 @@
172
  "node": ">=8"
173
  }
174
  },
 
 
 
 
 
 
175
  "node_modules/require-directory": {
176
  "version": "2.1.1",
177
  "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -205,6 +242,12 @@
205
  "url": "https://github.com/sponsors/ljharb"
206
  }
207
  },
 
 
 
 
 
 
208
  "node_modules/string-width": {
209
  "version": "4.2.3",
210
  "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
 
1
  {
2
+ "name": "examinsight",
3
+ "version": "1.0.0",
4
  "lockfileVersion": 3,
5
  "requires": true,
6
  "packages": {
7
  "": {
8
+ "name": "examinsight",
9
+ "version": "1.0.0",
10
+ "hasInstallScript": true,
11
+ "license": "MIT",
12
  "devDependencies": {
13
+ "concurrently": "^8.2.2"
14
+ }
15
+ },
16
+ "node_modules/@babel/runtime": {
17
+ "version": "7.28.6",
18
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
19
+ "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
20
+ "dev": true,
21
+ "engines": {
22
+ "node": ">=6.9.0"
23
  }
24
  },
25
  "node_modules/ansi-regex": {
 
114
  "license": "MIT"
115
  },
116
  "node_modules/concurrently": {
117
+ "version": "8.2.2",
118
+ "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
119
+ "integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
120
  "dev": true,
 
121
  "dependencies": {
122
+ "chalk": "^4.1.2",
123
+ "date-fns": "^2.30.0",
124
+ "lodash": "^4.17.21",
125
+ "rxjs": "^7.8.1",
126
+ "shell-quote": "^1.8.1",
127
+ "spawn-command": "0.0.2",
128
+ "supports-color": "^8.1.1",
129
+ "tree-kill": "^1.2.2",
130
+ "yargs": "^17.7.2"
131
  },
132
  "bin": {
133
  "conc": "dist/bin/concurrently.js",
134
  "concurrently": "dist/bin/concurrently.js"
135
  },
136
  "engines": {
137
+ "node": "^14.13.0 || >=16.0.0"
138
  },
139
  "funding": {
140
  "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
141
  }
142
  },
143
+ "node_modules/date-fns": {
144
+ "version": "2.30.0",
145
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
146
+ "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
147
+ "dev": true,
148
+ "dependencies": {
149
+ "@babel/runtime": "^7.21.0"
150
+ },
151
+ "engines": {
152
+ "node": ">=0.11"
153
+ },
154
+ "funding": {
155
+ "type": "opencollective",
156
+ "url": "https://opencollective.com/date-fns"
157
+ }
158
+ },
159
  "node_modules/emoji-regex": {
160
  "version": "8.0.0",
161
  "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
 
203
  "node": ">=8"
204
  }
205
  },
206
+ "node_modules/lodash": {
207
+ "version": "4.17.23",
208
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
209
+ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
210
+ "dev": true
211
+ },
212
  "node_modules/require-directory": {
213
  "version": "2.1.1",
214
  "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
 
242
  "url": "https://github.com/sponsors/ljharb"
243
  }
244
  },
245
+ "node_modules/spawn-command": {
246
+ "version": "0.0.2",
247
+ "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
248
+ "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
249
+ "dev": true
250
+ },
251
  "node_modules/string-width": {
252
  "version": "4.2.3",
253
  "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
chatkit/package.json CHANGED
@@ -1,12 +1,27 @@
1
  {
2
- "name": "chatkit",
 
 
3
  "private": true,
4
  "scripts": {
5
- "dev": "concurrently --kill-others-on-fail --names backend,frontend \"npm run backend\" \"npm run frontend\"",
6
- "frontend": "npm --prefix frontend install && npm --prefix frontend run dev",
7
- "backend": "./backend/scripts/run.sh"
 
 
 
8
  },
 
 
 
 
 
 
 
 
 
 
9
  "devDependencies": {
10
- "concurrently": "^9.1.2"
11
  }
12
  }
 
1
  {
2
+ "name": "examinsight",
3
+ "version": "1.0.0",
4
+ "description": "ExamInsight - AI-Powered Exam Analysis for Teachers using OpenAI ChatKit",
5
  "private": true,
6
  "scripts": {
7
+ "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
8
+ "dev:backend": "cd backend && ./scripts/run.sh",
9
+ "dev:frontend": "cd frontend && npm run dev",
10
+ "install": "cd frontend && npm install && cd ../backend && pip install -e .",
11
+ "build": "cd frontend && npm run build",
12
+ "lint": "cd frontend && npm run lint"
13
  },
14
+ "keywords": [
15
+ "examinsight",
16
+ "openai",
17
+ "chatkit",
18
+ "education",
19
+ "exam-analysis",
20
+ "teacher-tools"
21
+ ],
22
+ "author": "",
23
+ "license": "MIT",
24
  "devDependencies": {
25
+ "concurrently": "^8.2.2"
26
  }
27
  }