Files
meeting-minutes/src/lib/live-summary.ts
T
ecd3192b40 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>
2026-09-07 18:16:15 +09:00

126 lines
3.7 KiB
TypeScript

import { complete, toProviderSettings, type ProviderSettings } from './providers'
import {
buildPrompt,
resolveDepth,
type SummaryDepth,
type TemplateId,
} from './templates'
type LiveSummaryResult =
| { success: true; markdown: string }
| { success: false; error: string; rateLimited?: boolean }
interface LiveSummaryOptions {
/** 프로바이더 설정. 생략하면 apiKey로 Gemini를 호출한다. */
provider?: ProviderSettings
apiKey?: string
/**
* 직전 롤링 요약.
*
* 값이 있으면 증분 모드로 동작하며, 이때 `transcript` 인자는 전사 전체가 아니라
* **직전 요약 이후 새로 추가된 발화**만 담아야 한다. 호출당 토큰이 회의 길이와
* 무관하게 일정해진다.
*/
previousSummary?: string
template?: TemplateId
depth?: SummaryDepth
customPrompt?: string
fetchFn?: typeof fetch
}
export async function generateLiveSummary(
transcript: string,
options: LiveSummaryOptions,
): Promise<LiveSummaryResult> {
const { fetchFn } = options
const settings = toProviderSettings(options.provider, options.apiKey)
const trimmed = transcript.trim()
if (trimmed.length === 0) {
return { success: false, error: '요약할 텍스트가 비어 있습니다.' }
}
const previous = options.previousSummary?.trim() ?? ''
const incremental = previous.length > 0
const templateId = options.template ?? 'meeting'
const depth = resolveDepth(templateId, options.depth)
const prompt = buildPrompt({
templateId,
depth,
transcript: incremental
? `[지금까지의 요약]\n${previous}\n\n[새로 추가된 발화]\n${trimmed}`
: trimmed,
live: true,
incremental,
customPrompt: options.customPrompt,
})
const result = await complete(prompt, settings, { fetchFn })
if (!result.success) {
return {
success: false,
error: result.error,
...(result.rateLimited ? { rateLimited: true } : {}),
}
}
if (result.text.trim().length === 0) {
return { success: false, error: '요약 결과가 비어 있습니다.' }
}
return { success: true, markdown: result.text }
}
export interface LiveSummaryPlan {
/** 'full'이면 전사 전체를 보내고 previousSummary를 쓰지 않는다. */
mode: 'full' | 'incremental'
/** 이번에 보낼 청크의 시작 인덱스. full이면 0. */
startIndex: number
}
export interface LiveSummaryPlanArgs {
totalChunks: number
lastSummarizedIndex: number
incrementsSinceFull: number
/** 0이면 주기적 전체 재요약을 하지 않는다. */
fullRefreshEvery: number
hasPreviousSummary: boolean
}
/**
* 이번 롤링 요약 호출을 증분으로 보낼지 전체로 보낼지 결정한다.
*
* 증분 모드는 호출당 토큰을 회의 길이와 무관하게 유지하지만, 요약을 요약하는
* 구조라 반복될수록 오차가 쌓인다. 그래서 일정 횟수마다 전사 전체로 한 번씩
* 다시 요약해 오차를 끊는다.
*/
export function planLiveSummaryRequest(
args: LiveSummaryPlanArgs,
): LiveSummaryPlan {
const {
totalChunks,
lastSummarizedIndex,
incrementsSinceFull,
fullRefreshEvery,
hasPreviousSummary,
} = args
// 전사가 초기화되어 인덱스가 범위를 벗어난 경우
if (lastSummarizedIndex > totalChunks) {
return { mode: 'full', startIndex: 0 }
}
// 첫 호출이거나 갱신할 요약이 아직 없는 경우
if (lastSummarizedIndex === 0 || !hasPreviousSummary) {
return { mode: 'full', startIndex: 0 }
}
if (fullRefreshEvery > 0 && incrementsSinceFull >= fullRefreshEvery) {
return { mode: 'full', startIndex: 0 }
}
return { mode: 'incremental', startIndex: lastSummarizedIndex }
}