Expand weather forecast phrasing and day offsets
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user