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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user