diff --git a/docs/status.md b/docs/status.md index 4a65b09..1474094 100644 --- a/docs/status.md +++ b/docs/status.md @@ -22,8 +22,8 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메 | REQ-002 | 개발용 현재 사용자 Provider | Verified | High | 인증 필요 API 공통 | [#4](https://github.com/Rewrite-Team/Rewrite-BE/issues/4), [PR #6](https://github.com/Rewrite-Team/Rewrite-BE/pull/6) | `DevCurrentUserProviderTest`, `./gradlew test` | `CurrentUserProvider`, `DevCurrentUserProvider` 구현됨 | | REQ-003 | 자기소개서 기본 CRUD | In Progress | High | API-007, API-008, API-012, API-013 | [#5](https://github.com/Rewrite-Team/Rewrite-BE/issues/5), [#18](https://github.com/Rewrite-Team/Rewrite-BE/issues/18), [#28](https://github.com/Rewrite-Team/Rewrite-BE/issues/28), [#30](https://github.com/Rewrite-Team/Rewrite-BE/issues/30), [PR #15](https://github.com/Rewrite-Team/Rewrite-BE/pull/15) | `CoverLetterServiceTest`, `CoverLetterControllerTest`, `CoverLetterRepositoryTest`, `./gradlew test`, `./gradlew check` | API-007 현재 사용자 자기소개서 목록 조회, API-008 자기소개서 초안 생성, API-013 soft delete 계약 구현됨. 진행 중 Job cancel은 `llm_jobs` 구현 이후 연결 | | REQ-004 | 자기소개서 등록 step 저장 | Implemented | High | API-009, API-010, API-011 | [#32](https://github.com/Rewrite-Team/Rewrite-BE/issues/32), [#34](https://github.com/Rewrite-Team/Rewrite-BE/issues/34), [#36](https://github.com/Rewrite-Team/Rewrite-BE/issues/36) | `CoverLetterServiceTest`, `CoverLetterControllerTest`, `CoverLetterQuestionRepositoryTest`, `GlobalExceptionHandlerTest`, `./gradlew test`, `./gradlew check` | API-009 등록 step1 기본 정보 저장, API-010 등록 step2 채용 우대사항 저장, API-011 등록 step3 질문과 답변 저장 계약 구현됨 | -| REQ-005 | 자기소개서 제출과 LLM Job 생성 | In Progress | High | API-014, API-015, API-016 | [#38](https://github.com/Rewrite-Team/Rewrite-BE/issues/38), [#40](https://github.com/Rewrite-Team/Rewrite-BE/issues/40), [#42](https://github.com/Rewrite-Team/Rewrite-BE/issues/42) | `CoverLetterRepositoryTest`, `CoverLetterServiceTest`, `CoverLetterControllerTest`, `LlmJobRepositoryTest`, `LlmJobServiceTest`, `LlmJobControllerTest`, `ReviewVersionServiceTest`, `./gradlew test`, `./gradlew check` | API-014 제출과 PENDING 최초 첨삭 Job 생성, API-015 Job 상태 조회 skeleton, 최초 첨삭 성공 결과 완료 경계 구현됨. API-016 SSE와 실제 LLM provider 호출은 후속 이슈로 분리 | -| REQ-006 | 첨삭 버전 조회와 최종 작성본 저장 | In Progress | High | API-017, API-018, API-019, API-024 | [#42](https://github.com/Rewrite-Team/Rewrite-BE/issues/42) | `ReviewVersionRepositoryTest`, `ReviewVersionServiceTest`, `./gradlew test`, `./gradlew check` | `ReviewVersion`, 문항별 결과 persistence와 최초 첨삭 성공 저장 경계 구현됨. API-017~019, API-024는 후속 범위 | +| REQ-005 | 자기소개서 제출과 LLM Job 생성 | In Progress | High | API-014, API-015, API-016 | [#38](https://github.com/Rewrite-Team/Rewrite-BE/issues/38), [#40](https://github.com/Rewrite-Team/Rewrite-BE/issues/40), [#42](https://github.com/Rewrite-Team/Rewrite-BE/issues/42), [#44](https://github.com/Rewrite-Team/Rewrite-BE/issues/44) | `CoverLetterRepositoryTest`, `CoverLetterServiceTest`, `CoverLetterControllerTest`, `LlmJobRepositoryTest`, `LlmJobServiceTest`, `LlmJobControllerTest`, `ReviewVersionServiceTest`, `OpenAiFirstReviewClientTest`, `./gradlew test`, `./gradlew check` | API-014 제출과 PENDING 최초 첨삭 Job 생성, API-015 Job 상태 조회 skeleton, 최초 첨삭 성공 결과 완료 경계, OpenAI 최초 첨삭 client 구현됨. API-016 SSE와 비동기 worker 연결은 후속 이슈로 분리 | +| REQ-006 | 첨삭 버전 조회와 최종 작성본 저장 | In Progress | High | API-017, API-018, API-019, API-024 | [#42](https://github.com/Rewrite-Team/Rewrite-BE/issues/42), [#44](https://github.com/Rewrite-Team/Rewrite-BE/issues/44) | `ReviewVersionRepositoryTest`, `ReviewVersionServiceTest`, `OpenAiFirstReviewClientTest`, `./gradlew test`, `./gradlew check` | `ReviewVersion`, 문항별 결과 persistence, 최초 첨삭 성공 저장 경계와 OpenAI 문항별 첨삭 결과 client 구현됨. API-017~019, API-024는 후속 범위 | | REQ-007 | DB/JPA 전환 | Implemented | High | API-008 내부 persistence, persistence 내부 변경 | [#22](https://github.com/Rewrite-Team/Rewrite-BE/issues/22), [#24](https://github.com/Rewrite-Team/Rewrite-BE/issues/24), [#25](https://github.com/Rewrite-Team/Rewrite-BE/issues/25), [PR #26](https://github.com/Rewrite-Team/Rewrite-BE/pull/26) | `CoverLetterRepositoryTest`, `CoverLetterServiceTest`, `CoverLetterControllerTest`, `./gradlew test`, `./gradlew check` | API-008 공개 계약은 유지하고 `cover_letters` persistence를 DB/JPA로 전환. H2 file 로컬 DB, H2 in-memory 테스트 DB 사용. Flyway는 별도 이슈로 분리 | | REQ-008 | 실제 인증 경계 | Planned | Medium | API-001 - API-006 | - | - | 카카오 OAuth, cookie, CSRF | | REQ-009 | 키워드 분석 | Planned | Medium | API-020, API-021 | - | - | LLM Job 기반 | @@ -58,7 +58,6 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메 | Candidate | Related REQ | Related APIs | Suggested Scope | |---|---|---|---| -| OpenAI 최초 첨삭 client | REQ-005, REQ-006 | LLM provider 내부 연동 | 자기소개서 입력을 구조화된 prompt로 변환하고 문항별 `aiReport`, `rewrittenAnswer` 응답을 검증하는 client 구현 | | 최초 첨삭 비동기 worker | REQ-005, REQ-006 | API-014 이후 내부 실행 | PENDING Job 실행, provider client 호출, 성공 시 ReviewVersion 완료 경계 연결, 실패 상태와 자동 재시도 처리 | ## Update Rules diff --git a/src/main/java/com/daon/rewrite/reviewversion/client/FirstReviewClient.java b/src/main/java/com/daon/rewrite/reviewversion/client/FirstReviewClient.java new file mode 100644 index 0000000..1b3cf73 --- /dev/null +++ b/src/main/java/com/daon/rewrite/reviewversion/client/FirstReviewClient.java @@ -0,0 +1,8 @@ +package com.daon.rewrite.reviewversion.client; + +import java.util.List; + +public interface FirstReviewClient { + + List review(FirstReviewRequest request); +} diff --git a/src/main/java/com/daon/rewrite/reviewversion/client/FirstReviewClientException.java b/src/main/java/com/daon/rewrite/reviewversion/client/FirstReviewClientException.java new file mode 100644 index 0000000..b5881f9 --- /dev/null +++ b/src/main/java/com/daon/rewrite/reviewversion/client/FirstReviewClientException.java @@ -0,0 +1,44 @@ +package com.daon.rewrite.reviewversion.client; + +public class FirstReviewClientException extends RuntimeException { + + private final Reason reason; + + private FirstReviewClientException(Reason reason, String message, Throwable cause) { + super(message, cause); + this.reason = reason; + } + + static FirstReviewClientException outputValidationFailed() { + return new FirstReviewClientException( + Reason.OUTPUT_VALIDATION_FAILED, + "최초 첨삭 결과 구조가 올바르지 않습니다.", + null + ); + } + + static FirstReviewClientException outputValidationFailed(Throwable cause) { + return new FirstReviewClientException( + Reason.OUTPUT_VALIDATION_FAILED, + "최초 첨삭 결과를 변환할 수 없습니다.", + cause + ); + } + + static FirstReviewClientException providerError(Throwable cause) { + return new FirstReviewClientException( + Reason.PROVIDER_ERROR, + "최초 첨삭 provider 호출에 실패했습니다.", + cause + ); + } + + public Reason getReason() { + return reason; + } + + public enum Reason { + PROVIDER_ERROR, + OUTPUT_VALIDATION_FAILED + } +} diff --git a/src/main/java/com/daon/rewrite/reviewversion/client/FirstReviewQuestion.java b/src/main/java/com/daon/rewrite/reviewversion/client/FirstReviewQuestion.java new file mode 100644 index 0000000..76fee7f --- /dev/null +++ b/src/main/java/com/daon/rewrite/reviewversion/client/FirstReviewQuestion.java @@ -0,0 +1,10 @@ +package com.daon.rewrite.reviewversion.client; + +public record FirstReviewQuestion( + String questionId, + int questionOrder, + String question, + int maxAnswerLength, + String originalAnswer +) { +} diff --git a/src/main/java/com/daon/rewrite/reviewversion/client/FirstReviewRequest.java b/src/main/java/com/daon/rewrite/reviewversion/client/FirstReviewRequest.java new file mode 100644 index 0000000..a0b44ff --- /dev/null +++ b/src/main/java/com/daon/rewrite/reviewversion/client/FirstReviewRequest.java @@ -0,0 +1,17 @@ +package com.daon.rewrite.reviewversion.client; + +import java.util.List; + +public record FirstReviewRequest( + String title, + String companyName, + String positionTitle, + String jobPostingUrl, + String preferences, + List questions +) { + + public FirstReviewRequest { + questions = List.copyOf(questions); + } +} diff --git a/src/main/java/com/daon/rewrite/reviewversion/client/FirstReviewResult.java b/src/main/java/com/daon/rewrite/reviewversion/client/FirstReviewResult.java new file mode 100644 index 0000000..5211c80 --- /dev/null +++ b/src/main/java/com/daon/rewrite/reviewversion/client/FirstReviewResult.java @@ -0,0 +1,8 @@ +package com.daon.rewrite.reviewversion.client; + +public record FirstReviewResult( + String questionId, + String aiReport, + String rewrittenAnswer +) { +} diff --git a/src/main/java/com/daon/rewrite/reviewversion/client/OpenAiFirstReviewClient.java b/src/main/java/com/daon/rewrite/reviewversion/client/OpenAiFirstReviewClient.java new file mode 100644 index 0000000..841db9a --- /dev/null +++ b/src/main/java/com/daon/rewrite/reviewversion/client/OpenAiFirstReviewClient.java @@ -0,0 +1,118 @@ +package com.daon.rewrite.reviewversion.client; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.stereotype.Component; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.json.JsonMapper; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Component +public class OpenAiFirstReviewClient implements FirstReviewClient { + + private static final String SYSTEM_PROMPT = """ + 당신은 한국어 자기소개서를 첨삭하는 전문 리뷰어입니다. + 모든 문항을 함께 읽고 각 문항별 AI 리포트와 수정본을 작성하세요. + + 다음 기준을 반드시 반영하세요. + - STAR 구성이 명확한지 평가합니다. + - 내용의 구체성을 평가합니다. + - 채용 우대사항과 직무에 적합한지 평가합니다. + - 맞춤법과 문장이 자연스러운지 평가합니다. + - 중복 표현을 줄입니다. + - 각 문항의 최대 글자 수를 준수합니다. + - 원문에 없는 경험이나 사실을 임의로 만들지 않습니다. + + 응답의 questionId는 입력값을 그대로 사용하고 모든 문항의 결과를 정확히 한 번씩 반환하세요. + """; + + private static final JsonMapper JSON_MAPPER = JsonMapper.builder().build(); + + private final ChatClient chatClient; + + public OpenAiFirstReviewClient(ChatClient.Builder chatClientBuilder) { + this.chatClient = chatClientBuilder.build(); + } + + @Override + public List review(FirstReviewRequest request) { + OpenAiFirstReviewResponse response; + try { + response = chatClient.prompt() + .system(SYSTEM_PROMPT) + .user(buildUserPrompt(request)) + .call() + .entity(OpenAiFirstReviewResponse.class); + } catch (JacksonException exception) { + throw FirstReviewClientException.outputValidationFailed(exception); + } catch (Exception exception) { + throw FirstReviewClientException.providerError(exception); + } + + return validateAndNormalize(request, response); + } + + private String buildUserPrompt(FirstReviewRequest request) { + return "다음 자기소개서 전체를 첨삭하세요.\n입력 JSON:\n" + + JSON_MAPPER.writeValueAsString(request); + } + + private List validateAndNormalize( + FirstReviewRequest request, + OpenAiFirstReviewResponse response + ) { + if (response == null || response.results() == null + || response.results().size() != request.questions().size()) { + throw FirstReviewClientException.outputValidationFailed(); + } + + Map questionById = new HashMap<>(); + for (FirstReviewQuestion question : request.questions()) { + questionById.put(question.questionId(), question); + } + + Map normalizedResultById = new HashMap<>(); + for (FirstReviewResult result : response.results()) { + if (result == null || result.questionId() == null || result.questionId().isBlank() + || normalizedResultById.containsKey(result.questionId())) { + throw FirstReviewClientException.outputValidationFailed(); + } + + FirstReviewQuestion question = questionById.get(result.questionId()); + String aiReport = normalizeRequired(result.aiReport()); + String rewrittenAnswer = normalizeRequired(result.rewrittenAnswer()); + if (question == null || aiReport == null || rewrittenAnswer == null + || countCodePoints(rewrittenAnswer) > question.maxAnswerLength()) { + throw FirstReviewClientException.outputValidationFailed(); + } + + normalizedResultById.put(result.questionId(), new FirstReviewResult( + result.questionId(), + aiReport, + rewrittenAnswer + )); + } + + if (!normalizedResultById.keySet().equals(questionById.keySet())) { + throw FirstReviewClientException.outputValidationFailed(); + } + + return request.questions().stream() + .map(question -> normalizedResultById.get(question.questionId())) + .toList(); + } + + private String normalizeRequired(String value) { + if (value == null) { + return null; + } + String normalized = value.strip(); + return normalized.isEmpty() ? null : normalized; + } + + private int countCodePoints(String value) { + return value.codePointCount(0, value.length()); + } +} diff --git a/src/main/java/com/daon/rewrite/reviewversion/client/OpenAiFirstReviewResponse.java b/src/main/java/com/daon/rewrite/reviewversion/client/OpenAiFirstReviewResponse.java new file mode 100644 index 0000000..9ceea6a --- /dev/null +++ b/src/main/java/com/daon/rewrite/reviewversion/client/OpenAiFirstReviewResponse.java @@ -0,0 +1,6 @@ +package com.daon.rewrite.reviewversion.client; + +import java.util.List; + +record OpenAiFirstReviewResponse(List results) { +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 075082b..edba596 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -22,6 +22,8 @@ spring: path: /h2-console ai: + retry: + max-attempts: 1 openai: api-key: ${OPENAI_API_KEY} chat: diff --git a/src/test/java/com/daon/rewrite/reviewversion/client/OpenAiFirstReviewClientTest.java b/src/test/java/com/daon/rewrite/reviewversion/client/OpenAiFirstReviewClientTest.java new file mode 100644 index 0000000..53a83d3 --- /dev/null +++ b/src/test/java/com/daon/rewrite/reviewversion/client/OpenAiFirstReviewClientTest.java @@ -0,0 +1,205 @@ +package com.daon.rewrite.reviewversion.client; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OpenAiFirstReviewClientTest { + + @Test + void reviewsWholeCoverLetterAndReturnsResultsInQuestionOrder() { + CapturingChatModel chatModel = new CapturingChatModel(""" + { + "results": [ + { + "questionId": "clq_2", + "aiReport": " 두 번째 리포트 ", + "rewrittenAnswer": " 두 번째 수정본 " + }, + { + "questionId": "clq_1", + "aiReport": " 첫 번째 리포트 ", + "rewrittenAnswer": " 첫 번째 수정본 " + } + ] + } + """); + FirstReviewClient client = new OpenAiFirstReviewClient(ChatClient.builder(chatModel)); + FirstReviewRequest request = request(); + + List results = client.review(request); + + assertThat(results) + .extracting(FirstReviewResult::questionId) + .containsExactly("clq_1", "clq_2"); + assertThat(results) + .extracting(FirstReviewResult::aiReport) + .containsExactly("첫 번째 리포트", "두 번째 리포트"); + assertThat(results) + .extracting(FirstReviewResult::rewrittenAnswer) + .containsExactly("첫 번째 수정본", "두 번째 수정본"); + + Prompt prompt = chatModel.capturedPrompt(); + assertThat(prompt.getSystemMessage().getText()) + .contains("STAR", "구체성", "우대사항", "직무", "맞춤법", "중복 표현", "최대 글자 수") + .contains("원문에 없는 경험이나 사실을 임의로 만들지"); + assertThat(prompt.getUserMessage().getText()) + .contains( + "백엔드 자기소개서", + "다온", + "백엔드 개발자", + "https://example.com/jobs/1", + "Spring 경험 우대", + "clq_1", + "지원 동기는?", + "1000", + "첫 번째 원본 답변", + "clq_2", + "직무 역량은?", + "500", + "두 번째 원본 답변" + ); + } + + @Test + void rejectsMissingDuplicateOrUnknownQuestionResults() { + List invalidResponses = List.of( + """ + {"results":[ + {"questionId":"clq_1","aiReport":"리포트","rewrittenAnswer":"수정본"} + ]} + """, + """ + {"results":[ + {"questionId":"clq_1","aiReport":"리포트1","rewrittenAnswer":"수정본1"}, + {"questionId":"clq_1","aiReport":"리포트2","rewrittenAnswer":"수정본2"} + ]} + """, + """ + {"results":[ + {"questionId":"clq_1","aiReport":"리포트","rewrittenAnswer":"수정본"}, + {"questionId":"clq_unknown","aiReport":"리포트","rewrittenAnswer":"수정본"} + ]} + """ + ); + + for (String response : invalidResponses) { + FirstReviewClient client = clientReturning(response); + + assertOutputValidationFailure(() -> client.review(request())); + } + } + + @Test + void rejectsBlankOrTooLongGeneratedText() { + List invalidResponses = List.of( + """ + {"results":[ + {"questionId":"clq_1","aiReport":" ","rewrittenAnswer":"수정본"}, + {"questionId":"clq_2","aiReport":"리포트","rewrittenAnswer":"수정본"} + ]} + """, + """ + {"results":[ + {"questionId":"clq_1","aiReport":"리포트","rewrittenAnswer":"수정본"}, + {"questionId":"clq_2","aiReport":"리포트","rewrittenAnswer":" "} + ]} + """, + """ + {"results":[ + {"questionId":"clq_1","aiReport":"리포트","rewrittenAnswer":"수정본"}, + {"questionId":"clq_2","aiReport":"리포트","rewrittenAnswer":"%s"} + ]} + """.formatted("가".repeat(501)) + ); + + for (String response : invalidResponses) { + FirstReviewClient client = clientReturning(response); + + assertOutputValidationFailure(() -> client.review(request())); + } + } + + @Test + void classifiesMalformedJsonAsOutputValidationFailure() { + FirstReviewClient client = clientReturning("not-json"); + + assertOutputValidationFailure(() -> client.review(request())); + } + + @Test + void wrapsProviderFailureAndPreservesCause() { + IllegalStateException cause = new IllegalStateException("provider unavailable"); + ChatModel failingModel = prompt -> { + throw cause; + }; + FirstReviewClient client = new OpenAiFirstReviewClient(ChatClient.builder(failingModel)); + + assertThatThrownBy(() -> client.review(request())) + .isInstanceOfSatisfying(FirstReviewClientException.class, exception -> { + assertThat(exception.getReason()) + .isEqualTo(FirstReviewClientException.Reason.PROVIDER_ERROR); + assertThat(exception.getCause()).isSameAs(cause); + }); + } + + private FirstReviewClient clientReturning(String response) { + return new OpenAiFirstReviewClient(ChatClient.builder(new CapturingChatModel(response))); + } + + private void assertOutputValidationFailure(ThrowingCall call) { + assertThatThrownBy(call::invoke) + .isInstanceOfSatisfying(FirstReviewClientException.class, exception -> + assertThat(exception.getReason()) + .isEqualTo(FirstReviewClientException.Reason.OUTPUT_VALIDATION_FAILED)); + } + + private FirstReviewRequest request() { + return new FirstReviewRequest( + "백엔드 자기소개서", + "다온", + "백엔드 개발자", + "https://example.com/jobs/1", + "Spring 경험 우대", + List.of( + new FirstReviewQuestion("clq_1", 1, "지원 동기는?", 1000, "첫 번째 원본 답변"), + new FirstReviewQuestion("clq_2", 2, "직무 역량은?", 500, "두 번째 원본 답변") + ) + ); + } + + @FunctionalInterface + private interface ThrowingCall { + + void invoke(); + } + + private static final class CapturingChatModel implements ChatModel { + + private final String response; + private Prompt capturedPrompt; + + private CapturingChatModel(String response) { + this.response = response; + } + + @Override + public ChatResponse call(Prompt prompt) { + this.capturedPrompt = prompt; + return new ChatResponse(List.of(new Generation(new AssistantMessage(response)))); + } + + private Prompt capturedPrompt() { + return capturedPrompt; + } + } +} diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 4f5be60..952d1b5 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -19,6 +19,8 @@ spring: enabled: false ai: + retry: + max-attempts: 1 openai: api-key: test-key chat: