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
10 changes: 10 additions & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,16 @@ docker compose up -d
재개된 세션은 IN_PROGRESS 라 그대로 통과한다. 그래서 `applyFollowup` 이 **이미 FAILED 인
placeholder 를 되살리지 않도록** 막는다 — 복구가 실패 확정 + 다음 질문까지 마친 뒤 늦은 콜백이
그 자리를 채우면 살아있는 질문이 두 개가 된다. POOL 은 `countBySessionId > 0` 로 이미 멱등.
- **STT 콜백 유실 복구 본 구현**: 음성 답변은 `(transcribing)` placeholder 로 먼저 저장되고
`callback.voice` 가 도착해야 채워진다. 그 콜백이 유실되면(AI 크래시·DLQ 격리·브로커 단절)
메시지가 그 상태로 남고 프론트 턴 판정상 **답변 차례가 오지 않아 면접이 멈춘다** —
세션 시간 초과로 통째로 끝날 때까지. 질문 생성 쪽은 실패 신호로 이미 해결했지만 음성엔
대응이 없었다. `StaleTranscriptionSweeper`(기본 2분 주기)가 `interview.voice.
stale-transcription-minutes`(기본 5분)를 넘긴 placeholder 를 찾아
`VoiceTranscriptionRecoveryService.failStaleTranscription` 으로 FAILED 확정한다
(`STT_CALLBACK_TIMEOUT`). 그러면 기존 STT 실패 경로를 그대로 타서 사용자가 같은 질문에
텍스트로 다시 답할 수 있다. 목록 생성 후 콜백이 도착한 경우를 위해 확정 직전 상태를 다시
확인한다(완료된 답변을 실패로 되돌리지 않는다).
- **Spring AI 미사용** — LLM·임베딩 호출은 모두 AI 서버 위임. Core는 RabbitMQ 발행만 담당.
- **Redis 미사용** — 휘발성 데이터는 DB short-lived 레코드 또는 인메모리로.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.stackup.stackup.session.application;

import com.stackup.stackup.session.domain.InterviewMessage;
import com.stackup.stackup.session.domain.InterviewMessageRepository;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

/**
* STT 콜백을 영영 못 받은 음성 답변을 정리한다.
*
* <p>음성 답변은 `(transcribing)` placeholder 로 먼저 저장되고 `callback.voice` 가 도착해야
* 채워진다. 그 콜백이 유실되면(AI 크래시·DLQ 격리·브로커 단절) 메시지는 그 상태로 남고,
* 프론트의 턴 판정상 <b>답변 차례가 오지 않아 면접이 그대로 멈춘다</b> — 세션 시간 초과로
* 통째로 끝날 때까지. 질문 생성 쪽은 실패 신호(callback status=FAILED)로 이미 해결했지만
* 음성에는 대응이 없었다.
*
* <p>여기서는 오래 멈춘 placeholder 를 FAILED 로 확정한다. 그러면 기존 STT 실패 경로를 그대로
* 타서(프론트 `currentTurn` 의 INTERVIEWEE+FAILED 분기, 백엔드 `resolveAnswerParent`)
* 사용자는 같은 질문에 텍스트로 다시 답할 수 있다.
*/
@Component
@RequiredArgsConstructor
public class StaleTranscriptionSweeper {

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

private final InterviewMessageRepository messageRepository;
private final VoiceTranscriptionRecoveryService recoveryService;

// 이 시간이 지나도 전사가 안 채워지면 유실로 본다. 배치 STT 는 보통 수십 초 안에 끝난다.
@Value("${interview.voice.stale-transcription-minutes:5}")
private long staleAfterMinutes = 5;

@Transactional(propagation = Propagation.NOT_SUPPORTED)
@Scheduled(
fixedDelayString = "${interview.voice.sweep-interval-ms:120000}",
initialDelayString = "${interview.voice.sweep-initial-delay-ms:60000}")
public void sweep() {
Instant before = Instant.now().minus(Duration.ofMinutes(staleAfterMinutes));
List<InterviewMessage> stale = messageRepository.findStaleTranscribing(
InterviewMessage.VOICE_TRANSCRIPTION_PENDING_TEXT, before);
if (stale.isEmpty()) {
return;
}
int failed = 0;
for (InterviewMessage m : stale) {
try {
// 메시지마다 독립 트랜잭션 — 하나가 실패해도 나머지는 정리된다.
recoveryService.failStaleTranscription(m.getId());
failed++;
} catch (RuntimeException e) {
log.warn("stale transcription recovery failed. messageId={}", m.getId(), e);
}
}
log.info("stale transcription sweeper failed {} of {} pending voice answer(s)",
failed, stale.size());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.stackup.stackup.session.application;

import com.stackup.stackup.common.messaging.RealtimeNotifyEvent;
import com.stackup.stackup.common.sse.SseEventType;
import com.stackup.stackup.session.domain.InterviewMessage;
import com.stackup.stackup.session.domain.InterviewMessageRepository;
import com.stackup.stackup.session.domain.MessageStatus;
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 콜백 유실 복구. 스위퍼가 메시지마다 이 서비스를 호출한다(각자 독립 트랜잭션).
@Service
@RequiredArgsConstructor
public class VoiceTranscriptionRecoveryService {

private static final Logger log = LoggerFactory.getLogger(VoiceTranscriptionRecoveryService.class);
private static final String ERROR_CODE = "STT_CALLBACK_TIMEOUT";

private final InterviewMessageRepository messageRepository;
private final ApplicationEventPublisher events;

@Transactional
public void failStaleTranscription(Long messageId) {
InterviewMessage message = messageRepository.findById(messageId).orElse(null);
if (message == null) {
return;
}
// 스위퍼가 목록을 만든 뒤 콜백이 도착했을 수 있다 — 이미 채워졌으면 건드리지 않는다.
if (message.getStatus() != MessageStatus.CREATED
|| !InterviewMessage.VOICE_TRANSCRIPTION_PENDING_TEXT.equals(message.getContent())) {
return;
}
message.failVoiceTranscription();

Long sessionId = message.getSession().getId();
VoiceCallbackService.VoiceFailedNotice notice = new VoiceCallbackService.VoiceFailedNotice(
sessionId, message.getId(), ERROR_CODE, message.getContent());
events.publishEvent(RealtimeNotifyEvent.session(sessionId, SseEventType.SESSION_MESSAGE, notice));
events.publishEvent(RealtimeNotifyEvent.user(message.getSession().getUser().getId(),
SseEventType.SESSION_MESSAGE, notice));
log.warn("voice answer stuck in transcription — marked FAILED so the turn unlocks. "
+ "sessionId={}, messageId={}", sessionId, messageId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ public interface InterviewMessageRepository extends JpaRepository<InterviewMessa
""")
List<InterviewMessage> findBookmarkedByOwner(@Param("userId") Long userId);

// STT 콜백을 기다리다 멈춰 있는 음성 답변. content 가 아직 pending sentinel 이고
// 진행 중 세션에 속한 것만. (callback.voice 가 유실되면 이 상태로 영구히 남는다.)
@Query("""
select m from InterviewMessage m
where m.role = com.stackup.stackup.session.domain.MessageRole.INTERVIEWEE
and m.status = com.stackup.stackup.session.domain.MessageStatus.CREATED
and m.content = :pendingText
and m.createdAt < :before
and m.session.deleted = false
and m.session.status = com.stackup.stackup.session.domain.SessionStatus.IN_PROGRESS
""")
List<InterviewMessage> findStaleTranscribing(@Param("pendingText") String pendingText,
@Param("before") java.time.Instant before);

// 질문에 달린 답변(있으면 1개). 오답노트에 '내 답변 + 코칭'을 함께 보여주기 위해.
List<InterviewMessage> findByParentMessage_IdIn(List<Long> parentMessageIds);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.stackup.stackup.session.application;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import com.stackup.stackup.common.messaging.RealtimeNotifyEvent;
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.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.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
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;

/**
* `callback.voice` 가 유실되면 음성 답변이 `(transcribing)` 으로 남고 답변 차례가 오지 않아
* 면접이 멈춘다. 이를 FAILED 로 확정해 기존 STT 실패 경로(텍스트 재답변)를 타게 한다.
*/
@ExtendWith(MockitoExtension.class)
class VoiceTranscriptionRecoveryServiceTest {

@Mock InterviewMessageRepository messageRepository;
@Mock ApplicationEventPublisher events;
@InjectMocks VoiceTranscriptionRecoveryService service;

@Test
void failStaleTranscription_marksFailedAndNotifies() {
InterviewMessage pending = pendingVoiceAnswer(200L);
when(messageRepository.findById(200L)).thenReturn(Optional.of(pending));

service.failStaleTranscription(200L);

assertThat(pending.getStatus()).isEqualTo(MessageStatus.FAILED);
// 내용이 실패 안내로 바뀌어야 프론트가 '텍스트로 다시 답변' 턴으로 인식한다.
assertThat(pending.getContent())
.isNotEqualTo(InterviewMessage.VOICE_TRANSCRIPTION_PENDING_TEXT);
// 세션 채널 + 유저 채널 양쪽에 알린다(다른 탭에서도 턴이 풀려야 한다).
verify(events, times(2)).publishEvent(any(RealtimeNotifyEvent.class));
}

// 스위퍼가 목록을 만든 뒤 콜백이 도착했을 수 있다 — 완료된 답변을 실패로 되돌리면 안 된다.
@Test
void failStaleTranscription_skipsWhenTranscriptArrivedMeanwhile() {
InterviewMessage completed = pendingVoiceAnswer(200L);
completed.completeWithTranscript("실제로는 전사가 도착했습니다");
when(messageRepository.findById(200L)).thenReturn(Optional.of(completed));

service.failStaleTranscription(200L);

assertThat(completed.getStatus()).isEqualTo(MessageStatus.COMPLETED);
assertThat(completed.getContent()).isEqualTo("실제로는 전사가 도착했습니다");
verify(events, never()).publishEvent(any());
}

@Test
void failStaleTranscription_isNoopWhenMessageMissing() {
when(messageRepository.findById(200L)).thenReturn(Optional.empty());

service.failStaleTranscription(200L);

verify(events, never()).publishEvent(any());
}

private InterviewMessage pendingVoiceAnswer(Long id) {
User user = User.createGithubUser(1L, "u", null, null, "t");
ReflectionTestUtils.setField(user, "id", 1L);
InterviewSession session = InterviewSession.create(
user, "면접", null, SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), 5, 30, null, null);
ReflectionTestUtils.setField(session, "id", 10L);
session.start();

InterviewMessage question = InterviewMessage.interviewer(session, 1, "질문");
InterviewMessage pending = InterviewMessage.voiceInterviewee(session, 2, question, null);
ReflectionTestUtils.setField(pending, "id", id);
return pending;
}
}
Loading