3ssem0
fix: configuration error - aligned entry point to app.py and enforced LF/BOM-less encoding
ec47953
Raw
History Blame Contribute Delete
25.5 kB
import os
import zipfile
import json
import tempfile
import rarfile
import datetime
from github import Github
import requests
import random
import string
import time
import shutil
from fastapi.responses import JSONResponse
from fastapi import FastAPI, File, UploadFile, Form, Request
from fastapi.middleware.cors import CORSMiddleware
from dotenv import load_dotenv
from github_utils import create_github_repo, upload_files_to_repo, enable_github_pages, resync_webcraft_file, get_repository
from title_generator import get_repo_title, sanitize_for_repo_name
from code_commenter import add_comments_to_webcraft_file
# تحميل الإعدادات من ملف .env
load_dotenv()
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")
GITHUB_USERNAME = os.getenv("GITHUB_USERNAME")
app = FastAPI()
# Add CORS middleware to allow browser requests
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Allows all origins
allow_credentials=True,
allow_methods=["*"], # Allows all methods
allow_headers=["*"], # Allows all headers
)
@app.get("/")
async def root():
return {"status": "success", "message": "WebCraft AI Backend is running!"}
@app.post("/upload_website")
async def upload_website(
request: Request,
file: UploadFile = File(...),
github_token: str = Form(None),
github_username: str = Form(None),
repo_name: str = Form(None),
add_comments: bool = Form(True),
comment_level: str = Form("concise"),
is_update: bool = Form(False)
):
try:
# ----------------------------------------
# 0. Handle Repo Name
# ----------------------------------------
provided_repo_name = repo_name
# ----------------------------------------
# 1. read uploaded file
# ----------------------------------------
is_zip = file.filename.endswith(".zip")
is_html = file.filename.endswith(".html")
if not (is_zip or is_html):
return {"status": "error", "message": "Only .html or .zip files are accepted."}
# Create temporary directory
temp_dir = tempfile.mkdtemp()
file_path = os.path.join(temp_dir, file.filename)
# Save file
with open(file_path, "wb") as f:
f.write(await file.read())
# If it's a zip, extract it
if is_zip:
print(f"📦 Extracting {file.filename}...")
# Use zipfile to extract
with zipfile.ZipFile(file_path, 'r') as zip_ref:
zip_ref.extractall(temp_dir)
# Remove the original zip file so it doesn't get uploaded
os.remove(file_path)
# --- FLATTEN FRONTEND FOLDER FOR GITHUB PAGES ---
# If there is a 'frontend' folder, move its contents to the root
frontend_dir = os.path.join(temp_dir, "frontend")
if os.path.exists(frontend_dir) and os.path.isdir(frontend_dir):
print("📂 Multi-file project detected. Flattening 'frontend/' for GitHub Pages...")
for item in os.listdir(frontend_dir):
s = os.path.join(frontend_dir, item)
d = os.path.join(temp_dir, item)
# if destination exists (unlikely), remove it first
if os.path.exists(d):
if os.path.isdir(d): shutil.rmtree(d)
else: os.remove(d)
shutil.move(s, d)
try:
os.rmdir(frontend_dir)
except:
pass
# Update file_path to point to the index.html (now in root after flattening)
# Find the main HTML file for comment adding if enabled
# We'll look for index.html or any html file in the tree
found_html = None
for root, dirs, files in os.walk(temp_dir):
if "index.html" in files:
found_html = os.path.join(root, "index.html")
break
if not found_html:
# Fallback to any html file
for root, dirs, files in os.walk(temp_dir):
html_files = [f for f in files if f.endswith(".html")]
if html_files:
found_html = os.path.join(root, html_files[0])
break
if found_html:
file_path = found_html
print(f"Target HTML for AI comments: {file_path}")
else:
print("No HTML file found for AI commenting in the extracted package.")
add_comments = False
# ----------------------------------------
# 2. Generate repo name
# ----------------------------------------
if provided_repo_name:
repo_name = sanitize_for_repo_name(provided_repo_name)
print(f"Using provided repo name: {repo_name}")
else:
# Using the free local model (T5-small)
title_results = get_repo_title(file_path)
# Prioritize: AI Title > Title Tag (if specific) > Fallback
# Check if title_tag is generic
t_tag = title_results.get("title_tag")
ai_title = title_results.get("ai_title")
fallback = title_results.get("fallback")
generic_terms = ["document", "untitled", "index", "home", "test", "my-site", "new-project"]
is_generic = not t_tag or any(term in t_tag.lower() for term in generic_terms)
if ai_title and (is_generic or len(t_tag) < 3):
repo_name = ai_title
print(f"Preferring AI Title over generic/short tag: {repo_name}")
else:
repo_name = t_tag or ai_title or fallback
print(f"Selected best repo name (Final): {repo_name}")
# ----------------------------------------
# 3. Add AI comments to WebCraft code (if enabled)
# ----------------------------------------
if add_comments and comment_level in ["detailed", "concise", "minimal"]:
try:
# Using the free rule-based + CodeT5 way
# Pass repo_name to force update the <title> tag
is_benchmark = request.headers.get("x-benchmark-mode") == "true"
file_path = add_comments_to_webcraft_file(
file_path, comment_level, force_title=repo_name, benchmark_mode=is_benchmark
)
print(f"Added {comment_level} comments to code and updated title to '{repo_name}'")
except Exception as e:
if str(e) == "AI_EXHAUSTED":
raise e
print(f"Could not add comments: {e}")
# Continue without comments
# ----------------------------------------
# 4. Determine user credentials
# ----------------------------------------
user_token = github_token if github_token else GITHUB_TOKEN
if not user_token:
print("Error: GITHUB_TOKEN is missing!")
return {"status": "error", "message": "GitHub Token is missing. Please provide it in the form or as a Secret."}
# Get the actual username from GitHub token to ensure correctness
try:
g = Github(user_token)
authed_user = g.get_user()
authed_username = authed_user.login
print(f"🔑 Token verified for GitHub user: {authed_username}")
except Exception as e:
print(f"GitHub Token Verification Failed: {e}")
return {"status": "error", "message": f"Invalid GitHub Token: {str(e)}"}
# ----------------------------------------
# 5. Create or Get repo
# ----------------------------------------
if is_update:
print(f"Attempting to update existing repository: {repo_name}...")
repo = get_repository(authed_username, repo_name, user_token)
if not repo:
print(f"Repository {repo_name} not found. Creating new instead...")
repo = create_github_repo(user_token, repo_name)
else:
print(f"Attempting to create new repository: {repo_name}...")
repo = create_github_repo(user_token, repo_name)
actual_repo_name = repo.name
print(f"Repository ready: {repo.html_url}")
# ----------------------------------------
# 6. Upload the HTML file ONLY
# ----------------------------------------
print(f"📤 Uploading files to {actual_repo_name}...")
upload_files_to_repo(repo, temp_dir)
print("Upload complete.")
# ----------------------------------------
# 7. Enable GitHub Pages
# ----------------------------------------
print(f"🌐 Enabling GitHub Pages for {actual_repo_name}...")
if enable_github_pages(authed_username, actual_repo_name, user_token):
site_url = f"https://{authed_username}.github.io/{actual_repo_name}/"
print(f"🚀 Success! Website live at: {site_url}")
return {
"status": "success",
"repo_name": actual_repo_name,
"repo_url": repo.html_url,
"website_url": site_url
}
else:
print("GitHub Pages activation returned non-200 status. It may still be activating...")
site_url = f"https://{authed_username}.github.io/{actual_repo_name}/"
return {
"status": "partial_success",
"message": "Files uploaded, but GitHub Pages is still processing. Check back in 1 minute.",
"repo_name": actual_repo_name,
"repo_url": repo.html_url,
"website_url": site_url
}
except Exception as e:
if str(e) == "AI_EXHAUSTED":
return JSONResponse(
status_code=503,
content={"error": "AI service temporarily unavailable. Please retry."}
)
import traceback
error_details = traceback.format_exc()
print(f"🔥 UNCAUGHT EXCEPTION:\n{error_details}")
return JSONResponse(
status_code=400,
content={"status": "error", "message": str(e)}
)
@app.post("/resync_repository")
async def resync_repository(
request: Request,
file: UploadFile = File(...),
repo_name: str = Form(...),
github_token: str = Form(None),
github_username: str = Form(None),
add_comments: bool = Form(False),
comment_level: str = Form("concise")
):
"""
Resync WebCraft HTML file with existing GitHub repository.
Args:
file: Updated WebCraft HTML file
repo_name: Existing repository name
github_token: GitHub token (optional, uses .env)
github_username: GitHub username (optional, uses .env)
add_comments: Whether to add/update comments
comment_level: Comment detail level (detailed/concise/minimal)
"""
try:
# Validate file type
if not file.filename.endswith(".html"):
return {"status": "error", "message": "Only .html files are accepted."}
# Create temporary directory and save file
temp_dir = tempfile.mkdtemp()
file_path = os.path.join(temp_dir, file.filename)
with open(file_path, "wb") as f:
f.write(await file.read())
# Add comments if requested
if add_comments and comment_level in ["detailed", "concise", "minimal"]:
try:
is_benchmark = request.headers.get("x-benchmark-mode") == "true"
file_path = add_comments_to_webcraft_file(
file_path, comment_level, benchmark_mode=is_benchmark
)
print(f"Added {comment_level} comments")
except Exception as e:
if str(e) == "AI_EXHAUSTED":
raise e
print(f"Could not add comments: {e}")
# Use provided credentials or fallback to .env
user_token = github_token if github_token else GITHUB_TOKEN
user_name = github_username if github_username else GITHUB_USERNAME
# Resync with GitHub repository
result = resync_webcraft_file(
username=user_name,
repo_name=repo_name,
token=user_token,
html_file_path=file_path,
commit_message="Resync from WebCraft"
)
return result
except Exception as e:
if str(e) == "AI_EXHAUSTED":
return JSONResponse(
status_code=503,
content={"error": "AI service temporarily unavailable. Please retry."}
)
return JSONResponse(
status_code=400,
content={"status": "error", "message": str(e)}
)
@app.post("/preview_comments")
async def preview_comments(
request: Request,
file: UploadFile = File(...),
comment_level: str = Form("concise")
):
"""
Preview WebCraft HTML file with AI-generated comments.
Does not upload to GitHub - just returns the commented code.
Args:
file: WebCraft HTML file
comment_level: Comment detail level (detailed/concise/minimal)
"""
try:
# Validate file type
if not file.filename.endswith(".html"):
return {"status": "error", "message": "Only .html files are accepted."}
# Create temporary directory and save file
temp_dir = tempfile.mkdtemp()
file_path = os.path.join(temp_dir, file.filename)
output_path = os.path.join(temp_dir, f"commented_{file.filename}")
with open(file_path, "wb") as f:
f.write(await file.read())
# Add comments
if comment_level not in ["detailed", "concise", "minimal", "educational"]:
comment_level = "concise"
is_benchmark = request.headers.get("x-benchmark-mode") == "true"
commented_file = add_comments_to_webcraft_file(
file_path, comment_level, output_path, benchmark_mode=is_benchmark
)
# Read commented file
with open(commented_file, "r", encoding="utf-8") as f:
commented_content = f.read()
return {
"status": "success",
"commented_code": commented_content,
"comment_level": comment_level
}
except Exception as e:
if str(e) == "AI_EXHAUSTED":
return JSONResponse(
status_code=503,
content={"error": "AI service temporarily unavailable. Please retry."}
)
return JSONResponse(
status_code=400,
content={"status": "error", "message": str(e)}
)
@app.post("/suggest_repo_name")
async def suggest_repo_name(
file: UploadFile = File(...)
):
"""
Suggest a repository name based on HTML content.
"""
try:
# Validate file type
if not file.filename.endswith(".html"):
return {"status": "error", "message": "Only .html files are accepted."}
# Create temporary directory and save file
temp_dir = tempfile.mkdtemp()
file_path = os.path.join(temp_dir, file.filename)
with open(file_path, "wb") as f:
f.write(await file.read())
# Generate repo name using local T5 model
title_results = get_repo_title(file_path)
# Build list of options
options = []
if title_results.get("title_tag"):
options.append({"type": "title_tag", "name": title_results["title_tag"]})
if title_results.get("ai_title"):
options.append({"type": "ai_title", "name": title_results["ai_title"]})
if title_results.get("fallback"):
# If not already present
if not any(opt["name"] == title_results["fallback"] for opt in options):
options.append({"type": "fallback", "name": title_results["fallback"]})
return {
"status": "success",
"suggestions": options,
"best_suggestion": options[0]["name"] if options else "my-webcraft-project"
}
except Exception as e:
return JSONResponse(
status_code=400,
content={"status": "error", "message": str(e)}
)
@app.post("/ai_builder")
async def ai_builder(
prompt: str = Form(...),
current_workspace: str = Form(None), # New optional parameter
max_blocks: int = Form(50),
max_retries: int = Form(3)
):
"""
Generate Blockly workspace from natural language prompt using AI.
This endpoint uses Qwen2.5-Coder-7B-Instruct to convert natural language
descriptions into Blockly block configurations.
Args:
prompt: Natural language description (e.g., "Create a landing page with hero")
current_workspace: JSON string of existing workspace (optional, for context)
max_blocks: Maximum blocks to generate (safety limit, default: 50)
max_retries: Maximum retry attempts for validation (default: 3)
Returns:
{
"status": "success" | "error",
"workspace": {"blocks": [...], "connections": [...]},
"block_count": N,
"attempts": N,
"message": "..."
}
"""
try:
from ai_planner import get_planner
from block_validator import validate_workspace, format_validation_errors
# Use plain ASCII logging to avoid Windows console encoding issues
print(f"\nAI Builder Request: '{prompt[:100]}...'")
if current_workspace:
print(f"Context provided: {len(current_workspace)} chars")
# Get AI planner (singleton, loaded once)
planner = get_planner()
# Generate with retry logic
retry_context = None
for attempt in range(1, max_retries + 1):
print(f"Attempt {attempt}/{max_retries}...")
# Generate workspace
try:
# Pass current_workspace to the planner
workspace = planner.generate_blocks(prompt, retry_context, current_workspace)
print(f"Generated {len(workspace.get('blocks', []))} blocks")
except Exception as e:
print(f"Generation failed: {e}")
if attempt == max_retries:
return JSONResponse(
status_code=500,
content={
"status": "error",
"message": f"AI generation failed: {str(e)}",
"attempts": attempt
}
)
continue
# Validate
validation = validate_workspace(workspace, max_blocks)
if validation["valid"]:
print("Valid workspace generated!")
return {
"status": "success",
"workspace": workspace,
"block_count": len(workspace["blocks"]),
"attempts": attempt,
"message": f"Successfully generated {len(workspace['blocks'])} blocks"
}
# Validation failed
print("Validation failed:")
for error in validation.get("errors", [])[:3]: # Show first 3 errors
print(f" - {error}")
# Retry with error context
if attempt < max_retries:
retry_context = format_validation_errors(validation)
print("Retrying with error context...")
# All retries failed
return JSONResponse(
status_code=400,
content={
"status": "error",
"message": "Failed to generate valid workspace after all retries",
"last_errors": validation.get("errors", []),
"last_warnings": validation.get("warnings"),
"attempts": max_retries
}
)
except ImportError as e:
return JSONResponse(
status_code=500,
content={
"status": "error",
"message": f"AI Builder not available: {str(e)}. Please install required dependencies: transformers, torch, pydantic"
}
)
except Exception as e:
# Keep logging minimal and ASCII-only to avoid Windows console encoding issues.
print(f"AI Builder Error (internal): {type(e).__name__}")
return JSONResponse(
status_code=500,
content={
"status": "error",
"message": f"Internal server error: {str(e)}"
}
)
@app.get("/templates")
async def list_templates():
"""List all saved templates."""
try:
templates_dir = "templates"
if not os.path.exists(templates_dir):
os.makedirs(templates_dir)
templates = []
for filename in os.listdir(templates_dir):
if filename.endswith(".json"):
with open(os.path.join(templates_dir, filename), "r", encoding="utf-8") as f:
try:
data = json.load(f)
templates.append(data)
except:
pass
return {"status": "success", "templates": templates}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@app.post("/templates")
async def save_template(
name: str = Form(...),
description: str = Form(""),
blocks: str = Form(...)
):
"""Save a new block template."""
try:
templates_dir = "templates"
if not os.path.exists(templates_dir):
os.makedirs(templates_dir)
# Create a safe filename
safe_name = "".join([c for c in name if c.isalnum() or c in (' ', '-', '_')]).rstrip()
safe_name = safe_name.replace(' ', '_').lower()
filename = f"{safe_name}.json"
template_data = {
"id": safe_name,
"name": name,
"description": description,
"blocks": blocks,
"created_at": str(datetime.datetime.now())
}
with open(os.path.join(templates_dir, filename), "w", encoding="utf-8") as f:
json.dump(template_data, f, indent=2)
return {"status": "success", "message": "Template saved", "template": template_data}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@app.delete("/templates/{template_id}")
async def delete_template(template_id: str):
"""Delete a saved template."""
try:
templates_dir = "templates"
filename = f"{template_id}.json"
file_path = os.path.join(templates_dir, filename)
if os.path.exists(file_path):
os.remove(file_path)
return {"status": "success", "message": f"Template {template_id} deleted"}
else:
return JSONResponse(status_code=404, content={"status": "error", "message": "Template not found"})
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@app.get("/health_check")
async def health_check():
"""Diagnostic endpoint: tests all AI models and returns 'UP'/'DOWN' per model.
Intended for benchmark scripts only — each call fires up to 5 LLM test prompts.
"""
from code_commenter import _FREE_MODEL_CHAIN, _call_openrouter, _call_huggingface
status = {}
test_msg = [
{"role": "system", "content": "You are a tester."},
{"role": "user", "content": "Reply exactly with the word OK and nothing else."}
]
for model in _FREE_MODEL_CHAIN:
try:
resp = _call_openrouter(model, test_msg, label="health-check")
if resp and resp.status_code == 200:
status[model] = "UP"
else:
status[model] = "DOWN"
except Exception:
status[model] = "DOWN"
try:
hf_resp = _call_huggingface(test_msg)
status["microsoft/Phi-3.5-mini-instruct (HF)"] = "UP" if hf_resp else "DOWN"
except Exception:
status["microsoft/Phi-3.5-mini-instruct (HF)"] = "DOWN"
return status
if __name__ == "__main__":
import uvicorn
# Get port from environment variable for Heroku
port = int(os.environ.get("PORT", 8000))
uvicorn.run(app, host="0.0.0.0", port=port)