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
Expand Up @@ -21,10 +21,10 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

// callback.questions 처리.
// POOL: 첫 질문을 interview_messages 에 INSERT + SSE session.message push.
// (전체 풀은 first cut 에선 메모리상 처리 — 후속 PR 에서 별도 테이블 또는 컬럼)
// FOLLOWUP: parent_message_id 매핑하여 INSERT + SSE push (B5 진행 시 활성)
// callback.questions handling.
// POOL is kept as the legacy callback kind, but Core treats it as the single
// initial question result. Extra questions are ignored; Core does not manage a pool.
// FOLLOWUP inserts a follow-up question and pushes it through SSE.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
Expand Down Expand Up @@ -65,7 +65,7 @@ public void apply(QuestionsCallbackEnvelope envelope) {
}

if (payload.isPool()) {
applyPool(session, payload);
applyInitialQuestion(session, payload);
} else if (payload.isFollowup()) {
applyFollowup(session, payload);
} else {
Expand All @@ -74,13 +74,17 @@ public void apply(QuestionsCallbackEnvelope envelope) {
markProcessed(envelope.messageId());
}

private void applyPool(InterviewSession session, QuestionsCallbackPayload payload) {
private void applyInitialQuestion(InterviewSession session, QuestionsCallbackPayload payload) {
List<GeneratedQuestion> questions = payload.questions();
if (questions == null || questions.isEmpty()) {
log.warn("callback.questions POOL with no questions. sessionId={}", session.getId());
log.warn("callback.questions initial result with no questions. sessionId={}", session.getId());
return;
}
GeneratedQuestion first = questions.get(0);
if (questions.size() > 1) {
log.info("callback.questions initial result included extra questions; ignoring extras. sessionId={}, extra={}",
session.getId(), questions.size() - 1);
}
InterviewMessage message = messageRepository.save(
InterviewMessage.interviewer(session, 1, first.question())
);
Expand All @@ -90,10 +94,10 @@ private void applyPool(InterviewSession session, QuestionsCallbackPayload payloa
sseEventPublisher.publishToUser(
session.getUser().getId(),
SseEventType.SESSION_MESSAGE,
new SessionMessageNotice(session.getId(), message.getId(), "QUESTION_POOL_READY")
new SessionMessageNotice(session.getId(), message.getId(), "INITIAL_QUESTION_READY")
);
log.info("callback.questions POOL processed. sessionId={}, total={}",
session.getId(), questions.size());
log.info("callback.questions initial question processed. sessionId={}, ignored_extras={}",
session.getId(), Math.max(questions.size() - 1, 0));
}

private void applyFollowup(InterviewSession session, QuestionsCallbackPayload payload) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public class SessionQuestionsRequester {
private static final JsonMapper JSON = JsonMapper.builder().build();
private static final TypeReference<List<String>> TECH_STACK_TYPE = new TypeReference<>() {};
private static final long MAX_MARKDOWN_BYTES = 200_000L; // envelope 비대화 방지
private static final int INITIAL_QUESTION_COUNT = 1;

private final RabbitMessagePublisher publisher;
private final RabbitMqProperties properties;
Expand All @@ -53,15 +54,16 @@ public void onSessionCreated(SessionCreatedEvent event) {
event.mode(),
event.jobCategory(),
documents,
INITIAL_QUESTION_COUNT,
event.maxQuestions()
);
publisher.publishToAi(
properties.routingKeys().generateQuestions(),
payload,
new MessageContext(event.userId(), event.sessionId(), null, null)
);
log.info("generate.questions published. sessionId={}, doc_count={}, max={}",
event.sessionId(), documents.size(), event.maxQuestions());
log.info("generate.questions published. sessionId={}, doc_count={}, initial_count={}, max={}",
event.sessionId(), documents.size(), INITIAL_QUESTION_COUNT, event.maxQuestions());
}

private List<DocumentContext> buildDocumentContexts(List<Long> documentIds) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ public record GenerateQuestionsPayload(
SessionMode mode,
JobCategory jobCategory,
List<DocumentContext> documents,
// Number of questions AI should generate for this initial callback.
Integer initialQuestionCount,
// Overall session question limit; not the generation batch size.
Integer maxQuestions
) {
public record DocumentContext(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
public record QuestionsCallbackPayload(
Long sessionId,
String kind, // POOL | FOLLOWUP
List<GeneratedQuestion> questions, // POOL 시 사용
List<GeneratedQuestion> questions, // POOL legacy kind: initial question result
Long parentMessageId, // FOLLOWUP 시 사용
String followupQuestion, // FOLLOWUP 시 사용
AnswerEvaluation answerEvaluation // FOLLOWUP 시 옵션
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
Expand All @@ -26,6 +27,7 @@
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
Expand All @@ -42,7 +44,7 @@ class QuestionsCallbackServiceTest {
@InjectMocks QuestionsCallbackService service;

@Test
void apply_poolInsertsFirstQuestionAndPushesSse() {
void apply_poolCallbackStoresOnlyInitialQuestionAndPushesSse() {
InterviewSession session = sessionFixture(11L, SessionStatus.READY);
QuestionsCallbackEnvelope env = poolEnvelope(11L, List.of(
new GeneratedQuestion("INTRO", "Introduce yourself"),
Expand All @@ -58,8 +60,14 @@ void apply_poolInsertsFirstQuestionAndPushesSse() {

service.apply(env);

verify(messageRepository).save(any(InterviewMessage.class));
ArgumentCaptor<InterviewMessage> messageCaptor = ArgumentCaptor.forClass(InterviewMessage.class);
verify(messageRepository).save(messageCaptor.capture());
assertThat(messageCaptor.getValue().getContent()).isEqualTo("Introduce yourself");
assertThat(messageCaptor.getValue().getSequenceNumber()).isEqualTo(1);
verify(sseEventPublisher).publishToSession(eq(11L), eq(SseEventType.SESSION_MESSAGE), any());
verify(sseEventPublisher).publishToUser(eq(1L), eq(SseEventType.SESSION_MESSAGE),
argThat(payload -> payload instanceof QuestionsCallbackService.SessionMessageNotice notice
&& "INITIAL_QUESTION_READY".equals(notice.reason())));
verify(processedMessageRepository).save(any(ProcessedMessage.class));
assertThat(session.getTotalQuestionCount()).isEqualTo(1);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.stackup.stackup.session.application;

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

import com.stackup.stackup.common.config.properties.RabbitMqProperties;
import com.stackup.stackup.common.messaging.MessageContext;
import com.stackup.stackup.common.messaging.RabbitMessagePublisher;
import com.stackup.stackup.common.storage.ObjectStorageClient;
import com.stackup.stackup.document.domain.AnalyzedDocumentRepository;
import com.stackup.stackup.session.application.dto.GenerateQuestionsPayload;
import com.stackup.stackup.session.application.event.SessionCreatedEvent;
import com.stackup.stackup.session.domain.JobCategory;
import com.stackup.stackup.session.domain.SessionMode;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class SessionQuestionsRequesterTest {

@Mock RabbitMessagePublisher publisher;
@Mock RabbitMqProperties properties;
@Mock AnalyzedDocumentRepository documentRepository;
@Mock ObjectStorageClient storage;
@InjectMocks SessionQuestionsRequester requester;

@Test
void onSessionCreated_requestsOneInitialQuestionAndKeepsSessionLimit() {
when(properties.routingKeys()).thenReturn(mockRoutingKeys());

requester.onSessionCreated(new SessionCreatedEvent(
1L,
11L,
SessionMode.INTEGRATED,
JobCategory.BACKEND,
5,
List.of()
));

ArgumentCaptor<GenerateQuestionsPayload> payloadCaptor =
ArgumentCaptor.forClass(GenerateQuestionsPayload.class);
verify(publisher).publishToAi(eq("generate.questions"), payloadCaptor.capture(), any(MessageContext.class));

GenerateQuestionsPayload payload = payloadCaptor.getValue();
assertThat(payload.sessionId()).isEqualTo(11L);
assertThat(payload.mode()).isEqualTo(SessionMode.INTEGRATED);
assertThat(payload.jobCategory()).isEqualTo(JobCategory.BACKEND);
assertThat(payload.documents()).isEmpty();
assertThat(payload.initialQuestionCount()).isEqualTo(1);
assertThat(payload.maxQuestions()).isEqualTo(5);
}

private RabbitMqProperties.RoutingKeyProperties mockRoutingKeys() {
return new RabbitMqProperties.RoutingKeyProperties(
"analyze.resume", "analyze.repository",
"generate.questions", "generate.followup", "generate.feedback", "analyze.voice",
"callback.analysis", "callback.questions", "callback.feedback", "callback.voice",
"realtime.session.notify");
}
}