fix(desktop): harden session, meeting, caption and LLM lifecycles; route LLM calls through the gateway
This commit is contained in:
parent
3a46437f28
commit
ddc78546f0
62 changed files with 4786 additions and 648 deletions
237
apps/desktop/tests/red/meeting-recording-redteam-r2-1.test.ts
Normal file
237
apps/desktop/tests/red/meeting-recording-redteam-r2-1.test.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
// tests/red/meeting-recording-redteam-r2-1.test.ts
|
||||
// MeetingModeService 녹음 수명주기 회귀 테스트 (실제 DB + 가짜 CaptionService / LLM):
|
||||
// - 정지를 두 번 눌러도(마지막 문장 flush 중) 정지·저장은 한 번 — 마지막 문장 유지, durationMs > 0, 완료 알림 1회
|
||||
// - 앱 종료(shutdown) 시 녹음 중이던 전사가 저장되고 행이 'recording' 으로 남지 않는다
|
||||
// - 시작 시 중단된 'recording'/'processing' 행을 닫는다
|
||||
// - 페이지를 다시 열 때 진행 중인 전사·메모를 돌려준다
|
||||
// - 긴 회의: 문서 생성은 조각별로 줄여 한 메시지 한도를 넘지 않고, 채팅은 관련 발췌만 넣는다
|
||||
// - 회의 채팅 지우기·취소는 그 회의만
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { EventEmitter } from 'events'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { CaptionSegment } from '@d3ro/core/types'
|
||||
import { LLM_PROXY_CHAT_LIMITS } from '@d3ro/core/llm-chat'
|
||||
import { useRedHarness } from './harness'
|
||||
|
||||
const caption = vi.hoisted(() => ({
|
||||
bus: null as EventEmitter | null,
|
||||
state: 'inactive' as string,
|
||||
stopCalls: 0,
|
||||
/** 정지(flush)를 붙잡아 두는 게이트 — null 이면 바로 끝난다 */
|
||||
gate: null as Promise<void> | null,
|
||||
}))
|
||||
|
||||
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 () => {
|
||||
caption.state = 'active'
|
||||
},
|
||||
stop: async () => {
|
||||
caption.stopCalls++
|
||||
if (caption.state !== 'active') return null
|
||||
caption.state = 'stopping'
|
||||
if (caption.gate) await caption.gate
|
||||
const segment: CaptionSegment = { id: 'seg-last', text: '마지막 문장', timestamp: Date.now(), 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(() => ({
|
||||
generateInputs: [] as string[],
|
||||
chatCalls: [] as Array<Array<{ role: string; content: 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: async (text: string) => {
|
||||
llm.generateInputs.push(text)
|
||||
return { text: `요약(${text.length})`, model: 'fx-local' }
|
||||
},
|
||||
cancelGeneration: vi.fn(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
useRedHarness()
|
||||
|
||||
import { getMeetingModeService } from '../../src/main/services/MeetingModeService'
|
||||
import { configSet } from '../../src/main/services/ConfigService'
|
||||
import { getDatabase } from '../../src/main/db'
|
||||
import { meetingSessions } from '../../src/main/db/schema'
|
||||
|
||||
function row(id: string) {
|
||||
return getDatabase().select().from(meetingSessions).where(eq(meetingSessions.id, id)).get()
|
||||
}
|
||||
|
||||
function insertSession(id: string, fields: Partial<typeof meetingSessions.$inferInsert> = {}): void {
|
||||
const now = Date.now()
|
||||
getDatabase()
|
||||
.insert(meetingSessions)
|
||||
.values({ id, title: id, status: 'completed', startedAt: now - 60_000, createdAt: now, updatedAt: now, ...fields })
|
||||
.run()
|
||||
}
|
||||
|
||||
function longTranscript(lines: number): string {
|
||||
return Array.from({ length: lines }, (_, i) => {
|
||||
const mm = String(Math.floor(i / 60)).padStart(2, '0')
|
||||
const ss = String(i % 60).padStart(2, '0')
|
||||
return `[${mm}:${ss}] 이번 분기 예산과 일정에 대한 ${i}번째 발언이 이어집니다 항목 ${i}`
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
caption.state = 'inactive'
|
||||
caption.stopCalls = 0
|
||||
caption.gate = null
|
||||
llm.generateInputs = []
|
||||
llm.chatCalls = []
|
||||
configSet('llmBackend', 'local')
|
||||
})
|
||||
|
||||
describe('회의 정지 — 동시에 두 번', () => {
|
||||
it('두 번째 정지는 진행 중인 정지를 함께 기다린다 — 마지막 문장·durationMs 유지, 완료 1회', async () => {
|
||||
const meeting = getMeetingModeService()
|
||||
const { sessionId } = await meeting.startRecording()
|
||||
caption.bus?.emit('segment', { id: 'seg-1', text: '첫 문장', timestamp: Date.now(), isFinal: true } satisfies CaptionSegment)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
|
||||
let release: () => void = () => undefined
|
||||
caption.gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const completed = vi.fn()
|
||||
meeting.on('session-completed', completed)
|
||||
|
||||
const first = meeting.stopRecording()
|
||||
const second = meeting.stopRecording()
|
||||
// 정지 중에는 '처리 중' — 화면의 정지 버튼이 사라진다
|
||||
expect(meeting.getState()).toBe('processing')
|
||||
release()
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(caption.stopCalls).toBe(1)
|
||||
expect(completed).toHaveBeenCalledTimes(1)
|
||||
const saved = row(sessionId)
|
||||
expect(saved?.status).toBe('completed')
|
||||
expect(saved?.durationMs ?? 0).toBeGreaterThan(0)
|
||||
expect((saved?.rawTranscript ?? '').split('\n')).toHaveLength(2)
|
||||
expect(saved?.rawTranscript).toContain('마지막 문장')
|
||||
expect(meeting.getState()).toBe('idle')
|
||||
await expect(meeting.stopRecording()).rejects.toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('앱 종료·중단 복구', () => {
|
||||
it('종료(shutdown) 시 녹음 중이던 전사를 저장하고 행을 completed 로 닫는다', async () => {
|
||||
const meeting = getMeetingModeService()
|
||||
const { sessionId } = await meeting.startRecording()
|
||||
caption.bus?.emit('segment', { id: 'seg-a', text: '종료 전 문장', timestamp: Date.now(), isFinal: true } satisfies CaptionSegment)
|
||||
|
||||
await meeting.shutdown()
|
||||
|
||||
const saved = row(sessionId)
|
||||
expect(saved?.status).toBe('completed')
|
||||
expect(saved?.rawTranscript).toContain('종료 전 문장')
|
||||
expect(saved?.endedAt).not.toBeNull()
|
||||
expect(meeting.getState()).toBe('idle')
|
||||
})
|
||||
|
||||
it('남은 recording/processing 행을 닫는다 — 전사가 있으면 completed, 없으면 error', () => {
|
||||
insertSession('stuck-with-text', { status: 'recording', rawTranscript: '[00:01] 저장된 부분' })
|
||||
insertSession('stuck-empty', { status: 'processing' })
|
||||
insertSession('done', { status: 'completed', rawTranscript: 'x' })
|
||||
|
||||
expect(getMeetingModeService().recoverInterruptedSessions()).toBe(2)
|
||||
|
||||
expect(row('stuck-with-text')?.status).toBe('completed')
|
||||
expect(row('stuck-with-text')?.endedAt).not.toBeNull()
|
||||
expect(row('stuck-empty')?.status).toBe('error')
|
||||
expect(row('done')?.status).toBe('completed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('녹음 화면 복원', () => {
|
||||
it('진행 중인 녹음의 세그먼트와 메모를 돌려준다', async () => {
|
||||
const meeting = getMeetingModeService()
|
||||
const { sessionId } = await meeting.startRecording()
|
||||
caption.bus?.emit('segment', { id: 'seg-x', text: '앞부분 발언', timestamp: Date.now(), isFinal: true } satisfies CaptionSegment)
|
||||
await meeting.addMemo('중요')
|
||||
|
||||
const live = meeting.getLiveSession()
|
||||
expect(live?.sessionId).toBe(sessionId)
|
||||
expect(live?.segments.map((s) => s.text)).toEqual(['앞부분 발언'])
|
||||
expect(live?.memos.map((m) => m.content)).toEqual(['중요'])
|
||||
|
||||
await meeting.stopRecording()
|
||||
expect(meeting.getLiveSession()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('긴 회의의 LLM 입력', () => {
|
||||
it('문서 생성은 전사를 조각별로 줄여, 어떤 호출도 메시지 한도를 넘지 않는다', async () => {
|
||||
const transcript = longTranscript(400)
|
||||
expect(transcript.length).toBeGreaterThan(LLM_PROXY_CHAT_LIMITS.maxMessageChars)
|
||||
insertSession('long-doc', { rawTranscript: transcript })
|
||||
|
||||
const doc = await getMeetingModeService().generateDocument({ sessionId: 'long-doc', templateId: 'builtin-minutes' })
|
||||
|
||||
expect(doc.content).toBeTruthy()
|
||||
expect(llm.generateInputs.length).toBeGreaterThan(2)
|
||||
for (const input of llm.generateInputs) {
|
||||
expect(input.length).toBeLessThanOrEqual(LLM_PROXY_CHAT_LIMITS.maxMessageChars)
|
||||
}
|
||||
})
|
||||
|
||||
it('채팅은 system 한도 안에 질문과 관련된 발췌를 넣고, 발췌라는 사실을 모델에 알린다', async () => {
|
||||
const lines = longTranscript(300).split('\n')
|
||||
lines[280] = '[04:40] 마지막에 출시 담당자는 김민수로 결정했습니다'
|
||||
insertSession('long-chat', { rawTranscript: lines.join('\n') })
|
||||
|
||||
await getMeetingModeService().chatWithMeeting('long-chat', '출시 담당자는 누구로 결정했나요?')
|
||||
|
||||
const system = llm.chatCalls[0].find((m) => m.role === 'system')?.content ?? ''
|
||||
expect(system.length).toBeLessThanOrEqual(LLM_PROXY_CHAT_LIMITS.maxSystemChars)
|
||||
expect(system).toContain('김민수')
|
||||
expect(system).toContain('발췌')
|
||||
})
|
||||
})
|
||||
|
||||
describe('회의 채팅 범위', () => {
|
||||
it('한 회의의 지우기는 다른 회의 대화를 남긴다', async () => {
|
||||
insertSession('chat-a', { rawTranscript: '[00:01] A' })
|
||||
insertSession('chat-b', { rawTranscript: '[00:01] B' })
|
||||
const meeting = getMeetingModeService()
|
||||
await meeting.chatWithMeeting('chat-a', 'A?')
|
||||
await meeting.chatWithMeeting('chat-b', 'B?')
|
||||
|
||||
meeting.clearMeetingChatHistory('chat-a')
|
||||
|
||||
expect(meeting.getMeetingChatHistory('chat-a')).toEqual([])
|
||||
expect(meeting.getMeetingChatHistory('chat-b')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue