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
12 changes: 12 additions & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 레코드 또는 인메모리로.

Expand Down
67 changes: 67 additions & 0 deletions backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" ],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, "질문만 오답노트에 담을 수 있습니다."),

Expand Down
Original file line number Diff line number Diff line change
@@ -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";
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -32,6 +33,7 @@ public class VoiceAnswerController {

private final VoiceAnswerSubmitService submitService;
private final VoiceStreamService streamService;
private final VoiceRetranscribeService retranscribeService;

@Operation(
operationId = "submitVoiceAnswer",
Expand Down Expand Up @@ -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));
}
}
Loading
Loading