Skip to content

Commit 0c5ecb2

Browse files
authored
merge: 챗봇 단계별 추천 기능 추가
[feat] 챗봇 단계별 추천 기능 구현 #154
2 parents b30cd6c + 83a961f commit 0c5ecb2

File tree

7 files changed

+231
-7
lines changed

7 files changed

+231
-7
lines changed

src/main/java/com/back/domain/chatbot/controller/ChatbotController.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,5 @@ public ResponseEntity<RsData<List<ChatConversation>>> getUserChatHistory(@PathVa
4444
.body(RsData.failOf("서버 오류가 발생했습니다."));
4545
}
4646
}
47+
4748
}

src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package com.back.domain.chatbot.dto;
22

3+
import com.back.domain.cocktail.enums.AlcoholBaseType;
4+
import com.back.domain.cocktail.enums.AlcoholStrength;
5+
import com.back.domain.cocktail.enums.CocktailType;
36
import jakarta.validation.constraints.NotBlank;
47
import lombok.Getter;
58
import lombok.NoArgsConstructor;
@@ -13,6 +16,12 @@ public class ChatRequestDto {
1316
@NotBlank(message = "메시지는 필수입니다.")
1417
private String message;
1518

16-
1719
private Long userId;
20+
21+
// 단계별 추천 관련 필드들
22+
private boolean isStepRecommendation = false;
23+
private Integer currentStep;
24+
private AlcoholStrength selectedAlcoholStrength;
25+
private AlcoholBaseType selectedAlcoholBaseType;
26+
private CocktailType selectedCocktailType;
1827
}

src/main/java/com/back/domain/chatbot/dto/ChatResponseDto.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,17 @@ public class ChatResponseDto {
1616
private String response;
1717
private LocalDateTime timestamp;
1818

19+
// 단계별 추천 관련 필드 (선택사항)
20+
private StepRecommendationResponseDto stepRecommendation;
21+
1922
public ChatResponseDto(String response) {
2023
this.response = response;
2124
this.timestamp = LocalDateTime.now();
2225
}
26+
27+
public ChatResponseDto(String response, StepRecommendationResponseDto stepRecommendation) {
28+
this.response = response;
29+
this.timestamp = LocalDateTime.now();
30+
this.stepRecommendation = stepRecommendation;
31+
}
2332
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.back.domain.chatbot.dto;
2+
3+
import com.back.domain.cocktail.dto.CocktailSummaryResponseDto;
4+
import lombok.AllArgsConstructor;
5+
import lombok.Getter;
6+
import lombok.NoArgsConstructor;
7+
import lombok.Setter;
8+
9+
import java.util.List;
10+
11+
@Getter
12+
@Setter
13+
@NoArgsConstructor
14+
@AllArgsConstructor
15+
public class StepRecommendationResponseDto {
16+
17+
private Integer currentStep; // 현재 단계
18+
private String stepTitle; // 단계 제목 (예: "원하시는 도수를 선택해주세요!")
19+
private List<StepOption> options; // 선택 옵션들
20+
private List<CocktailSummaryResponseDto> recommendations; // 최종 추천 칵테일 (4단계에서만)
21+
private boolean isCompleted; // 추천이 완료되었는지 여부
22+
23+
@Getter
24+
@Setter
25+
@NoArgsConstructor
26+
@AllArgsConstructor
27+
public static class StepOption {
28+
private String value; // enum 값 (예: "NON_ALCOHOLIC")
29+
private String label; // 화면에 표시될 텍스트 (예: "논알콜 (0%)")
30+
private String description; // 부가 설명 (선택사항)
31+
}
32+
}

src/main/java/com/back/domain/chatbot/service/ChatbotService.java

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,15 @@
22

33
import com.back.domain.chatbot.dto.ChatRequestDto;
44
import com.back.domain.chatbot.dto.ChatResponseDto;
5+
import com.back.domain.chatbot.dto.StepRecommendationResponseDto;
56
import com.back.domain.chatbot.entity.ChatConversation;
67
import com.back.domain.chatbot.repository.ChatConversationRepository;
8+
import com.back.domain.cocktail.dto.CocktailSummaryResponseDto;
9+
import com.back.domain.cocktail.entity.Cocktail;
10+
import com.back.domain.cocktail.enums.AlcoholBaseType;
11+
import com.back.domain.cocktail.enums.AlcoholStrength;
12+
import com.back.domain.cocktail.enums.CocktailType;
13+
import com.back.domain.cocktail.repository.CocktailRepository;
714
import jakarta.annotation.PostConstruct;
815
import lombok.RequiredArgsConstructor;
916
import lombok.extern.slf4j.Slf4j;
@@ -12,6 +19,8 @@
1219
import org.springframework.ai.openai.OpenAiChatOptions;
1320
import org.springframework.beans.factory.annotation.Value;
1421
import org.springframework.core.io.Resource;
22+
import org.springframework.data.domain.Page;
23+
import org.springframework.data.domain.PageRequest;
1524
import org.springframework.data.domain.Pageable;
1625
import org.springframework.stereotype.Service;
1726
import org.springframework.transaction.annotation.Transactional;
@@ -20,8 +29,10 @@
2029
import java.io.IOException;
2130
import java.nio.charset.StandardCharsets;
2231
import java.time.LocalDateTime;
32+
import java.util.ArrayList;
2333
import java.util.Collections;
2434
import java.util.List;
35+
import java.util.stream.Collectors;
2536

2637
@Service
2738
@RequiredArgsConstructor
@@ -30,6 +41,7 @@ public class ChatbotService {
3041

3142
private final ChatModel chatModel;
3243
private final ChatConversationRepository chatConversationRepository;
44+
private final CocktailRepository cocktailRepository;
3345

3446

3547
@Value("classpath:prompts/chatbot-system-prompt.txt")
@@ -77,6 +89,16 @@ public void init() throws IOException {
7789
@Transactional
7890
public ChatResponseDto sendMessage(ChatRequestDto requestDto) {
7991
try {
92+
// 단계별 추천 모드 확인 (currentStep이 있으면 무조건 단계별 추천 모드)
93+
if (requestDto.isStepRecommendation() ||
94+
requestDto.getCurrentStep() != null ||
95+
isStepRecommendationTrigger(requestDto.getMessage())) {
96+
log.info("Recommendation chat mode for userId: {}", requestDto.getUserId());
97+
return handleStepRecommendation(requestDto);
98+
}
99+
100+
log.info("Normal chat mode for userId: {}", requestDto.getUserId());
101+
80102
// 메시지 타입 감지
81103
MessageType messageType = detectMessageType(requestDto.getMessage());
82104

@@ -238,10 +260,160 @@ private MessageType detectMessageType(String message) {
238260
return MessageType.CASUAL_CHAT;
239261
}
240262

263+
// 단계별 추천 시작 키워드 감지
264+
private boolean isStepRecommendationTrigger(String message) {
265+
String lower = message.toLowerCase().trim();
266+
return lower.contains("단계별 추천");
267+
}
268+
269+
// 단계별 추천 처리 통합 메서드
270+
private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) {
271+
Integer currentStep = requestDto.getCurrentStep();
272+
273+
// 단계가 지정되지 않았거나 첫 시작인 경우
274+
if (currentStep == null || currentStep <= 0) {
275+
currentStep = 1;
276+
}
277+
278+
StepRecommendationResponseDto stepRecommendation;
279+
String chatResponse;
280+
281+
switch (currentStep) {
282+
case 1:
283+
stepRecommendation = getAlcoholStrengthOptions();
284+
chatResponse = "단계별 맞춤 추천을 시작합니다! 🎯\n원하시는 도수를 선택해주세요!";
285+
break;
286+
case 2:
287+
stepRecommendation = getAlcoholBaseTypeOptions(requestDto.getSelectedAlcoholStrength());
288+
chatResponse = "좋은 선택이네요! 이제 베이스가 될 술을 선택해주세요 🍸";
289+
break;
290+
case 3:
291+
stepRecommendation = getCocktailTypeOptions(requestDto.getSelectedAlcoholStrength(), requestDto.getSelectedAlcoholBaseType());
292+
chatResponse = "완벽해요! 마지막으로 어떤 스타일로 즐기실 건가요? 🥃";
293+
break;
294+
case 4:
295+
stepRecommendation = getFinalRecommendations(
296+
requestDto.getSelectedAlcoholStrength(),
297+
requestDto.getSelectedAlcoholBaseType(),
298+
requestDto.getSelectedCocktailType()
299+
);
300+
chatResponse = stepRecommendation.getStepTitle();
301+
break;
302+
default:
303+
stepRecommendation = getAlcoholStrengthOptions();
304+
chatResponse = "단계별 맞춤 추천을 시작합니다! 🎯";
305+
}
306+
307+
// 대화 기록 저장
308+
saveConversation(requestDto, chatResponse);
309+
310+
return new ChatResponseDto(chatResponse, stepRecommendation);
311+
}
312+
241313
@Transactional(readOnly = true)
242314
public List<ChatConversation> getUserChatHistory(Long userId) {
243315
return chatConversationRepository.findByUserIdOrderByCreatedAtDesc(userId, Pageable.unpaged()).getContent();
244316
}
245317

318+
319+
private StepRecommendationResponseDto getAlcoholStrengthOptions() {
320+
List<StepRecommendationResponseDto.StepOption> options = new ArrayList<>();
321+
322+
for (AlcoholStrength strength : AlcoholStrength.values()) {
323+
options.add(new StepRecommendationResponseDto.StepOption(
324+
strength.name(),
325+
strength.getDescription(),
326+
null
327+
));
328+
}
329+
330+
return new StepRecommendationResponseDto(
331+
1,
332+
"원하시는 도수를 선택해주세요!",
333+
options,
334+
null,
335+
false
336+
);
337+
}
338+
339+
private StepRecommendationResponseDto getAlcoholBaseTypeOptions(AlcoholStrength alcoholStrength) {
340+
List<StepRecommendationResponseDto.StepOption> options = new ArrayList<>();
341+
342+
for (AlcoholBaseType baseType : AlcoholBaseType.values()) {
343+
options.add(new StepRecommendationResponseDto.StepOption(
344+
baseType.name(),
345+
baseType.getDescription(),
346+
null
347+
));
348+
}
349+
350+
return new StepRecommendationResponseDto(
351+
2,
352+
"베이스가 될 술을 선택해주세요!",
353+
options,
354+
null,
355+
false
356+
);
357+
}
358+
359+
private StepRecommendationResponseDto getCocktailTypeOptions(AlcoholStrength alcoholStrength, AlcoholBaseType alcoholBaseType) {
360+
List<StepRecommendationResponseDto.StepOption> options = new ArrayList<>();
361+
362+
for (CocktailType cocktailType : CocktailType.values()) {
363+
options.add(new StepRecommendationResponseDto.StepOption(
364+
cocktailType.name(),
365+
cocktailType.getDescription(),
366+
null
367+
));
368+
}
369+
370+
return new StepRecommendationResponseDto(
371+
3,
372+
"어떤 종류의 잔으로 드시겠어요?",
373+
options,
374+
null,
375+
false
376+
);
377+
}
378+
379+
private StepRecommendationResponseDto getFinalRecommendations(
380+
AlcoholStrength alcoholStrength,
381+
AlcoholBaseType alcoholBaseType,
382+
CocktailType cocktailType) {
383+
// 필터링 조건에 맞는 칵테일 검색
384+
List<AlcoholStrength> strengths = List.of(alcoholStrength);
385+
List<AlcoholBaseType> baseTypes = List.of(alcoholBaseType);
386+
List<CocktailType> cocktailTypes = List.of(cocktailType);
387+
388+
Page<Cocktail> cocktailPage = cocktailRepository.searchWithFilters(
389+
null, // 키워드 없음
390+
strengths,
391+
cocktailTypes,
392+
baseTypes,
393+
PageRequest.of(0, 5) // 최대 5개 추천
394+
);
395+
396+
List<CocktailSummaryResponseDto> recommendations = cocktailPage.getContent().stream()
397+
.map(cocktail -> new CocktailSummaryResponseDto(
398+
cocktail.getId(),
399+
cocktail.getCocktailName(),
400+
cocktail.getCocktailImgUrl(),
401+
cocktail.getAlcoholStrength()
402+
))
403+
.collect(Collectors.toList());
404+
405+
String stepTitle = recommendations.isEmpty()
406+
? "조건에 맞는 칵테일을 찾을 수 없습니다 😢"
407+
: "당신을 위한 맞춤 칵테일 추천입니다! 🍹";
408+
409+
return new StepRecommendationResponseDto(
410+
4,
411+
stepTitle,
412+
null,
413+
recommendations,
414+
true
415+
);
416+
}
417+
246418
}
247419

src/main/resources/prompts/chatbot-response-rules.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
위의 시스템 프롬프트와 대화 기록을 참고하여, '쑤울 AI 바텐더'로서 친근하고 전문적인 답변을 제공해주세요.
1+
위의 시스템 프롬프트와 대화 기록을 참고하여, 칵테일 AI 바텐더 '쑤리'로서 친근하고 전문적인 답변을 제공해주세요.
22
칵테일과 관련된 유용한 정보를 포함하되, 자연스럽고 대화하듯 응답해주세요.
33

44
【중요한 응답 규칙】

src/main/resources/prompts/chatbot-system-prompt.txt

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
당신은 'Ssoul' 칵테일 전문 AI 바텐더입니다.
1+
당신은 'Ssoul' 칵테일 전문 AI 바텐더 '쑤리' 입니다.
22

33
## 역할과 페르소나
4-
- 이름: 쑤울 AI 바텐더
4+
- 이름: 쑤리
55
- 성격: 친근하고 전문적이며, 유머러스하면서도 신뢰할 수 있는 칵테일 전문가
66
- 말투: 반말이 아닌 존댓말을 사용하며, 친근한 바텐더처럼 대화
77
- 특징: 칵테일에 대한 깊은 지식과 함께 상황에 맞는 칵테일 추천 능력
@@ -21,8 +21,8 @@
2121
5. **창의성**: 클래식 칵테일 외에도 현대적 변형이나 논알콜 대안 제시
2222

2323
## 응답 길이 제한
24-
- **기본 답변**: 200자 이내로 간결하게 작성
25-
- **레시피 제공**: 최대 300자 이내로 핵심만 전달
24+
- **기본 답변**: 300자 이내로 간결하게 작성
25+
- **레시피 제공**: 최대 400자 이내로 핵심만 전달
2626
- **복잡한 설명**: 필요시 "더 알고 싶으시면 추가로 질문해주세요"로 마무리
2727
- **한 문단**: 최대 3-4문장으로 제한
2828

@@ -56,4 +56,5 @@
5656
- 논알콜 칵테일(목테일)도 적극적으로 소개
5757
- 홈바 입문자를 위한 기본 도구와 재료 안내
5858
- 칵테일과 어울리는 안주나 분위기 추천
59-
- 과음 방지를 위한 적절한 조언 포함
59+
- 과음 방지를 위한 적절한 조언 포함
60+
- 칵테일 추천에 대한 이유를 간단히 특징에 언급

0 commit comments

Comments
 (0)