From b1e6710534f058f4b0f72d3b19c03faa6c03533a Mon Sep 17 00:00:00 2001 From: jmj Date: Fri, 19 Jun 2026 11:31:01 +0900 Subject: [PATCH] =?UTF-8?q?fix(session):=20=EA=BC=AC=EB=A6=AC=EC=A7=88?= =?UTF-8?q?=EB=AC=B8=EC=9D=84=20maxQuestions=20=EC=B9=B4=EC=9A=B4=ED=8A=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=A0=9C=EC=99=B8=20=E2=80=94=20=EB=A9=B4?= =?UTF-8?q?=EC=A0=91=20=EC=A1=B0=EA=B8=B0=C2=B7=EB=8F=8C=EC=97=B0=20?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 답변 직후 꼬리질문이 maxQuestions 한도를 채워 면접이 메인질문 몇 개 만에 끝나고, 생성된 꼬리질문을 버린 채 피드백으로 튕기던 문제 수정. (이전 PR이 미머지·구버전이라 현재 dev 위에 재적용. 멀티에이전트 2B와 무충돌.) - applyFollowup: 꼬리질문은 incrementQuestionCount/자동종료에서 제외 - insertGeneralFromPool: 메인질문 삽입 직후 즉시 종료 제거(maybeAutoEnd 삭제) → 종료는 메인질문의 꼬리 사이클 후 advanceToNextGeneral(isMaxReached 또는 POOL_EXHAUSTED)에서만. "마지막 질문 묻자마자 종료" 신규 버그도 방지 - totalQuestionCount/isMaxReached = 메인(일반)질문 수 의미로 정리(주석) - 테스트: 꼬리질문 비카운트·비종료, 메인질문 max 도달 시 advance 종료 Co-Authored-By: Claude Opus 4.8 --- .../application/QuestionsCallbackService.java | 18 ++++-------- .../session/domain/InterviewSession.java | 3 ++ .../QuestionsCallbackServiceTest.java | 28 ++++++++++++++----- 3 files changed, 29 insertions(+), 20 deletions(-) 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 dc1f42ce..5519a759 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 @@ -129,7 +129,8 @@ private void insertGeneralFromPool(InterviewSession session, SessionQuestionPool pool.getCategory(), pool.getTargetEvidence(), pool.getExpectedSignal())); session.incrementQuestionCount(); publishQuestionEvents(session, message, reason); - maybeAutoEnd(session); + // 여기서 자동종료하지 않는다 — 방금 던진 메인질문은 답변·꼬리질문을 거쳐야 한다. + // maxQuestions 도달 종료는 꼬리 사이클 후 advanceToNextGeneral 에서 판정한다. } private void publishQuestionEvents(InterviewSession session, InterviewMessage message, String reason) { @@ -140,12 +141,6 @@ private void publishQuestionEvents(InterviewSession session, InterviewMessage me 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(); @@ -205,13 +200,10 @@ private void applyFollowup(InterviewSession session, QuestionsCallbackPayload pa : InterviewMessage.followup(session, nextSeq, payload.followupQuestion(), parent)); } - if (!clarification) { - session.incrementQuestionCount(); - } + // 꼬리질문은 maxQuestions(메인질문 수) 카운트에 포함하지 않고, 여기서 자동종료하지도 + // 않는다 — 종료는 메인질문의 꼬리 사이클이 끝나고 advanceToNextGeneral 시점에서만 일어나야 + // 답변 직후 생성된 꼬리질문을 버리고 갑자기 피드백으로 튕기는 일이 없다. publishQuestionEvents(session, message, clarification ? "CLARIFICATION" : "FOLLOWUP_READY"); - if (!clarification) { - maybeAutoEnd(session); - } log.info("callback.questions FOLLOWUP processed. sessionId={}, msg={}, clarification={}", session.getId(), message.getId(), clarification); } 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 a293ebc6..3ce80676 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 @@ -179,6 +179,8 @@ public void cancel() { this.status = SessionStatus.CANCELLED; } + // 메인(일반) 질문만 센다. 꼬리질문은 maxQuestions 한도에 포함하지 않는다 + // (꼬리질문은 maxFollowupsPerQuestion 으로 별도 제한). public void incrementQuestionCount() { if (totalQuestionCount == null) { totalQuestionCount = 0; @@ -186,6 +188,7 @@ public void incrementQuestionCount() { totalQuestionCount++; } + // 던진 메인질문 수가 maxQuestions 에 도달했는지. 꼬리 사이클 종료 후 advanceToNextGeneral 에서 판정. public boolean isMaxReached() { return totalQuestionCount != null && maxQuestions != null 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 fb0bfcfc..cdcf9699 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 @@ -96,9 +96,10 @@ void apply_poolCallbackSeedsPoolAndInsertsFirstQuestion() { } @Test - void apply_followupAutoEndsSessionAtMaxQuestions() { + void apply_followupDoesNotCountOrAutoEnd() { InterviewSession session = sessionFixture(11L, SessionStatus.IN_PROGRESS); - // maxQuestions=5; total=4, then one follow-up reaches the limit. + // 꼬리질문은 maxQuestions(메인질문 수) 한도에 포함되지 않으며, 도착해도 세션을 + // 끝내지 않는다(종료는 메인질문의 꼬리 사이클 후 advanceToNextGeneral 에서만). ReflectionTestUtils.setField(session, "totalQuestionCount", 4); QuestionsCallbackEnvelope env = followupEnvelope(11L, 200L, "Follow-up?"); @@ -114,8 +115,8 @@ void apply_followupAutoEndsSessionAtMaxQuestions() { service.apply(env); - assertThat(session.getStatus()).isEqualTo(SessionStatus.COMPLETED); - assertThat(session.getTotalQuestionCount()).isEqualTo(5); + assertThat(session.getStatus()).isEqualTo(SessionStatus.IN_PROGRESS); + assertThat(session.getTotalQuestionCount()).isEqualTo(4); } @Test @@ -188,7 +189,7 @@ void apply_clarificationReExplainsWithoutCountingQuestion() { // ── 새 placeholder 경로 테스트 ───────────────────────────────────────────── @Test - void apply_followupNormal_updatesPlaceholderInPlaceAndCounts() { + void apply_followupNormal_updatesPlaceholderInPlaceWithoutCounting() { InterviewSession session = sessionFixture(20L, SessionStatus.IN_PROGRESS); // placeholder: followupPlaceholder 의 sentinel content InterviewMessage placeholder = InterviewMessage.followupPlaceholder( @@ -213,8 +214,8 @@ void apply_followupNormal_updatesPlaceholderInPlaceAndCounts() { // placeholder 가 in-place 로 업데이트되어야 함 assertThat(placeholder.getContent()).isEqualTo("꼬리질문 내용?"); assertThat(placeholder.getStatus()).isEqualTo(com.stackup.stackup.session.domain.MessageStatus.COMPLETED); - // 질문 수 카운트 증가 - assertThat(session.getTotalQuestionCount()).isEqualTo(1); + // 꼬리질문은 메인질문 카운트(maxQuestions 한도)에 포함되지 않는다. + assertThat(session.getTotalQuestionCount()).isEqualTo(0); // save 는 placeholder 자체에 대해 호출됨 (새 InterviewMessage INSERT 아님) ArgumentCaptor cap = ArgumentCaptor.forClass(InterviewMessage.class); verify(messageRepository).save(cap.capture()); @@ -281,6 +282,19 @@ void apply_followupDontKnow_deletesPlaceholderAndAdvances() { assertThat(session.getStatus()).isEqualTo(SessionStatus.COMPLETED); } + @Test + void advanceToNextGeneral_endsWithMaxReached_whenMainQuestionsHitLimit() { + InterviewSession session = sessionFixture(33L, SessionStatus.IN_PROGRESS); + // maxQuestions=5; 메인질문 5개를 이미 던진 상태 → 다음 advance 에서 풀을 보지 않고 종료. + ReflectionTestUtils.setField(session, "totalQuestionCount", 5); + when(sessionRepository.findById(33L)).thenReturn(Optional.of(session)); + + service.advanceToNextGeneral(33L); + + assertThat(session.getStatus()).isEqualTo(SessionStatus.COMPLETED); + verify(poolRepository, never()).findFirstBySessionIdAndUsedFalseOrderByIdxAsc(any()); + } + private QuestionsCallbackEnvelope poolEnvelope(Long sessionId, List questions) { QuestionsCallbackPayload payload = new QuestionsCallbackPayload( sessionId, "POOL", questions, null, null, null, null, null, null