diff --git a/ops/db/migrations/20260615_mock_embedding_vector_1024.sql b/ops/db/migrations/20260615_mock_embedding_vector_1024.sql new file mode 100644 index 0000000..4d6802b --- /dev/null +++ b/ops/db/migrations/20260615_mock_embedding_vector_1024.sql @@ -0,0 +1,36 @@ +-- Manual migration for environments that already have embedding tables created +-- with an unbounded vector column. Run after backing up the database. + +CREATE EXTENSION IF NOT EXISTS vector; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'mock_job_posting_embeddings' + AND column_name = 'embedding' + ) THEN + ALTER TABLE mock_job_posting_embeddings + ALTER COLUMN embedding TYPE vector(1024); + END IF; +END $$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'mock_question_embeddings' + AND column_name = 'embedding' + ) THEN + ALTER TABLE mock_question_embeddings + ALTER COLUMN embedding TYPE vector(1024); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_mock_job_posting_embeddings_hnsw + ON mock_job_posting_embeddings USING hnsw (embedding vector_cosine_ops); + +CREATE INDEX IF NOT EXISTS idx_mock_question_embeddings_hnsw + ON mock_question_embeddings USING hnsw (embedding vector_cosine_ops); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisAdminController.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisAdminController.java new file mode 100644 index 0000000..5b153b6 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisAdminController.java @@ -0,0 +1,37 @@ +package com.jobdri.jobdri_api.domain.analysis.controller; + +import com.jobdri.jobdri_api.domain.analysis.dto.request.AnalysisRetrievalPreviewRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisRetrievalPreviewResponse; +import com.jobdri.jobdri_api.domain.analysis.service.AnalysisAdminDebugService; +import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/analysis") +@Tag(name = "AnalysisAdmin", description = "관리자용 자소서 분석 디버그 API") +public class AnalysisAdminController { + + private final AnalysisAdminDebugService analysisAdminDebugService; + + @Operation( + summary = "분석 retrieval 미리보기", + description = "mockApplyId를 기준으로 실제 분석 전에 조회되는 유사 JD/문항 검색 결과를 반환합니다." + ) + @PostMapping("/retrieval-preview") + public ApiResponse previewRetrieval( + @Valid @RequestBody AnalysisRetrievalPreviewRequest request + ) { + return ApiResponse.onSuccess( + "분석 retrieval 미리보기에 성공했습니다.", + analysisAdminDebugService.previewRetrieval(request.mockApplyId()) + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/AnalysisRetrievalPreviewRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/AnalysisRetrievalPreviewRequest.java new file mode 100644 index 0000000..7c05701 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/AnalysisRetrievalPreviewRequest.java @@ -0,0 +1,11 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +public record AnalysisRetrievalPreviewRequest( + @NotNull(message = "mockApplyId는 필수입니다.") + @Positive(message = "mockApplyId는 1 이상이어야 합니다.") + Long mockApplyId +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisRetrievalPreviewResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisRetrievalPreviewResponse.java new file mode 100644 index 0000000..24719d7 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisRetrievalPreviewResponse.java @@ -0,0 +1,50 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.response; + +import java.util.List; + +public record AnalysisRetrievalPreviewResponse( + Long mockApplyId, + JobPostingSnapshot jobPosting, + List questions, + List similarJobPostings, + List similarQuestions +) { + public record JobPostingSnapshot( + Long jobPostingId, + String companyName, + String detailClassificationName, + String task, + String requirement, + String preferred + ) { + } + + public record QuestionSnapshot( + Long questionId, + String content, + String answer + ) { + } + + public record JobPostingReference( + Long corpusId, + String companyName, + String roleName, + String responsibilities, + String requirements, + String preferred, + double distance + ) { + } + + public record QuestionReference( + Long corpusId, + String companyName, + String roleName, + String questionType, + Integer charLimit, + String questionText, + double distance + ) { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAdminDebugService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAdminDebugService.java new file mode 100644 index 0000000..f7bb3f2 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAdminDebugService.java @@ -0,0 +1,80 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisRetrievalPreviewResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; +import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService; +import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AnalysisAdminDebugService { + + private final MockApplyRepository mockApplyRepository; + private final QuestionRepository questionRepository; + private final CorpusRetrievalService corpusRetrievalService; + + public AnalysisRetrievalPreviewResponse previewRetrieval(Long mockApplyId) { + MockApply mockApply = mockApplyRepository.findByIdWithJobPosting(mockApplyId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.MOCK_APPLY_NOT_FOUND, + "해당 모의 서류 지원을 찾을 수 없습니다. mockApplyId=" + mockApplyId + )); + List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApplyId); + JobPosting jobPosting = mockApply.getJobPosting(); + + RetrievalContext referenceContext = corpusRetrievalService.retrieveForAnalysis(jobPosting, questions); + + return new AnalysisRetrievalPreviewResponse( + mockApply.getId(), + new AnalysisRetrievalPreviewResponse.JobPostingSnapshot( + jobPosting.getId(), + jobPosting.getCompany().getName(), + jobPosting.getDetailClassification().getDetailName(), + jobPosting.getTask(), + jobPosting.getRequirement(), + jobPosting.getPreferred() + ), + questions.stream() + .map(question -> new AnalysisRetrievalPreviewResponse.QuestionSnapshot( + question.getId(), + question.getContent(), + question.getAnswer() + )) + .toList(), + referenceContext.jobPostingReferences().stream() + .map(reference -> new AnalysisRetrievalPreviewResponse.JobPostingReference( + reference.corpusId(), + reference.companyName(), + reference.roleName(), + reference.responsibilities(), + reference.requirements(), + reference.preferred(), + reference.distance() + )) + .toList(), + referenceContext.questionReferences().stream() + .map(reference -> new AnalysisRetrievalPreviewResponse.QuestionReference( + reference.corpusId(), + reference.companyName(), + reference.roleName(), + reference.questionType(), + reference.charLimit(), + reference.questionText(), + reference.distance() + )) + .toList() + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java index 710b3bb..7ce9eda 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java @@ -2,6 +2,10 @@ import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService; +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.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; @@ -20,16 +24,26 @@ @Slf4j @RequiredArgsConstructor public class AnalysisAiClient { + private static final int MAX_REFERENCE_SECTION_LENGTH = 3000; + private static final int MAX_REFERENCE_FIELD_LENGTH = 300; private final OpenAIClient openAIClient; + private final CorpusRetrievalService corpusRetrievalService; @Value("${openai.model.cover-letter-analysis:gpt-4o-mini}") private String analysisModel; public AnalysisLlmResponse analyze(JobPosting jobPosting, List questions) { + RetrievalContext referenceContext = emptyContext(); + try { + referenceContext = corpusRetrievalService.retrieveForAnalysis(jobPosting, questions); + } catch (Exception e) { + log.warn("자소서 분석 retrieval 실패. mock analysis will continue without references. message={}", e.getMessage()); + log.debug("analysis retrieval exception", e); + } var params = ResponseCreateParams.builder() .model(analysisModel) - .input(buildPrompt(jobPosting, questions)) + .input(buildPrompt(jobPosting, questions, referenceContext)) .temperature(0.2) .text(AnalysisLlmResponse.class) .build(); @@ -48,7 +62,11 @@ public AnalysisLlmResponse analyze(JobPosting jobPosting, List questio } } - private String buildPrompt(JobPosting jobPosting, List questions) { + private String buildPrompt( + JobPosting jobPosting, + List questions, + RetrievalContext referenceContext + ) { String questionText = questions.stream() .map(question -> """ - questionId: %d @@ -61,6 +79,9 @@ private String buildPrompt(JobPosting jobPosting, List questions) { )) .reduce("", (left, right) -> left + "\n" + right); + String similarJobPostingText = formatJobPostingReferences(referenceContext.jobPostingReferences()); + String similarQuestionText = formatQuestionReferences(referenceContext.questionReferences()); + return """ [시스템 지시] 너는 한국 채용 담당자이자 자기소개서 평가 전문가다. @@ -126,6 +147,12 @@ private String buildPrompt(JobPosting jobPosting, List questions) { 우대 사항: %s + [유사 JD 검색 결과] + %s + + [유사 자소서 문항 검색 결과] + %s + [자소서 문항과 답변] %s @@ -148,10 +175,62 @@ private String buildPrompt(JobPosting jobPosting, List questions) { defaultString(jobPosting.getTask()), defaultString(jobPosting.getRequirement()), defaultString(jobPosting.getPreferred()), + similarJobPostingText, + similarQuestionText, questionText ); } + private String formatJobPostingReferences(List references) { + if (references == null || references.isEmpty()) { + return "없음"; + } + String formatted = references.stream() + .map(reference -> """ + - 회사명: %s + 직무명: %s + 주요 업무: %s + 자격 요건: %s + 우대 사항: %s + 거리: %.4f + """.formatted( + truncate(defaultString(reference.companyName()), MAX_REFERENCE_FIELD_LENGTH), + truncate(defaultString(reference.roleName()), MAX_REFERENCE_FIELD_LENGTH), + truncate(defaultString(reference.responsibilities()), MAX_REFERENCE_FIELD_LENGTH), + truncate(defaultString(reference.requirements()), MAX_REFERENCE_FIELD_LENGTH), + truncate(defaultString(reference.preferred()), MAX_REFERENCE_FIELD_LENGTH), + reference.distance() + )) + .reduce("", (left, right) -> left + "\n" + right) + .trim(); + return truncate(formatted, MAX_REFERENCE_SECTION_LENGTH); + } + + private String formatQuestionReferences(List references) { + if (references == null || references.isEmpty()) { + return "없음"; + } + String formatted = references.stream() + .map(reference -> """ + - 회사명: %s + 직무명: %s + 문항 유형: %s + 글자 수 제한: %s + 문항: %s + 거리: %.4f + """.formatted( + truncate(defaultString(reference.companyName()), MAX_REFERENCE_FIELD_LENGTH), + truncate(defaultString(reference.roleName()), MAX_REFERENCE_FIELD_LENGTH), + truncate(defaultString(reference.questionType()), MAX_REFERENCE_FIELD_LENGTH), + reference.charLimit() == null ? "" : reference.charLimit(), + truncate(defaultString(reference.questionText()), MAX_REFERENCE_FIELD_LENGTH), + reference.distance() + )) + .reduce("", (left, right) -> left + "\n" + right) + .trim(); + return truncate(formatted, MAX_REFERENCE_SECTION_LENGTH); + } + private AnalysisLlmResponse extractStructuredContent(StructuredResponse response) { return response.output().stream() .filter(item -> item.message().isPresent()) @@ -168,4 +247,15 @@ private AnalysisLlmResponse extractStructuredContent(StructuredResponse { List findAllByMiddleClassificationId(Long middleClassificationId); - Optional findByDetailName(String detailName); - long countByDetailName(String detailName); + Optional findByDetailNameIgnoreCase(String detailName); + long countByDetailNameIgnoreCase(String detailName); @Query(""" SELECT dc diff --git a/src/main/java/com/jobdri/jobdri_api/domain/corpus/controller/CorpusAdminController.java b/src/main/java/com/jobdri/jobdri_api/domain/corpus/controller/CorpusAdminController.java index 7fa4253..291c2cd 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/corpus/controller/CorpusAdminController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/corpus/controller/CorpusAdminController.java @@ -7,16 +7,20 @@ import com.jobdri.jobdri_api.domain.corpus.service.CorpusImportResult; import com.jobdri.jobdri_api.domain.corpus.service.CorpusImportService; import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.io.IOException; +import java.nio.file.InvalidPathException; import java.nio.file.Path; @RestController @@ -28,14 +32,18 @@ public class CorpusAdminController { private final CorpusImportService corpusImportService; private final CorpusEmbeddingSyncService corpusEmbeddingSyncService; + @Value("${app.corpus.import.allowed-root:}") + private String allowedImportRoot; + @Operation(summary = "corpus 엑셀 적재", description = "관리자가 xlsx 파일 경로를 넘겨 corpus 원본 테이블에 적재합니다.") @PostMapping("/import") public ApiResponse importCorpus( @Valid @RequestBody CorpusImportRequest request ) throws IOException { + Path validatedPath = validateImportPath(request.filePath()); return ApiResponse.onSuccess( "corpus 엑셀 적재에 성공했습니다.", - corpusImportService.importFromXlsx(Path.of(request.filePath())) + corpusImportService.importFromXlsx(validatedPath) ); } @@ -49,4 +57,36 @@ public ApiResponse syncEmbeddings( corpusEmbeddingSyncService.syncAll(request.limit()) ); } + + private Path validateImportPath(String rawPath) { + if (allowedImportRoot == null || allowedImportRoot.isBlank()) { + throw new GeneralException( + GeneralErrorCode.SERVICE_UNAVAILABLE, + "corpus import 허용 경로가 설정되지 않았습니다." + ); + } + + try { + Path requestedPath = Path.of(rawPath).toAbsolutePath().normalize(); + Path allowedRootPath = Path.of(allowedImportRoot).toAbsolutePath().normalize().toRealPath(); + Path resolvedPath = requestedPath.toRealPath(); + if (!resolvedPath.startsWith(allowedRootPath)) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "허용된 import 경로 밖의 파일에는 접근할 수 없습니다." + ); + } + return resolvedPath; + } catch (InvalidPathException e) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "유효하지 않은 파일 경로입니다." + ); + } catch (IOException e) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "접근 가능한 import 파일 경로가 아닙니다." + ); + } + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/corpus/repository/MockJobPostingCorpusRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/corpus/repository/MockJobPostingCorpusRepository.java index 8b905bc..0f36868 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/corpus/repository/MockJobPostingCorpusRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/corpus/repository/MockJobPostingCorpusRepository.java @@ -2,6 +2,7 @@ import com.jobdri.jobdri_api.domain.corpus.entity.MockJobPostingCorpus; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.domain.Pageable; import java.util.List; import java.util.Optional; @@ -11,5 +12,7 @@ public interface MockJobPostingCorpusRepository extends JpaRepository findAllByCompanyId(Long companyId); - List findAllByValidForEmbeddingTrueAndEmbeddingTextIsNotNullOrderByIdAsc(); + List findAllByValidForEmbeddingTrueOrderByIdAsc(Pageable pageable); + + List findAllByValidForEmbeddingTrueOrderByIdAsc(); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/corpus/repository/MockQuestionCorpusRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/corpus/repository/MockQuestionCorpusRepository.java index 239af0a..84502b9 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/corpus/repository/MockQuestionCorpusRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/corpus/repository/MockQuestionCorpusRepository.java @@ -2,6 +2,7 @@ import com.jobdri.jobdri_api.domain.corpus.entity.MockQuestionCorpus; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.domain.Pageable; import java.util.List; import java.util.Optional; @@ -13,5 +14,7 @@ public interface MockQuestionCorpusRepository extends JpaRepository findAllByCompanyId(Long companyId); + List findAllByValidForEmbeddingTrueAndEmbeddingTextIsNotNullOrderByIdAsc(Pageable pageable); + List findAllByValidForEmbeddingTrueAndEmbeddingTextIsNotNullOrderByIdAsc(); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/BootstrapAdminService.java b/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/BootstrapAdminService.java index 4d1b602..dfd6641 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/BootstrapAdminService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/BootstrapAdminService.java @@ -31,7 +31,11 @@ public void promoteConfiguredAdmins() { .toList(); for (String email : emails) { - userRepository.findByEmail(email).ifPresent(this::promoteIfNeeded); + userRepository.findByEmail(email) + .ifPresentOrElse( + this::promoteIfNeeded, + () -> log.warn("bootstrap admin 대상 사용자를 찾지 못했습니다. email={}", email) + ); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CohereCorpusEmbeddingClient.java b/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CohereCorpusEmbeddingClient.java index f5d747a..7d4ffb4 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CohereCorpusEmbeddingClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CohereCorpusEmbeddingClient.java @@ -4,10 +4,12 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.client.RestClient; +import java.time.Duration; import java.util.List; @Component @@ -25,11 +27,8 @@ public class CohereCorpusEmbeddingClient implements CorpusEmbeddingClient { @Value("${app.corpus.embedding.output-dimension:1024}") private int outputDimension; - @Value("${app.corpus.embedding.document-input-type:search_document}") - private String documentInputType; - @Override - public List embed(List texts) { + public List embed(List texts, InputType inputType) { if (!StringUtils.hasText(cohereApiKey)) { throw new IllegalStateException("Cohere API 키가 설정되지 않았습니다."); } @@ -37,8 +36,13 @@ public List embed(List texts) { return List.of(); } + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(Duration.ofSeconds(5)); + requestFactory.setReadTimeout(Duration.ofSeconds(10)); + RestClient client = restClientBuilder .baseUrl("https://api.cohere.com") + .requestFactory(requestFactory) .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + cohereApiKey) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .build(); @@ -48,7 +52,7 @@ public List embed(List texts) { .body(new EmbedRequest( texts, embeddingModel, - documentInputType, + inputType.value(), outputDimension, List.of("float") )) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusAdminRunner.java b/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusAdminRunner.java index 54d203a..b02575a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusAdminRunner.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusAdminRunner.java @@ -29,16 +29,24 @@ public class CorpusAdminRunner implements ApplicationRunner { private final CorpusEmbeddingSyncService corpusEmbeddingSyncService; @Override - public void run(ApplicationArguments args) throws Exception { + public void run(ApplicationArguments args) { bootstrapAdminService.promoteConfiguredAdmins(); if (runImportOnStartup && StringUtils.hasText(importXlsxPath)) { - CorpusImportResult result = corpusImportService.importFromXlsx(Path.of(importXlsxPath)); - log.info("corpus import 완료: {}", result); + try { + CorpusImportResult result = corpusImportService.importFromXlsx(Path.of(importXlsxPath)); + log.info("corpus import 완료: {}", result); + } catch (Exception e) { + log.error("startup corpus import 실패. path={}", importXlsxPath, e); + } } if (syncEmbeddingsOnStartup) { - log.info("corpus embedding sync 완료: {}", corpusEmbeddingSyncService.syncAll(null)); + try { + log.info("corpus embedding sync 완료: {}", corpusEmbeddingSyncService.syncAll(null)); + } catch (Exception e) { + log.error("startup corpus embedding sync 실패", e); + } } } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusClassificationResolver.java b/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusClassificationResolver.java index 99842d6..bb5577f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusClassificationResolver.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusClassificationResolver.java @@ -4,6 +4,8 @@ import com.jobdri.jobdri_api.domain.classification.repository.DetailClassificationRepository; import com.jobdri.jobdri_api.domain.corpus.entity.CorpusClassificationMapping; import com.jobdri.jobdri_api.domain.corpus.repository.CorpusClassificationMappingRepository; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -54,8 +56,8 @@ public Optional resolve( } } - if (detailClassificationRepository.countByDetailName(normalizedRole) == 1) { - return detailClassificationRepository.findByDetailName(normalizedRole); + if (detailClassificationRepository.countByDetailNameIgnoreCase(normalizedRole) == 1) { + return detailClassificationRepository.findByDetailNameIgnoreCase(normalizedRole); } return Optional.empty(); @@ -72,6 +74,15 @@ public CorpusClassificationMapping registerMapping( String normalizedJobFamily = normalize(jobFamilyL2); String normalizedRole = normalize(roleL3); + if (!StringUtils.hasText(normalizedJobGroup) + || !StringUtils.hasText(normalizedJobFamily) + || !StringUtils.hasText(normalizedRole)) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "분류 매핑을 등록하려면 대분류, 중분류, 소분류가 모두 필요합니다." + ); + } + return mappingRepository .findBySourceJobGroupL1AndSourceJobFamilyL2AndSourceRoleL3( normalizedJobGroup, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusEmbeddingClient.java b/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusEmbeddingClient.java index 184791f..eaa00c8 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusEmbeddingClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusEmbeddingClient.java @@ -3,5 +3,32 @@ import java.util.List; public interface CorpusEmbeddingClient { - List embed(List texts); + List embed(List texts, InputType inputType); + + default List embedDocuments(List texts) { + return embed(texts, InputType.SEARCH_DOCUMENT); + } + + default float[] embedQuery(String text) { + List embeddings = embed(List.of(text), InputType.SEARCH_QUERY); + if (embeddings.isEmpty()) { + throw new IllegalStateException("쿼리 임베딩 결과가 비어 있습니다."); + } + return embeddings.getFirst(); + } + + enum InputType { + SEARCH_DOCUMENT("search_document"), + SEARCH_QUERY("search_query"); + + private final String value; + + InputType(String value) { + this.value = value; + } + + public String value() { + return value; + } + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusEmbeddingSyncService.java b/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusEmbeddingSyncService.java index da15910..082bf9f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusEmbeddingSyncService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusEmbeddingSyncService.java @@ -8,8 +8,10 @@ import com.pgvector.PGvector; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.jdbc.datasource.DataSourceUtils; import javax.sql.DataSource; import java.sql.Connection; @@ -55,33 +57,33 @@ ON CONFLICT (corpus_id) private final CorpusEmbeddingClient corpusEmbeddingClient; private final DataSource dataSource; - @Transactional(readOnly = true) + @Transactional public CorpusEmbeddingSyncResponse syncAll(Integer limit) { int jobPostingCount = syncJobPostingEmbeddings(limit); int questionCount = syncQuestionEmbeddings(limit); return new CorpusEmbeddingSyncResponse(jobPostingCount, questionCount, embeddingModel); } - @Transactional(readOnly = true) + @Transactional public int syncJobPostingEmbeddings(Integer limit) { - List all = mockJobPostingCorpusRepository - .findAllByValidForEmbeddingTrueAndEmbeddingTextIsNotNullOrderByIdAsc(); - List corpusList = applyLimit(all, limit); + List corpusList = limit == null + ? mockJobPostingCorpusRepository.findAllByValidForEmbeddingTrueOrderByIdAsc() + : mockJobPostingCorpusRepository.findAllByValidForEmbeddingTrueOrderByIdAsc(PageRequest.of(0, limit)); return upsertJobPostingEmbeddings(corpusList); } - @Transactional(readOnly = true) + @Transactional public int syncQuestionEmbeddings(Integer limit) { - List all = mockQuestionCorpusRepository - .findAllByValidForEmbeddingTrueAndEmbeddingTextIsNotNullOrderByIdAsc(); - List corpusList = applyLimit(all, limit); + List corpusList = limit == null + ? mockQuestionCorpusRepository.findAllByValidForEmbeddingTrueAndEmbeddingTextIsNotNullOrderByIdAsc() + : mockQuestionCorpusRepository.findAllByValidForEmbeddingTrueAndEmbeddingTextIsNotNullOrderByIdAsc(PageRequest.of(0, limit)); return upsertQuestionEmbeddings(corpusList); } private int upsertJobPostingEmbeddings(List corpusList) { int processed = 0; for (List batch : partition(corpusList, batchSize)) { - List embeddings = corpusEmbeddingClient.embed( + List embeddings = corpusEmbeddingClient.embedDocuments( batch.stream().map(MockJobPostingCorpus::getEmbeddingText).toList() ); upsertVectors(UPSERT_JOB_POSTING_SQL, batch.stream().map(MockJobPostingCorpus::getId).toList(), embeddings); @@ -93,7 +95,7 @@ private int upsertJobPostingEmbeddings(List corpusList) { private int upsertQuestionEmbeddings(List corpusList) { int processed = 0; for (List batch : partition(corpusList, batchSize)) { - List embeddings = corpusEmbeddingClient.embed( + List embeddings = corpusEmbeddingClient.embedDocuments( batch.stream().map(MockQuestionCorpus::getEmbeddingText).toList() ); upsertVectors(UPSERT_QUESTION_SQL, batch.stream().map(MockQuestionCorpus::getId).toList(), embeddings); @@ -107,7 +109,8 @@ private void upsertVectors(String sql, List ids, List embeddings) throw new IllegalStateException("임베딩 결과 개수가 corpus 개수와 일치하지 않습니다."); } - try (Connection connection = dataSource.getConnection()) { + Connection connection = DataSourceUtils.getConnection(dataSource); + try { PGvector.registerTypes(connection); try (PreparedStatement statement = connection.prepareStatement(sql)) { Timestamp now = Timestamp.valueOf(LocalDateTime.now()); @@ -123,16 +126,11 @@ private void upsertVectors(String sql, List ids, List embeddings) } } catch (SQLException e) { throw new IllegalStateException("임베딩 벡터 저장 중 오류가 발생했습니다.", e); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); } } - private List applyLimit(List items, Integer limit) { - if (limit == null || limit >= items.size()) { - return items; - } - return items.subList(0, limit); - } - private List> partition(List items, int batchSize) { List> result = new ArrayList<>(); if (items.isEmpty()) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusImportService.java b/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusImportService.java index 0440cbd..44b8edc 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusImportService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusImportService.java @@ -7,6 +7,8 @@ import com.jobdri.jobdri_api.domain.corpus.entity.MockQuestionCorpus; import com.jobdri.jobdri_api.domain.corpus.repository.MockJobPostingCorpusRepository; import com.jobdri.jobdri_api.domain.corpus.repository.MockQuestionCorpusRepository; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; import org.apache.poi.ss.usermodel.*; import org.apache.poi.xssf.usermodel.XSSFWorkbook; @@ -18,8 +20,11 @@ import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; +import java.text.DecimalFormat; +import java.text.ParseException; import java.util.HashMap; import java.util.Iterator; +import java.util.Locale; import java.util.Map; import java.util.Optional; @@ -47,9 +52,10 @@ public CorpusImportResult importWorkbook(Workbook workbook) { DataFormatter formatter = new DataFormatter(); FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator(); ImportStats stats = new ImportStats(); + Map companyCache = new HashMap<>(); - importJobPostingSheet(workbook.getSheet(JD_SHEET_NAME), formatter, evaluator, stats); - importQuestionSheet(workbook.getSheet(QUESTION_SHEET_NAME), formatter, evaluator, stats); + importJobPostingSheet(workbook.getSheet(JD_SHEET_NAME), formatter, evaluator, stats, companyCache); + importQuestionSheet(workbook.getSheet(QUESTION_SHEET_NAME), formatter, evaluator, stats, companyCache); return stats.toResult(); } @@ -58,7 +64,8 @@ private void importJobPostingSheet( Sheet sheet, DataFormatter formatter, FormulaEvaluator evaluator, - ImportStats stats + ImportStats stats, + Map companyCache ) { if (sheet == null) { return; @@ -70,6 +77,11 @@ private void importJobPostingSheet( } Map headerMap = readHeaderMap(rows.next(), formatter, evaluator); + validateRequiredHeaders( + headerMap, + "analysis_id", "company_name", "job_group_l1", "job_family_l2", "role_l3", + "skills", "responsibilities", "requirements", "preferred", "embedding_text", "is_valid_for_embedding" + ); while (rows.hasNext()) { Row row = rows.next(); String sourceAnalysisId = getString(row, headerMap, "analysis_id", formatter, evaluator); @@ -78,7 +90,7 @@ private void importJobPostingSheet( } String companyName = getString(row, headerMap, "company_name", formatter, evaluator); - Company company = resolveCompany(companyName, stats); + Company company = resolveCompany(companyName, stats, companyCache); Optional detailClassification = resolveClassification(row, headerMap, formatter, evaluator, stats); MockJobPostingCorpus corpus = mockJobPostingCorpusRepository.findBySourceAnalysisId(sourceAnalysisId) @@ -130,7 +142,8 @@ private void importQuestionSheet( Sheet sheet, DataFormatter formatter, FormulaEvaluator evaluator, - ImportStats stats + ImportStats stats, + Map companyCache ) { if (sheet == null) { return; @@ -142,6 +155,11 @@ private void importQuestionSheet( } Map headerMap = readHeaderMap(rows.next(), formatter, evaluator); + validateRequiredHeaders( + headerMap, + "question_id", "analysis_id", "company_name", "job_group_l1", "job_family_l2", "role_l3", + "source", "question_text", "embedding_text", "is_valid_for_embedding" + ); while (rows.hasNext()) { Row row = rows.next(); String sourceQuestionId = getString(row, headerMap, "question_id", formatter, evaluator); @@ -150,7 +168,7 @@ private void importQuestionSheet( } String companyName = getString(row, headerMap, "company_name", formatter, evaluator); - Company company = resolveCompany(companyName, stats); + Company company = resolveCompany(companyName, stats, companyCache); Optional detailClassification = resolveClassification(row, headerMap, formatter, evaluator, stats); MockQuestionCorpus corpus = mockQuestionCorpusRepository.findBySourceQuestionId(sourceQuestionId) @@ -219,16 +237,22 @@ private Optional resolveClassification( return detailClassification; } - private Company resolveCompany(String companyName, ImportStats stats) { + private Company resolveCompany(String companyName, ImportStats stats, Map companyCache) { String normalizedCompanyName = normalize(companyName); if (!StringUtils.hasText(normalizedCompanyName)) { return null; } - return companyRepository.findByName(normalizedCompanyName) + Company cachedCompany = companyCache.get(normalizedCompanyName); + if (cachedCompany != null) { + return cachedCompany; + } + Company company = companyRepository.findByName(normalizedCompanyName) .orElseGet(() -> { stats.createdCompanies++; return companyRepository.save(Company.create(normalizedCompanyName, null)); }); + companyCache.put(normalizedCompanyName, company); + return company; } private Map readHeaderMap(Row headerRow, DataFormatter formatter, FormulaEvaluator evaluator) { @@ -244,6 +268,17 @@ private Map readHeaderMap(Row headerRow, DataFormatter formatte return headerMap; } + private void validateRequiredHeaders(Map headerMap, String... requiredColumns) { + for (String requiredColumn : requiredColumns) { + if (!headerMap.containsKey(requiredColumn)) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "필수 헤더가 누락되었습니다. column=" + requiredColumn + ); + } + } + } + private String getString( Row row, Map headerMap, @@ -269,7 +304,21 @@ private Integer getInteger( if (!StringUtils.hasText(value)) { return null; } - return Integer.parseInt(value); + String normalized = value.replace(",", "").trim(); + try { + if (normalized.contains(".")) { + double decimalValue = Double.parseDouble(normalized); + return (int) Math.round(decimalValue); + } + return Integer.parseInt(normalized); + } catch (NumberFormatException e) { + try { + Number parsed = DecimalFormat.getInstance(Locale.US).parse(normalized); + return parsed == null ? null : parsed.intValue(); + } catch (ParseException ignored) { + return null; + } + } } private boolean getBoolean( 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 new file mode 100644 index 0000000..777d931 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusRetrievalService.java @@ -0,0 +1,448 @@ +package com.jobdri.jobdri_api.domain.corpus.service; + +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import com.jobdri.jobdri_api.domain.company.entity.Company; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.pgvector.PGvector; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CorpusRetrievalService { + + @Value("${app.analysis.retrieval.jd-limit:3}") + private int jdLimit; + + @Value("${app.analysis.retrieval.question-limit:5}") + private int questionLimit; + + private final CorpusEmbeddingClient corpusEmbeddingClient; + private final DataSource dataSource; + + public RetrievalContext retrieveForAnalysis(JobPosting jobPosting, List questions) { + String jdQuery = buildAnalysisJobPostingQuery(jobPosting); + String questionQuery = buildAnalysisQuestionQuery(jobPosting, questions); + + 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() + ); + } + + public RetrievalContext retrieveForMockGeneration(Company company, DetailClassification detailClassification) { + String baseQuery = buildMockBaseQuery(company, detailClassification); + float[] vector = corpusEmbeddingClient.embedQuery(baseQuery); + return new RetrievalContext( + findSimilarJobPostings(company, detailClassification, baseQuery, vector, jdLimit), + findSimilarQuestions(company, detailClassification, baseQuery, vector, questionLimit) + ); + } + + private List findSimilarJobPostings( + Company company, + DetailClassification detailClassification, + String query, + int limit + ) { + return findSimilarJobPostings( + company, + detailClassification, + query, + corpusEmbeddingClient.embedQuery(query), + limit + ); + } + + private List findSimilarJobPostings( + Company company, + DetailClassification detailClassification, + String query, + float[] vector, + int limit + ) { + String companyAndDetailSql = """ + SELECT + c.id, + c.company_name, + c.role_l3, + c.responsibilities, + c.requirements, + c.preferred, + e.embedding <=> ? AS distance + FROM mock_job_posting_embeddings e + JOIN mock_job_posting_corpus c ON e.corpus_id = c.id + WHERE c.is_valid_for_embedding = true + AND c.detail_classification_id = ? + AND lower(c.company_name) = lower(?) + ORDER BY e.embedding <=> ? + LIMIT ? + """; + String detailOnlySql = """ + SELECT + c.id, + c.company_name, + c.role_l3, + c.responsibilities, + c.requirements, + c.preferred, + e.embedding <=> ? AS distance + FROM mock_job_posting_embeddings e + JOIN mock_job_posting_corpus c ON e.corpus_id = c.id + WHERE c.is_valid_for_embedding = true + AND c.detail_classification_id = ? + ORDER BY e.embedding <=> ? + LIMIT ? + """; + String hierarchySql = """ + SELECT + c.id, + c.company_name, + c.role_l3, + c.responsibilities, + c.requirements, + c.preferred, + e.embedding <=> ? AS distance + FROM mock_job_posting_embeddings e + JOIN mock_job_posting_corpus c ON e.corpus_id = c.id + WHERE c.is_valid_for_embedding = true + AND c.job_group_l1 = ? + AND c.job_family_l2 = ? + ORDER BY e.embedding <=> ? + LIMIT ? + """; + try (Connection connection = dataSource.getConnection()) { + PGvector.registerTypes(connection); + + List companyAndDetail = queryJobPostingReferences( + connection, + companyAndDetailSql, + vector, + statement -> { + statement.setObject(2, detailClassification.getId()); + statement.setString(3, company.getName()); + statement.setObject(4, new PGvector(vector)); + statement.setInt(5, limit); + } + ); + if (!companyAndDetail.isEmpty()) { + return companyAndDetail; + } + + List detailOnly = queryJobPostingReferences( + connection, + detailOnlySql, + vector, + statement -> { + statement.setObject(2, detailClassification.getId()); + statement.setObject(3, new PGvector(vector)); + statement.setInt(4, limit); + } + ); + if (!detailOnly.isEmpty()) { + return detailOnly; + } + + return queryJobPostingReferences( + connection, + hierarchySql, + vector, + statement -> { + statement.setString(2, detailClassification.getMiddleClassification().getClassification().getBigName()); + statement.setString(3, detailClassification.getMiddleClassification().getMiddleName()); + statement.setObject(4, new PGvector(vector)); + statement.setInt(5, limit); + } + ); + } catch (SQLException e) { + throw new IllegalStateException("유사 JD 검색 중 오류가 발생했습니다.", e); + } + } + + private List findSimilarQuestions( + Company company, + DetailClassification detailClassification, + String query, + int limit + ) { + return findSimilarQuestions( + company, + detailClassification, + query, + corpusEmbeddingClient.embedQuery(query), + limit + ); + } + + private List findSimilarQuestions( + Company company, + DetailClassification detailClassification, + String query, + float[] vector, + int limit + ) { + String companyAndDetailSql = """ + SELECT + c.id, + c.company_name, + c.role_l3, + c.question_type, + c.char_limit, + c.question_text, + e.embedding <=> ? AS distance + FROM mock_question_embeddings e + JOIN mock_question_corpus c ON e.corpus_id = c.id + WHERE c.is_valid_for_embedding = true + AND c.detail_classification_id = ? + AND lower(c.company_name) = lower(?) + ORDER BY e.embedding <=> ? + LIMIT ? + """; + String detailOnlySql = """ + SELECT + c.id, + c.company_name, + c.role_l3, + c.question_type, + c.char_limit, + c.question_text, + e.embedding <=> ? AS distance + FROM mock_question_embeddings e + JOIN mock_question_corpus c ON e.corpus_id = c.id + WHERE c.is_valid_for_embedding = true + AND c.detail_classification_id = ? + ORDER BY e.embedding <=> ? + LIMIT ? + """; + String hierarchySql = """ + SELECT + c.id, + c.company_name, + c.role_l3, + c.question_type, + c.char_limit, + c.question_text, + e.embedding <=> ? AS distance + FROM mock_question_embeddings e + JOIN mock_question_corpus c ON e.corpus_id = c.id + WHERE c.is_valid_for_embedding = true + AND c.job_group_l1 = ? + AND c.job_family_l2 = ? + ORDER BY e.embedding <=> ? + LIMIT ? + """; + try (Connection connection = dataSource.getConnection()) { + PGvector.registerTypes(connection); + + List companyAndDetail = queryQuestionReferences( + connection, + companyAndDetailSql, + vector, + statement -> { + statement.setObject(2, detailClassification.getId()); + statement.setString(3, company.getName()); + statement.setObject(4, new PGvector(vector)); + statement.setInt(5, limit); + } + ); + if (!companyAndDetail.isEmpty()) { + return companyAndDetail; + } + + List detailOnly = queryQuestionReferences( + connection, + detailOnlySql, + vector, + statement -> { + statement.setObject(2, detailClassification.getId()); + statement.setObject(3, new PGvector(vector)); + statement.setInt(4, limit); + } + ); + if (!detailOnly.isEmpty()) { + return detailOnly; + } + + return queryQuestionReferences( + connection, + hierarchySql, + vector, + statement -> { + statement.setString(2, detailClassification.getMiddleClassification().getClassification().getBigName()); + statement.setString(3, detailClassification.getMiddleClassification().getMiddleName()); + statement.setObject(4, new PGvector(vector)); + statement.setInt(5, limit); + } + ); + } catch (SQLException e) { + throw new IllegalStateException("유사 문항 검색 중 오류가 발생했습니다.", e); + } + } + + private List queryJobPostingReferences( + Connection connection, + String sql, + float[] vector, + StatementBinder binder + ) throws SQLException { + List result = new ArrayList<>(); + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setObject(1, new PGvector(vector)); + binder.bind(statement); + try (ResultSet rs = statement.executeQuery()) { + while (rs.next()) { + result.add(new RetrievedJobPostingReference( + rs.getLong("id"), + rs.getString("company_name"), + rs.getString("role_l3"), + rs.getString("responsibilities"), + rs.getString("requirements"), + rs.getString("preferred"), + rs.getDouble("distance") + )); + } + } + } + return result; + } + + private List queryQuestionReferences( + Connection connection, + String sql, + float[] vector, + StatementBinder binder + ) throws SQLException { + List result = new ArrayList<>(); + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setObject(1, new PGvector(vector)); + binder.bind(statement); + try (ResultSet rs = statement.executeQuery()) { + while (rs.next()) { + result.add(new RetrievedQuestionReference( + rs.getLong("id"), + rs.getString("company_name"), + rs.getString("role_l3"), + rs.getString("question_type"), + getNullableInt(rs, "char_limit"), + rs.getString("question_text"), + rs.getDouble("distance") + )); + } + } + } + return result; + } + + private Integer getNullableInt(ResultSet rs, String columnName) throws SQLException { + int value = rs.getInt(columnName); + return rs.wasNull() ? null : value; + } + + private String buildAnalysisJobPostingQuery(JobPosting jobPosting) { + return """ + 직무명: %s + 자격 요건: %s + 우대 사항: %s + 주요 업무: %s + 핵심 요구 역량 요약: %s + 우대 역량 요약: %s + 참고 회사명: %s + """.formatted( + defaultString(jobPosting.getDetailClassification().getDetailName()), + defaultString(jobPosting.getRequirement()), + defaultString(jobPosting.getPreferred()), + defaultString(jobPosting.getTask()), + defaultString(jobPosting.getRequirement()), + defaultString(jobPosting.getPreferred()), + defaultString(jobPosting.getCompany().getName()) + ).trim(); + } + + private String buildAnalysisQuestionQuery(JobPosting jobPosting, List questions) { + String questionText = questions.stream() + .map(Question::getContent) + .filter(StringUtils::hasText) + .map(text -> "- " + text) + .reduce("", (left, right) -> left + "\n" + right) + .trim(); + + return """ + 직무명: %s + 자격 요건: %s + 우대 사항: %s + 자소서 문항: + %s + 참고 회사명: %s + """.formatted( + defaultString(jobPosting.getDetailClassification().getDetailName()), + defaultString(jobPosting.getRequirement()), + defaultString(jobPosting.getPreferred()), + questionText, + defaultString(jobPosting.getCompany().getName()) + ).trim(); + } + + private String buildMockBaseQuery(Company company, DetailClassification detailClassification) { + return """ + 직무명: %s + 중분류: %s + 대분류: %s + 회사명: %s + """.formatted( + defaultString(detailClassification.getDetailName()), + defaultString(detailClassification.getMiddleClassification().getMiddleName()), + defaultString(detailClassification.getMiddleClassification().getClassification().getBigName()), + defaultString(company.getName()) + ).trim(); + } + + private String defaultString(String value) { + return value == null ? "" : value; + } + + public record RetrievalContext( + List jobPostingReferences, + List questionReferences + ) { + } + + public record RetrievedJobPostingReference( + Long corpusId, + String companyName, + String roleName, + String responsibilities, + String requirements, + String preferred, + double distance + ) { + } + + public record RetrievedQuestionReference( + Long corpusId, + String companyName, + String roleName, + String questionType, + Integer charLimit, + String questionText, + double distance + ) { + } + + @FunctionalInterface + private interface StatementBinder { + void bind(PreparedStatement statement) throws SQLException; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/MockQuestionCache.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/MockQuestionCache.java index 6d62e52..c7ccb69 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/MockQuestionCache.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/MockQuestionCache.java @@ -1,6 +1,7 @@ package com.jobdri.jobdri_api.domain.jobposting.entity; import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import com.jobdri.jobdri_api.domain.company.entity.Company; import jakarta.persistence.CollectionTable; import jakarta.persistence.Column; import jakarta.persistence.ElementCollection; @@ -31,8 +32,8 @@ @Table( name = "mock_question_caches", uniqueConstraints = @UniqueConstraint( - name = "uk_mock_question_cache_detail_version", - columnNames = {"detail_classification_id", "prompt_version"} + name = "uk_mock_question_cache_company_detail_version", + columnNames = {"company_id", "detail_classification_id", "prompt_version"} ) ) public class MockQuestionCache { @@ -45,6 +46,10 @@ public class MockQuestionCache { @JoinColumn(name = "detail_classification_id", nullable = false) private DetailClassification detailClassification; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "company_id", nullable = false) + private Company company; + @Column(name = "prompt_version", nullable = false, length = 50) private String promptVersion; @@ -59,11 +64,13 @@ public class MockQuestionCache { private List questions = new ArrayList<>(); public static MockQuestionCache create( + Company company, DetailClassification detailClassification, String promptVersion, List questions ) { return MockQuestionCache.builder() + .company(company) .detailClassification(detailClassification) .promptVersion(promptVersion) .questions(new ArrayList<>(questions)) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/MockQuestionCacheRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/MockQuestionCacheRepository.java index 28c5e7a..2e3c115 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/MockQuestionCacheRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/MockQuestionCacheRepository.java @@ -6,5 +6,9 @@ import java.util.Optional; public interface MockQuestionCacheRepository extends JpaRepository { - Optional findByDetailClassification_IdAndPromptVersion(Long detailClassificationId, String promptVersion); + Optional findByCompany_IdAndDetailClassification_IdAndPromptVersion( + Long companyId, + Long detailClassificationId, + String promptVersion + ); } 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 38071cc..b8deb3d 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 @@ -13,7 +13,10 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockGenerateResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockQuestionResponse; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; -import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; +import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService; +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.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import com.openai.client.OpenAIClient; @@ -36,7 +39,7 @@ public class JobPostingAiService { private final OpenAIClient openAIClient; private final DetailClassificationRepository detailClassificationRepository; - private final JobPostingRepository jobPostingRepository; + private final CorpusRetrievalService corpusRetrievalService; private final JobPostingImageStorageService jobPostingImageStorageService; @Value("${openai.model.job-posting-extractor:gpt-4o-mini}") @@ -78,11 +81,17 @@ public JobPostingMockGenerateResponse generateMockJobPosting(JobPostingMockGener DetailClassification detailClassification = findDetailClassification(request.detailClassificationId()); validateMiddleClassification(request, detailClassification); - List referencePostings = findMockReferencePostings(request, company); + RetrievalContext retrievalContext = emptyRetrievalContext(); + try { + 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); + } var params = ResponseCreateParams.builder() .model(extractionModel) - .input(buildMockGenerationPrompt(request, company, detailClassification, referencePostings)) + .input(buildMockGenerationPrompt(request, company, detailClassification, retrievalContext)) .temperature(0.7) .text(JobPostingMockGenerateResponse.class) .build(); @@ -96,19 +105,28 @@ public JobPostingMockGenerateResponse generateMockJobPosting(JobPostingMockGener return normalizeMockGeneratedResponse(generated, company, detailClassification); } catch (Exception e) { log.error("모의 공고 생성 OpenAI API 호출 오류: {}", e.getMessage(), e); - return createFallbackMockGeneratedResponse(company, detailClassification, referencePostings); + return createFallbackMockGeneratedResponse(company, detailClassification, retrievalContext.jobPostingReferences()); } } - public JobPostingMockQuestionResponse generateMockRecommendedQuestions(JobPostingMockGenerateRequest request) { + public JobPostingMockQuestionResponse generateMockRecommendedQuestions( + JobPostingMockGenerateRequest request, + Company company + ) { DetailClassification detailClassification = findDetailClassification(request.detailClassificationId()); validateMiddleClassification(request, detailClassification); - List referencePostings = findMockReferencePostings(request, null); + RetrievalContext retrievalContext = emptyRetrievalContext(); + try { + 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); + } var params = ResponseCreateParams.builder() .model(extractionModel) - .input(buildMockQuestionPrompt(request, detailClassification, referencePostings)) + .input(buildMockQuestionPrompt(request, detailClassification, retrievalContext)) .temperature(0.4) .text(JobPostingMockQuestionResponse.class) .build(); @@ -424,11 +442,12 @@ private String buildMockGenerationPrompt( JobPostingMockGenerateRequest request, Company company, DetailClassification detailClassification, - List referencePostings + RetrievalContext retrievalContext ) { String middleName = detailClassification.getMiddleClassification().getMiddleName(); String detailName = detailClassification.getDetailName(); - String referenceText = buildReferencePostingText(referencePostings); + String referenceText = buildReferencePostingText(retrievalContext.jobPostingReferences()); + String questionReferenceText = buildReferenceQuestionText(retrievalContext.questionReferences()); return """ 아래 직무 분류를 바탕으로 한국어 모의 채용 공고 초안을 작성해주세요. @@ -471,24 +490,29 @@ private String buildMockGenerationPrompt( [같은 소분류의 기존 공고 참고 자료] %s + + [같은 조건의 유사 자소서 문항 참고 자료] + %s """.formatted( company.getName(), request.middleClassificationId(), middleName, request.detailClassificationId(), detailName, - referenceText + referenceText, + questionReferenceText ); } private String buildMockQuestionPrompt( JobPostingMockGenerateRequest request, DetailClassification detailClassification, - List referencePostings + RetrievalContext retrievalContext ) { String middleName = detailClassification.getMiddleClassification().getMiddleName(); String detailName = detailClassification.getDetailName(); - String referenceText = buildReferencePostingText(referencePostings); + String referenceText = buildReferencePostingText(retrievalContext.jobPostingReferences()); + String questionReferenceText = buildReferenceQuestionText(retrievalContext.questionReferences()); return """ 아래 직무 분류와 참고 공고를 바탕으로, 모의 지원자에게 제시할 추천 질문 5개를 작성해주세요. @@ -522,22 +546,30 @@ private String buildMockQuestionPrompt( [같은 소분류의 기존 공고 참고 자료] %s + + [같은 조건의 유사 자소서 문항 참고 자료] + %s """.formatted( request.middleClassificationId(), middleName, request.detailClassificationId(), detailName, - referenceText + referenceText, + questionReferenceText ); } - private String buildReferencePostingText(List referencePostings) { + private String buildReferencePostingText(List referencePostings) { if (referencePostings == null || referencePostings.isEmpty()) { return "참고 가능한 기존 공고가 없습니다."; } return referencePostings.stream() .map(jobPosting -> """ + - 회사명: + %s + - 직무명: + %s - 주요 업무: %s - 자격 요건: @@ -545,18 +577,40 @@ private String buildReferencePostingText(List referencePostings) { - 우대 사항: %s """.formatted( - truncateForPrompt(jobPosting.getTask()), - truncateForPrompt(jobPosting.getRequirement()), - truncateForPrompt(jobPosting.getPreferred()) + truncateForPrompt(jobPosting.companyName()), + truncateForPrompt(jobPosting.roleName()), + truncateForPrompt(jobPosting.responsibilities()), + truncateForPrompt(jobPosting.requirements()), + truncateForPrompt(jobPosting.preferred()) )) .collect(Collectors.joining("\n")); } - private List findMockReferencePostings(JobPostingMockGenerateRequest request, Company company) { - return jobPostingRepository.findTop5ReferencePostings( - company == null ? null : company.getId(), - request.detailClassificationId() - ); + private String buildReferenceQuestionText(List referenceQuestions) { + if (referenceQuestions == null || referenceQuestions.isEmpty()) { + return "참고 가능한 유사 자소서 문항이 없습니다."; + } + + return referenceQuestions.stream() + .map(question -> """ + - 회사명: + %s + - 직무명: + %s + - 문항 유형: + %s + - 글자 수 제한: + %s + - 문항: + %s + """.formatted( + truncateForPrompt(question.companyName()), + truncateForPrompt(question.roleName()), + truncateForPrompt(question.questionType()), + question.charLimit() == null ? "" : question.charLimit(), + truncateForPrompt(question.questionText()) + )) + .collect(Collectors.joining("\n")); } private JobPostingGenerateResponse normalizeGeneratedResponse(JobPostingGenerateResponse response, JobPostingGenerateRequest request) { @@ -711,9 +765,9 @@ private JobPostingGenerateResponse createFallbackGeneratedResponse(JobPostingGen private JobPostingMockGenerateResponse createFallbackMockGeneratedResponse( Company company, DetailClassification detailClassification, - List referencePostings + List referencePostings ) { - JobPosting referencePosting = referencePostings == null || referencePostings.isEmpty() + RetrievedJobPostingReference referencePosting = referencePostings == null || referencePostings.isEmpty() ? null : referencePostings.getFirst(); String middleName = detailClassification.getMiddleClassification().getMiddleName(); @@ -724,13 +778,13 @@ private JobPostingMockGenerateResponse createFallbackMockGeneratedResponse( detailName, referencePosting == null ? "%s 직무의 기본 업무를 수행하며, 서비스 개발과 운영 과정에 참여합니다.".formatted(detailName) - : defaultString(referencePosting.getTask()), + : defaultString(referencePosting.responsibilities()), referencePosting == null ? "%s 분야에 대한 기본 이해와 협업 역량을 갖춘 분을 찾습니다.".formatted(detailName) - : defaultString(referencePosting.getRequirement()), + : defaultString(referencePosting.requirements()), referencePosting == null ? "관련 프로젝트 경험 또는 %s 분야 학습 경험이 있으면 좋습니다.".formatted(middleName) - : defaultString(referencePosting.getPreferred()), + : defaultString(referencePosting.preferred()), "%s/%s 직무 기반으로 생성된 신입 및 주니어 대상 모의 공고입니다.".formatted(middleName, detailName), List.of() ); @@ -767,6 +821,10 @@ private String defaultString(String value) { return value == null ? "" : value; } + private RetrievalContext emptyRetrievalContext() { + return new RetrievalContext(List.of(), List.of()); + } + private boolean hasText(String value) { return value != null && !value.isBlank(); } 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 ae4fe0e..2990167 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 @@ -2,6 +2,8 @@ import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; 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.repository.CompanyRepository; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingMockGenerateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockQuestionResponse; import com.jobdri.jobdri_api.domain.jobposting.entity.MockQuestionCache; @@ -23,18 +25,27 @@ public class MockQuestionCacheService { private final MockQuestionCacheRepository mockQuestionCacheRepository; private final DetailClassificationRepository detailClassificationRepository; + private final CompanyRepository companyRepository; private final JobPostingAiService jobPostingAiService; public List getRecommendedQuestions(JobPostingMockGenerateRequest request) { return mockQuestionCacheRepository - .findByDetailClassification_IdAndPromptVersion(request.detailClassificationId(), PROMPT_VERSION) + .findByCompany_IdAndDetailClassification_IdAndPromptVersion( + request.companyId(), + request.detailClassificationId(), + PROMPT_VERSION + ) .map(MockQuestionCache::getQuestions) .orElseGet(() -> createAndCacheQuestions(request)); } public List createAndCacheQuestions(JobPostingMockGenerateRequest request) { return mockQuestionCacheRepository - .findByDetailClassification_IdAndPromptVersion(request.detailClassificationId(), PROMPT_VERSION) + .findByCompany_IdAndDetailClassification_IdAndPromptVersion( + request.companyId(), + request.detailClassificationId(), + PROMPT_VERSION + ) .map(MockQuestionCache::getQuestions) .orElseGet(() -> { DetailClassification detailClassification = detailClassificationRepository.findById(request.detailClassificationId()) @@ -42,11 +53,17 @@ public List createAndCacheQuestions(JobPostingMockGenerateRequest reques GeneralErrorCode.CLASSIFICATION_NOT_FOUND, "해당 소분류를 찾을 수 없습니다. detailClassificationId=" + request.detailClassificationId() )); + Company company = companyRepository.findById(request.companyId()) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.COMPANY_NOT_FOUND, + "해당 회사를 찾을 수 없습니다. companyId=" + request.companyId() + )); JobPostingMockQuestionResponse generated = - jobPostingAiService.generateMockRecommendedQuestions(request); + jobPostingAiService.generateMockRecommendedQuestions(request, company); MockQuestionCache saved = mockQuestionCacheRepository.save( MockQuestionCache.create( + company, detailClassification, PROMPT_VERSION, generated.recommendedQuestions() diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 6fc91f0..b444e85 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -74,12 +74,17 @@ app: import: run-on-startup: ${APP_CORPUS_IMPORT_RUN_ON_STARTUP:false} xlsx-path: ${APP_CORPUS_IMPORT_XLSX_PATH:} + allowed-root: ${APP_CORPUS_IMPORT_ALLOWED_ROOT:} embedding: sync-on-startup: ${APP_CORPUS_EMBEDDING_SYNC_ON_STARTUP:false} model: ${APP_CORPUS_EMBEDDING_MODEL:embed-v4.0} output-dimension: ${APP_CORPUS_EMBEDDING_OUTPUT_DIMENSION:1024} document-input-type: ${APP_CORPUS_EMBEDDING_DOCUMENT_INPUT_TYPE:search_document} batch-size: ${APP_CORPUS_EMBEDDING_BATCH_SIZE:32} + analysis: + retrieval: + jd-limit: ${APP_ANALYSIS_RETRIEVAL_JD_LIMIT:3} + question-limit: ${APP_ANALYSIS_RETRIEVAL_QUESTION_LIMIT:5} server: port: 8080 diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 07b08e8..a0d4eff 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -74,12 +74,17 @@ app: import: run-on-startup: ${APP_CORPUS_IMPORT_RUN_ON_STARTUP:false} xlsx-path: ${APP_CORPUS_IMPORT_XLSX_PATH:} + allowed-root: ${APP_CORPUS_IMPORT_ALLOWED_ROOT:} embedding: sync-on-startup: ${APP_CORPUS_EMBEDDING_SYNC_ON_STARTUP:false} model: ${APP_CORPUS_EMBEDDING_MODEL:embed-v4.0} output-dimension: ${APP_CORPUS_EMBEDDING_OUTPUT_DIMENSION:1024} document-input-type: ${APP_CORPUS_EMBEDDING_DOCUMENT_INPUT_TYPE:search_document} batch-size: ${APP_CORPUS_EMBEDDING_BATCH_SIZE:32} + analysis: + retrieval: + jd-limit: ${APP_ANALYSIS_RETRIEVAL_JD_LIMIT:3} + question-limit: ${APP_ANALYSIS_RETRIEVAL_QUESTION_LIMIT:5} server: port: 8080 diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index d2c0e0a..50a37d6 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,3 +1,5 @@ spring: profiles: active: ${SPRING_PROFILES_ACTIVE:dev} + jpa: + defer-datasource-initialization: true diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 4902207..27e5d3d 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS mock_job_posting_embeddings ( id BIGSERIAL PRIMARY KEY, corpus_id BIGINT NOT NULL UNIQUE REFERENCES mock_job_posting_corpus(id) ON DELETE CASCADE, embedding_model VARCHAR(100) NOT NULL, - embedding vector NOT NULL, + embedding vector(1024) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -14,7 +14,7 @@ CREATE TABLE IF NOT EXISTS mock_question_embeddings ( id BIGSERIAL PRIMARY KEY, corpus_id BIGINT NOT NULL UNIQUE REFERENCES mock_question_corpus(id) ON DELETE CASCADE, embedding_model VARCHAR(100) NOT NULL, - embedding vector NOT NULL, + embedding vector(1024) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -33,3 +33,9 @@ CREATE INDEX IF NOT EXISTS idx_mock_job_posting_embeddings_corpus CREATE INDEX IF NOT EXISTS idx_mock_question_embeddings_corpus ON mock_question_embeddings (corpus_id); + +CREATE INDEX IF NOT EXISTS idx_mock_job_posting_embeddings_hnsw + ON mock_job_posting_embeddings USING hnsw (embedding vector_cosine_ops); + +CREATE INDEX IF NOT EXISTS idx_mock_question_embeddings_hnsw + ON mock_question_embeddings USING hnsw (embedding vector_cosine_ops); 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 7de1202..9a0b388 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 @@ -6,13 +6,14 @@ 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; +import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService; +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.dto.request.JobPostingGenerateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingMockGenerateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingGenerateResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockGenerateResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockQuestionResponse; -import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; -import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingImageStorageService; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; @@ -46,7 +47,7 @@ class JobPostingAiServiceTest { private DetailClassificationRepository detailClassificationRepository; @Mock - private JobPostingRepository jobPostingRepository; + private CorpusRetrievalService corpusRetrievalService; @Mock private JobPostingImageStorageService jobPostingImageStorageService; @@ -58,7 +59,7 @@ void setUp() { jobPostingAiService = new JobPostingAiService( openAIClient, detailClassificationRepository, - jobPostingRepository, + corpusRetrievalService, jobPostingImageStorageService ); ReflectionTestUtils.setField(TEST_COMPANY, "id", 1L); @@ -100,7 +101,8 @@ void generateMockJobPostingThrowsWhenDetailDoesNotBelongToMiddle() { void generateMockJobPostingUsesFallbackWhenNoReferencePostings() { DetailClassification detailClassification = createDetailClassification(10L, 100L, "백엔드", "Java/Spring"); when(detailClassificationRepository.findById(100L)).thenReturn(Optional.of(detailClassification)); - when(jobPostingRepository.findTop5ReferencePostings(1L, 100L)).thenReturn(List.of()); + when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, detailClassification)) + .thenReturn(new RetrievalContext(List.of(), List.of())); JobPostingMockGenerateResponse response = jobPostingAiService.generateMockJobPosting( new JobPostingMockGenerateRequest(1L, 10L, 100L), @@ -117,16 +119,18 @@ void generateMockJobPostingUsesFallbackWhenNoReferencePostings() { @DisplayName("기존 공고가 있으면 fallback에서도 참고 공고 내용을 반영한다") void generateMockJobPostingUsesReferencePostingFallback() { DetailClassification detailClassification = createDetailClassification(10L, 100L, "데이터", "데이터 분석"); - JobPosting referencePosting = JobPosting.create( - TEST_USER, - Company.create("참고 기업", CompanySize.MEDIUM), - detailClassification, + RetrievedJobPostingReference referencePosting = new RetrievedJobPostingReference( + 1L, + "참고 기업", + "데이터 분석", "기존 주요 업무", "기존 자격 요건", - "기존 우대 사항" + "기존 우대 사항", + 0.1 ); when(detailClassificationRepository.findById(100L)).thenReturn(Optional.of(detailClassification)); - when(jobPostingRepository.findTop5ReferencePostings(1L, 100L)).thenReturn(List.of(referencePosting)); + when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, detailClassification)) + .thenReturn(new RetrievalContext(List.of(referencePosting), List.of())); JobPostingMockGenerateResponse response = jobPostingAiService.generateMockJobPosting( new JobPostingMockGenerateRequest(1L, 10L, 100L), @@ -144,17 +148,18 @@ void generateMockJobPostingUsesReferencePostingFallback() { @DisplayName("같은 회사와 소분류 공고가 있으면 그 공고를 우선 참고한다") void generateMockJobPostingPrefersCompanyAndDetailReferences() { DetailClassification detailClassification = createDetailClassification(10L, 100L, "데이터", "데이터 분석"); - JobPosting companySpecificPosting = JobPosting.create( - TEST_USER, - Company.create("선택 기업", CompanySize.MEDIUM), - detailClassification, + RetrievedJobPostingReference companySpecificPosting = new RetrievedJobPostingReference( + 1L, + "선택 기업", + "데이터 분석", "회사 맞춤 주요 업무", "회사 맞춤 자격 요건", - "회사 맞춤 우대 사항" + "회사 맞춤 우대 사항", + 0.1 ); when(detailClassificationRepository.findById(100L)).thenReturn(Optional.of(detailClassification)); - when(jobPostingRepository.findTop5ReferencePostings(1L, 100L)) - .thenReturn(List.of(companySpecificPosting)); + when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, detailClassification)) + .thenReturn(new RetrievalContext(List.of(companySpecificPosting), List.of())); JobPostingMockGenerateResponse response = jobPostingAiService.generateMockJobPosting( new JobPostingMockGenerateRequest(1L, 10L, 100L), @@ -171,10 +176,12 @@ void generateMockJobPostingPrefersCompanyAndDetailReferences() { void generateMockRecommendedQuestionsUsesFallback() { DetailClassification detailClassification = createDetailClassification(10L, 100L, "백엔드", "Java/Spring"); when(detailClassificationRepository.findById(100L)).thenReturn(Optional.of(detailClassification)); - when(jobPostingRepository.findTop5ReferencePostings(null, 100L)).thenReturn(List.of()); + when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, detailClassification)) + .thenReturn(new RetrievalContext(List.of(), List.of())); JobPostingMockQuestionResponse response = jobPostingAiService.generateMockRecommendedQuestions( - new JobPostingMockGenerateRequest(1L, 10L, 100L) + new JobPostingMockGenerateRequest(1L, 10L, 100L), + TEST_COMPANY ); assertThat(response.recommendedQuestions()).hasSize(5); @@ -185,25 +192,27 @@ void generateMockRecommendedQuestionsUsesFallback() { @DisplayName("점수화된 참고 공고 목록의 첫 공고를 우선 사용한다") void generateMockJobPostingUsesTopScoredReferenceFirst() { DetailClassification detailClassification = createDetailClassification(10L, 100L, "백엔드", "Java/Spring"); - JobPosting topScoredPosting = JobPosting.create( - TEST_USER, - Company.create("선택 기업", CompanySize.MEDIUM), - detailClassification, + RetrievedJobPostingReference topScoredPosting = new RetrievedJobPostingReference( + 1L, + "선택 기업", + "Java/Spring", "회사 기반 주요 업무", "회사 기반 자격 요건", - "회사 기반 우대 사항" + "회사 기반 우대 사항", + 0.1 ); - JobPosting lowerPriorityPosting = JobPosting.create( - TEST_USER, - Company.create("다른 기업", CompanySize.MEDIUM), - detailClassification, + RetrievedJobPostingReference lowerPriorityPosting = new RetrievedJobPostingReference( + 2L, + "다른 기업", + "Java/Spring", "직무 기반 주요 업무", "직무 기반 자격 요건", - "직무 기반 우대 사항" + "직무 기반 우대 사항", + 0.2 ); when(detailClassificationRepository.findById(100L)).thenReturn(Optional.of(detailClassification)); - when(jobPostingRepository.findTop5ReferencePostings(1L, 100L)) - .thenReturn(List.of(topScoredPosting, lowerPriorityPosting)); + when(corpusRetrievalService.retrieveForMockGeneration(TEST_COMPANY, detailClassification)) + .thenReturn(new RetrievalContext(List.of(topScoredPosting, lowerPriorityPosting), List.of())); JobPostingMockGenerateResponse response = jobPostingAiService.generateMockJobPosting( new JobPostingMockGenerateRequest(1L, 10L, 100L), diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheServiceTest.java index e3ac261..55b16d9 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheServiceTest.java @@ -4,6 +4,9 @@ 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.repository.DetailClassificationRepository; +import com.jobdri.jobdri_api.domain.company.entity.Company; +import com.jobdri.jobdri_api.domain.company.entity.CompanySize; +import com.jobdri.jobdri_api.domain.company.repository.CompanyRepository; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingMockGenerateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockQuestionResponse; import com.jobdri.jobdri_api.domain.jobposting.entity.MockQuestionCache; @@ -33,6 +36,9 @@ class MockQuestionCacheServiceTest { @Mock private DetailClassificationRepository detailClassificationRepository; + @Mock + private CompanyRepository companyRepository; + @Mock private JobPostingAiService jobPostingAiService; @@ -43,6 +49,7 @@ void setUp() { mockQuestionCacheService = new MockQuestionCacheService( mockQuestionCacheRepository, detailClassificationRepository, + companyRepository, jobPostingAiService ); } @@ -51,12 +58,18 @@ void setUp() { @DisplayName("캐시가 있으면 AI 호출 없이 추천 질문을 반환한다") void getRecommendedQuestionsUsesCache() { DetailClassification detailClassification = createDetailClassification(10L, 100L, "백엔드", "Java/Spring"); + Company company = Company.create("선택 기업", CompanySize.MEDIUM); MockQuestionCache cache = MockQuestionCache.create( + company, detailClassification, MockQuestionCacheService.PROMPT_VERSION, List.of("질문 1", "질문 2") ); - when(mockQuestionCacheRepository.findByDetailClassification_IdAndPromptVersion(100L, MockQuestionCacheService.PROMPT_VERSION)) + when(mockQuestionCacheRepository.findByCompany_IdAndDetailClassification_IdAndPromptVersion( + 1L, + 100L, + MockQuestionCacheService.PROMPT_VERSION + )) .thenReturn(Optional.of(cache)); List questions = mockQuestionCacheService.getRecommendedQuestions( @@ -64,7 +77,10 @@ void getRecommendedQuestionsUsesCache() { ); assertThat(questions).containsExactly("질문 1", "질문 2"); - verify(jobPostingAiService, never()).generateMockRecommendedQuestions(org.mockito.ArgumentMatchers.any()); + verify(jobPostingAiService, never()).generateMockRecommendedQuestions( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any() + ); } @Test @@ -74,10 +90,18 @@ void createAndCacheQuestionsWhenCacheMissing() { JobPostingMockGenerateRequest request = new JobPostingMockGenerateRequest(1L, 10L, 100L); JobPostingMockQuestionResponse aiResponse = new JobPostingMockQuestionResponse(List.of("질문 A", "질문 B")); - when(mockQuestionCacheRepository.findByDetailClassification_IdAndPromptVersion(100L, MockQuestionCacheService.PROMPT_VERSION)) + when(mockQuestionCacheRepository.findByCompany_IdAndDetailClassification_IdAndPromptVersion( + 1L, + 100L, + MockQuestionCacheService.PROMPT_VERSION + )) .thenReturn(Optional.empty()); when(detailClassificationRepository.findById(100L)).thenReturn(Optional.of(detailClassification)); - when(jobPostingAiService.generateMockRecommendedQuestions(request)).thenReturn(aiResponse); + when(companyRepository.findById(1L)).thenReturn(Optional.of(Company.create("선택 기업", CompanySize.MEDIUM))); + when(jobPostingAiService.generateMockRecommendedQuestions( + org.mockito.ArgumentMatchers.eq(request), + org.mockito.ArgumentMatchers.any(Company.class) + )).thenReturn(aiResponse); when(mockQuestionCacheRepository.save(org.mockito.ArgumentMatchers.any(MockQuestionCache.class))) .thenAnswer(invocation -> invocation.getArgument(0));