마이크 캡처 강제(시스템 오디오→마이크), CaptionOverlay 숨김, 오디오 레벨 인디케이터 추가, 상태 전환 꼬임 수정, 전사 타임스탬프 상대 시간 변환.
785 lines
26 KiB
TypeScript
785 lines
26 KiB
TypeScript
// src/main/services/MeetingModeService.ts
|
|
// Phase 14: Meeting Mode — 실시간 녹음 + 타임스탬프 메모 + LLM 구조화 회의록
|
|
// 싱글톤 + EventEmitter 패턴. CaptionService 연동.
|
|
|
|
import { EventEmitter } from 'events'
|
|
import { BrowserWindow, Notification, dialog } from 'electron'
|
|
import fs from 'fs'
|
|
import { nanoid } from 'nanoid'
|
|
import { eq, desc, sql } from 'drizzle-orm'
|
|
import { getLogger } from './LoggerService'
|
|
import { configGet } from './ConfigService'
|
|
import { getMainWindow } from '../windows/WindowManager'
|
|
import { getDatabase } from '../db'
|
|
import { meetingSessions, meetingMemos } from '../db/schema'
|
|
import { IPC_CHANNELS } from '@shared/ipc-channels'
|
|
import { D3ROError, ErrorCode } from '@shared/errors'
|
|
import type {
|
|
MeetingModeState,
|
|
MeetingMemo,
|
|
MeetingSessionSummary,
|
|
MeetingSessionDetail,
|
|
MeetingMinutes,
|
|
MeetingSessionPage,
|
|
MeetingStartResult,
|
|
MeetingModeStateInfo,
|
|
MeetingProcessingStep,
|
|
MeetingProcessingProgress,
|
|
CaptionSegment,
|
|
} from '@shared/types'
|
|
|
|
const logger = getLogger('MeetingModeService')
|
|
|
|
const MEETING_MODE_SYSTEM_PROMPT = `당신은 전문 회의록 작성 비서입니다.
|
|
전사록과 참석자 메모를 분석하여 구조화된 회의록을 작성합니다.
|
|
|
|
규칙:
|
|
- 전사록/메모에 없는 내용을 추가하지 마세요
|
|
- 메모(📝)는 참석자가 직접 작성한 핵심 포인트이므로 우선 반영하세요
|
|
- 시간 순서를 유지하세요
|
|
- 한국어로 작성하세요 (원문이 영어면 원어 유지)
|
|
|
|
출력 형식:
|
|
## 요약
|
|
(3-5줄 핵심 요약)
|
|
|
|
## 핵심 결정사항
|
|
- (결정 1)
|
|
- (결정 2)
|
|
|
|
## 할 일 목록
|
|
- [ ] (담당자가 있으면 포함) (할 일)
|
|
|
|
## 타임라인
|
|
| 시간 | 내용 |
|
|
|------|------|
|
|
| MM:SS | 주요 발언/이벤트 |`
|
|
|
|
/** 긴 회의 청크 분할 기준 (자) */
|
|
const LONG_MEETING_THRESHOLD = 20000
|
|
const CHUNK_SIZE = 5000
|
|
|
|
interface MeetingModeServiceEvents {
|
|
'state-changed': (state: MeetingModeState) => void
|
|
'segment': (segment: CaptionSegment) => void
|
|
'memo-added': (memo: MeetingMemo) => void
|
|
'processing-progress': (progress: MeetingProcessingProgress) => void
|
|
'session-completed': (detail: MeetingSessionDetail) => void
|
|
'error': (error: D3ROError) => void
|
|
}
|
|
|
|
class MeetingModeService extends EventEmitter {
|
|
private _state: MeetingModeState = 'idle'
|
|
private _sessionId: string | null = null
|
|
private _sessionStartedAt: number | null = null
|
|
private _segments: CaptionSegment[] = []
|
|
private _memos: MeetingMemo[] = []
|
|
private _segmentHandler: ((segment: CaptionSegment) => void) | null = null
|
|
private _audioDataHandler: ((payload: { buffer: Buffer; timestamp: number }) => void) | null = null
|
|
private _audioLevelTimer: ReturnType<typeof setInterval> | null = null
|
|
private _lastRms = 0
|
|
/** CaptionService session-saved 방지 플래그 */
|
|
private _meetingModeActive = false
|
|
|
|
// ── 공개 접근자 ──
|
|
|
|
getState(): MeetingModeState {
|
|
return this._state
|
|
}
|
|
|
|
getStateInfo(): MeetingModeStateInfo {
|
|
return {
|
|
state: this._state,
|
|
sessionId: this._sessionId,
|
|
elapsedMs: this._sessionStartedAt ? Date.now() - this._sessionStartedAt : 0,
|
|
memoCount: this._memos.length,
|
|
segmentCount: this._segments.length,
|
|
}
|
|
}
|
|
|
|
isMeetingModeActive(): boolean {
|
|
return this._meetingModeActive
|
|
}
|
|
|
|
// ── 녹음 시작 ──
|
|
|
|
async startRecording(): Promise<MeetingStartResult> {
|
|
if (this._state !== 'idle') {
|
|
throw new D3ROError(
|
|
ErrorCode.MeetingAlreadyRecording,
|
|
`회의 모드가 이미 활성 상태입니다: ${this._state}`,
|
|
)
|
|
}
|
|
|
|
// CaptionService 상태 확인
|
|
const { getCaptionService } = await import('./CaptionService')
|
|
const captionService = getCaptionService()
|
|
const captionState = captionService.getState()
|
|
if (captionState !== 'inactive') {
|
|
throw new D3ROError(
|
|
ErrorCode.MeetingAlreadyRecording,
|
|
'자막 모드가 활성 상태입니다. 먼저 자막을 종료해주세요.',
|
|
)
|
|
}
|
|
|
|
this._setState('recording')
|
|
this._meetingModeActive = true
|
|
|
|
const sessionId = nanoid()
|
|
const now = Date.now()
|
|
|
|
this._sessionId = sessionId
|
|
this._sessionStartedAt = now
|
|
this._segments = []
|
|
this._memos = []
|
|
|
|
// DB에 세션 생성
|
|
const db = getDatabase()
|
|
db.insert(meetingSessions).values({
|
|
id: sessionId,
|
|
status: 'recording',
|
|
startedAt: now,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}).run()
|
|
|
|
// CaptionService 시작 (오버레이 숨김 상태로 — 회의 모드 UI에서 자체 표시)
|
|
this._segmentHandler = (segment: CaptionSegment) => {
|
|
this._segments.push(segment)
|
|
this.emit('segment', segment)
|
|
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.SEGMENT, segment)
|
|
}
|
|
captionService.on('segment', this._segmentHandler)
|
|
|
|
// 회의 모드는 마이크 캡처 강제 (시스템 오디오가 아닌 마이크)
|
|
const prevAudioSource = captionService.getConfig().audioSource
|
|
captionService.setConfig({ audioSource: 'mic' })
|
|
|
|
try {
|
|
await captionService.start()
|
|
// 회의 모드 UI가 자체 자막 패널에 표시하므로 오버레이 숨김
|
|
const { hideCaptionOverlay } = await import('../windows/WindowManager')
|
|
hideCaptionOverlay()
|
|
} catch (err) {
|
|
// 오디오 소스 복원
|
|
captionService.setConfig({ audioSource: prevAudioSource })
|
|
// 시작 실패 시 복원
|
|
captionService.off('segment', this._segmentHandler)
|
|
this._segmentHandler = null
|
|
this._meetingModeActive = false
|
|
this._sessionId = null
|
|
this._sessionStartedAt = null
|
|
|
|
db.update(meetingSessions).set({
|
|
status: 'error',
|
|
errorMessage: err instanceof Error ? err.message : String(err),
|
|
updatedAt: Date.now(),
|
|
}).where(eq(meetingSessions.id, sessionId)).run()
|
|
|
|
this._setState('idle')
|
|
throw err
|
|
}
|
|
|
|
// 오디오 레벨 모니터링 시작
|
|
const { getAudioCaptureService, calculateRMS } = await import('./AudioCaptureService')
|
|
const audioCaptureService = getAudioCaptureService()
|
|
this._audioDataHandler = (payload: { buffer: Buffer; timestamp: number }) => {
|
|
this._lastRms = calculateRMS(payload.buffer)
|
|
}
|
|
audioCaptureService.on('audio-data', this._audioDataHandler)
|
|
|
|
this._audioLevelTimer = setInterval(() => {
|
|
this._sendToRenderer('meetingMode:audioLevel', { level: this._lastRms })
|
|
}, 100)
|
|
|
|
logger.info(`회의 녹음 시작: sessionId=${sessionId}`)
|
|
this._sendStateToRenderer()
|
|
|
|
return { sessionId }
|
|
}
|
|
|
|
// ── 메모 추가 ──
|
|
|
|
async addMemo(content: string): Promise<MeetingMemo> {
|
|
if (this._state !== 'recording' || !this._sessionId || !this._sessionStartedAt) {
|
|
throw new D3ROError(ErrorCode.MeetingNotRecording, '녹음 중이 아닙니다')
|
|
}
|
|
|
|
const memo: MeetingMemo = {
|
|
id: nanoid(),
|
|
sessionId: this._sessionId,
|
|
content,
|
|
timestampMs: Date.now() - this._sessionStartedAt,
|
|
createdAt: Date.now(),
|
|
}
|
|
|
|
this._memos.push(memo)
|
|
|
|
// DB 저장
|
|
const db = getDatabase()
|
|
db.insert(meetingMemos).values({
|
|
id: memo.id,
|
|
sessionId: memo.sessionId,
|
|
content: memo.content,
|
|
timestampMs: memo.timestampMs,
|
|
createdAt: memo.createdAt,
|
|
}).run()
|
|
|
|
this.emit('memo-added', memo)
|
|
logger.debug(`메모 추가: [${this._formatTime(memo.timestampMs)}] ${content}`)
|
|
|
|
return memo
|
|
}
|
|
|
|
// ── 녹음 종료 ──
|
|
|
|
async stopRecording(): Promise<void> {
|
|
if (this._state !== 'recording') {
|
|
throw new D3ROError(ErrorCode.MeetingNotRecording, '녹음 중이 아닙니다')
|
|
}
|
|
|
|
const sessionId = this._sessionId!
|
|
const now = Date.now()
|
|
|
|
// 오디오 레벨 모니터링 정리
|
|
if (this._audioLevelTimer) {
|
|
clearInterval(this._audioLevelTimer)
|
|
this._audioLevelTimer = null
|
|
}
|
|
if (this._audioDataHandler) {
|
|
const { getAudioCaptureService } = await import('./AudioCaptureService')
|
|
getAudioCaptureService().off('audio-data', this._audioDataHandler)
|
|
this._audioDataHandler = null
|
|
}
|
|
|
|
// CaptionService 종료
|
|
const { getCaptionService } = await import('./CaptionService')
|
|
const captionService = getCaptionService()
|
|
|
|
if (this._segmentHandler) {
|
|
captionService.off('segment', this._segmentHandler)
|
|
this._segmentHandler = null
|
|
}
|
|
|
|
await captionService.stop()
|
|
|
|
// 세션 종료 시간 기록
|
|
const db = getDatabase()
|
|
db.update(meetingSessions).set({
|
|
endedAt: now,
|
|
durationMs: this._sessionStartedAt ? now - this._sessionStartedAt : 0,
|
|
updatedAt: now,
|
|
}).where(eq(meetingSessions.id, sessionId)).run()
|
|
|
|
logger.info(`회의 녹음 종료: sessionId=${sessionId}, segments=${this._segments.length}`)
|
|
|
|
// 후처리 시작 (비동기)
|
|
this._runPostProcessing().catch((err: unknown) => {
|
|
logger.error(`후처리 실패: ${err instanceof Error ? err.message : String(err)}`)
|
|
})
|
|
}
|
|
|
|
// ── 후처리 파이프라인 ──
|
|
|
|
private async _runPostProcessing(): Promise<void> {
|
|
this._setState('processing')
|
|
this._sendStateToRenderer()
|
|
const sessionId = this._sessionId!
|
|
const db = getDatabase()
|
|
|
|
try {
|
|
db.update(meetingSessions).set({
|
|
status: 'processing',
|
|
updatedAt: Date.now(),
|
|
}).where(eq(meetingSessions.id, sessionId)).run()
|
|
|
|
// Step 1: 전사 텍스트 합산
|
|
this._sendProgress(sessionId, 'merging', 20)
|
|
const rawTranscript = this._segments
|
|
.map((s) => `[${this._formatTime(s.timestamp)}] ${s.text}`)
|
|
.join('\n')
|
|
|
|
// Step 2: 메모 포맷팅
|
|
const formattedMemos = this._memos
|
|
.map((m) => `[${this._formatTime(m.timestampMs)}] 📝 ${m.content}`)
|
|
.join('\n')
|
|
|
|
// Step 3: LLM 구조화
|
|
this._sendProgress(sessionId, 'generating', 40)
|
|
const { getLocalLLMService } = await import('./LocalLLMService')
|
|
const llmService = getLocalLLMService()
|
|
const prompt = this._buildPrompt(rawTranscript, formattedMemos)
|
|
const sttEnd = Date.now()
|
|
|
|
let resultText: string
|
|
let resultModel: string
|
|
|
|
if (rawTranscript.length > LONG_MEETING_THRESHOLD) {
|
|
// 긴 회의: 2-pass 요약
|
|
const chunks = this._splitIntoChunks(rawTranscript, CHUNK_SIZE)
|
|
const chunkResults = []
|
|
for (let i = 0; i < chunks.length; i++) {
|
|
const pct = 40 + Math.floor((i / chunks.length) * 30)
|
|
this._sendProgress(sessionId, 'generating', pct)
|
|
const chunkResult = await llmService.generate(
|
|
`다음 회의 전사 일부를 3줄로 요약하세요:\n\n${chunks[i]}`,
|
|
{ temperature: 0.3 },
|
|
)
|
|
chunkResults.push(chunkResult.text)
|
|
}
|
|
const condensed = chunkResults.join('\n\n')
|
|
const finalResult = await llmService.generate(
|
|
this._buildPrompt(condensed, formattedMemos),
|
|
{
|
|
systemPrompt: MEETING_MODE_SYSTEM_PROMPT,
|
|
temperature: 0.3,
|
|
maxTokens: 4096,
|
|
},
|
|
)
|
|
resultText = finalResult.text
|
|
resultModel = finalResult.model
|
|
} else {
|
|
const result = await llmService.generate(prompt, {
|
|
systemPrompt: MEETING_MODE_SYSTEM_PROMPT,
|
|
temperature: 0.3,
|
|
maxTokens: 4096,
|
|
})
|
|
resultText = result.text
|
|
resultModel = result.model
|
|
}
|
|
const llmEnd = Date.now()
|
|
|
|
// Step 4: 마크다운 파싱
|
|
this._sendProgress(sessionId, 'parsing', 85)
|
|
const minutes = this._parseMinutes(resultText)
|
|
|
|
// Step 5: DB 저장
|
|
this._sendProgress(sessionId, 'saving', 90)
|
|
const now = Date.now()
|
|
db.update(meetingSessions).set({
|
|
status: 'completed',
|
|
rawTranscript,
|
|
minutesMarkdown: resultText,
|
|
minutesJson: JSON.stringify(minutes),
|
|
sttModel: configGet('sttModelId') as string | undefined,
|
|
llmModel: resultModel,
|
|
sttLatencyMs: sttEnd - (this._sessionStartedAt ?? sttEnd),
|
|
llmLatencyMs: llmEnd - sttEnd,
|
|
updatedAt: now,
|
|
}).where(eq(meetingSessions.id, sessionId)).run()
|
|
|
|
// 자동 제목 생성
|
|
const autoTitle = minutes.summary.split('\n')[0].slice(0, 50) || '무제 회의'
|
|
db.update(meetingSessions).set({
|
|
title: autoTitle,
|
|
updatedAt: now,
|
|
}).where(eq(meetingSessions.id, sessionId)).run()
|
|
|
|
// Step 6: Windows 알림
|
|
this._sendProgress(sessionId, 'notifying', 95)
|
|
new Notification({ title: '회의록 준비 완료', body: '회의록이 생성되었습니다.' }).show()
|
|
|
|
// 완료 이벤트
|
|
this._sendProgress(sessionId, 'notifying', 100)
|
|
const detail = this._loadSessionDetail(sessionId)
|
|
if (detail) {
|
|
this.emit('session-completed', detail)
|
|
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.SESSION_COMPLETED, detail)
|
|
}
|
|
|
|
this._resetSession()
|
|
this._setState('idle')
|
|
this._sendStateToRenderer()
|
|
logger.info(`회의록 생성 완료: sessionId=${sessionId}`)
|
|
} catch (err) {
|
|
db.update(meetingSessions).set({
|
|
status: 'error',
|
|
errorMessage: err instanceof Error ? err.message : String(err),
|
|
updatedAt: Date.now(),
|
|
}).where(eq(meetingSessions.id, sessionId)).run()
|
|
|
|
this._resetSession()
|
|
this._setState('idle')
|
|
this._sendStateToRenderer()
|
|
|
|
const d3roErr = new D3ROError(
|
|
ErrorCode.MeetingProcessingFailed,
|
|
`회의록 생성 실패: ${err instanceof Error ? err.message : String(err)}`,
|
|
)
|
|
this.emit('error', d3roErr)
|
|
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.ERROR, {
|
|
code: d3roErr.code,
|
|
message: d3roErr.message,
|
|
})
|
|
logger.error(`후처리 실패: ${d3roErr.message}`)
|
|
}
|
|
}
|
|
|
|
// ── 세션 조회 ──
|
|
|
|
getSessions(page: number, pageSize: number): MeetingSessionPage {
|
|
const db = getDatabase()
|
|
const offset = (page - 1) * pageSize
|
|
|
|
const countResult = db.select({ count: sql<number>`count(*)` })
|
|
.from(meetingSessions)
|
|
.get()
|
|
const total = countResult?.count ?? 0
|
|
|
|
const rows = db.select()
|
|
.from(meetingSessions)
|
|
.orderBy(desc(meetingSessions.createdAt))
|
|
.limit(pageSize)
|
|
.offset(offset)
|
|
.all()
|
|
|
|
const sessions: MeetingSessionSummary[] = rows.map((row) => {
|
|
const memoCount = db.select({ count: sql<number>`count(*)` })
|
|
.from(meetingMemos)
|
|
.where(eq(meetingMemos.sessionId, row.id))
|
|
.get()?.count ?? 0
|
|
|
|
return {
|
|
id: row.id,
|
|
title: row.title,
|
|
status: row.status as MeetingSessionSummary['status'],
|
|
startedAt: row.startedAt,
|
|
endedAt: row.endedAt,
|
|
durationMs: row.durationMs,
|
|
memoCount,
|
|
}
|
|
})
|
|
|
|
return {
|
|
sessions,
|
|
total,
|
|
page,
|
|
pageSize,
|
|
totalPages: Math.ceil(total / pageSize),
|
|
}
|
|
}
|
|
|
|
getSession(sessionId: string): MeetingSessionDetail {
|
|
const detail = this._loadSessionDetail(sessionId)
|
|
if (!detail) {
|
|
throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`)
|
|
}
|
|
return detail
|
|
}
|
|
|
|
deleteSession(sessionId: string): void {
|
|
const db = getDatabase()
|
|
const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
|
|
if (!row) {
|
|
throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`)
|
|
}
|
|
|
|
db.delete(meetingMemos).where(eq(meetingMemos.sessionId, sessionId)).run()
|
|
db.delete(meetingSessions).where(eq(meetingSessions.id, sessionId)).run()
|
|
logger.info(`회의 세션 삭제: ${sessionId}`)
|
|
}
|
|
|
|
updateTitle(sessionId: string, title: string): void {
|
|
const db = getDatabase()
|
|
const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
|
|
if (!row) {
|
|
throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`)
|
|
}
|
|
db.update(meetingSessions).set({ title, updatedAt: Date.now() })
|
|
.where(eq(meetingSessions.id, sessionId)).run()
|
|
}
|
|
|
|
// ── 내보내기 ──
|
|
|
|
async exportPdf(sessionId: string): Promise<string> {
|
|
const session = this._loadSessionDetail(sessionId)
|
|
if (!session) {
|
|
throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`)
|
|
}
|
|
|
|
const html = this._buildPdfHtml(session)
|
|
|
|
const { filePath } = await dialog.showSaveDialog({
|
|
title: 'PDF로 내보내기',
|
|
defaultPath: `회의록_${this._formatDateFile(session.startedAt)}.pdf`,
|
|
filters: [{ name: 'PDF', extensions: ['pdf'] }],
|
|
})
|
|
if (!filePath) return ''
|
|
|
|
const win = new BrowserWindow({ show: false, width: 800, height: 600 })
|
|
try {
|
|
await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`)
|
|
const pdfBuffer = await win.webContents.printToPDF({
|
|
printBackground: true,
|
|
pageSize: 'A4',
|
|
margins: { top: 20, bottom: 20, left: 20, right: 20 },
|
|
})
|
|
fs.writeFileSync(filePath, pdfBuffer)
|
|
logger.info(`PDF 내보내기 완료: ${filePath}`)
|
|
return filePath
|
|
} finally {
|
|
win.close()
|
|
}
|
|
}
|
|
|
|
async exportMarkdown(sessionId: string): Promise<string> {
|
|
const session = this._loadSessionDetail(sessionId)
|
|
if (!session) {
|
|
throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`)
|
|
}
|
|
|
|
const { filePath } = await dialog.showSaveDialog({
|
|
title: '마크다운으로 내보내기',
|
|
defaultPath: `회의록_${this._formatDateFile(session.startedAt)}.md`,
|
|
filters: [{ name: 'Markdown', extensions: ['md'] }],
|
|
})
|
|
if (!filePath) return ''
|
|
|
|
const md = this._buildExportMarkdown(session)
|
|
fs.writeFileSync(filePath, md, 'utf-8')
|
|
logger.info(`마크다운 내보내기 완료: ${filePath}`)
|
|
return filePath
|
|
}
|
|
|
|
// ── 내부 헬퍼 ──
|
|
|
|
private _setState(state: MeetingModeState): void {
|
|
this._state = state
|
|
this.emit('state-changed', state)
|
|
}
|
|
|
|
private _resetSession(): void {
|
|
this._sessionId = null
|
|
this._sessionStartedAt = null
|
|
this._segments = []
|
|
this._memos = []
|
|
this._meetingModeActive = false
|
|
}
|
|
|
|
private _sendToRenderer(channel: string, data: unknown): void {
|
|
const win = getMainWindow()
|
|
if (win && !win.isDestroyed()) {
|
|
win.webContents.send(channel, data)
|
|
}
|
|
}
|
|
|
|
private _sendStateToRenderer(): void {
|
|
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.STATE_CHANGED, this.getStateInfo())
|
|
}
|
|
|
|
private _sendProgress(sessionId: string, step: MeetingProcessingStep, percent: number): void {
|
|
const progress: MeetingProcessingProgress = { sessionId, step, percent }
|
|
this.emit('processing-progress', progress)
|
|
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.PROCESSING_PROGRESS, progress)
|
|
}
|
|
|
|
private _formatTime(ms: number): string {
|
|
const totalSeconds = Math.floor(ms / 1000)
|
|
const minutes = Math.floor(totalSeconds / 60)
|
|
const seconds = totalSeconds % 60
|
|
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
|
}
|
|
|
|
private _formatDateFile(epochMs: number): string {
|
|
const d = new Date(epochMs)
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}_${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}`
|
|
}
|
|
|
|
private _formatDateRange(startedAt: number, endedAt: number | null): string {
|
|
const fmt = (ts: number): string => {
|
|
const d = new Date(ts)
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
}
|
|
if (!endedAt) return fmt(startedAt)
|
|
return `${fmt(startedAt)} ~ ${new Date(endedAt).getHours()}:${String(new Date(endedAt).getMinutes()).padStart(2, '0')}`
|
|
}
|
|
|
|
private _buildPrompt(rawTranscript: string, formattedMemos: string): string {
|
|
return `## 전사록\n${rawTranscript}\n\n## 참석자 메모 (📝 표시)\n${formattedMemos}\n\n위 전사록과 메모를 바탕으로 회의록을 작성해주세요.`
|
|
}
|
|
|
|
private _parseMinutes(markdown: string): MeetingMinutes {
|
|
const minutes: MeetingMinutes = {
|
|
summary: '',
|
|
decisions: [],
|
|
actionItems: [],
|
|
timeline: [],
|
|
}
|
|
|
|
const sections = markdown.split(/^## /m)
|
|
for (const section of sections) {
|
|
const lines = section.trim().split('\n')
|
|
const heading = lines[0]?.toLowerCase() ?? ''
|
|
const body = lines.slice(1).join('\n').trim()
|
|
|
|
if (heading.includes('요약') || heading.includes('summary')) {
|
|
minutes.summary = body
|
|
} else if (heading.includes('결정') || heading.includes('decision')) {
|
|
minutes.decisions = body
|
|
.split('\n')
|
|
.filter((l) => l.startsWith('-') || l.startsWith('*'))
|
|
.map((l) => l.replace(/^[-*]\s*/, '').trim())
|
|
} else if (heading.includes('할 일') || heading.includes('action')) {
|
|
minutes.actionItems = body
|
|
.split('\n')
|
|
.filter((l) => l.startsWith('-') || l.startsWith('*'))
|
|
.map((l) => {
|
|
const text = l.replace(/^[-*]\s*(\[.\]\s*)?/, '').trim()
|
|
return { task: text }
|
|
})
|
|
} else if (heading.includes('타임라인') || heading.includes('timeline')) {
|
|
const tableRows = body
|
|
.split('\n')
|
|
.filter((l) => l.includes('|') && !l.includes('---'))
|
|
for (const row of tableRows) {
|
|
const cells = row.split('|').map((c) => c.trim()).filter(Boolean)
|
|
if (cells.length >= 2) {
|
|
minutes.timeline.push({
|
|
time: cells[0],
|
|
content: cells[1],
|
|
type: cells[1].includes('📝') ? 'memo' : 'transcript',
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return minutes
|
|
}
|
|
|
|
private _loadSessionDetail(sessionId: string): MeetingSessionDetail | null {
|
|
const db = getDatabase()
|
|
const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
|
|
if (!row) return null
|
|
|
|
const memoRows = db.select().from(meetingMemos)
|
|
.where(eq(meetingMemos.sessionId, sessionId))
|
|
.orderBy(meetingMemos.timestampMs)
|
|
.all()
|
|
|
|
const memos: MeetingMemo[] = memoRows.map((m) => ({
|
|
id: m.id,
|
|
sessionId: m.sessionId,
|
|
content: m.content,
|
|
timestampMs: m.timestampMs,
|
|
createdAt: m.createdAt,
|
|
}))
|
|
|
|
let minutes: MeetingMinutes | null = null
|
|
if (row.minutesJson) {
|
|
try {
|
|
minutes = JSON.parse(row.minutesJson) as MeetingMinutes
|
|
} catch {
|
|
logger.warn(`회의록 JSON 파싱 실패: sessionId=${sessionId}`)
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: row.id,
|
|
title: row.title,
|
|
status: row.status as MeetingSessionDetail['status'],
|
|
startedAt: row.startedAt,
|
|
endedAt: row.endedAt,
|
|
durationMs: row.durationMs,
|
|
memoCount: memos.length,
|
|
rawTranscript: row.rawTranscript,
|
|
minutesMarkdown: row.minutesMarkdown,
|
|
minutes,
|
|
memos,
|
|
sttModel: row.sttModel,
|
|
llmModel: row.llmModel,
|
|
errorMessage: row.errorMessage,
|
|
}
|
|
}
|
|
|
|
private _splitIntoChunks(text: string, chunkSize: number): string[] {
|
|
const chunks: string[] = []
|
|
for (let i = 0; i < text.length; i += chunkSize) {
|
|
chunks.push(text.slice(i, i + chunkSize))
|
|
}
|
|
return chunks
|
|
}
|
|
|
|
private _buildPdfHtml(session: MeetingSessionDetail): string {
|
|
const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0
|
|
const minutesHtml = session.minutesMarkdown
|
|
? this._markdownToSimpleHtml(session.minutesMarkdown)
|
|
: '<p>회의록이 생성되지 않았습니다.</p>'
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<style>
|
|
body { font-family: 'Malgun Gothic', sans-serif; margin: 40px; color: #222; }
|
|
h1 { font-size: 22px; border-bottom: 2px solid #f25b29; padding-bottom: 8px; }
|
|
h2 { font-size: 16px; color: #444; margin-top: 24px; }
|
|
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
|
|
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; font-size: 13px; }
|
|
th { background: #f5f5f5; font-weight: 600; }
|
|
.memo-badge { background: #fff3cd; padding: 2px 6px; border-radius: 4px; font-size: 11px; }
|
|
.meta { color: #888; font-size: 12px; margin-bottom: 20px; }
|
|
ul { padding-left: 20px; }
|
|
li { margin: 4px 0; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>${session.title ?? '무제 회의'}</h1>
|
|
<p class="meta">일시: ${this._formatDateRange(session.startedAt, session.endedAt)} | 소요: ${durationMin}분</p>
|
|
${minutesHtml}
|
|
</body>
|
|
</html>`
|
|
}
|
|
|
|
private _buildExportMarkdown(session: MeetingSessionDetail): string {
|
|
const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0
|
|
const memoTable = session.memos.length > 0
|
|
? `## 참석자 메모\n\n| 시간 | 메모 |\n|------|------|\n${session.memos.map((m) => `| ${this._formatTime(m.timestampMs)} | ${m.content} |`).join('\n')}`
|
|
: ''
|
|
|
|
return `# 회의록 — ${session.title ?? '무제 회의'}
|
|
|
|
- **일시**: ${this._formatDateRange(session.startedAt, session.endedAt)}
|
|
- **소요 시간**: ${durationMin}분
|
|
- **STT 모델**: ${session.sttModel ?? '-'}
|
|
- **LLM 모델**: ${session.llmModel ?? '-'}
|
|
|
|
---
|
|
|
|
${session.minutesMarkdown ?? '회의록이 생성되지 않았습니다.'}
|
|
|
|
---
|
|
|
|
${memoTable}
|
|
|
|
---
|
|
|
|
## 원문 전사
|
|
|
|
${session.rawTranscript ?? '(없음)'}
|
|
`
|
|
}
|
|
|
|
/** 간단한 마크다운→HTML 변환 (PDF 생성용) */
|
|
private _markdownToSimpleHtml(md: string): string {
|
|
return md
|
|
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
|
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
|
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
|
.replace(/^\- \[.\] (.+)$/gm, '<li>$1</li>')
|
|
.replace(/^\- (.+)$/gm, '<li>$1</li>')
|
|
.replace(/^\* (.+)$/gm, '<li>$1</li>')
|
|
.replace(/\n{2,}/g, '</p><p>')
|
|
.replace(/^(?!<[h|l|t|p])/gm, '')
|
|
}
|
|
}
|
|
|
|
// ── 싱글톤 ──
|
|
let instance: MeetingModeService | null = null
|
|
|
|
export function getMeetingModeService(): MeetingModeService {
|
|
if (!instance) {
|
|
instance = new MeetingModeService()
|
|
}
|
|
return instance
|
|
}
|