176 lines
5.6 KiB
Python
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"}
|