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:
dsyoon
2026-05-26 22:27:48 +09:00
parent 7bee72f287
commit 073a8343dd
84 changed files with 10883 additions and 1043 deletions

View File

@@ -0,0 +1,496 @@
/**
* AI 활용 사례 — 사용자 제출(글쓰기) PostgreSQL 저장
*/
const { v4: uuidv4 } = require("uuid");
const { stripForCount } = require("./strip-for-count");
const PLACEHOLDERS = {
situation:
"고객센터에는 반복적인 문의(배송 일정, 환불 절차, 계정 비밀번호 초기화 등)가 하루 수백 건씩 접수되어 상담사의 업무가 과중되고, 응답 지연으로 고객 만족도가 낮아지고 있었습니다.",
task:
"반복 문의를 AI로 자동 처리하여 상담사의 업무 부담을 줄이고, 고객 응답 속도와 만족도를 높이는 것이 목표였습니다.",
action:
"FAQ 데이터와 기존 상담 이력을 기반으로 AI 챗봇을 구축했습니다.\n주요 질문 유형을 분류하고, 자연어 기반 답변이 가능하도록 학습시켰으며, 복잡한 문의는 상담사에게 자동 이관되도록 설계했습니다.",
result:
"전체 문의의 65%를 AI가 자동 처리하게 되었고, 평균 응답 시간이 10분에서 30초 이내로 단축되었습니다. 상담사들은 고난도 고객 대응에 집중할 수 있게 되었고, 고객 만족도는 20% 이상 향상되었습니다.",
};
const MAX_BODY_TOTAL = 10000;
const MAX_THUMB_BYTES = 5 * 1024 * 1024;
const MAX_THUMB_COUNT = 5;
const MAX_ATTACH_BYTES = 20 * 1024 * 1024;
const MAX_ATTACH_COUNT = 1;
/**
* @param {object} f
* @returns {{ originalName: string, relativePath: string, size?: number } | null}
*/
function normalizeThumbFileEntry(f) {
if (!f || typeof f !== "object") return null;
const rp = String(f.relativePath || f.relative_path || "").trim();
if (!rp) return null;
const orig =
String(f.originalName || f.original_name || rp.split("/").pop() || "thumbnail").trim() ||
"thumbnail";
const out = { originalName: orig, relativePath: rp };
if (typeof f.size === "number" && f.size >= 0) out.size = f.size;
return out;
}
/**
* DB row → 썸네일 파일 목록(legacy 단일 경로 포함)
* @param {object} row
* @returns {Array<{ originalName: string, relativePath: string, size?: number }>}
*/
function parseThumbnailFiles(row) {
let files = [];
try {
const raw = row && row.thumbnail_files;
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
if (Array.isArray(parsed)) {
files = parsed.map(normalizeThumbFileEntry).filter(Boolean);
}
} catch {
files = [];
}
if (!files.length && row && row.thumbnail_relative_path) {
const rp = String(row.thumbnail_relative_path).trim();
if (rp) {
files = [
{
originalName: rp.split("/").pop() || "thumbnail",
relativePath: rp,
},
];
}
}
return files;
}
/**
* @param {Array<{ relativePath?: string }>} files
* @returns {string | null}
*/
function primaryThumbnailPath(files) {
if (!Array.isArray(files) || !files.length) return null;
const rp = String(files[0].relativePath || "").trim();
return rp || null;
}
function countBodyTotal(row) {
return (
stripForCount(row.situation || "") +
stripForCount(row.task_goal || "") +
stripForCount(row.action_taken || "") +
stripForCount(row.result_outcome || "")
);
}
/**
* @param {import("pg").Pool} pgPool
* @param {object} p
* @param {string} [p.id] - 업로드 디렉터리명과 동일한 UUID(미입력 시 생성)
* @param {string} p.submitterEmail
* @param {string} p.title
* @param {string} p.situation
* @param {string} p.taskGoal
* @param {string} p.actionTaken
* @param {string} p.resultOutcome
* @param {string[]} p.tags
* @param {string | null} p.thumbnailRelativePath - 목록 카드용 대표(첫 번째) 경로
* @param {Array<{ originalName: string, relativePath: string, size?: number }>} p.thumbnailFiles
* @param {Array<{ originalName: string, relativePath: string }>} p.attachmentFiles
*/
async function insertSubmission(pgPool, p) {
if (!pgPool) throw new Error("PostgreSQL이 필요합니다.");
const total = countBodyTotal({
situation: p.situation,
task_goal: p.taskGoal,
action_taken: p.actionTaken,
result_outcome: p.resultOutcome,
});
if (total > MAX_BODY_TOTAL) {
throw new Error(`본문(4개 합계)은 ${MAX_BODY_TOTAL}자 이하여야 합니다. (현재 ${total}자)`);
}
const thumbFiles = Array.isArray(p.thumbnailFiles) ? p.thumbnailFiles.map(normalizeThumbFileEntry).filter(Boolean) : [];
const primaryThumb = p.thumbnailRelativePath || primaryThumbnailPath(thumbFiles);
const id = p.id || uuidv4();
const q = `INSERT INTO ai_use_case_submissions (
id, submitter_email, title, situation, task_goal, action_taken, result_outcome,
tags, thumbnail_relative_path, thumbnail_files, attachment_files, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb, NOW(), NOW())`;
await pgPool.query(q, [
id,
p.submitterEmail,
p.title,
p.situation || "",
p.taskGoal || "",
p.actionTaken || "",
p.resultOutcome || "",
p.tags && p.tags.length ? p.tags : [],
primaryThumb || null,
JSON.stringify(thumbFiles),
JSON.stringify(p.attachmentFiles || []),
]);
return { id };
}
const SUBMISSION_DEPT = "일반 제출";
const EXCERPT_MAX = 220;
/**
* @param {string} situation
* @param {string} task
* @param {string} action
* @param {string} result
* @param {number} [max]
*/
function buildExcerpt(situation, task, action, result, max) {
const cap = max == null ? EXCERPT_MAX : max;
const full = [situation, task, action, result]
.map((h) => stripForCount(h || ""))
.filter(Boolean)
.join(" ")
.replace(/\s+/g, " ")
.trim();
if (!full) return "";
if (full.length <= cap) return full;
return full.slice(0, cap) + "…";
}
/**
* /ai-cases 목록 카드용
* @param {object} row — pg row
* @returns {object}
*/
function mapRowToListCard(row) {
const email = (row.submitter_email || "").trim();
const author = email && email.indexOf("@") > 0 ? email.split("@")[0] : email || "제출자";
const created = row.created_at ? new Date(row.created_at) : new Date(0);
const published = Number.isNaN(created.getTime()) ? "" : created.toISOString().slice(0, 10);
const cover = primaryThumbnailPath(parseThumbnailFiles(row)) || (row.thumbnail_relative_path || "").trim();
return {
_source: "submission",
submissionId: String(row.id),
title: (row.title || "").trim() || "제목 없음",
excerpt: buildExcerpt(row.situation, row.task_goal, row.action_taken, row.result_outcome),
department: SUBMISSION_DEPT,
author,
submitterEmail: email,
tags: Array.isArray(row.tags) ? row.tags : [],
publishedAt: published,
_dateSort: created,
coverImageUrl: cover,
viewCount: Number(row.view_count) || 0,
likeCount: Number(row.like_count) || 0,
};
}
/**
* @param {import("pg").Pool} pgPool
* @param {number} [limit=500]
*/
async function listForPublicList(pgPool, limit) {
if (!pgPool) return [];
const lim = Math.min(Math.max(1, limit || 500), 1000);
const r = await pgPool.query(
`SELECT
s.id,
s.submitter_email,
s.title,
s.situation,
s.task_goal,
s.action_taken,
s.result_outcome,
s.tags,
s.thumbnail_relative_path,
s.thumbnail_files,
s.created_at,
COALESCE(s.view_count, 0)::int AS view_count,
(SELECT COUNT(*)::int FROM ai_use_case_submission_likes l WHERE l.submission_id = s.id) AS like_count
FROM ai_use_case_submissions s
ORDER BY s.created_at DESC
LIMIT $1`,
[lim]
);
return (r.rows || []).map(mapRowToListCard);
}
/**
* @param {import("pg").Pool} pgPool
* @param {string} id
*/
async function getSubmissionById(pgPool, id) {
if (!pgPool) return null;
const r = await pgPool.query(
`SELECT * FROM ai_use_case_submissions WHERE id = $1 LIMIT 1`,
[id]
);
return r.rows && r.rows[0] ? r.rows[0] : null;
}
/**
* 편집 권한: 제출자 본인 또는 관리자
* @param {string} rowSubmitterEmail
* @param {string | null} requestEmail
* @param {boolean} isAdmin
*/
function canUserEditSubmission(rowSubmitterEmail, requestEmail, isAdmin) {
if (isAdmin) return true;
if (!requestEmail) return false;
const a = (rowSubmitterEmail || "").trim().toLowerCase();
const b = String(requestEmail).trim().toLowerCase();
return Boolean(a && b && a === b);
}
/**
* 작성 화면 Toast 초기 HTML (DB에 저장된 4섹션 HTML을 그대로 감쌈)
* @param {string} situation
* @param {string} task
* @param {string} action
* @param {string} result
*/
function buildComposeEditorHtml(situation, task, action, result) {
const b = (x) => (x && String(x).trim() ? String(x) : "<p><br></p>");
return (
'<div class="uc-doc">' +
'<div id="uc-situation" class="uc-section"><h2>1. Situation (배경)</h2>' +
b(situation) +
"</div>" +
'<div id="uc-task" class="uc-section"><h2>2. Task (과제/목표)</h2>' +
b(task) +
"</div>" +
'<div id="uc-action" class="uc-section"><h2>3. Action (행동)</h2>' +
b(action) +
"</div>" +
'<div id="uc-result" class="uc-section"><h2>4. Result (결과)</h2>' +
b(result) +
"</div>" +
"</div>"
);
}
/**
* @param {import("pg").Pool} pgPool
* @param {object} p
* @param {string} p.id
* @param {string} p.title
* @param {string} p.situation
* @param {string} p.taskGoal
* @param {string} p.actionTaken
* @param {string} p.resultOutcome
* @param {string[]} p.tags
* @param {string} p.thumbnailFilesJson — 썸네일 배열 전체 JSON
* @param {string} p.attachmentFilesJson — json 문자열(배열 전체)
*/
async function updateSubmission(pgPool, p) {
if (!pgPool) throw new Error("PostgreSQL이 필요합니다.");
const total = countBodyTotal({
situation: p.situation,
task_goal: p.taskGoal,
action_taken: p.actionTaken,
result_outcome: p.resultOutcome,
});
if (total > MAX_BODY_TOTAL) {
throw new Error(`본문(4개 합계)은 ${MAX_BODY_TOTAL}자 이하여야 합니다. (현재 ${total}자)`);
}
let thumbFiles = [];
try {
const parsed = typeof p.thumbnailFilesJson === "string" ? JSON.parse(p.thumbnailFilesJson) : p.thumbnailFilesJson;
if (Array.isArray(parsed)) thumbFiles = parsed.map(normalizeThumbFileEntry).filter(Boolean);
} catch {
thumbFiles = [];
}
const primaryThumb = primaryThumbnailPath(thumbFiles);
await pgPool.query(
`UPDATE ai_use_case_submissions SET
title = $1,
situation = $2,
task_goal = $3,
action_taken = $4,
result_outcome = $5,
tags = $6,
thumbnail_relative_path = $7,
thumbnail_files = $8::jsonb,
attachment_files = $9::jsonb,
updated_at = NOW()
WHERE id = $10`,
[
p.title,
p.situation || "",
p.taskGoal || "",
p.actionTaken || "",
p.resultOutcome || "",
p.tags && p.tags.length ? p.tags : [],
primaryThumb,
JSON.stringify(thumbFiles),
p.attachmentFilesJson || "[]",
p.id,
]
);
return { ok: true };
}
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* 상세 페이지 방문마다 view_count +1 (페이지뷰). 제출자 본인(로그인) 조회만 제외.
* @param {import("pg").Pool} pgPool
* @param {string} submissionId
* @param {string | null | undefined} viewerEmail
* @param {string | null | undefined} submitterEmail
* @returns {Promise<number>}
*/
async function recordViewFromOther(pgPool, submissionId, viewerEmail, submitterEmail) {
if (!pgPool || !submissionId || !UUID_RE.test(submissionId)) return 0;
const viewer = String(viewerEmail || "")
.trim()
.toLowerCase();
const submitter = String(submitterEmail || "")
.trim()
.toLowerCase();
if (viewer && submitter && viewer === submitter) {
const cur = await pgPool.query(
`SELECT view_count FROM ai_use_case_submissions WHERE id = $1::uuid LIMIT 1`,
[submissionId]
);
return cur.rows[0]?.view_count ?? 0;
}
await pgPool.query(
`UPDATE ai_use_case_submissions SET view_count = view_count + 1 WHERE id = $1::uuid`,
[submissionId]
);
const r = await pgPool.query(
`SELECT view_count FROM ai_use_case_submissions WHERE id = $1::uuid LIMIT 1`,
[submissionId]
);
return r.rows[0]?.view_count ?? 0;
}
/**
* @param {import("pg").Pool} pgPool
* @param {string} submissionId
* @param {string | null | undefined} userEmail
* @returns {Promise<{ viewCount: number, likeCount: number, myLike: boolean }>}
*/
async function getSubmissionEngagement(pgPool, submissionId, userEmail) {
if (!pgPool || !submissionId || !UUID_RE.test(submissionId)) {
return { viewCount: 0, likeCount: 0, myLike: false };
}
const stats = await pgPool.query(
`SELECT
COALESCE(s.view_count, 0)::int AS view_count,
(SELECT COUNT(*)::int FROM ai_use_case_submission_likes l WHERE l.submission_id = s.id) AS like_count
FROM ai_use_case_submissions s
WHERE s.id = $1::uuid
LIMIT 1`,
[submissionId]
);
if (!stats.rowCount) {
return { viewCount: 0, likeCount: 0, myLike: false };
}
const row = stats.rows[0];
let myLike = false;
const email = String(userEmail || "")
.trim()
.toLowerCase();
if (email) {
const mine = await pgPool.query(
`SELECT 1 FROM ai_use_case_submission_likes
WHERE submission_id = $1::uuid AND user_email = $2
LIMIT 1`,
[submissionId, email]
);
myLike = mine.rowCount > 0;
}
return {
viewCount: row.view_count || 0,
likeCount: row.like_count || 0,
myLike,
};
}
/**
* @param {import("pg").Pool} pgPool
* @param {string} submissionId
* @param {string} userEmail
* @returns {Promise<{ liked: boolean, likeCount: number }>}
*/
async function toggleSubmissionLike(pgPool, submissionId, userEmail) {
if (!pgPool) {
const err = new Error("DB를 사용할 수 없습니다.");
err.code = "NO_DB";
throw err;
}
const id = String(submissionId || "").trim();
if (!UUID_RE.test(id)) {
const err = new Error("잘못된 ID입니다.");
err.code = "VALIDATION";
throw err;
}
const email = String(userEmail || "")
.trim()
.toLowerCase();
if (!email) {
const err = new Error("로그인이 필요합니다.");
err.code = "UNAUTHORIZED";
throw err;
}
const exists = await pgPool.query(
`SELECT 1 FROM ai_use_case_submissions WHERE id = $1::uuid LIMIT 1`,
[id]
);
if (!exists.rowCount) {
const err = new Error("사례를 찾을 수 없습니다.");
err.code = "NOT_FOUND";
throw err;
}
const del = await pgPool.query(
`DELETE FROM ai_use_case_submission_likes
WHERE submission_id = $1::uuid AND user_email = $2
RETURNING submission_id`,
[id, email]
);
if (del.rowCount) {
const cnt = await pgPool.query(
`SELECT COUNT(*)::int AS c FROM ai_use_case_submission_likes WHERE submission_id = $1::uuid`,
[id]
);
return { liked: false, likeCount: cnt.rows[0]?.c || 0 };
}
await pgPool.query(
`INSERT INTO ai_use_case_submission_likes (submission_id, user_email) VALUES ($1::uuid, $2)`,
[id, email]
);
const cnt = await pgPool.query(
`SELECT COUNT(*)::int AS c FROM ai_use_case_submission_likes WHERE submission_id = $1::uuid`,
[id]
);
return { liked: true, likeCount: cnt.rows[0]?.c || 0 };
}
module.exports = {
PLACEHOLDERS,
MAX_BODY_TOTAL,
MAX_THUMB_BYTES,
MAX_THUMB_COUNT,
MAX_ATTACH_BYTES,
MAX_ATTACH_COUNT,
countBodyTotal,
normalizeThumbFileEntry,
parseThumbnailFiles,
primaryThumbnailPath,
insertSubmission,
buildExcerpt,
mapRowToListCard,
listForPublicList,
getSubmissionById,
canUserEditSubmission,
buildComposeEditorHtml,
updateSubmission,
recordViewFromOther,
getSubmissionEngagement,
toggleSubmissionLike,
SUBMISSION_DEPT,
};

View File

@@ -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 lines 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,
},

View File

@@ -13,7 +13,7 @@ function loadXlsx() {
}
const ROOT = path.join(__dirname, "..");
const DEFAULT_PAYLOAD_PATH = path.join(ROOT, "data", "mgmt-perf-default-payload.json");
const DEFAULT_PAYLOAD_PATH = path.join(ROOT, "config", "mgmt-perf-default-payload.json");
const FILE_STATE_PATH = path.join(ROOT, "data", "mgmt-perf-last-state.json");
function loadDefaultPayload() {

115
lib/ops-session-revoke.js Normal file
View File

@@ -0,0 +1,115 @@
/**
* OPS 이메일 세션 전체 무효화(모든 디바이스 로그아웃)
* 쿠키에 iat(발급 시각)를 넣고, DB sessions_revoked_at 이후 iat만 유효하게 한다.
*/
const REVOKE_FILE = "ops-session-revocations.json";
/**
* @param {string} email
* @returns {string}
*/
function normalizeEmail(email) {
return String(email || "")
.trim()
.toLowerCase()
.slice(0, 320);
}
/**
* @param {import('pg').Pool | null | undefined} pgPool
* @param {string} dataDir
* @param {string} email
* @returns {Promise<number>} revoked at epoch ms, 0 if never revoked
*/
async function getSessionsRevokedAtMs(pgPool, dataDir, email) {
const e = normalizeEmail(email);
if (!e) return 0;
if (pgPool) {
try {
const r = await pgPool.query(
`SELECT sessions_revoked_at FROM ops_email_users WHERE email = $1`,
[e]
);
const ts = r.rows?.[0]?.sessions_revoked_at;
return ts ? new Date(ts).getTime() : 0;
} catch (err) {
console.error("[ops-session-revoke] get failed:", err.message);
return 0;
}
}
try {
const fs = require("fs");
const path = require("path");
const fp = path.join(dataDir, REVOKE_FILE);
if (!fs.existsSync(fp)) return 0;
const raw = fs.readFileSync(fp, "utf8");
const map = JSON.parse(raw);
const v = map?.[e];
return typeof v === "number" && Number.isFinite(v) ? v : 0;
} catch {
return 0;
}
}
/**
* @param {import('pg').Pool | null | undefined} pgPool
* @param {string} dataDir
* @param {string} email
* @param {number} iatMs
* @returns {Promise<boolean>}
*/
async function isOpsSessionRevoked(pgPool, dataDir, email, iatMs) {
const revokedAt = await getSessionsRevokedAtMs(pgPool, dataDir, email);
if (!revokedAt) return false;
const iat = typeof iatMs === "number" && Number.isFinite(iatMs) ? iatMs : 0;
return iat <= revokedAt;
}
/**
* @param {import('pg').Pool | null | undefined} pgPool
* @param {string} dataDir
* @param {string} email
* @returns {Promise<{ revokedAtMs: number }>}
*/
async function revokeAllOpsSessionsForEmail(pgPool, dataDir, email) {
const e = normalizeEmail(email);
if (!e) {
throw new Error("email is required");
}
const revokedAtMs = Date.now();
if (pgPool) {
await pgPool.query(
`INSERT INTO ops_email_users (email, first_seen_at, last_login_at, login_count, sessions_revoked_at)
VALUES ($1, NOW(), NOW(), 0, to_timestamp($2 / 1000.0))
ON CONFLICT (email) DO UPDATE SET sessions_revoked_at = EXCLUDED.sessions_revoked_at`,
[e, revokedAtMs]
);
return { revokedAtMs };
}
const fs = require("fs");
const path = require("path");
const fp = path.join(dataDir, REVOKE_FILE);
let map = {};
try {
if (fs.existsSync(fp)) {
map = JSON.parse(fs.readFileSync(fp, "utf8")) || {};
}
} catch {
map = {};
}
map[e] = revokedAtMs;
fs.mkdirSync(path.dirname(fp), { recursive: true });
fs.writeFileSync(fp, JSON.stringify(map, null, 2), "utf8");
return { revokedAtMs };
}
module.exports = {
getSessionsRevokedAtMs,
isOpsSessionRevoked,
revokeAllOpsSessionsForEmail,
};

View File

@@ -21,7 +21,7 @@ function isOpsStateSuper() {
return normalizeOpsState() === "SUPER";
}
/** 임직원 이메일(@xavis.co.kr) 매직 링크 로그인을 강제하는 모드 (REAL 구 값 포함) */
/** 임직원 이메일(@ncue.net) 매직 링크 로그인을 강제하는 모드 (REAL 구 값 포함) */
function isOpsProdMode() {
return isOpsStateProd();
}

View File

@@ -142,9 +142,16 @@ function parseBulletItems(body) {
cur = null;
}
};
let inFootnote = false;
for (const raw of lines) {
const t = raw.trim();
if (!t) continue;
if (/^※/.test(t)) {
inFootnote = true;
flush();
continue;
}
if (inFootnote) continue;
let m = t.match(/^\s*[-*•]\s+\[([ xX✓])\]\s*(.+)$/);
if (m) {
flush();
@@ -205,6 +212,95 @@ function parseBulletItems(body) {
return items.filter((x) => x.title.length > 0);
}
/**
* 마크다운 표 행에서 셀 배열 추출 (`| a | b | c |` → ["a","b","c"])
* @param {string} line
* @returns {string[]}
*/
function parseTableCells(line) {
let s = String(line || "").trim();
if (/^\d+\s*\|/.test(s) && !s.startsWith("|")) s = "| " + s;
return s
.replace(/^\s*\|\s*/, "")
.replace(/\s*\|\s*$/, "")
.split("|")
.map((cell) => cell.trim());
}
/**
* 마크다운 표 구분선 행인지 판별 (각 셀이 `-`, `:`, 공백만 포함)
* @param {string} t - trim된 행
*/
function isTableSeparatorRow(t) {
const cells = t.replace(/^\s*\|\s*/, "").replace(/\s*\|\s*$/, "").split("|");
return cells.length > 0 && cells.every((c) => /^[\s:]*-+[\s:-]*$/.test(c.trim()) || c.trim() === "");
}
/**
* 마크다운 표 형식 액션 아이템 파싱 (# | 담당 | 내용 | 기한 구조)
* @param {string} body
* @returns {ParsedItem[]}
*/
function parseTableActionRows(body) {
if (!body || !body.trim()) return [];
const lines = body.split(/\r?\n/);
/** @type {string[]|null} */
let headers = null;
/** @type {ParsedItem[]} */
const out = [];
for (const raw of lines) {
const t = raw.trim();
if (!t) continue;
const isPipeRow = /^\|/.test(t) || /^\d+\s*\|/.test(t);
if (!isPipeRow) continue;
const tSep = /^\d+\s*\|/.test(t) && !t.startsWith("|") ? "| " + t : t;
if (isTableSeparatorRow(tSep)) continue;
const cells = parseTableCells(t);
if (!cells.length) continue;
if (headers === null) {
headers = cells.map((h) => h.toLowerCase());
continue;
}
const get = (keywords) => {
for (const kw of keywords) {
const idx = headers.findIndex((h) => h.includes(kw));
if (idx !== -1 && idx < cells.length) return cells[idx].trim();
}
return "";
};
let title = get(["내용", "항목", "task", "할 일"]);
let assignee = get(["담당", "assignee", "담당자"]);
let due_note = get(["기한", "due", "마감"]);
const numFirstCell =
cells.length >= 4 && /^\d+$/.test(String(cells[0] || "").trim());
const headerHasOrdinalCol = headers.some((h) => {
const x = String(h || "").trim().toLowerCase();
return x === "#" || x === "no" || /^no\.?$/.test(x) || x.includes("순번") || x.includes("번호");
});
/** 헤더에 '#'/순번 열이 빠져 있으나 데이터 줄은 순번 포함 4열 — 담당·내용·기한 순으로 해석 */
if (numFirstCell && !headerHasOrdinalCol && headers.length >= 3 && headers.length <= cells.length - 1) {
assignee = String(cells[1] || "").trim();
due_note = String(cells[cells.length - 1] || "").trim();
title = cells
.slice(2, cells.length - 1)
.join(" ")
.replace(/\s*\|\s*/g, " ")
.trim();
}
if (!title) continue;
out.push({
title: stripMd(title),
detail: "",
assignee: assignee || null,
due_note: due_note || null,
completed: false,
});
}
return out.filter((x) => x.title.length > 0);
}
/**
* 액션 아이템 번호 목록 블록 (담당/기한/할 일)
* @param {string} body
@@ -329,6 +425,8 @@ function parseItemsFromMinutes(generatedMinutes, mode = "checklist") {
if (mode === "actions") {
const section = extractActionSectionBody(text);
if (!section) return [];
const tableRows = parseTableActionRows(section);
if (tableRows.length) return tableRows;
const numbered = parseNumberedActionBlocks(section);
if (numbered.length) return numbered;
return parseBulletItems(section);
@@ -345,6 +443,9 @@ module.exports = {
extractActionSectionBody,
parseBulletItems,
parseNumberedActionBlocks,
parseTableCells,
parseTableActionRows,
isTableSeparatorRow,
parseItemsFromMinutes,
parseAllRuleBasedWorkItems,
CHECKLIST_HEADINGS,

334
lib/prompt-library.js Normal file
View File

@@ -0,0 +1,334 @@
/**
* 프롬프트 라이브러리(공식 JSON + DB 커뮤니티/좋아요)
*/
"use strict";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function maskEmailForDisplay(email) {
const s = String(email || "")
.trim()
.toLowerCase();
const at = s.indexOf("@");
if (at < 1) return "팀원";
const local = s.slice(0, at);
const dom = s.slice(at);
if (local.length <= 2) {
return `${local[0] || "*"}*${dom}`;
}
return `${local[0]}**${local.slice(-1)}${dom}`;
}
/** @ 사인 앞 로컬부(예: spark_ai@ncue.net → spark_ai) — 라이브러리 카드·메타 표시용 */
function emailLocalPartForDisplay(email) {
const s = String(email || "").trim();
if (!s) return "팀원";
const at = s.indexOf("@");
if (at < 1) {
const t = s.slice(0, 80);
return t || "팀원";
}
const local = s.slice(0, at);
return (local && local.trim()) || "팀원";
}
/**
* @param {import("pg").Pool} pool
* @param {Array<{id:string,title?:string,description?:string,tag?:string,body?:string}>} officialPrompts
* @param {string | null} userEmail
*/
async function getLibraryData(pool, officialPrompts, userEmail) {
const official = Array.isArray(officialPrompts) ? officialPrompts : [];
const officialIds = official.map((p) => p.id).filter((id) => id && String(id).length < 200);
if (!pool) {
return {
hasDb: false,
community: [],
likeCountOfficial: {},
likeCountCommunity: {},
myOfficialLikes: [],
myCommunityLikes: [],
mySubmissions: [],
};
}
const [commRes, offCntRes, myLikesRes] = await Promise.all([
pool.query(
`SELECT id, author_email, title, description, body, tag, created_at, prompt_attachments, result_sample_attachments
FROM prompt_community_entries
WHERE is_published = true AND is_deleted = false
ORDER BY created_at DESC
LIMIT 300`
),
officialIds.length
? pool.query(
`SELECT target_id, COUNT(*)::int AS c
FROM prompt_likes
WHERE target_kind = 'official' AND target_id = ANY($1::text[])
GROUP BY target_id`,
[officialIds]
)
: Promise.resolve({ rows: [] }),
userEmail
? pool.query(
`SELECT target_kind, target_id FROM prompt_likes WHERE user_email = $1`,
[userEmail]
)
: Promise.resolve({ rows: [] }),
]);
const commIds = commRes.rows.map((r) => r.id);
let commCntRes = { rows: [] };
if (commIds.length) {
commCntRes = await pool.query(
`SELECT target_id, COUNT(*)::int AS c
FROM prompt_likes
WHERE target_kind = 'community' AND target_id = ANY($1::text[])
GROUP BY target_id`,
[commIds.map((id) => String(id))]
);
}
const likeCountOfficial = {};
for (const r of offCntRes.rows) {
likeCountOfficial[r.target_id] = r.c;
}
const likeCountCommunity = {};
for (const r of commCntRes.rows) {
likeCountCommunity[r.target_id] = r.c;
}
const myOfficialLikes = [];
const myCommunityLikes = [];
for (const r of myLikesRes.rows) {
if (r.target_kind === "official") myOfficialLikes.push(r.target_id);
else if (r.target_kind === "community") myCommunityLikes.push(r.target_id);
}
let mySubmissions = [];
if (userEmail) {
const mine = await pool.query(
`SELECT id, title, created_at
FROM prompt_community_entries
WHERE author_email = $1 AND is_deleted = false
ORDER BY created_at DESC
LIMIT 100`,
[userEmail]
);
mySubmissions = (mine.rows || []).map((row) => ({
id: String(row.id),
title: (row.title || "").trim() || "제목 없음",
createdAt: row.created_at ? new Date(row.created_at).toISOString() : "",
}));
}
const community = (commRes.rows || []).map((row) => {
const parseFileJson = (raw) => {
try {
const j = typeof raw === "string" ? JSON.parse(raw) : raw;
return Array.isArray(j) ? j : [];
} catch {
return [];
}
};
return {
id: String(row.id),
title: (row.title || "").trim() || "제목 없음",
description: String(row.description || "").trim(),
body: String(row.body || ""),
tag: (row.tag || "기타").trim() || "기타",
authorLabel: emailLocalPartForDisplay(row.author_email),
likeCount: likeCountCommunity[String(row.id)] || 0,
createdAt: row.created_at ? new Date(row.created_at).toISOString() : "",
promptFiles: parseFileJson(row.prompt_attachments).filter((f) => f && f.relativePath),
resultFiles: parseFileJson(row.result_sample_attachments).filter((f) => f && f.relativePath),
};
});
return {
hasDb: true,
community,
likeCountOfficial,
likeCountCommunity,
myOfficialLikes,
myCommunityLikes,
mySubmissions,
};
}
/**
* @param {import("pg").Pool} pool
* @param {string} userEmail
* @param {string} kind
* @param {string} targetId
* @param {Set<string>} officialIdSet
*/
async function toggleLike(pool, userEmail, kind, targetId, officialIdSet) {
if (!["official", "community"].includes(kind)) {
const err = new Error("target_kind");
err.code = "VALIDATION";
throw err;
}
const id = String(targetId || "").trim();
if (!id) {
const err = new Error("target_id");
err.code = "VALIDATION";
throw err;
}
if (kind === "official" && !officialIdSet.has(id)) {
const err = new Error("알 수 없는 공식 프롬프트입니다.");
err.code = "VALIDATION";
throw err;
}
if (kind === "community" && !UUID_RE.test(id)) {
const err = new Error("잘못된 ID입니다.");
err.code = "VALIDATION";
throw err;
}
if (kind === "community") {
const r = await pool.query(
`SELECT 1 FROM prompt_community_entries WHERE id = $1::uuid AND is_deleted = false AND is_published = true LIMIT 1`,
[id]
);
if (!r.rowCount) {
const err = new Error("삭제되었거나 없는 프롬프트입니다.");
err.code = "NOT_FOUND";
throw err;
}
}
const del = await pool.query(
`DELETE FROM prompt_likes
WHERE user_email = $1 AND target_kind = $2 AND target_id = $3
RETURNING id`,
[userEmail, kind, id]
);
if (del.rowCount) {
const cnt = await pool.query(
`SELECT COUNT(*)::int AS c FROM prompt_likes WHERE target_kind = $1 AND target_id = $2`,
[kind, id]
);
return { liked: false, likeCount: cnt.rows[0].c || 0 };
}
await pool.query(
`INSERT INTO prompt_likes (user_email, target_kind, target_id) VALUES ($1, $2, $3)`,
[userEmail, kind, id]
);
const cnt = await pool.query(
`SELECT COUNT(*)::int AS c FROM prompt_likes WHERE target_kind = $1 AND target_id = $2`,
[kind, id]
);
return { liked: true, likeCount: cnt.rows[0].c || 0 };
}
const MAX_TITLE = 500;
const MAX_DESC = 2000;
const MAX_BODY = 50000;
const MAX_TAG = 100;
const MAX_ATTACH_PER_GROUP = 5;
const MAX_ATTACH_BYTES = 20 * 1024 * 1024;
/**
* @param {import("pg").Pool} pool
* @param {object} p
* @param {string} p.authorEmail
* @param {string} p.title
* @param {string} p.description
* @param {string} p.body
* @param {string} p.tag
* @param {Array<{ originalName: string, relativePath: string, size: number }>} [p.promptAttachments]
* @param {Array<{ originalName: string, relativePath: string, size: number }>} [p.resultSampleAttachments]
*/
function normalizeFileList(arr, maxN) {
if (!Array.isArray(arr) || !arr.length) return [];
return arr
.filter((a) => a && typeof a.relativePath === "string" && a.relativePath.startsWith("/uploads/"))
.slice(0, maxN)
.map((a) => ({
originalName: String(a.originalName || "file").slice(0, 400),
relativePath: String(a.relativePath).slice(0, 2000),
size: Math.min(Number(a.size) || 0, MAX_ATTACH_BYTES * 2),
}));
}
async function createCommunityEntry(pool, p) {
const title = String(p.title || "")
.trim()
.slice(0, MAX_TITLE);
const description = String(p.description || "")
.trim()
.slice(0, MAX_DESC);
const body = String(p.body || "").trim();
const tag = String(p.tag || "기타")
.trim()
.slice(0, MAX_TAG) || "기타";
if (!title) {
const e = new Error("제목을 입력해 주세요.");
e.code = "VALIDATION";
throw e;
}
if (body.length < 10) {
const e = new Error("본문은 10자 이상 입력해 주세요.");
e.code = "VALIDATION";
throw e;
}
if (body.length > MAX_BODY) {
const e = new Error(`본문은 ${MAX_BODY}자 이하여야 합니다.`);
e.code = "VALIDATION";
throw e;
}
const promptA = normalizeFileList(p.promptAttachments, MAX_ATTACH_PER_GROUP);
const resultA = normalizeFileList(p.resultSampleAttachments, MAX_ATTACH_PER_GROUP);
const r = await pool.query(
`INSERT INTO prompt_community_entries (author_email, title, description, body, tag, prompt_attachments, result_sample_attachments)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb)
RETURNING id, created_at`,
[p.authorEmail, title, description, body, tag, JSON.stringify(promptA), JSON.stringify(resultA)]
);
const row = r.rows[0];
return {
id: String(row.id),
createdAt: row.created_at ? new Date(row.created_at).toISOString() : "",
promptFiles: promptA,
resultFiles: resultA,
};
}
/**
* @param {import("pg").Pool} pool
* @param {string} id
* @param {string} authorEmail
*/
async function softDeleteCommunityEntry(pool, id, authorEmail) {
if (!UUID_RE.test(String(id || "").trim())) {
const e = new Error("잘못된 ID입니다.");
e.code = "VALIDATION";
throw e;
}
const r = await pool.query(
`UPDATE prompt_community_entries
SET is_deleted = true, is_published = false, updated_at = NOW()
WHERE id = $1::uuid AND author_email = $2 AND is_deleted = false
RETURNING id`,
[id, authorEmail]
);
if (!r.rowCount) {
const e = new Error("삭제할 수 없습니다. 본인이 올린 글만 삭제할 수 있습니다.");
e.code = "NOT_FOUND";
throw e;
}
return { ok: true };
}
module.exports = {
getLibraryData,
toggleLike,
createCommunityEntry,
softDeleteCommunityEntry,
maskEmailForDisplay,
emailLocalPartForDisplay,
UUID_RE,
MAX_ATTACH_PER_GROUP,
MAX_ATTACH_BYTES,
};

View File

@@ -0,0 +1,65 @@
const sanitizeHtml = require("sanitize-html");
const SANITIZE_OPTIONS = {
allowedTags: [
"p",
"br",
"div",
"span",
"strong",
"b",
"em",
"i",
"u",
"s",
"del",
"strike",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"ul",
"ol",
"li",
"blockquote",
"pre",
"code",
"hr",
"a",
"table",
"thead",
"tbody",
"tfoot",
"tr",
"th",
"td",
],
allowedAttributes: {
a: ["href", "target", "rel", "name"],
th: ["colspan", "rowspan", "align"],
td: ["colspan", "rowspan", "align"],
},
allowedSchemes: ["http", "https", "mailto", "tel"],
transformTags: {
a: (tagName, attribs) => {
const next = { ...attribs };
if (next.target === "_blank") {
next.rel = (next.rel || "noopener") + (next.rel && next.rel.indexOf("noreferrer") >= 0 ? "" : " noreferrer");
}
return { tagName, attribs: next };
},
},
};
/**
* @param {string} html
* @returns {string}
*/
function sanitizeUseCaseBody(html) {
if (html == null) return "";
return sanitizeHtml(String(html), SANITIZE_OPTIONS);
}
module.exports = { sanitizeUseCaseBody };

20
lib/strip-for-count.js Normal file
View File

@@ -0,0 +1,20 @@
/**
* HTML·태그·엔티티를 제외한 보이는 텍스트(글자 수 제한용)
* — sanitize-html 없이 사용해 서버 기동 시 모듈 누락으로 전체 앱이 죽는 것을 막습니다.
* @param {string} html
* @returns {string}
*/
function stripForCount(html) {
if (!html) return "";
return String(html)
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/&#[0-9]+;/g, " ")
.replace(/&[a-zA-Z0-9]+;/g, " ")
.replace(/\s+/g, " ")
.trim();
}
module.exports = { stripForCount };