import type { ResumeImport, ResumeImportRole } from "@/lib/data/me"; import { flattenLinkedIn, looksLikeLinkedIn } from "@/lib/resume-parse-linkedin"; /** * Reading a pasted resume well enough to correct. * * The conversational path is better than this and always will be: an assistant * reads the document and calls import_resume with what it says. This exists for * the person who opened the app before connecting anything, and its output is * never written straight to the database — it fills a form they check first. * Every guess it makes is one they can see and fix, which is the only reason a * heuristic is allowed near somebody's career history at all. * * Pure, so the dialog can re-read as you paste without a round trip. No PDF * parsing: a PDF's text layer arrives in column order, so a two-column resume * comes out interleaved, and a wrong parse of a document you cannot see the * parse of is worse than pasting the text yourself. The app says "paste the * text" and means it. */ /** * One thing the parser is unsure about, tied to the field it is unsure about. * * A list of warnings above the form tells you something is wrong somewhere; a * note on the field tells you where to look. `path` addresses the draft the * way a person would read it: "roles.2.company", "roles.2.bullets". */ export type ParseNote = { path: string; message: string; }; export type ParsedResume = { /** Exactly the shape importResume takes. */ draft: ResumeImport; /** The document as it arrived, filed as a note by the caller. */ sourceText: string; /** Lines it could not place. Never dropped — sourceText still carries them. */ unparsed: string[]; /** What it could not read at all, about the document as a whole. */ warnings: string[]; /** Where it guessed, on the field it guessed about. */ notes: ParseNote[]; }; type Section = | "experience" | "education" | "skills" | "projects" | "certifications" | "summary" | "header" | "other"; const SECTION_ALIASES: [RegExp, Section][] = [ [/^(work\s+)?(experience|employment|professional\s+experience|work\s+history|career)\b/i, "experience"], [/^education\b/i, "education"], [/^(technical\s+)?(skills|competencies|technologies|tools)\b/i, "skills"], [/^(projects|selected\s+projects|side\s+projects|open\s+source)\b/i, "projects"], [/^(certifications?|licen[sc]es?|awards?)\b/i, "certifications"], [/^(summary|profile|about|objective)\b/i, "summary"], ]; const MONTHS: Record = { jan: "01", feb: "02", mar: "03", apr: "04", may: "05", jun: "06", jul: "07", aug: "08", sep: "09", oct: "10", nov: "11", dec: "12", }; const DATE_RANGE = /((?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+)?(\d{4})\s*(?:–|—|-|to|until)\s*((?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+)?(\d{4}|present|current|now)/i; const EMAIL = /[\w.+-]+@[\w-]+\.[\w.-]+/; const PHONE = /(\+?\d[\d\s().-]{7,}\d)/; const URL = /\b((?:https?:\/\/|www\.)[^\s,;]+|[\w-]+\.(?:com|io|dev|net|org|me|co)(?:\/[^\s,;]*)?)/gi; /** Bullet glyphs a resume actually uses, folded to one marker. */ const BULLET = /^\s*[•‣▪◦·*•▪●\-–—]\s+/; const CONTRACT = /\b(intern(ship)?|contract(or)?|freelance|consultant|part[-\s]?time)\b/i; function normalise(text: string): string[] { return text .replace(/\r/g, "") .split("\n") // LinkedIn's PDF export stamps every page and so do plenty of templates. // Dropped rather than blanked: a blank line separates entries here, so // blanking one mid-role cut the bullets below it off from their job. .filter((line) => !/^\s*page \d+( of \d+)?\s*$/i.test(line)) .map((line) => line.replace(/\s+$/g, "")) .filter((line, index, all) => line.trim() !== "" || (all[index - 1] ?? "").trim() !== ""); } /** * A heading is a line that says one word and stops. * * "Skills" is a heading. "Tools: Postgres, Kafka, Terraform" under a job is * not — and reading it as one swallowed every job below it into the skills * section. So the line has to be the heading and nothing else: no comma- * separated tail, no sentence hanging off it. */ function headingOf(line: string): Section | null { const clean = line.trim().replace(/[:•·]+$/, "").trim(); if (!clean || clean.length > 40) return null; if (BULLET.test(line)) return null; for (const [pattern, section] of SECTION_ALIASES) { const match = clean.match(pattern); if (!match) continue; // Whatever follows the word the alias matched. A heading has nothing. const rest = clean.slice(match[0].length).trim(); if (rest === "" || /^(and\s+\w+|&\s+\w+)$/i.test(rest)) return section; } return null; } function parseDates(line: string) { const match = line.match(DATE_RANGE); if (!match) return null; const month = (raw?: string) => { const key = raw?.trim().slice(0, 3).toLowerCase(); return key && MONTHS[key] ? `-${MONTHS[key]}` : ""; }; const end = match[4].toLowerCase(); const current = ["present", "current", "now"].includes(end); return { startDate: `${match[2]}${month(match[1])}`, endDate: current ? "" : `${match[4]}${month(match[3])}`, isCurrent: current, }; } /** Words that make a line read as a job rather than an employer. */ const TITLE_WORDS = /\b(engineer|manager|director|designer|analyst|lead|head|intern|consultant|founder|vp|president|officer|scientist|developer|architect|principal|staff|senior|junior|associate|specialist|coordinator|administrator|editor|writer|researcher|partner|chief|cto|ceo|cfo|coo)\b/i; /** * Which of two halves is the job and which is the employer. * * Both orders are common — "Staff Engineer, Stripe" and "Stripe — Staff * Engineer" — and reading them positionally got the second backwards every * time, which is the worst thing this parser can do quietly: it files your * whole career under employers named after your job titles. When exactly one * half reads like a job, that settles it. When neither does or both do, order * decides and the field says it was a guess. */ function pickTitleAndCompany(first: string, second: string) { const firstIsTitle = TITLE_WORDS.test(first); const secondIsTitle = TITLE_WORDS.test(second); if (firstIsTitle && !secondIsTitle) return { title: first, company: second, sure: true }; if (secondIsTitle && !firstIsTitle) return { title: second, company: first, sure: true }; return { title: second, company: first, sure: false }; } /** Split "Staff Engineer at Stripe" / "Stripe — Staff Engineer" into its two halves. */ function splitTitleAndCompany( line: string, ): { company: string; title: string; sure: boolean } | null { const stripped = line.replace(DATE_RANGE, "").replace(/[,–—|]+\s*$/, "").trim(); // "X at Y" says which is which in words; nothing to guess. const at = stripped.match(/^(.+?)\s+(?:at|@)\s+(.+)$/i); if (at) return { title: at[1].trim(), company: at[2].trim(), sure: true }; const parts = stripped.split(/\s+[–—|]\s+|\s{2,}|,\s+/).map((part) => part.trim()).filter(Boolean); if (parts.length >= 2) return pickTitleAndCompany(parts[0], parts[1]); return null; } export function parseResumeText(text: string): ParsedResume { const source = text.slice(0, 200_000); // LinkedIn is the document people actually keep current, so it is what they // paste. What the clipboard gives is not a resume — see // resume-parse-linkedin.ts — so it is rewritten into one first, and the // review step is told, because the fields will not match the text they // pasted line for line. const linkedin = looksLikeLinkedIn(source); const lines = normalise(linkedin ? flattenLinkedIn(source) : source); const warnings: string[] = []; const notes: ParseNote[] = []; const unparsed: string[] = []; if (linkedin) { warnings.push( "Read as a LinkedIn profile: durations, employment types and repeated company names were folded in, and promotions grouped under one employer were split into separate jobs.", ); } // --- the block above the first heading is the header -------------------- const blocks: { section: Section; lines: string[] }[] = [{ section: "header", lines: [] }]; for (const line of lines) { const heading = headingOf(line); if (heading) blocks.push({ section: heading, lines: [] }); else blocks[blocks.length - 1].lines.push(line); } const header = blocks[0].lines.filter((line) => line.trim()); const joined = header.join(" "); const profile: NonNullable = {}; const name = header[0]?.trim(); if (name && name.length <= 60 && !EMAIL.test(name)) profile.fullName = name; const email = joined.match(EMAIL)?.[0]; if (email) profile.email = email; const phone = joined.match(PHONE)?.[0]; if (phone) profile.phone = phone.trim(); for (const line of header) { // Emails first, or "wil@example.com" donates "example.com" as a website. for (const [url] of line.replace(new RegExp(EMAIL, "g"), " ").matchAll(URL)) { const lower = url.toLowerCase(); if (lower.includes("linkedin.")) profile.linkedin ??= url; else if (lower.includes("github.")) profile.github ??= url; else if (lower.includes("x.com") || lower.includes("twitter.")) profile.twitter ??= url; else profile.website ??= url; } } if (!profile.fullName) warnings.push("No name found at the top — fill it in yourself."); const draft: ResumeImport = { profile, roles: [], education: [], projects: [], skillGroups: [], certifications: [], }; for (const block of blocks.slice(1)) { const body = block.lines; switch (block.section) { case "summary": draft.profile!.summary = body.join("\n").trim(); break; case "skills": { for (const line of body) { const clean = line.replace(BULLET, "").trim(); if (!clean) continue; const labelled = clean.match(/^([A-Za-z][\w\s/&+-]{0,40}?)\s*[:—–]\s*(.+)$/); const name = labelled ? labelled[1].trim() : "Skills"; const list = (labelled ? labelled[2] : clean) .split(/[,;|•·]/) .map((skill) => skill.trim()) .filter((skill) => skill.length > 0 && skill.length < 60); if (list.length === 0) continue; const group = draft.skillGroups!.find((existing) => existing.name === name); if (group) group.skills = [...(group.skills ?? []), ...list]; else draft.skillGroups!.push({ name, skills: list }); } break; } case "certifications": for (const line of body) { const clean = line.replace(BULLET, "").trim(); if (!clean) continue; const parts = clean.split(/\s+[–—|]\s+|,\s+/); draft.certifications!.push({ name: parts[0].trim(), issuer: parts[1]?.trim(), date: clean.match(/\b(19|20)\d{2}\b/)?.[0], }); } break; case "education": for (const entry of groupEntries(body)) { const first = entry[0] ?? ""; const dates = parseDates(entry.join(" ")); const degree = entry.find((line) => /\b(b\.?s\.?c?|b\.?a\.?|b\.?eng|m\.?s\.?c?|m\.?eng|m\.?b\.?a\.?|ph\.?d|bachelors?|masters?|doctor|associate|diploma)\b/i.test(line), ); draft.education!.push({ school: first.replace(DATE_RANGE, "").replace(/[,–—|]\s*$/, "").trim(), degree: degree && degree !== first ? degree.trim() : undefined, startDate: dates?.startDate, endDate: dates?.endDate, details: entry.slice(1).filter((line) => line !== degree).join("\n").trim() || undefined, }); } break; case "projects": for (const entry of groupEntries(body)) { const first = (entry[0] ?? "").replace(BULLET, "").trim(); if (!first) continue; draft.projects!.push({ name: first.split(/\s+[–—|:]\s+/)[0].trim(), description: entry.slice(1).join("\n").trim() || undefined, url: entry.join(" ").match(URL)?.[0], background: entry.join("\n"), }); } break; case "experience": for (const entry of groupEntries(body)) { const at = draft.roles!.length; const role = parseRole(entry, (field, message) => notes.push({ path: `roles.${at}.${field}`, message }), ); if (role) draft.roles!.push(role); else unparsed.push(...entry); } break; default: unparsed.push(...body.filter((line) => line.trim())); } } if ((draft.roles ?? []).length === 0) { warnings.push( "No jobs were recognised. Check the headings — this looks for one called Experience, Employment or Work History.", ); } if (unparsed.length > 0) { warnings.push( `${unparsed.length} line${unparsed.length > 1 ? "s" : ""} could not be placed. The whole document is saved as a note either way, so nothing is lost.`, ); } // sourceText is what they pasted, never the rewrite: the note filed alongside // the import is the original document, and anything the parser dropped is // still findable in it. return { draft, sourceText: text, unparsed, warnings, notes }; } /** Blank lines separate entries; so does a line that starts a new dated header. */ function groupEntries(lines: string[]): string[][] { const entries: string[][] = []; let current: string[] = []; for (const line of lines) { if (!line.trim()) { if (current.length > 0) entries.push(current); current = []; continue; } // A dated line after another dated line is the next job, whether or not // the one before it had bullets — plenty of resumes list three stints at // one company with no bullets at all, and they were collapsing into one. const lastDated = current.findLastIndex((row) => DATE_RANGE.test(row)); if (!BULLET.test(line) && DATE_RANGE.test(line) && lastDated !== -1) { // The title line already read belongs to the entry this date opens, not // to the one it closes: a header is usually "Title" then "Dates". const carried = current.slice(lastDated + 1); entries.push(current.slice(0, lastDated + 1)); current = [...carried, line]; continue; } current.push(line); } if (current.length > 0) entries.push(current); return entries; } /** Doubt a field, for the review step to show beside it. */ type Doubt = (field: string, message: string) => void; function parseRole(entry: string[], doubt: Doubt): ResumeImportRole | null { const plain = entry.filter((line) => !BULLET.test(line)); let bullets = entry .filter((line) => BULLET.test(line)) .map((line) => line.replace(BULLET, "").trim()); if (plain.length === 0) return null; const dates = parseDates(plain.join(" ")); if (!dates) doubt("startDate", "No dates found in this entry."); const split = splitTitleAndCompany(plain[0]); // Which plain lines the header used up. Whatever is left is either noise or // the bullets of a document that never used a bullet mark. const used = new Set([0]); let company = ""; let title = ""; if (split) { company = split.company; title = split.title; if (!split.sure) { doubt("company", `Guessed which half of "${plain[0].trim()}" is the employer.`); } } else if (plain.length >= 2) { // The header runs over two lines: same question, one line each. const [first, second] = plain; const picked = pickTitleAndCompany(first.trim(), second.trim()); company = picked.company; title = picked.title; used.add(1); if (!picked.sure) { doubt("company", `Guessed which of "${first.trim()}" and "${second.trim()}" is the employer.`); } } else { company = plain[0]; } const clean = (value: string) => value.replace(DATE_RANGE, "").replace(/[,–—|]\s*$/, "").trim(); company = clean(company); title = clean(title); if (!company && !title) return null; if (!title) doubt("title", "No title read for this one."); // A resume written without bullet marks — plenty are, and a PDF often loses // them in the paste — otherwise lands here with a header and nothing else, // and the person is shown a job with zero bullets under it. Sentences left // over after the header become the bullets, and the field says they were // guessed. Only when nothing was marked: mixing marked and unmarked lines // is how a wrapped bullet becomes two. if (bullets.length === 0) { const leftover = plain .filter((_, index) => !used.has(index)) .map((line) => line.trim()) // Not a date line, not a location: something with the length of a claim. .filter((line) => !DATE_RANGE.test(line) && line.split(/\s+/).length >= 6); if (leftover.length > 0) { bullets = leftover; doubt("bullets", "This document has no bullet marks — these lines were read as bullets."); } } const type = entry.join(" ").match(CONTRACT)?.[0]; return { company: company || title, title: title || "Role", employmentType: type ? type[0].toUpperCase() + type.slice(1).toLowerCase() : undefined, startDate: dates?.startDate, endDate: dates?.endDate, isCurrent: dates?.isCurrent, // Bullets become highlights AND stay in the background: the background is // the raw material, and losing the original wording is how detail goes. background: entry.join("\n"), bullets: bullets.map((text) => ({ text })), }; }