JAA-ATS-Tool / src /deterministic_extract.py
saitejatirunagari's picture
feat: competency-aware keyword placement, Skills-first design (v2.6.0)
8d38bb5
Raw
History Blame
22.2 kB
"""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"(?<![a-z0-9])" + re.escape(term) + r"(?![a-z0-9])", jd_low)) >= 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"(?<![a-z0-9])" + re.escape(term.lower()) + r"(?![a-z0-9])", jd_low)
if not m:
return None
start = m.start()
# containing sentence
sent = next((s for s in sents if term.lower() in s.lower()), jd[max(0, start-40):start+80])
return start, start + len(term), sent
out: List[dict] = []
seen = set()
for r in req.all_requirements():
term = (r.term or "").strip()
tl = term.lower()
if not term or tl in seen:
continue
if tl in _GENERIC_ALONE or tl in _BUZZWORDS:
continue
if not _is_ats_keyword(term):
continue
sr0 = _src(term)
if sr0 is None: # traceability: must be verbatim in the JD
continue
start, end, sent = sr0
section = _section_of(sent, sections or {})
is_req = (r.importance == "must_have") or _is_required(sent, section)
if r.category == "seniority": # title modifiers, not ATS keywords
continue
cat = r.category if r.category in (
"tool", "domain", "responsibility", "soft_skill", "hard_skill",
"core_skill", "certification", "education") else "hard_skill"
iscore = _importance(tl, jd_low, sent, section, cat, is_req)
out.append({
"exact_phrase": term,
"normalized_concept": tl,
"source_sentence": sent[:200],
"source_text": sent[:200], # scorer/validator compatibility
"source_section": section,
"source_start": start,
"source_end": end,
"requirement_type": "required" if is_req else "preferred",
"category": cat,
"importance_score": iscore,
"importance": _band(iscore), # critical|high|medium|low
"confidence": iscore,
"requires_resume_evidence": True,
"semantic_variants": (list(getattr(r, "aliases", []) or [])
+ _synonyms_for(tl))[:6],
})
seen.add(tl)
# multi-word professional phrases from requirement/responsibility sentences
# (comma-lists after a cue) — captures domain phrases the gazetteers miss
# (e.g. distributed systems, demand generation, inventory management).
for sent in sents:
section = _section_of(sent, sections or {})
if not (any(k in section for k in _RESP_SECTIONS) or _LIST_CUES.search(sent)):
continue
is_req = _is_required(sent, section)
for phrase in _list_phrases(sent):
pl = phrase.lower()
if pl in seen:
continue
sr = _src(phrase)
if sr is None:
continue
start, end, s2 = sr
cat = "responsibility" if " " in phrase and any(
v in sent.lower() for v in ("own", "drive", "lead", "manage", "run")) else "hard_skill"
iscore = _importance(pl, jd_low, sent, section, cat, is_req)
out.append({
"exact_phrase": phrase, "normalized_concept": pl,
"source_sentence": sent[:200], "source_text": sent[:200],
"source_section": section, "source_start": start, "source_end": end,
"requirement_type": "required" if is_req else "preferred",
"category": cat, "importance_score": iscore, "importance": _band(iscore),
"confidence": iscore, "requires_resume_evidence": True,
"semantic_variants": _synonyms_for(pl),
})
seen.add(pl)
# explicit years-of-experience requirement (a distinct criterion)
ym = _YEARS_RE.search(jd)
if ym and not any("year" in o["normalized_concept"] for o in out):
start = ym.start()
sent = next((s for s in sents if ym.group(0).lower() in s.lower()), ym.group(0))
out.append({
"exact_phrase": ym.group(0), "normalized_concept": ym.group(0).lower(),
"source_sentence": sent[:200], "source_text": sent[:200],
"source_section": _section_of(sent, sections or {}),
"source_start": start, "source_end": ym.end(),
"requirement_type": "required", "category": "experience_signal",
"importance_score": 0.6, "importance": "high", "confidence": 0.6,
"requires_resume_evidence": True, "semantic_variants": [],
})
# ponytail: dedup only TRUE duplicates (same concept), not sub-phrases.
# ATS scanners search for each keyword independently — "solutions" and
# "enterprise solutions" are different search terms, both worth placing.
seen_concepts: set = set()
kept: List[dict] = []
for o in sorted(out, key=lambda x: len(x["exact_phrase"]), reverse=True):
c = o["normalized_concept"]
if c in seen_concepts:
continue
seen_concepts.add(c)
kept.append(o)
kept.sort(key=lambda o: o["importance_score"], reverse=True)
return kept
def extract_from_raw(raw_jd: str, company: str = "") -> 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']})")