From aba256b1549c20d801c8e64cd10d2b67a33279d0 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Thu, 18 Jun 2026 15:58:23 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[Fix]=20=EB=AA=A8=EC=9D=98=20=EA=B3=B5?= =?UTF-8?q?=EA=B3=A0=20=EC=83=9D=EC=84=B1=20=EB=B6=84=EB=A5=98=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A5=BC=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/ClassificationHierarchy.java | 10 +++ .../mapper/ClassificationHierarchyMapper.java | 20 ++++++ .../service/CorpusRetrievalService.java | 56 +++++++++------- .../service/JobPostingAiService.java | 67 ++++++++++--------- .../service/JobPostingAiServiceTest.java | 21 ++++-- 5 files changed, 114 insertions(+), 60 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/classification/dto/ClassificationHierarchy.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/classification/mapper/ClassificationHierarchyMapper.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/classification/dto/ClassificationHierarchy.java b/src/main/java/com/jobdri/jobdri_api/domain/classification/dto/ClassificationHierarchy.java new file mode 100644 index 0000000..e75d210 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/classification/dto/ClassificationHierarchy.java @@ -0,0 +1,10 @@ +package com.jobdri.jobdri_api.domain.classification.dto; + +public record ClassificationHierarchy( + Long detailClassificationId, + String detailClassificationName, + Long middleClassificationId, + String middleClassificationName, + String bigClassificationName +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/classification/mapper/ClassificationHierarchyMapper.java b/src/main/java/com/jobdri/jobdri_api/domain/classification/mapper/ClassificationHierarchyMapper.java new file mode 100644 index 0000000..65fbab5 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/classification/mapper/ClassificationHierarchyMapper.java @@ -0,0 +1,20 @@ +package com.jobdri.jobdri_api.domain.classification.mapper; + +import com.jobdri.jobdri_api.domain.classification.dto.ClassificationHierarchy; +import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; + +public final class ClassificationHierarchyMapper { + + private ClassificationHierarchyMapper() { + } + + public static ClassificationHierarchy toHierarchy(DetailClassification detailClassification) { + return new ClassificationHierarchy( + detailClassification.getId(), + detailClassification.getDetailName(), + detailClassification.getMiddleClassification().getId(), + detailClassification.getMiddleClassification().getMiddleName(), + detailClassification.getMiddleClassification().getClassification().getBigName() + ); + } +} 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 777d931..b52d6a6 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 @@ -1,7 +1,9 @@ package com.jobdri.jobdri_api.domain.corpus.service; import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.classification.dto.ClassificationHierarchy; import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import com.jobdri.jobdri_api.domain.classification.mapper.ClassificationHierarchyMapper; import com.jobdri.jobdri_api.domain.company.entity.Company; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; import com.pgvector.PGvector; @@ -36,31 +38,37 @@ public class CorpusRetrievalService { public RetrievalContext retrieveForAnalysis(JobPosting jobPosting, List questions) { String jdQuery = buildAnalysisJobPostingQuery(jobPosting); String questionQuery = buildAnalysisQuestionQuery(jobPosting, questions); + ClassificationHierarchy classificationHierarchy = + ClassificationHierarchyMapper.toHierarchy(jobPosting.getDetailClassification()); return new RetrievalContext( - StringUtils.hasText(jdQuery) ? findSimilarJobPostings(jobPosting.getCompany(), jobPosting.getDetailClassification(), jdQuery, jdLimit) : List.of(), - StringUtils.hasText(questionQuery) ? findSimilarQuestions(jobPosting.getCompany(), jobPosting.getDetailClassification(), questionQuery, questionLimit) : List.of() + StringUtils.hasText(jdQuery) + ? findSimilarJobPostings(jobPosting.getCompany(), classificationHierarchy, jdQuery, jdLimit) + : List.of(), + StringUtils.hasText(questionQuery) + ? findSimilarQuestions(jobPosting.getCompany(), classificationHierarchy, questionQuery, questionLimit) + : List.of() ); } - public RetrievalContext retrieveForMockGeneration(Company company, DetailClassification detailClassification) { - String baseQuery = buildMockBaseQuery(company, detailClassification); + public RetrievalContext retrieveForMockGeneration(Company company, ClassificationHierarchy classificationHierarchy) { + String baseQuery = buildMockBaseQuery(company, classificationHierarchy); float[] vector = corpusEmbeddingClient.embedQuery(baseQuery); return new RetrievalContext( - findSimilarJobPostings(company, detailClassification, baseQuery, vector, jdLimit), - findSimilarQuestions(company, detailClassification, baseQuery, vector, questionLimit) + findSimilarJobPostings(company, classificationHierarchy, baseQuery, vector, jdLimit), + findSimilarQuestions(company, classificationHierarchy, baseQuery, vector, questionLimit) ); } private List findSimilarJobPostings( Company company, - DetailClassification detailClassification, + ClassificationHierarchy classificationHierarchy, String query, int limit ) { return findSimilarJobPostings( company, - detailClassification, + classificationHierarchy, query, corpusEmbeddingClient.embedQuery(query), limit @@ -69,7 +77,7 @@ private List findSimilarJobPostings( private List findSimilarJobPostings( Company company, - DetailClassification detailClassification, + ClassificationHierarchy classificationHierarchy, String query, float[] vector, int limit @@ -132,7 +140,7 @@ AND lower(c.company_name) = lower(?) companyAndDetailSql, vector, statement -> { - statement.setObject(2, detailClassification.getId()); + statement.setObject(2, classificationHierarchy.detailClassificationId()); statement.setString(3, company.getName()); statement.setObject(4, new PGvector(vector)); statement.setInt(5, limit); @@ -147,7 +155,7 @@ AND lower(c.company_name) = lower(?) detailOnlySql, vector, statement -> { - statement.setObject(2, detailClassification.getId()); + statement.setObject(2, classificationHierarchy.detailClassificationId()); statement.setObject(3, new PGvector(vector)); statement.setInt(4, limit); } @@ -161,8 +169,8 @@ AND lower(c.company_name) = lower(?) hierarchySql, vector, statement -> { - statement.setString(2, detailClassification.getMiddleClassification().getClassification().getBigName()); - statement.setString(3, detailClassification.getMiddleClassification().getMiddleName()); + statement.setString(2, classificationHierarchy.bigClassificationName()); + statement.setString(3, classificationHierarchy.middleClassificationName()); statement.setObject(4, new PGvector(vector)); statement.setInt(5, limit); } @@ -174,13 +182,13 @@ AND lower(c.company_name) = lower(?) private List findSimilarQuestions( Company company, - DetailClassification detailClassification, + ClassificationHierarchy classificationHierarchy, String query, int limit ) { return findSimilarQuestions( company, - detailClassification, + classificationHierarchy, query, corpusEmbeddingClient.embedQuery(query), limit @@ -189,7 +197,7 @@ private List findSimilarQuestions( private List findSimilarQuestions( Company company, - DetailClassification detailClassification, + ClassificationHierarchy classificationHierarchy, String query, float[] vector, int limit @@ -252,7 +260,7 @@ AND lower(c.company_name) = lower(?) companyAndDetailSql, vector, statement -> { - statement.setObject(2, detailClassification.getId()); + statement.setObject(2, classificationHierarchy.detailClassificationId()); statement.setString(3, company.getName()); statement.setObject(4, new PGvector(vector)); statement.setInt(5, limit); @@ -267,7 +275,7 @@ AND lower(c.company_name) = lower(?) detailOnlySql, vector, statement -> { - statement.setObject(2, detailClassification.getId()); + statement.setObject(2, classificationHierarchy.detailClassificationId()); statement.setObject(3, new PGvector(vector)); statement.setInt(4, limit); } @@ -281,8 +289,8 @@ AND lower(c.company_name) = lower(?) hierarchySql, vector, statement -> { - statement.setString(2, detailClassification.getMiddleClassification().getClassification().getBigName()); - statement.setString(3, detailClassification.getMiddleClassification().getMiddleName()); + statement.setString(2, classificationHierarchy.bigClassificationName()); + statement.setString(3, classificationHierarchy.middleClassificationName()); statement.setObject(4, new PGvector(vector)); statement.setInt(5, limit); } @@ -395,16 +403,16 @@ private String buildAnalysisQuestionQuery(JobPosting jobPosting, List ).trim(); } - private String buildMockBaseQuery(Company company, DetailClassification detailClassification) { + private String buildMockBaseQuery(Company company, ClassificationHierarchy classificationHierarchy) { return """ 직무명: %s 중분류: %s 대분류: %s 회사명: %s """.formatted( - defaultString(detailClassification.getDetailName()), - defaultString(detailClassification.getMiddleClassification().getMiddleName()), - defaultString(detailClassification.getMiddleClassification().getClassification().getBigName()), + defaultString(classificationHierarchy.detailClassificationName()), + defaultString(classificationHierarchy.middleClassificationName()), + defaultString(classificationHierarchy.bigClassificationName()), defaultString(company.getName()) ).trim(); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java index f6fbf32..2b785f0 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java @@ -1,6 +1,8 @@ package com.jobdri.jobdri_api.domain.jobposting.service; import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import com.jobdri.jobdri_api.domain.classification.dto.ClassificationHierarchy; +import com.jobdri.jobdri_api.domain.classification.mapper.ClassificationHierarchyMapper; import com.jobdri.jobdri_api.domain.classification.repository.DetailClassificationRepository; import com.jobdri.jobdri_api.domain.company.entity.Company; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtractRequest; @@ -78,12 +80,12 @@ public JobPostingGenerateResponse generateJobPosting(JobPostingGenerateRequest r } public JobPostingMockGenerateResponse generateMockJobPosting(JobPostingMockGenerateRequest request, Company company) { - DetailClassification detailClassification = findDetailClassification(request.detailClassificationId()); - validateMiddleClassification(request, detailClassification); + ClassificationHierarchy classificationHierarchy = findClassificationHierarchy(request.detailClassificationId()); + validateMiddleClassification(request, classificationHierarchy); RetrievalContext retrievalContext = emptyRetrievalContext(); try { - retrievalContext = corpusRetrievalService.retrieveForMockGeneration(company, detailClassification); + retrievalContext = corpusRetrievalService.retrieveForMockGeneration(company, classificationHierarchy); } catch (Exception e) { log.warn("모의 공고 생성 retrieval 실패. fallback without corpus references. message={}", e.getMessage()); log.debug("mock job posting retrieval exception", e); @@ -91,7 +93,7 @@ public JobPostingMockGenerateResponse generateMockJobPosting(JobPostingMockGener var params = ResponseCreateParams.builder() .model(extractionModel) - .input(buildMockGenerationPrompt(request, company, detailClassification, retrievalContext)) + .input(buildMockGenerationPrompt(request, company, classificationHierarchy, retrievalContext)) .temperature(0.7) .text(JobPostingMockGenerateResponse.class) .build(); @@ -102,10 +104,10 @@ public JobPostingMockGenerateResponse generateMockJobPosting(JobPostingMockGener response, JobPostingMockGenerateResponse.class ); - return normalizeMockGeneratedResponse(generated, company, detailClassification); + return normalizeMockGeneratedResponse(generated, company, classificationHierarchy); } catch (Exception e) { log.error("모의 공고 생성 OpenAI API 호출 오류: {}", e.getMessage(), e); - return createFallbackMockGeneratedResponse(company, detailClassification, retrievalContext.jobPostingReferences()); + return createFallbackMockGeneratedResponse(company, classificationHierarchy, retrievalContext.jobPostingReferences()); } } @@ -113,12 +115,12 @@ public JobPostingMockQuestionResponse generateMockRecommendedQuestions( JobPostingMockGenerateRequest request, Company company ) { - DetailClassification detailClassification = findDetailClassification(request.detailClassificationId()); - validateMiddleClassification(request, detailClassification); + ClassificationHierarchy classificationHierarchy = findClassificationHierarchy(request.detailClassificationId()); + validateMiddleClassification(request, classificationHierarchy); RetrievalContext retrievalContext = emptyRetrievalContext(); try { - retrievalContext = corpusRetrievalService.retrieveForMockGeneration(company, detailClassification); + retrievalContext = corpusRetrievalService.retrieveForMockGeneration(company, classificationHierarchy); } catch (Exception e) { log.warn("추천 질문 생성 retrieval 실패. fallback without corpus references. message={}", e.getMessage()); log.debug("mock question retrieval exception", e); @@ -126,7 +128,7 @@ public JobPostingMockQuestionResponse generateMockRecommendedQuestions( var params = ResponseCreateParams.builder() .model(extractionModel) - .input(buildMockQuestionPrompt(request, detailClassification, retrievalContext)) + .input(buildMockQuestionPrompt(request, classificationHierarchy, retrievalContext)) .temperature(0.4) .text(JobPostingMockQuestionResponse.class) .build(); @@ -137,10 +139,10 @@ public JobPostingMockQuestionResponse generateMockRecommendedQuestions( response, JobPostingMockQuestionResponse.class ); - return normalizeMockQuestionResponse(generated, detailClassification); + return normalizeMockQuestionResponse(generated, classificationHierarchy); } catch (Exception e) { log.error("모의 공고 추천 질문 생성 OpenAI API 호출 오류: {}", e.getMessage(), e); - return createFallbackMockQuestionResponse(detailClassification); + return createFallbackMockQuestionResponse(classificationHierarchy); } } @@ -441,11 +443,11 @@ private String buildGenerationPrompt(JobPostingGenerateRequest request, DetailCl private String buildMockGenerationPrompt( JobPostingMockGenerateRequest request, Company company, - DetailClassification detailClassification, + ClassificationHierarchy classificationHierarchy, RetrievalContext retrievalContext ) { - String middleName = detailClassification.getMiddleClassification().getMiddleName(); - String detailName = detailClassification.getDetailName(); + String middleName = classificationHierarchy.middleClassificationName(); + String detailName = classificationHierarchy.detailClassificationName(); String referenceText = buildReferencePostingText(retrievalContext.jobPostingReferences()); String questionReferenceText = buildReferenceQuestionText(retrievalContext.questionReferences()); @@ -506,11 +508,11 @@ private String buildMockGenerationPrompt( private String buildMockQuestionPrompt( JobPostingMockGenerateRequest request, - DetailClassification detailClassification, + ClassificationHierarchy classificationHierarchy, RetrievalContext retrievalContext ) { - String middleName = detailClassification.getMiddleClassification().getMiddleName(); - String detailName = detailClassification.getDetailName(); + String middleName = classificationHierarchy.middleClassificationName(); + String detailName = classificationHierarchy.detailClassificationName(); String referenceText = buildReferencePostingText(retrievalContext.jobPostingReferences()); String questionReferenceText = buildReferenceQuestionText(retrievalContext.questionReferences()); @@ -639,7 +641,7 @@ private JobPostingGenerateResponse normalizeGeneratedResponse(JobPostingGenerate private JobPostingMockGenerateResponse normalizeMockGeneratedResponse( JobPostingMockGenerateResponse response, Company company, - DetailClassification detailClassification + ClassificationHierarchy classificationHierarchy ) { if (response == null) { throw new GeneralException( @@ -655,7 +657,7 @@ private JobPostingMockGenerateResponse normalizeMockGeneratedResponse( String jobTitle = response.jobTitle(); if (jobTitle == null || jobTitle.isBlank()) { - jobTitle = detailClassification.getDetailName(); + jobTitle = classificationHierarchy.detailClassificationName(); } return new JobPostingMockGenerateResponse( @@ -671,7 +673,7 @@ private JobPostingMockGenerateResponse normalizeMockGeneratedResponse( private JobPostingMockQuestionResponse normalizeMockQuestionResponse( JobPostingMockQuestionResponse response, - DetailClassification detailClassification + ClassificationHierarchy classificationHierarchy ) { if (response == null) { throw new GeneralException( @@ -691,11 +693,12 @@ private JobPostingMockQuestionResponse normalizeMockQuestionResponse( return new JobPostingMockQuestionResponse(questions); } - return createFallbackMockQuestionResponse(detailClassification); + return createFallbackMockQuestionResponse(classificationHierarchy); } - private DetailClassification findDetailClassification(Long detailClassificationId) { + private ClassificationHierarchy findClassificationHierarchy(Long detailClassificationId) { return detailClassificationRepository.findWithHierarchyById(detailClassificationId) + .map(ClassificationHierarchyMapper::toHierarchy) .orElseThrow(() -> new GeneralException( GeneralErrorCode.CLASSIFICATION_NOT_FOUND, "해당 소분류를 찾을 수 없습니다. detailClassificationId=" + detailClassificationId @@ -704,9 +707,9 @@ private DetailClassification findDetailClassification(Long detailClassificationI private void validateMiddleClassification( JobPostingMockGenerateRequest request, - DetailClassification detailClassification + ClassificationHierarchy classificationHierarchy ) { - Long actualMiddleClassificationId = detailClassification.getMiddleClassification().getId(); + Long actualMiddleClassificationId = classificationHierarchy.middleClassificationId(); if (!actualMiddleClassificationId.equals(request.middleClassificationId())) { throw new GeneralException( GeneralErrorCode.CLASSIFICATION_NOT_FOUND, @@ -764,14 +767,14 @@ private JobPostingGenerateResponse createFallbackGeneratedResponse(JobPostingGen private JobPostingMockGenerateResponse createFallbackMockGeneratedResponse( Company company, - DetailClassification detailClassification, + ClassificationHierarchy classificationHierarchy, List referencePostings ) { RetrievedJobPostingReference referencePosting = referencePostings == null || referencePostings.isEmpty() ? null : referencePostings.getFirst(); - String middleName = detailClassification.getMiddleClassification().getMiddleName(); - String detailName = detailClassification.getDetailName(); + String middleName = classificationHierarchy.middleClassificationName(); + String detailName = classificationHierarchy.detailClassificationName(); return new JobPostingMockGenerateResponse( company.getName(), @@ -790,9 +793,11 @@ private JobPostingMockGenerateResponse createFallbackMockGeneratedResponse( ); } - private JobPostingMockQuestionResponse createFallbackMockQuestionResponse(DetailClassification detailClassification) { - String middleName = detailClassification.getMiddleClassification().getMiddleName(); - String detailName = detailClassification.getDetailName(); + private JobPostingMockQuestionResponse createFallbackMockQuestionResponse( + ClassificationHierarchy classificationHierarchy + ) { + String middleName = classificationHierarchy.middleClassificationName(); + String detailName = classificationHierarchy.detailClassificationName(); return new JobPostingMockQuestionResponse(List.of( "%s 직무에 지원한 이유와 가장 관심 있는 업무를 설명해주세요.".formatted(detailName), diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java index 1007a36..f297559 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java @@ -3,6 +3,8 @@ 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; +import com.jobdri.jobdri_api.domain.classification.dto.ClassificationHierarchy; +import com.jobdri.jobdri_api.domain.classification.mapper.ClassificationHierarchyMapper; import com.jobdri.jobdri_api.domain.classification.repository.DetailClassificationRepository; import com.jobdri.jobdri_api.domain.company.entity.Company; import com.jobdri.jobdri_api.domain.company.entity.CompanySize; @@ -100,8 +102,9 @@ void generateMockJobPostingThrowsWhenDetailDoesNotBelongToMiddle() { @DisplayName("기존 공고가 없으면 분류명 기반 fallback 모의 공고를 생성한다") void generateMockJobPostingUsesFallbackWhenNoReferencePostings() { DetailClassification detailClassification = createDetailClassification(10L, 100L, "백엔드", "Java/Spring"); + ClassificationHierarchy hierarchy = createHierarchy(detailClassification); when(detailClassificationRepository.findWithHierarchyById(100L)).thenReturn(Optional.of(detailClassification)); - when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, detailClassification)) + when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, hierarchy)) .thenReturn(new RetrievalContext(List.of(), List.of())); JobPostingMockGenerateResponse response = jobPostingAiService.generateMockJobPosting( @@ -119,6 +122,7 @@ void generateMockJobPostingUsesFallbackWhenNoReferencePostings() { @DisplayName("기존 공고가 있으면 fallback에서도 참고 공고 내용을 반영한다") void generateMockJobPostingUsesReferencePostingFallback() { DetailClassification detailClassification = createDetailClassification(10L, 100L, "데이터", "데이터 분석"); + ClassificationHierarchy hierarchy = createHierarchy(detailClassification); RetrievedJobPostingReference referencePosting = new RetrievedJobPostingReference( 1L, "참고 기업", @@ -129,7 +133,7 @@ void generateMockJobPostingUsesReferencePostingFallback() { 0.1 ); when(detailClassificationRepository.findWithHierarchyById(100L)).thenReturn(Optional.of(detailClassification)); - when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, detailClassification)) + when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, hierarchy)) .thenReturn(new RetrievalContext(List.of(referencePosting), List.of())); JobPostingMockGenerateResponse response = jobPostingAiService.generateMockJobPosting( @@ -148,6 +152,7 @@ void generateMockJobPostingUsesReferencePostingFallback() { @DisplayName("같은 회사와 소분류 공고가 있으면 그 공고를 우선 참고한다") void generateMockJobPostingPrefersCompanyAndDetailReferences() { DetailClassification detailClassification = createDetailClassification(10L, 100L, "데이터", "데이터 분석"); + ClassificationHierarchy hierarchy = createHierarchy(detailClassification); RetrievedJobPostingReference companySpecificPosting = new RetrievedJobPostingReference( 1L, "선택 기업", @@ -158,7 +163,7 @@ void generateMockJobPostingPrefersCompanyAndDetailReferences() { 0.1 ); when(detailClassificationRepository.findWithHierarchyById(100L)).thenReturn(Optional.of(detailClassification)); - when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, detailClassification)) + when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, hierarchy)) .thenReturn(new RetrievalContext(List.of(companySpecificPosting), List.of())); JobPostingMockGenerateResponse response = jobPostingAiService.generateMockJobPosting( @@ -175,8 +180,9 @@ void generateMockJobPostingPrefersCompanyAndDetailReferences() { @DisplayName("추천 질문 생성 실패 시 소분류 기반 fallback 질문을 반환한다") void generateMockRecommendedQuestionsUsesFallback() { DetailClassification detailClassification = createDetailClassification(10L, 100L, "백엔드", "Java/Spring"); + ClassificationHierarchy hierarchy = createHierarchy(detailClassification); when(detailClassificationRepository.findWithHierarchyById(100L)).thenReturn(Optional.of(detailClassification)); - when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, detailClassification)) + when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, hierarchy)) .thenReturn(new RetrievalContext(List.of(), List.of())); JobPostingMockQuestionResponse response = jobPostingAiService.generateMockRecommendedQuestions( @@ -192,6 +198,7 @@ void generateMockRecommendedQuestionsUsesFallback() { @DisplayName("점수화된 참고 공고 목록의 첫 공고를 우선 사용한다") void generateMockJobPostingUsesTopScoredReferenceFirst() { DetailClassification detailClassification = createDetailClassification(10L, 100L, "백엔드", "Java/Spring"); + ClassificationHierarchy hierarchy = createHierarchy(detailClassification); RetrievedJobPostingReference topScoredPosting = new RetrievedJobPostingReference( 1L, "선택 기업", @@ -211,7 +218,7 @@ void generateMockJobPostingUsesTopScoredReferenceFirst() { 0.2 ); when(detailClassificationRepository.findWithHierarchyById(100L)).thenReturn(Optional.of(detailClassification)); - when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, detailClassification)) + when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, hierarchy)) .thenReturn(new RetrievalContext(List.of(topScoredPosting, lowerPriorityPosting), List.of())); JobPostingMockGenerateResponse response = jobPostingAiService.generateMockJobPosting( @@ -261,4 +268,8 @@ private DetailClassification createDetailClassification( ReflectionTestUtils.setField(detailClassification, "id", detailClassificationId); return detailClassification; } + + private ClassificationHierarchy createHierarchy(DetailClassification detailClassification) { + return ClassificationHierarchyMapper.toHierarchy(detailClassification); + } } From 4b7f1118dc0b882d4e345c9332b04bcb35a16ea5 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Thu, 18 Jun 2026 16:02:10 +0900 Subject: [PATCH 2/4] =?UTF-8?q?Revert=20"[Fix]=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20(#62)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 8cd0ef5ea8563833dd5891e18aa16972b3479c74. --- ...8_credit_transactions_unique_reference.sql | 50 ------------------- 1 file changed, 50 deletions(-) delete mode 100644 ops/db/migrations/20260618_credit_transactions_unique_reference.sql diff --git a/ops/db/migrations/20260618_credit_transactions_unique_reference.sql b/ops/db/migrations/20260618_credit_transactions_unique_reference.sql deleted file mode 100644 index a6c9dc3..0000000 --- a/ops/db/migrations/20260618_credit_transactions_unique_reference.sql +++ /dev/null @@ -1,50 +0,0 @@ --- Manual migration to enforce credit transaction idempotency at the database level. --- Run after backing up the database. - --- Remove duplicate rows that violate the intended uniqueness rule and keep the earliest row. -WITH ranked_duplicates AS ( - SELECT - id, - ROW_NUMBER() OVER ( - PARTITION BY user_id, type, reference_id - ORDER BY id - ) AS duplicate_rank - FROM credit_transactions - WHERE reference_id IS NOT NULL -) -DELETE FROM credit_transactions -WHERE id IN ( - SELECT id - FROM ranked_duplicates - WHERE duplicate_rank > 1 -); - --- Abort before adding the constraint if duplicates still remain for any reason. -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 - FROM credit_transactions - WHERE reference_id IS NOT NULL - GROUP BY user_id, type, reference_id - HAVING COUNT(*) > 1 - ) THEN - RAISE EXCEPTION - 'Duplicate credit_transactions remain for (user_id, type, reference_id); aborting unique constraint creation.'; - END IF; -END $$; - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM information_schema.table_constraints - WHERE table_schema = current_schema() - AND table_name = 'credit_transactions' - AND constraint_name = 'uk_credit_transactions_user_type_reference' - ) THEN - ALTER TABLE credit_transactions - ADD CONSTRAINT uk_credit_transactions_user_type_reference - UNIQUE (user_id, type, reference_id); - END IF; -END $$; From 81486d19fe2267fab8ea642123d31185fe4626df Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Thu, 18 Jun 2026 16:02:51 +0900 Subject: [PATCH 3/4] =?UTF-8?q?Revert=20"Revert=20"[Fix]=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20(#62)""?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 4b7f1118dc0b882d4e345c9332b04bcb35a16ea5. --- ...8_credit_transactions_unique_reference.sql | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 ops/db/migrations/20260618_credit_transactions_unique_reference.sql diff --git a/ops/db/migrations/20260618_credit_transactions_unique_reference.sql b/ops/db/migrations/20260618_credit_transactions_unique_reference.sql new file mode 100644 index 0000000..a6c9dc3 --- /dev/null +++ b/ops/db/migrations/20260618_credit_transactions_unique_reference.sql @@ -0,0 +1,50 @@ +-- Manual migration to enforce credit transaction idempotency at the database level. +-- Run after backing up the database. + +-- Remove duplicate rows that violate the intended uniqueness rule and keep the earliest row. +WITH ranked_duplicates AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY user_id, type, reference_id + ORDER BY id + ) AS duplicate_rank + FROM credit_transactions + WHERE reference_id IS NOT NULL +) +DELETE FROM credit_transactions +WHERE id IN ( + SELECT id + FROM ranked_duplicates + WHERE duplicate_rank > 1 +); + +-- Abort before adding the constraint if duplicates still remain for any reason. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM credit_transactions + WHERE reference_id IS NOT NULL + GROUP BY user_id, type, reference_id + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION + 'Duplicate credit_transactions remain for (user_id, type, reference_id); aborting unique constraint creation.'; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE table_schema = current_schema() + AND table_name = 'credit_transactions' + AND constraint_name = 'uk_credit_transactions_user_type_reference' + ) THEN + ALTER TABLE credit_transactions + ADD CONSTRAINT uk_credit_transactions_user_type_reference + UNIQUE (user_id, type, reference_id); + END IF; +END $$; From 86b49792642ae0730c3e5a0d052388e76a276532 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Thu, 18 Jun 2026 16:10:43 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[Fix]=20=EB=AA=A8=EC=9D=98=20=EA=B3=B5?= =?UTF-8?q?=EA=B3=A0=20=EC=A7=88=EB=AC=B8=20=EC=BA=90=EC=8B=9C=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=9D=98=20lazy=20=EC=BB=AC=EB=A0=89=EC=85=98=20?= =?UTF-8?q?=EC=A7=81=EB=A0=AC=ED=99=94=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/ClassificationHierarchy.java | 10 --- .../mapper/ClassificationHierarchyMapper.java | 20 ------ .../service/CorpusRetrievalService.java | 56 +++++++--------- .../service/JobPostingAiService.java | 67 +++++++++---------- .../service/MockQuestionCacheService.java | 10 ++- .../service/JobPostingAiServiceTest.java | 21 ++---- 6 files changed, 67 insertions(+), 117 deletions(-) delete mode 100644 src/main/java/com/jobdri/jobdri_api/domain/classification/dto/ClassificationHierarchy.java delete mode 100644 src/main/java/com/jobdri/jobdri_api/domain/classification/mapper/ClassificationHierarchyMapper.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/classification/dto/ClassificationHierarchy.java b/src/main/java/com/jobdri/jobdri_api/domain/classification/dto/ClassificationHierarchy.java deleted file mode 100644 index e75d210..0000000 --- a/src/main/java/com/jobdri/jobdri_api/domain/classification/dto/ClassificationHierarchy.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.jobdri.jobdri_api.domain.classification.dto; - -public record ClassificationHierarchy( - Long detailClassificationId, - String detailClassificationName, - Long middleClassificationId, - String middleClassificationName, - String bigClassificationName -) { -} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/classification/mapper/ClassificationHierarchyMapper.java b/src/main/java/com/jobdri/jobdri_api/domain/classification/mapper/ClassificationHierarchyMapper.java deleted file mode 100644 index 65fbab5..0000000 --- a/src/main/java/com/jobdri/jobdri_api/domain/classification/mapper/ClassificationHierarchyMapper.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.jobdri.jobdri_api.domain.classification.mapper; - -import com.jobdri.jobdri_api.domain.classification.dto.ClassificationHierarchy; -import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; - -public final class ClassificationHierarchyMapper { - - private ClassificationHierarchyMapper() { - } - - public static ClassificationHierarchy toHierarchy(DetailClassification detailClassification) { - return new ClassificationHierarchy( - detailClassification.getId(), - detailClassification.getDetailName(), - detailClassification.getMiddleClassification().getId(), - detailClassification.getMiddleClassification().getMiddleName(), - detailClassification.getMiddleClassification().getClassification().getBigName() - ); - } -} 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 b52d6a6..777d931 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 @@ -1,9 +1,7 @@ package com.jobdri.jobdri_api.domain.corpus.service; import com.jobdri.jobdri_api.domain.analysis.entity.Question; -import com.jobdri.jobdri_api.domain.classification.dto.ClassificationHierarchy; import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; -import com.jobdri.jobdri_api.domain.classification.mapper.ClassificationHierarchyMapper; import com.jobdri.jobdri_api.domain.company.entity.Company; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; import com.pgvector.PGvector; @@ -38,37 +36,31 @@ public class CorpusRetrievalService { public RetrievalContext retrieveForAnalysis(JobPosting jobPosting, List questions) { String jdQuery = buildAnalysisJobPostingQuery(jobPosting); String questionQuery = buildAnalysisQuestionQuery(jobPosting, questions); - ClassificationHierarchy classificationHierarchy = - ClassificationHierarchyMapper.toHierarchy(jobPosting.getDetailClassification()); return new RetrievalContext( - StringUtils.hasText(jdQuery) - ? findSimilarJobPostings(jobPosting.getCompany(), classificationHierarchy, jdQuery, jdLimit) - : List.of(), - StringUtils.hasText(questionQuery) - ? findSimilarQuestions(jobPosting.getCompany(), classificationHierarchy, questionQuery, questionLimit) - : List.of() + StringUtils.hasText(jdQuery) ? findSimilarJobPostings(jobPosting.getCompany(), jobPosting.getDetailClassification(), jdQuery, jdLimit) : List.of(), + StringUtils.hasText(questionQuery) ? findSimilarQuestions(jobPosting.getCompany(), jobPosting.getDetailClassification(), questionQuery, questionLimit) : List.of() ); } - public RetrievalContext retrieveForMockGeneration(Company company, ClassificationHierarchy classificationHierarchy) { - String baseQuery = buildMockBaseQuery(company, classificationHierarchy); + public RetrievalContext retrieveForMockGeneration(Company company, DetailClassification detailClassification) { + String baseQuery = buildMockBaseQuery(company, detailClassification); float[] vector = corpusEmbeddingClient.embedQuery(baseQuery); return new RetrievalContext( - findSimilarJobPostings(company, classificationHierarchy, baseQuery, vector, jdLimit), - findSimilarQuestions(company, classificationHierarchy, baseQuery, vector, questionLimit) + findSimilarJobPostings(company, detailClassification, baseQuery, vector, jdLimit), + findSimilarQuestions(company, detailClassification, baseQuery, vector, questionLimit) ); } private List findSimilarJobPostings( Company company, - ClassificationHierarchy classificationHierarchy, + DetailClassification detailClassification, String query, int limit ) { return findSimilarJobPostings( company, - classificationHierarchy, + detailClassification, query, corpusEmbeddingClient.embedQuery(query), limit @@ -77,7 +69,7 @@ private List findSimilarJobPostings( private List findSimilarJobPostings( Company company, - ClassificationHierarchy classificationHierarchy, + DetailClassification detailClassification, String query, float[] vector, int limit @@ -140,7 +132,7 @@ AND lower(c.company_name) = lower(?) companyAndDetailSql, vector, statement -> { - statement.setObject(2, classificationHierarchy.detailClassificationId()); + statement.setObject(2, detailClassification.getId()); statement.setString(3, company.getName()); statement.setObject(4, new PGvector(vector)); statement.setInt(5, limit); @@ -155,7 +147,7 @@ AND lower(c.company_name) = lower(?) detailOnlySql, vector, statement -> { - statement.setObject(2, classificationHierarchy.detailClassificationId()); + statement.setObject(2, detailClassification.getId()); statement.setObject(3, new PGvector(vector)); statement.setInt(4, limit); } @@ -169,8 +161,8 @@ AND lower(c.company_name) = lower(?) hierarchySql, vector, statement -> { - statement.setString(2, classificationHierarchy.bigClassificationName()); - statement.setString(3, classificationHierarchy.middleClassificationName()); + statement.setString(2, detailClassification.getMiddleClassification().getClassification().getBigName()); + statement.setString(3, detailClassification.getMiddleClassification().getMiddleName()); statement.setObject(4, new PGvector(vector)); statement.setInt(5, limit); } @@ -182,13 +174,13 @@ AND lower(c.company_name) = lower(?) private List findSimilarQuestions( Company company, - ClassificationHierarchy classificationHierarchy, + DetailClassification detailClassification, String query, int limit ) { return findSimilarQuestions( company, - classificationHierarchy, + detailClassification, query, corpusEmbeddingClient.embedQuery(query), limit @@ -197,7 +189,7 @@ private List findSimilarQuestions( private List findSimilarQuestions( Company company, - ClassificationHierarchy classificationHierarchy, + DetailClassification detailClassification, String query, float[] vector, int limit @@ -260,7 +252,7 @@ AND lower(c.company_name) = lower(?) companyAndDetailSql, vector, statement -> { - statement.setObject(2, classificationHierarchy.detailClassificationId()); + statement.setObject(2, detailClassification.getId()); statement.setString(3, company.getName()); statement.setObject(4, new PGvector(vector)); statement.setInt(5, limit); @@ -275,7 +267,7 @@ AND lower(c.company_name) = lower(?) detailOnlySql, vector, statement -> { - statement.setObject(2, classificationHierarchy.detailClassificationId()); + statement.setObject(2, detailClassification.getId()); statement.setObject(3, new PGvector(vector)); statement.setInt(4, limit); } @@ -289,8 +281,8 @@ AND lower(c.company_name) = lower(?) hierarchySql, vector, statement -> { - statement.setString(2, classificationHierarchy.bigClassificationName()); - statement.setString(3, classificationHierarchy.middleClassificationName()); + statement.setString(2, detailClassification.getMiddleClassification().getClassification().getBigName()); + statement.setString(3, detailClassification.getMiddleClassification().getMiddleName()); statement.setObject(4, new PGvector(vector)); statement.setInt(5, limit); } @@ -403,16 +395,16 @@ private String buildAnalysisQuestionQuery(JobPosting jobPosting, List ).trim(); } - private String buildMockBaseQuery(Company company, ClassificationHierarchy classificationHierarchy) { + private String buildMockBaseQuery(Company company, DetailClassification detailClassification) { return """ 직무명: %s 중분류: %s 대분류: %s 회사명: %s """.formatted( - defaultString(classificationHierarchy.detailClassificationName()), - defaultString(classificationHierarchy.middleClassificationName()), - defaultString(classificationHierarchy.bigClassificationName()), + defaultString(detailClassification.getDetailName()), + defaultString(detailClassification.getMiddleClassification().getMiddleName()), + defaultString(detailClassification.getMiddleClassification().getClassification().getBigName()), defaultString(company.getName()) ).trim(); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java index 2b785f0..f6fbf32 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java @@ -1,8 +1,6 @@ package com.jobdri.jobdri_api.domain.jobposting.service; import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; -import com.jobdri.jobdri_api.domain.classification.dto.ClassificationHierarchy; -import com.jobdri.jobdri_api.domain.classification.mapper.ClassificationHierarchyMapper; import com.jobdri.jobdri_api.domain.classification.repository.DetailClassificationRepository; import com.jobdri.jobdri_api.domain.company.entity.Company; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtractRequest; @@ -80,12 +78,12 @@ public JobPostingGenerateResponse generateJobPosting(JobPostingGenerateRequest r } public JobPostingMockGenerateResponse generateMockJobPosting(JobPostingMockGenerateRequest request, Company company) { - ClassificationHierarchy classificationHierarchy = findClassificationHierarchy(request.detailClassificationId()); - validateMiddleClassification(request, classificationHierarchy); + DetailClassification detailClassification = findDetailClassification(request.detailClassificationId()); + validateMiddleClassification(request, detailClassification); RetrievalContext retrievalContext = emptyRetrievalContext(); try { - retrievalContext = corpusRetrievalService.retrieveForMockGeneration(company, classificationHierarchy); + retrievalContext = corpusRetrievalService.retrieveForMockGeneration(company, detailClassification); } catch (Exception e) { log.warn("모의 공고 생성 retrieval 실패. fallback without corpus references. message={}", e.getMessage()); log.debug("mock job posting retrieval exception", e); @@ -93,7 +91,7 @@ public JobPostingMockGenerateResponse generateMockJobPosting(JobPostingMockGener var params = ResponseCreateParams.builder() .model(extractionModel) - .input(buildMockGenerationPrompt(request, company, classificationHierarchy, retrievalContext)) + .input(buildMockGenerationPrompt(request, company, detailClassification, retrievalContext)) .temperature(0.7) .text(JobPostingMockGenerateResponse.class) .build(); @@ -104,10 +102,10 @@ public JobPostingMockGenerateResponse generateMockJobPosting(JobPostingMockGener response, JobPostingMockGenerateResponse.class ); - return normalizeMockGeneratedResponse(generated, company, classificationHierarchy); + return normalizeMockGeneratedResponse(generated, company, detailClassification); } catch (Exception e) { log.error("모의 공고 생성 OpenAI API 호출 오류: {}", e.getMessage(), e); - return createFallbackMockGeneratedResponse(company, classificationHierarchy, retrievalContext.jobPostingReferences()); + return createFallbackMockGeneratedResponse(company, detailClassification, retrievalContext.jobPostingReferences()); } } @@ -115,12 +113,12 @@ public JobPostingMockQuestionResponse generateMockRecommendedQuestions( JobPostingMockGenerateRequest request, Company company ) { - ClassificationHierarchy classificationHierarchy = findClassificationHierarchy(request.detailClassificationId()); - validateMiddleClassification(request, classificationHierarchy); + DetailClassification detailClassification = findDetailClassification(request.detailClassificationId()); + validateMiddleClassification(request, detailClassification); RetrievalContext retrievalContext = emptyRetrievalContext(); try { - retrievalContext = corpusRetrievalService.retrieveForMockGeneration(company, classificationHierarchy); + retrievalContext = corpusRetrievalService.retrieveForMockGeneration(company, detailClassification); } catch (Exception e) { log.warn("추천 질문 생성 retrieval 실패. fallback without corpus references. message={}", e.getMessage()); log.debug("mock question retrieval exception", e); @@ -128,7 +126,7 @@ public JobPostingMockQuestionResponse generateMockRecommendedQuestions( var params = ResponseCreateParams.builder() .model(extractionModel) - .input(buildMockQuestionPrompt(request, classificationHierarchy, retrievalContext)) + .input(buildMockQuestionPrompt(request, detailClassification, retrievalContext)) .temperature(0.4) .text(JobPostingMockQuestionResponse.class) .build(); @@ -139,10 +137,10 @@ public JobPostingMockQuestionResponse generateMockRecommendedQuestions( response, JobPostingMockQuestionResponse.class ); - return normalizeMockQuestionResponse(generated, classificationHierarchy); + return normalizeMockQuestionResponse(generated, detailClassification); } catch (Exception e) { log.error("모의 공고 추천 질문 생성 OpenAI API 호출 오류: {}", e.getMessage(), e); - return createFallbackMockQuestionResponse(classificationHierarchy); + return createFallbackMockQuestionResponse(detailClassification); } } @@ -443,11 +441,11 @@ private String buildGenerationPrompt(JobPostingGenerateRequest request, DetailCl private String buildMockGenerationPrompt( JobPostingMockGenerateRequest request, Company company, - ClassificationHierarchy classificationHierarchy, + DetailClassification detailClassification, RetrievalContext retrievalContext ) { - String middleName = classificationHierarchy.middleClassificationName(); - String detailName = classificationHierarchy.detailClassificationName(); + String middleName = detailClassification.getMiddleClassification().getMiddleName(); + String detailName = detailClassification.getDetailName(); String referenceText = buildReferencePostingText(retrievalContext.jobPostingReferences()); String questionReferenceText = buildReferenceQuestionText(retrievalContext.questionReferences()); @@ -508,11 +506,11 @@ private String buildMockGenerationPrompt( private String buildMockQuestionPrompt( JobPostingMockGenerateRequest request, - ClassificationHierarchy classificationHierarchy, + DetailClassification detailClassification, RetrievalContext retrievalContext ) { - String middleName = classificationHierarchy.middleClassificationName(); - String detailName = classificationHierarchy.detailClassificationName(); + String middleName = detailClassification.getMiddleClassification().getMiddleName(); + String detailName = detailClassification.getDetailName(); String referenceText = buildReferencePostingText(retrievalContext.jobPostingReferences()); String questionReferenceText = buildReferenceQuestionText(retrievalContext.questionReferences()); @@ -641,7 +639,7 @@ private JobPostingGenerateResponse normalizeGeneratedResponse(JobPostingGenerate private JobPostingMockGenerateResponse normalizeMockGeneratedResponse( JobPostingMockGenerateResponse response, Company company, - ClassificationHierarchy classificationHierarchy + DetailClassification detailClassification ) { if (response == null) { throw new GeneralException( @@ -657,7 +655,7 @@ private JobPostingMockGenerateResponse normalizeMockGeneratedResponse( String jobTitle = response.jobTitle(); if (jobTitle == null || jobTitle.isBlank()) { - jobTitle = classificationHierarchy.detailClassificationName(); + jobTitle = detailClassification.getDetailName(); } return new JobPostingMockGenerateResponse( @@ -673,7 +671,7 @@ private JobPostingMockGenerateResponse normalizeMockGeneratedResponse( private JobPostingMockQuestionResponse normalizeMockQuestionResponse( JobPostingMockQuestionResponse response, - ClassificationHierarchy classificationHierarchy + DetailClassification detailClassification ) { if (response == null) { throw new GeneralException( @@ -693,12 +691,11 @@ private JobPostingMockQuestionResponse normalizeMockQuestionResponse( return new JobPostingMockQuestionResponse(questions); } - return createFallbackMockQuestionResponse(classificationHierarchy); + return createFallbackMockQuestionResponse(detailClassification); } - private ClassificationHierarchy findClassificationHierarchy(Long detailClassificationId) { + private DetailClassification findDetailClassification(Long detailClassificationId) { return detailClassificationRepository.findWithHierarchyById(detailClassificationId) - .map(ClassificationHierarchyMapper::toHierarchy) .orElseThrow(() -> new GeneralException( GeneralErrorCode.CLASSIFICATION_NOT_FOUND, "해당 소분류를 찾을 수 없습니다. detailClassificationId=" + detailClassificationId @@ -707,9 +704,9 @@ private ClassificationHierarchy findClassificationHierarchy(Long detailClassific private void validateMiddleClassification( JobPostingMockGenerateRequest request, - ClassificationHierarchy classificationHierarchy + DetailClassification detailClassification ) { - Long actualMiddleClassificationId = classificationHierarchy.middleClassificationId(); + Long actualMiddleClassificationId = detailClassification.getMiddleClassification().getId(); if (!actualMiddleClassificationId.equals(request.middleClassificationId())) { throw new GeneralException( GeneralErrorCode.CLASSIFICATION_NOT_FOUND, @@ -767,14 +764,14 @@ private JobPostingGenerateResponse createFallbackGeneratedResponse(JobPostingGen private JobPostingMockGenerateResponse createFallbackMockGeneratedResponse( Company company, - ClassificationHierarchy classificationHierarchy, + DetailClassification detailClassification, List referencePostings ) { RetrievedJobPostingReference referencePosting = referencePostings == null || referencePostings.isEmpty() ? null : referencePostings.getFirst(); - String middleName = classificationHierarchy.middleClassificationName(); - String detailName = classificationHierarchy.detailClassificationName(); + String middleName = detailClassification.getMiddleClassification().getMiddleName(); + String detailName = detailClassification.getDetailName(); return new JobPostingMockGenerateResponse( company.getName(), @@ -793,11 +790,9 @@ private JobPostingMockGenerateResponse createFallbackMockGeneratedResponse( ); } - private JobPostingMockQuestionResponse createFallbackMockQuestionResponse( - ClassificationHierarchy classificationHierarchy - ) { - String middleName = classificationHierarchy.middleClassificationName(); - String detailName = classificationHierarchy.detailClassificationName(); + private JobPostingMockQuestionResponse createFallbackMockQuestionResponse(DetailClassification detailClassification) { + String middleName = detailClassification.getMiddleClassification().getMiddleName(); + String detailName = detailClassification.getDetailName(); return new JobPostingMockQuestionResponse(List.of( "%s 직무에 지원한 이유와 가장 관심 있는 업무를 설명해주세요.".formatted(detailName), diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheService.java index 2990167..7e95599 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheService.java @@ -35,7 +35,7 @@ public List getRecommendedQuestions(JobPostingMockGenerateRequest reques request.detailClassificationId(), PROMPT_VERSION ) - .map(MockQuestionCache::getQuestions) + .map(this::copyQuestions) .orElseGet(() -> createAndCacheQuestions(request)); } @@ -46,7 +46,7 @@ public List createAndCacheQuestions(JobPostingMockGenerateRequest reques request.detailClassificationId(), PROMPT_VERSION ) - .map(MockQuestionCache::getQuestions) + .map(this::copyQuestions) .orElseGet(() -> { DetailClassification detailClassification = detailClassificationRepository.findById(request.detailClassificationId()) .orElseThrow(() -> new GeneralException( @@ -69,7 +69,11 @@ public List createAndCacheQuestions(JobPostingMockGenerateRequest reques generated.recommendedQuestions() ) ); - return saved.getQuestions(); + return copyQuestions(saved); }); } + + private List copyQuestions(MockQuestionCache cache) { + return List.copyOf(cache.getQuestions()); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java index f297559..1007a36 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java @@ -3,8 +3,6 @@ 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; -import com.jobdri.jobdri_api.domain.classification.dto.ClassificationHierarchy; -import com.jobdri.jobdri_api.domain.classification.mapper.ClassificationHierarchyMapper; import com.jobdri.jobdri_api.domain.classification.repository.DetailClassificationRepository; import com.jobdri.jobdri_api.domain.company.entity.Company; import com.jobdri.jobdri_api.domain.company.entity.CompanySize; @@ -102,9 +100,8 @@ void generateMockJobPostingThrowsWhenDetailDoesNotBelongToMiddle() { @DisplayName("기존 공고가 없으면 분류명 기반 fallback 모의 공고를 생성한다") void generateMockJobPostingUsesFallbackWhenNoReferencePostings() { DetailClassification detailClassification = createDetailClassification(10L, 100L, "백엔드", "Java/Spring"); - ClassificationHierarchy hierarchy = createHierarchy(detailClassification); when(detailClassificationRepository.findWithHierarchyById(100L)).thenReturn(Optional.of(detailClassification)); - when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, hierarchy)) + when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, detailClassification)) .thenReturn(new RetrievalContext(List.of(), List.of())); JobPostingMockGenerateResponse response = jobPostingAiService.generateMockJobPosting( @@ -122,7 +119,6 @@ void generateMockJobPostingUsesFallbackWhenNoReferencePostings() { @DisplayName("기존 공고가 있으면 fallback에서도 참고 공고 내용을 반영한다") void generateMockJobPostingUsesReferencePostingFallback() { DetailClassification detailClassification = createDetailClassification(10L, 100L, "데이터", "데이터 분석"); - ClassificationHierarchy hierarchy = createHierarchy(detailClassification); RetrievedJobPostingReference referencePosting = new RetrievedJobPostingReference( 1L, "참고 기업", @@ -133,7 +129,7 @@ void generateMockJobPostingUsesReferencePostingFallback() { 0.1 ); when(detailClassificationRepository.findWithHierarchyById(100L)).thenReturn(Optional.of(detailClassification)); - when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, hierarchy)) + when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, detailClassification)) .thenReturn(new RetrievalContext(List.of(referencePosting), List.of())); JobPostingMockGenerateResponse response = jobPostingAiService.generateMockJobPosting( @@ -152,7 +148,6 @@ void generateMockJobPostingUsesReferencePostingFallback() { @DisplayName("같은 회사와 소분류 공고가 있으면 그 공고를 우선 참고한다") void generateMockJobPostingPrefersCompanyAndDetailReferences() { DetailClassification detailClassification = createDetailClassification(10L, 100L, "데이터", "데이터 분석"); - ClassificationHierarchy hierarchy = createHierarchy(detailClassification); RetrievedJobPostingReference companySpecificPosting = new RetrievedJobPostingReference( 1L, "선택 기업", @@ -163,7 +158,7 @@ void generateMockJobPostingPrefersCompanyAndDetailReferences() { 0.1 ); when(detailClassificationRepository.findWithHierarchyById(100L)).thenReturn(Optional.of(detailClassification)); - when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, hierarchy)) + when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, detailClassification)) .thenReturn(new RetrievalContext(List.of(companySpecificPosting), List.of())); JobPostingMockGenerateResponse response = jobPostingAiService.generateMockJobPosting( @@ -180,9 +175,8 @@ void generateMockJobPostingPrefersCompanyAndDetailReferences() { @DisplayName("추천 질문 생성 실패 시 소분류 기반 fallback 질문을 반환한다") void generateMockRecommendedQuestionsUsesFallback() { DetailClassification detailClassification = createDetailClassification(10L, 100L, "백엔드", "Java/Spring"); - ClassificationHierarchy hierarchy = createHierarchy(detailClassification); when(detailClassificationRepository.findWithHierarchyById(100L)).thenReturn(Optional.of(detailClassification)); - when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, hierarchy)) + when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, detailClassification)) .thenReturn(new RetrievalContext(List.of(), List.of())); JobPostingMockQuestionResponse response = jobPostingAiService.generateMockRecommendedQuestions( @@ -198,7 +192,6 @@ void generateMockRecommendedQuestionsUsesFallback() { @DisplayName("점수화된 참고 공고 목록의 첫 공고를 우선 사용한다") void generateMockJobPostingUsesTopScoredReferenceFirst() { DetailClassification detailClassification = createDetailClassification(10L, 100L, "백엔드", "Java/Spring"); - ClassificationHierarchy hierarchy = createHierarchy(detailClassification); RetrievedJobPostingReference topScoredPosting = new RetrievedJobPostingReference( 1L, "선택 기업", @@ -218,7 +211,7 @@ void generateMockJobPostingUsesTopScoredReferenceFirst() { 0.2 ); when(detailClassificationRepository.findWithHierarchyById(100L)).thenReturn(Optional.of(detailClassification)); - when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, hierarchy)) + when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, detailClassification)) .thenReturn(new RetrievalContext(List.of(topScoredPosting, lowerPriorityPosting), List.of())); JobPostingMockGenerateResponse response = jobPostingAiService.generateMockJobPosting( @@ -268,8 +261,4 @@ private DetailClassification createDetailClassification( ReflectionTestUtils.setField(detailClassification, "id", detailClassificationId); return detailClassification; } - - private ClassificationHierarchy createHierarchy(DetailClassification detailClassification) { - return ClassificationHierarchyMapper.toHierarchy(detailClassification); - } }