Skip to content
Closed
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
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ dependencies {

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

testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
Expand Down
85 changes: 85 additions & 0 deletions src/main/java/com/example/weatherapp/ai/AiSummaryService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.example.weatherapp.ai;

import com.example.weatherapp.weather.WeatherService.WeatherReport;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.Objects;

@Service
public class AiSummaryService {

private final String apiKey;
private final String modelName;
private final ObjectMapper mapper = new ObjectMapper();

private volatile ChatLanguageModel cachedModel;

public AiSummaryService(
@Value("${ai.gemini.api-key:}") String apiKey,
@Value("${ai.gemini.model:gemini-1.5-flash}") String modelName
) {
this.apiKey = apiKey == null ? "" : apiKey.trim();
this.modelName = (modelName == null || modelName.isBlank()) ? "gemini-1.5-flash" : modelName;
}

public boolean isConfigured() {
return apiKey != null && !apiKey.isBlank();
}

public String summarize(WeatherReport report, String timezone, String city) {
if (!isConfigured()) {
return "AI summary unavailable: missing Google Gemini API key.";
}
ChatLanguageModel model = getModel();
String reportJson;
try {
reportJson = mapper.writeValueAsString(report);
} catch (JsonProcessingException e) {
reportJson = safeReport(report);
}

String location = city != null && !city.isBlank() ? city : "the provided coordinates";
String tz = timezone != null ? timezone : "auto";

String prompt = "You are an assistant that summarizes short-term weather forecasts for lay people.\n" +
"Given the JSON weather report from Open-Meteo (hourly arrays for next ~72 hours) and context, provide:\n" +
"1) A concise overview of the upcoming weather for the next 1-3 days in " + location + " (timezone: " + tz + ").\n" +
"2) Practical tips.\n" +
"3) 3-5 activity suggestions suited to the conditions (indoor/outdoor).\n" +
"Be specific about temperature ranges, precipitation likelihood, wind, and humidity.\n" +
"Keep it under 180 words, use short paragraphs and bullet points.\n\n" +
"Weather JSON:\n" + reportJson + "\n\n" +
"Respond in plain text (no JSON).";

try {
return model.generate(prompt);
} catch (RuntimeException ex) {
return "AI summary unavailable at the moment. Reason: " + ex.getMessage();
}
}

private String safeReport(WeatherReport r) {
try { return mapper.writeValueAsString(r); } catch (Exception e) { return "{}"; }
}

private ChatLanguageModel getModel() {
ChatLanguageModel m = cachedModel;
if (m == null) {
synchronized (this) {
m = cachedModel;
if (m == null) {
cachedModel = m = GoogleAiGeminiChatModel.builder()
.apiKey(apiKey)
.modelName(modelName)
.build();
}
}
}
return m;
}
}
144 changes: 107 additions & 37 deletions src/main/java/com/example/weatherapp/weather/WeatherService.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,15 @@ public WeatherReport fetchHourlyReport(double lat, double lon) {
"temperature_2m",
"precipitation",
"wind_speed_10m",
"relative_humidity_2m"
))
"relative_humidity_2m"))
.queryParam("forecast_days", 3)
.queryParam("timezone", "auto")
.toUriString();

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

// Map response into our DTO
WeatherReport report = new WeatherReport();
Expand All @@ -52,11 +52,13 @@ public WeatherReport fetchHourlyReport(double lat, double lon) {
if (!(hourlyObj instanceof Map<?, ?> hourly)) {
throw new IllegalStateException("Unexpected response: missing hourly");
}
report.setTimes(asStringList(hourly.get("time")));
report.setTemperature2m(asDoubleList(hourly.get("temperature_2m")));
report.setPrecipitation(asDoubleList(hourly.get("precipitation")));
report.setWindSpeed10m(asDoubleList(hourly.get("wind_speed_10m")));
report.setRelativeHumidity2m(asDoubleList(hourly.get("relative_humidity_2m")));
WeatherReport.Hourly hourlyData = new WeatherReport.Hourly();
hourlyData.setTime(asStringList(hourly.get("time")));
hourlyData.setTemperature2m(asDoubleList(hourly.get("temperature_2m")));
hourlyData.setPrecipitation(asDoubleList(hourly.get("precipitation")));
hourlyData.setWindSpeed10m(asDoubleList(hourly.get("wind_speed_10m")));
hourlyData.setRelativeHumidity2m(asDoubleList(hourly.get("relative_humidity_2m")));
report.setHourly(hourlyData);

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

@SuppressWarnings("unchecked")
private static List<Double> asDoubleList(Object o) {
if (o == null) return null;
if (o == null)
return null;
return ((List<?>) o).stream().map(WeatherService::asDouble).toList();
}

private static Double asDouble(Object o) {
if (o == null) return null;
if (o instanceof Number n) return n.doubleValue();
try { return Double.parseDouble(String.valueOf(o)); } catch (Exception e) { return null; }
if (o == null)
return null;
if (o instanceof Number n)
return n.doubleValue();
try {
return Double.parseDouble(String.valueOf(o));
} catch (Exception e) {
return null;
}
}

@JsonIgnoreProperties(ignoreUnknown = true)
public static class WeatherReport {
private Double latitude;
private Double longitude;
private String timezone;
private List<String> times;
@JsonProperty("temperature_2m")
private List<Double> temperature2m;
private List<Double> precipitation;
@JsonProperty("wind_speed_10m")
private List<Double> windSpeed10m;
@JsonProperty("relative_humidity_2m")
private List<Double> relativeHumidity2m;

public Double getLatitude() { return latitude; }
public void setLatitude(Double latitude) { this.latitude = latitude; }
public Double getLongitude() { return longitude; }
public void setLongitude(Double longitude) { this.longitude = longitude; }
public String getTimezone() { return timezone; }
public void setTimezone(String timezone) { this.timezone = timezone; }
public List<String> getTimes() { return times; }
public void setTimes(List<String> times) { this.times = times; }
public List<Double> getTemperature2m() { return temperature2m; }
public void setTemperature2m(List<Double> temperature2m) { this.temperature2m = temperature2m; }
public List<Double> getPrecipitation() { return precipitation; }
public void setPrecipitation(List<Double> precipitation) { this.precipitation = precipitation; }
public List<Double> getWindSpeed10m() { return windSpeed10m; }
public void setWindSpeed10m(List<Double> windSpeed10m) { this.windSpeed10m = windSpeed10m; }
public List<Double> getRelativeHumidity2m() { return relativeHumidity2m; }
public void setRelativeHumidity2m(List<Double> relativeHumidity2m) { this.relativeHumidity2m = relativeHumidity2m; }
private Hourly hourly;

public Double getLatitude() {
return latitude;
}

public void setLatitude(Double latitude) {
this.latitude = latitude;
}

public Double getLongitude() {
return longitude;
}

public void setLongitude(Double longitude) {
this.longitude = longitude;
}

public String getTimezone() {
return timezone;
}

public void setTimezone(String timezone) {
this.timezone = timezone;
}

public Hourly getHourly() {
return hourly;
}

public void setHourly(Hourly hourly) {
this.hourly = hourly;
}

// Nested class to match the "hourly" object structure
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Hourly {
private List<String> time;
@JsonProperty("temperature_2m")
private List<Double> temperature2m;
private List<Double> precipitation;
@JsonProperty("wind_speed_10m")
private List<Double> windSpeed10m;
@JsonProperty("relative_humidity_2m")
private List<Double> relativeHumidity2m;

public List<String> getTime() {
return time;
}

public void setTime(List<String> time) {
this.time = time;
}

public List<Double> getTemperature2m() {
return temperature2m;
}

public void setTemperature2m(List<Double> temperature2m) {
this.temperature2m = temperature2m;
}

public List<Double> getPrecipitation() {
return precipitation;
}

public void setPrecipitation(List<Double> precipitation) {
this.precipitation = precipitation;
}

public List<Double> getWindSpeed10m() {
return windSpeed10m;
}

public void setWindSpeed10m(List<Double> windSpeed10m) {
this.windSpeed10m = windSpeed10m;
}

public List<Double> getRelativeHumidity2m() {
return relativeHumidity2m;
}

public void setRelativeHumidity2m(List<Double> relativeHumidity2m) {
this.relativeHumidity2m = relativeHumidity2m;
}
}
}
}
40 changes: 40 additions & 0 deletions src/main/java/com/example/weatherapp/web/AiSummaryController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.example.weatherapp.web;

import com.example.weatherapp.ai.AiSummaryService;
import com.example.weatherapp.weather.WeatherService.WeatherReport;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/api/ai-summary")
public class AiSummaryController {

private final AiSummaryService aiSummaryService;

public AiSummaryController(AiSummaryService aiSummaryService) {
this.aiSummaryService = aiSummaryService;
}

@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<?> summarize(@RequestBody WeatherReport report,
@RequestParam(value = "timezone", required = false) String timezone,
@RequestParam(value = "city", required = false) String city) {
try {
String text = aiSummaryService.summarize(report, timezone, city);
return ResponseEntity.ok(Map.of(
"summary", text,
"model", "gemini",
"configured", aiSummaryService.isConfigured()
));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of(
"summary", "AI summary failed.",
"error", e.getMessage()
));
}
}
}
5 changes: 5 additions & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
spring.application.name=weather-app-java
server.port=8080
spring.thymeleaf.cache=false

# Google Gemini API configuration (Google AI Studio / Generative Language API)
# Provide via environment variable for security in production
ai.gemini.api-key=${AI_API_KEY:}
ai.gemini.model=${GOOGLE_GEMINI_MODEL:gemini-1.5-flash}
Loading