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
4 changes: 3 additions & 1 deletion ai/src/ai_server/chain/followup_generation_chain.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand Down
6 changes: 6 additions & 0 deletions ai/src/ai_server/chain/prompts/followup_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 스키마를 따릅니다."
)

Expand Down
1 change: 1 addition & 0 deletions ai/src/ai_server/messaging/consumers/followup_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions ai/src/ai_server/model/messages/followup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,8 @@ public void onAnswerSubmitted(AnswerSubmittedEvent event) {
private int currentFollowupDepth(List<InterviewMessage> 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; // 일반질문 → 리셋
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- 부연(질문 재설명) 메시지 표시. clarification 메시지는 총 질문 수(k)·꼬리 깊이(m)에
-- 카운트되지 않는다. 같은 질문을 다시 제시하는 용도.
ALTER TABLE interview_messages
ADD COLUMN clarification BOOLEAN NOT NULL DEFAULT FALSE;
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<InterviewMessage> 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<GeneratedQuestion> 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);
}
Expand Down