diff --git a/ai/.env.example b/ai/.env.example index c910b2b9..400b1c85 100644 --- a/ai/.env.example +++ b/ai/.env.example @@ -56,9 +56,15 @@ DEEPGRAM_BASE_URL=https://api.deepgram.com/v1 DEEPGRAM_MODEL=whisper-large DEEPGRAM_LANGUAGE=ko DEEPGRAM_TIMEOUT_SEC=60 -# OpenAI Whisper (Mindlogic 미지원, 직접 호출). +# 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=OPENAI_API_KEY 보유 시 openai, 없으면 mock. (OPENAI_API_KEY 재사용) +TTS_PROVIDER=auto +OPENAI_TTS_MODEL=gpt-4o-mini-tts +OPENAI_TTS_VOICE=alloy +OPENAI_TTS_TIMEOUT_SEC=30 diff --git a/ai/CLAUDE.md b/ai/CLAUDE.md index e900901d..8fc4a3a4 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -95,6 +95,7 @@ ai/ | `ai.analyze.web` | `analyze.web` | 본 구현 (URL → trafilatura) | | `ai.generate.questions` | `generate.questions` | 본 구현 (Pro 모델, 질문 풀 생성, US-18) | | `ai.generate.followup` | `generate.followup` | 본 구현 (Flash 모델, 답변 평가+꼬리질문, US-19) | +| `ai.generate.tts` | `generate.tts` | 본 구현 (질문 음성화, OpenAI TTS → S3 → `callback.tts`) | 콜백 발행: `ai.callback.{type}` 익스체인지. 상세 envelope/스키마/재시도: [`/docs/messaging.md`](../docs/messaging.md). @@ -188,8 +189,8 @@ chain = prompt | llm | PydanticOutputParser(pydantic_object=...) - 비용: $0.006 / 분 (1시간 면접 ≈ ₩500 / USD ≈ $0.36) - 셀프호스팅 옵션: `whisper.cpp` 또는 `faster-whisper` (GPU 권장, 비용 ↓ but 운영 부담 ↑) - 브라우저 내장 SpeechRecognition API는 정확도 부족으로 채택 안 함 -- TTS 제공자 미정 → 도입 시 본 섹션 갱신 -- 추상화 계층 두기: `voice/stt/base.py` (interface), `voice/stt/whisper_api.py`, `voice/tts/{provider}.py` +- **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` 발행. +- 추상화 계층 두기: `voice/stt/base.py` (interface), `voice/stt/whisper_api.py`, `voice/tts/base.py` + `voice/tts/{provider}.py` - 분석: - WPM = words / minutes - 간투어: 한국어 정규식 `r"\b(음+|어+|그+)\b"` 카운트 @@ -324,6 +325,8 @@ docker run --env-file .env -p 8000:8000 stackup-ai - **스토리지 추상화** (`storage/`): `S3Storage`(기본) / `LocalFilesystemStorage`. `STORAGE_BACKEND` 토글. - **LLM 호출 로깅 본 구현** (`observability/llm_logging_callback.py`, US-30): LangChain `AsyncCallbackHandler` 가 토큰/latency 측정 → Core `/api/internal/ai-logs` POST. -- 음성 모듈은 Phase 2 +- **질문 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` 발행. +- 음성 분석(STT/WPM/filler) 모듈은 Phase 2 각 도입 시 본 문서 갱신. diff --git a/ai/src/ai_server/config/settings.py b/ai/src/ai_server/config/settings.py index 86a9ca22..cd0482b5 100644 --- a/ai/src/ai_server/config/settings.py +++ b/ai/src/ai_server/config/settings.py @@ -26,12 +26,14 @@ class Settings(BaseSettings): ai_queue_followup: str = "ai.generate.followup" ai_queue_feedback: str = "ai.generate.feedback" ai_queue_voice: str = "ai.analyze.voice" + ai_queue_tts: str = "ai.generate.tts" ai_queue_prefetch: int = 10 ai_callback_exchange: str = "stackup.ai-to-core" ai_callback_routing_analysis: str = "callback.analysis" ai_callback_routing_questions: str = "callback.questions" ai_callback_routing_feedback: str = "callback.feedback" ai_callback_routing_voice: str = "callback.voice" + ai_callback_routing_tts: str = "callback.tts" # AI -> RealTime 직접 발행 (분석 단계 진행 상황). Core 를 거치지 않는 휘발성 알림. ai_realtime_exchange: str = "stackup.realtime" ai_realtime_routing_user: str = "realtime.user.notify" @@ -52,6 +54,13 @@ class Settings(BaseSettings): deepgram_model: str = "whisper-large" # 한국어 정확도 우선; 저비용 우선 시 nova-2. deepgram_language: str = "ko" deepgram_timeout_sec: float = 60.0 + + # TTS (질문 음성화). "auto" 면 openai 키 보유 시 openai, 없으면 mock. + tts_provider: Literal["auto", "mock", "openai"] = "auto" + openai_tts_model: str = "gpt-4o-mini-tts" + openai_tts_voice: str = "alloy" + openai_tts_timeout_sec: float = 30.0 + tts_audio_key_template: str = "interview/tts/{session_id}/{message_id}.mp3" # 음성 분석 voice_filler_pattern: str = r"(?:음+|어+|그+|아+)" ai_publisher_name: str = "ai-server" diff --git a/ai/src/ai_server/messaging/consumers/tts_consumer.py b/ai/src/ai_server/messaging/consumers/tts_consumer.py new file mode 100644 index 00000000..62a8341b --- /dev/null +++ b/ai/src/ai_server/messaging/consumers/tts_consumer.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import structlog +from aio_pika.abc import AbstractIncomingMessage + +from ai_server.messaging.idempotency import LruIdempotencyStore +from ai_server.messaging.publisher import CallbackPublisher +from ai_server.model.envelope import Envelope +from ai_server.model.messages.tts import GenerateTtsRequest, TtsCallbackPayload +from ai_server.storage.base import ObjectStorage +from ai_server.voice.tts.base import TtsError, TtsProvider + +log = structlog.get_logger(__name__) + + +class TtsConsumer: + """generate.tts consumer — 질문 텍스트를 TTS 합성 → S3 PUT → callback.tts 발행.""" + + def __init__( + self, + *, + tts: TtsProvider, + storage: ObjectStorage, + publisher: CallbackPublisher, + idempotency: LruIdempotencyStore, + callback_routing_key: str, + voice: str, + key_template: str, + ) -> None: + self._tts = tts + self._storage = storage + self._publisher = publisher + self._idempotency = idempotency + self._callback_routing_key = callback_routing_key + self._voice = voice + self._key_template = key_template + + async def handle(self, message: AbstractIncomingMessage) -> None: + async with message.process(requeue=False): + try: + envelope = Envelope[GenerateTtsRequest].model_validate_json(message.body) + except Exception as exc: + log.error("tts.parse.failed", error=str(exc), delivery_tag=message.delivery_tag) + raise + + if self._idempotency.is_seen_then_mark(envelope.message_id): + log.info("tts.idempotent.skip", message_id=envelope.message_id) + return + + req = envelope.payload + log.info( + "tts.synthesize.start", + message_id=envelope.message_id, + session_id=req.session_id, + interview_message_id=req.message_id, + trace_id=envelope.trace_id, + ) + + try: + result = await self._tts.synthesize(req.text, voice=self._voice) + except TtsError as exc: + log.error("tts.synthesize.failed", code=exc.code, error=exc.message, + session_id=req.session_id) + await self._publish(envelope, TtsCallbackPayload( + session_id=req.session_id, message_id=req.message_id, + status="FAILED", error_code=exc.code, + )) + return + except Exception as exc: + log.error("tts.synthesize.unexpected", error=str(exc), session_id=req.session_id) + await self._publish(envelope, TtsCallbackPayload( + session_id=req.session_id, message_id=req.message_id, + status="FAILED", error_code="TTS_FAILED", + )) + return + + key = self._key_template.format(session_id=req.session_id, message_id=req.message_id) + try: + await self._storage.put_bytes(key, result.audio_bytes, content_type=result.content_type) + except Exception as exc: + log.error("tts.storage.failed", error=str(exc), key=key) + await self._publish(envelope, TtsCallbackPayload( + session_id=req.session_id, message_id=req.message_id, + status="FAILED", error_code="TTS_STORAGE_FAILED", + )) + return + + await self._publish(envelope, TtsCallbackPayload( + session_id=req.session_id, message_id=req.message_id, + status="SUCCEEDED", audio_key=key, duration_sec=result.duration_sec, + )) + log.info("tts.synthesize.done", session_id=req.session_id, + interview_message_id=req.message_id, key=key) + + async def _publish(self, envelope, payload: TtsCallbackPayload) -> None: + await self._publisher.publish( + routing_key=self._callback_routing_key, + message_type="callback.tts", + payload=payload, + trace_id=envelope.trace_id, + correlation_id=envelope.message_id, + context=envelope.context, + ) diff --git a/ai/src/ai_server/messaging/runner.py b/ai/src/ai_server/messaging/runner.py index 0e2df2f1..242279e0 100644 --- a/ai/src/ai_server/messaging/runner.py +++ b/ai/src/ai_server/messaging/runner.py @@ -33,8 +33,10 @@ from ai_server.messaging.consumers.feedback_consumer import FeedbackConsumer from ai_server.messaging.consumers.followup_consumer import FollowupConsumer from ai_server.messaging.consumers.questions_consumer import QuestionsConsumer +from ai_server.messaging.consumers.tts_consumer import TtsConsumer from ai_server.messaging.consumers.voice_consumer import VoiceConsumer from ai_server.voice.stt.factory import build_stt_provider +from ai_server.voice.tts.factory import build_tts_provider from ai_server.messaging.consumers.repository_consumer import RepositoryConsumer from ai_server.messaging.consumers.resume_consumer import ResumeConsumer from ai_server.messaging.consumers.web_consumer import WebResumeConsumer @@ -210,6 +212,18 @@ def __init__(self, settings: Settings) -> None: core_client=core_client, ) + # 질문 TTS (Part A) + tts = build_tts_provider(settings) + self._tts_consumer = TtsConsumer( + tts=tts, + storage=storage, + publisher=self._publisher, + idempotency=self._idempotency, + callback_routing_key=settings.ai_callback_routing_tts, + voice=settings.openai_tts_voice, + key_template=settings.tts_audio_key_template, + ) + self._consumers: list[tuple[AbstractRobustQueue, str]] = [] async def start(self) -> None: @@ -254,6 +268,11 @@ async def start(self) -> None: queue_name=self._settings.ai_queue_voice, handler=self._voice_consumer.handle, ) + await self._start_consumer( + channel, + queue_name=self._settings.ai_queue_tts, + handler=self._tts_consumer.handle, + ) async def _start_consumer(self, channel, *, queue_name, handler) -> None: queue = await channel.declare_queue( diff --git a/ai/src/ai_server/model/messages/tts.py b/ai/src/ai_server/model/messages/tts.py new file mode 100644 index 00000000..bb12f0eb --- /dev/null +++ b/ai/src/ai_server/model/messages/tts.py @@ -0,0 +1,28 @@ +from typing import Literal + +from pydantic import BaseModel + +from ai_server.model._config import camel_config + + +class GenerateTtsRequest(BaseModel): + """Core 가 질문 메시지 commit 후 발행. AI 가 text 를 TTS 합성.""" + model_config = camel_config() + + session_id: int + message_id: int # interview_messages.id (INTERVIEWER 질문) + text: str + mode: Literal["PERSONALITY", "TECHNICAL", "INTEGRATED"] + job_category: Literal["FRONTEND", "BACKEND", "INFRA", "DBA"] + + +class TtsCallbackPayload(BaseModel): + """AI → Core. 합성 결과(S3 키) 또는 실패. Core TtsCallbackPayload 와 호환.""" + model_config = camel_config() + + session_id: int + message_id: int + status: Literal["SUCCEEDED", "FAILED"] + audio_key: str | None = None # camelCase 직렬화 → audioKey + duration_sec: float | None = None + error_code: str | None = None diff --git a/ai/src/ai_server/voice/tts/__init__.py b/ai/src/ai_server/voice/tts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ai/src/ai_server/voice/tts/base.py b/ai/src/ai_server/voice/tts/base.py new file mode 100644 index 00000000..c83d4969 --- /dev/null +++ b/ai/src/ai_server/voice/tts/base.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass + + +class TtsError(Exception): + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + self.message = message + + +@dataclass(frozen=True) +class TtsResult: + audio_bytes: bytes + duration_sec: float | None + content_type: str = "audio/mpeg" + + +class TtsProvider(ABC): + model_name: str = "tts" + + @abstractmethod + async def synthesize(self, text: str, *, voice: str) -> TtsResult: ... diff --git a/ai/src/ai_server/voice/tts/factory.py b/ai/src/ai_server/voice/tts/factory.py new file mode 100644 index 00000000..a06adb5f --- /dev/null +++ b/ai/src/ai_server/voice/tts/factory.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import structlog + +from ai_server.config.settings import Settings +from ai_server.voice.tts.base import TtsProvider +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 → openai 키 보유 시 openai, 없으면 mock.""" + provider = (settings.tts_provider or "auto").lower() + + if provider == "auto": + provider = "openai" if settings.openai_api_key else "mock" + + 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() + + log.warn("tts.unknown_provider_fallback_mock", provider=provider) + return MockTtsProvider() diff --git a/ai/src/ai_server/voice/tts/mock.py b/ai/src/ai_server/voice/tts/mock.py new file mode 100644 index 00000000..638b9e35 --- /dev/null +++ b/ai/src/ai_server/voice/tts/mock.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from ai_server.voice.tts.base import TtsProvider, TtsResult + + +class MockTtsProvider(TtsProvider): + model_name = "mock-tts" + + async def synthesize(self, text: str, *, voice: str) -> TtsResult: + # 텍스트 길이에 비례한 가짜 오디오 바이트 + 추정 길이(분당 ~300자 가정). + payload = ("MOCKMP3:" + text).encode("utf-8") + duration = max(0.5, round(len(text) / 5.0, 2)) + return TtsResult(audio_bytes=payload, duration_sec=duration, content_type="audio/mpeg") diff --git a/ai/src/ai_server/voice/tts/openai_tts.py b/ai/src/ai_server/voice/tts/openai_tts.py new file mode 100644 index 00000000..02e4b328 --- /dev/null +++ b/ai/src/ai_server/voice/tts/openai_tts.py @@ -0,0 +1,50 @@ +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_consumer.py b/ai/tests/test_tts_consumer.py new file mode 100644 index 00000000..2c0f5cb8 --- /dev/null +++ b/ai/tests/test_tts_consumer.py @@ -0,0 +1,85 @@ +import json +from datetime import datetime, timezone + +import pytest + +from ai_server.messaging.consumers.tts_consumer import TtsConsumer +from ai_server.voice.tts.mock import MockTtsProvider + + +class _FakeStorage: + def __init__(self): + self.puts = [] + + async def put_bytes(self, key, data, *, content_type=None): + self.puts.append((key, data, content_type)) + + +class _FakePublisher: + def __init__(self): + self.published = [] + + async def publish(self, *, routing_key, message_type, payload, trace_id, correlation_id, context): + self.published.append((routing_key, message_type, payload)) + + +class _FakeIdem: + def is_seen_then_mark(self, mid): + return False + + +class _FakeMsg: + def __init__(self, body: bytes): + self.body = body + self.delivery_tag = 1 + + def process(self, requeue=False): + class _Ctx: + async def __aenter__(self_): return self_ + async def __aexit__(self_, *a): return False + return _Ctx() + + +def _envelope_body(): + return json.dumps({ + "messageId": "m-1", + "messageType": "generate.tts", + "version": "v1", + "traceId": "t-1", + "publishedAt": datetime.now(timezone.utc).isoformat(), + "publisher": "core-server", + "payload": { + "sessionId": 7, "messageId": 42, "text": "질문 본문", + "mode": "TECHNICAL", "jobCategory": "BACKEND", + }, + "context": {"userId": 1, "sessionId": 7}, + }).encode("utf-8") + + +@pytest.mark.asyncio +async def test_tts_consumer_synthesizes_and_publishes(): + storage = _FakeStorage() + publisher = _FakePublisher() + consumer = TtsConsumer( + tts=MockTtsProvider(), + storage=storage, + publisher=publisher, + idempotency=_FakeIdem(), + callback_routing_key="callback.tts", + voice="alloy", + key_template="interview/tts/{session_id}/{message_id}.mp3", + ) + + await consumer.handle(_FakeMsg(_envelope_body())) + + assert storage.puts, "S3 PUT 발생해야 함" + key, data, ctype = storage.puts[0] + assert key == "interview/tts/7/42.mp3" + assert ctype == "audio/mpeg" + assert publisher.published, "callback.tts 발행해야 함" + rk, mtype, payload = publisher.published[0] + assert rk == "callback.tts" + assert mtype == "callback.tts" + assert payload.status == "SUCCEEDED" + assert payload.audio_key == "interview/tts/7/42.mp3" + assert payload.message_id == 42 diff --git a/ai/tests/test_tts_factory.py b/ai/tests/test_tts_factory.py new file mode 100644 index 00000000..38591c14 --- /dev/null +++ b/ai/tests/test_tts_factory.py @@ -0,0 +1,31 @@ +from ai_server.config.settings import Settings +from ai_server.voice.tts.factory import build_tts_provider +from ai_server.voice.tts.mock import MockTtsProvider +from ai_server.voice.tts.openai_tts import OpenAiTtsProvider + + +def _settings(**over): + base = dict( + rabbitmq_url="amqp://x", + s3_endpoint_url="http://x", + s3_access_key="x", + s3_secret_key="x", + s3_bucket_name="b", + ) + base.update(over) + return Settings(**base) + + +def test_auto_falls_back_to_mock_without_key(): + s = _settings(tts_provider="auto", openai_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") + assert isinstance(build_tts_provider(s), OpenAiTtsProvider) + + +def test_explicit_mock(): + s = _settings(tts_provider="mock", openai_api_key="sk-test") + assert isinstance(build_tts_provider(s), MockTtsProvider) diff --git a/ai/tests/test_tts_provider.py b/ai/tests/test_tts_provider.py new file mode 100644 index 00000000..c8792d7e --- /dev/null +++ b/ai/tests/test_tts_provider.py @@ -0,0 +1,14 @@ +import pytest + +from ai_server.voice.tts.mock import MockTtsProvider + + +@pytest.mark.asyncio +async def test_mock_tts_returns_bytes_and_duration(): + provider = MockTtsProvider() + result = await provider.synthesize("안녕하세요 질문입니다", voice="alloy") + assert isinstance(result.audio_bytes, bytes) + assert len(result.audio_bytes) > 0 + assert result.duration_sec is not None + assert result.content_type == "audio/mpeg" + assert provider.model_name == "mock-tts" diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 70c3aa9b..433dd373 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -359,6 +359,8 @@ docker compose up -d - ArchUnit 룰 적용 (의존 방향 · 순환 차단 · `@Transactional` application 한정 · entity는 domain 패키지) - 면접 도메인 (US-13~20) 본 구현: 세션 CRUD/start/end/interrupt, generate.questions 발행, callback.questions(POOL/FOLLOWUP) 수신, 자동 종료 +- 질문 TTS 발행 본 구현: 질문 영속 후 `QuestionPersistedEvent`(AFTER_COMMIT) → `SessionTtsRequester` 가 `generate.tts` 발행, + `callback.tts` 수신해 메시지에 오디오 경로 반영(`TtsCallbackService`) - AI 호출 로깅 (US-30) 본 구현: `/api/internal/ai-logs` + `ai_request_logs` INSERT - **Spring AI 미사용** — LLM·임베딩 호출은 모두 AI 서버 위임. Core는 RabbitMQ 발행만 담당. - **Redis 미사용** — 휘발성 데이터는 DB short-lived 레코드 또는 인메모리로. diff --git a/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java b/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java index 3076aab2..8bc4f4e3 100644 --- a/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java +++ b/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java @@ -69,6 +69,7 @@ public record Names( @NotBlank String aiGenerateFollowup, @NotBlank String aiGenerateFeedback, @NotBlank String aiAnalyzeVoice, + @NotBlank String aiGenerateTts, @NotBlank String coreCallbackAnalysis, @NotBlank String coreCallbackQuestions, @NotBlank String coreCallbackFeedback, @@ -85,6 +86,7 @@ public record RoutingKeyProperties( @NotBlank String generateFollowup, @NotBlank String generateFeedback, @NotBlank String analyzeVoice, + @NotBlank String generateTts, @NotBlank String callbackAnalysis, @NotBlank String callbackQuestions, @NotBlank String callbackFeedback, diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java b/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java index a6040d89..de24ba78 100644 --- a/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java +++ b/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java @@ -97,6 +97,11 @@ public Queue aiAnalyzeVoiceQueue() { return workQueue(properties.queues().names().aiAnalyzeVoice()); } + @Bean + public Queue aiGenerateTtsQueue() { + return workQueue(properties.queues().names().aiGenerateTts()); + } + @Bean public Queue coreCallbackAnalysisQueue() { return workQueue(properties.queues().names().coreCallbackAnalysis()); @@ -134,6 +139,7 @@ public Declarables rabbitDeclarables( Queue aiGenerateFollowupQueue, Queue aiGenerateFeedbackQueue, Queue aiAnalyzeVoiceQueue, + Queue aiGenerateTtsQueue, Queue coreCallbackAnalysisQueue, Queue coreCallbackQuestionsQueue, Queue coreCallbackFeedbackQueue, @@ -146,6 +152,7 @@ public Declarables rabbitDeclarables( Queue dlqAiGenerateFollowup = dlq(properties.queues().names().aiGenerateFollowup()); Queue dlqAiGenerateFeedback = dlq(properties.queues().names().aiGenerateFeedback()); Queue dlqAiAnalyzeVoice = dlq(properties.queues().names().aiAnalyzeVoice()); + Queue dlqAiGenerateTts = dlq(properties.queues().names().aiGenerateTts()); Queue dlqCoreCallbackAnalysis = dlq(properties.queues().names().coreCallbackAnalysis()); Queue dlqCoreCallbackQuestions = dlq(properties.queues().names().coreCallbackQuestions()); Queue dlqCoreCallbackFeedback = dlq(properties.queues().names().coreCallbackFeedback()); @@ -163,6 +170,7 @@ public Declarables rabbitDeclarables( aiGenerateFollowupQueue, aiGenerateFeedbackQueue, aiAnalyzeVoiceQueue, + aiGenerateTtsQueue, coreCallbackAnalysisQueue, coreCallbackQuestionsQueue, coreCallbackFeedbackQueue, @@ -174,6 +182,7 @@ public Declarables rabbitDeclarables( dlqAiGenerateFollowup, dlqAiGenerateFeedback, dlqAiAnalyzeVoice, + dlqAiGenerateTts, dlqCoreCallbackAnalysis, dlqCoreCallbackQuestions, dlqCoreCallbackFeedback, @@ -185,6 +194,7 @@ public Declarables rabbitDeclarables( BindingBuilder.bind(aiGenerateFollowupQueue).to(coreToAiExchange).with(properties.routingKeys().generateFollowup()), BindingBuilder.bind(aiGenerateFeedbackQueue).to(coreToAiExchange).with(properties.routingKeys().generateFeedback()), BindingBuilder.bind(aiAnalyzeVoiceQueue).to(coreToAiExchange).with(properties.routingKeys().analyzeVoice()), + BindingBuilder.bind(aiGenerateTtsQueue).to(coreToAiExchange).with(properties.routingKeys().generateTts()), BindingBuilder.bind(coreCallbackAnalysisQueue).to(aiToCoreExchange).with(properties.routingKeys().callbackAnalysis()), BindingBuilder.bind(coreCallbackQuestionsQueue).to(aiToCoreExchange).with(properties.routingKeys().callbackQuestions()), BindingBuilder.bind(coreCallbackFeedbackQueue).to(aiToCoreExchange).with(properties.routingKeys().callbackFeedback()), @@ -196,6 +206,7 @@ public Declarables rabbitDeclarables( BindingBuilder.bind(dlqAiGenerateFollowup).to(deadLetterExchange).with(dlqAiGenerateFollowup.getName()), BindingBuilder.bind(dlqAiGenerateFeedback).to(deadLetterExchange).with(dlqAiGenerateFeedback.getName()), BindingBuilder.bind(dlqAiAnalyzeVoice).to(deadLetterExchange).with(dlqAiAnalyzeVoice.getName()), + BindingBuilder.bind(dlqAiGenerateTts).to(deadLetterExchange).with(dlqAiGenerateTts.getName()), BindingBuilder.bind(dlqCoreCallbackAnalysis).to(deadLetterExchange).with(dlqCoreCallbackAnalysis.getName()), BindingBuilder.bind(dlqCoreCallbackQuestions).to(deadLetterExchange).with(dlqCoreCallbackQuestions.getName()), BindingBuilder.bind(dlqCoreCallbackFeedback).to(deadLetterExchange).with(dlqCoreCallbackFeedback.getName()), diff --git a/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java b/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java index 9115d239..d13d784a 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java @@ -7,6 +7,7 @@ import com.stackup.stackup.session.application.dto.QuestionsCallbackEnvelope; import com.stackup.stackup.session.application.dto.QuestionsCallbackPayload; import com.stackup.stackup.session.application.dto.QuestionsCallbackPayload.GeneratedQuestion; +import com.stackup.stackup.session.application.event.QuestionPersistedEvent; import com.stackup.stackup.session.application.event.SessionEndedEvent; import org.springframework.context.ApplicationEventPublisher; import com.stackup.stackup.session.domain.InterviewMessage; @@ -88,6 +89,8 @@ private void applyInitialQuestion(InterviewSession session, QuestionsCallbackPay InterviewMessage.interviewer(session, 1, first.question()) ); session.incrementQuestionCount(); + events.publishEvent(new QuestionPersistedEvent( + session.getUser().getId(), session.getId(), message.getId())); events.publishEvent(RealtimeNotifyEvent.session(session.getId(), SseEventType.SESSION_MESSAGE, message.getId())); // 사용자 user 채널에도 알림 — frontend 가 documentId/sessionId 사전 인지 없이도 받을 수 있게 events.publishEvent(RealtimeNotifyEvent.user( @@ -116,6 +119,9 @@ private void applyFollowup(InterviewSession session, QuestionsCallbackPayload pa ); session.incrementQuestionCount(); + events.publishEvent(new QuestionPersistedEvent( + session.getUser().getId(), session.getId(), message.getId())); + events.publishEvent(RealtimeNotifyEvent.session(session.getId(), SseEventType.SESSION_MESSAGE, message.getId())); events.publishEvent(RealtimeNotifyEvent.user( session.getUser().getId(), diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionTtsRequester.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionTtsRequester.java new file mode 100644 index 00000000..ac5dd3cc --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionTtsRequester.java @@ -0,0 +1,54 @@ +package com.stackup.stackup.session.application; + +import com.stackup.stackup.common.config.properties.RabbitMqProperties; +import com.stackup.stackup.common.messaging.MessageContext; +import com.stackup.stackup.common.messaging.RabbitMessagePublisher; +import com.stackup.stackup.session.application.dto.GenerateTtsPayload; +import com.stackup.stackup.session.application.event.QuestionPersistedEvent; +import com.stackup.stackup.session.domain.InterviewMessage; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.InterviewSession; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +// 질문(INTERVIEWER) 메시지 commit 후 발화 → generate.tts envelope 발행 (Part A). +@Component +@RequiredArgsConstructor +public class SessionTtsRequester { + + private static final Logger log = LoggerFactory.getLogger(SessionTtsRequester.class); + + private final RabbitMessagePublisher publisher; + private final RabbitMqProperties properties; + private final InterviewMessageRepository messageRepository; + + @Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onQuestionPersisted(QuestionPersistedEvent event) { + InterviewMessage message = messageRepository.findById(event.messageId()).orElse(null); + if (message == null) { + log.warn("generate.tts skipped — message not found. messageId={}", event.messageId()); + return; + } + InterviewSession session = message.getSession(); + GenerateTtsPayload payload = new GenerateTtsPayload( + session.getId(), + message.getId(), + message.getContent(), + session.getMode(), + session.getJobCategory() + ); + publisher.publishToAi( + properties.routingKeys().generateTts(), + payload, + new MessageContext(event.userId(), session.getId(), null, null) + ); + log.info("generate.tts published. sessionId={}, messageId={}", session.getId(), message.getId()); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateTtsPayload.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateTtsPayload.java new file mode 100644 index 00000000..abc2491a --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateTtsPayload.java @@ -0,0 +1,14 @@ +package com.stackup.stackup.session.application.dto; + +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.SessionMode; + +// generate.tts envelope payload (Core → AI). AI 가 text 를 TTS 합성 → S3 PUT → callback.tts 발행. +public record GenerateTtsPayload( + Long sessionId, + Long messageId, + String text, + SessionMode mode, + JobCategory jobCategory +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/event/QuestionPersistedEvent.java b/backend/src/main/java/com/stackup/stackup/session/application/event/QuestionPersistedEvent.java new file mode 100644 index 00000000..209501d7 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/event/QuestionPersistedEvent.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.session.application.event; + +// INTERVIEWER 질문 메시지 영속 후 발행. SessionTtsRequester 가 AFTER_COMMIT 에 받아 generate.tts 발행. +public record QuestionPersistedEvent( + Long userId, + Long sessionId, + Long messageId +) { +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index df8031e3..cb48ce3c 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -96,6 +96,7 @@ app: ai-generate-followup: ai.generate.followup ai-generate-feedback: ai.generate.feedback ai-analyze-voice: ai.analyze.voice + ai-generate-tts: ai.generate.tts core-callback-analysis: core.callback.analysis core-callback-questions: core.callback.questions core-callback-feedback: core.callback.feedback @@ -108,6 +109,7 @@ app: generate-followup: generate.followup generate-feedback: generate.feedback analyze-voice: analyze.voice + generate-tts: generate.tts callback-analysis: callback.analysis callback-questions: callback.questions callback-feedback: callback.feedback diff --git a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java index 9085e434..7c79b5c1 100644 --- a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java +++ b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java @@ -94,6 +94,7 @@ private RabbitMqProperties rabbitMqProperties() { "ai.generate.followup", "ai.generate.feedback", "ai.analyze.voice", + "ai.generate.tts", "core.callback.analysis", "core.callback.questions", "core.callback.feedback", @@ -108,6 +109,7 @@ private RabbitMqProperties rabbitMqProperties() { "generate.followup", "generate.feedback", "analyze.voice", + "generate.tts", "callback.analysis", "callback.questions", "callback.feedback", diff --git a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMqConfigTest.java b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMqConfigTest.java index 6f56fa37..c9ddc9cf 100644 --- a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMqConfigTest.java +++ b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMqConfigTest.java @@ -63,6 +63,7 @@ private RabbitMqProperties rabbitMqProperties() { "ai.generate.followup", "ai.generate.feedback", "ai.analyze.voice", + "ai.generate.tts", "core.callback.analysis", "core.callback.questions", "core.callback.feedback", @@ -77,6 +78,7 @@ private RabbitMqProperties rabbitMqProperties() { "generate.followup", "generate.feedback", "analyze.voice", + "generate.tts", "callback.analysis", "callback.questions", "callback.feedback", diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java index a5247e91..bcd484c1 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java @@ -136,6 +136,7 @@ private RabbitMqProperties.RoutingKeyProperties mockRoutingKeys() { return new RabbitMqProperties.RoutingKeyProperties( "analyze.resume", "analyze.repository", "generate.questions", "generate.followup", "generate.feedback", "analyze.voice", + "generate.tts", "callback.analysis", "callback.questions", "callback.feedback", "callback.voice", "callback.tts", "realtime.session.notify", "realtime.user.notify", "realtime.document.notify"); diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionQuestionsRequesterTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionQuestionsRequesterTest.java index 07d924e3..f1cbd760 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/SessionQuestionsRequesterTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionQuestionsRequesterTest.java @@ -62,6 +62,7 @@ private RabbitMqProperties.RoutingKeyProperties mockRoutingKeys() { return new RabbitMqProperties.RoutingKeyProperties( "analyze.resume", "analyze.repository", "generate.questions", "generate.followup", "generate.feedback", "analyze.voice", + "generate.tts", "callback.analysis", "callback.questions", "callback.feedback", "callback.voice", "callback.tts", "realtime.session.notify", "realtime.user.notify", "realtime.document.notify"); diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionTtsRequesterTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionTtsRequesterTest.java new file mode 100644 index 00000000..c93d7a02 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionTtsRequesterTest.java @@ -0,0 +1,62 @@ +package com.stackup.stackup.session.application; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.common.config.properties.RabbitMqProperties; +import com.stackup.stackup.common.messaging.MessageContext; +import com.stackup.stackup.common.messaging.RabbitMessagePublisher; +import com.stackup.stackup.session.application.dto.GenerateTtsPayload; +import com.stackup.stackup.session.application.event.QuestionPersistedEvent; +import com.stackup.stackup.session.domain.InterviewMessage; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.InterviewSession; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SessionTtsRequesterTest { + + @Mock RabbitMessagePublisher publisher; + @Mock RabbitMqProperties properties; + @Mock RabbitMqProperties.RoutingKeyProperties routingKeys; + @Mock InterviewMessageRepository messageRepository; + @InjectMocks SessionTtsRequester requester; + + @Test + void publishesGenerateTts_whenQuestionPersisted() { + InterviewSession session = org.mockito.Mockito.mock(InterviewSession.class); + when(session.getId()).thenReturn(7L); + InterviewMessage message = org.mockito.Mockito.mock(InterviewMessage.class); + when(message.getId()).thenReturn(42L); + when(message.getContent()).thenReturn("당신의 프로젝트에서..."); + when(message.getSession()).thenReturn(session); + when(messageRepository.findById(42L)).thenReturn(Optional.of(message)); + when(properties.routingKeys()).thenReturn(routingKeys); + when(routingKeys.generateTts()).thenReturn("generate.tts"); + + requester.onQuestionPersisted(new QuestionPersistedEvent(1L, 7L, 42L)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(GenerateTtsPayload.class); + verify(publisher).publishToAi(eq("generate.tts"), captor.capture(), any(MessageContext.class)); + org.assertj.core.api.Assertions.assertThat(captor.getValue().messageId()).isEqualTo(42L); + org.assertj.core.api.Assertions.assertThat(captor.getValue().text()).isEqualTo("당신의 프로젝트에서..."); + } + + @Test + void skips_whenMessageNotFound() { + when(messageRepository.findById(99L)).thenReturn(Optional.empty()); + + requester.onQuestionPersisted(new QuestionPersistedEvent(1L, 7L, 99L)); + + verify(publisher, never()).publishToAi(any(), any(), any()); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/session/application/VoiceAnswerUploadServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/VoiceAnswerUploadServiceTest.java index 4a7c4d81..bb45b0ac 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/VoiceAnswerUploadServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/VoiceAnswerUploadServiceTest.java @@ -213,6 +213,7 @@ private RabbitMqProperties rabbitMqProperties() { "ai.generate.followup", "ai.generate.feedback", "ai.analyze.voice", + "ai.generate.tts", "core.callback.analysis", "core.callback.questions", "core.callback.feedback", @@ -226,6 +227,7 @@ private RabbitMqProperties rabbitMqProperties() { "generate.followup", "generate.feedback", "analyze.voice", + "generate.tts", "callback.analysis", "callback.questions", "callback.feedback", diff --git a/docs/environment.md b/docs/environment.md index 11064f0e..c4f990d7 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -146,7 +146,10 @@ ANALYZED_WEB_RESUME_MD_KEY_TEMPLATE=analyzed/web-resume/{resume_id}/summary.md # ===== STT/TTS (Phase 2) ===== STT_PROVIDER=whisper-api # whisper-api | whisper-self-hosted WHISPER_MODEL=whisper-1 # OpenAI Whisper API 모델 -TTS_PROVIDER= # 결정 시 추가 +TTS_PROVIDER=auto # auto | mock | openai (auto=OPENAI_API_KEY 보유 시 openai) +OPENAI_TTS_MODEL=gpt-4o-mini-tts # OpenAI TTS 모델 (질문 음성화) +OPENAI_TTS_VOICE=alloy # TTS 보이스 +OPENAI_TTS_TIMEOUT_SEC=30 # TTS HTTP 타임아웃(초) ``` --- diff --git a/docs/messaging.md b/docs/messaging.md index c20b68d9..29cd6f83 100644 --- a/docs/messaging.md +++ b/docs/messaging.md @@ -24,6 +24,7 @@ | `ai.analyze.web` | `stackup.core-to-ai` | `analyze.web` | AI Server | | `ai.generate.questions` | `stackup.core-to-ai` | `generate.questions` | AI Server | | `ai.generate.followup` | `stackup.core-to-ai` | `generate.followup` | AI Server | +| `ai.generate.tts` | `stackup.core-to-ai` | `generate.tts` | AI Server | | `core.callback.analysis` | `stackup.ai-to-core` | `callback.analysis` | Core Server | | `core.callback.questions` | `stackup.ai-to-core` | `callback.questions` | Core Server | | `q.realtime.session.notify` | `stackup.realtime` | `realtime.session.*` · `realtime.user.*` · `realtime.document.*` | RealTime Server | @@ -40,6 +41,7 @@ | `dlq.ai.analyze.web` | `stackup.dlx` | `dlq.ai.analyze.web` | `ai.analyze.web` 처리 실패 | | `dlq.ai.generate.questions` | `stackup.dlx` | `dlq.ai.generate.questions` | `ai.generate.questions` 처리 실패 | | `dlq.ai.generate.followup` | `stackup.dlx` | `dlq.ai.generate.followup` | `ai.generate.followup` 처리 실패 | +| `dlq.ai.generate.tts` | `stackup.dlx` | `dlq.ai.generate.tts` | `ai.generate.tts` 처리 실패 | | `dlq.core.callback.analysis` | `stackup.dlx` | `dlq.core.callback.analysis` | `core.callback.analysis` 처리 실패 | | `dlq.core.callback.questions` | `stackup.dlx` | `dlq.core.callback.questions` | `core.callback.questions` 처리 실패 | | `dlq.q.realtime.session.notify` | `stackup.dlx` | `dlq.q.realtime.session.notify` | `q.realtime.session.notify` 처리 실패 | @@ -60,7 +62,7 @@ ``` `action` ∈ `analyze | generate | callback | realtime` -`aggregate` ∈ `resume | repository | web | questions | followup | analysis | feedback | session` +`aggregate` ∈ `resume | repository | web | questions | followup | tts | analysis | feedback | session` 새 routing key 추가 시 본 패턴 유지. @@ -121,6 +123,7 @@ | 레포 분석 (US-10) | `analyze.repository` | `callback.analysis` | `core.callback.analysis` | | 질문 풀 생성 (US-18) | `generate.questions` | `callback.questions` | `core.callback.questions` | | 꼬리질문 생성 (US-19) | `generate.followup` | `callback.questions` | `core.callback.questions` | +| 질문 TTS 합성 | `generate.tts` | `callback.tts` | `core.callback.tts` | | 피드백 생성 (US-24) | `generate.feedback` *(예정)* | `callback.feedback` *(예정)* | `core.callback.feedback` *(예정)* | | 세션 알림 (RT2 SSE) | `realtime.session.notify` | (없음 — 단방향 push) | `q.realtime.session.notify` | @@ -283,6 +286,40 @@ > `callback.questions` 큐는 두 종류(`POOL`, `FOLLOWUP`)를 받으므로 consumer는 `payload.kind`로 분기. +### 5.9a `generate.tts` +```json +{ + "messageType": "generate.tts", + "payload": { + "sessionId": 99, + "messageId": 502, + "text": "당신의 프로젝트에서 가장 어려웠던 점은 무엇인가요?", + "mode": "TECHNICAL", + "jobCategory": "BACKEND" + }, + "context": { "userId": 123, "sessionId": 99 } +} +``` + +> 질문(INTERVIEWER) 메시지 영속 후 Core 가 발행. `messageId` 는 `interview_messages.id`. AI 가 `text` 를 TTS 합성 → S3 PUT → `callback.tts` 회신. + +### 5.9b `callback.tts` +```json +{ + "messageType": "callback.tts", + "payload": { + "sessionId": 99, + "messageId": 502, + "status": "SUCCEEDED", + "audioKey": "interview/tts/99/502.mp3", + "durationSec": 4.2, + "errorCode": null + } +} +``` + +> 실패 시 `status: "FAILED"` + `errorCode`(`TTS_API_ERROR`/`TTS_STORAGE_FAILED` 등), `audioKey`/`durationSec` 는 null. OpenAI TTS 는 duration 을 주지 않으므로 `durationSec` 는 null 일 수 있다. + ### 5.10 `generate.feedback` *(예정)* ```json { diff --git a/infra/CLAUDE.md b/infra/CLAUDE.md index 318f609b..25601bd4 100644 --- a/infra/CLAUDE.md +++ b/infra/CLAUDE.md @@ -113,6 +113,7 @@ docker exec -i stackup-postgres psql -U stackup stackup < backup.sql | `ai.analyze.web` | `stackup.core-to-ai` | `analyze.web` | `dlq.ai.analyze.web` | AI Server | | `ai.generate.questions` | `stackup.core-to-ai` | `generate.questions` | `dlq.ai.generate.questions` | AI Server | | `ai.generate.followup` | `stackup.core-to-ai` | `generate.followup` | `dlq.ai.generate.followup` | AI Server | +| `ai.generate.tts` | `stackup.core-to-ai` | `generate.tts` | `dlq.ai.generate.tts` | AI Server | | `core.callback.analysis` | `stackup.ai-to-core` | `callback.analysis` | `dlq.core.callback.analysis` | Core Server | | `core.callback.questions` | `stackup.ai-to-core` | `callback.questions` | `dlq.core.callback.questions` | Core Server | | `q.realtime.session.notify` | `stackup.realtime` | `realtime.session.*` | `dlq.q.realtime.session.notify` | RealTime Server | diff --git a/infra/rabbitmq/definitions.json b/infra/rabbitmq/definitions.json index 8ddcd475..6b027b73 100644 --- a/infra/rabbitmq/definitions.json +++ b/infra/rabbitmq/definitions.json @@ -120,6 +120,16 @@ "x-dead-letter-routing-key": "dlq.ai.analyze.voice" } }, + { + "name": "ai.generate.tts", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": { + "x-dead-letter-exchange": "stackup.dlx", + "x-dead-letter-routing-key": "dlq.ai.generate.tts" + } + }, { "name": "core.callback.analysis", "vhost": "/", @@ -229,6 +239,13 @@ "auto_delete": false, "arguments": {} }, + { + "name": "dlq.ai.generate.tts", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, { "name": "dlq.core.callback.analysis", "vhost": "/", @@ -322,6 +339,13 @@ "destination_type": "queue", "routing_key": "analyze.voice" }, + { + "source": "stackup.core-to-ai", + "vhost": "/", + "destination": "ai.generate.tts", + "destination_type": "queue", + "routing_key": "generate.tts" + }, { "source": "stackup.ai-to-core", "vhost": "/", @@ -427,6 +451,13 @@ "destination_type": "queue", "routing_key": "dlq.ai.analyze.voice" }, + { + "source": "stackup.dlx", + "vhost": "/", + "destination": "dlq.ai.generate.tts", + "destination_type": "queue", + "routing_key": "dlq.ai.generate.tts" + }, { "source": "stackup.dlx", "vhost": "/",