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 192ea07..1c7db5c 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 @@ -14,6 +14,7 @@ public record AnalysisWorkerContextResponse( String middleClassificationName, String detailClassificationName, List questions, + List corpusReferences, List similarJobPostings ) { public AnalysisWorkerContextResponse( @@ -41,12 +42,45 @@ public AnalysisWorkerContextResponse( middleClassificationName, detailClassificationName, questions, + List.of(), List.of() ); } + public AnalysisWorkerContextResponse( + Long userId, + Long mockApplyId, + String companyName, + String jobTitle, + String task, + String requirements, + String preferredQualifications, + String bigClassificationName, + String middleClassificationName, + String detailClassificationName, + List questions, + List similarJobPostings + ) { + this( + userId, + mockApplyId, + companyName, + jobTitle, + task, + requirements, + preferredQualifications, + bigClassificationName, + middleClassificationName, + detailClassificationName, + questions, + List.of(), + similarJobPostings + ); + } + public AnalysisWorkerContextResponse { questions = questions == null ? List.of() : List.copyOf(questions); + corpusReferences = corpusReferences == null ? List.of() : List.copyOf(corpusReferences); similarJobPostings = similarJobPostings == null ? List.of() : List.copyOf(similarJobPostings); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/CorpusReferenceContext.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/CorpusReferenceContext.java new file mode 100644 index 0000000..a4873c3 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/CorpusReferenceContext.java @@ -0,0 +1,86 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.worker; + +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 org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; + +public record CorpusReferenceContext( + Long corpusId, + String category, + String title, + String content, + int rank +) { + public static List from(RetrievalContext retrievalContext) { + if (retrievalContext == null) { + return List.of(); + } + + List references = new ArrayList<>(); + List jobPostings = retrievalContext.jobPostingReferences() == null + ? List.of() + : retrievalContext.jobPostingReferences(); + for (int index = 0; index < jobPostings.size(); index++) { + references.add(fromJobPosting(jobPostings.get(index), index + 1)); + } + + List questions = retrievalContext.questionReferences() == null + ? List.of() + : retrievalContext.questionReferences(); + for (int index = 0; index < questions.size(); index++) { + references.add(fromQuestion(questions.get(index), index + 1)); + } + return List.copyOf(references); + } + + private static CorpusReferenceContext fromJobPosting(RetrievedJobPostingReference reference, int rank) { + return new CorpusReferenceContext( + reference.corpusId(), + "JOB_POSTING", + joinTitle(reference.companyName(), reference.roleName()), + joinContent( + line("주요 업무", reference.responsibilities()), + line("자격 요건", reference.requirements()), + line("우대 사항", reference.preferred()) + ), + rank + ); + } + + private static CorpusReferenceContext fromQuestion(RetrievedQuestionReference reference, int rank) { + return new CorpusReferenceContext( + reference.corpusId(), + "QUESTION", + joinTitle(reference.companyName(), reference.roleName(), reference.questionType()), + joinContent( + line("문항", reference.questionText()), + reference.charLimit() == null ? "" : "글자 수 제한: " + reference.charLimit() + ), + rank + ); + } + + private static String joinTitle(String... values) { + return java.util.Arrays.stream(values) + .filter(StringUtils::hasText) + .map(String::trim) + .reduce((left, right) -> left + " - " + right) + .orElse(""); + } + + private static String joinContent(String... values) { + return java.util.Arrays.stream(values) + .filter(StringUtils::hasText) + .map(String::trim) + .reduce((left, right) -> left + "\n" + right) + .orElse(""); + } + + private static String line(String label, String value) { + return StringUtils.hasText(value) ? label + ": " + value.trim() : ""; + } +} 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 87bf224..bc0b7f1 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 @@ -7,6 +7,7 @@ import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerCompleteRequest; import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerContextResponse; import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerResultStoreRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.CorpusReferenceContext; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.CreditStatus; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; @@ -132,6 +133,7 @@ public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long payload.jobPosting().getDetailClassification().getMiddleClassification().getMiddleName(), payload.jobPosting().getDetailClassification().getDetailName(), toQuestionItems(payload.questions()), + CorpusReferenceContext.from(payload.retrievalContext()), payload.similarJobPostings() ); task.captureExecutionSnapshot( 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 d2753bf..f2dbf09 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,10 +3,9 @@ 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.CorpusReferenceContext; 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; @@ -26,8 +25,8 @@ @Component public class AnalysisInputFingerprintProvider { - 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 String FINGERPRINT_SCHEMA_VERSION = "analysis-input-fingerprint-v3"; + private static final String ANALYSIS_PROMPT_POLICY_VERSION = "analysis-prompt-policy-v3-curated-corpus-rag"; private static final double ANALYSIS_TEMPERATURE = 0.2; private final ObjectMapper objectMapper; @@ -88,51 +87,23 @@ private Map retrievalPolicy() { private Map retrievalContextFingerprintSource(RetrievalContext retrievalContext) { Map retrievalContextSource = new LinkedHashMap<>(); - if (retrievalContext == null) { - retrievalContextSource.put("jobPostingReferences", List.of()); - retrievalContextSource.put("questionReferences", List.of()); - return retrievalContextSource; - } - - retrievalContextSource.put( - "jobPostingReferences", - retrievalContext.jobPostingReferences().stream() - .map(this::jobPostingReferenceFingerprintSource) - .toList() - ); retrievalContextSource.put( - "questionReferences", - retrievalContext.questionReferences().stream() - .map(this::questionReferenceFingerprintSource) + "corpusReferences", + CorpusReferenceContext.from(retrievalContext).stream() + .map(reference -> { + Map source = new LinkedHashMap<>(); + source.put("corpusId", reference.corpusId()); + source.put("category", reference.category()); + source.put("title", reference.title()); + source.put("content", reference.content()); + source.put("rank", reference.rank()); + return source; + }) .toList() ); return retrievalContextSource; } - private Map jobPostingReferenceFingerprintSource(RetrievedJobPostingReference reference) { - Map referenceSource = new LinkedHashMap<>(); - referenceSource.put("corpusId", reference.corpusId()); - referenceSource.put("companyName", defaultString(reference.companyName())); - referenceSource.put("roleName", defaultString(reference.roleName())); - referenceSource.put("responsibilities", defaultString(reference.responsibilities())); - referenceSource.put("requirements", defaultString(reference.requirements())); - referenceSource.put("preferred", defaultString(reference.preferred())); - referenceSource.put("distance", reference.distance()); - return referenceSource; - } - - private Map questionReferenceFingerprintSource(RetrievedQuestionReference reference) { - Map referenceSource = new LinkedHashMap<>(); - referenceSource.put("corpusId", reference.corpusId()); - referenceSource.put("companyName", defaultString(reference.companyName())); - referenceSource.put("roleName", defaultString(reference.roleName())); - referenceSource.put("questionType", defaultString(reference.questionType())); - referenceSource.put("charLimit", reference.charLimit()); - referenceSource.put("questionText", defaultString(reference.questionText())); - referenceSource.put("distance", reference.distance()); - return referenceSource; - } - private Map jobPostingFingerprintSource(JobPosting jobPosting) { Map jobPostingSource = new LinkedHashMap<>(); jobPostingSource.put("companyName", defaultString(jobPosting.getCompany().getName())); 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 e023f47..007b592 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 @@ -117,15 +117,7 @@ public AnalysisResponse analyze(User user, Long mockApplyId) { public void validateAnalysisRequest(User user, Long mockApplyId) { MockApply mockApply = getOwnedMockApply(user, mockApplyId); List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); - boolean hasAnsweredQuestion = questions.stream() - .anyMatch(question -> StringUtils.hasText(question.getAnswer())); - - if (!hasAnsweredQuestion) { - throw new GeneralException( - GeneralErrorCode.INVALID_PARAMETER, - "분석할 자소서 답변이 1개 이상 필요합니다." - ); - } + answeredQuestionsOrThrow(questions); } @Transactional @@ -140,10 +132,16 @@ public void refundAnalysisCredit(User user, String referenceId) { @Transactional(readOnly = true) public AnalysisExecutionPayload prepareAnalysisExecution(User user, Long mockApplyId) { + MockApply mockApply = getOwnedMockApply(user, mockApplyId); + List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); + List answeredQuestions = answeredQuestionsOrThrow(questions); return prepareAnalysisExecution( user, - mockApplyId, - jobPostingRagContextAssembler.assemble(getOwnedMockApply(user, mockApplyId).getJobPosting().getId()) + mockApply, + questions, + answeredQuestions, + retrieveAnalysisReferences(mockApply.getJobPosting(), answeredQuestions), + jobPostingRagContextAssembler.assemble(mockApply.getJobPosting().getId()) ); } @@ -155,17 +153,25 @@ public AnalysisExecutionPayload prepareAnalysisExecution( ) { MockApply mockApply = getOwnedMockApply(user, mockApplyId); List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); - List answeredQuestions = questions.stream() - .filter(question -> StringUtils.hasText(question.getAnswer())) - .toList(); - - if (answeredQuestions.isEmpty()) { - throw new GeneralException( - GeneralErrorCode.INVALID_PARAMETER, - "분석할 자소서 답변이 1개 이상 필요합니다." - ); - } + List answeredQuestions = answeredQuestionsOrThrow(questions); + return prepareAnalysisExecution( + user, + mockApply, + questions, + answeredQuestions, + new RetrievalContext(List.of(), List.of()), + similarJobPostings + ); + } + private AnalysisExecutionPayload prepareAnalysisExecution( + User user, + MockApply mockApply, + List questions, + List answeredQuestions, + RetrievalContext retrievalContext, + List similarJobPostings + ) { // Initialize hierarchy before leaving the read transaction so detached payload can be used safely. mockApply.getJobPosting().getDetailClassification().getMiddleClassification().getMiddleName(); mockApply.getJobPosting().getDetailClassification().getMiddleClassification().getClassification().getBigName(); @@ -175,16 +181,29 @@ public AnalysisExecutionPayload prepareAnalysisExecution( return new AnalysisExecutionPayload( user.getId(), - mockApplyId, + mockApply.getId(), mockApply.getJobPosting(), List.copyOf(questions), List.copyOf(answeredQuestions), evaluationCriteria, - retrieveAnalysisReferences(mockApply.getJobPosting(), answeredQuestions), + retrievalContext, similarJobPostings ); } + private List answeredQuestionsOrThrow(List questions) { + List answeredQuestions = questions.stream() + .filter(question -> StringUtils.hasText(question.getAnswer())) + .toList(); + if (answeredQuestions.isEmpty()) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "분석할 자소서 답변이 1개 이상 필요합니다." + ); + } + return answeredQuestions; + } + public AnalysisLlmResponse executeAnalysis(AnalysisExecutionPayload payload) { return analysisAiClient.analyze(payload); } @@ -339,8 +358,8 @@ private RetrievalContext retrieveAnalysisReferences(JobPosting jobPosting, List< try { return corpusRetrievalService.retrieveForAnalysis(jobPosting, answeredQuestions); } catch (Exception exception) { - log.warn("자소서 분석 fingerprint용 retrieval 실패. fallback without references. message={}", exception.getMessage()); - log.debug("analysis fingerprint retrieval exception", exception); + log.warn("자소서 분석 Curated Corpus retrieval 실패. fallback without references. message={}", exception.getMessage()); + log.debug("analysis Curated Corpus retrieval exception", exception); return new RetrievalContext(List.of(), List.of()); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusRetrievalService.java b/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusRetrievalService.java index a26db31..a06a236 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusRetrievalService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusRetrievalService.java @@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -57,6 +58,7 @@ public class CorpusRetrievalService { private final CorpusEmbeddingClient corpusEmbeddingClient; private final DataSource dataSource; + @Transactional(propagation = Propagation.NOT_SUPPORTED) public RetrievalContext retrieveForAnalysis(JobPosting jobPosting, List questions) { String jdQuery = buildAnalysisJobPostingQuery(jobPosting); String questionQuery = buildAnalysisQuestionQuery(jobPosting, questions); 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 index 029670e..5c44861 100644 --- 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 @@ -39,6 +39,13 @@ void serializeSimilarJobPostings() throws Exception { "서버", "백엔드", List.of(), + List.of(new CorpusReferenceContext( + 11L, + "JOB_POSTING", + "참고 회사 - 백엔드", + "주요 업무: API 개발", + 1 + )), List.of(similarJobPosting) ); @@ -49,6 +56,14 @@ void serializeSimilarJobPostings() throws Exception { 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(); + assertThat(json.path("corpusReferences").size()).isEqualTo(1); + assertThat(json.path("corpusReferences").get(0).path("corpusId").asLong()).isEqualTo(11L); + assertThat(json.path("corpusReferences").get(0).path("category").asText()).isEqualTo("JOB_POSTING"); + assertThat(json.path("corpusReferences").get(0).path("title").asText()).isEqualTo("참고 회사 - 백엔드"); + assertThat(json.path("corpusReferences").get(0).path("content").asText()).isEqualTo("주요 업무: API 개발"); + assertThat(json.path("corpusReferences").get(0).path("rank").asInt()).isEqualTo(1); + assertThat(json.path("corpusReferences").get(0).has("distance")).isFalse(); + assertThat(json.path("corpusReferences").get(0).has("embedding")).isFalse(); } @Test @@ -72,5 +87,47 @@ void oldConstructorDefaultsSimilarJobPostingsToEmptyList() throws Exception { assertThat(json.path("similarJobPostings").isArray()).isTrue(); assertThat(json.path("similarJobPostings").size()).isZero(); + assertThat(json.path("corpusReferences").isArray()).isTrue(); + assertThat(json.path("corpusReferences").size()).isZero(); + } + + @Test + @DisplayName("기존 worker snapshot JSON은 corpusReferences 없이도 역직렬화된다") + void deserializeLegacySnapshotWithoutCorpusReferences() throws Exception { + String legacySnapshot = """ + { + "userId": 1, + "mockApplyId": 10, + "companyName": "현재 회사", + "jobTitle": "백엔드 개발자", + "task": "API 개발", + "requirements": "Java", + "preferredQualifications": "AWS", + "bigClassificationName": "개발", + "middleClassificationName": "서버", + "detailClassificationName": "백엔드", + "questions": [], + "similarJobPostings": [{ + "jobPostingId": 31, + "companyName": "유사 회사", + "postingName": "유사 공고", + "jobTitle": "서버 개발자", + "task": "API 개발", + "requirements": "Java", + "preferredQualifications": "AWS", + "similarityRank": 1, + "similarityScore": 0.91 + }] + } + """; + + AnalysisWorkerContextResponse response = objectMapper.readValue( + legacySnapshot, + AnalysisWorkerContextResponse.class + ); + + assertThat(response.corpusReferences()).isEmpty(); + assertThat(response.similarJobPostings()).hasSize(1); + assertThat(response.similarJobPostings().getFirst().jobPostingId()).isEqualTo(31L); } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/CorpusReferenceContextTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/CorpusReferenceContextTest.java new file mode 100644 index 0000000..bd45bc4 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/CorpusReferenceContextTest.java @@ -0,0 +1,87 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.worker; + +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 org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class CorpusReferenceContextTest { + + @Test + @DisplayName("기존 Corpus retrieval 결과를 score 없는 worker context로 변환한다") + void convertsExistingRetrievalContext() { + RetrievalContext retrievalContext = new RetrievalContext( + List.of(new RetrievedJobPostingReference( + 11L, "회사", "백엔드", "API 개발", "Spring Boot", "AWS", 0.12 + )), + List.of(new RetrievedQuestionReference( + 21L, "회사", "백엔드", "EXPERIENCE", 500, "성과 경험을 작성하세요.", 0.21 + )) + ); + + List references = CorpusReferenceContext.from(retrievalContext); + + assertThat(references).hasSize(2); + assertThat(references.get(0)).isEqualTo(new CorpusReferenceContext( + 11L, + "JOB_POSTING", + "회사 - 백엔드", + "주요 업무: API 개발\n자격 요건: Spring Boot\n우대 사항: AWS", + 1 + )); + assertThat(references.get(1)).isEqualTo(new CorpusReferenceContext( + 21L, + "QUESTION", + "회사 - 백엔드 - EXPERIENCE", + "문항: 성과 경험을 작성하세요.\n글자 수 제한: 500", + 1 + )); + } + + @Test + @DisplayName("Corpus retrieval 결과가 없으면 빈 worker context를 반환한다") + void returnsEmptyForMissingRetrievalContext() { + assertThat(CorpusReferenceContext.from(null)).isEmpty(); + assertThat(CorpusReferenceContext.from(new RetrievalContext(List.of(), List.of()))).isEmpty(); + } + + @Test + @DisplayName("blank 필드는 빈 title과 content로 정규화한다") + void normalizesBlankFields() { + RetrievalContext retrievalContext = new RetrievalContext( + List.of(new RetrievedJobPostingReference(11L, " ", null, "", " ", null, 0.1)), + List.of() + ); + + CorpusReferenceContext reference = CorpusReferenceContext.from(retrievalContext).getFirst(); + + assertThat(reference.title()).isEmpty(); + assertThat(reference.content()).isEmpty(); + } + + @Test + @DisplayName("문항 charLimit이 null이면 글자 수 라벨을 추가하지 않는다") + void omitsMissingQuestionCharLimit() { + RetrievalContext retrievalContext = new RetrievalContext( + List.of(), + List.of(new RetrievedQuestionReference( + 21L, "회사", "백엔드", "MOTIVATION", null, "지원 동기를 작성하세요.", 0.2 + )) + ); + + CorpusReferenceContext reference = CorpusReferenceContext.from(retrievalContext).getFirst(); + + assertThat(reference.content()).isEqualTo("문항: 지원 동기를 작성하세요."); + } + + @Test + @DisplayName("RetrievalContext 내부 null 리스트는 빈 목록으로 처리한다") + void handlesNullReferenceLists() { + assertThat(CorpusReferenceContext.from(new RetrievalContext(null, null))).isEmpty(); + } +} 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 8069d95..fa518bd 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 @@ -13,6 +13,8 @@ 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.corpus.service.CorpusRetrievalService.RetrievalContext; +import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedJobPostingReference; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.service.UserService; @@ -164,7 +166,18 @@ void getContextReservesCreditBeforePreparingExecution() { List.of(), List.of(), null, - null, + new RetrievalContext( + List.of(new RetrievedJobPostingReference( + 11L, + "참고 회사", + "백엔드 개발자", + "API 개발", + "Spring Boot", + "AWS", + 0.12 + )), + List.of() + ), List.of(similarContext) ); @@ -176,6 +189,8 @@ void getContextReservesCreditBeforePreparingExecution() { verify(analysisService).deductAnalysisCredit(user, "analysisTaskId=" + task.getTaskId()); verify(analysisAsyncTaskService).markCreditReserved(task.getTaskId(), "analysisTaskId=" + task.getTaskId()); verify(analysisService).prepareAnalysisExecution(user, 10L); + assertThat(context.corpusReferences()).hasSize(1); + assertThat(context.corpusReferences().getFirst().corpusId()).isEqualTo(11L); assertThat(context.similarJobPostings()).containsExactly(similarContext); } @@ -234,7 +249,25 @@ void completeTaskReusesInitialExecutionSnapshot() { 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() + 1L, + 10L, + jobPosting, + List.of(), + List.of(), + null, + new RetrievalContext( + List.of(new RetrievedJobPostingReference( + 11L, + "참고 회사", + "백엔드 개발자", + "API 개발", + "Spring Boot", + "AWS", + 0.12 + )), + List.of() + ), + List.of() ); SimilarJobPostingContext laterContext = new SimilarJobPostingContext( 31L, "유사 회사", "유사 공고", "서버 개발자", "API 개발", "Java", "AWS", 1, 0.91 @@ -265,9 +298,13 @@ void completeTaskReusesInitialExecutionSnapshot() { "initial-fingerprint" )).thenReturn(analysisResponse); - analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L); + var initialContext = analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L); + var retriedContext = analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L); analysisWorkerBridgeService.completeTask(task.getTaskId(), request); + assertThat(retriedContext).isEqualTo(initialContext); + assertThat(retriedContext.corpusReferences()).hasSize(1); + assertThat(retriedContext.corpusReferences().getFirst().corpusId()).isEqualTo(11L); verify(analysisService, times(1)).prepareAnalysisExecution(user, 10L); verify(analysisService).prepareAnalysisExecution(user, 10L, List.of()); verify(analysisService).finalizeAnalysis( 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 index e7b43f1..5e8a6cf 100644 --- 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 @@ -3,6 +3,9 @@ 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.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.jobposting.entity.JobPosting; import com.jobdri.jobdri_api.global.cohere.CohereProperties; import org.junit.jupiter.api.DisplayName; @@ -50,6 +53,50 @@ void fingerprintIgnoresSimilarityScore() { assertThat(provider.create(first)).isEqualTo(provider.create(changedScore)); } + @Test + @DisplayName("Curated Corpus 내용이 달라지면 fingerprint가 달라진다") + void fingerprintChangesWhenCorpusContentChanges() { + when(fewShotPromptProvider.getPrompt()).thenReturn("few-shot"); + JobPosting current = currentJobPosting(); + AnalysisExecutionPayload first = payload(current, corpusReference("Spring Boot", 0.1)); + AnalysisExecutionPayload changed = payload(current, corpusReference("Kotlin", 0.1)); + + assertThat(provider.create(first)).isNotEqualTo(provider.create(changed)); + } + + @Test + @DisplayName("Curated Corpus distance 변화는 fingerprint에 영향을 주지 않는다") + void fingerprintIgnoresCorpusDistance() { + when(fewShotPromptProvider.getPrompt()).thenReturn("few-shot"); + JobPosting current = currentJobPosting(); + AnalysisExecutionPayload first = payload(current, corpusReference("Spring Boot", 0.1)); + AnalysisExecutionPayload changedDistance = payload(current, corpusReference("Spring Boot", 0.9)); + + assertThat(provider.create(first)).isEqualTo(provider.create(changedDistance)); + } + + @Test + @DisplayName("Curated Corpus 문항 내용이 달라지면 fingerprint가 달라진다") + void fingerprintChangesWhenCorpusQuestionContentChanges() { + when(fewShotPromptProvider.getPrompt()).thenReturn("few-shot"); + JobPosting current = currentJobPosting(); + AnalysisExecutionPayload first = payload(current, questionCorpusReference("성과 경험", 0.1)); + AnalysisExecutionPayload changed = payload(current, questionCorpusReference("지원 동기", 0.1)); + + assertThat(provider.create(first)).isNotEqualTo(provider.create(changed)); + } + + @Test + @DisplayName("Curated Corpus 문항 distance 변화는 fingerprint에 영향을 주지 않는다") + void fingerprintIgnoresCorpusQuestionDistance() { + when(fewShotPromptProvider.getPrompt()).thenReturn("few-shot"); + JobPosting current = currentJobPosting(); + AnalysisExecutionPayload first = payload(current, questionCorpusReference("성과 경험", 0.1)); + AnalysisExecutionPayload changedDistance = payload(current, questionCorpusReference("성과 경험", 0.9)); + + assertThat(provider.create(first)).isEqualTo(provider.create(changedDistance)); + } + private AnalysisExecutionPayload payload(JobPosting jobPosting, SimilarJobPostingContext context) { return new AnalysisExecutionPayload( 1L, @@ -63,6 +110,49 @@ private AnalysisExecutionPayload payload(JobPosting jobPosting, SimilarJobPostin ); } + private AnalysisExecutionPayload payload(JobPosting jobPosting, RetrievalContext retrievalContext) { + return new AnalysisExecutionPayload( + 1L, + 10L, + jobPosting, + List.of(), + List.of(), + null, + retrievalContext, + List.of() + ); + } + + private RetrievalContext corpusReference(String requirements, double distance) { + return new RetrievalContext( + List.of(new RetrievedJobPostingReference( + 11L, + "참고 회사", + "백엔드 개발자", + "API 개발", + requirements, + "AWS", + distance + )), + List.of() + ); + } + + private RetrievalContext questionCorpusReference(String questionText, double distance) { + return new RetrievalContext( + List.of(), + List.of(new RetrievedQuestionReference( + 21L, + "참고 회사", + "백엔드 개발자", + "EXPERIENCE", + 500, + questionText, + distance + )) + ); + } + private SimilarJobPostingContext similarContext(String task, double score) { return new SimilarJobPostingContext( 31L, 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 33aae35..1c4a7c6 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 @@ -1011,6 +1011,7 @@ void prepareAnalysisExecutionIncludesJobCategoryEvaluationCriteria() { assertThat(payload.jobCategoryEvaluationCriteria()).isNotNull(); assertThat(payload.jobCategoryEvaluationCriteria().jobCategoryMiddle()).isEqualTo("AI·개발·데이터"); assertThat(payload.similarJobPostings()).containsExactly(similarContext); + verify(corpusRetrievalService, times(1)).retrieveForAnalysis(any(), any()); verify(jobPostingRagContextAssembler).assemble(jobPosting.getId()); } @@ -1026,6 +1027,22 @@ void prepareAnalysisExecutionKeepsCriteriaNullWhenMiddleNameIsUnknown() { assertThat(payload.jobCategoryEvaluationCriteria()).isNull(); } + @Test + @DisplayName("Curated Corpus retrieval 실패 시 빈 context로 분석 준비를 계속한다") + void prepareAnalysisExecutionFailsOpenWhenCorpusRetrievalFails() { + User user = saveUser("analysis-corpus-fail-open@example.com"); + MockApply mockApply = saveMockApply(user); + saveQuestion(mockApply, "지원 직무 경험", "Spring Boot API를 개발했습니다."); + when(corpusRetrievalService.retrieveForAnalysis(any(), any())) + .thenThrow(new IllegalStateException("corpus unavailable")); + + AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(user, mockApply.getId()); + + assertThat(payload.retrievalContext().jobPostingReferences()).isEmpty(); + assertThat(payload.retrievalContext().questionReferences()).isEmpty(); + verify(corpusRetrievalService, times(1)).retrieveForAnalysis(any(), any()); + } + @Test @DisplayName("다른 사용자의 지원서는 분석할 수 없다") void analyzeThrowsWhenUserDoesNotOwnMockApply() {