Files
meeting-minutes/src/lib/providers/presets.ts
T
csbaeandClaude Opus 5 b115663ecf feat: 서버 STT 재전사 — 오디오 원본에서 확정 전사문을 뽑는다
#18이 오디오를 남기게 했다면, 이 PR은 그 오디오를 실제로 쓴다.
Web Speech 포착률 7~10%를 끌어올리는 유일한 방법은 인식 엔진 교체다.

## 두 경로가 하나로 만난다

    실시간 녹음 ─┐
                 ├→ recordingId → /api/transcribe → 확정 전사문
    파일 업로드 ─┘

`/api/upload`는 파일을 디스크에 떨궈만 두고 "실시간 녹음 탭에서 하세요"라고
안내하던 막다른 길이었다. 이제 녹음 저장소에 넣고 recordingId를 돌려주므로
브라우저 녹음과 완전히 같은 파이프라인을 탄다.

## 실제 타임스탬프

Whisper `verbose_json`이 구간별 실제 오디오 시각을 준다. Web Speech 경로가 쓰던
`결과 이벤트 시각 − 2초` 추정값과 달리 구간 재생·화자 분리에 그대로 쓸 수 있다.
이 값이 있어야 PR #17의 diarization 화자 배정이 비로소 의미를 갖는다.

verbose_json을 지원하지 않는 모델(gpt-4o-transcribe 등)은 400을 주므로 `json`
으로 한 번 더 시도해 텍스트만이라도 받는다.

## 프로바이더별 지원

| 프로바이더 | webm 녹음 | 업로드 파일 | 기본 모델 |
|---|:---:|:---:|---|
| OpenAI · OrcaRouter · 로컬 whisper | O | O | whisper-1 |
| Gemini | X | O | gemini-3.6-flash |

Gemini가 문서로 밝힌 오디오 형식에 webm이 없다. 조용히 실패시키지 않고
"OpenAI 호환 프로바이더를 쓰라"고 명시적으로 안내하며, 설정 화면에도 경고를 띄운다.

## 비트레이트 128k → 32k

이 오디오는 감상용이 아니라 STT 입력이다. 128kbps면 46분 회의가 44MB가 되어
Whisper 상한(25MB)도 Gemini inline 상한도 넘긴다. 32kbps mono Opus는 음성
인식 정확도에 영향이 없으면서 46분을 11MB로 줄인다.

## 참석자·용어 힌트

홈 화면에 입력란을 두고 전사 요청의 어휘 힌트로 보낸다. 실측 transcript에서
"계명대동산병원 → 저희 키스해 주셔서", "양식대로 → 양복점" 같은 고유명사
붕괴가 심했던 부분이다.

## 검증

가짜 Whisper 서버를 세워 종단간 확인:
- 업로드 → recordingId → 전사 → `[00:00]/[00:03]/[00:12]` 전사문
- multipart 필드 검증: model · response_format=verbose_json · language=ko ·
  prompt(어휘 힌트) · Bearer 인증 · 파일 바이트 정확히 일치
- webm 녹음도 Whisper로 정상 전사 (5000 bytes 그대로 전달)
- Gemini+webm 거부 / 미설정 400 / 경로조작 400 / 없는 녹음 404

테스트 206 → 229 통과, 신규 타입 에러 0, 린트 baseline과 동일, 빌드 성공.

### 검증하지 못한 것

실제 Whisper·Gemini API를 호출하지 못했다(키 없음). 모든 프로바이더 코드는
목 fetch와 가짜 서버로만 검증했다. 요청 형식은 문서를 따랐으나 실제 응답에
대한 확인이 필요하다.

포착률이 실제로 얼마나 오르는지도 아직 모른다. 다음 회의에서 같은 오디오로
Web Speech와 STT를 나란히 놓고 재야 한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzAH7GPWSYTe2AoBV6CZDP
2026-09-09 15:19:49 +09:00

115 lines
3.9 KiB
TypeScript

import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_STT_MODEL } from './gemini'
import { DEFAULT_OPENAI_STT_MODEL } from './openai-compatible'
import type { ProviderId } from './types'
export interface ProviderPreset {
id: string
label: string
provider: ProviderId
/** openai-compatible 프리셋의 기본 base URL. 사용자가 수정할 수 있다. */
baseUrl: string
defaultModel: string
/** 음성 전사(STT)에 쓸 기본 모델. 요약용 모델과 다르다. */
defaultSttModel: string
/** 이 프로바이더로 브라우저 녹음(webm)을 전사할 수 있는지. */
canTranscribeWebm: boolean
description: string
apiKeyLabel: string
apiKeyPlaceholder: string
/** 키 없이도 동작하는 엔드포인트(로컬 모델 서버)인지 여부 */
apiKeyOptional?: boolean
docsUrl?: string
docsLabel?: string
modelHint?: string
}
export const PROVIDER_PRESETS: ProviderPreset[] = [
{
id: 'gemini',
label: 'Google Gemini',
provider: 'gemini',
baseUrl: '',
defaultModel: DEFAULT_GEMINI_MODEL,
defaultSttModel: DEFAULT_GEMINI_STT_MODEL,
// Gemini는 webm 오디오를 받지 않는다 — 업로드한 mp3/wav/flac만 전사 가능.
canTranscribeWebm: false,
description: 'Google에 직접 호출합니다. 무료 티어가 있어 가장 간단합니다.',
apiKeyLabel: 'Gemini API 키',
apiKeyPlaceholder: 'AIzaSy...',
docsUrl: 'https://aistudio.google.com/apikey',
docsLabel: 'Google AI Studio',
modelHint:
'예: gemini-3.5-flash-lite(저렴·빠름), gemini-3.6-flash, gemini-2.5-pro',
},
{
id: 'openai',
label: 'OpenAI',
provider: 'openai-compatible',
baseUrl: 'https://api.openai.com/v1',
defaultModel: 'gpt-4o-mini',
defaultSttModel: DEFAULT_OPENAI_STT_MODEL,
canTranscribeWebm: true,
description: 'OpenAI Chat Completions + Whisper 전사를 사용합니다.',
apiKeyLabel: 'OpenAI API 키',
apiKeyPlaceholder: 'sk-...',
docsUrl: 'https://platform.openai.com/api-keys',
docsLabel: 'OpenAI 대시보드',
modelHint: '예: gpt-4o-mini, gpt-4o',
},
{
id: 'orcarouter',
label: 'OrcaRouter',
provider: 'openai-compatible',
baseUrl: 'https://api.orcarouter.ai/v1',
defaultModel: 'google/gemini-3.5-flash-lite',
defaultSttModel: 'openai/whisper-1',
canTranscribeWebm: true,
description:
'하나의 키로 여러 제공사 모델을 사용합니다. 별도 가입과 크레딧 충전(또는 BYOK 등록)이 필요합니다.',
apiKeyLabel: 'OrcaRouter API 키',
apiKeyPlaceholder: 'sk-...',
docsUrl: 'https://www.orcarouter.ai/',
docsLabel: 'OrcaRouter',
modelHint:
'예: google/gemini-3.5-flash-lite, openai/gpt-4o-mini, orcarouter/auto',
},
{
id: 'local',
label: '로컬 모델',
provider: 'openai-compatible',
baseUrl: 'http://localhost:11434/v1',
defaultModel: 'llama3.1',
defaultSttModel: 'whisper-1',
canTranscribeWebm: true,
description:
'Ollama · LM Studio · vLLM 등 내 PC에서 도는 모델. 회의 내용이 외부로 나가지 않습니다.',
apiKeyLabel: 'API 키 (보통 불필요)',
apiKeyPlaceholder: '비워두세요',
apiKeyOptional: true,
modelHint: 'Ollama 기본 포트는 11434, LM Studio는 1234입니다.',
},
{
id: 'custom',
label: '직접 입력',
provider: 'openai-compatible',
baseUrl: '',
defaultModel: '',
defaultSttModel: DEFAULT_OPENAI_STT_MODEL,
canTranscribeWebm: true,
description: 'OpenAI 호환 엔드포인트라면 무엇이든 연결할 수 있습니다.',
apiKeyLabel: 'API 키',
apiKeyPlaceholder: 'sk-...',
apiKeyOptional: true,
modelHint: '엔드포인트가 제공하는 모델 ID를 그대로 입력하세요.',
},
]
export const DEFAULT_PRESET_ID = 'gemini'
export function findPreset(presetId: string | null | undefined): ProviderPreset {
return (
PROVIDER_PRESETS.find((preset) => preset.id === presetId) ??
PROVIDER_PRESETS[0]
)
}