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

182 lines
6.2 KiB
Python

"""CV text cleanup helpers for the PDF extractor.
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.
"""
from __future__ import annotations
import re
from job.candidate.decorators import despace_line, normalize_unicode
@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
# 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