Files
ai_platform/lib/meeting-employee-names.js

294 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 회의 전사/원문에 등장한 토큰만 임직원 명단과 퍼지 매칭해,
* 회의록 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,
};