Spaces:
Sleeping
Initial commit: LLM Code Deployment System
Browse filesThis commit includes:
- Student API for receiving tasks and deploying to GitHub Pages
- Instructor system for task generation and evaluation
- Docker configuration for Hugging Face Spaces deployment
- AIPipe integration for cost-effective LLM access
- Comprehensive documentation for deployment and usage
Features:
- FastAPI-based REST APIs for student and instructor systems
- LLM-powered code generation (supports Anthropic, OpenAI, AIPipe)
- Automated GitHub repository creation and Pages deployment
- Multi-level evaluation (static, dynamic, LLM-based)
- PostgreSQL database for task and result tracking
- Playwright-based dynamic testing
- Round 1 and Round 2 task support
Documentation:
- README.md: Project overview and setup
- DEPLOYMENT.md: Step-by-step student deployment guide
- README_SPACES.md: Hugging Face Spaces configuration
- QUICKSTART.md: Quick start guide for local development
🚀 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
- .env.example +38 -0
- .gitignore +40 -0
- .python-version +1 -0
- DEPLOYMENT.md +317 -0
- Dockerfile +41 -0
- LICENSE +21 -0
- QUICKSTART.md +185 -0
- README.md +411 -0
- README_SPACES.md +180 -0
- instructor/__init__.py +1 -0
- instructor/api.py +168 -0
- instructor/checks/__init__.py +1 -0
- instructor/checks/dynamic_checks.py +216 -0
- instructor/checks/llm_checks.py +209 -0
- instructor/checks/static_checks.py +204 -0
- instructor/database.py +307 -0
- instructor/evaluate.py +258 -0
- instructor/round1.py +223 -0
- instructor/round2.py +227 -0
- instructor/task_templates.py +312 -0
- main.py +95 -0
- pyproject.toml +52 -0
- requirements.txt +21 -0
- shared/__init__.py +1 -0
- shared/config.py +82 -0
- shared/logger.py +39 -0
- shared/models.py +93 -0
- shared/utils.py +57 -0
- student/__init__.py +1 -0
- student/api.py +276 -0
- student/code_generator.py +276 -0
- student/github_manager.py +319 -0
- student/notification_client.py +86 -0
- submissions.csv.example +3 -0
- templates/github-user-created.yaml +25 -0
- templates/markdown-to-html.yaml +28 -0
- templates/sum-of-sales.yaml +31 -0
- uv.lock +0 -0
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Student Configuration
|
| 2 |
+
STUDENT_SECRET=your-secret-key-here
|
| 3 |
+
STUDENT_EMAIL=student@example.com
|
| 4 |
+
STUDENT_API_PORT=8000
|
| 5 |
+
|
| 6 |
+
# GitHub Configuration
|
| 7 |
+
GITHUB_TOKEN=ghp_your_github_personal_access_token
|
| 8 |
+
GITHUB_USERNAME=your-github-username
|
| 9 |
+
|
| 10 |
+
# LLM Configuration (Choose one)
|
| 11 |
+
# For AIPipe (recommended for IIT Madras students - $2/month free)
|
| 12 |
+
LLM_PROVIDER=aipipe # aipipe, anthropic, or openai
|
| 13 |
+
LLM_MODEL=google/gemini-2.0-flash-lite-001 # Model to use with AIPipe
|
| 14 |
+
AIPIPE_TOKEN=your-aipipe-token-from-https://aipipe.org/login
|
| 15 |
+
AIPIPE_BASE_URL=https://aipipe.org/openrouter/v1 # or https://aipipe.org/openai/v1
|
| 16 |
+
|
| 17 |
+
# Alternative: Direct API access
|
| 18 |
+
ANTHROPIC_API_KEY=sk-ant-your-anthropic-api-key
|
| 19 |
+
OPENAI_API_KEY=sk-your-openai-api-key
|
| 20 |
+
|
| 21 |
+
# Instructor Configuration
|
| 22 |
+
INSTRUCTOR_API_PORT=8001
|
| 23 |
+
DATABASE_URL=postgresql://user:password@localhost:5432/llm_deployment
|
| 24 |
+
EVALUATION_API_URL=http://localhost:8001/api/evaluate
|
| 25 |
+
|
| 26 |
+
# Task Configuration
|
| 27 |
+
TASK_TIMEOUT_MINUTES=10
|
| 28 |
+
MAX_RETRY_ATTEMPTS=3
|
| 29 |
+
RETRY_DELAYS=1,2,4,8
|
| 30 |
+
|
| 31 |
+
# Paths
|
| 32 |
+
GENERATED_REPOS_DIR=./generated_repos
|
| 33 |
+
TASK_TEMPLATES_DIR=./templates
|
| 34 |
+
SUBMISSIONS_CSV=./submissions.csv
|
| 35 |
+
|
| 36 |
+
# Logging
|
| 37 |
+
LOG_LEVEL=INFO
|
| 38 |
+
LOG_FILE=./logs/app.log
|
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python-generated files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[oc]
|
| 4 |
+
build/
|
| 5 |
+
dist/
|
| 6 |
+
wheels/
|
| 7 |
+
*.egg-info
|
| 8 |
+
|
| 9 |
+
# Virtual environments
|
| 10 |
+
.venv
|
| 11 |
+
venv/
|
| 12 |
+
env/
|
| 13 |
+
|
| 14 |
+
# Environment variables and secrets
|
| 15 |
+
.env
|
| 16 |
+
.env.local
|
| 17 |
+
*.key
|
| 18 |
+
*.pem
|
| 19 |
+
|
| 20 |
+
# Database
|
| 21 |
+
*.db
|
| 22 |
+
*.sqlite
|
| 23 |
+
*.sqlite3
|
| 24 |
+
|
| 25 |
+
# Generated repos
|
| 26 |
+
generated_repos/
|
| 27 |
+
|
| 28 |
+
# Logs
|
| 29 |
+
logs/
|
| 30 |
+
*.log
|
| 31 |
+
|
| 32 |
+
# IDE
|
| 33 |
+
.vscode/
|
| 34 |
+
.idea/
|
| 35 |
+
*.swp
|
| 36 |
+
*.swo
|
| 37 |
+
|
| 38 |
+
# OS
|
| 39 |
+
.DS_Store
|
| 40 |
+
Thumbs.db
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.13
|
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deployment Guide for Students
|
| 2 |
+
|
| 3 |
+
This guide will help you deploy your LLM Code Deployment API to Hugging Face Spaces so instructors can send you tasks and grade your work.
|
| 4 |
+
|
| 5 |
+
## Prerequisites
|
| 6 |
+
|
| 7 |
+
1. ✅ GitHub account with a personal access token
|
| 8 |
+
2. ✅ Hugging Face account (free)
|
| 9 |
+
3. ✅ AIPipe token from https://aipipe.org/login
|
| 10 |
+
4. ✅ IIT Madras email (@ds.study.iitm.ac.in) for free AIPipe access
|
| 11 |
+
|
| 12 |
+
## Step-by-Step Deployment
|
| 13 |
+
|
| 14 |
+
### Step 1: Get Your AIPipe Token
|
| 15 |
+
|
| 16 |
+
1. Visit **https://aipipe.org/login**
|
| 17 |
+
2. Sign in with your **@ds.study.iitm.ac.in** email
|
| 18 |
+
3. Copy your API token (starts with `pk_`)
|
| 19 |
+
4. **Important**: You get $2/month free. Don't exceed this limit!
|
| 20 |
+
|
| 21 |
+
### Step 2: Create GitHub Personal Access Token
|
| 22 |
+
|
| 23 |
+
1. Go to https://github.com/settings/tokens
|
| 24 |
+
2. Click **"Generate new token"** → **"Generate new token (classic)"**
|
| 25 |
+
3. Give it a name like "LLM Code Deployment"
|
| 26 |
+
4. Select scopes: ✅ **repo** (all permissions)
|
| 27 |
+
5. Click **"Generate token"**
|
| 28 |
+
6. **Copy the token immediately** (you won't see it again)
|
| 29 |
+
|
| 30 |
+
### Step 3: Create a Hugging Face Space
|
| 31 |
+
|
| 32 |
+
1. Go to **https://huggingface.co/new-space**
|
| 33 |
+
2. Choose a name (e.g., `llm-code-deploy-yourname`)
|
| 34 |
+
3. Select **Docker** as the SDK
|
| 35 |
+
4. Make it **Public**
|
| 36 |
+
5. Click **Create Space**
|
| 37 |
+
|
| 38 |
+
### Step 4: Configure Environment Variables
|
| 39 |
+
|
| 40 |
+
In your Space, go to **Settings** → **Variables and secrets**:
|
| 41 |
+
|
| 42 |
+
Add these **environment variables** (not secrets):
|
| 43 |
+
|
| 44 |
+
| Variable | Value |
|
| 45 |
+
|----------|-------|
|
| 46 |
+
| `STUDENT_EMAIL` | Your email (e.g., `yourname@ds.study.iitm.ac.in`) |
|
| 47 |
+
| `STUDENT_SECRET` | A secret phrase (e.g., `mySecretKey123`) |
|
| 48 |
+
| `GITHUB_USERNAME` | Your GitHub username |
|
| 49 |
+
| `LLM_PROVIDER` | `aipipe` |
|
| 50 |
+
| `LLM_MODEL` | `google/gemini-2.0-flash-lite-001` |
|
| 51 |
+
|
| 52 |
+
Add these as **secrets** (hidden values):
|
| 53 |
+
|
| 54 |
+
| Secret | Value |
|
| 55 |
+
|--------|-------|
|
| 56 |
+
| `GITHUB_TOKEN` | Your GitHub personal access token |
|
| 57 |
+
| `AIPIPE_TOKEN` | Your AIPipe token |
|
| 58 |
+
|
| 59 |
+
### Step 5: Deploy Your Code
|
| 60 |
+
|
| 61 |
+
**Option A: Push from Local Repository**
|
| 62 |
+
|
| 63 |
+
```bash
|
| 64 |
+
# Clone your repository
|
| 65 |
+
git clone https://github.com/YOUR_USERNAME/tds-p1.git
|
| 66 |
+
cd tds-p1
|
| 67 |
+
|
| 68 |
+
# Add Hugging Face Space as remote
|
| 69 |
+
git remote add space https://huggingface.co/spaces/YOUR_HF_USERNAME/YOUR_SPACE_NAME
|
| 70 |
+
|
| 71 |
+
# Push to Space
|
| 72 |
+
git push space main
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
**Option B: Upload Files Directly**
|
| 76 |
+
|
| 77 |
+
1. In your Space, click **Files** → **Add file**
|
| 78 |
+
2. Upload these files:
|
| 79 |
+
- `Dockerfile`
|
| 80 |
+
- `requirements.txt`
|
| 81 |
+
- All `.py` files
|
| 82 |
+
- `templates/` folder
|
| 83 |
+
- `.gitignore`
|
| 84 |
+
|
| 85 |
+
### Step 6: Wait for Build
|
| 86 |
+
|
| 87 |
+
1. Go to the **Logs** tab
|
| 88 |
+
2. Wait for the build to complete (5-10 minutes)
|
| 89 |
+
3. Look for: `Application startup complete` in the logs
|
| 90 |
+
|
| 91 |
+
### Step 7: Test Your Deployment
|
| 92 |
+
|
| 93 |
+
Your API endpoint will be:
|
| 94 |
+
```
|
| 95 |
+
https://YOUR_HF_USERNAME-YOUR_SPACE_NAME.hf.space/api/build
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
Test it:
|
| 99 |
+
```bash
|
| 100 |
+
curl https://YOUR_HF_USERNAME-YOUR_SPACE_NAME.hf.space/health
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
Expected response:
|
| 104 |
+
```json
|
| 105 |
+
{
|
| 106 |
+
"status": "healthy",
|
| 107 |
+
"active_tasks": 0,
|
| 108 |
+
"timestamp": "2025-01-16T10:30:00.123456"
|
| 109 |
+
}
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
### Step 8: Submit to Instructors
|
| 113 |
+
|
| 114 |
+
1. Go to the instructor's Google Form
|
| 115 |
+
2. Submit:
|
| 116 |
+
- **API Endpoint**: `https://YOUR_HF_USERNAME-YOUR_SPACE_NAME.hf.space/api/build`
|
| 117 |
+
- **Secret**: Same value you used in `STUDENT_SECRET`
|
| 118 |
+
- **Email**: Same value you used in `STUDENT_EMAIL`
|
| 119 |
+
|
| 120 |
+
## What Happens Next?
|
| 121 |
+
|
| 122 |
+
1. **Instructors send tasks** → Your API receives a JSON request
|
| 123 |
+
2. **Code generation** → LLM generates a web app based on the task
|
| 124 |
+
3. **GitHub deployment** → New repo is created and pushed
|
| 125 |
+
4. **Pages deployment** → GitHub Pages is enabled
|
| 126 |
+
5. **Notification** → Your API notifies the evaluation endpoint
|
| 127 |
+
6. **Grading** → Instructors run automated checks
|
| 128 |
+
|
| 129 |
+
## Monitoring Your Deployment
|
| 130 |
+
|
| 131 |
+
### View Logs
|
| 132 |
+
|
| 133 |
+
In your Space:
|
| 134 |
+
- **Logs** tab shows real-time activity
|
| 135 |
+
- Look for task requests, code generation, and deployment status
|
| 136 |
+
|
| 137 |
+
### Check GitHub
|
| 138 |
+
|
| 139 |
+
Generated repos will appear at:
|
| 140 |
+
```
|
| 141 |
+
https://github.com/YOUR_GITHUB_USERNAME/
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
Each task creates a new repo like:
|
| 145 |
+
```
|
| 146 |
+
sum-of-sales-abc12
|
| 147 |
+
markdown-to-html-def34
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
### Monitor AIPipe Usage
|
| 151 |
+
|
| 152 |
+
Visit https://aipipe.org/usage to see:
|
| 153 |
+
- How much of your $2/month quota you've used
|
| 154 |
+
- Number of API calls made
|
| 155 |
+
- Estimated cost
|
| 156 |
+
|
| 157 |
+
## Troubleshooting
|
| 158 |
+
|
| 159 |
+
### Space won't build
|
| 160 |
+
**Error**: Build fails with dependency errors
|
| 161 |
+
|
| 162 |
+
**Solution**:
|
| 163 |
+
1. Check `Dockerfile` is present and correct
|
| 164 |
+
2. Verify `requirements.txt` has all dependencies
|
| 165 |
+
3. Review **Logs** for specific error messages
|
| 166 |
+
|
| 167 |
+
### Invalid secret error
|
| 168 |
+
**Error**: `401 Unauthorized: Invalid secret`
|
| 169 |
+
|
| 170 |
+
**Solution**:
|
| 171 |
+
1. Check `STUDENT_SECRET` matches what you submitted in the form
|
| 172 |
+
2. Verify `STUDENT_EMAIL` is correct
|
| 173 |
+
3. Test with the exact same values
|
| 174 |
+
|
| 175 |
+
### GitHub authentication fails
|
| 176 |
+
**Error**: `Failed to create repository`
|
| 177 |
+
|
| 178 |
+
**Solution**:
|
| 179 |
+
1. Verify `GITHUB_TOKEN` is set as a **secret** (not variable)
|
| 180 |
+
2. Ensure token has `repo` permissions
|
| 181 |
+
3. Check token hasn't expired (they last 1 year by default)
|
| 182 |
+
|
| 183 |
+
### LLM API errors
|
| 184 |
+
**Error**: `LLM generation failed: 401 Unauthorized`
|
| 185 |
+
|
| 186 |
+
**Solution**:
|
| 187 |
+
1. Verify `AIPIPE_TOKEN` is correct
|
| 188 |
+
2. Check you haven't exceeded $2/month quota
|
| 189 |
+
3. Try a different model: `anthropic/claude-3-haiku`
|
| 190 |
+
|
| 191 |
+
### GitHub Pages not deploying
|
| 192 |
+
**Error**: `Pages URL returns 404`
|
| 193 |
+
|
| 194 |
+
**Solution**:
|
| 195 |
+
1. Wait 60-120 seconds for Pages to activate
|
| 196 |
+
2. Check repo is **public** (not private)
|
| 197 |
+
3. Verify Pages is enabled in repo settings
|
| 198 |
+
|
| 199 |
+
## Cost Optimization
|
| 200 |
+
|
| 201 |
+
### Recommended Models (Cheapest to Most Expensive)
|
| 202 |
+
|
| 203 |
+
1. **google/gemini-2.0-flash-lite-001** ✅ Recommended
|
| 204 |
+
- $0.00001/1K tokens (cheapest)
|
| 205 |
+
- ~$0.02 per task
|
| 206 |
+
- ~100 tasks per $2
|
| 207 |
+
|
| 208 |
+
2. **anthropic/claude-3-haiku**
|
| 209 |
+
- $0.00025/1K tokens
|
| 210 |
+
- ~$0.50 per task
|
| 211 |
+
- ~4 tasks per $2
|
| 212 |
+
|
| 213 |
+
3. **openai/gpt-4.1-nano**
|
| 214 |
+
- $0.0004/1K tokens
|
| 215 |
+
- ~$0.80 per task
|
| 216 |
+
- ~2.5 tasks per $2
|
| 217 |
+
|
| 218 |
+
### Tips to Reduce Costs
|
| 219 |
+
|
| 220 |
+
- ✅ Use Gemini Flash Lite (default)
|
| 221 |
+
- ✅ Test locally before deploying
|
| 222 |
+
- ✅ Monitor usage at https://aipipe.org/usage
|
| 223 |
+
- ❌ Don't use GPT-4 or Claude Sonnet (expensive)
|
| 224 |
+
- ❌ Don't test excessively (each test costs money)
|
| 225 |
+
|
| 226 |
+
## Alternative: Run Locally (No Cost)
|
| 227 |
+
|
| 228 |
+
If you prefer not to use AIPipe, run the instructor evaluation locally:
|
| 229 |
+
|
| 230 |
+
```bash
|
| 231 |
+
# Set up with your own Anthropic/OpenAI key
|
| 232 |
+
echo "LLM_PROVIDER=anthropic" >> .env
|
| 233 |
+
echo "ANTHROPIC_API_KEY=sk-ant-your-key" >> .env
|
| 234 |
+
|
| 235 |
+
# Run student API locally
|
| 236 |
+
uv run python main.py student-api
|
| 237 |
+
|
| 238 |
+
# Use ngrok to expose it publicly
|
| 239 |
+
ngrok http 8000
|
| 240 |
+
```
|
| 241 |
+
|
| 242 |
+
Then submit the ngrok URL to instructors.
|
| 243 |
+
|
| 244 |
+
## FAQ
|
| 245 |
+
|
| 246 |
+
### Can I use a different LLM provider?
|
| 247 |
+
|
| 248 |
+
Yes! Change these environment variables:
|
| 249 |
+
|
| 250 |
+
**For Anthropic Claude:**
|
| 251 |
+
```
|
| 252 |
+
LLM_PROVIDER=anthropic
|
| 253 |
+
ANTHROPIC_API_KEY=sk-ant-your-key
|
| 254 |
+
LLM_MODEL=claude-3-5-sonnet-20241022
|
| 255 |
+
```
|
| 256 |
+
|
| 257 |
+
**For OpenAI:**
|
| 258 |
+
```
|
| 259 |
+
LLM_PROVIDER=openai
|
| 260 |
+
OPENAI_API_KEY=sk-your-key
|
| 261 |
+
LLM_MODEL=gpt-4-turbo-preview
|
| 262 |
+
```
|
| 263 |
+
|
| 264 |
+
### How long does code generation take?
|
| 265 |
+
|
| 266 |
+
- Gemini Flash Lite: 2-5 seconds
|
| 267 |
+
- Claude Haiku: 3-7 seconds
|
| 268 |
+
- GPT-4: 5-15 seconds
|
| 269 |
+
|
| 270 |
+
Plus:
|
| 271 |
+
- GitHub deployment: 30-60 seconds
|
| 272 |
+
- Pages activation: 30-120 seconds
|
| 273 |
+
- **Total**: ~2-5 minutes per task
|
| 274 |
+
|
| 275 |
+
### Can I see the generated code before deployment?
|
| 276 |
+
|
| 277 |
+
Yes! Check the logs:
|
| 278 |
+
```bash
|
| 279 |
+
# View logs in real-time
|
| 280 |
+
curl https://YOUR_SPACE.hf.space/api/status/TASK_ID
|
| 281 |
+
```
|
| 282 |
+
|
| 283 |
+
Or check the generated repo on GitHub after deployment.
|
| 284 |
+
|
| 285 |
+
### What if I exceed the $2 limit?
|
| 286 |
+
|
| 287 |
+
1. AIPipe will block your requests
|
| 288 |
+
2. Use your own API key (see alternative providers above)
|
| 289 |
+
3. Contact instructors for assistance
|
| 290 |
+
|
| 291 |
+
### How many tasks will I receive?
|
| 292 |
+
|
| 293 |
+
Instructors may send:
|
| 294 |
+
- **Round 1**: 1-3 tasks
|
| 295 |
+
- **Round 2**: 1-3 update tasks per Round 1 task
|
| 296 |
+
- **Total**: 2-9 tasks
|
| 297 |
+
|
| 298 |
+
With Gemini Flash Lite (~$0.02/task), you'll use ~$0.18 total. Well within the $2 limit!
|
| 299 |
+
|
| 300 |
+
## Support
|
| 301 |
+
|
| 302 |
+
If you encounter issues:
|
| 303 |
+
|
| 304 |
+
1. **Check logs**: Space → Logs tab
|
| 305 |
+
2. **Review this guide**: Especially troubleshooting section
|
| 306 |
+
3. **Test locally**: Run the API locally to debug
|
| 307 |
+
4. **Ask instructors**: In the course forum or office hours
|
| 308 |
+
|
| 309 |
+
## Next Steps
|
| 310 |
+
|
| 311 |
+
After deployment:
|
| 312 |
+
1. ✅ Submit your endpoint to the Google Form
|
| 313 |
+
2. ✅ Wait for tasks from instructors
|
| 314 |
+
3. ✅ Monitor logs and GitHub for activity
|
| 315 |
+
4. ✅ Check evaluation results after deadline
|
| 316 |
+
|
| 317 |
+
Good luck! 🚀
|
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# Create the same UID (1000) that Hugging Face Spaces uses
|
| 4 |
+
RUN useradd -m -u 1000 user
|
| 5 |
+
|
| 6 |
+
# Install system dependencies
|
| 7 |
+
RUN apt-get update && apt-get install -y \
|
| 8 |
+
git \
|
| 9 |
+
build-essential \
|
| 10 |
+
curl \
|
| 11 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
+
|
| 13 |
+
# Set working directory
|
| 14 |
+
WORKDIR /home/user/app
|
| 15 |
+
|
| 16 |
+
# Copy requirements and install Python dependencies
|
| 17 |
+
COPY --chown=user requirements.txt .
|
| 18 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 19 |
+
pip install --no-cache-dir -r requirements.txt
|
| 20 |
+
|
| 21 |
+
# Install Playwright and chromium browser
|
| 22 |
+
RUN pip install --no-cache-dir playwright && \
|
| 23 |
+
playwright install chromium && \
|
| 24 |
+
playwright install-deps chromium
|
| 25 |
+
|
| 26 |
+
# Copy the rest of the application
|
| 27 |
+
COPY --chown=user . .
|
| 28 |
+
|
| 29 |
+
# Switch to non-root user
|
| 30 |
+
USER user
|
| 31 |
+
|
| 32 |
+
# Set environment variables
|
| 33 |
+
ENV HOME=/home/user \
|
| 34 |
+
PATH=/home/user/.local/bin:$PATH \
|
| 35 |
+
PYTHONUNBUFFERED=1
|
| 36 |
+
|
| 37 |
+
# Expose the port that Hugging Face Spaces expects
|
| 38 |
+
EXPOSE 7860
|
| 39 |
+
|
| 40 |
+
# Start the student API (this is what students will deploy)
|
| 41 |
+
CMD ["uvicorn", "student.api:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2025 LLM Code Deployment System
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Quick Start Guide
|
| 2 |
+
|
| 3 |
+
## Installation
|
| 4 |
+
|
| 5 |
+
1. **Install dependencies with uv**
|
| 6 |
+
```bash
|
| 7 |
+
uv sync
|
| 8 |
+
```
|
| 9 |
+
|
| 10 |
+
2. **Install Playwright browsers**
|
| 11 |
+
```bash
|
| 12 |
+
uv run playwright install chromium
|
| 13 |
+
```
|
| 14 |
+
|
| 15 |
+
3. **Set up environment variables**
|
| 16 |
+
```bash
|
| 17 |
+
cp .env.example .env
|
| 18 |
+
```
|
| 19 |
+
|
| 20 |
+
Edit `.env` and configure at minimum:
|
| 21 |
+
- `STUDENT_SECRET` - Your secret key
|
| 22 |
+
- `STUDENT_EMAIL` - Your email
|
| 23 |
+
- `GITHUB_TOKEN` - GitHub personal access token
|
| 24 |
+
- `GITHUB_USERNAME` - Your GitHub username
|
| 25 |
+
- `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` - LLM API key
|
| 26 |
+
|
| 27 |
+
## Running the System
|
| 28 |
+
|
| 29 |
+
### Option 1: Using main.py CLI
|
| 30 |
+
|
| 31 |
+
**Start Student API:**
|
| 32 |
+
```bash
|
| 33 |
+
uv run python main.py student-api
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
**Start Instructor API:**
|
| 37 |
+
```bash
|
| 38 |
+
uv run python main.py instructor-api
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
**Initialize Database:**
|
| 42 |
+
```bash
|
| 43 |
+
uv run python main.py init-db
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
**Run Round 1:**
|
| 47 |
+
```bash
|
| 48 |
+
uv run python main.py round1
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
**Run Evaluation:**
|
| 52 |
+
```bash
|
| 53 |
+
uv run python main.py evaluate
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
**Run Round 2:**
|
| 57 |
+
```bash
|
| 58 |
+
uv run python main.py round2
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
### Option 2: Direct module execution
|
| 62 |
+
|
| 63 |
+
**Start Student API:**
|
| 64 |
+
```bash
|
| 65 |
+
uv run python -m student.api
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
**Start Instructor API:**
|
| 69 |
+
```bash
|
| 70 |
+
uv run python -m instructor.api
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
## Testing the System
|
| 74 |
+
|
| 75 |
+
### 1. Test Student API
|
| 76 |
+
|
| 77 |
+
Start the student API:
|
| 78 |
+
```bash
|
| 79 |
+
uv run python main.py student-api
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
In another terminal, send a test request:
|
| 83 |
+
```bash
|
| 84 |
+
curl -X POST http://localhost:8000/api/build \
|
| 85 |
+
-H "Content-Type: application/json" \
|
| 86 |
+
-d '{
|
| 87 |
+
"email": "your-email@example.com",
|
| 88 |
+
"secret": "your-secret",
|
| 89 |
+
"task": "test-task-abc",
|
| 90 |
+
"round": 1,
|
| 91 |
+
"nonce": "unique-nonce-123",
|
| 92 |
+
"brief": "Create a simple Hello World page with Bootstrap",
|
| 93 |
+
"checks": ["Page displays Hello World", "Bootstrap is loaded"],
|
| 94 |
+
"evaluation_url": "http://localhost:8001/api/evaluate",
|
| 95 |
+
"attachments": []
|
| 96 |
+
}'
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
Check status:
|
| 100 |
+
```bash
|
| 101 |
+
curl http://localhost:8000/api/status/test-task-abc
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
### 2. Test Instructor Workflow
|
| 105 |
+
|
| 106 |
+
**Start Instructor API:**
|
| 107 |
+
```bash
|
| 108 |
+
uv run python main.py instructor-api
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
**Initialize Database:**
|
| 112 |
+
```bash
|
| 113 |
+
uv run python main.py init-db
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
**Create submissions.csv:**
|
| 117 |
+
```csv
|
| 118 |
+
timestamp,email,endpoint,secret
|
| 119 |
+
2025-01-15T10:00:00,student@example.com,http://localhost:8000/api/build,your-secret
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
**Run Round 1:**
|
| 123 |
+
```bash
|
| 124 |
+
uv run python main.py round1
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
**Run Evaluation:**
|
| 128 |
+
```bash
|
| 129 |
+
uv run python main.py evaluate
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
**Check Results:**
|
| 133 |
+
```bash
|
| 134 |
+
curl http://localhost:8001/api/results/student@example.com
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
## Project Structure Overview
|
| 138 |
+
|
| 139 |
+
```
|
| 140 |
+
student/ # Student side (receives tasks, generates code)
|
| 141 |
+
├── api.py # API endpoint
|
| 142 |
+
├── code_generator.py # LLM code generation
|
| 143 |
+
├── github_manager.py # GitHub operations
|
| 144 |
+
└── notification_client.py # Notify evaluation
|
| 145 |
+
|
| 146 |
+
instructor/ # Instructor side (generates tasks, evaluates)
|
| 147 |
+
├── api.py # Evaluation endpoint
|
| 148 |
+
├── database.py # Database operations
|
| 149 |
+
├── task_templates.py # Template management
|
| 150 |
+
├── round1.py # Generate round 1 tasks
|
| 151 |
+
├── round2.py # Generate round 2 tasks
|
| 152 |
+
├── evaluate.py # Run evaluations
|
| 153 |
+
└── checks/ # Evaluation checks
|
| 154 |
+
├── static_checks.py
|
| 155 |
+
├── dynamic_checks.py
|
| 156 |
+
└── llm_checks.py
|
| 157 |
+
|
| 158 |
+
shared/ # Shared utilities
|
| 159 |
+
├── config.py # Configuration
|
| 160 |
+
├── models.py # Data models
|
| 161 |
+
├── logger.py # Logging
|
| 162 |
+
└── utils.py # Utilities
|
| 163 |
+
|
| 164 |
+
templates/ # Task templates (YAML)
|
| 165 |
+
```
|
| 166 |
+
|
| 167 |
+
## Next Steps
|
| 168 |
+
|
| 169 |
+
1. Configure your `.env` file with actual credentials
|
| 170 |
+
2. Set up a PostgreSQL database (if using instructor features)
|
| 171 |
+
3. Review the task templates in `templates/`
|
| 172 |
+
4. Test the student API with a simple request
|
| 173 |
+
5. Set up the instructor system for evaluation
|
| 174 |
+
|
| 175 |
+
## Common Issues
|
| 176 |
+
|
| 177 |
+
**Import errors**: Make sure you run commands with `uv run` prefix
|
| 178 |
+
|
| 179 |
+
**GitHub auth errors**: Verify GITHUB_TOKEN in .env has proper permissions
|
| 180 |
+
|
| 181 |
+
**Database errors**: Make sure PostgreSQL is running and DATABASE_URL is correct
|
| 182 |
+
|
| 183 |
+
**LLM errors**: Check your API key and quota
|
| 184 |
+
|
| 185 |
+
For more details, see README.md
|
|
@@ -0,0 +1,411 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# LLM Code Deployment System
|
| 2 |
+
|
| 3 |
+
An automated system for building, deploying, and evaluating LLM-generated web applications with GitHub Pages integration.
|
| 4 |
+
|
| 5 |
+
## Overview
|
| 6 |
+
|
| 7 |
+
This project implements a complete workflow for:
|
| 8 |
+
- **Students**: Receive task requests, use LLMs to generate code, deploy to GitHub Pages, and submit for evaluation
|
| 9 |
+
- **Instructors**: Generate task requests, receive submissions, run automated evaluations (static, dynamic, and LLM-based)
|
| 10 |
+
|
| 11 |
+
## 🚀 Quick Deployment for Students
|
| 12 |
+
|
| 13 |
+
**Deploy to Hugging Face Spaces in 10 minutes:**
|
| 14 |
+
|
| 15 |
+
1. Read **[DEPLOYMENT.md](DEPLOYMENT.md)** for complete step-by-step instructions
|
| 16 |
+
2. Read **[README_SPACES.md](README_SPACES.md)** for Hugging Face Spaces configuration
|
| 17 |
+
3. Get your AIPipe token from https://aipipe.org/login ($2/month free for IIT Madras students)
|
| 18 |
+
4. Create a Space at https://huggingface.co/new-space
|
| 19 |
+
5. Configure environment variables and deploy!
|
| 20 |
+
|
| 21 |
+
**Already deployed?** Just submit your endpoint URL to the instructor's Google Form!
|
| 22 |
+
|
| 23 |
+
## Architecture
|
| 24 |
+
|
| 25 |
+
```
|
| 26 |
+
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
|
| 27 |
+
│ Instructor │ │ Student │ │ GitHub │
|
| 28 |
+
│ System │────────▶│ API │────────▶│ Pages │
|
| 29 |
+
│ │ POST │ │ Deploy │ │
|
| 30 |
+
└─────────────┘ Task └──────────────┘ └─────────────┘
|
| 31 |
+
│ │ │
|
| 32 |
+
│ │ │
|
| 33 |
+
│ └────────POST─────────────┘
|
| 34 |
+
│ Submission │
|
| 35 |
+
│ │
|
| 36 |
+
▼ ▼
|
| 37 |
+
┌─────────────┐ ┌─────────────┐
|
| 38 |
+
│ Evaluation │◀──────────────────────────────────│ Validation │
|
| 39 |
+
│ Database │ │ & Checks │
|
| 40 |
+
└─────────────┘ └─────────────┘
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
## Features
|
| 44 |
+
|
| 45 |
+
### Student-Side
|
| 46 |
+
- **API Endpoint**: Receives task requests via HTTP POST
|
| 47 |
+
- **LLM Code Generation**: Uses Claude/GPT to generate complete web apps
|
| 48 |
+
- **GitHub Integration**: Automatically creates repos, pushes code, enables Pages
|
| 49 |
+
- **Automatic Notification**: Sends repo details to evaluation endpoint
|
| 50 |
+
- **Round 2 Support**: Handles update requests for existing repos
|
| 51 |
+
|
| 52 |
+
### Instructor-Side
|
| 53 |
+
- **Task Templates**: YAML-based parametrizable task definitions
|
| 54 |
+
- **Round 1 & 2 Scripts**: Automated task generation and distribution
|
| 55 |
+
- **Evaluation API**: Receives and validates student submissions
|
| 56 |
+
- **Multi-Level Checks**:
|
| 57 |
+
- Static: License, README, repo creation time, secrets detection
|
| 58 |
+
- LLM: Code quality, documentation quality
|
| 59 |
+
- Dynamic: Playwright-based functional testing
|
| 60 |
+
- **Database**: PostgreSQL storage for tasks, repos, and results
|
| 61 |
+
|
| 62 |
+
## Project Structure
|
| 63 |
+
|
| 64 |
+
```
|
| 65 |
+
tds-p1/
|
| 66 |
+
├── shared/ # Shared utilities and models
|
| 67 |
+
│ ├── config.py # Configuration management
|
| 68 |
+
│ ├── models.py # Pydantic data models
|
| 69 |
+
│ ├── logger.py # Logging setup
|
| 70 |
+
│ └── utils.py # Utility functions
|
| 71 |
+
├── student/ # Student-side components
|
| 72 |
+
│ ├── api.py # FastAPI endpoint
|
| 73 |
+
│ ├── code_generator.py # LLM-based code generation
|
| 74 |
+
│ ├── github_manager.py # GitHub operations
|
| 75 |
+
│ └── notification_client.py # Evaluation notification
|
| 76 |
+
├── instructor/ # Instructor-side components
|
| 77 |
+
│ ├── api.py # Evaluation endpoint
|
| 78 |
+
│ ├── database.py # Database models and operations
|
| 79 |
+
│ ├── task_templates.py # Template management
|
| 80 |
+
│ ├── round1.py # Round 1 task generation
|
| 81 |
+
│ ├── round2.py # Round 2 task generation
|
| 82 |
+
│ ├── evaluate.py # Main evaluation script
|
| 83 |
+
│ └── checks/ # Evaluation modules
|
| 84 |
+
│ ├── static_checks.py # Static analysis
|
| 85 |
+
│ ├── dynamic_checks.py # Playwright tests
|
| 86 |
+
│ └── llm_checks.py # LLM evaluations
|
| 87 |
+
├── templates/ # Task template YAML files
|
| 88 |
+
│ ├── sum-of-sales.yaml
|
| 89 |
+
│ ├── markdown-to-html.yaml
|
| 90 |
+
│ └── github-user-created.yaml
|
| 91 |
+
├── pyproject.toml # Project dependencies
|
| 92 |
+
├── .env.example # Environment variables template
|
| 93 |
+
└── README.md # This file
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
## Setup
|
| 97 |
+
|
| 98 |
+
### Prerequisites
|
| 99 |
+
- Python 3.10+
|
| 100 |
+
- PostgreSQL database
|
| 101 |
+
- GitHub account with personal access token
|
| 102 |
+
- Anthropic or OpenAI API key
|
| 103 |
+
|
| 104 |
+
### Installation
|
| 105 |
+
|
| 106 |
+
1. **Clone the repository**
|
| 107 |
+
```bash
|
| 108 |
+
git clone <your-repo-url>
|
| 109 |
+
cd tds-p1
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
2. **Install dependencies**
|
| 113 |
+
```bash
|
| 114 |
+
pip install -e .
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
3. **Install Playwright browsers**
|
| 118 |
+
```bash
|
| 119 |
+
playwright install chromium
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
4. **Configure environment**
|
| 123 |
+
```bash
|
| 124 |
+
cp .env.example .env
|
| 125 |
+
# Edit .env with your credentials
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
5. **Set up database**
|
| 129 |
+
```bash
|
| 130 |
+
# Create PostgreSQL database
|
| 131 |
+
createdb llm_deployment
|
| 132 |
+
|
| 133 |
+
# Initialize tables
|
| 134 |
+
python -c "from instructor.database import Database; Database().create_tables()"
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
## Configuration
|
| 138 |
+
|
| 139 |
+
Edit `.env` with your settings:
|
| 140 |
+
|
| 141 |
+
### Student Configuration
|
| 142 |
+
```bash
|
| 143 |
+
STUDENT_SECRET=your-secret-key
|
| 144 |
+
STUDENT_EMAIL=your-email@example.com
|
| 145 |
+
STUDENT_API_PORT=8000
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
### GitHub
|
| 149 |
+
```bash
|
| 150 |
+
GITHUB_TOKEN=ghp_your_personal_access_token
|
| 151 |
+
GITHUB_USERNAME=your-username
|
| 152 |
+
```
|
| 153 |
+
|
| 154 |
+
### LLM Provider
|
| 155 |
+
```bash
|
| 156 |
+
# Choose one
|
| 157 |
+
LLM_PROVIDER=anthropic # or openai
|
| 158 |
+
ANTHROPIC_API_KEY=sk-ant-...
|
| 159 |
+
# OR
|
| 160 |
+
OPENAI_API_KEY=sk-...
|
| 161 |
+
LLM_MODEL=claude-3-5-sonnet-20241022
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
### Instructor
|
| 165 |
+
```bash
|
| 166 |
+
DATABASE_URL=postgresql://user:password@localhost:5432/llm_deployment
|
| 167 |
+
EVALUATION_API_URL=http://your-server:8001/api/evaluate
|
| 168 |
+
```
|
| 169 |
+
|
| 170 |
+
## Usage
|
| 171 |
+
|
| 172 |
+
### For Students
|
| 173 |
+
|
| 174 |
+
1. **Start the Student API**
|
| 175 |
+
```bash
|
| 176 |
+
python -m student.api
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
The API will listen on `http://localhost:8000/api/build`
|
| 180 |
+
|
| 181 |
+
2. **Test with a sample request**
|
| 182 |
+
```bash
|
| 183 |
+
curl -X POST http://localhost:8000/api/build \
|
| 184 |
+
-H "Content-Type: application/json" \
|
| 185 |
+
-d '{
|
| 186 |
+
"email": "your-email@example.com",
|
| 187 |
+
"secret": "your-secret",
|
| 188 |
+
"task": "test-task-abc",
|
| 189 |
+
"round": 1,
|
| 190 |
+
"nonce": "unique-nonce-123",
|
| 191 |
+
"brief": "Create a simple Hello World page",
|
| 192 |
+
"checks": ["Page displays Hello World"],
|
| 193 |
+
"evaluation_url": "http://localhost:8001/api/evaluate",
|
| 194 |
+
"attachments": []
|
| 195 |
+
}'
|
| 196 |
+
```
|
| 197 |
+
|
| 198 |
+
### For Instructors
|
| 199 |
+
|
| 200 |
+
1. **Start the Evaluation API**
|
| 201 |
+
```bash
|
| 202 |
+
python -m instructor.api
|
| 203 |
+
```
|
| 204 |
+
|
| 205 |
+
2. **Prepare submissions.csv**
|
| 206 |
+
```csv
|
| 207 |
+
timestamp,email,endpoint,secret
|
| 208 |
+
2025-01-15T10:00:00,student1@example.com,http://student1.com/api/build,secret1
|
| 209 |
+
2025-01-15T10:05:00,student2@example.com,http://student2.com/api/build,secret2
|
| 210 |
+
```
|
| 211 |
+
|
| 212 |
+
3. **Run Round 1 task generation**
|
| 213 |
+
```bash
|
| 214 |
+
python -m instructor.round1
|
| 215 |
+
```
|
| 216 |
+
|
| 217 |
+
This will:
|
| 218 |
+
- Load submissions from CSV
|
| 219 |
+
- Generate unique tasks from templates
|
| 220 |
+
- POST tasks to student endpoints
|
| 221 |
+
- Log results to database
|
| 222 |
+
|
| 223 |
+
4. **Run evaluations**
|
| 224 |
+
```bash
|
| 225 |
+
python -m instructor.evaluate
|
| 226 |
+
```
|
| 227 |
+
|
| 228 |
+
This will:
|
| 229 |
+
- Fetch pending submissions
|
| 230 |
+
- Clone repositories
|
| 231 |
+
- Run static, LLM, and Playwright checks
|
| 232 |
+
- Save results to database
|
| 233 |
+
|
| 234 |
+
5. **Run Round 2 task generation**
|
| 235 |
+
```bash
|
| 236 |
+
python -m instructor.round2
|
| 237 |
+
```
|
| 238 |
+
|
| 239 |
+
This will:
|
| 240 |
+
- Find all Round 1 submissions
|
| 241 |
+
- Generate Round 2 update tasks
|
| 242 |
+
- POST to student endpoints
|
| 243 |
+
|
| 244 |
+
## Task Templates
|
| 245 |
+
|
| 246 |
+
Task templates are YAML files in the `templates/` directory. Example:
|
| 247 |
+
|
| 248 |
+
```yaml
|
| 249 |
+
id: sum-of-sales
|
| 250 |
+
brief: Publish a single-page site that fetches data.csv from attachments...
|
| 251 |
+
attachments:
|
| 252 |
+
- name: data.csv
|
| 253 |
+
url: data:text/csv;base64,placeholder
|
| 254 |
+
checks:
|
| 255 |
+
- "Page title equals 'Sales Summary {{ seed }}'"
|
| 256 |
+
- "Bootstrap 5 CSS loaded from jsdelivr"
|
| 257 |
+
round2:
|
| 258 |
+
- brief: Add a Bootstrap table #product-sales...
|
| 259 |
+
checks:
|
| 260 |
+
- "Table #product-sales exists"
|
| 261 |
+
```
|
| 262 |
+
|
| 263 |
+
### Template Variables
|
| 264 |
+
- `{{ seed }}`: Unique seed based on email and timestamp
|
| 265 |
+
- `{{ hash }}`: Deterministic hash value
|
| 266 |
+
- `{{ result }}`: Generated numeric value
|
| 267 |
+
|
| 268 |
+
## API Endpoints
|
| 269 |
+
|
| 270 |
+
### Student API
|
| 271 |
+
|
| 272 |
+
**POST /api/build**
|
| 273 |
+
- Receives task request
|
| 274 |
+
- Returns 200 on acceptance
|
| 275 |
+
- Processes in background
|
| 276 |
+
|
| 277 |
+
**GET /api/status/{task_id}**
|
| 278 |
+
- Returns task status
|
| 279 |
+
|
| 280 |
+
**GET /health**
|
| 281 |
+
- Health check
|
| 282 |
+
|
| 283 |
+
### Instructor API
|
| 284 |
+
|
| 285 |
+
**POST /api/evaluate**
|
| 286 |
+
- Receives repo submission
|
| 287 |
+
- Validates against task record
|
| 288 |
+
- Returns 200 on success
|
| 289 |
+
|
| 290 |
+
**GET /api/submissions/{email}**
|
| 291 |
+
- Returns all submissions for email
|
| 292 |
+
|
| 293 |
+
**GET /api/results/{email}**
|
| 294 |
+
- Returns all evaluation results
|
| 295 |
+
|
| 296 |
+
## Evaluation Criteria
|
| 297 |
+
|
| 298 |
+
### Static Checks
|
| 299 |
+
- ✓ Repository created after task sent
|
| 300 |
+
- ✓ MIT LICENSE exists in root
|
| 301 |
+
- ✓ README.md present with good structure
|
| 302 |
+
- ✓ No secrets in git history
|
| 303 |
+
|
| 304 |
+
### LLM Checks
|
| 305 |
+
- ✓ README.md professional quality (0-1 score)
|
| 306 |
+
- ✓ Code quality and best practices (0-1 score)
|
| 307 |
+
|
| 308 |
+
### Dynamic Checks
|
| 309 |
+
- ✓ Task-specific requirements (from template)
|
| 310 |
+
- ✓ Page loads successfully
|
| 311 |
+
- ✓ JavaScript evaluations
|
| 312 |
+
- ✓ Element presence and content
|
| 313 |
+
|
| 314 |
+
## Database Schema
|
| 315 |
+
|
| 316 |
+
### Tasks Table
|
| 317 |
+
- Task requests sent to students
|
| 318 |
+
- Fields: email, task, round, nonce, brief, checks, etc.
|
| 319 |
+
|
| 320 |
+
### Repos Table
|
| 321 |
+
- Repository submissions from students
|
| 322 |
+
- Fields: email, task, round, repo_url, commit_sha, pages_url
|
| 323 |
+
|
| 324 |
+
### Results Table
|
| 325 |
+
- Evaluation results
|
| 326 |
+
- Fields: email, task, round, check, score, reason, logs
|
| 327 |
+
|
| 328 |
+
## Troubleshooting
|
| 329 |
+
|
| 330 |
+
### Common Issues
|
| 331 |
+
|
| 332 |
+
**Student API not receiving requests**
|
| 333 |
+
- Check firewall settings
|
| 334 |
+
- Ensure port 8000 is accessible
|
| 335 |
+
- Verify endpoint URL in submissions.csv
|
| 336 |
+
|
| 337 |
+
**GitHub Pages not deploying**
|
| 338 |
+
- Verify GITHUB_TOKEN has repo permissions
|
| 339 |
+
- Check repository is public
|
| 340 |
+
- Wait up to 60 seconds for Pages to activate
|
| 341 |
+
|
| 342 |
+
**LLM generation fails**
|
| 343 |
+
- Check API key is valid
|
| 344 |
+
- Verify API quota/credits
|
| 345 |
+
- Review logs for error details
|
| 346 |
+
|
| 347 |
+
**Playwright tests fail**
|
| 348 |
+
- Ensure chromium is installed: `playwright install chromium`
|
| 349 |
+
- Check Pages URL is accessible
|
| 350 |
+
- Increase timeout if needed
|
| 351 |
+
|
| 352 |
+
**Database connection errors**
|
| 353 |
+
- Verify PostgreSQL is running
|
| 354 |
+
- Check DATABASE_URL credentials
|
| 355 |
+
- Ensure database exists
|
| 356 |
+
|
| 357 |
+
## Development
|
| 358 |
+
|
| 359 |
+
### Running Tests
|
| 360 |
+
```bash
|
| 361 |
+
pytest tests/
|
| 362 |
+
```
|
| 363 |
+
|
| 364 |
+
### Code Formatting
|
| 365 |
+
```bash
|
| 366 |
+
black .
|
| 367 |
+
ruff check .
|
| 368 |
+
```
|
| 369 |
+
|
| 370 |
+
### Type Checking
|
| 371 |
+
```bash
|
| 372 |
+
mypy .
|
| 373 |
+
```
|
| 374 |
+
|
| 375 |
+
## License
|
| 376 |
+
|
| 377 |
+
MIT License
|
| 378 |
+
|
| 379 |
+
Copyright (c) 2025
|
| 380 |
+
|
| 381 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 382 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 383 |
+
in the Software without restriction, including without limitation the rights
|
| 384 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 385 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 386 |
+
furnished to do so, subject to the following conditions:
|
| 387 |
+
|
| 388 |
+
The above copyright notice and this permission notice shall be included in all
|
| 389 |
+
copies or substantial portions of the Software.
|
| 390 |
+
|
| 391 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 392 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 393 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 394 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 395 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 396 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 397 |
+
SOFTWARE.
|
| 398 |
+
|
| 399 |
+
## Contributing
|
| 400 |
+
|
| 401 |
+
1. Fork the repository
|
| 402 |
+
2. Create a feature branch
|
| 403 |
+
3. Make your changes
|
| 404 |
+
4. Submit a pull request
|
| 405 |
+
|
| 406 |
+
## Support
|
| 407 |
+
|
| 408 |
+
For issues and questions:
|
| 409 |
+
- Check the troubleshooting section
|
| 410 |
+
- Review logs in `logs/app.log`
|
| 411 |
+
- Open an issue on GitHub
|
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: LLM Code Deployment API
|
| 3 |
+
emoji: 🚀
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# LLM Code Deployment - Student API
|
| 11 |
+
|
| 12 |
+
This is a Hugging Face Space that hosts the student API endpoint for the LLM Code Deployment project. It receives build requests, generates code using LLMs, and deploys to GitHub Pages.
|
| 13 |
+
|
| 14 |
+
## Setup on Hugging Face Spaces
|
| 15 |
+
|
| 16 |
+
### 1. Create a New Space
|
| 17 |
+
|
| 18 |
+
1. Go to https://huggingface.co/new-space
|
| 19 |
+
2. Choose a name for your Space
|
| 20 |
+
3. Select **Docker** as the SDK
|
| 21 |
+
4. Click **Create Space**
|
| 22 |
+
|
| 23 |
+
### 2. Configure Environment Variables
|
| 24 |
+
|
| 25 |
+
Go to your Space's **Settings** → **Variables and secrets** tab and add:
|
| 26 |
+
|
| 27 |
+
**Required:**
|
| 28 |
+
- `STUDENT_EMAIL` - Your email address (matching the submission form)
|
| 29 |
+
- `STUDENT_SECRET` - Your secret key (matching the submission form)
|
| 30 |
+
- `GITHUB_TOKEN` - Your GitHub personal access token (with `repo` permissions)
|
| 31 |
+
- `GITHUB_USERNAME` - Your GitHub username
|
| 32 |
+
- `AIPIPE_TOKEN` - Your AIPipe token from https://aipipe.org/login
|
| 33 |
+
|
| 34 |
+
**Optional (advanced):**
|
| 35 |
+
- `LLM_PROVIDER` - `aipipe` (default), `anthropic`, or `openai`
|
| 36 |
+
- `LLM_MODEL` - Model name (default: `google/gemini-2.0-flash-lite-001`)
|
| 37 |
+
- `AIPIPE_BASE_URL` - AIPipe endpoint (default: `https://aipipe.org/openrouter/v1`)
|
| 38 |
+
|
| 39 |
+
### 3. Get Your AIPipe Token
|
| 40 |
+
|
| 41 |
+
1. Visit https://aipipe.org/login
|
| 42 |
+
2. Sign in with your `@ds.study.iitm.ac.in` email
|
| 43 |
+
3. Copy your API token
|
| 44 |
+
4. You get **$2 per month free** - don't exceed this
|
| 45 |
+
|
| 46 |
+
### 4. Deploy the Code
|
| 47 |
+
|
| 48 |
+
**Option A: Clone from GitHub**
|
| 49 |
+
```bash
|
| 50 |
+
git clone https://github.com/YOUR_USERNAME/YOUR_REPO.git
|
| 51 |
+
cd YOUR_REPO
|
| 52 |
+
git remote add space https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE
|
| 53 |
+
git push space main
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
**Option B: Direct Upload**
|
| 57 |
+
1. Upload all project files to your Space via the web interface
|
| 58 |
+
2. Ensure `Dockerfile`, `requirements.txt`, and all code files are present
|
| 59 |
+
|
| 60 |
+
### 5. Wait for Build
|
| 61 |
+
|
| 62 |
+
The Space will automatically build using the Dockerfile. This may take 5-10 minutes.
|
| 63 |
+
|
| 64 |
+
### 6. Get Your API Endpoint
|
| 65 |
+
|
| 66 |
+
Once deployed, your endpoint will be:
|
| 67 |
+
```
|
| 68 |
+
https://YOUR_USERNAME-YOUR_SPACE.hf.space/api/build
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
Use this URL when submitting to the instructor's Google Form.
|
| 72 |
+
|
| 73 |
+
## Testing Your Deployment
|
| 74 |
+
|
| 75 |
+
Test your endpoint locally first:
|
| 76 |
+
|
| 77 |
+
```bash
|
| 78 |
+
curl -X POST https://YOUR_USERNAME-YOUR_SPACE.hf.space/api/build \
|
| 79 |
+
-H "Content-Type: application/json" \
|
| 80 |
+
-d '{
|
| 81 |
+
"email": "your-email@ds.study.iitm.ac.in",
|
| 82 |
+
"secret": "your-secret",
|
| 83 |
+
"task": "test-task-123",
|
| 84 |
+
"round": 1,
|
| 85 |
+
"nonce": "test-nonce-456",
|
| 86 |
+
"brief": "Create a simple Hello World page with Bootstrap",
|
| 87 |
+
"checks": ["Page displays Hello World"],
|
| 88 |
+
"evaluation_url": "https://example.com/evaluate",
|
| 89 |
+
"attachments": []
|
| 90 |
+
}'
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
Expected response:
|
| 94 |
+
```json
|
| 95 |
+
{
|
| 96 |
+
"status": "accepted",
|
| 97 |
+
"message": "Task test-task-123-1 accepted for processing",
|
| 98 |
+
"task": "test-task-123",
|
| 99 |
+
"round": 1
|
| 100 |
+
}
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
## Health Check
|
| 104 |
+
|
| 105 |
+
Check if your Space is running:
|
| 106 |
+
```bash
|
| 107 |
+
curl https://YOUR_USERNAME-YOUR_SPACE.hf.space/health
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
## Monitoring
|
| 111 |
+
|
| 112 |
+
View logs in the **Logs** tab of your Space to monitor:
|
| 113 |
+
- Task requests received
|
| 114 |
+
- Code generation progress
|
| 115 |
+
- GitHub deployment status
|
| 116 |
+
- Evaluation notification results
|
| 117 |
+
|
| 118 |
+
## Troubleshooting
|
| 119 |
+
|
| 120 |
+
### Space won't start
|
| 121 |
+
- Check the **Logs** tab for build errors
|
| 122 |
+
- Ensure all environment variables are set correctly
|
| 123 |
+
- Verify Dockerfile and requirements.txt are present
|
| 124 |
+
|
| 125 |
+
### Authentication errors
|
| 126 |
+
- Verify `STUDENT_SECRET` matches what you submitted in the form
|
| 127 |
+
- Check `STUDENT_EMAIL` is correct
|
| 128 |
+
- Ensure `GITHUB_TOKEN` has repo permissions
|
| 129 |
+
|
| 130 |
+
### LLM generation fails
|
| 131 |
+
- Verify `AIPIPE_TOKEN` is set and valid
|
| 132 |
+
- Check you haven't exceeded the $2/month quota
|
| 133 |
+
- Review logs for specific error messages
|
| 134 |
+
|
| 135 |
+
### GitHub deployment fails
|
| 136 |
+
- Ensure `GITHUB_TOKEN` has correct permissions
|
| 137 |
+
- Check `GITHUB_USERNAME` is correct
|
| 138 |
+
- Verify token hasn't expired
|
| 139 |
+
|
| 140 |
+
## Cost Management
|
| 141 |
+
|
| 142 |
+
**AIPipe Limits:**
|
| 143 |
+
- Free tier: $2 per month for @ds.study.iitm.ac.in emails
|
| 144 |
+
- Models recommended:
|
| 145 |
+
- `google/gemini-2.0-flash-lite-001` (cheapest)
|
| 146 |
+
- `anthropic/claude-3-haiku` (good quality)
|
| 147 |
+
- `openai/gpt-4.1-nano` (balanced)
|
| 148 |
+
|
| 149 |
+
**Monitoring usage:**
|
| 150 |
+
- Check https://aipipe.org/usage
|
| 151 |
+
- Each code generation uses ~1000-2000 tokens
|
| 152 |
+
- Budget for ~50-100 task submissions per month
|
| 153 |
+
|
| 154 |
+
## Local Development
|
| 155 |
+
|
| 156 |
+
To test locally before deploying to Spaces:
|
| 157 |
+
|
| 158 |
+
```bash
|
| 159 |
+
# Set environment variables
|
| 160 |
+
cp .env.example .env
|
| 161 |
+
# Edit .env with your credentials
|
| 162 |
+
|
| 163 |
+
# Run with Docker
|
| 164 |
+
docker build -t llm-code-deploy .
|
| 165 |
+
docker run -p 7860:7860 --env-file .env llm-code-deploy
|
| 166 |
+
|
| 167 |
+
# Or run directly with Python
|
| 168 |
+
uv run python main.py student-api
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
## Support
|
| 172 |
+
|
| 173 |
+
For issues:
|
| 174 |
+
1. Check the project README.md
|
| 175 |
+
2. Review Hugging Face Spaces documentation
|
| 176 |
+
3. Contact the course instructors
|
| 177 |
+
|
| 178 |
+
## License
|
| 179 |
+
|
| 180 |
+
MIT License - See LICENSE file
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Instructor-side modules for task generation and evaluation."""
|
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Instructor API endpoint for receiving repo submissions."""
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
|
| 4 |
+
from fastapi import FastAPI, HTTPException
|
| 5 |
+
from fastapi.responses import JSONResponse
|
| 6 |
+
|
| 7 |
+
from instructor.database import Database
|
| 8 |
+
from shared.logger import setup_logger
|
| 9 |
+
from shared.models import RepoSubmission
|
| 10 |
+
|
| 11 |
+
logger = setup_logger(__name__)
|
| 12 |
+
|
| 13 |
+
app = FastAPI(
|
| 14 |
+
title="LLM Code Deployment - Instructor API",
|
| 15 |
+
description="API endpoint for receiving and validating repo submissions",
|
| 16 |
+
version="1.0.0",
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
# Initialize database
|
| 20 |
+
db = Database()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@app.on_event("startup")
|
| 24 |
+
async def startup_event():
|
| 25 |
+
"""Create database tables on startup."""
|
| 26 |
+
db.create_tables()
|
| 27 |
+
logger.info("Database initialized")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@app.post("/api/evaluate")
|
| 31 |
+
async def evaluate_submission(submission: RepoSubmission) -> JSONResponse:
|
| 32 |
+
"""Receive and validate repo submission.
|
| 33 |
+
|
| 34 |
+
Args:
|
| 35 |
+
submission: Repository submission from student
|
| 36 |
+
|
| 37 |
+
Returns:
|
| 38 |
+
HTTP 200 on success, HTTP 400 on validation failure
|
| 39 |
+
"""
|
| 40 |
+
logger.info(
|
| 41 |
+
f"Received submission: {submission.email}, "
|
| 42 |
+
f"task {submission.task}, round {submission.round}"
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
# Validate submission against task record
|
| 46 |
+
task = db.get_task_by_nonce(submission.nonce)
|
| 47 |
+
|
| 48 |
+
if not task:
|
| 49 |
+
logger.error(f"Invalid nonce: {submission.nonce}")
|
| 50 |
+
raise HTTPException(status_code=400, detail="Invalid nonce")
|
| 51 |
+
|
| 52 |
+
# Validate fields match
|
| 53 |
+
if task.email != submission.email:
|
| 54 |
+
logger.error(f"Email mismatch: expected {task.email}, got {submission.email}")
|
| 55 |
+
raise HTTPException(status_code=400, detail="Email mismatch")
|
| 56 |
+
|
| 57 |
+
if task.task != submission.task:
|
| 58 |
+
logger.error(f"Task mismatch: expected {task.task}, got {submission.task}")
|
| 59 |
+
raise HTTPException(status_code=400, detail="Task mismatch")
|
| 60 |
+
|
| 61 |
+
if task.round != submission.round:
|
| 62 |
+
logger.error(f"Round mismatch: expected {task.round}, got {submission.round}")
|
| 63 |
+
raise HTTPException(status_code=400, detail="Round mismatch")
|
| 64 |
+
|
| 65 |
+
# Check if already submitted
|
| 66 |
+
try:
|
| 67 |
+
existing = db.get_session().query(db.Repo).filter_by(nonce=submission.nonce).first()
|
| 68 |
+
if existing:
|
| 69 |
+
logger.warning(f"Duplicate submission for nonce {submission.nonce}")
|
| 70 |
+
return JSONResponse(
|
| 71 |
+
status_code=200,
|
| 72 |
+
content={
|
| 73 |
+
"status": "duplicate",
|
| 74 |
+
"message": "Submission already received",
|
| 75 |
+
},
|
| 76 |
+
)
|
| 77 |
+
finally:
|
| 78 |
+
db.get_session().close()
|
| 79 |
+
|
| 80 |
+
# Add to repos table
|
| 81 |
+
try:
|
| 82 |
+
repo = db.add_repo(
|
| 83 |
+
{
|
| 84 |
+
"timestamp": datetime.utcnow(),
|
| 85 |
+
"email": submission.email,
|
| 86 |
+
"task": submission.task,
|
| 87 |
+
"round": submission.round,
|
| 88 |
+
"nonce": submission.nonce,
|
| 89 |
+
"repo_url": submission.repo_url,
|
| 90 |
+
"commit_sha": submission.commit_sha,
|
| 91 |
+
"pages_url": submission.pages_url,
|
| 92 |
+
}
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
logger.info(
|
| 96 |
+
f"Added repo submission: {submission.task}, round {submission.round}, "
|
| 97 |
+
f"repo {submission.repo_url}"
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
return JSONResponse(
|
| 101 |
+
status_code=200,
|
| 102 |
+
content={
|
| 103 |
+
"status": "success",
|
| 104 |
+
"message": "Submission received and queued for evaluation",
|
| 105 |
+
"repo_id": repo.id,
|
| 106 |
+
},
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
except Exception as e:
|
| 110 |
+
logger.error(f"Failed to add repo submission: {e}", exc_info=True)
|
| 111 |
+
raise HTTPException(status_code=500, detail="Internal server error")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@app.get("/api/submissions/{email}")
|
| 115 |
+
async def get_submissions(email: str) -> JSONResponse:
|
| 116 |
+
"""Get all submissions for an email.
|
| 117 |
+
|
| 118 |
+
Args:
|
| 119 |
+
email: Student email
|
| 120 |
+
|
| 121 |
+
Returns:
|
| 122 |
+
List of submissions
|
| 123 |
+
"""
|
| 124 |
+
repos = db.get_repos(email=email)
|
| 125 |
+
return JSONResponse(content=[repo.to_dict() for repo in repos])
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@app.get("/api/results/{email}")
|
| 129 |
+
async def get_results(email: str) -> JSONResponse:
|
| 130 |
+
"""Get all evaluation results for an email.
|
| 131 |
+
|
| 132 |
+
Args:
|
| 133 |
+
email: Student email
|
| 134 |
+
|
| 135 |
+
Returns:
|
| 136 |
+
List of evaluation results
|
| 137 |
+
"""
|
| 138 |
+
results = db.get_results(email=email)
|
| 139 |
+
return JSONResponse(content=[result.to_dict() for result in results])
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
@app.get("/health")
|
| 143 |
+
async def health_check() -> JSONResponse:
|
| 144 |
+
"""Health check endpoint.
|
| 145 |
+
|
| 146 |
+
Returns:
|
| 147 |
+
Health status
|
| 148 |
+
"""
|
| 149 |
+
return JSONResponse(
|
| 150 |
+
content={
|
| 151 |
+
"status": "healthy",
|
| 152 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 153 |
+
}
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
if __name__ == "__main__":
|
| 158 |
+
import uvicorn
|
| 159 |
+
|
| 160 |
+
from shared.config import settings
|
| 161 |
+
|
| 162 |
+
logger.info(f"Starting Instructor API on port {settings.instructor_api_port}")
|
| 163 |
+
uvicorn.run(
|
| 164 |
+
"instructor.api:app",
|
| 165 |
+
host="0.0.0.0",
|
| 166 |
+
port=settings.instructor_api_port,
|
| 167 |
+
reload=True,
|
| 168 |
+
)
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Evaluation check modules."""
|
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dynamic checks using Playwright."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import json
|
| 4 |
+
import re
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from playwright.async_api import async_playwright
|
| 8 |
+
|
| 9 |
+
from shared.logger import setup_logger
|
| 10 |
+
|
| 11 |
+
logger = setup_logger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class DynamicChecker:
|
| 15 |
+
"""Perform dynamic checks using Playwright."""
|
| 16 |
+
|
| 17 |
+
def __init__(self, pages_url: str, checks: list[str]) -> None:
|
| 18 |
+
"""Initialize dynamic checker.
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
pages_url: GitHub Pages URL
|
| 22 |
+
checks: List of check descriptions
|
| 23 |
+
"""
|
| 24 |
+
self.pages_url = pages_url
|
| 25 |
+
self.checks = checks
|
| 26 |
+
|
| 27 |
+
async def run_checks(self) -> list[dict[str, Any]]:
|
| 28 |
+
"""Run all dynamic checks.
|
| 29 |
+
|
| 30 |
+
Returns:
|
| 31 |
+
List of check results
|
| 32 |
+
"""
|
| 33 |
+
results = []
|
| 34 |
+
|
| 35 |
+
async with async_playwright() as p:
|
| 36 |
+
browser = await p.chromium.launch()
|
| 37 |
+
page = await browser.new_page()
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
# Load page
|
| 41 |
+
await page.goto(self.pages_url, timeout=30000, wait_until="networkidle")
|
| 42 |
+
|
| 43 |
+
# Wait a bit for JS to execute
|
| 44 |
+
await page.wait_for_timeout(2000)
|
| 45 |
+
|
| 46 |
+
# Run each check
|
| 47 |
+
for check in self.checks:
|
| 48 |
+
result = await self._run_single_check(page, check)
|
| 49 |
+
results.append(result)
|
| 50 |
+
|
| 51 |
+
except Exception as e:
|
| 52 |
+
logger.error(f"Error loading page {self.pages_url}: {e}")
|
| 53 |
+
results.append(
|
| 54 |
+
{
|
| 55 |
+
"check": "Page Load",
|
| 56 |
+
"passed": False,
|
| 57 |
+
"score": 0.0,
|
| 58 |
+
"reason": f"Failed to load page: {e}",
|
| 59 |
+
}
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
finally:
|
| 63 |
+
await browser.close()
|
| 64 |
+
|
| 65 |
+
return results
|
| 66 |
+
|
| 67 |
+
async def _run_single_check(self, page, check_desc: str) -> dict[str, Any]:
|
| 68 |
+
"""Run a single check.
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
page: Playwright page
|
| 72 |
+
check_desc: Check description
|
| 73 |
+
|
| 74 |
+
Returns:
|
| 75 |
+
Check result
|
| 76 |
+
"""
|
| 77 |
+
try:
|
| 78 |
+
# Parse check type
|
| 79 |
+
if check_desc.startswith("js:"):
|
| 80 |
+
# JavaScript evaluation check
|
| 81 |
+
js_code = check_desc[3:].strip()
|
| 82 |
+
return await self._run_js_check(page, js_code)
|
| 83 |
+
|
| 84 |
+
else:
|
| 85 |
+
# Text-based check
|
| 86 |
+
return await self._run_text_check(page, check_desc)
|
| 87 |
+
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.error(f"Error running check '{check_desc}': {e}")
|
| 90 |
+
return {
|
| 91 |
+
"check": check_desc,
|
| 92 |
+
"passed": False,
|
| 93 |
+
"score": 0.0,
|
| 94 |
+
"reason": f"Error: {e}",
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
async def _run_js_check(self, page, js_code: str) -> dict[str, Any]:
|
| 98 |
+
"""Run JavaScript evaluation check.
|
| 99 |
+
|
| 100 |
+
Args:
|
| 101 |
+
page: Playwright page
|
| 102 |
+
js_code: JavaScript code to evaluate
|
| 103 |
+
|
| 104 |
+
Returns:
|
| 105 |
+
Check result
|
| 106 |
+
"""
|
| 107 |
+
try:
|
| 108 |
+
result = await page.evaluate(js_code)
|
| 109 |
+
|
| 110 |
+
if isinstance(result, bool):
|
| 111 |
+
return {
|
| 112 |
+
"check": f"JS: {js_code[:50]}...",
|
| 113 |
+
"passed": result,
|
| 114 |
+
"score": 1.0 if result else 0.0,
|
| 115 |
+
"reason": f"JavaScript evaluated to {result}",
|
| 116 |
+
}
|
| 117 |
+
else:
|
| 118 |
+
return {
|
| 119 |
+
"check": f"JS: {js_code[:50]}...",
|
| 120 |
+
"passed": True,
|
| 121 |
+
"score": 1.0,
|
| 122 |
+
"reason": f"JavaScript returned: {result}",
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
except Exception as e:
|
| 126 |
+
return {
|
| 127 |
+
"check": f"JS: {js_code[:50]}...",
|
| 128 |
+
"passed": False,
|
| 129 |
+
"score": 0.0,
|
| 130 |
+
"reason": f"JavaScript error: {e}",
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
async def _run_text_check(self, page, check_desc: str) -> dict[str, Any]:
|
| 134 |
+
"""Run text-based check.
|
| 135 |
+
|
| 136 |
+
Args:
|
| 137 |
+
page: Playwright page
|
| 138 |
+
check_desc: Check description
|
| 139 |
+
|
| 140 |
+
Returns:
|
| 141 |
+
Check result
|
| 142 |
+
"""
|
| 143 |
+
# Extract selector and expected condition
|
| 144 |
+
# Examples:
|
| 145 |
+
# - "Page title equals 'Sales Summary xyz'"
|
| 146 |
+
# - "Element #total-sales exists and displays numeric value"
|
| 147 |
+
# - "Bootstrap 5 CSS loaded from jsdelivr"
|
| 148 |
+
|
| 149 |
+
content = await page.content()
|
| 150 |
+
|
| 151 |
+
# Check for page title
|
| 152 |
+
if "page title" in check_desc.lower():
|
| 153 |
+
title = await page.title()
|
| 154 |
+
match = re.search(r"['\"]([^'\"]+)['\"]", check_desc)
|
| 155 |
+
if match:
|
| 156 |
+
expected = match.group(1)
|
| 157 |
+
passed = expected in title
|
| 158 |
+
return {
|
| 159 |
+
"check": check_desc,
|
| 160 |
+
"passed": passed,
|
| 161 |
+
"score": 1.0 if passed else 0.0,
|
| 162 |
+
"reason": f"Title is '{title}', expected to contain '{expected}'",
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
# Check for element existence
|
| 166 |
+
if "element" in check_desc.lower():
|
| 167 |
+
# Extract selector
|
| 168 |
+
selector_match = re.search(r"(#[\w-]+|\.[a-z-]+|[a-z]+)", check_desc, re.IGNORECASE)
|
| 169 |
+
if selector_match:
|
| 170 |
+
selector = selector_match.group(1)
|
| 171 |
+
try:
|
| 172 |
+
element = await page.query_selector(selector)
|
| 173 |
+
if element:
|
| 174 |
+
text = await element.text_content()
|
| 175 |
+
return {
|
| 176 |
+
"check": check_desc,
|
| 177 |
+
"passed": True,
|
| 178 |
+
"score": 1.0,
|
| 179 |
+
"reason": f"Element {selector} found with content: {text[:50]}...",
|
| 180 |
+
}
|
| 181 |
+
else:
|
| 182 |
+
return {
|
| 183 |
+
"check": check_desc,
|
| 184 |
+
"passed": False,
|
| 185 |
+
"score": 0.0,
|
| 186 |
+
"reason": f"Element {selector} not found",
|
| 187 |
+
}
|
| 188 |
+
except Exception as e:
|
| 189 |
+
return {
|
| 190 |
+
"check": check_desc,
|
| 191 |
+
"passed": False,
|
| 192 |
+
"score": 0.0,
|
| 193 |
+
"reason": f"Error finding element: {e}",
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
# Check for Bootstrap/library loading
|
| 197 |
+
if "bootstrap" in check_desc.lower() or "cdn" in check_desc.lower():
|
| 198 |
+
has_bootstrap = "bootstrap" in content.lower()
|
| 199 |
+
return {
|
| 200 |
+
"check": check_desc,
|
| 201 |
+
"passed": has_bootstrap,
|
| 202 |
+
"score": 1.0 if has_bootstrap else 0.0,
|
| 203 |
+
"reason": f"Bootstrap {'found' if has_bootstrap else 'not found'} in page",
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
# Default: check if description keywords appear in content
|
| 207 |
+
keywords = re.findall(r"\b\w{4,}\b", check_desc.lower())
|
| 208 |
+
matches = sum(1 for kw in keywords if kw in content.lower())
|
| 209 |
+
score = min(matches / max(len(keywords), 1), 1.0)
|
| 210 |
+
|
| 211 |
+
return {
|
| 212 |
+
"check": check_desc,
|
| 213 |
+
"passed": score >= 0.5,
|
| 214 |
+
"score": score,
|
| 215 |
+
"reason": f"Matched {matches}/{len(keywords)} keywords",
|
| 216 |
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LLM-based code quality checks."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
import anthropic
|
| 6 |
+
import openai
|
| 7 |
+
|
| 8 |
+
from shared.config import settings
|
| 9 |
+
from shared.logger import setup_logger
|
| 10 |
+
|
| 11 |
+
logger = setup_logger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class LLMChecker:
|
| 15 |
+
"""Perform LLM-based quality checks."""
|
| 16 |
+
|
| 17 |
+
def __init__(self) -> None:
|
| 18 |
+
"""Initialize LLM checker."""
|
| 19 |
+
self.provider = settings.llm_provider
|
| 20 |
+
self.model = settings.llm_model
|
| 21 |
+
|
| 22 |
+
if self.provider == "anthropic":
|
| 23 |
+
self.client = anthropic.Anthropic(api_key=settings.anthropic_api_key)
|
| 24 |
+
elif self.provider == "openai":
|
| 25 |
+
self.client = openai.OpenAI(api_key=settings.openai_api_key)
|
| 26 |
+
elif self.provider == "aipipe":
|
| 27 |
+
# Use OpenAI client with AIPipe endpoints
|
| 28 |
+
self.client = openai.OpenAI(
|
| 29 |
+
api_key=settings.aipipe_token,
|
| 30 |
+
base_url=settings.aipipe_base_url
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
logger.info(f"Initialized LLMChecker with {self.provider}/{self.model}")
|
| 34 |
+
|
| 35 |
+
def check_readme_quality(self, readme_path: Path) -> dict[str, Any]:
|
| 36 |
+
"""Evaluate README.md quality using LLM.
|
| 37 |
+
|
| 38 |
+
Args:
|
| 39 |
+
readme_path: Path to README.md
|
| 40 |
+
|
| 41 |
+
Returns:
|
| 42 |
+
Check result
|
| 43 |
+
"""
|
| 44 |
+
try:
|
| 45 |
+
if not readme_path.exists():
|
| 46 |
+
return {
|
| 47 |
+
"passed": False,
|
| 48 |
+
"score": 0.0,
|
| 49 |
+
"reason": "README.md not found",
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
content = readme_path.read_text()
|
| 53 |
+
|
| 54 |
+
prompt = f"""Evaluate this README.md for quality. Rate it on a scale of 0.0 to 1.0.
|
| 55 |
+
|
| 56 |
+
Criteria:
|
| 57 |
+
- Has a clear title and description
|
| 58 |
+
- Explains setup/installation
|
| 59 |
+
- Explains usage
|
| 60 |
+
- Has code examples or explanations
|
| 61 |
+
- Is well-formatted and professional
|
| 62 |
+
- Mentions license
|
| 63 |
+
|
| 64 |
+
README content:
|
| 65 |
+
{content}
|
| 66 |
+
|
| 67 |
+
Respond with ONLY a JSON object in this format:
|
| 68 |
+
{{
|
| 69 |
+
"score": 0.85,
|
| 70 |
+
"reason": "Clear title and good structure, but missing detailed setup instructions"
|
| 71 |
+
}}"""
|
| 72 |
+
|
| 73 |
+
response = self._call_llm(prompt)
|
| 74 |
+
|
| 75 |
+
# Parse JSON from response
|
| 76 |
+
import json
|
| 77 |
+
import re
|
| 78 |
+
|
| 79 |
+
json_match = re.search(r"\{.*\}", response, re.DOTALL)
|
| 80 |
+
if json_match:
|
| 81 |
+
result = json.loads(json_match.group(0))
|
| 82 |
+
score = float(result.get("score", 0.0))
|
| 83 |
+
reason = result.get("reason", "No reason provided")
|
| 84 |
+
|
| 85 |
+
return {
|
| 86 |
+
"passed": score >= 0.7,
|
| 87 |
+
"score": score,
|
| 88 |
+
"reason": reason,
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
return {
|
| 92 |
+
"passed": False,
|
| 93 |
+
"score": 0.0,
|
| 94 |
+
"reason": "Could not parse LLM response",
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
except Exception as e:
|
| 98 |
+
logger.error(f"Error in README quality check: {e}")
|
| 99 |
+
return {"passed": False, "score": 0.0, "reason": f"Error: {e}"}
|
| 100 |
+
|
| 101 |
+
def check_code_quality(self, code_dir: Path) -> dict[str, Any]:
|
| 102 |
+
"""Evaluate code quality using LLM.
|
| 103 |
+
|
| 104 |
+
Args:
|
| 105 |
+
code_dir: Directory containing code
|
| 106 |
+
|
| 107 |
+
Returns:
|
| 108 |
+
Check result
|
| 109 |
+
"""
|
| 110 |
+
try:
|
| 111 |
+
# Gather code files
|
| 112 |
+
code_files = {}
|
| 113 |
+
for ext in [".html", ".js", ".css"]:
|
| 114 |
+
for file in code_dir.rglob(f"*{ext}"):
|
| 115 |
+
if ".git" not in str(file):
|
| 116 |
+
rel_path = file.relative_to(code_dir)
|
| 117 |
+
code_files[str(rel_path)] = file.read_text()[:2000] # Limit size
|
| 118 |
+
|
| 119 |
+
if not code_files:
|
| 120 |
+
return {
|
| 121 |
+
"passed": False,
|
| 122 |
+
"score": 0.0,
|
| 123 |
+
"reason": "No code files found",
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
# Format code for LLM
|
| 127 |
+
code_text = "\n\n".join(
|
| 128 |
+
f"=== {name} ===\n{content}" for name, content in code_files.items()
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
prompt = f"""Evaluate this web application code for quality. Rate it on a scale of 0.0 to 1.0.
|
| 132 |
+
|
| 133 |
+
Criteria:
|
| 134 |
+
- Code is clean and well-organized
|
| 135 |
+
- Proper use of HTML semantics
|
| 136 |
+
- Good JavaScript practices
|
| 137 |
+
- Reasonable styling
|
| 138 |
+
- Comments where helpful
|
| 139 |
+
- No obvious bugs or security issues
|
| 140 |
+
|
| 141 |
+
Code:
|
| 142 |
+
{code_text}
|
| 143 |
+
|
| 144 |
+
Respond with ONLY a JSON object in this format:
|
| 145 |
+
{{
|
| 146 |
+
"score": 0.8,
|
| 147 |
+
"reason": "Clean code with good structure, minor improvements possible"
|
| 148 |
+
}}"""
|
| 149 |
+
|
| 150 |
+
response = self._call_llm(prompt)
|
| 151 |
+
|
| 152 |
+
# Parse JSON
|
| 153 |
+
import json
|
| 154 |
+
import re
|
| 155 |
+
|
| 156 |
+
json_match = re.search(r"\{.*\}", response, re.DOTALL)
|
| 157 |
+
if json_match:
|
| 158 |
+
result = json.loads(json_match.group(0))
|
| 159 |
+
score = float(result.get("score", 0.0))
|
| 160 |
+
reason = result.get("reason", "No reason provided")
|
| 161 |
+
|
| 162 |
+
return {
|
| 163 |
+
"passed": score >= 0.6,
|
| 164 |
+
"score": score,
|
| 165 |
+
"reason": reason,
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
return {
|
| 169 |
+
"passed": False,
|
| 170 |
+
"score": 0.0,
|
| 171 |
+
"reason": "Could not parse LLM response",
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
except Exception as e:
|
| 175 |
+
logger.error(f"Error in code quality check: {e}")
|
| 176 |
+
return {"passed": False, "score": 0.0, "reason": f"Error: {e}"}
|
| 177 |
+
|
| 178 |
+
def _call_llm(self, prompt: str) -> str:
|
| 179 |
+
"""Call LLM API.
|
| 180 |
+
|
| 181 |
+
Args:
|
| 182 |
+
prompt: Prompt text
|
| 183 |
+
|
| 184 |
+
Returns:
|
| 185 |
+
LLM response
|
| 186 |
+
"""
|
| 187 |
+
try:
|
| 188 |
+
if self.provider == "anthropic":
|
| 189 |
+
response = self.client.messages.create(
|
| 190 |
+
model=self.model,
|
| 191 |
+
max_tokens=1024,
|
| 192 |
+
temperature=0.3,
|
| 193 |
+
messages=[{"role": "user", "content": prompt}],
|
| 194 |
+
)
|
| 195 |
+
return response.content[0].text
|
| 196 |
+
|
| 197 |
+
elif self.provider in ["openai", "aipipe"]:
|
| 198 |
+
# Both OpenAI and AIPipe use the same API format
|
| 199 |
+
response = self.client.chat.completions.create(
|
| 200 |
+
model=self.model,
|
| 201 |
+
messages=[{"role": "user", "content": prompt}],
|
| 202 |
+
temperature=0.3,
|
| 203 |
+
max_tokens=1024,
|
| 204 |
+
)
|
| 205 |
+
return response.choices[0].message.content
|
| 206 |
+
|
| 207 |
+
except Exception as e:
|
| 208 |
+
logger.error(f"LLM API call failed: {e}")
|
| 209 |
+
raise
|
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Static code analysis checks."""
|
| 2 |
+
import re
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
import git
|
| 8 |
+
import requests
|
| 9 |
+
from bs4 import BeautifulSoup
|
| 10 |
+
|
| 11 |
+
from shared.logger import setup_logger
|
| 12 |
+
|
| 13 |
+
logger = setup_logger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class StaticChecker:
|
| 17 |
+
"""Perform static checks on repository."""
|
| 18 |
+
|
| 19 |
+
def __init__(self, repo_url: str, commit_sha: str, clone_dir: Path) -> None:
|
| 20 |
+
"""Initialize static checker.
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
repo_url: Repository URL
|
| 24 |
+
commit_sha: Commit SHA to check
|
| 25 |
+
clone_dir: Directory to clone repo
|
| 26 |
+
"""
|
| 27 |
+
self.repo_url = repo_url
|
| 28 |
+
self.commit_sha = commit_sha
|
| 29 |
+
self.clone_dir = clone_dir
|
| 30 |
+
self.repo = None
|
| 31 |
+
|
| 32 |
+
def clone_repo(self) -> None:
|
| 33 |
+
"""Clone repository and checkout specific commit."""
|
| 34 |
+
logger.info(f"Cloning {self.repo_url} to {self.clone_dir}")
|
| 35 |
+
|
| 36 |
+
# Clean up if exists
|
| 37 |
+
if self.clone_dir.exists():
|
| 38 |
+
import shutil
|
| 39 |
+
|
| 40 |
+
shutil.rmtree(self.clone_dir)
|
| 41 |
+
|
| 42 |
+
# Clone
|
| 43 |
+
self.repo = git.Repo.clone_from(self.repo_url, self.clone_dir)
|
| 44 |
+
|
| 45 |
+
# Checkout specific commit
|
| 46 |
+
self.repo.git.checkout(self.commit_sha)
|
| 47 |
+
|
| 48 |
+
logger.info(f"Checked out commit {self.commit_sha}")
|
| 49 |
+
|
| 50 |
+
def check_created_after_task(self, task_timestamp: datetime) -> dict[str, Any]:
|
| 51 |
+
"""Check if repo was created after task was sent.
|
| 52 |
+
|
| 53 |
+
Args:
|
| 54 |
+
task_timestamp: When task was sent
|
| 55 |
+
|
| 56 |
+
Returns:
|
| 57 |
+
Check result
|
| 58 |
+
"""
|
| 59 |
+
try:
|
| 60 |
+
# Get first commit timestamp
|
| 61 |
+
commits = list(self.repo.iter_commits())
|
| 62 |
+
if not commits:
|
| 63 |
+
return {
|
| 64 |
+
"passed": False,
|
| 65 |
+
"score": 0.0,
|
| 66 |
+
"reason": "No commits found",
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
first_commit = commits[-1]
|
| 70 |
+
first_commit_time = datetime.fromtimestamp(first_commit.committed_date)
|
| 71 |
+
|
| 72 |
+
if first_commit_time > task_timestamp:
|
| 73 |
+
return {
|
| 74 |
+
"passed": True,
|
| 75 |
+
"score": 1.0,
|
| 76 |
+
"reason": f"Repo created after task ({first_commit_time} > {task_timestamp})",
|
| 77 |
+
}
|
| 78 |
+
else:
|
| 79 |
+
return {
|
| 80 |
+
"passed": False,
|
| 81 |
+
"score": 0.0,
|
| 82 |
+
"reason": f"Repo existed before task ({first_commit_time} <= {task_timestamp})",
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
except Exception as e:
|
| 86 |
+
logger.error(f"Error checking repo creation time: {e}")
|
| 87 |
+
return {"passed": False, "score": 0.0, "reason": f"Error: {e}"}
|
| 88 |
+
|
| 89 |
+
def check_mit_license(self) -> dict[str, Any]:
|
| 90 |
+
"""Check if repo has MIT LICENSE in root.
|
| 91 |
+
|
| 92 |
+
Returns:
|
| 93 |
+
Check result
|
| 94 |
+
"""
|
| 95 |
+
try:
|
| 96 |
+
license_path = self.clone_dir / "LICENSE"
|
| 97 |
+
|
| 98 |
+
if not license_path.exists():
|
| 99 |
+
return {
|
| 100 |
+
"passed": False,
|
| 101 |
+
"score": 0.0,
|
| 102 |
+
"reason": "No LICENSE file found in root",
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
content = license_path.read_text()
|
| 106 |
+
|
| 107 |
+
if "MIT License" in content or "MIT" in content[:200]:
|
| 108 |
+
return {
|
| 109 |
+
"passed": True,
|
| 110 |
+
"score": 1.0,
|
| 111 |
+
"reason": "MIT LICENSE found",
|
| 112 |
+
}
|
| 113 |
+
else:
|
| 114 |
+
return {
|
| 115 |
+
"passed": False,
|
| 116 |
+
"score": 0.0,
|
| 117 |
+
"reason": "LICENSE file exists but is not MIT",
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
except Exception as e:
|
| 121 |
+
logger.error(f"Error checking LICENSE: {e}")
|
| 122 |
+
return {"passed": False, "score": 0.0, "reason": f"Error: {e}"}
|
| 123 |
+
|
| 124 |
+
def check_readme(self) -> dict[str, Any]:
|
| 125 |
+
"""Check README.md quality.
|
| 126 |
+
|
| 127 |
+
Returns:
|
| 128 |
+
Check result
|
| 129 |
+
"""
|
| 130 |
+
try:
|
| 131 |
+
readme_path = self.clone_dir / "README.md"
|
| 132 |
+
|
| 133 |
+
if not readme_path.exists():
|
| 134 |
+
return {
|
| 135 |
+
"passed": False,
|
| 136 |
+
"score": 0.0,
|
| 137 |
+
"reason": "No README.md found",
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
content = readme_path.read_text()
|
| 141 |
+
|
| 142 |
+
# Basic quality checks
|
| 143 |
+
has_title = bool(re.search(r"^#\s+.+", content, re.MULTILINE))
|
| 144 |
+
has_sections = content.count("#") >= 3
|
| 145 |
+
has_enough_content = len(content) >= 200
|
| 146 |
+
has_code_blocks = "```" in content
|
| 147 |
+
|
| 148 |
+
score = sum([has_title, has_sections, has_enough_content, has_code_blocks]) / 4
|
| 149 |
+
|
| 150 |
+
return {
|
| 151 |
+
"passed": score >= 0.75,
|
| 152 |
+
"score": score,
|
| 153 |
+
"reason": f"README quality: {score:.0%} (title={has_title}, "
|
| 154 |
+
f"sections={has_sections}, content={has_enough_content}, code={has_code_blocks})",
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
except Exception as e:
|
| 158 |
+
logger.error(f"Error checking README: {e}")
|
| 159 |
+
return {"passed": False, "score": 0.0, "reason": f"Error: {e}"}
|
| 160 |
+
|
| 161 |
+
def check_no_secrets(self) -> dict[str, Any]:
|
| 162 |
+
"""Check for common secret patterns in git history.
|
| 163 |
+
|
| 164 |
+
Returns:
|
| 165 |
+
Check result
|
| 166 |
+
"""
|
| 167 |
+
try:
|
| 168 |
+
# Check for common secret patterns
|
| 169 |
+
secret_patterns = [
|
| 170 |
+
r"sk-[a-zA-Z0-9]{32,}", # API keys
|
| 171 |
+
r"ghp_[a-zA-Z0-9]{36,}", # GitHub tokens
|
| 172 |
+
r"AKIA[0-9A-Z]{16}", # AWS keys
|
| 173 |
+
r"password\s*=\s*['\"][^'\"]{8,}", # Hardcoded passwords
|
| 174 |
+
]
|
| 175 |
+
|
| 176 |
+
found_secrets = []
|
| 177 |
+
|
| 178 |
+
# Check all files in current commit
|
| 179 |
+
for item in self.clone_dir.rglob("*"):
|
| 180 |
+
if item.is_file() and not item.name.startswith(".git"):
|
| 181 |
+
try:
|
| 182 |
+
content = item.read_text(errors="ignore")
|
| 183 |
+
for pattern in secret_patterns:
|
| 184 |
+
if re.search(pattern, content, re.IGNORECASE):
|
| 185 |
+
found_secrets.append(str(item.relative_to(self.clone_dir)))
|
| 186 |
+
except Exception:
|
| 187 |
+
pass
|
| 188 |
+
|
| 189 |
+
if found_secrets:
|
| 190 |
+
return {
|
| 191 |
+
"passed": False,
|
| 192 |
+
"score": 0.0,
|
| 193 |
+
"reason": f"Potential secrets found in: {', '.join(found_secrets[:3])}",
|
| 194 |
+
}
|
| 195 |
+
else:
|
| 196 |
+
return {
|
| 197 |
+
"passed": True,
|
| 198 |
+
"score": 1.0,
|
| 199 |
+
"reason": "No obvious secrets detected",
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
except Exception as e:
|
| 203 |
+
logger.error(f"Error checking secrets: {e}")
|
| 204 |
+
return {"passed": True, "score": 1.0, "reason": "Could not check secrets"}
|
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Database models and operations for instructor system."""
|
| 2 |
+
import json
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from sqlalchemy import (
|
| 7 |
+
Column,
|
| 8 |
+
DateTime,
|
| 9 |
+
Float,
|
| 10 |
+
Integer,
|
| 11 |
+
String,
|
| 12 |
+
Text,
|
| 13 |
+
create_engine,
|
| 14 |
+
)
|
| 15 |
+
from sqlalchemy.orm import declarative_base, sessionmaker
|
| 16 |
+
|
| 17 |
+
from shared.config import settings
|
| 18 |
+
from shared.logger import setup_logger
|
| 19 |
+
|
| 20 |
+
logger = setup_logger(__name__)
|
| 21 |
+
|
| 22 |
+
Base = declarative_base()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class Task(Base):
|
| 26 |
+
"""Task records sent to students."""
|
| 27 |
+
|
| 28 |
+
__tablename__ = "tasks"
|
| 29 |
+
|
| 30 |
+
id = Column(Integer, primary_key=True)
|
| 31 |
+
timestamp = Column(DateTime, default=datetime.utcnow, nullable=False)
|
| 32 |
+
email = Column(String(255), nullable=False, index=True)
|
| 33 |
+
task = Column(String(255), nullable=False, index=True)
|
| 34 |
+
round = Column(Integer, nullable=False)
|
| 35 |
+
nonce = Column(String(255), nullable=False, unique=True)
|
| 36 |
+
brief = Column(Text, nullable=False)
|
| 37 |
+
attachments = Column(Text, nullable=False) # JSON serialized
|
| 38 |
+
checks = Column(Text, nullable=False) # JSON serialized
|
| 39 |
+
evaluation_url = Column(String(512), nullable=False)
|
| 40 |
+
endpoint = Column(String(512), nullable=False)
|
| 41 |
+
statuscode = Column(Integer, nullable=True)
|
| 42 |
+
secret = Column(String(255), nullable=False)
|
| 43 |
+
|
| 44 |
+
def to_dict(self) -> dict[str, Any]:
|
| 45 |
+
"""Convert to dictionary."""
|
| 46 |
+
return {
|
| 47 |
+
"id": self.id,
|
| 48 |
+
"timestamp": self.timestamp.isoformat() if self.timestamp else None,
|
| 49 |
+
"email": self.email,
|
| 50 |
+
"task": self.task,
|
| 51 |
+
"round": self.round,
|
| 52 |
+
"nonce": self.nonce,
|
| 53 |
+
"brief": self.brief,
|
| 54 |
+
"attachments": json.loads(self.attachments) if self.attachments else [],
|
| 55 |
+
"checks": json.loads(self.checks) if self.checks else [],
|
| 56 |
+
"evaluation_url": self.evaluation_url,
|
| 57 |
+
"endpoint": self.endpoint,
|
| 58 |
+
"statuscode": self.statuscode,
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class Repo(Base):
|
| 63 |
+
"""Repository submissions from students."""
|
| 64 |
+
|
| 65 |
+
__tablename__ = "repos"
|
| 66 |
+
|
| 67 |
+
id = Column(Integer, primary_key=True)
|
| 68 |
+
timestamp = Column(DateTime, default=datetime.utcnow, nullable=False)
|
| 69 |
+
email = Column(String(255), nullable=False, index=True)
|
| 70 |
+
task = Column(String(255), nullable=False, index=True)
|
| 71 |
+
round = Column(Integer, nullable=False)
|
| 72 |
+
nonce = Column(String(255), nullable=False, unique=True)
|
| 73 |
+
repo_url = Column(String(512), nullable=False)
|
| 74 |
+
commit_sha = Column(String(255), nullable=False)
|
| 75 |
+
pages_url = Column(String(512), nullable=False)
|
| 76 |
+
|
| 77 |
+
def to_dict(self) -> dict[str, Any]:
|
| 78 |
+
"""Convert to dictionary."""
|
| 79 |
+
return {
|
| 80 |
+
"id": self.id,
|
| 81 |
+
"timestamp": self.timestamp.isoformat() if self.timestamp else None,
|
| 82 |
+
"email": self.email,
|
| 83 |
+
"task": self.task,
|
| 84 |
+
"round": self.round,
|
| 85 |
+
"nonce": self.nonce,
|
| 86 |
+
"repo_url": self.repo_url,
|
| 87 |
+
"commit_sha": self.commit_sha,
|
| 88 |
+
"pages_url": self.pages_url,
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class Result(Base):
|
| 93 |
+
"""Evaluation results."""
|
| 94 |
+
|
| 95 |
+
__tablename__ = "results"
|
| 96 |
+
|
| 97 |
+
id = Column(Integer, primary_key=True)
|
| 98 |
+
timestamp = Column(DateTime, default=datetime.utcnow, nullable=False)
|
| 99 |
+
email = Column(String(255), nullable=False, index=True)
|
| 100 |
+
task = Column(String(255), nullable=False, index=True)
|
| 101 |
+
round = Column(Integer, nullable=False)
|
| 102 |
+
repo_url = Column(String(512), nullable=False)
|
| 103 |
+
commit_sha = Column(String(255), nullable=False)
|
| 104 |
+
pages_url = Column(String(512), nullable=False)
|
| 105 |
+
check = Column(String(512), nullable=False)
|
| 106 |
+
score = Column(Float, nullable=False)
|
| 107 |
+
reason = Column(Text, nullable=False)
|
| 108 |
+
logs = Column(Text, nullable=True)
|
| 109 |
+
|
| 110 |
+
def to_dict(self) -> dict[str, Any]:
|
| 111 |
+
"""Convert to dictionary."""
|
| 112 |
+
return {
|
| 113 |
+
"id": self.id,
|
| 114 |
+
"timestamp": self.timestamp.isoformat() if self.timestamp else None,
|
| 115 |
+
"email": self.email,
|
| 116 |
+
"task": self.task,
|
| 117 |
+
"round": self.round,
|
| 118 |
+
"repo_url": self.repo_url,
|
| 119 |
+
"commit_sha": self.commit_sha,
|
| 120 |
+
"pages_url": self.pages_url,
|
| 121 |
+
"check": self.check,
|
| 122 |
+
"score": self.score,
|
| 123 |
+
"reason": self.reason,
|
| 124 |
+
"logs": self.logs,
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class Database:
|
| 129 |
+
"""Database manager for instructor system."""
|
| 130 |
+
|
| 131 |
+
def __init__(self, database_url: str | None = None) -> None:
|
| 132 |
+
"""Initialize database connection.
|
| 133 |
+
|
| 134 |
+
Args:
|
| 135 |
+
database_url: Database URL (uses settings if not provided)
|
| 136 |
+
"""
|
| 137 |
+
self.database_url = database_url or settings.database_url
|
| 138 |
+
self.engine = create_engine(self.database_url, echo=False)
|
| 139 |
+
self.SessionLocal = sessionmaker(bind=self.engine)
|
| 140 |
+
logger.info(f"Initialized database: {self.database_url}")
|
| 141 |
+
|
| 142 |
+
def create_tables(self) -> None:
|
| 143 |
+
"""Create all tables."""
|
| 144 |
+
Base.metadata.create_all(self.engine)
|
| 145 |
+
logger.info("Created database tables")
|
| 146 |
+
|
| 147 |
+
def drop_tables(self) -> None:
|
| 148 |
+
"""Drop all tables (use with caution)."""
|
| 149 |
+
Base.metadata.drop_all(self.engine)
|
| 150 |
+
logger.warning("Dropped all database tables")
|
| 151 |
+
|
| 152 |
+
def get_session(self):
|
| 153 |
+
"""Get database session."""
|
| 154 |
+
return self.SessionLocal()
|
| 155 |
+
|
| 156 |
+
# Task operations
|
| 157 |
+
def add_task(self, task_data: dict[str, Any]) -> Task:
|
| 158 |
+
"""Add a task record.
|
| 159 |
+
|
| 160 |
+
Args:
|
| 161 |
+
task_data: Task data dictionary
|
| 162 |
+
|
| 163 |
+
Returns:
|
| 164 |
+
Created task record
|
| 165 |
+
"""
|
| 166 |
+
session = self.get_session()
|
| 167 |
+
try:
|
| 168 |
+
task = Task(
|
| 169 |
+
email=task_data["email"],
|
| 170 |
+
task=task_data["task"],
|
| 171 |
+
round=task_data["round"],
|
| 172 |
+
nonce=task_data["nonce"],
|
| 173 |
+
brief=task_data["brief"],
|
| 174 |
+
attachments=json.dumps(task_data.get("attachments", [])),
|
| 175 |
+
checks=json.dumps(task_data.get("checks", [])),
|
| 176 |
+
evaluation_url=task_data["evaluation_url"],
|
| 177 |
+
endpoint=task_data["endpoint"],
|
| 178 |
+
statuscode=task_data.get("statuscode"),
|
| 179 |
+
secret=task_data["secret"],
|
| 180 |
+
)
|
| 181 |
+
session.add(task)
|
| 182 |
+
session.commit()
|
| 183 |
+
session.refresh(task)
|
| 184 |
+
logger.info(f"Added task: {task.task}, round {task.round}")
|
| 185 |
+
return task
|
| 186 |
+
finally:
|
| 187 |
+
session.close()
|
| 188 |
+
|
| 189 |
+
def get_task_by_nonce(self, nonce: str) -> Task | None:
|
| 190 |
+
"""Get task by nonce.
|
| 191 |
+
|
| 192 |
+
Args:
|
| 193 |
+
nonce: Task nonce
|
| 194 |
+
|
| 195 |
+
Returns:
|
| 196 |
+
Task or None
|
| 197 |
+
"""
|
| 198 |
+
session = self.get_session()
|
| 199 |
+
try:
|
| 200 |
+
return session.query(Task).filter(Task.nonce == nonce).first()
|
| 201 |
+
finally:
|
| 202 |
+
session.close()
|
| 203 |
+
|
| 204 |
+
def task_exists(self, email: str, task: str, round: int) -> bool:
|
| 205 |
+
"""Check if task exists.
|
| 206 |
+
|
| 207 |
+
Args:
|
| 208 |
+
email: Student email
|
| 209 |
+
task: Task ID
|
| 210 |
+
round: Round number
|
| 211 |
+
|
| 212 |
+
Returns:
|
| 213 |
+
True if exists
|
| 214 |
+
"""
|
| 215 |
+
session = self.get_session()
|
| 216 |
+
try:
|
| 217 |
+
return (
|
| 218 |
+
session.query(Task)
|
| 219 |
+
.filter(Task.email == email, Task.task == task, Task.round == round)
|
| 220 |
+
.first()
|
| 221 |
+
is not None
|
| 222 |
+
)
|
| 223 |
+
finally:
|
| 224 |
+
session.close()
|
| 225 |
+
|
| 226 |
+
# Repo operations
|
| 227 |
+
def add_repo(self, repo_data: dict[str, Any]) -> Repo:
|
| 228 |
+
"""Add a repo submission.
|
| 229 |
+
|
| 230 |
+
Args:
|
| 231 |
+
repo_data: Repo data dictionary
|
| 232 |
+
|
| 233 |
+
Returns:
|
| 234 |
+
Created repo record
|
| 235 |
+
"""
|
| 236 |
+
session = self.get_session()
|
| 237 |
+
try:
|
| 238 |
+
repo = Repo(**repo_data)
|
| 239 |
+
session.add(repo)
|
| 240 |
+
session.commit()
|
| 241 |
+
session.refresh(repo)
|
| 242 |
+
logger.info(f"Added repo: {repo.task}, round {repo.round}")
|
| 243 |
+
return repo
|
| 244 |
+
finally:
|
| 245 |
+
session.close()
|
| 246 |
+
|
| 247 |
+
def get_repos(self, email: str | None = None) -> list[Repo]:
|
| 248 |
+
"""Get repo submissions.
|
| 249 |
+
|
| 250 |
+
Args:
|
| 251 |
+
email: Filter by email (optional)
|
| 252 |
+
|
| 253 |
+
Returns:
|
| 254 |
+
List of repos
|
| 255 |
+
"""
|
| 256 |
+
session = self.get_session()
|
| 257 |
+
try:
|
| 258 |
+
query = session.query(Repo)
|
| 259 |
+
if email:
|
| 260 |
+
query = query.filter(Repo.email == email)
|
| 261 |
+
return query.all()
|
| 262 |
+
finally:
|
| 263 |
+
session.close()
|
| 264 |
+
|
| 265 |
+
# Result operations
|
| 266 |
+
def add_result(self, result_data: dict[str, Any]) -> Result:
|
| 267 |
+
"""Add an evaluation result.
|
| 268 |
+
|
| 269 |
+
Args:
|
| 270 |
+
result_data: Result data dictionary
|
| 271 |
+
|
| 272 |
+
Returns:
|
| 273 |
+
Created result record
|
| 274 |
+
"""
|
| 275 |
+
session = self.get_session()
|
| 276 |
+
try:
|
| 277 |
+
result = Result(**result_data)
|
| 278 |
+
session.add(result)
|
| 279 |
+
session.commit()
|
| 280 |
+
session.refresh(result)
|
| 281 |
+
logger.debug(f"Added result: {result.task}, {result.check}")
|
| 282 |
+
return result
|
| 283 |
+
finally:
|
| 284 |
+
session.close()
|
| 285 |
+
|
| 286 |
+
def get_results(
|
| 287 |
+
self, email: str | None = None, task: str | None = None
|
| 288 |
+
) -> list[Result]:
|
| 289 |
+
"""Get evaluation results.
|
| 290 |
+
|
| 291 |
+
Args:
|
| 292 |
+
email: Filter by email (optional)
|
| 293 |
+
task: Filter by task (optional)
|
| 294 |
+
|
| 295 |
+
Returns:
|
| 296 |
+
List of results
|
| 297 |
+
"""
|
| 298 |
+
session = self.get_session()
|
| 299 |
+
try:
|
| 300 |
+
query = session.query(Result)
|
| 301 |
+
if email:
|
| 302 |
+
query = query.filter(Result.email == email)
|
| 303 |
+
if task:
|
| 304 |
+
query = query.filter(Result.task == task)
|
| 305 |
+
return query.all()
|
| 306 |
+
finally:
|
| 307 |
+
session.close()
|
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Main evaluation script for running all checks."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import json
|
| 4 |
+
import shutil
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from instructor.checks.dynamic_checks import DynamicChecker
|
| 9 |
+
from instructor.checks.llm_checks import LLMChecker
|
| 10 |
+
from instructor.checks.static_checks import StaticChecker
|
| 11 |
+
from instructor.database import Database
|
| 12 |
+
from shared.config import settings
|
| 13 |
+
from shared.logger import setup_logger
|
| 14 |
+
|
| 15 |
+
logger = setup_logger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class Evaluator:
|
| 19 |
+
"""Main evaluator for running all checks on submissions."""
|
| 20 |
+
|
| 21 |
+
def __init__(self) -> None:
|
| 22 |
+
"""Initialize evaluator."""
|
| 23 |
+
self.db = Database()
|
| 24 |
+
self.llm_checker = LLMChecker()
|
| 25 |
+
self.temp_dir = Path("./temp_evaluations")
|
| 26 |
+
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
| 27 |
+
|
| 28 |
+
def get_pending_repos(self) -> list[dict]:
|
| 29 |
+
"""Get repos that need evaluation.
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
List of repo dictionaries
|
| 33 |
+
"""
|
| 34 |
+
all_repos = self.db.get_repos()
|
| 35 |
+
pending = []
|
| 36 |
+
|
| 37 |
+
for repo in all_repos:
|
| 38 |
+
repo_dict = repo.to_dict()
|
| 39 |
+
|
| 40 |
+
# Check if already evaluated
|
| 41 |
+
existing_results = self.db.get_results(
|
| 42 |
+
email=repo_dict["email"], task=repo_dict["task"]
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
# Filter to this round
|
| 46 |
+
round_results = [
|
| 47 |
+
r for r in existing_results if r.to_dict()["round"] == repo_dict["round"]
|
| 48 |
+
]
|
| 49 |
+
|
| 50 |
+
if not round_results:
|
| 51 |
+
pending.append(repo_dict)
|
| 52 |
+
|
| 53 |
+
logger.info(f"Found {len(pending)} pending repos for evaluation")
|
| 54 |
+
return pending
|
| 55 |
+
|
| 56 |
+
async def evaluate_repo(self, repo: dict) -> None:
|
| 57 |
+
"""Evaluate a single repository.
|
| 58 |
+
|
| 59 |
+
Args:
|
| 60 |
+
repo: Repository submission data
|
| 61 |
+
"""
|
| 62 |
+
logger.info(f"Evaluating {repo['email']}/{repo['task']}, round {repo['round']}")
|
| 63 |
+
|
| 64 |
+
# Get task details
|
| 65 |
+
session = self.db.get_session()
|
| 66 |
+
try:
|
| 67 |
+
task = (
|
| 68 |
+
session.query(self.db.Task)
|
| 69 |
+
.filter_by(
|
| 70 |
+
email=repo["email"],
|
| 71 |
+
task=repo["task"],
|
| 72 |
+
round=repo["round"],
|
| 73 |
+
)
|
| 74 |
+
.first()
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
if not task:
|
| 78 |
+
logger.error(f"No task found for {repo['task']}")
|
| 79 |
+
return
|
| 80 |
+
|
| 81 |
+
task_dict = task.to_dict()
|
| 82 |
+
checks_list = task_dict["checks"]
|
| 83 |
+
|
| 84 |
+
finally:
|
| 85 |
+
session.close()
|
| 86 |
+
|
| 87 |
+
# Clone repo
|
| 88 |
+
clone_dir = self.temp_dir / f"{repo['task']}_{repo['round']}"
|
| 89 |
+
static_checker = StaticChecker(repo["repo_url"], repo["commit_sha"], clone_dir)
|
| 90 |
+
|
| 91 |
+
try:
|
| 92 |
+
static_checker.clone_repo()
|
| 93 |
+
|
| 94 |
+
# Run static checks
|
| 95 |
+
logger.info("Running static checks...")
|
| 96 |
+
static_results = await self._run_static_checks(
|
| 97 |
+
static_checker, task_dict["timestamp"]
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
# Run LLM checks
|
| 101 |
+
logger.info("Running LLM checks...")
|
| 102 |
+
llm_results = self._run_llm_checks(clone_dir)
|
| 103 |
+
|
| 104 |
+
# Run dynamic checks
|
| 105 |
+
logger.info("Running dynamic checks...")
|
| 106 |
+
dynamic_results = await self._run_dynamic_checks(repo["pages_url"], checks_list)
|
| 107 |
+
|
| 108 |
+
# Save all results
|
| 109 |
+
all_results = static_results + llm_results + dynamic_results
|
| 110 |
+
for result in all_results:
|
| 111 |
+
self.db.add_result(
|
| 112 |
+
{
|
| 113 |
+
"timestamp": datetime.utcnow(),
|
| 114 |
+
"email": repo["email"],
|
| 115 |
+
"task": repo["task"],
|
| 116 |
+
"round": repo["round"],
|
| 117 |
+
"repo_url": repo["repo_url"],
|
| 118 |
+
"commit_sha": repo["commit_sha"],
|
| 119 |
+
"pages_url": repo["pages_url"],
|
| 120 |
+
"check": result["check"],
|
| 121 |
+
"score": result["score"],
|
| 122 |
+
"reason": result["reason"],
|
| 123 |
+
"logs": result.get("logs", ""),
|
| 124 |
+
}
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
logger.info(
|
| 128 |
+
f"Completed evaluation for {repo['email']}/{repo['task']}: "
|
| 129 |
+
f"{len(all_results)} checks"
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
except Exception as e:
|
| 133 |
+
logger.error(f"Error evaluating {repo['task']}: {e}", exc_info=True)
|
| 134 |
+
|
| 135 |
+
# Save error result
|
| 136 |
+
self.db.add_result(
|
| 137 |
+
{
|
| 138 |
+
"timestamp": datetime.utcnow(),
|
| 139 |
+
"email": repo["email"],
|
| 140 |
+
"task": repo["task"],
|
| 141 |
+
"round": repo["round"],
|
| 142 |
+
"repo_url": repo["repo_url"],
|
| 143 |
+
"commit_sha": repo["commit_sha"],
|
| 144 |
+
"pages_url": repo["pages_url"],
|
| 145 |
+
"check": "Evaluation",
|
| 146 |
+
"score": 0.0,
|
| 147 |
+
"reason": f"Evaluation failed: {e}",
|
| 148 |
+
"logs": str(e),
|
| 149 |
+
}
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
finally:
|
| 153 |
+
# Cleanup clone
|
| 154 |
+
if clone_dir.exists():
|
| 155 |
+
shutil.rmtree(clone_dir)
|
| 156 |
+
|
| 157 |
+
async def _run_static_checks(
|
| 158 |
+
self, checker: StaticChecker, task_timestamp: str
|
| 159 |
+
) -> list[dict]:
|
| 160 |
+
"""Run static checks.
|
| 161 |
+
|
| 162 |
+
Args:
|
| 163 |
+
checker: Static checker instance
|
| 164 |
+
task_timestamp: When task was sent
|
| 165 |
+
|
| 166 |
+
Returns:
|
| 167 |
+
List of check results
|
| 168 |
+
"""
|
| 169 |
+
results = []
|
| 170 |
+
|
| 171 |
+
# Parse timestamp
|
| 172 |
+
if isinstance(task_timestamp, str):
|
| 173 |
+
task_time = datetime.fromisoformat(task_timestamp.replace("Z", "+00:00"))
|
| 174 |
+
else:
|
| 175 |
+
task_time = task_timestamp
|
| 176 |
+
|
| 177 |
+
# Repo creation time
|
| 178 |
+
result = checker.check_created_after_task(task_time)
|
| 179 |
+
results.append({"check": "Repo created after task", **result})
|
| 180 |
+
|
| 181 |
+
# MIT License
|
| 182 |
+
result = checker.check_mit_license()
|
| 183 |
+
results.append({"check": "MIT LICENSE exists", **result})
|
| 184 |
+
|
| 185 |
+
# README exists and basic quality
|
| 186 |
+
result = checker.check_readme()
|
| 187 |
+
results.append({"check": "README.md basic quality", **result})
|
| 188 |
+
|
| 189 |
+
# No secrets
|
| 190 |
+
result = checker.check_no_secrets()
|
| 191 |
+
results.append({"check": "No secrets in code", **result})
|
| 192 |
+
|
| 193 |
+
return results
|
| 194 |
+
|
| 195 |
+
def _run_llm_checks(self, code_dir: Path) -> list[dict]:
|
| 196 |
+
"""Run LLM-based checks.
|
| 197 |
+
|
| 198 |
+
Args:
|
| 199 |
+
code_dir: Directory containing code
|
| 200 |
+
|
| 201 |
+
Returns:
|
| 202 |
+
List of check results
|
| 203 |
+
"""
|
| 204 |
+
results = []
|
| 205 |
+
|
| 206 |
+
# README quality
|
| 207 |
+
readme_path = code_dir / "README.md"
|
| 208 |
+
if readme_path.exists():
|
| 209 |
+
result = self.llm_checker.check_readme_quality(readme_path)
|
| 210 |
+
results.append({"check": "LLM: README.md quality", **result})
|
| 211 |
+
|
| 212 |
+
# Code quality
|
| 213 |
+
result = self.llm_checker.check_code_quality(code_dir)
|
| 214 |
+
results.append({"check": "LLM: Code quality", **result})
|
| 215 |
+
|
| 216 |
+
return results
|
| 217 |
+
|
| 218 |
+
async def _run_dynamic_checks(
|
| 219 |
+
self, pages_url: str, checks: list[str]
|
| 220 |
+
) -> list[dict]:
|
| 221 |
+
"""Run dynamic Playwright checks.
|
| 222 |
+
|
| 223 |
+
Args:
|
| 224 |
+
pages_url: GitHub Pages URL
|
| 225 |
+
checks: List of checks to run
|
| 226 |
+
|
| 227 |
+
Returns:
|
| 228 |
+
List of check results
|
| 229 |
+
"""
|
| 230 |
+
dynamic_checker = DynamicChecker(pages_url, checks)
|
| 231 |
+
results = await dynamic_checker.run_checks()
|
| 232 |
+
return results
|
| 233 |
+
|
| 234 |
+
async def run(self) -> None:
|
| 235 |
+
"""Run evaluation on all pending repos."""
|
| 236 |
+
logger.info("Starting evaluation process")
|
| 237 |
+
|
| 238 |
+
pending_repos = self.get_pending_repos()
|
| 239 |
+
|
| 240 |
+
if not pending_repos:
|
| 241 |
+
logger.info("No pending repos to evaluate")
|
| 242 |
+
return
|
| 243 |
+
|
| 244 |
+
# Evaluate each repo
|
| 245 |
+
for repo in pending_repos:
|
| 246 |
+
await self.evaluate_repo(repo)
|
| 247 |
+
|
| 248 |
+
logger.info("Evaluation process complete")
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
async def main():
|
| 252 |
+
"""Main entry point."""
|
| 253 |
+
evaluator = Evaluator()
|
| 254 |
+
await evaluator.run()
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
if __name__ == "__main__":
|
| 258 |
+
asyncio.run(main())
|
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Round 1 task generation and distribution script."""
|
| 2 |
+
import csv
|
| 3 |
+
import json
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
|
| 6 |
+
import httpx
|
| 7 |
+
|
| 8 |
+
from instructor.database import Database
|
| 9 |
+
from instructor.task_templates import TaskTemplateManager
|
| 10 |
+
from shared.config import settings
|
| 11 |
+
from shared.logger import setup_logger
|
| 12 |
+
from shared.models import Attachment, TaskRequest
|
| 13 |
+
from shared.utils import generate_nonce
|
| 14 |
+
|
| 15 |
+
logger = setup_logger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class Round1TaskGenerator:
|
| 19 |
+
"""Generate and send round 1 tasks to students."""
|
| 20 |
+
|
| 21 |
+
def __init__(self) -> None:
|
| 22 |
+
"""Initialize task generator."""
|
| 23 |
+
self.db = Database()
|
| 24 |
+
self.template_manager = TaskTemplateManager()
|
| 25 |
+
self.db.create_tables()
|
| 26 |
+
|
| 27 |
+
def load_submissions(self) -> list[dict]:
|
| 28 |
+
"""Load submissions from CSV file.
|
| 29 |
+
|
| 30 |
+
Returns:
|
| 31 |
+
List of submission dictionaries
|
| 32 |
+
"""
|
| 33 |
+
submissions = []
|
| 34 |
+
|
| 35 |
+
if not settings.submissions_csv.exists():
|
| 36 |
+
logger.error(f"Submissions file not found: {settings.submissions_csv}")
|
| 37 |
+
return submissions
|
| 38 |
+
|
| 39 |
+
with open(settings.submissions_csv, "r") as f:
|
| 40 |
+
reader = csv.DictReader(f)
|
| 41 |
+
for row in reader:
|
| 42 |
+
submissions.append(
|
| 43 |
+
{
|
| 44 |
+
"timestamp": row["timestamp"],
|
| 45 |
+
"email": row["email"],
|
| 46 |
+
"endpoint": row["endpoint"],
|
| 47 |
+
"secret": row["secret"],
|
| 48 |
+
}
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
logger.info(f"Loaded {len(submissions)} submissions")
|
| 52 |
+
return submissions
|
| 53 |
+
|
| 54 |
+
def generate_task_request(self, submission: dict) -> TaskRequest:
|
| 55 |
+
"""Generate task request for a submission.
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
submission: Submission data
|
| 59 |
+
|
| 60 |
+
Returns:
|
| 61 |
+
Task request
|
| 62 |
+
"""
|
| 63 |
+
email = submission["email"]
|
| 64 |
+
|
| 65 |
+
# Check if round 1 task already exists
|
| 66 |
+
# We check if there's a successful task (with statuscode 200)
|
| 67 |
+
session = self.db.get_session()
|
| 68 |
+
try:
|
| 69 |
+
existing = (
|
| 70 |
+
session.query(self.db.Task)
|
| 71 |
+
.filter_by(email=email, round=1, statuscode=200)
|
| 72 |
+
.first()
|
| 73 |
+
)
|
| 74 |
+
if existing:
|
| 75 |
+
logger.info(f"Round 1 task already exists for {email}, skipping")
|
| 76 |
+
return None
|
| 77 |
+
finally:
|
| 78 |
+
session.close()
|
| 79 |
+
|
| 80 |
+
# Generate task from random template
|
| 81 |
+
task_data = self.template_manager.generate_task(email, round_num=1)
|
| 82 |
+
|
| 83 |
+
# Create task request
|
| 84 |
+
nonce = generate_nonce()
|
| 85 |
+
attachments = [Attachment(**att) for att in task_data["attachments"]]
|
| 86 |
+
|
| 87 |
+
task_request = TaskRequest(
|
| 88 |
+
email=email,
|
| 89 |
+
secret=submission["secret"],
|
| 90 |
+
task=task_data["task_id"],
|
| 91 |
+
round=1,
|
| 92 |
+
nonce=nonce,
|
| 93 |
+
brief=task_data["brief"],
|
| 94 |
+
checks=task_data["checks"],
|
| 95 |
+
evaluation_url=settings.evaluation_api_url,
|
| 96 |
+
attachments=attachments,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
logger.info(f"Generated task request for {email}: {task_request.task}")
|
| 100 |
+
return task_request, submission
|
| 101 |
+
|
| 102 |
+
async def send_task_request(
|
| 103 |
+
self, task_request: TaskRequest, submission: dict
|
| 104 |
+
) -> int:
|
| 105 |
+
"""Send task request to student endpoint.
|
| 106 |
+
|
| 107 |
+
Args:
|
| 108 |
+
task_request: Task request to send
|
| 109 |
+
submission: Submission data with endpoint
|
| 110 |
+
|
| 111 |
+
Returns:
|
| 112 |
+
HTTP status code
|
| 113 |
+
"""
|
| 114 |
+
endpoint = submission["endpoint"]
|
| 115 |
+
logger.info(f"Sending task to {endpoint}")
|
| 116 |
+
|
| 117 |
+
try:
|
| 118 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 119 |
+
response = await client.post(
|
| 120 |
+
endpoint,
|
| 121 |
+
json=task_request.model_dump(),
|
| 122 |
+
headers={"Content-Type": "application/json"},
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
status_code = response.status_code
|
| 126 |
+
logger.info(
|
| 127 |
+
f"Task sent to {endpoint}: "
|
| 128 |
+
f"status {status_code}, response: {response.text[:200]}"
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
return status_code
|
| 132 |
+
|
| 133 |
+
except Exception as e:
|
| 134 |
+
logger.error(f"Failed to send task to {endpoint}: {e}")
|
| 135 |
+
return 0 # Use 0 to indicate failure
|
| 136 |
+
|
| 137 |
+
def save_task_record(
|
| 138 |
+
self, task_request: TaskRequest, submission: dict, status_code: int
|
| 139 |
+
) -> None:
|
| 140 |
+
"""Save task record to database.
|
| 141 |
+
|
| 142 |
+
Args:
|
| 143 |
+
task_request: Task request
|
| 144 |
+
submission: Submission data
|
| 145 |
+
status_code: HTTP status code from response
|
| 146 |
+
"""
|
| 147 |
+
task_data = {
|
| 148 |
+
"timestamp": datetime.utcnow(),
|
| 149 |
+
"email": task_request.email,
|
| 150 |
+
"task": task_request.task,
|
| 151 |
+
"round": task_request.round,
|
| 152 |
+
"nonce": task_request.nonce,
|
| 153 |
+
"brief": task_request.brief,
|
| 154 |
+
"attachments": json.dumps([att.model_dump() for att in task_request.attachments]),
|
| 155 |
+
"checks": json.dumps(task_request.checks),
|
| 156 |
+
"evaluation_url": task_request.evaluation_url,
|
| 157 |
+
"endpoint": submission["endpoint"],
|
| 158 |
+
"statuscode": status_code,
|
| 159 |
+
"secret": submission["secret"],
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
self.db.add_task(task_data)
|
| 163 |
+
logger.info(f"Saved task record: {task_request.task}")
|
| 164 |
+
|
| 165 |
+
async def process_submission(self, submission: dict) -> None:
|
| 166 |
+
"""Process a single submission.
|
| 167 |
+
|
| 168 |
+
Args:
|
| 169 |
+
submission: Submission data
|
| 170 |
+
"""
|
| 171 |
+
try:
|
| 172 |
+
# Generate task
|
| 173 |
+
result = self.generate_task_request(submission)
|
| 174 |
+
if result is None:
|
| 175 |
+
return # Already processed
|
| 176 |
+
|
| 177 |
+
task_request, submission = result
|
| 178 |
+
|
| 179 |
+
# Send task
|
| 180 |
+
status_code = await self.send_task_request(task_request, submission)
|
| 181 |
+
|
| 182 |
+
# Save record
|
| 183 |
+
self.save_task_record(task_request, submission, status_code)
|
| 184 |
+
|
| 185 |
+
if status_code == 200:
|
| 186 |
+
logger.info(f"Successfully sent round 1 task to {submission['email']}")
|
| 187 |
+
else:
|
| 188 |
+
logger.warning(
|
| 189 |
+
f"Failed to send round 1 task to {submission['email']}: "
|
| 190 |
+
f"status {status_code}"
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
except Exception as e:
|
| 194 |
+
logger.error(f"Error processing submission {submission['email']}: {e}", exc_info=True)
|
| 195 |
+
|
| 196 |
+
async def run(self) -> None:
|
| 197 |
+
"""Run round 1 task generation."""
|
| 198 |
+
logger.info("Starting round 1 task generation")
|
| 199 |
+
|
| 200 |
+
# Load submissions
|
| 201 |
+
submissions = self.load_submissions()
|
| 202 |
+
|
| 203 |
+
if not submissions:
|
| 204 |
+
logger.error("No submissions to process")
|
| 205 |
+
return
|
| 206 |
+
|
| 207 |
+
# Process each submission
|
| 208 |
+
for submission in submissions:
|
| 209 |
+
await self.process_submission(submission)
|
| 210 |
+
|
| 211 |
+
logger.info("Round 1 task generation complete")
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
async def main():
|
| 215 |
+
"""Main entry point."""
|
| 216 |
+
generator = Round1TaskGenerator()
|
| 217 |
+
await generator.run()
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
if __name__ == "__main__":
|
| 221 |
+
import asyncio
|
| 222 |
+
|
| 223 |
+
asyncio.run(main())
|
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Round 2 task generation and distribution script."""
|
| 2 |
+
import json
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
import httpx
|
| 6 |
+
|
| 7 |
+
from instructor.database import Database
|
| 8 |
+
from instructor.task_templates import TaskTemplateManager
|
| 9 |
+
from shared.config import settings
|
| 10 |
+
from shared.logger import setup_logger
|
| 11 |
+
from shared.models import Attachment, TaskRequest
|
| 12 |
+
from shared.utils import generate_nonce
|
| 13 |
+
|
| 14 |
+
logger = setup_logger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class Round2TaskGenerator:
|
| 18 |
+
"""Generate and send round 2 tasks to students."""
|
| 19 |
+
|
| 20 |
+
def __init__(self) -> None:
|
| 21 |
+
"""Initialize task generator."""
|
| 22 |
+
self.db = Database()
|
| 23 |
+
self.template_manager = TaskTemplateManager()
|
| 24 |
+
|
| 25 |
+
def get_round1_repos(self) -> list[dict]:
|
| 26 |
+
"""Get all round 1 repository submissions.
|
| 27 |
+
|
| 28 |
+
Returns:
|
| 29 |
+
List of repo dictionaries
|
| 30 |
+
"""
|
| 31 |
+
repos = []
|
| 32 |
+
session = self.db.get_session()
|
| 33 |
+
try:
|
| 34 |
+
round1_repos = session.query(self.db.Repo).filter_by(round=1).all()
|
| 35 |
+
repos = [repo.to_dict() for repo in round1_repos]
|
| 36 |
+
finally:
|
| 37 |
+
session.close()
|
| 38 |
+
|
| 39 |
+
logger.info(f"Found {len(repos)} round 1 repos")
|
| 40 |
+
return repos
|
| 41 |
+
|
| 42 |
+
def generate_round2_task(self, repo: dict) -> tuple[TaskRequest, dict]:
|
| 43 |
+
"""Generate round 2 task for a repo.
|
| 44 |
+
|
| 45 |
+
Args:
|
| 46 |
+
repo: Round 1 repo submission
|
| 47 |
+
|
| 48 |
+
Returns:
|
| 49 |
+
Tuple of (task_request, submission_data) or None if skipped
|
| 50 |
+
"""
|
| 51 |
+
email = repo["email"]
|
| 52 |
+
task_id = repo["task"]
|
| 53 |
+
|
| 54 |
+
# Check if round 2 task already exists
|
| 55 |
+
if self.db.task_exists(email, task_id, round=2):
|
| 56 |
+
logger.info(f"Round 2 task already exists for {email}/{task_id}, skipping")
|
| 57 |
+
return None
|
| 58 |
+
|
| 59 |
+
# Get the round 1 task to find the template
|
| 60 |
+
session = self.db.get_session()
|
| 61 |
+
try:
|
| 62 |
+
round1_task = (
|
| 63 |
+
session.query(self.db.Task)
|
| 64 |
+
.filter_by(email=email, task=task_id, round=1)
|
| 65 |
+
.first()
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
if not round1_task:
|
| 69 |
+
logger.warning(f"No round 1 task found for {email}/{task_id}")
|
| 70 |
+
return None
|
| 71 |
+
|
| 72 |
+
# Extract template ID from task ID
|
| 73 |
+
template_id = task_id.rsplit("-", 1)[0]
|
| 74 |
+
|
| 75 |
+
# Generate round 2 task from same template
|
| 76 |
+
task_data = self.template_manager.generate_task(
|
| 77 |
+
email, template_id=template_id, round_num=2
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# Create task request
|
| 81 |
+
nonce = generate_nonce()
|
| 82 |
+
attachments = [Attachment(**att) for att in task_data["attachments"]]
|
| 83 |
+
|
| 84 |
+
task_request = TaskRequest(
|
| 85 |
+
email=email,
|
| 86 |
+
secret=round1_task.secret,
|
| 87 |
+
task=task_id, # Keep same task ID
|
| 88 |
+
round=2,
|
| 89 |
+
nonce=nonce,
|
| 90 |
+
brief=task_data["brief"],
|
| 91 |
+
checks=task_data["checks"],
|
| 92 |
+
evaluation_url=settings.evaluation_api_url,
|
| 93 |
+
attachments=attachments,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
submission_data = {"endpoint": round1_task.endpoint, "secret": round1_task.secret}
|
| 97 |
+
|
| 98 |
+
logger.info(f"Generated round 2 task for {email}: {task_id}")
|
| 99 |
+
return task_request, submission_data
|
| 100 |
+
|
| 101 |
+
except Exception as e:
|
| 102 |
+
logger.error(f"Error generating round 2 task for {email}/{task_id}: {e}")
|
| 103 |
+
return None
|
| 104 |
+
finally:
|
| 105 |
+
session.close()
|
| 106 |
+
|
| 107 |
+
async def send_task_request(
|
| 108 |
+
self, task_request: TaskRequest, submission: dict
|
| 109 |
+
) -> int:
|
| 110 |
+
"""Send task request to student endpoint.
|
| 111 |
+
|
| 112 |
+
Args:
|
| 113 |
+
task_request: Task request to send
|
| 114 |
+
submission: Submission data with endpoint
|
| 115 |
+
|
| 116 |
+
Returns:
|
| 117 |
+
HTTP status code
|
| 118 |
+
"""
|
| 119 |
+
endpoint = submission["endpoint"]
|
| 120 |
+
logger.info(f"Sending round 2 task to {endpoint}")
|
| 121 |
+
|
| 122 |
+
try:
|
| 123 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 124 |
+
response = await client.post(
|
| 125 |
+
endpoint,
|
| 126 |
+
json=task_request.model_dump(),
|
| 127 |
+
headers={"Content-Type": "application/json"},
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
status_code = response.status_code
|
| 131 |
+
logger.info(
|
| 132 |
+
f"Round 2 task sent to {endpoint}: "
|
| 133 |
+
f"status {status_code}, response: {response.text[:200]}"
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
return status_code
|
| 137 |
+
|
| 138 |
+
except Exception as e:
|
| 139 |
+
logger.error(f"Failed to send round 2 task to {endpoint}: {e}")
|
| 140 |
+
return 0
|
| 141 |
+
|
| 142 |
+
def save_task_record(
|
| 143 |
+
self, task_request: TaskRequest, submission: dict, status_code: int
|
| 144 |
+
) -> None:
|
| 145 |
+
"""Save task record to database.
|
| 146 |
+
|
| 147 |
+
Args:
|
| 148 |
+
task_request: Task request
|
| 149 |
+
submission: Submission data
|
| 150 |
+
status_code: HTTP status code from response
|
| 151 |
+
"""
|
| 152 |
+
task_data = {
|
| 153 |
+
"timestamp": datetime.utcnow(),
|
| 154 |
+
"email": task_request.email,
|
| 155 |
+
"task": task_request.task,
|
| 156 |
+
"round": task_request.round,
|
| 157 |
+
"nonce": task_request.nonce,
|
| 158 |
+
"brief": task_request.brief,
|
| 159 |
+
"attachments": json.dumps([att.model_dump() for att in task_request.attachments]),
|
| 160 |
+
"checks": json.dumps(task_request.checks),
|
| 161 |
+
"evaluation_url": task_request.evaluation_url,
|
| 162 |
+
"endpoint": submission["endpoint"],
|
| 163 |
+
"statuscode": status_code,
|
| 164 |
+
"secret": submission["secret"],
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
self.db.add_task(task_data)
|
| 168 |
+
logger.info(f"Saved round 2 task record: {task_request.task}")
|
| 169 |
+
|
| 170 |
+
async def process_repo(self, repo: dict) -> None:
|
| 171 |
+
"""Process a single repo for round 2.
|
| 172 |
+
|
| 173 |
+
Args:
|
| 174 |
+
repo: Repo submission data
|
| 175 |
+
"""
|
| 176 |
+
try:
|
| 177 |
+
# Generate task
|
| 178 |
+
result = self.generate_round2_task(repo)
|
| 179 |
+
if result is None:
|
| 180 |
+
return # Already processed or error
|
| 181 |
+
|
| 182 |
+
task_request, submission = result
|
| 183 |
+
|
| 184 |
+
# Send task
|
| 185 |
+
status_code = await self.send_task_request(task_request, submission)
|
| 186 |
+
|
| 187 |
+
# Save record
|
| 188 |
+
self.save_task_record(task_request, submission, status_code)
|
| 189 |
+
|
| 190 |
+
if status_code == 200:
|
| 191 |
+
logger.info(f"Successfully sent round 2 task to {repo['email']}")
|
| 192 |
+
else:
|
| 193 |
+
logger.warning(
|
| 194 |
+
f"Failed to send round 2 task to {repo['email']}: status {status_code}"
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
except Exception as e:
|
| 198 |
+
logger.error(f"Error processing repo {repo['task']}: {e}", exc_info=True)
|
| 199 |
+
|
| 200 |
+
async def run(self) -> None:
|
| 201 |
+
"""Run round 2 task generation."""
|
| 202 |
+
logger.info("Starting round 2 task generation")
|
| 203 |
+
|
| 204 |
+
# Get round 1 repos
|
| 205 |
+
repos = self.get_round1_repos()
|
| 206 |
+
|
| 207 |
+
if not repos:
|
| 208 |
+
logger.error("No round 1 repos to process")
|
| 209 |
+
return
|
| 210 |
+
|
| 211 |
+
# Process each repo
|
| 212 |
+
for repo in repos:
|
| 213 |
+
await self.process_repo(repo)
|
| 214 |
+
|
| 215 |
+
logger.info("Round 2 task generation complete")
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
async def main():
|
| 219 |
+
"""Main entry point."""
|
| 220 |
+
generator = Round2TaskGenerator()
|
| 221 |
+
await generator.run()
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
if __name__ == "__main__":
|
| 225 |
+
import asyncio
|
| 226 |
+
|
| 227 |
+
asyncio.run(main())
|
|
@@ -0,0 +1,312 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Task template management and parametrization."""
|
| 2 |
+
import csv
|
| 3 |
+
import hashlib
|
| 4 |
+
import random
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import yaml
|
| 10 |
+
from jinja2 import Template
|
| 11 |
+
|
| 12 |
+
from shared.config import settings
|
| 13 |
+
from shared.logger import setup_logger
|
| 14 |
+
from shared.utils import encode_data_uri, generate_task_id
|
| 15 |
+
|
| 16 |
+
logger = setup_logger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class TaskTemplate:
|
| 20 |
+
"""Single task template with parametrization."""
|
| 21 |
+
|
| 22 |
+
def __init__(self, data: dict[str, Any]) -> None:
|
| 23 |
+
"""Initialize task template.
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
data: Template data from YAML
|
| 27 |
+
"""
|
| 28 |
+
self.id = data["id"]
|
| 29 |
+
self.brief = data["brief"]
|
| 30 |
+
self.attachments = data.get("attachments", [])
|
| 31 |
+
self.checks = data.get("checks", [])
|
| 32 |
+
self.round2 = data.get("round2", [])
|
| 33 |
+
|
| 34 |
+
def parametrize(self, seed: str) -> dict[str, Any]:
|
| 35 |
+
"""Parametrize template with seed.
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
seed: Seed string for randomization
|
| 39 |
+
|
| 40 |
+
Returns:
|
| 41 |
+
Parametrized task data
|
| 42 |
+
"""
|
| 43 |
+
# Set random seed for reproducibility
|
| 44 |
+
random.seed(seed)
|
| 45 |
+
|
| 46 |
+
# Generate data based on template
|
| 47 |
+
context = self._generate_context(seed)
|
| 48 |
+
|
| 49 |
+
# Parametrize brief
|
| 50 |
+
brief = Template(self.brief).render(**context)
|
| 51 |
+
|
| 52 |
+
# Parametrize attachments
|
| 53 |
+
attachments = []
|
| 54 |
+
for att_template in self.attachments:
|
| 55 |
+
att_name = Template(att_template["name"]).render(**context)
|
| 56 |
+
att_url_template = att_template["url"]
|
| 57 |
+
|
| 58 |
+
# Generate attachment content
|
| 59 |
+
if "data:text/csv" in att_url_template:
|
| 60 |
+
content = self._generate_csv_data(seed)
|
| 61 |
+
att_url = encode_data_uri("text/csv", content.encode())
|
| 62 |
+
elif "data:text/markdown" in att_url_template:
|
| 63 |
+
content = self._generate_markdown_data(seed)
|
| 64 |
+
att_url = encode_data_uri("text/markdown", content.encode())
|
| 65 |
+
elif "data:application/json" in att_url_template:
|
| 66 |
+
content = self._generate_json_data(seed)
|
| 67 |
+
att_url = encode_data_uri("application/json", content.encode())
|
| 68 |
+
else:
|
| 69 |
+
att_url = Template(att_url_template).render(**context)
|
| 70 |
+
|
| 71 |
+
attachments.append({"name": att_name, "url": att_url})
|
| 72 |
+
|
| 73 |
+
# Parametrize checks
|
| 74 |
+
checks = [Template(check).render(**context) for check in self.checks]
|
| 75 |
+
|
| 76 |
+
return {
|
| 77 |
+
"brief": brief,
|
| 78 |
+
"attachments": attachments,
|
| 79 |
+
"checks": checks,
|
| 80 |
+
"context": context,
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
def parametrize_round2(self, seed: str, round2_index: int = 0) -> dict[str, Any]:
|
| 84 |
+
"""Parametrize round 2 task.
|
| 85 |
+
|
| 86 |
+
Args:
|
| 87 |
+
seed: Seed string
|
| 88 |
+
round2_index: Index of round2 option to use
|
| 89 |
+
|
| 90 |
+
Returns:
|
| 91 |
+
Parametrized round 2 task data
|
| 92 |
+
"""
|
| 93 |
+
if not self.round2 or round2_index >= len(self.round2):
|
| 94 |
+
raise ValueError(f"No round2 option at index {round2_index}")
|
| 95 |
+
|
| 96 |
+
round2_task = self.round2[round2_index]
|
| 97 |
+
random.seed(seed + f"-round2-{round2_index}")
|
| 98 |
+
|
| 99 |
+
context = self._generate_context(seed)
|
| 100 |
+
|
| 101 |
+
brief = Template(round2_task["brief"]).render(**context)
|
| 102 |
+
checks = [Template(check).render(**context) for check in round2_task.get("checks", [])]
|
| 103 |
+
|
| 104 |
+
attachments = []
|
| 105 |
+
for att_template in round2_task.get("attachments", []):
|
| 106 |
+
att_name = Template(att_template["name"]).render(**context)
|
| 107 |
+
att_url = Template(att_template["url"]).render(**context)
|
| 108 |
+
attachments.append({"name": att_name, "url": att_url})
|
| 109 |
+
|
| 110 |
+
return {
|
| 111 |
+
"brief": brief,
|
| 112 |
+
"attachments": attachments,
|
| 113 |
+
"checks": checks,
|
| 114 |
+
"context": context,
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
def _generate_context(self, seed: str) -> dict[str, Any]:
|
| 118 |
+
"""Generate context variables for template.
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
seed: Seed string
|
| 122 |
+
|
| 123 |
+
Returns:
|
| 124 |
+
Context dictionary
|
| 125 |
+
"""
|
| 126 |
+
# Generate deterministic values based on seed
|
| 127 |
+
hash_val = int(hashlib.md5(seed.encode()).hexdigest(), 16)
|
| 128 |
+
|
| 129 |
+
return {
|
| 130 |
+
"seed": seed,
|
| 131 |
+
"hash": hash_val % 10000,
|
| 132 |
+
"result": (hash_val % 9000) + 1000, # Result between 1000-9999
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
def _generate_csv_data(self, seed: str) -> str:
|
| 136 |
+
"""Generate sample CSV data.
|
| 137 |
+
|
| 138 |
+
Args:
|
| 139 |
+
seed: Seed for randomization
|
| 140 |
+
|
| 141 |
+
Returns:
|
| 142 |
+
CSV string
|
| 143 |
+
"""
|
| 144 |
+
random.seed(seed)
|
| 145 |
+
|
| 146 |
+
products = ["Widget", "Gadget", "Doohickey", "Thingamajig", "Gizmo"]
|
| 147 |
+
regions = ["North", "South", "East", "West", "Central"]
|
| 148 |
+
|
| 149 |
+
lines = ["product,region,sales"]
|
| 150 |
+
for _ in range(random.randint(10, 20)):
|
| 151 |
+
product = random.choice(products)
|
| 152 |
+
region = random.choice(regions)
|
| 153 |
+
sales = round(random.uniform(100, 1000), 2)
|
| 154 |
+
lines.append(f"{product},{region},{sales}")
|
| 155 |
+
|
| 156 |
+
return "\n".join(lines)
|
| 157 |
+
|
| 158 |
+
def _generate_markdown_data(self, seed: str) -> str:
|
| 159 |
+
"""Generate sample Markdown data.
|
| 160 |
+
|
| 161 |
+
Args:
|
| 162 |
+
seed: Seed for randomization
|
| 163 |
+
|
| 164 |
+
Returns:
|
| 165 |
+
Markdown string
|
| 166 |
+
"""
|
| 167 |
+
random.seed(seed)
|
| 168 |
+
|
| 169 |
+
content = f"""# Sample Document {seed[:8]}
|
| 170 |
+
|
| 171 |
+
## Introduction
|
| 172 |
+
|
| 173 |
+
This is a sample markdown document generated for testing.
|
| 174 |
+
|
| 175 |
+
## Code Example
|
| 176 |
+
|
| 177 |
+
```python
|
| 178 |
+
def hello():
|
| 179 |
+
print("Hello, world!")
|
| 180 |
+
```
|
| 181 |
+
|
| 182 |
+
## List
|
| 183 |
+
|
| 184 |
+
- Item 1
|
| 185 |
+
- Item 2
|
| 186 |
+
- Item 3
|
| 187 |
+
|
| 188 |
+
## Conclusion
|
| 189 |
+
|
| 190 |
+
This document contains {random.randint(50, 150)} words.
|
| 191 |
+
"""
|
| 192 |
+
return content
|
| 193 |
+
|
| 194 |
+
def _generate_json_data(self, seed: str) -> str:
|
| 195 |
+
"""Generate sample JSON data.
|
| 196 |
+
|
| 197 |
+
Args:
|
| 198 |
+
seed: Seed for randomization
|
| 199 |
+
|
| 200 |
+
Returns:
|
| 201 |
+
JSON string
|
| 202 |
+
"""
|
| 203 |
+
import json
|
| 204 |
+
|
| 205 |
+
random.seed(seed)
|
| 206 |
+
|
| 207 |
+
data = {
|
| 208 |
+
"USD": 1.0,
|
| 209 |
+
"EUR": round(random.uniform(0.8, 0.95), 2),
|
| 210 |
+
"GBP": round(random.uniform(0.7, 0.85), 2),
|
| 211 |
+
"JPY": round(random.uniform(110, 140), 2),
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
return json.dumps(data, indent=2)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
class TaskTemplateManager:
|
| 218 |
+
"""Manage task templates."""
|
| 219 |
+
|
| 220 |
+
def __init__(self, templates_dir: Path | None = None) -> None:
|
| 221 |
+
"""Initialize template manager.
|
| 222 |
+
|
| 223 |
+
Args:
|
| 224 |
+
templates_dir: Directory containing template YAML files
|
| 225 |
+
"""
|
| 226 |
+
self.templates_dir = templates_dir or settings.task_templates_dir
|
| 227 |
+
self.templates: dict[str, TaskTemplate] = {}
|
| 228 |
+
self.load_templates()
|
| 229 |
+
|
| 230 |
+
def load_templates(self) -> None:
|
| 231 |
+
"""Load all templates from directory."""
|
| 232 |
+
if not self.templates_dir.exists():
|
| 233 |
+
logger.warning(f"Templates directory not found: {self.templates_dir}")
|
| 234 |
+
return
|
| 235 |
+
|
| 236 |
+
for yaml_file in self.templates_dir.glob("*.yaml"):
|
| 237 |
+
try:
|
| 238 |
+
with open(yaml_file) as f:
|
| 239 |
+
data = yaml.safe_load(f)
|
| 240 |
+
template = TaskTemplate(data)
|
| 241 |
+
self.templates[template.id] = template
|
| 242 |
+
logger.info(f"Loaded template: {template.id}")
|
| 243 |
+
except Exception as e:
|
| 244 |
+
logger.error(f"Failed to load template {yaml_file}: {e}")
|
| 245 |
+
|
| 246 |
+
logger.info(f"Loaded {len(self.templates)} templates")
|
| 247 |
+
|
| 248 |
+
def get_template(self, template_id: str) -> TaskTemplate:
|
| 249 |
+
"""Get template by ID.
|
| 250 |
+
|
| 251 |
+
Args:
|
| 252 |
+
template_id: Template identifier
|
| 253 |
+
|
| 254 |
+
Returns:
|
| 255 |
+
Task template
|
| 256 |
+
|
| 257 |
+
Raises:
|
| 258 |
+
KeyError: If template not found
|
| 259 |
+
"""
|
| 260 |
+
if template_id not in self.templates:
|
| 261 |
+
raise KeyError(f"Template not found: {template_id}")
|
| 262 |
+
return self.templates[template_id]
|
| 263 |
+
|
| 264 |
+
def get_random_template(self) -> TaskTemplate:
|
| 265 |
+
"""Get random template.
|
| 266 |
+
|
| 267 |
+
Returns:
|
| 268 |
+
Random task template
|
| 269 |
+
"""
|
| 270 |
+
if not self.templates:
|
| 271 |
+
raise ValueError("No templates available")
|
| 272 |
+
return random.choice(list(self.templates.values()))
|
| 273 |
+
|
| 274 |
+
def generate_task(
|
| 275 |
+
self, email: str, template_id: str | None = None, round_num: int = 1
|
| 276 |
+
) -> dict[str, Any]:
|
| 277 |
+
"""Generate a complete task from template.
|
| 278 |
+
|
| 279 |
+
Args:
|
| 280 |
+
email: Student email
|
| 281 |
+
template_id: Template ID (random if None)
|
| 282 |
+
round_num: Round number
|
| 283 |
+
|
| 284 |
+
Returns:
|
| 285 |
+
Complete task data
|
| 286 |
+
"""
|
| 287 |
+
# Get template
|
| 288 |
+
if template_id:
|
| 289 |
+
template = self.get_template(template_id)
|
| 290 |
+
else:
|
| 291 |
+
template = self.get_random_template()
|
| 292 |
+
|
| 293 |
+
# Generate seed
|
| 294 |
+
now = datetime.utcnow()
|
| 295 |
+
seed = f"{email}-{now.strftime('%Y-%m-%d-%H')}"
|
| 296 |
+
|
| 297 |
+
# Parametrize
|
| 298 |
+
if round_num == 1:
|
| 299 |
+
task_data = template.parametrize(seed)
|
| 300 |
+
else:
|
| 301 |
+
# For round 2, pick random round2 option
|
| 302 |
+
round2_index = random.randint(0, len(template.round2) - 1) if template.round2 else 0
|
| 303 |
+
task_data = template.parametrize_round2(seed, round2_index)
|
| 304 |
+
|
| 305 |
+
# Generate task ID
|
| 306 |
+
task_id = generate_task_id(template.id, task_data["brief"], task_data["attachments"])
|
| 307 |
+
|
| 308 |
+
return {
|
| 309 |
+
"template_id": template.id,
|
| 310 |
+
"task_id": task_id,
|
| 311 |
+
**task_data,
|
| 312 |
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Main entry point for LLM Code Deployment System."""
|
| 2 |
+
import argparse
|
| 3 |
+
import asyncio
|
| 4 |
+
import sys
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def main():
|
| 8 |
+
"""Main CLI entry point."""
|
| 9 |
+
parser = argparse.ArgumentParser(
|
| 10 |
+
description="LLM Code Deployment System",
|
| 11 |
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 12 |
+
epilog="""
|
| 13 |
+
Examples:
|
| 14 |
+
# Start student API
|
| 15 |
+
python main.py student-api
|
| 16 |
+
|
| 17 |
+
# Start instructor API
|
| 18 |
+
python main.py instructor-api
|
| 19 |
+
|
| 20 |
+
# Run round 1 task generation
|
| 21 |
+
python main.py round1
|
| 22 |
+
|
| 23 |
+
# Run evaluation
|
| 24 |
+
python main.py evaluate
|
| 25 |
+
|
| 26 |
+
# Run round 2 task generation
|
| 27 |
+
python main.py round2
|
| 28 |
+
|
| 29 |
+
# Initialize database
|
| 30 |
+
python main.py init-db
|
| 31 |
+
""",
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
parser.add_argument(
|
| 35 |
+
"command",
|
| 36 |
+
choices=[
|
| 37 |
+
"student-api",
|
| 38 |
+
"instructor-api",
|
| 39 |
+
"round1",
|
| 40 |
+
"round2",
|
| 41 |
+
"evaluate",
|
| 42 |
+
"init-db",
|
| 43 |
+
],
|
| 44 |
+
help="Command to run",
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
args = parser.parse_args()
|
| 48 |
+
|
| 49 |
+
if args.command == "student-api":
|
| 50 |
+
from student.api import app
|
| 51 |
+
import uvicorn
|
| 52 |
+
from shared.config import settings
|
| 53 |
+
|
| 54 |
+
settings.ensure_directories()
|
| 55 |
+
print(f"Starting Student API on port {settings.student_api_port}...")
|
| 56 |
+
uvicorn.run(app, host="0.0.0.0", port=settings.student_api_port)
|
| 57 |
+
|
| 58 |
+
elif args.command == "instructor-api":
|
| 59 |
+
from instructor.api import app
|
| 60 |
+
import uvicorn
|
| 61 |
+
from shared.config import settings
|
| 62 |
+
|
| 63 |
+
settings.ensure_directories()
|
| 64 |
+
print(f"Starting Instructor API on port {settings.instructor_api_port}...")
|
| 65 |
+
uvicorn.run(app, host="0.0.0.0", port=settings.instructor_api_port)
|
| 66 |
+
|
| 67 |
+
elif args.command == "round1":
|
| 68 |
+
from instructor.round1 import main as round1_main
|
| 69 |
+
|
| 70 |
+
print("Running Round 1 task generation...")
|
| 71 |
+
asyncio.run(round1_main())
|
| 72 |
+
|
| 73 |
+
elif args.command == "round2":
|
| 74 |
+
from instructor.round2 import main as round2_main
|
| 75 |
+
|
| 76 |
+
print("Running Round 2 task generation...")
|
| 77 |
+
asyncio.run(round2_main())
|
| 78 |
+
|
| 79 |
+
elif args.command == "evaluate":
|
| 80 |
+
from instructor.evaluate import main as evaluate_main
|
| 81 |
+
|
| 82 |
+
print("Running evaluation...")
|
| 83 |
+
asyncio.run(evaluate_main())
|
| 84 |
+
|
| 85 |
+
elif args.command == "init-db":
|
| 86 |
+
from instructor.database import Database
|
| 87 |
+
|
| 88 |
+
print("Initializing database...")
|
| 89 |
+
db = Database()
|
| 90 |
+
db.create_tables()
|
| 91 |
+
print("Database initialized successfully!")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
if __name__ == "__main__":
|
| 95 |
+
main()
|
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "llm-code-deployment"
|
| 3 |
+
version = "1.0.0"
|
| 4 |
+
description = "LLM-powered code deployment and evaluation system for automated app building and testing"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.10"
|
| 7 |
+
dependencies = [
|
| 8 |
+
"fastapi>=0.104.1",
|
| 9 |
+
"uvicorn[standard]>=0.24.0",
|
| 10 |
+
"anthropic>=0.34.0",
|
| 11 |
+
"openai>=1.3.0",
|
| 12 |
+
"pydantic>=2.5.0",
|
| 13 |
+
"pydantic-settings>=2.1.0",
|
| 14 |
+
"requests>=2.31.0",
|
| 15 |
+
"httpx>=0.25.0",
|
| 16 |
+
"PyGithub>=2.1.1",
|
| 17 |
+
"gitpython>=3.1.40",
|
| 18 |
+
"python-dotenv>=1.0.0",
|
| 19 |
+
"sqlalchemy>=2.0.23",
|
| 20 |
+
"alembic>=1.12.1",
|
| 21 |
+
"psycopg2-binary>=2.9.9",
|
| 22 |
+
"pyyaml>=6.0.1",
|
| 23 |
+
"jinja2>=3.1.2",
|
| 24 |
+
"playwright>=1.40.0",
|
| 25 |
+
"pandas>=2.1.3",
|
| 26 |
+
"beautifulsoup4>=4.12.2",
|
| 27 |
+
"tenacity>=8.2.3",
|
| 28 |
+
"uuid-utils>=0.9.0",
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
[project.optional-dependencies]
|
| 32 |
+
dev = [
|
| 33 |
+
"pytest>=7.4.3",
|
| 34 |
+
"pytest-asyncio>=0.21.1",
|
| 35 |
+
"black>=23.11.0",
|
| 36 |
+
"ruff>=0.1.6",
|
| 37 |
+
"mypy>=1.7.0",
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
[tool.black]
|
| 41 |
+
line-length = 100
|
| 42 |
+
target-version = ['py310']
|
| 43 |
+
|
| 44 |
+
[tool.ruff]
|
| 45 |
+
line-length = 100
|
| 46 |
+
select = ["E", "F", "I", "N", "W"]
|
| 47 |
+
ignore = ["E501"]
|
| 48 |
+
|
| 49 |
+
[tool.mypy]
|
| 50 |
+
python_version = "3.10"
|
| 51 |
+
warn_return_any = true
|
| 52 |
+
warn_unused_configs = true
|
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.104.1
|
| 2 |
+
uvicorn[standard]>=0.24.0
|
| 3 |
+
anthropic>=0.34.0
|
| 4 |
+
openai>=1.3.0
|
| 5 |
+
pydantic>=2.5.0
|
| 6 |
+
pydantic-settings>=2.1.0
|
| 7 |
+
requests>=2.31.0
|
| 8 |
+
httpx>=0.25.0
|
| 9 |
+
PyGithub>=2.1.1
|
| 10 |
+
gitpython>=3.1.40
|
| 11 |
+
python-dotenv>=1.0.0
|
| 12 |
+
sqlalchemy>=2.0.23
|
| 13 |
+
alembic>=1.12.1
|
| 14 |
+
psycopg2-binary>=2.9.9
|
| 15 |
+
pyyaml>=6.0.1
|
| 16 |
+
jinja2>=3.1.2
|
| 17 |
+
playwright>=1.40.0
|
| 18 |
+
pandas>=2.1.3
|
| 19 |
+
beautifulsoup4>=4.12.2
|
| 20 |
+
tenacity>=8.2.3
|
| 21 |
+
uuid-utils>=0.9.0
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Shared modules for LLM Code Deployment system."""
|
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configuration management for LLM Code Deployment system."""
|
| 2 |
+
import os
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Literal
|
| 5 |
+
|
| 6 |
+
from pydantic import Field
|
| 7 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Settings(BaseSettings):
|
| 11 |
+
"""Application settings loaded from environment variables."""
|
| 12 |
+
|
| 13 |
+
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
| 14 |
+
|
| 15 |
+
# Student Configuration
|
| 16 |
+
student_secret: str = Field(default="", description="Secret key for authentication")
|
| 17 |
+
student_email: str = Field(default="", description="Student email address")
|
| 18 |
+
student_api_port: int = Field(default=8000, description="Student API port")
|
| 19 |
+
|
| 20 |
+
# GitHub Configuration
|
| 21 |
+
github_token: str = Field(default="", description="GitHub personal access token")
|
| 22 |
+
github_username: str = Field(default="", description="GitHub username")
|
| 23 |
+
|
| 24 |
+
# LLM Configuration
|
| 25 |
+
anthropic_api_key: str = Field(default="", description="Anthropic API key")
|
| 26 |
+
openai_api_key: str = Field(default="", description="OpenAI API key")
|
| 27 |
+
llm_provider: Literal["anthropic", "openai", "aipipe"] = Field(
|
| 28 |
+
default="aipipe", description="LLM provider to use"
|
| 29 |
+
)
|
| 30 |
+
llm_model: str = Field(
|
| 31 |
+
default="google/gemini-2.0-flash-lite-001", description="LLM model to use"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# AIPipe Configuration
|
| 35 |
+
aipipe_token: str = Field(default="", description="AIPipe API token")
|
| 36 |
+
aipipe_base_url: str = Field(
|
| 37 |
+
default="https://aipipe.org/openrouter/v1",
|
| 38 |
+
description="AIPipe base URL"
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
# Instructor Configuration
|
| 42 |
+
instructor_api_port: int = Field(default=8001, description="Instructor API port")
|
| 43 |
+
database_url: str = Field(
|
| 44 |
+
default="postgresql://localhost:5432/llm_deployment", description="Database URL"
|
| 45 |
+
)
|
| 46 |
+
evaluation_api_url: str = Field(
|
| 47 |
+
default="http://localhost:8001/api/evaluate", description="Evaluation API URL"
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
# Task Configuration
|
| 51 |
+
task_timeout_minutes: int = Field(default=10, description="Task timeout in minutes")
|
| 52 |
+
max_retry_attempts: int = Field(default=3, description="Maximum retry attempts")
|
| 53 |
+
retry_delays: str = Field(default="1,2,4,8", description="Retry delays in seconds")
|
| 54 |
+
|
| 55 |
+
# Paths
|
| 56 |
+
generated_repos_dir: Path = Field(
|
| 57 |
+
default=Path("./generated_repos"), description="Directory for generated repos"
|
| 58 |
+
)
|
| 59 |
+
task_templates_dir: Path = Field(
|
| 60 |
+
default=Path("./templates"), description="Directory for task templates"
|
| 61 |
+
)
|
| 62 |
+
submissions_csv: Path = Field(
|
| 63 |
+
default=Path("./submissions.csv"), description="Path to submissions CSV"
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# Logging
|
| 67 |
+
log_level: str = Field(default="INFO", description="Logging level")
|
| 68 |
+
log_file: Path = Field(default=Path("./logs/app.log"), description="Log file path")
|
| 69 |
+
|
| 70 |
+
def get_retry_delays(self) -> list[int]:
|
| 71 |
+
"""Parse retry delays from comma-separated string."""
|
| 72 |
+
return [int(d.strip()) for d in self.retry_delays.split(",")]
|
| 73 |
+
|
| 74 |
+
def ensure_directories(self) -> None:
|
| 75 |
+
"""Ensure all required directories exist."""
|
| 76 |
+
self.generated_repos_dir.mkdir(parents=True, exist_ok=True)
|
| 77 |
+
self.task_templates_dir.mkdir(parents=True, exist_ok=True)
|
| 78 |
+
self.log_file.parent.mkdir(parents=True, exist_ok=True)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# Global settings instance
|
| 82 |
+
settings = Settings()
|
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Logging configuration for LLM Code Deployment system."""
|
| 2 |
+
import logging
|
| 3 |
+
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from .config import settings
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def setup_logger(name: str) -> logging.Logger:
|
| 10 |
+
"""Set up logger with file and console handlers."""
|
| 11 |
+
logger = logging.getLogger(name)
|
| 12 |
+
logger.setLevel(getattr(logging, settings.log_level.upper()))
|
| 13 |
+
|
| 14 |
+
# Avoid adding handlers multiple times
|
| 15 |
+
if logger.handlers:
|
| 16 |
+
return logger
|
| 17 |
+
|
| 18 |
+
# Console handler
|
| 19 |
+
console_handler = logging.StreamHandler(sys.stdout)
|
| 20 |
+
console_handler.setLevel(logging.INFO)
|
| 21 |
+
console_formatter = logging.Formatter(
|
| 22 |
+
"%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
| 23 |
+
datefmt="%Y-%m-%d %H:%M:%S",
|
| 24 |
+
)
|
| 25 |
+
console_handler.setFormatter(console_formatter)
|
| 26 |
+
logger.addHandler(console_handler)
|
| 27 |
+
|
| 28 |
+
# File handler
|
| 29 |
+
settings.log_file.parent.mkdir(parents=True, exist_ok=True)
|
| 30 |
+
file_handler = logging.FileHandler(settings.log_file)
|
| 31 |
+
file_handler.setLevel(logging.DEBUG)
|
| 32 |
+
file_formatter = logging.Formatter(
|
| 33 |
+
"%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s",
|
| 34 |
+
datefmt="%Y-%m-%d %H:%M:%S",
|
| 35 |
+
)
|
| 36 |
+
file_handler.setFormatter(file_formatter)
|
| 37 |
+
logger.addHandler(file_handler)
|
| 38 |
+
|
| 39 |
+
return logger
|
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared data models for LLM Code Deployment system."""
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel, Field, HttpUrl
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class Attachment(BaseModel):
|
| 9 |
+
"""File attachment with data URI."""
|
| 10 |
+
|
| 11 |
+
name: str = Field(..., description="Attachment filename")
|
| 12 |
+
url: str = Field(..., description="Data URI or URL of the attachment")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class TaskRequest(BaseModel):
|
| 16 |
+
"""Task request sent to student endpoint."""
|
| 17 |
+
|
| 18 |
+
email: str = Field(..., description="Student email ID")
|
| 19 |
+
secret: str = Field(..., description="Student-provided secret")
|
| 20 |
+
task: str = Field(..., description="Unique task ID")
|
| 21 |
+
round: int = Field(..., description="Round index", ge=1)
|
| 22 |
+
nonce: str = Field(..., description="Nonce to pass back to evaluation URL")
|
| 23 |
+
brief: str = Field(..., description="App brief description")
|
| 24 |
+
checks: list[str] = Field(..., description="Evaluation checks")
|
| 25 |
+
evaluation_url: str = Field(..., description="URL to send repo details")
|
| 26 |
+
attachments: list[Attachment] = Field(default_factory=list, description="File attachments")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class RepoSubmission(BaseModel):
|
| 30 |
+
"""Repository submission sent to evaluation endpoint."""
|
| 31 |
+
|
| 32 |
+
email: str = Field(..., description="Student email ID")
|
| 33 |
+
task: str = Field(..., description="Task ID")
|
| 34 |
+
round: int = Field(..., description="Round index", ge=1)
|
| 35 |
+
nonce: str = Field(..., description="Nonce from request")
|
| 36 |
+
repo_url: str = Field(..., description="GitHub repository URL")
|
| 37 |
+
commit_sha: str = Field(..., description="Git commit SHA")
|
| 38 |
+
pages_url: str = Field(..., description="GitHub Pages URL")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class TaskRecord(BaseModel):
|
| 42 |
+
"""Task record in database."""
|
| 43 |
+
|
| 44 |
+
timestamp: datetime
|
| 45 |
+
email: str
|
| 46 |
+
task: str
|
| 47 |
+
round: int
|
| 48 |
+
nonce: str
|
| 49 |
+
brief: str
|
| 50 |
+
attachments: str # JSON serialized
|
| 51 |
+
checks: str # JSON serialized
|
| 52 |
+
evaluation_url: str
|
| 53 |
+
endpoint: str
|
| 54 |
+
statuscode: int | None = None
|
| 55 |
+
secret: str
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class RepoRecord(BaseModel):
|
| 59 |
+
"""Repository record in database."""
|
| 60 |
+
|
| 61 |
+
timestamp: datetime
|
| 62 |
+
email: str
|
| 63 |
+
task: str
|
| 64 |
+
round: int
|
| 65 |
+
nonce: str
|
| 66 |
+
repo_url: str
|
| 67 |
+
commit_sha: str
|
| 68 |
+
pages_url: str
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class EvaluationResult(BaseModel):
|
| 72 |
+
"""Evaluation result record."""
|
| 73 |
+
|
| 74 |
+
timestamp: datetime
|
| 75 |
+
email: str
|
| 76 |
+
task: str
|
| 77 |
+
round: int
|
| 78 |
+
repo_url: str
|
| 79 |
+
commit_sha: str
|
| 80 |
+
pages_url: str
|
| 81 |
+
check: str
|
| 82 |
+
score: float
|
| 83 |
+
reason: str
|
| 84 |
+
logs: str
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class Submission(BaseModel):
|
| 88 |
+
"""Student submission from CSV."""
|
| 89 |
+
|
| 90 |
+
timestamp: datetime
|
| 91 |
+
email: str
|
| 92 |
+
endpoint: str
|
| 93 |
+
secret: str
|
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Utility functions for LLM Code Deployment system."""
|
| 2 |
+
import base64
|
| 3 |
+
import hashlib
|
| 4 |
+
import re
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from uuid_utils import uuid7
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def generate_nonce() -> str:
|
| 12 |
+
"""Generate a unique nonce using UUID7."""
|
| 13 |
+
return str(uuid7())
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def generate_task_id(template_id: str, brief: str, attachments: list[dict[str, Any]]) -> str:
|
| 17 |
+
"""Generate a unique task ID based on template and content."""
|
| 18 |
+
content = f"{brief}{str(attachments)}"
|
| 19 |
+
hash_value = hashlib.sha256(content.encode()).hexdigest()[:5]
|
| 20 |
+
return f"{template_id}-{hash_value}"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def decode_data_uri(data_uri: str) -> tuple[str, bytes]:
|
| 24 |
+
"""Decode a data URI into mime type and content."""
|
| 25 |
+
# Format: data:mime/type;base64,content
|
| 26 |
+
match = re.match(r"data:([^;]+);base64,(.+)", data_uri)
|
| 27 |
+
if not match:
|
| 28 |
+
raise ValueError(f"Invalid data URI format: {data_uri[:50]}...")
|
| 29 |
+
|
| 30 |
+
mime_type = match.group(1)
|
| 31 |
+
encoded_content = match.group(2)
|
| 32 |
+
content = base64.b64decode(encoded_content)
|
| 33 |
+
|
| 34 |
+
return mime_type, content
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def encode_data_uri(mime_type: str, content: bytes) -> str:
|
| 38 |
+
"""Encode content as a data URI."""
|
| 39 |
+
encoded = base64.b64encode(content).decode("utf-8")
|
| 40 |
+
return f"data:{mime_type};base64,{encoded}"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def sanitize_repo_name(name: str) -> str:
|
| 44 |
+
"""Sanitize a string for use as a GitHub repository name."""
|
| 45 |
+
# Replace spaces and special chars with hyphens
|
| 46 |
+
sanitized = re.sub(r"[^a-zA-Z0-9-_.]", "-", name)
|
| 47 |
+
# Remove leading/trailing hyphens
|
| 48 |
+
sanitized = sanitized.strip("-")
|
| 49 |
+
# Collapse multiple hyphens
|
| 50 |
+
sanitized = re.sub(r"-+", "-", sanitized)
|
| 51 |
+
# Ensure lowercase
|
| 52 |
+
return sanitized.lower()
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def get_timestamp() -> datetime:
|
| 56 |
+
"""Get current UTC timestamp."""
|
| 57 |
+
return datetime.utcnow()
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Student-side modules for receiving tasks and deploying code."""
|
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Student API endpoint for receiving build/update requests."""
|
| 2 |
+
import asyncio
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from fastapi import FastAPI, HTTPException, BackgroundTasks
|
| 7 |
+
from fastapi.responses import JSONResponse
|
| 8 |
+
|
| 9 |
+
from shared.config import settings
|
| 10 |
+
from shared.logger import setup_logger
|
| 11 |
+
from shared.models import RepoSubmission, TaskRequest
|
| 12 |
+
from student.code_generator import CodeGenerator
|
| 13 |
+
from student.github_manager import GitHubManager
|
| 14 |
+
from student.notification_client import NotificationClient
|
| 15 |
+
|
| 16 |
+
logger = setup_logger(__name__)
|
| 17 |
+
|
| 18 |
+
app = FastAPI(
|
| 19 |
+
title="LLM Code Deployment - Student API",
|
| 20 |
+
description="API endpoint for receiving build and update requests",
|
| 21 |
+
version="1.0.0",
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
# Initialize services
|
| 25 |
+
code_generator = CodeGenerator()
|
| 26 |
+
github_manager = GitHubManager()
|
| 27 |
+
notification_client = NotificationClient()
|
| 28 |
+
|
| 29 |
+
# Track ongoing tasks
|
| 30 |
+
active_tasks: dict[str, dict] = {}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@app.post("/api/build")
|
| 34 |
+
async def build_app(request: TaskRequest, background_tasks: BackgroundTasks) -> JSONResponse:
|
| 35 |
+
"""Receive build request and process in background.
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
request: Task request with app brief and requirements
|
| 39 |
+
background_tasks: FastAPI background tasks
|
| 40 |
+
|
| 41 |
+
Returns:
|
| 42 |
+
HTTP 200 JSON response
|
| 43 |
+
"""
|
| 44 |
+
logger.info(f"Received build request for task {request.task}, round {request.round}")
|
| 45 |
+
|
| 46 |
+
# Verify secret
|
| 47 |
+
if request.secret != settings.student_secret:
|
| 48 |
+
logger.error(f"Invalid secret for task {request.task}")
|
| 49 |
+
raise HTTPException(status_code=401, detail="Invalid secret")
|
| 50 |
+
|
| 51 |
+
# Verify email
|
| 52 |
+
if request.email != settings.student_email:
|
| 53 |
+
logger.error(f"Email mismatch: expected {settings.student_email}, got {request.email}")
|
| 54 |
+
raise HTTPException(status_code=401, detail="Email mismatch")
|
| 55 |
+
|
| 56 |
+
# Check if task is already in progress
|
| 57 |
+
task_key = f"{request.task}-{request.round}"
|
| 58 |
+
if task_key in active_tasks:
|
| 59 |
+
logger.warning(f"Task {task_key} already in progress")
|
| 60 |
+
return JSONResponse(
|
| 61 |
+
status_code=200,
|
| 62 |
+
content={
|
| 63 |
+
"status": "in_progress",
|
| 64 |
+
"message": f"Task {task_key} is already being processed",
|
| 65 |
+
},
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
# Mark task as active
|
| 69 |
+
active_tasks[task_key] = {
|
| 70 |
+
"status": "accepted",
|
| 71 |
+
"started_at": datetime.utcnow().isoformat(),
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
# Process in background
|
| 75 |
+
if request.round == 1:
|
| 76 |
+
background_tasks.add_task(process_build_request, request)
|
| 77 |
+
else:
|
| 78 |
+
background_tasks.add_task(process_update_request, request)
|
| 79 |
+
|
| 80 |
+
logger.info(f"Accepted task {task_key} for background processing")
|
| 81 |
+
|
| 82 |
+
return JSONResponse(
|
| 83 |
+
status_code=200,
|
| 84 |
+
content={
|
| 85 |
+
"status": "accepted",
|
| 86 |
+
"message": f"Task {task_key} accepted for processing",
|
| 87 |
+
"task": request.task,
|
| 88 |
+
"round": request.round,
|
| 89 |
+
},
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
async def process_build_request(request: TaskRequest) -> None:
|
| 94 |
+
"""Process build request in background.
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
request: Task request
|
| 98 |
+
"""
|
| 99 |
+
task_key = f"{request.task}-{request.round}"
|
| 100 |
+
|
| 101 |
+
try:
|
| 102 |
+
logger.info(f"Processing build request for {task_key}")
|
| 103 |
+
|
| 104 |
+
# Create output directory
|
| 105 |
+
output_dir = settings.generated_repos_dir / request.task
|
| 106 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 107 |
+
|
| 108 |
+
# Generate code
|
| 109 |
+
logger.info(f"Generating code for {task_key}")
|
| 110 |
+
active_tasks[task_key]["status"] = "generating"
|
| 111 |
+
code_generator.generate_app(request, output_dir)
|
| 112 |
+
|
| 113 |
+
# Create repo and deploy
|
| 114 |
+
logger.info(f"Deploying to GitHub for {task_key}")
|
| 115 |
+
active_tasks[task_key]["status"] = "deploying"
|
| 116 |
+
repo_url, commit_sha, pages_url = github_manager.create_and_deploy(
|
| 117 |
+
request.task, output_dir
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
# Prepare submission
|
| 121 |
+
submission = RepoSubmission(
|
| 122 |
+
email=request.email,
|
| 123 |
+
task=request.task,
|
| 124 |
+
round=request.round,
|
| 125 |
+
nonce=request.nonce,
|
| 126 |
+
repo_url=repo_url,
|
| 127 |
+
commit_sha=commit_sha,
|
| 128 |
+
pages_url=pages_url,
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
# Notify evaluation endpoint
|
| 132 |
+
logger.info(f"Notifying evaluation endpoint for {task_key}")
|
| 133 |
+
active_tasks[task_key]["status"] = "notifying"
|
| 134 |
+
success = await notification_client.notify_with_timeout(
|
| 135 |
+
request.evaluation_url,
|
| 136 |
+
submission,
|
| 137 |
+
timeout_minutes=settings.task_timeout_minutes,
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
if success:
|
| 141 |
+
logger.info(f"Successfully completed task {task_key}")
|
| 142 |
+
active_tasks[task_key]["status"] = "completed"
|
| 143 |
+
active_tasks[task_key]["completed_at"] = datetime.utcnow().isoformat()
|
| 144 |
+
else:
|
| 145 |
+
logger.error(f"Failed to notify evaluation endpoint for {task_key}")
|
| 146 |
+
active_tasks[task_key]["status"] = "notification_failed"
|
| 147 |
+
|
| 148 |
+
except Exception as e:
|
| 149 |
+
logger.error(f"Error processing build request {task_key}: {e}", exc_info=True)
|
| 150 |
+
active_tasks[task_key]["status"] = "failed"
|
| 151 |
+
active_tasks[task_key]["error"] = str(e)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
async def process_update_request(request: TaskRequest) -> None:
|
| 155 |
+
"""Process update request in background.
|
| 156 |
+
|
| 157 |
+
Args:
|
| 158 |
+
request: Task request
|
| 159 |
+
"""
|
| 160 |
+
task_key = f"{request.task}-{request.round}"
|
| 161 |
+
|
| 162 |
+
try:
|
| 163 |
+
logger.info(f"Processing update request for {task_key}")
|
| 164 |
+
|
| 165 |
+
# Use existing output directory
|
| 166 |
+
output_dir = settings.generated_repos_dir / request.task
|
| 167 |
+
|
| 168 |
+
if not output_dir.exists():
|
| 169 |
+
# If directory doesn't exist, treat as new build
|
| 170 |
+
logger.warning(f"No existing code for {request.task}, creating new")
|
| 171 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 172 |
+
code_generator.generate_app(request, output_dir)
|
| 173 |
+
else:
|
| 174 |
+
# Generate updated code
|
| 175 |
+
logger.info(f"Updating code for {task_key}")
|
| 176 |
+
active_tasks[task_key]["status"] = "generating"
|
| 177 |
+
code_generator.generate_app(request, output_dir)
|
| 178 |
+
|
| 179 |
+
# Update repo and redeploy
|
| 180 |
+
logger.info(f"Redeploying to GitHub for {task_key}")
|
| 181 |
+
active_tasks[task_key]["status"] = "deploying"
|
| 182 |
+
|
| 183 |
+
from shared.utils import sanitize_repo_name
|
| 184 |
+
|
| 185 |
+
repo_name = sanitize_repo_name(request.task)
|
| 186 |
+
|
| 187 |
+
try:
|
| 188 |
+
repo_url, commit_sha = github_manager.update_and_redeploy(repo_name, output_dir)
|
| 189 |
+
pages_url = f"https://{settings.github_username}.github.io/{repo_name}/"
|
| 190 |
+
except Exception as e:
|
| 191 |
+
logger.warning(f"Update failed, creating new repo: {e}")
|
| 192 |
+
repo_url, commit_sha, pages_url = github_manager.create_and_deploy(
|
| 193 |
+
request.task, output_dir
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
# Prepare submission
|
| 197 |
+
submission = RepoSubmission(
|
| 198 |
+
email=request.email,
|
| 199 |
+
task=request.task,
|
| 200 |
+
round=request.round,
|
| 201 |
+
nonce=request.nonce,
|
| 202 |
+
repo_url=repo_url,
|
| 203 |
+
commit_sha=commit_sha,
|
| 204 |
+
pages_url=pages_url,
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
# Notify evaluation endpoint
|
| 208 |
+
logger.info(f"Notifying evaluation endpoint for {task_key}")
|
| 209 |
+
active_tasks[task_key]["status"] = "notifying"
|
| 210 |
+
success = await notification_client.notify_with_timeout(
|
| 211 |
+
request.evaluation_url,
|
| 212 |
+
submission,
|
| 213 |
+
timeout_minutes=settings.task_timeout_minutes,
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
if success:
|
| 217 |
+
logger.info(f"Successfully completed task {task_key}")
|
| 218 |
+
active_tasks[task_key]["status"] = "completed"
|
| 219 |
+
active_tasks[task_key]["completed_at"] = datetime.utcnow().isoformat()
|
| 220 |
+
else:
|
| 221 |
+
logger.error(f"Failed to notify evaluation endpoint for {task_key}")
|
| 222 |
+
active_tasks[task_key]["status"] = "notification_failed"
|
| 223 |
+
|
| 224 |
+
except Exception as e:
|
| 225 |
+
logger.error(f"Error processing update request {task_key}: {e}", exc_info=True)
|
| 226 |
+
active_tasks[task_key]["status"] = "failed"
|
| 227 |
+
active_tasks[task_key]["error"] = str(e)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
@app.get("/api/status/{task_id}")
|
| 231 |
+
async def get_task_status(task_id: str) -> JSONResponse:
|
| 232 |
+
"""Get status of a task.
|
| 233 |
+
|
| 234 |
+
Args:
|
| 235 |
+
task_id: Task identifier
|
| 236 |
+
|
| 237 |
+
Returns:
|
| 238 |
+
Task status information
|
| 239 |
+
"""
|
| 240 |
+
matching_tasks = {k: v for k, v in active_tasks.items() if k.startswith(task_id)}
|
| 241 |
+
|
| 242 |
+
if not matching_tasks:
|
| 243 |
+
raise HTTPException(status_code=404, detail="Task not found")
|
| 244 |
+
|
| 245 |
+
return JSONResponse(content=matching_tasks)
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
@app.get("/health")
|
| 249 |
+
async def health_check() -> JSONResponse:
|
| 250 |
+
"""Health check endpoint.
|
| 251 |
+
|
| 252 |
+
Returns:
|
| 253 |
+
Health status
|
| 254 |
+
"""
|
| 255 |
+
return JSONResponse(
|
| 256 |
+
content={
|
| 257 |
+
"status": "healthy",
|
| 258 |
+
"active_tasks": len(active_tasks),
|
| 259 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 260 |
+
}
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
if __name__ == "__main__":
|
| 265 |
+
import uvicorn
|
| 266 |
+
|
| 267 |
+
# Ensure directories exist
|
| 268 |
+
settings.ensure_directories()
|
| 269 |
+
|
| 270 |
+
logger.info(f"Starting Student API on port {settings.student_api_port}")
|
| 271 |
+
uvicorn.run(
|
| 272 |
+
"student.api:app",
|
| 273 |
+
host="0.0.0.0",
|
| 274 |
+
port=settings.student_api_port,
|
| 275 |
+
reload=True,
|
| 276 |
+
)
|
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LLM-based code generator for creating web applications."""
|
| 2 |
+
import json
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
import anthropic
|
| 7 |
+
import openai
|
| 8 |
+
|
| 9 |
+
from shared.config import settings
|
| 10 |
+
from shared.logger import setup_logger
|
| 11 |
+
from shared.models import Attachment, TaskRequest
|
| 12 |
+
from shared.utils import decode_data_uri
|
| 13 |
+
|
| 14 |
+
logger = setup_logger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class CodeGenerator:
|
| 18 |
+
"""Generate code using LLM based on task requirements."""
|
| 19 |
+
|
| 20 |
+
def __init__(self) -> None:
|
| 21 |
+
"""Initialize code generator with LLM client."""
|
| 22 |
+
self.provider = settings.llm_provider
|
| 23 |
+
self.model = settings.llm_model
|
| 24 |
+
|
| 25 |
+
if self.provider == "anthropic":
|
| 26 |
+
self.client = anthropic.Anthropic(api_key=settings.anthropic_api_key)
|
| 27 |
+
elif self.provider == "openai":
|
| 28 |
+
self.client = openai.OpenAI(api_key=settings.openai_api_key)
|
| 29 |
+
elif self.provider == "aipipe":
|
| 30 |
+
# Use OpenAI client with AIPipe endpoints
|
| 31 |
+
self.client = openai.OpenAI(
|
| 32 |
+
api_key=settings.aipipe_token,
|
| 33 |
+
base_url=settings.aipipe_base_url
|
| 34 |
+
)
|
| 35 |
+
else:
|
| 36 |
+
raise ValueError(f"Unsupported LLM provider: {self.provider}")
|
| 37 |
+
|
| 38 |
+
logger.info(f"Initialized CodeGenerator with {self.provider}/{self.model}")
|
| 39 |
+
|
| 40 |
+
def generate_app(self, task: TaskRequest, output_dir: Path) -> dict[str, str]:
|
| 41 |
+
"""Generate application code based on task requirements.
|
| 42 |
+
|
| 43 |
+
Args:
|
| 44 |
+
task: Task request containing brief and requirements
|
| 45 |
+
output_dir: Directory to save generated files
|
| 46 |
+
|
| 47 |
+
Returns:
|
| 48 |
+
Dictionary mapping filenames to their content
|
| 49 |
+
"""
|
| 50 |
+
logger.info(f"Generating app for task {task.task}")
|
| 51 |
+
|
| 52 |
+
# Prepare context with attachments
|
| 53 |
+
attachment_info = self._prepare_attachments(task.attachments, output_dir)
|
| 54 |
+
|
| 55 |
+
# Build prompt
|
| 56 |
+
prompt = self._build_generation_prompt(task, attachment_info)
|
| 57 |
+
|
| 58 |
+
# Generate code
|
| 59 |
+
generated_files = self._generate_with_llm(prompt)
|
| 60 |
+
|
| 61 |
+
# Save files
|
| 62 |
+
self._save_files(generated_files, output_dir)
|
| 63 |
+
|
| 64 |
+
logger.info(f"Generated {len(generated_files)} files for task {task.task}")
|
| 65 |
+
return generated_files
|
| 66 |
+
|
| 67 |
+
def _prepare_attachments(
|
| 68 |
+
self, attachments: list[Attachment], output_dir: Path
|
| 69 |
+
) -> list[dict[str, str]]:
|
| 70 |
+
"""Decode and save attachments, return metadata.
|
| 71 |
+
|
| 72 |
+
Args:
|
| 73 |
+
attachments: List of attachments with data URIs
|
| 74 |
+
output_dir: Directory to save attachments
|
| 75 |
+
|
| 76 |
+
Returns:
|
| 77 |
+
List of attachment metadata
|
| 78 |
+
"""
|
| 79 |
+
attachment_info = []
|
| 80 |
+
|
| 81 |
+
for att in attachments:
|
| 82 |
+
try:
|
| 83 |
+
mime_type, content = decode_data_uri(att.url)
|
| 84 |
+
file_path = output_dir / att.name
|
| 85 |
+
file_path.write_bytes(content)
|
| 86 |
+
|
| 87 |
+
attachment_info.append(
|
| 88 |
+
{
|
| 89 |
+
"name": att.name,
|
| 90 |
+
"mime_type": mime_type,
|
| 91 |
+
"size": len(content),
|
| 92 |
+
"preview": content[:200].decode("utf-8", errors="ignore")
|
| 93 |
+
if mime_type.startswith("text/")
|
| 94 |
+
else "[binary data]",
|
| 95 |
+
}
|
| 96 |
+
)
|
| 97 |
+
logger.debug(f"Saved attachment {att.name} ({mime_type}, {len(content)} bytes)")
|
| 98 |
+
except Exception as e:
|
| 99 |
+
logger.error(f"Failed to process attachment {att.name}: {e}")
|
| 100 |
+
attachment_info.append({"name": att.name, "error": str(e)})
|
| 101 |
+
|
| 102 |
+
return attachment_info
|
| 103 |
+
|
| 104 |
+
def _build_generation_prompt(
|
| 105 |
+
self, task: TaskRequest, attachment_info: list[dict[str, str]]
|
| 106 |
+
) -> str:
|
| 107 |
+
"""Build prompt for LLM code generation.
|
| 108 |
+
|
| 109 |
+
Args:
|
| 110 |
+
task: Task request
|
| 111 |
+
attachment_info: Attachment metadata
|
| 112 |
+
|
| 113 |
+
Returns:
|
| 114 |
+
Formatted prompt
|
| 115 |
+
"""
|
| 116 |
+
attachments_section = ""
|
| 117 |
+
if attachment_info:
|
| 118 |
+
attachments_section = "\n\n**Attachments:**\n" + "\n".join(
|
| 119 |
+
f"- {att['name']}: {att.get('mime_type', 'unknown')}"
|
| 120 |
+
for att in attachment_info
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
checks_section = "\n\n**Requirements (will be tested):**\n" + "\n".join(
|
| 124 |
+
f"- {check}" for check in task.checks
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
prompt = f"""You are an expert web developer. Create a complete, production-ready single-page web application based on the following requirements.
|
| 128 |
+
|
| 129 |
+
**Task:** {task.task}
|
| 130 |
+
|
| 131 |
+
**Brief:** {task.brief}{attachments_section}{checks_section}
|
| 132 |
+
|
| 133 |
+
**Instructions:**
|
| 134 |
+
1. Create a minimal, functional web application that meets ALL requirements
|
| 135 |
+
2. Use only vanilla HTML, CSS, and JavaScript (no build tools required)
|
| 136 |
+
3. Include all necessary CDN links for external libraries (Bootstrap, marked, highlight.js, etc.)
|
| 137 |
+
4. Ensure the app is self-contained in a single index.html file or minimal files
|
| 138 |
+
5. Follow best practices for code quality, accessibility, and user experience
|
| 139 |
+
6. Include helpful comments explaining key functionality
|
| 140 |
+
7. Make the UI clean and professional using Bootstrap 5 or similar
|
| 141 |
+
|
| 142 |
+
**Output Format:**
|
| 143 |
+
Provide the complete code for each file in JSON format:
|
| 144 |
+
```json
|
| 145 |
+
{{
|
| 146 |
+
"index.html": "<!DOCTYPE html>...",
|
| 147 |
+
"style.css": "/* optional styles */",
|
| 148 |
+
"script.js": "// optional separate JS",
|
| 149 |
+
"README.md": "# Project Title\\n\\n..."
|
| 150 |
+
}}
|
| 151 |
+
```
|
| 152 |
+
|
| 153 |
+
Generate ONLY the JSON output, no other text. Ensure all code is complete and functional.
|
| 154 |
+
"""
|
| 155 |
+
return prompt
|
| 156 |
+
|
| 157 |
+
def _generate_with_llm(self, prompt: str) -> dict[str, str]:
|
| 158 |
+
"""Call LLM API to generate code.
|
| 159 |
+
|
| 160 |
+
Args:
|
| 161 |
+
prompt: Generation prompt
|
| 162 |
+
|
| 163 |
+
Returns:
|
| 164 |
+
Dictionary of filename -> content
|
| 165 |
+
"""
|
| 166 |
+
logger.info(f"Calling {self.provider} API for code generation")
|
| 167 |
+
|
| 168 |
+
try:
|
| 169 |
+
if self.provider == "anthropic":
|
| 170 |
+
response = self.client.messages.create(
|
| 171 |
+
model=self.model,
|
| 172 |
+
max_tokens=4096,
|
| 173 |
+
temperature=0.3,
|
| 174 |
+
messages=[{"role": "user", "content": prompt}],
|
| 175 |
+
)
|
| 176 |
+
content = response.content[0].text
|
| 177 |
+
|
| 178 |
+
elif self.provider in ["openai", "aipipe"]:
|
| 179 |
+
# Both OpenAI and AIPipe use the same API format
|
| 180 |
+
response = self.client.chat.completions.create(
|
| 181 |
+
model=self.model,
|
| 182 |
+
messages=[{"role": "user", "content": prompt}],
|
| 183 |
+
temperature=0.3,
|
| 184 |
+
max_tokens=4096,
|
| 185 |
+
)
|
| 186 |
+
content = response.choices[0].message.content
|
| 187 |
+
|
| 188 |
+
else:
|
| 189 |
+
raise ValueError(f"Unsupported provider: {self.provider}")
|
| 190 |
+
|
| 191 |
+
# Extract JSON from response
|
| 192 |
+
files = self._extract_json(content)
|
| 193 |
+
return files
|
| 194 |
+
|
| 195 |
+
except Exception as e:
|
| 196 |
+
logger.error(f"LLM generation failed: {e}")
|
| 197 |
+
# Fallback to minimal template
|
| 198 |
+
return self._get_fallback_template()
|
| 199 |
+
|
| 200 |
+
def _extract_json(self, content: str) -> dict[str, str]:
|
| 201 |
+
"""Extract JSON from LLM response.
|
| 202 |
+
|
| 203 |
+
Args:
|
| 204 |
+
content: LLM response text
|
| 205 |
+
|
| 206 |
+
Returns:
|
| 207 |
+
Parsed JSON dictionary
|
| 208 |
+
"""
|
| 209 |
+
# Try to find JSON in markdown code block
|
| 210 |
+
import re
|
| 211 |
+
|
| 212 |
+
json_match = re.search(r"```json\s*(\{.*?\})\s*```", content, re.DOTALL)
|
| 213 |
+
if json_match:
|
| 214 |
+
return json.loads(json_match.group(1))
|
| 215 |
+
|
| 216 |
+
# Try to parse the whole content as JSON
|
| 217 |
+
try:
|
| 218 |
+
return json.loads(content)
|
| 219 |
+
except json.JSONDecodeError:
|
| 220 |
+
# Try to find any JSON object
|
| 221 |
+
json_match = re.search(r"\{.*\}", content, re.DOTALL)
|
| 222 |
+
if json_match:
|
| 223 |
+
return json.loads(json_match.group(0))
|
| 224 |
+
|
| 225 |
+
raise ValueError("Could not extract JSON from LLM response")
|
| 226 |
+
|
| 227 |
+
def _get_fallback_template(self) -> dict[str, str]:
|
| 228 |
+
"""Get fallback template when LLM generation fails.
|
| 229 |
+
|
| 230 |
+
Returns:
|
| 231 |
+
Basic HTML template
|
| 232 |
+
"""
|
| 233 |
+
return {
|
| 234 |
+
"index.html": """<!DOCTYPE html>
|
| 235 |
+
<html lang="en">
|
| 236 |
+
<head>
|
| 237 |
+
<meta charset="UTF-8">
|
| 238 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 239 |
+
<title>Generated App</title>
|
| 240 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 241 |
+
</head>
|
| 242 |
+
<body>
|
| 243 |
+
<div class="container mt-5">
|
| 244 |
+
<h1>Application</h1>
|
| 245 |
+
<p>This is a minimal fallback template.</p>
|
| 246 |
+
</div>
|
| 247 |
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
| 248 |
+
</body>
|
| 249 |
+
</html>""",
|
| 250 |
+
"README.md": """# Generated Application
|
| 251 |
+
|
| 252 |
+
This is an automatically generated web application.
|
| 253 |
+
|
| 254 |
+
## Setup
|
| 255 |
+
|
| 256 |
+
Simply open `index.html` in a web browser.
|
| 257 |
+
|
| 258 |
+
## License
|
| 259 |
+
|
| 260 |
+
MIT License
|
| 261 |
+
""",
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
def _save_files(self, files: dict[str, str], output_dir: Path) -> None:
|
| 265 |
+
"""Save generated files to output directory.
|
| 266 |
+
|
| 267 |
+
Args:
|
| 268 |
+
files: Dictionary of filename -> content
|
| 269 |
+
output_dir: Directory to save files
|
| 270 |
+
"""
|
| 271 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 272 |
+
|
| 273 |
+
for filename, content in files.items():
|
| 274 |
+
file_path = output_dir / filename
|
| 275 |
+
file_path.write_text(content, encoding="utf-8")
|
| 276 |
+
logger.debug(f"Saved {filename} ({len(content)} bytes)")
|
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GitHub repository management and Pages deployment."""
|
| 2 |
+
import shutil
|
| 3 |
+
import time
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import git
|
| 7 |
+
from github import Github, GithubException
|
| 8 |
+
|
| 9 |
+
from shared.config import settings
|
| 10 |
+
from shared.logger import setup_logger
|
| 11 |
+
from shared.utils import sanitize_repo_name
|
| 12 |
+
|
| 13 |
+
logger = setup_logger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class GitHubManager:
|
| 17 |
+
"""Manage GitHub repository creation, pushing, and Pages deployment."""
|
| 18 |
+
|
| 19 |
+
def __init__(self) -> None:
|
| 20 |
+
"""Initialize GitHub manager."""
|
| 21 |
+
self.github = Github(settings.github_token)
|
| 22 |
+
self.user = self.github.get_user()
|
| 23 |
+
logger.info(f"Initialized GitHubManager for user {self.user.login}")
|
| 24 |
+
|
| 25 |
+
def create_and_deploy(
|
| 26 |
+
self, task_id: str, code_dir: Path
|
| 27 |
+
) -> tuple[str, str, str]:
|
| 28 |
+
"""Create repo, push code, enable Pages, and return URLs.
|
| 29 |
+
|
| 30 |
+
Args:
|
| 31 |
+
task_id: Unique task identifier
|
| 32 |
+
code_dir: Directory containing generated code
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
Tuple of (repo_url, commit_sha, pages_url)
|
| 36 |
+
"""
|
| 37 |
+
repo_name = sanitize_repo_name(task_id)
|
| 38 |
+
logger.info(f"Creating and deploying repository: {repo_name}")
|
| 39 |
+
|
| 40 |
+
# Create GitHub repository
|
| 41 |
+
repo_url = self._create_repo(repo_name)
|
| 42 |
+
|
| 43 |
+
# Initialize git and push
|
| 44 |
+
commit_sha = self._push_code(repo_name, code_dir)
|
| 45 |
+
|
| 46 |
+
# Enable GitHub Pages
|
| 47 |
+
pages_url = self._enable_pages(repo_name)
|
| 48 |
+
|
| 49 |
+
# Wait for Pages to be ready
|
| 50 |
+
self._wait_for_pages(pages_url)
|
| 51 |
+
|
| 52 |
+
logger.info(
|
| 53 |
+
f"Successfully deployed {repo_name}: "
|
| 54 |
+
f"repo={repo_url}, commit={commit_sha}, pages={pages_url}"
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
return repo_url, commit_sha, pages_url
|
| 58 |
+
|
| 59 |
+
def update_and_redeploy(
|
| 60 |
+
self, repo_name: str, code_dir: Path
|
| 61 |
+
) -> tuple[str, str]:
|
| 62 |
+
"""Update existing repo with new code and return new commit.
|
| 63 |
+
|
| 64 |
+
Args:
|
| 65 |
+
repo_name: Repository name
|
| 66 |
+
code_dir: Directory containing updated code
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
Tuple of (repo_url, commit_sha)
|
| 70 |
+
"""
|
| 71 |
+
logger.info(f"Updating repository: {repo_name}")
|
| 72 |
+
|
| 73 |
+
try:
|
| 74 |
+
repo = self.user.get_repo(repo_name)
|
| 75 |
+
repo_url = repo.html_url
|
| 76 |
+
|
| 77 |
+
# Clone, update, and push
|
| 78 |
+
commit_sha = self._update_code(repo_name, code_dir)
|
| 79 |
+
|
| 80 |
+
logger.info(f"Successfully updated {repo_name}: commit={commit_sha}")
|
| 81 |
+
return repo_url, commit_sha
|
| 82 |
+
|
| 83 |
+
except GithubException as e:
|
| 84 |
+
logger.error(f"Failed to update repo {repo_name}: {e}")
|
| 85 |
+
raise
|
| 86 |
+
|
| 87 |
+
def _create_repo(self, repo_name: str) -> str:
|
| 88 |
+
"""Create a new GitHub repository.
|
| 89 |
+
|
| 90 |
+
Args:
|
| 91 |
+
repo_name: Repository name
|
| 92 |
+
|
| 93 |
+
Returns:
|
| 94 |
+
Repository URL
|
| 95 |
+
"""
|
| 96 |
+
try:
|
| 97 |
+
# Check if repo already exists
|
| 98 |
+
try:
|
| 99 |
+
existing_repo = self.user.get_repo(repo_name)
|
| 100 |
+
logger.warning(f"Repository {repo_name} already exists, using it")
|
| 101 |
+
return existing_repo.html_url
|
| 102 |
+
except GithubException:
|
| 103 |
+
pass # Repo doesn't exist, create it
|
| 104 |
+
|
| 105 |
+
# Create new repo
|
| 106 |
+
repo = self.user.create_repo(
|
| 107 |
+
name=repo_name,
|
| 108 |
+
description=f"Auto-generated repository for task {repo_name}",
|
| 109 |
+
private=False, # Must be public for Pages
|
| 110 |
+
auto_init=False,
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
logger.info(f"Created repository: {repo.html_url}")
|
| 114 |
+
return repo.html_url
|
| 115 |
+
|
| 116 |
+
except GithubException as e:
|
| 117 |
+
logger.error(f"Failed to create repository {repo_name}: {e}")
|
| 118 |
+
raise
|
| 119 |
+
|
| 120 |
+
def _push_code(self, repo_name: str, code_dir: Path) -> str:
|
| 121 |
+
"""Initialize git repo and push code.
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
repo_name: Repository name
|
| 125 |
+
code_dir: Directory containing code
|
| 126 |
+
|
| 127 |
+
Returns:
|
| 128 |
+
Commit SHA
|
| 129 |
+
"""
|
| 130 |
+
try:
|
| 131 |
+
# Ensure LICENSE file exists
|
| 132 |
+
self._ensure_license(code_dir)
|
| 133 |
+
|
| 134 |
+
# Initialize git repo
|
| 135 |
+
repo = git.Repo.init(code_dir)
|
| 136 |
+
|
| 137 |
+
# Configure git
|
| 138 |
+
with repo.config_writer() as config:
|
| 139 |
+
if not config.has_option("user", "name"):
|
| 140 |
+
config.set_value("user", "name", settings.github_username)
|
| 141 |
+
if not config.has_option("user", "email"):
|
| 142 |
+
config.set_value("user", "email", settings.student_email)
|
| 143 |
+
|
| 144 |
+
# Add all files
|
| 145 |
+
repo.index.add("*")
|
| 146 |
+
|
| 147 |
+
# Create .gitignore if needed
|
| 148 |
+
gitignore_path = code_dir / ".gitignore"
|
| 149 |
+
if not gitignore_path.exists():
|
| 150 |
+
gitignore_path.write_text("*.pyc\n__pycache__/\n.DS_Store\n")
|
| 151 |
+
repo.index.add([".gitignore"])
|
| 152 |
+
|
| 153 |
+
# Commit
|
| 154 |
+
commit = repo.index.commit("Initial commit: Generated application")
|
| 155 |
+
commit_sha = commit.hexsha
|
| 156 |
+
|
| 157 |
+
# Add remote
|
| 158 |
+
remote_url = f"https://{settings.github_token}@github.com/{settings.github_username}/{repo_name}.git"
|
| 159 |
+
try:
|
| 160 |
+
origin = repo.remote("origin")
|
| 161 |
+
origin.set_url(remote_url)
|
| 162 |
+
except ValueError:
|
| 163 |
+
origin = repo.create_remote("origin", remote_url)
|
| 164 |
+
|
| 165 |
+
# Push
|
| 166 |
+
origin.push(refspec="main:main", force=True)
|
| 167 |
+
|
| 168 |
+
logger.info(f"Pushed code to {repo_name}: commit {commit_sha}")
|
| 169 |
+
return commit_sha
|
| 170 |
+
|
| 171 |
+
except Exception as e:
|
| 172 |
+
logger.error(f"Failed to push code to {repo_name}: {e}")
|
| 173 |
+
raise
|
| 174 |
+
|
| 175 |
+
def _update_code(self, repo_name: str, code_dir: Path) -> str:
|
| 176 |
+
"""Update existing repository with new code.
|
| 177 |
+
|
| 178 |
+
Args:
|
| 179 |
+
repo_name: Repository name
|
| 180 |
+
code_dir: Directory containing updated code
|
| 181 |
+
|
| 182 |
+
Returns:
|
| 183 |
+
New commit SHA
|
| 184 |
+
"""
|
| 185 |
+
try:
|
| 186 |
+
# Clone the repo
|
| 187 |
+
temp_dir = settings.generated_repos_dir / f"{repo_name}_temp"
|
| 188 |
+
if temp_dir.exists():
|
| 189 |
+
shutil.rmtree(temp_dir)
|
| 190 |
+
|
| 191 |
+
clone_url = f"https://{settings.github_token}@github.com/{settings.github_username}/{repo_name}.git"
|
| 192 |
+
repo = git.Repo.clone_from(clone_url, temp_dir)
|
| 193 |
+
|
| 194 |
+
# Copy new files (except .git)
|
| 195 |
+
for item in code_dir.iterdir():
|
| 196 |
+
if item.name == ".git":
|
| 197 |
+
continue
|
| 198 |
+
|
| 199 |
+
dest = temp_dir / item.name
|
| 200 |
+
if item.is_file():
|
| 201 |
+
shutil.copy2(item, dest)
|
| 202 |
+
elif item.is_dir():
|
| 203 |
+
if dest.exists():
|
| 204 |
+
shutil.rmtree(dest)
|
| 205 |
+
shutil.copytree(item, dest)
|
| 206 |
+
|
| 207 |
+
# Ensure LICENSE
|
| 208 |
+
self._ensure_license(temp_dir)
|
| 209 |
+
|
| 210 |
+
# Commit and push
|
| 211 |
+
repo.index.add("*")
|
| 212 |
+
commit = repo.index.commit("Update: Revised application")
|
| 213 |
+
commit_sha = commit.hexsha
|
| 214 |
+
|
| 215 |
+
origin = repo.remote("origin")
|
| 216 |
+
origin.push()
|
| 217 |
+
|
| 218 |
+
# Cleanup
|
| 219 |
+
shutil.rmtree(temp_dir)
|
| 220 |
+
|
| 221 |
+
logger.info(f"Updated code in {repo_name}: commit {commit_sha}")
|
| 222 |
+
return commit_sha
|
| 223 |
+
|
| 224 |
+
except Exception as e:
|
| 225 |
+
logger.error(f"Failed to update code in {repo_name}: {e}")
|
| 226 |
+
raise
|
| 227 |
+
|
| 228 |
+
def _ensure_license(self, code_dir: Path) -> None:
|
| 229 |
+
"""Ensure MIT LICENSE file exists in code directory.
|
| 230 |
+
|
| 231 |
+
Args:
|
| 232 |
+
code_dir: Directory to add license to
|
| 233 |
+
"""
|
| 234 |
+
license_path = code_dir / "LICENSE"
|
| 235 |
+
if not license_path.exists():
|
| 236 |
+
year = time.strftime("%Y")
|
| 237 |
+
license_text = f"""MIT License
|
| 238 |
+
|
| 239 |
+
Copyright (c) {year} {settings.github_username}
|
| 240 |
+
|
| 241 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 242 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 243 |
+
in the Software without restriction, including without limitation the rights
|
| 244 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 245 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 246 |
+
furnished to do so, subject to the following conditions:
|
| 247 |
+
|
| 248 |
+
The above copyright notice and this permission notice shall be included in all
|
| 249 |
+
copies or substantial portions of the Software.
|
| 250 |
+
|
| 251 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 252 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 253 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 254 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 255 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 256 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 257 |
+
SOFTWARE.
|
| 258 |
+
"""
|
| 259 |
+
license_path.write_text(license_text)
|
| 260 |
+
logger.debug(f"Created MIT LICENSE in {code_dir}")
|
| 261 |
+
|
| 262 |
+
def _enable_pages(self, repo_name: str) -> str:
|
| 263 |
+
"""Enable GitHub Pages for repository.
|
| 264 |
+
|
| 265 |
+
Args:
|
| 266 |
+
repo_name: Repository name
|
| 267 |
+
|
| 268 |
+
Returns:
|
| 269 |
+
Pages URL
|
| 270 |
+
"""
|
| 271 |
+
try:
|
| 272 |
+
repo = self.user.get_repo(repo_name)
|
| 273 |
+
|
| 274 |
+
# Enable Pages on main branch
|
| 275 |
+
try:
|
| 276 |
+
repo.create_pages_site(source={"branch": "main", "path": "/"})
|
| 277 |
+
logger.info(f"Enabled GitHub Pages for {repo_name}")
|
| 278 |
+
except GithubException as e:
|
| 279 |
+
if "already enabled" in str(e).lower():
|
| 280 |
+
logger.info(f"GitHub Pages already enabled for {repo_name}")
|
| 281 |
+
else:
|
| 282 |
+
raise
|
| 283 |
+
|
| 284 |
+
# Construct Pages URL
|
| 285 |
+
pages_url = f"https://{settings.github_username}.github.io/{repo_name}/"
|
| 286 |
+
return pages_url
|
| 287 |
+
|
| 288 |
+
except GithubException as e:
|
| 289 |
+
logger.error(f"Failed to enable Pages for {repo_name}: {e}")
|
| 290 |
+
# Return expected URL even if enabling fails
|
| 291 |
+
return f"https://{settings.github_username}.github.io/{repo_name}/"
|
| 292 |
+
|
| 293 |
+
def _wait_for_pages(self, pages_url: str, max_wait: int = 60) -> bool:
|
| 294 |
+
"""Wait for GitHub Pages to be ready.
|
| 295 |
+
|
| 296 |
+
Args:
|
| 297 |
+
pages_url: Pages URL to check
|
| 298 |
+
max_wait: Maximum wait time in seconds
|
| 299 |
+
|
| 300 |
+
Returns:
|
| 301 |
+
True if ready, False if timeout
|
| 302 |
+
"""
|
| 303 |
+
import requests
|
| 304 |
+
|
| 305 |
+
logger.info(f"Waiting for Pages to be ready: {pages_url}")
|
| 306 |
+
|
| 307 |
+
for i in range(max_wait):
|
| 308 |
+
try:
|
| 309 |
+
response = requests.get(pages_url, timeout=5)
|
| 310 |
+
if response.status_code == 200:
|
| 311 |
+
logger.info(f"Pages ready after {i+1} seconds")
|
| 312 |
+
return True
|
| 313 |
+
except requests.RequestException:
|
| 314 |
+
pass
|
| 315 |
+
|
| 316 |
+
time.sleep(1)
|
| 317 |
+
|
| 318 |
+
logger.warning(f"Pages not ready after {max_wait} seconds")
|
| 319 |
+
return False
|
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Client for sending notifications to evaluation URL."""
|
| 2 |
+
import asyncio
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
import httpx
|
| 6 |
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 7 |
+
|
| 8 |
+
from shared.config import settings
|
| 9 |
+
from shared.logger import setup_logger
|
| 10 |
+
from shared.models import RepoSubmission
|
| 11 |
+
|
| 12 |
+
logger = setup_logger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class NotificationClient:
|
| 16 |
+
"""Client for notifying evaluation endpoint about repo submissions."""
|
| 17 |
+
|
| 18 |
+
def __init__(self) -> None:
|
| 19 |
+
"""Initialize notification client."""
|
| 20 |
+
self.retry_delays = settings.get_retry_delays()
|
| 21 |
+
self.max_retries = settings.max_retry_attempts
|
| 22 |
+
|
| 23 |
+
@retry(
|
| 24 |
+
stop=stop_after_attempt(4),
|
| 25 |
+
wait=wait_exponential(multiplier=1, min=1, max=8),
|
| 26 |
+
reraise=True,
|
| 27 |
+
)
|
| 28 |
+
async def notify(self, evaluation_url: str, submission: RepoSubmission) -> bool:
|
| 29 |
+
"""Send repo submission to evaluation URL with retry logic.
|
| 30 |
+
|
| 31 |
+
Args:
|
| 32 |
+
evaluation_url: URL to send notification to
|
| 33 |
+
submission: Repository submission data
|
| 34 |
+
|
| 35 |
+
Returns:
|
| 36 |
+
True if successful, False otherwise
|
| 37 |
+
"""
|
| 38 |
+
logger.info(f"Sending notification to {evaluation_url}")
|
| 39 |
+
logger.debug(f"Submission data: {submission.model_dump()}")
|
| 40 |
+
|
| 41 |
+
try:
|
| 42 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 43 |
+
response = await client.post(
|
| 44 |
+
evaluation_url,
|
| 45 |
+
json=submission.model_dump(),
|
| 46 |
+
headers={"Content-Type": "application/json"},
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
if response.status_code == 200:
|
| 50 |
+
logger.info(f"Successfully notified {evaluation_url}")
|
| 51 |
+
return True
|
| 52 |
+
else:
|
| 53 |
+
logger.error(
|
| 54 |
+
f"Failed to notify {evaluation_url}: "
|
| 55 |
+
f"Status {response.status_code}, Response: {response.text}"
|
| 56 |
+
)
|
| 57 |
+
raise Exception(f"HTTP {response.status_code}: {response.text}")
|
| 58 |
+
|
| 59 |
+
except httpx.TimeoutException:
|
| 60 |
+
logger.error(f"Timeout while notifying {evaluation_url}")
|
| 61 |
+
raise
|
| 62 |
+
except Exception as e:
|
| 63 |
+
logger.error(f"Error notifying {evaluation_url}: {e}")
|
| 64 |
+
raise
|
| 65 |
+
|
| 66 |
+
async def notify_with_timeout(
|
| 67 |
+
self, evaluation_url: str, submission: RepoSubmission, timeout_minutes: int = 10
|
| 68 |
+
) -> bool:
|
| 69 |
+
"""Send notification with overall timeout.
|
| 70 |
+
|
| 71 |
+
Args:
|
| 72 |
+
evaluation_url: URL to send notification to
|
| 73 |
+
submission: Repository submission data
|
| 74 |
+
timeout_minutes: Overall timeout in minutes
|
| 75 |
+
|
| 76 |
+
Returns:
|
| 77 |
+
True if successful, False otherwise
|
| 78 |
+
"""
|
| 79 |
+
try:
|
| 80 |
+
return await asyncio.wait_for(
|
| 81 |
+
self.notify(evaluation_url, submission),
|
| 82 |
+
timeout=timeout_minutes * 60,
|
| 83 |
+
)
|
| 84 |
+
except asyncio.TimeoutError:
|
| 85 |
+
logger.error(f"Overall timeout of {timeout_minutes} minutes exceeded")
|
| 86 |
+
return False
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
timestamp,email,endpoint,secret
|
| 2 |
+
2025-01-15T10:00:00,student1@example.com,http://localhost:8000/api/build,secret1
|
| 3 |
+
2025-01-15T10:05:00,student2@example.com,http://localhost:8000/api/build,secret2
|
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
id: github-user-created
|
| 2 |
+
brief: Publish a Bootstrap page with form id="github-user-{{ seed[:8] }}" that fetches a GitHub username, optionally uses ?token=, and displays the account creation date in YYYY-MM-DD UTC inside #github-created-at.
|
| 3 |
+
checks:
|
| 4 |
+
- "Form with id 'github-user-{{ seed[:8] }}' exists"
|
| 5 |
+
- "Element #github-created-at displays date in YYYY-MM-DD format"
|
| 6 |
+
- "Code fetches from GitHub API (https://api.github.com/users/)"
|
| 7 |
+
- "Bootstrap CSS is loaded"
|
| 8 |
+
round2:
|
| 9 |
+
- brief: Show an aria-live alert #github-status that reports when a lookup starts, succeeds, or fails.
|
| 10 |
+
checks:
|
| 11 |
+
- "Element #github-status has aria-live='polite' attribute"
|
| 12 |
+
- "Code updates #github-status with status messages"
|
| 13 |
+
- "Status changes are properly announced"
|
| 14 |
+
|
| 15 |
+
- brief: Display the account age in whole years inside #github-account-age alongside the creation date.
|
| 16 |
+
checks:
|
| 17 |
+
- "Element #github-account-age displays number of years"
|
| 18 |
+
- "Text includes 'years' or 'year'"
|
| 19 |
+
- "Age calculation is accurate"
|
| 20 |
+
|
| 21 |
+
- brief: Cache the last successful lookup in localStorage under "github-user-{{ seed[:8] }}" and repopulate the form on load.
|
| 22 |
+
checks:
|
| 23 |
+
- "Code uses localStorage.setItem with 'github-user-{{ seed[:8] }}'"
|
| 24 |
+
- "Code uses localStorage.getItem with 'github-user-{{ seed[:8] }}'"
|
| 25 |
+
- "Form repopulates on page reload with cached data"
|
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
id: markdown-to-html
|
| 2 |
+
brief: Publish a static page that converts input.md from attachments to HTML with marked, renders it inside #markdown-output, and loads highlight.js for code blocks.
|
| 3 |
+
attachments:
|
| 4 |
+
- name: input.md
|
| 5 |
+
url: data:text/markdown;base64,placeholder
|
| 6 |
+
checks:
|
| 7 |
+
- "Script tag loading marked library exists"
|
| 8 |
+
- "Script tag loading highlight.js exists"
|
| 9 |
+
- "Element #markdown-output contains rendered HTML with headings"
|
| 10 |
+
- "Code blocks are properly highlighted"
|
| 11 |
+
round2:
|
| 12 |
+
- brief: Add tabs #markdown-tabs that switch between rendered HTML in #markdown-output and the original Markdown in #markdown-source while keeping content in sync.
|
| 13 |
+
checks:
|
| 14 |
+
- "Element #markdown-tabs has at least 2 buttons"
|
| 15 |
+
- "Element #markdown-source displays original markdown"
|
| 16 |
+
- "Tab switching works correctly"
|
| 17 |
+
|
| 18 |
+
- brief: Support loading Markdown from a ?url= parameter when present and fall back to the attachment otherwise, showing the active source in #markdown-source-label.
|
| 19 |
+
checks:
|
| 20 |
+
- "Element #markdown-source-label exists and shows source"
|
| 21 |
+
- "Code includes fetch() for loading external URLs"
|
| 22 |
+
- "Fallback to attachment works when no URL parameter"
|
| 23 |
+
|
| 24 |
+
- brief: Display a live word count badge #markdown-word-count that updates after every render and formats numbers with Intl.NumberFormat.
|
| 25 |
+
checks:
|
| 26 |
+
- "Element #markdown-word-count displays formatted number with comma"
|
| 27 |
+
- "Code uses Intl.NumberFormat for formatting"
|
| 28 |
+
- "Word count updates on content change"
|
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
id: sum-of-sales
|
| 2 |
+
brief: Publish a single-page site that fetches data.csv from attachments, sums its sales column, sets the title to "Sales Summary {{ seed }}", displays the total inside #total-sales, and loads Bootstrap 5 from jsdelivr.
|
| 3 |
+
attachments:
|
| 4 |
+
- name: data.csv
|
| 5 |
+
url: data:text/csv;base64,placeholder
|
| 6 |
+
checks:
|
| 7 |
+
- "Page title equals 'Sales Summary {{ seed }}'"
|
| 8 |
+
- "Bootstrap 5 CSS loaded from jsdelivr"
|
| 9 |
+
- "Element #total-sales exists and displays numeric value"
|
| 10 |
+
- "Total sales calculation is accurate"
|
| 11 |
+
round2:
|
| 12 |
+
- brief: Add a Bootstrap table #product-sales that lists each product with its total sales and keeps #total-sales accurate after render.
|
| 13 |
+
checks:
|
| 14 |
+
- "Table #product-sales exists with proper Bootstrap classes"
|
| 15 |
+
- "Table has at least one row in tbody"
|
| 16 |
+
- "Sum of product sales matches #total-sales value"
|
| 17 |
+
|
| 18 |
+
- brief: Introduce a currency select #currency-picker that converts the computed total using rates.json from attachments and mirrors the active currency inside #total-currency.
|
| 19 |
+
attachments:
|
| 20 |
+
- name: rates.json
|
| 21 |
+
url: data:application/json;base64,placeholder
|
| 22 |
+
checks:
|
| 23 |
+
- "Select #currency-picker exists with USD option"
|
| 24 |
+
- "Element #total-currency displays current currency"
|
| 25 |
+
- "Currency conversion works correctly"
|
| 26 |
+
|
| 27 |
+
- brief: Allow filtering by region via #region-filter, update #total-sales with the filtered sum, and set data-region on that element to the active choice.
|
| 28 |
+
checks:
|
| 29 |
+
- "Select #region-filter exists"
|
| 30 |
+
- "Element #total-sales has data-region attribute"
|
| 31 |
+
- "Filtering updates the total correctly"
|
|
The diff for this file is too large to render.
See raw diff
|
|
|