diff --git a/OpenJibo/docs/feature-backlog.md b/OpenJibo/docs/feature-backlog.md index 8391175..988c66a 100644 --- a/OpenJibo/docs/feature-backlog.md +++ b/OpenJibo/docs/feature-backlog.md @@ -723,6 +723,8 @@ Current release theme: - 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 + - birthday-aware proactive greetings now use stored birthday memory on matching dates + - holiday-aware proactive greetings now use loop holiday records on matching dates - 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 271b724..693a999 100644 --- a/OpenJibo/docs/greetings-presence-plan.md +++ b/OpenJibo/docs/greetings-presence-plan.md @@ -64,7 +64,9 @@ 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 +- 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 ## Implementation Slices diff --git a/OpenJibo/docs/release-1.0.19-plan.md b/OpenJibo/docs/release-1.0.19-plan.md index abd2576..278c480 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 and per-person cooldown gating are now in place + - in progress: durable greeting-presence history, per-person cooldown gating, and birthday/holiday-aware special-day greetings 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 e833c70..529f83f 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 @@ -59,14 +59,21 @@ public sealed partial class JiboInteractionService { var displayName = ResolvePreferredGreetingName(turn, presence); var greetingPrefix = ResolveTimeOfDayGreetingPrefix(referenceLocalTime); - 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); + 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." + : string.IsNullOrWhiteSpace(displayName) + ? $"{specialGreeting.Prefix}. I am glad to see you." + : $"{specialGreeting.Prefix}, {displayName}. It is nice to celebrate with you."; + RecordGreetingPresence(turn, presence, route, intentName, displayName, proactive: true); return new JiboInteractionDecision( - "proactive_greeting", + intentName, replyText, - ContextUpdates: BuildGreetingContextUpdates("ProactiveGreeting", presence.PrimaryPersonId, true)); + ContextUpdates: BuildGreetingContextUpdates(route, presence.PrimaryPersonId, true)); } private static string BuildReactiveGreetingReply( @@ -208,6 +215,8 @@ public sealed partial class JiboInteractionService }); } + private sealed record SpecialGreetingPrefix(string Route, string IntentName, string Prefix); + private static string ResolveTimeOfDayGreetingPrefix(DateTimeOffset? referenceLocalTime) { var hour = (referenceLocalTime ?? DateTimeOffset.UtcNow).Hour; @@ -219,6 +228,58 @@ public sealed partial class JiboInteractionService }; } + private SpecialGreetingPrefix? ResolveSpecialGreetingPrefix( + TurnContext turn, + GreetingPresenceProfile presence, + DateTimeOffset? referenceLocalTime) + { + var today = DateOnly.FromDateTime((referenceLocalTime ?? DateTimeOffset.UtcNow).Date); + var birthday = ResolveBirthdayGreeting(turn, presence, today); + if (birthday is not null) return birthday; + + return ResolveHolidayGreeting(turn, today); + } + + private SpecialGreetingPrefix? ResolveBirthdayGreeting( + TurnContext turn, + GreetingPresenceProfile presence, + DateOnly today) + { + var identityScope = !string.IsNullOrWhiteSpace(presence.PrimaryPersonId) + ? ResolveTenantScope(turn, presence.PrimaryPersonId) + : ResolveTenantScope(turn); + + var birthdayText = personalMemoryStore.GetBirthday(identityScope) ?? + personalMemoryStore.GetBirthday(ResolveTenantScope(turn)); + if (string.IsNullOrWhiteSpace(birthdayText)) return null; + + var birthdayDate = TryParseBirthdayDate(birthdayText); + if (birthdayDate is null) return null; + + return birthdayDate.Value.Month == today.Month && birthdayDate.Value.Day == today.Day + ? new SpecialGreetingPrefix("ProactiveBirthdayGreeting", "proactive_birthday_greeting", + "Happy birthday") + : null; + } + + private SpecialGreetingPrefix? ResolveHolidayGreeting(TurnContext turn, DateOnly today) + { + if (cloudStateStore is null) return null; + + var loopId = ReadTenantAttribute(turn, "loopId") ?? "openjibo-default-loop"; + var holiday = cloudStateStore.GetHolidays(loopId) + .FirstOrDefault(item => + item.IsEnabled && + item.Category != "birthday" && + item.Date.Month == today.Month && + item.Date.Day == today.Day); + + return holiday is null + ? null + : new SpecialGreetingPrefix("ProactiveHolidayGreeting", "proactive_holiday_greeting", + "Happy holidays"); + } + private JiboInteractionDecision BuildPizzaDecision() { return BuildPizzaAnimationDecision("pizza", "One pizza, coming right up."); diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs index 97f07a7..74ea95d 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs @@ -348,6 +348,75 @@ public sealed class JiboInteractionServiceTests greeting.LastGreetingIntent == "proactive_greeting"); } + [Fact] + public async Task BuildDecisionAsync_TriggerOnBirthday_BuildsBirthdayGreeting() + { + var memoryStore = new InMemoryPersonalMemoryStore(); + memoryStore.SetName(new PersonalMemoryTenantScope("acct-bday", "loop-bday", "device-bday", "person-7"), + "jake"); + memoryStore.SetBirthday(new PersonalMemoryTenantScope("acct-bday", "loop-bday", "device-bday", "person-7"), + "March 14"); + var cloudStateStore = new InMemoryCloudStateStore(); + var service = CreateService(memoryStore, cloudStateStore); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = string.Empty, + NormalizedTranscript = string.Empty, + Attributes = new Dictionary + { + ["accountId"] = "acct-bday", + ["loopId"] = "loop-bday", + ["messageType"] = "TRIGGER", + ["triggerSource"] = "PRESENCE", + ["context"] = + """{"runtime":{"location":{"iso":"2026-03-14T09:00:00-05:00"},"perception":{"speaker":"person-7","peoplePresent":[{"id":"person-7"}]},"loop":{"users":[{"id":"person-7","firstName":"jake"}]}}}""" + }, + DeviceId = "device-bday" + }); + + Assert.Equal("proactive_birthday_greeting", decision.IntentName); + Assert.Contains("Happy birthday", decision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Jake", decision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Equal("ProactiveBirthdayGreeting", decision.ContextUpdates![GreetingRouteKey]); + } + + [Fact] + public async Task BuildDecisionAsync_TriggerOnHoliday_BuildsHolidayGreeting() + { + var cloudStateStore = new InMemoryCloudStateStore(); + cloudStateStore.UpsertHoliday(new HolidayRecord + { + LoopId = "loop-holiday", + Name = "Christmas", + Category = "holiday", + Date = new DateOnly(2026, 12, 25), + IsEnabled = true, + Source = "manual", + CountryCode = "US" + }); + var service = CreateService(cloudStateStore: cloudStateStore); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = string.Empty, + NormalizedTranscript = string.Empty, + Attributes = new Dictionary + { + ["accountId"] = "acct-holiday", + ["loopId"] = "loop-holiday", + ["messageType"] = "TRIGGER", + ["triggerSource"] = "PRESENCE", + ["context"] = + """{"runtime":{"location":{"iso":"2026-12-25T09:00:00-05:00"},"perception":{"speaker":"person-8","peoplePresent":[{"id":"person-8"}]},"loop":{"users":[{"id":"person-8","firstName":"jake"}]}}}""" + } + }); + + Assert.Equal("proactive_holiday_greeting", decision.IntentName); + Assert.Contains("Happy holidays", decision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Equal("ProactiveHolidayGreeting", decision.ContextUpdates![GreetingRouteKey]); + } + [Fact] public async Task BuildDecisionAsync_TriggerWithKnownIdentity_SuppressesRepeatGreetingFromCloudHistory() {