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
|
- 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user