Add birthday and holiday special-day greetings

This commit is contained in:
Jacob Dubin
2026-05-21 12:05:46 -05:00
parent 70b1b1547f
commit e8d7bafcd6
5 changed files with 142 additions and 8 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.");

View File

@@ -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<string, object?>
{
["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<string, object?>
{
["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()
{