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
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package repit.repit_api_server.domain.userdata.analysis.controller;

import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import repit.repit_api_server.domain.userdata.analysis.dto.request.AudioAnalysisCallbackRequest;
import repit.repit_api_server.domain.userdata.analysis.dto.response.AudioAnalysisResponse;
Expand All @@ -33,4 +35,12 @@ public ApiResponse<AudioAnalysisResponse> get(@AuthenticationPrincipal AuthUser
@PathVariable Long interviewId) {
return ApiResponse.success(audioAnalysisService.get(authUser.id(), interviewId));
}

/** 음성 분석을 다시 요청한다. 면접당 2번까지, 결과가 없거나 다시 하면 나아질 실패가 있는 답변만 다시 보낸다. */
@PostMapping("/api/interviews/{interviewId}/audio-analysis/retry")
@ResponseStatus(HttpStatus.ACCEPTED)
public ApiResponse<AudioAnalysisResponse> retry(@AuthenticationPrincipal AuthUser authUser,
@PathVariable Long interviewId) {
return ApiResponse.success(audioAnalysisService.retry(authUser.id(), interviewId));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@
/**
* 웹에 내려주는 면접 음성 분석 상태와 답변별 지표. 상태 이름은 웹 계약(docs/interview-recording.md)을 따른다.
*
* @param status 면접 전체 상태. 웹은 PENDING일 때만 다시 조회한다
* @param answers 녹음이 있는 답변, 질문 진행 순서대로
* @param status 면접 전체 상태. 웹은 PENDING일 때만 다시 조회한다
* @param answers 녹음이 있는 답변, 질문 진행 순서대로
* @param canRetry 지금 음성 분석을 다시 요청할 수 있는지
*/
public record AudioAnalysisResponse(Status status, List<Answer> answers) {
public record AudioAnalysisResponse(Status status, List<Answer> answers, boolean canRetry) {

public enum Status {
/** 아직 요청 전이거나 분석 중이다. 이것만 진행 중이고 나머지는 끝난 상태다. */
/** 아직 요청 전이거나 분석 중이다. 다시 분석하는 중도 여기다. 이것만 진행 중이고 나머지는 끝난 상태다. */
PENDING,
READY,
/** 일부만 결과가 있다. 진행 중이 아니라 끝난 상태다. */
Expand All @@ -25,8 +26,8 @@ public enum Status {

/**
* @param questionId 지금의 질문 PK. 피드백 문항(FeedbackItemResponse)의 questionId와 같다
* @param requestId 이 답변의 상태·지표를 낸 분석 요청 id. 결과가 있으면 그 결과의 요청, 없으면 이 녹음을 실은
* 가장 나중 요청이다. 아직 요청 전이면 null
* @param requestId 이 답변의 상태·지표를 낸 분석 요청 id. 결과가 있으면 그 결과의 요청, 없거나 다시 분석하는
* 중이면 이 녹음을 실은 가장 나중 요청이다. 아직 요청 전이면 null
* @param timing 말 속도·침묵·속도 안정성. 분석 서버가 보낸 {status, data, error} 그대로
* @param fluency 간투사·반복 후보 등. 분석 서버가 보낸 {status, data, error} 그대로
*/
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ public class FeedbackDispatchEntity {
@Column(columnDefinition = "TEXT")
private String lastError;

// 사용자가 다시 요청한 횟수. 음성 분석 행만 쓴다. 조건부 갱신으로만 바꾼다.
@Column(nullable = false)
@Builder.Default
private Integer userRetryCount = 0;

@CreationTimestamp
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,27 @@ public interface FeedbackDispatchRepository extends JpaRepository<FeedbackDispat
+ "and d.status = repit.repit_api_server.domain.userdata.feedback.entity.enums.FeedbackDispatchStatus.DONE")
int reopen(Long interviewId, FeedbackDispatchKind kind, LocalDateTime lastActivityAt, String reason);

/**
* 사용자의 다시 요청을 받아 끝난(DONE·FAILED) 건을 기다리는 자리로 돌려놓는다. 바뀐 행이 있을 때만 받은 것이다.
*
* <p>한도와 "지금 요청 중이 아닌지"를 같은 문장에서 확인한다. 연달아 누른 요청이 둘 다 받아들여지거나 한도를
* 넘지 않는다.
*
* <p>{@link #reopen}과 달리 시도 횟수를 0으로 되돌리고 FAILED도 되돌린다. 자동 재시도를 다 쓰고 닫힌 건이라도
* 사람이 다시 요청했으면 처음처럼 시도한다. 마지막 활동 시각은 그대로 둔다 — 대개 유예 시간이 지나 있어 다음
* 스윕이 곧바로 집는다.
*/
@Transactional
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("update FeedbackDispatchEntity d set d.status = repit.repit_api_server.domain.userdata.feedback.entity.enums.FeedbackDispatchStatus.WAITING, "
+ "d.claimedAt = null, d.nextAttemptAt = null, d.attemptCount = 0, d.lastError = null, "
+ "d.userRetryCount = d.userRetryCount + 1 "
+ "where d.interviewId = :interviewId and d.kind = :kind "
+ "and d.status in (repit.repit_api_server.domain.userdata.feedback.entity.enums.FeedbackDispatchStatus.DONE, "
+ "repit.repit_api_server.domain.userdata.feedback.entity.enums.FeedbackDispatchStatus.FAILED) "
+ "and d.userRetryCount < :maxRetries")
int reopenForUserRetry(Long interviewId, FeedbackDispatchKind kind, int maxRetries);

/**
* 차지한 채 오래 끝나지 않은 건을 WAITING으로 되돌린다.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- 사용자가 음성 분석을 다시 요청할 수 있게 한다(D3). 면접당 정해진 횟수까지만 받는다.
--
-- 횟수는 음성 분석 대기 행(kind = AUDIO_ANALYSIS)에 둔다. 한도 확인, 횟수 증가, 대기 행을 다시 기다리는 자리로
-- 돌리는 일을 한 문장의 조건부 갱신으로 해야 연달아 누른 요청이 둘 다 받아들여지거나 한도를 넘지 않는다.
ALTER TABLE feedback_dispatch ADD COLUMN user_retry_count INTEGER NOT NULL DEFAULT 0;

COMMENT ON COLUMN feedback_dispatch.user_retry_count IS
'사용자가 음성 분석을 다시 요청한 횟수. AUDIO_ANALYSIS 행만 쓴다. 면접당 2회까지 받는다.';
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ void tearDown() {
void 면접의_음성_분석을_답변별로_내려준다() throws Exception {
when(audioAnalysisService.get(7L, 42L)).thenReturn(new AudioAnalysisResponse(Status.PARTIAL, List.of(
new AudioAnalysisResponse.Answer("1203", "2207", "301", "audio-1", Status.PARTIAL, 120000L,
Map.of("status", "ready"), Map.of("status", "partial"), null))));
Map.of("status", "ready"), Map.of("status", "partial"), null)), true));

mockMvc.perform(get("/api/interviews/42/audio-analysis"))
.andExpect(status().isOk())
Expand All @@ -71,7 +71,18 @@ void tearDown() {
.andExpect(jsonPath("$.data.answers[0].recordingId").value("301"))
.andExpect(jsonPath("$.data.answers[0].requestId").value("audio-1"))
.andExpect(jsonPath("$.data.answers[0].status").value("PARTIAL"))
.andExpect(jsonPath("$.data.answers[0].timing.status").value("ready"));
.andExpect(jsonPath("$.data.answers[0].timing.status").value("ready"))
.andExpect(jsonPath("$.data.canRetry").value(true));
}

@Test
void 음성_분석을_다시_요청하면_202로_받는다() throws Exception {
when(audioAnalysisService.retry(7L, 42L)).thenReturn(new AudioAnalysisResponse(Status.PENDING, List.of(), false));

mockMvc.perform(post("/api/interviews/42/audio-analysis/retry"))
.andExpect(status().isAccepted())
.andExpect(jsonPath("$.data.status").value("PENDING"))
.andExpect(jsonPath("$.data.canRetry").value(false));
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,171 @@ void setUp() {
new AudioAnalysisResponse.Error(null, "timeout", null));
}

// ---- 다시 요청 ----

/** 정상 결과도 말더듬 검출이 없어 PARTIAL이다. 상태만 보고 열면 모든 면접이 다시 요청 대상이 된다. */
@Test
void 다시_보낼_답변은_결과가_없거나_분석_서버가_다시_하면_된다고_알린_실패가_있는_답변뿐이다() {
assertThat(AudioAnalysisService.worthRetrying(answer(Status.FAILED, null, null, null))).isTrue();
assertThat(AudioAnalysisService.worthRetrying(answer(Status.UNAVAILABLE, null, null, null))).isTrue();
assertThat(AudioAnalysisService.worthRetrying(answer(Status.PENDING, null, null, null))).isFalse();
assertThat(AudioAnalysisService.worthRetrying(answer(Status.PARTIAL, TIMING, FLUENCY, null))).isFalse();
// 전사할 말이 없다. 같은 음성이면 같은 결과다.
assertThat(AudioAnalysisService.worthRetrying(answer(Status.PARTIAL, TIMING,
failedLine("TRANSCRIPT_UNAVAILABLE_OR_EMPTY", false), null))).isFalse();
assertThat(AudioAnalysisService.worthRetrying(answer(Status.PARTIAL, TIMING,
failedLine("EXTERNAL_SERVICE_UNAVAILABLE", true), null))).isTrue();
// 원본을 받지 못해 두 라인이 막혔다. 라인 사유는 retryable=false여도 파일 사유가 retryable=true다.
assertThat(AudioAnalysisService.worthRetrying(answer(Status.UNAVAILABLE,
failedLine("REQUIRED_INPUT_FAILED", false), failedLine("REQUIRED_INPUT_FAILED", false),
new AudioAnalysisResponse.Error("SOURCE_DOWNLOAD_FAILED", "x", true)))).isTrue();
}

@Test
void 정상_결과만_있으면_다시_요청할_수_없고_다시_보낼_답변이_있으면_할_수_있다() {
answerRecordings(loaded(301L, 101L, 201L, null), loaded(302L, 102L, 202L, null));
AudioAnalysisEntity done = analysis("audio-1", AudioAnalysisStatus.PARTIAL, List.of(301L, 302L), LocalDateTime.now());
analyses.add(done);
dispatch(FeedbackDispatchStatus.DONE, 0);

results(resultRow(done, 1L, 301L, "PARTIAL", null), resultRow(done, 2L, 302L, "PARTIAL", null));
assertThat(service.get(7L, INTERVIEW_ID).canRetry()).isFalse();

results(resultRow(done, 1L, 301L, "PARTIAL", null), retryableFailureRow(done, 2L, 302L));
assertThat(service.get(7L, INTERVIEW_ID).canRetry()).isTrue();
}

@Test
void 다시_요청하면_다시_보낼_답변만_새_요청_자리로_만들고_대기_행을_돌린다() {
answerRecordings(loaded(301L, 101L, 201L, null), loaded(302L, 102L, 202L, null), loaded(303L, 103L, 203L, null));
AudioAnalysisEntity done = analysis("audio-1", AudioAnalysisStatus.PARTIAL, List.of(301L, 302L, 303L), LocalDateTime.now());
analyses.add(done);
// 301은 정상, 302는 일시 장애로 fluency가 실패했고, 303은 결과가 빠졌다.
results(resultRow(done, 1L, 301L, "PARTIAL", null), retryableFailureRow(done, 2L, 302L));
dispatch(FeedbackDispatchStatus.DONE, 0);
when(dispatchRepository.reopenForUserRetry(INTERVIEW_ID, FeedbackDispatchKind.AUDIO_ANALYSIS, 2)).thenReturn(1);

AudioAnalysisResponse response = service.retry(7L, INTERVIEW_ID);

assertThat(analyses).hasSize(2);
AudioAnalysisEntity again = analyses.getLast();
assertThat(again.getRecordingIds()).containsExactly(302L, 303L);
assertThat(again.getStatus()).isEqualTo(AudioAnalysisStatus.PENDING);
assertThat(again.getJobId()).isNull();
assertThat(again.getRequestId()).startsWith("audio-").isNotEqualTo("audio-1");
// 보내는 것은 대기 스윕이 한다.
verify(aiServerClient, never()).requestAudioAnalysis(any());
assertThat(response.status()).isEqualTo(Status.PENDING);
assertThat(response.answers()).extracting(Answer::status, Answer::requestId).containsExactly(
tuple(Status.PARTIAL, "audio-1"), tuple(Status.PENDING, again.getRequestId()),
tuple(Status.PENDING, again.getRequestId()));
assertThat(response.canRetry()).isFalse();
}

@Test
void 다시_요청을_받지_않으면_이유를_409로_알리고_아무것도_만들지_않는다() {
answerRecordings(loaded(301L, 101L, 201L, null));
AudioAnalysisEntity done = analysis("audio-1", AudioAnalysisStatus.PARTIAL, List.of(301L), LocalDateTime.now());
analyses.add(done);
results(retryableFailureRow(done, 1L, 301L));

assertRetryBlocked("아직 분석을 시작하지 않았습니다.");
dispatch(FeedbackDispatchStatus.WAITING, 0);
assertRetryBlocked("이미 분석 중입니다.");
dispatch(FeedbackDispatchStatus.DONE, 2);
assertRetryBlocked("면접당 2번까지");
// 연달아 누른 다른 요청이 먼저 받아들여졌다.
dispatch(FeedbackDispatchStatus.DONE, 1);
assertRetryBlocked("이미 분석 중입니다.");
results(resultRow(done, 1L, 301L, "PARTIAL", null));
assertRetryBlocked("다시 분석할 답변이 없습니다.");

assertThat(analyses).hasSize(1);
}

/** 다시 요청한 요청이 받아들여지지 않고 닫혔다. 앞선 결과가 있다고 "이미 맡긴 녹음"으로 보면 다시 요청이 사라진다. */
@Test
void 다시_요청한_요청이_닫히면_앞선_결과가_있어도_그_녹음을_다시_보낸다() {
answerRecordings(loaded(301L, 101L, 201L, null), loaded(302L, 102L, 202L, null));
analyses.add(analysis("audio-1", AudioAnalysisStatus.PARTIAL, List.of(301L, 302L), LocalDateTime.now()));
analyses.add(analysis("audio-2", null, AudioAnalysisStatus.FAILED, List.of(302L), LocalDateTime.now()));

service.requestForFinishedInterview(INTERVIEW_ID);

List<AudioAnalysisRequest> sent = sentRequests();
assertThat(sent).hasSize(1);
assertThat(sent.getFirst().getRequestId()).isNotIn("audio-1", "audio-2");
assertThat(sent.getFirst().getRecordings()).extracting(AudioAnalysisRequest.Recording::getRecordingId)
.containsExactly("302");
}

@Test
void 다시_요청해_만든_요청은_그_요청_id로_처음_보낸다() {
answerRecordings(loaded(301L, 101L, 201L, null));
analyses.add(analysis("audio-1", AudioAnalysisStatus.UNAVAILABLE, List.of(301L), LocalDateTime.now().minusDays(1)));
AudioAnalysisEntity again = analysis("audio-2", null, AudioAnalysisStatus.PENDING, List.of(301L), LocalDateTime.now());
analyses.add(again);

service.requestForFinishedInterview(INTERVIEW_ID);

assertThat(sentRequests()).extracting(AudioAnalysisRequest::getRequestId).containsExactly("audio-2");
assertThat(again.getJobId()).isEqualTo("job-audio-2");
assertThat(analyses).hasSize(2);
}

/** 다시 분석하는 동안 앞선 결과를 보여 주면 전체가 PENDING이 아니게 돼 웹이 조회를 멈추고 새 결과를 놓친다. */
@Test
void 다시_분석하는_동안은_PENDING이고_다시_요청이_닫히면_앞선_결과로_돌아간다() {
answerRecordings(loaded(301L, 101L, 201L, null));
AudioAnalysisEntity first = analysis("audio-1", AudioAnalysisStatus.PARTIAL, List.of(301L), LocalDateTime.now());
analyses.add(first);
AudioAnalysisEntity again = analysis("audio-2", AudioAnalysisStatus.PENDING, List.of(301L), LocalDateTime.now());
analyses.add(again);
results(retryableFailureRow(first, 1L, 301L));
dispatch(FeedbackDispatchStatus.DONE, 1);

AudioAnalysisResponse during = service.get(7L, INTERVIEW_ID);
assertThat(during.status()).isEqualTo(Status.PENDING);
assertThat(during.answers().getFirst().requestId()).isEqualTo("audio-2");

again.setStatus(AudioAnalysisStatus.FAILED);
AudioAnalysisResponse closed = service.get(7L, INTERVIEW_ID);
assertThat(closed.answers()).extracting(Answer::status, Answer::requestId)
.containsExactly(tuple(Status.PARTIAL, "audio-1"));
assertThat(closed.canRetry()).isTrue();
}

private void assertRetryBlocked(String reason) {
assertThatThrownBy(() -> service.retry(7L, INTERVIEW_ID))
.isInstanceOfSatisfying(BusinessException.class,
e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT))
.hasMessageContaining(reason);
}

private static Answer answer(Status status, Map<String, Object> timing, Map<String, Object> fluency,
AudioAnalysisResponse.Error error) {
return new Answer("101", "201", "301", "audio-1", status, null, timing, fluency, error);
}

/** 분석 서버가 실패로 알린 라인. data 없이 {code, message, retryable}만 온다. */
private static Map<String, Object> failedLine(String code, boolean retryable) {
Map<String, Object> line = new LinkedHashMap<>();
line.put("status", "unavailable");
line.put("data", null);
line.put("error", Map.of("code", code, "message", "음성 분석의 일부 작업을 완료하지 못했습니다.", "retryable", retryable));
return line;
}

/** 일시 장애로 fluency만 실패한 결과 행. */
private static AudioAnalysisResultEntity retryableFailureRow(AudioAnalysisEntity analysis, long resultId,
long recordingId) {
return AudioAnalysisResultEntity.builder()
.resultId(resultId).analysisId(analysis.getAnalysisId()).recordingId(recordingId)
.questionId(101L).answerId(201L).status("PARTIAL").durationMs(120000L)
.timing(TIMING).fluency(failedLine("EXTERNAL_SERVICE_UNAVAILABLE", true))
.build();
}

private static Status overall(Status... statuses) {
return AudioAnalysisService.overallStatus(List.of(statuses).stream()
.map(status -> new Answer("1", "1", "1", null, status, null, null, null, null))
Expand All @@ -768,9 +933,14 @@ private static Status overall(Status... statuses) {

/** 이 면접의 음성 분석 대기 행. 없으면 면접 기록이 아직 오지 않은 것이다. */
private void dispatch(FeedbackDispatchStatus status) {
dispatch(status, 0);
}

private void dispatch(FeedbackDispatchStatus status, int userRetryCount) {
when(dispatchRepository.findByInterviewIdAndKind(INTERVIEW_ID, FeedbackDispatchKind.AUDIO_ANALYSIS))
.thenReturn(Optional.of(FeedbackDispatchEntity.builder()
.interviewId(INTERVIEW_ID).kind(FeedbackDispatchKind.AUDIO_ANALYSIS).status(status).build()));
.interviewId(INTERVIEW_ID).kind(FeedbackDispatchKind.AUDIO_ANALYSIS).status(status)
.userRetryCount(userRetryCount).build()));
}

private void results(AudioAnalysisResultEntity... rows) {
Expand Down
Loading
Loading