Files
ai_platform/lib/meeting-minutes-store.js

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,
};