Expand affinity parser guardrails with Pegasus phrases
This commit is contained in:
@@ -20,6 +20,10 @@ public sealed class JiboInteractionServiceTests
|
||||
private const string ChitchatStateKey = "chitchatState";
|
||||
private const string ChitchatRouteKey = "chitchatRoute";
|
||||
private const string ChitchatEmotionKey = "chitchatEmotion";
|
||||
private const string GreetingRouteKey = "greetingsRoute";
|
||||
private const string GreetingSpeakerKey = "greetingsSpeaker";
|
||||
private const string GreetingLastProactiveUtcKey = "greetingsLastProactiveUtc";
|
||||
private const string GreetingLastReactiveUtcKey = "greetingsLastReactiveUtc";
|
||||
|
||||
[Fact]
|
||||
public async Task BuildDecisionAsync_Joke_UsesCatalogBackedRandomContent()
|
||||
@@ -150,6 +154,103 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal("My birthday is March 22, 2026.", decision.ReplyText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildDecisionAsync_GoodMorning_UsesReactiveGreetingWithRememberedName()
|
||||
{
|
||||
var memoryStore = new InMemoryPersonalMemoryStore();
|
||||
memoryStore.SetName(new PersonalMemoryTenantScope("acct-a", "loop-a", "device-a"), "jake");
|
||||
var service = CreateService(memoryStore);
|
||||
|
||||
var decision = await service.BuildDecisionAsync(new TurnContext
|
||||
{
|
||||
RawTranscript = "good morning",
|
||||
NormalizedTranscript = "good morning",
|
||||
Attributes = new Dictionary<string, object?>
|
||||
{
|
||||
["accountId"] = "acct-a",
|
||||
["loopId"] = "loop-a"
|
||||
},
|
||||
DeviceId = "device-a"
|
||||
});
|
||||
|
||||
Assert.Equal("good_morning", decision.IntentName);
|
||||
Assert.Equal("Good morning, Jake. It is great to see you.", decision.ReplyText);
|
||||
Assert.NotNull(decision.ContextUpdates);
|
||||
Assert.Equal("ReactiveGreeting", decision.ContextUpdates![GreetingRouteKey]);
|
||||
Assert.Equal(string.Empty, decision.ContextUpdates[GreetingSpeakerKey]);
|
||||
Assert.True(DateTimeOffset.TryParse(decision.ContextUpdates[GreetingLastReactiveUtcKey]?.ToString(), out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildDecisionAsync_TriggerWithKnownIdentity_BuildsProactiveGreetingAndContext()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var decision = await service.BuildDecisionAsync(new TurnContext
|
||||
{
|
||||
RawTranscript = string.Empty,
|
||||
NormalizedTranscript = string.Empty,
|
||||
Attributes = new Dictionary<string, object?>
|
||||
{
|
||||
["messageType"] = "TRIGGER",
|
||||
["triggerSource"] = "PRESENCE",
|
||||
["context"] = """{"runtime":{"perception":{"speaker":"person-1","peoplePresent":[{"id":"person-1"}]},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}"""
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Equal("proactive_greeting", decision.IntentName);
|
||||
Assert.Contains("Jake", decision.ReplyText, StringComparison.Ordinal);
|
||||
Assert.NotNull(decision.ContextUpdates);
|
||||
Assert.Equal("ProactiveGreeting", decision.ContextUpdates![GreetingRouteKey]);
|
||||
Assert.Equal("person-1", decision.ContextUpdates[GreetingSpeakerKey]);
|
||||
Assert.True(DateTimeOffset.TryParse(decision.ContextUpdates[GreetingLastProactiveUtcKey]?.ToString(), out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildDecisionAsync_TriggerFromSurprise_ReturnsSilentTriggerIgnoredDecision()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var decision = await service.BuildDecisionAsync(new TurnContext
|
||||
{
|
||||
RawTranscript = string.Empty,
|
||||
NormalizedTranscript = string.Empty,
|
||||
Attributes = new Dictionary<string, object?>
|
||||
{
|
||||
["messageType"] = "TRIGGER",
|
||||
["triggerSource"] = "SURPRISE",
|
||||
["context"] = """{"runtime":{"perception":{"speaker":"person-1"},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}"""
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Equal("trigger_ignored", decision.IntentName);
|
||||
Assert.Equal(string.Empty, decision.ReplyText);
|
||||
Assert.Equal("chitchat-skill", decision.SkillName);
|
||||
Assert.NotNull(decision.SkillPayload);
|
||||
Assert.Equal("completion_only", decision.SkillPayload!["cloudResponseMode"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildDecisionAsync_TriggerWithinCooldown_IsIgnored()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var decision = await service.BuildDecisionAsync(new TurnContext
|
||||
{
|
||||
RawTranscript = string.Empty,
|
||||
NormalizedTranscript = string.Empty,
|
||||
Attributes = new Dictionary<string, object?>
|
||||
{
|
||||
["messageType"] = "TRIGGER",
|
||||
["triggerSource"] = "PRESENCE",
|
||||
["context"] = """{"runtime":{"perception":{"speaker":"person-1"},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}""",
|
||||
[GreetingLastProactiveUtcKey] = DateTimeOffset.UtcNow.ToString("O")
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Equal("trigger_ignored", decision.IntentName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildDecisionAsync_DoYouHaveAPersonality_UsesCatalogBackedPersonalityReply()
|
||||
{
|
||||
@@ -691,6 +792,144 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal("Yes. You told me you like country music.", recallDecision.ReplyText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildDecisionAsync_AffinityMemory_PegasusWeLovePhrase_SetThenRecallWithinTenant()
|
||||
{
|
||||
var memoryStore = new InMemoryPersonalMemoryStore();
|
||||
var service = CreateService(memoryStore);
|
||||
|
||||
var setDecision = await service.BuildDecisionAsync(new TurnContext
|
||||
{
|
||||
RawTranscript = "we love pizza",
|
||||
NormalizedTranscript = "we love pizza",
|
||||
Attributes = new Dictionary<string, object?>
|
||||
{
|
||||
["accountId"] = "acct-a",
|
||||
["loopId"] = "loop-a"
|
||||
},
|
||||
DeviceId = "device-a"
|
||||
});
|
||||
|
||||
Assert.Equal("memory_set_affinity", setDecision.IntentName);
|
||||
Assert.Equal("Got it. I will remember you love pizza.", setDecision.ReplyText);
|
||||
|
||||
var recallDecision = await service.BuildDecisionAsync(new TurnContext
|
||||
{
|
||||
RawTranscript = "do i love pizza",
|
||||
NormalizedTranscript = "do i love pizza",
|
||||
Attributes = new Dictionary<string, object?>
|
||||
{
|
||||
["accountId"] = "acct-a",
|
||||
["loopId"] = "loop-a"
|
||||
},
|
||||
DeviceId = "device-a"
|
||||
});
|
||||
|
||||
Assert.Equal("memory_get_affinity", recallDecision.IntentName);
|
||||
Assert.Equal("Yes. You told me you love pizza.", recallDecision.ReplyText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildDecisionAsync_AffinityMemory_PegasusLoathePhrase_SetThenRecallWithinTenant()
|
||||
{
|
||||
var memoryStore = new InMemoryPersonalMemoryStore();
|
||||
var service = CreateService(memoryStore);
|
||||
|
||||
var setDecision = await service.BuildDecisionAsync(new TurnContext
|
||||
{
|
||||
RawTranscript = "I loathe celery",
|
||||
NormalizedTranscript = "I loathe celery",
|
||||
Attributes = new Dictionary<string, object?>
|
||||
{
|
||||
["accountId"] = "acct-a",
|
||||
["loopId"] = "loop-a"
|
||||
},
|
||||
DeviceId = "device-a"
|
||||
});
|
||||
|
||||
Assert.Equal("memory_set_affinity", setDecision.IntentName);
|
||||
Assert.Equal("Got it. I will remember you dislike celery.", setDecision.ReplyText);
|
||||
|
||||
var recallDecision = await service.BuildDecisionAsync(new TurnContext
|
||||
{
|
||||
RawTranscript = "do i loathe celery",
|
||||
NormalizedTranscript = "do i loathe celery",
|
||||
Attributes = new Dictionary<string, object?>
|
||||
{
|
||||
["accountId"] = "acct-a",
|
||||
["loopId"] = "loop-a"
|
||||
},
|
||||
DeviceId = "device-a"
|
||||
});
|
||||
|
||||
Assert.Equal("memory_get_affinity", recallDecision.IntentName);
|
||||
Assert.Equal("Yes. You told me you dislike celery.", recallDecision.ReplyText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildDecisionAsync_AffinityMemory_PegasusDoYouThinkLikeLookup_SetsAndRecalls()
|
||||
{
|
||||
var memoryStore = new InMemoryPersonalMemoryStore();
|
||||
var service = CreateService(memoryStore);
|
||||
|
||||
await service.BuildDecisionAsync(new TurnContext
|
||||
{
|
||||
RawTranscript = "I enjoy country music",
|
||||
NormalizedTranscript = "I enjoy country music",
|
||||
Attributes = new Dictionary<string, object?>
|
||||
{
|
||||
["accountId"] = "acct-a",
|
||||
["loopId"] = "loop-a"
|
||||
},
|
||||
DeviceId = "device-a"
|
||||
});
|
||||
|
||||
var recallDecision = await service.BuildDecisionAsync(new TurnContext
|
||||
{
|
||||
RawTranscript = "do you think i like country music",
|
||||
NormalizedTranscript = "do you think i like country music",
|
||||
Attributes = new Dictionary<string, object?>
|
||||
{
|
||||
["accountId"] = "acct-a",
|
||||
["loopId"] = "loop-a"
|
||||
},
|
||||
DeviceId = "device-a"
|
||||
});
|
||||
|
||||
Assert.Equal("memory_get_affinity", recallDecision.IntentName);
|
||||
Assert.Equal("Yes. You told me you like country music.", recallDecision.ReplyText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildDecisionAsync_AffinitySetAttemptWithoutItem_RoutesToAffinityPrompt()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var decision = await service.BuildDecisionAsync(new TurnContext
|
||||
{
|
||||
RawTranscript = "we like",
|
||||
NormalizedTranscript = "we like"
|
||||
});
|
||||
|
||||
Assert.Equal("memory_set_affinity", decision.IntentName);
|
||||
Assert.Equal("I can remember it if you say, I like pizza or I dislike mushrooms.", decision.ReplyText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildDecisionAsync_AffinityRecallAttemptWithoutItem_RoutesToRecallPrompt()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var decision = await service.BuildDecisionAsync(new TurnContext
|
||||
{
|
||||
RawTranscript = "do you think i like",
|
||||
NormalizedTranscript = "do you think i like"
|
||||
});
|
||||
|
||||
Assert.Equal("memory_get_affinity", decision.IntentName);
|
||||
Assert.Equal("Ask me like this: do I like pizza?", decision.ReplyText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildDecisionAsync_PreferenceReversePhrase_ParsesFavoriteVariant()
|
||||
{
|
||||
|
||||
@@ -491,6 +491,75 @@ public sealed class JiboWebSocketServiceTests
|
||||
Assert.Equal("i do like pizza", listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BufferedAudio_WithIncompletePegasusWeAffinityHint_DefersThenFinalizesWhenContinuationArrives()
|
||||
{
|
||||
await _service.HandleMessageAsync(new WebSocketMessageEnvelope
|
||||
{
|
||||
HostName = "neo-hub.jibo.com",
|
||||
Path = "/listen",
|
||||
Kind = "neo-hub-listen",
|
||||
Token = "hub-affinity-we-continuation-token",
|
||||
Text = """{"type":"LISTEN","transID":"trans-affinity-we-continuation","data":{"rules":["launch"]}}"""
|
||||
});
|
||||
|
||||
await _service.HandleMessageAsync(new WebSocketMessageEnvelope
|
||||
{
|
||||
HostName = "neo-hub.jibo.com",
|
||||
Path = "/listen",
|
||||
Kind = "neo-hub-listen",
|
||||
Token = "hub-affinity-we-continuation-token",
|
||||
Text = """{"type":"CONTEXT","transID":"trans-affinity-we-continuation","data":{"audioTranscriptHint":"we like"}}"""
|
||||
});
|
||||
|
||||
for (var index = 0; index < 4; index += 1)
|
||||
{
|
||||
var chunkReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope
|
||||
{
|
||||
HostName = "neo-hub.jibo.com",
|
||||
Path = "/listen",
|
||||
Kind = "neo-hub-listen",
|
||||
Token = "hub-affinity-we-continuation-token",
|
||||
Binary = new byte[3000]
|
||||
});
|
||||
|
||||
Assert.Empty(chunkReplies);
|
||||
}
|
||||
|
||||
var session = _store.FindSessionByToken("hub-affinity-we-continuation-token");
|
||||
Assert.NotNull(session);
|
||||
session.TurnState.FirstAudioReceivedUtc = DateTimeOffset.UtcNow - TimeSpan.FromSeconds(2);
|
||||
|
||||
var deferredReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope
|
||||
{
|
||||
HostName = "neo-hub.jibo.com",
|
||||
Path = "/listen",
|
||||
Kind = "neo-hub-listen",
|
||||
Token = "hub-affinity-we-continuation-token",
|
||||
Binary = new byte[3000]
|
||||
});
|
||||
|
||||
Assert.Empty(deferredReplies);
|
||||
|
||||
var finalizedReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope
|
||||
{
|
||||
HostName = "neo-hub.jibo.com",
|
||||
Path = "/listen",
|
||||
Kind = "neo-hub-listen",
|
||||
Token = "hub-affinity-we-continuation-token",
|
||||
Text = """{"type":"CONTEXT","transID":"trans-affinity-we-continuation","data":{"audioTranscriptHint":"we like pizza"}}"""
|
||||
});
|
||||
|
||||
Assert.Equal(3, finalizedReplies.Count);
|
||||
Assert.Equal("LISTEN", ReadReplyType(finalizedReplies[0]));
|
||||
Assert.Equal("EOS", ReadReplyType(finalizedReplies[1]));
|
||||
Assert.Equal("SKILL_ACTION", ReadReplyType(finalizedReplies[2]));
|
||||
|
||||
using var listenPayload = JsonDocument.Parse(finalizedReplies[0].Text!);
|
||||
Assert.Equal("memory_set_affinity", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString());
|
||||
Assert.Equal("we like pizza", listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultiChunkAudio_AccumulatesBufferedStateAcrossMessages()
|
||||
{
|
||||
@@ -3501,6 +3570,122 @@ public sealed class JiboWebSocketServiceTests
|
||||
Assert.False(session.Metadata.ContainsKey("pendingProactivityOffer"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerPresence_WithIdentity_EmitsProactiveGreetingAndPersistsGreetingMetadata()
|
||||
{
|
||||
var token = _store.IssueRobotToken("trigger-greeting-device-a");
|
||||
|
||||
var listenSetupReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope
|
||||
{
|
||||
HostName = "neo-hub.jibo.com",
|
||||
Path = "/listen",
|
||||
Kind = "neo-hub-listen",
|
||||
Token = token,
|
||||
Text = """{"type":"LISTEN","transID":"trans-greeting-trigger","data":{"rules":["launch","globals/global_commands_launch"],"mode":"CLIENT_NLU"}}"""
|
||||
});
|
||||
Assert.Empty(listenSetupReplies);
|
||||
|
||||
var contextReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope
|
||||
{
|
||||
HostName = "neo-hub.jibo.com",
|
||||
Path = "/listen",
|
||||
Kind = "neo-hub-listen",
|
||||
Token = token,
|
||||
Text = """{"type":"CONTEXT","transID":"trans-greeting-trigger","data":{"runtime":{"perception":{"speaker":"person-1","peoplePresent":[{"id":"person-1"}]},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}}"""
|
||||
});
|
||||
Assert.Empty(contextReplies);
|
||||
|
||||
var triggerReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope
|
||||
{
|
||||
HostName = "neo-hub.jibo.com",
|
||||
Path = "/listen",
|
||||
Kind = "neo-hub-listen",
|
||||
Token = token,
|
||||
Text = """{"type":"TRIGGER","transID":"trans-greeting-trigger","data":{"triggerSource":"PRESENCE","triggerData":{"looperID":"person-1"}}}"""
|
||||
});
|
||||
|
||||
Assert.Equal(3, triggerReplies.Count);
|
||||
Assert.Equal("LISTEN", ReadReplyType(triggerReplies[0]));
|
||||
Assert.Equal("EOS", ReadReplyType(triggerReplies[1]));
|
||||
Assert.Equal("SKILL_ACTION", ReadReplyType(triggerReplies[2]));
|
||||
|
||||
using (var listenPayload = JsonDocument.Parse(triggerReplies[0].Text!))
|
||||
{
|
||||
Assert.Equal("proactive_greeting", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString());
|
||||
}
|
||||
using (var skillPayload = JsonDocument.Parse(triggerReplies[2].Text!))
|
||||
{
|
||||
var esml = skillPayload.RootElement
|
||||
.GetProperty("data")
|
||||
.GetProperty("action")
|
||||
.GetProperty("config")
|
||||
.GetProperty("jcp")
|
||||
.GetProperty("config")
|
||||
.GetProperty("play")
|
||||
.GetProperty("esml")
|
||||
.GetString();
|
||||
Assert.Contains("Jake", esml, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
var session = _store.FindSessionByToken(token);
|
||||
Assert.NotNull(session);
|
||||
Assert.False(session.FollowUpOpen);
|
||||
Assert.True(session.Metadata.TryGetValue("greetingsRoute", out var route));
|
||||
Assert.Equal("ProactiveGreeting", route?.ToString());
|
||||
Assert.True(session.Metadata.TryGetValue("greetingsSpeaker", out var speaker));
|
||||
Assert.Equal("person-1", speaker?.ToString());
|
||||
Assert.True(session.Metadata.TryGetValue("greetingsLastProactiveUtc", out var lastUtc));
|
||||
Assert.True(DateTimeOffset.TryParse(lastUtc?.ToString(), out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerSurprise_IsIgnoredWithoutLeavingMicOpen()
|
||||
{
|
||||
var token = _store.IssueRobotToken("trigger-greeting-device-b");
|
||||
|
||||
await _service.HandleMessageAsync(new WebSocketMessageEnvelope
|
||||
{
|
||||
HostName = "neo-hub.jibo.com",
|
||||
Path = "/listen",
|
||||
Kind = "neo-hub-listen",
|
||||
Token = token,
|
||||
Text = """{"type":"LISTEN","transID":"trans-greeting-trigger-ignore","data":{"rules":["launch"],"mode":"CLIENT_NLU"}}"""
|
||||
});
|
||||
|
||||
await _service.HandleMessageAsync(new WebSocketMessageEnvelope
|
||||
{
|
||||
HostName = "neo-hub.jibo.com",
|
||||
Path = "/listen",
|
||||
Kind = "neo-hub-listen",
|
||||
Token = token,
|
||||
Text = """{"type":"CONTEXT","transID":"trans-greeting-trigger-ignore","data":{"runtime":{"perception":{"speaker":"person-1"},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}}"""
|
||||
});
|
||||
|
||||
var triggerReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope
|
||||
{
|
||||
HostName = "neo-hub.jibo.com",
|
||||
Path = "/listen",
|
||||
Kind = "neo-hub-listen",
|
||||
Token = token,
|
||||
Text = """{"type":"TRIGGER","transID":"trans-greeting-trigger-ignore","data":{"triggerSource":"SURPRISE","triggerData":{"looperID":"person-1"}}}"""
|
||||
});
|
||||
|
||||
Assert.Equal(3, triggerReplies.Count);
|
||||
Assert.Equal("LISTEN", ReadReplyType(triggerReplies[0]));
|
||||
Assert.Equal("EOS", ReadReplyType(triggerReplies[1]));
|
||||
Assert.Equal("SKILL_ACTION", ReadReplyType(triggerReplies[2]));
|
||||
|
||||
using (var listenPayload = JsonDocument.Parse(triggerReplies[0].Text!))
|
||||
{
|
||||
Assert.Equal("trigger_ignored", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString());
|
||||
}
|
||||
|
||||
var session = _store.FindSessionByToken(token);
|
||||
Assert.NotNull(session);
|
||||
Assert.False(session.FollowUpOpen);
|
||||
Assert.False(session.Metadata.ContainsKey("greetingsLastProactiveUtc"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientAsrPersonalReport_StateMachinePersistsAcrossTurns()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user