Files
ai_platform/lib/meeting-minutes.js

753 lines
31 KiB
JavaScript
Raw Permalink 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.
/**
* 회의록 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_LINES = [
"【액션 아이템(Action Item)】",
"정의: 회의 중 논의된 내용에 따라 구성원들이 완료해야 하는 구체적인 작업, 활동 또는 조치입니다.",
"목적: 누가(Who), 언제(When), 무엇을(What), 필요 시 어떻게(How)까지 명확히 하여 후속 조치를 추적·관리합니다.",
"필수 요소: What(수행해야 할 구체적 작업), Who(작업을 완료할 책임이 있는 특정 개인), When(완료해야 하는 구체적 일시·기한). 원문에 없으면 추측하지 말고 미정·TBD 등으로 표기하세요.",
"작성 예시: \"김대리(Who) - 10월 24일까지(When) - OO 프로젝트 보고서 초안 작성 및 참석자 배포(What)\".",
"회의록에는 반드시 별도 마크다운 섹션(예: ## 액션 아이템 또는 ## Action Items)으로 번호 목록·하위 항목·표 등으로 정리하세요. 액션 아이템과 회의 체크리스트 섹션은 서로 구분하세요.",
];
/**
* 회의록·담당자 표기 시 참고할 사내 임직원 성명(쉼표 구분, 중복은 로드 시 제거)
* 전사 오타·유사 발음 교정용 — 영어·외국어·외부 인명은 원문 유지
*/
const MEETING_EMPLOYEE_NAMES_RAW =
"강봉조, 강선규, 강성국, 강성준, 강신균, 강인창, 강종덕, 고영철, 곽병우, 구본엽, 구병철, 권기현, 권순영, 권현철, 김광오, 김광용, 김기덕, 김기연, 김기홍, 김다경, 김대환, 김도균, 김동욱, 김상진, 김성빈, 김성희, 김수지, 김승현, 김의수, 김용현, 김재복, 김정섭, 김정훈, 김태식, 김태우, 김하영, 김항래, 김혜정, 김형규, 김형철, 김효규, 김창열, 남서연, 노은식, 노윤규, 노현주, 류덕현, 박경덕, 박기형, 박대희, 박병찬, 박상욱, 박상현, 박용영, 박정관, 박종철, 박현규, 배문우, 배준영, 서민호, 서원민, 설재민, 성필영, 송제웅, 송지연, 송홍규, 신동균, 신극돈, 신에스더, 신우재, 신화섭, 안종석, 양동환, 양성민, 양소라, 양준삼, 오승우, 오주헌, 우현, 유용일, 유서연, 유성호, 유주상, 유철명, 유휘상, 유웨이, 윤도상, 윤비시아, 윤상혁, 윤지은, 윤종석, 은재민, 이가람, 이강열, 이규민, 이길현, 이동명, 이동석, 이리종철, 이민호, 이병훈, 이사우, 이상규, 이상설, 이상윤, 이상훈, 이성희, 이승묵, 이아정, 이영환, 이재국, 이재동, 이정용, 이정열, 이주승, 이태용, 이석제, 임영규, 임창민, 임현도, 임호균, 장병주, 장소라, 전문호, 정관욱, 정광연, 정대진, 정안용, 정일구, 정재형, 정정주, 정진용, 정인선, 정효진, 조익환, 조정숙, 조현우, 지준경, 진현우, 진형민, 채광석, 천지영, 최봉진, 최상현, 최세영, 최연봉, 최연이, 최원녕, 최인환, 최정운, 최철, 최환성, 한수진, 한준희, 한혜연, 허수연, 허수정, 현진섭, 형성복";
function getMeetingEmployeeNamesDeduped() {
const seen = new Set();
const out = [];
for (const part of MEETING_EMPLOYEE_NAMES_RAW.split(/[,\s]+/)) {
const t = part.trim();
if (!t || seen.has(t)) continue;
seen.add(t);
out.push(t);
}
return out;
}
/** @type {string[]|null} */
let _employeeNamesForPromptCache = null;
function getMeetingEmployeeNamesCommaSeparated() {
if (!_employeeNamesForPromptCache) {
_employeeNamesForPromptCache = getMeetingEmployeeNamesDeduped().join(", ");
}
return _employeeNamesForPromptCache;
}
const EMPLOYEE_NAME_GUIDANCE_LINES = [
"【임직원 인명 표기】",
"참석자·발언자·액션 아이템의 담당자(Who)·회의 체크리스트에 언급된 주요 담당자 등, 사람 이름을 쓸 때 원문·전사가 음성 인식 오류·유사 발음으로 틀릴 수 있습니다.",
"아래는 사내 임직원 성명 참고 목록입니다. 문맥상 동일 인물로 확실할 때만, 목록에서 가장 가까운 표기로 통일해 주세요. 억지로 맞추지 마세요.",
"영어·외국어 표기, 또는 위 목록과 완전히 다른 고유 인명(외부 인물·고객 등)은 원문 그대로 두어도 됩니다.",
"임직원 참고 목록: " + getMeetingEmployeeNamesCommaSeparated(),
];
/** 회의 체크리스트 — 정의·목적·전·중·후 + 업무 체크리스트 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 custom =
(settings.customInstructions && String(settings.customInstructions)) ||
(settings.custom_instructions && String(settings.custom_instructions)) ||
"";
const lines = [
"당신은 사내 회의록을 정리하는 전문가입니다. 입력된 회의 원문(또는 음성 전사)을 바탕으로 읽기 쉬운 회의록을 한국어로 작성합니다.",
];
EMPLOYEE_NAME_GUIDANCE_LINES.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("- 음성 전사·회의 원문 전체를 회의록 본문에 다시 붙여 넣지 마세요. 원문/전사는 시스템에서 별도 필드(전사 기록·회의 원문)로 이미 보관됩니다.");
lines.push("- ‘스크립트’, ‘스크랩트’(오타), ‘원문 전사’, ‘전사문’, Verbatim 등 원문을 통째로 실어 나르는 제목의 섹션을 만들지 마세요. 요약·결정·액션·체크리스트만 회의록에 포함하세요.");
lines.push("- 회의 제목, 참석자, 요약, 결정 사항, 액션 아이템 등은 반드시 마크다운 제목(예: ## 회의 제목, ### 요약)으로 구분해 주세요.");
if (includeActionItems) {
lines.push("");
ACTION_ITEMS_GUIDANCE_LINES.forEach((line) => lines.push(line));
}
lines.push("");
MEETING_CHECKLIST_GUIDANCE_LINES.forEach((line) => lines.push(line));
lines.push("");
lines.push("【말미 섹션 금지】");
lines.push(
"- 회의 체크리스트·액션 아이템 이후에 ‘추가 메모’, ‘확인 필요 사항’, ‘추가 메모 / 확인 필요 사항’ 등 제목의 섹션을 두지 마세요. CSV·표 제안, 설문/스크립트 초안, ‘필요하시면’ 안내 등 말미 부가 안내도 포함하지 않습니다."
);
lines.push(
"- 체크리스트 섹션을 마지막으로 두고, 그 아래에 시연·피드백 제출 방식(문서/슬랙/이메일) 회신, 액션 우선순위 재정렬·담당·기한 확정 안내, DRM·후보군 추가 작성 제안 같은 **운영/후속 안내 문단**을 붙이지 마세요."
);
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;
}
/** 체크리스트 항목 뒤에 붙는 운영/후속 안내 문단(제거 대상) */
function isPostChecklistBoilerplateLine(t) {
const s = String(t || "").trim();
if (!s) return false;
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();
}
/**
* API·저장·생성 공통: 스크립트 제거 → 체크리스트까지만 → 말미 안내 제거 → 말미 섹션(추가 메모·추가 권고 등) 제거 → 제목 승격
* @param {string} markdown
* @returns {string}
*/
function prepareMeetingMinutesForApi(markdown) {
let md = stripVerbatimScriptSections(markdown);
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 }) {
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);
}
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 lines 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<Array<{ title: string, detail: string, assignee: string|null, due_note: string|null, completed: boolean }>>}
*/
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,
};