diff --git a/ai/CLAUDE.md b/ai/CLAUDE.md index 04c83759..dbf509bd 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -208,10 +208,23 @@ chain = prompt | llm | PydanticOutputParser(pydantic_object=...) 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%). + 있었다). ② `pronunciation_accuracy` 는 STT 신뢰도라 **모델마다 분포가 다르다**. Core 의 + `PRONUNCIATION_LOW` 를 0.85→0.93 으로 재보정(실측: whisper 중앙 0.841 → 0.85 기준 57% 가 + '불분명'으로 이미 무의미했다 / nova-2 중앙 0.976, 0.93 기준 15%). +- **`keywords` 힌트는 보내지 않는다 (2026-09-27, 위 ①의 후속)**: 모델 교체 직후엔 "200 응답 + + 전사 동일"만 보고 안전하다고 판단했는데, **비교 1회로는 효과 없음과 비결정성을 구분할 수 + 없었다.** 같은 오디오로 힌트 유/무를 5회씩 돌리니 두 조건의 출력 분포가 **동일**했다 + (각각 297자 4회 + 285자 1회). 즉 차이처럼 보이던 것은 힌트가 아니라 **nova-2 자체의 + 비결정성**이다. 단어 하나 + 강도(`백오프:10`)로 줄여도, 영어 단어(`jitter:10`)로 바꿔도 + 같았다 — 한국어에서 Deepgram keyword boosting 은 동작하지 않는다. + - 그래서 `params["keywords"]` 를 뺐다. 효과가 없는데 매 호출마다 질문 200자를 외부로 보내고, + Deepgram 이 언젠가 이 값을 반영하기 시작하면 **답변 전사가 질문 어휘 쪽으로 끌려간다** — + 채점하는 서비스에서 그건 사용자가 하지 않은 말로 감점되는 것이다. `hint` 인자 자체는 + `SttProvider` 공통 인터페이스라 남겨 둔다. + - `keyterm`(nova-3 용 후속 파라미터)은 nova-2 에서 400 이다. + - **덤으로 알게 된 것: nova-2 는 비결정적이다.** 같은 파일 10회 중 2회가 마지막 문장 + ("여기까지 하겠습니다.")을 빠뜨렸다. 재전사(`retranscribe`)가 다른 결과를 내는 것은 + 버그가 아니라 이 성질 때문이며, 실패가 아니어도 재전사가 도움이 될 수 있다는 뜻이다. - **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/voice/stt/deepgram.py b/ai/src/ai_server/voice/stt/deepgram.py index 426c80ea..cb38df2c 100644 --- a/ai/src/ai_server/voice/stt/deepgram.py +++ b/ai/src/ai_server/voice/stt/deepgram.py @@ -65,10 +65,14 @@ async def transcribe( } if self._language: params["language"] = self._language - # Deepgram keywords 는 model=nova-* 에서만 지원 (Whisper 모델은 400). - # hint 는 한국어 정확도가 모델 자체로 충분하므로 nova 계열에서만 사용. - if hint and self._model.startswith("nova"): - params["keywords"] = hint[:200] + # hint(직전 질문 본문)는 **보내지 않는다.** Deepgram keywords 로 실어 봤지만 + # 한국어에서는 아무 효과가 없었다. 같은 오디오로 힌트 유/무 5회씩 측정한 결과 + # 두 조건의 출력 분포가 동일했다(각각 297자 4회 + 285자 1회) — 차이처럼 보이던 것은 + # nova-2 자체의 비결정성이었다. 단어 하나 + 강도(`백오프:10`)로 줄여도 같았다. + # 효과가 없는데 매 호출마다 질문 200자를 외부로 보내고, 언젠가 Deepgram 이 이 값을 + # 실제로 반영하기 시작하면 **답변 전사가 질문 어휘 쪽으로 끌려간다** — 채점하는 + # 서비스에서 그건 사용자가 하지 않은 말로 감점되는 것이다. + # hint 인자는 인터페이스에 남겨 둔다(SttProvider 공통, 다른 제공자가 쓸 수 있다). headers = { "Authorization": f"Token {self._api_key}", diff --git a/ai/tests/test_deepgram_stt.py b/ai/tests/test_deepgram_stt.py index 168e7e15..8240f691 100644 --- a/ai/tests/test_deepgram_stt.py +++ b/ai/tests/test_deepgram_stt.py @@ -151,11 +151,12 @@ def test_default_model_is_nova2_not_whisper(): @pytest.mark.asyncio -async def test_nova_model_sends_keyword_hint(): - """nova 계열에서만 keywords 를 붙인다 — Whisper 모델은 400 을 낸다. +async def test_keyword_hint_is_never_sent(): + """직전 질문을 keywords 로 보내지 않는다. - whisper-large 를 쓰는 동안 이 경로는 잠들어 있었다. 모델 교체로 켜지므로 - 실제로 파라미터가 실리는지 고정해 둔다(운영 키로 200 응답·전사 동일 확인 완료). + 한국어에서 효과가 없음을 실측했다(힌트 유/무 5회씩, 출력 분포 동일 — 차이로 보이던 + 것은 nova-2 의 비결정성이었다). 효과가 없는데 매 호출마다 질문 200자를 외부로 보내고, + Deepgram 이 언젠가 이 값을 반영하면 답변 전사가 질문 어휘로 끌려간다. """ seen = {} @@ -163,30 +164,14 @@ 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 + for model in ("nova-2", "nova-3", "whisper-large"): + seen.clear() + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = DeepgramSttProvider(api_key="k", model=model, client=client) + await provider.transcribe( + audio_bytes=b"x", + content_type="audio/webm", + hint="ACID 를 설명해 주세요", + ) + assert seen["model"] == model + assert "keywords" not in seen, f"{model} 에서 keywords 가 실렸다"