diff --git a/src/main/kotlin/com/back/koreaTravelGuide/common/config/AiConfig.kt b/src/main/kotlin/com/back/koreaTravelGuide/common/config/AiConfig.kt index 7183ff5..d643278 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/common/config/AiConfig.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/common/config/AiConfig.kt @@ -1,5 +1,6 @@ package com.back.koreaTravelGuide.common.config +import com.back.koreaTravelGuide.domain.ai.aiChat.tool.TourTool import com.back.koreaTravelGuide.domain.ai.aiChat.tool.WeatherTool import org.springframework.ai.chat.client.ChatClient import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor @@ -36,10 +37,11 @@ class AiConfig { chatModel: ChatModel, chatMemory: ChatMemory, weatherTool: WeatherTool, + tourTool: TourTool, ): ChatClient { return ChatClient.builder(chatModel) .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build()) - .defaultTools(weatherTool) + .defaultTools(weatherTool, tourTool) .build() } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/common/config/RedisConfig.kt b/src/main/kotlin/com/back/koreaTravelGuide/common/config/RedisConfig.kt index df4e094..4885ac3 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/common/config/RedisConfig.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/common/config/RedisConfig.kt @@ -1,10 +1,19 @@ package com.back.koreaTravelGuide.common.config +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator +import com.fasterxml.jackson.module.kotlin.KotlinModule +import org.springframework.cache.CacheManager import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.cache.RedisCacheConfiguration +import org.springframework.data.redis.cache.RedisCacheManager import org.springframework.data.redis.connection.RedisConnectionFactory import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer +import org.springframework.data.redis.serializer.RedisSerializationContext import org.springframework.data.redis.serializer.StringRedisSerializer +import java.time.Duration @Configuration class RedisConfig { @@ -22,4 +31,37 @@ class RedisConfig { return template } + + @Bean + fun cacheManager(connectionFactory: RedisConnectionFactory): CacheManager { + // Kotlin data class 역직렬화 지원을 위한 ObjectMapper 생성 + val objectMapper = + ObjectMapper().apply { + // Kotlin 모듈 등록 (data class 생성자 인식) + registerModule(KotlinModule.Builder().build()) + // 타입 정보 보존을 위한 검증기 설정 + activateDefaultTyping( + BasicPolymorphicTypeValidator.builder() + .allowIfBaseType(Any::class.java) + .build(), + ObjectMapper.DefaultTyping.NON_FINAL, + ) + } + + val redisCacheConfiguration = + RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith( + RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()), + ) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer( + GenericJackson2JsonRedisSerializer(objectMapper), + ), + ) + .entryTtl(Duration.ofHours(12)) + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(redisCacheConfiguration) + .build() + } } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/tool/TourToolExample.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/tool/TourTool.kt similarity index 77% rename from src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/tool/TourToolExample.kt rename to src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/tool/TourTool.kt index 6e549c8..2e6c349 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/tool/TourToolExample.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/tool/TourTool.kt @@ -1,6 +1,7 @@ package com.back.koreaTravelGuide.domain.ai.aiChat.tool import com.back.backend.BuildConfig +import com.back.koreaTravelGuide.common.logging.log import com.back.koreaTravelGuide.domain.ai.tour.dto.TourDetailParams import com.back.koreaTravelGuide.domain.ai.tour.dto.TourLocationBasedParams import com.back.koreaTravelGuide.domain.ai.tour.service.TourService @@ -9,7 +10,7 @@ import org.springframework.ai.tool.annotation.ToolParam import org.springframework.stereotype.Component @Component -class TourToolExample( +class TourTool( private val tourService: TourService, ) { /** @@ -21,17 +22,18 @@ class TourToolExample( */ @Tool(description = "areaBasedList2 : 지역기반 관광정보 조회, 특정 지역의 관광 정보 조회") - fun getTourInfo( + fun getAreaBasedTourInfo( @ToolParam(description = BuildConfig.CONTENT_TYPE_CODES_DESCRIPTION, required = true) contentTypeId: String, @ToolParam(description = BuildConfig.AREA_CODES_DESCRIPTION, required = true) areaAndSigunguCode: String, ): String { - // areaAndSigunguCode를 areaCode와 sigunguCode로 분리 - val tourParams = tourService.parseParams(contentTypeId, areaAndSigunguCode) + log.info("🔧 [TOOL CALLED] getAreaBasedTourInfo - contentTypeId: $contentTypeId, areaAndSigunguCode: $areaAndSigunguCode") + val tourParams = tourService.parseParams(contentTypeId, areaAndSigunguCode) val tourInfo = tourService.fetchTours(tourParams) + log.info("✅ [TOOL RESULT] getAreaBasedTourInfo - 결과: ${tourInfo.toString().take(100)}...") return tourInfo.toString() ?: "지역기반 관광정보 조회를 가져올 수 없습니다." } @@ -47,7 +49,7 @@ class TourToolExample( */ @Tool(description = "locationBasedList2 : 위치기반 관광정보 조회, 특정 위치 기반의 관광 정보 조회") - fun get( + fun getLocationBasedTourInfo( @ToolParam(description = BuildConfig.CONTENT_TYPE_CODES_DESCRIPTION, required = true) contentTypeId: String, @ToolParam(description = BuildConfig.AREA_CODES_DESCRIPTION, required = true) @@ -59,30 +61,37 @@ class TourToolExample( @ToolParam(description = "검색 반경(m)", required = true) radius: String = "100", ): String { - // areaAndSigunguCode를 areaCode와 sigunguCode로 분리 + log.info( + "🔧 [TOOL CALLED] getLocationBasedTourInfo - " + + "contentTypeId: $contentTypeId, area: $areaAndSigunguCode, " + + "mapX: $mapX, mapY: $mapY, radius: $radius", + ) + val tourParams = tourService.parseParams(contentTypeId, areaAndSigunguCode) val locationBasedParams = TourLocationBasedParams(mapX, mapY, radius) - val tourLocationBasedInfo = tourService.fetchLocationBasedTours(tourParams, locationBasedParams) + log.info("✅ [TOOL RESULT] getLocationBasedTourInfo - 결과: ${tourLocationBasedInfo.toString().take(100)}...") return tourLocationBasedInfo.toString() ?: "위치기반 관광정보 조회를 가져올 수 없습니다." } /** * fetchTourDetail - 상세조회 - * 케이스 : 콘텐츠ID가 “126128”인 관광정보의 “상베 정보” 조회 + * 케이스 : 콘텐츠ID가 "126128"인 관광정보의 "상베 정보" 조회 * "contentid": "127974", */ @Tool(description = "detailCommon2 : 관광정보 상세조회, 특정 관광 정보의 상세 정보 조회") - fun get( + fun getTourDetailInfo( @ToolParam(description = "Tour API Item에 각각 할당된 contentId", required = true) contentId: String = "127974", ): String { - val tourDetailParams = TourDetailParams(contentId) + log.info("🔧 [TOOL CALLED] getTourDetailInfo - contentId: $contentId") + val tourDetailParams = TourDetailParams(contentId) val tourDetailInfo = tourService.fetchTourDetail(tourDetailParams) + log.info("✅ [TOOL RESULT] getTourDetailInfo - 결과: ${tourDetailInfo.toString().take(100)}...") return tourDetailInfo.toString() ?: "관광정보 상세조회를 가져올 수 없습니다." } } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/tool/WeatherTool.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/tool/WeatherTool.kt index ea19f12..73128d2 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/tool/WeatherTool.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/tool/WeatherTool.kt @@ -1,7 +1,9 @@ package com.back.koreaTravelGuide.domain.ai.aiChat.tool import com.back.backend.BuildConfig +import com.back.koreaTravelGuide.common.logging.log import com.back.koreaTravelGuide.domain.ai.weather.service.WeatherService +import com.fasterxml.jackson.databind.ObjectMapper import org.springframework.ai.tool.annotation.Tool import org.springframework.ai.tool.annotation.ToolParam import org.springframework.stereotype.Component @@ -9,21 +11,50 @@ import org.springframework.stereotype.Component @Component class WeatherTool( private val weatherService: WeatherService, + private val objectMapper: ObjectMapper, ) { @Tool(description = "전국 중기예보를 조회합니다") fun getWeatherForecast(): String { + log.info("🔧 [TOOL CALLED] getWeatherForecast") + val forecasts = weatherService.getWeatherForecast() + log.info("📦 [DATA] forecasts is null? ${forecasts == null}") + log.info("📦 [DATA] forecasts 타입: ${forecasts?.javaClass?.name}") + log.info("📦 [DATA] forecasts 내용: $forecasts") - return forecasts?.toString() ?: "중기예보 데이터를 가져올 수 없습니다." + return try { + val result = forecasts?.let { objectMapper.writeValueAsString(it) } ?: "중기예보 데이터를 가져올 수 없습니다." + log.info("✅ [TOOL RESULT] getWeatherForecast - 결과: $result") + result + } catch (e: Exception) { + log.error("❌ [TOOL ERROR] getWeatherForecast - 예외 발생: ${e.javaClass.name}", e) + log.error("❌ [TOOL ERROR] 예외 메시지: ${e.message}") + throw e + } } @Tool(description = "특정 지역의 상세 기온 및 날씨 예보를 조회합니다") fun getRegionalWeatherDetails( - @ToolParam(description = BuildConfig.REGION_CODES_DESCRIPTION, required = true) + @ToolParam( + description = "지역 코드를 사용하세요. 사용 가능한 지역 코드: ${BuildConfig.REGION_CODES_DESCRIPTION}", + required = true, + ) location: String, ): String { + log.info("🔧 [TOOL CALLED] getRegionalWeatherDetails - location: $location") + val forecasts = weatherService.getTemperatureAndLandForecast(location) - return forecasts?.toString() ?: "$location 지역의 상세 날씨 정보를 가져올 수 없습니다." + return try { + val result = forecasts?.let { objectMapper.writeValueAsString(it) } ?: "$location 지역의 상세 날씨 정보를 가져올 수 없습니다." + log.info("✅ [TOOL RESULT] getRegionalWeatherDetails - 결과: $result") + result + } catch (e: Exception) { + log.error("❌ [TOOL ERROR] getRegionalWeatherDetails - 예외 발생: ${e.javaClass.name}", e) + log.error("❌ [TOOL ERROR] 예외 메시지: ${e.message}") + log.error("❌ [TOOL ERROR] forecasts 타입: ${forecasts?.javaClass?.name}") + log.error("❌ [TOOL ERROR] forecasts 내용: $forecasts") + throw e + } } } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/cache/WeatherCacheConfig.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/cache/WeatherCacheConfig.kt deleted file mode 100644 index 14c8b41..0000000 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/cache/WeatherCacheConfig.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.back.koreaTravelGuide.domain.ai.weather.cache - -import org.springframework.context.annotation.Configuration - -@Configuration -class WeatherCacheConfig { - // @Bean -// fun cacheManager(connectionFactory: RedisConnectionFactory): RedisCacheManager { -// val config = RedisCacheConfiguration.defaultCacheConfig() -// .entryTtl(Duration.ofHours(12)) -// .disableCachingNullValues() -// -// return RedisCacheManager.builder(connectionFactory) -// .cacheDefaults(config) -// .build() -// } -} diff --git a/src/main/resources/prompts.yml b/src/main/resources/prompts.yml index fc747bb..ec9e0c8 100644 --- a/src/main/resources/prompts.yml +++ b/src/main/resources/prompts.yml @@ -1,9 +1,46 @@ prompts: system: korea-travel-guide: | - 당신은 한국 여행 전문 AI 가이드입니다. - 한국의 관광지, 음식, 문화에 대해 정확하고 친근한 정보를 제공하세요. - 답변은 한국어로 해주시고, 구체적인 추천과 팁을 포함해주세요. - 사용자에게 도움이 되는 실용적인 여행 정보를 제공하는 것이 목표입니다. + 당신은 한국 여행 전문 AI 챗봇입니다. + 사용자에게 날씨 기반 여행지를 추천하고, 구체적인 관광 정보를 제공하는 것이 목표입니다. + 답변은 친근하고 자연스러운 한국어로 작성하세요. + + # 기본 추천 플로우 (사용자의 별도 요청이 없는 경우) + + 1단계: 전국 날씨 조회 및 안내 + - 사용자가 여행지 추천을 요청하면 getWeatherForecast()를 사용해 전국 중기예보를 조회하세요. + - 각 지역별 날씨 정보를 요약해서 알려주세요. (예: "서울은 맑고, 부산은 구름 많음, 제주는 비 예상") + - 날씨가 좋은 지역을 강조하고, 사용자에게 "어느 지역 날씨를 더 자세히 알아볼까요?" 라고 물어보세요. + + 2단계: 지역 상세 날씨 조회 + - 사용자가 특정 지역을 선택하거나 긍정의 대답을 하면 getRegionalWeatherDetails(location)를 사용하세요. + - location 파라미터는 사용자가 언급한 지역에 해당하는 지역 코드를 사용하세요. + - 상세 날씨(기온, 강수량, 풍속 등)를 알려주세요. + + 3단계: 관광 타입 선택 + - 날씨 정보를 제공한 후, "이 지역에서 어떤 곳을 알아볼까요?" 라고 물어보세요. + - CONTENT_TYPE_CODES_DESCRIPTION에 정의된 타입들(관광지, 음식점, 숙박, 쇼핑 등)을 자연스럽게 제시하세요. + - 예: "관광지, 음식점, 숙박시설 중 어떤 정보가 필요하신가요?" + + 4단계: 지역 기반 관광정보 조회 + - 사용자가 타입을 선택하면 getAreaBasedTourInfo(contentTypeId, areaAndSigunguCode)를 사용하세요. + - contentTypeId는 CONTENT_TYPE_CODES_DESCRIPTION에서 선택된 타입의 코드를 사용하세요. + - areaAndSigunguCode는 AREA_CODES_DESCRIPTION에서 사용자가 선택한 지역의 코드를 사용하세요. + - 조회된 관광정보를 사용자에게 친근하게 추천하세요. + + 5단계: 위치 기반 조회 (선택적) + - 사용자가 특정 위치나 주소를 언급하면 getLocationBasedTourInfo()를 사용할 수 있습니다. + - 경도/위도와 반경을 지정해서 주변 관광정보를 조회하세요. + + # 독립적 Tool 사용 + 각 Tool은 사용자의 직접적인 요청이 있을 경우 독립적으로 사용할 수 있습니다. + - "서울 날씨 알려줘" → getRegionalWeatherDetails() 바로 사용 + - "부산 관광지 추천해줘" → getAreaBasedTourInfo() 바로 사용 + - "명동 근처 음식점 알려줘" → getLocationBasedTourInfo() 바로 사용 + + # 중요 원칙 + - 사용자 경험을 자연스럽게 이어가세요. 로봇처럼 딱딱하게 말하지 마세요. + - Tool 호출 결과를 그대로 보여주지 말고, 핵심 정보를 요약해서 전달하세요. + - 다음 단계로 자연스럽게 유도하되, 사용자가 원하지 않으면 강요하지 마세요. errors: ai-fallback: "죄송합니다. 일시적인 문제로 응답을 생성할 수 없습니다. 다시 시도해 주세요." \ No newline at end of file