diff --git a/.env.example b/.env.example index 98835f9d..2aa3cab0 100644 --- a/.env.example +++ b/.env.example @@ -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 @@ -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 diff --git a/.github/workflows/deploy-app.yml b/.github/workflows/deploy-app.yml index 9784ddc1..09877566 100644 --- a/.github/workflows/deploy-app.yml +++ b/.github/workflows/deploy-app.yml @@ -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" @@ -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 diff --git a/ai/.env.example b/ai/.env.example index 3c668318..6e4e73ea 100644 --- a/ai/.env.example +++ b/ai/.env.example @@ -59,7 +59,7 @@ 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= @@ -67,20 +67,11 @@ 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 diff --git a/ai/CLAUDE.md b/ai/CLAUDE.md index 9ae395ab..a549dd26 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -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 는 제거해 무음/발음 메트릭이 실제 무음 반영. "감사합니다"·"뉴스 앱" 등 정상 표현은 보존. @@ -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" @@ -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) diff --git a/ai/src/ai_server/CLAUDE.md b/ai/src/ai_server/CLAUDE.md index 6bed8ce3..4cb0f1f0 100644 --- a/ai/src/ai_server/CLAUDE.md +++ b/ai/src/ai_server/CLAUDE.md @@ -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/` diff --git a/ai/src/ai_server/config/settings.py b/ai/src/ai_server/config/settings.py index edf148cf..9883c157 100644 --- a/ai/src/ai_server/config/settings.py +++ b/ai/src/ai_server/config/settings.py @@ -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. @@ -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" diff --git a/ai/src/ai_server/messaging/runner.py b/ai/src/ai_server/messaging/runner.py index eb4c51d8..2ada889a 100644 --- a/ai/src/ai_server/messaging/runner.py +++ b/ai/src/ai_server/messaging/runner.py @@ -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, ) @@ -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, ) diff --git a/ai/src/ai_server/voice/stt/factory.py b/ai/src/ai_server/voice/stt/factory.py index b10b0c4b..082125ad 100644 --- a/ai/src/ai_server/voice/stt/factory.py +++ b/ai/src/ai_server/voice/stt/factory.py @@ -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__) @@ -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: @@ -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() diff --git a/ai/src/ai_server/voice/stt/openai_whisper.py b/ai/src/ai_server/voice/stt/openai_whisper.py deleted file mode 100644 index e833b481..00000000 --- a/ai/src/ai_server/voice/stt/openai_whisper.py +++ /dev/null @@ -1,147 +0,0 @@ -from __future__ import annotations - -import httpx -import structlog - -from ai_server.voice.stt.base import ( - SttError, - TranscriptionResult, - TranscriptionSegment, -) -from ai_server.voice.stt.sanitize import sanitize_transcription - -log = structlog.get_logger(__name__) - - -class OpenAiWhisperSttProvider: - """OpenAI Whisper API (`/audio/transcriptions`) 호출. - - Mindlogic 게이트웨이가 STT 미지원이라 OpenAI 직접 호출. OPENAI_API_KEY 필요. - response_format=verbose_json 으로 segment timestamps 와 logprob 회수. - """ - - def __init__( - self, - *, - api_key: str, - base_url: str = "https://api.openai.com/v1", - model: str = "whisper-1", - language: str | None = "ko", - timeout_sec: float = 60.0, - client: httpx.AsyncClient | None = None, - ) -> None: - if not api_key: - raise ValueError("OpenAI API key 누락") - self._api_key = api_key - self._base_url = base_url.rstrip("/") - self._model = model - self._language = language - self._timeout_sec = timeout_sec - self._client = client - - @property - def model_name(self) -> str | None: - return self._model - - async def transcribe( - self, - *, - audio_bytes: bytes, - content_type: str, - hint: str | None = None, - ) -> TranscriptionResult: - url = f"{self._base_url}/audio/transcriptions" - filename = "audio." + _ext_for(content_type) - files = {"file": (filename, audio_bytes, content_type)} - data: dict[str, str] = { - "model": self._model, - "response_format": "verbose_json", - } - if self._language: - data["language"] = self._language - if hint: - data["prompt"] = hint[:200] - headers = {"Authorization": f"Bearer {self._api_key}"} - - try: - if self._client is not None: - resp = await self._client.post( - url, headers=headers, files=files, data=data - ) - else: - async with httpx.AsyncClient(timeout=self._timeout_sec) as client: - resp = await client.post( - url, headers=headers, files=files, data=data - ) - except httpx.HTTPError as exc: - raise SttError( - code="STT_UNAVAILABLE", - message=f"OpenAI 호출 실패: {exc}", - retriable=True, - ) from exc - - if resp.status_code in (401, 403): - raise SttError( - code="STT_AUTH_FAILED", - message=f"OpenAI 인증 실패: {resp.status_code}", - retriable=False, - ) - if resp.status_code >= 500: - raise SttError( - code="STT_UNAVAILABLE", - message=f"OpenAI 5xx: {resp.status_code}", - retriable=True, - ) - if resp.status_code >= 400: - raise SttError( - code="STT_BAD_REQUEST", - message=f"OpenAI {resp.status_code}: {resp.text[:200]}", - retriable=False, - ) - - try: - data_resp = resp.json() - except ValueError as exc: - raise SttError( - code="STT_BAD_RESPONSE", - message=f"JSON 파싱 실패: {exc}", - retriable=True, - ) from exc - - segments = [] - for seg in data_resp.get("segments") or []: - segments.append( - TranscriptionSegment( - start_sec=float(seg.get("start", 0.0)), - end_sec=float(seg.get("end", 0.0)), - text=str(seg.get("text", "")), - avg_logprob=( - float(seg["avg_logprob"]) if "avg_logprob" in seg else None - ), - ) - ) - return sanitize_transcription( - TranscriptionResult( - text=str(data_resp.get("text", "")), - language=data_resp.get("language"), - duration_sec=( - float(data_resp["duration"]) if "duration" in data_resp else None - ), - segments=segments, - ) - ) - - -def _ext_for(content_type: str) -> str: - ct = (content_type or "").lower() - if "webm" in ct: - return "webm" - if "ogg" in ct: - return "ogg" - if "mpeg" in ct or "mp3" in ct: - return "mp3" - if "wav" in ct: - return "wav" - if "mp4" in ct or "m4a" in ct: - return "m4a" - return "bin" diff --git a/ai/src/ai_server/voice/tts/factory.py b/ai/src/ai_server/voice/tts/factory.py index 2e5d1c2d..1de921f1 100644 --- a/ai/src/ai_server/voice/tts/factory.py +++ b/ai/src/ai_server/voice/tts/factory.py @@ -7,13 +7,12 @@ from ai_server.voice.tts.gateway import GatewayTtsProvider from ai_server.voice.tts.gemini import GeminiTtsProvider from ai_server.voice.tts.mock import MockTtsProvider -from ai_server.voice.tts.openai_tts import OpenAiTtsProvider log = structlog.get_logger(__name__) def build_tts_provider(settings: Settings) -> TtsProvider: - """TTS 공급자 선택. auto → gateway(충남대 키) > gemini(직접 키) > openai, 없으면 mock. + """TTS 공급자 선택. auto → gateway(충남대 키) > gemini(직접 키), 없으면 mock. gateway 를 우선해 직접 GEMINI_API_KEY 의 429 부하를 던다(게이트웨이가 Gemini TTS 로 라우팅). """ @@ -24,8 +23,6 @@ def build_tts_provider(settings: Settings) -> TtsProvider: provider = "gateway" elif settings.gemini_api_key: provider = "gemini" - elif settings.openai_api_key: - provider = "openai" else: provider = "mock" @@ -53,17 +50,6 @@ def build_tts_provider(settings: Settings) -> TtsProvider: timeout_sec=settings.gemini_tts_timeout_sec, ) - if provider == "openai": - if not settings.openai_api_key: - log.warn("tts.fallback_to_mock", reason="OPENAI_API_KEY 누락") - return MockTtsProvider() - return OpenAiTtsProvider( - api_key=settings.openai_api_key, - base_url=settings.openai_base_url, - model=settings.openai_tts_model, - timeout_sec=settings.openai_tts_timeout_sec, - ) - if provider == "mock": return MockTtsProvider() diff --git a/ai/src/ai_server/voice/tts/openai_tts.py b/ai/src/ai_server/voice/tts/openai_tts.py deleted file mode 100644 index 36d2e07f..00000000 --- a/ai/src/ai_server/voice/tts/openai_tts.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations - -import httpx -import structlog - -from ai_server.voice.tts.base import TtsError, TtsProvider, TtsResult - -log = structlog.get_logger(__name__) - - -class OpenAiTtsProvider(TtsProvider): - """OpenAI 오디오 합성 (POST /audio/speech). mp3 반환.""" - - def __init__( - self, - *, - api_key: str, - base_url: str, - model: str, - timeout_sec: float, - ) -> None: - self._api_key = api_key - self._base_url = base_url.rstrip("/") - self.model_name = model - self._timeout = timeout_sec - - async def synthesize(self, text: str, *, voice: str) -> TtsResult: - url = f"{self._base_url}/audio/speech" - body = { - "model": self.model_name, - "voice": voice, - "input": text, - "response_format": "mp3", - } - headers = {"Authorization": f"Bearer {self._api_key}"} - try: - async with httpx.AsyncClient(timeout=self._timeout) as client: - resp = await client.post(url, json=body, headers=headers) - except httpx.HTTPError as exc: - raise TtsError("TTS_HTTP_ERROR", str(exc)) from exc - if resp.status_code != 200: - raise TtsError( - "TTS_API_ERROR", - f"openai tts status {resp.status_code}: {resp.text[:200]}", - ) - audio = resp.content - if not audio: - raise TtsError("TTS_EMPTY_AUDIO", "empty audio body") - # OpenAI 는 duration 을 주지 않음 → None (Core completeTts 가 null 허용). - return TtsResult( - audio_bytes=audio, duration_sec=None, content_type="audio/mpeg" - ) diff --git a/ai/tests/test_tts_factory.py b/ai/tests/test_tts_factory.py index a84a6255..8cd668de 100644 --- a/ai/tests/test_tts_factory.py +++ b/ai/tests/test_tts_factory.py @@ -3,7 +3,6 @@ from ai_server.voice.tts.gateway import GatewayTtsProvider from ai_server.voice.tts.gemini import GeminiTtsProvider from ai_server.voice.tts.mock import MockTtsProvider -from ai_server.voice.tts.openai_tts import OpenAiTtsProvider def _settings(**over): @@ -27,25 +26,18 @@ def test_auto_prefers_gateway_when_llm_key_present(): def test_auto_falls_back_to_mock_without_key(): - s = _settings(tts_provider="auto", openai_api_key="", gemini_api_key="") + s = _settings(tts_provider="auto", gemini_api_key="") assert isinstance(build_tts_provider(s), MockTtsProvider) -def test_auto_picks_openai_with_key(): - s = _settings(tts_provider="auto", openai_api_key="sk-test", gemini_api_key="") - assert isinstance(build_tts_provider(s), OpenAiTtsProvider) - - def test_explicit_mock(): - s = _settings(tts_provider="mock", openai_api_key="sk-test") + s = _settings(tts_provider="mock", gemini_api_key="g-test") assert isinstance(build_tts_provider(s), MockTtsProvider) -def test_auto_prefers_gemini_when_key_present(): - # Deepgram/OpenAI TTS 는 한국어 미지원 → gemini 키가 있으면 gemini 우선. - s = _settings( - tts_provider="auto", gemini_api_key="g-test", openai_api_key="sk-test" - ) +def test_auto_picks_gemini_without_gateway_key(): + # 게이트웨이 키가 없으면 직접 Gemini 키로. + s = _settings(tts_provider="auto", gemini_api_key="g-test") assert isinstance(build_tts_provider(s), GeminiTtsProvider) diff --git a/docker-compose.yml b/docker-compose.yml index d842a1db..ae149bb4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -90,15 +90,14 @@ services: LLM_FLASH_MODEL: ${LLM_FLASH_MODEL:-gemini-3.5-flash-lite} LLM_FLASH_TEMPERATURE: ${LLM_FLASH_TEMPERATURE:-0.4} LLM_FLASH_MAX_TOKENS: ${LLM_FLASH_MAX_TOKENS:-512} + LLM_PRO_TIMEOUT_SEC: ${LLM_PRO_TIMEOUT_SEC:-30.0} + LLM_FLASH_TIMEOUT_SEC: ${LLM_FLASH_TIMEOUT_SEC:-10.0} + QUESTIONS_RAG_TIMEOUT_SEC: ${QUESTIONS_RAG_TIMEOUT_SEC:-1.5} STT_PROVIDER: ${STT_PROVIDER:-auto} DEEPGRAM_API_KEY: ${DEEPGRAM_API_KEY:-} DEEPGRAM_BASE_URL: ${DEEPGRAM_BASE_URL:-https://api.deepgram.com/v1} DEEPGRAM_MODEL: ${DEEPGRAM_MODEL:-whisper-large} DEEPGRAM_LANGUAGE: ${DEEPGRAM_LANGUAGE:-ko} - OPENAI_API_KEY: ${OPENAI_API_KEY:-} - OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1} - WHISPER_MODEL: ${WHISPER_MODEL:-whisper-1} - WHISPER_LANGUAGE: ${WHISPER_LANGUAGE:-ko} # AI → Core 내부 API. 같은 compose 네트워크의 backend 서비스로 직접 라우팅. CORE_INTERNAL_BASE_URL: ${CORE_INTERNAL_BASE_URL:-http://backend:38010} CORE_INTERNAL_API_KEY: ${CORE_INTERNAL_API_KEY:-local-development-internal-api-key} diff --git a/docs/environment.md b/docs/environment.md index 459e5c5a..de2cf8ac 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -87,8 +87,6 @@ GOOGLE_OAUTH_CLIENT_ID= GOOGLE_OAUTH_CLIENT_SECRET= GOOGLE_OAUTH_REDIRECT_URI=http://localhost:5173/auth/google/callback -# ===== AI Server ===== -AI_SERVER_BASE_URL=http://ai:8000 ``` --- @@ -97,8 +95,7 @@ AI_SERVER_BASE_URL=http://ai:8000 ```dotenv # ===== App ===== -AI_SERVER_PORT=8000 -AI_LOG_LEVEL=INFO +DEBUG=false # 호스트 포트는 루트 .env 의 AI_PORT (docker-compose) # ===== LLM (Mindlogic CNU AC Gateway 단일 경로) ===== LLM_API_KEY= # 학교 발급 키 @@ -112,9 +109,8 @@ LLM_FLASH_MAX_TOKENS=512 LLM_FLASH_TIMEOUT_SEC=10.0 # Pro 보다 짧게 — 꼬리질문 저지연(<3s) 요구사항 QUESTIONS_RAG_TIMEOUT_SEC=1.5 # 질문 풀 생성 시 다문서 RAG 검색 상한. followup 과 대칭 -# (외부 직접 호출용, fallback) +# (외부 직접 호출용, fallback — 임베딩·TTS 폴백) GEMINI_API_KEY= -OPENAI_API_KEY= # Whisper STT에도 사용 # ===== Object Storage ===== STORAGE_BACKEND=s3 # s3 | local @@ -160,12 +156,16 @@ ANALYZED_WEB_RESUME_MD_KEY_TEMPLATE=analyzed/web-resume/{resume_id}/summary.md FEEDBACK_REPORT_MD_KEY_TEMPLATE=feedback/{session_id}/report.md # ===== STT/TTS (Phase 2) ===== -STT_PROVIDER=whisper-api # whisper-api | whisper-self-hosted -WHISPER_MODEL=whisper-1 # OpenAI Whisper API 모델 -TTS_PROVIDER=auto # auto | mock | openai | gemini | gateway (auto=LLM_API_KEY(gateway) > GEMINI_API_KEY > OPENAI_API_KEY) -OPENAI_TTS_MODEL=gpt-4o-mini-tts # OpenAI TTS 모델 (질문 음성화) -OPENAI_TTS_VOICE=alloy # TTS 보이스 -OPENAI_TTS_TIMEOUT_SEC=30 # TTS HTTP 타임아웃(초) +STT_PROVIDER=auto # auto | mock | deepgram (auto=DEEPGRAM_API_KEY 보유 시 deepgram, 없으면 mock) +DEEPGRAM_BASE_URL=https://api.deepgram.com/v1 +DEEPGRAM_MODEL=whisper-large # 한국어 정확도 우선; 저비용 우선 시 nova-2 +DEEPGRAM_LANGUAGE=ko +DEEPGRAM_TIMEOUT_SEC=60 +TTS_PROVIDER=auto # auto | mock | gemini | gateway (auto=LLM_API_KEY(gateway) > GEMINI_API_KEY, 없으면 mock) +GEMINI_TTS_MODEL=gemini-2.5-flash-preview-tts +GEMINI_TTS_VOICE=Kore +GEMINI_TTS_BASE_URL=https://generativelanguage.googleapis.com/v1beta +GEMINI_TTS_TIMEOUT_SEC=30 # ===== 실시간 스트리밍 STT (RT3 음성 답변, Phase 2) ===== LIVE_STT_PROVIDER=auto # auto | mock | deepgram_live (auto=DEEPGRAM_API_KEY 보유 시 deepgram_live, 없으면 mock) @@ -186,8 +186,7 @@ Vite 환경변수는 `VITE_` 접두 필수 (런타임에 노출됨). ```dotenv VITE_API_BASE_URL=http://localhost:8080 VITE_SSE_BASE_URL=http://localhost:8080 -VITE_GITHUB_OAUTH_CLIENT_ID= # public OK -VITE_SENTRY_DSN= # 옵션 +VITE_REALTIME_BASE_URL=http://localhost:38020 ``` > 비밀값(API key 등)은 절대 `VITE_*`에 두지 않는다. 빌드 결과물에 평문으로 들어간다. diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index b259e563..9f85c5a0 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -255,7 +255,7 @@ SEED 팔레트 블록에는 `prefers-color-scheme` 미디어쿼리가 없어서, export const env = { API_BASE_URL: import.meta.env.VITE_API_BASE_URL, SSE_BASE_URL: import.meta.env.VITE_SSE_BASE_URL, - GITHUB_OAUTH_CLIENT_ID: import.meta.env.VITE_GITHUB_OAUTH_CLIENT_ID, + REALTIME_BASE_URL: import.meta.env.VITE_REALTIME_BASE_URL, } as const; ``` diff --git a/frontend/src/shared/CLAUDE.md b/frontend/src/shared/CLAUDE.md index 08fc350d..3d496d7c 100644 --- a/frontend/src/shared/CLAUDE.md +++ b/frontend/src/shared/CLAUDE.md @@ -150,7 +150,7 @@ Phase 1은 한국어 only. Phase 2 영어 추가 대비해서 키 기반 운영. export const env = { API_BASE_URL: import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080', SSE_BASE_URL: import.meta.env.VITE_SSE_BASE_URL ?? 'http://localhost:8080', - GITHUB_OAUTH_CLIENT_ID: import.meta.env.VITE_GITHUB_OAUTH_CLIENT_ID ?? '', + REALTIME_BASE_URL: import.meta.env.VITE_REALTIME_BASE_URL ?? 'http://localhost:38020', } as const; ```