diff --git a/docs/api/keyword-analysis.md b/docs/api/keyword-analysis.md index 4454198..2e63e15 100644 --- a/docs/api/keyword-analysis.md +++ b/docs/api/keyword-analysis.md @@ -24,7 +24,7 @@ Request: 재분석 요청에서는 `sourceReviewVersionId`를 보내지 않는다. 서버는 항상 `CoverLetter.latestReviewVersionId`를 기준으로 다시 분석한다. -현재 구현 범위에서는 `KeywordAnalysis` persistence와 `KEYWORD_ANALYSIS` LLM Job 생성 계약을 제공한다. 실제 LLM 키워드 추출 worker와 완료 결과 저장은 후속 이슈에서 구현한다. +`KEYWORD_ANALYSIS` LLM Job은 커밋 이후 비동기 worker에서 실행된다. worker는 분석 기준 첨삭 버전의 문항별 최종 작성본을 입력으로 사용하고, 완료 시 최신 키워드 결과를 저장한다. Response: diff --git a/docs/status.md b/docs/status.md index d85be26..5a39a58 100644 --- a/docs/status.md +++ b/docs/status.md @@ -26,7 +26,7 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메 | 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), [#48](https://github.com/Rewrite-Team/Rewrite-BE/issues/48), [#50](https://github.com/Rewrite-Team/Rewrite-BE/issues/50), [#52](https://github.com/Rewrite-Team/Rewrite-BE/issues/52), [#54](https://github.com/Rewrite-Team/Rewrite-BE/issues/54) | `ReviewVersionRepositoryTest`, `ReviewVersionServiceTest`, `ReviewVersionQueryServiceTest`, `ReviewVersionCommandServiceTest`, `ReviewVersionControllerTest`, `OpenAiFirstReviewClientTest`, `FirstReviewJobWorkerTest`, `ReReviewJobWorkerTest`, `FirstReviewJobEventIntegrationTest`, `./gradlew test`, `./gradlew check` | `ReviewVersion`, 문항별 결과 persistence, 최초 첨삭 성공 저장 경계와 OpenAI 문항별 첨삭 결과 client 및 worker 연결, API-017/018 첨삭 버전 목록/상세 조회, API-019 최종 작성본 일괄 저장, API-024 AI 첨삭 다시받기 구현됨 | | 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 | 키워드 분석 | In Progress | Medium | API-020, API-021 | [#56](https://github.com/Rewrite-Team/Rewrite-BE/issues/56), [#58](https://github.com/Rewrite-Team/Rewrite-BE/issues/58) | `KeywordAnalysisRepositoryTest`, `KeywordAnalysisKeywordRepositoryTest`, `KeywordAnalysisServiceTest`, `KeywordAnalysisControllerTest`, `LlmJobRepositoryTest`, `./gradlew test`, `./gradlew check` | API-020 키워드 분석 시작/재분석 Job 생성과 `KeywordAnalysis` persistence 구현됨. API-021 최신 결과 조회와 `KeywordAnalysisKeyword` 결과 저장 구조 구현됨. 실제 LLM 키워드 분석 worker는 후속 이슈로 분리 | +| REQ-009 | 키워드 분석 | Implemented | Medium | API-020, API-021 | [#56](https://github.com/Rewrite-Team/Rewrite-BE/issues/56), [#58](https://github.com/Rewrite-Team/Rewrite-BE/issues/58), [#60](https://github.com/Rewrite-Team/Rewrite-BE/issues/60) | `KeywordAnalysisRepositoryTest`, `KeywordAnalysisKeywordRepositoryTest`, `KeywordAnalysisServiceTest`, `KeywordAnalysisControllerTest`, `OpenAiKeywordAnalysisClientTest`, `KeywordAnalysisJobWorkerTest`, `KeywordAnalysisJobEventIntegrationTest`, `LlmJobRepositoryTest`, `./gradlew test`, `./gradlew check` | API-020 키워드 분석 시작/재분석 Job 생성, API-021 최신 결과 조회, OpenAI 키워드 분석 client와 after-commit worker, `KeywordAnalysisKeyword` 완료 결과 저장 구현됨 | | REQ-010 | AI 면접 | Planned | Medium | API-022, API-023, API-025 - API-029 | - | - | LLM Job/SSE 기반 | ## Open Maintenance Work @@ -59,7 +59,6 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메 | Candidate | Related REQ | Related APIs | Suggested Scope | |---|---|---|---| -| 키워드 분석 worker 구현 | REQ-009 | API-020 이후 내부 실행 | OpenAI 키워드 분석 client/worker를 연결하고 완료 결과를 `KeywordAnalysisKeyword`로 저장 | | LLM Job 자동 재시도 | REQ-005, REQ-006 | API-014 이후 내부 실행 | provider/output 실패 시 Decision 017 기준 1회 자동 재시도 처리 | ## Update Rules diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/client/KeywordAnalysisAnswer.java b/src/main/java/com/daon/rewrite/keywordanalysis/client/KeywordAnalysisAnswer.java new file mode 100644 index 0000000..763a9d4 --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/client/KeywordAnalysisAnswer.java @@ -0,0 +1,9 @@ +package com.daon.rewrite.keywordanalysis.client; + +public record KeywordAnalysisAnswer( + String questionId, + int questionOrder, + String question, + String finalAnswer +) { +} diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/client/KeywordAnalysisClient.java b/src/main/java/com/daon/rewrite/keywordanalysis/client/KeywordAnalysisClient.java new file mode 100644 index 0000000..e4c50fa --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/client/KeywordAnalysisClient.java @@ -0,0 +1,8 @@ +package com.daon.rewrite.keywordanalysis.client; + +import java.util.List; + +public interface KeywordAnalysisClient { + + List analyze(KeywordAnalysisRequest request); +} diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/client/KeywordAnalysisClientException.java b/src/main/java/com/daon/rewrite/keywordanalysis/client/KeywordAnalysisClientException.java new file mode 100644 index 0000000..99be515 --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/client/KeywordAnalysisClientException.java @@ -0,0 +1,44 @@ +package com.daon.rewrite.keywordanalysis.client; + +public class KeywordAnalysisClientException extends RuntimeException { + + private final Reason reason; + + private KeywordAnalysisClientException(Reason reason, String message, Throwable cause) { + super(message, cause); + this.reason = reason; + } + + public static KeywordAnalysisClientException outputValidationFailed() { + return new KeywordAnalysisClientException( + Reason.OUTPUT_VALIDATION_FAILED, + "키워드 분석 결과 구조가 올바르지 않습니다.", + null + ); + } + + public static KeywordAnalysisClientException outputValidationFailed(Throwable cause) { + return new KeywordAnalysisClientException( + Reason.OUTPUT_VALIDATION_FAILED, + "키워드 분석 결과를 변환할 수 없습니다.", + cause + ); + } + + public static KeywordAnalysisClientException providerError(Throwable cause) { + return new KeywordAnalysisClientException( + 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/keywordanalysis/client/KeywordAnalysisRequest.java b/src/main/java/com/daon/rewrite/keywordanalysis/client/KeywordAnalysisRequest.java new file mode 100644 index 0000000..3408cc6 --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/client/KeywordAnalysisRequest.java @@ -0,0 +1,16 @@ +package com.daon.rewrite.keywordanalysis.client; + +import java.util.List; + +public record KeywordAnalysisRequest( + String title, + String companyName, + String positionTitle, + String preferences, + List answers +) { + + public KeywordAnalysisRequest { + answers = List.copyOf(answers); + } +} diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/client/KeywordAnalysisResult.java b/src/main/java/com/daon/rewrite/keywordanalysis/client/KeywordAnalysisResult.java new file mode 100644 index 0000000..b0240f4 --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/client/KeywordAnalysisResult.java @@ -0,0 +1,7 @@ +package com.daon.rewrite.keywordanalysis.client; + +public record KeywordAnalysisResult( + String keyword, + int importance +) { +} diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/client/OpenAiKeywordAnalysisClient.java b/src/main/java/com/daon/rewrite/keywordanalysis/client/OpenAiKeywordAnalysisClient.java new file mode 100644 index 0000000..cde9311 --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/client/OpenAiKeywordAnalysisClient.java @@ -0,0 +1,100 @@ +package com.daon.rewrite.keywordanalysis.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.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Component +public class OpenAiKeywordAnalysisClient implements KeywordAnalysisClient { + + private static final int MAX_KEYWORD_COUNT = 20; + private static final int MIN_IMPORTANCE = 1; + private static final int MAX_IMPORTANCE = 100; + private static final String SYSTEM_PROMPT = """ + 당신은 한국어 자기소개서의 핵심 키워드를 분석하는 전문가입니다. + 자기소개서의 문항과 최종 작성본을 함께 읽고 워드클라우드에 사용할 핵심 키워드를 추출하세요. + + 다음 기준을 반드시 지키세요. + - 핵심 키워드는 중요도 기준 상위 20개를 반환합니다. + - importance는 1~100 범위의 정수입니다. + - keyword는 짧은 명사 또는 명사구로 작성합니다. + - 같은 의미의 키워드를 중복 반환하지 않습니다. + - 응답은 JSON 객체 하나만 반환합니다. + """; + + private static final JsonMapper JSON_MAPPER = JsonMapper.builder().build(); + + private final ChatClient chatClient; + + public OpenAiKeywordAnalysisClient(ChatClient.Builder chatClientBuilder) { + this.chatClient = chatClientBuilder.build(); + } + + @Override + public List analyze(KeywordAnalysisRequest request) { + OpenAiKeywordAnalysisResponse response; + try { + response = chatClient.prompt() + .system(SYSTEM_PROMPT) + .user(buildUserPrompt(request)) + .call() + .entity(OpenAiKeywordAnalysisResponse.class); + } catch (JacksonException exception) { + throw KeywordAnalysisClientException.outputValidationFailed(exception); + } catch (Exception exception) { + throw KeywordAnalysisClientException.providerError(exception); + } + + return validateAndNormalize(response); + } + + private String buildUserPrompt(KeywordAnalysisRequest request) { + return "다음 자기소개서 최종 작성본의 핵심 키워드를 분석하세요.\n입력 JSON:\n" + + JSON_MAPPER.writeValueAsString(request); + } + + private List validateAndNormalize(OpenAiKeywordAnalysisResponse response) { + if (response == null || response.keywords() == null + || response.keywords().isEmpty()) { + throw KeywordAnalysisClientException.outputValidationFailed(); + } + + Set seenKeywords = new HashSet<>(); + List normalizedResults = response.keywords().stream() + .map(result -> normalize(result, seenKeywords)) + .toList(); + + return normalizedResults.stream() + .sorted(Comparator.comparingInt(KeywordAnalysisResult::importance).reversed()) + .limit(MAX_KEYWORD_COUNT) + .toList(); + } + + private KeywordAnalysisResult normalize(KeywordAnalysisResult result, Set seenKeywords) { + if (result == null) { + throw KeywordAnalysisClientException.outputValidationFailed(); + } + String keyword = normalizeRequired(result.keyword()); + if (keyword == null + || result.importance() < MIN_IMPORTANCE + || result.importance() > MAX_IMPORTANCE + || !seenKeywords.add(keyword)) { + throw KeywordAnalysisClientException.outputValidationFailed(); + } + return new KeywordAnalysisResult(keyword, result.importance()); + } + + private String normalizeRequired(String value) { + if (value == null) { + return null; + } + String normalized = value.strip(); + return normalized.isEmpty() ? null : normalized; + } +} diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/client/OpenAiKeywordAnalysisResponse.java b/src/main/java/com/daon/rewrite/keywordanalysis/client/OpenAiKeywordAnalysisResponse.java new file mode 100644 index 0000000..4ee939d --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/client/OpenAiKeywordAnalysisResponse.java @@ -0,0 +1,6 @@ +package com.daon.rewrite.keywordanalysis.client; + +import java.util.List; + +record OpenAiKeywordAnalysisResponse(List keywords) { +} diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisKeywordRepository.java b/src/main/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisKeywordRepository.java index 3eecadd..ec136e7 100644 --- a/src/main/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisKeywordRepository.java +++ b/src/main/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisKeywordRepository.java @@ -8,4 +8,6 @@ public interface KeywordAnalysisKeywordRepository extends JpaRepository { List findByKeywordAnalysisIdOrderByKeywordOrderAsc(String keywordAnalysisId); + + void deleteByKeywordAnalysisId(String keywordAnalysisId); } diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisJobEventListener.java b/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisJobEventListener.java new file mode 100644 index 0000000..f00708b --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisJobEventListener.java @@ -0,0 +1,31 @@ +package com.daon.rewrite.keywordanalysis.service; + +import com.daon.rewrite.llmjob.entity.LlmJob; +import com.daon.rewrite.llmjob.entity.LlmJobType; +import com.daon.rewrite.llmjob.repository.LlmJobRepository; +import com.daon.rewrite.llmjob.service.LlmJobCreatedEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +class KeywordAnalysisJobEventListener { + + private final LlmJobRepository llmJobRepository; + private final KeywordAnalysisJobWorker worker; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(LlmJobCreatedEvent event) { + llmJobRepository.findById(event.jobId()).ifPresent(this::dispatch); + } + + private void dispatch(LlmJob job) { + if (job.getType() == LlmJobType.KEYWORD_ANALYSIS) { + worker.execute(job.getId()); + } + } +} diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisJobTransactionService.java b/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisJobTransactionService.java new file mode 100644 index 0000000..cdf7a0c --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisJobTransactionService.java @@ -0,0 +1,216 @@ +package com.daon.rewrite.keywordanalysis.service; + +import com.daon.rewrite.coverletter.entity.CoverLetter; +import com.daon.rewrite.coverletter.entity.CoverLetterStatus; +import com.daon.rewrite.coverletter.repository.CoverLetterRepository; +import com.daon.rewrite.global.exception.BusinessException; +import com.daon.rewrite.global.exception.ErrorCode; +import com.daon.rewrite.global.util.IdGenerator; +import com.daon.rewrite.keywordanalysis.client.KeywordAnalysisAnswer; +import com.daon.rewrite.keywordanalysis.client.KeywordAnalysisClientException; +import com.daon.rewrite.keywordanalysis.client.KeywordAnalysisRequest; +import com.daon.rewrite.keywordanalysis.client.KeywordAnalysisResult; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysis; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisKeyword; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisStatus; +import com.daon.rewrite.keywordanalysis.repository.KeywordAnalysisKeywordRepository; +import com.daon.rewrite.keywordanalysis.repository.KeywordAnalysisRepository; +import com.daon.rewrite.llmjob.entity.LlmJob; +import com.daon.rewrite.llmjob.entity.LlmJobResultRefType; +import com.daon.rewrite.llmjob.entity.LlmJobStatus; +import com.daon.rewrite.llmjob.entity.LlmJobTargetType; +import com.daon.rewrite.llmjob.entity.LlmJobType; +import com.daon.rewrite.llmjob.repository.LlmJobRepository; +import com.daon.rewrite.reviewversion.entity.ReviewVersion; +import com.daon.rewrite.reviewversion.entity.ReviewVersionQuestionResult; +import com.daon.rewrite.reviewversion.repository.ReviewVersionQuestionResultRepository; +import com.daon.rewrite.reviewversion.repository.ReviewVersionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +class KeywordAnalysisJobTransactionService { + + private static final String KEYWORD_ID_PREFIX = "kak"; + private static final String STARTED_MESSAGE = "키워드 분석을 시작합니다."; + private static final String COMPLETED_MESSAGE = "키워드 분석이 완료되었습니다."; + private static final String FAILED_MESSAGE = "키워드 분석에 실패했습니다."; + private static final String PROVIDER_ERROR_CODE = "LLM_PROVIDER_ERROR"; + private static final String PROVIDER_ERROR_MESSAGE = "LLM 응답 생성에 실패했습니다."; + private static final String OUTPUT_VALIDATION_ERROR_CODE = "LLM_OUTPUT_VALIDATION_FAILED"; + private static final String OUTPUT_VALIDATION_ERROR_MESSAGE = "LLM 출력 형식이 올바르지 않습니다."; + private static final String UNEXPECTED_ERROR_CODE = "INTERNAL_ERROR"; + private static final String UNEXPECTED_ERROR_MESSAGE = "키워드 분석 처리 중 오류가 발생했습니다."; + + private final LlmJobRepository llmJobRepository; + private final CoverLetterRepository coverLetterRepository; + private final ReviewVersionRepository reviewVersionRepository; + private final ReviewVersionQuestionResultRepository questionResultRepository; + private final KeywordAnalysisRepository keywordAnalysisRepository; + private final KeywordAnalysisKeywordRepository keywordRepository; + private final IdGenerator idGenerator; + private final Clock clock; + + @Transactional + public KeywordAnalysisWork start(String jobId) { + LlmJob job = findKeywordAnalysisJobForUpdate(jobId); + if (job.getStatus() != LlmJobStatus.PENDING) { + return null; + } + + CoverLetter coverLetter = coverLetterRepository.findActiveByIdForUpdate(job.getTargetId()) + .orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)); + if (coverLetter.getStatus() != CoverLetterStatus.REVIEWED) { + throw new BusinessException(ErrorCode.INTERNAL_ERROR); + } + + KeywordAnalysis keywordAnalysis = keywordAnalysisRepository.findByCoverLetterId(coverLetter.getId()) + .orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)); + if (keywordAnalysis.getStatus() != KeywordAnalysisStatus.PROCESSING) { + throw new BusinessException(ErrorCode.INTERNAL_ERROR); + } + + ReviewVersion sourceReviewVersion = reviewVersionRepository.findByIdAndCoverLetterId( + keywordAnalysis.getSourceReviewVersionId(), + coverLetter.getId() + ) + .orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)); + List questionResults = questionResultRepository + .findByReviewVersionIdOrderByQuestionOrderAsc(sourceReviewVersion.getId()); + if (questionResults.isEmpty()) { + throw new BusinessException(ErrorCode.INTERNAL_ERROR); + } + + job.startProcessing(STARTED_MESSAGE); + return new KeywordAnalysisWork(new KeywordAnalysisRequest( + coverLetter.getTitle(), + coverLetter.getCompanyName(), + coverLetter.getPositionTitle(), + coverLetter.getPreferences(), + questionResults.stream() + .map(result -> new KeywordAnalysisAnswer( + result.getQuestion().getId(), + result.getQuestionOrder(), + result.getQuestionText(), + result.getFinalAnswer() + )) + .toList() + )); + } + + @Transactional + public void complete(String jobId, List results) { + LlmJob job = findKeywordAnalysisJobForUpdate(jobId); + if (job.getStatus() == LlmJobStatus.COMPLETED || job.getStatus() == LlmJobStatus.FAILED) { + return; + } + if (job.getStatus() != LlmJobStatus.PROCESSING) { + throw new BusinessException(ErrorCode.INTERNAL_ERROR); + } + + KeywordAnalysis keywordAnalysis = keywordAnalysisRepository.findByCoverLetterId(job.getTargetId()) + .orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)); + if (keywordAnalysis.getStatus() != KeywordAnalysisStatus.PROCESSING) { + throw new BusinessException(ErrorCode.INTERNAL_ERROR); + } + + keywordRepository.deleteByKeywordAnalysisId(keywordAnalysis.getId()); + keywordRepository.flush(); + List keywords = new ArrayList<>(); + for (int index = 0; index < results.size(); index++) { + KeywordAnalysisResult result = results.get(index); + keywords.add(KeywordAnalysisKeyword.of( + idGenerator.generate(KEYWORD_ID_PREFIX), + keywordAnalysis, + index + 1, + result.keyword(), + result.importance() + )); + } + keywordRepository.saveAll(keywords); + + Instant now = Instant.now(clock); + keywordAnalysis.complete(now); + job.markCompleted( + job.getProgressTotal(), + COMPLETED_MESSAGE, + LlmJobResultRefType.KEYWORD_ANALYSIS, + keywordAnalysis.getId(), + now + ); + } + + @Transactional + public void fail(String jobId, KeywordAnalysisClientException.Reason reason) { + LlmJob job = findKeywordAnalysisJobForUpdate(jobId); + if (job.getStatus() == LlmJobStatus.COMPLETED || job.getStatus() == LlmJobStatus.FAILED) { + return; + } + + KeywordAnalysis keywordAnalysis = keywordAnalysisRepository.findByCoverLetterId(job.getTargetId()) + .orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)); + if (keywordAnalysis.getStatus() != KeywordAnalysisStatus.PROCESSING) { + throw new BusinessException(ErrorCode.INTERNAL_ERROR); + } + Instant now = Instant.now(clock); + job.markFailed( + job.getProgressCurrent(), + FAILED_MESSAGE, + errorCode(reason), + errorMessage(reason), + now + ); + keywordAnalysis.fail(now); + } + + @Transactional + public void failUnexpected(String jobId) { + LlmJob job = findKeywordAnalysisJobForUpdate(jobId); + if (job.getStatus() == LlmJobStatus.COMPLETED || job.getStatus() == LlmJobStatus.FAILED) { + return; + } + + Instant now = Instant.now(clock); + job.markFailed( + job.getProgressCurrent(), + FAILED_MESSAGE, + UNEXPECTED_ERROR_CODE, + UNEXPECTED_ERROR_MESSAGE, + now + ); + keywordAnalysisRepository.findByCoverLetterId(job.getTargetId()) + .filter(keywordAnalysis -> keywordAnalysis.getStatus() == KeywordAnalysisStatus.PROCESSING) + .ifPresent(keywordAnalysis -> keywordAnalysis.fail(now)); + } + + private LlmJob findKeywordAnalysisJobForUpdate(String jobId) { + LlmJob job = llmJobRepository.findByIdForUpdate(jobId) + .orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)); + if (job.getType() != LlmJobType.KEYWORD_ANALYSIS + || job.getTargetType() != LlmJobTargetType.COVER_LETTER) { + throw new BusinessException(ErrorCode.INTERNAL_ERROR); + } + return job; + } + + private String errorCode(KeywordAnalysisClientException.Reason reason) { + if (reason == KeywordAnalysisClientException.Reason.OUTPUT_VALIDATION_FAILED) { + return OUTPUT_VALIDATION_ERROR_CODE; + } + return PROVIDER_ERROR_CODE; + } + + private String errorMessage(KeywordAnalysisClientException.Reason reason) { + if (reason == KeywordAnalysisClientException.Reason.OUTPUT_VALIDATION_FAILED) { + return OUTPUT_VALIDATION_ERROR_MESSAGE; + } + return PROVIDER_ERROR_MESSAGE; + } +} diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisJobWorker.java b/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisJobWorker.java new file mode 100644 index 0000000..c7350d8 --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisJobWorker.java @@ -0,0 +1,53 @@ +package com.daon.rewrite.keywordanalysis.service; + +import com.daon.rewrite.keywordanalysis.client.KeywordAnalysisClient; +import com.daon.rewrite.keywordanalysis.client.KeywordAnalysisClientException; +import com.daon.rewrite.keywordanalysis.client.KeywordAnalysisResult; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class KeywordAnalysisJobWorker { + + private static final Logger log = LoggerFactory.getLogger(KeywordAnalysisJobWorker.class); + + private final KeywordAnalysisJobTransactionService transactionService; + private final KeywordAnalysisClient keywordAnalysisClient; + + public void execute(String jobId) { + try { + KeywordAnalysisWork work = transactionService.start(jobId); + if (work == null) { + return; + } + List results = keywordAnalysisClient.analyze(work.request()); + transactionService.complete(jobId, results); + } catch (KeywordAnalysisClientException exception) { + failByClientException(jobId, exception); + } catch (RuntimeException exception) { + failByUnexpectedException(jobId, exception); + } + } + + private void failByClientException(String jobId, KeywordAnalysisClientException exception) { + try { + transactionService.fail(jobId, exception.getReason()); + } catch (RuntimeException failureException) { + log.error("Failed to mark keyword analysis job as failed. jobId={}", jobId, failureException); + } + } + + private void failByUnexpectedException(String jobId, RuntimeException exception) { + try { + transactionService.failUnexpected(jobId); + } catch (RuntimeException failureException) { + log.error("Failed to mark unexpected keyword analysis job failure. jobId={}", jobId, failureException); + } + log.error("Unexpected keyword analysis job failure. jobId={}", jobId, exception); + } +} diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisService.java b/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisService.java index e6fe5cd..034e26c 100644 --- a/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisService.java +++ b/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisService.java @@ -10,6 +10,7 @@ import com.daon.rewrite.global.util.IdGenerator; import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysis; import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisKeyword; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisStatus; import com.daon.rewrite.keywordanalysis.repository.KeywordAnalysisKeywordRepository; import com.daon.rewrite.keywordanalysis.repository.KeywordAnalysisRepository; import com.daon.rewrite.llmjob.entity.LlmJob; @@ -99,13 +100,20 @@ public LatestKeywordAnalysisResult findMyLatestKeywordAnalysis(String coverLette return keywordAnalysisRepository.findByCoverLetterId(coverLetter.getId()) .map(keywordAnalysis -> { - List keywords = keywordAnalysisKeywordRepository - .findByKeywordAnalysisIdOrderByKeywordOrderAsc(keywordAnalysis.getId()); + List keywords = findCompletedKeywords(keywordAnalysis); return LatestKeywordAnalysisResult.of(coverLetter.getId(), keywordAnalysis, keywords); }) .orElseGet(() -> LatestKeywordAnalysisResult.empty(coverLetter.getId())); } + private List findCompletedKeywords(KeywordAnalysis keywordAnalysis) { + if (keywordAnalysis.getStatus() != KeywordAnalysisStatus.COMPLETED) { + return List.of(); + } + return keywordAnalysisKeywordRepository + .findByKeywordAnalysisIdOrderByKeywordOrderAsc(keywordAnalysis.getId()); + } + private String selectSourceReviewVersionId(CoverLetter coverLetter, String sourceReviewVersionId) { String normalizedSourceReviewVersionId = normalize(sourceReviewVersionId); String selectedSourceReviewVersionId = normalizedSourceReviewVersionId == null diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisWork.java b/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisWork.java new file mode 100644 index 0000000..38febf7 --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisWork.java @@ -0,0 +1,6 @@ +package com.daon.rewrite.keywordanalysis.service; + +import com.daon.rewrite.keywordanalysis.client.KeywordAnalysisRequest; + +record KeywordAnalysisWork(KeywordAnalysisRequest request) { +} diff --git a/src/test/java/com/daon/rewrite/keywordanalysis/client/OpenAiKeywordAnalysisClientTest.java b/src/test/java/com/daon/rewrite/keywordanalysis/client/OpenAiKeywordAnalysisClientTest.java new file mode 100644 index 0000000..ccfd70a --- /dev/null +++ b/src/test/java/com/daon/rewrite/keywordanalysis/client/OpenAiKeywordAnalysisClientTest.java @@ -0,0 +1,196 @@ +package com.daon.rewrite.keywordanalysis.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 OpenAiKeywordAnalysisClientTest { + + @Test + void analyzesFinalAnswersAndReturnsKeywordsByImportanceDescending() { + CapturingChatModel chatModel = new CapturingChatModel(""" + { + "keywords": [ + { + "keyword": " Spring ", + "importance": 88 + }, + { + "keyword": "백엔드", + "importance": 95 + } + ] + } + """); + KeywordAnalysisClient client = new OpenAiKeywordAnalysisClient(ChatClient.builder(chatModel)); + + List results = client.analyze(request()); + + assertThat(results) + .extracting(KeywordAnalysisResult::keyword, KeywordAnalysisResult::importance) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("백엔드", 95), + org.assertj.core.groups.Tuple.tuple("Spring", 88) + ); + + Prompt prompt = chatModel.capturedPrompt(); + assertThat(prompt.getSystemMessage().getText()) + .contains("자기소개서", "핵심 키워드", "상위 20개", "importance", "1~100"); + assertThat(prompt.getUserMessage().getText()) + .contains( + "백엔드 자기소개서", + "다온", + "백엔드 개발자", + "Spring 경험 우대", + "clq_1", + "지원 동기는?", + "최종 작성본 1", + "clq_2", + "직무 역량은?", + "최종 작성본 2" + ); + } + + @Test + void rejectsInvalidKeywordResponses() { + List invalidResponses = List.of( + """ + {"keywords":null} + """, + """ + {"keywords":[]} + """, + """ + {"keywords":[{"keyword":" ","importance":80}]} + """, + """ + {"keywords":[{"keyword":"백엔드","importance":0}]} + """, + """ + {"keywords":[{"keyword":"백엔드","importance":101}]} + """, + """ + {"keywords":[ + {"keyword":"백엔드","importance":95}, + {"keyword":" 백엔드 ","importance":90} + ]} + """, + """ + {"keywords":["not-object"]} + """ + ); + + for (String response : invalidResponses) { + KeywordAnalysisClient client = clientReturning(response); + + assertOutputValidationFailure(() -> client.analyze(request())); + } + } + + @Test + void trimsMoreThanTwentyKeywordsToTopTwentyByImportance() { + StringBuilder keywordJson = new StringBuilder(); + for (int index = 1; index <= 21; index++) { + if (index > 1) { + keywordJson.append(","); + } + keywordJson.append(""" + {"keyword":"키워드%s","importance":%s} + """.formatted(index, index)); + } + + KeywordAnalysisClient client = clientReturning(""" + {"keywords":[%s]} + """.formatted(keywordJson)); + + List results = client.analyze(request()); + + assertThat(results).hasSize(20); + assertThat(results) + .extracting(KeywordAnalysisResult::keyword, KeywordAnalysisResult::importance) + .startsWith(org.assertj.core.groups.Tuple.tuple("키워드21", 21)) + .endsWith(org.assertj.core.groups.Tuple.tuple("키워드2", 2)); + } + + @Test + void classifiesMalformedJsonAsOutputValidationFailure() { + KeywordAnalysisClient client = clientReturning("not-json"); + + assertOutputValidationFailure(() -> client.analyze(request())); + } + + @Test + void wrapsProviderFailureAndPreservesCause() { + IllegalStateException cause = new IllegalStateException("provider unavailable"); + ChatModel failingModel = prompt -> { + throw cause; + }; + KeywordAnalysisClient client = new OpenAiKeywordAnalysisClient(ChatClient.builder(failingModel)); + + assertThatThrownBy(() -> client.analyze(request())) + .isInstanceOfSatisfying(KeywordAnalysisClientException.class, exception -> { + assertThat(exception.getReason()) + .isEqualTo(KeywordAnalysisClientException.Reason.PROVIDER_ERROR); + assertThat(exception.getCause()).isSameAs(cause); + }); + } + + private KeywordAnalysisClient clientReturning(String response) { + return new OpenAiKeywordAnalysisClient(ChatClient.builder(new CapturingChatModel(response))); + } + + private void assertOutputValidationFailure(ThrowingCall call) { + assertThatThrownBy(call::invoke) + .isInstanceOfSatisfying(KeywordAnalysisClientException.class, exception -> + assertThat(exception.getReason()) + .isEqualTo(KeywordAnalysisClientException.Reason.OUTPUT_VALIDATION_FAILED)); + } + + private KeywordAnalysisRequest request() { + return new KeywordAnalysisRequest( + "백엔드 자기소개서", + "다온", + "백엔드 개발자", + "Spring 경험 우대", + List.of( + new KeywordAnalysisAnswer("clq_1", 1, "지원 동기는?", "최종 작성본 1"), + new KeywordAnalysisAnswer("clq_2", 2, "직무 역량은?", "최종 작성본 2") + ) + ); + } + + @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/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisJobEventIntegrationTest.java b/src/test/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisJobEventIntegrationTest.java new file mode 100644 index 0000000..41690a4 --- /dev/null +++ b/src/test/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisJobEventIntegrationTest.java @@ -0,0 +1,63 @@ +package com.daon.rewrite.keywordanalysis.service; + +import com.daon.rewrite.llmjob.entity.LlmJob; +import com.daon.rewrite.llmjob.repository.LlmJobRepository; +import com.daon.rewrite.llmjob.service.LlmJobCreatedEvent; +import com.daon.rewrite.reviewversion.service.FirstReviewJobWorker; +import com.daon.rewrite.reviewversion.service.ReReviewJobWorker; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.Instant; + +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; + +@SpringBootTest +@ActiveProfiles("test") +class KeywordAnalysisJobEventIntegrationTest { + + @Autowired + private LlmJobRepository llmJobRepository; + + @Autowired + private ApplicationEventPublisher eventPublisher; + + @Autowired + private PlatformTransactionManager transactionManager; + + @MockitoBean + private KeywordAnalysisJobWorker keywordAnalysisJobWorker; + + @MockitoBean + private FirstReviewJobWorker firstReviewJobWorker; + + @MockitoBean + private ReReviewJobWorker reReviewJobWorker; + + @AfterEach + void cleanUp() { + llmJobRepository.deleteAll(); + } + + @Test + void keywordAnalysisJobEventSchedulesKeywordAnalysisWorkerAfterCommit() { + Instant now = Instant.parse("2026-07-05T00:00:00Z"); + new TransactionTemplate(transactionManager).executeWithoutResult(status -> { + llmJobRepository.save(LlmJob.pendingKeywordAnalysis("job_1", "cl_1", now)); + eventPublisher.publishEvent(new LlmJobCreatedEvent("job_1")); + }); + + then(keywordAnalysisJobWorker).should(timeout(1000)).execute("job_1"); + then(firstReviewJobWorker).should(never()).execute("job_1"); + then(reReviewJobWorker).should(never()).execute("job_1"); + } +} diff --git a/src/test/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisJobWorkerTest.java b/src/test/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisJobWorkerTest.java new file mode 100644 index 0000000..075cd53 --- /dev/null +++ b/src/test/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisJobWorkerTest.java @@ -0,0 +1,341 @@ +package com.daon.rewrite.keywordanalysis.service; + +import com.daon.rewrite.coverletter.entity.CoverLetter; +import com.daon.rewrite.coverletter.entity.CoverLetterQuestion; +import com.daon.rewrite.coverletter.repository.CoverLetterQuestionRepository; +import com.daon.rewrite.coverletter.repository.CoverLetterRepository; +import com.daon.rewrite.global.util.IdGenerator; +import com.daon.rewrite.keywordanalysis.client.KeywordAnalysisAnswer; +import com.daon.rewrite.keywordanalysis.client.KeywordAnalysisClient; +import com.daon.rewrite.keywordanalysis.client.KeywordAnalysisClientException; +import com.daon.rewrite.keywordanalysis.client.KeywordAnalysisRequest; +import com.daon.rewrite.keywordanalysis.client.KeywordAnalysisResult; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysis; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisKeyword; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisStatus; +import com.daon.rewrite.keywordanalysis.repository.KeywordAnalysisKeywordRepository; +import com.daon.rewrite.keywordanalysis.repository.KeywordAnalysisRepository; +import com.daon.rewrite.llmjob.entity.LlmJob; +import com.daon.rewrite.llmjob.entity.LlmJobResultRefType; +import com.daon.rewrite.llmjob.entity.LlmJobStatus; +import com.daon.rewrite.llmjob.repository.LlmJobRepository; +import com.daon.rewrite.reviewversion.entity.ReviewVersion; +import com.daon.rewrite.reviewversion.entity.ReviewVersionQuestionResult; +import com.daon.rewrite.reviewversion.repository.ReviewVersionQuestionResultRepository; +import com.daon.rewrite.reviewversion.repository.ReviewVersionRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@SpringBootTest +@ActiveProfiles("test") +class KeywordAnalysisJobWorkerTest { + + @Autowired + private KeywordAnalysisJobWorker worker; + + @Autowired + private CoverLetterRepository coverLetterRepository; + + @Autowired + private CoverLetterQuestionRepository questionRepository; + + @Autowired + private ReviewVersionRepository reviewVersionRepository; + + @Autowired + private ReviewVersionQuestionResultRepository questionResultRepository; + + @Autowired + private KeywordAnalysisRepository keywordAnalysisRepository; + + @Autowired + private KeywordAnalysisKeywordRepository keywordRepository; + + @Autowired + private LlmJobRepository llmJobRepository; + + @MockitoBean + private KeywordAnalysisClient keywordAnalysisClient; + + @MockitoBean + private IdGenerator idGenerator; + + @MockitoBean + private Clock clock; + + @AfterEach + void cleanUp() { + keywordRepository.deleteAll(); + keywordAnalysisRepository.deleteAll(); + questionResultRepository.deleteAll(); + reviewVersionRepository.deleteAll(); + llmJobRepository.deleteAll(); + questionRepository.deleteAll(); + coverLetterRepository.deleteAll(); + } + + @Test + void executeCompletesPendingKeywordAnalysisJobWithFinalAnswers() { + Instant completedAt = Instant.parse("2026-07-05T01:00:00Z"); + savePendingKeywordAnalysisJob("cl_1", "rv_1", "ka_1", "job_1"); + KeywordAnalysis keywordAnalysis = keywordAnalysisRepository.findById("ka_1").orElseThrow(); + keywordRepository.save(KeywordAnalysisKeyword.of("kak_old", keywordAnalysis, 1, "기존", 70)); + given(keywordAnalysisClient.analyze(any())).willReturn(List.of( + new KeywordAnalysisResult("백엔드", 95), + new KeywordAnalysisResult("Spring", 88) + )); + given(idGenerator.generate("kak")).willReturn("kak_1", "kak_2"); + given(clock.instant()).willReturn(completedAt); + + worker.execute("job_1"); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(KeywordAnalysisRequest.class); + then(keywordAnalysisClient).should().analyze(requestCaptor.capture()); + KeywordAnalysisRequest request = requestCaptor.getValue(); + assertThat(request.title()).isEqualTo("제목"); + assertThat(request.companyName()).isEqualTo("회사"); + assertThat(request.positionTitle()).isEqualTo("직무"); + assertThat(request.preferences()).isEqualTo("Spring Boot 경험"); + assertThat(request.answers()) + .extracting( + KeywordAnalysisAnswer::questionId, + KeywordAnalysisAnswer::questionOrder, + KeywordAnalysisAnswer::question, + KeywordAnalysisAnswer::finalAnswer + ) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("clq_1", 1, "질문 1", "최종 작성본 1"), + org.assertj.core.groups.Tuple.tuple("clq_2", 2, "질문 2", "최종 작성본 2") + ); + + assertThat(keywordAnalysisRepository.findById("ka_1")).hasValueSatisfying(analysis -> { + assertThat(analysis.getStatus()).isEqualTo(KeywordAnalysisStatus.COMPLETED); + assertThat(analysis.getCompletedAt()).isEqualTo(completedAt); + }); + assertThat(keywordRepository.findByKeywordAnalysisIdOrderByKeywordOrderAsc("ka_1")) + .extracting( + KeywordAnalysisKeyword::getId, + KeywordAnalysisKeyword::getKeywordOrder, + KeywordAnalysisKeyword::getKeyword, + KeywordAnalysisKeyword::getImportance + ) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("kak_1", 1, "백엔드", 95), + org.assertj.core.groups.Tuple.tuple("kak_2", 2, "Spring", 88) + ); + assertThat(llmJobRepository.findById("job_1")).hasValueSatisfying(job -> { + assertThat(job.getStatus()).isEqualTo(LlmJobStatus.COMPLETED); + assertThat(job.getResultRefType()).isEqualTo(LlmJobResultRefType.KEYWORD_ANALYSIS); + assertThat(job.getResultRefId()).isEqualTo("ka_1"); + assertThat(job.getCompletedAt()).isEqualTo(completedAt); + }); + } + + @Test + void executeFailsKeywordAnalysisWhenProviderFails() { + Instant failedAt = Instant.parse("2026-07-05T01:00:00Z"); + savePendingKeywordAnalysisJob("cl_1", "rv_1", "ka_1", "job_1"); + given(keywordAnalysisClient.analyze(any())) + .willThrow(KeywordAnalysisClientException.providerError(new IllegalStateException("provider down"))); + given(clock.instant()).willReturn(failedAt); + + worker.execute("job_1"); + + assertThat(keywordAnalysisRepository.findById("ka_1")).hasValueSatisfying(analysis -> { + assertThat(analysis.getStatus()).isEqualTo(KeywordAnalysisStatus.FAILED); + assertThat(analysis.getCompletedAt()).isEqualTo(failedAt); + }); + assertThat(llmJobRepository.findById("job_1")).hasValueSatisfying(job -> { + assertThat(job.getStatus()).isEqualTo(LlmJobStatus.FAILED); + assertThat(job.getErrorCode()).isEqualTo("LLM_PROVIDER_ERROR"); + assertThat(job.getErrorMessage()).isEqualTo("LLM 응답 생성에 실패했습니다."); + assertThat(job.getCompletedAt()).isEqualTo(failedAt); + }); + assertThat(keywordRepository.findByKeywordAnalysisIdOrderByKeywordOrderAsc("ka_1")).isEmpty(); + } + + @Test + void executeFailsKeywordAnalysisWhenOutputValidationFails() { + Instant failedAt = Instant.parse("2026-07-05T01:00:00Z"); + savePendingKeywordAnalysisJob("cl_1", "rv_1", "ka_1", "job_1"); + given(keywordAnalysisClient.analyze(any())) + .willThrow(KeywordAnalysisClientException.outputValidationFailed()); + given(clock.instant()).willReturn(failedAt); + + worker.execute("job_1"); + + assertThat(keywordAnalysisRepository.findById("ka_1")).hasValueSatisfying(analysis -> + assertThat(analysis.getStatus()).isEqualTo(KeywordAnalysisStatus.FAILED) + ); + assertThat(llmJobRepository.findById("job_1")).hasValueSatisfying(job -> { + assertThat(job.getStatus()).isEqualTo(LlmJobStatus.FAILED); + assertThat(job.getErrorCode()).isEqualTo("LLM_OUTPUT_VALIDATION_FAILED"); + assertThat(job.getErrorMessage()).isEqualTo("LLM 출력 형식이 올바르지 않습니다."); + assertThat(job.getCompletedAt()).isEqualTo(failedAt); + }); + } + + @Test + void executeFailsKeywordAnalysisWhenUnexpectedClientExceptionOccurs() { + Instant failedAt = Instant.parse("2026-07-05T01:00:00Z"); + savePendingKeywordAnalysisJob("cl_1", "rv_1", "ka_1", "job_1"); + given(keywordAnalysisClient.analyze(any())) + .willThrow(new IllegalStateException("unexpected")); + given(clock.instant()).willReturn(failedAt); + + worker.execute("job_1"); + + assertThat(keywordAnalysisRepository.findById("ka_1")).hasValueSatisfying(analysis -> { + assertThat(analysis.getStatus()).isEqualTo(KeywordAnalysisStatus.FAILED); + assertThat(analysis.getCompletedAt()).isEqualTo(failedAt); + }); + assertThat(llmJobRepository.findById("job_1")).hasValueSatisfying(job -> { + assertThat(job.getStatus()).isEqualTo(LlmJobStatus.FAILED); + assertThat(job.getErrorCode()).isEqualTo("INTERNAL_ERROR"); + assertThat(job.getErrorMessage()).isEqualTo("키워드 분석 처리 중 오류가 발생했습니다."); + assertThat(job.getCompletedAt()).isEqualTo(failedAt); + }); + } + + @Test + void executeFailsKeywordAnalysisWhenStartPreconditionFails() { + Instant failedAt = Instant.parse("2026-07-05T01:00:00Z"); + saveProcessingKeywordAnalysisForDraftCoverLetter("cl_1", "ka_1", "job_1"); + given(clock.instant()).willReturn(failedAt); + + worker.execute("job_1"); + + then(keywordAnalysisClient).shouldHaveNoInteractions(); + assertThat(keywordAnalysisRepository.findById("ka_1")).hasValueSatisfying(analysis -> { + assertThat(analysis.getStatus()).isEqualTo(KeywordAnalysisStatus.FAILED); + assertThat(analysis.getCompletedAt()).isEqualTo(failedAt); + }); + assertThat(llmJobRepository.findById("job_1")).hasValueSatisfying(job -> { + assertThat(job.getStatus()).isEqualTo(LlmJobStatus.FAILED); + assertThat(job.getErrorCode()).isEqualTo("INTERNAL_ERROR"); + assertThat(job.getErrorMessage()).isEqualTo("키워드 분석 처리 중 오류가 발생했습니다."); + assertThat(job.getCompletedAt()).isEqualTo(failedAt); + }); + } + + @Test + void executeSkipsAlreadyCompletedJob() { + Instant completedAt = Instant.parse("2026-07-05T01:00:00Z"); + savePendingKeywordAnalysisJob("cl_1", "rv_1", "ka_1", "job_1"); + LlmJob job = llmJobRepository.findById("job_1").orElseThrow(); + job.startProcessing("키워드 분석을 시작합니다."); + job.markCompleted(1, "키워드 분석이 완료되었습니다.", LlmJobResultRefType.KEYWORD_ANALYSIS, "ka_1", completedAt); + llmJobRepository.save(job); + KeywordAnalysis keywordAnalysis = keywordAnalysisRepository.findById("ka_1").orElseThrow(); + keywordAnalysis.complete(completedAt); + keywordAnalysisRepository.save(keywordAnalysis); + + worker.execute("job_1"); + + then(keywordAnalysisClient).shouldHaveNoInteractions(); + assertThat(keywordAnalysisRepository.findById("ka_1")).hasValueSatisfying(analysis -> { + assertThat(analysis.getStatus()).isEqualTo(KeywordAnalysisStatus.COMPLETED); + assertThat(analysis.getCompletedAt()).isEqualTo(completedAt); + }); + assertThat(llmJobRepository.findById("job_1")).hasValueSatisfying(reloadedJob -> { + assertThat(reloadedJob.getStatus()).isEqualTo(LlmJobStatus.COMPLETED); + assertThat(reloadedJob.getResultRefType()).isEqualTo(LlmJobResultRefType.KEYWORD_ANALYSIS); + assertThat(reloadedJob.getResultRefId()).isEqualTo("ka_1"); + assertThat(reloadedJob.getCompletedAt()).isEqualTo(completedAt); + }); + } + + private void savePendingKeywordAnalysisJob( + String coverLetterId, + String reviewVersionId, + String keywordAnalysisId, + String jobId + ) { + Instant now = Instant.parse("2026-07-05T00:00:00Z"); + CoverLetter coverLetter = CoverLetter.draft(coverLetterId, "user_1", now); + coverLetter.fillBasicInfo("제목", "회사", "직무", null, now); + coverLetter.fillPreferences("Spring Boot 경험", now); + coverLetterRepository.save(coverLetter); + + CoverLetterQuestion firstQuestion = questionRepository.save(CoverLetterQuestion.create( + "clq_1", + coverLetter, + 1, + "질문 1", + 1000, + "원본 답변 1" + )); + CoverLetterQuestion secondQuestion = questionRepository.save(CoverLetterQuestion.create( + "clq_2", + coverLetter, + 2, + "질문 2", + 1000, + "원본 답변 2" + )); + + ReviewVersion reviewVersion = reviewVersionRepository.save(ReviewVersion.first( + reviewVersionId, + coverLetter, + now.plusSeconds(60) + )); + ReviewVersionQuestionResult firstResult = ReviewVersionQuestionResult.create( + "rvqr_1", + reviewVersion, + firstQuestion, + "AI 리포트 1", + "수정본 1" + ); + firstResult.updateFinalAnswer("최종 작성본 1"); + ReviewVersionQuestionResult secondResult = ReviewVersionQuestionResult.create( + "rvqr_2", + reviewVersion, + secondQuestion, + "AI 리포트 2", + "수정본 2" + ); + secondResult.updateFinalAnswer("최종 작성본 2"); + questionResultRepository.saveAll(List.of(firstResult, secondResult)); + + coverLetter.completeReview(reviewVersion.getId(), now.plusSeconds(90)); + coverLetterRepository.save(coverLetter); + + keywordAnalysisRepository.save(KeywordAnalysis.processing( + keywordAnalysisId, + coverLetter, + reviewVersion.getId(), + now.plusSeconds(120) + )); + llmJobRepository.save(LlmJob.pendingKeywordAnalysis(jobId, coverLetterId, now.plusSeconds(120))); + } + + private void saveProcessingKeywordAnalysisForDraftCoverLetter( + String coverLetterId, + String keywordAnalysisId, + String jobId + ) { + Instant now = Instant.parse("2026-07-05T00:00:00Z"); + CoverLetter coverLetter = coverLetterRepository.save(CoverLetter.draft(coverLetterId, "user_1", now)); + keywordAnalysisRepository.save(KeywordAnalysis.processing( + keywordAnalysisId, + coverLetter, + "rv_1", + now.plusSeconds(120) + )); + llmJobRepository.save(LlmJob.pendingKeywordAnalysis(jobId, coverLetterId, now.plusSeconds(120))); + } +} diff --git a/src/test/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisServiceTest.java b/src/test/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisServiceTest.java index 04221f0..0d7ddc3 100644 --- a/src/test/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisServiceTest.java +++ b/src/test/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisServiceTest.java @@ -237,6 +237,33 @@ void findMyLatestKeywordAnalysisReturnsFailedAnalysisWithError() { assertThat(result.keywords()).isEmpty(); } + @Test + void findMyLatestKeywordAnalysisHidesKeywordsWhenAnalysisIsNotCompleted() { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + CoverLetter coverLetter = saveReviewedCoverLetter("cl_1", "user_1", "rv_1", now); + KeywordAnalysis keywordAnalysis = keywordAnalysisRepository.save(KeywordAnalysis.processing( + "ka_1", + coverLetter, + "rv_1", + now.plusSeconds(120) + )); + keywordAnalysisKeywordRepository.save(KeywordAnalysisKeyword.of("kak_1", keywordAnalysis, 1, "백엔드", 95)); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + + LatestKeywordAnalysisResult processingResult = service.findMyLatestKeywordAnalysis("cl_1"); + + assertThat(processingResult.keywordAnalysis().getStatus()).isEqualTo(KeywordAnalysisStatus.PROCESSING); + assertThat(processingResult.keywords()).isEmpty(); + + keywordAnalysis.fail(now.plusSeconds(180)); + keywordAnalysisRepository.save(keywordAnalysis); + + LatestKeywordAnalysisResult failedResult = service.findMyLatestKeywordAnalysis("cl_1"); + + assertThat(failedResult.keywordAnalysis().getStatus()).isEqualTo(KeywordAnalysisStatus.FAILED); + assertThat(failedResult.keywords()).isEmpty(); + } + @Test void findMyLatestKeywordAnalysisRejectsMissingOtherOwnerOrDeletedCoverLetter() { Instant now = Instant.parse("2026-07-03T01:00:00Z");