diff --git a/README.md b/README.md index 41da1ac..42d7790 100644 --- a/README.md +++ b/README.md @@ -1 +1,19 @@ -# spring-sunshine-precourse \ No newline at end of file +# spring-sunshine-precourse + +## 기능 요구 사항 +- 주어진 도시 이름을 입력받아 외부 REST API를 호출해 해당 도시의 날씨 정보를 조회하고 이를 정리해 반환하는 간단한 웹 서비스를 스프링 프레임워크로 구현한다 +- Git의 커밋 단위는 앞 단계에서 README.md 에 정리한 기능 목록 단위로 추가한다. +- AngularJS Git Commit Message Conventions을 참고해 커밋 메시지를 작성한다. +- AI 도구를 활용하였다면, README.md 에 활용한 방식과 코드를 어떻게 수정하였는지, 무엇을 학습하였는지 기록한다. +- 자세한 과제 진행 방법은 프리코스 진행 가이드 문서를 참고한다. +- 사용자는 도시 이름을 입력하면 해당 도시의 위도와 경도를 기반으로 날씨 정보를 조회할 수 있다. +- 최소 다섯 개 이상의 도시는 반드시 지원해야 한다. +e.g. Seoul, Tokyo, NewYork, Paris, London +- 도시 이름과 좌표를 매핑하는 자료 구조는 직접 설계한다. +- 서비스는 Open-Meteo API를 호출하여 아래 정보를 조회한다. + - 현재 온도 + - 체감 온도 + - 하늘 상태(맑음, 흐림 등) + - 습도 +- 조회한 데이터를 기반으로 간단한 한 줄 요약 문장을 생성하는 기능을 추가한다. +e.g. "현재 서울의 기온은 3.4°C이며, 풍속은 5.7m/s입니다. 날씨는 흐림입니다." diff --git a/build.gradle.kts b/build.gradle.kts index 3f75395..2be5068 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,6 @@ plugins { - id("org.springframework.boot") version "3.3.1" - id("io.spring.dependency-management") version "1.1.5" + id("org.springframework.boot") version "3.5.8" + id("io.spring.dependency-management") version "1.1.7" kotlin("plugin.jpa") version "1.9.24" kotlin("jvm") version "1.9.24" kotlin("plugin.spring") version "1.9.24" @@ -28,6 +28,8 @@ dependencies { implementation("org.flywaydb:flyway-core") implementation("org.flywaydb:flyway-mysql") implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation(platform("org.springframework.ai:spring-ai-bom:1.1.2")) + implementation("org.springframework.ai:spring-ai-starter-model-google-genai") runtimeOnly("com.h2database:h2") runtimeOnly("com.mysql:mysql-connector-j") testImplementation("org.springframework.boot:spring-boot-starter-test") @@ -44,3 +46,4 @@ kotlin { tasks.withType { useJUnitPlatform() } + diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..345b942 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.daemon=true +org.gradle.console=rich +org.gradle.parallel=true +org.gradle.user.home=C:/Users/chaeyj/gradle-home + diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 3fa8f86..aa2328f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,6 @@ +#Wed Dec 10 14:47:04 KST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip -networkTimeout=10000 -validateDistributionUrl=true +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/main/java/study/AddDayRequest.java b/src/main/java/study/AddDayRequest.java new file mode 100644 index 0000000..44350fb --- /dev/null +++ b/src/main/java/study/AddDayRequest.java @@ -0,0 +1,5 @@ +package study; + +public record AddDayRequest(int days){ + +} diff --git a/src/main/java/study/Application.java b/src/main/java/study/Application.java new file mode 100644 index 0000000..a76167a --- /dev/null +++ b/src/main/java/study/Application.java @@ -0,0 +1,11 @@ +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/DateResponse.java b/src/main/java/study/DateResponse.java new file mode 100644 index 0000000..5375a93 --- /dev/null +++ b/src/main/java/study/DateResponse.java @@ -0,0 +1,17 @@ +package study; + +import java.time.LocalDate; + + +public class DateResponse { + private LocalDate date; + + public DateResponse(LocalDate date) { + this.date = date; + } + + public LocalDate getDate() { + return date; + } + +} diff --git a/src/main/java/study/Functions.java b/src/main/java/study/Functions.java new file mode 100644 index 0000000..242e486 --- /dev/null +++ b/src/main/java/study/Functions.java @@ -0,0 +1,22 @@ +package study; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Description; +import java.util.function.Function; + +import java.time.LocalDate; + +@Configuration +public class Functions { + + @Description("Calculate a date after adding days from today") + @Bean + public Function addDaysFromToday() { + return request -> + new DateResponse( + LocalDate.now().plusDays(request.days()) + ); + } +} + diff --git a/src/main/java/study/JokeController.java b/src/main/java/study/JokeController.java new file mode 100644 index 0000000..55c017c --- /dev/null +++ b/src/main/java/study/JokeController.java @@ -0,0 +1,81 @@ +package study; + +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.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +@RestController +public class JokeController { + private final ChatClient client; + + public JokeController(ChatClient.Builder builder){ + this.client = builder.build(); + } + + @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).call().content(); + } +// public record ActorsFilms(String actor, List movies) { +// +// } + +// @GetMapping("/actors") +// public ActorFilms actors(@RequestParam(defaultValue = "Tom Cruise") String actor) +// ) { +// +// var beanOutputConverter = new BeanOutputConverter<>(ActorsFilms.class); +// var format = beanOutputConverter.getFormat(); +// +// log.debug(format); +// +// var userMessage = """ +// Generate the filmography of 5 movies for {actor}. +// {format} +// """; +// var promptTemplate = new PromptTemplate(userMessage, Map.of("actor", actor, "format", format)); +// var prompt = promptTemplate.create(); +// beanOutputConverter.convert(client.prompt(prompt).call().chatResponse().getResult().getOutput().getText()); +// +// +// } + + + @GetMapping("/joke") + public ChatResponse joke(@RequestParam(defaultValue = "Bob") String name, + @RequestParam(defaultValue = "pirate") String voice + ) { + var userMessage = 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 systemPromptTemplate = 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 systemMessage = systemPromptTemplate.createMessage(Map.of("name", name, "voice", voice)); + var prompt = new Prompt(List.of(userMessage, systemMessage)); + + return client.prompt(prompt).call().chatResponse(); + + } + +} diff --git a/src/main/java/sunshine/ai/Functions.java b/src/main/java/sunshine/ai/Functions.java new file mode 100644 index 0000000..fc213ee --- /dev/null +++ b/src/main/java/sunshine/ai/Functions.java @@ -0,0 +1,152 @@ +package sunshine.ai; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Description; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +import java.time.LocalDate; + +@Configuration +public class Functions { + + @Description("요청된 도시의 날씨 데이터를 요약 문장으로 생성합니다.") + @Bean("makeWeatherSummary") + public Function makeWeatherSummary() { + + return request -> { + + String city = request.city(); + float currentTemperature = request.currentTemperature(); + float apparentTemperature = request.apparentTemperature(); + int weatherCode = request.weatherCode(); + float humidity = request.humidity(); + + // 자연스러운 한 줄 요약 생성 + String summary = String.format( + "현재 %s의 기온은 %.1f°C이며, 체감온도는 %.1f°C입니다. 하늘 상태는 '%d'이며 습도는 %.0f%%입니다.", + city, currentTemperature, apparentTemperature, weatherCode, humidity + ); + + return new WeatherFunctionResponse(summary); + }; + } + + private boolean isRain(int code) { + return (code >= 51 && code <= 67) || (code >= 80 && code <= 82); + } + + private boolean isSnow(int code) { + return (code >= 71 && code <= 77) || code == 85 || code == 86; + } + + private boolean isClear(int code) { + return code == 0 || code == 1; + } + + + @Description("Open-Meteo 날씨 데이터를 기준으로 오늘 입기 좋은 복장을 추천한다.") + @Bean("recommendOutfit") + public Function recommendOutfit() { + + return request -> { + + // 1. 기준 온도 선택 + float baseTemp = request.apparentTemperature() > 0 + ? request.apparentTemperature() + : request.currentTemperature(); + + String outer; + String top; + String bottom; + String shoes = "운동화"; + List extras = new ArrayList<>(); + + // 2. 기온 기준 복장 + if (baseTemp <= -5) { + outer = "두꺼운 패딩"; + top = "기모 상의"; + bottom = "기모 바지"; + extras.add("목도리"); + extras.add("장갑"); + } + else if (baseTemp <= 4) { + outer = "코트나 패딩"; + top = "니트"; + bottom = "긴바지"; + } + else if (baseTemp <= 9) { + outer = "자켓"; + top = "후드나 니트"; + bottom = "긴바지"; + } + else if (baseTemp <= 16) { + outer = "얇은 자켓"; + top = "맨투맨이나 셔츠"; + bottom = "슬랙스나 청바지"; + } + else if (baseTemp <= 22) { + outer = "가벼운 가디건"; + top = "반팔이나 얇은 긴팔"; + bottom = "면바지"; + } + else if (baseTemp <= 27) { + outer = "겉옷 없이"; + top = "반팔"; + bottom = "가벼운 바지"; + } + else { + outer = "겉옷 없이"; + top = "민소매나 반팔"; + bottom = "반바지"; + extras.add("선크림"); + } + + // 3. 강수 보정 (weatherCode) + if (isRain(request.weatherCode())) { + outer += " (방수)"; + shoes = "방수 운동화"; + extras.add("우산"); + } + + if (isSnow(request.weatherCode())) { + shoes = "미끄럼 방지 신발"; + extras.add("장갑"); + } + + // 4. 습도 보정 + if (request.humidity() >= 75 && baseTemp >= 23) { + extras.add("통풍 잘 되는 옷"); + } + + // 5. 문장 생성 + StringBuilder sb = new StringBuilder(); + sb.append("오늘은 "); + + if (!outer.contains("없이")) { + sb.append(outer).append("에 "); + } + + sb.append(top) + .append("을 입고 ") + .append(bottom) + .append("를 입으면 좋아요. ") + .append(shoes) + .append("를 추천해요."); + + if (!extras.isEmpty()) { + sb.append(" "); + sb.append(String.join(", ", extras)) + .append("도 챙기세요."); + } + + return new OutfitRecommendationResponse(sb.toString()); + }; + } + + +} + diff --git a/src/main/java/sunshine/ai/OutfitRecommendationRequest.java b/src/main/java/sunshine/ai/OutfitRecommendationRequest.java new file mode 100644 index 0000000..6e0473c --- /dev/null +++ b/src/main/java/sunshine/ai/OutfitRecommendationRequest.java @@ -0,0 +1,8 @@ +package sunshine.ai; + +public record OutfitRecommendationRequest( + float currentTemperature, + float apparentTemperature, + int weatherCode, + float humidity +) {} diff --git a/src/main/java/sunshine/ai/OutfitRecommendationResponse.java b/src/main/java/sunshine/ai/OutfitRecommendationResponse.java new file mode 100644 index 0000000..d2eef71 --- /dev/null +++ b/src/main/java/sunshine/ai/OutfitRecommendationResponse.java @@ -0,0 +1,5 @@ +package sunshine.ai; + +public record OutfitRecommendationResponse( + String recommendation +) {} diff --git a/src/main/java/sunshine/ai/WeatherFunctionRequest.java b/src/main/java/sunshine/ai/WeatherFunctionRequest.java new file mode 100644 index 0000000..83d922b --- /dev/null +++ b/src/main/java/sunshine/ai/WeatherFunctionRequest.java @@ -0,0 +1,9 @@ +package sunshine.ai; + +public record WeatherFunctionRequest( + String city, + float currentTemperature, + float apparentTemperature, + int weatherCode, + float humidity +) {} diff --git a/src/main/java/sunshine/ai/WeatherFunctionResponse.java b/src/main/java/sunshine/ai/WeatherFunctionResponse.java new file mode 100644 index 0000000..428a8fd --- /dev/null +++ b/src/main/java/sunshine/ai/WeatherFunctionResponse.java @@ -0,0 +1,6 @@ +package sunshine.ai; + +public record WeatherFunctionResponse( + String summary +) {} + diff --git a/src/main/java/sunshine/client/OpenMeteoClient.java b/src/main/java/sunshine/client/OpenMeteoClient.java new file mode 100644 index 0000000..224cfa1 --- /dev/null +++ b/src/main/java/sunshine/client/OpenMeteoClient.java @@ -0,0 +1,47 @@ +package sunshine.client; + +import org.springframework.web.client.RestTemplate; +import org.springframework.stereotype.Component; +import sunshine.domain.OpenMeteoResponse; +import sunshine.domain.WeatherInfo; + +@Component +public class OpenMeteoClient { + private final RestTemplate restTemplate = new RestTemplate(); + + public OpenMeteoResponse getWeather(float lat, float lon) { + + // 1. API URL 만들기 + String url = "https://api.open-meteo.com/v1/forecast" + + "?latitude=" + lat + + "&longitude=" + lon + + "¤t_weather=true" + + "&hourly=relativehumidity_2m"; + + // 2. API 호출해서 DTO로 받기 + OpenMeteoResponse response = restTemplate.getForObject(url, OpenMeteoResponse.class); + + // 3. 응답이 null이면 예외 + if (response == null || response.getCurrentWeather() == null) { + throw new RuntimeException("날씨 정보를 가져올 수 없습니다."); + } + + return response; + } + +// private WeatherInfo convertToWeatherInfo(OpenMeteoResponse response) { +// float temperature = response.current_weather.temperature; +// float apparentTemperature = response.current_weather.apparent_temperature; +// int weatherCode = response.current_weather.weathercode; +// float humidity = response.hourly.relativehumidity_2m[0]; // 첫 번째 값 사용 +// +// return new WeatherInfo( +// temperature, +// apparentTemperature, +// weatherCode, +// humidity +// ); +// } + + +} \ No newline at end of file diff --git a/src/main/java/sunshine/config/LlmPricingProperties.java b/src/main/java/sunshine/config/LlmPricingProperties.java new file mode 100644 index 0000000..b956db4 --- /dev/null +++ b/src/main/java/sunshine/config/LlmPricingProperties.java @@ -0,0 +1,56 @@ +package sunshine.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +@Component +@ConfigurationProperties(prefix = "llm.pricing") +public class LlmPricingProperties { + + private Map models = new HashMap<>(); + + // ⭐ 기본 생성자에서 기본 pricing 세팅 + public LlmPricingProperties() { + // Gemini 2.5 Flash Lite 기본 가격 + Price gemini25FlashLite = new Price(); + gemini25FlashLite.setInput(0.00010); + gemini25FlashLite.setOutput(0.00040); + + models.put("gemini-2.5-flash-lite", gemini25FlashLite); + } + + public Map getModels() { + return models; + } + + public void setModels(Map models) { + // properties로 값이 들어오면 기본값을 덮어씀 + if (models != null && !models.isEmpty()) { + this.models = models; + } + } + + public static class Price { + private double input; + private double output; + + public double getInput() { + return input; + } + + public void setInput(double input) { + this.input = input; + } + + public double getOutput() { + return output; + } + + public void setOutput(double output) { + this.output = output; + } + } +} diff --git a/src/main/java/sunshine/controller/WeatherController.java b/src/main/java/sunshine/controller/WeatherController.java new file mode 100644 index 0000000..791d30b --- /dev/null +++ b/src/main/java/sunshine/controller/WeatherController.java @@ -0,0 +1,146 @@ +package sunshine.controller; + +import org.flywaydb.core.ProgressLogger; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import sunshine.ai.OutfitRecommendationRequest; +import sunshine.ai.OutfitRecommendationResponse; +import sunshine.domain.CityCoordinate; +import sunshine.domain.WeatherInfo; +import sunshine.repository.CityCoordinateRepository; +import sunshine.repository.RegionRepository; +import sunshine.service.WeatherService; +import sunshine.utils.TokenEstimator; +import sunshine.log.LlmUsageLogger; + +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; + +@RestController +@RequestMapping("/weather") +public class WeatherController { + private final ChatClient client; + + private final CityCoordinateRepository cityCoordinateRepository; + private final WeatherService weatherService; + private RegionRepository regionRepository; + private LlmUsageLogger llmUsageLogger; + + @Qualifier("recommendOutfit") + private final Function recommendOutfit; + + public WeatherController(CityCoordinateRepository cityCoordinateRepository, WeatherService weatherService, + ChatClient.Builder builder, RegionRepository regionRepository, LlmUsageLogger llmUsageLogger, Function recommendOutfit + ) { + this.cityCoordinateRepository = cityCoordinateRepository; + this.weatherService = weatherService; + this.client = builder.build(); + this.regionRepository = regionRepository; + + this.recommendOutfit = recommendOutfit; + this.llmUsageLogger = llmUsageLogger; + + } + +// @GetMapping +// public String getWeather(@RequestParam String city) { +// WeatherInfo weatherinfo = null; +// +// // 1) 도시 이름으로 좌표 가져오기 +// CityCoordinate coordinate = cityCoordinateRepository.findByCity(city); +// System.out.printf("lat : %f, lon %f", coordinate.getLat(), coordinate.getLon()); +// weatherinfo = weatherService.getWeather(coordinate.getLat(), coordinate.getLon()); +//// System.out.printf("현재 %s의 기온은 %d도 이며, 체감 온도는 %d도, 날씨는 %s, 습도는 %.2f %% 입니다", +//// city, (int) weatherinfo.getCurrentTemp(), (int) weatherinfo.getApparentTemp(), weatherinfo.getWeather_kr(), weatherinfo.getHumidity()); +// +// var template = new PromptTemplate("OpenMeteo로 받아온 {city}에서의 현재 기온 {currentTemp}, 체감 기온 {ApparentTemp}, 날씨 코드 {weatherCode}, 습도 {humidity}를 바탕으로 날씨를 요약해줘. 날씨 코드는 OpenMeteo에서 표현하는 규격을 참고해줘"); +// var prompt = template.render(Map.of( +// "city", city, +// "currentTemp", (int) weatherinfo.getCurrentTemp(), +// "ApparentTemp", (int) weatherinfo.getApparentTemp(), +// "weatherCode", weatherinfo.getWeatherCode(), +// "humidity", weatherinfo.getHumidity() +// )); +// +// +// return client.prompt(prompt).call().content(); +// } + + @GetMapping + public String getWeather(@RequestParam String city) { + WeatherInfo weatherinfo = null; + String requestId = UUID.randomUUID().toString(); + + if (cityCoordinateRepository.exists(city)) { + // 단일 도시 + CityCoordinate coord = cityCoordinateRepository.findByCity(city); + weatherinfo = weatherService.getWeather(coord.getLat(), coord.getLon()); + } + else if (regionRepository.exists(city)) { + // 수도권 같은 권역 + weatherinfo = weatherService.getRegionWeather(city); + } + else { + throw new IllegalArgumentException("지원하지 않는 도시 또는 권역입니다."); + } + + + var template = new PromptTemplate("OpenMeteo로 받아온 {city}에서의 현재 기온 {currentTemp}, 체감 기온 {ApparentTemp}, 날씨 코드 {weatherCode}, 습도 {humidity}를 바탕으로 날씨를 요약해줘. 날씨 코드는 OpenMeteo에서 표현하는 규격을 참고해줘"); + var prompt = template.render(Map.of( + "city", city, + "currentTemp", (int) weatherinfo.getCurrentTemp(), + "ApparentTemp", (int) weatherinfo.getApparentTemp(), + "weatherCode", weatherinfo.getWeatherCode(), + "humidity", weatherinfo.getHumidity() + )); + + var llmResponse = client.prompt(prompt).call(); + String weatherSummary = llmResponse.content(); + + // 버전 문제로 Gemini 응답에서 사용량 정보 추출 불가, estimator 사용 + // 4. 토큰 추정 + int inputTokens = TokenEstimator.estimate(prompt); + int outputTokens = TokenEstimator.estimate(weatherSummary); + + // 5. 로그 남기기 + llmUsageLogger.log( + requestId, + "gemini-2.5-flash-lite", + inputTokens, + outputTokens + ); + + OutfitRecommendationRequest outfitRequest = + new OutfitRecommendationRequest( + weatherinfo.getCurrentTemp(), + weatherinfo.getApparentTemp(), + weatherinfo.getWeatherCode(), + weatherinfo.getHumidity() + ); + + // 2. 복장 추천 (Function 호출) + OutfitRecommendationResponse outfitResponse = + recommendOutfit.apply( + new OutfitRecommendationRequest( + weatherinfo.getCurrentTemp(), + weatherinfo.getApparentTemp(), + weatherinfo.getWeatherCode(), + weatherinfo.getHumidity() + ) + ); + + // 3. 결과 합치기 + return weatherSummary + "\n\n" + outfitResponse.recommendation(); + + } + + +} diff --git a/src/main/java/sunshine/cost/LlmCostCalculator.java b/src/main/java/sunshine/cost/LlmCostCalculator.java new file mode 100644 index 0000000..343e249 --- /dev/null +++ b/src/main/java/sunshine/cost/LlmCostCalculator.java @@ -0,0 +1,26 @@ +package sunshine.cost; + +import org.springframework.stereotype.Component; +import sunshine.config.LlmPricingProperties; + +@Component +public class LlmCostCalculator { + + private final LlmPricingProperties pricingProperties; + + public LlmCostCalculator(LlmPricingProperties pricingProperties) { + this.pricingProperties = pricingProperties; + } + + public double calculate(String model, int inputTokens, int outputTokens) { + LlmPricingProperties.Price price = + pricingProperties.getModels().get(model); + + if (price == null) { + throw new IllegalArgumentException("Unknown LLM model: " + model); + } + + return (inputTokens / 1000.0 * price.getInput()) + + (outputTokens / 1000.0 * price.getOutput()); + } +} diff --git a/src/main/java/sunshine/domain/CityCoordinate.java b/src/main/java/sunshine/domain/CityCoordinate.java new file mode 100644 index 0000000..e4dd854 --- /dev/null +++ b/src/main/java/sunshine/domain/CityCoordinate.java @@ -0,0 +1,22 @@ +package sunshine.domain; + +public class CityCoordinate { + + private float lat; + private float lon; + + + public CityCoordinate(float lat, float lon) { + this.lat = lat; + this.lon = lon; + } + + public float getLat() { + return lat; + } + public float getLon() { + return lon; + } + + +} \ No newline at end of file diff --git a/src/main/java/sunshine/domain/OpenMeteoResponse.java b/src/main/java/sunshine/domain/OpenMeteoResponse.java new file mode 100644 index 0000000..e20e820 --- /dev/null +++ b/src/main/java/sunshine/domain/OpenMeteoResponse.java @@ -0,0 +1,79 @@ +package sunshine.domain; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +public class OpenMeteoResponse { + + @JsonProperty("current_weather") + private CurrentWeather currentWeather; + + private Hourly hourly; + + public CurrentWeather getCurrentWeather() { + return currentWeather; + } + + public Hourly getHourly() { + return hourly; + } + + @Override + public String toString() { + return "OpenMeteoResponse{" + + "currentWeather=" + currentWeather + + ", hourly=" + hourly + + '}'; + } + + // ===== 내부 클래스: CurrentWeather ===== + public static class CurrentWeather { + + private double temperature; + + @JsonProperty("apparent_temperature") + private double apparentTemperature; + + @JsonProperty("weathercode") + private int weatherCode; + + public double getTemperature() { + return temperature; + } + + public double getApparentTemperature() { + return apparentTemperature; + } + + public int getWeatherCode() { + return weatherCode; + } + + @Override + public String toString() { + return "CurrentWeather{" + + "temperature=" + temperature + + ", apparentTemperature=" + apparentTemperature + + ", weatherCode=" + weatherCode + + '}'; + } + } + + // ===== 내부 클래스: Hourly 데이터 ===== + public static class Hourly { + + @JsonProperty("relativehumidity_2m") + private List relativeHumidity2m; + + public List getRelativeHumidity2m() { + return relativeHumidity2m; + } + + @Override + public String toString() { + return "Hourly{" + + "relativeHumidity2m=" + relativeHumidity2m + + '}'; + } + } +} diff --git a/src/main/java/sunshine/domain/Region.java b/src/main/java/sunshine/domain/Region.java new file mode 100644 index 0000000..3e7c98a --- /dev/null +++ b/src/main/java/sunshine/domain/Region.java @@ -0,0 +1,19 @@ +package sunshine.domain; + +import java.util.List; + +public class Region { + private String name; + private RegionType type; + private List areas; // 포함된 도시목록 + + public Region(String name, RegionType type, List areas) { + this.name = name; + this.type = type; + this.areas = areas; + } + + public String getName() { return name; } + public RegionType getType() { return type; } + public List getAreas() { return areas; } +} diff --git a/src/main/java/sunshine/domain/RegionType.java b/src/main/java/sunshine/domain/RegionType.java new file mode 100644 index 0000000..2d50341 --- /dev/null +++ b/src/main/java/sunshine/domain/RegionType.java @@ -0,0 +1,7 @@ +package sunshine.domain; + +public enum RegionType { + CITY, // 단일 도시 + DISTRICT, // 구/동 + METRO_AREA // 수도권처럼 큰 권역 +} diff --git a/src/main/java/sunshine/domain/WeatherInfo.java b/src/main/java/sunshine/domain/WeatherInfo.java new file mode 100644 index 0000000..7b153f1 --- /dev/null +++ b/src/main/java/sunshine/domain/WeatherInfo.java @@ -0,0 +1,84 @@ +package sunshine.domain; + +public class WeatherInfo { + private float currentTemp; + private float apparentTemp; + private int weatherCode; + private float humidity; + private String weather_kr; + + public WeatherInfo(float currentTemp, float apparentTemp, int weatherCode, float humidity) { + this.currentTemp = currentTemp; + this.apparentTemp = apparentTemp; + this.weatherCode = weatherCode; + this.humidity = humidity; + this.weather_kr = WeatherCodeMapper.toKorean(weatherCode); + } + + public float getCurrentTemp() { + return currentTemp; + } + + public float getApparentTemp() { + return apparentTemp; + } + + public int getWeatherCode() { + return weatherCode; + } + + public float getHumidity() { + return humidity; + } + + public String getWeather_kr() { + return weather_kr; + } + public class WeatherCodeMapper { + + public static String toKorean(int code) { + if (code == 0) return "맑음"; + + if (code == 1 || code == 2 || code == 3) + return "대체로 흐림"; + + if (code == 45 || code == 48) + return "안개"; + + if (code == 51 || code == 53 || code == 55) + return "이슬비"; + + if (code == 56 || code == 57) + return "어는 비"; + + if (code == 61 || code == 63 || code == 65) + return "비"; + + if (code == 66 || code == 67) + return "어는 비"; + + if (code == 71 || code == 73 || code == 75) + return "눈"; + + if (code == 77) + return "싸락눈"; + + if (code == 80 || code == 81 || code == 82) + return "소나기"; + + if (code == 85 || code == 86) + return "눈 소나기"; + + if (code == 95) + return "천둥번개"; + + if (code == 96 || code == 99) + return "우박을 동반한 천둥"; + + return "알 수 없음"; + } + } + + +} + diff --git a/src/main/java/sunshine/log/LlmUsageLogger.java b/src/main/java/sunshine/log/LlmUsageLogger.java new file mode 100644 index 0000000..72333ba --- /dev/null +++ b/src/main/java/sunshine/log/LlmUsageLogger.java @@ -0,0 +1,45 @@ +package sunshine.log; + +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import sunshine.cost.LlmCostCalculator; + +@Service +public class LlmUsageLogger { + + private static final Logger log = + (Logger) LoggerFactory.getLogger(LlmUsageLogger.class); + + private final LlmCostCalculator costCalculator; + + public LlmUsageLogger(LlmCostCalculator costCalculator) { + this.costCalculator = costCalculator; + } + + public void log( + String requestId, + String model, + int inputTokens, + int outputTokens + ) { + int totalTokens = inputTokens + outputTokens; + double costUsd = costCalculator.calculate( + model, inputTokens, outputTokens + ); + + log.info( + "[LLM_USAGE] requestId={}, model={}, inputTokens={}, outputTokens={}, totalTokens={}, estimatedCostUsd={}", + requestId, + model, + inputTokens, + outputTokens, + totalTokens, + costUsd + ); + } +} + + diff --git a/src/main/java/sunshine/repository/CityCoordinateRepository.java b/src/main/java/sunshine/repository/CityCoordinateRepository.java new file mode 100644 index 0000000..8176e04 --- /dev/null +++ b/src/main/java/sunshine/repository/CityCoordinateRepository.java @@ -0,0 +1,75 @@ +package sunshine.repository; + +import java.util.Map; +import java.util.HashMap; + +import org.springframework.stereotype.Repository; +import sunshine.domain.CityCoordinate; + + + +// 1. repository 패키지 안에 CityCoordinateRepository 클래스를 만든다. + +// 2. 도시 이름(String)을 key, +// CityCoordinate 객체를 value 로 가지는 Map 를 필드로 선언한다. + +// 3. 생성자에서 최소 5개 도시의 좌표를 Map에 미리 채워 넣는다. +// 예: "Seoul" → new CityCoordinate(lat, lon) +// "Tokyo" → new CityCoordinate(lat, lon) + +// 4. findByCity(String city) 메서드를 만든다. + +// 5. city 이름을 소문자/대문자 구분 없이 조회할 수 있도록 +// city.toLowerCase() 등을 사용해 Map에서 찾도록 한다. + +// 6. 만약 Map 에 해당 도시가 없다면 +// - null 반환 +// 또는 +// - IllegalArgumentException 같은 예외 던지기 +// 중 하나를 선택한다. + +// 7. 존재한다면 CityCoordinate 객체를 그대로 반환한다. + +@Repository +public class CityCoordinateRepository { + private Map cityMap; + + public CityCoordinateRepository() { + cityMap = new HashMap<>(); + cityMap.put("seoul", new CityCoordinate(37.5665f, 126.9780f)); // 서울 + cityMap.put("busan", new CityCoordinate(35.1796f, 129.0756f)); // 부산 + cityMap.put("incheon", new CityCoordinate(37.4563f, 126.7052f)); // 인천 + cityMap.put("daegu", new CityCoordinate(35.8714f, 128.6014f)); // 대구 + cityMap.put("daejeon", new CityCoordinate(36.3504f, 127.3845f)); // 대전 + cityMap.put("gwangju", new CityCoordinate(35.1595f, 126.8526f)); // 광주 + cityMap.put("ulsan", new CityCoordinate(35.5384f, 129.3114f)); // 울산 + cityMap.put("sejong", new CityCoordinate(36.4800f, 127.2890f)); // 세종 + + cityMap.put("suwon", new CityCoordinate(37.2636f, 127.0286f)); // 수원 + cityMap.put("yongin", new CityCoordinate(37.2411f, 127.1776f)); // 용인 + cityMap.put("seongnam", new CityCoordinate(37.4200f, 127.1265f)); // 성남 + cityMap.put("goyang", new CityCoordinate(37.6584f, 126.8320f)); // 고양 + + cityMap.put("chuncheon", new CityCoordinate(37.8813f, 127.7298f)); // 춘천 + cityMap.put("cheongju", new CityCoordinate(36.6424f, 127.4890f)); // 청주 + cityMap.put("jeonju", new CityCoordinate(35.8242f, 127.1480f)); // 전주 + cityMap.put("mokpo", new CityCoordinate(34.8118f, 126.3922f)); // 목포 + cityMap.put("yeosu", new CityCoordinate(34.7604f, 127.6622f)); // 여수 + + cityMap.put("pohang", new CityCoordinate(36.0190f, 129.3435f)); // 포항 + cityMap.put("changwon", new CityCoordinate(35.2286f, 128.6811f)); // 창원 + cityMap.put("jinju", new CityCoordinate(35.1795f, 128.1076f)); // 진주 + + cityMap.put("jeju", new CityCoordinate(33.4996f, 126.5312f)); // 제주 + } + + public CityCoordinate findByCity(String city){ + city = city.toLowerCase(); + return cityMap.get(city); + } + + public boolean exists(String city) { + return cityMap.containsKey(city.toLowerCase()); + } + +} \ No newline at end of file diff --git a/src/main/java/sunshine/repository/RegionRepository.java b/src/main/java/sunshine/repository/RegionRepository.java new file mode 100644 index 0000000..d59a0ae --- /dev/null +++ b/src/main/java/sunshine/repository/RegionRepository.java @@ -0,0 +1,37 @@ +package sunshine.repository; + +import org.springframework.stereotype.Repository; +import sunshine.domain.Region; +import sunshine.domain.RegionType; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Repository +public class RegionRepository { + + private final Map regions = new HashMap<>(); + + public RegionRepository() { + + // 단일 도시 등록(도시 이름 그대로) + regions.put("Seoul", new Region("Seoul", RegionType.CITY, List.of("Seoul"))); + + // 수도권 예시 + regions.put("수도권", new Region("수도권", RegionType.METRO_AREA, + List.of("Seoul", "Incheon"))); + + // 동/구 예시 + regions.put("강남구", new Region("강남구", RegionType.DISTRICT, + List.of("Gangnam1", "Gangnam2"))); + } + + public Region findByName(String name) { + return regions.get(name); + } + + public boolean exists(String name) { + return regions.containsKey(name); + } +} diff --git a/src/main/java/sunshine/service/WeatherService.java b/src/main/java/sunshine/service/WeatherService.java new file mode 100644 index 0000000..42b4ff3 --- /dev/null +++ b/src/main/java/sunshine/service/WeatherService.java @@ -0,0 +1,109 @@ +package sunshine.service; + +// 1. @Service 붙여서 스프링 빈으로 등록한다. + +// 2. OpenMeteoClient(아직 안 만들었으면 빈 자리만 마련) 를 주입받을 준비를 한다. + +// 3. getWeather(float lat, float lon) 메서드를 만든다. +// - 이 메서드는 API 호출을 담당한다. +// - 지금은 진짜 호출하지 말고, "lat/lon 잘 들어왔는지"만 println으로 확인해도 됨. + +// 4. 반환 타입은 나중에 WeatherInfo가 될 예정이므로, 지금은 임시로 String 또는 void로 만들어도 됨. + +import org.springframework.stereotype.Service; +import sunshine.client.OpenMeteoClient; +import sunshine.domain.CityCoordinate; +import sunshine.domain.OpenMeteoResponse; +import sunshine.domain.Region; +import sunshine.domain.WeatherInfo; +import sunshine.repository.CityCoordinateRepository; +import sunshine.repository.RegionRepository; + +import java.util.ArrayList; +import java.util.List; + +@Service +public class WeatherService { +// private WeatherInfo weatherInfo; + private OpenMeteoClient openMeteoClient; + private final RegionRepository regionRepository; + private CityCoordinateRepository cityCoordinateRepository; + + public WeatherService(OpenMeteoClient openMeteoClient, + RegionRepository regionRepository, + CityCoordinateRepository cityCoordinateRepository) { + this.openMeteoClient = openMeteoClient; + this.regionRepository = regionRepository; + this.cityCoordinateRepository = cityCoordinateRepository; + } + + public WeatherInfo getWeather(float lat, float lon){ + WeatherInfo weatherInfo = null; + + try { +// System.out.println("openMeteoClient is null? " + (openMeteoClient == null)); + + OpenMeteoResponse openMeteoResponse = openMeteoClient.getWeather(lat, lon); + weatherInfo = getWeatherInfo(openMeteoResponse); + + } catch (Exception e) { + e.printStackTrace(); + + } + + return weatherInfo; + + } + + private WeatherInfo averageWeather(List list) { + + float temp = 0, app = 0, hum = 0; + + for (WeatherInfo w : list) { + temp += w.getCurrentTemp(); + app += w.getApparentTemp(); + hum += w.getHumidity(); + } + + int n = list.size(); + + return new WeatherInfo( + temp/n, + app/n, + list.get(0).getWeatherCode(), // 대표값 + hum/n + ); + } + + public WeatherInfo getRegionWeather(String regionName) { + + Region region = regionRepository.findByName(regionName); + List list = new ArrayList<>(); + + for (String city : region.getAreas()) { + System.out.println(city); + CityCoordinate coord = cityCoordinateRepository.findByCity(city); + list.add(getWeather(coord.getLat(), coord.getLon())); + } + + return averageWeather(list); + } + + + private WeatherInfo getWeatherInfo(OpenMeteoResponse response){ + float currentTemp = (float) response.getCurrentWeather().getTemperature(); + float apparentTemp = (float) response.getCurrentWeather().getApparentTemperature(); + int weatherCode = response.getCurrentWeather().getWeatherCode(); + double humidity = 0.0F; + if (response.getHourly() != null && + response.getHourly().getRelativeHumidity2m() != null && + !response.getHourly().getRelativeHumidity2m().isEmpty()) { + + humidity = response.getHourly().getRelativeHumidity2m().get(0); + } + + return new WeatherInfo(currentTemp, apparentTemp, weatherCode, (float) humidity); + } + +} + diff --git a/src/main/java/sunshine/utils/TokenEstimator.java b/src/main/java/sunshine/utils/TokenEstimator.java new file mode 100644 index 0000000..f334744 --- /dev/null +++ b/src/main/java/sunshine/utils/TokenEstimator.java @@ -0,0 +1,27 @@ +package sunshine.utils; + +public class TokenEstimator { + + /** + * 매우 단순한 토큰 추정기 + * - 영어: 약 4 chars = 1 token + * - 한글: 약 2 chars = 1 token + */ + public static int estimate(String text) { + if (text == null || text.isEmpty()) { + return 0; + } + + int length = text.length(); + + // 한글 포함 여부로 보정 + boolean hasKorean = text.chars() + .anyMatch(ch -> ch >= 0xAC00 && ch <= 0xD7A3); + + if (hasKorean) { + return length / 2; + } else { + return length / 4; + } + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 10fffc9..2e5364d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,5 @@ spring.application.name=spring-sunshine +server.port=9998 +spring.ai.google.genai.chat.options.model=gemini-2.5-flash-lite + +