resumematch-api / apps /api /main.py
ayushgupta7777's picture
Deploy ResumeMatch Lab API (engine + 9,014-job corpus)
5a2849e
Raw
History Blame Contribute Delete
4.62 kB
"""ResumeMatch Lab β€” FastAPI backend.
Endpoints:
GET /health β€” liveness + corpus/model info
POST /compare β€” multipart: two resume files (or form text) -> full report
POST /compare/text β€” JSON: {resume_a, resume_b} plain text -> full report
Reuses the same engine as the Streamlit app (core/ + stats/), so both UIs share
one source of truth. Deploy on Hugging Face Spaces (Docker) β€” see apps/api/Dockerfile.
"""
from __future__ import annotations
import os
import sys
from contextlib import asynccontextmanager
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from fastapi import FastAPI, File, Form, HTTPException, UploadFile # noqa: E402
from fastapi.concurrency import run_in_threadpool
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
from pydantic import BaseModel # noqa: E402
from apps.api.chatbot import GroqError, groq_chat, prepare_messages
from apps.api.serialize import report_to_dict # noqa: E402
from core.data import MODEL_NAME, load_corpus # noqa: E402
from core.scoring import compare_resumes # noqa: E402
from core.types import MIN_SCORABLE_CHARS, ResumeText # noqa: E402
from parsers.resume import parse_resume # noqa: E402
from stats.engine import analyze # noqa: E402
@asynccontextmanager
async def lifespan(app: FastAPI):
load_corpus() # warm the corpus cache so the first request is fast
yield
app = FastAPI(
title="ResumeMatch Lab API",
version="1.0.0",
description="A/B test two resume variants against the full Indian tech job corpus.",
lifespan=lifespan,
)
_origins = [o.strip() for o in os.getenv("CORS_ORIGINS", "*").split(",") if o.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=_origins or ["*"],
allow_methods=["GET", "POST"],
allow_headers=["*"],
allow_credentials=False,
)
class TextPair(BaseModel):
resume_a: str
resume_b: str
def _run(a: ResumeText, b: ResumeText) -> dict:
if not a.is_scorable or not b.is_scorable:
raise HTTPException(
status_code=422,
detail=f"A resume parsed to under {MIN_SCORABLE_CHARS} characters; "
"cannot score. Please provide fuller text.",
)
corpus = load_corpus()
scoring = compare_resumes(a, b, corpus)
report = analyze(scoring, corpus, a.text, b.text)
return report_to_dict(report, scoring, a, b)
async def _resume_from(file: UploadFile | None, text: str | None, slot: str) -> ResumeText:
if file is not None:
data = await file.read()
if data:
return parse_resume(data=data, filename=file.filename)
if text and text.strip():
return parse_resume(raw_text=text)
raise HTTPException(status_code=422, detail=f"Provide resume {slot} as a file or text.")
class ChatBody(BaseModel):
messages: list[dict]
result: dict | None = None
page: str | None = None
@app.post("/chat")
async def chat(body: ChatBody) -> dict:
msgs = prepare_messages(body.messages)
if not msgs:
raise HTTPException(status_code=422, detail="Send at least one user message.")
try:
reply = await run_in_threadpool(groq_chat, msgs, body.result, body.page)
except GroqError as e:
reason = str(e)
if reason == "unconfigured":
raise HTTPException(
status_code=503,
detail="The assistant isn't configured on this deployment.",
) from e
if reason == "rate_limited":
raise HTTPException(
status_code=429, detail="Assistant is busy β€” try again shortly."
) from e
raise HTTPException(status_code=502, detail="Assistant is temporarily unavailable.") from e
return {"reply": reply}
@app.get("/health")
def health() -> dict:
corpus = load_corpus()
return {
"status": "ok",
"n_jobs": corpus.n_jobs,
"n_clusters": corpus.n_clusters,
"model": MODEL_NAME,
"clusters": list(corpus.cluster_names.values()),
}
@app.post("/compare")
async def compare(
resume_a: UploadFile | None = File(default=None),
resume_b: UploadFile | None = File(default=None),
resume_a_text: str | None = Form(default=None),
resume_b_text: str | None = Form(default=None),
) -> dict:
a = await _resume_from(resume_a, resume_a_text, "A")
b = await _resume_from(resume_b, resume_b_text, "B")
return _run(a, b)
@app.post("/compare/text")
def compare_text(body: TextPair) -> dict:
return _run(parse_resume(raw_text=body.resume_a), parse_resume(raw_text=body.resume_b))