From 4bc87f927bd2eab9b7365683f5363f27b5d72941 Mon Sep 17 00:00:00 2001 From: Jacob Dubin Date: Sun, 10 May 2026 21:22:25 -0500 Subject: [PATCH] Broaden yes no parsing for proactive follow ups --- .../Services/JiboInteractionService.cs | 289 ++++++++++++++++-- .../ResponsePlanToSocketMessagesMapper.cs | 4 +- .../WebSocketTurnFinalizationService.cs | 188 +++++++++++- .../News/NewsApiBriefingProvider.cs | 42 +++ .../WebSockets/JiboInteractionServiceTests.cs | 83 +++++ .../WebSockets/JiboWebSocketServiceTests.cs | 134 +++++++- 6 files changed, 712 insertions(+), 28 deletions(-) diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs index 25076cd..eee904d 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs @@ -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? categories, - int? headlineCount) + int? headlineCount, + IReadOnlyDictionary? providerDiagnostics = null) { + var speakableBriefing = NormalizeNewsSpeechText(spokenBriefing); var payload = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["skillId"] = "news", @@ -1042,7 +1075,7 @@ public sealed class JiboInteractionService( ["prompt_id"] = "NewsHeadline_AN_01", ["prompt_sub_category"] = "AN", ["esml"] = - $"{EscapeForEsml(spokenBriefing)}" + $"{EscapeForEsml(speakableBriefing)}" }; 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 preferredCategories) + IReadOnlyList 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 BuildNewsProviderDiagnostics( + string status, + IReadOnlyList preferredCategories, + int requestedHeadlineCount, + int? resolvedHeadlineCount = null) + { + var diagnostics = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["news_provider_status"] = status, + ["news_provider_requested_headlines"] = requestedHeadlineCount, + ["news_provider_preferred_categories"] = preferredCategories.Count > 0 + ? preferredCategories.ToArray() + : Array.Empty() + }; + + if (resolvedHeadlineCount is not null) + { + diagnostics["news_provider_resolved_headlines"] = resolvedHeadlineCount.Value; + } + + return diagnostics; } private static string BuildNewsLeadIn(string? sourceName, IReadOnlyList 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 ResolvePreferredNewsCategories(TurnContext turn, string transcript) { var categories = new List(); @@ -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(?\d{1,2}|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)(?:[:\s,-]+(?\d{2}|[a-z\-]+(?:\s+[a-z\-]+)?))?\s*(?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 YesNoAffirmativeLeadTokens = new(StringComparer.Ordinal) + { + "yes", + "yeah", + "yep", + "yup", + "sure", + "ok", + "okay", + "absolutely", + "affirmative", + "definitely", + "certainly", + "indeed" + }; + + private static readonly HashSet YesNoNegativeLeadTokens = new(StringComparer.Ordinal) + { + "no", + "nope", + "nah", + "negative", + "never" + }; + + private static readonly HashSet 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 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 = diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ResponsePlanToSocketMessagesMapper.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ResponsePlanToSocketMessagesMapper.cs index 98b39b1..e47ad5c 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ResponsePlanToSocketMessagesMapper.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ResponsePlanToSocketMessagesMapper.cs @@ -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", diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/WebSocketTurnFinalizationService.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/WebSocketTurnFinalizationService.cs index e92ffc0..0cf1838 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/WebSocketTurnFinalizationService.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/WebSocketTurnFinalizationService.cs @@ -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 + { + ["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 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 YesNoAffirmativeLeadTokens = new(StringComparer.Ordinal) + { + "yes", + "yeah", + "yep", + "yup", + "sure", + "ok", + "okay", + "absolutely", + "affirmative", + "definitely", + "certainly", + "indeed" + }; + + private static readonly HashSet YesNoNegativeLeadTokens = new(StringComparer.Ordinal) + { + "no", + "nope", + "nah", + "negative", + "never" + }; + + private static readonly HashSet 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 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(); } diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/News/NewsApiBriefingProvider.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/News/NewsApiBriefingProvider.cs index 2d2ec32..dfdc979 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/News/NewsApiBriefingProvider.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/News/NewsApiBriefingProvider.cs @@ -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) diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs index 2b86951..1ecddb0 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs @@ -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 + { + ["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 + { + ["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 + { + ["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 + { + ["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] diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs index ade8d55..b58323b 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs @@ -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() {