1414import com .stackup .stackup .session .domain .InterviewMessageRepository ;
1515import com .stackup .stackup .session .domain .InterviewSession ;
1616import 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 ;
1720import java .util .List ;
1821import lombok .RequiredArgsConstructor ;
1922import 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 ());
0 commit comments