diff --git a/build.gradle b/build.gradle index 614c54a..c539342 100644 --- a/build.gradle +++ b/build.gradle @@ -27,7 +27,7 @@ repositories { } ext { - set('springCloudVersion', "2023.0.0") + set('springCloudVersion', "2023.0.3") } dependencyManagement { @@ -101,6 +101,9 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // feign client + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' } tasks.named('test') { diff --git a/src/main/java/com/groom/orbit/OrbitApplication.java b/src/main/java/com/groom/orbit/OrbitApplication.java index 091b13a..279477c 100644 --- a/src/main/java/com/groom/orbit/OrbitApplication.java +++ b/src/main/java/com/groom/orbit/OrbitApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; @SpringBootApplication +@EnableFeignClients public class OrbitApplication { public static void main(String[] args) { diff --git a/src/main/java/com/groom/orbit/config/openai/AiFeedbackRequestDto.java b/src/main/java/com/groom/orbit/config/openai/AiFeedbackRequestDto.java new file mode 100644 index 0000000..6c3481f --- /dev/null +++ b/src/main/java/com/groom/orbit/config/openai/AiFeedbackRequestDto.java @@ -0,0 +1,91 @@ +package com.groom.orbit.config.openai; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonValue; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +public class AiFeedbackRequestDto { + + private String model = "gpt-4o-mini"; + + private List messages; + + public AiFeedbackRequestDto(List messages) { + this.messages = messages; + } + + @Getter + @AllArgsConstructor(access = AccessLevel.PROTECTED) + public static class Message { + private Role role; + private List content; + } + + @RequiredArgsConstructor + @Getter + public enum Role { + USER("user"), + SYSTEM("system"); + @JsonValue private final String value; + } + + @AllArgsConstructor(access = AccessLevel.PROTECTED) + @Getter + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Content { + private Type type; + + private String text; + + public static Content textContent(String text) { + return new Content(Type.TEXT, text); + } + } + + @RequiredArgsConstructor + @Getter + public enum Type { + TEXT("text"); + @JsonValue private final String value; + } + + public static AiFeedbackRequestDto from( + String job, + String academy, + String career, + String qualification, + String experience, + String etc) { + return new AiFeedbackRequestDto( + createMessages(job, academy, career, qualification, experience, etc)); + } + + private static List createMessages( + String job, + String academy, + String career, + String qualification, + String experience, + String etc) { + return List.of( + new Message(Role.SYSTEM, createSystemContents(job)), + new Message(Role.USER, createContents(academy, career, qualification, experience, etc))); + } + + private static List createSystemContents(String job) { + return List.of(Content.textContent(Prompt.getSystemPrompt(job))); + } + + private static List createContents( + String academy, String career, String qualification, String experience, String etc) { + return List.of( + Content.textContent(Prompt.getUserPrompt(academy, career, qualification, experience, etc))); + } +} diff --git a/src/main/java/com/groom/orbit/config/openai/AiFeedbackResponseDto.java b/src/main/java/com/groom/orbit/config/openai/AiFeedbackResponseDto.java new file mode 100644 index 0000000..30ab7bf --- /dev/null +++ b/src/main/java/com/groom/orbit/config/openai/AiFeedbackResponseDto.java @@ -0,0 +1,27 @@ +package com.groom.orbit.config.openai; + +import java.util.List; + +import lombok.Getter; + +@Getter +public class AiFeedbackResponseDto { + + private List choices; + + @Getter + public static class Choice { + private Message message; + private Integer index; + } + + @Getter + public static class Message { + String role; + String content; + } + + public String getAnswer() { + return choices.get(0).message.content; + } +} diff --git a/src/main/java/com/groom/orbit/config/openai/OpenAiClient.java b/src/main/java/com/groom/orbit/config/openai/OpenAiClient.java new file mode 100644 index 0000000..739d0c2 --- /dev/null +++ b/src/main/java/com/groom/orbit/config/openai/OpenAiClient.java @@ -0,0 +1,14 @@ +package com.groom.orbit.config.openai; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@FeignClient( + name = "open-ai-client", + url = "https://api.openai.com/v1/chat/completions", + configuration = OpenAiClientConfig.class) +public interface OpenAiClient { + @PostMapping + AiFeedbackResponseDto createAiFeedback(@RequestBody AiFeedbackRequestDto requestDto); +} diff --git a/src/main/java/com/groom/orbit/config/openai/OpenAiClientConfig.java b/src/main/java/com/groom/orbit/config/openai/OpenAiClientConfig.java new file mode 100644 index 0000000..4e6aa7a --- /dev/null +++ b/src/main/java/com/groom/orbit/config/openai/OpenAiClientConfig.java @@ -0,0 +1,21 @@ +package com.groom.orbit.config.openai; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; + +import feign.Logger; +import feign.Logger.Level; +import feign.RequestInterceptor; + +public class OpenAiClientConfig { + + @Bean + public RequestInterceptor requestInterceptor(@Value("${openai.api-key}") String apiKey) { + return template -> template.header("Authorization", "Bearer " + apiKey); + } + + @Bean + public Logger.Level loggerLevel() { + return Level.FULL; + } +} diff --git a/src/main/java/com/groom/orbit/config/openai/Prompt.java b/src/main/java/com/groom/orbit/config/openai/Prompt.java new file mode 100644 index 0000000..e8ba496 --- /dev/null +++ b/src/main/java/com/groom/orbit/config/openai/Prompt.java @@ -0,0 +1,42 @@ +package com.groom.orbit.config.openai; + +public class Prompt { + private static final String SYSTEM_PROMPT = + "내 관심 직무 분야는 {job}인데 이에 관한 내 활동사항들이야. 이 항목 대해 피드백을 해줘"; + private static final String USER_PROMPT = + """ + {academy} + + 이건 내 학력이고, + + {career} + + 이건 내 경력이야. + + {qualification} + + 이건 내 자격, 어학, 수상에 관련된 내용들이고 + + {experience} + + 이건 내 경험, 활동, 교육에 관한 내용들이야. + + {etc} + + 이건 기타사항이야 + """; + + public static String getSystemPrompt(String job) { + return SYSTEM_PROMPT.replace("{job}", job); + } + + public static String getUserPrompt( + String academy, String career, String qualification, String experience, String etc) { + USER_PROMPT.replace("{academy}", academy); + USER_PROMPT.replace("{career}", career); + USER_PROMPT.replace("{qualification}", qualification); + USER_PROMPT.replace("{experience}", experience); + USER_PROMPT.replace("{etc}", etc); + return USER_PROMPT; + } +} diff --git a/src/main/java/com/groom/orbit/member/app/dto/response/MemberCommandService.java b/src/main/java/com/groom/orbit/member/app/dto/response/MemberCommandService.java new file mode 100644 index 0000000..cadfcb2 --- /dev/null +++ b/src/main/java/com/groom/orbit/member/app/dto/response/MemberCommandService.java @@ -0,0 +1,66 @@ +package com.groom.orbit.member.app.dto.response; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.groom.orbit.config.openai.AiFeedbackRequestDto; +import com.groom.orbit.config.openai.AiFeedbackResponseDto; +import com.groom.orbit.config.openai.OpenAiClient; +import com.groom.orbit.job.app.InterestJobService; +import com.groom.orbit.job.app.dto.JobDetailResponseDto; +import com.groom.orbit.member.app.MemberQueryService; +import com.groom.orbit.member.dao.jpa.MemberRepository; +import com.groom.orbit.member.dao.jpa.entity.Member; +import com.groom.orbit.resume.app.ResumeQueryService; +import com.groom.orbit.resume.app.dto.GetResumeResponseDto; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class MemberCommandService { + + private final MemberRepository memberRepository; + private final InterestJobService interestJobService; + private final ResumeQueryService resumeQueryService; + private final MemberQueryService memberQueryService; + private final OpenAiClient openAiClient; + + private AiFeedbackRequestDto createAiFeedbackRequest(Long memberId) { + + List jobName = + interestJobService.findJobsByMemberId(memberId).stream() + .map(JobDetailResponseDto::name) + .toList(); + String job = String.join(",", jobName); + + GetResumeResponseDto getResumeResponseDto = resumeQueryService.getResume(memberId); + + List categoryStringList = + resumeQueryService.convertToResumeStrings(getResumeResponseDto); + + String academy = categoryStringList.get(0); + String career = categoryStringList.get(1); + String qualification = categoryStringList.get(2); + String experience = categoryStringList.get(3); + String etc = categoryStringList.get(4); + + return AiFeedbackRequestDto.from(job, academy, career, qualification, experience, etc); + } + + public String createAiFeedbackResponse(Long memberId) { + AiFeedbackResponseDto responseDto = + openAiClient.createAiFeedback(createAiFeedbackRequest(memberId)); + + Member member = memberQueryService.findMember(memberId); + + member.setAiFeedback(responseDto.getAnswer()); + + memberRepository.save(member); + + return responseDto.getAnswer(); + } +} diff --git a/src/main/java/com/groom/orbit/member/controller/MemberCommandController.java b/src/main/java/com/groom/orbit/member/controller/MemberCommandController.java new file mode 100644 index 0000000..c1967d7 --- /dev/null +++ b/src/main/java/com/groom/orbit/member/controller/MemberCommandController.java @@ -0,0 +1,24 @@ +package com.groom.orbit.member.controller; + +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.groom.orbit.common.annotation.AuthMember; +import com.groom.orbit.common.dto.ResponseDto; +import com.groom.orbit.member.app.dto.response.MemberCommandService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/member") +public class MemberCommandController { + + private final MemberCommandService memberCommandService; + + @PostMapping("/ai") + public ResponseDto createAiFeedback(@AuthMember Long memberId) { + return ResponseDto.ok(memberCommandService.createAiFeedbackResponse(memberId)); + } +} diff --git a/src/main/java/com/groom/orbit/member/dao/jpa/entity/Member.java b/src/main/java/com/groom/orbit/member/dao/jpa/entity/Member.java index b661a72..e5c7dd9 100644 --- a/src/main/java/com/groom/orbit/member/dao/jpa/entity/Member.java +++ b/src/main/java/com/groom/orbit/member/dao/jpa/entity/Member.java @@ -18,6 +18,7 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; @Entity(name = "member") @Table(name = "member") @@ -49,6 +50,10 @@ public class Member { @Column(name = "is_profile") private Boolean isProfile = false; + @Setter + @Column(name = "ai_feedback", length = 50000) + private String aiFeedback = ""; + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) private List interestJobs = new ArrayList<>(); diff --git a/src/main/java/com/groom/orbit/resume/app/ResumeQueryService.java b/src/main/java/com/groom/orbit/resume/app/ResumeQueryService.java index 65f479b..a742d04 100644 --- a/src/main/java/com/groom/orbit/resume/app/ResumeQueryService.java +++ b/src/main/java/com/groom/orbit/resume/app/ResumeQueryService.java @@ -1,5 +1,7 @@ package com.groom.orbit.resume.app; +import java.text.SimpleDateFormat; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -35,4 +37,44 @@ public GetResumeResponseDto getResume(Long memberId) { categorizedResumes.getOrDefault(ResumeCategory.EXPERIENCE, List.of()), categorizedResumes.getOrDefault(ResumeCategory.ETC, List.of())); } + + public List convertToResumeStrings(GetResumeResponseDto responseDto) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); + + Map> categorizedResumes = + Map.of( + ResumeCategory.ACADEMY, responseDto.academyList(), + ResumeCategory.CAREER, responseDto.careerList(), + ResumeCategory.QUALIFICATION, responseDto.qualificationList(), + ResumeCategory.EXPERIENCE, responseDto.experienceList(), + ResumeCategory.ETC, responseDto.etcList()); + + return Arrays.stream(ResumeCategory.values()) + .map( + category -> { + List resumeList = + categorizedResumes.getOrDefault(category, List.of()); + + if (resumeList.isEmpty()) { + return String.format("Category: %s\n없음.\n", category); + } + + String details = + resumeList.stream() + .map( + dto -> { + String startDate = + dto.startDate() != null ? sdf.format(dto.startDate()) : "N/A"; + String endDate = + dto.endDate() != null ? sdf.format(dto.endDate()) : "N/A"; + return String.format( + "제목: %s\n내용: %s\n시작일: %s\n마감일: %s", + dto.title(), dto.content(), startDate, endDate); + }) + .collect(Collectors.joining("\n\n")); + + return String.format("Category: %s\n%s", category, details); + }) + .collect(Collectors.toList()); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 35d6d48..cb73442 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -28,4 +28,7 @@ oauth: client-secret: ${KAKAO_CLIENT_SECRET} url: auth: https://kauth.kakao.com - api: https://kapi.kakao.com \ No newline at end of file + api: https://kapi.kakao.com +--- +openai: + api-key: ${OPENAI_API_KEY} \ No newline at end of file