fix(authors): keep non-latin letters when matching enrichment candidates (#1615)

* fix(authors): keep non-latin letters when matching enrichment candidates

Keep letters and digits of any script in normalizeName() instead of
dropping everything outside [a-z0-9], and stop treating two empty token
lists as a token-set match. Names without ASCII letters normalized to an
empty string, so any two of them scored 0.96 and passed the confidence
gate, and enrichment linked authors to unrelated people.

Closes #1459

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(authors): keep combining marks when normalizing author names

Keep \p{M} in normalizeName() alongside letters and digits. Vowel signs
in scripts such as Devanagari are combining marks, not letters, so
dropping them merged distinct names: कमल and कमला both normalized to
कमल and matched with score 1. Latin diacritics are still folded by the
earlier U+0300-U+036F strip, so Latin and Cyrillic matching is unchanged.

Refs #1459

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
This commit is contained in:
SergeiAK
2026-09-30 21:15:21 +03:00
committed by GitHub
parent de0954e77f
commit d1b3fae7f7
2 changed files with 40 additions and 1 deletions
@@ -57,4 +57,42 @@ describe('author-name-match', () => {
]); ]);
expect(best).toBeNull(); expect(best).toBeNull();
}); });
it('rejects an unrelated candidate when both names have no ASCII letters', () => {
const match = scoreAuthorNameMatch('Агата Кристи', candidate('Андрея Илиев'));
expect(match.tokenSetEqual).toBe(false);
expect(match.accept).toBe(false);
});
it('returns null when every non-ASCII candidate is unrelated', () => {
const best = pickBestAuthorNameMatch('Агата Кристи', [candidate('Андрея Илиев'), candidate('Борис Акунин')]);
expect(best).toBeNull();
});
it('rejects same-last-name false positives in a non-Latin script', () => {
const match = scoreAuthorNameMatch('Агата Кристи', candidate('Иван Кристи'));
expect(match.lastTokenExact).toBe(true);
expect(match.accept).toBe(false);
});
it('accepts matching names in a non-Latin script', () => {
const exact = scoreAuthorNameMatch('Агата Кристи', candidate('Агата Кристи'));
const swapped = scoreAuthorNameMatch('Кристи Агата', candidate('Агата Кристи'));
expect(exact.accept).toBe(true);
expect(exact.score).toBe(1);
expect(swapped.accept).toBe(true);
expect(swapped.tokenSetEqual).toBe(true);
});
it('never treats two names without letters or digits as a token-set match', () => {
const match = scoreAuthorNameMatch('...', candidate('?!'));
expect(match.tokenSetEqual).toBe(false);
expect(match.accept).toBe(false);
});
it('keeps script-specific combining marks, so names that differ only by one stay distinct', () => {
const match = scoreAuthorNameMatch('कमल', candidate('कमला'));
expect(match.exactNormalized).toBe(false);
expect(match.accept).toBe(false);
});
}); });
@@ -66,7 +66,7 @@ function normalizeName(value: string): string {
.normalize('NFKD') .normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '') .replace(/[\u0300-\u036f]/g, '')
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9\s]/g, ' ') .replace(/[^\p{L}\p{M}\p{N}\s]/gu, ' ')
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
.trim(); .trim();
} }
@@ -95,6 +95,7 @@ function overlapRatio(queryTokens: string[], candidateTokens: string[]): number
} }
function haveEqualTokenMultiset(a: string[], b: string[]): boolean { function haveEqualTokenMultiset(a: string[], b: string[]): boolean {
if (a.length === 0 || b.length === 0) return false;
if (a.length !== b.length) return false; if (a.length !== b.length) return false;
const aSorted = [...a].sort(); const aSorted = [...a].sort();
const bSorted = [...b].sort(); const bSorted = [...b].sort();