diff --git a/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/McpNotRespondingException.kt b/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/McpNotRespondingException.kt new file mode 100644 index 0000000..278cb57 --- /dev/null +++ b/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/McpNotRespondingException.kt @@ -0,0 +1,5 @@ +package noweekend.client.mcp + +import java.lang.RuntimeException + +class McpNotRespondingException() : RuntimeException() diff --git a/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/RecommendApi.kt b/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/RecommendApi.kt index 1dd9326..3983a00 100644 --- a/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/RecommendApi.kt +++ b/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/RecommendApi.kt @@ -1,7 +1,9 @@ package noweekend.client.mcp.recommend +import noweekend.client.mcp.recommend.model.AiGenerateVacationRequest +import noweekend.client.mcp.recommend.model.AiGenerateVacationResponse +import noweekend.client.mcp.recommend.model.BridgeVacationPeriod import noweekend.client.mcp.recommend.model.SandwichRequest -import noweekend.client.mcp.recommend.model.SandwichResponse import noweekend.client.mcp.recommend.model.TagRequest import noweekend.client.mcp.recommend.model.WeatherRequest import noweekend.core.domain.tag.TagRecommendation @@ -43,5 +45,12 @@ interface RecommendApi { consumes = [MediaType.APPLICATION_JSON_VALUE], method = [RequestMethod.POST], ) - fun getSandwich(@RequestBody request: SandwichRequest): SandwichResponse + fun getSandwich(@RequestBody request: SandwichRequest): List + + @RequestMapping( + value = ["/generate-vacation"], + consumes = [MediaType.APPLICATION_JSON_VALUE], + method = [RequestMethod.POST], + ) + fun generateVacation(@RequestBody request: AiGenerateVacationRequest): AiGenerateVacationResponse } diff --git a/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/RecommendClient.kt b/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/RecommendClient.kt index 0fb85df..928b778 100644 --- a/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/RecommendClient.kt +++ b/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/RecommendClient.kt @@ -1,8 +1,11 @@ package noweekend.client.mcp.recommend import feign.FeignException +import noweekend.client.mcp.McpNotRespondingException +import noweekend.client.mcp.recommend.model.AiGenerateVacationRequest +import noweekend.client.mcp.recommend.model.AiGenerateVacationResponse +import noweekend.client.mcp.recommend.model.BridgeVacationPeriod import noweekend.client.mcp.recommend.model.SandwichRequest -import noweekend.client.mcp.recommend.model.SandwichResponse import noweekend.client.mcp.recommend.model.TagRequest import noweekend.client.mcp.recommend.model.WeatherRequest import noweekend.client.mcp.recommend.model.toRequestType @@ -79,11 +82,23 @@ class RecommendClient( ) } - fun getSandwich(request: SandwichRequest): SandwichResponse? { + fun getSandwich(request: SandwichRequest): List { return try { api.getSandwich(request) } catch (e: FeignException) { - log.warn("[getSandwich] FeignException, empty 반환. msg=${e.message}") + log.warn("[getSandwich] FeignException occurred. msg=${e.message}") + throw McpNotRespondingException() + } catch (e: Exception) { + log.error("[getSandwich] Unexpected exception occurred.", e) + throw McpNotRespondingException() + } + } + + fun generateVacation(request: AiGenerateVacationRequest): AiGenerateVacationResponse? { + return try { + api.generateVacation(request) + } catch (e: FeignException) { + log.warn("[generateVacation] FeignException, empty 반환. msg=${e.message}") return null } catch (e: Exception) { log.error("[getSandwich] 예기치 못한 예외, empty 반환.", e) diff --git a/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/model/AiGenerateVacation.kt b/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/model/AiGenerateVacation.kt new file mode 100644 index 0000000..bc3f00d --- /dev/null +++ b/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/model/AiGenerateVacation.kt @@ -0,0 +1,29 @@ +package noweekend.client.mcp.recommend.model + +import java.time.LocalDate + +data class AiGenerateVacationRequest( + val days: Int, + + val travelStyleOptionLabels: List, + val chosenTravelStyleLabel: String, + + val activityTypeOptionLabels: List, + val chosenActivityTypeLabel: String, + + val restPreferenceOptionLabels: List, + val chosenRestPreferenceLabel: String, + + val leisurePreferenceOptionLabels: List, + val chosenLeisurePreferenceLabel: String, + + val birthDate: LocalDate, + val selectedTags: List, + val unselectedTags: List, + val upcomingHolidays: List, +) + +data class AiGenerateVacationResponse( + val title: String, + val content: String, +) diff --git a/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/model/Sandwich.kt b/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/model/Sandwich.kt index 72ed532..f22e4ca 100644 --- a/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/model/Sandwich.kt +++ b/noweekend-clients/client-mcp/src/main/kotlin/noweekend/client/mcp/recommend/model/Sandwich.kt @@ -5,9 +5,22 @@ import java.time.LocalDate data class SandwichRequest( val birthDay: LocalDate, val holidays: List, + val remainingAnnualLeave: Int, + val weekends: List, +) + +data class BridgeVacationPeriod( + val startDate: LocalDate, + val endDate: LocalDate, ) data class SandwichResponse( val startDate: LocalDate, val endDate: LocalDate, + val useAnnualLeave: Int, + val totalVacationDays: Int, +) + +data class SandwichApiResponse( + val responses: List, ) diff --git a/noweekend-core/core-api/src/main/kotlin/noweekend/core/api/controller/v1/RecommendController.kt b/noweekend-core/core-api/src/main/kotlin/noweekend/core/api/controller/v1/RecommendController.kt index 990c92a..92aca9b 100644 --- a/noweekend-core/core-api/src/main/kotlin/noweekend/core/api/controller/v1/RecommendController.kt +++ b/noweekend-core/core-api/src/main/kotlin/noweekend/core/api/controller/v1/RecommendController.kt @@ -1,5 +1,6 @@ package noweekend.core.api.controller.v1 +import noweekend.client.mcp.recommend.model.SandwichApiResponse import noweekend.client.mcp.recommend.model.SandwichResponse import noweekend.core.api.controller.v1.docs.RecommendControllerDocs import noweekend.core.api.controller.v1.request.GenerateVacationRequest @@ -51,13 +52,25 @@ class RecommendController( @GetMapping("/sandwich") override fun getSandwich( @CurrentUserId userId: String, - ): ApiResponse { - val mockData = SandwichResponse( - startDate = LocalDate.now(), - endDate = LocalDate.now().plusDays(3), + ): ApiResponse { +// return ApiResponse.success(recommendService.getSandwich(userId)) + val mockResponse = SandwichApiResponse( + responses = listOf( + SandwichResponse( + startDate = LocalDate.of(2025, 8, 14), + endDate = LocalDate.of(2025, 8, 16), + useAnnualLeave = 1, + totalVacationDays = 3, + ), + SandwichResponse( + startDate = LocalDate.of(2025, 9, 11), + endDate = LocalDate.of(2025, 9, 15), + useAnnualLeave = 2, + totalVacationDays = 5, + ), + ), ) - return ApiResponse.success(mockData) -// return ApiResponse.success(recommendService.getSandwich(userId)) + return ApiResponse.success(mockResponse) } @PostMapping("/generate-vacation") diff --git a/noweekend-core/core-api/src/main/kotlin/noweekend/core/api/controller/v1/docs/RecommendControllerDocs.kt b/noweekend-core/core-api/src/main/kotlin/noweekend/core/api/controller/v1/docs/RecommendControllerDocs.kt index 9cbe898..73e8416 100644 --- a/noweekend-core/core-api/src/main/kotlin/noweekend/core/api/controller/v1/docs/RecommendControllerDocs.kt +++ b/noweekend-core/core-api/src/main/kotlin/noweekend/core/api/controller/v1/docs/RecommendControllerDocs.kt @@ -6,7 +6,7 @@ import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.media.ExampleObject import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.tags.Tag -import noweekend.client.mcp.recommend.model.SandwichResponse +import noweekend.client.mcp.recommend.model.SandwichApiResponse import noweekend.core.api.controller.v1.request.GenerateVacationRequest import noweekend.core.api.controller.v1.response.AiGenerateVacationApiResponse import noweekend.core.api.controller.v1.response.WeatherResponse @@ -249,15 +249,15 @@ interface RecommendControllerDocs { ): ApiResponse @Operation( - summary = "샌드위치 연휴 추천", + summary = "유저의 남은 연차와 올해 남은 공휴일/주말로 샌드위치 휴가(bridge vacation) 추천", description = """ - 사용자 생일 및 공휴일 정보를 기반으로 - 샌드위치 연차(연휴 시작일·종료일) 기간을 추천합니다. + 유저의 남은 연차, 올해 남은 공휴일/주말을 바탕으로, 연속으로 쉴 수 있는 휴가(샌드위치 휴가) 구간을 추천합니다. + 각 휴가 구간별 실제 사용 연차 일수와 전체 휴가 일수도 반환됩니다. """, responses = [ SwaggerApiResponse( responseCode = "200", - description = "샌드위치 연차 기간 반환 성공", + description = "샌드위치 휴가 추천 성공", content = [ Content( mediaType = "application/json", @@ -269,8 +269,20 @@ interface RecommendControllerDocs { { "result": "SUCCESS", "data": { - "startDate": "2025-07-14", - "endDate": "2025-07-16" + "responses": [ + { + "startDate": "2025-10-02", + "endDate": "2025-10-09", + "useAnnualLeave": 1, + "totalVacationDays": 8 + }, + { + "startDate": "2025-12-24", + "endDate": "2025-12-28", + "useAnnualLeave": 1, + "totalVacationDays": 5 + } + ] }, "error": null } @@ -289,66 +301,14 @@ interface RecommendControllerDocs { schema = Schema(implementation = ApiResponse::class), examples = [ ExampleObject( - name = "잘못된 요청 예시", + name = "에러 예시", value = """ { "result": "ERROR", "data": null, "error": { "code": "INVALID_PARAMETER", - "message": "잘못된 요청입니다.", - "data": {} - } -} -""", - ), - ], - ), - ], - ), - SwaggerApiResponse( - responseCode = "400", - description = "생일 정보 없음", - content = [ - Content( - mediaType = "application/json", - schema = Schema(implementation = ApiResponse::class), - examples = [ - ExampleObject( - name = "생일 정보 없음 에러 예시", - value = """ -{ - "result": "ERROR", - "data": null, - "error": { - "code": "USER_BIRTH_DAY_NOT_FOUND", - "message": "사용자가 생일을 갖고있지 않습니다. 생일 먼저 추가해주세요.", - "data": {} - } -} -""", - ), - ], - ), - ], - ), - SwaggerApiResponse( - responseCode = "504", - description = "MCP 추천 서버 무응답", - content = [ - Content( - mediaType = "application/json", - schema = Schema(implementation = ApiResponse::class), - examples = [ - ExampleObject( - name = "MCP 서버 타임아웃 에러 예시", - value = """ -{ - "result": "ERROR", - "data": null, - "error": { - "code": "MCP_SERVER_SANDWICH_ERROR", - "message": "MCP 추천 서버의 응답이 없습니다. 잠시 후 다시 시도해주세요.", + "message": "사용자 정보를 찾을 수 없습니다.", "data": {} } } @@ -362,7 +322,7 @@ interface RecommendControllerDocs { ) fun getSandwich( @Parameter(hidden = true) @CurrentUserId userId: String, - ): ApiResponse + ): ApiResponse @Operation( summary = "AI 기반 여행 일정 생성", diff --git a/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/holiday/HolidayServiceImpl.kt b/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/holiday/HolidayServiceImpl.kt index e806e9e..af3a027 100644 --- a/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/holiday/HolidayServiceImpl.kt +++ b/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/holiday/HolidayServiceImpl.kt @@ -10,33 +10,22 @@ import java.time.LocalDate @Service class HolidayServiceImpl( private val holidayClient: HolidayClient, - private val holidayRepository: HolidayRepository, private val holidayWriter: HolidayWriter, + private val holidayReader: HolidayReader, ) : HolidayService { override fun getMonthHolidays( year: Int, month: Int, ): List { - return holidayRepository.findAllByYear(year) - .asSequence() - .filter { it.month == month } - .sortedBy { it.day } + return holidayReader.findMonthHolidays(year, month) .map { HolidayResponse.from(it) } - .toList() } override fun getRemainingHolidays(): List { val today = LocalDate.now() - val year = today.year - - return holidayRepository.findAllByYear(year) - .asSequence() - .map { it to LocalDate.of(it.year, it.month, it.day) } - .filter { (_, date) -> date.isAfter(today) || date.isEqual(today) } - .sortedBy { (_, date) -> date } - .map { (entity, _) -> HolidayResponse.from(entity) } - .toList() + return holidayReader.findRemainingHolidays(today) + .map { HolidayResponse.from(it) } } override fun updateHolidays(year: Int) { @@ -45,15 +34,12 @@ class HolidayServiceImpl( } private fun syncHolidays(year: Int): List { - // 1) DB에 이미 저장된 공휴일 불러오기 - val saved = holidayRepository.findAllByYear(year) + val saved = holidayReader.findAllByYear(year) - // 2) 기존에 저장된 키 집합 생성 ((month, day, content) + dayOfWeekKor) val existing = saved .map { Triple(it.month, it.day, it.content) to it.dayOfWeekKor } .toSet() - // 3) 외부 API 호출 → Domain 객체 리스트 val fetched = (1..12).flatMap { month -> holidayClient .getHolidays(HolidayRequest(year, month)) diff --git a/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/recommend/RecommendService.kt b/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/recommend/RecommendService.kt index 32f5eac..4f1728f 100644 --- a/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/recommend/RecommendService.kt +++ b/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/recommend/RecommendService.kt @@ -1,6 +1,8 @@ package noweekend.core.domain.recommend -import noweekend.client.mcp.recommend.model.SandwichResponse +import noweekend.client.mcp.recommend.model.SandwichApiResponse +import noweekend.core.api.controller.v1.request.GenerateVacationRequest +import noweekend.core.api.controller.v1.response.AiGenerateVacationApiResponse import noweekend.core.api.controller.v1.response.WeatherResponse import noweekend.core.domain.tag.TagRecommendations @@ -8,5 +10,6 @@ interface RecommendService { fun getWeatherRecommend(userId: String): WeatherResponse fun getTagRecommend(userId: String): TagRecommendations fun getTagRecommendOnlyNew(userId: String): TagRecommendations - fun getSandwich(userId: String): SandwichResponse + fun getSandwich(userId: String): SandwichApiResponse + fun generateVacation(userId: String, request: GenerateVacationRequest): AiGenerateVacationApiResponse } diff --git a/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/recommend/RecommendServiceImpl.kt b/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/recommend/RecommendServiceImpl.kt index 3883740..adb221e 100644 --- a/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/recommend/RecommendServiceImpl.kt +++ b/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/recommend/RecommendServiceImpl.kt @@ -1,10 +1,21 @@ package noweekend.core.domain.recommend +import noweekend.client.mcp.McpNotRespondingException import noweekend.client.mcp.recommend.RecommendClient +import noweekend.client.mcp.recommend.model.AiGenerateVacationRequest +import noweekend.client.mcp.recommend.model.SandwichApiResponse import noweekend.client.mcp.recommend.model.SandwichRequest import noweekend.client.mcp.recommend.model.SandwichResponse import noweekend.client.mcp.recommend.model.WeatherRequest +import noweekend.core.api.controller.v1.request.GenerateVacationRequest +import noweekend.core.api.controller.v1.response.AiGenerateVacationApiResponse import noweekend.core.api.controller.v1.response.WeatherResponse +import noweekend.core.domain.ActivityType +import noweekend.core.domain.IconStyle +import noweekend.core.domain.LeisurePreference +import noweekend.core.domain.RestPreference +import noweekend.core.domain.TravelStyle +import noweekend.core.domain.holiday.Holiday import noweekend.core.domain.holiday.HolidayReader import noweekend.core.domain.tag.RecommendType import noweekend.core.domain.tag.TagReader @@ -20,10 +31,12 @@ import noweekend.core.domain.weather.WeatherReader import noweekend.core.domain.weather.WeatherRecommendCache import noweekend.core.domain.weather.WeatherRecommendation import noweekend.core.domain.weather.WeatherWriter +import noweekend.core.domain.weekend.WeekendReader import noweekend.core.support.error.CoreException import noweekend.core.support.error.ErrorType import org.springframework.stereotype.Service import java.time.LocalDate +import java.time.temporal.ChronoUnit import kotlin.random.Random @Service @@ -36,6 +49,7 @@ class RecommendServiceImpl( private val weatherWriter: WeatherWriter, private val tagRecommendCacheReader: TagRecommendCacheReader, private val tagRecommendCacheWriter: TagRecommendCacheWriter, + private val weekendReader: WeekendReader, ) : RecommendService { override fun getWeatherRecommend(userId: String): WeatherResponse { @@ -194,15 +208,108 @@ class RecommendServiceImpl( throw CoreException(ErrorType.MCP_SERVER_TAGS_ERROR) } - override fun getSandwich(userId: String): SandwichResponse { + override fun getSandwich(userId: String): SandwichApiResponse { val findUser = userReader.findUserById(userId) ?: throw CoreException(ErrorType.USER_NOT_FOUND_INTERNAL) val birthDate = findUser.birthDate ?: throw CoreException(ErrorType.USER_BIRTH_DAY_NOT_FOUND) - val holidays: List = holidayReader - .findAllByYear(LocalDate.now().year) - .map { it.date } + val holidays: List = holidayReader.findRemainingHolidays(LocalDate.now()) + .map { holiday: Holiday -> holiday.date } - return recommendClient.getSandwich( - SandwichRequest(birthDay = birthDate, holidays = holidays), - ) ?: throw CoreException(ErrorType.MCP_SERVER_SANDWICH_ERROR) + val weekends: List = weekendReader.getUpcomingWeekends().map { weekend -> weekend.date } + + val holidayOrWeekendSet = holidays.toSet() + weekends.toSet() + val remainingAnnualLeave = findUser.remainingAnnualLeave ?: throw CoreException(ErrorType.INVALID_LOCATION) + + try { + val bridgePeriods = recommendClient.getSandwich( + SandwichRequest(birthDay = birthDate, holidays = holidays, remainingAnnualLeave.toInt(), weekends), + ) + return SandwichApiResponse( + bridgePeriods.map { period -> + val allDates = generateDateRange(period.startDate, period.endDate) + val useAnnualLeaveDates = allDates.filter { date -> + !holidayOrWeekendSet.contains(date) && date.dayOfWeek.value in 1..5 + } + SandwichResponse( + startDate = period.startDate, + endDate = period.endDate, + useAnnualLeave = useAnnualLeaveDates.size, + totalVacationDays = allDates.size, + ) + }.toList(), + ) + } catch (_: McpNotRespondingException) { + throw CoreException(ErrorType.MCP_SERVER_INTERNAL_ERROR) + } + } + + fun generateDateRange(start: LocalDate, end: LocalDate): List { + return (0..ChronoUnit.DAYS.between(start, end)).map { start.plusDays(it) } + } + + override fun generateVacation(userId: String, request: GenerateVacationRequest): AiGenerateVacationApiResponse { + val user = userReader.findUserById(userId) ?: throw CoreException(ErrorType.USER_NOT_FOUND_INTERNAL) + val birthDate = user.birthDate ?: throw CoreException(ErrorType.USER_BIRTH_DAY_NOT_FOUND) + + val tags = tagReader.getUserTags(userId) + val selected = (tags.selectedBasicTags + tags.selectedCustomTags).map { it.content } + val unselected = (tags.unselectedBasicTags + tags.unselectedCustomTags).map { it.content } + + val today = LocalDate.now() + val endDate = today.plusDays(15) + val holidaysY = holidayReader.findAllByYear(today.year) + val upcomingH = holidaysY + .filter { it.date in today..endDate } + .map { "${it.date}(${it.dayOfWeekKor.display})" } + + val travelStyleLabels = TravelStyle.entries.map { it.korean } + val activityTypeLabels = ActivityType.entries.map { it.korean } + val restPreferenceLabels = RestPreference.entries.map { it.korean } + val leisurePrefLabels = LeisurePreference.entries.map { it.korean } + + val aiRequest = AiGenerateVacationRequest( + days = request.days, + travelStyleOptionLabels = travelStyleLabels, + chosenTravelStyleLabel = request.travelStyle.korean, + + activityTypeOptionLabels = activityTypeLabels, + chosenActivityTypeLabel = request.activityType.korean, + + restPreferenceOptionLabels = restPreferenceLabels, + chosenRestPreferenceLabel = request.restPreference.korean, + + leisurePreferenceOptionLabels = leisurePrefLabels, + chosenLeisurePreferenceLabel = request.leisurePreference.korean, + + birthDate = birthDate, + selectedTags = selected, + unselectedTags = unselected, + upcomingHolidays = upcomingH, + ) + val iconStyle = solveIcon(request) + val aiResponse = + recommendClient.generateVacation(aiRequest) ?: throw CoreException(ErrorType.MCP_SERVER_INTERNAL_ERROR) + return AiGenerateVacationApiResponse( + title = aiResponse.title, + content = aiResponse.content, + iconStyle = iconStyle, + ) + } + + private fun solveIcon(request: GenerateVacationRequest): IconStyle { + if (request.activityType == ActivityType.AT_HOME) { + return IconStyle.HOUSE + } + + if (request.activityType == ActivityType.OUTDOOR) { + if (request.travelStyle == TravelStyle.PLANNER) { + return IconStyle.PLANE + } + + if (request.travelStyle == TravelStyle.SPONTANEOUS) { + return IconStyle.TRAIN + } + } + + return IconStyle.STAR } } diff --git a/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/weekend/WeekendScheduler.kt b/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/weekend/WeekendScheduler.kt new file mode 100644 index 0000000..2c96a80 --- /dev/null +++ b/noweekend-core/core-api/src/main/kotlin/noweekend/core/domain/weekend/WeekendScheduler.kt @@ -0,0 +1,35 @@ +package noweekend.core.domain.weekend + +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import java.time.DayOfWeek +import java.time.LocalDate + +@Component +class WeekendScheduler( + private val weekendWriter: WeekendWriter, +) { + + @Scheduled(cron = "0 0 0 1 1 *") + fun generateThisYearWeekends() { + val year = LocalDate.now().year + val start = LocalDate.of(year, 1, 1) + val end = LocalDate.of(year, 12, 31) + var date = start + val weekends = mutableListOf() + + while (!date.isAfter(end)) { + if (date.dayOfWeek == DayOfWeek.SATURDAY || date.dayOfWeek == DayOfWeek.SUNDAY) { + weekends.add( + Weekend.generate( + date = date, + dayOfWeek = date.dayOfWeek, + ), + ) + } + date = date.plusDays(1) + } + + weekends.forEach { weekendWriter.register(it) } + } +} diff --git a/noweekend-core/core-api/src/main/kotlin/noweekend/core/support/error/ErrorType.kt b/noweekend-core/core-api/src/main/kotlin/noweekend/core/support/error/ErrorType.kt index b5cee9d..239bbc7 100644 --- a/noweekend-core/core-api/src/main/kotlin/noweekend/core/support/error/ErrorType.kt +++ b/noweekend-core/core-api/src/main/kotlin/noweekend/core/support/error/ErrorType.kt @@ -20,7 +20,7 @@ enum class ErrorType( USER_TAGS_ERROR(HttpStatus.BAD_REQUEST, ErrorCode.E400, "사용자의 태그가 3개 미만입니다. 초기화되지 않았습니다.", LogLevel.WARN), MCP_SERVER_TAGS_ERROR(HttpStatus.GATEWAY_TIMEOUT, ErrorCode.E504, "MCP 추천 서버에서 장애가 발생했습니다. 새로운 태그를 추천할 수 없습니다. 잠시 후 시도해주세요.", LogLevel.ERROR), MCP_SERVER_WEATHER_ERROR(HttpStatus.GATEWAY_TIMEOUT, ErrorCode.E504, "MCP 추천 서버에서 장애가 발생했습니다. 날씨를 추천할 수 없습니다. 잠시 후 시도해주세요.", LogLevel.ERROR), - MCP_SERVER_INTERNAL_ERROR(HttpStatus.GATEWAY_TIMEOUT, ErrorCode.E504, "MCP 추천 서버에서 알 수 없는 장애가 발생했습니다. 잠시 후 시도해주세요.", LogLevel.ERROR), + MCP_SERVER_INTERNAL_ERROR(HttpStatus.GATEWAY_TIMEOUT, ErrorCode.E504, "MCP 추천 서버가 추천할 수 있는 상황이 아닙니다. 잠시 후 시도해주세요.", LogLevel.ERROR), USER_BIRTH_DAY_NOT_FOUND(HttpStatus.BAD_REQUEST, ErrorCode.E400, "사용자가 생일을 갖고있지 않습니다. 생일 먼저 추가해주세요.", LogLevel.WARN), MCP_SERVER_SANDWICH_ERROR(HttpStatus.GATEWAY_TIMEOUT, ErrorCode.E504, "MCP 추천 서버의 응답이 없습니다. 잠시 후 다시 시도해주세요.", LogLevel.ERROR), INVALID_LOCATION(HttpStatus.BAD_REQUEST, ErrorCode.E400, "사용자가 한국 위치가 아니기 때문에 날씨를 추천할 수 없습니다.", LogLevel.WARN), diff --git a/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/holiday/HolidayReader.kt b/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/holiday/HolidayReader.kt index f543b2b..36debf8 100644 --- a/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/holiday/HolidayReader.kt +++ b/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/holiday/HolidayReader.kt @@ -2,6 +2,7 @@ package noweekend.core.domain.holiday import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate @Component @Transactional(readOnly = true) @@ -12,4 +13,20 @@ class HolidayReader( fun findAllByYear(year: Int): List { return holidayRepository.findAllByYear(year) } + + fun findMonthHolidays(year: Int, month: Int): List { + return holidayRepository.findAllByYear(year) + .filter { it.month == month } + .sortedBy { it.day } + } + + fun findRemainingHolidays(from: LocalDate): List { + val year = from.year + return holidayRepository.findAllByYear(year) + .filter { + val date = LocalDate.of(it.year, it.month, it.day) + date.isAfter(from) || date.isEqual(from) + } + .sortedWith(compareBy({ it.month }, { it.day })) + } } diff --git a/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/weekend/Weekend.kt b/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/weekend/Weekend.kt new file mode 100644 index 0000000..45a6580 --- /dev/null +++ b/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/weekend/Weekend.kt @@ -0,0 +1,21 @@ +package noweekend.core.domain.weekend + +import noweekend.core.domain.util.IdGenerator +import java.time.DayOfWeek +import java.time.LocalDate + +data class Weekend( + val id: String, + val date: LocalDate, + val dayOfWeek: DayOfWeek, +) { + companion object { + fun generate(date: LocalDate, dayOfWeek: DayOfWeek): Weekend { + return Weekend( + id = IdGenerator.generate(), + date = date, + dayOfWeek = dayOfWeek, + ) + } + } +} diff --git a/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/weekend/WeekendReader.kt b/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/weekend/WeekendReader.kt new file mode 100644 index 0000000..6b596f2 --- /dev/null +++ b/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/weekend/WeekendReader.kt @@ -0,0 +1,22 @@ +package noweekend.core.domain.weekend + +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate + +@Component +@Transactional(readOnly = true) +class WeekendReader( + private val weekendRepository: WeekendRepository, +) { + + fun getAllThisYearWeekends(): List { + val year = LocalDate.now().year + return weekendRepository.findByYear(year) + } + + fun getUpcomingWeekends(): List { + val now = LocalDate.now() + return weekendRepository.findAfterDate(now) + } +} diff --git a/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/weekend/WeekendRepository.kt b/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/weekend/WeekendRepository.kt new file mode 100644 index 0000000..3535c9f --- /dev/null +++ b/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/weekend/WeekendRepository.kt @@ -0,0 +1,9 @@ +package noweekend.core.domain.weekend + +import java.time.LocalDate + +interface WeekendRepository { + fun register(weekend: Weekend) + fun findByYear(year: Int): List + fun findAfterDate(date: LocalDate): List +} diff --git a/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/weekend/WeekendWriter.kt b/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/weekend/WeekendWriter.kt new file mode 100644 index 0000000..c2a3acc --- /dev/null +++ b/noweekend-core/core-domain/src/main/kotlin/noweekend/core/domain/weekend/WeekendWriter.kt @@ -0,0 +1,15 @@ +package noweekend.core.domain.weekend + +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +@Transactional +class WeekendWriter( + private val weekendRepository: WeekendRepository, +) { + + fun register(weekend: Weekend) { + weekendRepository.register(weekend) + } +} diff --git a/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/config/LangGraphConfig.kt b/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/config/LangGraphConfig.kt deleted file mode 100644 index 99a9169..0000000 --- a/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/config/LangGraphConfig.kt +++ /dev/null @@ -1,75 +0,0 @@ -package noweekend.mcphost.config - -import org.bsc.langgraph4j.StateGraph -import org.bsc.langgraph4j.action.AsyncNodeAction -import org.slf4j.LoggerFactory -import org.springframework.ai.chat.client.ChatClient -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import java.time.LocalDateTime -import java.time.ZoneId - -@Configuration -class LangGraphConfig( - private val chatClient: ChatClient, -) { - @Bean - fun myGraph() = - StateGraph(MyAgentState.SCHEMA, ::MyAgentState) - .addEdge(StateGraph.START, "dispatch") - .addNode( - "dispatch", - AsyncNodeAction.node_async { st -> - logger.info("[LangGraph] dispatch 노드 진입, question=${st.question()}") - val today: LocalDateTime = LocalDateTime.now(ZoneId.of("Asia/Seoul")) - val systemPrompt1 = """ - You are an AI assistant. Always communicate in polite Korean honorifics. - - A predeclared variable is available: - """.trimIndent() - - val systemPrompt2 = """ - Use this variable to convert Korean relative date expressions to ISO date strings (YYYY-MM-DD): - • “오늘” → today.toLocalDate().toString() - • “내일” → today.plusDays(1).toLocalDate().toString() - • “모레” → today.plusDays(2).toLocalDate().toString() - • “어제” → today.minusDays(1).toLocalDate().toString() - - Tools (use exact calls): - • getFutureWeather("YYYY-MM-DD Seoul"): returns a JSON weather forecast for the specified date and “Seoul” - • search("…"): returns text search results - - Workflow: - 1. Internally translate the Korean user query into English. - 2. If the query contains any relative date expressions, immediately output only: - getCurrentDate() - 3. After receiving the concrete date, rewrite the English query by replacing all relative dates with their ISO equivalents. - 4. For each date+“Seoul” pair, output only: - getFutureWeather("YYYY-MM-DD Seoul") - 5. Once you receive the JSON forecast, compose your answer in English. - 6. Translate that answer back into polite Korean. - 7. Return only the final Korean text—no code, no markdown. - """.trimIndent() - - val reply = try { - chatClient.prompt() - .system("$systemPrompt1$today$systemPrompt2") - .user(st.question()) - .call() - .content() - .orEmpty() - } catch (e: Exception) { - logger.error("Failed to get chat response", e) - "죄송합니다. 요청을 처리하는 중 오류가 발생했습니다." - } - - mapOf("chat_response" to reply) - }, - ) - .addEdge("dispatch", StateGraph.END) - .compile() - - companion object { - private val logger = LoggerFactory.getLogger(LangGraphConfig::class.java) - } -} diff --git a/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/config/MyAgentState.kt b/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/config/MyAgentState.kt deleted file mode 100644 index 81ac847..0000000 --- a/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/config/MyAgentState.kt +++ /dev/null @@ -1,27 +0,0 @@ -package noweekend.mcphost.config - -import org.bsc.langgraph4j.state.AgentState -import org.bsc.langgraph4j.state.Channel -import org.bsc.langgraph4j.state.Channels -import java.util.function.Supplier - -class MyAgentState(initData: Map) : AgentState(initData) { - - fun question() = value("question") - .orElseThrow { IllegalStateException("question missing") } - - fun chatResponse() = value("chat_response") - .orElse("") - - fun weatherJson() = value("weather_json") - .orElse("") - - companion object { - @JvmStatic - val SCHEMA: Map> = mapOf( - "question" to Channels.base(Supplier { "" }), - "chat_response" to Channels.base(Supplier { "" }), - "weather_json" to Channels.base(Supplier { "" }), - ) - } -} diff --git a/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/config/RestTemplateConfig.kt b/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/config/RestTemplateConfig.kt new file mode 100644 index 0000000..8394657 --- /dev/null +++ b/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/config/RestTemplateConfig.kt @@ -0,0 +1,27 @@ +package noweekend.mcphost.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.MediaType +import org.springframework.http.converter.HttpMessageConverter +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter +import org.springframework.web.client.RestTemplate + +@Configuration +class RestTemplateConfig { + @Bean + fun restTemplate(): RestTemplate { + val converter = MappingJackson2HttpMessageConverter().apply { + supportedMediaTypes = listOf( + MediaType.APPLICATION_JSON, + MediaType.TEXT_XML, + MediaType.APPLICATION_XML, + MediaType.TEXT_PLAIN, + MediaType.ALL, + ) + } + return RestTemplate().apply { + messageConverters = listOf>(converter) + } + } +} diff --git a/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/ChatbotController.kt b/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/ChatbotController.kt index cc1aa77..708958b 100644 --- a/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/ChatbotController.kt +++ b/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/ChatbotController.kt @@ -1,11 +1,14 @@ package noweekend.mcphost.controller +import noweekend.mcphost.controller.request.AiGenerateVacationRequest +import noweekend.mcphost.controller.request.AiGenerateVacationResponse +import noweekend.mcphost.controller.request.SandwichRequest import noweekend.mcphost.controller.request.Tag import noweekend.mcphost.controller.request.TagRequest import noweekend.mcphost.controller.request.WeatherRequest +import noweekend.mcphost.controller.response.BridgeVacationPeriod import noweekend.mcphost.controller.response.WeatherResponse import noweekend.mcphost.service.ChatbotService -import org.slf4j.LoggerFactory import org.springframework.http.MediaType import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody @@ -15,8 +18,6 @@ import org.springframework.web.bind.annotation.RestController class ChatbotController( private val chatbotService: ChatbotService, ) { - private val logger = LoggerFactory.getLogger(ChatbotController::class.java) - @PostMapping( "/getFutureWeather", produces = [MediaType.APPLICATION_JSON_VALUE], @@ -34,4 +35,14 @@ class ChatbotController( fun getTagOnlyNew(@RequestBody request: TagRequest): List { return chatbotService.tagRecommendationOnlyNew(request) } + + @PostMapping("/getSandwich") + fun getSandwich(@RequestBody request: SandwichRequest): List { + return chatbotService.getSandwich(request) + } + + @PostMapping("/generate-vacation") + fun getTagOnlyNew(@RequestBody request: AiGenerateVacationRequest): AiGenerateVacationResponse { + return chatbotService.generateVacation(request) + } } diff --git a/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/service/Prompt.kt b/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/Prompt.kt similarity index 56% rename from noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/service/Prompt.kt rename to noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/Prompt.kt index aea01bb..8581345 100644 --- a/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/service/Prompt.kt +++ b/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/Prompt.kt @@ -1,8 +1,17 @@ -package noweekend.mcphost.service - -class Prompt { - companion object { - val WEATHER_PROMPT = """ +package noweekend.mcphost.controller + +import com.fasterxml.jackson.databind.ObjectMapper +import noweekend.mcphost.controller.request.AiGenerateVacationRequest +import org.springframework.stereotype.Component +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale + +@Component +class Prompt( + private val objectMapper: ObjectMapper, +) { + val weatherPrompt = """ You MUST call the TOOL to get the weather data. DO NOT generate, guess, or hallucinate weather data yourself. ALWAYS use the TOOL OUTPUT ONLY to create your answer. @@ -47,9 +56,9 @@ AGAIN: - Only output dates where you can recommend "연차" or "반차" according to the rules above. - Never output a recommendation like "연차 쓰지 마세요" or "휴가를 추천하지 않습니다". - """.trimIndent() + """.trimIndent() - val TAG_SYSTEM_PROMPT = """ + val tagSystemPrompt = """ You are an assistant specialized in Korean lifestyle and activity tag recommendations for daily schedules. Your ONLY allowed output is a valid JSON array of 3 tag objects as shown below. If you output anything else, your answer is invalid. @@ -77,9 +86,9 @@ Output example: ] Return ONLY this JSON array. Never add any other text, explanation, or formatting. - """.trimIndent() + """.trimIndent() - val ONLY_NEW_TAG_PROMPT = """ + val onlyNewTagSystemPrompt = """ You are an expert assistant for tag recommendations. Below is a JSON object representing the user's tag lists, all in Korean. @@ -107,6 +116,108 @@ Return ONLY this JSON array. Never add any other text, explanation, or formattin ] Here is the user's tag information in JSON: + """.trimIndent() + + /** + * 1단계: 생일·공휴일·주말·연차를 조합해 최대 연속 휴가 날짜(dates)를 계산 + */ + fun sandwichDatePrompt(req: AiGenerateVacationRequest): String { + return """ +You are a Vacation Date Optimizer. + +INPUT (exactly one JSON): +{"days": number, "birthDate": "YYYY-MM-DD" or null, "upcomingHolidays": ["YYYY-MM-DD", ...]} + +INSTRUCTIONS: +- Treat weekends (Sat, Sun) and upcomingHolidays as automatic non-working days. +- Use available vacation days ("days") on weekdays only to form the **longest continuous break** within targetMonth. +- If birthDate is a weekday in targetMonth, you may include it as a vacation day. +- **Output EXACTLY one valid JSON object** with key "dates", containing a sorted list of "YYYY-MM-DD". +- **Do NOT include any extra text, explanation, or debugging information.** +- If no valid continuous block exists, output: {"dates":[]} + +RULES: +- Only count weekdays toward "days". +- Continuous block may span weekends/holidays, but vacation days apply only to weekdays. +- Do not reference events outside targetMonth. + +OUTPUT: +{"dates":["YYYY-MM-DD", ...]} + """.trimIndent() + } + + fun detailedPlanPrompt( + req: AiGenerateVacationRequest, + dates: List, + offset: Int, + totalDays: Int, + prevUsed: List, + ): String { + val headers = dates.mapIndexed { i, d -> + val num = offset + i + 1 + val label = LocalDate.parse(d) + .format(DateTimeFormatter.ofPattern("MM/dd E", Locale.KOREAN)) + "• Day $num ($label)" + }.joinToString("\n") + + val usedClause = if (prevUsed.isNotEmpty()) { + "Recently used items: ${prevUsed.joinToString(", ")}.\nDo NOT reuse in this block.\n\n" + } else { + "" + } + + // profile JSON with Korean tags + val profileJson = objectMapper.writeValueAsString( + mapOf( + "travelStyle" to req.chosenTravelStyleLabel, + "activityType" to req.chosenActivityTypeLabel, + "restPreference" to req.chosenRestPreferenceLabel, + "leisurePreference" to req.chosenLeisurePreferenceLabel, + "preferredTags" to req.selectedTags, + "excludedTags" to req.unselectedTags, + ), + ) + + return """ +You are a Vacation Planning Expert. Use Perplexity to fetch real-time facts. + +$usedClause +INPUT (JSON): +{"dates":[${dates.joinToString(","){ "\"$it\"" }}],"profile":$profileJson,"totalDays":$totalDays} + +TASK: +Create a personalized itinerary over ${dates.size} days: +- If activityType == "집콕", propose home-based plans (e.g. cooking, movies, reading). +- Else (travel): plan actual trips with real departure→arrival chains. + * The origin of Day N must match the destination of Day N-1. + * The final day must include return to home. + +STYLE: +- travelStyle == "계획형": use precise times, transport modes, and locations. +- travelStyle == "즉흥 자유형": allow flexible timing, optional excursions. +- Honor restPreference and leisurePreference from profile. +- Include preferredTags, avoid excludedTags. +- Prevent reuse of activities/restaurants/accommodations used within the last 2 days. +- Mention "Day X of Y" to orient days within totalDays. + +FORMAT: +For each day, produce exactly 6 Korean bullet points: +1. Morning: 출발지→도착지, 교통수단, 출발시간 +2. Morning activity: 장소 및 내용, 이동 방식·소요시간 +3. Lunch: 식당명(추천메뉴), 지역 +4. Afternoon activity: 장소 및 내용, 이동 방식·소요시간 +5. Dinner: 식당명(추천메뉴), 지역 +6. Night: 숙소명 (or '집'), 교통수단, 체크인 or evening time + +REQUIREMENTS: +- Use Perplexity to verify opening hours or popularity info. +- Do not include JSON, code fences, or extra commentary. +- End output right after the last bullet. + +HEADERS: +$headers + +Now generate the itinerary. """.trimIndent() } } diff --git a/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/request/AiGenerateVacation.kt b/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/request/AiGenerateVacation.kt new file mode 100644 index 0000000..d9f3ae4 --- /dev/null +++ b/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/request/AiGenerateVacation.kt @@ -0,0 +1,29 @@ +package noweekend.mcphost.controller.request + +import java.time.LocalDate + +data class AiGenerateVacationRequest( + val days: Int, + + val travelStyleOptionLabels: List, + val chosenTravelStyleLabel: String, + + val activityTypeOptionLabels: List, + val chosenActivityTypeLabel: String, + + val restPreferenceOptionLabels: List, + val chosenRestPreferenceLabel: String, + + val leisurePreferenceOptionLabels: List, + val chosenLeisurePreferenceLabel: String, + + val birthDate: LocalDate, + val selectedTags: List, + val unselectedTags: List, + val upcomingHolidays: List, +) + +data class AiGenerateVacationResponse( + val title: String, + val content: String, +) diff --git a/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/request/SandwichRequest.kt b/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/request/SandwichRequest.kt new file mode 100644 index 0000000..ce9913a --- /dev/null +++ b/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/request/SandwichRequest.kt @@ -0,0 +1,10 @@ +package noweekend.mcphost.controller.request + +import java.time.LocalDate + +data class SandwichRequest( + val birthDay: LocalDate, + val holidays: List, + val remainingAnnualLeave: Int, + val weekends: List, +) diff --git a/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/response/BridgeVacationPeriod.kt b/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/response/BridgeVacationPeriod.kt new file mode 100644 index 0000000..67b5327 --- /dev/null +++ b/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/controller/response/BridgeVacationPeriod.kt @@ -0,0 +1,8 @@ +package noweekend.mcphost.controller.response + +import java.time.LocalDate + +data class BridgeVacationPeriod( + val startDate: LocalDate, + val endDate: LocalDate, +) diff --git a/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/service/ChatbotService.kt b/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/service/ChatbotService.kt index 6a86914..15933b8 100644 --- a/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/service/ChatbotService.kt +++ b/noweekend-mcp/mcp-host/src/main/kotlin/noweekend/mcphost/service/ChatbotService.kt @@ -1,35 +1,36 @@ package noweekend.mcphost.service import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue +import noweekend.mcphost.controller.Prompt +import noweekend.mcphost.controller.request.AiGenerateVacationRequest +import noweekend.mcphost.controller.request.AiGenerateVacationResponse +import noweekend.mcphost.controller.request.SandwichRequest import noweekend.mcphost.controller.request.Tag import noweekend.mcphost.controller.request.TagRequest import noweekend.mcphost.controller.request.WeatherRequest +import noweekend.mcphost.controller.response.BridgeVacationPeriod import noweekend.mcphost.controller.response.WeatherResponse -import noweekend.mcphost.service.Prompt.Companion.TAG_SYSTEM_PROMPT -import noweekend.mcphost.service.Prompt.Companion.WEATHER_PROMPT import org.slf4j.LoggerFactory import org.springframework.ai.chat.client.ChatClient +import org.springframework.ai.retry.NonTransientAiException import org.springframework.stereotype.Service +import org.springframework.web.client.RestClientResponseException import java.time.LocalDate import java.time.ZoneId +data class DateList(val dates: List) + @Service class ChatbotService( private val chatClient: ChatClient, private val objectMapper: ObjectMapper, + private val prompt: Prompt, ) { private val logger = LoggerFactory.getLogger(ChatbotService::class.java) - fun chat(question: String): String { - return chatClient.prompt() - .user(question) - .call() - .content() - ?: throw IllegalStateException("Chat response content is null for question: $question") - } - fun weatherRecommendation(request: WeatherRequest): List { val baseDate = LocalDate.now(ZoneId.of("Asia/Seoul")).toString() val userMsg = """ @@ -39,13 +40,13 @@ class ChatbotService( """.trimIndent() val maxRetries = 5 - val backoff = listOf(1000L, 2000L, 4000L, 8000L, 16000L) // 1,2,4,8,16초 대기 + val backoff = listOf(1000L, 2000L, 4000L, 8000L, 16000L) var lastException: Exception? = null for (attempt in 0 until maxRetries) { try { val jsonString = chatClient.prompt() - .system(WEATHER_PROMPT) + .system(prompt.weatherPrompt) .user(userMsg) .call() .content() @@ -80,7 +81,7 @@ Based on the above rules, return ONLY a valid JSON array of 3 Korean lifestyle a repeat(7) { attempt -> try { val rawResponse = chatClient.prompt() - .system(TAG_SYSTEM_PROMPT) + .system(prompt.tagSystemPrompt) .user(userPrompt) .call() .content() @@ -123,7 +124,7 @@ Based on the above rules, return ONLY a valid JSON array of 3 Korean lifestyle a repeat(5) { attempt -> val jsonString = chatClient.prompt() - .system(Prompt.ONLY_NEW_TAG_PROMPT) + .system(prompt.onlyNewTagSystemPrompt) .user(userMsg) .call() .content() @@ -140,9 +141,202 @@ Based on the above rules, return ONLY a valid JSON array of 3 Korean lifestyle a return tags } } catch (e: Exception) { - if (attempt == 1) throw IllegalStateException("태그 추천을 받아올 수 없습니다.") + if (attempt == 4) throw IllegalStateException("태그 추천을 받아올 수 없습니다.") } } throw IllegalStateException("태그 추천을 받아올 수 없습니다.") } + + private val chunkSize = 2 + + fun generateVacation(request: AiGenerateVacationRequest): AiGenerateVacationResponse { + // 1) 샌드위치 날짜 계산 (기존 로직) + val sandwichJson = objectMapper.writeValueAsString( + mapOf( + "days" to request.days, + "birthDate" to request.birthDate.toString(), + "upcomingHolidays" to request.upcomingHolidays, + ), + ) + val sandwichRaw = chatClient.prompt() + .system(prompt.sandwichDatePrompt(request)) + .user(sandwichJson) + .call() + .content() ?: error("샌드위치 휴가 날짜 생성 실패") + val cleanedSandwichJson = extractJsonObject(sandwichRaw) + val allDates = objectMapper.readValue(cleanedSandwichJson).dates + + val chunkCache = mutableMapOf() + val usedItems = mutableListOf() + + val itineraryChunks = allDates.chunked(chunkSize).mapIndexed { idx, datesChunk -> + chunkCache[idx]?.let { return@mapIndexed it } + + val detailPrompt = prompt.detailedPlanPrompt(request, datesChunk, idx * chunkSize, allDates.size, usedItems) + val userJson = objectMapper.writeValueAsString( + mapOf( + "dates" to datesChunk, + "profile" to minimalProfileMap(request), + "totalDays" to allDates.size, + ), + ) + + while (true) { + try { + val resp = chatClient.prompt() + .system(detailPrompt) + .user(userJson) + .call() + val content = resp.content() ?: error("Empty chunk") + usedItems += extractUsedItems(content) + chunkCache[idx] = content + return@mapIndexed content + } catch (e: NonTransientAiException) { + val cause = e.cause + val status = (cause as? RestClientResponseException)?.statusCode?.value() + val headers = (cause as? RestClientResponseException)?.responseHeaders + + if (status == 429) { + val retryAfterSec = headers + ?.getFirst("retry-after") + ?.toLongOrNull() + ?: 180L + + logger.warn("429 rate limit hit, waiting $retryAfterSec seconds before retry") + Thread.sleep(retryAfterSec * 1000) + continue + } + throw e + } + } + } + + val planRaw = itineraryChunks.joinToString("\n\n") + + val summary = chatClient.prompt() + .system( + """ + You are an expert at creating concise and catchy Korean titles for vacation plans. + Summarize core concept in 15 characters max, Korean only. + """.trimIndent(), + ) + .user( + """ + 아래는 사용자 일정입니다: + $planRaw + + 핵심 키워드 중심으로 15자 이내 제목 하나 만들어 주세요. + """.trimIndent(), + ) + .call() + .content() ?: error("제목 요약 실패") + + return AiGenerateVacationResponse(title = summary, content = planRaw) + } + + private fun minimalProfileMap(req: AiGenerateVacationRequest) = mapOf( + "days" to req.days, + "travelStyle" to req.chosenTravelStyleLabel, + "activityType" to req.chosenActivityTypeLabel, + "restPreference" to req.chosenRestPreferenceLabel, + "leisureInterest" to req.chosenLeisurePreferenceLabel, + "preferredTags" to req.selectedTags, + "excludedTags" to req.unselectedTags, + ) + + private fun extractUsedItems(chunkRaw: String): List { + val items = mutableListOf() + val commonRegex = """• [^:]+: ([^,]+)""".toRegex() + items += commonRegex.findAll(chunkRaw).map { it.groupValues[1] }.toList() + val hotelRegex = """• Night / Accommodation: ([^,]+)""".toRegex() + items += hotelRegex.findAll(chunkRaw).map { it.groupValues[1] }.toList() + return items + } + + private fun printRaw(text: String) { + println("------------------------------------------------\n") + println(text) + println("------------------------------------------------\n") + } + + private fun extractJsonObject(raw: String): String { + val start = raw.indexOfFirst { it == '{' } + val end = raw.lastIndexOf('}') + if (start == -1 || end == -1 || end <= start) { + error("응답에서 유효한 JSON을 찾을 수 없습니다: $raw") + } + return raw.substring(start, end + 1) + } + + fun getSandwich(request: SandwichRequest): List { + request.holidays.forEach { holiday -> println(holiday) } + request.weekends.forEach { weekend -> println(weekend) } + + val systemPrompt = """ +Given the input data below, find all possible "bridge vacation" periods for the rest of the year. + +INPUT: +- Public holidays: [${request.holidays.joinToString(", ")}] +- Weekends: [${request.weekends.joinToString(", ")}] + +RULES: +1. A "bridge vacation" is a period that connects public holidays and weekends, allowing for up to 1 or 2 weekdays ("gaps") between them, if those weekdays can be replaced with annual leave. +2. If more than 2 consecutive weekdays (gaps) occur, **end the current vacation block before these weekdays begin**, and start a new block from the next holiday or weekend. +3. If after using up the allowed gaps (1 or 2 consecutive weekdays), another holiday or weekend immediately follows, **continue the same block**. +4. If there are 3 or more consecutive weekdays (not holidays or weekends), **break the block** and start a new vacation block after the next holiday/weekend. +5. For each bridge vacation block, output ONLY: + - "startDate": yyyy-MM-dd (first date of the period) + - "endDate": yyyy-MM-dd (last date of the period) +6. Only output blocks that are at least 3 days long (inclusive). +7. DO NOT return overlapping or duplicate blocks. +8. Output MUST be a valid JSON array of objects, each with "startDate" and "endDate". + - No totalDays, no extra fields. + - NO markdown, code block, explanation, or any other text—**JSON array ONLY**. +9. If the output includes anything other than the JSON array, it is INVALID. + +EXAMPLE (must follow this format exactly): + +[ + { "startDate": "2025-10-02", "endDate": "2025-10-09" }, + { "startDate": "2025-12-24", "endDate": "2025-12-28" } +] + +***Return ONLY a JSON array as above. No explanation, markdown, or extra text.*** + """.trimIndent() + + val objectMapper = jacksonObjectMapper().findAndRegisterModules() + + var lastException: Throwable? = null + repeat(10) { attempt -> + try { + val rawResponse = chatClient.prompt() + .system(systemPrompt) + .user("It's Order") + .call() + .content() ?: throw IllegalStateException("No response from MCP host") + + println("rawResponse = $rawResponse") + + var cleaned = rawResponse.trim() + if (cleaned.startsWith("```json")) cleaned = cleaned.removePrefix("```json").trim() + if (cleaned.startsWith("```")) cleaned = cleaned.removePrefix("```").trim() + if (cleaned.endsWith("```")) cleaned = cleaned.removeSuffix("```").trim() + + val arrStart = cleaned.indexOfFirst { it == '[' } + val arrEnd = cleaned.lastIndexOf(']') + if (arrStart != -1 && arrEnd != -1 && arrEnd > arrStart) { + cleaned = cleaned.substring(arrStart, arrEnd + 1) + } + + println("cleaned = $cleaned") + + // 바로 파싱 (실패시 예외 발생) + return objectMapper.readValue(cleaned) + } catch (e: Throwable) { + lastException = e + println("getSandwich retry ${attempt + 1}/5: ${e.message}") + } + } + throw IllegalStateException("Failed to get valid bridge vacation periods after 5 attempts", lastException) + } } diff --git a/noweekend-mcp/mcp-host/src/main/resources/application.yml b/noweekend-mcp/mcp-host/src/main/resources/application.yml index 3ea1d76..63a19c1 100644 --- a/noweekend-mcp/mcp-host/src/main/resources/application.yml +++ b/noweekend-mcp/mcp-host/src/main/resources/application.yml @@ -4,6 +4,15 @@ spring: mvc: time-zone: Asia/Seoul ai: + retry: + max-attempts: 5 + backoff: + initial-interval: 10s + multiplier: 2 + max-interval: 3m + on-client-errors: true + on-http-codes: 429 + anthropic: api-key: ENC(DSDMqqudORinUFWutrGj/hFq31Rcp+1B5SqZmhQUZKe5g5G01KIcngKwmep3K0aDGrqMIGsxlAxj9Bx9fe2MwHnpQ+pZYCT/vYAp/6hKIiuSQULyrMWgkTxd08+RWnzP9DNZb7BpX1NYuZ3dsIqPo0qVCVsyLLjc) # api-key: ENC(q6WkPzavRozxQbgOktG6eCgCIMtfAlLQuakXP4sBXPDS+Ij8dvo9p0GJyE0+FJgEOkhys5DM50qsMOedjdZS5ewcc1ACvpRw6TcHWi7Mu158YCQUC5M4A5RGqC5ACDNnbrjrA1dmmJtEi8dNHCPlYLXRKK2m/t5Z) @@ -24,8 +33,8 @@ spring: # local # url: http://localhost:8081 enabled: true -# stdio: -# connections: + stdio: + connections: # brave-search: # command: npx # args: @@ -33,6 +42,13 @@ spring: # - "@modelcontextprotocol/server-brave-search" # env: # BRAVE_API_KEY: ENC(tOf1NF3MQ4QX7ou+iVM6BoOB5NrW2B9KkZVC3jn72puMsagRysNFGA==) + perplexity-ask: + command: npx + args: + - "-y" + - "server-perplexity-ask" + env: + PERPLEXITY_API_KEY: ENC(az7SSvHLhX8edbEueje4Hds6SaMn4YOvlgaAFVDuRBkYlROjLriocCmgZbQUx7jVzNc4fQJwn0CbyVY2xOal3g== type: SYNC request-timeout: 60s diff --git a/noweekend-mcp/mcp-server/src/main/kotlin/noweekend/mcpserver/controller/WeatherController.kt b/noweekend-mcp/mcp-server/src/main/kotlin/noweekend/mcpserver/controller/WeatherController.kt index 9518014..988ca34 100644 --- a/noweekend-mcp/mcp-server/src/main/kotlin/noweekend/mcpserver/controller/WeatherController.kt +++ b/noweekend-mcp/mcp-server/src/main/kotlin/noweekend/mcpserver/controller/WeatherController.kt @@ -19,8 +19,5 @@ class WeatherController( longitude = 126.3131, ) val rawKmaForecast = futureWeatherTool.getRainSnowSummaryByDay(request) - for (entry in rawKmaForecast) { - println(entry) - } } } diff --git a/noweekend-storage/db-core/src/main/kotlin/noweekend/storage/db/core/weekend/WeekendCoreRepository.kt b/noweekend-storage/db-core/src/main/kotlin/noweekend/storage/db/core/weekend/WeekendCoreRepository.kt new file mode 100644 index 0000000..4bbc7d9 --- /dev/null +++ b/noweekend-storage/db-core/src/main/kotlin/noweekend/storage/db/core/weekend/WeekendCoreRepository.kt @@ -0,0 +1,25 @@ +package noweekend.storage.db.core.weekend + +import noweekend.core.domain.weekend.Weekend +import noweekend.core.domain.weekend.WeekendRepository +import org.springframework.stereotype.Repository +import java.time.LocalDate + +@Repository +class WeekendCoreRepository( + private val jpaRepository: WeekendJpaRepository, +) : WeekendRepository { + override fun register(weekend: Weekend) { + jpaRepository.save(weekend.toEntity()) + } + + override fun findByYear(year: Int): List { + val start = LocalDate.of(year, 1, 1) + val end = LocalDate.of(year, 12, 31) + return jpaRepository.findAllByDateBetween(start, end).map { it.toDomain() } + } + + override fun findAfterDate(date: LocalDate): List { + return jpaRepository.findAllByDateAfter(date).map { it.toDomain() } + } +} diff --git a/noweekend-storage/db-core/src/main/kotlin/noweekend/storage/db/core/weekend/WeekendEntity.kt b/noweekend-storage/db-core/src/main/kotlin/noweekend/storage/db/core/weekend/WeekendEntity.kt new file mode 100644 index 0000000..98ed875 --- /dev/null +++ b/noweekend-storage/db-core/src/main/kotlin/noweekend/storage/db/core/weekend/WeekendEntity.kt @@ -0,0 +1,40 @@ +package noweekend.storage.db.core.weekend + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Table +import noweekend.core.domain.weekend.Weekend +import java.time.DayOfWeek +import java.time.LocalDate + +@Entity +@Table(name = "weekend") +class WeekendEntity( + @Id + @Column(name = "id") + val id: String, + + @Column(name = "date", nullable = false) + val date: LocalDate, + + @Enumerated(EnumType.STRING) + @Column(name = "day_of_week", nullable = false) + val dayOfWeek: DayOfWeek, +) + +fun Weekend.toEntity(): WeekendEntity = + WeekendEntity( + id = this.id, + date = this.date, + dayOfWeek = this.dayOfWeek, + ) + +fun WeekendEntity.toDomain(): Weekend = + Weekend( + id = this.id, + date = this.date, + dayOfWeek = this.dayOfWeek, + ) diff --git a/noweekend-storage/db-core/src/main/kotlin/noweekend/storage/db/core/weekend/WeekendJpaRepository.kt b/noweekend-storage/db-core/src/main/kotlin/noweekend/storage/db/core/weekend/WeekendJpaRepository.kt new file mode 100644 index 0000000..32e2600 --- /dev/null +++ b/noweekend-storage/db-core/src/main/kotlin/noweekend/storage/db/core/weekend/WeekendJpaRepository.kt @@ -0,0 +1,9 @@ +package noweekend.storage.db.core.weekend + +import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDate + +interface WeekendJpaRepository : JpaRepository { + fun findAllByDateBetween(start: LocalDate, end: LocalDate): List + fun findAllByDateAfter(date: LocalDate): List +}