diff --git a/OpenJibo/docs/feature-backlog.md b/OpenJibo/docs/feature-backlog.md index 243cdb9..cca8b4f 100644 --- a/OpenJibo/docs/feature-backlog.md +++ b/OpenJibo/docs/feature-backlog.md @@ -654,6 +654,8 @@ Current release theme: - Follow-up: - add durable persistence path for personal facts - broaden fact categories further (multi-person household memory, relationship cues, and corrective updates) + - add explicit person-scoped state so future interactions can distinguish household members inside the same loop + - define the first server-to-server sync envelope for durable state before we need it in production ### 24. Memory-Triggered Proactivity Baseline @@ -669,6 +671,7 @@ Current release theme: - expand proactivity beyond pizza to additional Pegasus-backed categories - add cooldown/throttle policy and observability around proactive offer frequency - connect memory store to durable multi-tenant persistence + - keep the sync story visible so stateful offers can survive a multi-server deployment later ### 25. Weather Report-Skill Launch Compatibility diff --git a/OpenJibo/docs/release-1.0.19-plan.md b/OpenJibo/docs/release-1.0.19-plan.md index b412734..91be779 100644 --- a/OpenJibo/docs/release-1.0.19-plan.md +++ b/OpenJibo/docs/release-1.0.19-plan.md @@ -43,6 +43,15 @@ The goal is to keep compatibility work steady while shipping personality and cap - define tenant boundaries across account, loop, device, and person-memory records - add storage abstractions that can move from in-memory/local JSON to hosted SQL/Blob without reworking behavior layers - implement memory-ready schemas and repository contracts for user facts (names, birthdays, personal dates, preferences) with strict tenant scoping +- seed person-aware state keys now so future interactions can scope to account + loop + device + person without another shape change +- keep stateful interaction flows repository-backed instead of embedding more ad hoc metadata in the websocket layer + +### 6. Multi-Server Sync Path + +- document the eventual sync boundary for stateful data that should move between servers +- treat the first pass as repository-local durability, then layer replication and conflict handling on top +- prefer explicit change records or versioned state snapshots over implicit last-writer wins when we outgrow a single node +- keep cross-server reconciliation out of the hot path until the single-server semantics are stable ## First Implemented Slice In `1.0.19` diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICloudStateStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICloudStateStore.cs index 2eb4ae3..c5b88fb 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICloudStateStore.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICloudStateStore.cs @@ -13,6 +13,7 @@ public interface ICloudStateStore CloudSession OpenSession(string kind, string? deviceId, string? token, string? hostName, string? path); CloudSession? FindSessionByToken(string token); IReadOnlyList GetLoops(); + IReadOnlyList GetPeople(); IReadOnlyList ListUpdates(string? subsystem = null, string? filter = null); UpdateManifest? GetUpdateFrom(string? subsystem, string? fromVersion, string? filter); UpdateManifest CreateUpdate(string? fromVersion, string? toVersion, string? changes, string? shaHash, long? length, string? subsystem, string? filter, IDictionary? dependencies); 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 index 2e7762c..169a1c1 100644 --- 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 @@ -18,7 +18,7 @@ public interface IPersonalMemoryStore 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, string? PersonId = null); public enum PersonalAffinity { 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 3276d50..1a7303b 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 @@ -78,7 +78,7 @@ public sealed class JiboInteractionService( randomizer, personalMemoryStore, BuildWeatherReportDecisionAsync, - ResolveTenantScope, + turnContext => ResolveTenantScope(turnContext), cancellationToken); if (personalReportDecision is not null) { @@ -92,7 +92,7 @@ public sealed class JiboInteractionService( lowered, randomizer, personalMemoryStore, - ResolveTenantScope); + turnContext => ResolveTenantScope(turnContext)); if (householdListDecision is not null) { return householdListDecision; @@ -151,7 +151,7 @@ public sealed class JiboInteractionService( "good_night" => BuildReactiveGreetingDecision(turn, "good_night", referenceLocalTime), "welcome_back" => BuildReactiveGreetingDecision(turn, "welcome_back", referenceLocalTime), "memory_set_name" => BuildRememberNameDecision(turn, transcript), - "memory_get_name" => BuildRecallNameDecision(turn), + "memory_get_name" => BuildRecallNameDecision(turn, greetingPresence), "memory_set_birthday" => BuildRememberBirthdayDecision(turn, transcript), "memory_get_birthday" => BuildRecallBirthdayDecision(turn), "memory_set_important_date" => BuildRememberImportantDateDecision(turn, transcript), @@ -270,12 +270,18 @@ public sealed class JiboInteractionService( private string? ResolvePreferredGreetingName(TurnContext turn, GreetingPresenceProfile presence) { - var rememberedName = personalMemoryStore.GetName(ResolveTenantScope(turn)); + var rememberedName = personalMemoryStore.GetName(ResolveTenantScope(turn, presence.PrimaryPersonId)); if (!string.IsNullOrWhiteSpace(rememberedName)) { return ToDisplayName(rememberedName); } + var tenantRememberedName = personalMemoryStore.GetName(ResolveTenantScope(turn)); + if (!string.IsNullOrWhiteSpace(tenantRememberedName)) + { + return ToDisplayName(tenantRememberedName); + } + if (!string.IsNullOrWhiteSpace(presence.PrimaryPersonId) && presence.LoopUserFirstNames.TryGetValue(presence.PrimaryPersonId, out var firstName) && !string.IsNullOrWhiteSpace(firstName)) @@ -372,16 +378,35 @@ public sealed class JiboInteractionService( $"Nice to meet you, {name}. I will remember your name."); } - private JiboInteractionDecision BuildRecallNameDecision(TurnContext turn) + private JiboInteractionDecision BuildRecallNameDecision(TurnContext turn, GreetingPresenceProfile? presence = null) { - var name = personalMemoryStore.GetName(ResolveTenantScope(turn)); + var personScope = ResolveTenantScope(turn, presence?.PrimaryPersonId); + var name = personalMemoryStore.GetName(personScope); + if (string.IsNullOrWhiteSpace(name)) + { + name = personalMemoryStore.GetName(ResolveTenantScope(turn)); + } + + if (string.IsNullOrWhiteSpace(name) && + presence is not null && + !string.IsNullOrWhiteSpace(presence.PrimaryPersonId) && + presence.LoopUserFirstNames.TryGetValue(presence.PrimaryPersonId, out var firstName) && + !string.IsNullOrWhiteSpace(firstName)) + { + name = ToDisplayName(firstName); + } + + name = ToDisplayName(name ?? string.Empty); + return string.IsNullOrWhiteSpace(name) ? new JiboInteractionDecision( "memory_get_name", "I do not know your name yet. You can say, my name is Alex.") : new JiboInteractionDecision( "memory_get_name", - $"You told me your name is {name}."); + presence is not null && !string.IsNullOrWhiteSpace(presence.PrimaryPersonId) + ? $"I think you are {name}." + : $"You told me your name is {name}."); } private JiboInteractionDecision BuildRememberBirthdayDecision(TurnContext turn, string transcript) @@ -4137,12 +4162,15 @@ public sealed class JiboInteractionService( }; } - private static PersonalMemoryTenantScope ResolveTenantScope(TurnContext turn) + private static PersonalMemoryTenantScope ResolveTenantScope(TurnContext turn, string? personId = null) { 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); + var resolvedPersonId = !string.IsNullOrWhiteSpace(personId) + ? personId + : ReadTenantAttribute(turn, "personId") ?? ReadTenantAttribute(turn, "speakerId"); + return new PersonalMemoryTenantScope(accountId, loopId, deviceId, resolvedPersonId); } private static string? ReadTenantAttribute(TurnContext turn, string key) diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/PersonRecord.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/PersonRecord.cs new file mode 100644 index 0000000..3da37d0 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/PersonRecord.cs @@ -0,0 +1,14 @@ +namespace Jibo.Cloud.Domain.Models; + +public sealed class PersonRecord +{ + public string PersonId { get; init; } = "person-openjibo-owner"; + public string AccountId { get; init; } = "usr_openjibo_owner"; + public string LoopId { get; init; } = "openjibo-default-loop"; + public string RobotId { get; init; } = "my-robot-name"; + public string DisplayName { get; init; } = "Jibo Owner"; + public string? Alias { get; init; } + public bool IsPrimary { get; init; } = true; + public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedUtc { get; init; } = DateTimeOffset.UtcNow; +} 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 2a060a5..d419412 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 @@ -23,6 +23,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore private readonly List _media = []; private readonly List _backups = []; private readonly List _loops; + private readonly List _people; private DeviceRegistration _robot; private RobotProfile _robotProfile; @@ -60,6 +61,29 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore RobotFriendlyId = _robot.DeviceId } ]; + _people = + [ + new PersonRecord + { + PersonId = "person-openjibo-owner", + AccountId = _account.AccountId, + LoopId = _loops[0].LoopId, + RobotId = _robot.RobotId, + DisplayName = $"{_account.FirstName} {_account.LastName}", + Alias = _account.FirstName, + IsPrimary = true + }, + new PersonRecord + { + PersonId = "person-openjibo-household-member", + AccountId = _account.AccountId, + LoopId = _loops[0].LoopId, + RobotId = _robot.RobotId, + DisplayName = "OpenJibo Household Member", + Alias = "Household Member", + IsPrimary = false + } + ]; _updates = []; LoadPersistentState(); @@ -154,6 +178,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore public IReadOnlyList GetLoops() => _loops.ToArray(); + public IReadOnlyList GetPeople() => _people.ToArray(); + public IReadOnlyList ListUpdates(string? subsystem = null, string? filter = null) { return _updates 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 index 7bb060d..6326f85 100644 --- 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 @@ -148,7 +148,9 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore private static string BuildTenantKey(PersonalMemoryTenantScope tenantScope) { - return $"{tenantScope.AccountId}|{tenantScope.LoopId}|{tenantScope.DeviceId}"; + return string.IsNullOrWhiteSpace(tenantScope.PersonId) + ? $"{tenantScope.AccountId}|{tenantScope.LoopId}|{tenantScope.DeviceId}" + : $"{tenantScope.AccountId}|{tenantScope.LoopId}|{tenantScope.DeviceId}|{tenantScope.PersonId}"; } private static string NormalizeCategory(string category) diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/JiboCloudProtocolServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/JiboCloudProtocolServiceTests.cs index 6a9dccc..cc2bdee 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/JiboCloudProtocolServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/JiboCloudProtocolServiceTests.cs @@ -216,4 +216,17 @@ public sealed class JiboCloudProtocolServiceTests using var payload = JsonDocument.Parse(result.BodyText); Assert.Single(payload.RootElement.EnumerateArray()); } + + [Fact] + public void InMemoryCloudStateStore_SeedsPeopleForTheDefaultAccountLoop() + { + var store = new InMemoryCloudStateStore(); + + var people = store.GetPeople(); + + Assert.NotEmpty(people); + Assert.Contains(people, person => person.IsPrimary); + Assert.Contains(people, person => string.Equals(person.AccountId, store.GetAccount().AccountId, StringComparison.OrdinalIgnoreCase)); + Assert.Contains(people, person => string.Equals(person.LoopId, store.GetLoops()[0].LoopId, StringComparison.OrdinalIgnoreCase)); + } } diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs index d11400b..afcefd0 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs @@ -183,6 +183,54 @@ public sealed class JiboInteractionServiceTests Assert.True(DateTimeOffset.TryParse(decision.ContextUpdates[GreetingLastReactiveUtcKey]?.ToString(), out _)); } + [Fact] + public async Task BuildDecisionAsync_GoodMorning_UsesPersonScopedNameWhenSpeakerIsKnown() + { + var memoryStore = new InMemoryPersonalMemoryStore(); + memoryStore.SetName(new PersonalMemoryTenantScope("acct-a", "loop-a", "device-a", "person-1"), "alex"); + var service = CreateService(memoryStore); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "good morning", + NormalizedTranscript = "good morning", + Attributes = new Dictionary + { + ["accountId"] = "acct-a", + ["loopId"] = "loop-a", + ["context"] = """{"runtime":{"perception":{"speaker":"person-1"},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}""" + }, + DeviceId = "device-a" + }); + + Assert.Equal("good_morning", decision.IntentName); + Assert.Equal("Good morning, Alex. It is great to see you.", decision.ReplyText); + } + + [Fact] + public async Task BuildDecisionAsync_WhoAmI_UsesPersonScopedNameWhenSpeakerIsKnown() + { + var memoryStore = new InMemoryPersonalMemoryStore(); + memoryStore.SetName(new PersonalMemoryTenantScope("acct-b", "loop-b", "device-b", "person-2"), "sam"); + var service = CreateService(memoryStore); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "who am i", + NormalizedTranscript = "who am i", + Attributes = new Dictionary + { + ["accountId"] = "acct-b", + ["loopId"] = "loop-b", + ["context"] = """{"runtime":{"perception":{"speaker":"person-2"},"loop":{"users":[{"id":"person-2","firstName":"sam"}]}}}""" + }, + DeviceId = "device-b" + }); + + Assert.Equal("memory_get_name", decision.IntentName); + Assert.Equal("I think you are Sam.", decision.ReplyText); + } + [Fact] public async Task BuildDecisionAsync_TriggerWithKnownIdentity_BuildsProactiveGreetingAndContext() { @@ -803,7 +851,7 @@ public sealed class JiboInteractionServiceTests }); Assert.Equal("memory_get_name", recallDecision.IntentName); - Assert.Equal("You told me your name is alex.", recallDecision.ReplyText); + Assert.Equal("You told me your name is Alex.", recallDecision.ReplyText); } [Fact]