"""Deterministic, source-grounded ATS criterion extraction — NO external LLM. This is the PRIMARY extraction path for V1. It turns a cleaned job description into a full criterion inventory (schema below) using only local, deterministic signals: the curated gazetteers + cue-word requirement classification in `jd_analyzer.analyze_jd` (llm=None), plus section detection, years/title/cert patterns, importance scoring, verbatim traceability, and longest-first dedup. An external LLM is NEVER required for extraction, ranking, evidence matching, coverage, or scoring — it is optional wording polish only (see resume_rewrite). """ from __future__ import annotations import re from typing import Dict, List from .jd_analyzer import analyze_jd, _BUZZWORDS, _TOOLS from .jd_preprocess import preprocess_jd # Generic fragments never promoted on their own when a specific phrase exists. # ponytail: trimmed to truly generic words only — "influence", "strategy", # "innovation", "solutions" etc. carry ATS weight as PM competencies. _GENERIC_ALONE = { "work", "problem", "manage", "communicate", "process", "team", "teams", "skills", "experience", "ability", "knowledge", "understanding", "responsibilities", "requirements", "role", "job", "candidate", "years", "environment", "priorities", "results", "data", "users", "quality", "success", "partners", # title/seniority modifiers — low standalone ATS value "staff", "senior", "lead", "principal", "junior", "associate", "director", "mentor", "legal", "autonomy", "acumen", "empathy", "passion", "trust", "joy", "decisions", "field", # ponytail: bare generic nouns/verbs — real ATS scanners search for specific # competencies, and these match nothing a recruiter would Boolean-search on. # Placing them is pure stuffing, so they must not enter the criterion set. "impact", "execution", "solutions", "collaborate", "reporting", "ownership", "messaging", "competitors", "influencing", "performance", "timelines", "stakeholders", "innovative", "solution", "deliverables", "initiatives", } # ── ATS keyword shape gate ─────────────────────────────────────────────────── # An ATS keyword is a NOUN PHRASE a recruiter could Boolean-search. JD prose # chopped at comma/'and' boundaries produces clause fragments ("knack for # precise", "what's not", "Ability to write", "running experiments (e.g") that # can never match a résumé and only inflate the denominator. Reject by SHAPE, # never by topic, so unfamiliar-but-real skills still get through. _WEAK_HEADS = { "ability", "knack", "comfort", "willingness", "desire", "passion", "understanding", "knowledge", "familiarity", "proficiency", "expertise", "appetite", "bias", "sense", "love", "eagerness", "capacity", "aptitude", "exposure", "flair", "hunger", "drive", "commitment", "dedication", } # Bare imperative verbs that start a responsibility clause (not a keyword). _VERB_HEADS = { "optimize", "monitor", "identify", "refine", "ensure", "own", "lead", "manage", "build", "create", "develop", "deliver", "conduct", "design", "run", "write", "define", "execute", "collaborate", "partner", "work", "help", "support", "maintain", "improve", "drive", "champion", "coordinate", "translate", "communicate", "present", "report", "track", "measure", "evaluate", "assess", "review", "prioritize", "align", "engage", "influence", } # Tokens that mark a subordinate clause — never inside a keyword. _CLAUSE_MARKERS = {"what", "how", "why", "whether", "which", "who", "that", "when", "where", "if", "because", "so", "then", "than"} _PRONOUNS = {"your", "their", "our", "you", "we", "they", "it", "them", "its", "his", "her", "my", "me", "us"} _TRAILING_JUNK = re.compile( r"\b(?:a plus|as well|and more|etc|and so on|or so|preferred|required)$", re.I) # Function words that must never begin or end a keyword. _STOP_EDGE_ALL = {"and", "or", "the", "a", "an", "to", "of", "for", "with", "in", "on", "across", "using", "including", "etc", "such", "as", "e.g", "i.e", "you", "we", "they", "our", "your", "their", "will", "must", "at", "by", "from", "about", "into", "per", "is", "are", "be", "been", "who", "via", "plus", "more"} def _is_ats_keyword(phrase: str) -> bool: """True when `phrase` has the shape of a searchable ATS keyword. Structural only — rejects clause fragments, not unfamiliar skills. """ p = (phrase or "").strip() if not p: return False # Parenthetical / example fragments: "running experiments (e.g", # "refine campaign copy (emails", "domain (e.g". if "(" in p or ")" in p or re.search(r"\be\.?g\b|\bi\.?e\b", p, re.I): return False if _TRAILING_JUNK.search(p): return False toks = p.split() if not toks or len(toks) > 5: return False low = [t.lower().strip(".,;:") for t in toks] # Function-word contractions ("what's not", "it's working") — but keep # genuine possessive nouns like "bachelor's degree" / "master's". for t in low: if "'" in t and t.split("'")[0] in (_CLAUSE_MARKERS | _PRONOUNS): return False if low[0] in _WEAK_HEADS or low[0] in _VERB_HEADS: return False # Gerund/3rd-person forms of those same verbs ("ensuring deadlines", # "monitoring performance") are clause fragments too. Strip the inflection # and re-test the head. h = low[0] for suf, rep in (("ing", ""), ("ing", "e"), ("es", ""), ("s", ""), ("ed", ""), ("ed", "e")): if h.endswith(suf) and (h[: -len(suf)] + rep) in _VERB_HEADS: return False if any(t in _CLAUSE_MARKERS for t in low): return False if any(t in _PRONOUNS for t in low): return False # Possessive proper nouns are employer-specific and unmatchable # ("Stripe's product suite"). if re.search(r"\b[A-Z][A-Za-z0-9]*'s\b", p): return False # "&"-joined JD section headings ("Collaboration & Leadership", # "Performance & Optimization") are labels, not searchable terms. if "&" in p: return False # A clause connector between >=3 tokens signals prose, not a term. # ("knack for precise", "timelines aligned with growth"). Interior # "of/at/in/on/to" is fine — "systems at scale", "time to market". if len(low) >= 3 and any(t in ("for", "with", "by", "from", "about") for t in low[1:-1]): return False # Must end on a content word, not a dangling function word. if low[-1] in _STOP_EDGE_ALL: return False # Needs at least one token that is not itself generic filler. if not any(t not in _GENERIC_ALONE and t not in _STOP_EDGE_ALL for t in low): return False return True # Importance signal weights (sum 1.0) — Step "Keyword importance". _W = { "mandatory_wording": 0.25, "required_section": 0.20, "centrality": 0.15, "title_summary": 0.10, "repetition": 0.10, "specificity": 0.10, "recruiter_filter": 0.05, "outcome": 0.05, } _YEARS_RE = re.compile(r"\b(\d{1,2})\s*\+?\s*years?\b", re.I) _MUST_CUES = ("required", "must have", "must-have", "minimum", "essential", "mandatory", "at least", "you must", "strong") _RESP_SECTIONS = ("responsibilit", "requirement", "qualification", "what you") _OUTCOME_HINTS = ("revenue", "conversion", "growth", "retention", "roi", "impact", "kpi", "metric", "margin", "adoption", "engagement") # Locally-stored professional synonym map: JD concept -> résumé-side surface forms # a candidate might genuinely use for the SAME capability. Powers curated-synonym # evidence matching AND deterministic terminology alignment (swap weaker->exact). _SYNONYMS = { "stakeholder management": ["stakeholder communication", "stakeholder comms", "stakeholder engagement", "managing stakeholders"], "roadmap prioritization": ["roadmap planning", "product roadmap planning", "prioritization", "roadmapping"], "product experimentation": ["experiments", "experimentation", "a/b testing", "ab testing", "a/b experimentation"], "cross-functional collaboration": ["cross-functional teams", "cross functional", "cross-functional", "worked with teams"], "product analytics": ["analytics", "product data", "data analysis"], "go-to-market": ["gtm", "launch", "go to market"], "product strategy": ["product vision", "strategy and vision"], "conversion rate optimization": ["cro", "conversion optimization", "funnel optimization"], "demand generation": ["demand gen", "lead generation", "lead gen"], "data visualization": ["dashboards", "dashboarding", "visualization"], "statistical modeling": ["statistics", "statistical analysis", "modeling"], "distributed systems": ["distributed backends", "scalable systems"], "inventory management": ["stock management", "inventory control"], "process optimization": ["process improvement", "operational efficiency"], "risk management": ["risk mitigation", "risk assessment"], "campaign management": ["campaigns", "campaign execution"], } # reverse index: any surface form -> canonical concept (for fast enrichment) _SYN_REVERSE = {} for _canon, _forms in _SYNONYMS.items(): for _f in _forms: _SYN_REVERSE.setdefault(_f.lower(), set()).add(_canon) def _synonyms_for(concept: str) -> list: c = (concept or "").lower().strip() out = list(_SYNONYMS.get(c, [])) # also include forms whose canonical shares this concept's head tokens for canon, forms in _SYNONYMS.items(): if canon != c and (c in canon or canon in c): out += forms return list(dict.fromkeys(out))[:6] def _band(score: float) -> str: return ("critical" if score >= 0.7 else "high" if score >= 0.5 else "medium" if score >= 0.3 else "low") def _sentences(text: str) -> List[str]: parts = re.split(r"(?<=[.;:!?])\s+|\n+", text or "") return [p.strip() for p in parts if p and p.strip()] def _section_of(sentence: str, sections: Dict[str, str]) -> str: for name, body in (sections or {}).items(): if sentence and sentence[:40] in body: return name return "body" def _is_required(source_sentence: str, section: str) -> bool: low = (source_sentence or "").lower() if any(k in section for k in ("requirement", "minimum", "must", "qualification")): return True return any(c in low for c in _MUST_CUES) def _importance(term: str, jd_low: str, source_sentence: str, section: str, category: str, is_required: bool) -> float: s = source_sentence.lower() score = 0.0 if is_required or any(c in s for c in _MUST_CUES): score += _W["mandatory_wording"] if any(k in section for k in _RESP_SECTIONS): score += _W["required_section"] # centrality: multi-word skill/responsibility/tool phrase if category in ("responsibility", "hard_skill", "core_skill", "tool") and " " in term: score += _W["centrality"] # title/summary presence if term in jd_low[:400]: score += _W["title_summary"] # repetition across the JD if len(re.findall(r"(?= 2: score += _W["repetition"] # role specificity: not a generic single token if " " in term or category in ("tool", "domain", "certification"): score += _W["specificity"] # recruiter-filter usefulness: tools/domains/certs/hard skills if category in ("tool", "domain", "certification", "hard_skill", "core_skill"): score += _W["recruiter_filter"] if any(h in s for h in _OUTCOME_HINTS): score += _W["outcome"] return round(min(score, 1.0), 3) # Cues that introduce a list of skills/responsibilities in a requirement sentence. _LIST_CUES = re.compile( r"\b(?:own|owns|owning|drive|drives|driving|build|builds|building|run|runs|" r"running|manage|manages|managing|lead|leads|leading|define|defines|defining|" r"develop|develops|developing|deliver|delivers|conduct|design|designs|" r"experience (?:with|in)|proficiency (?:with|in)|expertise (?:with|in)|" r"skills? (?:with|in)|knowledge of|strong|hands-on|familiarity with)\b", re.I) _LEAD_TRIM = re.compile( r"^(?:the|a|an|and|our|your|their|strong|deep|solid|excellent|proven|" r"cross[- ]|end[- ]to[- ]end|both|other|new|complex|scalable)\s+", re.I) _STOP_EDGE = {"and", "or", "the", "a", "an", "to", "of", "for", "with", "in", "on", "across", "using", "including", "etc", "such", "as", "e.g", "i.e", "you", "we", "they", "our", "your", "their", "will", "must"} def _list_phrases(sent: str) -> List[str]: """Extract multi-word professional phrases from a requirement/responsibility sentence: the comma/'and'-separated items following a list cue (e.g. 'own product strategy, roadmap prioritization, and stakeholder management').""" out: List[str] = [] m = _LIST_CUES.search(sent) tail = sent[m.end():] if m else sent # split into candidate items for raw in re.split(r"\s*(?:,|;|\band\b|\bor\b|\bacross\b|\bvia\b|\bthrough\b|/|\||•)\s*", tail): item = _LEAD_TRIM.sub("", raw.strip().strip(".:—-()").strip()).strip() item = re.sub(r"\s+", " ", item) toks = item.split() # trim generic edge words while toks and toks[0].lower() in _STOP_EDGE: toks = toks[1:] while toks and toks[-1].lower() in _STOP_EDGE: toks = toks[:-1] if not (1 <= len(toks) <= 4): continue phrase = " ".join(toks) pl = phrase.lower() if pl in _GENERIC_ALONE or pl in _BUZZWORDS: continue # must contain at least one content token not in the generic/stop sets and # be multi-word OR a known-ish single technical token (kept multi-word only) content = [t for t in toks if t.lower() not in _STOP_EDGE and t.lower() not in _GENERIC_ALONE] if not _is_ats_keyword(phrase): continue if len(toks) >= 2 and content: out.append(phrase) elif len(toks) == 1: # accept a single-word item only if it's a known tool or an acronym # (SEO, SQL, AWS, Docker) — not a generic noun. t = toks[0] if t.lower() in _TOOLS or re.fullmatch(r"[A-Z][A-Za-z0-9]{1,5}", t) \ and t.lower() not in _GENERIC_ALONE: out.append(t) return out def extract_criteria(clean_jd: str, sections: Dict[str, str] | None = None) -> List[dict]: """Return the full deterministic criterion inventory for a CLEANED JD. Each item: exact_phrase, normalized_concept, source_sentence, source_section, source_start, source_end, requirement_type, category, importance_score. """ jd = clean_jd or "" jd_low = jd.lower() req = analyze_jd(jd, llm=None) # deterministic gazetteer engine, NO LLM sents = _sentences(jd) def _src(term: str) -> tuple: m = re.search(r"(? tuple: """Preprocess (untrusted) + deterministic extraction. Returns (PreprocessResult, criteria_list). criteria is [] when JD isolation fails.""" pre = preprocess_jd(raw_jd, company=company) if not pre.ok: return pre, [] return pre, extract_criteria(pre.clean_text, pre.sections) if __name__ == "__main__": # ponytail: runnable self-check JD = """About the Role We seek a Product Manager to own the roadmap and drive product-led growth. Responsibilities - You will own product strategy, roadmap prioritization, and stakeholder management. - Run A/B testing and product analytics to improve activation and conversion. Minimum requirements - 5+ years of product management experience. - Strong SQL and experience with payments and fintech. """ crit = extract_criteria(JD, None) concepts = {c["normalized_concept"] for c in crit} assert "product management" in concepts or "product manager" in concepts assert any("sql" == c["normalized_concept"] for c in crit) assert any(c["requirement_type"] == "required" for c in crit) # generic bare tokens excluded assert "work" not in concepts and "team" not in concepts # every phrase is traceable + has the full schema for c in crit: assert JD.lower().find(c["normalized_concept"]) >= 0 assert set(c) >= {"exact_phrase", "requirement_type", "category", "importance_score", "source_start"} print(f"deterministic_extract self-check PASSED — {len(crit)} criteria") for c in crit[:8]: print(f" [{c['requirement_type']:<9} {c['importance_score']:.2f}] " f"{c['exact_phrase']} ({c['category']})")