Add Pegasus-style weather hi-lo visual payload parity

This commit is contained in:
Jacob Dubin
2026-05-07 07:48:51 -05:00
parent 92491adf85
commit 3ad4a3e025
4 changed files with 329 additions and 2 deletions

View File

@@ -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<string, object?>(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

View File

@@ -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<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["weatherHiLo"] = weatherHiLoView
};
}
return new
{
type = "SKILL_ACTION",
@@ -1070,6 +1085,188 @@ public sealed class ResponsePlanToSocketMessagesMapper
};
}
private static object? BuildWeatherHiLoView(IDictionary<string, object?>? 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<string, object?>(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<string, object?>? 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<string, object?>? 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()}";

View File

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

View File

@@ -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<WeatherReportSnapshot?> GetReportAsync(
WeatherReportRequest request,
CancellationToken cancellationToken = default)
{
return Task.FromResult<WeatherReportSnapshot?>(snapshot);
}
}
}