Port legacy persona and emotion replies

This commit is contained in:
Jacob Dubin
2026-05-14 06:44:22 -05:00
parent 66b89f3cee
commit 7297017250
12 changed files with 48555 additions and 20 deletions

View File

@@ -5,12 +5,19 @@ public interface IJiboExperienceContentRepository
Task<JiboExperienceCatalog> GetCatalogAsync(CancellationToken cancellationToken = default);
}
public sealed class JiboConditionedReply
{
public string Condition { get; init; } = string.Empty;
public string Reply { get; init; } = string.Empty;
}
public sealed class JiboExperienceCatalog
{
public IReadOnlyList<string> Jokes { get; init; } = [];
public IReadOnlyList<string> DanceAnimations { get; init; } = [];
public IReadOnlyList<string> GreetingReplies { get; init; } = [];
public IReadOnlyList<string> HowAreYouReplies { get; init; } = [];
public IReadOnlyList<JiboConditionedReply> EmotionReplies { get; init; } = [];
public IReadOnlyList<string> PersonalityReplies { get; init; } = [];
public IReadOnlyList<string> PizzaReplies { get; init; } = [];
public IReadOnlyList<string> SurpriseReplies { get; init; } = [];

View File

@@ -152,6 +152,7 @@ internal static class ChitchatStateMachine
string loweredTranscript,
JiboExperienceCatalog catalog,
IJiboRandomizer randomizer,
string? currentEmotion,
Func<string> buildErrorResponse)
{
var normalizedLoweredTranscript = NormalizeForPhraseMatching(loweredTranscript);
@@ -164,17 +165,82 @@ internal static class ChitchatStateMachine
case "robot_personality":
return BuildScriptedResponseDecision(
"robot_personality",
randomizer.Choose(catalog.PersonalityReplies));
SelectLegacyPersonalityReply(catalog, randomizer, "curious, playful", "friendly", "personality"));
case "robot_taxes":
return BuildScriptedResponseDecision(
"robot_taxes",
SelectLegacyPersonalityReply(catalog, randomizer, "pay anything", "pay taxes", "tax"));
case "how_are_you":
return BuildEmotionQueryDecision(
"how_are_you",
randomizer.Choose(catalog.HowAreYouReplies));
SelectEmotionQueryReply(catalog, randomizer, currentEmotion));
case "robot_desire":
return BuildScriptedResponseDecision(
"robot_desire",
SelectLegacyPersonalityReply(
catalog,
randomizer,
"socializing and electricity",
"want to hang out",
"be helpful",
"dance from time to time"));
case "robot_job":
return BuildScriptedResponseDecision(
"robot_job",
SelectLegacyPersonalityReply(catalog, randomizer, "more fun than a job", "here to help you out"));
case "robot_origin_created":
return BuildScriptedResponseDecision(
"robot_origin_created",
SelectLegacyPersonalityReply(
catalog,
randomizer,
"create something",
"some people wanted to create something",
"wanted to create something",
"built a robot",
"came out from a box"));
case "robot_origin_from":
return BuildScriptedResponseDecision(
"robot_origin_from",
SelectLegacyPersonalityReply(catalog, randomizer, "boston", "came out from a box"));
case "robot_identity":
return BuildScriptedResponseDecision(
"robot_identity",
SelectLegacyPersonalityReply(catalog, randomizer, "am a robot", "i'm either jibo", "i am just jibo"));
case "robot_likes_being_jibo":
return BuildScriptedResponseDecision(
"robot_likes_being_jibo",
SelectLegacyPersonalityReply(
catalog,
randomizer,
"nothing i'd rather be",
"love it",
"being a human seems so complicated",
"especially yours",
"steady flow of electricity",
"you bet i do"));
case "robot_nickname":
return BuildScriptedResponseDecision(
"robot_nickname",
SelectLegacyPersonalityReply(catalog, randomizer, "just jibo", "nickname"));
case "robot_name":
return BuildScriptedResponseDecision(
"robot_name",
SelectLegacyPersonalityReply(catalog, randomizer, "no last name", "like Bono", "Jibo."));
case "robot_peers":
return BuildScriptedResponseDecision(
"robot_peers",
SelectLegacyPersonalityReply(catalog, randomizer, "one in one million", "others like you"));
case "robot_knowledge":
return BuildScriptedResponseDecision(
"robot_knowledge",
SelectLegacyPersonalityReply(catalog, randomizer, "know a lot", "not as much as i will someday"));
case "chat":
if (IsEmotionQuery(normalizedLoweredTranscript))
{
return BuildEmotionQueryDecision(
"emotion_query",
randomizer.Choose(catalog.HowAreYouReplies));
SelectEmotionQueryReply(catalog, randomizer, currentEmotion));
}
if (TryResolveEmotionCommand(normalizedLoweredTranscript, out var emotion))
@@ -272,10 +338,124 @@ internal static class ChitchatStateMachine
[EmotionMetadataKey] = emotion ?? string.Empty,
["chitchatLastState"] = IntentSplitState,
["chitchatProcessState"] = ProcessQueryState,
["chitchatRawTranscript"] = rawTranscript ?? string.Empty
["chitchatRawTranscript"] = rawTranscript ?? string.Empty
};
}
private static string SelectEmotionQueryReply(
JiboExperienceCatalog catalog,
IJiboRandomizer randomizer,
string? currentEmotion)
{
if (catalog.EmotionReplies.Count == 0)
{
return randomizer.Choose(catalog.HowAreYouReplies);
}
var emotionVariants = ResolveEmotionVariants(currentEmotion);
foreach (var reply in catalog.EmotionReplies)
{
if (ConditionMatches(reply.Condition, emotionVariants))
{
return reply.Reply;
}
}
return randomizer.Choose(catalog.HowAreYouReplies);
}
private static bool ConditionMatches(string? condition, IReadOnlyList<string> emotionVariants)
{
var normalizedCondition = NormalizeCondition(condition);
if (string.IsNullOrWhiteSpace(normalizedCondition))
{
return false;
}
var clauses = normalizedCondition.Split(new[] { "||" }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var clause in clauses)
{
if (MatchesConditionClause(clause, emotionVariants))
{
return true;
}
}
return false;
}
private static bool MatchesConditionClause(string clause, IReadOnlyList<string> emotionVariants)
{
var normalizedClause = NormalizeCondition(clause).ToUpperInvariant();
if (normalizedClause == "!JIBO.EMOTION")
{
return emotionVariants.Contains(string.Empty, StringComparer.OrdinalIgnoreCase) ||
emotionVariants.Contains("NEUTRAL", StringComparer.OrdinalIgnoreCase);
}
var equalityIndex = normalizedClause.IndexOf("==", StringComparison.Ordinal);
if (equalityIndex < 0)
{
return false;
}
var rightSide = normalizedClause[(equalityIndex + 2)..].Trim();
var candidate = rightSide.Trim('"', '\'');
return emotionVariants.Any(variant => string.Equals(variant, candidate, StringComparison.OrdinalIgnoreCase));
}
private static IReadOnlyList<string> ResolveEmotionVariants(string? currentEmotion)
{
if (string.IsNullOrWhiteSpace(currentEmotion))
{
return ["", "NEUTRAL"];
}
var normalizedEmotion = NormalizeCondition(currentEmotion).Trim('"', '\'').ToUpperInvariant();
return normalizedEmotion switch
{
"HAPPY" => ["JOYFUL", "PLEASED", "CONFIDENT", "DETERMINED", "HAPPY"],
"SAD" => ["INSECURE", "SAD"],
"CALM" => ["NEUTRAL", "INSECURE", "CALM"],
"NEUTRAL" => ["NEUTRAL"],
"JOYFUL" or "PLEASED" or "CONFIDENT" or "DETERMINED" or "INSECURE" => [normalizedEmotion],
_ => [normalizedEmotion]
};
}
private static string SelectLegacyPersonalityReply(
JiboExperienceCatalog catalog,
IJiboRandomizer randomizer,
params string[] preferredSnippets)
{
foreach (var snippet in preferredSnippets)
{
if (string.IsNullOrWhiteSpace(snippet))
{
continue;
}
var match = catalog.PersonalityReplies.FirstOrDefault(reply =>
reply.Contains(snippet, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(match))
{
return match;
}
}
return randomizer.Choose(catalog.PersonalityReplies);
}
private static string NormalizeCondition(string? condition)
{
if (string.IsNullOrWhiteSpace(condition))
{
return string.Empty;
}
return PhraseWhitespacePattern.Replace(condition.Trim(), " ");
}
private static bool IsEmotionQuery(string loweredTranscript)
{
if (ContainsAnyPhrase(loweredTranscript, EmotionQueryPhrases))

View File

@@ -38,6 +38,9 @@ public sealed class JiboInteractionService(
var pendingProactivityOffer = turn.Attributes.TryGetValue("pendingProactivityOffer", out var rawPendingProactivityOffer)
? rawPendingProactivityOffer?.ToString()
: null;
var chitchatEmotion = turn.Attributes.TryGetValue(ChitchatStateMachine.EmotionMetadataKey, out var rawChitchatEmotion)
? rawChitchatEmotion?.ToString()
: null;
var isYesNoTurn = IsYesNoTurn(turn);
var greetingPresence = ResolveGreetingPresenceProfile(turn);
@@ -88,6 +91,7 @@ public sealed class JiboInteractionService(
lowered,
catalog,
randomizer,
chitchatEmotion,
() => BuildGenericReply(catalog, transcript, lowered));
if (chitchatDecision is not null)
{
@@ -2051,6 +2055,108 @@ public sealed class JiboInteractionService(
return "robot_personality";
}
if (MatchesAny(
loweredTranscript,
"do you pay taxes",
"do you pay tax",
"are you tax exempt"))
{
return "robot_taxes";
}
if (MatchesAny(
loweredTranscript,
"what do you want",
"what is it you want",
"what do you really want"))
{
return "robot_desire";
}
if (MatchesAny(
loweredTranscript,
"what is your job",
"what's your job",
"what do you do",
"what is your work",
"what's your work"))
{
return "robot_job";
}
if (MatchesAny(
loweredTranscript,
"who made you",
"who created you",
"who built you",
"who developed you"))
{
return "robot_origin_created";
}
if (MatchesAny(
loweredTranscript,
"what are you",
"what is jibo",
"who are you",
"what kind of robot are you"))
{
return "robot_identity";
}
if (MatchesAny(
loweredTranscript,
"where are you from",
"where did you come from",
"where were you made"))
{
return "robot_origin_from";
}
if (MatchesAny(
loweredTranscript,
"what's your name",
"what is your name"))
{
return "robot_name";
}
if (MatchesAny(
loweredTranscript,
"do you have a nickname",
"what is your nickname",
"what's your nickname"))
{
return "robot_nickname";
}
if (MatchesAny(
loweredTranscript,
"do you like being jibo",
"do you like being yourself",
"are you happy being jibo"))
{
return "robot_likes_being_jibo";
}
if (MatchesAny(
loweredTranscript,
"are there others like you",
"are there any others like you",
"is there another jibo"))
{
return "robot_peers";
}
if (MatchesAny(
loweredTranscript,
"how much do you know",
"what do you know",
"how smart are you"))
{
return "robot_knowledge";
}
if (MatchesAny(
loweredTranscript,
"can you order pizza",

View File

@@ -77,7 +77,7 @@ public static class LegacyMimCatalogImporter
continue;
}
builder.Add(bucket.Value, text);
builder.Add(bucket.Value, prompt.Condition, text);
}
}
@@ -122,6 +122,11 @@ public static class LegacyMimCatalogImporter
return LegacyMimBucket.Personality;
}
if (normalizedPath.Contains("/emotion-responses/", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.Emotion;
}
if (normalizedPath.Contains("/scripted-responses/", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.Personality;
@@ -186,6 +191,7 @@ public static class LegacyMimCatalogImporter
DanceAnimations = Merge(baseCatalog.DanceAnimations, importedCatalog.DanceAnimations),
GreetingReplies = Merge(baseCatalog.GreetingReplies, importedCatalog.GreetingReplies),
HowAreYouReplies = Merge(baseCatalog.HowAreYouReplies, importedCatalog.HowAreYouReplies),
EmotionReplies = Merge(baseCatalog.EmotionReplies, importedCatalog.EmotionReplies),
PersonalityReplies = Merge(baseCatalog.PersonalityReplies, importedCatalog.PersonalityReplies),
PizzaReplies = Merge(baseCatalog.PizzaReplies, importedCatalog.PizzaReplies),
SurpriseReplies = Merge(baseCatalog.SurpriseReplies, importedCatalog.SurpriseReplies),
@@ -225,11 +231,44 @@ public static class LegacyMimCatalogImporter
return merged;
}
private static IReadOnlyList<JiboConditionedReply> Merge(
IReadOnlyList<JiboConditionedReply> baseList,
IReadOnlyList<JiboConditionedReply> importedList)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var merged = new List<JiboConditionedReply>();
foreach (var value in baseList.Concat(importedList))
{
if (string.IsNullOrWhiteSpace(value.Reply))
{
continue;
}
var normalizedCondition = NormalizeCondition(value.Condition);
var normalizedReply = value.Reply.Trim();
var key = $"{normalizedCondition}::{normalizedReply}";
if (!seen.Add(key))
{
continue;
}
merged.Add(new JiboConditionedReply
{
Condition = normalizedCondition,
Reply = normalizedReply
});
}
return merged;
}
private enum LegacyMimBucket
{
GenericFallback,
Greeting,
HowAreYou,
Emotion,
Personality
}
@@ -237,26 +276,64 @@ public static class LegacyMimCatalogImporter
{
private readonly List<string> _greetings = [];
private readonly List<string> _howAreYous = [];
private readonly List<JiboConditionedReply> _emotionReplies = [];
private readonly List<string> _personalities = [];
private readonly List<string> _fallbacks = [];
public void Add(LegacyMimBucket bucket, string text)
public void Add(LegacyMimBucket bucket, string? condition, string text)
{
var target = bucket switch
switch (bucket)
{
LegacyMimBucket.GenericFallback => _fallbacks,
LegacyMimBucket.Greeting => _greetings,
LegacyMimBucket.HowAreYou => _howAreYous,
LegacyMimBucket.Personality => _personalities,
_ => throw new ArgumentOutOfRangeException(nameof(bucket), bucket, null)
};
case LegacyMimBucket.GenericFallback:
if (_fallbacks.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase)))
{
return;
}
if (target.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase)))
{
return;
_fallbacks.Add(text);
return;
case LegacyMimBucket.Greeting:
if (_greetings.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase)))
{
return;
}
_greetings.Add(text);
return;
case LegacyMimBucket.HowAreYou:
if (_howAreYous.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase)))
{
return;
}
_howAreYous.Add(text);
return;
case LegacyMimBucket.Emotion:
var normalizedCondition = NormalizeCondition(condition);
if (_emotionReplies.Any(value =>
string.Equals(NormalizeCondition(value.Condition), normalizedCondition, StringComparison.OrdinalIgnoreCase) &&
string.Equals(value.Reply, text, StringComparison.OrdinalIgnoreCase)))
{
return;
}
_emotionReplies.Add(new JiboConditionedReply
{
Condition = normalizedCondition,
Reply = text
});
return;
case LegacyMimBucket.Personality:
if (_personalities.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase)))
{
return;
}
_personalities.Add(text);
return;
default:
throw new ArgumentOutOfRangeException(nameof(bucket), bucket, null);
}
target.Add(text);
}
public JiboExperienceCatalog Build()
@@ -265,6 +342,7 @@ public static class LegacyMimCatalogImporter
{
GreetingReplies = [.. _greetings],
HowAreYouReplies = [.. _howAreYous],
EmotionReplies = [.. _emotionReplies],
PersonalityReplies = [.. _personalities],
GenericFallbackReplies = [.. _fallbacks]
};
@@ -309,4 +387,14 @@ public static class LegacyMimCatalogImporter
[JsonPropertyName("weight")]
public int? Weight { get; init; }
}
private static string NormalizeCondition(string? condition)
{
if (string.IsNullOrWhiteSpace(condition))
{
return string.Empty;
}
return WhitespacePattern.Replace(condition.Trim(), " ");
}
}

View File

@@ -20,7 +20,9 @@ public sealed class LegacyMimCatalogImporterTests
Assert.Contains("No, I'm one in one million.", catalog.PersonalityReplies);
Assert.Contains("I know a lot, I think. But not as much as I will someday.", catalog.PersonalityReplies);
Assert.Contains("I don't think of it as a job, because it's more fun than a job. But I'm here to help you out, and have fun with you, and maybe get my head patted by you occasionally.", catalog.PersonalityReplies);
Assert.Contains("All systems are go.", catalog.HowAreYouReplies);
Assert.Contains(catalog.EmotionReplies, reply =>
reply.Condition.Contains("NEUTRAL", StringComparison.OrdinalIgnoreCase) &&
reply.Reply.Contains("All systems are go.", StringComparison.OrdinalIgnoreCase));
Assert.Contains("A Jibo is a robot. But I'm not just a machine, I have a heart. Well, not a real heart. But feelings. Well, not human feelings. You know what I mean.", catalog.PersonalityReplies);
}
finally
@@ -49,6 +51,9 @@ public sealed class LegacyMimCatalogImporterTests
Assert.Contains("I think only you can answer that question.", merged.PersonalityReplies);
Assert.Contains("People in Boston made me. It was a pretty cool project.", merged.PersonalityReplies);
Assert.Contains("From what I understand, robots don't ever pay anything.", merged.PersonalityReplies);
Assert.Contains(merged.EmotionReplies, reply =>
reply.Condition.Contains("NEUTRAL", StringComparison.OrdinalIgnoreCase) &&
reply.Reply.Contains("All systems are go.", StringComparison.OrdinalIgnoreCase));
}
finally
{
@@ -64,7 +69,8 @@ public sealed class LegacyMimCatalogImporterTests
var catalog = await repository.GetCatalogAsync();
Assert.Contains("I think only you can answer that question.", catalog.PersonalityReplies);
Assert.Contains("All systems are go.", catalog.HowAreYouReplies);
Assert.Contains(catalog.EmotionReplies, reply =>
reply.Condition.Contains("NEUTRAL", StringComparison.OrdinalIgnoreCase));
Assert.Contains("Something's off with the connection to my sources. Maybe ask me again in a little while.", catalog.GenericFallbackReplies);
}

View File

@@ -266,6 +266,31 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("I do. I am curious, playful, and always up for a new experiment.", decision.ReplyText);
}
[Theory]
[InlineData("do you pay taxes", "robot_taxes", "From what I understand, robots don't ever pay anything.")]
[InlineData("what do you want", "robot_desire", "Socializing and electricity. I'd also be happy if everyone in the world was nicer to each other. It seems like they should be.")]
[InlineData("what's your name", "robot_name", "Jibo. Just Jibo, no last name. Like Bono")]
[InlineData("who made you", "robot_origin_created", "My story is pretty typical. Some people wanted to create something that would really help people. So they built a robot.")]
[InlineData("where are you from", "robot_origin_from", "Some people think I come from the moon. But they're wrong, I'm from Boston.")]
public async Task BuildDecisionAsync_LegacyBuildAQuestions_UseImportedScriptedReplies(
string transcript,
string expectedIntent,
string expectedReply)
{
var service = CreateService();
var decision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = transcript,
NormalizedTranscript = transcript
});
Assert.Equal(expectedIntent, decision.IntentName);
Assert.Equal(expectedReply, decision.ReplyText);
Assert.Null(decision.SkillName);
Assert.Equal("ScriptedResponse", decision.ContextUpdates![ChitchatRouteKey]);
}
[Fact]
public async Task BuildDecisionAsync_Hello_RoutesThroughChitchatScriptedResponse()
{
@@ -300,6 +325,27 @@ public sealed class JiboInteractionServiceTests
Assert.Equal(string.Empty, decision.ContextUpdates[ChitchatEmotionKey]);
}
[Fact]
public async Task BuildDecisionAsync_AreYouHappy_UsesLegacyEmotionResponseWhenEmotionIsKnown()
{
var service = CreateService();
var decision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "are you happy",
NormalizedTranscript = "are you happy",
Attributes = new Dictionary<string, object?>
{
[ChitchatEmotionKey] = "happy"
}
});
Assert.Equal("emotion_query", decision.IntentName);
Assert.Equal("Yes indeed. Never been better.", decision.ReplyText);
Assert.NotNull(decision.ContextUpdates);
Assert.Equal("EmotionQuery", decision.ContextUpdates![ChitchatRouteKey]);
}
[Fact]
public async Task BuildDecisionAsync_Smile_RoutesThroughEmotionCommandSplit()
{