Spaces:
Sleeping
Sleeping
| """ | |
| 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, PM_SKILL_TAXONOMY, GENERIC_PROFESSIONAL_VOCAB, | |
| ) | |
| # 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", | |
| } | |
| # Prose tokens that survive atomic_keywords() decomposition but are NOT genuine | |
| # ATS-relevant skills. Dropped from the V2 scoring DENOMINATOR by | |
| # skill_relevant_filter(). DESIGN: a BLOCKLIST (not an allowlist) so unfamiliar | |
| # tool names and niche skills still survive (avoids the Phase-5 self-grading trap). | |
| # Do NOT add "logistics" β it is a legitimate business domain a PM can claim. | |
| _PROSE_NOISE_EXTRA: frozenset = frozenset({ | |
| # Industry-context nouns, not PM skills | |
| "driver-partners", "intra-city", "inter-city", "connecting", "largest", | |
| "reliable", "redefine", | |
| # JD-prose action-verb atoms (survive splitting but aren't skill names) | |
| "synthesise", "synthesize", "refine", "validate", "iterate", "establish", | |
| "proactively", "concurrent", "concurrently", "independently", | |
| # Marketing scale/superlative descriptors | |
| "millions", "billion", "thousands", "smarter", "fastest", "meaningful", | |
| # Role/seniority modifiers (not skills) | |
| "junior", "independence", "accountable", "operates", | |
| # Generic best-practice fragments | |
| "coach", | |
| }) | |
| def skill_relevant_filter(terms: List[str]) -> List[str]: | |
| """Drop prose-noise atoms from the atomized expected set (V2 scoring denominator | |
| only). BLOCKLIST: drops tokens in _FILLER or _PROSE_NOISE_EXTRA; keeps every | |
| other token (including unfamiliar tools). Applied in `_build_report` AFTER | |
| `atomic_keywords`, BEFORE `external_coverage`. V2-only; never called from V1.""" | |
| combined_noise = _FILLER | _PROSE_NOISE_EXTRA | |
| return [t for t in (terms or []) if t.lower() not in combined_noise] | |
| # Acronym β expansion doubling: recruiters' Boolean search treats "AWS" and | |
| # "Amazon Web Services" as different strings, so both forms should count. | |
| _ACRONYM_MAP_RAW: dict = { | |
| "aws": "amazon web services", "azure": "microsoft azure", | |
| "gcp": "google cloud platform", "api": "application programming interface", | |
| "sql": "structured query language", "crm": "customer relationship management", | |
| "erp": "enterprise resource planning", "saas": "software as a service", | |
| "paas": "platform as a service", "kpi": "key performance indicator", | |
| "okr": "objective and key result", "mvp": "minimum viable product", | |
| "prd": "product requirements document", "gtm": "go-to-market", | |
| "ux": "user experience", "ui": "user interface", "ml": "machine learning", | |
| "ai": "artificial intelligence", "nlp": "natural language processing", | |
| "llm": "large language model", "b2b": "business to business", | |
| "b2c": "business to consumer", "seo": "search engine optimization", | |
| "roi": "return on investment", "nps": "net promoter score", | |
| "ltv": "lifetime value", "arpu": "average revenue per user", | |
| } | |
| _ACRONYM_MAP: dict = {} | |
| for _short, _full in _ACRONYM_MAP_RAW.items(): | |
| _ACRONYM_MAP[_short] = _full | |
| _ACRONYM_MAP[_full] = _short | |
| def _expand_acronyms(terms: List[str]) -> List[str]: | |
| """For each term matching an acronym or its expansion, also append the partner | |
| form (so both short and full forms count in coverage / recruiter search). | |
| Order-preserving, de-duplicated. V2-only.""" | |
| out = list(terms or []) | |
| seen = {t.lower() for t in out} | |
| for t in list(terms or []): | |
| partner = _ACRONYM_MAP.get(t.lower().strip()) | |
| if partner and partner not in seen: | |
| seen.add(partner) | |
| out.append(partner) | |
| return out | |
| # 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", | |
| ) | |
| # ββ V2-only scraped-noise filter (R27) βββββββββββββββββββββββββββββββββββββββ | |
| # These three sets + helpers reject scraped, non-skill tokens (company | |
| # geography, executive/recruiter names, role/boilerplate nouns) that the broad | |
| # extractor cannot tell apart from real skills. They are applied ONLY in the V2 | |
| # allocation path via `filter_scraped_noise()`; V1 structured placement does NOT | |
| # call them, so V1 behaviour is unchanged. | |
| # Geography / location tokens that are never skills. Word-boundary matching is | |
| # used for single-word entries so 'hq' never blocks 'graphql'; multi-word phrases | |
| # ('latin america') match as substrings. | |
| _GEO_EXTENDED: frozenset = frozenset({ | |
| "amsterdam", "netherlands", "europe", "africa", "asia", "asia-pacific", | |
| "latin america", "latin", "north america", "south america", "middle east", | |
| "apac", "emea", "headquartered", "headquarters", "hq", "worldwide", | |
| "global", "offices", "office", | |
| # Common India / global locations the job-card header drags in as "keywords". | |
| "india", "bengaluru", "bangalore", "karnataka", "hyderabad", "telangana", | |
| "mumbai", "maharashtra", "delhi", "new delhi", "ncr", "gurgaon", "gurugram", | |
| "noida", "pune", "chennai", "kolkata", "ahmedabad", "boca raton", "florida", | |
| "usa", "united states", "uk", "united kingdom", "singapore", "dubai", | |
| }) | |
| # Corporate-entity / boilerplate suffix words that travel with "About us" copy. | |
| _ENTITY_NOISE: frozenset = frozenset({ | |
| "corporation", "incorporated", "subsidiary", "subsidiaries", "conglomerate", | |
| }) | |
| # Job-board posting metadata: employment type, seniority chips, posting age, | |
| # work-mode, applicant counts, section labels β never resume keywords. | |
| _META_NOISE: frozenset = frozenset({ | |
| "full-time", "full time", "fulltime", "part-time", "part time", "parttime", | |
| "contract", "contractor", "internship", "intern", "temporary", "permanent", | |
| "associate", "senior", "junior", "mid-level", "entry-level", "entry level", | |
| "applicants", "applicant", "applied", "alerts", "alert", "get alerts", | |
| "posted", "remote", "hybrid", "onsite", "on-site", "on site", | |
| "seniority level", "employment type", "job function", "industries", | |
| "week ago", "weeks ago", "day ago", "days ago", "month ago", "months ago", | |
| "hour ago", "hours ago", "minute ago", "minutes ago", | |
| }) | |
| # Single tokens that mark a gram as posting metadata wherever they appear. | |
| _META_WORDS: frozenset = frozenset({ | |
| "ago", "full-time", "fulltime", "part-time", "parttime", "applicants", | |
| "applicant", "alerts", "posted", "internship", "hybrid", "onsite", | |
| }) | |
| # Job-board / LinkedIn page chrome that the DOM scrape drags in (the broad V1 | |
| # extractor's _UI_NOISE list doesn't cover these exact fragments). | |
| _UI_BLURB: tuple = ( | |
| "actively engaged", "actively reviewing", "get alerts", "reach applicants", | |
| "top applicant", "easy apply", "be an early applicant", "promoted by", | |
| "set alert", "job alert", "people you can reach", | |
| ) | |
| # Generic role/people nouns that indicate a person or HR context, not a skill. | |
| _ROLE_NOISE: frozenset = frozenset({ | |
| "recruiter", "recruiters", "applicant", "applicants", | |
| "founder", "co-founder", "cofounder", "ceo", "cto", "coo", "cfo", | |
| "investor", "investors", "partner", "partners", | |
| "member", "members", "shareholder", "shareholders", | |
| "colleague", "colleagues", "peer", "peers", "backbasers", | |
| "hire", "hiring", "hired", | |
| }) | |
| # Common sentence/clause-starting words that appear Title Case but are NOT part | |
| # of a person name β excluded from the two-word name pattern. | |
| _SENTENCE_STARTERS: frozenset = frozenset({ | |
| "The", "A", "An", "We", "Our", "You", "This", "That", "These", "Those", | |
| "For", "At", "In", "On", "By", "With", "To", "From", "And", "Or", "But", | |
| "If", "As", "Is", "Are", "Was", "About", "During", "After", "Before", | |
| "When", "Where", "Please", "Must", "Will", "Can", "Has", "Have", "Had", | |
| "Be", "Do", "Does", "Did", "Should", "Would", "Could", "May", "Might", | |
| "Not", "All", "Some", "Any", "Each", "Also", "Both", "More", "Most", | |
| "How", "What", "Who", "Why", "Which", | |
| }) | |
| # Cue words that appear immediately BEFORE a person name in a JD ("Recruiter: | |
| # Mohammed Nayeem", "Founder Jouk Pleiter"). | |
| _NAME_CUE_BEFORE = ( | |
| r"recruiter|contact|hiring manager|posted by|submitted by|reach out to|" | |
| r"founder|co-?founder|ceo|cto|coo|cfo|president|vice president|vp|" | |
| r"director|head of [a-z ]+|lead" | |
| ) | |
| def _contains_geo_token(term: str) -> bool: | |
| """True if `term` is or contains a geography/location token. Single-word geo | |
| entries use word-boundary matching so the 'hq' substring never blocks | |
| 'graphql'; multi-word geo phrases (e.g. 'latin america') match as substrings.""" | |
| tl = (term or "").lower() | |
| for g in _GEO_EXTENDED: | |
| if g == tl: | |
| return True | |
| if " " in g and g in tl: | |
| return True | |
| if " " not in g and re.search( | |
| r"(?<![a-z])" + re.escape(g) + r"(?![a-z])", tl | |
| ): | |
| return True | |
| return False | |
| def _contains_person_name(term_lower: str, original_jd: str) -> bool: | |
| """True if `term_lower` contains a two-word Title-Case sequence that, in the | |
| original JD, sits next to a person cue β a label before it ('Recruiter:') or a | |
| predicate/appositive after it ('is the CEO', 'β Account Manager', ', founder'). | |
| Context-gated on purpose: a bare Title-Case bigram is NOT enough (job-title and | |
| domain headings like 'Forward Deployed' or 'Banking Integrations' are Title | |
| Case too). Requiring a person cue avoids those false positives while still | |
| catching real names embedded anywhere in a multi-word gram. | |
| """ | |
| words = term_lower.split() | |
| if len(words) < 2: | |
| return False | |
| orig = original_jd or "" | |
| for w1, w2 in zip(words, words[1:]): | |
| cap1, cap2 = w1.capitalize(), w2.capitalize() | |
| if cap1 in _SENTENCE_STARTERS or cap2 in _SENTENCE_STARTERS: | |
| continue | |
| name = re.escape(cap1) + r"\s+" + re.escape(cap2) | |
| before = r"(?:" + _NAME_CUE_BEFORE + r")\s*[:\-ββ]?\s+" + name | |
| after = name + r"\s*(?:\bis\b|\bwas\b|,|[\-ββ]|\bthe ceo\b|\bfounder\b)" | |
| if re.search(before, orig, flags=re.I) or re.search(after, orig): | |
| return True | |
| return False | |
| def _is_skill_like(term: str, original_jd: str = "", company: str = "") -> bool: | |
| """Return False (reject) if `term` is geography, a corporate-entity/role noun, | |
| the company's own name, or a person name. This is the sole V2 rejection gate; | |
| it does NOT touch the V1 extractor's stopword/UI/filler checks.""" | |
| tl = (term or "").strip().lower() | |
| if not tl: | |
| return True | |
| if _is_ui_noise(tl): | |
| return False | |
| if any(b in tl for b in _UI_BLURB): | |
| return False | |
| if tl in _META_NOISE: | |
| return False | |
| if _contains_geo_token(tl): | |
| return False | |
| parts = tl.split() | |
| if any(w in _META_WORDS for w in parts): | |
| return False | |
| if tl in _ROLE_NOISE or any(w in _ROLE_NOISE for w in parts): | |
| return False | |
| if any(w in _ENTITY_NOISE for w in parts): | |
| return False | |
| if company: | |
| for ctok in re.findall(r"[a-z0-9]+", company.lower()): | |
| if len(ctok) >= 4 and re.search( | |
| r"\b" + re.escape(ctok) + r"(?:s|rs|ers)?\b", tl | |
| ): | |
| return False | |
| if original_jd and _contains_person_name(tl, original_jd): | |
| return False | |
| return True | |
| def filter_scraped_noise( | |
| terms: List[str], original_jd: str = "", company: str = "" | |
| ) -> List[str]: | |
| """V2-only: drop scraped non-skill tokens (geography, executive/recruiter | |
| names, corporate-entity/boilerplate nouns, the company's own name) from an | |
| already-extracted term list, preserving order. V1 does NOT call this β V1's | |
| pool is unchanged by design.""" | |
| return [t for t in (terms or []) if _is_skill_like(t, original_jd, company)] | |
| # 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"(?<!\w)" + re.escape(tl) + r"(?!\w)", k.lower()): | |
| subsumed = True | |
| break | |
| if not subsumed: | |
| kept.append(t) | |
| return sorted(kept, key=lambda t: order[t]) | |
| # ββ Atomic keyword decomposition (R32) βββββββββββββββββββββββββββββββββββββββ | |
| # The extractor emits multi-word run-grams from a JD's comma lists (e.g. | |
| # "saas cloud aws azure"); the matcher then needs them verbatim/in-order, so a | |
| # resume that genuinely has those skills scattered scores them as MISSING. Real | |
| # ATS checkers (Jobalytics/Simplify) score INDIVIDUAL keywords. `atomic_keywords` | |
| # decomposes long grams into single terms for scoring β but keeps genuine 2-word | |
| # semantic units (go-to-market, machine learning, product roadmap) intact so they | |
| # aren't shredded. Used ONLY in V2's `_build_report` (the V1 scorer is untouched). | |
| _ATOMIC_VOCAB: frozenset = frozenset( | |
| t.lower() for t in (PM_SKILL_TAXONOMY | GENERIC_PROFESSIONAL_VOCAB) | |
| if " " in t or "-" in t or "/" in t | |
| ) | |
| def atomic_keywords(terms: List[str]) -> List[str]: | |
| """Decompose multi-word run-grams into individual significant terms, preserving | |
| known multi-word skills and 2-word units. Order-preserving, de-duplicated.""" | |
| out: List[str] = [] | |
| seen = set() | |
| def _add(tok: str): | |
| tok = tok.strip(".-/ ").strip() | |
| if len(tok) < 2 or tok in _STOP: | |
| return | |
| if tok not in seen: | |
| seen.add(tok) | |
| out.append(tok) | |
| for term in (terms or []): | |
| tl = (term or "").strip().lower() | |
| if not tl: | |
| continue | |
| # Known multi-word skill β keep as a single semantic unit. | |
| if tl in _ATOMIC_VOCAB: | |
| if tl not in seen: | |
| seen.add(tl) | |
| out.append(tl) | |
| continue | |
| content = [w for w in re.split(r"[\s/]+", tl) if w and w not in _STOP] | |
| if not content: | |
| continue # all stopwords/noise β drop entirely | |
| if len(content) <= 2: | |
| # 1β2 content words: an atomic unit already (e.g. "metric definition"). | |
| if tl not in seen: | |
| seen.add(tl) | |
| out.append(tl) | |
| else: | |
| # 3+ words: a run-gram from a JD list β split into individual atoms. | |
| for w in re.split(r"[\s/]+", tl): | |
| _add(w) | |
| return out | |
| def external_coverage(expected: List[str], exported_text: str) -> 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, | |
| } | |