diff --git a/ops/s3/README.md b/ops/s3/README.md index 6df4841..d11900b 100644 --- a/ops/s3/README.md +++ b/ops/s3/README.md @@ -8,6 +8,13 @@ aws s3api put-bucket-cors \ --cors-configuration file://ops/s3/job-posting-image-cors.json ``` +Verify the applied CORS configuration: + +```bash +aws s3api get-bucket-cors \ + --bucket "$S3_BUCKET" +``` + ## 2. Apply lifecycle policy ```bash diff --git a/ops/s3/job-posting-image-cors.json b/ops/s3/job-posting-image-cors.json index 582d34f..389729f 100644 --- a/ops/s3/job-posting-image-cors.json +++ b/ops/s3/job-posting-image-cors.json @@ -11,8 +11,12 @@ "AllowedOrigins": [ "https://jobdri.site", "https://www.jobdri.site", + "https://jobdri.com", + "https://www.jobdri.com", + "https://api.jobdri.site", "https://job-dri.vercel.app", "http://localhost:3000", + "http://localhost:8080", "http://localhost:5173" ], "ExposeHeaders": [ diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisCandidateResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisCandidateResponse.java new file mode 100644 index 0000000..f73170b --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisCandidateResponse.java @@ -0,0 +1,38 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.llm; + +import java.util.List; + +public record AnalysisCandidateResponse( + List strengthCandidates, + List analysisCandidates, + List missingKeywordCandidates +) { + public record StrengthCandidate( + Long questionId, + String quote, + String relatedSource, + String relatedRequirement, + String reasonBasis + ) { + } + + public record AnalysisCandidate( + Long questionId, + String sentence, + String sentenceType, + String relatedSource, + String relatedRequirement, + String status, + String issueType, + String reasonBasis + ) { + } + + public record MissingKeywordCandidate( + String keyword, + String source, + String relatedRequirement + ) { + } +} + diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java index 064a85c..1869240 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java @@ -7,9 +7,10 @@ import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; import com.jobdri.jobdri_api.domain.analysis.service.AnalysisAiClient; -import com.jobdri.jobdri_api.domain.analysis.service.AnalysisImprovementRules; +import com.jobdri.jobdri_api.domain.analysis.service.AnalysisAiClient.AnalysisAiCallResult; import com.jobdri.jobdri_api.domain.analysis.service.AnalysisPromptInput; import com.jobdri.jobdri_api.domain.analysis.service.AnalysisResultConstants; +import com.jobdri.jobdri_api.domain.analysis.service.AnalysisSanitizationRules; import com.jobdri.jobdri_api.domain.analysis.service.JobCategoryEvaluationCriteriaProvider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -117,17 +118,18 @@ private EvaluationAnalysisResult analyzeCase(EvaluationAnalysisCase evaluationCa )) ); - AnalysisLlmResponse llmResponse = analysisAiClient.analyzeForEvaluation( + AnalysisAiCallResult aiCallResult = analysisAiClient.analyzeForEvaluationResult( promptInput, jobCategoryEvaluationCriteriaProvider .findByMiddleName(evaluationCase.jobCategoryMiddle()) .orElse(null) ); + AnalysisLlmResponse llmResponse = aiCallResult.response(); int jobFit = validateScore("jobFit", llmResponse == null ? null : llmResponse.jobFit()); int impact = validateScore("impact", llmResponse == null ? null : llmResponse.impact()); int completeness = validateScore("completeness", llmResponse == null ? null : llmResponse.completeness()); - List missingKeywords = buildMissingKeywords(llmResponse); + List missingKeywords = buildMissingKeywords(evaluationCase, llmResponse); List questionAnalyses = buildQuestionAnalyses(evaluationCase, llmResponse); return new EvaluationAnalysisResult( @@ -142,12 +144,23 @@ private EvaluationAnalysisResult analyzeCase(EvaluationAnalysisCase evaluationCa writeJson(missingKeywords), writeJson(questionAnalyses), writeJson(llmResponse), + writeJson(aiCallResult.rawCandidateResponse()), + writeJson(aiCallResult.sanitizedCandidateResponse()), + size(aiCallResult.sanitizedCandidateResponse() == null ? null : aiCallResult.sanitizedCandidateResponse().analysisCandidates()), + size(aiCallResult.sanitizedCandidateResponse() == null ? null : aiCallResult.sanitizedCandidateResponse().strengthCandidates()), + size(aiCallResult.sanitizedCandidateResponse() == null ? null : aiCallResult.sanitizedCandidateResponse().missingKeywordCandidates()), + aiCallResult.candidateCallLatencyMs(), + aiCallResult.finalCallLatencyMs(), + "", "", createdAt() ); } - private List buildMissingKeywords(AnalysisLlmResponse llmResponse) { + private List buildMissingKeywords( + EvaluationAnalysisCase evaluationCase, + AnalysisLlmResponse llmResponse + ) { if (llmResponse == null || llmResponse.missingKeywords() == null) { return List.of(); } @@ -169,6 +182,14 @@ private List buildMissingKeywords(AnalysisLlmResponse ll if (source.isEmpty()) { continue; } + if (!AnalysisSanitizationRules.isValidMissingKeyword( + keyword, + source.get(), + evaluationCase.mainTasks(), + evaluationCase.qualifications() + )) { + continue; + } String dedupeKey = normalizeKeyword(keyword); if (!seenKeywords.add(dedupeKey)) { @@ -200,6 +221,7 @@ private List buildQuestionAnalyses( Map analysisCountByQuestionId = new HashMap<>(); Map nextSearchIndexByQuestionId = new HashMap<>(); Set seenSentences = new HashSet<>(); + Set keyStrengthQuotes = normalizedKeyStrengthQuotes(llmResponse); for (AnalysisLlmResponse.QuestionAnalysisItem item : llmResponse.questionAnalyses()) { if (item == null || item.questionId() == null || !StringUtils.hasText(item.sentence())) { @@ -210,7 +232,13 @@ private List buildQuestionAnalyses( } QuestionAnalysisStatus status = parseStatus(item.status()); - if (status == null || status == QuestionAnalysisStatus.MISSING) { + if (status == null + || status == QuestionAnalysisStatus.MISSING + || status == QuestionAnalysisStatus.PROVEN) { + continue; + } + if (status == QuestionAnalysisStatus.FABRICATED + && !AnalysisSanitizationRules.hasFabricatedDirectConflictReason(item.reason())) { continue; } @@ -220,6 +248,9 @@ private List buildQuestionAnalyses( } String sentence = item.sentence(); + if (keyStrengthQuotes.contains(normalizeKeyword(sentence))) { + continue; + } String dedupeKey = item.questionId() + ":" + sentence.trim(); if (!seenSentences.add(dedupeKey)) { continue; @@ -241,7 +272,7 @@ private List buildQuestionAnalyses( sentence, status.name().toLowerCase(), defaultString(item.reason()), - normalizeImprovement(item.improvement()), + normalizeImprovement(sentence, answer, item.improvement(), status), start, start + sentence.length() )); @@ -249,6 +280,16 @@ private List buildQuestionAnalyses( return result; } + private Set normalizedKeyStrengthQuotes(AnalysisLlmResponse llmResponse) { + if (llmResponse == null || llmResponse.keyStrengths() == null) { + return Set.of(); + } + return llmResponse.keyStrengths().stream() + .filter(item -> item != null && StringUtils.hasText(item.quote())) + .map(item -> normalizeKeyword(item.quote())) + .collect(java.util.stream.Collectors.toSet()); + } + private int validateScore(String fieldName, Integer score) { if (score == null || score < AnalysisResultConstants.MIN_SCORE @@ -295,6 +336,10 @@ private String writeJson(Object value) { } } + private Integer size(List values) { + return values == null ? null : values.size(); + } + private String normalizeFeedback(String feedback) { if (StringUtils.hasText(feedback)) { return feedback; @@ -302,16 +347,18 @@ private String normalizeFeedback(String feedback) { return "자소서 분석 결과를 확인해주세요."; } - private String normalizeImprovement(String improvement) { - if (!StringUtils.hasText(improvement)) { - return ""; - } - - String normalized = improvement.trim(); - if (AnalysisImprovementRules.isInstructionLike(normalized)) { - return ""; - } - return normalized; + private String normalizeImprovement( + String sentence, + String answer, + String improvement, + QuestionAnalysisStatus status + ) { + return AnalysisSanitizationRules.normalizeImprovement( + sentence, + answer, + improvement, + status == QuestionAnalysisStatus.PROVEN + ); } private String normalizeKeyword(String keyword) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisResult.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisResult.java index 6978a20..6010166 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisResult.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisResult.java @@ -12,6 +12,14 @@ record EvaluationAnalysisResult( String aiMissingKeywordsJson, String aiQuestionAnalysesJson, String rawLlmResponseJson, + String rawCandidateResponseJson, + String sanitizedCandidateResponseJson, + Integer candidateAnalysisCount, + Integer candidateStrengthCount, + Integer candidateMissingKeywordCount, + Long candidateCallLatencyMs, + Long finalCallLatencyMs, + String failureStage, String errorMessage, String createdAt ) { @@ -28,8 +36,29 @@ static EvaluationAnalysisResult failed(EvaluationAnalysisCase evaluationCase, St "[]", "[]", "", + "", + "", + null, + null, + null, + null, + null, + failureStage(errorMessage), errorMessage, createdAt ); } + + private static String failureStage(String errorMessage) { + if (errorMessage == null) { + return ""; + } + if (errorMessage.contains("candidate")) { + return "candidate_call_failed"; + } + if (errorMessage.contains("final")) { + return "final_call_failed"; + } + return ""; + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupport.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupport.java index c868dee..4e5271b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupport.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupport.java @@ -81,6 +81,14 @@ static void write(Path path, List results) throws IOEx "aiMissingKeywordsJson", "aiQuestionAnalysesJson", "rawLlmResponseJson", + "rawCandidateResponseJson", + "sanitizedCandidateResponseJson", + "candidateAnalysisCount", + "candidateStrengthCount", + "candidateMissingKeywordCount", + "candidateCallLatencyMs", + "finalCallLatencyMs", + "failureStage", "errorMessage", "createdAt" )); @@ -98,6 +106,14 @@ static void write(Path path, List results) throws IOEx value(result.aiMissingKeywordsJson()), value(result.aiQuestionAnalysesJson()), value(result.rawLlmResponseJson()), + value(result.rawCandidateResponseJson()), + value(result.sanitizedCandidateResponseJson()), + value(result.candidateAnalysisCount()), + value(result.candidateStrengthCount()), + value(result.candidateMissingKeywordCount()), + value(result.candidateCallLatencyMs()), + value(result.finalCallLatencyMs()), + value(result.failureStage()), value(result.errorMessage()), value(result.createdAt()) )); 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 05fa127..febe7d3 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 @@ -1,7 +1,12 @@ package com.jobdri.jobdri_api.domain.analysis.service; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisCandidateResponse; import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; +import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; +import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; 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; @@ -19,8 +24,15 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import java.util.List; +import java.util.Map; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Set; +import java.util.ArrayList; +import java.util.Optional; import java.util.stream.Collectors; @Component @@ -32,52 +44,76 @@ public class AnalysisAiClient { private static final int MAX_REFERENCE_FIELD_LENGTH = 300; private static final int MAX_CRITERIA_ITEMS = 5; private static final String OUTPUT_SCHEMA = """ - [출력 형식] - { - "jobFit": 70, - "impact": 55, - "completeness": 67, - "feedback": "한 줄 피드백", - "keyStrengths": [ - { - "title": "직무 연관 경험이 명확하게 드러나요", - "quote": "자소서 답변 안에 실제 존재하는 정확한 부분 문자열" - } - ], - "keyWeaknesses": [ - { - "title": "SQL 활용 경험이 보강되면 더 설득력 있어요", - "quote": "JD 또는 자소서 답변 안에 실제 존재하는 정확한 부분 문자열" - } - ], - "missingKeywords": [ - { - "keyword": "SQL 활용 경험", - "source": "qualification" - } - ], - "questionAnalyses": [ - { - "questionId": 1, - "sentence": "자소서 답변 안에 실제 존재하는 정확한 부분 문자열", - "status": "mentioned", - "reason": "문제 이유", - "improvement": "사용자가 그대로 붙여 넣을 수 있는 완성된 개선 예시 문장" - } - ] - } + [출력 규칙] + - Structured Output 스키마에 맞는 JSON object만 반환한다. + - jobFit: 실제 JD와 실제 답변 전체를 기준으로 독립 산정한 0~100 정수 + - impact: 실제 JD와 실제 답변 전체를 기준으로 독립 산정한 0~100 정수 + - completeness: 실제 JD와 실제 답변 전체를 기준으로 독립 산정한 0~100 정수 + - feedback: 한 줄 피드백 + - keyStrengths: 없으면 [] + - keyWeaknesses: 없으면 [] + - missingKeywords: 없으면 [] + - questionAnalyses: 실제 첨삭이 필요한 문장에 따라 0~3개 + - improvement: 안전한 개선문을 만들 수 없으면 null + - 프롬프트 안에 특정 점수 숫자 조합을 JSON 예시로 넣지 않는다. + - 0, 50, 70, 100 같은 임의 점수도 출력 예시로 사용하지 않는다. + - Structured Output은 JSON 형식과 타입만 보장한다. + - sentence 원문 존재 여부, status/reason 정합성, improvement 사실 생성 여부, missingKeyword JD 근거 여부는 서버가 다시 검증한다. """; private static final String EVALUATION_CRITERIA = """ [평가 절차] - 1. JD의 주요 업무, 필수 자격요건, 우대사항을 구분한다. - 2. 각 요건에 대응하는 자기소개서 원문 근거를 찾는다. - 3. 근거를 proven, mentioned, missing, fabricated 중 하나로 판정한다. - 4. jobFit, impact, completeness를 각각 평가한다. - 5. 감점 금지 조건과 status 오남용 여부를 확인한다. - 6. 보완이 필요한 원문 문장을 문항당 최대 3개만 추출한다. - 7. 총점 한 줄 평가 feedback과 핵심 강점/약점 카드를 작성한다. - 8. JD에는 있지만 자소서에 충분히 드러나지 않은 역량을 missingKeywords로 최대 3개 추출한다. - 9. 지정된 JSON만 반환한다. + 최종 JSON을 작성하기 전에 내부적으로 다음 순서를 따른다. + 내부 판단 과정이나 chain-of-thought를 응답에 출력하지 않는다. + 별도의 reasoning 필드나 analysis 필드를 API 응답에 추가하지 않는다. + 모델의 상세 사고과정을 로그나 DB에 저장하지 않는다. + 1. 문장 유형을 경험/성과, 포부/계획, 지원동기, 역량/자격으로 판단한다. + 2. mainTask와 qualification 중 직접 관련된 요구사항을 찾는다. + 3. preference만 근거인 경우 첨삭 대상에서 제외한다. + 4. 문장 유형에 맞는 평가 기준만 적용한다. + 5. 실제로 첨삭이 필요한 문장인지 판단한다. + 6. mentioned 또는 fabricated 상태를 결정한다. + 7. reason이 status 및 문장 유형과 일치하는지 확인한다. + 8. 원문 사실만으로 안전한 improvement를 작성할 수 있는지 확인한다. + 9. 새 경험, 수치, 기술, 계획, 시제 변경, 메타 조언이 없는지 재검사한다. + 10. 최종 JSON만 반환한다. + + [문장 유형 구분] + - 경험/성과: 과거에 수행한 행동, 역할, 문제 해결, 결과, 수치, 산출물이 드러나는 문장 + - 포부/계획: 입사 후 하겠다, 기여하겠다, 성장하겠다처럼 미래 실행 의지를 말하는 문장 + - 지원동기: 회사/직무를 선택한 이유, 관심, 가치관, 동기를 설명하는 문장 + - 역량/자격: 보유 기술, 자격, 면허, 전공, 경력, 교육 이수 여부를 설명하는 문장 + + [포부/계획 문장 평가 규칙] + - "~하겠습니다", "~되겠습니다", "~기여하겠습니다", "~노력하겠습니다", "~성장하고 싶습니다" 등 미래 시점 문장은 포부/계획으로 우선 판단한다. + - 포부/계획 문장에는 과거 성과 수치, 과거 결과, Before-After를 요구하지 않는다. + - 포부/계획은 실행 대상, 실행 방법, 단계, 직무 연결성이 구체적인지 중심으로 판단한다. + - 포부/계획 문장을 "성과 수치가 부족하다"는 이유로 questionAnalyses에 포함하지 않는다. + - 포부/계획 문장의 reason에는 "성과 수치가 부족", "정량적 결과가 부족", "Before-After가 부족", "과거 성과가 드러나지 않음"을 사용하지 않는다. + - 포부가 너무 추상적일 때만 실행 대상, 방법, 단계, 직무 연결성을 보완하도록 reason을 작성한다. + + [문장 유형별 평가 기준] + - 경험/성과: 과거 수행 행동, 역할, 문제, 결과를 평가한다. 모든 경험에 반드시 수치를 요구하지 않는다. + - 경험/성과: 과정, 역할, 산출물, 검증 결과 중 하나 이상이 충분히 구체적이면 인정한다. + - 경험/성과: 이미 수치나 명확한 결과가 있으면 "성과 수치 부족" reason을 생성하지 않는다. + - 포부/계획: 미래 시점 문장에는 과거 성과 수치나 Before-After를 요구하지 않고 실행 대상, 방법, 단계, 직무 연결성을 평가한다. + - 지원동기: 회사 또는 직무를 선택한 이유가 개인 경험·관심과 연결되는지 평가하며, 지원동기를 성과 문장처럼 평가하지 않는다. + - 지원동기: 해당 회사만의 이유가 없는 일반론인지 확인한다. + - 역량/자격: 보유 기술이나 역량의 실제 사용 맥락을 평가한다. + - 역량/자격: 자격증이나 전공 자체를 문장 첨삭의 핵심 문제로 삼지 않는다. + - 역량/자격: 해당 기술을 실제로 어떻게 사용했는지가 있으면 그 근거를 평가한다. + + [JD 반영 우선순위] + - 판단 우선순위는 mainTask > qualification >>> preference다. + - mainTask와 qualification은 직무 적합성 판단의 핵심 기준이다. + - preference는 reason과 점수에 보조적으로만 반영한다. + - preference만 누락된 경우 questionAnalyses의 첨삭 대상으로 선택하지 않는다. + - preference만 근거로 jobFit을 크게 감점하지 않는다. + + [점수 산정 규칙] + - Few-shot 예시는 문장 상태 판정과 출력 형식 참고용이며 점수 예시가 아니다. + - 점수는 실제 JD와 실제 답변 전체를 기준으로 독립적으로 산정한다. + - 예시의 수치나 특정 고정 점수를 추론해서 사용하지 않는다. + - 서로 다른 입력에 동일한 점수를 기계적으로 반복하지 않는다. [jobFit 평가 기준] JD가 요구하는 역량, 경험, 기술을 자기소개서가 얼마나 증명하는지 평가한다. @@ -131,16 +167,20 @@ public class AnalysisAiClient { """; private static final String STATUS_AND_WRITING_RULES = """ [status 판정 기준] - - proven: 구체적인 경험, 행동, 기술, 프로젝트, 수치 또는 결과로 역량을 실질적으로 입증함 - - mentioned: 관련 키워드나 경험은 언급했지만 구체적인 근거, 에피소드 또는 결과가 부족함 + - questionAnalyses의 허용 status는 mentioned, fabricated뿐이다. + - mentioned: 관련 경험이나 의도는 있으나 구체성이 부족한 문장 - missing: 해당 역량이나 요건을 자기소개서에서 전혀 다루지 않음 - - fabricated: 주장 내용이 앞뒤 맥락과 명백히 충돌하거나 현실적으로 불가능한 수준의 비일관성이 있음 + - fabricated: JD 또는 답변 내부의 명시적 사실과 직접 충돌하거나, 지원자가 실제로 하지 않았다고 밝힌 경험을 한 것처럼 주장한 경우 [status 중요 규칙] + - PROVEN은 questionAnalyses에 반환하지 않는다. + - 충분히 좋은 문장은 questionAnalyses에 넣지 않고 keyStrengths로 반환한다. + - MISSING은 sentence가 없으므로 questionAnalyses에 넣지 않고 missingKeywords로만 반환한다. - 직접적인 증거가 부족해도 관련 경험이 있으면 mentioned로 분류한다. - missing은 관련 언급이 전혀 없을 때만 사용한다. - fabricated는 단순히 근거가 부족하다는 이유로 사용하지 않는다. - - fabricated는 명백한 모순이나 비현실적 과장이 있을 때만 사용한다. + - fabricated는 명시적 사실과 직접 충돌하거나 지원자가 하지 않았다고 밝힌 경험을 한 것처럼 주장한 경우에만 사용한다. + - status 다양성을 만들기 위해 억지로 fabricated를 생성하지 않는다. - 구체적인 경험이나 수치가 부족하다는 이유만으로 fabricated를 사용하지 않는다. [sentence 규칙] @@ -148,20 +188,34 @@ public class AnalysisAiClient { - 원문에 없는 문장을 생성하지 않는다. - sentence를 요약하거나 수정하지 않는다. - 원문 매칭이 불확실하면 questionAnalyses에 포함하지 않는다. - - 분석 대상 문장은 문항당 최대 3개만 반환한다. + - 좋은 문장은 questionAnalyses에 넣지 않고 keyStrengths로 반환한다. + - questionAnalyses는 실제 첨삭이 필요한 문장만 반환한다. + - questionAnalyses는 문항당 0~3개 반환한다. + - 항상 1개를 반환할 필요가 없다. + - 실제로 보완이 필요한 문장만 문항당 최대 3개 반환한다. + - 실제로 독립적인 문제 문장이 여러 개라면 대표 1개만 선택하지 말고 최대 3개까지 반환한다. + - 동일한 문제를 반복하는 문장은 하나만 선택한다. + - 보완할 문장이 없으면 빈 배열 []을 반환한다. - 동일하거나 거의 동일한 문장을 중복 반환하지 않는다. - start/end index는 출력하지 않는다. 서버가 Java String character index 기준으로 계산한다. - missing은 원문에 해당 문장이 없을 수 있으므로 sentence를 임의로 만들지 않는다. - missing은 questionAnalyses에 억지로 넣지 않는다. [missingKeywords 규칙] - - JD에는 있지만 자소서에 충분히 드러나지 않은 요건이나 역량을 추출한다. + - 실제 입력 JD의 주요 업무, 자격 요건 원문에 존재하지만 자소서에 충분히 드러나지 않은 경험형 역량만 추출한다. + - 유사 JD 검색 결과, 직무별 보조 평가 기준, few-shot 예시, 모델의 일반 지식에서 키워드를 생성하지 않는다. + - JD 원문에 없는 SQL, Python, AWS, 대용량 트래픽 같은 키워드를 생성하지 않는다. + - 자격증, 면허, 어학성적, 학위, 전공, 경력 연차, 근무 가능 여부, 나이, 국적, 졸업 여부 같은 정형 자격요건은 missingKeywords에 넣지 않는다. + - preference에만 존재하는 항목은 원칙적으로 missingKeywords에서 제외한다. + - mainTask와 qualification에서 근거를 찾지 못한 경우 preference만으로 문장을 문제 삼지 않는다. + - reason에서 preference를 핵심 결격 사유처럼 표현하지 않는다. + - preference가 없다는 이유만으로 mentioned를 생성하지 않는다. - questionAnalyses와 분리해서 missingKeywords에만 넣는다. - 최대 3개만 반환한다. - 누락 키워드가 없으면 null이나 필드 생략이 아니라 빈 배열 []을 반환한다. - - 우선순위는 자격요건(qualification) > 우대사항(preference) > 주요 업무(mainTask)다. + - 우선순위는 주요 업무(mainTask) > 자격요건(qualification) >>> 우대사항(preference)다. - keyword는 단순 단어보다 짧은 역량 문구 형태로 작성한다. - - 가능하면 JD에 실제 들어간 표현을 유지한다. + - 반드시 실제 입력 JD에 들어간 표현을 유지한다. - 중복되거나 유사한 keyword는 하나로 묶고, 대표 문구는 자격요건 표현을 우선한다. - source는 qualification, preference, mainTask 중 하나만 사용한다. @@ -170,6 +224,10 @@ public class AnalysisAiClient { - title은 화면 카드 제목으로 바로 노출할 짧은 한국어 문장으로 작성한다. - quote는 반드시 실제 텍스트에서 가져온 짧은 직접 인용이어야 하며 새로 만들거나 요약하지 않는다. - keyStrengths의 quote는 자소서 answer에 실제 포함된 정확한 부분 문자열만 사용한다. + - 충분히 구체적인 좋은 문장은 keyStrengths 후보로 사용한다. + - keyStrengths는 mainTask 또는 qualification과 직접 연결된 근거를 우선한다. + - preference만 충족하는 문장은 핵심 강점으로 과대평가하지 않는다. + - keyStrengths와 questionAnalyses에 같은 quote/sentence를 동시에 넣지 않는다. - keyWeaknesses의 첫 항목들은 missingKeywords와 같은 누락 요건을 다룬다. - missingKeywords 기반 keyWeaknesses의 quote는 JD의 주요 업무, 자격 요건, 우대 사항에 실제 포함된 표현을 사용한다. - missingKeywords가 없으면 keyWeaknesses는 questionAnalyses의 보완 대상 문장 quote를 우선 사용한다. @@ -186,9 +244,19 @@ public class AnalysisAiClient { [improvement 작성 규칙] - improvement는 첨삭 조언이 아니라 사용자가 sentence를 대체해 사용할 수 있는 완성된 한국어 문장이어야 한다. + - improvement는 사용자가 그대로 교체해 쓸 수 있는 자기소개서 문장이어야 하며 첨삭 행위를 설명하는 메타 문장이 아니어야 한다. + - 개선이 필요하지 않으면 improvement는 null로 반환한다. + - 원문 정보만으로 개선문을 만들 수 없으면 improvement는 null로 반환한다. - 반드시 한국어 평서문으로 작성한다. - "추가하세요", "보완하세요", "수정해주세요", "필요합니다" 같은 지시문을 사용하지 않는다. + - "구체적으로 작성했습니다", "명확히 설명했습니다" 같은 메타 조언을 improvement로 반환하지 않는다. + - 원문과 실질적으로 동일한 문장을 improvement로 반환하지 않는다. + - 답변의 다른 문장을 그대로 복사해 improvement로 반환하지 않는다. + - JD 요구사항을 지원자가 실제 수행한 경험처럼 생성하지 않는다. - 사용자가 언급하지 않은 경험, 기술, 도구명, 인원수, 금액, 성과 수치를 임의로 만들지 않는다. + - 원문이 과거 경험이면 개선문도 과거 경험을 유지한다. + - 원문이 포부이면 개선문도 포부를 유지한다. + - 새로운 경험이나 계획을 추가하지 않는다. - 수치가 필요하지만 원문에 없다면 N건, X%%, 약 N시간 같은 빈칸 표현을 사용한다. - 원래 경험과 맥락을 최대한 유지한다. - 가능하면 행동, 역할, 결과가 드러나도록 개선한다. @@ -199,10 +267,15 @@ public class AnalysisAiClient { private final OpenAIClient openAIClient; private final CorpusRetrievalService corpusRetrievalService; private final LlmConcurrencyLimiter llmConcurrencyLimiter; + private final FewShotPromptProvider fewShotPromptProvider; + private final ObjectMapper objectMapper; @Value("${openai.model.cover-letter-analysis:gpt-4o-mini}") private String analysisModel; + @Value("${analysis.two-pass.enabled:false}") + private boolean twoPassEnabled; + public AnalysisLlmResponse analyze(AnalysisExecutionPayload payload) { return analyze(payload.jobPosting(), payload.answeredQuestions(), payload.jobCategoryEvaluationCriteria()); } @@ -223,19 +296,21 @@ public AnalysisLlmResponse analyze( 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, referenceContext, jobCategoryEvaluationCriteria)) - .temperature(0.2) - .text(AnalysisLlmResponse.class) - .build(); - try { - StructuredResponse response = llmConcurrencyLimiter.execute( - "cover-letter-analysis", - () -> openAIClient.responses().create(params) - ); - return extractStructuredContent(response); + if (twoPassEnabled) { + return analyzeTwoPass( + AnalysisPromptInput.from(jobPosting, questions), + referenceContext, + jobCategoryEvaluationCriteria, + "cover-letter-analysis" + ).response(); + } + return analyzeSinglePass( + AnalysisPromptInput.from(jobPosting, questions), + referenceContext, + jobCategoryEvaluationCriteria, + "cover-letter-analysis" + ).response(); } catch (GeneralException e) { throw e; } catch (Exception e) { @@ -251,19 +326,28 @@ public AnalysisLlmResponse analyzeForEvaluation( AnalysisPromptInput promptInput, JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria ) { - var params = ResponseCreateParams.builder() - .model(analysisModel) - .input(buildPrompt(promptInput, emptyContext(), jobCategoryEvaluationCriteria)) - .temperature(0.2) - .text(AnalysisLlmResponse.class) - .build(); + return analyzeForEvaluationResult(promptInput, jobCategoryEvaluationCriteria).response(); + } + public AnalysisAiCallResult analyzeForEvaluationResult( + AnalysisPromptInput promptInput, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria + ) { try { - StructuredResponse response = llmConcurrencyLimiter.execute( - "cover-letter-analysis-evaluation", - () -> openAIClient.responses().create(params) + if (twoPassEnabled) { + return analyzeTwoPass( + promptInput, + emptyContext(), + jobCategoryEvaluationCriteria, + "cover-letter-analysis-evaluation" + ); + } + return analyzeSinglePass( + promptInput, + emptyContext(), + jobCategoryEvaluationCriteria, + "cover-letter-analysis-evaluation" ); - return extractStructuredContent(response); } catch (GeneralException e) { throw e; } catch (Exception e) { @@ -275,6 +359,79 @@ public AnalysisLlmResponse analyzeForEvaluation( } } + private AnalysisAiCallResult analyzeSinglePass( + AnalysisPromptInput promptInput, + RetrievalContext referenceContext, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, + String operationName + ) { + long startedAt = System.nanoTime(); + AnalysisLlmResponse response = createStructuredResponse( + operationName, + buildPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria), + AnalysisLlmResponse.class + ); + return AnalysisAiCallResult.singlePass(response, elapsedMillis(startedAt)); + } + + private AnalysisAiCallResult analyzeTwoPass( + AnalysisPromptInput promptInput, + RetrievalContext referenceContext, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, + String operationName + ) { + long candidateStartedAt = System.nanoTime(); + AnalysisCandidateResponse rawCandidates = createStructuredResponse( + operationName + "-candidates", + buildCandidatePrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria), + AnalysisCandidateResponse.class + ); + long candidateLatencyMs = elapsedMillis(candidateStartedAt); + AnalysisCandidateResponse sanitizedCandidates = sanitizeCandidates(promptInput, rawCandidates); + log.debug( + "analysis two-pass candidate result. enabled={}, rawAnalysisCount={}, sanitizedAnalysisCount={}, rawStrengthCount={}, sanitizedStrengthCount={}, rawMissingKeywordCount={}, sanitizedMissingKeywordCount={}, model={}, latencyMs={}", + true, + size(rawCandidates == null ? null : rawCandidates.analysisCandidates()), + size(sanitizedCandidates.analysisCandidates()), + size(rawCandidates == null ? null : rawCandidates.strengthCandidates()), + size(sanitizedCandidates.strengthCandidates()), + size(rawCandidates == null ? null : rawCandidates.missingKeywordCandidates()), + size(sanitizedCandidates.missingKeywordCandidates()), + analysisModel, + candidateLatencyMs + ); + + long finalStartedAt = System.nanoTime(); + AnalysisLlmResponse response = createStructuredResponse( + operationName + "-final", + buildFinalPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria, sanitizedCandidates), + AnalysisLlmResponse.class + ); + long finalLatencyMs = elapsedMillis(finalStartedAt); + log.debug( + "analysis two-pass final result. enabled={}, finalAnalysisCount={}, model={}, latencyMs={}", + true, + size(response == null ? null : response.questionAnalyses()), + analysisModel, + finalLatencyMs + ); + return AnalysisAiCallResult.twoPass(response, rawCandidates, sanitizedCandidates, candidateLatencyMs, finalLatencyMs); + } + + private T createStructuredResponse(String operationName, String prompt, Class responseType) { + var params = ResponseCreateParams.builder() + .model(analysisModel) + .input(prompt) + .temperature(0.2) + .text(responseType) + .build(); + StructuredResponse response = llmConcurrencyLimiter.execute( + operationName, + () -> openAIClient.responses().create(params) + ); + return extractStructuredContent(response); + } + String buildPrompt( JobPosting jobPosting, List questions, @@ -292,6 +449,196 @@ String buildPrompt( AnalysisPromptInput promptInput, RetrievalContext referenceContext, JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria + ) { + return buildSinglePassPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria); + } + + String buildCandidatePrompt( + AnalysisPromptInput promptInput, + RetrievalContext referenceContext, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria + ) { + String questionText = formatQuestions(promptInput); + String jobCategoryCriteriaSection = formatJobCategoryEvaluationCriteriaSection(jobCategoryEvaluationCriteria); + return """ + [시스템 지시] + 너는 자기소개서 분석을 위한 1차 후보 판정기다. + 반드시 Structured Output 스키마에 맞는 JSON object만 반환한다. + 내부 판단 과정이나 chain-of-thought를 출력하지 않는다. + reasoning, analysis 같은 별도 필드를 추가하지 않는다. + + [1차 출력 필드] + - strengthCandidates: 충분히 구체적이고 mainTask 또는 qualification과 직접 연결된 좋은 문장 후보. 없으면 []. + - analysisCandidates: 실제 첨삭이 필요한 문장 후보. status는 MENTIONED 또는 FABRICATED만 사용. 최대 3개. + - missingKeywordCandidates: sentence가 없는 누락 역량 후보. 없으면 []. + - 점수 필드, feedback, improvement, keyWeaknesses는 1차 출력에 존재하지 않는다. + + [허용값] + - sentenceType: EXPERIENCE, PLAN, MOTIVATION, COMPETENCY + - relatedSource: MAIN_TASK, QUALIFICATION, PREFERENCE, NONE + - status: MENTIONED, FABRICATED + - issueType: LACK_OF_ACTION, LACK_OF_METHOD, LACK_OF_RESULT, LACK_OF_ROLE, LACK_OF_JOB_CONNECTION, ABSTRACT_PLAN, GENERIC_MOTIVATION, UNSUPPORTED_CLAIM, DIRECT_CONTRADICTION + + [1차 판정 절차] + 1. 답변을 문장 단위로 확인한다. + 2. 각 문장의 유형을 분류한다. + 3. mainTask와 qualification의 직접 근거를 찾는다. + 4. preference만 근거인 후보는 제외한다. + 5. 문장이 이미 충분히 구체적인지 판단한다. + 6. 충분한 문장은 strengthCandidates로 분류한다. + 7. 보완이 필요한 문장만 analysisCandidates로 분류한다. + 8. 실제 충돌이 있는 경우에만 FABRICATED를 사용한다. + 9. 누락 요구사항은 sentence가 아니라 missingKeywordCandidates로 분리한다. + 10. 대표 1개만 기계적으로 고르지 말고, 독립적인 문제가 있으면 최대 3개까지 반환한다. + + [1차 후보 규칙] + - questionId는 입력된 questionId 중 하나만 사용한다. + - sentence와 quote는 해당 answer에 실제 포함된 정확한 부분 문자열만 사용한다. + - preference-only 후보는 strengthCandidates, analysisCandidates, missingKeywordCandidates에서 제외한다. + - 충분히 좋은 문장은 analysisCandidates에 넣지 않는다. + - 포부/계획 문장에는 과거 성과 수치나 Before-After를 요구하지 않는다. + - 지원동기는 수치 부족으로 분류하지 않는다. + - MISSING은 analysisCandidates에 넣지 않고 missingKeywordCandidates로만 분리한다. + - FABRICATED는 JD 또는 답변 내부의 명시적 사실과 직접 충돌하거나, 지원자가 실제로 하지 않았다고 밝힌 경험을 한 것처럼 주장한 경우에만 사용한다. + - status 다양성이나 개수를 채우기 위해 후보를 만들지 않는다. + - improvement를 생성하지 않는다. + + [채용 공고] + 회사명: %s + 직무명: %s + + %s + + + %s + + + %s + + + [유사 JD 검색 결과] + %s + + [유사 자소서 문항 검색 결과] + %s + + %s + + [자소서 문항과 답변] + %s + """.formatted( + defaultString(promptInput.companyName()), + defaultString(promptInput.jobName()), + defaultString(promptInput.mainTasks()), + defaultString(promptInput.qualifications()), + defaultString(promptInput.preferences()), + formatJobPostingReferences(referenceContext.jobPostingReferences()), + formatQuestionReferences(referenceContext.questionReferences()), + jobCategoryCriteriaSection, + questionText + ); + } + + String buildFinalPrompt( + AnalysisPromptInput promptInput, + RetrievalContext referenceContext, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, + AnalysisCandidateResponse candidates + ) { + String questionText = formatQuestions(promptInput); + String candidateJson = writeJson(candidates); + String jobCategoryCriteriaSection = formatJobCategoryEvaluationCriteriaSection(jobCategoryEvaluationCriteria); + return """ + [시스템 지시] + 너는 한국 채용 담당자이자 자기소개서 평가 전문가다. + 반드시 Structured Output 스키마에 맞는 JSON object만 반환한다. + 내부 판단 과정이나 chain-of-thought를 출력하지 않는다. + + %s + + [2차 후보 존중 원칙] + - 아래 후보는 1차 호출 후 서버 검증을 통과한 후보만 포함한다. + - 1차 후보를 참고하되 무조건 복사하지 않는다. + - 1차 후보가 실제 원문이나 JD와 맞지 않으면 최종 결과에서 제외한다. + - 1차에 없는 새로운 questionAnalysis를 임의로 추가하지 않는다. + - keyStrengths와 missingKeywords는 1차 후보 범위 안에서 정리·축약 가능하다. + - questionAnalyses는 검증된 analysisCandidates 중 실제 첨삭 가치가 있는 항목만 반환한다. + - questionAnalyses 최종 status는 mentioned 또는 fabricated만 사용한다. + - PROVEN은 questionAnalyses에 반환하지 않고 좋은 문장은 keyStrengths로 표현한다. + - MISSING은 questionAnalyses가 아니라 missingKeywords로만 처리한다. + + [문장 유형별 첨삭] + - 경험/성과: 행동, 역할, 방법, 결과 중 실제 부족한 요소만 보완한다. + - 경험/성과: 이미 수치와 방법이 있으면 부족하다고 평가하지 않는다. + - 포부/계획: 과거 성과 수치를 요구하지 않고 실행 대상, 방법, 단계, 직무 연결성을 중심으로 reason을 작성한다. + - 지원동기: 수치를 요구하지 않고 회사 또는 직무 선택 이유와 개인 경험의 연결성을 평가한다. + - 역량/자격: 자격증이나 전공 자체보다 실제 활용 맥락을 평가한다. + - 역량/자격: 정형 자격요건은 missingKeywords에서 제외한다. + + [improvement 안전 규칙] + - 사용자가 그대로 교체해 쓸 수 있는 자기소개서 문장이어야 한다. + - 첨삭 행위를 설명하는 메타 문장을 금지한다. + - 원문과 실질적으로 동일한 문장을 금지한다. + - 답변의 다른 문장 복사를 금지한다. + - 원문에 없는 수치, 도구, 경험, 직무 수행, 계획 추가를 금지한다. + - JD 문구를 지원자의 실제 경험처럼 변환하지 않는다. + - 과거 문장을 미래 포부로 변경하지 않는다. + - 미래 포부를 과거 경험으로 변경하지 않는다. + - 원문 사실만으로 안전한 개선이 불가능하면 null을 반환한다. + - 금지 예시: 구체적으로 설명했습니다. 경험을 추가하면 좋겠습니다. 성과를 강조했습니다. 구체적인 계획을 추가하겠습니다. 명확하게 작성할 수 있습니다. + + [점수 산정] + - 1차 후보 개수와 점수를 연결하지 않는다. + - questionAnalyses가 0개여도 무조건 고득점으로 처리하지 않는다. + - questionAnalyses가 많다고 기계적으로 감점하지 않는다. + - 점수는 JD 전체와 답변 전체를 기준으로 독립적으로 산정한다. + - Few-shot의 점수나 고정 숫자 예시는 사용하지 않는다. + + [채용 공고] + 회사명: %s + 직무명: %s + + %s + + + %s + + + %s + + + [검증된 1차 후보] + %s + + [유사 JD 검색 결과] + %s + + [유사 자소서 문항 검색 결과] + %s + + %s + + [자소서 문항과 답변] + %s + """.formatted( + OUTPUT_SCHEMA, + defaultString(promptInput.companyName()), + defaultString(promptInput.jobName()), + defaultString(promptInput.mainTasks()), + defaultString(promptInput.qualifications()), + defaultString(promptInput.preferences()), + candidateJson, + formatJobPostingReferences(referenceContext.jobPostingReferences()), + formatQuestionReferences(referenceContext.questionReferences()), + jobCategoryCriteriaSection, + questionText + ); + } + + String buildSinglePassPrompt( + AnalysisPromptInput promptInput, + RetrievalContext referenceContext, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria ) { String questionText = promptInput.questions().stream() .map(question -> """ @@ -323,17 +670,28 @@ String buildPrompt( %s + [Few-shot 예시] + %s + [채용 공고] 회사명: %s 직무명: %s - 주요 업무: + 아래 JD 영역의 역할: + - 는 최우선 업무 기준이다. + - 는 핵심 자격요건 기준이다. + - 는 보조 기준이며 단독 결격 사유로 사용하지 않는다. + + %s + - 자격 요건: + %s + - 우대 사항: + %s + [유사 JD 검색 결과] %s @@ -366,6 +724,7 @@ String buildPrompt( OUTPUT_SCHEMA, EVALUATION_CRITERIA, STATUS_AND_WRITING_RULES.formatted(AnalysisImprovementRules.bannedPhrasesText()), + fewShotPromptProvider.getPrompt(), defaultString(promptInput.companyName()), defaultString(promptInput.jobName()), defaultString(promptInput.mainTasks()), @@ -378,6 +737,132 @@ String buildPrompt( ); } + AnalysisCandidateResponse sanitizeCandidates( + AnalysisPromptInput promptInput, + AnalysisCandidateResponse candidates + ) { + Map answerByQuestionId = promptInput.questions().stream() + .collect(Collectors.toMap(AnalysisPromptInput.QuestionAnswer::questionId, AnalysisPromptInput.QuestionAnswer::answer)); + List strengthCandidates = sanitizeStrengthCandidates(candidates, answerByQuestionId); + List analysisCandidates = sanitizeAnalysisCandidates(candidates, answerByQuestionId); + List missingKeywordCandidates = sanitizeMissingKeywordCandidates(promptInput, candidates); + return new AnalysisCandidateResponse(strengthCandidates, analysisCandidates, missingKeywordCandidates); + } + + private List sanitizeStrengthCandidates( + AnalysisCandidateResponse candidates, + Map answerByQuestionId + ) { + if (candidates == null || candidates.strengthCandidates() == null) { + return List.of(); + } + List result = new ArrayList<>(); + Set seen = new HashSet<>(); + for (AnalysisCandidateResponse.StrengthCandidate candidate : candidates.strengthCandidates()) { + if (candidate == null || candidate.questionId() == null || !StringUtils.hasText(candidate.quote())) { + continue; + } + String answer = answerByQuestionId.get(candidate.questionId()); + if (!containsExact(answer, candidate.quote()) || !isPrimarySource(candidate.relatedSource())) { + continue; + } + String dedupeKey = candidate.questionId() + ":" + normalize(candidate.quote()); + if (!seen.add(dedupeKey)) { + continue; + } + result.add(candidate); + if (result.size() >= 3) { + break; + } + } + return result; + } + + private List sanitizeAnalysisCandidates( + AnalysisCandidateResponse candidates, + Map answerByQuestionId + ) { + if (candidates == null || candidates.analysisCandidates() == null) { + return List.of(); + } + List result = new ArrayList<>(); + Set seen = new HashSet<>(); + for (AnalysisCandidateResponse.AnalysisCandidate candidate : candidates.analysisCandidates()) { + if (candidate == null || candidate.questionId() == null || !StringUtils.hasText(candidate.sentence())) { + continue; + } + String answer = answerByQuestionId.get(candidate.questionId()); + if (!containsExact(answer, candidate.sentence()) || !isPrimarySource(candidate.relatedSource())) { + continue; + } + QuestionAnalysisStatus status = parseQuestionAnalysisStatus(candidate.status()); + if (status != QuestionAnalysisStatus.MENTIONED && status != QuestionAnalysisStatus.FABRICATED) { + continue; + } + if (status == QuestionAnalysisStatus.FABRICATED + && !AnalysisSanitizationRules.hasFabricatedDirectConflictReason(candidate.reasonBasis())) { + continue; + } + String dedupeKey = candidate.questionId() + ":" + normalize(candidate.sentence()); + if (!seen.add(dedupeKey)) { + continue; + } + result.add(candidate); + if (result.size() >= 3) { + break; + } + } + return result; + } + + private List sanitizeMissingKeywordCandidates( + AnalysisPromptInput promptInput, + AnalysisCandidateResponse candidates + ) { + if (candidates == null || candidates.missingKeywordCandidates() == null) { + return List.of(); + } + List result = new ArrayList<>(); + Set seen = new HashSet<>(); + for (AnalysisCandidateResponse.MissingKeywordCandidate candidate : candidates.missingKeywordCandidates()) { + if (candidate == null || !StringUtils.hasText(candidate.keyword())) { + continue; + } + Optional source = parseCandidateSource(candidate.source()); + if (source.isEmpty() + || !AnalysisSanitizationRules.isValidMissingKeyword( + candidate.keyword(), + source.get(), + promptInput.mainTasks(), + promptInput.qualifications() + )) { + continue; + } + String dedupeKey = normalize(candidate.keyword()); + if (!seen.add(dedupeKey)) { + continue; + } + result.add(candidate); + if (result.size() >= 3) { + break; + } + } + return result; + } + + private Optional parseCandidateSource(String source) { + if ("MAIN_TASK".equalsIgnoreCase(defaultString(source))) { + return Optional.of(MissingKeywordSource.MAIN_TASK); + } + if ("QUALIFICATION".equalsIgnoreCase(defaultString(source))) { + return Optional.of(MissingKeywordSource.QUALIFICATION); + } + if ("PREFERENCE".equalsIgnoreCase(defaultString(source))) { + return Optional.of(MissingKeywordSource.PREFERENCE); + } + return MissingKeywordSource.from(source); + } + private String formatJobCategoryEvaluationCriteriaSection(JobCategoryEvaluationCriteria criteria) { if (criteria == null) { return ""; @@ -470,7 +955,21 @@ private String formatQuestionReferences(List referen return truncate(formatted, MAX_REFERENCE_SECTION_LENGTH); } - private AnalysisLlmResponse extractStructuredContent(StructuredResponse response) { + private String formatQuestions(AnalysisPromptInput promptInput) { + return promptInput.questions().stream() + .map(question -> """ + - questionId: %d + question: %s + answer: %s + """.formatted( + question.questionId(), + defaultString(question.question()), + defaultString(question.answer()) + )) + .collect(Collectors.joining("\n")); + } + + private T extractStructuredContent(StructuredResponse response) { return response.output().stream() .filter(item -> item.message().isPresent()) .flatMap(item -> item.asMessage().content().stream()) @@ -478,11 +977,53 @@ private AnalysisLlmResponse extractStructuredContent(StructuredResponse new GeneralException( - GeneralErrorCode.INTERNAL_SERVER_ERROR, - "AI 응답에서 자소서 분석 결과를 찾을 수 없습니다." + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "AI 응답에서 자소서 분석 결과를 찾을 수 없습니다." )); } + private boolean isPrimarySource(String relatedSource) { + return "MAIN_TASK".equalsIgnoreCase(defaultString(relatedSource)) + || "QUALIFICATION".equalsIgnoreCase(defaultString(relatedSource)); + } + + private boolean containsExact(String source, String part) { + return StringUtils.hasText(source) + && StringUtils.hasText(part) + && source.contains(part); + } + + private QuestionAnalysisStatus parseQuestionAnalysisStatus(String status) { + if (!StringUtils.hasText(status)) { + return null; + } + try { + return QuestionAnalysisStatus.valueOf(status.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + return null; + } + } + + private long elapsedMillis(long startedAt) { + return (System.nanoTime() - startedAt) / 1_000_000; + } + + private int size(List values) { + return values == null ? 0 : values.size(); + } + + private String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value == null ? List.of() : value); + } catch (JsonProcessingException e) { + return "[]"; + } + } + + private String normalize(String value) { + return value == null ? "" : value.replaceAll("\\s+", "").toLowerCase(); + } + private String defaultString(String value) { return value == null ? "" : value; } @@ -497,4 +1038,34 @@ private String truncate(String value, int maxLength) { private RetrievalContext emptyContext() { return new RetrievalContext(List.of(), List.of()); } + + public record AnalysisAiCallResult( + AnalysisLlmResponse response, + AnalysisCandidateResponse rawCandidateResponse, + AnalysisCandidateResponse sanitizedCandidateResponse, + boolean twoPassEnabled, + long candidateCallLatencyMs, + long finalCallLatencyMs + ) { + static AnalysisAiCallResult singlePass(AnalysisLlmResponse response, long latencyMs) { + return new AnalysisAiCallResult(response, null, null, false, 0, latencyMs); + } + + static AnalysisAiCallResult twoPass( + AnalysisLlmResponse response, + AnalysisCandidateResponse rawCandidateResponse, + AnalysisCandidateResponse sanitizedCandidateResponse, + long candidateCallLatencyMs, + long finalCallLatencyMs + ) { + return new AnalysisAiCallResult( + response, + rawCandidateResponse, + sanitizedCandidateResponse, + true, + candidateCallLatencyMs, + finalCallLatencyMs + ); + } + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisSanitizationRules.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisSanitizationRules.java new file mode 100644 index 0000000..9f31eb9 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisSanitizationRules.java @@ -0,0 +1,256 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; +import org.springframework.util.StringUtils; + +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class AnalysisSanitizationRules { + private static final double MIN_KEYWORD_TOKEN_MATCH_RATIO = 0.5; + private static final Pattern TOKEN_PATTERN = Pattern.compile("[a-zA-Z0-9+#.]+|[가-힣]+"); + 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", "학위", "전공", "졸업", "학력", "신입", "근무 가능", "국적", "나이", "연령" + }; + private static final String[] META_IMPROVEMENT_TERMS = { + "구체적으로 작성했습니다", "명확히 설명했습니다", "성과를 강조했습니다", "수치로 명시했습니다", + "더 설득력 있게 작성했습니다", "경험을 구체적으로 작성했습니다", "구체적으로 설명했습니다", + "구체적으로 서술했습니다", "명확히 서술했습니다", "구체적으로 설명합니다", + "구체적으로 서술합니다", "명확히 설명합니다", "명확히 서술합니다" + }; + private static final String[] CONTRADICTORY_PROVEN_REASON_TERMS = { + "근거가 부족", "성과가 부족", "수치가 부족", "구체성이 부족", "보완이 필요", + "드러나지 않음", "확인하기 어려움" + }; + private static final String[] FABRICATED_DIRECT_CONFLICT_TERMS = { + "직접 충돌", "명시적 사실과 충돌", "사실과 충돌", "조건과 충돌", "요건과 충돌", + "서로 충돌", "상충", "하지 않았다고", "수행하지 않았다고", "경험이 없다고", + "없다고 밝혔", "실제로 하지 않았" + }; + private static final Set STOP_WORDS = Set.of( + "경험", "역량", "업무", "관련", "가능", "보유", "필수", "우대", "자격", "요건", + "사항", "직무", "수행", "활용", "사용", "기반", "중심", "대한", "통한", "등", + "및", "또는", "위한", "있는", "없는" + ); + + private AnalysisSanitizationRules() { + } + + public static boolean isValidMissingKeyword( + String keyword, + MissingKeywordSource source, + String mainTasks, + String qualifications + ) { + if (!StringUtils.hasText(keyword) || source == null) { + return false; + } + if (source == MissingKeywordSource.PREFERENCE) { + return false; + } + if (isStructuredQualificationKeyword(keyword)) { + return false; + } + if (source == MissingKeywordSource.MAIN_TASK) { + return isGroundedInSource(keyword, mainTasks); + } + if (source == MissingKeywordSource.QUALIFICATION) { + return isGroundedInSource(keyword, qualifications); + } + return false; + } + + public static String normalizeImprovement( + String sentence, + String answer, + String improvement, + boolean provenStatus + ) { + if (provenStatus || !StringUtils.hasText(improvement)) { + return ""; + } + + String normalized = improvement.trim(); + if (AnalysisImprovementRules.isInstructionLike(normalized) + || equalsNormalized(sentence, normalized) + || isCopiedFromAnotherAnswerSentence(sentence, answer, normalized) + || isMetaImprovement(normalized) + || changesSentenceTense(sentence, normalized)) { + return ""; + } + return normalized; + } + + public static boolean isContradictoryProvenReason(String reason) { + if (!StringUtils.hasText(reason)) { + return false; + } + String normalized = normalize(reason); + for (String term : CONTRADICTORY_PROVEN_REASON_TERMS) { + if (normalized.contains(normalize(term))) { + return true; + } + } + return false; + } + + public static boolean hasFabricatedDirectConflictReason(String reason) { + if (!StringUtils.hasText(reason)) { + return false; + } + String normalized = normalize(reason); + for (String term : FABRICATED_DIRECT_CONFLICT_TERMS) { + if (normalized.contains(normalize(term))) { + return true; + } + } + return false; + } + + public static boolean isStructuredQualificationKeyword(String keyword) { + String normalized = normalize(keyword); + if (CAREER_YEAR_PATTERN.matcher(keyword).find()) { + return true; + } + for (String term : STRUCTURED_QUALIFICATION_TERMS) { + if (normalized.contains(normalize(term))) { + return true; + } + } + return false; + } + + private static boolean isGroundedInSource(String keyword, String sourceText) { + Set keywordTokens = coreTokens(keyword); + if (keywordTokens.isEmpty() || !StringUtils.hasText(sourceText)) { + return false; + } + + Set sourceTokens = coreTokens(sourceText); + long matchCount = keywordTokens.stream() + .filter(sourceTokens::contains) + .count(); + if (matchCount < 1) { + return false; + } + + if (keywordTokens.size() == 1) { + String token = keywordTokens.iterator().next(); + return token.length() >= 3 && sourceTokens.contains(token); + } + return (double) matchCount / keywordTokens.size() >= MIN_KEYWORD_TOKEN_MATCH_RATIO; + } + + private static Set coreTokens(String value) { + Set tokens = new LinkedHashSet<>(); + if (!StringUtils.hasText(value)) { + return tokens; + } + + Matcher matcher = TOKEN_PATTERN.matcher(value.toLowerCase(Locale.ROOT)); + while (matcher.find()) { + String token = stripKoreanSuffix(matcher.group()); + if (isCoreToken(token)) { + tokens.add(token); + } + } + return tokens; + } + + private static boolean isCoreToken(String token) { + return StringUtils.hasText(token) + && token.length() >= 2 + && !STOP_WORDS.contains(token); + } + + private static String stripKoreanSuffix(String token) { + if (token == null || !token.matches("[가-힣]+")) { + return token == null ? "" : token; + } + String result = token; + String[] suffixes = {"으로", "에서", "하며", "하고", "하는", "까지", "부터", "에게", "보다", + "은", "는", "이", "가", "을", "를", "와", "과", "의", "에", "로", "한"}; + boolean changed; + do { + changed = false; + for (String suffix : suffixes) { + if (result.length() > suffix.length() + 1 && result.endsWith(suffix)) { + result = result.substring(0, result.length() - suffix.length()); + changed = true; + break; + } + } + } while (changed); + return result; + } + + private static boolean containsNormalized(String sourceText, String keyword) { + return StringUtils.hasText(sourceText) + && StringUtils.hasText(keyword) + && normalize(sourceText).contains(normalize(keyword)); + } + + private static boolean equalsNormalized(String left, String right) { + return StringUtils.hasText(left) + && StringUtils.hasText(right) + && normalize(left).equals(normalize(right)); + } + + private static boolean isCopiedFromAnotherAnswerSentence(String sentence, String answer, String improvement) { + if (!StringUtils.hasText(answer) || !StringUtils.hasText(improvement)) { + return false; + } + return containsNormalized(answer, improvement) && !equalsNormalized(sentence, improvement); + } + + private static boolean isMetaImprovement(String improvement) { + String normalized = normalize(improvement); + for (String term : META_IMPROVEMENT_TERMS) { + if (normalized.contains(normalize(term))) { + return true; + } + } + return false; + } + + private static boolean changesSentenceTense(String sentence, String improvement) { + return isPastSentence(sentence) && isFutureSentence(improvement) + || isFutureSentence(sentence) && isPastSentence(improvement); + } + + private static boolean isPastSentence(String value) { + if (!StringUtils.hasText(value)) { + return false; + } + String normalized = normalize(value); + return normalized.contains("했습니다") + || normalized.contains("였습니다") + || normalized.contains("수행했습니다") + || normalized.contains("개선했습니다") + || normalized.contains("달성했습니다") + || normalized.contains("근무했습니다") + || normalized.contains("담당했습니다"); + } + + private static boolean isFutureSentence(String value) { + if (!StringUtils.hasText(value)) { + return false; + } + String normalized = normalize(value); + return normalized.contains("하겠습니다") + || normalized.contains("되겠습니다") + || normalized.contains("기여하겠습니다") + || normalized.contains("노력하겠습니다") + || normalized.contains("성장하겠습니다") + || normalized.contains("싶습니다"); + } + + private static String normalize(String value) { + return value == null ? "" : value.replaceAll("\\s+", "").toLowerCase(); + } +} 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 bd36a7d..e564b49 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 @@ -179,7 +179,7 @@ public AnalysisResponse finalizeAnalysis( int completeness = validateScore("completeness", llmResponse.completeness()); List keyStrengths = buildHighlights(llmResponse.keyStrengths()); List keyWeaknesses = buildHighlights(llmResponse.keyWeaknesses()); - List missingKeywords = buildMissingKeywords(llmResponse); + List missingKeywords = buildMissingKeywords(mockApply.getJobPosting(), llmResponse); replaceExistingAnalysis(mockApply); Analysis analysis = analysisRepository.save(Analysis.create( @@ -308,6 +308,7 @@ private List buildQuestionAnalyses( Map analysisCountByQuestionId = new HashMap<>(); Map nextSearchIndexByQuestionId = new HashMap<>(); Set seenSentences = new HashSet<>(); + Set keyStrengthQuotes = normalizedKeyStrengthQuotes(llmResponse); if (llmResponse.questionAnalyses() == null) { return result; @@ -328,7 +329,13 @@ private List buildQuestionAnalyses( continue; } QuestionAnalysisStatus status = parseStatus(item.status()); - if (status == null || status == QuestionAnalysisStatus.MISSING) { + if (status == null + || status == QuestionAnalysisStatus.MISSING + || status == QuestionAnalysisStatus.PROVEN) { + continue; + } + if (status == QuestionAnalysisStatus.FABRICATED + && !AnalysisSanitizationRules.hasFabricatedDirectConflictReason(item.reason())) { continue; } int currentCount = analysisCountByQuestionId.getOrDefault(question.getId(), 0); @@ -336,6 +343,9 @@ private List buildQuestionAnalyses( continue; } String sentence = item.sentence(); + if (keyStrengthQuotes.contains(normalizeKeyword(sentence))) { + continue; + } String dedupeKey = question.getId() + ":" + sentence.trim(); if (!seenSentences.add(dedupeKey)) { continue; @@ -356,7 +366,7 @@ private List buildQuestionAnalyses( analysis, sentence, defaultString(item.reason()), - normalizeImprovement(item.improvement()), + normalizeImprovement(sentence, answer, item.improvement(), status), status, start, start + sentence.length() @@ -366,6 +376,16 @@ private List buildQuestionAnalyses( return result; } + private Set normalizedKeyStrengthQuotes(AnalysisLlmResponse llmResponse) { + if (llmResponse == null || llmResponse.keyStrengths() == null) { + return Set.of(); + } + return llmResponse.keyStrengths().stream() + .filter(item -> item != null && StringUtils.hasText(item.quote())) + .map(item -> normalizeKeyword(item.quote())) + .collect(Collectors.toSet()); + } + private AnalysisResponse toResponse( MockApply mockApply, Analysis analysis, @@ -410,7 +430,7 @@ private List buildHighlights(List buildMissingKeywords(AnalysisLlmResponse llmResponse) { + private List buildMissingKeywords(JobPosting jobPosting, AnalysisLlmResponse llmResponse) { if (llmResponse == null || llmResponse.missingKeywords() == null) { return List.of(); } @@ -432,6 +452,14 @@ private List buildMissingKeywords(AnalysisLlmResponse ll if (source.isEmpty()) { continue; } + if (!AnalysisSanitizationRules.isValidMissingKeyword( + keyword, + source.get(), + jobPosting == null ? "" : jobPosting.getTask(), + jobPosting == null ? "" : jobPosting.getRequirement() + )) { + continue; + } String dedupeKey = normalizeKeyword(keyword); if (!seenKeywords.add(dedupeKey)) { @@ -658,20 +686,18 @@ private String defaultString(String value) { return value == null ? "" : value; } - private String normalizeImprovement(String improvement) { - if (!StringUtils.hasText(improvement)) { - return ""; - } - - String normalized = improvement.trim(); - if (isInstructionLikeImprovement(normalized)) { - return ""; - } - return normalized; - } - - private boolean isInstructionLikeImprovement(String improvement) { - return AnalysisImprovementRules.isInstructionLike(improvement); + private String normalizeImprovement( + String sentence, + String answer, + String improvement, + QuestionAnalysisStatus status + ) { + return AnalysisSanitizationRules.normalizeImprovement( + sentence, + answer, + improvement, + status == QuestionAnalysisStatus.PROVEN + ); } private QuestionAnalysisStatus parseStatus(String status) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/FewShotPromptProvider.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/FewShotPromptProvider.java new file mode 100644 index 0000000..5dd6274 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/FewShotPromptProvider.java @@ -0,0 +1,43 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; +import org.springframework.util.StreamUtils; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.regex.Pattern; + +@Component +@Slf4j +public class FewShotPromptProvider { + private static final String RESOURCE_PATH = "ai/analysis/fewshot-prompt-block.txt"; + private static final Pattern EXAMPLE_HEADER_PATTERN = Pattern.compile("(?m)^## 예시 "); + + @Getter + private final String prompt; + + public FewShotPromptProvider() { + this.prompt = loadPrompt(); + log.debug( + "few-shot prompt resource loaded. success={}, exampleCount={}", + true, + EXAMPLE_HEADER_PATTERN.matcher(prompt).results().count() + ); + } + + private String loadPrompt() { + ClassPathResource resource = new ClassPathResource(RESOURCE_PATH); + try (var inputStream = resource.getInputStream()) { + String content = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); + if (!org.springframework.util.StringUtils.hasText(content)) { + throw new IllegalStateException("Few-shot prompt resource is empty: " + RESOURCE_PATH); + } + return content; + } catch (IOException e) { + throw new IllegalStateException("Failed to load few-shot prompt resource: " + RESOURCE_PATH, e); + } + } +} diff --git a/src/main/resources/ai/analysis/fewshot-prompt-block.txt b/src/main/resources/ai/analysis/fewshot-prompt-block.txt new file mode 100644 index 0000000..2f94659 --- /dev/null +++ b/src/main/resources/ai/analysis/fewshot-prompt-block.txt @@ -0,0 +1,110 @@ +[# Few-shot examples] +Few-shot 예시는 판정 기준과 출력 형식의 참고 사례다. +예시의 분석 개수, 상태 비율, 문장 표현, 점수를 실제 입력에 복사하지 않는다. +실제 입력마다 독립적으로 판단한다. +questionAnalyses는 실제 첨삭이 필요한 문장에 따라 0~3개가 될 수 있다. +항상 1개를 반환할 필요가 없다. +Few-shot 출력에는 전체 점수를 포함하지 않는다. + +## 예시 A: 좋은 근거가 있으면 보완 문장이 0개일 수 있음 +관련 JD 요구사항: +- mainTask: API 성능 개선 및 데이터베이스 쿼리 최적화 + +평가 대상 답변: +- questionId: 1 +- answer: 쿼리 실행 계획을 분석하고 복합 인덱스를 적용해 평균 응답 시간을 1.2초에서 300ms로 단축했습니다. + +판정 참고: +- 위 문장은 구체적인 행동, 사용 기술, 수치 결과가 있어 좋은 강점으로 볼 수 있다. +- 그러나 이미 충분히 구체적이므로 questionAnalyses에는 포함하지 않는다. + +출력 중 강점/문장/누락 관련 필드: +{ + "keyStrengths": [ + { + "title": "성능 개선 근거가 구체적으로 드러나요", + "quote": "쿼리 실행 계획을 분석하고 복합 인덱스를 적용해 평균 응답 시간을 1.2초에서 300ms로 단축했습니다." + } + ], + "missingKeywords": [], + "questionAnalyses": [] +} + +## 예시 B: 관련 경험은 있으나 근거가 약한 경우 +관련 JD 요구사항: +- mainTask: 재고 현황 분석 및 적정 재고 관리 + +평가 대상 답변: +- questionId: 1 +- answer: 입출고 기록을 확인하며 재고 관리 업무를 수행했습니다. + +출력 중 문장/누락 관련 필드: +{ + "missingKeywords": [], + "questionAnalyses": [ + { + "questionId": 1, + "sentence": "입출고 기록을 확인하며 재고 관리 업무를 수행했습니다.", + "status": "mentioned", + "reason": "재고 관리 경험은 언급했지만 어떤 기준으로 현황을 분석하고 적정 재고를 관리했는지 설명이 부족합니다.", + "improvement": null + } + ] +} + +## 예시 C: 포부/계획 문장은 과거 성과 문장처럼 평가하지 않음 +관련 JD 요구사항: +- mainTask: 설비 점검 절차 개선 및 정비 이력 관리 +- qualification: 현장 설비 데이터 기록 경험 + +평가 대상 답변: +- questionId: 1 +- answer: 입사 후 설비 데이터를 꼼꼼히 관리하는 엔지니어가 되겠습니다. 또한 문제 발생 시 빠르게 대응하겠습니다. + +출력 중 문장/누락 관련 필드: +{ + "missingKeywords": [ + { + "keyword": "정비 이력 관리", + "source": "mainTask" + } + ], + "questionAnalyses": [ + { + "questionId": 1, + "sentence": "입사 후 설비 데이터를 꼼꼼히 관리하는 엔지니어가 되겠습니다.", + "status": "mentioned", + "reason": "포부의 실행 대상은 드러나지만 어떤 절차와 기준으로 설비 데이터를 관리할지 구체적인 방법이 부족합니다.", + "improvement": null + }, + { + "questionId": 1, + "sentence": "또한 문제 발생 시 빠르게 대응하겠습니다.", + "status": "mentioned", + "reason": "문제 대응 의지는 있으나 대응 단계와 직무 수행 방식이 추상적입니다.", + "improvement": null + } + ] +} + +## 예시 D: 확인된 조건과 직접 충돌하는 경우 +관련 JD 요구사항: +- qualification: 관련 업무 경력 3년 이하 + +평가 대상 답변: +- questionId: 1 +- answer: 저는 같은 직무에서 6년간 근무하며 팀 운영을 담당했습니다. + +출력 중 문장/누락 관련 필드: +{ + "missingKeywords": [], + "questionAnalyses": [ + { + "questionId": 1, + "sentence": "저는 같은 직무에서 6년간 근무하며 팀 운영을 담당했습니다.", + "status": "fabricated", + "reason": "답변의 6년 경력 주장이 JD의 경력 3년 이하 조건과 직접 충돌합니다.", + "improvement": null + } + ] +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java index 273a564..49c6441 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.service.AnalysisAiClient; +import com.jobdri.jobdri_api.domain.analysis.service.AnalysisAiClient.AnalysisAiCallResult; import com.jobdri.jobdri_api.domain.analysis.service.AnalysisPromptInput; import com.jobdri.jobdri_api.domain.analysis.service.JobCategoryEvaluationCriteriaProvider; import org.junit.jupiter.api.DisplayName; @@ -39,8 +40,8 @@ void runWritesSanitizedEvaluationResults() throws Exception { new JobCategoryEvaluationCriteriaProvider(objectMapper), objectMapper ); - when(analysisAiClient.analyzeForEvaluation(any(AnalysisPromptInput.class), any())) - .thenReturn(new AnalysisLlmResponse( + when(analysisAiClient.analyzeForEvaluationResult(any(AnalysisPromptInput.class), any())) + .thenReturn(result(new AnalysisLlmResponse( 80, 70, 60, @@ -75,14 +76,14 @@ void runWritesSanitizedEvaluationResults() throws Exception { "무시되어야 합니다." ) ) - )); + ))); Path input = tempDir.resolve("evaluation_cases.csv"); Path output = tempDir.resolve("evaluation_ai_results.csv"); Files.writeString( input, "caseId,jobCategoryMiddle,jobCategorySmall,mainTasks,qualifications,preferences,question,answer\n" - + "EV-01,AI·개발·데이터,백엔드,서버 개발,SQL,대용량 처리,경험을 쓰세요,데이터 처리 경험이 있습니다.\n", + + "EV-01,AI·개발·데이터,백엔드,테스트 자동화 경험,SQL 활용 경험,대용량 처리,경험을 쓰세요,데이터 처리 경험이 있습니다.\n", StandardCharsets.UTF_8 ); @@ -96,10 +97,155 @@ void runWritesSanitizedEvaluationResults() throws Exception { assertThat(csv).contains(",73,80,70,60,"); assertThat(row.get("aiMissingKeywordsJson")).contains("SQL 활용 경험"); assertThat(row.get("aiMissingKeywordsJson")).contains("테스트 자동화 경험"); + assertThat(row.get("aiMissingKeywordsJson")).doesNotContain("preference"); assertThat(row.get("aiMissingKeywordsJson")).doesNotContain("잘못된 출처"); assertThat(row.get("aiQuestionAnalysesJson")).doesNotContain("답변에 없는 문장"); assertThat(row.get("aiQuestionAnalysesJson")).doesNotContain("missing은 저장하지 않습니다."); - verify(analysisAiClient).analyzeForEvaluation(any(AnalysisPromptInput.class), any()); + verify(analysisAiClient).analyzeForEvaluationResult(any(AnalysisPromptInput.class), any()); + } + + @Test + @DisplayName("평가 결과도 운영과 동일하게 missingKeywords와 improvement를 후처리한다") + void runAppliesProductionSanitizationRules() throws Exception { + AnalysisAiClient analysisAiClient = mock(AnalysisAiClient.class); + ObjectMapper objectMapper = new ObjectMapper(); + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService( + analysisAiClient, + new JobCategoryEvaluationCriteriaProvider(objectMapper), + objectMapper + ); + when(analysisAiClient.analyzeForEvaluationResult(any(AnalysisPromptInput.class), any())) + .thenReturn(result(new AnalysisLlmResponse( + 80, + 70, + 60, + "운영 후처리 검증", + List.of( + new AnalysisLlmResponse.MissingKeywordItem("영어 공인성적", "qualification"), + new AnalysisLlmResponse.MissingKeywordItem("SQL 활용 경험", "qualification"), + new AnalysisLlmResponse.MissingKeywordItem("온라인 쇼핑몰 근무 경험자", "preference"), + new AnalysisLlmResponse.MissingKeywordItem("테스트 자동화 경험", "mainTask") + ), + List.of( + new AnalysisLlmResponse.QuestionAnalysisItem( + 1L, + "첫 번째 문장입니다.", + "mentioned", + "구체성이 부족합니다.", + "첫 번째 문장입니다." + ), + new AnalysisLlmResponse.QuestionAnalysisItem( + 1L, + "두 번째 문장입니다.", + "mentioned", + "결과가 부족합니다.", + "세 번째 문장입니다." + ), + new AnalysisLlmResponse.QuestionAnalysisItem( + 1L, + "세 번째 문장입니다.", + "proven", + "구체적인 성과 보완이 필요합니다.", + "세 번째 문장입니다." + ) + ) + ))); + + Path input = tempDir.resolve("evaluation_cases.csv"); + Path output = tempDir.resolve("evaluation_ai_results.csv"); + Files.writeString( + input, + "caseId,jobCategoryMiddle,jobCategorySmall,mainTasks,qualifications,preferences,question,answer\n" + + "EV-01,AI·개발·데이터,백엔드,테스트 자동화 경험,SQL 활용 경험,온라인 쇼핑몰 근무 경험자,경험을 쓰세요,첫 번째 문장입니다. 두 번째 문장입니다. 세 번째 문장입니다.\n", + StandardCharsets.UTF_8 + ); + + service.run(input, output); + + Map row = EvaluationCsvSupport.read(output).getFirst(); + assertThat(row.get("aiMissingKeywordsJson")) + .contains("SQL 활용 경험", "테스트 자동화 경험") + .doesNotContain("영어 공인성적") + .doesNotContain("온라인 쇼핑몰 근무 경험자"); + assertThat(row.get("aiQuestionAnalysesJson")) + .contains("\"improvement\":\"\"") + .doesNotContain("구체적인 성과 보완이 필요합니다."); + } + + @Test + @DisplayName("평가 결과도 운영과 동일하게 PROVEN/FABRICATED 상태 필터를 적용하고 raw/final 비교 정보를 남긴다") + void runKeepsRawAndAppliesFinalStatusFilter() throws Exception { + AnalysisAiClient analysisAiClient = mock(AnalysisAiClient.class); + ObjectMapper objectMapper = new ObjectMapper(); + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService( + analysisAiClient, + new JobCategoryEvaluationCriteriaProvider(objectMapper), + objectMapper + ); + when(analysisAiClient.analyzeForEvaluationResult(any(AnalysisPromptInput.class), any())) + .thenReturn(result(new AnalysisLlmResponse( + 80, + 70, + 60, + "raw/final 검증", + List.of(new AnalysisLlmResponse.HighlightItem( + "구체적인 강점입니다.", + "첫 번째 문장입니다." + )), + List.of(), + List.of(), + List.of( + new AnalysisLlmResponse.QuestionAnalysisItem( + 1L, + "첫 번째 문장입니다.", + "proven", + "근거가 충분합니다.", + null + ), + new AnalysisLlmResponse.QuestionAnalysisItem( + 1L, + "두 번째 문장입니다.", + "mentioned", + "실행 방법이 부족합니다.", + null + ), + new AnalysisLlmResponse.QuestionAnalysisItem( + 1L, + "세 번째 문장입니다.", + "fabricated", + "답변 내부의 명시적 사실과 직접 충돌합니다.", + null + ), + new AnalysisLlmResponse.QuestionAnalysisItem( + 1L, + "네 번째 문장입니다.", + "fabricated", + "", + null + ) + ) + ))); + + Path input = tempDir.resolve("evaluation_cases.csv"); + Path output = tempDir.resolve("evaluation_ai_results.csv"); + Files.writeString( + input, + "caseId,jobCategoryMiddle,jobCategorySmall,mainTasks,qualifications,preferences,question,answer\n" + + "EV-05,AI·개발·데이터,백엔드,API 개발,Spring Boot 경험,,경험을 쓰세요,첫 번째 문장입니다. 두 번째 문장입니다. 세 번째 문장입니다. 네 번째 문장입니다.\n", + StandardCharsets.UTF_8 + ); + + service.run(input, output); + + Map row = EvaluationCsvSupport.read(output).getFirst(); + assertThat(row.get("rawLlmResponseJson")) + .contains("\"status\":\"proven\"") + .contains("\"status\":\"fabricated\""); + assertThat(row.get("aiQuestionAnalysesJson")) + .contains("\"status\":\"mentioned\"") + .contains("\"status\":\"fabricated\"") + .doesNotContain("\"status\":\"proven\"") + .doesNotContain("네 번째 문장입니다."); } @Test @@ -112,8 +258,8 @@ void runOmitsCriteriaWhenMiddleNameNotFound() throws Exception { new JobCategoryEvaluationCriteriaProvider(objectMapper), objectMapper ); - when(analysisAiClient.analyzeForEvaluation(any(AnalysisPromptInput.class), isNull())) - .thenReturn(new AnalysisLlmResponse(70, 70, 70, "피드백", List.of(), List.of())); + when(analysisAiClient.analyzeForEvaluationResult(any(AnalysisPromptInput.class), isNull())) + .thenReturn(result(new AnalysisLlmResponse(70, 70, 70, "피드백", List.of(), List.of()))); Path input = tempDir.resolve("evaluation_cases.csv"); Path output = tempDir.resolve("evaluation_ai_results.csv"); @@ -126,7 +272,7 @@ void runOmitsCriteriaWhenMiddleNameNotFound() throws Exception { service.run(input, output); - verify(analysisAiClient).analyzeForEvaluation(any(AnalysisPromptInput.class), isNull()); + verify(analysisAiClient).analyzeForEvaluationResult(any(AnalysisPromptInput.class), isNull()); } @Test @@ -165,7 +311,7 @@ void runWritesFailureRowWhenAnalyzeFails() throws Exception { new JobCategoryEvaluationCriteriaProvider(objectMapper), objectMapper ); - when(analysisAiClient.analyzeForEvaluation(any(AnalysisPromptInput.class), any())) + when(analysisAiClient.analyzeForEvaluationResult(any(AnalysisPromptInput.class), any())) .thenThrow(new RuntimeException("rate limit exceeded")); Path input = tempDir.resolve("evaluation_cases.csv"); @@ -188,4 +334,8 @@ void runWritesFailureRowWhenAnalyzeFails() throws Exception { assertThat(row.get("aiQuestionAnalysesJson")).isEqualTo("[]"); assertThat(row.get("errorMessage")).contains("rate limit exceeded"); } + + private AnalysisAiCallResult result(AnalysisLlmResponse response) { + return new AnalysisAiCallResult(response, null, null, false, 0, 1); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupportTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupportTest.java index c40c5e0..545f40c 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupportTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupportTest.java @@ -54,6 +54,14 @@ void writeEscapedCsv() throws Exception { "[]", "{\"feedback\":\"문장\"}", "", + "", + null, + null, + null, + null, + null, + "", + "", "2026-07-13T10:00:00" ))); diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClientTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClientTest.java index 3e5208d..a090c1c 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClientTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClientTest.java @@ -1,6 +1,8 @@ package com.jobdri.jobdri_api.domain.analysis.service; import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisCandidateResponse; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.company.entity.Company; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService; @@ -22,7 +24,9 @@ class AnalysisAiClientTest { private final AnalysisAiClient analysisAiClient = new AnalysisAiClient( mock(OpenAIClient.class), mock(CorpusRetrievalService.class), - mock(LlmConcurrencyLimiter.class) + mock(LlmConcurrencyLimiter.class), + new FewShotPromptProvider(), + new ObjectMapper() ); @Test @@ -68,6 +72,285 @@ void buildPromptOmitsJobCategoryCriteriaSectionWhenCriteriaMissing() { assertThat(prompt).doesNotContain("이 직무별 기준은 실제 JD를 대체하지 않는다."); } + @Test + @DisplayName("프롬프트는 포부 문장, JD 우선순위, missingKeywords, improvement 안전 규칙을 포함한다") + void buildPromptIncludesReviewPolicyRules() { + String prompt = analysisAiClient.buildPrompt( + mockJobPosting(), + List.of(mockQuestion()), + new RetrievalContext(List.of(), List.of()), + null + ); + + assertThat(prompt) + .contains("[출력 규칙]") + .contains("Structured Output 스키마에 맞는 JSON object만 반환한다.") + .contains("jobFit: 실제 JD와 실제 답변 전체를 기준으로 독립 산정한 0~100 정수") + .contains("impact: 실제 JD와 실제 답변 전체를 기준으로 독립 산정한 0~100 정수") + .contains("completeness: 실제 JD와 실제 답변 전체를 기준으로 독립 산정한 0~100 정수") + .contains("questionAnalyses: 실제 첨삭이 필요한 문장에 따라 0~3개") + .contains("improvement: 안전한 개선문을 만들 수 없으면 null") + .contains("[문장 유형 구분]") + .contains("경험/성과") + .contains("포부/계획") + .contains("지원동기") + .contains("역량/자격") + .contains("포부/계획 문장에는 과거 성과 수치, 과거 결과, Before-After를 요구하지 않는다.") + .contains("포부/계획은 실행 대상, 실행 방법, 단계, 직무 연결성이 구체적인지 중심으로 판단한다.") + .contains("판단 우선순위는 mainTask > qualification >>> preference다.") + .contains("Few-shot 예시는 문장 상태 판정과 출력 형식 참고용이며 점수 예시가 아니다.") + .contains("점수는 실제 JD와 실제 답변 전체를 기준으로 독립적으로 산정한다.") + .contains("서로 다른 입력에 동일한 점수를 기계적으로 반복하지 않는다.") + .contains("preference만 누락된 경우 questionAnalyses의 첨삭 대상으로 선택하지 않는다.") + .contains("preference는 reason과 점수에 보조적으로만 반영한다.") + .contains("실제 입력 JD의 주요 업무, 자격 요건 원문에 존재하지만 자소서에 충분히 드러나지 않은 경험형 역량만 추출한다.") + .contains("유사 JD 검색 결과, 직무별 보조 평가 기준, few-shot 예시, 모델의 일반 지식에서 키워드를 생성하지 않는다.") + .contains("자격증, 면허, 어학성적, 학위, 전공, 경력 연차, 근무 가능 여부") + .contains("좋은 문장은 questionAnalyses에 넣지 않고 keyStrengths로 반환한다.") + .contains("개선이 필요하지 않으면 improvement는 null로 반환한다.") + .contains("원문 정보만으로 개선문을 만들 수 없으면 improvement는 null로 반환한다.") + .contains("원문과 실질적으로 동일한 문장을 improvement로 반환하지 않는다.") + .contains("답변의 다른 문장을 그대로 복사해 improvement로 반환하지 않는다.") + .contains("메타 조언을 improvement로 반환하지 않는다.") + .contains("JD 요구사항을 지원자가 실제 수행한 경험처럼 생성하지 않는다."); + + assertThat(prompt) + .doesNotContain("[출력 형식]") + .doesNotContain("\"jobFit\"") + .doesNotContain("\"impact\"") + .doesNotContain("\"completeness\""); + } + + @Test + @DisplayName("프롬프트는 v4 few-shot 편향 완화 규칙과 JD 영역 분리를 포함한다") + void buildPromptIncludesFewShotV4Rules() { + String prompt = analysisAiClient.buildPrompt( + mockJobPosting(), + List.of(mockQuestion()), + new RetrievalContext(List.of(), List.of()), + null + ); + + assertThat(prompt) + .contains("[Few-shot 예시]") + .contains("questionAnalyses\": []") + .contains("status\": \"mentioned\"") + .contains("status\": \"fabricated\"") + .contains("충분히 구체적이므로 questionAnalyses에는 포함하지 않는다.") + .contains("예시의 분석 개수, 상태 비율, 문장 표현, 점수를 실제 입력에 복사하지 않는다.") + .contains("questionAnalyses는 실제 첨삭이 필요한 문장에 따라 0~3개가 될 수 있다.") + .contains("항상 1개를 반환할 필요가 없다.") + .contains("Few-shot 출력에는 전체 점수를 포함하지 않는다.") + .contains("포부/계획 문장의 reason에는 \"성과 수치가 부족\"") + .contains("preference가 없다는 이유만으로 mentioned를 생성하지 않는다.") + .contains("questionAnalyses의 허용 status는 mentioned, fabricated뿐이다.") + .contains("PROVEN은 questionAnalyses에 반환하지 않는다.") + .contains("MISSING은 sentence가 없으므로 questionAnalyses에 넣지 않고 missingKeywords로만 반환한다.") + .contains("실제로 독립적인 문제 문장이 여러 개라면 대표 1개만 선택하지 말고 최대 3개까지 반환한다.") + .contains("내부 판단 과정이나 chain-of-thought를 응답에 출력하지 않는다.") + .contains("원문이 과거 경험이면 개선문도 과거 경험을 유지한다.") + .contains("원문이 포부이면 개선문도 포부를 유지한다.") + .contains("") + .contains("") + .contains(""); + + assertThat(prompt.indexOf("[Few-shot 예시]")) + .isLessThan(prompt.indexOf("[채용 공고]")); + assertThat(prompt.indexOf("[Few-shot 예시]")) + .isLessThan(prompt.indexOf("[자소서 문항과 답변]")); + } + + @Test + @DisplayName("1차 후보 프롬프트는 점수와 improvement 없이 후보 판정 규칙만 포함한다") + void buildCandidatePromptIncludesCandidateRulesOnly() { + String prompt = analysisAiClient.buildCandidatePrompt( + promptInput(), + new RetrievalContext(List.of(), List.of()), + null + ); + + assertThat(prompt) + .contains("[1차 출력 필드]") + .contains("strengthCandidates") + .contains("analysisCandidates") + .contains("missingKeywordCandidates") + .contains("sentenceType: EXPERIENCE, PLAN, MOTIVATION, COMPETENCY") + .contains("preference만 근거인 후보는 제외한다.") + .contains("충분한 문장은 strengthCandidates로 분류한다.") + .contains("보완이 필요한 문장만 analysisCandidates로 분류한다.") + .contains("MISSING은 analysisCandidates에 넣지 않고 missingKeywordCandidates로만 분리한다.") + .contains("독립적인 문제가 있으면 최대 3개까지 반환한다.") + .contains("내부 판단 과정이나 chain-of-thought를 출력하지 않는다.") + .contains("점수 필드, feedback, improvement, keyWeaknesses는 1차 출력에 존재하지 않는다."); + } + + @Test + @DisplayName("후보 sanitizer는 1차 후보의 원문, source, status, 개수를 검증한다") + void sanitizeCandidatesFiltersInvalidCandidateItems() { + AnalysisPromptInput promptInput = promptInput(); + AnalysisCandidateResponse sanitized = analysisAiClient.sanitizeCandidates( + promptInput, + new AnalysisCandidateResponse( + List.of( + new AnalysisCandidateResponse.StrengthCandidate( + 1L, + "Spring Boot API를 개발했습니다.", + "MAIN_TASK", + "API 개발", + "직접 관련됩니다." + ), + new AnalysisCandidateResponse.StrengthCandidate( + 1L, + "답변에 없는 강점", + "MAIN_TASK", + "API 개발", + "원문에 없습니다." + ) + ), + List.of( + new AnalysisCandidateResponse.AnalysisCandidate( + 99L, + "Spring Boot API를 개발했습니다.", + "EXPERIENCE", + "MAIN_TASK", + "API 개발", + "MENTIONED", + "LACK_OF_RESULT", + "잘못된 questionId입니다." + ), + new AnalysisCandidateResponse.AnalysisCandidate( + 1L, + "답변에 없는 문장", + "EXPERIENCE", + "MAIN_TASK", + "API 개발", + "MENTIONED", + "LACK_OF_RESULT", + "원문에 없습니다." + ), + new AnalysisCandidateResponse.AnalysisCandidate( + 1L, + "Spring Boot API를 개발했습니다.", + "EXPERIENCE", + "PREFERENCE", + "대용량 트래픽", + "MENTIONED", + "LACK_OF_RESULT", + "preference-only입니다." + ), + new AnalysisCandidateResponse.AnalysisCandidate( + 1L, + "Spring Boot API를 개발했습니다.", + "EXPERIENCE", + "MAIN_TASK", + "API 개발", + "PROVEN", + "LACK_OF_RESULT", + "허용하지 않은 status입니다." + ), + new AnalysisCandidateResponse.AnalysisCandidate( + 1L, + "장애 대응 경험이 있습니다.", + "EXPERIENCE", + "QUALIFICATION", + "장애 대응 경험", + "FABRICATED", + "DIRECT_CONTRADICTION", + "" + ), + new AnalysisCandidateResponse.AnalysisCandidate( + 1L, + "장애 대응 경험이 있습니다.", + "EXPERIENCE", + "QUALIFICATION", + "장애 대응 경험", + "FABRICATED", + "DIRECT_CONTRADICTION", + "답변 내부의 명시적 사실과 직접 충돌합니다." + ), + new AnalysisCandidateResponse.AnalysisCandidate( + 1L, + "Spring Boot API를 개발했습니다.", + "EXPERIENCE", + "MAIN_TASK", + "API 개발", + "MENTIONED", + "LACK_OF_RESULT", + "결과가 부족합니다." + ) + ), + List.of( + new AnalysisCandidateResponse.MissingKeywordCandidate( + "SQL 활용 경험", + "QUALIFICATION", + "Spring Boot 경험" + ), + new AnalysisCandidateResponse.MissingKeywordCandidate( + "영어 공인성적", + "QUALIFICATION", + "영어 공인성적" + ), + new AnalysisCandidateResponse.MissingKeywordCandidate( + "API 개발 경험", + "MAIN_TASK", + "API 개발" + ) + ) + ) + ); + + assertThat(sanitized.strengthCandidates()).hasSize(1); + assertThat(sanitized.analysisCandidates()).extracting("sentence") + .containsExactly("장애 대응 경험이 있습니다.", "Spring Boot API를 개발했습니다."); + assertThat(sanitized.missingKeywordCandidates()).extracting("keyword") + .containsExactly("API 개발 경험"); + } + + @Test + @DisplayName("2차 프롬프트는 검증된 후보만 입력하고 새 questionAnalysis 추가를 금지한다") + void buildFinalPromptUsesSanitizedCandidatesOnly() { + AnalysisCandidateResponse candidates = new AnalysisCandidateResponse( + List.of(new AnalysisCandidateResponse.StrengthCandidate( + 1L, + "Spring Boot API를 개발했습니다.", + "MAIN_TASK", + "API 개발", + "직접 근거입니다." + )), + List.of(new AnalysisCandidateResponse.AnalysisCandidate( + 1L, + "장애 대응 경험이 있습니다.", + "EXPERIENCE", + "QUALIFICATION", + "장애 대응 경험", + "MENTIONED", + "LACK_OF_RESULT", + "결과가 부족합니다." + )), + List.of() + ); + + String prompt = analysisAiClient.buildFinalPrompt( + promptInput(), + new RetrievalContext(List.of(), List.of()), + null, + candidates + ); + + assertThat(prompt) + .contains("[검증된 1차 후보]") + .contains("1차에 없는 새로운 questionAnalysis를 임의로 추가하지 않는다.") + .contains("첨삭 행위를 설명하는 메타 문장을 금지한다.") + .contains("원문에 없는 수치, 도구, 경험, 직무 수행, 계획 추가를 금지한다.") + .contains("과거 문장을 미래 포부로 변경하지 않는다.") + .contains("미래 포부를 과거 경험으로 변경하지 않는다.") + .contains("원문 사실만으로 안전한 개선이 불가능하면 null을 반환한다.") + .contains("1차 후보 개수와 점수를 연결하지 않는다.") + .contains("점수는 JD 전체와 답변 전체를 기준으로 독립적으로 산정한다.") + .contains("장애 대응 경험이 있습니다.") + .doesNotContain("답변에 없는 문장"); + } + private JobPosting mockJobPosting() { JobPosting jobPosting = mock(JobPosting.class, org.mockito.Answers.RETURNS_DEEP_STUBS); Company company = mock(Company.class); @@ -87,4 +370,19 @@ private Question mockQuestion() { when(question.getAnswer()).thenReturn("Spring Boot API를 개발했습니다."); return question; } + + private AnalysisPromptInput promptInput() { + return new AnalysisPromptInput( + "잡드리", + "백엔드 개발", + "API 개발", + "Spring Boot 경험 및 장애 대응 경험", + "대용량 트래픽 경험", + List.of(new AnalysisPromptInput.QuestionAnswer( + 1L, + "직무 경험을 작성해주세요.", + "Spring Boot API를 개발했습니다. 장애 대응 경험이 있습니다." + )) + ); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisSanitizationRulesTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisSanitizationRulesTest.java new file mode 100644 index 0000000..a6029bb --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisSanitizationRulesTest.java @@ -0,0 +1,92 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AnalysisSanitizationRulesTest { + + @Test + @DisplayName("PROVEN 모순 reason은 결핍 구문 기준으로만 판단한다") + void detectsContradictoryProvenReasonByDefectPhrase() { + assertThat(AnalysisSanitizationRules.isContradictoryProvenReason("필요한 역량을 보여줍니다.")) + .isFalse(); + assertThat(AnalysisSanitizationRules.isContradictoryProvenReason("성과 수치가 부족합니다.")) + .isTrue(); + assertThat(AnalysisSanitizationRules.isContradictoryProvenReason("추가 보완이 필요합니다.")) + .isTrue(); + } + + @Test + @DisplayName("unsafe improvement를 제거한다") + void removesUnsafeImprovement() { + String answer = "API 응답 속도를 개선했습니다. 장애 로그를 분석했습니다."; + + assertThat(AnalysisSanitizationRules.normalizeImprovement( + "API 응답 속도를 개선했습니다.", + answer, + "API 응답 속도를 개선했습니다.", + false + )).isEmpty(); + assertThat(AnalysisSanitizationRules.normalizeImprovement( + "API 응답 속도를 개선했습니다.", + answer, + "장애 로그를 분석했습니다.", + false + )).isEmpty(); + assertThat(AnalysisSanitizationRules.normalizeImprovement( + "API 응답 속도를 개선했습니다.", + answer, + "성과를 강조했습니다.", + false + )).isEmpty(); + assertThat(AnalysisSanitizationRules.normalizeImprovement( + "API 응답 속도를 개선했습니다.", + answer, + "입사 후 API 운영 안정화에 기여하겠습니다.", + false + )).isEmpty(); + } + + @Test + @DisplayName("정형 자격요건은 제외하고 경험형 키워드는 유지한다") + void filtersStructuredQualificationKeywordsConservatively() { + assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("영어 공인성적")).isTrue(); + assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("영어 커뮤니케이션 경험")).isFalse(); + assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("반도체 관련 전공")).isTrue(); + assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("반도체 공정 분석 경험")).isFalse(); + assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("경력 3년 이상")).isTrue(); + assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("장애 대응 경험")).isFalse(); + } + + @Test + @DisplayName("missingKeyword는 source JD 영역의 핵심 토큰 근거로 검증한다") + void validatesMissingKeywordByCoreTokens() { + assertThat(AnalysisSanitizationRules.isValidMissingKeyword( + "재고 관리 및 분석 경험", + MissingKeywordSource.MAIN_TASK, + "재고 현황을 분석하고 적정 재고를 관리", + "" + )).isTrue(); + assertThat(AnalysisSanitizationRules.isValidMissingKeyword( + "SQL 활용 경험", + MissingKeywordSource.QUALIFICATION, + "재고 현황을 분석하고 적정 재고를 관리", + "Spring Boot API 개발 경험" + )).isFalse(); + assertThat(AnalysisSanitizationRules.isValidMissingKeyword( + "영어 커뮤니케이션 경험", + MissingKeywordSource.QUALIFICATION, + "", + "영어 커뮤니케이션 경험" + )).isTrue(); + assertThat(AnalysisSanitizationRules.isValidMissingKeyword( + "온라인 쇼핑몰 근무 경험자", + MissingKeywordSource.PREFERENCE, + "고객 데이터 분석", + "고객 데이터 분석" + )).isFalse(); + } +} 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 aead43b..c4bfdb6 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 @@ -195,7 +195,13 @@ void analyzeThrowsAndRefundsWhenDimensionScoreOutOfRange() { @DisplayName("missingKeywords는 검증 후 최대 3개까지 응답에 포함한다") void analyzeReturnsValidatedMissingKeywords() { User user = saveUser("analysis-missing-keywords@example.com"); - MockApply mockApply = saveMockApply(user); + 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, @@ -257,11 +263,11 @@ void analyzeReturnsValidatedMissingKeywords() { .containsExactly("SQL 활용 경험 보강이 필요해요", "대용량 트래픽 경험을 더 보여주세요", "테스트 자동화 경험을 보강하세요"); assertThat(response.keyWeaknesses()).extracting("quote") .containsExactly("SQL 활용 경험", "대용량 트래픽 처리 경험", "테스트 자동화 경험"); - assertThat(response.missingKeywords()).hasSize(3); + assertThat(response.missingKeywords()).hasSize(2); assertThat(response.missingKeywords()).extracting("keyword") - .containsExactly("SQL 활용 경험", "대용량 트래픽 처리 경험", "테스트 자동화 경험"); + .containsExactly("SQL 활용 경험", "테스트 자동화 경험"); assertThat(response.missingKeywords()).extracting(keyword -> keyword.source().value()) - .containsExactly("qualification", "preference", "mainTask"); + .containsExactly("qualification", "mainTask"); Analysis analysis = analysisRepository.findByMockApplyId(mockApply.getId()).orElseThrow(); assertThat(analysis.getKeyStrengthsJson()) @@ -278,12 +284,283 @@ void analyzeReturnsValidatedMissingKeywords() { .doesNotContain("네 번째 약점"); assertThat(analysis.getMissingKeywordsJson()) .contains("\"keyword\":\"SQL 활용 경험\"", "\"source\":\"qualification\"") - .contains("\"keyword\":\"대용량 트래픽 처리 경험\"", "\"source\":\"preference\"") .contains("\"keyword\":\"테스트 자동화 경험\"", "\"source\":\"mainTask\"") + .doesNotContain("대용량 트래픽 처리 경험") .doesNotContain("잘못된 출처") .doesNotContain("이 키워드는 너무 길어서"); } + @Test + @DisplayName("정형 자격요건과 JD에 없는 missingKeywords를 제외한다") + void analyzeSkipsStructuredAndNonJdMissingKeywords() { + User user = saveUser("analysis-missing-keywords-strict@example.com"); + JobPosting jobPosting = saveJobPosting( + user, + "장비 설치 및 유지보수 경험", + "데이터 분석 관련 Tool 활용 경험", + "온라인 쇼핑몰 근무 경험자" + ); + MockApply mockApply = mockApplyRepository.save(MockApply.create(user, jobPosting, ApplyType.ACTUAL)); + saveQuestion(mockApply, "지원 직무 경험", "장비 운용 경험이 있습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 80, + 70, + 60, + "엄격한 누락 키워드 검증입니다.", + List.of( + new AnalysisLlmResponse.MissingKeywordItem("영어 공인성적", "qualification"), + new AnalysisLlmResponse.MissingKeywordItem("반도체 관련 전공", "qualification"), + new AnalysisLlmResponse.MissingKeywordItem("SQL 활용 경험", "qualification"), + new AnalysisLlmResponse.MissingKeywordItem("온라인 쇼핑몰 근무 경험자", "preference"), + new AnalysisLlmResponse.MissingKeywordItem("장비 설치 및 유지보수 경험", "mainTask"), + new AnalysisLlmResponse.MissingKeywordItem("데이터 분석 관련 Tool 활용 경험", "qualification") + ), + List.of() + )); + + AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); + + assertThat(response.missingKeywords()).extracting("keyword") + .containsExactly("장비 설치 및 유지보수 경험", "데이터 분석 관련 Tool 활용 경험"); + } + + @Test + @DisplayName("improvement가 원문 동일, 다른 원문 문장 복사, 메타 조언이면 제거한다") + void analyzeRemovesUnsafeImprovements() { + User user = saveUser("analysis-unsafe-improvement@example.com"); + MockApply mockApply = saveMockApply(user); + Question question = saveQuestion( + mockApply, + "문제 해결 경험", + "첫 번째 문장입니다. 두 번째 문장입니다. 세 번째 문장입니다." + ); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 70, + 70, + 70, + "improvement 검증입니다.", + List.of( + new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "첫 번째 문장입니다.", + "mentioned", + "구체성이 부족합니다.", + "첫 번째 문장입니다." + ), + new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "두 번째 문장입니다.", + "mentioned", + "결과가 부족합니다.", + "세 번째 문장입니다." + ), + new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "세 번째 문장입니다.", + "mentioned", + "직무 연결이 부족합니다.", + "직무 연결성을 구체적으로 설명했습니다." + ) + ) + )); + + AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); + + assertThat(response.questions().get(0).analyses()).hasSize(3); + assertThat(response.questions().get(0).analyses()).extracting("improvement") + .containsExactly("", "", ""); + } + + @Test + @DisplayName("PROVEN인데 reason이 부족이나 보완 필요를 말하면 모순 결과로 제외한다") + void analyzeSkipsProvenWithContradictoryReason() { + User user = saveUser("analysis-proven-contradictory-reason@example.com"); + MockApply mockApply = saveMockApply(user); + Question question = saveQuestion(mockApply, "성과 경험", "평균 거칠기(Ra)를 1.5nm 감소시켰습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 80, + 80, + 80, + "PROVEN 모순 검증입니다.", + List.of( + new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "평균 거칠기(Ra)를 1.5nm 감소시켰습니다.", + "proven", + "구체적인 성과 보완이 필요합니다.", + "평균 거칠기(Ra)를 1.5nm 감소시킨 성과를 강조했습니다." + ) + ) + )); + + AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); + + assertThat(response.questions().get(0).analyses()).isEmpty(); + } + + @Test + @DisplayName("PROVEN questionAnalysis는 최종 결과에서 제외한다") + void analyzeRemovesImprovementForProvenStatus() { + User user = saveUser("analysis-proven-improvement-empty@example.com"); + MockApply mockApply = saveMockApply(user); + Question question = saveQuestion(mockApply, "성과 경험", "평균 거칠기(Ra)를 1.5nm 감소시켰습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 80, + 80, + 80, + "PROVEN improvement 검증입니다.", + List.of(new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "평균 거칠기(Ra)를 1.5nm 감소시켰습니다.", + "proven", + "수치와 결과가 구체적으로 제시되어 있습니다.", + "평균 거칠기(Ra)를 1.5nm 감소시킨 성과를 강조했습니다." + )) + )); + + AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); + + assertThat(response.questions().get(0).analyses()).isEmpty(); + } + + @Test + @DisplayName("PROVEN reason이 긍정 문맥이어도 questionAnalyses에서는 제외한다") + void analyzeKeepsValidProvenReasonWithPositiveNeedContext() { + User user = saveUser("analysis-valid-proven-need-context@example.com"); + MockApply mockApply = saveMockApply(user); + Question question = saveQuestion(mockApply, "성과 경험", "Spring Boot API를 개발해 장애 대응 시간을 단축했습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 80, + 80, + 80, + "PROVEN 긍정 문맥 검증입니다.", + List.of(new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "Spring Boot API를 개발해 장애 대응 시간을 단축했습니다.", + "proven", + "직무에 필요한 역량을 보여줍니다.", + "Spring Boot API 개발 경험을 더 구체적으로 작성했습니다." + )) + )); + + AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); + + assertThat(response.questions().get(0).analyses()).isEmpty(); + } + + @Test + @DisplayName("questionAnalyses 빈 배열도 유효한 분석 결과로 허용한다") + void analyzeAllowsEmptyQuestionAnalyses() { + User user = saveUser("analysis-empty-question-analyses@example.com"); + MockApply mockApply = saveMockApply(user); + saveQuestion(mockApply, "성과 경험", "Spring Boot API를 개발해 장애 대응 시간을 단축했습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 80, + 80, + 80, + "보완 대상이 없습니다.", + List.of() + )); + + AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); + + assertThat(response.questions().get(0).analyses()).isEmpty(); + } + + @Test + @DisplayName("유효한 문장이 2개면 두 분석 모두 저장한다") + void analyzeKeepsTwoValidQuestionAnalyses() { + User user = saveUser("analysis-two-valid-items@example.com"); + MockApply mockApply = saveMockApply(user); + Question question = saveQuestion(mockApply, "성과 경험", "첫 번째 문장입니다. 두 번째 문장입니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 80, + 80, + 80, + "두 문장 검증입니다.", + List.of( + new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "첫 번째 문장입니다.", + "mentioned", + "실행 방법이 부족합니다.", + null + ), + new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "두 번째 문장입니다.", + "fabricated", + "확인된 사실과 직접 충돌합니다.", + null + ) + ) + )); + + AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); + + assertThat(response.questions().get(0).analyses()).hasSize(2); + assertThat(response.questions().get(0).analyses()) + .extracting("status") + .containsExactly("mentioned", "fabricated"); + } + + @Test + @DisplayName("reason이 비어 있는 FABRICATED는 최종 결과에서 제외한다") + void analyzeSkipsFabricatedWithoutReason() { + User user = saveUser("analysis-fabricated-empty-reason@example.com"); + MockApply mockApply = saveMockApply(user); + Question question = saveQuestion(mockApply, "성과 경험", "답변 내부에서 서로 다른 경력을 주장했습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 80, + 80, + 80, + "FABRICATED reason 검증입니다.", + List.of(new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "답변 내부에서 서로 다른 경력을 주장했습니다.", + "fabricated", + "", + null + )) + )); + + AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); + + assertThat(response.questions().get(0).analyses()).isEmpty(); + } + + @Test + @DisplayName("keyStrengths quote와 동일한 questionAnalysis sentence는 제외한다") + void analyzeSkipsQuestionAnalysisDuplicatedWithKeyStrength() { + User user = saveUser("analysis-strength-analysis-duplicate@example.com"); + MockApply mockApply = saveMockApply(user); + Question question = saveQuestion(mockApply, "성과 경험", "API 응답 시간을 300ms 단축했습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 80, + 80, + 80, + "강점과 분석 중복 검증입니다.", + List.of(new AnalysisLlmResponse.HighlightItem( + "성능 개선 근거가 구체적입니다.", + "API 응답 시간을 300ms 단축했습니다." + )), + List.of(), + List.of(), + List.of(new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "API 응답 시간을 300ms 단축했습니다.", + "mentioned", + "구체성이 부족합니다.", + null + )) + )); + + AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); + + assertThat(response.keyStrengths()).hasSize(1); + assertThat(response.questions().get(0).analyses()).isEmpty(); + } + @Test @DisplayName("missingKeywords가 null이면 빈 배열로 응답한다") void analyzeReturnsEmptyMissingKeywordsWhenLlmMissingKeywordsIsNull() { @@ -416,7 +693,7 @@ void analyzeMapsLegacyWorkerStatuses() { question.getId(), "검증되지 않은 성과를 주장했습니다.", "RISK", - "과장 위험이 있습니다.", + "답변 내부의 명시적 사실과 직접 충돌합니다.", "검증 가능한 표현으로 낮추세요." )) )); @@ -425,7 +702,7 @@ void analyzeMapsLegacyWorkerStatuses() { assertThat(response.questions().get(0).analyses()) .extracting("status") - .containsExactly("proven", "mentioned", "fabricated"); + .containsExactly("mentioned", "fabricated"); } @Test @@ -799,7 +1076,7 @@ void analyzeReplacesExistingAnalysis() { question.getId(), "API 응답 속도를 개선했습니다.", "proven", - "성과 기준이 더 필요합니다.", + "성과가 구체적으로 제시되어 있습니다.", "API 응답 속도를 300ms 단축했습니다." )) )); @@ -810,9 +1087,9 @@ void analyzeReplacesExistingAnalysis() { assertThat(second.analysisId()).isNotEqualTo(first.analysisId()); assertThat(second.score()).isEqualTo(90); assertThat(second.feedback()).isEqualTo("두 번째 분석"); - assertThat(second.questions().get(0).analyses().get(0).status()).isEqualTo("proven"); + assertThat(second.questions().get(0).analyses()).isEmpty(); assertThat(analysisRepository.findByMockApplyId(mockApply.getId()).orElseThrow().getScore()).isEqualTo(90); - assertThat(questionAnalysisRepository.findAllByAnalysisId(second.analysisId())).hasSize(1); + assertThat(questionAnalysisRepository.findAllByAnalysisId(second.analysisId())).isEmpty(); assertThat(questionAnalysisRepository.findAllByAnalysisId(first.analysisId())).isEmpty(); } @@ -864,7 +1141,7 @@ void getAnalysisReturnsPersistedMissingKeywordsWithoutRecalculation() { "저장된 분석입니다.", List.of(new AnalysisLlmResponse.HighlightItem("저장된 강점", "Spring Boot API를 개발했습니다.")), List.of(new AnalysisLlmResponse.HighlightItem("저장된 약점", "SQL 활용 경험")), - List.of(new AnalysisLlmResponse.MissingKeywordItem("LLM 저장 키워드", "qualification")), + List.of(new AnalysisLlmResponse.MissingKeywordItem("SQL 활용 경험", "qualification")), List.of() )); AnalysisResponse saved = analysisService.analyze(user, mockApply.getId()); @@ -876,19 +1153,19 @@ void getAnalysisReturnsPersistedMissingKeywordsWithoutRecalculation() { assertThat(persisted.getKeyWeaknessesJson()) .contains("\"title\":\"저장된 약점\"", "\"quote\":\"SQL 활용 경험\""); assertThat(persisted.getMissingKeywordsJson()) - .contains("\"keyword\":\"LLM 저장 키워드\"", "\"source\":\"qualification\""); + .contains("\"keyword\":\"SQL 활용 경험\"", "\"source\":\"qualification\""); entityManager.clear(); AnalysisResponse response = analysisService.getAnalysis(user, mockApply.getId()); assertThat(saved.missingKeywords()).extracting("keyword") - .containsExactly("LLM 저장 키워드"); + .containsExactly("SQL 활용 경험"); assertThat(response.keyStrengths()).extracting("title") .containsExactly("저장된 강점"); assertThat(response.keyWeaknesses()).extracting("title") .containsExactly("저장된 약점"); assertThat(response.missingKeywords()).extracting("keyword") - .containsExactly("LLM 저장 키워드"); + .containsExactly("SQL 활용 경험"); assertThat(response.missingKeywords()).extracting(keyword -> keyword.source().value()) .containsExactly("qualification"); } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/FewShotPromptProviderTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/FewShotPromptProviderTest.java new file mode 100644 index 0000000..6399235 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/FewShotPromptProviderTest.java @@ -0,0 +1,36 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +class FewShotPromptProviderTest { + private static final Pattern EXAMPLE_HEADER_PATTERN = Pattern.compile("(?m)^## 예시 "); + + @Test + @DisplayName("few-shot 리소스를 UTF-8로 읽고 v4 예시 구성을 제공한다") + void readsFewShotPromptResource() { + FewShotPromptProvider provider = new FewShotPromptProvider(); + + String prompt = provider.getPrompt(); + + assertThat(prompt).isNotBlank(); + assertThat(EXAMPLE_HEADER_PATTERN.matcher(prompt).results().count()).isEqualTo(4); + assertThat(prompt) + .contains("\"keyStrengths\"") + .contains("\"status\": \"mentioned\"") + .contains("\"status\": \"fabricated\"") + .contains("\"missingKeywords\"") + .contains("\"questionAnalyses\": []") + .contains("예시의 분석 개수, 상태 비율, 문장 표현, 점수를 실제 입력에 복사하지 않는다."); + assertThat(prompt).doesNotContain("\"jobFit\""); + assertThat(prompt).doesNotContain("\"impact\""); + assertThat(prompt).doesNotContain("\"completeness\""); + assertThat(prompt).doesNotContain("weaknessType"); + assertThat(prompt).doesNotContain("dimension"); + assertThat(prompt).doesNotContain("relatedRequirement"); + } +}