diff --git a/docker-compose.yml b/docker-compose.yml index bb03b11..8e0a93e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,7 +80,7 @@ services: restart: unless-stopped postgres: - image: postgres:16-alpine + image: pgvector/pgvector:pg16@sha256:1d533553fefe4f12e5d80c7b80622ba0c382abb5758856f52983d8789179f0fb container_name: jobdri-postgres environment: POSTGRES_DB: ${POSTGRES_DB:-jobdri} 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 04d27a1..91d7c36 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,6 +7,8 @@ public record AnalysisLlmResponse( Integer impact, Integer completeness, String feedback, + List keyStrengths, + List keyWeaknesses, List missingKeywords, List questionAnalyses ) { @@ -15,9 +17,26 @@ public AnalysisLlmResponse( Integer impact, Integer completeness, String feedback, + List missingKeywords, List questionAnalyses ) { - this(jobFit, impact, completeness, feedback, List.of(), questionAnalyses); + this(jobFit, impact, completeness, feedback, List.of(), List.of(), missingKeywords, questionAnalyses); + } + + public AnalysisLlmResponse( + Integer jobFit, + Integer impact, + Integer completeness, + String feedback, + List questionAnalyses + ) { + this(jobFit, impact, completeness, feedback, List.of(), List.of(), List.of(), questionAnalyses); + } + + public record HighlightItem( + String title, + String quote + ) { } public record MissingKeywordItem( diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisHighlightResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisHighlightResponse.java new file mode 100644 index 0000000..1ef6379 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisHighlightResponse.java @@ -0,0 +1,7 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.response; + +public record AnalysisHighlightResponse( + String title, + String quote +) { +} 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 7f9cdad..b713ad7 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,6 +15,8 @@ public record AnalysisResponse( int impact, int completeness, String feedback, + List keyStrengths, + List keyWeaknesses, List missingKeywords, List questions ) { @@ -22,6 +24,8 @@ public static AnalysisResponse of( Analysis analysis, MockApplyStatus status, int sequence, + List keyStrengths, + List keyWeaknesses, List missingKeywords, List questions ) { @@ -35,6 +39,8 @@ public static AnalysisResponse of( analysis.getImpact(), analysis.getCompleteness(), analysis.getFeedback(), + keyStrengths == null ? List.of() : keyStrengths, + keyWeaknesses == null ? List.of() : keyWeaknesses, missingKeywords == null ? List.of() : missingKeywords, questions ); 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 10e6132..cbdb2ec 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 @@ -43,6 +43,14 @@ public class Analysis extends BaseEntity { @Column(name = "missing_keywords", nullable = false, columnDefinition = "TEXT DEFAULT '[]'") private String missingKeywordsJson = "[]"; + @Builder.Default + @Column(name = "key_strengths", nullable = false, columnDefinition = "TEXT DEFAULT '[]'") + private String keyStrengthsJson = "[]"; + + @Builder.Default + @Column(name = "key_weaknesses", nullable = false, columnDefinition = "TEXT DEFAULT '[]'") + private String keyWeaknessesJson = "[]"; + @Builder.Default @OneToMany(mappedBy = "analysis", cascade = CascadeType.ALL, orphanRemoval = true) private List questionAnalyses = new ArrayList<>(); @@ -66,6 +74,20 @@ public static Analysis create( int completeness, String feedback, String missingKeywordsJson + ) { + return create(mockApply, score, jobFit, impact, completeness, feedback, missingKeywordsJson, "[]", "[]"); + } + + public static Analysis create( + MockApply mockApply, + int score, + int jobFit, + int impact, + int completeness, + String feedback, + String missingKeywordsJson, + String keyStrengthsJson, + String keyWeaknessesJson ) { return Analysis.builder() .mockApply(mockApply) @@ -75,6 +97,8 @@ public static Analysis create( .completeness(completeness) .feedback(feedback) .missingKeywordsJson(missingKeywordsJson == null ? "[]" : missingKeywordsJson) + .keyStrengthsJson(keyStrengthsJson == null ? "[]" : keyStrengthsJson) + .keyWeaknessesJson(keyWeaknessesJson == null ? "[]" : keyWeaknessesJson) .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 91a6742..05fa127 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 @@ -38,6 +38,18 @@ public class AnalysisAiClient { "impact": 55, "completeness": 67, "feedback": "한 줄 피드백", + "keyStrengths": [ + { + "title": "직무 연관 경험이 명확하게 드러나요", + "quote": "자소서 답변 안에 실제 존재하는 정확한 부분 문자열" + } + ], + "keyWeaknesses": [ + { + "title": "SQL 활용 경험이 보강되면 더 설득력 있어요", + "quote": "JD 또는 자소서 답변 안에 실제 존재하는 정확한 부분 문자열" + } + ], "missingKeywords": [ { "keyword": "SQL 활용 경험", @@ -63,8 +75,9 @@ public class AnalysisAiClient { 4. jobFit, impact, completeness를 각각 평가한다. 5. 감점 금지 조건과 status 오남용 여부를 확인한다. 6. 보완이 필요한 원문 문장을 문항당 최대 3개만 추출한다. - 7. JD에는 있지만 자소서에 충분히 드러나지 않은 역량을 missingKeywords로 최대 3개 추출한다. - 8. 지정된 JSON만 반환한다. + 7. 총점 한 줄 평가 feedback과 핵심 강점/약점 카드를 작성한다. + 8. JD에는 있지만 자소서에 충분히 드러나지 않은 역량을 missingKeywords로 최대 3개 추출한다. + 9. 지정된 JSON만 반환한다. [jobFit 평가 기준] JD가 요구하는 역량, 경험, 기술을 자기소개서가 얼마나 증명하는지 평가한다. @@ -152,6 +165,17 @@ public class AnalysisAiClient { - 중복되거나 유사한 keyword는 하나로 묶고, 대표 문구는 자격요건 표현을 우선한다. - source는 qualification, preference, mainTask 중 하나만 사용한다. + [핵심 강점/약점 작성 규칙] + - keyStrengths와 keyWeaknesses는 각각 최대 3개만 반환한다. + - title은 화면 카드 제목으로 바로 노출할 짧은 한국어 문장으로 작성한다. + - quote는 반드시 실제 텍스트에서 가져온 짧은 직접 인용이어야 하며 새로 만들거나 요약하지 않는다. + - keyStrengths의 quote는 자소서 answer에 실제 포함된 정확한 부분 문자열만 사용한다. + - keyWeaknesses의 첫 항목들은 missingKeywords와 같은 누락 요건을 다룬다. + - missingKeywords 기반 keyWeaknesses의 quote는 JD의 주요 업무, 자격 요건, 우대 사항에 실제 포함된 표현을 사용한다. + - missingKeywords가 없으면 keyWeaknesses는 questionAnalyses의 보완 대상 문장 quote를 우선 사용한다. + - quote는 너무 길게 붙이지 말고 사용자가 근거를 확인할 수 있는 핵심 구절만 사용한다. + - 적절한 강점이나 약점이 없으면 null이나 필드 생략이 아니라 빈 배열 []을 반환한다. + [reason 작성 규칙] - 사용자가 왜 해당 문장이 보완 대상인지 이해할 수 있게 작성한다. - 가능하면 JD의 어떤 업무, 자격요건, 우대사항과 관련된 문제인지 설명한다. @@ -328,6 +352,9 @@ String buildPrompt( - questionAnalyses의 status는 proven, mentioned, missing, fabricated 중 하나만 사용한다. - sentence는 answer에 포함된 정확한 substring만 사용한다. - missing 상태를 questionAnalyses에 넣기 위해 원문에 없는 sentence를 만들지 않는다. + - keyStrengths와 keyWeaknesses는 각각 최대 3개이며, 없으면 []로 출력한다. + - keyStrengths의 quote는 answer에 실제 포함된 substring만 사용한다. + - keyWeaknesses에서 missingKeywords를 다루는 항목의 quote는 실제 JD 문구만 사용한다. - missingKeywords는 최대 3개이며, 없으면 []로 출력한다. - missingKeywords의 source는 qualification, preference, mainTask 중 하나만 사용한다. - improvement가 지시문이 아닌 완성된 한국어 평서문인지 확인한다. diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisResultConstants.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisResultConstants.java index 6263e28..8408975 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisResultConstants.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisResultConstants.java @@ -5,6 +5,9 @@ public final class AnalysisResultConstants { public static final int MIN_SCORE = 0; public static final int MAX_SCORE = 100; public static final int MAX_ANALYSES_PER_QUESTION = 3; + public static final int MAX_HIGHLIGHTS = 3; + public static final int MAX_HIGHLIGHT_TITLE_LENGTH = 80; + public static final int MAX_HIGHLIGHT_QUOTE_LENGTH = 200; public static final int MAX_MISSING_KEYWORDS = 3; public static final int MAX_MISSING_KEYWORD_LENGTH = 60; public static final double JOB_FIT_WEIGHT = 0.50; 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 aa1c78c..32f79fe 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 @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisHighlightResponse; 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; @@ -48,6 +49,9 @@ import static com.jobdri.jobdri_api.domain.analysis.service.AnalysisResultConstants.IMPACT_WEIGHT; import static com.jobdri.jobdri_api.domain.analysis.service.AnalysisResultConstants.JOB_FIT_WEIGHT; import static com.jobdri.jobdri_api.domain.analysis.service.AnalysisResultConstants.MAX_ANALYSES_PER_QUESTION; +import static com.jobdri.jobdri_api.domain.analysis.service.AnalysisResultConstants.MAX_HIGHLIGHTS; +import static com.jobdri.jobdri_api.domain.analysis.service.AnalysisResultConstants.MAX_HIGHLIGHT_QUOTE_LENGTH; +import static com.jobdri.jobdri_api.domain.analysis.service.AnalysisResultConstants.MAX_HIGHLIGHT_TITLE_LENGTH; import static com.jobdri.jobdri_api.domain.analysis.service.AnalysisResultConstants.MAX_MISSING_KEYWORDS; import static com.jobdri.jobdri_api.domain.analysis.service.AnalysisResultConstants.MAX_MISSING_KEYWORD_LENGTH; import static com.jobdri.jobdri_api.domain.analysis.service.AnalysisResultConstants.MAX_SCORE; @@ -61,6 +65,8 @@ public class AnalysisService { private static final TypeReference> MISSING_KEYWORDS_TYPE = new TypeReference<>() { }; + private static final TypeReference> HIGHLIGHTS_TYPE = new TypeReference<>() { + }; private final MockApplyRepository mockApplyRepository; private final QuestionRepository questionRepository; @@ -171,6 +177,8 @@ public AnalysisResponse finalizeAnalysis( int jobFit = validateScore("jobFit", llmResponse.jobFit()); int impact = validateScore("impact", llmResponse.impact()); int completeness = validateScore("completeness", llmResponse.completeness()); + List keyStrengths = buildHighlights(llmResponse.keyStrengths()); + List keyWeaknesses = buildHighlights(llmResponse.keyWeaknesses()); List missingKeywords = buildMissingKeywords(llmResponse); replaceExistingAnalysis(mockApply); @@ -181,7 +189,9 @@ public AnalysisResponse finalizeAnalysis( impact, completeness, normalizeFeedback(llmResponse.feedback()), - serializeMissingKeywords(missingKeywords) + serializeMissingKeywords(missingKeywords), + serializeHighlights(keyStrengths, "keyStrengths"), + serializeHighlights(keyWeaknesses, "keyWeaknesses") )); List questionAnalyses = buildQuestionAnalyses( @@ -193,7 +203,7 @@ public AnalysisResponse finalizeAnalysis( questionAnalysisRepository.saveAll(questionAnalyses); mockApply.updateStatus(MockApplyStatus.COMPLETED); - return toResponse(mockApply, analysis, questions, questionAnalyses, readMissingKeywords(analysis)); + return toResponse(mockApply, analysis, questions, questionAnalyses, analysisResultPayload(analysis)); } public AnalysisResponse getAnalysis(User user, Long mockApplyId) { @@ -212,7 +222,7 @@ public AnalysisResponse getAnalysis(User user, Long mockApplyId) { analysis, questions, questionAnalyses, - readMissingKeywords(analysis) + analysisResultPayload(analysis) ); } @@ -233,7 +243,7 @@ public AnalysisResponse getAnalysisByJobPostingSequence(User user, Long jobPosti analysis, questions, questionAnalyses, - readMissingKeywords(analysis) + analysisResultPayload(analysis) ); } @@ -361,7 +371,7 @@ private AnalysisResponse toResponse( Analysis analysis, List questions, List questionAnalyses, - List missingKeywords + AnalysisResultPayload resultPayload ) { Map> analysesByQuestionId = questionAnalyses.stream() .collect(Collectors.groupingBy( @@ -381,11 +391,25 @@ private AnalysisResponse toResponse( analysis, mockApply.getStatus(), mockApplyRepository.calculateSequence(mockApply), - missingKeywords, + resultPayload.keyStrengths(), + resultPayload.keyWeaknesses(), + resultPayload.missingKeywords(), questionResponses ); } + private AnalysisResultPayload analysisResultPayload(Analysis analysis) { + return new AnalysisResultPayload( + readHighlights(analysis, analysis == null ? null : analysis.getKeyStrengthsJson(), "keyStrengths"), + readHighlights(analysis, analysis == null ? null : analysis.getKeyWeaknessesJson(), "keyWeaknesses"), + readMissingKeywords(analysis) + ); + } + + private List buildHighlights(List items) { + return sanitizeHighlights(items, AnalysisLlmResponse.HighlightItem::title, AnalysisLlmResponse.HighlightItem::quote); + } + private List buildMissingKeywords(AnalysisLlmResponse llmResponse) { if (llmResponse == null || llmResponse.missingKeywords() == null) { return List.of(); @@ -436,6 +460,34 @@ private String serializeMissingKeywords(List missingKeyw } } + private String serializeHighlights(List highlights, String fieldName) { + try { + return objectMapper.writeValueAsString(highlights == null ? List.of() : highlights); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize {}. Fallback to empty array.", fieldName, e); + return "[]"; + } + } + + private List readHighlights(Analysis analysis, String json, String fieldName) { + if (!StringUtils.hasText(json)) { + return List.of(); + } + + try { + List highlights = objectMapper.readValue(json, HIGHLIGHTS_TYPE); + return sanitizeStoredHighlights(highlights); + } catch (Exception e) { + log.warn( + "Failed to deserialize {}. analysisId={}, fallback to empty array.", + fieldName, + analysis == null ? null : analysis.getId(), + e + ); + return List.of(); + } + } + private List readMissingKeywords(Analysis analysis) { if (analysis == null || !StringUtils.hasText(analysis.getMissingKeywordsJson())) { return List.of(); @@ -489,6 +541,60 @@ private List sanitizeStoredMissingKeywords(List sanitizeStoredHighlights(List highlights) { + return sanitizeHighlights(highlights, AnalysisHighlightResponse::title, AnalysisHighlightResponse::quote); + } + + private List sanitizeHighlights( + List items, + Function titleExtractor, + Function quoteExtractor + ) { + if (items == null) { + return List.of(); + } + + List result = new ArrayList<>(); + Set seenHighlights = new HashSet<>(); + + for (T item : items) { + if (item == null) { + continue; + } + + String rawTitle = titleExtractor.apply(item); + String rawQuote = quoteExtractor.apply(item); + if (!StringUtils.hasText(rawTitle) || !StringUtils.hasText(rawQuote)) { + continue; + } + + String title = rawTitle.trim(); + String quote = rawQuote.trim(); + if (title.length() > MAX_HIGHLIGHT_TITLE_LENGTH || quote.length() > MAX_HIGHLIGHT_QUOTE_LENGTH) { + continue; + } + + String dedupeKey = normalizeKeyword(title) + ":" + normalizeKeyword(quote); + if (!seenHighlights.add(dedupeKey)) { + continue; + } + + result.add(new AnalysisHighlightResponse(title, quote)); + if (result.size() >= MAX_HIGHLIGHTS) { + break; + } + } + + return result; + } + + private record AnalysisResultPayload( + List keyStrengths, + List keyWeaknesses, + List missingKeywords + ) { + } + 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 e31007c..3302b39 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -42,3 +42,9 @@ CREATE INDEX IF NOT EXISTS idx_mock_question_embeddings_hnsw ALTER TABLE IF EXISTS analyses ADD COLUMN IF NOT EXISTS missing_keywords TEXT NOT NULL DEFAULT '[]'; + +ALTER TABLE IF EXISTS analyses + ADD COLUMN IF NOT EXISTS key_strengths TEXT NOT NULL DEFAULT '[]'; + +ALTER TABLE IF EXISTS analyses + ADD COLUMN IF NOT EXISTS key_weaknesses 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 1f779c8..c4dd0d8 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 @@ -202,6 +202,34 @@ void analyzeReturnsValidatedMissingKeywords() { 70, 60, "누락 키워드 검증입니다.", + List.of( + new AnalysisLlmResponse.HighlightItem( + "직무 경험이 실제 구현 사례로 드러나요", + "Spring Boot API를 개발했습니다." + ), + new AnalysisLlmResponse.HighlightItem(" ", "Spring Boot API를 개발했습니다."), + new AnalysisLlmResponse.HighlightItem( + "이 강점 제목은 너무 길어서 응답에서 제외되어야 하는 매우 긴 문장이며 허용 길이를 명확하게 초과하는 잘못된 핵심 강점 카드 제목입니다. 화면 카드 제목으로 사용할 수 없는 수준의 장문입니다.", + "Spring Boot API를 개발했습니다." + ), + new AnalysisLlmResponse.HighlightItem("협업 기반 문제 해결이 보여요", "팀원들과 함께 일주일 동안 상권으로 나갔습니다."), + new AnalysisLlmResponse.HighlightItem("실행력이 구체적으로 드러나요", "직접 심층 인터뷰를 진행하고"), + new AnalysisLlmResponse.HighlightItem("네 번째 강점은 최대 개수 제한으로 제외돼요", "최우수상을 수상할 수 있었습니다.") + ), + List.of( + new AnalysisLlmResponse.HighlightItem( + "SQL 활용 경험 보강이 필요해요", + "SQL 활용 경험" + ), + new AnalysisLlmResponse.HighlightItem( + "SQL 활용 경험 보강이 필요해요", + "SQL 활용 경험" + ), + new AnalysisLlmResponse.HighlightItem(" ", "테스트 자동화 경험"), + new AnalysisLlmResponse.HighlightItem("대용량 트래픽 경험을 더 보여주세요", "대용량 트래픽 처리 경험"), + new AnalysisLlmResponse.HighlightItem("테스트 자동화 경험을 보강하세요", "테스트 자동화 경험"), + new AnalysisLlmResponse.HighlightItem("네 번째 약점은 최대 개수 제한으로 제외돼요", "성능 최적화 경험") + ), List.of( new AnalysisLlmResponse.MissingKeywordItem("SQL 활용 경험", "qualification"), new AnalysisLlmResponse.MissingKeywordItem(" ", "qualification"), @@ -219,6 +247,16 @@ void analyzeReturnsValidatedMissingKeywords() { AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); + assertThat(response.keyStrengths()).hasSize(3); + assertThat(response.keyStrengths()).extracting("title") + .containsExactly("직무 경험이 실제 구현 사례로 드러나요", "협업 기반 문제 해결이 보여요", "실행력이 구체적으로 드러나요"); + assertThat(response.keyStrengths()).extracting("quote") + .containsExactly("Spring Boot API를 개발했습니다.", "팀원들과 함께 일주일 동안 상권으로 나갔습니다.", "직접 심층 인터뷰를 진행하고"); + assertThat(response.keyWeaknesses()).hasSize(3); + assertThat(response.keyWeaknesses()).extracting("title") + .containsExactly("SQL 활용 경험 보강이 필요해요", "대용량 트래픽 경험을 더 보여주세요", "테스트 자동화 경험을 보강하세요"); + assertThat(response.keyWeaknesses()).extracting("quote") + .containsExactly("SQL 활용 경험", "대용량 트래픽 처리 경험", "테스트 자동화 경험"); assertThat(response.missingKeywords()).hasSize(3); assertThat(response.missingKeywords()).extracting("keyword") .containsExactly("SQL 활용 경험", "대용량 트래픽 처리 경험", "테스트 자동화 경험"); @@ -226,6 +264,18 @@ void analyzeReturnsValidatedMissingKeywords() { .containsExactly("qualification", "preference", "mainTask"); Analysis analysis = analysisRepository.findByMockApplyId(mockApply.getId()).orElseThrow(); + assertThat(analysis.getKeyStrengthsJson()) + .contains("\"title\":\"직무 경험이 실제 구현 사례로 드러나요\"") + .contains("\"quote\":\"Spring Boot API를 개발했습니다.\"") + .contains("\"title\":\"협업 기반 문제 해결이 보여요\"") + .contains("\"title\":\"실행력이 구체적으로 드러나요\"") + .doesNotContain("너무 길어서") + .doesNotContain("네 번째 강점"); + assertThat(analysis.getKeyWeaknessesJson()) + .contains("\"title\":\"SQL 활용 경험 보강이 필요해요\"", "\"quote\":\"SQL 활용 경험\"") + .contains("\"title\":\"대용량 트래픽 경험을 더 보여주세요\"") + .contains("\"title\":\"테스트 자동화 경험을 보강하세요\"") + .doesNotContain("네 번째 약점"); assertThat(analysis.getMissingKeywordsJson()) .contains("\"keyword\":\"SQL 활용 경험\"", "\"source\":\"qualification\"") .contains("\"keyword\":\"대용량 트래픽 처리 경험\"", "\"source\":\"preference\"") @@ -256,6 +306,32 @@ void analyzeReturnsEmptyMissingKeywordsWhenLlmMissingKeywordsIsNull() { assertThat(analysis.getMissingKeywordsJson()).isEqualTo("[]"); } + @Test + @DisplayName("keyStrengths와 keyWeaknesses가 null이면 빈 배열로 응답한다") + void analyzeReturnsEmptyHighlightsWhenLlmHighlightsAreNull() { + User user = saveUser("analysis-highlights-null@example.com"); + MockApply mockApply = saveMockApply(user); + saveQuestion(mockApply, "지원 직무 경험", "Spring Boot API를 개발했습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 80, + 70, + 60, + "핵심 강약점이 없습니다.", + null, + null, + List.of(), + List.of() + )); + + AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); + + assertThat(response.keyStrengths()).isEmpty(); + assertThat(response.keyWeaknesses()).isEmpty(); + Analysis analysis = analysisRepository.findByMockApplyId(mockApply.getId()).orElseThrow(); + assertThat(analysis.getKeyStrengthsJson()).isEqualTo("[]"); + assertThat(analysis.getKeyWeaknessesJson()).isEqualTo("[]"); + } + @Test @DisplayName("분석 응답은 같은 공고 기준 현재 지원 순번을 반환한다") void analyzeReturnsSequence() { @@ -743,6 +819,8 @@ void getAnalysisReturnsPersistedMissingKeywordsWithoutRecalculation() { 70, 60, "저장된 분석입니다.", + List.of(new AnalysisLlmResponse.HighlightItem("저장된 강점", "Spring Boot API를 개발했습니다.")), + List.of(new AnalysisLlmResponse.HighlightItem("저장된 약점", "SQL 활용 경험")), List.of(new AnalysisLlmResponse.MissingKeywordItem("LLM 저장 키워드", "qualification")), List.of() )); @@ -750,6 +828,10 @@ void getAnalysisReturnsPersistedMissingKeywordsWithoutRecalculation() { entityManager.clear(); Analysis persisted = analysisRepository.findByMockApplyId(mockApply.getId()).orElseThrow(); + assertThat(persisted.getKeyStrengthsJson()) + .contains("\"title\":\"저장된 강점\"", "\"quote\":\"Spring Boot API를 개발했습니다.\""); + assertThat(persisted.getKeyWeaknessesJson()) + .contains("\"title\":\"저장된 약점\"", "\"quote\":\"SQL 활용 경험\""); assertThat(persisted.getMissingKeywordsJson()) .contains("\"keyword\":\"LLM 저장 키워드\"", "\"source\":\"qualification\""); entityManager.clear(); @@ -758,6 +840,10 @@ void getAnalysisReturnsPersistedMissingKeywordsWithoutRecalculation() { assertThat(saved.missingKeywords()).extracting("keyword") .containsExactly("LLM 저장 키워드"); + assertThat(response.keyStrengths()).extracting("title") + .containsExactly("저장된 강점"); + assertThat(response.keyWeaknesses()).extracting("title") + .containsExactly("저장된 약점"); assertThat(response.missingKeywords()).extracting("keyword") .containsExactly("LLM 저장 키워드"); assertThat(response.missingKeywords()).extracting(keyword -> keyword.source().value()) @@ -793,6 +879,39 @@ void getAnalysisReturnsEmptyMissingKeywordsWhenPersistedJsonIsMalformed() { assertThat(response.missingKeywords()).isEmpty(); } + @Test + @DisplayName("저장된 핵심 강약점 JSON이 깨져 있어도 조회 응답은 빈 배열로 fallback한다") + void getAnalysisReturnsEmptyHighlightsWhenPersistedJsonIsMalformed() { + User user = saveUser("analysis-get-malformed-highlights@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.HighlightItem("저장된 강점", "Spring Boot API를 개발했습니다.")), + List.of(new AnalysisLlmResponse.HighlightItem("저장된 약점", "SQL 활용 경험")), + List.of(), + List.of() + )); + AnalysisResponse saved = analysisService.analyze(user, mockApply.getId()); + + jdbcTemplate.update( + "UPDATE analyses SET key_strengths = ?, key_weaknesses = ? WHERE id = ?", + "not-json", + "not-json", + saved.analysisId() + ); + entityManager.clear(); + + AnalysisResponse response = analysisService.getAnalysis(user, mockApply.getId()); + + assertThat(response.analysisId()).isEqualTo(saved.analysisId()); + assertThat(response.keyStrengths()).isEmpty(); + assertThat(response.keyWeaknesses()).isEmpty(); + } + @Test @DisplayName("jobPosting 기준 sequence로 특정 회차 분석 결과를 조회한다") void getAnalysisByJobPostingSequence() {