/** * jobParser.ts * Parses job description text into a structured TargetRoleProfile. * Handles both structured JDs (with sections) and unstructured prose. */ import type { TargetRoleProfile } from '../schema/carouselSchema'; // --------------------------------------------------------------------------- // Seniority detection // --------------------------------------------------------------------------- type Seniority = 'entry' | 'mid' | 'senior' | 'staff' | 'principal' | 'director' | 'unknown'; const SENIORITY_MAP: Array<{ pattern: RegExp; level: Seniority }> = [ { pattern: /\b(vp|vice president|c-suite|cto|cpo|ceo|chief)\b/i, level: 'director' }, { pattern: /\b(director|head of)\b/i, level: 'director' }, { pattern: /\b(principal|distinguished)\b/i, level: 'principal' }, { pattern: /\b(staff)\b/i, level: 'staff' }, { pattern: /\b(senior|sr\.?)\b/i, level: 'senior' }, { pattern: /\b(junior|jr\.?|entry.?level|new grad|associate|early career)\b/i, level: 'entry' }, { pattern: /\b(mid.?level|intermediate|ii|iii)\b/i, level: 'mid' }, ]; function detectSeniority(text: string): Seniority { for (const { pattern, level } of SENIORITY_MAP) { if (pattern.test(text)) return level; } return 'unknown'; } // --------------------------------------------------------------------------- // Skill extraction // --------------------------------------------------------------------------- // Common required-skill section headers const REQUIRED_SECTION_PATTERNS = [ /required\s+(?:skills?|qualifications?|experience)/i, /you\s+(?:must|will)\s+have/i, /what\s+we(?:'re|\s+are)\s+looking\s+for/i, /minimum\s+qualifications?/i, /basic\s+qualifications?/i, ]; const PREFERRED_SECTION_PATTERNS = [ /preferred\s+(?:skills?|qualifications?|experience)/i, /nice\s+to\s+have/i, /bonus\s+(?:points?|if\s+you)/i, /plus(?:es?)?:/i, /additional\s+qualifications?/i, ]; // Skill indicator phrases that precede skill mentions const SKILL_INDICATORS = [ /experience\s+(?:with|in)\s+(.+?)(?:[,;.]|$)/gi, /proficiency\s+(?:with|in)\s+(.+?)(?:[,;.]|$)/gi, /knowledge\s+of\s+(.+?)(?:[,;.]|$)/gi, /familiarity\s+with\s+(.+?)(?:[,;.]|$)/gi, /expertise\s+in\s+(.+?)(?:[,;.]|$)/gi, /strong\s+(?:background|understanding|grasp)\s+in\s+(.+?)(?:[,;.]|$)/gi, ]; // Technical stack keywords to detect in JD const JD_TECH_KEYWORDS = [ 'python', 'sql', 'java', 'javascript', 'typescript', 'go', 'rust', 'scala', 'react', 'node', 'angular', 'django', 'fastapi', 'spring', 'aws', 'gcp', 'azure', 'kubernetes', 'docker', 'terraform', 'helm', 'spark', 'kafka', 'airflow', 'dbt', 'snowflake', 'bigquery', 'redshift', 'tableau', 'looker', 'power bi', 'postgresql', 'mysql', 'mongodb', 'redis', 'elasticsearch', 'tensorflow', 'pytorch', 'scikit-learn', 'ml', 'machine learning', 'llm', 'nlp', 'computer vision', 'figma', 'sketch', 'rest', 'graphql', 'grpc', 'api', 'git', 'github', 'ci/cd', 'a/b testing', 'experimentation', 'agile', 'scrum', 'kanban', ]; // Soft skill / leadership keywords const LEADERSHIP_KEYWORDS = [ 'lead', 'mentor', 'manage', 'cross-functional', 'stakeholder', 'executive', 'strategy', 'roadmap', 'influence', 'align', 'collaborate', 'communicate', 'drive', 'own', 'accountability', 'team', 'hire', 'grow', 'scale organization', ]; // Domain/industry clues const DOMAIN_SIGNALS: Record = { fintech: ['payments', 'financial', 'banking', 'lending', 'transactions', 'fraud', 'compliance'], healthcare: ['clinical', 'patient', 'ehr', 'hipaa', 'medical', 'health'], ecommerce: ['e-commerce', 'marketplace', 'retail', 'inventory', 'fulfillment'], enterprise: ['b2b', 'enterprise', 'saas', 'crm', 'erp'], consumer: ['b2c', 'consumer', 'mobile app', 'growth', 'engagement'], data: ['analytics', 'data warehouse', 'bi', 'reporting', 'dashboard', 'insights'], infrastructure: ['platform', 'infrastructure', 'devops', 'reliability', 'observability'], }; // --------------------------------------------------------------------------- // Section-aware parsing // --------------------------------------------------------------------------- type JDSection = 'intro' | 'responsibilities' | 'required' | 'preferred' | 'benefits' | 'about'; function detectJDSection(line: string): JDSection | null { const lower = line.toLowerCase().trim(); if (/^about (us|the role|this role|the company|the team):?$/i.test(lower)) return 'about'; if (/^(responsibilities|what you('ll| will) do|the role|your role|key responsibilities):?$/i.test(lower)) return 'responsibilities'; if (REQUIRED_SECTION_PATTERNS.some((p) => p.test(lower))) return 'required'; if (PREFERRED_SECTION_PATTERNS.some((p) => p.test(lower))) return 'preferred'; if (/^(benefits?|perks?|compensation|what we offer):?$/i.test(lower)) return 'benefits'; return null; } // --------------------------------------------------------------------------- // Skill extraction from a line // --------------------------------------------------------------------------- function extractSkillsFromLine(line: string): string[] { const skills: string[] = []; const lower = line.toLowerCase(); // Check for known tech keywords for (const tech of JD_TECH_KEYWORDS) { if (lower.includes(tech)) skills.push(tech); } // Extract from "experience with X" patterns for (const pattern of SKILL_INDICATORS) { let match: RegExpExecArray | null; pattern.lastIndex = 0; // reset global regex while ((match = pattern.exec(line)) !== null) { const extracted = match[1].trim(); // Split on commas/and for multi-skill extractions const parts = extracted.split(/,|\band\b/).map((s) => s.trim()).filter((s) => s.length > 1 && s.length < 40); skills.push(...parts); } } return [...new Set(skills)]; } // --------------------------------------------------------------------------- // Recruiter priority signal extraction // --------------------------------------------------------------------------- /** * Extract the top recurring themes/requirements from the JD text. * These indicate what the recruiter cares most about. */ function extractRecruiterPriorities(text: string): string[] { const priorities: string[] = []; const lower = text.toLowerCase(); // Count how many times key phrases appear (more = higher priority) const candidates = [ 'communication', 'collaboration', 'cross-functional', 'stakeholder', 'data-driven', 'analytical', 'metrics', 'ownership', 'impact', 'leadership', 'scale', 'growth', 'technical', 'strategic', 'customer', 'product sense', 'execution', 'delivery', ]; const counts: [string, number][] = candidates .map((c) => [c, (lower.match(new RegExp(c, 'g')) || []).length] as [string, number]) .filter(([, count]) => count > 0) .sort(([, a], [, b]) => b - a); return counts.slice(0, 5).map(([phrase]) => phrase); } // --------------------------------------------------------------------------- // Main parser // --------------------------------------------------------------------------- /** * Parse raw job description text into a TargetRoleProfile. */ export function parseJobDescription(rawText: string): TargetRoleProfile { const lines = rawText.split('\n'); const requiredSkills: string[] = []; const preferredSkills: string[] = []; const leadershipSignals: string[] = []; const themes: string[] = []; const domainClues: string[] = []; let title = ''; let company = ''; let currentSection: JDSection = 'intro'; const lowerFull = rawText.toLowerCase(); // Extract title from first non-empty line, stripping common label prefixes const TITLE_LABEL_PREFIX = /^(job\s+title|position|role|title|opening|requisition)[:\s]+/i; for (const line of lines) { const trimmed = line.trim(); if (trimmed && trimmed.length < 100) { title = trimmed.replace(TITLE_LABEL_PREFIX, '').trim(); break; } } // Extract company from common patterns const companyMatch = rawText.match(/(?:at|@|company:|employer:)\s+([A-Z][A-Za-z0-9\s&,.]+?)(?:\n|,|–|\|)/); if (companyMatch) company = companyMatch[1].trim(); // Detect seniority from title line const seniority = detectSeniority(title); // Process line by line for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; const newSection = detectJDSection(trimmed); if (newSection) { currentSection = newSection; continue; } const extractedSkills = extractSkillsFromLine(trimmed); if (currentSection === 'required' || currentSection === 'responsibilities') { requiredSkills.push(...extractedSkills); // Check for leadership signals for (const keyword of LEADERSHIP_KEYWORDS) { if (trimmed.toLowerCase().includes(keyword)) { leadershipSignals.push(keyword); } } } else if (currentSection === 'preferred') { preferredSkills.push(...extractedSkills); } else { // From any section, capture tech mentions requiredSkills.push(...extractedSkills); } } // Extract domain clues from full text for (const [domain, signals] of Object.entries(DOMAIN_SIGNALS)) { const matchCount = signals.filter((s) => lowerFull.includes(s)).length; if (matchCount >= 2) { domainClues.push(domain); } } // Extract themes from full text const commonThemes = [ '0-to-1', 'scale', 'growth', 'platform', 'cross-functional', 'data-driven', 'experimentation', 'agile', 'technical leadership', 'customer-obsessed', 'startup', 'distributed systems', ]; for (const theme of commonThemes) { if (lowerFull.includes(theme)) themes.push(theme); } const recruiterPriorities = extractRecruiterPriorities(rawText); // Clean a raw extracted skill string: remove parenthetical suffixes and trailing noise function cleanSkill(raw: string): string { return raw .replace(/\s*\(.*$/, '') // strip everything from "(" onward .replace(/,?\s*(or similar|etc\.?|and more|and others)$/i, '') .replace(/\s+/g, ' ') .trim() .toLowerCase(); } // Deduplicate and clean skills — drop entries that are too long to be a real skill name const deduped = (arr: string[]) => [...new Set(arr.map(cleanSkill))] .filter((s) => Boolean(s) && s.length > 1 && s.length <= 40); return { title, seniority, company: company || undefined, requiredSkills: deduped(requiredSkills), preferredSkills: deduped(preferredSkills), domainClues, recruiterPriorities, themes, leadershipSignals: [...new Set(leadershipSignals)], rawText, }; }