Broaden yes no parsing for proactive follow ups
This commit is contained in:
@@ -1000,22 +1000,49 @@ public sealed class JiboInteractionService(
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var preferredCategories = ResolvePreferredNewsCategories(turn, transcript);
|
var preferredCategories = ResolvePreferredNewsCategories(turn, transcript);
|
||||||
|
var requestedHeadlineCount = MaxNewsHeadlines;
|
||||||
if (newsBriefingProvider is not null)
|
if (newsBriefingProvider is not null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var snapshot = await newsBriefingProvider.GetBriefingAsync(
|
var snapshot = await newsBriefingProvider.GetBriefingAsync(
|
||||||
new NewsBriefingRequest(preferredCategories, MaxNewsHeadlines),
|
new NewsBriefingRequest(preferredCategories, requestedHeadlineCount),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
if (snapshot?.Headlines.Count > 0)
|
if (snapshot?.Headlines.Count > 0)
|
||||||
{
|
{
|
||||||
return BuildProviderNewsDecision(snapshot, preferredCategories);
|
return BuildProviderNewsDecision(snapshot, preferredCategories, requestedHeadlineCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var fallbackBriefingWhenEmpty = randomizer.Choose(catalog.NewsBriefings);
|
||||||
|
return BuildNewsDecision(
|
||||||
|
fallbackBriefingWhenEmpty,
|
||||||
|
sourceName: null,
|
||||||
|
preferredCategories.Count > 0 ? preferredCategories : null,
|
||||||
|
headlineCount: null,
|
||||||
|
providerDiagnostics: BuildNewsProviderDiagnostics(
|
||||||
|
"provider_empty",
|
||||||
|
preferredCategories,
|
||||||
|
requestedHeadlineCount,
|
||||||
|
snapshot?.Headlines.Count ?? 0));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Provider failures should never block baseline news behavior.
|
// Provider failures should never block baseline news behavior.
|
||||||
|
var fallbackBriefingOnError = randomizer.Choose(catalog.NewsBriefings);
|
||||||
|
return BuildNewsDecision(
|
||||||
|
fallbackBriefingOnError,
|
||||||
|
sourceName: null,
|
||||||
|
preferredCategories.Count > 0 ? preferredCategories : null,
|
||||||
|
headlineCount: null,
|
||||||
|
providerDiagnostics: BuildNewsProviderDiagnostics(
|
||||||
|
"provider_exception",
|
||||||
|
preferredCategories,
|
||||||
|
requestedHeadlineCount));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1024,15 +1051,21 @@ public sealed class JiboInteractionService(
|
|||||||
fallbackBriefing,
|
fallbackBriefing,
|
||||||
sourceName: null,
|
sourceName: null,
|
||||||
preferredCategories.Count > 0 ? preferredCategories : null,
|
preferredCategories.Count > 0 ? preferredCategories : null,
|
||||||
headlineCount: null);
|
headlineCount: null,
|
||||||
|
providerDiagnostics: BuildNewsProviderDiagnostics(
|
||||||
|
"provider_unavailable",
|
||||||
|
preferredCategories,
|
||||||
|
requestedHeadlineCount));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static JiboInteractionDecision BuildNewsDecision(
|
private static JiboInteractionDecision BuildNewsDecision(
|
||||||
string spokenBriefing,
|
string spokenBriefing,
|
||||||
string? sourceName,
|
string? sourceName,
|
||||||
IReadOnlyList<string>? categories,
|
IReadOnlyList<string>? categories,
|
||||||
int? headlineCount)
|
int? headlineCount,
|
||||||
|
IReadOnlyDictionary<string, object?>? providerDiagnostics = null)
|
||||||
{
|
{
|
||||||
|
var speakableBriefing = NormalizeNewsSpeechText(spokenBriefing);
|
||||||
var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
["skillId"] = "news",
|
["skillId"] = "news",
|
||||||
@@ -1042,7 +1075,7 @@ public sealed class JiboInteractionService(
|
|||||||
["prompt_id"] = "NewsHeadline_AN_01",
|
["prompt_id"] = "NewsHeadline_AN_01",
|
||||||
["prompt_sub_category"] = "AN",
|
["prompt_sub_category"] = "AN",
|
||||||
["esml"] =
|
["esml"] =
|
||||||
$"<speak><anim cat='news' meta='news-stinger' nonBlocking='true' /><break size='0.35'/><es cat='neutral' filter='!ssa-only, !sfx-only' endNeutral='true'>{EscapeForEsml(spokenBriefing)}</es></speak>"
|
$"<speak><anim cat='news' meta='news-stinger' nonBlocking='true' /><break size='0.35'/><es cat='neutral' filter='!ssa-only, !sfx-only' endNeutral='true'>{EscapeForEsml(speakableBriefing)}</es></speak>"
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(sourceName))
|
if (!string.IsNullOrWhiteSpace(sourceName))
|
||||||
@@ -1060,12 +1093,21 @@ public sealed class JiboInteractionService(
|
|||||||
payload["news_categories"] = categories.ToArray();
|
payload["news_categories"] = categories.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (providerDiagnostics is not null)
|
||||||
|
{
|
||||||
|
foreach (var (key, value) in providerDiagnostics)
|
||||||
|
{
|
||||||
|
payload[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return new JiboInteractionDecision("news", spokenBriefing, "news", payload);
|
return new JiboInteractionDecision("news", spokenBriefing, "news", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static JiboInteractionDecision BuildProviderNewsDecision(
|
private static JiboInteractionDecision BuildProviderNewsDecision(
|
||||||
NewsBriefingSnapshot snapshot,
|
NewsBriefingSnapshot snapshot,
|
||||||
IReadOnlyList<string> preferredCategories)
|
IReadOnlyList<string> preferredCategories,
|
||||||
|
int requestedHeadlineCount)
|
||||||
{
|
{
|
||||||
var headlines = snapshot.Headlines
|
var headlines = snapshot.Headlines
|
||||||
.Where(headline => !string.IsNullOrWhiteSpace(headline.Title))
|
.Where(headline => !string.IsNullOrWhiteSpace(headline.Title))
|
||||||
@@ -1077,7 +1119,12 @@ public sealed class JiboInteractionService(
|
|||||||
"I couldn't load fresh headlines right now.",
|
"I couldn't load fresh headlines right now.",
|
||||||
snapshot.SourceName,
|
snapshot.SourceName,
|
||||||
preferredCategories,
|
preferredCategories,
|
||||||
headlineCount: 0);
|
headlineCount: 0,
|
||||||
|
providerDiagnostics: BuildNewsProviderDiagnostics(
|
||||||
|
"provider_empty",
|
||||||
|
preferredCategories,
|
||||||
|
requestedHeadlineCount,
|
||||||
|
0));
|
||||||
}
|
}
|
||||||
|
|
||||||
var leadIn = BuildNewsLeadIn(snapshot.SourceName, preferredCategories);
|
var leadIn = BuildNewsLeadIn(snapshot.SourceName, preferredCategories);
|
||||||
@@ -1087,7 +1134,35 @@ public sealed class JiboInteractionService(
|
|||||||
spokenBriefing,
|
spokenBriefing,
|
||||||
snapshot.SourceName,
|
snapshot.SourceName,
|
||||||
preferredCategories,
|
preferredCategories,
|
||||||
headlines.Length);
|
headlines.Length,
|
||||||
|
providerDiagnostics: BuildNewsProviderDiagnostics(
|
||||||
|
"provider_success",
|
||||||
|
preferredCategories,
|
||||||
|
requestedHeadlineCount,
|
||||||
|
headlines.Length));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, object?> BuildNewsProviderDiagnostics(
|
||||||
|
string status,
|
||||||
|
IReadOnlyList<string> preferredCategories,
|
||||||
|
int requestedHeadlineCount,
|
||||||
|
int? resolvedHeadlineCount = null)
|
||||||
|
{
|
||||||
|
var diagnostics = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["news_provider_status"] = status,
|
||||||
|
["news_provider_requested_headlines"] = requestedHeadlineCount,
|
||||||
|
["news_provider_preferred_categories"] = preferredCategories.Count > 0
|
||||||
|
? preferredCategories.ToArray()
|
||||||
|
: Array.Empty<string>()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (resolvedHeadlineCount is not null)
|
||||||
|
{
|
||||||
|
diagnostics["news_provider_resolved_headlines"] = resolvedHeadlineCount.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return diagnostics;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildNewsLeadIn(string? sourceName, IReadOnlyList<string> preferredCategories)
|
private static string BuildNewsLeadIn(string? sourceName, IReadOnlyList<string> preferredCategories)
|
||||||
@@ -1104,6 +1179,21 @@ public sealed class JiboInteractionService(
|
|||||||
: $"{categoryLeadIn} Source: {sourceName}.";
|
: $"{categoryLeadIn} Source: {sourceName}.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string NormalizeNewsSpeechText(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand "AI" so Nimbus TTS does not collapse it to a single "aye" sound.
|
||||||
|
return Regex.Replace(
|
||||||
|
text,
|
||||||
|
@"\bA\.?\s*I\.?\b",
|
||||||
|
"artificial intelligence",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||||
|
}
|
||||||
|
|
||||||
private List<string> ResolvePreferredNewsCategories(TurnContext turn, string transcript)
|
private List<string> ResolvePreferredNewsCategories(TurnContext turn, string transcript)
|
||||||
{
|
{
|
||||||
var categories = new List<string>();
|
var categories = new List<string>();
|
||||||
@@ -1343,6 +1433,19 @@ public sealed class JiboInteractionService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isYesNoTurn)
|
||||||
|
{
|
||||||
|
if (IsAffirmativeReply(loweredTranscript))
|
||||||
|
{
|
||||||
|
return "yes";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsNegativeReply(loweredTranscript))
|
||||||
|
{
|
||||||
|
return "no";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (IsNameSetStatement(loweredTranscript))
|
if (IsNameSetStatement(loweredTranscript))
|
||||||
{
|
{
|
||||||
return "memory_set_name";
|
return "memory_set_name";
|
||||||
@@ -1779,14 +1882,6 @@ public sealed class JiboInteractionService(
|
|||||||
return "hello";
|
return "hello";
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (isYesNoTurn)
|
|
||||||
{
|
|
||||||
case true when IsAffirmativeReply(loweredTranscript):
|
|
||||||
return "yes";
|
|
||||||
case true when IsNegativeReply(loweredTranscript):
|
|
||||||
return "no";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (IsTimeRequest(loweredTranscript))
|
if (IsTimeRequest(loweredTranscript))
|
||||||
{
|
{
|
||||||
return "time";
|
return "time";
|
||||||
@@ -2252,15 +2347,99 @@ public sealed class JiboInteractionService(
|
|||||||
private static bool IsAffirmativeReply(string loweredTranscript)
|
private static bool IsAffirmativeReply(string loweredTranscript)
|
||||||
{
|
{
|
||||||
var normalized = NormalizeCommandPhrase(loweredTranscript);
|
var normalized = NormalizeCommandPhrase(loweredTranscript);
|
||||||
return normalized is "yes" or "yeah" or "yep" or "yup" or "sure" or "ok" or "okay" or "absolutely" or "please do" or "why not" ||
|
return TryClassifyYesNoReply(normalized) == YesNoReply.Affirmative;
|
||||||
MatchesAny(normalized, "uh huh", "sounds good");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsNegativeReply(string loweredTranscript)
|
private static bool IsNegativeReply(string loweredTranscript)
|
||||||
{
|
{
|
||||||
var normalized = NormalizeCommandPhrase(loweredTranscript);
|
var normalized = NormalizeCommandPhrase(loweredTranscript);
|
||||||
return normalized is "no" or "nope" or "nah" or "not now" or "no thanks" or "not today" ||
|
return TryClassifyYesNoReply(normalized) == YesNoReply.Negative;
|
||||||
MatchesAny(normalized, "no thank you", "maybe later");
|
}
|
||||||
|
|
||||||
|
private static YesNoReply TryClassifyYesNoReply(string normalizedTranscript)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedTranscript))
|
||||||
|
{
|
||||||
|
return YesNoReply.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = normalizedTranscript;
|
||||||
|
while (TryTrimLeadingAcknowledgement(normalized, out var trimmed))
|
||||||
|
{
|
||||||
|
normalized = trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(normalized))
|
||||||
|
{
|
||||||
|
return YesNoReply.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokens = normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
if (tokens.Length == 0)
|
||||||
|
{
|
||||||
|
return YesNoReply.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (YesNoNegativeLeadTokens.Contains(tokens[0]))
|
||||||
|
{
|
||||||
|
return YesNoReply.Negative;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (YesNoAffirmativeLeadTokens.Contains(tokens[0]))
|
||||||
|
{
|
||||||
|
return YesNoReply.Affirmative;
|
||||||
|
}
|
||||||
|
|
||||||
|
var leadingTwo = tokens.Length >= 2 ? $"{tokens[0]} {tokens[1]}" : null;
|
||||||
|
if (leadingTwo is not null)
|
||||||
|
{
|
||||||
|
if (YesNoNegativeLeadPhrases.Contains(leadingTwo))
|
||||||
|
{
|
||||||
|
return YesNoReply.Negative;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (YesNoAffirmativeLeadPhrases.Contains(leadingTwo))
|
||||||
|
{
|
||||||
|
return YesNoReply.Affirmative;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var leadingThree = tokens.Length >= 3 ? $"{tokens[0]} {tokens[1]} {tokens[2]}" : null;
|
||||||
|
if (leadingThree is not null)
|
||||||
|
{
|
||||||
|
if (YesNoNegativeLeadPhrases.Contains(leadingThree))
|
||||||
|
{
|
||||||
|
return YesNoReply.Negative;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (YesNoAffirmativeLeadPhrases.Contains(leadingThree))
|
||||||
|
{
|
||||||
|
return YesNoReply.Affirmative;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return YesNoReply.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryTrimLeadingAcknowledgement(string normalizedTranscript, out string trimmedTranscript)
|
||||||
|
{
|
||||||
|
foreach (var acknowledgement in YesNoAcknowledgementPrefixes)
|
||||||
|
{
|
||||||
|
if (string.Equals(normalizedTranscript, acknowledgement, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
trimmedTranscript = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedTranscript.StartsWith($"{acknowledgement} ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
trimmedTranscript = normalizedTranscript[(acknowledgement.Length + 1)..].TrimStart();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmedTranscript = normalizedTranscript;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsTimeRequest(string loweredTranscript)
|
private static bool IsTimeRequest(string loweredTranscript)
|
||||||
@@ -3983,6 +4162,13 @@ public sealed class JiboInteractionService(
|
|||||||
public static WeatherDateEntity None { get; } = new(null, 0, null);
|
public static WeatherDateEntity None { get; } = new(null, 0, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum YesNoReply
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Affirmative = 1,
|
||||||
|
Negative = 2
|
||||||
|
}
|
||||||
|
|
||||||
private static readonly Regex SplitAlarmPattern = new(
|
private static readonly Regex SplitAlarmPattern = new(
|
||||||
@"\b(?<hour>\d{1,2}|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)(?:[:\s,-]+(?<minute>\d{2}|[a-z\-]+(?:\s+[a-z\-]+)?))?\s*(?<ampm>a[\s\.]*m\.?|p[\s\.]*m\.?)?\b",
|
@"\b(?<hour>\d{1,2}|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)(?:[:\s,-]+(?<minute>\d{2}|[a-z\-]+(?:\s+[a-z\-]+)?))?\s*(?<ampm>a[\s\.]*m\.?|p[\s\.]*m\.?)?\b",
|
||||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
||||||
@@ -4062,6 +4248,69 @@ public sealed class JiboInteractionService(
|
|||||||
"day"
|
"day"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] YesNoAcknowledgementPrefixes =
|
||||||
|
[
|
||||||
|
"uh",
|
||||||
|
"um",
|
||||||
|
"hmm",
|
||||||
|
"well",
|
||||||
|
"so",
|
||||||
|
"actually",
|
||||||
|
"honestly"
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly HashSet<string> YesNoAffirmativeLeadTokens = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"yes",
|
||||||
|
"yeah",
|
||||||
|
"yep",
|
||||||
|
"yup",
|
||||||
|
"sure",
|
||||||
|
"ok",
|
||||||
|
"okay",
|
||||||
|
"absolutely",
|
||||||
|
"affirmative",
|
||||||
|
"definitely",
|
||||||
|
"certainly",
|
||||||
|
"indeed"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly HashSet<string> YesNoNegativeLeadTokens = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"no",
|
||||||
|
"nope",
|
||||||
|
"nah",
|
||||||
|
"negative",
|
||||||
|
"never"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly HashSet<string> YesNoAffirmativeLeadPhrases = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"uh huh",
|
||||||
|
"sounds good",
|
||||||
|
"sure thing",
|
||||||
|
"why not",
|
||||||
|
"please do",
|
||||||
|
"go ahead",
|
||||||
|
"of course",
|
||||||
|
"i guess so",
|
||||||
|
"i think so"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly HashSet<string> YesNoNegativeLeadPhrases = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"not now",
|
||||||
|
"not today",
|
||||||
|
"not really",
|
||||||
|
"no thanks",
|
||||||
|
"no thank you",
|
||||||
|
"maybe later",
|
||||||
|
"i guess not",
|
||||||
|
"i do not",
|
||||||
|
"i dont",
|
||||||
|
"i don t"
|
||||||
|
};
|
||||||
|
|
||||||
// Directly imported from Pegasus parser intent phrase families:
|
// Directly imported from Pegasus parser intent phrase families:
|
||||||
// userLikesThing / userDislikesThing / doesUserLikeThing / doesUserDislikeThing.
|
// userLikesThing / userDislikesThing / doesUserLikeThing / doesUserDislikeThing.
|
||||||
private static readonly (string Prefix, PersonalAffinity Affinity)[] PegasusUserAffinitySetPrefixes =
|
private static readonly (string Prefix, PersonalAffinity Affinity)[] PegasusUserAffinitySetPrefixes =
|
||||||
|
|||||||
@@ -1198,7 +1198,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
|||||||
{
|
{
|
||||||
id = "hiNumLabel",
|
id = "hiNumLabel",
|
||||||
type = "Label",
|
type = "Label",
|
||||||
text = $"{high.Value}\u00B0",
|
text = high.Value.ToString(),
|
||||||
style = new
|
style = new
|
||||||
{
|
{
|
||||||
fontSize = "160",
|
fontSize = "160",
|
||||||
@@ -1230,7 +1230,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
|||||||
{
|
{
|
||||||
id = "loNumLabel",
|
id = "loNumLabel",
|
||||||
type = "Label",
|
type = "Label",
|
||||||
text = $"{low.Value}\u00B0",
|
text = low.Value.ToString(),
|
||||||
style = new
|
style = new
|
||||||
{
|
{
|
||||||
fontSize = "160",
|
fontSize = "160",
|
||||||
|
|||||||
@@ -682,6 +682,29 @@ public sealed partial class WebSocketTurnFinalizationService(
|
|||||||
["payload"] = invokedSkillAction.Payload
|
["payload"] = invokedSkillAction.Payload
|
||||||
}),
|
}),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
if (string.Equals(plan.IntentName, "news", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
invokedSkillAction.Payload.TryGetValue("news_provider_status", out var providerStatus))
|
||||||
|
{
|
||||||
|
invokedSkillAction.Payload.TryGetValue("news_provider_requested_headlines", out var requestedHeadlines);
|
||||||
|
invokedSkillAction.Payload.TryGetValue("news_provider_resolved_headlines", out var resolvedHeadlines);
|
||||||
|
invokedSkillAction.Payload.TryGetValue("news_provider_preferred_categories", out var preferredCategories);
|
||||||
|
invokedSkillAction.Payload.TryGetValue("news_source", out var newsSource);
|
||||||
|
|
||||||
|
await sink.RecordTurnDiagnosticAsync(
|
||||||
|
"news_provider_trace",
|
||||||
|
BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["intent"] = plan.IntentName,
|
||||||
|
["skillName"] = invokedSkillAction.SkillName,
|
||||||
|
["status"] = providerStatus,
|
||||||
|
["requestedHeadlines"] = requestedHeadlines,
|
||||||
|
["resolvedHeadlines"] = resolvedHeadlines,
|
||||||
|
["preferredCategories"] = preferredCategories,
|
||||||
|
["source"] = newsSource
|
||||||
|
}),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
session.FollowUpExpiresUtc = plan.FollowUp.KeepMicOpen
|
session.FollowUpExpiresUtc = plan.FollowUp.KeepMicOpen
|
||||||
@@ -1056,13 +1079,13 @@ public sealed partial class WebSocketTurnFinalizationService(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsYesNoTurn(turn) && transcript is "yes" or "no" or "sure" or "nope" or "yup" or "uh huh" or "yeah" or "nah")
|
if (IsYesNoTurn(turn) && IsYesNoReplyTranscript(transcript))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(pendingProactivityOffer) &&
|
if (!string.IsNullOrWhiteSpace(pendingProactivityOffer) &&
|
||||||
transcript is "yes" or "no" or "sure" or "nope" or "yup" or "uh huh" or "yeah" or "nah")
|
IsYesNoReplyTranscript(transcript))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1188,6 +1211,97 @@ public sealed partial class WebSocketTurnFinalizationService(
|
|||||||
string.Equals(rule, "surprises-ota/want_to_download_now", StringComparison.OrdinalIgnoreCase);
|
string.Equals(rule, "surprises-ota/want_to_download_now", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsYesNoReplyTranscript(string normalizedTranscript)
|
||||||
|
{
|
||||||
|
return TryClassifyYesNoReply(normalizedTranscript) is not YesNoReply.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static YesNoReply TryClassifyYesNoReply(string normalizedTranscript)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedTranscript))
|
||||||
|
{
|
||||||
|
return YesNoReply.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = normalizedTranscript;
|
||||||
|
while (TryTrimLeadingAcknowledgement(normalized, out var trimmed))
|
||||||
|
{
|
||||||
|
normalized = trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(normalized))
|
||||||
|
{
|
||||||
|
return YesNoReply.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokens = normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
if (tokens.Length == 0)
|
||||||
|
{
|
||||||
|
return YesNoReply.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (YesNoNegativeLeadTokens.Contains(tokens[0]))
|
||||||
|
{
|
||||||
|
return YesNoReply.Negative;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (YesNoAffirmativeLeadTokens.Contains(tokens[0]))
|
||||||
|
{
|
||||||
|
return YesNoReply.Affirmative;
|
||||||
|
}
|
||||||
|
|
||||||
|
var leadingTwo = tokens.Length >= 2 ? $"{tokens[0]} {tokens[1]}" : null;
|
||||||
|
if (leadingTwo is not null)
|
||||||
|
{
|
||||||
|
if (YesNoNegativeLeadPhrases.Contains(leadingTwo))
|
||||||
|
{
|
||||||
|
return YesNoReply.Negative;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (YesNoAffirmativeLeadPhrases.Contains(leadingTwo))
|
||||||
|
{
|
||||||
|
return YesNoReply.Affirmative;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var leadingThree = tokens.Length >= 3 ? $"{tokens[0]} {tokens[1]} {tokens[2]}" : null;
|
||||||
|
if (leadingThree is not null)
|
||||||
|
{
|
||||||
|
if (YesNoNegativeLeadPhrases.Contains(leadingThree))
|
||||||
|
{
|
||||||
|
return YesNoReply.Negative;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (YesNoAffirmativeLeadPhrases.Contains(leadingThree))
|
||||||
|
{
|
||||||
|
return YesNoReply.Affirmative;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return YesNoReply.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryTrimLeadingAcknowledgement(string normalizedTranscript, out string trimmedTranscript)
|
||||||
|
{
|
||||||
|
foreach (var acknowledgement in YesNoAcknowledgementPrefixes)
|
||||||
|
{
|
||||||
|
if (string.Equals(normalizedTranscript, acknowledgement, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
trimmedTranscript = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedTranscript.StartsWith($"{acknowledgement} ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
trimmedTranscript = normalizedTranscript[(acknowledgement.Length + 1)..].TrimStart();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmedTranscript = normalizedTranscript;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ApplyContextUpdatesAsync(
|
private async Task ApplyContextUpdatesAsync(
|
||||||
CloudSession session,
|
CloudSession session,
|
||||||
IDictionary<string, object?> contextUpdates,
|
IDictionary<string, object?> contextUpdates,
|
||||||
@@ -1772,6 +1886,76 @@ public sealed partial class WebSocketTurnFinalizationService(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum YesNoReply
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Affirmative = 1,
|
||||||
|
Negative = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly string[] YesNoAcknowledgementPrefixes =
|
||||||
|
[
|
||||||
|
"uh",
|
||||||
|
"um",
|
||||||
|
"hmm",
|
||||||
|
"well",
|
||||||
|
"so",
|
||||||
|
"actually",
|
||||||
|
"honestly"
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly HashSet<string> YesNoAffirmativeLeadTokens = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"yes",
|
||||||
|
"yeah",
|
||||||
|
"yep",
|
||||||
|
"yup",
|
||||||
|
"sure",
|
||||||
|
"ok",
|
||||||
|
"okay",
|
||||||
|
"absolutely",
|
||||||
|
"affirmative",
|
||||||
|
"definitely",
|
||||||
|
"certainly",
|
||||||
|
"indeed"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly HashSet<string> YesNoNegativeLeadTokens = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"no",
|
||||||
|
"nope",
|
||||||
|
"nah",
|
||||||
|
"negative",
|
||||||
|
"never"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly HashSet<string> YesNoAffirmativeLeadPhrases = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"uh huh",
|
||||||
|
"sounds good",
|
||||||
|
"sure thing",
|
||||||
|
"why not",
|
||||||
|
"please do",
|
||||||
|
"go ahead",
|
||||||
|
"of course",
|
||||||
|
"i guess so",
|
||||||
|
"i think so"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly HashSet<string> YesNoNegativeLeadPhrases = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"not now",
|
||||||
|
"not today",
|
||||||
|
"not really",
|
||||||
|
"no thanks",
|
||||||
|
"no thank you",
|
||||||
|
"maybe later",
|
||||||
|
"i guess not",
|
||||||
|
"i do not",
|
||||||
|
"i dont",
|
||||||
|
"i don t"
|
||||||
|
};
|
||||||
|
|
||||||
[GeneratedRegex(@"[^\w\s]")]
|
[GeneratedRegex(@"[^\w\s]")]
|
||||||
private static partial Regex TranscriptNormalizationRegex();
|
private static partial Regex TranscriptNormalizationRegex();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public sealed class NewsApiBriefingProvider(
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(options.ApiKey))
|
if (string.IsNullOrWhiteSpace(options.ApiKey))
|
||||||
{
|
{
|
||||||
|
logger.LogWarning("NewsAPI provider disabled because no API key is configured.");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,8 +34,18 @@ public sealed class NewsApiBriefingProvider(
|
|||||||
|
|
||||||
var requestedHeadlineCount = Math.Clamp(request.MaxHeadlines, 1, MaxHeadlines);
|
var requestedHeadlineCount = Math.Clamp(request.MaxHeadlines, 1, MaxHeadlines);
|
||||||
cacheKey = BuildCacheKey(categories, requestedHeadlineCount);
|
cacheKey = BuildCacheKey(categories, requestedHeadlineCount);
|
||||||
|
logger.LogInformation(
|
||||||
|
"NewsAPI request started. Categories={Categories} RequestedHeadlineCount={RequestedHeadlineCount} CacheKey={CacheKey}",
|
||||||
|
string.Join(",", categories),
|
||||||
|
requestedHeadlineCount,
|
||||||
|
cacheKey);
|
||||||
if (TryGetCachedValue(briefingCache, cacheKey, out var cachedBriefing))
|
if (TryGetCachedValue(briefingCache, cacheKey, out var cachedBriefing))
|
||||||
{
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"NewsAPI cache hit. CacheKey={CacheKey} HasSnapshot={HasSnapshot} HeadlineCount={HeadlineCount}",
|
||||||
|
cacheKey,
|
||||||
|
cachedBriefing is not null,
|
||||||
|
cachedBriefing?.Headlines.Count ?? 0);
|
||||||
return cachedBriefing;
|
return cachedBriefing;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,14 +58,32 @@ public sealed class NewsApiBriefingProvider(
|
|||||||
using var response = await httpClient.GetAsync(uri, cancellationToken);
|
using var response = await httpClient.GetAsync(uri, cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"NewsAPI request failed for category {Category}. StatusCode={StatusCode} Reason={ReasonPhrase}",
|
||||||
|
category,
|
||||||
|
(int)response.StatusCode,
|
||||||
|
response.ReasonPhrase);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||||
|
if (document.RootElement.TryGetProperty("status", out var statusNode) &&
|
||||||
|
statusNode.ValueKind == JsonValueKind.String &&
|
||||||
|
!string.Equals(statusNode.GetString(), "ok", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"NewsAPI returned non-ok status for category {Category}. Status={Status} Code={Code} Message={Message}",
|
||||||
|
category,
|
||||||
|
statusNode.GetString(),
|
||||||
|
ReadString(document.RootElement, "code") ?? string.Empty,
|
||||||
|
ReadString(document.RootElement, "message") ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
if (!document.RootElement.TryGetProperty("articles", out var articles) ||
|
if (!document.RootElement.TryGetProperty("articles", out var articles) ||
|
||||||
articles.ValueKind != JsonValueKind.Array)
|
articles.ValueKind != JsonValueKind.Array)
|
||||||
{
|
{
|
||||||
|
logger.LogWarning("NewsAPI response missing articles array for category {Category}.", category);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +107,10 @@ public sealed class NewsApiBriefingProvider(
|
|||||||
{
|
{
|
||||||
var snapshot = new NewsBriefingSnapshot(headlines, "NewsAPI");
|
var snapshot = new NewsBriefingSnapshot(headlines, "NewsAPI");
|
||||||
SetCachedValue(briefingCache, cacheKey, snapshot, options.CacheTtlSeconds);
|
SetCachedValue(briefingCache, cacheKey, snapshot, options.CacheTtlSeconds);
|
||||||
|
logger.LogInformation(
|
||||||
|
"NewsAPI request succeeded. Categories={Categories} HeadlineCount={HeadlineCount}",
|
||||||
|
string.Join(",", categories),
|
||||||
|
headlines.Count);
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,11 +119,20 @@ public sealed class NewsApiBriefingProvider(
|
|||||||
if (headlines.Count == 0)
|
if (headlines.Count == 0)
|
||||||
{
|
{
|
||||||
SetCachedValue(briefingCache, cacheKey, null, options.FailureCacheTtlSeconds);
|
SetCachedValue(briefingCache, cacheKey, null, options.FailureCacheTtlSeconds);
|
||||||
|
logger.LogWarning(
|
||||||
|
"NewsAPI returned no usable headlines. Categories={Categories} RequestedHeadlineCount={RequestedHeadlineCount}",
|
||||||
|
string.Join(",", categories),
|
||||||
|
requestedHeadlineCount);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var populatedSnapshot = new NewsBriefingSnapshot(headlines, "NewsAPI");
|
var populatedSnapshot = new NewsBriefingSnapshot(headlines, "NewsAPI");
|
||||||
SetCachedValue(briefingCache, cacheKey, populatedSnapshot, options.CacheTtlSeconds);
|
SetCachedValue(briefingCache, cacheKey, populatedSnapshot, options.CacheTtlSeconds);
|
||||||
|
logger.LogInformation(
|
||||||
|
"NewsAPI request partially filled headlines. Categories={Categories} HeadlineCount={HeadlineCount} RequestedHeadlineCount={RequestedHeadlineCount}",
|
||||||
|
string.Join(",", categories),
|
||||||
|
headlines.Count,
|
||||||
|
requestedHeadlineCount);
|
||||||
return populatedSnapshot;
|
return populatedSnapshot;
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
|
|||||||
@@ -1048,6 +1048,25 @@ public sealed class JiboInteractionServiceTests
|
|||||||
Assert.Contains("350 slices per second", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("350 slices per second", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildDecisionAsync_PendingPizzaFactOffer_YesWithTailMapsToFact()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
|
||||||
|
var decision = await service.BuildDecisionAsync(new TurnContext
|
||||||
|
{
|
||||||
|
RawTranscript = "yes I want to",
|
||||||
|
NormalizedTranscript = "yes I want to",
|
||||||
|
Attributes = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["pendingProactivityOffer"] = "pizza_fact"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("proactive_pizza_fact", decision.IntentName);
|
||||||
|
Assert.Contains("350 slices per second", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task BuildDecisionAsync_PendingPizzaFactOffer_NoMapsToDecline()
|
public async Task BuildDecisionAsync_PendingPizzaFactOffer_NoMapsToDecline()
|
||||||
{
|
{
|
||||||
@@ -1067,6 +1086,25 @@ public sealed class JiboInteractionServiceTests
|
|||||||
Assert.Equal("No problem. We can save the pizza fact for another time.", decision.ReplyText);
|
Assert.Equal("No problem. We can save the pizza fact for another time.", decision.ReplyText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildDecisionAsync_PendingPizzaFactOffer_NoWithTailMapsToDecline()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
|
||||||
|
var decision = await service.BuildDecisionAsync(new TurnContext
|
||||||
|
{
|
||||||
|
RawTranscript = "no I do not",
|
||||||
|
NormalizedTranscript = "no I do not",
|
||||||
|
Attributes = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["pendingProactivityOffer"] = "pizza_fact"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("proactive_offer_declined", decision.IntentName);
|
||||||
|
Assert.Equal("No problem. We can save the pizza fact for another time.", decision.ReplyText);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task BuildDecisionAsync_MakePizza_UsesOriginalMimStylePayload()
|
public async Task BuildDecisionAsync_MakePizza_UsesOriginalMimStylePayload()
|
||||||
{
|
{
|
||||||
@@ -1968,6 +2006,46 @@ public sealed class JiboInteractionServiceTests
|
|||||||
Assert.Equal("No.", decision.ReplyText);
|
Assert.Equal("No.", decision.ReplyText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildDecisionAsync_SharedYesNoPrompt_MapsAffirmativeWordToYesIntent()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
|
||||||
|
var decision = await service.BuildDecisionAsync(new TurnContext
|
||||||
|
{
|
||||||
|
RawTranscript = "affirmative",
|
||||||
|
NormalizedTranscript = "affirmative",
|
||||||
|
Attributes = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["listenRules"] = (string[])["shared/yes_no", "globals/gui_nav"],
|
||||||
|
["listenAsrHints"] = (string[])["$YESNO"]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("yes", decision.IntentName);
|
||||||
|
Assert.Equal("Yes.", decision.ReplyText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildDecisionAsync_SharedYesNoPrompt_MapsNegativeWordToNoIntent()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
|
||||||
|
var decision = await service.BuildDecisionAsync(new TurnContext
|
||||||
|
{
|
||||||
|
RawTranscript = "negative",
|
||||||
|
NormalizedTranscript = "negative",
|
||||||
|
Attributes = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["listenRules"] = (string[])["shared/yes_no", "globals/gui_nav"],
|
||||||
|
["listenAsrHints"] = (string[])["$YESNO"]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("no", decision.IntentName);
|
||||||
|
Assert.Equal("No.", decision.ReplyText);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task BuildDecisionAsync_SettingsDownloadPrompt_MapsShortDenialToNoIntent()
|
public async Task BuildDecisionAsync_SettingsDownloadPrompt_MapsShortDenialToNoIntent()
|
||||||
{
|
{
|
||||||
@@ -2666,6 +2744,7 @@ public sealed class JiboInteractionServiceTests
|
|||||||
Assert.Equal("news", decision.SkillPayload!["skillId"]);
|
Assert.Equal("news", decision.SkillPayload!["skillId"]);
|
||||||
Assert.Equal("news", decision.SkillPayload["cloudSkill"]);
|
Assert.Equal("news", decision.SkillPayload["cloudSkill"]);
|
||||||
Assert.Equal("runtime-news", decision.SkillPayload["mim_id"]);
|
Assert.Equal("runtime-news", decision.SkillPayload["mim_id"]);
|
||||||
|
Assert.Equal("provider_unavailable", decision.SkillPayload["news_provider_status"]);
|
||||||
Assert.DoesNotContain("future cloud integration", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
Assert.DoesNotContain("future cloud integration", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2697,6 +2776,9 @@ public sealed class JiboInteractionServiceTests
|
|||||||
Assert.Contains("news-stinger", decision.SkillPayload["esml"]?.ToString(), StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("news-stinger", decision.SkillPayload["esml"]?.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||||
Assert.Equal("NewsAPI", decision.SkillPayload["news_source"]);
|
Assert.Equal("NewsAPI", decision.SkillPayload["news_source"]);
|
||||||
Assert.Equal(2, decision.SkillPayload["news_headline_count"]);
|
Assert.Equal(2, decision.SkillPayload["news_headline_count"]);
|
||||||
|
Assert.Equal("provider_success", decision.SkillPayload["news_provider_status"]);
|
||||||
|
Assert.Equal(3, decision.SkillPayload["news_provider_requested_headlines"]);
|
||||||
|
Assert.Equal(2, decision.SkillPayload["news_provider_resolved_headlines"]);
|
||||||
Assert.Contains("Local robotics team unveils weather-ready helper", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("Local robotics team unveils weather-ready helper", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||||
Assert.NotNull(provider.LastRequest);
|
Assert.NotNull(provider.LastRequest);
|
||||||
Assert.Equal(3, provider.LastRequest!.MaxHeadlines);
|
Assert.Equal(3, provider.LastRequest!.MaxHeadlines);
|
||||||
@@ -2724,6 +2806,7 @@ public sealed class JiboInteractionServiceTests
|
|||||||
Assert.Equal("news", decision.IntentName);
|
Assert.Equal("news", decision.IntentName);
|
||||||
Assert.NotNull(provider.LastRequest);
|
Assert.NotNull(provider.LastRequest);
|
||||||
Assert.Contains("technology", provider.LastRequest!.PreferredCategories, StringComparer.OrdinalIgnoreCase);
|
Assert.Contains("technology", provider.LastRequest!.PreferredCategories, StringComparer.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("artificial intelligence", decision.SkillPayload?["esml"]?.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -1613,6 +1613,36 @@ public sealed class JiboWebSocketServiceTests
|
|||||||
Assert.Equal("surprises-ota/want_to_download_now", listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString());
|
Assert.Equal("surprises-ota/want_to_download_now", listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClientAsr_YesNoPromptFromAsrHints_MapsYepToYesIntent()
|
||||||
|
{
|
||||||
|
await _service.HandleMessageAsync(new WebSocketMessageEnvelope
|
||||||
|
{
|
||||||
|
HostName = "neo-hub.jibo.com",
|
||||||
|
Path = "/listen",
|
||||||
|
Kind = "neo-hub-listen",
|
||||||
|
Token = "hub-yesno-hints-yep-token",
|
||||||
|
Text = """{"type":"LISTEN","transID":"trans-yesno-hints-yep","data":{"rules":["surprises-ota/want_to_download_now"],"asr":{"hints":["$YESNO"]}}}"""
|
||||||
|
});
|
||||||
|
|
||||||
|
var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope
|
||||||
|
{
|
||||||
|
HostName = "neo-hub.jibo.com",
|
||||||
|
Path = "/listen",
|
||||||
|
Kind = "neo-hub-listen",
|
||||||
|
Token = "hub-yesno-hints-yep-token",
|
||||||
|
Text = """{"type":"CLIENT_ASR","transID":"trans-yesno-hints-yep","data":{"text":"yep"}}"""
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(3, replies.Count);
|
||||||
|
|
||||||
|
using var listenPayload = JsonDocument.Parse(replies[0].Text!);
|
||||||
|
Assert.Equal("yep", listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString());
|
||||||
|
Assert.Equal("yes", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString());
|
||||||
|
Assert.Equal("surprises-ota/want_to_download_now", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules")[0].GetString());
|
||||||
|
Assert.Equal("surprises-ota/want_to_download_now", listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ClientAsr_SharedYesNoPrompt_StripsGlobalRulesAndStaysLocal()
|
public async Task ClientAsr_SharedYesNoPrompt_StripsGlobalRulesAndStaysLocal()
|
||||||
{
|
{
|
||||||
@@ -1644,6 +1674,37 @@ public sealed class JiboWebSocketServiceTests
|
|||||||
Assert.Equal("shared/yes_no", listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString());
|
Assert.Equal("shared/yes_no", listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClientAsr_SharedYesNoPrompt_MapsNegativeWordToNoIntent()
|
||||||
|
{
|
||||||
|
await _service.HandleMessageAsync(new WebSocketMessageEnvelope
|
||||||
|
{
|
||||||
|
HostName = "neo-hub.jibo.com",
|
||||||
|
Path = "/listen",
|
||||||
|
Kind = "neo-hub-listen",
|
||||||
|
Token = "hub-shared-yesno-negative-token",
|
||||||
|
Text = """{"type":"LISTEN","transID":"trans-shared-yesno-negative","data":{"rules":["shared/yes_no","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"],"asr":{"hints":["$YESNO"]}}}"""
|
||||||
|
});
|
||||||
|
|
||||||
|
var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope
|
||||||
|
{
|
||||||
|
HostName = "neo-hub.jibo.com",
|
||||||
|
Path = "/listen",
|
||||||
|
Kind = "neo-hub-listen",
|
||||||
|
Token = "hub-shared-yesno-negative-token",
|
||||||
|
Text = """{"type":"CLIENT_ASR","transID":"trans-shared-yesno-negative","data":{"text":"negative"}}"""
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(3, replies.Count);
|
||||||
|
|
||||||
|
using var listenPayload = JsonDocument.Parse(replies[0].Text!);
|
||||||
|
Assert.Equal("no", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString());
|
||||||
|
var rules = listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules");
|
||||||
|
Assert.Single(rules.EnumerateArray());
|
||||||
|
Assert.Equal("shared/yes_no", rules[0].GetString());
|
||||||
|
Assert.Equal("shared/yes_no", listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ClientAsr_AlarmTimerChangeYesNoPrompt_StripsGlobalRulesAndStaysLocal()
|
public async Task ClientAsr_AlarmTimerChangeYesNoPrompt_StripsGlobalRulesAndStaysLocal()
|
||||||
{
|
{
|
||||||
@@ -2134,12 +2195,13 @@ public sealed class JiboWebSocketServiceTests
|
|||||||
Text = """{"type":"CLIENT_ASR","transID":"trans-weather-provider","data":{"text":"how is the weather"}}"""
|
Text = """{"type":"CLIENT_ASR","transID":"trans-weather-provider","data":{"text":"how is the weather"}}"""
|
||||||
});
|
});
|
||||||
|
|
||||||
Assert.Equal(3, replies.Count);
|
Assert.True(replies.Count >= 3);
|
||||||
Assert.Equal("LISTEN", ReadReplyType(replies[0]));
|
Assert.Equal("LISTEN", ReadReplyType(replies[0]));
|
||||||
Assert.Equal("EOS", ReadReplyType(replies[1]));
|
Assert.Equal("EOS", ReadReplyType(replies[1]));
|
||||||
Assert.Equal("SKILL_ACTION", ReadReplyType(replies[2]));
|
Assert.Contains(replies, static reply => string.Equals(ReadReplyType(reply), "SKILL_ACTION", StringComparison.Ordinal));
|
||||||
|
|
||||||
using var skillPayload = JsonDocument.Parse(replies[2].Text!);
|
var skillReply = replies.Last(static reply => string.Equals(ReadReplyType(reply), "SKILL_ACTION", StringComparison.Ordinal));
|
||||||
|
using var skillPayload = JsonDocument.Parse(skillReply.Text!);
|
||||||
var jcpConfig = skillPayload.RootElement
|
var jcpConfig = skillPayload.RootElement
|
||||||
.GetProperty("data")
|
.GetProperty("data")
|
||||||
.GetProperty("action")
|
.GetProperty("action")
|
||||||
@@ -2173,7 +2235,7 @@ public sealed class JiboWebSocketServiceTests
|
|||||||
"weatherTempView",
|
"weatherTempView",
|
||||||
local.GetProperty("views").GetProperty("weatherHiLo").GetProperty("viewConfig").GetProperty("id").GetString());
|
local.GetProperty("views").GetProperty("weatherHiLo").GetProperty("viewConfig").GetProperty("id").GetString());
|
||||||
|
|
||||||
var payloadText = replies[2].Text!;
|
var payloadText = skillReply.Text!;
|
||||||
Assert.Contains("assets/personal-report-skill/weather/icons/rain_v01.crn", payloadText, StringComparison.Ordinal);
|
Assert.Contains("assets/personal-report-skill/weather/icons/rain_v01.crn", payloadText, StringComparison.Ordinal);
|
||||||
Assert.Contains("tempNormal_v01.crn", payloadText, StringComparison.Ordinal);
|
Assert.Contains("tempNormal_v01.crn", payloadText, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
@@ -3557,6 +3619,38 @@ public sealed class JiboWebSocketServiceTests
|
|||||||
Assert.False(session.Metadata.ContainsKey("pendingProactivityOffer"));
|
Assert.False(session.Metadata.ContainsKey("pendingProactivityOffer"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClientAsrSurpriseOffer_PersistsPendingOfferAndResolvesYesFollowUpWithTail()
|
||||||
|
{
|
||||||
|
var token = _store.IssueRobotToken("proactivity-device-a-tail");
|
||||||
|
|
||||||
|
await _service.HandleMessageAsync(new WebSocketMessageEnvelope
|
||||||
|
{
|
||||||
|
HostName = "neo-hub.jibo.com",
|
||||||
|
Path = "/listen",
|
||||||
|
Kind = "neo-hub-listen",
|
||||||
|
Token = token,
|
||||||
|
Text = """{"type":"CLIENT_ASR","transID":"trans-proactive-offer-tail","data":{"text":"surprise me"}}"""
|
||||||
|
});
|
||||||
|
|
||||||
|
var followUpReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope
|
||||||
|
{
|
||||||
|
HostName = "neo-hub.jibo.com",
|
||||||
|
Path = "/listen",
|
||||||
|
Kind = "neo-hub-listen",
|
||||||
|
Token = token,
|
||||||
|
Text = """{"type":"CLIENT_ASR","transID":"trans-proactive-offer-tail-yes","data":{"text":"yes I want to"}}"""
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(3, followUpReplies.Count);
|
||||||
|
using var followUpListenPayload = JsonDocument.Parse(followUpReplies[0].Text!);
|
||||||
|
Assert.Equal("proactive_pizza_fact", followUpListenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString());
|
||||||
|
|
||||||
|
var session = _store.FindSessionByToken(token);
|
||||||
|
Assert.NotNull(session);
|
||||||
|
Assert.False(session.Metadata.ContainsKey("pendingProactivityOffer"));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ClientAsrSurpriseOffer_PersistsPendingOfferAndResolvesNoFollowUp()
|
public async Task ClientAsrSurpriseOffer_PersistsPendingOfferAndResolvesNoFollowUp()
|
||||||
{
|
{
|
||||||
@@ -3617,6 +3711,38 @@ public sealed class JiboWebSocketServiceTests
|
|||||||
Assert.False(session.Metadata.ContainsKey("pendingProactivityOffer"));
|
Assert.False(session.Metadata.ContainsKey("pendingProactivityOffer"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClientAsrSurpriseOffer_PersistsPendingOfferAndResolvesNoFollowUpWithTail()
|
||||||
|
{
|
||||||
|
var token = _store.IssueRobotToken("proactivity-device-b-tail");
|
||||||
|
|
||||||
|
await _service.HandleMessageAsync(new WebSocketMessageEnvelope
|
||||||
|
{
|
||||||
|
HostName = "neo-hub.jibo.com",
|
||||||
|
Path = "/listen",
|
||||||
|
Kind = "neo-hub-listen",
|
||||||
|
Token = token,
|
||||||
|
Text = """{"type":"CLIENT_ASR","transID":"trans-proactive-offer-no-tail","data":{"text":"surprise me"}}"""
|
||||||
|
});
|
||||||
|
|
||||||
|
var followUpReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope
|
||||||
|
{
|
||||||
|
HostName = "neo-hub.jibo.com",
|
||||||
|
Path = "/listen",
|
||||||
|
Kind = "neo-hub-listen",
|
||||||
|
Token = token,
|
||||||
|
Text = """{"type":"CLIENT_ASR","transID":"trans-proactive-offer-no-tail-followup","data":{"text":"no I do not"}}"""
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(3, followUpReplies.Count);
|
||||||
|
using var followUpListenPayload = JsonDocument.Parse(followUpReplies[0].Text!);
|
||||||
|
Assert.Equal("proactive_offer_declined", followUpListenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString());
|
||||||
|
|
||||||
|
var session = _store.FindSessionByToken(token);
|
||||||
|
Assert.NotNull(session);
|
||||||
|
Assert.False(session.Metadata.ContainsKey("pendingProactivityOffer"));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TriggerPresence_WithIdentity_EmitsProactiveGreetingAndPersistsGreetingMetadata()
|
public async Task TriggerPresence_WithIdentity_EmitsProactiveGreetingAndPersistsGreetingMetadata()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user