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
34 changes: 34 additions & 0 deletions src/lib/heuristics/pdf-extract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,38 @@ describe("assembleTextFromLines", () => {
it("returns empty string for empty input (scanned PDFs)", () => {
expect(assembleTextFromLines([])).toBe("");
});

it("splits two-column same-y items so right-column bullets keep line-start position (issue #9)", () => {
// Deedy-style two-column layout: left column has education text, right
// column has experience bullets, both at the same baseline y. Pre-fix
// groupIntoLines merged them into a single PdfLine like
// "BS in Computer Science • Led migration"
// which dropped the bullet glyph out of line-start position and the
// score-side bullet counter missed it. The 50pt column-gap split keeps
// each column on its own line.
const items = [
// Left column: education text starting at x=35
item("BS in Computer Science", 35, 200, 1, 120),
// Right column: experience bullet starting at x=300 — 145pt gap
item("•", 300, 200, 1, 6),
item("Led migration of legacy auth system", 315, 200, 1, 200),
];
const text = assembleTextFromLines(items);
const lines = text.split("\n").filter((l) => l.length > 0);
expect(lines).toHaveLength(2);
expect(lines[0]).toBe("BS in Computer Science");
expect(lines[1]).toMatch(/^•\s+Led migration/);
});

it("does NOT split a normal single-column line at small gaps", () => {
// Regression guard: a tightly-laid-out single-column line with normal
// word/run spacing (gaps well under 50pt) must stay as one PdfLine,
// otherwise we'd fragment every Awesome-CV / OpenResume-style PDF.
const items = [
item("Jane", 72, 100, 1, 30),
item("Smith", 110, 100, 1, 35), // ~8pt gap
item("Senior Engineer", 160, 100, 1, 100), // ~15pt gap
];
expect(assembleTextFromLines(items)).toBe("Jane Smith Senior Engineer");
});
});
53 changes: 42 additions & 11 deletions src/lib/heuristics/sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ export interface PdfSection {
/** Items within this vertical distance (PDF points) are treated as same line. */
const LINE_Y_EPS = 3.5;

/**
* Horizontal gap inside a same-y cluster that flags a column boundary.
* Awesome-CV / single-column LaTeX exports produce essentially 0pt gaps
* between adjacent items even across `\hfill` alignment, so 50pt is well
* above any in-line word/run spacing while comfortably below the column
* gaps observed in real two-column resumes (Deedy's experience column
* jumps in at ~70pt past the education column edge). Splitting at this
* threshold rescues the bullet count on two-column layouts that don't
* trigger the `two_column` layout flag (asymmetric 0.33/0.66 splits
* like Deedy's slip past `probeTwoColumn`). Issue #9.
*/
const COLUMN_GAP_THRESHOLD = 50;

// ── Line grouping ───────────────────────────────────────────────────────────

export function groupIntoLines(items: PdfTextItem[]): PdfLine[] {
Expand All @@ -55,21 +68,39 @@ export function groupIntoLines(items: PdfTextItem[]): PdfLine[] {
const lines: PdfLine[] = [];
let current: PdfTextItem[] = [];

const flush = () => {
if (current.length === 0) return;
current.sort((a, b) => a.x - b.x);
const text = mergeItemText(current);
const ys = current.map((i) => i.y);
/** Build a PdfLine from a contiguous run of items (already x-sorted). */
const buildLine = (run: PdfTextItem[]): PdfLine => {
const text = mergeItemText(run);
const ys = run.map((i) => i.y);
const avgY = ys.reduce((a, b) => a + b, 0) / ys.length;
lines.push({
page: current[0].page,
return {
page: run[0].page,
y: avgY,
x: current[0].x,
items: [...current],
x: run[0].x,
items: [...run],
text,
maxFontSize: Math.max(...current.map((i) => i.fontSize)),
maxFontSize: Math.max(...run.map((i) => i.fontSize)),
allCaps: text.replace(/[^A-Za-z]/g, "").length > 0 && text === text.toUpperCase(),
});
};
};

const flush = () => {
if (current.length === 0) return;
current.sort((a, b) => a.x - b.x);
// Split the same-y cluster at column-sized horizontal gaps so two-column
// layouts that share a baseline don't get merged into one PdfLine — see
// COLUMN_GAP_THRESHOLD and issue #9.
let runStart = 0;
for (let i = 1; i < current.length; i++) {
const prev = current[i - 1];
const cur = current[i];
const gap = cur.x - (prev.x + prev.width);
if (gap > COLUMN_GAP_THRESHOLD) {
lines.push(buildLine(current.slice(runStart, i)));
runStart = i;
}
}
lines.push(buildLine(current.slice(runStart)));
current = [];
};

Expand Down
30 changes: 29 additions & 1 deletion src/lib/score/score.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,8 +285,36 @@ describe("computeAnonymousAtsScore", () => {
expect(result.structure.score).toBe(0);
});

it("counts short bullets in the visible total even when too short to score well (issue #9)", () => {
// Issue #9: previously ANON_BULLET_MIN_WORDS = 4 silently dropped short
// bullets like "• Everything that matters." (3 words after marker
// strip), so the displayed bullet count under-reported what the user
// could see in the PDF. The fix counts every marker-prefixed line and
// lets the well-formed length window flag short bullets in per-bullet
// feedback rather than hiding them.
const mixedLengths = [
"- Led migration of 3 microservices reducing latency by 40%",
"- Managed team of 5 engineers shipping weekly releases",
"- Reduced infrastructure cost by 35% through right-sizing efforts",
"- Increased conversion rate by 22% through experimentation",
"- Built CI pipeline cutting deploy time from 45 to 8 minutes",
"- Everything that matters.", // 3 words — pre-fix this was dropped
].join("\n");
const result = computeAnonymousAtsScore(
makeAnonInput({ rawText: mixedLengths }),
);
expect(result.specificity.totalBullets).toBe(6);
expect(result.bullets?.length).toBe(6);
// The short bullet adds 1 to the denominator without contributing to
// metric/structure numerators, so quality scores drop slightly — that's
// the correct grading, previously masked by hiding the bullet.
expect(result.specificity.metricBullets).toBe(5);
});

it("drops Specificity proportional to non-metric bullets", () => {
// Each line has ≥4 words after the marker so all six register as bullets.
// Each line has multiple words after the marker so all six register as
// bullets. (Min words to count as a bullet is 1; quality grading is
// handled by the length-window check, not the count filter.)
const fewMetrics = [
"- Reduced latency by 40%",
"- Built a thing for users to enjoy",
Expand Down
21 changes: 15 additions & 6 deletions src/lib/score/score.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,11 +469,16 @@ export interface AnonymousAtsScoreInput {

const ANON_CONTACT_CONFIDENCE_FLOOR = 0.5;
const ANON_MIN_BULLETS_TO_GRADE = 3;
/** Word-count floor for raw-text bullet extraction. The authed splitBullets
* uses a char floor instead because its input is already a curated
* description string; raw-text extraction needs to skip headers / one-line
* section labels that share a leading marker. */
const ANON_BULLET_MIN_WORDS = 4;
/** Word-count floor for raw-text bullet extraction. Set to 1 so the displayed
* bullet count matches what the user can see in the PDF — every line that
* begins with a recognised marker AND has at least one non-empty word is a
* bullet. Quality grading (well-formed length window, action verb, metric)
* is handled downstream by `scoreBulletPool` and `analyzeBullets`, which
* naturally penalise short bullets without hiding them from the count.
* Issue #9 — previously set to 4, which silently dropped legitimate short
* bullets like "• Everything that matters." and caused bullet count to
* under-report vs. the visible PDF. */
const ANON_BULLET_MIN_WORDS = 1;

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 original comment on this constant explicitly noted: "raw-text extraction needs to skip headers / one-line section labels that share a leading marker." Lowering to 1 means "• Summary" or "• Education" (1 word after marker strip) now counts as a bullet. The corpus shows no regression across 7 fixtures, so this is not blocking — but the guard is removed without a test that pins the boundary. Consider a unit test in score.test.ts that feeds a resume text with a single-word bullet-prefixed section label and asserts it does not inflate the bullet count (or asserts the expected behavior explicitly), so future changes to this threshold can't silently regress.


const ANON_CONTACT_FIELDS: readonly {
key: "full_name" | "email" | "phone" | "location" | "linkedin_url";
Expand All @@ -489,7 +494,11 @@ const ANON_CONTACT_FIELDS: readonly {
/**
* Pull bullet-like lines out of raw resume text. A line counts as a bullet
* when it starts with a recognized bullet marker (`-`, `•`, etc.) or a
* numbered list prefix and contains 4+ words after the marker is stripped.
* numbered list prefix and the stripped line contains at least one word.
* The displayed count is meant to match what a reader sees in the PDF —
* short or low-quality bullets are still bullets, they just get flagged
* by the downstream length / verb / metric checks in `scoreBulletPool`
* and `analyzeBullets`.
*
* We deliberately do NOT try to grade unmarked indented lines — most modern
* resumes use markers, and grading paragraphs would either over-count
Expand Down
12 changes: 6 additions & 6 deletions tests/fixtures/pdfs/latex/awesome-cv-cv.expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,21 +34,21 @@
"sectionSource": "markdown"
},
"score": {
"overall": 59,
"preLayoutOverall": 59,
"overall": 58,
"preLayoutOverall": 58,
"specificity": {
"score": 12,
"score": 11,
"max": 40,
"gradable": true,
"metricBullets": 10,
"totalBullets": 58
"totalBullets": 59
},
"structure": {
"score": 20,
"max": 30,
"gradable": true,
"goodBullets": 39,
"totalBullets": 58
"totalBullets": 59
},
"completeness": {
"score": 27,
Expand All @@ -63,7 +63,7 @@
"multiplier": 1,
"scanned": false
},
"bulletCount": 58,
"bulletCount": 59,
"algoVersion": "1.0"
}
}
12 changes: 6 additions & 6 deletions tests/fixtures/pdfs/latex/awesome-cv-resume.expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,21 @@
"sectionSource": "markdown"
},
"score": {
"overall": 64,
"preLayoutOverall": 64,
"overall": 63,
"preLayoutOverall": 63,
"specificity": {
"score": 18,
"score": 17,
"max": 40,
"gradable": true,
"metricBullets": 8,
"totalBullets": 30
"totalBullets": 31
},
"structure": {
"score": 23,
"max": 30,
"gradable": true,
"goodBullets": 24,
"totalBullets": 30
"totalBullets": 31
},
"completeness": {
"score": 23,
Expand All @@ -61,7 +61,7 @@
"multiplier": 1,
"scanned": false
},
"bulletCount": 30,
"bulletCount": 31,
"algoVersion": "1.0"
}
}
41 changes: 21 additions & 20 deletions tests/fixtures/pdfs/latex/deedy-resume-macfonts.expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,19 @@
],
"suggestedEscalation": "ocr",
"fieldsPopulated": [
"current_company",
"current_title",
"email",
"experience",
"family_name",
"full_name",
"github_url",
"given_name",
"linkedin_url",
"location",
"phone",
"skills",
"website_url"
],
"skillsCount": 0,
"experienceCount": 0,
"skillsCount": 25,
"experienceCount": 6,
"educationCount": 0,
"rawTextCharCount": 3304,
"pageCount": 1,
Expand All @@ -29,39 +30,39 @@
"sectionSource": "regex"
},
"score": {
"overall": 17,
"preLayoutOverall": 17,
"overall": 47,
"preLayoutOverall": 47,
"specificity": {
"score": 0,
"score": 8,
"max": 40,
"gradable": false,
"metricBullets": 0,
"totalBullets": 1
"gradable": true,
"metricBullets": 1,
"totalBullets": 8
},
"structure": {
"score": 0,
"score": 21,
"max": 30,
"gradable": false,
"goodBullets": 1,
"totalBullets": 1
"gradable": true,
"goodBullets": 6,
"totalBullets": 8
},
"completeness": {
"score": 17,
"score": 18,
"max": 30,
"gradable": true,
"missing": [
"LinkedIn",
"education",
"skills",
"summary",
"work experience"
"location",
"summary"
]
},
"layout": {
"triggers": [],
"multiplier": 1,
"scanned": false
},
"bulletCount": 1,
"bulletCount": 8,
"algoVersion": "1.0"
}
}
Loading