# ============================================================ # main.py # ------------------------------------------------------------ # Titan API Gateway — FastAPI application entry point. # # V2 Changes: # ★ APScheduler lifespan — auto-publishes Immersion story # every hour (00:05, 01:05, 02:05, … UTC) — 24 stories/day. # No manual cron job needed. # ★ Added admin_router (/admin/refresh-hot-cache). # # Responsibilities: # 1. Create the FastAPI app instance with metadata. # 2. Configure CORS + SharedArrayBuffer security headers. # 3. Include all feature routers (Cinema, Immersion, Chat, Translate, AI Tasks, Admin). # 4. Start/stop the Immersion story scheduler via lifespan. # 5. Provide a public /health endpoint for HF Space monitoring. # 6. Launch uvicorn when executed directly. # # Hugging Face Spaces notes: # - HF expects the app to bind on 0.0.0.0:7860. # - Set INTERNAL_BASE_URL=http://localhost:7860 in HF Secrets. # - Set TITAN_INTERNAL_KEY in HF Secrets (same key sent as the # X-Titan-Key header — see core/security.py for the current # auth module; this replaced the old hardcoded "Titan2026_Admin" # string and the stale TITAN_API_KEY name this comment used to say). # - Titan Academy (see api/academy.py) additionally needs # AGENTROUTER_API_KEY in HF Secrets for its Opus calls. # ============================================================ import logging import os import time import asyncio from contextlib import asynccontextmanager from pathlib import Path import uvicorn from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse, FileResponse from fastapi.staticfiles import StaticFiles from api.cinema import router as cinema_router from api.immersion import router as immersion_router from api.academy import router as academy_router # ★ Titan Academy — standalone module, see api/academy.py from api.academy_generator import run_generator_cycle # ★ Academy auto-runner — direct in-process call, see below # ★ استيراد الراوترات (Chat, Translate, AI, Admin, Live) from api.chat import chat_router, translate_router, ai_router, admin_router, live_router, image_router, messaging_router, story_companion_router # 8 routers from api.scheduler import start_scheduler, stop_scheduler # ============================================================ # Logging # ============================================================ logging.basicConfig( level=logging.INFO, format="%(asctime)s | %(levelname)-8s | %(name)s — %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) logger = logging.getLogger("titan.main") # Paths exempt from COEP/COOP headers — HF health checker hits these # from a different origin; require-corp would cause silent failures. _HEALTH_PATHS = {"/", "/health"} # ============================================================ # Lifespan — scheduler start/stop # ============================================================ # ============================================================ # Generated Images — served directly from /tmp/titan_images/ # URL: GET /images/{filename} # Auto-cleanup: files older than 2 hours are deleted # ============================================================ IMAGES_DIR = Path("/tmp/titan_images") IMAGES_DIR.mkdir(parents=True, exist_ok=True) IMAGE_TTL_SECONDS = 7200 # 2 hours async def _image_cleanup_loop(): """Deletes generated images older than IMAGE_TTL_SECONDS every 30 minutes.""" while True: await asyncio.sleep(1800) # every 30 minutes try: now = time.time() deleted = 0 for f in IMAGES_DIR.iterdir(): if f.is_file() and (now - f.stat().st_mtime) > IMAGE_TTL_SECONDS: f.unlink(missing_ok=True) deleted += 1 if deleted: logger.info("🗑️ Image cleanup — deleted %d expired files", deleted) except Exception as exc: logger.warning("Image cleanup error: %s", exc) # ============================================================ # Titan Academy — autonomous curriculum runner # ============================================================ # Off by default (ACADEMY_AUTO_RUN unset/false) — a human should verify # a handful of units via the existing manual endpoint # (POST /api/v1/academy/generate-next-unit) first. Once satisfied, set # ACADEMY_AUTO_RUN=true in this Space's secrets and redeploy; the loop # below then keeps calling run_generator_cycle() with NO topic_hint # (so academy_generator.py pulls each unit's topic from the # AI-generated curriculum plan — see that file's _ensure_level_planned) # until run_generator_cycle() reports status="complete" — i.e. all # TOTAL_LEVELS × UNITS_PER_LEVEL units exist — at which point the loop # exits on its own and does not need to be turned back off manually. # # PACING: each unit does 8 Pexels image searches (one per vocabulary # word). Pexels' free tier is capped at 200 requests/hour, so the # absolute ceiling is ~25 units/hour. ACADEMY_AUTO_RUN_DELAY_SECONDS # defaults to 180s between units (~20 units/hour, ~160 Pexels # requests/hour) — comfortably under the cap with headroom for # retries, rather than running at the ceiling itself. At the default # pace, the full 50×20=1,000-unit course finishes in roughly 50 hours # of wall-clock time (a couple of days), not weeks — lower this # constant to go faster (up to the ~25 units/hour Pexels ceiling), or # raise it to spread generation out more slowly. ACADEMY_AUTO_RUN = os.environ.get("ACADEMY_AUTO_RUN", "false").strip().lower() == "true" ACADEMY_AUTO_RUN_DELAY_SECONDS = int(os.environ.get("ACADEMY_AUTO_RUN_DELAY_SECONDS", "180")) ACADEMY_AUTO_RUN_LANG_PAIR = os.environ.get("ACADEMY_AUTO_RUN_LANG_PAIR", "en_ar") async def _academy_auto_run_loop(): if not ACADEMY_AUTO_RUN: logger.info("[AcademyAutoRun] Disabled (set ACADEMY_AUTO_RUN=true to enable).") return logger.info( "[AcademyAutoRun] ▶️ Starting — lang_pair=%s, %ds between units.", ACADEMY_AUTO_RUN_LANG_PAIR, ACADEMY_AUTO_RUN_DELAY_SECONDS, ) await asyncio.sleep(30) # let the rest of the app finish booting first while True: try: result = await run_generator_cycle( lang_pair=ACADEMY_AUTO_RUN_LANG_PAIR, topic_hint=None, # → pulled from the curriculum plan automatically ) except Exception: logger.exception("[AcademyAutoRun] Unexpected error — retrying after delay.") await asyncio.sleep(ACADEMY_AUTO_RUN_DELAY_SECONDS) continue status = result.get("status") if status == "complete": logger.info("[AcademyAutoRun] 🎉 Full curriculum complete (%s) — stopping.", ACADEMY_AUTO_RUN_LANG_PAIR) return if status == "skipped": # Lock held by another run (e.g. a concurrent manual trigger) — short wait, try again. await asyncio.sleep(30) continue if status == "error": logger.warning("[AcademyAutoRun] Unit generation error: %s — retrying same unit after delay.", result.get("reason")) elif status == "success": logger.info("[AcademyAutoRun] ✅ unit=%s → next=lvl_%02d_unit_%02d", result.get("unit_id"), result.get("next_level", 0), result.get("next_unit", 0)) await asyncio.sleep(ACADEMY_AUTO_RUN_DELAY_SECONDS) @asynccontextmanager async def lifespan(app: FastAPI): """Start Immersion auto-scheduler on startup, stop on shutdown.""" logger.info("🚀 Titan Gateway starting up…") start_scheduler() asyncio.create_task(_image_cleanup_loop()) asyncio.create_task(_academy_auto_run_loop()) # ★ no-op unless ACADEMY_AUTO_RUN=true yield logger.info("🛑 Titan Gateway shutting down…") stop_scheduler() # ============================================================ # FastAPI application # ============================================================ app = FastAPI( title="Titan API Gateway", description=( "Master API Gateway for Titan applications. " "Provides AI-powered transcription (Groq Whisper) and translation (Google Gemini/Gemma) " "services with built-in key rotation, rate-limit handling, and zero disk I/O. " # ★ CORRECTED: was "auto-published every 2 hours via APScheduler" — no longer # true as of scheduler.py V6.0 (auto-scheduling was deliberately disabled; # this line was stale and misleading anyone reading /docs). "Titan Immersion stories and Titan Academy units are generated via manual/internal " "trigger endpoints, not an automatic schedule. " "All endpoints require the `X-Titan-Key` header for authentication." ), version="23.0.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, ) # ============================================================ # Security Headers Middleware # Required for FFmpeg.wasm SharedArrayBuffer support. # ============================================================ @app.middleware("http") async def add_security_headers(request: Request, call_next): response = await call_next(request) if request.url.path not in _HEALTH_PATHS: response.headers["Cross-Origin-Opener-Policy"] = "same-origin" response.headers["Cross-Origin-Embedder-Policy"] = "require-corp" return response # ============================================================ # CORS # ============================================================ ALLOWED_ORIGINS: list[str] = os.environ.get("CORS_ORIGINS", "*").split(",") app.add_middleware( CORSMiddleware, allow_origins=ALLOWED_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], expose_headers=["X-Request-ID"], ) # ============================================================ # Routers # ============================================================ app.include_router(cinema_router) app.include_router(immersion_router) app.include_router(academy_router) # ★ Titan Academy: POST /api/v1/academy/generate-next-unit (manual/internal — see # api/scheduler.py's own V6.0 note on why story auto-scheduling is disabled; # Academy follows the same manual-trigger philosophy, not APScheduler) app.include_router(chat_router) # POST /api/v1/chat/ask & /ask/sync app.include_router(translate_router) # POST /api/v1/translate app.include_router(ai_router) # POST /api/v1/ai/task app.include_router(admin_router) # ★ POST /admin/refresh-hot-cache app.include_router(live_router) # ★ WebSocket /ws/live/{model_id} app.include_router(image_router) # ★ POST /api/v1/image/generate | /enhance app.include_router(messaging_router) # ★ Titan Connect: POST /transcribe, GET/POST /tts/* app.include_router(story_companion_router) # ★ Titan Immersion: POST /api/v1/immersion/story-chat (AI narrator) # ============================================================ # Generated Image Serving # GET /images/{filename} → serves from /tmp/titan_images/ # ============================================================ @app.get("/images/{filename}", tags=["Images"]) async def serve_generated_image(filename: str): """Serves a locally generated image — valid for 2 hours after creation.""" if "/" in filename or "\\" in filename or ".." in filename: from fastapi import HTTPException raise HTTPException(status_code=400, detail="Invalid filename") filepath = IMAGES_DIR / filename if not filepath.exists(): from fastapi import HTTPException raise HTTPException(status_code=404, detail="Image not found or expired") ext = filepath.suffix.lower().lstrip(".") media_types = {"webp": "image/webp", "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg"} return FileResponse( path=str(filepath), media_type=media_types.get(ext, "image/webp"), headers={"Cache-Control": "public, max-age=7200"}, ) # ============================================================ # Health check (no auth — used by HF Spaces + load balancers) # ============================================================ @app.get( "/health", tags=["System"], summary="Gateway health check", ) async def health_check() -> dict: return { "status": "ok", "gateway": "Titan API Gateway", "version": "23.0.0", } # ============================================================ # Root → Health Check (To satisfy Hugging Face) # ============================================================ @app.get("/", tags=["System"]) async def root() -> dict: return { "status": "ok", "message": "Titan Gateway V23.0 is Alive and Ready!", "docs_url": "/docs", "health": "All systems operational" } # ============================================================ # Entry point # ============================================================ if __name__ == "__main__": uvicorn.run( "main:app", host="0.0.0.0", port=7860, reload=os.environ.get("RELOAD", "true").lower() == "true", log_level="info", workers=1, )