diff --git a/build.gradle b/build.gradle index a2bfc98..d29afa8 100644 --- a/build.gradle +++ b/build.gradle @@ -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' } diff --git a/src/main/java/com/example/weatherapp/ai/AiSummaryService.java b/src/main/java/com/example/weatherapp/ai/AiSummaryService.java new file mode 100644 index 0000000..02c1d46 --- /dev/null +++ b/src/main/java/com/example/weatherapp/ai/AiSummaryService.java @@ -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; + } +} diff --git a/src/main/java/com/example/weatherapp/weather/WeatherService.java b/src/main/java/com/example/weatherapp/weather/WeatherService.java index 7d0ce47..0f2a3c5 100644 --- a/src/main/java/com/example/weatherapp/weather/WeatherService.java +++ b/src/main/java/com/example/weatherapp/weather/WeatherService.java @@ -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 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(); @@ -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; } @@ -72,14 +74,21 @@ private static List asStringList(Object o) { @SuppressWarnings("unchecked") private static List 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) @@ -87,30 +96,91 @@ public static class WeatherReport { private Double latitude; private Double longitude; private String timezone; - private List times; - @JsonProperty("temperature_2m") - private List temperature2m; - private List precipitation; - @JsonProperty("wind_speed_10m") - private List windSpeed10m; - @JsonProperty("relative_humidity_2m") - private List 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 getTimes() { return times; } - public void setTimes(List times) { this.times = times; } - public List getTemperature2m() { return temperature2m; } - public void setTemperature2m(List temperature2m) { this.temperature2m = temperature2m; } - public List getPrecipitation() { return precipitation; } - public void setPrecipitation(List precipitation) { this.precipitation = precipitation; } - public List getWindSpeed10m() { return windSpeed10m; } - public void setWindSpeed10m(List windSpeed10m) { this.windSpeed10m = windSpeed10m; } - public List getRelativeHumidity2m() { return relativeHumidity2m; } - public void setRelativeHumidity2m(List 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 time; + @JsonProperty("temperature_2m") + private List temperature2m; + private List precipitation; + @JsonProperty("wind_speed_10m") + private List windSpeed10m; + @JsonProperty("relative_humidity_2m") + private List relativeHumidity2m; + + public List getTime() { + return time; + } + + public void setTime(List time) { + this.time = time; + } + + public List getTemperature2m() { + return temperature2m; + } + + public void setTemperature2m(List temperature2m) { + this.temperature2m = temperature2m; + } + + public List getPrecipitation() { + return precipitation; + } + + public void setPrecipitation(List precipitation) { + this.precipitation = precipitation; + } + + public List getWindSpeed10m() { + return windSpeed10m; + } + + public void setWindSpeed10m(List windSpeed10m) { + this.windSpeed10m = windSpeed10m; + } + + public List getRelativeHumidity2m() { + return relativeHumidity2m; + } + + public void setRelativeHumidity2m(List relativeHumidity2m) { + this.relativeHumidity2m = relativeHumidity2m; + } + } } } diff --git a/src/main/java/com/example/weatherapp/web/AiSummaryController.java b/src/main/java/com/example/weatherapp/web/AiSummaryController.java new file mode 100644 index 0000000..c4e3832 --- /dev/null +++ b/src/main/java/com/example/weatherapp/web/AiSummaryController.java @@ -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() + )); + } + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 0c074a1..a8cca54 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -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} diff --git a/src/main/resources/templates/report.html b/src/main/resources/templates/report.html index 4640045..a6bbe6e 100644 --- a/src/main/resources/templates/report.html +++ b/src/main/resources/templates/report.html @@ -85,6 +85,7 @@ } + @@ -102,6 +103,11 @@

Loading weather data…
+
+

AI summary & activity suggestions

+
Generating AI summary…
+
+ + +