/** * resumeParser.ts * Parses raw resume text into a structured CandidateProfile. * Uses regex heuristics — no external NLP dependency required. */ import type { CandidateProfile, Education, Role } from '../schema/carouselSchema'; // --------------------------------------------------------------------------- // Patterns // --------------------------------------------------------------------------- // Date range patterns: "Jan 2020 – Present", "2018-2021", "2020 to 2022" const DATE_RANGE_PATTERN = /(\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\.?\s+\d{4}|\d{4})\s*(?:–|—|-|to|–)\s*(Present|Current|Now|\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\.?\s+\d{4}|\d{4})/gi; // Job title patterns (common title words) const TITLE_INDICATOR_WORDS = [ 'Engineer', 'Manager', 'Director', 'Analyst', 'Designer', 'Developer', 'Lead', 'Head', 'VP', 'President', 'Consultant', 'Specialist', 'Architect', 'Scientist', 'Officer', 'Principal', 'Staff', 'Senior', 'Junior', 'Associate', 'Product', 'Data', 'Software', 'Marketing', 'Sales', 'Operations', 'Finance', 'Researcher', 'Coordinator', 'Administrator', 'Strategist', 'Executive', ]; // Bullet starters — lines that look like achievements const BULLET_LINE_PATTERN = /^[\s]*[•\-\*\u2022\u2023\u25CF\u25E6>]\s*(.+)$/; const NUMBERED_BULLET_PATTERN = /^[\s]*\d+\.\s+(.+)$/; // Metric patterns to extract const METRIC_PATTERNS = [ /\$[\d,]+\.?\d*[KMBkm]?/g, // dollar amounts /\d+\.?\d*[KMBkm]?\s*%/g, // percentages /\d+\.?\d*\s*[xX]\s+(?:faster|better|more|improvement)/g, // multipliers /[\d,]+\s*(?:users|customers|employees|engineers|transactions|requests)/gi, // counts /\d+\+\s*(?:years?|months?)/g, // duration ]; // Ownership verbs const OWNERSHIP_VERB_LIST = [ 'led', 'built', 'launched', 'founded', 'created', 'designed', 'architected', 'developed', 'drove', 'owned', 'managed', 'defined', 'pioneered', 'introduced', 'established', 'delivered', 'shipped', 'deployed', 'implemented', 'structured', 'restructured', 'migrated', 'scaled', 'automated', ]; // Collaboration/participation verbs const COLLAB_VERB_LIST = [ 'helped', 'supported', 'assisted', 'contributed', 'collaborated', 'partnered', 'worked with', 'participated', 'joined', 'reported to', ]; // Common tools/tech stack keywords const TECH_KEYWORDS = [ 'python', 'sql', 'java', 'javascript', 'typescript', 'go', 'rust', 'c++', 'c#', 'react', 'node', 'angular', 'vue', 'next.js', 'express', 'aws', 'gcp', 'azure', 'kubernetes', 'docker', 'terraform', 'spark', 'hadoop', 'kafka', 'airflow', 'dbt', 'fivetran', 'postgres', 'mysql', 'mongodb', 'redis', 'elasticsearch', 'snowflake', 'bigquery', 'redshift', 'tableau', 'looker', 'power bi', 'domo', 'mode', 'figma', 'sketch', 'invision', 'jira', 'confluence', 'notion', 'airtable', 'salesforce', 'hubspot', 'marketo', 'git', 'github', 'gitlab', 'jenkins', 'circleci', 'tensorflow', 'pytorch', 'scikit-learn', 'pandas', 'numpy', 'llm', 'openai', 'langchain', 'anthropic', ]; // Domain/industry keywords const DOMAIN_KEYWORDS = [ 'fintech', 'finance', 'banking', 'payments', 'lending', 'insurance', 'healthtech', 'healthcare', 'medtech', 'clinical', 'pharma', 'edtech', 'education', 'elearning', 'e-commerce', 'retail', 'marketplace', 'saas', 'enterprise software', 'platform', 'logistics', 'supply chain', 'manufacturing', 'media', 'entertainment', 'gaming', 'real estate', 'proptech', 'adtech', 'marketing technology', 'security', 'cybersecurity', 'mobility', 'transportation', 'automotive', 'ai', 'machine learning', 'data', 'analytics', ]; // --------------------------------------------------------------------------- // Education parsing helpers // --------------------------------------------------------------------------- const DEGREE_KEYWORD_PATTERN = /\b(bachelor(?:'?s)?|b\.?s\.?|b\.?a\.?|b\.?e\.?|master(?:'?s)?|m\.?s\.?|m\.?a\.?|m\.?b\.?a\.?|mba|m\.?eng\.?|doctor(?:ate)?|ph\.?d\.?|associate(?:'?s)?|a\.?s\.?|a\.?a\.?|certificate|diploma)\b/i; const EDU_YEAR_PATTERN = /\b(19|20)\d{2}\b/; function parseEducationLine(line: string): Education | null { const trimmed = line.trim(); if (trimmed.length < 4) return null; if (!DEGREE_KEYWORD_PATTERN.test(trimmed) && !EDU_YEAR_PATTERN.test(trimmed)) return null; const yearMatch = trimmed.match(EDU_YEAR_PATTERN); const graduationYear = yearMatch ? yearMatch[0] : undefined; // Remove the year and surrounding punctuation, then split on separators const withoutYear = trimmed.replace(/\s*[\|–—,]\s*\d{4}/, '').replace(EDU_YEAR_PATTERN, '').replace(/[-–|,]+$/, '').trim(); const parts = withoutYear.split(/\s*[|–—]\s*/).map((p) => p.trim()).filter(Boolean); let degree = ''; let institution = ''; let field = ''; if (parts.length >= 2) { const hasDegree0 = DEGREE_KEYWORD_PATTERN.test(parts[0]); if (hasDegree0) { degree = parts[0]; institution = parts[1]; field = parts[2] ?? ''; } else { institution = parts[0]; degree = parts[1]; field = parts[2] ?? ''; } } else if (parts.length === 1) { if (DEGREE_KEYWORD_PATTERN.test(parts[0])) { degree = parts[0]; } else { institution = parts[0]; } } if (!degree && !institution) return null; return { degree: degree || 'Degree', institution: institution || '', graduationYear, field: field || undefined, }; } // --------------------------------------------------------------------------- // Section detection helpers // --------------------------------------------------------------------------- type ResumeSection = 'header' | 'experience' | 'education' | 'skills' | 'projects' | 'other'; function detectSection(line: string): ResumeSection | null { const lower = line.toLowerCase().trim(); if (/^(experience|work experience|employment|professional experience|career)s?:?$/.test(lower)) return 'experience'; if (/^(education|academic|schooling|degrees?):?$/.test(lower)) return 'education'; if (/^(skills?|technical skills?|core competencies|technologies):?$/.test(lower)) return 'skills'; if (/^(projects?|side projects?|personal projects?|open source):?$/.test(lower)) return 'projects'; return null; } // --------------------------------------------------------------------------- // Role block parser // --------------------------------------------------------------------------- // Patterns that disqualify a line from being a role header const CONTACT_LINE_PATTERN = /[\w.+-]+@[\w-]+\.\w+|\(\d{3}\)[\s-]?\d{3}[\s-]?\d{4}|\blinkedin\.com\b|\bgithub\.com\b/i; const URL_PATTERN = /https?:\/\/|www\./i; function looksLikeRoleHeader(line: string): boolean { const stripped = line.trim(); if (stripped.length < 3 || stripped.length > 120) return false; // Reject contact info lines (email, phone, LinkedIn, GitHub URLs) if (CONTACT_LINE_PATTERN.test(stripped)) return false; if (URL_PATTERN.test(stripped)) return false; // Reject lines that are purely uppercase section headers (e.g. "SUMMARY", "EXPERIENCE") if (/^[A-Z\s]+$/.test(stripped) && stripped.split(/\s+/).length <= 3) return false; // Must contain at least one title indicator word const hasTitleWord = TITLE_INDICATOR_WORDS.some((word) => stripped.toLowerCase().includes(word.toLowerCase()) ); // Common separators: "Title at Company", "Title, Company", "Title | Company" const hasSeparator = /\bat\b|,\s*[A-Z]|\|\s*[A-Z]|–\s*[A-Z]/.test(stripped); return hasTitleWord || hasSeparator; } // City/state patterns — used to detect location tokens so we don't confuse them with employers const CITY_STATE_PATTERN = /^[A-Z][a-zA-Z\s]+,?\s*(AZ|CA|NY|TX|WA|IL|FL|MA|GA|CO|OH|NC|VA|PA|NJ|OR|MN|MI|MD|UT|DC|Remote)$/i; function parseRoleHeader(line: string): { title: string; employer: string } { // Try "Title at Employer" pattern const atMatch = line.match(/^(.+?)\s+at\s+(.+?)(?:\s*[\|,–].*)?$/i); if (atMatch) return { title: atMatch[1].trim(), employer: atMatch[2].trim() }; // Resume format: "Title - Company | City, State | Dates" // First, split on "|" to isolate the first segment (title + company) const pipeParts = line.split('|').map((p) => p.trim()); if (pipeParts.length >= 2) { const firstSegment = pipeParts[0]; // e.g. "Senior Data Analyst - Sanluna" // Try to extract "Title - Company" from the first segment const dashMatch = firstSegment.match(/^(.+?)\s*[-–]\s*(.+)$/); if (dashMatch) { const possibleTitle = dashMatch[1].trim(); const possibleEmployer = dashMatch[2].trim(); // Validate: the employer part should look like a company, not a city if (!CITY_STATE_PATTERN.test(possibleEmployer) && possibleEmployer.length < 60) { return { title: possibleTitle, employer: possibleEmployer }; } } // Fallback: first pipe segment = title, second = try to skip city to find company for (let i = 1; i < pipeParts.length; i++) { const candidate = pipeParts[i]; if (!CITY_STATE_PATTERN.test(candidate) && !/^\d{4}/.test(candidate)) { return { title: firstSegment, employer: candidate }; } } return { title: firstSegment, employer: '' }; } // Fallback: "Title, Employer" comma separator const commaMatch = line.match(/^(.+?),\s*([A-Z][A-Za-z0-9\s&.]+)$/); if (commaMatch && !CITY_STATE_PATTERN.test(commaMatch[2])) { return { title: commaMatch[1].trim(), employer: commaMatch[2].trim() }; } // Last resort: try splitting on dash const dashMatch = line.match(/^(.+?)\s*[-–]\s*(.+)$/); if (dashMatch) return { title: dashMatch[1].trim(), employer: dashMatch[2].trim() }; return { title: line.trim(), employer: '' }; } // --------------------------------------------------------------------------- // Main parser // --------------------------------------------------------------------------- /** * Parse raw resume text into a CandidateProfile. * Handles messy, inconsistent formatting. */ export function parseResume(rawText: string): CandidateProfile { const lines = rawText.split('\n'); const roles: Role[] = []; const education: Education[] = []; const allBullets: string[] = []; const projects: string[] = []; const toolsSet = new Set(); const metricsSet = new Set(); const domainsSet = new Set(); const ownershipIndicators: string[] = []; const collaborationIndicators: string[] = []; let currentSection: ResumeSection = 'header'; let currentRole: Role | null = null; // Pass 1: Extract tools and domains from full text const fullLower = rawText.toLowerCase(); for (const tech of TECH_KEYWORDS) { if (fullLower.includes(tech)) toolsSet.add(tech); } for (const domain of DOMAIN_KEYWORDS) { if (fullLower.includes(domain)) domainsSet.add(domain); } // Extract all metrics from raw text for (const pattern of METRIC_PATTERNS) { const matches = rawText.match(pattern) || []; matches.forEach((m) => metricsSet.add(m)); } // Pass 2: Line-by-line structural parsing for (let i = 0; i < lines.length; i++) { const line = lines[i]; const trimmed = line.trim(); if (!trimmed) continue; // Skip blank lines // Section header detection const newSection = detectSection(trimmed); if (newSection) { if (currentRole) { roles.push(currentRole); currentRole = null; } currentSection = newSection; continue; } // Bullet detection const bulletMatch = trimmed.match(BULLET_LINE_PATTERN) || trimmed.match(NUMBERED_BULLET_PATTERN); if (bulletMatch) { const bulletText = bulletMatch[1].trim(); if (currentSection === 'experience' && currentRole) { currentRole.bullets.push(bulletText); allBullets.push(bulletText); } else if (currentSection === 'projects') { projects.push(bulletText); } else if (currentSection !== 'education') { // Could be experience bullets before we detected a header allBullets.push(bulletText); } // Classify ownership/collaboration from this bullet const lowerBullet = bulletText.toLowerCase(); const foundOwnership = OWNERSHIP_VERB_LIST.find((v) => lowerBullet.startsWith(v)); if (foundOwnership) ownershipIndicators.push(foundOwnership); const foundCollab = COLLAB_VERB_LIST.find((v) => lowerBullet.includes(v)); if (foundCollab) collaborationIndicators.push(foundCollab); continue; } // Education section — parse degree/institution entries if (currentSection === 'education' && !bulletMatch) { const edu = parseEducationLine(trimmed); if (edu) education.push(edu); continue; } // Skills section — extract tool mentions if (currentSection === 'skills') { const skillTokens = trimmed.split(/[,;|\/•]/); for (const token of skillTokens) { const clean = token.trim().toLowerCase(); if (clean.length > 1 && clean.length < 50) { toolsSet.add(clean); } } continue; } // Experience section — detect role headers if (currentSection === 'experience' || currentSection === 'header') { // Check if line contains a date range const dateMatches = [...trimmed.matchAll(DATE_RANGE_PATTERN)]; const dateStr = dateMatches[0]?.[0] ?? ''; const lineWithoutDate = trimmed.replace(DATE_RANGE_PATTERN, '').trim(); if (looksLikeRoleHeader(lineWithoutDate || trimmed)) { // Save previous role if (currentRole) roles.push(currentRole); currentSection = 'experience'; const { title, employer } = parseRoleHeader(lineWithoutDate || trimmed); const isPresent = /present|current|now/i.test(dateStr); // Extract start/end from date range let startDate: string | undefined; let endDate: string | undefined; if (dateMatches[0]) { startDate = dateMatches[0][1]; endDate = isPresent ? 'Present' : dateMatches[0][2]; } currentRole = { title: title || trimmed, employer: employer || '', startDate, endDate, isCurrent: isPresent, bullets: [], }; } } } // Don't forget the last role if (currentRole) roles.push(currentRole); // If no structured roles were parsed (very flat resume), treat all bullets as coming from first "unknown" role if (roles.length === 0 && allBullets.length > 0) { roles.push({ title: 'Unknown Role', employer: 'Unknown Employer', isCurrent: false, bullets: allBullets.slice(), }); } const employers = roles.map((r) => r.employer).filter(Boolean); return { roles, employers, education, allBullets, projects, tools: [...toolsSet], metrics: [...metricsSet], domains: [...domainsSet], ownershipIndicators: [...new Set(ownershipIndicators)], collaborationIndicators: [...new Set(collaborationIndicators)], rawText, }; }