Add weekly weather cards and improve news API fallback
This commit is contained in:
@@ -657,11 +657,17 @@ public sealed class JiboInteractionService(
|
||||
"I couldn't fetch the weather right now. Please try again.");
|
||||
}
|
||||
|
||||
var weeklySegments = BuildWeeklyForecastCardSegments(weeklySnapshots, referenceLocalTime);
|
||||
var weeklySpokenReply = BuildWeeklyForecastSpokenReply(
|
||||
weeklySnapshots,
|
||||
referenceLocalTime,
|
||||
weeklySegments,
|
||||
weeklySnapshots[0].Snapshot.LocationName,
|
||||
weeklySnapshots[0].Snapshot.UseCelsius,
|
||||
isThisWeekForecast);
|
||||
var weeklyWeatherPayload = BuildWeatherSkillPayload(weeklySpokenReply, weeklySnapshots[0].Snapshot, referenceLocalTime);
|
||||
var weeklyWeatherPayload = BuildWeeklyWeatherSkillPayload(
|
||||
weeklySpokenReply,
|
||||
weeklySnapshots[0].Snapshot,
|
||||
weeklySegments,
|
||||
referenceLocalTime);
|
||||
return new JiboInteractionDecision(
|
||||
"weather",
|
||||
weeklySpokenReply,
|
||||
@@ -744,28 +750,38 @@ public sealed class JiboInteractionService(
|
||||
}
|
||||
|
||||
private static string BuildWeeklyForecastSpokenReply(
|
||||
IReadOnlyList<(int DayOffset, WeatherReportSnapshot Snapshot)> snapshots,
|
||||
DateTimeOffset? referenceLocalTime,
|
||||
IReadOnlyList<WeatherForecastCardSegment> segments,
|
||||
string? locationName,
|
||||
bool useCelsius,
|
||||
bool isThisWeekForecast)
|
||||
{
|
||||
if (snapshots.Count == 0)
|
||||
if (segments.Count == 0)
|
||||
{
|
||||
return "I couldn't build a forecast right now.";
|
||||
}
|
||||
|
||||
var location = snapshots[0].Snapshot.LocationName;
|
||||
if (string.IsNullOrWhiteSpace(location))
|
||||
var location = string.IsNullOrWhiteSpace(locationName)
|
||||
? "your area"
|
||||
: NormalizeLocationForSpeech(locationName);
|
||||
var unit = useCelsius ? "Celsius" : "Fahrenheit";
|
||||
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.Select(static segment => segment.SpokenLine))} Temperatures are in {unit}.";
|
||||
}
|
||||
|
||||
private static IReadOnlyList<WeatherForecastCardSegment> BuildWeeklyForecastCardSegments(
|
||||
IReadOnlyList<(int DayOffset, WeatherReportSnapshot Snapshot)> snapshots,
|
||||
DateTimeOffset? referenceLocalTime)
|
||||
{
|
||||
if (snapshots.Count == 0)
|
||||
{
|
||||
location = "your area";
|
||||
}
|
||||
else
|
||||
{
|
||||
location = NormalizeLocationForSpeech(location);
|
||||
return [];
|
||||
}
|
||||
|
||||
var unit = snapshots[0].Snapshot.UseCelsius ? "Celsius" : "Fahrenheit";
|
||||
var referenceDate = (referenceLocalTime ?? DateTimeOffset.UtcNow).Date;
|
||||
var segments = snapshots
|
||||
var resolvedReference = referenceLocalTime ?? DateTimeOffset.UtcNow;
|
||||
var referenceDate = resolvedReference.Date;
|
||||
return snapshots
|
||||
.OrderBy(static item => item.DayOffset)
|
||||
.Take(MaxWeatherForecastDayOffset)
|
||||
.Select(item =>
|
||||
@@ -776,13 +792,47 @@ public sealed class JiboInteractionService(
|
||||
: item.Snapshot.Summary.Trim().TrimEnd('.');
|
||||
var high = item.Snapshot.HighTemperature ?? item.Snapshot.Temperature;
|
||||
var low = item.Snapshot.LowTemperature ?? item.Snapshot.Temperature;
|
||||
return $"{dayName}: {summary}, high {high}, low {low}.";
|
||||
});
|
||||
var iconReference = new DateTimeOffset(
|
||||
resolvedReference.Date.AddDays(item.DayOffset).AddHours(12),
|
||||
resolvedReference.Offset);
|
||||
var icon = ResolveWeatherAnimationIcon(item.Snapshot, iconReference);
|
||||
var unit = item.Snapshot.UseCelsius ? "C" : "F";
|
||||
var temperatureBand = ResolveWeatherTemperatureBand(high, item.Snapshot.UseCelsius);
|
||||
var spokenLine = $"{dayName}: {summary}, high {high}, low {low}.";
|
||||
return new WeatherForecastCardSegment(
|
||||
dayName,
|
||||
summary,
|
||||
high,
|
||||
low,
|
||||
icon,
|
||||
unit,
|
||||
temperatureBand,
|
||||
spokenLine);
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
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 IDictionary<string, object?> BuildWeeklyWeatherSkillPayload(
|
||||
string spokenReply,
|
||||
WeatherReportSnapshot snapshot,
|
||||
IReadOnlyList<WeatherForecastCardSegment> segments,
|
||||
DateTimeOffset? referenceLocalTime)
|
||||
{
|
||||
var payload = BuildWeatherSkillPayload(spokenReply, snapshot, referenceLocalTime);
|
||||
payload["weather_weekly_cards"] = segments
|
||||
.Select(static segment => new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["weather_day"] = segment.DayName,
|
||||
["weather_summary"] = segment.Summary,
|
||||
["weather_icon"] = segment.Icon,
|
||||
["weather_high"] = segment.High,
|
||||
["weather_low"] = segment.Low,
|
||||
["weather_unit"] = segment.Unit,
|
||||
["weather_theme"] = segment.Theme,
|
||||
["weather_spoken_line"] = segment.SpokenLine
|
||||
})
|
||||
.ToArray();
|
||||
return payload;
|
||||
}
|
||||
|
||||
private static bool IsNextWeekForecastRequest(string normalizedTranscript, bool isRangeForecastRequest)
|
||||
@@ -4620,6 +4670,16 @@ public sealed class JiboInteractionService(
|
||||
"WY"
|
||||
};
|
||||
|
||||
private sealed record WeatherForecastCardSegment(
|
||||
string DayName,
|
||||
string Summary,
|
||||
int High,
|
||||
int Low,
|
||||
string Icon,
|
||||
string Unit,
|
||||
string Theme,
|
||||
string SpokenLine);
|
||||
|
||||
private const string GreetingRouteMetadataKey = "greetingsRoute";
|
||||
private const string GreetingSpeakerMetadataKey = "greetingsSpeaker";
|
||||
private const string LastProactiveGreetingUtcMetadataKey = "greetingsLastProactiveUtc";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Jibo.Cloud.Domain.Models;
|
||||
using Jibo.Runtime.Abstractions;
|
||||
|
||||
@@ -805,7 +806,14 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
};
|
||||
}
|
||||
|
||||
var weatherHiLoView = BuildWeatherHiLoView(skillPayload);
|
||||
object? weatherHiLoView = BuildWeatherHiLoView(skillPayload);
|
||||
var weeklyWeatherCards = BuildWeatherHiLoSequenceCards(skillPayload);
|
||||
if (weatherHiLoView is null && weeklyWeatherCards.Count > 0)
|
||||
{
|
||||
weatherHiLoView = weeklyWeatherCards[0].View;
|
||||
}
|
||||
|
||||
var useWeatherSequence = false;
|
||||
if (weatherHiLoView is not null)
|
||||
{
|
||||
var resolvedGuiContext = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -853,6 +861,30 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
{
|
||||
["views"] = weatherViews
|
||||
};
|
||||
|
||||
if (weeklyWeatherCards.Count > 1)
|
||||
{
|
||||
useWeatherSequence = true;
|
||||
jcpConfig["children"] = BuildWeatherHiLoSequenceChildren(
|
||||
weeklyWeatherCards,
|
||||
promptSubCategory,
|
||||
mimId,
|
||||
mimType);
|
||||
}
|
||||
}
|
||||
|
||||
var jcp = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["type"] = "SLIM",
|
||||
["config"] = jcpConfig
|
||||
};
|
||||
if (useWeatherSequence &&
|
||||
jcpConfig.TryGetValue("children", out var sequenceChildren) &&
|
||||
sequenceChildren is not null)
|
||||
{
|
||||
jcp["type"] = "SEQUENCE";
|
||||
jcp.Remove("config");
|
||||
jcp["children"] = sequenceChildren;
|
||||
}
|
||||
|
||||
return new
|
||||
@@ -871,11 +903,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
{
|
||||
config = new
|
||||
{
|
||||
jcp = new
|
||||
{
|
||||
type = "SLIM",
|
||||
config = jcpConfig
|
||||
}
|
||||
jcp
|
||||
}
|
||||
},
|
||||
analytics = new Dictionary<string, object?>(),
|
||||
@@ -1105,6 +1133,184 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<WeatherHiLoSequenceCard> BuildWeatherHiLoSequenceCards(IDictionary<string, object?>? payload)
|
||||
{
|
||||
if (payload is null ||
|
||||
!payload.TryGetValue("weather_weekly_cards", out var rawCards) ||
|
||||
rawCards is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var cards = ReadPayloadObjectArray(rawCards);
|
||||
if (cards.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var sequenceCards = new List<WeatherHiLoSequenceCard>(cards.Count);
|
||||
foreach (var card in cards)
|
||||
{
|
||||
var weatherCardPayload = new Dictionary<string, object?>(card, StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["weather_view_enabled"] = true,
|
||||
["weather_view_kind"] = "weatherHiLo"
|
||||
};
|
||||
var view = BuildWeatherHiLoView(weatherCardPayload);
|
||||
if (view is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sequenceCards.Add(new WeatherHiLoSequenceCard(
|
||||
view,
|
||||
ReadPayloadString(weatherCardPayload, "weather_day"),
|
||||
ReadPayloadString(weatherCardPayload, "weather_icon"),
|
||||
ReadPayloadString(weatherCardPayload, "weather_spoken_line")));
|
||||
}
|
||||
|
||||
return sequenceCards;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<object> BuildWeatherHiLoSequenceChildren(
|
||||
IReadOnlyList<WeatherHiLoSequenceCard> cards,
|
||||
string promptSubCategory,
|
||||
string mimId,
|
||||
string mimType)
|
||||
{
|
||||
var children = new List<object>(cards.Count);
|
||||
for (var index = 0; index < cards.Count; index += 1)
|
||||
{
|
||||
var card = cards[index];
|
||||
var promptLabel = string.IsNullOrWhiteSpace(card.DayName)
|
||||
? $"Day{index + 1}"
|
||||
: Regex.Replace(card.DayName, "[^A-Za-z0-9]", string.Empty, RegexOptions.CultureInvariant);
|
||||
var promptId = $"WeatherForecast{promptLabel}_AN_13";
|
||||
var spokenLine = string.IsNullOrWhiteSpace(card.SpokenLine)
|
||||
? "Here is another day's forecast."
|
||||
: card.SpokenLine!;
|
||||
var icon = string.IsNullOrWhiteSpace(card.Icon)
|
||||
? "cloudy"
|
||||
: card.Icon!;
|
||||
var esml =
|
||||
$"<speak><anim cat='weather' meta='{icon}' nonBlocking='true' /><break size='0.2'/><es cat='neutral' filter='!ssa-only, !sfx-only' endNeutral='true'>{EscapeXml(spokenLine)}</es></speak>";
|
||||
var resolvedGuiContext = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["type"] = "Javascript",
|
||||
["data"] = card.View,
|
||||
["pause"] = true
|
||||
};
|
||||
|
||||
children.Add(new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["type"] = "SLIM",
|
||||
["config"] = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["play"] = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["esml"] = esml,
|
||||
["meta"] = new
|
||||
{
|
||||
prompt_id = promptId,
|
||||
prompt_sub_category = promptSubCategory,
|
||||
mim_id = mimId,
|
||||
mim_type = mimType
|
||||
}
|
||||
},
|
||||
["gui"] = new
|
||||
{
|
||||
type = "Javascript",
|
||||
data = "views.weatherHiLo",
|
||||
pause = true
|
||||
},
|
||||
["display"] = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["view"] = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["type"] = "Javascript",
|
||||
["data"] = card.View,
|
||||
["pause"] = true,
|
||||
["context"] = resolvedGuiContext
|
||||
}
|
||||
},
|
||||
["timeout"] = 6,
|
||||
["barge_in"] = true,
|
||||
["no_matches_for_gui"] = 0,
|
||||
["no_inputs_for_gui"] = 0
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<IDictionary<string, object?>> ReadPayloadObjectArray(object rawValue)
|
||||
{
|
||||
if (rawValue is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return jsonArray
|
||||
.EnumerateArray()
|
||||
.Select(ConvertJsonObjectToDictionary)
|
||||
.Where(static item => item is not null)
|
||||
.Cast<IDictionary<string, object?>>()
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
if (rawValue is IEnumerable<object?> rawObjects)
|
||||
{
|
||||
return rawObjects
|
||||
.Select(ConvertObjectToDictionary)
|
||||
.Where(static item => item is not null)
|
||||
.Cast<IDictionary<string, object?>>()
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private static IDictionary<string, object?>? ConvertObjectToDictionary(object? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value is IDictionary<string, object?> dictionary)
|
||||
{
|
||||
return new Dictionary<string, object?>(dictionary, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return value is JsonElement jsonValue
|
||||
? ConvertJsonObjectToDictionary(jsonValue)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static IDictionary<string, object?>? ConvertJsonObjectToDictionary(JsonElement value)
|
||||
{
|
||||
if (value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dictionary = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var property in value.EnumerateObject())
|
||||
{
|
||||
dictionary[property.Name] = property.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => property.Value.GetString(),
|
||||
JsonValueKind.Number when property.Value.TryGetInt32(out var intValue) => intValue,
|
||||
JsonValueKind.Number when property.Value.TryGetDouble(out var doubleValue) => doubleValue,
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Object => ConvertJsonObjectToDictionary(property.Value),
|
||||
JsonValueKind.Array => property.Value,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
return dictionary;
|
||||
}
|
||||
|
||||
private static object? BuildWeatherHiLoView(IDictionary<string, object?>? payload)
|
||||
{
|
||||
if (!TryReadPayloadBool(payload, "weather_view_enabled"))
|
||||
@@ -1347,6 +1553,12 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
return Guid.NewGuid().ToString("N");
|
||||
}
|
||||
|
||||
private sealed record WeatherHiLoSequenceCard(
|
||||
object View,
|
||||
string? DayName,
|
||||
string? Icon,
|
||||
string? SpokenLine);
|
||||
|
||||
public sealed record SocketReplyPlan(string Text, int DelayMs = 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -83,16 +83,20 @@ public sealed class NewsApiBriefingProvider(
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var responseBody = await TryReadResponseBodySnippetAsync(response, cancellationToken);
|
||||
var apiError = TryParseApiError(responseBody);
|
||||
CaptureFailure(
|
||||
"http_error",
|
||||
$"Category '{category}' returned {(int)response.StatusCode} {response.ReasonPhrase}.",
|
||||
apiError?.Message ?? $"Category '{category}' returned {(int)response.StatusCode} {response.ReasonPhrase}.",
|
||||
(int)response.StatusCode,
|
||||
uri);
|
||||
uri,
|
||||
apiError?.Code);
|
||||
logger.LogWarning(
|
||||
"NewsAPI request failed for category {Category}. StatusCode={StatusCode} Reason={ReasonPhrase} Body={Body}",
|
||||
"NewsAPI request failed for category {Category}. StatusCode={StatusCode} Reason={ReasonPhrase} ErrorCode={ErrorCode} ErrorMessage={ErrorMessage} Body={Body}",
|
||||
category,
|
||||
(int)response.StatusCode,
|
||||
response.ReasonPhrase,
|
||||
apiError?.Code ?? string.Empty,
|
||||
apiError?.Message ?? string.Empty,
|
||||
responseBody ?? string.Empty);
|
||||
continue;
|
||||
}
|
||||
@@ -211,15 +215,19 @@ public sealed class NewsApiBriefingProvider(
|
||||
else
|
||||
{
|
||||
var fallbackBody = await TryReadResponseBodySnippetAsync(broadResponse, cancellationToken);
|
||||
var apiError = TryParseApiError(fallbackBody);
|
||||
CaptureFailure(
|
||||
"http_error",
|
||||
$"Uncategorized fallback returned {(int)broadResponse.StatusCode} {broadResponse.ReasonPhrase}.",
|
||||
apiError?.Message ?? $"Uncategorized fallback returned {(int)broadResponse.StatusCode} {broadResponse.ReasonPhrase}.",
|
||||
(int)broadResponse.StatusCode,
|
||||
broadUri);
|
||||
broadUri,
|
||||
apiError?.Code);
|
||||
logger.LogWarning(
|
||||
"NewsAPI uncategorized fallback failed. StatusCode={StatusCode} Reason={ReasonPhrase} Body={Body}",
|
||||
"NewsAPI uncategorized fallback failed. StatusCode={StatusCode} Reason={ReasonPhrase} ErrorCode={ErrorCode} ErrorMessage={ErrorMessage} Body={Body}",
|
||||
(int)broadResponse.StatusCode,
|
||||
broadResponse.ReasonPhrase,
|
||||
apiError?.Code ?? string.Empty,
|
||||
apiError?.Message ?? string.Empty,
|
||||
fallbackBody ?? string.Empty);
|
||||
}
|
||||
}
|
||||
@@ -274,15 +282,19 @@ public sealed class NewsApiBriefingProvider(
|
||||
else
|
||||
{
|
||||
var everythingBody = await TryReadResponseBodySnippetAsync(everythingResponse, cancellationToken);
|
||||
var apiError = TryParseApiError(everythingBody);
|
||||
CaptureFailure(
|
||||
"http_error",
|
||||
$"Everything fallback returned {(int)everythingResponse.StatusCode} {everythingResponse.ReasonPhrase}.",
|
||||
apiError?.Message ?? $"Everything fallback returned {(int)everythingResponse.StatusCode} {everythingResponse.ReasonPhrase}.",
|
||||
(int)everythingResponse.StatusCode,
|
||||
everythingUri);
|
||||
everythingUri,
|
||||
apiError?.Code);
|
||||
logger.LogWarning(
|
||||
"NewsAPI everything fallback failed. StatusCode={StatusCode} Reason={ReasonPhrase} Body={Body}",
|
||||
"NewsAPI everything fallback failed. StatusCode={StatusCode} Reason={ReasonPhrase} ErrorCode={ErrorCode} ErrorMessage={ErrorMessage} Body={Body}",
|
||||
(int)everythingResponse.StatusCode,
|
||||
everythingResponse.ReasonPhrase,
|
||||
apiError?.Code ?? string.Empty,
|
||||
apiError?.Message ?? string.Empty,
|
||||
everythingBody ?? string.Empty);
|
||||
}
|
||||
}
|
||||
@@ -444,6 +456,36 @@ public sealed class NewsApiBriefingProvider(
|
||||
return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
|
||||
}
|
||||
|
||||
private static ApiError? TryParseApiError(string? responseBody)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(responseBody))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(responseBody);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var code = ReadString(document.RootElement, "code");
|
||||
var message = ReadString(document.RootElement, "message");
|
||||
if (string.IsNullOrWhiteSpace(code) && string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ApiError(code, message);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string SanitizeEndpoint(Uri uri)
|
||||
{
|
||||
var path = uri.GetLeftPart(UriPartial.Path);
|
||||
@@ -516,5 +558,6 @@ public sealed class NewsApiBriefingProvider(
|
||||
private const int MaxHeadlines = 5;
|
||||
private const int MaxCategories = 2;
|
||||
|
||||
private sealed record ApiError(string? Code, string? Message);
|
||||
private sealed record CacheEntry<T>(T Value, DateTimeOffset ExpiresUtc);
|
||||
}
|
||||
|
||||
@@ -149,6 +149,103 @@ public sealed class ProviderCachingTests
|
||||
Assert.Equal(1, handler.GetCallCount("/v2/everything"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NewsApiBriefingProvider_ContinuesFallbackChain_WhenCategoryReturnsHttpError()
|
||||
{
|
||||
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 new HttpResponseMessage(HttpStatusCode.BadRequest)
|
||||
{
|
||||
Content = new StringContent(
|
||||
"""{"status":"error","code":"parameterInvalid","message":"Category not supported for this key."}""",
|
||||
Encoding.UTF8,
|
||||
"application/json")
|
||||
};
|
||||
}
|
||||
|
||||
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("success", result.ProviderStatus);
|
||||
Assert.Equal(2, handler.GetCallCount("/v2/top-headlines"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NewsApiBriefingProvider_PropagatesApiErrorCodeAndMessage_WhenAllEndpointsFail()
|
||||
{
|
||||
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.BadRequest)
|
||||
{
|
||||
Content = new StringContent(
|
||||
"""{"status":"error","code":"parameterInvalid","message":"Category 'general' is not available for this account."}""",
|
||||
Encoding.UTF8,
|
||||
"application/json")
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(path, "/v2/everything", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.BadRequest)
|
||||
{
|
||||
Content = new StringContent(
|
||||
"""{"status":"error","code":"parametersMissing","message":"Missing required search query."}""",
|
||||
Encoding.UTF8,
|
||||
"application/json")
|
||||
};
|
||||
}
|
||||
|
||||
return 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.Empty(result!.Headlines);
|
||||
Assert.Equal("http_error", result.ProviderStatus);
|
||||
Assert.Equal("parameterInvalid", result.ProviderErrorCode);
|
||||
Assert.Equal("Category 'general' is not available for this account.", result.ProviderMessage);
|
||||
Assert.Equal((int)HttpStatusCode.BadRequest, result.ProviderHttpStatusCode);
|
||||
Assert.Contains("/v2/top-headlines", result.ProviderEndpoint, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage JsonResponse(string body)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
|
||||
@@ -1801,6 +1801,14 @@ public sealed class JiboInteractionServiceTests
|
||||
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);
|
||||
Assert.NotNull(decision.SkillPayload);
|
||||
Assert.True(decision.SkillPayload!.TryGetValue("weather_weekly_cards", out var weeklyCardsValue));
|
||||
var weeklyCards = Assert.IsAssignableFrom<IReadOnlyList<IDictionary<string, object?>>>(weeklyCardsValue);
|
||||
Assert.Equal(5, weeklyCards.Count);
|
||||
var firstCard = weeklyCards[0];
|
||||
Assert.Equal("Tuesday", firstCard["weather_day"]);
|
||||
Assert.Equal(61, firstCard["weather_high"]);
|
||||
Assert.Equal(52, firstCard["weather_low"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1829,6 +1837,10 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.NotNull(provider.LastRequest);
|
||||
Assert.Equal("Seattle", provider.LastRequest!.LocationQuery);
|
||||
Assert.Equal(5, provider.LastRequest.ForecastDayOffset);
|
||||
Assert.NotNull(decision.SkillPayload);
|
||||
Assert.True(decision.SkillPayload!.TryGetValue("weather_weekly_cards", out var weeklyCardsValue));
|
||||
var weeklyCards = Assert.IsAssignableFrom<IReadOnlyList<IDictionary<string, object?>>>(weeklyCardsValue);
|
||||
Assert.Equal(5, weeklyCards.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -2250,6 +2250,53 @@ public sealed class JiboWebSocketServiceTests
|
||||
Assert.Contains("tempNormal_v01.crn", payloadText, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientAsr_WeatherNextWeek_WithProvider_EmitsWeatherHiLoSequenceCards()
|
||||
{
|
||||
var customStore = new InMemoryCloudStateStore();
|
||||
var customService = CreateService(
|
||||
customStore,
|
||||
new StubWeatherReportProvider(
|
||||
new WeatherReportSnapshot("Seattle, US", "light rain", 58, 61, 52, "rain", false)));
|
||||
|
||||
await customService.HandleMessageAsync(new WebSocketMessageEnvelope
|
||||
{
|
||||
HostName = "neo-hub.jibo.com",
|
||||
Path = "/listen",
|
||||
Kind = "neo-hub-listen",
|
||||
Token = "hub-weather-next-week-token",
|
||||
Text = """{"type":"LISTEN","transID":"trans-weather-next-week","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}"""
|
||||
});
|
||||
|
||||
var replies = await customService.HandleMessageAsync(new WebSocketMessageEnvelope
|
||||
{
|
||||
HostName = "neo-hub.jibo.com",
|
||||
Path = "/listen",
|
||||
Kind = "neo-hub-listen",
|
||||
Token = "hub-weather-next-week-token",
|
||||
Text = """{"type":"CLIENT_ASR","transID":"trans-weather-next-week","data":{"text":"forecast for seattle next week"}}"""
|
||||
});
|
||||
|
||||
Assert.True(replies.Count >= 3);
|
||||
var skillReply = replies.Last(static reply => string.Equals(ReadReplyType(reply), "SKILL_ACTION", StringComparison.Ordinal));
|
||||
using var skillPayload = JsonDocument.Parse(skillReply.Text!);
|
||||
var jcp = skillPayload.RootElement
|
||||
.GetProperty("data")
|
||||
.GetProperty("action")
|
||||
.GetProperty("config")
|
||||
.GetProperty("jcp");
|
||||
|
||||
Assert.Equal("SEQUENCE", jcp.GetProperty("type").GetString());
|
||||
var children = jcp.GetProperty("children");
|
||||
Assert.Equal(5, children.GetArrayLength());
|
||||
|
||||
var firstChildConfig = children[0].GetProperty("config");
|
||||
Assert.True(firstChildConfig.TryGetProperty("display", out var firstDisplay));
|
||||
Assert.Equal(
|
||||
"weatherTempView",
|
||||
firstDisplay.GetProperty("view").GetProperty("data").GetProperty("viewConfig").GetProperty("id").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientAsr_OpenTheRadio_EmitsRadioRedirectAndSilentCompletion()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user