Skip to content

Commit 4e20da3

Browse files
i3monthsclaude
andcommitted
feat(interview): 모르겠습니다/부연 요청 처리 (PR2)
답변 의도를 분류해 분기. 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 <noreply@anthropic.com>
1 parent 277ea2a commit 4e20da3

10 files changed

Lines changed: 89 additions & 25 deletions

File tree

ai/src/ai_server/chain/followup_generation_chain.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import Protocol
3+
from typing import Literal, Protocol
44

55
from langchain_core.output_parsers import PydanticOutputParser
66
from langchain_core.prompts import ChatPromptTemplate
@@ -17,6 +17,8 @@
1717
class FollowupResult(BaseModel):
1818
followup_question: str = Field(..., description="한국어 꼬리질문 1개")
1919
answer_evaluation: AnswerEvaluation
20+
# 답변 의도. NORMAL=정상답변, DONT_KNOW=모름/포기, CLARIFICATION=질문 재설명 요청.
21+
answer_intent: Literal["NORMAL", "DONT_KNOW", "CLARIFICATION"] = "NORMAL"
2022

2123

2224
class FollowupGenerator(Protocol):

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@
2020
"- 꼬리질문은 가장 약한 축을 겨냥합니다 (예: 구체성 낮음→수치/사례 요구, "
2121
"correctness 의심→자료와의 불일치 확인).\n"
2222
"- '이미 나눈 대화'에서 다룬 내용을 그대로 반복하지 말고 새로운 각도로 파고드세요.\n"
23+
"- answer_intent 로 답변 의도를 분류하세요:\n"
24+
" - DONT_KNOW: '모르겠습니다/잘 모릅니다/패스' 등 사실상 답을 못 한 경우.\n"
25+
" - CLARIFICATION: 답변 대신 '질문을 다시/쉽게 설명해 달라'고 요청한 경우. "
26+
"이때 followup_question 에는 새 꼬리질문이 아니라 **직전 질문을 더 쉽고 구체적으로 다시 설명한 문장**을 담으세요.\n"
27+
" - NORMAL: 그 외 정상 답변. followup_question 은 평소처럼 가장 약한 축을 파는 꼬리질문.\n"
28+
" - DONT_KNOW/CLARIFICATION 이면 채점(specificity/logic/correctness)은 보수적으로(낮게/null) 둡니다.\n"
2329
"- 응답은 반드시 지정된 JSON 스키마를 따릅니다."
2430
)
2531

ai/src/ai_server/messaging/consumers/followup_consumer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
9191
answer_message_id=req.answer_message_id,
9292
followup_question=result.followup_question,
9393
answer_evaluation=result.answer_evaluation,
94+
answer_intent=result.answer_intent,
9495
)
9596

9697
await self._publisher.publish(

ai/src/ai_server/model/messages/followup.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,5 @@ class FollowupCallbackPayload(BaseModel):
5656
answer_message_id: int # 평가가 달릴 답변 메시지 (Core 가 평가 영속에 사용)
5757
followup_question: str
5858
answer_evaluation: AnswerEvaluation | None = None
59+
# 답변 의도: NORMAL | DONT_KNOW | CLARIFICATION. Core 가 흐름 분기에 사용.
60+
answer_intent: str = "NORMAL"

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

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -158,34 +158,43 @@ private void endSession(InterviewSession session, String reason) {
158158
}
159159

160160
private void applyFollowup(InterviewSession session, QuestionsCallbackPayload payload) {
161-
if (payload.followupQuestion() == null || payload.followupQuestion().isBlank()) {
162-
log.warn("callback.questions FOLLOWUP empty question. sessionId={}", session.getId());
163-
return;
164-
}
165161
InterviewMessage parent = payload.parentMessageId() == null
166162
? null
167163
: messageRepository.findById(payload.parentMessageId()).orElse(null);
164+
String intent = payload.answerIntent() == null ? "NORMAL" : payload.answerIntent();
168165

169-
// 답변 평가를 해당 답변 메시지에 영속 (피드백 롤업 재활용). 평가/대상 없으면 skip.
170-
recordAnswerEvaluation(payload);
166+
// 모르겠음 → 이 주제 더 안 파고 다음 일반질문으로(없으면 종료). 답변평가는 기록(낮은 점수 의미 있음).
167+
if ("DONT_KNOW".equalsIgnoreCase(intent)) {
168+
recordAnswerEvaluation(payload);
169+
log.info("callback.questions DONT_KNOW — advancing to next general. sessionId={}", session.getId());
170+
advanceToNextGeneral(session.getId());
171+
return;
172+
}
171173

172-
long currentMsgs = messageRepository.countBySession_Id(session.getId());
173-
int nextSeq = (int) currentMsgs + 1;
174+
if (payload.followupQuestion() == null || payload.followupQuestion().isBlank()) {
175+
log.warn("callback.questions FOLLOWUP empty question. sessionId={}", session.getId());
176+
return;
177+
}
178+
179+
// 부연 요청 → 같은 질문 재설명. 질문 수(k)·깊이(m) 미반영, 답변평가 기록 안 함.
180+
if ("CLARIFICATION".equalsIgnoreCase(intent)) {
181+
int seq = (int) messageRepository.countBySession_Id(session.getId()) + 1;
182+
InterviewMessage clar = messageRepository.save(
183+
InterviewMessage.clarification(session, seq, payload.followupQuestion(), parent));
184+
publishQuestionEvents(session, clar, "CLARIFICATION");
185+
log.info("callback.questions CLARIFICATION — re-explained question. sessionId={}, msg={}",
186+
session.getId(), clar.getId());
187+
return;
188+
}
174189

190+
// 정상 답변 → 꼬리질문 삽입.
191+
recordAnswerEvaluation(payload);
192+
int nextSeq = (int) messageRepository.countBySession_Id(session.getId()) + 1;
175193
InterviewMessage message = messageRepository.save(
176194
InterviewMessage.followup(session, nextSeq, payload.followupQuestion(), parent)
177195
);
178196
session.incrementQuestionCount();
179-
180-
events.publishEvent(new QuestionPersistedEvent(
181-
session.getUser().getId(), session.getId(), message.getId()));
182-
183-
events.publishEvent(RealtimeNotifyEvent.session(session.getId(), SseEventType.SESSION_MESSAGE, message.getId()));
184-
events.publishEvent(RealtimeNotifyEvent.user(
185-
session.getUser().getId(),
186-
SseEventType.SESSION_MESSAGE,
187-
new SessionMessageNotice(session.getId(), message.getId(), "FOLLOWUP_READY")
188-
));
197+
publishQuestionEvents(session, message, "FOLLOWUP_READY");
189198

190199
// maxQuestions(k) 도달 시 자동 종료.
191200
maybeAutoEnd(session);

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,8 @@ public void onAnswerSubmitted(AnswerSubmittedEvent event) {
101101
private int currentFollowupDepth(List<InterviewMessage> ordered) {
102102
int depth = 0;
103103
for (InterviewMessage m : ordered) {
104-
if (m.getRole() != MessageRole.INTERVIEWER) {
105-
continue;
104+
if (m.getRole() != MessageRole.INTERVIEWER || m.isClarification()) {
105+
continue; // 부연 메시지는 깊이에 미반영
106106
}
107107
if (m.getParentMessage() == null) {
108108
depth = 0; // 일반질문 → 리셋

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ public record QuestionsCallbackPayload(
1111
Long parentMessageId, // FOLLOWUP 시 사용 (직전 질문)
1212
Long answerMessageId, // FOLLOWUP 시 평가 대상 답변 메시지
1313
String followupQuestion, // FOLLOWUP 시 사용
14-
AnswerEvaluation answerEvaluation // FOLLOWUP 시 옵션
14+
AnswerEvaluation answerEvaluation, // FOLLOWUP 시 옵션
15+
String answerIntent // NORMAL | DONT_KNOW | CLARIFICATION (FOLLOWUP)
1516
) {
1617
public boolean isPool() {
1718
return "POOL".equals(kind);

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ public class InterviewMessage extends BaseTimeEntity {
102102
@Column(name = "answer_correctness")
103103
private Double answerCorrectness;
104104

105+
// 부연(질문 재설명) 메시지 여부. true 면 총 질문 수·꼬리 깊이에 카운트하지 않는다.
106+
@Column(nullable = false)
107+
private boolean clarification = false;
108+
105109
private InterviewMessage(InterviewSession session, Integer sequenceNumber, MessageRole role,
106110
String content, InterviewMessage parentMessage,
107111
MessageStatus initialStatus, String idempotencyKey) {
@@ -147,6 +151,16 @@ public static InterviewMessage followup(InterviewSession session, int seq, Strin
147151
return m;
148152
}
149153

154+
// 부연: 직전 질문을 다시 설명해 같은 차례를 유지. 질문 수/깊이에 카운트 안 함.
155+
public static InterviewMessage clarification(InterviewSession session, int seq, String content,
156+
InterviewMessage parent) {
157+
InterviewMessage m = new InterviewMessage(session, seq, MessageRole.INTERVIEWER, content, parent,
158+
MessageStatus.CREATED, null);
159+
m.clarification = true;
160+
m.markTtsPending();
161+
return m;
162+
}
163+
150164
public static InterviewMessage interviewee(InterviewSession session, int seq, String content,
151165
InterviewMessage parent, String idempotencyKey) {
152166
return new InterviewMessage(session, seq, MessageRole.INTERVIEWEE, content, parent,
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- 부연(질문 재설명) 메시지 표시. clarification 메시지는 총 질문 수(k)·꼬리 깊이(m)에
2+
-- 카운트되지 않는다. 같은 질문을 다시 제시하는 용도.
3+
ALTER TABLE interview_messages
4+
ADD COLUMN clarification BOOLEAN NOT NULL DEFAULT FALSE;

backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ void apply_followupPersistsAnswerEvaluationOntoAnswerMessage() {
139139

140140
QuestionsCallbackPayload payload = new QuestionsCallbackPayload(
141141
11L, "FOLLOWUP", null, 500L, 600L, "다음 질문?",
142-
new QuestionsCallbackPayload.AnswerEvaluation(2.0, 3.0, "PARTIAL_STAR", 1.5)
142+
new QuestionsCallbackPayload.AnswerEvaluation(2.0, 3.0, "PARTIAL_STAR", 1.5), "NORMAL"
143143
);
144144
QuestionsCallbackEnvelope env = new QuestionsCallbackEnvelope(
145145
"m-eval", "callback.questions", "1", "t", null, "ai", payload, null);
@@ -160,16 +160,41 @@ void apply_followupPersistsAnswerEvaluationOntoAnswerMessage() {
160160
assertThat(answer.getAnswerCorrectness()).isEqualTo(1.5);
161161
}
162162

163+
@Test
164+
void apply_clarificationReExplainsWithoutCountingQuestion() {
165+
InterviewSession session = sessionFixture(11L, SessionStatus.IN_PROGRESS);
166+
QuestionsCallbackPayload payload = new QuestionsCallbackPayload(
167+
11L, "FOLLOWUP", null, 500L, 600L, "쉽게 다시 설명: 트랜잭션이란…",
168+
null, "CLARIFICATION"
169+
);
170+
QuestionsCallbackEnvelope env = new QuestionsCallbackEnvelope(
171+
"m-clar", "callback.questions", "1", "t", null, "ai", payload, null);
172+
173+
when(processedMessageRepository.existsById("m-clar")).thenReturn(false);
174+
when(sessionRepository.findById(11L)).thenReturn(Optional.of(session));
175+
when(messageRepository.findById(500L)).thenReturn(Optional.empty());
176+
when(messageRepository.countBySession_Id(11L)).thenReturn(2L);
177+
when(messageRepository.save(any(InterviewMessage.class))).thenAnswer(inv -> inv.getArgument(0));
178+
179+
service.apply(env);
180+
181+
ArgumentCaptor<InterviewMessage> cap = ArgumentCaptor.forClass(InterviewMessage.class);
182+
verify(messageRepository).save(cap.capture());
183+
assertThat(cap.getValue().isClarification()).isTrue();
184+
// 부연은 질문 수(k)에 카운트되지 않는다.
185+
assertThat(session.getTotalQuestionCount()).isEqualTo(0);
186+
}
187+
163188
private QuestionsCallbackEnvelope poolEnvelope(Long sessionId, List<GeneratedQuestion> questions) {
164189
QuestionsCallbackPayload payload = new QuestionsCallbackPayload(
165-
sessionId, "POOL", questions, null, null, null, null
190+
sessionId, "POOL", questions, null, null, null, null, null
166191
);
167192
return new QuestionsCallbackEnvelope("m-1", "callback.questions", "1", "t", null, "ai", payload, null);
168193
}
169194

170195
private QuestionsCallbackEnvelope followupEnvelope(Long sessionId, Long parentId, String followup) {
171196
QuestionsCallbackPayload payload = new QuestionsCallbackPayload(
172-
sessionId, "FOLLOWUP", null, parentId, null, followup, null
197+
sessionId, "FOLLOWUP", null, parentId, null, followup, null, "NORMAL"
173198
);
174199
return new QuestionsCallbackEnvelope("m-2", "callback.questions", "1", "t", null, "ai", payload, null);
175200
}

0 commit comments

Comments
 (0)