From b40ab8a13d82bf732acc8334af07344f6a9b2a1c Mon Sep 17 00:00:00 2001 From: jimin Date: Wed, 31 Dec 2025 14:28:09 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20AI=20=ED=8F=B4=EB=8D=94=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 13 +++ .../com/kt/common/exception/ErrorCode.java | 8 +- .../java/com/kt/common/vector/VectorApi.java | 15 ++++ .../kt/controller/faq/AdminFAQController.java | 46 +++++++++++ src/main/java/com/kt/domain/faq/Category.java | 8 ++ src/main/java/com/kt/domain/faq/FAQ.java | 30 +++++++ .../java/com/kt/domain/vector/Vector.java | 24 ++++++ .../java/com/kt/domain/vector/VectorType.java | 11 +++ .../java/com/kt/dto/faq/FAQCreateRequest.java | 20 +++++ .../config/OpenAIConfiguration.java | 19 +++++ .../openai/advisor/OpenAICustomAdvisor.java | 78 ++++++++++++++++++ .../openai/api/DefaultChatApi.java | 21 +++++ .../openai/api/DefaultVectorApi.java | 81 +++++++++++++++++++ .../integration/openai/api/OpenAIChatApi.java | 5 ++ .../openai/client/OpenAIClient.java | 58 +++++++++++++ .../dto/request/VectorCreateRequest.java | 12 +++ .../dto/request/VectorSearchRequest.java | 9 +++ .../dto/request/VectorUploadFileRequest.java | 12 +++ .../openai/dto/response/OpenAIResponse.java | 78 ++++++++++++++++++ .../properties/OpenAIProperties.java | 27 +++++++ .../com/kt/repository/faq/FAQRepository.java | 8 ++ .../repository/vector/VectorRepository.java | 17 ++++ .../java/com/kt/service/faq/FAQService.java | 56 +++++++++++++ .../com/kt/service/vector/VectorService.java | 48 +++++++++++ src/main/resources/application-local.yml | 10 +++ 25 files changed, 713 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/kt/common/vector/VectorApi.java create mode 100644 src/main/java/com/kt/controller/faq/AdminFAQController.java create mode 100644 src/main/java/com/kt/domain/faq/Category.java create mode 100644 src/main/java/com/kt/domain/faq/FAQ.java create mode 100644 src/main/java/com/kt/domain/vector/Vector.java create mode 100644 src/main/java/com/kt/domain/vector/VectorType.java create mode 100644 src/main/java/com/kt/dto/faq/FAQCreateRequest.java create mode 100644 src/main/java/com/kt/integration/config/OpenAIConfiguration.java create mode 100644 src/main/java/com/kt/integration/openai/advisor/OpenAICustomAdvisor.java create mode 100644 src/main/java/com/kt/integration/openai/api/DefaultChatApi.java create mode 100644 src/main/java/com/kt/integration/openai/api/DefaultVectorApi.java create mode 100644 src/main/java/com/kt/integration/openai/api/OpenAIChatApi.java create mode 100644 src/main/java/com/kt/integration/openai/client/OpenAIClient.java create mode 100644 src/main/java/com/kt/integration/openai/dto/request/VectorCreateRequest.java create mode 100644 src/main/java/com/kt/integration/openai/dto/request/VectorSearchRequest.java create mode 100644 src/main/java/com/kt/integration/openai/dto/request/VectorUploadFileRequest.java create mode 100644 src/main/java/com/kt/integration/openai/dto/response/OpenAIResponse.java create mode 100644 src/main/java/com/kt/integration/properties/OpenAIProperties.java create mode 100644 src/main/java/com/kt/repository/faq/FAQRepository.java create mode 100644 src/main/java/com/kt/repository/vector/VectorRepository.java create mode 100644 src/main/java/com/kt/service/faq/FAQService.java create mode 100644 src/main/java/com/kt/service/vector/VectorService.java 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 8278728a..adc41e0c 100644 --- a/src/main/java/com/kt/common/exception/ErrorCode.java +++ b/src/main/java/com/kt/common/exception/ErrorCode.java @@ -104,7 +104,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/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..dc0a9c06 --- /dev/null +++ b/src/main/java/com/kt/domain/faq/Category.java @@ -0,0 +1,8 @@ +package com.kt.domain.faq; + +public enum Category { + ACCOUNT, + ORDER, + PAYMENT, + OTHER +} 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..788b8fc9 --- /dev/null +++ b/src/main/java/com/kt/integration/config/OpenAIConfiguration.java @@ -0,0 +1,19 @@ +package com.kt.integration.config; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.api.BaseAdvisor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class OpenAIConfiguration { + @Bean + public ChatClient chatClient(ChatClient.Builder builder, BaseAdvisor openAICustomAdvisor) { + return builder + .defaultAdvisors(openAICustomAdvisor) + .build(); + } +} \ 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..b1f89dbc --- /dev/null +++ b/src/main/java/com/kt/integration/openai/advisor/OpenAICustomAdvisor.java @@ -0,0 +1,78 @@ +package com.kt.integration.openai.advisor; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; + +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.stereotype.Component; + +import com.kt.integration.openai.client.OpenAIClient; +import com.kt.integration.openai.dto.request.VectorSearchRequest; +import com.kt.integration.openai.dto.response.OpenAIResponse.SearchData; +import com.kt.integration.properties.OpenAIProperties; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +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; + + @NotNull + @Override + public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) { + var prompt = chatClientRequest.prompt(); + var message = prompt.getUserMessage().getText(); + var candidateAnswers = new ArrayList(); + + 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(","); + + 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); + + var context = topScoreSearchData.content() == null + ? "" + : topScoreSearchData.content().toString(); + + var newPrompt = prompt.augmentSystemMessage(context); + + return chatClientRequest.mutate() + .prompt(newPrompt) + .build(); + } + + @Override + public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) { + return chatClientResponse; + } + + @Override + public int getOrder() { + return 0; + } +} 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..546b33fe --- /dev/null +++ b/src/main/java/com/kt/integration/openai/api/DefaultChatApi.java @@ -0,0 +1,21 @@ +package com.kt.integration.openai.api; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class DefaultChatApi implements OpenAIChatApi { + private final ChatClient clientClient; + + @Override + public String search(String query) { + var response = clientClient.prompt() + .user(query) + .call() + .content(); + return response; + } +} 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..40bf59a7 --- /dev/null +++ b/src/main/java/com/kt/integration/openai/api/DefaultVectorApi.java @@ -0,0 +1,81 @@ +package com.kt.integration.openai.api; + +import java.util.UUID; + +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("local") +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 = 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..d57bf2e9 --- /dev/null +++ b/src/main/java/com/kt/integration/openai/client/OpenAIClient.java @@ -0,0 +1,58 @@ +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/properties/OpenAIProperties.java b/src/main/java/com/kt/integration/properties/OpenAIProperties.java new file mode 100644 index 00000000..f70910f7 --- /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 = "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/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 ef9faa4e..b8efd644 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -39,5 +39,15 @@ slack: bot-token: ${slack.token} log-channel: ${slack.channel} +ai: + model: + embedding: text-embedding-3-small + openai: + api-key: ${OPENAI_API_KEY} + chat: + options: + temperature: 1 + model: o4-mini + server: port: 8080 \ No newline at end of file From 6c9c5e086be5c1e7b2ecfcbcf154f4b149af3b62 Mon Sep 17 00:00:00 2001 From: jimin Date: Wed, 31 Dec 2025 15:49:54 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20OpenAIConfiguration=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=99=98=EA=B2=BD=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/kt/integration/config/OpenAIConfiguration.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/kt/integration/config/OpenAIConfiguration.java b/src/main/java/com/kt/integration/config/OpenAIConfiguration.java index 788b8fc9..17ab10b5 100644 --- a/src/main/java/com/kt/integration/config/OpenAIConfiguration.java +++ b/src/main/java/com/kt/integration/config/OpenAIConfiguration.java @@ -4,11 +4,13 @@ import org.springframework.ai.chat.client.advisor.api.BaseAdvisor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import lombok.RequiredArgsConstructor; @Configuration @RequiredArgsConstructor +@Profile("!test") public class OpenAIConfiguration { @Bean public ChatClient chatClient(ChatClient.Builder builder, BaseAdvisor openAICustomAdvisor) { From 74702d26336fa176df46944f0a6a10b58ebcb4d3 Mon Sep 17 00:00:00 2001 From: jimin Date: Wed, 31 Dec 2025 16:17:44 +0900 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20OpenAICustomAdvisor,=20DefaultChatAp?= =?UTF-8?q?i=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B9=84=ED=99=9C=EC=84=B1?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/kt/integration/openai/advisor/OpenAICustomAdvisor.java | 2 ++ src/main/java/com/kt/integration/openai/api/DefaultChatApi.java | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/main/java/com/kt/integration/openai/advisor/OpenAICustomAdvisor.java b/src/main/java/com/kt/integration/openai/advisor/OpenAICustomAdvisor.java index b1f89dbc..a7167388 100644 --- a/src/main/java/com/kt/integration/openai/advisor/OpenAICustomAdvisor.java +++ b/src/main/java/com/kt/integration/openai/advisor/OpenAICustomAdvisor.java @@ -9,6 +9,7 @@ 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.context.annotation.Profile; import org.springframework.stereotype.Component; import com.kt.integration.openai.client.OpenAIClient; @@ -20,6 +21,7 @@ @Component @RequiredArgsConstructor +@Profile("!test") public class OpenAICustomAdvisor implements BaseAdvisor { private static final SearchData EMPTY_SEARCH_DATA = new SearchData("", "", 0.0, null, null); diff --git a/src/main/java/com/kt/integration/openai/api/DefaultChatApi.java b/src/main/java/com/kt/integration/openai/api/DefaultChatApi.java index 546b33fe..0ec1dc17 100644 --- a/src/main/java/com/kt/integration/openai/api/DefaultChatApi.java +++ b/src/main/java/com/kt/integration/openai/api/DefaultChatApi.java @@ -1,12 +1,14 @@ package com.kt.integration.openai.api; import org.springframework.ai.chat.client.ChatClient; +import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; import lombok.RequiredArgsConstructor; @Component @RequiredArgsConstructor +@Profile("!test") public class DefaultChatApi implements OpenAIChatApi { private final ChatClient clientClient; From 16e0e533dfeea35e2da8001f57072f7d931ad01e Mon Sep 17 00:00:00 2001 From: jimin Date: Fri, 2 Jan 2026 23:21:20 +0900 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20OpenAI=20API=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EC=8B=9C=20Bearer=20prefix=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/OpenAIConfiguration.java | 15 ++++++++++++++- .../openai/advisor/OpenAICustomAdvisor.java | 2 +- .../openai/api/DefaultChatApi.java | 2 +- .../openai/api/DefaultVectorApi.java | 4 ++-- .../properties/OpenAIProperties.java | 2 +- src/main/resources/application-local.yml | 19 +++++++++---------- src/main/resources/logback-spring.xml | 14 +++++++------- src/test/resources/application-test.yml | 11 ++++++++++- 8 files changed, 45 insertions(+), 24 deletions(-) diff --git a/src/main/java/com/kt/integration/config/OpenAIConfiguration.java b/src/main/java/com/kt/integration/config/OpenAIConfiguration.java index 17ab10b5..b382aadd 100644 --- a/src/main/java/com/kt/integration/config/OpenAIConfiguration.java +++ b/src/main/java/com/kt/integration/config/OpenAIConfiguration.java @@ -5,12 +5,17 @@ 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 -@Profile("!test") +// @Profile("!test") public class OpenAIConfiguration { @Bean public ChatClient chatClient(ChatClient.Builder builder, BaseAdvisor openAICustomAdvisor) { @@ -18,4 +23,12 @@ public ChatClient chatClient(ChatClient.Builder builder, BaseAdvisor openAICusto .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 index a7167388..c1629429 100644 --- a/src/main/java/com/kt/integration/openai/advisor/OpenAICustomAdvisor.java +++ b/src/main/java/com/kt/integration/openai/advisor/OpenAICustomAdvisor.java @@ -21,7 +21,7 @@ @Component @RequiredArgsConstructor -@Profile("!test") +// @Profile("!test") public class OpenAICustomAdvisor implements BaseAdvisor { private static final SearchData EMPTY_SEARCH_DATA = new SearchData("", "", 0.0, null, null); diff --git a/src/main/java/com/kt/integration/openai/api/DefaultChatApi.java b/src/main/java/com/kt/integration/openai/api/DefaultChatApi.java index 0ec1dc17..847ab773 100644 --- a/src/main/java/com/kt/integration/openai/api/DefaultChatApi.java +++ b/src/main/java/com/kt/integration/openai/api/DefaultChatApi.java @@ -8,7 +8,7 @@ @Component @RequiredArgsConstructor -@Profile("!test") +// @Profile("!test") public class DefaultChatApi implements OpenAIChatApi { private final ChatClient clientClient; diff --git a/src/main/java/com/kt/integration/openai/api/DefaultVectorApi.java b/src/main/java/com/kt/integration/openai/api/DefaultVectorApi.java index 40bf59a7..2da96caf 100644 --- a/src/main/java/com/kt/integration/openai/api/DefaultVectorApi.java +++ b/src/main/java/com/kt/integration/openai/api/DefaultVectorApi.java @@ -14,7 +14,7 @@ import com.kt.integration.properties.OpenAIProperties; @Component -@Profile("local") +// @Profile("local") public class DefaultVectorApi implements VectorApi { private final OpenAIClient openAIClient; private final OpenAIProperties openAIProperties; @@ -23,7 +23,7 @@ public class DefaultVectorApi implements VectorApi { public DefaultVectorApi(OpenAIClient openAIClient, OpenAIProperties openAIProperties) { this.openAIClient = openAIClient; this.openAIProperties = openAIProperties; - this.token = openAIProperties.openai().apiKey(); + this.token = "Bearer " + openAIProperties.openai().apiKey(); } @Override diff --git a/src/main/java/com/kt/integration/properties/OpenAIProperties.java b/src/main/java/com/kt/integration/properties/OpenAIProperties.java index f70910f7..aba747ca 100644 --- a/src/main/java/com/kt/integration/properties/OpenAIProperties.java +++ b/src/main/java/com/kt/integration/properties/OpenAIProperties.java @@ -2,7 +2,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties; -@ConfigurationProperties(prefix = "ai") +@ConfigurationProperties(prefix = "spring.ai") public record OpenAIProperties( OpenAI openai, Model model diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index b8efd644..170d1586 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -29,6 +29,15 @@ spring: timeout: 5000 starttls: enable: true + ai: + model: + embedding: text-embedding-3-small + openai: + api-key: ${OPENAI_API_KEY} + chat: + options: + temperature: 1 + model: o4-mini jwt: secret: ${kt.jwt.secret} @@ -39,15 +48,5 @@ slack: bot-token: ${slack.token} log-channel: ${slack.channel} -ai: - model: - embedding: text-embedding-3-small - openai: - api-key: ${OPENAI_API_KEY} - chat: - options: - temperature: 1 - model: o4-mini - server: port: 8080 \ No newline at end of file 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/resources/application-test.yml b/src/test/resources/application-test.yml index c59b94db..f0a03e2d 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -20,6 +20,15 @@ spring: autoconfigure: exclude: - org.redisson.spring.starter.RedissonAutoConfigurationV2 + ai: + model: + embedding: text-embedding-3-small + openai: + api-key: ${OPENAI_API_KEY} + chat: + options: + temperature: 1 + model: o4-mini # data: # redis: # host: ${redis.host:localhost} @@ -28,4 +37,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 From 76e3de61380fc6b53c5d4e879b1c0a1d6c2020c7 Mon Sep 17 00:00:00 2001 From: jimin Date: Mon, 5 Jan 2026 09:12:25 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20FakeVectorApi=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/OpenAIConfiguration.java | 7 +++- .../openai/advisor/OpenAICustomAdvisor.java | 39 ++++++++++++++++++- .../openai/api/DefaultChatApi.java | 7 +++- .../openai/api/DefaultVectorApi.java | 3 +- src/main/resources/application-local.yml | 1 + .../ratelimit/vector/FakeVectorApi.java | 29 ++++++++++++++ src/test/resources/application-test.yml | 1 + 7 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/kt/common/ratelimit/vector/FakeVectorApi.java diff --git a/src/main/java/com/kt/integration/config/OpenAIConfiguration.java b/src/main/java/com/kt/integration/config/OpenAIConfiguration.java index b382aadd..1bcbe009 100644 --- a/src/main/java/com/kt/integration/config/OpenAIConfiguration.java +++ b/src/main/java/com/kt/integration/config/OpenAIConfiguration.java @@ -2,6 +2,7 @@ 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; @@ -15,7 +16,11 @@ @Configuration @RequiredArgsConstructor -// @Profile("!test") +@ConditionalOnProperty( + prefix = "spring.ai.openai", + name = "enabled", + havingValue = "true" +) public class OpenAIConfiguration { @Bean public ChatClient chatClient(ChatClient.Builder builder, BaseAdvisor openAICustomAdvisor) { diff --git a/src/main/java/com/kt/integration/openai/advisor/OpenAICustomAdvisor.java b/src/main/java/com/kt/integration/openai/advisor/OpenAICustomAdvisor.java index c1629429..e7dc6daf 100644 --- a/src/main/java/com/kt/integration/openai/advisor/OpenAICustomAdvisor.java +++ b/src/main/java/com/kt/integration/openai/advisor/OpenAICustomAdvisor.java @@ -9,9 +9,12 @@ 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.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.integration.openai.client.OpenAIClient; import com.kt.integration.openai.dto.request.VectorSearchRequest; import com.kt.integration.openai.dto.response.OpenAIResponse.SearchData; @@ -21,13 +24,19 @@ @Component @RequiredArgsConstructor -// @Profile("!test") +@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; + // JSON 파싱용 ObjectMapper 를 클래스 필드로 선언 + private final ObjectMapper objectMapper = new ObjectMapper(); @NotNull @Override @@ -61,6 +70,16 @@ public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChai ? "" : topScoreSearchData.content().toString(); + // content에서 answer만 추출 + // var context = ""; + // if (topScoreSearchData.content() != null) { + // context = topScoreSearchData.content().stream() + // .map(c -> extractAnswerFromVectorResult(c.text(), parsing[1])) + // .filter(s -> !s.isBlank()) + // .findFirst() + // .orElse(""); + // } + var newPrompt = prompt.augmentSystemMessage(context); return chatClientRequest.mutate() @@ -77,4 +96,22 @@ public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorCh public int getOrder() { return 0; } + + // JSON 파싱 helper (query 포함 매칭) + // private String extractAnswerFromVectorResult(String vectorText, String query) { + // try { + // JsonNode arrayNode = objectMapper.readTree(vectorText); + // if (arrayNode.isArray()) { + // for (JsonNode node : arrayNode) { + // if (node.has("question") && node.get("question").asText().toLowerCase().contains(query.toLowerCase())) { + // return node.has("answer") ? node.get("answer").asText() : ""; + // } + // } + // } + // } catch (Exception e) { + // // 파싱 실패 시 빈 문자열 반환 + // } + // 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 index 847ab773..eac59d79 100644 --- a/src/main/java/com/kt/integration/openai/api/DefaultChatApi.java +++ b/src/main/java/com/kt/integration/openai/api/DefaultChatApi.java @@ -1,6 +1,7 @@ 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; @@ -8,7 +9,11 @@ @Component @RequiredArgsConstructor -// @Profile("!test") +@ConditionalOnProperty( + prefix = "spring.ai.openai", + name = "enabled", + havingValue = "true" +) public class DefaultChatApi implements OpenAIChatApi { private final ChatClient clientClient; diff --git a/src/main/java/com/kt/integration/openai/api/DefaultVectorApi.java b/src/main/java/com/kt/integration/openai/api/DefaultVectorApi.java index 2da96caf..17061712 100644 --- a/src/main/java/com/kt/integration/openai/api/DefaultVectorApi.java +++ b/src/main/java/com/kt/integration/openai/api/DefaultVectorApi.java @@ -2,6 +2,7 @@ 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; @@ -14,7 +15,7 @@ import com.kt.integration.properties.OpenAIProperties; @Component -// @Profile("local") +@Profile("!test") public class DefaultVectorApi implements VectorApi { private final OpenAIClient openAIClient; private final OpenAIProperties openAIProperties; diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 170d1586..2c958fca 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -33,6 +33,7 @@ spring: model: embedding: text-embedding-3-small openai: + enabled: true api-key: ${OPENAI_API_KEY} chat: options: 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/resources/application-test.yml b/src/test/resources/application-test.yml index f0a03e2d..afc411c5 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -24,6 +24,7 @@ spring: model: embedding: text-embedding-3-small openai: + enabled: false api-key: ${OPENAI_API_KEY} chat: options: From 18dd788d1e607b4ecdce1013ae3ac8b4bb8cdedf Mon Sep 17 00:00:00 2001 From: jimin Date: Tue, 6 Jan 2026 14:24:47 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20FAQ=20=EC=B1=97=EB=B4=87=20=EB=8B=B5?= =?UTF-8?q?=EB=B3=80=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/kt/config/SecurityConfiguration.java | 2 +- .../java/com/kt/controller/chat/ChatApi.java | 23 +++++ src/main/java/com/kt/domain/faq/Category.java | 11 ++- .../openai/advisor/OpenAICustomAdvisor.java | 99 ++++++++++++------- .../openai/api/DefaultChatApi.java | 11 ++- .../openai/client/OpenAIClient.java | 1 - .../initializer/VectorStoreInitializer.java | 41 ++++++++ .../java/com/kt/service/chat/ChatService.java | 31 ++++++ 8 files changed, 176 insertions(+), 43 deletions(-) create mode 100644 src/main/java/com/kt/controller/chat/ChatApi.java create mode 100644 src/main/java/com/kt/integration/openai/initializer/VectorStoreInitializer.java create mode 100644 src/main/java/com/kt/service/chat/ChatService.java diff --git a/src/main/java/com/kt/config/SecurityConfiguration.java b/src/main/java/com/kt/config/SecurityConfiguration.java index 6d3337ee..11f412f7 100644 --- a/src/main/java/com/kt/config/SecurityConfiguration.java +++ b/src/main/java/com/kt/config/SecurityConfiguration.java @@ -28,7 +28,7 @@ public class SecurityConfiguration { - private static final String[] GET_PERMIT_ALL = {"/api/health/**", "/swagger-ui.html","/swagger-ui/**", "/v3/api-docs/**", "/actuator/**"}; + private static final String[] GET_PERMIT_ALL = {"/api/health/**", "/swagger-ui.html","/swagger-ui/**", "/v3/api-docs/**", "/actuator/**", "/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"}; 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/domain/faq/Category.java b/src/main/java/com/kt/domain/faq/Category.java index dc0a9c06..a791bc99 100644 --- a/src/main/java/com/kt/domain/faq/Category.java +++ b/src/main/java/com/kt/domain/faq/Category.java @@ -2,7 +2,14 @@ public enum Category { ACCOUNT, + ADDRESS, + CART, + DISCOUNT, + MEMBERSHIP, ORDER, PAYMENT, - OTHER -} + PRODUCT, + REVIEW, + VARIANT, + WISHLIST +} \ 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 index e7dc6daf..de5f761e 100644 --- a/src/main/java/com/kt/integration/openai/advisor/OpenAICustomAdvisor.java +++ b/src/main/java/com/kt/integration/openai/advisor/OpenAICustomAdvisor.java @@ -3,22 +3,28 @@ 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; @@ -30,20 +36,20 @@ 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; - // JSON 파싱용 ObjectMapper 를 클래스 필드로 선언 - private final ObjectMapper objectMapper = new ObjectMapper(); + 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 candidateAnswers = new ArrayList(); var parsing = message.split(":", 2); if (parsing.length < 2 || parsing[1].isBlank()) { @@ -51,36 +57,55 @@ public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChai } var request = new VectorSearchRequest(parsing[1]); - var ids = parsing[0].split(","); - Arrays.stream(ids).forEach(id -> { - var response = openAIClient.search(id, String.format("Bearer %s", openAIProperties.openai().apiKey()), request); + var candidateAnswers = new ArrayList(); - var searchData = response.data().stream().max(Comparator.comparingDouble(SearchData::score)) + 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)) + var topScoreSearchData = candidateAnswers.stream() + .max(Comparator.comparingDouble(SearchData::score)) .orElse(EMPTY_SEARCH_DATA); - var context = topScoreSearchData.content() == null - ? "" - : topScoreSearchData.content().toString(); + String answer = ""; + if (topScoreSearchData.content() != null) { + answer = topScoreSearchData.content().stream() + .map(c -> extractAnswerFromVectorResult(c.text())) + .filter(s -> s != null && !s.isBlank()) + .findFirst() + .orElse(""); + } - // content에서 answer만 추출 - // var context = ""; - // if (topScoreSearchData.content() != null) { - // context = topScoreSearchData.content().stream() - // .map(c -> extractAnswerFromVectorResult(c.text(), parsing[1])) - // .filter(s -> !s.isBlank()) - // .findFirst() - // .orElse(""); - // } + String contextText; - var newPrompt = prompt.augmentSystemMessage(context); + // FAQ가 없으면 안내, 있으면 FAQ JSON 기반 답변 + if (answer.isBlank()) { + contextText = "죄송하지만, 현재 FAQ에 등록된 정보 외에는 답변할 수 없습니다."; + } + + // FAQ가 있을 경우 기존 로직 그대로 + contextText = """ + 다음은 참고 정보이다. + 아래 내용을 기반으로 사용자의 질문에 답변하되, + 최종 답변 문장만 출력하라. + + [참고 정보] + %s + """.formatted(answer); + + var newPrompt = prompt.augmentSystemMessage(contextText); return chatClientRequest.mutate() .prompt(newPrompt) @@ -97,21 +122,21 @@ public int getOrder() { return 0; } - // JSON 파싱 helper (query 포함 매칭) - // private String extractAnswerFromVectorResult(String vectorText, String query) { - // try { - // JsonNode arrayNode = objectMapper.readTree(vectorText); - // if (arrayNode.isArray()) { - // for (JsonNode node : arrayNode) { - // if (node.has("question") && node.get("question").asText().toLowerCase().contains(query.toLowerCase())) { - // return node.has("answer") ? node.get("answer").asText() : ""; - // } - // } - // } - // } catch (Exception e) { - // // 파싱 실패 시 빈 문자열 반환 - // } - // return ""; - // } + // 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 index eac59d79..721467f0 100644 --- a/src/main/java/com/kt/integration/openai/api/DefaultChatApi.java +++ b/src/main/java/com/kt/integration/openai/api/DefaultChatApi.java @@ -15,14 +15,21 @@ havingValue = "true" ) public class DefaultChatApi implements OpenAIChatApi { + private final ChatClient clientClient; @Override public String search(String query) { - var response = clientClient.prompt() + return clientClient.prompt() + .system(""" + 너는 고객센터 챗봇이다. + 제공된 참고 정보를 바탕으로 질문에 답변하되, + 최종 답변 문장만 출력해라. + 설명, 출처, 문맥, JSON 형식은 절대 포함하지 마라. + 한두 문장으로 간결하게 답변해라. + """) .user(query) .call() .content(); - return response; } } diff --git a/src/main/java/com/kt/integration/openai/client/OpenAIClient.java b/src/main/java/com/kt/integration/openai/client/OpenAIClient.java index d57bf2e9..6cfd2ecf 100644 --- a/src/main/java/com/kt/integration/openai/client/OpenAIClient.java +++ b/src/main/java/com/kt/integration/openai/client/OpenAIClient.java @@ -54,5 +54,4 @@ OpenAIResponse.Search search( @RequestHeader("Authorization") String authorization, @RequestBody VectorSearchRequest request ); - } 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/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); + } +} From 4767be59de30726aa211a5d92860f9db2e77f983 Mon Sep 17 00:00:00 2001 From: jimin Date: Tue, 6 Jan 2026 15:06:24 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20FakeChatApi=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/kt/ShoppingApplicationTests.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/test/java/com/kt/ShoppingApplicationTests.java b/src/test/java/com/kt/ShoppingApplicationTests.java index 313d5151..e87d5504 100644 --- a/src/test/java/com/kt/ShoppingApplicationTests.java +++ b/src/test/java/com/kt/ShoppingApplicationTests.java @@ -2,12 +2,38 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; import org.springframework.test.context.ActiveProfiles; +import com.kt.integration.openai.api.OpenAIChatApi; +import com.kt.service.chat.ChatService; + @ActiveProfiles("test") @SpringBootTest class ShoppingApplicationTests { + @TestConfiguration + static class TestConfig { + // 테스트 환경에서 OpenAIChatApi DI를 통과시키기 위한 Fake 구현 + @Bean + public OpenAIChatApi fakeChatApi() { + return new OpenAIChatApi() { + @Override + public String search(String query) { + // 실제 OpenAI 호출 없이 고정 답변 반환 + return "이것은 테스트용 답변입니다."; + } + }; + } + } + + private final ChatService chatService; + + ShoppingApplicationTests(ChatService chatService) { + this.chatService = chatService; + } + @Test void contextLoads() { } From 9f8acafd97bd8c268f9b26a57b608ebd15b47fa5 Mon Sep 17 00:00:00 2001 From: jimin Date: Tue, 6 Jan 2026 15:27:32 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20FakeChatApi=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/kt/ShoppingApplicationTests.java | 26 ------------------- .../java/com/kt/integration/FakeChatApi.java | 17 ++++++++++++ 2 files changed, 17 insertions(+), 26 deletions(-) create mode 100644 src/test/java/com/kt/integration/FakeChatApi.java diff --git a/src/test/java/com/kt/ShoppingApplicationTests.java b/src/test/java/com/kt/ShoppingApplicationTests.java index e87d5504..313d5151 100644 --- a/src/test/java/com/kt/ShoppingApplicationTests.java +++ b/src/test/java/com/kt/ShoppingApplicationTests.java @@ -2,38 +2,12 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; import org.springframework.test.context.ActiveProfiles; -import com.kt.integration.openai.api.OpenAIChatApi; -import com.kt.service.chat.ChatService; - @ActiveProfiles("test") @SpringBootTest class ShoppingApplicationTests { - @TestConfiguration - static class TestConfig { - // 테스트 환경에서 OpenAIChatApi DI를 통과시키기 위한 Fake 구현 - @Bean - public OpenAIChatApi fakeChatApi() { - return new OpenAIChatApi() { - @Override - public String search(String query) { - // 실제 OpenAI 호출 없이 고정 답변 반환 - return "이것은 테스트용 답변입니다."; - } - }; - } - } - - private final ChatService chatService; - - ShoppingApplicationTests(ChatService chatService) { - this.chatService = chatService; - } - @Test void contextLoads() { } 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 "테스트용 답변"; + } +}