"""Bounded batch orchestration. Two properties this module exists to guarantee: * Concurrency is bounded by a semaphore. There is no unbounded ``asyncio.gather``. * The shared job-description prefix is cached before the batch fans out. A cache entry only becomes readable once the first response has begun, so launching all candidates at once means every one of them pays full input price and none reads the cache. The first candidate is therefore awaited alone, priming the prefix for the rest. ``score_batch`` returns results in **input order**. Sorting is :func:`sort_results`, applied by the caller once extraction failures have been merged back in. """ from __future__ import annotations import asyncio import logging import re import time from app.core.errors import classify_error from app.models.scoring import ATSScore, CandidateResult, CompletedCandidate, FailedCandidate from app.services.llm import Scorer from app.services.pdf import ExtractedResume logger = logging.getLogger(__name__) _SEPARATORS = re.compile(r"[\s\-_/.]+") def _flatten(value: str) -> str: return _SEPARATORS.sub("", value.casefold()) def verify_matched_keywords(score: ATSScore, resume_text: str) -> tuple[ATSScore, int]: """Drop matched keywords that have no occurrence in the resume text. A matched keyword is an evidence pointer, so it must actually occur in the resume. The model occasionally canonicalizes a skill into a name the resume never uses, or invents one outright; either way a recruiter would be shown evidence that is not there. Matching is case-, separator- and trailing-plural-insensitive ("CI/CD" ~ "ci cd", "vector databases" ~ "Vector Database") so the resume's own spelling always survives. ``missing_keywords`` name JD requirements, not resume evidence, and are deliberately not filtered. Returns the (possibly copied) score and the drop count. """ haystack = _flatten(resume_text) kept: list[str] = [] dropped = 0 for keyword in score.matched_keywords: needle = _flatten(keyword) if needle in haystack or (needle.endswith("s") and needle[:-1] in haystack): kept.append(keyword) else: dropped += 1 if not dropped: return score, 0 return score.model_copy(update={"matched_keywords": kept}), dropped def _sort_key(result: CandidateResult) -> tuple[int, int]: """Completed first by descending score; failures last. ``sorted`` is stable and ``score_batch`` preserves input order, so ties and failures both retain their original upload order. """ if isinstance(result, CompletedCandidate): return (0, -result.match_score) return (1, 0) def sort_results(results: list[CandidateResult]) -> list[CandidateResult]: return sorted(results, key=_sort_key) async def score_batch( items: list[ExtractedResume], *, job_description: str, scorer: Scorer, concurrency: int, ) -> list[CandidateResult]: """Score every extracted resume, isolating per-candidate failures.""" if not items: return [] semaphore = asyncio.Semaphore(concurrency) async def score_one(item: ExtractedResume) -> CandidateResult: async with semaphore: started = time.perf_counter() try: score = await scorer.score(job_description, item.text) score, dropped_keywords = verify_matched_keywords(score, item.text) except asyncio.CancelledError: # Never swallow cancellation. raise except Exception as exc: error_code, error_message = classify_error(exc) logger.exception( "candidate_scoring_failed", extra={ "file_name": item.filename, "candidate_id": item.candidate_id, "error_code": error_code, "duration_ms": round((time.perf_counter() - started) * 1000), }, ) return FailedCandidate( filename=item.filename, error_code=error_code, error_message=error_message, ) logger.info( "candidate_scored", extra={ "file_name": item.filename, "candidate_id": item.candidate_id, "status": "completed", "duration_ms": round((time.perf_counter() - started) * 1000), "dropped_keywords": dropped_keywords, }, ) return CompletedCandidate(filename=item.filename, **score.model_dump()) # Prime the shared prefix cache on the first candidate, then fan out. first = await score_one(items[0]) rest = await asyncio.gather(*(score_one(item) for item in items[1:])) return [first, *rest]