Skip to content

Commit 977f6f3

Browse files
committed
feat(be): weather Tool 기본 구현 및 AI chat test code 작성
1 parent 0a0a652 commit 977f6f3

File tree

6 files changed

+223
-58
lines changed

6 files changed

+223
-58
lines changed

src/main/kotlin/com/back/koreaTravelGuide/common/config/AiConfig.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.back.koreaTravelGuide.common.config
22

3+
import com.back.koreaTravelGuide.domain.ai.aiChat.tool.WeatherTool
34
import org.springframework.ai.chat.client.ChatClient
45
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor
56
import org.springframework.ai.chat.memory.ChatMemory
@@ -34,9 +35,11 @@ class AiConfig {
3435
fun chatClient(
3536
chatModel: ChatModel,
3637
chatMemory: ChatMemory,
38+
weatherTool: WeatherTool,
3739
): ChatClient {
3840
return ChatClient.builder(chatModel)
3941
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
42+
.defaultTools(weatherTool)
4043
.build()
4144
}
4245

src/main/kotlin/com/back/koreaTravelGuide/domain/ai/aiChat/service/AiChatService.kt

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import com.back.koreaTravelGuide.domain.ai.aiChat.entity.AiChatSession
77
import com.back.koreaTravelGuide.domain.ai.aiChat.entity.SenderType
88
import com.back.koreaTravelGuide.domain.ai.aiChat.repository.AiChatMessageRepository
99
import com.back.koreaTravelGuide.domain.ai.aiChat.repository.AiChatSessionRepository
10-
import org.slf4j.LoggerFactory
1110
import org.springframework.ai.chat.client.ChatClient
1211
import org.springframework.ai.chat.memory.ChatMemory
1312
import org.springframework.stereotype.Service
@@ -18,8 +17,6 @@ class AiChatService(
1817
private val aiChatSessionRepository: AiChatSessionRepository,
1918
private val chatClient: ChatClient,
2019
) {
21-
private val logger = LoggerFactory.getLogger(AiChatService::class.java)
22-
2320
fun getSessions(userId: Long): List<AiChatSession> {
2421
return aiChatSessionRepository.findByUserIdOrderByCreatedAtDesc(userId)
2522
}
@@ -77,7 +74,6 @@ class AiChatService(
7774
.call()
7875
.content() ?: AI_ERROR_FALLBACK
7976
} catch (e: Exception) {
80-
logger.error("AI 응답 생성 실패: {}", e.message, e)
8177
AI_ERROR_FALLBACK
8278
}
8379

Lines changed: 21 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,33 @@
11
package com.back.koreaTravelGuide.domain.ai.aiChat.tool
22

3-
// TODO: AI 날씨 도구 - Spring AI @Tool 어노테이션으로 AI가 호출할 수 있는 날씨 기능
4-
import com.back.koreaTravelGuide.domain.ai.weather.dto.MidForecastDto
5-
import com.back.koreaTravelGuide.domain.ai.weather.dto.TemperatureAndLandForecastDto
6-
import com.back.koreaTravelGuide.domain.ai.weather.service.WeatherServiceCore
3+
import com.back.koreaTravelGuide.domain.ai.weather.service.WeatherService
4+
import com.back.koreaTravelGuide.domain.ai.weather.service.tools.Tools
75
import org.springframework.ai.tool.annotation.Tool
86
import org.springframework.ai.tool.annotation.ToolParam
9-
import org.springframework.stereotype.Service
10-
import java.time.ZoneId
11-
import java.time.ZonedDateTime
12-
import java.time.format.DateTimeFormatter
7+
import org.springframework.stereotype.Component
138

14-
@Service
9+
@Component
1510
class WeatherTool(
16-
private val weatherServiceCore: WeatherServiceCore,
11+
private val weatherService: WeatherService,
12+
private val tools: Tools,
1713
) {
18-
@Tool(description = "현재 한국 시간을 조회합니다.")
19-
fun getCurrentTime(): String {
20-
val now = ZonedDateTime.now(ZoneId.of("Asia/Seoul"))
21-
return "현재 한국 표준시(KST): ${now.format(DateTimeFormatter.ofPattern("yyyy년 MM월 dd일 HH시 mm분"))}"
22-
}
14+
@Tool(description = "전국 중기예보를 조회합니다")
15+
fun getWeatherForecast(): String {
16+
val actualBaseTime = tools.validBaseTime(null)
17+
val forecasts = weatherService.fetchMidForecast(actualBaseTime)
2318

24-
@Tool(description = "전국 중기전망 텍스트를 조회해 여행하기 좋은 지역 후보를 파악합니다. 먼저 호출하여 비교할 지역 코드를 추려 주세요.")
25-
fun queryMidTermNarrative(
26-
@ToolParam(description = "발표 시각 (YYYYMMDDHHMM). 미지정 시 최근 발표시각 사용.", required = false) baseTime: String?,
27-
): List<MidForecastDto>? {
28-
return weatherServiceCore.getWeatherForecast(
29-
baseTime = baseTime,
30-
)
19+
return forecasts?.toString() ?: "중기예보 데이터를 가져올 수 없습니다."
3120
}
3221

33-
@Tool(description = "중기 기온과 강수 확률 지표를 지역별로 조회합니다. 첫 번째 툴에서 제안한 지역 코드로 비교 분석에 사용하세요.")
34-
fun queryMidTermAndLandForecastMetrics(
35-
@ToolParam(description = "지역 이름 (예: 서울, 인천)", required = true) location: String?,
36-
@ToolParam(description = "중기예보 regId (예: [\"11B10101\", \"11H20301\"]).", required = true) regionCode: String?,
37-
// @ToolParam(description = "중기예보 regId 배열 (예: [\"11B10101\", \"11H20301\"]).", required = true) regionCodes: List<String>,
38-
@ToolParam(description = "발표 시각 (YYYYMMDDHHMM). 미지정 시 최근 발표시각 사용.", required = false) baseTime: String?,
39-
// @ToolParam(description = "확인할 일 수 offset 목록 (4~10). 비워 두면 4~10일 모두 반환.", required = false) days: List<Int>?,
40-
): List<TemperatureAndLandForecastDto>? {
41-
return weatherServiceCore.getTemperatureAndLandForecast(
42-
location = location,
43-
regionCode = regionCode,
44-
baseTime = baseTime,
45-
)
46-
}
22+
@Tool(description = "특정 지역의 상세 기온 및 날씨 예보를 조회합니다")
23+
fun getRegionalWeatherDetails(
24+
@ToolParam(description = "지역명 (예: 서울, 부산, 대전, 제주 등)", required = true)
25+
location: String,
26+
): String {
27+
val regionCode = tools.getRegionCodeFromLocation(location)
28+
val actualBaseTime = tools.validBaseTime(null)
29+
val forecasts = weatherService.fetchTemperatureAndLandForecast(regionCode, actualBaseTime)
4730

48-
// @Deprecated(
49-
// message = "AI 툴 분리 이후에는 queryMidTermNarrative/queryMidTermMetrics를 사용하세요.",
50-
// level = DeprecationLevel.WARNING,
51-
// )
52-
// fun getWeatherForecast(
53-
// location: String?,
54-
// regionCode: String?,
55-
// baseTime: String?,
56-
// ): WeatherResponse {
57-
// return weatherService.getWeatherForecast(
58-
// location = location,
59-
// regionCode = regionCode,
60-
// baseTime = baseTime,
61-
// )
62-
// }
31+
return forecasts?.toString() ?: "$location 지역의 상세 날씨 정보를 가져올 수 없습니다."
32+
}
6333
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
package com.back.koreaTravelGuide.domain.ai.aiChat.controller
2+
3+
import com.back.koreaTravelGuide.domain.ai.aiChat.dto.AiChatRequest
4+
import com.fasterxml.jackson.databind.ObjectMapper
5+
import io.github.cdimascio.dotenv.dotenv
6+
import org.junit.jupiter.api.BeforeAll
7+
import org.junit.jupiter.api.Test
8+
import org.junit.jupiter.api.TestInstance
9+
import org.springframework.beans.factory.annotation.Autowired
10+
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
11+
import org.springframework.boot.test.context.SpringBootTest
12+
import org.springframework.http.MediaType
13+
import org.springframework.security.test.context.support.WithMockUser
14+
import org.springframework.test.context.ActiveProfiles
15+
import org.springframework.test.web.servlet.MockMvc
16+
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
17+
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
18+
import org.springframework.transaction.annotation.Transactional
19+
20+
/**
21+
* AI 채팅 컨트롤러 간단 테스트
22+
* 응답 구조 확인 및 기본 동작 테스트
23+
*/
24+
@SpringBootTest
25+
@AutoConfigureMockMvc
26+
@ActiveProfiles("test")
27+
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
28+
@Transactional
29+
class AiChatControllerTest {
30+
companion object {
31+
@JvmStatic
32+
@BeforeAll
33+
fun loadEnv() {
34+
val dotenv = dotenv { ignoreIfMissing = true }
35+
dotenv.entries().forEach { entry ->
36+
System.setProperty(entry.key, entry.value)
37+
}
38+
}
39+
}
40+
41+
@Autowired
42+
private lateinit var mockMvc: MockMvc
43+
44+
private val objectMapper = ObjectMapper()
45+
private val userId = 1L
46+
47+
@Test
48+
@WithMockUser
49+
fun `AI 채팅 기본 동작 테스트`() {
50+
println("========================================")
51+
println("🤖 AI 채팅 기본 동작 테스트")
52+
println("========================================")
53+
54+
// 1. 세션 생성
55+
println("1️⃣ 새 채팅방 생성")
56+
val createResult =
57+
mockMvc.perform(
58+
post("/api/aichat/sessions")
59+
.param("userId", userId.toString()),
60+
).andReturn()
61+
62+
println("📦 세션 생성 응답 상태: ${createResult.response.status}")
63+
println("📦 세션 생성 응답 내용: ${createResult.response.contentAsString}")
64+
65+
if (createResult.response.status != 200) {
66+
println("❌ 세션 생성 실패 - 테스트 중단")
67+
return
68+
}
69+
70+
// JSON 파싱해서 sessionId 추출
71+
val createResponse = objectMapper.readTree(createResult.response.contentAsString)
72+
val sessionId = createResponse.get("data").get("sessionId").asLong()
73+
println("✅ 세션 생성 완료: sessionId=$sessionId")
74+
75+
// 2. 간단한 메시지 전송
76+
println("2️⃣ AI에게 간단한 질문")
77+
val chatRequest = AiChatRequest("안녕하세요!")
78+
79+
val messageResult =
80+
mockMvc.perform(
81+
post("/api/aichat/sessions/$sessionId/messages")
82+
.param("userId", userId.toString())
83+
.contentType(MediaType.APPLICATION_JSON)
84+
.content(objectMapper.writeValueAsString(chatRequest)),
85+
).andReturn()
86+
87+
println("📦 메시지 응답 상태: ${messageResult.response.status}")
88+
println("📦 메시지 응답 내용: ${messageResult.response.contentAsString}")
89+
90+
if (messageResult.response.status == 200) {
91+
println("✅ 메시지 전송 성공")
92+
} else {
93+
println("❌ 메시지 전송 실패")
94+
}
95+
96+
// 3. 세션 목록 조회
97+
println("3️⃣ 채팅방 목록 조회")
98+
val sessionsResult =
99+
mockMvc.perform(
100+
get("/api/aichat/sessions")
101+
.param("userId", userId.toString()),
102+
).andReturn()
103+
104+
println("📦 세션 목록 응답 상태: ${sessionsResult.response.status}")
105+
if (sessionsResult.response.status == 200) {
106+
println("✅ 세션 목록 조회 성공")
107+
} else {
108+
println("❌ 세션 목록 조회 실패")
109+
}
110+
111+
println("🎉 기본 동작 테스트 완료!")
112+
}
113+
114+
@Test
115+
@WithMockUser
116+
fun `날씨 질문 테스트`() {
117+
println("========================================")
118+
println("🌤️ 날씨 질문 테스트")
119+
println("========================================")
120+
121+
// 세션 생성
122+
val createResult =
123+
mockMvc.perform(
124+
post("/api/aichat/sessions")
125+
.param("userId", userId.toString()),
126+
).andReturn()
127+
128+
if (createResult.response.status != 200) {
129+
println("❌ 세션 생성 실패")
130+
return
131+
}
132+
133+
val sessionId =
134+
objectMapper.readTree(createResult.response.contentAsString)
135+
.get("data").get("sessionId").asLong()
136+
137+
// 날씨 질문
138+
val weatherQuestions =
139+
listOf(
140+
"서울 날씨 어떤가요?",
141+
"비 올까요?",
142+
)
143+
144+
weatherQuestions.forEachIndexed { index, question ->
145+
println("💬 질문 ${index + 1}: $question")
146+
147+
val chatRequest = AiChatRequest(question)
148+
val result =
149+
mockMvc.perform(
150+
post("/api/aichat/sessions/$sessionId/messages")
151+
.param("userId", userId.toString())
152+
.contentType(MediaType.APPLICATION_JSON)
153+
.content(objectMapper.writeValueAsString(chatRequest)),
154+
).andReturn()
155+
156+
if (result.response.status == 200) {
157+
println("✅ 응답 성공 (${result.response.contentLength} bytes)")
158+
} else {
159+
println("❌ 응답 실패: ${result.response.status}")
160+
}
161+
}
162+
163+
println("🎉 날씨 질문 테스트 완료!")
164+
}
165+
}

src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,9 @@ class TourApiClientTest {
2323
@Value("\${tour.api.key}")
2424
private lateinit var serviceKey: String
2525

26-
2726
@DisplayName("fetchTourInfo - 실제 관광청 API 호출 (데이터 기대)")
2827
@Test
2928
fun fetchTourInfoTest() {
30-
3129
val params =
3230
TourSearchParams(
3331
numOfRows = 1,
@@ -48,7 +46,6 @@ class TourApiClientTest {
4846
@DisplayName("fetchTourInfo - 실제 관광청 API 장애 시 빈 결과 확인")
4947
@Test
5048
fun fetchTourInfoEmptyTest() {
51-
5249
val params =
5350
TourSearchParams(
5451
numOfRows = 1,
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
spring:
2+
security:
3+
oauth2:
4+
client:
5+
registration:
6+
google:
7+
client-id: test
8+
client-secret: test
9+
scope: profile, email
10+
naver:
11+
client-id: test
12+
client-secret: test
13+
authorization-grant-type: authorization_code
14+
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
15+
scope: name, email
16+
client-name: Naver
17+
kakao:
18+
client-id: test
19+
client-secret: test
20+
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
21+
authorization-grant-type: authorization_code
22+
scope: profile_nickname, account_email
23+
client-name: Kakao
24+
provider:
25+
naver:
26+
authorization-uri: https://nid.naver.com/oauth2.0/authorize
27+
token-uri: https://nid.naver.com/oauth2.0/token
28+
user-info-uri: https://openapi.naver.com/v1/nid/me
29+
user-name-attribute: response
30+
kakao:
31+
authorization-uri: https://kauth.kakao.com/oauth/authorize
32+
token-uri: https://kauth.kakao.com/oauth/token
33+
user-info-uri: https://kapi.kakao.com/v2/user/me
34+
user-name-attribute: id

0 commit comments

Comments
 (0)