diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IWeatherReportProvider.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IWeatherReportProvider.cs index ad7a039..d0ba95e 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IWeatherReportProvider.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IWeatherReportProvider.cs @@ -12,7 +12,8 @@ public sealed record WeatherReportRequest( double? Latitude, double? Longitude, bool IsTomorrow, - bool? UseCelsius); + bool? UseCelsius, + int? ForecastDayOffset = null); public sealed record WeatherReportSnapshot( string LocationName, diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs index c5e67b8..f302d32 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs @@ -417,7 +417,8 @@ public sealed class JiboInteractionService( string transcript, CancellationToken cancellationToken) { - var dateEntity = TryResolveWeatherDateEntity(transcript); + var referenceLocalTime = TryResolveReferenceLocalTime(turn); + var weatherDate = ResolveWeatherDateEntity(turn, transcript, referenceLocalTime); if (weatherReportProvider is null) { return new JiboInteractionDecision( @@ -425,6 +426,13 @@ public sealed class JiboInteractionService( "I can check weather once my weather service is connected."); } + if (weatherDate.ForecastDayOffset > MaxWeatherForecastDayOffset) + { + return new JiboInteractionDecision( + "weather", + $"I can forecast up to {MaxWeatherForecastDayOffset} days ahead. Try tomorrow or another day this week."); + } + var locationQuery = TryResolveWeatherLocationQuery(transcript); var weatherCoordinates = TryResolveWeatherCoordinates(turn); var useCelsius = ShouldUseCelsius(turn, transcript); @@ -436,8 +444,9 @@ public sealed class JiboInteractionService( locationQuery, weatherCoordinates?.Latitude, weatherCoordinates?.Longitude, - string.Equals(dateEntity, "tomorrow", StringComparison.OrdinalIgnoreCase), - useCelsius), + string.Equals(weatherDate.DateEntity, "tomorrow", StringComparison.OrdinalIgnoreCase), + useCelsius, + weatherDate.ForecastDayOffset), cancellationToken); } catch (Exception) when (!cancellationToken.IsCancellationRequested) @@ -452,8 +461,8 @@ public sealed class JiboInteractionService( "I couldn't fetch the weather right now. Please try again."); } - var spokenReply = BuildWeatherSpokenReply(snapshot, dateEntity); - var weatherPayload = BuildWeatherSkillPayload(spokenReply, snapshot, TryResolveReferenceLocalTime(turn)); + var spokenReply = BuildWeatherSpokenReply(snapshot, weatherDate); + var weatherPayload = BuildWeatherSkillPayload(spokenReply, snapshot, referenceLocalTime); return new JiboInteractionDecision( "weather", spokenReply, @@ -463,7 +472,7 @@ public sealed class JiboInteractionService( private static string BuildWeatherSpokenReply( WeatherReportSnapshot snapshot, - string? dateEntity) + WeatherDateEntity weatherDate) { var unit = snapshot.UseCelsius ? "Celsius" : "Fahrenheit"; var summary = string.IsNullOrWhiteSpace(snapshot.Summary) @@ -473,7 +482,7 @@ public sealed class JiboInteractionService( ? "your area" : snapshot.LocationName; - if (string.Equals(dateEntity, "tomorrow", StringComparison.OrdinalIgnoreCase)) + if (weatherDate.ForecastDayOffset > 0) { var highText = snapshot.HighTemperature is null ? null @@ -486,7 +495,10 @@ public sealed class JiboInteractionService( : highText is not null && lowText is not null ? $" with {highText} and {lowText}" : $" with {highText ?? lowText}"; - return $"Tomorrow in {location}, expect {summary}{tempRange}."; + var forecastLeadIn = string.IsNullOrWhiteSpace(weatherDate.ForecastLeadIn) + ? "Tomorrow" + : weatherDate.ForecastLeadIn; + return $"{forecastLeadIn} in {location}, expect {summary}{tempRange}."; } return $"Right now in {location}, it is {summary} and {snapshot.Temperature} degrees {unit}."; @@ -1809,6 +1821,12 @@ public sealed class JiboInteractionService( private static bool IsWeatherRequest(string loweredTranscript) { + var normalized = NormalizeCommandPhrase(loweredTranscript); + if (IsWeatherTopicQuestion(normalized)) + { + return true; + } + if (MatchesAny( loweredTranscript, "weather", @@ -1864,6 +1882,41 @@ public sealed class JiboInteractionService( return WeatherConditionForecastPattern.IsMatch(loweredTranscript); } + private static bool IsWeatherTopicQuestion(string normalizedTranscript) + { + if (string.IsNullOrWhiteSpace(normalizedTranscript)) + { + return false; + } + + var mentionsWeatherTopic = + normalizedTranscript.Contains("weather", StringComparison.Ordinal) || + normalizedTranscript.Contains("forecast", StringComparison.Ordinal) || + normalizedTranscript.Contains("temperature", StringComparison.Ordinal) || + normalizedTranscript.Contains("humidity", StringComparison.Ordinal); + if (!mentionsWeatherTopic) + { + return false; + } + + if (normalizedTranscript.StartsWith("what ", StringComparison.Ordinal) || + normalizedTranscript.StartsWith("how ", StringComparison.Ordinal) || + normalizedTranscript.StartsWith("check ", StringComparison.Ordinal) || + normalizedTranscript.StartsWith("show ", StringComparison.Ordinal) || + normalizedTranscript.StartsWith("tell ", StringComparison.Ordinal) || + normalizedTranscript.StartsWith("look up ", StringComparison.Ordinal) || + normalizedTranscript.StartsWith("launch ", StringComparison.Ordinal) || + normalizedTranscript.StartsWith("give me ", StringComparison.Ordinal) || + normalizedTranscript.StartsWith("temperature ", StringComparison.Ordinal) || + normalizedTranscript.StartsWith("forecast ", StringComparison.Ordinal) || + normalizedTranscript.StartsWith("weather ", StringComparison.Ordinal)) + { + return true; + } + + return WeatherTopicLocationPattern.IsMatch(normalizedTranscript); + } + private static string? TryResolveWeatherLocationQuery(string transcript) { var normalized = NormalizeCommandPhrase(transcript); @@ -1975,15 +2028,247 @@ public sealed class JiboInteractionService( return null; } - private static string? TryResolveWeatherDateEntity(string transcript) + private static WeatherDateEntity ResolveWeatherDateEntity( + TurnContext turn, + string transcript, + DateTimeOffset? referenceLocalTime) { - var normalized = NormalizeCommandPhrase(transcript); - if (MatchesAny(normalized, "tomorrow", "tomorrow s", "tomorrow's")) + var entities = ReadEntities(turn); + if (TryResolveWeatherDateEntityFromClientEntities(entities, referenceLocalTime, out var entityFromClient)) { - return "tomorrow"; + return entityFromClient; } - return null; + var normalized = NormalizeCommandPhrase(transcript); + if (string.IsNullOrWhiteSpace(normalized)) + { + return WeatherDateEntity.None; + } + + if (normalized.Contains("day after tomorrow", StringComparison.Ordinal)) + { + return new WeatherDateEntity("day_after_tomorrow", 2, "The day after tomorrow"); + } + + if (MatchesAny(normalized, "tomorrow", "tomorrow s", "tomorrow's")) + { + return new WeatherDateEntity("tomorrow", 1, "Tomorrow"); + } + + if (referenceLocalTime is not null && + TryResolveWeatherTimeRangeOffset(normalized, referenceLocalTime.Value, out var rangeOffset, out var rangeLeadIn) && + rangeOffset > 0) + { + return new WeatherDateEntity("range", rangeOffset, rangeLeadIn); + } + + if (referenceLocalTime is not null && + TryResolveWeatherDayOfWeekOffset(normalized, referenceLocalTime.Value, out var dayOffset, out var dayName) && + dayOffset > 0) + { + return new WeatherDateEntity("weekday", dayOffset, $"On {dayName}"); + } + + return WeatherDateEntity.None; + } + + private static bool TryResolveWeatherDateEntityFromClientEntities( + IReadOnlyDictionary clientEntities, + DateTimeOffset? referenceLocalTime, + out WeatherDateEntity weatherDate) + { + weatherDate = WeatherDateEntity.None; + if (!TryReadClientWeatherDateValue(clientEntities, out var rawDateValue)) + { + return false; + } + + var normalizedDate = NormalizeCommandPhrase(rawDateValue); + if (normalizedDate.Contains("day after tomorrow", StringComparison.Ordinal)) + { + weatherDate = new WeatherDateEntity("day_after_tomorrow", 2, "The day after tomorrow"); + return true; + } + + if (MatchesAny(normalizedDate, "tomorrow", "tomorrow s", "tomorrow's")) + { + weatherDate = new WeatherDateEntity("tomorrow", 1, "Tomorrow"); + return true; + } + + if (referenceLocalTime is not null && + TryResolveWeatherTimeRangeOffset(normalizedDate, referenceLocalTime.Value, out var rangeOffset, out var rangeLeadIn) && + rangeOffset > 0) + { + weatherDate = new WeatherDateEntity("range", rangeOffset, rangeLeadIn); + return true; + } + + DateOnly targetDate; + if (DateOnly.TryParse(rawDateValue, out var parsedDate)) + { + targetDate = parsedDate; + } + else if (DateTimeOffset.TryParse(rawDateValue, out var parsedDateTimeOffset)) + { + targetDate = DateOnly.FromDateTime(parsedDateTimeOffset.DateTime); + } + else + { + return false; + } + + var referenceDate = DateOnly.FromDateTime((referenceLocalTime ?? DateTimeOffset.UtcNow).DateTime); + var dayOffset = targetDate.DayNumber - referenceDate.DayNumber; + if (dayOffset <= 0) + { + return false; + } + + weatherDate = dayOffset == 1 + ? new WeatherDateEntity("tomorrow", 1, "Tomorrow") + : new WeatherDateEntity( + "date", + dayOffset, + $"On {targetDate.ToDateTime(TimeOnly.MinValue).ToString("dddd", CultureInfo.InvariantCulture)}"); + return true; + } + + private static bool TryReadClientWeatherDateValue( + IReadOnlyDictionary clientEntities, + out string dateValue) + { + foreach (var key in WeatherDateEntityKeys) + { + if (!clientEntities.TryGetValue(key, out var rawValue) || + string.IsNullOrWhiteSpace(rawValue)) + { + continue; + } + + dateValue = rawValue.Trim(); + return true; + } + + dateValue = string.Empty; + return false; + } + + private static bool TryResolveWeatherDayOfWeekOffset( + string normalizedTranscript, + DateTimeOffset referenceLocalTime, + out int dayOffset, + out string dayName) + { + dayOffset = 0; + dayName = string.Empty; + + var match = WeatherDayOfWeekPattern.Match(normalizedTranscript); + if (!match.Success) + { + return false; + } + + var dayToken = match.Groups["day"].Value; + if (!TryParseDayOfWeek(dayToken, out var targetDay)) + { + return false; + } + + var currentDay = referenceLocalTime.DayOfWeek; + dayOffset = ((int)targetDay - (int)currentDay + 7) % 7; + if (match.Groups["next"].Success) + { + dayOffset = dayOffset == 0 ? 7 : dayOffset + 7; + } + else if (match.Groups["this"].Success && dayOffset == 0) + { + return false; + } + + dayName = CultureInfo.InvariantCulture.TextInfo.ToTitleCase(dayToken); + return dayOffset > 0; + } + + private static bool TryResolveWeatherTimeRangeOffset( + string normalizedTranscript, + DateTimeOffset referenceLocalTime, + out int dayOffset, + out string leadIn) + { + dayOffset = 0; + leadIn = string.Empty; + if (string.IsNullOrWhiteSpace(normalizedTranscript)) + { + return false; + } + + var hasNextWeekend = normalizedTranscript.Contains("next weekend", StringComparison.Ordinal); + var hasThisWeekend = + normalizedTranscript.Contains("this weekend", StringComparison.Ordinal) || + normalizedTranscript.Contains("the weekend", StringComparison.Ordinal) || + normalizedTranscript.EndsWith("weekend", StringComparison.Ordinal); + if (hasNextWeekend || hasThisWeekend) + { + dayOffset = ((int)DayOfWeek.Saturday - (int)referenceLocalTime.DayOfWeek + 7) % 7; + if (hasNextWeekend) + { + dayOffset = dayOffset + 7; + leadIn = "Next weekend"; + } + else + { + // If it's already Saturday, prefer forecasting Sunday for "this weekend". + if (dayOffset == 0 && referenceLocalTime.DayOfWeek == DayOfWeek.Saturday) + { + dayOffset = 1; + } + + leadIn = "This weekend"; + } + + return dayOffset > 0; + } + + var hasNextWeek = normalizedTranscript.Contains("next week", StringComparison.Ordinal); + if (hasNextWeek) + { + dayOffset = 7; + leadIn = "Next week"; + return true; + } + + var hasThisWeek = normalizedTranscript.Contains("this week", StringComparison.Ordinal); + if (hasThisWeek) + { + dayOffset = referenceLocalTime.DayOfWeek == DayOfWeek.Saturday ? 1 : 2; + leadIn = "Later this week"; + return true; + } + + return false; + } + + private static bool TryParseDayOfWeek(string dayToken, out DayOfWeek dayOfWeek) + { + dayOfWeek = DayOfWeek.Sunday; + return dayToken switch + { + "monday" => AssignDayOfWeek(DayOfWeek.Monday, out dayOfWeek), + "tuesday" => AssignDayOfWeek(DayOfWeek.Tuesday, out dayOfWeek), + "wednesday" => AssignDayOfWeek(DayOfWeek.Wednesday, out dayOfWeek), + "thursday" => AssignDayOfWeek(DayOfWeek.Thursday, out dayOfWeek), + "friday" => AssignDayOfWeek(DayOfWeek.Friday, out dayOfWeek), + "saturday" => AssignDayOfWeek(DayOfWeek.Saturday, out dayOfWeek), + "sunday" => AssignDayOfWeek(DayOfWeek.Sunday, out dayOfWeek), + _ => false + }; + } + + private static bool AssignDayOfWeek(DayOfWeek value, out DayOfWeek target) + { + target = value; + return true; } private static string? TryResolveWeatherConditionEntity(string transcript) @@ -3018,6 +3303,11 @@ public sealed class JiboInteractionService( private sealed record PizzaSignal(PersonalAffinity? Affinity); + private sealed record WeatherDateEntity(string? DateEntity, int ForecastDayOffset, string? ForecastLeadIn) + { + public static WeatherDateEntity None { get; } = new(null, 0, null); + } + private static readonly Regex SplitAlarmPattern = new( @"\b(?\d{1,2}|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)(?:[:\s,-]+(?\d{2}|[a-z\-]+(?:\s+[a-z\-]+)?))?\s*(?a[\s\.]*m\.?|p[\s\.]*m\.?)?\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); @@ -3051,13 +3341,21 @@ public sealed class JiboInteractionService( RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); private static readonly Regex WeatherLocationSuffixPattern = new( - @"\b(?:today|tonight|tomorrow|outside|right now|please|thanks|this weekend|next weekend|the weekend|weekend|this week|next week|on monday|on tuesday|on wednesday|on thursday|on friday|on saturday|on sunday|monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b", + @"\b(?:today|tonight|tomorrow|day after tomorrow|outside|right now|please|thanks|this weekend|next weekend|the weekend|weekend|this week|next week|on monday|on tuesday|on wednesday|on thursday|on friday|on saturday|on sunday|this monday|this tuesday|this wednesday|this thursday|this friday|this saturday|this sunday|next monday|next tuesday|next wednesday|next thursday|next friday|next saturday|next sunday|monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); private static readonly Regex WeatherConditionForecastPattern = new( @"\bwill it be\s+(sunny|cloudy|windy|foggy|stormy|rainy|snowy|hail|hailing)\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + private static readonly Regex WeatherTopicLocationPattern = new( + @"\b(?:weather|forecast|temperature|humidity)\b.*\b(?:in|for|at)\s+[a-z]", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + + private static readonly Regex WeatherDayOfWeekPattern = new( + @"\b(?next\s+)?(?this\s+)?(?:on\s+)?(?monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + private static readonly PizzaMimPrompt[] PizzaMimPrompts = [ new("RA_JBO_ShowPizzaMaking_AN_01", ""), @@ -3079,6 +3377,16 @@ public sealed class JiboInteractionService( " are my favourite " ]; + private static readonly string[] WeatherDateEntityKeys = + [ + "date", + "sys.date", + "datetime", + "dateTime", + "date_time", + "day" + ]; + // Directly imported from Pegasus parser intent phrase families: // userLikesThing / userDislikesThing / doesUserLikeThing / doesUserDislikeThing. private static readonly (string Prefix, PersonalAffinity Affinity)[] PegasusUserAffinitySetPrefixes = @@ -3152,6 +3460,8 @@ public sealed class JiboInteractionService( "our neighbourhood" }; + private const int MaxWeatherForecastDayOffset = 5; + private static readonly (string Phrase, string Station)[] RadioGenreAliases = [ ("country music", "Country"), diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ResponsePlanToSocketMessagesMapper.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ResponsePlanToSocketMessagesMapper.cs index c887f67..3157f1b 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ResponsePlanToSocketMessagesMapper.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ResponsePlanToSocketMessagesMapper.cs @@ -795,19 +795,20 @@ public sealed class ResponsePlanToSocketMessagesMapper var promptId = ReadPayloadString(skillPayload, "prompt_id") ?? "RUNTIME_PROMPT"; var promptSubCategory = ReadPayloadString(skillPayload, "prompt_sub_category") ?? "AN"; var listenContexts = ReadPayloadStringArray(skillPayload, "listen_contexts"); + var playConfig = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["esml"] = esml, + ["meta"] = new + { + prompt_id = promptId, + prompt_sub_category = promptSubCategory, + mim_id = mimId, + mim_type = mimType + } + }; var jcpConfig = new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["play"] = new - { - esml, - meta = new - { - prompt_id = promptId, - prompt_sub_category = promptSubCategory, - mim_id = mimId, - mim_type = mimType - } - } + ["play"] = playConfig }; if (listenContexts.Count > 0) @@ -823,12 +824,16 @@ public sealed class ResponsePlanToSocketMessagesMapper var weatherHiLoView = BuildWeatherHiLoView(skillPayload); if (weatherHiLoView is not null) { - jcpConfig["gui"] = new + var guiConfig = new { type = "Javascript", data = "views.weatherHiLo", pause = true }; + jcpConfig["gui"] = guiConfig; + playConfig["gui"] = guiConfig; + playConfig["no_matches_for_gui"] = 0; + playConfig["no_inputs_for_gui"] = 0; jcpConfig["views"] = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["weatherHiLo"] = weatherHiLoView @@ -1110,6 +1115,11 @@ public sealed class ResponsePlanToSocketMessagesMapper return null; } + var hiNumX = GetTemperatureLabelXPosition(370, high.Value); + var hiUnitX = GetTemperatureLabelXPosition(360, high.Value); + var loNumX = GetTemperatureLabelXPosition(1110, low.Value); + var loUnitX = GetTemperatureLabelXPosition(1100, low.Value); + return new Dictionary(StringComparer.OrdinalIgnoreCase) { ["viewConfig"] = new @@ -1165,7 +1175,7 @@ public sealed class ResponsePlanToSocketMessagesMapper { id = "hiNumLabel", type = "Label", - text = $"{high.Value}°", + text = $"{high.Value}\u00B0", style = new { fontSize = "160", @@ -1174,7 +1184,7 @@ public sealed class ResponsePlanToSocketMessagesMapper fill = "#FFFFFF", align = "center" }, - position = new { x = 370, y = 430 }, + position = new { x = hiNumX, y = 430 }, targetAnchor = new { x = 1, y = 1 } }, new @@ -1190,14 +1200,14 @@ public sealed class ResponsePlanToSocketMessagesMapper fill = "#FFFFFF", align = "center" }, - position = new { x = 360, y = 418 }, + position = new { x = hiUnitX, y = 418 }, targetAnchor = new { x = 0, y = 1 } }, new { id = "loNumLabel", type = "Label", - text = $"{low.Value}°", + text = $"{low.Value}\u00B0", style = new { fontSize = "160", @@ -1206,7 +1216,7 @@ public sealed class ResponsePlanToSocketMessagesMapper fill = "#FFFFFF", align = "center" }, - position = new { x = 1110, y = 430 }, + position = new { x = loNumX, y = 430 }, targetAnchor = new { x = 1, y = 1 } }, new @@ -1222,13 +1232,58 @@ public sealed class ResponsePlanToSocketMessagesMapper fill = "#FFFFFF", align = "center" }, - position = new { x = 1100, y = 418 }, + position = new { x = loUnitX, y = 418 }, targetAnchor = new { x = 0, y = 1 } + }, + new + { + id = "hiTextLabel", + type = "Label", + text = "Hi", + style = new + { + fontSize = "60", + fontFamily = "Proxima Nova Light", + fill = "#FFFFFF", + align = "center" + }, + position = new { x = 280, y = 496 }, + targetAnchor = new { x = 0.5, y = 1 } + }, + new + { + id = "loTextLabel", + type = "Label", + text = "Lo", + style = new + { + fontSize = "60", + fontFamily = "Proxima Nova Light", + fill = "#FFFFFF", + align = "center" + }, + position = new { x = 990, y = 496 }, + targetAnchor = new { x = 0.5, y = 1 } } } }; } + private static int GetTemperatureLabelXPosition(int baseX, int temperature) + { + const int xOffset = 70; + if (temperature < -9 || temperature > 99) + { + return baseX + xOffset; + } + + if (temperature is >= 0 and < 10) + { + return baseX - xOffset; + } + + return baseX; + } private static int? TryReadPayloadInt(IDictionary? payload, string key) { if (payload is null || !payload.TryGetValue(key, out var value) || value is null) @@ -1279,3 +1334,4 @@ public sealed class ResponsePlanToSocketMessagesMapper public sealed record SocketReplyPlan(string Text, int DelayMs = 0); } + diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Weather/OpenWeatherReportProvider.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Weather/OpenWeatherReportProvider.cs index 9b107a7..a1ca235 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Weather/OpenWeatherReportProvider.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Weather/OpenWeatherReportProvider.cs @@ -29,9 +29,18 @@ public sealed class OpenWeatherReportProvider( } var useCelsius = request.UseCelsius ?? options.UseCelsius; - return request.IsTomorrow - ? await GetTomorrowForecastAsync(location.Value, useCelsius, cancellationToken) - : await GetCurrentWeatherAsync(location.Value, useCelsius, cancellationToken); + var forecastDayOffset = request.ForecastDayOffset ?? (request.IsTomorrow ? 1 : 0); + if (forecastDayOffset <= 0) + { + return await GetCurrentWeatherAsync(location.Value, useCelsius, cancellationToken); + } + + if (forecastDayOffset > MaxForecastDayOffset) + { + return null; + } + + return await GetForecastForDayOffsetAsync(location.Value, useCelsius, forecastDayOffset, cancellationToken); } catch (Exception exception) { @@ -134,9 +143,10 @@ public sealed class OpenWeatherReportProvider( useCelsius); } - private async Task GetTomorrowForecastAsync( + private async Task GetForecastForDayOffsetAsync( LocationPoint location, bool useCelsius, + int forecastDayOffset, CancellationToken cancellationToken) { var forecastUri = BuildRequestUri( @@ -160,7 +170,7 @@ public sealed class OpenWeatherReportProvider( } var offset = TryReadForecastOffset(root); - var tomorrow = DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(offset).DateTime.AddDays(1)); + var targetDate = DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(offset).DateTime.AddDays(forecastDayOffset)); var entries = new List(); foreach (var item in list.EnumerateArray()) { @@ -170,7 +180,7 @@ public sealed class OpenWeatherReportProvider( } var localTimestamp = DateTimeOffset.FromUnixTimeSeconds(unixSeconds).ToOffset(offset); - if (DateOnly.FromDateTime(localTimestamp.DateTime) != tomorrow) + if (DateOnly.FromDateTime(localTimestamp.DateTime) != targetDate) { continue; } @@ -361,4 +371,6 @@ public sealed class OpenWeatherReportProvider( int? LowTemperature, string? Summary, string? Condition); + + private const int MaxForecastDayOffset = 5; } diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs index ffdcee4..e15a0c2 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs @@ -1196,6 +1196,7 @@ public sealed class JiboInteractionServiceTests Assert.Equal("Right now in Boston, US, it is light rain and 61 degrees Fahrenheit.", decision.ReplyText); Assert.NotNull(provider.LastRequest); Assert.False(provider.LastRequest!.IsTomorrow); + Assert.Equal(0, provider.LastRequest.ForecastDayOffset); } [Fact] @@ -1216,6 +1217,7 @@ public sealed class JiboInteractionServiceTests Assert.Equal("weather", decision.IntentName); Assert.Equal("Chicago", provider.LastRequest?.LocationQuery); Assert.True(provider.LastRequest?.IsTomorrow); + Assert.Equal(1, provider.LastRequest?.ForecastDayOffset); Assert.Equal("Tomorrow in Chicago, US, expect mostly cloudy with a high near 74 degrees Fahrenheit and a low around 60 degrees Fahrenheit.", decision.ReplyText); } @@ -1237,6 +1239,7 @@ public sealed class JiboInteractionServiceTests Assert.Equal("weather", decision.IntentName); Assert.Equal("Seattle", provider.LastRequest?.LocationQuery); Assert.False(provider.LastRequest?.IsTomorrow); + Assert.Equal(0, provider.LastRequest?.ForecastDayOffset); Assert.Equal("Right now in Seattle, US, it is light rain and 58 degrees Fahrenheit.", decision.ReplyText); } @@ -1258,9 +1261,231 @@ public sealed class JiboInteractionServiceTests Assert.Equal("weather", decision.IntentName); Assert.Equal("Paris", provider.LastRequest?.LocationQuery); Assert.False(provider.LastRequest?.IsTomorrow); + Assert.Equal(0, provider.LastRequest?.ForecastDayOffset); Assert.Equal("Right now in Paris, FR, it is overcast clouds and 66 degrees Fahrenheit.", decision.ReplyText); } + [Fact] + public async Task BuildDecisionAsync_TemperatureLocationQuery_WithProvider_MapsToWeatherIntent() + { + var provider = new CapturingWeatherReportProvider + { + Snapshot = new WeatherReportSnapshot("Redmond, US", "clear sky", 63, 66, 52, "sunny", false) + }; + var service = CreateService(weatherReportProvider: provider); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "what is the temperature in redmond oregon", + NormalizedTranscript = "what is the temperature in redmond oregon" + }); + + Assert.Equal("weather", decision.IntentName); + Assert.Equal("Redmond Oregon", provider.LastRequest?.LocationQuery); + Assert.False(provider.LastRequest?.IsTomorrow); + Assert.Equal(0, provider.LastRequest?.ForecastDayOffset); + Assert.Equal("Right now in Redmond, US, it is clear sky and 63 degrees Fahrenheit.", decision.ReplyText); + } + + [Fact] + public async Task BuildDecisionAsync_ForecastLocationQuery_WithProvider_MapsToWeatherIntent() + { + var provider = new CapturingWeatherReportProvider + { + Snapshot = new WeatherReportSnapshot("New York, US", "partly cloudy", 71, 76, 61, "cloudy", false) + }; + var service = CreateService(weatherReportProvider: provider); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "forecast for new york city", + NormalizedTranscript = "forecast for new york city" + }); + + Assert.Equal("weather", decision.IntentName); + Assert.Equal("New York City", provider.LastRequest?.LocationQuery); + Assert.False(provider.LastRequest?.IsTomorrow); + Assert.Equal(0, provider.LastRequest?.ForecastDayOffset); + Assert.Equal("Right now in New York, US, it is partly cloudy and 71 degrees Fahrenheit.", decision.ReplyText); + } + + [Fact] + public async Task BuildDecisionAsync_WeatherQueryWithClientDateEntity_UsesForecastDayOffset() + { + var provider = new CapturingWeatherReportProvider + { + Snapshot = new WeatherReportSnapshot("Portland, US", "scattered clouds", 64, 68, 53, "cloudy", false) + }; + var service = CreateService(weatherReportProvider: provider); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "what's the weather", + NormalizedTranscript = "what's the weather", + Attributes = new Dictionary + { + ["clientEntities"] = new Dictionary + { + ["date"] = "2026-05-11" + }, + ["context"] = """{"runtime":{"location":{"iso":"2026-05-09T09:00:00-05:00"}}}""" + } + }); + + Assert.Equal("weather", decision.IntentName); + Assert.Equal(2, provider.LastRequest?.ForecastDayOffset); + Assert.False(provider.LastRequest?.IsTomorrow); + Assert.Equal("On Monday in Portland, US, expect scattered clouds with a high near 68 degrees Fahrenheit and a low around 53 degrees Fahrenheit.", decision.ReplyText); + } + + [Fact] + public async Task BuildDecisionAsync_WeatherQueryWithWeekday_UsesForecastDayOffset() + { + var provider = new CapturingWeatherReportProvider + { + Snapshot = new WeatherReportSnapshot("Chicago, US", "light rain", 59, 63, 51, "rain", false) + }; + var service = CreateService(weatherReportProvider: provider); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "what's the weather in chicago on tuesday", + NormalizedTranscript = "what's the weather in chicago on tuesday", + Attributes = new Dictionary + { + ["context"] = """{"runtime":{"location":{"iso":"2026-04-20T08:00:00-05:00"}}}""" + } + }); + + Assert.Equal("weather", decision.IntentName); + Assert.Equal("Chicago", provider.LastRequest?.LocationQuery); + Assert.Equal(1, provider.LastRequest?.ForecastDayOffset); + Assert.Equal("On Tuesday in Chicago, US, expect light rain with a high near 63 degrees Fahrenheit and a low around 51 degrees Fahrenheit.", decision.ReplyText); + } + + [Fact] + public async Task BuildDecisionAsync_WeatherQueryBeyondSupportedForecastRange_ReturnsGuardrailMessage() + { + var provider = new CapturingWeatherReportProvider + { + Snapshot = new WeatherReportSnapshot("Chicago, US", "light rain", 59, 63, 51, "rain", false) + }; + var service = CreateService(weatherReportProvider: provider); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "what's the weather next saturday", + NormalizedTranscript = "what's the weather next saturday", + Attributes = new Dictionary + { + ["context"] = """{"runtime":{"location":{"iso":"2026-04-20T08:00:00-05:00"}}}""" + } + }); + + Assert.Equal("weather", decision.IntentName); + Assert.Equal("I can forecast up to 5 days ahead. Try tomorrow or another day this week.", decision.ReplyText); + Assert.Null(provider.LastRequest); + } + + [Fact] + public async Task BuildDecisionAsync_WeatherThisWeekend_WithContext_UsesWeekendOffset() + { + var provider = new CapturingWeatherReportProvider + { + Snapshot = new WeatherReportSnapshot("Paris, FR", "overcast clouds", 66, 70, 60, "cloudy", false) + }; + var service = CreateService(weatherReportProvider: provider); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "what's the weather in paris this weekend", + NormalizedTranscript = "what's the weather in paris this weekend", + Attributes = new Dictionary + { + ["context"] = """{"runtime":{"location":{"iso":"2026-04-20T08:00:00-05:00"}}}""" + } + }); + + Assert.Equal("weather", decision.IntentName); + Assert.Equal("Paris", provider.LastRequest?.LocationQuery); + Assert.Equal(5, provider.LastRequest?.ForecastDayOffset); + Assert.Equal("This weekend in Paris, FR, expect overcast clouds with a high near 70 degrees Fahrenheit and a low around 60 degrees Fahrenheit.", decision.ReplyText); + } + + [Fact] + public async Task BuildDecisionAsync_WeatherThisWeek_WithContext_UsesRangeOffset() + { + var provider = new CapturingWeatherReportProvider + { + Snapshot = new WeatherReportSnapshot("Seattle, US", "light rain", 58, 61, 52, "rain", false) + }; + var service = CreateService(weatherReportProvider: provider); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "forecast for seattle this week", + NormalizedTranscript = "forecast for seattle this week", + Attributes = new Dictionary + { + ["context"] = """{"runtime":{"location":{"iso":"2026-04-20T08:00:00-05:00"}}}""" + } + }); + + Assert.Equal("weather", decision.IntentName); + Assert.Equal("Seattle", provider.LastRequest?.LocationQuery); + Assert.Equal(2, provider.LastRequest?.ForecastDayOffset); + Assert.Equal("Later this week in Seattle, US, expect light rain with a high near 61 degrees Fahrenheit and a low around 52 degrees Fahrenheit.", decision.ReplyText); + } + + [Fact] + public async Task BuildDecisionAsync_WeatherNextWeek_WithContext_ReturnsGuardrailMessage() + { + var provider = new CapturingWeatherReportProvider + { + Snapshot = new WeatherReportSnapshot("Seattle, US", "light rain", 58, 61, 52, "rain", false) + }; + var service = CreateService(weatherReportProvider: provider); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "forecast for seattle next week", + NormalizedTranscript = "forecast for seattle next week", + Attributes = new Dictionary + { + ["context"] = """{"runtime":{"location":{"iso":"2026-04-20T08:00:00-05:00"}}}""" + } + }); + + Assert.Equal("weather", decision.IntentName); + Assert.Equal("I can forecast up to 5 days ahead. Try tomorrow or another day this week.", decision.ReplyText); + Assert.Null(provider.LastRequest); + } + + [Fact] + public async Task BuildDecisionAsync_WeatherDayAfterTomorrow_WithContext_PassesDayOffsetAndLocation() + { + var provider = new CapturingWeatherReportProvider + { + Snapshot = new WeatherReportSnapshot("Chicago, US", "mostly cloudy", 72, 74, 60, "cloudy", false) + }; + var service = CreateService(weatherReportProvider: provider); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "what's the weather in chicago day after tomorrow", + NormalizedTranscript = "what's the weather in chicago day after tomorrow", + Attributes = new Dictionary + { + ["context"] = """{"runtime":{"location":{"iso":"2026-04-20T08:00:00-05:00"}}}""" + } + }); + + Assert.Equal("weather", decision.IntentName); + Assert.Equal("Chicago", provider.LastRequest?.LocationQuery); + Assert.Equal(2, provider.LastRequest?.ForecastDayOffset); + Assert.Equal("The day after tomorrow in Chicago, US, expect mostly cloudy with a high near 74 degrees Fahrenheit and a low around 60 degrees Fahrenheit.", decision.ReplyText); + } + [Fact] public async Task BuildDecisionAsync_ClientNluAskForDate_MapsToDateIntent() { diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs index 8d1bd6b..39b926c 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs @@ -2035,6 +2035,12 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("views.weatherHiLo", gui.GetProperty("data").GetString()); Assert.True(gui.GetProperty("pause").GetBoolean()); + var play = jcpConfig.GetProperty("play"); + Assert.True(play.TryGetProperty("gui", out var playGui)); + Assert.Equal("views.weatherHiLo", playGui.GetProperty("data").GetString()); + Assert.Equal(0, play.GetProperty("no_matches_for_gui").GetInt32()); + Assert.Equal(0, play.GetProperty("no_inputs_for_gui").GetInt32()); + Assert.True(jcpConfig.TryGetProperty("views", out var views)); var weatherHiLo = views.GetProperty("weatherHiLo"); Assert.Equal("weatherTempView", weatherHiLo.GetProperty("viewConfig").GetProperty("id").GetString());