refactor(desktop): session-scoped voice runtime, STT/LLM ports, caption ownership and meeting export fixes
This commit is contained in:
parent
0adedf5e7b
commit
83cf9133ac
39 changed files with 4787 additions and 1061 deletions
226
apps/desktop/tests/red/meeting-mode-redteam-r1-3.test.ts
Normal file
226
apps/desktop/tests/red/meeting-mode-redteam-r1-3.test.ts
Normal 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))
|
||||
})
|
||||
})
|
||||
32
apps/desktop/tests/red/meeting-summary-redteam-r1-3.test.ts
Normal file
32
apps/desktop/tests/red/meeting-summary-redteam-r1-3.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// tests/red/meeting-summary-redteam-r1-3.test.ts
|
||||
// 자막 세션 자동 요약이 자막 세션 id 로 히스토리를 찾아 항상 실패하던 버그의 회귀 테스트.
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { useRedHarness, historyInput } from './harness'
|
||||
|
||||
useRedHarness()
|
||||
|
||||
import { getMeetingSummaryService } from '../../src/main/services/MeetingSummaryService'
|
||||
import { getHistoryService } from '../../src/main/services/HistoryService'
|
||||
|
||||
describe('MeetingSummaryService.onCaptionSessionSaved', () => {
|
||||
it('자막 세션 요약에 실린 historyId 로 히스토리 행을 찾아 요약한다', async () => {
|
||||
const text = '오늘 회의에서는 다음 분기 출시 일정과 담당자를 정했습니다. '.repeat(3)
|
||||
const entry = getHistoryService().create(historyInput({ originalText: text, mode: 'caption' }))
|
||||
const service = getMeetingSummaryService()
|
||||
const summarize = vi.spyOn(service, 'summarize').mockResolvedValue(
|
||||
undefined as unknown as Awaited<ReturnType<typeof service.summarize>>,
|
||||
)
|
||||
|
||||
await service.onCaptionSessionSaved({ sessionId: 'caption-session-uuid', historyId: entry.id })
|
||||
|
||||
expect(summarize).toHaveBeenCalledWith(entry.id)
|
||||
})
|
||||
|
||||
it('저장되지 않은 세션(historyId 없음)은 조용히 건너뛴다', async () => {
|
||||
const service = getMeetingSummaryService()
|
||||
const summarize = vi.spyOn(service, 'summarize')
|
||||
await service.onCaptionSessionSaved({ sessionId: 'caption-session-uuid', historyId: null })
|
||||
expect(summarize).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue