From ab47ad7a2d8c642fd30f3480c85d17699de0ac1f Mon Sep 17 00:00:00 2001 From: Jacob Dubin Date: Mon, 20 Apr 2026 22:25:08 -0500 Subject: [PATCH] jibo clock family skills and feature backlog updates --- OpenJibo/docs/development-plan.md | 7 + OpenJibo/docs/feature-backlog.md | 20 +- .../Services/DemoConversationBroker.cs | 5 + .../Services/JiboInteractionService.cs | 225 ++++++++++++++++++ .../ResponsePlanToSocketMessagesMapper.cs | 84 ++++++- .../WebSocketTurnFinalizationService.cs | 5 + .../WebSockets/JiboInteractionServiceTests.cs | 56 +++++ .../WebSockets/JiboWebSocketServiceTests.cs | 71 ++++++ 8 files changed, 467 insertions(+), 6 deletions(-) diff --git a/OpenJibo/docs/development-plan.md b/OpenJibo/docs/development-plan.md index bd2ffb4..e6a22af 100644 --- a/OpenJibo/docs/development-plan.md +++ b/OpenJibo/docs/development-plan.md @@ -177,6 +177,13 @@ Latest news discovery findings: - The first OpenJibo news pass should therefore use a real cloud-skill shape, not a generic placeholder chat reply. - For now, the content can stay synthetic while the protocol is grounded: `match.cloudSkill = "news"` plus a supported `SLIM` announcement response is enough to validate the robot path before provider-backed headlines arrive later. +Latest clock discovery findings: + +- `@be/clock` is a real local skill with `clock`, `timer`, and `alarm` domains. +- Menu launches use `intent = "menu"` with `entities.domain` set to the target sub-area. +- Direct timer and alarm actions use `timerValue` and `alarmValue` utterances, not a generic chat path. +- A practical first OpenJibo slice is therefore: keep custom spoken time/date answers for now, but route `open clock`, `open timer`, `open alarm`, `set a timer ...`, and `set an alarm ...` through stock-shaped local `@be/clock` handoffs. + ## Speech, Animation, And ESML The current joke flow is only a small foothold into Jibo expressiveness. diff --git a/OpenJibo/docs/feature-backlog.md b/OpenJibo/docs/feature-backlog.md index b9f4a17..ed2737d 100644 --- a/OpenJibo/docs/feature-backlog.md +++ b/OpenJibo/docs/feature-backlog.md @@ -120,15 +120,20 @@ Parallel tags: ### 6. Clock Family Audit -- Status: `ready` +- Status: `in_progress` - Tags: `protocol` - Why now: clock, date, timer, and alarm menu hooks are already visible in captures and the robot repo has a real `@be/clock` skill. - Current evidence: - [protocol-inventory.md](C:/Projects/JiboExperiments/OpenJibo/docs/protocol-inventory.md) already tracks menu intents for `askForTime`, `askForDate`, `timerValue`, and `alarmValue` - `@be/clock` exists in the robot skill inventory + - `JiboOs` shows `@be/clock` branches on `entities.domain = clock | timer | alarm`, uses `intent = menu` for menu launches, and accepts direct `timerValue` / `alarmValue` utterances with structured entities - Implementation notes: - compare our custom time/date path against actual menu payloads - decide whether timer and alarm should stay robot-local with cloud acknowledgement, or whether cloud needs to shape the launch and follow-up turns +- Progress so far: + - voice `open clock`, `open timer`, and `open alarm` now synthesize stock-shaped local `@be/clock` launches + - voice `set a timer for five minutes` and `set an alarm for 7:30 am` now emit direct `timerValue` / `alarmValue` payloads with the domain and value entities the local skill expects + - time/date remain on the existing custom cloud reply path for now - Exit criteria: - time/date behavior stays correct - timer and alarm launch or set correctly from both menu and voice where applicable @@ -241,10 +246,19 @@ Parallel tags: - Questions to answer: - should calendar and commute be independent feature paths or sections inside personal report - what minimum provider data shape lets Jibo present these naturally + +### 14. Stop Command +- Status: `ready` +- Tags: `protocol` +- Why later: Jibo can be interrupted by any command, but it would be nice to have a dedicated "stop" type of command. +- Current evidence: + - There is an Idle skill or subskill under @be so I think we can utilize it, but I am not sure if that was the default behavior. +- Questions to answer: + - Can we find in the original source evidence for this skill or stop word phrase? ## Support Tracks -### 14. Hosted Capture And Storage Plan +### 15. Hosted Capture And Storage Plan - Status: `ready` - Tags: `docs` @@ -253,7 +267,7 @@ Parallel tags: - define a clean boundary between local capture sinks and hosted archival/export - document how group testers should submit sessions without touching repo paths directly -### 15. STT Upgrade And Noise Screening +### 16. STT Upgrade And Noise Screening - Status: `ready` - Tags: `stt` diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/DemoConversationBroker.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/DemoConversationBroker.cs index f17ac73..e0baaab 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/DemoConversationBroker.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/DemoConversationBroker.cs @@ -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 }; diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs index fa15eac..590f23b 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs @@ -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(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(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(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(?\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(?\d{1,2}|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)(?:[:\s](?\d{2}))?\s*(?a\.?m\.?|p\.?m\.?)\b", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + private static readonly (string Phrase, string Station)[] RadioGenreAliases = [ ("country music", "Country"), diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ResponsePlanToSocketMessagesMapper.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ResponsePlanToSocketMessagesMapper.cs index fdc9607..0200578 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ResponsePlanToSocketMessagesMapper.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ResponsePlanToSocketMessagesMapper.cs @@ -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() + : isClockSkillLaunch + ? string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase) ? rules : Array.Empty() : 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(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 diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/WebSocketTurnFinalizationService.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/WebSocketTurnFinalizationService.cs index 0f95443..b81ecd3 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/WebSocketTurnFinalizationService.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/WebSocketTurnFinalizationService.cs @@ -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 diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs index 8e519a3..72eeb98 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs @@ -181,6 +181,62 @@ public sealed class JiboInteractionServiceTests Assert.Equal("Country", decision.SkillPayload!["station"]); } + [Fact] + public async Task BuildDecisionAsync_OpenTimer_MapsToLocalClockTimerMenu() + { + var service = CreateService(); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "open timer", + NormalizedTranscript = "open timer" + }); + + Assert.Equal("timer_menu", decision.IntentName); + Assert.Equal("@be/clock", decision.SkillName); + Assert.Equal("timer", decision.SkillPayload!["domain"]); + Assert.Equal("menu", decision.SkillPayload["clockIntent"]); + } + + [Fact] + public async Task BuildDecisionAsync_SetTimerForFiveMinutes_MapsToTimerValue() + { + var service = CreateService(); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "set a timer for five minutes", + NormalizedTranscript = "set a timer for five minutes" + }); + + Assert.Equal("timer_value", decision.IntentName); + Assert.Equal("@be/clock", decision.SkillName); + Assert.Equal("timer", decision.SkillPayload!["domain"]); + Assert.Equal("timerValue", decision.SkillPayload["clockIntent"]); + Assert.Equal("0", decision.SkillPayload["hours"]); + Assert.Equal("5", decision.SkillPayload["minutes"]); + Assert.Equal("null", decision.SkillPayload["seconds"]); + } + + [Fact] + public async Task BuildDecisionAsync_SetAlarmForSevenThirtyAm_MapsToAlarmValue() + { + var service = CreateService(); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "set an alarm for 7:30 am", + NormalizedTranscript = "set an alarm for 7:30 am" + }); + + Assert.Equal("alarm_value", decision.IntentName); + Assert.Equal("@be/clock", decision.SkillName); + Assert.Equal("alarm", decision.SkillPayload!["domain"]); + Assert.Equal("alarmValue", decision.SkillPayload["clockIntent"]); + Assert.Equal("7:30", decision.SkillPayload["time"]); + Assert.Equal("am", decision.SkillPayload["ampm"]); + } + [Fact] public async Task BuildDecisionAsync_TellMeTheNews_UsesNimbusCloudSkillPath() { diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs index da566b8..82bfb54 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs @@ -343,6 +343,77 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("clock/clock_menu", listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); } + [Fact] + public async Task ClientAsr_SetTimerForFiveMinutes_RedirectsIntoClockSkillWithTimerEntities() + { + await _service.HandleMessageAsync(new WebSocketMessageEnvelope + { + HostName = "neo-hub.jibo.com", + Path = "/listen", + Kind = "neo-hub-listen", + Token = "hub-clock-timer-token", + Text = """{"type":"LISTEN","transID":"trans-clock-timer","data":{"rules":["globals/global_commands_launch"]}}""" + }); + + var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope + { + HostName = "neo-hub.jibo.com", + Path = "/listen", + Kind = "neo-hub-listen", + Token = "hub-clock-timer-token", + Text = """{"type":"CLIENT_ASR","transID":"trans-clock-timer","data":{"text":"set a timer for five minutes"}}""" + }); + + 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("timerValue", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("@be/clock", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString()); + Assert.Equal("timer", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("domain").GetString()); + Assert.Equal("0", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("hours").GetString()); + Assert.Equal("5", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("minutes").GetString()); + Assert.Equal("null", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("seconds").GetString()); + + using var redirectPayload = JsonDocument.Parse(replies[2].Text!); + Assert.Equal("@be/clock", redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); + Assert.Equal("timerValue", redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + } + + [Fact] + public async Task ClientAsr_SetAlarmForSevenThirtyAm_RedirectsIntoClockSkillWithAlarmEntities() + { + await _service.HandleMessageAsync(new WebSocketMessageEnvelope + { + HostName = "neo-hub.jibo.com", + Path = "/listen", + Kind = "neo-hub-listen", + Token = "hub-clock-alarm-token", + Text = """{"type":"LISTEN","transID":"trans-clock-alarm","data":{"rules":["globals/global_commands_launch"]}}""" + }); + + var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope + { + HostName = "neo-hub.jibo.com", + Path = "/listen", + Kind = "neo-hub-listen", + Token = "hub-clock-alarm-token", + Text = """{"type":"CLIENT_ASR","transID":"trans-clock-alarm","data":{"text":"set an alarm for 7:30 am"}}""" + }); + + Assert.Equal(4, replies.Count); + + using var listenPayload = JsonDocument.Parse(replies[0].Text!); + Assert.Equal("alarmValue", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("@be/clock", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString()); + Assert.Equal("alarm", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("domain").GetString()); + Assert.Equal("7:30", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("time").GetString()); + Assert.Equal("am", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("ampm").GetString()); + } + [Fact] public async Task ClientAsr_YesNoCreateFlow_PreservesCreateRuleAndDomain() {