src/lib/resume-parse.ts
Rama main · réplica de lectura del repositorio original.
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<string, string> = {
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<ResumeImport["profile"]> = {};
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<number>([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 })),
};
}