jibo clock family skills and feature backlog updates

This commit is contained in:
Jacob Dubin
2026-04-20 22:25:08 -05:00
parent 2ea7afe2e7
commit ab47ad7a2d
8 changed files with 467 additions and 6 deletions

View File

@@ -74,6 +74,11 @@ public sealed class DemoConversationBroker(JiboInteractionService interactionSer
"word_of_the_day_guess" => false,
"radio" => false,
"radio_genre" => false,
"clock_menu" => false,
"timer_menu" => false,
"alarm_menu" => false,
"timer_value" => false,
"alarm_value" => false,
"news" => false,
_ => true
};

View File

@@ -1,6 +1,7 @@
using Jibo.Cloud.Application.Abstractions;
using Jibo.Runtime.Abstractions;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Jibo.Cloud.Application.Services;
@@ -29,9 +30,15 @@ public sealed class JiboInteractionService(
"dance" => BuildDanceDecision(catalog),
"time" => new JiboInteractionDecision("time", $"It is {DateTime.Now:h:mm tt}."),
"date" => new JiboInteractionDecision("date", $"Today is {DateTime.Now:dddd, MMMM d}."),
"day" => new JiboInteractionDecision("day", $"Today is {DateTime.Now:dddd}."),
"cloud_version" => new JiboInteractionDecision("cloud_version", OpenJiboCloudBuildInfo.SpokenVersion),
"radio" => BuildRadioLaunchDecision(),
"radio_genre" => BuildRadioGenreLaunchDecision(lowered),
"clock_menu" => BuildClockLaunchDecision("clock", "Opening the clock."),
"timer_menu" => BuildClockLaunchDecision("timer", "Opening the timer."),
"alarm_menu" => BuildClockLaunchDecision("alarm", "Opening the alarm."),
"timer_value" => BuildTimerValueDecision(lowered),
"alarm_value" => BuildAlarmValueDecision(lowered),
"hello" => new JiboInteractionDecision("hello", randomizer.Choose(catalog.GreetingReplies)),
"how_are_you" => new JiboInteractionDecision("how_are_you", randomizer.Choose(catalog.HowAreYouReplies)),
"yes" => new JiboInteractionDecision("yes", "Yes."),
@@ -149,6 +156,33 @@ public sealed class JiboInteractionService(
return "date";
}
if (string.Equals(clientIntent, "askForDay", StringComparison.OrdinalIgnoreCase))
{
return "day";
}
if (string.Equals(clientIntent, "timerValue", StringComparison.OrdinalIgnoreCase))
{
return "timer_value";
}
if (string.Equals(clientIntent, "alarmValue", StringComparison.OrdinalIgnoreCase))
{
return "alarm_value";
}
if (string.Equals(clientIntent, "menu", StringComparison.OrdinalIgnoreCase) &&
clientEntities.TryGetValue("domain", out var clockDomain))
{
return clockDomain.ToLowerInvariant() switch
{
"clock" => "clock_menu",
"timer" => "timer_menu",
"alarm" => "alarm_menu",
_ => "chat"
};
}
if (MatchesAny(
loweredTranscript,
"word of the day",
@@ -187,6 +221,31 @@ public sealed class JiboInteractionService(
return "radio_genre";
}
if (TryParseAlarmValue(loweredTranscript) is not null)
{
return "alarm_value";
}
if (TryParseTimerValue(loweredTranscript) is not null)
{
return "timer_value";
}
if (MatchesAny(loweredTranscript, "open the clock", "open clock", "show the clock", "show clock"))
{
return "clock_menu";
}
if (MatchesAny(loweredTranscript, "open the timer", "open timer", "show the timer", "show timer"))
{
return "timer_menu";
}
if (MatchesAny(loweredTranscript, "open the alarm", "open alarm", "show the alarm", "show alarm"))
{
return "alarm_menu";
}
if (MatchesAny(loweredTranscript, "open the radio", "play the radio", "turn on the radio", "radio"))
{
return "radio";
@@ -253,6 +312,11 @@ public sealed class JiboInteractionService(
return "time";
}
if (MatchesAny(loweredTranscript, "what day is it", "what day is today"))
{
return "day";
}
if (MatchesAny(loweredTranscript, "what day is it", "what is the date", "today s date", "today's date") ||
loweredTranscript.Contains("date", StringComparison.Ordinal) ||
loweredTranscript.Contains("day", StringComparison.Ordinal))
@@ -288,6 +352,57 @@ public sealed class JiboInteractionService(
});
}
private static JiboInteractionDecision BuildClockLaunchDecision(string domain, string replyText)
{
return new JiboInteractionDecision(
$"{domain}_menu",
replyText,
"@be/clock",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "@be/clock",
["domain"] = domain,
["clockIntent"] = "menu"
});
}
private static JiboInteractionDecision BuildTimerValueDecision(string loweredTranscript)
{
var timer = TryParseTimerValue(loweredTranscript) ?? new ClockTimerValue("0", "1", "null");
return new JiboInteractionDecision(
"timer_value",
"Setting your timer.",
"@be/clock",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "@be/clock",
["domain"] = "timer",
["clockIntent"] = "timerValue",
["hours"] = timer.Hours,
["minutes"] = timer.Minutes,
["seconds"] = timer.Seconds
});
}
private static JiboInteractionDecision BuildAlarmValueDecision(string loweredTranscript)
{
var alarm = TryParseAlarmValue(loweredTranscript) ?? new ClockAlarmValue("7:00", "am");
return new JiboInteractionDecision(
"alarm_value",
"Setting your alarm.",
"@be/clock",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "@be/clock",
["domain"] = "alarm",
["clockIntent"] = "alarmValue",
["time"] = alarm.Time,
["ampm"] = alarm.AmPm
});
}
private static JiboInteractionDecision BuildRadioGenreLaunchDecision(string loweredTranscript)
{
var station = TryResolveRadioGenre(loweredTranscript) ?? "Country";
@@ -516,6 +631,116 @@ public sealed class JiboInteractionService(
};
}
private static ClockTimerValue? TryParseTimerValue(string loweredTranscript)
{
if (!loweredTranscript.Contains("timer", StringComparison.Ordinal))
{
return null;
}
var hours = ExtractDurationValue(loweredTranscript, "hour");
var minutes = ExtractDurationValue(loweredTranscript, "minute");
var seconds = ExtractDurationValue(loweredTranscript, "second");
if (hours is null && minutes is null && seconds is null)
{
return null;
}
return new ClockTimerValue(
(hours ?? 0).ToString(),
(minutes ?? 0).ToString(),
seconds is null ? "null" : seconds.Value.ToString());
}
private static ClockAlarmValue? TryParseAlarmValue(string loweredTranscript)
{
if (!loweredTranscript.Contains("alarm", StringComparison.Ordinal))
{
return null;
}
var match = AlarmPattern.Match(loweredTranscript);
if (!match.Success)
{
return null;
}
var hourToken = match.Groups["hour"].Value;
var minuteToken = match.Groups["minute"].Success ? match.Groups["minute"].Value : "00";
var hour = ParseNumberToken(hourToken);
if (hour is null || hour is < 1 or > 12)
{
return null;
}
if (!int.TryParse(minuteToken, out var minute) || minute is < 0 or > 59)
{
return null;
}
var ampm = match.Groups["ampm"].Value.StartsWith("p", StringComparison.Ordinal) ? "pm" : "am";
return new ClockAlarmValue($"{hour}:{minute:00}", ampm);
}
private static int? ExtractDurationValue(string loweredTranscript, string unitStem)
{
var pattern = new Regex($@"\b(?<value>\d+|[a-z\-]+)\s+{unitStem}s?\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
var match = pattern.Match(loweredTranscript);
if (!match.Success)
{
return null;
}
return ParseNumberToken(match.Groups["value"].Value);
}
private static int? ParseNumberToken(string token)
{
var normalized = token.Trim().ToLowerInvariant();
if (int.TryParse(normalized, out var numeric))
{
return numeric;
}
return normalized switch
{
"a" or "an" => 1,
"one" => 1,
"two" => 2,
"three" => 3,
"four" => 4,
"five" => 5,
"six" => 6,
"seven" => 7,
"eight" => 8,
"nine" => 9,
"ten" => 10,
"eleven" => 11,
"twelve" => 12,
"thirteen" => 13,
"fourteen" => 14,
"fifteen" => 15,
"sixteen" => 16,
"seventeen" => 17,
"eighteen" => 18,
"nineteen" => 19,
"twenty" => 20,
"thirty" => 30,
"forty" => 40,
"fifty" => 50,
_ => null
};
}
private sealed record ClockTimerValue(string Hours, string Minutes, string Seconds);
private sealed record ClockAlarmValue(string Time, string AmPm);
private static readonly Regex AlarmPattern = new(
@"\b(?<hour>\d{1,2}|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)(?:[:\s](?<minute>\d{2}))?\s*(?<ampm>a\.?m\.?|p\.?m\.?)\b",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
private static readonly (string Phrase, string Station)[] RadioGenreAliases =
[
("country music", "Country"),

View File

@@ -25,6 +25,14 @@ public sealed class ResponsePlanToSocketMessagesMapper
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 isClockSkillLaunch = string.Equals(skill?.SkillName, "@be/clock", StringComparison.OrdinalIgnoreCase);
var clockIntent = ReadSkillPayloadString(skill, "clockIntent");
var clockDomain = ReadSkillPayloadString(skill, "domain");
var timerHours = ReadSkillPayloadString(skill, "hours");
var timerMinutes = ReadSkillPayloadString(skill, "minutes");
var timerSeconds = ReadSkillPayloadString(skill, "seconds");
var alarmTime = ReadSkillPayloadString(skill, "time");
var alarmAmPm = ReadSkillPayloadString(skill, "ampm");
var radioStation = ReadSkillPayloadString(skill, "station");
var cloudSkill = ReadSkillPayloadString(skill, "cloudSkill");
var nluGuess = ReadClientEntity(turn, "guess");
@@ -33,6 +41,8 @@ public sealed class ResponsePlanToSocketMessagesMapper
? "menu"
: isRadioLaunch
? "menu"
: isClockSkillLaunch && !string.IsNullOrWhiteSpace(clockIntent)
? clockIntent
: isWordOfDayGuess
? "guess"
: string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(clientIntent)
@@ -44,6 +54,8 @@ public sealed class ResponsePlanToSocketMessagesMapper
? string.Empty
: isRadioLaunch
? transcript
: isClockSkillLaunch
? transcript
: string.Equals(clientIntent, "guess", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(nluGuess)
? nluGuess
: isYesNoTurn && isYesNoIntent
@@ -55,6 +67,8 @@ public sealed class ResponsePlanToSocketMessagesMapper
? ["word-of-the-day/menu"]
: isRadioLaunch
? Array.Empty<string>()
: isClockSkillLaunch
? string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase) ? rules : Array.Empty<string>()
: isWordOfDayGuess
? ["word-of-the-day/puzzle"]
: isYesNoTurn && isYesNoIntent ? [yesNoRule!] : rules;
@@ -67,7 +81,15 @@ public sealed class ResponsePlanToSocketMessagesMapper
isRadioLaunch,
isWordOfDayGuess,
wordOfDayGuess,
radioStation);
radioStation,
isClockSkillLaunch,
clockDomain,
clockIntent,
timerHours,
timerMinutes,
timerSeconds,
alarmTime,
alarmAmPm);
var listenMessage = new
{
type = "LISTEN",
@@ -80,7 +102,14 @@ public sealed class ResponsePlanToSocketMessagesMapper
final = true,
text = outboundAsrText
},
nlu = BuildNluPayload(outboundIntent, outboundRules, entities, isWordOfDayLaunch ? "@be/word-of-the-day" : isRadioLaunch ? "@be/radio" : null),
nlu = BuildNluPayload(
outboundIntent,
outboundRules,
entities,
isWordOfDayLaunch ? "@be/word-of-the-day" :
isRadioLaunch ? "@be/radio" :
isClockSkillLaunch ? "@be/clock" :
null),
match = new
{
intent = outboundIntent,
@@ -136,6 +165,23 @@ public sealed class ResponsePlanToSocketMessagesMapper
DelayMs: 125));
}
if (isClockSkillLaunch &&
!string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase))
{
messages.Add(new SocketReplyPlan(
JsonSerializer.Serialize(BuildSkillRedirectPayload(
transId,
"@be/clock",
outboundIntent,
outboundAsrText,
outboundRules,
entities)),
DelayMs: 75));
messages.Add(new SocketReplyPlan(
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/clock")),
DelayMs: 125));
}
if (emitSkillActions && speak is not null)
{
messages.Add(new SocketReplyPlan(
@@ -282,7 +328,15 @@ public sealed class ResponsePlanToSocketMessagesMapper
bool radioLaunch,
bool wordOfDayGuess,
string? guess,
string? radioStation)
string? radioStation,
bool clockSkillLaunch,
string? clockDomain,
string? clockIntent,
string? timerHours,
string? timerMinutes,
string? timerSeconds,
string? alarmTime,
string? alarmAmPm)
{
if (yesNoTurn)
{
@@ -316,6 +370,30 @@ public sealed class ResponsePlanToSocketMessagesMapper
return entities;
}
if (clockSkillLaunch)
{
var entities = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(clockDomain))
{
entities["domain"] = clockDomain;
}
if (string.Equals(clockIntent, "timerValue", StringComparison.OrdinalIgnoreCase))
{
entities["hours"] = timerHours ?? "0";
entities["minutes"] = timerMinutes ?? "0";
entities["seconds"] = timerSeconds ?? "null";
}
if (string.Equals(clockIntent, "alarmValue", StringComparison.OrdinalIgnoreCase))
{
entities["time"] = alarmTime ?? string.Empty;
entities["ampm"] = alarmAmPm ?? string.Empty;
}
return entities;
}
if (wordOfDayGuess)
{
return new Dictionary<string, object?>

View File

@@ -564,6 +564,11 @@ public sealed class WebSocketTurnFinalizationService(
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) &&
!string.Equals(plan.IntentName, "clock_menu", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "timer_menu", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "alarm_menu", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "timer_value", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "alarm_value", StringComparison.OrdinalIgnoreCase) &&
(messageType != "CLIENT_NLU" ||
string.Equals(plan.IntentName, "word_of_the_day_guess", StringComparison.OrdinalIgnoreCase));
var replies = ResponsePlanToSocketMessagesMapper.Map(plan, finalizedTurn, session, emitSkillActions).Select(map => new WebSocketReply