// 규칙 기반 용어 검색 (AI 없음). 등록된 이름·동의어·검색 표현·설명 문자열만 찾는다. const PUNCT = /[\s·\-–_/.,()()'"’‘“”「」『』\[\]:;!?~]/g; const CHO = "ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ"; export function normalize(s) { return String(s ?? "").normalize("NFC").toLowerCase().replace(PUNCT, ""); } export function choseong(s) { let out = ""; for (const ch of String(s ?? "")) { const c = ch.charCodeAt(0); if (c >= 0xac00 && c <= 0xd7a3) out += CHO[Math.floor((c - 0xac00) / 588)]; else if (!PUNCT.test(ch)) out += ch.toLowerCase(); PUNCT.lastIndex = 0; } return out; } export const isChoseongQuery = (q) => /^[ㄱ-ㅎ]{2,}$/.test(q.replace(/\s/g, "")); function editDistance(a, b, max) { if (Math.abs(a.length - b.length) > max) return max + 1; let prev = Array.from({ length: b.length + 1 }, (_, i) => i); for (let i = 1; i <= a.length; i++) { const cur = [i]; let rowMin = i; for (let j = 1; j <= b.length; j++) { cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); rowMin = Math.min(rowMin, cur[j]); } if (rowMin > max) return max + 1; prev = cur; } return prev[b.length]; } /** terms: [{i, n, a:[], q:[], x}] → 검색용 색인 */ export function buildIndex(terms) { return terms.map((t) => ({ t, name: normalize(t.n), cho: choseong(t.n), aliases: (t.a || []).map((a) => [a, normalize(a)]), extra: (t.q || []).map((a) => [a, normalize(a)]), text: normalize(t.x || ""), })); } /** * 반환: [{t, score, reason}] 점수순. * reason: 사용자에게 보여줄 일치 이유 (예: "동의어 ‘HBM’ 일치") */ export function search(index, query, { limit = Infinity, fuzzy = true } = {}) { const raw = String(query ?? "").trim(); if (!raw) return []; const q = normalize(raw); const words = raw.split(/[\s,]+/).map(normalize).filter(Boolean); const cho = isChoseongQuery(raw) ? raw.replace(/\s/g, "") : null; const hits = []; for (const e of index) { let score = 0; let reason = ""; if (q) { if (e.name === q) { score = 100; reason = "용어 일치"; } else if (e.name.startsWith(q)) { score = 80; reason = "용어 앞부분 일치"; } else if (e.name.includes(q)) { score = 62; reason = "용어 일부 일치"; } if (score < 70) { for (const [orig, a] of e.aliases) { if (a === q) { score = 72; reason = `동의어 ‘${orig}’ 일치`; break; } if (a.includes(q) && score < 46) { score = 46; reason = `동의어 ‘${orig}’에 포함`; } } } if (score < 42) { for (const [orig, a] of e.extra) { if (a.includes(q) || (a.length >= 3 && q.includes(a))) { score = 42; reason = `검색 표현 ‘${orig}’`; break; } } } if (!score && e.text.includes(q)) { score = 16; reason = "설명 속 단어"; } } if (words.length > 1) { let matched = 0; let sub = 0; for (const w of words) { if (e.name.includes(w)) { sub += 8; matched++; } else if (e.aliases.some(([, a]) => a.includes(w)) || e.extra.some(([, a]) => a.includes(w))) { sub += 5; matched++; } else if (e.text.includes(w)) { sub += 2; matched++; } } if (matched) { const all = matched === words.length; const s = sub + (all ? 12 : 0); if (s > score) { score = s; reason = all ? "모든 단어 포함" : `${words.length}개 단어 중 ${matched}개 포함`; } } } if (cho && !score) { if (e.cho.startsWith(cho)) { score = 50; reason = "초성 일치"; } else if (e.cho.includes(cho)) { score = 34; reason = "초성 포함"; } } if (score) hits.push({ t: e.t, score: score - Math.min(e.name.length, 30) * 0.05, reason }); } if (fuzzy && hits.length < 3 && q.length >= 2 && !cho) { const max = q.length <= 4 ? 1 : 2; const seen = new Set(hits.map((h) => h.t.i)); for (const e of index) { if (seen.has(e.t.i)) continue; let d = editDistance(q, e.name, max); if (d > max && e.name.length > q.length) { // 긴 이름 앞부분과 비교 (예: '우선순이' → '우선순위 설정') d = editDistance(q, e.name.slice(0, q.length), max); } if (d <= max) hits.push({ t: e.t, score: 8 - d, reason: "비슷한 표기" }); } } hits.sort((a, b) => b.score - a.score || a.t.n.localeCompare(b.t.n, "ko")); return hits.slice(0, limit); } /** 가나다 색인용 첫 글자 묶음 */ export function initialGroup(name) { const c = String(name).charCodeAt(0); if (c >= 0xac00 && c <= 0xd7a3) { const ch = CHO[Math.floor((c - 0xac00) / 588)]; return { ㄲ: "ㄱ", ㄸ: "ㄷ", ㅃ: "ㅂ", ㅆ: "ㅅ", ㅉ: "ㅈ" }[ch] || ch; } if (/[a-z]/i.test(name[0])) return "A–Z"; return "기타"; }