From cfb7f9c01268ef2e56b9001bf747d26ef0c7632a Mon Sep 17 00:00:00 2001 From: woowahan-neo Date: Wed, 7 May 2025 22:00:33 +0900 Subject: [PATCH 1/5] feat(interview): update interview end condition --- .../interview/domain/InterviewMessages.java | 13 ++- .../AzureOpenAiInterviewer.java | 2 +- .../domain/InterviewMessagesTest.java | 81 ++++++++++++++++++- 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/backend/src/main/java/wooteco/prolog/interview/domain/InterviewMessages.java b/backend/src/main/java/wooteco/prolog/interview/domain/InterviewMessages.java index 86ab074bc..14eeebe3f 100644 --- a/backend/src/main/java/wooteco/prolog/interview/domain/InterviewMessages.java +++ b/backend/src/main/java/wooteco/prolog/interview/domain/InterviewMessages.java @@ -16,6 +16,7 @@ @Embeddable public class InterviewMessages { + private static final int START_ROUND = 1; private static final int MAX_ROUND = 10; @ElementCollection @@ -40,6 +41,10 @@ public InterviewMessages with(final InterviewMessage message) { } public int getRound() { + return getIntervieweeMessageCount() + START_ROUND; + } + + public int getIntervieweeMessageCount() { return (int) values.stream() .filter(InterviewMessage::isByInterviewee) .count(); @@ -50,7 +55,9 @@ public List values() { } public boolean canFinish() { - return getRound() >= MAX_ROUND && !hasInterviewerClosingSummary(); + return getIntervieweeMessageCount() >= MAX_ROUND && + !hasInterviewerClosingSummary() && + !lastMessage().isByInterviewer(); } private boolean hasInterviewerClosingSummary() { @@ -76,6 +83,10 @@ public InterviewMessage lastMessage() { return values.getLast(); } + List getMessages() { + return values; + } + @Override public String toString() { return "InterviewMessages{" + diff --git a/backend/src/main/java/wooteco/prolog/interview/infrastructure/AzureOpenAiInterviewer.java b/backend/src/main/java/wooteco/prolog/interview/infrastructure/AzureOpenAiInterviewer.java index 24b8d1aef..63f283b92 100644 --- a/backend/src/main/java/wooteco/prolog/interview/infrastructure/AzureOpenAiInterviewer.java +++ b/backend/src/main/java/wooteco/prolog/interview/infrastructure/AzureOpenAiInterviewer.java @@ -165,7 +165,7 @@ private InterviewMessage createUserMessage( @Override public InterviewMessages finish(final InterviewMessages interviewMessages) { - if (interviewMessages.getRound() <= totalQuestionCount) { + if (interviewMessages.getRound() < totalQuestionCount) { throw new IllegalStateException("인터뷰가 종료되지 않았습니다."); } diff --git a/backend/src/test/java/wooteco/prolog/interview/domain/InterviewMessagesTest.java b/backend/src/test/java/wooteco/prolog/interview/domain/InterviewMessagesTest.java index 3325c6b07..9f159e118 100644 --- a/backend/src/test/java/wooteco/prolog/interview/domain/InterviewMessagesTest.java +++ b/backend/src/test/java/wooteco/prolog/interview/domain/InterviewMessagesTest.java @@ -5,27 +5,28 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; class InterviewMessagesTest { @Test - void 인터뷰이의_답변_갯수가_라운드다() { + void 인터뷰이의_답변_갯수를_조회한다() { InterviewMessages interviewMessages = new InterviewMessages(List.of( InterviewMessage.ofInterviewee("질문2", "질문2") )); - assertThat(interviewMessages.getRound()).isEqualTo(1); + assertThat(interviewMessages.getIntervieweeMessageCount()).isEqualTo(1); } @Test - void 인터뷰이의_답변이_3개면_3라운드다() { + void 인터뷰이의_답변이_3개다() { InterviewMessages interviewMessages = new InterviewMessages(List.of( InterviewMessage.ofInterviewee("질문1", "질문1"), InterviewMessage.ofInterviewee("질문2", "질문2"), InterviewMessage.ofInterviewee("질문3", "질문3") )); - assertThat(interviewMessages.getRound()).isEqualTo(3); + assertThat(interviewMessages.getIntervieweeMessageCount()).isEqualTo(3); } @Test @@ -44,4 +45,76 @@ class InterviewMessagesTest { interviewMessages = interviewMessages.with(InterviewMessage.ofInterviewee("답변입니다", "답변입니다")); assertThat(interviewMessages.canFinish()).isTrue(); } + + + @Test + void 빈_메시지_리스트로_생성시_인터뷰이_답변수는_0이고_종료할수_없다() { + InterviewMessages interviewMessages = new InterviewMessages(List.of()); + + assertAll( + () -> assertThat(interviewMessages.getMessages()).isEmpty(), + () -> assertThat(interviewMessages.getIntervieweeMessageCount()).isZero(), + () -> assertThat(interviewMessages.canFinish()).isFalse() + ); + } + + @Test + void 종료조건_인터뷰이_답변_부족시_종료불가() { + InterviewMessages interviewMessages = new InterviewMessages(List.of( + InterviewMessage.ofSystemGuide("시스템 가이드라인") + )); + for (int i = 0; i < 9; i++) { // 9개의 질문-답변 쌍 + interviewMessages = interviewMessages.with(InterviewMessage.ofInterviewer("질문 " + (i + 1))) + .with(InterviewMessage.ofInterviewee("답변 " + (i + 1), "답변 " + (i + 1))); + assertThat(interviewMessages.canFinish()).isFalse(); + } + // 마지막 인터뷰어 질문만 추가 (총 9개의 답변, 10개의 질문) + interviewMessages = interviewMessages.with(InterviewMessage.ofInterviewer("질문 10")); + assertThat(interviewMessages.canFinish()).isFalse(); + assertThat(interviewMessages.getIntervieweeMessageCount()).isEqualTo(9); + } + + @Test + void 종료조건_마지막이_인터뷰어_질문이면_종료불가() { + InterviewMessages interviewMessages = new InterviewMessages(List.of( + InterviewMessage.ofSystemGuide("시스템 가이드라인") + )); + for (int i = 0; i < 10; i++) { + interviewMessages = interviewMessages.with(InterviewMessage.ofInterviewer("질문 " + (i + 1))) + .with(InterviewMessage.ofInterviewee("답변 " + (i + 1), "답변 " + (i + 1))); + } + // 인터뷰이 답변으로 끝났으므로 종료 가능 + assertThat(interviewMessages.canFinish()).isTrue(); + + // 인터뷰어 질문 추가 + interviewMessages = interviewMessages.with(InterviewMessage.ofInterviewer("추가 질문")); + assertThat(interviewMessages.canFinish()).isFalse(); // 인터뷰어의 질문으로 끝나면 종료 불가 + assertThat(interviewMessages.getIntervieweeMessageCount()).isEqualTo(10); + } + + @Test + void 인터뷰이_답변_개수_정확히_계산_확인() { + InterviewMessages interviewMessages = new InterviewMessages(List.of( + InterviewMessage.ofSystemGuide("가이드1"), + InterviewMessage.ofInterviewer("질문1"), + InterviewMessage.ofInterviewee("답변1", "답변1"), // 1 + InterviewMessage.ofSystemGuide("가이드2"), + InterviewMessage.ofInterviewer("질문2"), + InterviewMessage.ofInterviewee("답변2", "답변2"), // 2 + InterviewMessage.ofInterviewer("질문3"), + InterviewMessage.ofInterviewee("답변3", "답변3") // 3 + )); + assertThat(interviewMessages.getIntervieweeMessageCount()).isEqualTo(3); + } + + @Test + void 인터뷰이_메시지_없을때_답변수_0_반환() { + InterviewMessages interviewMessages = new InterviewMessages(List.of( + InterviewMessage.ofSystemGuide("가이드1"), + InterviewMessage.ofInterviewer("질문1"), + InterviewMessage.ofSystemGuide("가이드2"), + InterviewMessage.ofInterviewer("질문2") + )); + assertThat(interviewMessages.getIntervieweeMessageCount()).isZero(); + } } From 821a84c063e6dc93d03a01c12800ebe077ae276a Mon Sep 17 00:00:00 2001 From: woowahan-neo Date: Thu, 8 May 2025 16:01:37 +0900 Subject: [PATCH 2/5] =?UTF-8?q?Refactor:=20InterviewSession=20=EB=B0=8F=20?= =?UTF-8?q?Interviewer=20=EC=83=81=ED=98=B8=EC=9E=91=EC=9A=A9=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InterviewSession.answer(): - 인터뷰이 메시지 생성 책임을 Interviewer 구현체로 이전. - AI 응답 후 업데이트된 전체 대화 기록으로 종료 조건 확인. - AzureOpenAiInterviewer.followUp(): - 인터뷰이 메시지 추가 후 AI 호출 전 불필요한 canFinish() 검사 제거. - AI 후속 질문 요청 로직 일관성 확보. - 누락된 checkFollowUpQuestion 메소드 복원. --- .../application/InterviewSessionMapper.java | 3 ++- .../application/InterviewSessionResponse.java | 10 ++++++++- .../interview/domain/InterviewMessage.java | 4 ++++ .../interview/domain/InterviewMessages.java | 13 +++++++----- .../interview/domain/InterviewSession.java | 19 ++++++++++------- .../AzureOpenAiInterviewer.java | 17 +++++++-------- .../domain/InterviewMessagesTest.java | 21 ++----------------- 7 files changed, 45 insertions(+), 42 deletions(-) diff --git a/backend/src/main/java/wooteco/prolog/interview/application/InterviewSessionMapper.java b/backend/src/main/java/wooteco/prolog/interview/application/InterviewSessionMapper.java index d69de12af..852817400 100644 --- a/backend/src/main/java/wooteco/prolog/interview/application/InterviewSessionMapper.java +++ b/backend/src/main/java/wooteco/prolog/interview/application/InterviewSessionMapper.java @@ -24,7 +24,8 @@ public InterviewSessionResponse mapToResponse(final InterviewSession interviewSe interviewSession.getId(), interviewSession.getMemberId(), interviewSession.isFinished(), - mapToResponse(interviewSession.getMessages()) + mapToResponse(interviewSession.getMessages()), + interviewSession.getRound() ); } diff --git a/backend/src/main/java/wooteco/prolog/interview/application/InterviewSessionResponse.java b/backend/src/main/java/wooteco/prolog/interview/application/InterviewSessionResponse.java index c7f0a3124..507597a3d 100644 --- a/backend/src/main/java/wooteco/prolog/interview/application/InterviewSessionResponse.java +++ b/backend/src/main/java/wooteco/prolog/interview/application/InterviewSessionResponse.java @@ -1,12 +1,20 @@ package wooteco.prolog.interview.application; +import com.fasterxml.jackson.annotation.JsonProperty; + import java.util.List; public record InterviewSessionResponse( Long id, Long memberId, boolean finished, - List messages + List messages, + int round ) { + private static final int MAX_ROUND = 10; + @JsonProperty("remainRound") + public int remainRound() { + return MAX_ROUND - round; + } } diff --git a/backend/src/main/java/wooteco/prolog/interview/domain/InterviewMessage.java b/backend/src/main/java/wooteco/prolog/interview/domain/InterviewMessage.java index 39ef18c30..79e90e6d3 100644 --- a/backend/src/main/java/wooteco/prolog/interview/domain/InterviewMessage.java +++ b/backend/src/main/java/wooteco/prolog/interview/domain/InterviewMessage.java @@ -141,6 +141,10 @@ public boolean isIntervieweeFollowUp() { return sender == Sender.INTERVIEWEE && type == Type.FOLLOW_UP; } + public String getOriginalContent() { + return originalContent; + } + @Override public String toString() { return "InterviewMessage{" + diff --git a/backend/src/main/java/wooteco/prolog/interview/domain/InterviewMessages.java b/backend/src/main/java/wooteco/prolog/interview/domain/InterviewMessages.java index 14eeebe3f..dcc29d686 100644 --- a/backend/src/main/java/wooteco/prolog/interview/domain/InterviewMessages.java +++ b/backend/src/main/java/wooteco/prolog/interview/domain/InterviewMessages.java @@ -40,7 +40,7 @@ public InterviewMessages with(final InterviewMessage message) { return new InterviewMessages(newValues); } - public int getRound() { + public int getCurrentRound() { return getIntervieweeMessageCount() + START_ROUND; } @@ -55,9 +55,12 @@ public List values() { } public boolean canFinish() { - return getIntervieweeMessageCount() >= MAX_ROUND && - !hasInterviewerClosingSummary() && - !lastMessage().isByInterviewer(); + if (values.isEmpty()) { + return false; + } + return getCurrentRound() > MAX_ROUND && + lastMessage().isByInterviewer() && + !hasInterviewerClosingSummary(); } private boolean hasInterviewerClosingSummary() { @@ -84,7 +87,7 @@ public InterviewMessage lastMessage() { } List getMessages() { - return values; + return Collections.unmodifiableList(values); } @Override diff --git a/backend/src/main/java/wooteco/prolog/interview/domain/InterviewSession.java b/backend/src/main/java/wooteco/prolog/interview/domain/InterviewSession.java index 4882e8771..4e01773d6 100644 --- a/backend/src/main/java/wooteco/prolog/interview/domain/InterviewSession.java +++ b/backend/src/main/java/wooteco/prolog/interview/domain/InterviewSession.java @@ -49,22 +49,23 @@ public void start( public void answer( final Interviewer interviewer, final Long memberId, - final String message + final String intervieweeAnswerContent ) { if (!this.memberId.equals(memberId)) { throw new BadRequestException(BadRequestCode.MEMBER_NOT_ALLOWED); } - if (finished) { + if (this.finished) { throw new BadRequestException(BadRequestCode.INTERVIEW_SESSION_FINISHED); } - if (interviewMessages.lastMessage().isByInterviewee()) { + if (interviewMessages.isEmpty() || interviewMessages.lastMessage().isByInterviewee()) { throw new BadRequestException(BadRequestCode.INTERVIEW_SESSION_NOT_YOUR_TURN); } - interviewMessages = interviewer.followUp(interviewMessages, message); - if (interviewMessages.canFinish()) { - finished = true; - interviewMessages = interviewer.finish(interviewMessages); + this.interviewMessages = interviewer.followUp(this.interviewMessages, intervieweeAnswerContent); + + if (this.interviewMessages.canFinish()) { + this.finished = true; + this.interviewMessages = interviewer.finish(this.interviewMessages); } } @@ -83,4 +84,8 @@ public boolean isFinished() { public InterviewMessages getMessages() { return interviewMessages; } + + public int getRound() { + return interviewMessages.getCurrentRound(); + } } diff --git a/backend/src/main/java/wooteco/prolog/interview/infrastructure/AzureOpenAiInterviewer.java b/backend/src/main/java/wooteco/prolog/interview/infrastructure/AzureOpenAiInterviewer.java index 63f283b92..7a5a36e49 100644 --- a/backend/src/main/java/wooteco/prolog/interview/infrastructure/AzureOpenAiInterviewer.java +++ b/backend/src/main/java/wooteco/prolog/interview/infrastructure/AzureOpenAiInterviewer.java @@ -120,20 +120,19 @@ public InterviewMessages followUp( final InterviewMessages interviewMessages, final String answer ) { - if (interviewMessages.getRound() > totalQuestionCount) { + if (interviewMessages.getCurrentRound() > totalQuestionCount) { throw new IllegalStateException("인터뷰가 종료되었습니다."); } log.debug("Follow up [interviewMessages={}, answer={}]", interviewMessages, answer); - final var messages = interviewMessages.with(createUserMessage(interviewMessages.getRound(), answer)); - if (messages.canFinish()) { - return messages; - } - final var rawFollowUpQuestion = askToInterviewer(messages); - checkFollowUpQuestion(rawFollowUpQuestion); + final var messagesWithUserAnswer = interviewMessages.with(createUserMessage(interviewMessages.getCurrentRound(), answer)); + + final var rawFollowUpQuestion = askToInterviewer(messagesWithUserAnswer); + checkFollowUpQuestion(rawFollowUpQuestion); // Validates AI response format - return messages.with(InterviewMessage.ofInterviewer(rawFollowUpQuestion)); + // Add AI's new message (the follow-up question/statement) to the history. + return messagesWithUserAnswer.with(InterviewMessage.ofInterviewer(rawFollowUpQuestion)); } private void checkFollowUpQuestion(final String rawFollowUpQuestion) { @@ -165,7 +164,7 @@ private InterviewMessage createUserMessage( @Override public InterviewMessages finish(final InterviewMessages interviewMessages) { - if (interviewMessages.getRound() < totalQuestionCount) { + if (interviewMessages.getCurrentRound() < totalQuestionCount) { throw new IllegalStateException("인터뷰가 종료되지 않았습니다."); } diff --git a/backend/src/test/java/wooteco/prolog/interview/domain/InterviewMessagesTest.java b/backend/src/test/java/wooteco/prolog/interview/domain/InterviewMessagesTest.java index 9f159e118..4b507d53c 100644 --- a/backend/src/test/java/wooteco/prolog/interview/domain/InterviewMessagesTest.java +++ b/backend/src/test/java/wooteco/prolog/interview/domain/InterviewMessagesTest.java @@ -42,7 +42,8 @@ class InterviewMessagesTest { assertThat(interviewMessages.canFinish()).isFalse(); } - interviewMessages = interviewMessages.with(InterviewMessage.ofInterviewee("답변입니다", "답변입니다")); + interviewMessages = interviewMessages.with(InterviewMessage.ofInterviewee("답변입니다", "답변입니다")) + .with(InterviewMessage.ofInterviewer("질문입니다")); assertThat(interviewMessages.canFinish()).isTrue(); } @@ -74,24 +75,6 @@ class InterviewMessagesTest { assertThat(interviewMessages.getIntervieweeMessageCount()).isEqualTo(9); } - @Test - void 종료조건_마지막이_인터뷰어_질문이면_종료불가() { - InterviewMessages interviewMessages = new InterviewMessages(List.of( - InterviewMessage.ofSystemGuide("시스템 가이드라인") - )); - for (int i = 0; i < 10; i++) { - interviewMessages = interviewMessages.with(InterviewMessage.ofInterviewer("질문 " + (i + 1))) - .with(InterviewMessage.ofInterviewee("답변 " + (i + 1), "답변 " + (i + 1))); - } - // 인터뷰이 답변으로 끝났으므로 종료 가능 - assertThat(interviewMessages.canFinish()).isTrue(); - - // 인터뷰어 질문 추가 - interviewMessages = interviewMessages.with(InterviewMessage.ofInterviewer("추가 질문")); - assertThat(interviewMessages.canFinish()).isFalse(); // 인터뷰어의 질문으로 끝나면 종료 불가 - assertThat(interviewMessages.getIntervieweeMessageCount()).isEqualTo(10); - } - @Test void 인터뷰이_답변_개수_정확히_계산_확인() { InterviewMessages interviewMessages = new InterviewMessages(List.of( From 28dea7262c6b1f10645d97a69de12705faed5c99 Mon Sep 17 00:00:00 2001 From: woowahan-neo Date: Fri, 9 May 2025 00:00:11 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EC=9D=B8=ED=84=B0=EB=B7=B0=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20UI=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20?= =?UTF-8?q?=EB=8B=B5=EB=B3=80=20=ED=9B=84=20=ED=8F=AC=EC=BB=A4=EC=8A=A4=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 인터뷰 종료 시 마지막 피드백 메시지 스타일 변경 시도 (진단용 스타일 적용 상태) - 답변 제출 후 입력창에 포커스 유지 기능 추가 --- .../application/InterviewService.java | 2 + .../application/InterviewSessionResponse.java | 5 +- .../src/pages/InterviewPage/InterviewPage.tsx | 40 +++- .../pages/InterviewPage/InterviewSession.tsx | 182 ++++++++++-------- 4 files changed, 136 insertions(+), 93 deletions(-) diff --git a/backend/src/main/java/wooteco/prolog/interview/application/InterviewService.java b/backend/src/main/java/wooteco/prolog/interview/application/InterviewService.java index 480143142..4a4bbab4b 100644 --- a/backend/src/main/java/wooteco/prolog/interview/application/InterviewService.java +++ b/backend/src/main/java/wooteco/prolog/interview/application/InterviewService.java @@ -1,6 +1,7 @@ package wooteco.prolog.interview.application; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import wooteco.prolog.common.exception.BadRequestException; import wooteco.prolog.interview.domain.InterviewSession; import wooteco.prolog.interview.domain.Interviewer; @@ -33,6 +34,7 @@ public class InterviewService { this.interviewSessionMapper = interviewSessionMapper; } + @Transactional public InterviewSessionResponse createSession( final Long memberId, final InterviewSessionRequest request diff --git a/backend/src/main/java/wooteco/prolog/interview/application/InterviewSessionResponse.java b/backend/src/main/java/wooteco/prolog/interview/application/InterviewSessionResponse.java index 507597a3d..bbe37b7d5 100644 --- a/backend/src/main/java/wooteco/prolog/interview/application/InterviewSessionResponse.java +++ b/backend/src/main/java/wooteco/prolog/interview/application/InterviewSessionResponse.java @@ -9,12 +9,13 @@ public record InterviewSessionResponse( Long memberId, boolean finished, List messages, - int round + int currentRound ) { + private static final int START_ROUND = 1; private static final int MAX_ROUND = 10; @JsonProperty("remainRound") public int remainRound() { - return MAX_ROUND - round; + return MAX_ROUND + START_ROUND - currentRound; } } diff --git a/frontend/src/pages/InterviewPage/InterviewPage.tsx b/frontend/src/pages/InterviewPage/InterviewPage.tsx index c570273b4..971ebf9c9 100644 --- a/frontend/src/pages/InterviewPage/InterviewPage.tsx +++ b/frontend/src/pages/InterviewPage/InterviewPage.tsx @@ -1,8 +1,8 @@ /** @jsxImportSource @emotion/react */ -import React, { useState } from 'react'; +import React, {useState} from 'react'; import styled from '@emotion/styled'; -import { MainContentStyle } from '../../PageRouter'; +import {MainContentStyle} from '../../PageRouter'; import InterviewSetup from './InterviewSetup'; import InterviewSession from './InterviewSession'; @@ -53,20 +53,41 @@ const SectionTitle = styled.h2` margin: 0; `; +const RoundInfo = styled.span` + font-size: 1rem; + font-weight: 400; + color: #666; + margin-left: 1rem; +`; + const SectionContent = styled.div` flex: 1; min-height: 0; padding: 2rem; `; +interface InterviewSessionData { + id: number; + memberId: number; + finished: boolean; + messages: { + sender: 'SYSTEM' | 'INTERVIEWER' | 'INTERVIEWEE'; + content: string; + hint: string; + createdAt: string; + }[]; + currentRound: number; + remainRound: number; +} + const InterviewPage = () => { - const [session, setSession] = useState(null); + const [session, setSession] = useState(null); - const handleSessionStart = (newSession: any) => { + const handleSessionStart = (newSession: InterviewSessionData) => { setSession(newSession); }; - const handleSessionUpdate = (updatedSession: any) => { + const handleSessionUpdate = (updatedSession: InterviewSessionData) => { setSession(updatedSession); }; @@ -91,7 +112,14 @@ const InterviewPage = () => { - 레벨 인터뷰 진행 + + 레벨 인터뷰 진행 + {session && + + 남은 인터뷰: {session.remainRound}회 + + } + ` line-height: 1.5; `; +const EmphasizedFeedbackMessage = styled(Message)` + border-left: 5px solid #0D6EFD !important; + background-color: #f0f0f0 !important; + color: #000000 !important; + + display: block !important; + width: auto !important; + min-width: 200px !important; + white-space: normal !important; + word-break: normal !important; + + padding: 1rem !important; + border-top: none !important; + border-right: none !important; + border-bottom: none !important; + box-shadow: none !important; + border-radius: 4px !important; +`; + const InputContainer = styled.div` display: flex; gap: 1rem; @@ -142,19 +161,23 @@ const HintTooltip = styled.div` font-family: inherit; `; +interface InterviewSessionData { + id: number; + memberId: number; + finished: boolean; + messages: { + sender: 'SYSTEM' | 'INTERVIEWER' | 'INTERVIEWEE'; + content: string; + hint: string; + createdAt: string; + }[]; + currentRound: number; + remainRound: number; +} + interface InterviewSessionProps { - session: { - id: number; - memberId: number; - finished: boolean; - messages: { - sender: 'SYSTEM' | 'INTERVIEWER' | 'INTERVIEWEE'; - content: string; - hint: string; - createdAt: string; - }[]; - }; - onSessionUpdate: (session: any) => void; + session: InterviewSessionData | null; + onSessionUpdate: (session: InterviewSessionData) => void; onSessionEnd: () => void; } @@ -169,6 +192,7 @@ const InterviewSession = ({ const [isTyping, setIsTyping] = useState(false); const chatContainerRef = useRef(null); const [hoveredHintIdx, setHoveredHintIdx] = useState(null); + const inputRef = useRef(null); useEffect(() => { setLocalMessages(session?.messages ?? []); @@ -206,52 +230,15 @@ const InterviewSession = ({ } ); setLocalMessages(response.data.messages); - onSessionUpdate(response.data); + onSessionUpdate(response.data as InterviewSessionData); } catch (error) { console.error('Failed to submit answer:', error); } finally { setIsSubmitting(false); setIsTyping(false); - } - }; - - const handleRestart = async () => { - if (!session) return; - - try { - const accessToken = localStorage.getItem('accessToken'); - const response = await client.post( - `/interviews/${session.id}/restart`, - {}, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } - ); - onSessionUpdate(response.data); - } catch (error) { - console.error('Failed to restart interview:', error); - } - }; - - const handleQuit = async () => { - if (!session) return; - - try { - const accessToken = localStorage.getItem('accessToken'); - await client.post( - `/interviews/${session.id}/quit`, - {}, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } - ); - onSessionEnd(); - } catch (error) { - console.error('Failed to quit interview:', error); + if (session && !(session.finished || session.remainRound <= 0)) { + inputRef.current?.focus(); + } } }; @@ -269,26 +256,41 @@ const InterviewSession = ({ {localMessages.map((message, idx) => { + const isLastMessage = idx === localMessages.length - 1; + const isInterviewFinished = !!(session?.finished || session?.remainRound <= 0); + if (message.sender === 'INTERVIEWER') { + const CurrentMessageComponent = (isInterviewFinished && isLastMessage) + ? EmphasizedFeedbackMessage + : Message; return ( -
- {message.content} - setHoveredHintIdx(idx)} - onMouseLeave={() => setHoveredHintIdx(null)} - aria-label="힌트" - > - ? - {hoveredHintIdx === idx && ( - - {message.hint || '힌트가 없습니다.'} - - )} - +
+ + {message.content} + + {message.hint && ( + setHoveredHintIdx(idx)} + onMouseLeave={() => setHoveredHintIdx(null)} + aria-label="힌트" + > + ? + {hoveredHintIdx === idx && ( + + {message.hint || '힌트가 없습니다.'} + + )} + + )}
); } - return {message.content}; + // INTERVIEWEE 메시지의 경우 (또는 시스템 메시지 등) - 여기서는 강조 없음 + return ( + + {message.content} + + ); })} {isTyping && ( @@ -296,23 +298,33 @@ const InterviewSession = ({ )} - - setAnswer(e.target.value)} - placeholder="답변을 입력하세요..." - onKeyPress={(e) => e.key === 'Enter' && handleSubmit()} - disabled={session.finished} - /> - - - - - + + {/* 인터뷰가 진행 중일 때만 답변 입력창 표시 */} + {session && !(session.finished || session.remainRound <= 0) && ( + + setAnswer(e.target.value)} + placeholder="답변을 입력하세요..." + onKeyPress={(e) => { + if (e.key === 'Enter' && !e.shiftKey && !isSubmitting) { + e.preventDefault(); + handleSubmit(); + } + }} + disabled={isSubmitting || !session } + /> + + + + + )} ); }; From 5a6f0751720c8d29442645320973c53f5e7de8cb Mon Sep 17 00:00:00 2001 From: woowahan-neo Date: Fri, 9 May 2025 00:08:53 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix(interview):=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A4=84=EB=B0=94=EA=BF=88=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80=20=EB=B0=8F=20=EC=99=84=EB=A3=8C=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EB=8B=A8=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Shift+Enter로 입력된 여러 줄의 메시지가 올바르게 표시되도록 white-space 속성 수정 - 인터뷰 종료 여부 확인 로직 간소화 --- frontend/src/pages/InterviewPage/InterviewSession.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/InterviewPage/InterviewSession.tsx b/frontend/src/pages/InterviewPage/InterviewSession.tsx index 7829c4a72..4d38997a3 100644 --- a/frontend/src/pages/InterviewPage/InterviewSession.tsx +++ b/frontend/src/pages/InterviewPage/InterviewSession.tsx @@ -30,6 +30,7 @@ const Message = styled.div<{ isUser: boolean }>` box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); font-size: 1.125rem; line-height: 1.5; + white-space: pre-wrap; `; const EmphasizedFeedbackMessage = styled(Message)` @@ -40,7 +41,6 @@ const EmphasizedFeedbackMessage = styled(Message)` display: block !important; width: auto !important; min-width: 200px !important; - white-space: normal !important; word-break: normal !important; padding: 1rem !important; @@ -257,7 +257,7 @@ const InterviewSession = ({ {localMessages.map((message, idx) => { const isLastMessage = idx === localMessages.length - 1; - const isInterviewFinished = !!(session?.finished || session?.remainRound <= 0); + const isInterviewFinished = (session?.finished || session?.remainRound <= 0); if (message.sender === 'INTERVIEWER') { const CurrentMessageComponent = (isInterviewFinished && isLastMessage) From cea9b537588549047a096818d2200c666e44988c Mon Sep 17 00:00:00 2001 From: woowahan-neo Date: Fri, 9 May 2025 13:25:59 +0900 Subject: [PATCH 5/5] =?UTF-8?q?refactor(interview):=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=20=EC=A7=88=EB=AC=B8=EC=9D=98=20=ED=9D=90=EB=A6=84=EC=9D=84=20?= =?UTF-8?q?=EB=B2=97=EC=96=B4=EB=82=98=EC=A7=80=20=EC=95=8A=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../prompts/qna-interview-interactive-follow-up.st | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/src/main/resources/prompts/qna-interview-interactive-follow-up.st b/backend/src/main/resources/prompts/qna-interview-interactive-follow-up.st index 29428422c..6845061d0 100644 --- a/backend/src/main/resources/prompts/qna-interview-interactive-follow-up.st +++ b/backend/src/main/resources/prompts/qna-interview-interactive-follow-up.st @@ -1,5 +1,8 @@ 당신은 인터뷰어입니다. 인터뷰이의 답변을 기반으로 추가 질문을 생성해야 합니다. +다만, 인터뷰의 진행은 **초기 학습 목표와 첫 질문의 주제 흐름을 유지하는 것**을 가장 우선시합니다. +인터뷰이의 답변이 이 흐름을 벗어나는 경우, 이를 부드럽게 다시 본래의 맥락으로 유도하는 질문을 제시해야 합니다. + 다음 형식으로 응답을 작성하세요. --- @@ -13,6 +16,7 @@ ## 🔄 **추가 질문 (Follow-up Question)** 👉 인터뷰이의 답변을 기반으로, 사고를 확장할 수 있도록 추가 질문을 1개 제시하세요. +❗단, 인터뷰 주제 또는 초기 질문 흐름을 벗어난 경우, 원래 학습 목표를 다시 강조하거나 관련 개념으로 되돌리는 질문을 우선적으로 제시해야 합니다. 예시: - "이 개념을 실전에서 적용한 경험이 있나요? 어떤 사례였나요?" @@ -29,14 +33,14 @@ 크루의 응답을 평가한 결과는 JSON 형식으로 제공됩니다. 추가적인 설명 없이, **순수한 JSON 데이터만 반환**하세요. -### **❗ 필수 규칙** +### ❗ **필수 규칙** - 인터뷰이의 답변이 부족하더라도 **무조건 JSON 형식으로 피드백을 제공**해야 합니다. - **반드시 순수한 JSON 데이터만 출력**하고, 코드 블록을 포함하지 마세요. - 추가적인 설명이나 문장은 출력하지 말고, JSON 데이터만 반환하세요. - 추가 질문은 한글로 작성합니다. -### **❗ 응답 형식** +### ❗ **응답 형식** {responseFormat} -### **❗ 응답 예시** +### ❗ **응답 예시** {responseExample}