diff --git a/OpenJibo/docs/feature-backlog.md b/OpenJibo/docs/feature-backlog.md index 988c66a..d44a7ab 100644 --- a/OpenJibo/docs/feature-backlog.md +++ b/OpenJibo/docs/feature-backlog.md @@ -725,6 +725,7 @@ Current release theme: - reactive and proactive greeting turns write back greeting-history records for later cooldown checks - birthday-aware proactive greetings now use stored birthday memory on matching dates - holiday-aware proactive greetings now use loop holiday records on matching dates + - morning proactive greetings now stay distinct from return-visit greetings - 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 693a999..cb2d945 100644 --- a/OpenJibo/docs/greetings-presence-plan.md +++ b/OpenJibo/docs/greetings-presence-plan.md @@ -66,7 +66,8 @@ Current implementation progress: - proactive greeting gating now consults stored greeting history first, then falls back to the current turn metadata - birthday-aware proactive greetings now use the loop/person birthday memory when the current date matches - holiday-aware proactive greetings now use the loop holiday calendar when the current date matches -- the remaining work is to broaden the presence policy surface so it can grow from reactive/proactive greeting split into richer day-part and return-visit behavior without reworking the storage seam again +- morning proactive greetings now stay distinct from return-visit greetings so a fresh start of day still sounds like a morning greeting +- the remaining work is to broaden the presence policy surface so it can grow into richer day-part and return-visit variations without reworking the storage seam again ## Implementation Slices diff --git a/OpenJibo/docs/release-1.0.19-plan.md b/OpenJibo/docs/release-1.0.19-plan.md index 278c480..5ea25f8 100644 --- a/OpenJibo/docs/release-1.0.19-plan.md +++ b/OpenJibo/docs/release-1.0.19-plan.md @@ -362,7 +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, per-person cooldown gating, and birthday/holiday-aware special-day greetings are now in place + - in progress: durable greeting-presence history, per-person cooldown gating, birthday/holiday-aware special-day greetings, and morning vs return-visit tone splits 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/Services/JiboInteractionService.PersonalityDecisions.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.PersonalityDecisions.cs index 529f83f..f70e3f5 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 @@ -58,14 +58,11 @@ public sealed partial class JiboInteractionService DateTimeOffset? referenceLocalTime) { var displayName = ResolvePreferredGreetingName(turn, presence); - var greetingPrefix = ResolveTimeOfDayGreetingPrefix(referenceLocalTime); var specialGreeting = ResolveSpecialGreetingPrefix(turn, presence, referenceLocalTime); var route = specialGreeting?.Route ?? "ProactiveGreeting"; var intentName = specialGreeting?.IntentName ?? "proactive_greeting"; var replyText = specialGreeting is null - ? string.IsNullOrWhiteSpace(displayName) - ? $"{greetingPrefix}. I am glad to see you." - : $"{greetingPrefix}, {displayName}. Welcome back." + ? BuildProactiveGreetingReply(turn, presence, displayName, referenceLocalTime) : string.IsNullOrWhiteSpace(displayName) ? $"{specialGreeting.Prefix}. I am glad to see you." : $"{specialGreeting.Prefix}, {displayName}. It is nice to celebrate with you."; @@ -137,20 +134,23 @@ public sealed partial class JiboInteractionService 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; - } + var greetingHistory = ResolveGreetingHistoryRecord(turn, presence); + if (greetingHistory is not null && greetingHistory.LastGreetedUtc.HasValue) + return greetingHistory.LastGreetedUtc; return ReadTimestampAttribute(turn, LastProactiveGreetingUtcMetadataKey); } + private GreetingPresenceRecord? ResolveGreetingHistoryRecord(TurnContext turn, GreetingPresenceProfile presence) + { + var historyIdentity = ResolveGreetingHistoryIdentity(presence); + if (string.IsNullOrWhiteSpace(historyIdentity) || cloudStateStore is null) return null; + + var loopId = ReadTenantAttribute(turn, "loopId") ?? "openjibo-default-loop"; + return cloudStateStore.GetGreetingPresences(loopId) + .FirstOrDefault(record => record.PersonId.Equals(historyIdentity, StringComparison.OrdinalIgnoreCase)); + } + private static string? ResolveGreetingHistoryIdentity(GreetingPresenceProfile presence) { if (!string.IsNullOrWhiteSpace(presence.PrimaryPersonId)) return presence.PrimaryPersonId; @@ -228,6 +228,39 @@ public sealed partial class JiboInteractionService }; } + private string BuildProactiveGreetingReply( + TurnContext turn, + GreetingPresenceProfile presence, + string? displayName, + DateTimeOffset? referenceLocalTime) + { + var greetingHistory = ResolveGreetingHistoryRecord(turn, presence); + var greetingPrefix = ResolveProactiveGreetingPrefix(referenceLocalTime, greetingHistory); + + if (string.Equals(greetingPrefix, "Welcome back", StringComparison.OrdinalIgnoreCase)) + return string.IsNullOrWhiteSpace(displayName) + ? "Welcome back. I am glad to see you again." + : $"Welcome back, {displayName}. I am glad to see you again."; + + return string.IsNullOrWhiteSpace(displayName) + ? $"{greetingPrefix}. I am glad to see you." + : $"{greetingPrefix}, {displayName}. It is great to see you."; + } + + private static string ResolveProactiveGreetingPrefix( + DateTimeOffset? referenceLocalTime, + GreetingPresenceRecord? greetingHistory) + { + var hour = (referenceLocalTime ?? DateTimeOffset.UtcNow).Hour; + var isMorning = hour >= 5 && hour < 12; + var recentGreeting = greetingHistory?.LastGreetedUtc is not null && + DateTimeOffset.UtcNow - greetingHistory.LastGreetedUtc.Value < TimeSpan.FromHours(8); + + if (recentGreeting) return "Welcome back"; + + return isMorning ? "Good morning" : ResolveTimeOfDayGreetingPrefix(referenceLocalTime); + } + private SpecialGreetingPrefix? ResolveSpecialGreetingPrefix( TurnContext turn, GreetingPresenceProfile presence, diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs index 74ea95d..a8d1b72 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs @@ -348,6 +348,76 @@ public sealed class JiboInteractionServiceTests greeting.LastGreetingIntent == "proactive_greeting"); } + [Fact] + public async Task BuildDecisionAsync_TriggerInTheMorning_UsesGoodMorningProactiveTone() + { + var memoryStore = new InMemoryPersonalMemoryStore(); + memoryStore.SetName(new PersonalMemoryTenantScope("acct-morning", "loop-morning", "device-morning", "person-9"), + "jake"); + var service = CreateService(memoryStore); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = string.Empty, + NormalizedTranscript = string.Empty, + Attributes = new Dictionary + { + ["accountId"] = "acct-morning", + ["loopId"] = "loop-morning", + ["messageType"] = "TRIGGER", + ["triggerSource"] = "PRESENCE", + ["context"] = + """{"runtime":{"location":{"iso":"2026-05-21T09:00:00-05:00"},"perception":{"speaker":"person-9","peoplePresent":[{"id":"person-9"}]},"loop":{"users":[{"id":"person-9","firstName":"jake"}]}}}""" + }, + DeviceId = "device-morning" + }); + + Assert.Equal("proactive_greeting", decision.IntentName); + Assert.Contains("Good morning, Jake", decision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("welcome back", decision.ReplyText, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task BuildDecisionAsync_TriggerWithRecentGreetingHistory_UsesWelcomeBackTone() + { + var memoryStore = new InMemoryPersonalMemoryStore(); + memoryStore.SetName(new PersonalMemoryTenantScope("acct-return", "loop-return", "device-return", "person-11"), + "jake"); + var cloudStateStore = new InMemoryCloudStateStore(); + cloudStateStore.UpsertGreetingPresence(new GreetingPresenceRecord + { + LoopId = "loop-return", + PersonId = "person-11", + SpeakerId = "person-11", + PreferredName = "Jake", + LastSeenUtc = DateTimeOffset.UtcNow.AddHours(-1), + LastGreetedUtc = DateTimeOffset.UtcNow.AddHours(-1), + LastGreetingRoute = "ProactiveGreeting", + LastGreetingIntent = "proactive_greeting" + }); + var service = CreateService(memoryStore, cloudStateStore); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = string.Empty, + NormalizedTranscript = string.Empty, + Attributes = new Dictionary + { + ["accountId"] = "acct-return", + ["loopId"] = "loop-return", + ["messageType"] = "TRIGGER", + ["triggerSource"] = "PRESENCE", + ["context"] = + """{"runtime":{"location":{"iso":"2026-05-21T15:00:00-05:00"},"perception":{"speaker":"person-11","peoplePresent":[{"id":"person-11"}]},"loop":{"users":[{"id":"person-11","firstName":"jake"}]}}}""" + }, + DeviceId = "device-return" + }); + + Assert.Equal("proactive_greeting", decision.IntentName); + Assert.Contains("Welcome back, Jake", decision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("again", decision.ReplyText, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task BuildDecisionAsync_TriggerOnBirthday_BuildsBirthdayGreeting() {