Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ class NlgEvaluationAiClient {
2. JobDri 서비스 정책상 missing keyword 제외 대상이 아니다.
3. answer에 동일하거나 의미상 충족되는 내용이 없다.
4. 현재 missingKeywords에 동일하거나 충분히 유사한 키워드가 없다.
- MISSED_MISSING_KEYWORD를 부여한다면 missedMissingKeywordEvaluations에도 같은 개수 이상의 근거를 작성한다.
- missedMissingKeywordEvaluations의 각 항목은 keyword, source, relatedRequirement, reason을 모두 포함한다.
- relatedRequirement는 mainTasks 또는 qualifications에 실제 존재하는 JD 원문 일부를 그대로 사용한다.
- source는 MAIN_TASK 또는 QUALIFICATION만 사용한다.
- "JD의 핵심 경험 요구사항", "채용 관련 경험", "업무 경험 부족", "핵심 경험"처럼 추상적인 표현을 keyword나 relatedRequirement로 사용하지 않는다.
- JD에 없는 요구사항을 생성하지 않는다.
- missing keyword 평가 대상에서 제외한다: 자격증, 면허, 학력, 경력 연차, 나이, 법적/정형 보유 조건, 선택형 자격요건의 다른 선택지.
- 제외 예시: 사회복지사, 청소년상담사, 직업상담사, 운전면허, 대졸 이상, 경력 3년 이상.
- 제외 대상이 아닌 예시: Spring Boot 실무 경험, 포토샵 활용 능력, 엑셀 고급 활용, 더존 사용 능력, 4대보험 신고 경험.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.exc.InvalidFormatException;
import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse;
import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource;
import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisSanitizationRules;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.NestedExceptionUtils;
Expand Down Expand Up @@ -181,9 +183,17 @@ private NlgEvaluationResult validateAndBuildResult(

List<NlgEvaluationResponse.QuestionAnalysisEvaluation> evaluations =
validQuestionEvaluations(input.questionAnalyses(), response.questionAnalysisEvaluations());
List<NlgEvaluationResponse.MissingKeywordMissEvaluation> validMissingKeywordMissEvaluations =
validMissingKeywordMissEvaluations(input, response.missedMissingKeywordEvaluations());
boolean missedValidatedMissingKeywords =
input.validatedMissingKeywordCandidateCount() > 0 && input.actualMissingKeywordCount() == 0;
List<NlgEvaluationErrorCode> errorCodes = mergeErrorCodes(response, evaluations, missedValidatedMissingKeywords);
List<NlgEvaluationErrorCode> errorCodes = mergeErrorCodes(
input,
response,
evaluations,
missedValidatedMissingKeywords,
!validMissingKeywordMissEvaluations.isEmpty()
);
boolean hasFatalError = hasFatalError(errorCodes);
Integer missingKeywordsCoverage = validCaseScore(response.missingKeywordsCoverage());
if (missedValidatedMissingKeywords) {
Expand Down Expand Up @@ -245,6 +255,7 @@ private NlgEvaluationResponse normalizeQuestionAnalysisEvaluations(
response.missingKeywordsPrecision(),
response.missingKeywordsCoverage(),
response.overallUsefulness(),
response.missedMissingKeywordEvaluations(),
response.caseErrorCodes(),
response.shortRationale()
);
Expand Down Expand Up @@ -323,15 +334,28 @@ && validScore(evaluation.nonMeta())
}

private List<NlgEvaluationErrorCode> mergeErrorCodes(
NlgEvaluationAiClient.NlgJudgeInput input,
NlgEvaluationResponse response,
List<NlgEvaluationResponse.QuestionAnalysisEvaluation> evaluations,
boolean missedValidatedMissingKeywords
boolean missedValidatedMissingKeywords,
boolean hasValidMissingKeywordMissEvidence
) {
boolean hasLowScore = hasLowCaseScore(response) || missedValidatedMissingKeywords;
Set<NlgEvaluationErrorCode> codes = new HashSet<>(sanitizeErrorCodes(
response.caseErrorCodes(),
hasLowScore
));
if (codes.remove(NlgEvaluationErrorCode.MISSED_MISSING_KEYWORD)) {
if (hasValidMissingKeywordMissEvidence || missedValidatedMissingKeywords) {
codes.add(NlgEvaluationErrorCode.MISSED_MISSING_KEYWORD);
} else {
log.warn(
"Removed NLG Judge missing keyword error without valid evidence. reason=judge_missing_keyword_without_valid_evidence, caseId={}, sourceResultFile={}",
input.caseId(),
input.sourceResultFile()
);
}
}
if (missedValidatedMissingKeywords) {
codes.add(NlgEvaluationErrorCode.MISSED_MISSING_KEYWORD);
codes.remove(NlgEvaluationErrorCode.NONE);
Expand All @@ -358,6 +382,106 @@ private List<NlgEvaluationErrorCode> mergeErrorCodes(
.toList();
}

private List<NlgEvaluationResponse.MissingKeywordMissEvaluation> validMissingKeywordMissEvaluations(
NlgEvaluationAiClient.NlgJudgeInput input,
List<NlgEvaluationResponse.MissingKeywordMissEvaluation> evaluations
) {
if (evaluations == null || evaluations.isEmpty()) {
return List.of();
}
List<AnalysisLlmResponse.MissingKeywordItem> actualMissingKeywords =
readActualMissingKeywords(input.missingKeywordsJson(), input.caseId());
List<NlgEvaluationResponse.MissingKeywordMissEvaluation> valid = new ArrayList<>();
for (NlgEvaluationResponse.MissingKeywordMissEvaluation evaluation : evaluations) {
Optional<MissingKeywordMissInvalidReason> invalidReason =
missingKeywordMissInvalidReason(input, actualMissingKeywords, evaluation);
if (invalidReason.isPresent()) {
log.warn(
"Removed NLG Judge missing keyword evidence. caseId={}, keyword={}, relatedRequirement={}, reason={}, invalidReason={}",
input.caseId(),
evaluation == null ? null : evaluation.keyword(),
evaluation == null ? null : evaluation.relatedRequirement(),
evaluation == null ? null : evaluation.reason(),
invalidReason.get()
);
continue;
}
valid.add(evaluation);
}
return valid;
}

private Optional<MissingKeywordMissInvalidReason> missingKeywordMissInvalidReason(
NlgEvaluationAiClient.NlgJudgeInput input,
List<AnalysisLlmResponse.MissingKeywordItem> actualMissingKeywords,
NlgEvaluationResponse.MissingKeywordMissEvaluation evaluation
) {
if (evaluation == null) {
return Optional.of(MissingKeywordMissInvalidReason.NULL_EVIDENCE);
}
if (!StringUtils.hasText(evaluation.keyword())) {
return Optional.of(MissingKeywordMissInvalidReason.BLANK_KEYWORD);
}
if (!StringUtils.hasText(evaluation.relatedRequirement())) {
return Optional.of(MissingKeywordMissInvalidReason.BLANK_RELATED_REQUIREMENT);
}
if (!StringUtils.hasText(evaluation.reason())) {
return Optional.of(MissingKeywordMissInvalidReason.BLANK_REASON);
}
Optional<MissingKeywordSource> source = parseJudgeMissingKeywordSource(evaluation.source());
if (source.isEmpty() || source.get() == MissingKeywordSource.PREFERENCE) {
return Optional.of(MissingKeywordMissInvalidReason.INVALID_SOURCE);
}
if (AnalysisSanitizationRules.isStructuredQualificationKeyword(evaluation.keyword())
|| AnalysisSanitizationRules.isStructuredQualificationKeyword(evaluation.relatedRequirement())) {
return Optional.of(MissingKeywordMissInvalidReason.STRUCTURED_QUALIFICATION);
}
String sourceText = source.get() == MissingKeywordSource.MAIN_TASK
? input.mainTasks()
: input.qualifications();
if (!containsNormalized(sourceText, evaluation.relatedRequirement())) {
return Optional.of(MissingKeywordMissInvalidReason.RELATED_REQUIREMENT_NOT_IN_JD);
}
if (!AnalysisSanitizationRules.isValidMissingKeyword(
evaluation.keyword(),
source.get(),
input.mainTasks(),
input.qualifications()
)) {
return Optional.of(MissingKeywordMissInvalidReason.INVALID_KEYWORD);
}
if (actualMissingKeywords.stream().anyMatch(keyword -> sameMissingKeyword(keyword, evaluation, source.get()))) {
return Optional.of(MissingKeywordMissInvalidReason.ALREADY_PRESENT_IN_FINAL_MISSING_KEYWORDS);
}
return Optional.empty();
}

private Optional<MissingKeywordSource> parseJudgeMissingKeywordSource(String source) {
if (!StringUtils.hasText(source)) {
return Optional.empty();
}
String normalized = source.trim();
if ("MAIN_TASK".equalsIgnoreCase(normalized) || "MAIN_TASKS".equalsIgnoreCase(normalized)) {
return Optional.of(MissingKeywordSource.MAIN_TASK);
}
if ("QUALIFICATION".equalsIgnoreCase(normalized) || "QUALIFICATIONS".equalsIgnoreCase(normalized)) {
return Optional.of(MissingKeywordSource.QUALIFICATION);
}
return MissingKeywordSource.from(normalized);
}

private boolean sameMissingKeyword(
AnalysisLlmResponse.MissingKeywordItem actual,
NlgEvaluationResponse.MissingKeywordMissEvaluation evaluation,
MissingKeywordSource source
) {
return actual != null
&& normalize(actual.keyword()).equals(normalize(evaluation.keyword()))
&& parseJudgeMissingKeywordSource(actual.source())
.map(actualSource -> actualSource == source)
.orElse(false);
}

private List<NlgEvaluationErrorCode> sanitizeErrorCodes(
List<NlgEvaluationErrorCode> errorCodes,
boolean hasLowScore
Expand Down Expand Up @@ -477,6 +601,22 @@ private int readJsonArraySize(String json, String fieldName, String caseId) {
}
}

private List<AnalysisLlmResponse.MissingKeywordItem> readActualMissingKeywords(String json, String caseId) {
if (!StringUtils.hasText(json)) {
return List.of();
}
try {
List<AnalysisLlmResponse.MissingKeywordItem> values = objectMapper.readValue(
json,
new TypeReference<>() {
}
);
return values == null ? List.of() : values;
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("aiMissingKeywordsJson must be a JSON array. caseId=" + caseId, e);
}
}

private int readMissingKeywordCandidateCount(String sanitizedCandidateResponseJson) {
if (!StringUtils.hasText(sanitizedCandidateResponseJson)) {
return 0;
Expand Down Expand Up @@ -741,6 +881,16 @@ private Double parseDouble(String value) {
}
}

private boolean containsNormalized(String sourceText, String fragment) {
return StringUtils.hasText(sourceText)
&& StringUtils.hasText(fragment)
&& normalize(sourceText).contains(normalize(fragment));
}

private String normalize(String value) {
return AnalysisSanitizationRules.normalizeText(value);
}

private String truncateShortRationale(String rationale) {
if (!StringUtils.hasText(rationale)) {
return "";
Expand Down Expand Up @@ -957,6 +1107,18 @@ private enum HeaderLocation {
MISSING
}

private enum MissingKeywordMissInvalidReason {
NULL_EVIDENCE,
BLANK_KEYWORD,
BLANK_RELATED_REQUIREMENT,
BLANK_REASON,
INVALID_SOURCE,
STRUCTURED_QUALIFICATION,
RELATED_REQUIREMENT_NOT_IN_JD,
INVALID_KEYWORD,
ALREADY_PRESENT_IN_FINAL_MISSING_KEYWORDS
}

private record ResolvedHeader(HeaderLocation location, String headerName) {
static ResolvedHeader input(String headerName) {
return new ResolvedHeader(HeaderLocation.INPUT, headerName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,37 @@ public record NlgEvaluationResponse(
Integer missingKeywordsPrecision,
Integer missingKeywordsCoverage,
Integer overallUsefulness,
List<MissingKeywordMissEvaluation> missedMissingKeywordEvaluations,
List<NlgEvaluationErrorCode> caseErrorCodes,
String shortRationale
) {
public NlgEvaluationResponse(
String caseId,
List<QuestionAnalysisEvaluation> questionAnalysisEvaluations,
Integer noAnalysisAppropriateness,
Integer strengthsPrecision,
Integer strengthsCoverage,
Integer missingKeywordsPrecision,
Integer missingKeywordsCoverage,
Integer overallUsefulness,
List<NlgEvaluationErrorCode> caseErrorCodes,
String shortRationale
) {
this(
caseId,
questionAnalysisEvaluations,
noAnalysisAppropriateness,
strengthsPrecision,
strengthsCoverage,
missingKeywordsPrecision,
missingKeywordsCoverage,
overallUsefulness,
List.of(),
caseErrorCodes,
shortRationale
);
}

public record QuestionAnalysisEvaluation(
Integer analysisIndex,
String sentence,
Expand All @@ -30,4 +58,12 @@ public record QuestionAnalysisEvaluation(
List<NlgEvaluationErrorCode> errorCodes
) {
}

public record MissingKeywordMissEvaluation(
String keyword,
String source,
String relatedRequirement,
String reason
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ void buildPromptAlignsMissedMissingKeywordWithServicePolicy() {
.contains("[MISSED_MISSING_KEYWORD 판정 기준]")
.contains("필요한 비정형 업무·역량 누락 키워드가 존재하면 actual=[]라도")
.contains("정형 자격요건은 이후 [MISSED_MISSING_KEYWORD 판정 기준]의 제외 정책이 우선")
.contains("missedMissingKeywordEvaluations에도 같은 개수 이상의 근거를 작성한다")
.contains("relatedRequirement는 mainTasks 또는 qualifications에 실제 존재하는 JD 원문 일부를 그대로 사용한다")
.contains("\"JD의 핵심 경험 요구사항\", \"채용 관련 경험\", \"업무 경험 부족\", \"핵심 경험\"")
.contains("실제 JD 원문의 mainTasks 또는 qualifications에 요구사항이 존재한다")
.contains("JobDri 서비스 정책상 missing keyword 제외 대상이 아니다")
.contains("answer에 동일하거나 의미상 충족되는 내용이 없다")
Expand Down
Loading
Loading