From 8810f91773da8df965fc123714ab76e98971fd0e Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Thu, 13 Aug 2026 13:15:37 +0900 Subject: [PATCH 1/7] =?UTF-8?q?[Refactor]=20AnalysisResponseAssembler=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20(#279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/AnalysisResponseAssembler.java | 84 +++++++++++++++++++ .../AnalysisResultPersistenceService.java | 76 +++-------------- 2 files changed, 95 insertions(+), 65 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResponseAssembler.java 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..8662450 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResponseAssembler.java @@ -0,0 +1,84 @@ +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.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, + AnalysisResultPersistenceService.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..f4e5181 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 @@ -6,11 +6,9 @@ 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; @@ -32,7 +30,6 @@ 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; @@ -70,6 +67,7 @@ public class AnalysisResultPersistenceService { private final AnalysisRepository analysisRepository; private final QuestionAnalysisRepository questionAnalysisRepository; private final AnalysisInputFingerprintProvider analysisInputFingerprintProvider; + private final AnalysisResponseAssembler analysisResponseAssembler; private final ObjectMapper objectMapper; @Transactional @@ -122,7 +120,13 @@ public AnalysisResponse finalizeAnalysis( lockedMockApply.updateStatus(MockApplyStatus.COMPLETED); mockApplyRepository.flush(); - return toResponse(lockedMockApply, analysis, questions, questionAnalyses, analysisResultPayload(analysis)); + return analysisResponseAssembler.toResponse( + lockedMockApply, + analysis, + questions, + questionAnalyses, + analysisResultPayload(analysis) + ); } @Transactional @@ -136,7 +140,7 @@ public AnalysisResponse getPersistedAnalysis(MockApply mockApply) { List questionAnalyses = questionAnalysisRepository.findAllByAnalysisIdOrderByQuestionIdAscIdAsc(analysis.getId()); - return toResponse( + return analysisResponseAssembler.toResponse( mockApply, analysis, questions, @@ -151,7 +155,7 @@ public AnalysisResponse getPersistedAnalysis(MockApply mockApply, Analysis analy List questionAnalyses = questionAnalysisRepository.findAllByAnalysisIdOrderByQuestionIdAscIdAsc(analysis.getId()); - return toResponse( + return analysisResponseAssembler.toResponse( mockApply, analysis, questions, @@ -317,64 +321,6 @@ private Set normalizedKeyStrengthQuotes(AnalysisLlmResponse llmResponse) .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( @@ -650,7 +596,7 @@ private Set normalizedHighlightQuotes(List hi .collect(Collectors.toSet()); } - private record AnalysisResultPayload( + record AnalysisResultPayload( List keyStrengths, List keyWeaknesses, List missingKeywords From fed079863ebb87d286c7dd25c94ca23671c8a182 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Thu, 13 Aug 2026 13:18:51 +0900 Subject: [PATCH 2/7] =?UTF-8?q?[Refactor]=20Analysis=20payload=20sanitizat?= =?UTF-8?q?ion=20=EC=A0=84=EC=9A=A9=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20(#279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/AnalysisResponseAssembler.java | 3 +- .../AnalysisResultPersistenceService.java | 329 +----------------- .../AnalysisResultSanitizationService.java | 327 +++++++++++++++++ 3 files changed, 347 insertions(+), 312 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisResultSanitizationService.java 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 index 8662450..ef9a312 100644 --- 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 @@ -6,6 +6,7 @@ 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; @@ -29,7 +30,7 @@ AnalysisResponse toResponse( Analysis analysis, List questions, List questionAnalyses, - AnalysisResultPersistenceService.AnalysisResultPayload resultPayload + AnalysisResultSanitizationService.AnalysisResultPayload resultPayload ) { Map questionById = questions.stream() .collect(Collectors.toMap(Question::getId, Function.identity())); 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 f4e5181..a469f6d 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,14 +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.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.entity.Analysis; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; @@ -16,15 +12,14 @@ 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; @@ -44,31 +39,20 @@ 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 AnalysisResponseAssembler analysisResponseAssembler; - private final ObjectMapper objectMapper; + private final AnalysisResultSanitizationService analysisResultSanitizationService; @Transactional public AnalysisResponse finalizeAnalysis( @@ -88,9 +72,14 @@ public AnalysisResponse finalizeAnalysis( 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( + List keyStrengths = analysisResultSanitizationService.buildHighlights( + llmResponse.keyStrengths() + ); + List keyWeaknesses = analysisResultSanitizationService.buildNonOverlappingHighlights( + llmResponse.keyWeaknesses(), + keyStrengths + ); + List missingKeywords = analysisResultSanitizationService.buildMissingKeywords( lockedMockApply.getJobPosting(), answerSnapshot.combinedAnswers(), llmResponse @@ -104,9 +93,9 @@ public AnalysisResponse finalizeAnalysis( impact, completeness, normalizeFeedback(llmResponse.feedback()), - serializeMissingKeywords(missingKeywords), - serializeHighlights(keyStrengths, "keyStrengths"), - serializeHighlights(keyWeaknesses, "keyWeaknesses"), + analysisResultSanitizationService.serializeMissingKeywords(missingKeywords), + analysisResultSanitizationService.serializeHighlights(keyStrengths, "keyStrengths"), + analysisResultSanitizationService.serializeHighlights(keyWeaknesses, "keyWeaknesses"), inputFingerprint )); @@ -125,7 +114,7 @@ public AnalysisResponse finalizeAnalysis( analysis, questions, questionAnalyses, - analysisResultPayload(analysis) + analysisResultSanitizationService.analysisResultPayload(analysis) ); } @@ -145,7 +134,7 @@ public AnalysisResponse getPersistedAnalysis(MockApply mockApply) { analysis, questions, questionAnalyses, - sanitizeAndPersistAnalysisPayload(analysis, true) + analysisResultSanitizationService.sanitizeAndPersistAnalysisPayload(analysis, true) ); } @@ -160,7 +149,7 @@ public AnalysisResponse getPersistedAnalysis(MockApply mockApply, Analysis analy analysis, questions, questionAnalyses, - sanitizeAndPersistAnalysisPayload(analysis, false) + analysisResultSanitizationService.sanitizeAndPersistAnalysisPayload(analysis, false) ); } @@ -274,7 +263,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(); @@ -317,292 +306,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 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()); - } - - record AnalysisResultPayload( - List keyStrengths, - List keyWeaknesses, - List missingKeywords - ) { - } - private void validateRequiredScores(AnalysisLlmResponse llmResponse) { if (llmResponse == null || llmResponse.jobFit() == null 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..5767e07 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisResultSanitizationService.java @@ -0,0 +1,327 @@ +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 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"); + if (persistIfChanged + && (!sanitizedKeyStrengthsJson.equals(analysis.getKeyStrengthsJson()) + || !sanitizedKeyWeaknessesJson.equals(analysis.getKeyWeaknessesJson()))) { + analysis.updateHighlightsJson(sanitizedKeyStrengthsJson, sanitizedKeyWeaknessesJson); + } + 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 + ) { + } +} From d83ff0137f57a24ce6da87a7977be0d5927c3f99 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Thu, 13 Aug 2026 13:21:43 +0900 Subject: [PATCH 3/7] =?UTF-8?q?[Refactor]=20Analysis=20result=20validation?= =?UTF-8?q?/normalization=20=EB=B6=84=EB=A6=AC=20(#279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AnalysisResultPersistenceService.java | 111 +++-------------- .../core/AnalysisResultValidationService.java | 112 ++++++++++++++++++ 2 files changed, 126 insertions(+), 97 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultValidationService.java 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 a469f6d..1e876b9 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 @@ -27,7 +27,6 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -39,8 +38,6 @@ 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_SCORE; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MIN_SCORE; @Service @RequiredArgsConstructor @@ -50,9 +47,9 @@ public class AnalysisResultPersistenceService { private final QuestionRepository questionRepository; private final AnalysisRepository analysisRepository; private final QuestionAnalysisRepository questionAnalysisRepository; - private final AnalysisInputFingerprintProvider analysisInputFingerprintProvider; private final AnalysisResponseAssembler analysisResponseAssembler; private final AnalysisResultSanitizationService analysisResultSanitizationService; + private final AnalysisResultValidationService analysisResultValidationService; @Transactional public AnalysisResponse finalizeAnalysis( @@ -67,11 +64,12 @@ 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()); + AnalysisResultValidationService.VerifiedAnswerSnapshot answerSnapshot = + analysisResultValidationService.verifyAnswerSnapshot(questions, payloadSnapshots); + analysisResultValidationService.validateRequiredScores(llmResponse); + int jobFit = analysisResultValidationService.validateScore("jobFit", llmResponse.jobFit()); + int impact = analysisResultValidationService.validateScore("impact", llmResponse.impact()); + int completeness = analysisResultValidationService.validateScore("completeness", llmResponse.completeness()); List keyStrengths = analysisResultSanitizationService.buildHighlights( llmResponse.keyStrengths() ); @@ -92,7 +90,7 @@ public AnalysisResponse finalizeAnalysis( jobFit, impact, completeness, - normalizeFeedback(llmResponse.feedback()), + analysisResultValidationService.normalizeFeedback(llmResponse.feedback()), analysisResultSanitizationService.serializeMissingKeywords(missingKeywords), analysisResultSanitizationService.serializeHighlights(keyStrengths, "keyStrengths"), analysisResultSanitizationService.serializeHighlights(keyWeaknesses, "keyWeaknesses"), @@ -166,49 +164,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, @@ -290,7 +245,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() @@ -310,28 +270,6 @@ private Set normalizedKeyStrengthQuotes(AnalysisLlmResponse llmResponse) .collect(Collectors.toSet()); } - 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 @@ -348,31 +286,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..291d6be --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultValidationService.java @@ -0,0 +1,112 @@ +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 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")); + } + } +} From 8d11a159885e51399103306ebf77333c71ccbac4 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Thu, 13 Aug 2026 13:24:11 +0900 Subject: [PATCH 4/7] =?UTF-8?q?[Refactor]=20AnalysisResultPersistenceServi?= =?UTF-8?q?ce=20=EC=A0=80=EC=9E=A5=20=EC=B1=85=EC=9E=84=EB=A7=8C=20?= =?UTF-8?q?=EB=82=A8=EB=8F=84=EB=A1=9D=20=EC=B6=95=EC=86=8C=20(#279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AnalysisResultPersistenceService.java | 48 ++++++++----------- .../core/AnalysisResultValidationService.java | 25 ++++++++++ .../AnalysisResultSanitizationService.java | 20 ++++++++ 3 files changed, 66 insertions(+), 27 deletions(-) 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 1e876b9..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 @@ -64,43 +64,37 @@ public AnalysisResponse finalizeAnalysis( GeneralErrorCode.MOCK_APPLY_NOT_FOUND, "해당 모의 서류 지원을 찾을 수 없습니다. mockApplyId=" + mockApply.getId() )); - AnalysisResultValidationService.VerifiedAnswerSnapshot answerSnapshot = - analysisResultValidationService.verifyAnswerSnapshot(questions, payloadSnapshots); - analysisResultValidationService.validateRequiredScores(llmResponse); - int jobFit = analysisResultValidationService.validateScore("jobFit", llmResponse.jobFit()); - int impact = analysisResultValidationService.validateScore("impact", llmResponse.impact()); - int completeness = analysisResultValidationService.validateScore("completeness", llmResponse.completeness()); - List keyStrengths = analysisResultSanitizationService.buildHighlights( - llmResponse.keyStrengths() - ); - List keyWeaknesses = analysisResultSanitizationService.buildNonOverlappingHighlights( - llmResponse.keyWeaknesses(), - keyStrengths - ); - List missingKeywords = analysisResultSanitizationService.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, - analysisResultValidationService.normalizeFeedback(llmResponse.feedback()), - analysisResultSanitizationService.serializeMissingKeywords(missingKeywords), - analysisResultSanitizationService.serializeHighlights(keyStrengths, "keyStrengths"), - analysisResultSanitizationService.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); 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 index 291d6be..c07f215 100644 --- 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 @@ -24,6 +24,22 @@ 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 @@ -109,4 +125,13 @@ public String combinedAnswers() { .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/sanitization/AnalysisResultSanitizationService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisResultSanitizationService.java index 5767e07..45eec2d 100644 --- 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 @@ -38,6 +38,19 @@ public class AnalysisResultSanitizationService { 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, @@ -324,4 +337,11 @@ public record AnalysisResultPayload( List missingKeywords ) { } + + public record SanitizedAnalysisContent( + List keyStrengths, + List keyWeaknesses, + List missingKeywords + ) { + } } From dd17a1f54a11f2b9f4a0b42433899a9d687d32de Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Thu, 13 Aug 2026 13:25:25 +0900 Subject: [PATCH 5/7] =?UTF-8?q?[Refactor]=20AnalysisService=20=EA=B3=B5?= =?UTF-8?q?=EA=B0=9C=20API=20=EC=A0=95=EB=A6=AC=20(#279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../async/AnalysisAsyncWorkerBridge.java | 9 +++-- .../service/core/AnalysisService.java | 40 ++++++++++++++----- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java index b742e80..849726f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java @@ -148,7 +148,10 @@ public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long return contextAccess.snapshot(); } - AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(userService.getUser(userId), mockApplyId); + AnalysisExecutionPayload payload = analysisService.prepareAsyncAnalysisExecution( + userService.getUser(userId), + mockApplyId + ); AnalysisWorkerContextResponse context = buildContext(userId, mockApplyId, payload); String contextSnapshot = writeContextSnapshot(context); String inputFingerprint = analysisInputFingerprintProvider.create(payload); @@ -211,7 +214,7 @@ public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteReques User user = userService.getUser(request.userId()); AnalysisWorkerContextResponse contextSnapshot = readContextSnapshot(task); - AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution( + AnalysisExecutionPayload payload = analysisService.prepareAsyncAnalysisExecution( user, request.mockApplyId(), contextSnapshot.similarJobPostings() @@ -223,7 +226,7 @@ public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteReques )) .toList()); AnalysisLlmResponse llmResponse = request.llmResponse(); - AnalysisResponse response = analysisService.finalizeAnalysis( + AnalysisResponse response = analysisService.completeAsyncAnalysis( user, request.mockApplyId(), payload, 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..e70fec7 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 @@ -89,12 +89,24 @@ public void validateAnalysisRequest(User user, Long mockApplyId) { } @Transactional(readOnly = true) - public AnalysisExecutionPayload prepareAnalysisExecution(User user, Long mockApplyId) { - return analysisPreparationService.prepare(user, mockApplyId).toExecutionPayload(); + public AnalysisExecutionPayload prepareAsyncAnalysisExecution(User user, Long mockApplyId) { + return prepareAnalysisExecution(user, mockApplyId); } @Transactional(readOnly = true) - public AnalysisExecutionPayload prepareAnalysisExecution( + public AnalysisExecutionPayload prepareAsyncAnalysisExecution( + User user, + Long mockApplyId, + List similarJobPostings + ) { + return prepareAnalysisExecution(user, mockApplyId, similarJobPostings); + } + + private AnalysisExecutionPayload prepareAnalysisExecution(User user, Long mockApplyId) { + return analysisPreparationService.prepare(user, mockApplyId).toExecutionPayload(); + } + + private AnalysisExecutionPayload prepareAnalysisExecution( User user, Long mockApplyId, List similarJobPostings @@ -102,24 +114,35 @@ public AnalysisExecutionPayload prepareAnalysisExecution( return analysisPreparationService.prepare(user, mockApplyId, similarJobPostings).toExecutionPayload(); } - public AnalysisLlmResponse executeAnalysis(AnalysisExecutionPayload payload) { + private AnalysisLlmResponse executeAnalysis(AnalysisExecutionPayload payload) { return analysisGenerator.analyze(payload); } @Transactional - public AnalysisResponse lockAndReuseExistingAnalysis(User user, Long mockApplyId, String inputFingerprint) { + private AnalysisResponse lockAndReuseExistingAnalysis(User user, Long mockApplyId, String inputFingerprint) { MockApply mockApply = lockOwnedMockApply(user, mockApplyId); return reuseExistingAnalysisIfSameInput(mockApply, inputFingerprint); } @Transactional - public AnalysisResponse finalizeAnalysis( + public AnalysisResponse completeAsyncAnalysis( + User user, + Long mockApplyId, + AnalysisExecutionPayload payload, + AnalysisLlmResponse llmResponse, + String inputFingerprint + ) { + return persistAnalysis(user, mockApplyId, payload, llmResponse, inputFingerprint); + } + + @Transactional + private AnalysisResponse finalizeAnalysis( User user, Long mockApplyId, AnalysisExecutionPayload payload, AnalysisLlmResponse llmResponse ) { - return finalizeAnalysis( + return persistAnalysis( user, mockApplyId, payload, @@ -128,8 +151,7 @@ public AnalysisResponse finalizeAnalysis( ); } - @Transactional - public AnalysisResponse finalizeAnalysis( + private AnalysisResponse persistAnalysis( User user, Long mockApplyId, AnalysisExecutionPayload payload, From 7c02ce8e28f52eec5c4ab9d959d725b10dcfb4d0 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Thu, 13 Aug 2026 13:38:23 +0900 Subject: [PATCH 6/7] =?UTF-8?q?[Fix]=20AnalysisService=20=EA=B3=B5?= =?UTF-8?q?=EA=B0=9C=20API=20=ED=98=B8=ED=99=98=EC=84=B1=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=20(#279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../async/AnalysisAsyncWorkerBridge.java | 9 +++---- .../service/core/AnalysisService.java | 24 +++++-------------- 2 files changed, 9 insertions(+), 24 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java index 849726f..b742e80 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java @@ -148,10 +148,7 @@ public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long return contextAccess.snapshot(); } - AnalysisExecutionPayload payload = analysisService.prepareAsyncAnalysisExecution( - userService.getUser(userId), - mockApplyId - ); + AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(userService.getUser(userId), mockApplyId); AnalysisWorkerContextResponse context = buildContext(userId, mockApplyId, payload); String contextSnapshot = writeContextSnapshot(context); String inputFingerprint = analysisInputFingerprintProvider.create(payload); @@ -214,7 +211,7 @@ public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteReques User user = userService.getUser(request.userId()); AnalysisWorkerContextResponse contextSnapshot = readContextSnapshot(task); - AnalysisExecutionPayload payload = analysisService.prepareAsyncAnalysisExecution( + AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution( user, request.mockApplyId(), contextSnapshot.similarJobPostings() @@ -226,7 +223,7 @@ public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteReques )) .toList()); AnalysisLlmResponse llmResponse = request.llmResponse(); - AnalysisResponse response = analysisService.completeAsyncAnalysis( + AnalysisResponse response = analysisService.finalizeAnalysis( user, request.mockApplyId(), payload, 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 e70fec7..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 @@ -89,24 +89,12 @@ public void validateAnalysisRequest(User user, Long mockApplyId) { } @Transactional(readOnly = true) - public AnalysisExecutionPayload prepareAsyncAnalysisExecution(User user, Long mockApplyId) { - return prepareAnalysisExecution(user, mockApplyId); - } - - @Transactional(readOnly = true) - public AnalysisExecutionPayload prepareAsyncAnalysisExecution( - User user, - Long mockApplyId, - List similarJobPostings - ) { - return prepareAnalysisExecution(user, mockApplyId, similarJobPostings); - } - - private AnalysisExecutionPayload prepareAnalysisExecution(User user, Long mockApplyId) { + public AnalysisExecutionPayload prepareAnalysisExecution(User user, Long mockApplyId) { return analysisPreparationService.prepare(user, mockApplyId).toExecutionPayload(); } - private AnalysisExecutionPayload prepareAnalysisExecution( + @Transactional(readOnly = true) + public AnalysisExecutionPayload prepareAnalysisExecution( User user, Long mockApplyId, List similarJobPostings @@ -119,13 +107,13 @@ private AnalysisLlmResponse executeAnalysis(AnalysisExecutionPayload payload) { } @Transactional - private AnalysisResponse lockAndReuseExistingAnalysis(User user, Long mockApplyId, String inputFingerprint) { + public AnalysisResponse lockAndReuseExistingAnalysis(User user, Long mockApplyId, String inputFingerprint) { MockApply mockApply = lockOwnedMockApply(user, mockApplyId); return reuseExistingAnalysisIfSameInput(mockApply, inputFingerprint); } @Transactional - public AnalysisResponse completeAsyncAnalysis( + public AnalysisResponse finalizeAnalysis( User user, Long mockApplyId, AnalysisExecutionPayload payload, @@ -136,7 +124,7 @@ public AnalysisResponse completeAsyncAnalysis( } @Transactional - private AnalysisResponse finalizeAnalysis( + public AnalysisResponse finalizeAnalysis( User user, Long mockApplyId, AnalysisExecutionPayload payload, From 70b2ec0ecdb7bd78c581e96419aab7cde4ca069a Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Thu, 13 Aug 2026 13:40:42 +0900 Subject: [PATCH 7/7] =?UTF-8?q?[Fix]=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jobdri_api/domain/analysis/entity/Analysis.java | 9 +++++++++ .../AnalysisResultSanitizationService.java | 10 ++++++++-- .../analysis/service/core/AnalysisServiceTest.java | 3 +++ 3 files changed, 20 insertions(+), 2 deletions(-) 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/sanitization/AnalysisResultSanitizationService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisResultSanitizationService.java index 45eec2d..1925c12 100644 --- 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 @@ -71,10 +71,16 @@ public AnalysisResultPayload sanitizeAndPersistAnalysisPayload(Analysis analysis 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()))) { - analysis.updateHighlightsJson(sanitizedKeyStrengthsJson, sanitizedKeyWeaknessesJson); + || !sanitizedKeyWeaknessesJson.equals(analysis.getKeyWeaknessesJson()) + || !sanitizedMissingKeywordsJson.equals(analysis.getMissingKeywordsJson()))) { + analysis.updateResultPayloadJson( + sanitizedKeyStrengthsJson, + sanitizedKeyWeaknessesJson, + sanitizedMissingKeywordsJson + ); } return payload; } 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