294 lines
6.9 KiB
JavaScript
294 lines
6.9 KiB
JavaScript
/**
|
||
* 회의 전사/원문에 등장한 토큰만 임직원 명단과 퍼지 매칭해,
|
||
* 회의록 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<string>}
|
||
*/
|
||
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<string, string>} */
|
||
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,
|
||
};
|