Add birthday and holiday special-day greetings
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user