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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ STT_PROVIDER=auto
DEEPGRAM_API_KEY=
DEEPGRAM_MODEL=whisper-large
DEEPGRAM_LANGUAGE=ko
DEEPGRAM_TIMEOUT_SEC=30
DEEPGRAM_CONNECT_TIMEOUT_SEC=5
STT_MAX_ATTEMPTS=3
STT_RETRY_BACKOFF_SEC=0.5

# RealTime server
REALTIME_PORT=38020
Expand Down
5 changes: 4 additions & 1 deletion ai/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,10 @@ DEEPGRAM_API_KEY=
DEEPGRAM_BASE_URL=https://api.deepgram.com/v1
DEEPGRAM_MODEL=whisper-large
DEEPGRAM_LANGUAGE=ko
DEEPGRAM_TIMEOUT_SEC=60
DEEPGRAM_TIMEOUT_SEC=30 # read 한도. 정상 호출 p50 3.6초/최대 12초
DEEPGRAM_CONNECT_TIMEOUT_SEC=5 # connect 한도. 일괄이면 연결 막힘도 read 한도만큼 대기
STT_MAX_ATTEMPTS=3 # 일시 장애(retriable) 재시도 횟수. 1=재시도 없음
STT_RETRY_BACKOFF_SEC=0.5 # 지수 백오프 기준값 (0.5 → 0.5s, 1s)

# TTS (질문 음성화). auto=LLM_API_KEY(gateway) > GEMINI_API_KEY 순, 없으면 mock.
# gateway: Mindlogic 게이트웨이(/audio/speech)가 Gemini TTS 로 라우팅 → 직접 GEMINI_API_KEY 429 부하 분산.
Expand Down
1 change: 1 addition & 0 deletions ai/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ 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 타임아웃 분리 + 재시도** (`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=30, 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
9 changes: 8 additions & 1 deletion ai/src/ai_server/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,14 @@ class Settings(BaseSettings):
deepgram_base_url: str = "https://api.deepgram.com/v1"
deepgram_model: str = "whisper-large" # 한국어 정확도 우선; 저비용 우선 시 nova-2.
deepgram_language: str = "ko"
deepgram_timeout_sec: float = 60.0
# read 한도. 정상 호출은 p50 3.6초/최대 12초라 30초도 크게 여유롭다.
# 이전 60초는 Deepgram 이 멎었을 때 사용자를 1분간 붙잡아 두기만 했다.
deepgram_timeout_sec: float = 30.0
deepgram_connect_timeout_sec: float = 5.0
# STT 재시도. Deepgram 이 간헐적으로 응답 없이 멎는데, 같은 오디오를 재전송하면
# 대부분 즉시 전사된다. retriable 오류에만 적용(인증·잘못된 요청은 즉시 실패).
stt_max_attempts: int = 3
stt_retry_backoff_sec: float = 0.5

# 스트리밍 STT (실시간 음성 답변). "auto" 면 DEEPGRAM_API_KEY 보유 시 deepgram_live, 없으면 mock.
live_stt_provider: Literal["auto", "mock", "deepgram_live"] = "auto"
Expand Down
102 changes: 74 additions & 28 deletions ai/src/ai_server/messaging/consumers/voice_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
)
from ai_server.storage.base import ObjectStorage
from ai_server.voice.analysis.metrics import analyze
from ai_server.voice.stt.base import SttError, SttProvider
from ai_server.voice.stt.base import SttError, SttProvider, TranscriptionResult

log = structlog.get_logger(__name__)

Expand All @@ -43,6 +43,8 @@ def __init__(
callback_routing_key: str,
filler_pattern: str,
core_client: CoreClient | None = None,
stt_max_attempts: int = 3,
stt_retry_backoff_sec: float = 0.5,
) -> None:
self._stt = stt
self._storage = storage
Expand All @@ -51,6 +53,8 @@ def __init__(
self._callback_routing_key = callback_routing_key
self._filler_pattern = filler_pattern
self._core_client = core_client
self._stt_max_attempts = max(1, stt_max_attempts)
self._stt_retry_backoff_sec = stt_retry_backoff_sec

async def handle(self, message: AbstractIncomingMessage) -> None:
async with message.process(requeue=False):
Expand Down Expand Up @@ -91,45 +95,21 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
await self._publish_failed(envelope, req, code="AUDIO_FETCH_FAILED")
return

started = None
try:
started = time.perf_counter()
result = await self._stt.transcribe(
audio_bytes=audio_bytes,
content_type=req.content_type,
hint=req.previous_question_text,
)
self._record_stt_log(
req,
envelope,
latency_ms=_elapsed_ms(started),
status="SUCCESS",
error_message=None,
result = await self._transcribe_with_retry(
req, envelope, audio_bytes
)
except SttError as exc:
self._record_stt_log(
req,
envelope,
latency_ms=_elapsed_ms(started),
status="FAILED",
error_message=exc.message,
)
log.error(
"voice.stt.failed",
error=str(exc),
code=exc.code,
session_id=req.session_id,
attempts=getattr(exc, "attempts", 1),
)
await self._publish_failed(envelope, req, code=exc.code)
return
except Exception as exc:
self._record_stt_log(
req,
envelope,
latency_ms=_elapsed_ms(started),
status="FAILED",
error_message=str(exc),
)
log.error(
"voice.stt.unexpected",
error=str(exc),
Expand Down Expand Up @@ -162,6 +142,72 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
trace_id=envelope.trace_id,
)

async def _transcribe_with_retry(
self, req: AnalyzeVoiceRequest, envelope, audio_bytes: bytes
) -> TranscriptionResult:
"""일시 장애(retriable)면 지수 백오프로 재시도.

Deepgram 호출은 간헐적으로 응답 없이 멎는다 — 운영 실패 2건이 60.8s/61.5s 로
read 한도에 정확히 걸렸고, 성공 호출은 p50 3.6초였다. 멎은 뒤 같은 오디오를
재전송하면 대부분 즉시 전사되므로(실패 파일 6개 중 5개가 재전송에서 성공),
한 번의 딸꾹질이 사용자에게 영구 실패로 보이지 않도록 여기서 흡수한다.

비재시도(STT_AUTH_FAILED, STT_BAD_REQUEST)는 재전송해도 같은 결과라 즉시 올린다.
예상 못 한 예외는 코드 버그일 가능성이 높아 재시도로 지연만 늘리므로 그대로 올린다.
`ai_request_logs` 에는 시도마다 한 행씩 남는다 — 재시도율을 보려면 error_message
앞의 `[n/N]` 을 본다.
"""
for attempt in range(1, self._stt_max_attempts + 1):
started = time.perf_counter()
try:
result = await self._stt.transcribe(
audio_bytes=audio_bytes,
content_type=req.content_type,
hint=req.previous_question_text,
)
except SttError as exc:
self._record_stt_log(
req,
envelope,
latency_ms=_elapsed_ms(started),
status="FAILED",
error_message=f"[{attempt}/{self._stt_max_attempts}] {exc.message}",
)
if not exc.retriable or attempt == self._stt_max_attempts:
exc.attempts = attempt # 최종 실패 로그가 실제 시도 횟수를 싣도록
raise
backoff = self._stt_retry_backoff_sec * (2 ** (attempt - 1))
log.warn(
"voice.stt.retry",
code=exc.code,
attempt=attempt,
max_attempts=self._stt_max_attempts,
backoff_sec=backoff,
session_id=req.session_id,
)
await asyncio.sleep(backoff)
continue
except Exception as exc:
self._record_stt_log(
req,
envelope,
latency_ms=_elapsed_ms(started),
status="FAILED",
error_message=str(exc),
)
raise

self._record_stt_log(
req,
envelope,
latency_ms=_elapsed_ms(started),
status="SUCCESS",
error_message=None,
)
return result

raise AssertionError("도달 불가 — 마지막 시도는 항상 raise 한다")

async def _publish_callback(self, envelope, payload: VoiceCallbackPayload) -> None:
await self._publisher.publish(
routing_key=self._callback_routing_key,
Expand Down
2 changes: 2 additions & 0 deletions ai/src/ai_server/messaging/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,8 @@ def __init__(self, settings: Settings) -> None:
callback_routing_key=settings.ai_callback_routing_voice,
filler_pattern=settings.voice_filler_pattern,
core_client=core_client,
stt_max_attempts=settings.stt_max_attempts,
stt_retry_backoff_sec=settings.stt_retry_backoff_sec,
)

# 질문 TTS (Part A) — tts 인스턴스는 위에서 이미 생성됨.
Expand Down
16 changes: 12 additions & 4 deletions ai/src/ai_server/voice/stt/deepgram.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ def __init__(
base_url: str = "https://api.deepgram.com/v1",
model: str = "whisper-large",
language: str | None = "ko",
timeout_sec: float = 60.0,
timeout_sec: float = 30.0,
connect_timeout_sec: float = 5.0,
client: httpx.AsyncClient | None = None,
) -> None:
if not api_key:
Expand All @@ -39,7 +40,9 @@ def __init__(
self._base_url = base_url.rstrip("/")
self._model = model
self._language = language
self._timeout_sec = timeout_sec
# connect 를 따로 둔다 — 일괄 timeout 이면 연결이 막혔을 때도 read 한도만큼(운영 60초)
# 붙잡고 있다가 실패했다. 정상 호출은 p50 3.6초라 read 30초로도 여유가 크다.
self._timeout = httpx.Timeout(timeout_sec, connect=connect_timeout_sec)
self._client = client

@property
Expand Down Expand Up @@ -78,14 +81,19 @@ async def transcribe(
url, params=params, headers=headers, content=audio_bytes
)
else:
async with httpx.AsyncClient(timeout=self._timeout_sec) as client:
async with httpx.AsyncClient(timeout=self._timeout) as client:
resp = await client.post(
url, params=params, headers=headers, content=audio_bytes
)
except httpx.HTTPError as exc:
# 타임아웃 예외는 str() 이 빈 문자열이라 타입을 같이 남겨야 connect/read 를 구분한다.
detail = str(exc)
raise SttError(
code="STT_UNAVAILABLE",
message=f"Deepgram 호출 실패: {exc}",
message=(
f"Deepgram 호출 실패: {type(exc).__name__}"
+ (f": {detail}" if detail else "")
),
retriable=True,
) from exc

Expand Down
1 change: 1 addition & 0 deletions ai/src/ai_server/voice/stt/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def build_stt_provider(settings: Settings) -> SttProvider:
model=settings.deepgram_model,
language=settings.deepgram_language or None,
timeout_sec=settings.deepgram_timeout_sec,
connect_timeout_sec=settings.deepgram_connect_timeout_sec,
)

if provider == "mock":
Expand Down
28 changes: 28 additions & 0 deletions ai/tests/test_deepgram_stt.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,31 @@ async def test_deepgram_5xx_is_retriable():
await provider.transcribe(audio_bytes=b"x", content_type="audio/webm")
assert exc_info.value.code == "STT_UNAVAILABLE"
assert exc_info.value.retriable is True


@pytest.mark.asyncio
async def test_deepgram_timeout_keeps_exception_type_in_message():
"""타임아웃 예외는 str() 이 빈 문자열이다 — 타입을 안 남기면 로그가
'Deepgram 호출 실패: ' 로 끝나 connect/read 를 구분할 수 없었다."""

def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectTimeout("", request=request)

async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
provider = DeepgramSttProvider(api_key="k", client=client)
with pytest.raises(SttError) as exc_info:
await provider.transcribe(audio_bytes=b"x", content_type="audio/webm")

assert exc_info.value.code == "STT_UNAVAILABLE"
assert exc_info.value.retriable is True
assert "ConnectTimeout" in exc_info.value.message


def test_deepgram_connect_timeout_is_separate_from_read_timeout():
"""일괄 timeout 이면 연결이 막혔을 때도 read 한도만큼 붙잡고 있다가 실패한다."""
provider = DeepgramSttProvider(
api_key="k", timeout_sec=30.0, connect_timeout_sec=5.0
)
timeout = provider._timeout # noqa: SLF001 — 구성값 회귀 고정
assert timeout.connect == 5.0
assert timeout.read == 30.0
Loading
Loading