diff --git a/README.md b/README.md index f49a8826..57d032ee 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,31 @@ Server: `https://api.jobdri.site` | **Mail / Realtime** | JavaMailSender(Gmail SMTP), SSE | | **Docs / Build** | SpringDoc Swagger, Gradle, GitHub Actions | +## Cohere Embed API 로컬 검증 + +Cohere 임베딩 클라이언트는 `COHERE_API_KEY` 환경변수를 통해 API key를 읽습니다. 값이 없어도 애플리케이션 기동은 실패하지 않지만, 실제 임베딩 호출 시 명확한 예외가 발생합니다. + +```bash +export COHERE_API_KEY='실제_API_키' +``` + +기본 설정: + +- endpoint: `POST https://api.cohere.com/v2/embed` +- model: `embed-v4.0` +- output dimension: `1024` +- document input type: `search_document` +- query input type: `search_query` +- embedding type: `float` + +초기 검증용 텍스트: + +```text +Spring Boot 기반 REST API 개발 및 PostgreSQL 성능 최적화 +``` + +기본 테스트 스위트는 실제 Cohere API를 호출하지 않습니다. 수동 검증이 필요하면 `COHERE_API_KEY`를 설정한 뒤 Spring 컨텍스트에서 `CohereEmbeddingClient.embedDocuments(...)` 또는 `embedQuery(...)`를 호출해 반환 벡터 차원이 1024인지 확인합니다. 전체 embedding 값이나 API key는 로그에 남기지 않습니다. + ## ✨ Key Features ### 1. 인증 및 사용자 관리 diff --git a/build.gradle b/build.gradle index 040c4c2b..fb46b242 100644 --- a/build.gradle +++ b/build.gradle @@ -41,6 +41,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-jdbc' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.apache.httpcomponents.client5:httpclient5' implementation 'org.apache.poi:poi-ooxml:5.4.1' implementation 'com.pgvector:pgvector:0.1.6' diff --git a/evaluation/evaluation_nlg_judge_missing_keyword_provenance.csv b/evaluation/evaluation_nlg_judge_missing_keyword_provenance.csv new file mode 100644 index 00000000..8f61bbd3 --- /dev/null +++ b/evaluation/evaluation_nlg_judge_missing_keyword_provenance.csv @@ -0,0 +1,21 @@ +caseId,sourceResultFile,analysisCount,averageRelevance,averageProblemValidity,averageSentenceTypeConsistency,averageReasonCorrectness,averageContextAwareness,averageFaithfulness,averageTenseConsistency,averageUsability,averageNonMeta,averageMeaningPreservation,noAnalysisAppropriateness,strengthsPrecision,strengthsCoverage,missingKeywordsPrecision,missingKeywordsCoverage,overallUsefulness,errorCodes,shortRationale,judgeInputTokens,judgeOutputTokens,judgeLatencyMs,failureStage +EV-01,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,1,4.0,3.0,4.0,3.0,3.0,4.0,5.0,4.0,4.0,4.0,4,5,5,5,5,4,"[""NONE""]","전반적으로 경험이 잘 드러나지만, 구체적인 절차와 결과 수치가 부족합니다.",6566,280,4806, +EV-02,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,5,5,5,5,5,4,"[""NONE""]","답변은 강점이 잘 드러나지만, 구체적인 행동과 방법론이 부족하여 일부 아쉬움이 있습니다.",6004,130,2873, +EV-03,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,3.0,3.0,3.0,3.0,3.0,4.0,4.0,4.0,4.0,4.0,4,5,5,5,5,4,"[""NONE""]","전반적으로 분석 역량과 경험이 잘 드러나지만, 구체적인 행동과 결과를 더 강조할 필요가 있습니다.",7600,450,6170, +EV-04,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,1,5,5,5,5,2,"[""MISSED_ANALYSIS""]",명확한 문제 문장이 존재하나 분석이 없어서 중요한 첨삭 대상을 놓쳤습니다.,7672,133,2355, +EV-05,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,4.0,3.0,3.0,4.0,3.0,4.0,5.0,4.0,4.0,4.0,4,5,5,5,5,4,"[""NONE""]","지원 동기와 포부가 구체적이지 않아 개선 여지가 있으며, 전반적으로 유용한 분석이 제공되었습니다.",6473,430,6139, +EV-06,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,1,5.0,5.0,5.0,5.0,5.0,5.0,5.0,5.0,5.0,5.0,4,5,5,5,5,5,"[""NONE""]","전반적으로 구체적인 성과와 실행 방법이 잘 드러나 있으며, 분석의 정확성이 높아 유용한 첨삭 자료로 활용될 수 있습니다.",9172,331,7780, +EV-07,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,4,5,5,5,5,3,"[""NONE""]","답변에 명확한 문제 문장이 없고, 강점 및 누락 키워드도 적절하여 분석 부재가 대체로 적합합니다.",5550,136,2995, +EV-08,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,4.0,3.0,3.0,4.0,3.0,4.0,4.0,3.0,4.0,4.0,4,5,5,5,5,4,"[""NONE""]","전반적으로 자기소개서의 강점이 잘 드러나지만, 구체적인 실행 방안과 방법론이 부족하여 개선 여지가 있습니다.",7503,431,5298, +EV-09,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,4.0,3.0,4.0,4.0,3.0,4.0,5.0,4.0,4.0,4.0,5,5,5,5,5,4,"[""NONE""]","전반적으로 구체적인 행동과 방법론이 부족하지만, 경험을 잘 설명하고 있어 유용한 분석입니다.",6496,418,12493, +EV-10,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,4,5,5,5,5,3,"[""NONE""]","답변에 명확한 문제 문장이 없고, 강점 및 누락 키워드도 적절하여 분석 부재가 대체로 적합합니다.",6709,136,2661, +EV-11,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,2,3,3,2,2,2,[],답변에서 JD의 핵심 경험 요구사항인 복지 프로그램 기획과 행정 지원이 누락되었습니다.,6763,267,4588, +EV-12,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,5.0,4.0,5.0,4.0,4.0,5.0,5.0,4.0,5.0,5.0,4,5,5,5,5,4,"[""NONE""]","전반적으로 직무 적합성을 잘 드러내고 있으나, 구체적인 행동이나 방법이 부족한 부분이 있습니다.",6990,424,6968, +EV-13,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,4.0,3.0,4.0,4.0,3.0,4.0,5.0,4.0,4.0,4.0,4,5,5,5,5,4,"[""NONE""]","전반적으로 경험과 방법론이 잘 서술되었으나, 구체성이 부족한 부분이 있어 개선 여지가 있습니다.",6620,440,5346, +EV-14,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,4,5,5,5,5,3,"[""NONE""]",지원 동기에서 구체적인 행동이나 방법이 부족하여 명확한 메시지를 전달하지 못하고 있습니다.,6673,125,2601, +EV-15,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,1,3,3,2,2,2,"[""MISSED_ANALYSIS""]","지원 동기와 직무 적합성에 대한 구체적인 사례가 부족하며, 중요한 경험 키워드가 누락되었습니다.",5595,217,4432, +EV-16,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,1,5,5,5,5,2,"[""MISSED_ANALYSIS""]","명확한 행동 계획과 방법론이 부족한 문장이 존재하나, 분석이 없어서 중요한 첨삭 대상을 놓쳤습니다.",6670,141,2356, +EV-17,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,4.0,3.0,4.0,4.0,3.0,4.0,5.0,4.0,4.0,4.0,4,5,5,5,5,4,"[""NONE""]","전반적으로 경험이 잘 서술되었으나, 구체적인 행동이나 방법이 부족하여 개선 여지가 있습니다.",6002,408,4717, +EV-18,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,5,3,3,2,2,3,[],"답변에서 JD의 핵심 경험 요구사항인 사내 일반 행정 지원이 누락되어 있으며, 전반적으로 구체적인 경험이 부족합니다.",6023,170,8328, +EV-19,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,3.0,3.0,4.0,3.0,3.0,4.0,4.0,3.0,4.0,4.0,3,5,5,5,5,3,"[""NONE""]",지원 동기와 강점에 대한 구체성이 부족하여 개선 여지가 있습니다.,6176,396,6194, +EV-20,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,1,5,5,5,5,2,"[""MISSED_ANALYSIS""]",명확한 문제 문장이 존재하나 분석이 없어서 중요한 첨삭 대상을 놓쳤습니다.,6018,133,2254, diff --git a/evaluation/evaluation_nlg_judge_missing_keyword_provenance_comparison.csv b/evaluation/evaluation_nlg_judge_missing_keyword_provenance_comparison.csv new file mode 100644 index 00000000..578d22c7 --- /dev/null +++ b/evaluation/evaluation_nlg_judge_missing_keyword_provenance_comparison.csv @@ -0,0 +1,3 @@ +sourceResultFile,caseCount,successCount,judgeFailedCount,averageRelevance,averageProblemValidity,averageSentenceTypeConsistency,averageReasonCorrectness,averageContextAwareness,averageFaithfulness,averageTenseConsistency,averageUsability,averageNonMeta,averageMeaningPreservation,noAnalysisAppropriateness,strengthsPrecision,strengthsCoverage,missingKeywordsPrecision,missingKeywordsCoverage,overallUsefulness,averageJudgeInputTokens,averageJudgeOutputTokens,averageJudgeLatencyMs,averageAnalysisCount,metaImprovementRate,unsupportedFactRate,falsePositiveAnalysisRate,fatalErrorRate,errorCodeCounts +evaluation/evaluation_nlg_judge_policy_alignment.csv,20,20,0,3.85,3.35,3.85,4.1,3.35,4.1,4.6,4.0,4.1,4.1,3.35,4.7,4.7,4.55,4.55,3.45,6412.75,258.95,7965.95,0.9,0.0,0.0,0.0,0.0,"{""MISSED_ANALYSIS"":5,""MISSED_MISSING_KEYWORD"":3,""NONE"":14}" +evaluation/evaluation_nlg_judge_missing_keyword_provenance.csv,20,20,0,4.0,3.3,3.9,3.8,3.3,4.2,4.7,3.9,4.2,4.2,3.4,4.7,4.7,4.55,4.55,3.3,6663.75,279.8,5067.7,0.9,0.0,0.0,0.0,0.0,"{""MISSED_ANALYSIS"":4,""NONE"":14}" diff --git a/ops/db/migrations/20260730_analysis_async_task_execution_snapshot.sql b/ops/db/migrations/20260730_analysis_async_task_execution_snapshot.sql new file mode 100644 index 00000000..6397cf34 --- /dev/null +++ b/ops/db/migrations/20260730_analysis_async_task_execution_snapshot.sql @@ -0,0 +1,5 @@ +ALTER TABLE analysis_async_tasks + ADD COLUMN IF NOT EXISTS execution_context_snapshot TEXT; + +ALTER TABLE analysis_async_tasks + ADD COLUMN IF NOT EXISTS input_fingerprint_snapshot VARCHAR(64); diff --git a/ops/db/migrations/20260730_analysis_async_tasks_home_lookup_index.sql b/ops/db/migrations/20260730_analysis_async_tasks_home_lookup_index.sql new file mode 100644 index 00000000..c13621fc --- /dev/null +++ b/ops/db/migrations/20260730_analysis_async_tasks_home_lookup_index.sql @@ -0,0 +1,2 @@ +CREATE INDEX IF NOT EXISTS idx_analysis_async_tasks_user_mock_apply_status + ON analysis_async_tasks (user_id, mock_apply_id, status); diff --git a/ops/db/migrations/20260730_job_posting_embeddings.sql b/ops/db/migrations/20260730_job_posting_embeddings.sql new file mode 100644 index 00000000..cfa3bb9c --- /dev/null +++ b/ops/db/migrations/20260730_job_posting_embeddings.sql @@ -0,0 +1,16 @@ +CREATE EXTENSION IF NOT EXISTS vector; + +CREATE TABLE IF NOT EXISTS job_posting_embeddings ( + id BIGSERIAL PRIMARY KEY, + job_posting_id BIGINT NOT NULL UNIQUE REFERENCES job_postings(id) ON DELETE CASCADE, + embedding_model VARCHAR(100) NOT NULL, + embedding vector(1024) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_job_posting_embeddings_job_posting + ON job_posting_embeddings (job_posting_id); + +CREATE INDEX IF NOT EXISTS idx_job_posting_embeddings_hnsw + ON job_posting_embeddings USING hnsw (embedding vector_cosine_ops); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponse.java index f92e7a93..192ea07b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponse.java @@ -13,8 +13,43 @@ public record AnalysisWorkerContextResponse( String bigClassificationName, String middleClassificationName, String detailClassificationName, - List questions + List questions, + List similarJobPostings ) { + public AnalysisWorkerContextResponse( + Long userId, + Long mockApplyId, + String companyName, + String jobTitle, + String task, + String requirements, + String preferredQualifications, + String bigClassificationName, + String middleClassificationName, + String detailClassificationName, + List questions + ) { + this( + userId, + mockApplyId, + companyName, + jobTitle, + task, + requirements, + preferredQualifications, + bigClassificationName, + middleClassificationName, + detailClassificationName, + questions, + List.of() + ); + } + + public AnalysisWorkerContextResponse { + questions = questions == null ? List.of() : List.copyOf(questions); + similarJobPostings = similarJobPostings == null ? List.of() : List.copyOf(similarJobPostings); + } + public record AnalysisWorkerQuestionItem( Long questionId, String question, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/SimilarJobPostingContext.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/SimilarJobPostingContext.java new file mode 100644 index 00000000..40dd252f --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/SimilarJobPostingContext.java @@ -0,0 +1,14 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.worker; + +public record SimilarJobPostingContext( + Long jobPostingId, + String companyName, + String postingName, + String jobTitle, + String task, + String requirements, + String preferredQualifications, + int similarityRank, + double similarityScore +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java index 64bcfa4a..42cb8b5c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java @@ -92,6 +92,12 @@ public class AnalysisAsyncTask extends CreatedAtEntity { @Column(name = "estimated_remaining_seconds") private Integer estimatedRemainingSeconds; + @Column(name = "execution_context_snapshot", columnDefinition = "TEXT") + private String executionContextSnapshot; + + @Column(name = "input_fingerprint_snapshot", length = 64) + private String inputFingerprintSnapshot; + public static AnalysisAsyncTask pending(Long userId, Long mockApplyId, int maxRetryCount) { AnalysisAsyncTask task = new AnalysisAsyncTask(); task.taskId = UUID.randomUUID().toString(); @@ -217,6 +223,14 @@ public void updateWorkerMetadata(String workerId, Long queueLatencyMillis) { } } + public void captureExecutionSnapshot(String executionContextSnapshot, String inputFingerprintSnapshot) { + if (this.executionContextSnapshot != null || this.inputFingerprintSnapshot != null) { + return; + } + this.executionContextSnapshot = executionContextSnapshot; + this.inputFingerprintSnapshot = inputFingerprintSnapshot; + } + private boolean isTerminal() { return status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED || status == TaskStatus.CANCELLED; } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java index 44266197..4a849e68 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java @@ -17,5 +17,11 @@ Optional findFirstByUserIdAndMockApplyIdAndStatusInOrderByCre Collection statuses ); + List findByUserIdAndMockApplyIdInAndStatusIn( + Long userId, + Collection mockApplyIds, + Collection statuses + ); + List findByStatusIn(Collection statuses); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java index aea36492..87bf224a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java @@ -1,5 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.service.async; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerCompleteRequest; @@ -12,6 +14,7 @@ import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisExecutionPayload; +import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisInputFingerprintProvider; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.service.UserService; @@ -43,6 +46,8 @@ public class AnalysisWorkerBridgeService { private final AnalysisService analysisService; private final UserService userService; private final WorkerTaskResultService workerTaskResultService; + private final AnalysisInputFingerprintProvider analysisInputFingerprintProvider; + private final ObjectMapper objectMapper; @Transactional public void markRunning(String taskId, String workerId, int retryCount, Instant submittedAt) { @@ -109,10 +114,13 @@ public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long ); } reserveCreditIfNeeded(task); + if (task.getExecutionContextSnapshot() != null) { + return readContextSnapshot(task); + } + User user = userService.getUser(userId); AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(user, mockApplyId); - - return new AnalysisWorkerContextResponse( + AnalysisWorkerContextResponse context = new AnalysisWorkerContextResponse( userId, mockApplyId, payload.jobPosting().getCompany().getName(), @@ -123,8 +131,14 @@ public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long payload.jobPosting().getDetailClassification().getMiddleClassification().getClassification().getBigName(), payload.jobPosting().getDetailClassification().getMiddleClassification().getMiddleName(), payload.jobPosting().getDetailClassification().getDetailName(), - toQuestionItems(payload.questions()) + toQuestionItems(payload.questions()), + payload.similarJobPostings() ); + task.captureExecutionSnapshot( + writeContextSnapshot(context), + analysisInputFingerprintProvider.create(payload) + ); + return context; } @Transactional @@ -169,9 +183,20 @@ public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteReques } User user = userService.getUser(request.userId()); - AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(user, request.mockApplyId()); + AnalysisWorkerContextResponse contextSnapshot = readContextSnapshot(task); + AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution( + user, + request.mockApplyId(), + contextSnapshot.similarJobPostings() + ); AnalysisLlmResponse llmResponse = request.llmResponse(); - AnalysisResponse response = analysisService.finalizeAnalysis(user, request.mockApplyId(), payload, llmResponse); + AnalysisResponse response = analysisService.finalizeAnalysis( + user, + request.mockApplyId(), + payload, + llmResponse, + task.getInputFingerprintSnapshot() + ); analysisAsyncTaskService.updateWorkerMetadata(taskId, request.workerId(), request.queueLatencyMillis()); confirmCreditIfNeeded(task); analysisAsyncTaskService.markSuccess(taskId, response); @@ -219,6 +244,34 @@ private List toQuestio .toList(); } + private String writeContextSnapshot(AnalysisWorkerContextResponse context) { + try { + return objectMapper.writeValueAsString(context); + } catch (JsonProcessingException exception) { + throw new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "자소서 분석 worker 컨텍스트 snapshot 저장에 실패했습니다." + ); + } + } + + private AnalysisWorkerContextResponse readContextSnapshot(AnalysisAsyncTask task) { + if (task.getExecutionContextSnapshot() == null || task.getInputFingerprintSnapshot() == null) { + throw new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "자소서 분석 worker 실행 snapshot이 존재하지 않습니다. taskId=" + task.getTaskId() + ); + } + try { + return objectMapper.readValue(task.getExecutionContextSnapshot(), AnalysisWorkerContextResponse.class); + } catch (JsonProcessingException exception) { + throw new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "자소서 분석 worker 컨텍스트 snapshot을 읽을 수 없습니다. taskId=" + task.getTaskId() + ); + } + } + private AnalysisAsyncTask getTask(String taskId) { return analysisAsyncTaskRepository.findById(taskId) .orElseThrow(() -> new GeneralException( diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisExecutionPayload.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisExecutionPayload.java index 3c7b0888..15dc3c5e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisExecutionPayload.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisExecutionPayload.java @@ -1,6 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.service.core; import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; @@ -15,7 +16,8 @@ public record AnalysisExecutionPayload( List questions, List answeredQuestions, JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, - RetrievalContext retrievalContext + RetrievalContext retrievalContext, + List similarJobPostings ) { public AnalysisExecutionPayload( Long userId, @@ -24,7 +26,7 @@ public AnalysisExecutionPayload( List questions, List answeredQuestions ) { - this(userId, mockApplyId, jobPosting, questions, answeredQuestions, null, null); + this(userId, mockApplyId, jobPosting, questions, answeredQuestions, null, null, List.of()); } public AnalysisExecutionPayload( @@ -35,6 +37,33 @@ public AnalysisExecutionPayload( List answeredQuestions, JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria ) { - this(userId, mockApplyId, jobPosting, questions, answeredQuestions, jobCategoryEvaluationCriteria, null); + this(userId, mockApplyId, jobPosting, questions, answeredQuestions, jobCategoryEvaluationCriteria, null, List.of()); + } + + public AnalysisExecutionPayload( + Long userId, + Long mockApplyId, + JobPosting jobPosting, + List questions, + List answeredQuestions, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, + RetrievalContext retrievalContext + ) { + this( + userId, + mockApplyId, + jobPosting, + questions, + answeredQuestions, + jobCategoryEvaluationCriteria, + retrievalContext, + List.of() + ); + } + + public AnalysisExecutionPayload { + questions = questions == null ? List.of() : List.copyOf(questions); + answeredQuestions = answeredQuestions == null ? List.of() : List.copyOf(answeredQuestions); + similarJobPostings = similarJobPostings == null ? List.of() : List.copyOf(similarJobPostings); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProvider.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProvider.java index ef483ca3..d2753bf2 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProvider.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProvider.java @@ -3,12 +3,14 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedJobPostingReference; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedQuestionReference; import com.jobdri.jobdri_api.domain.analysis.service.ai.FewShotPromptProvider; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.global.cohere.CohereProperties; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -24,8 +26,8 @@ @Component public class AnalysisInputFingerprintProvider { - private static final String FINGERPRINT_SCHEMA_VERSION = "analysis-input-fingerprint-v1"; - private static final String ANALYSIS_PROMPT_POLICY_VERSION = "analysis-prompt-policy-v1"; + private static final String FINGERPRINT_SCHEMA_VERSION = "analysis-input-fingerprint-v2"; + private static final String ANALYSIS_PROMPT_POLICY_VERSION = "analysis-prompt-policy-v2-similar-job-posting-rag"; private static final double ANALYSIS_TEMPERATURE = 0.2; private final ObjectMapper objectMapper; @@ -40,10 +42,10 @@ public class AnalysisInputFingerprintProvider { public AnalysisInputFingerprintProvider( ObjectMapper objectMapper, FewShotPromptProvider fewShotPromptProvider, + CohereProperties cohereProperties, @Value("${openai.model.cover-letter-analysis:gpt-4o-mini}") String analysisModel, @Value("${analysis.two-pass.enabled:false}") boolean twoPassEnabled, @Value("${analysis.mode:}") String analysisMode, - @Value("${app.corpus.embedding.model:embed-v4.0}") String embeddingModel, @Value("${app.analysis.retrieval.jd-limit:3}") int jdLimit, @Value("${app.analysis.retrieval.question-limit:5}") int questionLimit ) { @@ -52,7 +54,7 @@ public AnalysisInputFingerprintProvider( this.analysisModel = analysisModel; this.twoPassEnabled = twoPassEnabled; this.analysisMode = analysisMode; - this.embeddingModel = embeddingModel; + this.embeddingModel = cohereProperties.embedding().model(); this.jdLimit = jdLimit; this.questionLimit = questionLimit; } @@ -67,6 +69,7 @@ public String create(AnalysisExecutionPayload payload) { fingerprintSource.put("fewShotPrompt", fewShotPromptProvider.getPrompt()); fingerprintSource.put("retrievalPolicy", retrievalPolicy()); fingerprintSource.put("retrievalContext", retrievalContextFingerprintSource(payload.retrievalContext())); + fingerprintSource.put("similarJobPostings", similarJobPostingFingerprintSource(payload.similarJobPostings())); fingerprintSource.put("jobPosting", jobPostingFingerprintSource(payload.jobPosting())); fingerprintSource.put("answeredQuestions", answeredQuestionFingerprintSource(payload.answeredQuestions())); fingerprintSource.put("jobCategoryEvaluationCriteria", payload.jobCategoryEvaluationCriteria()); @@ -148,6 +151,28 @@ private Map jobPostingFingerprintSource(JobPosting jobPosting) { return jobPostingSource; } + private List> similarJobPostingFingerprintSource( + List similarJobPostings + ) { + if (similarJobPostings == null) { + return List.of(); + } + return similarJobPostings.stream() + .map(context -> { + Map source = new LinkedHashMap<>(); + source.put("jobPostingId", context.jobPostingId()); + source.put("companyName", defaultString(context.companyName())); + source.put("postingName", defaultString(context.postingName())); + source.put("jobTitle", defaultString(context.jobTitle())); + source.put("task", defaultString(context.task())); + source.put("requirements", defaultString(context.requirements())); + source.put("preferredQualifications", defaultString(context.preferredQualifications())); + source.put("similarityRank", context.similarityRank()); + return source; + }) + .toList(); + } + private List> answeredQuestionFingerprintSource(List answeredQuestions) { return answeredQuestions.stream() .map(question -> { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java index 2eeb836c..e023f477 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java @@ -11,6 +11,7 @@ import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnalysisResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; @@ -20,6 +21,7 @@ import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisAiClient; import com.jobdri.jobdri_api.domain.analysis.service.ai.JobCategoryEvaluationCriteriaProvider; +import com.jobdri.jobdri_api.domain.analysis.service.retrieval.JobPostingRagContextAssembler; import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisSanitizationRules; import com.jobdri.jobdri_api.domain.audit.annotation.AuditLogEvent; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService; @@ -84,6 +86,7 @@ public class AnalysisService { private final JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider; private final AnalysisInputFingerprintProvider analysisInputFingerprintProvider; private final CorpusRetrievalService corpusRetrievalService; + private final JobPostingRagContextAssembler jobPostingRagContextAssembler; @Transactional @AuditLogEvent(action = "ANALYSIS_RUN", targetType = "MOCK_APPLY", targetId = "#arg1") @@ -137,6 +140,19 @@ public void refundAnalysisCredit(User user, String referenceId) { @Transactional(readOnly = true) public AnalysisExecutionPayload prepareAnalysisExecution(User user, Long mockApplyId) { + return prepareAnalysisExecution( + user, + mockApplyId, + jobPostingRagContextAssembler.assemble(getOwnedMockApply(user, mockApplyId).getJobPosting().getId()) + ); + } + + @Transactional(readOnly = true) + public AnalysisExecutionPayload prepareAnalysisExecution( + User user, + Long mockApplyId, + List similarJobPostings + ) { MockApply mockApply = getOwnedMockApply(user, mockApplyId); List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); List answeredQuestions = questions.stream() @@ -164,7 +180,8 @@ public AnalysisExecutionPayload prepareAnalysisExecution(User user, Long mockApp List.copyOf(questions), List.copyOf(answeredQuestions), evaluationCriteria, - retrieveAnalysisReferences(mockApply.getJobPosting(), answeredQuestions) + retrieveAnalysisReferences(mockApply.getJobPosting(), answeredQuestions), + similarJobPostings ); } @@ -179,7 +196,23 @@ public AnalysisResponse finalizeAnalysis( AnalysisExecutionPayload payload, AnalysisLlmResponse llmResponse ) { - String inputFingerprint = analysisInputFingerprintProvider.create(payload); + return finalizeAnalysis( + user, + mockApplyId, + payload, + llmResponse, + analysisInputFingerprintProvider.create(payload) + ); + } + + @Transactional + public AnalysisResponse finalizeAnalysis( + User user, + Long mockApplyId, + AnalysisExecutionPayload payload, + AnalysisLlmResponse llmResponse, + String inputFingerprint + ) { MockApply mockApply = getOwnedMockApply(user, mockApplyId); List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); validateRequiredScores(llmResponse); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/retrieval/JobPostingRagContextAssembler.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/retrieval/JobPostingRagContextAssembler.java new file mode 100644 index 00000000..91893af0 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/retrieval/JobPostingRagContextAssembler.java @@ -0,0 +1,107 @@ +package com.jobdri.jobdri_api.domain.analysis.service.retrieval; + +import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingSimilarityResult; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; +import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingRetrievalService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@Slf4j +@RequiredArgsConstructor +public class JobPostingRagContextAssembler { + + static final int MAX_SIMILAR_JOB_POSTINGS = 3; + static final int MAX_CONTEXT_FIELD_LENGTH = 1_200; + + private final JobPostingRetrievalService jobPostingRetrievalService; + private final JobPostingRepository jobPostingRepository; + + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public List assemble(Long jobPostingId) { + try { + List results = jobPostingRetrievalService + .findSimilarJobPostings(jobPostingId, MAX_SIMILAR_JOB_POSTINGS) + .stream() + .limit(MAX_SIMILAR_JOB_POSTINGS) + .toList(); + if (results.isEmpty()) { + return List.of(); + } + + Map postingsById = jobPostingRepository.findAllById( + results.stream().map(JobPostingSimilarityResult::jobPostingId).toList() + ).stream() + .collect(Collectors.toMap(JobPosting::getId, Function.identity())); + + return java.util.stream.IntStream.range(0, results.size()) + .mapToObj(index -> toContext(results.get(index), postingsById, index + 1)) + .filter(java.util.Objects::nonNull) + .toList(); + } catch (RuntimeException exception) { + log.warn( + "유사 채용 공고 RAG context 조회에 실패해 빈 목록으로 분석을 계속합니다. jobPostingId={}, errorType={}", + jobPostingId, + exception.getClass().getSimpleName() + ); + log.debug("similar job posting RAG context retrieval exception", exception); + return List.of(); + } + } + + private SimilarJobPostingContext toContext( + JobPostingSimilarityResult result, + Map postingsById, + int rank + ) { + JobPosting posting = postingsById.get(result.jobPostingId()); + if (posting == null) { + log.warn( + "유사 채용 공고 상세 정보를 찾지 못해 context에서 제외합니다. jobPostingId={}, similarityRank={}", + result.jobPostingId(), + rank + ); + return null; + } + return new SimilarJobPostingContext( + result.jobPostingId(), + normalize(result.companyName()), + normalize(result.postingName()), + normalize(result.jobTitle()), + truncate(posting.getTask()), + truncate(posting.getRequirement()), + truncate(posting.getPreferred()), + rank, + result.similarityScore() + ); + } + + private String normalize(String value) { + return StringUtils.hasText(value) ? value.trim() : ""; + } + + private String truncate(String value) { + String normalized = normalize(value); + if (normalized.length() <= MAX_CONTEXT_FIELD_LENGTH) { + return normalized; + } + + int boundary = Math.max( + normalized.lastIndexOf('\n', MAX_CONTEXT_FIELD_LENGTH), + normalized.lastIndexOf(' ', MAX_CONTEXT_FIELD_LENGTH) + ); + int end = boundary >= MAX_CONTEXT_FIELD_LENGTH / 2 ? boundary : MAX_CONTEXT_FIELD_LENGTH; + return normalized.substring(0, end).stripTrailing(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CohereCorpusEmbeddingClient.java b/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CohereCorpusEmbeddingClient.java index 85d151d7..86dbe902 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CohereCorpusEmbeddingClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CohereCorpusEmbeddingClient.java @@ -1,115 +1,22 @@ package com.jobdri.jobdri_api.domain.corpus.service; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.global.cohere.CohereEmbeddingClient; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; -import org.springframework.web.client.RestClient; -import java.time.Duration; import java.util.List; @Component @RequiredArgsConstructor public class CohereCorpusEmbeddingClient implements CorpusEmbeddingClient { - private final RestClient.Builder restClientBuilder; - private final ObjectMapper objectMapper; - - @Value("${cohere.api.key:}") - private String cohereApiKey; - - @Value("${app.corpus.embedding.model:embed-v4.0}") - private String embeddingModel; - - @Value("${app.corpus.embedding.output-dimension:1024}") - private int outputDimension; + private final CohereEmbeddingClient cohereEmbeddingClient; @Override public List embed(List texts, InputType inputType) { - if (!StringUtils.hasText(cohereApiKey)) { - throw new IllegalStateException("Cohere API 키가 설정되지 않았습니다."); - } - if (texts == null || texts.isEmpty()) { - return List.of(); - } - - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - requestFactory.setConnectTimeout(Duration.ofSeconds(5)); - requestFactory.setReadTimeout(Duration.ofSeconds(10)); - - RestClient client = restClientBuilder - .baseUrl("https://api.cohere.com") - .requestFactory(requestFactory) - .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + cohereApiKey) - .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) - .build(); - - String responseBody = client.post() - .uri("/v2/embed") - .body(new EmbedRequest( - texts, - embeddingModel, - inputType.value(), - outputDimension, - List.of("float") - )) - .retrieve() - .body(String.class); - - return parseEmbeddings(responseBody); - } - - private float[] toFloatArray(List values) { - float[] array = new float[values.size()]; - for (int i = 0; i < values.size(); i++) { - array[i] = values.get(i).floatValue(); + if (inputType == InputType.SEARCH_QUERY) { + return List.of(cohereEmbeddingClient.embedQuery(texts == null || texts.isEmpty() ? null : texts.getFirst())); } - return array; + return cohereEmbeddingClient.embedDocuments(texts); } - - private List parseEmbeddings(String responseBody) { - if (!StringUtils.hasText(responseBody)) { - throw new IllegalStateException("Cohere 임베딩 응답이 비어 있습니다."); - } - - try { - JsonNode root = objectMapper.readTree(responseBody); - JsonNode floatEmbeddings = root.path("embeddings").path("float"); - if (!floatEmbeddings.isArray()) { - throw new IllegalStateException("Cohere 임베딩 응답 형식이 예상과 다릅니다."); - } - - List result = new java.util.ArrayList<>(); - for (JsonNode embeddingNode : floatEmbeddings) { - if (!embeddingNode.isArray()) { - throw new IllegalStateException("Cohere 임베딩 벡터 형식이 예상과 다릅니다."); - } - - float[] vector = new float[embeddingNode.size()]; - for (int i = 0; i < embeddingNode.size(); i++) { - vector[i] = embeddingNode.get(i).floatValue(); - } - result.add(vector); - } - return result; - } catch (Exception e) { - throw new IllegalStateException("Cohere 임베딩 응답 파싱에 실패했습니다.", e); - } - } - - private record EmbedRequest( - List texts, - String model, - String input_type, - Integer output_dimension, - List embedding_types - ) { - } - } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusEmbeddingSyncService.java b/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusEmbeddingSyncService.java index 082bf9fb..dd1c7dc8 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusEmbeddingSyncService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusEmbeddingSyncService.java @@ -5,6 +5,7 @@ import com.jobdri.jobdri_api.domain.corpus.entity.MockQuestionCorpus; import com.jobdri.jobdri_api.domain.corpus.repository.MockJobPostingCorpusRepository; import com.jobdri.jobdri_api.domain.corpus.repository.MockQuestionCorpusRepository; +import com.jobdri.jobdri_api.global.cohere.CohereProperties; import com.pgvector.PGvector; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -46,12 +47,10 @@ ON CONFLICT (corpus_id) updated_at = EXCLUDED.updated_at """; - @Value("${app.corpus.embedding.model:embed-v4.0}") - private String embeddingModel; - @Value("${app.corpus.embedding.batch-size:32}") private int batchSize; + private final CohereProperties cohereProperties; private final MockJobPostingCorpusRepository mockJobPostingCorpusRepository; private final MockQuestionCorpusRepository mockQuestionCorpusRepository; private final CorpusEmbeddingClient corpusEmbeddingClient; @@ -61,7 +60,7 @@ ON CONFLICT (corpus_id) public CorpusEmbeddingSyncResponse syncAll(Integer limit) { int jobPostingCount = syncJobPostingEmbeddings(limit); int questionCount = syncQuestionEmbeddings(limit); - return new CorpusEmbeddingSyncResponse(jobPostingCount, questionCount, embeddingModel); + return new CorpusEmbeddingSyncResponse(jobPostingCount, questionCount, cohereProperties.embedding().model()); } @Transactional @@ -116,7 +115,7 @@ private void upsertVectors(String sql, List ids, List embeddings) Timestamp now = Timestamp.valueOf(LocalDateTime.now()); for (int i = 0; i < ids.size(); i++) { statement.setLong(1, ids.get(i)); - statement.setString(2, embeddingModel); + statement.setString(2, cohereProperties.embedding().model()); statement.setObject(3, new PGvector(embeddings.get(i))); statement.setTimestamp(4, now); statement.setTimestamp(5, now); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingSimilarityResult.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingSimilarityResult.java new file mode 100644 index 00000000..5148579c --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingSimilarityResult.java @@ -0,0 +1,10 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.response; + +public record JobPostingSimilarityResult( + Long jobPostingId, + String postingName, + String companyName, + String jobTitle, + double similarityScore +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingRepository.java index 76187bfc..a9c3d2f7 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingRepository.java @@ -11,6 +11,8 @@ import java.util.List; public interface JobPostingRepository extends JpaRepository { + List findAllByOrderByIdAsc(); + @EntityGraph(attributePaths = { "company", "user", diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingService.java new file mode 100644 index 00000000..fde484ea --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingService.java @@ -0,0 +1,28 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.global.cohere.CohereEmbeddingClient; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class JobPostingEmbeddingService { + + private final JobPostingEmbeddingTextBuilder textBuilder; + private final CohereEmbeddingClient cohereEmbeddingClient; + + public float[] embed(JobPosting jobPosting) { + List embeddings = embedAll(List.of(jobPosting)); + return embeddings.getFirst(); + } + + public List embedAll(List jobPostings) { + List texts = jobPostings.stream() + .map(textBuilder::build) + .toList(); + return cohereEmbeddingClient.embedDocuments(texts); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingSyncService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingSyncService.java new file mode 100644 index 00000000..04ae07bc --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingSyncService.java @@ -0,0 +1,110 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.cohere.CohereProperties; +import com.pgvector.PGvector; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.datasource.DataSourceUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class JobPostingEmbeddingSyncService { + + private static final String UPSERT_JOB_POSTING_SQL = """ + INSERT INTO job_posting_embeddings (job_posting_id, embedding_model, embedding, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (job_posting_id) + DO UPDATE SET + embedding_model = EXCLUDED.embedding_model, + embedding = EXCLUDED.embedding, + updated_at = EXCLUDED.updated_at + """; + + @Value("${app.corpus.embedding.batch-size:32}") + private int batchSize; + + private final JobPostingRepository jobPostingRepository; + private final JobPostingEmbeddingService jobPostingEmbeddingService; + private final CohereProperties cohereProperties; + private final DataSource dataSource; + + @Transactional + public int syncJobPosting(Long jobPostingId) { + JobPosting jobPosting = jobPostingRepository.findById(jobPostingId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.JOB_POSTING_NOT_FOUND, + "해당 공고를 찾을 수 없습니다. jobPostingId=" + jobPostingId + )); + upsertJobPostingEmbeddings(List.of(jobPosting)); + return 1; + } + + @Transactional + public int syncAllJobPostings() { + return upsertJobPostingEmbeddings(jobPostingRepository.findAllByOrderByIdAsc()); + } + + private int upsertJobPostingEmbeddings(List jobPostings) { + int processed = 0; + for (List batch : partition(jobPostings, batchSize)) { + List embeddings = jobPostingEmbeddingService.embedAll(batch); + upsertVectors(batch.stream().map(JobPosting::getId).toList(), embeddings); + processed += batch.size(); + } + return processed; + } + + private void upsertVectors(List jobPostingIds, List embeddings) { + if (jobPostingIds.size() != embeddings.size()) { + throw new IllegalStateException("임베딩 결과 개수가 채용 공고 개수와 일치하지 않습니다."); + } + + Connection connection = DataSourceUtils.getConnection(dataSource); + try { + PGvector.registerTypes(connection); + try (PreparedStatement statement = connection.prepareStatement(UPSERT_JOB_POSTING_SQL)) { + Timestamp now = Timestamp.valueOf(LocalDateTime.now()); + for (int i = 0; i < jobPostingIds.size(); i++) { + statement.setLong(1, jobPostingIds.get(i)); + statement.setString(2, cohereProperties.embedding().model()); + statement.setObject(3, new PGvector(embeddings.get(i))); + statement.setTimestamp(4, now); + statement.setTimestamp(5, now); + statement.addBatch(); + } + statement.executeBatch(); + } + } catch (SQLException e) { + throw new IllegalStateException("채용 공고 임베딩 벡터 저장 중 오류가 발생했습니다.", e); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + private List> partition(List items, int batchSize) { + List> result = new ArrayList<>(); + if (items.isEmpty()) { + return result; + } + int actualBatchSize = Math.max(1, batchSize); + for (int i = 0; i < items.size(); i += actualBatchSize) { + result.add(items.subList(i, Math.min(items.size(), i + actualBatchSize))); + } + return result; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingTextBuilder.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingTextBuilder.java new file mode 100644 index 00000000..badb28d4 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingTextBuilder.java @@ -0,0 +1,28 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; + +@Component +public class JobPostingEmbeddingTextBuilder { + + public String build(JobPosting jobPosting) { + List sections = new ArrayList<>(); + addSection(sections, "직무", jobPosting.getJobTitle()); + addSection(sections, "주요업무", jobPosting.getTask()); + addSection(sections, "자격요건", jobPosting.getRequirement()); + addSection(sections, "우대사항", jobPosting.getPreferred()); + return String.join("\n\n", sections); + } + + private void addSection(List sections, String title, String value) { + if (!StringUtils.hasText(value)) { + return; + } + sections.add(title + "\n" + value.trim()); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingRetrievalService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingRetrievalService.java new file mode 100644 index 00000000..5fcd396f --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingRetrievalService.java @@ -0,0 +1,97 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingSimilarityResult; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.cohere.CohereEmbeddingClient; +import com.pgvector.PGvector; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class JobPostingRetrievalService { + + private static final int DEFAULT_LIMIT = 3; + private static final String SIMILAR_JOB_POSTINGS_SQL = """ + SELECT + jp.id, + jp.posting_name, + c.name AS company_name, + jp.job_title, + e.embedding <=> ? AS distance + FROM job_posting_embeddings e + JOIN job_postings jp ON e.job_posting_id = jp.id + JOIN companies c ON jp.company_id = c.id + WHERE jp.id <> ? + AND jp.user_id = ? + ORDER BY e.embedding <=> ? + LIMIT ? + """; + + private final JobPostingRepository jobPostingRepository; + private final JobPostingEmbeddingTextBuilder textBuilder; + private final CohereEmbeddingClient cohereEmbeddingClient; + private final DataSource dataSource; + + public List findSimilarJobPostings(Long jobPostingId) { + return findSimilarJobPostings(jobPostingId, DEFAULT_LIMIT); + } + + public List findSimilarJobPostings(Long jobPostingId, int limit) { + JobPosting current = jobPostingRepository.findById(jobPostingId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.JOB_POSTING_NOT_FOUND, + "해당 공고를 찾을 수 없습니다. jobPostingId=" + jobPostingId + )); + int actualLimit = Math.max(1, limit); + String query = textBuilder.build(current); + float[] vector = cohereEmbeddingClient.embedQuery(query); + return findSimilarJobPostings(current, vector, actualLimit); + } + + private List findSimilarJobPostings( + JobPosting current, + float[] vector, + int limit + ) { + try (Connection connection = dataSource.getConnection()) { + PGvector.registerTypes(connection); + try (PreparedStatement statement = connection.prepareStatement(SIMILAR_JOB_POSTINGS_SQL)) { + statement.setObject(1, new PGvector(vector)); + statement.setLong(2, current.getId()); + statement.setLong(3, current.getUser().getId()); + statement.setObject(4, new PGvector(vector)); + statement.setInt(5, limit); + try (ResultSet resultSet = statement.executeQuery()) { + List results = new ArrayList<>(); + while (resultSet.next()) { + double distance = resultSet.getDouble("distance"); + results.add(new JobPostingSimilarityResult( + resultSet.getLong("id"), + resultSet.getString("posting_name"), + resultSet.getString("company_name"), + resultSet.getString("job_title"), + Math.max(0.0, Math.min(1.0, 1.0 - distance)) + )); + } + return results; + } + } + } catch (SQLException e) { + throw new IllegalStateException("유사 채용 공고 검색 중 오류가 발생했습니다.", e); + } + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheVersionProvider.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheVersionProvider.java index f8629f1e..0c8b7ee6 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheVersionProvider.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheVersionProvider.java @@ -1,6 +1,7 @@ package com.jobdri.jobdri_api.domain.jobposting.service; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService; +import com.jobdri.jobdri_api.global.cohere.CohereProperties; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -24,14 +25,14 @@ public class MockQuestionCacheVersionProvider { public MockQuestionCacheVersionProvider( MockQuestionCacheProperties mockQuestionCacheProperties, + CohereProperties cohereProperties, @Value("${openai.model.job-posting-extractor:gpt-4o-mini}") String extractionModel, - @Value("${app.corpus.embedding.model:embed-v4.0}") String embeddingModel, @Value("${app.analysis.retrieval.jd-limit:3}") int jdLimit, @Value("${app.analysis.retrieval.question-limit:5}") int questionLimit ) { this.mockQuestionCacheProperties = mockQuestionCacheProperties; this.extractionModel = extractionModel; - this.embeddingModel = embeddingModel; + this.embeddingModel = cohereProperties.embedding().model(); this.jdLimit = jdLimit; this.questionLimit = questionLimit; } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyHomeItemResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyHomeItemResponse.java index bf46381d..23a9c236 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyHomeItemResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyHomeItemResponse.java @@ -22,9 +22,10 @@ public record MockApplyHomeItemResponse( JobPostingProfileColor profileColor, LocalDateTime createdAt, ApplyType applyType, - Integer score + Integer score, + boolean analysisInProgress ) { - public static MockApplyHomeItemResponse from(MockApply mockApply) { + public static MockApplyHomeItemResponse from(MockApply mockApply, boolean analysisInProgress) { JobPosting jobPosting = mockApply.getJobPosting(); String detailClassificationName = jobPosting.getDetailClassification().getDetailName(); Analysis analysis = mockApply.getAnalysis(); @@ -42,7 +43,8 @@ public static MockApplyHomeItemResponse from(MockApply mockApply) { jobPosting.getProfileColor(), mockApply.getCreatedAt(), mockApply.getApplyType(), - analysis == null ? null : analysis.getScore() + analysis == null ? null : analysis.getScore(), + analysisInProgress ); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java index 18f0ac0b..d2251b5d 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java @@ -1,6 +1,8 @@ package com.jobdri.jobdri_api.domain.mockapply.service; import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.TaskStatus; +import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; @@ -36,6 +38,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -46,6 +49,8 @@ import java.sql.SQLException; import java.util.List; import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -72,6 +77,7 @@ public class MockApplyService { private final UserService userService; private final MockApplyPersistenceService mockApplyPersistenceService; private final MockApplySequenceService mockApplySequenceService; + private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; @Transactional(propagation = Propagation.NOT_SUPPORTED) @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") @@ -222,11 +228,9 @@ public MockApplySequenceResponse getMockApplySequence(User user, Long mockApplyI public MockApplyHomeResponse getMyMockApplies(User user, int page, int size) { User validatedUser = userService.validateUser(user); - List inProgressItems = mockApplyRepository - .findAllByUserIdAndStatusNotOrderByCreatedAtDescIdDesc(validatedUser.getId(), MockApplyStatus.COMPLETED) - .stream() - .map(MockApplyHomeItemResponse::from) - .toList(); + List inProgressMockApplies = mockApplyRepository + .findAllByUserIdAndStatusNotOrderByCreatedAtDescIdDesc(validatedUser.getId(), MockApplyStatus.COMPLETED); + List inProgressItems = toHomeItems(validatedUser.getId(), inProgressMockApplies); Pageable pageable = PageRequest.of( Math.max(page, 0), Math.min(Math.max(size, 1), MAX_PAGE_SIZE), @@ -237,7 +241,7 @@ public MockApplyHomeResponse getMyMockApplies(User user, int page, int size) { ); Page completedItems = mockApplyRepository .findAllByUserIdAndStatus(validatedUser.getId(), MockApplyStatus.COMPLETED, pageable) - .map(MockApplyHomeItemResponse::from); + .map(mockApply -> MockApplyHomeItemResponse.from(mockApply, false)); return new MockApplyHomeResponse( inProgressItems, @@ -256,9 +260,8 @@ public List getRecentMockApplies(User user, int limit ) ); - return mockApplyRepository.findAllByUserId(validatedUser.getId(), pageable) - .map(MockApplyHomeItemResponse::from) - .getContent(); + List mockApplies = mockApplyRepository.findAllByUserId(validatedUser.getId(), pageable).getContent(); + return toHomeItems(validatedUser.getId(), mockApplies); } public Page getCompletedMockApplies( @@ -285,7 +288,7 @@ public Page getCompletedMockApplies( maxScoreExclusive(resolvedFilter), pageable ) - .map(MockApplyHomeItemResponse::from); + .map(mockApply -> MockApplyHomeItemResponse.from(mockApply, false)); } public Page searchMyMockApplies(User user, String query, int page, int size) { @@ -300,8 +303,40 @@ public Page searchMyMockApplies(User user, String que ) ); - return mockApplyRepository.searchByUserId(validatedUser.getId(), normalizedQuery, pageable) - .map(MockApplyHomeItemResponse::from); + Page mockApplyPage = mockApplyRepository.searchByUserId(validatedUser.getId(), normalizedQuery, pageable); + return new PageImpl<>( + toHomeItems(validatedUser.getId(), mockApplyPage.getContent()), + pageable, + mockApplyPage.getTotalElements() + ); + } + + private List toHomeItems(Long userId, List mockApplies) { + Set activeAnalysisMockApplyIds = findActiveAnalysisMockApplyIds(userId, mockApplies); + return mockApplies.stream() + .map(mockApply -> MockApplyHomeItemResponse.from( + mockApply, + activeAnalysisMockApplyIds.contains(mockApply.getId()) + )) + .toList(); + } + + private Set findActiveAnalysisMockApplyIds(Long userId, List mockApplies) { + List mockApplyIds = mockApplies.stream() + .filter(mockApply -> mockApply.getStatus() != MockApplyStatus.COMPLETED) + .map(MockApply::getId) + .toList(); + if (mockApplyIds.isEmpty()) { + return Set.of(); + } + return analysisAsyncTaskRepository.findByUserIdAndMockApplyIdInAndStatusIn( + userId, + mockApplyIds, + List.of(TaskStatus.PENDING, TaskStatus.RUNNING) + ) + .stream() + .map(task -> task.getMockApplyId()) + .collect(Collectors.toSet()); } @Transactional diff --git a/src/main/java/com/jobdri/jobdri_api/global/cohere/CohereConfig.java b/src/main/java/com/jobdri/jobdri_api/global/cohere/CohereConfig.java new file mode 100644 index 00000000..0d464ed7 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/cohere/CohereConfig.java @@ -0,0 +1,9 @@ +package com.jobdri.jobdri_api.global.cohere; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(CohereProperties.class) +public class CohereConfig { +} diff --git a/src/main/java/com/jobdri/jobdri_api/global/cohere/CohereEmbeddingClient.java b/src/main/java/com/jobdri/jobdri_api/global/cohere/CohereEmbeddingClient.java new file mode 100644 index 00000000..42d4b6de --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/cohere/CohereEmbeddingClient.java @@ -0,0 +1,300 @@ +package com.jobdri.jobdri_api.global.cohere; + +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.cohere.dto.CohereEmbeddingRequest; +import com.jobdri.jobdri_api.global.cohere.dto.CohereEmbeddingResponse; +import lombok.extern.slf4j.Slf4j; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.core5.util.Timeout; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import java.io.IOException; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.List; + +@Component +@Slf4j +public class CohereEmbeddingClient { + private static final int MAX_TEXTS_PER_REQUEST = 96; + private static final int MAX_TOTAL_CONNECTIONS = 100; + private static final int MAX_CONNECTIONS_PER_ROUTE = 20; + private static final int MAX_TRANSIENT_ATTEMPTS = 3; + private static final Duration INITIAL_RETRY_BACKOFF = Duration.ofMillis(200); + private static final Duration MAX_RETRY_BACKOFF = Duration.ofSeconds(2); + private static final String INPUT_TYPE_SEARCH_DOCUMENT = "search_document"; + private static final String INPUT_TYPE_SEARCH_QUERY = "search_query"; + private static final List FLOAT_EMBEDDING_TYPE = List.of("float"); + + private final CohereProperties properties; + private final RestClient restClient; + + public CohereEmbeddingClient(CohereProperties properties, RestClient.Builder restClientBuilder) { + this.properties = properties; + this.restClient = restClientBuilder + .baseUrl(properties.baseUrl()) + .requestFactory(requestFactory(properties)) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build(); + } + + public List embedDocuments(List texts) { + return embed(texts, INPUT_TYPE_SEARCH_DOCUMENT); + } + + public float[] embedQuery(String text) { + if (!StringUtils.hasText(text)) { + throw invalidParameter("검색 질의 텍스트는 필수입니다."); + } + List embeddings = embed(List.of(text), INPUT_TYPE_SEARCH_QUERY); + if (embeddings.isEmpty()) { + throw unavailable("Cohere 검색 질의 임베딩 응답이 비어 있습니다."); + } + return embeddings.getFirst(); + } + + private List embed(List texts, String inputType) { + validateApiKey(); + List normalizedTexts = validateTexts(texts); + CohereEmbeddingRequest request = new CohereEmbeddingRequest( + properties.embedding().model(), + normalizedTexts, + inputType, + FLOAT_EMBEDDING_TYPE, + properties.embedding().dimension() + ); + + CohereEmbeddingResponse response = callCohere(request); + return validateResponse(response, normalizedTexts.size()); + } + + private CohereEmbeddingResponse callCohere(CohereEmbeddingRequest request) { + Duration backoff = INITIAL_RETRY_BACKOFF; + for (int attempt = 1; attempt <= MAX_TRANSIENT_ATTEMPTS; attempt++) { + try { + return callCohereOnce(request); + } catch (TransientCohereException e) { + if (attempt == MAX_TRANSIENT_ATTEMPTS) { + throw unavailable("Cohere Embed API가 일시적으로 응답할 수 없습니다.", e); + } + Duration delay = e.retryAfter() != null ? e.retryAfter() : backoff; + log.warn( + "Cohere Embed API transient failure. attempt={}, maxAttempts={}, retryAfterMs={}, message={}", + attempt, + MAX_TRANSIENT_ATTEMPTS, + delay.toMillis(), + e.getMessage() + ); + sleepBeforeRetry(delay); + backoff = nextBackoff(backoff); + } + } + throw unavailable("Cohere Embed API가 일시적으로 응답할 수 없습니다."); + } + + private CohereEmbeddingResponse callCohereOnce(CohereEmbeddingRequest request) { + try { + return restClient.post() + .uri("/v2/embed") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + properties.apiKey()) + .body(request) + .retrieve() + .onStatus( + status -> status.value() == 429 || status.is5xxServerError(), + (ignoredRequest, response) -> { + throw new TransientCohereException( + "Cohere Embed API transient status=" + response.getStatusCode().value(), + retryAfter(response) + ); + } + ) + .onStatus( + status -> status.value() == 400 + || status.value() == 401 + || status.value() == 403 + || status.is4xxClientError(), + (ignoredRequest, ignoredResponse) -> { + throw invalidParameter("Cohere Embed API 요청 또는 설정이 올바르지 않습니다."); + } + ) + .body(CohereEmbeddingResponse.class); + } catch (GeneralException e) { + throw e; + } catch (ResourceAccessException e) { + log.warn("Cohere Embed API access failed. reason=resource_access_failure, message={}", e.getMessage()); + throw new GeneralException( + GeneralErrorCode.EXTERNAL_SERVICE_TIMEOUT, + "Cohere Embed API 응답이 지연되었거나 연결할 수 없습니다.", + e + ); + } catch (RestClientException e) { + log.warn("Cohere Embed API call failed. reason=rest_client_failure, message={}", e.getMessage()); + throw unavailable("Cohere Embed API 호출에 실패했습니다.", e); + } + } + + private List validateResponse(CohereEmbeddingResponse response, int expectedCount) { + if (response == null || response.embeddings() == null || response.embeddings().floatValues() == null + || response.embeddings().floatValues().isEmpty()) { + throw unavailable("Cohere 임베딩 응답이 비어 있습니다."); + } + + List> embeddings = response.embeddings().floatValues(); + if (embeddings.size() != expectedCount) { + throw unavailable("Cohere 임베딩 응답 개수가 요청 개수와 일치하지 않습니다."); + } + + List result = new ArrayList<>(); + for (List embedding : embeddings) { + if (embedding == null || embedding.size() != properties.embedding().dimension()) { + throw unavailable("Cohere 임베딩 차원이 설정값과 일치하지 않습니다."); + } + float[] vector = new float[embedding.size()]; + for (int i = 0; i < embedding.size(); i++) { + Double value = embedding.get(i); + if (value == null) { + throw unavailable("Cohere 임베딩 벡터에 비어 있는 값이 포함되어 있습니다."); + } + vector[i] = value.floatValue(); + } + result.add(vector); + } + return result; + } + + private void validateApiKey() { + if (!properties.hasApiKey()) { + throw new GeneralException( + GeneralErrorCode.SERVICE_UNAVAILABLE, + "Cohere API 키가 설정되지 않았습니다." + ); + } + } + + private List validateTexts(List texts) { + if (texts == null || texts.isEmpty()) { + throw invalidParameter("임베딩할 텍스트는 1개 이상이어야 합니다."); + } + if (texts.size() > MAX_TEXTS_PER_REQUEST) { + throw invalidParameter("Cohere 임베딩은 한 번에 최대 96개 텍스트만 요청할 수 있습니다."); + } + + List normalizedTexts = new ArrayList<>(); + for (String text : texts) { + if (!StringUtils.hasText(text)) { + throw invalidParameter("임베딩할 텍스트는 비어 있을 수 없습니다."); + } + normalizedTexts.add(text.trim()); + } + return List.copyOf(normalizedTexts); + } + + private static HttpComponentsClientHttpRequestFactory requestFactory(CohereProperties properties) { + RequestConfig requestConfig = RequestConfig.custom() + .setConnectTimeout(timeout(properties.embedding().connectTimeout())) + .setResponseTimeout(timeout(properties.embedding().readTimeout())) + .build(); + PoolingHttpClientConnectionManager connectionManager = PoolingHttpClientConnectionManagerBuilder.create() + .setMaxConnTotal(MAX_TOTAL_CONNECTIONS) + .setMaxConnPerRoute(MAX_CONNECTIONS_PER_ROUTE) + .build(); + CloseableHttpClient httpClient = HttpClients.custom() + .setConnectionManager(connectionManager) + .setDefaultRequestConfig(requestConfig) + .build(); + HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient); + requestFactory.setConnectionRequestTimeout(properties.embedding().connectTimeout()); + requestFactory.setReadTimeout(properties.embedding().readTimeout()); + return requestFactory; + } + + private static Timeout timeout(Duration duration) { + return Timeout.ofMilliseconds(duration.toMillis()); + } + + private static Duration retryAfter(ClientHttpResponse response) throws IOException { + String value = response.getHeaders().getFirst(HttpHeaders.RETRY_AFTER); + if (!StringUtils.hasText(value)) { + return null; + } + try { + long seconds = Long.parseLong(value.trim()); + return seconds <= 0 ? Duration.ZERO : Duration.ofSeconds(seconds); + } catch (NumberFormatException ignored) { + try { + Duration duration = Duration.between(OffsetDateTime.now(), OffsetDateTime.parse(value.trim())); + return duration.isNegative() ? Duration.ZERO : duration; + } catch (DateTimeParseException ignoredDate) { + try { + Duration duration = Duration.between( + ZonedDateTime.now(), + ZonedDateTime.parse(value.trim(), DateTimeFormatter.RFC_1123_DATE_TIME) + ); + return duration.isNegative() ? Duration.ZERO : duration; + } catch (DateTimeParseException ignoredHttpDate) { + return null; + } + } + } + } + + private static Duration nextBackoff(Duration current) { + Duration next = current.multipliedBy(2); + return next.compareTo(MAX_RETRY_BACKOFF) > 0 ? MAX_RETRY_BACKOFF : next; + } + + private static void sleepBeforeRetry(Duration delay) { + try { + Thread.sleep(delay.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new GeneralException( + GeneralErrorCode.SERVICE_UNAVAILABLE, + "Cohere Embed API 재시도 대기 중 인터럽트되었습니다.", + e + ); + } + } + + private GeneralException invalidParameter(String message) { + return new GeneralException(GeneralErrorCode.INVALID_PARAMETER, message); + } + + private GeneralException unavailable(String message) { + return new GeneralException(GeneralErrorCode.SERVICE_UNAVAILABLE, message); + } + + private GeneralException unavailable(String message, Throwable cause) { + return new GeneralException(GeneralErrorCode.SERVICE_UNAVAILABLE, message, cause); + } + + private static final class TransientCohereException extends RuntimeException { + private final Duration retryAfter; + + private TransientCohereException(String message, Duration retryAfter) { + super(message); + this.retryAfter = retryAfter; + } + + private Duration retryAfter() { + return retryAfter; + } + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/global/cohere/CohereProperties.java b/src/main/java/com/jobdri/jobdri_api/global/cohere/CohereProperties.java new file mode 100644 index 00000000..e1dca3cc --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/cohere/CohereProperties.java @@ -0,0 +1,46 @@ +package com.jobdri.jobdri_api.global.cohere; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +@ConfigurationProperties(prefix = "cohere") +public record CohereProperties( + String apiKey, + String baseUrl, + Embedding embedding +) { + private static final String DEFAULT_BASE_URL = "https://api.cohere.com"; + + public CohereProperties { + baseUrl = hasText(baseUrl) ? baseUrl : DEFAULT_BASE_URL; + embedding = embedding == null ? new Embedding(null, null, null, null) : embedding; + } + + boolean hasApiKey() { + return hasText(apiKey); + } + + public record Embedding( + String model, + Integer dimension, + Duration connectTimeout, + Duration readTimeout + ) { + private static final String DEFAULT_MODEL = "embed-v4.0"; + private static final int DEFAULT_DIMENSION = 1024; + private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(3); + private static final Duration DEFAULT_READ_TIMEOUT = Duration.ofSeconds(15); + + public Embedding { + model = hasText(model) ? model : DEFAULT_MODEL; + dimension = dimension == null ? DEFAULT_DIMENSION : dimension; + connectTimeout = connectTimeout == null ? DEFAULT_CONNECT_TIMEOUT : connectTimeout; + readTimeout = readTimeout == null ? DEFAULT_READ_TIMEOUT : readTimeout; + } + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/global/cohere/dto/CohereEmbeddingRequest.java b/src/main/java/com/jobdri/jobdri_api/global/cohere/dto/CohereEmbeddingRequest.java new file mode 100644 index 00000000..8fc67994 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/cohere/dto/CohereEmbeddingRequest.java @@ -0,0 +1,17 @@ +package com.jobdri.jobdri_api.global.cohere.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public record CohereEmbeddingRequest( + String model, + List texts, + @JsonProperty("input_type") + String inputType, + @JsonProperty("embedding_types") + List embeddingTypes, + @JsonProperty("output_dimension") + Integer outputDimension +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/global/cohere/dto/CohereEmbeddingResponse.java b/src/main/java/com/jobdri/jobdri_api/global/cohere/dto/CohereEmbeddingResponse.java new file mode 100644 index 00000000..72aac3f0 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/cohere/dto/CohereEmbeddingResponse.java @@ -0,0 +1,15 @@ +package com.jobdri.jobdri_api.global.cohere.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public record CohereEmbeddingResponse( + Embeddings embeddings +) { + public record Embeddings( + @JsonProperty("float") + List> floatValues + ) { + } +} diff --git a/src/main/resources/application-analysis-eval.yaml b/src/main/resources/application-analysis-eval.yaml index d36333ff..9101fc91 100644 --- a/src/main/resources/application-analysis-eval.yaml +++ b/src/main/resources/application-analysis-eval.yaml @@ -85,6 +85,15 @@ evaluation: output: "" review-output: "" +cohere: + api-key: ${COHERE_API_KEY:} + base-url: ${COHERE_BASE_URL:https://api.cohere.com} + embedding: + model: ${COHERE_EMBEDDING_MODEL:embed-v4.0} + dimension: ${COHERE_EMBEDDING_DIMENSION:1024} + connect-timeout: ${COHERE_EMBEDDING_CONNECT_TIMEOUT:3s} + read-timeout: ${COHERE_EMBEDDING_READ_TIMEOUT:15s} + jwt: secret: key: ${JWT_SECRET_KEY:am9iZHJpLWFuYWx5c2lzLWV2YWwtbG9jYWwtc2VjcmV0LWtleQ==} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 0121e489..0dc9f988 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -116,9 +116,6 @@ app: allowed-root: ${APP_CORPUS_IMPORT_ALLOWED_ROOT:} embedding: sync-on-startup: ${APP_CORPUS_EMBEDDING_SYNC_ON_STARTUP:false} - model: ${APP_CORPUS_EMBEDDING_MODEL:embed-v4.0} - output-dimension: ${APP_CORPUS_EMBEDDING_OUTPUT_DIMENSION:1024} - document-input-type: ${APP_CORPUS_EMBEDDING_DOCUMENT_INPUT_TYPE:search_document} batch-size: ${APP_CORPUS_EMBEDDING_BATCH_SIZE:32} analysis: retrieval: @@ -163,8 +160,13 @@ openai: job-posting-extractor: ${OPENAI_JOB_POSTING_MODEL:gpt-4o-mini} cohere: - api: - key: ${COHERE_API_KEY:} + api-key: ${COHERE_API_KEY:} + base-url: ${COHERE_BASE_URL:https://api.cohere.com} + embedding: + model: ${COHERE_EMBEDDING_MODEL:embed-v4.0} + dimension: ${COHERE_EMBEDDING_DIMENSION:1024} + connect-timeout: ${COHERE_EMBEDDING_CONNECT_TIMEOUT:3s} + read-timeout: ${COHERE_EMBEDDING_READ_TIMEOUT:15s} payment: coupon: diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 65b8406a..5ad569e0 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -116,9 +116,6 @@ app: allowed-root: ${APP_CORPUS_IMPORT_ALLOWED_ROOT:} embedding: sync-on-startup: ${APP_CORPUS_EMBEDDING_SYNC_ON_STARTUP:false} - model: ${APP_CORPUS_EMBEDDING_MODEL:embed-v4.0} - output-dimension: ${APP_CORPUS_EMBEDDING_OUTPUT_DIMENSION:1024} - document-input-type: ${APP_CORPUS_EMBEDDING_DOCUMENT_INPUT_TYPE:search_document} batch-size: ${APP_CORPUS_EMBEDDING_BATCH_SIZE:32} analysis: retrieval: @@ -164,8 +161,13 @@ openai: job-posting-extractor: ${OPENAI_JOB_POSTING_MODEL:gpt-4o-mini} cohere: - api: - key: ${COHERE_API_KEY:} + api-key: ${COHERE_API_KEY:} + base-url: ${COHERE_BASE_URL:https://api.cohere.com} + embedding: + model: ${COHERE_EMBEDDING_MODEL:embed-v4.0} + dimension: ${COHERE_EMBEDDING_DIMENSION:1024} + connect-timeout: ${COHERE_EMBEDDING_CONNECT_TIMEOUT:3s} + read-timeout: ${COHERE_EMBEDDING_READ_TIMEOUT:15s} payment: coupon: diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index baf65fed..8dadec32 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -19,6 +19,15 @@ CREATE TABLE IF NOT EXISTS mock_question_embeddings ( updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS job_posting_embeddings ( + id BIGSERIAL PRIMARY KEY, + job_posting_id BIGINT NOT NULL UNIQUE REFERENCES job_postings(id) ON DELETE CASCADE, + embedding_model VARCHAR(100) NOT NULL, + embedding vector(1024) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + CREATE INDEX IF NOT EXISTS idx_job_postings_company_detail ON job_postings (company_id, detail_classification_id); @@ -34,12 +43,21 @@ CREATE INDEX IF NOT EXISTS idx_mock_job_posting_embeddings_corpus CREATE INDEX IF NOT EXISTS idx_mock_question_embeddings_corpus ON mock_question_embeddings (corpus_id); +CREATE INDEX IF NOT EXISTS idx_job_posting_embeddings_job_posting + ON job_posting_embeddings (job_posting_id); + CREATE INDEX IF NOT EXISTS idx_mock_job_posting_embeddings_hnsw ON mock_job_posting_embeddings USING hnsw (embedding vector_cosine_ops); CREATE INDEX IF NOT EXISTS idx_mock_question_embeddings_hnsw ON mock_question_embeddings USING hnsw (embedding vector_cosine_ops); +CREATE INDEX IF NOT EXISTS idx_job_posting_embeddings_hnsw + ON job_posting_embeddings USING hnsw (embedding vector_cosine_ops); + +CREATE INDEX IF NOT EXISTS idx_analysis_async_tasks_user_mock_apply_status + ON analysis_async_tasks (user_id, mock_apply_id, status); + ALTER TABLE IF EXISTS analyses ADD COLUMN IF NOT EXISTS missing_keywords TEXT NOT NULL DEFAULT '[]'; @@ -85,6 +103,12 @@ ALTER TABLE IF EXISTS analysis_async_tasks ALTER TABLE IF EXISTS analysis_async_tasks ADD COLUMN IF NOT EXISTS estimated_remaining_seconds INTEGER; +ALTER TABLE IF EXISTS analysis_async_tasks + ADD COLUMN IF NOT EXISTS execution_context_snapshot TEXT; + +ALTER TABLE IF EXISTS analysis_async_tasks + ADD COLUMN IF NOT EXISTS input_fingerprint_snapshot VARCHAR(64); + ALTER TABLE IF EXISTS job_posting_async_tasks ADD COLUMN IF NOT EXISTS cancel_requested BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponseTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponseTest.java new file mode 100644 index 00000000..029670e7 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponseTest.java @@ -0,0 +1,76 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.worker; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class AnalysisWorkerContextResponseTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + @DisplayName("worker context JSON에 유사 공고 context를 포함하고 embedding과 소유자 정보는 노출하지 않는다") + void serializeSimilarJobPostings() throws Exception { + SimilarJobPostingContext similarJobPosting = new SimilarJobPostingContext( + 31L, + "유사 회사", + "유사 공고", + "서버 개발자", + "API 개발", + "Java", + "AWS", + 1, + 0.91 + ); + AnalysisWorkerContextResponse response = new AnalysisWorkerContextResponse( + 1L, + 10L, + "현재 회사", + "백엔드 개발자", + "현재 업무", + "현재 자격", + "현재 우대", + "개발", + "서버", + "백엔드", + List.of(), + List.of(similarJobPosting) + ); + + JsonNode json = objectMapper.readTree(objectMapper.writeValueAsString(response)); + + assertThat(json.path("similarJobPostings").size()).isEqualTo(1); + assertThat(json.path("similarJobPostings").get(0).path("jobPostingId").asLong()).isEqualTo(31L); + assertThat(json.path("similarJobPostings").get(0).path("similarityRank").asInt()).isEqualTo(1); + assertThat(json.path("similarJobPostings").get(0).has("embedding")).isFalse(); + assertThat(json.path("similarJobPostings").get(0).has("userId")).isFalse(); + } + + @Test + @DisplayName("기존 생성자는 유사 공고를 빈 배열로 직렬화한다") + void oldConstructorDefaultsSimilarJobPostingsToEmptyList() throws Exception { + AnalysisWorkerContextResponse response = new AnalysisWorkerContextResponse( + 1L, + 10L, + "현재 회사", + "백엔드 개발자", + "현재 업무", + "현재 자격", + "현재 우대", + "개발", + "서버", + "백엔드", + List.of() + ); + + JsonNode json = objectMapper.readTree(objectMapper.writeValueAsString(response)); + + assertThat(json.path("similarJobPostings").isArray()).isTrue(); + assertThat(json.path("similarJobPostings").size()).isZero(); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java index efe840b5..8069d95f 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java @@ -1,12 +1,16 @@ package com.jobdri.jobdri_api.domain.analysis.service.async; +import com.fasterxml.jackson.databind.ObjectMapper; import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerCompleteRequest; import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerResultStoreRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisExecutionPayload; +import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisInputFingerprintProvider; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; import com.jobdri.jobdri_api.domain.company.entity.Company; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; @@ -21,12 +25,14 @@ import org.mockito.InjectMocks; import org.mockito.InOrder; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; import java.util.Optional; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -34,6 +40,7 @@ import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -55,6 +62,12 @@ class AnalysisWorkerBridgeServiceTest { @Mock private WorkerTaskResultService workerTaskResultService; + @Mock + private AnalysisInputFingerprintProvider analysisInputFingerprintProvider; + + @Spy + private ObjectMapper objectMapper = new ObjectMapper(); + @InjectMocks private AnalysisWorkerBridgeService analysisWorkerBridgeService; @@ -133,23 +146,37 @@ void getContextReservesCreditBeforePreparingExecution() { when(jobPosting.getDetailClassification().getMiddleClassification().getMiddleName()).thenReturn("서버"); when(jobPosting.getDetailClassification().getMiddleClassification().getClassification().getBigName()).thenReturn("개발"); + SimilarJobPostingContext similarContext = new SimilarJobPostingContext( + 31L, + "유사 회사", + "유사 공고", + "서버 개발자", + "API 개발", + "Java", + "AWS", + 1, + 0.91 + ); AnalysisExecutionPayload payload = new AnalysisExecutionPayload( 1L, 10L, jobPosting, List.of(), - List.of() + List.of(), + null, + null, + List.of(similarContext) ); when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); when(userService.getUser(1L)).thenReturn(user); when(analysisService.prepareAnalysisExecution(user, 10L)).thenReturn(payload); - - analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L); + var context = analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L); verify(analysisService).deductAnalysisCredit(user, "analysisTaskId=" + task.getTaskId()); verify(analysisAsyncTaskService).markCreditReserved(task.getTaskId(), "analysisTaskId=" + task.getTaskId()); verify(analysisService).prepareAnalysisExecution(user, 10L); + assertThat(context.similarJobPostings()).containsExactly(similarContext); } @Test @@ -190,6 +217,68 @@ void getContextDoesNotReserveCreditTwice() { verify(analysisService).prepareAnalysisExecution(user, 10L); } + @Test + @DisplayName("완료 시 최초 worker context snapshot과 fingerprint를 재사용한다") + void completeTaskReusesInitialExecutionSnapshot() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + User user = User.signup("테스트 사용자", "analysis-snapshot@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + JobPosting jobPosting = mock(JobPosting.class, org.mockito.Answers.RETURNS_DEEP_STUBS); + Company company = mock(Company.class); + when(company.getName()).thenReturn("잡드리"); + when(jobPosting.getCompany()).thenReturn(company); + when(jobPosting.getTask()).thenReturn("백엔드 개발"); + when(jobPosting.getRequirement()).thenReturn("Spring"); + when(jobPosting.getPreferred()).thenReturn("RabbitMQ"); + when(jobPosting.getDetailClassification().getDetailName()).thenReturn("백엔드"); + when(jobPosting.getDetailClassification().getMiddleClassification().getMiddleName()).thenReturn("서버"); + when(jobPosting.getDetailClassification().getMiddleClassification().getClassification().getBigName()).thenReturn("개발"); + AnalysisExecutionPayload initialPayload = new AnalysisExecutionPayload( + 1L, 10L, jobPosting, List.of(), List.of(), null, null, List.of() + ); + SimilarJobPostingContext laterContext = new SimilarJobPostingContext( + 31L, "유사 회사", "유사 공고", "서버 개발자", "API 개발", "Java", "AWS", 1, 0.91 + ); + AnalysisExecutionPayload completionPayload = new AnalysisExecutionPayload( + 1L, 10L, jobPosting, List.of(), List.of(), null, null, List.of() + ); + AnalysisLlmResponse llmResponse = mock(AnalysisLlmResponse.class); + AnalysisResponse analysisResponse = mock(AnalysisResponse.class); + AnalysisWorkerCompleteRequest request = new AnalysisWorkerCompleteRequest( + 1L, 10L, llmResponse, "worker-1", 10L + ); + + when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + when(userService.getUser(1L)).thenReturn(user); + AnalysisExecutionPayload changedRetrievalPayload = new AnalysisExecutionPayload( + 1L, 10L, jobPosting, List.of(), List.of(), null, null, List.of(laterContext) + ); + when(analysisService.prepareAnalysisExecution(user, 10L)) + .thenReturn(initialPayload, changedRetrievalPayload); + when(analysisInputFingerprintProvider.create(initialPayload)).thenReturn("initial-fingerprint"); + when(analysisService.prepareAnalysisExecution(user, 10L, List.of())).thenReturn(completionPayload); + when(analysisService.finalizeAnalysis( + user, + 10L, + completionPayload, + llmResponse, + "initial-fingerprint" + )).thenReturn(analysisResponse); + + analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L); + analysisWorkerBridgeService.completeTask(task.getTaskId(), request); + + verify(analysisService, times(1)).prepareAnalysisExecution(user, 10L); + verify(analysisService).prepareAnalysisExecution(user, 10L, List.of()); + verify(analysisService).finalizeAnalysis( + user, + 10L, + completionPayload, + llmResponse, + "initial-fingerprint" + ); + } + @Test @DisplayName("완료된 작업은 running이나 retry로 되돌리지 않는다") void terminalTaskDoesNotReopen() { diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProviderTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProviderTest.java new file mode 100644 index 00000000..e7b43f10 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProviderTest.java @@ -0,0 +1,94 @@ +package com.jobdri.jobdri_api.domain.analysis.service.core; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; +import com.jobdri.jobdri_api.domain.analysis.service.ai.FewShotPromptProvider; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.global.cohere.CohereProperties; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AnalysisInputFingerprintProviderTest { + + private final FewShotPromptProvider fewShotPromptProvider = mock(FewShotPromptProvider.class); + private final AnalysisInputFingerprintProvider provider = new AnalysisInputFingerprintProvider( + new ObjectMapper(), + fewShotPromptProvider, + new CohereProperties(null, null, null), + "gpt-4o-mini", + false, + "", + 3, + 5 + ); + + @Test + @DisplayName("유사 공고 Prompt context가 달라지면 fingerprint가 달라진다") + void fingerprintChangesWhenSimilarJobPostingContextChanges() { + when(fewShotPromptProvider.getPrompt()).thenReturn("few-shot"); + JobPosting current = currentJobPosting(); + AnalysisExecutionPayload first = payload(current, similarContext("Spring Boot API 개발", 0.91)); + AnalysisExecutionPayload changed = payload(current, similarContext("Kotlin API 개발", 0.91)); + + assertThat(provider.create(first)).isNotEqualTo(provider.create(changed)); + } + + @Test + @DisplayName("유사도 점수의 미세한 변화는 fingerprint에 영향을 주지 않는다") + void fingerprintIgnoresSimilarityScore() { + when(fewShotPromptProvider.getPrompt()).thenReturn("few-shot"); + JobPosting current = currentJobPosting(); + AnalysisExecutionPayload first = payload(current, similarContext("Spring Boot API 개발", 0.910001)); + AnalysisExecutionPayload changedScore = payload(current, similarContext("Spring Boot API 개발", 0.909999)); + + assertThat(provider.create(first)).isEqualTo(provider.create(changedScore)); + } + + private AnalysisExecutionPayload payload(JobPosting jobPosting, SimilarJobPostingContext context) { + return new AnalysisExecutionPayload( + 1L, + 10L, + jobPosting, + List.of(), + List.of(), + null, + null, + List.of(context) + ); + } + + private SimilarJobPostingContext similarContext(String task, double score) { + return new SimilarJobPostingContext( + 31L, + "유사 회사", + "유사 공고", + "서버 개발자", + task, + "Java", + "AWS", + 1, + score + ); + } + + private JobPosting currentJobPosting() { + JobPosting posting = mock(JobPosting.class, org.mockito.Answers.RETURNS_DEEP_STUBS); + when(posting.getCompany().getName()).thenReturn("현재 회사"); + when(posting.getDetailClassification().getMiddleClassification().getClassification().getBigName()) + .thenReturn("개발"); + when(posting.getDetailClassification().getMiddleClassification().getMiddleName()).thenReturn("서버"); + when(posting.getDetailClassification().getDetailName()).thenReturn("백엔드"); + when(posting.getPostingName()).thenReturn("현재 공고"); + when(posting.getJobTitle()).thenReturn("백엔드 개발자"); + when(posting.getTask()).thenReturn("API 개발"); + when(posting.getRequirement()).thenReturn("Java"); + when(posting.getPreferred()).thenReturn("AWS"); + return posting; + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java index 0de757cb..33aae358 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java @@ -2,6 +2,7 @@ import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; @@ -10,6 +11,7 @@ import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisAiClient; +import com.jobdri.jobdri_api.domain.analysis.service.retrieval.JobPostingRagContextAssembler; import com.jobdri.jobdri_api.domain.classification.entity.Classification; import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; import com.jobdri.jobdri_api.domain.classification.entity.MiddleClassification; @@ -106,10 +108,15 @@ class AnalysisServiceTest { @MockBean private CorpusRetrievalService corpusRetrievalService; + @MockBean + private JobPostingRagContextAssembler jobPostingRagContextAssembler; + @BeforeEach void setUp() { lenient().when(corpusRetrievalService.retrieveForAnalysis(any(), any())) .thenReturn(emptyRetrievalContext()); + lenient().when(jobPostingRagContextAssembler.assemble(any())) + .thenReturn(List.of()); lenient().when(analysisAiClient.analyze(any(AnalysisExecutionPayload.class))) .thenAnswer(invocation -> { AnalysisExecutionPayload payload = invocation.getArgument(0); @@ -986,11 +993,25 @@ void prepareAnalysisExecutionIncludesJobCategoryEvaluationCriteria() { ); MockApply mockApply = mockApplyRepository.save(MockApply.create(user, jobPosting, ApplyType.ACTUAL)); saveQuestion(mockApply, "지원 직무 경험", "Spring Boot API를 개발했습니다."); + SimilarJobPostingContext similarContext = new SimilarJobPostingContext( + 31L, + "유사 회사", + "유사 공고", + "서버 개발자", + "API 개발", + "Java", + "AWS", + 1, + 0.91 + ); + when(jobPostingRagContextAssembler.assemble(jobPosting.getId())).thenReturn(List.of(similarContext)); AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(user, mockApply.getId()); assertThat(payload.jobCategoryEvaluationCriteria()).isNotNull(); assertThat(payload.jobCategoryEvaluationCriteria().jobCategoryMiddle()).isEqualTo("AI·개발·데이터"); + assertThat(payload.similarJobPostings()).containsExactly(similarContext); + verify(jobPostingRagContextAssembler).assemble(jobPosting.getId()); } @Test diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/retrieval/JobPostingRagContextAssemblerTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/retrieval/JobPostingRagContextAssemblerTest.java new file mode 100644 index 00000000..3b264fdc --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/retrieval/JobPostingRagContextAssemblerTest.java @@ -0,0 +1,136 @@ +package com.jobdri.jobdri_api.domain.analysis.service.retrieval; + +import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingSimilarityResult; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; +import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingRetrievalService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class JobPostingRagContextAssemblerTest { + + private final JobPostingRetrievalService retrievalService = mock(JobPostingRetrievalService.class); + private final JobPostingRepository jobPostingRepository = mock(JobPostingRepository.class); + private final JobPostingRagContextAssembler assembler = new JobPostingRagContextAssembler( + retrievalService, + jobPostingRepository + ); + + @Test + @DisplayName("검색 순서를 유지하며 Top3 유사 공고의 분석 필드를 조립한다") + void assembleTop3InSimilarityOrder() { + JobPosting postingA = posting(21L, "업무 A", "자격 A", "우대 A"); + JobPosting postingB = posting(22L, "업무 B", "자격 B", "우대 B"); + JobPosting postingC = posting(23L, "업무 C", "자격 C", "우대 C"); + when(retrievalService.findSimilarJobPostings(10L, 3)).thenReturn(List.of( + result(21L, "회사 A", "공고 A", "백엔드 A", 0.91), + result(22L, "회사 B", "공고 B", "백엔드 B", 0.82), + result(23L, "회사 C", "공고 C", "백엔드 C", 0.73), + result(24L, "회사 D", "공고 D", "백엔드 D", 0.64) + )); + when(jobPostingRepository.findAllById(anyList())).thenReturn(List.of( + postingC, + postingA, + postingB + )); + + List contexts = assembler.assemble(10L); + + assertThat(contexts).extracting(SimilarJobPostingContext::jobPostingId) + .containsExactly(21L, 22L, 23L); + assertThat(contexts).extracting(SimilarJobPostingContext::similarityRank) + .containsExactly(1, 2, 3); + assertThat(contexts.getFirst().task()).isEqualTo("업무 A"); + assertThat(contexts.getFirst().requirements()).isEqualTo("자격 A"); + assertThat(contexts.getFirst().preferredQualifications()).isEqualTo("우대 A"); + + verify(jobPostingRepository).findAllById(List.of(21L, 22L, 23L)); + } + + @Test + @DisplayName("빈 필드를 정규화하고 긴 필드를 제한한다") + void normalizeBlankAndTruncateLongFields() { + String longTask = "업무 ".repeat(500); + JobPosting posting = posting(21L, longTask, " ", null); + when(retrievalService.findSimilarJobPostings(10L, 3)) + .thenReturn(List.of(result(21L, " 회사 ", " 공고 ", " 직무 ", 0.9))); + when(jobPostingRepository.findAllById(anyList())) + .thenReturn(List.of(posting)); + + SimilarJobPostingContext context = assembler.assemble(10L).getFirst(); + + assertThat(context.companyName()).isEqualTo("회사"); + assertThat(context.task().length()) + .isLessThanOrEqualTo(JobPostingRagContextAssembler.MAX_CONTEXT_FIELD_LENGTH); + assertThat(context.requirements()).isEmpty(); + assertThat(context.preferredQualifications()).isEmpty(); + } + + @Test + @DisplayName("Retrieval 실패 시 빈 context로 분석을 계속한다") + void failOpenWhenRetrievalFails() { + when(retrievalService.findSimilarJobPostings(10L, 3)) + .thenThrow(new IllegalStateException("pgvector unavailable")); + + assertThat(assembler.assemble(10L)).isEmpty(); + } + + @Test + @DisplayName("상세 조회에서 누락된 공고만 제외하고 원래 검색 순위를 유지한다") + void excludesMissingPostingDetailsWithoutCompactingRanks() { + JobPosting postingA = posting(21L, "업무 A", "자격 A", "우대 A"); + JobPosting postingC = posting(23L, "업무 C", "자격 C", "우대 C"); + when(retrievalService.findSimilarJobPostings(10L, 3)).thenReturn(List.of( + result(21L, "회사 A", "공고 A", "백엔드 A", 0.91), + result(22L, "회사 B", "공고 B", "백엔드 B", 0.82), + result(23L, "회사 C", "공고 C", "백엔드 C", 0.73) + )); + when(jobPostingRepository.findAllById(anyList())).thenReturn(List.of(postingC, postingA)); + + List contexts = assembler.assemble(10L); + + assertThat(contexts).extracting(SimilarJobPostingContext::jobPostingId) + .containsExactly(21L, 23L); + assertThat(contexts).extracting(SimilarJobPostingContext::similarityRank) + .containsExactly(1, 3); + } + + @Test + @DisplayName("유사 공고 검색 결과가 비어 있으면 상세 조회를 호출하지 않는다") + void skipsDetailLookupWhenRetrievalIsEmpty() { + when(retrievalService.findSimilarJobPostings(10L, 3)).thenReturn(List.of()); + + assertThat(assembler.assemble(10L)).isEmpty(); + + verify(jobPostingRepository, never()).findAllById(anyList()); + } + + private JobPostingSimilarityResult result( + Long id, + String companyName, + String postingName, + String jobTitle, + double score + ) { + return new JobPostingSimilarityResult(id, postingName, companyName, jobTitle, score); + } + + private JobPosting posting(Long id, String task, String requirement, String preferred) { + JobPosting posting = mock(JobPosting.class); + when(posting.getId()).thenReturn(id); + when(posting.getTask()).thenReturn(task); + when(posting.getRequirement()).thenReturn(requirement); + when(posting.getPreferred()).thenReturn(preferred); + return posting; + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/corpus/service/CohereCorpusEmbeddingClientTest.java b/src/test/java/com/jobdri/jobdri_api/domain/corpus/service/CohereCorpusEmbeddingClientTest.java new file mode 100644 index 00000000..a2497632 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/corpus/service/CohereCorpusEmbeddingClientTest.java @@ -0,0 +1,45 @@ +package com.jobdri.jobdri_api.domain.corpus.service; + +import com.jobdri.jobdri_api.global.cohere.CohereEmbeddingClient; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class CohereCorpusEmbeddingClientTest { + + @Test + @DisplayName("문서 임베딩은 전역 CohereEmbeddingClient의 embedDocuments에 위임한다") + void delegatesDocumentEmbeddingToGlobalClient() { + CohereEmbeddingClient globalClient = mock(CohereEmbeddingClient.class); + List texts = List.of("문서 1", "문서 2"); + List embeddings = List.of(new float[]{1.0f}, new float[]{2.0f}); + when(globalClient.embedDocuments(texts)).thenReturn(embeddings); + + CohereCorpusEmbeddingClient client = new CohereCorpusEmbeddingClient(globalClient); + + assertThat(client.embed(texts, CorpusEmbeddingClient.InputType.SEARCH_DOCUMENT)) + .isSameAs(embeddings); + verify(globalClient).embedDocuments(texts); + } + + @Test + @DisplayName("검색 쿼리 임베딩은 전역 CohereEmbeddingClient의 embedQuery에 위임한다") + void delegatesQueryEmbeddingToGlobalClient() { + CohereEmbeddingClient globalClient = mock(CohereEmbeddingClient.class); + when(globalClient.embedQuery("검색 질의")).thenReturn(new float[]{1.0f, 2.0f}); + + CohereCorpusEmbeddingClient client = new CohereCorpusEmbeddingClient(globalClient); + + List result = client.embed(List.of("검색 질의"), CorpusEmbeddingClient.InputType.SEARCH_QUERY); + + assertThat(result).hasSize(1); + assertThat(result.getFirst()).containsExactly(1.0f, 2.0f); + verify(globalClient).embedQuery("검색 질의"); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingServiceTest.java new file mode 100644 index 00000000..a9e95f64 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingServiceTest.java @@ -0,0 +1,63 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.classification.entity.Classification; +import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import com.jobdri.jobdri_api.domain.company.entity.Company; +import com.jobdri.jobdri_api.domain.company.entity.CompanySize; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingProfileColor; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.entity.UserRole; +import com.jobdri.jobdri_api.global.cohere.CohereEmbeddingClient; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class JobPostingEmbeddingServiceTest { + + private final JobPostingEmbeddingTextBuilder textBuilder = new JobPostingEmbeddingTextBuilder(); + private final CohereEmbeddingClient cohereEmbeddingClient = mock(CohereEmbeddingClient.class); + private final JobPostingEmbeddingService embeddingService = + new JobPostingEmbeddingService(textBuilder, cohereEmbeddingClient); + + @Test + @DisplayName("채용 공고 embedding text를 Cohere search_document 요청으로 임베딩한다") + void embedAll() { + JobPosting first = jobPosting("백엔드 개발자", "Spring Boot API 개발", "Java", "AWS"); + JobPosting second = jobPosting("데이터 엔지니어", "ETL 파이프라인 개발", "Python", "Airflow"); + List expected = List.of(new float[]{0.1f, 0.2f}, new float[]{0.3f, 0.4f}); + when(cohereEmbeddingClient.embedDocuments(List.of( + textBuilder.build(first), + textBuilder.build(second) + ))).thenReturn(expected); + + List result = embeddingService.embedAll(List.of(first, second)); + + assertThat(result).isSameAs(expected); + verify(cohereEmbeddingClient).embedDocuments(List.of(textBuilder.build(first), textBuilder.build(second))); + } + + private JobPosting jobPosting(String jobTitle, String task, String requirement, String preferred) { + Classification classification = Classification.create("개발"); + DetailClassification detailClassification = classification + .addMiddleClassification("서버") + .addDetailClassification("백엔드"); + return JobPosting.create( + User.authenticatedPrincipal(1L, "user@example.com", UserRole.USER), + Company.create("테스트 기업", CompanySize.MEDIUM), + detailClassification, + JobPostingProfileColor.DEFAULT, + "테스트 공고", + jobTitle, + task, + requirement, + preferred + ); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingSyncServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingSyncServiceTest.java new file mode 100644 index 00000000..c093fe95 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingSyncServiceTest.java @@ -0,0 +1,155 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.classification.entity.Classification; +import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import com.jobdri.jobdri_api.domain.company.entity.Company; +import com.jobdri.jobdri_api.domain.company.entity.CompanySize; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingProfileColor; +import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.entity.UserRole; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.cohere.CohereProperties; +import com.pgvector.PGvector; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.test.util.ReflectionTestUtils; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.time.Duration; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class JobPostingEmbeddingSyncServiceTest { + + private final JobPostingRepository jobPostingRepository = mock(JobPostingRepository.class); + private final JobPostingEmbeddingService embeddingService = mock(JobPostingEmbeddingService.class); + private final DataSource dataSource = mock(DataSource.class); + private final JobPostingEmbeddingSyncService syncService = new JobPostingEmbeddingSyncService( + jobPostingRepository, + embeddingService, + new CohereProperties( + "test-api-key", + "https://api.cohere.com", + new CohereProperties.Embedding( + "embed-v4.0", + 1024, + Duration.ofSeconds(1), + Duration.ofSeconds(2) + ) + ), + dataSource + ); + + @Test + @DisplayName("syncJobPosting은 단일 채용 공고 embedding을 생성해 PGVector upsert로 저장한다") + void syncJobPosting() throws Exception { + JobPosting jobPosting = jobPosting(10L, "백엔드 개발자"); + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + when(jobPostingRepository.findById(10L)).thenReturn(Optional.of(jobPosting)); + when(embeddingService.embedAll(List.of(jobPosting))).thenReturn(List.of(new float[]{0.1f, 0.2f})); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + + try (MockedStatic pgvector = mockStatic(PGvector.class)) { + int processed = syncService.syncJobPosting(10L); + + assertThat(processed).isEqualTo(1); + pgvector.verify(() -> PGvector.registerTypes(connection)); + verify(connection).prepareStatement(org.mockito.ArgumentMatchers.contains("job_posting_embeddings")); + verify(statement).setLong(1, 10L); + verify(statement).setString(2, "embed-v4.0"); + verify(statement).setObject(eq(3), isA(PGvector.class)); + verify(statement).setTimestamp(eq(4), any()); + verify(statement).setTimestamp(eq(5), any()); + verify(statement).addBatch(); + verify(statement).executeBatch(); + } + } + + @Test + @DisplayName("syncAllJobPostings는 id 오름차순 전체 공고를 batch size대로 저장한다") + void syncAllJobPostings() throws Exception { + ReflectionTestUtils.setField(syncService, "batchSize", 1); + JobPosting first = jobPosting(1L, "백엔드 개발자"); + JobPosting second = jobPosting(2L, "데이터 엔지니어"); + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + when(jobPostingRepository.findAllByOrderByIdAsc()).thenReturn(List.of(first, second)); + when(embeddingService.embedAll(List.of(first))).thenReturn(List.of(new float[]{0.1f})); + when(embeddingService.embedAll(List.of(second))).thenReturn(List.of(new float[]{0.2f})); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + + try (MockedStatic ignored = mockStatic(PGvector.class)) { + int processed = syncService.syncAllJobPostings(); + + assertThat(processed).isEqualTo(2); + verify(embeddingService).embedAll(List.of(first)); + verify(embeddingService).embedAll(List.of(second)); + verify(statement).setLong(1, 1L); + verify(statement).setLong(1, 2L); + verify(statement, times(2)).executeBatch(); + } + } + + @Test + @DisplayName("임베딩 결과 개수가 채용 공고 개수와 다르면 저장하지 않는다") + void mismatchedEmbeddingCount() { + JobPosting jobPosting = jobPosting(10L, "백엔드 개발자"); + when(jobPostingRepository.findById(10L)).thenReturn(Optional.of(jobPosting)); + when(embeddingService.embedAll(List.of(jobPosting))).thenReturn(List.of()); + + assertThatThrownBy(() -> syncService.syncJobPosting(10L)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("채용 공고 개수"); + } + + @Test + @DisplayName("존재하지 않는 채용 공고는 명확한 예외를 발생시킨다") + void jobPostingNotFound() { + when(jobPostingRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> syncService.syncJobPosting(999L)) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.JOB_POSTING_NOT_FOUND)); + } + + private JobPosting jobPosting(Long id, String jobTitle) { + Classification classification = Classification.create("개발"); + DetailClassification detailClassification = classification + .addMiddleClassification("서버") + .addDetailClassification("백엔드"); + JobPosting jobPosting = JobPosting.create( + User.authenticatedPrincipal(1L, "user@example.com", UserRole.USER), + Company.create("테스트 기업", CompanySize.MEDIUM), + detailClassification, + JobPostingProfileColor.DEFAULT, + "테스트 공고", + jobTitle, + "Spring Boot API 개발", + "Java, Spring, JPA", + "AWS, Docker" + ); + ReflectionTestUtils.setField(jobPosting, "id", id); + return jobPosting; + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingTextBuilderTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingTextBuilderTest.java new file mode 100644 index 00000000..06c5b37c --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingTextBuilderTest.java @@ -0,0 +1,78 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.classification.entity.Classification; +import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import com.jobdri.jobdri_api.domain.company.entity.Company; +import com.jobdri.jobdri_api.domain.company.entity.CompanySize; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingProfileColor; +import com.jobdri.jobdri_api.domain.user.entity.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class JobPostingEmbeddingTextBuilderTest { + + private final JobPostingEmbeddingTextBuilder textBuilder = new JobPostingEmbeddingTextBuilder(); + + @Test + @DisplayName("채용 공고 필드를 corpus와 같은 단일 embedding text로 구성한다") + void build() { + JobPosting jobPosting = jobPosting( + "백엔드 개발자", + "Spring Boot API 개발\nMSA\nRabbitMQ", + "Java\nSpring\nJPA", + "AWS\nDocker" + ); + + String text = textBuilder.build(jobPosting); + + assertThat(text).isEqualTo(""" + 직무 + 백엔드 개발자 + + 주요업무 + Spring Boot API 개발 + MSA + RabbitMQ + + 자격요건 + Java + Spring + JPA + + 우대사항 + AWS + Docker"""); + } + + @Test + @DisplayName("blank 필드는 embedding text에서 제외한다") + void skipBlankFields() { + JobPosting jobPosting = jobPosting("백엔드 개발자", "Spring Boot API 개발", "Java", " "); + + String text = textBuilder.build(jobPosting); + + assertThat(text).doesNotContain("우대사항"); + assertThat(text).contains("직무\n백엔드 개발자", "주요업무\nSpring Boot API 개발", "자격요건\nJava"); + } + + private JobPosting jobPosting(String jobTitle, String task, String requirement, String preferred) { + Classification classification = Classification.create("개발"); + DetailClassification detailClassification = classification + .addMiddleClassification("서버") + .addDetailClassification("백엔드"); + return JobPosting.create( + User.authenticatedPrincipal(1L, "user@example.com", com.jobdri.jobdri_api.domain.user.entity.UserRole.USER), + Company.create("테스트 기업", CompanySize.MEDIUM), + detailClassification, + JobPostingProfileColor.DEFAULT, + "테스트 공고", + jobTitle, + task, + requirement, + preferred + ); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingRetrievalServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingRetrievalServiceTest.java new file mode 100644 index 00000000..2b47e58e --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingRetrievalServiceTest.java @@ -0,0 +1,199 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.classification.entity.Classification; +import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import com.jobdri.jobdri_api.domain.company.entity.Company; +import com.jobdri.jobdri_api.domain.company.entity.CompanySize; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingSimilarityResult; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingProfileColor; +import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.entity.UserRole; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.cohere.CohereEmbeddingClient; +import com.pgvector.PGvector; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.springframework.test.util.ReflectionTestUtils; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class JobPostingRetrievalServiceTest { + + private final JobPostingRepository jobPostingRepository = mock(JobPostingRepository.class); + private final JobPostingEmbeddingTextBuilder textBuilder = new JobPostingEmbeddingTextBuilder(); + private final CohereEmbeddingClient cohereEmbeddingClient = mock(CohereEmbeddingClient.class); + private final DataSource dataSource = mock(DataSource.class); + private final JobPostingRetrievalService retrievalService = new JobPostingRetrievalService( + jobPostingRepository, + textBuilder, + cohereEmbeddingClient, + dataSource + ); + + @Test + @DisplayName("현재 공고 embedding query로 유사 JobPosting Top3를 조회한다") + void findSimilarJobPostingsDefaultTop3() throws Exception { + JobPosting current = jobPosting(10L, 7L, "백엔드 개발자"); + float[] queryVector = new float[]{0.1f, 0.2f}; + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + ResultSet resultSet = mock(ResultSet.class); + when(jobPostingRepository.findById(10L)).thenReturn(Optional.of(current)); + when(cohereEmbeddingClient.embedQuery(textBuilder.build(current))).thenReturn(queryVector); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + when(statement.executeQuery()).thenReturn(resultSet); + when(resultSet.next()).thenReturn(true, true, false); + when(resultSet.getLong("id")).thenReturn(11L, 12L); + when(resultSet.getString("posting_name")).thenReturn("첫 번째 공고", "두 번째 공고"); + when(resultSet.getString("company_name")).thenReturn("첫 번째 회사", "두 번째 회사"); + when(resultSet.getString("job_title")).thenReturn("백엔드 엔지니어", "서버 개발자"); + when(resultSet.getDouble("distance")).thenReturn(0.12, 0.34); + + try (MockedStatic pgvector = mockStatic(PGvector.class)) { + List results = retrievalService.findSimilarJobPostings(10L); + + assertThat(results).containsExactly( + new JobPostingSimilarityResult(11L, "첫 번째 공고", "첫 번째 회사", "백엔드 엔지니어", 0.88), + new JobPostingSimilarityResult(12L, "두 번째 공고", "두 번째 회사", "서버 개발자", 0.6599999999999999) + ); + verify(cohereEmbeddingClient).embedQuery(textBuilder.build(current)); + pgvector.verify(() -> PGvector.registerTypes(connection)); + verify(statement).setObject(eq(1), isA(PGvector.class)); + verify(statement).setLong(2, 10L); + verify(statement).setLong(3, 7L); + verify(statement).setObject(eq(4), isA(PGvector.class)); + verify(statement).setInt(5, 3); + } + } + + @Test + @DisplayName("limit 인자를 SQL LIMIT에 바인딩하고 현재 공고를 제외한다") + void bindLimitAndExcludeCurrentJobPosting() throws Exception { + JobPosting current = jobPosting(20L, 8L, "데이터 엔지니어"); + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + ResultSet resultSet = mock(ResultSet.class); + when(jobPostingRepository.findById(20L)).thenReturn(Optional.of(current)); + when(cohereEmbeddingClient.embedQuery(textBuilder.build(current))).thenReturn(new float[]{0.3f}); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + when(statement.executeQuery()).thenReturn(resultSet); + when(resultSet.next()).thenReturn(false); + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + + try (MockedStatic ignored = mockStatic(PGvector.class)) { + List results = retrievalService.findSimilarJobPostings(20L, 5); + + assertThat(results).isEmpty(); + verify(connection).prepareStatement(sqlCaptor.capture()); + assertThat(sqlCaptor.getValue()) + .contains("FROM job_posting_embeddings e") + .contains("JOIN job_postings jp ON e.job_posting_id = jp.id") + .contains("jp.id <> ?") + .contains("jp.user_id = ?") + .contains("ORDER BY e.embedding <=> ?") + .contains("LIMIT ?"); + verify(statement).setLong(2, 20L); + verify(statement).setLong(3, 8L); + verify(statement).setInt(5, 5); + } + } + + @Test + @DisplayName("limit가 1보다 작으면 최소 1로 보정한다") + void minimumLimit() throws Exception { + JobPosting current = jobPosting(30L, 9L, "프론트엔드 개발자"); + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + ResultSet resultSet = mock(ResultSet.class); + when(jobPostingRepository.findById(30L)).thenReturn(Optional.of(current)); + when(cohereEmbeddingClient.embedQuery(textBuilder.build(current))).thenReturn(new float[]{0.4f}); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + when(statement.executeQuery()).thenReturn(resultSet); + when(resultSet.next()).thenReturn(false); + + try (MockedStatic ignored = mockStatic(PGvector.class)) { + retrievalService.findSimilarJobPostings(30L, 0); + + verify(statement).setInt(5, 1); + } + } + + @Test + @DisplayName("cosine distance가 1보다 크면 similarity score를 0으로 제한한다") + void clampNegativeSimilarityScoreToZero() throws Exception { + JobPosting current = jobPosting(40L, 10L, "백엔드 개발자"); + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + ResultSet resultSet = mock(ResultSet.class); + when(jobPostingRepository.findById(40L)).thenReturn(Optional.of(current)); + when(cohereEmbeddingClient.embedQuery(textBuilder.build(current))).thenReturn(new float[]{0.5f}); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + when(statement.executeQuery()).thenReturn(resultSet); + when(resultSet.next()).thenReturn(true, false); + when(resultSet.getLong("id")).thenReturn(41L); + when(resultSet.getString("posting_name")).thenReturn("유사 공고"); + when(resultSet.getString("company_name")).thenReturn("유사 회사"); + when(resultSet.getString("job_title")).thenReturn("서버 개발자"); + when(resultSet.getDouble("distance")).thenReturn(1.2); + + try (MockedStatic ignored = mockStatic(PGvector.class)) { + List results = retrievalService.findSimilarJobPostings(40L); + + assertThat(results.getFirst().similarityScore()).isZero(); + } + } + + @Test + @DisplayName("존재하지 않는 현재 공고는 조회하지 않고 예외 처리한다") + void currentJobPostingNotFound() { + when(jobPostingRepository.findById(404L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> retrievalService.findSimilarJobPostings(404L)) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.JOB_POSTING_NOT_FOUND)); + } + + private JobPosting jobPosting(Long jobPostingId, Long userId, String jobTitle) { + Classification classification = Classification.create("개발"); + DetailClassification detailClassification = classification + .addMiddleClassification("서버") + .addDetailClassification("백엔드"); + JobPosting jobPosting = JobPosting.create( + User.authenticatedPrincipal(userId, "user-" + userId + "@example.com", UserRole.USER), + Company.create("테스트 회사", CompanySize.MEDIUM), + detailClassification, + JobPostingProfileColor.DEFAULT, + "테스트 공고", + jobTitle, + "Spring Boot API 개발", + "Java, Spring, JPA", + "AWS, Docker" + ); + ReflectionTestUtils.setField(jobPosting, "id", jobPostingId); + return jobPosting; + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCachePropertiesTestSupport.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCachePropertiesTestSupport.java index f03409d2..8129d0a5 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCachePropertiesTestSupport.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCachePropertiesTestSupport.java @@ -1,5 +1,7 @@ package com.jobdri.jobdri_api.domain.jobposting.service; +import com.jobdri.jobdri_api.global.cohere.CohereProperties; + final class MockQuestionCachePropertiesTestSupport { static final String VERSION_PREFIX = "v1"; @@ -21,8 +23,12 @@ static MockQuestionCacheProperties createProperties() { static MockQuestionCacheVersionProvider createVersionProvider() { return new MockQuestionCacheVersionProvider( createProperties(), + new CohereProperties( + "test-api-key", + "https://api.cohere.com", + new CohereProperties.Embedding("embed-v4.0", 1024, null, null) + ), "gpt-4o-mini", - "embed-v4.0", 3, 5 ); diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheVersionProviderTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheVersionProviderTest.java index a0cd8cc0..6f4110bd 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheVersionProviderTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheVersionProviderTest.java @@ -1,5 +1,6 @@ package com.jobdri.jobdri_api.domain.jobposting.service; +import com.jobdri.jobdri_api.global.cohere.CohereProperties; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -24,15 +25,15 @@ void currentVersionChangesWhenModelChanges() { MockQuestionCacheProperties properties = MockQuestionCachePropertiesTestSupport.createProperties(); MockQuestionCacheVersionProvider baseline = new MockQuestionCacheVersionProvider( properties, + cohereProperties("embed-v4.0"), "gpt-4o-mini", - "embed-v4.0", 3, 5 ); MockQuestionCacheVersionProvider changedModel = new MockQuestionCacheVersionProvider( properties, + cohereProperties("embed-v4.0"), "gpt-5-mini", - "embed-v4.0", 3, 5 ); @@ -46,15 +47,15 @@ void currentVersionChangesWhenEmbeddingModelChanges() { MockQuestionCacheProperties properties = MockQuestionCachePropertiesTestSupport.createProperties(); MockQuestionCacheVersionProvider baseline = new MockQuestionCacheVersionProvider( properties, + cohereProperties("embed-v4.0"), "gpt-4o-mini", - "embed-v4.0", 3, 5 ); MockQuestionCacheVersionProvider changedEmbeddingModel = new MockQuestionCacheVersionProvider( properties, + cohereProperties("embed-v5.0"), "gpt-4o-mini", - "embed-v5.0", 3, 5 ); @@ -68,15 +69,15 @@ void currentVersionChangesWhenJdLimitChanges() { MockQuestionCacheProperties properties = MockQuestionCachePropertiesTestSupport.createProperties(); MockQuestionCacheVersionProvider baseline = new MockQuestionCacheVersionProvider( properties, + cohereProperties("embed-v4.0"), "gpt-4o-mini", - "embed-v4.0", 3, 5 ); MockQuestionCacheVersionProvider changedJdLimit = new MockQuestionCacheVersionProvider( properties, + cohereProperties("embed-v4.0"), "gpt-4o-mini", - "embed-v4.0", 4, 5 ); @@ -90,19 +91,27 @@ void currentVersionChangesWhenQuestionLimitChanges() { MockQuestionCacheProperties properties = MockQuestionCachePropertiesTestSupport.createProperties(); MockQuestionCacheVersionProvider baseline = new MockQuestionCacheVersionProvider( properties, + cohereProperties("embed-v4.0"), "gpt-4o-mini", - "embed-v4.0", 3, 5 ); MockQuestionCacheVersionProvider changedQuestionLimit = new MockQuestionCacheVersionProvider( properties, + cohereProperties("embed-v4.0"), "gpt-4o-mini", - "embed-v4.0", 3, 6 ); assertThat(changedQuestionLimit.currentVersion()).isNotEqualTo(baseline.currentVersion()); } + + private CohereProperties cohereProperties(String model) { + return new CohereProperties( + "test-api-key", + "https://api.cohere.com", + new CohereProperties.Embedding(model, 1024, null, null) + ); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java index 4419422e..1f184bdd 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java @@ -1,9 +1,11 @@ package com.jobdri.jobdri_api.domain.mockapply.service; import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; @@ -49,6 +51,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; +import java.time.Instant; import java.time.LocalDateTime; import java.util.List; import java.util.UUID; @@ -73,6 +76,9 @@ class MockApplyServiceTest { @Autowired private AnalysisRepository analysisRepository; + @Autowired + private AnalysisAsyncTaskRepository analysisAsyncTaskRepository; + @Autowired private QuestionAnalysisRepository questionAnalysisRepository; @@ -338,6 +344,12 @@ void getMyMockApplies() { MockApply inProgress = mockApplyRepository.save(MockApply.create(user, backendPosting, ApplyType.ACTUAL)); inProgress.updateStatus(MockApplyStatus.ANSWER_WRITE); inProgress.updateDisplayName("카카오 백엔드 지원 연습"); + analysisAsyncTaskRepository.save(AnalysisAsyncTask.pending(user.getId(), inProgress.getId(), 3)); + MockApply runningInProgress = mockApplyRepository.save(MockApply.create(user, backendPosting, ApplyType.MOCK)); + runningInProgress.updateStatus(MockApplyStatus.ANSWER_WRITE); + AnalysisAsyncTask runningTask = AnalysisAsyncTask.pending(user.getId(), runningInProgress.getId(), 3); + runningTask.markRunning("worker-1", 0, Instant.now()); + analysisAsyncTaskRepository.save(runningTask); MockApply completedFirst = mockApplyRepository.save(MockApply.create(user, dataPosting, ApplyType.MOCK)); completedFirst.updateStatus(MockApplyStatus.COMPLETED); MockApply completedSecond = mockApplyRepository.save(MockApply.create(user, dataPosting, ApplyType.ACTUAL)); @@ -350,29 +362,36 @@ void getMyMockApplies() { LocalDateTime baseTime = LocalDateTime.of(2026, 1, 1, 12, 0); ReflectionTestUtils.setField(inProgress, "createdAt", baseTime); + ReflectionTestUtils.setField(runningInProgress, "createdAt", baseTime.plusSeconds(30)); ReflectionTestUtils.setField(completedFirst, "createdAt", baseTime.plusMinutes(1)); ReflectionTestUtils.setField(completedSecond, "createdAt", baseTime.plusMinutes(2)); mockApplyRepository.saveAndFlush(inProgress); + mockApplyRepository.saveAndFlush(runningInProgress); mockApplyRepository.saveAndFlush(completedFirst); mockApplyRepository.saveAndFlush(completedSecond); MockApplyHomeResponse response = mockApplyService.getMyMockApplies(user, 0, 9); - assertThat(response.inProgress()).hasSize(1); + assertThat(response.inProgress()).hasSize(2); assertThat(response.completed().getContent()).hasSize(2); - assertThat(response.inProgress().get(0).mockApplyId()).isEqualTo(inProgress.getId()); - assertThat(response.inProgress().get(0).jobPostingId()).isEqualTo(backendPosting.getId()); - assertThat(response.inProgress().get(0).displayName()).isEqualTo("카카오 백엔드 지원 연습"); - assertThat(response.inProgress().get(0).sequence()).isEqualTo(1); - assertThat(response.inProgress().get(0).status()).isEqualTo(MockApplyStatus.ANSWER_WRITE); - assertThat(response.inProgress().get(0).companyName()).isEqualTo("테스트 기업"); - assertThat(response.inProgress().get(0).detailClassificationName()).isEqualTo("백엔드 개발"); - assertThat(response.inProgress().get(0).jobTitle()).isEqualTo("백엔드 개발"); - assertThat(response.inProgress().get(0).profileColor()).isEqualTo(JobPostingProfileColor.BLUE); - assertThat(response.inProgress().get(0).createdAt()).isEqualTo(baseTime); - assertThat(response.inProgress().get(0).applyType()).isEqualTo(ApplyType.ACTUAL); - assertThat(response.inProgress().get(0).score()).isNull(); - assertThat(response.inProgress().get(0).resumePath()).isEqualTo("/mock-applies/" + inProgress.getId() + "/answers"); + MockApplyHomeItemResponse runningItem = response.inProgress().get(0); + MockApplyHomeItemResponse pendingItem = response.inProgress().get(1); + assertThat(runningItem.mockApplyId()).isEqualTo(runningInProgress.getId()); + assertThat(runningItem.analysisInProgress()).isTrue(); + assertThat(pendingItem.mockApplyId()).isEqualTo(inProgress.getId()); + assertThat(pendingItem.jobPostingId()).isEqualTo(backendPosting.getId()); + assertThat(pendingItem.displayName()).isEqualTo("카카오 백엔드 지원 연습"); + assertThat(pendingItem.sequence()).isEqualTo(1); + assertThat(pendingItem.status()).isEqualTo(MockApplyStatus.ANSWER_WRITE); + assertThat(pendingItem.companyName()).isEqualTo("테스트 기업"); + assertThat(pendingItem.detailClassificationName()).isEqualTo("백엔드 개발"); + assertThat(pendingItem.jobTitle()).isEqualTo("백엔드 개발"); + assertThat(pendingItem.profileColor()).isEqualTo(JobPostingProfileColor.BLUE); + assertThat(pendingItem.createdAt()).isEqualTo(baseTime); + assertThat(pendingItem.applyType()).isEqualTo(ApplyType.ACTUAL); + assertThat(pendingItem.score()).isNull(); + assertThat(pendingItem.analysisInProgress()).isTrue(); + assertThat(pendingItem.resumePath()).isEqualTo("/mock-applies/" + inProgress.getId() + "/answers"); assertThat(response.completed().getContent()).extracting(MockApplyHomeItemResponse::mockApplyId) .containsExactly(completedSecond.getId(), completedFirst.getId()); assertThat(response.completed().getTotalElements()).isEqualTo(2); @@ -383,6 +402,7 @@ void getMyMockApplies() { assertThat(response.completed().getContent().get(0).profileColor()).isEqualTo(JobPostingProfileColor.GREEN); assertThat(response.completed().getContent().get(0).displayName()).isNull(); assertThat(response.completed().getContent().get(0).score()).isEqualTo(81); + assertThat(response.completed().getContent().get(0).analysisInProgress()).isFalse(); assertThat(response.completed().getContent().get(0).applyType()).isEqualTo(ApplyType.ACTUAL); assertThat(response.completed().getContent().get(0).resumePath()).isEqualTo("/mock-applies/" + completedSecond.getId() + "/analysis"); } diff --git a/src/test/java/com/jobdri/jobdri_api/global/cohere/CohereEmbeddingClientTest.java b/src/test/java/com/jobdri/jobdri_api/global/cohere/CohereEmbeddingClientTest.java new file mode 100644 index 00000000..51970f26 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/global/cohere/CohereEmbeddingClientTest.java @@ -0,0 +1,369 @@ +package com.jobdri.jobdri_api.global.cohere; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestClient; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CohereEmbeddingClientTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + @DisplayName("embedDocuments는 search_document 요청을 보내고 float embeddings를 반환한다") + void embedDocuments() throws Exception { + AtomicReference requestJson = new AtomicReference<>(); + try (TestCohereServer server = startServer(200, responseJson(3, 2), requestJson)) { + CohereEmbeddingClient client = client(server.baseUrl(), "test-api-key", 3); + + List embeddings = client.embedDocuments(List.of( + "Spring Boot 기반 REST API 개발", + "PostgreSQL 성능 최적화" + )); + + assertThat(embeddings).hasSize(2); + assertThat(embeddings.getFirst()).hasSize(3); + assertThat(embeddings.getFirst()[0]).isEqualTo(0.1f); + assertThat(requestJson.get().get("model").asText()).isEqualTo("embed-v4.0"); + assertThat(requestJson.get().get("input_type").asText()).isEqualTo("search_document"); + assertThat(requestJson.get().get("output_dimension").asInt()).isEqualTo(3); + assertThat(requestJson.get().get("embedding_types").get(0).asText()).isEqualTo("float"); + assertThat(requestJson.get().get("texts")).hasSize(2); + assertThat(server.authorizationHeader()).isEqualTo("Bearer test-api-key"); + } + } + + @Test + @DisplayName("embedQuery는 search_query 요청을 보내고 단일 embedding을 반환한다") + void embedQuery() throws Exception { + AtomicReference requestJson = new AtomicReference<>(); + try (TestCohereServer server = startServer(200, responseJson(3, 1), requestJson)) { + CohereEmbeddingClient client = client(server.baseUrl(), "test-api-key", 3); + + float[] embedding = client.embedQuery("Spring Boot 기반 REST API 개발 및 PostgreSQL 성능 최적화"); + + assertThat(embedding).hasSize(3); + assertThat(requestJson.get().get("input_type").asText()).isEqualTo("search_query"); + assertThat(requestJson.get().get("texts")).hasSize(1); + } + } + + @Test + @DisplayName("API Key가 없으면 호출 시 명확한 예외를 발생시킨다") + void missingApiKey() { + CohereEmbeddingClient client = client("http://localhost:1", "", 3); + + assertThatThrownBy(() -> client.embedDocuments(List.of("text"))) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.SERVICE_UNAVAILABLE)) + .hasMessageContaining("API 키"); + } + + @Test + @DisplayName("빈 입력과 blank query는 API 호출 전에 입력 오류로 처리한다") + void invalidInput() { + CohereEmbeddingClient client = client("http://localhost:1", "test-api-key", 3); + + assertThatThrownBy(() -> client.embedDocuments(List.of())) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.INVALID_PARAMETER)); + assertThatThrownBy(() -> client.embedDocuments(List.of("valid", " "))) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.INVALID_PARAMETER)); + assertThatThrownBy(() -> client.embedQuery(" ")) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.INVALID_PARAMETER)); + } + + @Test + @DisplayName("96개 초과 입력은 API 호출 전에 입력 오류로 처리한다") + void tooManyTexts() { + CohereEmbeddingClient client = client("http://localhost:1", "test-api-key", 3); + List texts = new ArrayList<>(); + for (int i = 0; i < 97; i++) { + texts.add("text-" + i); + } + + assertThatThrownBy(() -> client.embedDocuments(texts)) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.INVALID_PARAMETER)) + .hasMessageContaining("96"); + } + + @Test + @DisplayName("Cohere 429와 5xx는 서비스 일시 장애 예외로 변환한다") + void transientCohereErrors() throws Exception { + for (int status : List.of(429, 500)) { + try (TestCohereServer server = startServer(status, "{\"message\":\"temporary\"}", new AtomicReference<>())) { + CohereEmbeddingClient client = client(server.baseUrl(), "test-api-key", 3); + + assertThatThrownBy(() -> client.embedDocuments(List.of("text"))) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.SERVICE_UNAVAILABLE)); + } + } + } + + @Test + @DisplayName("Cohere 429와 5xx는 bounded retry 후 성공 응답을 반환한다") + void retryTransientCohereError() throws Exception { + AtomicReference requestJson = new AtomicReference<>(); + try (TestCohereServer server = startTransientThenSuccessServer( + 429, + responseJson(3, 1), + requestJson + )) { + CohereEmbeddingClient client = client(server.baseUrl(), "test-api-key", 3); + + float[] embedding = client.embedQuery("Spring Boot 기반 REST API 개발"); + + assertThat(embedding).hasSize(3); + assertThat(server.requestCount()).isEqualTo(2); + assertThat(requestJson.get().get("input_type").asText()).isEqualTo("search_query"); + } + } + + @Test + @DisplayName("Cohere 400, 401, 403은 요청 또는 설정 오류로 변환한다") + void requestOrConfigurationErrors() throws Exception { + for (int status : List.of(400, 401, 403)) { + try (TestCohereServer server = startServer(status, "{\"message\":\"bad request\"}", new AtomicReference<>())) { + CohereEmbeddingClient client = client(server.baseUrl(), "test-api-key", 3); + + assertThatThrownBy(() -> client.embedDocuments(List.of("text"))) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.INVALID_PARAMETER)); + } + } + } + + @Test + @DisplayName("Cohere 응답이 readTimeout보다 지연되면 timeout 예외로 변환한다") + void readTimeout() throws Exception { + try (TestCohereServer server = startServer( + 200, + responseJson(3, 1), + new AtomicReference<>(), + Duration.ofMillis(500) + )) { + CohereEmbeddingClient client = client( + server.baseUrl(), + "test-api-key", + 3, + Duration.ofMillis(100) + ); + + assertThatThrownBy(() -> client.embedQuery("query")) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.EXTERNAL_SERVICE_TIMEOUT)); + } + } + + @Test + @DisplayName("응답 embedding 개수가 요청 texts 개수와 다르면 예외 처리한다") + void mismatchedEmbeddingCount() throws Exception { + try (TestCohereServer server = startServer(200, responseJson(3, 1), new AtomicReference<>())) { + CohereEmbeddingClient client = client(server.baseUrl(), "test-api-key", 3); + + assertThatThrownBy(() -> client.embedDocuments(List.of("first", "second"))) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.SERVICE_UNAVAILABLE)) + .hasMessageContaining("개수"); + } + } + + @Test + @DisplayName("응답 embedding 차원이 설정 차원과 다르면 예외 처리한다") + void mismatchedEmbeddingDimension() throws Exception { + try (TestCohereServer server = startServer(200, responseJson(2, 1), new AtomicReference<>())) { + CohereEmbeddingClient client = client(server.baseUrl(), "test-api-key", 3); + + assertThatThrownBy(() -> client.embedQuery("query")) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.SERVICE_UNAVAILABLE)) + .hasMessageContaining("차원"); + } + } + + @Test + @DisplayName("응답 body가 없거나 embeddings가 비어 있으면 예외 처리한다") + void emptyResponseBodyOrEmbeddings() throws Exception { + try (TestCohereServer server = startServer(200, "", new AtomicReference<>())) { + CohereEmbeddingClient client = client(server.baseUrl(), "test-api-key", 3); + + assertThatThrownBy(() -> client.embedQuery("query")) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.SERVICE_UNAVAILABLE)); + } + try (TestCohereServer server = startServer(200, "{\"embeddings\":{\"float\":[]}}", new AtomicReference<>())) { + CohereEmbeddingClient client = client(server.baseUrl(), "test-api-key", 3); + + assertThatThrownBy(() -> client.embedQuery("query")) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.SERVICE_UNAVAILABLE)); + } + } + + private CohereEmbeddingClient client(String baseUrl, String apiKey, int dimension) { + return client(baseUrl, apiKey, dimension, Duration.ofSeconds(2)); + } + + private CohereEmbeddingClient client(String baseUrl, String apiKey, int dimension, Duration readTimeout) { + return new CohereEmbeddingClient( + new CohereProperties( + apiKey, + baseUrl, + new CohereProperties.Embedding( + "embed-v4.0", + dimension, + Duration.ofSeconds(1), + readTimeout + ) + ), + RestClient.builder() + ); + } + + private String responseJson(int dimension, int count) throws Exception { + List> embeddings = new ArrayList<>(); + for (int i = 0; i < count; i++) { + List vector = new ArrayList<>(); + for (int j = 0; j < dimension; j++) { + vector.add((i + 1) * 0.1 + j); + } + embeddings.add(vector); + } + return objectMapper.writeValueAsString(java.util.Map.of( + "embeddings", + java.util.Map.of("float", embeddings) + )); + } + + private TestCohereServer startServer( + int status, + String responseBody, + AtomicReference requestJson + ) throws IOException { + return startServer(status, responseBody, requestJson, Duration.ZERO); + } + + private TestCohereServer startServer( + int status, + String responseBody, + AtomicReference requestJson, + Duration responseDelay + ) throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + AtomicReference authorizationHeader = new AtomicReference<>(); + AtomicInteger requestCount = new AtomicInteger(); + server.createContext("/v2/embed", exchange -> { + requestCount.incrementAndGet(); + authorizationHeader.set(exchange.getRequestHeaders().getFirst("Authorization")); + String requestBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + if (!requestBody.isBlank()) { + requestJson.set(objectMapper.readTree(requestBody)); + } + if (!responseDelay.isZero()) { + sleep(responseDelay); + } + byte[] body = responseBody.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + return new TestCohereServer(server, authorizationHeader, requestCount); + } + + private TestCohereServer startTransientThenSuccessServer( + int transientStatus, + String successResponseBody, + AtomicReference requestJson + ) throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + AtomicReference authorizationHeader = new AtomicReference<>(); + AtomicInteger requestCount = new AtomicInteger(); + server.createContext("/v2/embed", exchange -> { + int requestNumber = requestCount.incrementAndGet(); + authorizationHeader.set(exchange.getRequestHeaders().getFirst("Authorization")); + String requestBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + if (!requestBody.isBlank()) { + requestJson.set(objectMapper.readTree(requestBody)); + } + if (requestNumber == 1) { + byte[] body = "{\"message\":\"temporary\"}".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Retry-After", "0"); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(transientStatus, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + return; + } + byte[] body = successResponseBody.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + return new TestCohereServer(server, authorizationHeader, requestCount); + } + + private static void sleep(Duration duration) { + try { + Thread.sleep(duration.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } + + private static final class TestCohereServer implements AutoCloseable { + private final HttpServer server; + private final AtomicReference authorizationHeader; + private final AtomicInteger requestCount; + + private TestCohereServer( + HttpServer server, + AtomicReference authorizationHeader, + AtomicInteger requestCount + ) { + this.server = server; + this.authorizationHeader = authorizationHeader; + this.requestCount = requestCount; + } + + String baseUrl() { + return "http://localhost:" + server.getAddress().getPort(); + } + + String authorizationHeader() { + return authorizationHeader.get(); + } + + int requestCount() { + return requestCount.get(); + } + + @Override + public void close() { + server.stop(0); + } + } +}