| 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 |
|
|
| |
| |
| 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), |
| ) |
| |
| _qdrant_client.create_payload_index( |
| collection_name=settings.collection_name, |
| field_name="session_id", |
| field_schema=PayloadSchemaType.UUID, |
| ) |
| |
| _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 |
|
|
| |
| |
| 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: |
| |
| 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") |
|
|