From 885fc34d6ff61a0418e54f1fee4e489d9f890aec Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 23 Sep 2026 17:32:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(session):=20STT=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=ED=95=9C=20=EC=9D=8C=EC=84=B1=20=EB=8B=B5=EB=B3=80=20=EC=9E=AC?= =?UTF-8?q?=EC=A0=84=EC=82=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STT 가 실패하면 답변이 "음성 인식에 실패했습니다" 로 확정되고 끝이었다. 오디오는 S3 에 그대로 남아 있는데 꺼낼 경로가 없어서, 사용자는 방금 말한 답변을 통째로 다시 입력해야 했다. 실패의 대부분은 Deepgram 이 간헐적으로 멎는 것이고 같은 파일을 재전송하면 대체로 전사된다(PR #244 의 자동 재시도가 1차 방어선). 이건 그마저 소진됐을 때 사용자가 직접 당기는 2차 방어선이다. - POST /api/sessions/{sid}/messages/voice/{mid}/retranscribe (VoiceRetranscribeService) — placeholder 상태로 되돌리고 업로드와 같은 VoiceAnswerUploadedEvent + AFTER_COMMIT 경로로 analyze.voice 재발행 - 실패 메시지가 세션의 마지막일 때만 허용. 이미 다시 답변했다면 뒤늦은 전사가 지나간 턴을 덮어쓴다(VOICE_RETRANSCRIBE_NOT_ALLOWED) - content_type 은 저장하지 않으므로 S3 key 확장자에서 역산(buildKey 의 역함수) - 프론트: 스테이지 인라인 배너 + 대화 기록 드로어 양쪽에 '다시 인식하기'. 스테이지에는 실패 안내가 아예 없어서, 사용자는 왜 답변이 사라졌는지 모른 채 컴포저만 다시 열리는 걸 봐야 했다 - 녹음이 남아 있을 때는 문구도 "다시 답변해 주세요" 대신 "녹음은 남아 있어요" 로 --- backend/CLAUDE.md | 12 + backend/openapi.json | 67 ++++++ .../common/exception/ApiErrorCode.java | 2 + .../application/VoiceRetranscribeService.java | 93 ++++++++ .../session/domain/InterviewMessage.java | 9 + .../presentation/VoiceAnswerController.java | 25 +++ .../VoiceRetranscribeServiceTest.java | 207 ++++++++++++++++++ .../src/features/interview/api/messageApi.ts | 13 ++ .../interview/model/useLiveInterview.ts | 24 +- .../live/AnswerBubble.retranscribe.test.tsx | 104 +++++++++ .../interview/ui/live/AnswerBubble.tsx | 46 +++- .../interview/ui/live/ConversationThread.tsx | 19 +- .../ui/live/InterviewStage.pause.test.tsx | 2 + .../interview/ui/live/InterviewStage.tsx | 33 +++ .../interview/ui/live/LiveInterview.tsx | 4 + .../interview/ui/live/TranscriptDrawer.tsx | 12 +- frontend/src/shared/api/generated.ts | 70 ++++++ 17 files changed, 729 insertions(+), 13 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/VoiceRetranscribeService.java create mode 100644 backend/src/test/java/com/stackup/stackup/session/application/VoiceRetranscribeServiceTest.java create mode 100644 frontend/src/features/interview/ui/live/AnswerBubble.retranscribe.test.tsx diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 30d41640..a738cd65 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -552,6 +552,18 @@ docker compose up -d (`STT_CALLBACK_TIMEOUT`). 그러면 기존 STT 실패 경로를 그대로 타서 사용자가 같은 질문에 텍스트로 다시 답할 수 있다. 목록 생성 후 콜백이 도착한 경우를 위해 확정 직전 상태를 다시 확인한다(완료된 답변을 실패로 되돌리지 않는다). +- **STT 실패 재전사 본 구현**: STT 가 실패하면 `failVoiceTranscription()` 이 content 를 실패 + 문구로 덮고 FAILED 로 확정해 턴을 풀어 줬는데, **오디오는 S3 에 그대로 남아 있는데 꺼낼 + 경로가 없어** 사용자가 답변을 통째로 다시 입력해야 했다. 실패의 대부분은 Deepgram 이 + 간헐적으로 멎는 것이고 같은 파일을 재전송하면 대체로 전사된다(AI 서버의 자동 재시도가 + 1차 방어선 — `ai/CLAUDE.md §8`). `POST /api/sessions/{sid}/messages/voice/{mid}/retranscribe` + (`VoiceRetranscribeService`)가 2차 방어선: `retryVoiceTranscription()` 으로 placeholder 상태로 + 되돌리고 업로드와 **같은** `VoiceAnswerUploadedEvent` + AFTER_COMMIT 경로로 `analyze.voice` 를 + 재발행한다(content_type 은 저장하지 않으므로 S3 key 확장자에서 역산 — `buildKey` 의 역함수). + **실패 메시지가 세션의 마지막일 때만 허용** — 이미 다시 답변했다면 뒤늦은 전사가 지나간 턴을 + 덮어쓴다(`VOICE_RETRANSCRIBE_NOT_ALLOWED`). 프론트는 스테이지 인라인 배너 + 대화 기록 드로어 + 양쪽에 '다시 인식하기' 를 낸다(스테이지엔 실패 안내가 아예 없어서 사용자가 왜 답변이 사라졌는지 + 모른 채 다시 말해야 했다). - **Spring AI 미사용** — LLM·임베딩 호출은 모두 AI 서버 위임. Core는 RabbitMQ 발행만 담당. - **Redis 미사용** — 휘발성 데이터는 DB short-lived 레코드 또는 인메모리로. diff --git a/backend/openapi.json b/backend/openapi.json index 25e0dfcb..6f037d3b 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -742,6 +742,73 @@ } } }, + "/api/sessions/{sessionId}/messages/voice/{messageId}/retranscribe" : { + "post" : { + "tags" : [ "Voice Answer" ], + "summary" : "STT 실패한 음성 답변 재전사", + "description" : "S3 에 남아 있는 같은 오디오로 STT 를 다시 요청한다. 답변을 다시 입력하지 않아도 되게 하는 경로. 실패한 음성 답변이 세션의 마지막 메시지일 때만 허용 — 이미 다시 답변했다면 뒤늦은 전사가 지나간 턴을 덮어쓴다.", + "operationId" : "retranscribeVoiceAnswer", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + }, { + "name" : "messageId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "transcribing 으로 되돌리고 analyze.voice 재발행", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + }, + "404" : { + "description" : "세션/메시지 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + }, + "422" : { + "description" : "재전사 대상이 아니거나 세션이 IN_PROGRESS 아님", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + } + } + } + }, "/api/sessions/{sessionId}/messages/voice/stream-begin" : { "post" : { "tags" : [ "Voice Answer" ], 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 5c46d5e4..0425f72a 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 @@ -55,6 +55,8 @@ public enum ApiErrorCode { VOICE_INVALID_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 음성 형식입니다."), VOICE_MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "음성 메시지를 찾을 수 없습니다."), VOICE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "음성 파일 업로드에 실패했습니다."), + VOICE_RETRANSCRIBE_NOT_ALLOWED(HttpStatus.UNPROCESSABLE_ENTITY, + "다시 인식할 수 있는 음성 답변이 아닙니다."), MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "메시지를 찾을 수 없습니다."), MESSAGE_NOT_BOOKMARKABLE(HttpStatus.UNPROCESSABLE_ENTITY, "질문만 오답노트에 담을 수 있습니다."), diff --git a/backend/src/main/java/com/stackup/stackup/session/application/VoiceRetranscribeService.java b/backend/src/main/java/com/stackup/stackup/session/application/VoiceRetranscribeService.java new file mode 100644 index 00000000..4ff875eb --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/VoiceRetranscribeService.java @@ -0,0 +1,93 @@ +package com.stackup.stackup.session.application; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.session.application.dto.MessageResult; +import com.stackup.stackup.session.application.event.VoiceAnswerUploadedEvent; +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.MessageRole; +import com.stackup.stackup.session.domain.MessageStatus; +import com.stackup.stackup.session.domain.SessionStatus; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +// STT 실패한 음성 답변을 같은 오디오로 다시 전사한다. +// +// 오디오는 S3 에 남아 있는데 꺼낼 경로가 없어서, 지금까지는 STT 가 한 번 실패하면 사용자가 +// 답변을 통째로 다시 입력해야 했다. 실패의 대부분은 Deepgram 이 간헐적으로 멎는 것이고 +// 같은 파일을 재전송하면 대체로 전사되므로(AI 서버의 자동 재시도가 1차 방어선), 그마저 +// 소진됐을 때 사용자가 직접 당길 수 있는 2차 방어선을 둔다. +// +// 발행은 업로드 경로와 같은 VoiceAnswerUploadedEvent + AFTER_COMMIT(VoiceAnalysisRequester) — +// 이 트랜잭션이 롤백되면 AI 호출도 일어나지 않는다. +@Service +@RequiredArgsConstructor +public class VoiceRetranscribeService { + + private static final Logger log = LoggerFactory.getLogger(VoiceRetranscribeService.class); + + private final InterviewSessionRepository sessionRepository; + private final InterviewMessageRepository messageRepository; + private final ApplicationEventPublisher events; + + @Transactional + public MessageResult retranscribe(Long userId, Long sessionId, Long messageId) { + InterviewSession session = sessionRepository + .findByIdAndUser_IdAndDeletedFalse(sessionId, userId) + .orElseThrow(() -> new DomainException(ApiErrorCode.SESSION_NOT_FOUND)); + if (session.getStatus() != SessionStatus.IN_PROGRESS) { + throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE); + } + + InterviewMessage message = messageRepository.findById(messageId) + .orElseThrow(() -> new DomainException(ApiErrorCode.VOICE_MESSAGE_NOT_FOUND)); + if (!message.getSession().getId().equals(sessionId)) { + throw new DomainException(ApiErrorCode.VOICE_MESSAGE_NOT_FOUND); + } + if (message.getRole() != MessageRole.INTERVIEWEE + || message.getStatus() != MessageStatus.FAILED + || message.getAudioFilePath() == null) { + throw new DomainException(ApiErrorCode.VOICE_RETRANSCRIBE_NOT_ALLOWED); + } + + // 실패 뒤 이미 다시 답변했다면 그 답변이 이 턴의 진짜 답이다. 여기서 전사를 + // 되살리면 뒤늦은 콜백이 지나간 턴의 내용을 덮어쓴다 — 마지막 메시지일 때만 허용. + InterviewMessage latest = messageRepository + .findFirstBySession_IdOrderBySequenceNumberDesc(sessionId) + .orElseThrow(() -> new DomainException(ApiErrorCode.SESSION_INVALID_STATE)); + if (!latest.getId().equals(messageId)) { + throw new DomainException(ApiErrorCode.VOICE_RETRANSCRIBE_NOT_ALLOWED); + } + + message.retryVoiceTranscription(); + events.publishEvent(new VoiceAnswerUploadedEvent( + userId, sessionId, messageId, + message.getAudioFilePath(), contentTypeOf(message.getAudioFilePath()))); + log.info("voice retranscribe requested. sessionId={}, messageId={}, key={}", + sessionId, messageId, message.getAudioFilePath()); + return MessageResult.of(message); + } + + // content_type 은 저장하지 않으므로 업로드 때 확장자로 인코딩한 정보를 되읽는다 + // (VoiceAnswerSubmitService.buildKey 의 역함수). 알 수 없으면 Deepgram 이 + // 컨테이너를 스스로 판별하도록 octet-stream 으로 넘긴다. + private static String contentTypeOf(String audioS3Key) { + int dot = audioS3Key.lastIndexOf('.'); + String ext = dot < 0 ? "" : audioS3Key.substring(dot + 1).toLowerCase(); + return switch (ext) { + case "webm" -> "audio/webm"; + case "ogg" -> "audio/ogg"; + case "mp3" -> "audio/mpeg"; + case "m4a" -> "audio/mp4"; + case "wav" -> "audio/wav"; + default -> "application/octet-stream"; + }; + } +} 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 c2f7cf58..6b668992 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 @@ -289,6 +289,15 @@ public void failVoiceTranscription() { this.status = MessageStatus.FAILED; } + // STT 실패를 되돌려 같은 오디오로 다시 전사한다. 오디오는 S3 에 그대로 있으므로 + // 실패는 영구적일 이유가 없다 — Deepgram 이 간헐적으로 멎는 것이 실패의 대부분이고, + // 같은 파일을 재전송하면 대체로 전사된다(voice_consumer 의 자동 재시도가 1차 방어선, + // 이건 그마저 소진됐을 때 사용자가 직접 당기는 2차 방어선). + public void retryVoiceTranscription() { + this.content = VOICE_TRANSCRIPTION_PENDING_TEXT; + this.status = MessageStatus.CREATED; + } + // 꼬리질문 placeholder 를 AI 생성 실패로 확정. 삭제하지 않고 실패 사실을 보여준 뒤 // 세션은 다음 일반질문으로 넘어간다(QuestionsCallbackService.applyFollowupFailed). public void failFollowup() { diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/VoiceAnswerController.java b/backend/src/main/java/com/stackup/stackup/session/presentation/VoiceAnswerController.java index d6fb30d7..0f08ab0b 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/VoiceAnswerController.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/VoiceAnswerController.java @@ -2,6 +2,7 @@ import com.stackup.stackup.common.security.UserPrincipal; import com.stackup.stackup.session.application.VoiceAnswerSubmitService; +import com.stackup.stackup.session.application.VoiceRetranscribeService; import com.stackup.stackup.session.application.VoiceStreamService; import com.stackup.stackup.session.application.dto.MessageResult; import com.stackup.stackup.session.application.dto.VoiceAnswerUploadCommand; @@ -32,6 +33,7 @@ public class VoiceAnswerController { private final VoiceAnswerSubmitService submitService; private final VoiceStreamService streamService; + private final VoiceRetranscribeService retranscribeService; @Operation( operationId = "submitVoiceAnswer", @@ -85,4 +87,27 @@ public VoiceStreamBeginResponse beginStream( ) { return VoiceStreamBeginResponse.from(streamService.begin(principal.userId(), sessionId, idempotencyKey)); } + + @Operation( + operationId = "retranscribeVoiceAnswer", + summary = "STT 실패한 음성 답변 재전사", + description = "S3 에 남아 있는 같은 오디오로 STT 를 다시 요청한다. 답변을 다시 입력하지 " + + "않아도 되게 하는 경로. 실패한 음성 답변이 세션의 마지막 메시지일 때만 허용 — " + + "이미 다시 답변했다면 뒤늦은 전사가 지나간 턴을 덮어쓴다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "transcribing 으로 되돌리고 analyze.voice 재발행"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "세션/메시지 없음"), + @ApiResponse(responseCode = "422", description = "재전사 대상이 아니거나 세션이 IN_PROGRESS 아님") + }) + @PostMapping("/{messageId}/retranscribe") + public MessageResponse retranscribe( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long sessionId, + @PathVariable Long messageId + ) { + return MessageResponse.from( + retranscribeService.retranscribe(principal.userId(), sessionId, messageId)); + } } diff --git a/backend/src/test/java/com/stackup/stackup/session/application/VoiceRetranscribeServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/VoiceRetranscribeServiceTest.java new file mode 100644 index 00000000..5a69ee99 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/application/VoiceRetranscribeServiceTest.java @@ -0,0 +1,207 @@ +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.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.session.application.dto.MessageResult; +import com.stackup.stackup.session.application.event.VoiceAnswerUploadedEvent; +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.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; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class VoiceRetranscribeServiceTest { + + private static final String KEY = "interview/voice/raw/10/200.webm"; + + @Mock InterviewSessionRepository sessionRepository; + @Mock InterviewMessageRepository messageRepository; + @Mock ApplicationEventPublisher events; + @InjectMocks VoiceRetranscribeService service; + + @Test + void retranscribe_resetsToTranscribingAndRepublishes() { + InterviewSession session = sessionInProgress(10L); + InterviewMessage failed = failedVoiceAnswer(session, KEY); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)) + .thenReturn(Optional.of(session)); + when(messageRepository.findById(200L)).thenReturn(Optional.of(failed)); + when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)) + .thenReturn(Optional.of(failed)); + + MessageResult result = service.retranscribe(1L, 10L, 200L); + + assertThat(failed.getStatus()).isEqualTo(MessageStatus.CREATED); + assertThat(failed.getContent()) + .isEqualTo(InterviewMessage.VOICE_TRANSCRIPTION_PENDING_TEXT); + assertThat(result.content()).isEqualTo(InterviewMessage.VOICE_TRANSCRIPTION_PENDING_TEXT); + // 오디오는 지우지 않는다 — 같은 파일로 다시 전사하는 것이 이 기능의 전부다. + assertThat(failed.getAudioFilePath()).isEqualTo(KEY); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(VoiceAnswerUploadedEvent.class); + verify(events).publishEvent(captor.capture()); + assertThat(captor.getValue().messageId()).isEqualTo(200L); + assertThat(captor.getValue().audioS3Key()).isEqualTo(KEY); + assertThat(captor.getValue().contentType()).isEqualTo("audio/webm"); + } + + @Test + void retranscribe_derivesContentTypeFromKeyExtension() { + InterviewSession session = sessionInProgress(10L); + InterviewMessage failed = failedVoiceAnswer(session, "interview/voice/raw/10/200.wav"); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)) + .thenReturn(Optional.of(session)); + when(messageRepository.findById(200L)).thenReturn(Optional.of(failed)); + when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)) + .thenReturn(Optional.of(failed)); + + service.retranscribe(1L, 10L, 200L); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(VoiceAnswerUploadedEvent.class); + verify(events).publishEvent(captor.capture()); + assertThat(captor.getValue().contentType()).isEqualTo("audio/wav"); + } + + @Test + void retranscribe_rejectsWhenAlreadyAnsweredAgain() { + // 실패 뒤 다시 답변했다면 그 답변이 이 턴의 진짜 답이다. 여기서 전사를 되살리면 + // 뒤늦은 콜백이 지나간 턴을 덮어쓴다. + InterviewSession session = sessionInProgress(10L); + InterviewMessage failed = failedVoiceAnswer(session, KEY); + InterviewMessage retyped = InterviewMessage.interviewee(session, 5, "다시 답변", null, null); + ReflectionTestUtils.setField(retyped, "id", 201L); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)) + .thenReturn(Optional.of(session)); + when(messageRepository.findById(200L)).thenReturn(Optional.of(failed)); + when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)) + .thenReturn(Optional.of(retyped)); + + assertThatThrownBy(() -> service.retranscribe(1L, 10L, 200L)) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("errorCode", ApiErrorCode.VOICE_RETRANSCRIBE_NOT_ALLOWED); + verify(events, never()).publishEvent(any(VoiceAnswerUploadedEvent.class)); + } + + @Test + void retranscribe_rejectsMessageThatDidNotFail() { + InterviewSession session = sessionInProgress(10L); + InterviewMessage ok = InterviewMessage.voiceInterviewee(session, 4, null, null); + ReflectionTestUtils.setField(ok, "id", 200L); + ok.attachAudio(KEY); + ok.completeWithTranscript("정상 전사된 답변"); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)) + .thenReturn(Optional.of(session)); + when(messageRepository.findById(200L)).thenReturn(Optional.of(ok)); + + assertThatThrownBy(() -> service.retranscribe(1L, 10L, 200L)) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("errorCode", ApiErrorCode.VOICE_RETRANSCRIBE_NOT_ALLOWED); + verify(events, never()).publishEvent(any(VoiceAnswerUploadedEvent.class)); + } + + @Test + void retranscribe_rejectsMessageWithoutAudio() { + // 업로드 자체가 실패해 오디오가 없으면 되살릴 원본이 없다. + InterviewSession session = sessionInProgress(10L); + InterviewMessage failed = InterviewMessage.voiceInterviewee(session, 4, null, null); + ReflectionTestUtils.setField(failed, "id", 200L); + failed.failVoiceTranscription(); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)) + .thenReturn(Optional.of(session)); + when(messageRepository.findById(200L)).thenReturn(Optional.of(failed)); + + assertThatThrownBy(() -> service.retranscribe(1L, 10L, 200L)) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("errorCode", ApiErrorCode.VOICE_RETRANSCRIBE_NOT_ALLOWED); + } + + @Test + void retranscribe_rejectsMessageFromAnotherSession() { + InterviewSession session = sessionInProgress(10L); + InterviewSession other = sessionInProgress(11L); + InterviewMessage failed = failedVoiceAnswer(other, KEY); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)) + .thenReturn(Optional.of(session)); + when(messageRepository.findById(200L)).thenReturn(Optional.of(failed)); + + assertThatThrownBy(() -> service.retranscribe(1L, 10L, 200L)) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("errorCode", ApiErrorCode.VOICE_MESSAGE_NOT_FOUND); + } + + @Test + void retranscribe_rejectsFinishedSession() { + InterviewSession session = sessionFixture(10L); // CREATED — IN_PROGRESS 아님 + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)) + .thenReturn(Optional.of(session)); + + assertThatThrownBy(() -> service.retranscribe(1L, 10L, 200L)) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("errorCode", ApiErrorCode.SESSION_INVALID_STATE); + } + + @Test + void retranscribe_rejectsSessionNotOwnedByUser() { + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.retranscribe(1L, 10L, 200L)) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("errorCode", ApiErrorCode.SESSION_NOT_FOUND); + } + + private InterviewMessage failedVoiceAnswer(InterviewSession session, String key) { + InterviewMessage question = InterviewMessage.interviewer(session, 3, "ACID 설명해주세요"); + ReflectionTestUtils.setField(question, "id", 100L); + InterviewMessage m = InterviewMessage.voiceInterviewee(session, 4, question, null); + ReflectionTestUtils.setField(m, "id", 200L); + m.attachAudio(key); + m.failVoiceTranscription(); + return m; + } + + 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, null, null + ); + ReflectionTestUtils.setField(s, "id", id); + return s; + } +} diff --git a/frontend/src/features/interview/api/messageApi.ts b/frontend/src/features/interview/api/messageApi.ts index a8281a04..60d158cc 100644 --- a/frontend/src/features/interview/api/messageApi.ts +++ b/frontend/src/features/interview/api/messageApi.ts @@ -34,6 +34,19 @@ export async function submitVoiceAnswer( return data } +// STT 실패한 음성 답변을 같은 오디오로 다시 전사 요청. 오디오는 S3 에 남아 있으므로 +// 답변을 다시 입력할 필요가 없다. 응답은 transcribing 으로 되돌아간 메시지이고, +// 완료는 업로드 때와 같이 SESSION_MESSAGE SSE 로 도착한다. +export async function retranscribeVoiceAnswer( + sessionId: number, + messageId: number, +): Promise { + const { data } = await apiClient.post( + `/api/sessions/${sessionId}/messages/voice/${messageId}/retranscribe`, + ) + return data +} + // 오디오 바이트를 Core 프록시로 받아 object URL 로 변환. // MinIO presigned URL 이 내부 호스트라 브라우저가 직접 못 가므로 이 경로를 쓴다. export async function fetchMessageAudioObjectUrl( diff --git a/frontend/src/features/interview/model/useLiveInterview.ts b/frontend/src/features/interview/model/useLiveInterview.ts index 581b4b9f..c022dbd0 100644 --- a/frontend/src/features/interview/model/useLiveInterview.ts +++ b/frontend/src/features/interview/model/useLiveInterview.ts @@ -4,7 +4,11 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { currentTurn } from '@/domain/session' import type { Message } from '@/domain/session' import { toast } from '@/shared/ui' -import { submitVoiceAnswer, fetchMessageSegmentObjectUrl } from '../api/messageApi' +import { + submitVoiceAnswer, + retranscribeVoiceAnswer, + fetchMessageSegmentObjectUrl, +} from '../api/messageApi' import { createSegmentQueue } from '../lib/media/segmentAudioQueue' import { sessionKeys, useSession } from './useSession' import { messageKeys, useSessionMessages } from './useSessionMessages' @@ -294,6 +298,22 @@ export function useLiveInterview(sessionId: number, deliveryMode: DeliveryMode = const { mutate: voiceMutate } = voiceMutation const submitVoice = useCallback((audio: Blob) => voiceMutate(audio), [voiceMutate]) + // STT 실패 답변 재전사. 오디오는 S3 에 남아 있으니 답변을 다시 입력할 필요가 없다. + // 업로드와 같은 경로를 타므로 성공 시 목록을 무효화해 "음성 인식 중…" 으로 되돌리고, + // 완료는 SESSION_MESSAGE SSE 로 도착한다. + const retranscribeMutation = useMutation({ + mutationFn: (messageId: number) => retranscribeVoiceAnswer(sessionId, messageId), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: messageKeys.list(sessionId) }), + onError: () => + toast.error('다시 인식하지 못했어요. 텍스트로 답변해 주세요.'), + }) + const { mutate: retranscribeMutate } = retranscribeMutation + const retranscribe = useCallback( + (messageId: number) => retranscribeMutate(messageId), + [retranscribeMutate], + ) + // 서버 메시지 기준으로 가장 최근 면접관 메시지가 여전히 sentinel이면 스트리밍 진행 중. const questionStreaming = useMemo(() => { const latest = [...serverMessages].reverse().find((m) => m.role === 'INTERVIEWER') @@ -343,6 +363,8 @@ export function useLiveInterview(sessionId: number, deliveryMode: DeliveryMode = submitVoice, voiceUploading: voiceMutation.isPending, voiceError: voiceMutation.isError, + retranscribe, + retranscribing: retranscribeMutation.isPending, endSession, interruptSession, isLoading: sessionQuery.isLoading, diff --git a/frontend/src/features/interview/ui/live/AnswerBubble.retranscribe.test.tsx b/frontend/src/features/interview/ui/live/AnswerBubble.retranscribe.test.tsx new file mode 100644 index 00000000..87f6e9b3 --- /dev/null +++ b/frontend/src/features/interview/ui/live/AnswerBubble.retranscribe.test.tsx @@ -0,0 +1,104 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import type { Message } from '@/domain/session' +import type { ThreadItem } from '../../model/useLiveInterview' +import { AnswerBubble } from './AnswerBubble' +import { ConversationThread } from './ConversationThread' + +vi.mock('../../lib/media/useMessageAudio', () => ({ + useMessageAudio: () => ({ url: undefined, load: async () => undefined }), +})) +vi.mock('../../lib/media/useTtsPlayback', () => ({ + useTtsPlayback: () => ({ playing: false, toggle: () => {}, audioNode: null }), +})) +vi.mock('../../model/useBookmarks', () => ({ + useSetQuestionBookmark: () => ({ mutate: () => {}, isPending: false }), +})) + +// 백엔드가 STT 실패 시 content 를 바꾸지 않고 sentinel 을 유지하는 경로 +// (isTranscribing 이 true 여야 실패 버블로 렌더된다). +function failedVoiceAnswer(overrides: Partial = {}): Message { + return { + id: 200, + sessionId: 10, + role: 'INTERVIEWEE', + content: '(transcribing)', + status: 'FAILED', + audioFilePath: 'interview/voice/raw/10/200.webm', + sequenceNumber: 4, + ...overrides, + } as Message +} + +describe('AnswerBubble 재전사', () => { + it('실패한 음성 답변에 다시 인식하기 버튼을 노출하고 messageId 로 호출한다', async () => { + const onRetranscribe = vi.fn() + render() + + await userEvent.click(screen.getByRole('button', { name: /다시 인식하기/ })) + expect(onRetranscribe).toHaveBeenCalledTimes(1) + }) + + it('녹음이 남아 있으면 "다시 답변해 주세요" 대신 남아 있다고 알린다', () => { + render() + expect(screen.getByText(/녹음은 남아 있어요/)).toBeInTheDocument() + }) + + it('재전사 진행 중에는 버튼을 잠근다 — 중복 발행 방지', () => { + render( + , + ) + expect(screen.getByRole('button', { name: /다시 인식하는 중/ })).toBeDisabled() + }) + + it('오디오가 없는 실패(업로드 자체 실패)에는 버튼을 내지 않는다', () => { + render( + , + ) + expect(screen.queryByRole('button', { name: /다시 인식/ })).not.toBeInTheDocument() + }) + + it('전사에 성공한 답변에는 버튼을 내지 않는다', () => { + render( + , + ) + expect(screen.queryByRole('button', { name: /다시 인식/ })).not.toBeInTheDocument() + }) +}) + +describe('ConversationThread 재전사 게이팅', () => { + it('이미 다시 답변한 뒤라면 버튼을 내지 않는다 — 뒤늦은 전사가 지나간 턴을 덮어쓴다', () => { + const items = [ + { ...failedVoiceAnswer(), key: 'm-200' }, + { + id: 201, + key: 'm-201', + sessionId: 10, + role: 'INTERVIEWEE', + content: '텍스트로 다시 답변', + status: 'COMPLETED', + sequenceNumber: 5, + }, + ] as ThreadItem[] + + render( + , + ) + expect(screen.queryByRole('button', { name: /다시 인식/ })).not.toBeInTheDocument() + }) + + it('실패 답변이 마지막이면 버튼을 낸다', () => { + const items = [{ ...failedVoiceAnswer(), key: 'm-200' }] as ThreadItem[] + render( + , + ) + expect(screen.getByRole('button', { name: /다시 인식하기/ })).toBeInTheDocument() + }) +}) diff --git a/frontend/src/features/interview/ui/live/AnswerBubble.tsx b/frontend/src/features/interview/ui/live/AnswerBubble.tsx index f4431082..8ded3378 100644 --- a/frontend/src/features/interview/ui/live/AnswerBubble.tsx +++ b/frontend/src/features/interview/ui/live/AnswerBubble.tsx @@ -3,10 +3,21 @@ import { isTranscribing } from '@/domain/session' import type { Message } from '@/domain/session' import { useMessageAudio } from '../../lib/media/useMessageAudio' -export const AnswerBubble = memo(function AnswerBubble({ message }: { message: Message }) { +export const AnswerBubble = memo(function AnswerBubble({ + message, + onRetranscribe, + retranscribing = false, +}: { + message: Message + // STT 실패한 마지막 답변에만 주어진다 — 이미 다시 답변한 뒤라면 서버가 거절하므로 + // (VoiceRetranscribeService) 버튼 자체를 내지 않는다. + onRetranscribe?: () => void + retranscribing?: boolean +}) { const transcribing = isTranscribing(message) const failed = transcribing && message.status === 'FAILED' const hasVoice = Boolean(message.audioFilePath) + const canRetranscribe = failed && hasVoice && Boolean(onRetranscribe) const { url, load } = useMessageAudio(message.sessionId, message.id) @@ -18,7 +29,10 @@ export const AnswerBubble = memo(function AnswerBubble({ message }: { message: M ) : ( {failed ? ( - '음성 인식에 실패했어요. 다시 답변해 주세요.' + // 녹음은 그대로 있으니 "다시 답변" 이 유일한 길인 것처럼 말하지 않는다. + canRetranscribe + ? '음성 인식에 실패했어요. 녹음은 남아 있어요.' + : '음성 인식에 실패했어요. 다시 답변해 주세요.' ) : ( <> @@ -28,18 +42,30 @@ export const AnswerBubble = memo(function AnswerBubble({ message }: { message: M )} - {hasVoice && - (url ? ( -