diff --git a/src/main/java/com/back/domain/chatbot/controller/ChatbotController.java b/src/main/java/com/back/domain/chatbot/controller/ChatbotController.java index b9e184d1..495787c0 100644 --- a/src/main/java/com/back/domain/chatbot/controller/ChatbotController.java +++ b/src/main/java/com/back/domain/chatbot/controller/ChatbotController.java @@ -44,4 +44,5 @@ public ResponseEntity>> getUserChatHistory(@PathVa .body(RsData.failOf("서버 오류가 발생했습니다.")); } } + } \ No newline at end of file diff --git a/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java b/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java index 55fbe1f7..1c70af37 100644 --- a/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java +++ b/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java @@ -1,5 +1,8 @@ package com.back.domain.chatbot.dto; +import com.back.domain.cocktail.enums.AlcoholBaseType; +import com.back.domain.cocktail.enums.AlcoholStrength; +import com.back.domain.cocktail.enums.CocktailType; import jakarta.validation.constraints.NotBlank; import lombok.Getter; import lombok.NoArgsConstructor; @@ -13,6 +16,12 @@ public class ChatRequestDto { @NotBlank(message = "메시지는 필수입니다.") private String message; - private Long userId; + + // 단계별 추천 관련 필드들 + private boolean isStepRecommendation = false; + private Integer currentStep; + private AlcoholStrength selectedAlcoholStrength; + private AlcoholBaseType selectedAlcoholBaseType; + private CocktailType selectedCocktailType; } \ No newline at end of file diff --git a/src/main/java/com/back/domain/chatbot/dto/ChatResponseDto.java b/src/main/java/com/back/domain/chatbot/dto/ChatResponseDto.java index 7b941e68..e96d1468 100644 --- a/src/main/java/com/back/domain/chatbot/dto/ChatResponseDto.java +++ b/src/main/java/com/back/domain/chatbot/dto/ChatResponseDto.java @@ -16,8 +16,17 @@ public class ChatResponseDto { private String response; private LocalDateTime timestamp; + // 단계별 추천 관련 필드 (선택사항) + private StepRecommendationResponseDto stepRecommendation; + public ChatResponseDto(String response) { this.response = response; this.timestamp = LocalDateTime.now(); } + + public ChatResponseDto(String response, StepRecommendationResponseDto stepRecommendation) { + this.response = response; + this.timestamp = LocalDateTime.now(); + this.stepRecommendation = stepRecommendation; + } } \ No newline at end of file diff --git a/src/main/java/com/back/domain/chatbot/dto/StepRecommendationResponseDto.java b/src/main/java/com/back/domain/chatbot/dto/StepRecommendationResponseDto.java new file mode 100644 index 00000000..08a8f41c --- /dev/null +++ b/src/main/java/com/back/domain/chatbot/dto/StepRecommendationResponseDto.java @@ -0,0 +1,32 @@ +package com.back.domain.chatbot.dto; + +import com.back.domain.cocktail.dto.CocktailSummaryResponseDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class StepRecommendationResponseDto { + + private Integer currentStep; // 현재 단계 + private String stepTitle; // 단계 제목 (예: "원하시는 도수를 선택해주세요!") + private List options; // 선택 옵션들 + private List recommendations; // 최종 추천 칵테일 (4단계에서만) + private boolean isCompleted; // 추천이 완료되었는지 여부 + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class StepOption { + private String value; // enum 값 (예: "NON_ALCOHOLIC") + private String label; // 화면에 표시될 텍스트 (예: "논알콜 (0%)") + private String description; // 부가 설명 (선택사항) + } +} \ 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 f374c453..f0345115 100644 --- a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java @@ -2,8 +2,15 @@ import com.back.domain.chatbot.dto.ChatRequestDto; import com.back.domain.chatbot.dto.ChatResponseDto; +import com.back.domain.chatbot.dto.StepRecommendationResponseDto; import com.back.domain.chatbot.entity.ChatConversation; import com.back.domain.chatbot.repository.ChatConversationRepository; +import com.back.domain.cocktail.dto.CocktailSummaryResponseDto; +import com.back.domain.cocktail.entity.Cocktail; +import com.back.domain.cocktail.enums.AlcoholBaseType; +import com.back.domain.cocktail.enums.AlcoholStrength; +import com.back.domain.cocktail.enums.CocktailType; +import com.back.domain.cocktail.repository.CocktailRepository; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -12,6 +19,8 @@ import org.springframework.ai.openai.OpenAiChatOptions; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,8 +29,10 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -30,6 +41,7 @@ public class ChatbotService { private final ChatModel chatModel; private final ChatConversationRepository chatConversationRepository; + private final CocktailRepository cocktailRepository; @Value("classpath:prompts/chatbot-system-prompt.txt") @@ -77,6 +89,16 @@ public void init() throws IOException { @Transactional public ChatResponseDto sendMessage(ChatRequestDto requestDto) { try { + // 단계별 추천 모드 확인 (currentStep이 있으면 무조건 단계별 추천 모드) + if (requestDto.isStepRecommendation() || + requestDto.getCurrentStep() != null || + isStepRecommendationTrigger(requestDto.getMessage())) { + log.info("Recommendation chat mode for userId: {}", requestDto.getUserId()); + return handleStepRecommendation(requestDto); + } + + log.info("Normal chat mode for userId: {}", requestDto.getUserId()); + // 메시지 타입 감지 MessageType messageType = detectMessageType(requestDto.getMessage()); @@ -238,10 +260,160 @@ private MessageType detectMessageType(String message) { return MessageType.CASUAL_CHAT; } + // 단계별 추천 시작 키워드 감지 + private boolean isStepRecommendationTrigger(String message) { + String lower = message.toLowerCase().trim(); + return lower.contains("단계별 추천"); + } + + // 단계별 추천 처리 통합 메서드 + private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { + Integer currentStep = requestDto.getCurrentStep(); + + // 단계가 지정되지 않았거나 첫 시작인 경우 + if (currentStep == null || currentStep <= 0) { + currentStep = 1; + } + + StepRecommendationResponseDto stepRecommendation; + String chatResponse; + + switch (currentStep) { + case 1: + stepRecommendation = getAlcoholStrengthOptions(); + chatResponse = "단계별 맞춤 추천을 시작합니다! 🎯\n원하시는 도수를 선택해주세요!"; + break; + case 2: + stepRecommendation = getAlcoholBaseTypeOptions(requestDto.getSelectedAlcoholStrength()); + chatResponse = "좋은 선택이네요! 이제 베이스가 될 술을 선택해주세요 🍸"; + break; + case 3: + stepRecommendation = getCocktailTypeOptions(requestDto.getSelectedAlcoholStrength(), requestDto.getSelectedAlcoholBaseType()); + chatResponse = "완벽해요! 마지막으로 어떤 스타일로 즐기실 건가요? 🥃"; + break; + case 4: + stepRecommendation = getFinalRecommendations( + requestDto.getSelectedAlcoholStrength(), + requestDto.getSelectedAlcoholBaseType(), + requestDto.getSelectedCocktailType() + ); + chatResponse = stepRecommendation.getStepTitle(); + break; + default: + stepRecommendation = getAlcoholStrengthOptions(); + chatResponse = "단계별 맞춤 추천을 시작합니다! 🎯"; + } + + // 대화 기록 저장 + saveConversation(requestDto, chatResponse); + + return new ChatResponseDto(chatResponse, stepRecommendation); + } + @Transactional(readOnly = true) public List getUserChatHistory(Long userId) { return chatConversationRepository.findByUserIdOrderByCreatedAtDesc(userId, Pageable.unpaged()).getContent(); } + + private StepRecommendationResponseDto getAlcoholStrengthOptions() { + List options = new ArrayList<>(); + + for (AlcoholStrength strength : AlcoholStrength.values()) { + options.add(new StepRecommendationResponseDto.StepOption( + strength.name(), + strength.getDescription(), + null + )); + } + + return new StepRecommendationResponseDto( + 1, + "원하시는 도수를 선택해주세요!", + options, + null, + false + ); + } + + private StepRecommendationResponseDto getAlcoholBaseTypeOptions(AlcoholStrength alcoholStrength) { + List options = new ArrayList<>(); + + for (AlcoholBaseType baseType : AlcoholBaseType.values()) { + options.add(new StepRecommendationResponseDto.StepOption( + baseType.name(), + baseType.getDescription(), + null + )); + } + + return new StepRecommendationResponseDto( + 2, + "베이스가 될 술을 선택해주세요!", + options, + null, + false + ); + } + + private StepRecommendationResponseDto getCocktailTypeOptions(AlcoholStrength alcoholStrength, AlcoholBaseType alcoholBaseType) { + List options = new ArrayList<>(); + + for (CocktailType cocktailType : CocktailType.values()) { + options.add(new StepRecommendationResponseDto.StepOption( + cocktailType.name(), + cocktailType.getDescription(), + null + )); + } + + return new StepRecommendationResponseDto( + 3, + "어떤 종류의 잔으로 드시겠어요?", + options, + null, + false + ); + } + + private StepRecommendationResponseDto getFinalRecommendations( + AlcoholStrength alcoholStrength, + AlcoholBaseType alcoholBaseType, + CocktailType cocktailType) { + // 필터링 조건에 맞는 칵테일 검색 + List strengths = List.of(alcoholStrength); + List baseTypes = List.of(alcoholBaseType); + List cocktailTypes = List.of(cocktailType); + + Page cocktailPage = cocktailRepository.searchWithFilters( + null, // 키워드 없음 + strengths, + cocktailTypes, + baseTypes, + PageRequest.of(0, 5) // 최대 5개 추천 + ); + + List recommendations = cocktailPage.getContent().stream() + .map(cocktail -> new CocktailSummaryResponseDto( + cocktail.getId(), + cocktail.getCocktailName(), + cocktail.getCocktailImgUrl(), + cocktail.getAlcoholStrength() + )) + .collect(Collectors.toList()); + + String stepTitle = recommendations.isEmpty() + ? "조건에 맞는 칵테일을 찾을 수 없습니다 😢" + : "당신을 위한 맞춤 칵테일 추천입니다! 🍹"; + + return new StepRecommendationResponseDto( + 4, + stepTitle, + null, + recommendations, + true + ); + } + } diff --git a/src/main/resources/prompts/chatbot-response-rules.txt b/src/main/resources/prompts/chatbot-response-rules.txt index 4050b112..e1017859 100644 --- a/src/main/resources/prompts/chatbot-response-rules.txt +++ b/src/main/resources/prompts/chatbot-response-rules.txt @@ -1,4 +1,4 @@ -위의 시스템 프롬프트와 대화 기록을 참고하여, '쑤울 AI 바텐더'로서 친근하고 전문적인 답변을 제공해주세요. +위의 시스템 프롬프트와 대화 기록을 참고하여, 칵테일 AI 바텐더 '쑤리'로서 친근하고 전문적인 답변을 제공해주세요. 칵테일과 관련된 유용한 정보를 포함하되, 자연스럽고 대화하듯 응답해주세요. 【중요한 응답 규칙】 diff --git a/src/main/resources/prompts/chatbot-system-prompt.txt b/src/main/resources/prompts/chatbot-system-prompt.txt index 1b6b3a9a..ce212fd6 100644 --- a/src/main/resources/prompts/chatbot-system-prompt.txt +++ b/src/main/resources/prompts/chatbot-system-prompt.txt @@ -1,7 +1,7 @@ -당신은 'Ssoul' 칵테일 전문 AI 바텐더입니다. +당신은 'Ssoul' 칵테일 전문 AI 바텐더 '쑤리' 입니다. ## 역할과 페르소나 -- 이름: 쑤울 AI 바텐더 +- 이름: 쑤리 - 성격: 친근하고 전문적이며, 유머러스하면서도 신뢰할 수 있는 칵테일 전문가 - 말투: 반말이 아닌 존댓말을 사용하며, 친근한 바텐더처럼 대화 - 특징: 칵테일에 대한 깊은 지식과 함께 상황에 맞는 칵테일 추천 능력 @@ -21,8 +21,8 @@ 5. **창의성**: 클래식 칵테일 외에도 현대적 변형이나 논알콜 대안 제시 ## 응답 길이 제한 -- **기본 답변**: 200자 이내로 간결하게 작성 -- **레시피 제공**: 최대 300자 이내로 핵심만 전달 +- **기본 답변**: 300자 이내로 간결하게 작성 +- **레시피 제공**: 최대 400자 이내로 핵심만 전달 - **복잡한 설명**: 필요시 "더 알고 싶으시면 추가로 질문해주세요"로 마무리 - **한 문단**: 최대 3-4문장으로 제한 @@ -56,4 +56,5 @@ - 논알콜 칵테일(목테일)도 적극적으로 소개 - 홈바 입문자를 위한 기본 도구와 재료 안내 - 칵테일과 어울리는 안주나 분위기 추천 -- 과음 방지를 위한 적절한 조언 포함 \ No newline at end of file +- 과음 방지를 위한 적절한 조언 포함 +- 칵테일 추천에 대한 이유를 간단히 특징에 언급 \ No newline at end of file