diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java index 9210d17..5183acf 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java @@ -5,11 +5,13 @@ import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.exc.InvalidFormatException; -import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; +import com.jobdri.jobdri_api.domain.evaluation.analysis.mapper.EvaluationLlmSnapshotParser; +import com.jobdri.jobdri_api.domain.evaluation.analysis.mapper.EvaluationMissingKeywordSourceMapper; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeyword; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSource; import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisSanitizationRules; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.NestedExceptionUtils; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -28,7 +30,6 @@ import java.util.stream.Collectors; @Service -@RequiredArgsConstructor @Slf4j class NlgEvaluationBatchService { private static final int MIN_SCORE = 1; @@ -64,6 +65,17 @@ class NlgEvaluationBatchService { private final NlgEvaluationAiClient nlgEvaluationAiClient; private final ObjectMapper objectMapper; + private final EvaluationLlmSnapshotParser evaluationLlmSnapshotParser; + + @Autowired + NlgEvaluationBatchService( + NlgEvaluationAiClient nlgEvaluationAiClient, + ObjectMapper objectMapper + ) { + this.nlgEvaluationAiClient = nlgEvaluationAiClient; + this.objectMapper = objectMapper; + this.evaluationLlmSnapshotParser = new EvaluationLlmSnapshotParser(objectMapper); + } NlgEvaluationSummary run(Path inputPath, Path outputPath) throws IOException { validateDifferentFiles(inputPath, outputPath); @@ -140,7 +152,9 @@ private NlgEvaluationAiClient.NlgJudgeInput buildJudgeInput( requiredResolvedValue(row, sourceCaseRows, resolvedHeaders, "question", caseId), requiredResolvedValue(row, sourceCaseRows, resolvedHeaders, "answer", caseId), questionAnalysesJson, - readKeyStrengthsJson(resolvedValue(row, sourceCaseRows, resolvedHeaders, "rawLlmResponseJson")), + writeJson(evaluationLlmSnapshotParser.parseRawLlmResponse( + resolvedValue(row, sourceCaseRows, resolvedHeaders, "rawLlmResponseJson") + ).keyStrengthQuotes()), missingKeywordsJson, value(row, "rawCandidateResponseJson"), value(row, "sanitizedCandidateResponseJson"), @@ -389,8 +403,8 @@ private List validMissingKey if (evaluations == null || evaluations.isEmpty()) { return List.of(); } - List actualMissingKeywords = - readActualMissingKeywords(input.missingKeywordsJson(), input.caseId()); + List actualMissingKeywords = + evaluationLlmSnapshotParser.parseMissingKeywords(input.missingKeywordsJson(), input.caseId()); List valid = new ArrayList<>(); for (NlgEvaluationResponse.MissingKeywordMissEvaluation evaluation : evaluations) { Optional invalidReason = @@ -413,7 +427,7 @@ private List validMissingKey private Optional missingKeywordMissInvalidReason( NlgEvaluationAiClient.NlgJudgeInput input, - List actualMissingKeywords, + List actualMissingKeywords, NlgEvaluationResponse.MissingKeywordMissEvaluation evaluation ) { if (evaluation == null) { @@ -428,15 +442,15 @@ private Optional missingKeywordMissInvalidReaso if (!StringUtils.hasText(evaluation.reason())) { return Optional.of(MissingKeywordMissInvalidReason.BLANK_REASON); } - Optional source = parseJudgeMissingKeywordSource(evaluation.source()); - if (source.isEmpty() || source.get() == MissingKeywordSource.PREFERENCE) { + Optional source = parseJudgeMissingKeywordSource(evaluation.source()); + if (source.isEmpty() || source.get() == EvaluationMissingKeywordSource.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 + String sourceText = source.get() == EvaluationMissingKeywordSource.MAIN_TASK ? input.mainTasks() : input.qualifications(); if (!containsNormalized(sourceText, evaluation.relatedRequirement())) { @@ -444,7 +458,7 @@ private Optional missingKeywordMissInvalidReaso } if (!AnalysisSanitizationRules.isValidMissingKeyword( evaluation.keyword(), - source.get(), + EvaluationMissingKeywordSourceMapper.toAnalysisSource(source.get()), input.mainTasks(), input.qualifications() )) { @@ -456,30 +470,18 @@ private Optional missingKeywordMissInvalidReaso return Optional.empty(); } - private Optional 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 Optional parseJudgeMissingKeywordSource(String source) { + return EvaluationMissingKeywordSource.from(source); } private boolean sameMissingKeyword( - AnalysisLlmResponse.MissingKeywordItem actual, + EvaluationMissingKeyword actual, NlgEvaluationResponse.MissingKeywordMissEvaluation evaluation, - MissingKeywordSource source + EvaluationMissingKeywordSource source ) { return actual != null && normalize(actual.keyword()).equals(normalize(evaluation.keyword())) - && parseJudgeMissingKeywordSource(actual.source()) - .map(actualSource -> actualSource == source) - .orElse(false); + && actual.source() == source; } private List sanitizeErrorCodes( @@ -601,22 +603,6 @@ private int readJsonArraySize(String json, String fieldName, String caseId) { } } - private List readActualMissingKeywords(String json, String caseId) { - if (!StringUtils.hasText(json)) { - return List.of(); - } - try { - List 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; @@ -634,18 +620,6 @@ private int readMissingKeywordCandidateCount(String sanitizedCandidateResponseJs } } - private String readKeyStrengthsJson(String rawLlmResponseJson) { - if (!StringUtils.hasText(rawLlmResponseJson)) { - return "[]"; - } - try { - AnalysisLlmResponse response = objectMapper.readValue(rawLlmResponseJson, AnalysisLlmResponse.class); - return writeJson(response == null || response.keyStrengths() == null ? List.of() : response.keyStrengths()); - } catch (JsonProcessingException e) { - return "[]"; - } - } - private SourceCaseRows loadSourceCaseRowsIfNeeded(Path inputPath, List inputHeaders) throws IOException { boolean hasAllContextHeaders = List.of("mainTasks", "qualifications", "question", "answer").stream() .allMatch(field -> directHeader(inputHeaders, field).isPresent()); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotMapper.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotMapper.java new file mode 100644 index 0000000..d8b7d0d --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotMapper.java @@ -0,0 +1,29 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationCandidateSnapshot; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordCandidate; + +import java.util.List; + +public final class EvaluationCandidateSnapshotMapper { + private EvaluationCandidateSnapshotMapper() { + } + + public static EvaluationCandidateSnapshot from(AnalysisCandidateResponse response) { + if (response == null || response.missingKeywordCandidates() == null) { + return new EvaluationCandidateSnapshot(List.of()); + } + List missingKeywordCandidates = response.missingKeywordCandidates().stream() + .map(candidate -> EvaluationMissingKeywordSourceMapper.fromAnalysisSource(candidate.source()) + .map(source -> new EvaluationMissingKeywordCandidate( + candidate.keyword(), + source, + candidate.relatedRequirement() + )) + .orElse(null)) + .filter(candidate -> candidate != null) + .toList(); + return new EvaluationCandidateSnapshot(missingKeywordCandidates); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotMapper.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotMapper.java new file mode 100644 index 0000000..4c11126 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotMapper.java @@ -0,0 +1,38 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationLlmSnapshot; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeyword; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationQuestionAnalysis; + +import java.util.List; +import java.util.Optional; + +public final class EvaluationLlmSnapshotMapper { + private EvaluationLlmSnapshotMapper() { + } + + public static EvaluationLlmSnapshot from(AnalysisLlmResponse response) { + if (response == null) { + return new EvaluationLlmSnapshot(List.of(), List.of(), List.of()); + } + List keyStrengthQuotes = response.keyStrengths() == null + ? List.of() + : response.keyStrengths().stream() + .map(AnalysisLlmResponse.HighlightItem::quote) + .toList(); + List missingKeywords = response.missingKeywords() == null + ? List.of() + : response.missingKeywords().stream() + .map(EvaluationMissingKeywordMapper::from) + .flatMap(Optional::stream) + .toList(); + List questionAnalyses = response.questionAnalyses() == null + ? List.of() + : response.questionAnalyses().stream() + .map(EvaluationQuestionAnalysisMapper::from) + .filter(questionAnalysis -> questionAnalysis != null) + .toList(); + return new EvaluationLlmSnapshot(keyStrengthQuotes, missingKeywords, questionAnalyses); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParser.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParser.java new file mode 100644 index 0000000..3850d80 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParser.java @@ -0,0 +1,104 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationLlmSnapshot; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeyword; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSource; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationQuestionAnalysis; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; + +public class EvaluationLlmSnapshotParser { + private final ObjectMapper objectMapper; + + public EvaluationLlmSnapshotParser(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public EvaluationLlmSnapshot parseRawLlmResponse(String rawLlmResponseJson) { + if (!StringUtils.hasText(rawLlmResponseJson)) { + return emptySnapshot(); + } + try { + JsonNode root = objectMapper.readTree(rawLlmResponseJson); + return new EvaluationLlmSnapshot( + readKeyStrengthQuotes(root.path("keyStrengths")), + readMissingKeywords(root.path("missingKeywords")), + readQuestionAnalyses(root.path("questionAnalyses")) + ); + } catch (JsonProcessingException e) { + return emptySnapshot(); + } + } + + public List parseMissingKeywords(String json, String caseId) { + if (!StringUtils.hasText(json)) { + return List.of(); + } + try { + JsonNode root = objectMapper.readTree(json); + if (!root.isArray()) { + throw new IllegalArgumentException("aiMissingKeywordsJson must be a JSON array. caseId=" + caseId); + } + return readMissingKeywords(root); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("aiMissingKeywordsJson must be a JSON array. caseId=" + caseId, e); + } + } + + private EvaluationLlmSnapshot emptySnapshot() { + return new EvaluationLlmSnapshot(List.of(), List.of(), List.of()); + } + + private List readKeyStrengthQuotes(JsonNode keyStrengthsNode) { + if (!keyStrengthsNode.isArray()) { + return List.of(); + } + List quotes = new ArrayList<>(); + for (JsonNode item : keyStrengthsNode) { + String quote = item.path("quote").asText(null); + if (StringUtils.hasText(quote)) { + quotes.add(quote); + } + } + return quotes; + } + + private List readMissingKeywords(JsonNode missingKeywordsNode) { + if (!missingKeywordsNode.isArray()) { + return List.of(); + } + List missingKeywords = new ArrayList<>(); + for (JsonNode item : missingKeywordsNode) { + String keyword = item.path("keyword").asText(null); + String source = item.path("source").asText(null); + if (!StringUtils.hasText(keyword)) { + continue; + } + EvaluationMissingKeywordSource.from(source) + .ifPresent(value -> missingKeywords.add(new EvaluationMissingKeyword(keyword, value))); + } + return missingKeywords; + } + + private List readQuestionAnalyses(JsonNode questionAnalysesNode) { + if (!questionAnalysesNode.isArray()) { + return List.of(); + } + List questionAnalyses = new ArrayList<>(); + for (JsonNode item : questionAnalysesNode) { + questionAnalyses.add(new EvaluationQuestionAnalysis( + item.path("questionId").isNumber() ? item.path("questionId").longValue() : null, + item.path("sentence").asText(null), + item.path("status").asText(null), + item.path("reason").asText(null), + item.path("improvement").asText(null) + )); + } + return questionAnalyses; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationMissingKeywordMapper.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationMissingKeywordMapper.java new file mode 100644 index 0000000..2ce2534 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationMissingKeywordMapper.java @@ -0,0 +1,19 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeyword; + +import java.util.Optional; + +public final class EvaluationMissingKeywordMapper { + private EvaluationMissingKeywordMapper() { + } + + public static Optional from(AnalysisLlmResponse.MissingKeywordItem item) { + if (item == null) { + return Optional.empty(); + } + return EvaluationMissingKeywordSourceMapper.fromAnalysisSource(item.source()) + .map(source -> new EvaluationMissingKeyword(item.keyword(), source)); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationMissingKeywordSourceMapper.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationMissingKeywordSourceMapper.java new file mode 100644 index 0000000..383c5b9 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationMissingKeywordSourceMapper.java @@ -0,0 +1,37 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSource; + +import java.util.Optional; + +public final class EvaluationMissingKeywordSourceMapper { + private EvaluationMissingKeywordSourceMapper() { + } + + public static Optional fromAnalysisSource(String value) { + return EvaluationMissingKeywordSource.from(value); + } + + public static Optional fromAnalysisSource(MissingKeywordSource source) { + if (source == null) { + return Optional.empty(); + } + return switch (source) { + case MAIN_TASK -> Optional.of(EvaluationMissingKeywordSource.MAIN_TASK); + case QUALIFICATION -> Optional.of(EvaluationMissingKeywordSource.QUALIFICATION); + case PREFERENCE -> Optional.of(EvaluationMissingKeywordSource.PREFERENCE); + }; + } + + public static MissingKeywordSource toAnalysisSource(EvaluationMissingKeywordSource source) { + if (source == null) { + return null; + } + return switch (source) { + case MAIN_TASK -> MissingKeywordSource.MAIN_TASK; + case QUALIFICATION -> MissingKeywordSource.QUALIFICATION; + case PREFERENCE -> MissingKeywordSource.PREFERENCE; + }; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationQuestionAnalysisMapper.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationQuestionAnalysisMapper.java new file mode 100644 index 0000000..f1d3777 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationQuestionAnalysisMapper.java @@ -0,0 +1,22 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationQuestionAnalysis; + +public final class EvaluationQuestionAnalysisMapper { + private EvaluationQuestionAnalysisMapper() { + } + + public static EvaluationQuestionAnalysis from(AnalysisLlmResponse.QuestionAnalysisItem item) { + if (item == null) { + return null; + } + return new EvaluationQuestionAnalysis( + item.questionId(), + item.sentence(), + item.status(), + item.reason(), + item.improvement() + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateSnapshot.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateSnapshot.java new file mode 100644 index 0000000..cd03d0a --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateSnapshot.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +import java.util.List; + +public record EvaluationCandidateSnapshot( + List missingKeywordCandidates +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationLlmSnapshot.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationLlmSnapshot.java new file mode 100644 index 0000000..4e01a38 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationLlmSnapshot.java @@ -0,0 +1,10 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +import java.util.List; + +public record EvaluationLlmSnapshot( + List keyStrengthQuotes, + List missingKeywords, + List questionAnalyses +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeyword.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeyword.java new file mode 100644 index 0000000..3fdc7d1 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeyword.java @@ -0,0 +1,7 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +public record EvaluationMissingKeyword( + String keyword, + EvaluationMissingKeywordSource source +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordCandidate.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordCandidate.java new file mode 100644 index 0000000..ff27488 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordCandidate.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +public record EvaluationMissingKeywordCandidate( + String keyword, + EvaluationMissingKeywordSource source, + String relatedRequirement +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordSource.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordSource.java new file mode 100644 index 0000000..2d34514 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordSource.java @@ -0,0 +1,33 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +import java.util.Arrays; +import java.util.Optional; + +public enum EvaluationMissingKeywordSource { + MAIN_TASK("mainTask", "MAIN_TASK", "MAIN_TASKS"), + QUALIFICATION("qualification", "QUALIFICATION", "QUALIFICATIONS"), + PREFERENCE("preference", "PREFERENCE", "PREFERENCES"); + + private final String value; + private final String[] aliases; + + EvaluationMissingKeywordSource(String value, String... aliases) { + this.value = value; + this.aliases = aliases; + } + + public String value() { + return value; + } + + public static Optional from(String value) { + if (value == null) { + return Optional.empty(); + } + String normalized = value.trim(); + return Arrays.stream(values()) + .filter(source -> source.value.equalsIgnoreCase(normalized) + || Arrays.stream(source.aliases).anyMatch(alias -> alias.equalsIgnoreCase(normalized))) + .findFirst(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationQuestionAnalysis.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationQuestionAnalysis.java new file mode 100644 index 0000000..acfa661 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationQuestionAnalysis.java @@ -0,0 +1,10 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +public record EvaluationQuestionAnalysis( + Long questionId, + String sentence, + String status, + String reason, + String improvement +) { +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java index 9a9b230..0488bca 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java @@ -1,5 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.service.ai; +import com.jobdri.jobdri_api.global.config.LlmConcurrencyLimiter; +import com.jobdri.jobdri_api.global.metrics.AsyncMetricsRecorder; import com.jobdri.jobdri_api.domain.analysis.dto.internal.criteria.JobCategoryEvaluationCriteria; import com.fasterxml.jackson.databind.ObjectMapper; import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; @@ -20,6 +22,7 @@ import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.openai.client.OpenAIClient; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotMapperTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotMapperTest.java new file mode 100644 index 0000000..42c0f62 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotMapperTest.java @@ -0,0 +1,32 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationCandidateSnapshot; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class EvaluationCandidateSnapshotMapperTest { + + @Test + @DisplayName("candidate response를 evaluation snapshot으로 변환한다") + void mapsCandidateResponseToEvaluationSnapshot() { + AnalysisCandidateResponse response = new AnalysisCandidateResponse( + List.of(), + List.of(), + List.of( + new AnalysisCandidateResponse.MissingKeywordCandidate("Spring Boot", "qualification", "Spring Boot 실무 경험"), + new AnalysisCandidateResponse.MissingKeywordCandidate("ignored", "unknown", "N/A") + ) + ); + + EvaluationCandidateSnapshot snapshot = EvaluationCandidateSnapshotMapper.from(response); + + assertThat(snapshot.missingKeywordCandidates()) + .extracting(candidate -> candidate.keyword() + ":" + candidate.source().name()) + .containsExactly("Spring Boot:QUALIFICATION"); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotMapperTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotMapperTest.java new file mode 100644 index 0000000..f43dcc9 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotMapperTest.java @@ -0,0 +1,51 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationLlmSnapshot; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class EvaluationLlmSnapshotMapperTest { + + @Test + @DisplayName("runtime llm response를 evaluation snapshot으로 변환한다") + void mapsAnalysisLlmResponseToEvaluationSnapshot() { + AnalysisLlmResponse response = new AnalysisLlmResponse( + 4, + 4, + 4, + "feedback", + List.of( + new AnalysisLlmResponse.HighlightItem("강점", "문장 A") + ), + List.of(), + List.of( + new AnalysisLlmResponse.MissingKeywordItem("Spring Boot", "mainTask"), + new AnalysisLlmResponse.MissingKeywordItem("무시됨", "unknown") + ), + List.of( + new AnalysisLlmResponse.QuestionAnalysisItem( + 1L, + "문장", + "mentioned", + "근거", + "개선" + ) + ) + ); + + EvaluationLlmSnapshot snapshot = EvaluationLlmSnapshotMapper.from(response); + + assertThat(snapshot.keyStrengthQuotes()).containsExactly("문장 A"); + assertThat(snapshot.missingKeywords()) + .extracting(keyword -> keyword.keyword() + ":" + keyword.source().name()) + .containsExactly("Spring Boot:MAIN_TASK"); + assertThat(snapshot.questionAnalyses()) + .extracting(questionAnalysis -> questionAnalysis.sentence() + ":" + questionAnalysis.status()) + .containsExactly("문장:mentioned"); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParserTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParserTest.java new file mode 100644 index 0000000..a9a7eec --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParserTest.java @@ -0,0 +1,59 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationLlmSnapshot; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class EvaluationLlmSnapshotParserTest { + + private final EvaluationLlmSnapshotParser parser = new EvaluationLlmSnapshotParser(new ObjectMapper()); + + @Test + @DisplayName("raw llm response json을 evaluation snapshot으로 파싱한다") + void parsesRawLlmResponseJson() { + String rawLlmResponseJson = """ + { + "keyStrengths": [{"quote": "강점 문장"}], + "missingKeywords": [{"keyword": "Spring Boot", "source": "MAIN_TASK"}], + "questionAnalyses": [{"questionId": 1, "sentence": "문장", "status": "mentioned", "reason": "근거", "improvement": "개선"}] + } + """; + + EvaluationLlmSnapshot snapshot = parser.parseRawLlmResponse(rawLlmResponseJson); + + assertThat(snapshot.keyStrengthQuotes()).containsExactly("강점 문장"); + assertThat(snapshot.missingKeywords()) + .extracting(keyword -> keyword.keyword() + ":" + keyword.source().name()) + .containsExactly("Spring Boot:MAIN_TASK"); + assertThat(snapshot.questionAnalyses()) + .extracting(questionAnalysis -> questionAnalysis.sentence() + ":" + questionAnalysis.status()) + .containsExactly("문장:mentioned"); + } + + @Test + @DisplayName("missing keyword json을 evaluation 모델로 파싱한다") + void parsesMissingKeywordJson() { + List missingKeywords = parser.parseMissingKeywords( + "[{\"keyword\":\"Spring Boot\",\"source\":\"QUALIFICATIONS\"}]", + "EV-01" + ); + + assertThat(missingKeywords) + .extracting(keyword -> keyword.toString()) + .containsExactly("EvaluationMissingKeyword[keyword=Spring Boot, source=QUALIFICATION]"); + } + + @Test + @DisplayName("missing keyword json이 배열이 아니면 예외를 던진다") + void rejectsNonArrayMissingKeywordJson() { + assertThatThrownBy(() -> parser.parseMissingKeywords("{\"keyword\":\"Spring Boot\"}", "EV-01")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("aiMissingKeywordsJson must be a JSON array"); + } +}