Improve weather routing and news API fallback

This commit is contained in:
Jacob Dubin
2026-05-11 07:15:11 -05:00
parent 0c597ebbf8
commit af2fdd230c
13 changed files with 57223 additions and 18 deletions

View File

@@ -39,6 +39,8 @@
"BaseUrl": "https://newsapi.org",
"ApiKey": "5df93a83db9c4c6888f3e06c4a53144f",
"Country": "us",
"Language": "en",
"FallbackQuery": "robotics OR technology OR science",
"DefaultCategories": [ "general", "technology", "sports", "business" ],
"CacheTtlSeconds": 300,
"FailureCacheTtlSeconds": 45

View File

@@ -615,11 +615,16 @@ public sealed class JiboInteractionService(
: null;
var useCelsius = ShouldUseCelsius(turn, transcript);
var isNextWeekForecast = IsNextWeekForecastRequest(normalizedTranscript, isRangeForecastRequest);
var isThisWeekForecast = IsThisWeekForecastRequest(normalizedTranscript, isRangeForecastRequest);
if (isNextWeekForecast)
if (isNextWeekForecast || isThisWeekForecast)
{
var rangeStartOffset = 1;
var rangeEndOffset = isThisWeekForecast
? ResolveThisWeekForecastEndOffset(referenceLocalTime)
: MaxWeatherForecastDayOffset;
var weeklySnapshots = new List<(int DayOffset, WeatherReportSnapshot Snapshot)>();
for (var offset = 1; offset <= MaxWeatherForecastDayOffset; offset += 1)
for (var offset = rangeStartOffset; offset <= rangeEndOffset; offset += 1)
{
WeatherReportSnapshot? weeklySnapshot;
try
@@ -652,12 +657,15 @@ public sealed class JiboInteractionService(
"I couldn't fetch the weather right now. Please try again.");
}
var weeklySpokenReply = BuildNextWeekForecastSpokenReply(weeklySnapshots, referenceLocalTime);
var weeklySpokenReply = BuildWeeklyForecastSpokenReply(
weeklySnapshots,
referenceLocalTime,
isThisWeekForecast);
var weeklyWeatherPayload = BuildWeatherSkillPayload(weeklySpokenReply, weeklySnapshots[0].Snapshot, referenceLocalTime);
return new JiboInteractionDecision(
"weather",
weeklySpokenReply,
"chitchat-skill",
"report-skill",
SkillPayload: weeklyWeatherPayload);
}
@@ -697,7 +705,7 @@ public sealed class JiboInteractionService(
return new JiboInteractionDecision(
"weather",
spokenReply,
"chitchat-skill",
"report-skill",
SkillPayload: weatherPayload);
}
@@ -735,9 +743,10 @@ public sealed class JiboInteractionService(
return $"Right now in {location}, it is {summary} and {snapshot.Temperature} degrees {unit}.";
}
private static string BuildNextWeekForecastSpokenReply(
private static string BuildWeeklyForecastSpokenReply(
IReadOnlyList<(int DayOffset, WeatherReportSnapshot Snapshot)> snapshots,
DateTimeOffset? referenceLocalTime)
DateTimeOffset? referenceLocalTime,
bool isThisWeekForecast)
{
if (snapshots.Count == 0)
{
@@ -766,7 +775,10 @@ public sealed class JiboInteractionService(
return $"{dayName}: {summary}, high {high}, low {low}.";
});
return $"I can share the next five-day forecast in {location}. {string.Join(" ", segments)} Temperatures are in {unit}.";
var leadIn = isThisWeekForecast
? $"Here's the rest of this week's forecast in {location}."
: $"I can share the next five-day forecast in {location}.";
return $"{leadIn} {string.Join(" ", segments)} Temperatures are in {unit}.";
}
private static bool IsNextWeekForecastRequest(string normalizedTranscript, bool isRangeForecastRequest)
@@ -808,6 +820,22 @@ public sealed class JiboInteractionService(
normalizedTranscript.Contains("forecast for next", StringComparison.Ordinal);
}
private static bool IsThisWeekForecastRequest(string normalizedTranscript, bool isRangeForecastRequest)
{
return isRangeForecastRequest &&
!string.IsNullOrWhiteSpace(normalizedTranscript) &&
normalizedTranscript.Contains("this week", StringComparison.Ordinal) &&
!normalizedTranscript.Contains("weekend", StringComparison.Ordinal);
}
private static int ResolveThisWeekForecastEndOffset(DateTimeOffset? referenceLocalTime)
{
var resolvedReference = referenceLocalTime ?? DateTimeOffset.UtcNow;
var daysUntilSunday = ((int)DayOfWeek.Sunday - (int)resolvedReference.DayOfWeek + 7) % 7;
var endOffset = Math.Min(MaxWeatherForecastDayOffset, daysUntilSunday);
return Math.Max(1, endOffset);
}
private static bool ShouldDefaultForecastToTomorrow(
string normalizedTranscript,
WeatherDateEntity weatherDate,

View File

@@ -58,11 +58,13 @@ public sealed class NewsApiBriefingProvider(
using var response = await httpClient.GetAsync(uri, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var responseBody = await TryReadResponseBodySnippetAsync(response, cancellationToken);
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} Body={Body}",
category,
(int)response.StatusCode,
response.ReasonPhrase,
responseBody ?? string.Empty);
continue;
}
@@ -160,10 +162,65 @@ public sealed class NewsApiBriefingProvider(
}
else
{
var fallbackBody = await TryReadResponseBodySnippetAsync(broadResponse, cancellationToken);
logger.LogWarning(
"NewsAPI uncategorized fallback failed. StatusCode={StatusCode} Reason={ReasonPhrase}",
"NewsAPI uncategorized fallback failed. StatusCode={StatusCode} Reason={ReasonPhrase} Body={Body}",
(int)broadResponse.StatusCode,
broadResponse.ReasonPhrase);
broadResponse.ReasonPhrase,
fallbackBody ?? string.Empty);
}
}
if (headlines.Count == 0)
{
logger.LogInformation(
"NewsAPI uncategorized headlines were empty. Falling back to everything query. Query={Query}",
options.FallbackQuery);
var everythingUri = BuildEverythingUri(requestedHeadlineCount);
using var everythingResponse = await httpClient.GetAsync(everythingUri, cancellationToken);
if (everythingResponse.IsSuccessStatusCode)
{
using var everythingStream = await everythingResponse.Content.ReadAsStreamAsync(cancellationToken);
using var everythingDocument = await JsonDocument.ParseAsync(everythingStream, cancellationToken: cancellationToken);
if (everythingDocument.RootElement.TryGetProperty("articles", out var everythingArticles) &&
everythingArticles.ValueKind == JsonValueKind.Array)
{
foreach (var article in everythingArticles.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 everything fallback response missing articles array.");
}
}
else
{
var everythingBody = await TryReadResponseBodySnippetAsync(everythingResponse, cancellationToken);
logger.LogWarning(
"NewsAPI everything fallback failed. StatusCode={StatusCode} Reason={ReasonPhrase} Body={Body}",
(int)everythingResponse.StatusCode,
everythingResponse.ReasonPhrase,
everythingBody ?? string.Empty);
}
}
@@ -240,6 +297,48 @@ public sealed class NewsApiBriefingProvider(
return new Uri($"{baseUrl}/v2/top-headlines?{query}");
}
private Uri BuildEverythingUri(int headlineCount)
{
var baseUrl = options.BaseUrl.TrimEnd('/');
var queryParts = new List<(string Key, string Value)>
{
("language", options.Language),
("sortBy", "publishedAt"),
("q", options.FallbackQuery),
("pageSize", headlineCount.ToString()),
("apiKey", options.ApiKey!)
};
var query = string.Join(
"&",
queryParts.Select(part =>
$"{Uri.EscapeDataString(part.Key)}={Uri.EscapeDataString(part.Value)}"));
return new Uri($"{baseUrl}/v2/everything?{query}");
}
private static async Task<string?> TryReadResponseBodySnippetAsync(
HttpResponseMessage response,
CancellationToken cancellationToken)
{
try
{
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(body))
{
return null;
}
const int maxLength = 400;
return body.Length <= maxLength
? body
: body[..maxLength];
}
catch
{
return null;
}
}
private static string? ReadString(JsonElement source, string propertyName)
{
return source.TryGetProperty(propertyName, out var value) &&

View File

@@ -8,6 +8,10 @@ public sealed class NewsApiOptions
public string Country { get; set; } = "us";
public string Language { get; set; } = "en";
public string FallbackQuery { get; set; } = "robotics";
public string[] DefaultCategories { get; set; } =
[
"general",