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/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..bbe37b7d5 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,21 @@ 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 currentRound ) { + private static final int START_ROUND = 1; + private static final int MAX_ROUND = 10; + @JsonProperty("remainRound") + public int remainRound() { + return MAX_ROUND + START_ROUND - currentRound; + } } 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 86ab074bc..dcc29d686 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 @@ -39,7 +40,11 @@ public InterviewMessages with(final InterviewMessage message) { return new InterviewMessages(newValues); } - public int getRound() { + public int getCurrentRound() { + return getIntervieweeMessageCount() + START_ROUND; + } + + public int getIntervieweeMessageCount() { return (int) values.stream() .filter(InterviewMessage::isByInterviewee) .count(); @@ -50,7 +55,12 @@ public List values() { } public boolean canFinish() { - return getRound() >= MAX_ROUND && !hasInterviewerClosingSummary(); + if (values.isEmpty()) { + return false; + } + return getCurrentRound() > MAX_ROUND && + lastMessage().isByInterviewer() && + !hasInterviewerClosingSummary(); } private boolean hasInterviewerClosingSummary() { @@ -76,6 +86,10 @@ public InterviewMessage lastMessage() { return values.getLast(); } + List getMessages() { + return Collections.unmodifiableList(values); + } + @Override public String toString() { return "InterviewMessages{" + 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 24b8d1aef..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/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} 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..4b507d53c 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 @@ -41,7 +42,62 @@ class InterviewMessagesTest { assertThat(interviewMessages.canFinish()).isFalse(); } - interviewMessages = interviewMessages.with(InterviewMessage.ofInterviewee("답변입니다", "답변입니다")); + interviewMessages = interviewMessages.with(InterviewMessage.ofInterviewee("답변입니다", "답변입니다")) + .with(InterviewMessage.ofInterviewer("질문입니다")); 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("가이드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(); + } } 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}회 + + } + ` 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)` + border-left: 5px solid #0D6EFD !important; + background-color: #f0f0f0 !important; + color: #000000 !important; + + display: block !important; + width: auto !important; + min-width: 200px !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` @@ -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 } + /> + + + + + )} ); };