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
43 changes: 43 additions & 0 deletions docs/behavior-analysis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# 행동 분석 연동에 대한 Chat 계약

## 역할

전체 면접 영상은 Web → API → S3 경로로 업로드한다.
API가 영상과 면접 식별자를 연결하고 Analysis에 분석을 요청한다.
Chat은 기존 질문·답변 기록을 API에 전달한다.
영상 업로드와 행동 분석 완료를 기다리는 단계는 Chat에 추가하지 않는다.
텍스트 면접도 동일한 종료 경로를 사용한다.

## 식별자와 중복 준비

- 준비 요청의 `sessionId`, `interviewId`, `userId`를 세션과 준비 응답에 그대로 보존한다.
- SOLO와 MULTI 모두 종료 본문에 동일 식별자와 `mode`를 전달한다.
- 같은 세션과 같은 면접·사용자로 준비를 재시도하면 기존 진행 상태를 반환한다.
- 같은 `sessionId`에 다른 `interviewId`나 `userId`를 보내면 HTTP 409 `ALREADY_IN_USE`로 거절한다.
- 식별자가 충돌한 요청은 기존 세션을 덮어쓰거나 TTS를 다시 등록하지 않는다.

## 종료와 전송

자동 종료·명시적 완료·중단은 기존 최종 기록 보존 경로를 사용한다.
마지막 답변을 저장한 후 최종 본문을 전송 대기 저장소에 보존한다.
API 타임아웃은 기존 정책에 따라 대기 기록을 보존하고 종료 응답을 반환한다.
재전송은 최초 직렬화 본문을 사용하므로 식별자와 답변 시각이 바뀌지 않는다.
중복 답변·완료·중단 요청은 기존 종료 상태를 반환한다.
인증 오류 등 재시도 불가능한 API 실패는 기존 오류 정책을 유지한다.

종료 본문의 최상위 필드는 다음과 같다.
`sessionId`, `interviewId`, `userId`, `status`, `mode`, `interviewCreatedAt`, `qnaRequests`.
영상 URL·행동 지표·landmark·모델 버전을 Redis 세션이나 종료 본문·AI 프롬프트에 추가하지 않는다.

## 회귀 검증

`ChatInterviewIdentityTest`의 8개 사례가 준비 식별자 보존·동일 요청 재시도·식별자 충돌 거절을 검증한다.
`BehaviorInterviewFlowTest`의 8개 사례가 SOLO/MULTI의 자동 종료·명시적 완료·중단과 타임아웃 재전송을 검증한다.
후자는 실제 Redis 직렬화기·Outbox·Delivery 서비스를 연결하고 Redis I/O와 API 클라이언트를 대역으로 사용한다.
기존 `InterviewResultRedisTest`는 실제 로컬 Redis를 사용하여 장애 복구와 잠금 동작을 검증한다.

검증 명령은 Java 21에서 `./gradlew test`다.
이번 변경에서는 `./gradlew --offline --no-daemon test`로 전체 263개 테스트가 통과했고 실패·건너뜀은 없었다.
수정 전에는 식별자 충돌 거절 4개 사례가 실패하는 것도 확인했다.
S3 업로드 순서와 API 분석 요청·콜백·Web 중복 업로드 방지는 해당 서버의 통합 검증 대상이다.
질문별 영상 시간축과 실시간 행동 분석은 이번 Chat 계약에 포함하지 않는다.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import com.person.repit.interview.exception.InterviewQuestionMismatchException;
import com.person.repit.interview.exception.InterviewQuestionNotFoundException;
import com.person.repit.interview.exception.InterviewSessionDataException;
import com.person.repit.interview.exception.InterviewSessionAlreadyExistsException;
import com.person.repit.interview.exception.InterviewSessionNotFoundException;
import com.person.repit.interview.service.AiQuestionClient;
import com.person.repit.interview.service.InterviewOperationLock;
Expand All @@ -44,6 +45,7 @@
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;

@Service
@RequiredArgsConstructor
Expand Down Expand Up @@ -120,7 +122,22 @@ public Mono<ChatInterviewResponse> prepareInterview(ChatInterviewPrepareRequest
.flatMap(created -> Boolean.TRUE.equals(created)
? registerOriginalQuestions(session)
.thenReturn(ChatInterviewResponse.from(session))
: getSession(request.getSessionId()).map(ChatInterviewResponse::from));
: existingPreparation(request));
});
}

private Mono<ChatInterviewResponse> existingPreparation(ChatInterviewPrepareRequest request) {
return getSession(request.getSessionId()).flatMap(existing -> {
boolean sameIdentity = Objects.equals(existing.getSessionId(), request.getSessionId())
&& Objects.equals(existing.getInterviewId(), request.getInterviewId())
&& Objects.equals(existing.getUserId(), request.getUserId());
if (!sameIdentity) {
// A session ID cannot silently resolve to another interview or user.
return Mono.error(new InterviewSessionAlreadyExistsException(
"이미 다른 면접 식별자로 준비된 세션입니다."
));
}
return Mono.just(ChatInterviewResponse.from(existing));
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package com.person.repit.interview.service.impl;

import com.fasterxml.jackson.databind.JsonNode;
import com.person.repit.common.config.JacksonConfig;
import com.person.repit.common.metrics.RepitMetrics;
import com.person.repit.interview.domain.ChatInterviewSession;
import com.person.repit.interview.domain.ChatQuestion;
import com.person.repit.interview.dto.request.ChatAnswerRequest;
import com.person.repit.interview.dto.response.ChatProgressResponse;
import com.person.repit.interview.service.*;
import com.person.repit.interview.type.*;
import com.person.repit.tts.service.InterviewTtsScheduler;
import com.person.repit.tts.service.PersonaTtsProfileResolver;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.EnumSource;
import org.springframework.data.redis.core.ReactiveRedisTemplate;
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
import org.springframework.data.redis.core.ReactiveValueOperations;
import org.springframework.data.redis.serializer.RedisSerializer;
import reactor.core.publisher.Mono;

import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeoutException;
import java.util.function.Supplier;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;

/** Exercises service -> serialized session -> outbox -> delivery without video/analysis infrastructure. */
@SuppressWarnings("unchecked")
class BehaviorInterviewFlowTest {
private final ReactiveRedisTemplate<String, Object> redis = mock(ReactiveRedisTemplate.class);
private final ReactiveValueOperations<String, Object> values = mock(ReactiveValueOperations.class);
private final ReactiveStringRedisTemplate strings = mock(ReactiveStringRedisTemplate.class);
private final ReactiveValueOperations<String, String> stringValues = mock(ReactiveValueOperations.class);
private final Map<String, byte[]> sessions = new HashMap<>();
private final Map<String, String> records = new HashMap<>();
private final List<String> sent = new ArrayList<>();
private final RedisSerializer<Object> serializer = RedisSerializer.json();
private final ApiServerClient api = mock(ApiServerClient.class);
private final AiQuestionClient ai = mock(AiQuestionClient.class);
private final InterviewTtsScheduler tts = mock(InterviewTtsScheduler.class);
private final com.fasterxml.jackson.databind.ObjectMapper mapper = new JacksonConfig().objectMapper();
private ChatInterviewServiceImpl service;
private InterviewResultOutbox outbox;
private InterviewResultDeliveryService delivery;

@BeforeEach
void setUp() {
when(redis.opsForValue()).thenReturn(values);
when(strings.opsForValue()).thenReturn(stringValues);
when(values.get(anyString())).thenAnswer(call -> Mono.fromSupplier(() ->
serializer.deserialize(sessions.get(call.getArgument(0)))));
when(values.set(anyString(), any(), any(Duration.class))).thenAnswer(call -> saveSession(call));
when(values.set(anyString(), any())).thenAnswer(call -> saveSession(call));
when(redis.delete(anyString())).thenAnswer(call -> Mono.fromSupplier(() ->
sessions.remove(call.getArgument(0)) == null ? 0L : 1L));
when(stringValues.get(anyString())).thenAnswer(call ->
Mono.fromSupplier(() -> records.get(call.getArgument(0))));
when(stringValues.setIfAbsent(anyString(), anyString())).thenAnswer(call ->
Mono.fromSupplier(() -> records.putIfAbsent(call.getArgument(0), call.getArgument(1)) == null));
when(stringValues.set(anyString(), anyString())).thenAnswer(call -> Mono.fromSupplier(() -> {
records.put(call.getArgument(0), call.getArgument(1));
return true;
}));
when(api.saveInterviewPayload(anyString())).thenAnswer(call -> Mono.fromRunnable(() ->
sent.add(call.getArgument(0))));
var lock = mock(InterviewOperationLock.class);
when(lock.execute(anyString(), any())).thenAnswer(call ->
((Supplier<Mono<?>>) call.getArgument(1)).get());
var meters = new SimpleMeterRegistry();
outbox = new InterviewResultOutbox(strings, mapper);
delivery = new InterviewResultDeliveryService(outbox, api, new InterviewResultRetryPolicy(), meters);
service = new ChatInterviewServiceImpl(ai, redis, delivery, lock, new RepitMetrics(meters),
mock(PersonaTtsProfileResolver.class), tts);
}

@ParameterizedTest
@CsvSource({"SOLO,AUTO", "MULTI,AUTO", "SOLO,COMPLETE", "MULTI,COMPLETE", "SOLO,QUIT", "MULTI,QUIT"})
void 영상_업로드나_분석없이_종료기록의_식별자를_보존한다(InterviewMode mode, String path) throws Exception {
prepareSession(mode);
Mono<ChatProgressResponse> operation = switch (path) {
case "AUTO" -> service.submitAnswer("api-session", answer());
case "QUIT" -> service.quitInterview("api-session");
default -> service.completeInterview("api-session");
};
var expected = path.equals("QUIT") ? InterviewStatus.ABANDONED : InterviewStatus.COMPLETED;
assertThat(operation.block(Duration.ofSeconds(2)).getStatus()).isEqualTo(expected);
assertThat(sent).hasSize(1);
var payload = mapper.readTree(sent.getFirst());
assertIdentity(payload, mode);
assertThat(payload.path("status").asText()).isEqualTo(expected.name());
assertThat(payload.path("qnaRequests").get(0).path("question").path("id").asLong()).isEqualTo(303L);
assertThat(payload.path("qnaRequests").get(0).path("question").path("personaId").asLong()).isEqualTo(404L);
if (path.equals("AUTO")) {
assertThat(payload.path("qnaRequests").get(0).path("answer").path("answerContent").asText())
.isEqualTo("마지막 답변");
}
assertThat(sessions).isEmpty();
verify(tts).cancelSession("api-session");
verifyNoInteractions(ai);
// Duplicate completion / answer / quit all return the original completion status.
assertThat(service.completeInterview("api-session").block().getStatus()).isEqualTo(expected);
assertThat(service.quitInterview("api-session").block().getStatus()).isEqualTo(expected);
assertThat(service.submitAnswer("api-session", answer()).block().getStatus()).isEqualTo(expected);
verify(api).saveInterviewPayload(anyString());
}

@ParameterizedTest
@EnumSource(InterviewMode.class)
void API_타임아웃후에도_종료하고_같은_식별자와_최종답변으로_재전송한다(InterviewMode mode) throws Exception {
prepareSession(mode);
when(api.saveInterviewPayload(anyString())).thenAnswer(call -> {
sent.add(call.getArgument(0));
return sent.size() == 1 ? Mono.error(new TimeoutException()) : Mono.empty();
});
assertThat(service.submitAnswer("api-session", answer()).block(Duration.ofSeconds(2)).getStatus())
.isEqualTo(InterviewStatus.COMPLETED);
assertThat(sessions).isEmpty();
var pending = outbox.find("api-session").block();
assertThat(pending.getState()).isEqualTo(com.person.repit.interview.domain.InterviewResultDelivery.State.PENDING);
assertIdentity(mapper.readTree(pending.getPayload()), mode);
assertThat(pending.getPayload()).isEqualTo(sent.getFirst()).contains("마지막 답변");
assertThat(service.completeInterview("api-session").block().getStatus()).isEqualTo(InterviewStatus.COMPLETED);
verify(api).saveInterviewPayload(anyString());
pending.setNextAttemptAt(Instant.EPOCH);
delivery.deliver(pending).block(Duration.ofSeconds(2));
assertThat(sent).hasSize(2);
assertThat(sent.get(1)).isEqualTo(sent.getFirst());
var restored = outbox.find("api-session").block();
assertThat(restored.getState()).isEqualTo(com.person.repit.interview.domain.InterviewResultDelivery.State.SENT);
assertThat(restored.getPayload()).isNull();
verifyNoInteractions(ai);
}

private Mono<Boolean> saveSession(org.mockito.invocation.InvocationOnMock call) {
return Mono.fromSupplier(() -> {
sessions.put(call.getArgument(0), serializer.serialize(call.getArgument(1)));
return true;
});
}

private void prepareSession(InterviewMode mode) {
var session = ChatInterviewSession.builder().sessionId("api-session").interviewId(101L).userId(202L)
.mode(mode).createdAt(LocalDateTime.of(2026, 10, 20, 10, 0))
.questions(new ArrayList<>(List.of(ChatQuestion.builder().questionId(303L)
.askedByPersonaId(404L).type(QuestionType.ORIGINAL).content("질문").build())))
.build();
sessions.put("chat:interview:api-session", serializer.serialize(session));
}

private void assertIdentity(JsonNode payload, InterviewMode mode) {
assertThat(payload.path("sessionId").asText()).isEqualTo("api-session");
assertThat(payload.path("interviewId").asLong()).isEqualTo(101L);
assertThat(payload.path("userId").asLong()).isEqualTo(202L);
assertThat(payload.path("mode").asText()).isEqualTo(mode.name());
var fields = new ArrayList<String>();
payload.fieldNames().forEachRemaining(fields::add);
assertThat(fields).containsExactlyInAnyOrder("sessionId", "interviewId", "userId", "status",
"mode", "interviewCreatedAt", "qnaRequests");
}

private ChatAnswerRequest answer() {
return ChatAnswerRequest.builder().questionId(303L).content("마지막 답변").responseTime(12).build();
}
}
Loading