// src/main/services/MeetingSummaryService.ts // Phase 12.2: 회의록 자동 요약 서비스 // CaptionService 세션 종료 후 LLM으로 요약 생성 import { EventEmitter } from 'events' import path from 'path' import fs from 'fs' import { app, dialog } from 'electron' import { eq } from 'drizzle-orm' import { getLogger } from './LoggerService' import { getLocalLLMService } from './LocalLLMService' import { getDatabase } from '../db' import { history } from '../db/schema' import { getMainWindow } from '../windows/WindowManager' import { IPC_CHANNELS } from '@shared/ipc-channels' import { D3ROError, ErrorCode } from '@shared/errors' import type { MeetingSummaryResult, MeetingSummaryProgress } from '@shared/types' const logger = getLogger('MeetingSummaryService') const MEETING_SUMMARY_PROMPT = `다음은 회의 전사록입니다. 아래 형식으로 정리해주세요: ## 요약 (3줄 이내 핵심 요약) ## 핵심 결정사항 - (결정 1) - (결정 2) ## 할 일 목록 - [ ] (할 일 1) - [ ] (할 일 2) 전사록 외의 내용을 추가하지 마세요. 전사록이 비어 있거나 내용이 부족하면 "요약할 내용이 충분하지 않습니다"라고만 출력하세요.` class MeetingSummaryService extends EventEmitter { /** * 히스토리 항목의 전사 텍스트를 LLM으로 요약 */ async summarize(historyId: string): Promise { // 라이센스 체크 try { const { getLicenseService } = await import('./LicenseService') const { Feature } = await import('@shared/types') const license = getLicenseService() const access = license.canUse(Feature.MEETING_SUMMARY) if (!access.allowed) { license.promptUpgrade( Feature.MEETING_SUMMARY, access.reason === 'quota_exceeded' ? 'quota_exceeded' : 'tier_required', ) throw new D3ROError(ErrorCode.FeatureNotAvailable, 'Pro+ required for meeting summary') } } catch (err) { if (err instanceof D3ROError) throw err } // 히스토리 항목 조회 const db = getDatabase() const rows = db.select().from(history).where(eq(history.id, historyId)).all() if (rows.length === 0) { throw new D3ROError(ErrorCode.HistoryNotFound, `History not found: ${historyId}`) } const entry = rows[0] const transcript = entry.originalText if (!transcript || transcript.trim().length === 0) { throw new D3ROError(ErrorCode.MeetingSummaryNoTranscript, 'No transcript text to summarize') } // 진행 상태 알림 this._sendProgress(historyId, 'generating') try { // LLM 요약 생성 const llmService = getLocalLLMService() const result = await llmService.generate(transcript, { systemPrompt: MEETING_SUMMARY_PROMPT, temperature: 0.3, }) const rawMarkdown = result.text.trim() const parsed = this._parseMarkdown(rawMarkdown) const summaryResult: MeetingSummaryResult = { historyId, summary: parsed.summary, decisions: parsed.decisions, actionItems: parsed.actionItems, rawMarkdown, generatedAt: Date.now(), } // DB에 요약 저장 db.update(history) .set({ summaryText: rawMarkdown, updatedAt: Date.now() }) .where(eq(history.id, historyId)) .run() this._sendProgress(historyId, 'done') this._sendToRenderer(IPC_CHANNELS.MEETING_SUMMARY.SUMMARY_READY, summaryResult) this.emit('summary-ready', summaryResult) return summaryResult } catch (err) { this._sendProgress(historyId, 'error') if (err instanceof D3ROError) throw err const msg = err instanceof Error ? err.message : String(err) throw new D3ROError(ErrorCode.MeetingSummaryGenerationFailed, msg) } } /** * 저장된 요약 조회 */ getSummary(historyId: string): MeetingSummaryResult | null { const db = getDatabase() const rows = db.select().from(history).where(eq(history.id, historyId)).all() if (rows.length === 0 || !rows[0].summaryText) { return null } const entry = rows[0] const parsed = this._parseMarkdown(entry.summaryText!) return { historyId, summary: parsed.summary, decisions: parsed.decisions, actionItems: parsed.actionItems, rawMarkdown: entry.summaryText!, generatedAt: entry.updatedAt, } } /** * 마크다운 파일로 내보내기 */ async exportMarkdown(historyId: string): Promise { const summary = this.getSummary(historyId) if (!summary) { throw new D3ROError(ErrorCode.MeetingSummaryExportFailed, 'No summary to export') } const db = getDatabase() const rows = db.select().from(history).where(eq(history.id, historyId)).all() const entry = rows[0] const date = new Date(entry.createdAt) const dateStr = date.toISOString().slice(0, 10) const defaultName = `meeting-summary-${dateStr}.md` const result = await dialog.showSaveDialog({ defaultPath: path.join(app.getPath('documents'), defaultName), filters: [{ name: 'Markdown', extensions: ['md'] }], }) if (result.canceled || !result.filePath) { throw new D3ROError(ErrorCode.MeetingSummaryExportFailed, 'Export cancelled') } const content = `# Meeting Summary — ${dateStr}\n\n${summary.rawMarkdown}\n\n---\n\n## Full Transcript\n\n${entry.originalText}\n` fs.writeFileSync(result.filePath, content, 'utf-8') return result.filePath } /** * CaptionService 세션 종료 시 자동 요약 (옵션) */ async onCaptionSessionSaved(sessionSummary: { sessionId: string }): Promise { // caption 모드 히스토리에서 해당 세션 찾기 const db = getDatabase() const rows = db .select() .from(history) .where(eq(history.id, sessionSummary.sessionId)) .all() if (rows.length === 0) { logger.warn(`Caption session not found in history: ${sessionSummary.sessionId}`) return } const entry = rows[0] if (!entry.originalText || entry.originalText.trim().length < 50) { logger.info('Caption session too short for summary, skipping') return } try { await this.summarize(entry.id) logger.info(`Auto-summary generated for caption session: ${entry.id}`) } catch (err) { logger.warn('Auto-summary failed:', err) } } private _parseMarkdown(markdown: string): { summary: string decisions: string[] actionItems: string[] } { const sections = markdown.split(/^## /m) let summary = '' const decisions: string[] = [] const actionItems: string[] = [] for (const section of sections) { const lines = section.trim().split('\n') const heading = lines[0]?.trim().toLowerCase() ?? '' const body = lines.slice(1).join('\n').trim() if (heading.includes('요약') || heading.includes('summary')) { summary = body } else if (heading.includes('결정') || heading.includes('decision')) { const items = body.split('\n').filter((l) => l.trim().startsWith('-')) decisions.push(...items.map((l) => l.replace(/^-\s*/, '').trim())) } else if (heading.includes('할 일') || heading.includes('action')) { const items = body.split('\n').filter((l) => l.trim().startsWith('-')) actionItems.push( ...items.map((l) => l.replace(/^-\s*\[[ x]?\]\s*/, '').trim()), ) } } return { summary, decisions, actionItems } } private _sendProgress(historyId: string, status: MeetingSummaryProgress['status']): void { this._sendToRenderer(IPC_CHANNELS.MEETING_SUMMARY.SUMMARY_PROGRESS, { historyId, status }) } private _sendToRenderer(channel: string, data: unknown): void { try { const mainWindow = getMainWindow() if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(channel, data) } } catch { // 윈도우 없으면 무시 } } dispose(): void { this.removeAllListeners() } } // ── 싱글톤 ── let instance: MeetingSummaryService | null = null export function getMeetingSummaryService(): MeetingSummaryService { if (!instance) { instance = new MeetingSummaryService() } return instance }