diff --git a/.env.example b/.env.example index cf0ca55..7e936b6 100644 --- a/.env.example +++ b/.env.example @@ -1,48 +1,23 @@ -# DEV: 개발 | PROD: 운영(임직원 이메일 로그인, 구 REAL) | SUPER: 데모·제한 완화 -OPS_STATE=DEV +# 환경 +OPS_STATE=PROD # DEV: 개발 | PROD: 운영(임직원 이메일 로그인) | SUPER: 데모·제한 완화 (REAL 은 PROD 와 동일) PORT=8030 # HTTP 수신 주소 (기본 0.0.0.0 = 모든 인터페이스, 로컬만이면 127.0.0.1) HOST=0.0.0.0 ADMIN_TOKEN=xavis-admin -# --- OPS_STATE=PROD: 매직 링크 이메일 (앱 서버가 아웃바운드로 TCP 연결 가능한 SMTP만 동작) -# BASE_URL=https://실제-도메인 -# AUTH_SECRET=운영용-비밀값 -# 사내 전용 게이트웨이(gw.* 등)는 클라우드에서 587이 ECONNREFUSED로 막히는 경우가 많음 → -# Google Workspace SMTP 릴레이(smtp-relay.gmail.com + 발신 IP 허용), SendGrid, SES 등 사용 권장. -# SMTP_HOST=smtp-relay.gmail.com -# SMTP_PORT=587 -# SMTP_SECURE=0 -# SMTP_USER= -# SMTP_PASS= -# SMTP_FROM=noreply@xavis.co.kr -# 선택: 587에서 STARTTLS 강제(기본 on). 특수 서버만 0 -# SMTP_REQUIRE_TLS=1 -# 이메일 로그인 세션: 로그인한 달력일(OPS_SESSION_TZ) + OPS_SESSION_TTL_DAYS일의 23:59:59까지(기본 15일) -# OPS_SESSION_TZ=Asia/Seoul -# OPS_SESSION_TTL_DAYS=15 PAGE_SIZE=9 -# 학습센터 동영상 파일 업로드 최대 크기(MB, 기본 500). 리버스 프록시(Nginx 등)의 client_max_body_size도 같이 늘려야 합니다. -LECTURE_VIDEO_MAX_MB=500 -# 대시보드 메뉴·경로 허용 이메일(OPS 로그인 @xavis.co.kr), 쉼표 구분. 비우면 대시보드 비표시 -DASHBOARD_MENU_ALLOWED_EMAILS=hmjin@xavis.co.kr,dsyoon@xavis.co.kr -# DEV에서만: 관리자 모드일 때 MEETING_DEV_EMAIL을 허용 목록과 대조(로컬 테스트). 운영에서는 미설정 권장 -# DASHBOARD_MENU_DEV_USE_MEETING_EMAIL=1 - # 1=PostgreSQL 단일 소스, 0=data/lectures.json 사용 ENABLE_POSTGRES=1 -DB_HOST=your-db-host +DB_HOST=localhost DB_PORT=5432 -DB_DATABASE=your_database -DB_USERNAME=your_user -DB_PASSWORD=your_password +DB_DATABASE=ai_web_platform +DB_USERNAME=xavis +DB_PASSWORD=wkqltm@@00492 + # DB 연결이 없거나 실패하면 회의록 AI는 data/meeting-ai.json에 저장됩니다(로컬 개발에 유용). # 회의 음성 업로드 최대 크기(MB, 기본 300). OpenAI 전사 API는 요청당 약 25MB이므로 초과분은 서버에서 ffmpeg로 분할 후 전사합니다. MEETING_AUDIO_MAX_MB=300 -# 회의록 저장 후 OpenAI JSON으로 체크리스트 추출 → 업무 체크리스트 자동 반영 (1=기본, 0=비활성) -MEETING_AUTO_CHECKLIST=1 -# 추출 시 회의록 본문 최대 길이(문자). 긴 경우 끝부분(체크리스트가 뒤에 있을 때)만 사용 -MEETING_CHECKLIST_EXTRACT_MAX_CHARS=24000 + ENABLE_PPT_THUMBNAIL=1 THUMBNAIL_WIDTH=1000 THUMBNAIL_MAX_RETRY=2 @@ -50,12 +25,26 @@ THUMBNAIL_RETRY_DELAY_MS=5000 THUMBNAIL_EVENT_KEEP=200 THUMBNAIL_EVENT_PAGE_SIZE=50 + +[인증 메일 정보] +BASE_URL=https://ai.xavis.co.kr # 메일 속 링크에 사용 +AUTH_SECRET=xavis-admin # 세션 서명 (ADMIN_TOKEN 폴백 가능) +# 메일 발송 (선택, 없으면 콘솔에 링크만) +SMTP_HOST=gw.xavis.co.kr +SMTP_PORT=25 +SMTP_SECURE=0 +SMTP_USER=dsyoon +SMTP_PASS=!xavis5004 +SMTP_FROM=dsyoon@xavis.co.kr + + +[채팅 기능 정보] # 채팅 기능용 API 키 # OpenAI: https://platform.openai.com/api-keys -OPENAI_API_KEY= +OPENAI_API_KEY=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 # 선택: UI의 gpt-5.4 / gpt-5-mini에 대응하는 실제 Chat Completions 모델 ID (미설정 시 gpt-4o / gpt-4o-mini) -# OPENAI_MODEL_DEFAULT=gpt-4o -# OPENAI_MODEL_MINI=gpt-4o-mini +OPENAI_MODEL_DEFAULT=gpt-5-mini +OPENAI_MODEL_MINI=gpt-5-mini # OpenAI Responses API 내장 웹 검색(기본 on). 끄려면 아래 주석 해제 후 0 # OPENAI_WEB_SEARCH=0 # 웹 검색 위치 힌트(선택) @@ -63,16 +52,26 @@ OPENAI_API_KEY= # OPENAI_WEB_SEARCH_CITY= # OPENAI_WEB_SEARCH_REGION= # OPENAI_WEB_SEARCH_TIMEZONE=Asia/Seoul +# gpt-4o 전사 API: 요청당 오디오+토큰 한도 → ffmpeg 분할 길이(초). 짧을수록 안전(호출 수 증가) +OPENAI_TRANSCRIBE_SEGMENT_SEC=30 + # Anthropic Claude (claude-*): https://console.anthropic.com/ CLAUDE_API_KEY= # Google Gemini (gemini-*): https://aistudio.google.com/apikey GENAI_API_KEY= -# OPS_STATE=DEV + 관리자 토큰일 때 회의록 AI 등에 쓸 가상 사용자 이메일 (미설정 시 dev@xavis.co.kr) -# MEETING_DEV_EMAIL=you@example.com -# SUPER 모드에서 회의록·체크리스트용 데모 사용자 이메일 (미설정 시 MEETING_DEV_EMAIL 또는 demo@xavis.local) -# MEETING_SUPER_EMAIL=demo@xavis.local -# 회의록 음성 전사 기본 모델 (미설정 시 gpt-4o-mini-transcribe) -# OPENAI_WHISPER_MODEL=gpt-4o-mini-transcribe -# gpt-4o 전사 API: 요청당 오디오 토큰 한도 → ffmpeg 분할 길이(초, 15~600, 기본 120). 한도 오류 시 30 또는 15 -# OPENAI_TRANSCRIBE_SEGMENT_SEC=30 + + +[회의록 기능 정보] +# 임직원 명단(회의록 인명 정규화). 기본 data/meeting-employee-names.txt 한 줄에 한 이름(또는 쉼표 구분) +# MEETING_EMPLOYEE_NAMES_FILE=./data/meeting-employee-names.txt +# 0 이면 전사→명단 퍼지 매칭 블록 비활성화 +# MEETING_NAME_NORMALIZATION=1 +# 관리자 토큰일 때 회의록 AI 등에 쓸 가상 사용자 이메일 (미설정 시 dev@xavis.co.kr) +EETING_DEV_EMAIL=dsyoon@xavis.co.kr +# 선택: Whisper 전사 모델 (기본 whisper-1) +OPENAI_WHISPER_MODEL=gpt-4o-mini-transcribe +MEETING_DEV_EMAIL=dsyoon@xavis.co.kr + +[경영성과 대시보드] +DASHBOARD_MENU_ALLOWED_EMAILS=hmjin@xavis.co.kr,dsyoon@xavis.co.kr diff --git a/README.md b/README.md index 6eabac6..dab1e68 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ - 학습센터 UI (좌측 메뉴 + 상단 헤더 + 강의 카드 레이아웃) - **AI 탐색** (`/ai-explore`): 전체 너비 레이아웃, AI 서비스 카드(프롬프트·회의록 등). 검색창에 **「프롬프트」**가 포함된 채 검색(Enter) 시 프롬프트 라이브러리로 이동 - **회의록 AI** (`/ai-explore/meeting-minutes`): 회의록 생성 시스템 프롬프트는 `lib/meeting-minutes.js`의 `buildMeetingMinutesSystemPrompt`에서 구성하며, **추가 지시**가 형식·섹션(체크리스트 포함 여부, 액션 표기 등)에 우선합니다. DB `meeting_ai_prompts.include_checklist`가 `true`일 때만 회의 체크리스트 강제 블록을 넣고, 기본값은 `false`입니다. `include_checklist`가 `false`이면 생성 직후 `prepareMeetingMinutesForApi`에서 `## 회의 체크리스트` 등 블록을 **후처리로 제거**합니다(모델이 습관적으로 넣은 경우 대비). 기존 DB에 `include_checklist = true`가 남아 있으면 `UPDATE meeting_ai_prompts SET include_checklist = false`로 끄거나, 화면에서 **프롬프트 저장**으로 덮어씁니다. 기본 **추가 지시**는 `views/meeting-minutes.ejs`의 `mmDefaultCustomInstructions`입니다. +- **임직원 인명 정규화**: `data/meeting-employee-names.txt`(또는 `MEETING_EMPLOYEE_NAMES_FILE`)에 성명을 두고, `lib/meeting-employee-names.js`가 전사·원문에서 **이름으로 보이는 토큰만** 명단과 퍼지 매칭해, 회의록 LLM 요청 **사용자 메시지 상단**에 짧은「이번 원문/전사 한정 · 임직원 표기 통일」블록만 붙입니다. 전 직원 명단을 시스템 프롬프트에 넣지 않습니다. 끄려면 `MEETING_NAME_NORMALIZATION=0`. - **대시보드** (`/dashboard`): AI 탐색과 유사한 카드 그리드·검색으로 대시보드를 모아 표시. 첫 카드 **경영성과 대시보드**는 `/dashboard/business-performance`로 연결 - **경영성과 대시보드** (`/dashboard/business-performance`): **위쪽 대시보드 조회**(Chart.js 인라인), **아래 엑셀 업로드**(`.xlsx`, 매출일보 시트) 순서. 본문은 AI 프롬프트 페이지와 동일하게 **`main.container.container-ai-full`**(전체 너비·좌우 24px)로 맞춤. 대시보드 상단 **연도·분기**로 `mgmt_perf_uploads`에 저장된 해당 기간 **최신 스냅샷**을 불러오며, 쿼리 **`?year=2026&quarter=1`** 또는 폼 조회와 동일. 해당 기간 업로드가 없으면 기본 JSON 샘플을 쓰고 안내 문구를 표시합니다. `public/mgmt-perf/dashboard.css`에 있던 **범용 `.container { background: white }`** 는 앱 페이지에서 `main.container`까지 적용되어 회색 본문이 가려졌으므로 제거하고, 흰 카드는 **`.mgmt-perf-embed .container`** 만 사용합니다. 업로드는 DB(`mgmt_perf_uploads` / `mgmt_perf_snapshots`) 또는 DB 미연결 시 `data/mgmt-perf-last-state.json`에 스냅샷 저장. 최근 업로드 행 **`DELETE /api/mgmt-perf/upload/:id`** 로 삭제(PG는 CASCADE, 파일 전용 모드는 `id=file`). 단독 임베드 페이지는 `/dashboard/business-performance/embed`(본문에 `body.mgmt-perf-standalone`으로 어두운 배경). Express에서 **`/mgmt-perf/*` → `public/mgmt-perf/`** 정적 제공이 등록되어 있어 `dashboard-app.js`·`chart.umd.min.js`(CDN 대신 동봉)가 항상 같은 오리진에서 로드됩니다. 업로드 시 **한글 파일명**은 multer 기본(`defParamCharset` 생략 시 latin1)으로 온 `originalname`을 **`lib/decode-upload-filename.js`**의 `decodeUploadFilename`으로 보정합니다(`decodeURIComponent(escape(...))` 우선, 이어서 `Buffer` latin1→utf8). Busboy에 `defParamCharset: 'utf8'`를 켜면 이중 디코딩으로 깨질 수 있어 두지 않습니다. 탭 전환·차트 렌더는 **ASCII 섹션 id**(`mgmt-sec-sales` 등)와 `state.currentSection`으로 동기화합니다. 리버스 프록시 사용 시 업로드 실패하면 **`client_max_body_size`**(예: 64m)와 **`/api/`·`/mgmt-perf/` → Node** 전달 여부를 확인. 엑셀 집계 치환은 `npm install`로 `xlsx` 설치 후 서버 재시작. - **경영성과 데이터 확인**: 브라우저에서 `GET /api/mgmt-perf/status`(JSON)로 최근 스냅샷의 `payloadKeys`, `_uploadMeta`(행 수 등)를 확인할 수 있습니다. **현재 구현**은 엑셀에서 **매출일보 행 수·시트명만** `payload._uploadMeta`에 넣고, **차트 수치는 기본 시드 JSON**(`data/mgmt-perf-default-payload.json`)을 씁니다. 5,000행이어도 차트가 엑셀 집계와 일치하려면 **별도 집계·매핑 로직**이 필요합니다. diff --git a/data/meeting-employee-names.txt b/data/meeting-employee-names.txt new file mode 100644 index 0000000..e687543 --- /dev/null +++ b/data/meeting-employee-names.txt @@ -0,0 +1,7 @@ +# 임직원 성명 (한 줄에 한 이름, 또는 한 줄에 쉼표로 여러 이름) +# 스프레드시트에서 붙여 넣어도 됩니다. # 으로 시작하는 줄은 무시됩니다. +# 회의록 생성 시 전사에 등장한 토큰만 이 목록과 대조해「표기 통일」블록이 LLM에 전달됩니다. + +김창열 +이소은 +현아 diff --git a/lib/meeting-employee-names.js b/lib/meeting-employee-names.js new file mode 100644 index 0000000..8634d09 --- /dev/null +++ b/lib/meeting-employee-names.js @@ -0,0 +1,293 @@ +/** + * 회의 전사/원문에 등장한 토큰만 임직원 명단과 퍼지 매칭해, + * 회의록 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, +}; diff --git a/lib/meeting-minutes.js b/lib/meeting-minutes.js index a09da8a..93074f0 100644 --- a/lib/meeting-minutes.js +++ b/lib/meeting-minutes.js @@ -4,6 +4,7 @@ const fsSync = require("fs"); const os = require("os"); const path = require("path"); +const meetingEmployeeNames = require("./meeting-employee-names"); const { execFile } = require("child_process"); const { promisify } = require("util"); const execFileAsync = promisify(execFile); @@ -59,7 +60,8 @@ const ACTION_ITEMS_GUIDANCE_MINIMAL = [ const EMPLOYEE_NAME_GUIDANCE_MINIMAL = [ "【인명·담당자】", "참석자·담당자 이름은 **원문·전사에 실제로 등장한 표기**를 따릅니다. 음성 인식 오류로 같은 사람이 문맥상 확실할 때만 철자를 다듬습니다.", - "사내 다른 성명 목록으로 바꿔 끼우거나, 전사에 없는 사람을 만들어내지 마세요.", + "사용자 메시지 상단에「이번 원문/전사 한정 · 임직원 표기 통일」블록이 있으면, **그 안의 매핑만** 적용하고 다른 이름을 임의로 목록에서 끌어오지 마세요.", + "전사에 없는 사람을 만들어내지 마세요.", ]; /** 회의 체크리스트 — 정의·목적·전·중·후 + 업무 체크리스트 AI 연동 */ @@ -708,13 +710,15 @@ async function transcribeMeetingAudio(openai, filePath, uiModel = DEFAULT_TRANSC */ async function generateMeetingMinutes(openai, { systemPrompt, userContent, uiModel, resolveApiModel, omitMeetingChecklistSection }) { const apiModel = resolveApiModel(uiModel || "gpt-5-mini"); + const namePrefix = meetingEmployeeNames.buildNameNormalizationUserPrefix(userContent); + const userPayload = namePrefix ? `${namePrefix}${userContent}` : userContent; const completion = await openai.chat.completions.create({ model: apiModel, messages: [ { role: "system", content: systemPrompt }, { role: "user", - content: `아래는 회의 원문 또는 전사입니다. 위 지시에 맞게 회의록을 작성해 주세요.\n\n---\n\n${userContent}`, + content: `아래는 회의 원문 또는 전사입니다. 위 지시에 맞게 회의록을 작성해 주세요.\n\n---\n\n${userPayload}`, }, ], });