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 - 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 - birthday-aware proactive greetings now use stored birthday memory on matching dates
- holiday-aware proactive greetings now use loop holiday records 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: - Exit criteria:
- presence-aware greetings are routed deterministically with tests - presence-aware greetings are routed deterministically with tests
- proactive greetings are frequency-bounded and do not trigger from surprise source when blocked by policy - 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 - 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 - 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 - 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 ## Implementation Slices

View File

@@ -362,7 +362,7 @@ First completed slice in this personal-report parity track:
1. MIM import foundation for personality expansion 1. MIM import foundation for personality expansion
2. Dialog parsing expansion 2. Dialog parsing expansion
3. Presence-aware greetings and identity-triggered proactivity 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 4. Personal report parity slices
5. Holidays and seasonal personality slice beyond pizza day 5. Holidays and seasonal personality slice beyond pizza day
6. Durable memory persistence path 6. Durable memory persistence path

View File

@@ -58,14 +58,11 @@ public sealed partial class JiboInteractionService
DateTimeOffset? referenceLocalTime) DateTimeOffset? referenceLocalTime)
{ {
var displayName = ResolvePreferredGreetingName(turn, presence); var displayName = ResolvePreferredGreetingName(turn, presence);
var greetingPrefix = ResolveTimeOfDayGreetingPrefix(referenceLocalTime);
var specialGreeting = ResolveSpecialGreetingPrefix(turn, presence, referenceLocalTime); var specialGreeting = ResolveSpecialGreetingPrefix(turn, presence, referenceLocalTime);
var route = specialGreeting?.Route ?? "ProactiveGreeting"; var route = specialGreeting?.Route ?? "ProactiveGreeting";
var intentName = specialGreeting?.IntentName ?? "proactive_greeting"; var intentName = specialGreeting?.IntentName ?? "proactive_greeting";
var replyText = specialGreeting is null var replyText = specialGreeting is null
? string.IsNullOrWhiteSpace(displayName) ? BuildProactiveGreetingReply(turn, presence, displayName, referenceLocalTime)
? $"{greetingPrefix}. I am glad to see you."
: $"{greetingPrefix}, {displayName}. Welcome back."
: string.IsNullOrWhiteSpace(displayName) : string.IsNullOrWhiteSpace(displayName)
? $"{specialGreeting.Prefix}. I am glad to see you." ? $"{specialGreeting.Prefix}. I am glad to see you."
: $"{specialGreeting.Prefix}, {displayName}. It is nice to celebrate with 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) private DateTimeOffset? ReadGreetingHistoryLastGreetedUtc(TurnContext turn, GreetingPresenceProfile presence)
{ {
var historyIdentity = ResolveGreetingHistoryIdentity(presence); var greetingHistory = ResolveGreetingHistoryRecord(turn, presence);
if (!string.IsNullOrWhiteSpace(historyIdentity) && cloudStateStore is not null) if (greetingHistory is not null && greetingHistory.LastGreetedUtc.HasValue)
{ return greetingHistory.LastGreetedUtc;
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;
}
return ReadTimestampAttribute(turn, LastProactiveGreetingUtcMetadataKey); 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) private static string? ResolveGreetingHistoryIdentity(GreetingPresenceProfile presence)
{ {
if (!string.IsNullOrWhiteSpace(presence.PrimaryPersonId)) return presence.PrimaryPersonId; 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( private SpecialGreetingPrefix? ResolveSpecialGreetingPrefix(
TurnContext turn, TurnContext turn,
GreetingPresenceProfile presence, GreetingPresenceProfile presence,

View File

@@ -348,6 +348,76 @@ public sealed class JiboInteractionServiceTests
greeting.LastGreetingIntent == "proactive_greeting"); 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] [Fact]
public async Task BuildDecisionAsync_TriggerOnBirthday_BuildsBirthdayGreeting() public async Task BuildDecisionAsync_TriggerOnBirthday_BuildsBirthdayGreeting()
{ {