diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IPersonalMemoryStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IPersonalMemoryStore.cs index aac5c4b..2e7762c 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IPersonalMemoryStore.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IPersonalMemoryStore.cs @@ -13,6 +13,9 @@ public interface IPersonalMemoryStore void SetAffinity(PersonalMemoryTenantScope tenantScope, string item, PersonalAffinity affinity); PersonalAffinity? GetAffinity(PersonalMemoryTenantScope tenantScope, string item); IReadOnlyDictionary GetAffinities(PersonalMemoryTenantScope tenantScope); + void AddListItem(PersonalMemoryTenantScope tenantScope, string listName, string item); + IReadOnlyList GetListItems(PersonalMemoryTenantScope tenantScope, string listName); + void ClearListItems(PersonalMemoryTenantScope tenantScope, string listName); } public sealed record PersonalMemoryTenantScope(string AccountId, string LoopId, string DeviceId); diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/HouseholdListOrchestrator.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/HouseholdListOrchestrator.cs new file mode 100644 index 0000000..f3b561e --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/HouseholdListOrchestrator.cs @@ -0,0 +1,299 @@ +using Jibo.Cloud.Application.Abstractions; +using Jibo.Runtime.Abstractions; +using System.Linq; + +namespace Jibo.Cloud.Application.Services; + +internal static class HouseholdListOrchestrator +{ + internal const string StateMetadataKey = "householdListState"; + internal const string TypeMetadataKey = "householdListType"; + internal const string NoMatchCountMetadataKey = "householdListNoMatchCount"; + internal const string NoInputCountMetadataKey = "householdListNoInputCount"; + + private const string IdleState = "idle"; + private const string AwaitingItemState = "awaiting_item"; + + public static Task TryBuildDecisionAsync( + TurnContext turn, + string semanticIntent, + string transcript, + string loweredTranscript, + IJiboRandomizer randomizer, + IPersonalMemoryStore personalMemoryStore, + Func tenantScopeResolver) + { + var state = ReadString(turn, StateMetadataKey); + var listType = ReadString(turn, TypeMetadataKey); + var isActiveState = !string.IsNullOrWhiteSpace(state) && + !string.Equals(state, IdleState, StringComparison.OrdinalIgnoreCase); + var isShoppingIntent = string.Equals(semanticIntent, "shopping_list", StringComparison.OrdinalIgnoreCase); + var isTodoIntent = string.Equals(semanticIntent, "todo_list", StringComparison.OrdinalIgnoreCase); + + if (!isActiveState && !isShoppingIntent && !isTodoIntent) + { + return Task.FromResult(null); + } + + var resolvedListType = isShoppingIntent ? "shopping" : isTodoIntent ? "todo" : NormalizeListType(listType); + if (string.IsNullOrWhiteSpace(resolvedListType)) + { + resolvedListType = "shopping"; + } + + var tenantScope = tenantScopeResolver(turn); + + if (ContainsAny(loweredTranscript, "cancel", "stop", "never mind", "nevermind", "forget it")) + { + return Task.FromResult(BuildCancelledDecision(resolvedListType)); + } + + if (IsRecallRequest(loweredTranscript)) + { + return Task.FromResult(BuildRecallDecision( + resolvedListType, + personalMemoryStore.GetListItems(tenantScope, resolvedListType))); + } + + var directItem = TryExtractListItem(loweredTranscript); + if (string.IsNullOrWhiteSpace(directItem) && isActiveState) + { + if (IsConversationComplete(loweredTranscript)) + { + return Task.FromResult(new JiboInteractionDecision( + resolvedListType == "shopping" ? "shopping_list_done" : "todo_list_done", + BuildDoneReply(resolvedListType, personalMemoryStore.GetListItems(tenantScope, resolvedListType)), + ContextUpdates: BuildContextUpdates(resolvedListType, IdleState))); + } + + directItem = NormalizeItem(transcript); + } + + if (!string.IsNullOrWhiteSpace(directItem)) + { + personalMemoryStore.AddListItem(tenantScope, resolvedListType, directItem); + return Task.FromResult(new JiboInteractionDecision( + resolvedListType == "shopping" ? "shopping_list_add" : "todo_list_add", + BuildAddedReply(resolvedListType, directItem, personalMemoryStore.GetListItems(tenantScope, resolvedListType)), + ContextUpdates: BuildContextUpdates(resolvedListType, AwaitingItemState))); + } + + if (string.IsNullOrWhiteSpace(transcript)) + { + return Task.FromResult(new JiboInteractionDecision( + resolvedListType == "shopping" ? "shopping_list_prompt" : "todo_list_prompt", + BuildPromptReply(resolvedListType), + ContextUpdates: BuildContextUpdates(resolvedListType, AwaitingItemState))); + } + + return Task.FromResult(new JiboInteractionDecision( + resolvedListType == "shopping" ? "shopping_list_prompt" : "todo_list_prompt", + BuildPromptReply(resolvedListType), + ContextUpdates: BuildContextUpdates(resolvedListType, AwaitingItemState))); + } + + private static IDictionary BuildContextUpdates(string listType, string state) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [StateMetadataKey] = state, + [TypeMetadataKey] = listType, + [NoMatchCountMetadataKey] = 0, + [NoInputCountMetadataKey] = 0 + }; + } + + private static JiboInteractionDecision BuildCancelledDecision(string listType) + { + return new JiboInteractionDecision( + listType == "shopping" ? "shopping_list_cancel" : "todo_list_cancel", + listType == "shopping" ? "Okay. I stopped the shopping list." : "Okay. I stopped the to-do list.", + ContextUpdates: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [StateMetadataKey] = IdleState, + [TypeMetadataKey] = listType, + [NoMatchCountMetadataKey] = 0, + [NoInputCountMetadataKey] = 0 + }); + } + + private static JiboInteractionDecision BuildRecallDecision(string listType, IReadOnlyList items) + { + if (items.Count == 0) + { + return new JiboInteractionDecision( + listType == "shopping" ? "shopping_list_recall" : "todo_list_recall", + listType == "shopping" + ? "Your shopping list is empty." + : "Your to-do list is empty.", + ContextUpdates: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [StateMetadataKey] = IdleState, + [TypeMetadataKey] = listType, + [NoMatchCountMetadataKey] = 0, + [NoInputCountMetadataKey] = 0 + }); + } + + return new JiboInteractionDecision( + listType == "shopping" ? "shopping_list_recall" : "todo_list_recall", + listType == "shopping" + ? $"Your shopping list has {JoinList(items)}." + : $"Your to-do list has {JoinList(items)}.", + ContextUpdates: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [StateMetadataKey] = IdleState, + [TypeMetadataKey] = listType, + [NoMatchCountMetadataKey] = 0, + [NoInputCountMetadataKey] = 0 + }); + } + + private static string BuildAddedReply(string listType, string addedItem, IReadOnlyList items) + { + var itemLabel = listType == "shopping" ? "shopping list" : "to-do list"; + return items.Count == 1 + ? $"Added {addedItem} to your {itemLabel}. What else should I add?" + : $"Added {addedItem} to your {itemLabel}. You now have {JoinList(items)}."; + } + + private static string BuildPromptReply(string listType) + { + return listType == "shopping" + ? "What should I add to your shopping list?" + : "What should I add to your to-do list?"; + } + + private static string BuildDoneReply(string listType, IReadOnlyList items) + { + if (items.Count == 0) + { + return listType == "shopping" + ? "Okay. Your shopping list is empty." + : "Okay. Your to-do list is empty."; + } + + return listType == "shopping" + ? $"Okay. Your shopping list has {JoinList(items)}." + : $"Okay. Your to-do list has {JoinList(items)}."; + } + + private static string JoinList(IReadOnlyList items) + { + return items.Count switch + { + 0 => string.Empty, + 1 => items[0], + 2 => $"{items[0]} and {items[1]}", + _ => $"{string.Join(", ", items.Take(items.Count - 1))}, and {items[^1]}" + }; + } + + private static string? TryExtractListItem(string loweredTranscript) + { + foreach (var prefix in ItemPrefixes) + { + if (!loweredTranscript.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var remainder = loweredTranscript[prefix.Length..].Trim(); + remainder = TrimTrailingListPhrases(remainder); + return NormalizeItem(remainder); + } + + return null; + } + + private static bool IsRecallRequest(string loweredTranscript) + { + return ContainsAny(loweredTranscript, + "what is on my shopping list", + "what's on my shopping list", + "show my shopping list", + "what is on my to do list", + "what's on my to do list", + "show my to do list", + "what are my tasks", + "what do i need to buy", + "what do i need to do"); + } + + private static string TrimTrailingListPhrases(string value) + { + var result = value; + foreach (var suffix in ItemSuffixes) + { + if (result.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + result = result[..^suffix.Length].Trim(); + } + } + + return result; + } + + private static string NormalizeItem(string value) + { + return value.Trim().TrimEnd('.', ',', '!', '?'); + } + + private static string NormalizeListType(string? listType) + { + var normalized = NormalizeItem(listType ?? string.Empty).ToLowerInvariant(); + return normalized.Contains("todo", StringComparison.OrdinalIgnoreCase) || normalized.Contains("to do", StringComparison.OrdinalIgnoreCase) + ? "todo" + : normalized.Contains("shopping", StringComparison.OrdinalIgnoreCase) || normalized.Contains("grocery", StringComparison.OrdinalIgnoreCase) + ? "shopping" + : string.Empty; + } + + private static bool ContainsAny(string loweredTranscript, params string[] phrases) + { + return phrases.Any(phrase => loweredTranscript.Contains(phrase, StringComparison.OrdinalIgnoreCase)); + } + + private static bool IsConversationComplete(string loweredTranscript) + { + return ContainsAny(loweredTranscript, + "done", + "that's it", + "that s it", + "all set", + "finished", + "no more", + "nothing else"); + } + + private static string? ReadString(TurnContext turn, string key) + { + return turn.Attributes.TryGetValue(key, out var value) ? value?.ToString() : null; + } + + private static readonly string[] ItemPrefixes = + [ + "add ", + "put ", + "buy ", + "get ", + "remind me to ", + "i need to ", + "i need ", + "please add ", + "please put " + ]; + + private static readonly string[] ItemSuffixes = + [ + " to my shopping list", + " to the shopping list", + " on my shopping list", + " to my to do list", + " to the to do list", + " on my to do list", + " to my todo list", + " to the todo list", + " on my todo list" + ]; +} 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 9f5caf7..3276d50 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 @@ -85,6 +85,19 @@ public sealed class JiboInteractionService( return personalReportDecision; } + var householdListDecision = await HouseholdListOrchestrator.TryBuildDecisionAsync( + turn, + semanticIntent, + transcript, + lowered, + randomizer, + personalMemoryStore, + ResolveTenantScope); + if (householdListDecision is not null) + { + return householdListDecision; + } + var chitchatDecision = ChitchatStateMachine.TryBuildDecision( semanticIntent, transcript, @@ -2196,6 +2209,30 @@ public sealed class JiboInteractionService( return "personal_report"; } + if (MatchesAny( + loweredTranscript, + "shopping list", + "grocery list", + "to do list", + "todo list", + "add to my shopping list", + "add to my to do list", + "add to my todo list", + "what's on my shopping list", + "what is on my shopping list", + "what's on my to do list", + "what is on my to do list", + "what are my tasks", + "what do i need to buy", + "what do i need to do")) + { + return loweredTranscript.Contains("to do", StringComparison.OrdinalIgnoreCase) || + loweredTranscript.Contains("todo", StringComparison.OrdinalIgnoreCase) || + loweredTranscript.Contains("task", StringComparison.OrdinalIgnoreCase) + ? "todo_list" + : "shopping_list"; + } + if (IsWeatherRequest(loweredTranscript)) { return "weather"; 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 5401ba9..1110e5b 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 @@ -122,7 +122,8 @@ public static class LegacyMimCatalogImporter return LegacyMimBucket.Personality; } - if (normalizedPath.Contains("/emotion-responses/", StringComparison.OrdinalIgnoreCase)) + if (normalizedPath.Contains("/emotion-responses/", StringComparison.OrdinalIgnoreCase) || + normalizedPath.Contains("/gqa-responses/", StringComparison.OrdinalIgnoreCase)) { return LegacyMimBucket.Emotion; } @@ -152,7 +153,7 @@ public static class LegacyMimCatalogImporter fileName.StartsWith("RI_JBO_Is", StringComparison.OrdinalIgnoreCase) || fileName.StartsWith("RN_WhatAreYouFeeling", StringComparison.OrdinalIgnoreCase)) { - return LegacyMimBucket.HowAreYou; + return LegacyMimBucket.Emotion; } if (fileName.Contains("Greeting", StringComparison.OrdinalIgnoreCase) || diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryPersonalMemoryStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryPersonalMemoryStore.cs index 077382f..7bb060d 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryPersonalMemoryStore.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryPersonalMemoryStore.cs @@ -92,6 +92,60 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore return new Dictionary(record.Affinities, StringComparer.OrdinalIgnoreCase); } + public void AddListItem(PersonalMemoryTenantScope tenantScope, string listName, string item) + { + var normalizedListName = NormalizeCategory(listName); + var normalizedItem = item.Trim(); + if (string.IsNullOrWhiteSpace(normalizedListName) || string.IsNullOrWhiteSpace(normalizedItem)) + { + return; + } + + var key = BuildTenantKey(tenantScope); + var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord()); + lock (record.SyncRoot) + { + var list = record.Lists.GetOrAdd(normalizedListName, static _ => []); + if (list.Any(value => string.Equals(value, normalizedItem, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + list.Add(normalizedItem); + } + } + + public IReadOnlyList GetListItems(PersonalMemoryTenantScope tenantScope, string listName) + { + var key = BuildTenantKey(tenantScope); + if (!_tenantMemory.TryGetValue(key, out var record)) + { + return []; + } + + var normalizedListName = NormalizeCategory(listName); + lock (record.SyncRoot) + { + return record.Lists.TryGetValue(normalizedListName, out var list) + ? [.. list] + : []; + } + } + + public void ClearListItems(PersonalMemoryTenantScope tenantScope, string listName) + { + var key = BuildTenantKey(tenantScope); + if (!_tenantMemory.TryGetValue(key, out var record)) + { + return; + } + + lock (record.SyncRoot) + { + record.Lists.TryRemove(NormalizeCategory(listName), out _); + } + } + private static string BuildTenantKey(PersonalMemoryTenantScope tenantScope) { return $"{tenantScope.AccountId}|{tenantScope.LoopId}|{tenantScope.DeviceId}"; @@ -109,5 +163,7 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore public ConcurrentDictionary Preferences { get; } = new(StringComparer.OrdinalIgnoreCase); public ConcurrentDictionary ImportantDates { get; } = new(StringComparer.OrdinalIgnoreCase); public ConcurrentDictionary Affinities { get; } = new(StringComparer.OrdinalIgnoreCase); + public ConcurrentDictionary> Lists { get; } = new(StringComparer.OrdinalIgnoreCase); + public object SyncRoot { get; } = new(); } } diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Content/LegacyMimCatalogImporterTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Content/LegacyMimCatalogImporterTests.cs index 1b9c05e..2342db8 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/Content/LegacyMimCatalogImporterTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Content/LegacyMimCatalogImporterTests.cs @@ -31,6 +31,49 @@ public sealed class LegacyMimCatalogImporterTests } } + [Fact] + public void ImportCatalog_MapsGqaResponsesIntoEmotionBucket() + { + var rootDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(rootDirectory, "gqa-responses")); + + try + { + File.WriteAllText( + Path.Combine(rootDirectory, "gqa-responses", "GQA_JBO_IsHappy.mim"), + """ + { + "mim_type": "announcement", + "prompts": [ + { + "condition": "jibo.emotion==\"JOYFUL\"", + "prompt": "GQA joyful reply.", + "prompt_id": "GQA_JBO_IsHappy_AN_01" + }, + { + "condition": "!jibo.emotion || jibo.emotion==\"NEUTRAL\"", + "prompt": "GQA neutral reply.", + "prompt_id": "GQA_JBO_IsHappy_AN_02" + } + ] + } + """); + + var catalog = LegacyMimCatalogImporter.ImportCatalog(rootDirectory); + + Assert.Contains(catalog.EmotionReplies, reply => + string.Equals(reply.Reply, "GQA joyful reply.", StringComparison.Ordinal)); + Assert.Contains(catalog.EmotionReplies, reply => + string.Equals(reply.Reply, "GQA neutral reply.", StringComparison.Ordinal)); + Assert.DoesNotContain(catalog.HowAreYouReplies, reply => + reply.Contains("GQA", StringComparison.OrdinalIgnoreCase)); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + [Fact] public void MergeInto_PreservesExistingCatalogAndAddsImportedContent() { diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs index 355b584..d11400b 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs @@ -17,6 +17,8 @@ public sealed class JiboInteractionServiceTests private const string PersonalReportCalendarEnabledKey = "personalReportCalendarEnabled"; private const string PersonalReportCommuteEnabledKey = "personalReportCommuteEnabled"; private const string PersonalReportNewsEnabledKey = "personalReportNewsEnabled"; + private const string HouseholdListStateKey = "householdListState"; + private const string HouseholdListTypeKey = "householdListType"; private const string ChitchatStateKey = "chitchatState"; private const string ChitchatRouteKey = "chitchatRoute"; private const string ChitchatEmotionKey = "chitchatEmotion"; @@ -346,6 +348,56 @@ public sealed class JiboInteractionServiceTests Assert.Equal("EmotionQuery", decision.ContextUpdates![ChitchatRouteKey]); } + [Fact] + public async Task BuildDecisionAsync_AreYouHappy_UsesNonBuildAEmotionCatalog() + { + var rootDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(rootDirectory, "gqa-responses")); + + try + { + File.WriteAllText( + Path.Combine(rootDirectory, "gqa-responses", "GQA_JBO_IsHappy.mim"), + """ + { + "mim_type": "announcement", + "prompts": [ + { + "condition": "jibo.emotion==\"JOYFUL\"", + "prompt": "The outside pack says I'm feeling joyful.", + "prompt_id": "GQA_JBO_IsHappy_AN_01" + }, + { + "condition": "!jibo.emotion || jibo.emotion==\"NEUTRAL\"", + "prompt": "The outside pack says I'm on neutral.", + "prompt_id": "GQA_JBO_IsHappy_AN_02" + } + ] + } + """); + + var catalog = LegacyMimCatalogImporter.ImportCatalog(rootDirectory); + var service = CreateService(contentRepository: new StaticCatalogRepository(catalog)); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "how are you", + NormalizedTranscript = "how are you", + Attributes = new Dictionary + { + [ChitchatEmotionKey] = "joyful" + } + }); + + Assert.Equal("how_are_you", decision.IntentName); + Assert.Equal("The outside pack says I'm feeling joyful.", decision.ReplyText); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + [Theory] [InlineData("joyful", "Yes indeed. Never been better.")] [InlineData("pleased", "You know it. Life is good.")] @@ -1419,6 +1471,157 @@ public sealed class JiboInteractionServiceTests Assert.Equal(true, decision.ContextUpdates[PersonalReportCommuteEnabledKey]); } + [Theory] + [InlineData("shopping list", "shopping_list_prompt", "What should I add to your shopping list?", "shopping")] + [InlineData("to do list", "todo_list_prompt", "What should I add to your to-do list?", "todo")] + public async Task BuildDecisionAsync_ListStart_PromptsForFollowUpItems( + string transcript, + string expectedIntent, + string expectedReply, + string expectedListType) + { + 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.NotNull(decision.ContextUpdates); + Assert.Equal("awaiting_item", decision.ContextUpdates![HouseholdListStateKey]); + Assert.Equal(expectedListType, decision.ContextUpdates[HouseholdListTypeKey]); + } + + [Fact] + public async Task BuildDecisionAsync_ShoppingList_FollowUpFlow_AddsItemsAndRecallsThem() + { + var memoryStore = new InMemoryPersonalMemoryStore(); + var service = CreateService(memoryStore); + var tenantAttributes = new Dictionary + { + ["accountId"] = "acct-a", + ["loopId"] = "loop-a" + }; + + var promptDecision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "shopping list", + NormalizedTranscript = "shopping list", + DeviceId = "device-a", + Attributes = new Dictionary(tenantAttributes) + }); + + Assert.Equal("shopping_list_prompt", promptDecision.IntentName); + Assert.Equal("awaiting_item", promptDecision.ContextUpdates![HouseholdListStateKey]); + Assert.Equal("shopping", promptDecision.ContextUpdates[HouseholdListTypeKey]); + + var addDecision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "milk", + NormalizedTranscript = "milk", + DeviceId = "device-a", + Attributes = new Dictionary(tenantAttributes) + { + [HouseholdListStateKey] = promptDecision.ContextUpdates[HouseholdListStateKey], + [HouseholdListTypeKey] = promptDecision.ContextUpdates[HouseholdListTypeKey] + } + }); + + Assert.Equal("shopping_list_add", addDecision.IntentName); + Assert.Contains("Added milk to your shopping list.", addDecision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("What else should I add?", addDecision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Equal("awaiting_item", addDecision.ContextUpdates![HouseholdListStateKey]); + Assert.Equal("shopping", addDecision.ContextUpdates[HouseholdListTypeKey]); + Assert.Equal(["milk"], memoryStore.GetListItems(new PersonalMemoryTenantScope("acct-a", "loop-a", "device-a"), "shopping")); + + var doneDecision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "that's it", + NormalizedTranscript = "that's it", + DeviceId = "device-a", + Attributes = new Dictionary(tenantAttributes) + { + [HouseholdListStateKey] = addDecision.ContextUpdates[HouseholdListStateKey], + [HouseholdListTypeKey] = addDecision.ContextUpdates[HouseholdListTypeKey] + } + }); + + Assert.Equal("shopping_list_done", doneDecision.IntentName); + Assert.Contains("Okay. Your shopping list has milk.", doneDecision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Equal("idle", doneDecision.ContextUpdates![HouseholdListStateKey]); + + var recallDecision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "what's on my shopping list", + NormalizedTranscript = "what's on my shopping list", + DeviceId = "device-a", + Attributes = new Dictionary(tenantAttributes) + }); + + Assert.Equal("shopping_list_recall", recallDecision.IntentName); + Assert.Contains("milk", recallDecision.ReplyText, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task BuildDecisionAsync_TodoList_FollowUpFlow_AddsItemAndCanBeCompleted() + { + var memoryStore = new InMemoryPersonalMemoryStore(); + var service = CreateService(memoryStore); + var tenantAttributes = new Dictionary + { + ["accountId"] = "acct-b", + ["loopId"] = "loop-b" + }; + + var promptDecision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "to do list", + NormalizedTranscript = "to do list", + DeviceId = "device-b", + Attributes = new Dictionary(tenantAttributes) + }); + + Assert.Equal("todo_list_prompt", promptDecision.IntentName); + Assert.Equal("awaiting_item", promptDecision.ContextUpdates![HouseholdListStateKey]); + Assert.Equal("todo", promptDecision.ContextUpdates[HouseholdListTypeKey]); + + var addDecision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "call mom", + NormalizedTranscript = "call mom", + DeviceId = "device-b", + Attributes = new Dictionary(tenantAttributes) + { + [HouseholdListStateKey] = promptDecision.ContextUpdates[HouseholdListStateKey], + [HouseholdListTypeKey] = promptDecision.ContextUpdates[HouseholdListTypeKey] + } + }); + + Assert.Equal("todo_list_add", addDecision.IntentName); + Assert.Contains("call mom", addDecision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("What else should I add?", addDecision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Equal(["call mom"], memoryStore.GetListItems(new PersonalMemoryTenantScope("acct-b", "loop-b", "device-b"), "todo")); + + var doneDecision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "finished", + NormalizedTranscript = "finished", + DeviceId = "device-b", + Attributes = new Dictionary(tenantAttributes) + { + [HouseholdListStateKey] = addDecision.ContextUpdates![HouseholdListStateKey], + [HouseholdListTypeKey] = addDecision.ContextUpdates[HouseholdListTypeKey] + } + }); + + Assert.Equal("todo_list_done", doneDecision.IntentName); + Assert.Contains("Okay. Your to-do list has call mom.", doneDecision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Equal("idle", doneDecision.ContextUpdates![HouseholdListStateKey]); + } + [Fact] public async Task BuildDecisionAsync_WeatherQuery_WithoutProvider_UsesSpokenFallback() { @@ -3206,10 +3409,11 @@ public sealed class JiboInteractionServiceTests private static JiboInteractionService CreateService( IPersonalMemoryStore? personalMemoryStore = null, IWeatherReportProvider? weatherReportProvider = null, - INewsBriefingProvider? newsBriefingProvider = null) + INewsBriefingProvider? newsBriefingProvider = null, + IJiboExperienceContentRepository? contentRepository = null) { return new JiboInteractionService( - new JiboExperienceContentCache(new InMemoryJiboExperienceContentRepository()), + new JiboExperienceContentCache(contentRepository ?? new InMemoryJiboExperienceContentRepository()), new FirstItemRandomizer(), personalMemoryStore ?? new InMemoryPersonalMemoryStore(), weatherReportProvider, @@ -3255,4 +3459,12 @@ public sealed class JiboInteractionServiceTests return Task.FromResult(Snapshot); } } + + private sealed class StaticCatalogRepository(JiboExperienceCatalog catalog) : IJiboExperienceContentRepository + { + public Task GetCatalogAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(catalog); + } + } }