diff --git a/README.md b/README.md index 41da1ac..b1bed01 100644 --- a/README.md +++ b/README.md @@ -1 +1,22 @@ -# spring-sunshine-precourse \ No newline at end of file +# spring-sunshine-precourse + + +## getCoordinatesByCityName(enum city) +도시 이름을 넣으면 위도와 경도를 반환하는 메소드 +output: coordinate double DTO + +## ??? +시/군, 구, 동 까지 볼 수 있는 어쩌고 저쩌고 + +## getWeatherByCoordinates(double latitude, double longitude) +위도와 경도를 넣으면 위치에 해당하는 온도, 습도, 체감온도, 날씨코드, 요약을 반환하는 메소드 +output: current.temperature_2m, relative_humedity_2m, apperent_temperature, weather_code, summary DTO + +## getSummaryByInfo(current.temperature_2m, relative_humedity_2m, apperent_temperature, weather_code) +온도, 습도, 체감온도, 날씨코드로 요약을 생성하여 반환하는 메소드 +output: summary string + +## getDescriptionByWeatherCode(int weather_code) +날씨코드와 매핑되는 설명을 반환하는 메소드 +output: string + diff --git a/build.gradle.kts b/build.gradle.kts index 3f75395..e638fae 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,11 +28,20 @@ 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") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") runtimeOnly("com.h2database:h2") runtimeOnly("com.mysql:mysql-connector-j") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testRuntimeOnly("org.junit.platform:junit-platform-launcher") + compileOnly("org.projectlombok:lombok:1.18.30") + annotationProcessor("org.projectlombok:lombok:1.18.30") + + testCompileOnly("org.projectlombok:lombok:1.18.30") + testAnnotationProcessor("org.projectlombok:lombok:1.18.30") + } kotlin { diff --git a/src/main/java/study/AddDaysRequest.java b/src/main/java/study/AddDaysRequest.java new file mode 100644 index 0000000..19dd82c --- /dev/null +++ b/src/main/java/study/AddDaysRequest.java @@ -0,0 +1,5 @@ +package study; + +public record AddDaysRequest(int days) { + +} \ No newline at end of file diff --git a/src/main/java/study/Application.java b/src/main/java/study/Application.java new file mode 100644 index 0000000..fa7ba46 --- /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..5dd993f --- /dev/null +++ b/src/main/java/study/DateResponse.java @@ -0,0 +1,5 @@ +package study; + +public record DateResponse(String day) { + +} \ No newline at end of file diff --git a/src/main/java/study/Functions.java b/src/main/java/study/Functions.java new file mode 100644 index 0000000..aef64c5 --- /dev/null +++ b/src/main/java/study/Functions.java @@ -0,0 +1,22 @@ +package study; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Description; + +import java.time.LocalDate; +import java.util.function.Function; + +@Configuration +public class Functions { + + @Tool(description = "Calculate a date after adding days from today") + public DateResponse addDaysFromToday(AddDaysRequest request) { + var result = LocalDate.now().plusDays(request.days()); + return new DateResponse(result.toString()); + + } +} \ No newline at end of file diff --git a/src/main/java/study/JokeController.java b/src/main/java/study/JokeController.java new file mode 100644 index 0000000..cbc4b91 --- /dev/null +++ b/src/main/java/study/JokeController.java @@ -0,0 +1,64 @@ +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.ai.converter.BeanOutputConverter; +import org.springframework.ai.template.TemplateRenderer; +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) + .tools(new Functions()) + .call() + .content(); + } + + + + @GetMapping("/joke") + public ChatResponse joke( + @RequestParam(defaultValue = "HWANG SEONGHYUN") 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(); + } +} diff --git a/src/main/java/sunshine/Current.java b/src/main/java/sunshine/Current.java new file mode 100644 index 0000000..05008f5 --- /dev/null +++ b/src/main/java/sunshine/Current.java @@ -0,0 +1,11 @@ +package sunshine; + + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record Current( + @JsonProperty("temperature_2m") double temperature2m, + @JsonProperty("relative_humidity_2m") double relativeHumidity2m, + @JsonProperty("apparent_temperature") double apparentTemperature, + @JsonProperty("weather_code") int weatherCode +) {} \ No newline at end of file diff --git a/src/main/java/sunshine/HttpClientConfig.java b/src/main/java/sunshine/HttpClientConfig.java new file mode 100644 index 0000000..f74d04a --- /dev/null +++ b/src/main/java/sunshine/HttpClientConfig.java @@ -0,0 +1,15 @@ +package sunshine; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.client.RestClient; + +@Configuration +public class HttpClientConfig { + @Bean + RestClient restClient(RestClient.Builder builder) { + return builder.build(); + } +} diff --git a/src/main/java/sunshine/LatLngDto.java b/src/main/java/sunshine/LatLngDto.java new file mode 100644 index 0000000..6bafb33 --- /dev/null +++ b/src/main/java/sunshine/LatLngDto.java @@ -0,0 +1,4 @@ +package sunshine; + +public record LatLngDto(double lat, double lng) { +} diff --git a/src/main/java/sunshine/OutfitRecommendationController.java b/src/main/java/sunshine/OutfitRecommendationController.java new file mode 100644 index 0000000..f68f240 --- /dev/null +++ b/src/main/java/sunshine/OutfitRecommendationController.java @@ -0,0 +1,8 @@ +package sunshine; + +import org.springframework.stereotype.Controller; + +@Controller +public class OutfitRecommendationController { + +} diff --git a/src/main/java/sunshine/WeatherApiResponseDto.java b/src/main/java/sunshine/WeatherApiResponseDto.java new file mode 100644 index 0000000..c82ba7c --- /dev/null +++ b/src/main/java/sunshine/WeatherApiResponseDto.java @@ -0,0 +1,11 @@ +package sunshine; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +public record WeatherApiResponseDto( + double latitude, + double longitude, + @JsonProperty("current") + Current current +) {} diff --git a/src/main/java/sunshine/WeatherQueryController.java b/src/main/java/sunshine/WeatherQueryController.java new file mode 100644 index 0000000..c654c2e --- /dev/null +++ b/src/main/java/sunshine/WeatherQueryController.java @@ -0,0 +1,106 @@ +package sunshine; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestClient; +import study.Functions; + +import java.util.Map; + +@RestController +public class WeatherQueryController { + private final ChatClient client; + private final RestClient httpClient; + private static final Logger log = LoggerFactory.getLogger(WeatherQueryController.class); + + public WeatherQueryController(ChatClient.Builder builder, RestClient httpClient) { + this.client = builder.build(); + this.httpClient = httpClient; + } + + @GetMapping("/getLatLngDto") + public String getLatLngDto(@RequestParam(defaultValue = "서울시 강서구") String city) { + + var beanOutputConverter = new BeanOutputConverter<>(LatLngDto.class); + var format = beanOutputConverter.getFormat(); + + var user = """ + {city}의 위도와 경도 알려줘. 답변 양식은 {format}으로 알려줘야해.; + """; + var prompt = new PromptTemplate(user).create(Map.of("city", city, "format", format)); + + try { + // ✅ ChatResponse를 명시적으로 받기 (메타데이터/토큰/ID 로깅용) + var chatResponse = client.prompt(prompt) + .call() + .chatResponse(); + + var metadata = chatResponse.getMetadata(); // ChatResponseMetadata :contentReference[oaicite:1]{index=1} + var usage = metadata.getUsage(); // Usage :contentReference[oaicite:2]{index=2} + + // ✅ 호출 ID + 토큰 사용량 로그 + log.info("llm.call id={} model={} promptTokens={} totalTokens={}", + metadata.getId(), + metadata.getModel(), + usage.getPromptTokens(), + usage.getTotalTokens() + ); + + // 기존 로직: content를 꺼내서 변환 + String content = chatResponse.getResult().getOutput().getText(); // 또는 너가 쓰던 result.content() 방식 + LatLngDto convert = beanOutputConverter.convert(content); + + WeatherApiResponseDto weatherByCoordinates = getWeatherByCoordinates(convert); + log.info("weather={}", weatherByCoordinates); + + return recommendOutfit(weatherByCoordinates); + + } catch (Exception e) { + log.info("llm.call failureType={}", e.getClass().getSimpleName(), e); + return null; + } + } + + public WeatherApiResponseDto getWeatherByCoordinates(LatLngDto latLngDto) { + RestClient.RequestHeadersSpec uri = httpClient.get() + .uri(uriBuilder -> uriBuilder + .scheme("https") + .host("api.open-meteo.com") + .path("/v1/forecast") + .queryParam("latitude", latLngDto.lat()) + .queryParam("longitude", latLngDto.lng()) + .queryParam("current", + "temperature_2m,relative_humidity_2m,apparent_temperature,weather_code") + .build()); + + log.info(uri.retrieve().body(WeatherApiResponseDto.class).toString()); + return uri.retrieve().body(WeatherApiResponseDto.class); + } + + public String recommendOutfit(WeatherApiResponseDto weatherinfo) { + var template = """ + 기온은 {temperature} 이고, + 습도는 {relativeHumidity} 이고, + 체감온도는 {apparentTemperature} 이고, + 날씨코드는 {weatherCode} 야.5 + 이 데이터에 맞는 옷차림을 추천해줘. + """; + + var prompt = new PromptTemplate(template).create(Map.of("temperature", weatherinfo.current().temperature2m(), + "relativeHumidity", weatherinfo.current().relativeHumidity2m(), + "apparentTemperature", weatherinfo.current().apparentTemperature(), + "weatherCode", weatherinfo.current().weatherCode())); + + ChatClient.CallResponseSpec call = client.prompt(prompt).call(); + log.info(call.toString()); + + return call.content(); + } +} diff --git a/src/main/java/sunshine/WeatherSummaryController.java b/src/main/java/sunshine/WeatherSummaryController.java new file mode 100644 index 0000000..ea3d1ce --- /dev/null +++ b/src/main/java/sunshine/WeatherSummaryController.java @@ -0,0 +1,4 @@ +package sunshine; + +public class WeatherSummaryController { +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 10fffc9..62698e7 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,3 @@ spring.application.name=spring-sunshine +spring.ai.google.genai.api-key=YOUR_API_KEY_HERE +spring.ai.google.genai.chat.options.model=gemini-2.5-flash-lite \ No newline at end of file