diff --git a/src/lib/heuristics/pdf-extract.test.ts b/src/lib/heuristics/pdf-extract.test.ts index 0c687634..ee937a4f 100644 --- a/src/lib/heuristics/pdf-extract.test.ts +++ b/src/lib/heuristics/pdf-extract.test.ts @@ -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"); + }); }); diff --git a/src/lib/heuristics/sections.ts b/src/lib/heuristics/sections.ts index 38e23afa..bf913e4d 100644 --- a/src/lib/heuristics/sections.ts +++ b/src/lib/heuristics/sections.ts @@ -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[] { @@ -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 = []; }; diff --git a/src/lib/score/score.test.ts b/src/lib/score/score.test.ts index 068b1b1f..5bacbfcd 100644 --- a/src/lib/score/score.test.ts +++ b/src/lib/score/score.test.ts @@ -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", diff --git a/src/lib/score/score.ts b/src/lib/score/score.ts index e1e6453a..5b1aea36 100644 --- a/src/lib/score/score.ts +++ b/src/lib/score/score.ts @@ -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; const ANON_CONTACT_FIELDS: readonly { key: "full_name" | "email" | "phone" | "location" | "linkedin_url"; @@ -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 diff --git a/tests/fixtures/pdfs/latex/awesome-cv-cv.expected.json b/tests/fixtures/pdfs/latex/awesome-cv-cv.expected.json index c28b4054..5c789a1b 100644 --- a/tests/fixtures/pdfs/latex/awesome-cv-cv.expected.json +++ b/tests/fixtures/pdfs/latex/awesome-cv-cv.expected.json @@ -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, @@ -63,7 +63,7 @@ "multiplier": 1, "scanned": false }, - "bulletCount": 58, + "bulletCount": 59, "algoVersion": "1.0" } } diff --git a/tests/fixtures/pdfs/latex/awesome-cv-resume.expected.json b/tests/fixtures/pdfs/latex/awesome-cv-resume.expected.json index 2106177a..068be954 100644 --- a/tests/fixtures/pdfs/latex/awesome-cv-resume.expected.json +++ b/tests/fixtures/pdfs/latex/awesome-cv-resume.expected.json @@ -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, @@ -61,7 +61,7 @@ "multiplier": 1, "scanned": false }, - "bulletCount": 30, + "bulletCount": 31, "algoVersion": "1.0" } } diff --git a/tests/fixtures/pdfs/latex/deedy-resume-macfonts.expected.json b/tests/fixtures/pdfs/latex/deedy-resume-macfonts.expected.json index a587ef7c..d0355328 100644 --- a/tests/fixtures/pdfs/latex/deedy-resume-macfonts.expected.json +++ b/tests/fixtures/pdfs/latex/deedy-resume-macfonts.expected.json @@ -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, @@ -29,31 +30,31 @@ "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": { @@ -61,7 +62,7 @@ "multiplier": 1, "scanned": false }, - "bulletCount": 1, + "bulletCount": 8, "algoVersion": "1.0" } } diff --git a/tests/fixtures/pdfs/latex/deedy-resume-openfonts.expected.json b/tests/fixtures/pdfs/latex/deedy-resume-openfonts.expected.json index 3cf7bb78..7f95d0db 100644 --- a/tests/fixtures/pdfs/latex/deedy-resume-openfonts.expected.json +++ b/tests/fixtures/pdfs/latex/deedy-resume-openfonts.expected.json @@ -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": 24, + "experienceCount": 6, "educationCount": 0, "rawTextCharCount": 3304, "pageCount": 1, @@ -29,31 +30,31 @@ "sectionSource": "regex" }, "score": { - "overall": 17, - "preLayoutOverall": 17, + "overall": 47, + "preLayoutOverall": 47, "specificity": { - "score": 0, + "score": 8, "max": 40, - "gradable": false, - "metricBullets": 0, - "totalBullets": 2 + "gradable": true, + "metricBullets": 1, + "totalBullets": 8 }, "structure": { - "score": 0, + "score": 21, "max": 30, - "gradable": false, - "goodBullets": 1, - "totalBullets": 2 + "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": { @@ -61,7 +62,7 @@ "multiplier": 1, "scanned": false }, - "bulletCount": 2, + "bulletCount": 8, "algoVersion": "1.0" } } diff --git a/tests/fixtures/pdfs/unknown/openresume-react-pdf.expected.json b/tests/fixtures/pdfs/unknown/openresume-react-pdf.expected.json index 4966b252..2c8b0143 100644 --- a/tests/fixtures/pdfs/unknown/openresume-react-pdf.expected.json +++ b/tests/fixtures/pdfs/unknown/openresume-react-pdf.expected.json @@ -23,7 +23,7 @@ "skills", "website_url" ], - "skillsCount": 16, + "skillsCount": 20, "experienceCount": 1, "educationCount": 1, "rawTextCharCount": 1923,