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/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/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 c38917c1..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,6 +3,7 @@ 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; @@ -25,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; @@ -68,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()); @@ -149,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/jobposting/service/JobPostingRetrievalService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingRetrievalService.java index 09c1c557..5fcd396f 100644 --- 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 @@ -84,7 +84,7 @@ private List findSimilarJobPostings( resultSet.getString("posting_name"), resultSet.getString("company_name"), resultSet.getString("job_title"), - 1.0 - distance + Math.max(0.0, Math.min(1.0, 1.0 - distance)) )); } return results; diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 75906997..61aef4bf 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -100,6 +100,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/jobposting/service/JobPostingRetrievalServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingRetrievalServiceTest.java index 2e1d5bf8..2b47e58e 100644 --- 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 @@ -141,6 +141,32 @@ void minimumLimit() throws Exception { } } + @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() {