refactor(desktop): session-scoped voice runtime, STT/LLM ports, caption ownership and meeting export fixes

This commit is contained in:
Yun Chan 2026-09-28 00:53:43 +09:00
parent 0adedf5e7b
commit 83cf9133ac
39 changed files with 4787 additions and 1061 deletions

View file

@ -0,0 +1,226 @@
// tests/red/meeting-mode-redteam-r1-3.test.ts
// MeetingModeService 회귀 테스트 (실제 DB + 가짜 CaptionService / LLM):
// - 정지 직전 문장(자막 flush 로 확정)이 회의 전사에 남는다
// - 전사 줄은 epoch 이 아니라 회의 시작 기준 [MM:SS] 로 저장된다
// - 회의는 자기 소유 자막 세션을 시작한다 (마이크 고정, 오버레이·히스토리 저장 없음, 전역 설정 불변)
// - 회의 AI 채팅 히스토리는 회의마다 분리된다
// - 긴 전사 다듬기가 잘리면 editedTranscript 를 덮어쓰지 않는다
// - 회의 삭제 시 로컬 녹음 WAV 도 지운다
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { EventEmitter } from 'events'
import fs from 'fs'
import path from 'path'
import { app } from 'electron'
import { eq } from 'drizzle-orm'
import { ErrorCode } from '@d3ro/core/errors'
import type { CaptionSegment } from '@d3ro/core/types'
import { useRedHarness } from './harness'
const caption = vi.hoisted(() => ({
bus: null as EventEmitter | null,
state: 'inactive' as string,
startOptions: [] as unknown[],
stopOwners: [] as unknown[],
flushText: '마지막 문장',
}))
vi.mock('../../src/main/services/CaptionService', async () => {
const { EventEmitter: Emitter } = await import('events')
const bus = new Emitter()
caption.bus = bus
const service = {
getState: () => caption.state,
on: (ev: string, fn: (...args: unknown[]) => void) => bus.on(ev, fn),
off: (ev: string, fn: (...args: unknown[]) => void) => bus.off(ev, fn),
start: async (opts: unknown) => {
caption.startOptions.push(opts)
caption.state = 'active'
},
stop: async (owner?: unknown) => {
caption.stopOwners.push(owner)
if (caption.state !== 'active') return null
// CaptionService.stop 은 말하던 문장을 flush 해 'segment' 로 확정한 뒤 끝난다
const segment: CaptionSegment = {
id: 'seg-last',
text: caption.flushText,
timestamp: Date.now() + 10_000,
isFinal: true,
}
bus.emit('segment', segment)
caption.state = 'inactive'
return { sessionId: 'cap', historyId: null, segments: [segment], startedAt: 0, endedAt: 0, totalDurationMs: 0 }
},
}
return {
getCaptionService: () => service,
resetCaptionServiceForTests: () => undefined,
}
})
vi.mock('../../src/main/services/AudioCaptureService', () => ({
getAudioCaptureService: () => ({ on: vi.fn(), off: vi.fn(), start: vi.fn(), stop: vi.fn(async () => undefined) }),
calculateRMS: () => 0,
}))
const llm = vi.hoisted(() => ({
chatCalls: [] as Array<Array<{ role: string; content: string }>>,
generate: null as null | ((text: string, options?: { maxTokens?: number }) => Promise<{ text: string }>),
}))
vi.mock('../../src/main/services/LocalLLMService', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/main/services/LocalLLMService')>()
return {
...actual,
getLocalLLMService: () => ({
isAvailable: () => true,
async *chatStream(messages: Array<{ role: string; content: string }>): AsyncGenerator<string, string> {
llm.chatCalls.push(messages)
yield '답변'
return '답변'
},
generate: (text: string, options?: { maxTokens?: number }) =>
llm.generate ? llm.generate(text, options) : Promise.resolve({ text }),
cancelGeneration: vi.fn(),
}),
}
})
useRedHarness()
import { getMeetingModeService } from '../../src/main/services/MeetingModeService'
import { configGet, configSet } from '../../src/main/services/ConfigService'
import { getDatabase } from '../../src/main/db'
import { meetingSessions } from '../../src/main/db/schema'
function insertSession(id: string, rawTranscript: string): void {
const now = Date.now()
getDatabase()
.insert(meetingSessions)
.values({
id,
title: id,
status: 'completed',
startedAt: now,
endedAt: now + 1000,
durationMs: 1000,
rawTranscript,
createdAt: now,
updatedAt: now,
})
.run()
}
beforeEach(() => {
caption.state = 'inactive'
caption.startOptions = []
caption.stopOwners = []
llm.chatCalls = []
llm.generate = null
})
describe('회의 녹음 정지', () => {
it('정지 직전 문장을 잃지 않고, 전사는 회의 시작 기준 시각으로 저장된다', async () => {
configSet('captionAudioSource', 'system')
const meeting = getMeetingModeService()
const { sessionId } = await meeting.startRecording()
expect(caption.startOptions).toEqual([
{ owner: 'meeting', audioSource: 'mic', showOverlay: false, persistHistory: false },
])
// 전역 설정은 건드리지 않는다
expect(configGet('captionAudioSource')).toBe('system')
const startedAt = Date.now()
caption.bus?.emit('segment', {
id: 'seg-1',
text: '첫 문장',
timestamp: startedAt + 5_000,
isFinal: true,
} satisfies CaptionSegment)
await meeting.stopRecording()
expect(caption.stopOwners).toEqual(['meeting'])
await vi.waitFor(() => {
const row = getDatabase().select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
expect(row?.rawTranscript).toBeTruthy()
})
const row = getDatabase().select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
const lines = (row?.rawTranscript ?? '').split('\n')
expect(lines).toHaveLength(2)
expect(lines[0]).toMatch(/^\[00:0[45]\] 첫 문장$/)
expect(lines[1]).toMatch(/^\[\d{2}:\d{2}\] 마지막 문장$/)
// epoch 이 새지 않는다 (예: [29841999:45])
expect(row?.rawTranscript).not.toMatch(/\[\d{4,}:/)
})
})
describe('회의 AI 채팅', () => {
it('회의 A 의 질의응답이 회의 B 의 대화로 새지 않는다', async () => {
insertSession('meeting-a', '[00:01] A 회의 내용')
insertSession('meeting-b', '[00:01] B 회의 내용')
const meeting = getMeetingModeService()
await meeting.chatWithMeeting('meeting-a', 'A 의 결정은?')
await meeting.chatWithMeeting('meeting-b', 'B 의 결정은?')
const bMessages = llm.chatCalls[1]
expect(bMessages.some((m) => m.content.includes('A 의 결정은?'))).toBe(false)
expect(bMessages.filter((m) => m.role !== 'system')).toEqual([{ role: 'user', content: 'B 의 결정은?' }])
expect(meeting.getMeetingChatHistory('meeting-a')).toHaveLength(2)
expect(meeting.getMeetingChatHistory('meeting-b')).toHaveLength(2)
})
})
describe('Auto Polish', () => {
it('LLM 결과가 잘리면 editedTranscript 를 덮어쓰지 않고 실패한다', async () => {
const lines = Array.from({ length: 200 }, (_, i) => {
const mm = String(Math.floor(i / 60)).padStart(2, '0')
const ss = String(i % 60).padStart(2, '0')
return `[${mm}:${ss}] 이것은 회의 발언 ${i}번째 문장입니다`
})
insertSession('long-meeting', lines.join('\n'))
// 출력 한도에 걸린 것처럼 입력의 앞 절반만 돌려준다
llm.generate = async (text) => {
const kept = text.split('\n')
return { text: kept.slice(0, Math.ceil(kept.length / 2)).join('\n') }
}
await expect(getMeetingModeService().polishTranscript('long-meeting')).rejects.toMatchObject({
code: ErrorCode.MeetingPolishFailed,
})
const row = getDatabase().select().from(meetingSessions).where(eq(meetingSessions.id, 'long-meeting')).get()
expect(row?.editedTranscript ?? null).toBeNull()
})
it('긴 전사는 줄 경계 조각으로 나눠 다듬고 이어 붙인다', async () => {
const lines = Array.from({ length: 200 }, (_, i) => `[00:${String(i % 60).padStart(2, '0')}] 발언 ${i}`)
insertSession('chunked', lines.join('\n'))
const maxTokens: Array<number | undefined> = []
llm.generate = async (text, options) => {
maxTokens.push(options?.maxTokens)
return { text }
}
const polished = await getMeetingModeService().polishTranscript('chunked')
expect(maxTokens.length).toBeGreaterThan(1)
expect(maxTokens.every((n) => n !== undefined && n <= 4096)).toBe(true)
expect(polished.split('\n')).toEqual(lines)
})
})
describe('회의 삭제', () => {
it('로컬 녹음 WAV 도 지운다', async () => {
insertSession('with-audio', '[00:01] x')
const audioDir = path.join(app.getPath('userData'), 'meeting-audio')
fs.mkdirSync(audioDir, { recursive: true })
const wav = path.join(audioDir, 'with-audio.wav')
fs.writeFileSync(wav, Buffer.alloc(64))
getMeetingModeService().deleteSession('with-audio')
await vi.waitFor(() => expect(fs.existsSync(wav)).toBe(false))
})
})