From 699e0d528209a35971242d827f9dd5a47676cb10 Mon Sep 17 00:00:00 2001 From: Jacob Dubin Date: Tue, 5 May 2026 22:40:11 -0500 Subject: [PATCH] Add tenant-scoped personal memory facts --- OpenJibo/docs/feature-backlog.md | 16 +- OpenJibo/docs/release-1.0.19-plan.md | 13 +- .../Abstractions/IPersonalMemoryStore.cs | 11 + .../Services/JiboInteractionService.cs | 217 +++++++++++++++++- .../Services/ProtocolToTurnContextMapper.cs | 17 ++ .../ServiceCollectionExtensions.cs | 1 + .../Persistence/InMemoryCloudStateStore.cs | 30 ++- .../InMemoryPersonalMemoryStore.cs | 54 +++++ .../WebSockets/JiboInteractionServiceTests.cs | 115 +++++++++- .../WebSockets/JiboWebSocketServiceTests.cs | 70 +++++- 10 files changed, 525 insertions(+), 19 deletions(-) create mode 100644 OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IPersonalMemoryStore.cs create mode 100644 OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryPersonalMemoryStore.cs diff --git a/OpenJibo/docs/feature-backlog.md b/OpenJibo/docs/feature-backlog.md index 393310e..8bf07ee 100644 --- a/OpenJibo/docs/feature-backlog.md +++ b/OpenJibo/docs/feature-backlog.md @@ -600,6 +600,20 @@ Current release theme: - expand command-vs-question splits to more expressive intents (pizza, surprise, photo prompts) - add Pegasus phrase and MIM-backed variants for richer style coverage +### 23. First Memory-Backed Personal Facts + +- Status: `implemented` +- Tags: `storage`, `content` +- Result: + - tenant-scoped memory store abstraction is in place for personal facts + - birthday set/recall works (`my birthday is ...` / `when is my birthday`) + - preference set/recall works (`my favorite X is Y` / `what is my favorite X`) + - account/loop/device scoped lookup prevents cross-tenant leakage +- Follow-up: + - extend phrase parsing beyond first rule-based patterns + - add durable persistence path for personal facts + - broaden fact categories (names, important dates, household preferences) + ## Suggested Order Before closing `1.0.18`: @@ -615,7 +629,7 @@ Use [regression-test-plan.md](regression-test-plan.md) as the detailed checklist For `1.0.19`: 1. Command-vs-question personality split (`dance` command vs `do you like to dance` question style; expand this pattern) -2. First memory-backed personal facts with tenant-scoped storage (birthday/preferences foundation) +2. Expand memory-backed personal facts with tenant-scoped storage (beyond the first birthday/preferences foundation) 3. Proactivity selector baseline with source-backed first offers 4. Dialog parsing expansion and ambiguity guardrails 5. Holidays and seasonal personality behavior built on the new memory/proactivity foundation diff --git a/OpenJibo/docs/release-1.0.19-plan.md b/OpenJibo/docs/release-1.0.19-plan.md index d29480f..f336da0 100644 --- a/OpenJibo/docs/release-1.0.19-plan.md +++ b/OpenJibo/docs/release-1.0.19-plan.md @@ -57,10 +57,21 @@ The first delivered slice in this release is persona expansion: This slice is intentionally small and user-visible. It creates immediate personality gains while we keep deeper platform work in parallel. +## Second Implemented Slice In `1.0.19` + +The second delivered slice is first tenant-scoped personal memory: + +- store birthday from phrases like `my birthday is April 12` +- recall birthday from phrases like `when is my birthday` +- store preferences from phrases like `my favorite music is jazz` +- recall preferences from phrases like `what is my favorite music` + +Memory keys are scoped by account/loop/device tenant context so one tenant does not leak into another. + ## Next Slices 1. Command-vs-question personality split (start with dance/twerk-style prompts, keep commands action-oriented and questions conversational) -2. First memory-backed personal facts (tenant-scoped birthday/preferences storage contracts + initial implementation) +2. Expand memory-backed personal facts (tenant-scoped birthday/preferences coverage, persistence depth, and parsing breadth) 3. Proactivity selector baseline (source-backed first proactive offers with safe throttling and stock-compatible payloads) 4. Dialog parsing expansion (more phrase variants, ambiguity handling, and transcript-to-intent guardrails) 5. Holidays and seasonal personality slice (time-scoped content backed by the new memory/proactivity path) diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IPersonalMemoryStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IPersonalMemoryStore.cs new file mode 100644 index 0000000..66557bd --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IPersonalMemoryStore.cs @@ -0,0 +1,11 @@ +namespace Jibo.Cloud.Application.Abstractions; + +public interface IPersonalMemoryStore +{ + void SetBirthday(PersonalMemoryTenantScope tenantScope, string birthdayText); + string? GetBirthday(PersonalMemoryTenantScope tenantScope); + void SetPreference(PersonalMemoryTenantScope tenantScope, string category, string value); + string? GetPreference(PersonalMemoryTenantScope tenantScope, string category); +} + +public sealed record PersonalMemoryTenantScope(string AccountId, string LoopId, string DeviceId); diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs index 4ddb381..eb73d30 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs @@ -7,7 +7,8 @@ namespace Jibo.Cloud.Application.Services; public sealed class JiboInteractionService( JiboExperienceContentCache contentCache, - IJiboRandomizer randomizer) + IJiboRandomizer randomizer, + IPersonalMemoryStore personalMemoryStore) { public async Task BuildDecisionAsync(TurnContext turn, CancellationToken cancellationToken = default) { @@ -77,6 +78,10 @@ public sealed class JiboInteractionService( "robot_age" => BuildRobotAgeDecision(referenceLocalTime), "robot_birthday" => BuildRobotBirthdayDecision(), "robot_personality" => new JiboInteractionDecision("robot_personality", randomizer.Choose(catalog.PersonalityReplies)), + "memory_set_birthday" => BuildRememberBirthdayDecision(turn, transcript), + "memory_get_birthday" => BuildRecallBirthdayDecision(turn), + "memory_set_preference" => BuildRememberPreferenceDecision(turn, transcript), + "memory_get_preference" => BuildRecallPreferenceDecision(turn, transcript), "pizza" => BuildPizzaDecision(), "order_pizza" => BuildOrderPizzaDecision(), "yes" => new JiboInteractionDecision("yes", "Yes."), @@ -115,6 +120,70 @@ public sealed class JiboInteractionService( $"My birthday is {OpenJiboCloudBuildInfo.PersonaBirthdayWords}."); } + private JiboInteractionDecision BuildRememberBirthdayDecision(TurnContext turn, string transcript) + { + var birthday = TryExtractBirthdayFact(transcript); + if (string.IsNullOrWhiteSpace(birthday)) + { + return new JiboInteractionDecision( + "memory_set_birthday", + "I can remember it if you say, my birthday is March 14."); + } + + personalMemoryStore.SetBirthday(ResolveTenantScope(turn), birthday); + return new JiboInteractionDecision( + "memory_set_birthday", + $"Got it. I will remember your birthday is {birthday}."); + } + + private JiboInteractionDecision BuildRecallBirthdayDecision(TurnContext turn) + { + var birthday = personalMemoryStore.GetBirthday(ResolveTenantScope(turn)); + return string.IsNullOrWhiteSpace(birthday) + ? new JiboInteractionDecision( + "memory_get_birthday", + "I do not know your birthday yet. You can say, my birthday is March 14.") + : new JiboInteractionDecision( + "memory_get_birthday", + $"You told me your birthday is {birthday}."); + } + + private JiboInteractionDecision BuildRememberPreferenceDecision(TurnContext turn, string transcript) + { + var preference = TryExtractPreferenceSet(transcript); + if (preference is null) + { + return new JiboInteractionDecision( + "memory_set_preference", + "I can remember it if you say, my favorite music is jazz."); + } + + personalMemoryStore.SetPreference(ResolveTenantScope(turn), preference.Value.Category, preference.Value.Value); + return new JiboInteractionDecision( + "memory_set_preference", + $"Got it. I will remember your favorite {preference.Value.Category} is {preference.Value.Value}."); + } + + private JiboInteractionDecision BuildRecallPreferenceDecision(TurnContext turn, string transcript) + { + var category = TryExtractPreferenceLookupCategory(transcript); + if (string.IsNullOrWhiteSpace(category)) + { + return new JiboInteractionDecision( + "memory_get_preference", + "Ask me like this: what is my favorite music?"); + } + + var preference = personalMemoryStore.GetPreference(ResolveTenantScope(turn), category); + return string.IsNullOrWhiteSpace(preference) + ? new JiboInteractionDecision( + "memory_get_preference", + $"I do not know your favorite {category} yet.") + : new JiboInteractionDecision( + "memory_get_preference", + $"You told me your favorite {category} is {preference}."); + } + private JiboInteractionDecision BuildPizzaDecision() { var prompt = randomizer.Choose(PizzaMimPrompts); @@ -266,6 +335,16 @@ public sealed class JiboInteractionService( }; } + if (IsUserBirthdaySetStatement(loweredTranscript)) + { + return "memory_set_birthday"; + } + + if (IsUserBirthdayRecallQuestion(loweredTranscript)) + { + return "memory_get_birthday"; + } + if (IsRobotBirthdayQuestion(loweredTranscript)) { return "robot_birthday"; @@ -390,6 +469,16 @@ public sealed class JiboInteractionService( return "cloud_version"; } + if (IsPreferenceSetStatement(loweredTranscript)) + { + return "memory_set_preference"; + } + + if (IsPreferenceRecallQuestion(loweredTranscript)) + { + return "memory_get_preference"; + } + if (TryResolveRadioGenre(loweredTranscript) is not null) { return "radio_genre"; @@ -1102,15 +1191,123 @@ public sealed class JiboInteractionService( private static bool IsRobotBirthdayQuestion(string loweredTranscript) { return MatchesAny( - loweredTranscript, - "when is your birthday", - "when's your birthday", - "what's your birthday", - "what s your birthday", - "what is your birthday", - "when were you born", - "what day is your birthday") || - loweredTranscript.Contains("birthday", StringComparison.Ordinal); + loweredTranscript, + "when is your birthday", + "when's your birthday", + "what's your birthday", + "what s your birthday", + "what is your birthday", + "when were you born", + "what day is your birthday"); + } + + private static bool IsUserBirthdayRecallQuestion(string loweredTranscript) + { + return MatchesAny( + loweredTranscript, + "when is my birthday", + "when's my birthday", + "what is my birthday", + "what s my birthday", + "what's my birthday", + "do you remember my birthday"); + } + + private static bool IsUserBirthdaySetStatement(string loweredTranscript) + { + return TryExtractBirthdayFact(loweredTranscript) is not null; + } + + private static string? TryExtractBirthdayFact(string transcript) + { + var normalized = NormalizeCommandPhrase(transcript); + var marker = "my birthday is "; + var markerIndex = normalized.IndexOf(marker, StringComparison.Ordinal); + if (markerIndex < 0) + { + return null; + } + + var value = normalized[(markerIndex + marker.Length)..].Trim(); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + private static bool IsPreferenceRecallQuestion(string loweredTranscript) + { + return TryExtractPreferenceLookupCategory(loweredTranscript) is not null; + } + + private static bool IsPreferenceSetStatement(string loweredTranscript) + { + return TryExtractPreferenceSet(loweredTranscript) is not null; + } + + private static string? TryExtractPreferenceLookupCategory(string transcript) + { + var normalized = NormalizeCommandPhrase(transcript); + var prefixes = new[] + { + "what is my favorite ", + "what s my favorite ", + "what's my favorite ", + "do you remember my favorite " + }; + + foreach (var prefix in prefixes) + { + if (!normalized.StartsWith(prefix, StringComparison.Ordinal)) + { + continue; + } + + var category = normalized[prefix.Length..].Trim(); + return string.IsNullOrWhiteSpace(category) ? null : category; + } + + return null; + } + + private static (string Category, string Value)? TryExtractPreferenceSet(string transcript) + { + var normalized = NormalizeCommandPhrase(transcript); + var marker = "my favorite "; + var markerIndex = normalized.IndexOf(marker, StringComparison.Ordinal); + if (markerIndex < 0) + { + return null; + } + + var preferencePhrase = normalized[(markerIndex + marker.Length)..]; + var splitMarker = " is "; + var splitIndex = preferencePhrase.IndexOf(splitMarker, StringComparison.Ordinal); + if (splitIndex <= 0 || splitIndex >= preferencePhrase.Length - splitMarker.Length) + { + return null; + } + + var category = preferencePhrase[..splitIndex].Trim(); + var value = preferencePhrase[(splitIndex + splitMarker.Length)..].Trim(); + if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return (category, value); + } + + private static PersonalMemoryTenantScope ResolveTenantScope(TurnContext turn) + { + var accountId = ReadTenantAttribute(turn, "accountId") ?? "usr_openjibo_owner"; + var loopId = ReadTenantAttribute(turn, "loopId") ?? "openjibo-default-loop"; + var deviceId = turn.DeviceId ?? ReadTenantAttribute(turn, "deviceId") ?? "unknown-device"; + return new PersonalMemoryTenantScope(accountId, loopId, deviceId); + } + + private static string? ReadTenantAttribute(TurnContext turn, string key) + { + return turn.Attributes.TryGetValue(key, out var value) + ? value?.ToString() + : null; } private static string? TryResolveRadioGenre(string loweredTranscript) diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ProtocolToTurnContextMapper.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ProtocolToTurnContextMapper.cs index ea415c0..f9ede49 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ProtocolToTurnContextMapper.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ProtocolToTurnContextMapper.cs @@ -21,6 +21,23 @@ public sealed class ProtocolToTurnContextMapper attributes["transID"] = turnState.TransId; } + if (!string.IsNullOrWhiteSpace(session.AccountId)) + { + attributes["accountId"] = session.AccountId; + } + + if (!string.IsNullOrWhiteSpace(session.DeviceId)) + { + attributes["deviceId"] = session.DeviceId; + } + + if (session.Metadata.TryGetValue("loopId", out var loopId) && + loopId is string loopIdText && + !string.IsNullOrWhiteSpace(loopIdText)) + { + attributes["loopId"] = loopIdText; + } + if (!string.IsNullOrWhiteSpace(turnState.ContextPayload)) { attributes["context"] = turnState.ContextPayload; diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs index b5e2a9b..35eedda 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -27,6 +27,7 @@ public static class ServiceCollectionExtensions var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"] ?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json"); services.AddSingleton(_ => new InMemoryCloudStateStore(statePersistencePath)); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryCloudStateStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryCloudStateStore.cs index a7f9901..2a060a5 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryCloudStateStore.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryCloudStateStore.cs @@ -102,7 +102,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore Kind = "hub", AccountId = _account.AccountId, Token = token, - DeviceId = _robot.DeviceId + DeviceId = _robot.DeviceId, + Metadata = BuildSessionMetadata(_account.AccountId, _robot.DeviceId, ResolveDefaultLoopId()) }; return token; @@ -116,7 +117,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore Kind = "robot", AccountId = _account.AccountId, Token = token, - DeviceId = deviceId + DeviceId = deviceId, + Metadata = BuildSessionMetadata(_account.AccountId, deviceId, ResolveDefaultLoopId()) }; return token; @@ -124,14 +126,17 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore public CloudSession OpenSession(string kind, string? deviceId, string? token, string? hostName, string? path) { + var resolvedDeviceId = deviceId ?? _robot.DeviceId; + var resolvedLoopId = ResolveDefaultLoopId(); var session = new CloudSession { Kind = kind, AccountId = _account.AccountId, - DeviceId = deviceId ?? _robot.DeviceId, + DeviceId = resolvedDeviceId, Token = token, HostName = hostName, - Path = path + Path = path, + Metadata = BuildSessionMetadata(_account.AccountId, resolvedDeviceId, resolvedLoopId) }; if (!string.IsNullOrWhiteSpace(token)) @@ -424,4 +429,21 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore public MediaRecord[]? Media { get; init; } public BackupRecord[]? Backups { get; init; } } + + private string ResolveDefaultLoopId() + { + return _loops.FirstOrDefault(loop => string.Equals(loop.OwnerAccountId, _account.AccountId, StringComparison.OrdinalIgnoreCase))?.LoopId + ?? _loops.FirstOrDefault()?.LoopId + ?? "openjibo-default-loop"; + } + + private static IDictionary BuildSessionMetadata(string accountId, string? deviceId, string loopId) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["accountId"] = accountId, + ["loopId"] = loopId, + ["deviceId"] = deviceId + }; + } } diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryPersonalMemoryStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryPersonalMemoryStore.cs new file mode 100644 index 0000000..bc45bdf --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryPersonalMemoryStore.cs @@ -0,0 +1,54 @@ +using System.Collections.Concurrent; +using Jibo.Cloud.Application.Abstractions; + +namespace Jibo.Cloud.Infrastructure.Persistence; + +public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore +{ + private readonly ConcurrentDictionary _tenantMemory = new(StringComparer.OrdinalIgnoreCase); + + public void SetBirthday(PersonalMemoryTenantScope tenantScope, string birthdayText) + { + var key = BuildTenantKey(tenantScope); + var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord()); + record.Birthday = birthdayText; + } + + public string? GetBirthday(PersonalMemoryTenantScope tenantScope) + { + var key = BuildTenantKey(tenantScope); + return _tenantMemory.TryGetValue(key, out var record) ? record.Birthday : null; + } + + public void SetPreference(PersonalMemoryTenantScope tenantScope, string category, string value) + { + var key = BuildTenantKey(tenantScope); + var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord()); + record.Preferences[NormalizeCategory(category)] = value; + } + + public string? GetPreference(PersonalMemoryTenantScope tenantScope, string category) + { + var key = BuildTenantKey(tenantScope); + return _tenantMemory.TryGetValue(key, out var record) && + record.Preferences.TryGetValue(NormalizeCategory(category), out var value) + ? value + : null; + } + + private static string BuildTenantKey(PersonalMemoryTenantScope tenantScope) + { + return $"{tenantScope.AccountId}|{tenantScope.LoopId}|{tenantScope.DeviceId}"; + } + + private static string NormalizeCategory(string category) + { + return category.Trim().ToLowerInvariant(); + } + + private sealed class TenantMemoryRecord + { + public string? Birthday { get; set; } + public ConcurrentDictionary Preferences { get; } = new(StringComparer.OrdinalIgnoreCase); + } +} diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs index 91f728d..5469307 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs @@ -1,5 +1,7 @@ +using Jibo.Cloud.Application.Abstractions; using Jibo.Cloud.Application.Services; using Jibo.Cloud.Infrastructure.Content; +using Jibo.Cloud.Infrastructure.Persistence; using Jibo.Runtime.Abstractions; using System.Text.Json; @@ -136,6 +138,114 @@ public sealed class JiboInteractionServiceTests Assert.Equal("I do. I am curious, playful, and always up for a new experiment.", decision.ReplyText); } + [Fact] + public async Task BuildDecisionAsync_BirthdayMemory_SetThenRecallWithinTenant() + { + var memoryStore = new InMemoryPersonalMemoryStore(); + var service = CreateService(memoryStore); + + var setDecision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "my birthday is April 12", + NormalizedTranscript = "my birthday is April 12", + Attributes = new Dictionary + { + ["accountId"] = "acct-a", + ["loopId"] = "loop-a" + }, + DeviceId = "device-a" + }); + + Assert.Equal("memory_set_birthday", setDecision.IntentName); + Assert.Equal("Got it. I will remember your birthday is april 12.", setDecision.ReplyText); + + var recallDecision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "when is my birthday", + NormalizedTranscript = "when is my birthday", + Attributes = new Dictionary + { + ["accountId"] = "acct-a", + ["loopId"] = "loop-a" + }, + DeviceId = "device-a" + }); + + Assert.Equal("memory_get_birthday", recallDecision.IntentName); + Assert.Equal("You told me your birthday is april 12.", recallDecision.ReplyText); + } + + [Fact] + public async Task BuildDecisionAsync_PreferenceMemory_SetThenRecallWithinTenant() + { + var memoryStore = new InMemoryPersonalMemoryStore(); + var service = CreateService(memoryStore); + + var setDecision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "my favorite music is jazz", + NormalizedTranscript = "my favorite music is jazz", + Attributes = new Dictionary + { + ["accountId"] = "acct-a", + ["loopId"] = "loop-a" + }, + DeviceId = "device-a" + }); + + Assert.Equal("memory_set_preference", setDecision.IntentName); + Assert.Equal("Got it. I will remember your favorite music is jazz.", setDecision.ReplyText); + + var recallDecision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "what is my favorite music", + NormalizedTranscript = "what is my favorite music", + Attributes = new Dictionary + { + ["accountId"] = "acct-a", + ["loopId"] = "loop-a" + }, + DeviceId = "device-a" + }); + + Assert.Equal("memory_get_preference", recallDecision.IntentName); + Assert.Equal("You told me your favorite music is jazz.", recallDecision.ReplyText); + } + + [Fact] + public async Task BuildDecisionAsync_PersonalMemory_IsTenantScoped() + { + var memoryStore = new InMemoryPersonalMemoryStore(); + var service = CreateService(memoryStore); + + await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "my birthday is April 12", + NormalizedTranscript = "my birthday is April 12", + Attributes = new Dictionary + { + ["accountId"] = "acct-a", + ["loopId"] = "loop-a" + }, + DeviceId = "device-a" + }); + + var otherTenantRecall = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "what is my birthday", + NormalizedTranscript = "what is my birthday", + Attributes = new Dictionary + { + ["accountId"] = "acct-b", + ["loopId"] = "loop-a" + }, + DeviceId = "device-b" + }); + + Assert.Equal("memory_get_birthday", otherTenantRecall.IntentName); + Assert.Equal("I do not know your birthday yet. You can say, my birthday is March 14.", otherTenantRecall.ReplyText); + } + [Fact] public async Task BuildDecisionAsync_MakePizza_UsesOriginalMimStylePayload() { @@ -1185,11 +1295,12 @@ public sealed class JiboInteractionServiceTests Assert.Equal("aglet", decision.SkillPayload!["guess"]); } - private static JiboInteractionService CreateService() + private static JiboInteractionService CreateService(IPersonalMemoryStore? personalMemoryStore = null) { return new JiboInteractionService( new JiboExperienceContentCache(new InMemoryJiboExperienceContentRepository()), - new FirstItemRandomizer()); + new FirstItemRandomizer(), + personalMemoryStore ?? new InMemoryPersonalMemoryStore()); } private sealed class FirstItemRandomizer : IJiboRandomizer diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs index cd98d0f..15c9b89 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs @@ -18,7 +18,7 @@ public sealed class JiboWebSocketServiceTests _store = new InMemoryCloudStateStore(); var contentRepository = new InMemoryJiboExperienceContentRepository(); var contentCache = new JiboExperienceContentCache(contentRepository); - var conversationBroker = new DemoConversationBroker(new JiboInteractionService(contentCache, new DefaultJiboRandomizer())); + var conversationBroker = new DemoConversationBroker(new JiboInteractionService(contentCache, new DefaultJiboRandomizer(), new InMemoryPersonalMemoryStore())); var sttSelector = new DefaultSttStrategySelector( [ new SyntheticBufferedAudioSttStrategy() @@ -2906,6 +2906,74 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("AN", meta.GetProperty("prompt_sub_category").GetString()); } + [Fact] + public async Task ClientAsrPersonalMemory_BirthdayIsScopedPerDeviceTenant() + { + var tokenA = _store.IssueRobotToken("tenant-device-a"); + var tokenB = _store.IssueRobotToken("tenant-device-b"); + + var setReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope + { + HostName = "neo-hub.jibo.com", + Path = "/listen", + Kind = "neo-hub-listen", + Token = tokenA, + Text = """{"type":"CLIENT_ASR","transID":"trans-memory-set","data":{"text":"my birthday is april 12"}}""" + }); + + Assert.Equal(3, setReplies.Count); + using (var setListenPayload = JsonDocument.Parse(setReplies[0].Text!)) + { + Assert.Equal("memory_set_birthday", setListenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + } + + var sameTenantRecallReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope + { + HostName = "neo-hub.jibo.com", + Path = "/listen", + Kind = "neo-hub-listen", + Token = tokenA, + Text = """{"type":"CLIENT_ASR","transID":"trans-memory-recall-a","data":{"text":"what is my birthday"}}""" + }); + + Assert.Equal(3, sameTenantRecallReplies.Count); + using (var skillPayload = JsonDocument.Parse(sameTenantRecallReplies[2].Text!)) + { + var esml = skillPayload.RootElement + .GetProperty("data") + .GetProperty("action") + .GetProperty("config") + .GetProperty("jcp") + .GetProperty("config") + .GetProperty("play") + .GetProperty("esml") + .GetString(); + Assert.Contains("You told me your birthday is april 12", esml, StringComparison.OrdinalIgnoreCase); + } + + var otherTenantRecallReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope + { + HostName = "neo-hub.jibo.com", + Path = "/listen", + Kind = "neo-hub-listen", + Token = tokenB, + Text = """{"type":"CLIENT_ASR","transID":"trans-memory-recall-b","data":{"text":"what is my birthday"}}""" + }); + + Assert.Equal(3, otherTenantRecallReplies.Count); + using var otherSkillPayload = JsonDocument.Parse(otherTenantRecallReplies[2].Text!); + var otherEsml = otherSkillPayload.RootElement + .GetProperty("data") + .GetProperty("action") + .GetProperty("config") + .GetProperty("jcp") + .GetProperty("config") + .GetProperty("play") + .GetProperty("esml") + .GetString(); + Assert.Contains("I do not know your birthday yet", otherEsml, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task FollowUpTurn_UsesNewTurnStateWithoutLeakingBufferedAudio() {