/** * 회의 전사/원문에 등장한 토큰만 임직원 명단과 퍼지 매칭해, * 회의록 LLM 요청에「이번 텍스트 한정 표기 통일」블록만 붙인다(전체 명단을 시스템 프롬프트에 넣지 않음). */ const fs = require("fs"); const path = require("path"); const DEFAULT_NAMES_PATH = path.join(__dirname, "..", "data", "meeting-employee-names.txt"); /** 회의 맥락에서 이름이 아닌 짧은 단어(오탐 감소) */ const STOPWORDS = new Set([ "대표님", "팀장", "담당", "참석", "회의", "논의", "결정", "검토", "파일럿", "라이선스", "도입", "확산", "목표", "일정", "비용", "운영", "현황", "보고", "전사", "고객", "프로젝트", "진행", "확인", "준비", "완료", "다음", "오늘", "내일", "이번", "주간", "월간", "분기", "연간", "슬랙", "노션", "클로드", "커서", "구글", "엔터프라이즈", "계정", "사용", "적용", "전환", "시연", "발표", "자료", "문서", "보안", "네트워크", "장비", "미정", "즉시", "가능", "필요", "관련", "내용", "사항", "기준", "방안", "계획", "요청", "제안", "결과", "챔피언", "담당자", "참석자", "목적", "배경", "이슈", "리스크", "효과", "대시보드", "지표", "활용", "추가", "삭제", "수정", "작성", "제출", "공유", "연동", "설정", "구매", "계약", "예산", "절감", "확정", "선발", "명단", "기한", "우선", "순위", "단계", "초기", "전체", "일부", "해당", "각각", "모든", "기타", ]); let _rosterCache = null; let _rosterCachePath = null; let _rosterCacheMtime = 0; function resolveNamesPath() { const env = (process.env.MEETING_EMPLOYEE_NAMES_FILE || "").trim(); if (env) { return path.isAbsolute(env) ? env : path.join(process.cwd(), env); } return DEFAULT_NAMES_PATH; } /** * 한 줄 한 이름(또는 쉼표 구분). # 으로 시작하는 줄·빈 줄 무시. * @returns {string[]} */ function loadEmployeeRoster() { if ((process.env.MEETING_NAME_NORMALIZATION || "1").trim() === "0") { return []; } const filePath = resolveNamesPath(); try { const st = fs.statSync(filePath); if (_rosterCache != null && _rosterCachePath === filePath && st.mtimeMs === _rosterCacheMtime) { return _rosterCache; } const raw = fs.readFileSync(filePath, "utf8"); const names = []; const seen = new Set(); for (const line of raw.split(/\r?\n/)) { const t = line.trim(); if (!t || t.startsWith("#")) continue; for (const part of t.split(/[,,]/)) { const clean = part.replace(/\s+/g, "").replace(/·/g, "").trim(); if (!clean || seen.has(clean)) continue; if (!/^[가-힣]{2,5}$/.test(clean)) continue; seen.add(clean); names.push(clean); } } _rosterCache = names; _rosterCachePath = filePath; _rosterCacheMtime = st.mtimeMs; return names; } catch { return []; } } function invalidateRosterCache() { _rosterCache = null; _rosterCachePath = null; _rosterCacheMtime = 0; } /** * @param {string} a * @param {string} b * @returns {number} */ function levenshtein(a, b) { const m = a.length; const n = b.length; if (m === 0) return n; if (n === 0) return m; const v0 = new Array(n + 1); const v1 = new Array(n + 1); for (let j = 0; j <= n; j++) v0[j] = j; for (let i = 0; i < m; i++) { v1[0] = i + 1; for (let j = 0; j < n; j++) { const cost = a.charAt(i) === b.charAt(j) ? 0 : 1; v1[j + 1] = Math.min(v1[j] + 1, v0[j + 1] + 1, v0[j] + cost); } for (let j = 0; j <= n; j++) v0[j] = v1[j]; } return v0[n]; } /** * @param {string} text * @returns {Set} */ function extractHangulNameLikeTokens(text) { const s = String(text || ""); const out = new Set(); const parts = s.split(/[\s,,.。::;;·\[\]()()「」『』"'`\-_/\\|\n\r\t]+/); for (const p of parts) { const t = p.trim(); if (!t) continue; if (!/^[가-힣]{2,4}$/.test(t)) continue; if (STOPWORDS.has(t)) continue; out.add(t); } return out; } /** * 토큰별 명단에서 최소 편집거리 후보. 동점 다수면 스킵(모호). * @param {string} token * @param {string[]} roster * @returns {{ name: string, dist: number } | null} */ function bestRosterMatch(token, roster) { const maxDist = token.length <= 2 ? 1 : token.length <= 3 ? 1 : 2; let bestDist = Infinity; const winners = []; for (const r of roster) { if (Math.abs(r.length - token.length) > maxDist) continue; const d = levenshtein(token, r); if (d > maxDist) continue; if (d < bestDist) { bestDist = d; winners.length = 0; winners.push(r); } else if (d === bestDist) { winners.push(r); } } if (bestDist === Infinity || bestDist === 0) return null; const uniq = [...new Set(winners)]; if (uniq.length !== 1) return null; return { name: uniq[0], dist: bestDist }; } /** * 전사/원문에 대해 전사 표기 → 표준 표기 매핑 생성 * @param {string} transcript * @param {string[]} roster * @returns {Array<{ from: string, to: string }>} */ function buildNormalizationMappings(transcript, roster) { if (!roster.length) return []; const tokens = extractHangulNameLikeTokens(transcript); /** @type {Map} */ const fromTo = new Map(); for (const t of tokens) { if (roster.includes(t)) continue; const hit = bestRosterMatch(t, roster); if (!hit || hit.dist === 0) continue; if (hit.name === t) continue; const existing = fromTo.get(t); if (existing && existing !== hit.name) continue; fromTo.set(t, hit.name); } return [...fromTo.entries()] .map(([from, to]) => ({ from, to })) .sort((a, b) => a.from.localeCompare(b.from, "ko")); } /** * LLM user 메시지 앞에 붙일 짧은 블록(매핑 없으면 빈 문자열) * @param {string} transcript * @param {string[]} [roster] */ function buildNameNormalizationUserPrefix(transcript, roster) { const r = roster || loadEmployeeRoster(); const maps = buildNormalizationMappings(transcript, r); if (!maps.length) { return ""; } const lines = maps.map((m) => `- 전사·원문에 「${m.from}」로 보이면, 회의록 인명은 「${m.to}」로 통일합니다.`); return ( "【이번 원문/전사 한정 · 임직원 표기 통일】\n" + "아래 줄은 **이번 입력 텍스트에 실제로 등장한 토큰**만 명단과 대조한 결과입니다. 해당할 때만 표기를 바꾸고, 전사에 없는 사람을 새로 만들지 마세요.\n" + lines.join("\n") + "\n\n---\n\n" ); } module.exports = { loadEmployeeRoster, buildNormalizationMappings, buildNameNormalizationUserPrefix, invalidateRosterCache, resolveNamesPath, DEFAULT_NAMES_PATH, };