Add Pegasus-style weather hi-lo visual payload parity
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()}";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user