diff --git a/__tests__/resumeImportDates.spec.ts b/__tests__/resumeImportDates.spec.ts new file mode 100644 index 000000000..0980bd1a8 --- /dev/null +++ b/__tests__/resumeImportDates.spec.ts @@ -0,0 +1,41 @@ +// @vitest-environment node +import { parseImportDate } from "@/actions/resumeImport/shared"; + +const ym = (d: Date | null) => d && [d.getFullYear(), d.getMonth() + 1]; + +describe("parseImportDate multilingual", () => { + it.each([ + ["Jan 2020", [2020, 1]], + ["janvier 2020", [2020, 1]], + ["Janvier 2020", [2020, 1]], + ["févr. 2021", [2021, 2]], + ["août 2019", [2019, 8]], + ["décembre 2018", [2018, 12]], + ["Januar 2020", [2020, 1]], + ["März 2020", [2020, 3]], + ["enero de 2020", [2020, 1]], + ["ene. 2020", [2020, 1]], + ["marzo 2022", [2022, 3]], + ["gennaio 2020", [2020, 1]], + ["janeiro de 2020", [2020, 1]], + ["январь 2020", [2020, 1]], + ["2020年3月", [2020, 3]], + ["2020-03", [2020, 3]], + ["03/2020", [2020, 3]], + ])("parses %s", (input, expected) => { + expect(ym(parseImportDate(input))).toEqual(expected); + }); + + it.each(["Present", "Présent", "actuel", "en cours", "Heute", "Actualidad", "現在", "по настоящее время", "-"])( + "treats %s as ongoing", + (input) => { + expect(parseImportDate(input)).toBeNull(); + }, + ); + + it("parses a bare year and ignores empty input", () => { + expect(ym(parseImportDate("2019"))).toEqual([2019, 1]); + expect(parseImportDate("")).toBeNull(); + expect(parseImportDate("not a date")).toBeNull(); + }); +}); diff --git a/src/actions/resumeImport/dates.ts b/src/actions/resumeImport/dates.ts new file mode 100644 index 000000000..a918503d9 --- /dev/null +++ b/src/actions/resumeImport/dates.ts @@ -0,0 +1,82 @@ +// Locale-independent date helpers for resume import. Month names come from +// Intl rather than a hand-written French/German/... list, so any locale the +// runtime's ICU knows works without code changes. + +const MONTH_LOCALES = [ + "en", "fr", "es", "de", "pt", "it", "nl", "sv", "da", "nb", "fi", "pl", + "cs", "sk", "ro", "hu", "tr", "ru", "uk", "el", "ca", "id", "vi", +]; + +// Words that mean "still ongoing" in end-date position. Matched after +// lowercasing and stripping diacritics and trailing punctuation. +const PRESENT_WORDS = new Set([ + "present", "current", "now", "ongoing", "today", "-", + "presente", "actuel", "actuelle", "actuellement", "aujourd'hui", "en cours", + "heute", "aktuell", "gegenwart", "laufend", "jetzt", + "actualidad", "actualmente", "actual", "hoy", "atualmente", "atual", "hoje", + "attuale", "attualmente", "oggi", "in corso", + "heden", "nu", "huidig", "nuvarande", "nutid", "nåværende", "nykyinen", + "obecnie", "dzis", "teraz", "soucasnost", "prezent", "actualmente", + "halen", "su anda", "devam ediyor", "в настоящее время", "настоящее время", + "по настоящее время", "наст. время", "сейчас", "теперь", + "現在", "至今", "今", "현재", "재직중", "الحالي", "حتى الآن", +]); + +const PARTICLES = new Set(["de", "del", "of", "d", "di", "van", "von", "em", "en", "a", "in"]); + +function fold(s: string): string { + return s + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .toLowerCase() + .replace(/[.,]+$/g, "") + .trim(); +} + +let monthMap: Map | null = null; + +function getMonthMap(): Map { + if (monthMap) return monthMap; + const seen = new Map(); + const ambiguous = new Set(); + for (const loc of MONTH_LOCALES) { + for (const style of ["long", "short"] as const) { + let fmt: Intl.DateTimeFormat; + try { + fmt = new Intl.DateTimeFormat(loc, { month: style, timeZone: "UTC" }); + } catch { + continue; + } + for (let m = 0; m < 12; m++) { + const name = fold(fmt.format(new Date(Date.UTC(2020, m, 1)))).replace(/\./g, ""); + if (!/^\p{L}{3,}$/u.test(name)) continue; + const prev = seen.get(name); + if (prev !== undefined && prev !== m) ambiguous.add(name); + else seen.set(name, m); + } + } + } + for (const a of ambiguous) seen.delete(a); + monthMap = seen; + return seen; +} + +export function isPresentMarker(s: string): boolean { + const t = fold(s); + return PRESENT_WORDS.has(t) || PRESENT_WORDS.has(s.trim().toLowerCase()); +} + +// "janvier 2020", "Januar 2020", "enero de 2020", "ene. 2020", "2020年1月" +export function parseNamedMonthYear(s: string): Date | null { + const cjk = /^(\d{4})\s*[年.]\s*(\d{1,2})\s*月?$/.exec(s.trim()); + if (cjk) return new Date(+cjk[1], +cjk[2] - 1, 1); + + const year = /(? w && !PARTICLES.has(w)); + if (words.length !== 1) return null; + const month = getMonthMap().get(words[0]); + return month === undefined ? null : new Date(+year[1], month, 1); +} diff --git a/src/actions/resumeImport/shared.ts b/src/actions/resumeImport/shared.ts index 8f64e9ec6..6bc076115 100644 --- a/src/actions/resumeImport/shared.ts +++ b/src/actions/resumeImport/shared.ts @@ -1,20 +1,17 @@ import prisma from "@/lib/db"; import { SectionType } from "@/models/profile.model"; +import { isPresentMarker, parseNamedMonthYear } from "./dates"; // Not a "use server" module: shared across src/actions/resumeImport/ modules, // so it must stay importable without becoming a callable server-action endpoint. export function parseImportDate(s: string | undefined | null): Date | null { if (!s) return null; - const t = s.trim().toLowerCase(); - if (["present", "current", "now", "ongoing", "-"].includes(t)) return null; + if (isPresentMarker(s)) return null; - // "Jan 2020" / "January 2020" - const monthYear = /^([a-z]+)\s+(\d{4})$/i.exec(s.trim()); - if (monthYear) { - const d = new Date(`${monthYear[1]} 1, ${monthYear[2]}`); - if (!isNaN(d.getTime())) return d; - } + // "Jan 2020" / "janvier 2020" / "Januar 2020" — any locale Intl knows + const named = parseNamedMonthYear(s); + if (named) return named; // "2020" if (/^\d{4}$/.test(s.trim())) { @@ -27,6 +24,12 @@ export function parseImportDate(s: string | undefined | null): Date | null { return new Date(parseInt(mmyyyy[2]), parseInt(mmyyyy[1]) - 1, 1); } + // YYYY-MM or YYYY/MM + const yyyymm = /^(\d{4})[/-](\d{1,2})$/.exec(s.trim()); + if (yyyymm) { + return new Date(parseInt(yyyymm[1]), parseInt(yyyymm[2]) - 1, 1); + } + const d = new Date(s); return isNaN(d.getTime()) ? null : d; } diff --git a/src/lib/ai/prompts/resume-import/system.ts b/src/lib/ai/prompts/resume-import/system.ts index f4496eafc..4f95272f2 100644 --- a/src/lib/ai/prompts/resume-import/system.ts +++ b/src/lib/ai/prompts/resume-import/system.ts @@ -54,11 +54,15 @@ DATE RULES (applies to both experience and education, every entry): If a date is genuinely not stated in the document, use an empty string "". - Return the exact string from the document (e.g. "Jan 2020", "2019", "Present"). - If an entry is ongoing (no end date given, or words like "Present"/"Current"), - set endDate to "Present". + set endDate to "Present". This applies in any language: "Présent", "Actuel", + "Heute", "Actualidad", "現在" and similar all become "Present". DESCRIPTION FIELD RULES (applies to both experience and education): - The description field is REQUIRED. Populate it whenever the entry has bullet points or details; use "" only if none exist. +- The document may be in any language. Copy every text value in its original + language and never translate it. Only the JSON keys and the "Present" marker + are fixed English. - Copy bullet points VERBATIM, in order, without summarizing or dropping any. - Preserve bullet symbols (•) at the start of each line. Join lines with newlines. - Do not use HTML or markdown.