HR-ATS-Portal/backend/job/candidate/plugins.py

110 lines
4.1 KiB
Python

"""CV text cleanup, scoring-JD builder, and the ATS scorer singleton.
Pure module: no FastAPI imports and no HTTPException.
Designer-made resumes position every glyph individually, so pypdf hands back
"S K I L L S" instead of "SKILLS". In that layout a single space is glyph
padding and a run of two or more spaces is the real word gap, which is what
the `despace_line` decorator keys off to rebuild readable lines.
The scoring pieces reuse the bulk-ats engine, installed editable from the repo
root (`pip install -e ..` -> `import app.*`), and share llm_setup's process-wide
AsyncOpenAI client rather than opening a second connection pool.
"""
from __future__ import annotations
import re
from app.core.config import Settings, get_settings
from app.services.llm import OpenAIScorer
from dotenv import load_dotenv
from job.candidate.decorators import despace_line, normalize_unicode
load_dotenv()
# Backend-local per-candidate error code for cases the bulk-ats engine never sees
# (its uploads always have bytes; inbox attachments can vanish from disk).
FILE_NOT_FOUND = "FILE_NOT_FOUND"
_scorer: OpenAIScorer | None = None
def get_scoring_settings() -> Settings:
"""Validated scoring knobs (model family, token floor, size limits).
Reads real env vars, which load_dotenv() above has populated from the nearest
.env (backend/.env, else the repo root). Shared names (OPENAI_MODEL,
OPENAI_MAX_OUTPUT_TOKENS) therefore match what llm_setup uses.
"""
return get_settings()
def get_scorer() -> OpenAIScorer:
"""Process-wide scorer over llm_setup's shared AsyncOpenAI client.
Lazy so that a missing/invalid OPENAI configuration surfaces on the first
scoring request, not at import; llm_setup.init_llm() in the app lifespan has
normally created and verified the client before this ever runs.
"""
global _scorer
if _scorer is None:
from llm_setup import get_client
settings = get_scoring_settings()
_scorer = OpenAIScorer(
get_client(),
model=settings.openai_model,
max_output_tokens=settings.openai_max_output_tokens,
effort=settings.openai_effort,
enable_cache=settings.openai_enable_prompt_cache,
)
return _scorer
def build_job_description(job) -> str:
"""Deterministic scoring JD from a JobPosts row.
Byte-stable per job: derived only from stored column values, in fixed order,
because OpenAI prompt caching works on exact prefix match — one volatile byte
(an id, a timestamp) would stop the whole batch from reusing the cache.
Deliberately excludes post_text (hashtags, salary, LinkedIn formatting) and
salary; list order is taken as stored.
"""
lines = [f"Job Title: {job.title}"]
if job.employment_type:
lines.append(f"Employment Type: {job.employment_type}")
if job.location:
lines.append(f"Location: {job.location}")
if job.experience_min is not None and job.experience_max is not None:
lines.append(f"Experience Required: {job.experience_min}-{job.experience_max} years")
elif job.experience_min is not None:
lines.append(f"Experience Required: {job.experience_min}+ years")
elif job.experience_max is not None:
lines.append(f"Experience Required: up to {job.experience_max} years")
if job.description:
lines += ["", "Description:", job.description.strip()]
if job.requirements:
lines += ["", "Mandatory Requirements:"]
lines += [f"- {item}" for item in job.requirements]
if job.optional_skills:
lines += ["", "Preferred (nice to have):"]
lines += [f"- {item}" for item in job.optional_skills]
return "\n".join(lines)
@normalize_unicode
@despace_line
def normalize_spaced_text(text) -> str:
"""Turn raw pypdf output into readable text, leaving normal lines untouched.
The decorators have already folded the unicode and rebuilt the glyph-padded
lines; what is left is the whitespace tidy-up that every line wants.
"""
if not text:
return ""
lines = [re.sub(r" {2,}", " ", line).strip() for line in text.splitlines()]
return re.sub(r"\n{3,}", "\n\n", "\n".join(lines)).strip()