gapguide-api / apps /accounts /resume_parser.py
arifRB's picture
Deploy GapGuide backend (Docker)
ffd36e0 verified
Raw
History Blame Contribute Delete
18.3 kB
"""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"(?<![A-Za-z0-9+#\-])" + re.escape(needle) + r"(?![A-Za-z0-9+#\-])"
windows = _windows_for(text, pattern)
if windows:
prof, boost = _classify_aggregated(windows)
# First window is kept as the human-readable matched_span — the
# strongest-signal window may be far away, and the first occurrence
# is what the reviewer will visually scan for.
hits[skill.id] = {
'confidence': round(min(0.95 + boost, 0.99), 3),
'proficiency': prof,
'matched_span': windows[0].strip(),
}
# Fuzzy pass: build the document's token set once, then for each unmatched
# skill find its best-matching token (rapidfuzz.process.extractOne).
tokens = list({m.group(0) for m in _TOKEN_RE.finditer(text)})
if not tokens:
return hits
for skill in skills:
if skill.id in hits:
continue
name = skill.skill_name.strip()
if len(name) < _FUZZY_MIN_SKILL_LEN:
# Short names ('Go', 'R', 'SQL') only travel the exact path.
continue
candidate = process.extractOne(
name.lower(),
[t.lower() for t in tokens],
scorer=fuzz.ratio,
score_cutoff=_FUZZY_TOKEN_SCORE,
)
if candidate is None:
continue
_, score, idx = candidate
hits[skill.id] = {
'confidence': round(score / 100.0, 3),
'proficiency': _DEFAULT_PROFICIENCY,
'matched_span': tokens[idx],
}
return hits
def _load_layer(layer_name: str):
"""Lazy-import a layer module by name. Returns None if import fails.
Done this way (rather than an eager registry) so a CI run pinned to
``GAPGUIDE_PARSE_LAYERS=lexical`` never imports transformers/torch.
"""
try:
# `apps.accounts.ner.<name>` — 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"(?<![A-Za-z0-9+#\-])" + needle + r"(?![A-Za-z0-9+#\-])"
windows = _windows_for(text, pattern)
if not windows:
return _DEFAULT_PROFICIENCY, ''
prof, _ = _classify_aggregated(windows)
return prof, windows[0].strip()
def _resolve_alias(alias: str, skills_by_lower: dict, all_skills: list[Skill]) -> 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,
}