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

@@ -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()
{

View File

@@ -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<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]
[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<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]
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<JiboExperienceCatalog> GetCatalogAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(catalog);
}
}
}