"""Resume parsing service — Module 8. Four-layer NER fallback chain merged on top of a lexical floor: Nucha BERT -> jobbert -> SkillNER -> SBERT+pgvector -> lexical (always) All layers that succeed contribute to the merged output — "merge semantics" rather than "first-success". This makes the fallback chain visible in the demo and maximises recall for downstream gap analysis: different layers catch different phrasings ("ML" vs "machine learning" vs "scikit-learn"). Each layer runs synchronously inside try/except; a layer that raises is logged at ERROR and skipped while the rest of the chain continues. The lexical floor always fires so the endpoint never returns empty solely because HF models are unavailable. Design constraints: * The endpoint contract of ``extract_skills_from_pdf`` is stable: a list of ``SkillPrediction`` dicts. Higher layers enrich confidence for skills the lexical layer already found, and add new skills via alias resolution. * Matching is done against the existing Skill catalog only. We never create new skills from free text — prevents catalog pollution and keeps the recommendation engine grounded in the curated taxonomy. * Proficiency is a heuristic, never an assertion. The user reviews and edits on the Profile page before anything is saved. Layer selection via env var ``GAPGUIDE_PARSE_LAYERS`` (default is the full chain; CI sets this to ``"lexical"`` via conftest.py so tests don't download any models). See ~/.claude/plans/okay-plan-for-7a-fluffy-marshmallow.md. """ from __future__ import annotations import io import logging import os import re from dataclasses import asdict, dataclass import pdfplumber from rapidfuzz import fuzz, process from apps.skills.models import Skill logger = logging.getLogger(__name__) # Layer order — the dispatcher runs in this sequence. Merging is order-agnostic # but the order here drives the "first to register a given skill wins the # proficiency heuristic" rule. Lexical last (it already has the best local # context), but we guarantee it runs last anyway in _predict_skills_from_text. _DEFAULT_LAYERS = "nucha,jobbert,skillner,sbert,lexical" MAX_PDF_BYTES = 5 * 1024 * 1024 # 5 MB — UCP students' CVs fit easily. MAX_TEXT_CHARS = 60_000 # defends the downstream matcher. # Rough heuristic — the parser is an *augmentation* layer, so we cap signals # below 70 so the user always has room to raise after review. _DEFAULT_PROFICIENCY = 55 _STRONG_SIGNAL_PROFICIENCY = 70 # Fuzzy layer is per-token (not partial_ratio against the full doc), so the # threshold is genuinely "this word is essentially a typo of the skill name". # Anything lower would flood the output with false positives like # partial_ratio("python", "sourdough") > 0.5. _FUZZY_TOKEN_SCORE = 88 _FUZZY_MIN_SKILL_LEN = 5 # 'Go', 'R', 'SQL' never go through fuzzy path # Terms that indicate hands-on experience → pushes proficiency toward the top. _EXPERIENCE_MARKERS = re.compile( r"\b(?:expert|advanced|proficien[tc]y?|senior|lead|architect|years?|yrs?)\b", re.IGNORECASE, ) # Terms that hint at entry level → pulls proficiency down. _NOVICE_MARKERS = re.compile( r"\b(?:beginner|junior|intro(?:duction)?|basic|elementary|familiar with)\b", re.IGNORECASE, ) # Rank by *signal strength*, NOT numeric proficiency. NOVICE=40 is numerically # less than DEFAULT=55 but carries a stronger claim ("beginner Python" beats # silence), so it should win during window aggregation. _NOVICE_PROFICIENCY = 40 _PROFICIENCY_RANK = { _STRONG_SIGNAL_PROFICIENCY: 2, _NOVICE_PROFICIENCY: 1, _DEFAULT_PROFICIENCY: 0, } # Per-layer calibration coefficient applied before the max() merge in # _predict_skills_from_text. Each layer emits [0,1] from a different # distribution — softmax (Nucha/JobBERT), cosine similarity above 0.65 threshold # (SBERT), hardcoded 0.95 / rapidfuzz ratio (lexical). Raw max lets lexical's # hardcoded 0.95 outweigh a genuine model hit of 0.92. Values are heuristic — # a Platt-scaling rebuild from labelled data is out of FYP scope, but these at # least stop the hardcoded layers from dominating live model output. _LAYER_CALIBRATION = { 'nucha': 1.0, 'jobbert': 1.0, 'skillner': 0.90, 'sbert': 0.85, 'lexical': 0.95, } class ResumeParseError(ValueError): """Raised for malformed or oversized inputs.""" @dataclass class SkillPrediction: skill_id: int skill_name: str proficiency: int confidence: float matched_span: str def to_dict(self) -> dict: return asdict(self) def _extract_text(pdf_bytes: bytes) -> str: if len(pdf_bytes) > MAX_PDF_BYTES: raise ResumeParseError( f"Resume exceeds the {MAX_PDF_BYTES // 1024 // 1024} MB upload limit." ) if not pdf_bytes: raise ResumeParseError("Empty upload.") try: with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf: pages = [] for page in pdf.pages: text = page.extract_text() or "" pages.append(text) if sum(len(p) for p in pages) > MAX_TEXT_CHARS: break full_text = "\n".join(pages) except Exception as exc: # pdfplumber raises a grab-bag of exceptions for corrupt PDFs; treat # them all as user input errors rather than 500s. raise ResumeParseError(f"Could not read PDF: {exc}") from exc if not full_text.strip(): raise ResumeParseError( "No text could be extracted. If your CV is a scanned image, " "re-export it as a text-based PDF." ) return full_text[:MAX_TEXT_CHARS] def _classify_proficiency(context: str) -> tuple[int, float]: """Return (proficiency, confidence_boost) from surrounding context. A short window around the matched span is all we get — this is purposely coarse. Higher layers can refine with a real classifier later. """ if _EXPERIENCE_MARKERS.search(context): return _STRONG_SIGNAL_PROFICIENCY, 0.10 if _NOVICE_MARKERS.search(context): return _NOVICE_PROFICIENCY, 0.05 return _DEFAULT_PROFICIENCY, 0.0 def _classify_aggregated(contexts: list[str]) -> tuple[int, float]: """Pick the strongest signal across all window contexts for a single skill. Short-circuits on STRONG (the ceiling). Ranks by _PROFICIENCY_RANK, not by raw proficiency value, so NOVICE ("beginner X") beats DEFAULT (silence) even though NOVICE=40 < DEFAULT=55. """ best = (_DEFAULT_PROFICIENCY, 0.0) best_rank = 0 for ctx in contexts: prof, boost = _classify_proficiency(ctx) rank = _PROFICIENCY_RANK.get(prof, 0) if rank > best_rank: best, best_rank = (prof, boost), rank if rank == 2: # STRONG — can't improve further. break return best def _windows_for(text: str, pattern: str, context_chars: int = 60) -> list[str]: """Yield ±context_chars windows around every match of `pattern` in text.""" windows = [] for match in re.finditer(pattern, text.lower()): start = max(0, match.start() - context_chars) end = min(len(text), match.end() + context_chars) windows.append(text[start:end]) return windows _TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9+#.\-]{2,}") def _candidate_matches(text: str, skills: list[Skill]) -> dict[int, dict]: """Return {skill_id: {'confidence', 'proficiency', 'matched_span'}} for every catalog skill that appears in the text. Two passes: 1. Exact (case-insensitive) boundary-aware substring hit → high confidence (0.95). We do NOT include '.' in the boundary character class because tech names like "Node.js" already round-trip through ``re.escape`` and sentence-final punctuation like "... SQL." must still match. 2. Token-level fuzzy match — for each token in the document that's long enough, rapidfuzz.fuzz.ratio against the catalog. ``partial_ ratio`` was rejected because it false-positives aggressively ("python" vs "sourdough" scored ~0.5). Per-token ``ratio`` with a high threshold (88) only fires on near-typos. """ hits: dict[int, dict] = {} for skill in skills: name = skill.skill_name.strip() if not name: continue needle = name.lower() # Word-boundary exact match — '\b' handles punctuation around # identifier-like tech names. '.' is intentionally excluded from the # boundary class so "SQL." at end-of-sentence still matches. pattern = r"(?` — lives next to this module. mod = __import__( f"apps.accounts.ner.{layer_name}", fromlist=["layer"], ) return getattr(mod, "layer", None) except Exception as exc: logger.warning("ner.%s: import failed, skipping (%s)", layer_name, exc) return None def _enabled_layers() -> list[str]: raw = os.environ.get("GAPGUIDE_PARSE_LAYERS", _DEFAULT_LAYERS) out = [s.strip() for s in raw.split(",") if s.strip()] # Always guarantee lexical at the end — contract says the floor fires. if "lexical" not in out: out.append("lexical") return out def _find_span_for(text: str, skill_name: str) -> tuple[int, str]: """Search the text for `skill_name` and derive (proficiency, matched_span). Used to enrich non-lexical layer hits with the same surrounding-context proficiency heuristic the lexical layer uses. If the skill name isn't literally in the text (the layer inferred it from a paraphrase), fall back to the default proficiency + empty span. Multi-window aggregation: if the skill appears multiple times, the strongest *signal* across windows wins (STRONG > NOVICE > DEFAULT). """ needle = re.escape(skill_name.lower()) pattern = r"(? Skill | None: """Map a free-text alias to a canonical Skill row. Exact match (case-insensitive) -> fuzzy match via rapidfuzz using the same threshold as the lexical layer. Returns None if nothing confident enough is found. Aliases too short to fuzzy-match just require an exact hit; keeps 'Go' and 'R' from matching arbitrary noun phrases. """ if not alias: return None key = alias.strip().lower() hit = skills_by_lower.get(key) if hit is not None: return hit if len(key) < _FUZZY_MIN_SKILL_LEN: return None candidate = process.extractOne( key, list(skills_by_lower.keys()), scorer=fuzz.ratio, score_cutoff=_FUZZY_TOKEN_SCORE, ) if candidate is None: return None matched_name, _, _ = candidate return skills_by_lower.get(matched_name) def _predict_skills_from_text(text: str) -> tuple[dict[int, dict], list[str]]: """Run enabled NER layers against `text` and merge their hits. Returns (hits_by_skill_id, fired_layers): - hits_by_skill_id: {skill_id: {confidence, proficiency, matched_span}} - fired_layers: layers that actually returned results (in order) Each layer runs synchronously. A layer that raises during predict() is logged at ERROR (with traceback) and skipped — the rest of the chain continues. (Import failures are caught earlier in _load_layer and logged at WARNING.) If ALL layers fail, the endpoint still returns 200 with the lexical-floor output (since lexical is always run). """ skills = list(Skill.objects.all().only('id', 'skill_name')) skills_by_lower = {s.skill_name.lower(): s for s in skills} hits: dict[int, dict] = {} fired: list[str] = [] for layer_name in _enabled_layers(): layer = _load_layer(layer_name) if layer is None or not layer.available(): continue try: # Run synchronously. A previous version ran each layer in a # ThreadPoolExecutor for timeout support, but Django's ORM uses # thread-local connections — the new thread never saw the test # transaction's uncommitted rows, so the lexical/sbert layers # found nothing under pytest-django. HF client libraries already # apply their own request timeouts, so dropping the local one # costs us only a theoretical hang on a misbehaving custom layer. layer_out = layer.predict(text) except Exception as exc: # Keep the catch broad (a flaky HF layer must never 500 the upload) # but log at ERROR with the traceback — the always-on net that makes # a silently-skipped layer (e.g. a wiring/construction bug) visible # in the logs instead of vanishing. logger.error( "ner.%s: raised %s, skipping", layer_name, exc, exc_info=True, ) continue if not layer_out: continue fired.append(layer_name) calibration = _LAYER_CALIBRATION.get(layer_name, 1.0) for alias, conf in layer_out.items(): skill = _resolve_alias(alias, skills_by_lower, skills) if skill is None: continue existing = hits.get(skill.id) # Calibrate per-layer so a 0.92 softmax isn't dominated by a # hardcoded lexical 0.95. Cap below 1.0 to keep downstream plots # away from the edge of the [0,1] axis. conf_f = round(min(float(conf) * calibration, 0.99), 3) if existing is None: prof, span = _find_span_for(text, skill.skill_name) hits[skill.id] = { 'confidence': conf_f, 'proficiency': prof, 'matched_span': span, } elif conf_f > existing['confidence']: existing['confidence'] = conf_f return hits, fired def extract_skills_from_pdf(pdf_bytes: bytes) -> list[dict]: """Parse a resume PDF and return ordered, catalog-resolved skill predictions. Kept as a list-only return to preserve the MVP test contract. The view uses :func:`parse_resume_envelope` instead, which also carries the list of layers that fired (the dispatcher's `parser_version`). """ env = parse_resume_envelope(pdf_bytes) return env['skills'] def parse_resume_envelope(pdf_bytes: bytes) -> dict: """Full response envelope: ``{'skills': [...], 'parser_version': [...]}``. `parser_version` is a list of layer names so the frontend can render a real "which layers fired" chip sequence (`["nucha", "lexical"]`) rather than the MVP's opaque `"mvp-lexical-v1"` string. """ text = _extract_text(pdf_bytes) scored, fired = _predict_skills_from_text(text) if not scored: return {'skills': [], 'parser_version': fired} skill_lookup = { s.id: s for s in Skill.objects.filter(id__in=scored.keys()) } predictions: list[SkillPrediction] = [] for skill_id, data in scored.items(): skill = skill_lookup.get(skill_id) if skill is None: continue predictions.append(SkillPrediction( skill_id=skill_id, skill_name=skill.skill_name, proficiency=int(data['proficiency']), confidence=float(data['confidence']), matched_span=str(data['matched_span'])[:200], )) predictions.sort(key=lambda p: (-p.confidence, p.skill_name)) return { 'skills': [p.to_dict() for p in predictions], 'parser_version': fired, }