"""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"}