""" External-ATS coverage simulator (Jobalytics/Simplify-style breadth). WHY THIS EXISTS ─────────────── Our internal scorer extracts keywords from a NARROW PM taxonomy and scores the resume against that same narrow set → it easily reports 90%+. External checkers (Jobalytics/Simplify) extract a MUCH broader set (40-46 terms incl. domain words, JD-specific responsibilities, soft skills) → the same resume scores ~54%. The internal number is therefore NOT a valid success signal for the external goal. This module computes a broad, Jobalytics-style EXPECTED keyword set from the JD, and measures coverage against the *re-parsed exported resume text* (the only truth). Used to (a) decide what to physically place in Maximum ATS Mode and (b) gate/report readiness on real external-style coverage, not internal score. It is intentionally broader than `extract_jd_keywords` (which stays skills-only to keep the honest default pipeline clean). The broad set is only used in Maximum ATS Mode / external-feedback repair, where the user has explicitly opted into aggressive coverage. """ from __future__ import annotations import re from typing import Dict, List from .ats_scorer import _kw_in_text, extract_jd_keywords # Words that are never standalone keywords (checkers penalise/ignore them). _STOP = { "the", "and", "for", "with", "you", "your", "our", "are", "will", "have", "has", "had", "this", "that", "these", "those", "from", "into", "out", "off", "who", "what", "when", "where", "why", "how", "all", "any", "can", "may", "should", "would", "could", "must", "able", "etc", "per", "via", "not", "but", "they", "them", "their", "his", "her", "its", "she", "him", "was", "were", "been", "being", "more", "most", "such", "than", "then", "also", "about", "across", "within", "while", "each", "other", "some", "many", "well", "very", "much", "like", "just", "only", "even", "both", "over", "under", "between", "during", "including", "include", "includes", "looking", "join", "join us", "work", "working", "team", "teams", "role", "job", "company", "candidate", "candidates", "ideal", "great", "good", "strong", "years", "year", "experience", "experiences", "plus", "preferred", "required", "requirement", "responsibilities", "qualifications", "skills", "ability", "knowledge", "understanding", "familiarity", "proficiency", "we", "us", "is", "in", "on", "of", "to", "at", "as", "an", "or", "be", "it", "by", "a", "i", } # Vague filler — never a keyword (mirrors jd_analyzer._BUZZWORDS intent). _FILLER = { "innovation", "innovative", "solution", "solutions", "tools", "tool", "ownership", "synergy", "dynamic", "passionate", "motivated", "self-starter", "results-driven", "detail-oriented", "team player", "track record", "expertise", "best practices", "thought leadership", "fast-paced", "cutting-edge", "world-class", "robust", "seamless", "holistic", "leverage", "excellence", "proven", "successful", "goals", "productivity", "reinvent", "reinvention", "mission", "vision", "culture", "value", "values", "impact", "environment", "opportunity", "opportunities", "responsibility", } # UI/CTA/marketing phrases injected by checker overlays (Simplify/Jobalytics/…) # — never genuine JD keywords. Exact lowercased matches. _UI_NOISE = frozenset({ "show match details", "people clicked apply", "month free trial", "free trial", "actively reviewing applicants", "message hiring managers", "get personalized cover letter", "get insider access", "members use premium", "recruiting bond", "visit website", "lacs pa", "help me stand", "tailor my resume", "uses advanced ai", "try premium", "easy apply", "see how you compare", "am i a good fit", "save job", "apply now", "show more", "show less", "sign in", "join now", }) # CTA/marketing lead tokens: a multi-word gram starting with one is UI noise. _UI_LEAD = { "show", "get", "try", "visit", "apply", "save", "click", "join", "sign", "message", "see", "tailor", "unlock", "upgrade", "start", } # Marketing substrings that mark a gram as UI noise wherever they appear. _UI_SUBSTR = ( "free trial", "premium", "hiring manager", "cover letter", "clicked apply", "match details", "lacs pa", ) # Generic JD action-verbs / weak edge words trimmed from the START/END of a # maximal run so an extracted phrase reads like a SKILL, not a sentence fragment # ("lead end-to-end product lifecycle" → "end-to-end product lifecycle"). _EDGE_TRIM = { "lead", "leads", "leading", "led", "drive", "drives", "driving", "drove", "build", "builds", "building", "built", "manage", "manages", "managing", "managed", "monitor", "monitors", "monitoring", "create", "creates", "creating", "created", "develop", "develops", "developing", "developed", "ensure", "ensures", "ensuring", "own", "owns", "owning", "owned", "define", "defines", "defining", "defined", "track", "tracks", "tracking", "tracked", "deliver", "delivers", "delivering", "delivered", "leverage", "leverages", "utilize", "utilizes", "utilizing", "use", "uses", "using", "used", "conduct", "conducts", "support", "supports", "supporting", "collaborate", "grow", "grows", "growing", "measure", "measures", "measuring", "interpret", "watch", "help", "helps", "helping", "based", "through", "across", "drive", "make", "makes", "making", "made", "run", "runs", "running", "set", "sets", "shape", "shapes", "shaping", "foster", "fosters", "integrate", "integrates", "maximize", "optimize", "optimizes", "validate", "validates", "meets", "meet", "need", "needs", "needed", "require", "requires", "required", "seeking", "seek", "want", "wants", "provide", "provides", "providing", "perform", "performs", "performing", "identify", "identifies", "execute", "executes", } def _emit_run(run: List[str], add) -> None: """Trim generic action-verbs from a maximal non-stop run, then emit it (long runs split into non-overlapping <=4-word chunks).""" r = list(run) while r and r[0] in _EDGE_TRIM: r = r[1:] while r and r[-1] in _EDGE_TRIM: r = r[:-1] if not r: return i = 0 while i < len(r): chunk = r[i:i + 4] gram = " ".join(chunk) if len(gram) <= 40: add(gram) i += 4 def _clean(term: str) -> str: t = re.sub(r"[^\w\s/+.\-]", "", term or "").strip().lower() # Drop leading/trailing sentence punctuation so grams that end a sentence # ("initiatives.", "optimization.") aren't kept as distinct dotted tokens. return t.strip(".-/ ").strip() def _is_ui_noise(t: str) -> bool: """True if `t` is a checker-overlay CTA / marketing phrase, not a JD term.""" t = (t or "").strip() if not t: return False if t in _UI_NOISE: return True words = t.split() if len(words) > 1 and words[0] in _UI_LEAD: return True if any(sub in t for sub in _UI_SUBSTR): return True return False def _is_term_like(t: str) -> bool: """A token/phrase that reads like a real skill/responsibility/domain term. Phase 9 (R21): intentionally WIDENED — "if in doubt, pick it up." We keep only genuine-noise guards (stopwords, filler, UI/CTA overlay strings, pure digits, too-short, >4 words). There is NO count cap; the honesty gate lives in decide_includable_terms (specialty/cert/seniority/blocked), not here. """ t = t.strip() if not t or t in _STOP or t in _FILLER: return False if _is_ui_noise(t): return False words = t.split() if len(words) > 4: return False if len(t) < 3: return False if t.isdigit(): return False # All words must be non-stop. for w in words: if w in _STOP: return False return True def extract_external_keywords(jd_text: str, extra: List[str] = None) -> List[str]: """Broad, Jobalytics-style expected keyword set for a JD. Union of: the reliable taxonomy floor (`extract_jd_keywords`), curated Maximum-ATS safe terms present in the JD, and term-like 1-3 word phrases pulled from the JD body (filtered against stopwords/filler). De-duplicated, lowercase. `extra` (e.g. pasted external missing/matched terms) is unioned in. """ jd_text = jd_text or "" jd_low = jd_text.lower() out: List[str] = [] seen = set() def _add(term: str): t = _clean(term) if t and t not in seen and _is_term_like(t): seen.add(t) out.append(t) # 1. Reliable taxonomy floor. for k in extract_jd_keywords(jd_text): _add(k) # 2. Curated safe vocabulary that actually appears in this JD. try: from config import MAXIMUM_ATS_SAFE_TERMS as _SAFE except Exception: _SAFE = set() for t in _SAFE: if t in jd_low: _add(t) # 3. Term-like phrases from the JD body. Instead of a sliding window (which # emits a gram at EVERY offset → many overlapping near-duplicates), extract # MAXIMAL RUNS of consecutive non-stop/non-filler words within each clause. # Stopwords/filler naturally split prose into clean skill phrases. Leading/ # trailing generic action-verbs are trimmed so a phrase reads like a skill # ("lead end-to-end product lifecycle" → "end-to-end product lifecycle"). # Uncapped in COUNT (R21); the subsumption pass removes residual overlaps. for seg in re.split(r"[.;:!?\n•‣●]", jd_low): seg_words = re.findall(r"[a-zA-Z][a-zA-Z\-/+.]{1,}", seg) run: List[str] = [] for w in seg_words: if w in _STOP or w in _FILLER: _emit_run(run, _add) run = [] else: run.append(w) _emit_run(run, _add) # 4. Pasted external terms (ground truth from a checker). for t in (extra or []): _add(t) # 5. Subsumption dedup (R21): drop any term that is a whole-phrase substring of # a longer kept term, so overlapping grams collapse to the longest unique # phrase ("end-to-end product", "product lifecycle" → "end-to-end product # lifecycle"). Coverage is preserved (the longer phrase contains the shorter, # so it still matches in the resume) and the keyword list has NO duplicates. return _dedupe_subsumed(out) def _dedupe_subsumed(terms: List[str]) -> List[str]: """Remove terms wholly contained (whole-word) in a longer retained term. Preserves first-seen order of the survivors.""" uniq = list(dict.fromkeys(terms)) order = {t: i for i, t in enumerate(uniq)} kept: List[str] = [] for t in sorted(uniq, key=len, reverse=True): # longest first tl = t.lower() subsumed = False for k in kept: if t == k: continue if re.search(r"(? Dict: """Measure coverage of `expected` terms against the exported resume text. Returns {expected, found, pct, present, missing}. Matching uses the same word-boundary/phrase logic as scoring (`_kw_in_text`). """ text = (exported_text or "").lower() exp = [] seen = set() for t in expected: tl = _clean(t) if tl and tl not in seen: seen.add(tl) exp.append(tl) present = [t for t in exp if _kw_in_text(t, text)] missing = [t for t in exp if t not in present] total = len(exp) return { "expected": total, "found": len(present), "pct": int(round(100 * len(present) / max(1, total))), "present": present, "missing": missing, }