Spaces:
Sleeping
Sleeping
File size: 31,320 Bytes
431d059 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 |
from fastapi import FastAPI, HTTPException
from starlette.responses import JSONResponse
from models import TaskRequest # Ensure models.py is available
from config import get_settings
import asyncio
import httpx # Used for making the HTTP notification call
import json # For parsing the structured JSON response from the LLM
import os # For configuration and file system operations
import base64
import re
import git # For local Git operations
import time
import shutil
import stat # For robust cleanup on Windows
# Assuming this model is defined elsewhere
# --- Configuration and Setup ---
settings = get_settings()
# --- Helper Function for Security ---
def verify_secret(secret_from_request: str) -> bool:
"""Checks if the provided secret matches the expected student secret."""
return secret_from_request == settings.STUDENT_SECRET
# --- GITHUB CONSTANTS ---
GITHUB_API_BASE = "https://api.github.com"
# Pages URL is constructed dynamically using the username from settings
GITHUB_PAGES_BASE = f"https://{settings.GITHUB_USERNAME}.github.io"
# --------------------------
# LLM Configuration
GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-05-20:generateContent"
# NOTE: API key is left empty (or read from environment) as per instructions;
# the execution environment is assumed to handle the required authentication.
GEMINI_API_KEY = settings.GEMINI_API_KEY
# Initialize the FastAPI application
app = FastAPI(
title="Automated Task Receiver & Processor",
description="Endpoint for receiving task assignments and triggering AI code generation/deployment."
)
# Global storage for the last received task (for demonstration purposes)
received_task_data = {}
# --- REFACTORING: SPLIT deploy_to_github ---
async def setup_local_repo(local_path: str, repo_name: str, repo_url_auth: str, repo_url_http: str, round_index: int) -> git.Repo:
"""Handles creating the remote repo (R1) or cloning the existing one (R2+) into an EMPTY directory."""
github_username = settings.GITHUB_USERNAME
github_token = settings.GITHUB_TOKEN
headers = {
"Authorization": f"token {github_token}",
"Accept": "application/vnd.github.v3+json",
"X-GitHub-Api-Version": "2022-11-28"
}
async with httpx.AsyncClient(timeout=45) as client:
try:
# 1. CREATE or INITIALIZE REPO / CLONE EXISTING REPO
if round_index == 1:
print(f" -> R1: Creating remote repository '{repo_name}'...")
payload = {"name": repo_name, "private": False, "auto_init": True}
response = await client.post(f"{GITHUB_API_BASE}/user/repos", json=payload, headers=headers)
response.raise_for_status()
# Initialize local git repo in the EMPTY path
repo = git.Repo.init(local_path)
repo.create_remote('origin', repo_url_auth)
print(" -> R1: Local git repository initialized.")
elif round_index >= 2:
# Crucial part for Round 2: Cloning the existing work into the EMPTY local_path
print(f" -> R{round_index}: Cloning existing repository from {repo_url_http}...")
# local_path is guaranteed to be empty due to the cleanup and directory creation in the main function
repo = git.Repo.clone_from(repo_url_auth, local_path)
print(f" -> R{round_index}: Repository cloned and ready for update.")
return repo
except httpx.HTTPStatusError as e:
print(f"--- [API ERROR] GitHub API call failed with status {e.response.status_code}: {e.response.text} ---")
raise Exception("GitHub API call failed during repository setup.")
except git.GitCommandError as e:
print(f"--- [GIT ERROR] Failed to perform git operation: {e} ---")
raise Exception("Git operation failed during repository setup.")
async def commit_and_publish(repo: git.Repo, task_id: str, round_index: int, repo_name: str) -> dict:
"""Handles adding, committing, pushing, and configuring GitHub Pages after files are saved."""
github_username = settings.GITHUB_USERNAME
github_token = settings.GITHUB_TOKEN
headers = {
"Authorization": f"token {github_token}",
"Accept": "application/vnd.github.v3+json",
"X-GitHub-Api-Version": "2022-11-28"
}
repo_url_http = f"https://github.com/{github_username}/{repo_name}"
async with httpx.AsyncClient(timeout=45) as client:
try:
# 1. CONFIGURE GIT USER (required for commits in Docker)
repo.config_writer().set_value("user", "name", "TDS AutoDeploy Bot").release()
repo.config_writer().set_value("user", "email", "bot@tds-project.local").release()
# 2. ADD, COMMIT, AND PUSH FILES
# The new files (generated and attachments) are now in the local_path.
repo.git.add(A=True)
commit_message = f"Task {task_id} - Round {round_index}: LLM-generated app update/creation"
repo.index.commit(commit_message)
commit_sha = repo.head.object.hexsha
print(f" -> Files committed. SHA: {commit_sha}")
# Ensure main branch consistency and push
repo.git.branch('-M', 'main')
print(" -> Branch renamed to 'main'.")
repo.git.push('--set-upstream', 'origin', 'main', force=True)
print(" -> Changes pushed to remote 'main' branch.")
# Wait for GitHub to register the branch
print(" -> Waiting 10 seconds for GitHub to register the main branch...")
await asyncio.sleep(10)
# 2. ENABLE GITHUB PAGES WITH ROBUST RETRIES
print(" -> Enabling GitHub Pages with robust retries...")
pages_api_url = f"{GITHUB_API_BASE}/repos/{github_username}/{repo_name}/pages"
pages_payload = {"source": {"branch": "main", "path": "/"}}
pages_max_retries = 5
pages_base_delay = 3
for retry_attempt in range(pages_max_retries):
try:
pages_response = await client.get(pages_api_url, headers=headers)
is_configured = (pages_response.status_code == 200)
if is_configured:
print(f" -> Pages exists. Updating configuration (Attempt {retry_attempt + 1}).")
(await client.put(pages_api_url, json=pages_payload, headers=headers)).raise_for_status()
else:
print(f" -> Creating Pages configuration (Attempt {retry_attempt + 1}).")
(await client.post(pages_api_url, json=pages_payload, headers=headers)).raise_for_status()
print(" -> Pages configuration successful.")
break
except httpx.HTTPStatusError as e:
if e.response.status_code == 422 and "main branch must exist" in e.response.text and retry_attempt < pages_max_retries - 1:
delay = pages_base_delay * (2 ** retry_attempt)
print(f" -> [Timing Issue] Branch not recognized. Retrying in {delay} seconds...")
await asyncio.sleep(delay)
else:
raise
else:
raise Exception("Failed to configure GitHub Pages after multiple retries due to branch existence.")
# 3. CONSTRUCT RETURN VALUES
print(" -> Waiting 5 seconds for GitHub Pages deployment...")
await asyncio.sleep(5)
pages_url = f"{GITHUB_PAGES_BASE}/{repo_name}/"
return {
"repo_url": repo_url_http,
"commit_sha": commit_sha,
"pages_url": pages_url
}
except git.GitCommandError as e:
print(f"--- [GIT ERROR] Failed to perform git operation: {e} ---")
raise Exception("Git operation failed during deployment.")
except httpx.HTTPStatusError as e:
print(f"--- [API ERROR] GitHub API call failed with status {e.response.status_code}: {e.response.text} ---")
raise Exception("GitHub API call failed during deployment.")
except Exception as e:
print(f"--- [CRITICAL ERROR] Deployment failed: {e} ---")
raise
# --- REMOVED: Original deploy_to_github (replaced by setup_local_repo and commit_and_publish) ---
# The function name deploy_to_github is now DELETED.
def data_uri_to_gemini_part(data_uri: str) -> dict:
"""
Extracts Base64 data and MIME type from a Data URI and formats it
as the 'inlineData' structure required for a Gemini API multimodal part.
"""
if not data_uri or not data_uri.startswith("data:"):
print("ERROR: Invalid Data URI provided.")
return None
try:
# Extract MIME type and Base64 part using regex
match = re.search(r"data:(?P<mime_type>[^;]+);base64,(?P<base64_data>.*)", data_uri, re.IGNORECASE)
if not match:
print("ERROR: Could not parse MIME type or base64 data from URI.")
return None
mime_type = match.group('mime_type')
base64_data = match.group('base64_data')
# Check if it's a known image type to ensure we only send images to the LLM
if not mime_type.startswith("image/"):
print(f"Skipping attachment with non-image MIME type: {mime_type}")
return None
return {
"inlineData": {
"data": base64_data, # The Base64 string itself
"mimeType": mime_type
}
}
except Exception as e:
print(f"ERROR creating Gemini Part from URI: {e}")
return None
def is_image_data_uri(data_uri: str) -> bool:
"""Checks if the data URI refers to an image based on the MIME type."""
if not data_uri.startswith("data:"):
return False
# Check for "image/" prefix in the MIME type part of the URI
return re.search(r"data:image/[^;]+;base64,", data_uri, re.IGNORECASE) is not None
# --- Helper Functions for File System Operations ---
async def save_generated_files_locally(task_id: str, files: dict) -> str:
"""
Saves the generated files (index.html, README.md, LICENSE) into a local
directory named after the task_id within the 'generated_tasks' folder.
Handles both old format {filename: content} and new format {"files": [{path, content}]}
"""
base_dir = "/tmp/generated_tasks"
task_dir = os.path.join(base_dir, task_id)
# Ensure the task-specific directory exists
# NOTE: This directory is created earlier in the main orchestration function
os.makedirs(task_dir, exist_ok=True)
print(f"--- [LOCAL_SAVE] Saving files to: {task_dir} ---")
# Handle new array-based format: {"files": [{"path": "...", "content": "..."}]}
if "files" in files and isinstance(files["files"], list):
files_list = files["files"]
for file_obj in files_list:
filename = file_obj.get("path", "")
content = file_obj.get("content", "")
if not filename:
print(f" -> WARNING: Skipping file with no path")
continue
# Handle case where content is a list instead of string
if isinstance(content, list):
print(f" -> WARNING: Content for {filename} is a list, joining with newlines")
content = "\n".join(str(item) for item in content)
elif not isinstance(content, str):
print(f" -> WARNING: Content for {filename} is {type(content)}, converting to string")
content = str(content)
file_path = os.path.join(task_dir, filename)
try:
# Create subdirectories if needed (e.g., "css/style.css")
os.makedirs(os.path.dirname(file_path), exist_ok=True)
# Write the content to the file
with open(file_path, "w", encoding="utf-8") as f:
f.write(content)
print(f" -> Saved: {filename} (Size: {len(content)} bytes)")
except Exception as e:
print(f" -> ERROR saving {filename}: {e}")
print(f" -> Content type: {type(content)}, First 200 chars: {str(content)[:200]}")
raise Exception(f"Failed to save file {filename} locally.")
# Handle old flat format: {filename: content} (for backwards compatibility)
else:
for filename, content in files.items():
# Handle case where content is a list instead of string
if isinstance(content, list):
print(f" -> WARNING: Content for {filename} is a list, joining with newlines")
content = "\n".join(str(item) for item in content)
elif not isinstance(content, str):
print(f" -> WARNING: Content for {filename} is {type(content)}, converting to string")
content = str(content)
file_path = os.path.join(task_dir, filename)
try:
# Write the content to the file. Assuming content is a string (text files).
with open(file_path, "w", encoding="utf-8") as f:
f.write(content)
print(f" -> Saved: {filename} (Size: {len(content)} bytes)")
except Exception as e:
print(f" -> ERROR saving {filename}: {e}")
raise Exception(f"Failed to save file {filename} locally.")
return task_dir
# --- Helper Functions for External Services ---
async def call_llm_for_code(prompt: str, task_id: str, image_parts: list) -> dict:
"""
Calls the Gemini API to generate the web application code and structured
metadata (README and LICENSE), now supporting image inputs.
The response is strictly validated against a JSON schema.
"""
print(f"--- [LLM_CALL] Attempting to generate code for Task: {task_id} using Gemini API ---")
# Define system instruction for the model
system_prompt = (
"You are an expert full-stack engineer and technical writer. Your task is to generate "
"a complete web application with three files in a structured JSON response:\n\n"
"Return a JSON object with a 'files' array containing:\n"
"1. index.html - A single, complete, fully responsive HTML file using Tailwind CSS CDN for styling, "
"with all JavaScript inline. Must be production-ready and implement ALL requested features.\n"
"2. README.md - Professional project documentation with title, description, features, usage instructions.\n"
"3. LICENSE - Full text of the MIT License.\n\n"
"Example response structure:\n"
"{\n"
' "files": [\n'
' {"path": "index.html", "content": "<!DOCTYPE html>..."},\n'
' {"path": "README.md", "content": "# Project Title\\n\\n..."},\n'
' {"path": "LICENSE", "content": "MIT License\\n\\nCopyright..."}\n'
" ]\n"
"}\n\n"
"Make the application beautiful, functional, and complete. Use modern design principles."
)
# Define the JSON response structure with proper array-based file list
response_schema = {
"type": "OBJECT",
"properties": {
"files": {
"type": "ARRAY",
"items": {
"type": "OBJECT",
"properties": {
"path": {"type": "STRING", "description": "File name (e.g., 'index.html', 'README.md', 'LICENSE')"},
"content": {"type": "STRING", "description": "Full content of the file"}
},
"required": ["path", "content"]
}
}
},
"required": ["files"]
}
# --- CONSTRUCT THE CONTENTS FIELD ---
contents = []
if image_parts:
# Combine image parts and the text prompt.
all_parts = image_parts + [
{ "text": prompt }
]
contents.append({ "parts": all_parts })
else:
# If no images, use the original structure with only the text prompt
contents.append({ "parts": [{ "text": prompt }] })
# Construct the final API payload
payload = {
"contents": contents,
"systemInstruction": { "parts": [{ "text": system_prompt }] },
"generationConfig": {
"responseMimeType": "application/json",
"responseSchema": response_schema
}
}
# Use exponential backoff for the API call
max_retries = 5 # Increased from 3 to 5
base_delay = 2 # Increased from 1 to 2
for attempt in range(max_retries):
try:
# Construct the URL with the API key
url = f"{GEMINI_API_URL}?key={GEMINI_API_KEY}"
# Increased timeout from 60s to 180s for complex tasks
async with httpx.AsyncClient(timeout=180.0) as client:
response = await client.post(
url,
json=payload,
headers={"Content-Type": "application/json"}
)
response.raise_for_status() # Raises an exception for 4xx/5xx status codes
# Parse the response to get the structured JSON text
result = response.json()
# Extract the generated JSON string from the result
json_text = result['candidates'][0]['content']['parts'][0]['text']
# The LLM output is a JSON string, so we need to parse it into a Python dict
generated_files = json.loads(json_text)
print(f"--- [LLM_CALL] Successfully generated files on attempt {attempt + 1}. ---")
return generated_files
except httpx.HTTPStatusError as e:
print(f"--- [LLM_CALL] HTTP Error on attempt {attempt + 1}: {e.response.status_code} - {e.response.text[:500]} ---")
except KeyError as e:
print(f"--- [LLM_CALL] KeyError on attempt {attempt + 1}: Missing expected key {e} in LLM response. ---")
print(f"--- [LLM_CALL] Full response: {result if 'result' in locals() else 'No response received'} ---")
except json.JSONDecodeError as e:
print(f"--- [LLM_CALL] JSON Decode Error on attempt {attempt + 1}: {e} ---")
print(f"--- [LLM_CALL] Raw LLM output that failed to parse: {json_text[:1000] if 'json_text' in locals() else 'No text extracted'} ---")
except httpx.RequestError as e:
# Catches network errors
print(f"--- [LLM_CALL] Network Error on attempt {attempt + 1}: {type(e).__name__}: {str(e)} ---")
if attempt < max_retries - 1:
delay = base_delay * (2 ** attempt)
print(f"--- [LLM_CALL] Retrying LLM call in {delay} seconds... ---")
await asyncio.sleep(delay)
# If all retries fail, we raise an exception which is caught downstream
print("--- [LLM_CALL] Failed to generate code after multiple retries. ---")
raise Exception("LLM Code Generation Failure")
async def notify_evaluation_server(
evaluation_url: str,
email: str,
task_id: str,
round_index: int,
nonce: str,
repo_url: str,
commit_sha: str,
pages_url: str
) -> bool:
"""
Calls the evaluation_url to notify the server that the code has been deployed.
"""
payload = {
"email": email,
"task": task_id,
"round": round_index,
"nonce": nonce,
"repo_url": repo_url,
"commit_sha": commit_sha,
"pages_url": pages_url
}
max_retries = 3
base_delay = 1
print(f"--- [NOTIFICATION] Attempting to notify server at {evaluation_url} ---")
for attempt in range(max_retries):
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(evaluation_url, json=payload)
response.raise_for_status() # Raises an exception for 4xx/5xx status codes
print(f"--- [NOTIFICATION] Successfully notified server. Response: {response.status_code} ---")
return True
except httpx.HTTPStatusError as e:
print(f"--- [NOTIFICATION] HTTP Error on attempt {attempt + 1}: {e}. ---")
except httpx.RequestError as e:
print(f"--- [NOTIFICATION] Request Error on attempt {attempt + 1}: {e}. ---")
if attempt < max_retries - 1:
delay = base_delay * (2 ** attempt)
print(f"--- [NOTIFICATION] Retrying in {delay} seconds... ---")
await asyncio.sleep(delay)
print(f"--- [NOTIFICATION] Failed to notify evaluation server after {max_retries} attempts. ---")
return False
async def save_attachments_locally(task_dir: str, attachments: list) -> list:
"""
Decodes and saves attachments (provided as Base64 Data URIs) into the task directory.
Returns a list of saved filenames.
"""
saved_files = []
print(f"--- [ATTACHMENTS] Processing {len(attachments)} attachments for: {task_dir} ---")
for attachment in attachments:
filename = attachment.name
data_uri = attachment.url
if not filename or not data_uri or not data_uri.startswith("data:"):
print(f" -> WARNING: Skipping invalid attachment entry: {filename}")
continue
# Use regex to extract the Base64 part of the URI (after base64,)
match = re.search(r"base64,(.*)", data_uri, re.IGNORECASE)
if not match:
print(f" -> ERROR: Could not find base64 data in URI for {filename}")
continue
base64_data = match.group(1)
file_path = os.path.join(task_dir, filename)
try:
# Decode the base64 string
file_bytes = base64.b64decode(base64_data)
# Write the raw bytes to the file
with open(file_path, "wb") as f:
f.write(file_bytes)
print(f" -> Saved Attachment: {filename} (Size: {len(file_bytes)} bytes)")
saved_files.append(filename)
except Exception as e:
print(f" -> CRITICAL ERROR saving attachment {filename}: {e}")
raise Exception(f"Failed to save attachment {filename} locally.")
return saved_files
# --- Main Orchestration Logic ---
async def generate_files_and_deploy(task_data: TaskRequest):
"""
The asynchronous background process that executes the main project workflow.
It adapts the LLM prompt for multi-round tasks and fixes the cloning order.
"""
task_id = task_data.task
email = task_data.email
round_index = task_data.round
brief = task_data.brief
evaluation_url = task_data.evaluation_url
nonce = task_data.nonce
attachments = task_data.attachments
print(f"\n--- [PROCESS START] Starting background task for {task_id}, Round {round_index} ---")
# Deployment configuration
repo_name = task_id.replace(' ', '-').lower()
github_username = settings.GITHUB_USERNAME
github_token = settings.GITHUB_TOKEN
repo_url_auth = f"https://{github_username}:{github_token}@github.com/{github_username}/{repo_name}.git"
repo_url_http = f"https://github.com/{github_username}/{repo_name}"
try:
# 0. Setup local directory - use /tmp which is always writable
base_dir = "/tmp/generated_tasks"
local_path = os.path.join(base_dir, task_id)
# --- ROBUST CLEANUP LOGIC ---
# Crucial: Cleans up local directory before cloning or creating a new repo.
if os.path.exists(local_path):
print(f"--- [CLEANUP] Deleting existing local directory: {local_path} ---")
def onerror(func, path, exc_info):
"""Error handler for shutil.rmtree to handle permission issues."""
if exc_info[0] is PermissionError or 'WinError 5' in str(exc_info[1]):
os.chmod(path, stat.S_IWUSR)
func(path)
else:
raise
try:
shutil.rmtree(local_path, onerror=onerror)
print("--- [CLEANUP] Directory deleted successfully. ---")
except Exception as e:
print(f"!!! CRITICAL: Failed to clean up directory. Error: {e}")
raise Exception(f"Failed to perform local cleanup: {e}")
# Create the fresh, EMPTY directory (ready for clone or init)
os.makedirs(local_path, exist_ok=True)
# --- END ROBUST CLEANUP ---
# 1. SETUP REPO (Clone or Init)
# MUST run before any files are saved to local_path.
print(f"--- [DEPLOYMENT] Setting up local Git repository for Round {round_index}... ---")
repo = await setup_local_repo(
local_path=local_path,
repo_name=repo_name,
repo_url_auth=repo_url_auth,
repo_url_http=repo_url_http,
round_index=round_index
)
# 2. Process Attachments for LLM Input
image_parts = []
attachment_list_for_llm_prompt = []
for attachment in attachments:
# Check for image parts for LLM input
if is_image_data_uri(attachment.url):
gemini_part = data_uri_to_gemini_part(attachment.url)
if gemini_part:
image_parts.append(gemini_part)
# List all attachment names for the prompt
attachment_list_for_llm_prompt.append(attachment.name)
print(f"--- [LLM_INPUT] Found {len(image_parts)} image(s) to pass to LLM. ---")
attachment_list_str = ", ".join(attachment_list_for_llm_prompt)
# 3. AI Code Generation - Adapt Prompt for Round 2
# --- MODIFICATION START: Adapting the LLM Prompt ---
if round_index > 1:
# For Round 2+, tell the LLM it's modifying existing work
llm_prompt = (
f"UPDATE INSTRUCTION (ROUND {round_index}): You must modify the existing project files "
f"(index.html, README.md, LICENSE) based on this new brief: '{brief}'. "
"You must replace all content in 'index.html', 'README.md', and 'LICENSE' with new, complete versions "
"that implement the requested modifications. The 'index.html' must remain a single, complete, "
"fully responsive HTML file using Tailwind CSS."
)
else:
# For Round 1, generate a new application
llm_prompt = (
f"Generate a complete, single-file HTML web application to achieve the following: {brief}. "
"Ensure your code is fully responsive, and uses Tailwind CSS. "
"Provide the code for the main web app, a README.md, and an MIT LICENSE."
)
# Add attachment context if files were provided, regardless of round.
if attachment_list_str:
llm_prompt += f"\nAdditional context: The following files are available in the project root: {attachment_list_str}. "
llm_prompt += f"Ensure your code references these files correctly (if applicable)."
# --- MODIFICATION END ---
# Call LLM
generated_files = await call_llm_for_code(llm_prompt, task_id, image_parts)
# 4. Save Generated Code Locally
# This overwrites the cloned files (index.html, README.md, LICENSE)
await save_generated_files_locally(task_id, generated_files)
# 5. Save Attachments Locally
# This adds attachments (like data.csv) to the local directory
# The attachment saving now happens *after* the clone/init, resolving the Round 2 error.
await save_attachments_locally(local_path, attachments)
# 6. COMMIT AND PUBLISH
print(f"--- [DEPLOYMENT] Committing and Publishing task {task_id}, Round {round_index} to GitHub... ---")
deployment_info = await commit_and_publish(
repo=repo,
task_id=task_id,
round_index=round_index,
repo_name=repo_name
)
repo_url = deployment_info["repo_url"]
commit_sha = deployment_info["commit_sha"]
pages_url = deployment_info["pages_url"]
print(f"--- [DEPLOYMENT] Success! Repo: {repo_url}, Pages: {pages_url} ---")
# 7. Notify the Evaluation Server
await notify_evaluation_server(
evaluation_url=evaluation_url,
email=email,
task_id=task_id,
round_index=round_index,
nonce=nonce,
repo_url=repo_url,
commit_sha=commit_sha,
pages_url=pages_url
)
except Exception as e:
print(f"--- [CRITICAL FAILURE] Task {task_id} failed during processing: {e} ---")
print(f"--- [PROCESS END] Background task for {task_id} completed. ---")
# --- FastAPI Endpoint ---
@app.post("/ready", status_code=200)
async def receive_task(task_data: TaskRequest):
"""
API endpoint that receives the task payload.
It verifies the secret and starts the generation/deployment process in the background.
"""
global received_task_data
# 1. SECRET VERIFICATION (CRITICAL PROJECT REQUIREMENT)
if not verify_secret(task_data.secret):
print(f"--- FAILED SECRET VERIFICATION for task {task_data.task} ---")
raise HTTPException(
status_code=401,
detail="Unauthorized: Secret does not match configured student secret."
)
# Store data and print initial confirmation
received_task_data = task_data.dict()
print("--- TASK RECEIVED SUCCESSFULLY ---")
print(f"Task ID: {received_task_data['task']}, Round: {received_task_data['round']}")
# Start the processing function in the background
asyncio.create_task(generate_files_and_deploy(task_data))
# Respond immediately with 200 OK to the evaluation server
return JSONResponse(
status_code=200,
content={"status": "ready", "message": f"Task {task_data.task} received and processing started."}
)
@app.get("/")
async def root():
return {"message": "Task Receiver Service is running. Post to /ready to submit a task."}
@app.get("/status")
async def get_status():
global received_task_data
if received_task_data:
# Note: This status only shows the last received request, not the live status of the background task.
return {"last_received_task": received_task_data}
else:
return {"message": "Awaiting first task submission to /ready"} |