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
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue