diff --git a/packages/core/__tests__/personal-graph-terms-multiscript.test.ts b/packages/core/__tests__/personal-graph-terms-multiscript.test.ts new file mode 100644 index 0000000..9615e14 --- /dev/null +++ b/packages/core/__tests__/personal-graph-terms-multiscript.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' + +import { extractTerms, jaccard, tokenizeTerms } from '../src/personal-graph' + +// 회귀: 예전 분리 정규식은 ASCII/한글/가나/한자만 문자로 봤다. +// 키릴·태국·그리스·아랍 문장은 terms=[] 가 되어 shares_terms 엣지가 생기지 않았고, +// 악센트 라틴 단어는 'pr'/'ller' 같은 조각으로 쪼개져 거짓 겹침을 만들었다. +describe('extractTerms — 다국어 문자 체계', () => { + it('러시아어(키릴) 단어를 용어로 뽑는다', () => { + const terms = extractTerms('Отправлю отчёт завтра утром') + expect(terms).toEqual(expect.arrayContaining(['отправлю', 'отчёт', 'завтра', 'утром'])) + }) + + it('독일어 악센트 단어를 조각내지 않는다', () => { + const terms = extractTerms('Müller schickt die Präsentation') + expect(terms).toContain('müller') + expect(terms).toContain('präsentation') + expect(terms).not.toContain('pr') + expect(terms).not.toContain('ller') + expect(terms).not.toContain('sentation') + }) + + it('프랑스어 악센트 단어를 온전히 유지한다', () => { + expect(extractTerms('café résumé')).toEqual(['café', 'résumé']) + }) + + it('그리스어·아랍어 단어를 용어로 뽑는다', () => { + expect(extractTerms('Στέλνω την αναφορά αύριο')).toEqual( + expect.arrayContaining(['στέλνω', 'την', 'αναφορά', 'αύριο']) + ) + expect(extractTerms('سأرسل التقرير غدا')).toEqual(expect.arrayContaining(['سأرسل', 'التقرير', 'غدا'])) + }) + + it('결합 문자(NFD)로 들어온 악센트도 같은 용어가 된다', () => { + const composed = extractTerms('Präsentation') + const decomposed = extractTerms('Präsentation') + expect(decomposed).toEqual(composed) + }) + + it('띄어쓰기 없는 태국어 문장도 용어를 만든다', () => { + const terms = extractTerms('ส่งรายงานพรุ่งนี้เช้า') + expect(terms.length).toBeGreaterThan(0) + if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') { + expect(terms).toContain('รายงาน') + } + }) + + it('같은 용어를 공유하는 러시아어 문장은 자카드가 0 보다 크다', () => { + const a = extractTerms('Отправлю отчёт завтра утром') + const b = extractTerms('Проверю отчёт завтра вечером') + expect(jaccard(a, b)).toBeGreaterThan(0) + }) + + it('한국어·영어·일본어·중국어 토큰화는 기존과 같다', () => { + expect(tokenizeTerms('오늘 회의에서, Report 정리!')).toEqual(['오늘', '회의에서', 'report', '정리']) + expect(tokenizeTerms('会議の資料を送ります')).toEqual(['会議の資料を送ります']) + expect(tokenizeTerms('明天发送报告')).toEqual(['明天发送报告']) + }) +}) diff --git a/packages/core/src/personal-graph.ts b/packages/core/src/personal-graph.ts index 7315e8e..c04d4f1 100644 --- a/packages/core/src/personal-graph.ts +++ b/packages/core/src/personal-graph.ts @@ -93,15 +93,63 @@ const STOPWORDS: ReadonlySet = new Set([ /** 최소 길이 (1~2자 토큰은 노이즈가 많다). */ const MIN_TERM_CHARS = 2 +/** + * 용어 경계 — 어떤 문자 체계든 문자(L)·숫자(N)·결합 부호(M)가 아닌 것. + * + * 예전에는 ASCII/한글/가나/한자만 허용해서 키릴·그리스·아랍·태국 문자는 통째로 사라지고 + * 'Präsentation' 같은 악센트 라틴 단어는 'pr' + 'sentation' 조각으로 쪼개졌다. + */ +const TERM_SEPARATOR = /[^\p{L}\p{N}\p{M}]+/u + +/** 띄어쓰기 없이 쓰는 문자 체계 — 경계 분리만으로는 문장 전체가 한 토큰이 된다. */ +const UNSPACED_SCRIPT = /[\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u + +let wordSegmenter: Intl.Segmenter | null | undefined + +/** 런타임이 지원할 때만 단어 분할기를 만든다 (Hermes 등에는 없을 수 있다). */ +function getWordSegmenter(): Intl.Segmenter | null { + if (wordSegmenter !== undefined) return wordSegmenter + wordSegmenter = + typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function' + ? new Intl.Segmenter(undefined, { granularity: 'word' }) + : null + return wordSegmenter +} + +/** 띄어쓰기 없는 문자 체계의 토큰을 사전 기반 단어로 나눈다. 분할기가 없으면 그대로 둔다. */ +function segmentUnspacedToken(token: string): string[] { + if (!UNSPACED_SCRIPT.test(token)) return [token] + const segmenter = getWordSegmenter() + if (!segmenter) return [token] + const words: string[] = [] + for (const part of segmenter.segment(token)) { + if (part.isWordLike) words.push(part.segment) + } + return words.length > 0 ? words : [token] +} + +/** + * 텍스트를 비교용 토큰으로 자른다 (불용어/길이 필터 전 단계). + * + * NFC 정규화 → 소문자화 → 문자/숫자/결합 부호 경계로 분리 → 띄어쓰기 없는 문자 체계는 단어 분할. + * 한글 음절·ASCII·가나·한자로 된 토큰은 예전과 같은 결과를 낸다 (저장된 노드의 terms 와 호환). + */ +export function tokenizeTerms(text: string): string[] { + return text + .normalize('NFC') + .toLowerCase() + .split(TERM_SEPARATOR) + .filter((token) => token.length > 0) + .flatMap(segmentUnspacedToken) +} + /** * 문장에서 비교용 용어를 뽑는다. * - * 소문자화 → 문자/숫자 경계로 분리 → 불용어/짧은 토큰 제거 → 빈도 순. + * 토큰화 → 불용어/짧은 토큰 제거 → 빈도 순. */ export function extractTerms(text: string, limit = 8): string[] { - const tokens = text - .toLowerCase() - .split(/[^0-9a-z\uac00-\ud7a3\u3040-\u30ff\u4e00-\u9fff]+/u) + const tokens = tokenizeTerms(text) .filter((token) => token.length >= MIN_TERM_CHARS) .filter((token) => !STOPWORDS.has(token))