Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ STORAGE_LOCAL_ROOT=/var/lib/stackup-ai/storage
# LLM_FLASH_API_KEY=
# 음성 STT. auto=DEEPGRAM_API_KEY 있으면 deepgram, 없으면 mock.
# STT_PROVIDER=auto
# DEEPGRAM_MODEL=whisper-large
# DEEPGRAM_MODEL=nova-2
# DEEPGRAM_LANGUAGE=ko
# DEEPGRAM_TIMEOUT_SEC=20
# DEEPGRAM_CONNECT_TIMEOUT_SEC=5
Expand Down
5 changes: 4 additions & 1 deletion ai/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,10 @@ STT_PROVIDER=auto
# Deepgram (한국어 정확도 우선 권장. 신규 가입 시 $200 크레딧).
DEEPGRAM_API_KEY=
DEEPGRAM_BASE_URL=https://api.deepgram.com/v1
DEEPGRAM_MODEL=whisper-large
# 모델을 바꾸면 Core 의 DeliveryFeedback.PRONUNCIATION_LOW 도 재보정해야 한다 —
# pronunciation_accuracy 는 STT 신뢰도라 분포가 모델마다 다르다(ai/CLAUDE.md §8).
# whisper-large: 같은 오디오에서 20초 read 타임아웃이 23% 발생해 2026-09-27 에 교체.
DEEPGRAM_MODEL=nova-2
DEEPGRAM_LANGUAGE=ko
DEEPGRAM_TIMEOUT_SEC=20 # read 한도. 정상 호출 p50 3.8초/p90 8.1초/최대 12.1초
DEEPGRAM_CONNECT_TIMEOUT_SEC=5 # connect 한도. 일괄이면 연결 막힘도 read 한도만큼 대기
Expand Down
13 changes: 13 additions & 0 deletions ai/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,19 @@ chain = prompt | llm | PydanticOutputParser(pydantic_object=...)
- **TTS: Gemini TTS 기본(한국어)** (`voice/tts/`) — 질문(INTERVIEWER) 메시지 음성화. Deepgram/OpenAI TTS 는 한국어 미지원이라 `GeminiTtsProvider`(`gemini-2.5-flash-preview-tts`, voice=Kore)가 기본. Gemini 는 raw PCM(L16/24kHz)을 반환하므로 WAV 로 감싸 `audio/wav` 로 저장. `TtsProvider` 추상화 + `GatewayTtsProvider`(Mindlogic 게이트웨이 `/audio/speech`, LLM_API_KEY, raw PCM→WAV)/`GeminiTtsProvider`(직접 GEMINI_API_KEY)/`MockTtsProvider`, `build_tts_provider` factory(`TTS_PROVIDER=auto`면 **LLM_API_KEY(gateway) > GEMINI_API_KEY** 순 — 게이트웨이 우선으로 직접 키 429 부하 분산). gateway/gemini 는 `gemini_tts_model`/`gemini_tts_voice` 공유. `generate.tts` consumer 가 합성 → S3 PUT(확장자는 content_type 기준) → `callback.tts` 발행. 재생은 Core 오디오 프록시(`GET /api/sessions/{sid}/messages/{mid}/audio`) 경유(MinIO presigned URL 이 내부 호스트라 브라우저 직접 접근 불가).
- **스트리밍 STT (실시간 음성 답변, RT3): Deepgram Live** (`voice/stt/deepgram_live.py`) — `websockets`로 Deepgram WS(`wss://api.deepgram.com/v1/listen`, nova-2)에 연결, interim/final 자막을 실시간 반환. `voice/stt/live.py`(`LiveSttProvider`/`LiveSttSession` 추상) + `voice/stt/mock_live.py`(키 없을 때 fallback) + `voice/stt/live_factory.py`(`LIVE_STT_PROVIDER=auto`면 DEEPGRAM_API_KEY 보유 시 deepgram_live).
- FastAPI WS 엔드포인트 `/internal/voice/stream`(`api/voice_stream.py`): RealTime이 프록시한 오디오를 받아 부분/최종 자막을 다운 프레임(`transcript.partial`/`transcript.final`)으로 보내고, 발화 종료(`stop` 또는 UtteranceEnd) 시 메트릭 계산 후 `callback.voice` 발행 → 기존 followup 파이프라인 재사용.
- **STT 모델 nova-2 로 교체 (2026-09-27)**: 운영 음성 3종을 30회 돌려 재시도 없이 측정하니
whisper-large 는 **23%(7/30)가 20초 read 타임아웃**이었고 파일별 편차가 컸다(60% / 10% / 0%
— 크기와 무관). 재시도 3회가 대부분 흡수하지만 최악 오디오는 5번에 1번꼴로 사용자에게 실패가
보이고, 실패마다 20초씩 더 기다린다(3회 전부 실패 시 61초). 같은 조건에서 **nova-2 는 28/28
성공**, 응답도 2~3배 빠르다. nova-3 는 가장 빠르지만 없는 말을 지어내고("아 네. 질문 지레인데?")
`imbeded`·`readis` 처럼 영문 철자가 깨져 제외했다. 한국어 품질은 whisper ≈ nova-2 로,
whisper 가 기술 영문을 원문 유지(`Revalidation Event`)하는 반면 nova-2 는 한글화(`리밸리데이션
이벤트`)하는 차이 정도다.
- **부작용 2개를 같이 처리했다.** ① `keywords` 힌트 경로가 켜진다(Whisper 는 400 이라 잠들어
있었다) — 운영 키로 200·전사 동일 확인 후 테스트로 고정. ② `pronunciation_accuracy` 는 STT
신뢰도라 **모델마다 분포가 다르다**. Core 의 `PRONUNCIATION_LOW` 를 0.85→0.93 으로 재보정
(실측: whisper 중앙 0.841 → 0.85 기준 57% 가 '불분명'으로 이미 무의미했다 / nova-2 중앙
0.976, 0.93 기준 15%).
- **STT 타임아웃 분리 + 재시도** (`voice/stt/deepgram.py`, `messaging/consumers/voice_consumer.py`): Deepgram 배치 호출이 간헐적으로 응답 없이 멎는다 — 운영 실패 2건이 60.8s/61.5s 로 read 한도에 정확히 걸렸고 성공 호출은 p50 3.6초/최대 12초였다. 오디오 크기·손상·인증·모델·로컬 네트워크는 실측으로 배제됐고(실패 파일 6개 중 5개가 재전송에서 즉시 전사), 상류가 구간적으로 멎는 것이 원인. 일괄 `timeout=60` 을 `httpx.Timeout(read=20, connect=5)` 으로 쪼개 연결 막힘에 1분을 쓰지 않게 하고, `VoiceConsumer` 가 `SttError.retriable` 을 **읽어** 지수 백오프로 재시도한다(`STT_MAX_ATTEMPTS` 기본 3, `STT_RETRY_BACKOFF_SEC` 0.5). 비재시도(STT_AUTH_FAILED/STT_BAD_REQUEST)와 예상 못 한 예외는 재시도 없이 즉시 실패 콜백. 타임아웃 예외는 `str()` 이 빈 문자열이라 메시지에 예외 타입을 같이 남긴다(이전 로그는 `Deepgram 호출 실패: ` 로 끝나 connect/read 구분이 불가능했다). `ai_request_logs` 는 시도마다 한 행 — `error_message` 앞의 `[n/N]` 으로 재시도율을 본다.
- **STT 환각 제거** (`voice/stt/sanitize.py`): Whisper/Deepgram 이 발화 끝 무음·잡음에서 학습데이터(방송/유튜브) 정형 문구를 환각으로 덧붙이는 문제(예: "MBC 뉴스 OOO입니다", "시청해주셔서 감사합니다", "구독과 좋아요", 영어 "thanks for watching")를 보수적으로 제거. 배치(`deepgram`/`openai_whisper`)는 `transcribe` 반환 시, 라이브(`deepgram_live`)는 **최종 자막마다** + `result()` 백스톱에서 적용 → 저장 전사·메트릭·실시간 표시 모두 정화. 환각만 남은 segment 는 제거해 무음/발음 메트릭이 실제 무음 반영. "감사합니다"·"뉴스 앱" 등 정상 표현은 보존.
- 추상화 계층 두기: `voice/stt/base.py` (interface), `voice/stt/whisper_api.py`, `voice/tts/base.py` + `voice/tts/{provider}.py`
Expand Down
7 changes: 6 additions & 1 deletion ai/src/ai_server/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,12 @@ class Settings(BaseSettings):
stt_provider: Literal["auto", "mock", "deepgram"] = "auto"
deepgram_api_key: str = ""
deepgram_base_url: str = "https://api.deepgram.com/v1"
deepgram_model: str = "whisper-large" # 한국어 정확도 우선; 저비용 우선 시 nova-2.
# whisper-large 에서 nova-2 로 교체(2026-09-27). 운영 음성으로 30회 측정했더니
# whisper-large 는 **23%(7/30)가 20초 read 타임아웃**이었고 파일에 따라 60%까지 갔다.
# 재시도 3회가 대부분 흡수하지만 최악 오디오는 5번에 1번꼴로 사용자에게 실패가 보이고,
# 실패할 때마다 20초씩 더 기다린다. nova-2 는 같은 조건에서 28/28 성공, 응답도 2~3배 빠르다.
# nova-3 는 더 빠르지만 없는 말을 지어내고("아 네. 질문 지레인데?") 영문 철자가 깨져 제외.
deepgram_model: str = "nova-2"
deepgram_language: str = "ko"
# read 한도. 운영 성공 호출은 p50 3.8초/p90 8.1초/최대 12.1초라 20초면 1.65배 여유다.
# 멎은 호출을 붙잡는 시간이 곧 사용자 대기 시간이므로(재시도 전 낭비) 짧을수록 좋다.
Expand Down
7 changes: 4 additions & 3 deletions ai/src/ai_server/voice/stt/deepgram.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,17 @@ class DeepgramSttProvider:
Mindlogic 게이트웨이가 STT 미지원이라 Deepgram 직접 호출. DEEPGRAM_API_KEY 필요.
응답에서 transcript + utterances(=문장 단위 segment) + words 활용.

한국어 정확도 우선 시 model=whisper-large 권장 (whisper-1 동등).
저비용 우선 시 nova-2 (한국어는 살짝 떨어짐).
기본 nova-2. whisper-large 는 운영 음성 30회 측정에서 23%(7/30)가 20초 read
타임아웃이었고 파일에 따라 60% 까지 갔다 — nova-2 는 같은 조건 28/28 성공에
응답도 2~3배 빠르다. nova-3 는 더 빠르지만 없는 말을 지어내고 영문 철자가 깨져 제외.
"""

def __init__(
self,
*,
api_key: str,
base_url: str = "https://api.deepgram.com/v1",
model: str = "whisper-large",
model: str = "nova-2",
language: str | None = "ko",
timeout_sec: float = 30.0,
connect_timeout_sec: float = 5.0,
Expand Down
57 changes: 57 additions & 0 deletions ai/tests/test_deepgram_stt.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,60 @@ def test_deepgram_connect_timeout_is_separate_from_read_timeout():
timeout = provider._timeout # noqa: SLF001 — 구성값 회귀 고정
assert timeout.connect == 5.0
assert timeout.read == 30.0


def test_default_model_is_nova2_not_whisper():
"""운영 음성 30회 측정에서 whisper-large 는 23%(7/30)가 20초 타임아웃이었다.

파일에 따라 60% 까지 갔고, 재시도 3회로도 최악 오디오는 5번에 1번꼴로 사용자에게
실패가 보인다. nova-2 는 같은 조건 28/28 성공. 모델 기본값이 조용히 되돌아가면
그 실패율이 함께 돌아오므로 여기서 고정한다.
"""
# 운영이 실제로 쓰는 값은 Settings 기본값이다(factory 가 이걸 provider 에 넘긴다).
from ai_server.config.settings import Settings

assert Settings.model_fields["deepgram_model"].default == "nova-2"
# provider 자체 기본값도 맞춰 둔다 — 두 곳이 어긋나면 어느 쪽이 진짜인지 헷갈린다.
assert DeepgramSttProvider(api_key="k").model_name == "nova-2"


@pytest.mark.asyncio
async def test_nova_model_sends_keyword_hint():
"""nova 계열에서만 keywords 를 붙인다 — Whisper 모델은 400 을 낸다.

whisper-large 를 쓰는 동안 이 경로는 잠들어 있었다. 모델 교체로 켜지므로
실제로 파라미터가 실리는지 고정해 둔다(운영 키로 200 응답·전사 동일 확인 완료).
"""
seen = {}

def handler(request: httpx.Request) -> httpx.Response:
seen.update(dict(request.url.params))
return httpx.Response(200, json={"metadata": {}, "results": {"channels": []}})

async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
provider = DeepgramSttProvider(api_key="k", model="nova-2", client=client)
await provider.transcribe(
audio_bytes=b"x", content_type="audio/webm", hint="ACID 를 설명해 주세요"
)

assert seen["model"] == "nova-2"
assert seen["keywords"] == "ACID 를 설명해 주세요"


@pytest.mark.asyncio
async def test_whisper_model_omits_keyword_hint():
seen = {}

def handler(request: httpx.Request) -> httpx.Response:
seen.update(dict(request.url.params))
return httpx.Response(200, json={"metadata": {}, "results": {"channels": []}})

async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
provider = DeepgramSttProvider(
api_key="k", model="whisper-large", client=client
)
await provider.transcribe(
audio_bytes=b"x", content_type="audio/webm", hint="ACID 를 설명해 주세요"
)

assert "keywords" not in seen
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ public record DeliveryFeedback(String rating, String comment) {
private static final double WPM_FAST = 150; // 어절/분 초과면 빠름
private static final double SILENCE_RATIO_HIGH = 0.35; // 전체 발화시간 중 무음 비율
private static final double FILLERS_PER_100_HIGH = 3.0; // 100어절당 간투어 개수
private static final double PRONUNCIATION_LOW = 0.85; // STT 신뢰도 근사
// STT 신뢰도 근사. **모델을 바꾸면 반드시 재보정해야 한다** — 값의 분포가 모델마다 다르다.
// whisper-large 시절 0.85 는 운영 77건 중 44건(57%)을 '발음 불분명'으로 찍어 이미 무의미했다.
// nova-2 로 교체하며 실제 답변 26건을 재측정: 중앙 0.976 / p10 0.909 / 최소 0.840.
// 0.93 이면 15%(4/26)가 걸린다 — 하위권만 짚어 신호가 남으면서 과다 판정은 피하는 지점.
private static final double PRONUNCIATION_LOW = 0.93;

// 음성 메트릭이 하나라도 있으면 평가, 전부 없으면(텍스트 답변 등) null.
public static DeliveryFeedback assess(
Expand Down
Loading