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
24 changes: 24 additions & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,30 @@ docker compose up -d
(`STT_CALLBACK_TIMEOUT`). 그러면 기존 STT 실패 경로를 그대로 타서 사용자가 같은 질문에
텍스트로 다시 답할 수 있다. 목록 생성 후 콜백이 도착한 경우를 위해 확정 직전 상태를 다시
확인한다(완료된 답변을 실패로 되돌리지 않는다).
- **꼬리질문 콜백 유실 복구 본 구현**: 스트리밍 꼬리질문은 `(생성 중)` placeholder 를 먼저
INSERT 하고 `callback.questions(FOLLOWUP)` 이 도착해야 확정된다. AI 가 **실패를 보고**하면
`applyFollowupFailed` 가 처리하지만, **콜백 자체가 오지 않으면**(AI 크래시·DLQ 격리·브로커
단절) 그 경로를 아무도 타지 못한다 — placeholder 가 CREATED 로 남고, 답할 질문이 없어
면접이 그 자리에서 멈춘다(세션 시간 초과로 통째로 끝날 때까지). 음성 답변의
`(transcribing)` 과 정확히 같은 구멍인데 질문 쪽에만 대응이 없었다. 운영에서 1건 발생
(messageId=444, sessionId=93 — 28일째 CREATED, 해당 세션은 시간초과로 조기 종료됨).
`StaleFollowupSweeper`(기본 2분 주기)가 `interview.followup.stale-generation-minutes`
(기본 3분)를 넘긴 placeholder 를 찾아 `QuestionsCallbackService.failStaleFollowup` 을 부른다.
- **콜백 경로와 같은 코드를 탄다**(`markFollowupFailedAndAdvance`) — 실패 문구 확정 +
`SESSION_MESSAGE` 발행 + `advanceToNextGeneral`. "어떻게 실패했는지"에 따라 사용자가
보는 결과가 갈리면 안 된다. 이벤트 reason 만 `FOLLOWUP_TIMEOUT` 으로 구분한다.
- 확정 직전 상태를 다시 확인한다 — 스위퍼 조회 이후 콜백이 도착했거나(완성된 질문을
실패로 되돌리면 안 된다) 세션이 종료됐을 수 있다.
- **실패로 확정된 질문은 TTS 를 요청하지 않는다**(`SessionTtsRequester` 가드). 실패 사실은
화면에 보여야 하므로 SSE 는 그대로 나가는데, `QuestionPersistedEvent` 에 가드가 없어
Gemini TTS 가 "질문 생성에 실패했습니다"를 음성으로 만들고 있었다 — 운영 2건
(messageId=434·437, `tts_status=SUCCEEDED`). 면접관이 오류 문구를 읽고, 대화 기록의
재생 버튼도 그걸 들려준다.
- 두 스위퍼의 조회 JPQL 은 `InterviewMessageSweepQueryTest`(`@PostgresRepositoryTest`)로
검증한다. **멈춘 면접을 푸는 유일한 장치인데 지금까지 테스트가 없었다** — 넓으면 정상
진행 중인 턴을 실패로 되돌리고, 좁으면 조용히 0건이 되어 스위퍼가 있으나 마나가 되는데
둘 다 배포 전에는 신호가 없다(§15.1). `createdAt` 은 감사 필드라 못 바꾸므로 cutoff 를
움직여 양방향으로 확인한다.
- **STT 실패 재전사 본 구현**: STT 가 실패하면 `failVoiceTranscription()` 이 content 를 실패
문구로 덮고 FAILED 로 확정해 턴을 풀어 줬는데, **오디오는 S3 에 그대로 남아 있는데 꺼낼
경로가 없어** 사용자가 답변을 통째로 다시 입력해야 했다. 실패의 대부분은 Deepgram 이
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -328,11 +328,50 @@ private void applyFollowupFailed(InterviewSession session, QuestionsCallbackPayl
publishErrorEvent(session, "FOLLOWUP", payload);
return;
}
markFollowupFailedAndAdvance(session, placeholder, "FOLLOWUP_FAILED");
}

/**
* 꼬리질문 콜백을 영영 못 받은 placeholder 를 실패로 확정하고 면접을 진행시킨다.
*
* <p>{@link StaleFollowupSweeper} 가 메시지마다 호출한다. AI 가 status=FAILED 콜백을
* 보내 준 경우는 {@link #applyFollowupFailed} 가 처리하지만, 콜백 자체가 오지 않으면
* (AI 크래시·DLQ 격리·브로커 단절) 그 경로를 타지 못한다 — 음성 답변의
* `(transcribing)` 과 정확히 같은 구멍이고, 여기가 그 대응이다.
*
* <p>확정 직전 상태를 다시 확인한다: 스위퍼가 목록을 만든 뒤 콜백이 도착했을 수 있고,
* 완성된 질문을 실패로 되돌리면 사용자가 받은 질문이 사라진다.
*/
@Transactional
public void failStaleFollowup(Long messageId) {
InterviewMessage placeholder = messageRepository.findById(messageId).orElse(null);
if (placeholder == null) {
return;
}
if (placeholder.getStatus() != MessageStatus.CREATED
|| !InterviewMessage.FOLLOWUP_GENERATING_TEXT.equals(placeholder.getContent())) {
return;
}
InterviewSession session = placeholder.getSession();
// 스위퍼 조회 이후 세션이 끝났을 수 있다(시간초과·수동종료). 종료 세션에 질문을
// 더하지 않는 것은 콜백 경로의 terminal 가드와 같은 원칙.
if (session == null || session.isDeleted() || session.getStatus().isTerminal()) {
return;
}
log.warn("follow-up stuck in generation — no callback arrived. sessionId={}, messageId={}",
session.getId(), messageId);
markFollowupFailedAndAdvance(session, placeholder, "FOLLOWUP_TIMEOUT");
}

// 실패 확정 + 화면 갱신 + 다음 일반질문. 콜백 경로와 스위퍼 경로가 같은 코드를 타야
// "어떻게 실패했는지"에 따라 사용자가 보는 결과가 갈리지 않는다.
private void markFollowupFailedAndAdvance(InterviewSession session, InterviewMessage placeholder,
String reason) {
placeholder.failFollowup();
InterviewMessage message = messageRepository.save(placeholder);
publishQuestionEvents(session, message, "FOLLOWUP_FAILED");
log.info("callback.questions FOLLOWUP marked failed, advancing to next general. sessionId={}, msg={}",
session.getId(), message.getId());
publishQuestionEvents(session, message, reason);
log.info("follow-up marked failed({}), advancing to next general. sessionId={}, msg={}",
reason, session.getId(), message.getId());
advanceToNextGeneral(session.getId());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
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.MessageStatus;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand Down Expand Up @@ -36,6 +37,15 @@ public void onQuestionPersisted(QuestionPersistedEvent event) {
log.warn("generate.tts skipped — message not found. messageId={}", event.messageId());
return;
}
// 실패로 확정된 질문은 읽어 주지 않는다. FOLLOWUP 생성 실패/유실도 이 이벤트를 타는데
// (실패 사실을 화면에 보여야 하므로 SSE 는 그대로 나간다), 그대로 두면 Gemini TTS 를
// 한 번 써서 "질문 생성에 실패했습니다"를 음성으로 만든다 — 실제로 운영에 2건 있다
// (messageId=434·437, tts_status=SUCCEEDED). 면접관이 오류 문구를 말하는 셈이고,
// 대화 기록의 재생 버튼도 그걸 들려준다.
if (message.getStatus() == MessageStatus.FAILED) {
log.info("generate.tts skipped — message is FAILED. messageId={}", event.messageId());
return;
}
InterviewSession session = message.getSession();
GenerateTtsPayload payload = new GenerateTtsPayload(
session.getId(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
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;

/**
* 꼬리질문 콜백을 영영 못 받은 placeholder 를 정리한다.
*
* <p>스트리밍 꼬리질문은 `(생성 중)` placeholder 를 먼저 INSERT 하고
* `callback.questions(FOLLOWUP)` 이 도착해야 확정된다. AI 가 <b>실패를 보고</b>하면
* {@code QuestionsCallbackService.applyFollowupFailed} 가 실패 문구로 확정하고 다음
* 일반질문으로 넘어가지만, <b>콜백 자체가 오지 않으면</b>(AI 크래시·DLQ 격리·브로커 단절)
* 그 경로를 아무도 타지 못한다. placeholder 는 CREATED 인 채로 남고, 화면에는 "(생성 중)"
* 만 떠 있으며, 답할 질문이 없으니 면접이 그 자리에서 멈춘다 — 세션 시간 초과로 통째로
* 끝날 때까지.
*
* <p>음성 답변의 `(transcribing)` 이 정확히 같은 구멍이었고 {@link StaleTranscriptionSweeper}
* 로 막았다. 이건 그 짝이다. 운영에서 1건 발생했다(messageId=444, sessionId=93 —
* 28일째 CREATED 로 남아 있었고 그 세션은 시간초과로 조기 종료됐다).
*/
@Component
@RequiredArgsConstructor
public class StaleFollowupSweeper {

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

private final InterviewMessageRepository messageRepository;
private final QuestionsCallbackService callbackService;

// 이 시간이 지나도 확정되지 않으면 유실로 본다. 정상 생성은 flash 타임아웃(10초) + RAG(1.5초)
// 범위에서 끝나고, AI 쪽에 메시지 단위 재시도가 없어 실패는 곧장 DLQ 로 간다 —
// 3분이면 정상 케이스를 자를 위험 없이 넉넉하다.
@Value("${interview.followup.stale-generation-minutes:3}")
private long staleAfterMinutes = 3;

@Transactional(propagation = Propagation.NOT_SUPPORTED)
@Scheduled(
fixedDelayString = "${interview.followup.sweep-interval-ms:120000}",
initialDelayString = "${interview.followup.sweep-initial-delay-ms:90000}")
public void sweep() {
Instant before = Instant.now().minus(Duration.ofMinutes(staleAfterMinutes));
List<InterviewMessage> stale = messageRepository.findStaleFollowupPlaceholders(
InterviewMessage.FOLLOWUP_GENERATING_TEXT, before);
if (stale.isEmpty()) {
return;
}
int recovered = 0;
for (InterviewMessage m : stale) {
try {
// 메시지마다 독립 트랜잭션 — 하나가 실패해도 나머지는 정리된다.
callbackService.failStaleFollowup(m.getId());
recovered++;
} catch (RuntimeException e) {
log.warn("stale follow-up recovery failed. messageId={}", m.getId(), e);
}
}
log.info("stale follow-up sweeper recovered {} of {} stuck placeholder(s)",
recovered, stale.size());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,21 @@ public interface InterviewMessageRepository extends JpaRepository<InterviewMessa
List<InterviewMessage> findStaleTranscribing(@Param("pendingText") String pendingText,
@Param("before") java.time.Instant before);

// AI 꼬리질문 콜백을 기다리다 멈춰 있는 placeholder. 음성 쪽 findStaleTranscribing 과
// 같은 성격이다 — callback.questions(FOLLOWUP) 이 유실되면 "(생성 중)" 인 채로 영구히 남고
// 면접이 그 자리에서 멈춘다.
@Query("""
select m from InterviewMessage m
where m.role = com.stackup.stackup.session.domain.MessageRole.INTERVIEWER
and m.status = com.stackup.stackup.session.domain.MessageStatus.CREATED
and m.content = :generatingText
and m.createdAt < :before
and m.session.deleted = false
and m.session.status = com.stackup.stackup.session.domain.SessionStatus.IN_PROGRESS
""")
List<InterviewMessage> findStaleFollowupPlaceholders(@Param("generatingText") String generatingText,
@Param("before") java.time.Instant before);

// 질문에 달린 답변(있으면 1개). 오답노트에 '내 답변 + 코칭'을 함께 보여주기 위해.
List<InterviewMessage> findByParentMessage_IdIn(List<Long> parentMessageIds);
}
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,77 @@ void advanceToNextGeneral_endsWithMaxReached_whenMainQuestionsHitLimit() {
verify(poolRepository, never()).findFirstBySessionIdAndUsedFalseOrderByIdxAsc(any());
}

// ── 콜백 유실 복구 (StaleFollowupSweeper 경로) ──────────────────────────────

@Test
void failStaleFollowup_marksFailedAndAdvances() {
InterviewSession session = sessionFixture(40L, SessionStatus.IN_PROGRESS);
InterviewMessage placeholder = InterviewMessage.followupPlaceholder(
session, 3, parentMessageFixture(session));
ReflectionTestUtils.setField(placeholder, "id", 444L);

when(messageRepository.findById(444L)).thenReturn(Optional.of(placeholder));
when(messageRepository.save(any(InterviewMessage.class))).thenAnswer(inv -> inv.getArgument(0));
when(sessionRepository.findById(40L)).thenReturn(Optional.of(session));
when(poolRepository.findFirstBySessionIdAndUsedFalseOrderByIdxAsc(40L)).thenReturn(Optional.empty());
when(sessionRepository.finishIfInProgress(any(), any(), any())).thenReturn(1);

service.failStaleFollowup(444L);

// AI 가 실패를 보고한 경우와 같은 결과여야 한다 — 어떻게 실패했는지에 따라
// 사용자가 보는 화면이 갈리면 안 된다.
assertThat(placeholder.getContent())
.isEqualTo(InterviewMessage.FOLLOWUP_GENERATION_FAILED_TEXT);
assertThat(placeholder.getStatus())
.isEqualTo(com.stackup.stackup.session.domain.MessageStatus.FAILED);
assertThat(session.getStatus()).isEqualTo(SessionStatus.COMPLETED);
}

// 스위퍼가 목록을 만든 뒤 콜백이 도착했을 수 있다. 완성된 질문을 실패로 되돌리면
// 사용자가 받은 질문이 화면에서 사라진다.
@Test
void failStaleFollowup_skipsWhenCallbackArrivedMeanwhile() {
InterviewSession session = sessionFixture(41L, SessionStatus.IN_PROGRESS);
InterviewMessage placeholder = InterviewMessage.followupPlaceholder(
session, 3, parentMessageFixture(session));
ReflectionTestUtils.setField(placeholder, "id", 445L);
placeholder.completeFollowup("뒤늦게 도착한 진짜 꼬리질문?", false);

when(messageRepository.findById(445L)).thenReturn(Optional.of(placeholder));

service.failStaleFollowup(445L);

assertThat(placeholder.getContent()).isEqualTo("뒤늦게 도착한 진짜 꼬리질문?");
verify(messageRepository, never()).save(any(InterviewMessage.class));
verify(events, never()).publishEvent(any());
}

// 스위퍼 조회 이후 세션이 시간초과로 끝났을 수 있다 — 종료 세션에 질문을 더하지 않는다.
@Test
void failStaleFollowup_skipsWhenSessionAlreadyTerminal() {
InterviewSession session = sessionFixture(42L, SessionStatus.IN_PROGRESS);
InterviewMessage placeholder = InterviewMessage.followupPlaceholder(
session, 3, parentMessageFixture(session));
ReflectionTestUtils.setField(placeholder, "id", 446L);
ReflectionTestUtils.setField(session, "status", SessionStatus.COMPLETED);

when(messageRepository.findById(446L)).thenReturn(Optional.of(placeholder));

service.failStaleFollowup(446L);

assertThat(placeholder.getContent()).isEqualTo(InterviewMessage.FOLLOWUP_GENERATING_TEXT);
verify(events, never()).publishEvent(any());
}

@Test
void failStaleFollowup_isNoopWhenMessageMissing() {
when(messageRepository.findById(447L)).thenReturn(Optional.empty());

service.failStaleFollowup(447L);

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

private QuestionsCallbackEnvelope poolEnvelope(Long sessionId, List<GeneratedQuestion> questions) {
QuestionsCallbackPayload payload = new QuestionsCallbackPayload(
sessionId, "POOL", questions, null, null, null, null, null, null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,20 @@ void publishesGenerateTts_whenQuestionPersisted() {
org.assertj.core.api.Assertions.assertThat(captor.getValue().text()).isEqualTo("당신의 프로젝트에서...");
}

// 꼬리질문 생성 실패/유실도 QuestionPersistedEvent 를 탄다(실패 사실을 화면에 보여야
// 하므로). 그대로 두면 Gemini TTS 를 써서 "질문 생성에 실패했습니다"를 음성으로 만들고
// 면접관이 오류 문구를 읽는다 — 운영에 2건 있었다(messageId=434·437).
@Test
void skips_whenMessageIsFailed() {
InterviewMessage message = org.mockito.Mockito.mock(InterviewMessage.class);
when(message.getStatus()).thenReturn(com.stackup.stackup.session.domain.MessageStatus.FAILED);
when(messageRepository.findById(43L)).thenReturn(Optional.of(message));

requester.onQuestionPersisted(new QuestionPersistedEvent(1L, 7L, 43L));

verify(publisher, never()).publishToAi(any(), any(), any());
}

@Test
void skips_whenMessageNotFound() {
when(messageRepository.findById(99L)).thenReturn(Optional.empty());
Expand Down
Loading
Loading