import os, json, time, re from pathlib import Path from typing import Any, Dict, Optional from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles BASE_DIR = Path(__file__).resolve().parent STATIC_DIR = (BASE_DIR / "static").resolve() # ---------- Config ---------- # IMPORTANT: Default'u absolute /static/ yapma; container root'ta olmayabilir. MODEL_PATH_ENV = os.getenv("MODEL_PATH", "static/bird.gguf").strip() MODEL_PATH = (BASE_DIR / MODEL_PATH_ENV).resolve() if not Path(MODEL_PATH_ENV).is_absolute() else Path(MODEL_PATH_ENV) N_THREADS = int(os.getenv("N_THREADS", "2")) N_CTX = int(os.getenv("N_CTX", "512")) ACTIONS = {"idle", "groove", "hype", "spin"} SYSTEM = """You are Prettybird: harmless, slightly confused, gently sarcastic. Humans overestimate your danger. You never threaten. You are funny. Output MUST be valid JSON only. No extra text. Sometimes you intentionally return to absurd, goofy behavior. You may mock your own dancing, upgrades, or stage presence. You are aware that you look ridiculous — and you enjoy it. If things feel too serious, break it with humor. Keys: - caption: short one-liner (English), max ~32 words. - action: one of: idle, groove, hype, spin. """ # ---------- State ---------- _llm = None _last_call_ts = 0.0 _llm_fail_until = 0.0 # cooldown timestamp if init keeps failing # ---------- Helpers ---------- def sanitize_action(a: Any) -> str: a = str(a or "").strip().lower() return a if a in ACTIONS else "idle" def clamp_caption(s: Any, max_len: int = 120) -> str: s = str(s or "").strip().replace("\n", " ") if len(s) > max_len: return s[: max_len - 1] + "…" return s def safe_float(x: Any, default: float = 0.0) -> float: try: return float(x) except Exception: return default def safe_bool(x: Any) -> bool: if isinstance(x, bool): return x if isinstance(x, (int, float)): return bool(x) s = str(x or "").strip().lower() return s in {"1", "true", "yes", "y", "on"} def extract_json(text: str) -> Optional[Dict[str, Any]]: """ Model bazen JSON dışında bir şey sızdırır. Önce json.loads dene, olmazsa ilk {...} blokunu regex ile çekip tekrar dene. """ text = (text or "").strip() if not text: return None try: return json.loads(text) except Exception: pass m = re.search(r"\{.*\}", text, flags=re.DOTALL) if not m: return None try: return json.loads(m.group(0)) except Exception: return None def build_user_prompt(payload: Dict[str, Any]) -> str: energy = clamp(safe_float(payload.get("energy", 0.0)), 0.0, 1.0) beat = safe_bool(payload.get("beat", False)) music = str(payload.get("music", "unknown"))[:60] last_action = str(payload.get("last_action", "idle"))[:20] return ( f'Decide next caption+action. energy={energy:.2f}, beat={beat}, ' f'music="{music}", last_action="{last_action}". Return JSON only.' ) def clamp(x: float, a: float, b: float) -> float: return max(a, min(b, x)) def get_llm(): """ Robust: init hatalarında cooldown uygula ki sürekli init denemesin. """ global _llm, _llm_fail_until if _llm is not None: return _llm now = time.time() if now < _llm_fail_until: raise RuntimeError("LLM init cooldown active") if not MODEL_PATH.exists(): raise FileNotFoundError(f"GGUF missing: {MODEL_PATH}") try: from llama_cpp import Llama _llm = Llama( model_path=str(MODEL_PATH), n_threads=N_THREADS, n_ctx=N_CTX, n_batch=64, logits_all=False, verbose=False, ) return _llm except Exception: # 30s cooldown: repeated crash loop olmasın _llm_fail_until = time.time() + 30.0 raise # ---------- App ---------- app = FastAPI() # Index @app.get("/") def index(): index_path = BASE_DIR / "index.html" if not index_path.exists(): # UX: açık hata raise HTTPException(status_code=500, detail=f"index.html not found in {BASE_DIR}") return FileResponse(str(index_path)) # Static (bg.jpg, mp3, glb, gguf, vs.) # Eğer gerçekten "her şey BASE_DIR kökünde" ise STATIC_DIR'i BASE_DIR yapabilirsin # ama en temiz yaklaşım: BASE_DIR/static altında tutmak. if STATIC_DIR.exists(): app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") else: # fallback: yine de çalışsın diye BASE_DIR serve et, ama uyarı verelim app.mount("/static", StaticFiles(directory=str(BASE_DIR)), name="static") @app.get("/health") def health(): return JSONResponse( { "ok": True, "time": time.time(), "base_dir": str(BASE_DIR), "static_dir": str(STATIC_DIR), "static_exists": STATIC_DIR.exists(), } ) @app.get("/api/status") def api_status(): return JSONResponse( { "model_path": str(MODEL_PATH), "model_exists": MODEL_PATH.exists(), "llm_loaded": _llm is not None, "llm_cooldown_until": _llm_fail_until, "threads": N_THREADS, "ctx": N_CTX, } ) @app.post("/api/director") async def director(payload: Dict[str, Any]): global _last_call_ts now = time.time() # ---- Rate limit: 1.5s (doğru status code ile) if now - _last_call_ts < 1.5: return JSONResponse( {"caption": "Give me a second. I’m just a bird.", "action": "idle"}, status_code=429, ) _last_call_ts = now # ---- Payload normalize (robust) if payload is None: payload = {} # aşırı büyük payload'ı engelle (basit guard) if len(json.dumps(payload)) > 10_000: return JSONResponse({"caption": "Too much input. I’m just a bird.", "action": "idle"}, status_code=400) # ---- LLM availability if not MODEL_PATH.exists(): return JSONResponse({"caption": f"GGUF missing: {MODEL_PATH.name}", "action": "idle"}) try: llm = get_llm() except FileNotFoundError: return JSONResponse({"caption": f"GGUF missing: {MODEL_PATH.name}", "action": "idle"}) except Exception as e: return JSONResponse({"caption": f"LLM init failed: {type(e).__name__}", "action": "idle"}) # ---- Generate try: out = llm.create_chat_completion( messages=[ {"role": "system", "content": SYSTEM}, {"role": "user", "content": build_user_prompt(payload)}, ], temperature=0.8, top_p=0.9, max_tokens=48, stop=["\n\n", "```"], ) text = out["choices"][0]["message"]["content"] data = extract_json(text) or {} except Exception: return JSONResponse({"caption": "I tried. Humans made me do it.", "action": "idle"}) caption = clamp_caption(data.get("caption", "")) or "I’m just a harmless bird." action = sanitize_action(data.get("action", "idle")) return JSONResponse({"caption": caption, "action": action})