import re from datetime import datetime # ── Resume Scoring ──────────────────────────────────── def score_resume_vs_job(resume_text: str, job: dict) -> int: """ Score how well a resume matches a job. Returns integer 0–100. Pure keyword + heuristic scoring (no LLM cost). """ if not resume_text or not job: return 0 resume_lower = resume_text.lower() score = 0 max_score = 0 # 1. Job title words in resume (up to 30 pts) title_words = _tokenize(job.get("title", "")) title_hits = sum(1 for w in title_words if w in resume_lower) title_score = int((title_hits / max(len(title_words), 1)) * 30) score += title_score max_score += 30 # 2. Tags / tech stack match (up to 40 pts) tags = [t.lower() for t in job.get("tags", [])] tag_hits = sum(1 for t in tags if t in resume_lower) tag_score = int((tag_hits / max(len(tags), 1)) * 40) score += tag_score max_score += 40 # 3. Description keywords in resume (up to 20 pts) desc_words = _tokenize(job.get("description", "")) important = _extract_important_words(desc_words) desc_hits = sum(1 for w in important if w in resume_lower) desc_score = int((desc_hits / max(len(important), 1)) * 20) score += desc_score max_score += 20 # 4. Resume length bonus (up to 10 pts) word_count = len(resume_text.split()) if word_count > 300: score += 10 elif word_count > 150: score += 5 max_score += 10 # Normalize to 100 final = int((score / max_score) * 100) if max_score > 0 else 0 return min(final, 100) def rank_jobs(resume_text: str, jobs: list) -> list: """ Return jobs sorted by match score (highest first). Adds 'match_score' key to each job dict. """ scored = [] for job in jobs: job_copy = dict(job) job_copy["match_score"] = score_resume_vs_job(resume_text, job) scored.append(job_copy) scored.sort(key=lambda x: x["match_score"], reverse=True) return scored # ── Text Helpers ────────────────────────────────────── def _tokenize(text: str) -> list: """Split text into lowercase word tokens, remove noise.""" words = re.findall(r'\b[a-zA-Z]{3,}\b', text.lower()) stopwords = { "the", "and", "for", "with", "this", "that", "are", "you", "will", "have", "our", "your", "from", "not", "but", "can", "all", "work", "team", "role", "join", "looking", "able", "must", "help", "well", "also", "more", "who", "what", "how", "when", "they", "their", "about", "into" } return [w for w in words if w not in stopwords] def _extract_important_words(words: list) -> list: """Return the most meaningful/unique words for matching.""" # Prioritize longer words (usually technical terms) return list(set([w for w in words if len(w) > 4]))[:40] def format_match_score(score: int) -> tuple: """Return (emoji, label, color) for a score.""" if score >= 80: return "🟢", "Excellent Match", "green" elif score >= 60: return "🟡", "Good Match", "orange" elif score >= 40: return "🟠", "Fair Match", "orange" else: return "🔴", "Low Match", "red" # ── Date Helpers ────────────────────────────────────── def format_date(iso_string: str) -> str: """Convert ISO timestamp to human-readable.""" if not iso_string: return "Unknown" try: dt = datetime.fromisoformat(iso_string.replace("Z", "+00:00")) return dt.strftime("%b %d, %Y at %I:%M %p") except Exception: return iso_string[:10] def days_ago(iso_string: str) -> str: """Return '3 days ago' style string.""" if not iso_string: return "" try: dt = datetime.fromisoformat(iso_string.replace("Z", "+00:00")) delta = datetime.now(dt.tzinfo) - dt days = delta.days if days == 0: return "Today" elif days == 1: return "Yesterday" else: return f"{days} days ago" except Exception: return "" # ── Resume Helpers ──────────────────────────────────── def extract_name_from_resume(resume_text: str) -> str: """Best-effort extract name from first line of resume.""" if not resume_text: return "Candidate" first_line = resume_text.strip().split("\n")[0].strip() if len(first_line) < 40: return first_line return "Candidate" def truncate(text: str, max_chars: int = 300) -> str: """Truncate text with ellipsis.""" if not text: return "" if len(text) <= max_chars: return text return text[:max_chars].rstrip() + "..." def sanitize_for_json(text: str) -> str: """Remove characters that break JSON storage.""" return text.replace("\x00", "").strip()