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:
csbaeandClaude Opus 5 committed 2026-09-09 18:09:05 +09:00
1 parent b115663ecf
commit dacf3e617f
10 files changed
+842 -44

No files matched your search

+31 -3
View File
@@ -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 검토 중) | — |