Add stateful shopping and to-do list follow-ups
This commit is contained in:
@@ -13,6 +13,9 @@ public interface IPersonalMemoryStore
|
||||
void SetAffinity(PersonalMemoryTenantScope tenantScope, string item, PersonalAffinity affinity);
|
||||
PersonalAffinity? GetAffinity(PersonalMemoryTenantScope tenantScope, string item);
|
||||
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);
|
||||
|
||||
@@ -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"
|
||||
];
|
||||
}
|
||||
@@ -85,6 +85,19 @@ public sealed class JiboInteractionService(
|
||||
return personalReportDecision;
|
||||
}
|
||||
|
||||
var householdListDecision = await HouseholdListOrchestrator.TryBuildDecisionAsync(
|
||||
turn,
|
||||
semanticIntent,
|
||||
transcript,
|
||||
lowered,
|
||||
randomizer,
|
||||
personalMemoryStore,
|
||||
ResolveTenantScope);
|
||||
if (householdListDecision is not null)
|
||||
{
|
||||
return householdListDecision;
|
||||
}
|
||||
|
||||
var chitchatDecision = ChitchatStateMachine.TryBuildDecision(
|
||||
semanticIntent,
|
||||
transcript,
|
||||
@@ -2196,6 +2209,30 @@ public sealed class JiboInteractionService(
|
||||
return "personal_report";
|
||||
}
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"shopping list",
|
||||
"grocery list",
|
||||
"to do list",
|
||||
"todo list",
|
||||
"add to my shopping list",
|
||||
"add to my to do list",
|
||||
"add to my todo list",
|
||||
"what's on my shopping list",
|
||||
"what is on my shopping list",
|
||||
"what's on my to do list",
|
||||
"what is on my to do list",
|
||||
"what are my tasks",
|
||||
"what do i need to buy",
|
||||
"what do i need to do"))
|
||||
{
|
||||
return loweredTranscript.Contains("to do", StringComparison.OrdinalIgnoreCase) ||
|
||||
loweredTranscript.Contains("todo", StringComparison.OrdinalIgnoreCase) ||
|
||||
loweredTranscript.Contains("task", StringComparison.OrdinalIgnoreCase)
|
||||
? "todo_list"
|
||||
: "shopping_list";
|
||||
}
|
||||
|
||||
if (IsWeatherRequest(loweredTranscript))
|
||||
{
|
||||
return "weather";
|
||||
|
||||
@@ -122,7 +122,8 @@ public static class LegacyMimCatalogImporter
|
||||
return LegacyMimBucket.Personality;
|
||||
}
|
||||
|
||||
if (normalizedPath.Contains("/emotion-responses/", StringComparison.OrdinalIgnoreCase))
|
||||
if (normalizedPath.Contains("/emotion-responses/", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalizedPath.Contains("/gqa-responses/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return LegacyMimBucket.Emotion;
|
||||
}
|
||||
@@ -152,7 +153,7 @@ public static class LegacyMimCatalogImporter
|
||||
fileName.StartsWith("RI_JBO_Is", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RN_WhatAreYouFeeling", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return LegacyMimBucket.HowAreYou;
|
||||
return LegacyMimBucket.Emotion;
|
||||
}
|
||||
|
||||
if (fileName.Contains("Greeting", StringComparison.OrdinalIgnoreCase) ||
|
||||
|
||||
@@ -92,6 +92,60 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
|
||||
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)
|
||||
{
|
||||
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> ImportantDates { 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user