Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 79 additions & 1 deletion src/lib/heuristics/extract-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
79 changes: 78 additions & 1 deletion src/lib/heuristics/extract-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,75 @@ 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é",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit]: This "résumé" entry is dead — looksLikeDocTitleBoilerplate lowers each token with .replace(/[^a-z]/g, ""), so "Résumé" normalizes to "rsum" and never matches this set member. The unaccented "resume" still catches the common case, so impact is small, but consider dropping it or adding the normalized form so the intent isn't misleading.

"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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit]: Minor comment inaccuracy — EMAIL_RE/PHONE_RE/LINKEDIN_RE are module-level constants, not "recompiled per call." The defensive lastIndex = 0 reset is correct and harmless (global regexes auto-reset lastIndex on a failed .test(), and the success path resets before returning), so no behavior change needed — just the comment.

// 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:
* +0.4 first line of profile
* +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,
Expand All @@ -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];
Expand All @@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion]: The reindex is a great catch for the boilerplate case, but its blast radius is broader than the commit title implies. firstEligibleIdx is the first line surviving all the continue filters above (digits, @, length>60, word-count, letterRatio < 0.7), not just boilerplate rejection. So if line 0 is filtered for any of those reasons, the next line now inherits the +0.4 first-line bonus where previously it got nothing. Likely an improvement, but worth either narrowing the comment to reflect that or confirming the intent — the 6 byte-identical snapshots are reassuring but only cover the existing corpus.

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 };
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Binary file not shown.