Initial commit: AI platform app (server, views, lib, data, deploy docs)
Made-with: Cursor
This commit is contained in:
97
lib/link-preview.js
Normal file
97
lib/link-preview.js
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 외부 URL의 HTML에서 Open Graph / Twitter 카드 이미지 URL 추출 (link.ncue.net 등과 유사한 미리보기)
|
||||
*/
|
||||
|
||||
const OG_FETCH_TIMEOUT_MS = 15000;
|
||||
const DEFAULT_UA =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
|
||||
function decodeBasicHtmlEntities(s) {
|
||||
return (s || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function resolveUrl(src, baseHref) {
|
||||
try {
|
||||
const t = (src || "").trim();
|
||||
if (!t) return null;
|
||||
return new URL(t, baseHref).href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractOgImageFromHtml(html) {
|
||||
if (!html || typeof html !== "string") return null;
|
||||
const patterns = [
|
||||
/<meta\s[^>]*property=["']og:image["'][^>]*content=["']([^"']+)["']/i,
|
||||
/<meta\s[^>]*content=["']([^"']+)["'][^>]*property=["']og:image["']/i,
|
||||
/<meta\s[^>]*name=["']twitter:image:src["'][^>]*content=["']([^"']+)["']/i,
|
||||
/<meta\s[^>]*content=["']([^"']+)["'][^>]*name=["']twitter:image:src["']/i,
|
||||
/<meta\s[^>]*name=["']twitter:image["'][^>]*content=["']([^"']+)["']/i,
|
||||
/<meta\s[^>]*content=["']([^"']+)["'][^>]*name=["']twitter:image["']/i,
|
||||
];
|
||||
for (const p of patterns) {
|
||||
const m = html.match(p);
|
||||
if (m && m[1]) return decodeBasicHtmlEntities(m[1].trim());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pageUrl
|
||||
* @returns {Promise<string|null>} 절대 URL의 og:image, 없으면 null
|
||||
*/
|
||||
async function fetchOpenGraphImageUrl(pageUrl) {
|
||||
const normalized = (pageUrl || "").trim();
|
||||
if (!normalized) return null;
|
||||
let base;
|
||||
try {
|
||||
base = new URL(normalized);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (base.protocol !== "http:" && base.protocol !== "https:") return null;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), OG_FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(base.href, {
|
||||
redirect: "follow",
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"User-Agent": DEFAULT_UA,
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
},
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const ct = (res.headers.get("content-type") || "").toLowerCase();
|
||||
if (!ct.includes("text/html") && !ct.includes("application/xhtml")) {
|
||||
return null;
|
||||
}
|
||||
const html = await res.text();
|
||||
const raw = extractOgImageFromHtml(html);
|
||||
if (!raw) return null;
|
||||
const abs = resolveUrl(raw, base.href);
|
||||
if (!abs) return null;
|
||||
let imgUrl;
|
||||
try {
|
||||
imgUrl = new URL(abs);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (imgUrl.protocol !== "http:" && imgUrl.protocol !== "https:") return null;
|
||||
return imgUrl.href;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { fetchOpenGraphImageUrl };
|
||||
20
lib/meeting-date-format.js
Normal file
20
lib/meeting-date-format.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* PostgreSQL DATE 또는 날짜 전용 값을 YYYY-MM-DD로 직렬화.
|
||||
* `new Date(x).toISOString().slice(0, 10)`은 UTC 기준이라
|
||||
* 서버/클라이언트 TZ가 한국 등일 때 **하루 전 날짜**로 잘릴 수 있음.
|
||||
*/
|
||||
function formatMeetingDateOnly(val) {
|
||||
if (val == null || val === "") return null;
|
||||
if (typeof val === "string") {
|
||||
const s = val.trim().slice(0, 10);
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
|
||||
}
|
||||
const d = val instanceof Date ? val : new Date(val);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
module.exports = { formatMeetingDateOnly };
|
||||
458
lib/meeting-minutes-store.js
Normal file
458
lib/meeting-minutes-store.js
Normal file
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* 회의록 AI: PostgreSQL 또는 data/meeting-ai.json 폴백
|
||||
*/
|
||||
const path = require("path");
|
||||
const fs = require("fs/promises");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
const { extractMeetingSummary } = require("./meeting-minutes-summary");
|
||||
const { formatMeetingDateOnly } = require("./meeting-date-format");
|
||||
const taskChecklistStore = require("./task-checklist-store");
|
||||
|
||||
const MEETING_AI_FILE = path.join(__dirname, "..", "data", "meeting-ai.json");
|
||||
|
||||
const defaultPromptRow = (email) => ({
|
||||
user_email: email,
|
||||
include_title_line: true,
|
||||
include_attendees: true,
|
||||
include_summary: true,
|
||||
include_action_items: true,
|
||||
include_checklist: true,
|
||||
custom_instructions: null,
|
||||
});
|
||||
|
||||
let writeChain = Promise.resolve();
|
||||
|
||||
function withFileLock(fn) {
|
||||
const p = writeChain.then(() => fn());
|
||||
writeChain = p.catch(() => {});
|
||||
return p;
|
||||
}
|
||||
|
||||
async function readFileStore() {
|
||||
try {
|
||||
const raw = await fs.readFile(MEETING_AI_FILE, "utf8");
|
||||
const j = JSON.parse(raw);
|
||||
if (!j || typeof j !== "object") return { prompts: {}, meetings: [] };
|
||||
if (!j.prompts || typeof j.prompts !== "object") j.prompts = {};
|
||||
if (!Array.isArray(j.meetings)) j.meetings = [];
|
||||
return j;
|
||||
} catch {
|
||||
return { prompts: {}, meetings: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function writeFileStore(data) {
|
||||
const dir = path.dirname(MEETING_AI_FILE);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const tmp = `${MEETING_AI_FILE}.${process.pid}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(data, null, 2), "utf8");
|
||||
await fs.rename(tmp, MEETING_AI_FILE);
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function rowToMeeting(row) {
|
||||
return {
|
||||
...row,
|
||||
created_at: row.created_at || nowIso(),
|
||||
updated_at: row.updated_at || nowIso(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
* @param {string} email
|
||||
*/
|
||||
async function ensureUserAndDefaultPrompt(pgPool, email) {
|
||||
if (pgPool) {
|
||||
await pgPool.query(`INSERT INTO meeting_ai_users (email) VALUES ($1) ON CONFLICT (email) DO NOTHING`, [email]);
|
||||
await pgPool.query(
|
||||
`INSERT INTO meeting_ai_prompts (user_email, include_title_line, include_attendees, include_summary, include_action_items, include_checklist)
|
||||
VALUES ($1, true, true, true, true, true)
|
||||
ON CONFLICT (user_email) DO NOTHING`,
|
||||
[email]
|
||||
);
|
||||
return;
|
||||
}
|
||||
await withFileLock(async () => {
|
||||
const data = await readFileStore();
|
||||
if (!data.prompts[email]) {
|
||||
data.prompts[email] = defaultPromptRow(email);
|
||||
await writeFileStore(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
* @param {string} email
|
||||
*/
|
||||
async function getPromptRow(pgPool, email) {
|
||||
if (pgPool) {
|
||||
const r = await pgPool.query(`SELECT * FROM meeting_ai_prompts WHERE user_email = $1`, [email]);
|
||||
return r.rows?.[0] || null;
|
||||
}
|
||||
const data = await readFileStore();
|
||||
const p = data.prompts[email];
|
||||
if (!p) return null;
|
||||
return {
|
||||
id: p.id || null,
|
||||
user_email: email,
|
||||
include_title_line: p.include_title_line !== false,
|
||||
include_attendees: p.include_attendees !== false,
|
||||
include_summary: p.include_summary !== false,
|
||||
include_action_items: p.include_action_items !== false,
|
||||
include_checklist: true,
|
||||
custom_instructions: p.custom_instructions,
|
||||
created_at: p.created_at || null,
|
||||
updated_at: p.updated_at || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
*/
|
||||
async function upsertPrompt(pgPool, email, fields) {
|
||||
if (pgPool) {
|
||||
await pgPool.query(
|
||||
`INSERT INTO meeting_ai_prompts (
|
||||
user_email, include_title_line, include_attendees, include_summary, include_action_items, include_checklist, custom_instructions
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (user_email) DO UPDATE SET
|
||||
include_title_line = EXCLUDED.include_title_line,
|
||||
include_attendees = EXCLUDED.include_attendees,
|
||||
include_summary = EXCLUDED.include_summary,
|
||||
include_action_items = EXCLUDED.include_action_items,
|
||||
include_checklist = EXCLUDED.include_checklist,
|
||||
custom_instructions = EXCLUDED.custom_instructions,
|
||||
updated_at = NOW()`,
|
||||
[
|
||||
email,
|
||||
fields.includeTitleLine,
|
||||
fields.includeAttendees,
|
||||
fields.includeSummary,
|
||||
fields.includeActionItems,
|
||||
true,
|
||||
fields.customInstructions || null,
|
||||
]
|
||||
);
|
||||
const r = await pgPool.query(`SELECT * FROM meeting_ai_prompts WHERE user_email = $1`, [email]);
|
||||
return r.rows?.[0] || null;
|
||||
}
|
||||
return withFileLock(async () => {
|
||||
const data = await readFileStore();
|
||||
const t = nowIso();
|
||||
const prev = data.prompts[email] || defaultPromptRow(email);
|
||||
data.prompts[email] = {
|
||||
...prev,
|
||||
include_title_line: fields.includeTitleLine,
|
||||
include_attendees: fields.includeAttendees,
|
||||
include_summary: fields.includeSummary,
|
||||
include_action_items: fields.includeActionItems,
|
||||
include_checklist: true,
|
||||
custom_instructions: fields.customInstructions || null,
|
||||
updated_at: t,
|
||||
created_at: prev.created_at || t,
|
||||
};
|
||||
await writeFileStore(data);
|
||||
return await getPromptRow(null, email);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
*/
|
||||
async function listMeetings(pgPool, email) {
|
||||
if (pgPool) {
|
||||
const r = await pgPool.query(
|
||||
`SELECT id, user_email, title, source_text, transcript_text, generated_minutes, summary_text, audio_file_path, audio_original_name, chat_model, transcription_model, meeting_date, created_at, updated_at
|
||||
FROM meeting_ai_meetings WHERE user_email = $1 ORDER BY created_at DESC LIMIT 200`,
|
||||
[email]
|
||||
);
|
||||
return r.rows || [];
|
||||
}
|
||||
const data = await readFileStore();
|
||||
return data.meetings
|
||||
.filter((m) => m.user_email === email)
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
|
||||
.slice(0, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
*/
|
||||
async function getMeeting(pgPool, id, email) {
|
||||
if (pgPool) {
|
||||
const r = await pgPool.query(`SELECT * FROM meeting_ai_meetings WHERE id = $1::uuid AND user_email = $2`, [id, email]);
|
||||
return r.rows?.[0] || null;
|
||||
}
|
||||
const data = await readFileStore();
|
||||
return data.meetings.find((m) => m.id === id && m.user_email === email) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
* @returns {Promise<{ row: object | null, audio_file_path?: string }>}
|
||||
*/
|
||||
async function deleteMeeting(pgPool, id, email) {
|
||||
await taskChecklistStore.deleteItemsByMeetingId(pgPool, id, email);
|
||||
if (pgPool) {
|
||||
const prev = await pgPool.query(
|
||||
`SELECT audio_file_path FROM meeting_ai_meetings WHERE id = $1::uuid AND user_email = $2`,
|
||||
[id, email]
|
||||
);
|
||||
const row = prev.rows?.[0];
|
||||
const del = await pgPool.query(`DELETE FROM meeting_ai_meetings WHERE id = $1::uuid AND user_email = $2 RETURNING id`, [
|
||||
id,
|
||||
email,
|
||||
]);
|
||||
return { deleted: !!del.rowCount, audio_file_path: row?.audio_file_path };
|
||||
}
|
||||
return withFileLock(async () => {
|
||||
const data = await readFileStore();
|
||||
const idx = data.meetings.findIndex((m) => m.id === id && m.user_email === email);
|
||||
if (idx < 0) return { deleted: false, audio_file_path: null };
|
||||
const audio = data.meetings[idx].audio_file_path;
|
||||
data.meetings.splice(idx, 1);
|
||||
await writeFileStore(data);
|
||||
return { deleted: true, audio_file_path: audio };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
*/
|
||||
async function insertMeetingText(pgPool, { email, title, sourceText, generated, model, meetingDate }) {
|
||||
const summaryText = extractMeetingSummary(generated || "", 1200);
|
||||
if (pgPool) {
|
||||
const ins = await pgPool.query(
|
||||
`INSERT INTO meeting_ai_meetings (user_email, title, source_text, transcript_text, generated_minutes, summary_text, chat_model, meeting_date)
|
||||
VALUES ($1, $2, $3, NULL, $4, $5, $6, $7) RETURNING *`,
|
||||
[email, title || "제목 없음", sourceText, generated, summaryText || null, model, meetingDate || null]
|
||||
);
|
||||
return ins.rows?.[0];
|
||||
}
|
||||
return withFileLock(async () => {
|
||||
const data = await readFileStore();
|
||||
const t = nowIso();
|
||||
const row = rowToMeeting({
|
||||
id: uuidv4(),
|
||||
user_email: email,
|
||||
title: title || "제목 없음",
|
||||
source_text: sourceText,
|
||||
transcript_text: null,
|
||||
generated_minutes: generated,
|
||||
summary_text: summaryText || null,
|
||||
audio_file_path: null,
|
||||
audio_original_name: null,
|
||||
chat_model: model,
|
||||
transcription_model: null,
|
||||
meeting_date: meetingDate || null,
|
||||
created_at: t,
|
||||
updated_at: t,
|
||||
});
|
||||
data.meetings.push(row);
|
||||
await writeFileStore(data);
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
*/
|
||||
async function insertMeetingAudio(pgPool, { email, title, transcript, generated, relPath, originalName, model, whisperModel, meetingDate }) {
|
||||
const summaryText = extractMeetingSummary(generated || "", 1200);
|
||||
if (pgPool) {
|
||||
const ins = await pgPool.query(
|
||||
`INSERT INTO meeting_ai_meetings (user_email, title, source_text, transcript_text, generated_minutes, summary_text, audio_file_path, audio_original_name, chat_model, transcription_model, meeting_date)
|
||||
VALUES ($1, $2, NULL, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
|
||||
[
|
||||
email,
|
||||
title || "제목 없음",
|
||||
transcript,
|
||||
generated,
|
||||
summaryText || null,
|
||||
relPath,
|
||||
(originalName || "").slice(0, 500),
|
||||
model,
|
||||
whisperModel,
|
||||
meetingDate || null,
|
||||
]
|
||||
);
|
||||
return ins.rows?.[0];
|
||||
}
|
||||
return withFileLock(async () => {
|
||||
const data = await readFileStore();
|
||||
const t = nowIso();
|
||||
const row = rowToMeeting({
|
||||
id: uuidv4(),
|
||||
user_email: email,
|
||||
title: title || "제목 없음",
|
||||
source_text: null,
|
||||
transcript_text: transcript,
|
||||
generated_minutes: generated,
|
||||
summary_text: summaryText || null,
|
||||
audio_file_path: relPath,
|
||||
audio_original_name: (originalName || "").slice(0, 500),
|
||||
chat_model: model,
|
||||
transcription_model: whisperModel,
|
||||
meeting_date: meetingDate || null,
|
||||
created_at: t,
|
||||
updated_at: t,
|
||||
});
|
||||
data.meetings.push(row);
|
||||
await writeFileStore(data);
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 회의록에 체크리스트 추출 JSON 스냅샷 저장 (업무 체크리스트 자동 연동)
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
* @param {string} meetingId
|
||||
* @param {string} email
|
||||
* @param {object} snapshotObj
|
||||
*/
|
||||
function formatMeetingDateIso(d) {
|
||||
return formatMeetingDateOnly(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* 업무 체크리스트 툴팁용: 회의 제목·일자·요약 (회의 id 목록)
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
* @param {string} email
|
||||
* @param {string[]} ids
|
||||
* @returns {Promise<Map<string, { meetingTitle: string, meetingDate: string|null, meetingSummary: string }>>}
|
||||
*/
|
||||
async function getMeetingMetaForIds(pgPool, email, ids) {
|
||||
const uid = [...new Set(ids.map((x) => String(x).trim()).filter(Boolean))];
|
||||
const map = new Map();
|
||||
if (uid.length === 0) return map;
|
||||
|
||||
if (pgPool) {
|
||||
const r = await pgPool.query(
|
||||
`SELECT id, title, meeting_date, summary_text, generated_minutes FROM meeting_ai_meetings
|
||||
WHERE user_email = $1 AND id = ANY($2::uuid[])`,
|
||||
[email, uid]
|
||||
);
|
||||
for (const row of r.rows || []) {
|
||||
const id = String(row.id);
|
||||
const raw = row.summary_text && String(row.summary_text).trim();
|
||||
const summary = raw || extractMeetingSummary(row.generated_minutes || "", 800);
|
||||
map.set(id, {
|
||||
meetingTitle: (row.title && String(row.title).trim()) || "제목 없음",
|
||||
meetingDate: formatMeetingDateIso(row.meeting_date),
|
||||
meetingSummary: summary || "",
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const data = await readFileStore();
|
||||
for (const m of data.meetings || []) {
|
||||
if (m.user_email !== email) continue;
|
||||
const id = String(m.id);
|
||||
if (!uid.includes(id)) continue;
|
||||
const raw = m.summary_text && String(m.summary_text).trim();
|
||||
const summary = raw || extractMeetingSummary(m.generated_minutes || "", 800);
|
||||
map.set(id, {
|
||||
meetingTitle: (m.title && String(m.title).trim()) || "제목 없음",
|
||||
meetingDate: formatMeetingDateIso(m.meeting_date),
|
||||
meetingSummary: summary || "",
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 회의록·전사/원문 수동 수정 저장 (요약은 generated_minutes 기준 재계산)
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
* @param {string} meetingId
|
||||
* @param {string} email
|
||||
* @param {{ generatedMinutes: string, transcriptText?: string, sourceText?: string }} fields
|
||||
*/
|
||||
async function updateMeetingContent(pgPool, meetingId, email, fields) {
|
||||
const gen = (fields.generatedMinutes ?? "").toString();
|
||||
const summaryText = extractMeetingSummary(gen, 1200);
|
||||
const hasT = Object.prototype.hasOwnProperty.call(fields, "transcriptText");
|
||||
const hasS = Object.prototype.hasOwnProperty.call(fields, "sourceText");
|
||||
if (pgPool) {
|
||||
const params = [meetingId, email, gen, summaryText || null];
|
||||
let extra = "";
|
||||
if (hasT) {
|
||||
params.push(fields.transcriptText ?? "");
|
||||
extra += `, transcript_text = $${params.length}`;
|
||||
}
|
||||
if (hasS) {
|
||||
params.push(fields.sourceText ?? "");
|
||||
extra += `, source_text = $${params.length}`;
|
||||
}
|
||||
const r = await pgPool.query(
|
||||
`UPDATE meeting_ai_meetings SET generated_minutes = $3, summary_text = $4${extra}, updated_at = NOW()
|
||||
WHERE id = $1::uuid AND user_email = $2
|
||||
RETURNING *`,
|
||||
params
|
||||
);
|
||||
return r.rows?.[0] || null;
|
||||
}
|
||||
return withFileLock(async () => {
|
||||
const data = await readFileStore();
|
||||
const m = data.meetings.find((x) => x.id === meetingId && x.user_email === email);
|
||||
if (!m) return null;
|
||||
m.generated_minutes = gen;
|
||||
m.summary_text = summaryText || null;
|
||||
if (hasT) m.transcript_text = fields.transcriptText ?? "";
|
||||
if (hasS) m.source_text = fields.sourceText ?? "";
|
||||
m.updated_at = nowIso();
|
||||
await writeFileStore(data);
|
||||
return m;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
* @param {string} meetingId
|
||||
* @param {string} email
|
||||
* @param {string} generatedMinutes
|
||||
*/
|
||||
async function updateMeetingGeneratedMinutes(pgPool, meetingId, email, generatedMinutes) {
|
||||
return updateMeetingContent(pgPool, meetingId, email, { generatedMinutes });
|
||||
}
|
||||
|
||||
async function updateMeetingChecklistSnapshot(pgPool, meetingId, email, snapshotObj) {
|
||||
if (pgPool) {
|
||||
await pgPool.query(
|
||||
`UPDATE meeting_ai_meetings SET checklist_snapshot = $3::jsonb, updated_at = NOW()
|
||||
WHERE id = $1::uuid AND user_email = $2`,
|
||||
[meetingId, email, JSON.stringify(snapshotObj)]
|
||||
);
|
||||
return;
|
||||
}
|
||||
await withFileLock(async () => {
|
||||
const data = await readFileStore();
|
||||
const m = data.meetings.find((x) => x.id === meetingId && x.user_email === email);
|
||||
if (m) {
|
||||
m.checklist_snapshot = snapshotObj;
|
||||
m.updated_at = nowIso();
|
||||
}
|
||||
await writeFileStore(data);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MEETING_AI_FILE,
|
||||
ensureUserAndDefaultPrompt,
|
||||
getPromptRow,
|
||||
upsertPrompt,
|
||||
listMeetings,
|
||||
getMeeting,
|
||||
deleteMeeting,
|
||||
insertMeetingText,
|
||||
insertMeetingAudio,
|
||||
updateMeetingChecklistSnapshot,
|
||||
updateMeetingContent,
|
||||
updateMeetingGeneratedMinutes,
|
||||
getMeetingMetaForIds,
|
||||
};
|
||||
78
lib/meeting-minutes-summary.js
Normal file
78
lib/meeting-minutes-summary.js
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 회의록 Markdown에서 업무 체크리스트 툴팁용 짧은 요약 추출 (저장 시 summary_text 보조용)
|
||||
*/
|
||||
|
||||
function stripMd(s) {
|
||||
if (!s) return "";
|
||||
return s
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
||||
.replace(/__([^_]+)__/g, "$1")
|
||||
.replace(/^[-*•]\s+/gm, "")
|
||||
.replace(/^\d+\.\s+/gm, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function truncate(s, maxLen) {
|
||||
const t = (s || "").trim();
|
||||
if (t.length <= maxLen) return t;
|
||||
return t.slice(0, maxLen - 1).trim() + "…";
|
||||
}
|
||||
|
||||
/** 체크리스트/액션 등 툴팁에 부적합한 섹션 제목 */
|
||||
const SKIP_SECTION = /체크리스트|액션|후속\s*확인|참석|결정\s*사항|action\s*items/i;
|
||||
|
||||
/** 요약으로 쓰기 좋은 섹션 제목 */
|
||||
const PREFERRED = /요약|개요|핵심|summary|논의\s*안건|회의\s*내용|discussion/i;
|
||||
|
||||
/**
|
||||
* @param {string} markdown
|
||||
* @param {number} [maxLen]
|
||||
* @returns {string}
|
||||
*/
|
||||
function extractMeetingSummary(markdown, maxLen = 800) {
|
||||
const md = (markdown || "").trim();
|
||||
if (!md) return "";
|
||||
|
||||
const lines = md.split(/\r?\n/);
|
||||
/** @type {{ h: string, body: string }[]} */
|
||||
const sections = [];
|
||||
for (let i = 0; i < lines.length; ) {
|
||||
const line = lines[i];
|
||||
const hm = line.match(/^#{1,6}\s+(.+)$/);
|
||||
if (!hm) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const heading = hm[1].replace(/\*\*/g, "").trim();
|
||||
const body = [];
|
||||
let j = i + 1;
|
||||
while (j < lines.length) {
|
||||
const tj = lines[j].trim();
|
||||
if (/^#{1,6}\s+/.test(tj)) break;
|
||||
body.push(lines[j]);
|
||||
j++;
|
||||
}
|
||||
const joined = body.join("\n").trim();
|
||||
if (joined.length >= 4) sections.push({ h: heading, body: joined });
|
||||
i = j;
|
||||
}
|
||||
|
||||
const preferred = sections.find((s) => PREFERRED.test(s.h) && !SKIP_SECTION.test(s.h));
|
||||
if (preferred) return truncate(stripMd(preferred.body), maxLen);
|
||||
|
||||
const firstOk = sections.find((s) => !SKIP_SECTION.test(s.h) && s.body.length >= 12);
|
||||
if (firstOk) return truncate(stripMd(firstOk.body), maxLen);
|
||||
|
||||
let plain = md.replace(/^#{1,6}\s+.*$/gm, "").trim();
|
||||
plain = plain.replace(/^[-*•]\s+.*$/gm, "").trim();
|
||||
if (plain.length < 30) plain = stripMd(md);
|
||||
return truncate(stripMd(plain), maxLen);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
extractMeetingSummary,
|
||||
stripMd,
|
||||
truncate,
|
||||
};
|
||||
752
lib/meeting-minutes.js
Normal file
752
lib/meeting-minutes.js
Normal file
@@ -0,0 +1,752 @@
|
||||
/**
|
||||
* 회의록 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 line’s main task name; put 담당/기한/할 일 lines into assignee, dueNote, detail as appropriate.
|
||||
2) **회의 체크리스트 / 후속 확인 체크리스트** sections: Each [ ] or bullet follow-up as one item.
|
||||
3) If an action item has 담당:, 기한:, 할 일: sub-lines, map them to assignee, dueNote, detail.
|
||||
- For assignee (담당자): follow the spelling already used in the minutes. Korean person names should match the company roster implied in the meeting minutes system prompt when the minutes corrected them; do not invent new spellings.
|
||||
- Do not merge multiple numbered actions into one item.
|
||||
- Deduplicate only exact duplicate titles.
|
||||
- If nothing found, return {"items":[]}.
|
||||
- All human-readable text in Korean.`;
|
||||
|
||||
/**
|
||||
* 회의록 Markdown에서 구조화 체크리스트 추출 (JSON). 업무 체크리스트 DB 자동 반영용.
|
||||
* @param {import("openai").default} openai
|
||||
* @param {{ minutesMarkdown: string, uiModel: string, resolveApiModel: (m: string) => string }} opts
|
||||
* @returns {Promise<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,
|
||||
};
|
||||
35
lib/ops-state.js
Normal file
35
lib/ops-state.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* OPS_STATE: DEV(개발), PROD(운영·임직원 이메일 로그인), SUPER(데모·제한 완화).
|
||||
* 과거 값 REAL 은 PROD 와 동일하게 처리합니다.
|
||||
*/
|
||||
function normalizeOpsState() {
|
||||
const raw = (process.env.OPS_STATE || "DEV").trim().toUpperCase();
|
||||
if (raw === "REAL") return "PROD";
|
||||
if (raw === "DEV" || raw === "PROD" || raw === "SUPER") return raw;
|
||||
return "DEV";
|
||||
}
|
||||
|
||||
function isOpsStateDev() {
|
||||
return normalizeOpsState() === "DEV";
|
||||
}
|
||||
|
||||
function isOpsStateProd() {
|
||||
return normalizeOpsState() === "PROD";
|
||||
}
|
||||
|
||||
function isOpsStateSuper() {
|
||||
return normalizeOpsState() === "SUPER";
|
||||
}
|
||||
|
||||
/** 임직원 이메일(@xavis.co.kr) 매직 링크 로그인을 강제하는 모드 (REAL 구 값 포함) */
|
||||
function isOpsProdMode() {
|
||||
return isOpsStateProd();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeOpsState,
|
||||
isOpsStateDev,
|
||||
isOpsStateProd,
|
||||
isOpsStateSuper,
|
||||
isOpsProdMode,
|
||||
};
|
||||
354
lib/parse-checklist-from-minutes.js
Normal file
354
lib/parse-checklist-from-minutes.js
Normal file
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* 회의록 generated_minutes(Markdown)에서 체크리스트·액션 섹션 추출
|
||||
* LLM 출력은 ##/### 혼용, 번호 접두, 굵게 표기 등이 섞이므로 규칙을 넓게 둔다.
|
||||
*/
|
||||
|
||||
const CHECKLIST_HEADINGS = [
|
||||
/^##\s+후속\s*확인\s*체크리스트\s*$/i,
|
||||
/^##\s+체크리스트\s*\([^)]*\)\s*$/i,
|
||||
/^##\s+체크리스트\s*$/i,
|
||||
/^##\s+후속\s*확인\s*$/i,
|
||||
];
|
||||
|
||||
const ACTION_HEADINGS = [
|
||||
/^##\s+Action\s+Items\s*$/i,
|
||||
/^##\s+Action\s+Item\s*$/i,
|
||||
/^##\s+액션\s*아이템\s*$/i,
|
||||
/^##\s+액션\s*$/i,
|
||||
];
|
||||
|
||||
function stripMd(s) {
|
||||
if (!s) return "";
|
||||
return s
|
||||
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
||||
.replace(/__([^_]+)__/g, "$1")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 제목 줄에서 비교용 텍스트 (굵게·번호 접두 제거)
|
||||
* @param {string} line
|
||||
*/
|
||||
function normalizeHeadingText(line) {
|
||||
return line
|
||||
.trim()
|
||||
.replace(/^#{1,6}\s+/, "")
|
||||
.replace(/^\d+[.)]\s*/, "")
|
||||
.replace(/\*\*/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} trimmed — 한 줄 (마크다운 제목 가능)
|
||||
*/
|
||||
function isChecklistHeadingLine(trimmed) {
|
||||
const t = normalizeHeadingText(trimmed);
|
||||
if (!t) return false;
|
||||
if (/액션\s*아이템/.test(t) && /체크리스트/.test(t)) return true;
|
||||
if (/^액션\s*아이템\s*$/.test(t) || /^action\s+items\s*$/.test(t)) return false;
|
||||
if (t.includes("체크리스트")) return true;
|
||||
if (t.includes("후속") && (t.includes("확인") || t.includes("체크"))) return true;
|
||||
if (t.includes("follow") && t.includes("check")) return true;
|
||||
if (t.includes("follow-up") || t.includes("follow up")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} trimmed
|
||||
*/
|
||||
function isActionHeadingLine(trimmed) {
|
||||
const t = normalizeHeadingText(trimmed);
|
||||
if (!t) return false;
|
||||
if (/^액션\s*아이템\s*$/.test(t) || /^action\s+items\s*$/i.test(t) || /^action\s+item\s*$/i.test(t)) return true;
|
||||
if (t.includes("액션") && (t.includes("아이템") || t.includes("항목"))) return true;
|
||||
if (t.includes("action") && (t.includes("item") || t.includes("items"))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {(trimmedLine: string) => boolean} predicate
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function extractSectionByHeadingPredicate(text, predicate) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const trimmed = lines[i].trim();
|
||||
const hm = trimmed.match(/^(#{1,6})(\s+.+)$/);
|
||||
if (!hm) continue;
|
||||
const level = hm[1].length;
|
||||
if (!predicate(trimmed)) continue;
|
||||
const body = [];
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
const tj = lines[j].trim();
|
||||
const nextHm = tj.match(/^(#{1,6})\s+/);
|
||||
if (nextHm) {
|
||||
const nextLevel = nextHm[1].length;
|
||||
if (nextLevel <= level) break;
|
||||
}
|
||||
body.push(lines[j]);
|
||||
}
|
||||
const joined = body.join("\n").trim();
|
||||
if (joined.length) return joined;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 ## 제목 전까지 본문 추출 (레거시: ## 만 구분)
|
||||
* @param {string} text
|
||||
* @param {RegExp[]} headingMatchers
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function extractSectionAfterHeading(text, headingMatchers) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const trimmed = lines[i].trim();
|
||||
for (const re of headingMatchers) {
|
||||
if (re.test(trimmed)) {
|
||||
const body = [];
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
if (/^##\s+/.test(lines[j].trim())) break;
|
||||
body.push(lines[j]);
|
||||
}
|
||||
const joined = body.join("\n").trim();
|
||||
return joined.length ? joined : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{ title: string, detail: string, assignee: string|null, due_note: string|null, completed: boolean }} ParsedItem
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} body
|
||||
* @returns {ParsedItem[]}
|
||||
*/
|
||||
function parseBulletItems(body) {
|
||||
if (!body || !body.trim()) return [];
|
||||
const lines = body.split(/\r?\n/);
|
||||
/** @type {ParsedItem[]} */
|
||||
const items = [];
|
||||
/** @type {ParsedItem|null} */
|
||||
let cur = null;
|
||||
const flush = () => {
|
||||
if (cur) {
|
||||
items.push(cur);
|
||||
cur = null;
|
||||
}
|
||||
};
|
||||
for (const raw of lines) {
|
||||
const t = raw.trim();
|
||||
if (!t) continue;
|
||||
let m = t.match(/^\s*[-*•]\s+\[([ xX✓])\]\s*(.+)$/);
|
||||
if (m) {
|
||||
flush();
|
||||
items.push({
|
||||
title: stripMd(m[2].trim()),
|
||||
detail: "",
|
||||
assignee: null,
|
||||
due_note: null,
|
||||
completed: /[xX✓]/.test(m[1]),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
m = t.match(/^\s*\[\s*([ xX✓])\s*\]\s+(.+)$/);
|
||||
if (m) {
|
||||
flush();
|
||||
items.push({
|
||||
title: stripMd(m[2].trim()),
|
||||
detail: "",
|
||||
assignee: null,
|
||||
due_note: null,
|
||||
completed: /[xX✓]/.test(m[1]),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
m = t.match(/^\s*[☐☑✓✔]\s*(.+)$/);
|
||||
if (m) {
|
||||
flush();
|
||||
const done = /^[☑✓✔]/.test(t.trim());
|
||||
items.push({
|
||||
title: stripMd(m[1].trim()),
|
||||
detail: "",
|
||||
assignee: null,
|
||||
due_note: null,
|
||||
completed: done,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
m = t.match(/^\s*[-*•]\s+(.+)$/);
|
||||
if (m) {
|
||||
flush();
|
||||
cur = { title: stripMd(m[1].trim()), detail: "", assignee: null, due_note: null, completed: false };
|
||||
continue;
|
||||
}
|
||||
m = t.match(/^\s*\d+\.\s+(.+)$/);
|
||||
if (m) {
|
||||
flush();
|
||||
cur = { title: stripMd(m[1].trim()), detail: "", assignee: null, due_note: null, completed: false };
|
||||
continue;
|
||||
}
|
||||
if (cur) {
|
||||
cur.detail += (cur.detail ? "\n" : "") + t;
|
||||
} else if (items.length) {
|
||||
const last = items[items.length - 1];
|
||||
last.detail += (last.detail ? "\n" : "") + t;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return items.filter((x) => x.title.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 액션 아이템 번호 목록 블록 (담당/기한/할 일)
|
||||
* @param {string} body
|
||||
* @returns {ParsedItem[]}
|
||||
*/
|
||||
function refineActionLines(detailText) {
|
||||
let assignee = null;
|
||||
let due_note = null;
|
||||
const rest = [];
|
||||
for (const line of (detailText || "").split(/\r?\n/)) {
|
||||
const r = line.trim();
|
||||
if (!r) continue;
|
||||
const t = r.replace(/^\*\s+/, "").trim();
|
||||
if (/담당\s*:/i.test(t)) {
|
||||
const m = t.match(/담당\s*:\s*(.+)$/i);
|
||||
if (m) assignee = stripMd(m[1].replace(/\*\*/g, "").replace(/^\*+|\*+$/g, "").trim());
|
||||
continue;
|
||||
}
|
||||
if (/기한\s*:/i.test(t)) {
|
||||
const m = t.match(/기한\s*:\s*(.+)$/i);
|
||||
if (m) due_note = stripMd(m[1].replace(/\*\*/g, "").replace(/^\*+|\*+$/g, "").trim());
|
||||
continue;
|
||||
}
|
||||
if (/할\s*일\s*:/i.test(t)) {
|
||||
const m = t.match(/할\s*일\s*:\s*(.+)$/i);
|
||||
if (m) rest.push(stripMd(m[1]));
|
||||
continue;
|
||||
}
|
||||
rest.push(r);
|
||||
}
|
||||
return { assignee, due_note, detail: rest.join("\n").trim() };
|
||||
}
|
||||
|
||||
function parseNumberedActionBlocks(body) {
|
||||
if (!body || !body.trim()) return [];
|
||||
const lines = body.split(/\r?\n/);
|
||||
/** @type {ParsedItem[]} */
|
||||
const out = [];
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i].trim();
|
||||
/** `1. 제목` 또는 `1) 제목` */
|
||||
const nm = line.match(/^(\d+)[.)]\s+(.+)$/);
|
||||
if (nm) {
|
||||
const title = stripMd(nm[2].trim());
|
||||
const rest = [];
|
||||
i++;
|
||||
while (i < lines.length) {
|
||||
const lt = lines[i].trim();
|
||||
if (/^\d+[.)]\s+/.test(lt)) break;
|
||||
if (lt) rest.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
const rawDetail = rest.join("\n").trim();
|
||||
const refined = refineActionLines(rawDetail);
|
||||
out.push({
|
||||
title,
|
||||
detail: refined.detail,
|
||||
assignee: refined.assignee,
|
||||
due_note: refined.due_note,
|
||||
completed: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return out.filter((x) => x.title.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 업무 체크리스트 자동 동기화용: 규칙 기반으로 액션(번호 목록) → 체크리스트 순으로 항목 수집(제목 기준 중복 제거)
|
||||
* @param {string} markdown
|
||||
* @returns {ParsedItem[]}
|
||||
*/
|
||||
function parseAllRuleBasedWorkItems(markdown) {
|
||||
const text = (markdown || "").trim();
|
||||
if (!text) return [];
|
||||
const actions = parseItemsFromMinutes(text, "actions");
|
||||
const checklist = parseItemsFromMinutes(text, "checklist");
|
||||
const seen = new Set();
|
||||
/** @type {ParsedItem[]} */
|
||||
const out = [];
|
||||
for (const it of [...actions, ...checklist]) {
|
||||
const t = (it.title || "").trim().toLowerCase();
|
||||
if (!t) continue;
|
||||
if (seen.has(t)) continue;
|
||||
seen.add(t);
|
||||
out.push(it);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크리스트 섹션 본문 찾기: 유연 매칭 → 레거시 정규식
|
||||
* @param {string} text
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function extractChecklistSectionBody(text) {
|
||||
const flexible = extractSectionByHeadingPredicate(text, isChecklistHeadingLine);
|
||||
if (flexible) return flexible;
|
||||
return extractSectionAfterHeading(text, CHECKLIST_HEADINGS);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function extractActionSectionBody(text) {
|
||||
const flexible = extractSectionByHeadingPredicate(text, isActionHeadingLine);
|
||||
if (flexible) return flexible;
|
||||
return extractSectionAfterHeading(text, ACTION_HEADINGS);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} generatedMinutes
|
||||
* @param {'checklist'|'actions'} mode
|
||||
* @returns {ParsedItem[]}
|
||||
*/
|
||||
function parseItemsFromMinutes(generatedMinutes, mode = "checklist") {
|
||||
const text = (generatedMinutes || "").trim();
|
||||
if (!text) return [];
|
||||
if (mode === "actions") {
|
||||
const section = extractActionSectionBody(text);
|
||||
if (!section) return [];
|
||||
const numbered = parseNumberedActionBlocks(section);
|
||||
if (numbered.length) return numbered;
|
||||
return parseBulletItems(section);
|
||||
}
|
||||
const section = extractChecklistSectionBody(text);
|
||||
if (!section) return [];
|
||||
return parseBulletItems(section);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
extractSectionAfterHeading,
|
||||
extractSectionByHeadingPredicate,
|
||||
extractChecklistSectionBody,
|
||||
extractActionSectionBody,
|
||||
parseBulletItems,
|
||||
parseNumberedActionBlocks,
|
||||
parseItemsFromMinutes,
|
||||
parseAllRuleBasedWorkItems,
|
||||
CHECKLIST_HEADINGS,
|
||||
ACTION_HEADINGS,
|
||||
isChecklistHeadingLine,
|
||||
isActionHeadingLine,
|
||||
};
|
||||
323
lib/task-checklist-store.js
Normal file
323
lib/task-checklist-store.js
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* 업무 체크리스트: PostgreSQL 또는 data/meeting-ai-checklist.json 폴백
|
||||
*/
|
||||
const path = require("path");
|
||||
const fs = require("fs/promises");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
|
||||
const CHECKLIST_FILE = path.join(__dirname, "..", "data", "meeting-ai-checklist.json");
|
||||
|
||||
let writeChain = Promise.resolve();
|
||||
|
||||
function withFileLock(fn) {
|
||||
const p = writeChain.then(() => fn());
|
||||
writeChain = p.catch(() => {});
|
||||
return p;
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
async function readFileStore() {
|
||||
try {
|
||||
const raw = await fs.readFile(CHECKLIST_FILE, "utf8");
|
||||
const j = JSON.parse(raw);
|
||||
if (!j || typeof j !== "object") return { items: [] };
|
||||
if (!Array.isArray(j.items)) j.items = [];
|
||||
return j;
|
||||
} catch {
|
||||
return { items: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function writeFileStore(data) {
|
||||
const dir = path.dirname(CHECKLIST_FILE);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const tmp = `${CHECKLIST_FILE}.${process.pid}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(data, null, 2), "utf8");
|
||||
await fs.rename(tmp, CHECKLIST_FILE);
|
||||
}
|
||||
|
||||
function rowToApi(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
userEmail: row.user_email,
|
||||
meetingId: row.meeting_id || null,
|
||||
title: row.title || "",
|
||||
detail: row.detail || "",
|
||||
assignee: row.assignee || null,
|
||||
dueNote: row.due_note || null,
|
||||
completed: row.completed === true,
|
||||
completedAt: row.completed_at ? new Date(row.completed_at).toISOString() : null,
|
||||
completionNote:
|
||||
row.completion_note == null || !String(row.completion_note).trim()
|
||||
? null
|
||||
: String(row.completion_note),
|
||||
sortOrder: Number(row.sort_order) || 0,
|
||||
source: row.source || "manual",
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : nowIso(),
|
||||
updatedAt: row.updated_at ? new Date(row.updated_at).toISOString() : nowIso(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
* @param {string} email
|
||||
* @param {{ completed?: boolean|null, meetingId?: string|null }} [filters]
|
||||
*/
|
||||
async function listItems(pgPool, email, filters = {}) {
|
||||
if (pgPool) {
|
||||
const params = [email];
|
||||
let sql = `SELECT * FROM meeting_ai_checklist_items WHERE user_email = $1`;
|
||||
if (filters.completed === true) sql += ` AND completed = true`;
|
||||
else if (filters.completed === false) sql += ` AND completed = false`;
|
||||
if (filters.meetingId) {
|
||||
params.push(filters.meetingId);
|
||||
sql += ` AND meeting_id = $${params.length}::uuid`;
|
||||
}
|
||||
sql += ` ORDER BY completed ASC, sort_order ASC, updated_at DESC`;
|
||||
const r = await pgPool.query(sql, params);
|
||||
return (r.rows || []).map(rowToApi);
|
||||
}
|
||||
const data = await readFileStore();
|
||||
let items = data.items.filter((x) => x.user_email === email);
|
||||
if (filters.completed === true) items = items.filter((x) => x.completed === true);
|
||||
else if (filters.completed === false) items = items.filter((x) => !x.completed);
|
||||
if (filters.meetingId) items = items.filter((x) => x.meeting_id === filters.meetingId);
|
||||
items.sort((a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
return new Date(b.updated_at) - new Date(a.updated_at);
|
||||
});
|
||||
return items.map(rowToApi);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
*/
|
||||
async function insertItem(pgPool, email, fields) {
|
||||
const title = (fields.title || "").toString().trim().slice(0, 2000);
|
||||
if (!title) throw new Error("제목이 필요합니다.");
|
||||
const detail = (fields.detail || "").toString().slice(0, 8000);
|
||||
const assignee = fields.assignee != null ? String(fields.assignee).slice(0, 300) : null;
|
||||
const dueNote = fields.dueNote != null ? String(fields.dueNote).slice(0, 300) : null;
|
||||
const meetingId = fields.meetingId || null;
|
||||
const source = fields.source === "imported" ? "imported" : "manual";
|
||||
const completed = fields.completed === true;
|
||||
const sortOrder = Number.isFinite(Number(fields.sortOrder)) ? Number(fields.sortOrder) : 0;
|
||||
|
||||
const completionNote =
|
||||
fields.completionNote != null && String(fields.completionNote).trim()
|
||||
? String(fields.completionNote).slice(0, 8000)
|
||||
: null;
|
||||
|
||||
if (pgPool) {
|
||||
const r = await pgPool.query(
|
||||
`INSERT INTO meeting_ai_checklist_items (
|
||||
user_email, meeting_id, title, detail, assignee, due_note, completed, completed_at, sort_order, source, completion_note
|
||||
) VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING *`,
|
||||
[
|
||||
email,
|
||||
meetingId,
|
||||
title,
|
||||
detail || null,
|
||||
assignee,
|
||||
dueNote,
|
||||
completed,
|
||||
completed ? new Date() : null,
|
||||
sortOrder,
|
||||
source,
|
||||
completionNote,
|
||||
]
|
||||
);
|
||||
return rowToApi(r.rows?.[0]);
|
||||
}
|
||||
return withFileLock(async () => {
|
||||
const data = await readFileStore();
|
||||
const t = nowIso();
|
||||
const row = {
|
||||
id: uuidv4(),
|
||||
user_email: email,
|
||||
meeting_id: meetingId,
|
||||
title,
|
||||
detail: detail || null,
|
||||
assignee,
|
||||
due_note: dueNote,
|
||||
completed,
|
||||
completed_at: completed ? t : null,
|
||||
completion_note: completionNote,
|
||||
sort_order: sortOrder,
|
||||
source,
|
||||
created_at: t,
|
||||
updated_at: t,
|
||||
};
|
||||
data.items.push(row);
|
||||
await writeFileStore(data);
|
||||
return rowToApi(row);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
*/
|
||||
async function updateItem(pgPool, id, email, fields) {
|
||||
if (pgPool) {
|
||||
const cur = await pgPool.query(
|
||||
`SELECT * FROM meeting_ai_checklist_items WHERE id = $1::uuid AND user_email = $2`,
|
||||
[id, email]
|
||||
);
|
||||
if (!cur.rows?.[0]) return null;
|
||||
const row = cur.rows[0];
|
||||
const title = fields.title != null ? String(fields.title).trim().slice(0, 2000) : row.title;
|
||||
const detail =
|
||||
fields.detail !== undefined
|
||||
? fields.detail != null && String(fields.detail).trim()
|
||||
? String(fields.detail).slice(0, 8000)
|
||||
: null
|
||||
: row.detail;
|
||||
const assignee = fields.assignee !== undefined ? (fields.assignee ? String(fields.assignee).slice(0, 300) : null) : row.assignee;
|
||||
const dueNote = fields.dueNote !== undefined ? (fields.dueNote ? String(fields.dueNote).slice(0, 300) : null) : row.due_note;
|
||||
let completed = row.completed;
|
||||
let completedAt = row.completed_at;
|
||||
let completionNote = row.completion_note;
|
||||
if (fields.completed !== undefined) {
|
||||
completed = fields.completed === true;
|
||||
completedAt = completed ? new Date() : null;
|
||||
}
|
||||
if (fields.completionNote !== undefined) {
|
||||
completionNote =
|
||||
fields.completionNote != null && String(fields.completionNote).trim()
|
||||
? String(fields.completionNote).slice(0, 8000)
|
||||
: null;
|
||||
}
|
||||
const r = await pgPool.query(
|
||||
`UPDATE meeting_ai_checklist_items SET
|
||||
title = $3, detail = $4, assignee = $5, due_note = $6,
|
||||
completed = $7, completed_at = $8, completion_note = $9, updated_at = NOW()
|
||||
WHERE id = $1::uuid AND user_email = $2
|
||||
RETURNING *`,
|
||||
[id, email, title, detail, assignee, dueNote, completed, completedAt, completionNote]
|
||||
);
|
||||
return rowToApi(r.rows?.[0]);
|
||||
}
|
||||
return withFileLock(async () => {
|
||||
const data = await readFileStore();
|
||||
const idx = data.items.findIndex((x) => x.id === id && x.user_email === email);
|
||||
if (idx < 0) return null;
|
||||
const row = data.items[idx];
|
||||
if (fields.title != null) row.title = String(fields.title).trim().slice(0, 2000);
|
||||
if (fields.detail !== undefined) {
|
||||
row.detail =
|
||||
fields.detail != null && String(fields.detail).trim() ? String(fields.detail).slice(0, 8000) : null;
|
||||
}
|
||||
if (fields.assignee !== undefined) row.assignee = fields.assignee ? String(fields.assignee).slice(0, 300) : null;
|
||||
if (fields.dueNote !== undefined) row.due_note = fields.dueNote ? String(fields.dueNote).slice(0, 300) : null;
|
||||
if (fields.completed !== undefined) {
|
||||
row.completed = fields.completed === true;
|
||||
row.completed_at = row.completed ? nowIso() : null;
|
||||
}
|
||||
if (fields.completionNote !== undefined) {
|
||||
row.completion_note =
|
||||
fields.completionNote != null && String(fields.completionNote).trim()
|
||||
? String(fields.completionNote).slice(0, 8000)
|
||||
: null;
|
||||
}
|
||||
row.updated_at = nowIso();
|
||||
await writeFileStore(data);
|
||||
return rowToApi(row);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
*/
|
||||
async function deleteItem(pgPool, id, email) {
|
||||
if (pgPool) {
|
||||
const r = await pgPool.query(`DELETE FROM meeting_ai_checklist_items WHERE id = $1::uuid AND user_email = $2 RETURNING id`, [
|
||||
id,
|
||||
email,
|
||||
]);
|
||||
return { deleted: !!r.rowCount };
|
||||
}
|
||||
return withFileLock(async () => {
|
||||
const data = await readFileStore();
|
||||
const idx = data.items.findIndex((x) => x.id === id && x.user_email === email);
|
||||
if (idx < 0) return { deleted: false };
|
||||
data.items.splice(idx, 1);
|
||||
await writeFileStore(data);
|
||||
return { deleted: true };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 회의록 삭제 시 연동: 해당 회의에서 가져온 업무 체크리스트 항목 일괄 삭제
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
* @param {string} meetingId
|
||||
* @param {string} email
|
||||
* @returns {Promise<{ removed: number }>}
|
||||
*/
|
||||
async function deleteItemsByMeetingId(pgPool, meetingId, email) {
|
||||
if (!meetingId) return { removed: 0 };
|
||||
if (pgPool) {
|
||||
const r = await pgPool.query(
|
||||
`DELETE FROM meeting_ai_checklist_items WHERE meeting_id = $1::uuid AND user_email = $2`,
|
||||
[meetingId, email]
|
||||
);
|
||||
return { removed: r.rowCount || 0 };
|
||||
}
|
||||
return withFileLock(async () => {
|
||||
const data = await readFileStore();
|
||||
const mid = String(meetingId);
|
||||
const before = data.items.length;
|
||||
data.items = data.items.filter((x) => !(String(x.meeting_id || "") === mid && x.user_email === email));
|
||||
const removed = before - data.items.length;
|
||||
if (removed > 0) await writeFileStore(data);
|
||||
return { removed };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 회의록 본문에서 항목 파싱 후 삽입 (중복 title+meeting_id 스킵)
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
* @param {string} email
|
||||
* @param {string} meetingId
|
||||
* @param {Array<{ title: string, detail?: string, assignee?: string|null, due_note?: string|null, completed?: boolean }>} parsed
|
||||
*/
|
||||
async function insertImportedBatch(pgPool, email, meetingId, parsed) {
|
||||
const inserted = [];
|
||||
const existing = await listItems(pgPool, email, { meetingId });
|
||||
const keys = new Set(existing.map((e) => `${(e.title || "").trim()}::${e.meetingId || ""}`));
|
||||
|
||||
let order = existing.length;
|
||||
for (const p of parsed) {
|
||||
const title = (p.title || "").trim();
|
||||
if (!title) continue;
|
||||
const key = `${title}::${meetingId}`;
|
||||
if (keys.has(key)) continue;
|
||||
keys.add(key);
|
||||
const item = await insertItem(pgPool, email, {
|
||||
title,
|
||||
detail: p.detail || "",
|
||||
assignee: p.assignee || null,
|
||||
dueNote: p.due_note || null,
|
||||
meetingId,
|
||||
source: "imported",
|
||||
completed: !!p.completed,
|
||||
sortOrder: order++,
|
||||
});
|
||||
inserted.push(item);
|
||||
}
|
||||
return inserted;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CHECKLIST_FILE,
|
||||
listItems,
|
||||
insertItem,
|
||||
updateItem,
|
||||
deleteItem,
|
||||
deleteItemsByMeetingId,
|
||||
insertImportedBatch,
|
||||
};
|
||||
185
lib/thumbnail-events-store.js
Normal file
185
lib/thumbnail-events-store.js
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* PPT 썸네일 작업 이벤트 로그 — PostgreSQL 우선, PG 없을 때만 data/thumbnail-events.json 폴백
|
||||
*/
|
||||
const fs = require("fs/promises");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
|
||||
/**
|
||||
* @param {import("pg").QueryResultRow} row
|
||||
*/
|
||||
function mapRowToEvent(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
at: row.occurred_at ? new Date(row.occurred_at).toISOString() : null,
|
||||
type: row.event_type,
|
||||
lectureId: row.lecture_id || undefined,
|
||||
lectureTitle: row.lecture_title || undefined,
|
||||
reason: row.reason || undefined,
|
||||
force: row.force_flag === true,
|
||||
queueSizeAfter: row.queue_size_after != null ? Number(row.queue_size_after) : undefined,
|
||||
retryCount: row.retry_count != null ? Number(row.retry_count) : undefined,
|
||||
durationMs: row.duration_ms != null ? Number(row.duration_ms) : undefined,
|
||||
error: row.error_text || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
* @param {string} jsonPath
|
||||
*/
|
||||
async function readThumbnailEvents(pgPool, jsonPath) {
|
||||
if (pgPool) {
|
||||
const r = await pgPool.query(
|
||||
`SELECT id, occurred_at, event_type, lecture_id, lecture_title, reason, force_flag,
|
||||
queue_size_after, retry_count, duration_ms, error_text
|
||||
FROM lecture_thumbnail_events
|
||||
ORDER BY occurred_at ASC`
|
||||
);
|
||||
return (r.rows || []).map(mapRowToEvent);
|
||||
}
|
||||
try {
|
||||
const raw = await fs.readFile(jsonPath, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool} pgPool
|
||||
* @param {number} keep
|
||||
*/
|
||||
async function pruneThumbnailEvents(pgPool, keep) {
|
||||
const k = Math.max(Number(keep) || 200, 20);
|
||||
await pgPool.query(
|
||||
`DELETE FROM lecture_thumbnail_events a
|
||||
USING (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (ORDER BY occurred_at DESC) AS rn
|
||||
FROM lecture_thumbnail_events
|
||||
) t WHERE t.rn > $1
|
||||
) d
|
||||
WHERE a.id = d.id`,
|
||||
[k]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
* @param {string} jsonPath
|
||||
* @param {number} keep
|
||||
* @param {object} payload
|
||||
*/
|
||||
async function appendThumbnailEvent(pgPool, jsonPath, keep, payload) {
|
||||
const id = uuidv4();
|
||||
const at = new Date().toISOString();
|
||||
if (pgPool) {
|
||||
await pgPool.query(
|
||||
`INSERT INTO lecture_thumbnail_events (
|
||||
id, occurred_at, event_type, lecture_id, lecture_title, reason, force_flag,
|
||||
queue_size_after, retry_count, duration_ms, error_text
|
||||
) VALUES ($1, $2::timestamptz, $3, $4::uuid, $5, $6, $7, $8, $9, $10, $11)`,
|
||||
[
|
||||
id,
|
||||
at,
|
||||
payload.type || "unknown",
|
||||
payload.lectureId || null,
|
||||
payload.lectureTitle || null,
|
||||
payload.reason || null,
|
||||
payload.force === true,
|
||||
payload.queueSizeAfter != null ? payload.queueSizeAfter : null,
|
||||
payload.retryCount != null ? payload.retryCount : null,
|
||||
payload.durationMs != null ? payload.durationMs : null,
|
||||
payload.error || null,
|
||||
]
|
||||
);
|
||||
await pruneThumbnailEvents(pgPool, keep);
|
||||
return;
|
||||
}
|
||||
const events = await readThumbnailEvents(null, jsonPath);
|
||||
events.push({ id, at, ...payload });
|
||||
const sliced = events.slice(-Math.max(keep, 20));
|
||||
await fs.writeFile(jsonPath, JSON.stringify(sliced, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
* @param {string} jsonPath
|
||||
*/
|
||||
async function clearThumbnailEvents(pgPool, jsonPath) {
|
||||
if (pgPool) {
|
||||
await pgPool.query(`DELETE FROM lecture_thumbnail_events`);
|
||||
return;
|
||||
}
|
||||
await fs.writeFile(jsonPath, "[]", "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 JSON 파일을 한 번 DB로 옮긴 뒤 파일을 백업 이름으로 변경 (PG 사용 시)
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
* @param {string} jsonPath
|
||||
*/
|
||||
/**
|
||||
* @param {import("pg").Pool | null} pgPool
|
||||
* @param {string} jsonPath
|
||||
* @param {number} [keep]
|
||||
*/
|
||||
async function migrateThumbnailEventsFromJson(pgPool, jsonPath, keep) {
|
||||
if (!pgPool) return;
|
||||
const keepN = Math.max(Number(keep) || 200, 20);
|
||||
let rows;
|
||||
try {
|
||||
const raw = await fs.readFile(jsonPath, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
rows = Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (rows.length === 0) return;
|
||||
|
||||
for (const evt of rows) {
|
||||
if (!evt || !evt.id || !evt.type) continue;
|
||||
try {
|
||||
await pgPool.query(
|
||||
`INSERT INTO lecture_thumbnail_events (
|
||||
id, occurred_at, event_type, lecture_id, lecture_title, reason, force_flag,
|
||||
queue_size_after, retry_count, duration_ms, error_text
|
||||
) VALUES ($1::uuid, $2::timestamptz, $3, $4::uuid, $5, $6, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
[
|
||||
evt.id,
|
||||
evt.at || new Date().toISOString(),
|
||||
evt.type,
|
||||
evt.lectureId || null,
|
||||
evt.lectureTitle || null,
|
||||
evt.reason || null,
|
||||
evt.force === true,
|
||||
evt.queueSizeAfter != null ? evt.queueSizeAfter : null,
|
||||
evt.retryCount != null ? evt.retryCount : null,
|
||||
evt.durationMs != null ? evt.durationMs : null,
|
||||
evt.error || null,
|
||||
]
|
||||
);
|
||||
} catch {
|
||||
/* ignore row */
|
||||
}
|
||||
}
|
||||
await pruneThumbnailEvents(pgPool, keepN);
|
||||
try {
|
||||
const fsSync = require("fs");
|
||||
if (fsSync.existsSync(jsonPath)) {
|
||||
fsSync.renameSync(jsonPath, `${jsonPath}.migrated.bak`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[thumbnail-events] JSON migration rename failed:", e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
readThumbnailEvents,
|
||||
appendThumbnailEvent,
|
||||
clearThumbnailEvents,
|
||||
migrateThumbnailEventsFromJson,
|
||||
};
|
||||
Reference in New Issue
Block a user