Files
JiboExperiments/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs
Jacob Dubin b595766ac9 twerk?
2026-04-21 22:13:19 -05:00

940 lines
32 KiB
C#

using Jibo.Cloud.Application.Abstractions;
using Jibo.Runtime.Abstractions;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Jibo.Cloud.Application.Services;
public sealed class JiboInteractionService(
JiboExperienceContentCache contentCache,
IJiboRandomizer randomizer)
{
public async Task<JiboInteractionDecision> BuildDecisionAsync(TurnContext turn, CancellationToken cancellationToken = default)
{
var catalog = await contentCache.GetCatalogAsync(cancellationToken);
var transcript = (turn.NormalizedTranscript ?? turn.RawTranscript ?? string.Empty).Trim();
var lowered = transcript.ToLowerInvariant();
var clientIntent = turn.Attributes.TryGetValue("clientIntent", out var rawClientIntent)
? rawClientIntent?.ToString()
: null;
var clientRules = ReadRules(turn, "clientRules").ToArray();
var listenRules = ReadRules(turn, "listenRules").ToArray();
var listenAsrHints = ReadRules(turn, "listenAsrHints").ToArray();
var clientEntities = ReadEntities(turn);
var isYesNoTurn = IsYesNoTurn(turn);
var semanticIntent = ResolveSemanticIntent(lowered, clientIntent, clientRules, listenRules, clientEntities, isYesNoTurn);
return semanticIntent switch
{
"joke" => BuildJokeDecision(catalog),
"dance" => BuildRandomDanceDecision(catalog),
"twerk" => BuildDanceDecision("rom-twerk", "Watch me twerk."),
"time" => BuildClockLaunchDecision("time", "clock", "askForTime", "Showing the time."),
"date" => BuildClockLaunchDecision("date", "clock", "askForDate", "Showing the date."),
"day" => BuildClockLaunchDecision("day", "clock", "askForDay", "Showing the day."),
"cloud_version" => new JiboInteractionDecision("cloud_version", OpenJiboCloudBuildInfo.SpokenVersion),
"radio" => BuildRadioLaunchDecision(),
"radio_genre" => BuildRadioGenreLaunchDecision(lowered),
"clock_open" => BuildClockLaunchDecision("clock_open", "clock", "askForTime", "Opening the clock."),
"clock_menu" => BuildClockLaunchDecision("clock_menu", "clock", "menu", "Opening the clock menu."),
"timer_menu" => BuildClockLaunchDecision("timer", "Opening the timer."),
"alarm_menu" => BuildClockLaunchDecision("alarm", "Opening the alarm."),
"timer_value" => BuildTimerValueDecision(lowered),
"alarm_value" => BuildAlarmValueDecision(lowered),
"timer_clarify" => new JiboInteractionDecision("timer_clarify", "How long should I set the timer for?"),
"alarm_clarify" => new JiboInteractionDecision("alarm_clarify", "What time should I set the alarm for?"),
"photo_gallery" => BuildPhotoGalleryLaunchDecision(),
"snapshot" => BuildPhotoCreateDecision("snapshot", "Taking a picture.", "createOnePhoto"),
"photobooth" => BuildPhotoCreateDecision("photobooth", "Starting photobooth.", "createSomePhotos"),
"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."),
"no" => new JiboInteractionDecision("no", "No."),
"word_of_the_day" => BuildWordOfTheDayLaunchDecision(),
"word_of_the_day_guess" => BuildWordOfTheDayGuessDecision(clientEntities, transcript, listenAsrHints),
"surprise" => new JiboInteractionDecision("surprise", randomizer.Choose(catalog.SurpriseReplies)),
"personal_report" => new JiboInteractionDecision("personal_report", randomizer.Choose(catalog.PersonalReportReplies)),
"weather" => new JiboInteractionDecision("weather", randomizer.Choose(catalog.WeatherReplies)),
"calendar" => new JiboInteractionDecision("calendar", randomizer.Choose(catalog.CalendarReplies)),
"commute" => new JiboInteractionDecision("commute", randomizer.Choose(catalog.CommuteReplies)),
"news" => BuildNewsDecision(catalog),
_ => new JiboInteractionDecision("chat", BuildGenericReply(catalog, transcript, lowered))
};
}
private JiboInteractionDecision BuildJokeDecision(JiboExperienceCatalog catalog)
{
var joke = randomizer.Choose(catalog.Jokes);
return new JiboInteractionDecision(
"joke",
joke,
"@be/joke",
new Dictionary<string, object?>
{
["replyType"] = "joke"
});
}
private JiboInteractionDecision BuildRandomDanceDecision(JiboExperienceCatalog catalog)
{
var dance = randomizer.Choose(catalog.DanceAnimations);
var replyText = randomizer.Choose(catalog.DanceReplies);
return BuildDanceDecision(dance, replyText);
}
private static JiboInteractionDecision BuildDanceDecision(string dance, string replyText)
{
return new JiboInteractionDecision(
"dance",
replyText,
"chitchat-skill",
new Dictionary<string, object?>
{
["esml"] = $"<speak>Okay.<break size='0.2'/> Watch this.<anim cat='dance' filter='music, {dance}' /></speak>",
["mim_id"] = "runtime-chat",
["mim_type"] = "announcement"
});
}
private JiboInteractionDecision BuildNewsDecision(JiboExperienceCatalog catalog)
{
var briefing = randomizer.Choose(catalog.NewsBriefings);
return new JiboInteractionDecision(
"news",
briefing,
"news",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "news",
["cloudSkill"] = "news",
["mim_id"] = "runtime-news",
["mim_type"] = "announcement"
});
}
private string BuildGenericReply(JiboExperienceCatalog catalog, string transcript, string lowered)
{
if (string.IsNullOrWhiteSpace(transcript))
{
return "I am listening.";
}
if (lowered.Contains("good morning", StringComparison.Ordinal))
{
return "Good morning! It is nice to hear your voice.";
}
if (lowered.Contains("good afternoon", StringComparison.Ordinal))
{
return "Good afternoon. I am happy to be here.";
}
return lowered.Contains("good night", StringComparison.Ordinal)
? "Good night. Sleep tight."
: randomizer.Choose(catalog.GenericFallbackReplies)
.Replace("{transcript}", transcript, StringComparison.Ordinal);
}
private static string ResolveSemanticIntent(
string loweredTranscript,
string? clientIntent,
IReadOnlyList<string> clientRules,
IReadOnlyList<string> listenRules,
IReadOnlyDictionary<string, string> clientEntities,
bool isYesNoTurn)
{
var wordOfDayPuzzleTurn = clientRules.Concat(listenRules)
.Any(rule => string.Equals(rule, "word-of-the-day/puzzle", StringComparison.OrdinalIgnoreCase));
if (string.Equals(clientIntent, "guess", StringComparison.OrdinalIgnoreCase) &&
wordOfDayPuzzleTurn)
{
return "word_of_the_day_guess";
}
if (string.Equals(clientIntent, "loadMenu", StringComparison.OrdinalIgnoreCase) &&
clientEntities.TryGetValue("destination", out var destination) &&
string.Equals(destination, "word-of-the-day", StringComparison.OrdinalIgnoreCase))
{
return "word_of_the_day";
}
if (string.Equals(clientIntent, "loadMenu", StringComparison.OrdinalIgnoreCase) &&
clientEntities.TryGetValue("destination", out var photoDestination))
{
return photoDestination.ToLowerInvariant() switch
{
"snapshot" => "snapshot",
"photobooth" => "photobooth",
"gallery" or "photo-gallery" or "photos" => "photo_gallery",
_ => "chat"
};
}
if (string.Equals(clientIntent, "askForTime", StringComparison.OrdinalIgnoreCase))
{
return "time";
}
if (string.Equals(clientIntent, "askForDate", StringComparison.OrdinalIgnoreCase))
{
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",
"start word of the day",
"play word of the day",
"do word of the day",
"open word of the day"))
{
return "word_of_the_day";
}
if (wordOfDayPuzzleTurn && !string.IsNullOrWhiteSpace(loweredTranscript))
{
return "word_of_the_day_guess";
}
if (MatchesAny(loweredTranscript, "joke", "funny", "make me laugh"))
{
return "joke";
}
if (MatchesAny(
loweredTranscript,
"cloud version",
"open jibo cloud version",
"openjibo cloud version",
"what version is the cloud",
"what s the cloud version",
"what's the cloud version"))
{
return "cloud_version";
}
if (TryResolveRadioGenre(loweredTranscript) is not null)
{
return "radio_genre";
}
if (MatchesAny(loweredTranscript, "open the clock", "open clock", "show the clock", "show clock"))
{
return "clock_open";
}
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 (TryParseAlarmValue(loweredTranscript) is not null)
{
return "alarm_value";
}
if (TryParseTimerValue(loweredTranscript) is not null)
{
return "timer_value";
}
if (IsAlarmRequest(loweredTranscript))
{
return "alarm_clarify";
}
if (IsTimerRequest(loweredTranscript))
{
return "timer_clarify";
}
if (MatchesAny(loweredTranscript, "open the radio", "play the radio", "turn on the radio", "radio"))
{
return "radio";
}
if (MatchesAny(
loweredTranscript,
"snap a picture",
"take a picture",
"take a photo",
"snap a photo"))
{
return "snapshot";
}
if (MatchesAny(
loweredTranscript,
"photo booth",
"photobooth",
"open photobooth",
"start photobooth"))
{
return "photobooth";
}
if (MatchesAny(
loweredTranscript,
"photo gallery",
"open the gallery",
"open photo gallery",
"show my photos",
"open my photos",
"gallery"))
{
return "photo_gallery";
}
if (MatchesAny(loweredTranscript, "dance", "boogie"))
{
return "dance";
}
if (MatchesAny(loweredTranscript, "twerk"))
{
return "twerk";
}
if (MatchesAny(loweredTranscript, "surprise", "surprise me", "show me something fun"))
{
return "surprise";
}
if (MatchesAny(loweredTranscript, "personal report", "my report", "daily report", "my update"))
{
return "personal_report";
}
if (MatchesAny(loweredTranscript, "weather", "forecast", "weather report", "is it raining"))
{
return "weather";
}
if (MatchesAny(loweredTranscript, "calendar", "schedule", "what's on my calendar", "what is on my calendar"))
{
return "calendar";
}
if (MatchesAny(loweredTranscript, "commute", "traffic", "drive to work", "how long to work"))
{
return "commute";
}
if (MatchesAny(loweredTranscript, "news", "headlines", "news update", "tell me the news"))
{
return "news";
}
if (MatchesAny(loweredTranscript, "how are you", "what's up", "what s up", "what up"))
{
return "how_are_you";
}
if (MatchesAny(loweredTranscript, "hello", "hi", "hey"))
{
return "hello";
}
if (isYesNoTurn && MatchesAny(loweredTranscript, "yes", "yeah", "yup", "sure", "uh huh"))
{
return "yes";
}
if (isYesNoTurn && MatchesAny(loweredTranscript, "no", "nope", "nah"))
{
return "no";
}
if (MatchesAny(loweredTranscript, "what time is it", "current time", "the time", "time is it") ||
loweredTranscript.Contains("time", StringComparison.Ordinal))
{
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))
{
return "date";
}
return "chat";
}
private static JiboInteractionDecision BuildWordOfTheDayLaunchDecision()
{
return new JiboInteractionDecision(
"word_of_the_day",
"Starting word of the day.",
"@be/word-of-the-day",
SkillPayload: new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["domain"] = "word-of-the-day",
["skillId"] = "@be/word-of-the-day"
});
}
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 BuildPhotoGalleryLaunchDecision()
{
return new JiboInteractionDecision(
"photo_gallery",
"Opening the photo gallery.",
"@be/gallery",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "@be/gallery",
["localIntent"] = "menu"
});
}
private static JiboInteractionDecision BuildPhotoCreateDecision(string intentName, string replyText, string localIntent)
{
return new JiboInteractionDecision(
intentName,
replyText,
"@be/create",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "@be/create",
["localIntent"] = localIntent
});
}
private static JiboInteractionDecision BuildClockLaunchDecision(string intentName, string domain, string clockIntent, string replyText)
{
return new JiboInteractionDecision(
intentName,
replyText,
"@be/clock",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "@be/clock",
["domain"] = domain,
["clockIntent"] = clockIntent
});
}
private static JiboInteractionDecision BuildClockLaunchDecision(string domain, string replyText)
{
return BuildClockLaunchDecision($"{domain}_menu", domain, "menu", replyText);
}
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";
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(
IReadOnlyDictionary<string, string> clientEntities,
string transcript,
IReadOnlyList<string> listenAsrHints)
{
var guess = ResolveWordOfTheDayGuess(clientEntities, transcript, listenAsrHints);
var reply = string.IsNullOrWhiteSpace(guess)
? "I heard your word of the day guess."
: $"I heard {guess}.";
return new JiboInteractionDecision(
"word_of_the_day_guess",
reply,
"@be/word-of-the-day",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["guess"] = guess,
["skillId"] = "@be/word-of-the-day",
["cloudResponseMode"] = "completion_only"
});
}
private static string ResolveWordOfTheDayGuess(
IReadOnlyDictionary<string, string> clientEntities,
string transcript,
IReadOnlyList<string> listenAsrHints)
{
if (clientEntities.TryGetValue("guess", out var guessValue) &&
!string.IsNullOrWhiteSpace(guessValue))
{
return guessValue;
}
var loweredTranscript = NormalizeGuessToken(transcript);
var hintIndex = loweredTranscript switch
{
"1" or "one" or "first" => 0,
"2" or "two" or "second" => 1,
"3" or "three" or "third" => 2,
_ => -1
};
if (hintIndex >= 0 && hintIndex < listenAsrHints.Count)
{
return listenAsrHints[hintIndex];
}
var fuzzyHintMatch = FindClosestHint(loweredTranscript, listenAsrHints);
if (!string.IsNullOrWhiteSpace(fuzzyHintMatch))
{
return fuzzyHintMatch;
}
return transcript;
}
private static bool IsYesNoTurn(TurnContext turn)
{
return ReadRules(turn, "listenRules")
.Concat(ReadRules(turn, "clientRules"))
.Concat(ReadRules(turn, "listenAsrHints"))
.Any(static rule =>
string.Equals(rule, "$YESNO", StringComparison.OrdinalIgnoreCase) ||
string.Equals(rule, "create/is_it_a_keeper", StringComparison.OrdinalIgnoreCase) ||
string.Equals(rule, "settings/download_now_later", StringComparison.OrdinalIgnoreCase) ||
string.Equals(rule, "surprises-date/offer_date_fact", StringComparison.OrdinalIgnoreCase) ||
string.Equals(rule, "surprises-ota/want_to_download_now", StringComparison.OrdinalIgnoreCase));
}
private static string? FindClosestHint(string normalizedTranscript, IReadOnlyList<string> hints)
{
if (string.IsNullOrWhiteSpace(normalizedTranscript))
{
return null;
}
string? bestHint = null;
var bestDistance = int.MaxValue;
foreach (var hint in hints)
{
if (string.IsNullOrWhiteSpace(hint))
{
continue;
}
var normalizedHint = NormalizeGuessToken(hint);
if (string.IsNullOrWhiteSpace(normalizedHint))
{
continue;
}
if (string.Equals(normalizedTranscript, normalizedHint, StringComparison.Ordinal))
{
return hint;
}
var distance = ComputeEditDistance(normalizedTranscript, normalizedHint);
if (distance < bestDistance)
{
bestDistance = distance;
bestHint = hint;
}
}
return bestDistance <= 2 ? bestHint : null;
}
private static string NormalizeGuessToken(string value)
{
return value.Trim().TrimEnd('.', '!', '?', ',').ToLowerInvariant();
}
private static int ComputeEditDistance(string left, string right)
{
var previous = new int[right.Length + 1];
var current = new int[right.Length + 1];
for (var column = 0; column <= right.Length; column += 1)
{
previous[column] = column;
}
for (var row = 1; row <= left.Length; row += 1)
{
current[0] = row;
for (var column = 1; column <= right.Length; column += 1)
{
var substitutionCost = left[row - 1] == right[column - 1] ? 0 : 1;
current[column] = Math.Min(
Math.Min(current[column - 1] + 1, previous[column] + 1),
previous[column - 1] + substitutionCost);
}
(previous, current) = (current, previous);
}
return previous[right.Length];
}
private static IEnumerable<string> ReadRules(TurnContext turn, string key)
{
if (!turn.Attributes.TryGetValue(key, out var value) || value is null)
{
return [];
}
return value switch
{
IReadOnlyList<string> typed => typed,
IEnumerable<string> strings => strings,
JsonElement { ValueKind: JsonValueKind.Array } json => json.EnumerateArray()
.Where(static item => item.ValueKind == JsonValueKind.String)
.Select(static item => item.GetString() ?? string.Empty),
_ => []
};
}
private static IReadOnlyDictionary<string, string> ReadEntities(TurnContext turn)
{
if (!turn.Attributes.TryGetValue("clientEntities", out var value) || value is null)
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
return value switch
{
JsonElement { ValueKind: JsonValueKind.Object } json => json.EnumerateObject()
.Where(static property => property.Value.ValueKind == JsonValueKind.String)
.ToDictionary(property => property.Name, property => property.Value.GetString() ?? string.Empty, StringComparer.OrdinalIgnoreCase),
IReadOnlyDictionary<string, string> typed => typed,
IDictionary<string, object?> dictionary => dictionary
.Where(pair => pair.Value is not null)
.ToDictionary(pair => pair.Key, pair => pair.Value?.ToString() ?? string.Empty, StringComparer.OrdinalIgnoreCase),
_ => new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
};
}
private static bool MatchesAny(string loweredTranscript, params string[] candidates)
{
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 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 compactMatch = CompactAlarmPattern.Match(loweredTranscript);
if (compactMatch.Success)
{
var compact = compactMatch.Groups["compact"].Value;
if (int.TryParse(compact, out var compactValue))
{
var compactHour = compact.Length switch
{
3 => compactValue / 100,
4 => compactValue / 100,
_ => -1
};
var compactMinute = compact.Length switch
{
3 => compactValue % 100,
4 => compactValue % 100,
_ => -1
};
if (compactHour is >= 1 and <= 12 && compactMinute is >= 0 and <= 59)
{
var compactAmPm = ResolveAmPm(compactMatch.Groups["ampm"].Value);
return new ClockAlarmValue($"{compactHour}:{compactMinute:00}", compactAmPm);
}
}
}
var match = SplitAlarmPattern.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 = ResolveAmPm(match.Groups["ampm"].Value);
return new ClockAlarmValue($"{hour}:{minute:00}", ampm);
}
private static string ResolveAmPm(string token)
{
return token.StartsWith("p", StringComparison.OrdinalIgnoreCase) ? "pm" : "am";
}
private static bool IsTimerRequest(string loweredTranscript)
{
return MatchesAny(
loweredTranscript,
"set a timer",
"set timer",
"start a timer",
"start timer",
"timer for");
}
private static bool IsAlarmRequest(string loweredTranscript)
{
return MatchesAny(
loweredTranscript,
"set an alarm",
"set alarm",
"wake me up",
"alarm for");
}
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 SplitAlarmPattern = 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 Regex CompactAlarmPattern = new(
@"\b(?<compact>\d{3,4})\s*(?<ampm>a\.?m\.?|p\.?m\.?)?\b",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
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(
string IntentName,
string ReplyText,
string? SkillName = null,
IDictionary<string, object?>? SkillPayload = null);