Refine proactive greetings with morning and return-visit tones
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user