mirror of
https://github.com/nad4-su/meeting-minutes.git
synced 2026-10-11 15:50:13 +09:00
fix: Web Speech 유실 지점 정리 + 긴 회의 분할 요약
P0에 남아 있던 세 가지. 실측 46분 회의 transcript에서 확인된 문제를 하나씩 코드로 막는다. ## 1. Chrome이 흘리는 것을 붙잡는다 **미확정 발화 보존** — Chrome은 `continuous`여도 침묵마다 세션을 닫는데, 그때 확정 전이던 문장을 그냥 버린다. 1시간 회의면 이 사이클이 수십 번 돌고 매번 마지막 한 문장씩 사라졌다. 세션이 끝날 때마다 직접 확정시킨다. **마지막 발화 보존** — `handleStop`이 렌더 시점의 `chunks`를 그대로 넘기는 바람에 회의 끝머리가 항상 빠졌다. `stopListening()`이 미확정분까지 굳힌 최종 청크를 돌려주도록 바꿨다. **인식 실패 가시화** — `isFinal: true`에 `transcript: ""`인 결과가 실측에서 19번 나왔다. "소리는 들었는데 못 알아듣겠다"는 신호다. 청크로 넣으면 요약 프롬프트가 오염되고, 조용히 버리면 유실이 보이지 않는다. 빼되 세어서 `⚠️ 인식 실패 N건` 배지로 노출한다. **미리보기로 강등** — 실시간 텍스트 패널에 (미리보기)를 붙였다. 확정본은 서버 STT 재전사가 만든다. ## 2. 타임스탬프 실측화 `startTime: Math.max(0, now - 2)` 하드코딩을 걷어냈다. interim이 처음 뜬 시각이 곧 발화 시작이므로 그것을 쓴다. 또 `startRecording()`이 캡처 시작 시각을 돌려주고 `startListening(timeOrigin)`이 그것을 받아, 전사 타임스탬프가 오디오 파일의 재생 위치와 같은 시간축을 쓴다. ## 3. 긴 회의가 마지막에 통째로 실패하지 않는다 `/api/summarize`가 10만 자를 넘으면 413으로 거부했다. 3시간 회의를 마치고 회의록을 만들려는 순간 아무것도 못 받는다는 뜻이다. 유실을 막자고 만든 파이프라인 끝에서 결과를 버리는 셈이었다. 구간별로 압축한 뒤(map) 압축본을 모아 평소와 똑같은 템플릿 경로로 회의록을 만든다(reduce). 최종 출력이 짧은 회의와 같은 프롬프트를 타므로 서식이 흔들리지 않는다. 상한은 50만 자(약 14시간)로 올렸다. 한 구간이라도 실패하면 전체를 실패로 돌린다. 일부만 빠진 압축본으로 회의록을 만들면 사용자는 무엇이 빠졌는지 모른 채 멀쩡해 보이는 문서를 받게 된다. ## 검증 브라우저에서 가짜 SpeechRecognition을 주입해 확인: - 빈 확정 2건 → 청크 0개 + "인식 실패 2건" 배지 - 미확정 마지막 발화가 중지 후 전사문에 남음 - 빈 `[MM:SS]` 줄이 하나도 없음 테스트 229 → 257 통과, 신규 타입 에러 0, 린트 baseline과 동일, 빌드 성공. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzAH7GPWSYTe2AoBV6CZDP
This commit is contained in:
1 parent
b115663ecf
commit
dacf3e617f
10 files changed
+842
-44
No files matched your search
@@ -58,8 +58,19 @@ docker compose up -d
|
||||
|
||||
> ⚠️ **Web Speech API는 발화를 상당량 놓칩니다.** 원거리·다인 대면 회의에서
|
||||
> 실측 포착률이 10% 안팎이었습니다(46분 회의 / 489어절). 회의 전사용으로
|
||||
> 설계된 엔진이 아니라 근접 음성 명령용입니다. 그래서 **오디오 원본을 반드시
|
||||
> 함께 남깁니다** — 놓친 발화는 나중에 서버 STT로 다시 살릴 수 있습니다.
|
||||
> 설계된 엔진이 아니라 근접 음성 명령용입니다. 그래서 이 패널은 **미리보기**이고,
|
||||
> 확정본은 종료 후 [서버 STT 재전사](#-서버-stt-재전사-정확도의-본선)로 만듭니다.
|
||||
|
||||
**유실 방지** — Chrome이 흘리는 것을 최대한 붙잡습니다.
|
||||
|
||||
- **미확정 발화 보존** — Chrome은 `continuous`여도 침묵마다 세션을 닫으면서 확정 전
|
||||
문장을 버립니다. 세션이 끝날 때마다 직접 확정시켜 살려냅니다
|
||||
- **마지막 발화 보존** — 중지 시점에 확정되지 않은 회의 끝머리도 함께 넘깁니다
|
||||
- **인식 실패 가시화** — Chrome이 "소리는 들었으나 못 알아듣겠다"고 답한 구간
|
||||
(`isFinal: true` + 빈 텍스트)은 요약 프롬프트를 오염시키지 않도록 청크에서 빼되,
|
||||
**`⚠️ 인식 실패 N건` 배지로 세어 보여줍니다.** 실측 46분 회의에서 19건이었습니다
|
||||
- **실제 타임스탬프** — 발화가 처음 들린 시각을 시작으로 잡고, 오디오 녹음과 같은
|
||||
시간축을 씁니다 (예전에는 `결과 이벤트 시각 − 2초` 하드코딩)
|
||||
|
||||
### 🎧 오디오 원본 보관
|
||||
|
||||
@@ -120,6 +131,23 @@ Gemini는 webm 오디오를 받지 않습니다. **실시간 녹음을 전사하
|
||||
|
||||
---
|
||||
|
||||
### 📏 긴 회의 (map-reduce 요약)
|
||||
|
||||
전사문이 6만 자를 넘으면 한 번에 모델에 넣지 못합니다. 예전에는 10만 자에서
|
||||
**413으로 거부**해, 3시간짜리 회의를 마치고 회의록을 만들려는 순간 아무것도 받지
|
||||
못했습니다. 유실을 막자고 만든 파이프라인 끝에서 결과를 통째로 버리는 셈이었습니다.
|
||||
|
||||
지금은 구간별로 한 번 압축한 뒤(map), 압축본을 모아 **평소와 똑같은 템플릿 경로로**
|
||||
회의록을 만듭니다(reduce). 최종 출력이 짧은 회의와 같은 프롬프트를 타므로 서식이
|
||||
흔들리지 않습니다.
|
||||
|
||||
- 압축 창 4만 자 · 단일 통과 상한 6만 자 · 전체 상한 50만 자(약 14시간 분량)
|
||||
- **한 구간이라도 실패하면 전체를 실패로 돌립니다.** 일부만 빠진 압축본으로 회의록을
|
||||
만들면 사용자는 무엇이 빠졌는지 모른 채 멀쩡해 보이는 문서를 받게 됩니다
|
||||
- 압축을 거치면 회의록 상단에 몇 개 구간으로 나눴는지 안내합니다
|
||||
|
||||
---
|
||||
|
||||
### 🤖 AI 회의록 (6 템플릿 × 3 강도)
|
||||
|
||||
회의 외에도 다양한 용도에 맞춰 출력 구조를 선택할 수 있습니다.
|
||||
@@ -541,7 +569,7 @@ prisma/
|
||||
| 제약 | 설명 | 대응 |
|
||||
|---|---|---|
|
||||
| **Chrome 전용** | Web Speech API는 비표준 — Safari / Firefox는 제한적 | 서버 사이드 STT 전환 예정 |
|
||||
| **Web Speech 포착률 낮음** | 원거리·다인 대면 회의 실측 10% 안팎 | 종료 후 **서버 STT 재전사**로 해결 |
|
||||
| **Web Speech 포착률 낮음** | 원거리·다인 대면 회의 실측 10% 안팎 | 미리보기로 강등, 종료 후 **서버 STT 재전사**로 해결 |
|
||||
| **Gemini는 webm 전사 불가** | Gemini가 받는 오디오 형식에 webm이 없음 | 녹음 전사는 OpenAI 호환 프로바이더 사용 |
|
||||
| **긴 회의 전사 상한** | Whisper 25MB(≈1시간 40분) / Gemini inline 14MB(≈1시간) | 초과 시 오류 안내. 분할 전사는 후속 |
|
||||
| **화자 구분 없음** | 미구현 (PR #16 / #17 검토 중) | — |
|
||||
|
||||
+12
-3
@@ -197,9 +197,18 @@ flush해서 7%를 20~30%로는 올려도 90%로는 못 간다. 구현 결함이
|
||||
- [ ] 상한 초과 회의 분할 전사 (Whisper 25MB / Gemini inline 14MB)
|
||||
- [ ] Gemini Files API 경로 (큰 파일 + webm 우회)
|
||||
- [ ] Web Speech를 "실시간 미리보기"로 명시적 강등 (현재는 둘 다 노출)
|
||||
- [ ] **빈 청크 필터링** — 빈 발화 19개가 요약 프롬프트를 오염시키고 있음
|
||||
- [ ] **타임스탬프 실측화** — `useSpeechRecognition.ts`의 `startTime: now - 2` 하드코딩 제거
|
||||
- [ ] **10만 자 하드 실패 → 분할 요약** — 긴 회의가 마지막에 통째로 실패함
|
||||
- [x] **Web Speech 유실 지점 정리**
|
||||
- [x] 빈 확정 결과를 청크에서 빼고 "인식 실패 N건"으로 노출 (실측 19건)
|
||||
- [x] 세션 재시작 시 미확정 발화 보존 — Chrome이 버리던 것을 직접 확정
|
||||
- [x] 중지 시 마지막 발화 보존 — `stopListening()`이 최종 청크를 돌려줌
|
||||
- [x] 실시간 텍스트 패널을 "미리보기"로 강등
|
||||
- [x] **타임스탬프 실측화** — `startTime: now - 2` 하드코딩 제거
|
||||
- [x] interim이 처음 뜬 시각을 발화 시작으로 사용
|
||||
- [x] `startRecording()`이 캡처 시작 시각을 돌려주고 인식기가 같은 시간축 사용
|
||||
- [x] **10만 자 하드 실패 → 분할 요약**
|
||||
- [x] 구간 압축(map) → 기존 템플릿 경로로 회의록(reduce)
|
||||
- [x] 상한 10만 → 50만 자 (약 14시간 분량)
|
||||
- [x] 한 구간이라도 실패하면 전체 실패 — 조용히 빠진 회의록을 만들지 않음
|
||||
|
||||
## 🎯 P1 — 입력단 개선
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import {
|
||||
CONDENSE_WINDOW_CHARS,
|
||||
MAX_TRANSCRIPT_CHARS,
|
||||
SINGLE_PASS_CHAR_LIMIT,
|
||||
condenseTranscript,
|
||||
planTranscriptWindows,
|
||||
} from '@/lib/long-transcript'
|
||||
|
||||
function okResponse(text: string) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
candidates: [{ content: { parts: [{ text }] } }],
|
||||
}),
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
const settings = { provider: 'gemini' as const, apiKey: 'AIza-test' }
|
||||
|
||||
describe('planTranscriptWindows', () => {
|
||||
it('상한 안이면 통째로 하나', () => {
|
||||
expect(planTranscriptWindows('짧은 전사문', 100)).toEqual(['짧은 전사문'])
|
||||
})
|
||||
|
||||
it('빈 입력은 구간이 없다', () => {
|
||||
expect(planTranscriptWindows(' ', 100)).toEqual([])
|
||||
})
|
||||
|
||||
it('줄 경계에서 나눈다', () => {
|
||||
const lines = ['[00:00] 가나다', '[00:05] 라마바', '[00:10] 사아자']
|
||||
const windows = planTranscriptWindows(lines.join('\n'), 25)
|
||||
|
||||
expect(windows.length).toBeGreaterThan(1)
|
||||
for (const w of windows) {
|
||||
// 줄 중간에서 끊기지 않았다
|
||||
for (const line of w.split('\n')) {
|
||||
expect(lines).toContain(line)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('어떤 내용도 잃지 않는다', () => {
|
||||
const lines = Array.from({ length: 500 }, (_, i) => `[00:${i % 60}] 발화 ${i}`)
|
||||
const source = lines.join('\n')
|
||||
|
||||
const windows = planTranscriptWindows(source, 200)
|
||||
|
||||
expect(windows.join('\n')).toBe(source)
|
||||
})
|
||||
|
||||
it('상한을 넘는 구간은 없다', () => {
|
||||
const source = Array.from({ length: 300 }, (_, i) => `줄 ${i}`).join('\n')
|
||||
for (const w of planTranscriptWindows(source, 50)) {
|
||||
expect(w.length).toBeLessThanOrEqual(50)
|
||||
}
|
||||
})
|
||||
|
||||
it('한 줄이 통째로 상한보다 길면 그 줄만 강제로 자른다', () => {
|
||||
const long = 'ㄱ'.repeat(250)
|
||||
const windows = planTranscriptWindows(long, 100)
|
||||
|
||||
expect(windows).toHaveLength(3)
|
||||
expect(windows.join('')).toBe(long)
|
||||
})
|
||||
|
||||
it('maxChars가 0 이하면 거부한다', () => {
|
||||
expect(() => planTranscriptWindows('가', 0)).toThrow()
|
||||
})
|
||||
|
||||
it('압축 창은 단일 통과 상한보다 작다', () => {
|
||||
// 압축한 결과를 다시 한 번에 넣을 수 있어야 map-reduce가 성립한다.
|
||||
expect(CONDENSE_WINDOW_CHARS).toBeLessThan(SINGLE_PASS_CHAR_LIMIT)
|
||||
expect(SINGLE_PASS_CHAR_LIMIT).toBeLessThan(MAX_TRANSCRIPT_CHARS)
|
||||
})
|
||||
})
|
||||
|
||||
describe('condenseTranscript', () => {
|
||||
it('구간마다 한 번씩 호출하고 결과를 이어 붙인다', async () => {
|
||||
const fetchFn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(okResponse('앞부분 정리'))
|
||||
.mockResolvedValueOnce(okResponse('뒷부분 정리'))
|
||||
|
||||
const source = Array.from({ length: 40 }, (_, i) => `줄 ${i}`).join('\n')
|
||||
const result = await condenseTranscript(source, {
|
||||
provider: settings,
|
||||
windowChars: 120,
|
||||
fetchFn,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (!result.success) return
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2)
|
||||
expect(result.windows).toBe(2)
|
||||
expect(result.text).toContain('앞부분 정리')
|
||||
expect(result.text).toContain('뒷부분 정리')
|
||||
expect(result.text).toContain('구간 1/2')
|
||||
})
|
||||
|
||||
it('프롬프트에 구간 위치를 알려 앞뒤를 지어내지 않게 한다', async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue(okResponse('정리'))
|
||||
const source = Array.from({ length: 40 }, (_, i) => `줄 ${i}`).join('\n')
|
||||
|
||||
await condenseTranscript(source, {
|
||||
provider: settings,
|
||||
windowChars: 120,
|
||||
fetchFn,
|
||||
})
|
||||
|
||||
const body = JSON.parse(
|
||||
(fetchFn.mock.calls[0][1] as RequestInit).body as string,
|
||||
)
|
||||
const prompt = body.contents[0].parts[0].text
|
||||
expect(prompt).toContain('1/2 구간')
|
||||
expect(prompt).toContain('추측해 채우지 않습니다')
|
||||
})
|
||||
|
||||
it('한 구간이라도 실패하면 전체를 실패로 돌린다', async () => {
|
||||
// 일부만 빠진 압축본으로 회의록을 만들면 사용자는 무엇이 빠졌는지 모른다.
|
||||
const fetchFn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(okResponse('앞부분'))
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({}),
|
||||
} as unknown as Response)
|
||||
|
||||
const source = Array.from({ length: 40 }, (_, i) => `줄 ${i}`).join('\n')
|
||||
const result = await condenseTranscript(source, {
|
||||
provider: settings,
|
||||
windowChars: 120,
|
||||
fetchFn,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (result.success) return
|
||||
expect(result.error).toContain('2/2 구간')
|
||||
})
|
||||
|
||||
it('빈 응답도 실패로 본다', async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue(okResponse(' '))
|
||||
const result = await condenseTranscript('짧은 글', {
|
||||
provider: settings,
|
||||
fetchFn,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('429는 rateLimited로 올려보낸다', async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
json: async () => ({}),
|
||||
} as unknown as Response)
|
||||
|
||||
const result = await condenseTranscript('짧은 글', {
|
||||
provider: settings,
|
||||
fetchFn,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (result.success) return
|
||||
expect(result.rateLimited).toBe(true)
|
||||
})
|
||||
|
||||
it('빈 전사문은 호출조차 하지 않는다', async () => {
|
||||
const fetchFn = vi.fn()
|
||||
const result = await condenseTranscript(' ', {
|
||||
provider: settings,
|
||||
fetchFn,
|
||||
})
|
||||
|
||||
expect(fetchFn).not.toHaveBeenCalled()
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -99,6 +99,45 @@ describe('useAudioRecorder — 캡처 제약', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAudioRecorder — 시간축', () => {
|
||||
it('캡처 시작 시각을 돌려준다', async () => {
|
||||
// 이 값을 인식기에 넘겨야 전사 타임스탬프가 오디오 파일과 같은 축을 쓴다.
|
||||
const before = Date.now()
|
||||
const view = renderHook(() => useAudioRecorder())
|
||||
await waitFor(() => expect(view.result.current.isSupported).toBe(true))
|
||||
|
||||
let startedAt: number | null = null
|
||||
await act(async () => {
|
||||
startedAt = await view.result.current.startRecording()
|
||||
})
|
||||
|
||||
expect(startedAt).not.toBeNull()
|
||||
expect(startedAt!).toBeGreaterThanOrEqual(before)
|
||||
expect(startedAt!).toBeLessThanOrEqual(Date.now())
|
||||
})
|
||||
|
||||
it('마이크를 못 열면 null', async () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
mediaDevices: {
|
||||
getUserMedia: vi.fn(async () => {
|
||||
throw Object.assign(new Error('denied'), { name: 'NotAllowedError' })
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const view = renderHook(() => useAudioRecorder())
|
||||
await waitFor(() => expect(view.result.current.isSupported).toBe(true))
|
||||
|
||||
let startedAt: number | null = 0
|
||||
await act(async () => {
|
||||
startedAt = await view.result.current.startRecording()
|
||||
})
|
||||
|
||||
expect(startedAt).toBeNull()
|
||||
expect(view.result.current.error).toContain('마이크 권한')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAudioRecorder — 조각 업로드', () => {
|
||||
it('조각이 생길 때마다 서버로 올린다', async () => {
|
||||
const view = await startedHook()
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { useSpeechRecognition } from '@/hooks/useSpeechRecognition'
|
||||
|
||||
/**
|
||||
* jsdom에는 SpeechRecognition이 없다. 여기서 검증하려는 것은 인식 품질이 아니라
|
||||
* **Chrome이 준 것을 우리가 흘리지 않는가**이므로, 이벤트를 직접 쏠 수 있는
|
||||
* 최소 구현이면 충분하다.
|
||||
*/
|
||||
|
||||
interface FakeResult {
|
||||
isFinal: boolean
|
||||
0: { transcript: string }
|
||||
}
|
||||
|
||||
class FakeSpeechRecognition {
|
||||
lang = ''
|
||||
continuous = false
|
||||
interimResults = false
|
||||
|
||||
onresult: ((event: { resultIndex: number; results: FakeResult[] }) => void) | null = null
|
||||
onend: (() => void) | null = null
|
||||
onerror: ((event: { error: string }) => void) | null = null
|
||||
|
||||
started = 0
|
||||
|
||||
constructor() {
|
||||
instances.push(this)
|
||||
}
|
||||
|
||||
start() {
|
||||
this.started += 1
|
||||
}
|
||||
|
||||
stop() {
|
||||
// 실제 Chrome은 stop() 뒤에도 onend를 쏘지만, 훅이 ref를 먼저 끊어
|
||||
// 재시작을 막는다. 그 동작을 그대로 흉내 낸다.
|
||||
this.onend?.()
|
||||
}
|
||||
|
||||
/** Chrome이 결과를 내보내는 것을 흉내 낸다. */
|
||||
emit(...results: Array<{ text: string; final: boolean }>) {
|
||||
this.onresult?.({
|
||||
resultIndex: 0,
|
||||
results: results.map((r) => ({
|
||||
isFinal: r.final,
|
||||
0: { transcript: r.text },
|
||||
})),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let instances: FakeSpeechRecognition[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
instances = []
|
||||
vi.stubGlobal('SpeechRecognition', FakeSpeechRecognition)
|
||||
vi.stubGlobal('webkitSpeechRecognition', FakeSpeechRecognition)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function listening(timeOrigin?: number) {
|
||||
const view = renderHook(() => useSpeechRecognition())
|
||||
await waitFor(() => expect(view.result.current.isSupported).toBe(true))
|
||||
act(() => view.result.current.startListening(timeOrigin))
|
||||
return view
|
||||
}
|
||||
|
||||
describe('확정된 발화', () => {
|
||||
it('청크로 쌓인다', async () => {
|
||||
const view = await listening()
|
||||
|
||||
act(() => instances[0].emit({ text: '안녕하세요', final: true }))
|
||||
|
||||
expect(view.result.current.chunks).toHaveLength(1)
|
||||
expect(view.result.current.chunks[0].text).toBe('안녕하세요')
|
||||
expect(view.result.current.chunks[0].isFinal).toBe(true)
|
||||
})
|
||||
|
||||
it('빈 확정 결과는 청크로 넣지 않고 유실로 센다', async () => {
|
||||
// 실측 46분 회의에서 19번 나온 케이스. Chrome이 "소리는 들었는데
|
||||
// 못 알아듣겠다"고 답한 것으로, 청크로 넣으면 요약 프롬프트가 오염된다.
|
||||
const view = await listening()
|
||||
|
||||
act(() => instances[0].emit({ text: ' ', final: true }))
|
||||
act(() => instances[0].emit({ text: '', final: true }))
|
||||
|
||||
expect(view.result.current.chunks).toHaveLength(0)
|
||||
expect(view.result.current.missedCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('미확정 발화 보존', () => {
|
||||
it('세션이 끊길 때 확정 전 발화를 살려낸다', async () => {
|
||||
// Chrome은 continuous여도 침묵마다 세션을 닫고, 그때 확정 전 문장을 버린다.
|
||||
const view = await listening()
|
||||
|
||||
act(() => instances[0].emit({ text: '아직 확정 안 된 말', final: false }))
|
||||
expect(view.result.current.interimText).toBe('아직 확정 안 된 말')
|
||||
expect(view.result.current.chunks).toHaveLength(0)
|
||||
|
||||
act(() => instances[0].onend?.())
|
||||
|
||||
expect(view.result.current.chunks).toHaveLength(1)
|
||||
expect(view.result.current.chunks[0].text).toBe('아직 확정 안 된 말')
|
||||
// 우리가 살려낸 값이지 Chrome이 확정해 준 값이 아니다.
|
||||
expect(view.result.current.chunks[0].isFinal).toBe(false)
|
||||
expect(view.result.current.interimText).toBe('')
|
||||
})
|
||||
|
||||
it('중지할 때도 마지막 발화를 살려낸다', async () => {
|
||||
// 회의 끝머리가 통째로 사라지던 자리.
|
||||
const view = await listening()
|
||||
|
||||
act(() => instances[0].emit({ text: '수고하셨습니다', final: false }))
|
||||
|
||||
let finalChunks: ReturnType<typeof view.result.current.stopListening> = []
|
||||
act(() => {
|
||||
finalChunks = view.result.current.stopListening()
|
||||
})
|
||||
|
||||
expect(finalChunks).toHaveLength(1)
|
||||
expect(finalChunks[0].text).toBe('수고하셨습니다')
|
||||
})
|
||||
|
||||
it('stopListening이 렌더 시점이 아닌 최신 청크를 돌려준다', async () => {
|
||||
const view = await listening()
|
||||
|
||||
act(() => instances[0].emit({ text: '첫 발화', final: true }))
|
||||
act(() => instances[0].emit({ text: '끝 발화', final: false }))
|
||||
|
||||
let finalChunks: ReturnType<typeof view.result.current.stopListening> = []
|
||||
act(() => {
|
||||
finalChunks = view.result.current.stopListening()
|
||||
})
|
||||
|
||||
expect(finalChunks.map((c) => c.text)).toEqual(['첫 발화', '끝 발화'])
|
||||
})
|
||||
|
||||
it('살려낼 미확정 발화가 없으면 아무것도 추가하지 않는다', async () => {
|
||||
const view = await listening()
|
||||
|
||||
act(() => instances[0].emit({ text: '확정된 말', final: true }))
|
||||
act(() => instances[0].onend?.())
|
||||
|
||||
expect(view.result.current.chunks).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('재시작이 반복돼도 확정 전 발화가 매번 보존된다', async () => {
|
||||
const view = await listening()
|
||||
const recognition = instances[0]
|
||||
|
||||
// flushInterim은 재시작 타이머보다 먼저, 동기로 돈다.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
act(() => recognition.emit({ text: `구간 ${i}`, final: false }))
|
||||
act(() => recognition.onend?.())
|
||||
}
|
||||
|
||||
expect(view.result.current.chunks.map((c) => c.text)).toEqual([
|
||||
'구간 0',
|
||||
'구간 1',
|
||||
'구간 2',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('타임스탬프', () => {
|
||||
it('발화가 처음 들린 시각을 시작으로 잡는다', async () => {
|
||||
const origin = Date.now()
|
||||
const view = await listening(origin)
|
||||
|
||||
const nowSpy = vi.spyOn(Date, 'now')
|
||||
|
||||
nowSpy.mockReturnValue(origin + 5_000)
|
||||
act(() => instances[0].emit({ text: '말하는 중', final: false }))
|
||||
|
||||
nowSpy.mockReturnValue(origin + 12_000)
|
||||
act(() => instances[0].emit({ text: '말하는 중입니다', final: true }))
|
||||
|
||||
const chunk = view.result.current.chunks[0]
|
||||
// 하드코딩된 `끝 − 2초`(10초)가 아니라 실제로 들리기 시작한 5초여야 한다.
|
||||
expect(chunk.startTime).toBeCloseTo(5, 1)
|
||||
expect(chunk.endTime).toBeCloseTo(12, 1)
|
||||
|
||||
nowSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('timeOrigin을 주면 그 기준으로 시각을 잰다', async () => {
|
||||
// 오디오 녹음 시작 시각을 넘기면 전사 타임스탬프가 파일 재생 위치와 맞는다.
|
||||
const origin = Date.now() - 30_000
|
||||
const view = await listening(origin)
|
||||
|
||||
act(() => instances[0].emit({ text: '발화', final: true }))
|
||||
|
||||
expect(view.result.current.chunks[0].endTime).toBeGreaterThan(29)
|
||||
})
|
||||
|
||||
it('interim 없이 확정만 오면 기본 길이로 되돌아간다', async () => {
|
||||
const origin = Date.now()
|
||||
const view = await listening(origin)
|
||||
|
||||
const nowSpy = vi.spyOn(Date, 'now')
|
||||
nowSpy.mockReturnValue(origin + 8_000)
|
||||
act(() => instances[0].emit({ text: '갑자기 확정', final: true }))
|
||||
|
||||
const chunk = view.result.current.chunks[0]
|
||||
expect(chunk.endTime).toBeCloseTo(8, 1)
|
||||
expect(chunk.startTime).toBeCloseTo(6, 1)
|
||||
|
||||
nowSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetChunks', () => {
|
||||
it('청크와 유실 카운트를 함께 지운다', async () => {
|
||||
const view = await listening()
|
||||
|
||||
act(() => instances[0].emit({ text: '발화', final: true }))
|
||||
act(() => instances[0].emit({ text: '', final: true }))
|
||||
act(() => view.result.current.resetChunks())
|
||||
|
||||
expect(view.result.current.chunks).toHaveLength(0)
|
||||
expect(view.result.current.missedCount).toBe(0)
|
||||
})
|
||||
|
||||
it('초기화 후 stopListening은 빈 배열', async () => {
|
||||
const view = await listening()
|
||||
|
||||
act(() => instances[0].emit({ text: '발화', final: true }))
|
||||
act(() => view.result.current.resetChunks())
|
||||
|
||||
let finalChunks: ReturnType<typeof view.result.current.stopListening> = []
|
||||
act(() => {
|
||||
finalChunks = view.result.current.stopListening()
|
||||
})
|
||||
expect(finalChunks).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -4,10 +4,13 @@ import {
|
||||
generateSimpleMinutes,
|
||||
} from '@/lib/minutes-generator'
|
||||
import { resolveProviderSettings } from '@/lib/api-keys'
|
||||
import {
|
||||
MAX_TRANSCRIPT_CHARS,
|
||||
SINGLE_PASS_CHAR_LIMIT,
|
||||
condenseTranscript,
|
||||
} from '@/lib/long-transcript'
|
||||
import type { SummaryDepth, TemplateId } from '@/lib/templates'
|
||||
|
||||
const MAX_TRANSCRIPT_CHARS = 100_000
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
@@ -52,6 +55,30 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
// 한 번에 넣기엔 긴 회의는 구간별로 압축한 뒤 평소 경로로 회의록을 만든다.
|
||||
// 예전에는 여기서 413으로 거부해, 3시간 회의가 마지막에 통째로 실패했다.
|
||||
let condensedNotice: string | undefined
|
||||
|
||||
if (transcript.length > SINGLE_PASS_CHAR_LIMIT) {
|
||||
const condensed = await condenseTranscript(transcript, {
|
||||
provider: settings,
|
||||
})
|
||||
|
||||
if (!condensed.success) {
|
||||
const fallback = generateSimpleMinutes(input)
|
||||
return Response.json({
|
||||
markdown: fallback,
|
||||
mode: 'simple',
|
||||
warning: `긴 회의 정리에 실패하여 단순 변환으로 대체되었습니다: ${condensed.error}`,
|
||||
})
|
||||
}
|
||||
|
||||
input.transcript = condensed.text
|
||||
condensedNotice =
|
||||
`긴 회의(${transcript.length.toLocaleString()}자)라 ${condensed.windows}개 구간으로 ` +
|
||||
'나눠 정리한 뒤 회의록을 작성했습니다.'
|
||||
}
|
||||
|
||||
const result = await generateAiMinutes(input, { provider: settings })
|
||||
|
||||
if (!result.success) {
|
||||
@@ -63,7 +90,11 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
return Response.json({ markdown: result.markdown, mode: 'gemini' })
|
||||
return Response.json({
|
||||
markdown: result.markdown,
|
||||
mode: 'gemini',
|
||||
...(condensedNotice ? { warning: condensedNotice } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
const markdown = generateSimpleMinutes(input)
|
||||
|
||||
@@ -40,6 +40,7 @@ export function LiveRecorder({
|
||||
chunks,
|
||||
interimText,
|
||||
error: recognitionError,
|
||||
missedCount,
|
||||
startListening,
|
||||
stopListening,
|
||||
resetChunks,
|
||||
@@ -123,14 +124,18 @@ export function LiveRecorder({
|
||||
resetRecording()
|
||||
resetTranscription()
|
||||
// 오디오 녹음을 먼저 건다. 전사가 실패하더라도 원본은 남아야 한다.
|
||||
await startRecording()
|
||||
startListening()
|
||||
// 녹음 시작 시각을 인식기에 넘겨, 전사 타임스탬프가 오디오 파일의
|
||||
// 재생 위치와 같은 시간축을 쓰게 한다.
|
||||
const audioStartedAt = await startRecording()
|
||||
startListening(audioStartedAt ?? undefined)
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
stopListening()
|
||||
// 훅이 미확정 발화까지 굳혀서 돌려준다. 렌더 시점의 `chunks`를 쓰면
|
||||
// 회의 마지막 문장이 빠진다.
|
||||
const finalChunks = stopListening()
|
||||
|
||||
const transcript = formatTranscriptChunks(chunks)
|
||||
const transcript = formatTranscriptChunks(finalChunks)
|
||||
if (transcript.length > 0) {
|
||||
onTranscriptReady(transcript)
|
||||
}
|
||||
@@ -240,6 +245,15 @@ export function LiveRecorder({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{missedCount > 0 && (
|
||||
<div
|
||||
className="flex items-center gap-1.5 rounded-full bg-amber-50 px-4 py-1.5 text-xs text-amber-700"
|
||||
title="Chrome이 소리는 감지했지만 무슨 말인지 인식하지 못한 구간입니다."
|
||||
>
|
||||
⚠️ 인식 실패 {missedCount}건
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRecording && (
|
||||
<div className="flex items-center gap-2 rounded-full bg-neutral-100 px-4 py-1.5 text-xs text-neutral-600">
|
||||
<span className="text-sm">🎧</span>
|
||||
@@ -337,7 +351,7 @@ export function LiveRecorder({
|
||||
}`}
|
||||
/>
|
||||
<h3 className="text-xs font-semibold text-neutral-600 uppercase tracking-wider">
|
||||
실시간 텍스트
|
||||
실시간 텍스트 <span className="normal-case font-normal text-neutral-400">(미리보기)</span>
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-500">
|
||||
|
||||
@@ -31,7 +31,8 @@ export interface AudioRecorderHook {
|
||||
localBytes: number
|
||||
uploadedBytes: number
|
||||
recording: CompletedRecording | null
|
||||
startRecording: () => Promise<void>
|
||||
/** 캡처가 시작된 시각(epoch ms). 실패하면 null. */
|
||||
startRecording: () => Promise<number | null>
|
||||
stopRecording: () => Promise<void>
|
||||
downloadRecording: (title: string) => void
|
||||
reset: () => void
|
||||
@@ -164,8 +165,8 @@ export function useAudioRecorder(): AudioRecorderHook {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
if (recorderRef.current) return
|
||||
const startRecording = useCallback(async (): Promise<number | null> => {
|
||||
if (recorderRef.current) return null
|
||||
|
||||
setError(null)
|
||||
setUploadWarning(null)
|
||||
@@ -180,7 +181,7 @@ export function useAudioRecorder(): AudioRecorderHook {
|
||||
|
||||
if (isSupported !== true) {
|
||||
setError('이 브라우저는 오디오 녹음을 지원하지 않습니다. Chrome을 사용해주세요.')
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
const mimeType = pickRecorderMimeType((type) =>
|
||||
@@ -188,7 +189,7 @@ export function useAudioRecorder(): AudioRecorderHook {
|
||||
)
|
||||
if (!mimeType) {
|
||||
setError('브라우저가 지원하는 녹음 형식을 찾지 못했습니다.')
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
let stream: MediaStream
|
||||
@@ -198,7 +199,7 @@ export function useAudioRecorder(): AudioRecorderHook {
|
||||
})
|
||||
} catch (err) {
|
||||
setError(describeCaptureError(err))
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
// 서버 세션은 있으면 좋고 없어도 녹음은 간다. 여기서 포기하면
|
||||
@@ -242,13 +243,16 @@ export function useAudioRecorder(): AudioRecorderHook {
|
||||
setError('녹음 중 오류가 발생했습니다. 지금까지의 오디오는 보존되어 있습니다.')
|
||||
}
|
||||
|
||||
const startedAt = new Date()
|
||||
streamRef.current = stream
|
||||
recorderRef.current = recorder
|
||||
mimeTypeRef.current = mimeType
|
||||
startedAtRef.current = new Date()
|
||||
startedAtRef.current = startedAt
|
||||
|
||||
recorder.start(CHUNK_INTERVAL_MS)
|
||||
setIsRecording(true)
|
||||
|
||||
return startedAt.getTime()
|
||||
}, [isSupported, queueUpload])
|
||||
|
||||
const stopRecording = useCallback(async () => {
|
||||
|
||||
@@ -9,8 +9,20 @@ interface SpeechRecognitionHook {
|
||||
chunks: TranscriptChunk[]
|
||||
interimText: string
|
||||
error: string | null
|
||||
startListening: () => void
|
||||
stopListening: () => void
|
||||
/**
|
||||
* Chrome이 "소리는 들었는데 못 알아듣겠다"고 답한 횟수.
|
||||
*
|
||||
* `isFinal: true`에 `transcript: ""`인 결과다. 실측 46분 회의에서 19번 나왔다.
|
||||
* 조용히 버리면 유실이 보이지 않으므로 세어서 노출한다.
|
||||
*/
|
||||
missedCount: number
|
||||
/**
|
||||
* @param timeOrigin 시간축의 기준점(ms). 오디오 녹음 시작 시각을 넘기면
|
||||
* 전사 타임스탬프가 녹음 파일의 재생 위치와 맞는다. 생략하면 지금 시각.
|
||||
*/
|
||||
startListening: (timeOrigin?: number) => void
|
||||
/** 미확정 발화까지 확정한 최종 청크를 돌려준다. */
|
||||
stopListening: () => TranscriptChunk[]
|
||||
resetChunks: () => void
|
||||
}
|
||||
|
||||
@@ -30,16 +42,31 @@ function describeError(code: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** 발화 시작을 못 잡았을 때 되돌아갈 기본 길이(초). */
|
||||
const FALLBACK_UTTERANCE_SECONDS = 2
|
||||
|
||||
export function useSpeechRecognition(): SpeechRecognitionHook {
|
||||
const [isListening, setIsListening] = useState(false)
|
||||
const [chunks, setChunks] = useState<TranscriptChunk[]>([])
|
||||
const [interimText, setInterimText] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isSupported, setIsSupported] = useState<boolean | null>(null)
|
||||
const [missedCount, setMissedCount] = useState(0)
|
||||
|
||||
const recognitionRef = useRef<SpeechRecognition | null>(null)
|
||||
const startTimeRef = useRef<number>(0)
|
||||
const networkRetryRef = useRef<number>(0)
|
||||
|
||||
// 청크의 정본은 ref다. 인식 종료 직후 곧바로 최종 결과를 넘겨야 하는데
|
||||
// setState는 비동기라 그 시점의 값을 읽을 수 없다.
|
||||
const chunksRef = useRef<TranscriptChunk[]>([])
|
||||
|
||||
// 아직 확정되지 않은 발화. 세션이 끊기면 Chrome은 이걸 그냥 버리므로
|
||||
// 우리가 들고 있다가 직접 확정시킨다.
|
||||
const interimRef = useRef('')
|
||||
// 현재 발화가 처음 들리기 시작한 시각(초). interim이 처음 뜰 때 기록한다.
|
||||
const utteranceStartRef = useRef<number | null>(null)
|
||||
|
||||
const MAX_NETWORK_RETRIES = 8
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,7 +77,44 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
||||
)
|
||||
}, [])
|
||||
|
||||
const startListening = useCallback(() => {
|
||||
const elapsed = useCallback(
|
||||
() => (Date.now() - startTimeRef.current) / 1000,
|
||||
[],
|
||||
)
|
||||
|
||||
const appendChunk = useCallback((chunk: TranscriptChunk) => {
|
||||
chunksRef.current = [...chunksRef.current, chunk]
|
||||
setChunks(chunksRef.current)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 들고 있던 미확정 발화를 청크로 굳힌다.
|
||||
*
|
||||
* 세션이 끝날 때마다 호출한다. Chrome은 `continuous`여도 침묵마다 세션을
|
||||
* 닫는데, 그때 확정 전이던 문장이 통째로 사라지는 것이 유실의 큰 축이었다.
|
||||
*/
|
||||
const flushInterim = useCallback(() => {
|
||||
const pending = interimRef.current.trim()
|
||||
interimRef.current = ''
|
||||
setInterimText('')
|
||||
|
||||
if (pending.length === 0) {
|
||||
utteranceStartRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
const end = elapsed()
|
||||
appendChunk({
|
||||
text: pending,
|
||||
startTime: utteranceStartRef.current ?? Math.max(0, end - FALLBACK_UTTERANCE_SECONDS),
|
||||
endTime: end,
|
||||
// Chrome이 확정해 준 결과가 아니라 우리가 살려낸 값이다.
|
||||
isFinal: false,
|
||||
})
|
||||
utteranceStartRef.current = null
|
||||
}, [appendChunk, elapsed])
|
||||
|
||||
const startListening = useCallback((timeOrigin?: number) => {
|
||||
if (isSupported !== true) {
|
||||
setError('이 브라우저는 음성 인식을 지원하지 않습니다. Chrome을 사용해주세요.')
|
||||
return
|
||||
@@ -64,8 +128,10 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
||||
recognition.continuous = true
|
||||
recognition.interimResults = true
|
||||
|
||||
startTimeRef.current = Date.now()
|
||||
startTimeRef.current = timeOrigin ?? Date.now()
|
||||
networkRetryRef.current = 0
|
||||
interimRef.current = ''
|
||||
utteranceStartRef.current = null
|
||||
setError(null)
|
||||
|
||||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||
@@ -74,32 +140,47 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const now = (Date.now() - startTimeRef.current) / 1000
|
||||
const now = elapsed()
|
||||
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const result = event.results[i]
|
||||
const text = result[0].transcript.trim()
|
||||
|
||||
if (result.isFinal) {
|
||||
setChunks((prev) => [
|
||||
...prev,
|
||||
{
|
||||
text,
|
||||
startTime: Math.max(0, now - 2),
|
||||
endTime: now,
|
||||
isFinal: true,
|
||||
},
|
||||
])
|
||||
setInterimText('')
|
||||
} else {
|
||||
setInterimText(text)
|
||||
if (!result.isFinal) {
|
||||
// 발화의 첫 조각이 들린 순간이 곧 시작 시각이다.
|
||||
if (utteranceStartRef.current === null) {
|
||||
utteranceStartRef.current = now
|
||||
}
|
||||
interimRef.current = text
|
||||
setInterimText(text)
|
||||
continue
|
||||
}
|
||||
|
||||
const start =
|
||||
utteranceStartRef.current ??
|
||||
Math.max(0, now - FALLBACK_UTTERANCE_SECONDS)
|
||||
|
||||
interimRef.current = ''
|
||||
utteranceStartRef.current = null
|
||||
setInterimText('')
|
||||
|
||||
if (text.length === 0) {
|
||||
// 소리는 감지했지만 인식에 실패한 구간. 프롬프트를 오염시키지 않도록
|
||||
// 청크로 넣지 않되, 유실이 있었다는 사실은 남긴다.
|
||||
setMissedCount((prev) => prev + 1)
|
||||
continue
|
||||
}
|
||||
|
||||
appendChunk({ text, startTime: start, endTime: now, isFinal: true })
|
||||
}
|
||||
}
|
||||
|
||||
recognition.onend = () => {
|
||||
if (recognitionRef.current !== recognition) return
|
||||
|
||||
// 재시작 전에 반드시 비운다. 새 세션은 이전 오디오를 다시 주지 않는다.
|
||||
flushInterim()
|
||||
|
||||
const delay = networkRetryRef.current > 0 ? 1500 : 0
|
||||
setTimeout(() => {
|
||||
if (recognitionRef.current !== recognition) return
|
||||
@@ -151,22 +232,31 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
||||
)
|
||||
recognitionRef.current = null
|
||||
}
|
||||
}, [isSupported])
|
||||
}, [appendChunk, elapsed, flushInterim, isSupported])
|
||||
|
||||
const stopListening = useCallback(() => {
|
||||
if (recognitionRef.current) {
|
||||
const stopListening = useCallback((): TranscriptChunk[] => {
|
||||
const recognition = recognitionRef.current
|
||||
if (recognition) {
|
||||
recognitionRef.current = null
|
||||
recognition.stop()
|
||||
setIsListening(false)
|
||||
setInterimText('')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 마지막 발화는 확정 전에 세션이 닫히는 경우가 대부분이다.
|
||||
// 여기서 굳히지 않으면 회의 끝머리가 통째로 사라진다.
|
||||
flushInterim()
|
||||
|
||||
return chunksRef.current
|
||||
}, [flushInterim])
|
||||
|
||||
const resetChunks = useCallback(() => {
|
||||
chunksRef.current = []
|
||||
interimRef.current = ''
|
||||
utteranceStartRef.current = null
|
||||
setChunks([])
|
||||
setInterimText('')
|
||||
setError(null)
|
||||
setMissedCount(0)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
@@ -175,6 +265,7 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
||||
chunks,
|
||||
interimText,
|
||||
error,
|
||||
missedCount,
|
||||
startListening,
|
||||
stopListening,
|
||||
resetChunks,
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { complete, toProviderSettings, type ProviderSettings } from './providers'
|
||||
|
||||
/**
|
||||
* 긴 회의 처리.
|
||||
*
|
||||
* 예전에는 10만 자를 넘으면 `/api/summarize`가 413으로 **거부**했다. 3시간짜리
|
||||
* 회의를 마치고 회의록을 만들려는 순간 아무것도 못 받는다는 뜻이다. 유실을
|
||||
* 막자고 만든 파이프라인의 끝에서 결과를 통째로 버리는 셈이었다.
|
||||
*
|
||||
* 이제는 구간별로 한 번 압축한 뒤(map), 압축본을 모아 평소와 똑같은 템플릿
|
||||
* 경로로 회의록을 만든다(reduce). 최종 출력이 짧은 회의와 같은 프롬프트를
|
||||
* 타므로 서식이 흔들리지 않는다.
|
||||
*/
|
||||
|
||||
/** 이 길이를 넘으면 한 번에 못 넣는다고 보고 압축 단계를 거친다. */
|
||||
export const SINGLE_PASS_CHAR_LIMIT = 60_000
|
||||
|
||||
/** 압축 단계에서 한 번에 넣을 구간 크기. */
|
||||
export const CONDENSE_WINDOW_CHARS = 40_000
|
||||
|
||||
/** 이걸 넘으면 압축을 해도 감당이 안 된다고 보고 거부한다(약 14시간 분량). */
|
||||
export const MAX_TRANSCRIPT_CHARS = 500_000
|
||||
|
||||
/**
|
||||
* 전사문을 줄 경계에서 잘라 구간으로 나눈다.
|
||||
*
|
||||
* 한 줄이 통째로 상한을 넘으면(타임스탬프 없는 긴 문단) 그 줄만 강제로 자른다.
|
||||
* 어떤 경우에도 원문의 어느 부분도 버리지 않는다.
|
||||
*/
|
||||
export function planTranscriptWindows(
|
||||
transcript: string,
|
||||
maxChars: number = CONDENSE_WINDOW_CHARS,
|
||||
): string[] {
|
||||
if (maxChars <= 0) throw new Error('maxChars는 1 이상이어야 한다')
|
||||
|
||||
const text = transcript.trim()
|
||||
if (text.length === 0) return []
|
||||
if (text.length <= maxChars) return [text]
|
||||
|
||||
const windows: string[] = []
|
||||
let current: string[] = []
|
||||
let currentLength = 0
|
||||
|
||||
function flush() {
|
||||
if (current.length === 0) return
|
||||
windows.push(current.join('\n'))
|
||||
current = []
|
||||
currentLength = 0
|
||||
}
|
||||
|
||||
for (const line of text.split('\n')) {
|
||||
// 줄 하나가 상한보다 길면 쪼개는 수밖에 없다.
|
||||
if (line.length > maxChars) {
|
||||
flush()
|
||||
for (let i = 0; i < line.length; i += maxChars) {
|
||||
windows.push(line.slice(i, i + maxChars))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const projected = currentLength + line.length + (current.length > 0 ? 1 : 0)
|
||||
if (projected > maxChars) flush()
|
||||
|
||||
current.push(line)
|
||||
currentLength += line.length + (current.length > 1 ? 1 : 0)
|
||||
}
|
||||
|
||||
flush()
|
||||
return windows
|
||||
}
|
||||
|
||||
function buildCondensePrompt(
|
||||
window: string,
|
||||
index: number,
|
||||
total: number,
|
||||
): string {
|
||||
return [
|
||||
`다음은 회의 전사문의 ${index + 1}/${total} 구간입니다.`,
|
||||
'',
|
||||
'이 구간에서 오간 내용을 빠짐없이 정리하세요. 이 정리본만 보고 회의록을',
|
||||
'작성하게 되므로, 뒤에서 쓰일 정보를 잃으면 안 됩니다.',
|
||||
'',
|
||||
'규칙:',
|
||||
'- 논의된 주제, 결정된 사항, 할 일, 숫자·날짜·고유명사를 모두 남깁니다.',
|
||||
'- 누가 말했는지 드러나면 함께 적습니다.',
|
||||
'- 인사말·잡담·중복은 덜어냅니다.',
|
||||
'- 회의록 서식으로 만들지 말고, 사실을 담은 개조식 메모로만 정리합니다.',
|
||||
'- 앞뒤 구간을 추측해 채우지 않습니다. 이 구간에 있는 내용만 씁니다.',
|
||||
'',
|
||||
'---',
|
||||
window,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export interface CondenseOptions {
|
||||
provider?: ProviderSettings
|
||||
apiKey?: string
|
||||
windowChars?: number
|
||||
fetchFn?: typeof fetch
|
||||
}
|
||||
|
||||
export type CondenseResult =
|
||||
| { success: true; text: string; windows: number }
|
||||
| { success: false; error: string; rateLimited?: boolean }
|
||||
|
||||
/**
|
||||
* 긴 전사문을 구간별로 압축한다.
|
||||
*
|
||||
* 한 구간이라도 실패하면 전체를 실패로 돌린다. 일부만 빠진 압축본으로 회의록을
|
||||
* 만들면 사용자는 무엇이 빠졌는지 모른 채 멀쩡해 보이는 문서를 받게 된다.
|
||||
*/
|
||||
export async function condenseTranscript(
|
||||
transcript: string,
|
||||
options: CondenseOptions = {},
|
||||
): Promise<CondenseResult> {
|
||||
const settings = toProviderSettings(options.provider, options.apiKey)
|
||||
const windows = planTranscriptWindows(
|
||||
transcript,
|
||||
options.windowChars ?? CONDENSE_WINDOW_CHARS,
|
||||
)
|
||||
|
||||
if (windows.length === 0) {
|
||||
return { success: false, error: '요약할 텍스트가 비어 있습니다.' }
|
||||
}
|
||||
|
||||
const condensed: string[] = []
|
||||
|
||||
for (const [index, window] of windows.entries()) {
|
||||
const result = await complete(
|
||||
buildCondensePrompt(window, index, windows.length),
|
||||
settings,
|
||||
{ fetchFn: options.fetchFn },
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${index + 1}/${windows.length} 구간 정리 실패: ${result.error}`,
|
||||
...(result.rateLimited ? { rateLimited: true } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const text = result.text.trim()
|
||||
if (text.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${index + 1}/${windows.length} 구간 정리 결과가 비어 있습니다.`,
|
||||
}
|
||||
}
|
||||
|
||||
condensed.push(`## 구간 ${index + 1}/${windows.length}\n\n${text}`)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
text: condensed.join('\n\n'),
|
||||
windows: windows.length,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user