From 4e20da30d3510919818c0b237d254cbbd6f3121e Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 3 Jun 2026 20:28:53 +0900 Subject: [PATCH] =?UTF-8?q?feat(interview):=20=EB=AA=A8=EB=A5=B4=EA=B2=A0?= =?UTF-8?q?=EC=8A=B5=EB=8B=88=EB=8B=A4/=EB=B6=80=EC=97=B0=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EC=B2=98=EB=A6=AC=20(PR2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 답변 의도를 분류해 분기. PR1의 n/m/k 흐름 위에 얹는다. AI: - generate.followup 응답에 answer_intent(NORMAL/DONT_KNOW/CLARIFICATION) 추가 (기존 호출 재활용). 프롬프트가 의도 분류 + CLARIFICATION 시 followup_question 에 "직전 질문을 쉽게 다시 설명한 문장"을 담도록 지시. Core: - QuestionsCallbackPayload.answerIntent 수신 - applyFollowup 분기: - DONT_KNOW → 꼬리질문 건너뛰고 다음 일반질문으로(advanceToNextGeneral). 답변 평가는 기록(낮은 점수 의미 있음). - CLARIFICATION → 같은 질문 재설명(clarification 메시지). 질문 수(k)·깊이(m) 미반영, 답변 평가 기록 안 함. role INTERVIEWER 라 컴포저는 다시 활성. - NORMAL → 기존 꼬리질문. - V13: interview_messages.clarification 컬럼. 깊이 계산에서 clarification 제외. 테스트: clarification 비카운트 + 기존 세션/AI 219 통과. (intent 는 내부 RabbitMQ 계약 → openapi 변화 없음) Co-Authored-By: Claude Opus 4.8 --- .../chain/followup_generation_chain.py | 4 +- .../chain/prompts/followup_generation.py | 6 +++ .../messaging/consumers/followup_consumer.py | 1 + ai/src/ai_server/model/messages/followup.py | 2 + .../application/QuestionsCallbackService.java | 45 +++++++++++-------- .../application/SessionFollowupRequester.java | 4 +- .../dto/QuestionsCallbackPayload.java | 3 +- .../session/domain/InterviewMessage.java | 14 ++++++ ...dd_clarification_to_interview_messages.sql | 4 ++ .../QuestionsCallbackServiceTest.java | 31 +++++++++++-- 10 files changed, 89 insertions(+), 25 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V13__add_clarification_to_interview_messages.sql diff --git a/ai/src/ai_server/chain/followup_generation_chain.py b/ai/src/ai_server/chain/followup_generation_chain.py index b3998711..54ffc8d5 100644 --- a/ai/src/ai_server/chain/followup_generation_chain.py +++ b/ai/src/ai_server/chain/followup_generation_chain.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Protocol +from typing import Literal, Protocol from langchain_core.output_parsers import PydanticOutputParser from langchain_core.prompts import ChatPromptTemplate @@ -17,6 +17,8 @@ class FollowupResult(BaseModel): followup_question: str = Field(..., description="한국어 꼬리질문 1개") answer_evaluation: AnswerEvaluation + # 답변 의도. NORMAL=정상답변, DONT_KNOW=모름/포기, CLARIFICATION=질문 재설명 요청. + answer_intent: Literal["NORMAL", "DONT_KNOW", "CLARIFICATION"] = "NORMAL" class FollowupGenerator(Protocol): diff --git a/ai/src/ai_server/chain/prompts/followup_generation.py b/ai/src/ai_server/chain/prompts/followup_generation.py index 00d8b09d..aeefdaf6 100644 --- a/ai/src/ai_server/chain/prompts/followup_generation.py +++ b/ai/src/ai_server/chain/prompts/followup_generation.py @@ -20,6 +20,12 @@ "- 꼬리질문은 가장 약한 축을 겨냥합니다 (예: 구체성 낮음→수치/사례 요구, " "correctness 의심→자료와의 불일치 확인).\n" "- '이미 나눈 대화'에서 다룬 내용을 그대로 반복하지 말고 새로운 각도로 파고드세요.\n" + "- answer_intent 로 답변 의도를 분류하세요:\n" + " - DONT_KNOW: '모르겠습니다/잘 모릅니다/패스' 등 사실상 답을 못 한 경우.\n" + " - CLARIFICATION: 답변 대신 '질문을 다시/쉽게 설명해 달라'고 요청한 경우. " + "이때 followup_question 에는 새 꼬리질문이 아니라 **직전 질문을 더 쉽고 구체적으로 다시 설명한 문장**을 담으세요.\n" + " - NORMAL: 그 외 정상 답변. followup_question 은 평소처럼 가장 약한 축을 파는 꼬리질문.\n" + " - DONT_KNOW/CLARIFICATION 이면 채점(specificity/logic/correctness)은 보수적으로(낮게/null) 둡니다.\n" "- 응답은 반드시 지정된 JSON 스키마를 따릅니다." ) diff --git a/ai/src/ai_server/messaging/consumers/followup_consumer.py b/ai/src/ai_server/messaging/consumers/followup_consumer.py index 8fe8420d..280980aa 100644 --- a/ai/src/ai_server/messaging/consumers/followup_consumer.py +++ b/ai/src/ai_server/messaging/consumers/followup_consumer.py @@ -91,6 +91,7 @@ async def handle(self, message: AbstractIncomingMessage) -> None: answer_message_id=req.answer_message_id, followup_question=result.followup_question, answer_evaluation=result.answer_evaluation, + answer_intent=result.answer_intent, ) await self._publisher.publish( diff --git a/ai/src/ai_server/model/messages/followup.py b/ai/src/ai_server/model/messages/followup.py index 927ac833..4ef93fdb 100644 --- a/ai/src/ai_server/model/messages/followup.py +++ b/ai/src/ai_server/model/messages/followup.py @@ -56,3 +56,5 @@ class FollowupCallbackPayload(BaseModel): answer_message_id: int # 평가가 달릴 답변 메시지 (Core 가 평가 영속에 사용) followup_question: str answer_evaluation: AnswerEvaluation | None = None + # 답변 의도: NORMAL | DONT_KNOW | CLARIFICATION. Core 가 흐름 분기에 사용. + answer_intent: str = "NORMAL" 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 b6d117da..f0885a58 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 @@ -158,34 +158,43 @@ private void endSession(InterviewSession session, String reason) { } private void applyFollowup(InterviewSession session, QuestionsCallbackPayload payload) { - if (payload.followupQuestion() == null || payload.followupQuestion().isBlank()) { - log.warn("callback.questions FOLLOWUP empty question. sessionId={}", session.getId()); - return; - } InterviewMessage parent = payload.parentMessageId() == null ? null : messageRepository.findById(payload.parentMessageId()).orElse(null); + String intent = payload.answerIntent() == null ? "NORMAL" : payload.answerIntent(); - // 답변 평가를 해당 답변 메시지에 영속 (피드백 롤업 재활용). 평가/대상 없으면 skip. - recordAnswerEvaluation(payload); + // 모르겠음 → 이 주제 더 안 파고 다음 일반질문으로(없으면 종료). 답변평가는 기록(낮은 점수 의미 있음). + if ("DONT_KNOW".equalsIgnoreCase(intent)) { + recordAnswerEvaluation(payload); + log.info("callback.questions DONT_KNOW — advancing to next general. sessionId={}", session.getId()); + advanceToNextGeneral(session.getId()); + return; + } - long currentMsgs = messageRepository.countBySession_Id(session.getId()); - int nextSeq = (int) currentMsgs + 1; + if (payload.followupQuestion() == null || payload.followupQuestion().isBlank()) { + log.warn("callback.questions FOLLOWUP empty question. sessionId={}", session.getId()); + return; + } + + // 부연 요청 → 같은 질문 재설명. 질문 수(k)·깊이(m) 미반영, 답변평가 기록 안 함. + if ("CLARIFICATION".equalsIgnoreCase(intent)) { + int seq = (int) messageRepository.countBySession_Id(session.getId()) + 1; + InterviewMessage clar = messageRepository.save( + InterviewMessage.clarification(session, seq, payload.followupQuestion(), parent)); + publishQuestionEvents(session, clar, "CLARIFICATION"); + log.info("callback.questions CLARIFICATION — re-explained question. sessionId={}, msg={}", + session.getId(), clar.getId()); + return; + } + // 정상 답변 → 꼬리질문 삽입. + recordAnswerEvaluation(payload); + int nextSeq = (int) messageRepository.countBySession_Id(session.getId()) + 1; InterviewMessage message = messageRepository.save( InterviewMessage.followup(session, nextSeq, payload.followupQuestion(), parent) ); session.incrementQuestionCount(); - - events.publishEvent(new QuestionPersistedEvent( - session.getUser().getId(), session.getId(), message.getId())); - - events.publishEvent(RealtimeNotifyEvent.session(session.getId(), SseEventType.SESSION_MESSAGE, message.getId())); - events.publishEvent(RealtimeNotifyEvent.user( - session.getUser().getId(), - SseEventType.SESSION_MESSAGE, - new SessionMessageNotice(session.getId(), message.getId(), "FOLLOWUP_READY") - )); + publishQuestionEvents(session, message, "FOLLOWUP_READY"); // maxQuestions(k) 도달 시 자동 종료. maybeAutoEnd(session); 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 1a6e2e3b..cc567232 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 @@ -101,8 +101,8 @@ public void onAnswerSubmitted(AnswerSubmittedEvent event) { private int currentFollowupDepth(List ordered) { int depth = 0; for (InterviewMessage m : ordered) { - if (m.getRole() != MessageRole.INTERVIEWER) { - continue; + if (m.getRole() != MessageRole.INTERVIEWER || m.isClarification()) { + continue; // 부연 메시지는 깊이에 미반영 } if (m.getParentMessage() == null) { depth = 0; // 일반질문 → 리셋 diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/QuestionsCallbackPayload.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/QuestionsCallbackPayload.java index a0b58c90..28e57fab 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/dto/QuestionsCallbackPayload.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/QuestionsCallbackPayload.java @@ -11,7 +11,8 @@ public record QuestionsCallbackPayload( Long parentMessageId, // FOLLOWUP 시 사용 (직전 질문) Long answerMessageId, // FOLLOWUP 시 평가 대상 답변 메시지 String followupQuestion, // FOLLOWUP 시 사용 - AnswerEvaluation answerEvaluation // FOLLOWUP 시 옵션 + AnswerEvaluation answerEvaluation, // FOLLOWUP 시 옵션 + String answerIntent // NORMAL | DONT_KNOW | CLARIFICATION (FOLLOWUP) ) { public boolean isPool() { return "POOL".equals(kind); diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java index 92995c10..37db87b5 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java @@ -102,6 +102,10 @@ public class InterviewMessage extends BaseTimeEntity { @Column(name = "answer_correctness") private Double answerCorrectness; + // 부연(질문 재설명) 메시지 여부. true 면 총 질문 수·꼬리 깊이에 카운트하지 않는다. + @Column(nullable = false) + private boolean clarification = false; + private InterviewMessage(InterviewSession session, Integer sequenceNumber, MessageRole role, String content, InterviewMessage parentMessage, MessageStatus initialStatus, String idempotencyKey) { @@ -147,6 +151,16 @@ public static InterviewMessage followup(InterviewSession session, int seq, Strin return m; } + // 부연: 직전 질문을 다시 설명해 같은 차례를 유지. 질문 수/깊이에 카운트 안 함. + public static InterviewMessage clarification(InterviewSession session, int seq, String content, + InterviewMessage parent) { + InterviewMessage m = new InterviewMessage(session, seq, MessageRole.INTERVIEWER, content, parent, + MessageStatus.CREATED, null); + m.clarification = true; + m.markTtsPending(); + return m; + } + public static InterviewMessage interviewee(InterviewSession session, int seq, String content, InterviewMessage parent, String idempotencyKey) { return new InterviewMessage(session, seq, MessageRole.INTERVIEWEE, content, parent, diff --git a/backend/src/main/resources/db/migration/V13__add_clarification_to_interview_messages.sql b/backend/src/main/resources/db/migration/V13__add_clarification_to_interview_messages.sql new file mode 100644 index 00000000..51d19ef7 --- /dev/null +++ b/backend/src/main/resources/db/migration/V13__add_clarification_to_interview_messages.sql @@ -0,0 +1,4 @@ +-- 부연(질문 재설명) 메시지 표시. clarification 메시지는 총 질문 수(k)·꼬리 깊이(m)에 +-- 카운트되지 않는다. 같은 질문을 다시 제시하는 용도. +ALTER TABLE interview_messages + ADD COLUMN clarification BOOLEAN NOT NULL DEFAULT FALSE; 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 2bf6362c..c6572204 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 @@ -139,7 +139,7 @@ void apply_followupPersistsAnswerEvaluationOntoAnswerMessage() { QuestionsCallbackPayload payload = new QuestionsCallbackPayload( 11L, "FOLLOWUP", null, 500L, 600L, "다음 질문?", - new QuestionsCallbackPayload.AnswerEvaluation(2.0, 3.0, "PARTIAL_STAR", 1.5) + new QuestionsCallbackPayload.AnswerEvaluation(2.0, 3.0, "PARTIAL_STAR", 1.5), "NORMAL" ); QuestionsCallbackEnvelope env = new QuestionsCallbackEnvelope( "m-eval", "callback.questions", "1", "t", null, "ai", payload, null); @@ -160,16 +160,41 @@ void apply_followupPersistsAnswerEvaluationOntoAnswerMessage() { assertThat(answer.getAnswerCorrectness()).isEqualTo(1.5); } + @Test + void apply_clarificationReExplainsWithoutCountingQuestion() { + InterviewSession session = sessionFixture(11L, SessionStatus.IN_PROGRESS); + QuestionsCallbackPayload payload = new QuestionsCallbackPayload( + 11L, "FOLLOWUP", null, 500L, 600L, "쉽게 다시 설명: 트랜잭션이란…", + null, "CLARIFICATION" + ); + QuestionsCallbackEnvelope env = new QuestionsCallbackEnvelope( + "m-clar", "callback.questions", "1", "t", null, "ai", payload, null); + + when(processedMessageRepository.existsById("m-clar")).thenReturn(false); + when(sessionRepository.findById(11L)).thenReturn(Optional.of(session)); + when(messageRepository.findById(500L)).thenReturn(Optional.empty()); + when(messageRepository.countBySession_Id(11L)).thenReturn(2L); + when(messageRepository.save(any(InterviewMessage.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.apply(env); + + ArgumentCaptor cap = ArgumentCaptor.forClass(InterviewMessage.class); + verify(messageRepository).save(cap.capture()); + assertThat(cap.getValue().isClarification()).isTrue(); + // 부연은 질문 수(k)에 카운트되지 않는다. + assertThat(session.getTotalQuestionCount()).isEqualTo(0); + } + private QuestionsCallbackEnvelope poolEnvelope(Long sessionId, List questions) { QuestionsCallbackPayload payload = new QuestionsCallbackPayload( - sessionId, "POOL", questions, null, null, null, null + sessionId, "POOL", questions, null, null, null, null, null ); return new QuestionsCallbackEnvelope("m-1", "callback.questions", "1", "t", null, "ai", payload, null); } private QuestionsCallbackEnvelope followupEnvelope(Long sessionId, Long parentId, String followup) { QuestionsCallbackPayload payload = new QuestionsCallbackPayload( - sessionId, "FOLLOWUP", null, parentId, null, followup, null + sessionId, "FOLLOWUP", null, parentId, null, followup, null, "NORMAL" ); return new QuestionsCallbackEnvelope("m-2", "callback.questions", "1", "t", null, "ai", payload, null); }