Skip to content

Commit 26bd8e7

Browse files
[Junie]: feat: implement AI weather summary service with AJAX (#12)
* feat: implement AI weather summary service with AJAX An AI summary service was implemented to generate weather summaries and activity suggestions using LangChain4j with Google Gemini. The report page now fetches the summary asynchronously via AJAX. Configuration for the Google Gemini API key was added for live summarization. * fix dto, render markdown, rearreange report content * location --------- Co-authored-by: jetbrains-junie[bot] <201638009+jetbrains-junie[bot]@users.noreply.github.com> Co-authored-by: Ivan Šarić <invoices@path-variable.com>
1 parent ceefa03 commit 26bd8e7

7 files changed

Lines changed: 383 additions & 39 deletions

File tree

build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ dependencies {
2020

2121
// LangChain4j core - used for PromptTemplate (no external API key required)
2222
implementation 'dev.langchain4j:langchain4j:0.35.0'
23+
// LangChain4j Google Gemini (direct Gemini API via Google AI Studio)
24+
implementation 'dev.langchain4j:langchain4j-google-ai-gemini:0.35.0'
2325

2426
testImplementation 'org.springframework.boot:spring-boot-starter-test'
2527
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package com.example.weatherapp.ai;
2+
3+
import com.example.weatherapp.weather.WeatherService.WeatherReport;
4+
import com.fasterxml.jackson.core.JsonProcessingException;
5+
import com.fasterxml.jackson.databind.ObjectMapper;
6+
import dev.langchain4j.model.chat.ChatLanguageModel;
7+
import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel;
8+
import org.springframework.beans.factory.annotation.Value;
9+
import org.springframework.stereotype.Service;
10+
11+
import java.util.Objects;
12+
13+
@Service
14+
public class AiSummaryService {
15+
16+
private final String apiKey;
17+
private final String modelName;
18+
private final ObjectMapper mapper = new ObjectMapper();
19+
20+
private volatile ChatLanguageModel cachedModel;
21+
22+
public AiSummaryService(
23+
@Value("${ai.gemini.api-key:}") String apiKey,
24+
@Value("${ai.gemini.model:gemini-1.5-flash}") String modelName
25+
) {
26+
this.apiKey = apiKey == null ? "" : apiKey.trim();
27+
this.modelName = (modelName == null || modelName.isBlank()) ? "gemini-1.5-flash" : modelName;
28+
}
29+
30+
public boolean isConfigured() {
31+
return apiKey != null && !apiKey.isBlank();
32+
}
33+
34+
public String summarize(WeatherReport report, String timezone, String city) {
35+
if (!isConfigured()) {
36+
return "AI summary unavailable: missing Google Gemini API key.";
37+
}
38+
ChatLanguageModel model = getModel();
39+
String reportJson;
40+
try {
41+
reportJson = mapper.writeValueAsString(report);
42+
} catch (JsonProcessingException e) {
43+
reportJson = safeReport(report);
44+
}
45+
46+
String location = city != null && !city.isBlank() ? city : "the provided coordinates";
47+
String tz = timezone != null ? timezone : "auto";
48+
49+
String prompt = "You are an assistant that summarizes short-term weather forecasts for lay people.\n" +
50+
"Given the JSON weather report from Open-Meteo (hourly arrays for next ~72 hours) and context, provide:\n" +
51+
"1) A concise overview of the upcoming weather for the next 1-3 days in " + location + " (timezone: " + tz + ").\n" +
52+
"2) Practical tips.\n" +
53+
"3) 3-5 activity suggestions suited to the conditions (indoor/outdoor).\n" +
54+
"Be specific about temperature ranges, precipitation likelihood, wind, and humidity.\n" +
55+
"Keep it under 180 words, use short paragraphs and bullet points.\n\n" +
56+
"Weather JSON:\n" + reportJson + "\n\n" +
57+
"Respond in plain text (no JSON).";
58+
59+
try {
60+
return model.generate(prompt);
61+
} catch (RuntimeException ex) {
62+
return "AI summary unavailable at the moment. Reason: " + ex.getMessage();
63+
}
64+
}
65+
66+
private String safeReport(WeatherReport r) {
67+
try { return mapper.writeValueAsString(r); } catch (Exception e) { return "{}"; }
68+
}
69+
70+
private ChatLanguageModel getModel() {
71+
ChatLanguageModel m = cachedModel;
72+
if (m == null) {
73+
synchronized (this) {
74+
m = cachedModel;
75+
if (m == null) {
76+
cachedModel = m = GoogleAiGeminiChatModel.builder()
77+
.apiKey(apiKey)
78+
.modelName(modelName)
79+
.build();
80+
}
81+
}
82+
}
83+
return m;
84+
}
85+
}

src/main/java/com/example/weatherapp/weather/WeatherService.java

Lines changed: 107 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,15 @@ public WeatherReport fetchHourlyReport(double lat, double lon) {
3232
"temperature_2m",
3333
"precipitation",
3434
"wind_speed_10m",
35-
"relative_humidity_2m"
36-
))
35+
"relative_humidity_2m"))
3736
.queryParam("forecast_days", 3)
3837
.queryParam("timezone", "auto")
3938
.toUriString();
4039

4140
ResponseEntity<Map> resp = restTemplate.getForEntity(url, Map.class);
4241
Map body = resp.getBody();
43-
if (body == null) throw new IllegalStateException("Empty response from weather API");
42+
if (body == null)
43+
throw new IllegalStateException("Empty response from weather API");
4444

4545
// Map response into our DTO
4646
WeatherReport report = new WeatherReport();
@@ -52,11 +52,13 @@ public WeatherReport fetchHourlyReport(double lat, double lon) {
5252
if (!(hourlyObj instanceof Map<?, ?> hourly)) {
5353
throw new IllegalStateException("Unexpected response: missing hourly");
5454
}
55-
report.setTimes(asStringList(hourly.get("time")));
56-
report.setTemperature2m(asDoubleList(hourly.get("temperature_2m")));
57-
report.setPrecipitation(asDoubleList(hourly.get("precipitation")));
58-
report.setWindSpeed10m(asDoubleList(hourly.get("wind_speed_10m")));
59-
report.setRelativeHumidity2m(asDoubleList(hourly.get("relative_humidity_2m")));
55+
WeatherReport.Hourly hourlyData = new WeatherReport.Hourly();
56+
hourlyData.setTime(asStringList(hourly.get("time")));
57+
hourlyData.setTemperature2m(asDoubleList(hourly.get("temperature_2m")));
58+
hourlyData.setPrecipitation(asDoubleList(hourly.get("precipitation")));
59+
hourlyData.setWindSpeed10m(asDoubleList(hourly.get("wind_speed_10m")));
60+
hourlyData.setRelativeHumidity2m(asDoubleList(hourly.get("relative_humidity_2m")));
61+
report.setHourly(hourlyData);
6062

6163
return report;
6264
}
@@ -72,45 +74,113 @@ private static List<String> asStringList(Object o) {
7274

7375
@SuppressWarnings("unchecked")
7476
private static List<Double> asDoubleList(Object o) {
75-
if (o == null) return null;
77+
if (o == null)
78+
return null;
7679
return ((List<?>) o).stream().map(WeatherService::asDouble).toList();
7780
}
7881

7982
private static Double asDouble(Object o) {
80-
if (o == null) return null;
81-
if (o instanceof Number n) return n.doubleValue();
82-
try { return Double.parseDouble(String.valueOf(o)); } catch (Exception e) { return null; }
83+
if (o == null)
84+
return null;
85+
if (o instanceof Number n)
86+
return n.doubleValue();
87+
try {
88+
return Double.parseDouble(String.valueOf(o));
89+
} catch (Exception e) {
90+
return null;
91+
}
8392
}
8493

8594
@JsonIgnoreProperties(ignoreUnknown = true)
8695
public static class WeatherReport {
8796
private Double latitude;
8897
private Double longitude;
8998
private String timezone;
90-
private List<String> times;
91-
@JsonProperty("temperature_2m")
92-
private List<Double> temperature2m;
93-
private List<Double> precipitation;
94-
@JsonProperty("wind_speed_10m")
95-
private List<Double> windSpeed10m;
96-
@JsonProperty("relative_humidity_2m")
97-
private List<Double> relativeHumidity2m;
98-
99-
public Double getLatitude() { return latitude; }
100-
public void setLatitude(Double latitude) { this.latitude = latitude; }
101-
public Double getLongitude() { return longitude; }
102-
public void setLongitude(Double longitude) { this.longitude = longitude; }
103-
public String getTimezone() { return timezone; }
104-
public void setTimezone(String timezone) { this.timezone = timezone; }
105-
public List<String> getTimes() { return times; }
106-
public void setTimes(List<String> times) { this.times = times; }
107-
public List<Double> getTemperature2m() { return temperature2m; }
108-
public void setTemperature2m(List<Double> temperature2m) { this.temperature2m = temperature2m; }
109-
public List<Double> getPrecipitation() { return precipitation; }
110-
public void setPrecipitation(List<Double> precipitation) { this.precipitation = precipitation; }
111-
public List<Double> getWindSpeed10m() { return windSpeed10m; }
112-
public void setWindSpeed10m(List<Double> windSpeed10m) { this.windSpeed10m = windSpeed10m; }
113-
public List<Double> getRelativeHumidity2m() { return relativeHumidity2m; }
114-
public void setRelativeHumidity2m(List<Double> relativeHumidity2m) { this.relativeHumidity2m = relativeHumidity2m; }
99+
private Hourly hourly;
100+
101+
public Double getLatitude() {
102+
return latitude;
103+
}
104+
105+
public void setLatitude(Double latitude) {
106+
this.latitude = latitude;
107+
}
108+
109+
public Double getLongitude() {
110+
return longitude;
111+
}
112+
113+
public void setLongitude(Double longitude) {
114+
this.longitude = longitude;
115+
}
116+
117+
public String getTimezone() {
118+
return timezone;
119+
}
120+
121+
public void setTimezone(String timezone) {
122+
this.timezone = timezone;
123+
}
124+
125+
public Hourly getHourly() {
126+
return hourly;
127+
}
128+
129+
public void setHourly(Hourly hourly) {
130+
this.hourly = hourly;
131+
}
132+
133+
// Nested class to match the "hourly" object structure
134+
@JsonIgnoreProperties(ignoreUnknown = true)
135+
public static class Hourly {
136+
private List<String> time;
137+
@JsonProperty("temperature_2m")
138+
private List<Double> temperature2m;
139+
private List<Double> precipitation;
140+
@JsonProperty("wind_speed_10m")
141+
private List<Double> windSpeed10m;
142+
@JsonProperty("relative_humidity_2m")
143+
private List<Double> relativeHumidity2m;
144+
145+
public List<String> getTime() {
146+
return time;
147+
}
148+
149+
public void setTime(List<String> time) {
150+
this.time = time;
151+
}
152+
153+
public List<Double> getTemperature2m() {
154+
return temperature2m;
155+
}
156+
157+
public void setTemperature2m(List<Double> temperature2m) {
158+
this.temperature2m = temperature2m;
159+
}
160+
161+
public List<Double> getPrecipitation() {
162+
return precipitation;
163+
}
164+
165+
public void setPrecipitation(List<Double> precipitation) {
166+
this.precipitation = precipitation;
167+
}
168+
169+
public List<Double> getWindSpeed10m() {
170+
return windSpeed10m;
171+
}
172+
173+
public void setWindSpeed10m(List<Double> windSpeed10m) {
174+
this.windSpeed10m = windSpeed10m;
175+
}
176+
177+
public List<Double> getRelativeHumidity2m() {
178+
return relativeHumidity2m;
179+
}
180+
181+
public void setRelativeHumidity2m(List<Double> relativeHumidity2m) {
182+
this.relativeHumidity2m = relativeHumidity2m;
183+
}
184+
}
115185
}
116186
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.example.weatherapp.web;
2+
3+
import com.example.weatherapp.ai.AiSummaryService;
4+
import com.example.weatherapp.weather.WeatherService.WeatherReport;
5+
import org.springframework.http.HttpStatus;
6+
import org.springframework.http.MediaType;
7+
import org.springframework.http.ResponseEntity;
8+
import org.springframework.web.bind.annotation.*;
9+
10+
import java.util.Map;
11+
12+
@RestController
13+
@RequestMapping("/api/ai-summary")
14+
public class AiSummaryController {
15+
16+
private final AiSummaryService aiSummaryService;
17+
18+
public AiSummaryController(AiSummaryService aiSummaryService) {
19+
this.aiSummaryService = aiSummaryService;
20+
}
21+
22+
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
23+
public ResponseEntity<?> summarize(@RequestBody WeatherReport report,
24+
@RequestParam(value = "timezone", required = false) String timezone,
25+
@RequestParam(value = "city", required = false) String city) {
26+
try {
27+
String text = aiSummaryService.summarize(report, timezone, city);
28+
return ResponseEntity.ok(Map.of(
29+
"summary", text,
30+
"model", "gemini",
31+
"configured", aiSummaryService.isConfigured()
32+
));
33+
} catch (Exception e) {
34+
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of(
35+
"summary", "AI summary failed.",
36+
"error", e.getMessage()
37+
));
38+
}
39+
}
40+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
spring.application.name=weather-app-java
22
server.port=8080
33
spring.thymeleaf.cache=false
4+
5+
# Google Gemini API configuration (Google AI Studio / Generative Language API)
6+
# Provide via environment variable for security in production
7+
ai.gemini.api-key=${AI_API_KEY:}
8+
ai.gemini.model=${GOOGLE_GEMINI_MODEL:gemini-1.5-flash}

0 commit comments

Comments
 (0)