From e8295f5d1edae54b9b35d9c8239f7d6fb0b7c729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9E=AC=ED=98=81?= <67510260+LEEJaeHyeok97@users.noreply.github.com> Date: Sat, 3 Aug 2024 19:51:24 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20:sparkles:=20=EC=B1=97=EB=B4=87=20?= =?UTF-8?q?=EC=9E=84=EB=B2=A0=EB=94=A9=20=EB=B0=8F=20=EB=8C=80=ED=99=94=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial commit * Feat/#1 oauth2login (#3) * feat: User 엔터티 생성 * feat: jwt 버전 11->12, JWTUtil 생성 * feat: JWTFilter(JwtAuthenticationFilter) 등록 * feat: kakao 로그인 구현 * docs: swagger 태그(Authorization) 추가 (#5) * feat: User 엔터티 생성 * feat: jwt 버전 11->12, JWTUtil 생성 * feat: JWTFilter(JwtAuthenticationFilter) 등록 * feat: kakao 로그인 구현 * docs: swagger 태그(Authorization) 추가 * feat: accesstoken 테스트를 위한 test login 생성 (#9) * feat: User 엔티티에 상속 (#12) * feat: BaseEntity 생성 * feat: User 엔티티에 상속 * feat: 일기 생성 기능 구현 (#14) * feat: accesstoken 테스트를 위한 test login 생성 * feat: 일기 생성 기능 구현 * hotfix: ci 에러 수정 (#16) * feat: accesstoken 테스트를 위한 test login 생성 * feat: 일기 생성 기능 구현 * hotfix: ci 에러 수정 * fix: OIDC 카카오 로그인 nullPointerException 해결 * feat: 닉네임 설정 기능 구현 (#21) * feat: 일기 수정 기능 구현 (#25) * feat: 일기에 감정 컬럼 추가 * feat: 일기 수정 기능 구현 * feat: 일기 삭제 기능 구현 (#27) * feat: 일기에 감정 컬럼 추가 * feat: 일기 수정 기능 구현 * feat: 일기 삭제 기능 구현 * feat: 일기 감정 분석 기능 구현 (#31) * feat: 감정 저장 기능 구현 (#33) * feat: 일기 감정 분석 기능 구현 * feat: 감정 저장 기능 구현 * fix: :bug: 감정 저장 안되던 오류 수정 (#35) * feat: 일기 감정 분석 기능 구현 * feat: 감정 저장 기능 구현 * fix: :bug: 감정 저장 안되던 오류 수정 * hotfix: :ambulance: 서버 꺼짐 현상 해결 (#37) * feat: 일기 감정 분석 기능 구현 * feat: 감정 저장 기능 구현 * fix: :bug: 감정 저장 안되던 오류 수정 * hotfix: :ambulance: 서버 꺼짐 현상 해결 * feat: :sparkles: 홈 화면 조회 기능 구현 (#41) * feat: :sparkles: 회원가입 완료 여부 필드 추가 (#44) * feat: :sparkles: 일기 상세 조회 구현 (#47) * feat: :sparkles: 기간 별 감정 통계 조회 기능 구현 (#50) * feat: :sparkles: 일기 내용 검색 기능 구현 (#52) * feat: :sparkles: 감정 별 일기 조회 (#54) * feat: :sparkles: 월 별 일기 조회 기능 구현 (#59) * ci: :zap: workflow 수정 (#61) * ci: :zap: workflow 수정 * ci: :zap: workflow 수정 * feat: :sparkles: user 엔터티 fcmToken 컬럼 추가, 로그인 시 토큰 최신화 구현 (#63) * ci: :zap: workflow 수정 * ci: :zap: workflow 수정 * feat: :sparkles: fcm 토큰 알림 기능 구현 * feat: :sparkles: user 엔터티 fcmToken 컬럼 추가, 로그인 시 토큰 최신화 구현 * feat: :sparkles: 북마크 추가/삭제 기능 구현, 일기/홈화면 조회 쿼리문 수정 (#65) * feat: :sparkles: 북마크 추가 기능 구현 * feat: :sparkles: 북마크 추가/삭제 기능 구현, 일기/홈화면 조회 쿼리문 수정 * feat: :rocket: fcmtoken 등록 api 분리 (#68) * feat: :sparkles: 유저 정보 조회 기능 구현 (#71) * Feat/#70 user info (#73) * feat: :sparkles: 유저 정보 조회 기능 구현 * hotfix: :ambulance: cd 에러 해결 * refactor: :rocket: gpt prompt 수정 (#76) * feat: :sparkles: 일기 요약 스케줄러 구현 (#80) * refactor: :rocket: 엔터티 접근 지정자 수정 (#84) * feat: :sparkles: 챗봇 임베딩 및 대화 기능 구현 완료 (#86) --- build.gradle | 3 + .../chatbot/application/ChatbotService.java | 95 ++++++++++++++++ .../domain/chatbot/domain/ChatHistory.java | 37 ++++++ .../domain/chatbot/domain/ChatRole.java | 6 + .../ChatHistoryQueryDslRepository.java | 9 ++ .../ChatHistoryQueryDslRepositoryImpl.java | 29 +++++ .../repository/ChatHistoryRepository.java | 9 ++ .../aidiary/domain/chatbot/dto/ChatReq.java | 9 ++ .../domain/chatbot/dto/DiaryEmbeddingReq.java | 12 ++ .../aidiary/domain/chatbot/dto/QueryReq.java | 13 +++ .../aidiary/domain/chatbot/dto/QueryRes.java | 11 ++ .../presentation/ChatbotController.java | 106 ++++++++++++++++++ .../repository/DiarySummaryRepository.java | 4 + 13 files changed, 343 insertions(+) create mode 100644 src/main/java/com/aidiary/domain/chatbot/application/ChatbotService.java create mode 100644 src/main/java/com/aidiary/domain/chatbot/domain/ChatHistory.java create mode 100644 src/main/java/com/aidiary/domain/chatbot/domain/ChatRole.java create mode 100644 src/main/java/com/aidiary/domain/chatbot/domain/repository/ChatHistoryQueryDslRepository.java create mode 100644 src/main/java/com/aidiary/domain/chatbot/domain/repository/ChatHistoryQueryDslRepositoryImpl.java create mode 100644 src/main/java/com/aidiary/domain/chatbot/domain/repository/ChatHistoryRepository.java create mode 100644 src/main/java/com/aidiary/domain/chatbot/dto/ChatReq.java create mode 100644 src/main/java/com/aidiary/domain/chatbot/dto/DiaryEmbeddingReq.java create mode 100644 src/main/java/com/aidiary/domain/chatbot/dto/QueryReq.java create mode 100644 src/main/java/com/aidiary/domain/chatbot/dto/QueryRes.java create mode 100644 src/main/java/com/aidiary/domain/chatbot/presentation/ChatbotController.java diff --git a/build.gradle b/build.gradle index 7e75d98..39cbc3a 100644 --- a/build.gradle +++ b/build.gradle @@ -65,6 +65,9 @@ dependencies { annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" annotationProcessor "jakarta.persistence:jakarta.persistence-api" annotationProcessor "jakarta.annotation:jakarta.annotation-api" + + + implementation 'org.json:json:20210307' } tasks.named('test') { diff --git a/src/main/java/com/aidiary/domain/chatbot/application/ChatbotService.java b/src/main/java/com/aidiary/domain/chatbot/application/ChatbotService.java new file mode 100644 index 0000000..513be83 --- /dev/null +++ b/src/main/java/com/aidiary/domain/chatbot/application/ChatbotService.java @@ -0,0 +1,95 @@ +package com.aidiary.domain.chatbot.application; + +import com.aidiary.domain.chatbot.domain.ChatHistory; +import com.aidiary.domain.chatbot.domain.ChatRole; +import com.aidiary.domain.chatbot.domain.repository.ChatHistoryRepository; +import com.aidiary.domain.chatbot.dto.DiaryEmbeddingReq; +import com.aidiary.domain.chatbot.dto.QueryReq; +import com.aidiary.domain.summary.domain.DiarySummary; +import com.aidiary.domain.summary.domain.repository.DiarySummaryRepository; +import com.aidiary.domain.user.domain.User; +import com.aidiary.domain.user.domain.repository.UserRepository; +import com.aidiary.global.config.security.token.UserPrincipal; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class ChatbotService { + + private final ChatHistoryRepository chatHistoryRepository; + private final DiarySummaryRepository diarySummaryRepository; + private final UserRepository userRepository; + + + @Transactional + public DiaryEmbeddingReq makeRequest(Long id) { + User user = userRepository.findById(id) + .orElseThrow(EntityNotFoundException::new); + DiarySummary diarySummary = diarySummaryRepository.findByUser(user) + .orElseThrow(EntityNotFoundException::new); + + DiaryEmbeddingReq diaryEmbeddingReq = DiaryEmbeddingReq.builder() + .userId(id.toString()) + .summarizedDiary(diarySummary.getSummarizedDiary()) + .build(); + + return diaryEmbeddingReq; + } + + @Transactional + public QueryReq makeQuery(UserPrincipal userPrincipal, String question) { + User user = userRepository.findById(userPrincipal.getId()) + .orElseThrow(EntityNotFoundException::new); + + + // 챗 히스토리를 최근 20개 이하로 가져온다 + List recentChatHistoryByUserId = chatHistoryRepository.findRecentChatHistoryByUserId(userPrincipal.getId()); + List chatHistoryList = recentChatHistoryByUserId.stream() + .map(chat -> String.format("%s: %s", chat.getChatRole() == ChatRole.USER ? "사용자" : "친구", chat.getMessage())) + .toList(); + + log.info("User ID: {}", userPrincipal.getId()); + log.info("Question: {}", question); + log.info("Chat History: {}", chatHistoryList); + + ChatHistory chatHistory = ChatHistory.builder() + .user(user) + .message(question) + .chatRole(ChatRole.USER) + .build(); + + + chatHistoryRepository.save(chatHistory); + + QueryReq queryReq = QueryReq.builder() + .userId(userPrincipal.getId().toString()) + .question(question) + .chatHistory(chatHistoryList) + .build(); + + return queryReq; + + } + + @Transactional + public void registerBotChat(UserPrincipal userPrincipal, String result) { + User user = userRepository.findById(userPrincipal.getId()) + .orElseThrow(EntityNotFoundException::new); + + ChatHistory chatHistory = ChatHistory.builder() + .user(user) + .message(result) + .chatRole(ChatRole.BOT) + .build(); + chatHistoryRepository.save(chatHistory); + } +} diff --git a/src/main/java/com/aidiary/domain/chatbot/domain/ChatHistory.java b/src/main/java/com/aidiary/domain/chatbot/domain/ChatHistory.java new file mode 100644 index 0000000..1b0eb19 --- /dev/null +++ b/src/main/java/com/aidiary/domain/chatbot/domain/ChatHistory.java @@ -0,0 +1,37 @@ +package com.aidiary.domain.chatbot.domain; + +import com.aidiary.domain.common.BaseEntity; +import com.aidiary.domain.user.domain.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChatHistory extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) + @JoinColumn(name = "user_id") + private User user; + + private String message; + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "VARCHAR(10)") + private ChatRole chatRole; + + + @Builder + public ChatHistory(User user, String message, ChatRole chatRole) { + this.user = user; + this.message = message; + this.chatRole = chatRole; + } +} diff --git a/src/main/java/com/aidiary/domain/chatbot/domain/ChatRole.java b/src/main/java/com/aidiary/domain/chatbot/domain/ChatRole.java new file mode 100644 index 0000000..0de7401 --- /dev/null +++ b/src/main/java/com/aidiary/domain/chatbot/domain/ChatRole.java @@ -0,0 +1,6 @@ +package com.aidiary.domain.chatbot.domain; + +public enum ChatRole { + USER, + BOT +} diff --git a/src/main/java/com/aidiary/domain/chatbot/domain/repository/ChatHistoryQueryDslRepository.java b/src/main/java/com/aidiary/domain/chatbot/domain/repository/ChatHistoryQueryDslRepository.java new file mode 100644 index 0000000..f941ca4 --- /dev/null +++ b/src/main/java/com/aidiary/domain/chatbot/domain/repository/ChatHistoryQueryDslRepository.java @@ -0,0 +1,9 @@ +package com.aidiary.domain.chatbot.domain.repository; + +import com.aidiary.domain.chatbot.domain.ChatHistory; + +import java.util.List; + +public interface ChatHistoryQueryDslRepository { + List findRecentChatHistoryByUserId(Long id); +} diff --git a/src/main/java/com/aidiary/domain/chatbot/domain/repository/ChatHistoryQueryDslRepositoryImpl.java b/src/main/java/com/aidiary/domain/chatbot/domain/repository/ChatHistoryQueryDslRepositoryImpl.java new file mode 100644 index 0000000..78eafcb --- /dev/null +++ b/src/main/java/com/aidiary/domain/chatbot/domain/repository/ChatHistoryQueryDslRepositoryImpl.java @@ -0,0 +1,29 @@ +package com.aidiary.domain.chatbot.domain.repository; + +import com.aidiary.domain.chatbot.domain.ChatHistory; +import com.aidiary.domain.chatbot.domain.QChatHistory; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static com.aidiary.domain.chatbot.domain.QChatHistory.chatHistory; + +@RequiredArgsConstructor +@Repository +public class ChatHistoryQueryDslRepositoryImpl implements ChatHistoryQueryDslRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public List findRecentChatHistoryByUserId(Long id) { + return queryFactory + .select(chatHistory) + .from(chatHistory) + .where(chatHistory.user.id.eq(id)) + .orderBy(chatHistory.createdAt.desc()) + .limit(20) + .fetch(); + } +} diff --git a/src/main/java/com/aidiary/domain/chatbot/domain/repository/ChatHistoryRepository.java b/src/main/java/com/aidiary/domain/chatbot/domain/repository/ChatHistoryRepository.java new file mode 100644 index 0000000..7d4f316 --- /dev/null +++ b/src/main/java/com/aidiary/domain/chatbot/domain/repository/ChatHistoryRepository.java @@ -0,0 +1,9 @@ +package com.aidiary.domain.chatbot.domain.repository; + +import com.aidiary.domain.chatbot.domain.ChatHistory; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ChatHistoryRepository extends JpaRepository, ChatHistoryQueryDslRepository { +} diff --git a/src/main/java/com/aidiary/domain/chatbot/dto/ChatReq.java b/src/main/java/com/aidiary/domain/chatbot/dto/ChatReq.java new file mode 100644 index 0000000..a780e76 --- /dev/null +++ b/src/main/java/com/aidiary/domain/chatbot/dto/ChatReq.java @@ -0,0 +1,9 @@ +package com.aidiary.domain.chatbot.dto; + +import lombok.Builder; + +@Builder +public record ChatReq( + String question +) { +} diff --git a/src/main/java/com/aidiary/domain/chatbot/dto/DiaryEmbeddingReq.java b/src/main/java/com/aidiary/domain/chatbot/dto/DiaryEmbeddingReq.java new file mode 100644 index 0000000..29982c8 --- /dev/null +++ b/src/main/java/com/aidiary/domain/chatbot/dto/DiaryEmbeddingReq.java @@ -0,0 +1,12 @@ +package com.aidiary.domain.chatbot.dto; + +import lombok.Builder; + +import java.util.List; + +@Builder +public record DiaryEmbeddingReq( + String userId, + String summarizedDiary +) { +} diff --git a/src/main/java/com/aidiary/domain/chatbot/dto/QueryReq.java b/src/main/java/com/aidiary/domain/chatbot/dto/QueryReq.java new file mode 100644 index 0000000..ac46331 --- /dev/null +++ b/src/main/java/com/aidiary/domain/chatbot/dto/QueryReq.java @@ -0,0 +1,13 @@ +package com.aidiary.domain.chatbot.dto; + +import lombok.Builder; + +import java.util.List; + +@Builder +public record QueryReq( + String userId, + String question, + List chatHistory +) { +} diff --git a/src/main/java/com/aidiary/domain/chatbot/dto/QueryRes.java b/src/main/java/com/aidiary/domain/chatbot/dto/QueryRes.java new file mode 100644 index 0000000..3e134ea --- /dev/null +++ b/src/main/java/com/aidiary/domain/chatbot/dto/QueryRes.java @@ -0,0 +1,11 @@ +package com.aidiary.domain.chatbot.dto; + +import lombok.Builder; + +import java.util.List; + +@Builder +public record QueryRes( + String result +) { +} diff --git a/src/main/java/com/aidiary/domain/chatbot/presentation/ChatbotController.java b/src/main/java/com/aidiary/domain/chatbot/presentation/ChatbotController.java new file mode 100644 index 0000000..2e99b9b --- /dev/null +++ b/src/main/java/com/aidiary/domain/chatbot/presentation/ChatbotController.java @@ -0,0 +1,106 @@ +package com.aidiary.domain.chatbot.presentation; + +import com.aidiary.domain.chatbot.application.ChatbotService; +import com.aidiary.domain.chatbot.dto.ChatReq; +import com.aidiary.domain.chatbot.dto.DiaryEmbeddingReq; +import com.aidiary.domain.chatbot.dto.QueryReq; +import com.aidiary.global.config.security.token.CurrentUser; +import com.aidiary.global.config.security.token.UserPrincipal; +import com.aidiary.global.payload.ErrorResponse; +import com.aidiary.global.payload.Message; +import com.aidiary.global.payload.ResponseCustom; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.json.JSONObject; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestTemplate; + +@Tag(name = "Chatbot", description = "Chatbot API") +@RequiredArgsConstructor +@RestController +@RequestMapping("/chatbot") +@Slf4j +public class ChatbotController { + + private final ChatbotService chatbotService; + private final RestTemplate restTemplate = new RestTemplate(); + + @Value("${chatbot.fastApi.addDiaryUrl}") + private String addDiaryUrl; + + @Value("${chatbot.fastApi.queryUrl}") + private String queryUrl; + + + @Operation(summary = "일기 임베딩", description = "유저의 일기를 AI 서버에 임베딩합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "일기 임베딩 성공", content = {@Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = String.class)))}), + @ApiResponse(responseCode = "400", description = "일기 임베딩 실패", content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))}), + }) + @PostMapping("/embedding") + public ResponseCustom addEmbedding( + @Parameter(description = "Accesstoken을 입력해주세요.", required = true) @CurrentUser UserPrincipal userPrincipal + ) { + String fastApiUrl = addDiaryUrl; + + HttpHeaders headers = new HttpHeaders(); + headers.set("Content-Type", "application/json"); + + DiaryEmbeddingReq madeRequest = chatbotService.makeRequest(userPrincipal.getId()); + + HttpEntity requestEntity = new HttpEntity<>(madeRequest, headers); + ResponseEntity response = restTemplate.postForEntity(fastApiUrl, requestEntity, String.class); + + JSONObject responseBody = new JSONObject(response.getBody()); + String message = responseBody.getString("message"); + + return ResponseCustom.OK(message); + } + + @Operation(summary = "챗봇과 대화하기", description = "임베딩된 일기를 바탕으로 챗봇과 대화합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "챗봇 대화 성공", content = {@Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = String.class)))}), + @ApiResponse(responseCode = "400", description = "챗봇 대화 실패", content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))}), + }) + @PostMapping("/chat") + public ResponseCustom chat( + @Parameter(description = "Accesstoken을 입력해주세요.", required = true) @CurrentUser UserPrincipal userPrincipal, + @RequestBody ChatReq chatReq + ) { + String fastApiUrl = queryUrl; + + HttpHeaders headers = new HttpHeaders(); + headers.set("Content-Type", "application/json"); + + QueryReq queryReq = chatbotService.makeQuery(userPrincipal, chatReq.question()); + + log.info("Requesting FastAPI with QueryReq: {}", queryReq); + + HttpEntity requestEntity = new HttpEntity<>(queryReq, headers); + ResponseEntity response = restTemplate.postForEntity(fastApiUrl, requestEntity, String.class); + + // JSON 응답에서 message 필드만 추출 + JSONObject responseBody = new JSONObject(response.getBody()); + String message = responseBody.getString("message"); + + // Bot 응답 저장 + chatbotService.registerBotChat(userPrincipal, message); + + return ResponseCustom.OK(message); + } +} diff --git a/src/main/java/com/aidiary/domain/summary/domain/repository/DiarySummaryRepository.java b/src/main/java/com/aidiary/domain/summary/domain/repository/DiarySummaryRepository.java index 0061df4..367d713 100644 --- a/src/main/java/com/aidiary/domain/summary/domain/repository/DiarySummaryRepository.java +++ b/src/main/java/com/aidiary/domain/summary/domain/repository/DiarySummaryRepository.java @@ -1,7 +1,11 @@ package com.aidiary.domain.summary.domain.repository; import com.aidiary.domain.summary.domain.DiarySummary; +import com.aidiary.domain.user.domain.User; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface DiarySummaryRepository extends JpaRepository { + Optional findByUser(User user); }