Spaces:
Sleeping
Sleeping
| """ | |
| Structured JD analysis (spec item #2). | |
| Turns a raw job description into a STRUCTURED set of requirements β not a flat | |
| keyword list. Each requirement carries its category, importance, the exact JD | |
| phrase it came from, aliases/synonyms, and where it should appear in the resume. | |
| Design: deterministic-first. `analyze_jd` always returns a usable result from | |
| the curated gazetteer (no model dependency). If an LLM client + cfg are passed, | |
| its richer structured extraction is merged on top (the LLM is the breadth lever | |
| that matches how AI checkers like Jobalytics extract; the gazetteer is the | |
| reliable floor). Either way, company names / locations / vague buzzwords are | |
| excluded. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from dataclasses import dataclass, field, asdict | |
| from typing import List, Dict, Optional | |
| from .ats_scorer import ( | |
| extract_jd_keywords, | |
| _is_taxonomy_skill, | |
| _LOCATIONS, | |
| ) | |
| # ββ Category gazetteers (deterministic classification) βββββββββββββββββββββββ | |
| _TOOLS = { | |
| "jira", "confluence", "figma", "notion", "asana", "miro", "trello", "linear", | |
| "mixpanel", "amplitude", "ga4", "google analytics", "metabase", "tableau", | |
| "looker", "power bi", "powerbi", "sql", "excel", "segment", "hotjar", | |
| "salesforce", "hubspot", "webengage", "clevertap", "braze", "slack", | |
| "productboard", "airtable", "python", "postgresql", "mysql", "snowflake", | |
| "kubernetes", "docker", "aws", "gcp", "azure", "github", "gitlab", "jenkins", | |
| "datadog", "splunk", "kibana", "grafana", "zendesk", "intercom", | |
| } | |
| _METHODS = { | |
| "agile", "scrum", "kanban", "lean", "sprint planning", "okrs", "okr", | |
| "a/b testing", "experimentation", "design thinking", "discovery", | |
| "roadmapping", "prioritization", "go-to-market", "gtm", "hypothesis testing", | |
| "user research", "agile methodologies", "backlog grooming", "story mapping", | |
| "competitive analysis", "market research", "wireframing", "user stories", | |
| "product roadmap", "product strategy", "product discovery", "user acceptance testing", | |
| "ci/cd", "tdd", "ml ops", "mlops", | |
| } | |
| _DOMAINS = { | |
| "fintech", "edtech", "healthtech", "martech", "ecommerce", "e-commerce", | |
| "saas", "b2b", "b2c", "b2b2c", "lending", "credit", "insurance", "fraud", | |
| "banking", "payments", "logistics", "cybersecurity", "secops", "siem", | |
| "soar", "xdr", "wellness", "healthcare", "supply chain", "marketplace", | |
| "consumer", "enterprise", "gaming", "edtech", "proptech", "insurtech", | |
| } | |
| _SOFT_SKILLS = { | |
| "communication", "leadership", "collaboration", "stakeholder management", | |
| "stakeholder communication", "cross-functional", "mentoring", "teamwork", | |
| "negotiation", "presentation", "influencing", "facilitation", | |
| "problem solving", "analytical thinking", "strategic thinking", | |
| } | |
| # Vague filler real checkers penalise β never a requirement. | |
| _BUZZWORDS = { | |
| "innovation", "innovative", "solutions", "solution", "tools", "tool", | |
| "lifecycle", "ownership", "synergy", "dynamic", "passionate", "motivated", | |
| "results-driven", "detail-oriented", "team player", "track record", | |
| "expertise", "strengths", "best practices", "value-add", "thought leadership", | |
| "self-starter", "go-getter", "fast-paced", "cutting-edge", "world-class", | |
| "robust", "seamless", "holistic", "leverage", "leveraging", "excellence", | |
| "proven", "successful", "goals", "authority", "productivity", "generation", | |
| "organisation", "organization", | |
| } | |
| # Importance cue words around a term in the JD. | |
| _MUST_CUES = ("required", "must have", "must-have", "must", "essential", | |
| "strong", "proven", "expert", "deep", "extensive", "mandatory", | |
| "minimum", "at least", "demonstrated") | |
| _PREF_CUES = ("preferred", "nice to have", "nice-to-have", "plus", "bonus", | |
| "a plus", "ideally", "desirable", "advantage", "good to have") | |
| _CERT_PAT = re.compile( | |
| r"\b(pmp|cspo|csm|safe|six sigma|prince2|aws certified|pmi[- ]acp|" | |
| r"google certified|scrum master certification|itil)\b", re.I) | |
| # Note: avoid bare two-letter forms (be/bs/ms/me) β they match common words. | |
| # Require dotted/explicit forms instead. | |
| _DEGREE_PAT = re.compile( | |
| r"\b(bachelor'?s?|master'?s?|mba|b\.?tech|m\.?tech|b\.e\.|m\.e\.|" | |
| r"b\.s\.|m\.s\.|ph\.?d|degree in|engineering degree|computer science)\b", re.I) | |
| _SENIORITY_PAT = re.compile( | |
| r"\b(\d{1,2}\+?\s*years?|senior|lead|principal|staff|head of|director|" | |
| r"junior|associate|entry[- ]level|mid[- ]level|vp\b)\b", re.I) | |
| _TITLE_PAT = re.compile( | |
| r"\b(product manager|associate product manager|senior product manager|" | |
| r"product owner|program manager|project manager|product lead|" | |
| r"group product manager|technical product manager|growth product manager|" | |
| r"platform product manager)\b", re.I) | |
| VALID_PLACEMENTS = {"title", "summary", "skills", "experience", | |
| "certifications", "education"} | |
| class Requirement: | |
| term: str | |
| category: str # hard_skill|tool|responsibility|domain|... | |
| importance: str = "preferred" # must_have | preferred | nice_to_have | |
| source_phrase: str = "" # the JD sentence/snippet it came from | |
| aliases: List[str] = field(default_factory=list) | |
| recommended_placement: List[str] = field(default_factory=list) | |
| def to_dict(self) -> dict: | |
| return asdict(self) | |
| class JDRequirements: | |
| target_role_titles: List[str] = field(default_factory=list) | |
| required_hard_skills: List[Requirement] = field(default_factory=list) | |
| preferred_hard_skills: List[Requirement] = field(default_factory=list) | |
| tools_platforms: List[Requirement] = field(default_factory=list) | |
| responsibilities: List[Requirement] = field(default_factory=list) | |
| domain_terms: List[Requirement] = field(default_factory=list) | |
| certifications: List[Requirement] = field(default_factory=list) | |
| education_requirements: List[Requirement] = field(default_factory=list) | |
| soft_skills: List[Requirement] = field(default_factory=list) | |
| seniority_signals: List[Requirement] = field(default_factory=list) | |
| def all_requirements(self) -> List[Requirement]: | |
| out: List[Requirement] = [] | |
| for f in (self.required_hard_skills, self.preferred_hard_skills, | |
| self.tools_platforms, self.responsibilities, self.domain_terms, | |
| self.certifications, self.education_requirements, | |
| self.soft_skills, self.seniority_signals): | |
| out.extend(f) | |
| return out | |
| def all_terms(self) -> List[str]: | |
| seen, out = set(), [] | |
| for r in self.all_requirements(): | |
| k = r.term.lower().strip() | |
| if k and k not in seen: | |
| seen.add(k) | |
| out.append(r.term) | |
| return out | |
| def to_dict(self) -> dict: | |
| d = {} | |
| for k, v in self.__dict__.items(): | |
| if k == "target_role_titles": | |
| d[k] = list(v) | |
| else: | |
| d[k] = [r.to_dict() for r in v] | |
| return d | |
| # ββ Deterministic extraction βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _source_phrase(term: str, jd_text: str) -> str: | |
| """Return the JD sentence containing the term (trimmed).""" | |
| low = jd_text.lower() | |
| i = low.find(term.lower()) | |
| if i < 0: | |
| return "" | |
| start = jd_text.rfind(".", 0, i) | |
| end = jd_text.find(".", i) | |
| start = 0 if start < 0 else start + 1 | |
| end = len(jd_text) if end < 0 else end | |
| return re.sub(r"\s+", " ", jd_text[start:end]).strip()[:160] | |
| def _importance_for(term: str, jd_text: str) -> str: | |
| """Infer must_have / preferred from cue words near the term.""" | |
| low = jd_text.lower() | |
| i = low.find(term.lower()) | |
| if i < 0: | |
| return "preferred" | |
| window = low[max(0, i - 90): i + 90] | |
| if any(c in window for c in _PREF_CUES): | |
| return "preferred" | |
| if any(c in window for c in _MUST_CUES): | |
| return "must_have" | |
| return "preferred" | |
| def _categorize(term: str) -> str: | |
| t = term.lower() | |
| if t in _TOOLS: | |
| return "tool" | |
| if t in _SOFT_SKILLS: | |
| return "soft_skill" | |
| if t in _DOMAINS: | |
| return "domain" | |
| if t in _METHODS: | |
| return "responsibility" if " " in t else "hard_skill" | |
| return "hard_skill" | |
| def _placement_for(category: str, importance: str) -> List[str]: | |
| if category == "tool": | |
| return ["skills", "experience"] | |
| if category == "soft_skill": | |
| return ["experience"] # soft skills belong in bullets, not raw list | |
| if category == "domain": | |
| return ["summary", "experience"] | |
| if category == "responsibility": | |
| return ["experience"] | |
| if category == "certification": | |
| return ["certifications"] | |
| if category == "education": | |
| return ["education"] | |
| # hard skill | |
| return ["skills", "experience"] if importance == "must_have" else ["skills"] | |
| def _deterministic_jd_requirements(jd_text: str) -> JDRequirements: | |
| req = JDRequirements() | |
| # Titles | |
| req.target_role_titles = sorted({m.group(0).title() | |
| for m in _TITLE_PAT.finditer(jd_text)}) | |
| # Certifications / education / seniority (regex driven) | |
| for m in {x.group(0).strip() for x in _CERT_PAT.finditer(jd_text)}: | |
| req.certifications.append(Requirement( | |
| term=m, category="certification", | |
| importance=_importance_for(m, jd_text), | |
| source_phrase=_source_phrase(m, jd_text), | |
| recommended_placement=["certifications"])) | |
| for m in {x.group(0).strip().lower() for x in _DEGREE_PAT.finditer(jd_text)}: | |
| req.education_requirements.append(Requirement( | |
| term=m, category="education", | |
| importance=_importance_for(m, jd_text), | |
| source_phrase=_source_phrase(m, jd_text), | |
| recommended_placement=["education"])) | |
| for m in {x.group(0).strip().lower() for x in _SENIORITY_PAT.finditer(jd_text)}: | |
| req.seniority_signals.append(Requirement( | |
| term=m, category="seniority", | |
| importance="must_have", | |
| source_phrase=_source_phrase(m, jd_text), | |
| recommended_placement=["summary"])) | |
| # Skills/tools/domains/soft β from the existing skills-only extractor | |
| for term in extract_jd_keywords(jd_text): | |
| t = term.lower().strip() | |
| if t in _BUZZWORDS or t in _LOCATIONS or len(t) < 3: | |
| continue | |
| category = _categorize(t) | |
| importance = _importance_for(t, jd_text) | |
| r = Requirement( | |
| term=term, category=category, importance=importance, | |
| source_phrase=_source_phrase(t, jd_text), | |
| recommended_placement=_placement_for(category, importance), | |
| ) | |
| if category == "tool": | |
| req.tools_platforms.append(r) | |
| elif category == "domain": | |
| req.domain_terms.append(r) | |
| elif category == "soft_skill": | |
| req.soft_skills.append(r) | |
| elif category == "responsibility": | |
| req.responsibilities.append(r) | |
| else: # hard_skill | |
| (req.required_hard_skills if importance == "must_have" | |
| else req.preferred_hard_skills).append(r) | |
| return req | |
| # ββ LLM enrichment (optional) ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _merge_llm(req: JDRequirements, data: dict, jd_text: str) -> None: | |
| """Merge an LLM structured-extraction dict into the deterministic result. | |
| Only terms that actually appear in the JD and aren't buzzwords/locations are | |
| accepted. Adds terms the gazetteer missed (the breadth lever).""" | |
| low = jd_text.lower() | |
| existing = {r.term.lower() for r in req.all_requirements()} | |
| def _add(bucket: List[Requirement], items, category: str, default_place): | |
| for it in (items or []): | |
| if isinstance(it, str): | |
| it = {"term": it} | |
| if not isinstance(it, dict): | |
| continue | |
| term = (it.get("term") or "").strip() | |
| tl = term.lower() | |
| if (not term or tl in existing or tl in _BUZZWORDS | |
| or tl in _LOCATIONS or len(tl) < 3 or tl not in low): | |
| continue | |
| existing.add(tl) | |
| place = it.get("recommended_placement") or default_place | |
| place = [p for p in place if p in VALID_PLACEMENTS] or default_place | |
| bucket.append(Requirement( | |
| term=term, category=category, | |
| importance=it.get("importance", "preferred") | |
| if it.get("importance") in ("must_have", "preferred", "nice_to_have") | |
| else "preferred", | |
| source_phrase=(it.get("source_phrase") or _source_phrase(tl, jd_text))[:160], | |
| aliases=[a for a in (it.get("aliases") or []) if isinstance(a, str)][:6], | |
| recommended_placement=place, | |
| )) | |
| for t in (data.get("target_role_titles") or []): | |
| if isinstance(t, str) and t.strip() and t not in req.target_role_titles: | |
| req.target_role_titles.append(t.strip()) | |
| _add(req.required_hard_skills, data.get("required_hard_skills"), "hard_skill", ["skills", "experience"]) | |
| _add(req.preferred_hard_skills, data.get("preferred_hard_skills"), "hard_skill", ["skills"]) | |
| _add(req.tools_platforms, data.get("tools_platforms"), "tool", ["skills", "experience"]) | |
| _add(req.responsibilities, data.get("responsibilities"), "responsibility", ["experience"]) | |
| _add(req.domain_terms, data.get("domain_terms"), "domain", ["summary", "experience"]) | |
| _add(req.certifications, data.get("certifications"), "certification", ["certifications"]) | |
| _add(req.education_requirements, data.get("education_requirements"), "education", ["education"]) | |
| _add(req.soft_skills, data.get("soft_skills"), "soft_skill", ["experience"]) | |
| _add(req.seniority_signals, data.get("seniority_signals"), "seniority", ["summary"]) | |
| def analyze_jd(jd_text: str, llm=None, cfg: dict = None) -> JDRequirements: | |
| """Analyze a JD into structured requirements. | |
| Deterministic gazetteer always runs (reliable floor). If `llm` (an LLMClient) | |
| and `cfg` are provided, the LLM's richer structured extraction is merged in. | |
| """ | |
| req = _deterministic_jd_requirements(jd_text or "") | |
| if llm is not None and hasattr(llm, "analyze_jd_requirements"): | |
| try: | |
| data = llm.analyze_jd_requirements(cfg or {}, jd_text) | |
| if isinstance(data, dict): | |
| _merge_llm(req, data, jd_text) | |
| except Exception as e: # never let enrichment break the floor | |
| print(f"[jd_analyzer] LLM enrichment skipped: {e}") | |
| return req | |