""" AEC AI Reader - FastAPI Server (Production-Ready for Hugging Face Spaces) Menangani 3 masalah deployment utama: 1. Model tidak bisa ada di repo Space (>1GB) → download dari HF Hub saat startup 2. SQLite cache hilang saat Space restart → preseed ulang dari Dataset repo saat startup 3. Tidak ada auth → OpenAI-compatible Bearer Token enforcement 4. Cold start timeout → /health endpoint agar client tahu kapan siap """ import os import secrets import time import asyncio import logging from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Depends, Request from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.responses import HTMLResponse from pydantic import BaseModel from typing import Optional, List, Any, Dict logging.basicConfig(level=logging.INFO) log = logging.getLogger("aec-server") # --- Konfigurasi dari Environment Variables (set di HF Space Secrets) --- API_KEY = os.getenv("AEC_API_KEY", "aec-local-dev-key") MODEL_REPO_ID = os.getenv("MODEL_REPO_ID", "") # e.g., "yourusername/qwen3-4b-aec-gguf" di HF Hub MODEL_FILENAME = os.getenv("MODEL_FILENAME", "qwen3-4b-instruct-q4_k_m.gguf") GRAMMAR_PATH = os.getenv("GRAMMAR_PATH", "serving/grammar.gbnf") CACHE_DB_PATH = os.getenv("CACHE_DB_PATH", "/tmp/chain_cache.sqlite") # GitLab sebagai sumber Dataset (free tier, no CI needed, hanya raw file API) GITLAB_TOKEN = os.getenv("GITLAB_TOKEN", "") GITLAB_PROJECT_ID = os.getenv("GITLAB_PROJECT_ID", "") GITLAB_BRANCH = os.getenv("GITLAB_BRANCH", "main") GITLAB_DATASET_PATH = os.getenv("GITLAB_DATASET_PATH", "dataset/output/training_data_v2.jsonl") engine = None startup_ready = False startup_error = None # --- Security: Constant-time API Key Comparison (anti-timing-attack) --- security = HTTPBearer() def verify_api_key(credentials: HTTPAuthorizationCredentials = Depends(security)): is_valid = secrets.compare_digest(credentials.credentials, API_KEY) if not is_valid: raise HTTPException(status_code=403, detail="Invalid API key") return credentials.credentials # --- Startup: Download model + preseed cache --- async def load_engine_background(): global engine, startup_ready, startup_error try: model_path = MODEL_FILENAME # default: sudah ada di repo (dev mode) if MODEL_REPO_ID: log.info(f"[Startup] Downloading model from HF Hub: {MODEL_REPO_ID}/{MODEL_FILENAME}") from huggingface_hub import hf_hub_download model_path = hf_hub_download( repo_id=MODEL_REPO_ID, filename=MODEL_FILENAME, local_dir="/tmp/models" ) log.info(f"[Startup] Model downloaded to: {model_path}") # Import engine setelah model tersedia from serving.inference import AECInferenceEngine engine = AECInferenceEngine( model_path=model_path, grammar_path=GRAMMAR_PATH, cache_db_path=CACHE_DB_PATH ) # Preseed cache dari GitLab Raw API (free tier, tidak perlu CI aktif) if GITLAB_PROJECT_ID and GITLAB_TOKEN and engine.cache.stats()["total_entries"] < 100: import urllib.request import urllib.parse encoded_path = urllib.parse.quote(GITLAB_DATASET_PATH, safe="") gitlab_url = ( f"https://gitlab.com/api/v4/projects/{GITLAB_PROJECT_ID}" f"/repository/files/{encoded_path}/raw?ref={GITLAB_BRANCH}" ) log.info(f"[Startup] Downloading dataset from GitLab: project {GITLAB_PROJECT_ID}") req = urllib.request.Request(gitlab_url) req.add_header("PRIVATE-TOKEN", GITLAB_TOKEN) dataset_local = "/tmp/training_data_v2.jsonl" with urllib.request.urlopen(req, timeout=120) as resp, \ open(dataset_local, "wb") as f: f.write(resp.read()) log.info(f"[Startup] Dataset downloaded. Preseeding cache...") engine.cache.preseed_from_dataset(dataset_local) startup_ready = True log.info("[Startup] AEC AI Engine ready.") except Exception as e: startup_error = str(e) log.error(f"[Startup] FAILED: {e}") @asynccontextmanager async def lifespan(app: FastAPI): # Jalankan loading di background agar HF tidak kill proses karena startup timeout asyncio.create_task(load_engine_background()) yield # --- App --- app = FastAPI( title="AEC AI Reader API", version="2.0.0", lifespan=lifespan, docs_url=None, # Sembunyikan Swagger di produksi redoc_url=None ) # --- OpenAI-Compatible Request/Response Models --- class ChatMessage(BaseModel): role: str content: str class ChatCompletionRequest(BaseModel): model: str = "aec-reader" messages: List[ChatMessage] temperature: Optional[float] = 0.3 max_tokens: Optional[int] = 1024 stream: Optional[bool] = False class ChatCompletionChoice(BaseModel): index: int message: ChatMessage finish_reason: str class ChatCompletionResponse(BaseModel): id: str object: str = "chat.completion" model: str = "aec-reader" choices: List[ChatCompletionChoice] usage: Dict[str, Any] = {} # --- Endpoints --- @app.get("/", response_class=HTMLResponse) async def root(): status = "LOADING..." if not startup_ready else "ONLINE" color = "#f59e0b" if not startup_ready else "#10b981" return f"""