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 =>

View File

@@ -77,6 +77,44 @@ public sealed class ProviderCachingTests
Assert.Equal(1, handler.GetCallCount("/v2/top-headlines"));
}
[Fact]
public async Task NewsApiBriefingProvider_FallsBackToUncategorizedHeadlines_WhenCategoryReturnsEmpty()
{
var handler = new CountingHttpMessageHandler(message =>
{
var path = message.RequestUri?.AbsolutePath ?? string.Empty;
if (!string.Equals(path, "/v2/top-headlines", StringComparison.OrdinalIgnoreCase))
{
return new HttpResponseMessage(HttpStatusCode.NotFound);
}
var query = message.RequestUri?.Query ?? string.Empty;
if (query.Contains("category=sports", StringComparison.OrdinalIgnoreCase))
{
return JsonResponse("""{"status":"ok","articles":[]}""");
}
return JsonResponse(
"""{"status":"ok","articles":[{"title":"General robotics update","description":"Top story","source":{"name":"AP News"},"url":"https://example.com/general"}]}""");
});
var provider = new NewsApiBriefingProvider(
new HttpClient(handler),
new NewsApiOptions
{
ApiKey = "test-key",
CacheTtlSeconds = 300,
FailureCacheTtlSeconds = 30
},
NullLogger<NewsApiBriefingProvider>.Instance);
var result = await provider.GetBriefingAsync(new NewsBriefingRequest(["sports"], 3));
Assert.NotNull(result);
Assert.Single(result!.Headlines);
Assert.Equal("General robotics update", result.Headlines[0].Title);
Assert.Equal(2, handler.GetCallCount("/v2/top-headlines"));
}
private static HttpResponseMessage JsonResponse(string body)
{
return new HttpResponseMessage(HttpStatusCode.OK)

View File

@@ -1462,6 +1462,7 @@ public sealed class JiboInteractionServiceTests
Assert.NotNull(decision.SkillPayload);
Assert.Contains("cat='weather'", decision.SkillPayload!["esml"]?.ToString(), StringComparison.OrdinalIgnoreCase);
Assert.Contains("meta='rain'", decision.SkillPayload["esml"]?.ToString(), StringComparison.OrdinalIgnoreCase);
Assert.Equal("report-skill", decision.SkillPayload["skillId"]);
Assert.Equal("WeatherCommentRain", decision.SkillPayload["mim_id"]);
Assert.Equal(true, decision.SkillPayload["weather_view_enabled"]);
Assert.Equal("weatherHiLo", decision.SkillPayload["weather_view_kind"]);
@@ -1826,6 +1827,34 @@ public sealed class JiboInteractionServiceTests
Assert.Equal(5, provider.LastRequest.ForecastDayOffset);
}
[Fact]
public async Task BuildDecisionAsync_WeatherForecastNextPhrase_WithContext_ReturnsFiveDaySummary()
{
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 = "what's the forecast next",
NormalizedTranscript = "what's the forecast next",
Attributes = new Dictionary<string, object?>
{
["context"] = """{"runtime":{"location":{"iso":"2026-04-20T08:00:00-05:00"}}}"""
}
});
Assert.Equal("weather", decision.IntentName);
Assert.Contains("next five-day forecast", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Seattle, US", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Temperatures are in Fahrenheit.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
Assert.NotNull(provider.LastRequest);
Assert.Null(provider.LastRequest!.LocationQuery);
Assert.Equal(5, provider.LastRequest.ForecastDayOffset);
}
[Fact]
public async Task BuildDecisionAsync_WeatherDayAfterTomorrow_WithContext_PassesDayOffsetAndLocation()
{

View File

@@ -2202,6 +2202,9 @@ public sealed class JiboWebSocketServiceTests
var skillReply = replies.Last(static reply => string.Equals(ReadReplyType(reply), "SKILL_ACTION", StringComparison.Ordinal));
using var skillPayload = JsonDocument.Parse(skillReply.Text!);
Assert.Equal(
"report-skill",
skillPayload.RootElement.GetProperty("data").GetProperty("skill").GetProperty("id").GetString());
var jcpConfig = skillPayload.RootElement
.GetProperty("data")
.GetProperty("action")