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 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(); } 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." } } } 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": []