Skip to content

Commit

Permalink
feat: GPT 컴포넌트 구현 (#9)
Browse files Browse the repository at this point in the history
* chore: gpt module 추가

* feat: gpt 연동

* chore: 패키지명 peomfoot -> poemfoot 수정

* feat: GptPoemRequest 요청 구조 변경

* feat: Gpt 모듈 Swagger 설정 추가

* feat: CORS 관련 설정 추가

* chore: application.yml db 및 jpa 설정 추가

* feat: 예외처리 관련 설정 추가

* feat: BaseTime 관련 설정 추가

* chore: 디렉토리 구조 변경

* feat: gpt 질문 및 답변 구조 구현

* feat: 일일 gpt request 횟수 제한 기능 구현

* docs: gpt관련 환경변수 추가

* style: 문자 join 방식 변경

* style: 구조 변경

* chore: 패키지 구조 변경

* feat: jpa-repository 관련 로직 api 모듈에서 실행되도록 구현

* test: gpt 모듈 관련 테스트 임시 비활성화 처리

* fix: 재사용 시에도 질문 및 응답이 저장되는 문제 해결

GptChatPoemResponse에 reuse변수 추가하였습니다.

* feat: gptRequestCount관련 예외 처리 추가

너무 많은 요청이 들어와 count가 제대로 되지 않는 경우 처리했습니다.

* feat: gptRequestCount 동시성 예외 처리

* chore: BaseTime 패키지 위치 이동

* fix: test용 gpt api-key값 추가

* test: GptAnswerService 관련 테스트 추가

* test: GptQuestionService 관련 테스트 추가

* fix: bootJar 관련 버그 수정

main 함수 추가

---------

Co-authored-by: Shinyoung Kim <[email protected]>
  • Loading branch information
0703kyj and rolroralra authored Jan 31, 2024
1 parent 19ac696 commit 75467cf
Show file tree
Hide file tree
Showing 39 changed files with 867 additions and 27 deletions.
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,9 @@ docker login poten16.kr.ncr.ntruss.com
```
# 환경변수
|이름 | 설명|
|-- | --|
|MYSQL_URL | MYSQL 주소입니다 (JDBC 형태여야 합니다)|
|MYSQL_USERNAME | MYSQL 사용자 명 입니다.|
|MYSQL_PASSWORD | MYSQL 비밀번호 입니다.|
|이름 | 설명 |
|-- |-----------------------------|
|MYSQL_URL | MYSQL 주소입니다 (JDBC 형태여야 합니다) |
|MYSQL_USERNAME | MYSQL 사용자 명 입니다. |
|MYSQL_PASSWORD | MYSQL 비밀번호 입니다. |
|GPT_TOKEN | GPT API-KEY 입니다.|
1 change: 1 addition & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
rootProject.name = 'java-sprintboot-subprojects'

include 'api'
include 'gpt'
include 'w3w'

rootProject.children.forEach { project ->
Expand Down
1 change: 1 addition & 0 deletions subprojects/api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dependencies {
//swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'

implementation project(':gpt')
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;

@SpringBootApplication
@ComponentScan(basePackages = {"com.poemfoot.api", "com.poemfoot.gpt"})
public class ApiApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.poemfoot.api.domain;

import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import java.sql.Timestamp;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseTime {
@CreatedDate
@Column(name = "created_time", updatable = false)
private Timestamp createdTime;

@LastModifiedDate
@Column(name = "modified_time")
private Timestamp modifiedTime;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package com.poemfoot.api.domain.gpt.controller;

import com.poemfoot.api.global.dto.error.ErrorResponse;
import com.poemfoot.gpt.exception.GptException;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.tomcat.util.http.fileupload.FileUploadException;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.FieldError;
import org.springframework.web.HttpMediaTypeException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

@Slf4j
@RequiredArgsConstructor
@RestControllerAdvice
public class ControllerAdvice {

private static final int FIELD_ERROR_CODE_INDEX = 0;
private static final int FIELD_ERROR_MESSAGE_INDEX = 1;

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleInputFieldException(MethodArgumentNotValidException e) {
FieldError mainError = e.getFieldErrors().get(0);
String[] errorInfo = Objects.requireNonNull(mainError.getDefaultMessage()).split(":");

int code = Integer.parseInt(errorInfo[FIELD_ERROR_CODE_INDEX]);
String message = errorInfo[FIELD_ERROR_MESSAGE_INDEX];

return ResponseEntity.badRequest().body(new ErrorResponse(code, message));
}

@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ErrorResponse> handleJsonException(HttpMessageNotReadableException e) {
log.warn("Json Exception ErrMessage={}\n", e.getMessage());

return ResponseEntity.badRequest()
.body(new ErrorResponse(9000, "Json 형식이 올바르지 않습니다."));
}

@ExceptionHandler(HttpMediaTypeException.class)
public ResponseEntity<ErrorResponse> handleContentTypeException(HttpMediaTypeException e) {
log.warn("ContentType Exception ErrMessage={}\n", e.getMessage());

return ResponseEntity.badRequest()
.body(new ErrorResponse(9001, "ContentType 값이 올바르지 않습니다."));
}

@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ErrorResponse> handleRequestMethodException(HttpRequestMethodNotSupportedException e) {
log.warn("Http Method not supported Exception ErrMessage={}\n", e.getMessage());

return ResponseEntity.badRequest()
.body(new ErrorResponse(9002, "해당 Http Method에 맞는 API가 존재하지 않습니다."));
}

@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ErrorResponse> handleMissingRequestParamException(MissingServletRequestParameterException e) {
log.warn("Request Param is Missing! ErrMessage={}\n", e.getMessage());

return ResponseEntity.badRequest()
.body(new ErrorResponse(9003, "요청 param 이름이 올바르지 않습니다."));
}

@ExceptionHandler(FileUploadException.class)
public ResponseEntity<ErrorResponse> handleMissingMultiPartParamException(FileUploadException e) {
log.warn("File Upload Exception! Please check request. ErrMessage={}\n", e.getMessage());

return ResponseEntity.badRequest()
.body(new ErrorResponse(9004, "요청 파일이 올바르지 않습니다. 파일 손상 여부나 요청 형식을 확인해주세요."));
}

@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ErrorResponse> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) {
log.warn("Invalid date format! Please provide the date in the format yyyy-MM-dd. ErrMessage={}\n", e.getMessage());

return ResponseEntity.badRequest()
.body(new ErrorResponse(9005, "올바른 날짜 형식으로 입력해주세요."));
}

@ExceptionHandler(GptException.class)
public ResponseEntity<ErrorResponse> handleGptException(GptException e) {
return ResponseEntity.status(e.getHttpStatus()).body(new ErrorResponse(e.getCode(), e.getMessage()));
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> unhandledException(Exception e, HttpServletRequest request) {
log.error("UnhandledException: {} {} errMessage={}\n",
request.getMethod(),
request.getRequestURI(),
e.getMessage()
);
return ResponseEntity.internalServerError()
.body(new ErrorResponse(9999, "일시적으로 접속이 원활하지 않습니다. 서버 팀에 문의 부탁드립니다."));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.poemfoot.api.domain.gpt.controller;

import com.poemfoot.api.domain.gpt.domain.GptAnswer;
import com.poemfoot.api.domain.gpt.service.GptAnswerService;
import com.poemfoot.api.domain.gpt.service.GptQuestionService;
import com.poemfoot.gpt.dto.request.GptChatPoemRequest;
import com.poemfoot.gpt.dto.response.chat.GptChatPoemResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
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;

@RestController
@RequiredArgsConstructor
@Tag(name = "GPT", description = "GPT 시 생성 관련 API")
@RequestMapping("/api/v1/gpt")
public class GptPoemController {

private final GptQuestionService gptQuestionService;
private final GptAnswerService gptAnswerService;

@Operation(summary = "Gpt 시 생성")
@PostMapping("/completion/chat")
public ResponseEntity<GptChatPoemResponse> completionChat(
final @RequestBody GptChatPoemRequest request) {
GptChatPoemResponse response = gptQuestionService.requestPoem(request);

if (!response.isReuse()) {
GptAnswer gptAnswer = gptAnswerService.saveAnswer(response);
gptQuestionService.saveQuestion(request, gptAnswer);
}
return ResponseEntity.ok(response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.poemfoot.api.domain.gpt.domain;

import com.poemfoot.api.domain.BaseTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class GptAnswer extends BaseTime {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(length = 500, nullable = false)
private String answer;

private String object;

private String model;

public GptAnswer(String answer, String object, String model) {
this.answer = answer;
this.object = object;
this.model = model;
}


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.poemfoot.api.domain.gpt.domain;

import com.poemfoot.api.domain.BaseTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "gpt_question")
public class GptQuestion extends BaseTime {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "question", nullable = false)
private String question;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "answer_id")
private GptAnswer answer;

public GptQuestion(String question, GptAnswer answer) {
this.question = question;
this.answer = answer;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.poemfoot.api.domain.gpt.repository;

import com.poemfoot.api.domain.gpt.domain.GptAnswer;
import org.springframework.data.jpa.repository.JpaRepository;

public interface GptAnswerRepository extends JpaRepository<GptAnswer,Long> {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.poemfoot.api.domain.gpt.repository;

import com.poemfoot.api.domain.gpt.domain.GptQuestion;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface GptQuestionRepository extends JpaRepository<GptQuestion, Long> {

Optional<GptQuestion> findFirstByQuestion(String question);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.poemfoot.api.domain.gpt.service;

import com.poemfoot.api.domain.gpt.domain.GptAnswer;
import com.poemfoot.api.domain.gpt.repository.GptAnswerRepository;
import com.poemfoot.gpt.dto.response.chat.GptChatPoemResponse;

import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class GptAnswerService {

private final GptAnswerRepository gptAnswerRepository;

@Transactional
public GptAnswer saveAnswer(GptChatPoemResponse response) {

return gptAnswerRepository.save(new GptAnswer(getAnswer(response), response.getObject(),
response.getObject()));
}

private String getAnswer(GptChatPoemResponse response) {
return responseToMessage(response).stream()
.filter(Objects::nonNull)
.collect(Collectors.joining());
}

private List<String> responseToMessage(GptChatPoemResponse response) {
return response.getMessages().stream()
.toList();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.poemfoot.api.domain.gpt.service;

import com.poemfoot.api.domain.gpt.domain.GptAnswer;
import com.poemfoot.api.domain.gpt.domain.GptQuestion;
import com.poemfoot.api.domain.gpt.repository.GptQuestionRepository;
import com.poemfoot.gpt.dto.request.GptChatPoemRequest;
import com.poemfoot.gpt.dto.response.chat.GptChatPoemResponse;
import com.poemfoot.gpt.exception.badrequest.GptOverRequestException;
import com.poemfoot.gpt.exception.toomanyrequest.GptTooManyRequestException;
import com.poemfoot.gpt.service.GptPoemProvider;

import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class GptQuestionService {

private final GptPoemProvider gptPoemProvider;
private final GptQuestionRepository gptQuestionRepository;

@Transactional
public GptChatPoemResponse requestPoem(GptChatPoemRequest request) {
Optional<GptQuestion> question = gptQuestionRepository
.findFirstByQuestion(getQuestion(request));

if (question.isPresent()) {
GptAnswer answer = question.get().getAnswer();
return GptChatPoemResponse.of(
answer.getId().toString(),
answer.getObject(),
answer.getModel(),
answer.getAnswer());
}
return gptPoemProvider.completionChat(request)
.orElseThrow(GptTooManyRequestException::new);
}

@Transactional
public GptQuestion saveQuestion(GptChatPoemRequest request, GptAnswer answer) {
return gptQuestionRepository.save(new GptQuestion(getQuestion(request), answer));
}

private String getQuestion(GptChatPoemRequest request) {
return Stream.concat(request.getWords().stream(), Stream.of(request.getLocation()))
.collect(Collectors.joining(","));
}
}
Loading

0 comments on commit 75467cf

Please sign in to comment.