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
3 changes: 2 additions & 1 deletion ai/src/ai_server/chain/prompts/question_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
20 changes: 20 additions & 0 deletions backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down Expand Up @@ -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" ]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -74,35 +78,83 @@ public void apply(QuestionsCallbackEnvelope envelope) {
markProcessed(envelope.messageId());
}

// POOL 콜백: AI 가 만든 일반질문들을 풀에 저장하고 첫 질문을 삽입한다.
private void applyInitialQuestion(InterviewSession session, QuestionsCallbackPayload payload) {
List<GeneratedQuestion> 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) {
Expand Down Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
Expand All @@ -54,6 +56,19 @@ public void onAnswerSubmitted(AnswerSubmittedEvent event) {
// 최근 대화 히스토리 (중복 질문 회피). 시퀀스 순 → 마지막 N개.
List<InterviewMessage> 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<HistoryItem> history = ordered.stream()
.skip(Math.max(0, ordered.size() - HISTORY_MAX))
.map(m -> new HistoryItem(m.getRole().name(), m.getContent()))
Expand All @@ -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<InterviewMessage> ordered) {
int depth = 0;
for (InterviewMessage m : ordered) {
if (m.getRole() != MessageRole.INTERVIEWER) {
continue;
}
if (m.getParentMessage() == null) {
depth = 0; // 일반질문 → 리셋
} else {
depth++; // 꼬리질문 → 증가
}
}
return depth;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +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 static final int DEFAULT_GENERAL_QUESTION_COUNT = 3;

private final RabbitMessagePublisher publisher;
private final RabbitMqProperties properties;
Expand All @@ -49,21 +49,23 @@ public class SessionQuestionsRequester {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onSessionCreated(SessionCreatedEvent event) {
List<DocumentContext> 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(
properties.routingKeys().generateQuestions(),
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<DocumentContext> buildDocumentContexts(List<Long> documentIds) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Long> linkedIds = linkContexts(session, userId, command.contextDocumentIds());
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ public record SessionCreateCommand(
JobCategory jobCategory,
Integer maxQuestions,
Integer maxDurationMinutes,
Integer generalQuestionCount,
Integer maxFollowupsPerQuestion,
List<Long> contextDocumentIds
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ public record SessionResult(
JobCategory jobCategory,
Integer maxQuestions,
Integer maxDurationMinutes,
Integer generalQuestionCount,
Integer maxFollowupsPerQuestion,
SessionStatus status,
Integer totalQuestionCount,
Instant startedAt,
Expand All @@ -32,6 +34,8 @@ public static SessionResult of(InterviewSession session, List<Long> documentIds)
session.getJobCategory(),
session.getMaxQuestions(),
session.getMaxDurationMinutes(),
session.getGeneralQuestionCount(),
session.getMaxFollowupsPerQuestion(),
session.getStatus(),
session.getTotalQuestionCount(),
session.getStartedAt(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public record SessionCreatedEvent(
SessionMode mode,
JobCategory jobCategory,
Integer maxQuestions,
Integer generalQuestionCount,
List<Long> contextDocumentIds
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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() {
Expand Down
Loading