이번에는 GPT를 적용하는 내용을 정리할 것이다.
openAi를 호출하는 것이기에 큰 어려움은 없다.
https://www.baeldung.com/spring-boot-chatgpt-api-openai
위 내용을 참고하였다.
따라서 중복되는 내용은 생략하여 진행하겠다.
Chat
@Entity
@Table(name = "CHAT")
public class Chat extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "chat_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@OnDelete(action = OnDeleteAction.CASCADE)
private Member member;
protected Chat() {
}
private Chat(final Long id, final Member member) {
this.id = id;
this.member = member;
}
public static Chat createDefault(final Member member) {
return new Chat(null, member);
}
public Long getId() {
return id;
}
public Member getMember() {
return member;
}
}
채팅방에 대한 도메인이다.
Conversation
@Entity
@Table(name = "CONVERSATION")
public class Conversation extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "conversation_id")
private Long id;
@Column(nullable = false)
@Lob
private String ask;
@Column(nullable = false)
@Lob
private String answer;
@ManyToOne(fetch = FetchType.LAZY)
@OnDelete(action = OnDeleteAction.CASCADE)
private Chat chat;
protected Conversation() {
}
private Conversation(final Long id,
final String ask,
final String answer,
final Chat chat) {
this.id = id;
this.ask = ask;
this.answer = answer;
this.chat = chat;
}
public static Conversation from(final String ask,
final String answer,
final Chat chat) {
return new Conversation(null, ask, answer, chat);
}
public Long getId() {
return id;
}
public String getAsk() {
return ask;
}
public String getAnswer() {
return answer;
}
public Chat getChat() {
return chat;
}
}
채팅 기록에 대한 도메인이다. ask과 answer은 말 그대로 질문과 응답에 대한 결과이다.
ChatController
@RequestMapping("/chats")
@RestController
public class ChatController {
private final ChatService chatService;
public ChatController(final ChatService chatService) {
this.chatService = chatService;
}
@PostMapping
public ResponseEntity<Void> makeChattingRoom(@JwtLogin final Member member) {
Long chatId = chatService.createNewChattingRoom(member);
return ResponseEntity.created(URI.create("/chats/" + chatId))
.build();
}
@GetMapping("/{chatId}")
public ResponseEntity<ChattingHistoryResponse> joinChattingRoom(@PathVariable final Long chatId) {
return ResponseEntity.ok(chatService.joinChattingRoom(chatId));
}
@PostMapping("/{chatId}")
public ResponseEntity<ConversationResponse> useChatBot(@RequestBody final ChatPromptRequest prompt,
@PathVariable final Long chatId) {
Conversation conversation = chatService.useRawChatBot(prompt.getPrompt(), chatId);
return ResponseEntity.ok(ConversationResponse.from(conversation));
}
}
메서드 네이밍에서 동작을 유추할 수 있듯이 채팅방을 만들고, 채팅방에 접속하고, gpt를 사용하는 것이다.
ChatService
@Service
public class ChatService {
private final WordRepository wordRepository;
private final ConversationRepository conversationRepository;
private final ChatRepository chatRepository;
private final ChatBot chatBot;
public ChatService(final WordRepository wordRepository,
final ConversationRepository conversationRepository,
final ChatRepository chatRepository,
final ChatBot chatBot) {
this.wordRepository = wordRepository;
this.conversationRepository = conversationRepository;
this.chatRepository = chatRepository;
this.chatBot = chatBot;
}
@Transactional
public Long createNewChattingRoom(final Member member) {
Chat chat = chatRepository.save(Chat.createDefault(member));
return chat.getId();
}
@Transactional
public Conversation useRawChatBot(final String prompt, final Long chatId) {
Chat chat = findChat(chatId);
Words pureWords = Words.fromRawPrompt(prompt);
Words duplicatedWords = Words.ofPureWords(wordRepository.findAllByWords(pureWords.getWordsToString()));
duplicatedWords.updateFrequencyCount();
List<Word> newWords = duplicatedWords.findNotContainsWordsFromOthers(pureWords.getWords());
wordRepository.saveAll(newWords);
String rawAnswer = chatBot.getRawAnswer(prompt);
return conversationRepository.save(Conversation.from(prompt, rawAnswer, chat));
}
private Chat findChat(final Long chatId) {
return chatRepository.findById(chatId)
.orElseThrow(() -> new ChattingRoomNotFoundException(chatId));
}
@Transactional(readOnly = true)
public ChattingHistoryResponse joinChattingRoom(final Long chatId) {
Chat chat = findChat(chatId);
List<Conversation> conversationsHistory = conversationRepository.findAllByChat(chat);
return ChattingHistoryResponse.from(chat, conversationsHistory);
}
}
다른 부분은 딱히 특별한 것이 없다.
gpt를 사용하는 useRawChatBot을 보자.
우선 raw라는 명칭을 붙인 이유는 현재 gpt답변을 매운맛버전과 순한 맛 버전으로 만들 생각이기에 나누었다.
내부 로직에 Word객체를 만드는데, 이는 추후에 실시간 검색 단어 순위나 여러 통계를 만들기 위해 추가한 기능이다.
이미 있는 단어라면 DB에 카운트를 증가시키고 없는 단어면 새롭게 DB에 저장해 준다.
자세한 코드는 아래 깃허브 링크를 달겠다.
GptChatBot
@Component
public class GptChatBot implements ChatBot {
@Value("${api.gpt_prefix_helper}")
private String PREFIX_HELPER;
@Value("${api.gpt_prefix_starter}")
private String PREFIX_STARTER;
private static final String CHAT_BOT_SYSTEM = "system";
private static final String USER = "user";
private static final String ENDPOINT = "https://api.openai.com/v1/chat/completions";
private static final String MODEL = "gpt-3.5-turbo";
private static final int CHOICE_INDEX = 0;
@Qualifier("openaiRestTemplate")
private final RestTemplate restTemplate;
public GptChatBot(final RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
@Override
public String getRawAnswer(final String prompt) {
ChatResponse chatBotAnswer = getChatBotAnswer(prompt);
return filterAnswer(chatBotAnswer);
}
private ChatResponse getChatBotAnswer(final String prompt) {
ChatRequest promptRequest = preparePrompt(prompt);
return restTemplate.postForObject(ENDPOINT, promptRequest, ChatResponse.class);
}
private ChatRequest preparePrompt(final String prompt) {
List<Message> messages = new ArrayList<>();
messages.add(new Message(CHAT_BOT_SYSTEM, PREFIX_HELPER));
messages.add(new Message(CHAT_BOT_SYSTEM, PREFIX_STARTER));
messages.add(new Message(USER, prompt));
return new ChatRequest(MODEL, messages);
}
private String filterAnswer(final ChatResponse chatBotAnswer) {
if (isFailureResponse(chatBotAnswer)) {
throw new OpenAIErrorException();
}
return chatBotAnswer.getChoices()
.get(CHOICE_INDEX)
.getMessage()
.getContent();
}
private boolean isFailureResponse(final ChatResponse chatBotAnswer) {
return chatBotAnswer == null || chatBotAnswer.getChoices() == null || chatBotAnswer.getChoices().isEmpty();
}
@Override
public String getMildAnswer(final String prompt) {
return null;
}
}
프롬프트를 암호화해서 사용하였다.
메서드 분리를 위해 따로 GptChatBot 클래스를 만들어주었다.
나머지는 기존 사용법과 비슷하다.
Test
테스트도 간단하게 작성할 수 있었다.
대표적으로 컨트롤러 통합테스트와 서비스 통합테스트만 작성하겠다.
ChatControllerIntegrationTest
public class ChatControllerIntegrationTest extends IntegrationTest {
@Autowired
private AuthService authService;
@Autowired
private ChatRepository chatRepository;
@Autowired
private MemberRepository memberRepository;
@Autowired
private ConversationRepository conversationRepository;
private Member member;
private String token;
@BeforeEach
void setUp() {
authService.register(new MemberCreateRequest("a@a.com", "1234"));
member = memberRepository.findByEmail("a@a.com").get();
MemberLoginRequest memberLoginRequest = new MemberLoginRequest("a@a.com", "1234");
token = authService.login(memberLoginRequest);
}
@DisplayName("채팅방을 생성한다.")
@Test
void create_new_chatting_room() {
// when
var result = RestAssured.given().log().all()
.auth().preemptive().oauth2(token)
.when()
.post("/chats")
.then().log().all()
.extract();
// then
assertAll(
() -> assertThat(result.statusCode()).isEqualTo(HttpStatus.CREATED.value()),
() -> assertThat(result.header("Location")).isEqualTo("/chats/1")
);
}
@DisplayName("기존 채팅방에 접속한다.")
@Test
void join_being_chatting_room() {
// given
Chat chat = chatRepository.save(Chat.createDefault(member));
Conversation conversation = Conversation.from("ask", "answer", chat);
conversationRepository.save(conversation);
// when
var result = RestAssured.given().log().all()
.auth().preemptive().oauth2(token)
.when()
.get("/chats/1")
.then().log().all()
.extract();
// then
assertThat(result.statusCode()).isEqualTo(HttpStatus.OK.value());
}
}
ChatServiceIntegrationTest
public class ChatServiceIntegrationTest extends IntegrationTest {
@Autowired
private ChatService chatService;
@Autowired
private WordRepository wordRepository;
@Autowired
private ConversationRepository conversationRepository;
@Autowired
private ChatRepository chatRepository;
@Autowired
private MemberRepository memberRepository;
@DisplayName("새로운 채팅방을 만든다.")
@Test
void create_new_chatting_room() {
// given
Member member = memberRepository.save(createMember());
// when
Long id = chatService.createNewChattingRoom(member);
// then
assertThat(id).isEqualTo(1L);
}
@DisplayName("기존 채팅방에 들어간다.")
@Test
void join_being_chatting_room() {
// given
Member member = memberRepository.save(createMember());
Chat chat = chatRepository.save(Chat.createDefault(member));
conversationRepository.save(Conversation.from("ask", "answer", chat));
conversationRepository.save(Conversation.from("ask2", "answer2", chat));
// when
ChattingHistoryResponse result = chatService.joinChattingRoom(chat.getId());
// then
assertThat(result.getConversations().size()).isEqualTo(2);
}
}
테스트 결과
사용해 보기
우선 이를 포스트맨을 통해 확인해 보자.
우선 채팅방을 생성했다. - 1번
채팅방 접속하기
이제 도서관이 어디 있는지 물어보았다.
생각보다 수위가 쌔서 4번의 시도 끝에 그나마 적저 한 답변을 받았다..
앞서 단어에 대한 카운트를 DB에 저장해 두었다.(4번 시도함)
채팅 기록이 아래와 같이 남는다. 수위가 조금 있어서 살짝 이게 맞나 싶기도 하지만 순한 버전도 만들면 되니까..
마무리
현재 패키지네이밍을 controller -> presentation으로 수정하였다.
또한 DTO의 경우 대부분 서비스에서 DTO를 씌워서 컨트롤러로 넘겨주었는데, 이를 컨트롤러에서 씌워주는 것으로 수정 중에 있다.
엄밀히 따지면 이게 맞다는 판단으로 이렇게 진행하는 중이다.
또한 단어 파싱, GPT 관련해서 아직 리펙토링이 많이 필요하여 진행 중에 있다.
다음 구현
우선 앞서 말했듯이 리펙토링을 해야 할 부분이 좀 많이 있다.
이를 수정해야 한다.
또한 동시성 처리를 해야 한다. GPT를 여러 사람이 접근할 때 데이터 정합성을 제대로 처리해야 한다.
페이징 처리의 경우에도 사실 데이터가 별로 없으면 상관없지만 큰 데이터를 다룬다는 생각으로 성능 최적화를 할 예정이다.
첫 번째로 생각나는 부분은 noOffset을 사용하거나 커버링 인덱스를 사용하는 방법이다.
이는 추후에 다시 작성하겠다.
사실 오늘은 특별하게 설명을 많이 하지 않았다.
openAi 사용법은 위 링크에도 있듯이 매뉴얼대로 사용한 느낌이 강했기 때문이다.
하지만 여러 설정이나 암호화는 따로 코드를 볼 가치가 있다고 생각한다.
위 프로젝트에 대한 자세한 코드는 아래 깃허브 링크를 통해 확인할 수 있습니다.
https://github.com/kimtaesoo99/ChatUniv/tree/develop
'프로젝트 > ChatUniv' 카테고리의 다른 글
[프로젝트] ChatUniv 페이징 최적화 (2) | 2023.09.02 |
---|---|
[프로젝트] ChatUniv Comment API, 동적 테스트 #4 (0) | 2023.07.30 |
[프로젝트] ChatUniv Board API #3 (0) | 2023.07.16 |
[프로젝트] ChatUniv Member API #2 (0) | 2023.07.16 |
[프로젝트] ChatUniv 전반적인 설계 및 Auth구조 #1 (0) | 2023.07.04 |