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
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ public record Names(
@NotBlank String coreCallbackAnalysis,
@NotBlank String coreCallbackQuestions,
@NotBlank String coreCallbackFeedback,
@NotBlank String coreCallbackVoice
@NotBlank String coreCallbackVoice,
@NotBlank String coreCallbackTts
) {
}
}
Expand All @@ -88,6 +89,7 @@ public record RoutingKeyProperties(
@NotBlank String callbackQuestions,
@NotBlank String callbackFeedback,
@NotBlank String callbackVoice,
@NotBlank String callbackTts,
@NotBlank String realtimeSessionNotify,
@NotBlank String realtimeUserNotify,
@NotBlank String realtimeDocumentNotify
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ public Queue coreCallbackVoiceQueue() {
return workQueue(properties.queues().names().coreCallbackVoice());
}

@Bean
public Queue coreCallbackTtsQueue() {
return workQueue(properties.queues().names().coreCallbackTts());
}

@Bean
public Declarables rabbitDeclarables(
TopicExchange coreToAiExchange,
Expand All @@ -132,7 +137,8 @@ public Declarables rabbitDeclarables(
Queue coreCallbackAnalysisQueue,
Queue coreCallbackQuestionsQueue,
Queue coreCallbackFeedbackQueue,
Queue coreCallbackVoiceQueue
Queue coreCallbackVoiceQueue,
Queue coreCallbackTtsQueue
) {
Queue dlqAiAnalyzeResume = dlq(properties.queues().names().aiAnalyzeResume());
Queue dlqAiAnalyzeRepository = dlq(properties.queues().names().aiAnalyzeRepository());
Expand All @@ -144,6 +150,7 @@ public Declarables rabbitDeclarables(
Queue dlqCoreCallbackQuestions = dlq(properties.queues().names().coreCallbackQuestions());
Queue dlqCoreCallbackFeedback = dlq(properties.queues().names().coreCallbackFeedback());
Queue dlqCoreCallbackVoice = dlq(properties.queues().names().coreCallbackVoice());
Queue dlqCoreCallbackTts = dlq(properties.queues().names().coreCallbackTts());

return new Declarables(
coreToAiExchange,
Expand All @@ -160,6 +167,7 @@ public Declarables rabbitDeclarables(
coreCallbackQuestionsQueue,
coreCallbackFeedbackQueue,
coreCallbackVoiceQueue,
coreCallbackTtsQueue,
dlqAiAnalyzeResume,
dlqAiAnalyzeRepository,
dlqAiGenerateQuestions,
Expand All @@ -170,6 +178,7 @@ public Declarables rabbitDeclarables(
dlqCoreCallbackQuestions,
dlqCoreCallbackFeedback,
dlqCoreCallbackVoice,
dlqCoreCallbackTts,
BindingBuilder.bind(aiAnalyzeResumeQueue).to(coreToAiExchange).with(properties.routingKeys().analyzeResume()),
BindingBuilder.bind(aiAnalyzeRepositoryQueue).to(coreToAiExchange).with(properties.routingKeys().analyzeRepository()),
BindingBuilder.bind(aiGenerateQuestionsQueue).to(coreToAiExchange).with(properties.routingKeys().generateQuestions()),
Expand All @@ -180,6 +189,7 @@ public Declarables rabbitDeclarables(
BindingBuilder.bind(coreCallbackQuestionsQueue).to(aiToCoreExchange).with(properties.routingKeys().callbackQuestions()),
BindingBuilder.bind(coreCallbackFeedbackQueue).to(aiToCoreExchange).with(properties.routingKeys().callbackFeedback()),
BindingBuilder.bind(coreCallbackVoiceQueue).to(aiToCoreExchange).with(properties.routingKeys().callbackVoice()),
BindingBuilder.bind(coreCallbackTtsQueue).to(aiToCoreExchange).with(properties.routingKeys().callbackTts()),
BindingBuilder.bind(dlqAiAnalyzeResume).to(deadLetterExchange).with(dlqAiAnalyzeResume.getName()),
BindingBuilder.bind(dlqAiAnalyzeRepository).to(deadLetterExchange).with(dlqAiAnalyzeRepository.getName()),
BindingBuilder.bind(dlqAiGenerateQuestions).to(deadLetterExchange).with(dlqAiGenerateQuestions.getName()),
Expand All @@ -189,7 +199,8 @@ public Declarables rabbitDeclarables(
BindingBuilder.bind(dlqCoreCallbackAnalysis).to(deadLetterExchange).with(dlqCoreCallbackAnalysis.getName()),
BindingBuilder.bind(dlqCoreCallbackQuestions).to(deadLetterExchange).with(dlqCoreCallbackQuestions.getName()),
BindingBuilder.bind(dlqCoreCallbackFeedback).to(deadLetterExchange).with(dlqCoreCallbackFeedback.getName()),
BindingBuilder.bind(dlqCoreCallbackVoice).to(deadLetterExchange).with(dlqCoreCallbackVoice.getName())
BindingBuilder.bind(dlqCoreCallbackVoice).to(deadLetterExchange).with(dlqCoreCallbackVoice.getName()),
BindingBuilder.bind(dlqCoreCallbackTts).to(deadLetterExchange).with(dlqCoreCallbackTts.getName())
// 주의: q.realtime.session.notify 큐 + binding (+ DLQ) 은 RealTime 서버 측에서
// infra/rabbitmq/definitions.json 으로 import 되므로 Core 는 exchange 만 declare.
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package com.stackup.stackup.session.application;

import com.stackup.stackup.common.messaging.domain.ProcessedMessage;
import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository;
import com.stackup.stackup.common.sse.SseEventPublisher;
import com.stackup.stackup.common.sse.SseEventType;
import com.stackup.stackup.session.application.dto.TtsCallbackEnvelope;
import com.stackup.stackup.session.application.dto.TtsCallbackPayload;
import com.stackup.stackup.session.domain.InterviewMessage;
import com.stackup.stackup.session.domain.InterviewMessageRepository;
import com.stackup.stackup.session.domain.MessageRole;
import com.stackup.stackup.session.domain.TtsStatus;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TtsCallbackService {

private static final Logger log = LoggerFactory.getLogger(TtsCallbackService.class);
private static final String CONSUMER_NAME = "core.callback.tts";

private final InterviewMessageRepository messageRepository;
private final ProcessedMessageRepository processedMessageRepository;
private final SseEventPublisher sseEventPublisher;

@Transactional
public void apply(TtsCallbackEnvelope envelope) {
if (envelope == null || envelope.payload() == null) {
log.warn("callback.tts envelope or payload is null - skip");
return;
}
if (isProcessed(envelope.messageId())) {
log.info("callback.tts duplicate, skip. messageId={}", envelope.messageId());
return;
}

TtsCallbackPayload p = envelope.payload();
if (p.messageId() == null || p.sessionId() == null) {
log.warn("callback.tts missing sessionId or messageId. messageId={}", envelope.messageId());
markProcessed(envelope.messageId());
return;
}

InterviewMessage message = messageRepository.findById(p.messageId()).orElse(null);
if (message == null) {
log.warn("callback.tts message not found. id={}", p.messageId());
markProcessed(envelope.messageId());
return;
}
if (!p.sessionId().equals(message.getSession().getId()) || message.getRole() != MessageRole.INTERVIEWER) {
log.warn("callback.tts ignored non-question or mismatched message. sessionId={}, msg={}",
p.sessionId(), p.messageId());
markProcessed(envelope.messageId());
return;
}

if (p.isSucceeded()) {
if (p.audioKey() == null || p.audioKey().isBlank()) {
message.failTts();
publishNotice(message, "TTS_EMPTY_AUDIO");
log.warn("callback.tts success without audio key. sessionId={}, msg={}", p.sessionId(), p.messageId());
} else {
message.completeTts(p.audioKey(), p.durationSec());
publishNotice(message, null);
log.info("callback.tts processed. sessionId={}, msg={}, durationSec={}",
p.sessionId(), p.messageId(), p.durationSec());
}
} else if (p.isFailed()) {
message.failTts();
publishNotice(message, p.errorCode());
log.warn("callback.tts failed. sessionId={}, msg={}, code={}",
p.sessionId(), p.messageId(), p.errorCode());
} else {
log.warn("callback.tts unknown status={}. sessionId={}, msg={}",
p.status(), p.sessionId(), p.messageId());
}

markProcessed(envelope.messageId());
}

private void publishNotice(InterviewMessage message, String errorCode) {
Long sessionId = message.getSession().getId();
TtsUpdatedNotice notice = new TtsUpdatedNotice(
sessionId,
message.getId(),
message.getTtsStatus(),
message.getTtsAudioPath(),
message.getTtsDurationSec(),
errorCode
);
sseEventPublisher.publishToSession(sessionId, SseEventType.SESSION_MESSAGE, notice);
sseEventPublisher.publishToUser(message.getSession().getUser().getId(),
SseEventType.SESSION_MESSAGE, notice);
}

private boolean isProcessed(String messageId) {
if (messageId == null || messageId.isBlank()) {
return false;
}
return processedMessageRepository.existsById(messageId);
}

private void markProcessed(String messageId) {
if (messageId == null || messageId.isBlank()) {
return;
}
try {
processedMessageRepository.save(ProcessedMessage.of(messageId, CONSUMER_NAME));
} catch (DataIntegrityViolationException ignored) {
// race
}
}

public record TtsUpdatedNotice(
Long sessionId,
Long messageId,
TtsStatus status,
String audioPath,
Double durationSec,
String errorCode
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.stackup.stackup.session.domain.InterviewMessage;
import com.stackup.stackup.session.domain.MessageRole;
import com.stackup.stackup.session.domain.MessageStatus;
import com.stackup.stackup.session.domain.TtsStatus;
import java.time.Instant;

public record MessageResult(
Expand All @@ -12,6 +13,9 @@ public record MessageResult(
MessageRole role,
String content,
String audioFilePath,
String ttsAudioPath,
TtsStatus ttsStatus,
Double ttsDurationSec,
Long parentMessageId,
MessageStatus status,
Instant createdAt
Expand All @@ -24,6 +28,9 @@ public static MessageResult of(InterviewMessage m) {
m.getRole(),
m.getContent(),
m.getAudioFilePath(),
m.getTtsAudioPath(),
m.getTtsStatus(),
m.getTtsDurationSec(),
m.getParentMessage() == null ? null : m.getParentMessage().getId(),
m.getStatus(),
m.getCreatedAt()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.stackup.stackup.session.application.dto;

import com.stackup.stackup.common.messaging.MessageContext;
import java.time.Instant;

public record TtsCallbackEnvelope(
String messageId,
String messageType,
String version,
String traceId,
Instant publishedAt,
String publisher,
TtsCallbackPayload payload,
MessageContext context
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.stackup.stackup.session.application.dto;

import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonInclude;

@JsonInclude(JsonInclude.Include.NON_NULL)
public record TtsCallbackPayload(
Long sessionId,
Long messageId,
String status,
@JsonAlias({"audioPath", "audioUrl"})
String audioKey,
Double durationSec,
String errorCode
) {
public boolean isSucceeded() {
return "SUCCEEDED".equalsIgnoreCase(status) || "SUCCESS".equalsIgnoreCase(status);
}

public boolean isFailed() {
return "FAILED".equalsIgnoreCase(status) || (errorCode != null && !errorCode.isBlank());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ public class InterviewMessage extends BaseTimeEntity {
@Column(name = "audio_file_path", length = 1000)
private String audioFilePath;

@Column(name = "tts_audio_path", length = 1000)
private String ttsAudioPath;

@Column(name = "tts_status", nullable = false, length = 20)
@Enumerated(EnumType.STRING)
private TtsStatus ttsStatus = TtsStatus.NOT_REQUESTED;

@Column(name = "tts_duration_sec")
private Double ttsDurationSec;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_message_id")
private InterviewMessage parentMessage;
Expand Down Expand Up @@ -91,14 +101,18 @@ private InterviewMessage(InterviewSession session, Integer sequenceNumber, Messa
}

public static InterviewMessage interviewer(InterviewSession session, int seq, String content) {
return new InterviewMessage(session, seq, MessageRole.INTERVIEWER, content, null,
InterviewMessage m = new InterviewMessage(session, seq, MessageRole.INTERVIEWER, content, null,
MessageStatus.CREATED, null);
m.markTtsPending();
return m;
}

public static InterviewMessage followup(InterviewSession session, int seq, String content,
InterviewMessage parent) {
return new InterviewMessage(session, seq, MessageRole.INTERVIEWER, content, parent,
InterviewMessage m = new InterviewMessage(session, seq, MessageRole.INTERVIEWER, content, parent,
MessageStatus.CREATED, null);
m.markTtsPending();
return m;
}

public static InterviewMessage interviewee(InterviewSession session, int seq, String content,
Expand Down Expand Up @@ -126,6 +140,22 @@ public void attachAudio(String audioFilePath) {
this.audioFilePath = audioFilePath;
}

public void markTtsPending() {
this.ttsStatus = TtsStatus.PENDING;
}

public void completeTts(String ttsAudioPath, Double ttsDurationSec) {
if (ttsAudioPath != null && !ttsAudioPath.isBlank()) {
this.ttsAudioPath = ttsAudioPath;
}
this.ttsDurationSec = ttsDurationSec;
this.ttsStatus = TtsStatus.SUCCEEDED;
}

public void failTts() {
this.ttsStatus = TtsStatus.FAILED;
}

public void completeWithTranscript(String transcript) {
if (transcript != null && !transcript.isBlank()) {
this.content = transcript;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.stackup.stackup.session.domain;

public enum TtsStatus {
NOT_REQUESTED,
PENDING,
SUCCEEDED,
FAILED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.stackup.stackup.session.infrastructure;

import com.stackup.stackup.session.application.TtsCallbackService;
import com.stackup.stackup.session.application.dto.TtsCallbackEnvelope;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class TtsCallbackHandler {

private static final Logger log = LoggerFactory.getLogger(TtsCallbackHandler.class);

private final TtsCallbackService callbackService;

@RabbitListener(queues = "${app.messaging.rabbitmq.queues.names.core-callback-tts}")
public void handle(TtsCallbackEnvelope envelope) {
try {
callbackService.apply(envelope);
} catch (RuntimeException e) {
log.error("callback.tts processing failed. messageId={}",
envelope == null ? null : envelope.messageId(), e);
throw e;
}
}
}
Loading