Spaces:
Sleeping
Sleeping
IamSamk
Serve via Gradio demo.launch() on HF Spaces (uvicorn.run fought the platform's own port-7860 server)
cace078 | """Placement Policy Advisor — FastAPI backend + Gradio chat UI (single app). | |
| Flow for every question: | |
| query -> FastEmbed embedding -> Qdrant top-K search -> prompt with injected | |
| context -> Hugging Face chat.completions (streaming) with automatic fallback | |
| to a second model on error/timeout. | |
| The Gradio ChatInterface is mounted on the same FastAPI app at "/", and a | |
| token-protected POST /api/query exposes the same pipeline programmatically. | |
| Everything runs in one process on APP_PORT. All config comes from the | |
| environment (loaded from .env when present); secrets are never defaulted. | |
| """ | |
| from __future__ import annotations | |
| import atexit | |
| import inspect | |
| import logging | |
| import os | |
| import secrets as _secrets | |
| import gradio as gr | |
| import uvicorn | |
| from dotenv import load_dotenv | |
| from fastapi import FastAPI, Header, HTTPException | |
| from fastapi.responses import StreamingResponse | |
| from huggingface_hub import AsyncInferenceClient | |
| from pydantic import BaseModel | |
| from qdrant_client import QdrantClient | |
| from system_prompt import INSUFFICIENT_CONTEXT_MESSAGE, SYSTEM_PROMPT | |
| load_dotenv() | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s | %(levelname)-7s | main | %(message)s", | |
| ) | |
| log = logging.getLogger("main") | |
| # --- ZeroGPU compatibility shim ------------------------------------------------ | |
| # This app does no local GPU work (FastEmbed runs ONNX on CPU; the LLM call goes | |
| # to HF's hosted Inference API over HTTP). But on a Hugging Face "ZeroGPU" | |
| # Space, the platform refuses to serve unless at least one function is | |
| # decorated with @spaces.GPU, so it knows when to allocate/release the shared | |
| # GPU. The `_zerogpu_probe` function below exists solely to satisfy that | |
| # startup scan; it is never called. | |
| # | |
| # IMPORTANT: `spaces` must NOT be listed in requirements.txt. On a real | |
| # ZeroGPU Space, the platform injects its own pinned, instrumented build of | |
| # `spaces` that's wired into its GPU scheduler; if we also pip-install a | |
| # generic version ourselves, it shadows that build with an inert stub that | |
| # never actually registers — the decorator applies but the platform still | |
| # reports "No @spaces.GPU function detected". Elsewhere (local, Docker, any | |
| # non-HF host) the package simply isn't installed, so the import fails and we | |
| # fall back to a no-op decorator. | |
| try: | |
| import spaces as _hf_spaces | |
| _gpu_decorator = _hf_spaces.GPU | |
| log.info("ZeroGPU 'spaces' package detected; @spaces.GPU shim active.") | |
| except Exception as _spaces_import_error: # noqa: BLE001 | |
| log.info( | |
| "ZeroGPU 'spaces' package not available (%s) — using no-op GPU decorator " | |
| "(expected outside a HF ZeroGPU Space).", | |
| _spaces_import_error, | |
| ) | |
| def _gpu_decorator(fn): | |
| return fn | |
| def _zerogpu_probe() -> None: | |
| return None | |
| def require_env(name: str) -> str: | |
| """Return a required secret/config value or fail fast with a clear message.""" | |
| value = os.environ.get(name) | |
| if not value: | |
| raise RuntimeError( | |
| f"Required environment variable '{name}' is not set. " | |
| f"Add it to .env (local) or your platform secrets (deployment)." | |
| ) | |
| return value | |
| # --- Secrets (REQUIRED — no defaults) ----------------------------------------- | |
| HF_TOKEN = require_env("HF_TOKEN") | |
| BACKEND_API_AUTH_TOKEN = require_env("BACKEND_API_AUTH_TOKEN") | |
| # --- Non-secret operational config (env with build-safe defaults) ------------- | |
| LLM_MODEL_ID = os.environ.get("LLM_MODEL_ID", "meta-llama/Llama-3.1-8B-Instruct") | |
| LLM_FALLBACK_MODEL_ID = os.environ.get("LLM_FALLBACK_MODEL_ID", "Qwen/Qwen2.5-7B-Instruct") | |
| EMBEDDING_MODEL_ID = os.environ.get("EMBEDDING_MODEL_ID", "BAAI/bge-small-en-v1.5") | |
| QDRANT_STORAGE_PATH = os.environ.get("QDRANT_STORAGE_PATH", "./qdrant_storage") | |
| QDRANT_COLLECTION_NAME = os.environ.get("QDRANT_COLLECTION_NAME", "placement_policy") | |
| TOP_K_RESULTS = int(os.environ.get("TOP_K_RESULTS", "5")) | |
| APP_PORT = int(os.environ.get("APP_PORT", "7860")) | |
| FASTEMBED_CACHE_DIR = os.environ.get("FASTEMBED_CACHE_DIR", ".fastembed_cache") | |
| # Generation params (env-overridable; not secrets). | |
| MAX_NEW_TOKENS = int(os.environ.get("MAX_NEW_TOKENS", "1024")) | |
| TEMPERATURE = float(os.environ.get("TEMPERATURE", "0.2")) | |
| LLM_REQUEST_TIMEOUT = float(os.environ.get("LLM_REQUEST_TIMEOUT", "60")) | |
| def _bootstrap_if_needed() -> None: | |
| """Ensure the vector index exists before serving. | |
| On a Docker deployment (see Dockerfile), convert_docs/download_models/ | |
| data_indexer already ran at image-build time, so this is a no-op. On a | |
| plain Python launch (e.g. a Hugging Face Gradio-SDK Space, which has no | |
| build-time hook), this runs those same steps once, live, at startup. | |
| """ | |
| probe = QdrantClient(path=QDRANT_STORAGE_PATH) | |
| try: | |
| indexed = probe.collection_exists(QDRANT_COLLECTION_NAME) and probe.count( | |
| QDRANT_COLLECTION_NAME | |
| ).count > 0 | |
| finally: | |
| probe.close() | |
| if indexed: | |
| log.info("Vector index already present; skipping bootstrap.") | |
| return | |
| log.info("No vector index found — running one-time bootstrap ...") | |
| import convert_docs | |
| import data_indexer | |
| import download_models | |
| for step_name, module in ( | |
| ("convert_docs", convert_docs), | |
| ("download_models", download_models), | |
| ("data_indexer", data_indexer), | |
| ): | |
| exit_code = module.main() | |
| if exit_code != 0: | |
| raise RuntimeError(f"Bootstrap step '{step_name}' failed (exit {exit_code})") | |
| log.info("Bootstrap complete.") | |
| _bootstrap_if_needed() | |
| # --- Heavy singletons: load once at import ------------------------------------ | |
| log.info("Loading FastEmbed model '%s' ...", EMBEDDING_MODEL_ID) | |
| from fastembed import TextEmbedding # noqa: E402 — after config so cache_dir is set | |
| _embedder = TextEmbedding(model_name=EMBEDDING_MODEL_ID, cache_dir=FASTEMBED_CACHE_DIR) | |
| log.info("Opening Qdrant local storage at '%s' ...", QDRANT_STORAGE_PATH) | |
| _qdrant = QdrantClient(path=QDRANT_STORAGE_PATH) | |
| # Close the embedded store cleanly on exit (avoids a noisy __del__ during | |
| # interpreter shutdown when local mode holds the storage lock). | |
| atexit.register(_qdrant.close) | |
| if not _qdrant.collection_exists(QDRANT_COLLECTION_NAME): | |
| log.warning( | |
| "Qdrant collection '%s' does not exist. Run data_indexer.py before " | |
| "serving, or the assistant will have no context to answer from.", | |
| QDRANT_COLLECTION_NAME, | |
| ) | |
| _hf_client = AsyncInferenceClient(token=HF_TOKEN, timeout=LLM_REQUEST_TIMEOUT) | |
| # --- Retrieval ---------------------------------------------------------------- | |
| def embed_query(query: str) -> list[float]: | |
| return next(iter(_embedder.embed([query]))).tolist() | |
| def retrieve(query: str): | |
| """Return the top-K scored points for a query (empty list if none/unavailable).""" | |
| if not _qdrant.collection_exists(QDRANT_COLLECTION_NAME): | |
| return [] | |
| vector = embed_query(query) | |
| response = _qdrant.query_points( | |
| collection_name=QDRANT_COLLECTION_NAME, | |
| query=vector, | |
| limit=TOP_K_RESULTS, | |
| with_payload=True, | |
| ) | |
| return response.points | |
| def build_messages(query: str, points) -> list[dict]: | |
| """Assemble the chat messages with retrieved policy context injected.""" | |
| blocks = [] | |
| for i, point in enumerate(points, start=1): | |
| payload = point.payload or {} | |
| label = payload.get("section_path") or payload.get("source") or "policy" | |
| blocks.append(f"[Source {i} — {label}]\n{payload.get('text', '').strip()}") | |
| context = "\n\n---\n\n".join(blocks) | |
| user_content = ( | |
| "POLICY CONTEXT (use only this to answer):\n" | |
| f"{context}\n\n" | |
| f"QUESTION: {query}" | |
| ) | |
| return [ | |
| {"role": "system", "content": SYSTEM_PROMPT}, | |
| {"role": "user", "content": user_content}, | |
| ] | |
| # --- LLM streaming with fallback ---------------------------------------------- | |
| async def _stream_model(model_id: str, messages: list[dict]): | |
| """Yield content deltas from one model. Raises on failure.""" | |
| result = _hf_client.chat.completions.create( | |
| model=model_id, | |
| messages=messages, | |
| stream=True, | |
| max_tokens=MAX_NEW_TOKENS, | |
| temperature=TEMPERATURE, | |
| ) | |
| # AsyncInferenceClient returns a coroutine (await -> async iterator) across | |
| # versions; guard so we work whether or not it must be awaited. | |
| if inspect.isawaitable(result): | |
| result = await result | |
| async for chunk in result: | |
| delta = chunk.choices[0].delta.content | |
| if delta: | |
| yield delta | |
| async def stream_answer(query: str): | |
| """Full RAG pipeline as an async token stream.""" | |
| query = (query or "").strip() | |
| if not query: | |
| yield "Please enter a question about the RV University Placement Policy." | |
| return | |
| points = retrieve(query) | |
| if not points: | |
| yield INSUFFICIENT_CONTEXT_MESSAGE | |
| return | |
| messages = build_messages(query, points) | |
| emitted = False | |
| for idx, model_id in enumerate((LLM_MODEL_ID, LLM_FALLBACK_MODEL_ID)): | |
| try: | |
| async for delta in _stream_model(model_id, messages): | |
| emitted = True | |
| yield delta | |
| return # completed successfully | |
| except Exception as exc: # noqa: BLE001 — any error triggers fallback | |
| log.warning("Model '%s' failed (%s): %s", model_id, type(exc).__name__, exc) | |
| if emitted: | |
| # Already streamed partial output; can't cleanly restart. | |
| yield "\n\n_(The response was interrupted. Please ask again.)_" | |
| return | |
| # else: fall through to the fallback model | |
| yield ( | |
| "The assistant is temporarily unavailable because the language model " | |
| "could not be reached. Please try again shortly." | |
| ) | |
| # --- FastAPI app -------------------------------------------------------------- | |
| app = FastAPI(title="Placement Policy Advisor") | |
| class QueryRequest(BaseModel): | |
| query: str | |
| def _authorize(authorization: str | None, x_api_token: str | None) -> None: | |
| """Constant-time check of the backend auth token from either header.""" | |
| provided = "" | |
| if authorization: | |
| provided = authorization[7:].strip() if authorization.lower().startswith("bearer ") else authorization.strip() | |
| elif x_api_token: | |
| provided = x_api_token.strip() | |
| if not provided: | |
| raise HTTPException(status_code=401, detail="Missing authentication token.") | |
| if not _secrets.compare_digest(provided, BACKEND_API_AUTH_TOKEN): | |
| raise HTTPException(status_code=403, detail="Invalid authentication token.") | |
| async def health(): | |
| return { | |
| "status": "ok", | |
| "collection": QDRANT_COLLECTION_NAME, | |
| "indexed": _qdrant.collection_exists(QDRANT_COLLECTION_NAME), | |
| } | |
| async def api_query( | |
| payload: QueryRequest, | |
| authorization: str | None = Header(default=None), | |
| x_api_token: str | None = Header(default=None), | |
| ): | |
| _authorize(authorization, x_api_token) | |
| return StreamingResponse(stream_answer(payload.query), media_type="text/plain") | |
| # --- Gradio UI (mounted at "/") ----------------------------------------------- | |
| async def _chat_fn(message: str, history): | |
| """Gradio streaming callback — accumulates deltas into the growing answer.""" | |
| answer = "" | |
| async for delta in stream_answer(message): | |
| answer += delta | |
| yield answer | |
| _demo = gr.ChatInterface( | |
| fn=_chat_fn, | |
| title="RV University — Placement Policy Advisor", | |
| description=( | |
| "Ask about eligibility, pre-placement training, the offer rules " | |
| "(One Offer 1×, Offer Progression 1.5×, Dream Offer), attempt limits, " | |
| "and placement procedures. Answers come only from the official policy. " | |
| "Disciplinary matters are handled by the Student Disciplinary Committee (STDC)." | |
| ), | |
| examples=[ | |
| "When does the 1.5× Offer Progression Rule get activated?", | |
| "How many placement opportunities do I get in a cycle?", | |
| "What are the eligibility criteria to participate in placements?", | |
| "What counts as a Dream Offer?", | |
| ], | |
| ) | |
| # Running on a Hugging Face Space? The platform sets SPACE_ID. | |
| ON_HF_SPACE = bool(os.environ.get("SPACE_ID")) | |
| # Only build the combined FastAPI app (Gradio UI + /api/query) for the self-host | |
| # / Docker path. On a HF Gradio-SDK Space we hand serving to Gradio's own | |
| # launcher instead (see __main__), so mounting here is unnecessary. | |
| if not ON_HF_SPACE: | |
| app = gr.mount_gradio_app(app, _demo, path="/") | |
| if __name__ == "__main__": | |
| log.info("Starting Placement Policy Advisor on port %d", APP_PORT) | |
| if ON_HF_SPACE: | |
| # Hugging Face Gradio-SDK Space: the platform expects the app to be | |
| # served by Gradio's own launcher and already manages port 7860. Calling | |
| # our own uvicorn.run() here double-binds that port (our Uvicorn finishes | |
| # lifespan startup, then fails to bind because HF's server already holds | |
| # it). ssr_mode=False avoids Gradio 6's separate Node.js SSR server, | |
| # which is unreliable on a constrained free Space. | |
| # NOTE: the token-protected REST API (/api/query) is exposed only in the | |
| # Docker / self-hosted deployment below, not on the Space UI. | |
| _demo.launch(server_name="0.0.0.0", server_port=APP_PORT, ssr_mode=False) | |
| else: | |
| # Self-host / Docker: single-process Uvicorn serving both the Gradio UI | |
| # (at "/") and the REST API. workers=1 is explicit so a platform-injected | |
| # WEB_CONCURRENCY can't spawn extra workers that fight over the port. | |
| uvicorn.run(app, host="0.0.0.0", port=APP_PORT, workers=1) | |