From 57f5e484a092cc4a99481ec84f71cfde4acd47b9 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Mon, 1 Jun 2026 20:39:09 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=9D=8C=EC=84=B1=20=EB=8B=B5?= =?UTF-8?q?=EB=B3=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/exception/ApiErrorCode.java | 2 + .../application/VoiceAnswerUploadService.java | 11 +- .../VoiceAnswerUploadServiceTest.java | 238 ++++++++++++++++++ 3 files changed, 247 insertions(+), 4 deletions(-) create mode 100644 backend/src/test/java/com/stackup/stackup/session/application/VoiceAnswerUploadServiceTest.java diff --git a/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java index 27cf4431..18136f9b 100644 --- a/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java +++ b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java @@ -34,6 +34,8 @@ public enum ApiErrorCode { SESSION_NOT_FOUND(HttpStatus.NOT_FOUND, "세션을 찾을 수 없습니다."), SESSION_FORBIDDEN(HttpStatus.FORBIDDEN, "세션에 접근할 수 없습니다."), FEEDBACK_NOT_READY(HttpStatus.NOT_FOUND, "피드백이 아직 생성되지 않았습니다."), + VOICE_EMPTY_FILE(HttpStatus.BAD_REQUEST, "음성 파일을 업로드할 수 없습니다."), + VOICE_FILE_TOO_LARGE(HttpStatus.BAD_REQUEST, "음성 파일 크기가 너무 큽니다."), VOICE_INVALID_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 음성 형식입니다."), VOICE_MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "음성 메시지를 찾을 수 없습니다."), diff --git a/backend/src/main/java/com/stackup/stackup/session/application/VoiceAnswerUploadService.java b/backend/src/main/java/com/stackup/stackup/session/application/VoiceAnswerUploadService.java index a94dccfa..8cc83298 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/VoiceAnswerUploadService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/VoiceAnswerUploadService.java @@ -96,16 +96,19 @@ public MessageResult submit(Long userId, Long sessionId, VoiceAnswerUploadComman } private void validate(VoiceAnswerUploadCommand cmd) { - if (cmd.size() <= 0 || cmd.size() > MAX_BYTES) { - throw new DomainException(ApiErrorCode.RESUME_FILE_TOO_LARGE); + if (cmd == null || cmd.content() == null || cmd.size() <= 0) { + throw new DomainException(ApiErrorCode.VOICE_EMPTY_FILE); } - if (cmd.contentType() == null || !ALLOWED_CONTENT_TYPES.contains(cmd.contentType().toLowerCase())) { + if (cmd.size() > MAX_BYTES) { + throw new DomainException(ApiErrorCode.VOICE_FILE_TOO_LARGE); + } + if (cmd.contentType() == null || !ALLOWED_CONTENT_TYPES.contains(cmd.contentType().trim().toLowerCase())) { throw new DomainException(ApiErrorCode.VOICE_INVALID_CONTENT_TYPE); } } private static String buildKey(Long sessionId, Long messageId, String contentType) { - String ext = switch (contentType.toLowerCase()) { + String ext = switch (contentType.trim().toLowerCase()) { case "audio/webm" -> "webm"; case "audio/ogg" -> "ogg"; case "audio/mpeg" -> "mp3"; 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 new file mode 100644 index 00000000..d53a7f1c --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/application/VoiceAnswerUploadServiceTest.java @@ -0,0 +1,238 @@ +package com.stackup.stackup.session.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +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.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.common.messaging.MessageContext; +import com.stackup.stackup.common.messaging.RabbitMessagePublisher; +import com.stackup.stackup.common.storage.ObjectStorageClient; +import com.stackup.stackup.session.application.dto.AnalyzeVoicePayload; +import com.stackup.stackup.session.application.dto.MessageResult; +import com.stackup.stackup.session.application.dto.VoiceAnswerUploadCommand; +import com.stackup.stackup.session.domain.InterviewMessage; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.InterviewSession; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.MessageStatus; +import com.stackup.stackup.session.domain.SessionMode; +import com.stackup.stackup.user.domain.User; +import java.io.ByteArrayInputStream; +import java.time.Duration; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class VoiceAnswerUploadServiceTest { + + @Mock InterviewSessionRepository sessionRepository; + @Mock InterviewMessageRepository messageRepository; + @Mock ObjectStorageClient storage; + @Mock RabbitMessagePublisher publisher; + + VoiceAnswerUploadService service; + + @BeforeEach + void setUp() { + service = new VoiceAnswerUploadService( + sessionRepository, + messageRepository, + storage, + publisher, + rabbitMqProperties() + ); + } + + @Test + void submit_savesAudioAndPublishesAnalyzeVoice() { + InterviewSession session = sessionInProgress(10L); + InterviewMessage question = InterviewMessage.interviewer(session, 1, "Tell me about ACID."); + ReflectionTestUtils.setField(question, "id", 100L); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session)); + when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)).thenReturn(Optional.of(question)); + when(messageRepository.save(any(InterviewMessage.class))).thenAnswer(inv -> { + InterviewMessage m = inv.getArgument(0); + ReflectionTestUtils.setField(m, "id", 200L); + return m; + }); + + MessageResult result = service.submit(1L, 10L, + command("voice-bytes".getBytes(), "audio/webm", "idem-1")); + + assertThat(result.id()).isEqualTo(200L); + assertThat(result.status()).isEqualTo(MessageStatus.CREATED); + assertThat(result.audioFilePath()).isEqualTo("interview/voice/raw/10/200.webm"); + verify(storage).put(eq("interview/voice/raw/10/200.webm"), any(), eq(11L), eq("audio/webm")); + + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(AnalyzeVoicePayload.class); + verify(publisher).publishToAi(eq("analyze.voice"), payloadCaptor.capture(), any(MessageContext.class)); + AnalyzeVoicePayload payload = payloadCaptor.getValue(); + assertThat(payload.sessionId()).isEqualTo(10L); + assertThat(payload.messageId()).isEqualTo(200L); + assertThat(payload.parentQuestionMessageId()).isEqualTo(100L); + assertThat(payload.audioS3Key()).isEqualTo("interview/voice/raw/10/200.webm"); + assertThat(payload.previousQuestionText()).isEqualTo("Tell me about ACID."); + } + + @Test + void submit_rejectsEmptyAudio() { + assertThatThrownBy(() -> service.submit(1L, 10L, + command(new byte[0], "audio/webm", null))) + .isInstanceOfSatisfying(DomainException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ApiErrorCode.VOICE_EMPTY_FILE)); + + verify(sessionRepository, never()).findByIdAndUser_IdAndDeletedFalse(any(), any()); + } + + @Test + void submit_rejectsTooLargeAudio() { + VoiceAnswerUploadCommand cmd = new VoiceAnswerUploadCommand( + new ByteArrayInputStream(new byte[] {1}), + 25L * 1024 * 1024 + 1, + "audio/webm", + "a.webm", + null + ); + + assertThatThrownBy(() -> service.submit(1L, 10L, cmd)) + .isInstanceOfSatisfying(DomainException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ApiErrorCode.VOICE_FILE_TOO_LARGE)); + } + + @Test + void submit_rejectsUnsupportedContentType() { + assertThatThrownBy(() -> service.submit(1L, 10L, + command("x".getBytes(), "text/plain", null))) + .isInstanceOfSatisfying(DomainException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ApiErrorCode.VOICE_INVALID_CONTENT_TYPE)); + } + + @Test + void submit_rejectsWhenSessionIsNotInProgress() { + InterviewSession session = sessionFixture(10L); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session)); + + assertThatThrownBy(() -> service.submit(1L, 10L, + command("voice".getBytes(), "audio/webm", null))) + .isInstanceOfSatisfying(DomainException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ApiErrorCode.SESSION_INVALID_STATE)); + + verify(storage, never()).put(any(), any(), anyLong(), any()); + verify(publisher, never()).publishToAi(any(), any(), any()); + } + + @Test + void submit_rejectsWhenNoQuestionMessageExists() { + InterviewSession session = sessionInProgress(10L); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session)); + when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.submit(1L, 10L, + command("voice".getBytes(), "audio/webm", null))) + .isInstanceOfSatisfying(DomainException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ApiErrorCode.SESSION_INVALID_STATE)); + + verify(storage, never()).put(any(), any(), anyLong(), any()); + verify(publisher, never()).publishToAi(any(), any(), any()); + } + + @Test + void submit_rejectsWhenLastMessageIsNotQuestion() { + InterviewSession session = sessionInProgress(10L); + InterviewMessage priorAnswer = InterviewMessage.interviewee(session, 1, "already answered", null, null); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session)); + when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)).thenReturn(Optional.of(priorAnswer)); + + assertThatThrownBy(() -> service.submit(1L, 10L, + command("voice".getBytes(), "audio/webm", null))) + .isInstanceOfSatisfying(DomainException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ApiErrorCode.SESSION_INVALID_STATE)); + + verify(storage, never()).put(any(), any(), anyLong(), any()); + verify(publisher, never()).publishToAi(any(), any(), any()); + } + + private VoiceAnswerUploadCommand command(byte[] content, String contentType, String idempotencyKey) { + return new VoiceAnswerUploadCommand( + new ByteArrayInputStream(content), + content.length, + contentType, + "answer.webm", + idempotencyKey + ); + } + + private InterviewSession sessionInProgress(Long id) { + InterviewSession s = sessionFixture(id); + s.start(); + return s; + } + + private InterviewSession sessionFixture(Long id) { + User user = User.createGithubUser(1L, "u", null, null, "t"); + ReflectionTestUtils.setField(user, "id", 1L); + InterviewSession s = InterviewSession.create( + user, "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30 + ); + ReflectionTestUtils.setField(s, "id", id); + return s; + } + + private RabbitMqProperties rabbitMqProperties() { + return new RabbitMqProperties( + "core", + "1", + new RabbitMqProperties.Message("application/json", "UTF-8", "X-Trace-Id"), + new RabbitMqProperties.Template(true), + new RabbitMqProperties.Exchanges(true, false, + new RabbitMqProperties.Exchanges.Names("core.ai", "ai.core", "realtime")), + new RabbitMqProperties.Queues(true, + new RabbitMqProperties.Queues.Names( + "ai.analyze.resume", + "ai.analyze.repository", + "ai.generate.questions", + "ai.generate.followup", + "ai.generate.feedback", + "ai.analyze.voice", + "core.callback.analysis", + "core.callback.questions", + "core.callback.feedback", + "core.callback.voice" + )), + new RabbitMqProperties.RoutingKeyProperties( + "analyze.resume", + "analyze.repository", + "generate.questions", + "generate.followup", + "generate.feedback", + "analyze.voice", + "callback.analysis", + "callback.questions", + "callback.feedback", + "callback.voice", + "session.notify" + ), + new RabbitMqProperties.DeadLetter("dlx", "dlq."), + new RabbitMqProperties.Retry(3, Duration.ofSeconds(1), 2.0, Duration.ofSeconds(10)) + ); + } +} From 755b866320e6239105aa09b1b09399b1032f06e3 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Mon, 1 Jun 2026 20:40:33 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20STT=20=EC=8B=A4=ED=8C=A8=EC=8B=9C?= =?UTF-8?q?=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=ED=9D=90=EB=A6=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/InterviewMessageService.java | 25 ++++++++++++---- .../session/domain/InterviewMessage.java | 12 +++++++- .../InterviewMessageServiceTest.java | 30 +++++++++++++++++++ 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java b/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java index 44b06662..58e9944e 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java @@ -8,6 +8,8 @@ import com.stackup.stackup.session.domain.InterviewMessageRepository; import com.stackup.stackup.session.domain.InterviewSession; import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.MessageRole; +import com.stackup.stackup.session.domain.MessageStatus; import com.stackup.stackup.session.domain.SessionStatus; import java.util.List; import lombok.RequiredArgsConstructor; @@ -51,22 +53,33 @@ public MessageResult submitAnswer(Long userId, Long sessionId, String content, S InterviewMessage latest = messageRepository .findFirstBySession_IdOrderBySequenceNumberDesc(sessionId) .orElseThrow(() -> new DomainException(ApiErrorCode.SESSION_INVALID_STATE)); - if (latest.getRole() != com.stackup.stackup.session.domain.MessageRole.INTERVIEWER) { - // 직전 메시지가 질문이 아니면 답변 불가 - throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE); - } + InterviewMessage parentQuestion = resolveAnswerParent(latest); int nextSeq = latest.getSequenceNumber() + 1; InterviewMessage answer = messageRepository.save( - InterviewMessage.interviewee(session, nextSeq, content, latest, + InterviewMessage.interviewee(session, nextSeq, content, parentQuestion, idempotencyKey != null && !idempotencyKey.isBlank() ? idempotencyKey : null) ); events.publishEvent(new AnswerSubmittedEvent( - userId, sessionId, latest.getId(), answer.getId() + userId, sessionId, parentQuestion.getId(), answer.getId() )); return MessageResult.of(answer); } + private InterviewMessage resolveAnswerParent(InterviewMessage latest) { + if (latest.getRole() == MessageRole.INTERVIEWER) { + return latest; + } + if (latest.getRole() == MessageRole.INTERVIEWEE + && latest.getStatus() == MessageStatus.FAILED + && latest.getAudioFilePath() != null + && latest.getParentMessage() != null + && latest.getParentMessage().getRole() == MessageRole.INTERVIEWER) { + return latest.getParentMessage(); + } + throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE); + } + private InterviewSession ownedSession(Long userId, Long sessionId) { return sessionRepository.findByIdAndUser_IdAndDeletedFalse(sessionId, userId) .orElseThrow(() -> new DomainException(ApiErrorCode.SESSION_NOT_FOUND)); diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java index 93f25ec3..f36e9ce7 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java @@ -33,6 +33,10 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class InterviewMessage extends BaseTimeEntity { + public static final String VOICE_TRANSCRIPTION_PENDING_TEXT = "(transcribing)"; + public static final String VOICE_TRANSCRIPTION_FAILED_TEXT = + "음성 인식에 실패했습니다. 텍스트로 다시 답변해 주세요."; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -106,7 +110,8 @@ public static InterviewMessage interviewee(InterviewSession session, int seq, St // 음성 답변 placeholder. content 는 STT 완료 후 채움. 생성 시 CHECK 제약 만족을 위해 임시 공백 + audio_file_path 후속 갱신. public static InterviewMessage voiceInterviewee(InterviewSession session, int seq, InterviewMessage parent, String idempotencyKey) { - InterviewMessage m = new InterviewMessage(session, seq, MessageRole.INTERVIEWEE, "(transcribing)", + InterviewMessage m = new InterviewMessage(session, seq, MessageRole.INTERVIEWEE, + VOICE_TRANSCRIPTION_PENDING_TEXT, parent, MessageStatus.CREATED, idempotencyKey); return m; } @@ -127,4 +132,9 @@ public void completeWithTranscript(String transcript) { } this.status = MessageStatus.COMPLETED; } + + public void failVoiceTranscription() { + this.content = VOICE_TRANSCRIPTION_FAILED_TEXT; + this.status = MessageStatus.FAILED; + } } diff --git a/backend/src/test/java/com/stackup/stackup/session/application/InterviewMessageServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/InterviewMessageServiceTest.java index ea421192..11cf1663 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/InterviewMessageServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/InterviewMessageServiceTest.java @@ -20,6 +20,7 @@ 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; @@ -84,6 +85,35 @@ void submitAnswer_rejectsWhenPreviousMessageNotInterviewer() { .isInstanceOf(DomainException.class); } + @Test + void submitAnswer_allowsTextRetryAfterFailedVoiceAnswer() { + InterviewSession session = sessionInProgress(10L); + InterviewMessage question = InterviewMessage.interviewer(session, 1, "Q1?"); + ReflectionTestUtils.setField(question, "id", 100L); + InterviewMessage failedVoice = InterviewMessage.voiceInterviewee(session, 2, question, null); + ReflectionTestUtils.setField(failedVoice, "id", 200L); + failedVoice.attachAudio("interview/voice/raw/10/200.webm"); + failedVoice.failVoiceTranscription(); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session)); + when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)).thenReturn(Optional.of(failedVoice)); + when(messageRepository.save(any(InterviewMessage.class))).thenAnswer(inv -> { + InterviewMessage m = inv.getArgument(0); + ReflectionTestUtils.setField(m, "id", 201L); + return m; + }); + + MessageResult result = service.submitAnswer(1L, 10L, "retry as text", null); + + assertThat(result.id()).isEqualTo(201L); + assertThat(result.sequenceNumber()).isEqualTo(3); + assertThat(result.parentMessageId()).isEqualTo(100L); + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(AnswerSubmittedEvent.class); + verify(events).publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue().parentQuestionMessageId()).isEqualTo(100L); + assertThat(eventCaptor.getValue().answerMessageId()).isEqualTo(201L); + } + private InterviewSession sessionInProgress(Long id) { User user = User.createGithubUser(1L, "u", null, null, "t"); ReflectionTestUtils.setField(user, "id", 1L); From d24d49b742d109e76239fe20959491d304e2dafb Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Mon, 1 Jun 2026 20:41:37 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=EC=9D=8C=EC=84=B1=20=EC=BD=9C?= =?UTF-8?q?=EB=B0=B1=20=EC=84=B1=EA=B3=B5=20=EC=8B=A4=ED=8C=A8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/VoiceCallbackService.java | 27 +++++++++++++++---- .../application/dto/MessageResult.java | 2 ++ .../presentation/dto/MessageResponse.java | 2 ++ .../application/VoiceCallbackServiceTest.java | 25 +++++++++++++++++ 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/backend/src/main/java/com/stackup/stackup/session/application/VoiceCallbackService.java b/backend/src/main/java/com/stackup/stackup/session/application/VoiceCallbackService.java index 407909f6..cc5e06b9 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/VoiceCallbackService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/VoiceCallbackService.java @@ -11,7 +11,6 @@ import com.stackup.stackup.session.application.event.AnswerSubmittedEvent; import com.stackup.stackup.session.domain.InterviewMessage; import com.stackup.stackup.session.domain.InterviewMessageRepository; -import com.stackup.stackup.session.domain.MessageStatus; import com.stackup.stackup.session.domain.MessageVoiceAnalysis; import com.stackup.stackup.session.domain.MessageVoiceAnalysisRepository; import lombok.RequiredArgsConstructor; @@ -64,14 +63,19 @@ public void apply(VoiceCallbackEnvelope envelope) { } if (p.errorCode() != null && !p.errorCode().isBlank()) { - message.markStatus(MessageStatus.FAILED); - sseEventPublisher.publishToSession(p.sessionId(), SseEventType.SESSION_MESSAGE, - new VoiceFailedNotice(p.sessionId(), message.getId(), p.errorCode())); + failVoiceMessage(p.sessionId(), message, p.errorCode()); markProcessed(envelope.messageId()); log.warn("callback.voice STT failed. sessionId={}, msg={}, code={}", p.sessionId(), message.getId(), p.errorCode()); return; } + if (p.transcript() == null || p.transcript().isBlank()) { + failVoiceMessage(p.sessionId(), message, "STT_EMPTY_TRANSCRIPT"); + markProcessed(envelope.messageId()); + log.warn("callback.voice STT empty transcript. sessionId={}, msg={}", + p.sessionId(), message.getId()); + return; + } message.completeWithTranscript(p.transcript()); @@ -113,7 +117,20 @@ public void apply(VoiceCallbackEnvelope envelope) { public record VoiceTranscribedNotice(Long sessionId, Long messageId, String transcript) { } - public record VoiceFailedNotice(Long sessionId, Long messageId, String errorCode) { + public record VoiceFailedNotice(Long sessionId, Long messageId, String errorCode, String fallbackText) { + } + + private void failVoiceMessage(Long sessionId, InterviewMessage message, String errorCode) { + message.failVoiceTranscription(); + VoiceFailedNotice notice = new VoiceFailedNotice( + sessionId, + message.getId(), + errorCode, + message.getContent() + ); + sseEventPublisher.publishToSession(sessionId, SseEventType.SESSION_MESSAGE, notice); + sseEventPublisher.publishToUser(message.getSession().getUser().getId(), + SseEventType.SESSION_MESSAGE, notice); } private String fillerToJson(java.util.Map map) { diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java index 2da1258f..80a2394a 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java @@ -11,6 +11,7 @@ public record MessageResult( Integer sequenceNumber, MessageRole role, String content, + String audioFilePath, Long parentMessageId, MessageStatus status, Instant createdAt @@ -22,6 +23,7 @@ public static MessageResult of(InterviewMessage m) { m.getSequenceNumber(), m.getRole(), m.getContent(), + m.getAudioFilePath(), m.getParentMessage() == null ? null : m.getParentMessage().getId(), m.getStatus(), m.getCreatedAt() diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageResponse.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageResponse.java index 0dc1b4e2..d62270c5 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageResponse.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageResponse.java @@ -11,6 +11,7 @@ public record MessageResponse( Integer sequenceNumber, MessageRole role, String content, + String audioFilePath, Long parentMessageId, MessageStatus status, Instant createdAt @@ -22,6 +23,7 @@ public static MessageResponse from(MessageResult r) { r.sequenceNumber(), r.role(), r.content(), + r.audioFilePath(), r.parentMessageId(), r.status(), r.createdAt() diff --git a/backend/src/test/java/com/stackup/stackup/session/application/VoiceCallbackServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/VoiceCallbackServiceTest.java index 825af1c5..73f1bf3e 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/VoiceCallbackServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/VoiceCallbackServiceTest.java @@ -86,6 +86,31 @@ void apply_marksMessageFailedOnErrorCode() { service.apply(env); assertThat(voiceMsg.getStatus()).isEqualTo(MessageStatus.FAILED); + assertThat(voiceMsg.getContent()).isEqualTo(InterviewMessage.VOICE_TRANSCRIPTION_FAILED_TEXT); + verify(voiceAnalysisRepository, never()).save(any(MessageVoiceAnalysis.class)); + verify(events, never()).publishEvent(any(AnswerSubmittedEvent.class)); + verify(sseEventPublisher).publishToSession(eq(50L), eq(SseEventType.SESSION_MESSAGE), any()); + verify(sseEventPublisher).publishToUser(eq(1L), eq(SseEventType.SESSION_MESSAGE), any()); + } + + @Test + void apply_marksMessageFailedOnBlankTranscript() { + InterviewSession session = sessionFixture(50L); + InterviewMessage voiceMsg = InterviewMessage.voiceInterviewee(session, 2, null, null); + ReflectionTestUtils.setField(voiceMsg, "id", 200L); + + VoiceCallbackPayload payload = new VoiceCallbackPayload(50L, 200L, " ", 120.0, 1.0, + Map.of(), 0.8, null); + VoiceCallbackEnvelope env = new VoiceCallbackEnvelope("vc-blank", "callback.voice", "1", "t", + null, "ai", payload, null); + + when(processedMessageRepository.existsById("vc-blank")).thenReturn(false); + when(messageRepository.findById(200L)).thenReturn(Optional.of(voiceMsg)); + + service.apply(env); + + assertThat(voiceMsg.getStatus()).isEqualTo(MessageStatus.FAILED); + assertThat(voiceMsg.getContent()).isEqualTo(InterviewMessage.VOICE_TRANSCRIPTION_FAILED_TEXT); verify(voiceAnalysisRepository, never()).save(any(MessageVoiceAnalysis.class)); verify(events, never()).publishEvent(any(AnswerSubmittedEvent.class)); }