Broaden yes no parsing for proactive follow ups
This commit is contained in:
@@ -1000,22 +1000,49 @@ public sealed class JiboInteractionService(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var preferredCategories = ResolvePreferredNewsCategories(turn, transcript);
|
||||
var requestedHeadlineCount = MaxNewsHeadlines;
|
||||
if (newsBriefingProvider is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = await newsBriefingProvider.GetBriefingAsync(
|
||||
new NewsBriefingRequest(preferredCategories, MaxNewsHeadlines),
|
||||
new NewsBriefingRequest(preferredCategories, requestedHeadlineCount),
|
||||
cancellationToken);
|
||||
|
||||
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
|
||||
{
|
||||
// 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,
|
||||
sourceName: null,
|
||||
preferredCategories.Count > 0 ? preferredCategories : null,
|
||||
headlineCount: null);
|
||||
headlineCount: null,
|
||||
providerDiagnostics: BuildNewsProviderDiagnostics(
|
||||
"provider_unavailable",
|
||||
preferredCategories,
|
||||
requestedHeadlineCount));
|
||||
}
|
||||
|
||||
private static JiboInteractionDecision BuildNewsDecision(
|
||||
string spokenBriefing,
|
||||
string? sourceName,
|
||||
IReadOnlyList<string>? categories,
|
||||
int? headlineCount)
|
||||
int? headlineCount,
|
||||
IReadOnlyDictionary<string, object?>? providerDiagnostics = null)
|
||||
{
|
||||
var speakableBriefing = NormalizeNewsSpeechText(spokenBriefing);
|
||||
var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["skillId"] = "news",
|
||||
@@ -1042,7 +1075,7 @@ public sealed class JiboInteractionService(
|
||||
["prompt_id"] = "NewsHeadline_AN_01",
|
||||
["prompt_sub_category"] = "AN",
|
||||
["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))
|
||||
@@ -1060,12 +1093,21 @@ public sealed class JiboInteractionService(
|
||||
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);
|
||||
}
|
||||
|
||||
private static JiboInteractionDecision BuildProviderNewsDecision(
|
||||
NewsBriefingSnapshot snapshot,
|
||||
IReadOnlyList<string> preferredCategories)
|
||||
IReadOnlyList<string> preferredCategories,
|
||||
int requestedHeadlineCount)
|
||||
{
|
||||
var headlines = snapshot.Headlines
|
||||
.Where(headline => !string.IsNullOrWhiteSpace(headline.Title))
|
||||
@@ -1077,7 +1119,12 @@ public sealed class JiboInteractionService(
|
||||
"I couldn't load fresh headlines right now.",
|
||||
snapshot.SourceName,
|
||||
preferredCategories,
|
||||
headlineCount: 0);
|
||||
headlineCount: 0,
|
||||
providerDiagnostics: BuildNewsProviderDiagnostics(
|
||||
"provider_empty",
|
||||
preferredCategories,
|
||||
requestedHeadlineCount,
|
||||
0));
|
||||
}
|
||||
|
||||
var leadIn = BuildNewsLeadIn(snapshot.SourceName, preferredCategories);
|
||||
@@ -1087,7 +1134,35 @@ public sealed class JiboInteractionService(
|
||||
spokenBriefing,
|
||||
snapshot.SourceName,
|
||||
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)
|
||||
@@ -1104,6 +1179,21 @@ public sealed class JiboInteractionService(
|
||||
: $"{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)
|
||||
{
|
||||
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))
|
||||
{
|
||||
return "memory_set_name";
|
||||
@@ -1779,14 +1882,6 @@ public sealed class JiboInteractionService(
|
||||
return "hello";
|
||||
}
|
||||
|
||||
switch (isYesNoTurn)
|
||||
{
|
||||
case true when IsAffirmativeReply(loweredTranscript):
|
||||
return "yes";
|
||||
case true when IsNegativeReply(loweredTranscript):
|
||||
return "no";
|
||||
}
|
||||
|
||||
if (IsTimeRequest(loweredTranscript))
|
||||
{
|
||||
return "time";
|
||||
@@ -2252,15 +2347,99 @@ public sealed class JiboInteractionService(
|
||||
private static bool IsAffirmativeReply(string 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" ||
|
||||
MatchesAny(normalized, "uh huh", "sounds good");
|
||||
return TryClassifyYesNoReply(normalized) == YesNoReply.Affirmative;
|
||||
}
|
||||
|
||||
private static bool IsNegativeReply(string loweredTranscript)
|
||||
{
|
||||
var normalized = NormalizeCommandPhrase(loweredTranscript);
|
||||
return normalized is "no" or "nope" or "nah" or "not now" or "no thanks" or "not today" ||
|
||||
MatchesAny(normalized, "no thank you", "maybe later");
|
||||
return TryClassifyYesNoReply(normalized) == YesNoReply.Negative;
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -3983,6 +4162,13 @@ public sealed class JiboInteractionService(
|
||||
public static WeatherDateEntity None { get; } = new(null, 0, null);
|
||||
}
|
||||
|
||||
private enum YesNoReply
|
||||
{
|
||||
None = 0,
|
||||
Affirmative = 1,
|
||||
Negative = 2
|
||||
}
|
||||
|
||||
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",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
||||
@@ -4062,6 +4248,69 @@ public sealed class JiboInteractionService(
|
||||
"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:
|
||||
// userLikesThing / userDislikesThing / doesUserLikeThing / doesUserDislikeThing.
|
||||
private static readonly (string Prefix, PersonalAffinity Affinity)[] PegasusUserAffinitySetPrefixes =
|
||||
|
||||
@@ -1198,7 +1198,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
{
|
||||
id = "hiNumLabel",
|
||||
type = "Label",
|
||||
text = $"{high.Value}\u00B0",
|
||||
text = high.Value.ToString(),
|
||||
style = new
|
||||
{
|
||||
fontSize = "160",
|
||||
@@ -1230,7 +1230,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
{
|
||||
id = "loNumLabel",
|
||||
type = "Label",
|
||||
text = $"{low.Value}\u00B0",
|
||||
text = low.Value.ToString(),
|
||||
style = new
|
||||
{
|
||||
fontSize = "160",
|
||||
|
||||
@@ -682,6 +682,29 @@ public sealed partial class WebSocketTurnFinalizationService(
|
||||
["payload"] = invokedSkillAction.Payload
|
||||
}),
|
||||
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
|
||||
@@ -1056,13 +1079,13 @@ public sealed partial class WebSocketTurnFinalizationService(
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1188,6 +1211,97 @@ public sealed partial class WebSocketTurnFinalizationService(
|
||||
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(
|
||||
CloudSession session,
|
||||
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]")]
|
||||
private static partial Regex TranscriptNormalizationRegex();
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ public sealed class NewsApiBriefingProvider(
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.ApiKey))
|
||||
{
|
||||
logger.LogWarning("NewsAPI provider disabled because no API key is configured.");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -33,8 +34,18 @@ public sealed class NewsApiBriefingProvider(
|
||||
|
||||
var requestedHeadlineCount = Math.Clamp(request.MaxHeadlines, 1, MaxHeadlines);
|
||||
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))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"NewsAPI cache hit. CacheKey={CacheKey} HasSnapshot={HasSnapshot} HeadlineCount={HeadlineCount}",
|
||||
cacheKey,
|
||||
cachedBriefing is not null,
|
||||
cachedBriefing?.Headlines.Count ?? 0);
|
||||
return cachedBriefing;
|
||||
}
|
||||
|
||||
@@ -47,14 +58,32 @@ public sealed class NewsApiBriefingProvider(
|
||||
using var response = await httpClient.GetAsync(uri, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"NewsAPI request failed for category {Category}. StatusCode={StatusCode} Reason={ReasonPhrase}",
|
||||
category,
|
||||
(int)response.StatusCode,
|
||||
response.ReasonPhrase);
|
||||
continue;
|
||||
}
|
||||
|
||||
using var stream = await response.Content.ReadAsStreamAsync(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) ||
|
||||
articles.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
logger.LogWarning("NewsAPI response missing articles array for category {Category}.", category);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -78,6 +107,10 @@ public sealed class NewsApiBriefingProvider(
|
||||
{
|
||||
var snapshot = new NewsBriefingSnapshot(headlines, "NewsAPI");
|
||||
SetCachedValue(briefingCache, cacheKey, snapshot, options.CacheTtlSeconds);
|
||||
logger.LogInformation(
|
||||
"NewsAPI request succeeded. Categories={Categories} HeadlineCount={HeadlineCount}",
|
||||
string.Join(",", categories),
|
||||
headlines.Count);
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
@@ -86,11 +119,20 @@ public sealed class NewsApiBriefingProvider(
|
||||
if (headlines.Count == 0)
|
||||
{
|
||||
SetCachedValue(briefingCache, cacheKey, null, options.FailureCacheTtlSeconds);
|
||||
logger.LogWarning(
|
||||
"NewsAPI returned no usable headlines. Categories={Categories} RequestedHeadlineCount={RequestedHeadlineCount}",
|
||||
string.Join(",", categories),
|
||||
requestedHeadlineCount);
|
||||
return null;
|
||||
}
|
||||
|
||||
var populatedSnapshot = new NewsBriefingSnapshot(headlines, "NewsAPI");
|
||||
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;
|
||||
}
|
||||
catch (Exception exception)
|
||||
|
||||
@@ -1048,6 +1048,25 @@ public sealed class JiboInteractionServiceTests
|
||||
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]
|
||||
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);
|
||||
}
|
||||
|
||||
[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]
|
||||
public async Task BuildDecisionAsync_MakePizza_UsesOriginalMimStylePayload()
|
||||
{
|
||||
@@ -1968,6 +2006,46 @@ public sealed class JiboInteractionServiceTests
|
||||
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]
|
||||
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["cloudSkill"]);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -2697,6 +2776,9 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Contains("news-stinger", decision.SkillPayload["esml"]?.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal("NewsAPI", decision.SkillPayload["news_source"]);
|
||||
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.NotNull(provider.LastRequest);
|
||||
Assert.Equal(3, provider.LastRequest!.MaxHeadlines);
|
||||
@@ -2724,6 +2806,7 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal("news", decision.IntentName);
|
||||
Assert.NotNull(provider.LastRequest);
|
||||
Assert.Contains("technology", provider.LastRequest!.PreferredCategories, StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Contains("artificial intelligence", decision.SkillPayload?["esml"]?.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[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());
|
||||
}
|
||||
|
||||
[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]
|
||||
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());
|
||||
}
|
||||
|
||||
[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]
|
||||
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"}}"""
|
||||
});
|
||||
|
||||
Assert.Equal(3, replies.Count);
|
||||
Assert.True(replies.Count >= 3);
|
||||
Assert.Equal("LISTEN", ReadReplyType(replies[0]));
|
||||
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
|
||||
.GetProperty("data")
|
||||
.GetProperty("action")
|
||||
@@ -2173,7 +2235,7 @@ public sealed class JiboWebSocketServiceTests
|
||||
"weatherTempView",
|
||||
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("tempNormal_v01.crn", payloadText, StringComparison.Ordinal);
|
||||
}
|
||||
@@ -3557,6 +3619,38 @@ public sealed class JiboWebSocketServiceTests
|
||||
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]
|
||||
public async Task ClientAsrSurpriseOffer_PersistsPendingOfferAndResolvesNoFollowUp()
|
||||
{
|
||||
@@ -3617,6 +3711,38 @@ public sealed class JiboWebSocketServiceTests
|
||||
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]
|
||||
public async Task TriggerPresence_WithIdentity_EmitsProactiveGreetingAndPersistsGreetingMetadata()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user