HR-ATS-Portal/app/api/routes.py

176 lines
5.6 KiB
Python

"""HTTP endpoints. Handlers stay thin: validate, delegate, assemble.
Validation order matters. The resume count is checked before any file body is read, so
an over-limit batch is rejected without buffering megabytes of PDFs.
Where a failure lands:
* Extension / declared MIME type wrong -> 415, whole batch rejected. This is a
malformed request, not a candidate outcome.
* Signature, parse, encryption, or empty-text failure -> per-candidate failure with a
200 batch response. One unreadable resume must not sink the other 49.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, File, Form, Request, UploadFile
from app.core.config import Settings
from app.core.errors import (
ATSError,
InvalidRequestError,
PayloadTooLargeError,
UnprocessableFieldError,
UnsupportedFileTypeError,
)
from app.core.logging import request_id_var
from app.models.scoring import (
CandidateResult,
CompletedCandidate,
FailedCandidate,
ScoreResponse,
)
from app.services.llm import Scorer
from app.services.pdf import ExtractedResume, extract_resume, sanitize_filename
from app.services.scoring import score_batch, sort_results
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1", tags=["scoring"])
# A .pdf extension is required regardless; browsers and CLIs disagree on the MIME type
# they send, so the declared type is a weak signal and the %PDF- signature is the real
# check (see app.services.pdf).
_ALLOWED_CONTENT_TYPES = frozenset(
{
"application/pdf",
"application/x-pdf",
"application/octet-stream",
"binary/octet-stream",
"",
}
)
_READ_CHUNK = 64 * 1024
def get_settings_dep(request: Request) -> Settings:
settings: Settings = request.app.state.settings
return settings
def get_scorer(request: Request) -> Scorer:
scorer: Scorer = request.app.state.scorer
return scorer
async def _read_capped(upload: UploadFile, limit: int) -> bytes:
"""Read an upload, aborting as soon as it exceeds ``limit`` bytes."""
chunks: list[bytes] = []
total = 0
while True:
chunk = await upload.read(_READ_CHUNK)
if not chunk:
break
total += len(chunk)
if total > limit:
raise PayloadTooLargeError(f"{upload.filename!r} exceeds the size limit")
chunks.append(chunk)
return b"".join(chunks)
def _validate_upload_types(resumes: list[UploadFile]) -> None:
for upload in resumes:
name = (upload.filename or "").lower()
content_type = (upload.content_type or "").lower().split(";")[0].strip()
if not name.endswith(".pdf") or content_type not in _ALLOWED_CONTENT_TYPES:
raise UnsupportedFileTypeError(f"{upload.filename!r} is not a PDF")
@router.post("/score", response_model=ScoreResponse)
async def score_resumes(
job_description: Annotated[str, Form()],
resumes: Annotated[list[UploadFile], File()],
settings: Annotated[Settings, Depends(get_settings_dep)],
scorer: Annotated[Scorer, Depends(get_scorer)],
) -> ScoreResponse:
jd = job_description.strip()
if not jd:
raise InvalidRequestError("job_description is blank")
if len(jd) > settings.max_jd_chars:
raise UnprocessableFieldError("job_description exceeds max_jd_chars")
if not resumes:
raise InvalidRequestError("no resumes supplied")
# Enforced before any body is read.
if len(resumes) > settings.max_resumes_per_request:
raise PayloadTooLargeError("too many resumes in one request")
_validate_upload_types(resumes)
# slot -> result, so extraction failures keep their upload position when merged
# back with scored candidates.
results_by_slot: dict[int, CandidateResult] = {}
extracted: list[tuple[int, ExtractedResume]] = []
for slot, upload in enumerate(resumes):
safe_name = sanitize_filename(upload.filename)
data = await _read_capped(upload, settings.max_pdf_size_bytes)
try:
# pypdf is synchronous and CPU-bound; keep it off the event loop.
resume = await asyncio.to_thread(
extract_resume, data, safe_name, settings.max_resume_chars
)
except ATSError as exc:
logger.info(
"pdf_extraction_failed",
extra={"file_name": safe_name, "error_code": exc.error_code},
)
results_by_slot[slot] = FailedCandidate(
filename=safe_name,
error_code=exc.error_code,
error_message=exc.public_message,
)
else:
extracted.append((slot, resume))
scored = await score_batch(
[resume for _, resume in extracted],
job_description=jd,
scorer=scorer,
concurrency=settings.scoring_concurrency,
)
for (slot, _), result in zip(extracted, scored, strict=True):
results_by_slot[slot] = result
ordered = [results_by_slot[slot] for slot in range(len(resumes))]
final = sort_results(ordered)
succeeded = sum(1 for item in final if isinstance(item, CompletedCandidate))
logger.info(
"batch_completed",
extra={
"total": len(final),
"succeeded": succeeded,
"failed": len(final) - succeeded,
"concurrency": settings.scoring_concurrency,
},
)
return ScoreResponse(
request_id=request_id_var.get(),
total=len(final),
succeeded=succeeded,
failed=len(final) - succeeded,
results=final,
)
@router.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}