Refine proactive greetings with morning and return-visit tones

This commit is contained in:
Jacob Dubin
2026-05-21 12:09:30 -05:00
parent e8d7bafcd6
commit c3b2e5fc2c
5 changed files with 121 additions and 16 deletions

View File

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

View File

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

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

View File

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

View File

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