Add weekly weather cards and improve news API fallback

This commit is contained in:
Jacob Dubin
2026-05-11 22:44:56 -05:00
parent 67c738fae3
commit df3b34c8ad
15 changed files with 66158 additions and 37 deletions

View File

@@ -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";

View File

@@ -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);
}

View File

@@ -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);
}