131 lines
4.9 KiB
Python
131 lines
4.9 KiB
Python
"""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]
|