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,151 @@
// tests/main/services/meeting-session-redteam-r2-1.test.ts
// MeetingRecordingSession(가짜 포트)·전사 줄이기(map-reduce) 단위 테스트
import { describe, expect, it, vi } from 'vitest'
import type { CaptionSegment } from '@d3ro/core/types'
import { ErrorCode } from '@d3ro/core/errors'
import { MeetingRecordingSession } from '../../../src/main/services/meeting/MeetingRecordingSession'
import { condenseTranscriptToBudget } from '../../../src/main/services/meeting/transcript-condenser'
function fakePorts() {
let segmentListener: ((segment: CaptionSegment) => void) | null = null
let chunkListener: ((buffer: Buffer) => void) | null = null
let releaseStop: () => void = () => undefined
const stop = vi.fn(async () => {
await new Promise<void>((resolve) => {
releaseStop = resolve
})
const last: CaptionSegment = { id: 'last', text: '마지막', timestamp: 3_000, isFinal: true }
segmentListener?.(last)
return { segments: [last] }
})
const unsubscribeSegment = vi.fn(() => {
segmentListener = null
})
const ports = {
caption: {
onSegment: (listener: (segment: CaptionSegment) => void) => {
segmentListener = listener
return unsubscribeSegment
},
stop,
},
audio: {
onChunk: (listener: (buffer: Buffer) => void) => {
chunkListener = listener
return () => {
chunkListener = null
}
},
},
now: () => 10_000,
}
return {
ports,
stop,
unsubscribeSegment,
emitSegment: (segment: CaptionSegment) => segmentListener?.(segment),
emitChunk: (buffer: Buffer) => chunkListener?.(buffer),
releaseStop: () => releaseStop(),
}
}
describe('MeetingRecordingSession', () => {
it('동시에 두 번 멈춰도 자막 정지와 스냅샷은 한 번 — 같은 불변 스냅샷을 받는다', async () => {
const h = fakePorts()
const session = new MeetingRecordingSession('s1', 1_000, h.ports)
session.attachCaption()
session.attachAudio()
h.emitSegment({ id: 'a', text: '첫 문장', timestamp: 2_000, isFinal: true })
h.emitChunk(Buffer.from([1, 2]))
const first = session.stop()
const second = session.stop()
expect(session.isStopping).toBe(true)
// 정지가 시작되면 메모·오디오는 더 받지 않는다
expect(session.addMemo('m', '늦은 메모')).toBeNull()
h.emitChunk(Buffer.from([3]))
h.releaseStop()
const [a, b] = await Promise.all([first, second])
expect(h.stop).toHaveBeenCalledTimes(1)
expect(a).toBe(b)
expect(a.segments.map((s) => s.text)).toEqual(['첫 문장', '마지막'])
expect(a.durationMs).toBe(9_000)
expect([...a.pcm]).toEqual([1, 2])
expect(Object.isFrozen(a)).toBe(true)
expect(h.unsubscribeSegment).toHaveBeenCalledTimes(1)
})
it('자막 정지가 실패해도 그때까지의 세그먼트로 스냅샷을 만든다', async () => {
const h = fakePorts()
const onStopError = vi.fn()
h.ports.caption.stop = vi.fn(async () => {
throw new Error('stt 500')
})
const session = new MeetingRecordingSession('s2', 1_000, { ...h.ports, onStopError })
session.attachCaption()
h.emitSegment({ id: 'a', text: '남은 문장', timestamp: 2_000, isFinal: true })
const snapshot = await session.stop()
expect(onStopError).toHaveBeenCalled()
expect(snapshot.segments.map((s) => s.text)).toEqual(['남은 문장'])
})
it('checkpoint 는 멈추지 않고 전사만(오디오 없이) 준다', () => {
const h = fakePorts()
const session = new MeetingRecordingSession('s3', 1_000, h.ports)
session.attachCaption()
session.attachAudio()
h.emitChunk(Buffer.from([9]))
h.emitSegment({ id: 'a', text: '중간', timestamp: 2_000, isFinal: true })
const snapshot = session.checkpoint({ includeAudio: false })
expect(snapshot.segments).toHaveLength(1)
expect(snapshot.pcm.length).toBe(0)
expect(session.isStopping).toBe(false)
})
})
describe('condenseTranscriptToBudget (map-reduce)', () => {
const transcript = Array.from({ length: 300 }, (_, i) => `[00:${String(i % 60).padStart(2, '0')}] 발언 ${i} `.padEnd(60, '가')).join('\n')
it('한도 안이면 LLM 을 부르지 않고 원문을 돌려준다', async () => {
const llm = { generate: vi.fn() }
const result = await condenseTranscriptToBudget(llm, '[00:01] 짧은 회의', 8_000)
expect(result).toEqual({ text: '[00:01] 짧은 회의', condensed: false, rounds: 0 })
expect(llm.generate).not.toHaveBeenCalled()
})
it('한도를 넘으면 조각마다 줄이고, 각 조각 입력과 결과 모두 한도 안이다', async () => {
const inputs: string[] = []
const llm = {
generate: vi.fn(async (text: string) => {
inputs.push(text)
return { text: `요약 ${inputs.length}` }
}),
}
const result = await condenseTranscriptToBudget(llm, transcript, 8_000)
expect(transcript.length).toBeGreaterThan(8_000)
expect(result.condensed).toBe(true)
expect(result.text.length).toBeLessThanOrEqual(8_000)
expect(inputs.length).toBeGreaterThan(1)
for (const input of inputs) expect(input.length).toBeLessThanOrEqual(8_000)
expect(result.text).toContain(`1/${inputs.length}`)
})
it('여러 번 줄여도 넘으면 조용히 자르지 않고 LLMPromptTooLong', async () => {
const llm = { generate: vi.fn(async (text: string) => ({ text })) }
await expect(condenseTranscriptToBudget(llm, transcript, 8_000, { maxRounds: 1 })).rejects.toMatchObject({
code: ErrorCode.LLMPromptTooLong,
})
})
it('남은 자리가 없으면(메모가 한도를 다 쓰면) LLMPromptTooLong', async () => {
await expect(condenseTranscriptToBudget({ generate: vi.fn() }, 'x', 0)).rejects.toMatchObject({
code: ErrorCode.LLMPromptTooLong,
})
})
})