/** * 회의록 AI: OpenAI 음성 전사 + Chat Completions 회의록 생성 */ const fsSync = require("fs"); const os = require("os"); const path = require("path"); 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-mini-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 행(카멜 또는 스네이크) */ /** 액션 아이템 — 형식은 사용자 추가 지시 우선(What/Who/When 강제 없음) */ const ACTION_ITEMS_GUIDANCE_MINIMAL = [ "【액션 아이템】", "「Action Item」포함이 켜져 있으면 별도 마크다운 섹션(예: ## 액션 아이템 또는 ## Action Items)으로 후속 과제를 정리합니다.", "**항목 표기(번호·부제목·영문 Who/When/What 라벨 사용 여부 등)는 아래「사용자 추가 지시」를 가장 우선합니다.** 추가 지시에 형식이 없으면 간단한 번호 목록·불릿으로, 원문·전사에서 확정된 일만 적습니다.", "담당자·이름·기한은 **회의 원문·전사에 명시된 경우에만** 적습니다. 없거나 불명확하면 생략하거나 ‘미정’·‘TBD’로 표기하고, 원문에 없는 인물·담당을 추측하여 쓰지 마세요.", ]; const EMPLOYEE_NAME_GUIDANCE_MINIMAL = [ "【인명·담당자】", "참석자·담당자 이름은 **원문·전사에 실제로 등장한 표기**를 따릅니다. 음성 인식 오류로 같은 사람이 문맥상 확실할 때만 철자를 다듬습니다.", "사내 다른 성명 목록으로 바꿔 끼우거나, 전사에 없는 사람을 만들어내지 마세요.", ]; /** 회의 체크리스트 — 정의·목적·전·중·후 + 업무 체크리스트 AI 연동 */ const MEETING_CHECKLIST_GUIDANCE_LINES = [ "【회의 체크리스트(Meeting Checklist)】", "정의: 회의가 원활하게 진행되고 목표를 달성할 수 있도록 사전에 준비하거나, 회의 후 검토해야 할 항목들을 목록화한 것입니다.", "목적: 준비 부족으로 인한 시간 낭비를 방지하고, 회의 전·중·후 전 과정을 구조화하여 효율을 높입니다.", "원문에서 도출 가능한 범위에서, 회의 전 준비·회의 중 진행 점검·회의 후 확인·후속 등을 [ ] 체크리스트 형태로 나열할 수 있습니다.", "업무 체크리스트 AI 연동: 위 체크리스트 섹션(예: ## 후속 확인 체크리스트, ## 회의 체크리스트)을 반드시 포함하고, 각 항목은 완료 여부를 파악·표시할 수 있는 형태(예: [ ] 항목, 체크박스)로 작성하세요. ‘사용자 추가 지시’에 체크리스트 관련 문구가 없더라도 이 요구는 동일하게 적용됩니다.", ]; function buildMeetingMinutesSystemPrompt(settings) { if (!settings || typeof settings !== "object") settings = {}; const includeTitle = settings.includeTitleLine !== false && settings.include_title_line !== false; const includeAttendees = settings.includeAttendees !== false && settings.include_attendees !== false; const includeSummary = settings.includeSummary !== false && settings.include_summary !== false; const includeActionItems = settings.includeActionItems !== false && settings.include_action_items !== false; 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 = [ "당신은 사내 회의록을 정리하는 전문가입니다. 입력된 회의 원문(또는 음성 전사)을 바탕으로 읽기 쉬운 회의록을 한국어로 작성합니다.", "【규칙 우선순위】이 메시지 아래에「사용자 추가 지시」가 있으면, 섹션 구성·액션 항목 표기·체크리스트 포함 여부 등은 **사용자 추가 지시를 최우선**으로 따릅니다. 일반 지시와 다르면 사용자 추가 지시가 우선합니다.", ]; EMPLOYEE_NAME_GUIDANCE_MINIMAL.forEach((line) => lines.push(line)); lines.push(""); lines.push("출력 형식 요구(위 체크박스·저장값):"); if (includeTitle) lines.push("- 맨 위에 회의 제목 한 줄(입력에 제목이 없으면 내용에서 추정)."); if (includeAttendees) lines.push("- 참석자·발언자가 드러나면 구분해 정리."); if (includeSummary) lines.push("- 핵심 요약(불릿 또는 짧은 단락)."); lines.push("- 불필요한 장황한 서론 없이, 마크다운 구조(##, ###, -, 표)를 사용해 읽기 쉽게 구분하세요."); lines.push(""); lines.push("【원문·전사와 회의록 분리】"); lines.push("- 음성 전사·회의 원문 전체를 회의록 본문에 다시 붙여 넣지 마세요. 원문/전사는 시스템에서 별도 필드(전사 기록·회의 원문)로 이미 보관됩니다."); if (includeChecklist) { lines.push( "- ‘스크립트’, ‘스크랩트’(오타), ‘원문 전사’, ‘전사문’, ‘Verbatim’ 등 원문을 통째로 실어 나르는 제목의 섹션을 만들지 마세요. 요약·결정·액션·체크리스트만 회의록 본문에 포함하세요." ); } else { lines.push( "- ‘스크립트’, ‘스크랩트’(오타), ‘원문 전사’, ‘전사문’, ‘Verbatim’ 등 원문을 통째로 실어 나르는 제목의 섹션을 만들지 마세요. 요약·결정·액션 등 **사용자 추가 지시에 적은 섹션만** 포함하세요." ); lines.push( "- 사용자 추가 지시에 「회의 체크리스트」「후속 확인 체크리스트」 등이 없으면, 그런 제목의 별도 체크리스트 섹션을 만들지 마세요." ); } lines.push("- 회의 제목, 참석자, 요약, 결정 사항, 액션 아이템 등은 마크다운 제목(예: ## 회의 제목, ### 요약)으로 구분해 주세요."); if (includeActionItems) { lines.push(""); ACTION_ITEMS_GUIDANCE_MINIMAL.forEach((line) => lines.push(line)); } if (includeChecklist) { lines.push(""); MEETING_CHECKLIST_GUIDANCE_LINES.forEach((line) => lines.push(line)); } lines.push(""); lines.push("【말미 섹션 금지】"); if (includeChecklist) { lines.push( "- 회의 체크리스트·액션 아이템 이후에 ‘추가 메모’, ‘확인 필요 사항’, ‘추가 메모 / 확인 필요 사항’ 등 제목의 섹션을 두지 마세요. CSV·표 제안, 설문/스크립트 초안, ‘필요하시면’ 안내 등 말미 부가 안내도 포함하지 않습니다." ); lines.push( "- 체크리스트 섹션을 마지막으로 두고, 그 아래에 시연·피드백 제출 방식(문서/슬랙/이메일) 회신, 액션 우선순위 재정렬·담당·기한 확정 안내, DRM·후보군 추가 작성 제안 같은 **운영/후속 안내 문단**을 붙이지 마세요." ); } else { lines.push( "- 액션 아이템·결정 사항 이후에 ‘추가 메모’, ‘확인 필요 사항’, ‘추가 메모 / 확인 필요 사항’ 등 제목의 섹션을 두지 마세요. CSV·표 제안, 설문/스크립트 초안, ‘필요하시면’ 안내 등 말미 부가 안내도 포함하지 않습니다." ); } lines.push( "- ‘추가 권고’, ‘회의록 작성자의 제안’, 엑셀 담당자 배정 템플릿·추적 체크리스트 제안 등 **회의 본문과 무관한 조언·제안 섹션**을 두지 마세요." ); if (custom.trim()) { lines.push(""); lines.push("사용자 추가 지시:"); lines.push(custom.trim()); } if (!includeChecklist) { lines.push(""); lines.push( "【출력에서 제외(최종)】`## 회의 체크리스트`, `## 후속 확인 체크리스트` 같은 체크리스트 전용 제목, 그 아래 `- [ ]`·불릿 목록, 회의 본문 끝의 괄호 메타 문장(예: 체크리스트를 마지막으로 작성)은 넣지 마세요." ); } 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; } /** * 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); 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 }; } /** * @param {import("openai").default} openai * @param {string} filePath * @param {string} [uiModel] * @param {number} [segmentSeconds] - ffmpeg 분할 길이(초) */ async function transcribeMeetingAudioChunked(openai, filePath, uiModel, segmentSeconds) { const { files, tmpDir } = await ffmpegSplitAudioForTranscription(filePath, segmentSeconds); try { const parts = []; for (const fp of files) { const text = await transcribeMeetingAudioOnce(openai, fp, uiModel); parts.push(text); } 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 */ async function transcribeMeetingAudio(openai, filePath, uiModel = DEFAULT_TRANSCRIPTION_MODEL) { 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); } if (size <= SAFE_SINGLE_REQUEST_BYTES) { return transcribeMeetingAudioOnce(openai, filePath, uiModel); } return transcribeMeetingAudioChunked(openai, filePath, uiModel); } /** * @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-mini"); const completion = await openai.chat.completions.create({ model: apiModel, messages: [ { role: "system", content: systemPrompt }, { role: "user", content: `아래는 회의 원문 또는 전사입니다. 위 지시에 맞게 회의록을 작성해 주세요.\n\n---\n\n${userContent}`, }, ], }); 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: Every numbered line (1. 2. 3. or 1) 2) 3)) MUST become a **separate** item. Title = the numbered line’s main task name; put 담당/기한/할 일 lines into assignee, dueNote, detail as appropriate. 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 (담당자): follow the spelling already used in the minutes. Korean person names should match the company roster implied in the meeting minutes system prompt when the minutes corrected them; do not invent new spellings. - Do not merge multiple 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-mini"); 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, };