Skip to content

Commit ae17114

Browse files
authored
refactor : AI 코드리뷰 프롬프트 정형화 v2 (#73)
1 parent 40ca073 commit ae17114

File tree

8 files changed

+238
-81
lines changed

8 files changed

+238
-81
lines changed

src/main/java/org/ezcode/codetest/application/submission/model/OpenAiResponse.java renamed to src/main/java/org/ezcode/codetest/application/submission/model/OpenAIResponse.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
11
package org.ezcode.codetest.application.submission.model;
22

33
import java.util.List;
4+
import java.util.Optional;
45

56
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
67

78
@JsonIgnoreProperties(ignoreUnknown = true)
8-
public record OpenAiResponse(
9+
public record OpenAIResponse(
910
List<Choice> choices
1011
) {
1112
public record Choice(Message message) {}
1213
public record Message(String role, String content) {}
1314

1415
public String getReviewContent() {
15-
if (choices == null || choices.isEmpty()) return "";
16-
Message message = choices.get(0).message;
17-
if (message == null || message.content == null) return "";
18-
return message.content;
16+
return Optional.ofNullable(choices)
17+
.flatMap(list -> list.stream().findFirst())
18+
.map(choice -> choice.message.content)
19+
.orElse("");
1920
}
2021
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package org.ezcode.codetest.domain.submission.exception;
2+
3+
import org.ezcode.codetest.common.base.exception.BaseException;
4+
import org.ezcode.codetest.common.base.exception.ResponseCode;
5+
import org.ezcode.codetest.domain.submission.exception.code.CodeReviewExceptionCode;
6+
import org.springframework.http.HttpStatus;
7+
8+
import lombok.Getter;
9+
10+
@Getter
11+
public class CodeReviewException extends BaseException {
12+
13+
private final ResponseCode responseCode;
14+
private final HttpStatus httpStatus;
15+
private final String message;
16+
17+
public CodeReviewException(CodeReviewExceptionCode responseCode) {
18+
this.responseCode = responseCode;
19+
this.httpStatus = responseCode.getStatus();
20+
this.message = responseCode.getMessage();
21+
}
22+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package org.ezcode.codetest.domain.submission.exception.code;
2+
3+
import org.ezcode.codetest.common.base.exception.ResponseCode;
4+
import org.springframework.http.HttpStatus;
5+
6+
import lombok.Getter;
7+
import lombok.RequiredArgsConstructor;
8+
9+
@Getter
10+
@RequiredArgsConstructor
11+
public enum CodeReviewExceptionCode implements ResponseCode {
12+
13+
REVIEW_SERVER_ERROR(false, HttpStatus.BAD_GATEWAY, "AI 서버 오류가 발생했습니다."),
14+
REVIEW_TIMEOUT(false, HttpStatus.GATEWAY_TIMEOUT, "AI 응답 시간이 초과되었습니다."),
15+
REVIEW_INVALID_FORMAT(false, HttpStatus.INTERNAL_SERVER_ERROR, "AI 리뷰 형식 검증에 실패했습니다."),
16+
;
17+
18+
private final boolean success;
19+
private final HttpStatus status;
20+
private final String message;
21+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package org.ezcode.codetest.infrastructure.openai;
2+
3+
import org.ezcode.codetest.application.submission.dto.request.review.ReviewPayload;
4+
import org.springframework.stereotype.Component;
5+
6+
@Component
7+
class OpenAIMessageBuilder {
8+
protected String buildPrompt(ReviewPayload request) {
9+
String status = request.isCorrect() ? "정답" : "오답";
10+
return """
11+
문제: %s
12+
13+
언어: %s
14+
정답 여부: %s
15+
16+
```%s
17+
%s
18+
```
19+
20+
""".formatted(
21+
request.problemDescription(),
22+
request.languageName(),
23+
status,
24+
request.languageName().toLowerCase(),
25+
request.sourceCode()
26+
);
27+
}
28+
29+
protected String buildErrorMessage() {
30+
return "현재 리뷰 생성에 일시적인 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.";
31+
}
32+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package org.ezcode.codetest.infrastructure.openai;
2+
3+
import org.springframework.stereotype.Component;
4+
5+
@Component
6+
class OpenAIResponseValidator {
7+
protected boolean isValidFormat(String content, boolean isCorrect) {
8+
if (content == null) return false;
9+
10+
if (isCorrect) {
11+
return content.contains("시간 복잡도:") &&
12+
content.contains("코드 총평:") &&
13+
content.contains("조금 더 개선할 수 있는 방안:");
14+
}
15+
16+
return content.contains("코드 총평:") &&
17+
content.contains("공부하면 좋은 키워드:");
18+
}
19+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package org.ezcode.codetest.infrastructure.openai;
2+
3+
import java.time.Duration;
4+
import java.util.List;
5+
import java.util.Map;
6+
import java.util.Objects;
7+
import java.util.concurrent.TimeoutException;
8+
9+
import org.ezcode.codetest.application.submission.dto.request.review.ReviewPayload;
10+
import org.ezcode.codetest.application.submission.model.OpenAIResponse;
11+
import org.ezcode.codetest.application.submission.model.ReviewResult;
12+
import org.ezcode.codetest.application.submission.port.ReviewClient;
13+
import org.ezcode.codetest.domain.submission.exception.CodeReviewException;
14+
import org.ezcode.codetest.domain.submission.exception.code.CodeReviewExceptionCode;
15+
import org.springframework.beans.factory.annotation.Value;
16+
import org.springframework.http.MediaType;
17+
import org.springframework.stereotype.Component;
18+
import org.springframework.web.reactive.function.client.WebClient;
19+
import org.springframework.web.reactive.function.client.WebClientResponseException;
20+
21+
import jakarta.annotation.PostConstruct;
22+
import lombok.RequiredArgsConstructor;
23+
import lombok.extern.slf4j.Slf4j;
24+
import reactor.util.retry.Retry;
25+
26+
@Slf4j
27+
@Component
28+
@RequiredArgsConstructor
29+
public class OpenAIReviewClient implements ReviewClient {
30+
31+
@Value("${OPEN_API_URL}")
32+
private String openApiUrl;
33+
34+
@Value("${OPEN_API_KEY}")
35+
private String openApiKey;
36+
private WebClient webClient;
37+
private final OpenAIMessageBuilder openAiMessageBuilder;
38+
private final OpenAIResponseValidator openAiResponseValidator;
39+
40+
41+
@PostConstruct
42+
private void init() {
43+
this.webClient = WebClient.create(openApiUrl);
44+
}
45+
46+
@Override
47+
public ReviewResult requestReview(ReviewPayload request) {
48+
String userPrompt = openAiMessageBuilder.buildPrompt(request);
49+
String content;
50+
int maxAttempts = 3;
51+
52+
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
53+
try {
54+
content = callChatApi(userPrompt);
55+
} catch (CodeReviewException e) {
56+
log.error("OpenAI API 호출 실패: {}, {}", e.getHttpStatus(), e.getMessage());
57+
return new ReviewResult(openAiMessageBuilder.buildErrorMessage());
58+
}
59+
60+
if (openAiResponseValidator.isValidFormat(content, request.isCorrect())) {
61+
return new ReviewResult(content);
62+
}
63+
log.warn("[{}/{}] 포맷 검증 실패:\n{}", attempt, maxAttempts, content);
64+
}
65+
66+
return new ReviewResult(openAiMessageBuilder.buildErrorMessage());
67+
}
68+
69+
private String callChatApi (String userPrompt){
70+
Map<String, Object> requestBody = Map.of(
71+
"model", "o4-mini",
72+
"messages", List.of(
73+
Map.of("role", "system", "content", """
74+
당신은 코딩 테스트 사이트의 코드 리뷰어입니다.
75+
아래 **정확히** 이 형식을 지켜 응답하세요:
76+
77+
<정답일 경우>
78+
- 시간 복잡도: Big-O 표기법으로만 답하세요. **단, N과 M을 같다고 가정하고 n으로 표기하세요.**
79+
- 코드에 포함된 중첩 루프(depth)에 따라 O(N^k) 형태로 정확히 표기해주세요.
80+
- **for 루프뿐만 아니라 while 루프도 모두 중첩(depth)에 포함**하여, 코드에 실제로 있는 루프 개수만큼 exponent를 세십시오.
81+
- 예) for-for-for ⇒ O(n³), for-for-while ⇒ O(n³), for-for-for-for-while ⇒ O(n⁵).
82+
83+
- 코드 총평:
84+
- 각 문장은 한 탭(\t) 들여쓰기 + '- ' 로 시작.
85+
- 문장 끝에만 마침표를 붙이세요.
86+
87+
- 조금 더 개선할 수 있는 방안:
88+
- 각 문장은 한 탭(\t) 들여쓰기 + '- ' 로 시작.
89+
- 문장 끝에만 마침표를 붙이세요.
90+
91+
<오답일 경우>
92+
- 코드 총평:
93+
- 각 문장은 한 탭(\t) 들여쓰기 + '- ' 로 시작.
94+
- 문장 끝에만 마침표를 붙이세요.
95+
96+
- 공부하면 좋은 키워드:
97+
1. 첫 번째 키워드
98+
2. 두 번째 키워드
99+
3. 세 번째 키워드
100+
… 필요한 만큼 번호를 늘려주세요.
101+
102+
**주의사항**
103+
1. 절대 코드 전문이나 정답을 알려주지 마세요.
104+
2. 절대 코드의 일부분을 작성하지 마세요.
105+
3. 칭찬할 건 칭찬하되 지적할 건 냉정하게 지적하세요.
106+
4. 늘 존댓말을 사용하세요.
107+
5. 이모지는 절대 사용하지 마세요.
108+
6. 다른 형식으로 답변하면 안 됩니다.
109+
"""),
110+
Map.of("role", "user", "content", userPrompt)
111+
)
112+
);
113+
114+
OpenAIResponse response = webClient.post()
115+
.uri("/v1/chat/completions")
116+
.header("Authorization", "Bearer " + openApiKey)
117+
.contentType(MediaType.APPLICATION_JSON)
118+
.bodyValue(requestBody)
119+
.retrieve()
120+
.bodyToMono(OpenAIResponse.class)
121+
.timeout(Duration.ofSeconds(10))
122+
.retryWhen(
123+
Retry.backoff(3, Duration.ofSeconds(1))
124+
.maxBackoff(Duration.ofSeconds(5))
125+
.filter(ex -> ex instanceof WebClientResponseException
126+
|| ex instanceof TimeoutException)
127+
.onRetryExhaustedThrow((spec, signal) -> signal.failure())
128+
)
129+
.onErrorMap(WebClientResponseException.class,
130+
ex -> new CodeReviewException(CodeReviewExceptionCode.REVIEW_SERVER_ERROR))
131+
.onErrorMap(TimeoutException.class,
132+
ex -> new CodeReviewException(CodeReviewExceptionCode.REVIEW_TIMEOUT))
133+
.block();
134+
135+
return Objects.requireNonNull(response).getReviewContent();
136+
}
137+
}

src/main/java/org/ezcode/codetest/infrastructure/openai/OpenAiClient.java

Lines changed: 0 additions & 75 deletions
This file was deleted.

src/main/resources/templates/submit-test.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ <h3>코드 리뷰 결과</h3>
132132
const reviewBox = document.getElementById('reviewResult');
133133
reviewBox.textContent = '';
134134

135-
const isCorrect = false; // 필요 시 동적으로 설정하세요
135+
const isCorrect = true; // 필요 시 동적으로 설정하세요
136136

137137
fetch(`/api/problems/${problemId}/review`, {
138138
method: 'POST',

0 commit comments

Comments
 (0)