Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,40 @@
# spring-sunshine-precourse
# spring-sunshine-precourse

### 기능 요구 사항
- [ ] 도시 이름을 입력하면 해당 도시의 위도와 경도를 기반으로 날씨 정보를 조회할 수 있다
- [ ] 최소 다섯 개 이상의 도시는 반드시 지원해야 한다 (ex. 서울, 도쿄, 뉴욕, 파리, 런던)
- [ ] 도시 이름과 좌표를 매핑하는 자료구조는 직접 설계한다
- [ ] 서비스는 open-meteo api 를 호출하여 아래 정보를 조회한다
- 현재온도
- 체감온도
- 하늘상태 (맑음, 흐림 등)
- 습도
- [ ] 조회한 데이터를 기반으로 간단한 한 줄 요약 문장을 생성하는 기능을 추가한다
- ex. 현재 서울의 기온은 3.4도 이며 풍속은 5.7m/s 입니다. 날씨는 흐림입니다

### 프로그래밍 요구 사항
- 자바 코드 컨벤션을 지키면서 프로그래밍한다
- 기본적으로 Google Java Style Guide 를 원칙으로 한다
- 단, 들여쓰기는 2spaces 가 아닌 4spaces 로 한다
- 들여쓰기 단계가 3을 넘지 않도록 구현한다. 2까지만 허용한다
- 예를들어, while 문 안의 if 문은 들여쓰기 2이다
- 힌트 : 들여쓰기 단계를 줄이는 좋은 방법은 함수를 분리하는 것이다
- 함수의 길이가 15줄을 넘어가지 않도록 구현한다
- 함수는 한가지 일만 하도록 작성한다
- else 키워드를 쓰지 않는다
- switch 문도 허용하지 않는다
- 힌트: if문에서 값을 반환하는 방식으로 구현하면 else 를 사용하지 않아도 된다
- 3항 연산자를 쓰지 않는다
- 정리한 기능 목록이 정상적으로 작동하는지 JUnit5 와 AssertJ 로 테스트한다

# 날씨조회 (LLM)

### 기능 요구 사항
- [ ] 조회한 날씨 데이터를 입력으로 LLM API 를 호출해 요약을 생성하고 반환한다.
- 설정에 따라 요약을 새로 생성하지 않고, 기존 요약을 재사용할 수 있다. (설정1 -> LLM API 호출. 설정2 -> 기존 meteo API 활용)
- [ ] 도시는 물론, 권역 단위로도 날씨 조회
- e.g. 서울 전체, 수도권, 특정 구/동 등 (Enum 데이터를 늘린다? 단지 db 데이터 증가?)
- [ ] 날씨 또는 기온을 기준으로 복장을 추천한다.
- e.g. 기온 구간, 강수 여부, 체감온도, 바람 등을 기준으로 추천 규칙을 적용한다.
- [ ] 각 요청에 대해 사용량과 비용 추정치를 로그로 남긴다.
- 예: 입력 토큰, 출력 토큰, 총 토큰, 모델명, 캐시 사용 여부, 추정 비용
9 changes: 7 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -28,11 +28,16 @@ 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")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0")
}

kotlin {
Expand Down
13 changes: 13 additions & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -1 +1,14 @@
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}

dependencyResolutionManagement {
repositories {
mavenCentral()
}
}

rootProject.name = "spring-sunshine"
9 changes: 9 additions & 0 deletions src/main/java/study/AIFunctionConfiguration.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package study;

import org.springframework.context.annotation.Configuration;

@Configuration
public class AIFunctionConfiguration {


}
6 changes: 6 additions & 0 deletions src/main/java/study/ActorFilms.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package study;

import java.util.List;

public record ActorFilms(String actor, List<String> movies) {
}
11 changes: 11 additions & 0 deletions src/main/java/study/Application.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
29 changes: 29 additions & 0 deletions src/main/java/study/Functions.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package study;

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 {

@Description("Calculate a date after adding days from today")
@Bean
public Function<AddDayRequest, DateResponse> addDaysFromToday() {
return request -> {
var result = LocalDate.now().plusDays(request.days);
return new DateResponse(result.toString());
};
}

public record AddDayRequest(int days) {

}

public record DateResponse(String date) {

}
}
90 changes: 90 additions & 0 deletions src/main/java/study/controller/JokeController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package study.controller;

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.ActorFilms;

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("/joke")
public ChatResponse joke(
@RequestParam(defaultValue = "Tell me a joke about {topic}") String message,
@RequestParam(defaultValue = "programming") String topic

) {

var template = new PromptTemplate(message);
var prompt = template.render(Map.of("topic", topic));
return client.prompt(prompt)
.call()
.chatResponse();
}

@GetMapping("/joke2")
public ChatResponse joke2(
@RequestParam(defaultValue = "HoJong") 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(List.of(user, system));
return client.prompt(prompt)
.call()
.chatResponse();
}

@GetMapping("/actors")
public ActorFilms actors(
@RequestParam(defaultValue = "Tom Cruise") String actor
) {
var beanOutputConverter = new BeanOutputConverter<>(ActorFilms.class);
String format = beanOutputConverter.getFormat();

var userMessage = """
Generate the filmography of 5 movies for {actor}.
{format}
""";

var promptTemplate = new PromptTemplate(userMessage).create(Map.of("actor", actor, "format", format));
return beanOutputConverter.convert(client.prompt(promptTemplate).call().chatResponse().getResult().getOutput().getText());
}

@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();
}
}
21 changes: 21 additions & 0 deletions src/main/java/sunshine/api/controller/WeatherController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package sunshine.api.controller;

import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import sunshine.api.controller.dto.WeatherReqDTO;
import sunshine.api.controller.dto.WeatherResDTO;
import sunshine.api.service.service.WeatherService;

@AllArgsConstructor
@RestController("/weather")
public class WeatherController {

private WeatherService weatherService;

@PostMapping("/v1")
public WeatherResDTO getWeather(@RequestBody WeatherReqDTO weatherReqDTO) {
return weatherService.getWeather(weatherReqDTO);
}
}
10 changes: 10 additions & 0 deletions src/main/java/sunshine/api/controller/dto/WeatherReqDTO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package sunshine.api.controller.dto;

import lombok.Getter;

@Getter
public class WeatherReqDTO {

private String country;
private boolean reuseSummary;
}
24 changes: 24 additions & 0 deletions src/main/java/sunshine/api/controller/dto/WeatherResDTO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package sunshine.api.controller.dto;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import sunshine.api.controller.vo.City;
import sunshine.external.dto.OpenMeteoResDTO;

@NoArgsConstructor
@Setter
@Getter
public class WeatherResDTO {

private String reportContent;

public WeatherResDTO(City city, OpenMeteoResDTO.OpenMeteoCurrent openMeteoCurrent) {
this.reportContent = String.format("현재 %s의 기온은 %s도, 체감온도는 %s도, 습도는 %s, 날씨상태는 %s 입니다",
city.getName(),
openMeteoCurrent.getTemperature_2m(),
openMeteoCurrent.getApparent_temperature(),
openMeteoCurrent.getRelative_humidity_2m(),
openMeteoCurrent.getWeather_code());
}
}
33 changes: 33 additions & 0 deletions src/main/java/sunshine/api/controller/enums/CityLocation.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package sunshine.api.controller.enums;

import lombok.Getter;

import java.util.Arrays;

@Getter
public enum CityLocation {

SEOUL("서울", 37.5665, 126.9784),
PARIS("파리", 48.8534, 2.3488),
NEWYORK("뉴욕", 40.7143, -74.006),
DOKYO("도쿄", 35.4541, 140.2199),
LONDON("런던", 51.5085, -0.1257);

private String name;
private double latitude;
private double longitude;

CityLocation(String name, double latitude, double longitude) {
this.name = name;
this.latitude = latitude;
this.longitude = longitude;
}

public static CityLocation from(String cityName) {
return Arrays.stream(values())
.filter(e -> e.name.equals(cityName))
.findFirst().orElseThrow(
() -> new IllegalArgumentException("등록되지 않은 도시입니다 : " + cityName)
);
}
}
19 changes: 19 additions & 0 deletions src/main/java/sunshine/api/controller/vo/City.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package sunshine.api.controller.vo;

import lombok.Getter;
import sunshine.api.controller.enums.CityLocation;

@Getter
public class City {

private String name;
private double latitude;
private double longitude;

public City(String name) {
CityLocation cityLocation = CityLocation.from(name);
this.name = cityLocation.getName();
this.latitude = cityLocation.getLatitude();
this.longitude = cityLocation.getLongitude();
}
}
42 changes: 42 additions & 0 deletions src/main/java/sunshine/api/service/service/WeatherService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package sunshine.api.service.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import sunshine.external.service.LLMSummery;
import sunshine.external.service.WeatherClient;
import sunshine.api.controller.vo.City;
import sunshine.external.dto.OpenMeteoResDTO;
import sunshine.api.controller.dto.WeatherReqDTO;
import sunshine.api.controller.dto.WeatherResDTO;

@Slf4j
@Service
public class WeatherService {

private final WeatherClient weatherClient;
private final LLMSummery llmSummery;

public WeatherService(WeatherClient weatherClient, LLMSummery llmSummery) {
this.weatherClient = weatherClient;
this.llmSummery = llmSummery;
}

public WeatherResDTO getWeather(WeatherReqDTO weatherReqDTO) {

City city = new City(weatherReqDTO.getCountry());

OpenMeteoResDTO.OpenMeteoCurrent openMeteoCurrent = weatherClient.fetchCurrent(city);

if (weatherReqDTO.isReuseSummary()) {
WeatherResDTO weatherResDTO = new WeatherResDTO(city, openMeteoCurrent);
return weatherResDTO;
} else {
return getWeatherByLLM(city, openMeteoCurrent);
}
}

private WeatherResDTO getWeatherByLLM(City city, OpenMeteoResDTO.OpenMeteoCurrent openMeteoCurrent) {
log.info("");
return llmSummery.getWeatherByLLM(city, openMeteoCurrent);
}
}
Loading