From 70b1b1547f38063a17dbd45d2eae7e04b3f07a7f Mon Sep 17 00:00:00 2001 From: Jacob Dubin Date: Thu, 21 May 2026 12:01:13 -0500 Subject: [PATCH] Track durable greeting history for presence-aware greetings --- OpenJibo/docs/feature-backlog.md | 6 +- OpenJibo/docs/greetings-presence-plan.md | 7 ++ OpenJibo/docs/release-1.0.19-plan.md | 1 + .../Abstractions/ICloudStateStore.cs | 4 +- ...InteractionService.PersonalityDecisions.cs | 57 +++++++++++++++- .../Models/GreetingPresenceRecord.cs | 17 +++++ .../Persistence/InMemoryCloudStateStore.cs | 68 ++++++++++++++++++- .../Infrastructure/PersistenceStoreTests.cs | 17 ++++- .../WebSockets/JiboInteractionServiceTests.cs | 53 +++++++++++++-- 9 files changed, 220 insertions(+), 10 deletions(-) create mode 100644 OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/GreetingPresenceRecord.cs diff --git a/OpenJibo/docs/feature-backlog.md b/OpenJibo/docs/feature-backlog.md index cbefc69..8391175 100644 --- a/OpenJibo/docs/feature-backlog.md +++ b/OpenJibo/docs/feature-backlog.md @@ -702,7 +702,7 @@ Current release theme: ### 26. Presence-Aware Greetings And Identity Proactivity -- Status: `ready` +- Status: `in_progress` - Tags: `protocol`, `content`, `storage`, `docs` - Why now: - this is the next personality-charm expansion after parser guardrail and weather bring-up @@ -719,6 +719,10 @@ Current release theme: - add greeting intent families and state-machine split for reactive vs proactive greeting routes - add cooldown and trigger-source guardrails for proactive greetings - start person-aware greeting hooks (name-aware greeting, morning greeting policy, return greeting policy) +- Shipped so far: + - durable greeting-presence records now persist last-seen and last-greeted per person/loop + - proactive greeting gating now consults cloud greeting history when available + - reactive and proactive greeting turns write back greeting-history records for later cooldown checks - Exit criteria: - presence-aware greetings are routed deterministically with tests - proactive greetings are frequency-bounded and do not trigger from surprise source when blocked by policy diff --git a/OpenJibo/docs/greetings-presence-plan.md b/OpenJibo/docs/greetings-presence-plan.md index 01a7e8d..271b724 100644 --- a/OpenJibo/docs/greetings-presence-plan.md +++ b/OpenJibo/docs/greetings-presence-plan.md @@ -59,6 +59,13 @@ Main gap: - no first-class presence/identity perception extraction from runtime context for greeting policy decisions +Current implementation progress: + +- runtime presence parsing now extracts speaker, people-present ids, and loop user first names +- reactive and proactive greeting turns now write durable greeting-presence history into cloud state +- proactive greeting gating now consults stored greeting history first, then falls back to the current turn metadata +- the remaining work is to broaden the presence policy surface so it can grow from reactive/proactive greeting split into richer day-part, birthday, and holiday behavior without reworking the storage seam again + ## Implementation Slices ### Slice G1: Presence Context Extraction And Session Snapshot diff --git a/OpenJibo/docs/release-1.0.19-plan.md b/OpenJibo/docs/release-1.0.19-plan.md index a79df18..abd2576 100644 --- a/OpenJibo/docs/release-1.0.19-plan.md +++ b/OpenJibo/docs/release-1.0.19-plan.md @@ -362,6 +362,7 @@ First completed slice in this personal-report parity track: 1. MIM import foundation for personality expansion 2. Dialog parsing expansion 3. Presence-aware greetings and identity-triggered proactivity + - in progress: durable greeting-presence history and per-person cooldown gating are now in place 4. Personal report parity slices 5. Holidays and seasonal personality slice beyond pizza day 6. Durable memory persistence path 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 c6cdfc2..917cb4e 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 @@ -48,5 +48,7 @@ public interface ICloudStateStore CommuteProfileRecord UpsertCommuteProfile(CommuteProfileRecord commuteProfile); IReadOnlyList GetCalendarEvents(string? loopId = null); CalendarEventRecord UpsertCalendarEvent(CalendarEventRecord calendarEvent); + IReadOnlyList GetGreetingPresences(string? loopId = null); + GreetingPresenceRecord UpsertGreetingPresence(GreetingPresenceRecord greetingPresence); void UpdateRobot(DeviceRegistration registration); -} \ No newline at end of file +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.PersonalityDecisions.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.PersonalityDecisions.cs index d72afa2..e833c70 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.PersonalityDecisions.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.PersonalityDecisions.cs @@ -45,6 +45,7 @@ public sealed partial class JiboInteractionService var presence = ResolveGreetingPresenceProfile(turn); var displayName = ResolvePreferredGreetingName(turn, presence); var replyText = BuildReactiveGreetingReply(greetingIntent, displayName, referenceLocalTime); + RecordGreetingPresence(turn, presence, "ReactiveGreeting", greetingIntent, displayName, proactive: false); return new JiboInteractionDecision( greetingIntent, replyText, @@ -61,6 +62,7 @@ public sealed partial class JiboInteractionService var replyText = string.IsNullOrWhiteSpace(displayName) ? $"{greetingPrefix}. I am glad to see you." : $"{greetingPrefix}, {displayName}. Welcome back."; + RecordGreetingPresence(turn, presence, "ProactiveGreeting", "proactive_greeting", displayName, proactive: true); return new JiboInteractionDecision( "proactive_greeting", replyText, @@ -113,7 +115,7 @@ public sealed partial class JiboInteractionService : CultureInfo.InvariantCulture.TextInfo.ToTitleCase(trimmed); } - private static bool ShouldHandleProactiveGreetingTrigger( + private bool ShouldHandleProactiveGreetingTrigger( TurnContext turn, string? triggerSource, GreetingPresenceProfile presence) @@ -122,10 +124,32 @@ public sealed partial class JiboInteractionService if (!presence.HasKnownIdentity) return false; - var lastGreetingUtc = ReadTimestampAttribute(turn, LastProactiveGreetingUtcMetadataKey); + var lastGreetingUtc = ReadGreetingHistoryLastGreetedUtc(turn, presence); return !lastGreetingUtc.HasValue || DateTimeOffset.UtcNow - lastGreetingUtc.Value >= ProactiveGreetingCooldown; } + private DateTimeOffset? ReadGreetingHistoryLastGreetedUtc(TurnContext turn, GreetingPresenceProfile presence) + { + var historyIdentity = ResolveGreetingHistoryIdentity(presence); + if (!string.IsNullOrWhiteSpace(historyIdentity) && cloudStateStore is not null) + { + var loopId = ReadTenantAttribute(turn, "loopId") ?? "openjibo-default-loop"; + var greetingHistory = cloudStateStore.GetGreetingPresences(loopId) + .FirstOrDefault(record => + record.PersonId.Equals(historyIdentity, StringComparison.OrdinalIgnoreCase)); + if (greetingHistory is not null && greetingHistory.LastGreetedUtc.HasValue) + return greetingHistory.LastGreetedUtc; + } + + return ReadTimestampAttribute(turn, LastProactiveGreetingUtcMetadataKey); + } + + private static string? ResolveGreetingHistoryIdentity(GreetingPresenceProfile presence) + { + if (!string.IsNullOrWhiteSpace(presence.PrimaryPersonId)) return presence.PrimaryPersonId; + return !string.IsNullOrWhiteSpace(presence.SpeakerId) ? presence.SpeakerId : null; + } + private static DateTimeOffset? ReadTimestampAttribute(TurnContext turn, string key) { if (!turn.Attributes.TryGetValue(key, out var value) || value is null) return null; @@ -155,6 +179,35 @@ public sealed partial class JiboInteractionService return updates; } + private void RecordGreetingPresence( + TurnContext turn, + GreetingPresenceProfile presence, + string route, + string intentName, + string? preferredName, + bool proactive) + { + if (cloudStateStore is null) return; + + var identityId = ResolveGreetingHistoryIdentity(presence); + if (string.IsNullOrWhiteSpace(identityId)) return; + + var now = DateTimeOffset.UtcNow; + var tenantScope = ResolveTenantScope(turn, identityId); + cloudStateStore.UpsertGreetingPresence(new GreetingPresenceRecord + { + AccountId = tenantScope.AccountId, + LoopId = tenantScope.LoopId, + PersonId = identityId, + SpeakerId = presence.SpeakerId, + PreferredName = preferredName, + LastSeenUtc = now, + LastGreetedUtc = now, + LastGreetingRoute = route, + LastGreetingIntent = intentName + }); + } + private static string ResolveTimeOfDayGreetingPrefix(DateTimeOffset? referenceLocalTime) { var hour = (referenceLocalTime ?? DateTimeOffset.UtcNow).Hour; diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/GreetingPresenceRecord.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/GreetingPresenceRecord.cs new file mode 100644 index 0000000..19c9f6f --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/GreetingPresenceRecord.cs @@ -0,0 +1,17 @@ +namespace Jibo.Cloud.Domain.Models; + +public sealed class GreetingPresenceRecord +{ + public string Id { get; init; } = $"greeting-presence-{Guid.NewGuid():N}"; + public string AccountId { get; init; } = "usr_openjibo_owner"; + public string LoopId { get; init; } = "openjibo-default-loop"; + public string PersonId { get; init; } = string.Empty; + public string? SpeakerId { get; init; } + public string? PreferredName { get; init; } + public DateTimeOffset LastSeenUtc { get; init; } = DateTimeOffset.UtcNow; + public DateTimeOffset? LastGreetedUtc { get; init; } + public string? LastGreetingRoute { get; init; } + public string? LastGreetingIntent { get; init; } + 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 4862277..b66e609 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 @@ -24,6 +24,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore private readonly IHolidayCalendarProvider _holidayCalendarProvider; private readonly List _holidayOverrides = []; + private readonly List _greetingPresences = []; private readonly ConcurrentDictionary _keyRequests = new(StringComparer.OrdinalIgnoreCase); @@ -170,6 +171,9 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore _calendarEvents.Clear(); _calendarEvents.AddRange(snapshot.CalendarEvents ?? []); + _greetingPresences.Clear(); + _greetingPresences.AddRange(snapshot.GreetingPresences ?? []); + _loops.Clear(); _loops.AddRange(snapshot.Loops ?? []); @@ -225,6 +229,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore Backups = _backups.ToArray(), CommuteProfiles = _commuteProfiles.ToArray(), CalendarEvents = _calendarEvents.ToArray(), + GreetingPresences = _greetingPresences.ToArray(), Loops = _loops.ToArray(), Holidays = _holidayOverrides.ToArray(), People = _people.ToArray() @@ -540,6 +545,66 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore return resolvedCalendarEvent; } + public IReadOnlyList GetGreetingPresences(string? loopId = null) + { + var resolvedLoopId = string.IsNullOrWhiteSpace(loopId) ? ResolveDefaultLoopId() : loopId.Trim(); + return _greetingPresences + .Where(greeting => + string.Equals(greeting.LoopId, resolvedLoopId, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(static greeting => greeting.LastGreetedUtc ?? greeting.LastSeenUtc) + .ThenByDescending(static greeting => greeting.UpdatedUtc) + .ToArray(); + } + + public GreetingPresenceRecord UpsertGreetingPresence(GreetingPresenceRecord greetingPresence) + { + var resolvedLoopId = string.IsNullOrWhiteSpace(greetingPresence.LoopId) + ? ResolveDefaultLoopId() + : greetingPresence.LoopId.Trim(); + var resolvedPersonId = string.IsNullOrWhiteSpace(greetingPresence.PersonId) + ? greetingPresence.SpeakerId?.Trim() ?? "unknown-person" + : greetingPresence.PersonId.Trim(); + var normalizedId = string.IsNullOrWhiteSpace(greetingPresence.Id) + ? $"greeting-presence-{resolvedLoopId}-{Slugify(resolvedPersonId)}" + : greetingPresence.Id.Trim(); + var now = DateTimeOffset.UtcNow; + var resolvedPresence = new GreetingPresenceRecord + { + Id = normalizedId, + AccountId = string.IsNullOrWhiteSpace(greetingPresence.AccountId) + ? _account.AccountId + : greetingPresence.AccountId.Trim(), + LoopId = resolvedLoopId, + PersonId = resolvedPersonId, + SpeakerId = string.IsNullOrWhiteSpace(greetingPresence.SpeakerId) ? null : greetingPresence.SpeakerId.Trim(), + PreferredName = string.IsNullOrWhiteSpace(greetingPresence.PreferredName) + ? null + : greetingPresence.PreferredName.Trim(), + LastSeenUtc = greetingPresence.LastSeenUtc == default ? now : greetingPresence.LastSeenUtc, + LastGreetedUtc = greetingPresence.LastGreetedUtc, + LastGreetingRoute = string.IsNullOrWhiteSpace(greetingPresence.LastGreetingRoute) + ? null + : greetingPresence.LastGreetingRoute.Trim(), + LastGreetingIntent = string.IsNullOrWhiteSpace(greetingPresence.LastGreetingIntent) + ? null + : greetingPresence.LastGreetingIntent.Trim(), + CreatedUtc = greetingPresence.CreatedUtc == default ? now : greetingPresence.CreatedUtc, + UpdatedUtc = now + }; + + var existingIndex = _greetingPresences.FindIndex(existing => + string.Equals(existing.LoopId, resolvedLoopId, StringComparison.OrdinalIgnoreCase) && + string.Equals(existing.PersonId, resolvedPersonId, StringComparison.OrdinalIgnoreCase)); + + if (existingIndex >= 0) + _greetingPresences[existingIndex] = resolvedPresence; + else + _greetingPresences.Add(resolvedPresence); + + TouchState(); + return resolvedPresence; + } + public bool ShouldCreateSymmetricKey(string loopId) { return !_symmetricKeys.ContainsKey(loopId); @@ -905,6 +970,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore public BackupRecord[]? Backups { get; init; } public CommuteProfileRecord[]? CommuteProfiles { get; init; } public CalendarEventRecord[]? CalendarEvents { get; init; } + public GreetingPresenceRecord[]? GreetingPresences { get; init; } public LoopRecord[]? Loops { get; init; } public HolidayRecord[]? Holidays { get; init; } public PersonRecord[]? People { get; init; } @@ -952,4 +1018,4 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore }; } } -} \ No newline at end of file +} diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/PersistenceStoreTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/PersistenceStoreTests.cs index f460026..412fc3d 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/PersistenceStoreTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/PersistenceStoreTests.cs @@ -119,6 +119,17 @@ public sealed class PersistenceStoreTests TimeLabel = "at 6:00 p.m.", Date = DateOnly.FromDateTime(DateTime.UtcNow) }); + var greetingPresence = firstStore.UpsertGreetingPresence(new GreetingPresenceRecord + { + LoopId = "openjibo-default-loop", + PersonId = "person-1", + SpeakerId = "person-1", + PreferredName = "Jake", + LastSeenUtc = DateTimeOffset.UtcNow.AddMinutes(-5), + LastGreetedUtc = DateTimeOffset.UtcNow.AddMinutes(-4), + LastGreetingRoute = "ProactiveGreeting", + LastGreetingIntent = "proactive_greeting" + }); var sessionToken = firstStore.IssueRobotToken("robot-123"); var device = firstStore.GetOrCreateDevice("robot-123", "3.2.1", "4.5.6"); firstStore.SavePersistedState(); @@ -137,6 +148,10 @@ public sealed class PersistenceStoreTests item => item.Id == commute.Id && item.Mode == commute.Mode); Assert.Contains(secondStore.GetCalendarEvents("openjibo-default-loop"), item => item.Id == calendarEvent.Id && item.Summary == calendarEvent.Summary); + Assert.Contains(secondStore.GetGreetingPresences("openjibo-default-loop"), + item => item.PersonId == greetingPresence.PersonId && + item.PreferredName == greetingPresence.PreferredName && + item.LastGreetingRoute == greetingPresence.LastGreetingRoute); Assert.NotNull(secondStore.FindSessionByToken(sessionToken)); Assert.Equal("3.2.1", secondStore.GetOrCreateDevice(device.DeviceId, null, null).FirmwareVersion); Assert.NotEmpty(secondStore.GetPeople()); @@ -189,4 +204,4 @@ public sealed class PersistenceStoreTests Saves.Add(snapshot); } } -} \ No newline at end of file +} diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs index c329953..97f07a7 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs @@ -168,8 +168,9 @@ public sealed class JiboInteractionServiceTests public async Task BuildDecisionAsync_GoodMorning_UsesReactiveGreetingWithRememberedName() { var memoryStore = new InMemoryPersonalMemoryStore(); + var cloudStateStore = new InMemoryCloudStateStore(); memoryStore.SetName(new PersonalMemoryTenantScope("acct-a", "loop-a", "device-a"), "jake"); - var service = CreateService(memoryStore); + var service = CreateService(memoryStore, cloudStateStore); var decision = await service.BuildDecisionAsync(new TurnContext { @@ -178,7 +179,9 @@ public sealed class JiboInteractionServiceTests Attributes = new Dictionary { ["accountId"] = "acct-a", - ["loopId"] = "loop-a" + ["loopId"] = "loop-a", + ["context"] = + """{"runtime":{"perception":{"speaker":"person-a","peoplePresent":[{"id":"person-a"}]},"loop":{"users":[{"id":"person-a","firstName":"jake"}]}}}""" }, DeviceId = "device-a" }); @@ -187,8 +190,12 @@ public sealed class JiboInteractionServiceTests Assert.Equal("Good morning, Jake. It is great to see you.", decision.ReplyText); Assert.NotNull(decision.ContextUpdates); Assert.Equal("ReactiveGreeting", decision.ContextUpdates![GreetingRouteKey]); - Assert.Equal(string.Empty, decision.ContextUpdates[GreetingSpeakerKey]); + Assert.Equal("person-a", decision.ContextUpdates[GreetingSpeakerKey]); Assert.True(DateTimeOffset.TryParse(decision.ContextUpdates[GreetingLastReactiveUtcKey]?.ToString(), out _)); + Assert.Contains(cloudStateStore.GetGreetingPresences("loop-a"), + greeting => greeting.PersonId == "person-a" && + greeting.LastGreetingRoute == "ReactiveGreeting" && + greeting.LastGreetingIntent == "good_morning"); } [Fact] @@ -313,7 +320,8 @@ public sealed class JiboInteractionServiceTests [Fact] public async Task BuildDecisionAsync_TriggerWithKnownIdentity_BuildsProactiveGreetingAndContext() { - var service = CreateService(); + var cloudStateStore = new InMemoryCloudStateStore(); + var service = CreateService(cloudStateStore: cloudStateStore); var decision = await service.BuildDecisionAsync(new TurnContext { @@ -334,6 +342,43 @@ public sealed class JiboInteractionServiceTests Assert.Equal("ProactiveGreeting", decision.ContextUpdates![GreetingRouteKey]); Assert.Equal("person-1", decision.ContextUpdates[GreetingSpeakerKey]); Assert.True(DateTimeOffset.TryParse(decision.ContextUpdates[GreetingLastProactiveUtcKey]?.ToString(), out _)); + Assert.Contains(cloudStateStore.GetGreetingPresences("openjibo-default-loop"), + greeting => greeting.PersonId == "person-1" && + greeting.LastGreetingRoute == "ProactiveGreeting" && + greeting.LastGreetingIntent == "proactive_greeting"); + } + + [Fact] + public async Task BuildDecisionAsync_TriggerWithKnownIdentity_SuppressesRepeatGreetingFromCloudHistory() + { + var cloudStateStore = new InMemoryCloudStateStore(); + cloudStateStore.UpsertGreetingPresence(new GreetingPresenceRecord + { + LoopId = "loop-history", + PersonId = "person-1", + SpeakerId = "person-1", + LastSeenUtc = DateTimeOffset.UtcNow.AddMinutes(-10), + LastGreetedUtc = DateTimeOffset.UtcNow, + LastGreetingRoute = "ProactiveGreeting", + LastGreetingIntent = "proactive_greeting" + }); + var service = CreateService(cloudStateStore: cloudStateStore); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = string.Empty, + NormalizedTranscript = string.Empty, + Attributes = new Dictionary + { + ["messageType"] = "TRIGGER", + ["triggerSource"] = "PRESENCE", + ["loopId"] = "loop-history", + ["context"] = + """{"runtime":{"perception":{"speaker":"person-1","peoplePresent":[{"id":"person-1"}]},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}""" + } + }); + + Assert.Equal("trigger_ignored", decision.IntentName); } [Fact]