diff --git a/src/main/java/UMC/career_mate/domain/answer/repository/AnswerRepository.java b/src/main/java/UMC/career_mate/domain/answer/repository/AnswerRepository.java index ebd66b7..fa385cc 100644 --- a/src/main/java/UMC/career_mate/domain/answer/repository/AnswerRepository.java +++ b/src/main/java/UMC/career_mate/domain/answer/repository/AnswerRepository.java @@ -1,6 +1,7 @@ package UMC.career_mate.domain.answer.repository; import UMC.career_mate.domain.answer.Answer; +import UMC.career_mate.domain.job.Job; import UMC.career_mate.domain.member.Member; import UMC.career_mate.domain.question.Question; import UMC.career_mate.domain.template.enums.TemplateType; @@ -12,18 +13,35 @@ import java.util.Optional; public interface AnswerRepository extends JpaRepository { + @Query(""" - SELECT a FROM Answer a - WHERE a.member = :member AND a.question.template.templateType = :templateType - """) - List findByMemberAndTemplateType(@Param("member") Member member, @Param("templateType") TemplateType templateType); + SELECT a FROM Answer a + WHERE a.member = :member AND a.question.template.templateType = :templateType + """) + List findByMemberAndTemplateType(@Param("member") Member member, + @Param("templateType") TemplateType templateType); - Optional findByMemberAndQuestionAndSequence(Member member, Question question, Long sequence); + Optional findByMemberAndQuestionAndSequence(Member member, Question question, + Long sequence); @Query(""" - SELECT COUNT(a) > 0 FROM Answer a - WHERE a.member = :member AND a.question.template.templateType = :templateType - """) - boolean existsByMemberAndTemplateType(@Param("member") Member member, @Param("templateType") TemplateType templateType); + SELECT COUNT(a) > 0 FROM Answer a + WHERE a.member = :member AND a.question.template.templateType = :templateType + """) + boolean existsByMemberAndTemplateType(@Param("member") Member member, + @Param("templateType") TemplateType templateType); + + @Query("select a from Answer a where a.member = :member and a.question.template.templateType in :templateTypes and a.question.order = :order and a.sequence = :sequence and a.question.template.job = :job") + List findByMemberAndTemplateTypesAndOrderAndJob(@Param("member") Member member, + @Param("templateTypes") List templateTypes, @Param("order") int order, + @Param("sequence") int sequence, @Param("job") Job job); + + @Query("select a from Answer a where a.member = :member and a.question.content = :content and a.question.template.templateType = :templateType and a.question.template.job = :job") + List findAnswersByMemberAndQuestionContentAndTemplateTypeAndJob( + @Param("member") Member member, @Param("content") String content, + @Param("templateType") TemplateType templateType, @Param("job") Job job); + @Query("select a from Answer a where a.member = :member and a.question.template.templateType in :templateTypes and a.question.template.job = :job") + List findByMemberAndTemplateTypesAndJob(@Param("member") Member member, + @Param("templateTypes") List templateTypes, @Param("job") Job job); } diff --git a/src/main/java/UMC/career_mate/domain/answer/service/AnswerCommandService.java b/src/main/java/UMC/career_mate/domain/answer/service/AnswerCommandService.java index c80ff23..1c4d37a 100644 --- a/src/main/java/UMC/career_mate/domain/answer/service/AnswerCommandService.java +++ b/src/main/java/UMC/career_mate/domain/answer/service/AnswerCommandService.java @@ -51,6 +51,11 @@ public void updateAnswerList(Member member, AnswerCreateOrUpdateDTO request) { .orElseThrow(() -> new GeneralException(CommonErrorCode.NOT_FOUND_ANSWER)); existingAnswer.updateContent(answerInfo.content()); + + // 질문 order 1의 답변 sequence 1은 수정일을 매번 업데이트 -> recruit 조회 로직에서 사용 + if (question.getOrder() == 1 && existingAnswer.getSequence() == 1) { + existingAnswer.setUpdatedAt(); + } } } } diff --git a/src/main/java/UMC/career_mate/domain/chatgpt/GptAnswer.java b/src/main/java/UMC/career_mate/domain/chatgpt/GptAnswer.java new file mode 100644 index 0000000..c48649f --- /dev/null +++ b/src/main/java/UMC/career_mate/domain/chatgpt/GptAnswer.java @@ -0,0 +1,52 @@ +package UMC.career_mate.domain.chatgpt; + +import UMC.career_mate.domain.member.Member; +import UMC.career_mate.domain.recruit.enums.RecruitKeyword; +import UMC.career_mate.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Table(name = "gpt_answers") +@SQLRestriction("deleted_at is NULL") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class GptAnswer extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "gpt_answer_id") + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @Enumerated(EnumType.STRING) + private RecruitKeyword recruitKeyword; + + private int careerYear; + + public void updateData(RecruitKeyword recruitKeyword, int careerYear) { + this.recruitKeyword = recruitKeyword; + this.careerYear = careerYear; + super.setUpdatedAt(); + } +} diff --git a/src/main/java/UMC/career_mate/domain/chatgpt/converter/GptAnswerConverter.java b/src/main/java/UMC/career_mate/domain/chatgpt/converter/GptAnswerConverter.java new file mode 100644 index 0000000..13409b8 --- /dev/null +++ b/src/main/java/UMC/career_mate/domain/chatgpt/converter/GptAnswerConverter.java @@ -0,0 +1,16 @@ +package UMC.career_mate.domain.chatgpt.converter; + +import UMC.career_mate.domain.chatgpt.GptAnswer; +import UMC.career_mate.domain.member.Member; +import UMC.career_mate.domain.recruit.enums.RecruitKeyword; + +public class GptAnswerConverter { + + public static GptAnswer toEntity(Member member, RecruitKeyword recruitKeyword, int careerYear) { + return GptAnswer.builder() + .member(member) + .recruitKeyword(recruitKeyword) + .careerYear(careerYear) + .build(); + } +} diff --git a/src/main/java/UMC/career_mate/domain/chatgpt/repository/GptAnswerRepository.java b/src/main/java/UMC/career_mate/domain/chatgpt/repository/GptAnswerRepository.java new file mode 100644 index 0000000..50f55c9 --- /dev/null +++ b/src/main/java/UMC/career_mate/domain/chatgpt/repository/GptAnswerRepository.java @@ -0,0 +1,11 @@ +package UMC.career_mate.domain.chatgpt.repository; + +import UMC.career_mate.domain.chatgpt.GptAnswer; +import UMC.career_mate.domain.member.Member; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface GptAnswerRepository extends JpaRepository { + + Optional findByMember(Member member); +} diff --git a/src/main/java/UMC/career_mate/domain/chatgpt/service/ChatGptService.java b/src/main/java/UMC/career_mate/domain/chatgpt/service/ChatGptService.java index 6aa7594..75de3b9 100644 --- a/src/main/java/UMC/career_mate/domain/chatgpt/service/ChatGptService.java +++ b/src/main/java/UMC/career_mate/domain/chatgpt/service/ChatGptService.java @@ -2,12 +2,10 @@ import UMC.career_mate.domain.chatgpt.dto.api.response.ChatCompletionResponse; import UMC.career_mate.domain.chatgpt.dto.request.ChatGPTRequestDTO; -import UMC.career_mate.domain.chatgpt.dto.response.FilterConditionDTO; import UMC.career_mate.domain.chatgpt.dto.api.request.GptRequest; import UMC.career_mate.domain.chatgpt.dto.api.request.GptRequest.Message; import UMC.career_mate.domain.recruit.enums.RecruitKeyword; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.util.Collections; @@ -32,29 +30,48 @@ public class ChatGptService { @Value("${chat-gpt.service-key}") private String serviceKey; - private static final String GPT_REQUEST_FORMAT_PREFIX_FOR_CAREER_YEAR = - "다음 이력서 데이터를 기반으로 추천 직무와 총 경력을 계산해줘. " + - "직무는 'BACKEND_SPRING', 'BACKEND_NODE', 'BACKEND_DJANGO', 'FRONTEND', 'DESIGNER', 'PM' 중 하나로 선택하고, " + - "총 경력은 각 회사의 근무 기간을 더해서 소수 말고 정수로만 결과를 계산해줘. " + - "응답은 JSON으로 {\"직무\": \"추천 직무\", \"경력\": \"총 경력(년차)\"} 형식으로 작성해줘."; + private static final String GPT_REQUEST_FORMAT_POSTFIX_FOR_CAREER_YEAR = + "이 사람의 경력을 계산해서 앞뒤 설명 하지말고 정수로만 올바른 답변 예시와 같은 형식으로 답변해줘. " + + "올바론 답변 예시) 5, 잘못된 답변 예시 1) 5년, 잘못된 답변 예시 2) 5 years, 잘못된 답변 예시 3) 이 사람의 경력은 5년"; + + private static final String GPT_REQUEST_FORMAT_POSTFIX_FOR_RECRUIT_KEYWORD = + "이 사람의 직무를 내가 제시한 보기들 중에서 하나만 골라서 답변해줘.\n" + + "직무 보기 :'BACKEND', 'BACKEND_SPRING', 'BACKEND_NODE', 'BACKEND_DJANGO', 'FRONTEND', 'DESIGNER', 'PM', " + + + "이 중에서 적절한 직무가 없다면 'MISMATCH'로 답변해줘.\n" + + "앞뒤 설명하지 말고 직무만 답변해줘."; private static final String GPT_REQUEST_FORMAT_PREFIX_FOR_COMMENT = " = 사용자 이름이고, 다음 이력서 데이터를 기반으로 사용자의 강점을 어필할 수 있고, " + - "어떤 포지션이 어울리는지 어떤 경험을 어필하면 좋을지 그런 내용들로 추천 조언 문구를 생성해줘. " + - "'~~한 경험이 있는 000님, ~~한 경험을 어필해보면 어때요?', 또는 '~~포지에 지원해보 건 어떨까요? ~~에 강점을 드러낼 수 있을 것 같아요.'" + - "와 비슷한 형식이지만 꼭 이런 형식이 아니더라도 너만의 스타일로 문구를 생성해줘." + - "말투는 \"입니다\"같은 딱딱말 말투는 사용하지 말고, \"요.\"같이 부드럽게 표현해줘. " + - "답변은 문구만 답변해줘. 문구를 생성할 때 이력서 데이터의 회사 이름은 제외해줘. 답변에서 엔터나 - 같은건 빼줘."; + "어떤 포지션이 어울리는지 어떤 경험을 어필하면 좋을지 그런 내용들로 추천 조언 문구를 생성해줘. " + + "'~~한 경험이 있는 000님, ~~한 경험을 어필해보면 어때요?', 또는 '~~포지에 지원해보 건 어떨까요? ~~에 강점을 드러낼 수 있을 것 같아요.'" + + + "와 비슷한 형식이지만 꼭 이런 형식이 아니더라도 너만의 스타일로 문구를 생성해줘." + + "말투는 \"입니다\"같은 딱딱말 말투는 사용하지 말고, \"요.\"같이 부드럽게 표현해줘. " + + "답변은 문구만 답변해줘. 문구를 생성할 때 이력서 데이터의 회사 이름은 제외해줘. 답변에서 엔터나 - 같은건 빼줘."; + + public int getCareerYear(String chatGptRequestContent) { + GptRequest gptRequest = createGptRequest( + chatGptRequestContent + GPT_REQUEST_FORMAT_POSTFIX_FOR_CAREER_YEAR); + + ObjectMapper om = new ObjectMapper(); + String gptAnswer = getGptAnswer(om, gptRequest); - public FilterConditionDTO getFilterCondition(ChatGPTRequestDTO chatGPTRequestDTO) { + log.info("\ngptRequest : {}, gptAnswer : {} ", gptRequest, gptAnswer); + + return Integer.parseInt(gptAnswer); + } + + public RecruitKeyword getRecruitKeyword(String chatGptRequestContent) { GptRequest gptRequest = createGptRequest( - GPT_REQUEST_FORMAT_PREFIX_FOR_CAREER_YEAR + " " + chatGPTRequestDTO.content()); + chatGptRequestContent + GPT_REQUEST_FORMAT_POSTFIX_FOR_RECRUIT_KEYWORD); ObjectMapper om = new ObjectMapper(); String gptAnswer = getGptAnswer(om, gptRequest); - // "직무"와 "경력" 추출 - return extractJobAndCareer(om, gptAnswer); + log.info("\ngptRequest : {}, gptAnswer : {} ", gptRequest, gptAnswer); + + return RecruitKeyword.getRecruitKeywordFromGptAnswerJob(gptAnswer); } public String getComment(ChatGPTRequestDTO chatGPTRequestDTO) { @@ -153,28 +170,4 @@ private void logGptUsage(ChatCompletionResponse.Usage usage) { private String parseGptResponse(ChatCompletionResponse response) { return response.choices().get(0).message().content(); } - - private FilterConditionDTO extractJobAndCareer(ObjectMapper om, String gptAnswer) { - JsonNode rootNode = null; - try { - rootNode = om.readTree(gptAnswer); - } catch (JsonProcessingException e) { - log.error("직무 경력 gpt 답변 결과 파싱 중 에러"); - throw new RuntimeException(e); - } - - String job = rootNode.get("직무").asText(); - String career = rootNode.get("경력").asText(); - int careerYear = Integer.parseInt(career.split("년")[0]); - - log.info("추천 직무: {}", job); - log.info("추천 경력: {}", career); - - RecruitKeyword recruitKeyword = RecruitKeyword.getRecruitKeyword(job); - - return FilterConditionDTO.builder() - .recruitKeyword(recruitKeyword) - .careerYear(careerYear) - .build(); - } } diff --git a/src/main/java/UMC/career_mate/domain/chatgpt/service/GptAnswerCommandService.java b/src/main/java/UMC/career_mate/domain/chatgpt/service/GptAnswerCommandService.java new file mode 100644 index 0000000..9cf470a --- /dev/null +++ b/src/main/java/UMC/career_mate/domain/chatgpt/service/GptAnswerCommandService.java @@ -0,0 +1,29 @@ +package UMC.career_mate.domain.chatgpt.service; + +import UMC.career_mate.domain.chatgpt.GptAnswer; +import UMC.career_mate.domain.chatgpt.converter.GptAnswerConverter; +import UMC.career_mate.domain.chatgpt.repository.GptAnswerRepository; +import UMC.career_mate.domain.member.Member; +import UMC.career_mate.domain.recruit.enums.RecruitKeyword; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(propagation = Propagation.REQUIRES_NEW) +public class GptAnswerCommandService { + + private final GptAnswerRepository gptAnswerRepository; + + public void saveGptAnswer(Member member, RecruitKeyword recruitKeyword, int careerYear) { + GptAnswer gptAnswer = GptAnswerConverter.toEntity(member, recruitKeyword, careerYear); + gptAnswerRepository.save(gptAnswer); + } + + public void updateGptAnswer(GptAnswer gptAnswer, RecruitKeyword recruitKeyword, int careerYear) { + gptAnswer.updateData(recruitKeyword, careerYear); + gptAnswerRepository.save(gptAnswer); + } +} diff --git a/src/main/java/UMC/career_mate/domain/recruit/controller/RecruitController.java b/src/main/java/UMC/career_mate/domain/recruit/controller/RecruitController.java index fbc25ea..5a911ea 100644 --- a/src/main/java/UMC/career_mate/domain/recruit/controller/RecruitController.java +++ b/src/main/java/UMC/career_mate/domain/recruit/controller/RecruitController.java @@ -46,7 +46,7 @@ public ApiResponse>> getRecommendRecru @RequestParam(defaultValue = "6", required = false) int size, @RequestParam(defaultValue = "POSTING_DESC", required = false) RecruitSortType recruitSortType, @LoginMember Member member - ) { + ) { return ApiResponse.onSuccess( recruitQueryService.getRecommendRecruitList(page, size, recruitSortType, member)); } @@ -54,11 +54,12 @@ public ApiResponse>> getRecommendRecru @Operation( summary = "채용 공고 요약 페이지 조회 API", description = """ - 채용 공고 요약 페이지를 조회하는 API입니다.\n\n - recruitId : 조회하려는 채용 공고 pk 값 - """) + 채용 공고 요약 페이지를 조회하는 API입니다.\n\n + recruitId : 조회하려는 채용 공고 pk 값 + """) @GetMapping("/{recruitId}") - public ResponseEntity> getRecruitInfo(@PathVariable Long recruitId, @LoginMember Member member) { + public ResponseEntity> getRecruitInfo(@PathVariable Long recruitId, + @LoginMember Member member) { return ResponseEntity.ok( ApiResponse.onSuccess(recruitQueryService.findRecruitInfo(member, recruitId))); } diff --git a/src/main/java/UMC/career_mate/domain/recruit/converter/RecruitConverter.java b/src/main/java/UMC/career_mate/domain/recruit/converter/RecruitConverter.java index 0db232b..f15fc95 100644 --- a/src/main/java/UMC/career_mate/domain/recruit/converter/RecruitConverter.java +++ b/src/main/java/UMC/career_mate/domain/recruit/converter/RecruitConverter.java @@ -1,10 +1,12 @@ package UMC.career_mate.domain.recruit.converter; import UMC.career_mate.domain.recruit.Recruit; +import UMC.career_mate.domain.recruit.dto.FilterConditionDTO; import UMC.career_mate.domain.recruit.dto.api.SaraminResponseDTO.Job; import UMC.career_mate.domain.recruit.dto.response.RecommendRecruitDTO; import UMC.career_mate.domain.recruit.dto.response.RecruitInfoDTO; import UMC.career_mate.domain.recruit.enums.EducationLevel; +import UMC.career_mate.domain.recruit.enums.RecruitKeyword; import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; @@ -78,6 +80,13 @@ public static RecruitInfoDTO toRecruitInfoDTO(String comment, Recruit recruit) { .build(); } + public static FilterConditionDTO toFilterConditionDTO(RecruitKeyword recruitKeyword, int careerYear) { + return FilterConditionDTO.builder() + .recruitKeyword(recruitKeyword) + .careerYear(careerYear) + .build(); + } + private static String formatDeadLine(Recruit recruit) { LocalDate today = LocalDate.now(); LocalDate targetDate = recruit.getDeadLine().toLocalDate(); diff --git a/src/main/java/UMC/career_mate/domain/chatgpt/dto/response/FilterConditionDTO.java b/src/main/java/UMC/career_mate/domain/recruit/dto/FilterConditionDTO.java similarity index 78% rename from src/main/java/UMC/career_mate/domain/chatgpt/dto/response/FilterConditionDTO.java rename to src/main/java/UMC/career_mate/domain/recruit/dto/FilterConditionDTO.java index 9b47054..acb1b0d 100644 --- a/src/main/java/UMC/career_mate/domain/chatgpt/dto/response/FilterConditionDTO.java +++ b/src/main/java/UMC/career_mate/domain/recruit/dto/FilterConditionDTO.java @@ -1,4 +1,4 @@ -package UMC.career_mate.domain.chatgpt.dto.response; +package UMC.career_mate.domain.recruit.dto; import UMC.career_mate.domain.recruit.enums.RecruitKeyword; import lombok.Builder; diff --git a/src/main/java/UMC/career_mate/domain/recruit/enums/RecruitKeyword.java b/src/main/java/UMC/career_mate/domain/recruit/enums/RecruitKeyword.java index 94ee4e5..2995f6c 100644 --- a/src/main/java/UMC/career_mate/domain/recruit/enums/RecruitKeyword.java +++ b/src/main/java/UMC/career_mate/domain/recruit/enums/RecruitKeyword.java @@ -1,9 +1,22 @@ package UMC.career_mate.domain.recruit.enums; +import UMC.career_mate.domain.job.Job; import java.util.List; public enum RecruitKeyword { + BACKEND { + @Override + public List getIncludeKeywordList() { + return List.of("Back", "Backend", "Back-end", "BACK", "백엔드", "서버", "시스템"); + } + + @Override + public List getExcludeKeywordList() { + return null; + } + } + , BACKEND_SPRING { @Override public List getIncludeKeywordList() { @@ -75,13 +88,23 @@ public List getExcludeKeywordList() { ; - public static RecruitKeyword getRecruitKeyword(String name) { + public static RecruitKeyword getRecruitKeywordFromGptAnswerJob(String name) { for (RecruitKeyword recruitKeyword : RecruitKeyword.values()) { if (recruitKeyword.toString().equals(name)) { return recruitKeyword; } } - return null; // 일치하는거 없으면 일단 Null 반환 처리 + return null; // 일치하는거 없으면 null + } + + public static RecruitKeyword getRecruitKeywordFromProfileJob(Job job) { + return switch (job.getName()) { + case "백엔드 개발자" -> BACKEND; + case "프론트엔드 개발자" -> FRONTEND; + case "PM(Product/Project Manager)" -> PM; + case "Designer" -> DESIGNER; + default -> throw new RuntimeException("추가 필요한 직무" + job.getName()); + }; } diff --git a/src/main/java/UMC/career_mate/domain/recruit/service/RecruitQueryService.java b/src/main/java/UMC/career_mate/domain/recruit/service/RecruitQueryService.java index 621ac9d..eec9ee8 100644 --- a/src/main/java/UMC/career_mate/domain/recruit/service/RecruitQueryService.java +++ b/src/main/java/UMC/career_mate/domain/recruit/service/RecruitQueryService.java @@ -1,22 +1,35 @@ package UMC.career_mate.domain.recruit.service; +import UMC.career_mate.domain.answer.Answer; +import UMC.career_mate.domain.answer.repository.AnswerRepository; +import UMC.career_mate.domain.chatgpt.GptAnswer; import UMC.career_mate.domain.chatgpt.dto.request.ChatGPTRequestDTO; -import UMC.career_mate.domain.chatgpt.dto.response.FilterConditionDTO; +import UMC.career_mate.domain.chatgpt.service.GptAnswerCommandService; +import UMC.career_mate.domain.recruit.dto.FilterConditionDTO; +import UMC.career_mate.domain.chatgpt.repository.GptAnswerRepository; import UMC.career_mate.domain.chatgpt.service.ChatGptService; +import UMC.career_mate.domain.job.Job; import UMC.career_mate.domain.member.Member; import UMC.career_mate.domain.recruit.Recruit; import UMC.career_mate.domain.recruit.converter.RecruitConverter; import UMC.career_mate.domain.recruit.dto.response.RecommendRecruitDTO; import UMC.career_mate.domain.recruit.dto.response.RecruitInfoDTO; import UMC.career_mate.domain.recruit.enums.EducationLevel; +import UMC.career_mate.domain.recruit.enums.RecruitKeyword; import UMC.career_mate.domain.recruit.enums.RecruitSortType; import UMC.career_mate.domain.recruit.repository.RecruitRepository; import UMC.career_mate.domain.recruitScrap.repository.RecruitScrapRepository; +import UMC.career_mate.domain.template.enums.TemplateType; import UMC.career_mate.global.common.PageResponseDTO; import UMC.career_mate.global.response.exception.GeneralException; import UMC.career_mate.global.response.exception.code.CommonErrorCode; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; @@ -24,6 +37,7 @@ import java.util.List; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -33,15 +47,23 @@ public class RecruitQueryService { private final ChatGptService chatGptService; private final RecruitScrapRepository recruitScrapRepository; - public PageResponseDTO> getRecommendRecruitList(int page, int size, - RecruitSortType recruitSortType, Member member) { + private final AnswerRepository answerRepository; - // TODO: db에서 사용자의 템플릿 데이터 가져오는걸로 수정 - // TODO: 템플릿 수정일과 gpt 요청일 비교 후 수정일이 더 최근인 경우 gpt에 템플릿 분석 요청, 아니면 저장해둔 경력 데이터 재사용 로직 추가 - ChatGPTRequestDTO chatGPTRequestDTO = createDummyData(member); + private final GptAnswerRepository gptAnswerRepository; + private final GptAnswerCommandService gptAnswerCommandService; + + private final static int ONLY_ENTER_LENGTH = 2; + private final static int NO_INTERN_EXPERIENCE = 0; + private final static String PROJECT_PREFIX = "프로젝트 경험 데이터 : "; + private final static String INTERN_PREFIX = "인턴 경험 데이터 : "; + private final static String PROJECT_QUESTION_CONTENT_PERIOD = "기간"; + private final static String INTERN_QUESTION_CONTENT_PERIOD = "근무기간"; - FilterConditionDTO filterCondition = chatGptService.getFilterCondition(chatGPTRequestDTO); + public PageResponseDTO> getRecommendRecruitList(int page, int size, + RecruitSortType recruitSortType, Member member) { + FilterConditionDTO filterCondition = createFilterCondition(member); + // 사용자 프로필의 학력과 상태로 검색에서 사용할 EducationLevel로 치환한다. EducationLevel educationLevel = EducationLevel.getEducationLevelFromMemberInfo( member.getEducationLevel(), member.getEducationStatus()); @@ -66,6 +88,153 @@ public RecruitInfoDTO findRecruitInfo(Member member, Long recruitId) { return RecruitConverter.toRecruitInfoDTO(comment, recruit); } + private FilterConditionDTO createFilterCondition(Member member) { + Optional findGptAnswer = gptAnswerRepository.findByMember(member); + + // 질문의 order 1번이고 인턴 템플릿 답변 데이터와 프로젝트 템플릿 답변 데이터의 1번을 조회 -> 최대 2개 조회. (updated_at 확인) + List internAndProjectAnswersFromFirstQuestion = answerRepository.findByMemberAndTemplateTypesAndOrderAndJob( + member, List.of(TemplateType.INTERN_EXPERIENCE, TemplateType.PROJECT_EXPERIENCE), 1, 1, + member.getJob()); + + int careerYear; + RecruitKeyword recruitKeyword; + if (findGptAnswer.isEmpty()) { // GptAnswer이 없는 경우 + log.info("GptAnswer가 없는 유저인 상황, GptAnswer에 대해 insert가 발생한다"); + + // 인턴 템플릿 근무기간 답변 데이터에 한해서 경력을 계산한다. + careerYear = calculateCareerYear(member); + + // 인턴 + 프로젝트 템플릿 답변 데이터에서 기간을 제외한 데이터로 추천 직무를 가져온다. + recruitKeyword = calculateRecruitKeyword(member); + + gptAnswerCommandService.saveGptAnswer(member, recruitKeyword, careerYear); + } else { + GptAnswer gptAnswer = findGptAnswer.get(); + + log.info("internAndProjectAnswersFromFirstQuestion 크기 : {}", + internAndProjectAnswersFromFirstQuestion.size()); + + List updatedAnswerList = internAndProjectAnswersFromFirstQuestion.stream() + .filter(answer -> answer.getUpdatedAt().isAfter(gptAnswer.getUpdatedAt())) + .toList(); + + // GptAnswer 마지막 요청 이후에 프로젝트 or 인턴 템플릿 답변이 수정된 경우 + if (!updatedAnswerList.isEmpty()) { + log.info("마지막 Gpt 요청 이후 인턴 or 프로젝트 답변 수정한 상황, GptAnswer의 업데이트가 발생한다."); + + // 인턴 템플릿 근무기간 답변 데이터에 한해서 경력을 계산한다. + careerYear = calculateCareerYear(member); + + // 인턴 + 프로젝트 템플릿 답변 데이터에서 기간을 제외한 데이터로 추천 직무를 가져온다. + recruitKeyword = calculateRecruitKeyword(member); + + gptAnswerCommandService.updateGptAnswer(gptAnswer, recruitKeyword, careerYear); + } else { + log.info("마지막 Gpt 요청 이후 인턴 or 프로젝트 답변 수정한 적이 없는 상황, GptAnswer에 대해 아무 일도 일어나지 않는다."); + careerYear = gptAnswer.getCareerYear(); + recruitKeyword = gptAnswer.getRecruitKeyword(); + } + } + + return RecruitConverter.toFilterConditionDTO(recruitKeyword, careerYear); + } + + private int calculateCareerYear(Member member) { + Job job = member.getJob(); + + // 인턴 템플릿의 근무기간 답변 데이터를 가져온다. 0개 or 2개 + List periodAnswerList = answerRepository.findAnswersByMemberAndQuestionContentAndTemplateTypeAndJob( + member, INTERN_QUESTION_CONTENT_PERIOD, TemplateType.INTERN_EXPERIENCE, job); + + if (periodAnswerList.isEmpty()) { + log.info("아직 답변을 안한 상태 (템플릿 답변 작성 없이 바로 공고 조회를 한 경우) -> 경력 0년 반환"); + return NO_INTERN_EXPERIENCE; + } + + StringBuilder contentBuilder = new StringBuilder(); + + periodAnswerList.forEach( + answer -> contentBuilder.append(answer.getContent() + "\n") + ); + + if (contentBuilder.length() == ONLY_ENTER_LENGTH) { + log.info("인턴 근무 기간이 빈 문자열인 경우 -> 경력 0년 반환"); + return NO_INTERN_EXPERIENCE; + } + + // 근무 기간이 입력되어 있는 경우 -> gpt에게 경력 계산 요청 + return chatGptService.getCareerYear(contentBuilder.toString()); + } + + private RecruitKeyword calculateRecruitKeyword(Member member) { + Job job = member.getJob(); + + // 프로젝트 템플릿의 답변, 인턴 템플릿의 답변을 가져온다. + Map> templateTypeByAnswers = getTemplateTypeByAnswers( + member, job); + + List projectAnswers = templateTypeByAnswers.getOrDefault( + TemplateType.PROJECT_EXPERIENCE, List.of()); + List internAnswers = templateTypeByAnswers.getOrDefault( + TemplateType.INTERN_EXPERIENCE, List.of()); + + if (projectAnswers.isEmpty() && internAnswers.isEmpty()) { + log.info("(프로젝트, 인턴) 템플릿 답변 없이, 바로 채용 공고 조회하는 경우 -> answer 생성 안된 경우"); + return RecruitKeyword.getRecruitKeywordFromProfileJob(job); + } + + StringBuilder contentBuilder = new StringBuilder(); + int projectEnterCnt = createChatGptRequestContent(contentBuilder, projectAnswers, + PROJECT_PREFIX, PROJECT_QUESTION_CONTENT_PERIOD); + int internEnterCnt = createChatGptRequestContent(contentBuilder, internAnswers, + INTERN_PREFIX, INTERN_QUESTION_CONTENT_PERIOD); + + if (isEmptyContentBuilder(contentBuilder, projectEnterCnt, internEnterCnt)) { + log.info("최종 데이터가 기본 틀 데이터를 제외하고 빈 문자열인 경우"); + return RecruitKeyword.getRecruitKeywordFromProfileJob(job); + } + + RecruitKeyword recruitKeyword = chatGptService.getRecruitKeyword(contentBuilder.toString()); + + if (Objects.isNull(recruitKeyword)) { + log.info("gpt 답변이 RecruitKeyword에 없는 값이라서 null인 경우 -> 멤버 프로필 job으로 대체"); + return RecruitKeyword.getRecruitKeywordFromProfileJob(job); + } + + return recruitKeyword; + } + + private Map> getTemplateTypeByAnswers(Member member, Job job) { + List findAnswers = answerRepository.findByMemberAndTemplateTypesAndJob(member, + List.of(TemplateType.PROJECT_EXPERIENCE, TemplateType.INTERN_EXPERIENCE), job); + + return findAnswers.stream() + .collect(Collectors.groupingBy( + answer -> answer.getQuestion().getTemplate().getTemplateType())); + } + + // 기간 데이터를 제외하고 답변 리스트의 content를 StringBuilder에 추가, 추가된 개수를 반환 + private int createChatGptRequestContent(StringBuilder contentBuilder, List answers, + String prefix, + String excludeContent) { + contentBuilder.append(prefix); + int enterCnt = 0; + for (Answer answer : answers) { + if (!answer.getQuestion().getContent().equals(excludeContent)) { + contentBuilder.append(answer.getContent().trim()).append("\n"); + enterCnt++; + } + } + return enterCnt; + } + + // StringBuilder가 기본 틀 외에 내용이 없는지 확인 + private boolean isEmptyContentBuilder(StringBuilder contentBuilder, int projectEnterCnt, + int internEnterCnt) { + return contentBuilder.length() + == PROJECT_PREFIX.length() + projectEnterCnt + INTERN_PREFIX.length() + internEnterCnt; + } + private PageResponseDTO> createPageResponseDTO(int page, int size, Page findRecruitPage, Member member) { Set scrapedRecruitIds = recruitScrapRepository.findRecruitIdsByMember(member); diff --git a/src/main/java/UMC/career_mate/global/entity/BaseEntity.java b/src/main/java/UMC/career_mate/global/entity/BaseEntity.java index d293f42..d717b38 100644 --- a/src/main/java/UMC/career_mate/global/entity/BaseEntity.java +++ b/src/main/java/UMC/career_mate/global/entity/BaseEntity.java @@ -31,4 +31,8 @@ public abstract class BaseEntity { public void delete() { this.deletedAt = LocalDateTime.now(); } + + public void setUpdatedAt() { + this.updatedAt = LocalDateTime.now(); + } }