Spaces:
Runtime error
Runtime error
chih.yikuan commited on
Commit ·
054d73a
1
Parent(s): b0fbfe1
🚀 ExamInsight: AI-powered exam analysis for teachers
Browse filesFeatures:
- 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 +33 -0
- chatkit/.gitignore +39 -2
- chatkit/DEPLOY.md +173 -0
- chatkit/Dockerfile +59 -0
- chatkit/README.md +57 -22
- chatkit/backend/app/__init__.py +3 -1
- chatkit/backend/app/config.py +67 -0
- chatkit/backend/app/database.py +137 -0
- chatkit/backend/app/email_service.py +250 -0
- chatkit/backend/app/google_sheets.py +408 -0
- chatkit/backend/app/main.py +215 -5
- chatkit/backend/app/oauth.py +165 -0
- chatkit/backend/app/server.py +390 -15
- chatkit/backend/app/status_tracker.py +102 -0
- chatkit/backend/pyproject.toml +15 -4
- chatkit/backend/scripts/run.sh +22 -30
- chatkit/env.example +50 -0
- chatkit/frontend/index.html +5 -2
- chatkit/frontend/package-lock.json +4 -4
- chatkit/frontend/package.json +3 -2
- chatkit/frontend/src/App.tsx +94 -6
- chatkit/frontend/src/TestChat.tsx +12 -0
- chatkit/frontend/src/components/AuthStatus.tsx +212 -0
- chatkit/frontend/src/components/ExamAnalyzer.tsx +182 -0
- chatkit/frontend/src/components/Features.tsx +137 -0
- chatkit/frontend/src/components/Header.tsx +45 -0
- chatkit/frontend/src/components/Hero.tsx +76 -0
- chatkit/frontend/src/components/ReasoningPanel.tsx +178 -0
- chatkit/frontend/src/components/{ChatKitPanel.tsx → SimpleChatPanel.tsx} +4 -3
- chatkit/frontend/src/components/WorkflowStatus.tsx +281 -0
- chatkit/frontend/src/index.css +293 -12
- chatkit/frontend/src/lib/config.ts +6 -0
- chatkit/frontend/vite.config.ts +8 -0
- chatkit/package-lock.json +57 -14
- chatkit/package.json +20 -5
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 |
-
.
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
requests to OpenAI through the ChatKit server library.
|
| 5 |
|
| 6 |
-
|
| 7 |
|
| 8 |
-
|
| 9 |
-
npm install
|
| 10 |
-
npm run dev
|
| 11 |
-
```
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
|
| 16 |
-
frontend on `127.0.0.1:3000` with a proxy at `/chatkit`.
|
| 17 |
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
-
|
| 21 |
-
- `VITE_CHATKIT_API_URL` (optional, defaults to `/chatkit`)
|
| 22 |
-
- `VITE_CHATKIT_API_DOMAIN_KEY` (optional, defaults to `domain_pk_localhost_dev`)
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
|
| 28 |
-
##
|
| 29 |
|
| 30 |
-
-
|
| 31 |
-
-
|
| 32 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
app.add_middleware(
|
| 15 |
CORSMiddleware,
|
|
@@ -19,9 +49,17 @@ app.add_middleware(
|
|
| 19 |
allow_headers=["*"],
|
| 20 |
)
|
| 21 |
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
|
| 16 |
-
MAX_RECENT_ITEMS =
|
| 17 |
-
MODEL = "gpt-4.1-mini"
|
| 18 |
|
|
|
|
|
|
|
| 19 |
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
model=MODEL,
|
| 22 |
-
name="
|
| 23 |
-
instructions=
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
| 28 |
)
|
| 29 |
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 = "
|
| 3 |
version = "0.1.0"
|
| 4 |
-
description = "
|
| 5 |
-
requires-python = ">=3.
|
| 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 |
-
#!/
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
set -euo pipefail
|
| 6 |
|
|
|
|
| 7 |
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
| 8 |
-
|
| 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 |
-
|
| 20 |
-
pip install -e . >/dev/null
|
| 21 |
|
| 22 |
-
# Load
|
| 23 |
-
|
| 24 |
-
|
| 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
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
fi
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
|
|
|
| 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>
|
|
|
|
| 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": "
|
| 3 |
-
"version": "
|
| 4 |
"lockfileVersion": 3,
|
| 5 |
"requires": true,
|
| 6 |
"packages": {
|
| 7 |
"": {
|
| 8 |
-
"name": "
|
| 9 |
-
"version": "
|
| 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": "
|
| 3 |
-
"version": "
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
export default function App() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
return (
|
| 5 |
-
<
|
| 6 |
-
<
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 5 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
color-scheme: light;
|
| 7 |
}
|
| 8 |
|
| 9 |
:root[data-color-scheme="dark"] {
|
| 10 |
-
--
|
| 11 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
color-scheme: dark;
|
| 13 |
}
|
| 14 |
|
| 15 |
@media (prefers-color-scheme: dark) {
|
| 16 |
:root:not([data-color-scheme]) {
|
| 17 |
-
--
|
| 18 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
color-scheme: dark;
|
| 20 |
}
|
| 21 |
}
|
| 22 |
|
| 23 |
@theme inline {
|
| 24 |
-
--color-background: var(--background);
|
| 25 |
-
--color-foreground: var(--
|
| 26 |
-
--font-sans:
|
| 27 |
-
--font-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
}
|
| 29 |
|
| 30 |
body {
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": "
|
|
|
|
| 3 |
"lockfileVersion": 3,
|
| 4 |
"requires": true,
|
| 5 |
"packages": {
|
| 6 |
"": {
|
| 7 |
-
"name": "
|
|
|
|
|
|
|
|
|
|
| 8 |
"devDependencies": {
|
| 9 |
-
"concurrently": "^
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
}
|
| 11 |
},
|
| 12 |
"node_modules/ansi-regex": {
|
|
@@ -101,30 +114,48 @@
|
|
| 101 |
"license": "MIT"
|
| 102 |
},
|
| 103 |
"node_modules/concurrently": {
|
| 104 |
-
"version": "
|
| 105 |
-
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-
|
| 106 |
-
"integrity": "sha512-
|
| 107 |
"dev": true,
|
| 108 |
-
"license": "MIT",
|
| 109 |
"dependencies": {
|
| 110 |
-
"chalk": "4.1.2",
|
| 111 |
-
"
|
| 112 |
-
"
|
| 113 |
-
"
|
| 114 |
-
"
|
| 115 |
-
"
|
|
|
|
|
|
|
|
|
|
| 116 |
},
|
| 117 |
"bin": {
|
| 118 |
"conc": "dist/bin/concurrently.js",
|
| 119 |
"concurrently": "dist/bin/concurrently.js"
|
| 120 |
},
|
| 121 |
"engines": {
|
| 122 |
-
"node": ">=
|
| 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": "
|
|
|
|
|
|
|
| 3 |
"private": true,
|
| 4 |
"scripts": {
|
| 5 |
-
"dev": "concurrently
|
| 6 |
-
"
|
| 7 |
-
"
|
|
|
|
|
|
|
|
|
|
| 8 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
"devDependencies": {
|
| 10 |
-
"concurrently": "^
|
| 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 |
}
|