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
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -33,6 +34,7 @@ public class InterviewService {
this.interviewSessionMapper = interviewSessionMapper;
}

@Transactional
public InterviewSessionResponse createSession(
final Long memberId,
final InterviewSessionRequest request
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ public InterviewSessionResponse mapToResponse(final InterviewSession interviewSe
interviewSession.getId(),
interviewSession.getMemberId(),
interviewSession.isFinished(),
mapToResponse(interviewSession.getMessages())
mapToResponse(interviewSession.getMessages()),
interviewSession.getRound()
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<InterviewMessageResponse> messages
List<InterviewMessageResponse> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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{" +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
@Embeddable
public class InterviewMessages {

private static final int START_ROUND = 1;
private static final int MAX_ROUND = 10;
Comment on lines +19 to 20
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Consider defining START_ROUND as a constant in a single, shared location (e.g., a configuration class or interface) to avoid duplication and potential inconsistencies with InterviewSessionResponse.java.


@ElementCollection
Expand All @@ -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();
Expand All @@ -50,7 +55,12 @@ public List<InterviewMessage> values() {
}

public boolean canFinish() {
return getRound() >= MAX_ROUND && !hasInterviewerClosingSummary();
if (values.isEmpty()) {
return false;
}
return getCurrentRound() > MAX_ROUND &&
lastMessage().isByInterviewer() &&
!hasInterviewerClosingSummary();
Comment on lines 57 to +63
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This canFinish method could be more readable. Consider extracting the conditions into separate, well-named methods to improve clarity. For example, isMaxRoundReached() and isLastMessageByInterviewer().

}

private boolean hasInterviewerClosingSummary() {
Expand All @@ -76,6 +86,10 @@ public InterviewMessage lastMessage() {
return values.getLast();
}

List<InterviewMessage> getMessages() {
return Collections.unmodifiableList(values);
}

@Override
public String toString() {
return "InterviewMessages{" +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand All @@ -83,4 +84,8 @@ public boolean isFinished() {
public InterviewMessages getMessages() {
return interviewMessages;
}

public int getRound() {
return interviewMessages.getCurrentRound();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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("인터뷰가 종료되지 않았습니다.");
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
당신은 인터뷰어입니다. 인터뷰이의 답변을 기반으로 추가 질문을 생성해야 합니다.

다만, 인터뷰의 진행은 **초기 학습 목표와 첫 질문의 주제 흐름을 유지하는 것**을 가장 우선시합니다.
인터뷰이의 답변이 이 흐름을 벗어나는 경우, 이를 부드럽게 다시 본래의 맥락으로 유도하는 질문을 제시해야 합니다.

다음 형식으로 응답을 작성하세요.

---
Expand All @@ -13,6 +16,7 @@

## 🔄 **추가 질문 (Follow-up Question)**
👉 인터뷰이의 답변을 기반으로, 사고를 확장할 수 있도록 추가 질문을 1개 제시하세요.
❗단, 인터뷰 주제 또는 초기 질문 흐름을 벗어난 경우, 원래 학습 목표를 다시 강조하거나 관련 개념으로 되돌리는 질문을 우선적으로 제시해야 합니다.

예시:
- "이 개념을 실전에서 적용한 경험이 있나요? 어떤 사례였나요?"
Expand All @@ -29,14 +33,14 @@
크루의 응답을 평가한 결과는 JSON 형식으로 제공됩니다.
추가적인 설명 없이, **순수한 JSON 데이터만 반환**하세요.

### **❗ 필수 규칙**
### ❗ **필수 규칙**
- 인터뷰이의 답변이 부족하더라도 **무조건 JSON 형식으로 피드백을 제공**해야 합니다.
- **반드시 순수한 JSON 데이터만 출력**하고, 코드 블록을 포함하지 마세요.
- 추가적인 설명이나 문장은 출력하지 말고, JSON 데이터만 반환하세요.
- 추가 질문은 한글로 작성합니다.

### **❗ 응답 형식**
### ❗ **응답 형식**
{responseFormat}

### **❗ 응답 예시**
### ❗ **응답 예시**
{responseExample}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
}
}
40 changes: 34 additions & 6 deletions frontend/src/pages/InterviewPage/InterviewPage.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
}
Comment on lines +69 to +81
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This InterviewSessionData interface is also defined in InterviewSession.tsx. Define it in a single place (e.g., a separate file like src/types/interview.ts) and import it in both components to avoid duplication and ensure type consistency.


const InterviewPage = () => {
const [session, setSession] = useState<any>(null);
const [session, setSession] = useState<InterviewSessionData | null>(null);

const handleSessionStart = (newSession: any) => {
const handleSessionStart = (newSession: InterviewSessionData) => {
setSession(newSession);
};

const handleSessionUpdate = (updatedSession: any) => {
const handleSessionUpdate = (updatedSession: InterviewSessionData) => {
setSession(updatedSession);
};

Expand All @@ -91,7 +112,14 @@ const InterviewPage = () => {
</SetupSection>
<SessionSection>
<SectionHeader>
<SectionTitle>레벨 인터뷰 진행</SectionTitle>
<SectionTitle>
레벨 인터뷰 진행
{session &&
<RoundInfo>
남은 인터뷰: {session.remainRound}회
</RoundInfo>
}
</SectionTitle>
</SectionHeader>
<SectionContent>
<InterviewSession
Expand Down
Loading
Loading