mirror of
https://github.com/nad4-su/meeting-minutes.git
synced 2026-10-11 15:50:13 +09:00
perf: 롤링 요약을 증분 방식으로 전환해 토큰 비용을 선형화 (#15)
롤링 요약이 30초마다 누적 전사 전체를 다시 보내고 있었다. 호출 N회차가 그때까지의 전사 전부를 담으므로 총 토큰이 회의 길이의 제곱으로 늘어난다. 1시간 회의 기준 약 116만 입력 토큰, 2시간이면 2배가 아니라 4배가 된다. [지금까지의 요약] + [새로 추가된 발화]만 보내도록 바꿔 호출당 토큰을 회의 길이와 무관하게 일정하게 만들었다. - templates.ts: BuildPromptArgs.incremental 추가, INCREMENTAL_MODIFIER 신설. 증분 모드에서는 전사 블록이 자체 라벨을 갖는다 - live-summary.ts: previousSummary 옵션. 값이 있으면 증분 모드로 동작 - live-summary.ts: planLiveSummaryRequest() — 전체/증분 결정을 순수 함수로 분리해 단위 테스트 가능하게 함 - useLiveSummary: 마지막 요약 지점 인덱스를 추적해 델타만 전송. 성공했을 때만 전진시켜 실패해도 발화를 잃지 않는다 - 요약을 요약하는 구조라 오차가 누적되므로 fullRefreshEvery(기본 20회)마다 전사 전체로 한 번 다시 요약해 오차를 끊는다 기본 설정에서 1시간 회의 기준 약 7배, 전체 재요약을 끄면 약 12배 절감된다. 회의가 2~3분보다 짧으면 지시문 오버헤드 때문에 오히려 조금 늘어난다. 테스트 141 → 151. Co-authored-by: csbae <csbae@RP-002.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
5 files changed
+276
-8
No files matched your search
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { generateLiveSummary } from '@/lib/live-summary'
|
||||
import {
|
||||
generateLiveSummary,
|
||||
planLiveSummaryRequest,
|
||||
} from '@/lib/live-summary'
|
||||
|
||||
describe('generateLiveSummary', () => {
|
||||
it('Gemini API 응답을 받아 중간 요약 마크다운을 반환한다', async () => {
|
||||
@@ -155,3 +158,131 @@ describe('generateLiveSummary — openai-compatible', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('planLiveSummaryRequest', () => {
|
||||
const base = {
|
||||
totalChunks: 50,
|
||||
lastSummarizedIndex: 30,
|
||||
incrementsSinceFull: 3,
|
||||
fullRefreshEvery: 20,
|
||||
hasPreviousSummary: true,
|
||||
}
|
||||
|
||||
it('평상시에는 직전 지점부터 증분으로 보낸다', () => {
|
||||
expect(planLiveSummaryRequest(base)).toEqual({
|
||||
mode: 'incremental',
|
||||
startIndex: 30,
|
||||
})
|
||||
})
|
||||
|
||||
it('첫 호출은 전체를 보낸다', () => {
|
||||
expect(
|
||||
planLiveSummaryRequest({ ...base, lastSummarizedIndex: 0 }),
|
||||
).toEqual({ mode: 'full', startIndex: 0 })
|
||||
})
|
||||
|
||||
it('갱신할 직전 요약이 없으면 전체를 보낸다', () => {
|
||||
expect(
|
||||
planLiveSummaryRequest({ ...base, hasPreviousSummary: false }),
|
||||
).toEqual({ mode: 'full', startIndex: 0 })
|
||||
})
|
||||
|
||||
it('증분이 누적되면 전체 재요약으로 오차를 끊는다', () => {
|
||||
expect(
|
||||
planLiveSummaryRequest({ ...base, incrementsSinceFull: 20 }),
|
||||
).toEqual({ mode: 'full', startIndex: 0 })
|
||||
})
|
||||
|
||||
it('fullRefreshEvery=0이면 전체 재요약을 하지 않는다', () => {
|
||||
expect(
|
||||
planLiveSummaryRequest({
|
||||
...base,
|
||||
fullRefreshEvery: 0,
|
||||
incrementsSinceFull: 999,
|
||||
}).mode,
|
||||
).toBe('incremental')
|
||||
})
|
||||
|
||||
it('전사가 초기화되어 인덱스가 범위를 벗어나면 전체를 보낸다', () => {
|
||||
expect(
|
||||
planLiveSummaryRequest({ ...base, totalChunks: 5 }),
|
||||
).toEqual({ mode: 'full', startIndex: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateLiveSummary — 증분 모드', () => {
|
||||
function mockOk(text = '## 요약\n갱신됨') {
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
candidates: [{ content: { parts: [{ text }] } }],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function sentPrompt(mockFetch: ReturnType<typeof vi.fn>): string {
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body)
|
||||
return body.contents[0].parts[0].text
|
||||
}
|
||||
|
||||
it('previousSummary가 있으면 요약과 신규 발화를 나눠 전달한다', async () => {
|
||||
const mockFetch = mockOk()
|
||||
|
||||
await generateLiveSummary('새로 나온 이야기', {
|
||||
apiKey: 'k',
|
||||
previousSummary: '## 요약\n이전까지의 내용',
|
||||
fetchFn: mockFetch,
|
||||
})
|
||||
|
||||
const prompt = sentPrompt(mockFetch)
|
||||
expect(prompt).toContain('[지금까지의 요약]')
|
||||
expect(prompt).toContain('이전까지의 내용')
|
||||
expect(prompt).toContain('[새로 추가된 발화]')
|
||||
expect(prompt).toContain('새로 나온 이야기')
|
||||
expect(prompt).toContain('갱신')
|
||||
})
|
||||
|
||||
it('previousSummary가 없으면 기존 전체 요약 형식을 유지한다', async () => {
|
||||
const mockFetch = mockOk()
|
||||
|
||||
await generateLiveSummary('전체 전사', { apiKey: 'k', fetchFn: mockFetch })
|
||||
|
||||
const prompt = sentPrompt(mockFetch)
|
||||
expect(prompt).toContain('음성 인식 텍스트:')
|
||||
expect(prompt).not.toContain('[지금까지의 요약]')
|
||||
})
|
||||
|
||||
it('빈 문자열 previousSummary는 증분으로 취급하지 않는다', async () => {
|
||||
const mockFetch = mockOk()
|
||||
|
||||
await generateLiveSummary('전체 전사', {
|
||||
apiKey: 'k',
|
||||
previousSummary: ' ',
|
||||
fetchFn: mockFetch,
|
||||
})
|
||||
|
||||
expect(sentPrompt(mockFetch)).not.toContain('[지금까지의 요약]')
|
||||
})
|
||||
|
||||
it('긴 회의에서 증분 프롬프트가 전체 프롬프트보다 짧다', async () => {
|
||||
const longTranscript = '회의 발화 한 줄입니다.\n'.repeat(500)
|
||||
|
||||
const fullFetch = mockOk()
|
||||
await generateLiveSummary(longTranscript, {
|
||||
apiKey: 'k',
|
||||
fetchFn: fullFetch,
|
||||
})
|
||||
|
||||
const incFetch = mockOk()
|
||||
await generateLiveSummary('마지막 30초에 나온 이야기', {
|
||||
apiKey: 'k',
|
||||
previousSummary: '## 요약\n지금까지의 요약 본문',
|
||||
fetchFn: incFetch,
|
||||
})
|
||||
|
||||
expect(sentPrompt(incFetch).length).toBeLessThan(
|
||||
sentPrompt(fullFetch).length / 5,
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user