SmartHire-AI / src /preprocess.py
Vishu2006's picture
Initial commit: SmartHire-AI FastAPI + Streamlit
91e794e
Raw
History Blame Contribute Delete
7.85 kB
"""
preprocess.py
-------------
Production-grade text preprocessing for resumes and job descriptions.
Key improvements over v1:
- Preserves C++, C#, .NET, F# -- no longer stripped
- Section-aware parsing (extracts structured sections)
- Experience-year extraction (regex: "5+ years Python")
- Cleans PDF artifacts (ligatures, bullet symbols, page numbers)
- UTF-8 safe with smart unicode normalization
Author: SmartHire AI
"""
import re
import logging
import unicodedata
from typing import Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
# -- Constants ---------------------------------------------------------
MAX_TOKENS = 512
CHARS_PER_TOK = 3.8 # tighter estimate for technical text
# Resume section headers
SECTION_HEADERS = [
"experience", "work experience", "employment", "professional experience",
"education", "skills", "technical skills", "core competencies",
"projects", "certifications", "achievements", "summary", "objective",
"publications", "languages", "interests", "volunteer",
]
# PDF ligature replacements
LIGATURE_MAP = {
"\ufb01": "fi", "\ufb02": "fl", "\ufb00": "ff",
"\ufb03": "ffi", "\ufb04": "ffl", "\u2019": "'",
"\u2018": "'", "\u201c": '"', "\u201d": '"',
"\u2013": "-", "\u2014": "-", "\u2022": " ",
"\u25cf": " ", "\u25aa": " ", "\u2023": " ",
"\xa0": " ", # non-breaking space
}
# Tech terms that must NOT be stripped
PRESERVE_TERMS = {
"c++": "cplusplus", "c#": "csharp", ".net": "dotnet",
"f#": "fsharp", "node.js": "nodejs", "vue.js": "vuejs",
"next.js": "nextjs", "express.js": "expressjs",
"asp.net": "aspnet", "ado.net": "adonet",
}
RESTORE_TERMS = {v: k for k, v in PRESERVE_TERMS.items()}
def fix_ligatures(text: str) -> str:
"""Replace common PDF ligature artifacts with ASCII equivalents."""
for char, replacement in LIGATURE_MAP.items():
text = text.replace(char, replacement)
return text
def protect_tech_terms(text: str) -> str:
"""Temporarily replace C++, C#, .NET etc. before lowercasing."""
for term, placeholder in PRESERVE_TERMS.items():
text = re.sub(re.escape(term), placeholder, text, flags=re.IGNORECASE)
return text
def restore_tech_terms(text: str) -> str:
"""Restore protected tech terms after preprocessing."""
for placeholder, term in RESTORE_TERMS.items():
text = text.replace(placeholder, term)
return text
def normalize_unicode(text: str) -> str:
"""NFKC normalization -- preserves more chars than NFKD."""
return unicodedata.normalize("NFKC", text)
def remove_urls_emails(text: str) -> str:
"""Strip URLs and email addresses."""
text = re.sub(r"https?://\S+|www\.\S+", "", text)
text = re.sub(r"\S+@\S+\.\S+", "", text)
return text
def remove_pdf_artifacts(text: str) -> str:
"""Remove common PDF-extraction noise: page numbers, divider lines."""
text = re.sub(r"^\s*\d{1,3}\s*$", "", text, flags=re.MULTILINE)
text = re.sub(r"[-_=]{4,}", " ", text)
text = re.sub(r"\|{2,}", " ", text)
return text
def clean_special_characters(text: str) -> str:
"""Remove non-useful special chars while preserving tech punctuation."""
text = re.sub(r"[^\w\s\.\,\-\+\#\/\(\)\@\%\&]", " ", text)
return text
def collapse_whitespace(text: str) -> str:
"""Normalize all whitespace to single spaces/newlines."""
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def extract_experience_years(text: str) -> Dict[str, int]:
"""
Extract experience mentions like '5+ years Python', '3 years of AWS'.
Returns:
Dict mapping skill -> years, e.g. {"python": 5, "aws": 3}
"""
pattern = re.compile(
r"(\d+)\+?\s*(?:years?|yrs?)(?:\s+of)?\s+([a-zA-Z][a-zA-Z0-9\+\#\.\/\s]{1,30})",
re.IGNORECASE
)
results = {}
for match in pattern.finditer(text):
years = int(match.group(1))
skill = match.group(2).strip().lower().rstrip(".,;:")
if len(skill) >= 2:
results[skill] = years
return results
def extract_sections(text: str) -> Dict[str, str]:
"""
Identify resume sections and return section_name -> content dict.
"""
sections: Dict[str, str] = {}
current_section = "header"
current_lines: List[str] = []
header_pattern = re.compile(
r"^(" + "|".join(re.escape(h) for h in SECTION_HEADERS) + r")\s*:?\s*$",
re.IGNORECASE
)
for line in text.split("\n"):
stripped = line.strip()
if header_pattern.match(stripped):
if current_lines:
sections[current_section] = "\n".join(current_lines).strip()
current_section = stripped.lower().rstrip(":")
current_lines = []
else:
current_lines.append(line)
if current_lines:
sections[current_section] = "\n".join(current_lines).strip()
return sections
def truncate_to_token_budget(text: str, max_tokens: int = MAX_TOKENS) -> str:
"""Truncate to token budget using character heuristic."""
max_chars = int(max_tokens * CHARS_PER_TOK)
if len(text) <= max_chars:
return text
logger.warning(f"Truncating text from {len(text)} chars to ~{max_chars}")
truncated = text[:max_chars]
for sep in [". ", ".\n", "! ", "? "]:
last = truncated.rfind(sep)
if last > max_chars * 0.85:
return truncated[:last + 1].strip()
last_space = truncated.rfind(" ")
if last_space > max_chars * 0.9:
return truncated[:last_space].strip()
return truncated.strip()
def preprocess_text(
text: str,
lowercase: bool = True,
remove_urls: bool = True,
fix_pdf: bool = True,
truncate: bool = True,
max_tokens: int = MAX_TOKENS,
preserve_sections: bool = False,
) -> str:
"""
Full production preprocessing pipeline.
Steps:
1. Fix PDF ligatures & artifacts
2. Unicode normalize (NFKC)
3. Protect tech terms (C++, C#, .NET)
4. Remove URLs / emails
5. Lowercase
6. Clean special characters
7. Restore tech terms
8. Collapse whitespace
9. Truncate to token budget
Args:
text : Raw input text.
lowercase : Lowercase the text (default True).
remove_urls : Strip URLs and emails (default True).
fix_pdf : Fix PDF extraction artifacts (default True).
truncate : Truncate to token limit (default True).
max_tokens : Token budget (default 512).
preserve_sections: Skip truncation for section parsing.
Returns:
Cleaned, normalized text.
Raises:
ValueError: If text is empty after processing.
"""
if not text or not text.strip():
raise ValueError("Input text is empty.")
if fix_pdf:
text = fix_ligatures(text)
text = remove_pdf_artifacts(text)
text = normalize_unicode(text)
text = protect_tech_terms(text)
if remove_urls:
text = remove_urls_emails(text)
if lowercase:
text = text.lower()
text = clean_special_characters(text)
text = restore_tech_terms(text)
text = collapse_whitespace(text)
if not text.strip():
raise ValueError("Text is empty after preprocessing.")
if truncate and not preserve_sections:
text = truncate_to_token_budget(text, max_tokens)
logger.debug(f"Preprocessed: {len(text)} chars")
return text
def tokenize_words(text: str) -> List[str]:
"""Tokenize into word list (min length 2)."""
return [t for t in re.findall(r"\b[a-z][a-z0-9\+\#\.]*\b", text.lower()) if len(t) >= 2]
def clean_skill_token(skill: str) -> str:
"""Normalize a skill string for comparison."""
return re.sub(r"\s+", " ", skill.strip().lower())