182 lines
6.2 KiB
Python
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
|
|
|