Skip to content

Commit 461fdbb

Browse files
i3monthsclaude
andcommitted
feat(interview): 질문 풀 부활 + 깊이 제어 n/m/k (PR1)
기존엔 AI가 만든 질문 중 1개만 쓰고 매 답변마다 무조건 꼬리질문을 달아, "씨앗 1개를 한 줄로 끝까지 파는" 형태였음. 일반질문 n개(서로 다른 주제) × 질문당 꼬리 최대 m개, 총 상한 k 구조로 breadth+depth 도입. DB(V12): - interview_sessions: general_question_count(n), max_followups_per_question(m) - session_question_pool 테이블(AI 생성 일반질문 풀) Core: - POOL 콜백: 1개만 쓰던 걸 → n개 전부 풀에 저장 후 첫 질문 삽입 - 답변 처리(SessionFollowupRequester): 현재 일반질문 아래 꼬리 depth<m 면 꼬리질문(AI), 아니면 풀의 다음 일반질문 삽입(AI 불필요), 없으면 종료 - QuestionsCallbackService: advanceToNextGeneral + 일반질문 삽입/자동종료 공통화 - 세션 생성/이벤트/DTO/응답에 n·m 전파, openapi 재생성 AI: - 질문 생성은 이미 initial_question_count(n)개 생성 → 프롬프트에 "서로 다른 주제" breadth 지시 추가 프론트: - 설정 폼에 일반질문 수(n)·질문당 꼬리(m)·총 상한(k) 입력 추가 테스트: Core 세션/ArchUnit/openapi, AI 219, 프론트 25 통과. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 62bc0ee commit 461fdbb

29 files changed

Lines changed: 358 additions & 67 deletions

ai/src/ai_server/chain/prompts/question_generation.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
"{context}\n"
2323
"---\n\n"
2424
"요구 사항:\n"
25-
"1. 정확히 {max_questions}개의 질문을 생성합니다.\n"
25+
"1. 정확히 {max_questions}개의 질문을 생성하되, **서로 다른 주제/영역**을 다룹니다 "
26+
"(각 질문이 일반질문 1개의 씨앗이 되고 뒤에 꼬리질문이 붙으므로 같은 주제 반복 금지).\n"
2627
"2. 각 질문에 적절한 category 를 부여합니다.\n"
2728
"3. 직군({job_category})·면접 모드({mode}) 에 맞는 비중으로 카테고리 분배:\n"
2829
" - TECHNICAL: CS_FUNDAMENTAL + TECH_CHOICE + PROJECT_DEEP_DIVE 중심.\n"

backend/openapi.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2529,6 +2529,18 @@
25292529
"type" : "integer",
25302530
"format" : "int32"
25312531
},
2532+
"generalQuestionCount" : {
2533+
"type" : "integer",
2534+
"format" : "int32",
2535+
"maximum" : 15,
2536+
"minimum" : 1
2537+
},
2538+
"maxFollowupsPerQuestion" : {
2539+
"type" : "integer",
2540+
"format" : "int32",
2541+
"maximum" : 10,
2542+
"minimum" : 0
2543+
},
25322544
"contextDocumentIds" : {
25332545
"type" : "array",
25342546
"items" : {
@@ -2568,6 +2580,14 @@
25682580
"type" : "integer",
25692581
"format" : "int32"
25702582
},
2583+
"generalQuestionCount" : {
2584+
"type" : "integer",
2585+
"format" : "int32"
2586+
},
2587+
"maxFollowupsPerQuestion" : {
2588+
"type" : "integer",
2589+
"format" : "int32"
2590+
},
25712591
"status" : {
25722592
"type" : "string",
25732593
"enum" : [ "READY", "IN_PROGRESS", "INTERRUPTED", "COMPLETED", "CANCELLED" ]

backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java

Lines changed: 71 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
import com.stackup.stackup.session.domain.InterviewMessageRepository;
1515
import com.stackup.stackup.session.domain.InterviewSession;
1616
import com.stackup.stackup.session.domain.InterviewSessionRepository;
17+
import com.stackup.stackup.session.domain.SessionQuestionPool;
18+
import com.stackup.stackup.session.domain.SessionQuestionPoolRepository;
19+
import com.stackup.stackup.session.domain.SessionStatus;
1720
import java.util.List;
1821
import lombok.RequiredArgsConstructor;
1922
import org.slf4j.Logger;
@@ -36,6 +39,7 @@ public class QuestionsCallbackService {
3639

3740
private final InterviewSessionRepository sessionRepository;
3841
private final InterviewMessageRepository messageRepository;
42+
private final SessionQuestionPoolRepository poolRepository;
3943
private final ProcessedMessageRepository processedMessageRepository;
4044
private final ApplicationEventPublisher events;
4145

@@ -74,35 +78,83 @@ public void apply(QuestionsCallbackEnvelope envelope) {
7478
markProcessed(envelope.messageId());
7579
}
7680

81+
// POOL 콜백: AI 가 만든 일반질문들을 풀에 저장하고 첫 질문을 삽입한다.
7782
private void applyInitialQuestion(InterviewSession session, QuestionsCallbackPayload payload) {
7883
List<GeneratedQuestion> questions = payload.questions();
7984
if (questions == null || questions.isEmpty()) {
8085
log.warn("callback.questions initial result with no questions. sessionId={}", session.getId());
8186
return;
8287
}
83-
GeneratedQuestion first = questions.get(0);
84-
if (questions.size() > 1) {
85-
log.info("callback.questions initial result included extra questions; ignoring extras. sessionId={}, extra={}",
86-
session.getId(), questions.size() - 1);
88+
if (poolRepository.countBySessionId(session.getId()) > 0) {
89+
log.info("callback.questions pool already seeded, skip. sessionId={}", session.getId());
90+
return;
91+
}
92+
int idx = 0;
93+
for (GeneratedQuestion q : questions) {
94+
poolRepository.save(SessionQuestionPool.of(
95+
session.getId(), idx++, q.question(), q.category(), q.targetEvidence(), q.expectedSignal()));
96+
}
97+
poolRepository.findFirstBySessionIdAndUsedFalseOrderByIdxAsc(session.getId())
98+
.ifPresent(first -> insertGeneralFromPool(session, first, "INITIAL_QUESTION_READY"));
99+
log.info("callback.questions pool seeded. sessionId={}, poolSize={}", session.getId(), questions.size());
100+
}
101+
102+
// 풀에서 다음 일반질문을 꺼내 삽입(꼬리질문 m개 소진 후 호출). 없으면 종료.
103+
@Transactional
104+
public void advanceToNextGeneral(Long sessionId) {
105+
InterviewSession session = sessionRepository.findById(sessionId).orElse(null);
106+
if (session == null || session.getStatus() != SessionStatus.IN_PROGRESS) {
107+
return;
87108
}
109+
if (session.isMaxReached()) {
110+
endSession(session, "MAX_QUESTIONS_REACHED");
111+
return;
112+
}
113+
poolRepository.findFirstBySessionIdAndUsedFalseOrderByIdxAsc(sessionId).ifPresentOrElse(
114+
next -> insertGeneralFromPool(session, next, "GENERAL_QUESTION_READY"),
115+
() -> endSession(session, "POOL_EXHAUSTED"));
116+
}
117+
118+
private void insertGeneralFromPool(InterviewSession session, SessionQuestionPool pool, String reason) {
119+
pool.markUsed();
120+
poolRepository.save(pool);
121+
long currentMsgs = messageRepository.countBySession_Id(session.getId());
122+
int nextSeq = (int) currentMsgs + 1;
88123
InterviewMessage message = messageRepository.save(
89-
InterviewMessage.interviewer(
90-
session, 1, first.question(),
91-
first.category(), first.targetEvidence(), first.expectedSignal()
92-
)
93-
);
124+
InterviewMessage.interviewer(session, nextSeq, pool.getQuestion(),
125+
pool.getCategory(), pool.getTargetEvidence(), pool.getExpectedSignal()));
94126
session.incrementQuestionCount();
127+
publishQuestionEvents(session, message, reason);
128+
maybeAutoEnd(session);
129+
}
130+
131+
private void publishQuestionEvents(InterviewSession session, InterviewMessage message, String reason) {
95132
events.publishEvent(new QuestionPersistedEvent(
96133
session.getUser().getId(), session.getId(), message.getId()));
97134
events.publishEvent(RealtimeNotifyEvent.session(session.getId(), SseEventType.SESSION_MESSAGE, message.getId()));
98-
// 사용자 user 채널에도 알림 — frontend 가 documentId/sessionId 사전 인지 없이도 받을 수 있게
99-
events.publishEvent(RealtimeNotifyEvent.user(
100-
session.getUser().getId(),
101-
SseEventType.SESSION_MESSAGE,
102-
new SessionMessageNotice(session.getId(), message.getId(), "INITIAL_QUESTION_READY")
103-
));
104-
log.info("callback.questions initial question processed. sessionId={}, ignored_extras={}",
105-
session.getId(), Math.max(questions.size() - 1, 0));
135+
events.publishEvent(RealtimeNotifyEvent.user(session.getUser().getId(), SseEventType.SESSION_MESSAGE,
136+
new SessionMessageNotice(session.getId(), message.getId(), reason)));
137+
}
138+
139+
private void maybeAutoEnd(InterviewSession session) {
140+
if (session.isMaxReached()) {
141+
endSession(session, "MAX_QUESTIONS_REACHED");
142+
}
143+
}
144+
145+
private void endSession(InterviewSession session, String reason) {
146+
try {
147+
session.end();
148+
events.publishEvent(RealtimeNotifyEvent.session(session.getId(), SseEventType.SESSION_STATE,
149+
new SessionStateNotice(session.getId(), session.getStatus().name(), reason)));
150+
events.publishEvent(RealtimeNotifyEvent.user(session.getUser().getId(), SseEventType.SESSION_STATE,
151+
new SessionStateNotice(session.getId(), session.getStatus().name(), reason)));
152+
events.publishEvent(new SessionEndedEvent(session.getUser().getId(), session.getId(), reason));
153+
log.info("session auto-completed. sessionId={}, reason={}", session.getId(), reason);
154+
} catch (IllegalStateException e) {
155+
log.warn("auto-end skipped — session not IN_PROGRESS. sessionId={}, status={}",
156+
session.getId(), session.getStatus());
157+
}
106158
}
107159

108160
private void applyFollowup(InterviewSession session, QuestionsCallbackPayload payload) {
@@ -135,25 +187,8 @@ private void applyFollowup(InterviewSession session, QuestionsCallbackPayload pa
135187
new SessionMessageNotice(session.getId(), message.getId(), "FOLLOWUP_READY")
136188
));
137189

138-
// maxQuestions 도달 시 자동 종료 (plan §A-4)
139-
Integer max = session.getMaxQuestions();
140-
if (max != null && session.getTotalQuestionCount() != null
141-
&& session.getTotalQuestionCount() >= max) {
142-
try {
143-
session.end();
144-
events.publishEvent(RealtimeNotifyEvent.session(session.getId(), SseEventType.SESSION_STATE,
145-
new SessionStateNotice(session.getId(), session.getStatus().name(), "MAX_QUESTIONS_REACHED")));
146-
events.publishEvent(RealtimeNotifyEvent.user(session.getUser().getId(), SseEventType.SESSION_STATE,
147-
new SessionStateNotice(session.getId(), session.getStatus().name(), "MAX_QUESTIONS_REACHED")));
148-
events.publishEvent(new SessionEndedEvent(
149-
session.getUser().getId(), session.getId(), "MAX_QUESTIONS_REACHED"));
150-
log.info("session auto-completed on max questions. sessionId={}, max={}",
151-
session.getId(), max);
152-
} catch (IllegalStateException e) {
153-
log.warn("auto-end skipped — session not IN_PROGRESS. sessionId={}, status={}",
154-
session.getId(), session.getStatus());
155-
}
156-
}
190+
// maxQuestions(k) 도달 시 자동 종료.
191+
maybeAutoEnd(session);
157192

158193
log.info("callback.questions FOLLOWUP processed. sessionId={}, msg={}, totalQ={}",
159194
session.getId(), message.getId(), session.getTotalQuestionCount());

backend/src/main/java/com/stackup/stackup/session/application/SessionFollowupRequester.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import com.stackup.stackup.session.domain.InterviewMessage;
1010
import com.stackup.stackup.session.domain.InterviewMessageRepository;
1111
import com.stackup.stackup.session.domain.InterviewSession;
12+
import com.stackup.stackup.session.domain.MessageRole;
1213
import com.stackup.stackup.session.domain.SessionContextRepository;
1314
import java.util.List;
1415
import lombok.RequiredArgsConstructor;
@@ -33,6 +34,7 @@ public class SessionFollowupRequester {
3334
private final RabbitMqProperties properties;
3435
private final InterviewMessageRepository messageRepository;
3536
private final SessionContextRepository contextRepository;
37+
private final QuestionsCallbackService questionsCallbackService;
3638

3739
@Transactional(propagation = Propagation.REQUIRES_NEW)
3840
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@@ -54,6 +56,19 @@ public void onAnswerSubmitted(AnswerSubmittedEvent event) {
5456
// 최근 대화 히스토리 (중복 질문 회피). 시퀀스 순 → 마지막 N개.
5557
List<InterviewMessage> ordered =
5658
messageRepository.findBySession_IdOrderBySequenceNumberAsc(session.getId());
59+
60+
// 깊이 제어: 현재 일반질문 아래 꼬리질문 수(depth) 가 m 이상이면
61+
// 꼬리질문 대신 풀의 다음 일반질문으로 넘어간다(없으면 종료). AI 호출 불필요.
62+
int depth = currentFollowupDepth(ordered);
63+
int maxFollowups = session.getMaxFollowupsPerQuestion() != null
64+
? session.getMaxFollowupsPerQuestion() : 2;
65+
if (depth >= maxFollowups) {
66+
log.info("followup depth reached (m={}) — advancing to next general. sessionId={}",
67+
maxFollowups, session.getId());
68+
questionsCallbackService.advanceToNextGeneral(session.getId());
69+
return;
70+
}
71+
5772
List<HistoryItem> history = ordered.stream()
5873
.skip(Math.max(0, ordered.size() - HISTORY_MAX))
5974
.map(m -> new HistoryItem(m.getRole().name(), m.getContent()))
@@ -80,4 +95,21 @@ public void onAnswerSubmitted(AnswerSubmittedEvent event) {
8095
log.info("generate.followup published. sessionId={}, parent={}, answer={}",
8196
session.getId(), parent.getId(), answer.getId());
8297
}
98+
99+
// 현재 일반질문(INTERVIEWER + parent null) 이후 달린 꼬리질문(INTERVIEWER + parent 있음) 수.
100+
// 새 일반질문이 나오면 0 으로 리셋된다.
101+
private int currentFollowupDepth(List<InterviewMessage> ordered) {
102+
int depth = 0;
103+
for (InterviewMessage m : ordered) {
104+
if (m.getRole() != MessageRole.INTERVIEWER) {
105+
continue;
106+
}
107+
if (m.getParentMessage() == null) {
108+
depth = 0; // 일반질문 → 리셋
109+
} else {
110+
depth++; // 꼬리질문 → 증가
111+
}
112+
}
113+
return depth;
114+
}
83115
}

backend/src/main/java/com/stackup/stackup/session/application/SessionQuestionsRequester.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ public class SessionQuestionsRequester {
3838
private static final JsonMapper JSON = JsonMapper.builder().build();
3939
private static final TypeReference<List<String>> TECH_STACK_TYPE = new TypeReference<>() {};
4040
private static final long MAX_MARKDOWN_BYTES = 200_000L; // envelope 비대화 방지
41-
private static final int INITIAL_QUESTION_COUNT = 1;
41+
private static final int DEFAULT_GENERAL_QUESTION_COUNT = 3;
4242

4343
private final RabbitMessagePublisher publisher;
4444
private final RabbitMqProperties properties;
@@ -49,21 +49,23 @@ public class SessionQuestionsRequester {
4949
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
5050
public void onSessionCreated(SessionCreatedEvent event) {
5151
List<DocumentContext> documents = buildDocumentContexts(event.contextDocumentIds());
52+
int generalCount = event.generalQuestionCount() != null
53+
? event.generalQuestionCount() : DEFAULT_GENERAL_QUESTION_COUNT;
5254
GenerateQuestionsPayload payload = new GenerateQuestionsPayload(
5355
event.sessionId(),
5456
event.mode(),
5557
event.jobCategory(),
5658
documents,
57-
INITIAL_QUESTION_COUNT,
59+
generalCount,
5860
event.maxQuestions()
5961
);
6062
publisher.publishToAi(
6163
properties.routingKeys().generateQuestions(),
6264
payload,
6365
new MessageContext(event.userId(), event.sessionId(), null, null)
6466
);
65-
log.info("generate.questions published. sessionId={}, doc_count={}, initial_count={}, max={}",
66-
event.sessionId(), documents.size(), INITIAL_QUESTION_COUNT, event.maxQuestions());
67+
log.info("generate.questions published. sessionId={}, doc_count={}, general_count={}, max={}",
68+
event.sessionId(), documents.size(), generalCount, event.maxQuestions());
6769
}
6870

6971
private List<DocumentContext> buildDocumentContexts(List<Long> documentIds) {

backend/src/main/java/com/stackup/stackup/session/application/SessionService.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ public SessionResult create(Long userId, SessionCreateCommand command) {
5050
command.mode(),
5151
command.jobCategory(),
5252
command.maxQuestions(),
53-
command.maxDurationMinutes()
53+
command.maxDurationMinutes(),
54+
command.generalQuestionCount(),
55+
command.maxFollowupsPerQuestion()
5456
));
5557

5658
List<Long> linkedIds = linkContexts(session, userId, command.contextDocumentIds());
@@ -62,6 +64,7 @@ public SessionResult create(Long userId, SessionCreateCommand command) {
6264
session.getMode(),
6365
session.getJobCategory(),
6466
session.getMaxQuestions(),
67+
session.getGeneralQuestionCount(),
6568
linkedIds
6669
));
6770
return SessionResult.of(session, linkedIds);

backend/src/main/java/com/stackup/stackup/session/application/dto/SessionCreateCommand.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ public record SessionCreateCommand(
1111
JobCategory jobCategory,
1212
Integer maxQuestions,
1313
Integer maxDurationMinutes,
14+
Integer generalQuestionCount,
15+
Integer maxFollowupsPerQuestion,
1416
List<Long> contextDocumentIds
1517
) {
1618
}

backend/src/main/java/com/stackup/stackup/session/application/dto/SessionResult.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ public record SessionResult(
1515
JobCategory jobCategory,
1616
Integer maxQuestions,
1717
Integer maxDurationMinutes,
18+
Integer generalQuestionCount,
19+
Integer maxFollowupsPerQuestion,
1820
SessionStatus status,
1921
Integer totalQuestionCount,
2022
Instant startedAt,
@@ -32,6 +34,8 @@ public static SessionResult of(InterviewSession session, List<Long> documentIds)
3234
session.getJobCategory(),
3335
session.getMaxQuestions(),
3436
session.getMaxDurationMinutes(),
37+
session.getGeneralQuestionCount(),
38+
session.getMaxFollowupsPerQuestion(),
3539
session.getStatus(),
3640
session.getTotalQuestionCount(),
3741
session.getStartedAt(),

backend/src/main/java/com/stackup/stackup/session/application/event/SessionCreatedEvent.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ public record SessionCreatedEvent(
1111
SessionMode mode,
1212
JobCategory jobCategory,
1313
Integer maxQuestions,
14+
Integer generalQuestionCount,
1415
List<Long> contextDocumentIds
1516
) {
1617
}

backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ public class InterviewSession extends BaseSoftDeleteEntity {
6060
@Column(name = "max_duration_minutes", nullable = false)
6161
private Integer maxDurationMinutes = 60;
6262

63+
// 일반질문 수(n). 서로 다른 주제로 풀에서 꺼내 묻는다.
64+
@Column(name = "general_question_count", nullable = false)
65+
private Integer generalQuestionCount = 3;
66+
67+
// 일반질문 1개당 최대 꼬리질문 수(m).
68+
@Column(name = "max_followups_per_question", nullable = false)
69+
private Integer maxFollowupsPerQuestion = 2;
70+
6371
@Column(nullable = false, length = 20)
6472
@Enumerated(EnumType.STRING)
6573
private SessionStatus status = SessionStatus.READY;
@@ -75,7 +83,8 @@ public class InterviewSession extends BaseSoftDeleteEntity {
7583

7684
private InterviewSession(User user, String title, String memo, SessionMode mode,
7785
JobCategory jobCategory,
78-
Integer maxQuestions, Integer maxDurationMinutes) {
86+
Integer maxQuestions, Integer maxDurationMinutes,
87+
Integer generalQuestionCount, Integer maxFollowupsPerQuestion) {
7988
this.user = user;
8089
this.title = title;
8190
this.memo = memo;
@@ -87,18 +96,26 @@ private InterviewSession(User user, String title, String memo, SessionMode mode,
8796
if (maxDurationMinutes != null) {
8897
this.maxDurationMinutes = maxDurationMinutes;
8998
}
99+
if (generalQuestionCount != null) {
100+
this.generalQuestionCount = generalQuestionCount;
101+
}
102+
if (maxFollowupsPerQuestion != null) {
103+
this.maxFollowupsPerQuestion = maxFollowupsPerQuestion;
104+
}
90105
}
91106

92107
public static InterviewSession create(User user, String title, String memo, SessionMode mode,
93108
JobCategory jobCategory,
94-
Integer maxQuestions, Integer maxDurationMinutes) {
109+
Integer maxQuestions, Integer maxDurationMinutes,
110+
Integer generalQuestionCount, Integer maxFollowupsPerQuestion) {
95111
if (user == null) {
96112
throw new IllegalArgumentException("user must not be null");
97113
}
98114
if (mode == null || jobCategory == null) {
99115
throw new IllegalArgumentException("mode/jobCategory must not be null");
100116
}
101-
return new InterviewSession(user, title, memo, mode, jobCategory, maxQuestions, maxDurationMinutes);
117+
return new InterviewSession(user, title, memo, mode, jobCategory,
118+
maxQuestions, maxDurationMinutes, generalQuestionCount, maxFollowupsPerQuestion);
102119
}
103120

104121
public void start() {

0 commit comments

Comments
 (0)