diff --git a/src/lib/heuristics/extract-fields.test.ts b/src/lib/heuristics/extract-fields.test.ts index 6d190169..da0290a1 100644 --- a/src/lib/heuristics/extract-fields.test.ts +++ b/src/lib/heuristics/extract-fields.test.ts @@ -2,7 +2,7 @@ // Copyright 2026 The resumelint Authors import { describe, it, expect } from "vitest"; -import { extractContact } from "./extract-fields.ts"; +import { extractContact, extractName } from "./extract-fields.ts"; import { groupIntoLines, splitIntoSections, findSection } from "./sections.ts"; import { US_LOCATION_RE } from "./regex.ts"; import type { PdfLinkAnnotation } from "./types.ts"; @@ -167,6 +167,84 @@ describe("extractContact — location no longer falls back to document-wide scan }); }); +describe("extractName — document-title boilerplate rejection (issue #10)", () => { + it("picks the real name when a 'Functional Resume Sample' header is above it", () => { + // Mode 1 of issue #10: a public Microsoft-style sample template renders + // the doc title in the largest font on the first profile line, which the + // original selector scored at 1.0 — choosing the boilerplate as the name. + const { profile } = buildContext([ + { text: "Functional Resume Sample", fontSize: 22 }, + { text: "Jane Smith", fontSize: 14 }, + { text: "jane.smith@example.com · (555) 010-0123", fontSize: 10 }, + { text: "" }, + { text: "EXPERIENCE", fontSize: 13 }, + ]); + const result = extractName(profile); + expect(result.value).toBe("Jane Smith"); + expect(result.confidence).toBeGreaterThan(0); + }); + + it("rejects 'Curriculum Vitae' as a name candidate", () => { + const { profile } = buildContext([ + { text: "Curriculum Vitae", fontSize: 22 }, + { text: "Jane Smith", fontSize: 14 }, + { text: "jane.smith@example.com", fontSize: 10 }, + ]); + expect(extractName(profile).value).toBe("Jane Smith"); + }); + + it("rejects 'Resume Sample' as a name candidate (all tokens are boilerplate)", () => { + const { profile } = buildContext([ + { text: "Resume Sample", fontSize: 22 }, + { text: "Jane Smith", fontSize: 14 }, + { text: "jane.smith@example.com", fontSize: 10 }, + ]); + expect(extractName(profile).value).toBe("Jane Smith"); + }); + + it("still picks 'Jane Smith Resume' (only 1 of 3 tokens is boilerplate)", () => { + // Conservative filter — a real name with the word "Resume" appended must + // still pass. Only ≥60% boilerplate triggers rejection. + const { profile } = buildContext([ + { text: "Jane Smith Resume", fontSize: 18 }, + { text: "jane.smith@example.com", fontSize: 10 }, + ]); + expect(extractName(profile).value).toBe("Jane Smith Resume"); + }); + + it("no regression: still picks a top-line name when no boilerplate is present", () => { + const { profile } = buildContext([ + { text: "Mohin Patel", fontSize: 18 }, + { text: "mohinp@uw.edu | 973-452-3653", fontSize: 10 }, + { text: "" }, + { text: "EDUCATION", fontSize: 13 }, + ]); + const result = extractName(profile); + expect(result.value).toBe("Mohin Patel"); + expect(result.confidence).toBeGreaterThan(0.8); + }); + + it("boilerplate-rejected name still picks up the contact-cluster proximity bonus on the runner-up", () => { + // Regression on the proximity signal itself: when the obvious first-line + // candidate is rejected as boilerplate, the proximity bonus must still + // fire for the surviving candidate. Otherwise we'd lose a confidence + // signal that's most useful precisely in the issue-10 scenario. + const { profile } = buildContext([ + { text: "Functional Resume Sample", fontSize: 22 }, + { text: "Jane Smith", fontSize: 14 }, + { text: "jane.smith@example.com", fontSize: 10 }, + ]); + const result = extractName(profile); + expect(result.value).toBe("Jane Smith"); + // Must clear ANON_CONTACT_CONFIDENCE_FLOOR (0.5) in score.ts — + // otherwise completeness scoring marks the (correctly-detected) name as + // "missing", which is mode 2 of issue #10 manifesting inside the fix + // for mode 1. Guarded so a future tuning regression on this threshold + // boundary fails loudly. + expect(result.confidence).toBeGreaterThanOrEqual(0.5); + }); +}); + describe("US_LOCATION_RE — preposition-phrase city rejection", () => { it("does not eat lowercase prepositions like 'and' / 'of' inside the city capture", () => { // Pre-fix the regex captured "CS and Engineering Seattle" (26 chars diff --git a/src/lib/heuristics/extract-fields.ts b/src/lib/heuristics/extract-fields.ts index 9827695b..a4a15161 100644 --- a/src/lib/heuristics/extract-fields.ts +++ b/src/lib/heuristics/extract-fields.ts @@ -64,6 +64,64 @@ function allMatches(re: RegExp, text: string): string[] { // ── Name ──────────────────────────────────────────────────────────────────── +/** + * Words that signal "this is a resume document title, not the candidate's name" + * — e.g. "Functional Resume Sample", "Chronological CV Template". Conservative: + * "Jane Smith Resume" still passes because only one of three words is boilerplate. + * See `looksLikeDocTitleBoilerplate` below for the rule. + */ +const NAME_BOILERPLATE_WORDS = new Set([ + "resume", + "résumé", + "cv", + "curriculum", + "vitae", + "sample", + "template", + "example", + "draft", + "chronological", + "functional", + "combination", + "profile", + "biography", +]); + +/** + * True when the line is mostly resume-document-title boilerplate rather than + * a person's name. Requires *all* tokens to be boilerplate (or ≥2 boilerplate + * tokens out of ≤3 total). Tuned so "Jane Smith" passes and "Resume" / "CV + * Sample" / "Functional Resume Sample" / "Curriculum Vitae" all reject. + */ +function looksLikeDocTitleBoilerplate(words: string[]): boolean { + const lowered = words.map((w) => w.toLowerCase().replace(/[^a-z]/g, "")); + const hits = lowered.filter((w) => NAME_BOILERPLATE_WORDS.has(w)).length; + if (hits === 0) return false; + if (hits === words.length) return true; + return words.length <= 3 && hits >= 2; +} + +/** y-position of the first line in `lines` matching any of the contact regexes, + * or undefined if no contact-bearing line is found. Used as a soft signal — + * candidate names close to this y get a small bonus. */ +function findContactClusterY(lines: PdfLine[]): number | undefined { + for (const line of lines) { + if ( + EMAIL_RE.test(line.text) || + PHONE_RE.test(line.text) || + LINKEDIN_RE.test(line.text) + ) { + // Reset lastIndex defensively; the constants are recompiled per call + // elsewhere in the file but test() with `g` flag mutates state. + EMAIL_RE.lastIndex = 0; + PHONE_RE.lastIndex = 0; + LINKEDIN_RE.lastIndex = 0; + return line.y; + } + } + return undefined; +} + /** * Resume names almost always appear at the very top, in the largest font, with * 2–4 words that are all letters (plus maybe a period or hyphen). Score: @@ -71,6 +129,10 @@ function allMatches(re: RegExp, text: string): string[] { * +0.3 font size larger than the rest of profile * +0.2 all-caps OR title-case * +0.1 2–4 words, 2–40 chars, no digits/emails + * +0.15 within ~80pt of the email/phone/linkedin line (contact-cluster proximity) + * + * Hard rejection: lines that are mostly resume-document-title boilerplate + * ("Functional Resume Sample", "Curriculum Vitae", etc.) — see issue #10. */ export function extractName( profile: PdfSection, @@ -80,8 +142,17 @@ export function extractName( const maxFontSize = Math.max(...profile.lines.map((l) => l.maxFontSize)); const averageFontSize = profile.lines.reduce((s, l) => s + l.maxFontSize, 0) / profile.lines.length; + const contactY = findContactClusterY(profile.lines); let best: { line: PdfLine; score: number } | null = null; + // Index of the first eligible candidate after rejections. When the literal + // first line is rejected as boilerplate (e.g. "Functional Resume Sample"), + // the next surviving line is effectively the header — it inherits the + // first-line bonus, which also keeps confidence above the scorer's + // contact-field floor (0.5). Without this, fixing the wrong-name pick + // would dial confidence down enough to mark the (correct) name as + // "missing" in completeness scoring. + let firstEligibleIdx: number | null = null; for (let i = 0; i < Math.min(profile.lines.length, 5); i++) { const line = profile.lines[i]; @@ -94,14 +165,20 @@ export function extractName( const letterRatio = text.replace(/[^A-Za-z]/g, "").length / Math.max(text.length, 1); if (letterRatio < 0.7) continue; + if (looksLikeDocTitleBoilerplate(words)) continue; + + if (firstEligibleIdx === null) firstEligibleIdx = i; let score = 0; - if (i === 0) score += 0.4; + if (i === firstEligibleIdx) score += 0.4; if (line.maxFontSize >= maxFontSize - 0.5) score += 0.3; if (line.maxFontSize > averageFontSize + 1) score += 0.1; const titleCase = words.every((w) => /^[A-Z][a-zA-Z.\-']*$/.test(w)); if (line.allCaps || titleCase) score += 0.2; if (words.length >= 2 && words.length <= 4) score += 0.1; + if (contactY !== undefined && Math.abs(line.y - contactY) < 80) { + score += 0.15; + } if (!best || score > best.score) best = { line, score }; } diff --git a/tests/fixtures/pdfs/latex/header-as-name-functional-resume.expected.json b/tests/fixtures/pdfs/latex/header-as-name-functional-resume.expected.json new file mode 100644 index 00000000..647d4137 --- /dev/null +++ b/tests/fixtures/pdfs/latex/header-as-name-functional-resume.expected.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": 1, + "cascade": { + "confidence": 0.87, + "triggers": [], + "tiers": [ + "t0_layout", + "t1_openresume" + ], + "suggestedEscalation": "none", + "fieldsPopulated": [ + "current_company", + "current_title", + "education", + "email", + "experience", + "family_name", + "full_name", + "given_name", + "linkedin_url", + "location", + "phone", + "summary", + "website_url" + ], + "skillsCount": 0, + "experienceCount": 2, + "educationCount": 1, + "rawTextCharCount": 762, + "pageCount": 1, + "linkAnnotationCount": 0, + "hasMarkdown": true, + "sectionSource": "regex" + }, + "score": { + "overall": 78, + "preLayoutOverall": 78, + "specificity": { + "score": 27, + "max": 40, + "gradable": true, + "metricBullets": 2, + "totalBullets": 5 + }, + "structure": { + "score": 24, + "max": 30, + "gradable": true, + "goodBullets": 4, + "totalBullets": 5 + }, + "completeness": { + "score": 27, + "max": 30, + "gradable": true, + "missing": [ + "skills" + ] + }, + "layout": { + "triggers": [], + "multiplier": 1, + "scanned": false + }, + "bulletCount": 5, + "algoVersion": "1.0" + } +} diff --git a/tests/fixtures/pdfs/latex/header-as-name-functional-resume.pdf b/tests/fixtures/pdfs/latex/header-as-name-functional-resume.pdf new file mode 100644 index 00000000..a19775ca Binary files /dev/null and b/tests/fixtures/pdfs/latex/header-as-name-functional-resume.pdf differ