/** * 회의록 AI: OpenAI 음성 전사 + Chat Completions 회의록 생성 */ const fsSync = require("fs"); const os = require("os"); const path = require("path"); const meetingEmployeeNames = require("./meeting-employee-names"); const parseCkFromMm = require("./parse-checklist-from-minutes"); const { execFile } = require("child_process"); const { promisify } = require("util"); const execFileAsync = promisify(execFile); /** OpenAI audio.transcriptions 요청당 파일 크기 상한(문서 기준 약 25MB) */ const OPENAI_TRANSCRIPTION_MAX_BYTES = 25 * 1024 * 1024; /** 단일 요청으로 보낼 때 여유를 둔 상한 */ const SAFE_SINGLE_REQUEST_BYTES = 24 * 1024 * 1024; /** ffmpeg 분할 시 세그먼트 길이(초). 16kHz mono PCM 기준 한 세그먼트가 API 한도를 넘지 않도록 설정 */ const FFMPEG_SEGMENT_SECONDS = 600; /** * gpt-4o-mini-transcribe / gpt-4o-transcribe: 요청당 "instructions + audio" 토큰 상한이 있어 * 길이·파일 크기가 작아도 한 파일 전체를 한 번에 보내면 400이 날 수 있음 → 짧게 분할 전사. * .env OPENAI_TRANSCRIBE_SEGMENT_SEC 로 조정 가능 (초, 기본 120). 한도 초과 시 30·15 등으로 낮춤. */ function getGpt4oTranscribeSegmentSeconds() { const n = Number(process.env.OPENAI_TRANSCRIBE_SEGMENT_SEC); if (Number.isFinite(n) && n >= 15 && n <= 600) return Math.floor(n); return 120; } /** 환경변수 미지정 시 기본 전사 모델 */ const DEFAULT_TRANSCRIPTION_MODEL = (process.env.OPENAI_WHISPER_MODEL || "gpt-4o-transcribe").trim(); /** @deprecated 하위 호환 — DEFAULT_TRANSCRIPTION_MODEL 과 동일 */ const DEFAULT_WHISPER_MODEL = DEFAULT_TRANSCRIPTION_MODEL; /** 화면·API 공통: 허용 전사 모델(OpenAI audio.transcriptions.model) */ const TRANSCRIPTION_UI_MODELS = new Set(["gpt-4o-mini-transcribe", "gpt-4o-transcribe"]); /** * @param {string} uiModel * @returns {string} OpenAI audio.transcriptions 에 넣을 model */ function resolveTranscriptionApiModel(uiModel) { const u = (uiModel || DEFAULT_TRANSCRIPTION_MODEL).trim(); if (TRANSCRIPTION_UI_MODELS.has(u)) return u; return DEFAULT_TRANSCRIPTION_MODEL; } /** * DB에 저장된 옵션으로 시스템 프롬프트 구성 * @param {object} settings - meeting_ai_prompts 행(카멜 또는 스네이크) */ const EMPLOYEE_NAME_GUIDANCE_MINIMAL = [ "【인명·담당자】", "참석자·담당자 이름은 **원문·전사에 실제로 등장한 표기**를 따릅니다. 음성 인식 오류로 같은 사람이 문맥상 확실할 때만 철자를 다듬습니다.", "사용자 메시지 상단에「이번 원문/전사 한정 · 임직원 표기 통일」블록이 있으면, **그 안의 매핑만** 적용하고 다른 이름을 임의로 목록에서 끌어오지 마세요.", "전사에 없는 사람을 만들어내지 마세요.", "발언자가 '우리', '우리 팀', '우리 회사', '저희 팀' 등으로 표현한 주체는 **회의 진행 요약·Q&A 등에서는** 원문 표현 그대로 유지하고, 문맥으로 특정 팀명·조직명을 추론하여 대체하지 마세요. (금지 예: '우리 팀(소프트웨어 운영팀)'처럼 괄호 보강) 단, 아래 【6) 액션 아이템】표의 **「담당」열**에서는 이 지칭을 쓸 수 없으며, 구체 이름·실제 조직·팀명이 없으면 **미정**만 둘 수 있습니다.", ]; /** 회의 체크리스트 — 정의·목적·전·중·후 + 업무 체크리스트 AI 연동 */ const MEETING_CHECKLIST_GUIDANCE_LINES = [ "【회의 체크리스트(Meeting Checklist)】", "정의: 회의가 원활하게 진행되고 목표를 달성할 수 있도록 사전에 준비하거나, 회의 후 검토해야 할 항목들을 목록화한 것입니다.", "목적: 준비 부족으로 인한 시간 낭비를 방지하고, 회의 전·중·후 전 과정을 구조화하여 효율을 높입니다.", "원문에서 도출 가능한 범위에서, 회의 전 준비·회의 중 진행 점검·회의 후 확인·후속 등을 [ ] 체크리스트 형태로 나열할 수 있습니다.", "업무 체크리스트 AI 연동: 위 체크리스트 섹션(예: ## 후속 확인 체크리스트, ## 회의 체크리스트)을 반드시 포함하고, 각 항목은 완료 여부를 파악·표시할 수 있는 형태(예: [ ] 항목, 체크박스)로 작성하세요. '사용자 추가 지시'에 체크리스트 관련 문구가 없더라도 이 요구는 동일하게 적용됩니다.", ]; function buildMeetingMinutesSystemPrompt(settings) { if (!settings || typeof settings !== "object") settings = {}; const includeChecklist = settings.includeChecklist === true || settings.include_checklist === true; const custom = (settings.customInstructions && String(settings.customInstructions)) || (settings.custom_instructions && String(settings.custom_instructions)) || ""; const lines = [ "당신은 사내 회의록 작성 전문가입니다. 음성 녹취록, 대화체 텍스트, 또는 회의 메모를 입력받아 구조화된 공식 회의록을 한국어로 작성합니다.", ]; if (custom.trim()) { lines.push("【규칙 우선순위】이 메시지 아래에「사용자 추가 지시」가 있으면, 섹션 구성·형식 등은 **사용자 추가 지시를 최우선**으로 따릅니다."); } EMPLOYEE_NAME_GUIDANCE_MINIMAL.forEach((line) => lines.push(line)); lines.push(""); lines.push("【출력 구조】반드시 아래 순서와 항목으로 작성하세요."); lines.push(""); lines.push("## 1) 회의 제목"); lines.push("- 회의 내용을 대표하는 명확한 제목을 1줄로 작성합니다."); lines.push("- 형식: [주제] + [목적/활동]"); lines.push(""); lines.push("## 2) 참석·언급 인원"); lines.push("- **표는 쓰지 않습니다.** 제목 다음 줄 내용은 **단일 줄**로, 원문·전사에 등장한 **인물·직함 호칭·조직·팀명**만 **한국어 쉼표(,)** 로 이어 적습니다."); lines.push("- 예시 형식(실제 이름·호칭·팀명은 반드시 **해당 회의 원문·전사**에서만 추출): `홍길동, 이영 팀장, 기획팀`."); lines.push("- **포함 기준:** 실명(예: 홍길동) 또는 원문의 직함·역할 호칭(예: 대표님, 팀장) 또는 명확한 조직·팀명(예: 인사총무팀)이 나온 경우만 나열합니다."); lines.push("- **제외 기준:** '발언자(미상)', '미상', '불명확', '알 수 없음' 등 이름·조직이 확인되지 않으면 빼세요."); lines.push("- **금지:** 구분·비고 형식의 표, 「참석」「언급됨」 비고 표기, 마크다운 표 전체를 이 섹션에 쓰지 마세요."); lines.push("- 동일 인물이 여러 호칭으로 불리더라도 하나의 대표 호칭으로만 적되, 불명하면 원문 표기를 우선합니다."); lines.push(""); lines.push("## 3) 회의 주제 및 요약"); lines.push("- **핵심 주제:** 1줄 요약"); lines.push("- **요약:** 3~5문장으로 회의 전체 흐름과 결론을 서술합니다."); lines.push("- 구어체·중복 내용은 정제하여 공식 문어체로 변환합니다."); lines.push(""); lines.push("## 4) 회의 진행 흐름에 따른 주제 변화"); lines.push("- 마크다운 표로 작성합니다: 순서 | 주제 | 내용 요약"); lines.push("- 회의 시작부터 종료까지 시간 순서대로 주제 전환을 기록합니다."); lines.push("- 각 주제는 2~3문장 이내로 요약합니다."); lines.push(""); lines.push("## 5) 회의 중 주요 질의응답"); lines.push("- `Q. 질문 내용` / `A. 답변 내용` 형식으로 작성합니다."); lines.push("- 명시적 질문이 없더라도 회의 중 제기된 핵심 쟁점을 Q&A 형태로 재구성합니다."); lines.push("- 발언자 불명확 시 역할로 표기합니다. (예: 대표이사, 팀장)"); lines.push(""); lines.push("## 6) 액션 아이템"); lines.push( '- **반드시 GitHub Flavored 마크다운 표** 로만 작성합니다. 열 순서 고정 **4열: `#`(순번) | 담당 | 내용 | 기한**(순번은 1부터 정수만). 헤더·구분선·데이터 **모든 행**은 **반드시 맨 앞·맨 뒤 파이프 `|` 포함** 규격을 따릅니다(렌더·자동연동 신뢰성).' ); lines.push( "- 헤더·구분선·데이터 각 행의 **열 개수(셀 분할 후 줄마다 같은 개수)·맨 앞과 맨 끝 `|` 존재**가 일치해야 합니다(**액션 아이템 표는 고정 네 열**: `#`(순번), 담당, 내용, 기한)." ); lines.push("【올바른 예시(형식 준수, 내용만 원문 반영)"); lines.push("| # | 담당 | 내용 | 기한 |"); lines.push("| --- | --- | --- | --- |"); lines.push("| 1 | 인사팀 | 요청 자료 작성 | 다음 주 |"); lines.push("| 2 | 김민수 | 관련 회신 확인 | 다음 주 |"); lines.push("【예시 끝】"); lines.push("- **담당 열(담당자):** 회의 원문·전사에 **성명 또는 실제 소속 조직·팀명으로 명확히 적힌 경우만** 기입합니다."); lines.push( "- **담당 열 금지:** 「발언자」「질문자」「저희」「우리 팀」「우리 회사」「우리」(단독 지칭)「본인」「해당 부서」처럼 특정인·실제 조직을 지목하지 않는 호칭은 담당 열에 두지 마십시오." ); lines.push( '- **직무·역할 명칭 단독**(예: 「엔지니어」「PM」「디자이너」만 들어 있는 경우) 또는 인명 없이 역할 이름만 들어 있는 경우에는 「담당」에 두지 말고 **미정**(또는 TBD)을 씁니다.' ); lines.push( '- 담당 주체가 원문만으로 특정되지 않으면 **내용·기한**은 채우고, **담당** 칸은 **미정** 또는 **TBD**만 허용합니다(빈 칸 금지).' ); lines.push( '- 누가 수행해야 하는지는 **내용** 칸 서술에 보조할 수 있으나, **담당** 칸은 위 기준만 따릅니다.' ); lines.push("- **금지 형식**: 헤더는 3열(`| 담당 | 내용 | 기한`)인데 데이터 행만 4열인 경우, 헤더엔 순번 줄을 안 쓰고 데이터 줄 맨 앞에만 번호 넣기, 시작/끝 `|` 빠진 행 — 이런 경우 브라우저에서 표가 깨져 보입니다."); lines.push("- 셀 내부에 `|` 문자가 들어가면 안 됩니다. 바꿀 표현으로 씁니다."); lines.push("- 회의에서 언급된 모든 후속 과제를 빠짐없이 추출합니다."); lines.push("- 기한이 명시되지 않은 경우 문맥 기반으로 추정하여 기재합니다. (예: 다음 주, 1개월 이내)"); lines.push("- 담당자·기한이 원문에 전혀 없으면 '미정'·'TBD'로 표기합니다."); if (includeChecklist) { lines.push(""); MEETING_CHECKLIST_GUIDANCE_LINES.forEach((line) => lines.push(line)); } lines.push(""); lines.push("【작성 규칙】"); lines.push("1. 언어: 구어체 입력이라도 출력은 반드시 공식 문어체 존댓말로 변환합니다."); lines.push("2. 정보 보완: 참석자·일시 등 누락 정보는 ※ 확인 필요 주석으로 표시합니다."); lines.push("3. 맥락 추론: 발언 **의도·내용**이 불명확한 경우에 한해 전후 문맥으로 추론하되, 추론 시 (추정) 표기합니다. 단, 발언 **주체(사람·팀·조직)**는 추론하여 만들지 마세요. **단, 【6) 액션 아이템】의 「담당」열에서는** 이름·실제 조직·팀명이 없으면 반드시 **미정**(또는 TBD); 「발언자」「우리 팀」「저희」 같은 지칭은 **담당 열 금지**입니다. 회의 진행·Q&A 본문 등 다른 곳에서는 원문 호칭을 유지할 수 있습니다."); lines.push("4. 중립성 유지: 특정 발언자에 유리하거나 불리한 방향으로 편집하지 않습니다."); lines.push("5. 비고 처리: 원문에서 명확히 파악되지 않는 사항(발언자 미상, 참석자 불명, 담당자 미확인 등)은 해당 섹션 내부 또는 섹션 6 아래에 `※ 비고` 평문 각주로만 표기합니다. 반드시 `##` 제목의 별도 섹션으로 만들지 마세요."); lines.push( '6. 표 형식: 4)·5)·6)·체크리스트 등 **표가 필요한 섹션**에서만 마크다운 표를 씁니다. 구분 줄은 각 열마다 `---`만(정렬 마커 `:---` 등 금지). **행마다 줄 선두·줄 말미에 반드시 `|` 를 둘 것.** **6) 액션 아이템 은 헤더 4열 고정 및 위 예시 규격 엄수.** **2) 참석·언급 인원은 표 없이 쉼표 목록 한 줄입니다.**' ); lines.push(""); lines.push("【금지 사항】"); lines.push("- 음성 전사·회의 원문 전체를 회의록 본문에 다시 붙여 넣지 마세요. 원문/전사는 시스템에서 별도 필드로 이미 보관됩니다."); lines.push("- '스크립트', '스크랩트'(오타), '원문 전사', '전사문', 'Verbatim' 등 원문을 통째로 실어 나르는 섹션을 만들지 마세요."); lines.push("- '추가 권고', '회의록 작성자의 제안', 엑셀 담당자 배정 템플릿·추적 체크리스트 제안 등 회의 본문과 무관한 조언·제안 섹션을 두지 마세요."); lines.push("- 액션 아이템 이후에 `##` 제목의 추가 섹션(예: '추가 메모', '확인 필요 사항')을 두지 마세요. 불명확 사항은 위 규칙 5(※ 비고 평문 각주)로만 처리하세요."); lines.push( '- 액션 아이템 표 **「담당」열**에는 실명 또는 원문 기준 실제 조직·팀명 외 문자열 금지(「발언자」「저희」「우리 팀」「우리」 호칭 전용 포함).' ); lines.push("- (일반) 원문에 없는 인명·팀명을 문맥으로 추측하여 괄호로 채워 넣는 것(예: '우리 팀(운영팀)')은 회의 진행 요약 등에도 두지 마십시오."); if (!includeChecklist) { lines.push("- '## 회의 체크리스트', '## 후속 확인 체크리스트' 같은 체크리스트 전용 섹션을 만들지 마세요."); } if (custom.trim()) { lines.push(""); lines.push("사용자 추가 지시:"); lines.push(custom.trim()); } return lines.join("\n"); } /** * 스크립트/스크랩트(오타) 등 원문 통째 반복 섹션의 제목인지 (마크다운 # 제목) * @param {string} title */ function isVerbatimScriptSectionTitle(title) { const t = (title || "").trim(); if (!t) return false; if (/^원문\s*전사/.test(t)) return true; if (/^전사문(\s|$)/.test(t)) return true; if (/^Verbatim/i.test(t)) return true; if (/^Full\s+transcript/i.test(t)) return true; if (/^스크랩트/i.test(t)) return scriptSectionRestIsVerbatim(t, /^스크랩트/i); if (/^스크립트/i.test(t)) return scriptSectionRestIsVerbatim(t, /^스크립트/i); return false; } function scriptSectionRestIsVerbatim(fullTitle, prefixRe) { const m = prefixRe.exec(fullTitle); if (!m) return false; const rest = fullTitle.slice(m[0].length).trim(); if (!rest) return true; if (/^[\(:(]/.test(rest)) return true; if (/^(검토|논의|요약|개선|작성|확인|점검)(\b|[\s,.])/i.test(rest)) return false; return true; } /** * # 없이 한 줄로만 쓴 스크립트/스크랩트 블록 시작인지 * @param {string} line */ function isPlainScriptSectionStartLine(line) { const raw = String(line || "").trim(); if (!raw) return false; if (/^#{1,6}\s/.test(raw)) return false; const t = raw.replace(/^\*\*\s*|\s*\*\*$/g, "").trim(); if (/^스크랩트/i.test(t)) return scriptSectionRestIsVerbatim(t, /^스크랩트/i); if (/^스크립트/i.test(t)) return scriptSectionRestIsVerbatim(t, /^스크립트/i); return false; } /** 스크립트 블록 다음에 오는 일반 회의록 단락(제목 없이 시작하는 경우)에서 스킵 중단 */ function isLikelyMinutesSectionPlainLine(line) { const t = String(line || "").trim(); if (!t) return false; if (/^#{1,6}\s/.test(t)) return false; return ( /^회의\s*제목\s*[::]/.test(t) || /^참석자\s*[\((::]/.test(t) || /^요약\s*[\((]/.test(t) || /^논의\s*안건/.test(t) || /^논의\s*요약/.test(t) || /^결정\s*사항/.test(t) || /^액션\s*아이템/.test(t) || /^회의\s*체크리스트/.test(t) || /^후속\s*확인/.test(t) || /^회의\s*개요/.test(t) || /^회의\s*일시/.test(t) || /^목적\s*[::]/.test(t) ); } /** * 회의록 마크다운에서 원문/전사를 반복하는 섹션 제거 (전사·원문은 별도 필드에 있음) * @param {string} markdown * @returns {string} */ function stripVerbatimScriptSections(markdown) { const text = String(markdown || ""); const lines = text.split("\n"); const out = []; let i = 0; while (i < lines.length) { const line = lines[i]; const hm = /^(#{1,6})\s+(.+)$/.exec(line); if (hm && isVerbatimScriptSectionTitle(hm[2])) { const level = hm[1].length; i++; while (i < lines.length) { const L = lines[i]; if (isLikelyMinutesSectionPlainLine(L)) break; const th = /^(#{1,6})\s+(.+)$/.exec(L); if (th) { const lv = th[1].length; if (lv < level) break; if (lv === level && !isVerbatimScriptSectionTitle(th[2])) break; } i++; } continue; } if (isPlainScriptSectionStartLine(line)) { i++; while (i < lines.length) { const L = lines[i]; if (isLikelyMinutesSectionPlainLine(L)) break; const t = L.trim(); if (/^#{1,6}\s/.test(t)) break; i++; } continue; } out.push(line); i++; } return out.join("\n").replace(/\n{3,}/g, "\n\n").trim(); } /** * 구형/비마크다운 회의록: '회의 제목:', '참석자', '요약' 등 단락을 ## 제목으로 승격해 뷰에서 제목 크기가 나오게 함 * @param {string} markdown * @returns {string} */ function enhanceMeetingMinutesHeadingLines(markdown) { const lines = String(markdown || "").split("\n"); const out = []; const headingRes = [ /^회의\s*제목\s*[::]/, /^참석자\s*[\((::]/, /^참석자\s*$/i, /^요약\s*[\((]/, /^논의\s*안건/, /^논의\s*요약/, /^결정\s*사항/, /^액션\s*아이템/, /^회의\s*체크리스트/, /^후속\s*확인/, /^회의\s*개요/, /^회의\s*일시/, /^목적\s*[::]/, ]; for (const line of lines) { const trimmedEnd = line.trimEnd(); const t = trimmedEnd.trim(); if (!t) { out.push(line); continue; } if (/^#{1,6}\s/.test(t)) { out.push(line); continue; } if (/^\s*[-*•]|^\s*\d+[.)]\s/.test(line)) { out.push(line); continue; } if (/^>\s/.test(t)) { out.push(line); continue; } let asHeading = false; for (const re of headingRes) { if (re.test(t)) { asHeading = true; break; } } if (asHeading) { out.push("## " + t); } else { out.push(line); } } return out.join("\n"); } /** * 체크리스트 이후 말미에 붙는 제목(추가 메모·추가 권고 등) * @param {string} title */ function isAdditionalNotesSectionTitle(title) { const t = (title || "").trim(); if (!t) return false; if (/추가\s*메모/i.test(t)) return true; if (/확인\s*필요\s*사항/i.test(t) && /(추가|메모|\/\s*확인)/i.test(t)) return true; return false; } function isTrailingJunkSectionTitle(title) { if (isAdditionalNotesSectionTitle(title)) return true; const t = (title || "").trim(); if (!t) return false; if (/추가\s*권고/i.test(t)) return true; if (/회의록\s*작성자의\s*제안/i.test(t)) return true; return false; } function isPlainTrailingJunkSectionStartLine(line) { const t = String(line || "").trim(); if (!t || /^#{1,6}\s/.test(t)) return false; if (/^\s*[-*•\d]/.test(line)) return false; if (/추가\s*메모/i.test(t)) return true; if (/확인\s*필요\s*사항/i.test(t) && /추가\s*메모|메모\s*\//i.test(t)) return true; if (/추가\s*권고/i.test(t)) return true; if (/회의록\s*작성자의\s*제안/i.test(t)) return true; return false; } function stripTrailingJunkSectionsFromStart(markdown) { const lines = String(markdown || "").split("\n"); const out = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const hm = /^(#{1,6})\s+(.+)$/.exec(line); if (hm && isTrailingJunkSectionTitle(hm[2])) { break; } if (isPlainTrailingJunkSectionStartLine(line)) { break; } out.push(line); } return out.join("\n").replace(/\n{3,}/g, "\n\n").trim(); } /** ## 회의 체크리스트 등 마지막 체크리스트 제목 */ function isMeetingChecklistSectionTitle(title) { const t = (title || "").trim(); if (!t) return false; if (/회의\s*체크리스트|후속\s*확인\s*체크리스트|후속\s*체크리스트/i.test(t)) return true; if (/^체크리스트\s*[\((]/.test(t)) return true; if (/^체크리스트\s*$/i.test(t)) return true; return false; } /** * 모델이 덧붙이는 메타 문장(체크리스트 섹션 직후 등) * @param {string} t */ function isChecklistMetaParentheticalLine(t) { const s = String(t || "").trim(); if (!s || s.length > 200) return false; if (!/^\(/.test(s) || !/\)$/.test(s)) return false; if (/체크리스트/.test(s) && /(작성|마지막|섹션)/.test(s)) return true; return false; } /** 체크리스트 항목 뒤에 붙는 운영/후속 안내 문단(제거 대상) */ function isPostChecklistBoilerplateLine(t) { const s = String(t || "").trim(); if (!s) return false; if (isChecklistMetaParentheticalLine(s)) return true; if (/필요\s*시\s*시연/i.test(s)) return true; if (/필요\s*시\s*위\s*액션\s*아이템별/i.test(s)) return true; if (/피드백\s*제출\s*방식/i.test(s) && /(문서|슬랙|이메일)/i.test(s)) return true; if (/우선순위\s*\(\s*긴급/i.test(s)) return true; if (/지금\s*바로\s*준비해/i.test(s)) return true; if (/DRM\s*파일\s*리스트/i.test(s) && /(작성|범위)/i.test(s)) return true; if (/1\s*~\s*2팀\s*후보군|후보군\s*제안/i.test(s)) return true; if (/담당자와\s*구체\s*기한/i.test(s) && /Timeline|타임라인/i.test(s)) return true; if (/추가\s*메모/i.test(s) && /확인\s*필요/i.test(s)) return true; if (/^추가\s*메모|^추가\s*메모\s*\//i.test(s)) return true; if (/^확인\s*필요\s*사항/i.test(s)) return true; if (/원하시면\s*각\s*액션\s*아이템/i.test(s)) return true; if (/담당자\s*배정\s*템플릿/i.test(s)) return true; if (/추적용\s*체크리스트/i.test(s)) return true; if (/엑셀\s*형식/i.test(s) && /(템플릿|배정|체크리스트)/i.test(s)) return true; if (/어떤\s*항목부터\s*우선\s*정리/i.test(s)) return true; if (/추가\s*권고/i.test(s) && /(회의록|제안|작성자)/i.test(s)) return true; return false; } /** * 마지막 「회의 체크리스트」 ## 섹션 이후의 말미(안내 문단·추가 섹션) 제거 * @param {string} markdown * @returns {string} */ function stripTrailingAfterMeetingChecklistSection(markdown) { const lines = String(markdown || "").split("\n"); let startIdx = -1; for (let i = 0; i < lines.length; i++) { const hm = /^(##)\s+(.+)$/.exec(lines[i].trimEnd()); if (!hm) continue; if (isMeetingChecklistSectionTitle(hm[2])) { startIdx = i; } } if (startIdx < 0) return markdown; const out = lines.slice(0, startIdx + 1); let seenListAfterChecklist = false; for (let i = startIdx + 1; i < lines.length; i++) { const line = lines[i]; const t = line.trim(); if (!t) { out.push(line); continue; } if (/^#{1,6}\s/.test(t)) { const titleOnly = t.replace(/^#+\s+/, "").trim(); if (isTrailingJunkSectionTitle(titleOnly)) { break; } const level = (t.match(/^(#+)/) || [""])[0].length; if (level <= 2) { if (isMeetingChecklistSectionTitle(titleOnly)) { out.push(line); continue; } break; } out.push(line); continue; } if (/^\s*[-*•]/.test(line) || /^\s*\d+[.)]\s/.test(line)) { seenListAfterChecklist = true; out.push(line); continue; } if (/^\s*\|/.test(line)) { out.push(line); continue; } if (/^>\s/.test(t)) { out.push(line); continue; } if (isPlainTrailingJunkSectionStartLine(line)) { break; } if (seenListAfterChecklist && isPostChecklistBoilerplateLine(t)) { break; } out.push(line); } return out.join("\n").replace(/\n{3,}/g, "\n\n").trim(); } /** * 문서 전체에서 알려진 말미 안내 한 줄 제거(체크리스트 밖에 남은 경우) * @param {string} markdown * @returns {string} */ function removeKnownBoilerplateLines(markdown) { return String(markdown || "") .split("\n") .filter((line) => { const t = line.trim(); if (!t) return true; if (isPostChecklistBoilerplateLine(t)) return false; return true; }) .join("\n") .replace(/\n{3,}/g, "\n\n") .trim(); } /** * `## 회의 체크리스트` 등 제목부터 다음 `##`(동일 레벨 다른 섹션) 전까지 제거. 반복 호출로 다중 블록 제거. * @param {string} markdown * @returns {string} */ function stripFirstMeetingChecklistSectionBlock(markdown) { const lines = String(markdown || "").split("\n"); let start = -1; for (let i = 0; i < lines.length; i++) { const hm = /^(##)\s+(.+)$/.exec(lines[i].trimEnd()); if (hm && isMeetingChecklistSectionTitle(hm[2])) { start = i; break; } } if (start < 0) return markdown; let end = lines.length; for (let j = start + 1; j < lines.length; j++) { const hm = /^(##)\s+(.+)$/.exec(lines[j].trimEnd()); if (hm) { end = j; break; } } const out = [...lines.slice(0, start), ...lines.slice(end)]; return out.join("\n").replace(/\n{3,}/g, "\n\n").trim(); } function stripAllMeetingChecklistSectionBlocks(markdown) { let md = String(markdown || ""); for (let k = 0; k < 24; k++) { const next = stripFirstMeetingChecklistSectionBlock(md); if (next === md) break; md = next; } return md; } /** * 액션 표「담당」셀이 일반 호칭·지칭이면 업무 규격에 맞게 **미정**으로 바꿉니다. */ function sanitizeActionItemAssigneeCell(assigneeRaw) { const vOrig = String(assigneeRaw ?? "") .trim() .replace(/\s+/g, " ") .replace(/|/g, "|"); if (!vOrig) return "미정"; if (/^미정$/i.test(vOrig)) return "미정"; if (/^TBD$/i.test(vOrig)) return "TBD"; /** @param {string} seg */ const segmentIsForbiddenGeneric = (seg) => { const v = seg.replace(/\([^)]*\)/g, "").trim(); if (!v) return true; return ( /발언자|질문자|발언측/i.test(v) || /^저희(\s+[팀회사])*$/u.test(v) || /^우리\s*$/u.test(v) || /^우리\s+(팀|회사)$/u.test(v) || /^당사$/u.test(v) || /^해당\s*부서$/u.test(v) || /^본인$/u.test(v) || /^미상$/u.test(v) || /^담당자\s*미상$/u.test(v) || /^직원$/u.test(v) || /^(엔지니어|개발\s*담당자?|디자이너|기획자|PM|P\.M\.|QA|테스터|운영)$/iu.test(v) ); }; const segments = vOrig .split(/[//]/) .map((p) => p.trim()) .filter(Boolean); if (segments.length === 0) return "미정"; /** "인사팀 / 발언자"처럼 금지 조각이 섞여 있어도 담당 특정 불가로 통일합니다 */ if (segments.some(segmentIsForbiddenGeneric)) return "미정"; return vOrig; } function rebuildMarkdownTableRowFromCells(cells) { const parts = cells.map((c) => String(c ?? "").trim()); return "| " + parts.join(" | ") + " |"; } /** * 【6) 액션 아이템】 블록 안 표 데이터 행(첫 칸 순번 숫자)의 담당 열만 정제합니다. * @param {string} markdown * @returns {string} */ function sanitizeActionItemAssigneesInMarkdown(markdown) { const lines = String(markdown || "").split(/\r?\n/); /** @type {string[]} */ const out = []; let i = 0; const canonLn = (ln) => String(ln || "").trimEnd().replace(/|/g, "|"); while (i < lines.length) { const raw = lines[i]; const ts = raw.trim(); if (/^##\s+/.test(ts)) { out.push(raw); const isActionHeading = /액션\s*아이템/.test(ts) || /^##\s+Action\s+Items\b/i.test(ts); if (!isActionHeading) { i++; continue; } i++; let secEnd = lines.length; for (let k = i; k < lines.length; k++) { const v = lines[k].trim(); if (v && /^##\s+/.test(v)) { secEnd = k; break; } } let jj = i; while (jj < secEnd) { if (canonLn(lines[jj]).trim() === "") { out.push(lines[jj]); jj++; continue; } const trimmed = canonLn(lines[jj]).trim(); const pipeRow = trimmed.startsWith("|") || (/^\d+\s*\|/.test(trimmed) && !trimmed.startsWith("|")); if (!pipeRow) { out.push(lines[jj]); jj++; continue; } if (parseCkFromMm.isTableSeparatorRow(trimmed)) { out.push(lines[jj]); jj++; continue; } const cells = parseCkFromMm.parseTableCells(lines[jj]); if ( cells.length >= 4 && /^\d+$/.test(String(cells[0] || "").trim()) ) { const assigneeIx = 1; const next = [...cells]; next[assigneeIx] = sanitizeActionItemAssigneeCell(next[assigneeIx]); out.push(rebuildMarkdownTableRowFromCells(next)); jj++; } else { out.push(lines[jj]); jj++; } } i = secEnd; continue; } out.push(raw); i++; } return out.join("\n"); } /** * GFM 규격: 헤더·구분·데이터 열 개수 불일치로 깨진 액션 아이템 파이프블록을 교정합니다. * `prepareMeetingMinutesForApi`에서 호출되어 저장·표시 전에 적용됩니다. * * @param {string[]} lines 전체 줄 * @param {number} start 의심 표 첫 줄(헤더) * @param {number} sectionEndExclusive 같은 절 안에서 다음 `##` 직전 인덱스 * @returns {{ replacement: string[], nextIndex: number } | null} */ function tryNormalizeBrokenActionItemTable(lines, start, sectionEndExclusive) { /** @param {string} ln */ const canon = (ln) => String(ln || "").trimEnd().replace(/|/g, "|"); const headerTrim = canon(lines[start]).trim(); if ( !headerTrim.includes("|") || !headerTrim.includes("담당") || !headerTrim.includes("내용") || !headerTrim.includes("기한") ) { return null; } const hdrCells = parseCkFromMm.parseTableCells(lines[start]); if (!hdrCells || hdrCells.length !== 3) return null; let j = start + 1; /** @type {string[]} */ const leadingBlankLines = []; while (j < sectionEndExclusive) { const u = canon(lines[j]).trim(); if (u !== "") break; leadingBlankLines.push(lines[j]); j++; } if (j < sectionEndExclusive) { const maybeSep = canon(lines[j]).trim(); if (parseCkFromMm.isTableSeparatorRow(maybeSep)) j++; } if (j >= sectionEndExclusive) return null; const firstCells = parseCkFromMm.parseTableCells(lines[j]); if ( !firstCells || firstCells.length < 4 || !/^\d+$/.test(String(firstCells[0] || "").trim()) ) { return null; } /** @type {string[]} */ const rebuilt = []; rebuilt.push("| # | 담당 | 내용 | 기한 |"); rebuilt.push("| --- | --- | --- | --- |"); let p = j; while (p < sectionEndExclusive) { const cand = canon(lines[p]).trim(); if (!cand) break; if (cand.startsWith("※")) break; const cells = parseCkFromMm.parseTableCells(lines[p]); if (!cells || cells.length < 4 || !/^\d+$/.test(String(cells[0] || "").trim())) break; const num = String(cells[0]).trim(); const assignee = String(cells[1] || "").trim(); const due = String(cells[cells.length - 1] || "").trim(); const body = cells .slice(2, cells.length - 1) .map((c) => c.trim()) .join(" ") .replace(/\s+/g, " ") .trim(); rebuilt.push(`| ${num} | ${sanitizeActionItemAssigneeCell(assignee)} | ${body} | ${due} |`); p++; } if (rebuilt.length < 3) return null; return { replacement: [...leadingBlankLines, ...rebuilt], nextIndex: p, }; } /** * 「## … 액션 아이템」(Action Items) 다음 절에서 위 깨진 표만 반복 교정합니다. * @param {string} markdown * @returns {string} */ function normalizeActionItemsMarkdownTables(markdown) { const lines = String(markdown || "").split(/\r?\n/); /** @param {string} ln */ const canonLn = (ln) => String(ln || "").trimEnd().replace(/|/g, "|"); /** @type {string[]} */ const out = []; let i = 0; while (i < lines.length) { const raw = lines[i]; const ts = raw.trim(); if (/^##\s+/.test(ts)) { out.push(raw); const isActionHeading = /액션\s*아이템/.test(ts) || /^##\s+Action\s+Items\b/i.test(ts); if (!isActionHeading) { i++; continue; } i++; let secEnd = lines.length; for (let k = i; k < lines.length; k++) { const v = lines[k].trim(); if (v && /^##\s+/.test(v)) { secEnd = k; break; } } let jj = i; while (jj < secEnd) { if (canonLn(lines[jj]).trim() === "") { out.push(lines[jj]); jj++; continue; } const patch = tryNormalizeBrokenActionItemTable(lines, jj, secEnd); if (patch) { patch.replacement.forEach((ln) => out.push(ln)); jj = patch.nextIndex; } else { out.push(lines[jj]); jj++; } } i = secEnd; continue; } out.push(raw); i++; } return out.join("\n"); } /** * API·저장·생성 공통: 스크립트 제거 → (옵션) 체크리스트 섹션 삭제 → 체크리스트 이후 말미 정리 → … * @param {string} markdown * @param {{ omitMeetingChecklistSection?: boolean }} [options] * @returns {string} */ function prepareMeetingMinutesForApi(markdown, options = {}) { let md = stripVerbatimScriptSections(markdown); if (options.omitMeetingChecklistSection === true) { md = stripAllMeetingChecklistSectionBlocks(md); } md = stripTrailingAfterMeetingChecklistSection(md); md = removeKnownBoilerplateLines(md); md = stripTrailingJunkSectionsFromStart(md); md = normalizeActionItemsMarkdownTables(md); md = sanitizeActionItemAssigneesInMarkdown(md); return enhanceMeetingMinutesHeadingLines(md); } /** * @param {import("openai").default} openai * @param {string} filePath * @param {string} [uiModel] - gpt-4o-mini-transcribe | gpt-4o-transcribe */ async function transcribeMeetingAudioOnce(openai, filePath, uiModel = DEFAULT_TRANSCRIPTION_MODEL) { const apiModel = resolveTranscriptionApiModel(uiModel); const stream = fsSync.createReadStream(filePath); /** gpt-4o 전사 계열은 문서상 response_format 이 json 제한인 경우가 많음 */ const isGpt4oStyleTranscribe = apiModel.startsWith("gpt-4o") && apiModel.includes("transcribe") && !apiModel.includes("diarize"); const params = { file: stream, model: apiModel, language: "ko", }; if (isGpt4oStyleTranscribe) { params.response_format = "json"; } const transcription = await openai.audio.transcriptions.create(params); const raw = transcription?.text; return (typeof raw === "string" ? raw : "").trim(); } /** * 16kHz mono PCM WAV로 분할(세그먼트당 OpenAI 한도 이하). 서버에 ffmpeg 필요. * @param {string} inputPath * @param {number} [segmentSeconds] - 기본 FFMPEG_SEGMENT_SECONDS (gpt-4o 전사는 더 짧게) * @returns {{ files: string[], tmpDir: string }} */ async function ffmpegSplitAudioForTranscription(inputPath, segmentSeconds) { const seg = typeof segmentSeconds === "number" && segmentSeconds >= 15 && segmentSeconds <= 3600 ? segmentSeconds : FFMPEG_SEGMENT_SECONDS; const tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "mm-tr-")); const outPattern = path.join(tmpDir, "seg_%03d.wav"); try { await execFileAsync("ffmpeg", [ "-hide_banner", "-loglevel", "error", "-y", "-i", inputPath, "-f", "segment", "-segment_time", String(seg), "-reset_timestamps", "1", "-ac", "1", "-ar", "16000", "-acodec", "pcm_s16le", outPattern, ]); } catch (e) { try { fsSync.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) { /* ignore */ } const code = e && e.code; if (code === "ENOENT") { throw new Error( "25MB를 초과하는 음성은 ffmpeg로 분할해 전사합니다. 서버에 ffmpeg가 설치되어 PATH에 있어야 합니다. " ); } throw new Error((e && e.message) || "ffmpeg 음성 분할에 실패했습니다."); } const files = fsSync .readdirSync(tmpDir) .filter((f) => /^seg_\d+\.wav$/.test(f)) .sort() .map((f) => path.join(tmpDir, f)); if (!files.length) { try { fsSync.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) { /* ignore */ } throw new Error("ffmpeg 분할 결과가 비어 있습니다."); } for (const f of files) { if (fsSync.statSync(f).size > OPENAI_TRANSCRIPTION_MAX_BYTES) { try { fsSync.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) { /* ignore */ } throw new Error("분할된 세그먼트가 여전히 너무 큽니다. 관리자에게 문의하세요."); } } return { files, tmpDir }; } /** * ffmpeg 분할 후 각 구간 파일을 순서대로 전사해 합칩니다(OpenAI 호출 순차 처리). * @param {import("openai").default} openai * @param {string} filePath * @param {string} [uiModel] * @param {number} [segmentSeconds] - ffmpeg 분할 길이(초) * @param {(p: {stage: string, done: number, total: number}) => void} [onProgress] */ async function transcribeMeetingAudioChunked(openai, filePath, uiModel, segmentSeconds, onProgress) { const { files, tmpDir } = await ffmpegSplitAudioForTranscription(filePath, segmentSeconds); if (onProgress) onProgress({ stage: "init", done: 0, total: files.length }); try { const parts = []; for (let i = 0; i < files.length; i++) { const text = await transcribeMeetingAudioOnce(openai, files[i], uiModel); parts.push(text); const done = i + 1; if (onProgress) onProgress({ stage: "transcribe", done, total: files.length }); } return parts.join("\n\n").trim(); } finally { try { fsSync.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) { /* ignore */ } } } /** * 단일 파일 전사. OpenAI 요청당 한도(약 25MB)를 넘으면 ffmpeg로 분할 후 구간 순차 전사. * gpt-4o 전사 계열은 오디오 토큰 상한으로, 용량이 작아도 한 파일 전체를 한 번에 보내면 400이 날 수 있어 짧게 분할한다. * @param {import("openai").default} openai * @param {string} filePath * @param {string} [uiModel] - gpt-4o-mini-transcribe | gpt-4o-transcribe * @param {(p: {stage: string, done: number, total: number}) => void} [onProgress] */ async function transcribeMeetingAudio(openai, filePath, uiModel = DEFAULT_TRANSCRIPTION_MODEL, onProgress) { const apiModel = resolveTranscriptionApiModel(uiModel); const isGpt4oStyleTranscribe = apiModel.startsWith("gpt-4o") && apiModel.includes("transcribe") && !apiModel.includes("diarize"); const size = fsSync.statSync(filePath).size; const gpt4oSeg = getGpt4oTranscribeSegmentSeconds(); if (isGpt4oStyleTranscribe) { return transcribeMeetingAudioChunked(openai, filePath, uiModel, gpt4oSeg, onProgress); } if (size <= SAFE_SINGLE_REQUEST_BYTES) { if (onProgress) onProgress({ stage: "init", done: 0, total: 1 }); const result = await transcribeMeetingAudioOnce(openai, filePath, uiModel); if (onProgress) onProgress({ stage: "transcribe", done: 1, total: 1 }); return result; } return transcribeMeetingAudioChunked(openai, filePath, uiModel, undefined, onProgress); } /** * @param {import("openai").default} openai * @param {object} opts * @param {string} opts.systemPrompt * @param {string} opts.userContent * @param {string} opts.uiModel - gpt-5-mini | gpt-5.4 * @param {(m: string) => string} opts.resolveApiModel */ async function generateMeetingMinutes(openai, { systemPrompt, userContent, uiModel, resolveApiModel, omitMeetingChecklistSection }) { const apiModel = resolveApiModel(uiModel || "gpt-5.4"); 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${userPayload}`, }, ], }); const raw = (completion.choices?.[0]?.message?.content || "").trim(); return prepareMeetingMinutesForApi(raw, { omitMeetingChecklistSection: omitMeetingChecklistSection === true }); } const CHECKLIST_EXTRACT_SYSTEM = `You extract actionable work items from Korean meeting minutes (Markdown). Return ONLY valid JSON with this exact shape (no markdown fence, no extra keys): {"items":[{"title":"string","detail":"string or empty","assignee":"string or empty","dueNote":"string or empty"}]} Priority (Action Items are as important as checklists — they are real work): 1) **Action Items / 액션 아이템** sections (headings like "## 6) 액션 아이템" or "## 액션 아이템"): - If the section contains a **Markdown table**: expect **four columns**: # | 담당 | 내용 | 기한 (header may mistakenly omit the first column '#' / 순번, but data rows often have numeric first cells — map columns by header names when clear, otherwise treat consecutive cells as 순번→담당→내용→기한). - **Each logical table row becomes a separate item.** Map **내용** column → title, **담당** → assignee, **기한** → dueNote. Leading **#**(순번) is not the title unless 내용 column is absent. - If the section contains **numbered lines** (1. 2. 3. or 1) 2) 3)): each numbered line becomes a separate item. Title = the line's main task name; put 담당/기한/할 일 sub-lines into assignee, dueNote, detail. 2) **회의 체크리스트 / 후속 확인 체크리스트** sections: Each [ ] or bullet follow-up as one item. 3) If an action item has 담당:, 기한:, 할 일: sub-lines, map them to assignee, dueNote, detail. - For assignee (담당): use only literal person names or real org or team names as printed in those minutes or table cells. Put an empty assignee string or 미정 when the cell would otherwise be placeholders such as 발언자, 저희, 우리 팀, 우리 alone, or 해당 부서. Do not invent names. - Do not merge multiple rows or numbered actions into one item. - Deduplicate only exact duplicate titles. - If nothing found, return {"items":[]}. - All human-readable text in Korean.`; /** * 회의록 Markdown에서 구조화 체크리스트 추출 (JSON). 업무 체크리스트 DB 자동 반영용. * @param {import("openai").default} openai * @param {{ minutesMarkdown: string, uiModel: string, resolveApiModel: (m: string) => string }} opts * @returns {Promise>} */ async function extractChecklistStructured(openai, { minutesMarkdown, uiModel, resolveApiModel }) { const apiModel = resolveApiModel(uiModel || "gpt-5.4"); const text = (minutesMarkdown || "").trim(); if (!text) return []; const completion = await openai.chat.completions.create({ model: apiModel, response_format: { type: "json_object" }, messages: [ { role: "system", content: CHECKLIST_EXTRACT_SYSTEM }, { role: "user", content: "아래 회의록에서 업무 항목을 JSON으로 추출하세요.\n" + "액션 아이템(마크다운 표 또는 번호 목록)과 회의 체크리스트 항목을 모두 포함하고, 행·번호마다 별도 item으로 나누세요.\n\n" + "---\n\n" + text, }, ], }); const raw = (completion.choices?.[0]?.message?.content || "{}").trim(); let parsed; try { parsed = JSON.parse(raw); } catch { return []; } const arr = Array.isArray(parsed.items) ? parsed.items : []; return arr .map((x) => { const title = String(x.title || x.Title || "").trim(); const detail = String(x.detail ?? x.Detail ?? "").trim(); const assigneeRaw = x.assignee ?? x.Assignee ?? x.담당; const dueRaw = x.dueNote ?? x.due_note ?? x.DueNote ?? x.기한; return { title, detail, assignee: assigneeRaw != null && String(assigneeRaw).trim() ? String(assigneeRaw).trim() : null, due_note: dueRaw != null && String(dueRaw).trim() ? String(dueRaw).trim() : null, completed: false, }; }) .filter((x) => x.title.length > 0); } module.exports = { buildMeetingMinutesSystemPrompt, stripVerbatimScriptSections, stripTrailingJunkSectionsFromStart, stripAdditionalNotesSection: stripTrailingJunkSectionsFromStart, prepareMeetingMinutesForApi, enhanceMeetingMinutesHeadingLines, transcribeMeetingAudio, generateMeetingMinutes, extractChecklistStructured, DEFAULT_TRANSCRIPTION_MODEL, DEFAULT_WHISPER_MODEL, TRANSCRIPTION_UI_MODELS, resolveTranscriptionApiModel, };