From a43c4513b8139ef2413507470e21f5359c977ca8 Mon Sep 17 00:00:00 2001 From: wooh Date: Fri, 22 May 2026 20:43:13 +0900 Subject: [PATCH] =?UTF-8?q?[Feat]=20=EC=9E=90=EC=86=8C=EC=84=9C=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20API=20=EA=B5=AC=ED=98=84=20(#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 자소서 분석 실행 API 추가 - 자소서 분석 결과 조회 API 추가 - 저장된 문항 답변과 공고 정보를 기반으로 LLM 분석 프롬프트 구성 - LLM JSON 응답을 Analysis 및 QuestionAnalysis 엔티티로 저장 - 재분석 시 기존 분석 결과와 문항 분석 결과를 교체하도록 처리 - 분석 완료 시 MockApply 상태를 COMPLETED로 변경 - 원문에 존재하지 않는 분석 sentence는 저장하지 않도록 검증 - start/end index를 서버에서 answer 기준으로 계산 - 점수를 0~100 범위로 보정 - 분석 결과 응답 DTO 및 LLM 응답 DTO 추가 - 분석 결과 없음 예외 코드 추가 - 로컬 실행 기본 프로필을 dev로 설정하고 dev JWT 기본값 추가 - 자소서 분석 서비스 테스트 추가 --- .../controller/AnalysisController.java | 57 ++++ .../analysis/dto/llm/AnalysisLlmResponse.java | 20 ++ .../response/AnalysisQuestionResponse.java | 24 ++ .../dto/response/AnalysisResponse.java | 36 +++ .../response/QuestionAnalysisResponse.java | 23 ++ .../domain/analysis/entity/Analysis.java | 4 +- .../QuestionAnalysisRepository.java | 2 + .../analysis/service/AnalysisAiClient.java | 163 ++++++++++ .../analysis/service/AnalysisService.java | 202 +++++++++++++ .../domain/mockapply/entity/MockApply.java | 4 + .../apiPayload/code/GeneralErrorCode.java | 1 + src/main/resources/application-dev.yaml | 2 +- src/main/resources/application.yaml | 2 +- .../analysis/service/AnalysisServiceTest.java | 282 ++++++++++++++++++ 14 files changed, 817 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisController.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisQuestionResponse.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisResponse.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnalysisResponse.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisController.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisController.java new file mode 100644 index 0000000..d5e552d --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisController.java @@ -0,0 +1,57 @@ +package com.jobdri.jobdri_api.domain.analysis.controller; + +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; +import com.jobdri.jobdri_api.domain.analysis.service.AnalysisService; +import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.security.UserDetailsImpl; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/mock-applies/{mockApplyId}/analysis") +@Tag(name = "Analysis", description = "자소서 분석 API") +public class AnalysisController { + + private final AnalysisService analysisService; + + @Operation(summary = "자소서 분석 실행", description = "저장된 문항 답변과 공고 정보를 기반으로 자소서를 분석하고 결과를 저장합니다.") + @PostMapping + public ApiResponse analyze( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long mockApplyId + ) { + return ApiResponse.onSuccess( + "자소서 분석이 완료되었습니다.", + analysisService.analyze(getAuthenticatedUser(userDetails), mockApplyId) + ); + } + + @Operation(summary = "자소서 분석 결과 조회", description = "저장된 자소서 분석 결과를 조회합니다.") + @GetMapping + public ApiResponse getAnalysis( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long mockApplyId + ) { + return ApiResponse.onSuccess( + "자소서 분석 결과 조회에 성공했습니다.", + analysisService.getAnalysis(getAuthenticatedUser(userDetails), mockApplyId) + ); + } + + private com.jobdri.jobdri_api.domain.user.entity.User getAuthenticatedUser(UserDetailsImpl userDetails) { + if (userDetails == null || userDetails.getUser() == null) { + throw new GeneralException(GeneralErrorCode.MISSING_AUTH_INFO, "인증 정보가 누락되었습니다."); + } + return userDetails.getUser(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java new file mode 100644 index 0000000..0cb253e --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java @@ -0,0 +1,20 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.llm; + +import java.util.List; + +public record AnalysisLlmResponse( + Integer score, + Integer jobFit, + Integer impact, + Integer completeness, + String feedback, + List questionAnalyses +) { + public record QuestionAnalysisItem( + Long questionId, + String sentence, + String reason, + String improvement + ) { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisQuestionResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisQuestionResponse.java new file mode 100644 index 0000000..00a477f --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisQuestionResponse.java @@ -0,0 +1,24 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.response; + +import com.jobdri.jobdri_api.domain.analysis.entity.Question; + +import java.util.List; + +public record AnalysisQuestionResponse( + Long questionId, + String questionContent, + String answer, + List analyses +) { + public static AnalysisQuestionResponse of( + Question question, + List analyses + ) { + return new AnalysisQuestionResponse( + question.getId(), + question.getContent(), + question.getAnswer(), + analyses + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisResponse.java new file mode 100644 index 0000000..077fcdb --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisResponse.java @@ -0,0 +1,36 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.response; + +import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; + +import java.util.List; + +public record AnalysisResponse( + Long mockApplyId, + Long analysisId, + MockApplyStatus status, + int score, + int jobFit, + int impact, + int completeness, + String feedback, + List questions +) { + public static AnalysisResponse of( + Analysis analysis, + MockApplyStatus status, + List questions + ) { + return new AnalysisResponse( + analysis.getMockApply().getId(), + analysis.getId(), + status, + analysis.getScore(), + analysis.getJobFit(), + analysis.getImpact(), + analysis.getCompleteness(), + analysis.getFeedback(), + questions + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnalysisResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnalysisResponse.java new file mode 100644 index 0000000..c4c6f97 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnalysisResponse.java @@ -0,0 +1,23 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.response; + +import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; + +public record QuestionAnalysisResponse( + Long questionAnalysisId, + String sentence, + String reason, + String improvement, + int start, + int end +) { + public static QuestionAnalysisResponse from(QuestionAnalysis questionAnalysis) { + return new QuestionAnalysisResponse( + questionAnalysis.getId(), + questionAnalysis.getSentence(), + questionAnalysis.getReason(), + questionAnalysis.getImprovement(), + questionAnalysis.getStart(), + questionAnalysis.getEnd() + ); + } +} 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 4e0d0e6..ba6852a 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 @@ -50,7 +50,7 @@ public static Analysis create( int completeness, String feedback ) { - Analysis analysis = Analysis.builder() + return Analysis.builder() .mockApply(mockApply) .score(score) .jobFit(jobFit) @@ -58,7 +58,5 @@ public static Analysis create( .completeness(completeness) .feedback(feedback) .build(); - mockApply.assignAnalysis(analysis); - return analysis; } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/QuestionAnalysisRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/QuestionAnalysisRepository.java index 695c72f..b32013f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/QuestionAnalysisRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/QuestionAnalysisRepository.java @@ -8,4 +8,6 @@ public interface QuestionAnalysisRepository extends JpaRepository { List findAllByQuestionId(Long questionId); List findAllByAnalysisId(Long analysisId); + List findAllByAnalysisIdOrderByQuestionIdAscIdAsc(Long analysisId); + void deleteAllByAnalysisId(Long analysisId); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java new file mode 100644 index 0000000..54afd62 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java @@ -0,0 +1,163 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.openai.client.OpenAIClient; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.StructuredResponse; +import com.openai.models.responses.StructuredResponseOutputMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@Slf4j +@RequiredArgsConstructor +public class AnalysisAiClient { + + private final OpenAIClient openAIClient; + + @Value("${openai.model.cover-letter-analysis:gpt-4o-mini}") + private String analysisModel; + + public AnalysisLlmResponse analyze(JobPosting jobPosting, List questions) { + var params = ResponseCreateParams.builder() + .model(analysisModel) + .input(buildPrompt(jobPosting, questions)) + .temperature(0.2) + .text(AnalysisLlmResponse.class) + .build(); + + try { + StructuredResponse response = openAIClient.responses().create(params); + return extractStructuredContent(response); + } catch (GeneralException e) { + throw e; + } catch (Exception e) { + log.error("자소서 분석 OpenAI API 호출 오류: {}", e.getMessage(), e); + throw new GeneralException( + GeneralErrorCode.SERVICE_UNAVAILABLE, + "자소서 분석 AI 호출에 실패했습니다." + ); + } + } + + private String buildPrompt(JobPosting jobPosting, List questions) { + String questionText = questions.stream() + .map(question -> """ + - questionId: %d + question: %s + answer: %s + """.formatted( + question.getId(), + defaultString(question.getContent()), + defaultString(question.getAnswer()) + )) + .reduce("", (left, right) -> left + "\n" + right); + + return """ + [시스템 지시] + 너는 한국 채용 담당자이자 자기소개서 평가 전문가다. + 반드시 JSON만 출력한다. + 자소서 원문에 없는 sentence를 만들지 않는다. + sentence는 반드시 해당 question의 answer에 포함된 정확한 부분 문자열이어야 한다. + + [출력 형식] + { + "score": 64, + "jobFit": 70, + "impact": 55, + "completeness": 67, + "feedback": "한 줄 피드백", + "questionAnalyses": [ + { + "questionId": 1, + "sentence": "자소서 답변 안에 실제 존재하는 정확한 부분 문자열", + "reason": "문제 이유", + "improvement": "개선 예시 문장" + } + ] + } + + [평가 절차] + 1. JD의 주요 업무, 자격 요건, 우대 사항을 읽고 핵심 역량을 정리한다. + 2. 각 문항 답변이 JD와 얼마나 연결되는지 평가한다. + 3. 주장, 경험, 성과가 구체적 근거로 입증되는지 평가한다. + 4. 질문에 맞게 답했는지, 문장 흐름과 완성도가 충분한지 평가한다. + 5. 보완이 필요한 원문 문장을 최대 2~3개만 추출한다. + + [점수 기준] + - 85~100: 매우 우수 + - 70~84: 양호 + - 55~69: 개선 필요 + - 40~54: 대폭 수정 필요 + - 40 미만: 직무/JD와 거의 무관 + + [세부 기준] + - jobFit: JD와 직무 역량 매칭 + - impact: 성과 구체성, 수치, 결과 + - completeness: 문장 완성도, 논리 흐름, 질문 적합성 + + [상태 라벨 참고] + - proven: 구체적 경험/수치로 충분히 입증됨 + - mentioned: 관련 내용을 언급은 했지만 구체 근거가 부족함 + - missing: 자소서에서 아예 다루지 않음 + - fabricated: 주장은 하지만 신뢰할 수 있는 근거가 부족함 + + [약점 유형 참고] + unsupported_claim, vague_evidence, exaggeration, missing_outcome + + [채용 공고] + 회사명: %s + 직무명: %s + 주요 업무: + %s + + 자격 요건: + %s + + 우대 사항: + %s + + [자소서 문항과 답변] + %s + + [중요 규칙] + - JSON 외 텍스트, 마크다운, 코드블럭을 출력하지 않는다. + - questionAnalyses의 questionId는 입력된 questionId 중 하나만 사용한다. + - sentence는 answer에 포함된 정확한 substring만 사용한다. + - start/end index는 출력하지 않는다. 서버가 Java에서 계산한다. + - 원문 매칭이 불확실하면 questionAnalyses에 포함하지 않는다. + """.formatted( + defaultString(jobPosting.getCompany().getName()), + defaultString(jobPosting.getDetailClassification().getDetailName()), + defaultString(jobPosting.getTask()), + defaultString(jobPosting.getRequirement()), + defaultString(jobPosting.getPreferred()), + questionText + ); + } + + private AnalysisLlmResponse extractStructuredContent(StructuredResponse response) { + return response.output().stream() + .filter(item -> item.message().isPresent()) + .flatMap(item -> item.asMessage().content().stream()) + .filter(content -> content.outputText().isPresent()) + .map(StructuredResponseOutputMessage.Content::asOutputText) + .findFirst() + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "AI 응답에서 자소서 분석 결과를 찾을 수 없습니다." + )); + } + + private String defaultString(String value) { + return value == null ? "" : value; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java new file mode 100644 index 0000000..96c8dbc --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java @@ -0,0 +1,202 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +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.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.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.domain.user.entity.User; +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.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AnalysisService { + + private final MockApplyRepository mockApplyRepository; + private final QuestionRepository questionRepository; + private final AnalysisRepository analysisRepository; + private final QuestionAnalysisRepository questionAnalysisRepository; + private final AnalysisAiClient analysisAiClient; + + @Transactional + public AnalysisResponse analyze(User user, Long mockApplyId) { + MockApply mockApply = getOwnedMockApply(user, mockApplyId); + List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); + List answeredQuestions = questions.stream() + .filter(question -> StringUtils.hasText(question.getAnswer())) + .toList(); + + if (answeredQuestions.isEmpty()) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "분석할 자소서 답변이 1개 이상 필요합니다." + ); + } + + AnalysisLlmResponse llmResponse = analysisAiClient.analyze(mockApply.getJobPosting(), answeredQuestions); + replaceExistingAnalysis(mockApply); + + Analysis analysis = analysisRepository.save(Analysis.create( + mockApply, + clampScore(llmResponse.score()), + clampScore(llmResponse.jobFit()), + clampScore(llmResponse.impact()), + clampScore(llmResponse.completeness()), + normalizeFeedback(llmResponse.feedback()) + )); + + List questionAnalyses = buildQuestionAnalyses(analysis, answeredQuestions, llmResponse); + questionAnalysisRepository.saveAll(questionAnalyses); + mockApply.updateStatus(MockApplyStatus.COMPLETED); + + return getAnalysis(user, mockApplyId); + } + + public AnalysisResponse getAnalysis(User user, Long mockApplyId) { + MockApply mockApply = getOwnedMockApply(user, mockApplyId); + Analysis analysis = analysisRepository.findByMockApplyId(mockApply.getId()) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.ANALYSIS_NOT_FOUND, + "해당 모의 서류 지원의 분석 결과를 찾을 수 없습니다. mockApplyId=" + mockApplyId + )); + List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); + List questionAnalyses = + questionAnalysisRepository.findAllByAnalysisIdOrderByQuestionIdAscIdAsc(analysis.getId()); + + return toResponse(mockApply, analysis, questions, questionAnalyses); + } + + private void replaceExistingAnalysis(MockApply mockApply) { + Optional existingAnalysis = analysisRepository.findByMockApplyId(mockApply.getId()); + if (existingAnalysis.isEmpty()) { + return; + } + + Analysis analysis = existingAnalysis.get(); + mockApply.clearAnalysis(); + questionAnalysisRepository.deleteAllByAnalysisId(analysis.getId()); + analysisRepository.delete(analysis); + analysisRepository.flush(); + } + + private List buildQuestionAnalyses( + Analysis analysis, + List questions, + AnalysisLlmResponse llmResponse + ) { + Map questionMap = questions.stream() + .collect(Collectors.toMap(Question::getId, Function.identity())); + List result = new ArrayList<>(); + + if (llmResponse.questionAnalyses() == null) { + return result; + } + + for (AnalysisLlmResponse.QuestionAnalysisItem item : llmResponse.questionAnalyses()) { + if (item == null || item.questionId() == null || !StringUtils.hasText(item.sentence())) { + continue; + } + + Question question = questionMap.get(item.questionId()); + if (question == null) { + continue; + } + + String answer = question.getAnswer(); + String sentence = item.sentence(); + int start = answer.indexOf(sentence); + if (start < 0) { + continue; + } + + result.add(QuestionAnalysis.create( + question, + analysis, + sentence, + defaultString(item.reason()), + defaultString(item.improvement()), + start, + start + sentence.length() + )); + } + + return result; + } + + private AnalysisResponse toResponse( + MockApply mockApply, + Analysis analysis, + List questions, + List questionAnalyses + ) { + Map> analysesByQuestionId = questionAnalyses.stream() + .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(), questionResponses); + } + + private MockApply getOwnedMockApply(User user, Long mockApplyId) { + MockApply mockApply = mockApplyRepository.findById(mockApplyId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.MOCK_APPLY_NOT_FOUND, + "해당 모의 서류 지원을 찾을 수 없습니다. mockApplyId=" + mockApplyId + )); + + if (!mockApply.getUser().getId().equals(user.getId())) { + throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 모의 서류 지원에 접근할 수 없습니다."); + } + + return mockApply; + } + + private int clampScore(Integer score) { + if (score == null) { + return 0; + } + return Math.max(0, Math.min(100, score)); + } + + private String normalizeFeedback(String feedback) { + if (StringUtils.hasText(feedback)) { + return feedback; + } + return "자소서 분석 결과를 확인해주세요."; + } + + private String defaultString(String value) { + return value == null ? "" : value; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApply.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApply.java index 9a8eab9..fe1e4b5 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApply.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApply.java @@ -72,4 +72,8 @@ public Question addQuestion(String content, int limit, String answer) { public void assignAnalysis(Analysis analysis) { this.analysis = analysis; } + + public void clearAnalysis() { + this.analysis = null; + } } diff --git a/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java b/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java index 76f87d1..9a5cda2 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java +++ b/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java @@ -42,6 +42,7 @@ public enum GeneralErrorCode implements BaseErrorCode { // 모의 서류 지원 에러 MOCK_APPLY_NOT_FOUND(HttpStatus.NOT_FOUND, "MOCK_APPLY_4041", "모의 서류 지원을 찾을 수 없습니다."), QUESTION_NOT_FOUND(HttpStatus.NOT_FOUND, "QUESTION_4041", "문항을 찾을 수 없습니다."), + ANALYSIS_NOT_FOUND(HttpStatus.NOT_FOUND, "ANALYSIS_4041", "자소서 분석 결과를 찾을 수 없습니다."), // 유저 에러 USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_4041", "유저를 찾을 수 없습니다."); diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 3a0975f..23b5d28 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -62,7 +62,7 @@ server: jwt: secret: - key: ${JWT_SECRET_KEY} + key: ${JWT_SECRET_KEY:am9iZHJpLWxvY2FsLXNlY3JldC1rZXktZm9yLWRldmVsb3BtZW50LTIwMjYtam9iZHJp} expiration: access-token: ${JWT_ACCESS_TOKEN_EXPIRATION:3600000} refresh-token: ${JWT_REFRESH_TOKEN_EXPIRATION:1209600000} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index f9333d9..d2c0e0a 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,3 +1,3 @@ spring: profiles: - active: prod # 본인이 테스트할 환경에 따라서 바꾸기 \ No newline at end of file + active: ${SPRING_PROFILES_ACTIVE:dev} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java new file mode 100644 index 0000000..64ea3e6 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java @@ -0,0 +1,282 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; +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.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.classification.entity.Classification; +import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import com.jobdri.jobdri_api.domain.classification.entity.MiddleClassification; +import com.jobdri.jobdri_api.domain.classification.repository.ClassificationRepository; +import com.jobdri.jobdri_api.domain.classification.repository.DetailClassificationRepository; +import com.jobdri.jobdri_api.domain.company.entity.Company; +import com.jobdri.jobdri_api.domain.company.entity.CompanySize; +import com.jobdri.jobdri_api.domain.company.repository.CompanyRepository; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; +import com.jobdri.jobdri_api.domain.mockapply.entity.ApplyType; +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.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.repository.UserRepository; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class AnalysisServiceTest { + + @Autowired + private AnalysisService analysisService; + + @Autowired + private AnalysisRepository analysisRepository; + + @Autowired + private QuestionAnalysisRepository questionAnalysisRepository; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private MockApplyRepository mockApplyRepository; + + @Autowired + private JobPostingRepository jobPostingRepository; + + @Autowired + private CompanyRepository companyRepository; + + @Autowired + private ClassificationRepository classificationRepository; + + @Autowired + private DetailClassificationRepository detailClassificationRepository; + + @Autowired + private UserRepository userRepository; + + @MockBean + private AnalysisAiClient analysisAiClient; + + @Test + @DisplayName("자소서 분석을 실행하고 결과와 문항 분석을 저장한다") + void analyzeSavesAnalysis() { + User user = saveUser("analysis-save@example.com"); + MockApply mockApply = saveMockApply(user); + Question question = saveQuestion(mockApply, "지원 직무 경험을 작성해주세요.", "Spring Boot API를 개발했습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 120, + 82, + 71, + 80, + "직무 경험은 좋지만 성과 근거 보완이 필요합니다.", + List.of( + new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "Spring Boot API를 개발했습니다.", + "성과 지표가 없어 구체성이 약합니다.", + "Spring Boot API를 개발해 응답 시간을 20% 개선했습니다." + ) + ) + )); + + AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); + + assertThat(response.status()).isEqualTo(MockApplyStatus.COMPLETED); + assertThat(response.score()).isEqualTo(100); + assertThat(response.jobFit()).isEqualTo(82); + assertThat(response.impact()).isEqualTo(71); + assertThat(response.completeness()).isEqualTo(80); + assertThat(response.questions()).hasSize(1); + assertThat(response.questions().get(0).analyses()).hasSize(1); + assertThat(response.questions().get(0).analyses().get(0).start()).isEqualTo(0); + assertThat(response.questions().get(0).analyses().get(0).end()) + .isEqualTo("Spring Boot API를 개발했습니다.".length()); + assertThat(mockApply.getStatus()).isEqualTo(MockApplyStatus.COMPLETED); + assertThat(analysisRepository.findByMockApplyId(mockApply.getId())).isPresent(); + } + + @Test + @DisplayName("답변이 없는 경우 분석을 실행할 수 없다") + void analyzeThrowsWhenNoAnswers() { + User user = saveUser("analysis-empty-answer@example.com"); + MockApply mockApply = saveMockApply(user); + saveQuestion(mockApply, "지원 동기", ""); + + assertThatThrownBy(() -> analysisService.analyze(user, mockApply.getId())) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); + } + + @Test + @DisplayName("다른 사용자의 지원서는 분석할 수 없다") + void analyzeThrowsWhenUserDoesNotOwnMockApply() { + User owner = saveUser("analysis-owner@example.com"); + User other = saveUser("analysis-other@example.com"); + MockApply mockApply = saveMockApply(owner); + saveQuestion(mockApply, "지원 동기", "백엔드 개발 경험이 있습니다."); + + assertThatThrownBy(() -> analysisService.analyze(other, mockApply.getId())) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.FORBIDDEN); + } + + @Test + @DisplayName("LLM sentence가 원문에 없으면 문항 분석 저장에서 제외한다") + void analyzeSkipsSentenceNotInAnswer() { + User user = saveUser("analysis-skip-sentence@example.com"); + MockApply mockApply = saveMockApply(user); + Question question = saveQuestion(mockApply, "문제 해결 경험", "장애 로그를 분석해 원인을 찾았습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 64, + 70, + 55, + 67, + "원문 매칭 실패 문장은 제외됩니다.", + List.of( + new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "답변에 없는 문장입니다.", + "원문에 없습니다.", + "원문 기반 문장으로 개선해야 합니다." + ) + ) + )); + + AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); + + assertThat(response.questions().get(0).analyses()).isEmpty(); + Analysis analysis = analysisRepository.findByMockApplyId(mockApply.getId()).orElseThrow(); + assertThat(questionAnalysisRepository.findAllByAnalysisId(analysis.getId())).isEmpty(); + } + + @Test + @DisplayName("재분석 시 기존 분석과 문항 분석을 새 결과로 교체한다") + void analyzeReplacesExistingAnalysis() { + User user = saveUser("analysis-replace@example.com"); + MockApply mockApply = saveMockApply(user); + Question question = saveQuestion(mockApply, "성과 경험", "가입 완료율을 개선했습니다. API 응답 속도를 개선했습니다."); + when(analysisAiClient.analyze(any(), any())) + .thenReturn(new AnalysisLlmResponse( + 60, + 61, + 62, + 63, + "첫 번째 분석", + List.of(new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "가입 완료율을 개선했습니다.", + "수치가 부족합니다.", + "가입 완료율을 12% 개선했습니다." + )) + )) + .thenReturn(new AnalysisLlmResponse( + 88, + 89, + 90, + 91, + "두 번째 분석", + List.of(new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "API 응답 속도를 개선했습니다.", + "성과 기준이 더 필요합니다.", + "API 응답 속도를 300ms 단축했습니다." + )) + )); + + AnalysisResponse first = analysisService.analyze(user, mockApply.getId()); + AnalysisResponse second = analysisService.analyze(user, mockApply.getId()); + + assertThat(second.analysisId()).isNotEqualTo(first.analysisId()); + assertThat(second.score()).isEqualTo(88); + assertThat(second.feedback()).isEqualTo("두 번째 분석"); + assertThat(analysisRepository.findByMockApplyId(mockApply.getId()).orElseThrow().getScore()).isEqualTo(88); + assertThat(questionAnalysisRepository.findAllByAnalysisId(second.analysisId())).hasSize(1); + assertThat(questionAnalysisRepository.findAllByAnalysisId(first.analysisId())).isEmpty(); + } + + @Test + @DisplayName("저장된 분석 결과를 조회한다") + void getAnalysis() { + User user = saveUser("analysis-get@example.com"); + MockApply mockApply = saveMockApply(user); + Question question = saveQuestion(mockApply, "지원 동기", "서비스 개선 경험이 있습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 75, + 76, + 77, + 78, + "저장된 분석 결과입니다.", + List.of(new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "서비스 개선 경험이 있습니다.", + "구체성이 조금 부족합니다.", + "서비스 개선 경험으로 전환율을 10% 높였습니다." + )) + )); + AnalysisResponse saved = analysisService.analyze(user, mockApply.getId()); + + AnalysisResponse response = analysisService.getAnalysis(user, mockApply.getId()); + + assertThat(response.analysisId()).isEqualTo(saved.analysisId()); + assertThat(response.score()).isEqualTo(75); + assertThat(response.questions()).hasSize(1); + assertThat(response.questions().get(0).analyses()).hasSize(1); + } + + private User saveUser(String email) { + return userRepository.save(User.signup("테스트 사용자", email, "encoded-password")); + } + + private MockApply saveMockApply(User user) { + JobPosting jobPosting = saveJobPosting(user); + return mockApplyRepository.save(MockApply.create(user, jobPosting, ApplyType.ACTUAL)); + } + + private Question saveQuestion(MockApply mockApply, String content, String answer) { + return questionRepository.save(Question.create(mockApply, content, 1000, answer)); + } + + private JobPosting saveJobPosting(User user) { + Company company = companyRepository.save(Company.create("분석 테스트 기업", CompanySize.MEDIUM)); + DetailClassification detailClassification = saveDetailClassification(); + return jobPostingRepository.save(JobPosting.create( + user, + company, + detailClassification, + "주요 업무", + "자격 요건", + "우대 사항" + )); + } + + private DetailClassification saveDetailClassification() { + Classification classification = Classification.create("분석 테스트 대분류 " + System.nanoTime()); + MiddleClassification middleClassification = classification.addMiddleClassification("분석 테스트 중분류"); + DetailClassification detailClassification = middleClassification.addDetailClassification("분석 테스트 소분류"); + classificationRepository.save(classification); + return detailClassificationRepository.findById(detailClassification.getId()).orElseThrow(); + } +}