fix(desktop): harden session, meeting, caption and LLM lifecycles; route LLM calls through the gateway

This commit is contained in:
Yun Chan 2026-09-28 02:16:15 +09:00
parent 3a46437f28
commit ddc78546f0
62 changed files with 4786 additions and 648 deletions

View file

@ -0,0 +1,114 @@
// tests/red/data-policy-redteam-r2-1.test.ts
// 로컬이 서버 쓰기 제약과 같은 규칙으로 입력을 거절한다(실제 서비스 + in-memory DB):
// - 사전: 대소문자만 다른 단어는 같은 단어(서버 lower(btrim(word)) 유일성) — 추가·수정·가져오기
// - 명령어: 편집으로 이름·프롬프트를 비울 수 없다(생성과 같은 규칙)
// - 메모 태그: 80자를 넘으면 저장하지 않는다
// - 검색: 기록은 다듬은 문장·제목·요약으로도 찾고, %·_ 는 글자 그대로 찾는다(사전 검색 포함)
import { describe, it, expect } from 'vitest'
import fs from 'fs'
import os from 'os'
import path from 'path'
import { ErrorCode } from '@d3ro/core/errors'
import { useRedHarness, historyInput } from './harness'
useRedHarness()
import { getDictionaryService } from '../../src/main/services/DictionaryService'
import { getCustomInstructionService } from '../../src/main/services/CustomInstructionService'
import { getMemoService } from '../../src/main/services/MemoService'
import { getHistoryService } from '../../src/main/services/HistoryService'
describe('사전 — 대소문자 무시 식별', () => {
it("'API' 가 있으면 'api' 추가는 DictionaryDuplicate 다(발음이 동기화에서 조용히 사라지지 않게)", () => {
const dict = getDictionaryService()
dict.add({ word: 'API' })
expect(() => dict.add({ word: ' api ', pronunciation: '에이피아이' })).toThrow(
expect.objectContaining({ code: ErrorCode.DictionaryDuplicate }),
)
})
it('다른 카테고리는 같은 단어를 허용한다', () => {
const dict = getDictionaryService()
dict.add({ word: 'API', category: 'user' })
expect(() => dict.add({ word: 'api', category: 'technical' })).not.toThrow()
})
it('수정으로 다른 항목과 대소문자만 다른 단어가 되면 거절하고, 자기 자신의 대소문자 변경은 허용한다', () => {
const dict = getDictionaryService()
dict.add({ word: 'Kubernetes' })
const other = dict.add({ word: 'Docker' })
expect(() => dict.update({ id: other.id, word: 'KUBERNETES' })).toThrow(
expect.objectContaining({ code: ErrorCode.DictionaryDuplicate }),
)
expect(dict.update({ id: other.id, word: 'DOCKER' })?.word).toBe('DOCKER')
})
it('가져오기는 대소문자만 다른 단어를 건너뛴다', () => {
const dict = getDictionaryService()
dict.add({ word: 'GraphQL' })
const file = path.join(os.tmpdir(), `d3ro-dict-r2-1-${Date.now()}.json`)
fs.writeFileSync(file, JSON.stringify([{ word: 'graphql' }, { word: 'gRPC' }, { word: 'GRPC' }]))
try {
const result = dict.importDictionary({ filePath: file, format: 'json' })
expect(result).toEqual({ imported: 1, skipped: 2, errors: 0 })
} finally {
fs.rmSync(file, { force: true })
}
})
it('사전 검색의 %·_ 는 와일드카드가 아니다', () => {
const dict = getDictionaryService()
dict.add({ word: 'alpha' })
dict.add({ word: 'snake_case' })
expect(dict.search({ query: '_', page: 0, pageSize: 20 }).entries.map((e) => e.word)).toEqual(['snake_case'])
expect(dict.search({ query: '%', page: 0, pageSize: 20 }).total).toBe(0)
})
})
describe('명령어 — 빈 값 검증', () => {
it('편집으로 프롬프트나 이름을 비울 수 없다', () => {
const svc = getCustomInstructionService()
svc.initialize()
const created = svc.create({ name: '요약해', description: '', prompt: '요약해 주세요' })
expect(() => svc.update(created.id, { prompt: ' ' })).toThrow(/prompt is empty/)
expect(() => svc.update(created.id, { name: '' })).toThrow(/name is empty/)
expect(svc.getById(created.id)?.prompt).toBe('요약해 주세요')
})
})
describe('메모 태그 — 서버 길이 제한', () => {
it('80자를 넘는 태그는 저장하지 않는다(80자는 허용)', () => {
const entry = getHistoryService().create(historyInput())
const memo = getMemoService()
expect(() => memo.addTag(entry.id, 't'.repeat(81))).toThrow(/too long/)
expect(memo.getTagsForEntry(entry.id)).toEqual([])
expect(memo.addTag(entry.id, 't'.repeat(80)).tag).toHaveLength(80)
})
})
describe('기록 검색', () => {
it('카드에 보이는 다듬은 문장·제목·요약으로도 찾는다', () => {
const history = getHistoryService()
history.create(historyInput({ originalText: '한국어 원문', polishedText: 'Quarterly roadmap review' }))
history.create(historyInput({ originalText: '다른 원문', title: '출시 회의록' }))
history.create(historyInput({ originalText: '세 번째', summaryText: '결정: 예산 증액' }))
const search = (query: string) => history.search({ query, page: 0, pageSize: 20 }).entries.length
expect(search('roadmap')).toBe(1)
expect(search('출시 회의')).toBe(1)
expect(search('예산 증액')).toBe(1)
})
it('%·_ 는 글자 그대로 찾는다', () => {
const history = getHistoryService()
history.create(historyInput({ originalText: '매출이 100% 늘었다' }))
history.create(historyInput({ originalText: '매출이 1005 건이다' }))
history.create(historyInput({ originalText: 'file_name 확인' }))
const texts = (query: string) =>
history.search({ query, page: 0, pageSize: 20 }).entries.map((e) => e.originalText)
expect(texts('100%')).toEqual(['매출이 100% 늘었다'])
expect(texts('_')).toEqual(['file_name 확인'])
})
})

View file

@ -0,0 +1,132 @@
// tests/red/llm-privacy-routing-redteam-r2-1.test.ts
// LLM 백엔드 선택이 게이트웨이 한 곳을 따른다(실제 서비스 + in-memory DB, LLM 어댑터만 가짜):
// - llmBackend='local' 이면 자막 자동 요약·받아쓰기 자동 제목이 Premium(클라우드·쿼터)을 부르지 않는다
// - llm:process 는 online + Supabase 로그인(레거시 authToken 없음)이면 Premium 을 탄다(.NET 경로 아님)
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { invokeIpc, useRedHarness, historyInput } from './harness'
const llm = vi.hoisted(() => ({
localGenerate: vi.fn(),
localProcess: vi.fn(),
premiumGenerate: vi.fn(),
premiumProcess: vi.fn(),
premiumAvailable: true,
onlineProcess: vi.fn(),
}))
vi.mock('../../src/main/services/LocalLLMService', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/main/services/LocalLLMService')>()
return {
...actual,
getLocalLLMService: () => ({
isAvailable: () => true,
generate: (...args: unknown[]) => llm.localGenerate(...args),
processText: (...args: unknown[]) => llm.localProcess(...args),
chatStream: async function* () {
yield 'x'
return 'x'
},
cancelGeneration: vi.fn(),
on: vi.fn(),
off: vi.fn(),
getStatus: () => ({}),
}),
}
})
vi.mock('../../src/main/services/PremiumLLMService', () => ({
getPremiumLLMService: () => ({
isAvailable: () => llm.premiumAvailable,
generate: (...args: unknown[]) => llm.premiumGenerate(...args),
processText: (...args: unknown[]) => llm.premiumProcess(...args),
chatStream: async function* () {
yield 'p'
return 'p'
},
cancelGeneration: vi.fn(),
on: vi.fn(),
off: vi.fn(),
getLastQuota: () => null,
}),
resetPremiumLLMServiceForTests: () => undefined,
}))
vi.mock('../../src/main/services/OnlineLLMService', () => ({
getOnlineLLMService: () => ({
processText: (...args: unknown[]) => llm.onlineProcess(...args),
cancelGeneration: vi.fn(),
}),
resetOnlineLLMServiceForTests: () => undefined,
}))
useRedHarness()
import { configSet } from '../../src/main/services/ConfigService'
import { getHistoryService } from '../../src/main/services/HistoryService'
import { getMeetingSummaryService } from '../../src/main/services/MeetingSummaryService'
import { registerLLMHandlers } from '../../src/main/ipc/llm-handlers'
import { setLlmGatewayForTests } from '../../src/main/services/llm/LlmGateway'
beforeEach(() => {
setLlmGatewayForTests(null)
llm.premiumAvailable = true
llm.localGenerate.mockReset().mockResolvedValue({ text: '## 요약\n로컬 요약', model: 'local' })
llm.localProcess.mockReset().mockResolvedValue('local-result')
llm.premiumGenerate.mockReset().mockResolvedValue({ text: 'premium' })
llm.premiumProcess.mockReset().mockResolvedValue('premium-result')
llm.onlineProcess.mockReset().mockResolvedValue('dotnet-result')
})
describe('llmBackend=local 은 텍스트를 클라우드로 보내지 않는다', () => {
it('자막 요약은 로컬 LLM 을 쓰고 Premium 을 부르지 않는다', async () => {
configSet('llmBackend', 'local')
const entry = getHistoryService().create(historyInput({ originalText: '회의 내용 '.repeat(20), mode: 'caption' }))
await vi.waitFor(() => expect(llm.localGenerate).toHaveBeenCalled())
llm.localGenerate.mockClear()
const result = await getMeetingSummaryService().summarize(entry.id)
expect(result.summary).toContain('로컬 요약')
expect(llm.localGenerate).toHaveBeenCalled()
expect(llm.premiumGenerate).not.toHaveBeenCalled()
})
it('받아쓰기 자동 제목도 Premium 을 부르지 않는다', async () => {
configSet('llmBackend', 'local')
const entry = getHistoryService().create(historyInput({ originalText: '다음 주 출시 일정을 정리해야 합니다' }))
await vi.waitFor(() => expect(llm.localGenerate).toHaveBeenCalled())
expect(llm.premiumGenerate).not.toHaveBeenCalled()
await vi.waitFor(() => expect(getHistoryService().getById(entry.id)?.title).toBeTruthy())
})
it('online 을 고른 사용자는 요약이 Premium 을 탄다(명시적 선택)', async () => {
configSet('llmBackend', 'online')
const entry = getHistoryService().create(historyInput({ originalText: '회의 내용 '.repeat(20), mode: 'caption' }))
await getMeetingSummaryService().summarize(entry.id)
expect(llm.premiumGenerate).toHaveBeenCalled()
})
})
describe('llm:process 는 실제 받아쓰기와 같은 경로를 탄다', () => {
it('online + Supabase 로그인(레거시 authToken 없음) → Premium, .NET 경로 아님', async () => {
configSet('llmBackend', 'online')
configSet('authToken', null)
registerLLMHandlers()
const res = await invokeIpc<{ processedText: string }>(IPC_CHANNELS.LLM.PROCESS, { text: '원문', action: 'refine' })
expect(res.success).toBe(true)
if (res.success) expect(res.data.processedText).toBe('premium-result')
expect(llm.onlineProcess).not.toHaveBeenCalled()
})
it('online 인데 Premium 을 쓸 수 없으면 받아쓰기처럼 로컬로 폴백한다', async () => {
configSet('llmBackend', 'online')
configSet('authToken', null)
llm.premiumAvailable = false
registerLLMHandlers()
const res = await invokeIpc<{ processedText: string }>(IPC_CHANNELS.LLM.PROCESS, { text: '원문', action: 'refine' })
expect(res.success).toBe(true)
if (res.success) expect(res.data.processedText).toBe('local-result')
})
})

View 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)
})
})