From dc706cee17760cc0b080c43122fd09b43e328c3e Mon Sep 17 00:00:00 2001 From: Enble Date: Fri, 12 Sep 2025 18:30:08 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=B1=97=EB=B4=87=20=EB=8B=A4?= =?UTF-8?q?=EA=B5=AD=EC=96=B4=20=EC=A7=80=EC=9B=90=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(=ED=95=9C=EA=B5=AD=EC=96=B4/=EC=98=81=EC=96=B4/=EC=9D=BC?= =?UTF-8?q?=EB=B3=B8=EC=96=B4/=EC=A4=91=EA=B5=AD=EC=96=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 언어별 의도 분류 및 컨텍스트 추출 시스템 프롬프트 추가 - ChatRequest, ChatContext에 language 필드 추가 - LanguageService: 언어 검증 및 fallback 처리 로직 구현 - MessageTemplateService: 4개 언어 메시지 템플릿 관리 - RagService: 언어별 장소 정보 및 루트 추천 응답 생성 - ChatResponseBuilder: 언어별 데이터 변환 및 응답 구성 - 일본어/중국어 요청 시 영어 데이터로 fallback 처리 - application.yml에 다국어 설정 추가 --- .../domain/chatbot/dto/ChatContext.java | 3 + .../domain/chatbot/dto/ChatRequest.java | 4 + .../chatbot/service/ChatResponseBuilder.java | 30 +- .../domain/chatbot/service/ChatService.java | 95 ++++-- .../chatbot/service/ContextExtractor.java | 179 ++++++++-- .../chatbot/service/IntentClassifier.java | 263 +++++++++++++-- .../chatbot/service/LanguageService.java | 131 ++++++++ .../service/MessageTemplateService.java | 207 ++++++++++++ .../domain/chatbot/service/RagService.java | 318 +++++++++++++++--- src/main/resources/application.yml | 133 ++++---- 10 files changed, 1136 insertions(+), 227 deletions(-) create mode 100644 src/main/java/com/mey/backend/domain/chatbot/service/LanguageService.java create mode 100644 src/main/java/com/mey/backend/domain/chatbot/service/MessageTemplateService.java diff --git a/src/main/java/com/mey/backend/domain/chatbot/dto/ChatContext.java b/src/main/java/com/mey/backend/domain/chatbot/dto/ChatContext.java index 890e696..3198d5a 100644 --- a/src/main/java/com/mey/backend/domain/chatbot/dto/ChatContext.java +++ b/src/main/java/com/mey/backend/domain/chatbot/dto/ChatContext.java @@ -46,4 +46,7 @@ public class ChatContext { @Schema(description = "대화 시작 시간 (타임스탬프)", example = "1693920000000") private Long conversationStartTime; + + @Schema(description = "사용자 언어", example = "ko", allowableValues = {"ko", "en", "ja", "zh"}) + private String userLanguage; } \ No newline at end of file diff --git a/src/main/java/com/mey/backend/domain/chatbot/dto/ChatRequest.java b/src/main/java/com/mey/backend/domain/chatbot/dto/ChatRequest.java index ae8b71a..c4f3c41 100644 --- a/src/main/java/com/mey/backend/domain/chatbot/dto/ChatRequest.java +++ b/src/main/java/com/mey/backend/domain/chatbot/dto/ChatRequest.java @@ -19,4 +19,8 @@ public class ChatRequest { @Schema(description = "이전 대화에서 추출된 컨텍스트 정보") private ChatContext context; + + @Schema(description = "사용자 언어", example = "ko", allowableValues = {"ko", "en", "ja", "zh"}) + @Builder.Default + private String language = "ko"; } diff --git a/src/main/java/com/mey/backend/domain/chatbot/service/ChatResponseBuilder.java b/src/main/java/com/mey/backend/domain/chatbot/service/ChatResponseBuilder.java index 88b5352..7586ff9 100644 --- a/src/main/java/com/mey/backend/domain/chatbot/service/ChatResponseBuilder.java +++ b/src/main/java/com/mey/backend/domain/chatbot/service/ChatResponseBuilder.java @@ -29,6 +29,8 @@ public class ChatResponseBuilder { private final ConversationManager conversationManager; + private final MessageTemplateService messageTemplateService; + private final LanguageService languageService; /** * 기본 질문 응답을 생성합니다. @@ -64,9 +66,10 @@ public ChatResponse createQuestionResponse(String message, ChatContext context, * 에러 응답을 생성합니다. */ public ChatResponse createErrorResponse(String message, ChatContext context) { + String language = context.getUserLanguage() != null ? context.getUserLanguage() : "ko"; return ChatResponse.builder() .responseType(ChatResponse.ResponseType.QUESTION) - .message(message + " 다시 시도해주시겠어요?") + .message(message + " " + messageTemplateService.getErrorSuffix(language)) .context(context) .build(); } @@ -86,12 +89,13 @@ public ChatResponse createGeneralInfoResponse(String message, ChatContext contex * 장소 정보 응답을 생성합니다. */ public ChatResponse createPlaceInfoResponse(String message, List places, ChatContext context) { + String language = context.getUserLanguage() != null ? context.getUserLanguage() : "ko"; List placeInfos = places.stream() .map(place -> ChatResponse.PlaceInfo.builder() .placeId(place.getPlaceId()) - .name(place.getNameKo()) - .description(place.getDescriptionKo()) - .address(place.getAddressKo()) + .name(languageService.getPlaceName(place, language)) + .description(languageService.getPlaceDescription(place, language)) + .address(languageService.getPlaceAddress(place, language)) .themes(place.getThemes()) .costInfo(place.getCostInfo()) .build()) @@ -111,9 +115,10 @@ public ChatResponse createPlaceInfoResponse(String message, List places, public ChatResponse createExistingRoutesResponse(String message, List routes, ChatContext context) { + String language = context.getUserLanguage() != null ? context.getUserLanguage() : "ko"; List existingRoutes = routes.stream() .limit(5) // 최대 5개 루트만 반환 - .map(this::convertRouteToExistingRoute) + .map(route -> convertRouteToExistingRoute(route, language)) .toList(); return ChatResponse.builder() @@ -163,7 +168,7 @@ public ChatResponse createAIRouteRecommendationResponse(String message, /** * Route 엔티티를 ExistingRoute DTO로 변환 */ - private ChatResponse.ExistingRoute convertRouteToExistingRoute(com.mey.backend.domain.route.entity.Route route) { + private ChatResponse.ExistingRoute convertRouteToExistingRoute(com.mey.backend.domain.route.entity.Route route, String language) { // Theme enum을 String으로 변환 List themeStrings = route.getThemes().stream() .map(theme -> theme.getRouteTheme()) @@ -171,8 +176,8 @@ private ChatResponse.ExistingRoute convertRouteToExistingRoute(com.mey.backend.d return ChatResponse.ExistingRoute.builder() .routeId(route.getId()) - .title(route.getTitleKo()) - .description(route.getDescriptionKo()) + .title(languageService.getRouteTitle(route, language)) + .description(languageService.getRouteDescription(route, language)) .estimatedCost(route.getTotalCost()) .durationMinutes(route.getTotalDurationMinutes()) .themes(themeStrings) @@ -183,12 +188,7 @@ private ChatResponse.ExistingRoute convertRouteToExistingRoute(com.mey.backend.d * 단계별 안내 메시지 생성 */ public String generateStepMessage(ConversationState currentState, ChatContext context) { - return switch (currentState) { - case AWAITING_THEME -> "테마 선택이 필요해요. K-POP, K-드라마, K-푸드, K-패션 중 어떤 테마를 원하시나요?"; - case AWAITING_REGION -> "좋습니다! " + (context.getTheme() != null ? context.getTheme().name() : "") + " 테마를 선택하셨네요. 어느 지역을 여행하고 싶으신가요?"; - case AWAITING_DAYS -> (context.getRegion() != null ? context.getRegion() : "") + " 지역을 선택하셨네요! 몇 일 여행을 계획하고 계신가요?"; - case READY_FOR_ROUTE -> "모든 정보가 준비되었습니다! 맞춤 루트를 생성해드릴게요."; - default -> "안내에 따라 정보를 입력해주세요."; - }; + String language = context.getUserLanguage() != null ? context.getUserLanguage() : "ko"; + return messageTemplateService.getStateMessage(currentState, language); } } diff --git a/src/main/java/com/mey/backend/domain/chatbot/service/ChatService.java b/src/main/java/com/mey/backend/domain/chatbot/service/ChatService.java index c7fd835..ea94aa0 100644 --- a/src/main/java/com/mey/backend/domain/chatbot/service/ChatService.java +++ b/src/main/java/com/mey/backend/domain/chatbot/service/ChatService.java @@ -34,6 +34,8 @@ public class ChatService { private final IntentClassifier intentClassifier; private final ContextExtractor contextExtractor; private final ChatResponseBuilder responseBuilder; + private final LanguageService languageService; + private final MessageTemplateService messageTemplateService; @EventListener(ApplicationReadyEvent.class) @Transactional(readOnly = true) @@ -86,26 +88,31 @@ public void initializeVectorStore() { * 상태 기반 대화 처리를 지원하며, 세션 연속성을 보장합니다. */ public ChatResponse processUserQuery(ChatRequest request) { - log.info("Processing user query: {}", request.getQuery()); + log.info("Processing user query: {} (language: {})", request.getQuery(), request.getLanguage()); - // 1. 세션 보장 및 컨텍스트 가져오기 + // 1. 언어 검증 및 설정 + String validatedLanguage = languageService.validateAndGetLanguage(request.getLanguage()); + log.info("Validated language: {}", validatedLanguage); + + // 2. 세션 보장 및 컨텍스트 가져오기 (언어 정보 포함) ChatContext context = conversationManager.ensureSessionAndGetContext(request); - log.info("Current conversation state: {}, SessionId: {}", - context.getConversationState(), context.getSessionId()); + context = context.toBuilder().userLanguage(validatedLanguage).build(); + log.info("Current conversation state: {}, SessionId: {}, Language: {}", + context.getConversationState(), context.getSessionId(), context.getUserLanguage()); - // 2. 상태 기반 대화 처리 + // 3. 상태 기반 대화 처리 if (conversationManager.requiresStatefulHandling(context)) { return handleStatefulConversation(request, context); } - // 3. 초기 상태 또는 상태 없음 - 의도 분류 수행 - IntentClassificationResult classificationResult = intentClassifier.classifyUserIntent(request.getQuery()); + // 4. 초기 상태 또는 상태 없음 - 의도 분류 수행 (언어 고려) + IntentClassificationResult classificationResult = intentClassifier.classifyUserIntent(request.getQuery(), validatedLanguage); log.info("LLM 의도 분류 결과: {} (신뢰도: {}, 근거: {})", classificationResult.getIntent(), classificationResult.getConfidence(), classificationResult.getReasoning()); - // 4. 의도별 처리 + // 5. 의도별 처리 return switch (classificationResult.getIntent()) { case CREATE_ROUTE -> handleCreateRouteIntent(request.toBuilder().context(context).build()); case SEARCH_EXISTING_ROUTES -> handleSearchExistingRoutesIntent(request.toBuilder().context(context).build()); @@ -140,11 +147,12 @@ private ChatResponse handleThemeInput(ChatRequest request, ChatContext context) ChatContext updatedContext = contextExtractor.extractThemeFromQuery(request.getQuery(), context); if (updatedContext.getTheme() == null) { + String language = context.getUserLanguage(); return responseBuilder.createQuestionResponse( - "테마를 인식하지 못했습니다. K-POP, K-드라마, K-푸드, K-패션 중에서 선택해주세요.", + messageTemplateService.getRecognitionFailureMessage("theme", language), context.toBuilder().build(), // 상태 유지 ConversationState.AWAITING_THEME, - "어떤 테마의 루트를 찾고 계신가요?" + messageTemplateService.getMissingInfoMessage("theme", language) ); } @@ -156,11 +164,12 @@ private ChatResponse handleThemeInput(ChatRequest request, ChatContext context) conversationManager.saveSessionContext(nextContext.getSessionId(), nextContext); + String language = context.getUserLanguage(); return responseBuilder.createQuestionResponse( - "좋습니다! " + updatedContext.getTheme().name() + " 테마를 선택하셨네요. 어느 지역의 루트를 원하시나요? (예: 서울, 부산)", + messageTemplateService.getThemeConfirmationMessage(updatedContext.getTheme().name(), language), nextContext, ConversationState.AWAITING_REGION, - "어느 지역의 루트를 원하시나요?" + messageTemplateService.getMissingInfoMessage("region", language) ); } @@ -174,11 +183,12 @@ private ChatResponse handleRegionInput(ChatRequest request, ChatContext context) ChatContext updatedContext = contextExtractor.extractRegionFromQuery(request.getQuery(), context); if (updatedContext.getRegion() == null || updatedContext.getRegion().trim().isEmpty()) { + String language = context.getUserLanguage(); return responseBuilder.createQuestionResponse( - "지역을 인식하지 못했습니다. 구체적인 지역명을 말씀해주세요. (예: 서울, 부산, 제주도)", + messageTemplateService.getRecognitionFailureMessage("region", language), context.toBuilder().build(), // 상태 유지 ConversationState.AWAITING_REGION, - "어느 지역의 루트를 원하시나요?" + messageTemplateService.getMissingInfoMessage("region", language) ); } @@ -190,11 +200,12 @@ private ChatResponse handleRegionInput(ChatRequest request, ChatContext context) conversationManager.saveSessionContext(nextContext.getSessionId(), nextContext); + String language = context.getUserLanguage(); return responseBuilder.createQuestionResponse( - updatedContext.getRegion() + " 지역을 선택하셨네요! 몇 일 여행을 계획하고 계신가요? (예: 1일, 2일, 3일)", + messageTemplateService.getRegionConfirmationMessage(updatedContext.getRegion(), language), nextContext, ConversationState.AWAITING_DAYS, - "몇 일 여행을 계획하고 계신가요?" + messageTemplateService.getMissingInfoMessage("days", language) ); } @@ -208,11 +219,12 @@ private ChatResponse handleDaysInput(ChatRequest request, ChatContext context) { ChatContext updatedContext = contextExtractor.extractDaysFromQuery(request.getQuery(), context); if (updatedContext.getDays() == null || updatedContext.getDays() <= 0) { + String language = context.getUserLanguage(); return responseBuilder.createQuestionResponse( - "일수를 인식하지 못했습니다. 숫자로 말씀해주세요. (예: 1일, 2일, 3일)", + messageTemplateService.getRecognitionFailureMessage("days", language), context.toBuilder().build(), // 상태 유지 ConversationState.AWAITING_DAYS, - "몇 일 여행을 계획하고 계신가요?" + messageTemplateService.getMissingInfoMessage("days", language) ); } @@ -237,7 +249,8 @@ private ChatResponse recommendRouteWithRag(ChatContext context, String originalQ List placeIds = ragService.searchPlaceIds(searchQuery, placesNeeded); if (placeIds.isEmpty()) { - return responseBuilder.createErrorResponse("죄송합니다. 요청하신 조건에 맞는 장소를 찾을 수 없습니다. 다른 테마나 지역을 시도해보시겠어요?", context); + String language = context.getUserLanguage(); + return responseBuilder.createErrorResponse(messageTemplateService.getNoResultsMessage(language), context); } // 2. 실제 검색된 장소 수를 기반으로 일수 조정 @@ -272,10 +285,20 @@ private String buildSearchQuery(ChatContext context, String originalQuery) { } private String createDocumentFromPlace(Place place) { + return createDocumentFromPlace(place, "ko"); // 기본 한국어로 벡터 스토어 구성 + } + + private String createDocumentFromPlace(Place place, String language) { StringBuilder document = new StringBuilder(); - document.append("장소명: ").append(place.getNameKo()).append("\n"); - document.append("설명: ").append(place.getDescriptionKo()).append("\n"); - document.append("주소: ").append(place.getAddressKo()).append("\n"); + + // 언어별 필드 활용 + String placeName = languageService.getPlaceName(place, language); + String placeDescription = languageService.getPlaceDescription(place, language); + String placeAddress = languageService.getPlaceAddress(place, language); + + document.append("장소명: ").append(placeName).append("\n"); + document.append("설명: ").append(placeDescription).append("\n"); + document.append("주소: ").append(placeAddress).append("\n"); document.append("지역: ").append(place.getRegion().getNameKo()).append("\n"); document.append("테마: ").append(String.join(", ", place.getThemes())).append("\n"); document.append("비용정보: ").append(place.getCostInfo()).append("\n"); @@ -363,10 +386,12 @@ private ChatResponse createRouteAndResponse(DaysAdjustmentResult adjustmentResul .filter(java.util.Objects::nonNull) .collect(java.util.stream.Collectors.toList()); + String language = adjustmentResult.adjustedContext().getUserLanguage(); String aiGeneratedMessage = ragService.generateRouteRecommendationAnswerWithPlaces( adjustmentResult.adjustedContext().getDays() + "일 " + adjustmentResult.adjustedContext().getTheme().name() + " 테마 루트", - routePlaces + routePlaces, + language ); String finalMessage = adjustmentResult.adjustmentMessage() + aiGeneratedMessage; @@ -390,7 +415,8 @@ private ChatResponse createRouteAndResponse(DaysAdjustmentResult adjustmentResul } catch (Exception e) { log.error("루트 생성 중 오류 발생", e); - return responseBuilder.createErrorResponse("루트 생성 중 오류가 발생했습니다. 다시 시도해주시겠어요?", adjustmentResult.adjustedContext()); + String language = adjustmentResult.adjustedContext().getUserLanguage(); + return responseBuilder.createErrorResponse(messageTemplateService.getNoResultsMessage(language), adjustmentResult.adjustedContext()); } } @@ -405,18 +431,19 @@ private ChatResponse handleCreateRouteIntent(ChatRequest request) { String missingInfo = contextExtractor.checkMissingRequiredInfo(extractedContext); if (missingInfo != null) { // 상태 기반 대화 시작 - 첫 번째 누락 항목에 따라 상태 설정 + String language = extractedContext.getUserLanguage(); ConversationState nextState; String question; if (extractedContext.getTheme() == null) { nextState = ConversationState.AWAITING_THEME; - question = "어떤 테마의 루트를 찾고 계신가요? (K-POP, K-드라마, K-푸드, K-패션 중 선택해주세요)"; + question = messageTemplateService.getMissingInfoMessage("theme", language); } else if (extractedContext.getRegion() == null || extractedContext.getRegion().trim().isEmpty()) { nextState = ConversationState.AWAITING_REGION; - question = "어느 지역의 루트를 원하시나요? (예: 서울, 부산)"; + question = messageTemplateService.getMissingInfoMessage("region", language); } else { nextState = ConversationState.AWAITING_DAYS; - question = "몇 일 여행을 계획하고 계신가요? (예: 1일, 2일, 3일)"; + question = messageTemplateService.getMissingInfoMessage("days", language); } return responseBuilder.createQuestionResponse(missingInfo, extractedContext, nextState, question); @@ -438,12 +465,14 @@ private ChatResponse handleSearchExistingRoutesIntent(ChatRequest request) { List routes = searchExistingRoutes(extractedContext, request.getQuery()); if (routes.isEmpty()) { - return responseBuilder.createQuestionResponse("요청하신 조건에 맞는 기존 루트를 찾을 수 없습니다. 새로운 루트를 만들어드릴까요?", extractedContext); + String language = extractedContext.getUserLanguage(); + return responseBuilder.createQuestionResponse(messageTemplateService.getNoResultsMessage(language), extractedContext); } - // 3. RAG를 통한 자연스러운 추천 메시지 생성 + // 3. RAG를 통한 자연스러운 추천 메시지 생성 (언어 고려) List relevantDocs = ragService.retrieve(request.getQuery(), 3); - String recommendationMessage = ragService.generateRouteRecommendationAnswer(request.getQuery(), relevantDocs); + String language = extractedContext.getUserLanguage(); + String recommendationMessage = ragService.generateRouteRecommendationAnswer(request.getQuery(), relevantDocs, language); // 4. Route 엔티티를 ExistingRoute DTO로 변환 return responseBuilder.createExistingRoutesResponse(recommendationMessage, routes, extractedContext); @@ -459,11 +488,13 @@ private ChatResponse handleSearchPlacesIntent(ChatRequest request) { List placeIds = ragService.searchPlaceIds(request.getQuery(), 5); if (placeIds.isEmpty()) { - return responseBuilder.createQuestionResponse("요청하신 조건에 맞는 장소를 찾을 수 없습니다. 다른 키워드로 검색해보시겠어요?", extractedContext); + String language = extractedContext.getUserLanguage(); + return responseBuilder.createQuestionResponse(messageTemplateService.getNoResultsMessage(language), extractedContext); } List places = placeRepository.findAllById(placeIds); - return responseBuilder.createPlaceInfoResponse("검색하신 조건에 맞는 장소들을 찾았습니다:", places, extractedContext); + String language = extractedContext.getUserLanguage(); + return responseBuilder.createPlaceInfoResponse(messageTemplateService.getPlaceInfoHeader(language), places, extractedContext); } /** diff --git a/src/main/java/com/mey/backend/domain/chatbot/service/ContextExtractor.java b/src/main/java/com/mey/backend/domain/chatbot/service/ContextExtractor.java index e0cdf33..c7a2233 100644 --- a/src/main/java/com/mey/backend/domain/chatbot/service/ContextExtractor.java +++ b/src/main/java/com/mey/backend/domain/chatbot/service/ContextExtractor.java @@ -44,35 +44,15 @@ public class ContextExtractor { * 사용자 질문에서 전체 컨텍스트를 추출합니다. */ public ChatContext extractContextFromQuery(String query, ChatContext existingContext) { - String systemPrompt = """ - 당신은 사용자 질문에서 한류 루트 추천에 필요한 정보를 추출하는 전문가입니다. - 다음 정보를 JSON 형태로 추출해주세요: - - theme: 테마 ("KDRAMA", "KPOP", "KFOOD", "KFASHION" 중 하나, 정확히 이 값들 사용) - - region: 지역명 (서울, 부산 등) - - budget: 예산 (숫자만, 원 단위) - - preferences: 특별 선호사항 - - durationMinutes: 소요 시간 (분 단위) - - days: 여행 일수 (1, 2, 3 등의 숫자) - - 기존 컨텍스트가 있다면 이를 기반으로 새로운 정보만 업데이트하세요. - 정보가 없거나 추출할 수 없으면 null로 설정하세요. - - 테마 변환 규칙: - - "K-POP", "케이팝", "kpop" → "KPOP" - - "K-드라마", "케이드라마", "kdrama" → "KDRAMA" - - "K-푸드", "케이푸드", "kfood" → "KFOOD" - - "K-패션", "케이패션", "kfashion" → "KFASHION" - - 응답 형식 (반드시 유효한 JSON): - { - "theme": "KPOP", - "region": "서울", - "budget": 50000, - "preferences": null, - "durationMinutes": null, - "days": 2 - } - """; + String language = existingContext != null ? existingContext.getUserLanguage() : "ko"; + return extractContextFromQuery(query, existingContext, language); + } + + /** + * 언어를 고려하여 사용자 질문에서 전체 컨텍스트를 추출합니다. + */ + public ChatContext extractContextFromQuery(String query, ChatContext existingContext, String language) { + String systemPrompt = getSystemPromptByLanguage(language); String contextInfo = existingContext != null ? "기존 컨텍스트: " + convertContextToString(existingContext) : "기존 컨텍스트 없음"; @@ -334,4 +314,145 @@ private org.springframework.ai.chat.model.ChatResponse callOpenAi(String userInp return chatModel.call(prompt); } + + /** + * 언어별 시스템 프롬프트를 반환합니다. + */ + private String getSystemPromptByLanguage(String language) { + return switch (language) { + case "ko" -> getKoreanSystemPrompt(); + case "en" -> getEnglishSystemPrompt(); + case "ja" -> getJapaneseSystemPrompt(); + case "zh" -> getChineseSystemPrompt(); + default -> getKoreanSystemPrompt(); + }; + } + + private String getKoreanSystemPrompt() { + return """ + 당신은 사용자 질문에서 한류 루트 추천에 필요한 정보를 추출하는 전문가입니다. + 다음 정보를 JSON 형태로 추출해주세요: + - theme: 테마 ("KDRAMA", "KPOP", "KFOOD", "KFASHION" 중 하나, 정확히 이 값들 사용) + - region: 지역명 (서울, 부산 등) + - budget: 예산 (숫자만, 원 단위) + - preferences: 특별 선호사항 + - durationMinutes: 소요 시간 (분 단위) + - days: 여행 일수 (1, 2, 3 등의 숫자) + + 기존 컨텍스트가 있다면 이를 기반으로 새로운 정보만 업데이트하세요. + 정보가 없거나 추출할 수 없으면 null로 설정하세요. + + 테마 변환 규칙: + - "K-POP", "케이팝", "kpop" → "KPOP" + - "K-드라마", "케이드라마", "kdrama" → "KDRAMA" + - "K-푸드", "케이푸드", "kfood" → "KFOOD" + - "K-패션", "케이패션", "kfashion" → "KFASHION" + + 응답 형식 (반드시 유효한 JSON): + { + "theme": "KPOP", + "region": "서울", + "budget": 50000, + "preferences": null, + "durationMinutes": null, + "days": 2 + } + """; + } + + private String getEnglishSystemPrompt() { + return """ + You are an expert at extracting information needed for Korean Wave route recommendations from user questions. + Please extract the following information in JSON format: + - theme: Theme ("KDRAMA", "KPOP", "KFOOD", "KFASHION" - use exactly these values) + - region: Region name (Seoul, Busan, etc.) + - budget: Budget (numbers only, in KRW) + - preferences: Special preferences + - durationMinutes: Duration in minutes + - days: Number of travel days (numbers like 1, 2, 3) + + If there is existing context, update only new information based on it. + If information is not available or cannot be extracted, set it to null. + + Theme conversion rules: + - "K-POP", "kpop", "k-pop" → "KPOP" + - "K-Drama", "kdrama", "k-drama" → "KDRAMA" + - "K-Food", "kfood", "k-food" → "KFOOD" + - "K-Fashion", "kfashion", "k-fashion" → "KFASHION" + + Response format (must be valid JSON): + { + "theme": "KPOP", + "region": "Seoul", + "budget": 50000, + "preferences": null, + "durationMinutes": null, + "days": 2 + } + """; + } + + private String getJapaneseSystemPrompt() { + return """ + あなたは韓流ルート推薦に必要な情報をユーザーの質問から抽出する専門家です。 + 以下の情報をJSON形式で抽出してください: + - theme: テーマ ("KDRAMA", "KPOP", "KFOOD", "KFASHION" のいずれか、正確にこれらの値を使用) + - region: 地域名 (ソウル、釜山など) + - budget: 予算 (数字のみ、ウォン単位) + - preferences: 特別な好み + - durationMinutes: 所要時間 (分単位) + - days: 旅行日数 (1, 2, 3などの数字) + + 既存のコンテキストがある場合は、それを基に新しい情報のみを更新してください。 + 情報がないか抽出できない場合は、nullに設定してください。 + + テーマ変換ルール: + - "K-POP", "ケイポップ", "kpop" → "KPOP" + - "K-ドラマ", "ケイドラマ", "kdrama" → "KDRAMA" + - "K-フード", "ケイフード", "kfood" → "KFOOD" + - "K-ファッション", "ケイファッション", "kfashion" → "KFASHION" + + 応答形式 (有効なJSONである必要があります): + { + "theme": "KPOP", + "region": "ソウル", + "budget": 50000, + "preferences": null, + "durationMinutes": null, + "days": 2 + } + """; + } + + private String getChineseSystemPrompt() { + return """ + 您是从用户问题中提取韩流路线推荐所需信息的专家。 + 请以JSON格式提取以下信息: + - theme: 主题 ("KDRAMA", "KPOP", "KFOOD", "KFASHION" 中的一个,请准确使用这些值) + - region: 地区名称 (首尔、釜山等) + - budget: 预算 (仅数字,韩元单位) + - preferences: 特殊偏好 + - durationMinutes: 持续时间 (分钟) + - days: 旅行天数 (1, 2, 3等数字) + + 如果有现有上下文,请基于它仅更新新信息。 + 如果信息不可用或无法提取,请设置为null。 + + 主题转换规则: + - "K-POP", "韩流音乐", "kpop" → "KPOP" + - "K-Drama", "韩剧", "kdrama" → "KDRAMA" + - "K-Food", "韩食", "kfood" → "KFOOD" + - "K-Fashion", "韩流时尚", "kfashion" → "KFASHION" + + 响应格式 (必须是有效的JSON): + { + "theme": "KPOP", + "region": "首尔", + "budget": 50000, + "preferences": null, + "durationMinutes": null, + "days": 2 + } + """; + } } diff --git a/src/main/java/com/mey/backend/domain/chatbot/service/IntentClassifier.java b/src/main/java/com/mey/backend/domain/chatbot/service/IntentClassifier.java index f8f3acb..677e280 100644 --- a/src/main/java/com/mey/backend/domain/chatbot/service/IntentClassifier.java +++ b/src/main/java/com/mey/backend/domain/chatbot/service/IntentClassifier.java @@ -39,45 +39,26 @@ public class IntentClassifier { * 사용자 질문의 의도를 분류합니다. */ public IntentClassificationResult classifyUserIntent(String query) { + return classifyUserIntent(query, "ko"); // 기본 한국어 + } + + /** + * 언어를 고려하여 사용자 질문의 의도를 분류합니다. + */ + public IntentClassificationResult classifyUserIntent(String query, String language) { try { - return classifyWithLLM(query); + return classifyWithLLM(query, language); } catch (Exception e) { log.error("LLM 의도 분류 실패, fallback 사용: {}", e.getMessage()); - return fallbackIntentClassification(query); + return fallbackIntentClassification(query, language); } } /** * LLM을 사용한 의도 분류 */ - private IntentClassificationResult classifyWithLLM(String query) { - String systemPrompt = """ - 당신은 한류 여행 챗봇의 의도 분류 전문가입니다. - 사용자의 질문을 다음 4가지 의도 중 하나로 분류해주세요: - - 1. CREATE_ROUTE: 새로운 여행 루트를 만들어달라는 요청 - - 키워드: "추천해줘", "계획해줘", "루트 만들", "여행 계획", "일정 짜줘" - - 예시: "2일 서울 K-POP 루트 추천해줘", "부산 여행 계획해줘" - - 2. SEARCH_EXISTING_ROUTES: 이미 만들어진 루트를 찾아달라는 요청 - - 키워드: "기존 루트", "만들어진 루트", "루트 찾아", "루트 검색", "있는 루트" - - 예시: "기존에 만들어진 부산 드라마 루트 있어?", "만들어진 K-POP 루트 보여줘" - - 3. SEARCH_PLACES: 특정 장소나 명소에 대한 정보를 찾는 요청 - - 키워드: "장소", "명소", "어디", "위치", "곳", "찾아줘", "근처", "~에 있는", "추천해줘" (장소 찾기 맥락에서) - - 예시: "홍대 근처 K-POP 장소 어디 있어?", "명동 맛집 찾아줘", "홍대 근처 K-POP 장소 추천해줘" - - 4. GENERAL_QUESTION: 한류나 여행에 대한 일반적인 질문 - - 키워드: 설명 요청, 정보 질문 - - 예시: "BTS가 뭐야?", "K-POP이란?", "한류 역사 알려줘" - - JSON 형식으로 응답해주세요: - { - "intent": "CREATE_ROUTE", - "confidence": 0.95, - "reasoning": "사용자가 새로운 여행 루트를 요청함" - } - """; + private IntentClassificationResult classifyWithLLM(String query, String language) { + String systemPrompt = getSystemPromptByLanguage(language); org.springframework.ai.chat.model.ChatResponse aiResponse = callOpenAi(query, systemPrompt); String responseText = aiResponse.getResult().getOutput().getText().trim(); @@ -91,23 +72,34 @@ private IntentClassificationResult classifyWithLLM(String query) { // 신뢰도 검증 (너무 낮으면 fallback) if (result.getConfidence() < 0.6) { log.warn("LLM 의도 분류 신뢰도가 낮음 ({})... fallback 사용", result.getConfidence()); - return fallbackIntentClassification(query); + return fallbackIntentClassification(query, language); } log.info("LLM 의도 분류 결과: {} (신뢰도: {})", result.getIntent(), result.getConfidence()); return result; } catch (JsonProcessingException e) { log.error("JSON 파싱 실패, fallback 사용: {}", e.getMessage()); - return fallbackIntentClassification(query); + return fallbackIntentClassification(query, language); } } /** * 키워드 기반 fallback 의도 분류 */ - private IntentClassificationResult fallbackIntentClassification(String query) { + private IntentClassificationResult fallbackIntentClassification(String query, String language) { String lowerQuery = query.toLowerCase(); + // 언어별 키워드로 의도 분류 + return switch (language) { + case "ko" -> fallbackClassificationKorean(lowerQuery); + case "en" -> fallbackClassificationEnglish(lowerQuery); + case "ja" -> fallbackClassificationJapanese(lowerQuery); + case "zh" -> fallbackClassificationChinese(lowerQuery); + default -> fallbackClassificationKorean(lowerQuery); // 기본값 + }; + } + + private IntentClassificationResult fallbackClassificationKorean(String lowerQuery) { // CREATE_ROUTE 키워드 if (containsAnyKeyword(lowerQuery, "추천", "계획", "루트 만들", "여행 계획", "일정", "만들어줘")) { return new IntentClassificationResult("CREATE_ROUTE", 0.8, "키워드 기반 분류: 루트 생성 요청"); @@ -132,6 +124,78 @@ private IntentClassificationResult fallbackIntentClassification(String query) { return new IntentClassificationResult("GENERAL_QUESTION", 0.7, "키워드 기반 분류: 일반 질문"); } + private IntentClassificationResult fallbackClassificationEnglish(String lowerQuery) { + // CREATE_ROUTE keywords + if (containsAnyKeyword(lowerQuery, "recommend", "suggest", "plan", "create route", "make itinerary", "trip planning")) { + return new IntentClassificationResult("CREATE_ROUTE", 0.8, "Keyword-based classification: Route creation request"); + } + + // SEARCH_EXISTING_ROUTES keywords + if (containsAnyKeyword(lowerQuery, "existing", "available routes", "find route", "search route", "show routes")) { + return new IntentClassificationResult("SEARCH_EXISTING_ROUTES", 0.8, "Keyword-based classification: Existing route search"); + } + + // SEARCH_PLACES keywords + if (containsAnyKeyword(lowerQuery, "near", "nearby", "around")) { + return new IntentClassificationResult("SEARCH_PLACES", 0.9, "Keyword-based classification: Nearby place search"); + } + + if (containsAnyKeyword(lowerQuery, "place", "location", "where", "find", "spot", "attraction")) { + return new IntentClassificationResult("SEARCH_PLACES", 0.8, "Keyword-based classification: Place search"); + } + + // Default: GENERAL_QUESTION + return new IntentClassificationResult("GENERAL_QUESTION", 0.7, "Keyword-based classification: General question"); + } + + private IntentClassificationResult fallbackClassificationJapanese(String lowerQuery) { + // CREATE_ROUTE keywords + if (containsAnyKeyword(lowerQuery, "おすすめ", "計画", "ルート作", "旅行計画", "スケジュール", "作って")) { + return new IntentClassificationResult("CREATE_ROUTE", 0.8, "キーワードベース分類: ルート作成要求"); + } + + // SEARCH_EXISTING_ROUTES keywords + if (containsAnyKeyword(lowerQuery, "既存", "作られた", "ルート探", "ルート検索", "あるルート")) { + return new IntentClassificationResult("SEARCH_EXISTING_ROUTES", 0.8, "キーワードベース分類: 既存ルート検索"); + } + + // SEARCH_PLACES keywords + if (containsAnyKeyword(lowerQuery, "近く", "付近")) { + return new IntentClassificationResult("SEARCH_PLACES", 0.9, "キーワードベース分類: 近くの場所検索"); + } + + if (containsAnyKeyword(lowerQuery, "場所", "名所", "どこ", "位置", "探して", "スポット")) { + return new IntentClassificationResult("SEARCH_PLACES", 0.8, "キーワードベース分類: 場所検索"); + } + + // Default: GENERAL_QUESTION + return new IntentClassificationResult("GENERAL_QUESTION", 0.7, "キーワードベース分類: 一般的な質問"); + } + + private IntentClassificationResult fallbackClassificationChinese(String lowerQuery) { + // CREATE_ROUTE keywords + if (containsAnyKeyword(lowerQuery, "推荐", "计划", "路线制", "旅行计划", "行程", "制作")) { + return new IntentClassificationResult("CREATE_ROUTE", 0.8, "基于关键词的分类: 路线创建请求"); + } + + // SEARCH_EXISTING_ROUTES keywords + if (containsAnyKeyword(lowerQuery, "现有", "已制作", "路线查找", "路线搜索", "现有路线")) { + return new IntentClassificationResult("SEARCH_EXISTING_ROUTES", 0.8, "基于关键词的分类: 现有路线搜索"); + } + + // SEARCH_PLACES keywords + if (containsAnyKeyword(lowerQuery, "附近", "周围")) { + return new IntentClassificationResult("SEARCH_PLACES", 0.9, "基于关键词的分类: 附近地点搜索"); + } + + if (containsAnyKeyword(lowerQuery, "地点", "景点", "哪里", "位置", "找", "地方")) { + return new IntentClassificationResult("SEARCH_PLACES", 0.8, "基于关键词的分类: 地点搜索"); + } + + // Default: GENERAL_QUESTION + return new IntentClassificationResult("GENERAL_QUESTION", 0.7, "基于关键词的分类: 一般问题"); + } + /** * 키워드 포함 여부 확인 */ @@ -159,4 +223,137 @@ private org.springframework.ai.chat.model.ChatResponse callOpenAi(String userInp return chatModel.call(prompt); } + + /** + * 언어별 시스템 프롬프트를 반환합니다. + */ + private String getSystemPromptByLanguage(String language) { + return switch (language) { + case "ko" -> getKoreanSystemPrompt(); + case "en" -> getEnglishSystemPrompt(); + case "ja" -> getJapaneseSystemPrompt(); + case "zh" -> getChineseSystemPrompt(); + default -> getKoreanSystemPrompt(); // 기본값 + }; + } + + private String getKoreanSystemPrompt() { + return """ + 당신은 한류 여행 챗봇의 의도 분류 전문가입니다. + 사용자의 질문을 다음 4가지 의도 중 하나로 분류해주세요: + + 1. CREATE_ROUTE: 새로운 여행 루트를 만들어달라는 요청 + - 키워드: "추천해줘", "계획해줘", "루트 만들", "여행 계획", "일정 짜줘" + - 예시: "2일 서울 K-POP 루트 추천해줘", "부산 여행 계획해줘" + + 2. SEARCH_EXISTING_ROUTES: 이미 만들어진 루트를 찾아달라는 요청 + - 키워드: "기존 루트", "만들어진 루트", "루트 찾아", "루트 검색", "있는 루트" + - 예시: "기존에 만들어진 부산 드라마 루트 있어?", "만들어진 K-POP 루트 보여줘" + + 3. SEARCH_PLACES: 특정 장소나 명소에 대한 정보를 찾는 요청 + - 키워드: "장소", "명소", "어디", "위치", "곳", "찾아줘", "근처" + - 예시: "홍대 근처 K-POP 장소 어디 있어?", "명동 맛집 찾아줘" + + 4. GENERAL_QUESTION: 한류나 여행에 대한 일반적인 질문 + - 키워드: 설명 요청, 정보 질문 + - 예시: "BTS가 뭐야?", "K-POP이란?", "한류 역사 알려줘" + + JSON 형식으로 응답해주세요: + { + "intent": "CREATE_ROUTE", + "confidence": 0.95, + "reasoning": "사용자가 새로운 여행 루트를 요청함" + } + """; + } + + private String getEnglishSystemPrompt() { + return """ + You are an intent classification expert for a Korean Wave (Hallyu) travel chatbot. + Please classify the user's question into one of the following 4 intents: + + 1. CREATE_ROUTE: Request to create a new travel route + - Keywords: "recommend", "suggest", "plan", "create route", "make itinerary" + - Examples: "Recommend a 2-day Seoul K-POP route", "Plan a Busan trip" + + 2. SEARCH_EXISTING_ROUTES: Request to find existing routes + - Keywords: "existing routes", "available routes", "find route", "search route" + - Examples: "Are there existing Busan drama routes?", "Show me K-POP routes" + + 3. SEARCH_PLACES: Request for information about specific places or attractions + - Keywords: "place", "location", "where", "spot", "find", "near", "around" + - Examples: "Where are K-POP places near Hongdae?", "Find restaurants in Myeongdong" + + 4. GENERAL_QUESTION: General questions about Korean Wave or travel + - Keywords: explanation requests, information questions + - Examples: "What is BTS?", "What is K-POP?", "Tell me about Hallyu history" + + Please respond in JSON format: + { + "intent": "CREATE_ROUTE", + "confidence": 0.95, + "reasoning": "User is requesting a new travel route" + } + """; + } + + private String getJapaneseSystemPrompt() { + return """ + あなたは韓流旅行チャットボットの意図分類専門家です。 + ユーザーの質問を以下の4つの意図のいずれかに分類してください: + + 1. CREATE_ROUTE: 新しい旅行ルートを作ってほしいという要求 + - キーワード: "おすすめ", "計画", "ルート作成", "旅行計画", "スケジュール" + - 例: "2日間のソウルK-POPルートをおすすめして", "釜山旅行を計画して" + + 2. SEARCH_EXISTING_ROUTES: 既存のルートを探してほしいという要求 + - キーワード: "既存ルート", "作られたルート", "ルート検索", "あるルート" + - 例: "釜山ドラマルートはありますか?", "K-POPルートを見せて" + + 3. SEARCH_PLACES: 特定の場所や観光地についての情報を探す要求 + - キーワード: "場所", "観光地", "どこ", "位置", "探して", "近く", "付近" + - 例: "弘大近くのK-POP場所はどこ?", "明洞のレストランを探して" + + 4. GENERAL_QUESTION: 韓流や旅行に関する一般的な質問 + - キーワード: 説明要求, 情報質問 + - 例: "BTSって何?", "K-POPとは?", "韓流の歴史を教えて" + + JSON形式で応答してください: + { + "intent": "CREATE_ROUTE", + "confidence": 0.95, + "reasoning": "ユーザーが新しい旅行ルートを要求している" + } + """; + } + + private String getChineseSystemPrompt() { + return """ + 您是韩流旅行聊天机器人的意图分类专家。 + 请将用户的问题分类为以下4个意图之一: + + 1. CREATE_ROUTE: 请求创建新的旅行路线 + - 关键词: "推荐", "建议", "计划", "创建路线", "制作行程" + - 示例: "推荐2天首尔K-POP路线", "计划釜山旅行" + + 2. SEARCH_EXISTING_ROUTES: 请求查找现有路线 + - 关键词: "现有路线", "已有路线", "查找路线", "搜索路线" + - 示例: "有釜山韩剧路线吗?", "显示K-POP路线" + + 3. SEARCH_PLACES: 请求特定地点或景点信息 + - 关键词: "地点", "景点", "哪里", "位置", "查找", "附近", "周围" + - 示例: "弘大附近的K-POP地点在哪?", "找明洞餐厅" + + 4. GENERAL_QUESTION: 关于韩流或旅行的一般问题 + - 关键词: 解释请求, 信息询问 + - 示例: "BTS是什么?", "什么是K-POP?", "告诉我韩流历史" + + 请以JSON格式回答: + { + "intent": "CREATE_ROUTE", + "confidence": 0.95, + "reasoning": "用户请求创建新的旅行路线" + } + """; + } } diff --git a/src/main/java/com/mey/backend/domain/chatbot/service/LanguageService.java b/src/main/java/com/mey/backend/domain/chatbot/service/LanguageService.java new file mode 100644 index 0000000..d6cb341 --- /dev/null +++ b/src/main/java/com/mey/backend/domain/chatbot/service/LanguageService.java @@ -0,0 +1,131 @@ +package com.mey.backend.domain.chatbot.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.Set; + +/** + * 다국어 지원을 위한 언어 처리 서비스 + * + * 주요 책임: + * - 지원 언어 검증 및 fallback 처리 + * - 언어별 데이터 소스 매핑 + * - 언어 설정 관리 + */ +@Slf4j +@Service +public class LanguageService { + + private static final Set SUPPORTED_LANGUAGES = Set.of("ko", "en", "ja", "zh"); + private static final String DEFAULT_FALLBACK_LANGUAGE = "en"; + + @Value("${chatbot.language.fallback:en}") + private String fallbackLanguage; + + /** + * 언어 코드를 검증하고 지원되지 않는 경우 fallback 언어를 반환합니다. + */ + public String validateAndGetLanguage(String requestedLanguage) { + if (requestedLanguage == null || requestedLanguage.trim().isEmpty()) { + return "ko"; // 기본 언어 + } + + String normalizedLanguage = requestedLanguage.toLowerCase().trim(); + + if (SUPPORTED_LANGUAGES.contains(normalizedLanguage)) { + return normalizedLanguage; + } + + log.info("지원되지 않는 언어 '{}' 요청, fallback 언어 '{}'로 처리", requestedLanguage, fallbackLanguage); + return fallbackLanguage; + } + + /** + * Place 데이터에서 해당 언어의 이름을 가져옵니다. + */ + public String getPlaceName(com.mey.backend.domain.place.entity.Place place, String language) { + return switch (language) { + case "ko" -> place.getNameKo(); + case "en" -> place.getNameEn() != null ? place.getNameEn() : place.getNameKo(); + case "ja", "zh" -> { + // 일본어/중국어는 아직 지원되지 않으므로 영어로 fallback + String englishName = place.getNameEn(); + yield englishName != null ? englishName : place.getNameKo(); + } + default -> place.getNameKo(); + }; + } + + /** + * Place 데이터에서 해당 언어의 설명을 가져옵니다. + */ + public String getPlaceDescription(com.mey.backend.domain.place.entity.Place place, String language) { + return switch (language) { + case "ko" -> place.getDescriptionKo(); + case "en" -> place.getDescriptionEn() != null ? place.getDescriptionEn() : place.getDescriptionKo(); + case "ja", "zh" -> { + // 일본어/중국어는 아직 지원되지 않으므로 영어로 fallback + String englishDesc = place.getDescriptionEn(); + yield englishDesc != null ? englishDesc : place.getDescriptionKo(); + } + default -> place.getDescriptionKo(); + }; + } + + /** + * Place 데이터에서 해당 언어의 주소를 가져옵니다. + * 현재는 한국어 주소만 지원하므로 모든 언어에 대해 한국어 주소를 반환 + */ + public String getPlaceAddress(com.mey.backend.domain.place.entity.Place place, String language) { + return place.getAddressKo(); // 현재는 한국어 주소만 지원 + } + + /** + * Route 데이터에서 해당 언어의 제목을 가져옵니다. + */ + public String getRouteTitle(com.mey.backend.domain.route.entity.Route route, String language) { + return switch (language) { + case "ko" -> route.getTitleKo(); + case "en" -> route.getTitleEn() != null ? route.getTitleEn() : route.getTitleKo(); + case "ja", "zh" -> { + // 일본어/중국어는 아직 지원되지 않으므로 영어로 fallback + String englishTitle = route.getTitleEn(); + yield englishTitle != null ? englishTitle : route.getTitleKo(); + } + default -> route.getTitleKo(); + }; + } + + /** + * Route 데이터에서 해당 언어의 설명을 가져옵니다. + */ + public String getRouteDescription(com.mey.backend.domain.route.entity.Route route, String language) { + return switch (language) { + case "ko" -> route.getDescriptionKo(); + case "en" -> route.getDescriptionEn() != null ? route.getDescriptionEn() : route.getDescriptionKo(); + case "ja", "zh" -> { + // 일본어/중국어는 아직 지원되지 않으므로 영어로 fallback + String englishDesc = route.getDescriptionEn(); + yield englishDesc != null ? englishDesc : route.getDescriptionKo(); + } + default -> route.getDescriptionKo(); + }; + } + + /** + * 지원되는 언어인지 확인합니다. + */ + public boolean isLanguageSupported(String language) { + return SUPPORTED_LANGUAGES.contains(language); + } + + /** + * 데이터베이스에서 완전히 지원되는 언어인지 확인합니다. + * (현재는 한국어와 영어만 완전 지원) + */ + public boolean isLanguageFullySupported(String language) { + return "ko".equals(language) || "en".equals(language); + } +} \ No newline at end of file diff --git a/src/main/java/com/mey/backend/domain/chatbot/service/MessageTemplateService.java b/src/main/java/com/mey/backend/domain/chatbot/service/MessageTemplateService.java new file mode 100644 index 0000000..809ddab --- /dev/null +++ b/src/main/java/com/mey/backend/domain/chatbot/service/MessageTemplateService.java @@ -0,0 +1,207 @@ +package com.mey.backend.domain.chatbot.service; + +import com.mey.backend.domain.chatbot.dto.ConversationState; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Map; + +/** + * 다국어 메시지 템플릿 관리 서비스 + * + * 주요 책임: + * - 언어별 시스템 메시지 및 사용자 응답 템플릿 관리 + * - 대화 상태별 안내 메시지 제공 + * - 에러 메시지 및 확인 메시지 다국어 지원 + */ +@Slf4j +@Service +public class MessageTemplateService { + + // 필수 정보 부족 시 안내 메시지 + private static final Map> MISSING_INFO_MESSAGES = Map.of( + "theme", Map.of( + "ko", "어떤 테마의 루트를 찾고 계신가요? (K-POP, K-드라마, K-푸드, K-패션 중 선택해주세요)", + "en", "What theme are you looking for? (Please choose from K-POP, K-Drama, K-Food, K-Fashion)", + "ja", "どのようなテーマをお探しですか?(K-POP、K-ドラマ、K-フード、K-ファッションからお選びください)", + "zh", "您在寻找什么主题?(请从K-POP、K-Drama、K-Food、K-Fashion中选择)" + ), + "region", Map.of( + "ko", "어느 지역의 루트를 원하시나요? (예: 서울, 부산)", + "en", "Which region would you like to visit? (e.g., Seoul, Busan)", + "ja", "どちらの地域をご希望ですか?(例:ソウル、釜山)", + "zh", "您希望去哪个地区?(例如:首尔、釜山)" + ), + "days", Map.of( + "ko", "몇 일 여행을 계획하고 계신가요? (예: 1일, 2일, 3일)", + "en", "How many days are you planning to travel? (e.g., 1 day, 2 days, 3 days)", + "ja", "何日間の旅行を計画していますか?(例:1日、2日、3日)", + "zh", "您计划旅行几天?(例如:1天、2天、3天)" + ) + ); + + // 대화 상태별 안내 메시지 + private static final Map> STATE_MESSAGES = Map.of( + ConversationState.AWAITING_THEME, Map.of( + "ko", "테마 선택이 필요해요. K-POP, K-드라마, K-푸드, K-패션 중 어떤 테마를 원하시나요?", + "en", "Theme selection is needed. Which theme would you like: K-POP, K-Drama, K-Food, or K-Fashion?", + "ja", "テーマの選択が必要です。K-POP、K-ドラマ、K-フード、K-ファッションのどのテーマをご希望ですか?", + "zh", "需要选择主题。您希望选择哪个主题:K-POP、K-Drama、K-Food还是K-Fashion?" + ), + ConversationState.AWAITING_REGION, Map.of( + "ko", "어느 지역을 여행하고 싶으신가요?", + "en", "Which region would you like to travel to?", + "ja", "どちらの地域を旅行したいですか?", + "zh", "您想去哪个地区旅行?" + ), + ConversationState.AWAITING_DAYS, Map.of( + "ko", "몇 일 여행을 계획하고 계신가요?", + "en", "How many days are you planning to travel?", + "ja", "何日間の旅行を計画していますか?", + "zh", "您计划旅行几天?" + ), + ConversationState.READY_FOR_ROUTE, Map.of( + "ko", "모든 정보가 준비되었습니다! 맞춤 루트를 생성해드릴게요.", + "en", "All information is ready! I'll create a customized route for you.", + "ja", "すべての情報が準備できました!カスタマイズされたルートを作成いたします。", + "zh", "所有信息都已准备就绪!我将为您创建定制路线。" + ) + ); + + // 에러 메시지 + private static final Map ERROR_MESSAGES = Map.of( + "ko", "다시 시도해주시겠어요?", + "en", "Would you like to try again?", + "ja", "もう一度お試しいただけますか?", + "zh", "您愿意再试一次吗?" + ); + + // 검색 결과 없음 메시지 + private static final Map NO_RESULTS_MESSAGES = Map.of( + "ko", "요청하신 조건에 맞는 정보를 찾을 수 없습니다. 다른 조건으로 검색해보시겠어요?", + "en", "No information found matching your criteria. Would you like to try different conditions?", + "ja", "お客様の条件に合う情報が見つかりませんでした。他の条件で検索してみませんか?", + "zh", "未找到符合您条件的信息。您想尝试其他条件吗?" + ); + + // 테마 확인 메시지 + private static final Map THEME_CONFIRMATION_TEMPLATES = Map.of( + "ko", "좋습니다! {theme} 테마를 선택하셨네요. 어느 지역의 루트를 원하시나요? (예: 서울, 부산)", + "en", "Great! You've selected the {theme} theme. Which region would you like to visit? (e.g., Seoul, Busan)", + "ja", "素晴らしい!{theme}テーマを選択されましたね。どちらの地域をご希望ですか?(例:ソウル、釜山)", + "zh", "太好了!您选择了{theme}主题。您希望去哪个地区?(例如:首尔、釜山)" + ); + + // 지역 확인 메시지 + private static final Map REGION_CONFIRMATION_TEMPLATES = Map.of( + "ko", "{region} 지역을 선택하셨네요! 몇 일 여행을 계획하고 계신가요? (예: 1일, 2일, 3일)", + "en", "You've selected {region}! How many days are you planning to travel? (e.g., 1 day, 2 days, 3 days)", + "ja", "{region}を選択されましたね!何日間の旅行を計画していますか?(例:1日、2日、3日)", + "zh", "您选择了{region}!您计划旅行几天?(例如:1天、2天、3天)" + ); + + // 인식 실패 메시지 + private static final Map> RECOGNITION_FAILURE_MESSAGES = Map.of( + "theme", Map.of( + "ko", "테마를 인식하지 못했습니다. K-POP, K-드라마, K-푸드, K-패션 중에서 선택해주세요.", + "en", "I couldn't recognize the theme. Please choose from K-POP, K-Drama, K-Food, K-Fashion.", + "ja", "テーマを認識できませんでした。K-POP、K-ドラマ、K-フード、K-ファッションからお選びください。", + "zh", "无法识别主题。请从K-POP、K-Drama、K-Food、K-Fashion中选择。" + ), + "region", Map.of( + "ko", "지역을 인식하지 못했습니다. 구체적인 지역명을 말씀해주세요. (예: 서울, 부산, 제주도)", + "en", "I couldn't recognize the region. Please provide a specific region name. (e.g., Seoul, Busan, Jeju)", + "ja", "地域を認識できませんでした。具体的な地域名を教えてください。(例:ソウル、釜山、済州島)", + "zh", "无法识别地区。请提供具体的地区名称。(例如:首尔、釜山、济州岛)" + ), + "days", Map.of( + "ko", "일수를 인식하지 못했습니다. 숫자로 말씀해주세요. (예: 1일, 2일, 3일)", + "en", "I couldn't recognize the number of days. Please provide a number. (e.g., 1 day, 2 days, 3 days)", + "ja", "日数を認識できませんでした。数字で教えてください。(例:1日、2日、3日)", + "zh", "无法识别天数。请用数字告诉我。(例如:1天、2天、3天)" + ) + ); + + // 일반 정보 응답 시작 문구 + private static final Map PLACE_INFO_HEADERS = Map.of( + "ko", "검색하신 조건에 맞는 장소들을 찾았습니다:", + "en", "I found places matching your search criteria:", + "ja", "お客様の検索条件に合う場所を見つけました:", + "zh", "我找到了符合您搜索条件的地点:" + ); + + /** + * 필수 정보 부족 메시지를 반환합니다. + */ + public String getMissingInfoMessage(String infoType, String language) { + Map messages = MISSING_INFO_MESSAGES.get(infoType); + if (messages == null) { + return MISSING_INFO_MESSAGES.get("theme").get("ko"); // 기본값 + } + + return messages.getOrDefault(language, messages.get("ko")); + } + + /** + * 대화 상태별 안내 메시지를 반환합니다. + */ + public String getStateMessage(ConversationState state, String language) { + Map messages = STATE_MESSAGES.get(state); + if (messages == null) { + return ""; + } + + return messages.getOrDefault(language, messages.get("ko")); + } + + /** + * 에러 메시지를 반환합니다. + */ + public String getErrorSuffix(String language) { + return ERROR_MESSAGES.getOrDefault(language, ERROR_MESSAGES.get("ko")); + } + + /** + * 검색 결과 없음 메시지를 반환합니다. + */ + public String getNoResultsMessage(String language) { + return NO_RESULTS_MESSAGES.getOrDefault(language, NO_RESULTS_MESSAGES.get("ko")); + } + + /** + * 테마 확인 메시지를 반환합니다. + */ + public String getThemeConfirmationMessage(String theme, String language) { + String template = THEME_CONFIRMATION_TEMPLATES.getOrDefault(language, + THEME_CONFIRMATION_TEMPLATES.get("ko")); + return template.replace("{theme}", theme); + } + + /** + * 지역 확인 메시지를 반환합니다. + */ + public String getRegionConfirmationMessage(String region, String language) { + String template = REGION_CONFIRMATION_TEMPLATES.getOrDefault(language, + REGION_CONFIRMATION_TEMPLATES.get("ko")); + return template.replace("{region}", region); + } + + /** + * 인식 실패 메시지를 반환합니다. + */ + public String getRecognitionFailureMessage(String infoType, String language) { + Map messages = RECOGNITION_FAILURE_MESSAGES.get(infoType); + if (messages == null) { + return RECOGNITION_FAILURE_MESSAGES.get("theme").get("ko"); // 기본값 + } + + return messages.getOrDefault(language, messages.get("ko")); + } + + /** + * 장소 정보 헤더 메시지를 반환합니다. + */ + public String getPlaceInfoHeader(String language) { + return PLACE_INFO_HEADERS.getOrDefault(language, PLACE_INFO_HEADERS.get("ko")); + } +} \ No newline at end of file diff --git a/src/main/java/com/mey/backend/domain/chatbot/service/RagService.java b/src/main/java/com/mey/backend/domain/chatbot/service/RagService.java index 94857dc..e92a23b 100644 --- a/src/main/java/com/mey/backend/domain/chatbot/service/RagService.java +++ b/src/main/java/com/mey/backend/domain/chatbot/service/RagService.java @@ -50,12 +50,19 @@ public List retrieve(String question, int maxResults) { * @return 출처 정보가 제거된 루트 추천 응답 */ public String generateRouteRecommendationAnswer(String question, List relevantDocs) { - log.debug("루트 추천 응답 생성 시작: '{}'", question); + return generateRouteRecommendationAnswer(question, relevantDocs, "ko"); // 기본 한국어 + } + + /** + * 언어를 고려한 한류 루트 추천 답변을 생성합니다. + */ + public String generateRouteRecommendationAnswer(String question, List relevantDocs, String language) { + log.debug("루트 추천 응답 생성 시작: '{}' (언어: {})", question, language); // 관련 문서 검색 또는 사용 if (relevantDocs.isEmpty()) { log.info("관련 정보를 찾을 수 없음: '{}'", question); - return "죄송합니다. 요청하신 조건에 맞는 루트 정보를 찾을 수 없습니다. 다른 테마나 지역을 시도해보시겠어요?"; + return getNoResultsMessage(language); } // 관련 문서의 내용을 컨텍스트로 결합 (번호 없이) @@ -65,21 +72,8 @@ public String generateRouteRecommendationAnswer(String question, List searchPlaceIds(String searchQuery, int maxResults) { * @return 모든 장소를 반영한 루트 추천 응답 */ public String generateRouteRecommendationAnswerWithPlaces(String question, java.util.List places) { - log.debug("루트 추천 응답 생성 시작 (장소 기반): '{}', 장소 수: {}", question, places.size()); + return generateRouteRecommendationAnswerWithPlaces(question, places, "ko"); // 기본 한국어 + } + + /** + * 언어를 고려한 실제 루트의 장소들 기반 루트 추천 메시지를 생성합니다. + */ + public String generateRouteRecommendationAnswerWithPlaces(String question, java.util.List places, String language) { + log.debug("루트 추천 응답 생성 시작 (장소 기반): '{}', 장소 수: {}, 언어: {}", question, places.size(), language); if (places.isEmpty()) { log.info("장소 정보 없음: '{}'", question); - return "죄송합니다. 요청하신 조건에 맞는 루트 정보를 찾을 수 없습니다. 다른 테마나 지역을 시도해보시겠어요?"; + return getNoResultsMessage(language); } - // 장소 정보를 순서대로 정렬하고 컨텍스트 생성 + // 장소 정보를 순서대로 정렬하고 언어별 컨텍스트 생성 String context = places.stream() - .map(place -> String.format(""" - 장소명: %s - 설명: %s - 주소: %s - 지역: %s - 테마: %s - 비용정보: %s - 연락처: %s - """, - place.getNameKo(), - place.getDescriptionKo(), - place.getAddressKo(), - place.getRegion().getNameKo(), - String.join(", ", place.getThemes()), - place.getCostInfo(), - place.getContactInfo() != null ? place.getContactInfo() : "정보 없음" - )) + .map(place -> createPlaceContext(place, language)) .collect(java.util.stream.Collectors.joining("\n")); log.debug("컨텍스트 크기: {} 문자, 장소 수: {}", context.length(), places.size()); - // 루트 추천에 특화된 시스템 프롬프트 생성 - String systemPromptText = String.format(""" - 당신은 친근하고 전문적인 한류 여행 가이드입니다. - 사용자의 질문에 대해 주어진 루트 정보를 바탕으로 자연스럽고 매력적인 추천을 해주세요. - - 다음 원칙을 따라주세요: - 1. 장소들은 제공된 순서대로 방문하는 것이 최적화된 루트입니다 - 2. 여행 일수에 맞게 일차별로 구성하되, 적절한 개수의 대표 장소들을 선별하여 소개해주세요 - 3. 각 장소의 특징과 매력을 간략하게 설명해주세요 - 4. 정보 출처나 참고 번호는 절대 언급하지 마세요 - 5. 자연스럽고 친근한 톤으로 작성해주세요 - 6. 2-3문단 정도의 적절한 길이로 작성해주세요 - - 루트 정보: - %s - """, context); + // 언어별 루트 추천 시스템 프롬프트 생성 + String systemPromptText = String.format(getPlaceBasedRouteRecommendationSystemPrompt(language), context); // LLM을 통한 응답 생성 try { @@ -303,8 +274,245 @@ public String generateRouteRecommendationAnswerWithPlaces(String question, java. } catch (Exception e) { log.error("AI 응답 생성 중 오류 발생", e); - return "루트 추천 정보를 생성하는 중에 오류가 발생했습니다. 다시 시도해주시겠어요?"; + return getSystemErrorMessage(language); } } + + /** + * 언어별 장소 컨텍스트를 생성합니다. + */ + private String createPlaceContext(com.mey.backend.domain.place.entity.Place place, String language) { + return switch (language) { + case "ko" -> String.format(""" + 장소명: %s + 설명: %s + 주소: %s + 지역: %s + 테마: %s + 비용정보: %s + 연락처: %s + """, + place.getNameKo(), + place.getDescriptionKo(), + place.getAddressKo(), + place.getRegion().getNameKo(), + String.join(", ", place.getThemes()), + place.getCostInfo(), + place.getContactInfo() != null ? place.getContactInfo() : "정보 없음" + ); + case "en" -> String.format(""" + Place Name: %s + Description: %s + Address: %s + Region: %s + Theme: %s + Cost Info: %s + Contact: %s + """, + place.getNameEn() != null ? place.getNameEn() : place.getNameKo(), + place.getDescriptionEn() != null ? place.getDescriptionEn() : place.getDescriptionKo(), + place.getAddressKo(), // Address is only available in Korean + place.getRegion().getNameKo(), + String.join(", ", place.getThemes()), + place.getCostInfo(), + place.getContactInfo() != null ? place.getContactInfo() : "No information" + ); + case "ja" -> String.format(""" + 場所名: %s + 説明: %s + 住所: %s + 地域: %s + テーマ: %s + 費用情報: %s + 連絡先: %s + """, + place.getNameEn() != null ? place.getNameEn() : place.getNameKo(), // Fallback to English + place.getDescriptionEn() != null ? place.getDescriptionEn() : place.getDescriptionKo(), + place.getAddressKo(), + place.getRegion().getNameKo(), + String.join(", ", place.getThemes()), + place.getCostInfo(), + place.getContactInfo() != null ? place.getContactInfo() : "情報なし" + ); + case "zh" -> String.format(""" + 地点名称: %s + 描述: %s + 地址: %s + 地区: %s + 主题: %s + 费用信息: %s + 联系方式: %s + """, + place.getNameEn() != null ? place.getNameEn() : place.getNameKo(), // Fallback to English + place.getDescriptionEn() != null ? place.getDescriptionEn() : place.getDescriptionKo(), + place.getAddressKo(), + place.getRegion().getNameKo(), + String.join(", ", place.getThemes()), + place.getCostInfo(), + place.getContactInfo() != null ? place.getContactInfo() : "无信息" + ); + default -> createPlaceContext(place, "ko"); // 기본값 + }; + } + + /** + * 언어별 "결과 없음" 메시지를 반환합니다. + */ + private String getNoResultsMessage(String language) { + return switch (language) { + case "ko" -> "죄송합니다. 요청하신 조건에 맞는 루트 정보를 찾을 수 없습니다. 다른 테마나 지역을 시도해보시겠어요?"; + case "en" -> "Sorry, I couldn't find route information matching your criteria. Would you like to try different themes or regions?"; + case "ja" -> "申し訳ございません。お客様の条件に合うルート情報が見つかりませんでした。他のテーマや地域をお試しいただけますか?"; + case "zh" -> "抱歉,找不到符合您条件的路线信息。您想尝试其他主题或地区吗?"; + default -> getNoResultsMessage("ko"); + }; + } + + /** + * 언어별 시스템 오류 메시지를 반환합니다. + */ + private String getSystemErrorMessage(String language) { + return switch (language) { + case "ko" -> "죄송합니다. 현재 추천 시스템에 문제가 있습니다. 잠시 후 다시 시도해주세요."; + case "en" -> "Sorry, there's currently an issue with the recommendation system. Please try again later."; + case "ja" -> "申し訳ございません。現在、推薦システムに問題があります。しばらくしてからもう一度お試しください。"; + case "zh" -> "抱歉,推荐系统目前有问题。请稍后再试。"; + default -> getSystemErrorMessage("ko"); + }; + } + + /** + * 언어별 루트 추천 시스템 프롬프트를 반환합니다. + */ + private String getRouteRecommendationSystemPrompt(String language) { + return switch (language) { + case "ko" -> """ + 당신은 친근하고 전문적인 한류 여행 가이드입니다. + 사용자의 질문에 대해 주어진 루트 정보를 바탕으로 자연스럽고 매력적인 추천을 해주세요. + + 다음 원칙을 따라주세요: + 1. 정보 출처나 참고 번호는 절대 언급하지 마세요 + 2. 자연스럽고 친근한 톤으로 응답하세요 + 3. 루트의 특징과 매력을 강조하세요 + 4. 구체적인 추천 이유를 포함하세요 + 5. 2-3문장으로 간결하게 작성하세요 + + 루트 정보: + %s + """; + case "en" -> """ + You are a friendly and professional Korean Wave travel guide. + Please provide natural and attractive recommendations based on the given route information in response to user questions. + + Please follow these principles: + 1. Never mention information sources or reference numbers + 2. Respond in a natural and friendly tone + 3. Emphasize the features and attractions of the route + 4. Include specific reasons for recommendations + 5. Write concisely in 2-3 sentences + + Route Information: + %s + """; + case "ja" -> """ + あなたは親しみやすく専門的な韓流旅行ガイドです。 + 与えられたルート情報に基づいて、ユーザーの質問に対して自然で魅力的な推薦をしてください。 + + 以下の原則に従ってください: + 1. 情報源や参考番号は絶対に言及しないでください + 2. 自然で親しみやすいトーンで応答してください + 3. ルートの特徴と魅力を強調してください + 4. 具体的な推薦理由を含めてください + 5. 2-3文で簡潔に書いてください + + ルート情報: + %s + """; + case "zh" -> """ + 您是一位友善且专业的韩流旅行向导。 + 请基于提供的路线信息,对用户的问题给出自然且有吸引力的推荐。 + + 请遵循以下原则: + 1. 绝不提及信息来源或参考编号 + 2. 以自然友好的语调回应 + 3. 强调路线的特色和魅力 + 4. 包含具体的推荐理由 + 5. 用2-3句话简洁地写出 + + 路线信息: + %s + """; + default -> getRouteRecommendationSystemPrompt("ko"); + }; + } + + /** + * 언어별 장소 기반 루트 추천 시스템 프롬프트를 반환합니다. + */ + private String getPlaceBasedRouteRecommendationSystemPrompt(String language) { + return switch (language) { + case "ko" -> """ + 당신은 친근하고 전문적인 한류 여행 가이드입니다. + 사용자의 질문에 대해 주어진 루트 정보를 바탕으로 자연스럽고 매력적인 추천을 해주세요. + + 다음 원칙을 따라주세요: + 1. 장소들은 제공된 순서대로 방문하는 것이 최적화된 루트입니다 + 2. 여행 일수에 맞게 일차별로 구성하되, 적절한 개수의 대표 장소들을 선별하여 소개해주세요 + 3. 각 장소의 특징과 매력을 간략하게 설명해주세요 + 4. 정보 출처나 참고 번호는 절대 언급하지 마세요 + 5. 자연스럽고 친근한 톤으로 작성해주세요 + 6. 2-3문단 정도의 적절한 길이로 작성해주세요 + + 루트 정보: + %s + """; + case "en" -> """ + You are a friendly and professional Korean Wave travel guide. + Please provide natural and attractive recommendations based on the given route information in response to user questions. + + Please follow these principles: + 1. The places should be visited in the provided order as it's an optimized route + 2. Organize by daily itinerary according to travel days, selecting appropriate representative places to introduce + 3. Briefly describe the features and attractions of each place + 4. Never mention information sources or reference numbers + 5. Write in a natural and friendly tone + 6. Write in an appropriate length of 2-3 paragraphs + + Route Information: + %s + """; + case "ja" -> """ + あなたは親しみやすく専門的な韓流旅行ガイドです。 + 与えられたルート情報に基づいて、ユーザーの質問に対して自然で魅力的な推薦をしてください。 + + 以下の原則に従ってください: + 1. 場所は提供された順序で訪問するのが最適化されたルートです + 2. 旅行日数に合わせて日別に構成し、適切な数の代表的な場所を選んで紹介してください + 3. 各場所の特徴と魅力を簡潔に説明してください + 4. 情報源や参考番号は絶対に言及しないでください + 5. 自然で親しみやすいトーンで書いてください + 6. 2-3段落程度の適切な長さで書いてください + + ルート情報: + %s + """; + case "zh" -> """ + 您是一位友善且专业的韩流旅行向导。 + 请基于提供的路线信息,对用户的问题给出自然且有吸引力的推荐。 + + 请遵循以下原则: + 1. 地点应按提供的顺序参观,这是优化的路线 + 2. 根据旅行天数按日安排,选择适当数量的代表性地点介绍 + 3. 简要描述每个地点的特色和魅力 + 4. 绝不提及信息来源或参考编号 + 5. 以自然友好的语调书写 + 6. 以2-3段的适当篇幅书写 + + 路线信息: + %s + """; + default -> getPlaceBasedRouteRecommendationSystemPrompt("ko"); + }; + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3d93c69..f329909 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,63 +1,70 @@ -spring: - application: - name: backend - - config: - import: optional:file:.env[.properties] - - profiles: - active: dev - - ai: - openai: - api-key: ${OPENAI_API_KEY} - embedding: - options: - model: text-embedding-3-small - -springdoc: - swagger-ui: - path: /swagger - -jwt: - secret: ${JWT_SECRET} - access-token-validity: 3600000 # 1시간 (밀리초) - refresh-token-validity: 604800000 # 7일 (밀리초) - -openai: - api: - key: ${OPENAI_API_KEY:dummy} - url: https://api.openai.com/v1/chat/completions - -tmap: - transit: - base-url: https://apis.openapi.sk.com/transit - app-key: ${TMAP_APP_KEY:dummy} - lang: 0 # 0=Korean - count: 1 - -tourapi: - service-key: ${TOURAPI_SERVICE_KEY} - mobile-os: "ETC" - mobile-app: "KRoute" - base: - related: http://apis.data.go.kr/B551011/TarRlteTarService1 - kor: http://apis.data.go.kr/B551011/KorService2 - eng: http://apis.data.go.kr/B551011/EngService2 - jpn: http://apis.data.go.kr/B551011/JpnService2 - chs: http://apis.data.go.kr/B551011/ChsService2 - -cloud: - aws: - s3: - bucket: mey-cd-bucket - path: - profile: profile - place: place - region: - static: ap-northeast-2 - stack: - auto: false - credentials: - access-key: ${S3_ACCESS_KEY} - secret-key: ${S3_SECRET_KEY} \ No newline at end of file +spring: + application: + name: backend + + config: + import: optional:file:.env[.properties] + + profiles: + active: dev + + ai: + openai: + api-key: ${OPENAI_API_KEY} + embedding: + options: + model: text-embedding-3-small + +springdoc: + swagger-ui: + path: /swagger + +jwt: + secret: ${JWT_SECRET} + access-token-validity: 3600000 # 1시간 (밀리초) + refresh-token-validity: 604800000 # 7일 (밀리초) + +openai: + api: + key: ${OPENAI_API_KEY:dummy} + url: https://api.openai.com/v1/chat/completions + +tmap: + transit: + base-url: https://apis.openapi.sk.com/transit + app-key: ${TMAP_APP_KEY:dummy} + lang: 0 # 0=Korean + count: 1 + +tourapi: + service-key: ${TOURAPI_SERVICE_KEY} + mobile-os: "ETC" + mobile-app: "KRoute" + base: + related: http://apis.data.go.kr/B551011/TarRlteTarService1 + kor: http://apis.data.go.kr/B551011/KorService2 + eng: http://apis.data.go.kr/B551011/EngService2 + jpn: http://apis.data.go.kr/B551011/JpnService2 + chs: http://apis.data.go.kr/B551011/ChsService2 + +cloud: + aws: + s3: + bucket: mey-cd-bucket + path: + profile: profile + place: place + region: + static: ap-northeast-2 + stack: + auto: false + credentials: + access-key: ${S3_ACCESS_KEY} + secret-key: ${S3_SECRET_KEY} + +# Chatbot 다국어 설정 +chatbot: + language: + supported: ko,en,ja,zh # 지원하는 언어 목록 + fallback: en # 지원되지 않는 언어의 fallback 언어 (일본어/중국어 → 영어) + default: ko # 기본 언어 \ No newline at end of file From b52a98d67989dd8833e5f079910fa3fe8abb28b1 Mon Sep 17 00:00:00 2001 From: Enble Date: Fri, 12 Sep 2025 18:59:08 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20NPE=20=EC=88=98=EC=A0=95(language?= =?UTF-8?q?=EA=B0=80=20null=EC=9D=B8=20=EA=B2=BD=EC=9A=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/IntentClassificationResult.java | 8 +++- .../domain/chatbot/service/ChatService.java | 4 +- .../chatbot/service/ContextExtractor.java | 3 ++ .../chatbot/service/IntentClassifier.java | 46 +++++++++++-------- .../service/MessageTemplateService.java | 5 ++ .../domain/chatbot/service/RagService.java | 17 +++++++ .../domain/place/service/PlaceService.java | 38 ++++++++++++++- 7 files changed, 96 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/mey/backend/domain/chatbot/dto/IntentClassificationResult.java b/src/main/java/com/mey/backend/domain/chatbot/dto/IntentClassificationResult.java index 28e792e..122eccb 100644 --- a/src/main/java/com/mey/backend/domain/chatbot/dto/IntentClassificationResult.java +++ b/src/main/java/com/mey/backend/domain/chatbot/dto/IntentClassificationResult.java @@ -3,14 +3,12 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @Schema(description = "LLM 기반 의도 분류 결과") @Getter @Builder -@AllArgsConstructor public class IntentClassificationResult { @Schema(description = "분류된 의도", example = "CREATE_ROUTE") @@ -32,6 +30,12 @@ public IntentClassificationResult( this.reasoning = reasoning; } + public IntentClassificationResult(UserIntent intent, double confidence, String reasoning) { + this.intent = intent; + this.confidence = confidence; + this.reasoning = reasoning; + } + public enum UserIntent { CREATE_ROUTE("CREATE_ROUTE", "새로운 루트 생성/추천 요청"), SEARCH_EXISTING_ROUTES("SEARCH_EXISTING_ROUTES", "기존에 만들어진 루트 검색 요청"), diff --git a/src/main/java/com/mey/backend/domain/chatbot/service/ChatService.java b/src/main/java/com/mey/backend/domain/chatbot/service/ChatService.java index ea94aa0..73c8776 100644 --- a/src/main/java/com/mey/backend/domain/chatbot/service/ChatService.java +++ b/src/main/java/com/mey/backend/domain/chatbot/service/ChatService.java @@ -431,7 +431,7 @@ private ChatResponse handleCreateRouteIntent(ChatRequest request) { String missingInfo = contextExtractor.checkMissingRequiredInfo(extractedContext); if (missingInfo != null) { // 상태 기반 대화 시작 - 첫 번째 누락 항목에 따라 상태 설정 - String language = extractedContext.getUserLanguage(); + String language = request.getLanguage(); ConversationState nextState; String question; @@ -446,7 +446,7 @@ private ChatResponse handleCreateRouteIntent(ChatRequest request) { question = messageTemplateService.getMissingInfoMessage("days", language); } - return responseBuilder.createQuestionResponse(missingInfo, extractedContext, nextState, question); + return responseBuilder.createQuestionResponse(question, extractedContext, nextState, question); } // 3. RAG를 통한 루트 생성 diff --git a/src/main/java/com/mey/backend/domain/chatbot/service/ContextExtractor.java b/src/main/java/com/mey/backend/domain/chatbot/service/ContextExtractor.java index c7a2233..34bcd23 100644 --- a/src/main/java/com/mey/backend/domain/chatbot/service/ContextExtractor.java +++ b/src/main/java/com/mey/backend/domain/chatbot/service/ContextExtractor.java @@ -319,6 +319,9 @@ private org.springframework.ai.chat.model.ChatResponse callOpenAi(String userInp * 언어별 시스템 프롬프트를 반환합니다. */ private String getSystemPromptByLanguage(String language) { + if (language == null) { + language = "ko"; + } return switch (language) { case "ko" -> getKoreanSystemPrompt(); case "en" -> getEnglishSystemPrompt(); diff --git a/src/main/java/com/mey/backend/domain/chatbot/service/IntentClassifier.java b/src/main/java/com/mey/backend/domain/chatbot/service/IntentClassifier.java index 677e280..d0e83f5 100644 --- a/src/main/java/com/mey/backend/domain/chatbot/service/IntentClassifier.java +++ b/src/main/java/com/mey/backend/domain/chatbot/service/IntentClassifier.java @@ -90,6 +90,9 @@ private IntentClassificationResult fallbackIntentClassification(String query, St String lowerQuery = query.toLowerCase(); // 언어별 키워드로 의도 분류 + if (language == null) { + language = "ko"; + } return switch (language) { case "ko" -> fallbackClassificationKorean(lowerQuery); case "en" -> fallbackClassificationEnglish(lowerQuery); @@ -102,98 +105,98 @@ private IntentClassificationResult fallbackIntentClassification(String query, St private IntentClassificationResult fallbackClassificationKorean(String lowerQuery) { // CREATE_ROUTE 키워드 if (containsAnyKeyword(lowerQuery, "추천", "계획", "루트 만들", "여행 계획", "일정", "만들어줘")) { - return new IntentClassificationResult("CREATE_ROUTE", 0.8, "키워드 기반 분류: 루트 생성 요청"); + return new IntentClassificationResult(IntentClassificationResult.UserIntent.CREATE_ROUTE, 0.8, "키워드 기반 분류: 루트 생성 요청"); } // SEARCH_EXISTING_ROUTES 키워드 if (containsAnyKeyword(lowerQuery, "기존", "만들어진", "루트 찾", "루트 검색", "있는 루트", "만든 루트")) { - return new IntentClassificationResult("SEARCH_EXISTING_ROUTES", 0.8, "키워드 기반 분류: 기존 루트 검색"); + return new IntentClassificationResult(IntentClassificationResult.UserIntent.SEARCH_EXISTING_ROUTES, 0.8, "키워드 기반 분류: 기존 루트 검색"); } // SEARCH_PLACES 키워드 (근처 키워드가 있으면 우선 처리) if (containsAnyKeyword(lowerQuery, "근처")) { - return new IntentClassificationResult("SEARCH_PLACES", 0.9, "키워드 기반 분류: 근처 장소 검색"); + return new IntentClassificationResult(IntentClassificationResult.UserIntent.SEARCH_PLACES, 0.9, "키워드 기반 분류: 근처 장소 검색"); } // SEARCH_PLACES 기타 키워드 if (containsAnyKeyword(lowerQuery, "장소", "명소", "어디", "위치", "곳", "찾아")) { - return new IntentClassificationResult("SEARCH_PLACES", 0.8, "키워드 기반 분류: 장소 검색"); + return new IntentClassificationResult(IntentClassificationResult.UserIntent.SEARCH_PLACES, 0.8, "키워드 기반 분류: 장소 검색"); } // 기본값: GENERAL_QUESTION - return new IntentClassificationResult("GENERAL_QUESTION", 0.7, "키워드 기반 분류: 일반 질문"); + return new IntentClassificationResult(IntentClassificationResult.UserIntent.GENERAL_QUESTION, 0.7, "키워드 기반 분류: 일반 질문"); } private IntentClassificationResult fallbackClassificationEnglish(String lowerQuery) { // CREATE_ROUTE keywords if (containsAnyKeyword(lowerQuery, "recommend", "suggest", "plan", "create route", "make itinerary", "trip planning")) { - return new IntentClassificationResult("CREATE_ROUTE", 0.8, "Keyword-based classification: Route creation request"); + return new IntentClassificationResult(IntentClassificationResult.UserIntent.CREATE_ROUTE, 0.8, "Keyword-based classification: Route creation request"); } // SEARCH_EXISTING_ROUTES keywords if (containsAnyKeyword(lowerQuery, "existing", "available routes", "find route", "search route", "show routes")) { - return new IntentClassificationResult("SEARCH_EXISTING_ROUTES", 0.8, "Keyword-based classification: Existing route search"); + return new IntentClassificationResult(IntentClassificationResult.UserIntent.SEARCH_EXISTING_ROUTES, 0.8, "Keyword-based classification: Existing route search"); } // SEARCH_PLACES keywords if (containsAnyKeyword(lowerQuery, "near", "nearby", "around")) { - return new IntentClassificationResult("SEARCH_PLACES", 0.9, "Keyword-based classification: Nearby place search"); + return new IntentClassificationResult(IntentClassificationResult.UserIntent.SEARCH_PLACES, 0.9, "Keyword-based classification: Nearby place search"); } if (containsAnyKeyword(lowerQuery, "place", "location", "where", "find", "spot", "attraction")) { - return new IntentClassificationResult("SEARCH_PLACES", 0.8, "Keyword-based classification: Place search"); + return new IntentClassificationResult(IntentClassificationResult.UserIntent.SEARCH_PLACES, 0.8, "Keyword-based classification: Place search"); } // Default: GENERAL_QUESTION - return new IntentClassificationResult("GENERAL_QUESTION", 0.7, "Keyword-based classification: General question"); + return new IntentClassificationResult(IntentClassificationResult.UserIntent.GENERAL_QUESTION, 0.7, "Keyword-based classification: General question"); } private IntentClassificationResult fallbackClassificationJapanese(String lowerQuery) { // CREATE_ROUTE keywords if (containsAnyKeyword(lowerQuery, "おすすめ", "計画", "ルート作", "旅行計画", "スケジュール", "作って")) { - return new IntentClassificationResult("CREATE_ROUTE", 0.8, "キーワードベース分類: ルート作成要求"); + return new IntentClassificationResult(IntentClassificationResult.UserIntent.CREATE_ROUTE, 0.8, "キーワードベース分類: ルート作成要求"); } // SEARCH_EXISTING_ROUTES keywords if (containsAnyKeyword(lowerQuery, "既存", "作られた", "ルート探", "ルート検索", "あるルート")) { - return new IntentClassificationResult("SEARCH_EXISTING_ROUTES", 0.8, "キーワードベース分類: 既存ルート検索"); + return new IntentClassificationResult(IntentClassificationResult.UserIntent.SEARCH_EXISTING_ROUTES, 0.8, "キーワードベース分類: 既存ルート検索"); } // SEARCH_PLACES keywords if (containsAnyKeyword(lowerQuery, "近く", "付近")) { - return new IntentClassificationResult("SEARCH_PLACES", 0.9, "キーワードベース分類: 近くの場所検索"); + return new IntentClassificationResult(IntentClassificationResult.UserIntent.SEARCH_PLACES, 0.9, "キーワードベース分類: 近くの場所検索"); } if (containsAnyKeyword(lowerQuery, "場所", "名所", "どこ", "位置", "探して", "スポット")) { - return new IntentClassificationResult("SEARCH_PLACES", 0.8, "キーワードベース分類: 場所検索"); + return new IntentClassificationResult(IntentClassificationResult.UserIntent.SEARCH_PLACES, 0.8, "キーワードベース分類: 場所検索"); } // Default: GENERAL_QUESTION - return new IntentClassificationResult("GENERAL_QUESTION", 0.7, "キーワードベース分類: 一般的な質問"); + return new IntentClassificationResult(IntentClassificationResult.UserIntent.GENERAL_QUESTION, 0.7, "キーワードベース分類: 一般的な質問"); } private IntentClassificationResult fallbackClassificationChinese(String lowerQuery) { // CREATE_ROUTE keywords if (containsAnyKeyword(lowerQuery, "推荐", "计划", "路线制", "旅行计划", "行程", "制作")) { - return new IntentClassificationResult("CREATE_ROUTE", 0.8, "基于关键词的分类: 路线创建请求"); + return new IntentClassificationResult(IntentClassificationResult.UserIntent.CREATE_ROUTE, 0.8, "基于关键词的分类: 路线创建请求"); } // SEARCH_EXISTING_ROUTES keywords if (containsAnyKeyword(lowerQuery, "现有", "已制作", "路线查找", "路线搜索", "现有路线")) { - return new IntentClassificationResult("SEARCH_EXISTING_ROUTES", 0.8, "基于关键词的分类: 现有路线搜索"); + return new IntentClassificationResult(IntentClassificationResult.UserIntent.SEARCH_EXISTING_ROUTES, 0.8, "基于关键词的分类: 现有路线搜索"); } // SEARCH_PLACES keywords if (containsAnyKeyword(lowerQuery, "附近", "周围")) { - return new IntentClassificationResult("SEARCH_PLACES", 0.9, "基于关键词的分类: 附近地点搜索"); + return new IntentClassificationResult(IntentClassificationResult.UserIntent.SEARCH_PLACES, 0.9, "基于关键词的分类: 附近地点搜索"); } if (containsAnyKeyword(lowerQuery, "地点", "景点", "哪里", "位置", "找", "地方")) { - return new IntentClassificationResult("SEARCH_PLACES", 0.8, "基于关键词的分类: 地点搜索"); + return new IntentClassificationResult(IntentClassificationResult.UserIntent.SEARCH_PLACES, 0.8, "基于关键词的分类: 地点搜索"); } // Default: GENERAL_QUESTION - return new IntentClassificationResult("GENERAL_QUESTION", 0.7, "基于关键词的分类: 一般问题"); + return new IntentClassificationResult(IntentClassificationResult.UserIntent.GENERAL_QUESTION, 0.7, "基于关键词的分类: 一般问题"); } /** @@ -228,6 +231,9 @@ private org.springframework.ai.chat.model.ChatResponse callOpenAi(String userInp * 언어별 시스템 프롬프트를 반환합니다. */ private String getSystemPromptByLanguage(String language) { + if (language == null) { + language = "ko"; + } return switch (language) { case "ko" -> getKoreanSystemPrompt(); case "en" -> getEnglishSystemPrompt(); diff --git a/src/main/java/com/mey/backend/domain/chatbot/service/MessageTemplateService.java b/src/main/java/com/mey/backend/domain/chatbot/service/MessageTemplateService.java index 809ddab..e1a3192 100644 --- a/src/main/java/com/mey/backend/domain/chatbot/service/MessageTemplateService.java +++ b/src/main/java/com/mey/backend/domain/chatbot/service/MessageTemplateService.java @@ -134,6 +134,11 @@ public class MessageTemplateService { * 필수 정보 부족 메시지를 반환합니다. */ public String getMissingInfoMessage(String infoType, String language) { + // null 체크 + if (infoType == null || language == null) { + return MISSING_INFO_MESSAGES.get("theme").get("ko"); // 기본값 + } + Map messages = MISSING_INFO_MESSAGES.get(infoType); if (messages == null) { return MISSING_INFO_MESSAGES.get("theme").get("ko"); // 기본값 diff --git a/src/main/java/com/mey/backend/domain/chatbot/service/RagService.java b/src/main/java/com/mey/backend/domain/chatbot/service/RagService.java index e92a23b..d85f393 100644 --- a/src/main/java/com/mey/backend/domain/chatbot/service/RagService.java +++ b/src/main/java/com/mey/backend/domain/chatbot/service/RagService.java @@ -282,6 +282,11 @@ public String generateRouteRecommendationAnswerWithPlaces(String question, java. * 언어별 장소 컨텍스트를 생성합니다. */ private String createPlaceContext(com.mey.backend.domain.place.entity.Place place, String language) { + // null 체크 및 기본값 설정 + if (language == null) { + language = "ko"; + } + return switch (language) { case "ko" -> String.format(""" 장소명: %s @@ -359,6 +364,9 @@ private String createPlaceContext(com.mey.backend.domain.place.entity.Place plac * 언어별 "결과 없음" 메시지를 반환합니다. */ private String getNoResultsMessage(String language) { + if (language == null) { + language = "ko"; + } return switch (language) { case "ko" -> "죄송합니다. 요청하신 조건에 맞는 루트 정보를 찾을 수 없습니다. 다른 테마나 지역을 시도해보시겠어요?"; case "en" -> "Sorry, I couldn't find route information matching your criteria. Would you like to try different themes or regions?"; @@ -372,6 +380,9 @@ private String getNoResultsMessage(String language) { * 언어별 시스템 오류 메시지를 반환합니다. */ private String getSystemErrorMessage(String language) { + if (language == null) { + language = "ko"; + } return switch (language) { case "ko" -> "죄송합니다. 현재 추천 시스템에 문제가 있습니다. 잠시 후 다시 시도해주세요."; case "en" -> "Sorry, there's currently an issue with the recommendation system. Please try again later."; @@ -385,6 +396,9 @@ private String getSystemErrorMessage(String language) { * 언어별 루트 추천 시스템 프롬프트를 반환합니다. */ private String getRouteRecommendationSystemPrompt(String language) { + if (language == null) { + language = "ko"; + } return switch (language) { case "ko" -> """ 당신은 친근하고 전문적인 한류 여행 가이드입니다. @@ -450,6 +464,9 @@ private String getRouteRecommendationSystemPrompt(String language) { * 언어별 장소 기반 루트 추천 시스템 프롬프트를 반환합니다. */ private String getPlaceBasedRouteRecommendationSystemPrompt(String language) { + if (language == null) { + language = "ko"; + } return switch (language) { case "ko" -> """ 당신은 친근하고 전문적인 한류 여행 가이드입니다. diff --git a/src/main/java/com/mey/backend/domain/place/service/PlaceService.java b/src/main/java/com/mey/backend/domain/place/service/PlaceService.java index e15b167..14d2a8d 100644 --- a/src/main/java/com/mey/backend/domain/place/service/PlaceService.java +++ b/src/main/java/com/mey/backend/domain/place/service/PlaceService.java @@ -44,7 +44,43 @@ public List getRelatedPlaces(Long placeId, String language) Place place = placeRepository.findPlaceByPlaceId(placeId); - return tourApiClient.fetchRelatedPlaces(place.getLatitude(), place.getLongitude(), language); + // chatbot 언어 코드를 Tour API 언어 코드로 변환 + String tourApiLanguage = convertToTourApiLanguage(language); + return tourApiClient.fetchRelatedPlaces(place.getLatitude(), place.getLongitude(), tourApiLanguage); + } + + /** + * 언어 코드 변환용 enum + */ + public enum LanguageCode { + KOREAN("ko", "K"), + ENGLISH("en", "E"), + JAPANESE("ja", "J"), + CHINESE("zh", "C"); + + private final String chatbotCode; + private final String tourApiCode; + + LanguageCode(String chatbotCode, String tourApiCode) { + this.chatbotCode = chatbotCode; + this.tourApiCode = tourApiCode; + } + + public static String toTourApiCode(String chatbotCode) { + for (LanguageCode lang : values()) { + if (lang.chatbotCode.equals(chatbotCode)) { + return lang.tourApiCode; + } + } + return KOREAN.tourApiCode; // 기본값 + } + } + + /** + * chatbot 언어 코드를 Tour API 언어 코드로 변환 + */ + private String convertToTourApiLanguage(String language) { + return LanguageCode.toTourApiCode(language); } @Transactional(readOnly = true) From 2f50c2f932c835fcb271c577e7ece4eba20d5cd6 Mon Sep 17 00:00:00 2001 From: Enble Date: Fri, 12 Sep 2025 19:06:23 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20GUIDE=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHATBOT_GUIDE.md | 274 +++++++++++++++++++++++++++++++---------------- 1 file changed, 179 insertions(+), 95 deletions(-) diff --git a/CHATBOT_GUIDE.md b/CHATBOT_GUIDE.md index f62b58d..5b5aac2 100644 --- a/CHATBOT_GUIDE.md +++ b/CHATBOT_GUIDE.md @@ -2,11 +2,60 @@ ## 🎯 API 구조 - **엔드포인트**: `POST /api/chat/query` -- **요청**: `ChatRequest` (query + context) +- **요청**: `ChatRequest` (query + context + language) - **응답**: `CommonResponse` +- **다국어 지원**: 한국어(ko), 영어(en), 일본어(ja), 중국어(zh) ## 📋 사용자 시나리오별 질답 흐름 +### 🌍 다국어 지원 예시 + +#### 영어 사용자 예시 +```json +// 요청 +{ + "query": "recommend me a kpop route", + "context": null, + "language": "en" +} + +// 응답 ❓ +{ + "responseType": "QUESTION", + "message": "Which region would you like to visit? (e.g., Seoul, Busan)", + "context": { + "theme": "K_POP", + "conversationState": "AWAITING_REGION", + "lastBotQuestion": "Which region would you like to visit? (e.g., Seoul, Busan)", + "sessionId": "...", + "userLanguage": "en" + } +} +``` + +#### 일본어 사용자 예시 +```json +// 요청 +{ + "query": "Kポップルートを推薦してください", + "context": null, + "language": "ja" +} + +// 응답 ❓ +{ + "responseType": "QUESTION", + "message": "どちらの地域をご希望ですか?(例:ソウル、釜山)", + "context": { + "theme": "K_POP", + "conversationState": "AWAITING_REGION", + "lastBotQuestion": "どちらの地域をご希望ですか?(例:ソウル、釜山)", + "sessionId": "...", + "userLanguage": "ja" + } +} +``` + ### 시나리오 1: 새 루트 생성 (CREATE_ROUTE) 🆕 #### 1-1. 완전한 정보 제공 시 @@ -15,7 +64,8 @@ POST /api/chat/query { "query": "2일 서울 K-POP 루트 추천해줘", - "context": null + "context": null, + "language": "ko" } // 응답 1 ✅ @@ -38,7 +88,8 @@ POST /api/chat/query // 요청 1 { "query": "루트 추천해줘", - "context": null + "context": null, + "language": "ko" } // 응답 1 ❓ @@ -51,7 +102,10 @@ POST /api/chat/query "budget": null, "preferences": null, "durationMinutes": null, - "days": null + "days": null, + "conversationState": "AWAITING_THEME", + "sessionId": "...", + "userLanguage": "ko" } } @@ -64,8 +118,12 @@ POST /api/chat/query "budget": null, "preferences": null, "durationMinutes": null, - "days": null - } + "days": null, + "conversationState": "AWAITING_THEME", + "sessionId": "...", + "userLanguage": "ko" + }, + "language": "ko" } // 응답 2 ❓ @@ -78,7 +136,10 @@ POST /api/chat/query "budget": null, "preferences": null, "durationMinutes": null, - "days": null + "days": null, + "conversationState": "AWAITING_REGION", + "sessionId": "...", + "userLanguage": "ko" } } @@ -91,8 +152,12 @@ POST /api/chat/query "budget": null, "preferences": null, "durationMinutes": null, - "days": null - } + "days": null, + "conversationState": "AWAITING_REGION", + "sessionId": "...", + "userLanguage": "ko" + }, + "language": "ko" } // 응답 3 ❓ @@ -105,7 +170,10 @@ POST /api/chat/query "budget": null, "preferences": null, "durationMinutes": null, - "days": null + "days": null, + "conversationState": "AWAITING_DAYS", + "sessionId": "...", + "userLanguage": "ko" } } @@ -118,8 +186,12 @@ POST /api/chat/query "budget": null, "preferences": null, "durationMinutes": null, - "days": null - } + "days": null, + "conversationState": "AWAITING_DAYS", + "sessionId": "...", + "userLanguage": "ko" + }, + "language": "ko" } // 응답 4 ✅ @@ -133,7 +205,10 @@ POST /api/chat/query "budget": null, "preferences": null, "durationMinutes": null, - "days": 2 + "days": 2, + "conversationState": "READY_FOR_ROUTE", + "sessionId": "...", + "userLanguage": "ko" } } ``` @@ -143,7 +218,8 @@ POST /api/chat/query // 요청 { "query": "4일 서울 K-POP 루트 만들어줘", - "context": null + "context": null, + "language": "ko" } // 응답 ⚠️ @@ -162,7 +238,8 @@ POST /api/chat/query // 요청 { "query": "기존에 만들어진 부산 드라마 루트 있어?", - "context": null + "context": null, + "language": "ko" } // 응답 ✅ @@ -190,7 +267,8 @@ POST /api/chat/query // 요청 { "query": "홍대 근처 K-POP 장소 어디 있어?", - "context": null + "context": null, + "language": "ko" } // 응답 ✅ @@ -218,7 +296,8 @@ POST /api/chat/query // 요청 { "query": "BTS가 뭐야?", - "context": null + "context": null, + "language": "ko" } // 응답 ✅ @@ -237,7 +316,8 @@ POST /api/chat/query // 요청 { "query": "루트 추천해줘", - "context": {"theme": "KPOP", "region": "서울", "days": 2} + "context": {"theme": "KPOP", "region": "서울", "days": 2}, + "language": "ko" } // 응답 ❌ @@ -252,7 +332,8 @@ POST /api/chat/query // 요청 { "query": "제주도 K-POP 장소 찾아줘", - "context": null + "context": null, + "language": "ko" } // 응답 ❌ @@ -269,7 +350,8 @@ POST /api/chat/query "query": "루트 추천", "context": { "theme": "INVALID_THEME" // 잘못된 Theme enum 값 - } + }, + "language": "ko" } // 응답 ❌ (400 Bad Request) @@ -303,11 +385,13 @@ interface ChatContext { lastBotQuestion?: string | null; sessionId?: string | null; // 🔒 사용자별 세션 보장 conversationStartTime?: number | null; + userLanguage?: "ko" | "en" | "ja" | "zh" | null; // 🌍 사용자 언어 } interface ChatRequest { query: string; context: ChatContext | null; + language: "ko" | "en" | "ja" | "zh"; // 🌍 다국어 지원 } interface ChatResponse { @@ -318,31 +402,40 @@ interface ChatResponse { } ``` -### 🔄 **Context 상태 관리 플로우 (개선된 세션 보장)** +### 🔄 **Context 상태 관리 플로우 (다국어 지원 포함)** ```javascript class ChatManager { - constructor() { + constructor(userLanguage = 'ko') { this.currentContext = null; // 현재 대화 컨텍스트 + this.userLanguage = userLanguage; // 🌍 사용자 언어 설정 } generateSessionId() { return 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); } + setLanguage(language) { + this.userLanguage = language; + // 언어 변경 시 새 세션 시작 + this.resetContext(); + } + async sendMessage(query) { // 🆕 첫 번째 메시지에서 세션 ID 생성 (없는 경우에만) if (!this.currentContext?.sessionId) { this.currentContext = { ...this.currentContext, sessionId: this.generateSessionId(), - conversationStartTime: Date.now() + conversationStartTime: Date.now(), + userLanguage: this.userLanguage // 🌍 언어 정보 포함 }; } const request = { query: query, - context: this.currentContext // ⚠️ sessionId를 포함한 전체 context 전송 + context: this.currentContext, // ⚠️ sessionId를 포함한 전체 context 전송 + language: this.userLanguage // 🌍 언어 명시적 전송 }; const response = await fetch('/api/chat/query', { @@ -369,62 +462,56 @@ class ChatManager { getConversationState() { return this.currentContext?.conversationState; } + + getUserLanguage() { + return this.userLanguage; + } } ``` -### 📝 **상태 기반 대화 사용 예시** +### 📝 **다국어 상태 기반 대화 사용 예시** ```javascript -const chatManager = new ChatManager(); +// 영어 사용자 +const chatManager = new ChatManager('en'); // 1. 첫 번째 요청 (세션 시작) -let response1 = await chatManager.sendMessage("루트 추천해줘"); +let response1 = await chatManager.sendMessage("recommend me a kpop route"); console.log(response1.result.context); // 출력: { // sessionId: "session_1693920000_abc123", -// conversationState: "AWAITING_THEME", -// lastBotQuestion: "어떤 테마의 루트를 찾고 계신가요?", +// conversationState: "AWAITING_REGION", // 테마는 이미 추출됨 +// lastBotQuestion: "Which region would you like to visit? (e.g., Seoul, Busan)", // conversationStartTime: 1693920000000, -// theme: null, region: null, days: null, ... -// } - -// 2. 두 번째 요청 (테마 제공 - 의도분류 건너뛰고 직접 처리!) -let response2 = await chatManager.sendMessage("K-POP이요"); -console.log(response2.result.context); -// 출력: { -// sessionId: "session_1693920000_abc123", // 동일 세션 유지 -// conversationState: "AWAITING_REGION", // 다음 상태로 전환 -// lastBotQuestion: "어느 지역의 루트를 원하시나요?", -// theme: "KPOP", // 테마 정보 추가 -// region: null, days: null, ... +// theme: "K_POP", region: null, days: null, +// userLanguage: "en" // } -// 3. 세 번째 요청 (지역 제공) -let response3 = await chatManager.sendMessage("서울로"); -// conversationState: "AWAITING_DAYS", theme: "KPOP", region: "서울" +// 2. 두 번째 요청 (지역 제공) +let response2 = await chatManager.sendMessage("Seoul"); +// conversationState: "AWAITING_DAYS", theme: "K_POP", region: "Seoul" -// 4. 네 번째 요청 (일수 제공) -let response4 = await chatManager.sendMessage("2일"); +// 3. 세 번째 요청 (일수 제공) +let response3 = await chatManager.sendMessage("2 days"); // responseType: "ROUTE_RECOMMENDATION" - 루트 생성 완료! - -// 🔍 세션 상태 확인 -console.log(`현재 세션 ID: ${chatManager.getCurrentSessionId()}`); -console.log(`대화 상태: ${chatManager.getConversationState()}`); ``` ### ⚠️ **주의사항** 1. **Context 누적**: 각 응답의 `context` 필드를 다음 요청의 `context`로 그대로 사용 2. **세션 보장**: `sessionId`는 백엔드에서 자동 생성/관리되므로 프론트엔드는 그대로 전달만 -3. **상태 초기화**: 새로운 대화 시작 시 `resetContext()` 호출하여 새 세션 생성 -4. **에러 처리**: API 에러 시에도 context 상태를 적절히 관리 (세션 ID 보존) -5. **타입 안정성**: TypeScript 사용 시 확장된 Context 타입 정의 준수 -6. **🔥 핵심 개선**: 이제 "k-pop" 같은 답변이 의도 분류 오류 없이 정확히 처리됨 +3. **다국어 지원**: `language` 필드를 항상 명시하고, context의 `userLanguage`와 일치시킴 +4. **언어 변경**: 언어 변경 시 새 세션 시작으로 이전 대화 컨텍스트 초기화 +5. **상태 초기화**: 새로운 대화 시작 시 `resetContext()` 호출하여 새 세션 생성 +6. **에러 처리**: API 에러 시에도 context 상태를 적절히 관리 (세션 ID 보존) +7. **타입 안정성**: TypeScript 사용 시 확장된 Context 타입 정의 준수 +8. **🔥 핵심**: 언어별 맞춤형 메시지 제공 및 fallback 전략 (ja/zh → en, 기타 → ko) --- -## 🎛️ 의도 분류 키워드 +## 🎛️ 의도 분류 키워드 (다국어) +### 한국어 (ko) | **Intent** | **키워드** | **예시** | |------------|------------|----------| | **CREATE_ROUTE** | "추천해줘", "계획해줘", "루트 만들", "여행 계획" | "2일 서울 여행 계획해줘" | @@ -432,53 +519,50 @@ console.log(`대화 상태: ${chatManager.getConversationState()}`); | **SEARCH_PLACES** | "장소", "명소", "어디", "위치", "곳" | "명동 근처 뷰티샵 어디 있어?" | | **GENERAL_QUESTION** | 기타 모든 질문 | "한류가 뭐야?", "K-POP 역사 알려줘" | +### 영어 (en) +| **Intent** | **키워드** | **예시** | +|------------|------------|----------| +| **CREATE_ROUTE** | "recommend", "suggest", "plan", "create route", "make itinerary" | "Recommend a 2-day Seoul K-POP route" | +| **SEARCH_EXISTING_ROUTES** | "existing routes", "available routes", "find route", "search route" | "Are there existing Busan drama routes?" | +| **SEARCH_PLACES** | "place", "location", "where", "spot", "find", "near", "around" | "Where are K-POP places near Hongdae?" | +| **GENERAL_QUESTION** | 기타 모든 질문 | "What is BTS?", "Tell me about K-POP history" | + +### 일본어 (ja) +| **Intent** | **키워드** | **예시** | +|------------|------------|----------| +| **CREATE_ROUTE** | "推薦", "計画", "ルート作成", "旅行プラン" | "2日間のソウルK-POPルートを推薦してください" | +| **SEARCH_EXISTING_ROUTES** | "既存ルート", "作成済みルート", "ルート検索" | "釜山のドラマルートはありますか?" | +| **SEARCH_PLACES** | "場所", "スポット", "どこ", "位置", "近く" | "弘大近くのK-POP場所はどこですか?" | +| **GENERAL_QUESTION** | 기타 모든 질문 | "BTSとは何ですか?", "K-POP歴史を教えて" | + +### 중국어 (zh) +| **Intent** | **키워드** | **예시** | +|------------|------------|----------| +| **CREATE_ROUTE** | "推荐", "计划", "路线制作", "旅行规划" | "请推荐2天首尔K-POP路线" | +| **SEARCH_EXISTING_ROUTES** | "现有路线", "已制作路线", "路线搜索" | "有釜山戏剧路线吗?" | +| **SEARCH_PLACES** | "地点", "景点", "哪里", "位置", "附近" | "弘大附近的K-POP地点在哪里?" | +| **GENERAL_QUESTION** | 기타 모든 질문 | "BTS是什么?", "告诉我K-POP历史" | + --- ## 📊 응답 타입별 데이터 구조 | **ResponseType** | **포함 필드** | **용도** | |------------------|---------------|----------| -| **QUESTION** | `message` | 추가 정보 요청 | +| **QUESTION** | `message` | 추가 정보 요청 (다국어 지원) | | **ROUTE_RECOMMENDATION** | `message`, `routeRecommendation` | 새 루트 생성 완료 | | **EXISTING_ROUTES** | `message`, `existingRoutes[]` | 기존 루트 목록 | | **PLACE_INFO** | `message`, `places[]` | 장소 정보 목록 | -| **GENERAL_INFO** | `message` | 일반 정보/답변 | - -## 🔄 주요 처리 흐름 - -1. **ChatController.sendMessage()** - - 요청 로깅 - - ChatService.processUserQuery() 호출 - - 예외 처리 (LLMException 발생) - -2. **ChatService.processUserQuery()** - - 세션 보장 및 컨텍스트 가져오기 (ConversationManager.ensureSessionAndGetContext) - - 상태 기반 대화 처리 확인 (requiresStatefulHandling) - - 상태가 있으면: handleStatefulConversation() - - 상태가 없으면: 의도 분류 후 의도별 핸들러 분기 - - 의도별 핸들러 분기 - - CREATE_ROUTE → handleCreateRouteIntent() - - SEARCH_EXISTING_ROUTES → handleSearchExistingRoutesIntent() - - SEARCH_PLACES → handleSearchPlacesIntent() - - GENERAL_QUESTION → handleGeneralQuestionIntent() - -3. **루트 생성 플로우** - - 컨텍스트 추출 (ContextExtractor.extractContextFromQuery) - - 필수 정보 확인 (ContextExtractor.checkMissingRequiredInfo) - - RAG 기반 루트 추천 (recommendRouteWithRag) - - 일수 조정 (adjustDaysIfNeeded) - **🔧 수정됨: K_POP → "K_POP" 올바른 테마 변환** - - 장소 검색 (searchAndExtractPlaceIds) - - RouteService 호출 (createRouteByAI) - - 자연스러운 메시지 생성 (RagService.generateRouteRecommendationAnswer) - - ChatResponseBuilder를 통한 일관된 응답 생성 - **🆕 추가됨** - -4. **상태 기반 대화 처리** - **🆕 추가됨** - - AWAITING_THEME → handleThemeInput() - - AWAITING_REGION → handleRegionInput() - - AWAITING_DAYS → handleDaysInput() - -5. **응답 생성 아키텍처** - **🆕 추가됨** - - 모든 응답 생성은 ChatResponseBuilder를 통해 일관성 보장 - - @JsonInclude(JsonInclude.Include.NON_NULL)로 null 필드 응답에서 제외 - - 루트 제목 개선: "서울 K-POP 2일 루트" 형태로 구체적 정보 포함 +| **GENERAL_INFO** | `message` | 일반 정보/답변 (다국어 지원) | + +--- + +## 🌍 언어별 Fallback 전략 +- **지원 언어**: ko (한국어), en (영어), ja (일본어), zh (중국어) +- **Fallback 규칙**: + - ja/zh → en (일본어/중국어는 영어로 fallback) + - 기타 모든 언어 → ko (한국어 기본값) +- **언어 코드 변환**: + - chatbot 도메인: "ja", "zh" 사용 + - Tour API 호출 시: "J", "C"로 자동 변환