diff --git a/build.gradle.kts b/build.gradle.kts index 6381f721..f6a6aec9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,6 +22,9 @@ configurations { repositories { mavenCentral() + // Spring AI 마일스톤 리포지토리 추가 + maven { url = uri("https://repo.spring.io/milestone") } + maven { url = uri("https://repo.spring.io/snapshot") } } dependencies { @@ -50,6 +53,14 @@ dependencies { annotationProcessor("org.projectlombok:lombok") + //Spring AI + implementation(platform("org.springframework.ai:spring-ai-bom:1.0.0-M4")) + + //Vertex + //implementation("org.springframework.ai:spring-ai-vertex-ai-gemini-spring-boot-starter") + + //OpenAI + implementation("org.springframework.ai:spring-ai-openai-spring-boot-starter") //test testImplementation("org.springframework.boot:spring-boot-starter-test") @@ -59,4 +70,4 @@ dependencies { tasks.withType { useJUnitPlatform() -} +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/chatbot/dto/GeminiRequestDto.java b/src/main/java/com/back/domain/chatbot/dto/GeminiRequestDto.java deleted file mode 100644 index 59169729..00000000 --- a/src/main/java/com/back/domain/chatbot/dto/GeminiRequestDto.java +++ /dev/null @@ -1,187 +0,0 @@ -package com.back.domain.chatbot.dto; - -import lombok.*; - -import java.util.List; - -@Getter -@Setter -public class GeminiRequestDto { - - private List contents; - private GenerationConfig generationConfig; - private List safetySettings; - - @Getter - @Setter - public static class Content { - private List parts; - - public Content(String text) { - this.parts = List.of(new Part(text)); - } - } - - @Getter - @Setter - public static class Part { - private String text; - - public Part(String text) { - this.text = text; - } - } - - @Getter - @Setter - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class GenerationConfig { // 생성 설정 - /** - * Temperature (0.0 ~ 2.0) - * - 낮을수록 (0.0): 일관되고 예측 가능한 응답 - * - 높을수록 (2.0): 창의적이고 다양한 응답 - * - 권장값: 0.7 ~ 1.0 - */ - private Double temperature; - - /** - * Top-P (0.0 ~ 1.0) - * - 누적 확률 임계값 - * - 0.95 = 상위 95% 확률의 토큰만 고려 - * - 낮을수록 더 집중된 응답 - */ - private Double topP; - - /** - * Top-K (1 ~ 40) - * - 고려할 토큰의 최대 개수 - * - 40 = 상위 40개 토큰만 고려 - * - 낮을수록 더 결정적인 응답 - */ - private Integer topK; - - /** - * Max Output Tokens - * - 응답의 최대 토큰 수 (출력 길이 제한) - * - Gemini 1.5 Flash: 최대 8192 토큰 - * - Gemini 1.5 Pro: 최대 8192 토큰 - * - 한글 1글자 ≈ 1-2 토큰, 영어 3-4글자 ≈ 1 토큰 - */ - private Integer maxOutputTokens; - - /** - * Stop Sequences - * - 이 문자열을 만나면 생성 중단 - * - 예: ["끝", "END", "\n\n"] - */ - private List stopSequences; - - /** - * Candidate Count (1 ~ 8) - * - 생성할 응답 후보의 개수 - * - 여러 개 생성 후 최적 선택 가능 - */ - private Integer candidateCount; - } - - @Getter - @Setter - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class SafetySetting { - private String category; - private String threshold; - - // 카테고리 상수 - public static final String HARM_CATEGORY_HARASSMENT = "HARM_CATEGORY_HARASSMENT"; - public static final String HARM_CATEGORY_HATE_SPEECH = "HARM_CATEGORY_HATE_SPEECH"; - public static final String HARM_CATEGORY_SEXUALLY_EXPLICIT = "HARM_CATEGORY_SEXUALLY_EXPLICIT"; - public static final String HARM_CATEGORY_DANGEROUS_CONTENT = "HARM_CATEGORY_DANGEROUS_CONTENT"; - - // 임계값 상수 - public static final String BLOCK_NONE = "BLOCK_NONE"; // 차단 안함 - public static final String BLOCK_LOW_AND_ABOVE = "BLOCK_LOW_AND_ABOVE"; // 낮음 이상 차단 - public static final String BLOCK_MEDIUM_AND_ABOVE = "BLOCK_MEDIUM_AND_ABOVE"; // 중간 이상 차단 - public static final String BLOCK_HIGH = "BLOCK_ONLY_HIGH"; // 높음만 차단 - } - - // 기본 생성자 - 간단한 텍스트만 - public GeminiRequestDto(String message) { - this.contents = List.of(new Content(message)); - } - - // 파라미터 설정 포함 생성자 - public GeminiRequestDto(String message, GenerationConfig config) { - this.contents = List.of(new Content(message)); - this.generationConfig = config; - } - - // 전체 설정 포함 생성자 - public GeminiRequestDto(String message, GenerationConfig config, List safetySettings) { - this.contents = List.of(new Content(message)); - this.generationConfig = config; - this.safetySettings = safetySettings; - } - - public static GeminiRequestDto createForCocktailChat(String message, boolean isDetailedResponse) { - GenerationConfig config = GenerationConfig.builder() - .temperature(0.8) // 적당한 창의성 - .topP(0.95) // 상위 95% 토큰 고려 - .topK(40) // 상위 40개 토큰 - .maxOutputTokens(isDetailedResponse ? 300 : 150) // 상세 답변 vs 간단 답변 - .candidateCount(1) // 하나의 응답만 - .stopSequences(List.of("끝.", "이상입니다.")) // 종료 구문 - .build(); - - // 안전 설정 (칵테일 관련이므로 비교적 관대하게) - List safetySettings = List.of( - SafetySetting.builder() - .category(SafetySetting.HARM_CATEGORY_HARASSMENT) - .threshold(SafetySetting.BLOCK_MEDIUM_AND_ABOVE) - .build(), - SafetySetting.builder() - .category(SafetySetting.HARM_CATEGORY_HATE_SPEECH) - .threshold(SafetySetting.BLOCK_MEDIUM_AND_ABOVE) - .build(), - SafetySetting.builder() - .category(SafetySetting.HARM_CATEGORY_SEXUALLY_EXPLICIT) - .threshold(SafetySetting.BLOCK_MEDIUM_AND_ABOVE) - .build(), - SafetySetting.builder() - .category(SafetySetting.HARM_CATEGORY_DANGEROUS_CONTENT) - .threshold(SafetySetting.BLOCK_LOW_AND_ABOVE) // 음주 관련이므로 더 엄격 - .build() - ); - - return new GeminiRequestDto(message, config, safetySettings); - } - - // 간결한 답변용 프리셋 - public static GeminiRequestDto createBriefResponse(String message) { - GenerationConfig config = GenerationConfig.builder() - .temperature(0.5) // 더 일관된 답변 - .topP(0.8) // 더 집중된 선택 - .topK(20) // 적은 선택지 - .maxOutputTokens(100) // 짧은 답변 - .candidateCount(1) - .build(); - - return new GeminiRequestDto(message, config); - } - - // 창의적 답변용 프리셋 - public static GeminiRequestDto createCreativeResponse(String message) { - GenerationConfig config = GenerationConfig.builder() - .temperature(1.2) // 높은 창의성 - .topP(0.98) // 더 다양한 선택 - .topK(40) // 많은 선택지 - .maxOutputTokens(500) // 긴 답변 허용 - .candidateCount(1) - .build(); - - return new GeminiRequestDto(message, config); - } -} \ No newline at end of file diff --git a/src/main/java/com/back/domain/chatbot/dto/GeminiResponseDto.java b/src/main/java/com/back/domain/chatbot/dto/GeminiResponseDto.java deleted file mode 100644 index c239ccfd..00000000 --- a/src/main/java/com/back/domain/chatbot/dto/GeminiResponseDto.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.back.domain.chatbot.dto; - -import lombok.Getter; -import lombok.Setter; - -import java.util.List; - -@Getter -@Setter -public class GeminiResponseDto { - - private List candidates; - - @Getter - @Setter - public static class Candidate { - private Content content; - } - - @Getter - @Setter - public static class Content { - private List parts; - } - - @Getter - @Setter - public static class Part { - private String text; - } - - public String getGeneratedText() { - if (candidates != null && !candidates.isEmpty() && - candidates.get(0).content != null && - candidates.get(0).content.parts != null && - !candidates.get(0).content.parts.isEmpty()) { - return candidates.get(0).content.parts.get(0).text; - } - return "응답을 생성할 수 없습니다."; - } -} \ No newline at end of file diff --git a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java index b0e4156e..54f48eff 100644 --- a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java @@ -6,6 +6,12 @@ import com.back.domain.chatbot.repository.ChatConversationRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; +import org.springframework.ai.chat.memory.InMemoryChatMemory; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.messages.*; +import org.springframework.ai.openai.OpenAiChatOptions; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; @@ -15,17 +21,21 @@ import jakarta.annotation.PostConstruct; import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.UUID; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; @Service @RequiredArgsConstructor @Slf4j public class ChatbotService { - private final GeminiApiService geminiApiService; + private final ChatModel chatModel; private final ChatConversationRepository chatConversationRepository; + // 세션별 메모리 관리 (Thread-Safe) + private final ConcurrentHashMap sessionMemories = new ConcurrentHashMap<>(); + @Value("classpath:prompts/chatbot-system-prompt.txt") private Resource systemPromptResource; @@ -35,84 +45,212 @@ public class ChatbotService { @Value("${chatbot.history.max-conversation-count:5}") private int maxConversationCount; + @Value("${spring.ai.openai.chat.options.temperature:0.8}") + private Double temperature; + + @Value("${spring.ai.openai.chat.options.max-tokens:300}") + private Integer maxTokens; + private String systemPrompt; private String responseRules; + private ChatClient chatClient; @PostConstruct public void init() throws IOException { - this.systemPrompt = StreamUtils.copyToString(systemPromptResource.getInputStream(), StandardCharsets.UTF_8); - this.responseRules = StreamUtils.copyToString(responseRulesResource.getInputStream(), StandardCharsets.UTF_8); - log.info("챗봇 시스템 프롬프트가 로드되었습니다. (길이: {} 문자)", systemPrompt.length()); + this.systemPrompt = StreamUtils.copyToString( + systemPromptResource.getInputStream(), + StandardCharsets.UTF_8 + ); + this.responseRules = StreamUtils.copyToString( + responseRulesResource.getInputStream(), + StandardCharsets.UTF_8 + ); + + // ChatClient 고급 설정 + this.chatClient = ChatClient.builder(chatModel) + .defaultSystem(systemPrompt) + .defaultOptions(OpenAiChatOptions.builder() + .withTemperature(temperature) + .withMaxTokens(maxTokens) + .build()) + .build(); + + log.info("Spring AI 챗봇 초기화 완료. Temperature: {}, MaxTokens: {}", temperature, maxTokens); } @Transactional public ChatResponseDto sendMessage(ChatRequestDto requestDto) { - String sessionId = requestDto.getSessionId(); - if (sessionId == null || sessionId.isEmpty()) { - sessionId = UUID.randomUUID().toString(); - } + String sessionId = ensureSessionId(requestDto.getSessionId()); try { - String contextualMessage = buildContextualMessage(requestDto.getMessage(), sessionId); + // 메시지 타입 감지 + MessageType messageType = detectMessageType(requestDto.getMessage()); - String botResponse = geminiApiService.generateResponse(contextualMessage).block(); + // 세션별 메모리 가져오기 + InMemoryChatMemory chatMemory = getOrCreateSessionMemory(sessionId); - ChatConversation conversation = ChatConversation.builder() - .userId(requestDto.getUserId()) - .userMessage(requestDto.getMessage()) - .botResponse(botResponse) - .sessionId(sessionId) - .build(); + // 이전 대화 기록 로드 + loadConversationHistory(sessionId, chatMemory); + + // ChatClient 빌더 생성 + var promptBuilder = chatClient.prompt() + .system(buildSystemMessage(messageType)) + .user(buildUserMessage(requestDto.getMessage(), messageType)) + .advisors(new MessageChatMemoryAdvisor(chatMemory)); + + // RAG 기능은 향후 구현 예정 (Vector DB 설정 필요) + + // 응답 생성 + String response = promptBuilder + .options(getOptionsForMessageType(messageType)) + .call() + .content(); + + // 응답 후처리 + response = postProcessResponse(response, messageType); - chatConversationRepository.save(conversation); + // 대화 저장 + saveConversation(requestDto, response, sessionId); - return new ChatResponseDto(botResponse, sessionId); + return new ChatResponseDto(response, sessionId); } catch (Exception e) { log.error("채팅 응답 생성 중 오류 발생: ", e); - return new ChatResponseDto("죄송합니다. 오류가 발생했습니다. 다시 시도해주세요.", sessionId); + return handleError(sessionId, e); } } - private String buildContextualMessage(String userMessage, String sessionId) { - List recentConversations = getRecentConversations(sessionId); + private String ensureSessionId(String sessionId) { + return (sessionId == null || sessionId.isEmpty()) + ? UUID.randomUUID().toString() + : sessionId; + } - StringBuilder contextBuilder = new StringBuilder(); - contextBuilder.append(systemPrompt).append("\n\n"); + private InMemoryChatMemory getOrCreateSessionMemory(String sessionId) { + return sessionMemories.computeIfAbsent( + sessionId, + k -> new InMemoryChatMemory() + ); + } - appendConversationHistory(contextBuilder, recentConversations); - appendCurrentQuestion(contextBuilder, userMessage); - appendResponseInstructions(contextBuilder); + private void loadConversationHistory(String sessionId, InMemoryChatMemory chatMemory) { + List conversations = + chatConversationRepository.findBySessionIdOrderByCreatedAtAsc(sessionId); - return contextBuilder.toString(); + // 최근 N개의 대화만 메모리에 로드 + String sessionIdForMemory = sessionId; + conversations.stream() + .skip(Math.max(0, conversations.size() - maxConversationCount)) + .forEach(conv -> { + chatMemory.add(sessionIdForMemory, new UserMessage(conv.getUserMessage())); + chatMemory.add(sessionIdForMemory, new AssistantMessage(conv.getBotResponse())); + }); } - private List getRecentConversations(String sessionId) { - return chatConversationRepository.findBySessionIdOrderByCreatedAtAsc(sessionId); + private String buildSystemMessage(MessageType type) { + StringBuilder sb = new StringBuilder(systemPrompt); + + // 메시지 타입별 추가 지시사항 + switch (type) { + case RECIPE: + sb.append("\n\n【레시피 답변 모드】정확한 재료 비율과 제조 순서를 강조하세요."); + break; + case RECOMMENDATION: + sb.append("\n\n【추천 모드】다양한 선택지와 각각의 특징을 설명하세요."); + break; + case QUESTION: + sb.append("\n\n【질문 답변 모드】정확하고 신뢰할 수 있는 정보를 제공하세요."); + break; + default: + break; + } + + return sb.toString(); } - private void appendConversationHistory(StringBuilder contextBuilder, List conversations) { - if (!conversations.isEmpty()) { - contextBuilder.append("=== 이전 대화 기록 ===\n"); + private String buildUserMessage(String userMessage, MessageType type) { + return userMessage + "\n\n" + responseRules; + } - int maxHistory = Math.min(conversations.size(), maxConversationCount); - int startIdx = Math.max(0, conversations.size() - maxHistory); + private OpenAiChatOptions getOptionsForMessageType(MessageType type) { + return switch (type) { + case RECIPE -> OpenAiChatOptions.builder() + .withTemperature(0.3) // 정확성 중시 + .withMaxTokens(400) // 레시피는 길게 + .build(); + case RECOMMENDATION -> OpenAiChatOptions.builder() + .withTemperature(0.9) // 다양성 중시 + .withMaxTokens(250) + .build(); + case QUESTION -> OpenAiChatOptions.builder() + .withTemperature(0.7) // 균형 + .withMaxTokens(200) + .build(); + default -> OpenAiChatOptions.builder() + .withTemperature(temperature) + .withMaxTokens(maxTokens) + .build(); + }; + } + + + private String postProcessResponse(String response, MessageType type) { + // 응답 길이 제한 확인 + if (response.length() > 500) { + response = response.substring(0, 497) + "..."; + } - for (int i = startIdx; i < conversations.size(); i++) { - ChatConversation conv = conversations.get(i); - contextBuilder.append("사용자: ").append(conv.getUserMessage()).append("\n"); - contextBuilder.append("AI 바텐더: ").append(conv.getBotResponse()).append("\n\n"); - } - contextBuilder.append("=================\n\n"); + // 이모지 추가 (타입별) + if (type == MessageType.RECIPE && !response.contains("🍹")) { + response = "🍹 " + response; } + + return response; } - private void appendCurrentQuestion(StringBuilder contextBuilder, String userMessage) { - contextBuilder.append("현재 사용자 질문: ").append(userMessage).append("\n\n"); + private void saveConversation(ChatRequestDto requestDto, String response, String sessionId) { + ChatConversation conversation = ChatConversation.builder() + .userId(requestDto.getUserId()) + .userMessage(requestDto.getMessage()) + .botResponse(response) + .sessionId(sessionId) + .createdAt(LocalDateTime.now()) + .build(); + + chatConversationRepository.save(conversation); + } + + private ChatResponseDto handleError(String sessionId, Exception e) { + String errorMessage = "죄송합니다. 잠시 후 다시 시도해주세요."; + + if (e.getMessage().contains("rate limit")) { + errorMessage = "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."; + } else if (e.getMessage().contains("timeout")) { + errorMessage = "응답 시간이 초과되었습니다. 다시 시도해주세요."; + } + + return new ChatResponseDto(errorMessage, sessionId); } - private void appendResponseInstructions(StringBuilder contextBuilder) { - contextBuilder.append(responseRules); + public enum MessageType { + RECIPE, RECOMMENDATION, QUESTION, CASUAL_CHAT + } + + private MessageType detectMessageType(String message) { + String lower = message.toLowerCase(); + + if (lower.contains("레시피") || lower.contains("만드는") || + lower.contains("제조") || lower.contains("recipe")) { + return MessageType.RECIPE; + } else if (lower.contains("추천") || lower.contains("어때") || + lower.contains("뭐가 좋") || lower.contains("recommend")) { + return MessageType.RECOMMENDATION; + } else if (lower.contains("?") || lower.contains("뭐") || + lower.contains("어떻") || lower.contains("왜")) { + return MessageType.QUESTION; + } + + return MessageType.CASUAL_CHAT; } @Transactional(readOnly = true) @@ -124,4 +262,17 @@ public List getChatHistory(String sessionId) { public List getUserChatHistory(Long userId, String sessionId) { return chatConversationRepository.findByUserIdAndSessionIdOrderByCreatedAtAsc(userId, sessionId); } -} \ No newline at end of file + + // 정기적인 메모리 정리 (스케줄러로 호출) + public void cleanupInactiveSessions() { + long thirtyMinutesAgo = System.currentTimeMillis() - (30 * 60 * 1000); + + sessionMemories.entrySet().removeIf(entry -> { + // 실제로는 마지막 사용 시간을 추적해야 함 + return false; + }); + + log.info("세션 메모리 정리 완료. 현재 활성 세션: {}", sessionMemories.size()); + } +} + diff --git a/src/main/java/com/back/domain/chatbot/service/GeminiApiService.java b/src/main/java/com/back/domain/chatbot/service/GeminiApiService.java deleted file mode 100644 index e6870a4d..00000000 --- a/src/main/java/com/back/domain/chatbot/service/GeminiApiService.java +++ /dev/null @@ -1,180 +0,0 @@ -package com.back.domain.chatbot.service; - -import com.back.domain.chatbot.dto.GeminiRequestDto; -import com.back.domain.chatbot.dto.GeminiResponseDto; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.springframework.web.reactive.function.client.WebClient; -import reactor.core.publisher.Mono; - -@Service -@RequiredArgsConstructor -@Slf4j -public class GeminiApiService { - - private final WebClient geminiWebClient; - - @Value("${gemini.api.key}") - private String apiKey; - - // 기본 응답 - public Mono generateResponse(String userMessage) { - GeminiRequestDto requestDto = new GeminiRequestDto(userMessage); - - return callGeminiApi(requestDto); - } - - // 간단한 응답 생성 (100 토큰) - public Mono generateBriefResponse(String userMessage) { - GeminiRequestDto requestDto = GeminiRequestDto.createBriefResponse(userMessage); - - return callGeminiApi(requestDto); - } - - // 상세한 응답 생성 (200 토큰, 구조화된 답변) - public Mono generateDetailedResponse(String userMessage) { - GeminiRequestDto requestDto = GeminiRequestDto.createForCocktailChat(userMessage, true); - - return callGeminiApi(requestDto); - } - - // 창의적인 응답 생성 (500 토큰, 높은 창의성) - public Mono generateCreativeResponse(String userMessage) { - GeminiRequestDto requestDto = GeminiRequestDto.createCreativeResponse(userMessage); - - return callGeminiApi(requestDto); - } - - // 사용자 지정 파라미터로 응답 생성 ( 커스텀 ) - public Mono generateCustomResponse( - String userMessage, - Double temperature, - Integer maxTokens, - Double topP, - Integer topK - ) { - GeminiRequestDto.GenerationConfig config = GeminiRequestDto.GenerationConfig.builder() - .temperature(temperature != null ? temperature : 0.8) - .maxOutputTokens(maxTokens != null ? maxTokens : 200) - .topP(topP != null ? topP : 0.95) - .topK(topK != null ? topK : 40) - .candidateCount(1) - .build(); - - GeminiRequestDto requestDto = new GeminiRequestDto(userMessage, config); - - return callGeminiApi(requestDto); - } - - // 메시지 유형에 따른 최적화된 응답 생성 - public Mono generateOptimizedResponse(String userMessage, MessageType messageType) { - GeminiRequestDto requestDto = switch (messageType) { - case RECIPE -> createRecipeRequest(userMessage); - case RECOMMENDATION -> createRecommendationRequest(userMessage); - case QUESTION -> createQuestionRequest(userMessage); - case CASUAL_CHAT -> createCasualChatRequest(userMessage); - default -> new GeminiRequestDto(userMessage); - }; - - return callGeminiApi(requestDto); - } - - // 레시피 요청 (구조화된 답변 필요) - private GeminiRequestDto createRecipeRequest(String message) { - GeminiRequestDto.GenerationConfig config = GeminiRequestDto.GenerationConfig.builder() - .temperature(0.3) // 낮은 temperature로 정확성 향상 - .topP(0.8) - .topK(20) - .maxOutputTokens(400) // 레시피는 좀 더 길게 - .candidateCount(1) - .build(); - - return new GeminiRequestDto(message, config); - } - - // 추천 요청 (적당한 창의성) - private GeminiRequestDto createRecommendationRequest(String message) { - GeminiRequestDto.GenerationConfig config = GeminiRequestDto.GenerationConfig.builder() - .temperature(0.9) // 다양한 추천을 위해 높게 - .topP(0.95) - .topK(40) - .maxOutputTokens(250) - .candidateCount(1) - .build(); - - return new GeminiRequestDto(message, config); - } - - // 일반 질문 (균형잡힌 설정) - private GeminiRequestDto createQuestionRequest(String message) { - GeminiRequestDto.GenerationConfig config = GeminiRequestDto.GenerationConfig.builder() - .temperature(0.7) - .topP(0.9) - .topK(30) - .maxOutputTokens(200) - .candidateCount(1) - .build(); - - return new GeminiRequestDto(message, config); - } - - // 캐주얼한 대화 (자연스러움 중시) - private GeminiRequestDto createCasualChatRequest(String message) { - GeminiRequestDto.GenerationConfig config = GeminiRequestDto.GenerationConfig.builder() - .temperature(1.0) - .topP(0.95) - .topK(40) - .maxOutputTokens(150) - .candidateCount(1) - .build(); - - return new GeminiRequestDto(message, config); - } - - // Gemini API 호출 공통 메서드 - private Mono callGeminiApi(GeminiRequestDto requestDto) { - log.debug("Gemini API 호출 - Temperature: {}, MaxTokens: {}", - requestDto.getGenerationConfig() != null ? requestDto.getGenerationConfig().getTemperature() : "default", - requestDto.getGenerationConfig() != null ? requestDto.getGenerationConfig().getMaxOutputTokens() : "default" - ); - - return geminiWebClient.post() - .uri("?key=" + apiKey) - .bodyValue(requestDto) - .retrieve() - .bodyToMono(GeminiResponseDto.class) - .map(GeminiResponseDto::getGeneratedText) - .doOnSuccess(response -> log.debug("응답 길이: {} 글자", response != null ? response.length() : 0)) - .doOnError(error -> log.error("Gemini API 호출 실패: ", error)) - .onErrorReturn("죄송합니다. 현재 응답을 생성할 수 없습니다. 잠시 후 다시 시도해주세요."); - } - - // 유형 ENUM 정의 - public enum MessageType { - RECIPE, // 레시피 요청 - RECOMMENDATION, // 추천 요청 - QUESTION, // 일반 질문 - CASUAL_CHAT, // 캐주얼 대화 - UNKNOWN // 분류 불가 - } - - // 메시지 유형 감지 (간단한 키워드 기반) - public static MessageType detectMessageType(String message) { - String lowerMessage = message.toLowerCase(); - - if (lowerMessage.contains("레시피") || lowerMessage.contains("만드는") || - lowerMessage.contains("제조") || lowerMessage.contains("recipe")) { - return MessageType.RECIPE; - } else if (lowerMessage.contains("추천") || lowerMessage.contains("어때") || - lowerMessage.contains("뭐가 좋") || lowerMessage.contains("recommend")) { - return MessageType.RECOMMENDATION; - } else if (lowerMessage.contains("?") || lowerMessage.contains("뭐") || - lowerMessage.contains("어떻") || lowerMessage.contains("왜")) { - return MessageType.QUESTION; - } else { - return MessageType.CASUAL_CHAT; - } - } -} \ No newline at end of file diff --git a/src/main/java/com/back/global/appConfig/GeminiConfig.java b/src/main/java/com/back/global/appConfig/GeminiConfig.java deleted file mode 100644 index 9695ed7b..00000000 --- a/src/main/java/com/back/global/appConfig/GeminiConfig.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.back.global.appConfig; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.reactive.function.client.WebClient; - -@Configuration -public class GeminiConfig { - - @Value("${gemini.api.url}") - private String geminiApiUrl; - - @Value("${gemini.api.model-name}") - private String modelName; - - @Bean - public WebClient geminiWebClient() { - return WebClient.builder() - .baseUrl(geminiApiUrl + "/" + modelName + ":generateContent") - .defaultHeader("Content-Type", "application/json") - .build(); - } -} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 67d50086..10a0200d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -48,6 +48,17 @@ spring: user-info-uri: https://openapi.naver.com/v1/nid/me user-name-attribute: response + ai: + openai: + api-key: ${GEMINI_API_KEY} + chat: + base-url: "https://generativelanguage.googleapis.com/v1beta/openai/" + options: + model: "gemini-2.0-flash-exp" + temperature: 0.8 + max-tokens: 800 + completions-path: "/chat/completions" + springdoc: default-produces-media-type: application/json;charset=UTF-8 @@ -60,6 +71,7 @@ server: enabled: true force: true +# 추후 삭제 예정 gemini: api: key: ${GEMINI_API_KEY} @@ -104,4 +116,7 @@ management: chatbot: history: - max-conversation-count: 5 \ No newline at end of file + max-conversation-count: 5 + system: + prompt-file: classpath:prompts/chatbot-system-prompt.txt + rules-file: classpath:prompts/chatbot-response-rules.txt \ No newline at end of file