From 07d7c835599b684a29523585047cbbd994279907 Mon Sep 17 00:00:00 2001 From: Zane V Date: Tue, 19 May 2026 16:01:12 -0400 Subject: [PATCH] Tweak how mims work to get better responses --- .../IJiboExperienceContentRepository.cs | 8 + .../Services/JiboInteractionService.cs | 42 ++- .../Content/LegacyMimCatalogImporter.cs | 240 +++++++++++++++++- 3 files changed, 283 insertions(+), 7 deletions(-) diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IJiboExperienceContentRepository.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IJiboExperienceContentRepository.cs index 1626a18..d17f971 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IJiboExperienceContentRepository.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IJiboExperienceContentRepository.cs @@ -49,4 +49,12 @@ public sealed class JiboExperienceCatalog public IReadOnlyList GenericFallbackReplies { get; init; } = []; public IReadOnlyList DanceReplies { get; init; } = []; public IReadOnlyList DanceQuestionReplies { get; init; } = []; + + // Key = MIM stem (e.g. "RI_JBO_CanMakeCoffee"), Value = list of stripped reply texts + public IReadOnlyDictionary> NamedScriptedReplies { get; init; } + = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + // Key = lowercased trigger phrase, Value = MIM stem it maps to + public IReadOnlyDictionary NamedScriptedTriggers { get; init; } + = new Dictionary(StringComparer.OrdinalIgnoreCase); } 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 7c3cdcc..3b9aaea 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 @@ -753,7 +753,8 @@ public sealed class JiboInteractionService( "calendar" => new JiboInteractionDecision("calendar", randomizer.Choose(catalog.CalendarReplies)), "commute" => new JiboInteractionDecision("commute", randomizer.Choose(catalog.CommuteReplies)), "news" => await BuildNewsDecisionAsync(turn, transcript, catalog, cancellationToken), - _ => new JiboInteractionDecision("chat", BuildGenericReply(catalog, transcript, lowered)) + _ => TryBuildNamedMimDecision(catalog, lowered) + ?? new JiboInteractionDecision("chat", BuildGenericReply(catalog, transcript, lowered)) }; } @@ -2233,10 +2234,16 @@ public sealed class JiboInteractionService( 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); + if (lowered.Contains("good night", StringComparison.Ordinal)) + return "Good night. Sleep tight."; + + // For unrecognized chitchat, use personality replies rather than the + // CC_Error "sources unavailable" content (which is reserved for service failures). + var chitchatPool = catalog.PersonalityReplies.Count > 0 + ? catalog.PersonalityReplies + : catalog.GenericFallbackReplies; + return randomizer.Choose(chitchatPool) + .Replace("{transcript}", transcript, StringComparison.Ordinal); } private JiboInteractionDecision BuildScriptedPersonalityDecision( @@ -2250,6 +2257,31 @@ public sealed class JiboInteractionService( ContextUpdates: BuildScriptedResponseContextUpdates()); } + private JiboInteractionDecision? TryBuildNamedMimDecision( + JiboExperienceCatalog catalog, string loweredTranscript) + { + // Exact trigger match first + if (!catalog.NamedScriptedTriggers.TryGetValue(loweredTranscript, out var stem)) + { + // Partial contains match: find the longest trigger phrase contained in the transcript + stem = catalog.NamedScriptedTriggers + .Where(kv => loweredTranscript.Contains(kv.Key, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(kv => kv.Key.Length) + .Select(static kv => kv.Value) + .FirstOrDefault(); + } + + if (stem is null) return null; + + if (!catalog.NamedScriptedReplies.TryGetValue(stem, out var replies) || replies.Count == 0) + return null; + + return new JiboInteractionDecision( + stem, + randomizer.Choose(replies), + ContextUpdates: BuildScriptedResponseContextUpdates()); + } + private JiboInteractionDecision BuildScriptedGreetingDecision( JiboExperienceCatalog catalog, string intentName, diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Content/LegacyMimCatalogImporter.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Content/LegacyMimCatalogImporter.cs index 59ffe2a..6787b33 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Content/LegacyMimCatalogImporter.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Content/LegacyMimCatalogImporter.cs @@ -30,6 +30,20 @@ public static class LegacyMimCatalogImporter @"\s+([,.;:!?])", RegexOptions.CultureInvariant | RegexOptions.Compiled); + // Splits CamelCase words, e.g. "CanMakeCoffee" → ["Can", "Make", "Coffee"] + private static readonly Regex CamelCaseSplitPattern = new( + @"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", + RegexOptions.CultureInvariant | RegexOptions.Compiled); + + // Known file prefixes to strip when deriving trigger phrases + private static readonly string[] KnownPrefixes = + [ + "RI_JBO_", "OI_JBO_", "JBO_", "RA_JBO_", "RN_JBO_", "RN_", + "KU_JBO_", "KU_", "JF_JBO_", "JF_", "SUP_JBO_", "SUP_", + "SRS_JBO_", "SRS_", "USR_JBO_", "USR_", "PR_JBO_", "PR_", + "CC_", "RA_", "OI_", "RI_" + ]; + public static JiboExperienceCatalog MergeInto( JiboExperienceCatalog baseCatalog, string? rootDirectory) @@ -56,18 +70,35 @@ public static class LegacyMimCatalogImporter var bucket = ResolveBucket(filePath); if (bucket is null) continue; + var fileName = Path.GetFileNameWithoutExtension(filePath); + var isScriptedResponse = IsScriptedResponsePath(filePath); + + var texts = new List(); foreach (var prompt in definition.Prompts) { var text = NormalizePrompt(prompt.Prompt, IsTemplateBucket(bucket.Value)); if (string.IsNullOrWhiteSpace(text)) continue; builder.Add(bucket.Value, prompt.Condition, text, prompt.Prompt); + texts.Add(text); } + + // Build named lookup for all scripted-response files + if (isScriptedResponse && texts.Count > 0) + builder.AddNamed(fileName, texts); } return builder.Build(); } + private static bool IsScriptedResponsePath(string filePath) + { + var normalizedPath = filePath.Replace('\\', '/'); + return normalizedPath.Contains("/scripted-responses/", StringComparison.OrdinalIgnoreCase) + || normalizedPath.Contains("/emotion-responses/", StringComparison.OrdinalIgnoreCase) + || normalizedPath.Contains("/gqa-responses/", StringComparison.OrdinalIgnoreCase); + } + private static bool TryLoadDefinition(string filePath, out LegacyMimDefinition definition) { definition = new LegacyMimDefinition(); @@ -197,6 +228,128 @@ public static class LegacyMimCatalogImporter return null; } + /// + /// Derives natural-language trigger phrases from a MIM filename stem. + /// E.g. "RI_JBO_CanMakeCoffee" → ["can make coffee", "can you make coffee", "are you able to make coffee"] + /// + internal static IReadOnlyList DeriveTriggerPhrases(string fileName) + { + var name = fileName; + + // Strip known prefix + foreach (var prefix in KnownPrefixes) + { + if (name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + name = name[prefix.Length..]; + break; + } + } + + if (string.IsNullOrWhiteSpace(name)) return []; + + // Split CamelCase and lowercase + var parts = CamelCaseSplitPattern.Split(name); + var lowered = parts.Select(static p => p.ToLowerInvariant()).Where(static p => !string.IsNullOrEmpty(p)).ToArray(); + if (lowered.Length == 0) return []; + + var joined = string.Join(" ", lowered); + var rest = lowered.Length > 1 ? string.Join(" ", lowered.Skip(1)) : string.Empty; + + var triggers = new List { joined }; + + var first = lowered[0]; + + switch (first) + { + case "can": + if (!string.IsNullOrEmpty(rest)) + { + triggers.Add($"can you {rest}"); + triggers.Add($"are you able to {rest}"); + triggers.Add($"could you {rest}"); + } + break; + + case "is": + if (!string.IsNullOrEmpty(rest)) + { + triggers.Add($"are you {rest}"); + triggers.Add($"is jibo {rest}"); + } + break; + + case "are": + if (!string.IsNullOrEmpty(rest)) + triggers.Add($"are you {rest}"); + break; + + case "likes" or "like": + if (!string.IsNullOrEmpty(rest)) + { + triggers.Add($"do you like {rest}"); + triggers.Add($"do you enjoy {rest}"); + triggers.Add($"does jibo like {rest}"); + } + break; + + case "loves" or "love": + if (!string.IsNullOrEmpty(rest)) + { + triggers.Add($"do you love {rest}"); + triggers.Add($"do you like {rest}"); + } + break; + + case "believes" or "believe": + if (!string.IsNullOrEmpty(rest)) + { + triggers.Add($"do you believe {rest}"); + // "BelievesInSanta" → rest = "in santa" → already covered, but also add without "in" + if (rest.StartsWith("in ", StringComparison.Ordinal)) + triggers.Add($"do you believe {rest["in ".Length..]}"); + } + break; + + case "knows" or "know": + if (!string.IsNullOrEmpty(rest)) + { + triggers.Add($"do you know {rest}"); + triggers.Add($"do you know about {rest}"); + } + break; + + case "has" or "have": + if (!string.IsNullOrEmpty(rest)) + { + triggers.Add($"do you have {rest}"); + triggers.Add($"have you {rest}"); + } + break; + + case "wants" or "want": + if (!string.IsNullOrEmpty(rest)) + triggers.Add($"do you want {rest}"); + break; + + case "what": + case "who": + case "where": + case "when": + case "why": + case "how": + // Already in question form — keep as-is, no extra variants needed + break; + + default: + // Generic: emit "do you [all words]" as a fallback variant + triggers.Add($"do you {joined}"); + break; + } + + return triggers.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + private static string NormalizePrompt(string? prompt) { return NormalizePrompt(prompt, false); @@ -266,7 +419,9 @@ public static class LegacyMimCatalogImporter NewsBriefings = Merge(baseCatalog.NewsBriefings, importedCatalog.NewsBriefings), GenericFallbackReplies = Merge(baseCatalog.GenericFallbackReplies, importedCatalog.GenericFallbackReplies), DanceReplies = Merge(baseCatalog.DanceReplies, importedCatalog.DanceReplies), - DanceQuestionReplies = Merge(baseCatalog.DanceQuestionReplies, importedCatalog.DanceQuestionReplies) + DanceQuestionReplies = Merge(baseCatalog.DanceQuestionReplies, importedCatalog.DanceQuestionReplies), + NamedScriptedReplies = MergeNamed(baseCatalog.NamedScriptedReplies, importedCatalog.NamedScriptedReplies), + NamedScriptedTriggers = MergeTriggers(baseCatalog.NamedScriptedTriggers, importedCatalog.NamedScriptedTriggers) }; } @@ -314,6 +469,50 @@ public static class LegacyMimCatalogImporter return merged; } + private static IReadOnlyDictionary> MergeNamed( + IReadOnlyDictionary> baseDict, + IReadOnlyDictionary> importedDict) + { + var result = new Dictionary>( + baseDict, StringComparer.OrdinalIgnoreCase); + + foreach (var (key, importedReplies) in importedDict) + { + if (result.TryGetValue(key, out var existing)) + { + // Merge reply lists, deduplicating + var seen = new HashSet(existing, StringComparer.OrdinalIgnoreCase); + var merged = new List(existing); + foreach (var reply in importedReplies) + { + if (!string.IsNullOrWhiteSpace(reply) && seen.Add(reply.Trim())) + merged.Add(reply.Trim()); + } + result[key] = merged; + } + else + { + result[key] = importedReplies; + } + } + + return result; + } + + private static IReadOnlyDictionary MergeTriggers( + IReadOnlyDictionary baseDict, + IReadOnlyDictionary importedDict) + { + // Base catalog's explicit triggers win; imported fills gaps + var result = new Dictionary(baseDict, StringComparer.OrdinalIgnoreCase); + foreach (var (trigger, stem) in importedDict) + { + if (!result.ContainsKey(trigger)) + result[trigger] = stem; + } + return result; + } + private static string NormalizeCondition(string? condition) { return string.IsNullOrWhiteSpace(condition) ? string.Empty : WhitespacePattern.Replace(condition.Trim(), " "); @@ -389,6 +588,12 @@ public static class LegacyMimCatalogImporter private readonly List _weatherTomorrowHighLowReplies = []; private readonly List _weatherTomorrowIntroReplies = []; + // Named MIM dictionaries + private readonly Dictionary> _namedReplies = + new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _namedTriggers = + new(StringComparer.OrdinalIgnoreCase); + public void Add(LegacyMimBucket bucket, string? condition, string text, string? sourcePrompt = null) { switch (bucket) @@ -511,8 +716,37 @@ public static class LegacyMimCatalogImporter } } + public void AddNamed(string fileName, IReadOnlyList replies) + { + if (!_namedReplies.TryGetValue(fileName, out var list)) + { + list = []; + _namedReplies[fileName] = list; + } + + var seen = new HashSet(list, StringComparer.OrdinalIgnoreCase); + foreach (var reply in replies) + { + if (!string.IsNullOrWhiteSpace(reply) && seen.Add(reply.Trim())) + list.Add(reply.Trim()); + } + + // Derive and register trigger phrases + var triggers = DeriveTriggerPhrases(fileName); + foreach (var trigger in triggers) + { + if (!string.IsNullOrWhiteSpace(trigger) && !_namedTriggers.ContainsKey(trigger)) + _namedTriggers[trigger] = fileName; + } + } + public JiboExperienceCatalog Build() { + var namedReplies = _namedReplies.ToDictionary( + static kv => kv.Key, + static kv => (IReadOnlyList)kv.Value, + StringComparer.OrdinalIgnoreCase); + return new JiboExperienceCatalog { Jokes = [.. _jokes], @@ -539,7 +773,9 @@ public static class LegacyMimCatalogImporter CommuteServiceDownReplies = [.. _commuteServiceDownReplies], NewsIntroReplies = [.. _newsIntroReplies], NewsCategoryIntroReplies = [.. _newsCategoryIntroReplies], - NewsOutroReplies = [.. _newsOutroReplies] + NewsOutroReplies = [.. _newsOutroReplies], + NamedScriptedReplies = namedReplies, + NamedScriptedTriggers = new Dictionary(_namedTriggers, StringComparer.OrdinalIgnoreCase) }; }