Expand affinity parser guardrails with Pegasus phrases
This commit is contained in:
@@ -773,8 +773,8 @@ For `1.0.19`:
|
|||||||
2. Expand memory-backed personal facts with tenant-scoped storage (beyond the first birthday/preferences foundation) - implemented
|
2. Expand memory-backed personal facts with tenant-scoped storage (beyond the first birthday/preferences foundation) - implemented
|
||||||
3. Proactivity selector baseline with source-backed first offers - implemented
|
3. Proactivity selector baseline with source-backed first offers - implemented
|
||||||
4. Weather report-skill launch compatibility - implemented
|
4. Weather report-skill launch compatibility - implemented
|
||||||
5. Dialog parsing expansion and ambiguity guardrails - in progress (`2026-05-07` first guardrail slice implemented)
|
5. Dialog parsing expansion and ambiguity guardrails - in progress (`2026-05-09` third guardrail slice implemented; Pegasus affinity phrase families + continuation guardrails expanded)
|
||||||
6. Presence-aware greetings and identity-triggered proactivity - ready
|
6. Presence-aware greetings and identity-triggered proactivity - implemented (trigger path, identity-aware reactive/proactive replies, cooldown metadata wiring, focused websocket coverage)
|
||||||
7. Personal report parity track (weather visuals, live news path, commute path, calendar parity matrix) - ready
|
7. Personal report parity track (weather visuals, live news path, commute path, calendar parity matrix) - ready
|
||||||
8. Holidays and seasonal personality behavior built on the new memory/proactivity foundation
|
8. Holidays and seasonal personality behavior built on the new memory/proactivity foundation
|
||||||
9. Durable memory persistence path (multi-tenant backing store)
|
9. Durable memory persistence path (multi-tenant backing store)
|
||||||
|
|||||||
@@ -172,6 +172,16 @@ Second completed guardrail slice under this queue:
|
|||||||
- weather variants (`what's today's weather look like`, `will it be sunny tomorrow`)
|
- weather variants (`what's today's weather look like`, `will it be sunny tomorrow`)
|
||||||
- listener continuation guardrail now differentiates incomplete preference fragments from complete shorthand preference sets
|
- listener continuation guardrail now differentiates incomplete preference fragments from complete shorthand preference sets
|
||||||
|
|
||||||
|
Third completed guardrail slice under this queue:
|
||||||
|
|
||||||
|
- expanded Pegasus `userLikesThing` / `userDislikesThing` / `doesUserLikeThing` / `doesUserDislikeThing` phrase-family coverage
|
||||||
|
- includes additional dislike/negation variants (`loathe`, `did not like`, `didn't enjoy`, `don't really like`)
|
||||||
|
- includes group-preference variants (`we like`, `we love`, `we dislike`, `we can't stand`)
|
||||||
|
- includes lookup variants (`do you think i like ...`, `do you believe i don't like ...`)
|
||||||
|
- added affinity set/lookup attempt guardrails so partial captures route to affinity prompts instead of generic chat
|
||||||
|
- extended auto-finalize continuation deferral for the new Pegasus affinity stems (`we like`, `i loathe`, and related variants)
|
||||||
|
- added focused interaction + websocket tests for the new parser/guardrail behavior
|
||||||
|
|
||||||
Next queued implementation track after parser guardrails:
|
Next queued implementation track after parser guardrails:
|
||||||
|
|
||||||
- presence-aware greetings and identity-triggered proactivity (Pegasus `@be/greetings` parity slice)
|
- presence-aware greetings and identity-triggered proactivity (Pegasus `@be/greetings` parity slice)
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ public sealed class DemoConversationBroker(JiboInteractionService interactionSer
|
|||||||
"snapshot" => false,
|
"snapshot" => false,
|
||||||
"photobooth" => false,
|
"photobooth" => false,
|
||||||
"news" => false,
|
"news" => false,
|
||||||
|
"trigger_ignored" => false,
|
||||||
|
"proactive_greeting" => false,
|
||||||
_ => true
|
_ => true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ public sealed class JiboInteractionService(
|
|||||||
var transcript = (turn.NormalizedTranscript ?? turn.RawTranscript ?? string.Empty).Trim();
|
var transcript = (turn.NormalizedTranscript ?? turn.RawTranscript ?? string.Empty).Trim();
|
||||||
var lowered = transcript.ToLowerInvariant();
|
var lowered = transcript.ToLowerInvariant();
|
||||||
var referenceLocalTime = TryResolveReferenceLocalTime(turn);
|
var referenceLocalTime = TryResolveReferenceLocalTime(turn);
|
||||||
|
var messageType = turn.Attributes.TryGetValue("messageType", out var rawMessageType)
|
||||||
|
? rawMessageType?.ToString()
|
||||||
|
: null;
|
||||||
|
var triggerSource = turn.Attributes.TryGetValue("triggerSource", out var rawTriggerSource)
|
||||||
|
? rawTriggerSource?.ToString()
|
||||||
|
: null;
|
||||||
var clientIntent = turn.Attributes.TryGetValue("clientIntent", out var rawClientIntent)
|
var clientIntent = turn.Attributes.TryGetValue("clientIntent", out var rawClientIntent)
|
||||||
? rawClientIntent?.ToString()
|
? rawClientIntent?.ToString()
|
||||||
: null;
|
: null;
|
||||||
@@ -32,6 +38,17 @@ public sealed class JiboInteractionService(
|
|||||||
? rawPendingProactivityOffer?.ToString()
|
? rawPendingProactivityOffer?.ToString()
|
||||||
: null;
|
: null;
|
||||||
var isYesNoTurn = IsYesNoTurn(turn);
|
var isYesNoTurn = IsYesNoTurn(turn);
|
||||||
|
var greetingPresence = ResolveGreetingPresenceProfile(turn);
|
||||||
|
|
||||||
|
if (string.Equals(messageType, "TRIGGER", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (ShouldHandleProactiveGreetingTrigger(turn, triggerSource, greetingPresence))
|
||||||
|
{
|
||||||
|
return BuildProactiveGreetingDecision(turn, greetingPresence, referenceLocalTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
return BuildTriggerIgnoredDecision();
|
||||||
|
}
|
||||||
|
|
||||||
var isTimerValueTurn = IsClockTimerValueTurn(clientRules, listenRules);
|
var isTimerValueTurn = IsClockTimerValueTurn(clientRules, listenRules);
|
||||||
var isAlarmValueTurn = IsClockAlarmValueTurn(clientRules, listenRules);
|
var isAlarmValueTurn = IsClockAlarmValueTurn(clientRules, listenRules);
|
||||||
@@ -110,6 +127,11 @@ public sealed class JiboInteractionService(
|
|||||||
"photobooth" => BuildPhotoCreateDecision("photobooth", "Starting photobooth.", "createSomePhotos"),
|
"photobooth" => BuildPhotoCreateDecision("photobooth", "Starting photobooth.", "createSomePhotos"),
|
||||||
"robot_age" => BuildRobotAgeDecision(referenceLocalTime),
|
"robot_age" => BuildRobotAgeDecision(referenceLocalTime),
|
||||||
"robot_birthday" => BuildRobotBirthdayDecision(),
|
"robot_birthday" => BuildRobotBirthdayDecision(),
|
||||||
|
"good_morning" => BuildReactiveGreetingDecision(turn, "good_morning", referenceLocalTime),
|
||||||
|
"good_afternoon" => BuildReactiveGreetingDecision(turn, "good_afternoon", referenceLocalTime),
|
||||||
|
"good_evening" => BuildReactiveGreetingDecision(turn, "good_evening", referenceLocalTime),
|
||||||
|
"good_night" => BuildReactiveGreetingDecision(turn, "good_night", referenceLocalTime),
|
||||||
|
"welcome_back" => BuildReactiveGreetingDecision(turn, "welcome_back", referenceLocalTime),
|
||||||
"memory_set_name" => BuildRememberNameDecision(turn, transcript),
|
"memory_set_name" => BuildRememberNameDecision(turn, transcript),
|
||||||
"memory_get_name" => BuildRecallNameDecision(turn),
|
"memory_get_name" => BuildRecallNameDecision(turn),
|
||||||
"memory_set_birthday" => BuildRememberBirthdayDecision(turn, transcript),
|
"memory_set_birthday" => BuildRememberBirthdayDecision(turn, transcript),
|
||||||
@@ -163,6 +185,159 @@ public sealed class JiboInteractionService(
|
|||||||
$"My birthday is {OpenJiboCloudBuildInfo.PersonaBirthdayWords}.");
|
$"My birthday is {OpenJiboCloudBuildInfo.PersonaBirthdayWords}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static JiboInteractionDecision BuildTriggerIgnoredDecision()
|
||||||
|
{
|
||||||
|
return new JiboInteractionDecision(
|
||||||
|
"trigger_ignored",
|
||||||
|
string.Empty,
|
||||||
|
"chitchat-skill",
|
||||||
|
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["skillId"] = "chitchat-skill",
|
||||||
|
["cloudResponseMode"] = "completion_only"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private JiboInteractionDecision BuildReactiveGreetingDecision(
|
||||||
|
TurnContext turn,
|
||||||
|
string greetingIntent,
|
||||||
|
DateTimeOffset? referenceLocalTime)
|
||||||
|
{
|
||||||
|
var presence = ResolveGreetingPresenceProfile(turn);
|
||||||
|
var displayName = ResolvePreferredGreetingName(turn, presence);
|
||||||
|
var replyText = BuildReactiveGreetingReply(greetingIntent, displayName, referenceLocalTime);
|
||||||
|
return new JiboInteractionDecision(
|
||||||
|
greetingIntent,
|
||||||
|
replyText,
|
||||||
|
ContextUpdates: BuildGreetingContextUpdates("ReactiveGreeting", presence.PrimaryPersonId, proactive: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
private JiboInteractionDecision BuildProactiveGreetingDecision(
|
||||||
|
TurnContext turn,
|
||||||
|
GreetingPresenceProfile presence,
|
||||||
|
DateTimeOffset? referenceLocalTime)
|
||||||
|
{
|
||||||
|
var displayName = ResolvePreferredGreetingName(turn, presence);
|
||||||
|
var greetingPrefix = ResolveTimeOfDayGreetingPrefix(referenceLocalTime);
|
||||||
|
var replyText = string.IsNullOrWhiteSpace(displayName)
|
||||||
|
? $"{greetingPrefix}. I am glad to see you."
|
||||||
|
: $"{greetingPrefix}, {displayName}. Welcome back.";
|
||||||
|
return new JiboInteractionDecision(
|
||||||
|
"proactive_greeting",
|
||||||
|
replyText,
|
||||||
|
ContextUpdates: BuildGreetingContextUpdates("ProactiveGreeting", presence.PrimaryPersonId, proactive: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildReactiveGreetingReply(
|
||||||
|
string greetingIntent,
|
||||||
|
string? displayName,
|
||||||
|
DateTimeOffset? referenceLocalTime)
|
||||||
|
{
|
||||||
|
var namePrefix = string.IsNullOrWhiteSpace(displayName)
|
||||||
|
? string.Empty
|
||||||
|
: $", {displayName}";
|
||||||
|
|
||||||
|
return greetingIntent switch
|
||||||
|
{
|
||||||
|
"good_morning" => $"Good morning{namePrefix}. It is great to see you.",
|
||||||
|
"good_afternoon" => $"Good afternoon{namePrefix}. I am glad you are here.",
|
||||||
|
"good_evening" => $"Good evening{namePrefix}. It is nice to have you back.",
|
||||||
|
"good_night" => $"Good night{namePrefix}. Sleep well.",
|
||||||
|
"welcome_back" => string.IsNullOrWhiteSpace(displayName)
|
||||||
|
? $"Welcome back. {ResolveTimeOfDayGreetingPrefix(referenceLocalTime)}."
|
||||||
|
: $"Welcome back, {displayName}. {ResolveTimeOfDayGreetingPrefix(referenceLocalTime)}.",
|
||||||
|
_ => $"Hello{namePrefix}. It is nice to see you."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? ResolvePreferredGreetingName(TurnContext turn, GreetingPresenceProfile presence)
|
||||||
|
{
|
||||||
|
var rememberedName = personalMemoryStore.GetName(ResolveTenantScope(turn));
|
||||||
|
if (!string.IsNullOrWhiteSpace(rememberedName))
|
||||||
|
{
|
||||||
|
return ToDisplayName(rememberedName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(presence.PrimaryPersonId) &&
|
||||||
|
presence.LoopUserFirstNames.TryGetValue(presence.PrimaryPersonId, out var firstName) &&
|
||||||
|
!string.IsNullOrWhiteSpace(firstName))
|
||||||
|
{
|
||||||
|
return ToDisplayName(firstName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToDisplayName(string value)
|
||||||
|
{
|
||||||
|
var trimmed = value.Trim();
|
||||||
|
return string.IsNullOrWhiteSpace(trimmed)
|
||||||
|
? string.Empty
|
||||||
|
: CultureInfo.InvariantCulture.TextInfo.ToTitleCase(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldHandleProactiveGreetingTrigger(
|
||||||
|
TurnContext turn,
|
||||||
|
string? triggerSource,
|
||||||
|
GreetingPresenceProfile presence)
|
||||||
|
{
|
||||||
|
if (string.Equals(triggerSource, "SURPRISE", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!presence.HasKnownIdentity)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastGreetingUtc = ReadTimestampAttribute(turn, LastProactiveGreetingUtcMetadataKey);
|
||||||
|
return !lastGreetingUtc.HasValue || DateTimeOffset.UtcNow - lastGreetingUtc.Value >= ProactiveGreetingCooldown;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTimeOffset? ReadTimestampAttribute(TurnContext turn, string key)
|
||||||
|
{
|
||||||
|
if (!turn.Attributes.TryGetValue(key, out var value) || value is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DateTimeOffset.TryParse(
|
||||||
|
value.ToString(),
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.RoundtripKind,
|
||||||
|
out var parsed)
|
||||||
|
? parsed
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IDictionary<string, object?> BuildGreetingContextUpdates(string route, string? speakerId, bool proactive)
|
||||||
|
{
|
||||||
|
var updates = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
[ChitchatStateMachine.StateMetadataKey] = "complete",
|
||||||
|
[ChitchatStateMachine.RouteMetadataKey] = "ScriptedResponse",
|
||||||
|
[ChitchatStateMachine.EmotionMetadataKey] = string.Empty,
|
||||||
|
[GreetingRouteMetadataKey] = route,
|
||||||
|
[GreetingSpeakerMetadataKey] = speakerId ?? string.Empty
|
||||||
|
};
|
||||||
|
|
||||||
|
updates[proactive ? LastProactiveGreetingUtcMetadataKey : LastReactiveGreetingUtcMetadataKey] =
|
||||||
|
DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture);
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveTimeOfDayGreetingPrefix(DateTimeOffset? referenceLocalTime)
|
||||||
|
{
|
||||||
|
var hour = (referenceLocalTime ?? DateTimeOffset.UtcNow).Hour;
|
||||||
|
return hour switch
|
||||||
|
{
|
||||||
|
>= 5 and < 12 => "Good morning",
|
||||||
|
>= 12 and < 17 => "Good afternoon",
|
||||||
|
_ => "Good evening"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private JiboInteractionDecision BuildRememberNameDecision(TurnContext turn, string transcript)
|
private JiboInteractionDecision BuildRememberNameDecision(TurnContext turn, string transcript)
|
||||||
{
|
{
|
||||||
var name = TryExtractNameFact(transcript);
|
var name = TryExtractNameFact(transcript);
|
||||||
@@ -1084,12 +1259,12 @@ public sealed class JiboInteractionService(
|
|||||||
return "memory_get_important_date";
|
return "memory_get_important_date";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsAffinitySetStatement(loweredTranscript))
|
if (IsAffinitySetStatement(loweredTranscript) || IsAffinitySetAttempt(loweredTranscript))
|
||||||
{
|
{
|
||||||
return "memory_set_affinity";
|
return "memory_set_affinity";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsAffinityRecallQuestion(loweredTranscript))
|
if (IsAffinityRecallQuestion(loweredTranscript) || IsAffinityRecallAttempt(loweredTranscript))
|
||||||
{
|
{
|
||||||
return "memory_get_affinity";
|
return "memory_get_affinity";
|
||||||
}
|
}
|
||||||
@@ -1315,6 +1490,31 @@ public sealed class JiboInteractionService(
|
|||||||
return "news";
|
return "news";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IsWelcomeBackGreeting(loweredTranscript))
|
||||||
|
{
|
||||||
|
return "welcome_back";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsGoodMorningGreeting(loweredTranscript))
|
||||||
|
{
|
||||||
|
return "good_morning";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsGoodAfternoonGreeting(loweredTranscript))
|
||||||
|
{
|
||||||
|
return "good_afternoon";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsGoodEveningGreeting(loweredTranscript))
|
||||||
|
{
|
||||||
|
return "good_evening";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsGoodNightGreeting(loweredTranscript))
|
||||||
|
{
|
||||||
|
return "good_night";
|
||||||
|
}
|
||||||
|
|
||||||
if (MatchesAny(loweredTranscript, "how are you", "what's up", "what s up", "what up"))
|
if (MatchesAny(loweredTranscript, "how are you", "what's up", "what s up", "what up"))
|
||||||
{
|
{
|
||||||
return "how_are_you";
|
return "how_are_you";
|
||||||
@@ -2004,6 +2204,115 @@ public sealed class JiboInteractionService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static GreetingPresenceProfile ResolveGreetingPresenceProfile(TurnContext turn)
|
||||||
|
{
|
||||||
|
if (!turn.Attributes.TryGetValue("context", out var contextValue) ||
|
||||||
|
contextValue is null ||
|
||||||
|
string.IsNullOrWhiteSpace(contextValue.ToString()))
|
||||||
|
{
|
||||||
|
return GreetingPresenceProfile.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(contextValue.ToString()!);
|
||||||
|
if (!document.RootElement.TryGetProperty("runtime", out var runtime) ||
|
||||||
|
runtime.ValueKind != JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
return GreetingPresenceProfile.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var loopUsers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (runtime.TryGetProperty("loop", out var loop) &&
|
||||||
|
loop.ValueKind == JsonValueKind.Object &&
|
||||||
|
loop.TryGetProperty("users", out var users) &&
|
||||||
|
users.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var user in users.EnumerateArray())
|
||||||
|
{
|
||||||
|
var id = TryReadStringProperty(user, "id");
|
||||||
|
var firstName = TryReadStringProperty(user, "firstName");
|
||||||
|
if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(firstName))
|
||||||
|
{
|
||||||
|
loopUsers[id] = firstName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var speakerId = string.Empty;
|
||||||
|
var peoplePresentIds = new List<string>();
|
||||||
|
if (runtime.TryGetProperty("perception", out var perception) &&
|
||||||
|
perception.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
if (perception.TryGetProperty("speaker", out var speaker))
|
||||||
|
{
|
||||||
|
if (speaker.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
speakerId = speaker.GetString() ?? string.Empty;
|
||||||
|
}
|
||||||
|
else if (speaker.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
speakerId = TryReadStringProperty(speaker, "id", "looperID", "looperId") ?? string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (perception.TryGetProperty("peoplePresent", out var peoplePresent) &&
|
||||||
|
peoplePresent.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var person in peoplePresent.EnumerateArray())
|
||||||
|
{
|
||||||
|
var personId = person.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.String => person.GetString(),
|
||||||
|
JsonValueKind.Object => TryReadStringProperty(person, "id", "looperID", "looperId"),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(personId) &&
|
||||||
|
!string.Equals(personId, "NOT_TRAINED", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
peoplePresentIds.Add(personId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var triggerLooperId = turn.Attributes.TryGetValue("triggerLooperId", out var rawTriggerLooperId)
|
||||||
|
? rawTriggerLooperId?.ToString()
|
||||||
|
: null;
|
||||||
|
var primaryPersonId = !string.IsNullOrWhiteSpace(speakerId)
|
||||||
|
? speakerId
|
||||||
|
: !string.IsNullOrWhiteSpace(triggerLooperId)
|
||||||
|
? triggerLooperId
|
||||||
|
: peoplePresentIds.FirstOrDefault();
|
||||||
|
|
||||||
|
return new GreetingPresenceProfile(
|
||||||
|
primaryPersonId,
|
||||||
|
string.IsNullOrWhiteSpace(speakerId) ? null : speakerId,
|
||||||
|
peoplePresentIds,
|
||||||
|
loopUsers);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return GreetingPresenceProfile.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? TryReadStringProperty(JsonElement source, params string[] propertyNames)
|
||||||
|
{
|
||||||
|
foreach (var propertyName in propertyNames)
|
||||||
|
{
|
||||||
|
if (source.TryGetProperty(propertyName, out var value) &&
|
||||||
|
value.ValueKind == JsonValueKind.String &&
|
||||||
|
!string.IsNullOrWhiteSpace(value.GetString()))
|
||||||
|
{
|
||||||
|
return value.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private static double? TryReadDoubleProperty(JsonElement source, params string[] propertyNames)
|
private static double? TryReadDoubleProperty(JsonElement source, params string[] propertyNames)
|
||||||
{
|
{
|
||||||
foreach (var propertyName in propertyNames)
|
foreach (var propertyName in propertyNames)
|
||||||
@@ -2315,6 +2624,57 @@ public sealed class JiboInteractionService(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsWelcomeBackGreeting(string loweredTranscript)
|
||||||
|
{
|
||||||
|
return MatchesAny(
|
||||||
|
loweredTranscript,
|
||||||
|
"i am back",
|
||||||
|
"i m back",
|
||||||
|
"im back",
|
||||||
|
"i am home",
|
||||||
|
"i m home",
|
||||||
|
"im home",
|
||||||
|
"i'm back",
|
||||||
|
"i'm home",
|
||||||
|
"welcome back");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsGoodMorningGreeting(string loweredTranscript)
|
||||||
|
{
|
||||||
|
return MatchesAny(
|
||||||
|
loweredTranscript,
|
||||||
|
"good morning",
|
||||||
|
"morning jibo",
|
||||||
|
"morning, jibo");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsGoodAfternoonGreeting(string loweredTranscript)
|
||||||
|
{
|
||||||
|
return MatchesAny(
|
||||||
|
loweredTranscript,
|
||||||
|
"good afternoon",
|
||||||
|
"afternoon jibo",
|
||||||
|
"afternoon, jibo");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsGoodEveningGreeting(string loweredTranscript)
|
||||||
|
{
|
||||||
|
return MatchesAny(
|
||||||
|
loweredTranscript,
|
||||||
|
"good evening",
|
||||||
|
"evening jibo",
|
||||||
|
"evening, jibo");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsGoodNightGreeting(string loweredTranscript)
|
||||||
|
{
|
||||||
|
return MatchesAny(
|
||||||
|
loweredTranscript,
|
||||||
|
"good night",
|
||||||
|
"night jibo",
|
||||||
|
"night, jibo");
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsDanceQuestion(string loweredTranscript)
|
private static bool IsDanceQuestion(string loweredTranscript)
|
||||||
{
|
{
|
||||||
return MatchesAny(
|
return MatchesAny(
|
||||||
@@ -2670,11 +3030,29 @@ public sealed class JiboInteractionService(
|
|||||||
return TryExtractAffinitySet(loweredTranscript) is not null;
|
return TryExtractAffinitySet(loweredTranscript) is not null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsAffinitySetAttempt(string loweredTranscript)
|
||||||
|
{
|
||||||
|
var normalized = NormalizeCommandPhrase(loweredTranscript);
|
||||||
|
return PegasusUserAffinitySetPrefixes.Any(prefix => MatchesPrefixOrStem(normalized, prefix.Prefix));
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsAffinityRecallQuestion(string loweredTranscript)
|
private static bool IsAffinityRecallQuestion(string loweredTranscript)
|
||||||
{
|
{
|
||||||
return TryExtractAffinityLookup(loweredTranscript) is not null;
|
return TryExtractAffinityLookup(loweredTranscript) is not null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsAffinityRecallAttempt(string loweredTranscript)
|
||||||
|
{
|
||||||
|
var normalized = NormalizeCommandPhrase(loweredTranscript);
|
||||||
|
return PegasusUserAffinityLookupPrefixes.Any(prefix => MatchesPrefixOrStem(normalized, prefix.Prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesPrefixOrStem(string normalized, string prefix)
|
||||||
|
{
|
||||||
|
return normalized.StartsWith(prefix, StringComparison.Ordinal) ||
|
||||||
|
string.Equals(normalized, prefix.TrimEnd(), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
private static (string Item, PersonalAffinity Affinity)? TryExtractAffinitySet(string transcript)
|
private static (string Item, PersonalAffinity Affinity)? TryExtractAffinitySet(string transcript)
|
||||||
{
|
{
|
||||||
var normalized = NormalizeCommandPhrase(transcript);
|
var normalized = NormalizeCommandPhrase(transcript);
|
||||||
@@ -3331,6 +3709,21 @@ public sealed class JiboInteractionService(
|
|||||||
|
|
||||||
private sealed record PizzaSignal(PersonalAffinity? Affinity);
|
private sealed record PizzaSignal(PersonalAffinity? Affinity);
|
||||||
|
|
||||||
|
private sealed record GreetingPresenceProfile(
|
||||||
|
string? PrimaryPersonId,
|
||||||
|
string? SpeakerId,
|
||||||
|
IReadOnlyList<string> PeoplePresentIds,
|
||||||
|
IReadOnlyDictionary<string, string> LoopUserFirstNames)
|
||||||
|
{
|
||||||
|
public static GreetingPresenceProfile Empty { get; } = new(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
Array.Empty<string>(),
|
||||||
|
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
public bool HasKnownIdentity => !string.IsNullOrWhiteSpace(PrimaryPersonId);
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record WeatherDateEntity(string? DateEntity, int ForecastDayOffset, string? ForecastLeadIn)
|
private sealed record WeatherDateEntity(string? DateEntity, int ForecastDayOffset, string? ForecastLeadIn)
|
||||||
{
|
{
|
||||||
public static WeatherDateEntity None { get; } = new(null, 0, null);
|
public static WeatherDateEntity None { get; } = new(null, 0, null);
|
||||||
@@ -3421,21 +3814,55 @@ public sealed class JiboInteractionService(
|
|||||||
[
|
[
|
||||||
("i love ", PersonalAffinity.Love),
|
("i love ", PersonalAffinity.Love),
|
||||||
("i like ", PersonalAffinity.Like),
|
("i like ", PersonalAffinity.Like),
|
||||||
|
("i like the ", PersonalAffinity.Like),
|
||||||
("i enjoy ", PersonalAffinity.Like),
|
("i enjoy ", PersonalAffinity.Like),
|
||||||
("i do like ", PersonalAffinity.Like),
|
("i do like ", PersonalAffinity.Like),
|
||||||
|
("we love ", PersonalAffinity.Love),
|
||||||
|
("we like ", PersonalAffinity.Like),
|
||||||
|
("we enjoy ", PersonalAffinity.Like),
|
||||||
("i dislike ", PersonalAffinity.Dislike),
|
("i dislike ", PersonalAffinity.Dislike),
|
||||||
("i hate ", PersonalAffinity.Dislike),
|
("i hate ", PersonalAffinity.Dislike),
|
||||||
|
("i hate the ", PersonalAffinity.Dislike),
|
||||||
|
("i loathe ", PersonalAffinity.Dislike),
|
||||||
("i don t like ", PersonalAffinity.Dislike),
|
("i don t like ", PersonalAffinity.Dislike),
|
||||||
("i dont like ", PersonalAffinity.Dislike),
|
("i dont like ", PersonalAffinity.Dislike),
|
||||||
|
("i not like ", PersonalAffinity.Dislike),
|
||||||
("i do not like ", PersonalAffinity.Dislike),
|
("i do not like ", PersonalAffinity.Dislike),
|
||||||
|
("i did not like ", PersonalAffinity.Dislike),
|
||||||
|
("i did not like the ", PersonalAffinity.Dislike),
|
||||||
|
("i didn t like ", PersonalAffinity.Dislike),
|
||||||
|
("i didnt like ", PersonalAffinity.Dislike),
|
||||||
|
("i didn t like the ", PersonalAffinity.Dislike),
|
||||||
|
("i didnt like the ", PersonalAffinity.Dislike),
|
||||||
|
("i didn t really like ", PersonalAffinity.Dislike),
|
||||||
|
("i didnt really like ", PersonalAffinity.Dislike),
|
||||||
|
("i don t really like ", PersonalAffinity.Dislike),
|
||||||
|
("i dont really like ", PersonalAffinity.Dislike),
|
||||||
("i don t enjoy ", PersonalAffinity.Dislike),
|
("i don t enjoy ", PersonalAffinity.Dislike),
|
||||||
("i dont enjoy ", PersonalAffinity.Dislike),
|
("i dont enjoy ", PersonalAffinity.Dislike),
|
||||||
("i do not enjoy ", PersonalAffinity.Dislike),
|
("i do not enjoy ", PersonalAffinity.Dislike),
|
||||||
|
("i did not enjoy ", PersonalAffinity.Dislike),
|
||||||
|
("i didn t enjoy ", PersonalAffinity.Dislike),
|
||||||
|
("i didnt enjoy ", PersonalAffinity.Dislike),
|
||||||
|
("i didn t really enjoy ", PersonalAffinity.Dislike),
|
||||||
|
("i didnt really enjoy ", PersonalAffinity.Dislike),
|
||||||
("i don t love ", PersonalAffinity.Dislike),
|
("i don t love ", PersonalAffinity.Dislike),
|
||||||
("i dont love ", PersonalAffinity.Dislike),
|
("i dont love ", PersonalAffinity.Dislike),
|
||||||
("i do not love ", PersonalAffinity.Dislike),
|
("i do not love ", PersonalAffinity.Dislike),
|
||||||
|
("i don t love to ", PersonalAffinity.Dislike),
|
||||||
|
("i dont love to ", PersonalAffinity.Dislike),
|
||||||
|
("i do not love to ", PersonalAffinity.Dislike),
|
||||||
("i can t stand ", PersonalAffinity.Dislike),
|
("i can t stand ", PersonalAffinity.Dislike),
|
||||||
("i cant stand ", PersonalAffinity.Dislike),
|
("i cant stand ", PersonalAffinity.Dislike),
|
||||||
|
("i can t stand the ", PersonalAffinity.Dislike),
|
||||||
|
("i cant stand the ", PersonalAffinity.Dislike),
|
||||||
|
("we dislike ", PersonalAffinity.Dislike),
|
||||||
|
("we hate ", PersonalAffinity.Dislike),
|
||||||
|
("we despise ", PersonalAffinity.Dislike),
|
||||||
|
("we detest ", PersonalAffinity.Dislike),
|
||||||
|
("we loathe ", PersonalAffinity.Dislike),
|
||||||
|
("we can t stand ", PersonalAffinity.Dislike),
|
||||||
|
("we cant stand ", PersonalAffinity.Dislike),
|
||||||
("i despise ", PersonalAffinity.Dislike),
|
("i despise ", PersonalAffinity.Dislike),
|
||||||
("i detest ", PersonalAffinity.Dislike)
|
("i detest ", PersonalAffinity.Dislike)
|
||||||
];
|
];
|
||||||
@@ -3447,8 +3874,14 @@ public sealed class JiboInteractionService(
|
|||||||
("do i enjoy ", PersonalAffinity.Like),
|
("do i enjoy ", PersonalAffinity.Like),
|
||||||
("do i dislike ", PersonalAffinity.Dislike),
|
("do i dislike ", PersonalAffinity.Dislike),
|
||||||
("do i hate ", PersonalAffinity.Dislike),
|
("do i hate ", PersonalAffinity.Dislike),
|
||||||
|
("do i loathe ", PersonalAffinity.Dislike),
|
||||||
|
("do i not like ", PersonalAffinity.Dislike),
|
||||||
("do i despise ", PersonalAffinity.Dislike),
|
("do i despise ", PersonalAffinity.Dislike),
|
||||||
("do i detest ", PersonalAffinity.Dislike),
|
("do i detest ", PersonalAffinity.Dislike),
|
||||||
|
("do you think i like ", PersonalAffinity.Like),
|
||||||
|
("do you believe i like ", PersonalAffinity.Like),
|
||||||
|
("do you think i don t like ", PersonalAffinity.Dislike),
|
||||||
|
("do you believe i don t like ", PersonalAffinity.Dislike),
|
||||||
("how do i feel about ", null),
|
("how do i feel about ", null),
|
||||||
("what do i think about ", null)
|
("what do i think about ", null)
|
||||||
];
|
];
|
||||||
@@ -3488,6 +3921,12 @@ public sealed class JiboInteractionService(
|
|||||||
"our neighbourhood"
|
"our neighbourhood"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private const string GreetingRouteMetadataKey = "greetingsRoute";
|
||||||
|
private const string GreetingSpeakerMetadataKey = "greetingsSpeaker";
|
||||||
|
private const string LastProactiveGreetingUtcMetadataKey = "greetingsLastProactiveUtc";
|
||||||
|
private const string LastReactiveGreetingUtcMetadataKey = "greetingsLastReactiveUtc";
|
||||||
|
private static readonly TimeSpan ProactiveGreetingCooldown = TimeSpan.FromMinutes(20);
|
||||||
|
|
||||||
private const int MaxWeatherForecastDayOffset = 5;
|
private const int MaxWeatherForecastDayOffset = 5;
|
||||||
|
|
||||||
private static readonly (string Phrase, string Station)[] RadioGenreAliases =
|
private static readonly (string Phrase, string Station)[] RadioGenreAliases =
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ public sealed class JiboWebSocketService(
|
|||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
return replies;
|
return replies;
|
||||||
}
|
}
|
||||||
case "CLIENT_NLU" or "CLIENT_ASR":
|
case "CLIENT_NLU" or "CLIENT_ASR" or "TRIGGER":
|
||||||
{
|
{
|
||||||
var replies = await turnFinalizationService.HandleTurnAsync(session, envelope, parsedType, cancellationToken);
|
var replies = await turnFinalizationService.HandleTurnAsync(session, envelope, parsedType, cancellationToken);
|
||||||
await telemetrySink.RecordTurnEventAsync(envelope, session, "turn_processed", new Dictionary<string, object?>
|
await telemetrySink.RecordTurnEventAsync(envelope, session, "turn_processed", new Dictionary<string, object?>
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ public sealed class ProtocolToTurnContextMapper
|
|||||||
foreach (var pair in session.Metadata)
|
foreach (var pair in session.Metadata)
|
||||||
{
|
{
|
||||||
if ((!pair.Key.StartsWith("personalReport", StringComparison.OrdinalIgnoreCase) &&
|
if ((!pair.Key.StartsWith("personalReport", StringComparison.OrdinalIgnoreCase) &&
|
||||||
!pair.Key.StartsWith("chitchat", StringComparison.OrdinalIgnoreCase)) ||
|
!pair.Key.StartsWith("chitchat", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!pair.Key.StartsWith("greetings", StringComparison.OrdinalIgnoreCase)) ||
|
||||||
pair.Value is null)
|
pair.Value is null)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@@ -154,6 +155,22 @@ public sealed class ProtocolToTurnContextMapper
|
|||||||
attributes["clientIntent"] = intent.GetString();
|
attributes["clientIntent"] = intent.GetString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.TryGetProperty("triggerSource", out var triggerSource) &&
|
||||||
|
triggerSource.ValueKind == JsonValueKind.String &&
|
||||||
|
!string.IsNullOrWhiteSpace(triggerSource.GetString()))
|
||||||
|
{
|
||||||
|
attributes["triggerSource"] = triggerSource.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.TryGetProperty("triggerData", out var triggerData) &&
|
||||||
|
triggerData.ValueKind == JsonValueKind.Object &&
|
||||||
|
triggerData.TryGetProperty("looperID", out var triggerLooperId) &&
|
||||||
|
triggerLooperId.ValueKind == JsonValueKind.String &&
|
||||||
|
!string.IsNullOrWhiteSpace(triggerLooperId.GetString()))
|
||||||
|
{
|
||||||
|
attributes["triggerLooperId"] = triggerLooperId.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
if (data.TryGetProperty("rules", out var rules) && rules.ValueKind == JsonValueKind.Array)
|
if (data.TryGetProperty("rules", out var rules) && rules.ValueKind == JsonValueKind.Array)
|
||||||
{
|
{
|
||||||
attributes["clientRules"] = rules.EnumerateArray()
|
attributes["clientRules"] = rules.EnumerateArray()
|
||||||
|
|||||||
@@ -24,21 +24,52 @@ public sealed partial class WebSocketTurnFinalizationService(
|
|||||||
{
|
{
|
||||||
"i love",
|
"i love",
|
||||||
"i like",
|
"i like",
|
||||||
|
"i like the",
|
||||||
"i enjoy",
|
"i enjoy",
|
||||||
"i do like",
|
"i do like",
|
||||||
|
"we love",
|
||||||
|
"we like",
|
||||||
|
"we enjoy",
|
||||||
"i dislike",
|
"i dislike",
|
||||||
"i hate",
|
"i hate",
|
||||||
|
"i hate the",
|
||||||
|
"i loathe",
|
||||||
|
"i not like",
|
||||||
"i dont like",
|
"i dont like",
|
||||||
"i don t like",
|
"i don t like",
|
||||||
"i do not like",
|
"i do not like",
|
||||||
|
"i did not like",
|
||||||
|
"i didn t like",
|
||||||
|
"i didnt like",
|
||||||
|
"i didn t really like",
|
||||||
|
"i didnt really like",
|
||||||
|
"i don t really like",
|
||||||
|
"i dont really like",
|
||||||
"i dont enjoy",
|
"i dont enjoy",
|
||||||
"i don t enjoy",
|
"i don t enjoy",
|
||||||
"i do not enjoy",
|
"i do not enjoy",
|
||||||
|
"i did not enjoy",
|
||||||
|
"i didn t enjoy",
|
||||||
|
"i didnt enjoy",
|
||||||
|
"i didn t really enjoy",
|
||||||
|
"i didnt really enjoy",
|
||||||
"i dont love",
|
"i dont love",
|
||||||
"i don t love",
|
"i don t love",
|
||||||
"i do not love",
|
"i do not love",
|
||||||
|
"i don t love to",
|
||||||
|
"i dont love to",
|
||||||
|
"i do not love to",
|
||||||
"i cant stand",
|
"i cant stand",
|
||||||
"i can t stand",
|
"i can t stand",
|
||||||
|
"i cant stand the",
|
||||||
|
"i can t stand the",
|
||||||
|
"we dislike",
|
||||||
|
"we hate",
|
||||||
|
"we despise",
|
||||||
|
"we detest",
|
||||||
|
"we loathe",
|
||||||
|
"we cant stand",
|
||||||
|
"we can t stand",
|
||||||
"i despise",
|
"i despise",
|
||||||
"i detest"
|
"i detest"
|
||||||
};
|
};
|
||||||
@@ -526,7 +557,9 @@ public sealed partial class WebSocketTurnFinalizationService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var allowEmptyTranscriptForPersonalReport = IsActivePersonalReportTurn(finalizedTurn);
|
var allowEmptyTranscriptForPersonalReport = IsActivePersonalReportTurn(finalizedTurn);
|
||||||
|
var allowEmptyTranscriptForTrigger = string.Equals(ReadMessageType(finalizedTurn), "TRIGGER", StringComparison.OrdinalIgnoreCase);
|
||||||
if (!allowEmptyTranscriptForPersonalReport &&
|
if (!allowEmptyTranscriptForPersonalReport &&
|
||||||
|
!allowEmptyTranscriptForTrigger &&
|
||||||
string.IsNullOrWhiteSpace(finalizedTurn.NormalizedTranscript) &&
|
string.IsNullOrWhiteSpace(finalizedTurn.NormalizedTranscript) &&
|
||||||
string.IsNullOrWhiteSpace(finalizedTurn.RawTranscript))
|
string.IsNullOrWhiteSpace(finalizedTurn.RawTranscript))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ public sealed class JiboInteractionServiceTests
|
|||||||
private const string ChitchatStateKey = "chitchatState";
|
private const string ChitchatStateKey = "chitchatState";
|
||||||
private const string ChitchatRouteKey = "chitchatRoute";
|
private const string ChitchatRouteKey = "chitchatRoute";
|
||||||
private const string ChitchatEmotionKey = "chitchatEmotion";
|
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]
|
[Fact]
|
||||||
public async Task BuildDecisionAsync_Joke_UsesCatalogBackedRandomContent()
|
public async Task BuildDecisionAsync_Joke_UsesCatalogBackedRandomContent()
|
||||||
@@ -150,6 +154,103 @@ public sealed class JiboInteractionServiceTests
|
|||||||
Assert.Equal("My birthday is March 22, 2026.", decision.ReplyText);
|
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]
|
[Fact]
|
||||||
public async Task BuildDecisionAsync_DoYouHaveAPersonality_UsesCatalogBackedPersonalityReply()
|
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);
|
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]
|
[Fact]
|
||||||
public async Task BuildDecisionAsync_PreferenceReversePhrase_ParsesFavoriteVariant()
|
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());
|
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]
|
[Fact]
|
||||||
public async Task MultiChunkAudio_AccumulatesBufferedStateAcrossMessages()
|
public async Task MultiChunkAudio_AccumulatesBufferedStateAcrossMessages()
|
||||||
{
|
{
|
||||||
@@ -3501,6 +3570,122 @@ public sealed class JiboWebSocketServiceTests
|
|||||||
Assert.False(session.Metadata.ContainsKey("pendingProactivityOffer"));
|
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]
|
[Fact]
|
||||||
public async Task ClientAsrPersonalReport_StateMachinePersistsAcrossTurns()
|
public async Task ClientAsrPersonalReport_StateMachinePersistsAcrossTurns()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user