110 lines
4.1 KiB
Python
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()
|
|
|