From 3ad4a3e025fef35da474325773fb2e8bbf22fc7d Mon Sep 17 00:00:00 2001 From: Jacob Dubin Date: Thu, 7 May 2026 07:48:51 -0500 Subject: [PATCH] Add Pegasus-style weather hi-lo visual payload parity --- .../Services/JiboInteractionService.cs | 33 ++- .../ResponsePlanToSocketMessagesMapper.cs | 197 ++++++++++++++++++ .../WebSockets/JiboInteractionServiceTests.cs | 9 +- .../WebSockets/JiboWebSocketServiceTests.cs | 92 ++++++++ 4 files changed, 329 insertions(+), 2 deletions(-) diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs index 91a449d..c5e67b8 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs @@ -457,6 +457,7 @@ public sealed class JiboInteractionService( return new JiboInteractionDecision( "weather", spokenReply, + "chitchat-skill", SkillPayload: weatherPayload); } @@ -498,6 +499,10 @@ public sealed class JiboInteractionService( { var weatherIcon = ResolveWeatherAnimationIcon(snapshot, referenceLocalTime); var promptToken = ResolveWeatherPromptToken(weatherIcon); + var highTemperature = snapshot.HighTemperature ?? snapshot.Temperature; + var lowTemperature = snapshot.LowTemperature ?? snapshot.Temperature; + var temperatureUnit = snapshot.UseCelsius ? "C" : "F"; + var temperatureBand = ResolveWeatherTemperatureBand(highTemperature, snapshot.UseCelsius); return new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -506,7 +511,16 @@ public sealed class JiboInteractionService( ["mim_id"] = $"WeatherComment{promptToken}", ["mim_type"] = "announcement", ["prompt_id"] = $"WeatherComment{promptToken}_AN_13", - ["prompt_sub_category"] = "AN" + ["prompt_sub_category"] = "AN", + ["weather_view_enabled"] = true, + ["weather_view_kind"] = "weatherHiLo", + ["weather_icon"] = weatherIcon, + ["weather_summary"] = snapshot.Summary, + ["weather_location"] = snapshot.LocationName, + ["weather_high"] = highTemperature, + ["weather_low"] = lowTemperature, + ["weather_unit"] = temperatureUnit, + ["weather_theme"] = temperatureBand }; } @@ -590,6 +604,23 @@ public sealed class JiboInteractionService( }; } + private static string ResolveWeatherTemperatureBand(int highTemperature, bool useCelsius) + { + var hotThreshold = useCelsius ? 29 : 85; + var coldThreshold = useCelsius ? 4 : 40; + if (highTemperature > hotThreshold) + { + return "Hot"; + } + + if (highTemperature < coldThreshold) + { + return "Cold"; + } + + return "Normal"; + } + private static string EscapeForEsml(string value) { return value diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ResponsePlanToSocketMessagesMapper.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ResponsePlanToSocketMessagesMapper.cs index 7a9d030..c887f67 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ResponsePlanToSocketMessagesMapper.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ResponsePlanToSocketMessagesMapper.cs @@ -820,6 +820,21 @@ public sealed class ResponsePlanToSocketMessagesMapper }; } + var weatherHiLoView = BuildWeatherHiLoView(skillPayload); + if (weatherHiLoView is not null) + { + jcpConfig["gui"] = new + { + type = "Javascript", + data = "views.weatherHiLo", + pause = true + }; + jcpConfig["views"] = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["weatherHiLo"] = weatherHiLoView + }; + } + return new { type = "SKILL_ACTION", @@ -1070,6 +1085,188 @@ public sealed class ResponsePlanToSocketMessagesMapper }; } + private static object? BuildWeatherHiLoView(IDictionary? payload) + { + if (!TryReadPayloadBool(payload, "weather_view_enabled")) + { + return null; + } + + if (!string.Equals( + ReadPayloadString(payload, "weather_view_kind"), + "weatherHiLo", + StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var icon = ReadPayloadString(payload, "weather_icon"); + var unit = ReadPayloadString(payload, "weather_unit") ?? "F"; + var theme = ReadPayloadString(payload, "weather_theme") ?? "Normal"; + var high = TryReadPayloadInt(payload, "weather_high"); + var low = TryReadPayloadInt(payload, "weather_low"); + if (string.IsNullOrWhiteSpace(icon) || high is null || low is null) + { + return null; + } + + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["viewConfig"] = new + { + type = "View", + id = "weatherTempView", + category = "gui" + }, + ["open"] = new + { + transitionOpen = "trans_in", + removeAll = true + }, + ["defaultSelect"] = new + { + transitionClose = "trans_out", + removeAll = true, + leaveEmpty = false + }, + ["componentConfigs"] = new object[] + { + new + { + id = "tempBGClip", + type = "Clip", + assets = new object[] + { + new + { + id = "tempBG", + src = $"assets/personal-report-skill/weather/bg/temp{theme}_v01.crn", + type = "texture" + } + }, + position = new { x = 36, y = 0 } + }, + new + { + id = "iconClip", + type = "Clip", + assets = new object[] + { + new + { + id = "icon", + src = $"assets/personal-report-skill/weather/icons/{icon}_v01.crn", + type = "texture" + } + }, + position = new { x = 475, y = 195 } + }, + new + { + id = "hiNumLabel", + type = "Label", + text = $"{high.Value}°", + style = new + { + fontSize = "160", + fontFamily = "Proxima Nova Soft", + fontWeight = "bold", + fill = "#FFFFFF", + align = "center" + }, + position = new { x = 370, y = 430 }, + targetAnchor = new { x = 1, y = 1 } + }, + new + { + id = "hiUnitLabel", + type = "Label", + text = unit, + style = new + { + fontSize = "90", + fontFamily = "Proxima Nova Soft", + fontWeight = "bold", + fill = "#FFFFFF", + align = "center" + }, + position = new { x = 360, y = 418 }, + targetAnchor = new { x = 0, y = 1 } + }, + new + { + id = "loNumLabel", + type = "Label", + text = $"{low.Value}°", + style = new + { + fontSize = "160", + fontFamily = "Proxima Nova Soft", + fontWeight = "bold", + fill = "#FFFFFF", + align = "center" + }, + position = new { x = 1110, y = 430 }, + targetAnchor = new { x = 1, y = 1 } + }, + new + { + id = "loUnitLabel", + type = "Label", + text = unit, + style = new + { + fontSize = "90", + fontFamily = "Proxima Nova Soft", + fontWeight = "bold", + fill = "#FFFFFF", + align = "center" + }, + position = new { x = 1100, y = 418 }, + targetAnchor = new { x = 0, y = 1 } + } + } + }; + } + + private static int? TryReadPayloadInt(IDictionary? payload, string key) + { + if (payload is null || !payload.TryGetValue(key, out var value) || value is null) + { + return null; + } + + return value switch + { + int number => number, + long number when number <= int.MaxValue && number >= int.MinValue => (int)number, + double number => (int)Math.Round(number, MidpointRounding.AwayFromZero), + float number => (int)Math.Round(number, MidpointRounding.AwayFromZero), + string text when int.TryParse(text, out var parsed) => parsed, + JsonElement { ValueKind: JsonValueKind.Number } jsonNumber when jsonNumber.TryGetInt32(out var parsed) => parsed, + JsonElement jsonText when jsonText.ValueKind == JsonValueKind.String && int.TryParse(jsonText.GetString(), out var parsed) => parsed, + _ => null + }; + } + + private static bool TryReadPayloadBool(IDictionary? payload, string key) + { + if (payload is null || !payload.TryGetValue(key, out var value) || value is null) + { + return false; + } + + return value switch + { + bool flag => flag, + string text when bool.TryParse(text, out var parsed) => parsed, + JsonElement { ValueKind: JsonValueKind.True } => true, + JsonElement { ValueKind: JsonValueKind.False } => false, + JsonElement jsonText when jsonText.ValueKind == JsonValueKind.String && bool.TryParse(jsonText.GetString(), out var parsed) => parsed, + _ => false + }; + } + private static string CreateHubMessageId() { return $"mid-{Guid.NewGuid()}"; diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs index 00304c0..ffdcee4 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs @@ -1181,11 +1181,18 @@ public sealed class JiboInteractionServiceTests }); Assert.Equal("weather", decision.IntentName); - Assert.Null(decision.SkillName); + Assert.Equal("chitchat-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); Assert.Equal("WeatherCommentRain", decision.SkillPayload["mim_id"]); + Assert.Equal(true, decision.SkillPayload["weather_view_enabled"]); + Assert.Equal("weatherHiLo", decision.SkillPayload["weather_view_kind"]); + Assert.Equal("rain", decision.SkillPayload["weather_icon"]); + Assert.Equal(65, decision.SkillPayload["weather_high"]); + Assert.Equal(54, decision.SkillPayload["weather_low"]); + Assert.Equal("F", decision.SkillPayload["weather_unit"]); + Assert.Equal("Normal", decision.SkillPayload["weather_theme"]); Assert.Equal("Right now in Boston, US, it is light rain and 61 degrees Fahrenheit.", decision.ReplyText); Assert.NotNull(provider.LastRequest); Assert.False(provider.LastRequest!.IsTomorrow); diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs index fdfac6d..8d1bd6b 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Jibo.Cloud.Application.Abstractions; using Jibo.Cloud.Application.Services; using Jibo.Cloud.Domain.Models; using Jibo.Cloud.Infrastructure.Content; @@ -1986,6 +1987,63 @@ public sealed class JiboWebSocketServiceTests Assert.Contains("weather service is connected", esml, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task ClientAsr_HowIsTheWeather_WithProvider_EmitsWeatherHiLoGuiCard() + { + var customStore = new InMemoryCloudStateStore(); + var customService = CreateService( + customStore, + new StubWeatherReportProvider( + new WeatherReportSnapshot("Boston, US", "light rain", 61, 65, 54, "rain", false))); + + await customService.HandleMessageAsync(new WebSocketMessageEnvelope + { + HostName = "neo-hub.jibo.com", + Path = "/listen", + Kind = "neo-hub-listen", + Token = "hub-weather-provider-token", + Text = """{"type":"LISTEN","transID":"trans-weather-provider","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-provider-token", + Text = """{"type":"CLIENT_ASR","transID":"trans-weather-provider","data":{"text":"how is the weather"}}""" + }); + + Assert.Equal(3, replies.Count); + Assert.Equal("LISTEN", ReadReplyType(replies[0])); + Assert.Equal("EOS", ReadReplyType(replies[1])); + Assert.Equal("SKILL_ACTION", ReadReplyType(replies[2])); + + using var skillPayload = JsonDocument.Parse(replies[2].Text!); + var jcpConfig = skillPayload.RootElement + .GetProperty("data") + .GetProperty("action") + .GetProperty("config") + .GetProperty("jcp") + .GetProperty("config"); + + var esml = jcpConfig.GetProperty("play").GetProperty("esml").GetString(); + Assert.Contains("cat='weather'", esml, StringComparison.OrdinalIgnoreCase); + + Assert.True(jcpConfig.TryGetProperty("gui", out var gui)); + Assert.Equal("Javascript", gui.GetProperty("type").GetString()); + Assert.Equal("views.weatherHiLo", gui.GetProperty("data").GetString()); + Assert.True(gui.GetProperty("pause").GetBoolean()); + + Assert.True(jcpConfig.TryGetProperty("views", out var views)); + var weatherHiLo = views.GetProperty("weatherHiLo"); + Assert.Equal("weatherTempView", weatherHiLo.GetProperty("viewConfig").GetProperty("id").GetString()); + + var payloadText = replies[2].Text!; + Assert.Contains("assets/personal-report-skill/weather/icons/rain_v01.crn", payloadText, StringComparison.Ordinal); + Assert.Contains("tempNormal_v01.crn", payloadText, StringComparison.Ordinal); + } + [Fact] public async Task ClientAsr_OpenTheRadio_EmitsRadioRedirectAndSilentCompletion() { @@ -3735,9 +3793,43 @@ public sealed class JiboWebSocketServiceTests } } + private static JiboWebSocketService CreateService( + InMemoryCloudStateStore stateStore, + IWeatherReportProvider? weatherReportProvider = null) + { + var contentRepository = new InMemoryJiboExperienceContentRepository(); + var contentCache = new JiboExperienceContentCache(contentRepository); + var interactionService = new JiboInteractionService( + contentCache, + new DefaultJiboRandomizer(), + new InMemoryPersonalMemoryStore(), + weatherReportProvider); + var conversationBroker = new DemoConversationBroker(interactionService); + var sttSelector = new DefaultSttStrategySelector( + [ + new SyntheticBufferedAudioSttStrategy() + ]); + var sink = new NullTurnTelemetrySink(); + + return new JiboWebSocketService( + stateStore, + new NullWebSocketTelemetrySink(), + new WebSocketTurnFinalizationService(conversationBroker, sttSelector, sink)); + } + private static string ReadReplyType(WebSocketReply reply) { using var payload = JsonDocument.Parse(reply.Text!); return payload.RootElement.GetProperty("type").GetString() ?? string.Empty; } + + private sealed class StubWeatherReportProvider(WeatherReportSnapshot snapshot) : IWeatherReportProvider + { + public Task GetReportAsync( + WeatherReportRequest request, + CancellationToken cancellationToken = default) + { + return Task.FromResult(snapshot); + } + } }