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
50 changes: 50 additions & 0 deletions src/lib/heuristics/extract-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
13 changes: 6 additions & 7 deletions src/lib/heuristics/extract/achievements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 7 additions & 7 deletions src/lib/heuristics/extract/experience.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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.
Expand Down
10 changes: 3 additions & 7 deletions src/lib/heuristics/extract/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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 !== "");
}

/**
Expand Down
20 changes: 20 additions & 0 deletions src/lib/heuristics/extract/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(
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)),
};
}
Loading