diff --git a/build.gradle b/build.gradle index 65cd1d0f..b8f76973 100644 --- a/build.gradle +++ b/build.gradle @@ -4,6 +4,10 @@ plugins { id 'io.spring.dependency-management' version '1.1.7' } +ext { + springAiVersion = "1.1.2" +} + group = 'com.kt' version = '0.0.1-SNAPSHOT' description = 'Demo project for Spring Boot' @@ -41,6 +45,9 @@ dependencies { implementation 'net.logstash.logback:logstash-logback-encoder:7.4' implementation 'io.micrometer:micrometer-registry-prometheus' + implementation platform("org.springframework.ai:spring-ai-bom:$springAiVersion") + implementation 'org.springframework.ai:spring-ai-starter-model-openai' + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' implementation 'io.jsonwebtoken:jjwt-api:0.13.0' @@ -79,6 +86,12 @@ dependencies { implementation 'com.bucket4j:bucket4j-caffeine:8.10.1' } +dependencyManagement { + imports { + mavenBom "org.springframework.ai:spring-ai-bom:$springAiVersion" + } +} + // fixed mockito error tasks.withType(Test).configureEach { def mockitoCoreJar = configurations.testRuntimeClasspath.find { diff --git a/src/main/java/com/kt/common/exception/ErrorCode.java b/src/main/java/com/kt/common/exception/ErrorCode.java index ba221884..2c68efc1 100644 --- a/src/main/java/com/kt/common/exception/ErrorCode.java +++ b/src/main/java/com/kt/common/exception/ErrorCode.java @@ -108,7 +108,13 @@ public enum ErrorCode { //wishlist ALREADY_WISHLISTED(HttpStatus.BAD_REQUEST, "이미 찜한 상품입니다."), WISHLIST_ADD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "찜 추가에 실패했습니다."), - NOT_FOUND_WISHLIST(HttpStatus.BAD_REQUEST, "찜하지 않은 상품입니다.") + NOT_FOUND_WISHLIST(HttpStatus.BAD_REQUEST, "찜하지 않은 상품입니다."), + + //vector + NOT_FOUND_VECTOR_STORE(HttpStatus.BAD_REQUEST, "존재하지 않는 벡터 스토어입니다."), + + //FAQ + NOT_FOUND_FAQ(HttpStatus.BAD_REQUEST, "존재하지 않는 자주 찾는 질문입니다.") ; private final HttpStatus status; diff --git a/src/main/java/com/kt/common/vector/VectorApi.java b/src/main/java/com/kt/common/vector/VectorApi.java new file mode 100644 index 00000000..28d08e90 --- /dev/null +++ b/src/main/java/com/kt/common/vector/VectorApi.java @@ -0,0 +1,15 @@ +package com.kt.common.vector; + +public interface VectorApi { + /** + * + * @param name 벡터 스토어 이름 + * @param description 벡터 스토어 설명 + * @return 생성된 벡터 스토어 ID + */ + String create(String name, String description); + + String uploadFile(String vectorStoreId, byte[] json); + + void delete(String vectorStoreId, String fileId); +} diff --git a/src/main/java/com/kt/config/SecurityConfiguration.java b/src/main/java/com/kt/config/SecurityConfiguration.java index 8cb3b6f0..a6a03a07 100644 --- a/src/main/java/com/kt/config/SecurityConfiguration.java +++ b/src/main/java/com/kt/config/SecurityConfiguration.java @@ -27,8 +27,7 @@ @RequiredArgsConstructor public class SecurityConfiguration { - - private static final String[] GET_PERMIT_ALL = {"/api/health/**", "/swagger-ui.html","/swagger-ui/**", "/v3/api-docs/**", "/actuator/**", "/payment-*.html", "/api/payments/client-key", "/*.css"}; + private static final String[] GET_PERMIT_ALL = {"/api/health/**", "/swagger-ui.html","/swagger-ui/**", "/v3/api-docs/**", "/actuator/**", "/payment-*.html", "/api/payments/client-key", "/*.css", "/api/chats", "/api/chats/**"}; private static final String[] POST_PERMIT_ALL = {"/api/users/auth/signup", "/api/users/auth/login","/api/admin/users/auth/signup", "/api/admin/users/auth/login","/api/users/reissue", "/api/payments/confirm"}; private static final String[] PUT_PERMIT_ALL = {"/api/v1/public/**"}; private static final String[] PATCH_PERMIT_ALL = {"/api/v1/public/**"}; diff --git a/src/main/java/com/kt/controller/chat/ChatApi.java b/src/main/java/com/kt/controller/chat/ChatApi.java new file mode 100644 index 00000000..67a327d5 --- /dev/null +++ b/src/main/java/com/kt/controller/chat/ChatApi.java @@ -0,0 +1,23 @@ +package com.kt.controller.chat; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.kt.common.response.ApiResult; +import com.kt.service.chat.ChatService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/chats") +@RequiredArgsConstructor +public class ChatApi { + private final ChatService chatService; + + @GetMapping + public ApiResult question(@RequestParam String query) { + return ApiResult.ok(chatService.questions(query)); + } +} \ No newline at end of file diff --git a/src/main/java/com/kt/controller/faq/AdminFAQController.java b/src/main/java/com/kt/controller/faq/AdminFAQController.java new file mode 100644 index 00000000..cd61c064 --- /dev/null +++ b/src/main/java/com/kt/controller/faq/AdminFAQController.java @@ -0,0 +1,46 @@ +package com.kt.controller.faq; + +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.kt.common.response.ApiResult; +import com.kt.dto.faq.FAQCreateRequest; +import com.kt.service.faq.FAQService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@PreAuthorize("hasRole('ADMIN')") +@Tag(name = "Admin FAQ", description = "FAQ 관리자용 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/faqs") +public class AdminFAQController { + private final FAQService fAQService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Operation(summary = "FAQ 생성") + public ApiResult create(@RequestBody @Valid FAQCreateRequest request) throws Exception { + fAQService.create(request); + return ApiResult.ok(); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "FAQ 삭제") + public ApiResult delete(@PathVariable Long id) { + fAQService.delete(id); + return ApiResult.ok(); + } + +} diff --git a/src/main/java/com/kt/domain/faq/Category.java b/src/main/java/com/kt/domain/faq/Category.java new file mode 100644 index 00000000..a791bc99 --- /dev/null +++ b/src/main/java/com/kt/domain/faq/Category.java @@ -0,0 +1,15 @@ +package com.kt.domain.faq; + +public enum Category { + ACCOUNT, + ADDRESS, + CART, + DISCOUNT, + MEMBERSHIP, + ORDER, + PAYMENT, + PRODUCT, + REVIEW, + VARIANT, + WISHLIST +} \ No newline at end of file diff --git a/src/main/java/com/kt/domain/faq/FAQ.java b/src/main/java/com/kt/domain/faq/FAQ.java new file mode 100644 index 00000000..bb8b4082 --- /dev/null +++ b/src/main/java/com/kt/domain/faq/FAQ.java @@ -0,0 +1,30 @@ +package com.kt.domain.faq; + +import com.kt.common.support.BaseEntity; + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +public class FAQ extends BaseEntity { + private String title; + private String content; + @Enumerated(EnumType.STRING) + private Category category; + private String fileId; + + public FAQ(String title, String content, Category category) { + this.title = title; + this.content = content; + this.category = category; + } + + public void updateFileId(String fileId) { + this.fileId = fileId; + } +} diff --git a/src/main/java/com/kt/domain/vector/Vector.java b/src/main/java/com/kt/domain/vector/Vector.java new file mode 100644 index 00000000..c953656d --- /dev/null +++ b/src/main/java/com/kt/domain/vector/Vector.java @@ -0,0 +1,24 @@ +package com.kt.domain.vector; + +import com.kt.common.support.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class Vector extends BaseEntity { + @Column(unique = true) + @Enumerated(EnumType.STRING) + private VectorType type; + private String storeId; + private String description; + private String name; +} diff --git a/src/main/java/com/kt/domain/vector/VectorType.java b/src/main/java/com/kt/domain/vector/VectorType.java new file mode 100644 index 00000000..d238b7df --- /dev/null +++ b/src/main/java/com/kt/domain/vector/VectorType.java @@ -0,0 +1,11 @@ +package com.kt.domain.vector; + +import java.util.List; + +public enum VectorType { + FAQ; + + public static List chatbotRange() { + return List.of(FAQ); + } +} diff --git a/src/main/java/com/kt/dto/faq/FAQCreateRequest.java b/src/main/java/com/kt/dto/faq/FAQCreateRequest.java new file mode 100644 index 00000000..bf165060 --- /dev/null +++ b/src/main/java/com/kt/dto/faq/FAQCreateRequest.java @@ -0,0 +1,20 @@ +package com.kt.dto.faq; + +import com.kt.domain.faq.Category; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record FAQCreateRequest( + @Schema(description = "FAQ 제목 (질문 형태 권장)", example = "환불은 얼마나 걸리나요?") + @NotBlank(message = "제목 입력은 필수입니다") + String title, + @Schema(description = "FAQ 답변 내용", example = "환불은 신청 후 영업일 2~3일 이내에 처리됩니다.") + @NotBlank(message = "내용 입력은 필수입니다") + String content, + @Schema(description = "FAQ 카테고리", example = "PAYMENT") + @NotNull(message = "카테고리 입력은 필수입니다") + Category category +) { +} diff --git a/src/main/java/com/kt/integration/config/OpenAIConfiguration.java b/src/main/java/com/kt/integration/config/OpenAIConfiguration.java new file mode 100644 index 00000000..1bcbe009 --- /dev/null +++ b/src/main/java/com/kt/integration/config/OpenAIConfiguration.java @@ -0,0 +1,39 @@ +package com.kt.integration.config; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.api.BaseAdvisor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +import com.kt.integration.openai.client.OpenAIClient; + +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +@ConditionalOnProperty( + prefix = "spring.ai.openai", + name = "enabled", + havingValue = "true" +) +public class OpenAIConfiguration { + @Bean + public ChatClient chatClient(ChatClient.Builder builder, BaseAdvisor openAICustomAdvisor) { + return builder + .defaultAdvisors(openAICustomAdvisor) + .build(); + } + + @Bean + public OpenAIClient openAIClient(RestClient.Builder builder) { + RestClient restClient = builder.build(); + HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(restClient)).build(); + + return factory.createClient(OpenAIClient.class); + } +} \ No newline at end of file diff --git a/src/main/java/com/kt/integration/openai/advisor/OpenAICustomAdvisor.java b/src/main/java/com/kt/integration/openai/advisor/OpenAICustomAdvisor.java new file mode 100644 index 00000000..de5f761e --- /dev/null +++ b/src/main/java/com/kt/integration/openai/advisor/OpenAICustomAdvisor.java @@ -0,0 +1,142 @@ +package com.kt.integration.openai.advisor; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; + +import org.jetbrains.annotations.NotNull; +import org.springframework.ai.chat.client.ChatClientRequest; +import org.springframework.ai.chat.client.ChatClientResponse; +import org.springframework.ai.chat.client.advisor.api.AdvisorChain; +import org.springframework.ai.chat.client.advisor.api.BaseAdvisor; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kt.domain.vector.Vector; +import com.kt.domain.vector.VectorType; +import com.kt.integration.openai.client.OpenAIClient; +import com.kt.integration.openai.dto.request.VectorSearchRequest; +import com.kt.integration.openai.dto.response.OpenAIResponse; +import com.kt.integration.openai.dto.response.OpenAIResponse.SearchData; +import com.kt.integration.properties.OpenAIProperties; +import com.kt.repository.vector.VectorRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty( + prefix = "spring.ai.openai", + name = "enabled", + havingValue = "true" +) +public class OpenAICustomAdvisor implements BaseAdvisor { + + private static final SearchData EMPTY_SEARCH_DATA = + new SearchData("", "", 0.0, null, null); + + private final OpenAIClient openAIClient; + private final OpenAIProperties openAIProperties; + private final ObjectMapper objectMapper; + private final VectorRepository vectorRepository; + + @NotNull + @Override + public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) { + var prompt = chatClientRequest.prompt(); + var message = prompt.getUserMessage().getText(); + + var parsing = message.split(":", 2); + if (parsing.length < 2 || parsing[1].isBlank()) { + return chatClientRequest; + } + + var request = new VectorSearchRequest(parsing[1]); + var ids = parsing[0].split(","); + + var candidateAnswers = new ArrayList(); + + Arrays.stream(ids).forEach(id -> { + var response = openAIClient.search( + id, + String.format("Bearer %s", openAIProperties.openai().apiKey()), + request + ); + + var searchData = response.data().stream() + .max(Comparator.comparingDouble(SearchData::score)) + .orElse(EMPTY_SEARCH_DATA); + + candidateAnswers.add(searchData); + }); + + var topScoreSearchData = candidateAnswers.stream() + .max(Comparator.comparingDouble(SearchData::score)) + .orElse(EMPTY_SEARCH_DATA); + + String answer = ""; + if (topScoreSearchData.content() != null) { + answer = topScoreSearchData.content().stream() + .map(c -> extractAnswerFromVectorResult(c.text())) + .filter(s -> s != null && !s.isBlank()) + .findFirst() + .orElse(""); + } + + String contextText; + + // FAQ가 없으면 안내, 있으면 FAQ JSON 기반 답변 + if (answer.isBlank()) { + contextText = "죄송하지만, 현재 FAQ에 등록된 정보 외에는 답변할 수 없습니다."; + } + + // FAQ가 있을 경우 기존 로직 그대로 + contextText = """ + 다음은 참고 정보이다. + 아래 내용을 기반으로 사용자의 질문에 답변하되, + 최종 답변 문장만 출력하라. + + [참고 정보] + %s + """.formatted(answer); + + var newPrompt = prompt.augmentSystemMessage(contextText); + + return chatClientRequest.mutate() + .prompt(newPrompt) + .build(); + } + + @Override + public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) { + return chatClientResponse; + } + + @Override + public int getOrder() { + return 0; + } + + // query 매칭 제거, answer만 추출 + private String extractAnswerFromVectorResult(String vectorText) { + try { + JsonNode arrayNode = objectMapper.readTree(vectorText); + if (arrayNode.isArray()) { + for (JsonNode node : arrayNode) { + if (node.has("answer")) { + return node.get("answer").asText(); + } + } + } + } catch (Exception e) { + // ignore + } + return ""; + } +} + diff --git a/src/main/java/com/kt/integration/openai/api/DefaultChatApi.java b/src/main/java/com/kt/integration/openai/api/DefaultChatApi.java new file mode 100644 index 00000000..721467f0 --- /dev/null +++ b/src/main/java/com/kt/integration/openai/api/DefaultChatApi.java @@ -0,0 +1,35 @@ +package com.kt.integration.openai.api; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty( + prefix = "spring.ai.openai", + name = "enabled", + havingValue = "true" +) +public class DefaultChatApi implements OpenAIChatApi { + + private final ChatClient clientClient; + + @Override + public String search(String query) { + return clientClient.prompt() + .system(""" + 너는 고객센터 챗봇이다. + 제공된 참고 정보를 바탕으로 질문에 답변하되, + 최종 답변 문장만 출력해라. + 설명, 출처, 문맥, JSON 형식은 절대 포함하지 마라. + 한두 문장으로 간결하게 답변해라. + """) + .user(query) + .call() + .content(); + } +} diff --git a/src/main/java/com/kt/integration/openai/api/DefaultVectorApi.java b/src/main/java/com/kt/integration/openai/api/DefaultVectorApi.java new file mode 100644 index 00000000..17061712 --- /dev/null +++ b/src/main/java/com/kt/integration/openai/api/DefaultVectorApi.java @@ -0,0 +1,82 @@ +package com.kt.integration.openai.api; + +import java.util.UUID; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; + +import com.kt.common.vector.VectorApi; +import com.kt.integration.openai.client.OpenAIClient; +import com.kt.integration.openai.dto.request.VectorCreateRequest; +import com.kt.integration.openai.dto.request.VectorUploadFileRequest; +import com.kt.integration.properties.OpenAIProperties; + +@Component +@Profile("!test") +public class DefaultVectorApi implements VectorApi { + private final OpenAIClient openAIClient; + private final OpenAIProperties openAIProperties; + private final String token; + + public DefaultVectorApi(OpenAIClient openAIClient, OpenAIProperties openAIProperties) { + this.openAIClient = openAIClient; + this.openAIProperties = openAIProperties; + this.token = "Bearer " + openAIProperties.openai().apiKey(); + } + + @Override + public String create(String name, String description) { + var response = openAIClient.create( + token, + new VectorCreateRequest(name, description) + ); + return response.id(); + } + + @Override + public String uploadFile(String vectorStoreId, byte[] json) { + var map = new LinkedMultiValueMap(); + + var fileResource = new ByteArrayResource( + json + ) { + @Override + public String getFilename() { + return String.format("%s.json", UUID.randomUUID()); + } + }; + + map.add("purpose", "assistants"); + map.add("file", fileResource); + + var response = openAIClient.upload( + token, + map + ); + + openAIClient.uploadVectorStore( + vectorStoreId, + token, + new VectorUploadFileRequest(response.id()) + ); + + return response.id(); + } + + @Override + public void delete(String vectorStoreId, String fileId) { + openAIClient.delete( + vectorStoreId, + fileId, + token + ); + + openAIClient.deleteFile( + fileId, + token + ); + } +} diff --git a/src/main/java/com/kt/integration/openai/api/OpenAIChatApi.java b/src/main/java/com/kt/integration/openai/api/OpenAIChatApi.java new file mode 100644 index 00000000..862c87c4 --- /dev/null +++ b/src/main/java/com/kt/integration/openai/api/OpenAIChatApi.java @@ -0,0 +1,5 @@ +package com.kt.integration.openai.api; + +public interface OpenAIChatApi { + String search(String query); +} diff --git a/src/main/java/com/kt/integration/openai/client/OpenAIClient.java b/src/main/java/com/kt/integration/openai/client/OpenAIClient.java new file mode 100644 index 00000000..6cfd2ecf --- /dev/null +++ b/src/main/java/com/kt/integration/openai/client/OpenAIClient.java @@ -0,0 +1,57 @@ +package com.kt.integration.openai.client; + +import org.springframework.http.MediaType; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.service.annotation.DeleteExchange; +import org.springframework.web.service.annotation.HttpExchange; +import org.springframework.web.service.annotation.PostExchange; + +import com.kt.integration.openai.dto.request.VectorCreateRequest; +import com.kt.integration.openai.dto.request.VectorSearchRequest; +import com.kt.integration.openai.dto.request.VectorUploadFileRequest; +import com.kt.integration.openai.dto.response.OpenAIResponse; + +@HttpExchange(url = "https://api.openai.com/v1", contentType = MediaType.APPLICATION_JSON_VALUE) +public interface OpenAIClient { + @PostExchange("/vector_stores") + OpenAIResponse.VectorCreate create( + @RequestHeader("Authorization") String authorization, + @RequestBody VectorCreateRequest request + ); + + @PostExchange(value = "/files", contentType = MediaType.MULTIPART_FORM_DATA_VALUE) + OpenAIResponse.Upload upload( + @RequestHeader("Authorization") String authorization, + @RequestBody MultiValueMap request + ); + + @PostExchange(value = "/vector_stores/{vector_store_id}/files") + void uploadVectorStore( + @PathVariable("vector_store_id") String vectorStoreId, + @RequestHeader("Authorization") String authorization, + @RequestBody VectorUploadFileRequest request + ); + + @DeleteExchange("/vector_stores/{vector_store_id}/files/{file_id}") + void delete( + @PathVariable("vector_store_id") String vectorStoreId, + @PathVariable("file_id") String fileId, + @RequestHeader("Authorization") String authorization + ); + + @DeleteExchange("/files/{file_id}") + void deleteFile( + @PathVariable("file_id") String fileId, + @RequestHeader("Authorization") String authorization + ); + + @PostExchange("/vector_stores/{vector_store_id}/search") + OpenAIResponse.Search search( + @PathVariable("vector_store_id") String vectorStoreId, + @RequestHeader("Authorization") String authorization, + @RequestBody VectorSearchRequest request + ); +} diff --git a/src/main/java/com/kt/integration/openai/dto/request/VectorCreateRequest.java b/src/main/java/com/kt/integration/openai/dto/request/VectorCreateRequest.java new file mode 100644 index 00000000..3f2c2a88 --- /dev/null +++ b/src/main/java/com/kt/integration/openai/dto/request/VectorCreateRequest.java @@ -0,0 +1,12 @@ +package com.kt.integration.openai.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record VectorCreateRequest( + @Schema(description = "벡터 스토어 이름", example = "FAQ_VectorStore") + String name, + + @Schema(description = "벡터 스토어 설명", example = "FAQ 챗봇용 벡터 저장소") + String description +) {} + diff --git a/src/main/java/com/kt/integration/openai/dto/request/VectorSearchRequest.java b/src/main/java/com/kt/integration/openai/dto/request/VectorSearchRequest.java new file mode 100644 index 00000000..f60c7648 --- /dev/null +++ b/src/main/java/com/kt/integration/openai/dto/request/VectorSearchRequest.java @@ -0,0 +1,9 @@ +package com.kt.integration.openai.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record VectorSearchRequest( + @Schema(description = "검색 쿼리", example = "환불 정책") + String query +) {} + diff --git a/src/main/java/com/kt/integration/openai/dto/request/VectorUploadFileRequest.java b/src/main/java/com/kt/integration/openai/dto/request/VectorUploadFileRequest.java new file mode 100644 index 00000000..56ea168d --- /dev/null +++ b/src/main/java/com/kt/integration/openai/dto/request/VectorUploadFileRequest.java @@ -0,0 +1,12 @@ +package com.kt.integration.openai.dto.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record VectorUploadFileRequest( + @Schema(description = "OpenAI에 업로드 된 파일 ID") + @JsonProperty("file_id") + String id +) { +} diff --git a/src/main/java/com/kt/integration/openai/dto/response/OpenAIResponse.java b/src/main/java/com/kt/integration/openai/dto/response/OpenAIResponse.java new file mode 100644 index 00000000..d869c08b --- /dev/null +++ b/src/main/java/com/kt/integration/openai/dto/response/OpenAIResponse.java @@ -0,0 +1,78 @@ +package com.kt.integration.openai.dto.response; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class OpenAIResponse { + public record VectorCreate( + String id, + String object, + @JsonProperty("created_at") + Long createdAt, + String name, + String description, + Long bytes, + @JsonProperty("file_counts") + FileCounts fileCounts + ) { + + } + + public record FileCounts( + @JsonProperty("in_progress") + int inProgress, + int completed, + int failed, + int cancelled, + int total + ) { + } + + public record Upload( + String id, + String object, + Long bytes, + @JsonProperty("created_at") + Long createdAt, + @JsonProperty("expires_at") + Long expiresAt, + String filename, + String purpose + ) { + } + + public record Search( + String object, + @JsonProperty("search_query") + List searchQuery, + List data, + @JsonProperty("has_more") + Boolean hasMore, + @JsonProperty("next_page") + Object nextPage + ) { + } + + public record SearchData( + @JsonProperty("file_id") + String fileId, + String filename, + Double score, + Attribute attributes, + List content + ) { + } + + public record Content( + String type, + String text + ) { + } + + public record Attribute( + String author, + String date + ) { + } +} diff --git a/src/main/java/com/kt/integration/openai/initializer/VectorStoreInitializer.java b/src/main/java/com/kt/integration/openai/initializer/VectorStoreInitializer.java new file mode 100644 index 00000000..57d37001 --- /dev/null +++ b/src/main/java/com/kt/integration/openai/initializer/VectorStoreInitializer.java @@ -0,0 +1,41 @@ +package com.kt.integration.openai.initializer; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.kt.domain.vector.Vector; +import com.kt.domain.vector.VectorType; +import com.kt.repository.vector.VectorRepository; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; + +@Component +@Profile("!test") +@RequiredArgsConstructor +public class VectorStoreInitializer { + + private final VectorRepository vectorRepository; + + @PostConstruct + @Transactional + public void init() { + String storeId = "vs_695c6f5c5c308191ac81f6368270b637"; + + // type 기준으로 체크 + if (vectorRepository.existsByType(VectorType.FAQ)) { + return; + } + + vectorRepository.save( + new Vector( + VectorType.FAQ, + storeId, + "FAQ_VectorStore", + "FAQ 챗봇용 벡터 스토어" + ) + ); + } +} + diff --git a/src/main/java/com/kt/integration/properties/OpenAIProperties.java b/src/main/java/com/kt/integration/properties/OpenAIProperties.java new file mode 100644 index 00000000..aba747ca --- /dev/null +++ b/src/main/java/com/kt/integration/properties/OpenAIProperties.java @@ -0,0 +1,27 @@ +package com.kt.integration.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "spring.ai") +public record OpenAIProperties( + OpenAI openai, + Model model +) { + public record OpenAI( + Chat chat, + String apiKey + ) { + public record Chat( + ChatOptions options + ) { + public record ChatOptions( + int temperature, + String model + ) {} + } + } + + public record Model( + String embedding + ) {} +} diff --git a/src/main/java/com/kt/repository/faq/FAQRepository.java b/src/main/java/com/kt/repository/faq/FAQRepository.java new file mode 100644 index 00000000..652314f4 --- /dev/null +++ b/src/main/java/com/kt/repository/faq/FAQRepository.java @@ -0,0 +1,8 @@ +package com.kt.repository.faq; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.kt.domain.faq.FAQ; + +public interface FAQRepository extends JpaRepository { +} diff --git a/src/main/java/com/kt/repository/vector/VectorRepository.java b/src/main/java/com/kt/repository/vector/VectorRepository.java new file mode 100644 index 00000000..919762a5 --- /dev/null +++ b/src/main/java/com/kt/repository/vector/VectorRepository.java @@ -0,0 +1,17 @@ +package com.kt.repository.vector; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.kt.domain.vector.Vector; +import com.kt.domain.vector.VectorType; + +public interface VectorRepository extends JpaRepository { + Boolean existsByType(VectorType type); + + Optional findByType(VectorType type); + + List findByTypeIn(List type); +} diff --git a/src/main/java/com/kt/service/chat/ChatService.java b/src/main/java/com/kt/service/chat/ChatService.java new file mode 100644 index 00000000..bfbbc108 --- /dev/null +++ b/src/main/java/com/kt/service/chat/ChatService.java @@ -0,0 +1,31 @@ +package com.kt.service.chat; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.kt.domain.vector.Vector; +import com.kt.domain.vector.VectorType; +import com.kt.integration.openai.api.OpenAIChatApi; +import com.kt.repository.vector.VectorRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@Transactional +@RequiredArgsConstructor +@Slf4j +public class ChatService { + private final OpenAIChatApi openAIChatApi; + private final VectorRepository vectorRepository; + + public String questions(String query) { + var ids = vectorRepository.findByTypeIn(VectorType.chatbotRange()).stream().map(Vector::getStoreId).toList(); + + log.info("사용 중인 vector store ids = {}", ids); + + var newQuery = String.format("%s:%s", String.join(",", ids), query); + + return openAIChatApi.search(newQuery); + } +} diff --git a/src/main/java/com/kt/service/faq/FAQService.java b/src/main/java/com/kt/service/faq/FAQService.java new file mode 100644 index 00000000..ef244375 --- /dev/null +++ b/src/main/java/com/kt/service/faq/FAQService.java @@ -0,0 +1,56 @@ +package com.kt.service.faq; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kt.common.exception.CustomException; +import com.kt.common.exception.ErrorCode; +import com.kt.common.vector.VectorApi; +import com.kt.domain.faq.FAQ; +import com.kt.domain.vector.VectorType; +import com.kt.dto.faq.FAQCreateRequest; +import com.kt.repository.faq.FAQRepository; +import com.kt.repository.vector.VectorRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional +@RequiredArgsConstructor +public class FAQService { + private final FAQRepository fAQRepository; + private final VectorApi vectorApi; + private final VectorRepository vectorRepository; + private final ObjectMapper objectMapper; + + public void create(FAQCreateRequest request) throws Exception { + var faq = fAQRepository.save( + new FAQ( + request.title(), + request.content(), + request.category() + ) + ); + + var vector = vectorRepository.findByType(VectorType.FAQ) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_VECTOR_STORE)); + + var fileId = vectorApi.uploadFile(vector.getStoreId(), objectMapper.writeValueAsBytes(faq)); + + faq.updateFileId(fileId); + } + + public void delete(Long id) { + var faq = fAQRepository.findById(id).orElseThrow( + () -> new CustomException(ErrorCode.NOT_FOUND_FAQ) + ); + + var vector = vectorRepository.findByType(VectorType.FAQ) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_VECTOR_STORE)); + + vectorApi.delete(vector.getStoreId(), faq.getFileId()); + + fAQRepository.delete(faq); + } +} diff --git a/src/main/java/com/kt/service/vector/VectorService.java b/src/main/java/com/kt/service/vector/VectorService.java new file mode 100644 index 00000000..5b4aea4a --- /dev/null +++ b/src/main/java/com/kt/service/vector/VectorService.java @@ -0,0 +1,48 @@ +package com.kt.service.vector; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.kt.common.vector.VectorApi; +import com.kt.domain.vector.Vector; +import com.kt.domain.vector.VectorType; +import com.kt.repository.vector.VectorRepository; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; + +@Service +@Transactional +@RequiredArgsConstructor +public class VectorService { + private final VectorRepository vectorRepository; + private final VectorApi vectorApi; + + @PostConstruct + void init() { + if (!vectorRepository.existsByType(VectorType.FAQ)) { + var name = "FAQ 벡터 스토어"; + var description = "자주 묻는 질문(FAQ) 데이터를 위한 벡터 스토어입니다."; + + var vectorStoreId = vectorApi.create(name, description); + + create( + vectorStoreId, + name, + description, + VectorType.FAQ + ); + } + } + + public void create(String storeId, String name, String description, VectorType type) { + vectorRepository.save( + new Vector( + type, + storeId, + description, + name + ) + ); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 01f84f45..552d7b4c 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -29,6 +29,16 @@ spring: timeout: 5000 starttls: enable: true + ai: + model: + embedding: text-embedding-3-small + openai: + enabled: true + api-key: ${OPENAI_API_KEY} + chat: + options: + temperature: 1 + model: o4-mini jwt: secret: ${kt.jwt.secret} diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index f7a5898c..b8a7940c 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -19,13 +19,13 @@ - - - - - - - + + + + + + + diff --git a/src/test/java/com/kt/common/ratelimit/vector/FakeVectorApi.java b/src/test/java/com/kt/common/ratelimit/vector/FakeVectorApi.java new file mode 100644 index 00000000..97982df3 --- /dev/null +++ b/src/test/java/com/kt/common/ratelimit/vector/FakeVectorApi.java @@ -0,0 +1,29 @@ +package com.kt.common.ratelimit.vector; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import com.kt.common.vector.VectorApi; + +// 테스트용 Fake 구현체 +@Profile("test") +@Component +public class FakeVectorApi implements VectorApi { + + @Override + public String create(String name, String description) { + // 테스트용 더미 vector store id + return "fake-vector-store-id"; + } + + @Override + public String uploadFile(String vectorStoreId, byte[] json) { + // 테스트용 더미 file id + return "fake-file-id"; + } + + @Override + public void delete(String vectorStoreId, String fileId) { + // 테스트에서는 아무 작업도 하지 않음 + } +} diff --git a/src/test/java/com/kt/integration/FakeChatApi.java b/src/test/java/com/kt/integration/FakeChatApi.java new file mode 100644 index 00000000..bd49e5f1 --- /dev/null +++ b/src/test/java/com/kt/integration/FakeChatApi.java @@ -0,0 +1,17 @@ +package com.kt.integration; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import com.kt.integration.openai.api.OpenAIChatApi; + +@Component +@Profile("test") +public class FakeChatApi implements OpenAIChatApi { + + @Override + public String search(String query) { + // CI 테스트용, 실제 답변 필요 없음 + return "테스트용 답변"; + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index c59b94db..afc411c5 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -20,6 +20,16 @@ spring: autoconfigure: exclude: - org.redisson.spring.starter.RedissonAutoConfigurationV2 + ai: + model: + embedding: text-embedding-3-small + openai: + enabled: false + api-key: ${OPENAI_API_KEY} + chat: + options: + temperature: 1 + model: o4-mini # data: # redis: # host: ${redis.host:localhost} @@ -28,4 +38,4 @@ spring: jwt: secret: kt-cloud-tech-up-shopping-202511171107 access-token-expiration: 300000 - refresh-token-expiration: 43200000 \ No newline at end of file + refresh-token-expiration: 43200000