Add stateful shopping and to-do list follow-ups

This commit is contained in:
Jacob Dubin
2026-05-14 07:44:46 -05:00
parent f5e37729ab
commit f299cef9be
7 changed files with 655 additions and 4 deletions

View File

@@ -13,6 +13,9 @@ public interface IPersonalMemoryStore
void SetAffinity(PersonalMemoryTenantScope tenantScope, string item, PersonalAffinity affinity); void SetAffinity(PersonalMemoryTenantScope tenantScope, string item, PersonalAffinity affinity);
PersonalAffinity? GetAffinity(PersonalMemoryTenantScope tenantScope, string item); PersonalAffinity? GetAffinity(PersonalMemoryTenantScope tenantScope, string item);
IReadOnlyDictionary<string, PersonalAffinity> GetAffinities(PersonalMemoryTenantScope tenantScope); IReadOnlyDictionary<string, PersonalAffinity> GetAffinities(PersonalMemoryTenantScope tenantScope);
void AddListItem(PersonalMemoryTenantScope tenantScope, string listName, string item);
IReadOnlyList<string> GetListItems(PersonalMemoryTenantScope tenantScope, string listName);
void ClearListItems(PersonalMemoryTenantScope tenantScope, string listName);
} }
public sealed record PersonalMemoryTenantScope(string AccountId, string LoopId, string DeviceId); public sealed record PersonalMemoryTenantScope(string AccountId, string LoopId, string DeviceId);

View File

@@ -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<JiboInteractionDecision?> TryBuildDecisionAsync(
TurnContext turn,
string semanticIntent,
string transcript,
string loweredTranscript,
IJiboRandomizer randomizer,
IPersonalMemoryStore personalMemoryStore,
Func<TurnContext, PersonalMemoryTenantScope> 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<JiboInteractionDecision?>(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<JiboInteractionDecision?>(BuildCancelledDecision(resolvedListType));
}
if (IsRecallRequest(loweredTranscript))
{
return Task.FromResult<JiboInteractionDecision?>(BuildRecallDecision(
resolvedListType,
personalMemoryStore.GetListItems(tenantScope, resolvedListType)));
}
var directItem = TryExtractListItem(loweredTranscript);
if (string.IsNullOrWhiteSpace(directItem) && isActiveState)
{
if (IsConversationComplete(loweredTranscript))
{
return Task.FromResult<JiboInteractionDecision?>(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<JiboInteractionDecision?>(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<JiboInteractionDecision?>(new JiboInteractionDecision(
resolvedListType == "shopping" ? "shopping_list_prompt" : "todo_list_prompt",
BuildPromptReply(resolvedListType),
ContextUpdates: BuildContextUpdates(resolvedListType, AwaitingItemState)));
}
return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision(
resolvedListType == "shopping" ? "shopping_list_prompt" : "todo_list_prompt",
BuildPromptReply(resolvedListType),
ContextUpdates: BuildContextUpdates(resolvedListType, AwaitingItemState)));
}
private static IDictionary<string, object?> BuildContextUpdates(string listType, string state)
{
return new Dictionary<string, object?>(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<string, object?>(StringComparer.OrdinalIgnoreCase)
{
[StateMetadataKey] = IdleState,
[TypeMetadataKey] = listType,
[NoMatchCountMetadataKey] = 0,
[NoInputCountMetadataKey] = 0
});
}
private static JiboInteractionDecision BuildRecallDecision(string listType, IReadOnlyList<string> 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<string, object?>(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<string, object?>(StringComparer.OrdinalIgnoreCase)
{
[StateMetadataKey] = IdleState,
[TypeMetadataKey] = listType,
[NoMatchCountMetadataKey] = 0,
[NoInputCountMetadataKey] = 0
});
}
private static string BuildAddedReply(string listType, string addedItem, IReadOnlyList<string> 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<string> 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<string> 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"
];
}

View File

@@ -85,6 +85,19 @@ public sealed class JiboInteractionService(
return personalReportDecision; 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( var chitchatDecision = ChitchatStateMachine.TryBuildDecision(
semanticIntent, semanticIntent,
transcript, transcript,
@@ -2196,6 +2209,30 @@ public sealed class JiboInteractionService(
return "personal_report"; 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)) if (IsWeatherRequest(loweredTranscript))
{ {
return "weather"; return "weather";

View File

@@ -122,7 +122,8 @@ public static class LegacyMimCatalogImporter
return LegacyMimBucket.Personality; 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; return LegacyMimBucket.Emotion;
} }
@@ -152,7 +153,7 @@ public static class LegacyMimCatalogImporter
fileName.StartsWith("RI_JBO_Is", StringComparison.OrdinalIgnoreCase) || fileName.StartsWith("RI_JBO_Is", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RN_WhatAreYouFeeling", StringComparison.OrdinalIgnoreCase)) fileName.StartsWith("RN_WhatAreYouFeeling", StringComparison.OrdinalIgnoreCase))
{ {
return LegacyMimBucket.HowAreYou; return LegacyMimBucket.Emotion;
} }
if (fileName.Contains("Greeting", StringComparison.OrdinalIgnoreCase) || if (fileName.Contains("Greeting", StringComparison.OrdinalIgnoreCase) ||

View File

@@ -92,6 +92,60 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
return new Dictionary<string, PersonalAffinity>(record.Affinities, StringComparer.OrdinalIgnoreCase); return new Dictionary<string, PersonalAffinity>(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<string> 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) private static string BuildTenantKey(PersonalMemoryTenantScope tenantScope)
{ {
return $"{tenantScope.AccountId}|{tenantScope.LoopId}|{tenantScope.DeviceId}"; return $"{tenantScope.AccountId}|{tenantScope.LoopId}|{tenantScope.DeviceId}";
@@ -109,5 +163,7 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
public ConcurrentDictionary<string, string> Preferences { get; } = new(StringComparer.OrdinalIgnoreCase); public ConcurrentDictionary<string, string> Preferences { get; } = new(StringComparer.OrdinalIgnoreCase);
public ConcurrentDictionary<string, string> ImportantDates { get; } = new(StringComparer.OrdinalIgnoreCase); public ConcurrentDictionary<string, string> ImportantDates { get; } = new(StringComparer.OrdinalIgnoreCase);
public ConcurrentDictionary<string, PersonalAffinity> Affinities { get; } = new(StringComparer.OrdinalIgnoreCase); public ConcurrentDictionary<string, PersonalAffinity> Affinities { get; } = new(StringComparer.OrdinalIgnoreCase);
public ConcurrentDictionary<string, List<string>> Lists { get; } = new(StringComparer.OrdinalIgnoreCase);
public object SyncRoot { get; } = new();
} }
} }

View File

@@ -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] [Fact]
public void MergeInto_PreservesExistingCatalogAndAddsImportedContent() public void MergeInto_PreservesExistingCatalogAndAddsImportedContent()
{ {

View File

@@ -17,6 +17,8 @@ public sealed class JiboInteractionServiceTests
private const string PersonalReportCalendarEnabledKey = "personalReportCalendarEnabled"; private const string PersonalReportCalendarEnabledKey = "personalReportCalendarEnabled";
private const string PersonalReportCommuteEnabledKey = "personalReportCommuteEnabled"; private const string PersonalReportCommuteEnabledKey = "personalReportCommuteEnabled";
private const string PersonalReportNewsEnabledKey = "personalReportNewsEnabled"; private const string PersonalReportNewsEnabledKey = "personalReportNewsEnabled";
private const string HouseholdListStateKey = "householdListState";
private const string HouseholdListTypeKey = "householdListType";
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";
@@ -346,6 +348,56 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("EmotionQuery", decision.ContextUpdates![ChitchatRouteKey]); 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<string, object?>
{
[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] [Theory]
[InlineData("joyful", "Yes indeed. Never been better.")] [InlineData("joyful", "Yes indeed. Never been better.")]
[InlineData("pleased", "You know it. Life is good.")] [InlineData("pleased", "You know it. Life is good.")]
@@ -1419,6 +1471,157 @@ public sealed class JiboInteractionServiceTests
Assert.Equal(true, decision.ContextUpdates[PersonalReportCommuteEnabledKey]); 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<string, object?>
{
["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<string, object?>(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<string, object?>(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<string, object?>(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<string, object?>(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<string, object?>
{
["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<string, object?>(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<string, object?>(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<string, object?>(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] [Fact]
public async Task BuildDecisionAsync_WeatherQuery_WithoutProvider_UsesSpokenFallback() public async Task BuildDecisionAsync_WeatherQuery_WithoutProvider_UsesSpokenFallback()
{ {
@@ -3206,10 +3409,11 @@ public sealed class JiboInteractionServiceTests
private static JiboInteractionService CreateService( private static JiboInteractionService CreateService(
IPersonalMemoryStore? personalMemoryStore = null, IPersonalMemoryStore? personalMemoryStore = null,
IWeatherReportProvider? weatherReportProvider = null, IWeatherReportProvider? weatherReportProvider = null,
INewsBriefingProvider? newsBriefingProvider = null) INewsBriefingProvider? newsBriefingProvider = null,
IJiboExperienceContentRepository? contentRepository = null)
{ {
return new JiboInteractionService( return new JiboInteractionService(
new JiboExperienceContentCache(new InMemoryJiboExperienceContentRepository()), new JiboExperienceContentCache(contentRepository ?? new InMemoryJiboExperienceContentRepository()),
new FirstItemRandomizer(), new FirstItemRandomizer(),
personalMemoryStore ?? new InMemoryPersonalMemoryStore(), personalMemoryStore ?? new InMemoryPersonalMemoryStore(),
weatherReportProvider, weatherReportProvider,
@@ -3255,4 +3459,12 @@ public sealed class JiboInteractionServiceTests
return Task.FromResult(Snapshot); return Task.FromResult(Snapshot);
} }
} }
private sealed class StaticCatalogRepository(JiboExperienceCatalog catalog) : IJiboExperienceContentRepository
{
public Task<JiboExperienceCatalog> GetCatalogAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(catalog);
}
}
} }