459 lines
15 KiB
JavaScript
459 lines
15 KiB
JavaScript
/**
|
|
* 회의록 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,
|
|
};
|