Skip to content

Commit 53ca53b

Browse files
authored
Merge pull request #57 from Team-StackUp/feature/question-tts
feat(backend): generate.tts 큐·라우팅키 설정 및 declare
2 parents dc5ae10 + 321f090 commit 53ca53b

32 files changed

Lines changed: 669 additions & 6 deletions

ai/.env.example

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,15 @@ DEEPGRAM_BASE_URL=https://api.deepgram.com/v1
5656
DEEPGRAM_MODEL=whisper-large
5757
DEEPGRAM_LANGUAGE=ko
5858
DEEPGRAM_TIMEOUT_SEC=60
59-
# OpenAI Whisper (Mindlogic 미지원, 직접 호출).
59+
# OpenAI Whisper (Mindlogic 미지원, 직접 호출). OPENAI_API_KEY/OPENAI_BASE_URL 은 TTS 와 공용.
6060
OPENAI_API_KEY=
6161
OPENAI_BASE_URL=https://api.openai.com/v1
6262
WHISPER_MODEL=whisper-1
6363
WHISPER_LANGUAGE=ko
6464
WHISPER_TIMEOUT_SEC=60
65+
66+
# TTS (질문 음성화). auto=OPENAI_API_KEY 보유 시 openai, 없으면 mock. (OPENAI_API_KEY 재사용)
67+
TTS_PROVIDER=auto
68+
OPENAI_TTS_MODEL=gpt-4o-mini-tts
69+
OPENAI_TTS_VOICE=alloy
70+
OPENAI_TTS_TIMEOUT_SEC=30

ai/CLAUDE.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ ai/
9595
| `ai.analyze.web` | `analyze.web` | 본 구현 (URL → trafilatura) |
9696
| `ai.generate.questions` | `generate.questions` | 본 구현 (Pro 모델, 질문 풀 생성, US-18) |
9797
| `ai.generate.followup` | `generate.followup` | 본 구현 (Flash 모델, 답변 평가+꼬리질문, US-19) |
98+
| `ai.generate.tts` | `generate.tts` | 본 구현 (질문 음성화, OpenAI TTS → S3 → `callback.tts`) |
9899

99100
콜백 발행: `ai.callback.{type}` 익스체인지.
100101
상세 envelope/스키마/재시도: [`/docs/messaging.md`](../docs/messaging.md).
@@ -188,8 +189,8 @@ chain = prompt | llm | PydanticOutputParser(pydantic_object=...)
188189
- 비용: $0.006 / 분 (1시간 면접 ≈ ₩500 / USD ≈ $0.36)
189190
- 셀프호스팅 옵션: `whisper.cpp` 또는 `faster-whisper` (GPU 권장, 비용 ↓ but 운영 부담 ↑)
190191
- 브라우저 내장 SpeechRecognition API는 정확도 부족으로 채택 안 함
191-
- TTS 제공자 미정 → 도입 시 본 섹션 갱신
192-
- 추상화 계층 두기: `voice/stt/base.py` (interface), `voice/stt/whisper_api.py`, `voice/tts/{provider}.py`
192+
- **TTS: OpenAI TTS 채택** (`voice/tts/`) — 질문(INTERVIEWER) 메시지 음성화. `TtsProvider` 추상화 + `OpenAiTtsProvider`(`gpt-4o-mini-tts`, mp3)/`MockTtsProvider`, `build_tts_provider` factory(`TTS_PROVIDER=auto`면 OPENAI_API_KEY 보유 시 openai). `generate.tts` consumer 가 합성 → S3 PUT → `callback.tts` 발행.
193+
- 추상화 계층 두기: `voice/stt/base.py` (interface), `voice/stt/whisper_api.py`, `voice/tts/base.py` + `voice/tts/{provider}.py`
193194
- 분석:
194195
- WPM = words / minutes
195196
- 간투어: 한국어 정규식 `r"\b(음+|어+|그+)\b"` 카운트
@@ -324,6 +325,8 @@ docker run --env-file .env -p 8000:8000 stackup-ai
324325
- **스토리지 추상화** (`storage/`): `S3Storage`(기본) / `LocalFilesystemStorage`. `STORAGE_BACKEND` 토글.
325326
- **LLM 호출 로깅 본 구현** (`observability/llm_logging_callback.py`, US-30):
326327
LangChain `AsyncCallbackHandler` 가 토큰/latency 측정 → Core `/api/internal/ai-logs` POST.
327-
- 음성 모듈은 Phase 2
328+
- **질문 TTS consumer 본 구현** (`messaging/consumers/tts_consumer.py`, `voice/tts/`):
329+
`generate.tts` 수신 → OpenAI TTS 합성(`OpenAiTtsProvider`, mock fallback) → S3 PUT(`interview/tts/{sessionId}/{messageId}.mp3`) → `callback.tts` 발행.
330+
- 음성 분석(STT/WPM/filler) 모듈은 Phase 2
328331

329332
각 도입 시 본 문서 갱신.

ai/src/ai_server/config/settings.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,14 @@ class Settings(BaseSettings):
2626
ai_queue_followup: str = "ai.generate.followup"
2727
ai_queue_feedback: str = "ai.generate.feedback"
2828
ai_queue_voice: str = "ai.analyze.voice"
29+
ai_queue_tts: str = "ai.generate.tts"
2930
ai_queue_prefetch: int = 10
3031
ai_callback_exchange: str = "stackup.ai-to-core"
3132
ai_callback_routing_analysis: str = "callback.analysis"
3233
ai_callback_routing_questions: str = "callback.questions"
3334
ai_callback_routing_feedback: str = "callback.feedback"
3435
ai_callback_routing_voice: str = "callback.voice"
36+
ai_callback_routing_tts: str = "callback.tts"
3537
# AI -> RealTime 직접 발행 (분석 단계 진행 상황). Core 를 거치지 않는 휘발성 알림.
3638
ai_realtime_exchange: str = "stackup.realtime"
3739
ai_realtime_routing_user: str = "realtime.user.notify"
@@ -52,6 +54,13 @@ class Settings(BaseSettings):
5254
deepgram_model: str = "whisper-large" # 한국어 정확도 우선; 저비용 우선 시 nova-2.
5355
deepgram_language: str = "ko"
5456
deepgram_timeout_sec: float = 60.0
57+
58+
# TTS (질문 음성화). "auto" 면 openai 키 보유 시 openai, 없으면 mock.
59+
tts_provider: Literal["auto", "mock", "openai"] = "auto"
60+
openai_tts_model: str = "gpt-4o-mini-tts"
61+
openai_tts_voice: str = "alloy"
62+
openai_tts_timeout_sec: float = 30.0
63+
tts_audio_key_template: str = "interview/tts/{session_id}/{message_id}.mp3"
5564
# 음성 분석
5665
voice_filler_pattern: str = r"(?:음+|어+|그+|아+)"
5766
ai_publisher_name: str = "ai-server"
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
from __future__ import annotations
2+
3+
import structlog
4+
from aio_pika.abc import AbstractIncomingMessage
5+
6+
from ai_server.messaging.idempotency import LruIdempotencyStore
7+
from ai_server.messaging.publisher import CallbackPublisher
8+
from ai_server.model.envelope import Envelope
9+
from ai_server.model.messages.tts import GenerateTtsRequest, TtsCallbackPayload
10+
from ai_server.storage.base import ObjectStorage
11+
from ai_server.voice.tts.base import TtsError, TtsProvider
12+
13+
log = structlog.get_logger(__name__)
14+
15+
16+
class TtsConsumer:
17+
"""generate.tts consumer — 질문 텍스트를 TTS 합성 → S3 PUT → callback.tts 발행."""
18+
19+
def __init__(
20+
self,
21+
*,
22+
tts: TtsProvider,
23+
storage: ObjectStorage,
24+
publisher: CallbackPublisher,
25+
idempotency: LruIdempotencyStore,
26+
callback_routing_key: str,
27+
voice: str,
28+
key_template: str,
29+
) -> None:
30+
self._tts = tts
31+
self._storage = storage
32+
self._publisher = publisher
33+
self._idempotency = idempotency
34+
self._callback_routing_key = callback_routing_key
35+
self._voice = voice
36+
self._key_template = key_template
37+
38+
async def handle(self, message: AbstractIncomingMessage) -> None:
39+
async with message.process(requeue=False):
40+
try:
41+
envelope = Envelope[GenerateTtsRequest].model_validate_json(message.body)
42+
except Exception as exc:
43+
log.error("tts.parse.failed", error=str(exc), delivery_tag=message.delivery_tag)
44+
raise
45+
46+
if self._idempotency.is_seen_then_mark(envelope.message_id):
47+
log.info("tts.idempotent.skip", message_id=envelope.message_id)
48+
return
49+
50+
req = envelope.payload
51+
log.info(
52+
"tts.synthesize.start",
53+
message_id=envelope.message_id,
54+
session_id=req.session_id,
55+
interview_message_id=req.message_id,
56+
trace_id=envelope.trace_id,
57+
)
58+
59+
try:
60+
result = await self._tts.synthesize(req.text, voice=self._voice)
61+
except TtsError as exc:
62+
log.error("tts.synthesize.failed", code=exc.code, error=exc.message,
63+
session_id=req.session_id)
64+
await self._publish(envelope, TtsCallbackPayload(
65+
session_id=req.session_id, message_id=req.message_id,
66+
status="FAILED", error_code=exc.code,
67+
))
68+
return
69+
except Exception as exc:
70+
log.error("tts.synthesize.unexpected", error=str(exc), session_id=req.session_id)
71+
await self._publish(envelope, TtsCallbackPayload(
72+
session_id=req.session_id, message_id=req.message_id,
73+
status="FAILED", error_code="TTS_FAILED",
74+
))
75+
return
76+
77+
key = self._key_template.format(session_id=req.session_id, message_id=req.message_id)
78+
try:
79+
await self._storage.put_bytes(key, result.audio_bytes, content_type=result.content_type)
80+
except Exception as exc:
81+
log.error("tts.storage.failed", error=str(exc), key=key)
82+
await self._publish(envelope, TtsCallbackPayload(
83+
session_id=req.session_id, message_id=req.message_id,
84+
status="FAILED", error_code="TTS_STORAGE_FAILED",
85+
))
86+
return
87+
88+
await self._publish(envelope, TtsCallbackPayload(
89+
session_id=req.session_id, message_id=req.message_id,
90+
status="SUCCEEDED", audio_key=key, duration_sec=result.duration_sec,
91+
))
92+
log.info("tts.synthesize.done", session_id=req.session_id,
93+
interview_message_id=req.message_id, key=key)
94+
95+
async def _publish(self, envelope, payload: TtsCallbackPayload) -> None:
96+
await self._publisher.publish(
97+
routing_key=self._callback_routing_key,
98+
message_type="callback.tts",
99+
payload=payload,
100+
trace_id=envelope.trace_id,
101+
correlation_id=envelope.message_id,
102+
context=envelope.context,
103+
)

ai/src/ai_server/messaging/runner.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@
3333
from ai_server.messaging.consumers.feedback_consumer import FeedbackConsumer
3434
from ai_server.messaging.consumers.followup_consumer import FollowupConsumer
3535
from ai_server.messaging.consumers.questions_consumer import QuestionsConsumer
36+
from ai_server.messaging.consumers.tts_consumer import TtsConsumer
3637
from ai_server.messaging.consumers.voice_consumer import VoiceConsumer
3738
from ai_server.voice.stt.factory import build_stt_provider
39+
from ai_server.voice.tts.factory import build_tts_provider
3840
from ai_server.messaging.consumers.repository_consumer import RepositoryConsumer
3941
from ai_server.messaging.consumers.resume_consumer import ResumeConsumer
4042
from ai_server.messaging.consumers.web_consumer import WebResumeConsumer
@@ -210,6 +212,18 @@ def __init__(self, settings: Settings) -> None:
210212
core_client=core_client,
211213
)
212214

215+
# 질문 TTS (Part A)
216+
tts = build_tts_provider(settings)
217+
self._tts_consumer = TtsConsumer(
218+
tts=tts,
219+
storage=storage,
220+
publisher=self._publisher,
221+
idempotency=self._idempotency,
222+
callback_routing_key=settings.ai_callback_routing_tts,
223+
voice=settings.openai_tts_voice,
224+
key_template=settings.tts_audio_key_template,
225+
)
226+
213227
self._consumers: list[tuple[AbstractRobustQueue, str]] = []
214228

215229
async def start(self) -> None:
@@ -254,6 +268,11 @@ async def start(self) -> None:
254268
queue_name=self._settings.ai_queue_voice,
255269
handler=self._voice_consumer.handle,
256270
)
271+
await self._start_consumer(
272+
channel,
273+
queue_name=self._settings.ai_queue_tts,
274+
handler=self._tts_consumer.handle,
275+
)
257276

258277
async def _start_consumer(self, channel, *, queue_name, handler) -> None:
259278
queue = await channel.declare_queue(
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from typing import Literal
2+
3+
from pydantic import BaseModel
4+
5+
from ai_server.model._config import camel_config
6+
7+
8+
class GenerateTtsRequest(BaseModel):
9+
"""Core 가 질문 메시지 commit 후 발행. AI 가 text 를 TTS 합성."""
10+
model_config = camel_config()
11+
12+
session_id: int
13+
message_id: int # interview_messages.id (INTERVIEWER 질문)
14+
text: str
15+
mode: Literal["PERSONALITY", "TECHNICAL", "INTEGRATED"]
16+
job_category: Literal["FRONTEND", "BACKEND", "INFRA", "DBA"]
17+
18+
19+
class TtsCallbackPayload(BaseModel):
20+
"""AI → Core. 합성 결과(S3 키) 또는 실패. Core TtsCallbackPayload 와 호환."""
21+
model_config = camel_config()
22+
23+
session_id: int
24+
message_id: int
25+
status: Literal["SUCCEEDED", "FAILED"]
26+
audio_key: str | None = None # camelCase 직렬화 → audioKey
27+
duration_sec: float | None = None
28+
error_code: str | None = None

ai/src/ai_server/voice/tts/__init__.py

Whitespace-only changes.

ai/src/ai_server/voice/tts/base.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from __future__ import annotations
2+
3+
from abc import ABC, abstractmethod
4+
from dataclasses import dataclass
5+
6+
7+
class TtsError(Exception):
8+
def __init__(self, code: str, message: str) -> None:
9+
super().__init__(message)
10+
self.code = code
11+
self.message = message
12+
13+
14+
@dataclass(frozen=True)
15+
class TtsResult:
16+
audio_bytes: bytes
17+
duration_sec: float | None
18+
content_type: str = "audio/mpeg"
19+
20+
21+
class TtsProvider(ABC):
22+
model_name: str = "tts"
23+
24+
@abstractmethod
25+
async def synthesize(self, text: str, *, voice: str) -> TtsResult: ...
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from __future__ import annotations
2+
3+
import structlog
4+
5+
from ai_server.config.settings import Settings
6+
from ai_server.voice.tts.base import TtsProvider
7+
from ai_server.voice.tts.mock import MockTtsProvider
8+
from ai_server.voice.tts.openai_tts import OpenAiTtsProvider
9+
10+
log = structlog.get_logger(__name__)
11+
12+
13+
def build_tts_provider(settings: Settings) -> TtsProvider:
14+
"""TTS 공급자 선택. auto → openai 키 보유 시 openai, 없으면 mock."""
15+
provider = (settings.tts_provider or "auto").lower()
16+
17+
if provider == "auto":
18+
provider = "openai" if settings.openai_api_key else "mock"
19+
20+
if provider == "openai":
21+
if not settings.openai_api_key:
22+
log.warn("tts.fallback_to_mock", reason="OPENAI_API_KEY 누락")
23+
return MockTtsProvider()
24+
return OpenAiTtsProvider(
25+
api_key=settings.openai_api_key,
26+
base_url=settings.openai_base_url,
27+
model=settings.openai_tts_model,
28+
timeout_sec=settings.openai_tts_timeout_sec,
29+
)
30+
31+
if provider == "mock":
32+
return MockTtsProvider()
33+
34+
log.warn("tts.unknown_provider_fallback_mock", provider=provider)
35+
return MockTtsProvider()

ai/src/ai_server/voice/tts/mock.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from __future__ import annotations
2+
3+
from ai_server.voice.tts.base import TtsProvider, TtsResult
4+
5+
6+
class MockTtsProvider(TtsProvider):
7+
model_name = "mock-tts"
8+
9+
async def synthesize(self, text: str, *, voice: str) -> TtsResult:
10+
# 텍스트 길이에 비례한 가짜 오디오 바이트 + 추정 길이(분당 ~300자 가정).
11+
payload = ("MOCKMP3:" + text).encode("utf-8")
12+
duration = max(0.5, round(len(text) / 5.0, 2))
13+
return TtsResult(audio_bytes=payload, duration_sec=duration, content_type="audio/mpeg")

0 commit comments

Comments
 (0)