Expand weather forecast phrasing and day offsets

This commit is contained in:
Jacob Dubin
2026-05-09 09:21:45 -05:00
parent 3ad4a3e025
commit 7fd732ad17
6 changed files with 649 additions and 39 deletions

View File

@@ -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,

View File

@@ -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<string, string> 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<string, string> 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(?<hour>\d{1,2}|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)(?:[:\s,-]+(?<minute>\d{2}|[a-z\-]+(?:\s+[a-z\-]+)?))?\s*(?<ampm>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>next\s+)?(?<this>this\s+)?(?:on\s+)?(?<day>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", "<speak><anim cat='jiboji' filter='pizza-making'/></speak>"),
@@ -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"),

View File

@@ -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<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["esml"] = esml,
["meta"] = new
{
prompt_id = promptId,
prompt_sub_category = promptSubCategory,
mim_id = mimId,
mim_type = mimType
}
};
var jcpConfig = new Dictionary<string, object?>(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<string, object?>(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<string, object?>(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<string, object?>? 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);
}

View File

@@ -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<WeatherReportSnapshot?> GetTomorrowForecastAsync(
private async Task<WeatherReportSnapshot?> 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<ForecastEntry>();
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;
}

View File

@@ -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<string, object?>
{
["clientEntities"] = new Dictionary<string, object?>
{
["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<string, object?>
{
["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<string, object?>
{
["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<string, object?>
{
["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<string, object?>
{
["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<string, object?>
{
["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<string, object?>
{
["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()
{

View File

@@ -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());