diff --git a/.env.example b/.env.example index dabf182c..a406ca37 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/ai/.env.example b/ai/.env.example index eab49e4e..41d5d988 100644 --- a/ai/.env.example +++ b/ai/.env.example @@ -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 한도만큼 대기 diff --git a/ai/CLAUDE.md b/ai/CLAUDE.md index d48c6cc1..04c83759 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -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` diff --git a/ai/src/ai_server/config/settings.py b/ai/src/ai_server/config/settings.py index 966aef0e..0ce1b427 100644 --- a/ai/src/ai_server/config/settings.py +++ b/ai/src/ai_server/config/settings.py @@ -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배 여유다. # 멎은 호출을 붙잡는 시간이 곧 사용자 대기 시간이므로(재시도 전 낭비) 짧을수록 좋다. diff --git a/ai/src/ai_server/voice/stt/deepgram.py b/ai/src/ai_server/voice/stt/deepgram.py index a0665f58..426c80ea 100644 --- a/ai/src/ai_server/voice/stt/deepgram.py +++ b/ai/src/ai_server/voice/stt/deepgram.py @@ -19,8 +19,9 @@ 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__( @@ -28,7 +29,7 @@ def __init__( *, 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, diff --git a/ai/tests/test_deepgram_stt.py b/ai/tests/test_deepgram_stt.py index e7bfd2e2..168e7e15 100644 --- a/ai/tests/test_deepgram_stt.py +++ b/ai/tests/test_deepgram_stt.py @@ -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 diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/DeliveryFeedback.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/DeliveryFeedback.java index 4a5e761e..0b91f883 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/dto/DeliveryFeedback.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/DeliveryFeedback.java @@ -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(