diff --git a/README.md b/README.md index 8d7e8aee..16389b19 100644 --- a/README.md +++ b/README.md @@ -1 +1,158 @@ -# java-baseball-precourse \ No newline at end of file +# 🌀️ 날씨 쑰회 μ„œλΉ„μŠ€ +λ„μ‹œ 이름을 μž…λ ₯λ°›μ•„ Open-Meteo APIλ₯Ό 톡해 μ‹€μ‹œκ°„ 날씨 정보λ₯Ό μ‘°νšŒν•˜λŠ” Spring 기반 μ›Ή μ„œλΉ„μŠ€ + +--- +## πŸ“Œ κ΅¬ν˜„ λͺ©ν‘œ +μ‚¬μš©μžκ°€ λ„μ‹œ 이름을 μž…λ ₯ν•˜λ©΄, ν•΄λ‹Ή λ„μ‹œμ˜ μœ„λ„Β·κ²½λ„λ₯Ό 기반으둜 μ™ΈλΆ€ API(Open-Meteo)λ₯Ό ν˜ΈμΆœν•˜μ—¬ λ‹€μŒ 정보λ₯Ό μ œκ³΅ν•˜λŠ” κ°„λ‹¨ν•œ 날씨 쑰회 APIλ₯Ό κ΅¬ν˜„ + +- ν˜„μž¬ 기온 +- 체감 μ˜¨λ„ +- ν•˜λŠ˜ μƒνƒœ(λ§‘μŒ, 흐림 λ“±) +- μŠ΅λ„ +- 쑰회된 데이터λ₯Ό 기반으둜 ν•œ ν•œ 쀄 μš”μ•½ λ¬Έμž₯ +--- +## πŸ—οΈ 기술 μŠ€νƒ +- Java 21 +- Spring Web +- Spring WebFlux (WebClient) +- Lombok +- JUnit5 / AssertJ / MockMvc +- Gradle +--- +## πŸ“ ν”„λ‘œμ νŠΈ ꡬ쑰 +``` +src +β”œβ”€β”€ main +β”‚ └── java/com/llm_precourse/weather +β”‚ β”œβ”€β”€ controller +β”‚ β”œβ”€β”€ service +β”‚ β”œβ”€β”€ domain +β”‚ β”œβ”€β”€ dto +β”‚ β”œβ”€β”€ exception +β”‚ └── common +└── test + └── java/com/llm_precourse/weather + β”œβ”€β”€ domain + β”œβ”€β”€ service + └── controller +``` +--- +## πŸš€ κ΅¬ν˜„ κΈ°λŠ₯ + +### 1. 지원 λ„μ‹œ 관리 (City enum) +- μ΅œμ†Œ 5개 λ„μ‹œλ₯Ό μ§€μ›ν•˜λ©°, λ„μ‹œλͺ… β†’ μœ„λ„/경도 맀핑을 Enum ν˜•νƒœλ‘œ λͺ¨λΈλ§ +- enum 내뢀에 `Map`λ₯Ό 두어 λ„μ‹œ 이름(λŒ€μ†Œλ¬Έμž λ¬΄μ‹œ)으둜 O(1)의 λ³΅μž‘λ„λ‘œ λ„μ‹œ 정보λ₯Ό μ‘°νšŒν•  수 μžˆλŠ” 자료ꡬ쑰λ₯Ό 섀계 +- λ„μ‹œλͺ… β†’ City λ§€ν•‘: `BY_NAME` (λΆˆλ³€ Map) +- 각 enum μƒμˆ˜λŠ” `cityName`, `cityNameKr`, `latitude`, `longitude`λ₯Ό 보유 +- `City.from(String city)` λ©”μ„œλ“œλ₯Ό 톡해 μž…λ ₯값을 μ •κ·œν™” ν›„ enum 쑰회 +- 지원 λ„μ‹œ: + +| λ„μ‹œ | μœ„λ„ | 경도 | +|---------|---------|----------| +| Seoul | 37.5665 | 126.9780 | +| Tokyo | 35.6762 | 139.6503 | +| NewYork | 40.7128 | -74.0060 | +| Paris | 48.8566 | 2.3522 | +| London | 51.5074 | -0.1278 | + +### 2. 날씨 μ½”λ“œ λ§€ν•‘ (WeatherCondition enum) +- Open-Meteoμ—μ„œ λ‚΄λ €μ£ΌλŠ” `weather_code` 값을 `WeatherCondition` enum으둜 λͺ¨λΈλ§ +- 각 enum μƒμˆ˜λŠ” `code`(μ •μˆ˜ μ½”λ“œ)와 `description`(ν•œκΈ€ μ„€λͺ…)을 κ°€μ§€λ©°, λ‚΄λΆ€ `Map`을 톡해 μƒμˆ˜ 쑰회 +- weather_code β†’ WeatherCondition λ§€ν•‘: `BY_CODE` (λΆˆλ³€ Map) +- `WeatherCondition.descriptionOf(int code)`λ₯Ό 톡해 λ¬Έμžμ—΄ λ°˜ν™˜ + +### 3. μ™ΈλΆ€ API 연동 (OpenMeteoClient) +Spring WebFlux의 WebClient λ₯Ό μ‚¬μš©ν•˜μ—¬ Open-Meteo API 호좜. +> e.g. https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}¤t_weather=true +- 쑰회 ν•„λ“œ: + - temperature_2m + - apparent_temperature + - relative_humidity_2m + - weather_code + +### 4. WeatherService (핡심 λΉ„μ¦ˆλ‹ˆμŠ€ 둜직) +- λ‹΄λ‹Ή κΈ°λŠ₯: + - λ„μ‹œλͺ… 검증 + - μœ„λ„/경도 기반 날씨 쑰회 + - ν•˜λŠ˜ μƒνƒœ(weather_code β†’ ν•œκΈ€ μ„€λͺ…) λ³€ν™˜ + - μ΅œμ’… μ‚¬μš©μž 응닡 DTO 생성 + - μžμ—°μŠ€λŸ¬μš΄ ν•œ 쀄 μš”μ•½ λ¬Έμž₯ 생성 +> μ˜ˆμ‹œ: β€œν˜„μž¬ μ„œμšΈμ˜ κΈ°μ˜¨μ€ 3.4℃이고, 체감 μ˜¨λ„λŠ” 1.2β„ƒμž…λ‹ˆλ‹€. ν•˜λŠ˜ μƒνƒœλŠ” 흐림이며, μŠ΅λ„λŠ” 65%μž…λ‹ˆλ‹€.” + +### 5. λ„μ‹œλ³„ 날씨 쑰회 API +`GET /api/weather?city={cityName}` +- 응닡(JSON) +``` + { + "city": "Seoul", + "temperature": 3.4, + "feelsLike": 1.2, + "condition": "λΆ€λΆ„μ μœΌλ‘œ 흐림", + "humidity": 65, + "summary": "ν˜„μž¬ μ„œμšΈμ˜ κΈ°μ˜¨μ€ 3.4℃이고, 체감 μ˜¨λ„λŠ” 1.2β„ƒμž…λ‹ˆλ‹€. ν•˜λŠ˜ μƒνƒœλŠ” λΆ€λΆ„μ μœΌλ‘œ 흐림이며, μŠ΅λ„λŠ” 65%μž…λ‹ˆλ‹€." + } +``` +--- +## ⚠️ μ—λŸ¬ 처리(μ˜ˆμ™Έ μ „λž΅) +쀑앙 집쀑 방식(GlobalExceptionHandler) 적용. + +| 상황 | HTTP Status | Error code | message | +|---------------------------|---------------------------|-----------------------|----------------------------| +| μž…λ ₯ νŒŒλΌλ―Έν„° λˆ„λ½/빈 κ°’ | 400 Bad Request | INVALID_REQUEST | city νŒŒλΌλ―Έν„°λŠ” ν•„μˆ˜μž…λ‹ˆλ‹€. | +| μ§€μ›ν•˜μ§€ μ•ŠλŠ” λ„μ‹œλͺ… | 400 Bad Request | CITY_NOT_SUPPORTED | μ§€μ›ν•˜μ§€ μ•ŠλŠ” λ„μ‹œμž…λ‹ˆλ‹€. | +| Open-Meteo API 호좜 μ‹€νŒ¨/νƒ€μž„μ•„μ›ƒ | 502 Bad Gateway | EXTERNAL_API_ERROR | μ™ΈλΆ€ 날씨 API 호좜 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€. | +| κ·Έ μ™Έ μ˜ˆμƒμΉ˜ λͺ»ν•œ μ„œλ²„ λ‚΄λΆ€ 였λ₯˜ | 500 Internal Server Error | INTERNAL_SERVER_ERROR | μ•Œ 수 μ—†λŠ” 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€. | + +- JSON Sample +``` +{ +"code": "CITY_NOT_SUPPORTED", +"message": "μ§€μ›ν•˜μ§€ μ•ŠλŠ” λ„μ‹œμž…λ‹ˆλ‹€.", +"timestamp": "2025-01-01T12:00:00" +} +``` +--- +## πŸ§ͺ ν…ŒμŠ€νŠΈ μ „λž΅ +ν…ŒμŠ€νŠΈλŠ” 총 3λ‹¨κ³„λ‘œ ꡬ성됨. + +### 1단계 β€” λ‹¨μœ„ ν…ŒμŠ€νŠΈ (Domain) +- λŒ€μƒ: City.from() +- 검증 λ‚΄μš©: + - 정상 λ„μ‹œ λ§€ν•‘ + - λŒ€μ†Œλ¬Έμž λ¬΄μ‹œ + - null / 빈 λ¬Έμžμ—΄ 처리 + - 미지원 λ„μ‹œ 처리 +- 파일: `CityTest.java` + +### 2단계 β€” λ‹¨μœ„ ν…ŒμŠ€νŠΈ (Service) +- λŒ€μƒ: WeatherService +- FakeOpenMeteoClient λ₯Ό μ‚¬μš©ν•˜μ—¬ μ™ΈλΆ€ API μ˜μ‘΄μ„± 제거 +- κ²€μ¦λ‚΄μš©: + - μš”μ•½ λ¬Έμž₯ 생성 둜직 검증 + - μ˜ˆμ™Έ 처리 검증 +- 파일: `WeatherServiceTest.java` + +### 3단계 β€” Controller ν…ŒμŠ€νŠΈ (API Endpoint) +- 도ꡬ: MockMvc + @WebMvcTest +- 검증 λ‚΄μš©: + - 정상 μš”μ²­ μ‹œ 200 OK + WeatherResponse λ°˜ν™˜ + - 잘λͺ»λœ city μž…λ ₯ μ‹œ 400 INVALID_REQUEST + - 미지원 λ„μ‹œ μš”μ²­ μ‹œ 400 CITY_NOT_SUPPORTED + - μ™ΈλΆ€ API μ—λŸ¬ μ‹œ 502 EXTERNAL_API_ERROR +- 파일: `WeatherControllerTest.java` +--- +## πŸ€– AI ν™œμš© 방식 +- κΈ°λŠ₯ 및 ν”„λ‘œκ·Έλž˜λ° μš”κ΅¬μ‚¬ν•­μ„ AI 도ꡬ에 μ œκ³΅ν•œ λ’€, μ†ŒμŠ€ μ½”λ“œΒ·ν…ŒμŠ€νŠΈ μ½”λ“œΒ·README.md μ΄ˆμ•ˆ 생성을 μš”μ²­ν•¨ +- μƒμ„±λœ μ½”λ“œλ₯Ό κ²€ν† ν•˜λ©° μš”κ΅¬μ‚¬ν•­κ³Ό λ§žμ§€ μ•ŠλŠ” 뢀뢄을 μ§€μ ν•˜κ³ , ν•„μš”ν•œ λ¦¬νŒ©ν† λ§μ„ μž¬μš”μ²­ν•¨ +- AIκ°€ μƒμ„±ν•œ README μ΄ˆμ•ˆμ„ λ°”νƒ•μœΌλ‘œ 전체 λ¬Έμ„œ ꡬ쑰λ₯Ό μž¬μ •λ¦¬ν•˜κ³ , Markdown ν‘œν˜„ 방식(bullet β†’ table λ“±)을 κ°œμ„ ν•¨ +- μƒμ„±λœ ν…ŒμŠ€νŠΈ μ½”λ“œλ₯Ό 직접 μ‹€ν–‰ν•˜μ—¬ 톡과 μ—¬λΆ€λ₯Ό ν™•μΈν•˜κ³ , μ‹€νŒ¨ν•œ ν…ŒμŠ€νŠΈμ— λŒ€ν•΄ AIμ—κ²Œ 디버깅을 μš”μ²­ν•¨ +- μ½”λ“œ/ν…ŒμŠ€νŠΈ/README 생성에 μ‚¬μš©λœ AI의 μ ‘κ·Ό 방식과 μ„€λͺ…을 μ°Έκ³ ν•˜λ©°, 이해가 ν•„μš”ν•œ 뢀뢄은 λ³„λ„λ‘œ μ§ˆλ¬Έν•¨ +- λ¦¬νŒ©ν† λ§ 및 디버깅 κ³Όμ •μ—μ„œλ„ AIκ°€ μ œμ‹œν•œ μ„€λͺ…을 기반으둜 μΆ”κ°€ μ§ˆλ¬Έμ„ ν•˜λ©° λ‚΄μš©μ„ 보완함 +- λ³Έ μ„Ήμ…˜(AI ν™œμš© 방식, Lessons Learned)의 λ¬Έμž₯ ν‘œν˜„ μ—­μ‹œ AIμ—κ²Œ μžμ—°μŠ€λŸ½κ²Œ 닀듬어 달라고 μš”μ²­ν•¨ + +## πŸ“˜ Lessons Learned +- AI 도ꡬλ₯Ό ν™œμš©ν•˜λ©΄ μš”κ΅¬μ‚¬ν•­μ„ λΉ λ₯΄κ³  효율적으둜 κ΅¬ν˜„ν•  수 μžˆμŒμ„ 체감함 +- κ·ΈλŸΌμ—λ„ 결과물의 ν’ˆμ§ˆμ„ μ™„μ „νžˆ 보μž₯ν•˜λ €λ©΄, μ‚¬λžŒμ˜ 검토와 νŒλ‹¨μ΄ μ—¬μ „νžˆ ν•„μš”ν•œ 뢀뢄도 있음 +- AIκ°€ μƒμ„±ν•œ μ‚°μΆœλ¬Όμ„ λ‹€μ‹œ AIμ—κ²Œ κ²€μ¦Β·λ³΄μ™„μ‹œν‚€λŠ” μžλ™ν™”λœ ν’ˆμ§ˆ 관리 νŒŒμ΄ν”„λΌμΈμ„ κ΅¬μΆ•ν•˜λ©΄ 생산성이 λ”μš± λ†’μ•„μ§ˆ 것이라 νŒλ‹¨ν•¨ +- μ—¬λŸ¬ AI 도ꡬ가 μ„œλ‘œ λ³΄μ™„μ μœΌλ‘œ ν˜‘λ ₯ν•˜λŠ” νŒŒμ΄ν”„λΌμΈμ„ κ΅¬μΆ•ν•œλ‹€λ©΄, 결과물의 ν’ˆμ§ˆμ„ λ”μš± 높일 수 μžˆμ„ 것이라 κΈ°λŒ€ν•¨ +- AIλŠ” μΈκ°„μ˜ μ—­λŸ‰μ„ ν™•μž₯μ‹œν‚€λŠ” 도ꡬ이며, 이λ₯Ό 톡해 더 λ§Žμ€ 문제λ₯Ό ν•΄κ²°ν•  수 μžˆλ‹€λŠ” κ°€λŠ₯성을 확인함 \ No newline at end of file diff --git a/build.gradle b/build.gradle index 20a92c9e..3778465f 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,7 @@ plugins { id 'java' + id("org.springframework.boot") version "3.5.8" + id("io.spring.dependency-management") version "1.1.7" } group = 'camp.nextstep.edu' @@ -7,7 +9,7 @@ version = '1.0-SNAPSHOT' java { toolchain { - languageVersion = JavaLanguageVersion.of(17) + languageVersion = JavaLanguageVersion.of(21) } } @@ -16,6 +18,18 @@ repositories { } dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web:3.5.8' + implementation 'org.springframework.boot:spring-boot-starter-webflux:3.5.8' + + implementation(platform("org.springframework.ai:spring-ai-bom:1.1.2")) + implementation("org.springframework.ai:spring-ai-starter-model-google-genai") + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + + testImplementation 'org.springframework.boot:spring-boot-starter-test:3.5.8' testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' testImplementation 'org.assertj:assertj-core:3.25.3' } diff --git a/settings.gradle b/settings.gradle index 11c425be..b86a01f0 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,4 @@ -plugins { - id 'org.gradle.toolchains.foojay-resolver-convention' version '0.7.0' -} +//plugins { +// id 'org.gradle.toolchains.foojay-resolver-convention' version '0.7.0' +//} rootProject.name = 'java-baseball' diff --git a/src/main/java/com/llm_precourse/weather/WeatherApplication.java b/src/main/java/com/llm_precourse/weather/WeatherApplication.java new file mode 100644 index 00000000..7d9bfc58 --- /dev/null +++ b/src/main/java/com/llm_precourse/weather/WeatherApplication.java @@ -0,0 +1,13 @@ +package com.llm_precourse.weather; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class WeatherApplication { + + public static void main(String[] args) { + SpringApplication.run(WeatherApplication.class, args); + } + +} diff --git a/src/main/java/com/llm_precourse/weather/common/GlobalExceptionHandler.java b/src/main/java/com/llm_precourse/weather/common/GlobalExceptionHandler.java new file mode 100644 index 00000000..a4814ad7 --- /dev/null +++ b/src/main/java/com/llm_precourse/weather/common/GlobalExceptionHandler.java @@ -0,0 +1,65 @@ +package com.llm_precourse.weather.common; + +import com.llm_precourse.weather.dto.ErrorResponse; +import com.llm_precourse.weather.exception.ExternalApiException; +import com.llm_precourse.weather.exception.InvalidRequestException; +import com.llm_precourse.weather.exception.UnsupportedCityException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import jakarta.servlet.http.HttpServletRequest; +import java.time.LocalDateTime; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(InvalidRequestException.class) + public ResponseEntity handleInvalidRequest(InvalidRequestException ex, + HttpServletRequest request) { + ErrorResponse body = ErrorResponse.builder() + .code("INVALID_REQUEST") + .message(ex.getMessage()) + .timestamp(LocalDateTime.now()) + .path(request.getRequestURI()) + .build(); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); + } + + @ExceptionHandler(UnsupportedCityException.class) + public ResponseEntity handleUnsupportedCity(UnsupportedCityException ex, + HttpServletRequest request) { + ErrorResponse body = ErrorResponse.builder() + .code("CITY_NOT_SUPPORTED") + .message(ex.getMessage()) + .timestamp(LocalDateTime.now()) + .path(request.getRequestURI()) + .build(); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); + } + + @ExceptionHandler(ExternalApiException.class) + public ResponseEntity handleExternalApi(ExternalApiException ex, + HttpServletRequest request) { + ErrorResponse body = ErrorResponse.builder() + .code("EXTERNAL_API_ERROR") + .message(ex.getMessage()) + .timestamp(LocalDateTime.now()) + .path(request.getRequestURI()) + .build(); + return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(body); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneric(Exception ex, + HttpServletRequest request) { + ErrorResponse body = ErrorResponse.builder() + .code("INTERNAL_SERVER_ERROR") + .message("μ•Œ 수 μ—†λŠ” 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄ μ£Όμ„Έμš”.") + .timestamp(LocalDateTime.now()) + .path(request.getRequestURI()) + .build(); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body); + } +} diff --git a/src/main/java/com/llm_precourse/weather/common/OpenMeteoClient.java b/src/main/java/com/llm_precourse/weather/common/OpenMeteoClient.java new file mode 100644 index 00000000..be4832c9 --- /dev/null +++ b/src/main/java/com/llm_precourse/weather/common/OpenMeteoClient.java @@ -0,0 +1,32 @@ +package com.llm_precourse.weather.common; + +import com.llm_precourse.weather.dto.OpenMeteoResponse; +import com.llm_precourse.weather.exception.ExternalApiException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +@Component +@RequiredArgsConstructor +public class OpenMeteoClient { + + private final WebClient webClient; + + public OpenMeteoResponse getCurrentWeather(double latitude, double longitude) { + try { + return webClient.get() + .uri(uriBuilder -> uriBuilder + .path("/forecast") + .queryParam("latitude", latitude) + .queryParam("longitude", longitude) + .queryParam("current", + "temperature_2m,apparent_temperature,relative_humidity_2m,weather_code") + .build()) + .retrieve() + .bodyToMono(OpenMeteoResponse.class) + .block(); + } catch (Exception e) { + throw new ExternalApiException("μ™ΈλΆ€ 날씨 API 호좜 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/llm_precourse/weather/common/WebClientConfig.java b/src/main/java/com/llm_precourse/weather/common/WebClientConfig.java new file mode 100644 index 00000000..e617238a --- /dev/null +++ b/src/main/java/com/llm_precourse/weather/common/WebClientConfig.java @@ -0,0 +1,16 @@ +package com.llm_precourse.weather.common; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + + @Bean + public WebClient openMeteoWebClient() { + return WebClient.builder() + .baseUrl("https://api.open-meteo.com/v1") + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/llm_precourse/weather/controller/WeatherController.java b/src/main/java/com/llm_precourse/weather/controller/WeatherController.java new file mode 100644 index 00000000..2001828d --- /dev/null +++ b/src/main/java/com/llm_precourse/weather/controller/WeatherController.java @@ -0,0 +1,25 @@ +package com.llm_precourse.weather.controller; + +import com.llm_precourse.weather.dto.WeatherResponse; +import com.llm_precourse.weather.service.WeatherService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class WeatherController { + + private final WeatherService weatherService; + + @GetMapping("/api/weather/v1") + public WeatherResponse getWeather(@RequestParam("city") String city) { + return weatherService.getWeather(city); + } + + @GetMapping("/api/weather/v2") + public WeatherResponse getWeatherByLlm(@RequestParam("city") String city) { + return weatherService.getWeatherByLlm(city); + } +} \ No newline at end of file diff --git a/src/main/java/com/llm_precourse/weather/domain/City.java b/src/main/java/com/llm_precourse/weather/domain/City.java new file mode 100644 index 00000000..64bb767f --- /dev/null +++ b/src/main/java/com/llm_precourse/weather/domain/City.java @@ -0,0 +1,39 @@ +package com.llm_precourse.weather.domain; + +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +@Getter +@RequiredArgsConstructor +public enum City { + SEOUL("Seoul", "μ„œμšΈ", 37.5665, 126.9780), + TOKYO("Tokyo", "도쿄", 35.6762, 139.6503), + NEW_YORK("NewYork", "λ‰΄μš•", 40.7128, -74.0060), + PARIS("Paris", "파리", 48.8566, 2.3522), + LONDON("London", "런던", 51.5074, -0.1278); + + private static final Map BY_NAME = + Arrays.stream(values()) + .collect( + Collectors.toUnmodifiableMap( + city -> city.cityName.toLowerCase(Locale.ROOT), Function.identity())); + + private final String cityName; + private final String cityNameKr; + private final double latitude; + private final double longitude; + + public static City from(String city) { + if (city == null) { + return null; + } + String normalized = city.trim().toLowerCase(Locale.ROOT); + return BY_NAME.get(normalized); + } +} diff --git a/src/main/java/com/llm_precourse/weather/domain/GeoInfo.java b/src/main/java/com/llm_precourse/weather/domain/GeoInfo.java new file mode 100644 index 00000000..8360efc3 --- /dev/null +++ b/src/main/java/com/llm_precourse/weather/domain/GeoInfo.java @@ -0,0 +1,11 @@ +package com.llm_precourse.weather.domain; + +import lombok.Data; + +@Data +public class GeoInfo { + + private final double latitude; + private final double longitude; + +} diff --git a/src/main/java/com/llm_precourse/weather/domain/WeatherCondition.java b/src/main/java/com/llm_precourse/weather/domain/WeatherCondition.java new file mode 100644 index 00000000..4b08ec69 --- /dev/null +++ b/src/main/java/com/llm_precourse/weather/domain/WeatherCondition.java @@ -0,0 +1,51 @@ +package com.llm_precourse.weather.domain; + +import java.util.Arrays; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum WeatherCondition { + + CLEAR(0, "λ§‘μŒ"), + PARTLY_CLOUDY_1(1, "λΆ€λΆ„μ μœΌλ‘œ 흐림"), + PARTLY_CLOUDY_2(2, "λΆ€λΆ„μ μœΌλ‘œ 흐림"), + PARTLY_CLOUDY_3(3, "λΆ€λΆ„μ μœΌλ‘œ 흐림"), + FOG_45(45, "μ•ˆκ°œ"), + FOG_48(48, "μ•ˆκ°œ"), + DRIZZLE_51(51, "μ΄μŠ¬λΉ„"), + DRIZZLE_53(53, "μ΄μŠ¬λΉ„"), + DRIZZLE_55(55, "μ΄μŠ¬λΉ„"), + RAIN_61(61, "λΉ„"), + RAIN_63(63, "λΉ„"), + RAIN_65(65, "λΉ„"), + SNOW_71(71, "눈"), + SNOW_73(73, "눈"), + SNOW_75(75, "눈"), + SHOWER_80(80, "μ†Œλ‚˜κΈ°"), + SHOWER_81(81, "μ†Œλ‚˜κΈ°"), + SHOWER_82(82, "μ†Œλ‚˜κΈ°"), + THUNDERSTORM_95(95, "λ‡Œμš°"), + UNKNOWN(-1, "μ•Œ 수 μ—†μŒ"); + + private static final Map BY_CODE = + Arrays.stream(values()) + .collect( + Collectors.toUnmodifiableMap( + WeatherCondition::getCode, Function.identity(), (first, second) -> second)); + + private final int code; + private final String description; + + public static String descriptionOf(int code) { + WeatherCondition condition = BY_CODE.get(code); + if (condition == null) { + return UNKNOWN.description; + } + return condition.description; + } +} \ No newline at end of file diff --git a/src/main/java/com/llm_precourse/weather/dto/Clothing.java b/src/main/java/com/llm_precourse/weather/dto/Clothing.java new file mode 100644 index 00000000..7a170f5e --- /dev/null +++ b/src/main/java/com/llm_precourse/weather/dto/Clothing.java @@ -0,0 +1,30 @@ +package com.llm_precourse.weather.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class Clothing { + + // κ²°κ³Όλ₯Ό λ‹΄λŠ” DTO 클래슀 + private final Double temperature; // μ‹€μ œ 기온 (섭씨, nullable) + private final Double feelsLike; // μ²΄κ°μ˜¨λ„ (섭씨, nullable) + private final String top; // μƒμ˜ + private final String bottom; // ν•˜μ˜ + private final String outer; // κ²‰μ˜· + private final String accessories; // μ•‘μ„Έμ„œλ¦¬/기타 + private final String message; // μΆ”κ°€ λ©”μ‹œμ§€ + + + @Override + public String toString() { + return "ν˜„μž¬ 기온: " + temperature + "Β°C, μ²΄κ°μ˜¨λ„: " + feelsLike + "Β°C\n" + + "[μƒμ˜] " + top + "\n" + + "[ν•˜μ˜] " + bottom + "\n" + + "[κ²‰μ˜·] " + outer + "\n" + + "[μ•‘μ„Έμ„œλ¦¬] " + accessories + "\n" + + "[λ©”μ‹œμ§€] " + message; + } + +} diff --git a/src/main/java/com/llm_precourse/weather/dto/ErrorResponse.java b/src/main/java/com/llm_precourse/weather/dto/ErrorResponse.java new file mode 100644 index 00000000..dd59596b --- /dev/null +++ b/src/main/java/com/llm_precourse/weather/dto/ErrorResponse.java @@ -0,0 +1,17 @@ +package com.llm_precourse.weather.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class ErrorResponse { + + private final String code; + private final String message; + private final LocalDateTime timestamp; + private final String path; + +} diff --git a/src/main/java/com/llm_precourse/weather/dto/OpenMeteoResponse.java b/src/main/java/com/llm_precourse/weather/dto/OpenMeteoResponse.java new file mode 100644 index 00000000..959907a5 --- /dev/null +++ b/src/main/java/com/llm_precourse/weather/dto/OpenMeteoResponse.java @@ -0,0 +1,39 @@ +package com.llm_precourse.weather.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class OpenMeteoResponse { + + @JsonProperty("latitude") + private double latitude; + + @JsonProperty("longitude") + private double longitude; + + @JsonProperty("current") + private Current current; + + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class Current { + + @JsonProperty("temperature_2m") + private double temperature; + + @JsonProperty("apparent_temperature") + private double apparentTemperature; + + @JsonProperty("relative_humidity_2m") + private double humidity; + + @JsonProperty("weather_code") + private int weatherCode; + } +} diff --git a/src/main/java/com/llm_precourse/weather/dto/WeatherResponse.java b/src/main/java/com/llm_precourse/weather/dto/WeatherResponse.java new file mode 100644 index 00000000..9e71b069 --- /dev/null +++ b/src/main/java/com/llm_precourse/weather/dto/WeatherResponse.java @@ -0,0 +1,20 @@ +package com.llm_precourse.weather.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class WeatherResponse { + + private final String city; + private final double temperature; + private final double feelsLike; + private final String condition; + private final double humidity; + private final String summary; + + private final String recommendedClothes; +} diff --git a/src/main/java/com/llm_precourse/weather/exception/ExternalApiException.java b/src/main/java/com/llm_precourse/weather/exception/ExternalApiException.java new file mode 100644 index 00000000..48ffbff2 --- /dev/null +++ b/src/main/java/com/llm_precourse/weather/exception/ExternalApiException.java @@ -0,0 +1,8 @@ +package com.llm_precourse.weather.exception; + +import lombok.experimental.StandardException; + +@StandardException +public class ExternalApiException extends RuntimeException { + +} \ No newline at end of file diff --git a/src/main/java/com/llm_precourse/weather/exception/InvalidInputException.java b/src/main/java/com/llm_precourse/weather/exception/InvalidInputException.java new file mode 100644 index 00000000..e205be26 --- /dev/null +++ b/src/main/java/com/llm_precourse/weather/exception/InvalidInputException.java @@ -0,0 +1,7 @@ +package com.llm_precourse.weather.exception; + +import lombok.experimental.StandardException; + +@StandardException +public class InvalidInputException extends RuntimeException { +} diff --git a/src/main/java/com/llm_precourse/weather/exception/InvalidRequestException.java b/src/main/java/com/llm_precourse/weather/exception/InvalidRequestException.java new file mode 100644 index 00000000..687a25b3 --- /dev/null +++ b/src/main/java/com/llm_precourse/weather/exception/InvalidRequestException.java @@ -0,0 +1,8 @@ +package com.llm_precourse.weather.exception; + +import lombok.experimental.StandardException; + +@StandardException +public class InvalidRequestException extends RuntimeException { + +} diff --git a/src/main/java/com/llm_precourse/weather/exception/UnsupportedCityException.java b/src/main/java/com/llm_precourse/weather/exception/UnsupportedCityException.java new file mode 100644 index 00000000..7fda088a --- /dev/null +++ b/src/main/java/com/llm_precourse/weather/exception/UnsupportedCityException.java @@ -0,0 +1,8 @@ +package com.llm_precourse.weather.exception; + +import lombok.experimental.StandardException; + +@StandardException +public class UnsupportedCityException extends RuntimeException { + +} \ No newline at end of file diff --git a/src/main/java/com/llm_precourse/weather/service/WeatherService.java b/src/main/java/com/llm_precourse/weather/service/WeatherService.java new file mode 100644 index 00000000..01a7c59b --- /dev/null +++ b/src/main/java/com/llm_precourse/weather/service/WeatherService.java @@ -0,0 +1,334 @@ +package com.llm_precourse.weather.service; + +import com.llm_precourse.weather.common.OpenMeteoClient; +import com.llm_precourse.weather.domain.City; +import com.llm_precourse.weather.domain.WeatherCondition; +import com.llm_precourse.weather.dto.Clothing; +import com.llm_precourse.weather.dto.OpenMeteoResponse; +import com.llm_precourse.weather.dto.WeatherResponse; +import com.llm_precourse.weather.exception.InvalidInputException; +import com.llm_precourse.weather.exception.InvalidRequestException; +import com.llm_precourse.weather.exception.UnsupportedCityException; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.ai.chat.prompt.SystemPromptTemplate; +import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import com.llm_precourse.weather.domain.GeoInfo; + +@Service +@Slf4j +public class WeatherService { + + private final OpenMeteoClient openMeteoClient; + private final ChatClient client; + + public WeatherService(OpenMeteoClient openMeteoClient, ChatClient.Builder builder) { + this.openMeteoClient = openMeteoClient; + this.client = builder.build(); + } + //private ChatClient client = ChatClient.Builder.build(); + + public WeatherResponse getWeather(String city) { + validateCityParameter(city); + + City supportedCity = resolveSupportedCity(city); + OpenMeteoResponse response = fetchWeather(supportedCity); + OpenMeteoResponse.Current current = extractCurrentWeather(response); + + return buildWeatherResponse(supportedCity, current); + } + + public WeatherResponse getWeatherByLlm(String query) { + //query의 μ’…λ₯˜λŠ” λ„μ‹œ/ꡬ/κ΅°/κΆŒμ—­ λ“± + validateCityParameter(query); + try { + return this.getWeather(query); + } catch(UnsupportedCityException uce) { + log.info("DB에 μ—†λŠ” λ„μ‹œμž…λ‹ˆλ‹€."); + } + + GeoInfo geoInfo = resolveSupportedCityByLlm(query); + + OpenMeteoResponse response = fetchWeather(geoInfo); + + OpenMeteoResponse.Current current = extractCurrentWeather(response); + + return buildWeatherResponseWithRecommendClothing(current, query); + } + + private void validateCityParameter(String city) { + if (!StringUtils.hasText(city)) { + throw new InvalidRequestException("city νŒŒλΌλ―Έν„°λŠ” ν•„μˆ˜μž…λ‹ˆλ‹€."); + } + } + + private City resolveSupportedCity(String city) { + City supportedCity = City.from(city); + if (supportedCity == null) { + throw new UnsupportedCityException( + "μ§€μ›ν•˜μ§€ μ•ŠλŠ” λ„μ‹œμž…λ‹ˆλ‹€. (Seoul, Tokyo, NewYork, Paris, London만 지원)"); + } + return supportedCity; + } + + private GeoInfo resolveSupportedCityByLlm(String query) { + var beanOutputConverter = new BeanOutputConverter<>(GeoInfo.class); + var format = beanOutputConverter.getFormat(); + log.info(format); + var systemPromptTemplate = new SystemPromptTemplate(""" + You are a geocoding assistant. + Goal: + - The user provides a city name, region name, or similar location descriptor. + - You must respond with the geographic coordinates (latitude and longitude) of that location. + Rules: + - latitude, longitude ν•„λ“œλŠ” λ°˜λ“œμ‹œ 숫자 νƒ€μž…μ΄μ–΄μ•Ό ν•œλ‹€ (λ¬Έμžμ—΄ X). + - ν•œκ΅­μ–΄λ₯Ό κΈ°λ³Έ μ–Έμ–΄λ‘œ μ‚¬μš©ν•˜λ˜, κ΅­κ°€λͺ… 등은 ν•„μš”ν•˜λ‹€λ©΄ μ˜μ–΄λ‘œ μ“Έ 수 μžˆλ‹€. + Ambiguity: + - If the input is ambiguous (e.g., "Paris"), and you choose the most common one (e.g., Paris, France), fill "note" with a short explanation in Korean (e.g., "κ°€μž₯ 일반적인 파리(ν”„λž‘μŠ€ κΈ°μ€€)둜 κ°€μ •ν–ˆμŠ΅λ‹ˆλ‹€."). + """); + var userStr = """ + {query}의 μœ„μΉ˜μ΄λ¦„, μœ„λ„, 경도λ₯Ό μ•Œλ €μ€˜. + {format} + """; + + var system = systemPromptTemplate.createMessage(); + var userMessage = new PromptTemplate(userStr).createMessage(Map.of("query", query, "format", format)); + var prompt = new Prompt(userMessage, system); + + var chatResponse = client.prompt(prompt).call().chatResponse(); + var usage = chatResponse.getMetadata().getUsage(); + var promptTokens = usage.getPromptTokens(); + var completionTokens = usage.getCompletionTokens(); + var totalTokens = usage.getTotalTokens(); + var text = chatResponse.getResult().getOutput().getText(); + + log.info(">> Prompt Tokens = {}", promptTokens); + log.info(">> Completion Tokens = {}", completionTokens); + log.info(">> Total Tokens = {}", totalTokens); + log.info(">> Output Text = {}", text); + + GeoInfo geoInfo = beanOutputConverter.convert(text); + + if (geoInfo == null) { + throw new UnsupportedCityException( + "μ§€μ›ν•˜μ§€ μ•ŠλŠ” λ„μ‹œμž…λ‹ˆλ‹€. (Seoul, Tokyo, NewYork, Paris, London만 지원)"); + } + return geoInfo; + } + + private OpenMeteoResponse fetchWeather(City supportedCity) { + OpenMeteoResponse response = + openMeteoClient.getCurrentWeather( + supportedCity.getLatitude(), supportedCity.getLongitude()); + + if (response == null || response.getCurrent() == null) { + throw new UnsupportedCityException("날씨 정보λ₯Ό μ‘°νšŒν•  수 μ—†μŠ΅λ‹ˆλ‹€."); + } + return response; + } + + private OpenMeteoResponse fetchWeather(GeoInfo geoInfo) { + OpenMeteoResponse response = + openMeteoClient.getCurrentWeather( + geoInfo.getLatitude(), geoInfo.getLongitude()); + + if (response == null || response.getCurrent() == null) { + throw new UnsupportedCityException("날씨 정보λ₯Ό μ‘°νšŒν•  수 μ—†μŠ΅λ‹ˆλ‹€."); + } + return response; + } + + private OpenMeteoResponse.Current extractCurrentWeather(OpenMeteoResponse response) { + return response.getCurrent(); + } + + private WeatherResponse buildWeatherResponse( + City supportedCity, OpenMeteoResponse.Current current) { + + double temperature = current.getTemperature(); + double feelsLike = current.getApparentTemperature(); + double humidity = current.getHumidity(); + int weatherCode = current.getWeatherCode(); + String conditionKr = WeatherCondition.descriptionOf(weatherCode); + + String summary = + String.format( + "ν˜„μž¬ %s의 κΈ°μ˜¨μ€ %.1f℃이고, 체감 μ˜¨λ„λŠ” %.1fβ„ƒμž…λ‹ˆλ‹€. ν•˜λŠ˜ μƒνƒœλŠ” %s이며, μŠ΅λ„λŠ” %.0f%%μž…λ‹ˆλ‹€.", + supportedCity.getCityNameKr(), temperature, feelsLike, conditionKr, humidity); + + return WeatherResponse.builder() + .city(supportedCity.getCityName()) + .temperature(temperature) + .feelsLike(feelsLike) + .condition(conditionKr) + .humidity(humidity) + .summary(summary) + .build(); + } + + private WeatherResponse buildWeatherResponseWithRecommendClothing(OpenMeteoResponse.Current current, String locationName) { + + double temperature = current.getTemperature(); + double feelsLike = current.getApparentTemperature(); + double humidity = current.getHumidity(); + int weatherCode = current.getWeatherCode(); + String conditionKr = WeatherCondition.descriptionOf(weatherCode); + + String summary = + String.format( + "ν˜„μž¬ %s의 κΈ°μ˜¨μ€ %.1f℃이고, 체감 μ˜¨λ„λŠ” %.1fβ„ƒμž…λ‹ˆλ‹€. ν•˜λŠ˜ μƒνƒœλŠ” %s이며, μŠ΅λ„λŠ” %.0f%%μž…λ‹ˆλ‹€.", + locationName, temperature, feelsLike, conditionKr, humidity); + + Clothing recommendedClothing = recommendClothing(temperature, feelsLike); + + String recommend = + String.format( + "였늘 μ²΄κ°μ˜¨λ„μ™€ κΈ°μ˜¨μ— 따라 μ™Ένˆ¬λŠ” %s, μƒμ˜λŠ” %s, ν•˜μ˜λŠ” %s μΆ”μ²œλ“œλ €μš”.\n* %s", + recommendedClothing.getOuter(), recommendedClothing.getTop(), recommendedClothing.getBottom(), recommendedClothing.getMessage()); + + return WeatherResponse.builder() + .city(locationName) + .temperature(temperature) + .feelsLike(feelsLike) + .condition(conditionKr) + .humidity(humidity) + .summary(summary) + .recommendedClothes(recommend) + .build(); + } + + /** + * μ²΄κ°μ˜¨λ„(feelsLike)λ₯Ό κΈ°μ€€μœΌλ‘œ κΈ°λ³Έ 볡μž₯을 μΆ”μ²œ + */ + private static String[] baseOutfitByFeelsLike(double feelsLike) { + String top; + String bottom; + String outer; + String accessories; + + // 맀우 좔움 (μ²΄κ°μ˜¨λ„ ≀ -5Β°C) + if (feelsLike <= -5) { + top = "λ‘κΊΌμš΄ λ‹ˆνŠΈ λ˜λŠ” κΈ°λͺ¨ μ΄λ„ˆ + ν‹°μ…”μΈ "; + bottom = "κΈ°λͺ¨ λ°”μ§€ λ˜λŠ” λ‘κΊΌμš΄ μŠ¬λž™μŠ€"; + outer = "λ‘κΊΌμš΄ λ‘±νŒ¨λ”© λ˜λŠ” ν—€λΉ„ μ½”νŠΈ"; + accessories = "λͺ©λ„리, μž₯κ°‘, κ·€λ§ˆκ°œ, λ‘κΊΌμš΄ 양말"; + } + // 좔움 (-4Β°C ~ 3Β°C) + else if (feelsLike >= -4 && feelsLike <= 3) { + top = "λ‹ˆνŠΈ λ˜λŠ” ν›„λ“œν‹° + μ΄λ„ˆν‹°"; + bottom = "κΈ°λͺ¨ λ°”μ§€ λ˜λŠ” λ‘κ»˜κ° μžˆλŠ” λ°”μ§€"; + outer = "νŒ¨λ”© λ˜λŠ” λ‘κΊΌμš΄ μ½”νŠΈ"; + accessories = "μž₯κ°‘, λͺ©λ„리(μΆ”μœ„λ₯Ό 잘 타면), λ”°λœ»ν•œ 양말"; + } + // μŒ€μŒ€ν•¨ (4Β°C ~ 9Β°C) + else if (feelsLike >= 4 && feelsLike <= 9) { + top = "λ‹ˆνŠΈ, 맨투맨, μ…”μΈ  + 가디건 쀑 택1"; + bottom = "일반 λ‘κ»˜μ˜ μ²­λ°”μ§€ λ˜λŠ” μŠ¬λž™μŠ€"; + outer = "울 μ½”νŠΈ, 점퍼, κ°€λ²Όμš΄ νŒ¨λ”© 쀑 택1"; + accessories = "ν•„μš”μ‹œ λͺ©λ„리, 얇은 양말"; + } + // 선선함 (10Β°C ~ 16Β°C) + else if (feelsLike >= 10 && feelsLike <= 16) { + top = "얇은 λ‹ˆνŠΈ, 가디건, 맨투맨"; + bottom = "μ²­λ°”μ§€, μŠ¬λž™μŠ€ λ“± 일반 λ°”μ§€"; + outer = "νŠΈλ ŒμΉ˜μ½”νŠΈ, λ°”λžŒλ§‰μ΄, 얇은 점퍼 쀑 택1"; + accessories = "κ²‰μ˜·μ€ 상황에 따라 선택"; + } + // 적당함 (17Β°C ~ 22Β°C) + else if (feelsLike >= 17 && feelsLike <= 22) { + top = "κΈ΄νŒ” ν‹°, 얇은 μ…”μΈ , 얇은 가디건"; + bottom = "μ²­λ°”μ§€, μŠ¬λž™μŠ€, 둱슀컀트 λ“±"; + outer = "κ²‰μ˜·μ€ 얇은 가디건 μ •λ„λ§Œ μ„ νƒμ μœΌλ‘œ"; + accessories = "μ‹€λ‚΄ 에어컨을 κ³ λ €ν•΄ 얇은 κ²‰μ˜· ν•˜λ‚˜ μ±™κΈ°κΈ° μ’‹μŒ"; + } + // λ”°λœ»ν•¨ (23Β°C ~ 26Β°C) + else if (feelsLike >= 23 && feelsLike <= 26) { + top = "λ°˜νŒ” ν‹°, 얇은 μ…”μΈ , μ›ν”ΌμŠ€"; + bottom = "얇은 λ°”μ§€λ‚˜ 슀컀트"; + outer = "λŒ€λΆ€λΆ„ λΆˆν•„μš”, μ•„μ£Ό 얇은 μ…”μΈ  정도"; + accessories = "햇빛이 κ°•ν•˜λ©΄ λͺ¨μž, 선크림"; + } + // 더움 (27Β°C ~ 29Β°C) + else if (feelsLike >= 27 && feelsLike <= 29) { + top = "λ°˜νŒ” λ˜λŠ” λ―Όμ†Œλ§€"; + bottom = "λ°˜λ°”μ§€, 얇은 μ›ν”ΌμŠ€"; + outer = "λΆˆν•„μš”"; + accessories = "톡풍 잘 λ˜λŠ” μ†Œμž¬(린넨, λ©΄), 선크림, λͺ¨μž"; + } + // 맀우 더움 (μ²΄κ°μ˜¨λ„ β‰₯ 30Β°C) + else { + top = "μ•„μ£Ό 얇은 λ°˜νŒ” λ˜λŠ” λ―Όμ†Œλ§€"; + bottom = "λ°˜λ°”μ§€, 얇은 슀컀트/μ›ν”ΌμŠ€"; + outer = "λΆˆν•„μš”"; + accessories = "λͺ¨μž, μ„ κΈ€λΌμŠ€, 선크림, μΆ©λΆ„ν•œ μˆ˜λΆ„ μ„­μ·¨"; + } + + return new String[]{top, bottom, outer, accessories}; + } + + /** + * μ˜¨λ„/μ²΄κ°μ˜¨λ„λ₯Ό μž…λ ₯λ°›μ•„ 볡μž₯을 μΆ”μ²œν•˜λŠ” 메인 λ©”μ†Œλ“œ + */ + private static Clothing recommendClothing( + Double temperature, // μ‹€μ œ 기온 (nullable) + Double feelsLike // μ²΄κ°μ˜¨λ„ (nullable) + ) { + // μ˜¨λ„ 정보가 μ „ν˜€ 없을 λ•Œ + if (temperature == null && feelsLike == null) { + throw new InvalidInputException("기온 λ˜λŠ” μ²΄κ°μ˜¨λ„λ₯Ό μž…λ ₯ν•˜λ©΄ 볡μž₯을 μΆ”μ²œν•΄ λ“œλ¦΄ 수 μžˆμŠ΅λ‹ˆλ‹€."); + } + + // λΉ„ν˜„μ‹€μ μΈ κ°’ 체크 (μ„ νƒμ μœΌλ‘œ μ‘°μ • κ°€λŠ₯) + Double[] values = {temperature, feelsLike}; + for (Double v : values) { + if (v != null && (v < -60 || v > 60)) { + throw new InvalidInputException("μž…λ ₯된 μ˜¨λ„κ°€ λΉ„ν˜„μ‹€μ μΈ κ°’ κ°™μŠ΅λ‹ˆλ‹€. 섭씨(Β°C) κΈ°μ€€μœΌλ‘œ λ‹€μ‹œ μž…λ ₯ν•΄ μ£Όμ„Έμš”."); + } + } + + // μ²΄κ°μ˜¨λ„ μš°μ„ , μ—†μœΌλ©΄ μ‹€μ œ 기온 μ‚¬μš© + double usedFeelsLike = (feelsLike != null) ? feelsLike : temperature; + + String[] baseOutfit = baseOutfitByFeelsLike(usedFeelsLike); + String top = baseOutfit[0]; + String bottom = baseOutfit[1]; + String outer = baseOutfit[2]; + String accessories = baseOutfit[3]; + + // μ‹€μ œ 기온 vs μ²΄κ°μ˜¨λ„ 차이에 λŒ€ν•œ λ©”μ‹œμ§€ + String tempDiffMsg = ""; + if (temperature != null && feelsLike != null) { + double diff = feelsLike - temperature; + if (Math.abs(diff) >= 3) { + String direction = diff < 0 ? "더 μΆ₯게" : "더 λ₯게"; + tempDiffMsg = String.format( + "μ‹€μ œ κΈ°μ˜¨λ³΄λ‹€ μ²΄κ°μ˜¨λ„κ°€ μ•½ %.1fΒ°C 정도 %s 느껴질 수 μžˆμŠ΅λ‹ˆλ‹€. 볡μž₯은 μ²΄κ°μ˜¨λ„ κΈ°μ€€μœΌλ‘œ μΆ”μ²œν–ˆμŠ΅λ‹ˆλ‹€.", + Math.abs(diff), direction + ); + } + } + + String message; + if (tempDiffMsg.isEmpty()) { + message = "μ²΄κ°μ˜¨λ„λ₯Ό κΈ°μ€€μœΌλ‘œ 일상적인 볡μž₯을 μΆ”μ²œν–ˆμŠ΅λ‹ˆλ‹€."; + } else { + message = tempDiffMsg; + } + + return new Clothing( + temperature, + feelsLike, + top, + bottom, + outer, + accessories, + message + ); + } + +} diff --git a/src/main/java/study/Application.java b/src/main/java/study/Application.java new file mode 100644 index 00000000..b9e813e7 --- /dev/null +++ b/src/main/java/study/Application.java @@ -0,0 +1,14 @@ +package study; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +//@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + + +} diff --git a/src/main/java/study/common/Functions.java b/src/main/java/study/common/Functions.java new file mode 100644 index 00000000..a43ad109 --- /dev/null +++ b/src/main/java/study/common/Functions.java @@ -0,0 +1,30 @@ +package study.common; + +import java.time.LocalDate; +import lombok.NoArgsConstructor; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.context.annotation.Configuration; +import study.dto.AddDayRequest; +import study.dto.DateResponse; + +@Configuration +@NoArgsConstructor +public class Functions { + + /*@Bean + @Description("Calculate a date after adding days from today") + public Function addDaysFromToday() { + return request -> { + LocalDate result = LocalDate.now().plusDays(request.days()); + return new DateResponse(result.toString()); + }; + }*/ + + @Tool(description = "Calculate a date after adding days from today") + public DateResponse addDaysFromToday(AddDayRequest request) { + var result= LocalDate.now().plusDays(request.days()); + return new DateResponse(result.toString()); + } + +} + diff --git a/src/main/java/study/controller/JokeController.java b/src/main/java/study/controller/JokeController.java new file mode 100644 index 00000000..1db49779 --- /dev/null +++ b/src/main/java/study/controller/JokeController.java @@ -0,0 +1,76 @@ +package study.controller; + +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.ai.chat.prompt.SystemPromptTemplate; +import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import study.common.Functions; +import study.dto.ActorFilms; + +@RestController +@Slf4j +public class JokeController { + private final ChatClient client; + + public JokeController(ChatClient.Builder builder) { + this.client = builder.build(); + } + + @GetMapping("/joke") + public ChatResponse joke(@RequestParam(defaultValue = "Mi-mi") String name, @RequestParam(defaultValue = "pirate") String voice) { + var user = new UserMessage(""" + Tell me about three famous pirates from the Golden Age of Piracy and what they did. + Write at least one sentence for each pirate. + """); + var template = new SystemPromptTemplate(""" + You are a helpful AI assistant. + You are an AI assistant that helps people find information. + Your name is {name}. + You should reply to the user's request using your name and in the style of a {voice}. + """); + var system = template.createMessage(Map.of("name", name, "voice", voice)); + var prompt = new Prompt(user, system); + return client.prompt(prompt).call().chatResponse(); + } + + @GetMapping("/joke2") + public ChatResponse joke2(@RequestParam(defaultValue = "Tell me a joke about {topic}") String message, @RequestParam(defaultValue = "Programming") String topic) { + //μ‚¬μš©μžμ˜ μž…λ ₯으둜 AIλ₯Ό ν˜ΈμΆœν•˜λ©΄, AI prompt injection 곡격에 μ·¨μ•½ν•˜κ³ , κ³ΌκΈˆλ„ 많이 될 수 μžˆμœΌλ―€λ‘œ 주의 + PromptTemplate template = new PromptTemplate(message); + String prompt = template.render(Map.of("topic", topic)); + return client.prompt(prompt).call().chatResponse(); + } + + @GetMapping("/actors") + public ActorFilms actors(@RequestParam(defaultValue = "Brad Pitt") String actor) { + var beanOutputConverter = new BeanOutputConverter<>(ActorFilms.class); + var format = beanOutputConverter.getFormat(); + log.info(format); + var userMessage = """ + Generate the filmography of 5 movies for {actor}. + {format} + """; + var promptTemplate = new PromptTemplate(userMessage); + var prompt = promptTemplate.create(Map.of("actor", actor, "format", format)); + var text = client.prompt(prompt).call().content(); + log.info(text); + + return beanOutputConverter.convert(text); + } + + @GetMapping("/addDays") + public String addDays(@RequestParam(defaultValue = "0") int days) { + var template = new PromptTemplate("였늘 κΈ°μ€€μœΌλ‘œ {days}일 λ’€ λ‚ μ§œλ₯Ό μ•Œλ € 쀘."); + var prompt = template.render(Map.of("days", days)); + return client.prompt(prompt).tools(new Functions()).call().content(); + } + +} diff --git a/src/main/java/study/dto/ActorFilms.java b/src/main/java/study/dto/ActorFilms.java new file mode 100644 index 00000000..4b4dac6b --- /dev/null +++ b/src/main/java/study/dto/ActorFilms.java @@ -0,0 +1,10 @@ +package study.dto; + +import lombok.Data; + +@Data +public class ActorFilms { + String actor; + String[] movies; + +} diff --git a/src/main/java/study/dto/AddDayRequest.java b/src/main/java/study/dto/AddDayRequest.java new file mode 100644 index 00000000..5b15d39c --- /dev/null +++ b/src/main/java/study/dto/AddDayRequest.java @@ -0,0 +1,5 @@ +package study.dto; + +public record AddDayRequest(int days) { + +} diff --git a/src/main/java/study/dto/DateResponse.java b/src/main/java/study/dto/DateResponse.java new file mode 100644 index 00000000..8bddf0f3 --- /dev/null +++ b/src/main/java/study/dto/DateResponse.java @@ -0,0 +1,5 @@ +package study.dto; + +public record DateResponse(String date) { + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 00000000..ea5e933b --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,2 @@ +spring.ai.google.genai.api-key= +spring.ai.google.genai.chat.options.model=gemini-2.5-flash-lite diff --git a/src/test/java/com/llm_precourse/weather/controller/WeatherControllerTest.java b/src/test/java/com/llm_precourse/weather/controller/WeatherControllerTest.java new file mode 100644 index 00000000..d37793a9 --- /dev/null +++ b/src/test/java/com/llm_precourse/weather/controller/WeatherControllerTest.java @@ -0,0 +1,101 @@ +package com.llm_precourse.weather.controller; + +import com.llm_precourse.weather.dto.WeatherResponse; +import com.llm_precourse.weather.exception.ExternalApiException; +import com.llm_precourse.weather.exception.InvalidRequestException; +import com.llm_precourse.weather.exception.UnsupportedCityException; +import com.llm_precourse.weather.service.WeatherService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(controllers = WeatherController.class) +class WeatherControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private WeatherService weatherService; + + @Test + @DisplayName("정상적인 city νŒŒλΌλ―Έν„°λ‘œ μš”μ²­ μ‹œ 200 OK와 WeatherResponseλ₯Ό λ°˜ν™˜ν•œλ‹€") + void getWeather_success() throws Exception { + // given + WeatherResponse response = WeatherResponse.builder() + .city("Seoul") + .temperature(3.4) + .feelsLike(1.2) + .condition("λΆ€λΆ„μ μœΌλ‘œ 흐림") + .humidity(65.0) + .summary("ν˜„μž¬ μ„œμšΈμ˜ κΈ°μ˜¨μ€ 3.4℃이고, 체감 μ˜¨λ„λŠ” 1.2β„ƒμž…λ‹ˆλ‹€. ν•˜λŠ˜ μƒνƒœλŠ” λΆ€λΆ„μ μœΌλ‘œ 흐림이며, μŠ΅λ„λŠ” 65%μž…λ‹ˆλ‹€.") + .build(); + + Mockito.when(weatherService.getWeather("Seoul")) + .thenReturn(response); + + // when & then + mockMvc.perform(get("/api/weather") + .param("city", "Seoul")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.city").value("Seoul")) + .andExpect(jsonPath("$.temperature").value(3.4)) + .andExpect(jsonPath("$.feelsLike").value(1.2)) + .andExpect(jsonPath("$.condition").value("λΆ€λΆ„μ μœΌλ‘œ 흐림")) + .andExpect(jsonPath("$.humidity").value(65.0)) + .andExpect(jsonPath("$.summary").exists()); + } + + @Test + @DisplayName("WeatherServiceμ—μ„œ InvalidRequestException이 λ°œμƒν•˜λ©΄ 400κ³Ό INVALID_REQUEST μ½”λ“œλ₯Ό λ°˜ν™˜ν•œλ‹€") + void getWeather_invalidRequest() throws Exception { + // given + Mockito.when(weatherService.getWeather(anyString())) + .thenThrow(new InvalidRequestException("city νŒŒλΌλ―Έν„°λŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.")); + + // when & then + mockMvc.perform(get("/api/weather") + .param("city", "")) // 빈 λ¬Έμžμ—΄λ‘œ 호좜 + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST")) + .andExpect(jsonPath("$.message").value("city νŒŒλΌλ―Έν„°λŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.")); + } + + @Test + @DisplayName("μ§€μ›ν•˜μ§€ μ•ŠλŠ” λ„μ‹œμΌ 경우 400κ³Ό CITY_NOT_SUPPORTED μ½”λ“œλ₯Ό λ°˜ν™˜ν•œλ‹€") + void getWeather_unsupportedCity() throws Exception { + // given + Mockito.when(weatherService.getWeather("Busan")) + .thenThrow(new UnsupportedCityException("μ§€μ›ν•˜μ§€ μ•ŠλŠ” λ„μ‹œμž…λ‹ˆλ‹€.")); + + // when & then + mockMvc.perform(get("/api/weather") + .param("city", "Busan")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("CITY_NOT_SUPPORTED")) + .andExpect(jsonPath("$.message").value("μ§€μ›ν•˜μ§€ μ•ŠλŠ” λ„μ‹œμž…λ‹ˆλ‹€.")); + } + + @Test + @DisplayName("μ™ΈλΆ€ API μ—λŸ¬κ°€ λ°œμƒν•˜λ©΄ 502와 EXTERNAL_API_ERROR μ½”λ“œλ₯Ό λ°˜ν™˜ν•œλ‹€") + void getWeather_externalApiError() throws Exception { + // given + Mockito.when(weatherService.getWeather("Seoul")) + .thenThrow(new ExternalApiException("μ™ΈλΆ€ 날씨 API 호좜 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.")); + + // when & then + mockMvc.perform(get("/api/weather") + .param("city", "Seoul")) + .andExpect(status().isBadGateway()) + .andExpect(jsonPath("$.code").value("EXTERNAL_API_ERROR")) + .andExpect(jsonPath("$.message").value("μ™ΈλΆ€ 날씨 API 호좜 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.")); + } +} \ No newline at end of file diff --git a/src/test/java/com/llm_precourse/weather/domain/CityTest.java b/src/test/java/com/llm_precourse/weather/domain/CityTest.java new file mode 100644 index 00000000..53387cef --- /dev/null +++ b/src/test/java/com/llm_precourse/weather/domain/CityTest.java @@ -0,0 +1,58 @@ +package com.llm_precourse.weather.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CityTest { + + @Test + @DisplayName("λ„μ‹œ μ΄λ¦„μœΌλ‘œ SupportedCityλ₯Ό μ •μƒμ μœΌλ‘œ μ°ΎλŠ”λ‹€") + void from_validCityName() { + // given + String city = "Seoul"; + + // when + City result = City.from(city); + + // then + assertThat(result).isEqualTo(City.SEOUL); + assertThat(result.getLatitude()).isEqualTo(37.5665); + assertThat(result.getLongitude()).isEqualTo(126.9780); + } + + @Test + @DisplayName("λŒ€μ†Œλ¬Έμžλ₯Ό κ΅¬λΆ„ν•˜μ§€ μ•Šκ³  λ„μ‹œλ₯Ό 찾을 수 μžˆλ‹€") + void from_ignoreCase() { + // given + String city = "seOul"; + + // when + City result = City.from(city); + + // then + assertThat(result).isEqualTo(City.SEOUL); + } + + @Test + @DisplayName("μ§€μ›ν•˜μ§€ μ•ŠλŠ” λ„μ‹œλŠ” null을 λ°˜ν™˜ν•œλ‹€") + void from_unsupportedCity() { + // given + String city = "Busan"; + + // when + City result = City.from(city); + + // then + assertThat(result).isNull(); + } + + @Test + @DisplayName("null λ˜λŠ” 곡백 λ¬Έμžμ—΄μ€ null을 λ°˜ν™˜ν•œλ‹€") + void from_nullOrBlank() { + assertThat(City.from(null)).isNull(); + assertThat(City.from("")).isNull(); + assertThat(City.from(" ")).isNull(); + } +} diff --git a/src/test/java/com/llm_precourse/weather/service/WeatherServiceTest.java b/src/test/java/com/llm_precourse/weather/service/WeatherServiceTest.java new file mode 100644 index 00000000..0acfbb0b --- /dev/null +++ b/src/test/java/com/llm_precourse/weather/service/WeatherServiceTest.java @@ -0,0 +1,90 @@ +package com.llm_precourse.weather.service; + +import com.llm_precourse.weather.common.OpenMeteoClient; +import com.llm_precourse.weather.domain.City; +import com.llm_precourse.weather.dto.OpenMeteoResponse; +import com.llm_precourse.weather.dto.WeatherResponse; +import com.llm_precourse.weather.exception.InvalidRequestException; +import com.llm_precourse.weather.exception.UnsupportedCityException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +class WeatherServiceTest { + + /** + * ν…ŒμŠ€νŠΈμš© κ°€μ§œ OpenMeteoClient - μ‹€μ œ HTTP 호좜 없이, μš°λ¦¬κ°€ μ›ν•˜λŠ” OpenMeteoResponse만 λŒλ €μ€€λ‹€. + */ + private static class FakeOpenMeteoClient extends OpenMeteoClient { + + public FakeOpenMeteoClient() { + super(null); // λΆ€λͺ¨ μƒμ„±μžμ— WebClientκ°€ ν•„μš”ν•˜μ§€λ§Œ, ν…ŒμŠ€νŠΈμ—μ„œλŠ” μ‚¬μš©ν•˜μ§€ μ•ŠμœΌλ―€λ‘œ null 전달 + } + + @Override + public OpenMeteoResponse getCurrentWeather(double latitude, double longitude) { + // latitude/longitude κ°€ SEOUL κ³Ό μΌμΉ˜ν•œλ‹€κ³  κ°€μ • + OpenMeteoResponse.Current current = new OpenMeteoResponse.Current( + 3.4, // temperature + 1.2, // apparentTemperature + 65.0, // humidity + 3 // weatherCode (λΆ€λΆ„μ μœΌλ‘œ 흐림) + ); + return new OpenMeteoResponse(latitude, longitude, current); + } + } + + /*@Test + @DisplayName("정상적인 λ„μ‹œ 이름에 λŒ€ν•΄ 날씨 정보와 μš”μ•½ λ¬Έμž₯을 μƒμ„±ν•œλ‹€") + void getWeather_success() { + // given + FakeOpenMeteoClient fakeClient = new FakeOpenMeteoClient(); + WeatherService weatherService = new WeatherService(fakeClient); + + // when + WeatherResponse response = weatherService.getWeather("Seoul"); + + // then + assertThat(response.getCity()).isEqualTo(City.SEOUL.getCityName()); + assertThat(response.getTemperature()).isEqualTo(3.4); + assertThat(response.getFeelsLike()).isEqualTo(1.2); + assertThat(response.getHumidity()).isEqualTo(65.0); + assertThat(response.getCondition()).isNotBlank(); + + // μš”μ•½ λ¬Έμž₯ 검증 (λŒ€λž΅μ μΈ ν˜•νƒœ) + assertThat(response.getSummary()) + .contains("ν˜„μž¬") + .contains("μ„œμšΈ") + .contains("κΈ°μ˜¨μ€ 3.4") + .contains("체감 μ˜¨λ„λŠ” 1.2") + .contains("μŠ΅λ„λŠ” 65"); + } + + @Test + @DisplayName("city νŒŒλΌλ―Έν„°κ°€ λΉ„μ–΄ 있으면 InvalidRequestException 이 λ°œμƒν•œλ‹€") + void getWeather_invalidCityParam() { + FakeOpenMeteoClient fakeClient = new FakeOpenMeteoClient(); + WeatherService weatherService = new WeatherService(fakeClient); + + assertThatThrownBy(() -> weatherService.getWeather(null)) + .isInstanceOf(InvalidRequestException.class); + + assertThatThrownBy(() -> weatherService.getWeather("")) + .isInstanceOf(InvalidRequestException.class); + + assertThatThrownBy(() -> weatherService.getWeather(" ")) + .isInstanceOf(InvalidRequestException.class); + } + + @Test + @DisplayName("μ§€μ›ν•˜μ§€ μ•ŠλŠ” λ„μ‹œ 이름이면 UnsupportedCityException 이 λ°œμƒν•œλ‹€") + void getWeather_unsupportedCity() { + FakeOpenMeteoClient fakeClient = new FakeOpenMeteoClient(); + WeatherService weatherService = new WeatherService(fakeClient); + + assertThatThrownBy(() -> weatherService.getWeather("Busan")) + .isInstanceOf(UnsupportedCityException.class); + }*/ +} +