diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java index f2fbbbd..04d27a1 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java @@ -7,8 +7,25 @@ public record AnalysisLlmResponse( Integer impact, Integer completeness, String feedback, + List missingKeywords, List questionAnalyses ) { + public AnalysisLlmResponse( + Integer jobFit, + Integer impact, + Integer completeness, + String feedback, + List questionAnalyses + ) { + this(jobFit, impact, completeness, feedback, List.of(), questionAnalyses); + } + + public record MissingKeywordItem( + String keyword, + String source + ) { + } + public record QuestionAnalysisItem( Long questionId, String sentence, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisResponse.java index 9d841fb..7f9cdad 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisResponse.java @@ -15,12 +15,14 @@ public record AnalysisResponse( int impact, int completeness, String feedback, + List missingKeywords, List questions ) { public static AnalysisResponse of( Analysis analysis, MockApplyStatus status, int sequence, + List missingKeywords, List questions ) { return new AnalysisResponse( @@ -33,6 +35,7 @@ public static AnalysisResponse of( analysis.getImpact(), analysis.getCompleteness(), analysis.getFeedback(), + missingKeywords == null ? List.of() : missingKeywords, questions ); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/MissingKeywordResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/MissingKeywordResponse.java new file mode 100644 index 0000000..27464ac --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/MissingKeywordResponse.java @@ -0,0 +1,7 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.response; + +public record MissingKeywordResponse( + String keyword, + MissingKeywordSource source +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/MissingKeywordSource.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/MissingKeywordSource.java new file mode 100644 index 0000000..ced0646 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/MissingKeywordSource.java @@ -0,0 +1,38 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.response; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import java.util.Arrays; +import java.util.Optional; + +public enum MissingKeywordSource { + QUALIFICATION("qualification"), + PREFERENCE("preference"), + MAIN_TASK("mainTask"); + + private final String value; + + MissingKeywordSource(String value) { + this.value = value; + } + + @JsonValue + public String value() { + return value; + } + + @JsonCreator + public static MissingKeywordSource fromJson(String value) { + return from(value).orElse(null); + } + + public static Optional from(String value) { + if (value == null) { + return Optional.empty(); + } + return Arrays.stream(values()) + .filter(source -> source.value.equals(value.trim())) + .findFirst(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java index 4080120..10e6132 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java @@ -39,6 +39,10 @@ public class Analysis extends BaseEntity { @Column(nullable = false, columnDefinition = "TEXT") private String feedback; + @Builder.Default + @Column(name = "missing_keywords", nullable = false, columnDefinition = "TEXT DEFAULT '[]'") + private String missingKeywordsJson = "[]"; + @Builder.Default @OneToMany(mappedBy = "analysis", cascade = CascadeType.ALL, orphanRemoval = true) private List questionAnalyses = new ArrayList<>(); @@ -50,6 +54,18 @@ public static Analysis create( int impact, int completeness, String feedback + ) { + return create(mockApply, score, jobFit, impact, completeness, feedback, "[]"); + } + + public static Analysis create( + MockApply mockApply, + int score, + int jobFit, + int impact, + int completeness, + String feedback, + String missingKeywordsJson ) { return Analysis.builder() .mockApply(mockApply) @@ -58,6 +74,7 @@ public static Analysis create( .impact(impact) .completeness(completeness) .feedback(feedback) + .missingKeywordsJson(missingKeywordsJson == null ? "[]" : missingKeywordsJson) .build(); } } 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 baeaf54..dffff39 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 @@ -34,6 +34,12 @@ public class AnalysisAiClient { "impact": 55, "completeness": 67, "feedback": "한 줄 피드백", + "missingKeywords": [ + { + "keyword": "SQL 활용 경험", + "source": "qualification" + } + ], "questionAnalyses": [ { "questionId": 1, @@ -53,7 +59,8 @@ public class AnalysisAiClient { 4. jobFit, impact, completeness를 각각 평가한다. 5. 감점 금지 조건과 status 오남용 여부를 확인한다. 6. 보완이 필요한 원문 문장을 문항당 최대 3개만 추출한다. - 7. 지정된 JSON만 반환한다. + 7. JD에는 있지만 자소서에 충분히 드러나지 않은 역량을 missingKeywords로 최대 3개 추출한다. + 8. 지정된 JSON만 반환한다. [jobFit 평가 기준] JD가 요구하는 역량, 경험, 기술을 자기소개서가 얼마나 증명하는지 평가한다. @@ -128,6 +135,18 @@ public class AnalysisAiClient { - 동일하거나 거의 동일한 문장을 중복 반환하지 않는다. - start/end index는 출력하지 않는다. 서버가 Java String character index 기준으로 계산한다. - missing은 원문에 해당 문장이 없을 수 있으므로 sentence를 임의로 만들지 않는다. + - missing은 questionAnalyses에 억지로 넣지 않는다. + + [missingKeywords 규칙] + - JD에는 있지만 자소서에 충분히 드러나지 않은 요건이나 역량을 추출한다. + - questionAnalyses와 분리해서 missingKeywords에만 넣는다. + - 최대 3개만 반환한다. + - 누락 키워드가 없으면 null이나 필드 생략이 아니라 빈 배열 []을 반환한다. + - 우선순위는 자격요건(qualification) > 우대사항(preference) > 주요 업무(mainTask)다. + - keyword는 단순 단어보다 짧은 역량 문구 형태로 작성한다. + - 가능하면 JD에 실제 들어간 표현을 유지한다. + - 중복되거나 유사한 keyword는 하나로 묶고, 대표 문구는 자격요건 표현을 우선한다. + - source는 qualification, preference, mainTask 중 하나만 사용한다. [reason 작성 규칙] - 사용자가 왜 해당 문장이 보완 대상인지 이해할 수 있게 작성한다. @@ -252,10 +271,14 @@ private String buildPrompt( - questionAnalyses의 questionId는 입력된 questionId 중 하나만 사용한다. - questionAnalyses의 status는 proven, mentioned, missing, fabricated 중 하나만 사용한다. - sentence는 answer에 포함된 정확한 substring만 사용한다. + - missing 상태를 questionAnalyses에 넣기 위해 원문에 없는 sentence를 만들지 않는다. + - missingKeywords는 최대 3개이며, 없으면 []로 출력한다. + - missingKeywords의 source는 qualification, preference, mainTask 중 하나만 사용한다. - improvement가 지시문이 아닌 완성된 한국어 평서문인지 확인한다. - 원문에 없는 경험, 기술, 도구명, 인원수, 금액, 성과 수치를 만들지 않았는지 확인한다. - fabricated를 단순 근거 부족에 사용하지 않았는지 확인한다. - jobFit, impact, completeness는 0~100 정수로 출력한다. + - 총점 score는 서버가 jobFit 50%, impact 30%, completeness 20%로 계산하므로 출력하지 않는다. """.formatted( OUTPUT_SCHEMA, EVALUATION_CRITERIA, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java index 5f41f40..0385b48 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java @@ -1,8 +1,13 @@ package com.jobdri.jobdri_api.domain.analysis.service; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisQuestionResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnalysisResponse; import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; import com.jobdri.jobdri_api.domain.analysis.entity.Question; @@ -22,6 +27,7 @@ import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -38,15 +44,20 @@ import java.util.stream.Collectors; @Service +@Slf4j @RequiredArgsConstructor @Transactional(readOnly = true) public class AnalysisService { private static final int MIN_SCORE = 0; private static final int MAX_SCORE = 100; private static final int MAX_ANALYSES_PER_QUESTION = 3; - private static final double JOB_FIT_WEIGHT = 0.40; - private static final double IMPACT_WEIGHT = 0.35; - private static final double COMPLETENESS_WEIGHT = 0.25; + private static final int MAX_MISSING_KEYWORDS = 3; + private static final int MAX_MISSING_KEYWORD_LENGTH = 60; + private static final double JOB_FIT_WEIGHT = 0.50; + private static final double IMPACT_WEIGHT = 0.30; + private static final double COMPLETENESS_WEIGHT = 0.20; + private static final TypeReference> MISSING_KEYWORDS_TYPE = new TypeReference<>() { + }; private final MockApplyRepository mockApplyRepository; private final QuestionRepository questionRepository; @@ -55,6 +66,7 @@ public class AnalysisService { private final JobPostingService jobPostingService; private final AnalysisAiClient analysisAiClient; private final CreditService creditService; + private final ObjectMapper objectMapper; @Transactional @AuditLogEvent(action = "ANALYSIS_RUN", targetType = "MOCK_APPLY", targetId = "#arg1") @@ -152,6 +164,7 @@ public AnalysisResponse finalizeAnalysis( int jobFit = validateScore("jobFit", llmResponse.jobFit()); int impact = validateScore("impact", llmResponse.impact()); int completeness = validateScore("completeness", llmResponse.completeness()); + List missingKeywords = buildMissingKeywords(llmResponse); replaceExistingAnalysis(mockApply); Analysis analysis = analysisRepository.save(Analysis.create( @@ -160,7 +173,8 @@ public AnalysisResponse finalizeAnalysis( jobFit, impact, completeness, - normalizeFeedback(llmResponse.feedback()) + normalizeFeedback(llmResponse.feedback()), + serializeMissingKeywords(missingKeywords) )); List questionAnalyses = buildQuestionAnalyses( @@ -172,7 +186,7 @@ public AnalysisResponse finalizeAnalysis( questionAnalysisRepository.saveAll(questionAnalyses); mockApply.updateStatus(MockApplyStatus.COMPLETED); - return toResponse(mockApply, analysis, questions, questionAnalyses); + return toResponse(mockApply, analysis, questions, questionAnalyses, readMissingKeywords(analysis)); } public AnalysisResponse getAnalysis(User user, Long mockApplyId) { @@ -186,7 +200,13 @@ public AnalysisResponse getAnalysis(User user, Long mockApplyId) { List questionAnalyses = questionAnalysisRepository.findAllByAnalysisIdOrderByQuestionIdAscIdAsc(analysis.getId()); - return toResponse(mockApply, analysis, questions, questionAnalyses); + return toResponse( + mockApply, + analysis, + questions, + questionAnalyses, + readMissingKeywords(analysis) + ); } public AnalysisResponse getAnalysisByJobPostingSequence(User user, Long jobPostingId, int sequence) { @@ -201,7 +221,13 @@ public AnalysisResponse getAnalysisByJobPostingSequence(User user, Long jobPosti List questionAnalyses = questionAnalysisRepository.findAllByAnalysisIdOrderByQuestionIdAscIdAsc(analysis.getId()); - return toResponse(mockApply, analysis, questions, questionAnalyses); + return toResponse( + mockApply, + analysis, + questions, + questionAnalyses, + readMissingKeywords(analysis) + ); } private MockApply resolveMockApplyBySequence(JobPosting jobPosting, int sequence) { @@ -327,7 +353,8 @@ private AnalysisResponse toResponse( MockApply mockApply, Analysis analysis, List questions, - List questionAnalyses + List questionAnalyses, + List missingKeywords ) { Map> analysesByQuestionId = questionAnalyses.stream() .collect(Collectors.groupingBy( @@ -347,10 +374,114 @@ private AnalysisResponse toResponse( analysis, mockApply.getStatus(), mockApplyRepository.calculateSequence(mockApply), + missingKeywords, questionResponses ); } + private List buildMissingKeywords(AnalysisLlmResponse llmResponse) { + if (llmResponse == null || llmResponse.missingKeywords() == null) { + return List.of(); + } + + List result = new ArrayList<>(); + Set seenKeywords = new HashSet<>(); + + for (AnalysisLlmResponse.MissingKeywordItem item : llmResponse.missingKeywords()) { + if (item == null || !StringUtils.hasText(item.keyword())) { + continue; + } + + String keyword = item.keyword().trim(); + if (keyword.length() > MAX_MISSING_KEYWORD_LENGTH) { + continue; + } + + Optional source = MissingKeywordSource.from(item.source()); + if (source.isEmpty()) { + continue; + } + + String dedupeKey = normalizeKeyword(keyword); + if (!seenKeywords.add(dedupeKey)) { + continue; + } + + result.add(new MissingKeywordResponse(keyword, source.get())); + if (result.size() >= MAX_MISSING_KEYWORDS) { + break; + } + } + + return result; + } + + private String normalizeKeyword(String keyword) { + return keyword == null ? "" : keyword.replaceAll("\\s+", "").toLowerCase(); + } + + private String serializeMissingKeywords(List missingKeywords) { + try { + return objectMapper.writeValueAsString(missingKeywords == null ? List.of() : missingKeywords); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize missingKeywords. Fallback to empty array.", e); + return "[]"; + } + } + + private List readMissingKeywords(Analysis analysis) { + if (analysis == null || !StringUtils.hasText(analysis.getMissingKeywordsJson())) { + return List.of(); + } + + try { + List missingKeywords = objectMapper.readValue( + analysis.getMissingKeywordsJson(), + MISSING_KEYWORDS_TYPE + ); + return sanitizeStoredMissingKeywords(missingKeywords); + } catch (Exception e) { + log.warn( + "Failed to deserialize missingKeywords. analysisId={}, fallback to empty array.", + analysis.getId(), + e + ); + return List.of(); + } + } + + private List sanitizeStoredMissingKeywords(List missingKeywords) { + if (missingKeywords == null) { + return List.of(); + } + + List result = new ArrayList<>(); + Set seenKeywords = new HashSet<>(); + + for (MissingKeywordResponse item : missingKeywords) { + if (item == null || !StringUtils.hasText(item.keyword()) || item.source() == null) { + continue; + } + + String keyword = item.keyword().trim(); + if (keyword.length() > MAX_MISSING_KEYWORD_LENGTH) { + continue; + } + + String dedupeKey = normalizeKeyword(keyword); + if (!seenKeywords.add(dedupeKey)) { + continue; + } + + result.add(new MissingKeywordResponse(keyword, item.source())); + if (result.size() >= MAX_MISSING_KEYWORDS) { + break; + } + } + + return result; + } + private MockApply getOwnedMockApply(User user, Long mockApplyId) { MockApply mockApply = mockApplyRepository.findById(mockApplyId) .orElseThrow(() -> new GeneralException( diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 27e5d3d..e31007c 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -39,3 +39,6 @@ CREATE INDEX IF NOT EXISTS idx_mock_job_posting_embeddings_hnsw CREATE INDEX IF NOT EXISTS idx_mock_question_embeddings_hnsw ON mock_question_embeddings USING hnsw (embedding vector_cosine_ops); + +ALTER TABLE IF EXISTS analyses + ADD COLUMN IF NOT EXISTS missing_keywords TEXT NOT NULL DEFAULT '[]'; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java index 60e1887..6731d53 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java @@ -27,11 +27,13 @@ import com.jobdri.jobdri_api.domain.user.repository.UserRepository; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import jakarta.persistence.EntityManager; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; import java.util.List; @@ -81,6 +83,12 @@ class AnalysisServiceTest { @Autowired private CreditTransactionRepository creditTransactionRepository; + @Autowired + private EntityManager entityManager; + + @Autowired + private JdbcTemplate jdbcTemplate; + @MockBean private AnalysisAiClient analysisAiClient; @@ -115,6 +123,7 @@ void analyzeSavesAnalysis() { assertThat(response.jobFit()).isEqualTo(82); assertThat(response.impact()).isEqualTo(71); assertThat(response.completeness()).isEqualTo(80); + assertThat(response.missingKeywords()).isEmpty(); assertThat(response.questions()).hasSize(1); assertThat(response.questions().get(0).analyses()).hasSize(1); assertThat(response.questions().get(0).analyses().get(0).status()).isEqualTo("mentioned"); @@ -153,7 +162,7 @@ void analyzeCalculatesScoreFromDimensionScores() { AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); - assertThat(response.score()).isEqualTo(40); + assertThat(response.score()).isEqualTo(50); assertThat(response.jobFit()).isEqualTo(100); assertThat(response.impact()).isZero(); assertThat(response.completeness()).isZero(); @@ -182,6 +191,71 @@ void analyzeThrowsAndRefundsWhenDimensionScoreOutOfRange() { assertThat(analysisRepository.findByMockApplyId(mockApply.getId())).isEmpty(); } + @Test + @DisplayName("missingKeywords는 검증 후 최대 3개까지 응답에 포함한다") + void analyzeReturnsValidatedMissingKeywords() { + User user = saveUser("analysis-missing-keywords@example.com"); + MockApply mockApply = saveMockApply(user); + saveQuestion(mockApply, "지원 직무 경험", "Spring Boot API를 개발했습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 80, + 70, + 60, + "누락 키워드 검증입니다.", + List.of( + new AnalysisLlmResponse.MissingKeywordItem("SQL 활용 경험", "qualification"), + new AnalysisLlmResponse.MissingKeywordItem(" ", "qualification"), + new AnalysisLlmResponse.MissingKeywordItem("SQL 활용 경험", "preference"), + new AnalysisLlmResponse.MissingKeywordItem("잘못된 출처", "unknown"), + new AnalysisLlmResponse.MissingKeywordItem( + "이 키워드는 너무 길어서 응답에서 제외되어야 하는 매우 긴 역량 문구이며 허용 길이를 명확하게 초과하는 잘못된 누락 키워드입니다", + "mainTask" + ), + new AnalysisLlmResponse.MissingKeywordItem("대용량 트래픽 처리 경험", "preference"), + new AnalysisLlmResponse.MissingKeywordItem("테스트 자동화 경험", "mainTask") + ), + List.of() + )); + + AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); + + assertThat(response.missingKeywords()).hasSize(3); + assertThat(response.missingKeywords()).extracting("keyword") + .containsExactly("SQL 활용 경험", "대용량 트래픽 처리 경험", "테스트 자동화 경험"); + assertThat(response.missingKeywords()).extracting(keyword -> keyword.source().value()) + .containsExactly("qualification", "preference", "mainTask"); + + Analysis analysis = analysisRepository.findByMockApplyId(mockApply.getId()).orElseThrow(); + assertThat(analysis.getMissingKeywordsJson()) + .contains("\"keyword\":\"SQL 활용 경험\"", "\"source\":\"qualification\"") + .contains("\"keyword\":\"대용량 트래픽 처리 경험\"", "\"source\":\"preference\"") + .contains("\"keyword\":\"테스트 자동화 경험\"", "\"source\":\"mainTask\"") + .doesNotContain("잘못된 출처") + .doesNotContain("이 키워드는 너무 길어서"); + } + + @Test + @DisplayName("missingKeywords가 null이면 빈 배열로 응답한다") + void analyzeReturnsEmptyMissingKeywordsWhenLlmMissingKeywordsIsNull() { + User user = saveUser("analysis-missing-keywords-null@example.com"); + MockApply mockApply = saveMockApply(user); + saveQuestion(mockApply, "지원 직무 경험", "Spring Boot API를 개발했습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 80, + 70, + 60, + "누락 키워드가 없습니다.", + null, + List.of() + )); + + AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); + + assertThat(response.missingKeywords()).isEmpty(); + Analysis analysis = analysisRepository.findByMockApplyId(mockApply.getId()).orElseThrow(); + assertThat(analysis.getMissingKeywordsJson()).isEqualTo("[]"); + } + @Test @DisplayName("분석 응답은 같은 공고 기준 현재 지원 순번을 반환한다") void analyzeReturnsSequence() { @@ -619,6 +693,73 @@ void getAnalysis() { assertThat(response.questions().get(0).analyses()).hasSize(1); } + @Test + @DisplayName("조회 응답은 JD와 답변으로 재계산하지 않고 DB에 저장된 missingKeywords를 반환한다") + void getAnalysisReturnsPersistedMissingKeywordsWithoutRecalculation() { + User user = saveUser("analysis-get-missing-keywords@example.com"); + JobPosting jobPosting = saveJobPosting( + user, + "- 테스트 자동화 경험", + "- SQL 활용 경험", + "- 대용량 트래픽 처리 경험" + ); + MockApply mockApply = mockApplyRepository.save(MockApply.create(user, jobPosting, ApplyType.ACTUAL)); + saveQuestion(mockApply, "지원 직무 경험", "Spring Boot API를 개발했습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 80, + 70, + 60, + "저장된 분석입니다.", + List.of(new AnalysisLlmResponse.MissingKeywordItem("LLM 저장 키워드", "qualification")), + List.of() + )); + AnalysisResponse saved = analysisService.analyze(user, mockApply.getId()); + entityManager.clear(); + + Analysis persisted = analysisRepository.findByMockApplyId(mockApply.getId()).orElseThrow(); + assertThat(persisted.getMissingKeywordsJson()) + .contains("\"keyword\":\"LLM 저장 키워드\"", "\"source\":\"qualification\""); + entityManager.clear(); + + AnalysisResponse response = analysisService.getAnalysis(user, mockApply.getId()); + + assertThat(saved.missingKeywords()).extracting("keyword") + .containsExactly("LLM 저장 키워드"); + assertThat(response.missingKeywords()).extracting("keyword") + .containsExactly("LLM 저장 키워드"); + assertThat(response.missingKeywords()).extracting(keyword -> keyword.source().value()) + .containsExactly("qualification"); + } + + @Test + @DisplayName("저장된 missingKeywords JSON이 깨져 있어도 조회 응답은 빈 배열로 fallback한다") + void getAnalysisReturnsEmptyMissingKeywordsWhenPersistedJsonIsMalformed() { + User user = saveUser("analysis-get-malformed-missing-keywords@example.com"); + MockApply mockApply = saveMockApply(user); + saveQuestion(mockApply, "지원 직무 경험", "Spring Boot API를 개발했습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 80, + 70, + 60, + "저장된 분석입니다.", + List.of(new AnalysisLlmResponse.MissingKeywordItem("SQL 활용 경험", "qualification")), + List.of() + )); + AnalysisResponse saved = analysisService.analyze(user, mockApply.getId()); + + jdbcTemplate.update( + "UPDATE analyses SET missing_keywords = ? WHERE id = ?", + "not-json", + saved.analysisId() + ); + entityManager.clear(); + + AnalysisResponse response = analysisService.getAnalysis(user, mockApply.getId()); + + assertThat(response.analysisId()).isEqualTo(saved.analysisId()); + assertThat(response.missingKeywords()).isEmpty(); + } + @Test @DisplayName("jobPosting 기준 sequence로 특정 회차 분석 결과를 조회한다") void getAnalysisByJobPostingSequence() { @@ -801,15 +942,19 @@ private AnalysisLlmResponse.QuestionAnalysisItem analysisItem(Long questionId, S } private JobPosting saveJobPosting(User user) { + return saveJobPosting(user, "주요 업무", "자격 요건", "우대 사항"); + } + + private JobPosting saveJobPosting(User user, String task, String requirement, String preferred) { Company company = companyRepository.save(Company.create("분석 테스트 기업", CompanySize.MEDIUM)); DetailClassification detailClassification = saveDetailClassification(); return jobPostingRepository.save(JobPosting.create( user, company, detailClassification, - "주요 업무", - "자격 요건", - "우대 사항" + task, + requirement, + preferred )); }