"""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 # One definition, two extractors. The bulk-ATS engine and this recruiting path # both read CVs with pypdf and both broke the same way on glyph-fragmented # files, so the repair lives in the package that owns PDF handling and is # re-exported here for the callers that import it from this module. from app.services.pdf import extract_pdf_text, is_glyph_fragmented # noqa: F401 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