CheeksTheGeek Claude commited on
Commit
c5292d8
·
unverified ·
0 Parent(s):

Initial commit: LLM Code Deployment System

Browse files

This 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 ADDED
@@ -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
.gitignore ADDED
@@ -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
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.13
DEPLOYMENT.md ADDED
@@ -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! 🚀
Dockerfile ADDED
@@ -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"]
LICENSE ADDED
@@ -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.
QUICKSTART.md ADDED
@@ -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
README.md ADDED
@@ -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
README_SPACES.md ADDED
@@ -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
instructor/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Instructor-side modules for task generation and evaluation."""
instructor/api.py ADDED
@@ -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
+ )
instructor/checks/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Evaluation check modules."""
instructor/checks/dynamic_checks.py ADDED
@@ -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
+ }
instructor/checks/llm_checks.py ADDED
@@ -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
instructor/checks/static_checks.py ADDED
@@ -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"}
instructor/database.py ADDED
@@ -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()
instructor/evaluate.py ADDED
@@ -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())
instructor/round1.py ADDED
@@ -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())
instructor/round2.py ADDED
@@ -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())
instructor/task_templates.py ADDED
@@ -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
+ }
main.py ADDED
@@ -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()
pyproject.toml ADDED
@@ -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
requirements.txt ADDED
@@ -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
shared/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Shared modules for LLM Code Deployment system."""
shared/config.py ADDED
@@ -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()
shared/logger.py ADDED
@@ -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
shared/models.py ADDED
@@ -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
shared/utils.py ADDED
@@ -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()
student/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Student-side modules for receiving tasks and deploying code."""
student/api.py ADDED
@@ -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
+ )
student/code_generator.py ADDED
@@ -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)")
student/github_manager.py ADDED
@@ -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
student/notification_client.py ADDED
@@ -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
submissions.csv.example ADDED
@@ -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
templates/github-user-created.yaml ADDED
@@ -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"
templates/markdown-to-html.yaml ADDED
@@ -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"
templates/sum-of-sales.yaml ADDED
@@ -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"
uv.lock ADDED
The diff for this file is too large to render. See raw diff