Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat] 챗봇 기능 구현 #38

Merged
merged 14 commits into from
Jan 29, 2025
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ configurations {

repositories {
mavenCentral()
maven { url 'https://repo.spring.io/milestone' } // Spring AI 저장소 추가
}

dependencies {
Expand All @@ -30,6 +31,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-devtools'

//Lombok
compileOnly 'org.projectlombok:lombok'
Expand Down Expand Up @@ -84,6 +86,8 @@ dependencies {
// amazon s3
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

//spring AI
implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter:0.8.0'
}

clean { delete file('src/main/generated')}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.ripple.BE.chatbot.controller;

import com.ripple.BE.chatbot.dto.ChatDTO;
import com.ripple.BE.chatbot.dto.ChatListDTO;
import com.ripple.BE.chatbot.dto.request.ChatRequest;
import com.ripple.BE.chatbot.dto.response.ChatListResponse;
import com.ripple.BE.chatbot.dto.response.ChatResponse;
import com.ripple.BE.chatbot.service.ChatbotService;
import com.ripple.BE.global.dto.response.ApiResponse;
import com.ripple.BE.user.domain.CustomUserDetails;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
@RequestMapping("/api/v1/chatbot")
@Tag(name = "Chatbot", description = "챗봇 API")
public class ChatbotController {

private final ChatbotService chatbotService;

@Operation(summary = "챗봇에게 메세지 보내기", description = "챗봇에게 메세지를 보내고 응답을 받습니다. 대화 내용을 저장합니다.")
@PostMapping
public ResponseEntity<ApiResponse<Object>> sendMessage(
final @AuthenticationPrincipal CustomUserDetails currentUser,
final @Valid ChatRequest request) {

ChatResponse chatResponse =
chatbotService.sendMessage(ChatDTO.tochatDTO(request), currentUser.getId());

return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.from(chatResponse));
}

@Operation(
summary = "대화 내역 조회",
description = "챗봇과의 대화 내역을 조회합니다. 페이지네이션을 지원합니다. 페이지당 10개의 대화 내역을 반환합니다.")
@GetMapping("/list")
public ResponseEntity<ApiResponse<Object>> getMessages(
final @AuthenticationPrincipal CustomUserDetails currentUser,
final @RequestParam(defaultValue = "0") @PositiveOrZero int page) {

ChatListDTO chatList = chatbotService.getChatList(currentUser.getId(), page);

return ResponseEntity.status(HttpStatus.OK)
.body(ApiResponse.from(ChatListResponse.toChatListResponse(chatList)));
}

@Operation(summary = "대화 내역 초기화", description = "챗봇과의 대화 내역을 초기화합니다.")
@PostMapping("/clear")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DeleteMapping으로 변경해도 좋을거 같네요

public ResponseEntity<ApiResponse<Object>> clearMessages(
final @AuthenticationPrincipal CustomUserDetails currentUser) {

chatbotService.clearChat(currentUser.getId());

return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.from(ApiResponse.EMPTY_RESPONSE));
}
}
5 changes: 3 additions & 2 deletions src/main/java/com/ripple/BE/chatbot/domain/ChatMessage.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.ripple.BE.chatbot.domain.type.Sender;
import com.ripple.BE.global.entity.BaseEntity;
import com.ripple.BE.user.domain.User;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
Expand Down Expand Up @@ -42,6 +43,6 @@ public class ChatMessage extends BaseEntity {
private Sender sender; // 메시지 송신자

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "chat_session_id")
private ChatSession chatSession;
@JoinColumn(name = "user_id")
private User user; // 작성자
}
43 changes: 0 additions & 43 deletions src/main/java/com/ripple/BE/chatbot/domain/ChatSession.java

This file was deleted.

17 changes: 17 additions & 0 deletions src/main/java/com/ripple/BE/chatbot/dto/ChatDTO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.ripple.BE.chatbot.dto;

import com.ripple.BE.chatbot.domain.ChatMessage;
import com.ripple.BE.chatbot.domain.type.Sender;
import com.ripple.BE.chatbot.dto.request.ChatRequest;

public record ChatDTO(String message, Sender sender, String createdAt) {

public static ChatDTO toChatDTO(final ChatMessage chatMessage) {
return new ChatDTO(
chatMessage.getMessage(), chatMessage.getSender(), chatMessage.getCreatedDate().toString());
}

public static ChatDTO tochatDTO(final ChatRequest request) {
return new ChatDTO(request.message(), Sender.USER, null);
}
}
16 changes: 16 additions & 0 deletions src/main/java/com/ripple/BE/chatbot/dto/ChatListDTO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.ripple.BE.chatbot.dto;

import com.ripple.BE.chatbot.domain.ChatMessage;
import java.util.List;
import lombok.Builder;
import org.springframework.data.domain.Page;

@Builder
public record ChatListDTO(List<ChatDTO> chatDTOList, int totalPage, int currentPage) {
public static ChatListDTO toChatListDTO(Page<ChatMessage> chatMessagePage) {
return new ChatListDTO(
chatMessagePage.getContent().stream().map(ChatDTO::toChatDTO).toList(),
chatMessagePage.getTotalPages(),
chatMessagePage.getNumber());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.ripple.BE.chatbot.dto.request;

import jakarta.validation.constraints.NotBlank;

public record ChatRequest(@NotBlank String message) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.ripple.BE.chatbot.dto.response;

import com.ripple.BE.chatbot.dto.ChatListDTO;
import java.util.List;

public record ChatListResponse(List<ChatResponse> postList, int totalPage, int currentPage) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기 변수명 변경이 필요할거 같아요

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앗 그러네요 수정해서 올렸습니다ㅎㅎ


public static ChatListResponse toChatListResponse(ChatListDTO chatListDTO) {
return new ChatListResponse(
chatListDTO.chatDTOList().stream().map(ChatResponse::toChatResponse).toList(),
chatListDTO.totalPage(),
chatListDTO.currentPage());
}
}
19 changes: 19 additions & 0 deletions src/main/java/com/ripple/BE/chatbot/dto/response/ChatResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.ripple.BE.chatbot.dto.response;

import com.ripple.BE.chatbot.domain.ChatMessage;
import com.ripple.BE.chatbot.dto.ChatDTO;

public record ChatResponse(String message, String sender, String createdAt) {

public static ChatResponse toChatResponse(ChatDTO chatDTO) {
return new ChatResponse(
chatDTO.message(), chatDTO.sender().toString(), chatDTO.createdAt().toString());
}

public static ChatResponse toChatResponse(ChatMessage chatMessage) {
return new ChatResponse(
chatMessage.getMessage(),
chatMessage.getSender().toString(),
chatMessage.getCreatedDate().toString());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.ripple.BE.chatbot.repository;

import com.ripple.BE.chatbot.domain.ChatMessage;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ChatbotRepository extends JpaRepository<ChatMessage, Long> {
Page<ChatMessage> findAllByUserIdOrderByCreatedDate(Long userId, Pageable pageable);

void deleteAllByUserId(Long userId);
}
93 changes: 93 additions & 0 deletions src/main/java/com/ripple/BE/chatbot/service/ChatbotService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package com.ripple.BE.chatbot.service;

import static com.ripple.BE.user.exception.errorcode.UserErrorCode.*;

import com.ripple.BE.chatbot.domain.ChatMessage;
import com.ripple.BE.chatbot.domain.type.Sender;
import com.ripple.BE.chatbot.dto.ChatDTO;
import com.ripple.BE.chatbot.dto.ChatListDTO;
import com.ripple.BE.chatbot.dto.response.ChatListResponse;
import com.ripple.BE.chatbot.dto.response.ChatResponse;
import com.ripple.BE.chatbot.repository.ChatbotRepository;
import com.ripple.BE.user.domain.User;
import com.ripple.BE.user.exception.UserException;
import com.ripple.BE.user.repository.UserRepository;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import org.springframework.ai.openai.OpenAiChatClient;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Slf4j
@Transactional(readOnly = true)
public class ChatbotService {
private final OpenAiChatClient openAiChatClient;

private final UserRepository userRepository;
private final ChatbotRepository chatbotRepository;

private static final int PAGE_SIZE = 10;

@Transactional
public ChatResponse sendMessage(final ChatDTO chatDTO, final Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserException(USER_NOT_FOUND));

// 프롬프트 포함하여 OpenAI API 호출
String prompt = """
당신은 경제학습 서비스를 위한 AI 챗봇입니다.
오직 경제와 관련된 질문에만 답변해야 하며, 경제와 무관한 질문에는 답변하지 않습니다.
모든 답변은 반드시 한국어로 제공해야 합니다.
경제 이외의 주제에 대한 질문에는 다음과 같이 답변하세요:
"죄송합니다. 저는 경제 관련 질문에만 답변할 수 있습니다."
""";

// OpenAI API 호출
String response = openAiChatClient.call(prompt + "\n사용자 질문: " + chatDTO.message());

// 유저의 메세지 저장
chatbotRepository.save(
ChatMessage.builder()
.user(user)
.message(chatDTO.message())
.sender(Sender.USER)
.build());

// 챗봇의 응답 저장
ChatMessage saved = chatbotRepository.save(
ChatMessage.builder()
.user(user)
.message(response)
.sender(Sender.CHATBOT)
.build());

return ChatResponse.toChatResponse(saved);
}

public ChatListDTO getChatList(final Long userId, final int page) {
Pageable pageable = PageRequest.of(page, PAGE_SIZE);

User user = userRepository.findById(userId)
.orElseThrow(() -> new UserException(USER_NOT_FOUND));

Page<ChatMessage> chatMessagePage = chatbotRepository.findAllByUserIdOrderByCreatedDate(
user.getId(), pageable);

return ChatListDTO.toChatListDTO(chatMessagePage);
}

@Transactional
public void clearChat(final Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserException(USER_NOT_FOUND));

chatbotRepository.deleteAllByUserId(user.getId());
}
}
4 changes: 0 additions & 4 deletions src/main/java/com/ripple/BE/user/domain/User.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.ripple.BE.user.domain;

import com.ripple.BE.chatbot.domain.ChatSession;
import com.ripple.BE.global.entity.BaseEntity;
import com.ripple.BE.learning.domain.learningset.UserLearningSet;
import com.ripple.BE.learning.domain.quiz.FailQuiz;
Expand Down Expand Up @@ -128,9 +127,6 @@ public class User extends BaseEntity {
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<TermScrap> termScrapList = new ArrayList<>(); // 스크랩한 용어 목록

@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ChatSession> chatSessionList = new ArrayList<>(); // 채팅 세션 목록

@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<UserLearningSet> userLearningSetList = new ArrayList<>(); // 학습 완료 목록

Expand Down
6 changes: 3 additions & 3 deletions src/main/resources/application-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ spring:
on-profile: local
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${DB_URL}
username: ${DB_USER}
password: ${DB_PASS}
url: ${LOCAL_DB_URL}
username: ${LOCAL_DB_USER}
password: ${LOCAL_DB_PASS}
jpa:
database: mysql
database-platform: org.hibernate.dialect.MySQLDialect
Expand Down
3 changes: 3 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ spring:
name: Ripple
profiles:
active: ${SPRING_PROFILES_ACTIVE:local} # 기본 프로파일을 'local'로 설정
ai:
openai:
api-key: ${OPENAI_API_KEY}

#JWT
jwt:
Expand Down