From 11584315ad3eeb05f54e1edc9b192bb16b7bd0c3 Mon Sep 17 00:00:00 2001 From: rohitgollapalli Date: Wed, 24 Jun 2026 11:42:24 -0400 Subject: [PATCH] fix(heuristics): drop empty-title (date-only) entry blocks before scoring (#145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A header/anchor line whose entire text is a date leaves nothing after stripDateRange, so parseEntryBlocks emits a block with empty headerLines. The three extractors mapped that straight through to a {title:""}/{name:""} entry with score 0 — a title-less item in output that also diluted section confidence. Add a shared finalizeEntries() helper that drops empty-label entries (filtering on the built entry's label, so a URL-only header is caught too) and packages the survivors; route extractAchievements/extractProjects/extractExperience through it. Co-Authored-By: Claude Opus 4.8 --- src/lib/heuristics/extract-fields.test.ts | 50 ++++++++++++++++++++++ src/lib/heuristics/extract/achievements.ts | 13 +++--- src/lib/heuristics/extract/experience.ts | 14 +++--- src/lib/heuristics/extract/projects.ts | 10 ++--- src/lib/heuristics/extract/shared.ts | 20 +++++++++ 5 files changed, 86 insertions(+), 21 deletions(-) diff --git a/src/lib/heuristics/extract-fields.test.ts b/src/lib/heuristics/extract-fields.test.ts index e3347539..5b11f32c 100644 --- a/src/lib/heuristics/extract-fields.test.ts +++ b/src/lib/heuristics/extract-fields.test.ts @@ -888,3 +888,53 @@ describe("extractAchievements", () => { expect(extractAchievements(undefined)).toEqual({ value: [], confidence: 0 }); }); }); + +// A header/anchor line whose entire text is a date leaves nothing after +// stripDateRange, so the block carries no title. Such a phantom must be dropped +// before it reaches the score, not emitted as a `{ title: "" }` entry (#145). +describe("empty-title (date-only) block filtering (#145)", () => { + it("drops a date-only bullet in an achievements flat list", () => { + // Flat-list fallback path: a bullet whose only text is a year. + expect(extractAchievements(mkSection("achievements", [{ text: "• 2023" }]))).toEqual({ + value: [], + confidence: 0, + }); + }); + + it("drops a date-only header in achievements (buildEntryBlock path)", () => { + expect(extractAchievements(mkSection("achievements", [{ text: "2023" }]))).toEqual({ + value: [], + confidence: 0, + }); + }); + + it("drops a date-only project, leaving no name-less entry", () => { + expect(extractProjects(mkSection("projects", [{ text: "• 2023" }]))).toEqual({ + value: [], + confidence: 0, + }); + }); + + it("drops a date-only experience block with neither title nor company", () => { + expect(extractExperience(mkSection("experience", [{ text: "2020 - 2022" }]))).toEqual({ + value: [], + confidence: 0, + }); + }); + + it("keeps the real entry and confidence is not diluted by a dropped phantom", () => { + const section = mkSection("experience", [ + { text: "Acme Corporation" }, + { text: "Senior Software Engineer" }, + { text: "Jan 2020 - Mar 2022" }, + { text: "• Shipped the thing" }, + { text: "2018 - 2019" }, // date-only phantom anchor, no header above + ]); + const { value, confidence } = extractExperience(section); + expect(value).toHaveLength(1); + expect(value[0].company).toBe("Acme Corporation"); + expect(value[0].title).toBe("Senior Software Engineer"); + // Phantom (score 0) is gone, so the average reflects only the real entry. + expect(confidence).toBeGreaterThan(0.8); + }); +}); diff --git a/src/lib/heuristics/extract/achievements.ts b/src/lib/heuristics/extract/achievements.ts index f6fcddf9..eb0c17aa 100644 --- a/src/lib/heuristics/extract/achievements.ts +++ b/src/lib/heuristics/extract/achievements.ts @@ -6,7 +6,7 @@ import type { PdfSection } from "../sections.ts"; import { parseEntryBlocks } from "../entry-blocks.ts"; import type { EntryBlock } from "../entry-blocks.ts"; import { YEAR_RE } from "../regex.ts"; -import { firstMatch, avgScore } from "./shared.ts"; +import { firstMatch, finalizeEntries } from "./shared.ts"; import { liftHeaderLabel } from "./projects.ts"; // ── Achievements ────────────────────────────────────────────────────────────── @@ -39,12 +39,11 @@ export function extractAchievements( anchor: "first_line", collectBody: true, }); - if (blocks.length === 0) return { value: [], confidence: 0 }; - const built = blocks.map(achievementFromBlock); - return { - value: built.map((b) => b.entry), - confidence: avgScore(built.map((b) => b.score)), - }; + // Drop any date-only / title-less block (#145) before scoring. + return finalizeEntries( + blocks.map(achievementFromBlock), + (e) => e.title !== "", + ); } /** Map one entry block to a `HeuristicAchievement` and its confidence score. diff --git a/src/lib/heuristics/extract/experience.ts b/src/lib/heuristics/extract/experience.ts index ef12b5dd..52711b4d 100644 --- a/src/lib/heuristics/extract/experience.ts +++ b/src/lib/heuristics/extract/experience.ts @@ -6,7 +6,7 @@ import type { PdfSection } from "../sections.ts"; import { parseEntryBlocks } from "../entry-blocks.ts"; import type { EntryBlock } from "../entry-blocks.ts"; import { US_LOCATION_RE, INTL_LOCATION_RE } from "../regex.ts"; -import { looksLikeTitle, looksLikeCompany, avgScore } from "./shared.ts"; +import { looksLikeTitle, looksLikeCompany, finalizeEntries } from "./shared.ts"; // ── Experience ────────────────────────────────────────────────────────────── @@ -35,12 +35,12 @@ export function extractExperience( collectBody: true, headerLookback: 2, }); - if (blocks.length === 0) return { value: [], confidence: 0 }; - const built = blocks.map(experienceFromBlock); - return { - value: built.map((b) => b.entry), - confidence: avgScore(built.map((b) => b.score)), - }; + // Drop a date-only phantom — a block with neither title nor company (#145). + // Experience has no single title axis, so we keep a role that has either. + return finalizeEntries( + blocks.map(experienceFromBlock), + (e) => e.title !== "" || e.company !== "", + ); } /** Map one dated entry block to a `ResumeExperience` and its confidence score. diff --git a/src/lib/heuristics/extract/projects.ts b/src/lib/heuristics/extract/projects.ts index 0293ec68..f5e7b580 100644 --- a/src/lib/heuristics/extract/projects.ts +++ b/src/lib/heuristics/extract/projects.ts @@ -6,7 +6,7 @@ import type { PdfSection } from "../sections.ts"; import { parseEntryBlocks } from "../entry-blocks.ts"; import type { EntryBlock } from "../entry-blocks.ts"; import { URL_RE } from "../regex.ts"; -import { firstMatch, avgScore } from "./shared.ts"; +import { firstMatch, finalizeEntries } from "./shared.ts"; // ── Projects ──────────────────────────────────────────────────────────────── @@ -36,12 +36,8 @@ export function extractProjects( anchor: "first_line", collectBody: true, }); - if (blocks.length === 0) return { value: [], confidence: 0 }; - const built = blocks.map(projectFromBlock); - return { - value: built.map((b) => b.entry), - confidence: avgScore(built.map((b) => b.score)), - }; + // Drop any date-only / name-less block (#145) before scoring. + return finalizeEntries(blocks.map(projectFromBlock), (e) => e.name !== ""); } /** diff --git a/src/lib/heuristics/extract/shared.ts b/src/lib/heuristics/extract/shared.ts index d0ca3cb0..865d1c84 100644 --- a/src/lib/heuristics/extract/shared.ts +++ b/src/lib/heuristics/extract/shared.ts @@ -56,3 +56,23 @@ export function looksLikeCompany(text: string): boolean { export function avgScore(scores: number[]): number { return scores.reduce((a, b) => a + b, 0) / Math.max(scores.length, 1); } + +/** + * Drop entries the parser couldn't label — a date-only / title-less block whose + * header reduced to "" (#145) — then package the survivors as the standard + * `{ value, confidence }` pair the three entry extractors return. Filtering on + * the built entry's label (not on empty `headerLines`) also catches a URL-only + * header, which `liftHeaderLabel` collapses to an empty label. Keeping the + * phantom out of the list also keeps its score 0 out of the `avgScore` + * denominator, so it no longer dilutes section confidence. + */ +export function finalizeEntries( + built: { entry: T; score: number }[], + hasLabel: (entry: T) => boolean, +): { value: T[]; confidence: number } { + const kept = built.filter((b) => hasLabel(b.entry)); + return { + value: kept.map((b) => b.entry), + confidence: avgScore(kept.map((b) => b.score)), + }; +}