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
Expand Up @@ -37,7 +37,7 @@ public class Answer extends BaseEntity {
@JoinColumn(name = "question_id", nullable = false)
private Question question;

@Column(name = "content", nullable = false, length = 2_000)
@Column(name = "content", nullable = false, length = 10_000)
private String content;

@Column(name = "answer_rank", nullable = false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ public Interview(Member member, RootQuestion rootQuestion, Integer maxQuestionCo
InterviewType.CATEGORY_BASED, null, null, 0L, 0L, null, null);
}

public Interview(Member member, RootQuestion rootQuestion, Integer maxQuestionCount, InterviewMode interviewMode,
InterviewType interviewType) {
this(null, member, rootQuestion, maxQuestionCount, InterviewState.IN_PROGRESS, interviewMode,
interviewType, null, null, 0L, 0L, null, null);
}

public Interview(Member member, GeneratedQuestion generatedQuestion, Integer maxQuestionCount,
InterviewMode interviewMode) {
this(null, member, null, maxQuestionCount, InterviewState.IN_PROGRESS, interviewMode,
Expand Down Expand Up @@ -178,6 +184,10 @@ public boolean isResumeBased() {
return this.interviewType == InterviewType.RESUME_BASED;
}

public boolean isLiveCoding() {
return this.interviewType == InterviewType.LIVE_CODING;
}

public String getDisplayCategory() {
if (isResumeBased()) {
return RESUME_BASED_DISPLAY_CATEGORY;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@

public enum InterviewType {
CATEGORY_BASED,
RESUME_BASED
RESUME_BASED,
LIVE_CODING
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public class Question extends BaseEntity {
@JoinColumn(name = "interview_id", nullable = false)
private Interview interview;

@Column(name = "content", nullable = false, length = 1_000)
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
private String content;

public Question(Interview interview, String content) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.samhap.kokomen.category.domain.Category;
import com.samhap.kokomen.global.domain.BaseEntity;
import com.samhap.kokomen.global.exception.BadRequestException;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
Expand Down Expand Up @@ -38,7 +39,14 @@ public class RootQuestion extends BaseEntity {
@Column(name = "state", nullable = false)
private RootQuestionState state;

@Column(name = "content", nullable = false, length = 1_000)
@Enumerated(EnumType.STRING)
@Column(name = "question_type", nullable = false)
private RootQuestionType questionType;

@Column(name = "title", length = 255)
private String title;

@Column(name = "content", nullable = false, columnDefinition = "TEXT")
private String content;

@Column(name = "question_order")
Expand All @@ -47,6 +55,31 @@ public class RootQuestion extends BaseEntity {
public RootQuestion(Category category, String content) {
this.category = category;
this.state = RootQuestionState.ACTIVE;
this.questionType = RootQuestionType.GENERAL;
this.content = content;
}

public static RootQuestion forCode(Category category, String title, String content) {
if (title == null || title.isBlank()) {
throw new BadRequestException("코드 타입 루트 질문은 제목(title)이 필수입니다.");
}
RootQuestion rootQuestion = new RootQuestion();
rootQuestion.category = category;
rootQuestion.state = RootQuestionState.ACTIVE;
rootQuestion.questionType = RootQuestionType.CODE;
rootQuestion.title = title;
rootQuestion.content = content;
return rootQuestion;
Comment on lines +62 to +72

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

CODE 루트 질문 생성 시 제목/본문 유효성 검증이 필요합니다.

forCode(...)title/content의 null·blank를 허용해서 CODE 질문의 필수 의미가 깨질 수 있고, 이후 초기 질문 생성 시 비정상 문자열이 생성될 수 있습니다.

수정 제안
+import com.samhap.kokomen.global.exception.BadRequestException;
@@
     public static RootQuestion forCode(Category category, String title, String content) {
+        validateCodeQuestion(title, content);
         RootQuestion rootQuestion = new RootQuestion();
         rootQuestion.category = category;
         rootQuestion.state = RootQuestionState.ACTIVE;
         rootQuestion.questionType = RootQuestionType.CODE;
         rootQuestion.title = title;
         rootQuestion.content = content;
         return rootQuestion;
     }
+
+    private static void validateCodeQuestion(String title, String content) {
+        if (title == null || title.isBlank()) {
+            throw new BadRequestException("코딩 문제 제목은 비어 있을 수 없습니다.");
+        }
+        if (content == null || content.isBlank()) {
+            throw new BadRequestException("코딩 문제 본문은 비어 있을 수 없습니다.");
+        }
+    }

As per coding guidelines, "Use @Valid annotation in DTOs for validation, entity-level validation in constructors, business validation in service layer".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/samhap/kokomen/interview/domain/RootQuestion.java` around
lines 61 - 68, The forCode factory method does not validate that the title and
content parameters are non-null and non-blank before assigning them to the
RootQuestion entity, which violates entity-level validation requirements. Add
validation checks in the forCode method to ensure both title and content
parameters are not null and not blank, throwing an appropriate exception (such
as IllegalArgumentException) if either validation fails. This will prevent the
creation of CODE type RootQuestion instances with invalid or empty title and
content fields.

Source: Coding guidelines

}
Comment on lines +62 to +73

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

CODE 타입의 루트 질문은 반드시 제목(title)이 존재해야 합니다. forCode 팩토리 메서드 호출 시 titlenull이거나 빈 값인지 검증하는 로직을 추가하여 올바르지 않은 상태의 객체가 생성되는 것을 방지하는 것이 좋습니다.

    public static RootQuestion forCode(Category category, String title, String content) {
        if (title == null || title.isBlank()) {
            throw new IllegalArgumentException("코드 타입 질문은 제목(title)이 필수입니다.");
        }
        RootQuestion rootQuestion = new RootQuestion();
        rootQuestion.category = category;
        rootQuestion.state = RootQuestionState.ACTIVE;
        rootQuestion.questionType = RootQuestionType.CODE;
        rootQuestion.title = title;
        rootQuestion.content = content;
        return rootQuestion;
    }


public String createInitialQuestionContent() {
if (questionType == RootQuestionType.CODE && title != null && !title.isBlank()) {
return title + "\n\n" + content;
}
return content;
}
Comment on lines +75 to +80

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

questionTypeCODE일 때 titlenull이거나 빈 문자열(blank)인 경우, title + "\n\n" + content 연산으로 인해 "null\n\n..."과 같이 비정상적인 문자열이 생성될 수 있습니다.

title이 유효한 경우에만 제목을 본문 앞에 추가하도록 방어적으로 코드를 개선하는 것이 좋습니다.

Suggested change
public String createInitialQuestionContent() {
if (questionType == RootQuestionType.CODE) {
return title + "\n\n" + content;
}
return content;
}
public String createInitialQuestionContent() {
if (questionType == RootQuestionType.CODE && title != null && !title.isBlank()) {
return title + "\n\n" + content;
}
return content;
}


public boolean isCode() {
return this.questionType == RootQuestionType.CODE;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.samhap.kokomen.interview.domain;

public enum RootQuestionType {
GENERAL,
CODE,
;
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ public AnswerFeedbackBedrockClient(

public String requestAnswerFeedback(QuestionAndAnswers questionAndAnswers, AnswerRank curAnswerRank) {
ConverseResponse response = converseClient.converse(
InterviewBedrockRequestFactory.createAnswerFeedbackSystem(curAnswerRank),
InterviewBedrockRequestFactory.createAnswerFeedbackSystem(
questionAndAnswers.getInterview().getInterviewType(), curAnswerRank),
InterviewBedrockRequestFactory.createAnswerFeedbackMessages(questionAndAnswers),
InterviewBedrockRequestFactory.createAnswerFeedbackToolConfig(),
properties.answerFeedbackMaxTokens(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ public BedrockConverseResponse requestToBedrock(QuestionAndAnswers questionAndAn

private BedrockConverseResponse requestProceed(QuestionAndAnswers questionAndAnswers) {
ConverseResponse response = converseClient.converse(
InterviewBedrockRequestFactory.createProceedSystem(),
InterviewBedrockRequestFactory.createProceedSystem(
questionAndAnswers.getInterview().getInterviewType()),
InterviewBedrockRequestFactory.createProceedMessages(questionAndAnswers),
InterviewBedrockRequestFactory.createProceedToolConfig(),
properties.proceedMaxTokens(),
Expand All @@ -47,7 +48,8 @@ private BedrockConverseResponse requestProceed(QuestionAndAnswers questionAndAns

private BedrockConverseResponse requestEnd(QuestionAndAnswers questionAndAnswers) {
ConverseResponse response = converseClient.converse(
InterviewBedrockRequestFactory.createEndSystem(),
InterviewBedrockRequestFactory.createEndSystem(
questionAndAnswers.getInterview().getInterviewType()),
InterviewBedrockRequestFactory.createProceedMessages(questionAndAnswers),
InterviewBedrockRequestFactory.createEndToolConfig(),
properties.endMaxTokens(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import com.samhap.kokomen.answer.domain.Answer;
import com.samhap.kokomen.answer.domain.AnswerRank;
import com.samhap.kokomen.interview.domain.InterviewType;
import com.samhap.kokomen.interview.domain.Question;
import com.samhap.kokomen.interview.tool.CodingInterviewBedrockSystemMessageConstant;
import com.samhap.kokomen.interview.tool.InterviewBedrockSystemMessageConstant;
import com.samhap.kokomen.interview.tool.QuestionAndAnswers;
import java.util.ArrayList;
Expand All @@ -28,22 +30,32 @@ public final class InterviewBedrockRequestFactory {
private InterviewBedrockRequestFactory() {
}

public static List<SystemContentBlock> createProceedSystem() {
public static List<SystemContentBlock> createProceedSystem(InterviewType interviewType) {
String prompt = interviewType == InterviewType.LIVE_CODING
? CodingInterviewBedrockSystemMessageConstant.CODING_IN_PROGRESS_RANK_AND_NEXT_QUESTION_PROMPT
: InterviewBedrockSystemMessageConstant.IN_PROGRESS_RANK_AND_NEXT_QUESTION_PROMPT;
return List.of(SystemContentBlock.builder()
.text(InterviewBedrockSystemMessageConstant.IN_PROGRESS_RANK_AND_NEXT_QUESTION_PROMPT)
.text(prompt)
.build());
}

public static List<SystemContentBlock> createEndSystem() {
public static List<SystemContentBlock> createEndSystem(InterviewType interviewType) {
String prompt = interviewType == InterviewType.LIVE_CODING
? CodingInterviewBedrockSystemMessageConstant.CODING_END_PROMPT
: InterviewBedrockSystemMessageConstant.END_PROMPT;
return List.of(SystemContentBlock.builder()
.text(InterviewBedrockSystemMessageConstant.END_PROMPT)
.text(prompt)
.build());
}

public static List<SystemContentBlock> createAnswerFeedbackSystem(AnswerRank curAnswerRank) {
public static List<SystemContentBlock> createAnswerFeedbackSystem(InterviewType interviewType,
AnswerRank curAnswerRank) {
String prompt = interviewType == InterviewType.LIVE_CODING
? CodingInterviewBedrockSystemMessageConstant.CODING_ANSWER_FEEDBACK_PROMPT
: InterviewBedrockSystemMessageConstant.ANSWER_FEEDBACK_PROMPT;
return List.of(
SystemContentBlock.builder()
.text(InterviewBedrockSystemMessageConstant.ANSWER_FEEDBACK_PROMPT)
.text(prompt)
.build(),
SystemContentBlock.builder()
.text("<context>대상 답변 rank: " + curAnswerRank.name() + "</context>")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.samhap.kokomen.category.domain.Category;
import com.samhap.kokomen.interview.domain.RootQuestion;
import com.samhap.kokomen.interview.domain.RootQuestionState;
import com.samhap.kokomen.interview.domain.RootQuestionType;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
Expand All @@ -15,6 +16,7 @@ public interface RootQuestionRepository extends JpaRepository<RootQuestion, Long
SELECT r
FROM RootQuestion r
WHERE r.category = :category AND r.state = :rootQuestionState
AND r.questionType = :questionType
AND NOT EXISTS (
SELECT 1
FROM Interview i
Expand All @@ -26,21 +28,24 @@ AND NOT EXISTS (
Optional<RootQuestion> findFirstRootQuestionMemberNotReceivedByCategory(
@Param("category") Category category,
@Param("memberId") Long memberId,
@Param("rootQuestionState") RootQuestionState rootQuestionState
@Param("rootQuestionState") RootQuestionState rootQuestionState,
@Param("questionType") RootQuestionType questionType
);

@Query(value = """
SELECT r
FROM Interview i JOIN RootQuestion r
ON i.rootQuestion = r
WHERE i.member.id = :memberId AND r.category = :category AND r.state = :rootQuestionState
AND r.questionType = :questionType
ORDER BY i.id DESC
LIMIT 1
""")
Optional<RootQuestion> findLastRootQuestionMemberReceivedByCategory(
@Param("category") Category category,
@Param("memberId") Long memberId,
@Param("rootQuestionState") RootQuestionState rootQuestionState
@Param("rootQuestionState") RootQuestionState rootQuestionState,
@Param("questionType") RootQuestionType questionType
);

Optional<RootQuestion> findRootQuestionByCategoryAndStateAndQuestionOrder(Category category,
Expand All @@ -50,4 +55,6 @@ Optional<RootQuestion> findRootQuestionByCategoryAndStateAndQuestionOrder(Catego
List<RootQuestion> findAllByCategoryAndState(Category category, RootQuestionState state);

List<RootQuestion> findAllByState(RootQuestionState state);

List<RootQuestion> findAllByStateAndQuestionType(RootQuestionState state, RootQuestionType questionType);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.samhap.kokomen.interview.domain.GeneratedQuestion;
import com.samhap.kokomen.interview.domain.Interview;
import com.samhap.kokomen.interview.domain.InterviewMode;
import com.samhap.kokomen.interview.domain.InterviewType;
import com.samhap.kokomen.interview.domain.Question;
import com.samhap.kokomen.interview.domain.ResumeQuestionGeneration;
import com.samhap.kokomen.interview.domain.RootQuestion;
Expand Down Expand Up @@ -56,14 +57,18 @@ public class InterviewStartFacadeService {
@Transactional
public InterviewStartResponse startInterview(InterviewRequest interviewRequest, MemberAuth memberAuth) {
InterviewMode interviewMode = interviewRequest.mode();
validateLiveCodingNotVoice(interviewRequest, interviewMode);
int requiredTokenCount = interviewRequest.maxQuestionCount() * interviewMode.getRequiredTokenCount()
- TOKEN_NOT_REQUIRED_FOR_ROOT_QUESTION_VOICE;
tokenFacadeService.validateEnoughTokens(memberAuth.memberId(), requiredTokenCount);
Member member = memberService.readById(memberAuth.memberId());
RootQuestion rootQuestion = rootQuestionService.findNextRootQuestionForMember(member, interviewRequest);
validateModeSupportedForRootQuestion(rootQuestion, interviewMode);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Interview interview = interviewService.saveInterview(
new Interview(member, rootQuestion, interviewRequest.maxQuestionCount(), interviewMode));
Question question = questionService.saveQuestion(new Question(interview, rootQuestion.getContent()));
new Interview(member, rootQuestion, interviewRequest.maxQuestionCount(), interviewMode,
resolveInterviewType(rootQuestion)));
Question question = questionService.saveQuestion(
new Question(interview, rootQuestion.createInitialQuestionContent()));

if (interviewMode == InterviewMode.VOICE) {
return new InterviewStartVoiceModeResponse(interview, question,
Expand All @@ -83,7 +88,8 @@ public InterviewStartResponse startGuestInterview(ClientIp clientIp) {
RootQuestion rootQuestion = rootQuestionService.readRandomActiveRootQuestion();
Interview interview = interviewService.saveInterview(Interview.forGuest(rootQuestion,
GUEST_INTERVIEW_MAX_QUESTION_COUNT, GUEST_INTERVIEW_MODE, clientIp));
Question question = questionService.saveQuestion(new Question(interview, rootQuestion.getContent()));
Question question = questionService.saveQuestion(
new Question(interview, rootQuestion.createInitialQuestionContent()));
return new InterviewStartTextModeResponse(interview, question);
} catch (RuntimeException e) {
redisService.releaseLockSafely(lockKey, lockValue);
Expand All @@ -99,14 +105,17 @@ public static String createGuestInterviewStartedLockKey(ClientIp clientIp) {
public InterviewStartResponse startRootQuestionCustomInterview(RootQuestionCustomInterviewRequest request,
MemberAuth memberAuth) {
InterviewMode interviewMode = request.mode();
RootQuestion rootQuestion = rootQuestionService.readRootQuestion(request.rootQuestionId());
validateModeSupportedForRootQuestion(rootQuestion, interviewMode);
int requiredTokenCount = request.maxQuestionCount() * interviewMode.getRequiredTokenCount()
- TOKEN_NOT_REQUIRED_FOR_ROOT_QUESTION_VOICE;
tokenFacadeService.validateEnoughTokens(memberAuth.memberId(), requiredTokenCount);
Member member = memberService.readById(memberAuth.memberId());
RootQuestion rootQuestion = rootQuestionService.readRootQuestion(request.rootQuestionId());
Interview interview = interviewService.saveInterview(
new Interview(member, rootQuestion, request.maxQuestionCount(), interviewMode));
Question question = questionService.saveQuestion(new Question(interview, rootQuestion.getContent()));
new Interview(member, rootQuestion, request.maxQuestionCount(), interviewMode,
resolveInterviewType(rootQuestion)));
Question question = questionService.saveQuestion(
new Question(interview, rootQuestion.createInitialQuestionContent()));

if (interviewMode == InterviewMode.VOICE) {
return new InterviewStartVoiceModeResponse(interview, question,
Expand All @@ -115,6 +124,22 @@ public InterviewStartResponse startRootQuestionCustomInterview(RootQuestionCusto
return new InterviewStartTextModeResponse(interview, question);
}

private InterviewType resolveInterviewType(RootQuestion rootQuestion) {
return rootQuestion.isCode() ? InterviewType.LIVE_CODING : InterviewType.CATEGORY_BASED;
}

private void validateLiveCodingNotVoice(InterviewRequest interviewRequest, InterviewMode interviewMode) {
if (interviewRequest.includeLiveCoding() && interviewMode == InterviewMode.VOICE) {
throw new BadRequestException("라이브 코테는 음성 모드를 지원하지 않습니다.");
}
}

private void validateModeSupportedForRootQuestion(RootQuestion rootQuestion, InterviewMode interviewMode) {
if (rootQuestion.isCode() && interviewMode == InterviewMode.VOICE) {
throw new BadRequestException("라이브 코테는 음성 모드를 지원하지 않습니다.");
}
}

@Transactional
public InterviewStartResponse startResumeBasedInterview(
Long generationId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ private InterviewResultResponse findGuestInterviewResult(Long interviewId, Clien
}

private List<RootQuestionReferenceAnswer> getReferenceAnswers(Interview interview) {
if (interview.isResumeBased()) {
if (interview.isResumeBased() || interview.isLiveCoding()) {
return List.of();
}
return findRootQuestionReferenceAnswers(interview.getRootQuestion().getId(), interview.getId());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import org.hibernate.validator.constraints.Length;

public record AnswerRequestV2(
@Length(max = 2000, message = "answer는 최대 2000자까지 입력할 수 있습니다.")
@Length(max = 10000, message = "answer는 최대 10000자까지 입력할 수 있습니다.")
@NotBlank(message = "answer는 비어있을 수 없습니다.")
String answer,
@NotNull(message = "mode는 null일 수 없습니다.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ public record InterviewRequest(
@NotNull(message = "max_question_count는 null일 수 없습니다.")
Integer maxQuestionCount,
@NotNull(message = "mode는 null일 수 없습니다.")
InterviewMode mode
InterviewMode mode,
Boolean includeLiveCoding
) {
public InterviewRequest {
if (includeLiveCoding == null) {
includeLiveCoding = false;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
package com.samhap.kokomen.interview.service.dto;

import com.samhap.kokomen.interview.domain.RootQuestion;
import com.samhap.kokomen.interview.domain.RootQuestionType;

public record RootQuestionResponse(
Long id,
RootQuestionType questionType,
String title,
String content
) {

public static RootQuestionResponse from(RootQuestion rootQuestion) {
return new RootQuestionResponse(
rootQuestion.getId(),
rootQuestion.getQuestionType(),
rootQuestion.getTitle(),
rootQuestion.getContent()
);
}
Expand Down
Loading
Loading