Harden weather date parsing and add request diagnostics

This commit is contained in:
Jacob Dubin
2026-05-12 07:52:38 -05:00
parent df3b34c8ad
commit 9093b429ca
12 changed files with 68558 additions and 14 deletions

View File

@@ -594,8 +594,8 @@ public sealed class JiboInteractionService(
CancellationToken cancellationToken)
{
var referenceLocalTime = TryResolveReferenceLocalTime(turn);
var weatherDate = ResolveWeatherDateEntity(turn, transcript, referenceLocalTime);
var normalizedTranscript = NormalizeCommandPhrase(transcript);
var weatherDate = ResolveWeatherDateEntity(turn, transcript, normalizedTranscript, referenceLocalTime);
var isRangeForecastRequest = IsRangeForecastRequest(normalizedTranscript);
if (ShouldDefaultForecastToTomorrow(normalizedTranscript, weatherDate, isRangeForecastRequest))
{
@@ -668,6 +668,15 @@ public sealed class JiboInteractionService(
weeklySnapshots[0].Snapshot,
weeklySegments,
referenceLocalTime);
AddWeatherRequestDiagnostics(
weeklyWeatherPayload,
transcript,
normalizedTranscript,
locationQuery,
weatherDate,
isRangeForecastRequest,
isThisWeekForecast,
isNextWeekForecast);
return new JiboInteractionDecision(
"weather",
weeklySpokenReply,
@@ -708,6 +717,15 @@ public sealed class JiboInteractionService(
var spokenReply = BuildWeatherSpokenReply(snapshot, weatherDate);
var weatherPayload = BuildWeatherSkillPayload(spokenReply, snapshot, referenceLocalTime);
AddWeatherRequestDiagnostics(
weatherPayload,
transcript,
normalizedTranscript,
locationQuery,
weatherDate,
isRangeForecastRequest,
isThisWeekForecast,
isNextWeekForecast);
return new JiboInteractionDecision(
"weather",
spokenReply,
@@ -835,6 +853,26 @@ public sealed class JiboInteractionService(
return payload;
}
private static void AddWeatherRequestDiagnostics(
IDictionary<string, object?> payload,
string transcript,
string normalizedTranscript,
string? locationQuery,
WeatherDateEntity weatherDate,
bool isRangeForecastRequest,
bool isThisWeekForecast,
bool isNextWeekForecast)
{
payload["weather_request_transcript"] = transcript;
payload["weather_request_normalized"] = normalizedTranscript;
payload["weather_request_location_query"] = locationQuery;
payload["weather_request_date_entity"] = weatherDate.DateEntity;
payload["weather_request_forecast_day_offset"] = weatherDate.ForecastDayOffset;
payload["weather_request_range"] = isRangeForecastRequest;
payload["weather_request_this_week"] = isThisWeekForecast;
payload["weather_request_next_week"] = isNextWeekForecast;
}
private static bool IsNextWeekForecastRequest(string normalizedTranscript, bool isRangeForecastRequest)
{
if (string.IsNullOrWhiteSpace(normalizedTranscript) || !isRangeForecastRequest)
@@ -2987,45 +3025,133 @@ public sealed class JiboInteractionService(
private static WeatherDateEntity ResolveWeatherDateEntity(
TurnContext turn,
string transcript,
string normalizedTranscript,
DateTimeOffset? referenceLocalTime)
{
normalizedTranscript = string.IsNullOrWhiteSpace(normalizedTranscript)
? NormalizeCommandPhrase(transcript)
: normalizedTranscript;
if (TryResolveWeatherDateEntityFromTranscript(normalizedTranscript, referenceLocalTime, out var entityFromTranscript))
{
return entityFromTranscript;
}
var entities = ReadEntities(turn);
if (TryResolveWeatherDateEntityFromClientEntities(entities, referenceLocalTime, out var entityFromClient))
if (TryResolveWeatherDateEntityFromClientEntities(entities, referenceLocalTime, out var entityFromClient) &&
ShouldAcceptClientWeatherDateEntity(normalizedTranscript))
{
return entityFromClient;
}
var normalized = NormalizeCommandPhrase(transcript);
if (string.IsNullOrWhiteSpace(normalized))
return WeatherDateEntity.None;
}
private static bool TryResolveWeatherDateEntityFromTranscript(
string normalizedTranscript,
DateTimeOffset? referenceLocalTime,
out WeatherDateEntity weatherDate)
{
weatherDate = WeatherDateEntity.None;
if (string.IsNullOrWhiteSpace(normalizedTranscript))
{
return WeatherDateEntity.None;
return false;
}
if (normalized.Contains("day after tomorrow", StringComparison.Ordinal))
if (normalizedTranscript.Contains("day after tomorrow", StringComparison.Ordinal))
{
return new WeatherDateEntity("day_after_tomorrow", 2, "The day after tomorrow");
weatherDate = new WeatherDateEntity("day_after_tomorrow", 2, "The day after tomorrow");
return true;
}
if (MatchesAny(normalized, "tomorrow", "tomorrow s", "tomorrow's"))
if (MatchesAny(normalizedTranscript, "tomorrow", "tomorrow s", "tomorrow's"))
{
return new WeatherDateEntity("tomorrow", 1, "Tomorrow");
weatherDate = new WeatherDateEntity("tomorrow", 1, "Tomorrow");
return true;
}
if (referenceLocalTime is not null &&
TryResolveWeatherTimeRangeOffset(normalized, referenceLocalTime.Value, out var rangeOffset, out var rangeLeadIn) &&
TryResolveWeatherTimeRangeOffset(normalizedTranscript, referenceLocalTime.Value, out var rangeOffset, out var rangeLeadIn) &&
rangeOffset > 0)
{
return new WeatherDateEntity("range", rangeOffset, rangeLeadIn);
weatherDate = new WeatherDateEntity("range", rangeOffset, rangeLeadIn);
return true;
}
if (referenceLocalTime is not null &&
TryResolveWeatherDayOfWeekOffset(normalized, referenceLocalTime.Value, out var dayOffset, out var dayName) &&
TryResolveWeatherDayOfWeekOffset(normalizedTranscript, referenceLocalTime.Value, out var dayOffset, out var dayName) &&
dayOffset > 0)
{
return new WeatherDateEntity("weekday", dayOffset, $"On {dayName}");
weatherDate = new WeatherDateEntity("weekday", dayOffset, $"On {dayName}");
return true;
}
return WeatherDateEntity.None;
return false;
}
private static bool ShouldAcceptClientWeatherDateEntity(string normalizedTranscript)
{
if (string.IsNullOrWhiteSpace(normalizedTranscript))
{
return true;
}
if (HasExplicitWeatherDateCue(normalizedTranscript))
{
return false;
}
if (HasWeatherLocationClause(normalizedTranscript))
{
return false;
}
return !normalizedTranscript.Contains("forecast", StringComparison.Ordinal);
}
private static bool HasExplicitWeatherDateCue(string normalizedTranscript)
{
if (string.IsNullOrWhiteSpace(normalizedTranscript))
{
return false;
}
if (MatchesAny(
normalizedTranscript,
"today",
"today s",
"today's",
"tonight",
"tomorrow",
"tomorrow s",
"tomorrow's",
"day after tomorrow",
"this week",
"next week",
"weekend",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday"))
{
return true;
}
return WeatherDayOfWeekPattern.IsMatch(normalizedTranscript);
}
private static bool HasWeatherLocationClause(string normalizedTranscript)
{
if (string.IsNullOrWhiteSpace(normalizedTranscript))
{
return false;
}
return WeatherTopicLocationPattern.IsMatch(normalizedTranscript) ||
WeatherLocationPattern.IsMatch(normalizedTranscript);
}
private static bool TryResolveWeatherDateEntityFromClientEntities(

View File

@@ -679,6 +679,7 @@ public sealed partial class WebSocketTurnFinalizationService(
{
["intent"] = plan.IntentName,
["skillName"] = invokedSkillAction.SkillName,
["transcript"] = finalizedTurn.NormalizedTranscript ?? finalizedTurn.RawTranscript,
["payload"] = invokedSkillAction.Payload
}),
cancellationToken);

View File

@@ -1635,6 +1635,66 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("Right now in Chicago, U.S., it is mostly cloudy and 70 degrees Fahrenheit.", decision.ReplyText);
}
[Fact]
public async Task BuildDecisionAsync_WeatherLocationQuery_WithClientDateEntity_PrefersTranscriptCurrentWeather()
{
var provider = new CapturingWeatherReportProvider
{
Snapshot = new WeatherReportSnapshot("Chicago, U.S.", "mostly cloudy", 70, 75, 62, "cloudy", false)
};
var service = CreateService(weatherReportProvider: provider);
var decision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "what's the weather in chicago",
NormalizedTranscript = "what's the weather in chicago",
Attributes = new Dictionary<string, object?>
{
["clientEntities"] = new Dictionary<string, object?>
{
["date"] = "2026-05-18"
},
["context"] = """{"runtime":{"location":{"iso":"2026-05-12T07:00:00-05:00"}}}"""
}
});
Assert.Equal("weather", decision.IntentName);
Assert.Equal("Chicago", provider.LastRequest?.LocationQuery);
Assert.Equal(0, provider.LastRequest?.ForecastDayOffset);
Assert.False(provider.LastRequest?.IsTomorrow);
Assert.Equal("Right now in Chicago, U.S., it is mostly cloudy and 70 degrees Fahrenheit.", decision.ReplyText);
}
[Fact]
public async Task BuildDecisionAsync_ForecastLocationQuery_WithClientDateEntity_DefaultsToTomorrow()
{
var provider = new CapturingWeatherReportProvider
{
Snapshot = new WeatherReportSnapshot("Chicago, U.S.", "mostly cloudy", 70, 75, 62, "cloudy", false)
};
var service = CreateService(weatherReportProvider: provider);
var decision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "what's the forecast in chicago",
NormalizedTranscript = "what's the forecast in chicago",
Attributes = new Dictionary<string, object?>
{
["clientEntities"] = new Dictionary<string, object?>
{
["date"] = "2026-05-18"
},
["context"] = """{"runtime":{"location":{"iso":"2026-05-12T07:00:00-05:00"}}}"""
}
});
Assert.Equal("weather", decision.IntentName);
Assert.Equal("Chicago", provider.LastRequest?.LocationQuery);
Assert.Equal(1, provider.LastRequest?.ForecastDayOffset);
Assert.True(provider.LastRequest?.IsTomorrow);
Assert.Equal("Tomorrow in Chicago, U.S., expect mostly cloudy with a high near 75 degrees Fahrenheit and a low around 62 degrees Fahrenheit.", decision.ReplyText);
}
[Theory]
[InlineData("how is the weather", null, 0, false)]
[InlineData("what's the forecast", null, 1, true)]