diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICommuteReportProvider.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICommuteReportProvider.cs new file mode 100644 index 0000000..90cb68c --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICommuteReportProvider.cs @@ -0,0 +1,15 @@ +using Jibo.Runtime.Abstractions; + +namespace Jibo.Cloud.Application.Abstractions; + +public interface ICommuteReportProvider +{ + Task GetReportAsync(TurnContext turn, CancellationToken cancellationToken = default); +} + +public sealed record CommuteReportSnapshot( + string LocationName, + string Summary, + int DurationMinutes, + string? Mode = null, + bool EventIsEarly = false); 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 13a8610..f9f9dc4 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 @@ -11,6 +11,7 @@ public sealed class JiboInteractionService( IJiboRandomizer randomizer, IPersonalMemoryStore personalMemoryStore, IWeatherReportProvider? weatherReportProvider = null, + ICommuteReportProvider? commuteReportProvider = null, INewsBriefingProvider? newsBriefingProvider = null) { private const string GreetingRouteMetadataKey = "greetingsRoute"; @@ -482,6 +483,7 @@ public sealed class JiboInteractionService( randomizer, personalMemoryStore, BuildWeatherReportDecisionAsync, + BuildCommuteReportDecisionAsync, turnContext => ResolveTenantScope(turnContext), cancellationToken); if (personalReportDecision is not null) return personalReportDecision; @@ -1372,6 +1374,37 @@ public sealed class JiboInteractionService( weatherPayload); } + private async Task BuildCommuteReportDecisionAsync( + TurnContext turn, + CancellationToken cancellationToken) + { + var catalog = await contentCache.GetCatalogAsync(cancellationToken); + + if (commuteReportProvider is null) + return new JiboInteractionDecision( + "commute", + ChooseCommuteServiceDownReply(catalog)); + + CommuteReportSnapshot? snapshot; + try + { + snapshot = await commuteReportProvider.GetReportAsync(turn, cancellationToken); + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + snapshot = null; + } + + if (snapshot is null) + return new JiboInteractionDecision( + "commute", + ChooseCommuteServiceDownReply(catalog)); + + return new JiboInteractionDecision( + "commute", + BuildCommuteSpokenReply(snapshot, catalog)); + } + private static string BuildWeatherSpokenReply( WeatherReportSnapshot snapshot, WeatherDateEntity weatherDate, @@ -1458,6 +1491,55 @@ public sealed class JiboInteractionService( $"{currentIntro} In {location}, it's {summary} and {snapshot.Temperature} degrees {unit}. {currentHighLow}"; } + private static string BuildCommuteSpokenReply( + CommuteReportSnapshot snapshot, + JiboExperienceCatalog catalog) + { + var duration = snapshot.DurationMinutes; + var durationText = duration <= 1 ? "1 minute" : $"{duration} minutes"; + var mode = string.IsNullOrWhiteSpace(snapshot.Mode) ? "driving" : snapshot.Mode.Trim(); + + var template = ChooseCommuteTemplate(catalog.CommuteNowReplies, mode, + "For your commute, it should take about ${skill.commute.durationMins} minutes."); + + return template + .Replace("${skill.commute.durationMins}", durationText, StringComparison.OrdinalIgnoreCase) + .Replace("${speaker}", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace(" ", " ", StringComparison.Ordinal) + .Trim(); + } + + private static string ChooseCommuteTemplate( + IReadOnlyList templates, + string mode, + string fallback) + { + if (templates.Count == 0) return fallback; + + var loweredMode = mode.Trim().ToLowerInvariant(); + var filtered = templates.Where(template => + { + var lowered = template.ToLowerInvariant(); + return loweredMode switch + { + "walking" => lowered.Contains("walk", StringComparison.OrdinalIgnoreCase), + "transit" => lowered.Contains("public transportation", StringComparison.OrdinalIgnoreCase) || + lowered.Contains("transit", StringComparison.OrdinalIgnoreCase) || + lowered.Contains("transportation", StringComparison.OrdinalIgnoreCase), + "bicycling" => lowered.Contains("bike", StringComparison.OrdinalIgnoreCase) || + lowered.Contains("ride", StringComparison.OrdinalIgnoreCase), + _ => lowered.Contains("drive", StringComparison.OrdinalIgnoreCase) || + lowered.Contains("commute", StringComparison.OrdinalIgnoreCase) + }; + }).ToList(); + + var selected = filtered.Count > 0 + ? filtered.OrderBy(static template => template.Length).First() + : templates.OrderBy(static template => template.Length).FirstOrDefault(); + + return string.IsNullOrWhiteSpace(selected) ? fallback : selected!; + } + private static string BuildWeeklyForecastSpokenReply( IReadOnlyList segments, string? locationName, @@ -1808,6 +1890,14 @@ public sealed class JiboInteractionService( return template.Trim(); } + private string ChooseCommuteServiceDownReply(JiboExperienceCatalog catalog) + { + var template = ChooseWeatherTemplate( + catalog.CommuteServiceDownReplies, + "Sorry, commute information isn't available right now."); + return template.Trim(); + } + private static string EscapeForEsml(string value) { return value diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/PersonalReportOrchestrator.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/PersonalReportOrchestrator.cs index 6e5b31f..b446add 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/PersonalReportOrchestrator.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/PersonalReportOrchestrator.cs @@ -70,6 +70,7 @@ internal static class PersonalReportOrchestrator IJiboRandomizer randomizer, IPersonalMemoryStore personalMemoryStore, Func> buildWeatherDecisionAsync, + Func> buildCommuteDecisionAsync, Func tenantScopeResolver, CancellationToken cancellationToken) { @@ -191,6 +192,7 @@ internal static class PersonalReportOrchestrator toggles, currentName, buildWeatherDecisionAsync, + buildCommuteDecisionAsync, cancellationToken); if (IsNegativeReply(loweredTranscript)) @@ -235,6 +237,7 @@ internal static class PersonalReportOrchestrator toggles, parsedName, buildWeatherDecisionAsync, + buildCommuteDecisionAsync, cancellationToken); } @@ -250,6 +253,7 @@ internal static class PersonalReportOrchestrator PersonalReportServiceToggles toggles, string userName, Func> buildWeatherDecisionAsync, + Func> buildCommuteDecisionAsync, CancellationToken cancellationToken) { var reportSections = new List @@ -293,13 +297,7 @@ internal static class PersonalReportOrchestrator } if (toggles.CommuteEnabled) - reportSections.Add( - RenderReportSkillTemplate( - ChooseReportSkillTemplate( - catalog.CommuteServiceDownReplies, - catalog.CommuteNowReplies, - "Sorry, commute information isn't available right now."), - userName)); + reportSections.Add((await buildCommuteDecisionAsync(turn, cancellationToken)).ReplyText); if (toggles.NewsEnabled) { diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Commute/UnavailableCommuteReportProvider.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Commute/UnavailableCommuteReportProvider.cs new file mode 100644 index 0000000..4be701f --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Commute/UnavailableCommuteReportProvider.cs @@ -0,0 +1,14 @@ +using Jibo.Cloud.Application.Abstractions; +using Jibo.Runtime.Abstractions; + +namespace Jibo.Cloud.Infrastructure.Commute; + +public sealed class UnavailableCommuteReportProvider : ICommuteReportProvider +{ + public Task GetReportAsync( + TurnContext turn, + CancellationToken cancellationToken = default) + { + return Task.FromResult(null); + } +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs index 19217d0..d59665d 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Jibo.Cloud.Application.Abstractions; using Jibo.Cloud.Application.Services; using Jibo.Cloud.Infrastructure.Audio; +using Jibo.Cloud.Infrastructure.Commute; using Jibo.Cloud.Infrastructure.Content; using Jibo.Cloud.Infrastructure.Media; using Jibo.Cloud.Infrastructure.News; @@ -45,6 +46,7 @@ public static class ServiceCollectionExtensions services.AddSingleton(newsApiOptions); services.AddHttpClient(); services.AddHttpClient(); + services.AddSingleton(); var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"] ?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json"); var personalMemoryPersistencePath = configuration?["OpenJibo:PersonalMemory:PersistencePath"] diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs index 7aa2566..20d3f40 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs @@ -3974,6 +3974,7 @@ public sealed class JiboInteractionServiceTests private static JiboInteractionService CreateService( IPersonalMemoryStore? personalMemoryStore = null, IWeatherReportProvider? weatherReportProvider = null, + ICommuteReportProvider? commuteReportProvider = null, INewsBriefingProvider? newsBriefingProvider = null, IJiboExperienceContentRepository? contentRepository = null, IJiboRandomizer? randomizer = null) @@ -3983,6 +3984,7 @@ public sealed class JiboInteractionServiceTests randomizer ?? new FirstItemRandomizer(), personalMemoryStore ?? new InMemoryPersonalMemoryStore(), weatherReportProvider, + commuteReportProvider, newsBriefingProvider); } @@ -4076,4 +4078,16 @@ public sealed class JiboInteractionServiceTests return Task.FromResult(catalog); } } + + private sealed class CapturingCommuteReportProvider : ICommuteReportProvider + { + public CommuteReportSnapshot? Snapshot { get; init; } + + public Task GetReportAsync( + TurnContext turn, + CancellationToken cancellationToken = default) + { + return Task.FromResult(Snapshot); + } + } } diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs index b634ed2..6b82fe5 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs @@ -21,7 +21,7 @@ public sealed class JiboWebSocketServiceTests var contentRepository = new InMemoryJiboExperienceContentRepository(); var contentCache = new JiboExperienceContentCache(contentRepository); var conversationBroker = new DemoConversationBroker(new JiboInteractionService(contentCache, - new LastItemRandomizer(), new InMemoryPersonalMemoryStore())); + new LastItemRandomizer(), new InMemoryPersonalMemoryStore(), null, null, null)); var sttSelector = new DefaultSttStrategySelector( [ new SyntheticBufferedAudioSttStrategy() @@ -4732,6 +4732,7 @@ public sealed class JiboWebSocketServiceTests customStore, new StubWeatherReportProvider( new WeatherReportSnapshot("Lone Jack, US", "overcast clouds", 79, 82, 78, "clouds", false)), + null, new StubNewsBriefingProvider( new NewsBriefingSnapshot( [ @@ -5122,6 +5123,7 @@ public sealed class JiboWebSocketServiceTests private static JiboWebSocketService CreateService( InMemoryCloudStateStore stateStore, IWeatherReportProvider? weatherReportProvider = null, + ICommuteReportProvider? commuteReportProvider = null, INewsBriefingProvider? newsBriefingProvider = null) { var contentRepository = new InMemoryJiboExperienceContentRepository(); @@ -5131,6 +5133,7 @@ public sealed class JiboWebSocketServiceTests new DefaultJiboRandomizer(), new InMemoryPersonalMemoryStore(), weatherReportProvider, + commuteReportProvider, newsBriefingProvider); var conversationBroker = new DemoConversationBroker(interactionService); var sttSelector = new DefaultSttStrategySelector(