diff --git a/OpenJibo/docs/feature-backlog.md b/OpenJibo/docs/feature-backlog.md index ce35603..ba45043 100644 --- a/OpenJibo/docs/feature-backlog.md +++ b/OpenJibo/docs/feature-backlog.md @@ -770,7 +770,7 @@ Current release theme: ### 28. Grocery List Capability (Requested Feature) -- Status: `discovery` +- Status: `in_progress` - Tags: `content`, `docs`, `storage` - Why now: - directly requested by Jibo owners and fits memory + household utility roadmap @@ -779,13 +779,14 @@ Current release theme: - examples: - `C:\Projects\jibo\pegasus\packages\chitchat-skill\mims\scripted-responses\RA_JBO_ShoppingList.mim` - `C:\Projects\jibo\pegasus\packages\chitchat-skill\mims\scripted-responses\RA_JBO_ManageToDoList.mim` -- Candidate delivery paths: - - native lightweight list skill (fastest user value) - - integration-backed list orchestration (long-term richer ecosystem fit) +- MVP decision: + - use the existing household list engine as the native lightweight grocery MVP + - keep grocery as a first-class spoken alias over the shopping list storage path + - reserve integration-backed list orchestration for a later discovery pass - Exit criteria: - - clear decision on MVP path - - first schema for list items + ownership scope - - initial voice flows and follow-up intent handling defined + - grocery prompts, add/recall/done flows, and list follow-ups consistently speak grocery wording + - existing shopping/to-do flows remain unchanged + - future integration-backed list work remains a separate backlog item ### 29. Legacy MIM Personality Import Ladder diff --git a/OpenJibo/docs/release-1.0.19-plan.md b/OpenJibo/docs/release-1.0.19-plan.md index 0e44685..3d86066 100644 --- a/OpenJibo/docs/release-1.0.19-plan.md +++ b/OpenJibo/docs/release-1.0.19-plan.md @@ -6,6 +6,8 @@ This release starts the shift from `1.0.18` hardening to visible feature growth. The goal is to keep compatibility work steady while shipping personality and capability slices that make OpenJibo feel less like a placeholder cloud and more like a real assistant platform. +For grocery list capability, the 1.0.19 MVP choice is the existing household list engine with grocery as a first-class spoken alias. That keeps the storage model simple now while leaving integration-backed list orchestration for a later pass. + ## Snapshot - Kickoff date: `2026-05-05` 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 index c5fcdae..db9585e 100644 --- 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 @@ -7,11 +7,15 @@ internal static class HouseholdListOrchestrator { internal const string StateMetadataKey = "householdListState"; internal const string TypeMetadataKey = "householdListType"; + internal const string DisplayTypeMetadataKey = "householdListDisplayType"; internal const string NoMatchCountMetadataKey = "householdListNoMatchCount"; internal const string NoInputCountMetadataKey = "householdListNoInputCount"; private const string IdleState = "idle"; private const string AwaitingItemState = "awaiting_item"; + private const string ShoppingListType = "shopping"; + private const string GroceryListType = "grocery"; + private const string TodoListType = "todo"; private static readonly string[] ItemPrefixes = [ @@ -31,6 +35,10 @@ internal static class HouseholdListOrchestrator " to my shopping list", " to the shopping list", " on my shopping list", + " to my grocery list", + " to the grocery list", + " on my grocery list", + " my grocery list", " to my to do list", " to the to do list", " on my to do list", @@ -50,6 +58,7 @@ internal static class HouseholdListOrchestrator { var state = ReadString(turn, StateMetadataKey); var listType = ReadString(turn, TypeMetadataKey); + var displayType = ReadString(turn, DisplayTypeMetadataKey); var isActiveState = !string.IsNullOrWhiteSpace(state) && !string.Equals(state, IdleState, StringComparison.OrdinalIgnoreCase); var isShoppingIntent = string.Equals(semanticIntent, "shopping_list", StringComparison.OrdinalIgnoreCase); @@ -58,17 +67,19 @@ internal static class HouseholdListOrchestrator if (!isActiveState && !isShoppingIntent && !isTodoIntent) return Task.FromResult(null); - var resolvedListType = isShoppingIntent ? "shopping" : isTodoIntent ? "todo" : NormalizeListType(listType); - if (string.IsNullOrWhiteSpace(resolvedListType)) resolvedListType = "shopping"; + var resolvedListType = isShoppingIntent ? ShoppingListType : isTodoIntent ? TodoListType : NormalizeListType(listType); + if (string.IsNullOrWhiteSpace(resolvedListType)) resolvedListType = ShoppingListType; + var resolvedDisplayType = ResolveDisplayType(resolvedListType, displayType, isActiveState, loweredTranscript); var tenantScope = tenantScopeResolver(turn); if (ContainsAny(loweredTranscript, "cancel", "stop", "never mind", "nevermind", "forget it")) - return Task.FromResult(BuildCancelledDecision(resolvedListType)); + return Task.FromResult(BuildCancelledDecision(resolvedListType, resolvedDisplayType)); if (IsRecallRequest(loweredTranscript)) return Task.FromResult(BuildRecallDecision( resolvedListType, + resolvedDisplayType, personalMemoryStore.GetListItems(tenantScope, resolvedListType))); var directItem = TryExtractListItem(loweredTranscript); @@ -76,9 +87,9 @@ internal static class HouseholdListOrchestrator { 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))); + BuildListIntentName(resolvedListType, "done"), + BuildDoneReply(resolvedDisplayType, personalMemoryStore.GetListItems(tenantScope, resolvedListType)), + ContextUpdates: BuildContextUpdates(resolvedListType, resolvedDisplayType, IdleState))); directItem = NormalizeItem(transcript); } @@ -87,104 +98,108 @@ internal static class HouseholdListOrchestrator { personalMemoryStore.AddListItem(tenantScope, resolvedListType, directItem); return Task.FromResult(new JiboInteractionDecision( - resolvedListType == "shopping" ? "shopping_list_add" : "todo_list_add", - BuildAddedReply(resolvedListType, directItem, + BuildListIntentName(resolvedListType, "add"), + BuildAddedReply(resolvedDisplayType, directItem, personalMemoryStore.GetListItems(tenantScope, resolvedListType)), - ContextUpdates: BuildContextUpdates(resolvedListType, AwaitingItemState))); + ContextUpdates: BuildContextUpdates(resolvedListType, resolvedDisplayType, AwaitingItemState))); } if (string.IsNullOrWhiteSpace(transcript)) return Task.FromResult(new JiboInteractionDecision( - resolvedListType == "shopping" ? "shopping_list_prompt" : "todo_list_prompt", - BuildPromptReply(resolvedListType), - ContextUpdates: BuildContextUpdates(resolvedListType, AwaitingItemState))); + BuildListIntentName(resolvedListType, "prompt"), + BuildPromptReply(resolvedDisplayType), + ContextUpdates: BuildContextUpdates(resolvedListType, resolvedDisplayType, AwaitingItemState))); return Task.FromResult(new JiboInteractionDecision( - resolvedListType == "shopping" ? "shopping_list_prompt" : "todo_list_prompt", - BuildPromptReply(resolvedListType), - ContextUpdates: BuildContextUpdates(resolvedListType, AwaitingItemState))); + BuildListIntentName(resolvedListType, "prompt"), + BuildPromptReply(resolvedDisplayType), + ContextUpdates: BuildContextUpdates(resolvedListType, resolvedDisplayType, AwaitingItemState))); } - private static IDictionary BuildContextUpdates(string listType, string state) + private static IDictionary BuildContextUpdates(string listType, string displayType, string state) { return new Dictionary(StringComparer.OrdinalIgnoreCase) { [StateMetadataKey] = state, [TypeMetadataKey] = listType, + [DisplayTypeMetadataKey] = displayType, [NoMatchCountMetadataKey] = 0, [NoInputCountMetadataKey] = 0 }; } - private static JiboInteractionDecision BuildCancelledDecision(string listType) + private static JiboInteractionDecision BuildCancelledDecision(string listType, string displayType) { 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.", + BuildListIntentName(listType, "cancel"), + $"Okay. I stopped the {BuildListLabel(displayType)}.", ContextUpdates: new Dictionary(StringComparer.OrdinalIgnoreCase) { [StateMetadataKey] = IdleState, [TypeMetadataKey] = listType, + [DisplayTypeMetadataKey] = displayType, [NoMatchCountMetadataKey] = 0, [NoInputCountMetadataKey] = 0 }); } - private static JiboInteractionDecision BuildRecallDecision(string listType, IReadOnlyList items) + private static JiboInteractionDecision BuildRecallDecision(string listType, string displayType, 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.", + BuildListIntentName(listType, "recall"), + $"Your {BuildListLabel(displayType)} is empty.", ContextUpdates: new Dictionary(StringComparer.OrdinalIgnoreCase) { [StateMetadataKey] = IdleState, [TypeMetadataKey] = listType, + [DisplayTypeMetadataKey] = displayType, [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)}.", + BuildListIntentName(listType, "recall"), + $"Your {BuildListLabel(displayType)} has {JoinList(items)}.", ContextUpdates: new Dictionary(StringComparer.OrdinalIgnoreCase) { [StateMetadataKey] = IdleState, [TypeMetadataKey] = listType, + [DisplayTypeMetadataKey] = displayType, [NoMatchCountMetadataKey] = 0, [NoInputCountMetadataKey] = 0 }); } - private static string BuildAddedReply(string listType, string addedItem, IReadOnlyList items) + private static string BuildAddedReply(string displayType, string addedItem, IReadOnlyList items) { - var itemLabel = listType == "shopping" ? "shopping list" : "to-do list"; + var itemLabel = BuildListLabel(displayType); 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) + private static string BuildPromptReply(string displayType) { - return listType == "shopping" - ? "What should I add to your shopping list?" - : "What should I add to your to-do list?"; + return $"What should I add to your {BuildListLabel(displayType)}?"; } - private static string BuildDoneReply(string listType, IReadOnlyList items) + private static string BuildDoneReply(string displayType, IReadOnlyList items) { if (items.Count == 0) - return listType == "shopping" - ? "Okay. Your shopping list is empty." - : "Okay. Your to-do list is empty."; + return $"Okay. Your {BuildListLabel(displayType)} is empty."; - return listType == "shopping" - ? $"Okay. Your shopping list has {JoinList(items)}." - : $"Okay. Your to-do list has {JoinList(items)}."; + return $"Okay. Your {BuildListLabel(displayType)} has {JoinList(items)}."; + } + + private static string BuildListLabel(string displayType) + { + return NormalizeDisplayType(displayType) switch + { + GroceryListType => "grocery list", + TodoListType => "to-do list", + _ => "shopping list" + }; } private static string JoinList(IReadOnlyList items) @@ -205,7 +220,13 @@ internal static class HouseholdListOrchestrator if (!loweredTranscript.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue; var remainder = loweredTranscript[prefix.Length..].Trim(); + if (IsListOnlyRemainder(remainder)) + return null; + remainder = TrimTrailingListPhrases(remainder); + if (IsListOnlyRemainder(remainder)) + return null; + return NormalizeItem(remainder); } @@ -218,6 +239,9 @@ internal static class HouseholdListOrchestrator "what is on my shopping list", "what's on my shopping list", "show my shopping list", + "what is on my grocery list", + "what's on my grocery list", + "show my grocery list", "what is on my to do list", "what's on my to do list", "show my to do list", @@ -246,13 +270,96 @@ internal static class HouseholdListOrchestrator var normalized = NormalizeItem(listType ?? string.Empty).ToLowerInvariant(); return normalized.Contains("todo", StringComparison.OrdinalIgnoreCase) || normalized.Contains("to do", StringComparison.OrdinalIgnoreCase) - ? "todo" + ? TodoListType : normalized.Contains("shopping", StringComparison.OrdinalIgnoreCase) || normalized.Contains("grocery", StringComparison.OrdinalIgnoreCase) - ? "shopping" + ? ShoppingListType : string.Empty; } + private static string ResolveDisplayType(string listType, string? storedDisplayType, bool isActiveState, string loweredTranscript) + { + var transcriptDisplayType = InferDisplayTypeFromTranscript(loweredTranscript); + var normalizedStoredDisplayType = NormalizeDisplayType(storedDisplayType); + + if (isActiveState && !string.IsNullOrWhiteSpace(normalizedStoredDisplayType)) + return normalizedStoredDisplayType; + + if (!string.IsNullOrWhiteSpace(transcriptDisplayType)) + return transcriptDisplayType; + + if (!string.IsNullOrWhiteSpace(normalizedStoredDisplayType)) + return normalizedStoredDisplayType; + + return string.Equals(listType, TodoListType, StringComparison.OrdinalIgnoreCase) + ? TodoListType + : ShoppingListType; + } + + private static string InferDisplayTypeFromTranscript(string loweredTranscript) + { + if (loweredTranscript.Contains("grocery", StringComparison.OrdinalIgnoreCase)) + return GroceryListType; + + if (loweredTranscript.Contains("to do", StringComparison.OrdinalIgnoreCase) || + loweredTranscript.Contains("todo", StringComparison.OrdinalIgnoreCase) || + loweredTranscript.Contains("task", StringComparison.OrdinalIgnoreCase)) + { + return TodoListType; + } + + if (loweredTranscript.Contains("shopping", StringComparison.OrdinalIgnoreCase)) + return ShoppingListType; + + return string.Empty; + } + + private static string NormalizeDisplayType(string? displayType) + { + var normalized = NormalizeItem(displayType ?? string.Empty).ToLowerInvariant(); + return normalized.Contains("grocery", StringComparison.OrdinalIgnoreCase) + ? GroceryListType + : normalized.Contains("todo", StringComparison.OrdinalIgnoreCase) || + normalized.Contains("to do", StringComparison.OrdinalIgnoreCase) + ? TodoListType + : normalized.Contains("shopping", StringComparison.OrdinalIgnoreCase) + ? ShoppingListType + : string.Empty; + } + + private static string BuildListIntentName(string listType, string action) + { + var normalizedListType = string.Equals(listType, TodoListType, StringComparison.OrdinalIgnoreCase) + ? TodoListType + : ShoppingListType; + return $"{normalizedListType}_list_{action}"; + } + + private static bool IsListOnlyRemainder(string value) + { + var normalized = NormalizeItem(value).ToLowerInvariant(); + return normalized is "shopping list" or + "grocery list" or + "to do list" or + "todo list" or + "my shopping list" or + "my grocery list" or + "my to do list" or + "my todo list" or + "to my shopping list" or + "to my grocery list" or + "to my to do list" or + "to my todo list" or + "to the shopping list" or + "to the grocery list" or + "to the to do list" or + "to the todo list" or + "on my shopping list" or + "on my grocery list" or + "on my to do list" or + "on my todo list"; + } + private static bool ContainsAny(string loweredTranscript, params string[] phrases) { return phrases.Any(phrase => loweredTranscript.Contains(phrase, StringComparison.OrdinalIgnoreCase)); @@ -274,4 +381,4 @@ internal static class HouseholdListOrchestrator { return turn.Attributes.TryGetValue(key, out var value) ? value?.ToString() : null; } -} \ No newline at end of file +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.IntentRouting.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.IntentRouting.cs index 12f8cd0..e90d1ac 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.IntentRouting.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.IntentRouting.cs @@ -780,13 +780,19 @@ public sealed partial class JiboInteractionService loweredTranscript, "shopping list", "grocery list", + "my grocery list", + "create grocery list", + "start grocery list", "to do list", "todo list", "add to my shopping list", + "add to my grocery 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 grocery list", + "what is on my grocery list", "what's on my to do list", "what is on my to do list", "what are my tasks", diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs index b0532ec..605436b 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs @@ -23,6 +23,7 @@ public sealed class JiboInteractionServiceTests private const string PersonalReportNewsEnabledKey = "personalReportNewsEnabled"; private const string HouseholdListStateKey = "householdListState"; private const string HouseholdListTypeKey = "householdListType"; + private const string HouseholdListDisplayTypeKey = "householdListDisplayType"; private const string ChitchatStateKey = "chitchatState"; private const string ChitchatRouteKey = "chitchatRoute"; private const string ChitchatEmotionKey = "chitchatEmotion"; @@ -2285,13 +2286,17 @@ public sealed class JiboInteractionServiceTests } [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")] + [InlineData("shopping list", "shopping_list_prompt", "What should I add to your shopping list?", "shopping", "shopping")] + [InlineData("grocery list", "shopping_list_prompt", "What should I add to your grocery list?", "shopping", "grocery")] + [InlineData("my grocery list", "shopping_list_prompt", "What should I add to your grocery list?", "shopping", "grocery")] + [InlineData("create grocery list", "shopping_list_prompt", "What should I add to your grocery list?", "shopping", "grocery")] + [InlineData("to do list", "todo_list_prompt", "What should I add to your to-do list?", "todo", "todo")] public async Task BuildDecisionAsync_ListStart_PromptsForFollowUpItems( string transcript, string expectedIntent, string expectedReply, - string expectedListType) + string expectedListType, + string expectedDisplayType) { var service = CreateService(); @@ -2306,6 +2311,7 @@ public sealed class JiboInteractionServiceTests Assert.NotNull(decision.ContextUpdates); Assert.Equal("awaiting_item", decision.ContextUpdates![HouseholdListStateKey]); Assert.Equal(expectedListType, decision.ContextUpdates[HouseholdListTypeKey]); + Assert.Equal(expectedDisplayType, decision.ContextUpdates[HouseholdListDisplayTypeKey]); } [Fact] @@ -2330,6 +2336,7 @@ public sealed class JiboInteractionServiceTests Assert.Equal("shopping_list_prompt", promptDecision.IntentName); Assert.Equal("awaiting_item", promptDecision.ContextUpdates![HouseholdListStateKey]); Assert.Equal("shopping", promptDecision.ContextUpdates[HouseholdListTypeKey]); + Assert.Equal("shopping", promptDecision.ContextUpdates[HouseholdListDisplayTypeKey]); var addDecision = await service.BuildDecisionAsync(new TurnContext { @@ -2339,7 +2346,8 @@ public sealed class JiboInteractionServiceTests Attributes = new Dictionary(tenantAttributes) { [HouseholdListStateKey] = promptDecision.ContextUpdates[HouseholdListStateKey], - [HouseholdListTypeKey] = promptDecision.ContextUpdates[HouseholdListTypeKey] + [HouseholdListTypeKey] = promptDecision.ContextUpdates[HouseholdListTypeKey], + [HouseholdListDisplayTypeKey] = promptDecision.ContextUpdates[HouseholdListDisplayTypeKey] } }); @@ -2348,6 +2356,7 @@ public sealed class JiboInteractionServiceTests 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("shopping", addDecision.ContextUpdates[HouseholdListDisplayTypeKey]); Assert.Equal(["milk"], memoryStore.GetListItems(new PersonalMemoryTenantScope("acct-a", "loop-a", "device-a"), "shopping")); @@ -2359,7 +2368,8 @@ public sealed class JiboInteractionServiceTests Attributes = new Dictionary(tenantAttributes) { [HouseholdListStateKey] = addDecision.ContextUpdates[HouseholdListStateKey], - [HouseholdListTypeKey] = addDecision.ContextUpdates[HouseholdListTypeKey] + [HouseholdListTypeKey] = addDecision.ContextUpdates[HouseholdListTypeKey], + [HouseholdListDisplayTypeKey] = addDecision.ContextUpdates[HouseholdListDisplayTypeKey] } }); @@ -2367,6 +2377,7 @@ public sealed class JiboInteractionServiceTests Assert.Contains("Okay. Your shopping list has milk.", doneDecision.ReplyText, StringComparison.OrdinalIgnoreCase); Assert.Equal("idle", doneDecision.ContextUpdates![HouseholdListStateKey]); + Assert.Equal("shopping", doneDecision.ContextUpdates[HouseholdListDisplayTypeKey]); var recallDecision = await service.BuildDecisionAsync(new TurnContext { @@ -2378,6 +2389,134 @@ public sealed class JiboInteractionServiceTests Assert.Equal("shopping_list_recall", recallDecision.IntentName); Assert.Contains("milk", recallDecision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("shopping list", recallDecision.ReplyText, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task BuildDecisionAsync_GroceryList_DirectAddAndRecallVariants_UseGroceryWording() + { + var memoryStore = new InMemoryPersonalMemoryStore(); + var service = CreateService(memoryStore); + var tenantAttributes = new Dictionary + { + ["accountId"] = "acct-d", + ["loopId"] = "loop-d" + }; + + var addStartDecision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "add to my grocery list", + NormalizedTranscript = "add to my grocery list", + DeviceId = "device-d", + Attributes = new Dictionary(tenantAttributes) + }); + + Assert.Equal("shopping_list_prompt", addStartDecision.IntentName); + Assert.Equal("grocery", addStartDecision.ContextUpdates![HouseholdListDisplayTypeKey]); + Assert.Equal("What should I add to your grocery list?", addStartDecision.ReplyText); + + var addDecision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "apples", + NormalizedTranscript = "apples", + DeviceId = "device-d", + Attributes = new Dictionary(tenantAttributes) + { + [HouseholdListStateKey] = addStartDecision.ContextUpdates[HouseholdListStateKey], + [HouseholdListTypeKey] = addStartDecision.ContextUpdates[HouseholdListTypeKey], + [HouseholdListDisplayTypeKey] = addStartDecision.ContextUpdates[HouseholdListDisplayTypeKey] + } + }); + + Assert.Equal("shopping_list_add", addDecision.IntentName); + Assert.Contains("Added apples to your grocery list.", addDecision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Equal(["apples"], + memoryStore.GetListItems(new PersonalMemoryTenantScope("acct-d", "loop-d", "device-d"), "shopping")); + + var recallDecision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "what is on my grocery list", + NormalizedTranscript = "what is on my grocery list", + DeviceId = "device-d", + Attributes = new Dictionary(tenantAttributes) + }); + + Assert.Equal("shopping_list_recall", recallDecision.IntentName); + Assert.Contains("apples", recallDecision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("grocery list", recallDecision.ReplyText, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task BuildDecisionAsync_GroceryList_FollowUpFlow_UsesGroceryWordingAndShoppingStorage() + { + var memoryStore = new InMemoryPersonalMemoryStore(); + var service = CreateService(memoryStore); + var tenantAttributes = new Dictionary + { + ["accountId"] = "acct-c", + ["loopId"] = "loop-c" + }; + + var promptDecision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "grocery list", + NormalizedTranscript = "grocery list", + DeviceId = "device-c", + Attributes = new Dictionary(tenantAttributes) + }); + + Assert.Equal("shopping_list_prompt", promptDecision.IntentName); + Assert.Equal("awaiting_item", promptDecision.ContextUpdates![HouseholdListStateKey]); + Assert.Equal("shopping", promptDecision.ContextUpdates[HouseholdListTypeKey]); + Assert.Equal("grocery", promptDecision.ContextUpdates[HouseholdListDisplayTypeKey]); + Assert.Equal("What should I add to your grocery list?", promptDecision.ReplyText); + + var addDecision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "milk", + NormalizedTranscript = "milk", + DeviceId = "device-c", + Attributes = new Dictionary(tenantAttributes) + { + [HouseholdListStateKey] = promptDecision.ContextUpdates[HouseholdListStateKey], + [HouseholdListTypeKey] = promptDecision.ContextUpdates[HouseholdListTypeKey], + [HouseholdListDisplayTypeKey] = promptDecision.ContextUpdates[HouseholdListDisplayTypeKey] + } + }); + + Assert.Equal("shopping_list_add", addDecision.IntentName); + Assert.Contains("Added milk to your grocery list.", addDecision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("What else should I add?", addDecision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Equal(["milk"], + memoryStore.GetListItems(new PersonalMemoryTenantScope("acct-c", "loop-c", "device-c"), "shopping")); + + var doneDecision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "that's it", + NormalizedTranscript = "that's it", + DeviceId = "device-c", + Attributes = new Dictionary(tenantAttributes) + { + [HouseholdListStateKey] = addDecision.ContextUpdates![HouseholdListStateKey], + [HouseholdListTypeKey] = addDecision.ContextUpdates[HouseholdListTypeKey], + [HouseholdListDisplayTypeKey] = addDecision.ContextUpdates[HouseholdListDisplayTypeKey] + } + }); + + Assert.Equal("shopping_list_done", doneDecision.IntentName); + Assert.Contains("Okay. Your grocery list has milk.", doneDecision.ReplyText, StringComparison.OrdinalIgnoreCase); + + var recallDecision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "what's on my grocery list", + NormalizedTranscript = "what's on my grocery list", + DeviceId = "device-c", + Attributes = new Dictionary(tenantAttributes) + }); + + Assert.Equal("shopping_list_recall", recallDecision.IntentName); + Assert.Contains("milk", recallDecision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("grocery list", recallDecision.ReplyText, StringComparison.OrdinalIgnoreCase); } [Fact]