Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,28 @@ ktlint {
buildConfig {
useKotlinOutput()

val areaCodes =
file("src/main/resources/area-codes.yml")
.readText()
.substringAfter("codes:")
.lines()
.filter { it.contains(":") }
.joinToString(", ") { line ->
val parts = line.split(":")
"${parts[0].trim()}: ${parts[1].trim().replace("\"", "")}"
}

val contentTypeCodes =
file("src/main/resources/content-type-id.yml")
.readText()
.substringAfter("codes:")
.lines()
.filter { it.contains(":") }
.joinToString(", ") { line ->
val parts = line.split(":")
"${parts[0].trim()}: ${parts[1].trim().replace("\"", "")}"
}

val regionCodes =
file("src/main/resources/region-codes.yml")
.readText()
Expand All @@ -140,6 +162,8 @@ buildConfig {
.substringAfter("ai-fallback: \"")
.substringBefore("\"")

buildConfigField("String", "AREA_CODES_DESCRIPTION", "\"\"\"$areaCodes\"\"\"")
buildConfigField("String", "CONTENT_TYPE_CODES_DESCRIPTION", "\"\"\"$contentTypeCodes\"\"\"")
buildConfigField("String", "REGION_CODES_DESCRIPTION", "\"\"\"$regionCodes\"\"\"")
buildConfigField("String", "KOREA_TRAVEL_GUIDE_SYSTEM", "\"\"\"$systemPrompt\"\"\"")
buildConfigField("String", "AI_ERROR_FALLBACK", "\"\"\"$errorPrompt\"\"\"")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.back.koreaTravelGuide.domain.ai.aiChat.tool

import com.back.backend.BuildConfig
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
import org.springframework.ai.tool.annotation.Tool
import org.springframework.ai.tool.annotation.ToolParam
import org.springframework.stereotype.Component

@Component
class TourToolExample(
private val tourService: TourService,
) {
/**
* fetchTours - 지역기반 관광정보 조회
* 케이스 : 부산광역시 사하구에 있는 관광지 조회
* "areacode": "6" 부산
* "sigungucode": "10" 사하구
* "contenttypeid": "12" 관광지
*/

@Tool(description = "areaBasedList2 : 지역기반 관광정보 조회, 특정 지역의 관광 정보 조회")
fun getTourInfo(
@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)

val tourInfo = tourService.fetchTours(tourParams)

return tourInfo.toString() ?: "지역기반 관광정보 조회를 가져올 수 없습니다."
}

/**
* fetchLocationBasedTours - 위치기반 관광정보 조회
* 케이스 : 서울특별시 중구 명동 근처 100m 이내에있는 음식점 조회
* "areacode": "1" 서울
* "sigungucode": "24" 중구
* "contenttypeid": "39" 음식점
* "mapx": "126.98375",
* "mapy": "37.563446",
* "radius": "100",
*/

@Tool(description = "locationBasedList2 : 위치기반 관광정보 조회, 특정 위치 기반의 관광 정보 조회")
fun get(
@ToolParam(description = BuildConfig.CONTENT_TYPE_CODES_DESCRIPTION, required = true)
contentTypeId: String,
@ToolParam(description = BuildConfig.AREA_CODES_DESCRIPTION, required = true)
areaAndSigunguCode: String,
@ToolParam(description = "WGS84 경도", required = true)
mapX: String = "126.98375",
@ToolParam(description = "WGS84 위도", required = true)
mapY: String = "37.563446",
@ToolParam(description = "검색 반경(m)", required = true)
radius: String = "100",
): String {
// areaAndSigunguCode를 areaCode와 sigunguCode로 분리
val tourParams = tourService.parseParams(contentTypeId, areaAndSigunguCode)
val locationBasedParams = TourLocationBasedParams(mapX, mapY, radius)

val tourLocationBasedInfo = tourService.fetchLocationBasedTours(tourParams, locationBasedParams)

return tourLocationBasedInfo.toString() ?: "위치기반 관광정보 조회를 가져올 수 없습니다."
}

/**
* fetchTourDetail - 상세조회
* 케이스 : 콘텐츠ID가 “126128”인 관광정보의 “상베 정보” 조회
* "contentid": "127974",
*/

@Tool(description = "detailCommon2 : 관광정보 상세조회, 특정 관광 정보의 상세 정보 조회")
fun get(
@ToolParam(description = "Tour API Item에 각각 할당된 contentId", required = true)
contentId: String = "127974",
): String {
val tourDetailParams = TourDetailParams(contentId)

val tourDetailInfo = tourService.fetchTourDetail(tourDetailParams)

return tourDetailInfo.toString() ?: "관광정보 상세조회를 가져올 수 없습니다."
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
package com.back.koreaTravelGuide.domain.ai.tour.client

import com.back.koreaTravelGuide.common.logging.log
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourDetailItem
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourDetailParams
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourDetailResponse
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourItem
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourLocationBasedParams
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourParams
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourResponse
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourSearchParams
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import org.springframework.web.client.RestTemplate
import org.springframework.web.reactive.function.server.RequestPredicates.queryParam
import org.springframework.web.util.UriComponentsBuilder
import java.net.URI

Expand All @@ -19,19 +25,14 @@ class TourApiClient(
@Value("\${tour.api.key}") private val serviceKey: String,
@Value("\${tour.api.base-url}") private val apiUrl: String,
) {
// println 대신 SLF4J 로거 사용
private val logger = LoggerFactory.getLogger(TourApiClient::class.java)

// 요청 URL 구성
private fun buildUrl(params: TourSearchParams): URI =
private fun buildUrl(params: TourParams): URI =
UriComponentsBuilder.fromUri(URI.create(apiUrl))
.path("/areaBasedList2")
.queryParam("serviceKey", serviceKey)
.queryParam("MobileOS", "WEB")
.queryParam("MobileApp", "KoreaTravelGuide")
.queryParam("_type", "json")
.queryParam("numOfRows", params.numOfRows)
.queryParam("pageNo", params.pageNo)
.queryParam("contentTypeId", params.contentTypeId)
.queryParam("areaCode", params.areaCode)
.queryParam("sigunguCode", params.sigunguCode)
Expand All @@ -40,59 +41,84 @@ class TourApiClient(
.toUri()

// 지역 기반 관광 정보 조회 (areaBasedList2)
fun fetchTourInfo(params: TourSearchParams): TourResponse {
logger.info("지역 기반 관광 정보 조회 시작")

fun fetchTourInfo(params: TourParams): TourResponse {
val url = buildUrl(params)
logger.info("Tour API URL 생성 : $url")

/*
* runCatching: 예외를 Result로 감싸 예외를 던지지 않고 처리하는 유틸리티 함수
* getOrNull(): 성공 시 응답 문자열을, 실패 시 null 반환
* takeUnless { it.isNullOrBlank() }: 공백 응답을 걸러냄
* ?.let { parseItems(it) } ?: emptyList(): 유효한 응답은 파싱, 아니면 빈 리스트 반환
*/
val response =

val body =
runCatching { restTemplate.getForObject(url, String::class.java) }
.onFailure { logger.error("관광 정보 조회 실패", it) }
.onFailure { log.error("관광 정보 조회 실패", it) }
.getOrNull()
.takeUnless { it.isNullOrBlank() }
?.let { parseItems(it) }

return response ?: TourResponse(items = emptyList())
return body
.takeUnless { it.isNullOrBlank() }
?.let { parseItems(it) }
?: TourResponse(items = emptyList())
}

private fun parseItems(json: String): TourResponse {
val root = objectMapper.readTree(json)
// 위치기반 관광정보 조회 (locationBasedList2)
fun fetchLocationBasedTours(
tourParams: TourParams,
locationParams: TourLocationBasedParams,
): TourResponse {
val url =
UriComponentsBuilder.fromUri(URI.create(apiUrl))
.path("/locationBasedList2")
.queryParam("serviceKey", serviceKey)
.queryParam("MobileOS", "WEB")
.queryParam("MobileApp", "KoreaTravelGuide")
.queryParam("_type", "json")
.queryParam("mapX", locationParams.mapX)
.queryParam("mapY", locationParams.mapY)
.queryParam("radius", locationParams.radius)
.queryParam("contentTypeId", tourParams.contentTypeId)
.queryParam("areaCode", tourParams.areaCode)
.queryParam("sigunguCode", tourParams.sigunguCode)
.build()
.encode()
.toUri()

// header.resultCode 값 추출위한 노스 탐색 과정
val resultCode =
root
.path("response")
.path("header")
.path("resultCode")
.asText()
val body =
runCatching { restTemplate.getForObject(url, String::class.java) }
.onFailure { log.error("위치기반 관광 정보 조회 실패", it) }
.getOrNull()

// resultCode가 "0000"이 아닌 경우 체크
if (resultCode != "0000") {
logger.warn("관광 정보 API resultCode={}", resultCode)
return TourResponse(items = emptyList())
}
return body
.takeUnless { it.isNullOrBlank() }
?.let { parseItems(it) }
?: TourResponse(items = emptyList())
}

// path("키") 형태로 노드를 탐색, 응답 Json 형태의 순서에 따라 순차적으로 내려감
val itemsNode =
root
.path("response")
.path("body")
.path("items")
.path("item")
// 공통정보 조회 (detailCommon2)
fun fetchTourDetail(params: TourDetailParams): TourDetailResponse {
val url =
UriComponentsBuilder.fromUri(URI.create(apiUrl))
.path("/detailCommon2")
.queryParam("serviceKey", serviceKey)
.queryParam("MobileOS", "WEB")
.queryParam("MobileApp", "KoreaTravelGuide")
.queryParam("_type", "json")
.queryParam("contentId", params.contentId)
.build()
.encode()
.toUri()

// 탐색 결과가 비어 있는 경우
if (!itemsNode.isArray || itemsNode.isEmpty) return TourResponse(items = emptyList())
val body =
runCatching { restTemplate.getForObject(url, String::class.java) }
.onFailure { log.error("공통정보 조회 실패", it) }
.getOrNull()

return body
.takeUnless { it.isNullOrBlank() }
?.let { parseDetailItems(it) }
?: TourDetailResponse(items = emptyList())
}

private fun parseItems(json: String): TourResponse {
val itemNodes = extractItemNodes(json, "관광 정보")
if (itemNodes.isEmpty()) return TourResponse(items = emptyList())

// itemsNode가 배열이므로 map으로 각 노드를 TourItem으로 변환 후 컨테이너로 감싼다.
val items =
itemsNode.map { node ->
itemNodes.map { node ->
TourItem(
contentId = node.path("contentid").asText(),
contentTypeId = node.path("contenttypeid").asText(),
Expand All @@ -105,6 +131,7 @@ class TourApiClient(
firstimage2 = node.path("firstimage2").takeIf { it.isTextual }?.asText(),
mapX = node.path("mapx").takeIf { it.isTextual }?.asText(),
mapY = node.path("mapy").takeIf { it.isTextual }?.asText(),
distance = node.path("dist").takeIf { it.isTextual }?.asText(),
mlevel = node.path("mlevel").takeIf { it.isTextual }?.asText(),
sigunguCode = node.path("sigungucode").takeIf { it.isTextual }?.asText(),
lDongRegnCd = node.path("lDongRegnCd").takeIf { it.isTextual }?.asText(),
Expand All @@ -114,4 +141,55 @@ class TourApiClient(

return TourResponse(items = items)
}

private fun parseDetailItems(json: String): TourDetailResponse {
val itemNodes = extractItemNodes(json, "공통정보")
if (itemNodes.isEmpty()) return TourDetailResponse(items = emptyList())

val items =
itemNodes.map { node ->
TourDetailItem(
contentId = node.path("contentid").asText(),
title = node.path("title").asText(),
overview = node.path("overview").takeIf { it.isTextual }?.asText(),
addr1 = node.path("addr1").takeIf { it.isTextual }?.asText(),
mapX = node.path("mapx").takeIf { it.isTextual }?.asText(),
mapY = node.path("mapy").takeIf { it.isTextual }?.asText(),
firstImage = node.path("firstimage").takeIf { it.isTextual }?.asText(),
tel = node.path("tel").takeIf { it.isTextual }?.asText(),
homepage = node.path("homepage").takeIf { it.isTextual }?.asText(),
)
}

return TourDetailResponse(items = items)
}

private fun extractItemNodes(
json: String,
apiName: String,
): List<JsonNode> {
val root = objectMapper.readTree(json)
val resultCode =
root
.path("response")
.path("header")
.path("resultCode")
.asText()

if (resultCode != "0000") {
log.warn("{} API resultCode={}", apiName, resultCode)
return emptyList()
}

val itemsNode =
root
.path("response")
.path("body")
.path("items")
.path("item")

if (!itemsNode.isArray || itemsNode.isEmpty) return emptyList()

return itemsNode.map { it }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.back.koreaTravelGuide.domain.ai.tour.dto

/**
* 공통정보(detailCommon2) 조회 요청 파라미터.
* contentId는 필수, 페이지 관련 값은 기본값으로 1페이지/10건을 사용한다.
*/
data class TourDetailParams(
val contentId: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.back.koreaTravelGuide.domain.ai.tour.dto

data class TourDetailResponse(
val items: List<TourDetailItem>,
)

data class TourDetailItem(
val contentId: String,
val title: String,
val overview: String?,
val addr1: String?,
val mapX: String?,
val mapY: String?,
val firstImage: String?,
val tel: String?,
val homepage: String?,
)
Loading