Improve weather routing and news API fallback
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -115,6 +115,40 @@ public sealed class ProviderCachingTests
|
||||
Assert.Equal(2, handler.GetCallCount("/v2/top-headlines"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NewsApiBriefingProvider_FallsBackToEverything_WhenTopHeadlinesAreEmpty()
|
||||
{
|
||||
var handler = new CountingHttpMessageHandler(message =>
|
||||
{
|
||||
var path = message.RequestUri?.AbsolutePath ?? string.Empty;
|
||||
return path switch
|
||||
{
|
||||
"/v2/top-headlines" => JsonResponse("""{"status":"ok","articles":[]}"""),
|
||||
"/v2/everything" => JsonResponse(
|
||||
"""{"status":"ok","articles":[{"title":"Robotics breakthrough announced","description":"Lab unveils a new platform.","source":{"name":"Science Daily"},"url":"https://example.com/robotics"}]}"""),
|
||||
_ => new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
};
|
||||
});
|
||||
var provider = new NewsApiBriefingProvider(
|
||||
new HttpClient(handler),
|
||||
new NewsApiOptions
|
||||
{
|
||||
ApiKey = "test-key",
|
||||
DefaultCategories = ["general"],
|
||||
CacheTtlSeconds = 300,
|
||||
FailureCacheTtlSeconds = 30
|
||||
},
|
||||
NullLogger<NewsApiBriefingProvider>.Instance);
|
||||
|
||||
var result = await provider.GetBriefingAsync(new NewsBriefingRequest([], 3));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result!.Headlines);
|
||||
Assert.Equal("Robotics breakthrough announced", result.Headlines[0].Title);
|
||||
Assert.Equal(2, handler.GetCallCount("/v2/top-headlines"));
|
||||
Assert.Equal(1, handler.GetCallCount("/v2/everything"));
|
||||
}
|
||||
|
||||
private static HttpResponseMessage JsonResponse(string body)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
|
||||
@@ -1458,7 +1458,7 @@ public sealed class JiboInteractionServiceTests
|
||||
});
|
||||
|
||||
Assert.Equal("weather", decision.IntentName);
|
||||
Assert.Equal("chitchat-skill", decision.SkillName);
|
||||
Assert.Equal("report-skill", decision.SkillName);
|
||||
Assert.NotNull(decision.SkillPayload);
|
||||
Assert.Contains("cat='weather'", decision.SkillPayload!["esml"]?.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("meta='rain'", decision.SkillPayload["esml"]?.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||
@@ -1667,7 +1667,7 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal(expectedLocationQuery, provider.LastRequest!.LocationQuery);
|
||||
Assert.Equal(expectedForecastOffset, provider.LastRequest.ForecastDayOffset);
|
||||
Assert.Equal(expectedIsTomorrow, provider.LastRequest.IsTomorrow);
|
||||
Assert.Equal("chitchat-skill", decision.SkillName);
|
||||
Assert.Equal("report-skill", decision.SkillName);
|
||||
Assert.Equal(true, decision.SkillPayload?["weather_view_enabled"]);
|
||||
}
|
||||
|
||||
@@ -1795,8 +1795,12 @@ public sealed class JiboInteractionServiceTests
|
||||
|
||||
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);
|
||||
Assert.Equal(5, provider.LastRequest?.ForecastDayOffset);
|
||||
Assert.Equal(5, provider.Requests.Count);
|
||||
Assert.Contains("rest of this week's forecast", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Tuesday: light rain, high 61, low 52.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Saturday: light rain, high 61, low 52.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Temperatures are in Fahrenheit.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -3024,6 +3028,7 @@ public sealed class JiboInteractionServiceTests
|
||||
private sealed class CapturingWeatherReportProvider : IWeatherReportProvider
|
||||
{
|
||||
public WeatherReportRequest? LastRequest { get; private set; }
|
||||
public List<WeatherReportRequest> Requests { get; } = [];
|
||||
|
||||
public WeatherReportSnapshot? Snapshot { get; init; }
|
||||
|
||||
@@ -3032,6 +3037,7 @@ public sealed class JiboInteractionServiceTests
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastRequest = request;
|
||||
Requests.Add(request);
|
||||
return Task.FromResult(Snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2198,8 +2198,15 @@ public sealed class JiboWebSocketServiceTests
|
||||
Assert.True(replies.Count >= 3);
|
||||
Assert.Equal("LISTEN", ReadReplyType(replies[0]));
|
||||
Assert.Equal("EOS", ReadReplyType(replies[1]));
|
||||
Assert.Contains(replies, static reply => string.Equals(ReadReplyType(reply), "SKILL_REDIRECT", StringComparison.Ordinal));
|
||||
Assert.Contains(replies, static reply => string.Equals(ReadReplyType(reply), "SKILL_ACTION", StringComparison.Ordinal));
|
||||
|
||||
using var listenPayload = JsonDocument.Parse(replies[0].Text!);
|
||||
Assert.Equal(
|
||||
"report-skill",
|
||||
listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString());
|
||||
Assert.Equal("weather", listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("cloudSkill").GetString());
|
||||
|
||||
var skillReply = replies.Last(static reply => string.Equals(ReadReplyType(reply), "SKILL_ACTION", StringComparison.Ordinal));
|
||||
using var skillPayload = JsonDocument.Parse(skillReply.Text!);
|
||||
Assert.Equal(
|
||||
|
||||
Reference in New Issue
Block a user