diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java index c91201b..c22f7a8 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java @@ -644,6 +644,15 @@ String buildCandidatePrompt( - status 다양성이나 개수를 채우기 위해 후보를 만들지 않는다. - improvement를 생성하지 않는다. + [strengthCandidates 생성 규칙] + - 반드시 수치 성과가 있어야만 strengthCandidates가 되는 것은 아니다. + - JD와 직접 연결된다면 구체적인 도구·기술 사용 경험은 strengthCandidates가 될 수 있다. + - 실제 업무 또는 프로젝트 수행 경험, 업무 전 과정을 수행한 경험은 strengthCandidates가 될 수 있다. + - 문제를 발견하고 해결한 과정, 수치로 표현되지 않더라도 확인 가능한 결과는 strengthCandidates가 될 수 있다. + - 자격 취득 자체가 아니라 직무와 연결되는 실습·적용 경험은 strengthCandidates가 될 수 있다. + - JD 우대사항과 직접 연결되는 경험도 strengthCandidates가 될 수 있으나 preference-only 강점을 과대평가하지 않는다. + - 추상적인 포부, 근거 없는 자기평가, 단순 성격 표현, JD와 무관한 경험, 답변에 없는 추론 강점, 동일 근거의 중복 강점은 생성하지 않는다. + [missingKeywordCandidates 생성 규칙] - missingKeywordCandidates는 recall보다 precision을 우선한다. 확실한 누락 역량이 없으면 []를 반환한다. - 개수를 채우기 위해 missingKeywordCandidates를 생성하지 않는다. 애매하면 생성하지 않는다. @@ -677,6 +686,21 @@ String buildCandidatePrompt( missingKeywordCandidates: [] 이유: 사업 및 행정지원 역량이 이미 답변에 충분히 존재한다. + [strengthCandidates Positive Examples] + - Example 1 + JD main_tasks: 상세페이지 제작 + JD qualifications: 포토샵·일러스트 활용 + JD preferences: 라이노·키샷 우대 + Answer: 포토샵과 일러스트로 상세페이지를 제작했고, 1인 제품 브랜드를 직접 기획·운영했습니다. 라이노와 Fusion360으로 제품을 설계한 뒤 키샷으로 렌더링하고 실제 제품 제작까지 완료했습니다. + strengthCandidates: 포토샵과 일러스트 상세페이지 제작 경험, 1인 제품 브랜드 기획·운영 경험, 라이노·Fusion360·키샷 기반 제품 제작 경험 중 JD와 직접 연결되는 quote를 사용한다. + 이유: 수치 성과가 없어도 도구, 수행 업무, 제작 완료 결과가 확인 가능하다. + - Example 2 + JD main_tasks: 엑셀 고급 활용, 4대보험 신고, 회계 업무 + JD preferences: 더존 활용 우대 + Answer: 급여 정산표를 만들어 함수로 자동 비교했고, 전표 입력과 계정 분류를 실습했습니다. 4대보험 자료 작성 방법을 학습하고 더존 프로그램 입력 기준을 비교했으며, 5만 원 시재 차이 원인을 거래 자료 대조로 추적해 수정 요청을 주 3~4건에서 1~2건으로 줄였습니다. + strengthCandidates: 엑셀 자동 비교, 회계 전표·계정 분류 실습, 4대보험 자료 작성 학습, 더존 입력 기준 비교, 시재 차이 원인 추적과 수정 요청 감소 중 JD와 직접 연결되는 quote를 사용한다. + 이유: 직무 도구, 실무 처리 과정, 문제 해결, 개선 결과가 확인 가능하다. + [문장 유형별 후보 기준] - EXPERIENCE: 역할, 행동, 방법, 결과, 직무 연결성 중 실제로 부족한 요소가 있어야 한다. - EXPERIENCE: 주변 문장에 역할, 방법, 성과가 이어지면 부족하다고 판단하지 않는다. @@ -1667,11 +1691,22 @@ CandidateReviewResponse validateCandidateReview( sanitizedCandidates == null || sanitizedCandidates.analysisCandidates() == null ? List.of() : sanitizedCandidates.analysisCandidates(); + List strengths = validateReviewStrengths( + promptInput, + sanitizedCandidates, + reviewResponse.strengths() + ); + List missingKeywords = validateReviewMissingKeywords( + promptInput, + sanitizedCandidates, + reviewResponse.missingKeywords() + ); if (analysisCandidates.isEmpty()) { + logDroppedDecisionsWithoutInput(promptInput, reviewResponse.decisions()); return new CandidateReviewResponse( List.of(), - reviewResponse.strengths() == null ? List.of() : reviewResponse.strengths(), - reviewResponse.missingKeywords() == null ? List.of() : reviewResponse.missingKeywords(), + strengths, + missingKeywords, reviewResponse.jobFit(), reviewResponse.impact(), reviewResponse.completeness(), @@ -1698,10 +1733,12 @@ CandidateReviewResponse validateCandidateReview( } String candidateId = decision.candidateId().trim(); if (!seenCandidateIds.add(candidateId)) { + logReviewDrop(promptInput, "review_unknown_candidate_id", candidateId, null, null); continue; } AnalysisCandidateResponse.AnalysisCandidate candidate = candidateById.get(candidateId); if (candidate == null || decision.accepted() == null || decision.rejectionCode() == null) { + logReviewDrop(promptInput, "review_unknown_candidate_id", candidateId, null, null); continue; } if (Boolean.TRUE.equals(decision.accepted())) { @@ -1723,8 +1760,8 @@ CandidateReviewResponse validateCandidateReview( } return new CandidateReviewResponse( decisions, - reviewResponse.strengths() == null ? List.of() : reviewResponse.strengths(), - reviewResponse.missingKeywords() == null ? List.of() : reviewResponse.missingKeywords(), + strengths, + missingKeywords, reviewResponse.jobFit(), reviewResponse.impact(), reviewResponse.completeness(), @@ -1732,6 +1769,156 @@ CandidateReviewResponse validateCandidateReview( ); } + private void logDroppedDecisionsWithoutInput( + AnalysisPromptInput promptInput, + List decisions + ) { + if (decisions == null) { + return; + } + for (CandidateReviewResponse.CandidateDecision decision : decisions) { + if (decision != null) { + logReviewDrop( + promptInput, + "review_output_without_input_candidate", + decision.candidateId(), + null, + null + ); + } + } + } + + private List validateReviewStrengths( + AnalysisPromptInput promptInput, + AnalysisCandidateResponse sanitizedCandidates, + List reviewStrengths + ) { + if (reviewStrengths == null || sanitizedCandidates == null || sanitizedCandidates.strengthCandidates() == null) { + if (reviewStrengths != null) { + logReviewStrengthsWithoutInput(promptInput, reviewStrengths); + } + return List.of(); + } + Set allowedQuotes = sanitizedCandidates.strengthCandidates().stream() + .filter(candidate -> isPrimarySource(candidate.relatedSource())) + .map(candidate -> normalize(candidate.quote())) + .collect(Collectors.toSet()); + if (allowedQuotes.isEmpty()) { + logReviewStrengthsWithoutInput(promptInput, reviewStrengths); + return List.of(); + } + + List result = new ArrayList<>(); + Set seen = new HashSet<>(); + for (CandidateReviewResponse.FinalStrengthCandidate strength : reviewStrengths) { + if (strength == null || !StringUtils.hasText(strength.quote())) { + logReviewDrop(promptInput, "review_strength_without_candidate", null, null, null); + continue; + } + String normalizedQuote = normalize(strength.quote()); + if (!allowedQuotes.contains(normalizedQuote) || !seen.add(normalizedQuote)) { + logReviewDrop(promptInput, "review_strength_without_candidate", null, null, null); + continue; + } + result.add(strength); + } + return List.copyOf(result); + } + + private void logReviewStrengthsWithoutInput( + AnalysisPromptInput promptInput, + List strengths + ) { + for (CandidateReviewResponse.FinalStrengthCandidate strength : strengths) { + logReviewDrop(promptInput, "review_strength_without_candidate", null, null, null); + } + } + + private List validateReviewMissingKeywords( + AnalysisPromptInput promptInput, + AnalysisCandidateResponse sanitizedCandidates, + List reviewMissingKeywords + ) { + if (reviewMissingKeywords == null || sanitizedCandidates == null || sanitizedCandidates.missingKeywordCandidates() == null) { + if (reviewMissingKeywords != null) { + logReviewMissingKeywordsWithoutInput(promptInput, reviewMissingKeywords); + } + return List.of(); + } + Set allowedKeywords = sanitizedCandidates.missingKeywordCandidates().stream() + .filter(candidate -> StringUtils.hasText(candidate.keyword())) + .map(candidate -> missingKeywordProvenanceKey(candidate.keyword(), candidate.source())) + .collect(Collectors.toSet()); + if (allowedKeywords.isEmpty()) { + logReviewMissingKeywordsWithoutInput(promptInput, reviewMissingKeywords); + return List.of(); + } + + List result = new ArrayList<>(); + Set seen = new HashSet<>(); + for (CandidateReviewResponse.FinalMissingKeywordCandidate keyword : reviewMissingKeywords) { + if (keyword == null || !StringUtils.hasText(keyword.keyword())) { + logReviewDrop(promptInput, "review_missing_keyword_without_candidate", null, null, null); + continue; + } + String key = missingKeywordProvenanceKey(keyword.keyword(), keyword.source()); + if (!allowedKeywords.contains(key) || !seen.add(key)) { + logReviewDrop( + promptInput, + "review_missing_keyword_without_candidate", + null, + keyword.keyword(), + keyword.source() + ); + continue; + } + result.add(keyword); + } + return List.copyOf(result); + } + + private void logReviewMissingKeywordsWithoutInput( + AnalysisPromptInput promptInput, + List keywords + ) { + for (CandidateReviewResponse.FinalMissingKeywordCandidate keyword : keywords) { + logReviewDrop( + promptInput, + "review_missing_keyword_without_candidate", + null, + keyword == null ? null : keyword.keyword(), + keyword == null ? null : keyword.source() + ); + } + } + + private String missingKeywordProvenanceKey(String keyword, String source) { + String normalizedKeyword = AnalysisSanitizationRules.normalizeText(keyword); + String normalizedSource = parseCandidateSource(source) + .map(Enum::name) + .orElseGet(() -> AnalysisSanitizationRules.normalizeText(source)); + return normalizedKeyword + ":" + normalizedSource; + } + + private void logReviewDrop( + AnalysisPromptInput promptInput, + String reason, + String candidateId, + String keyword, + String source + ) { + log.warn( + "Candidate review output removed. reason={}, companyName={}, jobName={}, candidateId={}, keyword={}, source={}", + reason, + promptInput == null ? null : promptInput.companyName(), + promptInput == null ? null : promptInput.jobName(), + candidateId, + keyword, + source + ); + } + private CandidateReviewResponse.CandidateDecision validateAcceptedDecision( CandidateReviewResponse.CandidateDecision decision, AnalysisCandidateResponse.AnalysisCandidate candidate, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisSanitizationRules.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisSanitizationRules.java index 08194cd..a917afb 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisSanitizationRules.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisSanitizationRules.java @@ -15,7 +15,9 @@ public final class AnalysisSanitizationRules { private static final Pattern CAREER_YEAR_PATTERN = Pattern.compile("경력\\s*\\d+\\s*년|\\d+\\s*년\\s*(이상|이하|미만|초과)"); private static final String[] STRUCTURED_QUALIFICATION_TERMS = { "자격증", "면허", "면허증", "공인성적", "어학성적", "토익", "toeic", "토플", "toefl", - "opic", "ielts", "학위", "전공", "졸업", "학력", "신입", "근무 가능", "국적", "나이", "연령" + "opic", "ielts", "학위", "전공", "졸업", "학력", "신입", "근무 가능", "국적", "나이", "연령", + "사회복지사", "청소년지도사", "청소년상담사", "직업상담사", "임상심리사", + "대졸", "초대졸", "전문대졸", "고졸" }; private static final String[] META_IMPROVEMENT_TERMS = { "구체적으로 작성했습니다", "명확히 설명했습니다", "성과를 강조했습니다", "수치로 명시했습니다", diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java index f97aab3..f9cf203 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java @@ -327,7 +327,18 @@ void buildCandidatePromptIncludesCandidateRulesOnly() { .contains("[missingKeywordCandidates Negative Examples]") .contains("상세페이지 제작을 UX/UI 인터페이스 설계 같은 JD 밖 개념으로 확장하지 않는다.") .contains("브랜드 운영은 이미 답변에 있으며, BI/CI 구축 경험으로 일반화하지 않는다.") - .contains("행정지원 역량이 이미 답변에 충분히 존재한다."); + .contains("사업 및 행정지원 역량이 이미 답변에 충분히 존재한다.") + .contains("[strengthCandidates 생성 규칙]") + .contains("반드시 수치 성과가 있어야만 strengthCandidates가 되는 것은 아니다.") + .contains("구체적인 도구·기술 사용 경험은 strengthCandidates가 될 수 있다.") + .contains("실제 업무 또는 프로젝트 수행 경험, 업무 전 과정을 수행한 경험은 strengthCandidates가 될 수 있다.") + .contains("수치로 표현되지 않더라도 확인 가능한 결과는 strengthCandidates가 될 수 있다.") + .contains("자격 취득 자체가 아니라 직무와 연결되는 실습·적용 경험은 strengthCandidates가 될 수 있다.") + .contains("[strengthCandidates Positive Examples]") + .contains("포토샵과 일러스트로 상세페이지를 제작") + .contains("라이노와 Fusion360으로 제품을 설계") + .contains("급여 정산표를 만들어 함수로 자동 비교") + .contains("5만 원 시재 차이 원인을 거래 자료 대조로 추적"); } @Test @@ -1022,14 +1033,18 @@ void applyRecheckResponseRecoversAtMostOneAndKeepsMissingKeywords() { @DisplayName("candidate review는 검증된 candidateId와 accepted/rejectionCode 조합만 유지한다") void validateCandidateReviewKeepsOnlyConsistentDecisions() { AnalysisCandidateResponse candidates = new AnalysisCandidateResponse( - List.of(), + List.of(strengthCandidate("Spring Boot API를 개발했습니다.")), List.of( candidate("accepted-1", "Spring Boot API를 개발했습니다."), candidate("rejected-1", "장애 대응 경험이 있습니다."), candidate("bad-accepted-code", "Spring Boot API를 개발했습니다."), candidate("bad-rejected-code", "장애 대응 경험이 있습니다.") ), - List.of() + List.of(new AnalysisCandidateResponse.MissingKeywordCandidate( + "Spring Boot API 개발 경험", + "QUALIFICATION", + "Spring Boot 경험" + )) ); CandidateReviewResponse validated = analysisAiClient.validateCandidateReview( @@ -1078,8 +1093,22 @@ void validateCandidateReviewKeepsOnlyConsistentDecisions() { null ) ), - List.of(), - List.of(), + List.of( + new CandidateReviewResponse.FinalStrengthCandidate( + "API 개발 경험", + "Spring Boot API를 개발했습니다.", + "MAIN_TASK" + ), + new CandidateReviewResponse.FinalStrengthCandidate( + "입력 후보에 없는 강점", + "장애 대응 경험이 있습니다.", + "MAIN_TASK" + ) + ), + List.of( + new CandidateReviewResponse.FinalMissingKeywordCandidate("Spring Boot API 개발 경험", "qualification"), + new CandidateReviewResponse.FinalMissingKeywordCandidate("채용 파이프라인 소싱 경험", "JD") + ), 80, 70, 60, @@ -1092,10 +1121,14 @@ void validateCandidateReviewKeepsOnlyConsistentDecisions() { .containsExactly("accepted-1", "rejected-1"); assertThat(validated.decisions().getFirst().improvement()).isNull(); assertThat(validated.decisions().get(1).improvement()).isNull(); + assertThat(validated.strengths()).extracting("quote") + .containsExactly("Spring Boot API를 개발했습니다."); + assertThat(validated.missingKeywords()).extracting("keyword") + .containsExactly("Spring Boot API 개발 경험"); } @Test - @DisplayName("candidate가 없으면 review decisions는 빈 배열로 검증된다") + @DisplayName("candidate가 없으면 review 출력은 빈 배열로 검증된다") void validateCandidateReviewReturnsEmptyDecisionsWhenNoCandidates() { CandidateReviewResponse validated = analysisAiClient.validateCandidateReview( promptInput(), @@ -1109,8 +1142,12 @@ void validateCandidateReviewReturnsEmptyDecisionsWhenNoCandidates() { "1차에 없는 후보입니다.", null )), - List.of(), - List.of(), + List.of(new CandidateReviewResponse.FinalStrengthCandidate( + "입력 후보에 없는 강점", + "Spring Boot API를 개발했습니다.", + "MAIN_TASK" + )), + List.of(new CandidateReviewResponse.FinalMissingKeywordCandidate("채용 파이프라인 소싱 경험", "JD")), 80, 70, 60, @@ -1119,6 +1156,61 @@ void validateCandidateReviewReturnsEmptyDecisionsWhenNoCandidates() { ); assertThat(validated.decisions()).isEmpty(); + assertThat(validated.strengths()).isEmpty(); + assertThat(validated.missingKeywords()).isEmpty(); + } + + @Test + @DisplayName("review strength와 missingKeyword는 sanitized 후보에 존재하는 항목만 유지한다") + void validateCandidateReviewKeepsOnlyProvenancedStrengthsAndMissingKeywords() { + AnalysisCandidateResponse candidates = new AnalysisCandidateResponse( + List.of(strengthCandidate("Spring Boot API를 개발했습니다.")), + List.of(), + List.of(new AnalysisCandidateResponse.MissingKeywordCandidate( + "Spring Boot API 개발 경험", + "QUALIFICATION", + "Spring Boot 경험" + )) + ); + + CandidateReviewResponse validated = analysisAiClient.validateCandidateReview( + promptInput(), + candidates, + new CandidateReviewResponse( + List.of(), + List.of( + new CandidateReviewResponse.FinalStrengthCandidate( + "API 개발 경험", + "Spring Boot API를 개발했습니다.", + "MAIN_TASK" + ), + new CandidateReviewResponse.FinalStrengthCandidate( + "후보 없는 강점", + "장애 대응 경험이 있습니다.", + "MAIN_TASK" + ) + ), + List.of( + new CandidateReviewResponse.FinalMissingKeywordCandidate( + " spring boot api 개발 경험 ", + "qualification" + ), + new CandidateReviewResponse.FinalMissingKeywordCandidate( + "채용 파이프라인 소싱 경험", + "JD" + ) + ), + 80, + 70, + 60, + "피드백" + ) + ); + + assertThat(validated.strengths()).extracting("quote") + .containsExactly("Spring Boot API를 개발했습니다."); + assertThat(validated.missingKeywords()).extracting("keyword") + .containsExactly(" spring boot api 개발 경험 "); } @Test @@ -1266,6 +1358,16 @@ private AnalysisCandidateResponse.AnalysisCandidate candidate(String candidateId ); } + private AnalysisCandidateResponse.StrengthCandidate strengthCandidate(String quote) { + return new AnalysisCandidateResponse.StrengthCandidate( + 1L, + quote, + "MAIN_TASK", + "API 개발", + "직접 근거입니다." + ); + } + private static class RecheckBuilder { private final String candidateId; private RecheckDecision decision = RecheckDecision.KEEP_BEST_CANDIDATE; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisSanitizationRulesTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisSanitizationRulesTest.java index da07f03..f2d8505 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisSanitizationRulesTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisSanitizationRulesTest.java @@ -74,6 +74,14 @@ void filtersStructuredQualificationKeywordsConservatively() { assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("반도체 공정 분석 경험")).isFalse(); assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("경력 3년 이상")).isTrue(); assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("장애 대응 경험")).isFalse(); + assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("사회복지사")).isTrue(); + assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("청소년상담사 3급")).isTrue(); + assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("직업상담사")).isTrue(); + assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("운전면허")).isTrue(); + assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("컴퓨터활용능력 자격증")).isTrue(); + assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("대졸 이상")).isTrue(); + assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("Spring Boot 실무 경험")).isFalse(); + assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("포토샵 활용 능력")).isFalse(); } @Test diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizerTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizerTest.java index 99676fa..a987a2c 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizerTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizerTest.java @@ -108,6 +108,46 @@ void structuredQualificationRejected() { .containsExactly(MissingKeywordRejectionReason.CERTIFICATE_OR_QUANTITATIVE_NOISE); } + @Test + @DisplayName("자격증, 면허, 학력, 경력 연차 누락 키워드는 정형 자격요건으로 제거한다") + void structuredCertificateAndEducationKeywordsRejected() { + List candidates = List.of( + candidate("사회복지사", "QUALIFICATION"), + candidate("청소년상담사 3급", "QUALIFICATION"), + candidate("운전면허", "QUALIFICATION"), + candidate("대졸 이상", "QUALIFICATION"), + candidate("경력 3년 이상", "QUALIFICATION") + ); + + MissingKeywordSanitizationResult result = MissingKeywordSanitizer.sanitize( + MAIN_TASKS, + QUALIFICATIONS, + "", + candidates + ); + + assertThat(result.acceptedCandidates()).isEmpty(); + assertThat(result.decisions()).extracting(MissingKeywordSanitizationDecision::rejectionReason) + .containsOnly(MissingKeywordRejectionReason.CERTIFICATE_OR_QUANTITATIVE_NOISE); + } + + @Test + @DisplayName("자격요건 섹션의 기술·실무 역량은 정형 자격요건으로 오인하지 않는다") + void practicalSkillQualificationKeywordsAreNotStructuredNoise() { + MissingKeywordSanitizationResult result = MissingKeywordSanitizer.sanitize( + MAIN_TASKS, + "Spring Boot 실무 경험과 포토샵 활용 능력", + "", + List.of( + candidate("Spring Boot 실무 경험", "QUALIFICATION"), + candidate("포토샵 활용 능력", "QUALIFICATION") + ) + ); + + assertThat(result.acceptedCandidates()).extracting("keyword") + .containsExactly("Spring Boot 실무 경험", "포토샵 활용 능력"); + } + @Test @DisplayName("유효 후보는 ACCEPTED로 기록한다") void validCandidateAccepted() {