|
|
"""
|
|
|
FastAPI main server for IITM LLM Quiz Solver.
|
|
|
"""
|
|
|
import os
|
|
|
import logging
|
|
|
import asyncio
|
|
|
from typing import Dict, Any, Optional
|
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
|
from fastapi.responses import JSONResponse
|
|
|
from pydantic import BaseModel, Field, field_validator
|
|
|
import uvicorn
|
|
|
|
|
|
|
|
|
try:
|
|
|
from dotenv import load_dotenv
|
|
|
load_dotenv()
|
|
|
except ImportError:
|
|
|
pass
|
|
|
|
|
|
from app.solver import solve_quiz
|
|
|
from app.utils import validate_secret
|
|
|
from app.browser import cleanup_browser
|
|
|
|
|
|
|
|
|
logging.basicConfig(
|
|
|
level=logging.INFO,
|
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
|
)
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
EXPECTED_SECRET = os.getenv("QUIZ_SECRET", "default_secret_change_me")
|
|
|
|
|
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
|
|
@asynccontextmanager
|
|
|
async def lifespan(app: FastAPI):
|
|
|
"""Lifespan context manager for startup and shutdown."""
|
|
|
|
|
|
logger.info("Application starting up...")
|
|
|
yield
|
|
|
|
|
|
logger.info("Shutting down, cleaning up browser...")
|
|
|
await cleanup_browser()
|
|
|
|
|
|
|
|
|
app = FastAPI(
|
|
|
title="IITM LLM Quiz Solver",
|
|
|
description="API endpoint to automatically solve dynamic quiz tasks",
|
|
|
version="1.0.0",
|
|
|
lifespan=lifespan
|
|
|
)
|
|
|
|
|
|
|
|
|
class QuizRequest(BaseModel):
|
|
|
"""Request model for quiz solving."""
|
|
|
email: str = Field(..., description="User email address")
|
|
|
secret: str = Field(..., description="Secret key for authentication")
|
|
|
url: str = Field(..., description="Quiz page URL")
|
|
|
|
|
|
@field_validator('email')
|
|
|
@classmethod
|
|
|
def validate_email(cls, v):
|
|
|
if not v or '@' not in v:
|
|
|
raise ValueError('Invalid email format')
|
|
|
return v
|
|
|
|
|
|
@field_validator('url')
|
|
|
@classmethod
|
|
|
def validate_url(cls, v):
|
|
|
if not v or not v.startswith(('http://', 'https://')):
|
|
|
raise ValueError('Invalid URL format')
|
|
|
return v
|
|
|
|
|
|
|
|
|
@app.get("/")
|
|
|
async def root():
|
|
|
"""Root endpoint."""
|
|
|
return {
|
|
|
"message": "IITM LLM Quiz Solver API",
|
|
|
"version": "1.0.0",
|
|
|
"endpoints": {
|
|
|
"/solve": "POST - Solve a quiz",
|
|
|
"/health": "GET - Health check",
|
|
|
"/demo": "POST - Demo endpoint"
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
@app.get("/health")
|
|
|
async def health_check():
|
|
|
"""Health check endpoint."""
|
|
|
return {"status": "healthy"}
|
|
|
|
|
|
|
|
|
@app.get("/env-check")
|
|
|
async def env_check():
|
|
|
"""
|
|
|
Check environment variables status (returns JSON).
|
|
|
Useful for verifying configuration.
|
|
|
"""
|
|
|
quiz_secret = os.getenv("QUIZ_SECRET")
|
|
|
openai_key = os.getenv("OPENAI_API_KEY")
|
|
|
openrouter_key = os.getenv("OPENROUTER_API_KEY")
|
|
|
port = os.getenv("PORT", "8000")
|
|
|
|
|
|
return {
|
|
|
"status": "ok",
|
|
|
"variables": {
|
|
|
"QUIZ_SECRET": {
|
|
|
"set": quiz_secret is not None,
|
|
|
"length": len(quiz_secret) if quiz_secret else 0,
|
|
|
"preview": f"{quiz_secret[:4]}...{quiz_secret[-4:]}" if quiz_secret and len(quiz_secret) > 8 else "***" if quiz_secret else None
|
|
|
},
|
|
|
"OPENAI_API_KEY": {
|
|
|
"set": openai_key is not None,
|
|
|
"length": len(openai_key) if openai_key else 0,
|
|
|
"preview": f"{openai_key[:7]}...{openai_key[-4:]}" if openai_key and len(openai_key) > 11 else "***" if openai_key else None,
|
|
|
"valid_format": openai_key.startswith("sk-") if openai_key else False
|
|
|
},
|
|
|
"OPENROUTER_API_KEY": {
|
|
|
"set": openrouter_key is not None,
|
|
|
"length": len(openrouter_key) if openrouter_key else 0,
|
|
|
"preview": f"{openrouter_key[:7]}...{openrouter_key[-4:]}" if openrouter_key and len(openrouter_key) > 11 else "***" if openrouter_key else None,
|
|
|
"valid_format": openrouter_key.startswith("sk-or-") if openrouter_key else False
|
|
|
},
|
|
|
"PORT": {
|
|
|
"set": True,
|
|
|
"value": port
|
|
|
}
|
|
|
},
|
|
|
"ready": quiz_secret is not None,
|
|
|
"llm_enabled": any([openai_key, openrouter_key])
|
|
|
}
|
|
|
|
|
|
|
|
|
@app.post("/solve")
|
|
|
async def solve_quiz_endpoint(request: QuizRequest):
|
|
|
"""
|
|
|
Main endpoint to solve a quiz.
|
|
|
|
|
|
Validates secret and solves the quiz recursively.
|
|
|
"""
|
|
|
try:
|
|
|
|
|
|
if not validate_secret(request.secret, EXPECTED_SECRET):
|
|
|
logger.warning(f"Invalid secret provided for email: {request.email}")
|
|
|
raise HTTPException(
|
|
|
status_code=403,
|
|
|
detail={"error": "forbidden"}
|
|
|
)
|
|
|
|
|
|
logger.info(f"Solving quiz for {request.email} at {request.url}")
|
|
|
|
|
|
|
|
|
try:
|
|
|
result = await asyncio.wait_for(
|
|
|
solve_quiz(request.url, request.email, request.secret),
|
|
|
timeout=180.0
|
|
|
)
|
|
|
return result
|
|
|
except asyncio.TimeoutError:
|
|
|
logger.error("Quiz solving timed out")
|
|
|
raise HTTPException(
|
|
|
status_code=504,
|
|
|
detail={"error": "Request timeout - quiz solving took too long"}
|
|
|
)
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error solving quiz: {e}", exc_info=True)
|
|
|
raise HTTPException(
|
|
|
status_code=500,
|
|
|
detail={"error": str(e)}
|
|
|
)
|
|
|
|
|
|
except HTTPException:
|
|
|
raise
|
|
|
except ValueError as e:
|
|
|
logger.error(f"Validation error: {e}")
|
|
|
raise HTTPException(
|
|
|
status_code=400,
|
|
|
detail={"error": "Invalid request format", "message": str(e)}
|
|
|
)
|
|
|
except Exception as e:
|
|
|
logger.error(f"Unexpected error: {e}", exc_info=True)
|
|
|
raise HTTPException(
|
|
|
status_code=500,
|
|
|
detail={"error": "Internal server error", "message": str(e)}
|
|
|
)
|
|
|
|
|
|
|
|
|
@app.post("/demo")
|
|
|
async def demo_endpoint(request: QuizRequest):
|
|
|
"""
|
|
|
Demo endpoint for testing.
|
|
|
|
|
|
Same as /solve but with more lenient error handling.
|
|
|
"""
|
|
|
try:
|
|
|
|
|
|
if not validate_secret(request.secret, EXPECTED_SECRET):
|
|
|
logger.warning(f"Invalid secret in demo request")
|
|
|
return JSONResponse(
|
|
|
status_code=403,
|
|
|
content={"error": "forbidden"}
|
|
|
)
|
|
|
|
|
|
logger.info(f"Demo: Solving quiz for {request.email} at {request.url}")
|
|
|
|
|
|
|
|
|
try:
|
|
|
result = await asyncio.wait_for(
|
|
|
solve_quiz(request.url, request.email, request.secret),
|
|
|
timeout=180.0
|
|
|
)
|
|
|
return result
|
|
|
except asyncio.TimeoutError:
|
|
|
return JSONResponse(
|
|
|
status_code=504,
|
|
|
content={"error": "Request timeout"}
|
|
|
)
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error in demo: {e}", exc_info=True)
|
|
|
return JSONResponse(
|
|
|
status_code=500,
|
|
|
content={"error": str(e)}
|
|
|
)
|
|
|
|
|
|
except ValueError as e:
|
|
|
return JSONResponse(
|
|
|
status_code=400,
|
|
|
content={"error": "Invalid request format", "message": str(e)}
|
|
|
)
|
|
|
except Exception as e:
|
|
|
logger.error(f"Unexpected error in demo: {e}", exc_info=True)
|
|
|
return JSONResponse(
|
|
|
status_code=500,
|
|
|
content={"error": "Internal server error", "message": str(e)}
|
|
|
)
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
port = int(os.getenv("PORT", 8000))
|
|
|
uvicorn.run(
|
|
|
"app.main:app",
|
|
|
host="0.0.0.0",
|
|
|
port=port,
|
|
|
log_level="info"
|
|
|
)
|
|
|
|
|
|
|