""" FastAPI server for the Contexto game's word embeddings engine. Replaces the Gemini API with local, free, unlimited word vector comparisons. Usage: python app.py # Server runs at http://localhost:8000 """ import json import os import sys from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from pydantic import BaseModel import uvicorn from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded # Add server directory to path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from embeddings import EmbeddingsEngine import random limiter = Limiter(key_func=get_remote_address) app = FastAPI( title="Ponele - Embeddings API", description="Local word embeddings engine for semantic similarity ranking", version="2.0.0" ) app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # CORS: Allow the frontend to call this API from any origin (local dev) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # --- Global Engine --- engine = EmbeddingsEngine() SECRET_WORDS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "vocab_secret_words.json") secret_words: list[str] = [] # --- Request/Response Models --- class GuessRequest(BaseModel): guess: str secret: str class HintRequest(BaseModel): secret: str excluded_words: list[str] class RankResponse(BaseModel): rank: int similarity: float found: bool normalized_word: str is_correct: bool error: str | None = None class HealthResponse(BaseModel): status: str vocabulary_size: int engine: str # --- Startup Event --- @app.on_event("startup") async def startup_event(): """Load the embeddings model when the server starts.""" global secret_words try: engine.load_model() with open(SECRET_WORDS_PATH, "r", encoding="utf-8") as f: secret_words = json.load(f) if not secret_words: raise RuntimeError("The secret-word catalog is empty") print("\n" + "=" * 50) print("šŸŽ® Ponele - Embeddings Server") print(f"šŸ“š Vocabulary: {engine.get_vocabulary_size()} words") print(f"šŸŽÆ Secret-word catalog: {len(secret_words)} words") print(f"šŸš€ Server ready and reachable.") print("=" * 50 + "\n") except FileNotFoundError as e: print(f"\nāŒ {e}") print("šŸ’” Run 'python build_bert_model.py' first to build the BERT embeddings model.") sys.exit(1) # --- API Endpoints --- @app.get("/health", response_model=HealthResponse) @limiter.limit("60/minute") async def health_check(request: Request): """Check if the server is running and the model is loaded.""" from backend.embeddings import MODEL_FILENAME return HealthResponse( status="ok", vocabulary_size=engine.get_vocabulary_size(), engine=f"BERT-{MODEL_FILENAME.replace('.kv', '')}" ) @app.post("/rank", response_model=RankResponse) @limiter.limit("100/minute") async def get_rank(request: Request, body_req: GuessRequest): """ Main endpoint: Get the semantic rank of a guessed word against the secret word. The rank is based on cosine similarity between word embeddings: - Rank 1 = exact match (the secret word) - Rank 2-100 = very similar words (synonyms, closely related) - Rank 101-1000 = related words - Rank 1001-5000 = loosely related - Rank 5000+ = unrelated words """ guess = body_req.guess.strip() secret = body_req.secret.strip() if not guess or not secret: raise HTTPException(status_code=400, detail="Both 'guess' and 'secret' are required") result = engine.get_rank_for_word(guess, secret) if not result["found"]: return JSONResponse( status_code=200, content={ "rank": -1, "similarity": 0.0, "found": False, "normalized_word": result["normalized_word"], "is_correct": False, "error": result.get("error", "Word not found in vocabulary") } ) return RankResponse(**result) @app.post("/hint") @limiter.limit("20/minute") async def get_hint(request: Request, body_req: HintRequest): """ Get a hint: a word that is semantically close but not yet guessed. """ secret = body_req.secret.strip() if not secret: raise HTTPException(status_code=400, detail="'secret' is required") hint = engine.get_hint(secret, body_req.excluded_words) if not hint: raise HTTPException(status_code=404, detail="No suitable hint found") # Get full rank info for this hint word result = engine.get_rank_for_word(hint["word"], secret) return result @app.get("/check/{word}") @limiter.limit("100/minute") async def check_word(request: Request, word: str): """Check if a word exists in the vocabulary.""" exists = engine.word_exists(word) return { "word": word, "exists": exists } @app.get("/vocabulary/size") @limiter.limit("60/minute") async def vocabulary_size(request: Request): """Get the size of the loaded vocabulary.""" return { "size": engine.get_vocabulary_size() } @app.get("/random-word") @limiter.limit("30/minute") async def random_word(request: Request, exclude: str = ""): """ Return a random word suitable for use as the secret word in a round. """ excluded_set = {w.strip().lower() for w in exclude.split(",") if w.strip()} candidates = [w for w in secret_words if w not in excluded_set] if not candidates: raise HTTPException(status_code=404, detail="No suitable words found") chosen = random.choice(candidates) return {"word": chosen.upper()} @app.get("/daily-word") @limiter.limit("30/minute") async def get_daily_word(request: Request): """ Return a deterministic 'Word of the Day' based on the current date in GMT-3 (Argentina). All players get the same word. """ from datetime import datetime, timedelta, timezone # Use Argentina Time (UTC-3) arg_tz = timezone(timedelta(hours=-3)) now = datetime.now(arg_tz) date_str = now.strftime("%Y-%m-%d") # Calculate a game number (how many days since a start date) start_date = datetime(2024, 1, 1, tzinfo=arg_tz) game_number = (now - start_date).days if not secret_words: raise HTTPException(status_code=404, detail="No suitable words found") # Use the date string as a seed for random # We create a local Random instance to not affect global randomness seeded_random = random.Random(date_str) chosen = seeded_random.choice(secret_words) return { "word": chosen.upper(), "game_number": game_number, "date": date_str } # --- Serve Static Frontend --- @app.get("/favicon.ico", include_in_schema=False) async def serve_favicon(): # Prevent 404 noise in the console for missing favicon return JSONResponse(status_code=204, content=None) from fastapi.staticfiles import StaticFiles app.mount("/", StaticFiles(directory=os.path.join(os.path.dirname(__file__), ".."), html=True), name="frontend") # --- Run --- if __name__ == "__main__": import uvicorn import os port = int(os.environ.get("PORT", 8000)) # Note: Use "backend.app:app" if running from root directory uvicorn.run("app:app", host="0.0.0.0", port=port, reload=True)