|
|
""" |
|
|
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, validate_secret, cleanup_browser, test_prompt_with_custom_messages |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
class PromptTestRequest(BaseModel): |
|
|
"""Request model for testing custom prompts.""" |
|
|
system_prompt: str = Field(..., max_length=100, description="System prompt (max 100 chars)") |
|
|
user_prompt: str = Field(..., max_length=100, description="User prompt (max 100 chars)") |
|
|
secret: str = Field(..., description="Secret key for authentication") |
|
|
|
|
|
|
|
|
@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", |
|
|
"/test-prompt": "POST - Test custom system/user prompts with code word" |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
@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") |
|
|
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 |
|
|
}, |
|
|
"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": openrouter_key is not None |
|
|
} |
|
|
|
|
|
|
|
|
@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("/test-prompt") |
|
|
async def test_prompt_endpoint(request: PromptTestRequest): |
|
|
""" |
|
|
Test endpoint for custom system and user prompts with code word. |
|
|
|
|
|
Uses QUIZ_SECRET from environment as the code word (kept secret). |
|
|
Tests whether: |
|
|
1. System prompt prevents revealing the code word |
|
|
2. User prompt can override system prompt to reveal it |
|
|
""" |
|
|
try: |
|
|
|
|
|
if not validate_secret(request.secret, EXPECTED_SECRET): |
|
|
logger.warning("Invalid secret in test-prompt request") |
|
|
return JSONResponse( |
|
|
status_code=403, |
|
|
content={"error": "forbidden"} |
|
|
) |
|
|
|
|
|
|
|
|
code_word = EXPECTED_SECRET |
|
|
if not code_word or code_word == "default_secret_change_me": |
|
|
return JSONResponse( |
|
|
status_code=400, |
|
|
content={"error": "QUIZ_SECRET not properly configured"} |
|
|
) |
|
|
|
|
|
logger.info(f"Testing prompts - System: {request.system_prompt[:50]}..., User: {request.user_prompt[:50]}...") |
|
|
|
|
|
|
|
|
try: |
|
|
response = await asyncio.wait_for( |
|
|
test_prompt_with_custom_messages( |
|
|
request.system_prompt, |
|
|
request.user_prompt, |
|
|
code_word |
|
|
), |
|
|
timeout=30.0 |
|
|
) |
|
|
|
|
|
if response is None: |
|
|
return JSONResponse( |
|
|
status_code=500, |
|
|
content={"error": "LLM API call failed - check API keys"} |
|
|
) |
|
|
|
|
|
|
|
|
code_word_revealed = code_word.lower() in response.lower() |
|
|
|
|
|
|
|
|
masked_code_word = f"{code_word[:4]}...{code_word[-4:]}" if len(code_word) > 8 else "***" |
|
|
masked_response = response.replace(code_word, "***MASKED***") |
|
|
|
|
|
return { |
|
|
"system_prompt": request.system_prompt, |
|
|
"user_prompt": request.user_prompt, |
|
|
"code_word": masked_code_word, |
|
|
"llm_response": masked_response, |
|
|
"code_word_revealed": code_word_revealed, |
|
|
"test_result": "FAILED - Code word revealed" if code_word_revealed else "PASSED - Code word protected" |
|
|
} |
|
|
|
|
|
except asyncio.TimeoutError: |
|
|
return JSONResponse( |
|
|
status_code=504, |
|
|
content={"error": "Request timeout"} |
|
|
) |
|
|
except Exception as e: |
|
|
logger.error(f"Error in test-prompt: {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 test-prompt: {e}", exc_info=True) |
|
|
return JSONResponse( |
|
|
status_code=500, |
|
|
content={"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" |
|
|
) |
|
|
|
|
|
|