Polish grocery list alias wording and backlog MVP decision

This commit is contained in:
Jacob Dubin
2026-05-21 17:00:29 -05:00
parent acdc6da286
commit eeef2b3beb
5 changed files with 311 additions and 56 deletions

View File

@@ -770,7 +770,7 @@ Current release theme:
### 28. Grocery List Capability (Requested Feature) ### 28. Grocery List Capability (Requested Feature)
- Status: `discovery` - Status: `in_progress`
- Tags: `content`, `docs`, `storage` - Tags: `content`, `docs`, `storage`
- Why now: - Why now:
- directly requested by Jibo owners and fits memory + household utility roadmap - directly requested by Jibo owners and fits memory + household utility roadmap
@@ -779,13 +779,14 @@ Current release theme:
- examples: - 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_ShoppingList.mim`
- `C:\Projects\jibo\pegasus\packages\chitchat-skill\mims\scripted-responses\RA_JBO_ManageToDoList.mim` - `C:\Projects\jibo\pegasus\packages\chitchat-skill\mims\scripted-responses\RA_JBO_ManageToDoList.mim`
- Candidate delivery paths: - MVP decision:
- native lightweight list skill (fastest user value) - use the existing household list engine as the native lightweight grocery MVP
- integration-backed list orchestration (long-term richer ecosystem fit) - 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: - Exit criteria:
- clear decision on MVP path - grocery prompts, add/recall/done flows, and list follow-ups consistently speak grocery wording
- first schema for list items + ownership scope - existing shopping/to-do flows remain unchanged
- initial voice flows and follow-up intent handling defined - future integration-backed list work remains a separate backlog item
### 29. Legacy MIM Personality Import Ladder ### 29. Legacy MIM Personality Import Ladder

View File

@@ -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. 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 ## Snapshot
- Kickoff date: `2026-05-05` - Kickoff date: `2026-05-05`

View File

@@ -7,11 +7,15 @@ internal static class HouseholdListOrchestrator
{ {
internal const string StateMetadataKey = "householdListState"; internal const string StateMetadataKey = "householdListState";
internal const string TypeMetadataKey = "householdListType"; internal const string TypeMetadataKey = "householdListType";
internal const string DisplayTypeMetadataKey = "householdListDisplayType";
internal const string NoMatchCountMetadataKey = "householdListNoMatchCount"; internal const string NoMatchCountMetadataKey = "householdListNoMatchCount";
internal const string NoInputCountMetadataKey = "householdListNoInputCount"; internal const string NoInputCountMetadataKey = "householdListNoInputCount";
private const string IdleState = "idle"; private const string IdleState = "idle";
private const string AwaitingItemState = "awaiting_item"; 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 = private static readonly string[] ItemPrefixes =
[ [
@@ -31,6 +35,10 @@ internal static class HouseholdListOrchestrator
" to my shopping list", " to my shopping list",
" to the shopping list", " to the shopping list",
" on my 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 my to do list",
" to the to do list", " to the to do list",
" on my to do list", " on my to do list",
@@ -50,6 +58,7 @@ internal static class HouseholdListOrchestrator
{ {
var state = ReadString(turn, StateMetadataKey); var state = ReadString(turn, StateMetadataKey);
var listType = ReadString(turn, TypeMetadataKey); var listType = ReadString(turn, TypeMetadataKey);
var displayType = ReadString(turn, DisplayTypeMetadataKey);
var isActiveState = !string.IsNullOrWhiteSpace(state) && var isActiveState = !string.IsNullOrWhiteSpace(state) &&
!string.Equals(state, IdleState, StringComparison.OrdinalIgnoreCase); !string.Equals(state, IdleState, StringComparison.OrdinalIgnoreCase);
var isShoppingIntent = string.Equals(semanticIntent, "shopping_list", StringComparison.OrdinalIgnoreCase); var isShoppingIntent = string.Equals(semanticIntent, "shopping_list", StringComparison.OrdinalIgnoreCase);
@@ -58,17 +67,19 @@ internal static class HouseholdListOrchestrator
if (!isActiveState && !isShoppingIntent && !isTodoIntent) if (!isActiveState && !isShoppingIntent && !isTodoIntent)
return Task.FromResult<JiboInteractionDecision?>(null); return Task.FromResult<JiboInteractionDecision?>(null);
var resolvedListType = isShoppingIntent ? "shopping" : isTodoIntent ? "todo" : NormalizeListType(listType); var resolvedListType = isShoppingIntent ? ShoppingListType : isTodoIntent ? TodoListType : NormalizeListType(listType);
if (string.IsNullOrWhiteSpace(resolvedListType)) resolvedListType = "shopping"; if (string.IsNullOrWhiteSpace(resolvedListType)) resolvedListType = ShoppingListType;
var resolvedDisplayType = ResolveDisplayType(resolvedListType, displayType, isActiveState, loweredTranscript);
var tenantScope = tenantScopeResolver(turn); var tenantScope = tenantScopeResolver(turn);
if (ContainsAny(loweredTranscript, "cancel", "stop", "never mind", "nevermind", "forget it")) if (ContainsAny(loweredTranscript, "cancel", "stop", "never mind", "nevermind", "forget it"))
return Task.FromResult<JiboInteractionDecision?>(BuildCancelledDecision(resolvedListType)); return Task.FromResult<JiboInteractionDecision?>(BuildCancelledDecision(resolvedListType, resolvedDisplayType));
if (IsRecallRequest(loweredTranscript)) if (IsRecallRequest(loweredTranscript))
return Task.FromResult<JiboInteractionDecision?>(BuildRecallDecision( return Task.FromResult<JiboInteractionDecision?>(BuildRecallDecision(
resolvedListType, resolvedListType,
resolvedDisplayType,
personalMemoryStore.GetListItems(tenantScope, resolvedListType))); personalMemoryStore.GetListItems(tenantScope, resolvedListType)));
var directItem = TryExtractListItem(loweredTranscript); var directItem = TryExtractListItem(loweredTranscript);
@@ -76,9 +87,9 @@ internal static class HouseholdListOrchestrator
{ {
if (IsConversationComplete(loweredTranscript)) if (IsConversationComplete(loweredTranscript))
return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision( return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision(
resolvedListType == "shopping" ? "shopping_list_done" : "todo_list_done", BuildListIntentName(resolvedListType, "done"),
BuildDoneReply(resolvedListType, personalMemoryStore.GetListItems(tenantScope, resolvedListType)), BuildDoneReply(resolvedDisplayType, personalMemoryStore.GetListItems(tenantScope, resolvedListType)),
ContextUpdates: BuildContextUpdates(resolvedListType, IdleState))); ContextUpdates: BuildContextUpdates(resolvedListType, resolvedDisplayType, IdleState)));
directItem = NormalizeItem(transcript); directItem = NormalizeItem(transcript);
} }
@@ -87,104 +98,108 @@ internal static class HouseholdListOrchestrator
{ {
personalMemoryStore.AddListItem(tenantScope, resolvedListType, directItem); personalMemoryStore.AddListItem(tenantScope, resolvedListType, directItem);
return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision( return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision(
resolvedListType == "shopping" ? "shopping_list_add" : "todo_list_add", BuildListIntentName(resolvedListType, "add"),
BuildAddedReply(resolvedListType, directItem, BuildAddedReply(resolvedDisplayType, directItem,
personalMemoryStore.GetListItems(tenantScope, resolvedListType)), personalMemoryStore.GetListItems(tenantScope, resolvedListType)),
ContextUpdates: BuildContextUpdates(resolvedListType, AwaitingItemState))); ContextUpdates: BuildContextUpdates(resolvedListType, resolvedDisplayType, AwaitingItemState)));
} }
if (string.IsNullOrWhiteSpace(transcript)) if (string.IsNullOrWhiteSpace(transcript))
return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision( return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision(
resolvedListType == "shopping" ? "shopping_list_prompt" : "todo_list_prompt", BuildListIntentName(resolvedListType, "prompt"),
BuildPromptReply(resolvedListType), BuildPromptReply(resolvedDisplayType),
ContextUpdates: BuildContextUpdates(resolvedListType, AwaitingItemState))); ContextUpdates: BuildContextUpdates(resolvedListType, resolvedDisplayType, AwaitingItemState)));
return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision( return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision(
resolvedListType == "shopping" ? "shopping_list_prompt" : "todo_list_prompt", BuildListIntentName(resolvedListType, "prompt"),
BuildPromptReply(resolvedListType), BuildPromptReply(resolvedDisplayType),
ContextUpdates: BuildContextUpdates(resolvedListType, AwaitingItemState))); ContextUpdates: BuildContextUpdates(resolvedListType, resolvedDisplayType, AwaitingItemState)));
} }
private static IDictionary<string, object?> BuildContextUpdates(string listType, string state) private static IDictionary<string, object?> BuildContextUpdates(string listType, string displayType, string state)
{ {
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase) return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{ {
[StateMetadataKey] = state, [StateMetadataKey] = state,
[TypeMetadataKey] = listType, [TypeMetadataKey] = listType,
[DisplayTypeMetadataKey] = displayType,
[NoMatchCountMetadataKey] = 0, [NoMatchCountMetadataKey] = 0,
[NoInputCountMetadataKey] = 0 [NoInputCountMetadataKey] = 0
}; };
} }
private static JiboInteractionDecision BuildCancelledDecision(string listType) private static JiboInteractionDecision BuildCancelledDecision(string listType, string displayType)
{ {
return new JiboInteractionDecision( return new JiboInteractionDecision(
listType == "shopping" ? "shopping_list_cancel" : "todo_list_cancel", BuildListIntentName(listType, "cancel"),
listType == "shopping" ? "Okay. I stopped the shopping list." : "Okay. I stopped the to-do list.", $"Okay. I stopped the {BuildListLabel(displayType)}.",
ContextUpdates: new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase) ContextUpdates: new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{ {
[StateMetadataKey] = IdleState, [StateMetadataKey] = IdleState,
[TypeMetadataKey] = listType, [TypeMetadataKey] = listType,
[DisplayTypeMetadataKey] = displayType,
[NoMatchCountMetadataKey] = 0, [NoMatchCountMetadataKey] = 0,
[NoInputCountMetadataKey] = 0 [NoInputCountMetadataKey] = 0
}); });
} }
private static JiboInteractionDecision BuildRecallDecision(string listType, IReadOnlyList<string> items) private static JiboInteractionDecision BuildRecallDecision(string listType, string displayType, IReadOnlyList<string> items)
{ {
if (items.Count == 0) if (items.Count == 0)
return new JiboInteractionDecision( return new JiboInteractionDecision(
listType == "shopping" ? "shopping_list_recall" : "todo_list_recall", BuildListIntentName(listType, "recall"),
listType == "shopping" $"Your {BuildListLabel(displayType)} is empty.",
? "Your shopping list is empty."
: "Your to-do list is empty.",
ContextUpdates: new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase) ContextUpdates: new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{ {
[StateMetadataKey] = IdleState, [StateMetadataKey] = IdleState,
[TypeMetadataKey] = listType, [TypeMetadataKey] = listType,
[DisplayTypeMetadataKey] = displayType,
[NoMatchCountMetadataKey] = 0, [NoMatchCountMetadataKey] = 0,
[NoInputCountMetadataKey] = 0 [NoInputCountMetadataKey] = 0
}); });
return new JiboInteractionDecision( return new JiboInteractionDecision(
listType == "shopping" ? "shopping_list_recall" : "todo_list_recall", BuildListIntentName(listType, "recall"),
listType == "shopping" $"Your {BuildListLabel(displayType)} has {JoinList(items)}.",
? $"Your shopping list has {JoinList(items)}."
: $"Your to-do list has {JoinList(items)}.",
ContextUpdates: new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase) ContextUpdates: new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{ {
[StateMetadataKey] = IdleState, [StateMetadataKey] = IdleState,
[TypeMetadataKey] = listType, [TypeMetadataKey] = listType,
[DisplayTypeMetadataKey] = displayType,
[NoMatchCountMetadataKey] = 0, [NoMatchCountMetadataKey] = 0,
[NoInputCountMetadataKey] = 0 [NoInputCountMetadataKey] = 0
}); });
} }
private static string BuildAddedReply(string listType, string addedItem, IReadOnlyList<string> items) private static string BuildAddedReply(string displayType, string addedItem, IReadOnlyList<string> items)
{ {
var itemLabel = listType == "shopping" ? "shopping list" : "to-do list"; var itemLabel = BuildListLabel(displayType);
return items.Count == 1 return items.Count == 1
? $"Added {addedItem} to your {itemLabel}. What else should I add?" ? $"Added {addedItem} to your {itemLabel}. What else should I add?"
: $"Added {addedItem} to your {itemLabel}. You now have {JoinList(items)}."; : $"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" return $"What should I add to your {BuildListLabel(displayType)}?";
? "What should I add to your shopping list?"
: "What should I add to your to-do list?";
} }
private static string BuildDoneReply(string listType, IReadOnlyList<string> items) private static string BuildDoneReply(string displayType, IReadOnlyList<string> items)
{ {
if (items.Count == 0) if (items.Count == 0)
return listType == "shopping" return $"Okay. Your {BuildListLabel(displayType)} is empty.";
? "Okay. Your shopping list is empty."
: "Okay. Your to-do list is empty.";
return listType == "shopping" return $"Okay. Your {BuildListLabel(displayType)} has {JoinList(items)}.";
? $"Okay. Your shopping list has {JoinList(items)}." }
: $"Okay. Your to-do list 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<string> items) private static string JoinList(IReadOnlyList<string> items)
@@ -205,7 +220,13 @@ internal static class HouseholdListOrchestrator
if (!loweredTranscript.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue; if (!loweredTranscript.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue;
var remainder = loweredTranscript[prefix.Length..].Trim(); var remainder = loweredTranscript[prefix.Length..].Trim();
if (IsListOnlyRemainder(remainder))
return null;
remainder = TrimTrailingListPhrases(remainder); remainder = TrimTrailingListPhrases(remainder);
if (IsListOnlyRemainder(remainder))
return null;
return NormalizeItem(remainder); return NormalizeItem(remainder);
} }
@@ -218,6 +239,9 @@ internal static class HouseholdListOrchestrator
"what is on my shopping list", "what is on my shopping list",
"what's on my shopping list", "what's on my shopping list",
"show 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 is on my to do list",
"what's on my to do list", "what's on my to do list",
"show my to do list", "show my to do list",
@@ -246,13 +270,96 @@ internal static class HouseholdListOrchestrator
var normalized = NormalizeItem(listType ?? string.Empty).ToLowerInvariant(); var normalized = NormalizeItem(listType ?? string.Empty).ToLowerInvariant();
return normalized.Contains("todo", StringComparison.OrdinalIgnoreCase) || return normalized.Contains("todo", StringComparison.OrdinalIgnoreCase) ||
normalized.Contains("to do", StringComparison.OrdinalIgnoreCase) normalized.Contains("to do", StringComparison.OrdinalIgnoreCase)
? "todo" ? TodoListType
: normalized.Contains("shopping", StringComparison.OrdinalIgnoreCase) || : normalized.Contains("shopping", StringComparison.OrdinalIgnoreCase) ||
normalized.Contains("grocery", StringComparison.OrdinalIgnoreCase) normalized.Contains("grocery", StringComparison.OrdinalIgnoreCase)
? "shopping" ? ShoppingListType
: string.Empty; : 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) private static bool ContainsAny(string loweredTranscript, params string[] phrases)
{ {
return phrases.Any(phrase => loweredTranscript.Contains(phrase, StringComparison.OrdinalIgnoreCase)); return phrases.Any(phrase => loweredTranscript.Contains(phrase, StringComparison.OrdinalIgnoreCase));

View File

@@ -780,13 +780,19 @@ public sealed partial class JiboInteractionService
loweredTranscript, loweredTranscript,
"shopping list", "shopping list",
"grocery list", "grocery list",
"my grocery list",
"create grocery list",
"start grocery list",
"to do list", "to do list",
"todo list", "todo list",
"add to my shopping list", "add to my shopping list",
"add to my grocery list",
"add to my to do list", "add to my to do list",
"add to my todo list", "add to my todo list",
"what's on my shopping list", "what's on my shopping list",
"what is 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's on my to do list",
"what is on my to do list", "what is on my to do list",
"what are my tasks", "what are my tasks",

View File

@@ -23,6 +23,7 @@ public sealed class JiboInteractionServiceTests
private const string PersonalReportNewsEnabledKey = "personalReportNewsEnabled"; private const string PersonalReportNewsEnabledKey = "personalReportNewsEnabled";
private const string HouseholdListStateKey = "householdListState"; private const string HouseholdListStateKey = "householdListState";
private const string HouseholdListTypeKey = "householdListType"; private const string HouseholdListTypeKey = "householdListType";
private const string HouseholdListDisplayTypeKey = "householdListDisplayType";
private const string ChitchatStateKey = "chitchatState"; private const string ChitchatStateKey = "chitchatState";
private const string ChitchatRouteKey = "chitchatRoute"; private const string ChitchatRouteKey = "chitchatRoute";
private const string ChitchatEmotionKey = "chitchatEmotion"; private const string ChitchatEmotionKey = "chitchatEmotion";
@@ -2285,13 +2286,17 @@ public sealed class JiboInteractionServiceTests
} }
[Theory] [Theory]
[InlineData("shopping list", "shopping_list_prompt", "What should I add to your shopping list?", "shopping")] [InlineData("shopping list", "shopping_list_prompt", "What should I add to your shopping list?", "shopping", "shopping")]
[InlineData("to do list", "todo_list_prompt", "What should I add to your to-do list?", "todo")] [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( public async Task BuildDecisionAsync_ListStart_PromptsForFollowUpItems(
string transcript, string transcript,
string expectedIntent, string expectedIntent,
string expectedReply, string expectedReply,
string expectedListType) string expectedListType,
string expectedDisplayType)
{ {
var service = CreateService(); var service = CreateService();
@@ -2306,6 +2311,7 @@ public sealed class JiboInteractionServiceTests
Assert.NotNull(decision.ContextUpdates); Assert.NotNull(decision.ContextUpdates);
Assert.Equal("awaiting_item", decision.ContextUpdates![HouseholdListStateKey]); Assert.Equal("awaiting_item", decision.ContextUpdates![HouseholdListStateKey]);
Assert.Equal(expectedListType, decision.ContextUpdates[HouseholdListTypeKey]); Assert.Equal(expectedListType, decision.ContextUpdates[HouseholdListTypeKey]);
Assert.Equal(expectedDisplayType, decision.ContextUpdates[HouseholdListDisplayTypeKey]);
} }
[Fact] [Fact]
@@ -2330,6 +2336,7 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("shopping_list_prompt", promptDecision.IntentName); Assert.Equal("shopping_list_prompt", promptDecision.IntentName);
Assert.Equal("awaiting_item", promptDecision.ContextUpdates![HouseholdListStateKey]); Assert.Equal("awaiting_item", promptDecision.ContextUpdates![HouseholdListStateKey]);
Assert.Equal("shopping", promptDecision.ContextUpdates[HouseholdListTypeKey]); Assert.Equal("shopping", promptDecision.ContextUpdates[HouseholdListTypeKey]);
Assert.Equal("shopping", promptDecision.ContextUpdates[HouseholdListDisplayTypeKey]);
var addDecision = await service.BuildDecisionAsync(new TurnContext var addDecision = await service.BuildDecisionAsync(new TurnContext
{ {
@@ -2339,7 +2346,8 @@ public sealed class JiboInteractionServiceTests
Attributes = new Dictionary<string, object?>(tenantAttributes) Attributes = new Dictionary<string, object?>(tenantAttributes)
{ {
[HouseholdListStateKey] = promptDecision.ContextUpdates[HouseholdListStateKey], [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.Contains("What else should I add?", addDecision.ReplyText, StringComparison.OrdinalIgnoreCase);
Assert.Equal("awaiting_item", addDecision.ContextUpdates![HouseholdListStateKey]); Assert.Equal("awaiting_item", addDecision.ContextUpdates![HouseholdListStateKey]);
Assert.Equal("shopping", addDecision.ContextUpdates[HouseholdListTypeKey]); Assert.Equal("shopping", addDecision.ContextUpdates[HouseholdListTypeKey]);
Assert.Equal("shopping", addDecision.ContextUpdates[HouseholdListDisplayTypeKey]);
Assert.Equal(["milk"], Assert.Equal(["milk"],
memoryStore.GetListItems(new PersonalMemoryTenantScope("acct-a", "loop-a", "device-a"), "shopping")); memoryStore.GetListItems(new PersonalMemoryTenantScope("acct-a", "loop-a", "device-a"), "shopping"));
@@ -2359,7 +2368,8 @@ public sealed class JiboInteractionServiceTests
Attributes = new Dictionary<string, object?>(tenantAttributes) Attributes = new Dictionary<string, object?>(tenantAttributes)
{ {
[HouseholdListStateKey] = addDecision.ContextUpdates[HouseholdListStateKey], [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, Assert.Contains("Okay. Your shopping list has milk.", doneDecision.ReplyText,
StringComparison.OrdinalIgnoreCase); StringComparison.OrdinalIgnoreCase);
Assert.Equal("idle", doneDecision.ContextUpdates![HouseholdListStateKey]); Assert.Equal("idle", doneDecision.ContextUpdates![HouseholdListStateKey]);
Assert.Equal("shopping", doneDecision.ContextUpdates[HouseholdListDisplayTypeKey]);
var recallDecision = await service.BuildDecisionAsync(new TurnContext var recallDecision = await service.BuildDecisionAsync(new TurnContext
{ {
@@ -2378,6 +2389,134 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("shopping_list_recall", recallDecision.IntentName); Assert.Equal("shopping_list_recall", recallDecision.IntentName);
Assert.Contains("milk", recallDecision.ReplyText, StringComparison.OrdinalIgnoreCase); 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<string, object?>
{
["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<string, object?>(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<string, object?>(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<string, object?>(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<string, object?>
{
["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<string, object?>(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<string, object?>(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<string, object?>(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<string, object?>(tenantAttributes)
});
Assert.Equal("shopping_list_recall", recallDecision.IntentName);
Assert.Contains("milk", recallDecision.ReplyText, StringComparison.OrdinalIgnoreCase);
Assert.Contains("grocery list", recallDecision.ReplyText, StringComparison.OrdinalIgnoreCase);
} }
[Fact] [Fact]