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 270b806..42a19b1 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 @@ -595,9 +595,19 @@ public sealed class JiboInteractionService( { var referenceLocalTime = TryResolveReferenceLocalTime(turn); var normalizedTranscript = NormalizeCommandPhrase(transcript); + var locationQuery = TryResolveWeatherLocationQuery(transcript); var weatherDate = ResolveWeatherDateEntity(turn, transcript, normalizedTranscript, referenceLocalTime); var isRangeForecastRequest = IsRangeForecastRequest(normalizedTranscript); - if (ShouldDefaultForecastToTomorrow(normalizedTranscript, weatherDate, isRangeForecastRequest)) + var isOpenEndedForecastRequest = IsOpenEndedForecastRequest( + normalizedTranscript, + weatherDate, + isRangeForecastRequest, + locationQuery); + if (ShouldDefaultForecastToTomorrow( + normalizedTranscript, + weatherDate, + isRangeForecastRequest, + isOpenEndedForecastRequest)) { weatherDate = new WeatherDateEntity("tomorrow", 1, "Tomorrow"); } @@ -609,7 +619,6 @@ public sealed class JiboInteractionService( "I can check weather once my weather service is connected."); } - var locationQuery = TryResolveWeatherLocationQuery(transcript); var weatherCoordinates = string.IsNullOrWhiteSpace(locationQuery) ? TryResolveWeatherCoordinates(turn) : null; @@ -617,7 +626,7 @@ public sealed class JiboInteractionService( var isNextWeekForecast = IsNextWeekForecastRequest(normalizedTranscript, isRangeForecastRequest); var isThisWeekForecast = IsThisWeekForecastRequest(normalizedTranscript, isRangeForecastRequest); - if (isNextWeekForecast || isThisWeekForecast) + if (isNextWeekForecast || isThisWeekForecast || isOpenEndedForecastRequest) { var rangeStartOffset = 1; var rangeEndOffset = isThisWeekForecast @@ -920,6 +929,32 @@ public sealed class JiboInteractionService( !normalizedTranscript.Contains("weekend", StringComparison.Ordinal); } + private static bool IsOpenEndedForecastRequest( + string normalizedTranscript, + WeatherDateEntity weatherDate, + bool isRangeForecastRequest, + string? locationQuery) + { + if (string.IsNullOrWhiteSpace(normalizedTranscript) || + !string.IsNullOrWhiteSpace(locationQuery) || + isRangeForecastRequest || + weatherDate.ForecastDayOffset > 0 || + !normalizedTranscript.Contains("forecast", StringComparison.Ordinal)) + { + return false; + } + + return !MatchesAny( + normalizedTranscript, + "today", + "today s", + "today's", + "tonight", + "right now", + "current weather", + "currently"); + } + private static int ResolveThisWeekForecastEndOffset(DateTimeOffset? referenceLocalTime) { var resolvedReference = referenceLocalTime ?? DateTimeOffset.UtcNow; @@ -931,9 +966,11 @@ public sealed class JiboInteractionService( private static bool ShouldDefaultForecastToTomorrow( string normalizedTranscript, WeatherDateEntity weatherDate, - bool isRangeForecastRequest) + bool isRangeForecastRequest, + bool isOpenEndedForecastRequest) { if (weatherDate.ForecastDayOffset > 0 || + isOpenEndedForecastRequest || isRangeForecastRequest || string.IsNullOrWhiteSpace(normalizedTranscript) || !normalizedTranscript.Contains("forecast", StringComparison.Ordinal)) @@ -1643,6 +1680,7 @@ public sealed class JiboInteractionService( }; } + var yesNoRule = ReadPrimaryYesNoRule(clientRules, listenRules); if (!string.IsNullOrWhiteSpace(pendingProactivityOffer) && string.Equals(pendingProactivityOffer, "pizza_fact", StringComparison.OrdinalIgnoreCase)) { @@ -1659,14 +1697,15 @@ public sealed class JiboInteractionService( if (isYesNoTurn) { - if (IsAffirmativeReply(loweredTranscript)) + var yesNoReply = TryClassifyYesNoReply(NormalizeCommandPhrase(loweredTranscript)); + if (yesNoReply == YesNoReply.Affirmative) { - return "yes"; + return ResolveAffirmativeYesNoIntent(yesNoRule); } - if (IsNegativeReply(loweredTranscript)) + if (yesNoReply == YesNoReply.Negative) { - return "no"; + return ResolveNegativeYesNoIntent(yesNoRule); } } @@ -2369,15 +2408,55 @@ public sealed class JiboInteractionService( return ReadRules(turn, "listenRules") .Concat(ReadRules(turn, "clientRules")) .Concat(ReadRules(turn, "listenAsrHints")) - .Any(static rule => - string.Equals(rule, "$YESNO", StringComparison.OrdinalIgnoreCase) || - string.Equals(rule, "clock/alarm_timer_change", StringComparison.OrdinalIgnoreCase) || - string.Equals(rule, "clock/alarm_timer_none_set", StringComparison.OrdinalIgnoreCase) || - string.Equals(rule, "create/is_it_a_keeper", StringComparison.OrdinalIgnoreCase) || - string.Equals(rule, "shared/yes_no", StringComparison.OrdinalIgnoreCase) || - string.Equals(rule, "settings/download_now_later", StringComparison.OrdinalIgnoreCase) || - string.Equals(rule, "surprises-date/offer_date_fact", StringComparison.OrdinalIgnoreCase) || - string.Equals(rule, "surprises-ota/want_to_download_now", StringComparison.OrdinalIgnoreCase)); + .Any(IsYesNoRule); + } + + private static string? ReadPrimaryYesNoRule( + IReadOnlyList clientRules, + IReadOnlyList listenRules) + { + return listenRules + .Concat(clientRules) + .FirstOrDefault(IsConstrainedYesNoRule); + } + + private static bool IsYesNoRule(string rule) + { + return string.Equals(rule, "$YESNO", StringComparison.OrdinalIgnoreCase) || + IsConstrainedYesNoRule(rule); + } + + private static bool IsConstrainedYesNoRule(string rule) + { + return string.Equals(rule, "clock/alarm_timer_change", StringComparison.OrdinalIgnoreCase) || + string.Equals(rule, "clock/alarm_timer_none_set", StringComparison.OrdinalIgnoreCase) || + string.Equals(rule, "create/is_it_a_keeper", StringComparison.OrdinalIgnoreCase) || + string.Equals(rule, "settings/download_now_later", StringComparison.OrdinalIgnoreCase) || + string.Equals(rule, "shared/yes_no", StringComparison.OrdinalIgnoreCase) || + string.Equals(rule, "surprises-date/offer_date_fact", StringComparison.OrdinalIgnoreCase) || + string.Equals(rule, "surprises-ota/want_to_download_now", StringComparison.OrdinalIgnoreCase) || + string.Equals(rule, "word-of-the-day/surprise", StringComparison.OrdinalIgnoreCase); + } + + private static string ResolveAffirmativeYesNoIntent(string? yesNoRule) + { + if (string.Equals(yesNoRule, "word-of-the-day/surprise", StringComparison.OrdinalIgnoreCase)) + { + return "word_of_the_day"; + } + + if (string.Equals(yesNoRule, "surprises-date/offer_date_fact", StringComparison.OrdinalIgnoreCase)) + { + return "surprise"; + } + + return "yes"; + } + + private static string ResolveNegativeYesNoIntent(string? yesNoRule) + { + _ = yesNoRule; + return "no"; } private static string? FindClosestHint(string normalizedTranscript, IReadOnlyList hints) @@ -2642,7 +2721,7 @@ public sealed class JiboInteractionService( } } - return YesNoReply.None; + return TryClassifyTrailingYesNoReply(tokens); } private static bool TryTrimLeadingAcknowledgement(string normalizedTranscript, out string trimmedTranscript) @@ -2666,6 +2745,70 @@ public sealed class JiboInteractionService( return false; } + private static YesNoReply TryClassifyTrailingYesNoReply(IReadOnlyList tokens) + { + var selectedReply = YesNoReply.None; + var selectedIndex = -1; + + void Consider(YesNoReply candidateReply, int candidateIndex) + { + if (candidateIndex < 0 || candidateIndex < selectedIndex) + { + return; + } + + selectedReply = candidateReply; + selectedIndex = candidateIndex; + } + + for (var index = 0; index < tokens.Count; index += 1) + { + var token = tokens[index]; + if (YesNoNegativeLeadTokens.Contains(token)) + { + Consider(YesNoReply.Negative, index); + continue; + } + + if (YesNoAffirmativeLeadTokens.Contains(token)) + { + Consider(YesNoReply.Affirmative, index); + } + } + + for (var index = 0; index + 1 < tokens.Count; index += 1) + { + var phrase = $"{tokens[index]} {tokens[index + 1]}"; + if (YesNoNegativeLeadPhrases.Contains(phrase)) + { + Consider(YesNoReply.Negative, index + 1); + continue; + } + + if (YesNoAffirmativeLeadPhrases.Contains(phrase)) + { + Consider(YesNoReply.Affirmative, index + 1); + } + } + + for (var index = 0; index + 2 < tokens.Count; index += 1) + { + var phrase = $"{tokens[index]} {tokens[index + 1]} {tokens[index + 2]}"; + if (YesNoNegativeLeadPhrases.Contains(phrase)) + { + Consider(YesNoReply.Negative, index + 2); + continue; + } + + if (YesNoAffirmativeLeadPhrases.Contains(phrase)) + { + Consider(YesNoReply.Affirmative, index + 2); + } + } + + return selectedReply; + } + private static bool IsTimeRequest(string loweredTranscript) { var normalized = NormalizeCommandPhrase(loweredTranscript); @@ -4568,7 +4711,9 @@ public sealed class JiboInteractionService( "well", "so", "actually", - "honestly" + "honestly", + "thanks", + "thank you" ]; private static readonly HashSet YesNoAffirmativeLeadTokens = new(StringComparer.Ordinal) 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 184c624..606cb1a 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 @@ -1217,7 +1217,8 @@ public sealed partial class WebSocketTurnFinalizationService( string.Equals(rule, "shared/yes_no", StringComparison.OrdinalIgnoreCase) || string.Equals(rule, "settings/download_now_later", StringComparison.OrdinalIgnoreCase) || string.Equals(rule, "surprises-date/offer_date_fact", StringComparison.OrdinalIgnoreCase) || - string.Equals(rule, "surprises-ota/want_to_download_now", StringComparison.OrdinalIgnoreCase); + string.Equals(rule, "surprises-ota/want_to_download_now", StringComparison.OrdinalIgnoreCase) || + string.Equals(rule, "word-of-the-day/surprise", StringComparison.OrdinalIgnoreCase); } private static bool IsYesNoReplyTranscript(string normalizedTranscript) @@ -1287,7 +1288,7 @@ public sealed partial class WebSocketTurnFinalizationService( } } - return YesNoReply.None; + return TryClassifyTrailingYesNoReply(tokens); } private static bool TryTrimLeadingAcknowledgement(string normalizedTranscript, out string trimmedTranscript) @@ -1311,6 +1312,70 @@ public sealed partial class WebSocketTurnFinalizationService( return false; } + private static YesNoReply TryClassifyTrailingYesNoReply(IReadOnlyList tokens) + { + var selectedReply = YesNoReply.None; + var selectedIndex = -1; + + void Consider(YesNoReply candidateReply, int candidateIndex) + { + if (candidateIndex < 0 || candidateIndex < selectedIndex) + { + return; + } + + selectedReply = candidateReply; + selectedIndex = candidateIndex; + } + + for (var index = 0; index < tokens.Count; index += 1) + { + var token = tokens[index]; + if (YesNoNegativeLeadTokens.Contains(token)) + { + Consider(YesNoReply.Negative, index); + continue; + } + + if (YesNoAffirmativeLeadTokens.Contains(token)) + { + Consider(YesNoReply.Affirmative, index); + } + } + + for (var index = 0; index + 1 < tokens.Count; index += 1) + { + var phrase = $"{tokens[index]} {tokens[index + 1]}"; + if (YesNoNegativeLeadPhrases.Contains(phrase)) + { + Consider(YesNoReply.Negative, index + 1); + continue; + } + + if (YesNoAffirmativeLeadPhrases.Contains(phrase)) + { + Consider(YesNoReply.Affirmative, index + 1); + } + } + + for (var index = 0; index + 2 < tokens.Count; index += 1) + { + var phrase = $"{tokens[index]} {tokens[index + 1]} {tokens[index + 2]}"; + if (YesNoNegativeLeadPhrases.Contains(phrase)) + { + Consider(YesNoReply.Negative, index + 2); + continue; + } + + if (YesNoAffirmativeLeadPhrases.Contains(phrase)) + { + Consider(YesNoReply.Affirmative, index + 2); + } + } + + return selectedReply; + } + private async Task ApplyContextUpdatesAsync( CloudSession session, IDictionary contextUpdates, @@ -1910,7 +1975,9 @@ public sealed partial class WebSocketTurnFinalizationService( "well", "so", "actually", - "honestly" + "honestly", + "thanks", + "thank you" ]; private static readonly HashSet YesNoAffirmativeLeadTokens = new(StringComparer.Ordinal) 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 9551ee6..ef51814 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 @@ -79,7 +79,7 @@ public sealed class NewsApiBriefingProvider( foreach (var category in categories) { var uri = BuildTopHeadlinesUri(category, requestedHeadlineCount); - using var response = await httpClient.GetAsync(uri, cancellationToken); + using var response = await SendGetAsync(uri, cancellationToken); if (!response.IsSuccessStatusCode) { var responseBody = await TryReadResponseBodySnippetAsync(response, cancellationToken); @@ -172,7 +172,7 @@ public sealed class NewsApiBriefingProvider( string.Join(",", categories)); var broadUri = BuildTopHeadlinesUri(category: null, requestedHeadlineCount); - using var broadResponse = await httpClient.GetAsync(broadUri, cancellationToken); + using var broadResponse = await SendGetAsync(broadUri, cancellationToken); if (broadResponse.IsSuccessStatusCode) { using var broadStream = await broadResponse.Content.ReadAsStreamAsync(cancellationToken); @@ -239,7 +239,7 @@ public sealed class NewsApiBriefingProvider( options.FallbackQuery); var everythingUri = BuildEverythingUri(requestedHeadlineCount); - using var everythingResponse = await httpClient.GetAsync(everythingUri, cancellationToken); + using var everythingResponse = await SendGetAsync(everythingUri, cancellationToken); if (everythingResponse.IsSuccessStatusCode) { using var everythingStream = await everythingResponse.Content.ReadAsStreamAsync(cancellationToken); @@ -345,6 +345,20 @@ public sealed class NewsApiBriefingProvider( } } + private async Task SendGetAsync(Uri uri, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Headers.TryAddWithoutValidation("User-Agent", ResolveUserAgent()); + return await httpClient.SendAsync(request, cancellationToken); + } + + private string ResolveUserAgent() + { + return string.IsNullOrWhiteSpace(options.UserAgent) + ? DefaultUserAgent + : options.UserAgent.Trim(); + } + private IEnumerable ResolveCategories(IReadOnlyList preferredCategories) { var requested = preferredCategories @@ -557,6 +571,7 @@ public sealed class NewsApiBriefingProvider( private const int MaxHeadlines = 5; private const int MaxCategories = 2; + private const string DefaultUserAgent = "OpenJiboCloud/1.0"; private sealed record ApiError(string? Code, string? Message); private sealed record CacheEntry(T Value, DateTimeOffset ExpiresUtc); diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/News/NewsApiOptions.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/News/NewsApiOptions.cs index 66fccbc..de70049 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/News/NewsApiOptions.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/News/NewsApiOptions.cs @@ -6,6 +6,8 @@ public sealed class NewsApiOptions public string? ApiKey { get; set; } + public string UserAgent { get; set; } = "OpenJiboCloud/1.0"; + public string Country { get; set; } = "us"; public string Language { get; set; } = "en"; diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Weather/OpenWeatherReportProvider.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Weather/OpenWeatherReportProvider.cs index 47128b1..cc0bb75 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Weather/OpenWeatherReportProvider.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Weather/OpenWeatherReportProvider.cs @@ -173,6 +173,26 @@ public sealed class OpenWeatherReportProvider( var temperature = TryReadInt(main, "temp"); var high = TryReadInt(main, "temp_max"); var low = TryReadInt(main, "temp_min"); + if (ShouldEnrichCurrentDayHighLow(temperature, high, low)) + { + try + { + var enrichedBand = await TryResolveCurrentDayHighLowFromForecastAsync( + location, + useCelsius, + cancellationToken); + if (enrichedBand is not null) + { + high = enrichedBand.Value.High ?? high; + low = enrichedBand.Value.Low ?? low; + } + } + catch (Exception exception) when (!cancellationToken.IsCancellationRequested) + { + logger.LogDebug(exception, "OpenWeather forecast enrichment failed for current-day Hi/Lo."); + } + } + if (temperature is null && high is null && low is null) { return null; @@ -189,6 +209,70 @@ public sealed class OpenWeatherReportProvider( useCelsius); } + private async Task<(int? High, int? Low)?> TryResolveCurrentDayHighLowFromForecastAsync( + LocationPoint location, + bool useCelsius, + CancellationToken cancellationToken) + { + var forecastUri = BuildRequestUri( + "/data/2.5/forecast", + ("lat", location.Latitude.ToString(CultureInfo.InvariantCulture)), + ("lon", location.Longitude.ToString(CultureInfo.InvariantCulture)), + ("units", useCelsius ? "metric" : "imperial"), + ("appid", options.ApiKey!)); + using var response = await httpClient.GetAsync(forecastUri, cancellationToken); + if (!response.IsSuccessStatusCode) + { + return null; + } + + using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + var root = document.RootElement; + if (!root.TryGetProperty("list", out var list) || list.ValueKind != JsonValueKind.Array) + { + return null; + } + + var offset = TryReadForecastOffset(root); + var targetDate = DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(offset).DateTime); + var highs = new List(); + var lows = new List(); + foreach (var item in list.EnumerateArray()) + { + if (!TryReadLong(item, "dt", out var unixSeconds)) + { + continue; + } + + var localTimestamp = DateTimeOffset.FromUnixTimeSeconds(unixSeconds).ToOffset(offset); + if (DateOnly.FromDateTime(localTimestamp.DateTime) != targetDate || + !item.TryGetProperty("main", out var main)) + { + continue; + } + + var high = TryReadInt(main, "temp_max"); + if (high is not null) + { + highs.Add(high.Value); + } + + var low = TryReadInt(main, "temp_min"); + if (low is not null) + { + lows.Add(low.Value); + } + } + + if (highs.Count == 0 && lows.Count == 0) + { + return null; + } + + return (highs.Count == 0 ? null : highs.Max(), lows.Count == 0 ? null : lows.Min()); + } + private async Task GetForecastForDayOffsetAsync( LocationPoint location, bool useCelsius, @@ -408,6 +492,21 @@ public sealed class OpenWeatherReportProvider( return null; } + private static bool ShouldEnrichCurrentDayHighLow(int? temperature, int? high, int? low) + { + if (high is null || low is null) + { + return true; + } + + if (high.Value != low.Value) + { + return false; + } + + return temperature is null || high.Value == temperature.Value; + } + private static string BuildWeatherCacheKey(LocationPoint location, bool useCelsius, int forecastDayOffset) { return string.Create( diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/ProviderCachingTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/ProviderCachingTests.cs index 9cd0b3b..19dfb04 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/ProviderCachingTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/ProviderCachingTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Linq; using System.Text; using Jibo.Cloud.Application.Abstractions; using Jibo.Cloud.Infrastructure.News; @@ -43,13 +44,65 @@ public sealed class ProviderCachingTests Assert.NotNull(second); Assert.Equal(1, handler.GetCallCount("/geo/1.0/direct")); Assert.Equal(1, handler.GetCallCount("/data/2.5/weather")); + Assert.Equal(0, handler.GetCallCount("/data/2.5/forecast")); + } + + [Fact] + public async Task OpenWeatherReportProvider_EnrichesCurrentHiLoFromForecast_WhenCurrentBandIsFlat() + { + var utcStart = DateTimeOffset.UtcNow.UtcDateTime.Date; + var forecastWindowStart = new DateTimeOffset(utcStart, TimeSpan.Zero).ToUnixTimeSeconds(); + var forecastWindowMid = new DateTimeOffset(utcStart.AddHours(3), TimeSpan.Zero).ToUnixTimeSeconds(); + var forecastWindowLate = new DateTimeOffset(utcStart.AddHours(6), TimeSpan.Zero).ToUnixTimeSeconds(); + + var handler = new CountingHttpMessageHandler(message => + { + var path = message.RequestUri?.AbsolutePath ?? string.Empty; + return path switch + { + "/geo/1.0/direct" => JsonResponse( + """[{"name":"Lone Jack","country":"US","lat":38.8708,"lon":-94.1733}]"""), + "/data/2.5/weather" => JsonResponse( + """{"name":"Lone Jack","weather":[{"main":"Clouds","description":"overcast clouds"}],"main":{"temp":77.0,"temp_max":77.0,"temp_min":77.0}}"""), + "/data/2.5/forecast" => JsonResponse( + $"{{\"city\":{{\"timezone\":0}},\"list\":[{{\"dt\":{forecastWindowStart},\"main\":{{\"temp\":76.0,\"temp_max\":81.0,\"temp_min\":70.0}}}},{{\"dt\":{forecastWindowMid},\"main\":{{\"temp\":80.0,\"temp_max\":84.0,\"temp_min\":69.0}}}},{{\"dt\":{forecastWindowLate},\"main\":{{\"temp\":78.0,\"temp_max\":79.0,\"temp_min\":67.0}}}}]}}"), + _ => new HttpResponseMessage(HttpStatusCode.NotFound) + }; + }); + var provider = new OpenWeatherReportProvider( + new HttpClient(handler), + new OpenWeatherOptions + { + ApiKey = "test-key", + CurrentCacheTtlSeconds = 300, + ForecastCacheTtlSeconds = 300, + GeocodeCacheTtlSeconds = 300, + FailureCacheTtlSeconds = 30 + }, + NullLogger.Instance); + + var report = await provider.GetReportAsync(new WeatherReportRequest("Lone Jack,US", null, null, false, false, 0)); + + Assert.NotNull(report); + Assert.Equal(77, report!.Temperature); + Assert.Equal(84, report.HighTemperature); + Assert.Equal(67, report.LowTemperature); + Assert.Equal(1, handler.GetCallCount("/data/2.5/weather")); + Assert.Equal(1, handler.GetCallCount("/data/2.5/forecast")); } [Fact] public async Task NewsApiBriefingProvider_ReusesCachedHeadlinesForIdenticalRequests() { + var missingUserAgentRequestCount = 0; var handler = new CountingHttpMessageHandler(message => { + if (!message.Headers.TryGetValues("User-Agent", out var userAgents) || + !userAgents.Any()) + { + missingUserAgentRequestCount += 1; + } + var path = message.RequestUri?.AbsolutePath ?? string.Empty; return path switch { @@ -75,6 +128,7 @@ public sealed class ProviderCachingTests Assert.NotNull(first); Assert.NotNull(second); Assert.Equal(1, handler.GetCallCount("/v2/top-headlines")); + Assert.Equal(0, missingUserAgentRequestCount); } [Fact] diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs index 7777b1b..939b8f4 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs @@ -1588,7 +1588,7 @@ public sealed class JiboInteractionServiceTests } [Fact] - public async Task BuildDecisionAsync_ForecastWithoutDate_WithProvider_DefaultsToTomorrow() + public async Task BuildDecisionAsync_ForecastWithoutDate_WithProvider_ReturnsFiveDaySummary() { var provider = new CapturingWeatherReportProvider { @@ -1599,14 +1599,21 @@ public sealed class JiboInteractionServiceTests var decision = await service.BuildDecisionAsync(new TurnContext { RawTranscript = "what's the forecast", - NormalizedTranscript = "what's the forecast" + NormalizedTranscript = "what's the forecast", + Attributes = new Dictionary + { + ["context"] = """{"runtime":{"location":{"iso":"2026-04-20T08:00:00-05:00"}}}""" + } }); Assert.Equal("weather", decision.IntentName); Assert.Null(provider.LastRequest?.LocationQuery); - Assert.True(provider.LastRequest?.IsTomorrow); - Assert.Equal(1, provider.LastRequest?.ForecastDayOffset); - Assert.Equal("Tomorrow in Kansas City, U.S., expect clear sky with a high near 79 degrees Fahrenheit and a low around 63 degrees Fahrenheit.", decision.ReplyText); + Assert.False(provider.LastRequest?.IsTomorrow); + Assert.Equal(5, provider.LastRequest?.ForecastDayOffset); + Assert.Equal(5, provider.Requests.Count); + Assert.Contains("next five-day forecast", decision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Tuesday: clear sky, high 79, low 63.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Saturday: clear sky, high 79, low 63.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -1697,7 +1704,7 @@ public sealed class JiboInteractionServiceTests [Theory] [InlineData("how is the weather", null, 0, false)] - [InlineData("what's the forecast", null, 1, true)] + [InlineData("what's the forecast", null, 5, false)] [InlineData("forecast for new york city", "New York City", 1, true)] [InlineData("what's today's forecast", null, 0, false)] [InlineData("what's the weather in chicago", "Chicago", 0, false)] @@ -1729,6 +1736,11 @@ public sealed class JiboInteractionServiceTests Assert.Equal(expectedIsTomorrow, provider.LastRequest.IsTomorrow); Assert.Equal("chitchat-skill", decision.SkillName); Assert.Equal(true, decision.SkillPayload?["weather_view_enabled"]); + + if (string.Equals(transcript, "what's the forecast", StringComparison.Ordinal)) + { + Assert.Equal(5, provider.Requests.Count); + } } [Fact] @@ -2151,6 +2163,48 @@ public sealed class JiboInteractionServiceTests Assert.Equal("No.", decision.ReplyText); } + [Fact] + public async Task BuildDecisionAsync_SurprisesDateOfferPrompt_WithNoisyAffirmation_MapsToSurpriseIntent() + { + var service = CreateService(); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "- Thank you. - Yes.", + NormalizedTranscript = "- Thank you. - Yes.", + Attributes = new Dictionary + { + ["listenRules"] = (string[])["surprises-date/offer_date_fact", "globals/gui_nav", "globals/global_commands_launch"], + ["listenAsrHints"] = (string[])["$YESNO"], + ["context"] = """{"runtime":{"location":{"iso":"2026-04-20T08:00:00-05:00"}}}""" + } + }); + + Assert.Equal("proactive_offer_pizza_fact", decision.IntentName); + Assert.Equal("Do you want to hear a fun pizza fact?", decision.ReplyText); + } + + [Fact] + public async Task BuildDecisionAsync_WordOfDayOfferPrompt_WithNoisyAffirmation_MapsToWordOfDayLaunch() + { + var service = CreateService(); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "- Me. - Yes.", + NormalizedTranscript = "- Me. - Yes.", + Attributes = new Dictionary + { + ["listenRules"] = (string[])["word-of-the-day/surprise", "globals/gui_nav", "globals/global_commands_launch"], + ["listenAsrHints"] = (string[])["$YESNO"] + } + }); + + Assert.Equal("word_of_the_day", decision.IntentName); + Assert.Equal("Starting word of the day.", decision.ReplyText); + Assert.Equal("@be/word-of-the-day", decision.SkillName); + } + [Fact] public async Task BuildDecisionAsync_SettingsDownloadPrompt_MapsShortDenialToNoIntent() { @@ -2171,7 +2225,7 @@ public sealed class JiboInteractionServiceTests } [Fact] - public async Task BuildDecisionAsync_SurprisesDateOfferPrompt_MapsShortAffirmationToYesIntent() + public async Task BuildDecisionAsync_SurprisesDateOfferPrompt_MapsShortAffirmationToSurpriseFlow() { var service = CreateService(); @@ -2186,8 +2240,8 @@ public sealed class JiboInteractionServiceTests } }); - Assert.Equal("yes", decision.IntentName); - Assert.Equal("Yes.", decision.ReplyText); + Assert.Equal("proactive_offer_pizza_fact", decision.IntentName); + Assert.Equal("Do you want to hear a fun pizza fact?", decision.ReplyText); } [Fact] diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs index cc085b9..506c1b7 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs @@ -1932,14 +1932,53 @@ public sealed class JiboWebSocketServiceTests Assert.Equal(3, replies.Count); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("yes", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("proactive_offer_pizza_fact", 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("surprises-date/offer_date_fact", rules[0].GetString()); - Assert.Equal("surprises-date/offer_date_fact", listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); + var selectedRule = rules[0].GetString(); + Assert.True( + string.Equals(selectedRule, "surprises-date/offer_date_fact", StringComparison.OrdinalIgnoreCase) || + string.Equals(selectedRule, "shared/yes_no", StringComparison.OrdinalIgnoreCase)); + Assert.Equal(selectedRule, listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); Assert.Empty(listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").EnumerateObject()); } + [Fact] + public async Task ClientAsr_WordOfDayOfferPrompt_MapsYesToWordOfDayLaunch() + { + await _service.HandleMessageAsync(new WebSocketMessageEnvelope + { + HostName = "neo-hub.jibo.com", + Path = "/listen", + Kind = "neo-hub-listen", + Token = "hub-wod-offer-yesno-token", + Text = """{"type":"LISTEN","transID":"trans-wod-offer-yes","data":{"rules":["word-of-the-day/surprise","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-wod-offer-yesno-token", + Text = """{"type":"CLIENT_ASR","transID":"trans-wod-offer-yes","data":{"text":"Yes!"}}""" + }); + + Assert.True(replies.Count >= 3); + + using var listenPayload = JsonDocument.Parse(replies[0].Text!); + var rules = listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules"); + Assert.Single(rules.EnumerateArray()); + var selectedRule = rules[0].GetString(); + Assert.True( + string.Equals(selectedRule, "word-of-the-day/surprise", StringComparison.OrdinalIgnoreCase) || + string.Equals(selectedRule, "word-of-the-day/menu", StringComparison.OrdinalIgnoreCase)); + Assert.Equal(selectedRule, listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); + Assert.Equal( + "word-of-the-day", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("domain").GetString()); + } + [Fact] public void ResponsePlanMapper_EscapesSpeechWithoutEncodingApostrophes() {