Fix weather forecast parsing and NewsAPI fallback

This commit is contained in:
Jacob Dubin
2026-05-10 23:08:06 -05:00
parent 4bc87f927b
commit 0c597ebbf8
11 changed files with 55098 additions and 15 deletions

View File

@@ -596,7 +596,8 @@ public sealed class JiboInteractionService(
var referenceLocalTime = TryResolveReferenceLocalTime(turn);
var weatherDate = ResolveWeatherDateEntity(turn, transcript, referenceLocalTime);
var normalizedTranscript = NormalizeCommandPhrase(transcript);
if (ShouldDefaultForecastToTomorrow(normalizedTranscript, weatherDate))
var isRangeForecastRequest = IsRangeForecastRequest(normalizedTranscript);
if (ShouldDefaultForecastToTomorrow(normalizedTranscript, weatherDate, isRangeForecastRequest))
{
weatherDate = new WeatherDateEntity("tomorrow", 1, "Tomorrow");
}
@@ -613,7 +614,7 @@ public sealed class JiboInteractionService(
? TryResolveWeatherCoordinates(turn)
: null;
var useCelsius = ShouldUseCelsius(turn, transcript);
var isNextWeekForecast = IsNextWeekForecastRequest(normalizedTranscript);
var isNextWeekForecast = IsNextWeekForecastRequest(normalizedTranscript, isRangeForecastRequest);
if (isNextWeekForecast)
{
@@ -768,21 +769,52 @@ public sealed class JiboInteractionService(
return $"I can share the next five-day forecast in {location}. {string.Join(" ", segments)} Temperatures are in {unit}.";
}
private static bool IsNextWeekForecastRequest(string normalizedTranscript)
private static bool IsNextWeekForecastRequest(string normalizedTranscript, bool isRangeForecastRequest)
{
if (string.IsNullOrWhiteSpace(normalizedTranscript) ||
!normalizedTranscript.Contains("next week", StringComparison.Ordinal))
if (string.IsNullOrWhiteSpace(normalizedTranscript) || !isRangeForecastRequest)
{
return false;
}
return normalizedTranscript.Contains("forecast", StringComparison.Ordinal) ||
normalizedTranscript.Contains("weather", StringComparison.Ordinal);
if (normalizedTranscript.Contains("next week", StringComparison.Ordinal))
{
return true;
}
if (!normalizedTranscript.Contains("next", StringComparison.Ordinal))
{
return false;
}
return normalizedTranscript.Contains("forecast next", StringComparison.Ordinal) ||
normalizedTranscript.Contains("forecast for next", StringComparison.Ordinal);
}
private static bool ShouldDefaultForecastToTomorrow(string normalizedTranscript, WeatherDateEntity weatherDate)
private static bool IsRangeForecastRequest(string normalizedTranscript)
{
if (string.IsNullOrWhiteSpace(normalizedTranscript))
{
return false;
}
if (normalizedTranscript.Contains("next week", StringComparison.Ordinal) ||
normalizedTranscript.Contains("this week", StringComparison.Ordinal) ||
normalizedTranscript.Contains("weekend", StringComparison.Ordinal))
{
return true;
}
return normalizedTranscript.Contains("forecast next", StringComparison.Ordinal) ||
normalizedTranscript.Contains("forecast for next", StringComparison.Ordinal);
}
private static bool ShouldDefaultForecastToTomorrow(
string normalizedTranscript,
WeatherDateEntity weatherDate,
bool isRangeForecastRequest)
{
if (weatherDate.ForecastDayOffset > 0 ||
isRangeForecastRequest ||
string.IsNullOrWhiteSpace(normalizedTranscript) ||
!normalizedTranscript.Contains("forecast", StringComparison.Ordinal))
{
@@ -814,6 +846,8 @@ public sealed class JiboInteractionService(
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "report-skill",
["cloudSkill"] = "weather",
["esml"] =
$"<speak><anim cat='weather' meta='{weatherIcon}' nonBlocking='true' /><break size='0.35'/><es cat='neutral' filter='!ssa-only, !sfx-only' endNeutral='true'>{EscapeForEsml(spokenReply)}</es></speak>",
["mim_id"] = $"WeatherComment{promptToken}",

View File

@@ -59,10 +59,10 @@ public sealed class NewsApiBriefingProvider(
if (!response.IsSuccessStatusCode)
{
logger.LogWarning(
"NewsAPI request failed for category {Category}. StatusCode={StatusCode} Reason={ReasonPhrase}",
category,
(int)response.StatusCode,
response.ReasonPhrase);
"NewsAPI request failed for category {Category}. StatusCode={StatusCode} Reason={ReasonPhrase}",
category,
(int)response.StatusCode,
response.ReasonPhrase);
continue;
}
@@ -116,6 +116,57 @@ public sealed class NewsApiBriefingProvider(
}
}
if (headlines.Count == 0)
{
logger.LogInformation(
"NewsAPI category lookup produced no headlines. Falling back to uncategorized top headlines. Categories={Categories}",
string.Join(",", categories));
var broadUri = BuildTopHeadlinesUri(category: null, requestedHeadlineCount);
using var broadResponse = await httpClient.GetAsync(broadUri, cancellationToken);
if (broadResponse.IsSuccessStatusCode)
{
using var broadStream = await broadResponse.Content.ReadAsStreamAsync(cancellationToken);
using var broadDocument = await JsonDocument.ParseAsync(broadStream, cancellationToken: cancellationToken);
if (broadDocument.RootElement.TryGetProperty("articles", out var broadArticles) &&
broadArticles.ValueKind == JsonValueKind.Array)
{
foreach (var article in broadArticles.EnumerateArray())
{
var title = NormalizeHeadlineTitle(ReadString(article, "title"));
if (string.IsNullOrWhiteSpace(title) || !seenTitles.Add(title))
{
continue;
}
var summary = ReadString(article, "description");
var source = article.TryGetProperty("source", out var sourceNode) &&
sourceNode.ValueKind == JsonValueKind.Object
? ReadString(sourceNode, "name")
: null;
var url = ReadString(article, "url");
headlines.Add(new NewsHeadline(title, summary, "general", source, url));
if (headlines.Count >= requestedHeadlineCount)
{
break;
}
}
}
else
{
logger.LogWarning("NewsAPI uncategorized fallback response missing articles array.");
}
}
else
{
logger.LogWarning(
"NewsAPI uncategorized fallback failed. StatusCode={StatusCode} Reason={ReasonPhrase}",
(int)broadResponse.StatusCode,
broadResponse.ReasonPhrase);
}
}
if (headlines.Count == 0)
{
SetCachedValue(briefingCache, cacheKey, null, options.FailureCacheTtlSeconds);
@@ -168,16 +219,20 @@ public sealed class NewsApiBriefingProvider(
.Take(MaxCategories);
}
private Uri BuildTopHeadlinesUri(string category, int headlineCount)
private Uri BuildTopHeadlinesUri(string? category, int headlineCount)
{
var baseUrl = options.BaseUrl.TrimEnd('/');
var queryParts = new (string Key, string Value)[]
var queryParts = new List<(string Key, string Value)>
{
("country", options.Country),
("category", category),
("pageSize", headlineCount.ToString()),
("apiKey", options.ApiKey!)
};
if (!string.IsNullOrWhiteSpace(category))
{
queryParts.Add(("category", category));
}
var query = string.Join(
"&",
queryParts.Select(part =>