From c64f4b91a8c26da1ef1514cea61d9e4da8243893 Mon Sep 17 00:00:00 2001 From: Jacob Dubin Date: Wed, 6 May 2026 16:19:45 -0500 Subject: [PATCH] Import Pegasus emotion phrases into chitchat routing --- .../Services/ChitchatStateMachine.cs | 264 ++++++++++++++++-- .../WebSockets/JiboInteractionServiceTests.cs | 66 +++++ 2 files changed, 311 insertions(+), 19 deletions(-) diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ChitchatStateMachine.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ChitchatStateMachine.cs index ffa5c94..e2908e1 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ChitchatStateMachine.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ChitchatStateMachine.cs @@ -1,4 +1,5 @@ using Jibo.Cloud.Application.Abstractions; +using System.Text.RegularExpressions; namespace Jibo.Cloud.Application.Services; @@ -22,21 +23,110 @@ internal static class ChitchatStateMachine [ "how are you feeling", "how do you feel", + "what are you feeling", "what mood are you in", "what is your mood", "what's your mood", - "are you happy", - "are you sad", - "are you excited", - "do you have emotions" + "do you have emotions", + "how angry are you", + "how jealous are you", + "how sad are you", + "how upset do you feel", + "how bored are you right now" ]; - private static readonly (string Emotion, string[] Phrases)[] EmotionCommandPhrases = + // Pegasus parser-derived query anchors from descriptor/emotion intent families. + private static readonly string[] EmotionQueryPrefixes = [ - ("happy", ["smile", "be happy", "look happy", "cheer up"]), - ("sad", ["be sad", "look sad"]), - ("excited", ["be excited", "get excited", "act excited"]), - ("calm", ["be calm", "relax"]) + "are you ", + "are you feeling ", + "are you able to feel ", + "are you able to get ", + "are you ever ", + "can you be ", + "do you feel ", + "do you ever feel ", + "do you ever get ", + "do you get ", + "does ", + "would ", + "how ", + "describe how " + ]; + + // Pegasus parser-derived specific-emotion assertion forms. + private static readonly string[] EmotionAssertionPrefixes = + [ + "you are ", + "you re ", + "you are acting ", + "you seem ", + "you look ", + "i think you are ", + "i think you re ", + "i feel like you are ", + "i feel like you re ", + "in my opinion you are ", + "in my opinion you re " + ]; + + private static readonly string[] EmotionCommandPositivePrefixes = + [ + "be ", + "be a little ", + "be a bit ", + "be very ", + "be more ", + "you should be ", + "you should try to be ", + "try to be ", + "look ", + "act " + ]; + + private static readonly string[] EmotionCommandNegativePrefixes = + [ + "do not be ", + "don t be ", + "dont be ", + "try not to be ", + "you should not be ", + "you shouldn t be " + ]; + + private static readonly (string Phrase, string Emotion)[] DirectEmotionCommandPhrases = + [ + ("smile", "happy"), + ("look happy", "happy"), + ("cheer up", "happy"), + ("be happy", "happy"), + ("be excited", "excited"), + ("get excited", "excited"), + ("act excited", "excited"), + ("be sad", "sad"), + ("look sad", "sad"), + ("be calm", "calm"), + ("calm down", "calm"), + ("relax", "calm") + ]; + + // Derived from Pegasus parser Emotion entity and utterance sets. + private static readonly (string Emotion, string[] Synonyms)[] PegasusEmotionSynonyms = + [ + ("afraid", ["afraid", "fearful", "frightened", "scared", "terrified", "spooked", "freak out", "freaked out"]), + ("amused", ["amused", "entertained", "tickled", "tickled pink"]), + ("angry", ["angry", "mad", "furious", "enraged", "irate", "incensed", "cross"]), + ("annoyed", ["annoyed", "aggravated", "bothered", "irritated", "grumpy", "nettled", "vexed", "bored"]), + ("anxious", ["anxious", "nervous", "worried", "tense", "on edge", "jittery", "restless", "concerned"]), + ("confident", ["confident", "assured", "secure", "self assured", "self confident"]), + ("confused", ["confused", "at a loss", "perplexed", "puzzled", "stumped", "uncertain", "unsure"]), + ("embarrassed", ["embarrassed", "ashamed", "flustered", "self conscious", "sheepish"]), + ("excited", ["excited", "jazzed", "psyched", "pumped"]), + ("happy", ["happy", "cheerful", "jovial", "pleased", "joyful", "content", "thrilled"]), + ("jealous", ["jealous", "envious", "covetous"]), + ("lonely", ["lonely", "alone", "lonesome"]), + ("proud", ["proud", "honored"]), + ("sad", ["sad", "upset", "unhappy", "depressed", "somber", "downcast", "gloomy", "miserable", "bummed", "heartbroken", "troubled"]) ]; private static readonly string[] EmotionCommandReplies = @@ -46,6 +136,16 @@ internal static class ChitchatStateMachine "Okay, mood change activated." ]; + private static readonly Regex PhrasePunctuationPattern = new( + @"[^\w\s]", + RegexOptions.CultureInvariant | RegexOptions.Compiled); + + private static readonly Regex PhraseWhitespacePattern = new( + @"\s+", + RegexOptions.CultureInvariant | RegexOptions.Compiled); + + private static readonly (string Phrase, string Emotion)[] EmotionSynonymMappings = BuildEmotionSynonymMappings(); + public static JiboInteractionDecision? TryBuildDecision( string semanticIntent, string transcript, @@ -54,6 +154,7 @@ internal static class ChitchatStateMachine IJiboRandomizer randomizer, Func buildErrorResponse) { + var normalizedLoweredTranscript = NormalizeForPhraseMatching(loweredTranscript); switch (semanticIntent) { case "hello": @@ -69,14 +170,14 @@ internal static class ChitchatStateMachine "how_are_you", randomizer.Choose(catalog.HowAreYouReplies)); case "chat": - if (IsEmotionQuery(loweredTranscript)) + if (IsEmotionQuery(normalizedLoweredTranscript)) { return BuildEmotionQueryDecision( "emotion_query", randomizer.Choose(catalog.HowAreYouReplies)); } - if (TryResolveEmotionCommand(loweredTranscript, out var emotion)) + if (TryResolveEmotionCommand(normalizedLoweredTranscript, out var emotion)) { return BuildEmotionCommandDecision(randomizer, emotion!); } @@ -90,8 +191,9 @@ internal static class ChitchatStateMachine } } - public static bool IsLikelyEmotionUtterance(string normalizedLoweredTranscript) + public static bool IsLikelyEmotionUtterance(string transcript) { + var normalizedLoweredTranscript = NormalizeForPhraseMatching(transcript); return IsEmotionQuery(normalizedLoweredTranscript) || TryResolveEmotionCommand(normalizedLoweredTranscript, out _); } @@ -176,15 +278,71 @@ internal static class ChitchatStateMachine private static bool IsEmotionQuery(string loweredTranscript) { - return ContainsAnyPhrase(loweredTranscript, EmotionQueryPhrases); + if (ContainsAnyPhrase(loweredTranscript, EmotionQueryPhrases)) + { + return true; + } + + if (!TryResolveEmotionFromText(loweredTranscript, out _)) + { + return false; + } + + return StartsWithAnyPhrase(loweredTranscript, EmotionQueryPrefixes) || + StartsWithAnyPhrase(loweredTranscript, EmotionAssertionPrefixes); } private static bool TryResolveEmotionCommand(string loweredTranscript, out string? emotion) { emotion = null; - foreach (var mapping in EmotionCommandPhrases) + + foreach (var mapping in DirectEmotionCommandPhrases) { - if (!ContainsAnyPhrase(loweredTranscript, mapping.Phrases)) + if (!ContainsPhrase(loweredTranscript, mapping.Phrase)) + { + continue; + } + + emotion = mapping.Emotion; + return true; + } + + var isNegativeCommand = StartsWithAnyPhrase(loweredTranscript, EmotionCommandNegativePrefixes); + var isPositiveCommand = !isNegativeCommand && StartsWithAnyPhrase(loweredTranscript, EmotionCommandPositivePrefixes); + if (!isNegativeCommand && !isPositiveCommand) + { + return false; + } + + if (!TryResolveEmotionFromText(loweredTranscript, out var canonicalEmotion) || + string.IsNullOrWhiteSpace(canonicalEmotion)) + { + return false; + } + + emotion = isNegativeCommand + ? "calm" + : MapCanonicalEmotionToRuntimeEmotion(canonicalEmotion); + return true; + } + + private static string MapCanonicalEmotionToRuntimeEmotion(string canonicalEmotion) + { + return canonicalEmotion switch + { + "happy" or "amused" or "excited" or "confident" or "proud" => "happy", + "sad" or "lonely" or "afraid" or "anxious" or "embarrassed" or "confused" => "sad", + "angry" or "annoyed" or "jealous" => "calm", + _ => "calm" + }; + } + + private static bool TryResolveEmotionFromText(string loweredTranscript, out string? emotion) + { + emotion = null; + foreach (var mapping in EmotionSynonymMappings) + { + if (!ContainsPhrase(loweredTranscript, mapping.Phrase)) { continue; } @@ -200,10 +358,7 @@ internal static class ChitchatStateMachine { foreach (var phrase in phrases) { - if (string.Equals(loweredTranscript, phrase, StringComparison.Ordinal) || - loweredTranscript.StartsWith($"{phrase} ", StringComparison.Ordinal) || - loweredTranscript.Contains($" {phrase} ", StringComparison.Ordinal) || - loweredTranscript.EndsWith($" {phrase}", StringComparison.Ordinal)) + if (ContainsPhrase(loweredTranscript, phrase)) { return true; } @@ -211,4 +366,75 @@ internal static class ChitchatStateMachine return false; } + + private static bool StartsWithAnyPhrase(string loweredTranscript, IEnumerable phrases) + { + foreach (var phrase in phrases) + { + var normalizedPhrase = NormalizeForPhraseMatching(phrase); + if (string.IsNullOrWhiteSpace(normalizedPhrase)) + { + continue; + } + + if (string.Equals(loweredTranscript, normalizedPhrase, StringComparison.Ordinal) || + loweredTranscript.StartsWith($"{normalizedPhrase} ", StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private static bool ContainsPhrase(string loweredTranscript, string phrase) + { + var normalizedPhrase = NormalizeForPhraseMatching(phrase); + if (string.IsNullOrWhiteSpace(normalizedPhrase) || + string.IsNullOrWhiteSpace(loweredTranscript)) + { + return false; + } + + return string.Equals(loweredTranscript, normalizedPhrase, StringComparison.Ordinal) || + loweredTranscript.StartsWith($"{normalizedPhrase} ", StringComparison.Ordinal) || + loweredTranscript.Contains($" {normalizedPhrase} ", StringComparison.Ordinal) || + loweredTranscript.EndsWith($" {normalizedPhrase}", StringComparison.Ordinal); + } + + private static string NormalizeForPhraseMatching(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var lowered = value.ToLowerInvariant(); + var withoutPunctuation = PhrasePunctuationPattern.Replace(lowered, " "); + return PhraseWhitespacePattern.Replace(withoutPunctuation, " ").Trim(); + } + + private static (string Phrase, string Emotion)[] BuildEmotionSynonymMappings() + { + var seen = new HashSet(StringComparer.Ordinal); + var mappings = new List<(string Phrase, string Emotion)>(); + + foreach (var emotionMapping in PegasusEmotionSynonyms) + { + foreach (var synonym in emotionMapping.Synonyms) + { + var normalizedSynonym = NormalizeForPhraseMatching(synonym); + if (string.IsNullOrWhiteSpace(normalizedSynonym) || + !seen.Add(normalizedSynonym)) + { + continue; + } + + mappings.Add((normalizedSynonym, emotionMapping.Emotion)); + } + } + + mappings.Sort(static (left, right) => right.Phrase.Length.CompareTo(left.Phrase.Length)); + return [.. mappings]; + } } diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs index e637dd4..a9137fe 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs @@ -220,6 +220,72 @@ public sealed class JiboInteractionServiceTests Assert.Equal("ErrorResponse", decision.ContextUpdates![ChitchatRouteKey]); } + [Fact] + public async Task BuildDecisionAsync_HowAngryAreYou_RoutesThroughPegasusEmotionQueryPhrase() + { + var service = CreateService(); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "how angry are you", + NormalizedTranscript = "how angry are you" + }); + + Assert.Equal("emotion_query", decision.IntentName); + Assert.NotNull(decision.ContextUpdates); + Assert.Equal("EmotionQuery", decision.ContextUpdates![ChitchatRouteKey]); + } + + [Fact] + public async Task BuildDecisionAsync_YouSeemSad_RoutesThroughPegasusEmotionAssertionPhrase() + { + var service = CreateService(); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "you seem sad", + NormalizedTranscript = "you seem sad" + }); + + Assert.Equal("emotion_query", decision.IntentName); + Assert.NotNull(decision.ContextUpdates); + Assert.Equal("EmotionQuery", decision.ContextUpdates![ChitchatRouteKey]); + } + + [Fact] + public async Task BuildDecisionAsync_YouShouldTryToBeHappy_RoutesThroughPegasusEmotionCommandPhrase() + { + var service = CreateService(); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "you should try to be happy", + NormalizedTranscript = "you should try to be happy" + }); + + Assert.Equal("emotion_command", decision.IntentName); + Assert.NotNull(decision.ContextUpdates); + Assert.Equal("EmotionCommand", decision.ContextUpdates![ChitchatRouteKey]); + Assert.Equal("happy", decision.ContextUpdates[ChitchatEmotionKey]); + } + + [Fact] + public async Task BuildDecisionAsync_DontBeAngry_RoutesThroughPegasusNegativeEmotionCommandPhrase() + { + var service = CreateService(); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "don't be angry", + NormalizedTranscript = "don't be angry" + }); + + Assert.Equal("emotion_command", decision.IntentName); + Assert.NotNull(decision.ContextUpdates); + Assert.Equal("EmotionCommand", decision.ContextUpdates![ChitchatRouteKey]); + Assert.Equal("calm", decision.ContextUpdates[ChitchatEmotionKey]); + } + [Fact] public async Task BuildDecisionAsync_BirthdayMemory_SetThenRecallWithinTenant() {