Broaden yes no parsing for proactive follow ups

This commit is contained in:
Jacob Dubin
2026-05-10 21:22:25 -05:00
parent a94b7ec493
commit 4bc87f927b
6 changed files with 712 additions and 28 deletions

View File

@@ -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 =

View File

@@ -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",

View File

@@ -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();
} }

View File

@@ -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)

View File

@@ -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]

View File

@@ -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()
{ {