diff --git a/ai/src/ai_server/chain/prompts/question_generation.py b/ai/src/ai_server/chain/prompts/question_generation.py index 7c49a7cc..d3f50d4c 100644 --- a/ai/src/ai_server/chain/prompts/question_generation.py +++ b/ai/src/ai_server/chain/prompts/question_generation.py @@ -22,7 +22,8 @@ "{context}\n" "---\n\n" "요구 사항:\n" - "1. 정확히 {max_questions}개의 질문을 생성합니다.\n" + "1. 정확히 {max_questions}개의 질문을 생성하되, **서로 다른 주제/영역**을 다룹니다 " + "(각 질문이 일반질문 1개의 씨앗이 되고 뒤에 꼬리질문이 붙으므로 같은 주제 반복 금지).\n" "2. 각 질문에 적절한 category 를 부여합니다.\n" "3. 직군({job_category})·면접 모드({mode}) 에 맞는 비중으로 카테고리 분배:\n" " - TECHNICAL: CS_FUNDAMENTAL + TECH_CHOICE + PROJECT_DEEP_DIVE 중심.\n" diff --git a/backend/openapi.json b/backend/openapi.json index 9a6923ea..887a1e0f 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -2529,6 +2529,18 @@ "type" : "integer", "format" : "int32" }, + "generalQuestionCount" : { + "type" : "integer", + "format" : "int32", + "maximum" : 15, + "minimum" : 1 + }, + "maxFollowupsPerQuestion" : { + "type" : "integer", + "format" : "int32", + "maximum" : 10, + "minimum" : 0 + }, "contextDocumentIds" : { "type" : "array", "items" : { @@ -2568,6 +2580,14 @@ "type" : "integer", "format" : "int32" }, + "generalQuestionCount" : { + "type" : "integer", + "format" : "int32" + }, + "maxFollowupsPerQuestion" : { + "type" : "integer", + "format" : "int32" + }, "status" : { "type" : "string", "enum" : [ "READY", "IN_PROGRESS", "INTERRUPTED", "COMPLETED", "CANCELLED" ] diff --git a/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java b/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java index 4b3dfcef..b6d117da 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java @@ -14,6 +14,9 @@ import com.stackup.stackup.session.domain.InterviewMessageRepository; import com.stackup.stackup.session.domain.InterviewSession; import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.SessionQuestionPool; +import com.stackup.stackup.session.domain.SessionQuestionPoolRepository; +import com.stackup.stackup.session.domain.SessionStatus; import java.util.List; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; @@ -36,6 +39,7 @@ public class QuestionsCallbackService { private final InterviewSessionRepository sessionRepository; private final InterviewMessageRepository messageRepository; + private final SessionQuestionPoolRepository poolRepository; private final ProcessedMessageRepository processedMessageRepository; private final ApplicationEventPublisher events; @@ -74,35 +78,83 @@ public void apply(QuestionsCallbackEnvelope envelope) { markProcessed(envelope.messageId()); } + // POOL 콜백: AI 가 만든 일반질문들을 풀에 저장하고 첫 질문을 삽입한다. private void applyInitialQuestion(InterviewSession session, QuestionsCallbackPayload payload) { List questions = payload.questions(); if (questions == null || questions.isEmpty()) { 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); + if (poolRepository.countBySessionId(session.getId()) > 0) { + log.info("callback.questions pool already seeded, skip. sessionId={}", session.getId()); + return; + } + int idx = 0; + for (GeneratedQuestion q : questions) { + poolRepository.save(SessionQuestionPool.of( + session.getId(), idx++, q.question(), q.category(), q.targetEvidence(), q.expectedSignal())); + } + poolRepository.findFirstBySessionIdAndUsedFalseOrderByIdxAsc(session.getId()) + .ifPresent(first -> insertGeneralFromPool(session, first, "INITIAL_QUESTION_READY")); + log.info("callback.questions pool seeded. sessionId={}, poolSize={}", session.getId(), questions.size()); + } + + // 풀에서 다음 일반질문을 꺼내 삽입(꼬리질문 m개 소진 후 호출). 없으면 종료. + @Transactional + public void advanceToNextGeneral(Long sessionId) { + InterviewSession session = sessionRepository.findById(sessionId).orElse(null); + if (session == null || session.getStatus() != SessionStatus.IN_PROGRESS) { + return; } + if (session.isMaxReached()) { + endSession(session, "MAX_QUESTIONS_REACHED"); + return; + } + poolRepository.findFirstBySessionIdAndUsedFalseOrderByIdxAsc(sessionId).ifPresentOrElse( + next -> insertGeneralFromPool(session, next, "GENERAL_QUESTION_READY"), + () -> endSession(session, "POOL_EXHAUSTED")); + } + + private void insertGeneralFromPool(InterviewSession session, SessionQuestionPool pool, String reason) { + pool.markUsed(); + poolRepository.save(pool); + long currentMsgs = messageRepository.countBySession_Id(session.getId()); + int nextSeq = (int) currentMsgs + 1; InterviewMessage message = messageRepository.save( - InterviewMessage.interviewer( - session, 1, first.question(), - first.category(), first.targetEvidence(), first.expectedSignal() - ) - ); + InterviewMessage.interviewer(session, nextSeq, pool.getQuestion(), + pool.getCategory(), pool.getTargetEvidence(), pool.getExpectedSignal())); session.incrementQuestionCount(); + publishQuestionEvents(session, message, reason); + maybeAutoEnd(session); + } + + private void publishQuestionEvents(InterviewSession session, InterviewMessage message, String reason) { events.publishEvent(new QuestionPersistedEvent( session.getUser().getId(), session.getId(), message.getId())); events.publishEvent(RealtimeNotifyEvent.session(session.getId(), SseEventType.SESSION_MESSAGE, message.getId())); - // 사용자 user 채널에도 알림 — frontend 가 documentId/sessionId 사전 인지 없이도 받을 수 있게 - events.publishEvent(RealtimeNotifyEvent.user( - session.getUser().getId(), - SseEventType.SESSION_MESSAGE, - new SessionMessageNotice(session.getId(), message.getId(), "INITIAL_QUESTION_READY") - )); - log.info("callback.questions initial question processed. sessionId={}, ignored_extras={}", - session.getId(), Math.max(questions.size() - 1, 0)); + events.publishEvent(RealtimeNotifyEvent.user(session.getUser().getId(), SseEventType.SESSION_MESSAGE, + new SessionMessageNotice(session.getId(), message.getId(), reason))); + } + + private void maybeAutoEnd(InterviewSession session) { + if (session.isMaxReached()) { + endSession(session, "MAX_QUESTIONS_REACHED"); + } + } + + private void endSession(InterviewSession session, String reason) { + try { + session.end(); + events.publishEvent(RealtimeNotifyEvent.session(session.getId(), SseEventType.SESSION_STATE, + new SessionStateNotice(session.getId(), session.getStatus().name(), reason))); + events.publishEvent(RealtimeNotifyEvent.user(session.getUser().getId(), SseEventType.SESSION_STATE, + new SessionStateNotice(session.getId(), session.getStatus().name(), reason))); + events.publishEvent(new SessionEndedEvent(session.getUser().getId(), session.getId(), reason)); + log.info("session auto-completed. sessionId={}, reason={}", session.getId(), reason); + } catch (IllegalStateException e) { + log.warn("auto-end skipped — session not IN_PROGRESS. sessionId={}, status={}", + session.getId(), session.getStatus()); + } } private void applyFollowup(InterviewSession session, QuestionsCallbackPayload payload) { @@ -135,25 +187,8 @@ private void applyFollowup(InterviewSession session, QuestionsCallbackPayload pa new SessionMessageNotice(session.getId(), message.getId(), "FOLLOWUP_READY") )); - // maxQuestions 도달 시 자동 종료 (plan §A-4) - Integer max = session.getMaxQuestions(); - if (max != null && session.getTotalQuestionCount() != null - && session.getTotalQuestionCount() >= max) { - try { - session.end(); - events.publishEvent(RealtimeNotifyEvent.session(session.getId(), SseEventType.SESSION_STATE, - new SessionStateNotice(session.getId(), session.getStatus().name(), "MAX_QUESTIONS_REACHED"))); - events.publishEvent(RealtimeNotifyEvent.user(session.getUser().getId(), SseEventType.SESSION_STATE, - new SessionStateNotice(session.getId(), session.getStatus().name(), "MAX_QUESTIONS_REACHED"))); - events.publishEvent(new SessionEndedEvent( - session.getUser().getId(), session.getId(), "MAX_QUESTIONS_REACHED")); - log.info("session auto-completed on max questions. sessionId={}, max={}", - session.getId(), max); - } catch (IllegalStateException e) { - log.warn("auto-end skipped — session not IN_PROGRESS. sessionId={}, status={}", - session.getId(), session.getStatus()); - } - } + // maxQuestions(k) 도달 시 자동 종료. + maybeAutoEnd(session); log.info("callback.questions FOLLOWUP processed. sessionId={}, msg={}, totalQ={}", session.getId(), message.getId(), session.getTotalQuestionCount()); diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionFollowupRequester.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionFollowupRequester.java index d061d10b..1a6e2e3b 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionFollowupRequester.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionFollowupRequester.java @@ -9,6 +9,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.MessageRole; import com.stackup.stackup.session.domain.SessionContextRepository; import java.util.List; import lombok.RequiredArgsConstructor; @@ -33,6 +34,7 @@ public class SessionFollowupRequester { private final RabbitMqProperties properties; private final InterviewMessageRepository messageRepository; private final SessionContextRepository contextRepository; + private final QuestionsCallbackService questionsCallbackService; @Transactional(propagation = Propagation.REQUIRES_NEW) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @@ -54,6 +56,19 @@ public void onAnswerSubmitted(AnswerSubmittedEvent event) { // 최근 대화 히스토리 (중복 질문 회피). 시퀀스 순 → 마지막 N개. List ordered = messageRepository.findBySession_IdOrderBySequenceNumberAsc(session.getId()); + + // 깊이 제어: 현재 일반질문 아래 꼬리질문 수(depth) 가 m 이상이면 + // 꼬리질문 대신 풀의 다음 일반질문으로 넘어간다(없으면 종료). AI 호출 불필요. + int depth = currentFollowupDepth(ordered); + int maxFollowups = session.getMaxFollowupsPerQuestion() != null + ? session.getMaxFollowupsPerQuestion() : 2; + if (depth >= maxFollowups) { + log.info("followup depth reached (m={}) — advancing to next general. sessionId={}", + maxFollowups, session.getId()); + questionsCallbackService.advanceToNextGeneral(session.getId()); + return; + } + List history = ordered.stream() .skip(Math.max(0, ordered.size() - HISTORY_MAX)) .map(m -> new HistoryItem(m.getRole().name(), m.getContent())) @@ -80,4 +95,21 @@ public void onAnswerSubmitted(AnswerSubmittedEvent event) { log.info("generate.followup published. sessionId={}, parent={}, answer={}", session.getId(), parent.getId(), answer.getId()); } + + // 현재 일반질문(INTERVIEWER + parent null) 이후 달린 꼬리질문(INTERVIEWER + parent 있음) 수. + // 새 일반질문이 나오면 0 으로 리셋된다. + private int currentFollowupDepth(List ordered) { + int depth = 0; + for (InterviewMessage m : ordered) { + if (m.getRole() != MessageRole.INTERVIEWER) { + continue; + } + if (m.getParentMessage() == null) { + depth = 0; // 일반질문 → 리셋 + } else { + depth++; // 꼬리질문 → 증가 + } + } + return depth; + } } diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionQuestionsRequester.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionQuestionsRequester.java index 4c30ae32..84744ce6 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionQuestionsRequester.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionQuestionsRequester.java @@ -38,7 +38,7 @@ public class SessionQuestionsRequester { private static final JsonMapper JSON = JsonMapper.builder().build(); private static final TypeReference> TECH_STACK_TYPE = new TypeReference<>() {}; private static final long MAX_MARKDOWN_BYTES = 200_000L; // envelope 비대화 방지 - private static final int INITIAL_QUESTION_COUNT = 1; + private static final int DEFAULT_GENERAL_QUESTION_COUNT = 3; private final RabbitMessagePublisher publisher; private final RabbitMqProperties properties; @@ -49,12 +49,14 @@ public class SessionQuestionsRequester { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onSessionCreated(SessionCreatedEvent event) { List documents = buildDocumentContexts(event.contextDocumentIds()); + int generalCount = event.generalQuestionCount() != null + ? event.generalQuestionCount() : DEFAULT_GENERAL_QUESTION_COUNT; GenerateQuestionsPayload payload = new GenerateQuestionsPayload( event.sessionId(), event.mode(), event.jobCategory(), documents, - INITIAL_QUESTION_COUNT, + generalCount, event.maxQuestions() ); publisher.publishToAi( @@ -62,8 +64,8 @@ public void onSessionCreated(SessionCreatedEvent event) { payload, new MessageContext(event.userId(), event.sessionId(), null, null) ); - log.info("generate.questions published. sessionId={}, doc_count={}, initial_count={}, max={}", - event.sessionId(), documents.size(), INITIAL_QUESTION_COUNT, event.maxQuestions()); + log.info("generate.questions published. sessionId={}, doc_count={}, general_count={}, max={}", + event.sessionId(), documents.size(), generalCount, event.maxQuestions()); } private List buildDocumentContexts(List documentIds) { diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java index c6083275..53a6d479 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java @@ -50,7 +50,9 @@ public SessionResult create(Long userId, SessionCreateCommand command) { command.mode(), command.jobCategory(), command.maxQuestions(), - command.maxDurationMinutes() + command.maxDurationMinutes(), + command.generalQuestionCount(), + command.maxFollowupsPerQuestion() )); List linkedIds = linkContexts(session, userId, command.contextDocumentIds()); @@ -62,6 +64,7 @@ public SessionResult create(Long userId, SessionCreateCommand command) { session.getMode(), session.getJobCategory(), session.getMaxQuestions(), + session.getGeneralQuestionCount(), linkedIds )); return SessionResult.of(session, linkedIds); diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionCreateCommand.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionCreateCommand.java index 8b667b8f..cf2c9669 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionCreateCommand.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionCreateCommand.java @@ -11,6 +11,8 @@ public record SessionCreateCommand( JobCategory jobCategory, Integer maxQuestions, Integer maxDurationMinutes, + Integer generalQuestionCount, + Integer maxFollowupsPerQuestion, List contextDocumentIds ) { } diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionResult.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionResult.java index c4ff6fe0..b2ff0583 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionResult.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionResult.java @@ -15,6 +15,8 @@ public record SessionResult( JobCategory jobCategory, Integer maxQuestions, Integer maxDurationMinutes, + Integer generalQuestionCount, + Integer maxFollowupsPerQuestion, SessionStatus status, Integer totalQuestionCount, Instant startedAt, @@ -32,6 +34,8 @@ public static SessionResult of(InterviewSession session, List documentIds) session.getJobCategory(), session.getMaxQuestions(), session.getMaxDurationMinutes(), + session.getGeneralQuestionCount(), + session.getMaxFollowupsPerQuestion(), session.getStatus(), session.getTotalQuestionCount(), session.getStartedAt(), diff --git a/backend/src/main/java/com/stackup/stackup/session/application/event/SessionCreatedEvent.java b/backend/src/main/java/com/stackup/stackup/session/application/event/SessionCreatedEvent.java index 97f656a4..19d310b3 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/event/SessionCreatedEvent.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/event/SessionCreatedEvent.java @@ -11,6 +11,7 @@ public record SessionCreatedEvent( SessionMode mode, JobCategory jobCategory, Integer maxQuestions, + Integer generalQuestionCount, List contextDocumentIds ) { } diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java index 998bec4d..920fa51c 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java @@ -60,6 +60,14 @@ public class InterviewSession extends BaseSoftDeleteEntity { @Column(name = "max_duration_minutes", nullable = false) private Integer maxDurationMinutes = 60; + // 일반질문 수(n). 서로 다른 주제로 풀에서 꺼내 묻는다. + @Column(name = "general_question_count", nullable = false) + private Integer generalQuestionCount = 3; + + // 일반질문 1개당 최대 꼬리질문 수(m). + @Column(name = "max_followups_per_question", nullable = false) + private Integer maxFollowupsPerQuestion = 2; + @Column(nullable = false, length = 20) @Enumerated(EnumType.STRING) private SessionStatus status = SessionStatus.READY; @@ -75,7 +83,8 @@ public class InterviewSession extends BaseSoftDeleteEntity { private InterviewSession(User user, String title, String memo, SessionMode mode, JobCategory jobCategory, - Integer maxQuestions, Integer maxDurationMinutes) { + Integer maxQuestions, Integer maxDurationMinutes, + Integer generalQuestionCount, Integer maxFollowupsPerQuestion) { this.user = user; this.title = title; this.memo = memo; @@ -87,18 +96,26 @@ private InterviewSession(User user, String title, String memo, SessionMode mode, if (maxDurationMinutes != null) { this.maxDurationMinutes = maxDurationMinutes; } + if (generalQuestionCount != null) { + this.generalQuestionCount = generalQuestionCount; + } + if (maxFollowupsPerQuestion != null) { + this.maxFollowupsPerQuestion = maxFollowupsPerQuestion; + } } public static InterviewSession create(User user, String title, String memo, SessionMode mode, JobCategory jobCategory, - Integer maxQuestions, Integer maxDurationMinutes) { + Integer maxQuestions, Integer maxDurationMinutes, + Integer generalQuestionCount, Integer maxFollowupsPerQuestion) { if (user == null) { throw new IllegalArgumentException("user must not be null"); } if (mode == null || jobCategory == null) { throw new IllegalArgumentException("mode/jobCategory must not be null"); } - return new InterviewSession(user, title, memo, mode, jobCategory, maxQuestions, maxDurationMinutes); + return new InterviewSession(user, title, memo, mode, jobCategory, + maxQuestions, maxDurationMinutes, generalQuestionCount, maxFollowupsPerQuestion); } public void start() { diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/SessionQuestionPool.java b/backend/src/main/java/com/stackup/stackup/session/domain/SessionQuestionPool.java new file mode 100644 index 00000000..9dd68190 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/domain/SessionQuestionPool.java @@ -0,0 +1,63 @@ +package com.stackup.stackup.session.domain; + +import com.stackup.stackup.common.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +// AI 가 생성한 일반질문 풀 항목. 진행하며 idx 순으로 하나씩 꺼내 일반질문으로 삽입한다. +@Entity +@Table(name = "session_question_pool") +@Getter +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +public class SessionQuestionPool extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "session_id", nullable = false) + private Long sessionId; + + @Column(nullable = false) + private Integer idx; + + @Column(nullable = false, columnDefinition = "text") + private String question; + + @Column(length = 40) + private String category; + + @Column(name = "target_evidence", columnDefinition = "text") + private String targetEvidence; + + @Column(name = "expected_signal", columnDefinition = "text") + private String expectedSignal; + + @Column(nullable = false) + private boolean used = false; + + private SessionQuestionPool(Long sessionId, int idx, String question, + String category, String targetEvidence, String expectedSignal) { + this.sessionId = sessionId; + this.idx = idx; + this.question = question; + this.category = category; + this.targetEvidence = targetEvidence; + this.expectedSignal = expectedSignal; + } + + public static SessionQuestionPool of(Long sessionId, int idx, String question, + String category, String targetEvidence, String expectedSignal) { + return new SessionQuestionPool(sessionId, idx, question, category, targetEvidence, expectedSignal); + } + + public void markUsed() { + this.used = true; + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/SessionQuestionPoolRepository.java b/backend/src/main/java/com/stackup/stackup/session/domain/SessionQuestionPoolRepository.java new file mode 100644 index 00000000..c9d122f9 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/domain/SessionQuestionPoolRepository.java @@ -0,0 +1,12 @@ +package com.stackup.stackup.session.domain; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SessionQuestionPoolRepository extends JpaRepository { + + // 다음에 쓸 일반질문(미사용 중 idx 최솟값). + Optional findFirstBySessionIdAndUsedFalseOrderByIdxAsc(Long sessionId); + + long countBySessionId(Long sessionId); +} diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java index c59be710..f42179fa 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java @@ -15,10 +15,14 @@ public record SessionCreateRequest( // mode: TECHNICAL/PERSONALITY/INTEGRATED 중 하나를 선택한다. @NotNull SessionMode mode, @NotNull JobCategory jobCategory, - // 첫 질문 + 꼬리질문 최소 1쌍 보장을 위해 하한 2. applyFollowup 자동 종료 흐름 결합. + // k: 총 질문 상한(안전장치). 첫 질문 + 꼬리질문 최소 1쌍 보장을 위해 하한 2. @Min(2) @Max(30) Integer maxQuestions, // maxDurationMinutes: 시간 기반 자동 종료 미구현. DB 저장만 유지, 입력 검증 풀어 미입력 허용. Integer maxDurationMinutes, + // n: 일반질문 수(서로 다른 주제). null 이면 기본 3. + @Min(1) @Max(15) Integer generalQuestionCount, + // m: 일반질문당 최대 꼬리질문 수. null 이면 기본 2. + @Min(0) @Max(10) Integer maxFollowupsPerQuestion, List contextDocumentIds ) { public SessionCreateCommand toCommand() { @@ -29,6 +33,8 @@ public SessionCreateCommand toCommand() { jobCategory, maxQuestions, maxDurationMinutes, + generalQuestionCount, + maxFollowupsPerQuestion, contextDocumentIds ); } diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionResponse.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionResponse.java index 1c4ecf7f..799357e6 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionResponse.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionResponse.java @@ -15,6 +15,8 @@ public record SessionResponse( JobCategory jobCategory, Integer maxQuestions, Integer maxDurationMinutes, + Integer generalQuestionCount, + Integer maxFollowupsPerQuestion, SessionStatus status, Integer totalQuestionCount, Instant startedAt, @@ -32,6 +34,8 @@ public static SessionResponse from(SessionResult r) { r.jobCategory(), r.maxQuestions(), r.maxDurationMinutes(), + r.generalQuestionCount(), + r.maxFollowupsPerQuestion(), r.status(), r.totalQuestionCount(), r.startedAt(), diff --git a/backend/src/main/resources/db/migration/V12__interview_depth_and_pool.sql b/backend/src/main/resources/db/migration/V12__interview_depth_and_pool.sql new file mode 100644 index 00000000..358e5064 --- /dev/null +++ b/backend/src/main/resources/db/migration/V12__interview_depth_and_pool.sql @@ -0,0 +1,21 @@ +-- 면접 깊이 제어(n/m/k) + 질문 풀. +-- n = 일반질문 수, m = 질문당 최대 꼬리질문 수, k(max_questions) = 총 상한(기존). +ALTER TABLE interview_sessions + ADD COLUMN general_question_count INT NOT NULL DEFAULT 3, + ADD COLUMN max_followups_per_question INT NOT NULL DEFAULT 2; + +-- AI 가 생성한 일반질문 풀. POOL 콜백에서 n개 저장 후, 진행하며 하나씩 꺼내 일반질문으로 삽입. +CREATE TABLE session_question_pool ( + id BIGSERIAL PRIMARY KEY, + session_id BIGINT NOT NULL REFERENCES interview_sessions (id), + idx INT NOT NULL, + question TEXT NOT NULL, + category VARCHAR(40), + target_evidence TEXT, + expected_signal TEXT, + used BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_pool_session_idx UNIQUE (session_id, idx) +); + +CREATE INDEX idx_pool_session_unused ON session_question_pool (session_id, idx) WHERE used = FALSE; diff --git a/backend/src/test/java/com/stackup/stackup/common/openapi/OpenApiSpecExportTest.java b/backend/src/test/java/com/stackup/stackup/common/openapi/OpenApiSpecExportTest.java index 4c7f1dc5..9698318f 100644 --- a/backend/src/test/java/com/stackup/stackup/common/openapi/OpenApiSpecExportTest.java +++ b/backend/src/test/java/com/stackup/stackup/common/openapi/OpenApiSpecExportTest.java @@ -17,6 +17,7 @@ import com.stackup.stackup.session.domain.MessageVoiceAnalysisRepository; import com.stackup.stackup.session.domain.SessionContextRepository; import com.stackup.stackup.session.domain.SessionFeedbackRepository; +import com.stackup.stackup.session.domain.SessionQuestionPoolRepository; import com.stackup.stackup.user.domain.UserRepository; import com.stackup.stackup.user.domain.consent.UserConsentRepository; import java.nio.charset.StandardCharsets; @@ -62,6 +63,7 @@ class OpenApiSpecExportTest { @MockitoBean private InterviewMessageRepository interviewMessageRepository; @MockitoBean private SessionContextRepository sessionContextRepository; @MockitoBean private SessionFeedbackRepository sessionFeedbackRepository; + @MockitoBean private SessionQuestionPoolRepository sessionQuestionPoolRepository; @MockitoBean private MessageVoiceAnalysisRepository messageVoiceAnalysisRepository; @MockitoBean private AiRequestLogRepository aiRequestLogRepository; diff --git a/backend/src/test/java/com/stackup/stackup/session/application/FeedbackCallbackServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/FeedbackCallbackServiceTest.java index 1d586e21..ed201a87 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/FeedbackCallbackServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/FeedbackCallbackServiceTest.java @@ -104,7 +104,7 @@ private FeedbackCallbackEnvelope envelope(Long sessionId, String messageId, Feed private InterviewSession sessionFixture(Long id) { User user = User.createGithubUser(1L, "u", null, null, "t"); ReflectionTestUtils.setField(user, "id", 1L); - InterviewSession s = InterviewSession.create(user, "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30); + InterviewSession s = InterviewSession.create(user, "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30, null, null); ReflectionTestUtils.setField(s, "id", id); return s; } diff --git a/backend/src/test/java/com/stackup/stackup/session/application/InterviewMessageServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/InterviewMessageServiceTest.java index 11cf1663..2acea44c 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/InterviewMessageServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/InterviewMessageServiceTest.java @@ -118,7 +118,7 @@ private InterviewSession sessionInProgress(Long id) { User user = User.createGithubUser(1L, "u", null, null, "t"); ReflectionTestUtils.setField(user, "id", 1L); InterviewSession s = InterviewSession.create( - user, "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30 + user, "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30, null, null ); ReflectionTestUtils.setField(s, "id", id); s.start(); diff --git a/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java index 1044e1c7..2bf6362c 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java @@ -37,12 +37,13 @@ class QuestionsCallbackServiceTest { @Mock InterviewSessionRepository sessionRepository; @Mock InterviewMessageRepository messageRepository; + @Mock com.stackup.stackup.session.domain.SessionQuestionPoolRepository poolRepository; @Mock ProcessedMessageRepository processedMessageRepository; @Mock org.springframework.context.ApplicationEventPublisher events; @InjectMocks QuestionsCallbackService service; @Test - void apply_poolCallbackStoresOnlyInitialQuestionAndPushesSse() { + void apply_poolCallbackSeedsPoolAndInsertsFirstQuestion() { InterviewSession session = sessionFixture(11L, SessionStatus.READY); QuestionsCallbackEnvelope env = poolEnvelope(11L, List.of( new GeneratedQuestion("PROJECT_DEEP_DIVE", "Introduce yourself", @@ -51,6 +52,12 @@ void apply_poolCallbackStoresOnlyInitialQuestionAndPushesSse() { )); when(processedMessageRepository.existsById("m-1")).thenReturn(false); when(sessionRepository.findById(11L)).thenReturn(Optional.of(session)); + when(poolRepository.countBySessionId(11L)).thenReturn(0L); + // 풀 저장 후 첫(미사용) 질문을 꺼내 일반질문으로 삽입. + when(poolRepository.findFirstBySessionIdAndUsedFalseOrderByIdxAsc(11L)).thenReturn( + Optional.of(com.stackup.stackup.session.domain.SessionQuestionPool.of( + 11L, 0, "Introduce yourself", "PROJECT_DEEP_DIVE", + "이력서: 결제 시스템", "협업/문제해결 깊이"))); when(messageRepository.save(any(InterviewMessage.class))).thenAnswer(inv -> { InterviewMessage m = inv.getArgument(0); ReflectionTestUtils.setField(m, "id", 500L); @@ -171,7 +178,7 @@ private InterviewSession sessionFixture(Long id, SessionStatus status) { User user = User.createGithubUser(1L, "u", null, null, "t"); ReflectionTestUtils.setField(user, "id", 1L); InterviewSession s = InterviewSession.create( - user, "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30 + user, "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30, null, null ); ReflectionTestUtils.setField(s, "id", id); if (status == SessionStatus.IN_PROGRESS || status == SessionStatus.COMPLETED) { diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java index bcd484c1..bc18b36d 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java @@ -127,7 +127,7 @@ void onSessionEnded_skipsWhenFeedbackExists() { private InterviewSession sessionFixture(Long id) { User user = User.createGithubUser(1L, "u", null, null, "t"); ReflectionTestUtils.setField(user, "id", 1L); - InterviewSession s = InterviewSession.create(user, "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30); + InterviewSession s = InterviewSession.create(user, "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30, null, null); ReflectionTestUtils.setField(s, "id", id); return s; } diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionQuestionsRequesterTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionQuestionsRequesterTest.java index f1cbd760..dfcc01ba 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/SessionQuestionsRequesterTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionQuestionsRequesterTest.java @@ -42,6 +42,7 @@ void onSessionCreated_requestsOneInitialQuestionAndKeepsSessionLimit() { SessionMode.INTEGRATED, JobCategory.BACKEND, 5, + 3, List.of() )); @@ -54,7 +55,8 @@ void onSessionCreated_requestsOneInitialQuestionAndKeepsSessionLimit() { assertThat(payload.mode()).isEqualTo(SessionMode.INTEGRATED); assertThat(payload.jobCategory()).isEqualTo(JobCategory.BACKEND); assertThat(payload.documents()).isEmpty(); - assertThat(payload.initialQuestionCount()).isEqualTo(1); + // generalQuestionCount(n) 만큼 생성 요청 (이벤트의 n=3). + assertThat(payload.initialQuestionCount()).isEqualTo(3); assertThat(payload.maxQuestions()).isEqualTo(5); } diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java index 33aba768..d550ef9e 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java @@ -55,7 +55,7 @@ void create_savesSessionAndPublishesEvent() { SessionResult result = service.create(1L, new SessionCreateCommand( "title", "memo", SessionMode.TECHNICAL, JobCategory.BACKEND, - 5, 30, List.of() + 5, 30, null, null, List.of() )); assertThat(result.id()).isEqualTo(100L); @@ -77,7 +77,7 @@ void create_linksAnalyzedContextDocuments() { SessionResult result = service.create(1L, new SessionCreateCommand( "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, - 5, 30, List.of(7L, 7L) + 5, 30, null, null, List.of(7L, 7L) )); assertThat(result.contextDocumentIds()).containsExactly(7L); @@ -94,7 +94,7 @@ void create_rejectsNonAnalyzedDocument() { assertThatThrownBy(() -> service.create(1L, new SessionCreateCommand( "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, - 5, 30, List.of(8L) + 5, 30, null, null, List.of(8L) ))).isInstanceOf(DomainException.class); } @@ -140,7 +140,7 @@ private User userFixture(Long id) { private InterviewSession sessionFixture(Long id) { InterviewSession s = InterviewSession.create( - userFixture(1L), "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30 + userFixture(1L), "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30, null, null ); ReflectionTestUtils.setField(s, "id", id); return s; diff --git a/backend/src/test/java/com/stackup/stackup/session/application/TtsCallbackServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/TtsCallbackServiceTest.java index 6f2439c1..746e6a5a 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/TtsCallbackServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/TtsCallbackServiceTest.java @@ -153,7 +153,7 @@ private Object argThatObject(Predicate predicate) { private InterviewSession sessionFixture(Long id) { User user = User.createGithubUser(1L, "u", null, null, "t"); ReflectionTestUtils.setField(user, "id", 1L); - InterviewSession s = InterviewSession.create(user, "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30); + InterviewSession s = InterviewSession.create(user, "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30, null, null); ReflectionTestUtils.setField(s, "id", id); return s; } diff --git a/backend/src/test/java/com/stackup/stackup/session/application/VoiceAnswerUploadServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/VoiceAnswerUploadServiceTest.java index bb45b0ac..faece9b0 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/VoiceAnswerUploadServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/VoiceAnswerUploadServiceTest.java @@ -191,7 +191,7 @@ private InterviewSession sessionFixture(Long id) { User user = User.createGithubUser(1L, "u", null, null, "t"); ReflectionTestUtils.setField(user, "id", 1L); InterviewSession s = InterviewSession.create( - user, "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30 + user, "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30, null, null ); ReflectionTestUtils.setField(s, "id", id); return s; diff --git a/backend/src/test/java/com/stackup/stackup/session/application/VoiceCallbackServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/VoiceCallbackServiceTest.java index eedd731a..8f8dafe8 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/VoiceCallbackServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/VoiceCallbackServiceTest.java @@ -147,7 +147,7 @@ void apply_skipsDuplicateMessageId() { private InterviewSession sessionFixture(Long id) { User user = User.createGithubUser(1L, "u", null, null, "t"); ReflectionTestUtils.setField(user, "id", 1L); - InterviewSession s = InterviewSession.create(user, "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30); + InterviewSession s = InterviewSession.create(user, "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30, null, null); ReflectionTestUtils.setField(s, "id", id); return s; } diff --git a/backend/src/test/java/com/stackup/stackup/session/presentation/InternalSessionMessageControllerTest.java b/backend/src/test/java/com/stackup/stackup/session/presentation/InternalSessionMessageControllerTest.java index 8579789b..30fed6bf 100644 --- a/backend/src/test/java/com/stackup/stackup/session/presentation/InternalSessionMessageControllerTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/presentation/InternalSessionMessageControllerTest.java @@ -39,7 +39,7 @@ private InterviewMessage answerFixture() { User user = User.createGithubUser(1L, "u", null, null, "t"); ReflectionTestUtils.setField(user, "id", 7L); InterviewSession s = InterviewSession.create(user, "t", null, SessionMode.TECHNICAL, - JobCategory.BACKEND, 5, 30); + JobCategory.BACKEND, 5, 30, null, null); ReflectionTestUtils.setField(s, "id", 99L); InterviewMessage q = InterviewMessage.interviewer(s, 1, "Q?"); ReflectionTestUtils.setField(q, "id", 500L); diff --git a/frontend/src/features/interview/ui/setup/InterviewSetupForm.test.tsx b/frontend/src/features/interview/ui/setup/InterviewSetupForm.test.tsx index 4384c481..8bc146a2 100644 --- a/frontend/src/features/interview/ui/setup/InterviewSetupForm.test.tsx +++ b/frontend/src/features/interview/ui/setup/InterviewSetupForm.test.tsx @@ -18,7 +18,9 @@ describe('InterviewSetupForm', () => { expect(onCreate).toHaveBeenCalledWith({ mode: 'TECHNICAL', jobCategory: 'BACKEND', - maxQuestions: 5, + generalQuestionCount: 3, + maxFollowupsPerQuestion: 2, + maxQuestions: 10, contextDocumentIds: [], }) }) diff --git a/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx b/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx index 6690267c..80ab18d7 100644 --- a/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx +++ b/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx @@ -1,9 +1,9 @@ import { useState } from 'react' import { Button } from '@/shared/ui/Button' +import { Stepper } from '@/shared/ui/Stepper' import type { JobCategory, SessionCreateRequest, SessionMode } from '@/domain/session' import { ModeSelector } from './ModeSelector' import { JobCategorySelector } from './JobCategorySelector' -import { QuestionCountField } from './QuestionCountField' import { ContextDocumentPicker } from './ContextDocumentPicker' import type { DocOption } from './ContextDocumentPicker' @@ -18,7 +18,9 @@ export function InterviewSetupForm({ }) { const [mode, setMode] = useState(null) const [jobCategory, setJobCategory] = useState(null) - const [maxQuestions, setMaxQuestions] = useState(5) + const [generalQuestionCount, setGeneralQuestionCount] = useState(3) + const [maxFollowupsPerQuestion, setMaxFollowupsPerQuestion] = useState(2) + const [maxQuestions, setMaxQuestions] = useState(10) const [selected, setSelected] = useState([]) const toggle = (id: number) => @@ -28,7 +30,14 @@ export function InterviewSetupForm({ const submit = () => { if (mode === null || jobCategory === null || !valid) return - onCreate({ mode, jobCategory, maxQuestions, contextDocumentIds: selected }) + onCreate({ + mode, + jobCategory, + generalQuestionCount, + maxFollowupsPerQuestion, + maxQuestions, + contextDocumentIds: selected, + }) } return ( @@ -47,9 +56,47 @@ export function InterviewSetupForm({

직군

-
-

질문 수

- +
+

면접 구성

+
+ + 일반질문 수 + 서로 다른 주제 + + +
+
+ + 질문당 꼬리질문 + 깊이 + + +
+
+ + 총 질문 상한 + 안전장치 + + +

참고 문서 (선택)

diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts index be41a2ee..b5552a5f 100644 --- a/frontend/src/shared/api/generated.ts +++ b/frontend/src/shared/api/generated.ts @@ -806,6 +806,10 @@ export interface components { maxQuestions?: number; /** Format: int32 */ maxDurationMinutes?: number; + /** Format: int32 */ + generalQuestionCount?: number; + /** Format: int32 */ + maxFollowupsPerQuestion?: number; contextDocumentIds?: number[]; }; SessionResponse: { @@ -821,6 +825,10 @@ export interface components { maxQuestions?: number; /** Format: int32 */ maxDurationMinutes?: number; + /** Format: int32 */ + generalQuestionCount?: number; + /** Format: int32 */ + maxFollowupsPerQuestion?: number; /** @enum {string} */ status?: "READY" | "IN_PROGRESS" | "INTERRUPTED" | "COMPLETED" | "CANCELLED"; /** Format: int32 */