import os import logging from contextlib import asynccontextmanager from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from qdrant_client import QdrantClient from qdrant_client.models import Distance, VectorParams, PayloadSchemaType from src.config import get_settings from src.models import JobDescription, Candidate, MatchResult, Session from src.routers import jds, candidates, matching, sessions, admin # Configure root logger so ALL module loggers (stage2, reranker, etc.) # emit to stdout — critical for error visibility on Hugging Face logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", handlers=[logging.StreamHandler()], ) logger = logging.getLogger(__name__) settings = get_settings() _qdrant_client: QdrantClient | None = None _qdrant_ready: bool = False def get_qdrant() -> QdrantClient: return _qdrant_client @asynccontextmanager async def lifespan(app: FastAPI): global _qdrant_client, _qdrant_ready _qdrant_client = QdrantClient(url=settings.qdrant_url, api_key=settings.qdrant_api_key) try: existing = [c.name for c in _qdrant_client.get_collections().collections] if settings.collection_name not in existing: _qdrant_client.create_collection( collection_name=settings.collection_name, vectors_config=VectorParams(size=settings.vector_size, distance=Distance.COSINE), ) # Create indexing for the session_id to allow fast filtering _qdrant_client.create_payload_index( collection_name=settings.collection_name, field_name="session_id", field_schema=PayloadSchemaType.UUID, ) # Create indexing for years_of_experience for range filtering _qdrant_client.create_payload_index( collection_name=settings.collection_name, field_name="years_of_experience", field_schema=PayloadSchemaType.FLOAT, ) _qdrant_ready = True logger.info("Qdrant connected — collection '%s' ready", settings.collection_name) except Exception as exc: _qdrant_ready = False logger.warning( "Qdrant unavailable at startup (%s). " "The API will start but vector search will fail until Qdrant is reachable.", exc, ) app.state.qdrant = _qdrant_client app.state.qdrant_ready = _qdrant_ready # Pre-load the lightweight CrossEncoder (~80MB) eagerly at startup so the # first matching request doesn't pay the cold-start download cost. try: import asyncio from src.ml.reranker import _get_reranker logger.info("Warming up Neural CrossEncoder reranker...") await asyncio.to_thread(_get_reranker) logger.info("Neural CrossEncoder loaded and ready!") except Exception as warm_exc: # Log but don't crash — matching will attempt lazy-load on first request logger.warning(f"Reranker warm-up failed (will retry on first request): {warm_exc}") yield _qdrant_client.close() app = FastAPI( title="TalentPulse — AI Candidate Matching", description="Two-stage retrieval + reranking pipeline for matching JDs against candidate sessions", version="1.0.0", lifespan=lifespan, ) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.include_router(sessions.router, prefix="/api/sessions", tags=["Sessions"]) app.include_router(jds.router, prefix="/api/jds", tags=["Job Descriptions"]) app.include_router(candidates.router, prefix="/api/candidates", tags=["Candidates"]) app.include_router(matching.router, prefix="/api/match", tags=["Matching"]) app.include_router(admin.router, prefix="/api/admin", tags=["Admin"]) @app.get("/health") async def health(request: "Request"): qdrant_ok = getattr(request.app.state, "qdrant_ready", False) return { "status": "ok", "version": "1.0.0", "qdrant": "connected" if qdrant_ok else "unavailable", } static_dir = os.path.join(os.path.dirname(__file__), "static") if os.path.isdir(static_dir): app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")