mapping radio

This commit is contained in:
Jacob Dubin
2026-04-20 20:55:49 -05:00
parent a9118c142f
commit 32d63584d6
8 changed files with 258 additions and 3 deletions

View File

@@ -158,6 +158,13 @@ Latest stock-OS WOD findings:
- Spoken WOD guesses should preferentially snap to the closest offered hint when Whisper lands very close to one of the menu words, since near-misses like `haglet` for `aglet` are common in live testing. - Spoken WOD guesses should preferentially snap to the closest offered hint when Whisper lands very close to one of the menu words, since near-misses like `haglet` for `aglet` are common in live testing.
- The stock robot still misroutes constrained local turns if the cloud echoes `globals/*` rules back on the reply. For spoken WOD guesses and settings/update `no`, we should only return the local rule (`word-of-the-day/puzzle`, `settings/download_now_later`, etc.) so Global Service does not relaunch Nimbus. - The stock robot still misroutes constrained local turns if the cloud echoes `globals/*` rules back on the reply. For spoken WOD guesses and settings/update `no`, we should only return the local rule (`word-of-the-day/puzzle`, `settings/download_now_later`, etc.) so Global Service does not relaunch Nimbus.
Latest radio discovery findings:
- `@be/radio` is a true local skill, not a cloud placeholder.
- Its `open(result, refresh, previousSkillName)` path treats `result.nlu.intent === "menu"` as a `play` launch.
- `result.nlu.entities.station` is the genre selector, and `Country` is a real supported station key from the robot's `genres.json`.
- The smallest stock-shaped cloud handoff for voice launch is therefore a local `SKILL_REDIRECT` to `@be/radio` with `nlu.intent = "menu"`, optional `entities.station`, and a silent completion to settle the hotphrase cloud response.
## Speech, Animation, And ESML ## Speech, Animation, And ESML
The current joke flow is only a small foothold into Jibo expressiveness. The current joke flow is only a small foothold into Jibo expressiveness.

View File

@@ -40,6 +40,7 @@ Parallel tags:
- Current evidence: - Current evidence:
- [index.js](C:/Projects/JiboOs/V3.1/build/opt/jibo/Jibo/Skills/@be/be/node_modules/@be/radio/index.js) resumes from `lastStation` - [index.js](C:/Projects/JiboOs/V3.1/build/opt/jibo/Jibo/Skills/@be/be/node_modules/@be/radio/index.js) resumes from `lastStation`
- the same file treats `menu` as a `play` launch and reads `result.nlu.entities.station` - the same file treats `menu` as a `play` launch and reads `result.nlu.entities.station`
- the same file confirms `menu + no station` is the clean resume path and `menu + station=Country` becomes a direct genre launch
- Implementation notes: - Implementation notes:
- add phrase routing for radio open/resume and genre launch - add phrase routing for radio open/resume and genre launch
- inspect radio genre and station metadata before locking the outbound entity values - inspect radio genre and station metadata before locking the outbound entity values

View File

@@ -72,6 +72,8 @@ public sealed class DemoConversationBroker(JiboInteractionService interactionSer
{ {
"word_of_the_day" => false, "word_of_the_day" => false,
"word_of_the_day_guess" => false, "word_of_the_day_guess" => false,
"radio" => false,
"radio_genre" => false,
_ => true _ => true
}; };
} }

View File

@@ -29,6 +29,8 @@ public sealed class JiboInteractionService(
"dance" => BuildDanceDecision(catalog), "dance" => BuildDanceDecision(catalog),
"time" => new JiboInteractionDecision("time", $"It is {DateTime.Now:h:mm tt}."), "time" => new JiboInteractionDecision("time", $"It is {DateTime.Now:h:mm tt}."),
"date" => new JiboInteractionDecision("date", $"Today is {DateTime.Now:dddd, MMMM d}."), "date" => new JiboInteractionDecision("date", $"Today is {DateTime.Now:dddd, MMMM d}."),
"radio" => BuildRadioLaunchDecision(),
"radio_genre" => BuildRadioGenreLaunchDecision(lowered),
"hello" => new JiboInteractionDecision("hello", randomizer.Choose(catalog.GreetingReplies)), "hello" => new JiboInteractionDecision("hello", randomizer.Choose(catalog.GreetingReplies)),
"how_are_you" => new JiboInteractionDecision("how_are_you", randomizer.Choose(catalog.HowAreYouReplies)), "how_are_you" => new JiboInteractionDecision("how_are_you", randomizer.Choose(catalog.HowAreYouReplies)),
"yes" => new JiboInteractionDecision("yes", "Yes."), "yes" => new JiboInteractionDecision("yes", "Yes."),
@@ -151,6 +153,16 @@ public sealed class JiboInteractionService(
return "joke"; return "joke";
} }
if (TryResolveRadioGenre(loweredTranscript) is not null)
{
return "radio_genre";
}
if (MatchesAny(loweredTranscript, "open the radio", "play the radio", "turn on the radio", "radio"))
{
return "radio";
}
if (MatchesAny(loweredTranscript, "dance", "boogie")) if (MatchesAny(loweredTranscript, "dance", "boogie"))
{ {
return "dance"; return "dance";
@@ -235,6 +247,33 @@ public sealed class JiboInteractionService(
}); });
} }
private static JiboInteractionDecision BuildRadioLaunchDecision()
{
return new JiboInteractionDecision(
"radio",
"Opening the radio.",
"@be/radio",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "@be/radio"
});
}
private static JiboInteractionDecision BuildRadioGenreLaunchDecision(string loweredTranscript)
{
var station = TryResolveRadioGenre(loweredTranscript) ?? "Country";
return new JiboInteractionDecision(
"radio_genre",
$"Playing {FormatRadioGenreForSpeech(station)} on the radio.",
"@be/radio",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "@be/radio",
["station"] = station
});
}
private static JiboInteractionDecision BuildWordOfTheDayGuessDecision( private static JiboInteractionDecision BuildWordOfTheDayGuessDecision(
IReadOnlyDictionary<string, string> clientEntities, IReadOnlyDictionary<string, string> clientEntities,
string transcript, string transcript,
@@ -417,6 +456,66 @@ public sealed class JiboInteractionService(
{ {
return candidates.Any(candidate => loweredTranscript.Contains(candidate, StringComparison.Ordinal)); return candidates.Any(candidate => loweredTranscript.Contains(candidate, StringComparison.Ordinal));
} }
private static string? TryResolveRadioGenre(string loweredTranscript)
{
foreach (var (phrase, station) in RadioGenreAliases)
{
if (loweredTranscript.Contains(phrase, StringComparison.Ordinal))
{
return station;
}
}
return null;
}
private static string FormatRadioGenreForSpeech(string station)
{
return station switch
{
"EightiesAndNinetiesHits" => "eighties and nineties hits",
"ChristianAndGospel" => "Christian and gospel",
"ClassicRock" => "classic rock",
"CollegeRadio" => "college radio",
"HipHop" => "hip hop",
"NewsAndTalk" => "news and talk",
"ReggaeAndIsland" => "reggae and island music",
"SoftRock" => "soft rock",
_ => station
};
}
private static readonly (string Phrase, string Station)[] RadioGenreAliases =
[
("country music", "Country"),
("country radio", "Country"),
("country", "Country"),
("classic rock", "ClassicRock"),
("soft rock", "SoftRock"),
("hip hop", "HipHop"),
("hip-hop", "HipHop"),
("news and talk", "NewsAndTalk"),
("news talk", "NewsAndTalk"),
("news radio", "NewsAndTalk"),
("sports radio", "Sports"),
("christian music", "ChristianAndGospel"),
("gospel music", "ChristianAndGospel"),
("oldies", "Oldies"),
("pop music", "Pop"),
("jazz", "Jazz"),
("latin music", "Latin"),
("dance music", "Dance"),
("reggae", "ReggaeAndIsland"),
("island music", "ReggaeAndIsland"),
("alternative", "Alternative"),
("blues", "Blues"),
("classical music", "Classical"),
("classical", "Classical"),
("college radio", "CollegeRadio"),
("comedy radio", "Comedy"),
("npr", "NPR")
];
} }
public sealed record JiboInteractionDecision( public sealed record JiboInteractionDecision(

View File

@@ -23,9 +23,14 @@ public sealed class ResponsePlanToSocketMessagesMapper
string.Equals(plan.IntentName, "no", StringComparison.OrdinalIgnoreCase); string.Equals(plan.IntentName, "no", StringComparison.OrdinalIgnoreCase);
var isWordOfDayLaunch = string.Equals(plan.IntentName, "word_of_the_day", StringComparison.OrdinalIgnoreCase); var isWordOfDayLaunch = string.Equals(plan.IntentName, "word_of_the_day", StringComparison.OrdinalIgnoreCase);
var isWordOfDayGuess = string.Equals(plan.IntentName, "word_of_the_day_guess", StringComparison.OrdinalIgnoreCase); var isWordOfDayGuess = string.Equals(plan.IntentName, "word_of_the_day_guess", StringComparison.OrdinalIgnoreCase);
var isRadioLaunch = string.Equals(plan.IntentName, "radio", StringComparison.OrdinalIgnoreCase) ||
string.Equals(plan.IntentName, "radio_genre", StringComparison.OrdinalIgnoreCase);
var radioStation = ReadSkillPayloadString(skill, "station");
var nluGuess = ReadClientEntity(turn, "guess"); var nluGuess = ReadClientEntity(turn, "guess");
var wordOfDayGuess = ResolveWordOfDayGuess(turn, transcript, nluGuess); var wordOfDayGuess = ResolveWordOfDayGuess(turn, transcript, nluGuess);
var outboundIntent = isWordOfDayLaunch var outboundIntent = isWordOfDayLaunch
? "menu"
: isRadioLaunch
? "menu" ? "menu"
: isWordOfDayGuess : isWordOfDayGuess
? "guess" ? "guess"
@@ -36,6 +41,8 @@ public sealed class ResponsePlanToSocketMessagesMapper
? wordOfDayGuess ? wordOfDayGuess
: isWordOfDayLaunch : isWordOfDayLaunch
? string.Empty ? string.Empty
: isRadioLaunch
? transcript
: string.Equals(clientIntent, "guess", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(nluGuess) : string.Equals(clientIntent, "guess", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(nluGuess)
? nluGuess ? nluGuess
: isYesNoTurn && isYesNoIntent : isYesNoTurn && isYesNoIntent
@@ -45,10 +52,12 @@ public sealed class ResponsePlanToSocketMessagesMapper
: transcript; : transcript;
var outboundRules = isWordOfDayLaunch var outboundRules = isWordOfDayLaunch
? ["word-of-the-day/menu"] ? ["word-of-the-day/menu"]
: isRadioLaunch
? Array.Empty<string>()
: isWordOfDayGuess : isWordOfDayGuess
? ["word-of-the-day/puzzle"] ? ["word-of-the-day/puzzle"]
: isYesNoTurn && isYesNoIntent ? [yesNoRule!] : rules; : isYesNoTurn && isYesNoIntent ? [yesNoRule!] : rules;
var entities = ReadEntities(turn, messageType, isYesNoTurn && isYesNoIntent, isWordOfDayLaunch, isWordOfDayGuess, wordOfDayGuess); var entities = ReadEntities(turn, messageType, isYesNoTurn && isYesNoIntent, isWordOfDayLaunch, isRadioLaunch, isWordOfDayGuess, wordOfDayGuess, radioStation);
var listenMessage = new var listenMessage = new
{ {
type = "LISTEN", type = "LISTEN",
@@ -61,7 +70,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
final = true, final = true,
text = outboundAsrText text = outboundAsrText
}, },
nlu = BuildNluPayload(outboundIntent, outboundRules, entities, isWordOfDayLaunch ? "@be/word-of-the-day" : null), nlu = BuildNluPayload(outboundIntent, outboundRules, entities, isWordOfDayLaunch ? "@be/word-of-the-day" : isRadioLaunch ? "@be/radio" : null),
match = new match = new
{ {
intent = outboundIntent, intent = outboundIntent,
@@ -100,6 +109,22 @@ public sealed class ResponsePlanToSocketMessagesMapper
DelayMs: 125)); DelayMs: 125));
} }
if (isRadioLaunch)
{
messages.Add(new SocketReplyPlan(
JsonSerializer.Serialize(BuildSkillRedirectPayload(
transId,
"@be/radio",
outboundIntent,
outboundAsrText,
outboundRules,
entities)),
DelayMs: 75));
messages.Add(new SocketReplyPlan(
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/radio")),
DelayMs: 125));
}
if (emitSkillActions && speak is not null) if (emitSkillActions && speak is not null)
{ {
messages.Add(new SocketReplyPlan( messages.Add(new SocketReplyPlan(
@@ -242,8 +267,10 @@ public sealed class ResponsePlanToSocketMessagesMapper
string? messageType, string? messageType,
bool yesNoCreateTurn, bool yesNoCreateTurn,
bool wordOfDayLaunch, bool wordOfDayLaunch,
bool radioLaunch,
bool wordOfDayGuess, bool wordOfDayGuess,
string? guess) string? guess,
string? radioStation)
{ {
if (yesNoCreateTurn) if (yesNoCreateTurn)
{ {
@@ -261,6 +288,17 @@ public sealed class ResponsePlanToSocketMessagesMapper
}; };
} }
if (radioLaunch)
{
var entities = new Dictionary<string, object?>();
if (!string.IsNullOrWhiteSpace(radioStation))
{
entities["station"] = radioStation;
}
return entities;
}
if (wordOfDayGuess) if (wordOfDayGuess)
{ {
return new Dictionary<string, object?> return new Dictionary<string, object?>

View File

@@ -544,6 +544,8 @@ public sealed class WebSocketTurnFinalizationService(
: DateTimeOffset.UtcNow.Add(WebSocketTurnState.DefaultLateAudioIgnoreWindow); : DateTimeOffset.UtcNow.Add(WebSocketTurnState.DefaultLateAudioIgnoreWindow);
var emitSkillActions = !string.Equals(plan.IntentName, "word_of_the_day", StringComparison.OrdinalIgnoreCase) && var emitSkillActions = !string.Equals(plan.IntentName, "word_of_the_day", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "radio", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "radio_genre", StringComparison.OrdinalIgnoreCase) &&
(messageType != "CLIENT_NLU" || (messageType != "CLIENT_NLU" ||
string.Equals(plan.IntentName, "word_of_the_day_guess", StringComparison.OrdinalIgnoreCase)); string.Equals(plan.IntentName, "word_of_the_day_guess", StringComparison.OrdinalIgnoreCase));
var replies = ResponsePlanToSocketMessagesMapper.Map(plan, finalizedTurn, session, emitSkillActions).Select(map => new WebSocketReply var replies = ResponsePlanToSocketMessagesMapper.Map(plan, finalizedTurn, session, emitSkillActions).Select(map => new WebSocketReply

View File

@@ -129,6 +129,38 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("joke", decision.IntentName); Assert.Equal("joke", decision.IntentName);
} }
[Fact]
public async Task BuildDecisionAsync_OpenTheRadio_MapsToRadioLaunchIntent()
{
var service = CreateService();
var decision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "open the radio",
NormalizedTranscript = "open the radio"
});
Assert.Equal("radio", decision.IntentName);
Assert.Equal("@be/radio", decision.SkillName);
Assert.Equal("@be/radio", decision.SkillPayload!["skillId"]);
}
[Fact]
public async Task BuildDecisionAsync_PlayCountryMusic_MapsToRadioGenreLaunchIntent()
{
var service = CreateService();
var decision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "play country music",
NormalizedTranscript = "play country music"
});
Assert.Equal("radio_genre", decision.IntentName);
Assert.Equal("@be/radio", decision.SkillName);
Assert.Equal("Country", decision.SkillPayload!["station"]);
}
[Fact] [Fact]
public async Task BuildDecisionAsync_WordOfDayGuess_UsesStructuredClientNluGuess() public async Task BuildDecisionAsync_WordOfDayGuess_UsesStructuredClientNluGuess()
{ {

View File

@@ -403,6 +403,80 @@ public sealed class JiboWebSocketServiceTests
Assert.Equal("surprises-ota/want_to_download_now", listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); Assert.Equal("surprises-ota/want_to_download_now", listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString());
} }
[Fact]
public async Task ClientAsr_OpenTheRadio_EmitsRadioRedirectAndSilentCompletion()
{
await _service.HandleMessageAsync(new WebSocketMessageEnvelope
{
HostName = "neo-hub.jibo.com",
Path = "/listen",
Kind = "neo-hub-listen",
Token = "hub-radio-open-token",
Text = """{"type":"LISTEN","transID":"trans-radio-open","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}"""
});
var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope
{
HostName = "neo-hub.jibo.com",
Path = "/listen",
Kind = "neo-hub-listen",
Token = "hub-radio-open-token",
Text = """{"type":"CLIENT_ASR","transID":"trans-radio-open","data":{"text":"open the radio"}}"""
});
Assert.Equal(4, replies.Count);
Assert.Equal("LISTEN", ReadReplyType(replies[0]));
Assert.Equal("EOS", ReadReplyType(replies[1]));
Assert.Equal("SKILL_REDIRECT", ReadReplyType(replies[2]));
Assert.Equal("SKILL_ACTION", ReadReplyType(replies[3]));
using var listenPayload = JsonDocument.Parse(replies[0].Text!);
Assert.Equal("menu", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString());
Assert.Equal("@be/radio", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString());
Assert.Equal(0, listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules").GetArrayLength());
Assert.Equal(0, listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").EnumerateObject().Count());
using var redirectPayload = JsonDocument.Parse(replies[2].Text!);
Assert.Equal("@be/radio", redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString());
Assert.True(redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("launch").GetBoolean());
Assert.Equal("menu", redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString());
using var completionPayload = JsonDocument.Parse(replies[3].Text!);
Assert.Equal("@be/radio", completionPayload.RootElement.GetProperty("data").GetProperty("skill").GetProperty("id").GetString());
}
[Fact]
public async Task ClientAsr_PlayCountryMusic_EmitsRadioRedirectWithCountryStation()
{
await _service.HandleMessageAsync(new WebSocketMessageEnvelope
{
HostName = "neo-hub.jibo.com",
Path = "/listen",
Kind = "neo-hub-listen",
Token = "hub-radio-country-token",
Text = """{"type":"LISTEN","transID":"trans-radio-country","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}"""
});
var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope
{
HostName = "neo-hub.jibo.com",
Path = "/listen",
Kind = "neo-hub-listen",
Token = "hub-radio-country-token",
Text = """{"type":"CLIENT_ASR","transID":"trans-radio-country","data":{"text":"play country music"}}"""
});
Assert.Equal(4, replies.Count);
using var listenPayload = JsonDocument.Parse(replies[0].Text!);
Assert.Equal("menu", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString());
Assert.Equal("Country", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("station").GetString());
using var redirectPayload = JsonDocument.Parse(replies[2].Text!);
Assert.Equal("Country", redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("station").GetString());
Assert.Equal("play country music", redirectPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString());
}
[Fact] [Fact]
public async Task ClientNlu_WordOfDayGuess_UsesGuessEntityAsAsrTextAndCompletesTurn() public async Task ClientNlu_WordOfDayGuess_UsesGuessEntityAsAsrTextAndCompletesTurn()
{ {