diff --git a/src/lib/heuristics/extract-fields.test.ts b/src/lib/heuristics/extract-fields.test.ts index 01d39f38..53673611 100644 --- a/src/lib/heuristics/extract-fields.test.ts +++ b/src/lib/heuristics/extract-fields.test.ts @@ -180,6 +180,55 @@ describe("extractContact — location no longer falls back to document-wide scan }); }); +describe("extractContact — locale-aware phone parsing (issue #69)", () => { + it("parses a UK national-format number when location is 'London, United Kingdom'", () => { + // 020 7946 0958 is an Ofcom-reserved London documentation number. + // Without the region hint, libphonenumber would fail to parse national-format + // UK numbers (no +44 prefix), so the phone field would be undefined. + const { lines, profile } = buildContext([ + { text: "Emma Clarke", fontSize: 18 }, + { text: "emma.clarke@example.com · 020 7946 0958 · London, United Kingdom", fontSize: 10 }, + { text: "" }, + { text: "EXPERIENCE", fontSize: 13 }, + { text: "Some London company, 2020 - Present", fontSize: 11 }, + ]); + const contact = extractContact(profile, lines); + expect(contact.location).toContain("London"); + expect(contact.phone).toBeDefined(); + // Should reformat as international since it's not a US/CA number. + expect(contact.phone).toMatch(/^\+44/); + expect(contact.confidence.phone).toBeGreaterThan(0); + }); + + it("still parses a US number correctly when location is 'Chicago, IL'", () => { + // (312) 555-0123 — real area code, 555 exchange, 0123 subscriber (synthetic). + const { lines, profile } = buildContext([ + { text: "Alex Johnson", fontSize: 18 }, + { text: "alex@example.com · (312) 555-0123 · Chicago, IL", fontSize: 10 }, + { text: "" }, + { text: "EXPERIENCE", fontSize: 13 }, + { text: "Acme Corp, 2021 - Present", fontSize: 11 }, + ]); + const contact = extractContact(profile, lines); + expect(contact.location).toContain("Chicago"); + expect(contact.phone).toBe("(312) 555-0123"); + }); + + it("falls back gracefully when no location is present in the profile", () => { + // US phone should still parse under the default US fallback. + const { lines, profile } = buildContext([ + { text: "Sam Rivera", fontSize: 18 }, + { text: "sam@example.com · (408) 555-0142", fontSize: 10 }, + { text: "" }, + { text: "EXPERIENCE", fontSize: 13 }, + { text: "Some Corp, 2022 - Present", fontSize: 11 }, + ]); + const contact = extractContact(profile, lines); + expect(contact.location).toBeUndefined(); + expect(contact.phone).toBe("(408) 555-0142"); + }); +}); + describe("extractName — document-title boilerplate rejection (issue #10)", () => { it("picks the real name when a 'Functional Resume Sample' header is above it", () => { // Mode 1 of issue #10: a public Microsoft-style sample template renders diff --git a/src/lib/heuristics/extract-fields.ts b/src/lib/heuristics/extract-fields.ts index ab0c0f67..46f7c0b1 100644 --- a/src/lib/heuristics/extract-fields.ts +++ b/src/lib/heuristics/extract-fields.ts @@ -34,7 +34,7 @@ import { INSTITUTION_HINTS, COMPANY_SUFFIX_RE, } from "./regex.ts"; -import { findFirstPhone } from "./phone.ts"; +import { findFirstPhone, regionFromLocation } from "./phone.ts"; import { parseEntryBlocks } from "./entry-blocks.ts"; import type { EntryBlock } from "./entry-blocks.ts"; import { @@ -251,22 +251,10 @@ export function extractContact( ): ContactExtractionResult { const scan = (lines: PdfLine[], joined: string): ContactExtractionResult => { const email = firstMatch(EMAIL_RE, joined); - const phoneResult = findFirstPhone(joined); - const phone = phoneResult?.formatted; - const linkedin = firstMatch(LINKEDIN_RE, joined); - const github = firstMatch(GITHUB_RE, joined); - - // Other URLs that aren't linkedin/github → portfolio/website bucket. - const others = allMatches(URL_RE, joined).filter((u) => { - const lower = u.toLowerCase(); - return !lower.includes("linkedin.com") && !lower.includes("github.com"); - }); - const portfolio = others.find((u) => - /(portfolio|\.me\b|\.io\b|\.dev\b|behance|dribbble|medium)/i.test(u), - ); - const websiteCandidates = others.filter((u) => u !== portfolio); - const website = websiteCandidates[0]; + // Extract location BEFORE phone so we can derive the parse region. + // `location` is intentionally not subject to the document-wide fallback — + // see the doc-comment on `extractContact` for the reasoning. let location: string | undefined; for (const line of lines) { const us = US_LOCATION_RE.exec(line.text); @@ -285,6 +273,25 @@ export function extractContact( } } + // Derive the phone parse region from the extracted location; fall back to + // "US" when the location is absent or the country is not in our mapping. + const phoneRegion = regionFromLocation(location) ?? "US"; + const phoneResult = findFirstPhone(joined, phoneRegion); + const phone = phoneResult?.formatted; + const linkedin = firstMatch(LINKEDIN_RE, joined); + const github = firstMatch(GITHUB_RE, joined); + + // Other URLs that aren't linkedin/github → portfolio/website bucket. + const others = allMatches(URL_RE, joined).filter((u) => { + const lower = u.toLowerCase(); + return !lower.includes("linkedin.com") && !lower.includes("github.com"); + }); + const portfolio = others.find((u) => + /(portfolio|\.me\b|\.io\b|\.dev\b|behance|dribbble|medium)/i.test(u), + ); + const websiteCandidates = others.filter((u) => u !== portfolio); + const website = websiteCandidates[0]; + return { email, phone, diff --git a/src/lib/heuristics/phone.test.ts b/src/lib/heuristics/phone.test.ts index c71d5dc1..1357d328 100644 --- a/src/lib/heuristics/phone.test.ts +++ b/src/lib/heuristics/phone.test.ts @@ -2,7 +2,7 @@ // Copyright 2026 The resumelint Authors import { describe, it, expect } from "vitest"; -import { normalizePhone, findFirstPhone } from "./phone.ts"; +import { normalizePhone, findFirstPhone, regionFromLocation } from "./phone.ts"; // ── normalizePhone ─────────────────────────────────────────────────────────── @@ -100,3 +100,103 @@ describe("findFirstPhone — extraction from text", () => { expect(r1?.formatted).toBe(r2?.formatted); }); }); + +// ── regionFromLocation ─────────────────────────────────────────────────────── + +describe("regionFromLocation — US locations", () => { + it("returns US for a standard City, ST pattern", () => { + expect(regionFromLocation("San Francisco, CA")).toBe("US"); + }); + + it("returns US for a two-word city with state abbr", () => { + expect(regionFromLocation("New York, NY")).toBe("US"); + }); + + it("returns US regardless of whether the state abbr is a known state", () => { + // US_LOCATION_RE matches any 2-letter uppercase token after the comma. + expect(regionFromLocation("Springfield, IL")).toBe("US"); + }); +}); + +describe("regionFromLocation — international locations", () => { + it("returns GB for 'United Kingdom'", () => { + expect(regionFromLocation("London, United Kingdom")).toBe("GB"); + }); + + it("returns GB for 'UK' abbreviation", () => { + expect(regionFromLocation("Manchester, UK")).toBe("GB"); + }); + + it("returns IN for India", () => { + expect(regionFromLocation("Bengaluru, India")).toBe("IN"); + }); + + it("returns CA for Canada", () => { + expect(regionFromLocation("Toronto, Canada")).toBe("CA"); + }); + + it("returns AU for Australia", () => { + expect(regionFromLocation("Sydney, Australia")).toBe("AU"); + }); + + it("returns DE for Germany", () => { + expect(regionFromLocation("Berlin, Germany")).toBe("DE"); + }); + + it("returns SG for Singapore", () => { + expect(regionFromLocation("Singapore, Singapore")).toBe("SG"); + }); +}); + +describe("regionFromLocation — unmapped / edge cases", () => { + it("returns undefined for undefined input", () => { + expect(regionFromLocation(undefined)).toBeUndefined(); + }); + + it("returns undefined for an empty string", () => { + expect(regionFromLocation("")).toBeUndefined(); + }); + + it("returns undefined for a country not in the mapping", () => { + // "Uzbekistan" is real but not in the explicit table. + expect(regionFromLocation("Tashkent, Uzbekistan")).toBeUndefined(); + }); + + it("returns undefined for plain text with no location pattern", () => { + expect(regionFromLocation("Remote")).toBeUndefined(); + }); +}); + +describe("regionFromLocation → findFirstPhone — intl locale path", () => { + it("parses a UK national-format number when region is GB", () => { + // 020 7946 0958 is an Ofcom-reserved London documentation number. + const region = regionFromLocation("London, United Kingdom"); + expect(region).toBe("GB"); + // national format: no country prefix — libphonenumber needs the region hint. + const result = findFirstPhone("020 7946 0958", region ?? "US"); + expect(result).toBeDefined(); + expect(result!.isValid).toBe(true); + // Non-US numbers format as international. + expect(result!.formatted).toBe("+44 20 7946 0958"); + }); + + it("parses an Indian national-format number when region is IN", () => { + // 098765 43210 is a common synthetic Indian mobile used in docs. + const region = regionFromLocation("Bengaluru, India"); + expect(region).toBe("IN"); + const result = findFirstPhone("098765 43210", region ?? "US"); + expect(result).toBeDefined(); + expect(result!.isValid).toBe(true); + // Indian numbers format as +91 … + expect(result!.formatted).toMatch(/^\+91/); + }); + + it("falls back to US for an unmapped location", () => { + const region = regionFromLocation("Tashkent, Uzbekistan") ?? "US"; + expect(region).toBe("US"); + // A standard US number still parses correctly under US default. + const result = findFirstPhone("(312) 555-0123", region); + expect(result).toBeDefined(); + expect(result!.formatted).toBe("(312) 555-0123"); + }); +}); diff --git a/src/lib/heuristics/phone.ts b/src/lib/heuristics/phone.ts index cfc54cc2..18d76809 100644 --- a/src/lib/heuristics/phone.ts +++ b/src/lib/heuristics/phone.ts @@ -16,7 +16,8 @@ * Currently informational only; consumed by future Issue C (scoring). * * Default region is "US" throughout the tier 1 / tier 1.5 pipeline. - * Region inference from location fields is tracked as Issue B. + * Region inference from location fields is implemented via `regionFromLocation` + * and wired into `extractContact` in extract-fields.ts. */ import { @@ -25,7 +26,110 @@ import { type CountryCode, type PhoneNumber, } from "libphonenumber-js/min"; -import { PHONE_RE } from "./regex.ts"; +import { PHONE_RE, US_LOCATION_RE, INTL_LOCATION_RE } from "./regex.ts"; + +// ── Region inference ───────────────────────────────────────────────────────── + +/** + * Explicit country-name → ISO 3166-1 alpha-2 mapping for the most common + * non-US locales seen on international résumés. Extend as needed; the list + * is intentionally small to keep the bundle tiny (no i18n dependency). + * + * Key: lowercased country name as it appears after the comma in a location + * string matched by INTL_LOCATION_RE (e.g. "London, United Kingdom" → "united kingdom"). + */ +const COUNTRY_TO_REGION: Record = { + "united kingdom": "GB", + "uk": "GB", + "england": "GB", + "scotland": "GB", + "wales": "GB", + "india": "IN", + "canada": "CA", + "australia": "AU", + "germany": "DE", + "france": "FR", + "netherlands": "NL", + "singapore": "SG", + "ireland": "IE", + "new zealand": "NZ", + "brazil": "BR", + "mexico": "MX", + "japan": "JP", + "china": "CN", + "south korea": "KR", + "korea": "KR", + "sweden": "SE", + "norway": "NO", + "denmark": "DK", + "finland": "FI", + "switzerland": "CH", + "austria": "AT", + "spain": "ES", + "italy": "IT", + "portugal": "PT", + "poland": "PL", + "israel": "IL", + "pakistan": "PK", + "bangladesh": "BD", + "nigeria": "NG", + "south africa": "ZA", + "kenya": "KE", + "ghana": "GH", + "egypt": "EG", + "uae": "AE", + "united arab emirates": "AE", + "saudi arabia": "SA", + "hong kong": "HK", + "taiwan": "TW", + "indonesia": "ID", + "malaysia": "MY", + "thailand": "TH", + "philippines": "PH", + "vietnam": "VN", + "argentina": "AR", + "chile": "CL", + "colombia": "CO", +}; + +/** + * Derive a libphonenumber-js region code from a candidate location string + * (as extracted by US_LOCATION_RE / INTL_LOCATION_RE in extract-fields.ts). + * + * - A US_LOCATION_RE match (City, XX where XX is a 2-letter US state abbr) → "US". + * - An INTL_LOCATION_RE match whose country tail maps in COUNTRY_TO_REGION → that code. + * - Anything else (unrecognised country, no match) → `undefined` (callers + * should fall back to "US"). + * + * @param location The raw location string, e.g. "San Francisco, CA" or + * "London, United Kingdom". May be undefined/empty. + * Three-part "City, State, Country" strings (e.g. "Bengaluru, + * Karnataka, India") do not map — INTL_LOCATION_RE captures + * only the first comma-segment as the country tail. + * @returns An ISO 3166-1 alpha-2 CountryCode, or `undefined` if unmapped. + */ +export function regionFromLocation( + location: string | undefined, +): CountryCode | undefined { + if (!location) return undefined; + + // Try the INTL table first — covers both full country names ("United Kingdom") + // and known 2-letter abbreviations like "UK" that would otherwise be caught + // by US_LOCATION_RE (which matches any 2-uppercase-letter token after a comma). + const intlMatch = INTL_LOCATION_RE.exec(location); + if (intlMatch) { + const countryTail = intlMatch[2].trim().toLowerCase(); + const mapped = COUNTRY_TO_REGION[countryTail]; + if (mapped) return mapped; + } + + // US check: "City, ST" where ST is exactly 2 uppercase letters and not a + // known international abbreviation (already handled above). + const usMatch = US_LOCATION_RE.exec(location); + if (usMatch) return "US"; + + return undefined; +} /** Result shape returned by both public helpers. */ export interface PhoneResult { @@ -69,27 +173,39 @@ export function normalizePhone( } /** - * Pre-filter check: returns true if `text` might contain a phone number, - * using two fast checks before invoking the heavier libphonenumber parser: + * Pre-filter check: returns true if `text` might contain a phone number. + * + * Two fast checks are applied before invoking the heavier libphonenumber parser: * 1. `PHONE_RE` — catches US/CA 10-digit shapes and common variants. * 2. `/\+\d/` — catches E.164 international numbers (`+44 …`, `+1 …`) * whose space-separated groups fall outside PHONE_RE's US-biased pattern. + * + * For non-US regions the pre-filter is relaxed to any 7+ total digits across + * the string, because national-format numbers (e.g. UK `020 7946 0958`, + * India `098765 43210`) do not match PHONE_RE and carry no `+` prefix. */ -function mightHavePhone(text: string): boolean { +function mightHavePhone(text: string, region: CountryCode): boolean { PHONE_RE.lastIndex = 0; const byUs = PHONE_RE.test(text); PHONE_RE.lastIndex = 0; if (byUs) return true; - return /\+\d/.test(text); + if (/\+\d/.test(text)) return true; + // For non-US regions, accept text containing 7+ total digits as a candidate + // to pass to libphonenumber. National-format numbers (e.g. UK "020 7946 0958", + // India "098765 43210") are space-separated so no single run of 6+ digits + // exists; counting all digits is the reliable pre-filter. The heavier + // libphonenumber parser is the authoritative validity gate. + if (region !== "US") return (text.replace(/\D/g, "").length >= 7); + return false; } /** * Locate and normalize the first phone number found in `text`. * - * Uses a cheap pre-filter (`PHONE_RE` + `+\d` heuristic): if no digit - * sequence looks like a phone, the heavier `findPhoneNumbersInText` call - * is skipped entirely. When a hit is found, the number is formatted per - * `formatPhoneNumber`. + * Uses a cheap pre-filter (`PHONE_RE` + `+\d` heuristic, relaxed for non-US + * regions): if no digit sequence looks like a phone, the heavier + * `findPhoneNumbersInText` call is skipped entirely. When a hit is found, + * the number is formatted per `formatPhoneNumber`. * * @param text Full text to search (e.g. the joined contact-header lines). * @param region ISO 3166-1 alpha-2 default region. Defaults to `"US"`. @@ -99,7 +215,7 @@ export function findFirstPhone( text: string, region: CountryCode = "US", ): PhoneResult | undefined { - if (!mightHavePhone(text)) return undefined; + if (!mightHavePhone(text, region)) return undefined; const hits = findPhoneNumbersInText(text, region); if (hits.length === 0) return undefined; diff --git a/src/lib/heuristics/regex-fallback.ts b/src/lib/heuristics/regex-fallback.ts index 7cc0a7c7..0c9ee216 100644 --- a/src/lib/heuristics/regex-fallback.ts +++ b/src/lib/heuristics/regex-fallback.ts @@ -30,7 +30,7 @@ import type { PdfLinkAnnotation, } from "./types.ts"; import { EMAIL_RE, LINKEDIN_RE } from "./regex.ts"; -import { findFirstPhone } from "./phone.ts"; +import { findFirstPhone, regionFromLocation } from "./phone.ts"; export interface RegexFallbackResult { parsed: HeuristicParsedResume; @@ -73,8 +73,11 @@ export function runRegexFallback( // Phone — findFirstPhone runs PHONE_RE as a pre-filter then validates via // libphonenumber, so the digit-count gate is no longer needed. + // Use tier-1's already-extracted location to derive region so intl + // national-format numbers benefit from locale-aware parsing here too. if (!parsed.phone) { - const phoneResult = findFirstPhone(rawText); + const region = regionFromLocation(parsed.location) ?? "US"; + const phoneResult = findFirstPhone(rawText, region); if (phoneResult) { parsed.phone = phoneResult.formatted; fieldConfidence.phone = 0.6; diff --git a/tests/fixtures/pdfs/README.md b/tests/fixtures/pdfs/README.md index 1ffa5028..7f122390 100644 --- a/tests/fixtures/pdfs/README.md +++ b/tests/fixtures/pdfs/README.md @@ -57,7 +57,10 @@ subscriber** (e.g. `(312) 555-0123`). That form passes the parser's `libphonenumber-js` validation while staying a reserved, never-rings number. Avoid area-code-`555` numbers like `(555) 010-0123` — `555` is an invalid NANP area code, so the validator rejects them and the fixture's `phone` field drops -out of the score. **Real-user PDFs do not belong here, ever.** +out of the score. Always use the **full 10-digit** form (e.g. `(512) 555-0142` +not `555-0142`) — the parser's `libphonenumber-js` path requires a 10-digit +NANP number and silently drops 7-digit local-format strings. +**Real-user PDFs do not belong here, ever.** **"Self-published upstream" is not an exception.** Several OSS résumé templates ship the author's *own real résumé* as the demo PDF — e.g. 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 4c8d4956..6f731a64 100644 --- a/tests/fixtures/pdfs/unknown/chromium-two-column-sidebar.expected.json +++ b/tests/fixtures/pdfs/unknown/chromium-two-column-sidebar.expected.json @@ -7,8 +7,7 @@ ], "tiers": [ "t0_layout", - "t1_openresume", - "t1_5_regex" + "t1_openresume" ], "suggestedEscalation": "ner", "fieldsPopulated": [ @@ -22,24 +21,25 @@ "given_name", "heuristic_achievements", "location", + "phone", "skills", "summary", "website_url" ], - "skillsCount": 5, - "experienceCount": 7, + "skillsCount": 10, + "experienceCount": 5, "educationCount": 1, "projectsCount": 0, "achievementsCount": 1, - "rawTextCharCount": 3937, + "rawTextCharCount": 3905, "pageCount": 2, "linkAnnotationCount": 0, "hasMarkdown": true, - "sectionSource": "markdown" + "sectionSource": "regex" }, "score": { - "overall": 48, - "preLayoutOverall": 57, + "overall": 51, + "preLayoutOverall": 60, "specificity": { "score": 17, "max": 40, @@ -55,12 +55,11 @@ "totalBullets": 27 }, "completeness": { - "score": 24, + "score": 27, "max": 30, "gradable": true, "missing": [ - "LinkedIn", - "phone" + "LinkedIn" ] }, "layout": { diff --git a/tests/fixtures/pdfs/unknown/chromium-two-column-sidebar.pdf b/tests/fixtures/pdfs/unknown/chromium-two-column-sidebar.pdf index f2595bcb..e8b1368b 100644 Binary files a/tests/fixtures/pdfs/unknown/chromium-two-column-sidebar.pdf and b/tests/fixtures/pdfs/unknown/chromium-two-column-sidebar.pdf differ