File size: 4,410 Bytes
c1dbdc6
12fa3c2
c1dbdc6
12fa3c2
c1dbdc6
12fa3c2
96fe8d8
 
c1dbdc6
 
12fa3c2
022fb5a
c1dbdc6
7770c5f
 
 
 
 
 
 
 
12fa3c2
c1dbdc6
 
7770c5f
96fe8d8
 
c1dbdc6
 
96fe8d8
 
c835700
 
c1dbdc6
 
96fe8d8
 
c1dbdc6
12fa3c2
96fe8d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12fa3c2
96fe8d8
 
12fa3c2
96fe8d8
12fa3c2
96fe8d8
 
12fa3c2
c1dbdc6
 
96fe8d8
 
7770c5f
 
 
 
 
 
 
 
 
 
 
 
20ae104
c1dbdc6
96fe8d8
c1dbdc6
 
 
 
12fa3c2
c1dbdc6
 
 
 
 
 
 
 
 
 
 
 
12fa3c2
c1dbdc6
 
 
022fb5a
c1dbdc6
 
 
12fa3c2
96fe8d8
12fa3c2
 
 
96fe8d8
12fa3c2
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
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")