Import Pegasus emotion phrases into chitchat routing

This commit is contained in:
Jacob Dubin
2026-05-06 16:19:45 -05:00
parent 7d31c3390c
commit c64f4b91a8
2 changed files with 311 additions and 19 deletions

View File

@@ -1,4 +1,5 @@
using Jibo.Cloud.Application.Abstractions; using Jibo.Cloud.Application.Abstractions;
using System.Text.RegularExpressions;
namespace Jibo.Cloud.Application.Services; namespace Jibo.Cloud.Application.Services;
@@ -22,21 +23,110 @@ internal static class ChitchatStateMachine
[ [
"how are you feeling", "how are you feeling",
"how do you feel", "how do you feel",
"what are you feeling",
"what mood are you in", "what mood are you in",
"what is your mood", "what is your mood",
"what's your mood", "what's your mood",
"are you happy", "do you have emotions",
"are you sad", "how angry are you",
"are you excited", "how jealous are you",
"do you have emotions" "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"]), "are you ",
("sad", ["be sad", "look sad"]), "are you feeling ",
("excited", ["be excited", "get excited", "act excited"]), "are you able to feel ",
("calm", ["be calm", "relax"]) "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 = private static readonly string[] EmotionCommandReplies =
@@ -46,6 +136,16 @@ internal static class ChitchatStateMachine
"Okay, mood change activated." "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( public static JiboInteractionDecision? TryBuildDecision(
string semanticIntent, string semanticIntent,
string transcript, string transcript,
@@ -54,6 +154,7 @@ internal static class ChitchatStateMachine
IJiboRandomizer randomizer, IJiboRandomizer randomizer,
Func<string> buildErrorResponse) Func<string> buildErrorResponse)
{ {
var normalizedLoweredTranscript = NormalizeForPhraseMatching(loweredTranscript);
switch (semanticIntent) switch (semanticIntent)
{ {
case "hello": case "hello":
@@ -69,14 +170,14 @@ internal static class ChitchatStateMachine
"how_are_you", "how_are_you",
randomizer.Choose(catalog.HowAreYouReplies)); randomizer.Choose(catalog.HowAreYouReplies));
case "chat": case "chat":
if (IsEmotionQuery(loweredTranscript)) if (IsEmotionQuery(normalizedLoweredTranscript))
{ {
return BuildEmotionQueryDecision( return BuildEmotionQueryDecision(
"emotion_query", "emotion_query",
randomizer.Choose(catalog.HowAreYouReplies)); randomizer.Choose(catalog.HowAreYouReplies));
} }
if (TryResolveEmotionCommand(loweredTranscript, out var emotion)) if (TryResolveEmotionCommand(normalizedLoweredTranscript, out var emotion))
{ {
return BuildEmotionCommandDecision(randomizer, 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) || return IsEmotionQuery(normalizedLoweredTranscript) ||
TryResolveEmotionCommand(normalizedLoweredTranscript, out _); TryResolveEmotionCommand(normalizedLoweredTranscript, out _);
} }
@@ -176,15 +278,71 @@ internal static class ChitchatStateMachine
private static bool IsEmotionQuery(string loweredTranscript) 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) private static bool TryResolveEmotionCommand(string loweredTranscript, out string? emotion)
{ {
emotion = null; 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; continue;
} }
@@ -200,10 +358,7 @@ internal static class ChitchatStateMachine
{ {
foreach (var phrase in phrases) foreach (var phrase in phrases)
{ {
if (string.Equals(loweredTranscript, phrase, StringComparison.Ordinal) || if (ContainsPhrase(loweredTranscript, phrase))
loweredTranscript.StartsWith($"{phrase} ", StringComparison.Ordinal) ||
loweredTranscript.Contains($" {phrase} ", StringComparison.Ordinal) ||
loweredTranscript.EndsWith($" {phrase}", StringComparison.Ordinal))
{ {
return true; return true;
} }
@@ -211,4 +366,75 @@ internal static class ChitchatStateMachine
return false; return false;
} }
private static bool StartsWithAnyPhrase(string loweredTranscript, IEnumerable<string> 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<string>(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];
}
} }

View File

@@ -220,6 +220,72 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("ErrorResponse", decision.ContextUpdates![ChitchatRouteKey]); 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] [Fact]
public async Task BuildDecisionAsync_BirthdayMemory_SetThenRecallWithinTenant() public async Task BuildDecisionAsync_BirthdayMemory_SetThenRecallWithinTenant()
{ {