396 lines
14 KiB
Python
396 lines
14 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 pathlib import Path
|
|
|
|
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)
|
|
|
|
|
|
def candidate_base_fields(source):
|
|
return {
|
|
"filename": source["safe_name"],
|
|
"file_path": source["file_path"],
|
|
"content_sha256": source["sha256"],
|
|
"candidate_email": source.get("candidate_email"),
|
|
}
|
|
|
|
|
|
def candidate_failed_fields(source, code, message):
|
|
return {
|
|
**candidate_base_fields(source),
|
|
"status": "failed",
|
|
"error_code": str(code),
|
|
"error_message": message,
|
|
"candidate_name": None,
|
|
"job_title": None,
|
|
"current_company": None,
|
|
"years_experience": None,
|
|
"match_score": None,
|
|
"matched_keywords": [],
|
|
"missing_keywords": [],
|
|
"summary_critique": None,
|
|
"linkedin_url": None,
|
|
}
|
|
|
|
|
|
def candidate_completed_fields(source, result):
|
|
return {
|
|
**candidate_base_fields(source),
|
|
"status": "completed",
|
|
"candidate_name": result.candidate_name,
|
|
"job_title": result.job_title,
|
|
"current_company": result.current_company,
|
|
"years_experience": result.years_experience,
|
|
"match_score": result.match_score,
|
|
"matched_keywords": result.matched_keywords,
|
|
"missing_keywords": result.missing_keywords,
|
|
"summary_critique": result.summary_critique,
|
|
"linkedin_url": None,
|
|
"error_code": None,
|
|
"error_message": None,
|
|
}
|
|
|
|
|
|
def extract_pdf_link_uris(reader) -> list[str]:
|
|
"""Clickable /URI annotations that pypdf's extract_text() never returns.
|
|
|
|
Designer CVs put LinkedIn (and portfolio) behind an icon; the URL lives on
|
|
the annotation, not in the text layer. Appending these after page text is
|
|
what lets linkedin_utils see a profile the recruiter can open.
|
|
"""
|
|
found: list[str] = []
|
|
seen: set[str] = set()
|
|
try:
|
|
pages = reader.pages
|
|
except Exception:
|
|
return found
|
|
for page in pages:
|
|
try:
|
|
annots = page.get("/Annots")
|
|
if annots is None:
|
|
continue
|
|
if hasattr(annots, "get_object"):
|
|
annots = annots.get_object()
|
|
except Exception:
|
|
continue
|
|
if not annots:
|
|
continue
|
|
for annot in annots:
|
|
try:
|
|
obj = annot.get_object() if hasattr(annot, "get_object") else annot
|
|
action = obj.get("/A") if obj is not None else None
|
|
if action is not None and hasattr(action, "get_object"):
|
|
action = action.get_object()
|
|
uri = None
|
|
if action is not None:
|
|
uri = action.get("/URI")
|
|
if uri is None and obj is not None:
|
|
uri = obj.get("/URI")
|
|
if uri is None:
|
|
continue
|
|
value = str(uri).strip()
|
|
if value and value not in seen:
|
|
seen.add(value)
|
|
found.append(value)
|
|
except Exception:
|
|
continue
|
|
return found
|
|
|
|
|
|
@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()
|
|
|
|
|
|
# Mirrors frontend Inbox.jsx sourceFrom — board name is tagged in the To address.
|
|
INBOX_SOURCES = (
|
|
"Microsoft Outlook",
|
|
"Career Portal",
|
|
"Manual CV Upload",
|
|
"LinkedIn",
|
|
"Indeed",
|
|
"Rozee",
|
|
"Mustakbil",
|
|
"Employee Referral",
|
|
"Recruitment Agency",
|
|
"Campus Hiring",
|
|
"Walk-in",
|
|
)
|
|
|
|
|
|
def _letters_only(value: str) -> str:
|
|
return re.sub(r"[^a-z]", "", (value or "").lower())
|
|
|
|
|
|
def source_from_message_to(message_to: str | None) -> str:
|
|
raw = (message_to or "").strip()
|
|
if not raw:
|
|
return "Unknown"
|
|
flat = _letters_only(raw)
|
|
for name in INBOX_SOURCES:
|
|
if _letters_only(name) and _letters_only(name) in flat:
|
|
return name
|
|
return raw.split(",")[0].strip()
|
|
|
|
|
|
def documents_from_message(file_name: str | None, file_path: str | None) -> list[dict]:
|
|
names = [n.strip() for n in (file_name or "").split(",") if n.strip()]
|
|
paths = [p.strip() for p in (file_path or "").split(",") if p.strip()]
|
|
out = []
|
|
for i, name in enumerate(names):
|
|
out.append({"name": name, "path": paths[i] if i < len(paths) else None})
|
|
if not out and paths:
|
|
for path in paths:
|
|
out.append({"name": path.rsplit("/", 1)[-1].rsplit("\\", 1)[-1], "path": path})
|
|
return out
|
|
|
|
|
|
_ATTACHMENTS_ROOT = Path(__file__).resolve().parents[2] / "inbox" / "decoded_attachments"
|
|
|
|
|
|
def contained_download_path(stored_path: str | None) -> Path | None:
|
|
"""Resolve `stored_path` only if it sits inside decoded_attachments.
|
|
|
|
Never follows a client-supplied path. Returns None on any failure so the
|
|
caller can 404 rather than 403 (a 403 would confirm the file exists).
|
|
|
|
Stored paths may be host-absolute Windows paths (see resolve_attachment_path).
|
|
Those fail the containment check against this process's attachments dir; fall
|
|
back to the basename under decoded_attachments, then contain that too.
|
|
"""
|
|
if not stored_path or not str(stored_path).strip():
|
|
return None
|
|
root = _ATTACHMENTS_ROOT.resolve()
|
|
raw = str(stored_path).strip()
|
|
basename = Path(raw.replace("\\", "/")).name
|
|
|
|
def _contained(path: Path) -> Path | None:
|
|
try:
|
|
resolved = path.resolve()
|
|
resolved.relative_to(root)
|
|
except (OSError, RuntimeError, ValueError):
|
|
return None
|
|
if not resolved.is_file():
|
|
return None
|
|
return resolved
|
|
|
|
try:
|
|
candidate = Path(raw)
|
|
if not candidate.is_absolute():
|
|
candidate = root / basename
|
|
hit = _contained(candidate)
|
|
if hit is not None:
|
|
return hit
|
|
except (OSError, RuntimeError, ValueError):
|
|
pass
|
|
if basename:
|
|
return _contained(root / basename)
|
|
return None
|
|
|
|
|
|
# Same system prefixes inbox/models._is_linkable_sender rejects — anything we
|
|
# accept here must remain linkable when insert_email creates the users row.
|
|
_SKIP_SENDER_PREFIXES = (
|
|
"noreply", "no-reply", "donotreply", "do-not-reply",
|
|
"mailer-daemon", "postmaster", "bounce",
|
|
)
|
|
_ROLE_LOCAL_PARTS = frozenset({
|
|
"info", "hr", "careers", "jobs", "admin", "support", "contact", "sales",
|
|
"recruitment", "office", "team", "hello", "enquiry", "inquiry", "recruit",
|
|
"talent", "hiring", "apply", "applications", "webmaster", "helpdesk",
|
|
})
|
|
_EMAIL_RE = re.compile(
|
|
r"(?i)\b([a-z0-9][a-z0-9._%+\-]{0,63})@([a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?"
|
|
r"(?:\.[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?)+)\b"
|
|
)
|
|
_PHONE_RE = re.compile(r"(?:\+?\d[\d\s\-().]{7,}\d)")
|
|
_LABEL_RE = re.compile(r"(?i)\b(?:e[\-\s]?mail|mail[\s\-]?id|contact)\b")
|
|
_REF_HEADING_RE = re.compile(r"(?i)^\s*(?:references?|referees?)\b")
|
|
_REF_MENTION_RE = re.compile(
|
|
r"(?i)\b(?:reference|referee|manager|supervisor|contact\s+person)\b"
|
|
)
|
|
_HEADER_LINE_COUNT = 12
|
|
_MIN_ACCEPT_SCORE = 3
|
|
|
|
|
|
def _presumed_name_tokens(lines: list[str]) -> list[str]:
|
|
"""First non-empty line with 2+ alpha tokens and no digits/@ — CV name header."""
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if not stripped:
|
|
continue
|
|
if any(ch.isdigit() for ch in stripped) or "@" in stripped:
|
|
continue
|
|
tokens = [_letters_only(t) for t in re.split(r"\s+", stripped) if _letters_only(t)]
|
|
if len(tokens) >= 2:
|
|
return tokens
|
|
return []
|
|
|
|
|
|
def _email_local_ok(local: str) -> bool:
|
|
lowered = (local or "").lower()
|
|
if lowered in _ROLE_LOCAL_PARTS:
|
|
return False
|
|
return not lowered.startswith(_SKIP_SENDER_PREFIXES)
|
|
|
|
|
|
def extract_candidate_email(text: str) -> tuple[str | None, list[str]]:
|
|
"""Pick the candidate's own email from CV text, or None when ambiguous/absent.
|
|
|
|
Returns ``(best, all_plausible)``. Ambiguity is intentional — a wrong guess
|
|
would create a user under a stranger's address and mail them a confirm link.
|
|
"""
|
|
if not text or not text.strip():
|
|
return None, []
|
|
|
|
lines = text.splitlines()
|
|
name_tokens = _presumed_name_tokens(lines)
|
|
in_references = False
|
|
scored: list[tuple[int, int, str]] = [] # (score, first_line_idx, email)
|
|
seen: dict[str, int] = {} # lower email -> index in scored
|
|
|
|
for idx, line in enumerate(lines):
|
|
if _REF_HEADING_RE.search(line):
|
|
in_references = True
|
|
for match in _EMAIL_RE.finditer(line):
|
|
local, domain = match.group(1), match.group(2)
|
|
if not _email_local_ok(local):
|
|
continue
|
|
email = f"{local}@{domain}".lower()
|
|
score = 0
|
|
if idx < _HEADER_LINE_COUNT:
|
|
score += 3
|
|
local_letters = _letters_only(local)
|
|
if local_letters and any(
|
|
tok and (tok in local_letters or local_letters in tok)
|
|
for tok in name_tokens
|
|
):
|
|
score += 3
|
|
if _LABEL_RE.search(line) or _PHONE_RE.search(line):
|
|
score += 1
|
|
if in_references:
|
|
score -= 5
|
|
if _REF_MENTION_RE.search(line):
|
|
score -= 3
|
|
|
|
if email in seen:
|
|
prev_i = seen[email]
|
|
prev_score, _, _ = scored[prev_i]
|
|
if score > prev_score:
|
|
scored[prev_i] = (score, idx, email)
|
|
continue
|
|
seen[email] = len(scored)
|
|
scored.append((score, idx, email))
|
|
|
|
if not scored:
|
|
return None, []
|
|
|
|
scored.sort(key=lambda t: (-t[0], t[1]))
|
|
plausible = [email for _, _, email in scored]
|
|
top_score, _, top_email = scored[0]
|
|
runner_up = scored[1][0] if len(scored) > 1 else None
|
|
if top_score < _MIN_ACCEPT_SCORE:
|
|
return None, plausible
|
|
if runner_up is not None and top_score <= runner_up:
|
|
return None, plausible
|
|
return top_email, plausible
|
|
|