From 1b083e3dfd8ec5a6f31df616d5917dbb1c8b7345 Mon Sep 17 00:00:00 2001 From: Mayank Bhaskar Date: Tue, 18 Aug 2026 07:00:55 +0530 Subject: [PATCH 1/4] fix(parser): ignore language proficiency rows Resolves #833 Ignore spoken-language proficiency rows such as `Language: Fluent in Spanish` while preserving programming-language and bare spoken-language lists. The change includes regression coverage and updates the affected corpus snapshots. Verified locally with focused parser tests, corpus and round-trip tests, typecheck, lint, fixture checks, baseline checks, core-package checks, and production build. The full suite also reproduces an unrelated existing BroadcastChannel/MessageEvent failure in src/hooks/useLibraryChanges.test.tsx. --- src/lib/heuristics/extract/skills.ts | 32 ++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/lib/heuristics/extract/skills.ts b/src/lib/heuristics/extract/skills.ts index 1c719a41..7e016768 100644 --- a/src/lib/heuristics/extract/skills.ts +++ b/src/lib/heuristics/extract/skills.ts @@ -304,6 +304,25 @@ const SUBLABEL_PREFIX_RE = new RegExp(`^(${SUBLABEL_BODY}):\\s*`); * tore off its body. See the rejoin in `splitColumnCells`. */ const BARE_SUBLABEL_RE = new RegExp(`^${SUBLABEL_BODY}:$`); +/** A Skills sub-label that MAY head a spoken-language row. Deliberately NOT + * added to NON_SKILL_SUBLABEL_RE: on most engineering résumés `Languages:` + * heads the programming-language row, so the label is ambiguous and the body + * must decide whether this is a proficiency statement. */ +const LANGUAGE_LABEL_RE = /^languages?$/i; + +/** A spoken-language proficiency predication, distinguished from a delimited + * programming-language list by its proficiency wording rather than by the + * label. */ +const LANGUAGE_PROFICIENCY_BODY_RE = + /\b(fluent|native|bilingual|conversational|proficient|intermediate|beginner|basic|working\s+proficiency|mother\s+tongue)\b/i; + +function isLanguageProficiencyCell(cell: string): boolean { + const debulleted = stripBullet(cell); + const match = debulleted.match(SUBLABEL_PREFIX_RE); + if (!match || !LANGUAGE_LABEL_RE.test(match[1])) return false; + return LANGUAGE_PROFICIENCY_BODY_RE.test(debulleted.slice(match[0].length)); +} + /** * Tokenizes a single column cell into valid skill tokens and adds them to * `out`. Drops the cell entirely when it looks like a contact/profile link — @@ -317,7 +336,11 @@ function tokenizeCell(cell: string, out: Set): void { // leading `Label:` prefix and drop the whole cell when it names a // hobbies/interests list — before the label is stripped and the items split. const labelMatch = debulleted.match(SUBLABEL_PREFIX_RE); - if (labelMatch && NON_SKILL_SUBLABEL_RE.test(labelMatch[1])) return; + if ( + labelMatch && + (NON_SKILL_SUBLABEL_RE.test(labelMatch[1]) || isLanguageProficiencyCell(debulleted)) + ) + return; const clean = debulleted.replace(SUBLABEL_PREFIX_RE, ""); // A whole cell that is a profile link ("github.com/janesmith") must be // dropped before splitting — a path slash would otherwise leave the path @@ -616,7 +639,12 @@ function upcomingContinuationTexts(lineCells: string[][], from: number): string[ */ function matchCellLabel(cell: string): string | undefined { const m = stripBullet(cell).match(SUBLABEL_PREFIX_RE); - if (!m || NON_SKILL_SUBLABEL_RE.test(m[1])) return undefined; + if ( + !m || + NON_SKILL_SUBLABEL_RE.test(m[1]) || + isLanguageProficiencyCell(cell) + ) + return undefined; return m[1].trim(); } From 616c09c716b5268416f6cdc3b33f509f556f3744 Mon Sep 17 00:00:00 2001 From: Mayank Bhaskar Date: Tue, 18 Aug 2026 07:02:14 +0530 Subject: [PATCH 2/4] test(parser): cover language proficiency rows --- src/lib/heuristics/extract/skills.test.ts | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/lib/heuristics/extract/skills.test.ts b/src/lib/heuristics/extract/skills.test.ts index 47bab79b..a706ed5d 100644 --- a/src/lib/heuristics/extract/skills.test.ts +++ b/src/lib/heuristics/extract/skills.test.ts @@ -835,6 +835,39 @@ describe("extractSkills — category capture (#473)", () => { ]); }); + it("drops language proficiency rows without dropping language lists", () => { + const section = skillsLines([ + [...BULLET_RUNS, { x: 74, str: "Languages: Python, Go, TypeScript", w: 180 }], + [...BULLET_RUNS, { x: 74, str: "Language: Fluent in Spanish", w: 160 }], + [...BULLET_RUNS, { x: 74, str: "Languages: Spanish, French, Mandarin", w: 180 }], + [ + ...BULLET_RUNS, + { x: 74, str: "Certifications: Certified in AWS Solutions Architecture", w: 220 }, + ], + ]); + const { value, categories } = extractSkills(section); + + expect(value).toEqual([ + "Python", + "Go", + "TypeScript", + "Spanish", + "French", + "Mandarin", + "Certified in AWS Solutions Architecture", + ]); + expect(categories).toEqual([ + { label: "Languages", skills: ["Python", "Go", "TypeScript"] }, + { label: "Languages", skills: ["Spanish", "French", "Mandarin"] }, + { label: "Certifications", skills: ["Certified in AWS Solutions Architecture"] }, + ]); + expect(value).not.toContain("Fluent in Spanish"); + expect(categories).not.toContainEqual({ + label: "Language", + skills: ["Fluent in Spanish"], + }); + }); + it("INVARIANT 1: `skills` deep-equals `categories.flatMap((c) => c.skills)`", () => { // A wrapped Frontend list whose continuation flushed as its own bare cell // ("… HTML5," ⏎ "CSS3, JavaScript") must fold back into Frontend, not become From b20c622945d9fc5654d8598f407045eef0ca09fe Mon Sep 17 00:00:00 2001 From: Mayank Bhaskar Date: Tue, 18 Aug 2026 07:03:50 +0530 Subject: [PATCH 3/4] test(fixtures): update language proficiency expectations --- ...ogle-docs-skia-proxy-role-first-experience.expected.json | 6 +++--- .../google-docs-skia-proxy-role-first-experience.truth.json | 5 ----- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-role-first-experience.expected.json b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-role-first-experience.expected.json index 62e33e1a..1a9b07d0 100644 --- a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-role-first-experience.expected.json +++ b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-role-first-experience.expected.json @@ -22,7 +22,7 @@ "phoneIsValid", "skills" ], - "skillsCount": 12, + "skillsCount": 11, "experienceCount": 2, "educationCount": 1, "projectsCount": 0, @@ -74,7 +74,7 @@ "sectionSource": "regex", "pageCount": 1, "rawCharCount": 1428, - "extractedCharCount": 1147, + "extractedCharCount": 1130, "sections": [ { "name": "profile", @@ -101,7 +101,7 @@ "hasSummary": false, "experienceCount": 2, "educationCount": 1, - "skillsCount": 12 + "skillsCount": 11 }, "linkAnnotationCount": 0, "disagreements": [] diff --git a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-role-first-experience.truth.json b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-role-first-experience.truth.json index 163c6651..92c537b7 100644 --- a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-role-first-experience.truth.json +++ b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-role-first-experience.truth.json @@ -42,11 +42,6 @@ "issue": null, "status": "unfiled", "note": "Role 2's employer line reads “Multicultural Engineering Program – State Polytechnic University”; `company` comes back as just the university — the program half is not lost, the parser puts it on `team`, but `experience.company` scores the `company` field alone. The identical shape is measured on unknown/single-column-title-below-anchor. Possibly a defensible org/team split rather than a defect — recorded rather than assumed, because ground truth's job is to state what the page says and let a human adjudicate." - }, - "skills": { - "issue": 833, - "status": "open", - "note": "“Fluent in Spanish” is admitted as a skill from the “Language:” row; the Programming Languages row is now correct after #832." } } } From 8eaae56809b8b718237273f8dd173bda0f28ea43 Mon Sep 17 00:00:00 2001 From: Mayank Bhaskar Date: Tue, 18 Aug 2026 07:05:03 +0530 Subject: [PATCH 4/4] test(fixtures): refresh Word skill snapshot --- .../pdfs/word/openresume-laverne-word-quartz.expected.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 08bfa09c..8f262696 100644 --- a/tests/fixtures/pdfs/word/openresume-laverne-word-quartz.expected.json +++ b/tests/fixtures/pdfs/word/openresume-laverne-word-quartz.expected.json @@ -22,7 +22,7 @@ "skills", "summary" ], - "skillsCount": 8, + "skillsCount": 7, "experienceCount": 3, "educationCount": 1, "projectsCount": 0, @@ -74,7 +74,7 @@ "sectionSource": "regex", "pageCount": 1, "rawCharCount": 1336, - "extractedCharCount": 856, + "extractedCharCount": 839, "sections": [ { "name": "profile", @@ -105,7 +105,7 @@ "hasSummary": true, "experienceCount": 3, "educationCount": 1, - "skillsCount": 8 + "skillsCount": 7 }, "linkAnnotationCount": 0, "disagreements": []