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
8 changes: 1 addition & 7 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,11 @@ LLM_FLASH_MAX_TOKENS=512
LLM_FLASH_TIMEOUT_SEC=10.0
QUESTIONS_RAG_TIMEOUT_SEC=1.5

# 음성 STT (Phase 2). auto=DEEPGRAM_API_KEY 있으면 deepgram, 없고 OPENAI_API_KEY 있으면 openai_whisper, 둘 다 없으면 mock.
# 음성 STT (Phase 2). auto=DEEPGRAM_API_KEY 있으면 deepgram, 없으면 mock.
STT_PROVIDER=auto
DEEPGRAM_API_KEY=
DEEPGRAM_MODEL=whisper-large
DEEPGRAM_LANGUAGE=ko
OPENAI_API_KEY=
WHISPER_MODEL=whisper-1
WHISPER_LANGUAGE=ko

# RealTime server
REALTIME_PORT=38020
Expand Down Expand Up @@ -69,6 +66,3 @@ GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
GOOGLE_OAUTH_REDIRECT_URI=http://localhost:5173/auth/google/callback
CORS_ALLOWED_ORIGINS=*

# NGINX
FRONTEND_PORT=38000
8 changes: 3 additions & 5 deletions .github/workflows/deploy-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ jobs:
GOOGLE_OAUTH_CLIENT_ID: ${{ secrets.GOOGLE_OAUTH_CLIENT_ID }}
GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.GOOGLE_OAUTH_CLIENT_SECRET }}
GOOGLE_OAUTH_REDIRECT_URI: ${{ secrets.GOOGLE_OAUTH_REDIRECT_URI }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
run: |
cd "$DEPLOY_DIR"
Expand Down Expand Up @@ -117,11 +116,10 @@ jobs:
upsert_env "GITHUB_OAUTH_CLIENT_ID" "$GITHUB_OAUTH_CLIENT_ID"
upsert_env "GITHUB_OAUTH_CLIENT_SECRET" "$GITHUB_OAUTH_CLIENT_SECRET"
upsert_env "GITHUB_OAUTH_REDIRECT_URI" "https://stack-up.shop/auth/callback"
upsert_env "OPENAI_API_KEY" "$OPENAI_API_KEY"
upsert_env "DEEPGRAM_API_KEY" "$DEEPGRAM_API_KEY"
# STT_PROVIDER 자동 선택. auto 로 두면 AI factory 가 보유 키 기준으로 선택 (deepgram > openai_whisper > mock).
# 키가 1개라도 있으면 auto 로 설정해두면 충분 — 명시적 전환 불필요.
if [ -n "$DEEPGRAM_API_KEY" ] || [ -n "$OPENAI_API_KEY" ]; then
# STT_PROVIDER 자동 선택. auto 로 두면 AI factory 가 보유 키 기준으로 선택 (deepgram > mock).
# 키가 있으면 auto 로 설정해두면 충분 — 명시적 전환 불필요.
if [ -n "$DEEPGRAM_API_KEY" ]; then
upsert_env "STT_PROVIDER" "auto"
fi

Expand Down
15 changes: 3 additions & 12 deletions ai/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -59,28 +59,19 @@ EMBEDDING_RETRY_BASE_DELAY_SEC=2.0 # 재시도 기본 지연(초). delay=base*

GEMINI_API_KEY=

# 음성 STT (Phase 2). auto=키 보유에 따라 deepgram > openai_whisper > mock 자동 선택.
# 음성 STT (Phase 2). auto=DEEPGRAM_API_KEY 보유 시 deepgram, 없으면 mock.
STT_PROVIDER=auto
# Deepgram (한국어 정확도 우선 권장. 신규 가입 시 $200 크레딧).
DEEPGRAM_API_KEY=
DEEPGRAM_BASE_URL=https://api.deepgram.com/v1
DEEPGRAM_MODEL=whisper-large
DEEPGRAM_LANGUAGE=ko
DEEPGRAM_TIMEOUT_SEC=60
# OpenAI Whisper (Mindlogic 미지원, 직접 호출). OPENAI_API_KEY/OPENAI_BASE_URL 은 TTS 와 공용.
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.openai.com/v1
WHISPER_MODEL=whisper-1
WHISPER_LANGUAGE=ko
WHISPER_TIMEOUT_SEC=60

# TTS (질문 음성화). auto=LLM_API_KEY(gateway) > GEMINI_API_KEY > OPENAI_API_KEY 순, 없으면 mock.
# TTS (질문 음성화). auto=LLM_API_KEY(gateway) > GEMINI_API_KEY 순, 없으면 mock.
# gateway: Mindlogic 게이트웨이(/audio/speech)가 Gemini TTS 로 라우팅 → 직접 GEMINI_API_KEY 429 부하 분산.
# Deepgram/OpenAI TTS 는 한국어 미지원. gateway/gemini 모두 GEMINI_TTS_MODEL/VOICE 공유, raw PCM→WAV.
# Deepgram/OpenAI TTS 는 한국어 미지원이라 제공자로 두지 않는다. gateway/gemini 모두 GEMINI_TTS_MODEL/VOICE 공유, raw PCM→WAV.
TTS_PROVIDER=auto
OPENAI_TTS_MODEL=gpt-4o-mini-tts
OPENAI_TTS_VOICE=alloy
OPENAI_TTS_TIMEOUT_SEC=30
GEMINI_TTS_MODEL=gemini-2.5-flash-preview-tts
GEMINI_TTS_VOICE=Kore
GEMINI_TTS_BASE_URL=https://generativelanguage.googleapis.com/v1beta
Expand Down
8 changes: 4 additions & 4 deletions ai/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ chain = prompt | llm | PydanticOutputParser(pydantic_object=...)
- 비용: $0.006 / 분 (1시간 면접 ≈ ₩500 / USD ≈ $0.36)
- 셀프호스팅 옵션: `whisper.cpp` 또는 `faster-whisper` (GPU 권장, 비용 ↓ but 운영 부담 ↑)
- 브라우저 내장 SpeechRecognition API는 정확도 부족으로 채택 안 함
- **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)/`OpenAiTtsProvider`(`gpt-4o-mini-tts`, mp3)/`MockTtsProvider`, `build_tts_provider` factory(`TTS_PROVIDER=auto`면 **LLM_API_KEY(gateway) > GEMINI_API_KEY > OPENAI_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 이 내부 호스트라 브라우저 직접 접근 불가).
- **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/sanitize.py`): Whisper/Deepgram 이 발화 끝 무음·잡음에서 학습데이터(방송/유튜브) 정형 문구를 환각으로 덧붙이는 문제(예: "MBC 뉴스 OOO입니다", "시청해주셔서 감사합니다", "구독과 좋아요", 영어 "thanks for watching")를 보수적으로 제거. 배치(`deepgram`/`openai_whisper`)는 `transcribe` 반환 시, 라이브(`deepgram_live`)는 **최종 자막마다** + `result()` 백스톱에서 적용 → 저장 전사·메트릭·실시간 표시 모두 정화. 환각만 남은 segment 는 제거해 무음/발음 메트릭이 실제 무음 반영. "감사합니다"·"뉴스 앱" 등 정상 표현은 보존.
Expand All @@ -220,8 +220,8 @@ class Settings(BaseSettings):
s3_access_key: str
s3_secret_key: str
s3_bucket_name: str
openai_api_key: str = ""
google_api_key: str = ""
llm_api_key: str = ""
gemini_api_key: str = ""
llm_pro_model: str = "gemini-3.1-pro-preview"
llm_flash_model: str = "gemini-3.5-flash-lite"
embedding_model: str = "gemini-embedding-001"
Expand Down Expand Up @@ -383,7 +383,7 @@ docker run --env-file .env -p 8000:8000 stackup-ai
- **LLM 호출 로깅 본 구현** (`observability/llm_logging_callback.py`, US-30):
LangChain `AsyncCallbackHandler` 가 토큰/latency 측정 → Core `/api/internal/ai-logs` POST.
- **질문 TTS consumer 본 구현** (`messaging/consumers/tts_consumer.py`, `voice/tts/`):
`generate.tts` 수신 → OpenAI TTS 합성(`OpenAiTtsProvider`, mock fallback) → S3 PUT(`interview/tts/{sessionId}/{messageId}.mp3`) → `callback.tts` 발행.
`generate.tts` 수신 → TTS 합성(`GatewayTtsProvider`/`GeminiTtsProvider`, mock fallback) → S3 PUT(`interview/tts/{sessionId}/{messageId}.*`, 확장자는 content_type 기준) → `callback.tts` 발행.
- **실시간 스트리밍 음성 답변 본 구현** (RT3, `api/voice_stream.py`, `voice/stt/{live,mock_live,deepgram_live,live_factory}.py`):
FastAPI WS `/internal/voice/stream` 수신(RealTime 프록시 경유) → Deepgram Live(`deepgram_live.py`, mock fallback)로 부분/최종 자막 다운 → 발화 종료 시 메트릭 계산 후 `callback.voice` 발행. `VoiceCallbackService`/followup 무변경 재사용. 신규 의존성 `websockets`.
- 배치 음성 분석(STT/WPM/filler) 모듈은 `voice/stt/whisper_api.py`(+ Deepgram) + `voice/analysis/metrics.py`로 본 구현(`analyze.voice` consumer)
Expand Down
4 changes: 2 additions & 2 deletions ai/src/ai_server/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,8 @@ async def consume(message: AbstractIncomingMessage) -> None:
[`/docs/messaging.md §10`](../../../docs/messaging.md))

### `voice/`
- `voice/stt/` — interface + provider impls (배치 Whisper/Deepgram + 라이브 Deepgram Live)
- `voice/tts/` — provider 추상화 (Gateway/Gemini/OpenAI/Mock)
- `voice/stt/` — interface + provider impls (배치 Deepgram + 라이브 Deepgram Live, 키 없으면 Mock)
- `voice/tts/` — provider 추상화 (Gateway/Gemini/Mock)
- `voice/analysis/` — WPM, filler, silence (`metrics.py`)

### `storage/`
Expand Down
18 changes: 5 additions & 13 deletions ai/src/ai_server/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,8 @@ class Settings(BaseSettings):
# 후속 작업에서 풀 저장 도입 시 늘리기 (예: 5).
questions_initial_pool_size: int = 1

# STT (음성 답변). "auto" 면 deepgram > openai_whisper > mock 순으로 키 보유 여부에 따라 자동 선택.
stt_provider: Literal["auto", "mock", "openai_whisper", "deepgram"] = "auto"
openai_api_key: str = ""
openai_base_url: str = "https://api.openai.com/v1"
whisper_model: str = "whisper-1"
whisper_language: str = "ko"
whisper_timeout_sec: float = 60.0
# STT (음성 답변). "auto" 면 DEEPGRAM_API_KEY 보유 시 deepgram, 없으면 mock.
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.
Expand All @@ -74,13 +69,10 @@ class Settings(BaseSettings):
deepgram_live_endpointing_ms: int = 800 # 무음 800ms → utterance end
voice_stream_internal_path: str = "/internal/voice/stream"

# TTS (질문 음성화). "auto" 면 gateway(충남대 키) > gemini(직접 키) > openai, 없으면 mock.
# TTS (질문 음성화). "auto" 면 gateway(충남대 키) > gemini(직접 키), 없으면 mock.
# gateway 는 Mindlogic 게이트웨이(/audio/speech)가 Gemini TTS 로 라우팅 — 직접 키 429 부하 분산.
# Deepgram/OpenAI TTS 는 한국어 미지원. gateway/gemini 모두 gemini_tts_model/voice 를 공유한다.
tts_provider: Literal["auto", "mock", "openai", "gemini", "gateway"] = "auto"
openai_tts_model: str = "gpt-4o-mini-tts"
openai_tts_voice: str = "alloy"
openai_tts_timeout_sec: float = 30.0
# Deepgram/OpenAI TTS 는 한국어 미지원이라 제공자로 두지 않는다. gateway/gemini 모두 gemini_tts_model/voice 를 공유한다.
tts_provider: Literal["auto", "mock", "gemini", "gateway"] = "auto"
# Gemini TTS — 한국어 지원. raw PCM(L16) 반환 → 저장 시 WAV 로 감싼다.
gemini_tts_model: str = "gemini-2.5-flash-preview-tts"
gemini_tts_voice: str = "Kore"
Expand Down
4 changes: 2 additions & 2 deletions ai/src/ai_server/messaging/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ def __init__(self, settings: Settings) -> None:
session_notifier=session_notifier,
tts=tts,
storage=storage,
tts_voice=settings.openai_tts_voice,
tts_voice=settings.gemini_tts_voice,
rag_timeout_sec=settings.followup_rag_timeout_sec,
)

Expand Down Expand Up @@ -303,7 +303,7 @@ def __init__(self, settings: Settings) -> None:
publisher=self._publisher,
idempotency=self._idempotency,
callback_routing_key=settings.ai_callback_routing_tts,
voice=settings.openai_tts_voice,
voice=settings.gemini_tts_voice,
key_template=settings.tts_audio_key_template,
session_notifier=session_notifier,
)
Expand Down
22 changes: 2 additions & 20 deletions ai/src/ai_server/voice/stt/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from ai_server.voice.stt.base import SttProvider
from ai_server.voice.stt.deepgram import DeepgramSttProvider
from ai_server.voice.stt.mock import MockSttProvider
from ai_server.voice.stt.openai_whisper import OpenAiWhisperSttProvider

log = structlog.get_logger(__name__)

Expand All @@ -16,19 +15,14 @@ def build_stt_provider(settings: Settings) -> SttProvider:

명시적 우선순위:
1. STT_PROVIDER 가 명시되면 그 값을 따름.
2. "auto" 인 경우 DEEPGRAM_API_KEY → OPENAI_API_KEY → mock 순.
2. "auto" 인 경우 DEEPGRAM_API_KEY 보유 시 deepgram, 없으면 mock.
3. 키 누락 시에는 안전한 mock fallback (서비스 부팅은 항상 성공).
"""

provider = (settings.stt_provider or "auto").lower()

if provider == "auto":
if settings.deepgram_api_key:
provider = "deepgram"
elif settings.openai_api_key:
provider = "openai_whisper"
else:
provider = "mock"
provider = "deepgram" if settings.deepgram_api_key else "mock"

if provider == "deepgram":
if not settings.deepgram_api_key:
Expand All @@ -42,18 +36,6 @@ def build_stt_provider(settings: Settings) -> SttProvider:
timeout_sec=settings.deepgram_timeout_sec,
)

if provider == "openai_whisper":
if not settings.openai_api_key:
log.warn("stt.fallback_to_mock", reason="OPENAI_API_KEY 누락")
return MockSttProvider()
return OpenAiWhisperSttProvider(
api_key=settings.openai_api_key,
base_url=settings.openai_base_url,
model=settings.whisper_model,
language=settings.whisper_language or None,
timeout_sec=settings.whisper_timeout_sec,
)

if provider == "mock":
return MockSttProvider()

Expand Down
147 changes: 0 additions & 147 deletions ai/src/ai_server/voice/stt/openai_whisper.py

This file was deleted.

Loading
Loading