diff --git a/package-lock.json b/package-lock.json index 68d588b7..8eb0e210 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@fontsource/poppins": "^5.2.7", "@mlc-ai/web-llm": "0.2.84", + "jszip": "^3.10.1", "libphonenumber-js": "^1.13.6", "mammoth": "^1.12.0", "pdfjs-dist": "^4.10.38", @@ -2298,9 +2299,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2318,9 +2316,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2338,9 +2333,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2358,9 +2350,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5679,9 +5668,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5703,9 +5689,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5727,9 +5710,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5751,9 +5731,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index 1f57804e..f3d9854a 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dependencies": { "@fontsource/poppins": "^5.2.7", "@mlc-ai/web-llm": "0.2.84", + "jszip": "^3.10.1", "libphonenumber-js": "^1.13.6", "mammoth": "^1.12.0", "pdfjs-dist": "^4.10.38", diff --git a/src/App.tsx b/src/App.tsx index 930efc2e..6931f2a1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -49,6 +49,7 @@ export default function App() { fieldConfidence: state.result.fieldConfidence, triggers: state.result.triggers, rawText, + skillsSectionText: state.result.skillsSectionText, }); return { parsed, rawText, score }; }, [ diff --git a/src/components/features/AtsScoreReadout.tsx b/src/components/features/AtsScoreReadout.tsx index 97bae8f3..e1384ee3 100644 --- a/src/components/features/AtsScoreReadout.tsx +++ b/src/components/features/AtsScoreReadout.tsx @@ -86,9 +86,12 @@ export function AtsScoreReadout({ score }: AtsScoreReadoutProps) { const specificityHint = `${score.specificity.metricBullets}/${score.specificity.totalBullets} bullets carry a metric`; const structureHint = `${score.structure.goodBullets}/${score.structure.totalBullets} bullets within 8–30 words`; const completenessHint = - score.completeness.missing.length === 0 + (score.completeness.missing.length === 0 ? "All expected fields present" - : `Missing: ${score.completeness.missing.join(", ")}`; + : `Missing: ${score.completeness.missing.join(", ")}`) + + (score.completeness.redactedDates + ? " · Dates appear redacted — use 4-digit years for best results." + : ""); const dimensions: VerdictDimension[] = [ { diff --git a/src/components/features/ContactCard.tsx b/src/components/features/ContactCard.tsx index 392996fe..dd916d74 100644 --- a/src/components/features/ContactCard.tsx +++ b/src/components/features/ContactCard.tsx @@ -4,9 +4,10 @@ /** * ContactCard — displays extracted contact fields as a chip strip. * - * Detected fields show the value with a success chip; undetected fields - * show a warning chip with a "not detected" label. Always renders all 5 - * fields so the reader can spot gaps at a glance. + * Detected fields show the value with a success chip; undetected required + * fields show a warning chip with a "not detected" label so the reader can + * spot gaps at a glance. Optional fields (e.g. GitHub) render only when + * detected — see `buildContactFields`. * * Edit mode (#58): when `overrides` and `onFieldChange` are provided, each * field chip gains an inline EditableField affordance. Edited values replace @@ -74,7 +75,7 @@ export function ContactCard({ return (

- Contact — {detectedCount} of 5 detected + Contact — {detectedCount} of {displayFields.length} detected

{displayFields.map((field) => { diff --git a/src/hooks/useResumeAnalysis.ts b/src/hooks/useResumeAnalysis.ts index e9f977c7..9fb8afe8 100644 --- a/src/hooks/useResumeAnalysis.ts +++ b/src/hooks/useResumeAnalysis.ts @@ -96,6 +96,7 @@ export function useResumeAnalysis(): ResumeAnalysis { fieldConfidence: result.fieldConfidence, triggers: result.triggers, rawText: result.rawText, + skillsSectionText: result.skillsSectionText, }); trackParseCompleted({ diff --git a/src/lib/contact.test.ts b/src/lib/contact.test.ts index b867d41a..1f83781a 100644 --- a/src/lib/contact.test.ts +++ b/src/lib/contact.test.ts @@ -25,7 +25,7 @@ function makeCascade( } describe("buildContactFields", () => { - it("returns 5 rows in the correct order", () => { + it("returns the 5 required rows (no GitHub) when GitHub is absent", () => { const fields = buildContactFields(makeCascade()); expect(fields).toHaveLength(5); expect(fields.map((f) => f.key)).toEqual([ @@ -37,6 +37,37 @@ describe("buildContactFields", () => { ]); }); + it("includes the GitHub row only when it is confidently detected", () => { + const fields = buildContactFields( + makeCascade( + { github_url: "https://github.com/jane" }, + { github_url: 0.95 }, + ), + ); + expect(fields.map((f) => f.key)).toEqual([ + "full_name", + "email", + "phone", + "linkedin_url", + "github_url", + "location", + ]); + const gh = fields.find((f) => f.key === "github_url")!; + expect(gh.gated).toBe(false); + expect(gh.value).toBe("https://github.com/jane"); + }); + + it("omits the GitHub row when present but below the confidence floor", () => { + const fields = buildContactFields( + makeCascade( + { github_url: "https://github.com/jane" }, + { github_url: CONTACT_DISPLAY_CONFIDENCE_FLOOR - 0.01 }, + ), + ); + expect(fields.some((f) => f.key === "github_url")).toBe(false); + expect(fields).toHaveLength(5); + }); + it("shows a field (gated=false) when value is present and confidence is above the floor", () => { const fields = buildContactFields( makeCascade( @@ -69,7 +100,7 @@ describe("buildContactFields", () => { expect(phoneField.value).toBe(""); }); - it("shows all five fields when all are present and above the confidence floor", () => { + it("shows all six fields when all are present and above the confidence floor", () => { const fields = buildContactFields( makeCascade( { @@ -77,6 +108,7 @@ describe("buildContactFields", () => { email: "jane@example.com", phone: "555-0100", linkedin_url: "https://linkedin.com/in/jane", + github_url: "https://github.com/jane", location: "San Francisco, CA", }, { @@ -84,6 +116,7 @@ describe("buildContactFields", () => { email: 0.95, phone: 0.85, linkedin_url: 0.8, + github_url: 0.8, location: 0.75, }, ), @@ -94,6 +127,7 @@ describe("buildContactFields", () => { "jane@example.com", "555-0100", "https://linkedin.com/in/jane", + "https://github.com/jane", "San Francisco, CA", ]); }); diff --git a/src/lib/contact.ts b/src/lib/contact.ts index 7522d9ff..811de65f 100644 --- a/src/lib/contact.ts +++ b/src/lib/contact.ts @@ -24,14 +24,23 @@ export interface ContactDisplayField { reason?: "absent" | "low_confidence"; } -const CONTACT_ROWS: readonly { key: keyof typeof FIELD_KEYS; label: string }[] = - [ - { key: "full_name", label: "Name" }, - { key: "email", label: "Email" }, - { key: "phone", label: "Phone" }, - { key: "linkedin_url", label: "LinkedIn" }, - { key: "location", label: "Location" }, - ]; +const CONTACT_ROWS: readonly { + key: keyof typeof FIELD_KEYS; + label: string; + /** Optional rows surface only when actually detected. Not every candidate + * keeps a GitHub profile, so its absence is not a gap — an optional row + * never renders a "not detected" chip nor counts against the detected/total + * ratio. Required rows (the rest) always render so the reader can spot a + * missing email/phone/etc. at a glance. */ + optional?: boolean; +}[] = [ + { key: "full_name", label: "Name" }, + { key: "email", label: "Email" }, + { key: "phone", label: "Phone" }, + { key: "linkedin_url", label: "LinkedIn" }, + { key: "github_url", label: "GitHub", optional: true }, + { key: "location", label: "Location" }, +]; // TypeScript trick: enumerate the valid keys for indexing `parsed`. const FIELD_KEYS = { @@ -39,36 +48,39 @@ const FIELD_KEYS = { email: true, phone: true, linkedin_url: true, + github_url: true, location: true, } as const; /** * Build the ordered contact display rows from a `CascadeResult`. * - * Always returns exactly 5 rows in the order: Name, Email, Phone, LinkedIn, - * Location. A row is `gated` when its value is absent or its confidence is - * below `CONTACT_DISPLAY_CONFIDENCE_FLOOR`. + * Returns the required rows in order — Name, Email, Phone, LinkedIn, Location — + * each always present (and `gated` when absent / below + * `CONTACT_DISPLAY_CONFIDENCE_FLOOR`). Optional rows (GitHub) are included only + * when confidently detected, so a candidate without a GitHub profile sees no + * "GitHub not detected" gap and no penalty in the detected/total ratio. */ export function buildContactFields( cascade: Pick, ): ContactDisplayField[] { - return CONTACT_ROWS.map(({ key, label }) => { + const rows: ContactDisplayField[] = []; + for (const { key, label, optional } of CONTACT_ROWS) { const raw = cascade.parsed[key as keyof typeof FIELD_KEYS]; const value = typeof raw === "string" ? raw : ""; const conf = cascade.fieldConfidence[key as keyof typeof FIELD_KEYS] ?? 0; + const detected = Boolean(value) && conf >= CONTACT_DISPLAY_CONFIDENCE_FLOOR; + + // An optional field is shown only when detected — its absence is not a gap. + if (optional && !detected) continue; if (!value) { - return { key, label, value: "", gated: true, reason: "absent" as const }; - } - if (conf < CONTACT_DISPLAY_CONFIDENCE_FLOOR) { - return { - key, - label, - value: "", - gated: true, - reason: "low_confidence" as const, - }; + rows.push({ key, label, value: "", gated: true, reason: "absent" }); + } else if (conf < CONTACT_DISPLAY_CONFIDENCE_FLOOR) { + rows.push({ key, label, value: "", gated: true, reason: "low_confidence" }); + } else { + rows.push({ key, label, value, gated: false }); } - return { key, label, value, gated: false }; - }); + } + return rows; } diff --git a/src/lib/heuristics/cascade.ts b/src/lib/heuristics/cascade.ts index c27616de..c628bb4b 100644 --- a/src/lib/heuristics/cascade.ts +++ b/src/lib/heuristics/cascade.ts @@ -195,6 +195,9 @@ export async function runCascade( tiers, rawText: extract.text, markdown, + ...(heuristic.skillsSectionLines?.length + ? { skillsSectionText: heuristic.skillsSectionLines.join("\n") } + : {}), linkAnnotations: extract.linkAnnotations, diagnostics: { rawCharCount: extract.rawCharCount, @@ -402,6 +405,9 @@ export async function runCascadeFromMarkdown( tiers, rawText, markdown, + ...(heuristic.skillsSectionLines?.length + ? { skillsSectionText: heuristic.skillsSectionLines.join("\n") } + : {}), // DOCX cascade has no PDF annotations. linkAnnotations: [], diagnostics: { diff --git a/src/lib/heuristics/corpus.test.ts b/src/lib/heuristics/corpus.test.ts index ea5b0c44..908f2ce5 100644 --- a/src/lib/heuristics/corpus.test.ts +++ b/src/lib/heuristics/corpus.test.ts @@ -105,6 +105,7 @@ describe("corpus snapshots", () => { fieldConfidence: cascade.fieldConfidence, triggers: cascade.triggers, rawText: cascade.rawText, + skillsSectionText: cascade.skillsSectionText, }); const snapshot = { diff --git a/src/lib/heuristics/entry-blocks.test.ts b/src/lib/heuristics/entry-blocks.test.ts index 5922bf6d..15392953 100644 --- a/src/lib/heuristics/entry-blocks.test.ts +++ b/src/lib/heuristics/entry-blocks.test.ts @@ -14,7 +14,7 @@ import { describe, it, expect } from "vitest"; import { groupIntoLines, splitIntoSections, findSection } from "./sections.ts"; import { parseEntryBlocks } from "./entry-blocks.ts"; import { mkItems } from "./__test-utils__/mkItem.ts"; -import type { PdfSection } from "./sections.ts"; +import type { PdfSection, PdfLine } from "./sections.ts"; /** Build an experience section from line specs (the date_range anchor case). */ function experienceSection( @@ -24,6 +24,24 @@ function experienceSection( return findSection(sections, "experience"); } +/** Build a section from explicit (text, x) lines — for x-sensitive cases like + * wrapped-bullet continuations indented past the bullet marker. */ +function xSection( + name: PdfSection["name"], + rows: Array<{ text: string; x: number }>, +): PdfSection { + const lines: PdfLine[] = rows.map(({ text, x }) => ({ + page: 1, + y: 0, + x, + items: [], + text, + maxFontSize: 11, + allCaps: false, + })); + return { name, lines }; +} + describe("parseEntryBlocks — date_range anchor", () => { it("returns [] for an absent or empty section", () => { expect( @@ -149,6 +167,31 @@ describe("parseEntryBlocks — date_range anchor", () => { ); }); + it("drops a wrapped bullet tail from the next entry's header (#boundary)", () => { + // The bullet wraps onto a marker-less line indented past the bullet marker + // (x 90 > marker x 64); headers sit at the left margin (x 50). The wrapped + // tail must not leak into the next entry's company / designation. + const section = xSection("experience", [ + { text: "Northwind Labs Jul 2025 - Present", x: 50 }, + { text: "• Documented architecture and managed changes with peer", x: 64 }, + { text: "review.", x: 90 }, // wrapped continuation of the bullet above + { text: "Riverton County Schools Oct 2025 - Present", x: 50 }, + { text: "Substitute Teacher", x: 50 }, + { text: "• Supported classroom instruction.", x: 64 }, + ]); + const blocks = parseEntryBlocks(section, { + anchor: "date_range", + collectBody: true, + headerLookback: 2, + }); + expect(blocks).toHaveLength(2); + // The fragment must not leak into the second entry's header. + expect(blocks[1].headerLines.some((h) => /review/.test(h))).toBe(false); + expect( + blocks[1].headerLines.some((h) => h.includes("Riverton County Schools")), + ).toBe(true); + }); + it("honors headerLookback=0 — no lines above the anchor join the header", () => { const section = experienceSection([ { text: "EXPERIENCE", fontSize: 13 }, @@ -220,4 +263,44 @@ describe("parseEntryBlocks — first_line anchor (projects / date-optional secti expect(blocks[0].dates.start_date).toBeTruthy(); expect(blocks[0].headerLines.some((h) => /\d{4}/.test(h))).toBe(false); }); + + // x-aware builder: a long bullet wraps onto a marker-less second line that + // aligns with the bullet *text* (indented past the header margin). + function sectionX(lines: Array<{ text: string; x: number }>): PdfSection { + return { + name: "projects", + lines: lines.map((l, i) => ({ + page: 1, + y: 72 + i * 14, + x: l.x, + items: [], + text: l.text, + maxFontSize: 11, + allCaps: false, + })), + }; + } + + it("treats an indented wrapped-bullet line as a continuation, not a new entry", () => { + // Headers sit at the section margin (x=50); the bullet text (and thus a + // wrapped continuation of it) is indented to x=73. The two wrap lines must + // not open phantom entries, and the real header that follows a wrap must + // still be recovered (it would be lost by a naive "prev is a bullet" rule). + const blocks = parseEntryBlocks( + sectionX([ + { text: "Revenue Forecasting Project", x: 50 }, + { text: "● Used five forecasting methods, and", x: 64 }, + { text: "TAF on deseasonalized revenue data", x: 73 }, // wrap + { text: "● Identified the most suitable method among all", x: 64 }, + { text: "methods", x: 73 }, // wrap + { text: "Global Entry Strategy Project", x: 50 }, // real header after wrap + { text: "● Evaluated market potential", x: 64 }, + ]), + { anchor: "first_line", collectBody: true }, + ); + expect(blocks.map((b) => b.headerLines[0])).toEqual([ + "Revenue Forecasting Project", + "Global Entry Strategy Project", + ]); + }); }); diff --git a/src/lib/heuristics/entry-blocks.ts b/src/lib/heuristics/entry-blocks.ts index 4653c584..889652b0 100644 --- a/src/lib/heuristics/entry-blocks.ts +++ b/src/lib/heuristics/entry-blocks.ts @@ -131,18 +131,53 @@ function isAnchorLine(line: PdfLine, anchor: EntryAnchor): boolean { */ function collectAnchors(lines: PdfLine[], anchor: EntryAnchor): number[] { const anchors: number[] = []; + // Reference indent for the `first_line` anchor: the x of the bullet markers. + // Entry headers sit at (or left of) this margin, but when a long bullet wraps + // onto a second, marker-less line that continuation aligns with the bullet + // *text* — i.e. to the RIGHT of the marker. That x relationship (not an + // absolute point tolerance, which fails on tightly-indented layouts) is what + // separates a wrapped continuation from a real new header. Only the + // `first_line` anchor needs it, so the others skip the scan (Infinity). + const markerX = anchor === "first_line" ? bulletMarkerX(lines) : Infinity; for (let i = 0; i < lines.length; i++) { if (!isAnchorLine(lines[i], anchor)) continue; - if (anchor === "first_line") { - const prevIsBullet = i > 0 && isBulletLine(lines[i - 1]); - const isFirst = i === 0; - if (!isFirst && !prevIsBullet) continue; // mid-header line, not a new entry + if (anchor === "first_line" && i > 0) { + // Indented past the bullet marker → a wrapped bullet line, not a header. + if (lines[i].x > markerX) continue; + // Directly below another header-level (marker-or-left) non-bullet line → + // the 2nd line of a multi-line header ("Title" / "Company"), not a new + // entry. (A header that follows a wrapped bullet or a bullet still opens + // one, so real headers after a wrap aren't lost.) + const prev = lines[i - 1]; + if (!isBulletLine(prev) && prev.x <= markerX) continue; } anchors.push(i); } return anchors; } +/** Leftmost x of any bullet line in the section — the bullet *marker* margin. + * `Infinity` when the section has no bullets. */ +function bulletMarkerX(lines: PdfLine[]): number { + let x = Infinity; + for (const l of lines) if (isBulletLine(l)) x = Math.min(x, l.x); + return x; +} + +/** + * True when a non-bullet line is the marker-less continuation of a wrapped + * bullet — it sits indented to the right of the bullet *marker* margin (where + * bullet TEXT wraps), whereas a real entry header sits at or left of that + * margin. This is the structural signal (also used by `collectAnchors`) that + * keeps a wrapped bullet's tail ("…and informing / them of resources") from + * contaminating the next entry's company / designation. A no-op when the + * section has no bullets (markerX = Infinity) or carries no x positions + * (markdown, all x = 0). + */ +function isWrappedContinuation(line: PdfLine, markerX: number): boolean { + return Number.isFinite(markerX) && line.x > markerX + 2; +} + /** * Split a section into entry blocks per `cfg`. Returns an empty array for an * absent/empty section or one with no anchors. @@ -185,14 +220,18 @@ function buildEntryBlock( const anchorIdx = anchors[a]; const nextAnchorIdx = a + 1 < anchors.length ? anchors[a + 1] : lines.length; const prevAnchorIdx = a === 0 ? 0 : anchors[a - 1] + 1; + const markerX = bulletMarkerX(lines); // Header candidates above the anchor (e.g. "Title\nCompany "). - // Bounded by the previous entry's window and the configured lookback; - // bullets from the previous entry are skipped. + // Bounded by the previous entry's window and the configured lookback; bullets + // and wrapped-bullet tails (indented past the marker) from the previous entry + // are skipped so they never leak into this entry's header (#boundary). const aboveStart = Math.max(prevAnchorIdx, anchorIdx - lookback); const aboveLines = lookback > 0 - ? lines.slice(aboveStart, anchorIdx).filter((l) => !isBulletLine(l)) + ? lines + .slice(aboveStart, anchorIdx) + .filter((l) => !isBulletLine(l) && !isWrappedContinuation(l, markerX)) : []; const anchorLine = lines[anchorIdx]; @@ -201,9 +240,11 @@ function buildEntryBlock( // Header candidates below the anchor (e.g. "Company \nTitle"): // consecutive non-bullet lines until the first bullet or the next anchor. + // A wrapped-bullet tail is skipped (not a header) but does not end the run. const belowHeaderLines: PdfLine[] = []; for (let i = anchorIdx + 1; i < nextAnchorIdx; i++) { if (isBulletLine(lines[i])) break; + if (isWrappedContinuation(lines[i], markerX)) continue; belowHeaderLines.push(lines[i]); } diff --git a/src/lib/heuristics/extract-fields.test.ts b/src/lib/heuristics/extract-fields.test.ts index d7e02ea1..e3347539 100644 --- a/src/lib/heuristics/extract-fields.test.ts +++ b/src/lib/heuristics/extract-fields.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect } from "vitest"; import { extractContact, extractName, + extractSkills, extractEducation, extractExperience, extractProjects, @@ -18,7 +19,7 @@ import { type PdfSection, } from "./sections.ts"; import { US_LOCATION_RE } from "./regex.ts"; -import type { PdfLinkAnnotation } from "./types.ts"; +import type { PdfLinkAnnotation, PdfTextItem } from "./types.ts"; import { mkItems, mkDefaultPages } from "./__test-utils__/mkItem.ts"; void mkDefaultPages; // imported for parity with sibling tests @@ -72,36 +73,12 @@ describe("extractContact — annotation fallback for hyperlinked URLs", () => { expect(contact.email).toBe("mohinp@uw.edu"); }); - it("does not pull a footer LinkedIn annotation into the candidate's profile", () => { - // A LinkedIn URL that lives in the body of a project section (e.g. - // referenced as a citation) should not be misattributed as the - // candidate's profile. The y-band filter restricts the lookup to - // annotations above the first section header. - const { lines, profile } = buildContext([ - { text: "Jane Doe", fontSize: 18 }, - { text: "jane@example.com", fontSize: 10 }, - { text: "" }, - { text: "EXPERIENCE", fontSize: 13 }, - { text: "Recommendation from LinkedIn at https://linkedin.com/in/someone-else", fontSize: 11 }, - ]); - const annotations: PdfLinkAnnotation[] = [ - { - page: 1, - // y past the EXPERIENCE header — outside the profile band. - url: "https://www.linkedin.com/in/someone-else/", - rect: [200, 200, 300, 215], - yTop: 600, - }, - ]; - const contact = extractContact(profile, lines, annotations); - // The text version above the regex would catch it from the body line, - // but the location/profile annotation logic specifically should not - // adopt the URL as the candidate's. Since `LINKEDIN_RE` can match the - // URL in the experience body line via fallback scan, we accept that — - // the regression we're guarding is that the annotation system does - // not contribute its own band-violating hit. Validate by removing - // the visible URL and checking annotation alone is rejected. - void contact; // suppress unused + it("recovers a LinkedIn annotation below a section header (identity links are doc-wide)", () => { + // A candidate's LinkedIn/GitHub is commonly hyperlinked behind an icon in a + // footer or a "Links" block placed after Skills. The `linkedin.com/in/` + // predicate is specific enough to adopt document-wide — and this already + // matches the text fallback, which scans the whole document. Losing the + // link entirely is worse than the rare misattribution of a cited profile. const annotationsOnly = buildContext([ { text: "Jane Doe", fontSize: 18 }, { text: "jane@example.com", fontSize: 10 }, @@ -115,14 +92,14 @@ describe("extractContact — annotation fallback for hyperlinked URLs", () => { [ { page: 1, - url: "https://www.linkedin.com/in/footer-only/", + url: "https://www.linkedin.com/in/jane-doe/", rect: [200, 200, 300, 215], - yTop: 600, // below EXPERIENCE header + yTop: 600, // below the EXPERIENCE header }, ], ); - expect(result.linkedin_url).toBeUndefined(); - expect(result.confidence.linkedin_url).toBe(0); + expect(result.linkedin_url).toBe("https://www.linkedin.com/in/jane-doe/"); + expect(result.confidence.linkedin_url).toBeGreaterThan(0); }); it("never overwrites a text-extracted URL with an annotation hit", () => { @@ -143,6 +120,39 @@ describe("extractContact — annotation fallback for hyperlinked URLs", () => { ]); expect(result.linkedin_url).toContain("from-text"); }); + + it("recovers a vanity LinkedIn hyperlink that omits the /in/ path", () => { + // Some resumes hyperlink "LinkedIn" to a bare vanity host + // (linkedin.com/) rather than the canonical /in/. GitHub + // links of this shape were already detected; LinkedIn must be symmetric. + const { lines, profile } = buildContext([ + { text: "John Doe", fontSize: 18 }, + { text: "john.doe@example.com | LinkedIn | GitHub", fontSize: 10 }, + { text: "" }, + { text: "EXPERIENCE", fontSize: 13 }, + { text: "Role", fontSize: 11 }, + ]); + const result = extractContact(profile, lines, [ + { page: 1, url: "https://linkedin.com/johndoe", rect: [0, 0, 100, 20], yTop: 80 }, + { page: 1, url: "https://github.com/johndoe", rect: [0, 0, 100, 20], yTop: 80 }, + ]); + expect(result.linkedin_url).toBe("https://linkedin.com/johndoe"); + expect(result.github_url).toBe("https://github.com/johndoe"); + }); + + it("does not treat a LinkedIn company/feed link as a personal profile", () => { + const { lines, profile } = buildContext([ + { text: "John Doe", fontSize: 18 }, + { text: "john.doe@example.com", fontSize: 10 }, + { text: "" }, + { text: "EXPERIENCE", fontSize: 13 }, + { text: "Role", fontSize: 11 }, + ]); + const result = extractContact(profile, lines, [ + { page: 1, url: "https://www.linkedin.com/company/acme", rect: [0, 0, 100, 20], yTop: 80 }, + ]); + expect(result.linkedin_url).toBeUndefined(); + }); }); describe("extractContact — location no longer falls back to document-wide scan", () => { @@ -436,6 +446,162 @@ describe("extractName — single-word (mononym) names (issue #107)", () => { }); }); +describe("extractName — stacked single-word name lines (#29)", () => { + it("merges adjacent given/family-name lines a tagline cannot outscore", () => { + // Word résumé templates render the name as two single-word lines + // ("Chanchal" / "Sharma"); each is individually rejected by the 2-word + // guard, so a two-word tagline below ("Office Manager") used to win the + // slot. The merged candidate must take it back. + const { profile } = buildContext([ + { text: "Chanchal", fontSize: 20 }, + { text: "Sharma", fontSize: 20 }, + { text: "Office Manager", fontSize: 12 }, + { text: "chanchals@example.com · (718) 555-0100", fontSize: 10 }, + ]); + const result = extractName(profile); + expect(result.value).toBe("Chanchal Sharma"); + // Must clear the scorer's 0.5 contact-confidence floor. + expect(result.confidence).toBeGreaterThanOrEqual(0.5); + }); + + it("does not merge a single name word with doc-title boilerplate", () => { + // "Jane" / "Resume" must not glue into "Jane Resume". + const { profile } = buildContext([ + { text: "Jane", fontSize: 20 }, + { text: "Resume", fontSize: 20 }, + { text: "jane@example.com", fontSize: 10 }, + ]); + expect(extractName(profile).value).not.toBe("Jane Resume"); + }); + + it("leaves a normal two-word name line unchanged (no spurious merge)", () => { + const { profile } = buildContext([ + { text: "Jane Smith", fontSize: 20 }, + { text: "jane.smith@example.com", fontSize: 10 }, + ]); + expect(extractName(profile).value).toBe("Jane Smith"); + }); +}); + +describe("extractSkills — borderless multi-column tables (#29)", () => { + // pdfjs fills the inter-column gap of a Word/LaTeX skills table with a wide + // blank "spacer" item, so the whole row arrives as one PdfLine. The geometry + // below mirrors the Chanchal Word fixture: three columns at x≈122/266/423. + function skillsSection( + runs: Array<{ x: number; str: string; w: number }>, + text: string, + ): PdfSection { + const items: PdfTextItem[] = runs.map((r) => ({ + page: 1, + str: r.str, + x: r.x, + y: 300, + width: r.w, + height: 11, + fontSize: 11, + fontName: "font-11", + hasEOL: true, + })); + const line: PdfLine = { + page: 1, + y: 300, + x: runs[0].x, + items, + text, + maxFontSize: 11, + allCaps: false, + }; + return { name: "skills", lines: [line] }; + } + + it("splits a row on wide blank spacer items into one skill per column", () => { + const section = skillsSection( + [ + { x: 122, str: "Project management", w: 85 }, + { x: 207, str: " ", w: 59 }, + { x: 266, str: "Data analysis", w: 53 }, + { x: 319, str: " ", w: 105 }, + { x: 423, str: "Communication", w: 64 }, + ], + "Project management Data analysis Communication", + ); + expect(extractSkills(section).value).toEqual([ + "Project management", + "Data analysis", + "Communication", + ]); + }); + + it("keeps a multi-word skill intact when the internal gap is ordinary", () => { + // A single narrow space between "Machine" and "Learning" is NOT a column + // spacer, so the cell stays whole instead of shredding into two tokens. + const section = skillsSection( + [{ x: 122, str: "Machine Learning", w: 80 }], + "Machine Learning", + ); + expect(extractSkills(section).value).toEqual(["Machine Learning"]); + }); + + it("still splits an ordinary comma-separated single-column line", () => { + const section = skillsSection( + [{ x: 122, str: "Python, Java, SQL", w: 90 }], + "Python, Java, SQL", + ); + expect(extractSkills(section).value).toEqual(["Python", "Java", "SQL"]); + }); + + it("excludes profile links and their bare heading words from skills", () => { + // GitHub/LinkedIn that surface as link headings swept into the Skills area + // must not be classified as skills — they belong only in contact. + const mkLine = (str: string): PdfLine => ({ + page: 1, + y: 300, + x: 122, + items: [ + { + page: 1, + str, + x: 122, + y: 300, + width: 100, + height: 11, + fontSize: 11, + fontName: "font-11", + hasEOL: true, + }, + ], + text: str, + maxFontSize: 11, + allCaps: false, + }); + const section: PdfSection = { + name: "skills", + lines: [ + mkLine("Python, React, Docker"), + mkLine("GitHub"), + mkLine("LinkedIn"), + mkLine("github.com/janesmith"), + mkLine("linkedin.com/in/janesmith"), + mkLine("https://janesmith.dev/portfolio"), + ], + }; + expect(extractSkills(section).value).toEqual(["Python", "React", "Docker"]); + }); + + it("does not over-reject dotted real skills that resemble domains", () => { + const section = skillsSection( + [{ x: 122, str: "Node.js, Socket.io, ASP.NET, GitHub Actions", w: 200 }], + "Node.js, Socket.io, ASP.NET, GitHub Actions", + ); + expect(extractSkills(section).value).toEqual([ + "Node.js", + "Socket.io", + "ASP.NET", + "GitHub Actions", + ]); + }); +}); + 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 @@ -519,6 +685,48 @@ describe("extractEducation — date-range parsing (issue #97)", () => { expect(value[0].year).toBe("2023"); }); + it("extracts every degree in a multi-qualification section (degree-first)", () => { + const { value } = extractEducation( + mkEduSection([ + "Ph.D. in Computer Science", + "Stanford University", + "2020 - 2024", + "M.S. in Electrical Engineering", + "MIT", + "2018 - 2020", + "B.S. in Computer Engineering", + "UC Berkeley", + "2014 - 2018", + ]), + ); + expect(value).toHaveLength(3); + expect(value.map((e) => e.degree)).toEqual(["Ph.D.", "M.S.", "B.S."]); + // Acronym schools with no "University"/"College" word are still recovered. + expect(value.map((e) => e.institution)).toEqual([ + "Stanford University", + "MIT", + "UC Berkeley", + ]); + expect(value.map((e) => e.end_date)).toEqual(["2024", "2020", "2018"]); + }); + + it("extracts every degree when institution leads each entry", () => { + const { value } = extractEducation( + mkEduSection([ + "Stanford University", + "Ph.D. in Computer Science, 2020 - 2024", + "University of Washington", + "B.S. in Informatics, 2016 - 2020", + ]), + ); + expect(value).toHaveLength(2); + expect(value.map((e) => e.institution)).toEqual([ + "Stanford University", + "University of Washington", + ]); + expect(value.map((e) => e.degree)).toEqual(["Ph.D.", "B.S."]); + }); + it("maps a single graduation date to end_date with NO spurious start", () => { const { value } = extractEducation( mkEduSection([ @@ -589,6 +797,43 @@ describe("extractExperience", () => { it("returns empty + zero confidence for an absent section", () => { expect(extractExperience(undefined)).toEqual({ value: [], confidence: 0 }); }); + + it("identifies the university as company, not title, in a stacked student-role header", () => { + // Reported bug: "Designation / University / Dates" stacked the wrong way — + // the university won the title slot because it carries no "Inc"/"LLC" suffix + // and "Assistant" was not a recognized title keyword. + const section = mkSection("experience", [ + { text: "Student Technology Assistant" }, + { text: "North Carolina A&T State University" }, + { text: "Jan 2024 - May 2025" }, + { text: "• Provided technical support for classrooms and computer labs." }, + { text: "• Assisted with desktop deployments and troubleshooting." }, + { text: "• Collaborated with endpoint management and security teams." }, + ]); + const { value } = extractExperience(section); + expect(value).toHaveLength(1); + expect(value[0].title).toBe("Student Technology Assistant"); + expect(value[0].company).toBe("North Carolina A&T State University"); + expect(value[0].start_date).toBe("Jan 2024"); + expect(value[0].end_date).toBe("May 2025"); + expect(value[0].description).toContain("Provided technical support"); + // The three bullets are responsibilities, not separate entries. + expect(value[0].description?.split("\n")).toHaveLength(3); + }); + + it("keeps a designation that contains an institution word as the title", () => { + // "University Lecturer" is a job title, not the employer — the company is the + // suffixed line. + const section = mkSection("experience", [ + { text: "University Lecturer" }, + { text: "Globex Corporation" }, + { text: "2021 - 2023" }, + { text: "• Taught undergraduate courses." }, + ]); + const { value } = extractExperience(section); + expect(value[0].title).toBe("University Lecturer"); + expect(value[0].company).toBe("Globex Corporation"); + }); }); describe("extractProjects", () => { diff --git a/src/lib/heuristics/extract-fields.ts b/src/lib/heuristics/extract-fields.ts index 0771b84d..72c28034 100644 --- a/src/lib/heuristics/extract-fields.ts +++ b/src/lib/heuristics/extract-fields.ts @@ -17,8 +17,9 @@ import type { HeuristicAchievement, } from "../score/types.ts"; import type { PdfLine, PdfSection } from "./sections.ts"; +import { mergeItemText } from "./sections.ts"; import { matchSectionHeader } from "./regex.ts"; -import type { PdfLinkAnnotation } from "./types.ts"; +import type { PdfLinkAnnotation, PdfTextItem } from "./types.ts"; import { EMAIL_RE, PHONE_RE, @@ -130,6 +131,25 @@ function looksLikeMononymName(text: string, line: PdfLine, maxFontSize: number): return true; } +/** + * True when `raw` is a single title-cased (or all-caps) letter token that could + * be one half of a stacked name. Word résumé templates render the given name and + * family name as separate single-word lines ("Chanchal" / "Sharma"); each is + * individually rejected by `extractName`'s ≥2-word guard, so an adjacent pair is + * merged into one candidate. Section headers ("SUMMARY") and document-title + * boilerplate ("Resume") are excluded so neither ever glues onto a name. + */ +function isSingleNameWord(raw: string): boolean { + const text = raw.trim(); + if (!text || text.length > 30) return false; + if (/\s/.test(text)) return false; // must be exactly one token + if (/\d/.test(text) || text.includes("@")) return false; + if (!/^[A-Z][A-Za-z.\-']*$/.test(text)) return false; + if (matchSectionHeader(text)) return false; + if (looksLikeDocTitleBoilerplate([text])) return false; + return true; +} + /** 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. */ @@ -181,7 +201,7 @@ export function extractName( 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; + let best: { text: string; 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 @@ -191,9 +211,38 @@ export function extractName( // "missing" in completeness scoring. let firstEligibleIdx: number | null = null; - for (let i = 0; i < Math.min(profile.lines.length, 5); i++) { + // Candidate list: each of the top lines, plus a synthetic merge of any two + // adjacent single-word name lines. Word templates stack the given and family + // name on separate lines ("Chanchal" / "Sharma"); each is individually + // rejected as a lone word, so the pair is offered as one two-word candidate. + // The merge keeps its first line's index so it can win the first-line bonus + // over a tagline ("Office Manager") sitting just below it. With no stacked + // single-word lines, the list is exactly the top-N lines in order, so + // single-line layouts score byte-identically to before. + const scan = Math.min(profile.lines.length, 5); + const candidates: { text: string; line: PdfLine; idx: number }[] = []; + for (let i = 0; i < scan; i++) { const line = profile.lines[i]; - const text = line.text.trim(); + candidates.push({ text: line.text.trim(), line, idx: i }); + const next = profile.lines[i + 1]; + if ( + i <= 1 && + next && + isSingleNameWord(line.text) && + isSingleNameWord(next.text) + ) { + candidates.push({ + text: `${line.text.trim()} ${next.text.trim()}`, + line: { + ...line, + maxFontSize: Math.max(line.maxFontSize, next.maxFontSize), + }, + idx: i, + }); + } + } + + for (const { text, line, idx } of candidates) { if (!text || text.length > 60) continue; if (/\d/.test(text)) continue; if (text.includes("@")) continue; @@ -216,10 +265,10 @@ export function extractName( if (letterRatio < 0.7) continue; if (looksLikeDocTitleBoilerplate(words)) continue; - if (firstEligibleIdx === null) firstEligibleIdx = i; + if (firstEligibleIdx === null) firstEligibleIdx = idx; let score = 0; - if (i === firstEligibleIdx) score += 0.4; + if (idx === firstEligibleIdx) score += 0.4; 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)); @@ -229,9 +278,9 @@ export function extractName( // First eligible line near contact: soft confirmation. A *later* line // near contact: a recovery bonus large enough to overtake a higher / // larger line that won only on position/size — the mode-2 case in #16. - // Gating the strong bonus on `i !== firstEligibleIdx` keeps the #14 + // Gating the strong bonus on `idx !== firstEligibleIdx` keeps the #14 // mode-1 fixture (first-eligible name) byte-identical. - score += i === firstEligibleIdx ? 0.15 : 0.4; + score += idx === firstEligibleIdx ? 0.15 : 0.4; } // A job-title tagline ("Product Designer", "Senior Marketing Lead") must // not win the name slot on position/size. Real names never match the @@ -243,11 +292,11 @@ export function extractName( // strong mononym still clears the scorer's 0.5 contact-confidence floor. if (isMononym) score -= 0.1; - if (!best || score > best.score) best = { line, score }; + if (!best || score > best.score) best = { text, score }; } if (!best) return { confidence: 0 }; - return { value: best.line.text.trim(), confidence: Math.min(best.score, 1) }; + return { value: best.text, confidence: Math.min(best.score, 1) }; } // ── Contact (email, phone, urls, location) ────────────────────────────────── @@ -289,6 +338,21 @@ export interface ContactExtractionResult { * confidence, matching the text-hit confidence, because the URL is * structurally guaranteed by the PDF. */ +/** LinkedIn paths that are NOT a personal profile — feed, company pages, job + * posts, articles, etc. Everything else under `linkedin.com/` (the + * `/in/` canonical form AND bare-vanity hosts) is treated as a + * profile, mirroring GitHub's "any `github.com/`" rule. */ +const LINKEDIN_NONPROFILE_RE = + /linkedin\.com\/(company|jobs|feed|school|learning|pulse|posts|groups|showcase|games|events|help|legal|search|signup|login|home)\b/i; + +/** True when `u` is a LinkedIn personal-profile URL. Accepts the canonical + * `linkedin.com/in/` and `linkedin.com/pub/...` as well as a vanity + * `linkedin.com/` that omits `/in/` — the latter is what makes a + * hyperlinked "LinkedIn" anchor resolve even when the target drops `/in/`. */ +function isLinkedinProfileUrl(u: string): boolean { + return /linkedin\.com\/[A-Za-z0-9]/i.test(u) && !LINKEDIN_NONPROFILE_RE.test(u); +} + export function extractContact( profile: PdfSection, allLines: PdfLine[], @@ -323,7 +387,13 @@ export function extractContact( const phoneRegion = regionFromLocation(location) ?? "US"; const phoneResult = findFirstPhone(joined, phoneRegion); const phone = phoneResult?.formatted; - const linkedin = firstMatch(LINKEDIN_RE, joined); + // LinkedIn profile URLs are usually `/in/` (LINKEDIN_RE), but some + // resumes link a bare vanity host (`linkedin.com/`). Fall back to + // any linkedin.com URL that is a profile (not /company, /jobs, … sections) + // so a hyperlinked "LinkedIn" anchor resolves regardless of the path shape. + const linkedin = + firstMatch(LINKEDIN_RE, joined) ?? + allMatches(URL_RE, joined).find(isLinkedinProfileUrl); const github = firstMatch(GITHUB_RE, joined); // Other URLs that aren't linkedin/github → portfolio/website bucket. @@ -367,21 +437,22 @@ export function extractContact( const fallback = scan(allLines, fullText); // Annotation fallback: URLs hyperlinked behind a visible word - // ("LinkedIn", "GitHub") only show up here. Y-band-filter to the profile - // section so a footer LinkedIn in a project description doesn't get - // misattributed as the candidate's profile. - const headerEndY = findFirstHeaderY(allLines); - const inProfileBand = (ann: PdfLinkAnnotation) => - ann.page === 1 && - (headerEndY === undefined || - headerEndY.page !== 1 || - ann.yTop < headerEndY.y); - // Portfolio/website use a slightly looser band — some design templates + // ("LinkedIn", "GitHub") only show up here. LinkedIn/GitHub are matched + // document-wide (see `anywhereOnDoc` below); only the looser-but-still-bounded + // portfolio/website lookup keeps a Y-band. + // Portfolio/website use a header-region band — some design templates // place the portfolio link in a sidebar or under the name block. "Top // third of page 1" approximated with a fixed PDF-points cutoff that // works for both Letter (792pt) and A4 (842pt). const inHeaderRegion = (ann: PdfLinkAnnotation) => ann.page === 1 && ann.yTop < 280; + // LinkedIn / GitHub identity links are commonly hyperlinked behind an icon + // placed in a footer or a "Links"/"Contact" block below a later heading + // (e.g. after Skills), so the profile-band filter dropped them. The + // `linkedin.com/in/` / `github.com/` predicates are specific + // enough that a document-wide match is safe — a stray profile link in a + // project description is rare and low-stakes vs. silently losing the link. + const anywhereOnDoc = () => true; const findAnnotationUrl = ( predicate: (url: string) => boolean, @@ -394,7 +465,7 @@ export function extractContact( return undefined; }; - const isLinkedinUrl = (u: string) => /linkedin\.com\/(in|pub)\//i.test(u); + const isLinkedinUrl = isLinkedinProfileUrl; const isGithubUrl = (u: string) => /github\.com\//i.test(u) && !/github\.com\/(orgs|topics|search)/i.test(u); const isPortfolioUrl = (u: string) => @@ -419,8 +490,8 @@ export function extractContact( return { value: undefined, confidence: 0 }; }; - const linkedin = pickUrl("linkedin_url", isLinkedinUrl, inProfileBand); - const github = pickUrl("github_url", isGithubUrl, inProfileBand); + const linkedin = pickUrl("linkedin_url", isLinkedinUrl, anywhereOnDoc); + const github = pickUrl("github_url", isGithubUrl, anywhereOnDoc); const portfolio = pickUrl( "portfolio_url", (u) => isPortfolioUrl(u) && !isLinkedinUrl(u) && !isGithubUrl(u), @@ -463,22 +534,6 @@ export function extractContact( }; } -/** - * Find the first line in `allLines` that is a canonical section header. - * Used as the y-band cutoff for annotation-based contact fallback — - * annotations above this line are in the profile region. - */ -function findFirstHeaderY( - allLines: PdfLine[], -): { page: number; y: number } | undefined { - for (const line of allLines) { - if (matchSectionHeader(line.text)) { - return { page: line.page, y: line.y }; - } - } - return undefined; -} - function normalizeUrl(raw: string | undefined): string | undefined { if (!raw) return undefined; const trimmed = raw.replace(/[,;.)]$/, "").trim(); @@ -525,9 +580,36 @@ const SKILL_SPLIT_RE = /[,;·•|/]+|\s{2,}/; * Note: trailing punctuation is stripped by the caller before this check, so * "AWS." → "AWS" passes without special-casing single-word tokens. */ +/** A bare profile-link heading word — these show up as standalone "headings + * with hyperlinks" after Skills and get swept into the skills pool. Exact-match + * only, so a real multi-word skill like "GitHub Actions" or "Portfolio + * Management" is never caught. */ +const PROFILE_LABEL_RE = + /^(linkedin|github|gitlab|portfolio|website|behance|dribbble)$/i; +/** A known social/profile host, with or without a path ("github.com", + * "linkedin.com/in/x"). */ +const PROFILE_HOST_RE = + /\b(linkedin|github|gitlab|behance|dribbble|medium|twitter|facebook|instagram|stackoverflow|kaggle|gitlab)\.[a-z]{2,}/i; +/** A generic URL: an explicit scheme, a `www.` prefix, or a domain followed by + * a path slash. The path-slash requirement is deliberate — it distinguishes a + * link ("mysite.com/portfolio") from a dotted real skill ("Node.js", + * "Socket.io", "ASP.NET") that has no slash. */ +const URLISH_RE = /(https?:\/\/|www\.|\b[a-z0-9-]+\.[a-z]{2,}\/\S)/i; + +/** True when a candidate skill token is really a professional-profile link + * (GitHub / LinkedIn / portfolio, etc.) or its bare heading word. Such links + * belong only in the contact/profile section, never in Skills. */ +function looksLikeContactLink(tok: string): boolean { + const t = tok.trim(); + return PROFILE_LABEL_RE.test(t) || PROFILE_HOST_RE.test(t) || URLISH_RE.test(t); +} + function isSkillToken(tok: string): boolean { if (tok.length < 2 || tok.length > 40) return false; if (/^\d+$/.test(tok)) return false; + // A professional-profile link (or its bare "GitHub" / "LinkedIn" heading) is + // contact info, not a skill — drop it wherever in the doc it surfaced. + if (looksLikeContactLink(tok)) return false; // Reject date-range runs: "1985 - 1989", "04/2021 - Present" etc. if (/\d{4}\s*[-–]\s*(\d{4}|present)/i.test(tok)) return false; // Reject tokens that span more than 4 words — real skills are terse. @@ -535,6 +617,42 @@ function isSkillToken(tok: string): boolean { return true; } +/** + * Word/LaTeX résumés often lay skills out in a borderless multi-column table. + * pdfjs renders each inter-column gap as a wide blank "spacer" item rather than + * a large x-gap between glyph runs, so `groupIntoLines` (which splits only on + * gaps between item *edges* — see COLUMN_GAP_THRESHOLD) keeps the whole row as + * one PdfLine, e.g. "Project management Data analysis Communication". Splitting + * the line at those spacer items recovers one cell per column without resorting + * to a blind `\s+` split that would shred multi-word skills. + * + * A spacer must be meaningfully wider than an ordinary inter-word space (one em, + * 10pt floor) so normal prose spacing never triggers a split. Returns one string + * per column cell, or `[line.text]` when the line has no column spacers — + * byte-identical to the pre-column behavior for ordinary single-column lines. + */ +function splitColumnCells(line: PdfLine): string[] { + const cells: PdfTextItem[][] = []; + let cur: PdfTextItem[] = []; + for (const item of line.items) { + const isSpacer = + item.str.trim() === "" && item.width > Math.max(item.fontSize, 10); + if (isSpacer) { + if (cur.length > 0) cells.push(cur); + cur = []; + continue; + } + cur.push(item); + } + if (cur.length > 0) cells.push(cur); + if (cells.length <= 1) return [line.text]; + // Collapse any whitespace a narrow (non-splitting) spacer item left inside a + // cell — e.g. a " \n" run between "REST" and "API" → "REST API". + return cells + .map((c) => mergeItemText(c).replace(/\s+/g, " ").trim()) + .filter((t) => t.length > 0); +} + export function extractSkills( skills: PdfSection | undefined, ): { value: string[]; confidence: number } { @@ -542,14 +660,20 @@ export function extractSkills( const tokens = new Set(); for (const line of skills.lines) { - const clean = stripBullet(line.text).replace(/^[A-Z][A-Za-z ]+:\s*/, ""); - for (const raw of clean.split(SKILL_SPLIT_RE)) { - // Strip trailing sentence punctuation that can appear at line-end (e.g. - // "Python, JavaScript, Git, SQL, Linux, AWS." → the period is a list - // terminator, not part of the skill name). - const tok = raw.trim().replace(/[.!?,;]+$/, ""); - if (isSkillToken(tok)) { - tokens.add(tok); + for (const cell of splitColumnCells(line)) { + const clean = stripBullet(cell).replace(/^[A-Z][A-Za-z ]+:\s*/, ""); + // A whole cell that is a profile link ("github.com/janesmith") must be + // dropped before SKILL_SPLIT_RE — which splits on "/" (for "HTML/CSS") — + // shreds the URL and leaves its path segment ("janesmith") as a token. + if (looksLikeContactLink(clean)) continue; + for (const raw of clean.split(SKILL_SPLIT_RE)) { + // Strip trailing sentence punctuation that can appear at line-end (e.g. + // "Python, JavaScript, Git, SQL, Linux, AWS." → the period is a list + // terminator, not part of the skill name). + const tok = raw.trim().replace(/[.!?,;]+$/, ""); + if (isSkillToken(tok)) { + tokens.add(tok); + } } } } @@ -912,7 +1036,7 @@ function educationDateFields( * as the title. */ const TITLE_KEYWORDS_RE = - /\b(Engineer|Engineering|Developer|Manager|Director|Lead|Consultant|Analyst|Specialist|Associate|Architect|Principal|Officer|Designer|Scientist|Researcher|Administrator|Founder|Co-?founder|President|VP|Vice President|Head|Chief|CTO|CEO|COO|CFO|CIO|PM|TPM|SRE|DevOps)\b/i; + /\b(Engineer|Engineering|Developer|Manager|Director|Lead|Consultant|Analyst|Specialist|Associate|Architect|Principal|Officer|Designer|Scientist|Researcher|Administrator|Founder|Co-?founder|President|VP|Vice President|Head|Chief|CTO|CEO|COO|CFO|CIO|PM|TPM|SRE|DevOps|Assistant|Intern|Internship|Trainee|Apprentice|Coordinator|Technician|Representative|Supervisor|Strategist|Advisor|Adviser|Counselor|Recruiter|Accountant|Auditor|Editor|Writer|Producer|Teacher|Instructor|Lecturer|Professor|Tutor|Agent|Clerk|Ambassador|Volunteer|Fellow)\b/i; /** Heuristic: text contains title-like keywords but no company suffix. */ function looksLikeTitle(text: string): boolean { @@ -920,10 +1044,25 @@ function looksLikeTitle(text: string): boolean { return TITLE_KEYWORDS_RE.test(text); } +/** Employer signal: a legal suffix ("Inc", "LLC") OR an institution word + * ("University", "College") — and NOT itself a job-title line, so a designation + * like "University Lecturer" or "School Counselor" stays a title rather than + * being mistaken for the company. */ +function looksLikeCompany(text: string): boolean { + return ( + (COMPANY_SUFFIX_RE.test(text) || INSTITUTION_HINTS.test(text)) && + !looksLikeTitle(text) + ); +} + /** * Given 1..3 header lines, decide which is the company and which is the title. * Heuristics (in priority order): - * - If one contains COMPANY_SUFFIX_RE, that's the company. + * - If one looks like a company/institution (legal suffix OR "University", + * "College", … — see `looksLikeCompany`) and is not itself a title, that's + * the company; the rest is the title. This fires on the common stacked + * "Designation / University / Dates" student-resume shape, which the old + * suffix-only check missed (it has no "Inc"/"LLC"). * - Else if one looks like a title (role/level keyword) and the other * doesn't, the title-keyword one is the title. * - Otherwise the first line (top of the entry) is the company. @@ -948,7 +1087,7 @@ export function disambiguateCompanyTitle(headers: string[]): { } }); - const companyIdx = splits.findIndex((s) => COMPANY_SUFFIX_RE.test(s.text)); + const companyIdx = splits.findIndex((s) => looksLikeCompany(s.text)); let company: string | undefined; let title: string | undefined; let team: string | undefined; @@ -985,76 +1124,111 @@ export function disambiguateCompanyTitle(headers: string[]): { // ── Education ─────────────────────────────────────────────────────────────── +/** A line that is essentially just a date / date-range (a bare year or + * month-year), so it must not be mistaken for the institution inside an + * education chunk. */ +function isDateOnlyLine(text: string): boolean { + const stripped = text + .replace(/\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\.?/gi, "") + .replace(/\b\d{4}\b/g, "") + .replace(/\b(?:present|current|expected|graduation|graduated|anticipated)\b/gi, "") + .replace(/[\s,–\-—|/().:]+/g, "") + .trim(); + return stripped.length === 0; +} + +/** Map one education chunk (the degree + institution + date lines of a single + * qualification) to a `ResumeEducation` and its confidence. */ +function educationFromChunk(chunk: string[]): { + entry: ResumeEducation; + score: number; +} { + const joined = chunk.join(" | "); + const degreeMatch = DEGREE_RE.exec(joined); + const degree = degreeMatch ? degreeMatch[0].trim() : ""; + + // Institution: an explicit institution-hint line first; else the first line + // that is neither the degree-bearing line nor a bare date — this recovers + // acronym schools ("MIT", "UC Berkeley") that carry no "University"/"College" + // word; else strip the degree off its own line (degree + school on one line). + let institution = ""; + const instLine = chunk.find((l) => INSTITUTION_HINTS.test(l)); + if (instLine) { + institution = instLine.trim(); + } else { + const cand = chunk.find((l) => !DEGREE_RE.test(l) && !isDateOnlyLine(l)); + if (cand) { + institution = cand.trim(); + } else if (degreeMatch) { + institution = joined + .replace(degreeMatch[0], "") + .replace(/\s*\|\s*/g, " ") + .replace(/[,|]+$/, "") + .trim(); + } + } + + // Shared date primitive (via the education wrapper) so a range like + // "Sep 2024 - July 2025" keeps both halves and a lone graduation date lands in + // `end_date` (#97). + const dates = parseEducationDates(joined); + const hasDate = !!(dates.start_date || dates.end_date); + + let score = 0; + if (institution) score += 0.3; + if (degree) score += 0.4; + if (hasDate) score += 0.3; + + return { + entry: { institution, degree, ...educationDateFields(dates) }, + score: Math.min(score, 1), + }; +} + export function extractEducation( education: PdfSection | undefined, ): { value: ResumeEducation[]; confidence: number } { if (!education || education.lines.length === 0) return { value: [], confidence: 0 }; - const lines = education.lines.filter((l) => !isBulletLine(l)); + const lines = education.lines + .filter((l) => !isBulletLine(l)) + .map((l) => l.text) + .filter((t) => t.trim().length > 0); if (lines.length === 0) return { value: [], confidence: 0 }; - // Anchor on institution-hint lines; walk forward to collect degree + year. - const entries: ResumeEducation[] = []; - const perEntryScores: number[] = []; - - let i = 0; - while (i < lines.length) { - const line = lines[i]; - if (!INSTITUTION_HINTS.test(line.text)) { - i++; - continue; - } - - const chunk: string[] = [line.text]; - let j = i + 1; - while (j < lines.length && j < i + 3 && !INSTITUTION_HINTS.test(lines[j].text)) { - chunk.push(lines[j].text); - j++; - } - - const joined = chunk.join(" | "); - const institution = line.text.trim(); - const degreeMatch = DEGREE_RE.exec(joined); - const degree = degreeMatch ? degreeMatch[0].trim() : ""; - // Use the shared date primitive (via the education-specific wrapper) so a - // range like "Sep 2024 - July 2025" keeps both halves and a lone graduation - // date lands in `end_date` — the old `YEAR_RE.exec(joined)[0]` took only the - // first year and dropped the end (#97). - const dates = parseEducationDates(joined); - const hasDate = !!(dates.start_date || dates.end_date); - - let score = 0; - if (institution) score += 0.3; - if (degree) score += 0.4; - if (hasDate) score += 0.3; - - entries.push({ - institution, - degree, - ...educationDateFields(dates), - }); - perEntryScores.push(Math.min(score, 1)); - i = j; - } - - if (entries.length === 0) { - // Fallback: scan for degrees in any line. - for (const line of lines) { - const degreeMatch = DEGREE_RE.exec(line.text); - if (!degreeMatch) continue; - const dates = parseEducationDates(line.text); - entries.push({ - degree: degreeMatch[0].trim(), - institution: line.text.replace(degreeMatch[0], "").trim().replace(/[,|]+$/, ""), - ...educationDateFields(dates), - }); - perEntryScores.push(0.5); - } + // Group into one chunk per qualification. A new chunk begins when the current + // one already holds a degree and the next line introduces another degree, or + // already holds an institution and the next line introduces another. This + // keeps multi-degree sections from collapsing into a single entry (only the + // first degree was ever extracted before) and works for both + // "Degree / School / Dates" and "School / Degree / Dates" orderings. + const chunks: string[][] = []; + let current: string[] = []; + let hasDegree = false; + let hasInstitution = false; + const flush = () => { + if (current.length > 0) chunks.push(current); + current = []; + hasDegree = false; + hasInstitution = false; + }; + for (const text of lines) { + const isDeg = DEGREE_RE.test(text); + const isInst = INSTITUTION_HINTS.test(text); + if ((isDeg && hasDegree) || (isInst && hasInstitution)) flush(); + current.push(text); + if (isDeg) hasDegree = true; + if (isInst) hasInstitution = true; } + flush(); - const avg = - perEntryScores.reduce((a, b) => a + b, 0) / - Math.max(perEntryScores.length, 1); - return { value: entries, confidence: entries.length ? avg : 0 }; + const built = chunks + .map(educationFromChunk) + .filter((b) => b.entry.degree || b.entry.institution); + if (built.length === 0) return { value: [], confidence: 0 }; + return { + value: built.map((b) => b.entry), + confidence: avgScore(built.map((b) => b.score)), + }; } diff --git a/src/lib/heuristics/openresume-markdown.test.ts b/src/lib/heuristics/openresume-markdown.test.ts index 72aeac13..e554a360 100644 --- a/src/lib/heuristics/openresume-markdown.test.ts +++ b/src/lib/heuristics/openresume-markdown.test.ts @@ -310,3 +310,70 @@ describe("parseHeuristicFromMarkdown — minimal / missing sections", () => { expect(result.parsed.experience).toEqual([]); }); }); + +describe("parseHeuristicFromMarkdown — promoted-link de-duplication", () => { + it("promotes a bottom LinkedIn/GitHub link to contact and removes it from the body", () => { + // The bare links trailing the Projects section are promoted into the + // contact card (document-wide identity-link detection). They must not also + // survive as a phantom project entry whose only content is the URL. + const markdown = [ + "# Jane Smith", + "jane.smith@example.com", + "## Projects", + "**Cool App** · 2024", + "- Built a thing that scaled to 1M users with 99.9% uptime reliability", + "github.com/janesmith", + "linkedin.com/in/janesmith", + ].join("\n"); + const result = parseHeuristicFromMarkdown(markdown, markdown); + + // Promoted into contact … + expect(result.parsed.github_url).toBe("https://github.com/janesmith"); + expect(result.parsed.linkedin_url).toBe("https://linkedin.com/in/janesmith"); + // … and NOT duplicated in the body: only the real project survives. + expect(result.parsed.projects?.map((p) => p.name)).toEqual(["Cool App"]); + expect( + result.parsed.projects?.some( + (p) => /github\.com|linkedin\.com/.test(p.url ?? "") || + /github\.com|linkedin\.com/.test(p.description ?? ""), + ), + ).toBe(false); + }); + + it("preserves a deeper repo path mentioned in a real bullet", () => { + // Contact github is github.com/janesmith; a bullet referencing a deeper + // path under it (a specific repo) is a real mention, not the identity link. + const markdown = [ + "# Jane Smith", + "jane.smith@example.com | github.com/janesmith", + "## Projects", + "**Cool App** · 2024", + "- Shipped github.com/janesmith/cool-app reaching 1M users with strong reliability", + ].join("\n"); + const result = parseHeuristicFromMarkdown(markdown, markdown); + + expect(result.parsed.github_url).toBe("https://github.com/janesmith"); + expect(result.parsed.projects?.[0]?.description).toContain( + "github.com/janesmith/cool-app", + ); + }); + + it("preserves a different, longer handle that shares the promoted slug's prefix", () => { + // Contact github is github.com/jane; a bullet citing github.com/jane-doe is a + // DIFFERENT handle. The strip lookahead must reject the trailing "-" so the + // longer handle is not chopped to "-doe". + const markdown = [ + "# Jane Smith", + "jane.smith@example.com | github.com/jane", + "## Projects", + "**Cool App** · 2024", + "- Contributed to github.com/jane-doe/awesome serving many active users daily", + ].join("\n"); + const result = parseHeuristicFromMarkdown(markdown, markdown); + + expect(result.parsed.github_url).toBe("https://github.com/jane"); + expect(result.parsed.projects?.[0]?.description).toContain( + "github.com/jane-doe/awesome", + ); + }); +}); diff --git a/src/lib/heuristics/openresume.ts b/src/lib/heuristics/openresume.ts index 5d9e0c19..94a23a57 100644 --- a/src/lib/heuristics/openresume.ts +++ b/src/lib/heuristics/openresume.ts @@ -32,6 +32,7 @@ import { type PdfSection, } from "./sections.ts"; import { sectionizeMarkdown } from "./markdown-lines.ts"; +import { escapeRegex } from "../jd-match/regex-utils.ts"; import { extractName, extractContact, @@ -94,6 +95,74 @@ export function parseHeuristicFromMarkdown( return buildHeuristicResult(lines, sections, "markdown"); } +// ── Promoted-identity-link de-duplication ─────────────────────────────────── +// LinkedIn/GitHub are detected document-wide (see `extractContact`), so an +// identity link sitting in a "Links"/footer block at the bottom of the résumé +// is promoted into the contact card. The same line also survives in whatever +// section it fell into — most often as a phantom project/achievement entry +// whose only content is the bare URL. We strip the promoted URLs out of the +// rendered section bodies and drop any entry left empty, so the reconstructed +// résumé never shows the same link twice (once in contact, once in the body it +// was lifted from). + +/** Host+path of a URL, lowercased, with scheme / `www.` / trailing punctuation + * removed — the comparable identity of a link across "https://github.com/x", + * "github.com/x" and "github.com/x/". */ +function urlSlug(u: string | undefined): string | undefined { + if (!u) return undefined; + const s = u + .trim() + .replace(/^https?:\/\//i, "") + .replace(/^www\./i, "") + .replace(/[/.,;:)\]]+$/, "") + .toLowerCase(); + return s.length > 0 ? s : undefined; +} + +/** A line left as nothing but an introducing label ("LinkedIn:", "GitHub —", + * "Find me online") once its URL has been stripped. */ +const PROMOTED_LABEL_RE = + /^[•\-–—*\s]*(?:linkedin|github|profile|portfolio|links?|find me(?: online)?|connect|social(?: media)?|online|website)\b[\s:|/–—-]*$/i; + +/** Remove every occurrence of `slugs` (and any orphaned label they leave + * behind) from a newline-joined bullet body. A slug matches the exact identity + * link, NOT a deeper path or longer handle under it — a real bullet mentioning + * "github.com/x/some-repo" or a different handle "github.com/x-team" is + * preserved (the lookahead rejects a following `\w`, `/`, `.` or `-`). Returns + * undefined when nothing survives. */ +function stripPromotedUrls( + text: string | undefined, + slugs: string[], +): string | undefined { + if (!text || slugs.length === 0) return text; + // Compile one matcher per slug, hoisted out of the per-line loop. The `g` + // flag strips every occurrence on a line (String.replace resets lastIndex + // between calls, so reusing the object across lines is safe). + const matchers = slugs.map( + (slug) => + new RegExp( + `(?:https?:\\/\\/)?(?:www\\.)?${escapeRegex(slug)}\\/?(?![\\w./-])`, + "ig", + ), + ); + const kept = text + .split("\n") + .map((line) => { + let l = line; + for (const re of matchers) l = l.replace(re, " "); + return l.replace(/\s{2,}/g, " ").trim(); + }) + .filter((l) => l.length > 0 && !PROMOTED_LABEL_RE.test(l)); + return kept.length > 0 ? kept.join("\n") : undefined; +} + +/** True when `url` IS one of the promoted identity links (exact, not a deeper + * path), so a phantom entry's header url can be cleared. */ +function isPromotedUrl(url: string | undefined, slugs: string[]): boolean { + const s = urlSlug(url); + return s !== undefined && slugs.includes(s); +} + function buildHeuristicResult( lines: PdfLine[], sections: PdfSection[], @@ -121,6 +190,65 @@ function buildHeuristicResult( const projects = extractProjects(projectsSection); const achievements = extractAchievements(achievementsSection); + // Strip promoted LinkedIn/GitHub links out of the rendered body so they don't + // render twice — once in the contact card, once in the section they were + // lifted from (see helpers above). + const promotedSlugs = [ + urlSlug(contact.linkedin_url), + urlSlug(contact.github_url), + ].filter((s): s is string => s !== undefined); + + const experienceValue = + promotedSlugs.length === 0 + ? experience.value + : experience.value + .map((e) => ({ + ...e, + description: stripPromotedUrls(e.description, promotedSlugs), + })) + .filter((e) => e.title.trim() || e.company.trim() || e.description); + + const educationValue = + promotedSlugs.length === 0 + ? education.value + : education.value + .map((e) => ({ + ...e, + description: stripPromotedUrls(e.description, promotedSlugs), + })) + .filter((e) => e.institution.trim() || e.degree.trim() || e.description); + + const projectsValue = + promotedSlugs.length === 0 + ? projects.value + : projects.value + .map((p) => ({ + ...p, + description: stripPromotedUrls(p.description, promotedSlugs), + url: isPromotedUrl(p.url, promotedSlugs) ? undefined : p.url, + })) + .filter((p) => p.name.trim() || p.description || p.url); + + const achievementsValue = + promotedSlugs.length === 0 + ? achievements.value + : achievements.value + .map((a) => ({ + ...a, + description: stripPromotedUrls(a.description, promotedSlugs), + url: isPromotedUrl(a.url, promotedSlugs) ? undefined : a.url, + })) + .filter((a) => a.title.trim() || a.description || a.url); + + const skillsValue = + promotedSlugs.length === 0 + ? skills.value + : skills.value.filter( + (s) => !promotedSlugs.some((slug) => s.toLowerCase().includes(slug)), + ); + + const summaryValue = stripPromotedUrls(summary.value, promotedSlugs); + const parsed: HeuristicParsedResume = { ...(name.value ? { full_name: name.value } : {}), ...splitGivenFamilyName(name.value), @@ -131,19 +259,19 @@ function buildHeuristicResult( ...(contact.github_url ? { github_url: contact.github_url } : {}), ...(contact.portfolio_url ? { portfolio_url: contact.portfolio_url } : {}), ...(contact.website_url ? { website_url: contact.website_url } : {}), - ...(summary.value ? { summary: summary.value } : {}), - skills: skills.value, + ...(summaryValue ? { summary: summaryValue } : {}), + skills: skillsValue, skills_explicit: [], skills_inferred: [], - experience: experience.value, - education: education.value, - ...(projects.value.length > 0 ? { projects: projects.value } : {}), - ...(achievements.value.length > 0 - ? { heuristic_achievements: achievements.value } + experience: experienceValue, + education: educationValue, + ...(projectsValue.length > 0 ? { projects: projectsValue } : {}), + ...(achievementsValue.length > 0 + ? { heuristic_achievements: achievementsValue } : {}), // Best-effort current role derivation. - ...(experience.value[0]?.title ? { current_title: experience.value[0].title } : {}), - ...(experience.value[0]?.company ? { current_company: experience.value[0].company } : {}), + ...(experienceValue[0]?.title ? { current_title: experienceValue[0].title } : {}), + ...(experienceValue[0]?.company ? { current_company: experienceValue[0].company } : {}), }; const fieldConfidence: FieldConfidence = { @@ -163,7 +291,18 @@ function buildHeuristicResult( achievements: achievements.confidence, }; - return { parsed, fieldConfidence, sectionSource }; + const skillsSectionLines = skillsSection?.lines + .map((l) => l.text.trim()) + .filter((t) => t.length > 0); + + return { + parsed, + fieldConfidence, + sectionSource, + ...(skillsSectionLines && skillsSectionLines.length > 0 + ? { skillsSectionLines } + : {}), + }; } function splitGivenFamilyName( diff --git a/src/lib/heuristics/phone.test.ts b/src/lib/heuristics/phone.test.ts index 1357d328..705bf7d1 100644 --- a/src/lib/heuristics/phone.test.ts +++ b/src/lib/heuristics/phone.test.ts @@ -77,6 +77,24 @@ describe("findFirstPhone — extraction from text", () => { expect(result!.formatted).toBe("(408) 372-6626"); }); + it("finds a US number whose digit groups are split by a Unicode en-dash (#29)", () => { + // Word/LaTeX templates render the separator as U+2013, e.g. "(718) 555–0100". + // The ASCII-only PHONE_RE pre-filter used to gate this out before the + // libphonenumber call ever ran; mightHavePhone now folds Unicode dashes. + const result = findFirstPhone("Phone: (718) 555–0100"); + expect(result).toBeDefined(); + expect(result!.formatted).toBe("(718) 555-0100"); + }); + + it("finds a US number split by an em-dash and a figure-dash (#29)", () => { + expect(findFirstPhone("(718) 555—0100")?.formatted).toBe( + "(718) 555-0100", + ); + expect(findFirstPhone("(718) 555‒0100")?.formatted).toBe( + "(718) 555-0100", + ); + }); + it("finds a UK number with country code in text", () => { // +44 20 7946 0958 is a UK Ofcom-reserved documentation number. const result = findFirstPhone("London office: +44 20 7946 0958"); diff --git a/src/lib/heuristics/phone.ts b/src/lib/heuristics/phone.ts index 18d76809..95bf90b3 100644 --- a/src/lib/heuristics/phone.ts +++ b/src/lib/heuristics/phone.ts @@ -185,8 +185,14 @@ export function normalizePhone( * India `098765 43210`) do not match PHONE_RE and carry no `+` prefix. */ function mightHavePhone(text: string, region: CountryCode): boolean { + // Some templates (Word/LaTeX) use a Unicode dash as the digit-group + // separator, e.g. "(718) 555–0100" with an en-dash (U+2013). PHONE_RE's + // separator class is ASCII-only, so fold en/em/figure dashes to "-" for + // this cheap pre-gate only. `findPhoneNumbersInText` parses the Unicode + // forms natively, so the matcher itself needs no change. + const ascii = text.replace(/[‒–—]/g, "-"); PHONE_RE.lastIndex = 0; - const byUs = PHONE_RE.test(text); + const byUs = PHONE_RE.test(ascii); PHONE_RE.lastIndex = 0; if (byUs) return true; if (/\+\d/.test(text)) return true; diff --git a/src/lib/heuristics/regex.ts b/src/lib/heuristics/regex.ts index 1d108cac..c76a5277 100644 --- a/src/lib/heuristics/regex.ts +++ b/src/lib/heuristics/regex.ts @@ -276,7 +276,7 @@ export const DEGREE_RE = /\b(B\.?A\.?|B\.?S\.?|B\.?Sc\.?|B\.?E\.?|B\.?Eng\.?|B\.?Tech\.?|M\.?A\.?|M\.?S\.?|M\.?Sc\.?|M\.?Eng\.?|M\.?B\.?A\.?|Ph\.?D\.?|M\.?D\.?|J\.?D\.?|Bachelor|Master|Doctor|Associate)(?:\s+of\s+[A-Za-z ]{2,40})?/; export const INSTITUTION_HINTS = - /\b(University|College|Institute|School|Academy|Polytechnic)\b/i; + /\b(University|College|Institute|School|Academy|Polytechnic)s?\b/i; // ── Company suffix hints ──────────────────────────────────────────────────── diff --git a/src/lib/heuristics/sections.ts b/src/lib/heuristics/sections.ts index 11aa4e1e..d0d82fff 100644 --- a/src/lib/heuristics/sections.ts +++ b/src/lib/heuristics/sections.ts @@ -199,7 +199,7 @@ function groupLinesSingle(items: PdfTextItem[]): PdfLine[] { * glyph run as a separate item, so naively joining with spaces over-pads * and joining without spaces under-pads. */ -function mergeItemText(items: PdfTextItem[]): string { +export function mergeItemText(items: PdfTextItem[]): string { if (items.length === 0) return ""; let out = items[0].str; for (let i = 1; i < items.length; i++) { diff --git a/src/lib/heuristics/types.ts b/src/lib/heuristics/types.ts index 76fbcd1f..c9387d96 100644 --- a/src/lib/heuristics/types.ts +++ b/src/lib/heuristics/types.ts @@ -153,6 +153,10 @@ export interface HeuristicResult { * font-size-promoted heading passed the emitter's promotion gate) from * regex-on-line parses. Optional; missing is treated as "regex". */ sectionSource?: "markdown" | "regex"; + /** Raw text lines of the detected skills section, if any. The scorer keeps + * these out of the experience-bullet pool so bulleted skills are not judged + * by the action-verb / metric / length rules (#30). */ + skillsSectionLines?: string[]; } // ── Cascade output ────────────────────────────────────────────────────────── @@ -180,6 +184,9 @@ export interface CascadeResult { * scanned PDFs or when the emitter could not produce useful structure. * Section splitters prefer this over `rawText` when present. */ markdown?: string; + /** Newline-joined text of the detected skills section, if any. Passed to the + * scorer so bulleted skills stay out of the experience-bullet pool (#30). */ + skillsSectionText?: string; /** Link annotations Tier 0 lifted off the PDF. Surfaces URLs hyperlinked * behind visible words; also the only credible recovered signal on * `fonts_unmappable` PDFs where the text path came back empty. Empty diff --git a/src/lib/ingest/docx.test.ts b/src/lib/ingest/docx.test.ts index 7306ccff..2497daa7 100644 --- a/src/lib/ingest/docx.test.ts +++ b/src/lib/ingest/docx.test.ts @@ -30,8 +30,23 @@ vi.mock("mammoth", () => ({ })); // --- Import after mock registration ----------------------------------------- -// Dynamic import path matches what docx.ts uses at runtime. -import { parseDocx } from "./docx.ts"; +// Dynamic import path matches what docx.ts uses at runtime. jszip is NOT mocked +// — the header/footer extraction reads a real in-memory zip. +import { parseDocx, parseHeaderFooterHyperlinks } from "./docx.ts"; +import JSZip from "jszip"; + +const W_NS = + 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ' + + 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"'; +const hyperlinkHeaderXml = (id: string, text: string) => + `${text}`; +const relsXml = (entries: Array<{ id: string; target: string }>) => + `${entries + .map( + (e) => + ``, + ) + .join("")}`; describe("parseDocx", () => { beforeEach(() => { @@ -59,4 +74,73 @@ describe("parseDocx", () => { expect(typeof result.rawText).toBe("string"); expect(typeof result.markdown).toBe("string"); }); + + it("recovers LinkedIn/GitHub hyperlinks from the DOCX header into the markdown", async () => { + // Build a minimal DOCX zip carrying a header part + its rels. mammoth (which + // ignores headers) is mocked; jszip reads this real zip. + const zip = new JSZip(); + zip.file("word/document.xml", ``); + zip.file( + "word/header1.xml", + `` + + `LinkedIn` + + `GitHub` + + ``, + ); + zip.file( + "word/_rels/header1.xml.rels", + relsXml([ + { id: "rId1", target: "https://linkedin.com/in/johndoe" }, + { id: "rId2", target: "https://github.com/johndoe" }, + ]), + ); + const bytes = (await zip.generateAsync({ type: "arraybuffer" })) as ArrayBuffer; + + const result = await parseDocx(bytes); + expect(result.markdown).toContain("[LinkedIn](https://linkedin.com/in/johndoe)"); + expect(result.markdown).toContain("[GitHub](https://github.com/johndoe)"); + expect(result.rawText).toContain("https://linkedin.com/in/johndoe"); + // The mocked body content still flows through. + expect(result.markdown).toContain("**Jane Doe**"); + }); +}); + +describe("parseHeaderFooterHyperlinks", () => { + it("resolves r:id hyperlinks against the rels file", () => { + const links = parseHeaderFooterHyperlinks( + hyperlinkHeaderXml("rId7", "LinkedIn"), + relsXml([{ id: "rId7", target: "https://linkedin.com/in/johndoe" }]), + ); + expect(links).toEqual([ + { text: "LinkedIn", url: "https://linkedin.com/in/johndoe" }, + ]); + }); + + it("decodes XML entities in the target URL", () => { + const links = parseHeaderFooterHyperlinks( + hyperlinkHeaderXml("rId1", "Profile"), + relsXml([{ id: "rId1", target: "https://example.com/p?a=1&b=2" }]), + ); + expect(links[0].url).toBe("https://example.com/p?a=1&b=2"); + }); + + it("decodes numeric (decimal + hex) character references in the target URL", () => { + const links = parseHeaderFooterHyperlinks( + hyperlinkHeaderXml("rId1", "Profile"), + // & = '&' (decimal), = = '=' (hex) + relsXml([{ id: "rId1", target: "https://example.com/p?a=1&b=2" }]), + ); + expect(links[0].url).toBe("https://example.com/p?a=1&b=2"); + }); + + it("ignores internal anchors with no external relationship", () => { + const xml = `Top`; + expect(parseHeaderFooterHyperlinks(xml, relsXml([]))).toEqual([]); + }); + + it("catches a full URL typed as plain header text", () => { + const xml = `github.com is here https://github.com/johndoe`; + const links = parseHeaderFooterHyperlinks(xml, ""); + expect(links).toEqual([{ text: "", url: "https://github.com/johndoe" }]); + }); }); diff --git a/src/lib/ingest/docx.ts b/src/lib/ingest/docx.ts index 5922753e..dd6fe2de 100644 --- a/src/lib/ingest/docx.ts +++ b/src/lib/ingest/docx.ts @@ -33,8 +33,21 @@ interface TurndownModule { default: new () => TurndownService; } +// Minimal JSZip surface (hand-rolled like MammothLib above so we neither bundle +// jszip into the entry chunk nor depend on its type package). +interface JSZipFile { + async(type: "string"): Promise; +} +interface JSZipInstance { + files: Record; +} +interface JSZipCtor { + loadAsync(data: ArrayBuffer): Promise; +} + let mammothCached: Promise | null = null; let turndownCached: Promise TurndownService> | null = null; +let jszipCached: Promise | null = null; async function loadMammoth(): Promise { if (mammothCached) return mammothCached; @@ -55,6 +68,129 @@ async function loadTurndown(): Promise TurndownService> { return turndownCached; } +async function loadJsZip(): Promise { + if (jszipCached) return jszipCached; + jszipCached = (async () => { + const mod = await import("jszip"); + return ("default" in mod ? mod.default : mod) as unknown as JSZipCtor; + })(); + return jszipCached; +} + +const XML_ENTITIES: Record = { + "&": "&", + "<": "<", + ">": ">", + """: '"', + "'": "'", +}; +function decodeXml(s: string): string { + // Numeric character references (`&`, ` `) are valid XML and emitted + // by some Word save paths / templating tools, esp. inside URL `Target` + // attributes. Decode them before the named pass (so a double-encoded + // `&#38;` still reduces) — mirrors the htmlToPlaintext fix in + // jd-match/fetch-jd.ts (#117). + return s + .replace(/&#(\d+);/g, (_m, n: string) => String.fromCodePoint(parseInt(n, 10))) + .replace(/&#x([0-9a-f]+);/gi, (_m, h: string) => + String.fromCodePoint(parseInt(h, 16)), + ) + .replace(/&(amp|lt|gt|quot|apos);/g, (m) => XML_ENTITIES[m] ?? m); +} + +export interface HeaderFooterLink { + /** Visible anchor text of the hyperlink (e.g. "LinkedIn"). */ + text: string; + /** External target URL. */ + url: string; +} + +/** + * Parse external hyperlinks out of one DOCX header/footer part. + * + * Mammoth converts only `word/document.xml`, so a "LinkedIn | GitHub" contact + * row placed in the Word *header* (a common template layout) is dropped along + * with its hyperlinks. DOCX stores a hyperlink's TARGET in a sibling `.rels` + * file keyed by the `r:id` on ``; this resolves id → URL from + * `relsXml`, then pulls each hyperlink's visible text from its `` runs. + * Also catches a full URL typed directly as visible text (no relationship). + * + * Pure + string-only so it is unit-testable without a zip or DOCX binary. + */ +export function parseHeaderFooterHyperlinks( + xml: string, + relsXml: string, +): HeaderFooterLink[] { + // id → external target (skip internal anchors / relative targets). + const rels = new Map(); + for (const m of relsXml.matchAll(/]*>/gi)) { + const tag = m[0]; + const id = /\bId="([^"]+)"/i.exec(tag)?.[1]; + const target = /\bTarget="([^"]+)"/i.exec(tag)?.[1]; + const mode = /\bTargetMode="([^"]+)"/i.exec(tag)?.[1]; + if (id && target && (mode === "External" || /^https?:/i.test(target))) { + rels.set(id, decodeXml(target)); + } + } + + const links: HeaderFooterLink[] = []; + const seen = new Set(); + const push = (text: string, url: string) => { + const key = `${text} ${url}`; + if (seen.has(key)) return; + seen.add(key); + links.push({ text, url }); + }; + + // LinkedIn + for (const m of xml.matchAll(/]*>([\s\S]*?)<\/w:hyperlink>/gi)) { + const open = /]*>/i.exec(m[0])?.[0] ?? ""; + const id = /\br:id="([^"]+)"/i.exec(open)?.[1]; + const url = id ? rels.get(id) : undefined; + if (!url) continue; // internal anchor or unresolved → no external target + const text = [...m[1].matchAll(/]*>([\s\S]*?)<\/w:t>/gi)] + .map((t) => decodeXml(t[1])) + .join("") + .trim(); + push(text, url); + } + + // A full URL typed as plain visible text (no hyperlink relationship). + const visibleText = [...xml.matchAll(/]*>([\s\S]*?)<\/w:t>/gi)] + .map((t) => decodeXml(t[1])) + .join(" "); + for (const um of visibleText.matchAll(/https?:\/\/[^\s<>")]+/gi)) { + push("", um[0]); + } + + return links; +} + +/** Read every `word/header*.xml` / `word/footer*.xml` part out of a DOCX zip and + * return their external hyperlinks. Non-fatal: returns [] on any zip error. */ +async function extractHeaderFooterLinks( + bytes: ArrayBuffer, +): Promise { + try { + const JSZip = await loadJsZip(); + const zip = await JSZip.loadAsync(bytes); + const parts = Object.keys(zip.files).filter((n) => + /^word\/(header|footer)\d*\.xml$/i.test(n), + ); + const out: HeaderFooterLink[] = []; + for (const name of parts) { + const xml = await zip.files[name].async("string"); + const base = name.replace(/^word\//, ""); + const relsFile = zip.files[`word/_rels/${base}.rels`]; + const relsXml = relsFile ? await relsFile.async("string") : ""; + out.push(...parseHeaderFooterHyperlinks(xml, relsXml)); + } + return out; + } catch { + return []; + } +} + export interface DocxParseResult { rawText: string; markdown: string; @@ -72,14 +208,30 @@ export async function parseDocx(bytes: ArrayBuffer): Promise { loadTurndown(), ]); - const [htmlResult, textResult] = await Promise.all([ + const [htmlResult, textResult, headerFooterLinks] = await Promise.all([ mammoth.convertToHtml({ arrayBuffer: bytes }), mammoth.extractRawText({ arrayBuffer: bytes }), + // Mammoth ignores headers/footers; recover their hyperlinks separately so a + // header-placed "LinkedIn | GitHub" contact row is not lost. See + // `parseHeaderFooterHyperlinks`. + extractHeaderFooterLinks(bytes), ]); const td = new TurndownService(); - const markdown = td.turndown(htmlResult.value); - const rawText = textResult.value; + let markdown = td.turndown(htmlResult.value); + let rawText = textResult.value; + + if (headerFooterLinks.length > 0) { + // Append as markdown links so the recovered targets flow through the same + // text-based URL extraction as body links. Identity links (LinkedIn/GitHub) + // are matched document-wide downstream and de-duplicated out of the body, so + // appended position does not matter and never double-renders. + const linkMd = headerFooterLinks + .map(({ text, url }) => (text ? `[${text}](${url})` : url)) + .join("\n"); + markdown = `${markdown}\n\n${linkMd}`; + rawText = `${rawText}\n${headerFooterLinks.map((l) => l.url).join("\n")}`; + } return { rawText, markdown }; } diff --git a/src/lib/score/score.test.ts b/src/lib/score/score.test.ts index 84e34991..ec2bdb65 100644 --- a/src/lib/score/score.test.ts +++ b/src/lib/score/score.test.ts @@ -490,4 +490,117 @@ describe("computeAnonymousAtsScore", () => { expect(metricCount).toBe(result.specificity.metricBullets); }); }); + + describe("skills bullets excluded from the experience pool (#30)", () => { + // A bulleted skills section ("• Project management, Data analysis") must not + // be judged by the action-verb / metric / length rules. The cascade supplies + // the skills-section text; matching lines are kept out of the bullet pool. + const skillsRaw = [ + "• Project management, Data analysis", + "• Communication, Problem-solving", + ].join("\n"); + + it("drops skills-section lines from the pool when skillsSectionText is supplied", () => { + const result = computeAnonymousAtsScore( + makeAnonInput({ rawText: skillsRaw, skillsSectionText: skillsRaw }), + ); + expect(result.bullets ?? []).toHaveLength(0); + }); + + it("would otherwise count them — proving the exclusion is what drops them", () => { + const result = computeAnonymousAtsScore( + makeAnonInput({ rawText: skillsRaw }), + ); + expect((result.bullets ?? []).length).toBe(2); + }); + + it("does not exclude genuine experience bullets outside the skills section", () => { + const result = computeAnonymousAtsScore( + makeAnonInput({ rawText: STRONG_BULLETS, skillsSectionText: skillsRaw }), + ); + expect(result.bullets!.length).toBe(6); + }); + }); + + describe("lone-bullet glyph merge (Word-table layout, #30)", () => { + // pdfjs/pdftotext can split a table-cell bullet so the "•" lands on its own + // line and the text on the next. The extractor merges them before scoring. + it("merges a marker-only line with the following non-empty text line", () => { + const raw = [ + "•", + "", + "Led migration of 3 microservices reducing latency by 40%", + ].join("\n"); + const result = computeAnonymousAtsScore(makeAnonInput({ rawText: raw })); + expect(result.bullets!.map((b) => b.text)).toEqual([ + "Led migration of 3 microservices reducing latency by 40%", + ]); + }); + + it("still excludes a lone-bullet skills entry after the merge", () => { + const raw = ["•", "Project management, Data analysis"].join("\n"); + const result = computeAnonymousAtsScore( + makeAnonInput({ + rawText: raw, + skillsSectionText: "Project management, Data analysis", + }), + ); + expect(result.bullets ?? []).toHaveLength(0); + }); + }); + + describe("redacted role dates (#31)", () => { + // A role whose date is a redaction stub ("August 20XX") stays incomplete, + // but must score distinctly from a role with no date text at all and drive + // the "use 4-digit years" guidance. + const undatedRole = [{ title: "Office Manager", company: "Acme" }]; + function inputWith(rawText: string): AnonymousAtsScoreInput { + return makeAnonInput({ + parsed: { + full_name: "Jane Doe", + email: "jane@example.com", + phone: "555-0100", + location: "San Francisco, CA", + linkedin_url: "https://www.linkedin.com/in/janedoe", + summary: "Backend engineer with ten years of distributed systems work.", + skills: ["TypeScript", "Go", "PostgreSQL"], + experience: undatedRole, + education: [{ degree: "BS CS", institution: "MIT" }], + }, + rawText, + }); + } + + it("flags redacted dates incomplete but scores above wholly-missing dates", () => { + const redacted = computeAnonymousAtsScore( + inputWith("Office Manager, Acme\nAugust 20XX – March 20XX"), + ); + const missing = computeAnonymousAtsScore( + inputWith("Office Manager, Acme"), + ); + expect(redacted.completeness.redactedDates).toBe(true); + expect(redacted.completeness.missing).toContain("role dates"); + expect(missing.completeness.redactedDates).toBeFalsy(); + expect(redacted.completeness.score).toBeGreaterThan( + missing.completeness.score, + ); + }); + + it.each([ + "August 20XX – March 20XX", + "Jan XXXX – Dec XXXX", + "Mar #### – Jun ####", + "August 20-- – March 20--", + ])("detects the redaction token family in %s", (dateLine) => { + const result = computeAnonymousAtsScore(inputWith(`Role, Co\n${dateLine}`)); + expect(result.completeness.redactedDates).toBe(true); + }); + + it("does not flag a bare XXXX outside a date context", () => { + const result = computeAnonymousAtsScore( + inputWith("Office Manager, Acme\nBadge ID XXXX-7 issued on site"), + ); + expect(result.completeness.redactedDates).toBeFalsy(); + }); + }); }); diff --git a/src/lib/score/score.ts b/src/lib/score/score.ts index 9985f92e..1f790824 100644 --- a/src/lib/score/score.ts +++ b/src/lib/score/score.ts @@ -40,11 +40,18 @@ export const WEIGHTS = { * Changelog: * - 1.0 (2026-04-28): initial release. * - 1.1 (2026-06-17): separator-less month-year date ranges now anchor experience entries (#119). + * - 1.2 (2026-06-19): Word-template parsing + scoring fixes (#29/#30/#31) — + * stacked-name / en-dash-phone / column-skills recovery shifts completeness; + * bulleted skills leave the experience-bullet pool; redacted role dates earn + * partial completeness credit instead of zero. Also: LinkedIn/GitHub identity + * links are recovered document-wide; multi-degree education sections extract + * every entry; and wrapped-bullet tails no longer leak into the next + * experience entry's header. */ // Internal-only: surfaced to the UI via the `algoVersion` score field, not // imported by name anywhere — so it stays unexported to satisfy the dead-code // gate (fallow flags exported symbols with no external consumer). -const ATS_SCORE_ALGO_VERSION = "1.1"; +const ATS_SCORE_ALGO_VERSION = "1.2"; // ── Shared scoring rules ──────────────────────────────────────────────────── // @@ -96,6 +103,29 @@ const STRONG_METRIC_PATTERNS = [ const YEAR_TOKEN = /\b(19|20)\d{2}\b/g; const ANY_DIGIT = /\d/; +/** Month names (incl. "Sept"), for anchoring redaction tokens to a date slot. */ +const MONTH_NAME = + "(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\\.?"; + +/** + * Year-position redaction placeholders in a *date* context (#31). Résumé + * templates ship dates as redaction stubs the parser can't read as a year: + * - `20XX` / `20--` (with any dash) — unambiguous year stubs, matched bare. + * - `XXXX` / `####` — only when anchored to a month ("August XXXX") or a + * range dash ("XXXX – XXXX"), so a stray `####` elsewhere doesn't trip it. + * Detecting these lets completeness score a redacted date distinctly from a + * wholly-missing one and surface "use 4-digit years" guidance. + */ +const REDACTED_DATE_RE = new RegExp( + [ + "\\b20XX\\b", + "\\b20[-\\u2012\\u2013\\u2014]{2}", + `${MONTH_NAME}\\s+(?:XXXX|####)`, + "(?:XXXX|####)\\s*[-\\u2012\\u2013\\u2014]\\s*(?:XXXX|####|20XX|Present|Current)", + ].join("|"), + "i", +); + function bulletHasMetric(text: string): boolean { if (STRONG_METRIC_PATTERNS.some((p) => p.test(text))) return true; const stripped = text.replace(YEAR_TOKEN, ""); @@ -439,6 +469,10 @@ export interface AnonymousAtsScore { }; completeness: AnonymousAtsScoreDimension & { missing: string[]; + /** True when at least one role's date is a redaction stub (e.g. "20XX") + * rather than wholly absent (#31). Drives the "use 4-digit years" UI + * hint; the date check still counts as incomplete. */ + redactedDates?: boolean; }; layout: { triggers: readonly string[]; @@ -491,6 +525,11 @@ export interface AnonymousAtsScoreInput { triggers: readonly string[]; /** Concatenated text from Tier 0. Used for bullet-level analysis. */ rawText: string; + /** Newline-joined text of the detected skills section, if any. Lines here are + * kept out of the experience-bullet pool so skills are never judged by the + * action-verb / metric / length rules (#30). Supplied by the cascade, which + * owns section detection — the pure scorer does not re-derive sections. */ + skillsSectionText?: string; } const ANON_CONTACT_CONFIDENCE_FLOOR = 0.5; @@ -517,6 +556,42 @@ const ANON_CONTACT_FIELDS: readonly { { key: "linkedin_url", label: "LinkedIn" }, ]; +/** A line that is *only* a bullet glyph (no text after it). Word tables can + * place the glyph and its text in separate cells, so pdfjs/pdftotext emit the + * marker on its own line followed by the text on the next — see #30. Dash-style + * markers are excluded here: a lone "-"/"–" line is far more often a divider + * than a bullet whose text wandered onto the next line. */ +const LONE_BULLET_RE = /^\s*[•●▪◦‣▶►·�]\s*$/; + +/** Marker-strip a single line (bullet glyph or numbered prefix) and trim. The + * key both `extractBulletsFromText` and the skills-exclusion set match on. */ +function stripBulletMarker(line: string): string { + return line + .replace(BULLET_MARKER_RE, "") + .replace(NUMBERED_BULLET_RE, "") + .trim(); +} + +/** + * Build the set of skills-section lines (marker-stripped) to keep out of the + * experience-bullet pool. A skill like "Project management" is not an + * accomplishment bullet and must not be judged by the action-verb / metric / + * length rules or surfaced in per-bullet feedback (#30). The text is supplied + * by the cascade, which owns section detection; the pure scorer never has to + * re-derive sections from `rawText`. + */ +function buildSkillsExclusion( + skillsSectionText: string | undefined, +): ReadonlySet | undefined { + if (!skillsSectionText) return undefined; + const set = new Set(); + for (const line of skillsSectionText.split(/\r?\n/)) { + const key = stripBulletMarker(line); + if (key) set.add(key); + } + return set.size > 0 ? set : undefined; +} + /** * 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 @@ -530,11 +605,29 @@ const ANON_CONTACT_FIELDS: readonly { * resumes use markers, and grading paragraphs would either over-count * narrative summary lines or require the experience-section detection we * don't have without an LLM. + * + * `excludeStripped` (optional) is the marker-stripped skills-section line set; + * matching lines are dropped so bulleted skills never enter the pool (#30). */ -function extractBulletsFromText(text: string): string[] { +function extractBulletsFromText( + text: string, + excludeStripped?: ReadonlySet, +): string[] { if (!text) return []; + const lines = text.split(/\r?\n/); const out: string[] = []; - for (const rawLine of text.split(/\r?\n/)) { + for (let i = 0; i < lines.length; i++) { + let rawLine = lines[i]; + // Lone-bullet merge (#30): a marker-only line adopts the next non-empty + // line as its text, recovering Word-table layouts that split the glyph and + // its text into separate cells (and thus separate extracted lines). + if (LONE_BULLET_RE.test(rawLine)) { + let j = i + 1; + while (j < lines.length && lines[j].trim() === "") j++; + if (j >= lines.length) break; + rawLine = `${rawLine.trimEnd()} ${lines[j].trim()}`; + i = j; + } let stripped = rawLine.replace(BULLET_MARKER_RE, ""); if (stripped === rawLine) { stripped = rawLine.replace(NUMBERED_BULLET_RE, ""); @@ -542,6 +635,8 @@ function extractBulletsFromText(text: string): string[] { } const trimmed = stripped.trim(); if (trimmed.split(/\s+/).filter(Boolean).length < ANON_BULLET_MIN_WORDS) continue; + // Section-aware (#30): skills entries are not experience bullets. + if (excludeStripped?.has(trimmed)) continue; out.push(trimmed); } return out; @@ -553,7 +648,8 @@ export function computeAnonymousAtsScore( // ── Bullet-level dimensions (Specificity 40, Structure 30) ───────────── // Same scoreBulletPool the authed scorer uses — guarantees the two // surfaces apply identical per-bullet rules. - const bullets = extractBulletsFromText(input.rawText); + const skillsExclude = buildSkillsExclusion(input.skillsSectionText); + const bullets = extractBulletsFromText(input.rawText, skillsExclude); const pool = scoreBulletPool(bullets); const observations = analyzeBullets(bullets); const gradable = pool.total >= ANON_MIN_BULLETS_TO_GRADE; @@ -564,8 +660,15 @@ export function computeAnonymousAtsScore( // Mirrors scoreCompleteness in spirit but works on cascade-shaped data. // 5 contact fields (10 pts), summary (3), experience+dates (10), education (4), // skills (3). Contact fields are gated by confidence; the rest by presence. - const completenessChecks: { key: string; passed: boolean; label: string }[] = - []; + // `credit` defaults to 1 when passed, 0 when not — but a partially-satisfied + // check (e.g. a redacted date, #31) can earn fractional credit while still + // counting as "not passed" so it surfaces in `missing`. + const completenessChecks: { + key: string; + passed: boolean; + label: string; + credit?: number; + }[] = []; for (const f of ANON_CONTACT_FIELDS) { const value = input.parsed[f.key]; const conf = input.fieldConfidence[f.key] ?? 0; @@ -603,18 +706,27 @@ export function computeAnonymousAtsScore( // Date completeness — pass if the majority of experience entries carry a // start date. We don't know which entry is current vs past at the cascade // tier so we don't penalize missing end_date. + let redactedDates = false; if (expEntries.length > 0) { const withStart = expEntries.filter((e) => !!e.start_date).length; + const datesPass = withStart / expEntries.length >= 0.5; + // A failing date check is "redacted" rather than wholly-missing when the + // text carries a year-position redaction stub (#31). It stays incomplete + // but earns half credit and triggers "use 4-digit years" guidance. + redactedDates = !datesPass && REDACTED_DATE_RE.test(input.rawText); completenessChecks.push({ key: "dates", - passed: withStart / expEntries.length >= 0.5, + passed: datesPass, label: "role dates", + credit: datesPass ? 1 : redactedDates ? 0.5 : 0, }); } const completenessRatio = - completenessChecks.filter((c) => c.passed).length / - completenessChecks.length; + completenessChecks.reduce( + (sum, c) => sum + (c.credit ?? (c.passed ? 1 : 0)), + 0, + ) / completenessChecks.length; const completenessScore = Math.round(completenessRatio * 30); const completenessMissing = completenessChecks .filter((c) => !c.passed) @@ -656,6 +768,7 @@ export function computeAnonymousAtsScore( max: 30, gradable: true, missing: completenessMissing, + ...(redactedDates ? { redactedDates: true } : {}), }, layout: { triggers: input.triggers.slice(), diff --git a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-classic.expected.json b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-classic.expected.json index 79a56544..2962498a 100644 --- a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-classic.expected.json +++ b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-classic.expected.json @@ -64,6 +64,6 @@ "scanned": false }, "bulletCount": 8, - "algoVersion": "1.1" + "algoVersion": "1.2" } } diff --git a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-minimal.expected.json b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-minimal.expected.json index 4a5caddc..b08dae64 100644 --- a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-minimal.expected.json +++ b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-minimal.expected.json @@ -65,6 +65,6 @@ "scanned": false }, "bulletCount": 7, - "algoVersion": "1.1" + "algoVersion": "1.2" } } diff --git a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-nonstandard-headers.expected.json b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-nonstandard-headers.expected.json index b51a03ef..62ab76cc 100644 --- a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-nonstandard-headers.expected.json +++ b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-nonstandard-headers.expected.json @@ -26,7 +26,7 @@ "skillsCount": 9, "experienceCount": 3, "educationCount": 1, - "projectsCount": 2, + "projectsCount": 1, "achievementsCount": 0, "rawTextCharCount": 1530, "pageCount": 1, @@ -66,6 +66,6 @@ "scanned": false }, "bulletCount": 8, - "algoVersion": "1.1" + "algoVersion": "1.2" } } diff --git a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-two-column.expected.json b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-two-column.expected.json index 75818b06..d9988d0f 100644 --- a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-two-column.expected.json +++ b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-two-column.expected.json @@ -30,7 +30,7 @@ "skillsCount": 17, "experienceCount": 3, "educationCount": 1, - "projectsCount": 2, + "projectsCount": 1, "achievementsCount": 0, "rawTextCharCount": 1720, "pageCount": 1, @@ -39,21 +39,21 @@ "sectionSource": "regex" }, "score": { - "overall": 54, - "preLayoutOverall": 64, + "overall": 63, + "preLayoutOverall": 74, "specificity": { - "score": 26, + "score": 33, "max": 40, "gradable": true, "metricBullets": 9, - "totalBullets": 23 + "totalBullets": 18 }, "structure": { - "score": 11, + "score": 14, "max": 30, "gradable": true, "goodBullets": 9, - "totalBullets": 23 + "totalBullets": 18 }, "completeness": { "score": 27, @@ -70,7 +70,7 @@ "multiplier": 0.85, "scanned": false }, - "bulletCount": 23, - "algoVersion": "1.1" + "bulletCount": 18, + "algoVersion": "1.2" } } diff --git a/tests/fixtures/pdfs/latex/awesome-cv-cv.expected.json b/tests/fixtures/pdfs/latex/awesome-cv-cv.expected.json index 5f0a7a8b..9522efbc 100644 --- a/tests/fixtures/pdfs/latex/awesome-cv-cv.expected.json +++ b/tests/fixtures/pdfs/latex/awesome-cv-cv.expected.json @@ -25,7 +25,7 @@ "skills", "website_url" ], - "skillsCount": 27, + "skillsCount": 32, "experienceCount": 16, "educationCount": 1, "projectsCount": 0, @@ -67,6 +67,6 @@ "scanned": false }, "bulletCount": 59, - "algoVersion": "1.1" + "algoVersion": "1.2" } } diff --git a/tests/fixtures/pdfs/latex/awesome-cv-resume.expected.json b/tests/fixtures/pdfs/latex/awesome-cv-resume.expected.json index f13f9011..b71afcbd 100644 --- a/tests/fixtures/pdfs/latex/awesome-cv-resume.expected.json +++ b/tests/fixtures/pdfs/latex/awesome-cv-resume.expected.json @@ -67,6 +67,6 @@ "scanned": false }, "bulletCount": 31, - "algoVersion": "1.1" + "algoVersion": "1.2" } } diff --git a/tests/fixtures/pdfs/latex/deedy-resume-macfonts.expected.json b/tests/fixtures/pdfs/latex/deedy-resume-macfonts.expected.json index a3ea08cc..cd0f389f 100644 --- a/tests/fixtures/pdfs/latex/deedy-resume-macfonts.expected.json +++ b/tests/fixtures/pdfs/latex/deedy-resume-macfonts.expected.json @@ -19,6 +19,7 @@ "experience", "family_name", "full_name", + "github_url", "given_name", "heuristic_achievements", "linkedin_url", @@ -71,6 +72,6 @@ "scanned": false }, "bulletCount": 8, - "algoVersion": "1.1" + "algoVersion": "1.2" } } diff --git a/tests/fixtures/pdfs/latex/deedy-resume-openfonts.expected.json b/tests/fixtures/pdfs/latex/deedy-resume-openfonts.expected.json index a3ea08cc..cd0f389f 100644 --- a/tests/fixtures/pdfs/latex/deedy-resume-openfonts.expected.json +++ b/tests/fixtures/pdfs/latex/deedy-resume-openfonts.expected.json @@ -19,6 +19,7 @@ "experience", "family_name", "full_name", + "github_url", "given_name", "heuristic_achievements", "linkedin_url", @@ -71,6 +72,6 @@ "scanned": false }, "bulletCount": 8, - "algoVersion": "1.1" + "algoVersion": "1.2" } } diff --git a/tests/fixtures/pdfs/latex/header-as-name-functional-resume.expected.json b/tests/fixtures/pdfs/latex/header-as-name-functional-resume.expected.json index 8a211440..74a9103b 100644 --- a/tests/fixtures/pdfs/latex/header-as-name-functional-resume.expected.json +++ b/tests/fixtures/pdfs/latex/header-as-name-functional-resume.expected.json @@ -65,6 +65,6 @@ "scanned": false }, "bulletCount": 5, - "algoVersion": "1.1" + "algoVersion": "1.2" } } diff --git a/tests/fixtures/pdfs/unknown/chromium-asymmetric-sidebar.expected.json b/tests/fixtures/pdfs/unknown/chromium-asymmetric-sidebar.expected.json index 8f1c276e..6edee6c3 100644 --- a/tests/fixtures/pdfs/unknown/chromium-asymmetric-sidebar.expected.json +++ b/tests/fixtures/pdfs/unknown/chromium-asymmetric-sidebar.expected.json @@ -69,6 +69,6 @@ "scanned": false }, "bulletCount": 11, - "algoVersion": "1.1" + "algoVersion": "1.2" } } diff --git a/tests/fixtures/pdfs/unknown/chromium-qualified-experience-headers.expected.json b/tests/fixtures/pdfs/unknown/chromium-qualified-experience-headers.expected.json index 5d492a91..7c57562f 100644 --- a/tests/fixtures/pdfs/unknown/chromium-qualified-experience-headers.expected.json +++ b/tests/fixtures/pdfs/unknown/chromium-qualified-experience-headers.expected.json @@ -64,6 +64,6 @@ "scanned": false }, "bulletCount": 0, - "algoVersion": "1.1" + "algoVersion": "1.2" } } diff --git a/tests/fixtures/pdfs/unknown/chromium-two-column-sidebar.expected.json b/tests/fixtures/pdfs/unknown/chromium-two-column-sidebar.expected.json index 5ccb4823..42569f18 100644 --- a/tests/fixtures/pdfs/unknown/chromium-two-column-sidebar.expected.json +++ b/tests/fixtures/pdfs/unknown/chromium-two-column-sidebar.expected.json @@ -27,10 +27,10 @@ "summary", "website_url" ], - "skillsCount": 10, + "skillsCount": 18, "experienceCount": 5, "educationCount": 1, - "projectsCount": 2, + "projectsCount": 3, "achievementsCount": 1, "rawTextCharCount": 3905, "pageCount": 2, @@ -71,6 +71,6 @@ "scanned": false }, "bulletCount": 27, - "algoVersion": "1.1" + "algoVersion": "1.2" } } diff --git a/tests/fixtures/pdfs/unknown/name-set-apart-tagline.expected.json b/tests/fixtures/pdfs/unknown/name-set-apart-tagline.expected.json index 495981dc..a21f22fa 100644 --- a/tests/fixtures/pdfs/unknown/name-set-apart-tagline.expected.json +++ b/tests/fixtures/pdfs/unknown/name-set-apart-tagline.expected.json @@ -65,6 +65,6 @@ "scanned": false }, "bulletCount": 4, - "algoVersion": "1.1" + "algoVersion": "1.2" } } diff --git a/tests/fixtures/pdfs/unknown/openresume-react-pdf.expected.json b/tests/fixtures/pdfs/unknown/openresume-react-pdf.expected.json index 4045df47..2bb58823 100644 --- a/tests/fixtures/pdfs/unknown/openresume-react-pdf.expected.json +++ b/tests/fixtures/pdfs/unknown/openresume-react-pdf.expected.json @@ -42,14 +42,14 @@ "max": 40, "gradable": true, "metricBullets": 9, - "totalBullets": 14 + "totalBullets": 12 }, "structure": { "score": 17, "max": 30, "gradable": true, - "goodBullets": 8, - "totalBullets": 14 + "goodBullets": 7, + "totalBullets": 12 }, "completeness": { "score": 27, @@ -64,7 +64,7 @@ "multiplier": 1, "scanned": false }, - "bulletCount": 14, - "algoVersion": "1.1" + "bulletCount": 12, + "algoVersion": "1.2" } } diff --git a/tests/fixtures/pdfs/unknown/single-word-name-mononym.expected.json b/tests/fixtures/pdfs/unknown/single-word-name-mononym.expected.json index 921518c9..a55b4046 100644 --- a/tests/fixtures/pdfs/unknown/single-word-name-mononym.expected.json +++ b/tests/fixtures/pdfs/unknown/single-word-name-mononym.expected.json @@ -63,6 +63,6 @@ "scanned": false }, "bulletCount": 0, - "algoVersion": "1.1" + "algoVersion": "1.2" } } diff --git a/tests/fixtures/pdfs/unknown/student-projects-activities-singlecol.expected.json b/tests/fixtures/pdfs/unknown/student-projects-activities-singlecol.expected.json index acb14648..26aacc07 100644 --- a/tests/fixtures/pdfs/unknown/student-projects-activities-singlecol.expected.json +++ b/tests/fixtures/pdfs/unknown/student-projects-activities-singlecol.expected.json @@ -1,7 +1,7 @@ { "schemaVersion": 3, "cascade": { - "confidence": 0.86, + "confidence": 0.88, "triggers": [], "tiers": [ "t0_layout", @@ -27,7 +27,7 @@ "skillsCount": 13, "experienceCount": 3, "educationCount": 2, - "projectsCount": 4, + "projectsCount": 2, "achievementsCount": 0, "rawTextCharCount": 2331, "pageCount": 2, @@ -36,21 +36,21 @@ "sectionSource": "regex" }, "score": { - "overall": 55, - "preLayoutOverall": 55, + "overall": 58, + "preLayoutOverall": 58, "specificity": { - "score": 17, + "score": 18, "max": 40, "gradable": true, - "metricBullets": 6, - "totalBullets": 23 + "metricBullets": 5, + "totalBullets": 19 }, "structure": { - "score": 11, + "score": 13, "max": 30, "gradable": true, - "goodBullets": 9, - "totalBullets": 23 + "goodBullets": 8, + "totalBullets": 19 }, "completeness": { "score": 27, @@ -65,7 +65,7 @@ "multiplier": 1, "scanned": false }, - "bulletCount": 23, - "algoVersion": "1.1" + "bulletCount": 19, + "algoVersion": "1.2" } } diff --git a/tests/fixtures/pdfs/unknown/two-column-achievements-sidebar.expected.json b/tests/fixtures/pdfs/unknown/two-column-achievements-sidebar.expected.json index eca48804..137cf7db 100644 --- a/tests/fixtures/pdfs/unknown/two-column-achievements-sidebar.expected.json +++ b/tests/fixtures/pdfs/unknown/two-column-achievements-sidebar.expected.json @@ -25,7 +25,7 @@ "summary", "website_url" ], - "skillsCount": 8, + "skillsCount": 18, "experienceCount": 4, "educationCount": 0, "projectsCount": 0, @@ -70,6 +70,6 @@ "scanned": false }, "bulletCount": 16, - "algoVersion": "1.1" + "algoVersion": "1.2" } } diff --git a/tests/fixtures/pdfs/unknown/weasyprint-cairo-classic.expected.json b/tests/fixtures/pdfs/unknown/weasyprint-cairo-classic.expected.json index c98587af..acfe65d8 100644 --- a/tests/fixtures/pdfs/unknown/weasyprint-cairo-classic.expected.json +++ b/tests/fixtures/pdfs/unknown/weasyprint-cairo-classic.expected.json @@ -64,6 +64,6 @@ "scanned": false }, "bulletCount": 8, - "algoVersion": "1.1" + "algoVersion": "1.2" } } diff --git a/tests/fixtures/pdfs/unknown/weasyprint-cairo-minimal.expected.json b/tests/fixtures/pdfs/unknown/weasyprint-cairo-minimal.expected.json index 4a5caddc..b08dae64 100644 --- a/tests/fixtures/pdfs/unknown/weasyprint-cairo-minimal.expected.json +++ b/tests/fixtures/pdfs/unknown/weasyprint-cairo-minimal.expected.json @@ -65,6 +65,6 @@ "scanned": false }, "bulletCount": 7, - "algoVersion": "1.1" + "algoVersion": "1.2" } } diff --git a/tests/fixtures/pdfs/unknown/weasyprint-cairo-nonstandard-headers.expected.json b/tests/fixtures/pdfs/unknown/weasyprint-cairo-nonstandard-headers.expected.json index b51a03ef..62ab76cc 100644 --- a/tests/fixtures/pdfs/unknown/weasyprint-cairo-nonstandard-headers.expected.json +++ b/tests/fixtures/pdfs/unknown/weasyprint-cairo-nonstandard-headers.expected.json @@ -26,7 +26,7 @@ "skillsCount": 9, "experienceCount": 3, "educationCount": 1, - "projectsCount": 2, + "projectsCount": 1, "achievementsCount": 0, "rawTextCharCount": 1530, "pageCount": 1, @@ -66,6 +66,6 @@ "scanned": false }, "bulletCount": 8, - "algoVersion": "1.1" + "algoVersion": "1.2" } } diff --git a/tests/fixtures/pdfs/unknown/weasyprint-cairo-two-column.expected.json b/tests/fixtures/pdfs/unknown/weasyprint-cairo-two-column.expected.json index 6a61c1f1..236135a3 100644 --- a/tests/fixtures/pdfs/unknown/weasyprint-cairo-two-column.expected.json +++ b/tests/fixtures/pdfs/unknown/weasyprint-cairo-two-column.expected.json @@ -20,7 +20,6 @@ "full_name", "github_url", "given_name", - "heuristic_achievements", "linkedin_url", "location", "phone", @@ -31,8 +30,8 @@ "skillsCount": 18, "experienceCount": 3, "educationCount": 1, - "projectsCount": 2, - "achievementsCount": 1, + "projectsCount": 1, + "achievementsCount": 0, "rawTextCharCount": 1722, "pageCount": 1, "linkAnnotationCount": 0, @@ -40,21 +39,21 @@ "sectionSource": "regex" }, "score": { - "overall": 46, - "preLayoutOverall": 54, + "overall": 53, + "preLayoutOverall": 62, "specificity": { - "score": 17, + "score": 22, "max": 40, "gradable": true, "metricBullets": 6, - "totalBullets": 23 + "totalBullets": 18 }, "structure": { - "score": 10, + "score": 13, "max": 30, "gradable": true, "goodBullets": 8, - "totalBullets": 23 + "totalBullets": 18 }, "completeness": { "score": 27, @@ -71,7 +70,7 @@ "multiplier": 0.85, "scanned": false }, - "bulletCount": 23, - "algoVersion": "1.1" + "bulletCount": 18, + "algoVersion": "1.2" } } diff --git a/tests/fixtures/pdfs/word/chanchal-sharma-bulleted-skills.expected.json b/tests/fixtures/pdfs/word/chanchal-sharma-bulleted-skills.expected.json new file mode 100644 index 00000000..48a8eb79 --- /dev/null +++ b/tests/fixtures/pdfs/word/chanchal-sharma-bulleted-skills.expected.json @@ -0,0 +1,70 @@ +{ + "schemaVersion": 3, + "cascade": { + "confidence": 0, + "triggers": [], + "tiers": [ + "t0_layout", + "t1_openresume" + ], + "suggestedEscalation": "ocr", + "fieldsPopulated": [ + "current_company", + "current_title", + "education", + "email", + "experience", + "family_name", + "full_name", + "given_name", + "location", + "phone", + "skills", + "website_url" + ], + "skillsCount": 5, + "experienceCount": 2, + "educationCount": 1, + "projectsCount": 0, + "achievementsCount": 0, + "rawTextCharCount": 986, + "pageCount": 1, + "linkAnnotationCount": 0, + "hasMarkdown": true, + "sectionSource": "regex" + }, + "score": { + "overall": 24, + "preLayoutOverall": 24, + "specificity": { + "score": 0, + "max": 40, + "gradable": false, + "metricBullets": 0, + "totalBullets": 0 + }, + "structure": { + "score": 0, + "max": 30, + "gradable": false, + "goodBullets": 0, + "totalBullets": 0 + }, + "completeness": { + "score": 24, + "max": 30, + "gradable": true, + "missing": [ + "LinkedIn", + "summary" + ] + }, + "layout": { + "triggers": [], + "multiplier": 1, + "scanned": false + }, + "bulletCount": 0, + "algoVersion": "1.2" + } +} diff --git a/tests/fixtures/pdfs/word/chanchal-sharma-bulleted-skills.pdf b/tests/fixtures/pdfs/word/chanchal-sharma-bulleted-skills.pdf new file mode 100644 index 00000000..f64a050f Binary files /dev/null and b/tests/fixtures/pdfs/word/chanchal-sharma-bulleted-skills.pdf differ diff --git a/tests/fixtures/pdfs/word/chanchal-sharma-sample.expected.json b/tests/fixtures/pdfs/word/chanchal-sharma-sample.expected.json new file mode 100644 index 00000000..b0214bc3 --- /dev/null +++ b/tests/fixtures/pdfs/word/chanchal-sharma-sample.expected.json @@ -0,0 +1,71 @@ +{ + "schemaVersion": 3, + "cascade": { + "confidence": 0, + "triggers": [], + "tiers": [ + "t0_layout", + "t1_openresume" + ], + "suggestedEscalation": "ocr", + "fieldsPopulated": [ + "current_company", + "current_title", + "education", + "email", + "experience", + "family_name", + "full_name", + "given_name", + "location", + "phone", + "skills", + "website_url" + ], + "skillsCount": 6, + "experienceCount": 1, + "educationCount": 1, + "projectsCount": 0, + "achievementsCount": 0, + "rawTextCharCount": 983, + "pageCount": 1, + "linkAnnotationCount": 0, + "hasMarkdown": true, + "sectionSource": "regex" + }, + "score": { + "overall": 23, + "preLayoutOverall": 23, + "specificity": { + "score": 0, + "max": 40, + "gradable": false, + "metricBullets": 0, + "totalBullets": 0 + }, + "structure": { + "score": 0, + "max": 30, + "gradable": false, + "goodBullets": 0, + "totalBullets": 0 + }, + "completeness": { + "score": 23, + "max": 30, + "gradable": true, + "missing": [ + "LinkedIn", + "role dates", + "summary" + ] + }, + "layout": { + "triggers": [], + "multiplier": 1, + "scanned": false + }, + "bulletCount": 0, + "algoVersion": "1.2" + } +} diff --git a/tests/fixtures/pdfs/word/chanchal-sharma-sample.pdf b/tests/fixtures/pdfs/word/chanchal-sharma-sample.pdf new file mode 100644 index 00000000..b4c58269 Binary files /dev/null and b/tests/fixtures/pdfs/word/chanchal-sharma-sample.pdf differ diff --git a/tests/fixtures/pdfs/word/openresume-laverne-word-quartz.expected.json b/tests/fixtures/pdfs/word/openresume-laverne-word-quartz.expected.json index 39a16986..0be61ffc 100644 --- a/tests/fixtures/pdfs/word/openresume-laverne-word-quartz.expected.json +++ b/tests/fixtures/pdfs/word/openresume-laverne-word-quartz.expected.json @@ -1,7 +1,7 @@ { "schemaVersion": 3, "cascade": { - "confidence": 0.9, + "confidence": 0.87, "triggers": [], "tiers": [ "t0_layout", @@ -66,6 +66,6 @@ "scanned": false }, "bulletCount": 6, - "algoVersion": "1.1" + "algoVersion": "1.2" } }