feat: xavis ai_platform 기능 이전 및 ncue 환경 전환
xavis 소스·DB 스키마·활용사례/F-Scan/프롬프트 라이브러리 등 기능 반영. @xavis.co.kr → @ncue.net, 관리자 토큰 ncue-admin, 런타임 data/ Git 추적 제외. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,6 +5,7 @@ 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);
|
||||
@@ -27,7 +28,7 @@ function getGpt4oTranscribeSegmentSeconds() {
|
||||
}
|
||||
|
||||
/** 환경변수 미지정 시 기본 전사 모델 */
|
||||
const DEFAULT_TRANSCRIPTION_MODEL = (process.env.OPENAI_WHISPER_MODEL || "gpt-4o-mini-transcribe").trim();
|
||||
const DEFAULT_TRANSCRIPTION_MODEL = (process.env.OPENAI_WHISPER_MODEL || "gpt-4o-transcribe").trim();
|
||||
|
||||
/** @deprecated 하위 호환 — DEFAULT_TRANSCRIPTION_MODEL 과 동일 */
|
||||
const DEFAULT_WHISPER_MODEL = DEFAULT_TRANSCRIPTION_MODEL;
|
||||
@@ -49,19 +50,12 @@ function resolveTranscriptionApiModel(uiModel) {
|
||||
* 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 = [
|
||||
"【인명·담당자】",
|
||||
"참석자·담당자 이름은 **원문·전사에 실제로 등장한 표기**를 따릅니다. 음성 인식 오류로 같은 사람이 문맥상 확실할 때만 철자를 다듬습니다.",
|
||||
"사용자 메시지 상단에「이번 원문/전사 한정 · 임직원 표기 통일」블록이 있으면, **그 안의 매핑만** 적용하고 다른 이름을 임의로 목록에서 끌어오지 마세요.",
|
||||
"전사에 없는 사람을 만들어내지 마세요.",
|
||||
"발언자가 '우리', '우리 팀', '우리 회사', '저희 팀' 등으로 표현한 주체는 **회의 진행 요약·Q&A 등에서는** 원문 표현 그대로 유지하고, 문맥으로 특정 팀명·조직명을 추론하여 대체하지 마세요. (금지 예: '우리 팀(소프트웨어 운영팀)'처럼 괄호 보강) 단, 아래 【6) 액션 아이템】표의 **「담당」열**에서는 이 지칭을 쓸 수 없으며, 구체 이름·실제 조직·팀명이 없으면 **미정**만 둘 수 있습니다.",
|
||||
];
|
||||
|
||||
/** 회의 체크리스트 — 정의·목적·전·중·후 + 업무 체크리스트 AI 연동 */
|
||||
@@ -70,15 +64,11 @@ const MEETING_CHECKLIST_GUIDANCE_LINES = [
|
||||
"정의: 회의가 원활하게 진행되고 목표를 달성할 수 있도록 사전에 준비하거나, 회의 후 검토해야 할 항목들을 목록화한 것입니다.",
|
||||
"목적: 준비 부족으로 인한 시간 낭비를 방지하고, 회의 전·중·후 전 과정을 구조화하여 효율을 높입니다.",
|
||||
"원문에서 도출 가능한 범위에서, 회의 전 준비·회의 중 진행 점검·회의 후 확인·후속 등을 [ ] 체크리스트 형태로 나열할 수 있습니다.",
|
||||
"업무 체크리스트 AI 연동: 위 체크리스트 섹션(예: ## 후속 확인 체크리스트, ## 회의 체크리스트)을 반드시 포함하고, 각 항목은 완료 여부를 파악·표시할 수 있는 형태(예: [ ] 항목, 체크박스)로 작성하세요. ‘사용자 추가 지시’에 체크리스트 관련 문구가 없더라도 이 요구는 동일하게 적용됩니다.",
|
||||
"업무 체크리스트 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)) ||
|
||||
@@ -86,71 +76,115 @@ function buildMeetingMinutesSystemPrompt(settings) {
|
||||
"";
|
||||
|
||||
const lines = [
|
||||
"당신은 사내 회의록을 정리하는 전문가입니다. 입력된 회의 원문(또는 음성 전사)을 바탕으로 읽기 쉬운 회의록을 한국어로 작성합니다.",
|
||||
"【규칙 우선순위】이 메시지 아래에「사용자 추가 지시」가 있으면, 섹션 구성·액션 항목 표기·체크리스트 포함 여부 등은 **사용자 추가 지시를 최우선**으로 따릅니다. 일반 지시와 다르면 사용자 추가 지시가 우선합니다.",
|
||||
"당신은 사내 회의록 작성 전문가입니다. 음성 녹취록, 대화체 텍스트, 또는 회의 메모를 입력받아 구조화된 공식 회의록을 한국어로 작성합니다.",
|
||||
];
|
||||
|
||||
if (custom.trim()) {
|
||||
lines.push("【규칙 우선순위】이 메시지 아래에「사용자 추가 지시」가 있으면, 섹션 구성·형식 등은 **사용자 추가 지시를 최우선**으로 따릅니다.");
|
||||
}
|
||||
|
||||
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("【원문·전사와 회의록 분리】");
|
||||
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));
|
||||
}
|
||||
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("【말미 섹션 금지】");
|
||||
if (includeChecklist) {
|
||||
lines.push(
|
||||
"- 회의 체크리스트·액션 아이템 이후에 ‘추가 메모’, ‘확인 필요 사항’, ‘추가 메모 / 확인 필요 사항’ 등 제목의 섹션을 두지 마세요. CSV·표 제안, 설문/스크립트 초안, ‘필요하시면’ 안내 등 말미 부가 안내도 포함하지 않습니다."
|
||||
);
|
||||
lines.push(
|
||||
"- 체크리스트 섹션을 마지막으로 두고, 그 아래에 시연·피드백 제출 방식(문서/슬랙/이메일) 회신, 액션 우선순위 재정렬·담당·기한 확정 안내, DRM·후보군 추가 작성 제안 같은 **운영/후속 안내 문단**을 붙이지 마세요."
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
"- 액션 아이템·결정 사항 이후에 ‘추가 메모’, ‘확인 필요 사항’, ‘추가 메모 / 확인 필요 사항’ 등 제목의 섹션을 두지 마세요. CSV·표 제안, 설문/스크립트 초안, ‘필요하시면’ 안내 등 말미 부가 안내도 포함하지 않습니다."
|
||||
);
|
||||
}
|
||||
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());
|
||||
}
|
||||
if (!includeChecklist) {
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"【출력에서 제외(최종)】`## 회의 체크리스트`, `## 후속 확인 체크리스트` 같은 체크리스트 전용 제목, 그 아래 `- [ ]`·불릿 목록, 회의 본문 끝의 괄호 메타 문장(예: 체크리스트를 마지막으로 작성)은 넣지 마세요."
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 스크립트/스크랩트(오타) 등 원문 통째 반복 섹션의 제목인지 (마크다운 # 제목)
|
||||
* @param {string} title
|
||||
@@ -536,6 +570,286 @@ function stripAllMeetingChecklistSectionBlocks(markdown) {
|
||||
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
|
||||
@@ -550,6 +864,8 @@ function prepareMeetingMinutesForApi(markdown, options = {}) {
|
||||
md = stripTrailingAfterMeetingChecklistSection(md);
|
||||
md = removeKnownBoilerplateLines(md);
|
||||
md = stripTrailingJunkSectionsFromStart(md);
|
||||
md = normalizeActionItemsMarkdownTables(md);
|
||||
md = sanitizeActionItemAssigneesInMarkdown(md);
|
||||
return enhanceMeetingMinutesHeadingLines(md);
|
||||
}
|
||||
|
||||
@@ -653,18 +969,23 @@ async function ffmpegSplitAudioForTranscription(inputPath, segmentSeconds) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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 (const fp of files) {
|
||||
const text = await transcribeMeetingAudioOnce(openai, fp, uiModel);
|
||||
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 {
|
||||
@@ -677,13 +998,14 @@ async function transcribeMeetingAudioChunked(openai, filePath, uiModel, segmentS
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 파일 전사. OpenAI 요청당 한도(약 25MB)를 넘으면 ffmpeg로 분할 후 순차 전사.
|
||||
* gpt-4o 전사 계열은 오디오 토큰 상한으로, 용량이 작아도 전체를 한 요청에 넣으면 400이 날 수 있어 짧은 구간으로 분할한다.
|
||||
* 단일 파일 전사. 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) {
|
||||
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");
|
||||
@@ -691,13 +1013,16 @@ async function transcribeMeetingAudio(openai, filePath, uiModel = DEFAULT_TRANSC
|
||||
const gpt4oSeg = getGpt4oTranscribeSegmentSeconds();
|
||||
|
||||
if (isGpt4oStyleTranscribe) {
|
||||
return transcribeMeetingAudioChunked(openai, filePath, uiModel, gpt4oSeg);
|
||||
return transcribeMeetingAudioChunked(openai, filePath, uiModel, gpt4oSeg, onProgress);
|
||||
}
|
||||
|
||||
if (size <= SAFE_SINGLE_REQUEST_BYTES) {
|
||||
return transcribeMeetingAudioOnce(openai, filePath, uiModel);
|
||||
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);
|
||||
return transcribeMeetingAudioChunked(openai, filePath, uiModel, undefined, onProgress);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -709,7 +1034,7 @@ async function transcribeMeetingAudio(openai, filePath, uiModel = DEFAULT_TRANSC
|
||||
* @param {(m: string) => string} opts.resolveApiModel
|
||||
*/
|
||||
async function generateMeetingMinutes(openai, { systemPrompt, userContent, uiModel, resolveApiModel, omitMeetingChecklistSection }) {
|
||||
const apiModel = resolveApiModel(uiModel || "gpt-5-mini");
|
||||
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({
|
||||
@@ -730,11 +1055,14 @@ const CHECKLIST_EXTRACT_SYSTEM = `You extract actionable work items from Korean
|
||||
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.
|
||||
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 (담당자): 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.
|
||||
- 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.`;
|
||||
@@ -746,7 +1074,7 @@ Priority (Action Items are as important as checklists — they are real work):
|
||||
* @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 apiModel = resolveApiModel(uiModel || "gpt-5.4");
|
||||
const text = (minutesMarkdown || "").trim();
|
||||
if (!text) return [];
|
||||
const completion = await openai.chat.completions.create({
|
||||
@@ -758,7 +1086,7 @@ async function extractChecklistStructured(openai, { minutesMarkdown, uiModel, re
|
||||
role: "user",
|
||||
content:
|
||||
"아래 회의록에서 업무 항목을 JSON으로 추출하세요.\n" +
|
||||
"액션 아이템(번호 목록)과 회의 체크리스트 항목을 모두 포함하고, 번호마다 별도 item으로 나누세요.\n\n" +
|
||||
"액션 아이템(마크다운 표 또는 번호 목록)과 회의 체크리스트 항목을 모두 포함하고, 행·번호마다 별도 item으로 나누세요.\n\n" +
|
||||
"---\n\n" +
|
||||
text,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user