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
114
apps/desktop/tests/red/data-policy-redteam-r2-1.test.ts
Normal file
114
apps/desktop/tests/red/data-policy-redteam-r2-1.test.ts
Normal 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 확인'])
|
||||
})
|
||||
})
|
||||
132
apps/desktop/tests/red/llm-privacy-routing-redteam-r2-1.test.ts
Normal file
132
apps/desktop/tests/red/llm-privacy-routing-redteam-r2-1.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
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