diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java index efc2b91..69f5659 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java @@ -133,7 +133,16 @@ public static Analysis create( } public void updateHighlightsJson(String keyStrengthsJson, String keyWeaknessesJson) { + updateResultPayloadJson(keyStrengthsJson, keyWeaknessesJson, this.missingKeywordsJson); + } + + public void updateResultPayloadJson( + String keyStrengthsJson, + String keyWeaknessesJson, + String missingKeywordsJson + ) { this.keyStrengthsJson = keyStrengthsJson == null ? "[]" : keyStrengthsJson; this.keyWeaknessesJson = keyWeaknessesJson == null ? "[]" : keyWeaknessesJson; + this.missingKeywordsJson = missingKeywordsJson == null ? "[]" : missingKeywordsJson; } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResponseAssembler.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResponseAssembler.java new file mode 100644 index 0000000..ef9a312 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResponseAssembler.java @@ -0,0 +1,85 @@ +package com.jobdri.jobdri_api.domain.analysis.service.core; + +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisQuestionResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnalysisResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; +import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisResultSanitizationService; +import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class AnalysisResponseAssembler { + private final MockApplyRepository mockApplyRepository; + + AnalysisResponse toResponse( + MockApply mockApply, + Analysis analysis, + List questions, + List questionAnalyses, + AnalysisResultSanitizationService.AnalysisResultPayload resultPayload + ) { + Map questionById = questions.stream() + .collect(Collectors.toMap(Question::getId, Function.identity())); + Map> analysesByQuestionId = questionAnalyses.stream() + .filter(questionAnalysis -> isValidQuestionAnalysisForResponse( + questionAnalysis, + questionById.get(questionAnalysis.getQuestion().getId()) + )) + .collect(Collectors.groupingBy( + questionAnalysis -> questionAnalysis.getQuestion().getId(), + Collectors.mapping(QuestionAnalysisResponse::from, Collectors.toList()) + )); + + List questionResponses = questions.stream() + .sorted(Comparator.comparing(Question::getId)) + .map(question -> AnalysisQuestionResponse.of( + question, + analysesByQuestionId.getOrDefault(question.getId(), List.of()) + )) + .toList(); + + return AnalysisResponse.of( + analysis, + mockApply.getStatus(), + mockApplyRepository.calculateSequence(mockApply), + resultPayload.keyStrengths(), + resultPayload.keyWeaknesses(), + resultPayload.missingKeywords(), + questionResponses + ); + } + + private boolean isValidQuestionAnalysisForResponse(QuestionAnalysis questionAnalysis, Question question) { + if (questionAnalysis == null || question == null) { + return false; + } + if (questionAnalysis.getStatus() == QuestionAnalysisStatus.MISSING) { + return false; + } + String answer = question.getAnswer(); + String sentence = questionAnalysis.getSentence(); + int start = questionAnalysis.getStart(); + int end = questionAnalysis.getEnd(); + if (!StringUtils.hasText(answer) || !StringUtils.hasText(sentence)) { + return false; + } + if (start < 0 || end <= start || end > answer.length()) { + return false; + } + return answer.substring(start, end).equals(sentence); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java index cf5d5ff..3055118 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java @@ -1,16 +1,10 @@ package com.jobdri.jobdri_api.domain.analysis.service.core; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisHighlightResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisQuestionResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; -import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnalysisResponse; import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; @@ -18,24 +12,21 @@ import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; +import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisResultSanitizationService; import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisSanitizationRules; -import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import java.util.ArrayList; -import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -47,30 +38,18 @@ import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.IMPACT_WEIGHT; import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.JOB_FIT_WEIGHT; import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_ANALYSES_PER_QUESTION; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_HIGHLIGHTS; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_HIGHLIGHT_QUOTE_LENGTH; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_HIGHLIGHT_TITLE_LENGTH; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_MISSING_KEYWORDS; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_MISSING_KEYWORD_LENGTH; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_SCORE; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MIN_SCORE; @Service -@Slf4j @RequiredArgsConstructor @Transactional(readOnly = true) public class AnalysisResultPersistenceService { - private static final TypeReference> MISSING_KEYWORDS_TYPE = new TypeReference<>() { - }; - private static final TypeReference> HIGHLIGHTS_TYPE = new TypeReference<>() { - }; - private final MockApplyRepository mockApplyRepository; private final QuestionRepository questionRepository; private final AnalysisRepository analysisRepository; private final QuestionAnalysisRepository questionAnalysisRepository; - private final AnalysisInputFingerprintProvider analysisInputFingerprintProvider; - private final ObjectMapper objectMapper; + private final AnalysisResponseAssembler analysisResponseAssembler; + private final AnalysisResultSanitizationService analysisResultSanitizationService; + private final AnalysisResultValidationService analysisResultValidationService; @Transactional public AnalysisResponse finalizeAnalysis( @@ -85,44 +64,50 @@ public AnalysisResponse finalizeAnalysis( GeneralErrorCode.MOCK_APPLY_NOT_FOUND, "해당 모의 서류 지원을 찾을 수 없습니다. mockApplyId=" + mockApply.getId() )); - VerifiedAnswerSnapshot answerSnapshot = verifyAnswerSnapshot(questions, payloadSnapshots); - validateRequiredScores(llmResponse); - int jobFit = validateScore("jobFit", llmResponse.jobFit()); - int impact = validateScore("impact", llmResponse.impact()); - int completeness = validateScore("completeness", llmResponse.completeness()); - List keyStrengths = buildHighlights(llmResponse.keyStrengths()); - List keyWeaknesses = buildNonOverlappingHighlights(llmResponse.keyWeaknesses(), keyStrengths); - List missingKeywords = buildMissingKeywords( - lockedMockApply.getJobPosting(), - answerSnapshot.combinedAnswers(), - llmResponse - ); + AnalysisResultValidationService.ValidatedAnalysisResult validatedResult = + analysisResultValidationService.validateForPersistence(questions, payloadSnapshots, llmResponse); + AnalysisResultSanitizationService.SanitizedAnalysisContent sanitizedContent = + analysisResultSanitizationService.sanitizeForPersistence( + lockedMockApply.getJobPosting(), + validatedResult.answerSnapshot().combinedAnswers(), + llmResponse + ); replaceExistingAnalysis(lockedMockApply); Analysis analysis = analysisRepository.save(Analysis.create( lockedMockApply, - calculateScore(jobFit, impact, completeness), - jobFit, - impact, - completeness, - normalizeFeedback(llmResponse.feedback()), - serializeMissingKeywords(missingKeywords), - serializeHighlights(keyStrengths, "keyStrengths"), - serializeHighlights(keyWeaknesses, "keyWeaknesses"), + calculateScore( + validatedResult.jobFit(), + validatedResult.impact(), + validatedResult.completeness() + ), + validatedResult.jobFit(), + validatedResult.impact(), + validatedResult.completeness(), + validatedResult.feedback(), + analysisResultSanitizationService.serializeMissingKeywords(sanitizedContent.missingKeywords()), + analysisResultSanitizationService.serializeHighlights(sanitizedContent.keyStrengths(), "keyStrengths"), + analysisResultSanitizationService.serializeHighlights(sanitizedContent.keyWeaknesses(), "keyWeaknesses"), inputFingerprint )); List questionAnalyses = buildQuestionAnalyses( analysis, questions, - answerSnapshot.answerByQuestionId(), + validatedResult.answerSnapshot().answerByQuestionId(), llmResponse ); questionAnalysisRepository.saveAll(questionAnalyses); lockedMockApply.updateStatus(MockApplyStatus.COMPLETED); mockApplyRepository.flush(); - return toResponse(lockedMockApply, analysis, questions, questionAnalyses, analysisResultPayload(analysis)); + return analysisResponseAssembler.toResponse( + lockedMockApply, + analysis, + questions, + questionAnalyses, + analysisResultSanitizationService.analysisResultPayload(analysis) + ); } @Transactional @@ -136,12 +121,12 @@ public AnalysisResponse getPersistedAnalysis(MockApply mockApply) { List questionAnalyses = questionAnalysisRepository.findAllByAnalysisIdOrderByQuestionIdAscIdAsc(analysis.getId()); - return toResponse( + return analysisResponseAssembler.toResponse( mockApply, analysis, questions, questionAnalyses, - sanitizeAndPersistAnalysisPayload(analysis, true) + analysisResultSanitizationService.sanitizeAndPersistAnalysisPayload(analysis, true) ); } @@ -151,12 +136,12 @@ public AnalysisResponse getPersistedAnalysis(MockApply mockApply, Analysis analy List questionAnalyses = questionAnalysisRepository.findAllByAnalysisIdOrderByQuestionIdAscIdAsc(analysis.getId()); - return toResponse( + return analysisResponseAssembler.toResponse( mockApply, analysis, questions, questionAnalyses, - sanitizeAndPersistAnalysisPayload(analysis, false) + analysisResultSanitizationService.sanitizeAndPersistAnalysisPayload(analysis, false) ); } @@ -173,49 +158,6 @@ private void replaceExistingAnalysis(MockApply mockApply) { analysisRepository.flush(); } - private VerifiedAnswerSnapshot verifyAnswerSnapshot( - List databaseQuestions, - List payloadSnapshots - ) { - String databaseFingerprint = analysisInputFingerprintProvider - .createAnswerFingerprintFromQuestions(databaseQuestions); - String payloadFingerprint = analysisInputFingerprintProvider - .createAnswerFingerprint(payloadSnapshots); - if (!databaseFingerprint.equals(payloadFingerprint)) { - throw new GeneralException( - GeneralErrorCode.INVALID_PARAMETER, - "분석 실행 이후 자소서 답변이 변경되어 결과를 저장할 수 없습니다." - ); - } - - List immutableSnapshots = List.copyOf(payloadSnapshots); - Map answerByQuestionId = new LinkedHashMap<>(); - for (AnalysisExecutionPayload.AnswerSnapshot snapshot : immutableSnapshots) { - if (snapshot == null || snapshot.questionId() == null || !StringUtils.hasText(snapshot.answer())) { - continue; - } - if (answerByQuestionId.putIfAbsent(snapshot.questionId(), snapshot.answer()) != null) { - throw new GeneralException( - GeneralErrorCode.INVALID_PARAMETER, - "분석 답변 snapshot에 중복된 questionId가 있습니다. questionId=" + snapshot.questionId() - ); - } - } - return new VerifiedAnswerSnapshot(immutableSnapshots, Map.copyOf(answerByQuestionId)); - } - - private record VerifiedAnswerSnapshot( - List answers, - Map answerByQuestionId - ) { - private String combinedAnswers() { - return answers.stream() - .map(AnalysisExecutionPayload.AnswerSnapshot::answer) - .filter(StringUtils::hasText) - .collect(Collectors.joining("\n")); - } - } - private List buildQuestionAnalyses( Analysis analysis, List questions, @@ -270,7 +212,7 @@ private List buildQuestionAnalyses( } String sentence = item.sentence(); if (status != QuestionAnalysisStatus.PROVEN - && keyStrengthQuotes.contains(normalizeKeyword(sentence))) { + && keyStrengthQuotes.contains(analysisResultSanitizationService.normalizeKeyword(sentence))) { continue; } String dedupeKey = question.getId() + ":" + sentence.trim(); @@ -297,7 +239,12 @@ private List buildQuestionAnalyses( analysis, sentence, defaultString(item.reason()), - normalizeImprovement(sentence, answer, item.improvement(), status), + analysisResultValidationService.normalizeImprovement( + sentence, + answer, + item.improvement(), + status + ), status, start, start + sentence.length() @@ -313,372 +260,10 @@ private Set normalizedKeyStrengthQuotes(AnalysisLlmResponse llmResponse) } return llmResponse.keyStrengths().stream() .filter(item -> item != null && StringUtils.hasText(item.quote())) - .map(item -> normalizeKeyword(item.quote())) + .map(item -> analysisResultSanitizationService.normalizeKeyword(item.quote())) .collect(Collectors.toSet()); } - private AnalysisResponse toResponse( - MockApply mockApply, - Analysis analysis, - List questions, - List questionAnalyses, - AnalysisResultPayload resultPayload - ) { - Map questionById = questions.stream() - .collect(Collectors.toMap(Question::getId, Function.identity())); - Map> analysesByQuestionId = questionAnalyses.stream() - .filter(questionAnalysis -> isValidQuestionAnalysisForResponse( - questionAnalysis, - questionById.get(questionAnalysis.getQuestion().getId()) - )) - .collect(Collectors.groupingBy( - questionAnalysis -> questionAnalysis.getQuestion().getId(), - Collectors.mapping(QuestionAnalysisResponse::from, Collectors.toList()) - )); - - List questionResponses = questions.stream() - .sorted(Comparator.comparing(Question::getId)) - .map(question -> AnalysisQuestionResponse.of( - question, - analysesByQuestionId.getOrDefault(question.getId(), List.of()) - )) - .toList(); - - return AnalysisResponse.of( - analysis, - mockApply.getStatus(), - mockApplyRepository.calculateSequence(mockApply), - resultPayload.keyStrengths(), - resultPayload.keyWeaknesses(), - resultPayload.missingKeywords(), - questionResponses - ); - } - - private boolean isValidQuestionAnalysisForResponse(QuestionAnalysis questionAnalysis, Question question) { - if (questionAnalysis == null || question == null) { - return false; - } - if (questionAnalysis.getStatus() == QuestionAnalysisStatus.MISSING) { - return false; - } - String answer = question.getAnswer(); - String sentence = questionAnalysis.getSentence(); - int start = questionAnalysis.getStart(); - int end = questionAnalysis.getEnd(); - if (!StringUtils.hasText(answer) || !StringUtils.hasText(sentence)) { - return false; - } - if (start < 0 || end <= start || end > answer.length()) { - return false; - } - return answer.substring(start, end).equals(sentence); - } - - private AnalysisResultPayload analysisResultPayload(Analysis analysis) { - List keyStrengths = readHighlights(analysis, analysis.getKeyStrengthsJson(), "keyStrengths"); - return new AnalysisResultPayload( - keyStrengths, - removeOverlappingHighlights( - readHighlights(analysis, analysis.getKeyWeaknessesJson(), "keyWeaknesses"), - keyStrengths - ), - readMissingKeywords(analysis) - ); - } - - private AnalysisResultPayload sanitizeAndPersistAnalysisPayload(Analysis analysis, boolean persistIfChanged) { - AnalysisResultPayload payload = analysisResultPayload(analysis); - String sanitizedKeyStrengthsJson = serializeHighlights(payload.keyStrengths(), "keyStrengths"); - String sanitizedKeyWeaknessesJson = serializeHighlights(payload.keyWeaknesses(), "keyWeaknesses"); - if (persistIfChanged - && (!sanitizedKeyStrengthsJson.equals(analysis.getKeyStrengthsJson()) - || !sanitizedKeyWeaknessesJson.equals(analysis.getKeyWeaknessesJson()))) { - analysis.updateHighlightsJson(sanitizedKeyStrengthsJson, sanitizedKeyWeaknessesJson); - } - return payload; - } - - private List buildHighlights(List items) { - return sanitizeHighlights(items, AnalysisLlmResponse.HighlightItem::title, AnalysisLlmResponse.HighlightItem::quote); - } - - private List buildNonOverlappingHighlights( - List items, - List existingHighlights - ) { - return sanitizeHighlights( - removeOverlappingRawHighlights(items, existingHighlights), - AnalysisLlmResponse.HighlightItem::title, - AnalysisLlmResponse.HighlightItem::quote - ); - } - - private List buildMissingKeywords( - JobPosting jobPosting, - String combinedAnswers, - AnalysisLlmResponse llmResponse - ) { - if (llmResponse == null || llmResponse.missingKeywords() == null) { - return List.of(); - } - - List result = new ArrayList<>(); - Set seenKeywords = new HashSet<>(); - - for (AnalysisLlmResponse.MissingKeywordItem item : llmResponse.missingKeywords()) { - if (item == null || !StringUtils.hasText(item.keyword())) { - continue; - } - - String keyword = item.keyword().trim(); - if (keyword.length() > MAX_MISSING_KEYWORD_LENGTH) { - continue; - } - - Optional source = MissingKeywordSource.from(item.source()); - if (source.isEmpty()) { - continue; - } - if (!AnalysisSanitizationRules.isValidMissingKeyword( - keyword, - source.get(), - jobPosting == null ? "" : jobPosting.getTask(), - jobPosting == null ? "" : jobPosting.getRequirement() - )) { - continue; - } - if (AnalysisSanitizationRules.isMissingKeywordMentionedInAnswers(keyword, combinedAnswers)) { - continue; - } - - String dedupeKey = normalizeKeyword(keyword); - if (!seenKeywords.add(dedupeKey)) { - continue; - } - - result.add(new MissingKeywordResponse(keyword, source.get())); - if (result.size() >= MAX_MISSING_KEYWORDS) { - break; - } - } - - return result; - } - - private String normalizeKeyword(String keyword) { - return keyword == null ? "" : keyword.replaceAll("\\s+", "").toLowerCase(); - } - - private String serializeMissingKeywords(List missingKeywords) { - try { - return objectMapper.writeValueAsString(missingKeywords == null ? List.of() : missingKeywords); - } catch (JsonProcessingException e) { - log.warn("Failed to serialize missingKeywords. Fallback to empty array.", e); - return "[]"; - } - } - - private String serializeHighlights(List highlights, String fieldName) { - try { - return objectMapper.writeValueAsString(highlights == null ? List.of() : highlights); - } catch (JsonProcessingException e) { - log.warn("Failed to serialize {}. Fallback to empty array.", fieldName, e); - return "[]"; - } - } - - private List readHighlights(Analysis analysis, String json, String fieldName) { - if (!StringUtils.hasText(json)) { - return List.of(); - } - - try { - List highlights = objectMapper.readValue(json, HIGHLIGHTS_TYPE); - return sanitizeStoredHighlights(highlights); - } catch (Exception e) { - log.warn( - "Failed to deserialize {}. analysisId={}, fallback to empty array.", - fieldName, - analysis == null ? null : analysis.getId(), - e - ); - return List.of(); - } - } - - private List readMissingKeywords(Analysis analysis) { - if (!StringUtils.hasText(analysis.getMissingKeywordsJson())) { - return List.of(); - } - - try { - List missingKeywords = objectMapper.readValue( - analysis.getMissingKeywordsJson(), - MISSING_KEYWORDS_TYPE - ); - return sanitizeStoredMissingKeywords(missingKeywords); - } catch (Exception e) { - log.warn( - "Failed to deserialize missingKeywords. analysisId={}, fallback to empty array.", - analysis.getId(), - e - ); - return List.of(); - } - } - - private List sanitizeStoredMissingKeywords(List missingKeywords) { - if (missingKeywords == null) { - return List.of(); - } - - List result = new ArrayList<>(); - Set seenKeywords = new HashSet<>(); - - for (MissingKeywordResponse item : missingKeywords) { - if (item == null || !StringUtils.hasText(item.keyword()) || item.source() == null) { - continue; - } - - String keyword = item.keyword().trim(); - if (keyword.length() > MAX_MISSING_KEYWORD_LENGTH) { - continue; - } - - String dedupeKey = normalizeKeyword(keyword); - if (!seenKeywords.add(dedupeKey)) { - continue; - } - - result.add(new MissingKeywordResponse(keyword, item.source())); - if (result.size() >= MAX_MISSING_KEYWORDS) { - break; - } - } - - return result; - } - - private List sanitizeStoredHighlights(List highlights) { - return sanitizeHighlights(highlights, AnalysisHighlightResponse::title, AnalysisHighlightResponse::quote); - } - - private List sanitizeHighlights( - List items, - Function titleExtractor, - Function quoteExtractor - ) { - if (items == null) { - return List.of(); - } - - List result = new ArrayList<>(); - Set seenHighlights = new HashSet<>(); - - for (T item : items) { - if (item == null) { - continue; - } - - String rawTitle = titleExtractor.apply(item); - String rawQuote = quoteExtractor.apply(item); - if (!StringUtils.hasText(rawTitle) || !StringUtils.hasText(rawQuote)) { - continue; - } - - String title = rawTitle.trim(); - String quote = rawQuote.trim(); - if (title.length() > MAX_HIGHLIGHT_TITLE_LENGTH || quote.length() > MAX_HIGHLIGHT_QUOTE_LENGTH) { - continue; - } - - String dedupeKey = normalizeKeyword(title) + ":" + normalizeKeyword(quote); - if (!seenHighlights.add(dedupeKey)) { - continue; - } - - result.add(new AnalysisHighlightResponse(title, quote)); - if (result.size() >= MAX_HIGHLIGHTS) { - break; - } - } - - return result; - } - - private List removeOverlappingHighlights( - List highlights, - List existingHighlights - ) { - if (highlights == null || highlights.isEmpty()) { - return List.of(); - } - Set existingQuotes = normalizedHighlightQuotes(existingHighlights); - if (existingQuotes.isEmpty()) { - return highlights; - } - - return highlights.stream() - .filter(highlight -> highlight != null && !existingQuotes.contains(normalizeKeyword(highlight.quote()))) - .toList(); - } - - private List removeOverlappingRawHighlights( - List highlights, - List existingHighlights - ) { - if (highlights == null || highlights.isEmpty()) { - return List.of(); - } - Set existingQuotes = normalizedHighlightQuotes(existingHighlights); - if (existingQuotes.isEmpty()) { - return highlights; - } - return highlights.stream() - .filter(highlight -> highlight != null && !existingQuotes.contains(normalizeKeyword(highlight.quote()))) - .toList(); - } - - private Set normalizedHighlightQuotes(List highlights) { - if (highlights == null || highlights.isEmpty()) { - return Set.of(); - } - return highlights.stream() - .filter(highlight -> highlight != null && StringUtils.hasText(highlight.quote())) - .map(highlight -> normalizeKeyword(highlight.quote())) - .collect(Collectors.toSet()); - } - - private record AnalysisResultPayload( - List keyStrengths, - List keyWeaknesses, - List missingKeywords - ) { - } - - private void validateRequiredScores(AnalysisLlmResponse llmResponse) { - if (llmResponse == null - || llmResponse.jobFit() == null - || llmResponse.impact() == null - || llmResponse.completeness() == null) { - throw new GeneralException( - GeneralErrorCode.SERVICE_UNAVAILABLE, - "자소서 분석 AI 응답에 필수 점수 필드가 누락되었습니다." - ); - } - } - - private int validateScore(String fieldName, Integer score) { - if (score == null || score < MIN_SCORE || score > MAX_SCORE) { - throw new GeneralException( - GeneralErrorCode.SERVICE_UNAVAILABLE, - "자소서 분석 AI 응답의 " + fieldName + " 점수 범위가 올바르지 않습니다." - ); - } - return score; - } - private int calculateScore(int jobFit, int impact, int completeness) { return (int) Math.round( jobFit * JOB_FIT_WEIGHT @@ -695,31 +280,10 @@ private int findNextSentenceStart(String answer, String sentence, int fromIndex) return answer.indexOf(sentence); } - private String normalizeFeedback(String feedback) { - if (StringUtils.hasText(feedback)) { - return feedback; - } - return "자소서 분석 결과를 확인해주세요."; - } - private String defaultString(String value) { return value == null ? "" : value; } - 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) { if (!StringUtils.hasText(status)) { return null; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultValidationService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultValidationService.java new file mode 100644 index 0000000..c07f215 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultValidationService.java @@ -0,0 +1,137 @@ +package com.jobdri.jobdri_api.domain.analysis.service.core; + +import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisSanitizationRules; +import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_SCORE; +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MIN_SCORE; + +@Service +@RequiredArgsConstructor +public class AnalysisResultValidationService { + private final AnalysisInputFingerprintProvider analysisInputFingerprintProvider; + + public ValidatedAnalysisResult validateForPersistence( + List databaseQuestions, + List payloadSnapshots, + AnalysisLlmResponse llmResponse + ) { + VerifiedAnswerSnapshot answerSnapshot = verifyAnswerSnapshot(databaseQuestions, payloadSnapshots); + validateRequiredScores(llmResponse); + return new ValidatedAnalysisResult( + answerSnapshot, + validateScore("jobFit", llmResponse.jobFit()), + validateScore("impact", llmResponse.impact()), + validateScore("completeness", llmResponse.completeness()), + normalizeFeedback(llmResponse.feedback()) + ); + } + + public VerifiedAnswerSnapshot verifyAnswerSnapshot( + List databaseQuestions, + List payloadSnapshots + ) { + String databaseFingerprint = analysisInputFingerprintProvider + .createAnswerFingerprintFromQuestions(databaseQuestions); + String payloadFingerprint = analysisInputFingerprintProvider + .createAnswerFingerprint(payloadSnapshots); + if (!databaseFingerprint.equals(payloadFingerprint)) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "분석 실행 이후 자소서 답변이 변경되어 결과를 저장할 수 없습니다." + ); + } + + List immutableSnapshots = List.copyOf(payloadSnapshots); + Map answerByQuestionId = new LinkedHashMap<>(); + for (AnalysisExecutionPayload.AnswerSnapshot snapshot : immutableSnapshots) { + if (snapshot == null || snapshot.questionId() == null || !StringUtils.hasText(snapshot.answer())) { + continue; + } + if (answerByQuestionId.putIfAbsent(snapshot.questionId(), snapshot.answer()) != null) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "분석 답변 snapshot에 중복된 questionId가 있습니다. questionId=" + snapshot.questionId() + ); + } + } + return new VerifiedAnswerSnapshot(immutableSnapshots, Map.copyOf(answerByQuestionId)); + } + + public void validateRequiredScores(AnalysisLlmResponse llmResponse) { + if (llmResponse == null + || llmResponse.jobFit() == null + || llmResponse.impact() == null + || llmResponse.completeness() == null) { + throw new GeneralException( + GeneralErrorCode.SERVICE_UNAVAILABLE, + "자소서 분석 AI 응답에 필수 점수 필드가 누락되었습니다." + ); + } + } + + public int validateScore(String fieldName, Integer score) { + if (score == null || score < MIN_SCORE || score > MAX_SCORE) { + throw new GeneralException( + GeneralErrorCode.SERVICE_UNAVAILABLE, + "자소서 분석 AI 응답의 " + fieldName + " 점수 범위가 올바르지 않습니다." + ); + } + return score; + } + + public String normalizeFeedback(String feedback) { + if (StringUtils.hasText(feedback)) { + return feedback; + } + return "자소서 분석 결과를 확인해주세요."; + } + + public String normalizeImprovement( + String sentence, + String answer, + String improvement, + QuestionAnalysisStatus status + ) { + return AnalysisSanitizationRules.normalizeImprovement( + sentence, + answer, + improvement, + status == QuestionAnalysisStatus.PROVEN + ); + } + + public record VerifiedAnswerSnapshot( + List answers, + Map answerByQuestionId + ) { + public String combinedAnswers() { + return answers.stream() + .map(AnalysisExecutionPayload.AnswerSnapshot::answer) + .filter(StringUtils::hasText) + .collect(Collectors.joining("\n")); + } + } + + public record ValidatedAnalysisResult( + VerifiedAnswerSnapshot answerSnapshot, + int jobFit, + int impact, + int completeness, + String feedback + ) { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java index c2d7fff..4a6e0db 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java @@ -102,7 +102,7 @@ public AnalysisExecutionPayload prepareAnalysisExecution( return analysisPreparationService.prepare(user, mockApplyId, similarJobPostings).toExecutionPayload(); } - public AnalysisLlmResponse executeAnalysis(AnalysisExecutionPayload payload) { + private AnalysisLlmResponse executeAnalysis(AnalysisExecutionPayload payload) { return analysisGenerator.analyze(payload); } @@ -112,6 +112,17 @@ public AnalysisResponse lockAndReuseExistingAnalysis(User user, Long mockApplyId return reuseExistingAnalysisIfSameInput(mockApply, inputFingerprint); } + @Transactional + public AnalysisResponse finalizeAnalysis( + User user, + Long mockApplyId, + AnalysisExecutionPayload payload, + AnalysisLlmResponse llmResponse, + String inputFingerprint + ) { + return persistAnalysis(user, mockApplyId, payload, llmResponse, inputFingerprint); + } + @Transactional public AnalysisResponse finalizeAnalysis( User user, @@ -119,7 +130,7 @@ public AnalysisResponse finalizeAnalysis( AnalysisExecutionPayload payload, AnalysisLlmResponse llmResponse ) { - return finalizeAnalysis( + return persistAnalysis( user, mockApplyId, payload, @@ -128,8 +139,7 @@ public AnalysisResponse finalizeAnalysis( ); } - @Transactional - public AnalysisResponse finalizeAnalysis( + private AnalysisResponse persistAnalysis( User user, Long mockApplyId, AnalysisExecutionPayload payload, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisResultSanitizationService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisResultSanitizationService.java new file mode 100644 index 0000000..1925c12 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisResultSanitizationService.java @@ -0,0 +1,353 @@ +package com.jobdri.jobdri_api.domain.analysis.service.sanitization; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisHighlightResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; +import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_HIGHLIGHTS; +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_HIGHLIGHT_QUOTE_LENGTH; +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_HIGHLIGHT_TITLE_LENGTH; +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_MISSING_KEYWORDS; +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_MISSING_KEYWORD_LENGTH; + +@Service +@Slf4j +@RequiredArgsConstructor +public class AnalysisResultSanitizationService { + private static final TypeReference> MISSING_KEYWORDS_TYPE = new TypeReference<>() { + }; + private static final TypeReference> HIGHLIGHTS_TYPE = new TypeReference<>() { + }; + + private final ObjectMapper objectMapper; + + public SanitizedAnalysisContent sanitizeForPersistence( + JobPosting jobPosting, + String combinedAnswers, + AnalysisLlmResponse llmResponse + ) { + List keyStrengths = buildHighlights(llmResponse.keyStrengths()); + return new SanitizedAnalysisContent( + keyStrengths, + buildNonOverlappingHighlights(llmResponse.keyWeaknesses(), keyStrengths), + buildMissingKeywords(jobPosting, combinedAnswers, llmResponse) + ); + } + + public AnalysisResultPayload analysisResultPayload(Analysis analysis) { + List keyStrengths = readHighlights( + analysis, + analysis.getKeyStrengthsJson(), + "keyStrengths" + ); + return new AnalysisResultPayload( + keyStrengths, + removeOverlappingHighlights( + readHighlights(analysis, analysis.getKeyWeaknessesJson(), "keyWeaknesses"), + keyStrengths + ), + readMissingKeywords(analysis) + ); + } + + public AnalysisResultPayload sanitizeAndPersistAnalysisPayload(Analysis analysis, boolean persistIfChanged) { + AnalysisResultPayload payload = analysisResultPayload(analysis); + String sanitizedKeyStrengthsJson = serializeHighlights(payload.keyStrengths(), "keyStrengths"); + String sanitizedKeyWeaknessesJson = serializeHighlights(payload.keyWeaknesses(), "keyWeaknesses"); + String sanitizedMissingKeywordsJson = serializeMissingKeywords(payload.missingKeywords()); + if (persistIfChanged + && (!sanitizedKeyStrengthsJson.equals(analysis.getKeyStrengthsJson()) + || !sanitizedKeyWeaknessesJson.equals(analysis.getKeyWeaknessesJson()) + || !sanitizedMissingKeywordsJson.equals(analysis.getMissingKeywordsJson()))) { + analysis.updateResultPayloadJson( + sanitizedKeyStrengthsJson, + sanitizedKeyWeaknessesJson, + sanitizedMissingKeywordsJson + ); + } + return payload; + } + + public List buildHighlights(List items) { + return sanitizeHighlights(items, AnalysisLlmResponse.HighlightItem::title, AnalysisLlmResponse.HighlightItem::quote); + } + + public List buildNonOverlappingHighlights( + List items, + List existingHighlights + ) { + return sanitizeHighlights( + removeOverlappingRawHighlights(items, existingHighlights), + AnalysisLlmResponse.HighlightItem::title, + AnalysisLlmResponse.HighlightItem::quote + ); + } + + public List buildMissingKeywords( + JobPosting jobPosting, + String combinedAnswers, + AnalysisLlmResponse llmResponse + ) { + if (llmResponse == null || llmResponse.missingKeywords() == null) { + return List.of(); + } + + List result = new ArrayList<>(); + Set seenKeywords = new java.util.HashSet<>(); + + for (AnalysisLlmResponse.MissingKeywordItem item : llmResponse.missingKeywords()) { + if (item == null || !StringUtils.hasText(item.keyword())) { + continue; + } + + String keyword = item.keyword().trim(); + if (keyword.length() > MAX_MISSING_KEYWORD_LENGTH) { + continue; + } + + Optional source = MissingKeywordSource.from(item.source()); + if (source.isEmpty()) { + continue; + } + if (!AnalysisSanitizationRules.isValidMissingKeyword( + keyword, + source.get(), + jobPosting == null ? "" : jobPosting.getTask(), + jobPosting == null ? "" : jobPosting.getRequirement() + )) { + continue; + } + if (AnalysisSanitizationRules.isMissingKeywordMentionedInAnswers(keyword, combinedAnswers)) { + continue; + } + + String dedupeKey = normalizeKeyword(keyword); + if (!seenKeywords.add(dedupeKey)) { + continue; + } + + result.add(new MissingKeywordResponse(keyword, source.get())); + if (result.size() >= MAX_MISSING_KEYWORDS) { + break; + } + } + + return result; + } + + public String normalizeKeyword(String keyword) { + return keyword == null ? "" : keyword.replaceAll("\\s+", "").toLowerCase(); + } + + public String serializeMissingKeywords(List missingKeywords) { + try { + return objectMapper.writeValueAsString(missingKeywords == null ? List.of() : missingKeywords); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize missingKeywords. Fallback to empty array.", e); + return "[]"; + } + } + + public String serializeHighlights(List highlights, String fieldName) { + try { + return objectMapper.writeValueAsString(highlights == null ? List.of() : highlights); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize {}. Fallback to empty array.", fieldName, e); + return "[]"; + } + } + + public List readHighlights(Analysis analysis, String json, String fieldName) { + if (!StringUtils.hasText(json)) { + return List.of(); + } + + try { + List highlights = objectMapper.readValue(json, HIGHLIGHTS_TYPE); + return sanitizeStoredHighlights(highlights); + } catch (Exception e) { + log.warn( + "Failed to deserialize {}. analysisId={}, fallback to empty array.", + fieldName, + analysis == null ? null : analysis.getId(), + e + ); + return List.of(); + } + } + + public List readMissingKeywords(Analysis analysis) { + if (!StringUtils.hasText(analysis.getMissingKeywordsJson())) { + return List.of(); + } + + try { + List missingKeywords = objectMapper.readValue( + analysis.getMissingKeywordsJson(), + MISSING_KEYWORDS_TYPE + ); + return sanitizeStoredMissingKeywords(missingKeywords); + } catch (Exception e) { + log.warn( + "Failed to deserialize missingKeywords. analysisId={}, fallback to empty array.", + analysis.getId(), + e + ); + return List.of(); + } + } + + private List sanitizeStoredMissingKeywords(List missingKeywords) { + if (missingKeywords == null) { + return List.of(); + } + + List result = new ArrayList<>(); + Set seenKeywords = new java.util.HashSet<>(); + + for (MissingKeywordResponse item : missingKeywords) { + if (item == null || !StringUtils.hasText(item.keyword()) || item.source() == null) { + continue; + } + + String keyword = item.keyword().trim(); + if (keyword.length() > MAX_MISSING_KEYWORD_LENGTH) { + continue; + } + + String dedupeKey = normalizeKeyword(keyword); + if (!seenKeywords.add(dedupeKey)) { + continue; + } + + result.add(new MissingKeywordResponse(keyword, item.source())); + if (result.size() >= MAX_MISSING_KEYWORDS) { + break; + } + } + + return result; + } + + private List sanitizeStoredHighlights(List highlights) { + return sanitizeHighlights(highlights, AnalysisHighlightResponse::title, AnalysisHighlightResponse::quote); + } + + private List sanitizeHighlights( + List items, + Function titleExtractor, + Function quoteExtractor + ) { + if (items == null) { + return List.of(); + } + + List result = new ArrayList<>(); + Set seenHighlights = new java.util.HashSet<>(); + + for (T item : items) { + if (item == null) { + continue; + } + + String rawTitle = titleExtractor.apply(item); + String rawQuote = quoteExtractor.apply(item); + if (!StringUtils.hasText(rawTitle) || !StringUtils.hasText(rawQuote)) { + continue; + } + + String title = rawTitle.trim(); + String quote = rawQuote.trim(); + if (title.length() > MAX_HIGHLIGHT_TITLE_LENGTH || quote.length() > MAX_HIGHLIGHT_QUOTE_LENGTH) { + continue; + } + + String dedupeKey = normalizeKeyword(title) + ":" + normalizeKeyword(quote); + if (!seenHighlights.add(dedupeKey)) { + continue; + } + + result.add(new AnalysisHighlightResponse(title, quote)); + if (result.size() >= MAX_HIGHLIGHTS) { + break; + } + } + + return result; + } + + private List removeOverlappingHighlights( + List highlights, + List existingHighlights + ) { + if (highlights == null || highlights.isEmpty()) { + return List.of(); + } + Set existingQuotes = normalizedHighlightQuotes(existingHighlights); + if (existingQuotes.isEmpty()) { + return highlights; + } + + return highlights.stream() + .filter(highlight -> highlight != null && !existingQuotes.contains(normalizeKeyword(highlight.quote()))) + .toList(); + } + + private List removeOverlappingRawHighlights( + List highlights, + List existingHighlights + ) { + if (highlights == null || highlights.isEmpty()) { + return List.of(); + } + Set existingQuotes = normalizedHighlightQuotes(existingHighlights); + if (existingQuotes.isEmpty()) { + return highlights; + } + + return highlights.stream() + .filter(highlight -> highlight != null && !existingQuotes.contains(normalizeKeyword(highlight.quote()))) + .toList(); + } + + private Set normalizedHighlightQuotes(List highlights) { + if (highlights == null || highlights.isEmpty()) { + return Set.of(); + } + return highlights.stream() + .filter(highlight -> highlight != null && StringUtils.hasText(highlight.quote())) + .map(highlight -> normalizeKeyword(highlight.quote())) + .collect(Collectors.toSet()); + } + + public record AnalysisResultPayload( + List keyStrengths, + List keyWeaknesses, + List missingKeywords + ) { + } + + public record SanitizedAnalysisContent( + List keyStrengths, + List keyWeaknesses, + List missingKeywords + ) { + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java index ed11201..6d7aa5a 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java @@ -1733,9 +1733,12 @@ void getAnalysisReturnsEmptyMissingKeywordsWhenPersistedJsonIsMalformed() { entityManager.clear(); AnalysisResponse response = analysisService.getAnalysis(user, mockApply.getId()); + entityManager.clear(); + Analysis persisted = analysisRepository.findByMockApplyId(mockApply.getId()).orElseThrow(); assertThat(response.analysisId()).isEqualTo(saved.analysisId()); assertThat(response.missingKeywords()).isEmpty(); + assertThat(persisted.getMissingKeywordsJson()).isEqualTo("[]"); } @Test