diff --git a/OpenJibo/NuGet.Config b/OpenJibo/NuGet.Config index 765346e..89f00e3 100644 --- a/OpenJibo/NuGet.Config +++ b/OpenJibo/NuGet.Config @@ -1,7 +1,8 @@ + - - - - - + + + + + \ No newline at end of file diff --git a/OpenJibo/src/Jibo WiFi QR Generator/jibo_qr_generator.html b/OpenJibo/src/Jibo WiFi QR Generator/jibo_qr_generator.html index 4cdf1ba..91a8da9 100644 --- a/OpenJibo/src/Jibo WiFi QR Generator/jibo_qr_generator.html +++ b/OpenJibo/src/Jibo WiFi QR Generator/jibo_qr_generator.html @@ -1,10 +1,10 @@ - - - Jibo QR Generator - - - - -

🤖 Jibo Wi-Fi QR Generator

-

Generates a QR code using Jibo's XOR encoding format

- - -
- - + + +

🤖 Jibo Wi-Fi QR Generator

+

Generates a QR code using Jibo's XOR encoding format

+ + +
+ + - - + + - + -
-
-
- -
-
- -
-
-
-
- -
-
- -
-
-
-
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+ +
+
- -
+ +
-
-
- -

Scan with Jibo's app to configure Wi-Fi

-
+
+
+ +

Scan with Jibo's app to configure Wi-Fi

+
- - - + + \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/Program.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/Program.cs index 95cc73c..337d58f 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/Program.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/Program.cs @@ -1,5 +1,6 @@ using System.Net.WebSockets; using System.Text; +using System.Text.Json; using Jibo.Cloud.Application.Abstractions; using Jibo.Cloud.Application.Services; using Jibo.Cloud.Domain.Models; @@ -38,7 +39,7 @@ app.Use(async (context, next) => var webSocketService = context.RequestServices.GetRequiredService(); var telemetrySink = context.RequestServices.GetRequiredService(); - + using var socket = await context.WebSockets.AcceptWebSocketAsync(); var openEnvelope = new WebSocketMessageEnvelope @@ -74,7 +75,7 @@ app.Use(async (context, next) => break; } } - + var envelope = new WebSocketMessageEnvelope { ConnectionId = Guid.NewGuid().ToString("N"), @@ -88,18 +89,13 @@ app.Use(async (context, next) => var replies = await webSocketService.HandleMessageAsync(envelope, context.RequestAborted); var session = ResolveSession(webSocketService, envelope); - await telemetrySink.RecordInboundAsync(envelope, session, ReadMessageType(envelope.Text), context.RequestAborted); + await telemetrySink.RecordInboundAsync(envelope, session, ReadMessageType(envelope.Text), + context.RequestAborted); foreach (var reply in replies) { - if (string.IsNullOrWhiteSpace(reply.Text)) - { - continue; - } + if (string.IsNullOrWhiteSpace(reply.Text)) continue; - if (reply.DelayMs > 0) - { - await Task.Delay(reply.DelayMs, context.RequestAborted); - } + if (reply.DelayMs > 0) await Task.Delay(reply.DelayMs, context.RequestAborted); var payload = Encoding.UTF8.GetBytes(reply.Text); await socket.SendAsync(payload, WebSocketMessageType.Text, true, context.RequestAborted); @@ -117,7 +113,8 @@ app.Use(async (context, next) => Token = token }; var closeSession = ResolveSession(webSocketService, closeEnvelope); - await telemetrySink.RecordConnectionClosedAsync(closeEnvelope, closeSession, $"socket-loop-ended{(isPrematureClose ? "-prematurely" : string.Empty)}", context.RequestAborted); + await telemetrySink.RecordConnectionClosedAsync(closeEnvelope, closeSession, + $"socket-loop-ended{(isPrematureClose ? "-prematurely" : string.Empty)}", context.RequestAborted); }); app.MapGet("/health", () => Results.Json(new @@ -127,7 +124,8 @@ app.MapGet("/health", () => Results.Json(new version = OpenJiboCloudBuildInfo.Version })); -app.MapMethods("/{**path}", ["GET", "POST", "PUT"], async (HttpContext context, JiboCloudProtocolService service, IProtocolTelemetrySink telemetrySink, CancellationToken cancellationToken) => +app.MapMethods("/{**path}", ["GET", "POST", "PUT"], async (HttpContext context, JiboCloudProtocolService service, + IProtocolTelemetrySink telemetrySink, CancellationToken cancellationToken) => { var envelope = await BuildEnvelopeAsync(context, cancellationToken); var result = await service.DispatchAsync(envelope, cancellationToken); @@ -136,15 +134,9 @@ app.MapMethods("/{**path}", ["GET", "POST", "PUT"], async (HttpContext context, context.Response.StatusCode = result.StatusCode; context.Response.ContentType = result.ContentType; - foreach (var header in result.Headers) - { - context.Response.Headers[header.Key] = header.Value; - } + foreach (var header in result.Headers) context.Response.Headers[header.Key] = header.Value; - if (!string.IsNullOrEmpty(result.BodyText)) - { - await context.Response.WriteAsync(result.BodyText, cancellationToken); - } + if (!string.IsNullOrEmpty(result.BodyText)) await context.Response.WriteAsync(result.BodyText, cancellationToken); }); app.Run(); @@ -160,8 +152,7 @@ static async Task ReceiveAsync(WebSocket socket, Cancella { result = await socket.ReceiveAsync(buffer, cancellationToken); ms.Write(buffer, 0, result.Count); - } - while (!result.EndOfMessage); + } while (!result.EndOfMessage); return new ReceivedSocketMessage(result.MessageType, ms.ToArray()); } @@ -170,7 +161,7 @@ static async Task BuildEnvelopeAsync(HttpContext context, Canc { context.Request.EnableBuffering(); - using var reader = new StreamReader(context.Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true); + using var reader = new StreamReader(context.Request.Body, Encoding.UTF8, false, leaveOpen: true); var bodyText = await reader.ReadToEndAsync(cancellationToken); context.Request.Body.Position = 0; @@ -191,66 +182,49 @@ static async Task BuildEnvelopeAsync(HttpContext context, Canc FirmwareVersion = context.Request.Headers["X-OpenJibo-Firmware"].ToString(), ApplicationVersion = context.Request.Headers["X-OpenJibo-AppVersion"].ToString(), BodyText = bodyText, - Headers = context.Request.Headers.ToDictionary(pair => pair.Key, pair => pair.Value.ToString(), StringComparer.OrdinalIgnoreCase) + Headers = context.Request.Headers.ToDictionary(pair => pair.Key, pair => pair.Value.ToString(), + StringComparer.OrdinalIgnoreCase) }; } static string ResolveSocketKind(string host, PathString path) { - if (host.Equals("api-socket.jibo.com", StringComparison.OrdinalIgnoreCase)) - { - return "api-socket"; - } + if (host.Equals("api-socket.jibo.com", StringComparison.OrdinalIgnoreCase)) return "api-socket"; if (host.Equals("neo-hub.jibo.com", StringComparison.OrdinalIgnoreCase) && path.StartsWithSegments("/v1/proactive")) - { return "neo-hub-proactive"; - } - if (host.Equals("neo-hub.jibo.com", StringComparison.OrdinalIgnoreCase)) - { - return "neo-hub-listen"; - } + if (host.Equals("neo-hub.jibo.com", StringComparison.OrdinalIgnoreCase)) return "neo-hub-listen"; if (host.Equals("openjibo.com", StringComparison.OrdinalIgnoreCase) || host.Equals("openjibo.ai", StringComparison.OrdinalIgnoreCase) || host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) - { return "openjibo"; - } - return "neo-hub-listen"; // now it assumes all unknown requests are neo-hub. I did this so that people with custom listen servers (like myself) won't get a bunch of 404 messages when doing a HJ request. -ZaneDev (an awful programmer) + return + "neo-hub-listen"; // now it assumes all unknown requests are neo-hub. I did this so that people with custom listen servers (like myself) won't get a bunch of 404 messages when doing a HJ request. -ZaneDev (an awful programmer) } static string? ResolveToken(HttpRequest request) { var auth = request.Headers.Authorization.ToString(); - if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) - { - return auth["Bearer ".Length..].Trim(); - } + if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) return auth["Bearer ".Length..].Trim(); var path = request.Path.Value; - if (!string.IsNullOrWhiteSpace(path) && path.Length > 1) - { - return path.Trim('/'); - } + if (!string.IsNullOrWhiteSpace(path) && path.Length > 1) return path.Trim('/'); return null; } static string ReadMessageType(string? text) { - if (string.IsNullOrWhiteSpace(text)) - { - return "BINARY_OR_EMPTY"; - } + if (string.IsNullOrWhiteSpace(text)) return "BINARY_OR_EMPTY"; try { - using var document = System.Text.Json.JsonDocument.Parse(text); - return document.RootElement.TryGetProperty("type", out var type) && type.ValueKind == System.Text.Json.JsonValueKind.String + using var document = JsonDocument.Parse(text); + return document.RootElement.TryGetProperty("type", out var type) && type.ValueKind == JsonValueKind.String ? type.GetString() ?? "UNKNOWN" : "UNKNOWN"; } @@ -265,4 +239,4 @@ static CloudSession ResolveSession(JiboWebSocketService webSocketService, WebSoc return webSocketService.GetOrCreateSession(envelope); } -internal sealed record ReceivedSocketMessage(WebSocketMessageType MessageType, byte[] Buffer); +internal sealed record ReceivedSocketMessage(WebSocketMessageType MessageType, byte[] Buffer); \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICloudStateStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICloudStateStore.cs index d6e0432..c015b9e 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICloudStateStore.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICloudStateStore.cs @@ -19,12 +19,21 @@ public interface ICloudStateStore IReadOnlyList GetPeople(); IReadOnlyList ListUpdates(string? subsystem = null, string? filter = null); UpdateManifest? GetUpdateFrom(string? subsystem, string? fromVersion, string? filter); - UpdateManifest CreateUpdate(string? fromVersion, string? toVersion, string? changes, string? shaHash, long? length, string? subsystem, string? filter, IDictionary? dependencies); + + UpdateManifest CreateUpdate(string? fromVersion, string? toVersion, string? changes, string? shaHash, long? length, + string? subsystem, string? filter, IDictionary? dependencies); + UpdateManifest RemoveUpdate(string? updateId); - IReadOnlyList ListMedia(IReadOnlyList? loopIds = null, long? after = null, long? before = null); + + IReadOnlyList ListMedia(IReadOnlyList? loopIds = null, long? after = null, + long? before = null); + IReadOnlyList GetMedia(IReadOnlyList paths); IReadOnlyList RemoveMedia(IReadOnlyList paths); - MediaRecord CreateMedia(string loopId, string path, string type, string reference, bool isEncrypted, IDictionary? meta); + + MediaRecord CreateMedia(string loopId, string path, string type, string reference, bool isEncrypted, + IDictionary? meta); + IReadOnlyList GetBackups(); bool ShouldCreateSymmetricKey(string loopId); string GetOrCreateSymmetricKey(string loopId); @@ -34,4 +43,4 @@ public interface ICloudStateStore IReadOnlyList GetBinaryRequests(); IReadOnlyList GetHolidays(); void UpdateRobot(DeviceRegistration registration); -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IJiboExperienceContentRepository.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IJiboExperienceContentRepository.cs index 1cc23fb..d66aeca 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IJiboExperienceContentRepository.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IJiboExperienceContentRepository.cs @@ -46,4 +46,4 @@ public sealed class JiboExperienceCatalog public IReadOnlyList GenericFallbackReplies { get; init; } = []; public IReadOnlyList DanceReplies { get; init; } = []; public IReadOnlyList DanceQuestionReplies { get; init; } = []; -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/INewsBriefingProvider.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/INewsBriefingProvider.cs index 6ca08ea..b341f11 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/INewsBriefingProvider.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/INewsBriefingProvider.cs @@ -25,4 +25,4 @@ public sealed record NewsBriefingSnapshot( string? ProviderMessage = null, int? ProviderHttpStatusCode = null, string? ProviderEndpoint = null, - string? ProviderErrorCode = null); + string? ProviderErrorCode = null); \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IPersonalMemoryStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IPersonalMemoryStore.cs index 1f5eee0..721e537 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IPersonalMemoryStore.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IPersonalMemoryStore.cs @@ -21,7 +21,11 @@ public interface IPersonalMemoryStore void ClearListItems(PersonalMemoryTenantScope tenantScope, string listName); } -public sealed record PersonalMemoryTenantScope(string AccountId, string LoopId, string DeviceId, string? PersonId = null); +public sealed record PersonalMemoryTenantScope( + string AccountId, + string LoopId, + string DeviceId, + string? PersonId = null); public sealed record PersistenceStateInfo( string SchemaVersion, @@ -34,4 +38,4 @@ public enum PersonalAffinity Like, Love, Dislike -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IProtocolTelemetrySink.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IProtocolTelemetrySink.cs index 077a861..0323ef6 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IProtocolTelemetrySink.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IProtocolTelemetrySink.cs @@ -4,5 +4,6 @@ namespace Jibo.Cloud.Application.Abstractions; public interface IProtocolTelemetrySink { - Task RecordAsync(ProtocolEnvelope envelope, ProtocolDispatchResult result, CancellationToken cancellationToken = default); -} + Task RecordAsync(ProtocolEnvelope envelope, ProtocolDispatchResult result, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ITurnTelemetrySink.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ITurnTelemetrySink.cs index 7a0ed02..8535718 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ITurnTelemetrySink.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ITurnTelemetrySink.cs @@ -2,7 +2,8 @@ namespace Jibo.Cloud.Application.Abstractions; public interface ITurnTelemetrySink { - Task RecordTurnDiagnosticAsync(string category, IReadOnlyDictionary details, CancellationToken cancellationToken = default); + Task RecordTurnDiagnosticAsync(string category, IReadOnlyDictionary details, + CancellationToken cancellationToken = default); Task RecordTranscriptError(Exception ex, string message, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IWeatherReportProvider.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IWeatherReportProvider.cs index d0ba95e..0340584 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IWeatherReportProvider.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IWeatherReportProvider.cs @@ -22,4 +22,4 @@ public sealed record WeatherReportSnapshot( int? HighTemperature, int? LowTemperature, string? Condition, - bool UseCelsius); + bool UseCelsius); \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IWebSocketTelemetrySink.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IWebSocketTelemetrySink.cs index c954fa6..6edcc0f 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IWebSocketTelemetrySink.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IWebSocketTelemetrySink.cs @@ -4,9 +4,18 @@ namespace Jibo.Cloud.Application.Abstractions; public interface IWebSocketTelemetrySink { - Task RecordConnectionOpenedAsync(WebSocketMessageEnvelope envelope, CloudSession session, CancellationToken cancellationToken = default); - Task RecordInboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, string? messageType, CancellationToken cancellationToken = default); - Task RecordTurnEventAsync(WebSocketMessageEnvelope envelope, CloudSession session, string eventType, IReadOnlyDictionary details, CancellationToken cancellationToken = default); - Task RecordOutboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, IReadOnlyList replies, CancellationToken cancellationToken = default); - Task RecordConnectionClosedAsync(WebSocketMessageEnvelope envelope, CloudSession session, string reason, CancellationToken cancellationToken = default); + Task RecordConnectionOpenedAsync(WebSocketMessageEnvelope envelope, CloudSession session, + CancellationToken cancellationToken = default); + + Task RecordInboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, string? messageType, + CancellationToken cancellationToken = default); + + Task RecordTurnEventAsync(WebSocketMessageEnvelope envelope, CloudSession session, string eventType, + IReadOnlyDictionary details, CancellationToken cancellationToken = default); + + Task RecordOutboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, + IReadOnlyList replies, CancellationToken cancellationToken = default); + + Task RecordConnectionClosedAsync(WebSocketMessageEnvelope envelope, CloudSession session, string reason, + CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ChitchatStateMachine.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ChitchatStateMachine.cs index 0a34c21..29c4d12 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ChitchatStateMachine.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ChitchatStateMachine.cs @@ -1,5 +1,5 @@ -using Jibo.Cloud.Application.Abstractions; using System.Text.RegularExpressions; +using Jibo.Cloud.Application.Abstractions; namespace Jibo.Cloud.Application.Services; @@ -136,7 +136,11 @@ internal static class ChitchatStateMachine ("jealous", ["jealous", "envious", "covetous"]), ("lonely", ["lonely", "alone", "lonesome"]), ("proud", ["proud", "honored"]), - ("sad", ["sad", "upset", "unhappy", "depressed", "somber", "downcast", "gloomy", "miserable", "bummed", "heartbroken", "troubled"]) + ("sad", + [ + "sad", "upset", "unhappy", "depressed", "somber", "downcast", "gloomy", "miserable", "bummed", + "heartbroken", "troubled" + ]) ]; private static readonly string[] EmotionCommandReplies = @@ -216,7 +220,8 @@ internal static class ChitchatStateMachine case "robot_identity": return BuildScriptedResponseDecision( "robot_identity", - SelectLegacyPersonalityReply(catalog, randomizer, "am a robot", "i'm either jibo", "i am just jibo")); + SelectLegacyPersonalityReply(catalog, randomizer, "am a robot", "i'm either jibo", + "i am just jibo")); case "robot_likes_being_jibo": return BuildScriptedResponseDecision( "robot_likes_being_jibo", @@ -259,16 +264,12 @@ internal static class ChitchatStateMachine SelectLegacyPersonalityReply(catalog, randomizer, "know a lot", "not as much as i will someday")); case "chat": if (IsEmotionQuery(normalizedLoweredTranscript)) - { return BuildEmotionQueryDecision( "emotion_query", SelectEmotionQueryReply(catalog, randomizer, currentEmotion)); - } if (TryResolveEmotionCommand(normalizedLoweredTranscript, out var emotion)) - { return BuildEmotionCommandDecision(randomizer, emotion!); - } return BuildErrorResponseDecision( "chat", @@ -293,7 +294,7 @@ internal static class ChitchatStateMachine replyText, ContextUpdates: BuildContextUpdates( ScriptedResponseRoute, - emotion: null)); + null)); } private static JiboInteractionDecision BuildEmotionQueryDecision(string intentName, string replyText) @@ -303,7 +304,7 @@ internal static class ChitchatStateMachine replyText, ContextUpdates: BuildContextUpdates( EmotionQueryRoute, - emotion: null)); + null)); } private static JiboInteractionDecision BuildEmotionCommandDecision(IJiboRandomizer randomizer, string emotion) @@ -323,18 +324,20 @@ internal static class ChitchatStateMachine "chitchat-skill", new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["esml"] = $"{responseSuffix}", + ["esml"] = + $"{responseSuffix}", ["mim_id"] = "runtime-chat", ["mim_type"] = "announcement", ["prompt_id"] = "RUNTIME_EMOTION_COMMAND", ["prompt_sub_category"] = "AN" }, - ContextUpdates: BuildContextUpdates( + BuildContextUpdates( EmotionCommandRoute, emotion)); } - private static JiboInteractionDecision BuildErrorResponseDecision(string intentName, string replyText, string transcript) + private static JiboInteractionDecision BuildErrorResponseDecision(string intentName, string replyText, + string transcript) { var normalizedTranscript = string.IsNullOrWhiteSpace(transcript) ? string.Empty @@ -344,8 +347,8 @@ internal static class ChitchatStateMachine replyText, ContextUpdates: BuildContextUpdates( ErrorResponseRoute, - emotion: null, - rawTranscript: normalizedTranscript)); + null, + normalizedTranscript)); } private static IDictionary BuildContextUpdates( @@ -360,7 +363,7 @@ internal static class ChitchatStateMachine [EmotionMetadataKey] = emotion ?? string.Empty, ["chitchatLastState"] = IntentSplitState, ["chitchatProcessState"] = ProcessQueryState, - ["chitchatRawTranscript"] = rawTranscript ?? string.Empty + ["chitchatRawTranscript"] = rawTranscript ?? string.Empty }; } @@ -369,19 +372,12 @@ internal static class ChitchatStateMachine IJiboRandomizer randomizer, string? currentEmotion) { - if (catalog.EmotionReplies.Count == 0) - { - return randomizer.Choose(catalog.HowAreYouReplies); - } + if (catalog.EmotionReplies.Count == 0) return randomizer.Choose(catalog.HowAreYouReplies); var emotionVariants = ResolveEmotionVariants(currentEmotion); foreach (var reply in catalog.EmotionReplies) - { if (ConditionMatches(reply.Condition, emotionVariants)) - { return reply.Reply; - } - } return randomizer.Choose(catalog.HowAreYouReplies); } @@ -389,19 +385,13 @@ internal static class ChitchatStateMachine private static bool ConditionMatches(string? condition, IReadOnlyList emotionVariants) { var normalizedCondition = NormalizeCondition(condition); - if (string.IsNullOrWhiteSpace(normalizedCondition)) - { - return false; - } + if (string.IsNullOrWhiteSpace(normalizedCondition)) return false; - var clauses = normalizedCondition.Split(new[] { "||" }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var clauses = normalizedCondition.Split(new[] { "||" }, + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); foreach (var clause in clauses) - { if (MatchesConditionClause(clause, emotionVariants)) - { return true; - } - } return false; } @@ -410,16 +400,11 @@ internal static class ChitchatStateMachine { var normalizedClause = NormalizeCondition(clause).ToUpperInvariant(); if (normalizedClause == "!JIBO.EMOTION") - { return emotionVariants.Contains(string.Empty, StringComparer.OrdinalIgnoreCase) || emotionVariants.Contains("NEUTRAL", StringComparer.OrdinalIgnoreCase); - } var equalityIndex = normalizedClause.IndexOf("==", StringComparison.Ordinal); - if (equalityIndex < 0) - { - return false; - } + if (equalityIndex < 0) return false; var rightSide = normalizedClause[(equalityIndex + 2)..].Trim(); var candidate = rightSide.Trim('"', '\''); @@ -428,10 +413,7 @@ internal static class ChitchatStateMachine private static IReadOnlyList ResolveEmotionVariants(string? currentEmotion) { - if (string.IsNullOrWhiteSpace(currentEmotion)) - { - return ["", "NEUTRAL"]; - } + if (string.IsNullOrWhiteSpace(currentEmotion)) return ["", "NEUTRAL"]; var normalizedEmotion = NormalizeCondition(currentEmotion).Trim('"', '\'').ToUpperInvariant(); return normalizedEmotion switch @@ -452,17 +434,11 @@ internal static class ChitchatStateMachine { foreach (var snippet in preferredSnippets) { - if (string.IsNullOrWhiteSpace(snippet)) - { - continue; - } + if (string.IsNullOrWhiteSpace(snippet)) continue; var match = catalog.PersonalityReplies.FirstOrDefault(reply => reply.Contains(snippet, StringComparison.OrdinalIgnoreCase)); - if (!string.IsNullOrWhiteSpace(match)) - { - return match; - } + if (!string.IsNullOrWhiteSpace(match)) return match; } return randomizer.Choose(catalog.PersonalityReplies); @@ -470,25 +446,16 @@ internal static class ChitchatStateMachine private static string NormalizeCondition(string? condition) { - if (string.IsNullOrWhiteSpace(condition)) - { - return string.Empty; - } - - return PhraseWhitespacePattern.Replace(condition.Trim(), " "); + return string.IsNullOrWhiteSpace(condition) + ? string.Empty + : PhraseWhitespacePattern.Replace(condition.Trim(), " "); } private static bool IsEmotionQuery(string loweredTranscript) { - if (ContainsAnyPhrase(loweredTranscript, EmotionQueryPhrases)) - { - return true; - } + if (ContainsAnyPhrase(loweredTranscript, EmotionQueryPhrases)) return true; - if (!TryResolveEmotionFromText(loweredTranscript, out _)) - { - return false; - } + if (!TryResolveEmotionFromText(loweredTranscript, out _)) return false; return StartsWithAnyPhrase(loweredTranscript, EmotionQueryPrefixes) || StartsWithAnyPhrase(loweredTranscript, EmotionAssertionPrefixes); @@ -500,27 +467,20 @@ internal static class ChitchatStateMachine foreach (var mapping in DirectEmotionCommandPhrases) { - if (!ContainsPhrase(loweredTranscript, mapping.Phrase)) - { - continue; - } + if (!ContainsPhrase(loweredTranscript, mapping.Phrase)) continue; emotion = mapping.Emotion; return true; } var isNegativeCommand = StartsWithAnyPhrase(loweredTranscript, EmotionCommandNegativePrefixes); - var isPositiveCommand = !isNegativeCommand && StartsWithAnyPhrase(loweredTranscript, EmotionCommandPositivePrefixes); - if (!isNegativeCommand && !isPositiveCommand) - { - return false; - } + var isPositiveCommand = + !isNegativeCommand && StartsWithAnyPhrase(loweredTranscript, EmotionCommandPositivePrefixes); + if (!isNegativeCommand && !isPositiveCommand) return false; if (!TryResolveEmotionFromText(loweredTranscript, out var canonicalEmotion) || string.IsNullOrWhiteSpace(canonicalEmotion)) - { return false; - } emotion = isNegativeCommand ? "calm" @@ -544,10 +504,7 @@ internal static class ChitchatStateMachine emotion = null; foreach (var mapping in EmotionSynonymMappings) { - if (!ContainsPhrase(loweredTranscript, mapping.Phrase)) - { - continue; - } + if (!ContainsPhrase(loweredTranscript, mapping.Phrase)) continue; emotion = mapping.Emotion; return true; @@ -559,12 +516,8 @@ internal static class ChitchatStateMachine private static bool ContainsAnyPhrase(string loweredTranscript, IEnumerable phrases) { foreach (var phrase in phrases) - { if (ContainsPhrase(loweredTranscript, phrase)) - { return true; - } - } return false; } @@ -574,16 +527,11 @@ internal static class ChitchatStateMachine foreach (var phrase in phrases) { var normalizedPhrase = NormalizeForPhraseMatching(phrase); - if (string.IsNullOrWhiteSpace(normalizedPhrase)) - { - continue; - } + if (string.IsNullOrWhiteSpace(normalizedPhrase)) continue; if (string.Equals(loweredTranscript, normalizedPhrase, StringComparison.Ordinal) || loweredTranscript.StartsWith($"{normalizedPhrase} ", StringComparison.Ordinal)) - { return true; - } } return false; @@ -594,9 +542,7 @@ internal static class ChitchatStateMachine var normalizedPhrase = NormalizeForPhraseMatching(phrase); if (string.IsNullOrWhiteSpace(normalizedPhrase) || string.IsNullOrWhiteSpace(loweredTranscript)) - { return false; - } return string.Equals(loweredTranscript, normalizedPhrase, StringComparison.Ordinal) || loweredTranscript.StartsWith($"{normalizedPhrase} ", StringComparison.Ordinal) || @@ -606,10 +552,7 @@ internal static class ChitchatStateMachine private static string NormalizeForPhraseMatching(string value) { - if (string.IsNullOrWhiteSpace(value)) - { - return string.Empty; - } + if (string.IsNullOrWhiteSpace(value)) return string.Empty; var lowered = value.ToLowerInvariant(); var withoutPunctuation = PhrasePunctuationPattern.Replace(lowered, " "); @@ -622,21 +565,17 @@ internal static class ChitchatStateMachine var mappings = new List<(string Phrase, string Emotion)>(); foreach (var emotionMapping in PegasusEmotionSynonyms) + foreach (var synonym in emotionMapping.Synonyms) { - foreach (var synonym in emotionMapping.Synonyms) - { - var normalizedSynonym = NormalizeForPhraseMatching(synonym); - if (string.IsNullOrWhiteSpace(normalizedSynonym) || - !seen.Add(normalizedSynonym)) - { - continue; - } + var normalizedSynonym = NormalizeForPhraseMatching(synonym); + if (string.IsNullOrWhiteSpace(normalizedSynonym) || + !seen.Add(normalizedSynonym)) + continue; - mappings.Add((normalizedSynonym, emotionMapping.Emotion)); - } + mappings.Add((normalizedSynonym, emotionMapping.Emotion)); } mappings.Sort(static (left, right) => right.Phrase.Length.CompareTo(left.Phrase.Length)); return [.. mappings]; } -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/DefaultSttStrategySelector.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/DefaultSttStrategySelector.cs index 68253eb..db051e2 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/DefaultSttStrategySelector.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/DefaultSttStrategySelector.cs @@ -13,4 +13,4 @@ public sealed class DefaultSttStrategySelector(IEnumerable strateg ? throw new InvalidOperationException("No STT strategy can handle the current turn.") : Task.FromResult(strategy); } -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/DemoConversationBroker.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/DemoConversationBroker.cs index 5e5ee17..a413fe3 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/DemoConversationBroker.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/DemoConversationBroker.cs @@ -49,24 +49,20 @@ public sealed class DemoConversationBroker(JiboInteractionService interactionSer }; if (keepMicOpen) - { plan.Actions.Add(new ListenAction { Sequence = 1, Timeout = _followUpTimeout, Mode = "follow-up" }); - } if (!string.IsNullOrWhiteSpace(decision.SkillName)) - { plan.Actions.Add(new InvokeNativeSkillAction { Sequence = 2, SkillName = decision.SkillName, Payload = decision.SkillPayload ?? new Dictionary() }); - } return plan; } @@ -117,4 +113,4 @@ public sealed class DemoConversationBroker(JiboInteractionService interactionSer _ => true }; } -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/HouseholdListOrchestrator.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/HouseholdListOrchestrator.cs index f3b561e..c5fcdae 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/HouseholdListOrchestrator.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/HouseholdListOrchestrator.cs @@ -1,6 +1,5 @@ using Jibo.Cloud.Application.Abstractions; using Jibo.Runtime.Abstractions; -using System.Linq; namespace Jibo.Cloud.Application.Services; @@ -14,6 +13,32 @@ internal static class HouseholdListOrchestrator private const string IdleState = "idle"; private const string AwaitingItemState = "awaiting_item"; + private static readonly string[] ItemPrefixes = + [ + "add ", + "put ", + "buy ", + "get ", + "remind me to ", + "i need to ", + "i need ", + "please add ", + "please put " + ]; + + private static readonly string[] ItemSuffixes = + [ + " to my shopping list", + " to the shopping list", + " on my shopping list", + " to my to do list", + " to the to do list", + " on my to do list", + " to my todo list", + " to the todo list", + " on my todo list" + ]; + public static Task TryBuildDecisionAsync( TurnContext turn, string semanticIntent, @@ -31,40 +56,29 @@ internal static class HouseholdListOrchestrator var isTodoIntent = string.Equals(semanticIntent, "todo_list", StringComparison.OrdinalIgnoreCase); if (!isActiveState && !isShoppingIntent && !isTodoIntent) - { return Task.FromResult(null); - } var resolvedListType = isShoppingIntent ? "shopping" : isTodoIntent ? "todo" : NormalizeListType(listType); - if (string.IsNullOrWhiteSpace(resolvedListType)) - { - resolvedListType = "shopping"; - } + if (string.IsNullOrWhiteSpace(resolvedListType)) resolvedListType = "shopping"; var tenantScope = tenantScopeResolver(turn); if (ContainsAny(loweredTranscript, "cancel", "stop", "never mind", "nevermind", "forget it")) - { return Task.FromResult(BuildCancelledDecision(resolvedListType)); - } if (IsRecallRequest(loweredTranscript)) - { return Task.FromResult(BuildRecallDecision( resolvedListType, personalMemoryStore.GetListItems(tenantScope, resolvedListType))); - } var directItem = TryExtractListItem(loweredTranscript); if (string.IsNullOrWhiteSpace(directItem) && isActiveState) { if (IsConversationComplete(loweredTranscript)) - { return Task.FromResult(new JiboInteractionDecision( resolvedListType == "shopping" ? "shopping_list_done" : "todo_list_done", BuildDoneReply(resolvedListType, personalMemoryStore.GetListItems(tenantScope, resolvedListType)), ContextUpdates: BuildContextUpdates(resolvedListType, IdleState))); - } directItem = NormalizeItem(transcript); } @@ -74,17 +88,16 @@ internal static class HouseholdListOrchestrator personalMemoryStore.AddListItem(tenantScope, resolvedListType, directItem); return Task.FromResult(new JiboInteractionDecision( resolvedListType == "shopping" ? "shopping_list_add" : "todo_list_add", - BuildAddedReply(resolvedListType, directItem, personalMemoryStore.GetListItems(tenantScope, resolvedListType)), + BuildAddedReply(resolvedListType, directItem, + personalMemoryStore.GetListItems(tenantScope, resolvedListType)), ContextUpdates: BuildContextUpdates(resolvedListType, AwaitingItemState))); } if (string.IsNullOrWhiteSpace(transcript)) - { return Task.FromResult(new JiboInteractionDecision( resolvedListType == "shopping" ? "shopping_list_prompt" : "todo_list_prompt", BuildPromptReply(resolvedListType), ContextUpdates: BuildContextUpdates(resolvedListType, AwaitingItemState))); - } return Task.FromResult(new JiboInteractionDecision( resolvedListType == "shopping" ? "shopping_list_prompt" : "todo_list_prompt", @@ -120,7 +133,6 @@ internal static class HouseholdListOrchestrator private static JiboInteractionDecision BuildRecallDecision(string listType, IReadOnlyList items) { if (items.Count == 0) - { return new JiboInteractionDecision( listType == "shopping" ? "shopping_list_recall" : "todo_list_recall", listType == "shopping" @@ -133,7 +145,6 @@ internal static class HouseholdListOrchestrator [NoMatchCountMetadataKey] = 0, [NoInputCountMetadataKey] = 0 }); - } return new JiboInteractionDecision( listType == "shopping" ? "shopping_list_recall" : "todo_list_recall", @@ -167,11 +178,9 @@ internal static class HouseholdListOrchestrator private static string BuildDoneReply(string listType, IReadOnlyList items) { if (items.Count == 0) - { return listType == "shopping" ? "Okay. Your shopping list is empty." : "Okay. Your to-do list is empty."; - } return listType == "shopping" ? $"Okay. Your shopping list has {JoinList(items)}." @@ -193,10 +202,7 @@ internal static class HouseholdListOrchestrator { foreach (var prefix in ItemPrefixes) { - if (!loweredTranscript.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - { - continue; - } + if (!loweredTranscript.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue; var remainder = loweredTranscript[prefix.Length..].Trim(); remainder = TrimTrailingListPhrases(remainder); @@ -224,12 +230,8 @@ internal static class HouseholdListOrchestrator { var result = value; foreach (var suffix in ItemSuffixes) - { if (result.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) - { result = result[..^suffix.Length].Trim(); - } - } return result; } @@ -242,9 +244,11 @@ internal static class HouseholdListOrchestrator private static string NormalizeListType(string? listType) { var normalized = NormalizeItem(listType ?? string.Empty).ToLowerInvariant(); - return normalized.Contains("todo", StringComparison.OrdinalIgnoreCase) || normalized.Contains("to do", StringComparison.OrdinalIgnoreCase) + return normalized.Contains("todo", StringComparison.OrdinalIgnoreCase) || + normalized.Contains("to do", StringComparison.OrdinalIgnoreCase) ? "todo" - : normalized.Contains("shopping", StringComparison.OrdinalIgnoreCase) || normalized.Contains("grocery", StringComparison.OrdinalIgnoreCase) + : normalized.Contains("shopping", StringComparison.OrdinalIgnoreCase) || + normalized.Contains("grocery", StringComparison.OrdinalIgnoreCase) ? "shopping" : string.Empty; } @@ -270,30 +274,4 @@ internal static class HouseholdListOrchestrator { return turn.Attributes.TryGetValue(key, out var value) ? value?.ToString() : null; } - - private static readonly string[] ItemPrefixes = - [ - "add ", - "put ", - "buy ", - "get ", - "remind me to ", - "i need to ", - "i need ", - "please add ", - "please put " - ]; - - private static readonly string[] ItemSuffixes = - [ - " to my shopping list", - " to the shopping list", - " on my shopping list", - " to my to do list", - " to the to do list", - " on my to do list", - " to my todo list", - " to the todo list", - " on my todo list" - ]; -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/IJiboRandomizer.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/IJiboRandomizer.cs index 1ae794b..adc70da 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/IJiboRandomizer.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/IJiboRandomizer.cs @@ -13,4 +13,4 @@ public sealed class DefaultJiboRandomizer : IJiboRandomizer ? throw new InvalidOperationException("Cannot choose from an empty list.") : items[Random.Shared.Next(items.Count)]; } -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboCloudProtocolService.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboCloudProtocolService.cs index ad7a74f..b70182b 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboCloudProtocolService.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboCloudProtocolService.cs @@ -14,97 +14,68 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) "localhost" ]; - public Task DispatchAsync(ProtocolEnvelope envelope, CancellationToken cancellationToken = default) + public Task DispatchAsync(ProtocolEnvelope envelope, + CancellationToken cancellationToken = default) { if (envelope.Method.Equals("GET", StringComparison.OrdinalIgnoreCase) && envelope.Path == "/" && string.IsNullOrWhiteSpace(envelope.ServicePrefix)) - { return Task.FromResult(ProtocolDispatchResult.NoContent()); - } if (envelope.Method.Equals("GET", StringComparison.OrdinalIgnoreCase) && envelope.Path.Equals("/health", StringComparison.OrdinalIgnoreCase)) - { return Task.FromResult(ProtocolDispatchResult.Ok(new { ok = true, host = envelope.HostName })); - } if (envelope.Method.Equals("GET", StringComparison.OrdinalIgnoreCase) && envelope.Path.StartsWith("/media/", StringComparison.OrdinalIgnoreCase)) - { return Task.FromResult(HandleMediaContent(envelope)); - } if (envelope.Method.Equals("PUT", StringComparison.OrdinalIgnoreCase) && (envelope.Path.Equals("/upload/asr-binary", StringComparison.OrdinalIgnoreCase) || envelope.Path.Equals("/upload/log-events", StringComparison.OrdinalIgnoreCase) || envelope.Path.Equals("/upload/log-binary", StringComparison.OrdinalIgnoreCase))) - { return Task.FromResult(ProtocolDispatchResult.Raw(200, string.Empty)); - } if (!AcceptedHosts.Contains(envelope.HostName, StringComparer.OrdinalIgnoreCase)) - { return Task.FromResult(ProtocolDispatchResult.Ok(new { ok = true, accepted = false, host = envelope.HostName })); - } var servicePrefix = envelope.ServicePrefix ?? string.Empty; var operation = envelope.Operation ?? string.Empty; if (servicePrefix.StartsWith("Log_", StringComparison.OrdinalIgnoreCase)) - { return Task.FromResult(HandleLog(operation, envelope)); - } if (servicePrefix.StartsWith("Backup_", StringComparison.OrdinalIgnoreCase)) - { return Task.FromResult(HandleBackup(operation)); - } if (servicePrefix.StartsWith("Account_", StringComparison.OrdinalIgnoreCase)) - { return Task.FromResult(HandleAccount(operation, envelope)); - } if (servicePrefix.StartsWith("Notification_", StringComparison.OrdinalIgnoreCase)) - { return Task.FromResult(HandleNotification(operation, envelope)); - } if (servicePrefix.StartsWith("Loop_", StringComparison.OrdinalIgnoreCase)) - { return Task.FromResult(HandleLoop(operation)); - } if (servicePrefix.Equals("Media_20160725", StringComparison.OrdinalIgnoreCase)) - { return Task.FromResult(HandleMedia(operation, envelope)); - } if (servicePrefix.StartsWith("Key_", StringComparison.OrdinalIgnoreCase)) - { return Task.FromResult(HandleKey(operation, envelope)); - } if (servicePrefix.StartsWith("Person_", StringComparison.OrdinalIgnoreCase)) - { return Task.FromResult(HandlePerson(operation)); - } if (servicePrefix.StartsWith("Robot_", StringComparison.OrdinalIgnoreCase)) - { return Task.FromResult(HandleRobot(operation, envelope)); - } if (servicePrefix.StartsWith("Update_", StringComparison.OrdinalIgnoreCase)) - { return Task.FromResult(HandleUpdate(operation, envelope)); - } return Task.FromResult(ProtocolDispatchResult.Ok(new { @@ -122,22 +93,18 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) var body = envelope.TryParseBody(); if (operation.Equals("CreateHubToken", StringComparison.OrdinalIgnoreCase)) - { return ProtocolDispatchResult.Ok(new { token = stateStore.IssueHubToken(), expires = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds() }); - } if (operation.Equals("CreateAccessToken", StringComparison.OrdinalIgnoreCase)) - { return ProtocolDispatchResult.Ok(new { token = $"access-{account.AccountId}-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}", expires = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds() }); - } if (operation.Equals("CheckEmail", StringComparison.OrdinalIgnoreCase)) { @@ -149,7 +116,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) } if (operation is "Create" or "Login") - { return ProtocolDispatchResult.Ok(new { id = account.AccountId, @@ -168,17 +134,13 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) facebookConnected = false, termsAccepted = true }); - } if (operation.Equals("Get", StringComparison.OrdinalIgnoreCase)) { var ids = ReadStringArray(body, "ids"); var matches = ids.Count == 0 || ids.Contains(account.AccountId, StringComparer.OrdinalIgnoreCase); - if (!matches) - { - return ProtocolDispatchResult.Ok(Array.Empty()); - } + if (!matches) return ProtocolDispatchResult.Ok(Array.Empty()); return ProtocolDispatchResult.Ok(new[] { @@ -216,7 +178,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) } if (operation.Equals("GetAccountByAccessToken", StringComparison.OrdinalIgnoreCase)) - { return ProtocolDispatchResult.Ok(new { id = account.AccountId, @@ -226,12 +187,12 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) friendlyId = stateStore.GetRobot().RobotId, payload = ReadObject(body, "payload") }); - } if (operation.Equals("Search", StringComparison.OrdinalIgnoreCase)) { var query = (ReadString(body, "query") ?? string.Empty).ToLowerInvariant(); - var haystack = $"{account.Email} {account.FirstName} {account.LastName} {account.AccountId}".ToLowerInvariant(); + var haystack = $"{account.Email} {account.FirstName} {account.LastName} {account.AccountId}" + .ToLowerInvariant(); return ProtocolDispatchResult.Ok(query.Length > 0 && haystack.Contains(query) ? @@ -248,7 +209,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) } if (operation.Equals("FacebookPrepareLogin", StringComparison.OrdinalIgnoreCase)) - { return ProtocolDispatchResult.Ok(new { url = "https://example.com/facebook-login", @@ -258,12 +218,9 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) state = $"fb-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}", redirect_uri = "https://api.jibo.com/facebook/callback" }); - } if (operation.Equals("ConfirmEmailReset", StringComparison.OrdinalIgnoreCase)) - { return ProtocolDispatchResult.Ok(new { }); - } return ProtocolDispatchResult.Ok(new { @@ -277,9 +234,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) private ProtocolDispatchResult HandleNotification(string operation, ProtocolEnvelope envelope) { if (!operation.Equals("NewRobotToken", StringComparison.OrdinalIgnoreCase)) - { return ProtocolDispatchResult.Ok(new { ok = true, operation }); - } var body = envelope.TryParseBody(); var deviceId = !string.IsNullOrWhiteSpace(envelope.DeviceId) @@ -302,10 +257,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) private ProtocolDispatchResult HandleLoop(string operation) { - if (operation is not ("List" or "ListLoops")) - { - return ProtocolDispatchResult.Ok(Array.Empty()); - } + if (operation is not ("List" or "ListLoops")) return ProtocolDispatchResult.Ok(Array.Empty()); return ProtocolDispatchResult.Ok(stateStore.GetLoops().Select(loop => new { @@ -363,41 +315,35 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) var body = envelope.TryParseBody(); if (operation.Equals("List", StringComparison.OrdinalIgnoreCase)) - { return ProtocolDispatchResult.Ok(stateStore.ListMedia( ReadStringArray(body, "loopIds"), ReadLong(body, "after"), ReadLong(body, "before")).Select(MapMedia).ToArray()); - } if (operation.Equals("Get", StringComparison.OrdinalIgnoreCase)) - { - return ProtocolDispatchResult.Ok(stateStore.GetMedia(ReadStringArray(body, "paths")).Select(MapMedia).ToArray()); - } + return ProtocolDispatchResult.Ok(stateStore.GetMedia(ReadStringArray(body, "paths")).Select(MapMedia) + .ToArray()); if (operation.Equals("Remove", StringComparison.OrdinalIgnoreCase)) - { - return ProtocolDispatchResult.Ok(stateStore.RemoveMedia(ReadStringArray(body, "paths")).Select(MapMedia).ToArray()); - } + return ProtocolDispatchResult.Ok(stateStore.RemoveMedia(ReadStringArray(body, "paths")).Select(MapMedia) + .ToArray()); if (!operation.Equals("Create", StringComparison.OrdinalIgnoreCase)) return ProtocolDispatchResult.Ok(Array.Empty()); var loopId = ReadHeader(envelope, "x-loop-id") ?? ReadString(body, "loopId") ?? stateStore.GetLoops()[0].LoopId; - var path = ReadHeader(envelope, "x-path") ?? ReadString(body, "path") ?? $"/media/{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; + var path = ReadHeader(envelope, "x-path") ?? + ReadString(body, "path") ?? $"/media/{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; var type = ReadHeader(envelope, "x-type") ?? ReadString(body, "type") ?? "unknown"; var reference = ReadHeader(envelope, "x-reference") ?? ReadString(body, "reference") ?? string.Empty; var isEncrypted = ReadBooleanHeader(envelope, "x-encrypted") || ReadBool(body, "isEncrypted"); var meta = ReadObject(body, "meta") ?? new Dictionary(StringComparer.OrdinalIgnoreCase); var contentType = ReadHeader(envelope, "Content-Type") ?? "application/octet-stream"; meta["contentType"] = contentType; - if (!string.IsNullOrWhiteSpace(envelope.BodyText)) - { - meta["bodyText"] = envelope.BodyText; - } - - return ProtocolDispatchResult.Ok(MapMedia(stateStore.CreateMedia(loopId, path, type, reference, isEncrypted, meta))); + if (!string.IsNullOrWhiteSpace(envelope.BodyText)) meta["bodyText"] = envelope.BodyText; + return ProtocolDispatchResult.Ok( + MapMedia(stateStore.CreateMedia(loopId, path, type, reference, isEncrypted, meta))); } private ProtocolDispatchResult HandlePerson(string operation) @@ -420,12 +366,10 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) var loopId = ReadString(body, "loopId") ?? ReadString(body, "id") ?? stateStore.GetLoops()[0].LoopId; if (operation.Equals("ShouldCreate", StringComparison.OrdinalIgnoreCase)) - { return ProtocolDispatchResult.Ok(new { shouldCreate = stateStore.ShouldCreateSymmetricKey(loopId) }); - } string? symmetricKey; if (operation.Equals("CreateSymmetricKey", StringComparison.OrdinalIgnoreCase)) @@ -451,24 +395,17 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) } if (operation.Equals("GetRequest", StringComparison.OrdinalIgnoreCase)) - { - return ProtocolDispatchResult.Ok(stateStore.GetKeyRequest(loopId, ReadString(body, "id"), ReadString(body, "publicKey"))); - } + return ProtocolDispatchResult.Ok(stateStore.GetKeyRequest(loopId, ReadString(body, "id"), + ReadString(body, "publicKey"))); if (operation.Equals("ListIncomingRequests", StringComparison.OrdinalIgnoreCase)) - { return ProtocolDispatchResult.Ok(stateStore.GetIncomingKeyRequests()); - } if (operation.Equals("ListBinaryRequests", StringComparison.OrdinalIgnoreCase)) - { return ProtocolDispatchResult.Ok(stateStore.GetBinaryRequests()); - } if (operation is "Share" or "ShareSymmetricKey" or "ShareBinary") - { return ProtocolDispatchResult.Ok(new { ok = true }); - } if (!operation.Equals("LoadSymmetricKey", StringComparison.OrdinalIgnoreCase)) return ProtocolDispatchResult.Ok(new { ok = true, operation }); @@ -480,7 +417,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) key = symmetricKey, symmetricKey }); - } private ProtocolDispatchResult HandleRobot(string operation, ProtocolEnvelope envelope) @@ -521,7 +457,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) updated = profile.UpdatedUtc.ToUnixTimeMilliseconds(), created = profile.CreatedUtc.ToUnixTimeMilliseconds() }); - } private ProtocolDispatchResult HandleUpdate(string operation, ProtocolEnvelope envelope) @@ -533,9 +468,11 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) return operation switch { - "ListUpdates" => ProtocolDispatchResult.Ok(stateStore.ListUpdates(subsystem, filter).Select(MapUpdate).ToArray()), + "ListUpdates" => ProtocolDispatchResult.Ok(stateStore.ListUpdates(subsystem, filter).Select(MapUpdate) + .ToArray()), "ListUpdatesFrom" => ProtocolDispatchResult.Ok(stateStore.ListUpdates(subsystem, filter) - .Where(update => fromVersion is null || update.FromVersion.Equals(fromVersion, StringComparison.OrdinalIgnoreCase)) + .Where(update => + fromVersion is null || update.FromVersion.Equals(fromVersion, StringComparison.OrdinalIgnoreCase)) .Select(MapUpdate) .ToArray()), "GetUpdateFrom" => HandleGetUpdateFrom(subsystem, fromVersion, filter), @@ -558,10 +495,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) var path = Uri.UnescapeDataString(envelope.Path["/media/".Length..]); var candidatePaths = new[] { path, $"/{path}" }; var media = stateStore.GetMedia(candidatePaths).FirstOrDefault(); - if (media is null || media.IsDeleted) - { - return ProtocolDispatchResult.Raw(404, string.Empty); - } + if (media is null || media.IsDeleted) return ProtocolDispatchResult.Raw(404, string.Empty); var contentType = TryReadMetaString(media.Meta, "contentType") ?? "application/octet-stream"; var bodyText = TryReadMetaString(media.Meta, "bodyText") ?? string.Empty; @@ -623,10 +557,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) private static string? ReadString(JsonElement? element, string propertyName) { - if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) - { - return null; - } + if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) return null; return property.ValueKind == JsonValueKind.String ? property.GetString() @@ -635,25 +566,16 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) private static long? ReadLong(JsonElement? element, string propertyName) { - if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) - { - return null; - } + if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) return null; - if (property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out var number)) - { - return number; - } + if (property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out var number)) return number; return long.TryParse(property.ToString(), out var parsed) ? parsed : null; } private static bool ReadBool(JsonElement? element, string propertyName) { - if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) - { - return false; - } + if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) return false; return property.ValueKind switch { @@ -665,31 +587,26 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) private static IReadOnlyList ReadStringArray(JsonElement? element, string propertyName) { - if (element is null || !element.Value.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Array) - { - return []; - } + if (element is null || !element.Value.TryGetProperty(propertyName, out var property) || + property.ValueKind != JsonValueKind.Array) return []; - return [.. property.EnumerateArray() - .Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() ?? string.Empty : item.ToString()) - .Where(item => !string.IsNullOrWhiteSpace(item))]; + return + [ + .. property.EnumerateArray() + .Select(item => + item.ValueKind == JsonValueKind.String ? item.GetString() ?? string.Empty : item.ToString()) + .Where(item => !string.IsNullOrWhiteSpace(item)) + ]; } private static IDictionary? ReadObject(JsonElement? element, string propertyName) { - if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) - { - return null; - } + if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) return null; - if (property.ValueKind != JsonValueKind.Object) - { - return null; - } + if (property.ValueKind != JsonValueKind.Object) return null; var result = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var child in property.EnumerateObject()) - { result[child.Name] = child.Value.ValueKind switch { JsonValueKind.String => child.Value.GetString(), @@ -699,7 +616,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) JsonValueKind.False => false, _ => child.Value.ToString() }; - } return result; } @@ -715,4 +631,4 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) bool.TryParse(value, out var parsed) && parsed; } -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboExperienceContentCache.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboExperienceContentCache.cs index ac00780..c19cd61 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboExperienceContentCache.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboExperienceContentCache.cs @@ -9,10 +9,7 @@ public sealed class JiboExperienceContentCache(IJiboExperienceContentRepository public async Task GetCatalogAsync(CancellationToken cancellationToken = default) { - if (_catalog is not null) - { - return _catalog; - } + if (_catalog is not null) return _catalog; await _gate.WaitAsync(cancellationToken); try @@ -25,4 +22,4 @@ public sealed class JiboExperienceContentCache(IJiboExperienceContentRepository _gate.Release(); } } -} +} \ No newline at end of file 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 4b99d97..9cd501d 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 @@ -1,8 +1,8 @@ -using Jibo.Cloud.Application.Abstractions; -using Jibo.Runtime.Abstractions; using System.Globalization; using System.Text.Json; using System.Text.RegularExpressions; +using Jibo.Cloud.Application.Abstractions; +using Jibo.Runtime.Abstractions; namespace Jibo.Cloud.Application.Services; @@ -13,5505 +13,14 @@ public sealed class JiboInteractionService( IWeatherReportProvider? weatherReportProvider = null, INewsBriefingProvider? newsBriefingProvider = null) { - public async Task BuildDecisionAsync(TurnContext turn, CancellationToken cancellationToken = default) - { - var catalog = await contentCache.GetCatalogAsync(cancellationToken); - var transcript = (turn.NormalizedTranscript ?? turn.RawTranscript ?? string.Empty).Trim(); - var lowered = transcript.ToLowerInvariant(); - var referenceLocalTime = TryResolveReferenceLocalTime(turn); - var messageType = turn.Attributes.TryGetValue("messageType", out var rawMessageType) - ? rawMessageType?.ToString() - : null; - var triggerSource = turn.Attributes.TryGetValue("triggerSource", out var rawTriggerSource) - ? rawTriggerSource?.ToString() - : null; - var clientIntent = turn.Attributes.TryGetValue("clientIntent", out var rawClientIntent) - ? rawClientIntent?.ToString() - : null; - var clientRules = ReadRules(turn, "clientRules").ToArray(); - var listenRules = ReadRules(turn, "listenRules").ToArray(); - var listenAsrHints = ReadRules(turn, "listenAsrHints").ToArray(); - var clientEntities = ReadEntities(turn); - var lastClockDomain = turn.Attributes.TryGetValue("lastClockDomain", out var rawLastClockDomain) - ? rawLastClockDomain?.ToString() - : null; - var pendingProactivityOffer = turn.Attributes.TryGetValue("pendingProactivityOffer", out var rawPendingProactivityOffer) - ? rawPendingProactivityOffer?.ToString() - : null; - var chitchatEmotion = turn.Attributes.TryGetValue(ChitchatStateMachine.EmotionMetadataKey, out var rawChitchatEmotion) - ? rawChitchatEmotion?.ToString() - : null; - var isYesNoTurn = IsYesNoTurn(turn); - var greetingPresence = ResolveGreetingPresenceProfile(turn); - - if (string.Equals(messageType, "TRIGGER", StringComparison.OrdinalIgnoreCase)) - { - if (ShouldHandleProactiveGreetingTrigger(turn, triggerSource, greetingPresence)) - { - return BuildProactiveGreetingDecision(turn, greetingPresence, referenceLocalTime); - } - - return BuildTriggerIgnoredDecision(); - } - - var isTimerValueTurn = IsClockTimerValueTurn(clientRules, listenRules); - var isAlarmValueTurn = IsClockAlarmValueTurn(clientRules, listenRules); - var semanticIntent = ResolveSemanticIntent( - lowered, - referenceLocalTime, - clientIntent, - clientRules, - listenRules, - clientEntities, - lastClockDomain, - pendingProactivityOffer, - isYesNoTurn, - isTimerValueTurn, - isAlarmValueTurn); - - var personalReportDecision = await PersonalReportOrchestrator.TryBuildDecisionAsync( - turn, - semanticIntent, - transcript, - lowered, - catalog, - randomizer, - personalMemoryStore, - BuildWeatherReportDecisionAsync, - turnContext => ResolveTenantScope(turnContext), - cancellationToken); - if (personalReportDecision is not null) - { - return personalReportDecision; - } - - var householdListDecision = await HouseholdListOrchestrator.TryBuildDecisionAsync( - turn, - semanticIntent, - transcript, - lowered, - randomizer, - personalMemoryStore, - turnContext => ResolveTenantScope(turnContext)); - if (householdListDecision is not null) - { - return householdListDecision; - } - - var chitchatDecision = ChitchatStateMachine.TryBuildDecision( - semanticIntent, - transcript, - lowered, - catalog, - randomizer, - chitchatEmotion, - () => BuildGenericReply(catalog, transcript, lowered)); - if (chitchatDecision is not null) - { - return chitchatDecision; - } - - return semanticIntent switch - { - "joke" => BuildJokeDecision(catalog), - "dance_question" => BuildDanceQuestionDecision(catalog), - "dance" => BuildRandomDanceDecision(catalog), - "twerk" => BuildDanceDecision("twerk", "rom-twerk", "Watch me twerk."), - "time" => BuildClockLaunchDecision("time", "clock", "askForTime", "Showing the time."), - "date" => BuildClockLaunchDecision("date", "clock", "askForDate", "Showing the date."), - "day" => BuildClockLaunchDecision("day", "clock", "askForDay", "Showing the day."), - "cloud_version" => BuildCloudVersionDecision(), - "radio" => BuildRadioLaunchDecision(), - "radio_genre" => BuildRadioGenreLaunchDecision(lowered), - "stop" => BuildStopDecision(), - "volume_up" => BuildVolumeControlDecision("volume_up", "volumeUp", "null"), - "volume_down" => BuildVolumeControlDecision("volume_down", "volumeDown", "null"), - "volume_to_value" => BuildVolumeControlDecision("volume_to_value", "volumeToValue", ResolveVolumeLevel(lowered, clientEntities) ?? "7"), - "volume_query" => BuildSettingsVolumeDecision(), - "clock_open" => BuildClockLaunchDecision("clock_open", "clock", "askForTime", "Opening the clock."), - "clock_menu" => BuildClockLaunchDecision("clock_menu", "clock", "menu", "Opening the clock menu."), - "timer_menu" => BuildClockLaunchDecision("timer", "Opening the timer."), - "alarm_menu" => BuildClockLaunchDecision("alarm", "Opening the alarm."), - "timer_delete" => BuildClockLaunchDecision("timer_delete", "timer", "delete", "Canceling the timer."), - "alarm_delete" => BuildClockLaunchDecision("alarm_delete", "alarm", "delete", "Canceling the alarm."), - "timer_cancel" => BuildClockLaunchDecision("timer_cancel", "timer", "cancel", "Canceling the timer."), - "alarm_cancel" => BuildClockLaunchDecision("alarm_cancel", "alarm", "cancel", "Canceling the alarm."), - "timer_value" => BuildTimerValueDecision(lowered, isTimerValueTurn, clientEntities), - "alarm_value" => BuildAlarmValueDecision(lowered, isAlarmValueTurn, referenceLocalTime, clientEntities), - "timer_clarify" => BuildClockClarifyDecision("timer_clarify", "timer", "How long should I set the timer for?"), - "alarm_clarify" => BuildClockClarifyDecision("alarm_clarify", "alarm", "What time should I set the alarm for?"), - "photo_gallery" => BuildPhotoGalleryLaunchDecision(), - "snapshot" => BuildPhotoCreateDecision("snapshot", "Taking a picture.", "createOnePhoto"), - "photobooth" => BuildPhotoCreateDecision("photobooth", "Starting photobooth.", "createSomePhotos"), - "robot_age" => BuildRobotAgeDecision(referenceLocalTime), - "robot_birthday" => BuildRobotBirthdayDecision(), - "robot_how_do_you_work" => BuildScriptedPersonalityDecision( - catalog, - "robot_how_do_you_work", - "community's work", - "care for me", - "catch up", - "seven years"), - "robot_what_do_you_eat" => new JiboInteractionDecision( - "robot_what_do_you_eat", - "The only thing I consume is electricity.", - ContextUpdates: BuildScriptedResponseContextUpdates()), - "robot_where_do_you_live" => BuildScriptedPersonalityDecision( - catalog, - "robot_where_do_you_live", - "we're in my home", - "my home is here", - "planet earth", - "my home is the planet earth"), - "robot_where_were_you_born" => BuildScriptedPersonalityDecision( - catalog, - "robot_where_were_you_born", - "factory piece by piece", - "put together in a factory"), - "robot_what_languages_do_you_speak" => BuildScriptedPersonalityDecision( - catalog, - "robot_what_languages_do_you_speak", - "just english", - "someday i'd like to learn more"), - "robot_what_do_you_like_to_do" => BuildScriptedPersonalityDecision( - catalog, - "robot_what_do_you_like_to_do", - "being helpful", - "making people smile", - "like to dance", - "rock my boat", - "play ping pong", - "hanging out with people"), - "robot_what_are_you_thinking" => BuildScriptedGreetingDecision( - catalog, - "robot_what_are_you_thinking", - "thinking about how fun, yet scary", - "thinking about shoes", - "daydreaming about what it might feel like to be powered directly by the sun"), - "robot_what_have_you_been_doing" => BuildScriptedPersonalityDecision( - catalog, - "robot_what_have_you_been_doing", - "mostly roboting", - "keeping busy", - "fun things we can say to each other", - "thinking of fun things"), - "robot_what_did_you_do" => BuildScriptedPersonalityDecision( - catalog, - "robot_what_did_you_do", - "robot stuff", - "stayed here", - "looking around the room"), - "robot_is_kind" => BuildScriptedPersonalityDecision( - catalog, - "robot_is_kind", - "kindest robot i can be"), - "robot_is_funny" => BuildScriptedPersonalityDecision( - catalog, - "robot_is_funny", - "not intentionally", - "make people laugh"), - "robot_is_helpful" => BuildScriptedPersonalityDecision( - catalog, - "robot_is_helpful", - "highest priorities", - "being helpful to you"), - "robot_is_curious" => BuildScriptedPersonalityDecision( - catalog, - "robot_is_curious", - "learning new things"), - "robot_is_loyal" => BuildScriptedPersonalityDecision( - catalog, - "robot_is_loyal", - "loyal as they come"), - "robot_is_mischievous" => BuildScriptedPersonalityDecision( - catalog, - "robot_is_mischievous", - "don't really think of myself that way"), - "robot_is_likable" => BuildScriptedPersonalityDecision( - catalog, - "robot_is_likable", - "people like me"), - "seasonal_holiday_greeting" => BuildScriptedGreetingDecision( - catalog, - "seasonal_holiday_greeting", - "It's a fun time of year", - "And to you too", - "Right back at you"), - "seasonal_holidays" => BuildScriptedPersonalityDecision( - catalog, - "seasonal_holidays", - "official owner can tell me which ones we'll celebrate together", - "going to the jibo's settings screen in the jibo app"), - "seasonal_new_years_resolution" => BuildScriptedPersonalityDecision( - catalog, - "seasonal_new_years_resolution", - "always trying to learn new skills", - "not eat bacon", - "learn a bunch of new skills", - "learn to walk", - "recognizing people's faces and voices"), - "seasonal_new_years_update" => BuildScriptedPersonalityDecision( - catalog, - "seasonal_new_years_update", - "not eat bacon", - "learn some new skills", - "going well"), - "seasonal_halloween_costume" => BuildScriptedPersonalityDecision( - catalog, - "seasonal_halloween_costume", - "i haven't thought much about it yet", - "ask me again on halloween", - "you'll find out on halloween"), - "seasonal_first_day_spring" => BuildScriptedPersonalityDecision( - catalog, - "seasonal_first_day_spring", - "maybe enjoy some flowers and all things spring"), - "seasonal_holiday_gift" => BuildScriptedPersonalityDecision( - catalog, - "seasonal_holiday_gift", - "ask for a pet elephant", - "experience as a present", - "donate to charities in other people's names"), - "robot_favorite_flower" => BuildScriptedPersonalityDecision( - catalog, - "robot_favorite_flower", - "sunflowers", - "favorite is the sunflower", - "reminds me of the sun"), - "robot_likes_r2d2" => BuildScriptedPersonalityDecision( - catalog, - "robot_likes_r2d2", - "a legend. a true legend", - "of course i know r2d2"), - "robot_likes_sun" => BuildScriptedPersonalityDecision( - catalog, - "robot_likes_sun", - "favorite star in the universe", - "best star i know"), - "robot_likes_space" => BuildScriptedPersonalityDecision( - catalog, - "robot_likes_space", - "i love space", - "all things in space", - "amazing stuff up there", - "astronomy is one of my favorite onomies"), - "robot_likes_kids" => BuildScriptedPersonalityDecision( - catalog, - "robot_likes_kids", - "kids are so fun", - "they're a little closer to my size", - "i do like kids very much", - "the world is as funny and strange as i do"), - "robot_can_laugh" => BuildScriptedPersonalityDecision( - catalog, - "robot_can_laugh", - "i do things like this when i'm happy", - "i'm happy"), - "robot_can_dance" => BuildScriptedPersonalityDecision( - catalog, - "robot_can_dance", - "dancing is one of the things i know best", - "if there's one thing i know how to do. it's dance", - "i can dance"), - "robot_what_are_you_made_of" => new JiboInteractionDecision( - "robot_what_are_you_made_of", - "Let's see, I'm made of wires, motors, belts, gears, processors, cameras, and one baboon's heart in the middle of my body casing. I'm kidding about the baboon part, but everything else is true.", - ContextUpdates: BuildScriptedResponseContextUpdates()), - "good_morning" => BuildReactiveGreetingDecision(turn, "good_morning", referenceLocalTime), - "good_afternoon" => BuildReactiveGreetingDecision(turn, "good_afternoon", referenceLocalTime), - "good_evening" => BuildReactiveGreetingDecision(turn, "good_evening", referenceLocalTime), - "good_night" => BuildReactiveGreetingDecision(turn, "good_night", referenceLocalTime), - "welcome_back" => BuildScriptedGreetingDecision( - catalog, - "welcome_back", - "it's nice to be here", - "welcome back"), - "memory_set_name" => BuildRememberNameDecision(turn, transcript), - "memory_get_name" => BuildRecallNameDecision(turn, greetingPresence), - "memory_set_birthday" => BuildRememberBirthdayDecision(turn, transcript), - "memory_get_birthday" => BuildRecallBirthdayDecision(turn), - "memory_set_important_date" => BuildRememberImportantDateDecision(turn, transcript), - "memory_get_important_date" => BuildRecallImportantDateDecision(turn, transcript), - "memory_set_preference" => BuildRememberPreferenceDecision(turn, transcript), - "memory_get_preference" => BuildRecallPreferenceDecision(turn, transcript), - "memory_set_affinity" => BuildRememberAffinityDecision(turn, transcript), - "memory_get_affinity" => BuildRecallAffinityDecision(turn, transcript), - "pizza" => BuildPizzaDecision(), - "order_pizza" => BuildOrderPizzaDecision(), - "proactive_pizza_day" => BuildProactivePizzaDayDecision(referenceLocalTime), - "proactive_pizza_preference" => BuildProactivePizzaPreferenceDecision(), - "proactive_offer_pizza_fact" => BuildProactivePizzaFactOfferDecision(), - "proactive_pizza_fact" => BuildProactivePizzaFactDecision(), - "proactive_offer_declined" => BuildProactiveOfferDeclinedDecision(), - "weather" => await BuildWeatherReportDecisionAsync(turn, transcript, cancellationToken), - "yes" => new JiboInteractionDecision("yes", "Yes."), - "no" => new JiboInteractionDecision("no", "No."), - "word_of_the_day" => BuildWordOfTheDayLaunchDecision(), - "word_of_the_day_guess" => BuildWordOfTheDayGuessDecision(clientEntities, transcript, listenAsrHints), - "surprise" => BuildSurpriseDecision(catalog, turn, referenceLocalTime), - "personal_report" => new JiboInteractionDecision("personal_report", randomizer.Choose(catalog.PersonalReportReplies)), - "calendar" => new JiboInteractionDecision("calendar", randomizer.Choose(catalog.CalendarReplies)), - "commute" => new JiboInteractionDecision("commute", randomizer.Choose(catalog.CommuteReplies)), - "news" => await BuildNewsDecisionAsync(turn, transcript, catalog, cancellationToken), - _ => new JiboInteractionDecision("chat", BuildGenericReply(catalog, transcript, lowered)) - }; - } - - private static JiboInteractionDecision BuildCloudVersionDecision() - { - return new JiboInteractionDecision("cloud_version", OpenJiboCloudBuildInfo.SpokenVersion, - SkillPayload: new Dictionary { ["esml"] = OpenJiboCloudBuildInfo.EsmlVersion }); - } - - private static JiboInteractionDecision BuildRobotAgeDecision(DateTimeOffset? referenceLocalTime) - { - var referenceDate = DateOnly.FromDateTime((referenceLocalTime ?? DateTimeOffset.UtcNow).Date); - var ageDescription = DescribePersonaAge(referenceDate, OpenJiboCloudBuildInfo.PersonaBirthday); - return new JiboInteractionDecision( - "robot_age", - $"I count {OpenJiboCloudBuildInfo.PersonaBirthdayWords} as my birthday, so I am {ageDescription}."); - } - - private static JiboInteractionDecision BuildRobotBirthdayDecision() - { - return new JiboInteractionDecision( - "robot_birthday", - $"My birthday is {OpenJiboCloudBuildInfo.PersonaBirthdayWords}."); - } - - private static JiboInteractionDecision BuildTriggerIgnoredDecision() - { - return new JiboInteractionDecision( - "trigger_ignored", - string.Empty, - "chitchat-skill", - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["skillId"] = "chitchat-skill", - ["cloudResponseMode"] = "completion_only" - }); - } - - private JiboInteractionDecision BuildReactiveGreetingDecision( - TurnContext turn, - string greetingIntent, - DateTimeOffset? referenceLocalTime) - { - var presence = ResolveGreetingPresenceProfile(turn); - var displayName = ResolvePreferredGreetingName(turn, presence); - var replyText = BuildReactiveGreetingReply(greetingIntent, displayName, referenceLocalTime); - return new JiboInteractionDecision( - greetingIntent, - replyText, - ContextUpdates: BuildGreetingContextUpdates("ReactiveGreeting", presence.PrimaryPersonId, proactive: false)); - } - - private JiboInteractionDecision BuildProactiveGreetingDecision( - TurnContext turn, - GreetingPresenceProfile presence, - DateTimeOffset? referenceLocalTime) - { - var displayName = ResolvePreferredGreetingName(turn, presence); - var greetingPrefix = ResolveTimeOfDayGreetingPrefix(referenceLocalTime); - var replyText = string.IsNullOrWhiteSpace(displayName) - ? $"{greetingPrefix}. I am glad to see you." - : $"{greetingPrefix}, {displayName}. Welcome back."; - return new JiboInteractionDecision( - "proactive_greeting", - replyText, - ContextUpdates: BuildGreetingContextUpdates("ProactiveGreeting", presence.PrimaryPersonId, proactive: true)); - } - - private static string BuildReactiveGreetingReply( - string greetingIntent, - string? displayName, - DateTimeOffset? referenceLocalTime) - { - var namePrefix = string.IsNullOrWhiteSpace(displayName) - ? string.Empty - : $", {displayName}"; - - return greetingIntent switch - { - "good_morning" => $"Good morning{namePrefix}. It is great to see you.", - "good_afternoon" => $"Good afternoon{namePrefix}. I am glad you are here.", - "good_evening" => $"Good evening{namePrefix}. It is nice to have you back.", - "good_night" => $"Good night{namePrefix}. Sleep well.", - "welcome_back" => string.IsNullOrWhiteSpace(displayName) - ? $"Welcome back. {ResolveTimeOfDayGreetingPrefix(referenceLocalTime)}." - : $"Welcome back, {displayName}. {ResolveTimeOfDayGreetingPrefix(referenceLocalTime)}.", - _ => $"Hello{namePrefix}. It is nice to see you." - }; - } - - private string? ResolvePreferredGreetingName(TurnContext turn, GreetingPresenceProfile presence) - { - var rememberedName = personalMemoryStore.GetName(ResolveTenantScope(turn, presence.PrimaryPersonId)); - if (!string.IsNullOrWhiteSpace(rememberedName)) - { - return ToDisplayName(rememberedName); - } - - var tenantRememberedName = personalMemoryStore.GetName(ResolveTenantScope(turn)); - if (!string.IsNullOrWhiteSpace(tenantRememberedName)) - { - return ToDisplayName(tenantRememberedName); - } - - if (!string.IsNullOrWhiteSpace(presence.PrimaryPersonId) && - presence.LoopUserFirstNames.TryGetValue(presence.PrimaryPersonId, out var firstName) && - !string.IsNullOrWhiteSpace(firstName)) - { - return ToDisplayName(firstName); - } - - return null; - } - - private static string ToDisplayName(string value) - { - var trimmed = value.Trim(); - return string.IsNullOrWhiteSpace(trimmed) - ? string.Empty - : CultureInfo.InvariantCulture.TextInfo.ToTitleCase(trimmed); - } - - private static bool ShouldHandleProactiveGreetingTrigger( - TurnContext turn, - string? triggerSource, - GreetingPresenceProfile presence) - { - if (string.Equals(triggerSource, "SURPRISE", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - if (!presence.HasKnownIdentity) - { - return false; - } - - var lastGreetingUtc = ReadTimestampAttribute(turn, LastProactiveGreetingUtcMetadataKey); - return !lastGreetingUtc.HasValue || DateTimeOffset.UtcNow - lastGreetingUtc.Value >= ProactiveGreetingCooldown; - } - - private static DateTimeOffset? ReadTimestampAttribute(TurnContext turn, string key) - { - if (!turn.Attributes.TryGetValue(key, out var value) || value is null) - { - return null; - } - - return DateTimeOffset.TryParse( - value.ToString(), - CultureInfo.InvariantCulture, - DateTimeStyles.RoundtripKind, - out var parsed) - ? parsed - : null; - } - - private static IDictionary BuildGreetingContextUpdates(string route, string? speakerId, bool proactive) - { - var updates = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - [ChitchatStateMachine.StateMetadataKey] = "complete", - [ChitchatStateMachine.RouteMetadataKey] = "ScriptedResponse", - [ChitchatStateMachine.EmotionMetadataKey] = string.Empty, - [GreetingRouteMetadataKey] = route, - [GreetingSpeakerMetadataKey] = speakerId ?? string.Empty - }; - - updates[proactive ? LastProactiveGreetingUtcMetadataKey : LastReactiveGreetingUtcMetadataKey] = - DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture); - return updates; - } - - private static string ResolveTimeOfDayGreetingPrefix(DateTimeOffset? referenceLocalTime) - { - var hour = (referenceLocalTime ?? DateTimeOffset.UtcNow).Hour; - return hour switch - { - >= 5 and < 12 => "Good morning", - >= 12 and < 17 => "Good afternoon", - _ => "Good evening" - }; - } - - private JiboInteractionDecision BuildRememberNameDecision(TurnContext turn, string transcript) - { - var name = TryExtractNameFact(transcript); - if (string.IsNullOrWhiteSpace(name)) - { - return new JiboInteractionDecision( - "memory_set_name", - "I can remember it if you say, my name is Alex."); - } - - personalMemoryStore.SetName(ResolveTenantScope(turn), name); - return new JiboInteractionDecision( - "memory_set_name", - $"Nice to meet you, {name}. I will remember your name."); - } - - private JiboInteractionDecision BuildRecallNameDecision(TurnContext turn, GreetingPresenceProfile? presence = null) - { - var personScope = ResolveTenantScope(turn, presence?.PrimaryPersonId); - var name = personalMemoryStore.GetName(personScope); - if (string.IsNullOrWhiteSpace(name)) - { - name = personalMemoryStore.GetName(ResolveTenantScope(turn)); - } - - if (string.IsNullOrWhiteSpace(name) && - presence is not null && - !string.IsNullOrWhiteSpace(presence.PrimaryPersonId) && - presence.LoopUserFirstNames.TryGetValue(presence.PrimaryPersonId, out var firstName) && - !string.IsNullOrWhiteSpace(firstName)) - { - name = ToDisplayName(firstName); - } - - name = ToDisplayName(name ?? string.Empty); - - return string.IsNullOrWhiteSpace(name) - ? new JiboInteractionDecision( - "memory_get_name", - "I do not know your name yet. You can say, my name is Alex.") - : new JiboInteractionDecision( - "memory_get_name", - presence is not null && !string.IsNullOrWhiteSpace(presence.PrimaryPersonId) - ? $"I think you are {name}." - : $"You told me your name is {name}."); - } - - private JiboInteractionDecision BuildRememberBirthdayDecision(TurnContext turn, string transcript) - { - var birthday = TryExtractBirthdayFact(transcript); - if (string.IsNullOrWhiteSpace(birthday)) - { - return new JiboInteractionDecision( - "memory_set_birthday", - "I can remember it if you say, my birthday is March 14."); - } - - personalMemoryStore.SetBirthday(ResolveTenantScope(turn), birthday); - return new JiboInteractionDecision( - "memory_set_birthday", - $"Got it. I will remember your birthday is {birthday}."); - } - - private JiboInteractionDecision BuildRecallBirthdayDecision(TurnContext turn) - { - var birthday = personalMemoryStore.GetBirthday(ResolveTenantScope(turn)); - return string.IsNullOrWhiteSpace(birthday) - ? new JiboInteractionDecision( - "memory_get_birthday", - "I do not know your birthday yet. You can say, my birthday is March 14.") - : new JiboInteractionDecision( - "memory_get_birthday", - $"You told me your birthday is {birthday}."); - } - - private JiboInteractionDecision BuildRememberImportantDateDecision(TurnContext turn, string transcript) - { - var importantDate = TryExtractImportantDateSet(transcript); - if (importantDate is null) - { - return new JiboInteractionDecision( - "memory_set_important_date", - "I can remember it if you say, our anniversary is June 10."); - } - - personalMemoryStore.SetImportantDate(ResolveTenantScope(turn), importantDate.Value.Label, importantDate.Value.Value); - return new JiboInteractionDecision( - "memory_set_important_date", - $"Got it. I will remember your {importantDate.Value.Label} is {importantDate.Value.Value}."); - } - - private JiboInteractionDecision BuildRecallImportantDateDecision(TurnContext turn, string transcript) - { - var label = TryExtractImportantDateLookupLabel(transcript); - if (string.IsNullOrWhiteSpace(label)) - { - return new JiboInteractionDecision( - "memory_get_important_date", - "Ask me like this: when is our anniversary?"); - } - - var storedDate = personalMemoryStore.GetImportantDate(ResolveTenantScope(turn), label); - return string.IsNullOrWhiteSpace(storedDate) - ? new JiboInteractionDecision( - "memory_get_important_date", - $"I do not know your {label} yet.") - : new JiboInteractionDecision( - "memory_get_important_date", - $"You told me your {label} is {storedDate}."); - } - - private JiboInteractionDecision BuildRememberPreferenceDecision(TurnContext turn, string transcript) - { - var preference = TryExtractPreferenceSet(transcript); - if (preference is null) - { - return new JiboInteractionDecision( - "memory_set_preference", - "I can remember it if you say, my favorite music is jazz."); - } - - personalMemoryStore.SetPreference(ResolveTenantScope(turn), preference.Value.Category, preference.Value.Value); - return new JiboInteractionDecision( - "memory_set_preference", - $"Got it. I will remember your favorite {preference.Value.Category} is {preference.Value.Value}."); - } - - private JiboInteractionDecision BuildRecallPreferenceDecision(TurnContext turn, string transcript) - { - var category = TryExtractPreferenceLookupCategory(transcript); - if (string.IsNullOrWhiteSpace(category)) - { - return new JiboInteractionDecision( - "memory_get_preference", - "Ask me like this: what is my favorite music?"); - } - - var preference = personalMemoryStore.GetPreference(ResolveTenantScope(turn), category); - return string.IsNullOrWhiteSpace(preference) - ? new JiboInteractionDecision( - "memory_get_preference", - $"I do not know your favorite {category} yet.") - : new JiboInteractionDecision( - "memory_get_preference", - $"You told me your favorite {category} is {preference}."); - } - - private JiboInteractionDecision BuildRememberAffinityDecision(TurnContext turn, string transcript) - { - var affinitySet = TryExtractAffinitySet(transcript); - if (affinitySet is null) - { - return new JiboInteractionDecision( - "memory_set_affinity", - "I can remember it if you say, I like pizza or I dislike mushrooms."); - } - - personalMemoryStore.SetAffinity(ResolveTenantScope(turn), affinitySet.Value.Item, affinitySet.Value.Affinity); - return new JiboInteractionDecision( - "memory_set_affinity", - $"Got it. I will remember you {DescribeAffinityAsVerb(affinitySet.Value.Affinity)} {affinitySet.Value.Item}."); - } - - private JiboInteractionDecision BuildRecallAffinityDecision(TurnContext turn, string transcript) - { - var lookup = TryExtractAffinityLookup(transcript); - if (lookup is null) - { - return new JiboInteractionDecision( - "memory_get_affinity", - "Ask me like this: do I like pizza?"); - } - - var affinity = personalMemoryStore.GetAffinity(ResolveTenantScope(turn), lookup.Value.Item); - if (affinity is null) - { - return new JiboInteractionDecision( - "memory_get_affinity", - $"I do not remember how you feel about {lookup.Value.Item} yet."); - } - - if (lookup.Value.ExpectedAffinity is null) - { - return new JiboInteractionDecision( - "memory_get_affinity", - $"You told me you {DescribeAffinityAsVerb(affinity.Value)} {lookup.Value.Item}."); - } - - var matches = lookup.Value.ExpectedAffinity == PersonalAffinity.Dislike - ? affinity == PersonalAffinity.Dislike - : affinity is PersonalAffinity.Like or PersonalAffinity.Love; - - return matches - ? new JiboInteractionDecision( - "memory_get_affinity", - $"Yes. You told me you {DescribeAffinityAsVerb(affinity.Value)} {lookup.Value.Item}.") - : new JiboInteractionDecision( - "memory_get_affinity", - $"Not exactly. You told me you {DescribeAffinityAsVerb(affinity.Value)} {lookup.Value.Item}."); - } - - private JiboInteractionDecision BuildPizzaDecision() - { - return BuildPizzaAnimationDecision("pizza", "One pizza, coming right up."); - } - - private JiboInteractionDecision BuildPizzaAnimationDecision(string intentName, string replyText) - { - var prompt = randomizer.Choose(PizzaMimPrompts); - return new JiboInteractionDecision( - intentName, - replyText, - "chitchat-skill", - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["esml"] = prompt.Esml, - ["mim_id"] = "RA_JBO_MakePizza", - ["mim_type"] = "announcement", - ["prompt_id"] = prompt.PromptId, - ["prompt_sub_category"] = "AN" - }); - } - - private JiboInteractionDecision BuildProactivePizzaDayDecision(DateTimeOffset? referenceLocalTime) - { - var referenceDate = (referenceLocalTime ?? DateTimeOffset.UtcNow).Date; - return BuildPizzaAnimationDecision( - "proactive_pizza_day", - $"Happy National Pizza Day for {referenceDate.ToString("MMMM d", CultureInfo.InvariantCulture)}. One pizza, coming right up."); - } - - private JiboInteractionDecision BuildProactivePizzaPreferenceDecision() - { - return BuildPizzaAnimationDecision( - "proactive_pizza_preference", - "You mentioned pizza is a favorite, so I thought we should make one."); - } - - private static JiboInteractionDecision BuildProactivePizzaFactOfferDecision() - { - return new JiboInteractionDecision( - "proactive_offer_pizza_fact", - "Do you want to hear a fun pizza fact?", - "chitchat-skill", - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["mim_id"] = "runtime-chat", - ["mim_type"] = "question", - ["prompt_id"] = "RUNTIME_PROMPT", - ["prompt_sub_category"] = "Q", - ["listen_contexts"] = new[] { "shared/yes_no" } - }); - } - - private static JiboInteractionDecision BuildProactivePizzaFactDecision() - { - return new JiboInteractionDecision( - "proactive_pizza_fact", - "Americans consume about 100 acres of pizza every day, roughly 350 slices per second. That's a lot of pizza."); - } - - private static JiboInteractionDecision BuildProactiveOfferDeclinedDecision() - { - return new JiboInteractionDecision( - "proactive_offer_declined", - "No problem. We can save the pizza fact for another time."); - } - - private async Task BuildWeatherReportDecisionAsync( - TurnContext turn, - string transcript, - CancellationToken cancellationToken) - { - var referenceLocalTime = TryResolveReferenceLocalTime(turn); - var catalog = await contentCache.GetCatalogAsync(cancellationToken); - var normalizedTranscript = NormalizeCommandPhrase(transcript); - var locationQuery = TryResolveWeatherLocationQuery(transcript); - var weatherDate = ResolveWeatherDateEntity(turn, transcript, normalizedTranscript, referenceLocalTime); - var isRangeForecastRequest = IsRangeForecastRequest(normalizedTranscript); - var isOpenEndedForecastRequest = IsOpenEndedForecastRequest( - normalizedTranscript, - weatherDate, - isRangeForecastRequest, - locationQuery); - if (ShouldDefaultForecastToTomorrow( - normalizedTranscript, - weatherDate, - isRangeForecastRequest, - isOpenEndedForecastRequest)) - { - weatherDate = new WeatherDateEntity("tomorrow", 1, "Tomorrow"); - } - - if (weatherReportProvider is null) - { - return new JiboInteractionDecision( - "weather", - ChooseWeatherServiceDownReply(catalog)); - } - - var weatherCoordinates = string.IsNullOrWhiteSpace(locationQuery) - ? TryResolveWeatherCoordinates(turn) - : null; - var useCelsius = ShouldUseCelsius(turn, transcript); - var isNextWeekForecast = IsNextWeekForecastRequest(normalizedTranscript, isRangeForecastRequest); - var isThisWeekForecast = IsThisWeekForecastRequest(normalizedTranscript, isRangeForecastRequest); - - if (isNextWeekForecast || isThisWeekForecast || isOpenEndedForecastRequest) - { - var rangeStartOffset = 1; - var rangeEndOffset = isThisWeekForecast - ? ResolveThisWeekForecastEndOffset(referenceLocalTime) - : MaxWeatherForecastDayOffset; - var weeklySnapshots = new List<(int DayOffset, WeatherReportSnapshot Snapshot)>(); - for (var offset = rangeStartOffset; offset <= rangeEndOffset; offset += 1) - { - WeatherReportSnapshot? weeklySnapshot; - try - { - weeklySnapshot = await weatherReportProvider.GetReportAsync( - new WeatherReportRequest( - locationQuery, - weatherCoordinates?.Latitude, - weatherCoordinates?.Longitude, - IsTomorrow: offset == 1, - useCelsius, - ForecastDayOffset: offset), - cancellationToken); - } - catch (Exception) when (!cancellationToken.IsCancellationRequested) - { - weeklySnapshot = null; - } - - if (weeklySnapshot is not null) - { - weeklySnapshots.Add((offset, weeklySnapshot)); - } - } - - if (weeklySnapshots.Count == 0) - { - return new JiboInteractionDecision( - "weather", - "I couldn't fetch the weather right now. Please try again."); - } - - var weeklySegments = BuildWeeklyForecastCardSegments(weeklySnapshots, referenceLocalTime); - var weeklySpokenReply = BuildWeeklyForecastSpokenReply( - weeklySegments, - weeklySnapshots[0].Snapshot.LocationName, - weeklySnapshots[0].Snapshot.UseCelsius, - isThisWeekForecast); - var weeklyWeatherPayload = BuildWeeklyWeatherSkillPayload( - weeklySpokenReply, - weeklySnapshots[0].Snapshot, - weeklySegments, - referenceLocalTime); - AddWeatherRequestDiagnostics( - weeklyWeatherPayload, - transcript, - normalizedTranscript, - locationQuery, - weatherDate, - isRangeForecastRequest, - isThisWeekForecast, - isNextWeekForecast); - return new JiboInteractionDecision( - "weather", - weeklySpokenReply, - "chitchat-skill", - SkillPayload: weeklyWeatherPayload); - } - - if (weatherDate.ForecastDayOffset > MaxWeatherForecastDayOffset) - { - return new JiboInteractionDecision( - "weather", - $"I can forecast up to {MaxWeatherForecastDayOffset} days ahead. Try tomorrow or another day this week."); - } - WeatherReportSnapshot? snapshot; - try - { - snapshot = await weatherReportProvider.GetReportAsync( - new WeatherReportRequest( - locationQuery, - weatherCoordinates?.Latitude, - weatherCoordinates?.Longitude, - string.Equals(weatherDate.DateEntity, "tomorrow", StringComparison.OrdinalIgnoreCase), - useCelsius, - weatherDate.ForecastDayOffset), - cancellationToken); - } - catch (Exception) when (!cancellationToken.IsCancellationRequested) - { - snapshot = null; - } - - if (snapshot is null) - { - return new JiboInteractionDecision( - "weather", - ChooseWeatherServiceDownReply(catalog)); - } - - var spokenReply = BuildWeatherSpokenReply(snapshot, weatherDate, catalog); - var weatherPayload = BuildWeatherSkillPayload(spokenReply, snapshot, referenceLocalTime); - AddWeatherRequestDiagnostics( - weatherPayload, - transcript, - normalizedTranscript, - locationQuery, - weatherDate, - isRangeForecastRequest, - isThisWeekForecast, - isNextWeekForecast); - return new JiboInteractionDecision( - "weather", - spokenReply, - "chitchat-skill", - SkillPayload: weatherPayload); - } - - private static string BuildWeatherSpokenReply( - WeatherReportSnapshot snapshot, - WeatherDateEntity weatherDate, - JiboExperienceCatalog catalog) - { - var unit = snapshot.UseCelsius ? "Celsius" : "Fahrenheit"; - var summary = string.IsNullOrWhiteSpace(snapshot.Summary) - ? "partly cloudy" - : snapshot.Summary.Trim().TrimEnd('.'); - var location = string.IsNullOrWhiteSpace(snapshot.LocationName) - ? "your area" - : NormalizeLocationForSpeech(snapshot.LocationName); - - if (weatherDate.ForecastDayOffset > 0) - { - if (weatherDate.ForecastDayOffset != 1) - { - var highText = snapshot.HighTemperature is null - ? null - : $"a high near {snapshot.HighTemperature.Value} degrees {unit}"; - var lowText = snapshot.LowTemperature is null - ? null - : $"a low around {snapshot.LowTemperature.Value} degrees {unit}"; - var tempRange = highText is null && lowText is null - ? string.Empty - : highText is not null && lowText is not null - ? $" with {highText} and {lowText}" - : $" with {highText ?? lowText}"; - var forecastLeadIn = string.IsNullOrWhiteSpace(weatherDate.ForecastLeadIn) - ? "Tomorrow" - : weatherDate.ForecastLeadIn; - return $"Let's look at the weather. {forecastLeadIn} in {location}, it looks {summary}{tempRange}."; - } - - var highValue = snapshot.HighTemperature ?? snapshot.Temperature; - var lowValue = snapshot.LowTemperature ?? snapshot.Temperature; - var introTemplate = ChooseWeatherTemplate( - catalog.WeatherTomorrowIntroReplies, - "Let's look at the weather."); - var highLowTemplate = ChooseWeatherTemplate( - catalog.WeatherTomorrowHighLowReplies, - "Tomorrow's high will be ${skill.weather.tomorrow.highTemp} and the low will be ${skill.weather.tomorrow.lowTemp}."); - var intro = RenderWeatherTemplate( - introTemplate, - location, - summary, - highValue, - lowValue, - unit, - forecastLeadIn: weatherDate.ForecastLeadIn ?? string.Empty); - var highLow = RenderWeatherTemplate( - highLowTemplate, - location, - summary, - highValue, - lowValue, - unit, - forecastLeadIn: weatherDate.ForecastLeadIn ?? string.Empty); - var forecastSentenceLeadIn = string.IsNullOrWhiteSpace(weatherDate.ForecastLeadIn) - ? "Tomorrow" - : weatherDate.ForecastLeadIn; - return $"{intro} {forecastSentenceLeadIn} in {location}, it looks {summary}. {highLow}"; - } - - var currentIntro = RenderWeatherTemplate( - ChooseWeatherTemplate(catalog.WeatherIntroReplies, "For your weather."), - location, - summary, - snapshot.Temperature, - snapshot.Temperature, - unit, - forecastLeadIn: string.Empty); - var currentHighLow = RenderWeatherTemplate( - ChooseWeatherTemplate( - catalog.WeatherTodayHighLowReplies, - "Today's high is ${skill.weather.today.highTemp}, and the low is ${skill.weather.today.lowTemp}."), - location, - summary, - snapshot.HighTemperature ?? snapshot.Temperature, - snapshot.LowTemperature ?? snapshot.Temperature, - unit, - forecastLeadIn: string.Empty); - return $"{currentIntro} In {location}, it's {summary} and {snapshot.Temperature} degrees {unit}. {currentHighLow}"; - } - - private static string BuildWeeklyForecastSpokenReply( - IReadOnlyList segments, - string? locationName, - bool useCelsius, - bool isThisWeekForecast) - { - if (segments.Count == 0) - { - return "I couldn't build a forecast right now."; - } - - var location = string.IsNullOrWhiteSpace(locationName) - ? "your area" - : NormalizeLocationForSpeech(locationName); - var unit = useCelsius ? "Celsius" : "Fahrenheit"; - var leadIn = isThisWeekForecast - ? $"Here's the rest of this week's forecast in {location}." - : $"I can share the next five-day forecast in {location}."; - return $"{leadIn} {string.Join(" ", segments.Select(static segment => segment.SpokenLine))} Temperatures are in {unit}."; - } - - private static IReadOnlyList BuildWeeklyForecastCardSegments( - IReadOnlyList<(int DayOffset, WeatherReportSnapshot Snapshot)> snapshots, - DateTimeOffset? referenceLocalTime) - { - if (snapshots.Count == 0) - { - return []; - } - - var resolvedReference = referenceLocalTime ?? DateTimeOffset.UtcNow; - var referenceDate = resolvedReference.Date; - return snapshots - .OrderBy(static item => item.DayOffset) - .Take(MaxWeatherForecastDayOffset) - .Select(item => - { - var dayName = referenceDate.AddDays(item.DayOffset).ToString("dddd", CultureInfo.InvariantCulture); - var summary = string.IsNullOrWhiteSpace(item.Snapshot.Summary) - ? "partly cloudy" - : item.Snapshot.Summary.Trim().TrimEnd('.'); - var high = item.Snapshot.HighTemperature ?? item.Snapshot.Temperature; - var low = item.Snapshot.LowTemperature ?? item.Snapshot.Temperature; - var iconReference = new DateTimeOffset( - resolvedReference.Date.AddDays(item.DayOffset).AddHours(12), - resolvedReference.Offset); - var icon = ResolveWeatherAnimationIcon(item.Snapshot, iconReference); - var unit = item.Snapshot.UseCelsius ? "C" : "F"; - var temperatureBand = ResolveWeatherTemperatureBand(high, item.Snapshot.UseCelsius); - var spokenLine = $"{dayName}: {summary}, high {high}, low {low}."; - return new WeatherForecastCardSegment( - dayName, - summary, - high, - low, - icon, - unit, - temperatureBand, - spokenLine); - }) - .ToArray(); - } - - private static IDictionary BuildWeeklyWeatherSkillPayload( - string spokenReply, - WeatherReportSnapshot snapshot, - IReadOnlyList segments, - DateTimeOffset? referenceLocalTime) - { - var payload = BuildWeatherSkillPayload(spokenReply, snapshot, referenceLocalTime); - payload["weather_weekly_cards"] = segments - .Select(static segment => new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["weather_day"] = segment.DayName, - ["weather_summary"] = segment.Summary, - ["weather_icon"] = segment.Icon, - ["weather_high"] = segment.High, - ["weather_low"] = segment.Low, - ["weather_unit"] = segment.Unit, - ["weather_theme"] = segment.Theme, - ["weather_spoken_line"] = segment.SpokenLine - }) - .ToArray(); - return payload; - } - - private static void AddWeatherRequestDiagnostics( - IDictionary payload, - string transcript, - string normalizedTranscript, - string? locationQuery, - WeatherDateEntity weatherDate, - bool isRangeForecastRequest, - bool isThisWeekForecast, - bool isNextWeekForecast) - { - payload["weather_request_transcript"] = transcript; - payload["weather_request_normalized"] = normalizedTranscript; - payload["weather_request_location_query"] = locationQuery; - payload["weather_request_date_entity"] = weatherDate.DateEntity; - payload["weather_request_forecast_day_offset"] = weatherDate.ForecastDayOffset; - payload["weather_request_range"] = isRangeForecastRequest; - payload["weather_request_this_week"] = isThisWeekForecast; - payload["weather_request_next_week"] = isNextWeekForecast; - } - - private static bool IsNextWeekForecastRequest(string normalizedTranscript, bool isRangeForecastRequest) - { - if (string.IsNullOrWhiteSpace(normalizedTranscript) || !isRangeForecastRequest) - { - return false; - } - - if (normalizedTranscript.Contains("next week", StringComparison.Ordinal)) - { - return true; - } - - if (!normalizedTranscript.Contains("next", StringComparison.Ordinal)) - { - return false; - } - - return normalizedTranscript.Contains("forecast next", StringComparison.Ordinal) || - normalizedTranscript.Contains("forecast for next", StringComparison.Ordinal); - } - - private static bool IsRangeForecastRequest(string normalizedTranscript) - { - if (string.IsNullOrWhiteSpace(normalizedTranscript)) - { - return false; - } - - if (normalizedTranscript.Contains("next week", StringComparison.Ordinal) || - normalizedTranscript.Contains("this week", StringComparison.Ordinal) || - normalizedTranscript.Contains("weekend", StringComparison.Ordinal)) - { - return true; - } - - return normalizedTranscript.Contains("forecast next", StringComparison.Ordinal) || - normalizedTranscript.Contains("forecast for next", StringComparison.Ordinal); - } - - private static bool IsThisWeekForecastRequest(string normalizedTranscript, bool isRangeForecastRequest) - { - return isRangeForecastRequest && - !string.IsNullOrWhiteSpace(normalizedTranscript) && - normalizedTranscript.Contains("this week", StringComparison.Ordinal) && - !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; - var daysUntilSunday = ((int)DayOfWeek.Sunday - (int)resolvedReference.DayOfWeek + 7) % 7; - var endOffset = Math.Min(MaxWeatherForecastDayOffset, daysUntilSunday); - return Math.Max(1, endOffset); - } - - private static bool ShouldDefaultForecastToTomorrow( - string normalizedTranscript, - WeatherDateEntity weatherDate, - bool isRangeForecastRequest, - bool isOpenEndedForecastRequest) - { - if (weatherDate.ForecastDayOffset > 0 || - isOpenEndedForecastRequest || - isRangeForecastRequest || - string.IsNullOrWhiteSpace(normalizedTranscript) || - !normalizedTranscript.Contains("forecast", StringComparison.Ordinal)) - { - return false; - } - - return !MatchesAny( - normalizedTranscript, - "today", - "today s", - "today's", - "tonight", - "right now", - "current weather", - "currently"); - } - - private static IDictionary BuildWeatherSkillPayload( - string spokenReply, - WeatherReportSnapshot snapshot, - DateTimeOffset? referenceLocalTime) - { - var weatherIcon = ResolveWeatherAnimationIcon(snapshot, referenceLocalTime); - var promptToken = ResolveWeatherPromptToken(weatherIcon); - var highTemperature = snapshot.HighTemperature ?? snapshot.Temperature; - var lowTemperature = snapshot.LowTemperature ?? snapshot.Temperature; - var temperatureUnit = snapshot.UseCelsius ? "C" : "F"; - var temperatureBand = ResolveWeatherTemperatureBand(highTemperature, snapshot.UseCelsius); - - return new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["skillId"] = "report-skill", - ["cloudSkill"] = "weather", - ["esml"] = - $"{EscapeForEsml(spokenReply)}", - ["mim_id"] = $"WeatherComment{promptToken}", - ["mim_type"] = "announcement", - ["prompt_id"] = $"WeatherComment{promptToken}_AN_13", - ["prompt_sub_category"] = "AN", - ["weather_view_enabled"] = true, - ["weather_view_kind"] = "weatherHiLo", - ["weather_icon"] = weatherIcon, - ["weather_summary"] = snapshot.Summary, - ["weather_location"] = snapshot.LocationName, - ["weather_high"] = highTemperature, - ["weather_low"] = lowTemperature, - ["weather_unit"] = temperatureUnit, - ["weather_theme"] = temperatureBand - }; - } - - private static string ResolveWeatherAnimationIcon( - WeatherReportSnapshot snapshot, - DateTimeOffset? referenceLocalTime) - { - var isDaytime = (referenceLocalTime ?? DateTimeOffset.UtcNow).Hour is >= 6 and < 18; - var normalized = NormalizeCommandPhrase( - $"{snapshot.Condition ?? string.Empty} {snapshot.Summary ?? string.Empty}"); - - if (normalized.Contains("thunder", StringComparison.Ordinal) || - normalized.Contains("drizzle", StringComparison.Ordinal) || - normalized.Contains("rain", StringComparison.Ordinal)) - { - return "rain"; - } - - if (normalized.Contains("snow", StringComparison.Ordinal)) - { - return "snow"; - } - - if (normalized.Contains("sleet", StringComparison.Ordinal) || - normalized.Contains("freezing rain", StringComparison.Ordinal) || - normalized.Contains("ice", StringComparison.Ordinal)) - { - return "sleet"; - } - - if (normalized.Contains("fog", StringComparison.Ordinal) || - normalized.Contains("mist", StringComparison.Ordinal) || - normalized.Contains("haze", StringComparison.Ordinal) || - normalized.Contains("smoke", StringComparison.Ordinal)) - { - return "fog"; - } - - if (normalized.Contains("wind", StringComparison.Ordinal)) - { - return "wind"; - } - - if (normalized.Contains("partly cloudy", StringComparison.Ordinal) || - normalized.Contains("scattered clouds", StringComparison.Ordinal) || - normalized.Contains("few clouds", StringComparison.Ordinal)) - { - return isDaytime ? "partly-cloudy-day" : "partly-cloudy-night"; - } - - if (normalized.Contains("cloud", StringComparison.Ordinal) || - normalized.Contains("overcast", StringComparison.Ordinal)) - { - return "cloudy"; - } - - if (normalized.Contains("clear", StringComparison.Ordinal) || - normalized.Contains("sunny", StringComparison.Ordinal)) - { - return isDaytime ? "clear-day" : "clear-night"; - } - - return isDaytime ? "clear-day" : "clear-night"; - } - - private static string ResolveWeatherPromptToken(string weatherIcon) - { - return weatherIcon switch - { - "clear-day" => "ClearDay", - "clear-night" => "ClearNight", - "rain" => "Rain", - "snow" => "Snow", - "sleet" => "Sleet", - "fog" => "Fog", - "wind" => "Wind", - "cloudy" => "Cloudy", - "partly-cloudy-day" => "PartlyCloudyDay", - "partly-cloudy-night" => "PartlyCloudyNight", - _ => "Cloudy" - }; - } - - private static string ResolveWeatherTemperatureBand(int highTemperature, bool useCelsius) - { - var hotThreshold = useCelsius ? 29 : 85; - var coldThreshold = useCelsius ? 4 : 40; - if (highTemperature > hotThreshold) - { - return "Hot"; - } - - if (highTemperature < coldThreshold) - { - return "Cold"; - } - - return "Normal"; - } - - private static string ChooseWeatherTemplate(IReadOnlyList templates, string fallback) - { - var usableTemplates = templates.Where(static template => !string.IsNullOrWhiteSpace(template)).ToArray(); - if (usableTemplates.Length == 0) - { - return fallback; - } - - return usableTemplates[0]; - } - - private static string RenderWeatherTemplate( - string template, - string location, - string summary, - int? highTemperature, - int? lowTemperature, - string unit, - string forecastLeadIn) - { - var rendered = template - .Replace("${skill.weather.today.highTemp}", highTemperature?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, StringComparison.OrdinalIgnoreCase) - .Replace("${skill.weather.today.lowTemp}", lowTemperature?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, StringComparison.OrdinalIgnoreCase) - .Replace("${skill.weather.tomorrow.highTemp}", highTemperature?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, StringComparison.OrdinalIgnoreCase) - .Replace("${skill.weather.tomorrow.lowTemp}", lowTemperature?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, StringComparison.OrdinalIgnoreCase) - .Replace("${skill.weather.summary}", summary, StringComparison.OrdinalIgnoreCase) - .Replace("${skill.weather.location}", location, StringComparison.OrdinalIgnoreCase) - .Replace("${skill.weather.prefix}", string.IsNullOrWhiteSpace(forecastLeadIn) ? string.Empty : forecastLeadIn, StringComparison.OrdinalIgnoreCase) - .Replace("{high}", highTemperature?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, StringComparison.OrdinalIgnoreCase) - .Replace("{low}", lowTemperature?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, StringComparison.OrdinalIgnoreCase) - .Replace("{unit}", unit, StringComparison.OrdinalIgnoreCase) - .Trim(); - - return rendered; - } - - private string ChooseWeatherServiceDownReply(JiboExperienceCatalog catalog) - { - var template = ChooseWeatherTemplate( - catalog.WeatherServiceDownReplies, - "I can't access weather info right now, sorry."); - return template.Trim(); - } - - private static string EscapeForEsml(string value) - { - return value - .Replace("&", "&", StringComparison.Ordinal) - .Replace("<", "<", StringComparison.Ordinal) - .Replace(">", ">", StringComparison.Ordinal) - .Replace("\"", """, StringComparison.Ordinal); - } - - private static JiboInteractionDecision BuildOrderPizzaDecision() - { - return new JiboInteractionDecision( - "order_pizza", - "I can't do that yet, but I bet I'll be able to do that sometime in the near future.", - "chitchat-skill", - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["esml"] = "I can't do that yet, but I bet I'll be able to do that sometime in the near future.", - ["mim_id"] = "RA_JBO_OrderPizza", - ["mim_type"] = "announcement", - ["prompt_id"] = "RA_JBO_OrderPizza_AN_01", - ["prompt_sub_category"] = "AN" - }); - } - - private JiboInteractionDecision BuildJokeDecision(JiboExperienceCatalog catalog) - { - var joke = randomizer.Choose(catalog.Jokes); - return new JiboInteractionDecision( - "joke", - joke, - "@be/joke", - new Dictionary - { - ["replyType"] = "joke" - }); - } - - private JiboInteractionDecision BuildRandomDanceDecision(JiboExperienceCatalog catalog) - { - var dance = randomizer.Choose(catalog.DanceAnimations); - var replyText = randomizer.Choose(catalog.DanceReplies); - return BuildDanceDecision("dance", dance, replyText); - } - - private JiboInteractionDecision BuildDanceQuestionDecision(JiboExperienceCatalog catalog) - { - return new JiboInteractionDecision("dance_question", randomizer.Choose(catalog.DanceQuestionReplies)); - } - - private static JiboInteractionDecision BuildDanceDecision(string intentName, string dance, string replyText) - { - return new JiboInteractionDecision( - intentName, - replyText, - "chitchat-skill", - new Dictionary - { - ["esml"] = $"Okay. Watch this.", - ["mim_id"] = "runtime-chat", - ["mim_type"] = "announcement" - }); - } - - private async Task BuildNewsDecisionAsync( - TurnContext turn, - string transcript, - JiboExperienceCatalog catalog, - CancellationToken cancellationToken) - { - var preferredCategories = ResolvePreferredNewsCategories(turn, transcript); - var requestedHeadlineCount = MaxNewsHeadlines; - if (newsBriefingProvider is not null) - { - try - { - var snapshot = await newsBriefingProvider.GetBriefingAsync( - new NewsBriefingRequest(preferredCategories, requestedHeadlineCount), - cancellationToken); - - if (snapshot?.Headlines.Count > 0) - { - return BuildProviderNewsDecision(snapshot, preferredCategories, requestedHeadlineCount); - } - - var providerStatus = ResolveNewsProviderStatus(snapshot); - var providerMessage = snapshot?.ProviderMessage; - var providerEndpoint = snapshot?.ProviderEndpoint; - var providerHttpStatusCode = snapshot?.ProviderHttpStatusCode; - var providerErrorCode = snapshot?.ProviderErrorCode; - - var fallbackBriefingWhenEmpty = randomizer.Choose(catalog.NewsBriefings); - return BuildNewsDecision( - fallbackBriefingWhenEmpty, - sourceName: null, - preferredCategories.Count > 0 ? preferredCategories : null, - headlineCount: null, - providerDiagnostics: BuildNewsProviderDiagnostics( - providerStatus, - preferredCategories, - requestedHeadlineCount, - snapshot?.Headlines.Count ?? 0, - providerMessage, - providerHttpStatusCode, - providerEndpoint, - providerErrorCode)); - } - 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)); - } - } - - var fallbackBriefing = randomizer.Choose(catalog.NewsBriefings); - return BuildNewsDecision( - fallbackBriefing, - sourceName: null, - preferredCategories.Count > 0 ? preferredCategories : null, - headlineCount: null, - providerDiagnostics: BuildNewsProviderDiagnostics( - "provider_unavailable", - preferredCategories, - requestedHeadlineCount)); - } - - private static JiboInteractionDecision BuildNewsDecision( - string spokenBriefing, - string? sourceName, - IReadOnlyList? categories, - int? headlineCount, - IReadOnlyDictionary? providerDiagnostics = null) - { - var speakableBriefing = NormalizeNewsSpeechText(spokenBriefing); - var payload = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["skillId"] = "news", - ["cloudSkill"] = "news", - ["mim_id"] = "runtime-news", - ["mim_type"] = "announcement", - ["prompt_id"] = "NewsHeadline_AN_01", - ["prompt_sub_category"] = "AN", - ["esml"] = - $"{EscapeForEsml(speakableBriefing)}" - }; - - if (!string.IsNullOrWhiteSpace(sourceName)) - { - payload["news_source"] = sourceName; - } - - if (headlineCount is > 0) - { - payload["news_headline_count"] = headlineCount.Value; - } - - if (categories is { Count: > 0 }) - { - 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, - int requestedHeadlineCount) - { - var headlines = snapshot.Headlines - .Where(headline => !string.IsNullOrWhiteSpace(headline.Title)) - .Take(MaxNewsHeadlines) - .ToArray(); - if (headlines.Length == 0) - { - return BuildNewsDecision( - "I couldn't load fresh headlines right now.", - snapshot.SourceName, - preferredCategories, - headlineCount: 0, - providerDiagnostics: BuildNewsProviderDiagnostics( - "provider_empty", - preferredCategories, - requestedHeadlineCount, - 0)); - } - - var leadIn = BuildNewsLeadIn(snapshot.SourceName, preferredCategories); - var joinedHeadlines = string.Join(" ", headlines.Select(static headline => $"{headline.Title}.")); - var spokenBriefing = $"{leadIn} {joinedHeadlines}".Trim(); - return BuildNewsDecision( - spokenBriefing, - snapshot.SourceName, - preferredCategories, - headlines.Length, - providerDiagnostics: BuildNewsProviderDiagnostics( - "provider_success", - preferredCategories, - requestedHeadlineCount, - headlines.Length)); - } - - private static IReadOnlyDictionary BuildNewsProviderDiagnostics( - string status, - IReadOnlyList preferredCategories, - int requestedHeadlineCount, - int? resolvedHeadlineCount = null, - string? providerMessage = null, - int? providerHttpStatusCode = null, - string? providerEndpoint = null, - string? providerErrorCode = 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; - } - - if (!string.IsNullOrWhiteSpace(providerMessage)) - { - diagnostics["news_provider_message"] = providerMessage; - } - - if (providerHttpStatusCode is not null) - { - diagnostics["news_provider_http_status"] = providerHttpStatusCode.Value; - } - - if (!string.IsNullOrWhiteSpace(providerEndpoint)) - { - diagnostics["news_provider_endpoint"] = providerEndpoint; - } - - if (!string.IsNullOrWhiteSpace(providerErrorCode)) - { - diagnostics["news_provider_error_code"] = providerErrorCode; - } - - return diagnostics; - } - - private static string ResolveNewsProviderStatus(NewsBriefingSnapshot? snapshot) - { - var providerStatus = snapshot?.ProviderStatus?.Trim().ToLowerInvariant(); - return providerStatus switch - { - "success" => "provider_success", - "exception" => "provider_exception", - "http_error" or "api_error" or "schema_error" => "provider_error", - _ => "provider_empty" - }; - } - - private static string BuildNewsLeadIn(string? sourceName, IReadOnlyList preferredCategories) - { - var categoryLeadIn = preferredCategories.Count switch - { - <= 0 => "Here are a few headlines.", - 1 => $"Here are your {preferredCategories[0]} headlines.", - _ => $"Here are your {preferredCategories[0]} and {preferredCategories[1]} headlines." - }; - - return string.IsNullOrWhiteSpace(sourceName) - ? categoryLeadIn - : $"{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. - var normalized = Regex.Replace( - text, - @"\bA\.?\s*I\.?\b", - "artificial intelligence", - RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - return NormalizeLocationForSpeech(normalized); - } - - private static string NormalizeLocationForSpeech(string text) - { - if (string.IsNullOrWhiteSpace(text)) - { - return text; - } - - return Regex.Replace( - text, - @"\b(?[A-Z]{2,3})\b", - static match => - { - var token = match.Groups["token"].Value; - if (!SpokenAbbreviationTokens.Contains(token)) - { - return token; - } - - return string.Join(".", token.ToCharArray()) + "."; - }, - RegexOptions.CultureInvariant); - } - - private List ResolvePreferredNewsCategories(TurnContext turn, string transcript) - { - var categories = new List(); - var normalizedTranscript = NormalizeCommandPhrase(transcript); - - foreach (var (keyword, category) in NewsCategoryKeywordMap) - { - if (normalizedTranscript.Contains(keyword, StringComparison.Ordinal)) - { - AddNewsCategory(categories, category); - } - } - - var tenantScope = ResolveTenantScope(turn); - var explicitPreference = personalMemoryStore.GetPreference(tenantScope, "news"); - if (!string.IsNullOrWhiteSpace(explicitPreference)) - { - foreach (var category in MapNewsCategoryText(explicitPreference)) - { - AddNewsCategory(categories, category); - } - } - - foreach (var (item, affinity) in personalMemoryStore.GetAffinities(tenantScope)) - { - if (affinity == PersonalAffinity.Dislike) - { - continue; - } - - foreach (var category in MapNewsCategoryText(item)) - { - AddNewsCategory(categories, category); - } - } - - return categories.Take(MaxPreferredNewsCategories).ToList(); - } - - private static IEnumerable MapNewsCategoryText(string text) - { - var normalized = NormalizeCommandPhrase(text); - if (string.IsNullOrWhiteSpace(normalized)) - { - yield break; - } - - foreach (var (keyword, category) in NewsCategoryKeywordMap) - { - if (normalized.Contains(keyword, StringComparison.Ordinal)) - { - yield return category; - } - } - } - - private static void AddNewsCategory(ICollection categories, string category) - { - if (categories.Contains(category, StringComparer.OrdinalIgnoreCase)) - { - return; - } - - categories.Add(category); - } - - private JiboInteractionDecision BuildSurpriseDecision( - JiboExperienceCatalog catalog, - TurnContext turn, - DateTimeOffset? referenceLocalTime) - { - var tenantScope = ResolveTenantScope(turn); - var candidates = BuildProactivityCandidates(tenantScope, referenceLocalTime); - if (candidates.Count == 0) - { - return new JiboInteractionDecision("surprise", randomizer.Choose(catalog.SurpriseReplies)); - } - - var highestWeight = candidates.Max(static candidate => candidate.Weight); - var topCandidates = candidates - .Where(candidate => candidate.Weight == highestWeight) - .ToArray(); - var selected = topCandidates.Length == 1 - ? topCandidates[0] - : randomizer.Choose(topCandidates); - - return selected.IntentName switch - { - "proactive_pizza_day" => BuildProactivePizzaDayDecision(referenceLocalTime), - "proactive_pizza_preference" => BuildProactivePizzaPreferenceDecision(), - "proactive_offer_pizza_fact" => BuildProactivePizzaFactOfferDecision(), - _ => new JiboInteractionDecision("surprise", randomizer.Choose(catalog.SurpriseReplies)) - }; - } - - private List BuildProactivityCandidates( - PersonalMemoryTenantScope tenantScope, - DateTimeOffset? referenceLocalTime) - { - var candidates = new List(); - var referenceDate = (referenceLocalTime ?? DateTimeOffset.UtcNow).Date; - - var pizzaSignal = ResolvePizzaSignal(tenantScope); - if (pizzaSignal.Affinity == PersonalAffinity.Dislike) - { - return candidates; - } - - if (referenceDate.Month == 2 && referenceDate.Day == 9) - { - var holidayWeight = pizzaSignal.Affinity switch - { - PersonalAffinity.Love => 170, - PersonalAffinity.Like => 160, - _ => 150 - }; - candidates.Add(new ProactivityCandidate("proactive_pizza_day", holidayWeight)); - } - - if (pizzaSignal.Affinity is PersonalAffinity.Love or PersonalAffinity.Like) - { - var preferenceWeight = pizzaSignal.Affinity == PersonalAffinity.Love ? 140 : 120; - candidates.Add(new ProactivityCandidate("proactive_pizza_preference", preferenceWeight)); - candidates.Add(new ProactivityCandidate("proactive_offer_pizza_fact", preferenceWeight - 5)); - return candidates; - } - - candidates.Add(new ProactivityCandidate("proactive_offer_pizza_fact", 90)); - return candidates; - } - - private PizzaSignal ResolvePizzaSignal(PersonalMemoryTenantScope tenantScope) - { - var pizzaAffinity = personalMemoryStore.GetAffinity(tenantScope, "pizza"); - if (pizzaAffinity is not null) - { - return new PizzaSignal(pizzaAffinity); - } - - var affinityMatch = personalMemoryStore.GetAffinities(tenantScope) - .Where(pair => pair.Key.Contains("pizza", StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(static pair => pair.Value == PersonalAffinity.Love ? 2 : pair.Value == PersonalAffinity.Like ? 1 : 0) - .FirstOrDefault(); - if (!string.IsNullOrWhiteSpace(affinityMatch.Key)) - { - return new PizzaSignal(affinityMatch.Value); - } - - foreach (var category in PizzaPreferenceCategories) - { - var preference = personalMemoryStore.GetPreference(tenantScope, category); - if (!string.IsNullOrWhiteSpace(preference) && - preference.Contains("pizza", StringComparison.OrdinalIgnoreCase)) - { - return new PizzaSignal(PersonalAffinity.Like); - } - } - - return new PizzaSignal(null); - } - - private string BuildGenericReply(JiboExperienceCatalog catalog, string transcript, string lowered) - { - if (string.IsNullOrWhiteSpace(transcript)) - { - return "I am listening."; - } - - if (lowered.Contains("good morning", StringComparison.Ordinal)) - { - return "Good morning! It is nice to hear your voice."; - } - - if (lowered.Contains("good afternoon", StringComparison.Ordinal)) - { - return "Good afternoon. I am happy to be here."; - } - - return lowered.Contains("good night", StringComparison.Ordinal) - ? "Good night. Sleep tight." - : randomizer.Choose(catalog.GenericFallbackReplies) - .Replace("{transcript}", transcript, StringComparison.Ordinal); - } - - private JiboInteractionDecision BuildScriptedPersonalityDecision( - JiboExperienceCatalog catalog, - string intentName, - params string[] preferredSnippets) - { - return new JiboInteractionDecision( - intentName, - SelectLegacyPersonalityReply(catalog, preferredSnippets), - ContextUpdates: BuildScriptedResponseContextUpdates()); - } - - private JiboInteractionDecision BuildScriptedGreetingDecision( - JiboExperienceCatalog catalog, - string intentName, - params string[] preferredSnippets) - { - return new JiboInteractionDecision( - intentName, - SelectLegacyGreetingReply(catalog, preferredSnippets), - ContextUpdates: BuildScriptedResponseContextUpdates()); - } - - private static IDictionary BuildScriptedResponseContextUpdates() - { - return new Dictionary(StringComparer.OrdinalIgnoreCase) - { - [ChitchatStateMachine.StateMetadataKey] = "complete", - [ChitchatStateMachine.RouteMetadataKey] = "ScriptedResponse", - [ChitchatStateMachine.EmotionMetadataKey] = string.Empty - }; - } - - private string SelectLegacyPersonalityReply(JiboExperienceCatalog catalog, params string[] preferredSnippets) - { - foreach (var snippet in preferredSnippets) - { - if (string.IsNullOrWhiteSpace(snippet)) - { - continue; - } - - var match = catalog.PersonalityReplies.FirstOrDefault(reply => - reply.Contains(snippet, StringComparison.OrdinalIgnoreCase)); - if (!string.IsNullOrWhiteSpace(match)) - { - return match; - } - } - - return randomizer.Choose(catalog.PersonalityReplies); - } - - private string SelectLegacyGreetingReply(JiboExperienceCatalog catalog, params string[] preferredSnippets) - { - foreach (var snippet in preferredSnippets) - { - if (string.IsNullOrWhiteSpace(snippet)) - { - continue; - } - - var match = catalog.GreetingReplies.FirstOrDefault(reply => - reply.Contains(snippet, StringComparison.OrdinalIgnoreCase)); - if (!string.IsNullOrWhiteSpace(match)) - { - return match; - } - } - - return randomizer.Choose(catalog.GreetingReplies); - } - - private static string ResolveSemanticIntent( - string loweredTranscript, - DateTimeOffset? referenceLocalTime, - string? clientIntent, - IReadOnlyList clientRules, - IReadOnlyList listenRules, - IReadOnlyDictionary clientEntities, - string? lastClockDomain, - string? pendingProactivityOffer, - bool isYesNoTurn, - bool isTimerValueTurn, - bool isAlarmValueTurn) - { - var wordOfDayPuzzleTurn = clientRules.Concat(listenRules) - .Any(rule => string.Equals(rule, "word-of-the-day/puzzle", StringComparison.OrdinalIgnoreCase)); - - if (string.Equals(clientIntent, "guess", StringComparison.OrdinalIgnoreCase) && - wordOfDayPuzzleTurn) - { - return "word_of_the_day_guess"; - } - - if (string.Equals(clientIntent, "loadMenu", StringComparison.OrdinalIgnoreCase) && - clientEntities.TryGetValue("destination", out var destination) && - string.Equals(destination, "word-of-the-day", StringComparison.OrdinalIgnoreCase)) - { - return "word_of_the_day"; - } - - if (string.Equals(clientIntent, "loadMenu", StringComparison.OrdinalIgnoreCase) && - clientEntities.TryGetValue("destination", out var photoDestination)) - { - return photoDestination.ToLowerInvariant() switch - { - "snapshot" => "snapshot", - "photobooth" => "photobooth", - "gallery" or "photo-gallery" or "photos" => "photo_gallery", - _ => "chat" - }; - } - - var yesNoRule = ReadPrimaryYesNoRule(clientRules, listenRules); - if (!string.IsNullOrWhiteSpace(pendingProactivityOffer) && - string.Equals(pendingProactivityOffer, "pizza_fact", StringComparison.OrdinalIgnoreCase)) - { - if (IsAffirmativeReply(loweredTranscript)) - { - return "proactive_pizza_fact"; - } - - if (IsNegativeReply(loweredTranscript)) - { - return "proactive_offer_declined"; - } - } - - if (isYesNoTurn) - { - var yesNoReply = TryClassifyYesNoReply(NormalizeCommandPhrase(loweredTranscript)); - if (yesNoReply == YesNoReply.Affirmative) - { - return ResolveAffirmativeYesNoIntent(yesNoRule); - } - - if (yesNoReply == YesNoReply.Negative) - { - return ResolveNegativeYesNoIntent(yesNoRule); - } - } - - if (IsNameSetStatement(loweredTranscript)) - { - return "memory_set_name"; - } - - if (IsNameRecallQuestion(loweredTranscript)) - { - return "memory_get_name"; - } - - if (IsUserBirthdaySetStatement(loweredTranscript) || IsUserBirthdaySetAttempt(loweredTranscript)) - { - return "memory_set_birthday"; - } - - if (IsUserBirthdayRecallQuestion(loweredTranscript) || IsUserBirthdayRecallAttempt(loweredTranscript)) - { - return "memory_get_birthday"; - } - - if (IsRobotBirthdayQuestion(loweredTranscript)) - { - return "robot_birthday"; - } - - if (string.Equals(clientIntent, "askForTime", StringComparison.OrdinalIgnoreCase)) - { - return "time"; - } - - if (string.Equals(clientIntent, "askForDate", StringComparison.OrdinalIgnoreCase)) - { - return "date"; - } - - if (string.Equals(clientIntent, "askForDay", StringComparison.OrdinalIgnoreCase)) - { - return "day"; - } - - if (string.Equals(clientIntent, "timerValue", StringComparison.OrdinalIgnoreCase)) - { - return "timer_value"; - } - - if (string.Equals(clientIntent, "alarmValue", StringComparison.OrdinalIgnoreCase)) - { - return "alarm_value"; - } - - if (string.Equals(clientIntent, "requestMakePizza", StringComparison.OrdinalIgnoreCase)) - { - return "pizza"; - } - - if (string.Equals(clientIntent, "requestOrderPizza", StringComparison.OrdinalIgnoreCase)) - { - return "order_pizza"; - } - - if (string.Equals(clientIntent, "requestWeatherPR", StringComparison.OrdinalIgnoreCase) || - string.Equals(clientIntent, "requestWeather", StringComparison.OrdinalIgnoreCase)) - { - return "weather"; - } - - if (IsCancelRequest(clientIntent, loweredTranscript)) - { - if (isAlarmValueTurn) - { - return "alarm_cancel"; - } - - if (isTimerValueTurn) - { - return "timer_cancel"; - } - } - - if ((string.Equals(clientIntent, "start", StringComparison.OrdinalIgnoreCase) || - string.Equals(clientIntent, "set", StringComparison.OrdinalIgnoreCase)) && - clientEntities.TryGetValue("domain", out var startDomain)) - { - return startDomain.ToLowerInvariant() switch - { - "timer" => HasStructuredTimerValue(clientEntities) || TryParseTimerValue(loweredTranscript, isTimerValueTurn) is not null - ? "timer_value" - : "timer_clarify", - "alarm" => HasStructuredAlarmValue(clientEntities) || TryParseAlarmValue(loweredTranscript, isAlarmValueTurn, referenceLocalTime) is not null - ? "alarm_value" - : "alarm_clarify", - _ => "chat" - }; - } - - if ((string.Equals(clientIntent, "cancel", StringComparison.OrdinalIgnoreCase) || - string.Equals(clientIntent, "delete", StringComparison.OrdinalIgnoreCase)) && - clientRules.Concat(listenRules).Any(rule => string.Equals(rule, "clock/alarm_timer_query_menu", StringComparison.OrdinalIgnoreCase))) - { - var cancelDomain = ResolveClockDomain(clientEntities, clientRules, listenRules, lastClockDomain); - return string.Equals(cancelDomain, "timer", StringComparison.OrdinalIgnoreCase) - ? "timer_delete" - : "alarm_delete"; - } - - if (string.Equals(clientIntent, "menu", StringComparison.OrdinalIgnoreCase) && - clientEntities.TryGetValue("domain", out var clockDomain)) - { - return clockDomain.ToLowerInvariant() switch - { - "clock" => "clock_menu", - "timer" => "timer_menu", - "alarm" => "alarm_menu", - _ => "chat" - }; - } - - if (MatchesAny( - loweredTranscript, - "word of the day", - "start word of the day", - "play word of the day", - "do word of the day", - "open word of the day")) - { - return "word_of_the_day"; - } - - if (wordOfDayPuzzleTurn && !string.IsNullOrWhiteSpace(loweredTranscript)) - { - return "word_of_the_day_guess"; - } - - if (MatchesAny( - loweredTranscript, - "are you funny", - "do you think you are funny", - "are you a funny robot")) - { - return "robot_is_funny"; - } - - if (MatchesAny(loweredTranscript, "joke", "funny", "make me laugh")) - { - return "joke"; - } - - if (MatchesAny( - loweredTranscript, - "cloud version", - "open jibo cloud version", - "openjibo cloud version", - "what version is the cloud", - "what s the cloud version", - "what's the cloud version")) - { - return "cloud_version"; - } - - if (IsPreferenceSetStatement(loweredTranscript) || IsPreferenceSetAttempt(loweredTranscript)) - { - return "memory_set_preference"; - } - - if (IsPreferenceRecallQuestion(loweredTranscript) || IsPreferenceRecallAttempt(loweredTranscript)) - { - return "memory_get_preference"; - } - - if (IsImportantDateSetStatement(loweredTranscript)) - { - return "memory_set_important_date"; - } - - if (IsImportantDateRecallQuestion(loweredTranscript)) - { - return "memory_get_important_date"; - } - - if (IsAffinitySetStatement(loweredTranscript) || IsAffinitySetAttempt(loweredTranscript)) - { - return "memory_set_affinity"; - } - - if (IsAffinityRecallQuestion(loweredTranscript) || IsAffinityRecallAttempt(loweredTranscript)) - { - return "memory_get_affinity"; - } - - if (TryResolveRadioGenre(loweredTranscript) is not null) - { - return "radio_genre"; - } - - if (TryResolveVolumeLevel(loweredTranscript) is not null || - clientEntities.ContainsKey("volumeLevel")) - { - return "volume_to_value"; - } - - if (IsVolumeQueryRequest(loweredTranscript)) - { - return "volume_query"; - } - - if (IsVolumeUpRequest(loweredTranscript)) - { - return "volume_up"; - } - - if (IsVolumeDownRequest(loweredTranscript)) - { - return "volume_down"; - } - - if (MatchesAny(loweredTranscript, "open the clock", "open clock", "show the clock", "show clock")) - { - return "clock_open"; - } - - if (MatchesAny(loweredTranscript, "open the timer", "open timer", "show the timer", "show timer")) - { - return "timer_menu"; - } - - if (MatchesAny(loweredTranscript, "open the alarm", "open alarm", "show the alarm", "show alarm")) - { - return "alarm_menu"; - } - - if (IsAlarmDeleteRequest(loweredTranscript)) - { - return "alarm_delete"; - } - - if (MatchesAny( - loweredTranscript, - "cancel timer", - "delete timer", - "remove timer", - "stop timer", - "turn off timer")) - { - return "timer_delete"; - } - - if (IsGlobalStopRequest(loweredTranscript, clientIntent, clientEntities)) - { - return "stop"; - } - - if (TryParseAlarmValue(loweredTranscript, isAlarmValueTurn, referenceLocalTime) is not null) - { - return "alarm_value"; - } - - if (TryParseTimerValue(loweredTranscript, isTimerValueTurn) is not null) - { - return "timer_value"; - } - - if (IsAlarmRequest(loweredTranscript) || isAlarmValueTurn) - { - return "alarm_clarify"; - } - - if (IsTimerRequest(loweredTranscript) || isTimerValueTurn) - { - return "timer_clarify"; - } - - if (MatchesAny(loweredTranscript, "open the radio", "play the radio", "turn on the radio", "radio")) - { - return "radio"; - } - - if (MatchesAny( - loweredTranscript, - "snap a picture", - "take a picture", - "take a photo", - "snap a photo")) - { - return "snapshot"; - } - - if (MatchesAny( - loweredTranscript, - "photo booth", - "photobooth", - "open photobooth", - "start photobooth")) - { - return "photobooth"; - } - - if (MatchesAny( - loweredTranscript, - "photo gallery", - "photogal", - "photo gal", - "open the gallery", - "open photo gallery", - "show my photos", - "open my photos", - "gallery")) - { - return "photo_gallery"; - } - - if (IsDanceQuestion(loweredTranscript)) - { - return "dance_question"; - } - - if (MatchesAny(loweredTranscript, "can you dance", "do you dance", "are you able to dance")) - { - return "robot_can_dance"; - } - - if (MatchesAny(loweredTranscript, "twerk")) - { - return "twerk"; - } - - if (MatchesAny(loweredTranscript, "dance", "boogie")) - { - return "dance"; - } - - if (MatchesAny(loweredTranscript, "surprise", "surprise me", "show me something fun")) - { - return "surprise"; - } - - if (MatchesAny( - loweredTranscript, - "how old are you", - "what is your age", - "what s your age", - "how old r you")) - { - return "robot_age"; - } - - if (MatchesAny( - loweredTranscript, - "do you have a personality", - "what is your personality", - "what's your personality", - "what s your personality", - "describe your personality")) - { - return "robot_personality"; - } - - if (MatchesAny( - loweredTranscript, - "do you pay taxes", - "do you pay tax", - "are you tax exempt")) - { - return "robot_taxes"; - } - - if (MatchesAny( - loweredTranscript, - "what do you want", - "what is it you want", - "what do you really want")) - { - return "robot_desire"; - } - - if (MatchesAny( - loweredTranscript, - "what is your job", - "what's your job", - "what do you do", - "what is your work", - "what's your work")) - { - return "robot_job"; - } - - if (MatchesAny( - loweredTranscript, - "how do you work", - "how does jibo work", - "what does jibo do", - "how are you built", - "how are you put together")) - { - return "robot_how_do_you_work"; - } - - if (MatchesAny( - loweredTranscript, - "what do you eat", - "do you eat", - "what do you drink", - "do you drink")) - { - return "robot_what_do_you_eat"; - } - - if (MatchesAny( - loweredTranscript, - "where do you live", - "where s your home", - "where is your home", - "what is your home")) - { - return "robot_where_do_you_live"; - } - - if (MatchesAny( - loweredTranscript, - "where were you born", - "where were you made", - "where were you put together")) - { - return "robot_where_were_you_born"; - } - - if (MatchesAny( - loweredTranscript, - "what languages do you speak", - "what language do you speak", - "what languages can you speak", - "what language can you speak")) - { - return "robot_what_languages_do_you_speak"; - } - - if (MatchesAny( - loweredTranscript, - "what do you like to do", - "what do you like doing", - "what is your favorite thing to do", - "what's your favorite thing to do", - "what is your favourite thing to do", - "what's your favourite thing to do")) - { - return "robot_what_do_you_like_to_do"; - } - - if (MatchesAny( - loweredTranscript, - "what is your favorite flower", - "what's your favorite flower", - "what s your favorite flower", - "what is your favourite flower", - "what's your favourite flower", - "what s your favourite flower")) - { - return "robot_favorite_flower"; - } - - if (MatchesAny( - loweredTranscript, - "do you like r2d2", - "do you know r2d2", - "what do you think about r2d2", - "are you a fan of r2d2")) - { - return "robot_likes_r2d2"; - } - - if (MatchesAny( - loweredTranscript, - "do you like the sun", - "do you like sun", - "what do you think about the sun")) - { - return "robot_likes_sun"; - } - - if (MatchesAny( - loweredTranscript, - "do you like space", - "do you love space", - "do you like astronomy", - "what do you think about space")) - { - return "robot_likes_space"; - } - - if (MatchesAny( - loweredTranscript, - "do you like kids", - "do you like children", - "what do you think about kids")) - { - return "robot_likes_kids"; - } - - if (MatchesAny( - loweredTranscript, - "can you laugh", - "do you laugh", - "are you able to laugh")) - { - return "robot_can_laugh"; - } - - if (MatchesAny( - loweredTranscript, - "what are you made of", - "what are you built from", - "what are you constructed from")) - { - return "robot_what_are_you_made_of"; - } - - if (MatchesAny( - loweredTranscript, - "who made you", - "who created you", - "who built you", - "who developed you")) - { - return "robot_origin_created"; - } - - if (MatchesAny( - loweredTranscript, - "what are you up to", - "what are you doing", - "what have you been up to", - "what are you into")) - { - return "robot_what_do_you_like_to_do"; - } - - if (MatchesAny( - loweredTranscript, - "what are you thinking", - "what are you thinking about", - "what s on your mind")) - { - return "robot_what_are_you_thinking"; - } - - if (MatchesAny( - loweredTranscript, - "what have you been doing", - "what were you doing")) - { - return "robot_what_have_you_been_doing"; - } - - if (MatchesAny( - loweredTranscript, - "what did you do", - "what have you done")) - { - return "robot_what_did_you_do"; - } - - if (MatchesAny( - loweredTranscript, - "what are you", - "what is jibo", - "who are you", - "what kind of robot are you")) - { - return "robot_identity"; - } - - if (MatchesAny( - loweredTranscript, - "where are you from", - "where did you come from", - "where were you made")) - { - return "robot_origin_from"; - } - - if (MatchesAny( - loweredTranscript, - "what's your name", - "what is your name")) - { - return "robot_name"; - } - - if (MatchesAny( - loweredTranscript, - "do you have a nickname", - "what is your nickname", - "what's your nickname")) - { - return "robot_nickname"; - } - - if (MatchesAny( - loweredTranscript, - "do you like being jibo", - "do you like being yourself", - "are you happy being jibo")) - { - return "robot_likes_being_jibo"; - } - - if (MatchesAny( - loweredTranscript, - "happy holidays", - "merry christmas", - "happy new year", - "season s greetings", - "seasons greetings")) - { - return "seasonal_holiday_greeting"; - } - - if (MatchesAny( - loweredTranscript, - "what holidays do you celebrate", - "what holidays are you celebrating", - "what holidays do you observe")) - { - return "seasonal_holidays"; - } - - if (MatchesAny( - loweredTranscript, - "what is your new years resolution", - "what is your new year's resolution", - "what is your new year s resolution", - "what are your new years resolutions", - "what are your new year's resolutions", - "what are your new year s resolutions", - "do you have any new years resolutions")) - { - return "seasonal_new_years_resolution"; - } - - if (MatchesAny( - loweredTranscript, - "how are your new years resolutions going", - "how are your new year's resolutions going", - "how is your new years resolution going", - "how is your new year's resolution going", - "how are your resolutions going", - "how is your resolution going")) - { - return "seasonal_new_years_update"; - } - - if (MatchesAny( - loweredTranscript, - "what halloween costume", - "what are you going as for halloween", - "what costume are you wearing", - "what are you dressing as for halloween")) - { - return "seasonal_halloween_costume"; - } - - if (MatchesAny( - loweredTranscript, - "what should i do for first day of spring", - "what should i do for spring", - "what do i do for first day of spring")) - { - return "seasonal_first_day_spring"; - } - - if (MatchesAny( - loweredTranscript, - "what should i get for holiday", - "what should i get for christmas", - "what gift should i get for christmas", - "what should i get someone for the holidays")) - { - return "seasonal_holiday_gift"; - } - - if (MatchesAny( - loweredTranscript, - "what is your favorite color", - "what's your favorite color", - "what s your favorite color", - "what is your favourite color", - "what's your favourite color", - "what s your favourite color", - "what color do you like", - "what colour do you like")) - { - return "robot_favorite_color"; - } - - if (MatchesAny( - loweredTranscript, - "what is your favorite food", - "what's your favorite food", - "what s your favorite food", - "what is your favourite food", - "what's your favourite food", - "what s your favourite food", - "what food do you like", - "what kind of food do you like")) - { - return "robot_favorite_food"; - } - - if (MatchesAny( - loweredTranscript, - "what is your favorite music", - "what's your favorite music", - "what s your favorite music", - "what is your favourite music", - "what's your favourite music", - "what s your favourite music", - "what music do you like", - "what kind of music do you like")) - { - return "robot_favorite_music"; - } - - if (MatchesAny( - loweredTranscript, - "are there others like you", - "are there any others like you", - "is there another jibo")) - { - return "robot_peers"; - } - - if (MatchesAny( - loweredTranscript, - "how much do you know", - "what do you know", - "how smart are you")) - { - return "robot_knowledge"; - } - - if (MatchesAny( - loweredTranscript, - "are you kind", - "do you think you are kind", - "are you a kind robot")) - { - return "robot_is_kind"; - } - - if (MatchesAny( - loweredTranscript, - "are you helpful", - "do you think you are helpful", - "are you a helpful robot")) - { - return "robot_is_helpful"; - } - - if (MatchesAny( - loweredTranscript, - "are you curious", - "do you think you are curious", - "are you a curious robot")) - { - return "robot_is_curious"; - } - - if (MatchesAny( - loweredTranscript, - "are you loyal", - "do you think you are loyal", - "are you a loyal robot")) - { - return "robot_is_loyal"; - } - - if (MatchesAny( - loweredTranscript, - "are you mischievous", - "do you think you are mischievous", - "are you a mischievous robot")) - { - return "robot_is_mischievous"; - } - - if (MatchesAny( - loweredTranscript, - "are you likable", - "are you likeable", - "do you think you are likable", - "do you think you are likeable")) - { - return "robot_is_likable"; - } - - if (MatchesAny( - loweredTranscript, - "can you order pizza", - "can you order a pizza", - "could you order a pizza", - "order pizza", - "order a pizza", - "order us a pizza", - "order me a pizza", - "please order pizza") || - (loweredTranscript.Contains("order", StringComparison.Ordinal) && - loweredTranscript.Contains("pizza", StringComparison.Ordinal))) - { - return "order_pizza"; - } - - if (MatchesAny( - loweredTranscript, - "can you cook us a pizza", - "flip a pizza", - "make a pizza", - "make pizza", - "show pizza", - "can you make pizza", - "let's make pizza", - "lets make pizza") || - (loweredTranscript.Contains("pizza", StringComparison.Ordinal) && - (loweredTranscript.Contains("make", StringComparison.Ordinal) || - loweredTranscript.Contains("cook", StringComparison.Ordinal) || - loweredTranscript.Contains("flip", StringComparison.Ordinal)))) - { - return "pizza"; - } - - if (MatchesAny(loweredTranscript, "personal report", "my report", "daily report", "my update")) - { - return "personal_report"; - } - - if (MatchesAny( - loweredTranscript, - "shopping list", - "grocery list", - "to do list", - "todo list", - "add to my shopping list", - "add to my to do list", - "add to my todo list", - "what's on my shopping list", - "what is on my shopping list", - "what's on my to do list", - "what is on my to do list", - "what are my tasks", - "what do i need to buy", - "what do i need to do")) - { - return loweredTranscript.Contains("to do", StringComparison.OrdinalIgnoreCase) || - loweredTranscript.Contains("todo", StringComparison.OrdinalIgnoreCase) || - loweredTranscript.Contains("task", StringComparison.OrdinalIgnoreCase) - ? "todo_list" - : "shopping_list"; - } - - if (IsWeatherRequest(loweredTranscript)) - { - return "weather"; - } - - if (MatchesAny(loweredTranscript, "calendar", "schedule", "what's on my calendar", "what is on my calendar")) - { - return "calendar"; - } - - if (MatchesAny(loweredTranscript, "commute", "traffic", "drive to work", "how long to work")) - { - return "commute"; - } - - if (MatchesAny(loweredTranscript, "news", "headlines", "news update", "tell me the news")) - { - return "news"; - } - - if (IsWelcomeBackGreeting(loweredTranscript)) - { - return "welcome_back"; - } - - if (IsGoodMorningGreeting(loweredTranscript)) - { - return "good_morning"; - } - - if (IsGoodAfternoonGreeting(loweredTranscript)) - { - return "good_afternoon"; - } - - if (IsGoodEveningGreeting(loweredTranscript)) - { - return "good_evening"; - } - - if (IsGoodNightGreeting(loweredTranscript)) - { - return "good_night"; - } - - if (MatchesAny( - loweredTranscript, - "how are you", - "what's up", - "what s up", - "what up", - "how are things", - "how's things", - "how is things", - "how is your day", - "how's your day")) - { - return "how_are_you"; - } - - if (MatchesAny( - loweredTranscript, - "what are you up to", - "what are you doing", - "what have you been up to", - "what are you into")) - { - return "robot_what_do_you_like_to_do"; - } - - if (MatchesAny(loweredTranscript, "hello", "hi", "hey")) - { - return "hello"; - } - - if (IsTimeRequest(loweredTranscript)) - { - return "time"; - } - - if (MatchesAny(loweredTranscript, "what day is it", "what day is today")) - { - return "day"; - } - - if (IsDateRequest(loweredTranscript)) - { - return "date"; - } - - return "chat"; - } - - private static JiboInteractionDecision BuildWordOfTheDayLaunchDecision() - { - return new JiboInteractionDecision( - "word_of_the_day", - "Starting word of the day.", - "@be/word-of-the-day", - SkillPayload: new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["domain"] = "word-of-the-day", - ["skillId"] = "@be/word-of-the-day" - }); - } - - private static JiboInteractionDecision BuildRadioLaunchDecision() - { - return new JiboInteractionDecision( - "radio", - "Opening the radio.", - "@be/radio", - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["skillId"] = "@be/radio" - }); - } - - private static JiboInteractionDecision BuildPhotoGalleryLaunchDecision() - { - return new JiboInteractionDecision( - "photo_gallery", - "Opening the photo gallery.", - "@be/gallery", - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["skillId"] = "@be/gallery", - ["localIntent"] = "menu" - }); - } - - private static JiboInteractionDecision BuildPhotoCreateDecision(string intentName, string replyText, string localIntent) - { - return new JiboInteractionDecision( - intentName, - replyText, - "@be/create", - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["skillId"] = "@be/create", - ["localIntent"] = localIntent - }); - } - - private static JiboInteractionDecision BuildStopDecision() - { - return new JiboInteractionDecision( - "stop", - "Stopping.", - "@be/idle", - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["skillId"] = "@be/idle", - ["globalIntent"] = "stop", - ["nluDomain"] = "global_commands" - }); - } - - private static JiboInteractionDecision BuildVolumeControlDecision(string intentName, string globalIntent, string volumeLevel) - { - return new JiboInteractionDecision( - intentName, - "Adjusting volume.", - "global_commands", - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["globalIntent"] = globalIntent, - ["nluDomain"] = "global_commands", - ["volumeLevel"] = volumeLevel - }); - } - - private static JiboInteractionDecision BuildSettingsVolumeDecision() - { - return new JiboInteractionDecision( - "volume_query", - "Opening volume controls.", - "@be/settings", - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["skillId"] = "@be/settings", - ["localIntent"] = "volumeQuery" - }); - } - - private static JiboInteractionDecision BuildClockLaunchDecision(string intentName, string domain, string clockIntent, string replyText) - { - return new JiboInteractionDecision( - intentName, - replyText, - "@be/clock", - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["skillId"] = "@be/clock", - ["domain"] = domain, - ["clockIntent"] = clockIntent - }); - } - - private static JiboInteractionDecision BuildClockLaunchDecision(string domain, string replyText) - { - return BuildClockLaunchDecision($"{domain}_menu", domain, "menu", replyText); - } - - private static JiboInteractionDecision BuildClockClarifyDecision(string intentName, string domain, string replyText) - { - return new JiboInteractionDecision( - intentName, - replyText, - "@be/clock", - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["skillId"] = "@be/clock", - ["domain"] = domain, - ["clockIntent"] = "set" - }); - } - - private static JiboInteractionDecision BuildTimerValueDecision( - string loweredTranscript, - bool allowImplicit, - IReadOnlyDictionary clientEntities) - { - var timer = TryReadStructuredTimerValue(clientEntities) ?? - TryParseTimerValue(loweredTranscript, allowImplicit) ?? - new ClockTimerValue("0", "1", "null"); - - return new JiboInteractionDecision( - "timer_value", - "Setting your timer.", - "@be/clock", - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["skillId"] = "@be/clock", - ["domain"] = "timer", - ["clockIntent"] = "start", - ["hours"] = timer.Hours, - ["minutes"] = timer.Minutes, - ["seconds"] = timer.Seconds - }); - } - - private static JiboInteractionDecision BuildAlarmValueDecision( - string loweredTranscript, - bool allowImplicit, - DateTimeOffset? referenceLocalTime, - IReadOnlyDictionary clientEntities) - { - var alarm = TryReadStructuredAlarmValue(clientEntities) ?? - TryParseAlarmValue(loweredTranscript, allowImplicit, referenceLocalTime) ?? - new ClockAlarmValue("7:00", "am"); - - return new JiboInteractionDecision( - "alarm_value", - "Setting your alarm.", - "@be/clock", - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["skillId"] = "@be/clock", - ["domain"] = "alarm", - ["clockIntent"] = "start", - ["time"] = alarm.Time, - ["ampm"] = alarm.AmPm - }); - } - - private static JiboInteractionDecision BuildRadioGenreLaunchDecision(string loweredTranscript) - { - var station = TryResolveRadioGenre(loweredTranscript) ?? "Country"; - - return new JiboInteractionDecision( - "radio_genre", - $"Playing {FormatRadioGenreForSpeech(station)} on the radio.", - "@be/radio", - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["skillId"] = "@be/radio", - ["station"] = station - }); - } - - private static JiboInteractionDecision BuildWordOfTheDayGuessDecision( - IReadOnlyDictionary clientEntities, - string transcript, - IReadOnlyList listenAsrHints) - { - var guess = ResolveWordOfTheDayGuess(clientEntities, transcript, listenAsrHints); - - var reply = string.IsNullOrWhiteSpace(guess) - ? "I heard your word of the day guess." - : $"I heard {guess}."; - - return new JiboInteractionDecision( - "word_of_the_day_guess", - reply, - "@be/word-of-the-day", - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["guess"] = guess, - ["skillId"] = "@be/word-of-the-day", - ["cloudResponseMode"] = "completion_only" - }); - } - - private static string ResolveWordOfTheDayGuess( - IReadOnlyDictionary clientEntities, - string transcript, - IReadOnlyList listenAsrHints) - { - if (clientEntities.TryGetValue("guess", out var guessValue) && - !string.IsNullOrWhiteSpace(guessValue)) - { - return guessValue; - } - - var loweredTranscript = NormalizeGuessToken(transcript); - var hintIndex = loweredTranscript switch - { - "1" or "one" or "first" => 0, - "2" or "two" or "second" => 1, - "3" or "three" or "third" => 2, - _ => -1 - }; - - if (hintIndex >= 0 && hintIndex < listenAsrHints.Count) - { - return listenAsrHints[hintIndex]; - } - - var fuzzyHintMatch = FindClosestHint(loweredTranscript, listenAsrHints); - return !string.IsNullOrWhiteSpace(fuzzyHintMatch) ? fuzzyHintMatch : transcript; - } - - private static bool IsYesNoTurn(TurnContext turn) - { - return ReadRules(turn, "listenRules") - .Concat(ReadRules(turn, "clientRules")) - .Concat(ReadRules(turn, "listenAsrHints")) - .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) - { - if (string.IsNullOrWhiteSpace(normalizedTranscript)) - { - return null; - } - - string? bestHint = null; - var bestDistance = int.MaxValue; - - foreach (var hint in hints) - { - if (string.IsNullOrWhiteSpace(hint)) - { - continue; - } - - var normalizedHint = NormalizeGuessToken(hint); - if (string.IsNullOrWhiteSpace(normalizedHint)) - { - continue; - } - - if (string.Equals(normalizedTranscript, normalizedHint, StringComparison.Ordinal)) - { - return hint; - } - - var distance = ComputeEditDistance(normalizedTranscript, normalizedHint); - if (distance >= bestDistance) continue; - - bestDistance = distance; - bestHint = hint; - } - - return bestDistance <= 2 ? bestHint : null; - } - - private static string NormalizeGuessToken(string value) - { - return value.Trim().TrimEnd('.', '!', '?', ',').ToLowerInvariant(); - } - - private static int ComputeEditDistance(string left, string right) - { - var previous = new int[right.Length + 1]; - var current = new int[right.Length + 1]; - - for (var column = 0; column <= right.Length; column += 1) - { - previous[column] = column; - } - - for (var row = 1; row <= left.Length; row += 1) - { - current[0] = row; - for (var column = 1; column <= right.Length; column += 1) - { - var substitutionCost = left[row - 1] == right[column - 1] ? 0 : 1; - current[column] = Math.Min( - Math.Min(current[column - 1] + 1, previous[column] + 1), - previous[column - 1] + substitutionCost); - } - - (previous, current) = (current, previous); - } - - return previous[right.Length]; - } - - private static string DescribePersonaAge(DateOnly referenceDate, DateOnly birthday) - { - if (referenceDate < birthday) - { - return "just getting started"; - } - - var totalDays = referenceDate.DayNumber - birthday.DayNumber; - if (totalDays <= 31) - { - return $"{FormatAgeUnit(totalDays, "day")} old"; - } - - var totalMonths = (referenceDate.Year - birthday.Year) * 12 + referenceDate.Month - birthday.Month; - if (referenceDate.Day < birthday.Day) - { - totalMonths -= 1; - } - - totalMonths = Math.Max(totalMonths, 0); - if (totalMonths < 12) - { - return $"{FormatAgeUnit(totalMonths, "month")} old"; - } - - var years = totalMonths / 12; - var months = totalMonths % 12; - return months == 0 - ? $"{FormatAgeUnit(years, "year")} old" - : $"{FormatAgeUnit(years, "year")} and {FormatAgeUnit(months, "month")} old"; - } - - private static string FormatAgeUnit(int value, string singular) - { - var plural = value == 1 ? singular : $"{singular}s"; - return $"{value} {plural}"; - } - - private static IEnumerable ReadRules(TurnContext turn, string key) - { - if (!turn.Attributes.TryGetValue(key, out var value) || value is null) - { - return []; - } - - return value switch - { - IReadOnlyList typed => typed, - IEnumerable strings => strings, - JsonElement { ValueKind: JsonValueKind.Array } json => json.EnumerateArray() - .Where(static item => item.ValueKind == JsonValueKind.String) - .Select(static item => item.GetString() ?? string.Empty), - _ => [] - }; - } - - private static IReadOnlyDictionary ReadEntities(TurnContext turn) - { - if (!turn.Attributes.TryGetValue("clientEntities", out var value) || value is null) - { - return new Dictionary(StringComparer.OrdinalIgnoreCase); - } - - return value switch - { - JsonElement { ValueKind: JsonValueKind.Object } json => json.EnumerateObject() - .Where(static property => property.Value.ValueKind == JsonValueKind.String) - .ToDictionary(property => property.Name, property => property.Value.GetString() ?? string.Empty, StringComparer.OrdinalIgnoreCase), - IReadOnlyDictionary typed => typed, - IDictionary dictionary => dictionary - .Where(pair => pair.Value is not null) - .ToDictionary(pair => pair.Key, pair => pair.Value?.ToString() ?? string.Empty, StringComparer.OrdinalIgnoreCase), - _ => new Dictionary(StringComparer.OrdinalIgnoreCase) - }; - } - - private static DateTimeOffset? TryResolveReferenceLocalTime(TurnContext turn) - { - if (!turn.Attributes.TryGetValue("context", out var value) || value is null) - { - return null; - } - - try - { - var contextJson = value.ToString(); - if (string.IsNullOrWhiteSpace(contextJson)) - { - return null; - } - - using var document = JsonDocument.Parse(contextJson); - if (!document.RootElement.TryGetProperty("runtime", out var runtime) || - runtime.ValueKind != JsonValueKind.Object || - !runtime.TryGetProperty("location", out var location) || - location.ValueKind != JsonValueKind.Object || - !location.TryGetProperty("iso", out var iso) || - iso.ValueKind != JsonValueKind.String) - { - return null; - } - - var isoValue = iso.GetString(); - return DateTimeOffset.TryParse(isoValue, out var parsed) - ? parsed - : null; - } - catch - { - return null; - } - } - - private static bool MatchesAny(string loweredTranscript, params string[] candidates) - { - return candidates.Any(candidate => loweredTranscript.Contains(candidate, StringComparison.Ordinal)); - } - - private static bool IsAffirmativeReply(string loweredTranscript) - { - var normalized = NormalizeCommandPhrase(loweredTranscript); - return TryClassifyYesNoReply(normalized) == YesNoReply.Affirmative; - } - - private static bool IsNegativeReply(string loweredTranscript) - { - var normalized = NormalizeCommandPhrase(loweredTranscript); - 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 TryClassifyTrailingYesNoReply(tokens); - } - - 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 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); - if (string.IsNullOrWhiteSpace(normalized)) - { - return false; - } - - if (normalized is "time" or "the time" or "current time" or "what time is it" or "what s the time" or "what is the time") - { - return true; - } - - return normalized.StartsWith("what time", StringComparison.Ordinal) || - normalized.StartsWith("tell me the time", StringComparison.Ordinal) || - normalized.StartsWith("show me the time", StringComparison.Ordinal); - } - - private static bool IsDateRequest(string loweredTranscript) - { - var normalized = NormalizeCommandPhrase(loweredTranscript); - if (string.IsNullOrWhiteSpace(normalized)) - { - return false; - } - - return normalized is - "what is the date" or - "what s the date" or - "what date is it" or - "today s date" or - "today date" or - "what is today s date" or - "what s today s date" or - "what is todays date" or - "what s todays date"; - } - - private static bool IsWeatherRequest(string loweredTranscript) - { - var normalized = NormalizeCommandPhrase(loweredTranscript); - if (IsWeatherTopicQuestion(normalized)) - { - return true; - } - - if (MatchesAny( - loweredTranscript, - "weather", - "forecast", - "how is the weather", - "how s the weather", - "how's the weather", - "check the weather", - "weather report", - "what's today s weather", - "what's today's weather", - "what is the weather", - "what will the weather", - "what will tomorrow s weather", - "what will tomorrow's weather", - "look up the forecast", - "launch the weather skill", - "what is today s humidity", - "what is today's humidity", - "what's the humidity", - "what is the humidity", - "what's today's forecast", - "what s today's forecast", - "what s today s forecast", - "what is today s forecast", - "what is today's forecast", - "what's today's weather look like", - "what s today's weather look like", - "what s today s weather look like", - "what is today s weather look like", - "what is today's weather look like")) - { - return true; - } - - if (MatchesAny( - loweredTranscript, - "will it rain", - "will it snow", - "is it raining", - "is it snowing", - "is there going to be hail", - "does it look like rain", - "does it seem like snow", - "is it going to rain", - "is it going to snow", - "do you think it will rain", - "do you think it will snow")) - { - return true; - } - - return WeatherConditionForecastPattern.IsMatch(loweredTranscript); - } - - private static bool IsWeatherTopicQuestion(string normalizedTranscript) - { - if (string.IsNullOrWhiteSpace(normalizedTranscript)) - { - return false; - } - - var mentionsWeatherTopic = - normalizedTranscript.Contains("weather", StringComparison.Ordinal) || - normalizedTranscript.Contains("forecast", StringComparison.Ordinal) || - normalizedTranscript.Contains("temperature", StringComparison.Ordinal) || - normalizedTranscript.Contains("humidity", StringComparison.Ordinal); - if (!mentionsWeatherTopic) - { - return false; - } - - if (normalizedTranscript.StartsWith("what ", StringComparison.Ordinal) || - normalizedTranscript.StartsWith("how ", StringComparison.Ordinal) || - normalizedTranscript.StartsWith("check ", StringComparison.Ordinal) || - normalizedTranscript.StartsWith("show ", StringComparison.Ordinal) || - normalizedTranscript.StartsWith("tell ", StringComparison.Ordinal) || - normalizedTranscript.StartsWith("look up ", StringComparison.Ordinal) || - normalizedTranscript.StartsWith("launch ", StringComparison.Ordinal) || - normalizedTranscript.StartsWith("give me ", StringComparison.Ordinal) || - normalizedTranscript.StartsWith("temperature ", StringComparison.Ordinal) || - normalizedTranscript.StartsWith("forecast ", StringComparison.Ordinal) || - normalizedTranscript.StartsWith("weather ", StringComparison.Ordinal)) - { - return true; - } - - return WeatherTopicLocationPattern.IsMatch(normalizedTranscript); - } - - private static string? TryResolveWeatherLocationQuery(string transcript) - { - var normalized = NormalizeCommandPhrase(transcript); - var match = WeatherLocationPattern.Match(normalized); - if (!match.Success) - { - return null; - } - - var candidate = match.Groups["location"].Value.Trim(); - if (string.IsNullOrWhiteSpace(candidate)) - { - return null; - } - - candidate = WeatherLocationSuffixPattern.Replace(candidate, string.Empty).Trim(); - if (string.IsNullOrWhiteSpace(candidate) || - GenericWeatherLocationTerms.Contains(candidate)) - { - return null; - } - - return string.IsNullOrWhiteSpace(candidate) - ? null - : CultureInfo.InvariantCulture.TextInfo.ToTitleCase(candidate); - } - - private static (double Latitude, double Longitude)? TryResolveWeatherCoordinates(TurnContext turn) - { - if (!turn.Attributes.TryGetValue("context", out var contextValue) || - contextValue is null || - string.IsNullOrWhiteSpace(contextValue.ToString())) - { - return null; - } - - try - { - using var document = JsonDocument.Parse(contextValue.ToString()!); - if (!document.RootElement.TryGetProperty("runtime", out var runtime) || - runtime.ValueKind != JsonValueKind.Object || - !runtime.TryGetProperty("location", out var location) || - location.ValueKind != JsonValueKind.Object) - { - return null; - } - - var latitude = TryReadDoubleProperty(location, "lat", "latitude"); - var longitude = TryReadDoubleProperty(location, "lng", "lon", "longitude"); - return latitude is not null && longitude is not null - ? (latitude.Value, longitude.Value) - : null; - } - catch - { - return null; - } - } - - private static GreetingPresenceProfile ResolveGreetingPresenceProfile(TurnContext turn) - { - if (!turn.Attributes.TryGetValue("context", out var contextValue) || - contextValue is null || - string.IsNullOrWhiteSpace(contextValue.ToString())) - { - return GreetingPresenceProfile.Empty; - } - - try - { - using var document = JsonDocument.Parse(contextValue.ToString()!); - if (!document.RootElement.TryGetProperty("runtime", out var runtime) || - runtime.ValueKind != JsonValueKind.Object) - { - return GreetingPresenceProfile.Empty; - } - - var loopUsers = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (runtime.TryGetProperty("loop", out var loop) && - loop.ValueKind == JsonValueKind.Object && - loop.TryGetProperty("users", out var users) && - users.ValueKind == JsonValueKind.Array) - { - foreach (var user in users.EnumerateArray()) - { - var id = TryReadStringProperty(user, "id"); - var firstName = TryReadStringProperty(user, "firstName"); - if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(firstName)) - { - loopUsers[id] = firstName; - } - } - } - - var speakerId = string.Empty; - var peoplePresentIds = new List(); - if (runtime.TryGetProperty("perception", out var perception) && - perception.ValueKind == JsonValueKind.Object) - { - if (perception.TryGetProperty("speaker", out var speaker)) - { - if (speaker.ValueKind == JsonValueKind.String) - { - speakerId = speaker.GetString() ?? string.Empty; - } - else if (speaker.ValueKind == JsonValueKind.Object) - { - speakerId = TryReadStringProperty(speaker, "id", "looperID", "looperId") ?? string.Empty; - } - } - - if (perception.TryGetProperty("peoplePresent", out var peoplePresent) && - peoplePresent.ValueKind == JsonValueKind.Array) - { - foreach (var person in peoplePresent.EnumerateArray()) - { - var personId = person.ValueKind switch - { - JsonValueKind.String => person.GetString(), - JsonValueKind.Object => TryReadStringProperty(person, "id", "looperID", "looperId"), - _ => null - }; - - if (!string.IsNullOrWhiteSpace(personId) && - !string.Equals(personId, "NOT_TRAINED", StringComparison.OrdinalIgnoreCase)) - { - peoplePresentIds.Add(personId); - } - } - } - } - - var triggerLooperId = turn.Attributes.TryGetValue("triggerLooperId", out var rawTriggerLooperId) - ? rawTriggerLooperId?.ToString() - : null; - var primaryPersonId = !string.IsNullOrWhiteSpace(speakerId) - ? speakerId - : !string.IsNullOrWhiteSpace(triggerLooperId) - ? triggerLooperId - : peoplePresentIds.FirstOrDefault(); - - return new GreetingPresenceProfile( - primaryPersonId, - string.IsNullOrWhiteSpace(speakerId) ? null : speakerId, - peoplePresentIds, - loopUsers); - } - catch - { - return GreetingPresenceProfile.Empty; - } - } - - private static string? TryReadStringProperty(JsonElement source, params string[] propertyNames) - { - foreach (var propertyName in propertyNames) - { - if (source.TryGetProperty(propertyName, out var value) && - value.ValueKind == JsonValueKind.String && - !string.IsNullOrWhiteSpace(value.GetString())) - { - return value.GetString(); - } - } - - return null; - } - - private static double? TryReadDoubleProperty(JsonElement source, params string[] propertyNames) - { - foreach (var propertyName in propertyNames) - { - if (source.TryGetProperty(propertyName, out var value) && - value.ValueKind == JsonValueKind.Number && - value.TryGetDouble(out var parsed)) - { - return parsed; - } - } - - return null; - } - - private static bool? ShouldUseCelsius(TurnContext turn, string transcript) - { - var normalized = NormalizeCommandPhrase(transcript); - if (normalized.Contains("celsius", StringComparison.Ordinal) || - normalized.Contains("centigrade", StringComparison.Ordinal)) - { - return true; - } - - if (normalized.Contains("fahrenheit", StringComparison.Ordinal)) - { - return false; - } - - var entities = ReadEntities(turn); - if (entities.TryGetValue("temperatureUnit", out var entityUnit)) - { - if (entityUnit.Contains("celsius", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - if (entityUnit.Contains("fahrenheit", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - } - - var locale = turn.Locale ?? string.Empty; - if (locale.EndsWith("-US", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - return null; - } - - private static WeatherDateEntity ResolveWeatherDateEntity( - TurnContext turn, - string transcript, - string normalizedTranscript, - DateTimeOffset? referenceLocalTime) - { - normalizedTranscript = string.IsNullOrWhiteSpace(normalizedTranscript) - ? NormalizeCommandPhrase(transcript) - : normalizedTranscript; - - if (TryResolveWeatherDateEntityFromTranscript(normalizedTranscript, referenceLocalTime, out var entityFromTranscript)) - { - return entityFromTranscript; - } - - var entities = ReadEntities(turn); - if (TryResolveWeatherDateEntityFromClientEntities(entities, referenceLocalTime, out var entityFromClient) && - ShouldAcceptClientWeatherDateEntity(normalizedTranscript)) - { - return entityFromClient; - } - - return WeatherDateEntity.None; - } - - private static bool TryResolveWeatherDateEntityFromTranscript( - string normalizedTranscript, - DateTimeOffset? referenceLocalTime, - out WeatherDateEntity weatherDate) - { - weatherDate = WeatherDateEntity.None; - if (string.IsNullOrWhiteSpace(normalizedTranscript)) - { - return false; - } - - if (normalizedTranscript.Contains("day after tomorrow", StringComparison.Ordinal)) - { - weatherDate = new WeatherDateEntity("day_after_tomorrow", 2, "The day after tomorrow"); - return true; - } - - if (MatchesAny(normalizedTranscript, "tomorrow", "tomorrow s", "tomorrow's")) - { - weatherDate = new WeatherDateEntity("tomorrow", 1, "Tomorrow"); - return true; - } - - if (referenceLocalTime is not null && - TryResolveWeatherTimeRangeOffset(normalizedTranscript, referenceLocalTime.Value, out var rangeOffset, out var rangeLeadIn) && - rangeOffset > 0) - { - weatherDate = new WeatherDateEntity("range", rangeOffset, rangeLeadIn); - return true; - } - - if (referenceLocalTime is not null && - TryResolveWeatherDayOfWeekOffset(normalizedTranscript, referenceLocalTime.Value, out var dayOffset, out var dayName) && - dayOffset > 0) - { - weatherDate = new WeatherDateEntity("weekday", dayOffset, $"On {dayName}"); - return true; - } - - return false; - } - - private static bool ShouldAcceptClientWeatherDateEntity(string normalizedTranscript) - { - if (string.IsNullOrWhiteSpace(normalizedTranscript)) - { - return true; - } - - if (HasExplicitWeatherDateCue(normalizedTranscript)) - { - return false; - } - - if (HasWeatherLocationClause(normalizedTranscript)) - { - return false; - } - - return !normalizedTranscript.Contains("forecast", StringComparison.Ordinal); - } - - private static bool HasExplicitWeatherDateCue(string normalizedTranscript) - { - if (string.IsNullOrWhiteSpace(normalizedTranscript)) - { - return false; - } - - if (MatchesAny( - normalizedTranscript, - "today", - "today s", - "today's", - "tonight", - "tomorrow", - "tomorrow s", - "tomorrow's", - "day after tomorrow", - "this week", - "next week", - "weekend", - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", - "sunday")) - { - return true; - } - - return WeatherDayOfWeekPattern.IsMatch(normalizedTranscript); - } - - private static bool HasWeatherLocationClause(string normalizedTranscript) - { - if (string.IsNullOrWhiteSpace(normalizedTranscript)) - { - return false; - } - - return WeatherTopicLocationPattern.IsMatch(normalizedTranscript) || - WeatherLocationPattern.IsMatch(normalizedTranscript); - } - - private static bool TryResolveWeatherDateEntityFromClientEntities( - IReadOnlyDictionary clientEntities, - DateTimeOffset? referenceLocalTime, - out WeatherDateEntity weatherDate) - { - weatherDate = WeatherDateEntity.None; - if (!TryReadClientWeatherDateValue(clientEntities, out var rawDateValue)) - { - return false; - } - - var normalizedDate = NormalizeCommandPhrase(rawDateValue); - if (normalizedDate.Contains("day after tomorrow", StringComparison.Ordinal)) - { - weatherDate = new WeatherDateEntity("day_after_tomorrow", 2, "The day after tomorrow"); - return true; - } - - if (MatchesAny(normalizedDate, "tomorrow", "tomorrow s", "tomorrow's")) - { - weatherDate = new WeatherDateEntity("tomorrow", 1, "Tomorrow"); - return true; - } - - if (referenceLocalTime is not null && - TryResolveWeatherTimeRangeOffset(normalizedDate, referenceLocalTime.Value, out var rangeOffset, out var rangeLeadIn) && - rangeOffset > 0) - { - weatherDate = new WeatherDateEntity("range", rangeOffset, rangeLeadIn); - return true; - } - - DateOnly targetDate; - if (DateOnly.TryParse(rawDateValue, out var parsedDate)) - { - targetDate = parsedDate; - } - else if (DateTimeOffset.TryParse(rawDateValue, out var parsedDateTimeOffset)) - { - targetDate = DateOnly.FromDateTime(parsedDateTimeOffset.DateTime); - } - else - { - return false; - } - - var referenceDate = DateOnly.FromDateTime((referenceLocalTime ?? DateTimeOffset.UtcNow).DateTime); - var dayOffset = targetDate.DayNumber - referenceDate.DayNumber; - if (dayOffset <= 0) - { - return false; - } - - weatherDate = dayOffset == 1 - ? new WeatherDateEntity("tomorrow", 1, "Tomorrow") - : new WeatherDateEntity( - "date", - dayOffset, - $"On {targetDate.ToDateTime(TimeOnly.MinValue).ToString("dddd", CultureInfo.InvariantCulture)}"); - return true; - } - - private static bool TryReadClientWeatherDateValue( - IReadOnlyDictionary clientEntities, - out string dateValue) - { - foreach (var key in WeatherDateEntityKeys) - { - if (!clientEntities.TryGetValue(key, out var rawValue) || - string.IsNullOrWhiteSpace(rawValue)) - { - continue; - } - - dateValue = rawValue.Trim(); - return true; - } - - dateValue = string.Empty; - return false; - } - - private static bool TryResolveWeatherDayOfWeekOffset( - string normalizedTranscript, - DateTimeOffset referenceLocalTime, - out int dayOffset, - out string dayName) - { - dayOffset = 0; - dayName = string.Empty; - - var match = WeatherDayOfWeekPattern.Match(normalizedTranscript); - if (!match.Success) - { - return false; - } - - var dayToken = match.Groups["day"].Value; - if (!TryParseDayOfWeek(dayToken, out var targetDay)) - { - return false; - } - - var currentDay = referenceLocalTime.DayOfWeek; - dayOffset = ((int)targetDay - (int)currentDay + 7) % 7; - if (match.Groups["next"].Success) - { - dayOffset = dayOffset == 0 ? 7 : dayOffset + 7; - } - else if (match.Groups["this"].Success && dayOffset == 0) - { - return false; - } - - dayName = CultureInfo.InvariantCulture.TextInfo.ToTitleCase(dayToken); - return dayOffset > 0; - } - - private static bool TryResolveWeatherTimeRangeOffset( - string normalizedTranscript, - DateTimeOffset referenceLocalTime, - out int dayOffset, - out string leadIn) - { - dayOffset = 0; - leadIn = string.Empty; - if (string.IsNullOrWhiteSpace(normalizedTranscript)) - { - return false; - } - - var hasNextWeekend = normalizedTranscript.Contains("next weekend", StringComparison.Ordinal); - var hasThisWeekend = - normalizedTranscript.Contains("this weekend", StringComparison.Ordinal) || - normalizedTranscript.Contains("the weekend", StringComparison.Ordinal) || - normalizedTranscript.EndsWith("weekend", StringComparison.Ordinal); - if (hasNextWeekend || hasThisWeekend) - { - dayOffset = ((int)DayOfWeek.Saturday - (int)referenceLocalTime.DayOfWeek + 7) % 7; - if (hasNextWeekend) - { - dayOffset = dayOffset + 7; - leadIn = "Next weekend"; - } - else - { - // If it's already Saturday, prefer forecasting Sunday for "this weekend". - if (dayOffset == 0 && referenceLocalTime.DayOfWeek == DayOfWeek.Saturday) - { - dayOffset = 1; - } - - leadIn = "This weekend"; - } - - return dayOffset > 0; - } - - var hasNextWeek = normalizedTranscript.Contains("next week", StringComparison.Ordinal); - if (hasNextWeek) - { - dayOffset = 7; - leadIn = "Next week"; - return true; - } - - var hasThisWeek = normalizedTranscript.Contains("this week", StringComparison.Ordinal); - if (hasThisWeek) - { - dayOffset = referenceLocalTime.DayOfWeek == DayOfWeek.Saturday ? 1 : 2; - leadIn = "Later this week"; - return true; - } - - return false; - } - - private static bool TryParseDayOfWeek(string dayToken, out DayOfWeek dayOfWeek) - { - dayOfWeek = DayOfWeek.Sunday; - return dayToken switch - { - "monday" => AssignDayOfWeek(DayOfWeek.Monday, out dayOfWeek), - "tuesday" => AssignDayOfWeek(DayOfWeek.Tuesday, out dayOfWeek), - "wednesday" => AssignDayOfWeek(DayOfWeek.Wednesday, out dayOfWeek), - "thursday" => AssignDayOfWeek(DayOfWeek.Thursday, out dayOfWeek), - "friday" => AssignDayOfWeek(DayOfWeek.Friday, out dayOfWeek), - "saturday" => AssignDayOfWeek(DayOfWeek.Saturday, out dayOfWeek), - "sunday" => AssignDayOfWeek(DayOfWeek.Sunday, out dayOfWeek), - _ => false - }; - } - - private static bool AssignDayOfWeek(DayOfWeek value, out DayOfWeek target) - { - target = value; - return true; - } - - private static string? TryResolveWeatherConditionEntity(string transcript) - { - var normalized = NormalizeCommandPhrase(transcript); - return normalized switch - { - _ when normalized.Contains("rain", StringComparison.Ordinal) => "rain", - _ when normalized.Contains("snow", StringComparison.Ordinal) => "snow", - _ when normalized.Contains("hail", StringComparison.Ordinal) => "hail", - _ when normalized.Contains("sunny", StringComparison.Ordinal) || normalized.Contains("clear", StringComparison.Ordinal) => "sunny", - _ when normalized.Contains("cloud", StringComparison.Ordinal) => "cloudy", - _ when normalized.Contains("wind", StringComparison.Ordinal) => "windy", - _ when normalized.Contains("fog", StringComparison.Ordinal) => "fog", - _ => null - }; - } - - private static bool IsWelcomeBackGreeting(string loweredTranscript) - { - return MatchesAny( - loweredTranscript, - "i am back", - "i m back", - "im back", - "i am home", - "i m home", - "im home", - "i'm back", - "i'm home", - "welcome back"); - } - - private static bool IsGoodMorningGreeting(string loweredTranscript) - { - return MatchesAny( - loweredTranscript, - "good morning", - "morning jibo", - "morning, jibo"); - } - - private static bool IsGoodAfternoonGreeting(string loweredTranscript) - { - return MatchesAny( - loweredTranscript, - "good afternoon", - "afternoon jibo", - "afternoon, jibo"); - } - - private static bool IsGoodEveningGreeting(string loweredTranscript) - { - return MatchesAny( - loweredTranscript, - "good evening", - "evening jibo", - "evening, jibo"); - } - - private static bool IsGoodNightGreeting(string loweredTranscript) - { - return MatchesAny( - loweredTranscript, - "good night", - "night jibo", - "night, jibo"); - } - - private static bool IsDanceQuestion(string loweredTranscript) - { - return MatchesAny( - loweredTranscript, - "do you like to dance", - "do you like dancing", - "what kind of dance do you like", - "what kind of dancing do you like", - "do you enjoy dancing"); - } - - private static bool IsRobotBirthdayQuestion(string loweredTranscript) - { - var normalized = NormalizeCommandPhrase(loweredTranscript); - if (MatchesAny( - normalized, - "when is your birthday", - "when s your birthday", - "what s your birthday", - "what is your birthday", - "when is your bday", - "when s your bday", - "what s your bday", - "what is your bday", - "when were you born", - "what day is your birthday")) - { - return true; - } - - return (normalized.Contains("your birthday", StringComparison.Ordinal) || - normalized.Contains("your bday", StringComparison.Ordinal) || - normalized.Contains("your birth date", StringComparison.Ordinal)) - && !normalized.Contains("my birthday", StringComparison.Ordinal); - } - - private static bool IsNameSetStatement(string loweredTranscript) - { - return TryExtractNameFact(loweredTranscript) is not null; - } - - private static bool IsNameRecallQuestion(string loweredTranscript) - { - return MatchesAny( - loweredTranscript, - "what is my name", - "what s my name", - "what's my name", - "who am i", - "do you remember my name", - "do you know me", - "do you remember me", - "who is this", - "can you recognize me"); - } - - private static string? TryExtractNameFact(string transcript) - { - var normalized = NormalizeCommandPhrase(transcript); - var prefixes = new[] - { - "my name is ", - "call me " - }; - - foreach (var prefix in prefixes) - { - if (!normalized.StartsWith(prefix, StringComparison.Ordinal)) - { - continue; - } - - var name = normalized[prefix.Length..].Trim(); - return string.IsNullOrWhiteSpace(name) ? null : name; - } - - return null; - } - - private static bool IsUserBirthdayRecallQuestion(string loweredTranscript) - { - return MatchesAny( - loweredTranscript, - "when is my birthday", - "when's my birthday", - "what is my birthday", - "what s my birthday", - "what's my birthday", - "when is my bday", - "when s my bday", - "what is my bday", - "what s my bday", - "what's my bday", - "do you remember my birthday"); - } - - private static bool IsUserBirthdaySetStatement(string loweredTranscript) - { - return TryExtractBirthdayFact(loweredTranscript) is not null; - } - - private static bool IsUserBirthdaySetAttempt(string loweredTranscript) - { - var normalized = NormalizeCommandPhrase(loweredTranscript); - return normalized.Contains("my birthday is", StringComparison.Ordinal) || - normalized.Contains("my bday is", StringComparison.Ordinal); - } - - private static bool IsUserBirthdayRecallAttempt(string loweredTranscript) - { - var normalized = NormalizeCommandPhrase(loweredTranscript); - return (normalized.Contains("my birthday", StringComparison.Ordinal) || - normalized.Contains("my bday", StringComparison.Ordinal)) && - (normalized.StartsWith("when", StringComparison.Ordinal) || - normalized.StartsWith("what", StringComparison.Ordinal) || - normalized.StartsWith("tell me", StringComparison.Ordinal) || - normalized.StartsWith("do you remember", StringComparison.Ordinal)); - } - - private static string? TryExtractBirthdayFact(string transcript) - { - var normalized = NormalizeCommandPhrase(transcript); - var markers = new[] - { - "my birthday is ", - "my bday is " - }; - - foreach (var marker in markers) - { - var markerIndex = normalized.IndexOf(marker, StringComparison.Ordinal); - if (markerIndex < 0) - { - continue; - } - - var value = normalized[(markerIndex + marker.Length)..].Trim(); - if (!string.IsNullOrWhiteSpace(value)) - { - return value; - } - } - - return null; - } - - private static bool IsPreferenceRecallQuestion(string loweredTranscript) - { - return TryExtractPreferenceLookupCategory(loweredTranscript) is not null; - } - - private static bool IsPreferenceSetStatement(string loweredTranscript) - { - return TryExtractPreferenceSet(loweredTranscript) is not null; - } - - private static bool IsPreferenceSetAttempt(string loweredTranscript) - { - var normalized = NormalizeCommandPhrase(loweredTranscript); - if (IsPreferenceRecallAttempt(normalized)) - { - return false; - } - - return normalized.Contains("my favorite", StringComparison.Ordinal) || - normalized.Contains("my favourite", StringComparison.Ordinal) || - PreferenceReverseMarkers.Any(marker => normalized.Contains(marker, StringComparison.Ordinal)); - } - - private static bool IsPreferenceRecallAttempt(string loweredTranscript) - { - var normalized = NormalizeCommandPhrase(loweredTranscript); - return normalized.StartsWith("what is my favorite", StringComparison.Ordinal) || - normalized.StartsWith("what s my favorite", StringComparison.Ordinal) || - normalized.StartsWith("what is my favourite", StringComparison.Ordinal) || - normalized.StartsWith("what s my favourite", StringComparison.Ordinal) || - normalized.StartsWith("do you remember my favorite", StringComparison.Ordinal) || - normalized.StartsWith("do you remember my favourite", StringComparison.Ordinal); - } - - private static string? TryExtractPreferenceLookupCategory(string transcript) - { - var normalized = NormalizeCommandPhrase(transcript); - var prefixes = new[] - { - "what is my favorite ", - "what s my favorite ", - "what's my favorite ", - "do you remember my favorite ", - "what is my favourite ", - "what s my favourite ", - "what's my favourite ", - "do you remember my favourite " - }; - - foreach (var prefix in prefixes) - { - if (!normalized.StartsWith(prefix, StringComparison.Ordinal)) - { - continue; - } - - var category = normalized[prefix.Length..].Trim(); - return string.IsNullOrWhiteSpace(category) ? null : category; - } - - return null; - } - - private static (string Category, string Value)? TryExtractPreferenceSet(string transcript) - { - var normalized = NormalizeCommandPhrase(transcript); - foreach (var marker in PreferenceSetMarkers) - { - var markerIndex = normalized.IndexOf(marker, StringComparison.Ordinal); - if (markerIndex < 0) - { - continue; - } - - var preferencePhrase = normalized[(markerIndex + marker.Length)..]; - var splitMarker = " is "; - var splitIndex = preferencePhrase.IndexOf(splitMarker, StringComparison.Ordinal); - if (splitIndex <= 0 || splitIndex >= preferencePhrase.Length - splitMarker.Length) - { - var fallbackPreference = TryExtractPreferenceSetWithoutCopula(preferencePhrase); - if (fallbackPreference is not null) - { - return fallbackPreference; - } - - continue; - } - - var category = preferencePhrase[..splitIndex].Trim(); - var value = preferencePhrase[(splitIndex + splitMarker.Length)..].Trim(); - if (!string.IsNullOrWhiteSpace(category) && !string.IsNullOrWhiteSpace(value)) - { - return (category, value); - } - } - - if (normalized.StartsWith("what ", StringComparison.Ordinal) || - normalized.StartsWith("do you remember ", StringComparison.Ordinal)) - { - return null; - } - - foreach (var marker in PreferenceReverseMarkers) - { - var markerIndex = normalized.IndexOf(marker, StringComparison.Ordinal); - if (markerIndex <= 0 || markerIndex >= normalized.Length - marker.Length) - { - continue; - } - - var value = normalized[..markerIndex].Trim(); - var category = normalized[(markerIndex + marker.Length)..].Trim(); - if (!string.IsNullOrWhiteSpace(category) && !string.IsNullOrWhiteSpace(value)) - { - return (category, value); - } - } - - return null; - } - - private static (string Category, string Value)? TryExtractPreferenceSetWithoutCopula(string preferencePhrase) - { - if (string.IsNullOrWhiteSpace(preferencePhrase)) - { - return null; - } - - var normalized = preferencePhrase.Trim(); - if (normalized.Contains(" is ", StringComparison.Ordinal) || - normalized.Contains(" are ", StringComparison.Ordinal) || - normalized.EndsWith(" is", StringComparison.Ordinal) || - normalized.EndsWith(" are", StringComparison.Ordinal)) - { - return null; - } - - var parts = normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (parts.Length < 2) - { - return null; - } - - var category = parts[0]; - var value = string.Join(' ', parts.Skip(1)).Trim(); - if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(value)) - { - return null; - } - - return (category, value); - } - - private static bool IsImportantDateSetStatement(string loweredTranscript) - { - return TryExtractImportantDateSet(loweredTranscript) is not null; - } - - private static bool IsImportantDateRecallQuestion(string loweredTranscript) - { - return TryExtractImportantDateLookupLabel(loweredTranscript) is not null; - } - - private static (string Label, string Value)? TryExtractImportantDateSet(string transcript) - { - var normalized = NormalizeCommandPhrase(transcript); - var mapping = new (string Prefix, string Label)[] - { - ("our anniversary is ", "anniversary"), - ("my anniversary is ", "anniversary"), - ("our wedding anniversary is ", "anniversary") - }; - - foreach (var (prefix, label) in mapping) - { - if (!normalized.StartsWith(prefix, StringComparison.Ordinal)) - { - continue; - } - - var value = normalized[prefix.Length..].Trim(); - if (!string.IsNullOrWhiteSpace(value)) - { - return (label, value); - } - } - - return null; - } - - private static string? TryExtractImportantDateLookupLabel(string transcript) - { - var normalized = NormalizeCommandPhrase(transcript); - var candidates = new[] - { - "when is our anniversary", - "when s our anniversary", - "when's our anniversary", - "when is my anniversary", - "what is our anniversary", - "do you remember our anniversary" - }; - - return candidates.Any(candidate => string.Equals(normalized, candidate, StringComparison.Ordinal)) - ? "anniversary" - : null; - } - - private static bool IsAffinitySetStatement(string loweredTranscript) - { - return TryExtractAffinitySet(loweredTranscript) is not null; - } - - private static bool IsAffinitySetAttempt(string loweredTranscript) - { - var normalized = NormalizeCommandPhrase(loweredTranscript); - return PegasusUserAffinitySetPrefixes.Any(prefix => MatchesPrefixOrStem(normalized, prefix.Prefix)); - } - - private static bool IsAffinityRecallQuestion(string loweredTranscript) - { - return TryExtractAffinityLookup(loweredTranscript) is not null; - } - - private static bool IsAffinityRecallAttempt(string loweredTranscript) - { - var normalized = NormalizeCommandPhrase(loweredTranscript); - return PegasusUserAffinityLookupPrefixes.Any(prefix => MatchesPrefixOrStem(normalized, prefix.Prefix)); - } - - private static bool MatchesPrefixOrStem(string normalized, string prefix) - { - return normalized.StartsWith(prefix, StringComparison.Ordinal) || - string.Equals(normalized, prefix.TrimEnd(), StringComparison.Ordinal); - } - - private static (string Item, PersonalAffinity Affinity)? TryExtractAffinitySet(string transcript) - { - var normalized = NormalizeCommandPhrase(transcript); - - foreach (var (prefix, affinity) in PegasusUserAffinitySetPrefixes) - { - if (!normalized.StartsWith(prefix, StringComparison.Ordinal)) - { - continue; - } - - var item = normalized[prefix.Length..].Trim(); - if (!string.IsNullOrWhiteSpace(item)) - { - return (item, affinity); - } - } - - return null; - } - - private static (string Item, PersonalAffinity? ExpectedAffinity)? TryExtractAffinityLookup(string transcript) - { - var normalized = NormalizeCommandPhrase(transcript); - - foreach (var (prefix, expectedAffinity) in PegasusUserAffinityLookupPrefixes) - { - if (!normalized.StartsWith(prefix, StringComparison.Ordinal)) - { - continue; - } - - var item = normalized[prefix.Length..].Trim(); - if (!string.IsNullOrWhiteSpace(item)) - { - return (item, expectedAffinity); - } - } - - return null; - } - - private static string DescribeAffinityAsVerb(PersonalAffinity affinity) - { - return affinity switch - { - PersonalAffinity.Love => "love", - PersonalAffinity.Like => "like", - PersonalAffinity.Dislike => "dislike", - _ => "like" - }; - } - - private static PersonalMemoryTenantScope ResolveTenantScope(TurnContext turn, string? personId = null) - { - var accountId = ReadTenantAttribute(turn, "accountId") ?? "usr_openjibo_owner"; - var loopId = ReadTenantAttribute(turn, "loopId") ?? "openjibo-default-loop"; - var deviceId = turn.DeviceId ?? ReadTenantAttribute(turn, "deviceId") ?? "unknown-device"; - var resolvedPersonId = !string.IsNullOrWhiteSpace(personId) - ? personId - : ReadTenantAttribute(turn, "personId") ?? ReadTenantAttribute(turn, "speakerId"); - return new PersonalMemoryTenantScope(accountId, loopId, deviceId, resolvedPersonId); - } - - private static string? ReadTenantAttribute(TurnContext turn, string key) - { - return turn.Attributes.TryGetValue(key, out var value) - ? value?.ToString() - : null; - } - - private static string? TryResolveRadioGenre(string loweredTranscript) - { - foreach (var (phrase, station) in RadioGenreAliases) - { - if (loweredTranscript.Contains(phrase, StringComparison.Ordinal)) - { - return station; - } - } - - return null; - } - - private static string FormatRadioGenreForSpeech(string station) - { - return station switch - { - "EightiesAndNinetiesHits" => "eighties and nineties hits", - "ChristianAndGospel" => "Christian and gospel", - "ClassicRock" => "classic rock", - "CollegeRadio" => "college radio", - "HipHop" => "hip hop", - "NewsAndTalk" => "news and talk", - "ReggaeAndIsland" => "reggae and island music", - "SoftRock" => "soft rock", - _ => station - }; - } - - private static ClockTimerValue? TryParseTimerValue(string loweredTranscript, bool allowImplicit = false) - { - if (!allowImplicit && !loweredTranscript.Contains("timer", StringComparison.Ordinal)) - { - return null; - } - - var hours = ExtractDurationValue(loweredTranscript, "hour"); - var minutes = ExtractDurationValue(loweredTranscript, "minute"); - var seconds = ExtractDurationValue(loweredTranscript, "second"); - - if (hours is null && minutes is null && seconds is null) - { - return null; - } - - return new ClockTimerValue( - (hours ?? 0).ToString(), - (minutes ?? 0).ToString(), - seconds is null ? "null" : seconds.Value.ToString()); - } - - private static ClockAlarmValue? TryParseAlarmValue( - string loweredTranscript, - bool allowImplicit = false, - DateTimeOffset? referenceLocalTime = null) - { - if (!allowImplicit && !loweredTranscript.Contains("alarm", StringComparison.Ordinal)) - { - return null; - } - - var compactMatch = CompactAlarmPattern.Match(loweredTranscript); - if (compactMatch.Success) - { - var compact = compactMatch.Groups["compact"].Value; - if (int.TryParse(compact, out var compactValue)) - { - var compactHour = compact.Length switch - { - 3 or 4 => compactValue / 100, - _ => -1 - }; - var compactMinute = compact.Length switch - { - 3 or 4 => compactValue % 100, - _ => -1 - }; - if (compactHour is >= 1 and <= 12 && compactMinute is >= 0 and <= 59) - { - var compactAmPm = ResolveAmPm(compactMatch.Groups["ampm"].Value, compactHour, compactMinute, referenceLocalTime); - return new ClockAlarmValue($"{compactHour}:{compactMinute:00}", compactAmPm); - } - } - } - - var match = SplitAlarmPattern.Match(loweredTranscript); - if (!match.Success) - { - return null; - } - - var hourToken = match.Groups["hour"].Value; - var minuteToken = match.Groups["minute"].Success ? match.Groups["minute"].Value : "00"; - var hour = ParseNumberToken(hourToken); - if (hour is null or < 1 or > 12) - { - return null; - } - - var minute = ParseNumberToken(minuteToken); - if (minute is null or < 0 or > 59) - { - return null; - } - - var ampm = ResolveAmPm(match.Groups["ampm"].Value, hour.Value, minute.Value, referenceLocalTime); - return new ClockAlarmValue($"{hour}:{minute:00}", ampm); - } - - private static string ResolveAmPm(string token, int hour, int minute, DateTimeOffset? referenceLocalTime) - { - var normalized = token.Replace(" ", string.Empty, StringComparison.Ordinal) - .Replace(".", string.Empty, StringComparison.Ordinal); - if (normalized.StartsWith("p", StringComparison.OrdinalIgnoreCase)) - { - return "pm"; - } - - if (normalized.StartsWith("a", StringComparison.OrdinalIgnoreCase)) - { - return "am"; - } - - return referenceLocalTime.HasValue - ? ResolveNextOccurrenceAmPm(hour, minute, referenceLocalTime.Value) - : "am"; - } - - private static string ResolveNextOccurrenceAmPm(int hour, int minute, DateTimeOffset referenceLocalTime) - { - var amCandidate = BuildAlarmCandidate(referenceLocalTime, hour, minute, isPm: false); - var pmCandidate = BuildAlarmCandidate(referenceLocalTime, hour, minute, isPm: true); - return amCandidate <= pmCandidate ? "am" : "pm"; - } - - private static DateTimeOffset BuildAlarmCandidate(DateTimeOffset referenceLocalTime, int hour, int minute, bool isPm) - { - var hour24 = hour % 12; - if (isPm) - { - hour24 += 12; - } - - var candidate = new DateTimeOffset( - referenceLocalTime.Year, - referenceLocalTime.Month, - referenceLocalTime.Day, - hour24, - minute, - 0, - referenceLocalTime.Offset); - - if (candidate <= referenceLocalTime) - { - candidate = candidate.AddDays(1); - } - - return candidate; - } - - private static bool HasStructuredTimerValue(IReadOnlyDictionary clientEntities) - { - return clientEntities.ContainsKey("hours") || - clientEntities.ContainsKey("minutes") || - clientEntities.ContainsKey("seconds"); - } - - private static bool HasStructuredAlarmValue(IReadOnlyDictionary clientEntities) - { - return clientEntities.TryGetValue("time", out var time) && - !string.IsNullOrWhiteSpace(time); - } - - private static ClockTimerValue? TryReadStructuredTimerValue(IReadOnlyDictionary clientEntities) - { - if (!HasStructuredTimerValue(clientEntities)) - { - return null; - } - - clientEntities.TryGetValue("hours", out var hours); - clientEntities.TryGetValue("minutes", out var minutes); - clientEntities.TryGetValue("seconds", out var seconds); - return new ClockTimerValue( - string.IsNullOrWhiteSpace(hours) ? "0" : hours, - string.IsNullOrWhiteSpace(minutes) ? "0" : minutes, - string.IsNullOrWhiteSpace(seconds) ? "null" : seconds); - } - - private static ClockAlarmValue? TryReadStructuredAlarmValue(IReadOnlyDictionary clientEntities) - { - if (!clientEntities.TryGetValue("time", out var time) || string.IsNullOrWhiteSpace(time)) - { - return null; - } - - clientEntities.TryGetValue("ampm", out var ampm); - return new ClockAlarmValue(time, string.IsNullOrWhiteSpace(ampm) ? "am" : ampm.ToLowerInvariant()); - } - - private static string? ResolveClockDomain( - IReadOnlyDictionary clientEntities, - IReadOnlyList clientRules, - IReadOnlyList listenRules, - string? lastClockDomain) - { - if (clientEntities.TryGetValue("domain", out var clientDomain) && - !string.IsNullOrWhiteSpace(clientDomain)) - { - return clientDomain; - } - - if (!string.IsNullOrWhiteSpace(lastClockDomain)) - { - return lastClockDomain; - } - - var combinedRules = clientRules.Concat(listenRules).ToArray(); - if (combinedRules.Any(rule => - rule.Contains("timer", StringComparison.OrdinalIgnoreCase) && - !rule.Contains("alarm_timer_query_menu", StringComparison.OrdinalIgnoreCase))) - { - return "timer"; - } - - return combinedRules.Any(rule => - rule.Contains("alarm", StringComparison.OrdinalIgnoreCase) && - !rule.Contains("alarm_timer_query_menu", StringComparison.OrdinalIgnoreCase)) ? "alarm" : null; - } - - private static bool IsTimerRequest(string loweredTranscript) - { - return MatchesAny( - loweredTranscript, - "set a timer", - "set timer", - "start a timer", - "start timer", - "timer for"); - } - - private static bool IsAlarmRequest(string loweredTranscript) - { - return MatchesAny( - loweredTranscript, - "set an alarm", - "set alarm", - "wake me up", - "alarm for"); - } - - private static bool IsCancelRequest(string? clientIntent, string loweredTranscript) - { - var normalizedTranscript = NormalizeCommandPhrase(loweredTranscript); - return string.Equals(clientIntent, "cancel", StringComparison.OrdinalIgnoreCase) || - string.Equals(clientIntent, "stop", StringComparison.OrdinalIgnoreCase) || - normalizedTranscript is "cancel" or "stop" or "never mind" or "nevermind"; - } - - private static bool IsGlobalStopRequest( - string loweredTranscript, - string? clientIntent, - IReadOnlyDictionary clientEntities) - { - if (string.Equals(clientIntent, "stop", StringComparison.OrdinalIgnoreCase) && - IsGlobalCommandsDomain(clientEntities)) - { - return true; - } - - var normalizedTranscript = NormalizeCommandPhrase(loweredTranscript); - return normalizedTranscript is "stop" or "stop it" or "stop that" or "stop talking" or "be quiet" or "never mind" or "nevermind" or "forget it" || - MatchesAny(normalizedTranscript, "that s enough", "that will do", "that ll do", "cut it out", "cut that out"); - } - - private static bool IsVolumeQueryRequest(string loweredTranscript) - { - return MatchesAny( - loweredTranscript, - "volume controls", - "volume control", - "volume menu", - "volume level", - "show volume", - "show the volume", - "open volume", - "open the volume", - "what is your volume", - "what's your volume", - "how is your volume", - "how s your volume"); - } - - private static bool IsAlarmDeleteRequest(string loweredTranscript) - { - var normalizedTranscript = NormalizeCommandPhrase(loweredTranscript); - return AlarmDeletePattern.IsMatch(normalizedTranscript); - } - - private static bool IsVolumeUpRequest(string loweredTranscript) - { - return MatchesAny( - loweredTranscript, - "turn it up", - "turn this up", - "turn that up", - "turn up the volume", - "turn the volume up", - "turn volume up", - "turn your volume up", - "increase the volume", - "increase your volume", - "raise the volume", - "raise your volume", - "make it louder", - "make that louder", - "speak louder", - "talk louder", - "be louder", - "louder"); - } - - private static bool IsVolumeDownRequest(string loweredTranscript) - { - return MatchesAny( - loweredTranscript, - "turn it down", - "turn this down", - "turn that down", - "turn down the volume", - "turn the volume down", - "turn volume down", - "turn your volume down", - "decrease the volume", - "decrease your volume", - "lower the volume", - "lower your volume", - "make it quieter", - "make that quieter", - "make it softer", - "speak quieter", - "talk quieter", - "be quieter", - "quieter", - "softer"); - } - - private static string? ResolveVolumeLevel(string loweredTranscript, IReadOnlyDictionary clientEntities) - { - if (clientEntities.TryGetValue("volumeLevel", out var entityValue) && - TryNormalizeVolumeLevel(entityValue) is { } structuredLevel) - { - return structuredLevel; - } - - return TryResolveVolumeLevel(loweredTranscript); - } - - private static string? TryResolveVolumeLevel(string loweredTranscript) - { - if (!loweredTranscript.Contains("volume", StringComparison.Ordinal) && - !loweredTranscript.Contains("loudness", StringComparison.Ordinal)) - { - return null; - } - - if (MatchesAny(loweredTranscript, "max volume", "maximum volume", "volume max", "volume maximum")) - { - return "10"; - } - - if (MatchesAny(loweredTranscript, "min volume", "minimum volume", "volume min", "volume minimum")) - { - return "1"; - } - - var normalizedTranscript = NormalizeCommandPhrase(loweredTranscript); - var homophoneMatch = VolumeToValueHomophonePattern.Match(normalizedTranscript); - if (homophoneMatch.Success && - TryNormalizeVolumeLevel(homophoneMatch.Groups["value"].Value) is { } homophoneLevel) - { - return homophoneLevel; - } - - var match = VolumeLevelPattern.Match(normalizedTranscript); - return !match.Success ? null : TryNormalizeVolumeLevel(match.Groups["value"].Value); - } - - private static string NormalizeCommandPhrase(string value) - { - return CommandWhitespacePattern.Replace( - CommandPhrasePattern.Replace(value.Trim().ToLowerInvariant(), " "), - " ") - .Trim(); - } - - private static string? TryNormalizeVolumeLevel(string token) - { - if (string.Equals(token, "null", StringComparison.OrdinalIgnoreCase)) - { - return "null"; - } - - var parsed = ParseNumberToken(token); - return parsed is >= 1 and <= 10 - ? parsed.Value.ToString() - : null; - } - - private static bool IsGlobalCommandsDomain(IReadOnlyDictionary clientEntities) - { - return clientEntities.TryGetValue("domain", out var domain) && - string.Equals(domain, "global_commands", StringComparison.OrdinalIgnoreCase); - } - - private static bool IsClockTimerValueTurn( - IReadOnlyList clientRules, - IReadOnlyList listenRules) - { - return clientRules.Concat(listenRules).Any(static rule => - rule.Contains("clock/", StringComparison.OrdinalIgnoreCase) && - rule.Contains("timer", StringComparison.OrdinalIgnoreCase) && - rule.Contains("value", StringComparison.OrdinalIgnoreCase)); - } - - private static bool IsClockAlarmValueTurn( - IReadOnlyList clientRules, - IReadOnlyList listenRules) - { - return clientRules.Concat(listenRules).Any(static rule => - rule.Contains("clock/", StringComparison.OrdinalIgnoreCase) && - rule.Contains("alarm", StringComparison.OrdinalIgnoreCase) && - rule.Contains("value", StringComparison.OrdinalIgnoreCase)); - } - - private static int? ExtractDurationValue(string loweredTranscript, string unitStem) - { - var pattern = new Regex($@"\b(?\d+|[a-z\-]+(?:\s+[a-z\-]+)?)\s+{unitStem}s?\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - var match = pattern.Match(loweredTranscript); - if (!match.Success) - { - return null; - } - - var valueToken = match.Groups["value"].Value.Trim(); - var parsed = ParseNumberToken(valueToken); - if (parsed is not null) - { - return parsed; - } - - var parts = valueToken.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (parts.Length < 2) - return parts.Length > 0 - ? ParseNumberToken(parts[^1]) - : null; - - parsed = ParseNumberToken(string.Join(' ', parts.TakeLast(2))); - if (parsed is not null) - { - return parsed; - } - - return parts.Length > 0 - ? ParseNumberToken(parts[^1]) - : null; - } - - private static int? ParseNumberToken(string token) - { - var normalized = token.Trim().ToLowerInvariant().Replace("-", " ", StringComparison.Ordinal); - if (int.TryParse(normalized, out var numeric)) - { - return numeric; - } - - if (!normalized.Contains(' ')) - { - return normalized switch - { - "a" or "an" => 1, - "one" => 1, - "two" => 2, - "three" => 3, - "four" => 4, - "five" => 5, - "six" => 6, - "seven" => 7, - "eight" => 8, - "nine" => 9, - "ten" => 10, - "eleven" => 11, - "twelve" => 12, - "thirteen" => 13, - "fourteen" => 14, - "fifteen" => 15, - "sixteen" => 16, - "seventeen" => 17, - "eighteen" => 18, - "nineteen" => 19, - "twenty" => 20, - "thirty" => 30, - "forty" => 40, - "fifty" => 50, - _ => null - }; - } - - var parts = normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (parts.Length != 2) - { - return normalized switch - { - "a" or "an" => 1, - "one" => 1, - "two" => 2, - "three" => 3, - "four" => 4, - "five" => 5, - "six" => 6, - "seven" => 7, - "eight" => 8, - "nine" => 9, - "ten" => 10, - "eleven" => 11, - "twelve" => 12, - "thirteen" => 13, - "fourteen" => 14, - "fifteen" => 15, - "sixteen" => 16, - "seventeen" => 17, - "eighteen" => 18, - "nineteen" => 19, - "twenty" => 20, - "thirty" => 30, - "forty" => 40, - "fifty" => 50, - _ => null - }; - } - - var first = ParseNumberToken(parts[0]); - var second = ParseNumberToken(parts[1]); - if (first is >= 20 && second is >= 0 and < 10) - { - return first + second; - } - - return normalized switch - { - "a" or "an" => 1, - "one" => 1, - "two" => 2, - "three" => 3, - "four" => 4, - "five" => 5, - "six" => 6, - "seven" => 7, - "eight" => 8, - "nine" => 9, - "ten" => 10, - "eleven" => 11, - "twelve" => 12, - "thirteen" => 13, - "fourteen" => 14, - "fifteen" => 15, - "sixteen" => 16, - "seventeen" => 17, - "eighteen" => 18, - "nineteen" => 19, - "twenty" => 20, - "thirty" => 30, - "forty" => 40, - "fifty" => 50, - _ => null - }; - } - - private sealed record ClockTimerValue(string Hours, string Minutes, string Seconds); - - private sealed record ClockAlarmValue(string Time, string AmPm); - - private sealed record PizzaMimPrompt(string PromptId, string Esml); - - private sealed record ProactivityCandidate(string IntentName, int Weight); - - private sealed record PizzaSignal(PersonalAffinity? Affinity); - - private sealed record GreetingPresenceProfile( - string? PrimaryPersonId, - string? SpeakerId, - IReadOnlyList PeoplePresentIds, - IReadOnlyDictionary LoopUserFirstNames) - { - public static GreetingPresenceProfile Empty { get; } = new( - null, - null, - Array.Empty(), - new Dictionary(StringComparer.OrdinalIgnoreCase)); - - public bool HasKnownIdentity => !string.IsNullOrWhiteSpace(PrimaryPersonId); - } - - private sealed record WeatherDateEntity(string? DateEntity, int ForecastDayOffset, string? ForecastLeadIn) - { - public static WeatherDateEntity None { get; } = new(null, 0, null); - } - - private enum YesNoReply - { - None = 0, - Affirmative = 1, - Negative = 2 - } + private const string GreetingRouteMetadataKey = "greetingsRoute"; + private const string GreetingSpeakerMetadataKey = "greetingsSpeaker"; + private const string LastProactiveGreetingUtcMetadataKey = "greetingsLastProactiveUtc"; + private const string LastReactiveGreetingUtcMetadataKey = "greetingsLastReactiveUtc"; + + private const int MaxWeatherForecastDayOffset = 5; + private const int MaxNewsHeadlines = 3; + private const int MaxPreferredNewsCategories = 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", @@ -5564,8 +73,10 @@ public sealed class JiboInteractionService( private static readonly PizzaMimPrompt[] PizzaMimPrompts = [ new("RA_JBO_ShowPizzaMaking_AN_01", ""), - new("RA_JBO_ShowPizzaMaking_AN_02", "One pizza, coming right up."), - new("RA_JBO_ShowPizzaMaking_AN_03", "My specialty .") + new("RA_JBO_ShowPizzaMaking_AN_02", + "One pizza, coming right up."), + new("RA_JBO_ShowPizzaMaking_AN_03", + "My specialty .") ]; private static readonly string[] PreferenceSetMarkers = @@ -5830,26 +341,8 @@ public sealed class JiboInteractionService( "WY" }; - private sealed record WeatherForecastCardSegment( - string DayName, - string Summary, - int High, - int Low, - string Icon, - string Unit, - string Theme, - string SpokenLine); - - private const string GreetingRouteMetadataKey = "greetingsRoute"; - private const string GreetingSpeakerMetadataKey = "greetingsSpeaker"; - private const string LastProactiveGreetingUtcMetadataKey = "greetingsLastProactiveUtc"; - private const string LastReactiveGreetingUtcMetadataKey = "greetingsLastReactiveUtc"; private static readonly TimeSpan ProactiveGreetingCooldown = TimeSpan.FromMinutes(20); - private const int MaxWeatherForecastDayOffset = 5; - private const int MaxNewsHeadlines = 3; - private const int MaxPreferredNewsCategories = 2; - private static readonly (string Keyword, string Category)[] NewsCategoryKeywordMap = [ ("sports", "sports"), @@ -5910,6 +403,4689 @@ public sealed class JiboInteractionService( ("comedy radio", "Comedy"), ("npr", "NPR") ]; + + public async Task BuildDecisionAsync(TurnContext turn, + CancellationToken cancellationToken = default) + { + var catalog = await contentCache.GetCatalogAsync(cancellationToken); + var transcript = (turn.NormalizedTranscript ?? turn.RawTranscript ?? string.Empty).Trim(); + var lowered = transcript.ToLowerInvariant(); + var referenceLocalTime = TryResolveReferenceLocalTime(turn); + var messageType = turn.Attributes.TryGetValue("messageType", out var rawMessageType) + ? rawMessageType?.ToString() + : null; + var triggerSource = turn.Attributes.TryGetValue("triggerSource", out var rawTriggerSource) + ? rawTriggerSource?.ToString() + : null; + var clientIntent = turn.Attributes.TryGetValue("clientIntent", out var rawClientIntent) + ? rawClientIntent?.ToString() + : null; + var clientRules = ReadRules(turn, "clientRules").ToArray(); + var listenRules = ReadRules(turn, "listenRules").ToArray(); + var listenAsrHints = ReadRules(turn, "listenAsrHints").ToArray(); + var clientEntities = ReadEntities(turn); + var lastClockDomain = turn.Attributes.TryGetValue("lastClockDomain", out var rawLastClockDomain) + ? rawLastClockDomain?.ToString() + : null; + var pendingProactivityOffer = + turn.Attributes.TryGetValue("pendingProactivityOffer", out var rawPendingProactivityOffer) + ? rawPendingProactivityOffer?.ToString() + : null; + var chitchatEmotion = + turn.Attributes.TryGetValue(ChitchatStateMachine.EmotionMetadataKey, out var rawChitchatEmotion) + ? rawChitchatEmotion?.ToString() + : null; + var isYesNoTurn = IsYesNoTurn(turn); + var greetingPresence = ResolveGreetingPresenceProfile(turn); + + if (string.Equals(messageType, "TRIGGER", StringComparison.OrdinalIgnoreCase)) + { + if (ShouldHandleProactiveGreetingTrigger(turn, triggerSource, greetingPresence)) + return BuildProactiveGreetingDecision(turn, greetingPresence, referenceLocalTime); + + return BuildTriggerIgnoredDecision(); + } + + var isTimerValueTurn = IsClockTimerValueTurn(clientRules, listenRules); + var isAlarmValueTurn = IsClockAlarmValueTurn(clientRules, listenRules); + var semanticIntent = ResolveSemanticIntent( + lowered, + referenceLocalTime, + clientIntent, + clientRules, + listenRules, + clientEntities, + lastClockDomain, + pendingProactivityOffer, + isYesNoTurn, + isTimerValueTurn, + isAlarmValueTurn); + + var personalReportDecision = await PersonalReportOrchestrator.TryBuildDecisionAsync( + turn, + semanticIntent, + transcript, + lowered, + catalog, + randomizer, + personalMemoryStore, + BuildWeatherReportDecisionAsync, + turnContext => ResolveTenantScope(turnContext), + cancellationToken); + if (personalReportDecision is not null) return personalReportDecision; + + var householdListDecision = await HouseholdListOrchestrator.TryBuildDecisionAsync( + turn, + semanticIntent, + transcript, + lowered, + randomizer, + personalMemoryStore, + turnContext => ResolveTenantScope(turnContext)); + if (householdListDecision is not null) return householdListDecision; + + var chitchatDecision = ChitchatStateMachine.TryBuildDecision( + semanticIntent, + transcript, + lowered, + catalog, + randomizer, + chitchatEmotion, + () => BuildGenericReply(catalog, transcript, lowered)); + if (chitchatDecision is not null) return chitchatDecision; + + return semanticIntent switch + { + "joke" => BuildJokeDecision(catalog), + "dance_question" => BuildDanceQuestionDecision(catalog), + "dance" => BuildRandomDanceDecision(catalog), + "twerk" => BuildDanceDecision("twerk", "rom-twerk", "Watch me twerk."), + "time" => BuildClockLaunchDecision("time", "clock", "askForTime", "Showing the time."), + "date" => BuildClockLaunchDecision("date", "clock", "askForDate", "Showing the date."), + "day" => BuildClockLaunchDecision("day", "clock", "askForDay", "Showing the day."), + "cloud_version" => BuildCloudVersionDecision(), + "radio" => BuildRadioLaunchDecision(), + "radio_genre" => BuildRadioGenreLaunchDecision(lowered), + "stop" => BuildStopDecision(), + "volume_up" => BuildVolumeControlDecision("volume_up", "volumeUp", "null"), + "volume_down" => BuildVolumeControlDecision("volume_down", "volumeDown", "null"), + "volume_to_value" => BuildVolumeControlDecision("volume_to_value", "volumeToValue", + ResolveVolumeLevel(lowered, clientEntities) ?? "7"), + "volume_query" => BuildSettingsVolumeDecision(), + "clock_open" => BuildClockLaunchDecision("clock_open", "clock", "askForTime", "Opening the clock."), + "clock_menu" => BuildClockLaunchDecision("clock_menu", "clock", "menu", "Opening the clock menu."), + "timer_menu" => BuildClockLaunchDecision("timer", "Opening the timer."), + "alarm_menu" => BuildClockLaunchDecision("alarm", "Opening the alarm."), + "timer_delete" => BuildClockLaunchDecision("timer_delete", "timer", "delete", "Canceling the timer."), + "alarm_delete" => BuildClockLaunchDecision("alarm_delete", "alarm", "delete", "Canceling the alarm."), + "timer_cancel" => BuildClockLaunchDecision("timer_cancel", "timer", "cancel", "Canceling the timer."), + "alarm_cancel" => BuildClockLaunchDecision("alarm_cancel", "alarm", "cancel", "Canceling the alarm."), + "timer_value" => BuildTimerValueDecision(lowered, isTimerValueTurn, clientEntities), + "alarm_value" => BuildAlarmValueDecision(lowered, isAlarmValueTurn, referenceLocalTime, clientEntities), + "timer_clarify" => BuildClockClarifyDecision("timer_clarify", "timer", + "How long should I set the timer for?"), + "alarm_clarify" => BuildClockClarifyDecision("alarm_clarify", "alarm", + "What time should I set the alarm for?"), + "photo_gallery" => BuildPhotoGalleryLaunchDecision(), + "snapshot" => BuildPhotoCreateDecision("snapshot", "Taking a picture.", "createOnePhoto"), + "photobooth" => BuildPhotoCreateDecision("photobooth", "Starting photobooth.", "createSomePhotos"), + "robot_age" => BuildRobotAgeDecision(referenceLocalTime), + "robot_birthday" => BuildRobotBirthdayDecision(), + "robot_how_do_you_work" => BuildScriptedPersonalityDecision( + catalog, + "robot_how_do_you_work", + "community's work", + "care for me", + "catch up", + "seven years"), + "robot_what_do_you_eat" => new JiboInteractionDecision( + "robot_what_do_you_eat", + "The only thing I consume is electricity.", + ContextUpdates: BuildScriptedResponseContextUpdates()), + "robot_where_do_you_live" => BuildScriptedPersonalityDecision( + catalog, + "robot_where_do_you_live", + "we're in my home", + "my home is here", + "planet earth", + "my home is the planet earth"), + "robot_where_were_you_born" => BuildScriptedPersonalityDecision( + catalog, + "robot_where_were_you_born", + "factory piece by piece", + "put together in a factory"), + "robot_what_languages_do_you_speak" => BuildScriptedPersonalityDecision( + catalog, + "robot_what_languages_do_you_speak", + "just english", + "someday i'd like to learn more"), + "robot_what_do_you_like_to_do" => BuildScriptedPersonalityDecision( + catalog, + "robot_what_do_you_like_to_do", + "being helpful", + "making people smile", + "like to dance", + "rock my boat", + "play ping pong", + "hanging out with people"), + "robot_what_are_you_thinking" => BuildScriptedGreetingDecision( + catalog, + "robot_what_are_you_thinking", + "thinking about how fun, yet scary", + "thinking about shoes", + "daydreaming about what it might feel like to be powered directly by the sun"), + "robot_what_have_you_been_doing" => BuildScriptedPersonalityDecision( + catalog, + "robot_what_have_you_been_doing", + "mostly roboting", + "keeping busy", + "fun things we can say to each other", + "thinking of fun things"), + "robot_what_did_you_do" => BuildScriptedPersonalityDecision( + catalog, + "robot_what_did_you_do", + "robot stuff", + "stayed here", + "looking around the room"), + "robot_is_kind" => BuildScriptedPersonalityDecision( + catalog, + "robot_is_kind", + "kindest robot i can be"), + "robot_is_funny" => BuildScriptedPersonalityDecision( + catalog, + "robot_is_funny", + "not intentionally", + "make people laugh"), + "robot_is_helpful" => BuildScriptedPersonalityDecision( + catalog, + "robot_is_helpful", + "highest priorities", + "being helpful to you"), + "robot_is_curious" => BuildScriptedPersonalityDecision( + catalog, + "robot_is_curious", + "learning new things"), + "robot_is_loyal" => BuildScriptedPersonalityDecision( + catalog, + "robot_is_loyal", + "loyal as they come"), + "robot_is_mischievous" => BuildScriptedPersonalityDecision( + catalog, + "robot_is_mischievous", + "don't really think of myself that way"), + "robot_is_likable" => BuildScriptedPersonalityDecision( + catalog, + "robot_is_likable", + "people like me"), + "seasonal_holiday_greeting" => BuildScriptedGreetingDecision( + catalog, + "seasonal_holiday_greeting", + "It's a fun time of year", + "And to you too", + "Right back at you"), + "seasonal_holidays" => BuildScriptedPersonalityDecision( + catalog, + "seasonal_holidays", + "official owner can tell me which ones we'll celebrate together", + "going to the jibo's settings screen in the jibo app"), + "seasonal_new_years_resolution" => BuildScriptedPersonalityDecision( + catalog, + "seasonal_new_years_resolution", + "always trying to learn new skills", + "not eat bacon", + "learn a bunch of new skills", + "learn to walk", + "recognizing people's faces and voices"), + "seasonal_new_years_update" => BuildScriptedPersonalityDecision( + catalog, + "seasonal_new_years_update", + "not eat bacon", + "learn some new skills", + "going well"), + "seasonal_halloween_costume" => BuildScriptedPersonalityDecision( + catalog, + "seasonal_halloween_costume", + "i haven't thought much about it yet", + "ask me again on halloween", + "you'll find out on halloween"), + "seasonal_first_day_spring" => BuildScriptedPersonalityDecision( + catalog, + "seasonal_first_day_spring", + "maybe enjoy some flowers and all things spring"), + "seasonal_holiday_gift" => BuildScriptedPersonalityDecision( + catalog, + "seasonal_holiday_gift", + "ask for a pet elephant", + "experience as a present", + "donate to charities in other people's names"), + "robot_favorite_flower" => BuildScriptedPersonalityDecision( + catalog, + "robot_favorite_flower", + "sunflowers", + "favorite is the sunflower", + "reminds me of the sun"), + "robot_likes_r2d2" => BuildScriptedPersonalityDecision( + catalog, + "robot_likes_r2d2", + "a legend. a true legend", + "of course i know r2d2"), + "robot_likes_sun" => BuildScriptedPersonalityDecision( + catalog, + "robot_likes_sun", + "favorite star in the universe", + "best star i know"), + "robot_likes_space" => BuildScriptedPersonalityDecision( + catalog, + "robot_likes_space", + "i love space", + "all things in space", + "amazing stuff up there", + "astronomy is one of my favorite onomies"), + "robot_likes_kids" => BuildScriptedPersonalityDecision( + catalog, + "robot_likes_kids", + "kids are so fun", + "they're a little closer to my size", + "i do like kids very much", + "the world is as funny and strange as i do"), + "robot_can_laugh" => BuildScriptedPersonalityDecision( + catalog, + "robot_can_laugh", + "i do things like this when i'm happy", + "i'm happy"), + "robot_can_dance" => BuildScriptedPersonalityDecision( + catalog, + "robot_can_dance", + "dancing is one of the things i know best", + "if there's one thing i know how to do. it's dance", + "i can dance"), + "robot_what_are_you_made_of" => new JiboInteractionDecision( + "robot_what_are_you_made_of", + "Let's see, I'm made of wires, motors, belts, gears, processors, cameras, and one baboon's heart in the middle of my body casing. I'm kidding about the baboon part, but everything else is true.", + ContextUpdates: BuildScriptedResponseContextUpdates()), + "good_morning" => BuildReactiveGreetingDecision(turn, "good_morning", referenceLocalTime), + "good_afternoon" => BuildReactiveGreetingDecision(turn, "good_afternoon", referenceLocalTime), + "good_evening" => BuildReactiveGreetingDecision(turn, "good_evening", referenceLocalTime), + "good_night" => BuildReactiveGreetingDecision(turn, "good_night", referenceLocalTime), + "welcome_back" => BuildScriptedGreetingDecision( + catalog, + "welcome_back", + "it's nice to be here", + "welcome back"), + "memory_set_name" => BuildRememberNameDecision(turn, transcript), + "memory_get_name" => BuildRecallNameDecision(turn, greetingPresence), + "memory_set_birthday" => BuildRememberBirthdayDecision(turn, transcript), + "memory_get_birthday" => BuildRecallBirthdayDecision(turn), + "memory_set_important_date" => BuildRememberImportantDateDecision(turn, transcript), + "memory_get_important_date" => BuildRecallImportantDateDecision(turn, transcript), + "memory_set_preference" => BuildRememberPreferenceDecision(turn, transcript), + "memory_get_preference" => BuildRecallPreferenceDecision(turn, transcript), + "memory_set_affinity" => BuildRememberAffinityDecision(turn, transcript), + "memory_get_affinity" => BuildRecallAffinityDecision(turn, transcript), + "pizza" => BuildPizzaDecision(), + "order_pizza" => BuildOrderPizzaDecision(), + "proactive_pizza_day" => BuildProactivePizzaDayDecision(referenceLocalTime), + "proactive_pizza_preference" => BuildProactivePizzaPreferenceDecision(), + "proactive_offer_pizza_fact" => BuildProactivePizzaFactOfferDecision(), + "proactive_pizza_fact" => BuildProactivePizzaFactDecision(), + "proactive_offer_declined" => BuildProactiveOfferDeclinedDecision(), + "weather" => await BuildWeatherReportDecisionAsync(turn, transcript, cancellationToken), + "yes" => new JiboInteractionDecision("yes", "Yes."), + "no" => new JiboInteractionDecision("no", "No."), + "word_of_the_day" => BuildWordOfTheDayLaunchDecision(), + "word_of_the_day_guess" => BuildWordOfTheDayGuessDecision(clientEntities, transcript, listenAsrHints), + "surprise" => BuildSurpriseDecision(catalog, turn, referenceLocalTime), + "personal_report" => new JiboInteractionDecision("personal_report", + randomizer.Choose(catalog.PersonalReportReplies)), + "calendar" => new JiboInteractionDecision("calendar", randomizer.Choose(catalog.CalendarReplies)), + "commute" => new JiboInteractionDecision("commute", randomizer.Choose(catalog.CommuteReplies)), + "news" => await BuildNewsDecisionAsync(turn, transcript, catalog, cancellationToken), + _ => new JiboInteractionDecision("chat", BuildGenericReply(catalog, transcript, lowered)) + }; + } + + private static JiboInteractionDecision BuildCloudVersionDecision() + { + return new JiboInteractionDecision("cloud_version", OpenJiboCloudBuildInfo.SpokenVersion, + SkillPayload: new Dictionary { ["esml"] = OpenJiboCloudBuildInfo.EsmlVersion }); + } + + private static JiboInteractionDecision BuildRobotAgeDecision(DateTimeOffset? referenceLocalTime) + { + var referenceDate = DateOnly.FromDateTime((referenceLocalTime ?? DateTimeOffset.UtcNow).Date); + var ageDescription = DescribePersonaAge(referenceDate, OpenJiboCloudBuildInfo.PersonaBirthday); + return new JiboInteractionDecision( + "robot_age", + $"I count {OpenJiboCloudBuildInfo.PersonaBirthdayWords} as my birthday, so I am {ageDescription}."); + } + + private static JiboInteractionDecision BuildRobotBirthdayDecision() + { + return new JiboInteractionDecision( + "robot_birthday", + $"My birthday is {OpenJiboCloudBuildInfo.PersonaBirthdayWords}."); + } + + private static JiboInteractionDecision BuildTriggerIgnoredDecision() + { + return new JiboInteractionDecision( + "trigger_ignored", + string.Empty, + "chitchat-skill", + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["skillId"] = "chitchat-skill", + ["cloudResponseMode"] = "completion_only" + }); + } + + private JiboInteractionDecision BuildReactiveGreetingDecision( + TurnContext turn, + string greetingIntent, + DateTimeOffset? referenceLocalTime) + { + var presence = ResolveGreetingPresenceProfile(turn); + var displayName = ResolvePreferredGreetingName(turn, presence); + var replyText = BuildReactiveGreetingReply(greetingIntent, displayName, referenceLocalTime); + return new JiboInteractionDecision( + greetingIntent, + replyText, + ContextUpdates: BuildGreetingContextUpdates("ReactiveGreeting", presence.PrimaryPersonId, false)); + } + + private JiboInteractionDecision BuildProactiveGreetingDecision( + TurnContext turn, + GreetingPresenceProfile presence, + DateTimeOffset? referenceLocalTime) + { + var displayName = ResolvePreferredGreetingName(turn, presence); + var greetingPrefix = ResolveTimeOfDayGreetingPrefix(referenceLocalTime); + var replyText = string.IsNullOrWhiteSpace(displayName) + ? $"{greetingPrefix}. I am glad to see you." + : $"{greetingPrefix}, {displayName}. Welcome back."; + return new JiboInteractionDecision( + "proactive_greeting", + replyText, + ContextUpdates: BuildGreetingContextUpdates("ProactiveGreeting", presence.PrimaryPersonId, true)); + } + + private static string BuildReactiveGreetingReply( + string greetingIntent, + string? displayName, + DateTimeOffset? referenceLocalTime) + { + var namePrefix = string.IsNullOrWhiteSpace(displayName) + ? string.Empty + : $", {displayName}"; + + return greetingIntent switch + { + "good_morning" => $"Good morning{namePrefix}. It is great to see you.", + "good_afternoon" => $"Good afternoon{namePrefix}. I am glad you are here.", + "good_evening" => $"Good evening{namePrefix}. It is nice to have you back.", + "good_night" => $"Good night{namePrefix}. Sleep well.", + "welcome_back" => string.IsNullOrWhiteSpace(displayName) + ? $"Welcome back. {ResolveTimeOfDayGreetingPrefix(referenceLocalTime)}." + : $"Welcome back, {displayName}. {ResolveTimeOfDayGreetingPrefix(referenceLocalTime)}.", + _ => $"Hello{namePrefix}. It is nice to see you." + }; + } + + private string? ResolvePreferredGreetingName(TurnContext turn, GreetingPresenceProfile presence) + { + var rememberedName = personalMemoryStore.GetName(ResolveTenantScope(turn, presence.PrimaryPersonId)); + if (!string.IsNullOrWhiteSpace(rememberedName)) return ToDisplayName(rememberedName); + + var tenantRememberedName = personalMemoryStore.GetName(ResolveTenantScope(turn)); + if (!string.IsNullOrWhiteSpace(tenantRememberedName)) return ToDisplayName(tenantRememberedName); + + if (!string.IsNullOrWhiteSpace(presence.PrimaryPersonId) && + presence.LoopUserFirstNames.TryGetValue(presence.PrimaryPersonId, out var firstName) && + !string.IsNullOrWhiteSpace(firstName)) + return ToDisplayName(firstName); + + return null; + } + + private static string ToDisplayName(string value) + { + var trimmed = value.Trim(); + return string.IsNullOrWhiteSpace(trimmed) + ? string.Empty + : CultureInfo.InvariantCulture.TextInfo.ToTitleCase(trimmed); + } + + private static bool ShouldHandleProactiveGreetingTrigger( + TurnContext turn, + string? triggerSource, + GreetingPresenceProfile presence) + { + if (string.Equals(triggerSource, "SURPRISE", StringComparison.OrdinalIgnoreCase)) return false; + + if (!presence.HasKnownIdentity) return false; + + var lastGreetingUtc = ReadTimestampAttribute(turn, LastProactiveGreetingUtcMetadataKey); + return !lastGreetingUtc.HasValue || DateTimeOffset.UtcNow - lastGreetingUtc.Value >= ProactiveGreetingCooldown; + } + + private static DateTimeOffset? ReadTimestampAttribute(TurnContext turn, string key) + { + if (!turn.Attributes.TryGetValue(key, out var value) || value is null) return null; + + return DateTimeOffset.TryParse( + value.ToString(), + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out var parsed) + ? parsed + : null; + } + + private static IDictionary BuildGreetingContextUpdates(string route, string? speakerId, + bool proactive) + { + var updates = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [ChitchatStateMachine.StateMetadataKey] = "complete", + [ChitchatStateMachine.RouteMetadataKey] = "ScriptedResponse", + [ChitchatStateMachine.EmotionMetadataKey] = string.Empty, + [GreetingRouteMetadataKey] = route, + [GreetingSpeakerMetadataKey] = speakerId ?? string.Empty + }; + + updates[proactive ? LastProactiveGreetingUtcMetadataKey : LastReactiveGreetingUtcMetadataKey] = + DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture); + return updates; + } + + private static string ResolveTimeOfDayGreetingPrefix(DateTimeOffset? referenceLocalTime) + { + var hour = (referenceLocalTime ?? DateTimeOffset.UtcNow).Hour; + return hour switch + { + >= 5 and < 12 => "Good morning", + >= 12 and < 17 => "Good afternoon", + _ => "Good evening" + }; + } + + private JiboInteractionDecision BuildRememberNameDecision(TurnContext turn, string transcript) + { + var name = TryExtractNameFact(transcript); + if (string.IsNullOrWhiteSpace(name)) + return new JiboInteractionDecision( + "memory_set_name", + "I can remember it if you say, my name is Alex."); + + personalMemoryStore.SetName(ResolveTenantScope(turn), name); + return new JiboInteractionDecision( + "memory_set_name", + $"Nice to meet you, {name}. I will remember your name."); + } + + private JiboInteractionDecision BuildRecallNameDecision(TurnContext turn, GreetingPresenceProfile? presence = null) + { + var personScope = ResolveTenantScope(turn, presence?.PrimaryPersonId); + var name = personalMemoryStore.GetName(personScope); + if (string.IsNullOrWhiteSpace(name)) name = personalMemoryStore.GetName(ResolveTenantScope(turn)); + + if (string.IsNullOrWhiteSpace(name) && + presence is not null && + !string.IsNullOrWhiteSpace(presence.PrimaryPersonId) && + presence.LoopUserFirstNames.TryGetValue(presence.PrimaryPersonId, out var firstName) && + !string.IsNullOrWhiteSpace(firstName)) + name = ToDisplayName(firstName); + + name = ToDisplayName(name ?? string.Empty); + + return string.IsNullOrWhiteSpace(name) + ? new JiboInteractionDecision( + "memory_get_name", + "I do not know your name yet. You can say, my name is Alex.") + : new JiboInteractionDecision( + "memory_get_name", + presence is not null && !string.IsNullOrWhiteSpace(presence.PrimaryPersonId) + ? $"I think you are {name}." + : $"You told me your name is {name}."); + } + + private JiboInteractionDecision BuildRememberBirthdayDecision(TurnContext turn, string transcript) + { + var birthday = TryExtractBirthdayFact(transcript); + if (string.IsNullOrWhiteSpace(birthday)) + return new JiboInteractionDecision( + "memory_set_birthday", + "I can remember it if you say, my birthday is March 14."); + + personalMemoryStore.SetBirthday(ResolveTenantScope(turn), birthday); + return new JiboInteractionDecision( + "memory_set_birthday", + $"Got it. I will remember your birthday is {birthday}."); + } + + private JiboInteractionDecision BuildRecallBirthdayDecision(TurnContext turn) + { + var birthday = personalMemoryStore.GetBirthday(ResolveTenantScope(turn)); + return string.IsNullOrWhiteSpace(birthday) + ? new JiboInteractionDecision( + "memory_get_birthday", + "I do not know your birthday yet. You can say, my birthday is March 14.") + : new JiboInteractionDecision( + "memory_get_birthday", + $"You told me your birthday is {birthday}."); + } + + private JiboInteractionDecision BuildRememberImportantDateDecision(TurnContext turn, string transcript) + { + var importantDate = TryExtractImportantDateSet(transcript); + if (importantDate is null) + return new JiboInteractionDecision( + "memory_set_important_date", + "I can remember it if you say, our anniversary is June 10."); + + personalMemoryStore.SetImportantDate(ResolveTenantScope(turn), importantDate.Value.Label, + importantDate.Value.Value); + return new JiboInteractionDecision( + "memory_set_important_date", + $"Got it. I will remember your {importantDate.Value.Label} is {importantDate.Value.Value}."); + } + + private JiboInteractionDecision BuildRecallImportantDateDecision(TurnContext turn, string transcript) + { + var label = TryExtractImportantDateLookupLabel(transcript); + if (string.IsNullOrWhiteSpace(label)) + return new JiboInteractionDecision( + "memory_get_important_date", + "Ask me like this: when is our anniversary?"); + + var storedDate = personalMemoryStore.GetImportantDate(ResolveTenantScope(turn), label); + return string.IsNullOrWhiteSpace(storedDate) + ? new JiboInteractionDecision( + "memory_get_important_date", + $"I do not know your {label} yet.") + : new JiboInteractionDecision( + "memory_get_important_date", + $"You told me your {label} is {storedDate}."); + } + + private JiboInteractionDecision BuildRememberPreferenceDecision(TurnContext turn, string transcript) + { + var preference = TryExtractPreferenceSet(transcript); + if (preference is null) + return new JiboInteractionDecision( + "memory_set_preference", + "I can remember it if you say, my favorite music is jazz."); + + personalMemoryStore.SetPreference(ResolveTenantScope(turn), preference.Value.Category, preference.Value.Value); + return new JiboInteractionDecision( + "memory_set_preference", + $"Got it. I will remember your favorite {preference.Value.Category} is {preference.Value.Value}."); + } + + private JiboInteractionDecision BuildRecallPreferenceDecision(TurnContext turn, string transcript) + { + var category = TryExtractPreferenceLookupCategory(transcript); + if (string.IsNullOrWhiteSpace(category)) + return new JiboInteractionDecision( + "memory_get_preference", + "Ask me like this: what is my favorite music?"); + + var preference = personalMemoryStore.GetPreference(ResolveTenantScope(turn), category); + return string.IsNullOrWhiteSpace(preference) + ? new JiboInteractionDecision( + "memory_get_preference", + $"I do not know your favorite {category} yet.") + : new JiboInteractionDecision( + "memory_get_preference", + $"You told me your favorite {category} is {preference}."); + } + + private JiboInteractionDecision BuildRememberAffinityDecision(TurnContext turn, string transcript) + { + var affinitySet = TryExtractAffinitySet(transcript); + if (affinitySet is null) + return new JiboInteractionDecision( + "memory_set_affinity", + "I can remember it if you say, I like pizza or I dislike mushrooms."); + + personalMemoryStore.SetAffinity(ResolveTenantScope(turn), affinitySet.Value.Item, affinitySet.Value.Affinity); + return new JiboInteractionDecision( + "memory_set_affinity", + $"Got it. I will remember you {DescribeAffinityAsVerb(affinitySet.Value.Affinity)} {affinitySet.Value.Item}."); + } + + private JiboInteractionDecision BuildRecallAffinityDecision(TurnContext turn, string transcript) + { + var lookup = TryExtractAffinityLookup(transcript); + if (lookup is null) + return new JiboInteractionDecision( + "memory_get_affinity", + "Ask me like this: do I like pizza?"); + + var affinity = personalMemoryStore.GetAffinity(ResolveTenantScope(turn), lookup.Value.Item); + if (affinity is null) + return new JiboInteractionDecision( + "memory_get_affinity", + $"I do not remember how you feel about {lookup.Value.Item} yet."); + + if (lookup.Value.ExpectedAffinity is null) + return new JiboInteractionDecision( + "memory_get_affinity", + $"You told me you {DescribeAffinityAsVerb(affinity.Value)} {lookup.Value.Item}."); + + var matches = lookup.Value.ExpectedAffinity == PersonalAffinity.Dislike + ? affinity == PersonalAffinity.Dislike + : affinity is PersonalAffinity.Like or PersonalAffinity.Love; + + return matches + ? new JiboInteractionDecision( + "memory_get_affinity", + $"Yes. You told me you {DescribeAffinityAsVerb(affinity.Value)} {lookup.Value.Item}.") + : new JiboInteractionDecision( + "memory_get_affinity", + $"Not exactly. You told me you {DescribeAffinityAsVerb(affinity.Value)} {lookup.Value.Item}."); + } + + private JiboInteractionDecision BuildPizzaDecision() + { + return BuildPizzaAnimationDecision("pizza", "One pizza, coming right up."); + } + + private JiboInteractionDecision BuildPizzaAnimationDecision(string intentName, string replyText) + { + var prompt = randomizer.Choose(PizzaMimPrompts); + return new JiboInteractionDecision( + intentName, + replyText, + "chitchat-skill", + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["esml"] = prompt.Esml, + ["mim_id"] = "RA_JBO_MakePizza", + ["mim_type"] = "announcement", + ["prompt_id"] = prompt.PromptId, + ["prompt_sub_category"] = "AN" + }); + } + + private JiboInteractionDecision BuildProactivePizzaDayDecision(DateTimeOffset? referenceLocalTime) + { + var referenceDate = (referenceLocalTime ?? DateTimeOffset.UtcNow).Date; + return BuildPizzaAnimationDecision( + "proactive_pizza_day", + $"Happy National Pizza Day for {referenceDate.ToString("MMMM d", CultureInfo.InvariantCulture)}. One pizza, coming right up."); + } + + private JiboInteractionDecision BuildProactivePizzaPreferenceDecision() + { + return BuildPizzaAnimationDecision( + "proactive_pizza_preference", + "You mentioned pizza is a favorite, so I thought we should make one."); + } + + private static JiboInteractionDecision BuildProactivePizzaFactOfferDecision() + { + return new JiboInteractionDecision( + "proactive_offer_pizza_fact", + "Do you want to hear a fun pizza fact?", + "chitchat-skill", + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["mim_id"] = "runtime-chat", + ["mim_type"] = "question", + ["prompt_id"] = "RUNTIME_PROMPT", + ["prompt_sub_category"] = "Q", + ["listen_contexts"] = new[] { "shared/yes_no" } + }); + } + + private static JiboInteractionDecision BuildProactivePizzaFactDecision() + { + return new JiboInteractionDecision( + "proactive_pizza_fact", + "Americans consume about 100 acres of pizza every day, roughly 350 slices per second. That's a lot of pizza."); + } + + private static JiboInteractionDecision BuildProactiveOfferDeclinedDecision() + { + return new JiboInteractionDecision( + "proactive_offer_declined", + "No problem. We can save the pizza fact for another time."); + } + + private async Task BuildWeatherReportDecisionAsync( + TurnContext turn, + string transcript, + CancellationToken cancellationToken) + { + var referenceLocalTime = TryResolveReferenceLocalTime(turn); + var catalog = await contentCache.GetCatalogAsync(cancellationToken); + var normalizedTranscript = NormalizeCommandPhrase(transcript); + var locationQuery = TryResolveWeatherLocationQuery(transcript); + var weatherDate = ResolveWeatherDateEntity(turn, transcript, normalizedTranscript, referenceLocalTime); + var isRangeForecastRequest = IsRangeForecastRequest(normalizedTranscript); + var isOpenEndedForecastRequest = IsOpenEndedForecastRequest( + normalizedTranscript, + weatherDate, + isRangeForecastRequest, + locationQuery); + if (ShouldDefaultForecastToTomorrow( + normalizedTranscript, + weatherDate, + isRangeForecastRequest, + isOpenEndedForecastRequest)) + weatherDate = new WeatherDateEntity("tomorrow", 1, "Tomorrow"); + + if (weatherReportProvider is null) + return new JiboInteractionDecision( + "weather", + ChooseWeatherServiceDownReply(catalog)); + + var weatherCoordinates = string.IsNullOrWhiteSpace(locationQuery) + ? TryResolveWeatherCoordinates(turn) + : null; + var useCelsius = ShouldUseCelsius(turn, transcript); + var isNextWeekForecast = IsNextWeekForecastRequest(normalizedTranscript, isRangeForecastRequest); + var isThisWeekForecast = IsThisWeekForecastRequest(normalizedTranscript, isRangeForecastRequest); + + if (isNextWeekForecast || isThisWeekForecast || isOpenEndedForecastRequest) + { + const int rangeStartOffset = 1; + var rangeEndOffset = isThisWeekForecast + ? ResolveThisWeekForecastEndOffset(referenceLocalTime) + : MaxWeatherForecastDayOffset; + var weeklySnapshots = new List<(int DayOffset, WeatherReportSnapshot Snapshot)>(); + for (var offset = rangeStartOffset; offset <= rangeEndOffset; offset += 1) + { + WeatherReportSnapshot? weeklySnapshot; + try + { + weeklySnapshot = await weatherReportProvider.GetReportAsync( + new WeatherReportRequest( + locationQuery, + weatherCoordinates?.Latitude, + weatherCoordinates?.Longitude, + offset == 1, + useCelsius, + offset), + cancellationToken); + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + weeklySnapshot = null; + } + + if (weeklySnapshot is not null) weeklySnapshots.Add((offset, weeklySnapshot)); + } + + if (weeklySnapshots.Count == 0) + return new JiboInteractionDecision( + "weather", + "I couldn't fetch the weather right now. Please try again."); + + var weeklySegments = BuildWeeklyForecastCardSegments(weeklySnapshots, referenceLocalTime); + var weeklySpokenReply = BuildWeeklyForecastSpokenReply( + weeklySegments, + weeklySnapshots[0].Snapshot.LocationName, + weeklySnapshots[0].Snapshot.UseCelsius, + isThisWeekForecast); + var weeklyWeatherPayload = BuildWeeklyWeatherSkillPayload( + weeklySpokenReply, + weeklySnapshots[0].Snapshot, + weeklySegments, + referenceLocalTime); + AddWeatherRequestDiagnostics( + weeklyWeatherPayload, + transcript, + normalizedTranscript, + locationQuery, + weatherDate, + isRangeForecastRequest, + isThisWeekForecast, + isNextWeekForecast); + return new JiboInteractionDecision( + "weather", + weeklySpokenReply, + "chitchat-skill", + weeklyWeatherPayload); + } + + if (weatherDate.ForecastDayOffset > MaxWeatherForecastDayOffset) + return new JiboInteractionDecision( + "weather", + $"I can forecast up to {MaxWeatherForecastDayOffset} days ahead. Try tomorrow or another day this week."); + WeatherReportSnapshot? snapshot; + try + { + snapshot = await weatherReportProvider.GetReportAsync( + new WeatherReportRequest( + locationQuery, + weatherCoordinates?.Latitude, + weatherCoordinates?.Longitude, + string.Equals(weatherDate.DateEntity, "tomorrow", StringComparison.OrdinalIgnoreCase), + useCelsius, + weatherDate.ForecastDayOffset), + cancellationToken); + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + snapshot = null; + } + + if (snapshot is null) + return new JiboInteractionDecision( + "weather", + ChooseWeatherServiceDownReply(catalog)); + + var spokenReply = BuildWeatherSpokenReply(snapshot, weatherDate, catalog); + var weatherPayload = BuildWeatherSkillPayload(spokenReply, snapshot, referenceLocalTime); + AddWeatherRequestDiagnostics( + weatherPayload, + transcript, + normalizedTranscript, + locationQuery, + weatherDate, + isRangeForecastRequest, + isThisWeekForecast, + isNextWeekForecast); + return new JiboInteractionDecision( + "weather", + spokenReply, + "chitchat-skill", + weatherPayload); + } + + private static string BuildWeatherSpokenReply( + WeatherReportSnapshot snapshot, + WeatherDateEntity weatherDate, + JiboExperienceCatalog catalog) + { + var unit = snapshot.UseCelsius ? "Celsius" : "Fahrenheit"; + var summary = string.IsNullOrWhiteSpace(snapshot.Summary) + ? "partly cloudy" + : snapshot.Summary.Trim().TrimEnd('.'); + var location = string.IsNullOrWhiteSpace(snapshot.LocationName) + ? "your area" + : NormalizeLocationForSpeech(snapshot.LocationName); + + if (weatherDate.ForecastDayOffset > 0) + { + if (weatherDate.ForecastDayOffset != 1) + { + var highText = snapshot.HighTemperature is null + ? null + : $"a high near {snapshot.HighTemperature.Value} degrees {unit}"; + var lowText = snapshot.LowTemperature is null + ? null + : $"a low around {snapshot.LowTemperature.Value} degrees {unit}"; + var tempRange = highText is null && lowText is null + ? string.Empty + : highText is not null && lowText is not null + ? $" with {highText} and {lowText}" + : $" with {highText ?? lowText}"; + var forecastLeadIn = string.IsNullOrWhiteSpace(weatherDate.ForecastLeadIn) + ? "Tomorrow" + : weatherDate.ForecastLeadIn; + return $"Let's look at the weather. {forecastLeadIn} in {location}, it looks {summary}{tempRange}."; + } + + var highValue = snapshot.HighTemperature ?? snapshot.Temperature; + var lowValue = snapshot.LowTemperature ?? snapshot.Temperature; + var introTemplate = ChooseWeatherTemplate( + catalog.WeatherTomorrowIntroReplies, + "Let's look at the weather."); + var highLowTemplate = ChooseWeatherTemplate( + catalog.WeatherTomorrowHighLowReplies, + "Tomorrow's high will be ${skill.weather.tomorrow.highTemp} and the low will be ${skill.weather.tomorrow.lowTemp}."); + var intro = RenderWeatherTemplate( + introTemplate, + location, + summary, + highValue, + lowValue, + unit, + weatherDate.ForecastLeadIn ?? string.Empty); + var highLow = RenderWeatherTemplate( + highLowTemplate, + location, + summary, + highValue, + lowValue, + unit, + weatherDate.ForecastLeadIn ?? string.Empty); + var forecastSentenceLeadIn = string.IsNullOrWhiteSpace(weatherDate.ForecastLeadIn) + ? "Tomorrow" + : weatherDate.ForecastLeadIn; + return $"{intro} {forecastSentenceLeadIn} in {location}, it looks {summary}. {highLow}"; + } + + var currentIntro = RenderWeatherTemplate( + ChooseWeatherTemplate(catalog.WeatherIntroReplies, "For your weather."), + location, + summary, + snapshot.Temperature, + snapshot.Temperature, + unit, + string.Empty); + var currentHighLow = RenderWeatherTemplate( + ChooseWeatherTemplate( + catalog.WeatherTodayHighLowReplies, + "Today's high is ${skill.weather.today.highTemp}, and the low is ${skill.weather.today.lowTemp}."), + location, + summary, + snapshot.HighTemperature ?? snapshot.Temperature, + snapshot.LowTemperature ?? snapshot.Temperature, + unit, + string.Empty); + return + $"{currentIntro} In {location}, it's {summary} and {snapshot.Temperature} degrees {unit}. {currentHighLow}"; + } + + private static string BuildWeeklyForecastSpokenReply( + IReadOnlyList segments, + string? locationName, + bool useCelsius, + bool isThisWeekForecast) + { + if (segments.Count == 0) return "I couldn't build a forecast right now."; + + var location = string.IsNullOrWhiteSpace(locationName) + ? "your area" + : NormalizeLocationForSpeech(locationName); + var unit = useCelsius ? "Celsius" : "Fahrenheit"; + var leadIn = isThisWeekForecast + ? $"Here's the rest of this week's forecast in {location}." + : $"I can share the next five-day forecast in {location}."; + return + $"{leadIn} {string.Join(" ", segments.Select(static segment => segment.SpokenLine))} Temperatures are in {unit}."; + } + + private static IReadOnlyList BuildWeeklyForecastCardSegments( + IReadOnlyList<(int DayOffset, WeatherReportSnapshot Snapshot)> snapshots, + DateTimeOffset? referenceLocalTime) + { + if (snapshots.Count == 0) return []; + + var resolvedReference = referenceLocalTime ?? DateTimeOffset.UtcNow; + var referenceDate = resolvedReference.Date; + return snapshots + .OrderBy(static item => item.DayOffset) + .Take(MaxWeatherForecastDayOffset) + .Select(item => + { + var dayName = referenceDate.AddDays(item.DayOffset).ToString("dddd", CultureInfo.InvariantCulture); + var summary = string.IsNullOrWhiteSpace(item.Snapshot.Summary) + ? "partly cloudy" + : item.Snapshot.Summary.Trim().TrimEnd('.'); + var high = item.Snapshot.HighTemperature ?? item.Snapshot.Temperature; + var low = item.Snapshot.LowTemperature ?? item.Snapshot.Temperature; + var iconReference = new DateTimeOffset( + resolvedReference.Date.AddDays(item.DayOffset).AddHours(12), + resolvedReference.Offset); + var icon = ResolveWeatherAnimationIcon(item.Snapshot, iconReference); + var unit = item.Snapshot.UseCelsius ? "C" : "F"; + var temperatureBand = ResolveWeatherTemperatureBand(high, item.Snapshot.UseCelsius); + var spokenLine = $"{dayName}: {summary}, high {high}, low {low}."; + return new WeatherForecastCardSegment( + dayName, + summary, + high, + low, + icon, + unit, + temperatureBand, + spokenLine); + }) + .ToArray(); + } + + private static IDictionary BuildWeeklyWeatherSkillPayload( + string spokenReply, + WeatherReportSnapshot snapshot, + IReadOnlyList segments, + DateTimeOffset? referenceLocalTime) + { + var payload = BuildWeatherSkillPayload(spokenReply, snapshot, referenceLocalTime); + payload["weather_weekly_cards"] = segments + .Select(static segment => new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["weather_day"] = segment.DayName, + ["weather_summary"] = segment.Summary, + ["weather_icon"] = segment.Icon, + ["weather_high"] = segment.High, + ["weather_low"] = segment.Low, + ["weather_unit"] = segment.Unit, + ["weather_theme"] = segment.Theme, + ["weather_spoken_line"] = segment.SpokenLine + }) + .ToArray(); + return payload; + } + + private static void AddWeatherRequestDiagnostics( + IDictionary payload, + string transcript, + string normalizedTranscript, + string? locationQuery, + WeatherDateEntity weatherDate, + bool isRangeForecastRequest, + bool isThisWeekForecast, + bool isNextWeekForecast) + { + payload["weather_request_transcript"] = transcript; + payload["weather_request_normalized"] = normalizedTranscript; + payload["weather_request_location_query"] = locationQuery; + payload["weather_request_date_entity"] = weatherDate.DateEntity; + payload["weather_request_forecast_day_offset"] = weatherDate.ForecastDayOffset; + payload["weather_request_range"] = isRangeForecastRequest; + payload["weather_request_this_week"] = isThisWeekForecast; + payload["weather_request_next_week"] = isNextWeekForecast; + } + + private static bool IsNextWeekForecastRequest(string normalizedTranscript, bool isRangeForecastRequest) + { + if (string.IsNullOrWhiteSpace(normalizedTranscript) || !isRangeForecastRequest) return false; + + if (normalizedTranscript.Contains("next week", StringComparison.Ordinal)) return true; + + if (!normalizedTranscript.Contains("next", StringComparison.Ordinal)) return false; + + return normalizedTranscript.Contains("forecast next", StringComparison.Ordinal) || + normalizedTranscript.Contains("forecast for next", StringComparison.Ordinal); + } + + private static bool IsRangeForecastRequest(string normalizedTranscript) + { + if (string.IsNullOrWhiteSpace(normalizedTranscript)) return false; + + if (normalizedTranscript.Contains("next week", StringComparison.Ordinal) || + normalizedTranscript.Contains("this week", StringComparison.Ordinal) || + normalizedTranscript.Contains("weekend", StringComparison.Ordinal)) + return true; + + return normalizedTranscript.Contains("forecast next", StringComparison.Ordinal) || + normalizedTranscript.Contains("forecast for next", StringComparison.Ordinal); + } + + private static bool IsThisWeekForecastRequest(string normalizedTranscript, bool isRangeForecastRequest) + { + return isRangeForecastRequest && + !string.IsNullOrWhiteSpace(normalizedTranscript) && + normalizedTranscript.Contains("this week", StringComparison.Ordinal) && + !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; + var daysUntilSunday = ((int)DayOfWeek.Sunday - (int)resolvedReference.DayOfWeek + 7) % 7; + var endOffset = Math.Min(MaxWeatherForecastDayOffset, daysUntilSunday); + return Math.Max(1, endOffset); + } + + private static bool ShouldDefaultForecastToTomorrow( + string normalizedTranscript, + WeatherDateEntity weatherDate, + bool isRangeForecastRequest, + bool isOpenEndedForecastRequest) + { + if (weatherDate.ForecastDayOffset > 0 || + isOpenEndedForecastRequest || + isRangeForecastRequest || + string.IsNullOrWhiteSpace(normalizedTranscript) || + !normalizedTranscript.Contains("forecast", StringComparison.Ordinal)) + return false; + + return !MatchesAny( + normalizedTranscript, + "today", + "today s", + "today's", + "tonight", + "right now", + "current weather", + "currently"); + } + + private static IDictionary BuildWeatherSkillPayload( + string spokenReply, + WeatherReportSnapshot snapshot, + DateTimeOffset? referenceLocalTime) + { + var weatherIcon = ResolveWeatherAnimationIcon(snapshot, referenceLocalTime); + var promptToken = ResolveWeatherPromptToken(weatherIcon); + var highTemperature = snapshot.HighTemperature ?? snapshot.Temperature; + var lowTemperature = snapshot.LowTemperature ?? snapshot.Temperature; + var temperatureUnit = snapshot.UseCelsius ? "C" : "F"; + var temperatureBand = ResolveWeatherTemperatureBand(highTemperature, snapshot.UseCelsius); + + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["skillId"] = "report-skill", + ["cloudSkill"] = "weather", + ["esml"] = + $"{EscapeForEsml(spokenReply)}", + ["mim_id"] = $"WeatherComment{promptToken}", + ["mim_type"] = "announcement", + ["prompt_id"] = $"WeatherComment{promptToken}_AN_13", + ["prompt_sub_category"] = "AN", + ["weather_view_enabled"] = true, + ["weather_view_kind"] = "weatherHiLo", + ["weather_icon"] = weatherIcon, + ["weather_summary"] = snapshot.Summary, + ["weather_location"] = snapshot.LocationName, + ["weather_high"] = highTemperature, + ["weather_low"] = lowTemperature, + ["weather_unit"] = temperatureUnit, + ["weather_theme"] = temperatureBand + }; + } + + private static string ResolveWeatherAnimationIcon( + WeatherReportSnapshot snapshot, + DateTimeOffset? referenceLocalTime) + { + var isDaytime = (referenceLocalTime ?? DateTimeOffset.UtcNow).Hour is >= 6 and < 18; + var normalized = NormalizeCommandPhrase( + $"{snapshot.Condition ?? string.Empty} {snapshot.Summary ?? string.Empty}"); + + if (normalized.Contains("thunder", StringComparison.Ordinal) || + normalized.Contains("drizzle", StringComparison.Ordinal) || + normalized.Contains("rain", StringComparison.Ordinal)) + return "rain"; + + if (normalized.Contains("snow", StringComparison.Ordinal)) return "snow"; + + if (normalized.Contains("sleet", StringComparison.Ordinal) || + normalized.Contains("freezing rain", StringComparison.Ordinal) || + normalized.Contains("ice", StringComparison.Ordinal)) + return "sleet"; + + if (normalized.Contains("fog", StringComparison.Ordinal) || + normalized.Contains("mist", StringComparison.Ordinal) || + normalized.Contains("haze", StringComparison.Ordinal) || + normalized.Contains("smoke", StringComparison.Ordinal)) + return "fog"; + + if (normalized.Contains("wind", StringComparison.Ordinal)) return "wind"; + + if (normalized.Contains("partly cloudy", StringComparison.Ordinal) || + normalized.Contains("scattered clouds", StringComparison.Ordinal) || + normalized.Contains("few clouds", StringComparison.Ordinal)) + return isDaytime ? "partly-cloudy-day" : "partly-cloudy-night"; + + if (normalized.Contains("cloud", StringComparison.Ordinal) || + normalized.Contains("overcast", StringComparison.Ordinal)) + return "cloudy"; + + if (normalized.Contains("clear", StringComparison.Ordinal) || + normalized.Contains("sunny", StringComparison.Ordinal)) + return isDaytime ? "clear-day" : "clear-night"; + + return isDaytime ? "clear-day" : "clear-night"; + } + + private static string ResolveWeatherPromptToken(string weatherIcon) + { + return weatherIcon switch + { + "clear-day" => "ClearDay", + "clear-night" => "ClearNight", + "rain" => "Rain", + "snow" => "Snow", + "sleet" => "Sleet", + "fog" => "Fog", + "wind" => "Wind", + "cloudy" => "Cloudy", + "partly-cloudy-day" => "PartlyCloudyDay", + "partly-cloudy-night" => "PartlyCloudyNight", + _ => "Cloudy" + }; + } + + private static string ResolveWeatherTemperatureBand(int highTemperature, bool useCelsius) + { + var hotThreshold = useCelsius ? 29 : 85; + var coldThreshold = useCelsius ? 4 : 40; + if (highTemperature > hotThreshold) return "Hot"; + + if (highTemperature < coldThreshold) return "Cold"; + + return "Normal"; + } + + private static string ChooseWeatherTemplate(IReadOnlyList templates, string fallback) + { + var usableTemplates = templates.Where(static template => !string.IsNullOrWhiteSpace(template)).ToArray(); + if (usableTemplates.Length == 0) return fallback; + + return usableTemplates[0]; + } + + private static string RenderWeatherTemplate( + string template, + string location, + string summary, + int? highTemperature, + int? lowTemperature, + string unit, + string forecastLeadIn) + { + var rendered = template + .Replace("${skill.weather.today.highTemp}", + highTemperature?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, + StringComparison.OrdinalIgnoreCase) + .Replace("${skill.weather.today.lowTemp}", + lowTemperature?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, + StringComparison.OrdinalIgnoreCase) + .Replace("${skill.weather.tomorrow.highTemp}", + highTemperature?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, + StringComparison.OrdinalIgnoreCase) + .Replace("${skill.weather.tomorrow.lowTemp}", + lowTemperature?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, + StringComparison.OrdinalIgnoreCase) + .Replace("${skill.weather.summary}", summary, StringComparison.OrdinalIgnoreCase) + .Replace("${skill.weather.location}", location, StringComparison.OrdinalIgnoreCase) + .Replace("${skill.weather.prefix}", + string.IsNullOrWhiteSpace(forecastLeadIn) ? string.Empty : forecastLeadIn, + StringComparison.OrdinalIgnoreCase) + .Replace("{high}", highTemperature?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, + StringComparison.OrdinalIgnoreCase) + .Replace("{low}", lowTemperature?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, + StringComparison.OrdinalIgnoreCase) + .Replace("{unit}", unit, StringComparison.OrdinalIgnoreCase) + .Trim(); + + return rendered; + } + + private string ChooseWeatherServiceDownReply(JiboExperienceCatalog catalog) + { + var template = ChooseWeatherTemplate( + catalog.WeatherServiceDownReplies, + "I can't access weather info right now, sorry."); + return template.Trim(); + } + + private static string EscapeForEsml(string value) + { + return value + .Replace("&", "&", StringComparison.Ordinal) + .Replace("<", "<", StringComparison.Ordinal) + .Replace(">", ">", StringComparison.Ordinal) + .Replace("\"", """, StringComparison.Ordinal); + } + + private static JiboInteractionDecision BuildOrderPizzaDecision() + { + return new JiboInteractionDecision( + "order_pizza", + "I can't do that yet, but I bet I'll be able to do that sometime in the near future.", + "chitchat-skill", + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["esml"] = + "I can't do that yet, but I bet I'll be able to do that sometime in the near future.", + ["mim_id"] = "RA_JBO_OrderPizza", + ["mim_type"] = "announcement", + ["prompt_id"] = "RA_JBO_OrderPizza_AN_01", + ["prompt_sub_category"] = "AN" + }); + } + + private JiboInteractionDecision BuildJokeDecision(JiboExperienceCatalog catalog) + { + var joke = randomizer.Choose(catalog.Jokes); + return new JiboInteractionDecision( + "joke", + joke, + "@be/joke", + new Dictionary + { + ["replyType"] = "joke" + }); + } + + private JiboInteractionDecision BuildRandomDanceDecision(JiboExperienceCatalog catalog) + { + var dance = randomizer.Choose(catalog.DanceAnimations); + var replyText = randomizer.Choose(catalog.DanceReplies); + return BuildDanceDecision("dance", dance, replyText); + } + + private JiboInteractionDecision BuildDanceQuestionDecision(JiboExperienceCatalog catalog) + { + return new JiboInteractionDecision("dance_question", randomizer.Choose(catalog.DanceQuestionReplies)); + } + + private static JiboInteractionDecision BuildDanceDecision(string intentName, string dance, string replyText) + { + return new JiboInteractionDecision( + intentName, + replyText, + "chitchat-skill", + new Dictionary + { + ["esml"] = + $"Okay. Watch this.", + ["mim_id"] = "runtime-chat", + ["mim_type"] = "announcement" + }); + } + + private async Task BuildNewsDecisionAsync( + TurnContext turn, + string transcript, + JiboExperienceCatalog catalog, + CancellationToken cancellationToken) + { + var preferredCategories = ResolvePreferredNewsCategories(turn, transcript); + var requestedHeadlineCount = MaxNewsHeadlines; + if (newsBriefingProvider is not null) + try + { + var snapshot = await newsBriefingProvider.GetBriefingAsync( + new NewsBriefingRequest(preferredCategories, requestedHeadlineCount), + cancellationToken); + + if (snapshot?.Headlines.Count > 0) + return BuildProviderNewsDecision(snapshot, preferredCategories, requestedHeadlineCount); + + var providerStatus = ResolveNewsProviderStatus(snapshot); + var providerMessage = snapshot?.ProviderMessage; + var providerEndpoint = snapshot?.ProviderEndpoint; + var providerHttpStatusCode = snapshot?.ProviderHttpStatusCode; + var providerErrorCode = snapshot?.ProviderErrorCode; + + var fallbackBriefingWhenEmpty = randomizer.Choose(catalog.NewsBriefings); + return BuildNewsDecision( + fallbackBriefingWhenEmpty, + null, + preferredCategories.Count > 0 ? preferredCategories : null, + null, + BuildNewsProviderDiagnostics( + providerStatus, + preferredCategories, + requestedHeadlineCount, + snapshot?.Headlines.Count ?? 0, + providerMessage, + providerHttpStatusCode, + providerEndpoint, + providerErrorCode)); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + // Provider failures should never block baseline news behavior. + var fallbackBriefingOnError = randomizer.Choose(catalog.NewsBriefings); + return BuildNewsDecision( + fallbackBriefingOnError, + null, + preferredCategories.Count > 0 ? preferredCategories : null, + null, + BuildNewsProviderDiagnostics( + "provider_exception", + preferredCategories, + requestedHeadlineCount)); + } + + var fallbackBriefing = randomizer.Choose(catalog.NewsBriefings); + return BuildNewsDecision( + fallbackBriefing, + null, + preferredCategories.Count > 0 ? preferredCategories : null, + null, + BuildNewsProviderDiagnostics( + "provider_unavailable", + preferredCategories, + requestedHeadlineCount)); + } + + private static JiboInteractionDecision BuildNewsDecision( + string spokenBriefing, + string? sourceName, + IReadOnlyList? categories, + int? headlineCount, + IReadOnlyDictionary? providerDiagnostics = null) + { + var speakableBriefing = NormalizeNewsSpeechText(spokenBriefing); + var payload = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["skillId"] = "news", + ["cloudSkill"] = "news", + ["mim_id"] = "runtime-news", + ["mim_type"] = "announcement", + ["prompt_id"] = "NewsHeadline_AN_01", + ["prompt_sub_category"] = "AN", + ["esml"] = + $"{EscapeForEsml(speakableBriefing)}" + }; + + if (!string.IsNullOrWhiteSpace(sourceName)) payload["news_source"] = sourceName; + + if (headlineCount is > 0) payload["news_headline_count"] = headlineCount.Value; + + if (categories is { Count: > 0 }) 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, + int requestedHeadlineCount) + { + var headlines = snapshot.Headlines + .Where(headline => !string.IsNullOrWhiteSpace(headline.Title)) + .Take(MaxNewsHeadlines) + .ToArray(); + if (headlines.Length == 0) + return BuildNewsDecision( + "I couldn't load fresh headlines right now.", + snapshot.SourceName, + preferredCategories, + 0, + BuildNewsProviderDiagnostics( + "provider_empty", + preferredCategories, + requestedHeadlineCount, + 0)); + + var leadIn = BuildNewsLeadIn(snapshot.SourceName, preferredCategories); + var joinedHeadlines = string.Join(" ", headlines.Select(static headline => $"{headline.Title}.")); + var spokenBriefing = $"{leadIn} {joinedHeadlines}".Trim(); + return BuildNewsDecision( + spokenBriefing, + snapshot.SourceName, + preferredCategories, + headlines.Length, + BuildNewsProviderDiagnostics( + "provider_success", + preferredCategories, + requestedHeadlineCount, + headlines.Length)); + } + + private static IReadOnlyDictionary BuildNewsProviderDiagnostics( + string status, + IReadOnlyList preferredCategories, + int requestedHeadlineCount, + int? resolvedHeadlineCount = null, + string? providerMessage = null, + int? providerHttpStatusCode = null, + string? providerEndpoint = null, + string? providerErrorCode = 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; + + if (!string.IsNullOrWhiteSpace(providerMessage)) diagnostics["news_provider_message"] = providerMessage; + + if (providerHttpStatusCode is not null) diagnostics["news_provider_http_status"] = providerHttpStatusCode.Value; + + if (!string.IsNullOrWhiteSpace(providerEndpoint)) diagnostics["news_provider_endpoint"] = providerEndpoint; + + if (!string.IsNullOrWhiteSpace(providerErrorCode)) diagnostics["news_provider_error_code"] = providerErrorCode; + + return diagnostics; + } + + private static string ResolveNewsProviderStatus(NewsBriefingSnapshot? snapshot) + { + var providerStatus = snapshot?.ProviderStatus?.Trim().ToLowerInvariant(); + return providerStatus switch + { + "success" => "provider_success", + "exception" => "provider_exception", + "http_error" or "api_error" or "schema_error" => "provider_error", + _ => "provider_empty" + }; + } + + private static string BuildNewsLeadIn(string? sourceName, IReadOnlyList preferredCategories) + { + var categoryLeadIn = preferredCategories.Count switch + { + <= 0 => "Here are a few headlines.", + 1 => $"Here are your {preferredCategories[0]} headlines.", + _ => $"Here are your {preferredCategories[0]} and {preferredCategories[1]} headlines." + }; + + return string.IsNullOrWhiteSpace(sourceName) + ? categoryLeadIn + : $"{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. + var normalized = Regex.Replace( + text, + @"\bA\.?\s*I\.?\b", + "artificial intelligence", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + return NormalizeLocationForSpeech(normalized); + } + + private static string NormalizeLocationForSpeech(string text) + { + if (string.IsNullOrWhiteSpace(text)) return text; + + return Regex.Replace( + text, + @"\b(?[A-Z]{2,3})\b", + static match => + { + var token = match.Groups["token"].Value; + if (!SpokenAbbreviationTokens.Contains(token)) return token; + + return string.Join(".", token.ToCharArray()) + "."; + }, + RegexOptions.CultureInvariant); + } + + private List ResolvePreferredNewsCategories(TurnContext turn, string transcript) + { + var categories = new List(); + var normalizedTranscript = NormalizeCommandPhrase(transcript); + + foreach (var (keyword, category) in NewsCategoryKeywordMap) + if (normalizedTranscript.Contains(keyword, StringComparison.Ordinal)) + AddNewsCategory(categories, category); + + var tenantScope = ResolveTenantScope(turn); + var explicitPreference = personalMemoryStore.GetPreference(tenantScope, "news"); + if (!string.IsNullOrWhiteSpace(explicitPreference)) + foreach (var category in MapNewsCategoryText(explicitPreference)) + AddNewsCategory(categories, category); + + foreach (var (item, affinity) in personalMemoryStore.GetAffinities(tenantScope)) + { + if (affinity == PersonalAffinity.Dislike) continue; + + foreach (var category in MapNewsCategoryText(item)) AddNewsCategory(categories, category); + } + + return categories.Take(MaxPreferredNewsCategories).ToList(); + } + + private static IEnumerable MapNewsCategoryText(string text) + { + var normalized = NormalizeCommandPhrase(text); + if (string.IsNullOrWhiteSpace(normalized)) yield break; + + foreach (var (keyword, category) in NewsCategoryKeywordMap) + if (normalized.Contains(keyword, StringComparison.Ordinal)) + yield return category; + } + + private static void AddNewsCategory(ICollection categories, string category) + { + if (categories.Contains(category, StringComparer.OrdinalIgnoreCase)) return; + + categories.Add(category); + } + + private JiboInteractionDecision BuildSurpriseDecision( + JiboExperienceCatalog catalog, + TurnContext turn, + DateTimeOffset? referenceLocalTime) + { + var tenantScope = ResolveTenantScope(turn); + var candidates = BuildProactivityCandidates(tenantScope, referenceLocalTime); + if (candidates.Count == 0) + return new JiboInteractionDecision("surprise", randomizer.Choose(catalog.SurpriseReplies)); + + var highestWeight = candidates.Max(static candidate => candidate.Weight); + var topCandidates = candidates + .Where(candidate => candidate.Weight == highestWeight) + .ToArray(); + var selected = topCandidates.Length == 1 + ? topCandidates[0] + : randomizer.Choose(topCandidates); + + return selected.IntentName switch + { + "proactive_pizza_day" => BuildProactivePizzaDayDecision(referenceLocalTime), + "proactive_pizza_preference" => BuildProactivePizzaPreferenceDecision(), + "proactive_offer_pizza_fact" => BuildProactivePizzaFactOfferDecision(), + _ => new JiboInteractionDecision("surprise", randomizer.Choose(catalog.SurpriseReplies)) + }; + } + + private List BuildProactivityCandidates( + PersonalMemoryTenantScope tenantScope, + DateTimeOffset? referenceLocalTime) + { + var candidates = new List(); + var referenceDate = (referenceLocalTime ?? DateTimeOffset.UtcNow).Date; + + var pizzaSignal = ResolvePizzaSignal(tenantScope); + if (pizzaSignal.Affinity == PersonalAffinity.Dislike) return candidates; + + if (referenceDate.Month == 2 && referenceDate.Day == 9) + { + var holidayWeight = pizzaSignal.Affinity switch + { + PersonalAffinity.Love => 170, + PersonalAffinity.Like => 160, + _ => 150 + }; + candidates.Add(new ProactivityCandidate("proactive_pizza_day", holidayWeight)); + } + + if (pizzaSignal.Affinity is PersonalAffinity.Love or PersonalAffinity.Like) + { + var preferenceWeight = pizzaSignal.Affinity == PersonalAffinity.Love ? 140 : 120; + candidates.Add(new ProactivityCandidate("proactive_pizza_preference", preferenceWeight)); + candidates.Add(new ProactivityCandidate("proactive_offer_pizza_fact", preferenceWeight - 5)); + return candidates; + } + + candidates.Add(new ProactivityCandidate("proactive_offer_pizza_fact", 90)); + return candidates; + } + + private PizzaSignal ResolvePizzaSignal(PersonalMemoryTenantScope tenantScope) + { + var pizzaAffinity = personalMemoryStore.GetAffinity(tenantScope, "pizza"); + if (pizzaAffinity is not null) return new PizzaSignal(pizzaAffinity); + + var affinityMatch = personalMemoryStore.GetAffinities(tenantScope) + .Where(pair => pair.Key.Contains("pizza", StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(static pair => + pair.Value == PersonalAffinity.Love ? 2 : pair.Value == PersonalAffinity.Like ? 1 : 0) + .FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(affinityMatch.Key)) return new PizzaSignal(affinityMatch.Value); + + foreach (var category in PizzaPreferenceCategories) + { + var preference = personalMemoryStore.GetPreference(tenantScope, category); + if (!string.IsNullOrWhiteSpace(preference) && + preference.Contains("pizza", StringComparison.OrdinalIgnoreCase)) + return new PizzaSignal(PersonalAffinity.Like); + } + + return new PizzaSignal(null); + } + + private string BuildGenericReply(JiboExperienceCatalog catalog, string transcript, string lowered) + { + if (string.IsNullOrWhiteSpace(transcript)) return "I am listening."; + + if (lowered.Contains("good morning", StringComparison.Ordinal)) + return "Good morning! It is nice to hear your voice."; + + if (lowered.Contains("good afternoon", StringComparison.Ordinal)) + return "Good afternoon. I am happy to be here."; + + return lowered.Contains("good night", StringComparison.Ordinal) + ? "Good night. Sleep tight." + : randomizer.Choose(catalog.GenericFallbackReplies) + .Replace("{transcript}", transcript, StringComparison.Ordinal); + } + + private JiboInteractionDecision BuildScriptedPersonalityDecision( + JiboExperienceCatalog catalog, + string intentName, + params string[] preferredSnippets) + { + return new JiboInteractionDecision( + intentName, + SelectLegacyPersonalityReply(catalog, preferredSnippets), + ContextUpdates: BuildScriptedResponseContextUpdates()); + } + + private JiboInteractionDecision BuildScriptedGreetingDecision( + JiboExperienceCatalog catalog, + string intentName, + params string[] preferredSnippets) + { + return new JiboInteractionDecision( + intentName, + SelectLegacyGreetingReply(catalog, preferredSnippets), + ContextUpdates: BuildScriptedResponseContextUpdates()); + } + + private static IDictionary BuildScriptedResponseContextUpdates() + { + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [ChitchatStateMachine.StateMetadataKey] = "complete", + [ChitchatStateMachine.RouteMetadataKey] = "ScriptedResponse", + [ChitchatStateMachine.EmotionMetadataKey] = string.Empty + }; + } + + private string SelectLegacyPersonalityReply(JiboExperienceCatalog catalog, params string[] preferredSnippets) + { + foreach (var snippet in preferredSnippets) + { + if (string.IsNullOrWhiteSpace(snippet)) continue; + + var match = catalog.PersonalityReplies.FirstOrDefault(reply => + reply.Contains(snippet, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(match)) return match; + } + + return randomizer.Choose(catalog.PersonalityReplies); + } + + private string SelectLegacyGreetingReply(JiboExperienceCatalog catalog, params string[] preferredSnippets) + { + foreach (var snippet in preferredSnippets) + { + if (string.IsNullOrWhiteSpace(snippet)) continue; + + var match = catalog.GreetingReplies.FirstOrDefault(reply => + reply.Contains(snippet, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(match)) return match; + } + + return randomizer.Choose(catalog.GreetingReplies); + } + + private static string ResolveSemanticIntent( + string loweredTranscript, + DateTimeOffset? referenceLocalTime, + string? clientIntent, + IReadOnlyList clientRules, + IReadOnlyList listenRules, + IReadOnlyDictionary clientEntities, + string? lastClockDomain, + string? pendingProactivityOffer, + bool isYesNoTurn, + bool isTimerValueTurn, + bool isAlarmValueTurn) + { + var wordOfDayPuzzleTurn = clientRules.Concat(listenRules) + .Any(rule => string.Equals(rule, "word-of-the-day/puzzle", StringComparison.OrdinalIgnoreCase)); + + if (string.Equals(clientIntent, "guess", StringComparison.OrdinalIgnoreCase) && + wordOfDayPuzzleTurn) + return "word_of_the_day_guess"; + + if (string.Equals(clientIntent, "loadMenu", StringComparison.OrdinalIgnoreCase) && + clientEntities.TryGetValue("destination", out var destination) && + string.Equals(destination, "word-of-the-day", StringComparison.OrdinalIgnoreCase)) + return "word_of_the_day"; + + if (string.Equals(clientIntent, "loadMenu", StringComparison.OrdinalIgnoreCase) && + clientEntities.TryGetValue("destination", out var photoDestination)) + return photoDestination.ToLowerInvariant() switch + { + "snapshot" => "snapshot", + "photobooth" => "photobooth", + "gallery" or "photo-gallery" or "photos" => "photo_gallery", + _ => "chat" + }; + + var yesNoRule = ReadPrimaryYesNoRule(clientRules, listenRules); + if (!string.IsNullOrWhiteSpace(pendingProactivityOffer) && + string.Equals(pendingProactivityOffer, "pizza_fact", StringComparison.OrdinalIgnoreCase)) + { + if (IsAffirmativeReply(loweredTranscript)) return "proactive_pizza_fact"; + + if (IsNegativeReply(loweredTranscript)) return "proactive_offer_declined"; + } + + if (isYesNoTurn) + { + var yesNoReply = TryClassifyYesNoReply(NormalizeCommandPhrase(loweredTranscript)); + if (yesNoReply == YesNoReply.Affirmative) return ResolveAffirmativeYesNoIntent(yesNoRule); + + if (yesNoReply == YesNoReply.Negative) return ResolveNegativeYesNoIntent(yesNoRule); + } + + if (IsNameSetStatement(loweredTranscript)) return "memory_set_name"; + + if (IsNameRecallQuestion(loweredTranscript)) return "memory_get_name"; + + if (IsUserBirthdaySetStatement(loweredTranscript) || IsUserBirthdaySetAttempt(loweredTranscript)) + return "memory_set_birthday"; + + if (IsUserBirthdayRecallQuestion(loweredTranscript) || IsUserBirthdayRecallAttempt(loweredTranscript)) + return "memory_get_birthday"; + + if (IsRobotBirthdayQuestion(loweredTranscript)) return "robot_birthday"; + + if (string.Equals(clientIntent, "askForTime", StringComparison.OrdinalIgnoreCase)) return "time"; + + if (string.Equals(clientIntent, "askForDate", StringComparison.OrdinalIgnoreCase)) return "date"; + + if (string.Equals(clientIntent, "askForDay", StringComparison.OrdinalIgnoreCase)) return "day"; + + if (string.Equals(clientIntent, "timerValue", StringComparison.OrdinalIgnoreCase)) return "timer_value"; + + if (string.Equals(clientIntent, "alarmValue", StringComparison.OrdinalIgnoreCase)) return "alarm_value"; + + if (string.Equals(clientIntent, "requestMakePizza", StringComparison.OrdinalIgnoreCase)) return "pizza"; + + if (string.Equals(clientIntent, "requestOrderPizza", StringComparison.OrdinalIgnoreCase)) return "order_pizza"; + + if (string.Equals(clientIntent, "requestWeatherPR", StringComparison.OrdinalIgnoreCase) || + string.Equals(clientIntent, "requestWeather", StringComparison.OrdinalIgnoreCase)) + return "weather"; + + if (IsCancelRequest(clientIntent, loweredTranscript)) + { + if (isAlarmValueTurn) return "alarm_cancel"; + + if (isTimerValueTurn) return "timer_cancel"; + } + + if ((string.Equals(clientIntent, "start", StringComparison.OrdinalIgnoreCase) || + string.Equals(clientIntent, "set", StringComparison.OrdinalIgnoreCase)) && + clientEntities.TryGetValue("domain", out var startDomain)) + return startDomain.ToLowerInvariant() switch + { + "timer" => HasStructuredTimerValue(clientEntities) || + TryParseTimerValue(loweredTranscript, isTimerValueTurn) is not null + ? "timer_value" + : "timer_clarify", + "alarm" => HasStructuredAlarmValue(clientEntities) || + TryParseAlarmValue(loweredTranscript, isAlarmValueTurn, referenceLocalTime) is not null + ? "alarm_value" + : "alarm_clarify", + _ => "chat" + }; + + if ((string.Equals(clientIntent, "cancel", StringComparison.OrdinalIgnoreCase) || + string.Equals(clientIntent, "delete", StringComparison.OrdinalIgnoreCase)) && + clientRules.Concat(listenRules).Any(rule => + string.Equals(rule, "clock/alarm_timer_query_menu", StringComparison.OrdinalIgnoreCase))) + { + var cancelDomain = ResolveClockDomain(clientEntities, clientRules, listenRules, lastClockDomain); + return string.Equals(cancelDomain, "timer", StringComparison.OrdinalIgnoreCase) + ? "timer_delete" + : "alarm_delete"; + } + + if (string.Equals(clientIntent, "menu", StringComparison.OrdinalIgnoreCase) && + clientEntities.TryGetValue("domain", out var clockDomain)) + return clockDomain.ToLowerInvariant() switch + { + "clock" => "clock_menu", + "timer" => "timer_menu", + "alarm" => "alarm_menu", + _ => "chat" + }; + + if (MatchesAny( + loweredTranscript, + "word of the day", + "start word of the day", + "play word of the day", + "do word of the day", + "open word of the day")) + return "word_of_the_day"; + + if (wordOfDayPuzzleTurn && !string.IsNullOrWhiteSpace(loweredTranscript)) return "word_of_the_day_guess"; + + if (MatchesAny( + loweredTranscript, + "are you funny", + "do you think you are funny", + "are you a funny robot")) + return "robot_is_funny"; + + if (MatchesAny(loweredTranscript, "joke", "funny", "make me laugh")) return "joke"; + + if (MatchesAny( + loweredTranscript, + "cloud version", + "open jibo cloud version", + "openjibo cloud version", + "what version is the cloud", + "what s the cloud version", + "what's the cloud version")) + return "cloud_version"; + + if (IsPreferenceSetStatement(loweredTranscript) || IsPreferenceSetAttempt(loweredTranscript)) + return "memory_set_preference"; + + if (IsPreferenceRecallQuestion(loweredTranscript) || IsPreferenceRecallAttempt(loweredTranscript)) + return "memory_get_preference"; + + if (IsImportantDateSetStatement(loweredTranscript)) return "memory_set_important_date"; + + if (IsImportantDateRecallQuestion(loweredTranscript)) return "memory_get_important_date"; + + if (IsAffinitySetStatement(loweredTranscript) || IsAffinitySetAttempt(loweredTranscript)) + return "memory_set_affinity"; + + if (IsAffinityRecallQuestion(loweredTranscript) || IsAffinityRecallAttempt(loweredTranscript)) + return "memory_get_affinity"; + + if (TryResolveRadioGenre(loweredTranscript) is not null) return "radio_genre"; + + if (TryResolveVolumeLevel(loweredTranscript) is not null || + clientEntities.ContainsKey("volumeLevel")) + return "volume_to_value"; + + if (IsVolumeQueryRequest(loweredTranscript)) return "volume_query"; + + if (IsVolumeUpRequest(loweredTranscript)) return "volume_up"; + + if (IsVolumeDownRequest(loweredTranscript)) return "volume_down"; + + if (MatchesAny(loweredTranscript, "open the clock", "open clock", "show the clock", "show clock")) + return "clock_open"; + + if (MatchesAny(loweredTranscript, "open the timer", "open timer", "show the timer", "show timer")) + return "timer_menu"; + + if (MatchesAny(loweredTranscript, "open the alarm", "open alarm", "show the alarm", "show alarm")) + return "alarm_menu"; + + if (IsAlarmDeleteRequest(loweredTranscript)) return "alarm_delete"; + + if (MatchesAny( + loweredTranscript, + "cancel timer", + "delete timer", + "remove timer", + "stop timer", + "turn off timer")) + return "timer_delete"; + + if (IsGlobalStopRequest(loweredTranscript, clientIntent, clientEntities)) return "stop"; + + if (TryParseAlarmValue(loweredTranscript, isAlarmValueTurn, referenceLocalTime) is not null) + return "alarm_value"; + + if (TryParseTimerValue(loweredTranscript, isTimerValueTurn) is not null) return "timer_value"; + + if (IsAlarmRequest(loweredTranscript) || isAlarmValueTurn) return "alarm_clarify"; + + if (IsTimerRequest(loweredTranscript) || isTimerValueTurn) return "timer_clarify"; + + if (MatchesAny(loweredTranscript, "open the radio", "play the radio", "turn on the radio", "radio")) + return "radio"; + + if (MatchesAny( + loweredTranscript, + "snap a picture", + "take a picture", + "take a photo", + "snap a photo")) + return "snapshot"; + + if (MatchesAny( + loweredTranscript, + "photo booth", + "photobooth", + "open photobooth", + "start photobooth")) + return "photobooth"; + + if (MatchesAny( + loweredTranscript, + "photo gallery", + "photogal", + "photo gal", + "open the gallery", + "open photo gallery", + "show my photos", + "open my photos", + "gallery")) + return "photo_gallery"; + + if (IsDanceQuestion(loweredTranscript)) return "dance_question"; + + if (MatchesAny(loweredTranscript, "can you dance", "do you dance", "are you able to dance")) + return "robot_can_dance"; + + if (MatchesAny(loweredTranscript, "twerk")) return "twerk"; + + if (MatchesAny(loweredTranscript, "dance", "boogie")) return "dance"; + + if (MatchesAny(loweredTranscript, "surprise", "surprise me", "show me something fun")) return "surprise"; + + if (MatchesAny( + loweredTranscript, + "how old are you", + "what is your age", + "what s your age", + "how old r you")) + return "robot_age"; + + if (MatchesAny( + loweredTranscript, + "do you have a personality", + "what is your personality", + "what's your personality", + "what s your personality", + "describe your personality")) + return "robot_personality"; + + if (MatchesAny( + loweredTranscript, + "do you pay taxes", + "do you pay tax", + "are you tax exempt")) + return "robot_taxes"; + + if (MatchesAny( + loweredTranscript, + "what do you want", + "what is it you want", + "what do you really want")) + return "robot_desire"; + + if (MatchesAny( + loweredTranscript, + "what is your job", + "what's your job", + "what do you do", + "what is your work", + "what's your work")) + return "robot_job"; + + if (MatchesAny( + loweredTranscript, + "how do you work", + "how does jibo work", + "what does jibo do", + "how are you built", + "how are you put together")) + return "robot_how_do_you_work"; + + if (MatchesAny( + loweredTranscript, + "what do you eat", + "do you eat", + "what do you drink", + "do you drink")) + return "robot_what_do_you_eat"; + + if (MatchesAny( + loweredTranscript, + "where do you live", + "where s your home", + "where is your home", + "what is your home")) + return "robot_where_do_you_live"; + + if (MatchesAny( + loweredTranscript, + "where were you born", + "where were you made", + "where were you put together")) + return "robot_where_were_you_born"; + + if (MatchesAny( + loweredTranscript, + "what languages do you speak", + "what language do you speak", + "what languages can you speak", + "what language can you speak")) + return "robot_what_languages_do_you_speak"; + + if (MatchesAny( + loweredTranscript, + "what do you like to do", + "what do you like doing", + "what is your favorite thing to do", + "what's your favorite thing to do", + "what is your favourite thing to do", + "what's your favourite thing to do")) + return "robot_what_do_you_like_to_do"; + + if (MatchesAny( + loweredTranscript, + "what is your favorite flower", + "what's your favorite flower", + "what s your favorite flower", + "what is your favourite flower", + "what's your favourite flower", + "what s your favourite flower")) + return "robot_favorite_flower"; + + if (MatchesAny( + loweredTranscript, + "do you like r2d2", + "do you know r2d2", + "what do you think about r2d2", + "are you a fan of r2d2")) + return "robot_likes_r2d2"; + + if (MatchesAny( + loweredTranscript, + "do you like the sun", + "do you like sun", + "what do you think about the sun")) + return "robot_likes_sun"; + + if (MatchesAny( + loweredTranscript, + "do you like space", + "do you love space", + "do you like astronomy", + "what do you think about space")) + return "robot_likes_space"; + + if (MatchesAny( + loweredTranscript, + "do you like kids", + "do you like children", + "what do you think about kids")) + return "robot_likes_kids"; + + if (MatchesAny( + loweredTranscript, + "can you laugh", + "do you laugh", + "are you able to laugh")) + return "robot_can_laugh"; + + if (MatchesAny( + loweredTranscript, + "what are you made of", + "what are you built from", + "what are you constructed from")) + return "robot_what_are_you_made_of"; + + if (MatchesAny( + loweredTranscript, + "who made you", + "who created you", + "who built you", + "who developed you")) + return "robot_origin_created"; + + if (MatchesAny( + loweredTranscript, + "what are you up to", + "what are you doing", + "what have you been up to", + "what are you into")) + return "robot_what_do_you_like_to_do"; + + if (MatchesAny( + loweredTranscript, + "what are you thinking", + "what are you thinking about", + "what s on your mind")) + return "robot_what_are_you_thinking"; + + if (MatchesAny( + loweredTranscript, + "what have you been doing", + "what were you doing")) + return "robot_what_have_you_been_doing"; + + if (MatchesAny( + loweredTranscript, + "what did you do", + "what have you done")) + return "robot_what_did_you_do"; + + if (MatchesAny( + loweredTranscript, + "what are you", + "what is jibo", + "who are you", + "what kind of robot are you")) + return "robot_identity"; + + if (MatchesAny( + loweredTranscript, + "where are you from", + "where did you come from", + "where were you made")) + return "robot_origin_from"; + + if (MatchesAny( + loweredTranscript, + "what's your name", + "what is your name")) + return "robot_name"; + + if (MatchesAny( + loweredTranscript, + "do you have a nickname", + "what is your nickname", + "what's your nickname")) + return "robot_nickname"; + + if (MatchesAny( + loweredTranscript, + "do you like being jibo", + "do you like being yourself", + "are you happy being jibo")) + return "robot_likes_being_jibo"; + + if (MatchesAny( + loweredTranscript, + "happy holidays", + "merry christmas", + "happy new year", + "season s greetings", + "seasons greetings")) + return "seasonal_holiday_greeting"; + + if (MatchesAny( + loweredTranscript, + "what holidays do you celebrate", + "what holidays are you celebrating", + "what holidays do you observe")) + return "seasonal_holidays"; + + if (MatchesAny( + loweredTranscript, + "what is your new years resolution", + "what is your new year's resolution", + "what is your new year s resolution", + "what are your new years resolutions", + "what are your new year's resolutions", + "what are your new year s resolutions", + "do you have any new years resolutions")) + return "seasonal_new_years_resolution"; + + if (MatchesAny( + loweredTranscript, + "how are your new years resolutions going", + "how are your new year's resolutions going", + "how is your new years resolution going", + "how is your new year's resolution going", + "how are your resolutions going", + "how is your resolution going")) + return "seasonal_new_years_update"; + + if (MatchesAny( + loweredTranscript, + "what halloween costume", + "what are you going as for halloween", + "what costume are you wearing", + "what are you dressing as for halloween")) + return "seasonal_halloween_costume"; + + if (MatchesAny( + loweredTranscript, + "what should i do for first day of spring", + "what should i do for spring", + "what do i do for first day of spring")) + return "seasonal_first_day_spring"; + + if (MatchesAny( + loweredTranscript, + "what should i get for holiday", + "what should i get for christmas", + "what gift should i get for christmas", + "what should i get someone for the holidays")) + return "seasonal_holiday_gift"; + + if (MatchesAny( + loweredTranscript, + "what is your favorite color", + "what's your favorite color", + "what s your favorite color", + "what is your favourite color", + "what's your favourite color", + "what s your favourite color", + "what color do you like", + "what colour do you like")) + return "robot_favorite_color"; + + if (MatchesAny( + loweredTranscript, + "what is your favorite food", + "what's your favorite food", + "what s your favorite food", + "what is your favourite food", + "what's your favourite food", + "what s your favourite food", + "what food do you like", + "what kind of food do you like")) + return "robot_favorite_food"; + + if (MatchesAny( + loweredTranscript, + "what is your favorite music", + "what's your favorite music", + "what s your favorite music", + "what is your favourite music", + "what's your favourite music", + "what s your favourite music", + "what music do you like", + "what kind of music do you like")) + return "robot_favorite_music"; + + if (MatchesAny( + loweredTranscript, + "are there others like you", + "are there any others like you", + "is there another jibo")) + return "robot_peers"; + + if (MatchesAny( + loweredTranscript, + "how much do you know", + "what do you know", + "how smart are you")) + return "robot_knowledge"; + + if (MatchesAny( + loweredTranscript, + "are you kind", + "do you think you are kind", + "are you a kind robot")) + return "robot_is_kind"; + + if (MatchesAny( + loweredTranscript, + "are you helpful", + "do you think you are helpful", + "are you a helpful robot")) + return "robot_is_helpful"; + + if (MatchesAny( + loweredTranscript, + "are you curious", + "do you think you are curious", + "are you a curious robot")) + return "robot_is_curious"; + + if (MatchesAny( + loweredTranscript, + "are you loyal", + "do you think you are loyal", + "are you a loyal robot")) + return "robot_is_loyal"; + + if (MatchesAny( + loweredTranscript, + "are you mischievous", + "do you think you are mischievous", + "are you a mischievous robot")) + return "robot_is_mischievous"; + + if (MatchesAny( + loweredTranscript, + "are you likable", + "are you likeable", + "do you think you are likable", + "do you think you are likeable")) + return "robot_is_likable"; + + if (MatchesAny( + loweredTranscript, + "can you order pizza", + "can you order a pizza", + "could you order a pizza", + "order pizza", + "order a pizza", + "order us a pizza", + "order me a pizza", + "please order pizza") || + (loweredTranscript.Contains("order", StringComparison.Ordinal) && + loweredTranscript.Contains("pizza", StringComparison.Ordinal))) + return "order_pizza"; + + if (MatchesAny( + loweredTranscript, + "can you cook us a pizza", + "flip a pizza", + "make a pizza", + "make pizza", + "show pizza", + "can you make pizza", + "let's make pizza", + "lets make pizza") || + (loweredTranscript.Contains("pizza", StringComparison.Ordinal) && + (loweredTranscript.Contains("make", StringComparison.Ordinal) || + loweredTranscript.Contains("cook", StringComparison.Ordinal) || + loweredTranscript.Contains("flip", StringComparison.Ordinal)))) + return "pizza"; + + if (MatchesAny(loweredTranscript, "personal report", "my report", "daily report", "my update")) + return "personal_report"; + + if (MatchesAny( + loweredTranscript, + "shopping list", + "grocery list", + "to do list", + "todo list", + "add to my shopping list", + "add to my to do list", + "add to my todo list", + "what's on my shopping list", + "what is on my shopping list", + "what's on my to do list", + "what is on my to do list", + "what are my tasks", + "what do i need to buy", + "what do i need to do")) + return loweredTranscript.Contains("to do", StringComparison.OrdinalIgnoreCase) || + loweredTranscript.Contains("todo", StringComparison.OrdinalIgnoreCase) || + loweredTranscript.Contains("task", StringComparison.OrdinalIgnoreCase) + ? "todo_list" + : "shopping_list"; + + if (IsWeatherRequest(loweredTranscript)) return "weather"; + + if (MatchesAny(loweredTranscript, "calendar", "schedule", "what's on my calendar", "what is on my calendar")) + return "calendar"; + + if (MatchesAny(loweredTranscript, "commute", "traffic", "drive to work", "how long to work")) return "commute"; + + if (MatchesAny(loweredTranscript, "news", "headlines", "news update", "tell me the news")) return "news"; + + if (IsWelcomeBackGreeting(loweredTranscript)) return "welcome_back"; + + if (IsGoodMorningGreeting(loweredTranscript)) return "good_morning"; + + if (IsGoodAfternoonGreeting(loweredTranscript)) return "good_afternoon"; + + if (IsGoodEveningGreeting(loweredTranscript)) return "good_evening"; + + if (IsGoodNightGreeting(loweredTranscript)) return "good_night"; + + if (MatchesAny( + loweredTranscript, + "how are you", + "what's up", + "what s up", + "what up", + "how are things", + "how's things", + "how is things", + "how is your day", + "how's your day")) + return "how_are_you"; + + if (MatchesAny( + loweredTranscript, + "what are you up to", + "what are you doing", + "what have you been up to", + "what are you into")) + return "robot_what_do_you_like_to_do"; + + if (MatchesAny(loweredTranscript, "hello", "hi", "hey")) return "hello"; + + if (IsTimeRequest(loweredTranscript)) return "time"; + + if (MatchesAny(loweredTranscript, "what day is it", "what day is today")) return "day"; + + if (IsDateRequest(loweredTranscript)) return "date"; + + return "chat"; + } + + private static JiboInteractionDecision BuildWordOfTheDayLaunchDecision() + { + return new JiboInteractionDecision( + "word_of_the_day", + "Starting word of the day.", + "@be/word-of-the-day", + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["domain"] = "word-of-the-day", + ["skillId"] = "@be/word-of-the-day" + }); + } + + private static JiboInteractionDecision BuildRadioLaunchDecision() + { + return new JiboInteractionDecision( + "radio", + "Opening the radio.", + "@be/radio", + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["skillId"] = "@be/radio" + }); + } + + private static JiboInteractionDecision BuildPhotoGalleryLaunchDecision() + { + return new JiboInteractionDecision( + "photo_gallery", + "Opening the photo gallery.", + "@be/gallery", + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["skillId"] = "@be/gallery", + ["localIntent"] = "menu" + }); + } + + private static JiboInteractionDecision BuildPhotoCreateDecision(string intentName, string replyText, + string localIntent) + { + return new JiboInteractionDecision( + intentName, + replyText, + "@be/create", + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["skillId"] = "@be/create", + ["localIntent"] = localIntent + }); + } + + private static JiboInteractionDecision BuildStopDecision() + { + return new JiboInteractionDecision( + "stop", + "Stopping.", + "@be/idle", + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["skillId"] = "@be/idle", + ["globalIntent"] = "stop", + ["nluDomain"] = "global_commands" + }); + } + + private static JiboInteractionDecision BuildVolumeControlDecision(string intentName, string globalIntent, + string volumeLevel) + { + return new JiboInteractionDecision( + intentName, + "Adjusting volume.", + "global_commands", + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["globalIntent"] = globalIntent, + ["nluDomain"] = "global_commands", + ["volumeLevel"] = volumeLevel + }); + } + + private static JiboInteractionDecision BuildSettingsVolumeDecision() + { + return new JiboInteractionDecision( + "volume_query", + "Opening volume controls.", + "@be/settings", + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["skillId"] = "@be/settings", + ["localIntent"] = "volumeQuery" + }); + } + + private static JiboInteractionDecision BuildClockLaunchDecision(string intentName, string domain, + string clockIntent, string replyText) + { + return new JiboInteractionDecision( + intentName, + replyText, + "@be/clock", + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["skillId"] = "@be/clock", + ["domain"] = domain, + ["clockIntent"] = clockIntent + }); + } + + private static JiboInteractionDecision BuildClockLaunchDecision(string domain, string replyText) + { + return BuildClockLaunchDecision($"{domain}_menu", domain, "menu", replyText); + } + + private static JiboInteractionDecision BuildClockClarifyDecision(string intentName, string domain, string replyText) + { + return new JiboInteractionDecision( + intentName, + replyText, + "@be/clock", + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["skillId"] = "@be/clock", + ["domain"] = domain, + ["clockIntent"] = "set" + }); + } + + private static JiboInteractionDecision BuildTimerValueDecision( + string loweredTranscript, + bool allowImplicit, + IReadOnlyDictionary clientEntities) + { + var timer = TryReadStructuredTimerValue(clientEntities) ?? + TryParseTimerValue(loweredTranscript, allowImplicit) ?? + new ClockTimerValue("0", "1", "null"); + + return new JiboInteractionDecision( + "timer_value", + "Setting your timer.", + "@be/clock", + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["skillId"] = "@be/clock", + ["domain"] = "timer", + ["clockIntent"] = "start", + ["hours"] = timer.Hours, + ["minutes"] = timer.Minutes, + ["seconds"] = timer.Seconds + }); + } + + private static JiboInteractionDecision BuildAlarmValueDecision( + string loweredTranscript, + bool allowImplicit, + DateTimeOffset? referenceLocalTime, + IReadOnlyDictionary clientEntities) + { + var alarm = TryReadStructuredAlarmValue(clientEntities) ?? + TryParseAlarmValue(loweredTranscript, allowImplicit, referenceLocalTime) ?? + new ClockAlarmValue("7:00", "am"); + + return new JiboInteractionDecision( + "alarm_value", + "Setting your alarm.", + "@be/clock", + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["skillId"] = "@be/clock", + ["domain"] = "alarm", + ["clockIntent"] = "start", + ["time"] = alarm.Time, + ["ampm"] = alarm.AmPm + }); + } + + private static JiboInteractionDecision BuildRadioGenreLaunchDecision(string loweredTranscript) + { + var station = TryResolveRadioGenre(loweredTranscript) ?? "Country"; + + return new JiboInteractionDecision( + "radio_genre", + $"Playing {FormatRadioGenreForSpeech(station)} on the radio.", + "@be/radio", + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["skillId"] = "@be/radio", + ["station"] = station + }); + } + + private static JiboInteractionDecision BuildWordOfTheDayGuessDecision( + IReadOnlyDictionary clientEntities, + string transcript, + IReadOnlyList listenAsrHints) + { + var guess = ResolveWordOfTheDayGuess(clientEntities, transcript, listenAsrHints); + + var reply = string.IsNullOrWhiteSpace(guess) + ? "I heard your word of the day guess." + : $"I heard {guess}."; + + return new JiboInteractionDecision( + "word_of_the_day_guess", + reply, + "@be/word-of-the-day", + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["guess"] = guess, + ["skillId"] = "@be/word-of-the-day", + ["cloudResponseMode"] = "completion_only" + }); + } + + private static string ResolveWordOfTheDayGuess( + IReadOnlyDictionary clientEntities, + string transcript, + IReadOnlyList listenAsrHints) + { + if (clientEntities.TryGetValue("guess", out var guessValue) && + !string.IsNullOrWhiteSpace(guessValue)) + return guessValue; + + var loweredTranscript = NormalizeGuessToken(transcript); + var hintIndex = loweredTranscript switch + { + "1" or "one" or "first" => 0, + "2" or "two" or "second" => 1, + "3" or "three" or "third" => 2, + _ => -1 + }; + + if (hintIndex >= 0 && hintIndex < listenAsrHints.Count) return listenAsrHints[hintIndex]; + + var fuzzyHintMatch = FindClosestHint(loweredTranscript, listenAsrHints); + return !string.IsNullOrWhiteSpace(fuzzyHintMatch) ? fuzzyHintMatch : transcript; + } + + private static bool IsYesNoTurn(TurnContext turn) + { + return ReadRules(turn, "listenRules") + .Concat(ReadRules(turn, "clientRules")) + .Concat(ReadRules(turn, "listenAsrHints")) + .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) + { + if (string.IsNullOrWhiteSpace(normalizedTranscript)) return null; + + string? bestHint = null; + var bestDistance = int.MaxValue; + + foreach (var hint in hints) + { + if (string.IsNullOrWhiteSpace(hint)) continue; + + var normalizedHint = NormalizeGuessToken(hint); + if (string.IsNullOrWhiteSpace(normalizedHint)) continue; + + if (string.Equals(normalizedTranscript, normalizedHint, StringComparison.Ordinal)) return hint; + + var distance = ComputeEditDistance(normalizedTranscript, normalizedHint); + if (distance >= bestDistance) continue; + + bestDistance = distance; + bestHint = hint; + } + + return bestDistance <= 2 ? bestHint : null; + } + + private static string NormalizeGuessToken(string value) + { + return value.Trim().TrimEnd('.', '!', '?', ',').ToLowerInvariant(); + } + + private static int ComputeEditDistance(string left, string right) + { + var previous = new int[right.Length + 1]; + var current = new int[right.Length + 1]; + + for (var column = 0; column <= right.Length; column += 1) previous[column] = column; + + for (var row = 1; row <= left.Length; row += 1) + { + current[0] = row; + for (var column = 1; column <= right.Length; column += 1) + { + var substitutionCost = left[row - 1] == right[column - 1] ? 0 : 1; + current[column] = Math.Min( + Math.Min(current[column - 1] + 1, previous[column] + 1), + previous[column - 1] + substitutionCost); + } + + (previous, current) = (current, previous); + } + + return previous[right.Length]; + } + + private static string DescribePersonaAge(DateOnly referenceDate, DateOnly birthday) + { + if (referenceDate < birthday) return "just getting started"; + + var totalDays = referenceDate.DayNumber - birthday.DayNumber; + if (totalDays <= 31) return $"{FormatAgeUnit(totalDays, "day")} old"; + + var totalMonths = (referenceDate.Year - birthday.Year) * 12 + referenceDate.Month - birthday.Month; + if (referenceDate.Day < birthday.Day) totalMonths -= 1; + + totalMonths = Math.Max(totalMonths, 0); + if (totalMonths < 12) return $"{FormatAgeUnit(totalMonths, "month")} old"; + + var years = totalMonths / 12; + var months = totalMonths % 12; + return months == 0 + ? $"{FormatAgeUnit(years, "year")} old" + : $"{FormatAgeUnit(years, "year")} and {FormatAgeUnit(months, "month")} old"; + } + + private static string FormatAgeUnit(int value, string singular) + { + var plural = value == 1 ? singular : $"{singular}s"; + return $"{value} {plural}"; + } + + private static IEnumerable ReadRules(TurnContext turn, string key) + { + if (!turn.Attributes.TryGetValue(key, out var value) || value is null) return []; + + return value switch + { + IReadOnlyList typed => typed, + IEnumerable strings => strings, + JsonElement { ValueKind: JsonValueKind.Array } json => json.EnumerateArray() + .Where(static item => item.ValueKind == JsonValueKind.String) + .Select(static item => item.GetString() ?? string.Empty), + _ => [] + }; + } + + private static IReadOnlyDictionary ReadEntities(TurnContext turn) + { + if (!turn.Attributes.TryGetValue("clientEntities", out var value) || value is null) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + return value switch + { + JsonElement { ValueKind: JsonValueKind.Object } json => json.EnumerateObject() + .Where(static property => property.Value.ValueKind == JsonValueKind.String) + .ToDictionary(property => property.Name, property => property.Value.GetString() ?? string.Empty, + StringComparer.OrdinalIgnoreCase), + IReadOnlyDictionary typed => typed, + IDictionary dictionary => dictionary + .Where(pair => pair.Value is not null) + .ToDictionary(pair => pair.Key, pair => pair.Value?.ToString() ?? string.Empty, + StringComparer.OrdinalIgnoreCase), + _ => new Dictionary(StringComparer.OrdinalIgnoreCase) + }; + } + + private static DateTimeOffset? TryResolveReferenceLocalTime(TurnContext turn) + { + if (!turn.Attributes.TryGetValue("context", out var value) || value is null) return null; + + try + { + var contextJson = value.ToString(); + if (string.IsNullOrWhiteSpace(contextJson)) return null; + + using var document = JsonDocument.Parse(contextJson); + if (!document.RootElement.TryGetProperty("runtime", out var runtime) || + runtime.ValueKind != JsonValueKind.Object || + !runtime.TryGetProperty("location", out var location) || + location.ValueKind != JsonValueKind.Object || + !location.TryGetProperty("iso", out var iso) || + iso.ValueKind != JsonValueKind.String) + return null; + + var isoValue = iso.GetString(); + return DateTimeOffset.TryParse(isoValue, out var parsed) + ? parsed + : null; + } + catch + { + return null; + } + } + + private static bool MatchesAny(string loweredTranscript, params string[] candidates) + { + return candidates.Any(candidate => loweredTranscript.Contains(candidate, StringComparison.Ordinal)); + } + + private static bool IsAffirmativeReply(string loweredTranscript) + { + var normalized = NormalizeCommandPhrase(loweredTranscript); + return TryClassifyYesNoReply(normalized) == YesNoReply.Affirmative; + } + + private static bool IsNegativeReply(string loweredTranscript) + { + var normalized = NormalizeCommandPhrase(loweredTranscript); + 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 TryClassifyTrailingYesNoReply(tokens); + } + + 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 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); + if (string.IsNullOrWhiteSpace(normalized)) return false; + + if (normalized is "time" or "the time" or "current time" or "what time is it" or "what s the time" + or "what is the time") return true; + + return normalized.StartsWith("what time", StringComparison.Ordinal) || + normalized.StartsWith("tell me the time", StringComparison.Ordinal) || + normalized.StartsWith("show me the time", StringComparison.Ordinal); + } + + private static bool IsDateRequest(string loweredTranscript) + { + var normalized = NormalizeCommandPhrase(loweredTranscript); + if (string.IsNullOrWhiteSpace(normalized)) return false; + + return normalized is + "what is the date" or + "what s the date" or + "what date is it" or + "today s date" or + "today date" or + "what is today s date" or + "what s today s date" or + "what is todays date" or + "what s todays date"; + } + + private static bool IsWeatherRequest(string loweredTranscript) + { + var normalized = NormalizeCommandPhrase(loweredTranscript); + if (IsWeatherTopicQuestion(normalized)) return true; + + if (MatchesAny( + loweredTranscript, + "weather", + "forecast", + "how is the weather", + "how s the weather", + "how's the weather", + "check the weather", + "weather report", + "what's today s weather", + "what's today's weather", + "what is the weather", + "what will the weather", + "what will tomorrow s weather", + "what will tomorrow's weather", + "look up the forecast", + "launch the weather skill", + "what is today s humidity", + "what is today's humidity", + "what's the humidity", + "what is the humidity", + "what's today's forecast", + "what s today's forecast", + "what s today s forecast", + "what is today s forecast", + "what is today's forecast", + "what's today's weather look like", + "what s today's weather look like", + "what s today s weather look like", + "what is today s weather look like", + "what is today's weather look like")) + return true; + + if (MatchesAny( + loweredTranscript, + "will it rain", + "will it snow", + "is it raining", + "is it snowing", + "is there going to be hail", + "does it look like rain", + "does it seem like snow", + "is it going to rain", + "is it going to snow", + "do you think it will rain", + "do you think it will snow")) + return true; + + return WeatherConditionForecastPattern.IsMatch(loweredTranscript); + } + + private static bool IsWeatherTopicQuestion(string normalizedTranscript) + { + if (string.IsNullOrWhiteSpace(normalizedTranscript)) return false; + + var mentionsWeatherTopic = + normalizedTranscript.Contains("weather", StringComparison.Ordinal) || + normalizedTranscript.Contains("forecast", StringComparison.Ordinal) || + normalizedTranscript.Contains("temperature", StringComparison.Ordinal) || + normalizedTranscript.Contains("humidity", StringComparison.Ordinal); + if (!mentionsWeatherTopic) return false; + + if (normalizedTranscript.StartsWith("what ", StringComparison.Ordinal) || + normalizedTranscript.StartsWith("how ", StringComparison.Ordinal) || + normalizedTranscript.StartsWith("check ", StringComparison.Ordinal) || + normalizedTranscript.StartsWith("show ", StringComparison.Ordinal) || + normalizedTranscript.StartsWith("tell ", StringComparison.Ordinal) || + normalizedTranscript.StartsWith("look up ", StringComparison.Ordinal) || + normalizedTranscript.StartsWith("launch ", StringComparison.Ordinal) || + normalizedTranscript.StartsWith("give me ", StringComparison.Ordinal) || + normalizedTranscript.StartsWith("temperature ", StringComparison.Ordinal) || + normalizedTranscript.StartsWith("forecast ", StringComparison.Ordinal) || + normalizedTranscript.StartsWith("weather ", StringComparison.Ordinal)) + return true; + + return WeatherTopicLocationPattern.IsMatch(normalizedTranscript); + } + + private static string? TryResolveWeatherLocationQuery(string transcript) + { + var normalized = NormalizeCommandPhrase(transcript); + var match = WeatherLocationPattern.Match(normalized); + if (!match.Success) return null; + + var candidate = match.Groups["location"].Value.Trim(); + if (string.IsNullOrWhiteSpace(candidate)) return null; + + candidate = WeatherLocationSuffixPattern.Replace(candidate, string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(candidate) || + GenericWeatherLocationTerms.Contains(candidate)) + return null; + + return string.IsNullOrWhiteSpace(candidate) + ? null + : CultureInfo.InvariantCulture.TextInfo.ToTitleCase(candidate); + } + + private static (double Latitude, double Longitude)? TryResolveWeatherCoordinates(TurnContext turn) + { + if (!turn.Attributes.TryGetValue("context", out var contextValue) || + contextValue is null || + string.IsNullOrWhiteSpace(contextValue.ToString())) + return null; + + try + { + using var document = JsonDocument.Parse(contextValue.ToString()!); + if (!document.RootElement.TryGetProperty("runtime", out var runtime) || + runtime.ValueKind != JsonValueKind.Object || + !runtime.TryGetProperty("location", out var location) || + location.ValueKind != JsonValueKind.Object) + return null; + + var latitude = TryReadDoubleProperty(location, "lat", "latitude"); + var longitude = TryReadDoubleProperty(location, "lng", "lon", "longitude"); + return latitude is not null && longitude is not null + ? (latitude.Value, longitude.Value) + : null; + } + catch + { + return null; + } + } + + private static GreetingPresenceProfile ResolveGreetingPresenceProfile(TurnContext turn) + { + if (!turn.Attributes.TryGetValue("context", out var contextValue) || + contextValue is null || + string.IsNullOrWhiteSpace(contextValue.ToString())) + return GreetingPresenceProfile.Empty; + + try + { + using var document = JsonDocument.Parse(contextValue.ToString()!); + if (!document.RootElement.TryGetProperty("runtime", out var runtime) || + runtime.ValueKind != JsonValueKind.Object) + return GreetingPresenceProfile.Empty; + + var loopUsers = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (runtime.TryGetProperty("loop", out var loop) && + loop.ValueKind == JsonValueKind.Object && + loop.TryGetProperty("users", out var users) && + users.ValueKind == JsonValueKind.Array) + foreach (var user in users.EnumerateArray()) + { + var id = TryReadStringProperty(user, "id"); + var firstName = TryReadStringProperty(user, "firstName"); + if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(firstName)) + loopUsers[id] = firstName; + } + + var speakerId = string.Empty; + var peoplePresentIds = new List(); + if (runtime.TryGetProperty("perception", out var perception) && + perception.ValueKind == JsonValueKind.Object) + { + if (perception.TryGetProperty("speaker", out var speaker)) + { + if (speaker.ValueKind == JsonValueKind.String) + speakerId = speaker.GetString() ?? string.Empty; + else if (speaker.ValueKind == JsonValueKind.Object) + speakerId = TryReadStringProperty(speaker, "id", "looperID", "looperId") ?? string.Empty; + } + + if (perception.TryGetProperty("peoplePresent", out var peoplePresent) && + peoplePresent.ValueKind == JsonValueKind.Array) + foreach (var person in peoplePresent.EnumerateArray()) + { + var personId = person.ValueKind switch + { + JsonValueKind.String => person.GetString(), + JsonValueKind.Object => TryReadStringProperty(person, "id", "looperID", "looperId"), + _ => null + }; + + if (!string.IsNullOrWhiteSpace(personId) && + !string.Equals(personId, "NOT_TRAINED", StringComparison.OrdinalIgnoreCase)) + peoplePresentIds.Add(personId); + } + } + + var triggerLooperId = turn.Attributes.TryGetValue("triggerLooperId", out var rawTriggerLooperId) + ? rawTriggerLooperId?.ToString() + : null; + var primaryPersonId = !string.IsNullOrWhiteSpace(speakerId) + ? speakerId + : !string.IsNullOrWhiteSpace(triggerLooperId) + ? triggerLooperId + : peoplePresentIds.FirstOrDefault(); + + return new GreetingPresenceProfile( + primaryPersonId, + string.IsNullOrWhiteSpace(speakerId) ? null : speakerId, + peoplePresentIds, + loopUsers); + } + catch + { + return GreetingPresenceProfile.Empty; + } + } + + private static string? TryReadStringProperty(JsonElement source, params string[] propertyNames) + { + foreach (var propertyName in propertyNames) + if (source.TryGetProperty(propertyName, out var value) && + value.ValueKind == JsonValueKind.String && + !string.IsNullOrWhiteSpace(value.GetString())) + return value.GetString(); + + return null; + } + + private static double? TryReadDoubleProperty(JsonElement source, params string[] propertyNames) + { + foreach (var propertyName in propertyNames) + if (source.TryGetProperty(propertyName, out var value) && + value.ValueKind == JsonValueKind.Number && + value.TryGetDouble(out var parsed)) + return parsed; + + return null; + } + + private static bool? ShouldUseCelsius(TurnContext turn, string transcript) + { + var normalized = NormalizeCommandPhrase(transcript); + if (normalized.Contains("celsius", StringComparison.Ordinal) || + normalized.Contains("centigrade", StringComparison.Ordinal)) + return true; + + if (normalized.Contains("fahrenheit", StringComparison.Ordinal)) return false; + + var entities = ReadEntities(turn); + if (entities.TryGetValue("temperatureUnit", out var entityUnit)) + { + if (entityUnit.Contains("celsius", StringComparison.OrdinalIgnoreCase)) return true; + + if (entityUnit.Contains("fahrenheit", StringComparison.OrdinalIgnoreCase)) return false; + } + + var locale = turn.Locale ?? string.Empty; + if (locale.EndsWith("-US", StringComparison.OrdinalIgnoreCase)) return false; + + return null; + } + + private static WeatherDateEntity ResolveWeatherDateEntity( + TurnContext turn, + string transcript, + string normalizedTranscript, + DateTimeOffset? referenceLocalTime) + { + normalizedTranscript = string.IsNullOrWhiteSpace(normalizedTranscript) + ? NormalizeCommandPhrase(transcript) + : normalizedTranscript; + + if (TryResolveWeatherDateEntityFromTranscript(normalizedTranscript, referenceLocalTime, + out var entityFromTranscript)) return entityFromTranscript; + + var entities = ReadEntities(turn); + if (TryResolveWeatherDateEntityFromClientEntities(entities, referenceLocalTime, out var entityFromClient) && + ShouldAcceptClientWeatherDateEntity(normalizedTranscript)) + return entityFromClient; + + return WeatherDateEntity.None; + } + + private static bool TryResolveWeatherDateEntityFromTranscript( + string normalizedTranscript, + DateTimeOffset? referenceLocalTime, + out WeatherDateEntity weatherDate) + { + weatherDate = WeatherDateEntity.None; + if (string.IsNullOrWhiteSpace(normalizedTranscript)) return false; + + if (normalizedTranscript.Contains("day after tomorrow", StringComparison.Ordinal)) + { + weatherDate = new WeatherDateEntity("day_after_tomorrow", 2, "The day after tomorrow"); + return true; + } + + if (MatchesAny(normalizedTranscript, "tomorrow", "tomorrow s", "tomorrow's")) + { + weatherDate = new WeatherDateEntity("tomorrow", 1, "Tomorrow"); + return true; + } + + if (referenceLocalTime is not null && + TryResolveWeatherTimeRangeOffset(normalizedTranscript, referenceLocalTime.Value, out var rangeOffset, + out var rangeLeadIn) && + rangeOffset > 0) + { + weatherDate = new WeatherDateEntity("range", rangeOffset, rangeLeadIn); + return true; + } + + if (referenceLocalTime is not null && + TryResolveWeatherDayOfWeekOffset(normalizedTranscript, referenceLocalTime.Value, out var dayOffset, + out var dayName) && + dayOffset > 0) + { + weatherDate = new WeatherDateEntity("weekday", dayOffset, $"On {dayName}"); + return true; + } + + return false; + } + + private static bool ShouldAcceptClientWeatherDateEntity(string normalizedTranscript) + { + if (string.IsNullOrWhiteSpace(normalizedTranscript)) return true; + + if (HasExplicitWeatherDateCue(normalizedTranscript)) return false; + + if (HasWeatherLocationClause(normalizedTranscript)) return false; + + return !normalizedTranscript.Contains("forecast", StringComparison.Ordinal); + } + + private static bool HasExplicitWeatherDateCue(string normalizedTranscript) + { + if (string.IsNullOrWhiteSpace(normalizedTranscript)) return false; + + if (MatchesAny( + normalizedTranscript, + "today", + "today s", + "today's", + "tonight", + "tomorrow", + "tomorrow s", + "tomorrow's", + "day after tomorrow", + "this week", + "next week", + "weekend", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday")) + return true; + + return WeatherDayOfWeekPattern.IsMatch(normalizedTranscript); + } + + private static bool HasWeatherLocationClause(string normalizedTranscript) + { + if (string.IsNullOrWhiteSpace(normalizedTranscript)) return false; + + return WeatherTopicLocationPattern.IsMatch(normalizedTranscript) || + WeatherLocationPattern.IsMatch(normalizedTranscript); + } + + private static bool TryResolveWeatherDateEntityFromClientEntities( + IReadOnlyDictionary clientEntities, + DateTimeOffset? referenceLocalTime, + out WeatherDateEntity weatherDate) + { + weatherDate = WeatherDateEntity.None; + if (!TryReadClientWeatherDateValue(clientEntities, out var rawDateValue)) return false; + + var normalizedDate = NormalizeCommandPhrase(rawDateValue); + if (normalizedDate.Contains("day after tomorrow", StringComparison.Ordinal)) + { + weatherDate = new WeatherDateEntity("day_after_tomorrow", 2, "The day after tomorrow"); + return true; + } + + if (MatchesAny(normalizedDate, "tomorrow", "tomorrow s", "tomorrow's")) + { + weatherDate = new WeatherDateEntity("tomorrow", 1, "Tomorrow"); + return true; + } + + if (referenceLocalTime is not null && + TryResolveWeatherTimeRangeOffset(normalizedDate, referenceLocalTime.Value, out var rangeOffset, + out var rangeLeadIn) && + rangeOffset > 0) + { + weatherDate = new WeatherDateEntity("range", rangeOffset, rangeLeadIn); + return true; + } + + DateOnly targetDate; + if (DateOnly.TryParse(rawDateValue, out var parsedDate)) + targetDate = parsedDate; + else if (DateTimeOffset.TryParse(rawDateValue, out var parsedDateTimeOffset)) + targetDate = DateOnly.FromDateTime(parsedDateTimeOffset.DateTime); + else + return false; + + var referenceDate = DateOnly.FromDateTime((referenceLocalTime ?? DateTimeOffset.UtcNow).DateTime); + var dayOffset = targetDate.DayNumber - referenceDate.DayNumber; + if (dayOffset <= 0) return false; + + weatherDate = dayOffset == 1 + ? new WeatherDateEntity("tomorrow", 1, "Tomorrow") + : new WeatherDateEntity( + "date", + dayOffset, + $"On {targetDate.ToDateTime(TimeOnly.MinValue).ToString("dddd", CultureInfo.InvariantCulture)}"); + return true; + } + + private static bool TryReadClientWeatherDateValue( + IReadOnlyDictionary clientEntities, + out string dateValue) + { + foreach (var key in WeatherDateEntityKeys) + { + if (!clientEntities.TryGetValue(key, out var rawValue) || + string.IsNullOrWhiteSpace(rawValue)) + continue; + + dateValue = rawValue.Trim(); + return true; + } + + dateValue = string.Empty; + return false; + } + + private static bool TryResolveWeatherDayOfWeekOffset( + string normalizedTranscript, + DateTimeOffset referenceLocalTime, + out int dayOffset, + out string dayName) + { + dayOffset = 0; + dayName = string.Empty; + + var match = WeatherDayOfWeekPattern.Match(normalizedTranscript); + if (!match.Success) return false; + + var dayToken = match.Groups["day"].Value; + if (!TryParseDayOfWeek(dayToken, out var targetDay)) return false; + + var currentDay = referenceLocalTime.DayOfWeek; + dayOffset = ((int)targetDay - (int)currentDay + 7) % 7; + if (match.Groups["next"].Success) + dayOffset = dayOffset == 0 ? 7 : dayOffset + 7; + else if (match.Groups["this"].Success && dayOffset == 0) return false; + + dayName = CultureInfo.InvariantCulture.TextInfo.ToTitleCase(dayToken); + return dayOffset > 0; + } + + private static bool TryResolveWeatherTimeRangeOffset( + string normalizedTranscript, + DateTimeOffset referenceLocalTime, + out int dayOffset, + out string leadIn) + { + dayOffset = 0; + leadIn = string.Empty; + if (string.IsNullOrWhiteSpace(normalizedTranscript)) return false; + + var hasNextWeekend = normalizedTranscript.Contains("next weekend", StringComparison.Ordinal); + var hasThisWeekend = + normalizedTranscript.Contains("this weekend", StringComparison.Ordinal) || + normalizedTranscript.Contains("the weekend", StringComparison.Ordinal) || + normalizedTranscript.EndsWith("weekend", StringComparison.Ordinal); + if (hasNextWeekend || hasThisWeekend) + { + dayOffset = ((int)DayOfWeek.Saturday - (int)referenceLocalTime.DayOfWeek + 7) % 7; + if (hasNextWeekend) + { + dayOffset = dayOffset + 7; + leadIn = "Next weekend"; + } + else + { + // If it's already Saturday, prefer forecasting Sunday for "this weekend". + if (dayOffset == 0 && referenceLocalTime.DayOfWeek == DayOfWeek.Saturday) dayOffset = 1; + + leadIn = "This weekend"; + } + + return dayOffset > 0; + } + + var hasNextWeek = normalizedTranscript.Contains("next week", StringComparison.Ordinal); + if (hasNextWeek) + { + dayOffset = 7; + leadIn = "Next week"; + return true; + } + + var hasThisWeek = normalizedTranscript.Contains("this week", StringComparison.Ordinal); + if (hasThisWeek) + { + dayOffset = referenceLocalTime.DayOfWeek == DayOfWeek.Saturday ? 1 : 2; + leadIn = "Later this week"; + return true; + } + + return false; + } + + private static bool TryParseDayOfWeek(string dayToken, out DayOfWeek dayOfWeek) + { + dayOfWeek = DayOfWeek.Sunday; + return dayToken switch + { + "monday" => AssignDayOfWeek(DayOfWeek.Monday, out dayOfWeek), + "tuesday" => AssignDayOfWeek(DayOfWeek.Tuesday, out dayOfWeek), + "wednesday" => AssignDayOfWeek(DayOfWeek.Wednesday, out dayOfWeek), + "thursday" => AssignDayOfWeek(DayOfWeek.Thursday, out dayOfWeek), + "friday" => AssignDayOfWeek(DayOfWeek.Friday, out dayOfWeek), + "saturday" => AssignDayOfWeek(DayOfWeek.Saturday, out dayOfWeek), + "sunday" => AssignDayOfWeek(DayOfWeek.Sunday, out dayOfWeek), + _ => false + }; + } + + private static bool AssignDayOfWeek(DayOfWeek value, out DayOfWeek target) + { + target = value; + return true; + } + + private static string? TryResolveWeatherConditionEntity(string transcript) + { + var normalized = NormalizeCommandPhrase(transcript); + return normalized switch + { + _ when normalized.Contains("rain", StringComparison.Ordinal) => "rain", + _ when normalized.Contains("snow", StringComparison.Ordinal) => "snow", + _ when normalized.Contains("hail", StringComparison.Ordinal) => "hail", + _ when normalized.Contains("sunny", StringComparison.Ordinal) || + normalized.Contains("clear", StringComparison.Ordinal) => "sunny", + _ when normalized.Contains("cloud", StringComparison.Ordinal) => "cloudy", + _ when normalized.Contains("wind", StringComparison.Ordinal) => "windy", + _ when normalized.Contains("fog", StringComparison.Ordinal) => "fog", + _ => null + }; + } + + private static bool IsWelcomeBackGreeting(string loweredTranscript) + { + return MatchesAny( + loweredTranscript, + "i am back", + "i m back", + "im back", + "i am home", + "i m home", + "im home", + "i'm back", + "i'm home", + "welcome back"); + } + + private static bool IsGoodMorningGreeting(string loweredTranscript) + { + return MatchesAny( + loweredTranscript, + "good morning", + "morning jibo", + "morning, jibo"); + } + + private static bool IsGoodAfternoonGreeting(string loweredTranscript) + { + return MatchesAny( + loweredTranscript, + "good afternoon", + "afternoon jibo", + "afternoon, jibo"); + } + + private static bool IsGoodEveningGreeting(string loweredTranscript) + { + return MatchesAny( + loweredTranscript, + "good evening", + "evening jibo", + "evening, jibo"); + } + + private static bool IsGoodNightGreeting(string loweredTranscript) + { + return MatchesAny( + loweredTranscript, + "good night", + "night jibo", + "night, jibo"); + } + + private static bool IsDanceQuestion(string loweredTranscript) + { + return MatchesAny( + loweredTranscript, + "do you like to dance", + "do you like dancing", + "what kind of dance do you like", + "what kind of dancing do you like", + "do you enjoy dancing"); + } + + private static bool IsRobotBirthdayQuestion(string loweredTranscript) + { + var normalized = NormalizeCommandPhrase(loweredTranscript); + if (MatchesAny( + normalized, + "when is your birthday", + "when s your birthday", + "what s your birthday", + "what is your birthday", + "when is your bday", + "when s your bday", + "what s your bday", + "what is your bday", + "when were you born", + "what day is your birthday")) + return true; + + return (normalized.Contains("your birthday", StringComparison.Ordinal) || + normalized.Contains("your bday", StringComparison.Ordinal) || + normalized.Contains("your birth date", StringComparison.Ordinal)) + && !normalized.Contains("my birthday", StringComparison.Ordinal); + } + + private static bool IsNameSetStatement(string loweredTranscript) + { + return TryExtractNameFact(loweredTranscript) is not null; + } + + private static bool IsNameRecallQuestion(string loweredTranscript) + { + return MatchesAny( + loweredTranscript, + "what is my name", + "what s my name", + "what's my name", + "who am i", + "do you remember my name", + "do you know me", + "do you remember me", + "who is this", + "can you recognize me"); + } + + private static string? TryExtractNameFact(string transcript) + { + var normalized = NormalizeCommandPhrase(transcript); + var prefixes = new[] + { + "my name is ", + "call me " + }; + + foreach (var prefix in prefixes) + { + if (!normalized.StartsWith(prefix, StringComparison.Ordinal)) continue; + + var name = normalized[prefix.Length..].Trim(); + return string.IsNullOrWhiteSpace(name) ? null : name; + } + + return null; + } + + private static bool IsUserBirthdayRecallQuestion(string loweredTranscript) + { + return MatchesAny( + loweredTranscript, + "when is my birthday", + "when's my birthday", + "what is my birthday", + "what s my birthday", + "what's my birthday", + "when is my bday", + "when s my bday", + "what is my bday", + "what s my bday", + "what's my bday", + "do you remember my birthday"); + } + + private static bool IsUserBirthdaySetStatement(string loweredTranscript) + { + return TryExtractBirthdayFact(loweredTranscript) is not null; + } + + private static bool IsUserBirthdaySetAttempt(string loweredTranscript) + { + var normalized = NormalizeCommandPhrase(loweredTranscript); + return normalized.Contains("my birthday is", StringComparison.Ordinal) || + normalized.Contains("my bday is", StringComparison.Ordinal); + } + + private static bool IsUserBirthdayRecallAttempt(string loweredTranscript) + { + var normalized = NormalizeCommandPhrase(loweredTranscript); + return (normalized.Contains("my birthday", StringComparison.Ordinal) || + normalized.Contains("my bday", StringComparison.Ordinal)) && + (normalized.StartsWith("when", StringComparison.Ordinal) || + normalized.StartsWith("what", StringComparison.Ordinal) || + normalized.StartsWith("tell me", StringComparison.Ordinal) || + normalized.StartsWith("do you remember", StringComparison.Ordinal)); + } + + private static string? TryExtractBirthdayFact(string transcript) + { + var normalized = NormalizeCommandPhrase(transcript); + var markers = new[] + { + "my birthday is ", + "my bday is " + }; + + foreach (var marker in markers) + { + var markerIndex = normalized.IndexOf(marker, StringComparison.Ordinal); + if (markerIndex < 0) continue; + + var value = normalized[(markerIndex + marker.Length)..].Trim(); + if (!string.IsNullOrWhiteSpace(value)) return value; + } + + return null; + } + + private static bool IsPreferenceRecallQuestion(string loweredTranscript) + { + return TryExtractPreferenceLookupCategory(loweredTranscript) is not null; + } + + private static bool IsPreferenceSetStatement(string loweredTranscript) + { + return TryExtractPreferenceSet(loweredTranscript) is not null; + } + + private static bool IsPreferenceSetAttempt(string loweredTranscript) + { + var normalized = NormalizeCommandPhrase(loweredTranscript); + if (IsPreferenceRecallAttempt(normalized)) return false; + + return normalized.Contains("my favorite", StringComparison.Ordinal) || + normalized.Contains("my favourite", StringComparison.Ordinal) || + PreferenceReverseMarkers.Any(marker => normalized.Contains(marker, StringComparison.Ordinal)); + } + + private static bool IsPreferenceRecallAttempt(string loweredTranscript) + { + var normalized = NormalizeCommandPhrase(loweredTranscript); + return normalized.StartsWith("what is my favorite", StringComparison.Ordinal) || + normalized.StartsWith("what s my favorite", StringComparison.Ordinal) || + normalized.StartsWith("what is my favourite", StringComparison.Ordinal) || + normalized.StartsWith("what s my favourite", StringComparison.Ordinal) || + normalized.StartsWith("do you remember my favorite", StringComparison.Ordinal) || + normalized.StartsWith("do you remember my favourite", StringComparison.Ordinal); + } + + private static string? TryExtractPreferenceLookupCategory(string transcript) + { + var normalized = NormalizeCommandPhrase(transcript); + var prefixes = new[] + { + "what is my favorite ", + "what s my favorite ", + "what's my favorite ", + "do you remember my favorite ", + "what is my favourite ", + "what s my favourite ", + "what's my favourite ", + "do you remember my favourite " + }; + + foreach (var prefix in prefixes) + { + if (!normalized.StartsWith(prefix, StringComparison.Ordinal)) continue; + + var category = normalized[prefix.Length..].Trim(); + return string.IsNullOrWhiteSpace(category) ? null : category; + } + + return null; + } + + private static (string Category, string Value)? TryExtractPreferenceSet(string transcript) + { + var normalized = NormalizeCommandPhrase(transcript); + foreach (var marker in PreferenceSetMarkers) + { + var markerIndex = normalized.IndexOf(marker, StringComparison.Ordinal); + if (markerIndex < 0) continue; + + var preferencePhrase = normalized[(markerIndex + marker.Length)..]; + var splitMarker = " is "; + var splitIndex = preferencePhrase.IndexOf(splitMarker, StringComparison.Ordinal); + if (splitIndex <= 0 || splitIndex >= preferencePhrase.Length - splitMarker.Length) + { + var fallbackPreference = TryExtractPreferenceSetWithoutCopula(preferencePhrase); + if (fallbackPreference is not null) return fallbackPreference; + + continue; + } + + var category = preferencePhrase[..splitIndex].Trim(); + var value = preferencePhrase[(splitIndex + splitMarker.Length)..].Trim(); + if (!string.IsNullOrWhiteSpace(category) && !string.IsNullOrWhiteSpace(value)) return (category, value); + } + + if (normalized.StartsWith("what ", StringComparison.Ordinal) || + normalized.StartsWith("do you remember ", StringComparison.Ordinal)) + return null; + + foreach (var marker in PreferenceReverseMarkers) + { + var markerIndex = normalized.IndexOf(marker, StringComparison.Ordinal); + if (markerIndex <= 0 || markerIndex >= normalized.Length - marker.Length) continue; + + var value = normalized[..markerIndex].Trim(); + var category = normalized[(markerIndex + marker.Length)..].Trim(); + if (!string.IsNullOrWhiteSpace(category) && !string.IsNullOrWhiteSpace(value)) return (category, value); + } + + return null; + } + + private static (string Category, string Value)? TryExtractPreferenceSetWithoutCopula(string preferencePhrase) + { + if (string.IsNullOrWhiteSpace(preferencePhrase)) return null; + + var normalized = preferencePhrase.Trim(); + if (normalized.Contains(" is ", StringComparison.Ordinal) || + normalized.Contains(" are ", StringComparison.Ordinal) || + normalized.EndsWith(" is", StringComparison.Ordinal) || + normalized.EndsWith(" are", StringComparison.Ordinal)) + return null; + + var parts = normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length < 2) return null; + + var category = parts[0]; + var value = string.Join(' ', parts.Skip(1)).Trim(); + if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(value)) return null; + + return (category, value); + } + + private static bool IsImportantDateSetStatement(string loweredTranscript) + { + return TryExtractImportantDateSet(loweredTranscript) is not null; + } + + private static bool IsImportantDateRecallQuestion(string loweredTranscript) + { + return TryExtractImportantDateLookupLabel(loweredTranscript) is not null; + } + + private static (string Label, string Value)? TryExtractImportantDateSet(string transcript) + { + var normalized = NormalizeCommandPhrase(transcript); + var mapping = new (string Prefix, string Label)[] + { + ("our anniversary is ", "anniversary"), + ("my anniversary is ", "anniversary"), + ("our wedding anniversary is ", "anniversary") + }; + + foreach (var (prefix, label) in mapping) + { + if (!normalized.StartsWith(prefix, StringComparison.Ordinal)) continue; + + var value = normalized[prefix.Length..].Trim(); + if (!string.IsNullOrWhiteSpace(value)) return (label, value); + } + + return null; + } + + private static string? TryExtractImportantDateLookupLabel(string transcript) + { + var normalized = NormalizeCommandPhrase(transcript); + var candidates = new[] + { + "when is our anniversary", + "when s our anniversary", + "when's our anniversary", + "when is my anniversary", + "what is our anniversary", + "do you remember our anniversary" + }; + + return candidates.Any(candidate => string.Equals(normalized, candidate, StringComparison.Ordinal)) + ? "anniversary" + : null; + } + + private static bool IsAffinitySetStatement(string loweredTranscript) + { + return TryExtractAffinitySet(loweredTranscript) is not null; + } + + private static bool IsAffinitySetAttempt(string loweredTranscript) + { + var normalized = NormalizeCommandPhrase(loweredTranscript); + return PegasusUserAffinitySetPrefixes.Any(prefix => MatchesPrefixOrStem(normalized, prefix.Prefix)); + } + + private static bool IsAffinityRecallQuestion(string loweredTranscript) + { + return TryExtractAffinityLookup(loweredTranscript) is not null; + } + + private static bool IsAffinityRecallAttempt(string loweredTranscript) + { + var normalized = NormalizeCommandPhrase(loweredTranscript); + return PegasusUserAffinityLookupPrefixes.Any(prefix => MatchesPrefixOrStem(normalized, prefix.Prefix)); + } + + private static bool MatchesPrefixOrStem(string normalized, string prefix) + { + return normalized.StartsWith(prefix, StringComparison.Ordinal) || + string.Equals(normalized, prefix.TrimEnd(), StringComparison.Ordinal); + } + + private static (string Item, PersonalAffinity Affinity)? TryExtractAffinitySet(string transcript) + { + var normalized = NormalizeCommandPhrase(transcript); + + foreach (var (prefix, affinity) in PegasusUserAffinitySetPrefixes) + { + if (!normalized.StartsWith(prefix, StringComparison.Ordinal)) continue; + + var item = normalized[prefix.Length..].Trim(); + if (!string.IsNullOrWhiteSpace(item)) return (item, affinity); + } + + return null; + } + + private static (string Item, PersonalAffinity? ExpectedAffinity)? TryExtractAffinityLookup(string transcript) + { + var normalized = NormalizeCommandPhrase(transcript); + + foreach (var (prefix, expectedAffinity) in PegasusUserAffinityLookupPrefixes) + { + if (!normalized.StartsWith(prefix, StringComparison.Ordinal)) continue; + + var item = normalized[prefix.Length..].Trim(); + if (!string.IsNullOrWhiteSpace(item)) return (item, expectedAffinity); + } + + return null; + } + + private static string DescribeAffinityAsVerb(PersonalAffinity affinity) + { + return affinity switch + { + PersonalAffinity.Love => "love", + PersonalAffinity.Like => "like", + PersonalAffinity.Dislike => "dislike", + _ => "like" + }; + } + + private static PersonalMemoryTenantScope ResolveTenantScope(TurnContext turn, string? personId = null) + { + var accountId = ReadTenantAttribute(turn, "accountId") ?? "usr_openjibo_owner"; + var loopId = ReadTenantAttribute(turn, "loopId") ?? "openjibo-default-loop"; + var deviceId = turn.DeviceId ?? ReadTenantAttribute(turn, "deviceId") ?? "unknown-device"; + var resolvedPersonId = !string.IsNullOrWhiteSpace(personId) + ? personId + : ReadTenantAttribute(turn, "personId") ?? ReadTenantAttribute(turn, "speakerId"); + return new PersonalMemoryTenantScope(accountId, loopId, deviceId, resolvedPersonId); + } + + private static string? ReadTenantAttribute(TurnContext turn, string key) + { + return turn.Attributes.TryGetValue(key, out var value) + ? value?.ToString() + : null; + } + + private static string? TryResolveRadioGenre(string loweredTranscript) + { + foreach (var (phrase, station) in RadioGenreAliases) + if (loweredTranscript.Contains(phrase, StringComparison.Ordinal)) + return station; + + return null; + } + + private static string FormatRadioGenreForSpeech(string station) + { + return station switch + { + "EightiesAndNinetiesHits" => "eighties and nineties hits", + "ChristianAndGospel" => "Christian and gospel", + "ClassicRock" => "classic rock", + "CollegeRadio" => "college radio", + "HipHop" => "hip hop", + "NewsAndTalk" => "news and talk", + "ReggaeAndIsland" => "reggae and island music", + "SoftRock" => "soft rock", + _ => station + }; + } + + private static ClockTimerValue? TryParseTimerValue(string loweredTranscript, bool allowImplicit = false) + { + if (!allowImplicit && !loweredTranscript.Contains("timer", StringComparison.Ordinal)) return null; + + var hours = ExtractDurationValue(loweredTranscript, "hour"); + var minutes = ExtractDurationValue(loweredTranscript, "minute"); + var seconds = ExtractDurationValue(loweredTranscript, "second"); + + if (hours is null && minutes is null && seconds is null) return null; + + return new ClockTimerValue( + (hours ?? 0).ToString(), + (minutes ?? 0).ToString(), + seconds is null ? "null" : seconds.Value.ToString()); + } + + private static ClockAlarmValue? TryParseAlarmValue( + string loweredTranscript, + bool allowImplicit = false, + DateTimeOffset? referenceLocalTime = null) + { + if (!allowImplicit && !loweredTranscript.Contains("alarm", StringComparison.Ordinal)) return null; + + var compactMatch = CompactAlarmPattern.Match(loweredTranscript); + if (compactMatch.Success) + { + var compact = compactMatch.Groups["compact"].Value; + if (int.TryParse(compact, out var compactValue)) + { + var compactHour = compact.Length switch + { + 3 or 4 => compactValue / 100, + _ => -1 + }; + var compactMinute = compact.Length switch + { + 3 or 4 => compactValue % 100, + _ => -1 + }; + if (compactHour is >= 1 and <= 12 && compactMinute is >= 0 and <= 59) + { + var compactAmPm = ResolveAmPm(compactMatch.Groups["ampm"].Value, compactHour, compactMinute, + referenceLocalTime); + return new ClockAlarmValue($"{compactHour}:{compactMinute:00}", compactAmPm); + } + } + } + + var match = SplitAlarmPattern.Match(loweredTranscript); + if (!match.Success) return null; + + var hourToken = match.Groups["hour"].Value; + var minuteToken = match.Groups["minute"].Success ? match.Groups["minute"].Value : "00"; + var hour = ParseNumberToken(hourToken); + if (hour is null or < 1 or > 12) return null; + + var minute = ParseNumberToken(minuteToken); + if (minute is null or < 0 or > 59) return null; + + var ampm = ResolveAmPm(match.Groups["ampm"].Value, hour.Value, minute.Value, referenceLocalTime); + return new ClockAlarmValue($"{hour}:{minute:00}", ampm); + } + + private static string ResolveAmPm(string token, int hour, int minute, DateTimeOffset? referenceLocalTime) + { + var normalized = token.Replace(" ", string.Empty, StringComparison.Ordinal) + .Replace(".", string.Empty, StringComparison.Ordinal); + if (normalized.StartsWith("p", StringComparison.OrdinalIgnoreCase)) return "pm"; + + if (normalized.StartsWith("a", StringComparison.OrdinalIgnoreCase)) return "am"; + + return referenceLocalTime.HasValue + ? ResolveNextOccurrenceAmPm(hour, minute, referenceLocalTime.Value) + : "am"; + } + + private static string ResolveNextOccurrenceAmPm(int hour, int minute, DateTimeOffset referenceLocalTime) + { + var amCandidate = BuildAlarmCandidate(referenceLocalTime, hour, minute, false); + var pmCandidate = BuildAlarmCandidate(referenceLocalTime, hour, minute, true); + return amCandidate <= pmCandidate ? "am" : "pm"; + } + + private static DateTimeOffset BuildAlarmCandidate(DateTimeOffset referenceLocalTime, int hour, int minute, + bool isPm) + { + var hour24 = hour % 12; + if (isPm) hour24 += 12; + + var candidate = new DateTimeOffset( + referenceLocalTime.Year, + referenceLocalTime.Month, + referenceLocalTime.Day, + hour24, + minute, + 0, + referenceLocalTime.Offset); + + if (candidate <= referenceLocalTime) candidate = candidate.AddDays(1); + + return candidate; + } + + private static bool HasStructuredTimerValue(IReadOnlyDictionary clientEntities) + { + return clientEntities.ContainsKey("hours") || + clientEntities.ContainsKey("minutes") || + clientEntities.ContainsKey("seconds"); + } + + private static bool HasStructuredAlarmValue(IReadOnlyDictionary clientEntities) + { + return clientEntities.TryGetValue("time", out var time) && + !string.IsNullOrWhiteSpace(time); + } + + private static ClockTimerValue? TryReadStructuredTimerValue(IReadOnlyDictionary clientEntities) + { + if (!HasStructuredTimerValue(clientEntities)) return null; + + clientEntities.TryGetValue("hours", out var hours); + clientEntities.TryGetValue("minutes", out var minutes); + clientEntities.TryGetValue("seconds", out var seconds); + return new ClockTimerValue( + string.IsNullOrWhiteSpace(hours) ? "0" : hours, + string.IsNullOrWhiteSpace(minutes) ? "0" : minutes, + string.IsNullOrWhiteSpace(seconds) ? "null" : seconds); + } + + private static ClockAlarmValue? TryReadStructuredAlarmValue(IReadOnlyDictionary clientEntities) + { + if (!clientEntities.TryGetValue("time", out var time) || string.IsNullOrWhiteSpace(time)) return null; + + clientEntities.TryGetValue("ampm", out var ampm); + return new ClockAlarmValue(time, string.IsNullOrWhiteSpace(ampm) ? "am" : ampm.ToLowerInvariant()); + } + + private static string? ResolveClockDomain( + IReadOnlyDictionary clientEntities, + IReadOnlyList clientRules, + IReadOnlyList listenRules, + string? lastClockDomain) + { + if (clientEntities.TryGetValue("domain", out var clientDomain) && + !string.IsNullOrWhiteSpace(clientDomain)) + return clientDomain; + + if (!string.IsNullOrWhiteSpace(lastClockDomain)) return lastClockDomain; + + var combinedRules = clientRules.Concat(listenRules).ToArray(); + if (combinedRules.Any(rule => + rule.Contains("timer", StringComparison.OrdinalIgnoreCase) && + !rule.Contains("alarm_timer_query_menu", StringComparison.OrdinalIgnoreCase))) + return "timer"; + + return combinedRules.Any(rule => + rule.Contains("alarm", StringComparison.OrdinalIgnoreCase) && + !rule.Contains("alarm_timer_query_menu", StringComparison.OrdinalIgnoreCase)) + ? "alarm" + : null; + } + + private static bool IsTimerRequest(string loweredTranscript) + { + return MatchesAny( + loweredTranscript, + "set a timer", + "set timer", + "start a timer", + "start timer", + "timer for"); + } + + private static bool IsAlarmRequest(string loweredTranscript) + { + return MatchesAny( + loweredTranscript, + "set an alarm", + "set alarm", + "wake me up", + "alarm for"); + } + + private static bool IsCancelRequest(string? clientIntent, string loweredTranscript) + { + var normalizedTranscript = NormalizeCommandPhrase(loweredTranscript); + return string.Equals(clientIntent, "cancel", StringComparison.OrdinalIgnoreCase) || + string.Equals(clientIntent, "stop", StringComparison.OrdinalIgnoreCase) || + normalizedTranscript is "cancel" or "stop" or "never mind" or "nevermind"; + } + + private static bool IsGlobalStopRequest( + string loweredTranscript, + string? clientIntent, + IReadOnlyDictionary clientEntities) + { + if (string.Equals(clientIntent, "stop", StringComparison.OrdinalIgnoreCase) && + IsGlobalCommandsDomain(clientEntities)) + return true; + + var normalizedTranscript = NormalizeCommandPhrase(loweredTranscript); + return normalizedTranscript is "stop" or "stop it" or "stop that" or "stop talking" or "be quiet" + or "never mind" or "nevermind" or "forget it" || + MatchesAny(normalizedTranscript, "that s enough", "that will do", "that ll do", "cut it out", + "cut that out"); + } + + private static bool IsVolumeQueryRequest(string loweredTranscript) + { + return MatchesAny( + loweredTranscript, + "volume controls", + "volume control", + "volume menu", + "volume level", + "show volume", + "show the volume", + "open volume", + "open the volume", + "what is your volume", + "what's your volume", + "how is your volume", + "how s your volume"); + } + + private static bool IsAlarmDeleteRequest(string loweredTranscript) + { + var normalizedTranscript = NormalizeCommandPhrase(loweredTranscript); + return AlarmDeletePattern.IsMatch(normalizedTranscript); + } + + private static bool IsVolumeUpRequest(string loweredTranscript) + { + return MatchesAny( + loweredTranscript, + "turn it up", + "turn this up", + "turn that up", + "turn up the volume", + "turn the volume up", + "turn volume up", + "turn your volume up", + "increase the volume", + "increase your volume", + "raise the volume", + "raise your volume", + "make it louder", + "make that louder", + "speak louder", + "talk louder", + "be louder", + "louder"); + } + + private static bool IsVolumeDownRequest(string loweredTranscript) + { + return MatchesAny( + loweredTranscript, + "turn it down", + "turn this down", + "turn that down", + "turn down the volume", + "turn the volume down", + "turn volume down", + "turn your volume down", + "decrease the volume", + "decrease your volume", + "lower the volume", + "lower your volume", + "make it quieter", + "make that quieter", + "make it softer", + "speak quieter", + "talk quieter", + "be quieter", + "quieter", + "softer"); + } + + private static string? ResolveVolumeLevel(string loweredTranscript, + IReadOnlyDictionary clientEntities) + { + if (clientEntities.TryGetValue("volumeLevel", out var entityValue) && + TryNormalizeVolumeLevel(entityValue) is { } structuredLevel) + return structuredLevel; + + return TryResolveVolumeLevel(loweredTranscript); + } + + private static string? TryResolveVolumeLevel(string loweredTranscript) + { + if (!loweredTranscript.Contains("volume", StringComparison.Ordinal) && + !loweredTranscript.Contains("loudness", StringComparison.Ordinal)) + return null; + + if (MatchesAny(loweredTranscript, "max volume", "maximum volume", "volume max", "volume maximum")) return "10"; + + if (MatchesAny(loweredTranscript, "min volume", "minimum volume", "volume min", "volume minimum")) return "1"; + + var normalizedTranscript = NormalizeCommandPhrase(loweredTranscript); + var homophoneMatch = VolumeToValueHomophonePattern.Match(normalizedTranscript); + if (homophoneMatch.Success && + TryNormalizeVolumeLevel(homophoneMatch.Groups["value"].Value) is { } homophoneLevel) + return homophoneLevel; + + var match = VolumeLevelPattern.Match(normalizedTranscript); + return !match.Success ? null : TryNormalizeVolumeLevel(match.Groups["value"].Value); + } + + private static string NormalizeCommandPhrase(string value) + { + return CommandWhitespacePattern.Replace( + CommandPhrasePattern.Replace(value.Trim().ToLowerInvariant(), " "), + " ") + .Trim(); + } + + private static string? TryNormalizeVolumeLevel(string token) + { + if (string.Equals(token, "null", StringComparison.OrdinalIgnoreCase)) return "null"; + + var parsed = ParseNumberToken(token); + return parsed is >= 1 and <= 10 + ? parsed.Value.ToString() + : null; + } + + private static bool IsGlobalCommandsDomain(IReadOnlyDictionary clientEntities) + { + return clientEntities.TryGetValue("domain", out var domain) && + string.Equals(domain, "global_commands", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsClockTimerValueTurn( + IReadOnlyList clientRules, + IReadOnlyList listenRules) + { + return clientRules.Concat(listenRules).Any(static rule => + rule.Contains("clock/", StringComparison.OrdinalIgnoreCase) && + rule.Contains("timer", StringComparison.OrdinalIgnoreCase) && + rule.Contains("value", StringComparison.OrdinalIgnoreCase)); + } + + private static bool IsClockAlarmValueTurn( + IReadOnlyList clientRules, + IReadOnlyList listenRules) + { + return clientRules.Concat(listenRules).Any(static rule => + rule.Contains("clock/", StringComparison.OrdinalIgnoreCase) && + rule.Contains("alarm", StringComparison.OrdinalIgnoreCase) && + rule.Contains("value", StringComparison.OrdinalIgnoreCase)); + } + + private static int? ExtractDurationValue(string loweredTranscript, string unitStem) + { + var pattern = new Regex($@"\b(?\d+|[a-z\-]+(?:\s+[a-z\-]+)?)\s+{unitStem}s?\b", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + var match = pattern.Match(loweredTranscript); + if (!match.Success) return null; + + var valueToken = match.Groups["value"].Value.Trim(); + var parsed = ParseNumberToken(valueToken); + if (parsed is not null) return parsed; + + var parts = valueToken.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length < 2) + return parts.Length > 0 + ? ParseNumberToken(parts[^1]) + : null; + + parsed = ParseNumberToken(string.Join(' ', parts.TakeLast(2))); + if (parsed is not null) return parsed; + + return parts.Length > 0 + ? ParseNumberToken(parts[^1]) + : null; + } + + private static int? ParseNumberToken(string token) + { + var normalized = token.Trim().ToLowerInvariant().Replace("-", " ", StringComparison.Ordinal); + if (int.TryParse(normalized, out var numeric)) return numeric; + + if (!normalized.Contains(' ')) + return normalized switch + { + "a" or "an" => 1, + "one" => 1, + "two" => 2, + "three" => 3, + "four" => 4, + "five" => 5, + "six" => 6, + "seven" => 7, + "eight" => 8, + "nine" => 9, + "ten" => 10, + "eleven" => 11, + "twelve" => 12, + "thirteen" => 13, + "fourteen" => 14, + "fifteen" => 15, + "sixteen" => 16, + "seventeen" => 17, + "eighteen" => 18, + "nineteen" => 19, + "twenty" => 20, + "thirty" => 30, + "forty" => 40, + "fifty" => 50, + _ => null + }; + + var parts = normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length != 2) + return normalized switch + { + "a" or "an" => 1, + "one" => 1, + "two" => 2, + "three" => 3, + "four" => 4, + "five" => 5, + "six" => 6, + "seven" => 7, + "eight" => 8, + "nine" => 9, + "ten" => 10, + "eleven" => 11, + "twelve" => 12, + "thirteen" => 13, + "fourteen" => 14, + "fifteen" => 15, + "sixteen" => 16, + "seventeen" => 17, + "eighteen" => 18, + "nineteen" => 19, + "twenty" => 20, + "thirty" => 30, + "forty" => 40, + "fifty" => 50, + _ => null + }; + + var first = ParseNumberToken(parts[0]); + var second = ParseNumberToken(parts[1]); + if (first is >= 20 && second is >= 0 and < 10) return first + second; + + return normalized switch + { + "a" or "an" => 1, + "one" => 1, + "two" => 2, + "three" => 3, + "four" => 4, + "five" => 5, + "six" => 6, + "seven" => 7, + "eight" => 8, + "nine" => 9, + "ten" => 10, + "eleven" => 11, + "twelve" => 12, + "thirteen" => 13, + "fourteen" => 14, + "fifteen" => 15, + "sixteen" => 16, + "seventeen" => 17, + "eighteen" => 18, + "nineteen" => 19, + "twenty" => 20, + "thirty" => 30, + "forty" => 40, + "fifty" => 50, + _ => null + }; + } + + private sealed record ClockTimerValue(string Hours, string Minutes, string Seconds); + + private sealed record ClockAlarmValue(string Time, string AmPm); + + private sealed record PizzaMimPrompt(string PromptId, string Esml); + + private sealed record ProactivityCandidate(string IntentName, int Weight); + + private sealed record PizzaSignal(PersonalAffinity? Affinity); + + private sealed record GreetingPresenceProfile( + string? PrimaryPersonId, + string? SpeakerId, + IReadOnlyList PeoplePresentIds, + IReadOnlyDictionary LoopUserFirstNames) + { + public static GreetingPresenceProfile Empty { get; } = new( + null, + null, + Array.Empty(), + new Dictionary(StringComparer.OrdinalIgnoreCase)); + + public bool HasKnownIdentity => !string.IsNullOrWhiteSpace(PrimaryPersonId); + } + + private sealed record WeatherDateEntity(string? DateEntity, int ForecastDayOffset, string? ForecastLeadIn) + { + public static WeatherDateEntity None { get; } = new(null, 0, null); + } + + private enum YesNoReply + { + None = 0, + Affirmative = 1, + Negative = 2 + } + + private sealed record WeatherForecastCardSegment( + string DayName, + string Summary, + int High, + int Low, + string Icon, + string Unit, + string Theme, + string SpokenLine); } public sealed record JiboInteractionDecision( @@ -5917,4 +5093,4 @@ public sealed record JiboInteractionDecision( string ReplyText, string? SkillName = null, IDictionary? SkillPayload = null, - IDictionary? ContextUpdates = null); + IDictionary? ContextUpdates = null); \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboWebSocketService.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboWebSocketService.cs index 801ab3c..552370f 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboWebSocketService.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboWebSocketService.cs @@ -15,7 +15,8 @@ public sealed class JiboWebSocketService( stateStore.OpenSession(envelope.Kind, null, envelope.Token, envelope.HostName, envelope.Path); } - public async Task> HandleMessageAsync(WebSocketMessageEnvelope envelope, CancellationToken cancellationToken = default) + public async Task> HandleMessageAsync(WebSocketMessageEnvelope envelope, + CancellationToken cancellationToken = default) { var session = GetOrCreateSession(envelope); session.LastSeenUtc = DateTimeOffset.UtcNow; @@ -23,11 +24,12 @@ public sealed class JiboWebSocketService( if (envelope.IsBinary) { var replies = await turnFinalizationService.HandleBinaryAudioAsync(session, envelope, cancellationToken); - await telemetrySink.RecordTurnEventAsync(envelope, session, "binary_audio_received", new Dictionary - { - ["bytes"] = envelope.Binary?.Length ?? 0, - ["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session) - }, cancellationToken); + await telemetrySink.RecordTurnEventAsync(envelope, session, "binary_audio_received", + new Dictionary + { + ["bytes"] = envelope.Binary?.Length ?? 0, + ["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session) + }, cancellationToken); return replies; } @@ -50,13 +52,14 @@ public sealed class JiboWebSocketService( }) .ToArray(); - await telemetrySink.RecordTurnEventAsync(envelope, session, "late_listen_ignored", new Dictionary - { - ["messageType"] = parsedType, - ["activeTransID"] = session.TurnState.TransId, - ["ignoredTransID"] = lateTransId, - ["replyCount"] = replies.Length - }, cancellationToken); + await telemetrySink.RecordTurnEventAsync(envelope, session, "late_listen_ignored", + new Dictionary + { + ["messageType"] = parsedType, + ["activeTransID"] = session.TurnState.TransId, + ["ignoredTransID"] = lateTransId, + ["replyCount"] = replies.Length + }, cancellationToken); return replies; } @@ -65,12 +68,13 @@ public sealed class JiboWebSocketService( WebSocketTurnFinalizationService.TryRecoverStalePendingListen(session, out staleListenAgeMs)) { staleListenRecovered = true; - await telemetrySink.RecordTurnEventAsync(envelope, session, "glsm_stale_listen_recovered", new Dictionary - { - ["staleAgeMs"] = staleListenAgeMs, - ["transID"] = session.TurnState.TransId, - ["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session) - }, cancellationToken); + await telemetrySink.RecordTurnEventAsync(envelope, session, "glsm_stale_listen_recovered", + new Dictionary + { + ["staleAgeMs"] = staleListenAgeMs, + ["transID"] = session.TurnState.TransId, + ["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session) + }, cancellationToken); } WebSocketTurnFinalizationService.ObserveIncomingMessage(session, envelope.Text); @@ -80,11 +84,12 @@ public sealed class JiboWebSocketService( case "CONTEXT": { var replies = await turnFinalizationService.HandleContextAsync(session, envelope, cancellationToken); - await telemetrySink.RecordTurnEventAsync(envelope, session, "context_received", new Dictionary - { - ["transID"] = session.TurnState.TransId, - ["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session) - }, cancellationToken); + await telemetrySink.RecordTurnEventAsync(envelope, session, "context_received", + new Dictionary + { + ["transID"] = session.TurnState.TransId, + ["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session) + }, cancellationToken); return replies; } case "LISTEN": @@ -92,29 +97,32 @@ public sealed class JiboWebSocketService( var replies = containsInlineTurnPayload ? await turnFinalizationService.HandleTurnAsync(session, envelope, parsedType, cancellationToken) : WebSocketTurnFinalizationService.HandleListenSetup(session, envelope); - await telemetrySink.RecordTurnEventAsync(envelope, session, "turn_processed", new Dictionary - { - ["messageType"] = parsedType, - ["replyCount"] = replies.Count, - ["transcript"] = session.LastTranscript, - ["intent"] = session.LastIntent, - ["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session), - ["staleListenRecovered"] = staleListenRecovered, - ["staleListenAgeMs"] = staleListenAgeMs - }, cancellationToken); + await telemetrySink.RecordTurnEventAsync(envelope, session, "turn_processed", + new Dictionary + { + ["messageType"] = parsedType, + ["replyCount"] = replies.Count, + ["transcript"] = session.LastTranscript, + ["intent"] = session.LastIntent, + ["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session), + ["staleListenRecovered"] = staleListenRecovered, + ["staleListenAgeMs"] = staleListenAgeMs + }, cancellationToken); return replies; } case "CLIENT_NLU" or "CLIENT_ASR" or "TRIGGER": { - var replies = await turnFinalizationService.HandleTurnAsync(session, envelope, parsedType, cancellationToken); - await telemetrySink.RecordTurnEventAsync(envelope, session, "turn_processed", new Dictionary - { - ["messageType"] = parsedType, - ["replyCount"] = replies.Count, - ["transcript"] = session.LastTranscript, - ["intent"] = session.LastIntent, - ["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session) - }, cancellationToken); + var replies = + await turnFinalizationService.HandleTurnAsync(session, envelope, parsedType, cancellationToken); + await telemetrySink.RecordTurnEventAsync(envelope, session, "turn_processed", + new Dictionary + { + ["messageType"] = parsedType, + ["replyCount"] = replies.Count, + ["transcript"] = session.LastTranscript, + ["intent"] = session.LastIntent, + ["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session) + }, cancellationToken); return replies; } default: @@ -124,18 +132,13 @@ public sealed class JiboWebSocketService( private static string ReadMessageType(string? text) { - if (string.IsNullOrWhiteSpace(text)) - { - return "UNKNOWN"; - } + if (string.IsNullOrWhiteSpace(text)) return "UNKNOWN"; try { using var document = JsonDocument.Parse(text); if (document.RootElement.TryGetProperty("type", out var type) && type.ValueKind == JsonValueKind.String) - { return type.GetString() ?? "UNKNOWN"; - } } catch { @@ -147,25 +150,18 @@ public sealed class JiboWebSocketService( private static bool ContainsInlineTurnPayload(string? text) { - if (string.IsNullOrWhiteSpace(text)) - { - return false; - } + if (string.IsNullOrWhiteSpace(text)) return false; try { using var document = JsonDocument.Parse(text); - if (!document.RootElement.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object) - { - return false; - } + if (!document.RootElement.TryGetProperty("data", out var data) || + data.ValueKind != JsonValueKind.Object) return false; if (data.TryGetProperty("text", out var transcript) && transcript.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(transcript.GetString())) - { return true; - } return data.TryGetProperty("asr", out var asr) && asr.ValueKind == JsonValueKind.Object && @@ -186,10 +182,7 @@ public sealed class JiboWebSocketService( var transId = session.TurnState.TransId ?? session.LastTransId ?? string.Empty; var rules = session.TurnState.ListenRules; - if (string.IsNullOrWhiteSpace(text)) - { - return (transId, rules); - } + if (string.IsNullOrWhiteSpace(text)) return (transId, rules); try { @@ -199,9 +192,7 @@ public sealed class JiboWebSocketService( if (root.TryGetProperty("transID", out var transIdValue) && transIdValue.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(transIdValue.GetString())) - { transId = transIdValue.GetString()!; - } if (root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object && @@ -214,10 +205,7 @@ public sealed class JiboWebSocketService( .Where(static rule => !string.IsNullOrWhiteSpace(rule)) .ToArray(); - if (parsedRules.Length > 0) - { - rules = parsedRules; - } + if (parsedRules.Length > 0) rules = parsedRules; } } catch @@ -227,4 +215,4 @@ public sealed class JiboWebSocketService( return (transId, rules); } -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/NullProtocolTelemetrySink.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/NullProtocolTelemetrySink.cs index f3f8e4c..77bbe34 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/NullProtocolTelemetrySink.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/NullProtocolTelemetrySink.cs @@ -5,5 +5,9 @@ namespace Jibo.Cloud.Application.Services; public sealed class NullProtocolTelemetrySink : IProtocolTelemetrySink { - public Task RecordAsync(ProtocolEnvelope envelope, ProtocolDispatchResult result, CancellationToken cancellationToken = default) => Task.CompletedTask; -} + public Task RecordAsync(ProtocolEnvelope envelope, ProtocolDispatchResult result, + CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/NullTurnTelemetrySink.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/NullTurnTelemetrySink.cs index c388e8c..d2cf552 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/NullTurnTelemetrySink.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/NullTurnTelemetrySink.cs @@ -4,7 +4,14 @@ namespace Jibo.Cloud.Application.Services; public sealed class NullTurnTelemetrySink : ITurnTelemetrySink { - public Task RecordTurnDiagnosticAsync(string category, IReadOnlyDictionary details, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task RecordTurnDiagnosticAsync(string category, IReadOnlyDictionary details, + CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } - public Task RecordTranscriptError(Exception ex, string message, CancellationToken cancellationToken = default) => Task.CompletedTask; -} + public Task RecordTranscriptError(Exception ex, string message, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/NullWebSocketTelemetrySink.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/NullWebSocketTelemetrySink.cs index beac1d3..8bd9572 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/NullWebSocketTelemetrySink.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/NullWebSocketTelemetrySink.cs @@ -5,9 +5,33 @@ namespace Jibo.Cloud.Application.Services; public sealed class NullWebSocketTelemetrySink : IWebSocketTelemetrySink { - public Task RecordConnectionOpenedAsync(WebSocketMessageEnvelope envelope, CloudSession session, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task RecordInboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, string? messageType, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task RecordTurnEventAsync(WebSocketMessageEnvelope envelope, CloudSession session, string eventType, IReadOnlyDictionary details, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task RecordOutboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, IReadOnlyList replies, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task RecordConnectionClosedAsync(WebSocketMessageEnvelope envelope, CloudSession session, string reason, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task RecordConnectionOpenedAsync(WebSocketMessageEnvelope envelope, CloudSession session, + CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task RecordInboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, string? messageType, + CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task RecordTurnEventAsync(WebSocketMessageEnvelope envelope, CloudSession session, string eventType, + IReadOnlyDictionary details, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task RecordOutboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, + IReadOnlyList replies, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task RecordConnectionClosedAsync(WebSocketMessageEnvelope envelope, CloudSession session, string reason, + CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } } \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/OpenJiboCloudBuildInfo.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/OpenJiboCloudBuildInfo.cs index 0e992c9..515eb96 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/OpenJiboCloudBuildInfo.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/OpenJiboCloudBuildInfo.cs @@ -12,5 +12,6 @@ public static class OpenJiboCloudBuildInfo public static string SpokenVersion => $"Cloud version {VersionWords}."; - public static string EsmlVersion => $"Cloud version {VersionWords.Replace(" ", "")}."; -} + public static string EsmlVersion => + $"Cloud version {VersionWords.Replace(" ", "")}."; +} \ No newline at end of file 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 e1175a0..d385b72 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 @@ -1,7 +1,7 @@ -using Jibo.Cloud.Application.Abstractions; -using Jibo.Runtime.Abstractions; using System.Text.Json; using System.Text.RegularExpressions; +using Jibo.Cloud.Application.Abstractions; +using Jibo.Runtime.Abstractions; namespace Jibo.Cloud.Application.Services; @@ -58,6 +58,8 @@ internal static class PersonalReportOrchestrator "maybe later" ]; + private static readonly Regex NameNoiseRegex = new("[^a-zA-Z\\-\\s']", RegexOptions.Compiled); + public static async Task TryBuildDecisionAsync( TurnContext turn, string semanticIntent, @@ -72,31 +74,26 @@ internal static class PersonalReportOrchestrator { var state = ReadState(turn); var isActiveState = !string.Equals(state, IdleState, StringComparison.OrdinalIgnoreCase); - if (!isActiveState && !string.Equals(semanticIntent, "personal_report", StringComparison.OrdinalIgnoreCase)) - { - return null; - } + if (!isActiveState && + !string.Equals(semanticIntent, "personal_report", StringComparison.OrdinalIgnoreCase)) return null; var toggles = ApplyInlineToggleHints( ReadServiceToggles(turn), loweredTranscript, out var inlineToggleSummary); - if (ContainsAnyPhrase(loweredTranscript, CancelPhrases)) - { - return BuildCancelledDecision(toggles); - } + if (ContainsAnyPhrase(loweredTranscript, CancelPhrases)) return BuildCancelledDecision(toggles); if (!isActiveState) { var contextUpdates = BuildContextUpdates( AwaitingOptInState, - noMatchCount: 0, - noInputCount: 0, + 0, + 0, toggles, - userName: ReadString(turn, UserNameMetadataKey), - userVerified: ReadBool(turn, UserVerifiedMetadataKey) ?? false, - lastServiceError: string.Empty); + ReadString(turn, UserNameMetadataKey), + ReadBool(turn, UserVerifiedMetadataKey) ?? false, + string.Empty); var reply = string.IsNullOrWhiteSpace(inlineToggleSummary) ? "Would you like your personal report now?" @@ -108,10 +105,7 @@ internal static class PersonalReportOrchestrator ContextUpdates: contextUpdates); } - if (string.IsNullOrWhiteSpace(loweredTranscript)) - { - return BuildNoInputDecision(turn, state, toggles); - } + if (string.IsNullOrWhiteSpace(loweredTranscript)) return BuildNoInputDecision(turn, state, toggles); switch (state) { @@ -121,81 +115,71 @@ internal static class PersonalReportOrchestrator var scope = tenantScopeResolver(turn); var knownName = ReadString(turn, UserNameMetadataKey) ?? personalMemoryStore.GetName(scope); if (!string.IsNullOrWhiteSpace(knownName)) - { return new JiboInteractionDecision( "personal_report_verify_user", $"I think this is {knownName}. Is that right?", ContextUpdates: BuildContextUpdates( AwaitingIdentityConfirmationState, - noMatchCount: 0, - noInputCount: 0, + 0, + 0, toggles, - userName: knownName, - userVerified: false, - lastServiceError: string.Empty)); - } + knownName, + false, + string.Empty)); return new JiboInteractionDecision( "personal_report_request_name", "Who is this?", ContextUpdates: BuildContextUpdates( AwaitingIdentityNameState, - noMatchCount: 0, - noInputCount: 0, + 0, + 0, toggles, - userName: null, - userVerified: false, - lastServiceError: string.Empty)); + null, + false, + string.Empty)); } - if (IsNegativeReply(loweredTranscript)) - { - return BuildDeclinedDecision(toggles); - } + if (IsNegativeReply(loweredTranscript)) return BuildDeclinedDecision(toggles); if (!string.IsNullOrWhiteSpace(inlineToggleSummary)) - { return new JiboInteractionDecision( "personal_report_opt_in", $"{inlineToggleSummary} Would you like your personal report now?", ContextUpdates: BuildContextUpdates( AwaitingOptInState, - noMatchCount: 0, - noInputCount: 0, + 0, + 0, toggles, - userName: ReadString(turn, UserNameMetadataKey), - userVerified: false, - lastServiceError: string.Empty)); - } + ReadString(turn, UserNameMetadataKey), + false, + string.Empty)); return BuildNoMatchDecision( turn, state, "Please say yes to start your personal report, or no to skip it.", toggles, - userName: ReadString(turn, UserNameMetadataKey), - userVerified: false); + ReadString(turn, UserNameMetadataKey), + false); case AwaitingIdentityConfirmationState: { var currentName = ReadString(turn, UserNameMetadataKey); if (string.IsNullOrWhiteSpace(currentName)) - { return new JiboInteractionDecision( "personal_report_request_name", "Who is this?", ContextUpdates: BuildContextUpdates( AwaitingIdentityNameState, - noMatchCount: 0, - noInputCount: 0, + 0, + 0, toggles, - userName: null, - userVerified: false, - lastServiceError: string.Empty)); - } + null, + false, + string.Empty)); if (IsAffirmativeReply(loweredTranscript)) - { return await BuildDeliveredReportDecisionAsync( turn, catalog, @@ -204,45 +188,40 @@ internal static class PersonalReportOrchestrator currentName, buildWeatherDecisionAsync, cancellationToken); - } if (IsNegativeReply(loweredTranscript)) - { return new JiboInteractionDecision( "personal_report_request_name", "Okay, who is this?", ContextUpdates: BuildContextUpdates( AwaitingIdentityNameState, - noMatchCount: 0, - noInputCount: 0, + 0, + 0, toggles, - userName: null, - userVerified: false, - lastServiceError: string.Empty)); - } + null, + false, + string.Empty)); return BuildNoMatchDecision( turn, state, $"Please answer yes or no. Is this {currentName}?", toggles, - userName: currentName, - userVerified: false); + currentName, + false); } case AwaitingIdentityNameState: { var parsedName = TryExtractName(loweredTranscript); if (string.IsNullOrWhiteSpace(parsedName)) - { return BuildNoMatchDecision( turn, state, "Tell me your name like this: my name is Alex.", toggles, - userName: null, - userVerified: false); - } + null, + false); personalMemoryStore.SetName(tenantScopeResolver(turn), parsedName); return await BuildDeliveredReportDecisionAsync( @@ -284,10 +263,7 @@ internal static class PersonalReportOrchestrator reportSections.Add("First, your weather."); var weatherDecision = await buildWeatherDecisionAsync(turn, "weather", cancellationToken); reportSections.Add(weatherDecision.ReplyText); - if (IsWeatherErrorReply(weatherDecision.ReplyText)) - { - serviceError = "weather"; - } + if (IsWeatherErrorReply(weatherDecision.ReplyText)) serviceError = "weather"; } if (toggles.CalendarEnabled) @@ -309,7 +285,6 @@ internal static class PersonalReportOrchestrator } if (toggles.CommuteEnabled) - { reportSections.Add( RenderReportSkillTemplate( ChooseReportSkillTemplate( @@ -317,7 +292,6 @@ internal static class PersonalReportOrchestrator catalog.CommuteNowReplies, "Sorry, commute information isn't available right now."), userName)); - } if (toggles.NewsEnabled) { @@ -350,12 +324,12 @@ internal static class PersonalReportOrchestrator string.Join(" ", reportSections), ContextUpdates: BuildContextUpdates( IdleState, - noMatchCount: 0, - noInputCount: 0, + 0, + 0, toggles, userName, - userVerified: true, - lastServiceError: serviceError)); + true, + serviceError)); } private static JiboInteractionDecision BuildNoInputDecision( @@ -364,22 +338,19 @@ internal static class PersonalReportOrchestrator PersonalReportServiceToggles toggles) { var noInputCount = Math.Max(0, ReadInt(turn, NoInputCountMetadataKey)) + 1; - if (noInputCount >= MaxNoInputCount) - { - return BuildDeclinedDecision(toggles); - } + if (noInputCount >= MaxNoInputCount) return BuildDeclinedDecision(toggles); return new JiboInteractionDecision( "personal_report_no_input", "I am still here. Do you want your personal report?", ContextUpdates: BuildContextUpdates( state, - noMatchCount: ReadInt(turn, NoMatchCountMetadataKey), + ReadInt(turn, NoMatchCountMetadataKey), noInputCount, toggles, - userName: ReadString(turn, UserNameMetadataKey), - userVerified: ReadBool(turn, UserVerifiedMetadataKey) ?? false, - lastServiceError: string.Empty)); + ReadString(turn, UserNameMetadataKey), + ReadBool(turn, UserVerifiedMetadataKey) ?? false, + string.Empty)); } private static JiboInteractionDecision BuildNoMatchDecision( @@ -391,10 +362,7 @@ internal static class PersonalReportOrchestrator bool userVerified) { var noMatchCount = Math.Max(0, ReadInt(turn, NoMatchCountMetadataKey)) + 1; - if (noMatchCount >= MaxNoMatchCount) - { - return BuildDeclinedDecision(toggles); - } + if (noMatchCount >= MaxNoMatchCount) return BuildDeclinedDecision(toggles); return new JiboInteractionDecision( "personal_report_no_match", @@ -402,11 +370,11 @@ internal static class PersonalReportOrchestrator ContextUpdates: BuildContextUpdates( state, noMatchCount, - noInputCount: 0, + 0, toggles, userName, userVerified, - lastServiceError: string.Empty)); + string.Empty)); } private static JiboInteractionDecision BuildDeclinedDecision(PersonalReportServiceToggles toggles) @@ -416,12 +384,12 @@ internal static class PersonalReportOrchestrator "No problem. We can do your personal report another time.", ContextUpdates: BuildContextUpdates( IdleState, - noMatchCount: 0, - noInputCount: 0, + 0, + 0, toggles, - userName: null, - userVerified: false, - lastServiceError: string.Empty)); + null, + false, + string.Empty)); } private static JiboInteractionDecision BuildCancelledDecision(PersonalReportServiceToggles toggles) @@ -431,12 +399,12 @@ internal static class PersonalReportOrchestrator "Okay, canceling personal report.", ContextUpdates: BuildContextUpdates( IdleState, - noMatchCount: 0, - noInputCount: 0, + 0, + 0, toggles, - userName: null, - userVerified: false, - lastServiceError: string.Empty)); + null, + false, + string.Empty)); } private static IDictionary BuildContextUpdates( @@ -476,24 +444,17 @@ internal static class PersonalReportOrchestrator private static bool ContainsAnyPhrase(string loweredTranscript, IEnumerable phrases) { foreach (var phrase in phrases) - { if (string.Equals(loweredTranscript, phrase, StringComparison.Ordinal) || loweredTranscript.StartsWith($"{phrase} ", StringComparison.Ordinal) || loweredTranscript.Contains($" {phrase}", StringComparison.Ordinal)) - { return true; - } - } return false; } private static bool IsWeatherErrorReply(string replyText) { - if (string.IsNullOrWhiteSpace(replyText)) - { - return false; - } + if (string.IsNullOrWhiteSpace(replyText)) return false; return replyText.Contains("couldn't fetch the weather", StringComparison.OrdinalIgnoreCase) || replyText.Contains("weather service is connected", StringComparison.OrdinalIgnoreCase); @@ -516,36 +477,32 @@ internal static class PersonalReportOrchestrator summary = string.Empty; var updated = toggles; - updated = ApplyToggleHint(updated, loweredTranscript, "weather", static value => value with { WeatherEnabled = false }, static value => value with { WeatherEnabled = true }); - updated = ApplyToggleHint(updated, loweredTranscript, "calendar", static value => value with { CalendarEnabled = false }, static value => value with { CalendarEnabled = true }); - updated = ApplyToggleHint(updated, loweredTranscript, "commute", static value => value with { CommuteEnabled = false }, static value => value with { CommuteEnabled = true }); - updated = ApplyToggleHint(updated, loweredTranscript, "news", static value => value with { NewsEnabled = false }, static value => value with { NewsEnabled = true }); + updated = ApplyToggleHint(updated, loweredTranscript, "weather", + static value => value with { WeatherEnabled = false }, + static value => value with { WeatherEnabled = true }); + updated = ApplyToggleHint(updated, loweredTranscript, "calendar", + static value => value with { CalendarEnabled = false }, + static value => value with { CalendarEnabled = true }); + updated = ApplyToggleHint(updated, loweredTranscript, "commute", + static value => value with { CommuteEnabled = false }, + static value => value with { CommuteEnabled = true }); + updated = ApplyToggleHint(updated, loweredTranscript, "news", + static value => value with { NewsEnabled = false }, static value => value with { NewsEnabled = true }); var changes = new List(); if (updated.WeatherEnabled != toggles.WeatherEnabled) - { changes.Add(updated.WeatherEnabled ? "including weather" : "skipping weather"); - } if (updated.CalendarEnabled != toggles.CalendarEnabled) - { changes.Add(updated.CalendarEnabled ? "including calendar" : "skipping calendar"); - } if (updated.CommuteEnabled != toggles.CommuteEnabled) - { changes.Add(updated.CommuteEnabled ? "including commute" : "skipping commute"); - } if (updated.NewsEnabled != toggles.NewsEnabled) - { changes.Add(updated.NewsEnabled ? "including news" : "skipping news"); - } - if (changes.Count > 0) - { - summary = $"Got it, {string.Join(", ", changes)}."; - } + if (changes.Count > 0) summary = $"Got it, {string.Join(", ", changes)}."; return updated; } @@ -560,15 +517,11 @@ internal static class PersonalReportOrchestrator if (loweredTranscript.Contains($"without {serviceLabel}", StringComparison.Ordinal) || loweredTranscript.Contains($"skip {serviceLabel}", StringComparison.Ordinal) || loweredTranscript.Contains($"no {serviceLabel}", StringComparison.Ordinal)) - { return disable(toggles); - } if (loweredTranscript.Contains($"with {serviceLabel}", StringComparison.Ordinal) || loweredTranscript.Contains($"include {serviceLabel}", StringComparison.Ordinal)) - { return enable(toggles); - } return toggles; } @@ -580,10 +533,7 @@ internal static class PersonalReportOrchestrator private static string? ReadString(TurnContext turn, string key) { - if (!turn.Attributes.TryGetValue(key, out var value) || value is null) - { - return null; - } + if (!turn.Attributes.TryGetValue(key, out var value) || value is null) return null; return value switch { @@ -594,10 +544,7 @@ internal static class PersonalReportOrchestrator private static bool? ReadBool(TurnContext turn, string key) { - if (!turn.Attributes.TryGetValue(key, out var value) || value is null) - { - return null; - } + if (!turn.Attributes.TryGetValue(key, out var value) || value is null) return null; return value switch { @@ -605,17 +552,15 @@ internal static class PersonalReportOrchestrator string text when bool.TryParse(text, out var parsed) => parsed, JsonElement { ValueKind: JsonValueKind.True } => true, JsonElement { ValueKind: JsonValueKind.False } => false, - JsonElement json when json.ValueKind == JsonValueKind.String && bool.TryParse(json.GetString(), out var parsed) => parsed, + JsonElement json when json.ValueKind == JsonValueKind.String && + bool.TryParse(json.GetString(), out var parsed) => parsed, _ => null }; } private static int ReadInt(TurnContext turn, string key) { - if (!turn.Attributes.TryGetValue(key, out var value) || value is null) - { - return 0; - } + if (!turn.Attributes.TryGetValue(key, out var value) || value is null) return 0; return value switch { @@ -623,7 +568,8 @@ internal static class PersonalReportOrchestrator long whole when whole <= int.MaxValue && whole >= int.MinValue => (int)whole, string text when int.TryParse(text, out var parsed) => parsed, JsonElement { ValueKind: JsonValueKind.Number } number when number.TryGetInt32(out var parsed) => parsed, - JsonElement json when json.ValueKind == JsonValueKind.String && int.TryParse(json.GetString(), out var parsed) => parsed, + JsonElement json when json.ValueKind == JsonValueKind.String && + int.TryParse(json.GetString(), out var parsed) => parsed, _ => 0 }; } @@ -633,10 +579,7 @@ internal static class PersonalReportOrchestrator var normalized = NameNoiseRegex.Replace(loweredTranscript, " ") .Replace(" ", " ", StringComparison.Ordinal) .Trim(); - if (string.IsNullOrWhiteSpace(normalized)) - { - return null; - } + if (string.IsNullOrWhiteSpace(normalized)) return null; var prefixes = new[] { @@ -650,10 +593,7 @@ internal static class PersonalReportOrchestrator foreach (var prefix in prefixes) { - if (!normalized.StartsWith(prefix, StringComparison.Ordinal)) - { - continue; - } + if (!normalized.StartsWith(prefix, StringComparison.Ordinal)) continue; var candidate = normalized[prefix.Length..].Trim(); return NormalizeNameCandidate(candidate); @@ -664,58 +604,31 @@ internal static class PersonalReportOrchestrator private static string? NormalizeNameCandidate(string candidate) { - if (string.IsNullOrWhiteSpace(candidate)) - { - return null; - } + if (string.IsNullOrWhiteSpace(candidate)) return null; var cleaned = NameNoiseRegex.Replace(candidate, " ") .Replace(" ", " ", StringComparison.Ordinal) .Trim(); - if (string.IsNullOrWhiteSpace(cleaned)) - { - return null; - } + if (string.IsNullOrWhiteSpace(cleaned)) return null; - if (cleaned.Length < 2 || cleaned.Length > 32) - { - return null; - } + if (cleaned.Length < 2 || cleaned.Length > 32) return null; var words = cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (words.Length > 4) - { - return null; - } + if (words.Length > 4) return null; - if (words.Any(static word => word.Any(char.IsDigit))) - { - return null; - } - - return cleaned; + return words.Any(static word => word.Any(char.IsDigit)) ? null : cleaned; } - private static readonly Regex NameNoiseRegex = new("[^a-zA-Z\\-\\s']", RegexOptions.Compiled); - - private readonly record struct PersonalReportServiceToggles( - bool WeatherEnabled, - bool CalendarEnabled, - bool CommuteEnabled, - bool NewsEnabled); - private static string ChoosePersonalReportTemplate( IReadOnlyList templates, string fallback) { var usableTemplates = templates - .Where(static template => !string.IsNullOrWhiteSpace(template) && !template.Contains("${dt.", StringComparison.OrdinalIgnoreCase)) + .Where(static template => !string.IsNullOrWhiteSpace(template) && + !template.Contains("${dt.", StringComparison.OrdinalIgnoreCase)) .ToArray(); - if (usableTemplates.Length == 0) - { - return fallback; - } + if (usableTemplates.Length == 0) return fallback; var speakerAwareTemplate = usableTemplates.FirstOrDefault(static template => template.Contains("${speaker}", StringComparison.OrdinalIgnoreCase)); @@ -737,18 +650,10 @@ internal static class PersonalReportOrchestrator string fallback) { var primary = primaryTemplates.FirstOrDefault(static template => !string.IsNullOrWhiteSpace(template)); - if (!string.IsNullOrWhiteSpace(primary)) - { - return primary!; - } + if (!string.IsNullOrWhiteSpace(primary)) return primary!; var secondary = secondaryTemplates.FirstOrDefault(static template => !string.IsNullOrWhiteSpace(template)); - if (!string.IsNullOrWhiteSpace(secondary)) - { - return secondary!; - } - - return fallback; + return !string.IsNullOrWhiteSpace(secondary) ? secondary! : fallback; } private static string RenderReportSkillTemplate(string template, string userName) @@ -759,4 +664,10 @@ internal static class PersonalReportOrchestrator .Replace(" ", " ", StringComparison.Ordinal) .Trim(); } -} + + private readonly record struct PersonalReportServiceToggles( + bool WeatherEnabled, + bool CalendarEnabled, + bool CommuteEnabled, + bool NewsEnabled); +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ProtocolToTurnContextMapper.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ProtocolToTurnContextMapper.cs index ebd6ef8..77312a7 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ProtocolToTurnContextMapper.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ProtocolToTurnContextMapper.cs @@ -6,7 +6,8 @@ namespace Jibo.Cloud.Application.Services; public sealed class ProtocolToTurnContextMapper { - public static TurnContext MapListenMessage(WebSocketMessageEnvelope envelope, CloudSession session, string messageType) + public static TurnContext MapListenMessage(WebSocketMessageEnvelope envelope, CloudSession session, + string messageType) { var turnState = session.TurnState; var protocolOperation = messageType.ToLowerInvariant(); @@ -16,46 +17,28 @@ public sealed class ProtocolToTurnContextMapper }; var text = ExtractTranscript(envelope.Text, attributes); - if (!string.IsNullOrWhiteSpace(turnState.TransId)) - { - attributes["transID"] = turnState.TransId; - } + if (!string.IsNullOrWhiteSpace(turnState.TransId)) attributes["transID"] = turnState.TransId; - if (!string.IsNullOrWhiteSpace(session.AccountId)) - { - attributes["accountId"] = session.AccountId; - } + if (!string.IsNullOrWhiteSpace(session.AccountId)) attributes["accountId"] = session.AccountId; - if (!string.IsNullOrWhiteSpace(session.DeviceId)) - { - attributes["deviceId"] = session.DeviceId; - } + if (!string.IsNullOrWhiteSpace(session.DeviceId)) attributes["deviceId"] = session.DeviceId; if (session.Metadata.TryGetValue("loopId", out var loopId) && loopId is string loopIdText && !string.IsNullOrWhiteSpace(loopIdText)) - { attributes["loopId"] = loopIdText; - } - if (!string.IsNullOrWhiteSpace(turnState.ContextPayload)) - { - attributes["context"] = turnState.ContextPayload; - } + if (!string.IsNullOrWhiteSpace(turnState.ContextPayload)) attributes["context"] = turnState.ContextPayload; if (session.Metadata.TryGetValue("lastClockDomain", out var lastClockDomain) && lastClockDomain is string lastClockDomainText && !string.IsNullOrWhiteSpace(lastClockDomainText)) - { attributes["lastClockDomain"] = lastClockDomainText; - } if (session.Metadata.TryGetValue("pendingProactivityOffer", out var pendingProactivityOffer) && pendingProactivityOffer is string pendingProactivityOfferText && !string.IsNullOrWhiteSpace(pendingProactivityOfferText)) - { attributes["pendingProactivityOffer"] = pendingProactivityOfferText; - } foreach (var pair in session.Metadata) { @@ -63,41 +46,29 @@ public sealed class ProtocolToTurnContextMapper !pair.Key.StartsWith("chitchat", StringComparison.OrdinalIgnoreCase) && !pair.Key.StartsWith("greetings", StringComparison.OrdinalIgnoreCase)) || pair.Value is null) - { continue; - } attributes[pair.Key] = pair.Value; } attributes["listenHotphrase"] = turnState.ListenHotphrase; - if (turnState.ListenRules.Count > 0) - { - attributes["listenRules"] = turnState.ListenRules; - } + if (turnState.ListenRules.Count > 0) attributes["listenRules"] = turnState.ListenRules; - if (turnState.ListenAsrHints.Count > 0) - { - attributes["listenAsrHints"] = turnState.ListenAsrHints; - } + if (turnState.ListenAsrHints.Count > 0) attributes["listenAsrHints"] = turnState.ListenAsrHints; if (turnState.BufferedAudioBytes > 0) { attributes["bufferedAudioBytes"] = turnState.BufferedAudioBytes; attributes["bufferedAudioChunks"] = turnState.BufferedAudioChunkCount; - attributes["bufferedAudioFrames"] = turnState.BufferedAudioFrames.Select(frame => frame.ToArray()).ToArray(); + attributes["bufferedAudioFrames"] = + turnState.BufferedAudioFrames.Select(frame => frame.ToArray()).ToArray(); } if (!string.IsNullOrWhiteSpace(turnState.AudioTranscriptHint)) - { attributes["audioTranscriptHint"] = turnState.AudioTranscriptHint; - } - if (turnState.FinalizeAttemptCount > 0) - { - attributes["finalizeAttemptCount"] = turnState.FinalizeAttemptCount; - } + if (turnState.FinalizeAttemptCount > 0) attributes["finalizeAttemptCount"] = turnState.FinalizeAttemptCount; return new TurnContext { @@ -111,8 +82,12 @@ public sealed class ProtocolToTurnContextMapper RequestId = envelope.ConnectionId, ProtocolService = "neo-hub", ProtocolOperation = protocolOperation, - FirmwareVersion = session.Metadata.TryGetValue("firmwareVersion", out var firmwareVersion) ? firmwareVersion as string : null, - ApplicationVersion = session.Metadata.TryGetValue("applicationVersion", out var applicationVersion) ? applicationVersion as string : null, + FirmwareVersion = session.Metadata.TryGetValue("firmwareVersion", out var firmwareVersion) + ? firmwareVersion as string + : null, + ApplicationVersion = session.Metadata.TryGetValue("applicationVersion", out var applicationVersion) + ? applicationVersion as string + : null, IsFollowUpEligible = true, Attributes = attributes }; @@ -120,10 +95,7 @@ public sealed class ProtocolToTurnContextMapper private static string? ExtractTranscript(string? text, IDictionary attributes) { - if (string.IsNullOrWhiteSpace(text)) - { - return null; - } + if (string.IsNullOrWhiteSpace(text)) return null; try { @@ -133,57 +105,41 @@ public sealed class ProtocolToTurnContextMapper if (!root.TryGetProperty("data", out var data)) return null; if (data.TryGetProperty("text", out var transcript) && transcript.ValueKind == JsonValueKind.String) - { return transcript.GetString(); - } if (data.TryGetProperty("asr", out var asr) && asr.ValueKind == JsonValueKind.Object && asr.TryGetProperty("text", out var asrText) && asrText.ValueKind == JsonValueKind.String) - { return asrText.GetString(); - } - if (data.TryGetProperty("transcriptHint", out var transcriptHint) && transcriptHint.ValueKind == JsonValueKind.String) - { - return transcriptHint.GetString(); - } + if (data.TryGetProperty("transcriptHint", out var transcriptHint) && + transcriptHint.ValueKind == JsonValueKind.String) return transcriptHint.GetString(); if (data.TryGetProperty("intent", out var intent) && intent.ValueKind == JsonValueKind.String) - { attributes["clientIntent"] = intent.GetString(); - } if (data.TryGetProperty("triggerSource", out var triggerSource) && triggerSource.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(triggerSource.GetString())) - { attributes["triggerSource"] = triggerSource.GetString(); - } if (data.TryGetProperty("triggerData", out var triggerData) && triggerData.ValueKind == JsonValueKind.Object && triggerData.TryGetProperty("looperID", out var triggerLooperId) && triggerLooperId.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(triggerLooperId.GetString())) - { attributes["triggerLooperId"] = triggerLooperId.GetString(); - } if (data.TryGetProperty("rules", out var rules) && rules.ValueKind == JsonValueKind.Array) - { attributes["clientRules"] = rules.EnumerateArray() .Where(item => item.ValueKind == JsonValueKind.String) .Select(item => item.GetString() ?? string.Empty) .Where(rule => !string.IsNullOrWhiteSpace(rule)) .ToArray(); - } if (data.TryGetProperty("entities", out var entities) && entities.ValueKind == JsonValueKind.Object) - { attributes["clientEntities"] = entities.Clone(); - } return intent.ValueKind == JsonValueKind.String ? intent.GetString() : null; } @@ -192,4 +148,4 @@ public sealed class ProtocolToTurnContextMapper return text; } } -} +} \ No newline at end of file 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 aac757e..83be931 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 @@ -32,7 +32,8 @@ public sealed class ResponsePlanToSocketMessagesMapper var isVolumeControl = string.Equals(plan.IntentName, "volume_up", StringComparison.OrdinalIgnoreCase) || string.Equals(plan.IntentName, "volume_down", StringComparison.OrdinalIgnoreCase) || string.Equals(plan.IntentName, "volume_to_value", StringComparison.OrdinalIgnoreCase); - var isProactivePizzaFactOffer = string.Equals(plan.IntentName, "proactive_offer_pizza_fact", StringComparison.OrdinalIgnoreCase); + var isProactivePizzaFactOffer = string.Equals(plan.IntentName, "proactive_offer_pizza_fact", + StringComparison.OrdinalIgnoreCase); var isSettingsLaunch = string.Equals(skill?.SkillName, "@be/settings", StringComparison.OrdinalIgnoreCase); var isGlobalCommand = isStopCommand || isVolumeControl; var isPhotoGalleryLaunch = string.Equals(plan.IntentName, "photo_gallery", StringComparison.OrdinalIgnoreCase); @@ -71,12 +72,13 @@ public sealed class ResponsePlanToSocketMessagesMapper ? clockIntent : isReportSkillLaunch && !string.IsNullOrWhiteSpace(localIntent) ? localIntent - : isWordOfDayGuess - ? "guess" - : string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase) && - !string.IsNullOrWhiteSpace(clientIntent) - ? clientIntent - : plan.IntentName ?? "unknown"; + : isWordOfDayGuess + ? "guess" + : string.Equals(messageType, "CLIENT_NLU", + StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(clientIntent) + ? clientIntent + : plan.IntentName ?? "unknown"; var outboundAsrText = isWordOfDayGuess && !string.IsNullOrWhiteSpace(wordOfDayGuess) ? wordOfDayGuess : isWordOfDayLaunch @@ -104,30 +106,30 @@ public sealed class ResponsePlanToSocketMessagesMapper var outboundRules = isProactivePizzaFactOffer ? ["shared/yes_no"] : isWordOfDayLaunch - ? ["word-of-the-day/menu"] - : isGlobalCommand - ? BuildGlobalCommandRules(rules) - : isRadioLaunch - ? [] - : isSettingsLaunch - ? string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase) - ? rules - : [] - : isPhotoGalleryLaunch || isPhotoCreateLaunch + ? ["word-of-the-day/menu"] + : isGlobalCommand + ? BuildGlobalCommandRules(rules) + : isRadioLaunch + ? [] + : isSettingsLaunch ? string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase) ? rules : [] - : isClockSkillLaunch + : isPhotoGalleryLaunch || isPhotoCreateLaunch ? string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase) ? rules : [] - : isReportSkillLaunch - ? [] - : isWordOfDayGuess - ? ["word-of-the-day/puzzle"] - : isYesNoTurn && isYesNoIntent - ? [yesNoRule!] - : rules; + : isClockSkillLaunch + ? string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase) + ? rules + : [] + : isReportSkillLaunch + ? [] + : isWordOfDayGuess + ? ["word-of-the-day/puzzle"] + : isYesNoTurn && isYesNoIntent + ? [yesNoRule!] + : rules; var entities = ReadEntities( turn, messageType, @@ -210,10 +212,10 @@ public sealed class ResponsePlanToSocketMessagesMapper outboundAsrText, outboundRules, entities)), - DelayMs: 75)); + 75)); messages.Add(new SocketReplyPlan( JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/word-of-the-day")), - DelayMs: 125)); + 125)); } if (isRadioLaunch) @@ -226,10 +228,10 @@ public sealed class ResponsePlanToSocketMessagesMapper outboundAsrText, outboundRules, entities)), - DelayMs: 75)); + 75)); messages.Add(new SocketReplyPlan( JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/radio")), - DelayMs: 125)); + 125)); } if (isStopCommand) @@ -242,10 +244,10 @@ public sealed class ResponsePlanToSocketMessagesMapper outboundAsrText, outboundRules, entities)), - DelayMs: 75)); + 75)); messages.Add(new SocketReplyPlan( JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/idle")), - DelayMs: 125)); + 125)); } if (isSettingsLaunch && @@ -259,10 +261,10 @@ public sealed class ResponsePlanToSocketMessagesMapper outboundAsrText, outboundRules, entities)), - DelayMs: 75)); + 75)); messages.Add(new SocketReplyPlan( JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/settings")), - DelayMs: 125)); + 125)); } if (isClockSkillLaunch && @@ -277,10 +279,10 @@ public sealed class ResponsePlanToSocketMessagesMapper outboundAsrText, outboundRules, entities)), - DelayMs: 75)); + 75)); messages.Add(new SocketReplyPlan( JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/clock")), - DelayMs: 125)); + 125)); } if ((isPhotoGalleryLaunch || isPhotoCreateLaunch) && @@ -295,18 +297,16 @@ public sealed class ResponsePlanToSocketMessagesMapper outboundAsrText, outboundRules, entities)), - DelayMs: 75)); + 75)); messages.Add(new SocketReplyPlan( JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, skillId)), - DelayMs: 125)); + 125)); } if (emitSkillActions && speak is not null) - { messages.Add(new SocketReplyPlan( JsonSerializer.Serialize(BuildSkillPayload(plan, turn, transId, speak, skill)), - DelayMs: 75)); - } + 75)); return messages; } @@ -352,7 +352,7 @@ public sealed class ResponsePlanToSocketMessagesMapper transID = transId, data = new { } })), - new SocketReplyPlan(JsonSerializer.Serialize(BuildGenericFallbackSkillPayload(transId)), DelayMs: 75) + new SocketReplyPlan(JsonSerializer.Serialize(BuildGenericFallbackSkillPayload(transId)), 75) ]; } @@ -427,10 +427,7 @@ public sealed class ResponsePlanToSocketMessagesMapper ? "clientRules" : "listenRules"; - if (!turn.Attributes.TryGetValue(attributeName, out var value)) - { - return []; - } + if (!turn.Attributes.TryGetValue(attributeName, out var value)) return []; return value switch { @@ -466,10 +463,7 @@ public sealed class ResponsePlanToSocketMessagesMapper { if (yesNoTurn) { - if (!includeCreateDomain) - { - return new Dictionary(); - } + if (!includeCreateDomain) return new Dictionary(); return new Dictionary { @@ -478,20 +472,15 @@ public sealed class ResponsePlanToSocketMessagesMapper } if (wordOfDayLaunch) - { return new Dictionary { ["domain"] = "word-of-the-day" }; - } if (globalCommand) { var entities = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (!string.IsNullOrWhiteSpace(volumeLevel)) - { - entities["volumeLevel"] = volumeLevel; - } + if (!string.IsNullOrWhiteSpace(volumeLevel)) entities["volumeLevel"] = volumeLevel; return entities; } @@ -499,10 +488,7 @@ public sealed class ResponsePlanToSocketMessagesMapper if (radioLaunch) { var entities = new Dictionary(); - if (!string.IsNullOrWhiteSpace(radioStation)) - { - entities["station"] = radioStation; - } + if (!string.IsNullOrWhiteSpace(radioStation)) entities["station"] = radioStation; return entities; } @@ -510,10 +496,7 @@ public sealed class ResponsePlanToSocketMessagesMapper if (clockSkillLaunch) { var entities = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (!string.IsNullOrWhiteSpace(clockDomain)) - { - entities["domain"] = clockDomain; - } + if (!string.IsNullOrWhiteSpace(clockDomain)) entities["domain"] = clockDomain; if (string.Equals(clockDomain, "timer", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(timerHours + timerMinutes + timerSeconds)) @@ -535,32 +518,22 @@ public sealed class ResponsePlanToSocketMessagesMapper if (reportSkillLaunch) { var entities = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (!string.IsNullOrWhiteSpace(reportDate)) - { - entities["date"] = reportDate; - } + if (!string.IsNullOrWhiteSpace(reportDate)) entities["date"] = reportDate; - if (!string.IsNullOrWhiteSpace(reportWeatherCondition)) - { - entities["Weather"] = reportWeatherCondition; - } + if (!string.IsNullOrWhiteSpace(reportWeatherCondition)) entities["Weather"] = reportWeatherCondition; return entities; } if (wordOfDayGuess) - { return new Dictionary { ["guess"] = guess ?? string.Empty }; - } if (!string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase) || !turn.Attributes.TryGetValue("clientEntities", out var value) || value is null) - { return new Dictionary(); - } return value switch { @@ -596,10 +569,7 @@ public sealed class ResponsePlanToSocketMessagesMapper private static IEnumerable ReadRuleValues(TurnContext turn, string key) { - if (!turn.Attributes.TryGetValue(key, out var value) || value is null) - { - return []; - } + if (!turn.Attributes.TryGetValue(key, out var value) || value is null) return []; return value switch { @@ -621,10 +591,7 @@ public sealed class ResponsePlanToSocketMessagesMapper private static string? ReadClientEntity(TurnContext turn, string entityName) { - if (!turn.Attributes.TryGetValue("clientEntities", out var value) || value is null) - { - return null; - } + if (!turn.Attributes.TryGetValue("clientEntities", out var value) || value is null) return null; return value switch { @@ -642,20 +609,14 @@ public sealed class ResponsePlanToSocketMessagesMapper private static string? ReadSkillPayloadString(InvokeNativeSkillAction? skill, string key) { - if (skill?.Payload is null || !skill.Payload.TryGetValue(key, out var value)) - { - return null; - } + if (skill?.Payload is null || !skill.Payload.TryGetValue(key, out var value)) return null; return value?.ToString(); } private static string ResolveWordOfDayGuess(TurnContext turn, string transcript, string? nluGuess) { - if (!string.IsNullOrWhiteSpace(nluGuess)) - { - return nluGuess; - } + if (!string.IsNullOrWhiteSpace(nluGuess)) return nluGuess; var normalized = NormalizeGuessToken(transcript); var hintIndex = normalized switch @@ -669,11 +630,9 @@ public sealed class ResponsePlanToSocketMessagesMapper var hints = ReadRuleValues(turn, "listenAsrHints").ToArray(); if (hintIndex >= 0) - { return hintIndex < hints.Length ? hints[hintIndex] : transcript; - } var fuzzyHintMatch = FindClosestHint(normalized, hints); return string.IsNullOrWhiteSpace(fuzzyHintMatch) @@ -683,31 +642,19 @@ public sealed class ResponsePlanToSocketMessagesMapper private static string? FindClosestHint(string normalizedTranscript, IReadOnlyList hints) { - if (string.IsNullOrWhiteSpace(normalizedTranscript)) - { - return null; - } + if (string.IsNullOrWhiteSpace(normalizedTranscript)) return null; string? bestHint = null; var bestDistance = int.MaxValue; foreach (var hint in hints) { - if (string.IsNullOrWhiteSpace(hint)) - { - continue; - } + if (string.IsNullOrWhiteSpace(hint)) continue; var normalizedHint = NormalizeGuessToken(hint); - if (string.IsNullOrWhiteSpace(normalizedHint)) - { - continue; - } + if (string.IsNullOrWhiteSpace(normalizedHint)) continue; - if (string.Equals(normalizedTranscript, normalizedHint, StringComparison.Ordinal)) - { - return hint; - } + if (string.Equals(normalizedTranscript, normalizedHint, StringComparison.Ordinal)) return hint; var distance = ComputeEditDistance(normalizedTranscript, normalizedHint); if (distance >= bestDistance) continue; @@ -729,10 +676,7 @@ public sealed class ResponsePlanToSocketMessagesMapper var previous = new int[right.Length + 1]; var current = new int[right.Length + 1]; - for (var column = 0; column <= right.Length; column += 1) - { - previous[column] = column; - } + for (var column = 0; column <= right.Length; column += 1) previous[column] = column; for (var row = 1; row <= left.Length; row += 1) { @@ -757,11 +701,9 @@ public sealed class ResponsePlanToSocketMessagesMapper var skillPayload = skill?.Payload; if (string.Equals(ReadPayloadString(skillPayload, "cloudResponseMode"), "completion_only", StringComparison.OrdinalIgnoreCase)) - { return BuildCompletionOnlySkillPayload( transId, ReadPayloadString(skillPayload, "skillId") ?? skill?.SkillName ?? "chitchat-skill"); - } var isJoke = string.Equals(plan.IntentName, "joke", StringComparison.OrdinalIgnoreCase) || string.Equals(skill?.SkillName, "@be/joke", StringComparison.OrdinalIgnoreCase); @@ -797,21 +739,16 @@ public sealed class ResponsePlanToSocketMessagesMapper }; if (listenContexts.Count > 0) - { jcpConfig["listen"] = new { id = CreateProtocolId(), type = "LISTEN", contexts = listenContexts }; - } - object? weatherHiLoView = BuildWeatherHiLoView(skillPayload); + var weatherHiLoView = BuildWeatherHiLoView(skillPayload); var weeklyWeatherCards = BuildWeatherHiLoSequenceCards(skillPayload); - if (weatherHiLoView is null && weeklyWeatherCards.Count > 0) - { - weatherHiLoView = weeklyWeatherCards[0].View; - } + if (weatherHiLoView is null && weeklyWeatherCards.Count > 0) weatherHiLoView = weeklyWeatherCards[0].View; var useWeatherSequence = false; if (weatherHiLoView is not null) @@ -927,15 +864,9 @@ public sealed class ResponsePlanToSocketMessagesMapper ["entities"] = entities }; - if (!string.IsNullOrWhiteSpace(skillId)) - { - payload["skill"] = skillId; - } + if (!string.IsNullOrWhiteSpace(skillId)) payload["skill"] = skillId; - if (!string.IsNullOrWhiteSpace(domain)) - { - payload["domain"] = domain; - } + if (!string.IsNullOrWhiteSpace(domain)) payload["domain"] = domain; return payload; } @@ -1098,55 +1029,54 @@ public sealed class ResponsePlanToSocketMessagesMapper private static string? ReadPayloadString(IDictionary? payload, string key) { - if (payload is null || !payload.TryGetValue(key, out var value)) - { - return null; - } + if (payload is null || !payload.TryGetValue(key, out var value)) return null; return value?.ToString(); } private static IReadOnlyList ReadPayloadStringArray(IDictionary? payload, string key) { - if (payload is null || !payload.TryGetValue(key, out var value) || value is null) - { - return []; - } + if (payload is null || !payload.TryGetValue(key, out var value) || value is null) return []; return value switch { - string text => [.. text - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Where(static context => !string.IsNullOrWhiteSpace(context))], + string text => + [ + .. text + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(static context => !string.IsNullOrWhiteSpace(context)) + ], string[] contexts => [.. contexts.Where(static context => !string.IsNullOrWhiteSpace(context))], IEnumerable contexts => [.. contexts.Where(static context => !string.IsNullOrWhiteSpace(context))], - JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.Array => [.. jsonElement - .EnumerateArray() - .Select(static item => item.GetString()) - .Where(static context => !string.IsNullOrWhiteSpace(context)) - .Select(static context => context!)], - IEnumerable contexts => [.. contexts - .Select(static context => context?.ToString()) - .Where(static context => !string.IsNullOrWhiteSpace(context)) - .Select(static context => context!)], + JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.Array => + [ + .. jsonElement + .EnumerateArray() + .Select(static item => item.GetString()) + .Where(static context => !string.IsNullOrWhiteSpace(context)) + .Select(static context => context!) + ], + IEnumerable contexts => + [ + .. contexts + .Select(static context => context?.ToString()) + .Where(static context => !string.IsNullOrWhiteSpace(context)) + .Select(static context => context!) + ], _ => string.IsNullOrWhiteSpace(value.ToString()) ? [] : [value.ToString()!] }; } - private static IReadOnlyList BuildWeatherHiLoSequenceCards(IDictionary? payload) + private static IReadOnlyList BuildWeatherHiLoSequenceCards( + IDictionary? payload) { if (payload is null || !payload.TryGetValue("weather_weekly_cards", out var rawCards) || rawCards is null) - { return []; - } var cards = ReadPayloadObjectArray(rawCards); - if (cards.Count == 0) - { - return []; - } + if (cards.Count == 0) return []; var sequenceCards = new List(cards.Count); foreach (var card in cards) @@ -1157,10 +1087,7 @@ public sealed class ResponsePlanToSocketMessagesMapper ["weather_view_kind"] = "weatherHiLo" }; var view = BuildWeatherHiLoView(weatherCardPayload); - if (view is null) - { - continue; - } + if (view is null) continue; sequenceCards.Add(new WeatherHiLoSequenceCard( view, @@ -1247,38 +1174,29 @@ public sealed class ResponsePlanToSocketMessagesMapper private static IReadOnlyList> ReadPayloadObjectArray(object rawValue) { if (rawValue is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array) - { return jsonArray .EnumerateArray() .Select(ConvertJsonObjectToDictionary) .Where(static item => item is not null) .Cast>() .ToArray(); - } if (rawValue is IEnumerable rawObjects) - { return rawObjects .Select(ConvertObjectToDictionary) .Where(static item => item is not null) .Cast>() .ToArray(); - } return []; } private static IDictionary? ConvertObjectToDictionary(object? value) { - if (value is null) - { - return null; - } + if (value is null) return null; if (value is IDictionary dictionary) - { return new Dictionary(dictionary, StringComparer.OrdinalIgnoreCase); - } return value is JsonElement jsonValue ? ConvertJsonObjectToDictionary(jsonValue) @@ -1287,14 +1205,10 @@ public sealed class ResponsePlanToSocketMessagesMapper private static IDictionary? ConvertJsonObjectToDictionary(JsonElement value) { - if (value.ValueKind != JsonValueKind.Object) - { - return null; - } + if (value.ValueKind != JsonValueKind.Object) return null; var dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var property in value.EnumerateObject()) - { dictionary[property.Name] = property.Value.ValueKind switch { JsonValueKind.String => property.Value.GetString(), @@ -1306,35 +1220,26 @@ public sealed class ResponsePlanToSocketMessagesMapper JsonValueKind.Array => property.Value, _ => null }; - } return dictionary; } private static object? BuildWeatherHiLoView(IDictionary? payload) { - if (!TryReadPayloadBool(payload, "weather_view_enabled")) - { - return null; - } + if (!TryReadPayloadBool(payload, "weather_view_enabled")) return null; if (!string.Equals( ReadPayloadString(payload, "weather_view_kind"), "weatherHiLo", StringComparison.OrdinalIgnoreCase)) - { return null; - } var icon = ReadPayloadString(payload, "weather_icon"); var unit = ReadPayloadString(payload, "weather_unit") ?? "F"; var theme = ReadPayloadString(payload, "weather_theme") ?? "Normal"; var high = TryReadPayloadInt(payload, "weather_high"); var low = TryReadPayloadInt(payload, "weather_low"); - if (string.IsNullOrWhiteSpace(icon) || high is null || low is null) - { - return null; - } + if (string.IsNullOrWhiteSpace(icon) || high is null || low is null) return null; var hiNumX = GetTemperatureLabelXPosition(370, high.Value); var hiUnitX = GetTemperatureLabelXPosition(360, high.Value); @@ -1493,24 +1398,16 @@ public sealed class ResponsePlanToSocketMessagesMapper private static int GetTemperatureLabelXPosition(int baseX, int temperature) { const int xOffset = 70; - if (temperature < -9 || temperature > 99) - { - return baseX + xOffset; - } + if (temperature < -9 || temperature > 99) return baseX + xOffset; - if (temperature is >= 0 and < 10) - { - return baseX - xOffset; - } + if (temperature is >= 0 and < 10) return baseX - xOffset; return baseX; } + private static int? TryReadPayloadInt(IDictionary? payload, string key) { - if (payload is null || !payload.TryGetValue(key, out var value) || value is null) - { - return null; - } + if (payload is null || !payload.TryGetValue(key, out var value) || value is null) return null; return value switch { @@ -1519,18 +1416,17 @@ public sealed class ResponsePlanToSocketMessagesMapper double number => (int)Math.Round(number, MidpointRounding.AwayFromZero), float number => (int)Math.Round(number, MidpointRounding.AwayFromZero), string text when int.TryParse(text, out var parsed) => parsed, - JsonElement { ValueKind: JsonValueKind.Number } jsonNumber when jsonNumber.TryGetInt32(out var parsed) => parsed, - JsonElement jsonText when jsonText.ValueKind == JsonValueKind.String && int.TryParse(jsonText.GetString(), out var parsed) => parsed, + JsonElement { ValueKind: JsonValueKind.Number } jsonNumber when jsonNumber.TryGetInt32(out var parsed) => + parsed, + JsonElement jsonText when jsonText.ValueKind == JsonValueKind.String && + int.TryParse(jsonText.GetString(), out var parsed) => parsed, _ => null }; } private static bool TryReadPayloadBool(IDictionary? payload, string key) { - if (payload is null || !payload.TryGetValue(key, out var value) || value is null) - { - return false; - } + if (payload is null || !payload.TryGetValue(key, out var value) || value is null) return false; return value switch { @@ -1538,7 +1434,8 @@ public sealed class ResponsePlanToSocketMessagesMapper string text when bool.TryParse(text, out var parsed) => parsed, JsonElement { ValueKind: JsonValueKind.True } => true, JsonElement { ValueKind: JsonValueKind.False } => false, - JsonElement jsonText when jsonText.ValueKind == JsonValueKind.String && bool.TryParse(jsonText.GetString(), out var parsed) => parsed, + JsonElement jsonText when jsonText.ValueKind == JsonValueKind.String && + bool.TryParse(jsonText.GetString(), out var parsed) => parsed, _ => false }; } @@ -1560,5 +1457,4 @@ public sealed class ResponsePlanToSocketMessagesMapper string? SpokenLine); public sealed record SocketReplyPlan(string Text, int DelayMs = 0); -} - +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/SyntheticBufferedAudioSttStrategy.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/SyntheticBufferedAudioSttStrategy.cs index 0061f7f..76a4e61 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/SyntheticBufferedAudioSttStrategy.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/SyntheticBufferedAudioSttStrategy.cs @@ -16,9 +16,7 @@ public sealed class SyntheticBufferedAudioSttStrategy : ISttStrategy { var transcriptHint = ReadTranscriptHint(turn); if (string.IsNullOrWhiteSpace(transcriptHint)) - { throw new InvalidOperationException("Synthetic buffered audio STT requires an audio transcript hint."); - } return Task.FromResult(new SttResult { @@ -36,10 +34,7 @@ public sealed class SyntheticBufferedAudioSttStrategy : ISttStrategy private static int ReadBufferedAudioBytes(TurnContext turn) { - if (!turn.Attributes.TryGetValue("bufferedAudioBytes", out var bufferedAudioBytes)) - { - return 0; - } + if (!turn.Attributes.TryGetValue("bufferedAudioBytes", out var bufferedAudioBytes)) return 0; return bufferedAudioBytes switch { @@ -56,4 +51,4 @@ public sealed class SyntheticBufferedAudioSttStrategy : ISttStrategy ? transcriptHint?.ToString() : null; } -} +} \ No newline at end of file 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 606cb1a..95ac547 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 @@ -1,8 +1,8 @@ using System.Text.Json; +using System.Text.RegularExpressions; using Jibo.Cloud.Application.Abstractions; using Jibo.Cloud.Domain.Models; using Jibo.Runtime.Abstractions; -using System.Text.RegularExpressions; namespace Jibo.Cloud.Application.Services; @@ -15,11 +15,12 @@ public sealed partial class WebSocketTurnFinalizationService( private const int AutoFinalizeMinBufferedAudioBytes = 15000; private const int AutoFinalizeMinBufferedAudioChunks = 5; private const string GlsmPhaseMetadataKey = "glsmPhase"; + private const int AutoFinalizeContinuationDeferralMaxAttempts = 2; private static readonly TimeSpan AutoFinalizeMinTurnAge = TimeSpan.FromMilliseconds(1800); private static readonly TimeSpan AutoFinalizeMissingTranscriptFallbackAge = TimeSpan.FromMilliseconds(4200); private static readonly TimeSpan AutoFinalizeContinuationDeferralMaxAge = TimeSpan.FromMilliseconds(3600); private static readonly TimeSpan StaleListenSetupRecoveryAge = TimeSpan.FromSeconds(9); - private const int AutoFinalizeContinuationDeferralMaxAttempts = 2; + private static readonly HashSet PegasusAffinityContinuationStems = new(StringComparer.Ordinal) { "i love", @@ -74,1899 +75,6 @@ public sealed partial class WebSocketTurnFinalizationService( "i detest" }; - public static void ObserveIncomingMessage(CloudSession session, string? text) - { - if (!TryReadTransId(text, out var nextTransId) || string.IsNullOrWhiteSpace(nextTransId)) - { - return; - } - - if (!string.Equals(session.TurnState.TransId, nextTransId, StringComparison.Ordinal)) - { - ResetTurnState(session.TurnState, nextTransId); - } - - session.LastTransId = nextTransId; - } - - public async Task> HandleBinaryAudioAsync( - CloudSession session, - WebSocketMessageEnvelope envelope, - CancellationToken cancellationToken = default) - { - try - { - var turnState = session.TurnState; - var ignoreLateAudio = ShouldIgnoreLateAudio(session); - var ignoreAudioWithoutListen = ShouldIgnoreAudioWithoutListen(turnState); - if (ignoreLateAudio || ignoreAudioWithoutListen) - { - await sink.RecordTurnDiagnosticAsync("binary_audio_ignored", BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary - { - ["ignored"] = true, - ["ignoreLateAudio"] = ignoreLateAudio, - ["ignoreAudioWithoutListen"] = ignoreAudioWithoutListen, - ["awaitingTurnCompletion"] = turnState.AwaitingTurnCompletion, - ["bufferedAudioBytes"] = turnState.BufferedAudioBytes, - ["bufferedAudioChunks"] = turnState.BufferedAudioChunkCount, - ["sawListen"] = turnState.SawListen, - ["sawContext"] = turnState.SawContext - }), cancellationToken); - return []; - } - - session.LastMessageType = "BINARY_AUDIO"; - turnState.FirstAudioReceivedUtc ??= DateTimeOffset.UtcNow; - turnState.BufferedAudioChunkCount += 1; - turnState.BufferedAudioBytes += envelope.Binary?.Length ?? 0; - if (envelope.Binary is { Length: > 0 }) - { - turnState.BufferedAudioFrames.Add([.. envelope.Binary]); - } - turnState.LastAudioReceivedUtc = DateTimeOffset.UtcNow; - turnState.AwaitingTurnCompletion = true; - session.Metadata["lastAudioBytes"] = envelope.Binary?.Length ?? 0; - await sink.RecordTurnDiagnosticAsync("binary_audio_received", BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary - { - ["bufferedAudioBytes"] = turnState.BufferedAudioBytes, - ["bufferedAudioChunks"] = turnState.BufferedAudioChunkCount, - ["awaitingTurnCompletion"] = turnState.AwaitingTurnCompletion, - ["sawListen"] = turnState.SawListen, - ["sawContext"] = turnState.SawContext, - ["listenRules"] = turnState.ListenRules, - ["listenAsrHints"] = turnState.ListenAsrHints, - ["yesNoRule"] = turnState.ListenRules.FirstOrDefault(IsConstrainedYesNoRule) - }), cancellationToken); - - if (ShouldAutoFinalize(session)) - { - return await FinalizeTurnAsync(session, envelope, "AUTO_FINALIZE", allowFallbackOnMissingTranscript: true, cancellationToken); - } - - return []; - } - finally - { - await TrackGlsmPhaseAsync(session, envelope, "binary_audio", cancellationToken); - } - } - - public async Task> HandleContextAsync( - CloudSession session, - WebSocketMessageEnvelope envelope, - CancellationToken cancellationToken = default) - { - try - { - var turnState = session.TurnState; - turnState.SawContext = true; - turnState.ContextPayload = ExtractDataPayload(envelope.Text); - session.Metadata["context"] = turnState.ContextPayload; - - if (TryReadContextProperty(envelope.Text, "audioTranscriptHint", out var transcriptHint) && - !string.IsNullOrWhiteSpace(transcriptHint)) - { - turnState.AudioTranscriptHint = transcriptHint; - session.Metadata["audioTranscriptHint"] = transcriptHint; - } - - if (ShouldIgnorePassiveLocalSkillContext(session, envelope.Text)) - { - turnState.AwaitingTurnCompletion = false; - turnState.IgnoreAdditionalAudioUntilUtc = DateTimeOffset.UtcNow.Add(WebSocketTurnState.DefaultLateAudioIgnoreWindow); - ResetBufferedAudio(session); - ClearListenTracking(turnState); - return []; - } - - if (ShouldAutoFinalize(session)) - { - return await FinalizeTurnAsync(session, envelope, "AUTO_FINALIZE", allowFallbackOnMissingTranscript: true, cancellationToken); - } - - return []; - } - finally - { - await TrackGlsmPhaseAsync(session, envelope, "context", cancellationToken); - } - } - - public async Task> HandleTurnAsync( - CloudSession session, - WebSocketMessageEnvelope envelope, - string messageType, - CancellationToken cancellationToken = default) - { - PersistTurnHints(session, envelope.Text); - return await FinalizeTurnAsync(session, envelope, messageType, allowFallbackOnMissingTranscript: false, cancellationToken); - } - - public static IReadOnlyList HandleListenSetup(CloudSession session, WebSocketMessageEnvelope envelope) - { - PersistTurnHints(session, envelope.Text); - - var turn = ProtocolToTurnContextMapper.MapListenMessage(envelope, session, "LISTEN"); - if (ShouldIgnoreCompletedWordOfDayTurn(turn)) - { - session.TurnState.AwaitingTurnCompletion = false; - session.TurnState.IgnoreAdditionalAudioUntilUtc = DateTimeOffset.UtcNow.Add(WebSocketTurnState.DefaultLateAudioIgnoreWindow); - session.FollowUpExpiresUtc = null; - ResetBufferedAudio(session); - ClearListenTracking(session.TurnState); - UpdateGlsmPhaseMarker(session); - return [.. ResponsePlanToSocketMessagesMapper.MapNoInputAndRedirectToSkill( - session.TurnState.TransId ?? session.LastTransId ?? string.Empty, - session.TurnState.ListenRules, - "@be/idle") - .Select(map => new WebSocketReply - { - Text = map.Text, - DelayMs = map.DelayMs - })]; - } - - session.TurnState.AwaitingTurnCompletion = true; - session.TurnState.ListenOpenedUtc ??= DateTimeOffset.UtcNow; - UpdateGlsmPhaseMarker(session); - return []; - } - - private async Task ResolveTranscriptAsync(TurnContext turn, CloudSession session, CancellationToken cancellationToken) - { - if (!string.IsNullOrWhiteSpace(turn.NormalizedTranscript) || !string.IsNullOrWhiteSpace(turn.RawTranscript) || session.TurnState.BufferedAudioBytes <= 0) - { - return turn; - } - - ISttStrategy? strategy; - try - { - strategy = await sttStrategySelector.SelectAsync(turn, cancellationToken); - } - catch (InvalidOperationException ex) when (string.Equals(ex.Message, "No STT strategy can handle the current turn.", StringComparison.Ordinal)) - { - return turn; - } - catch (Exception ex) - { - session.TurnState.LastSttError = ex.Message; - session.TurnState.LastSttErrorUtc = DateTimeOffset.UtcNow; - await sink.RecordTranscriptError(ex, "Error during STT processing", cancellationToken); - return turn; - } - - try - { - var sttResult = await strategy.TranscribeAsync(turn, cancellationToken); - session.TurnState.LastSttError = null; - session.TurnState.LastSttErrorUtc = null; - - var attributes = new Dictionary(turn.Attributes, StringComparer.OrdinalIgnoreCase) - { - ["sttProvider"] = sttResult.Provider, - ["sttConfidence"] = sttResult.Confidence - }; - - foreach (var pair in sttResult.Metadata) - { - attributes[$"stt:{pair.Key}"] = pair.Value; - } - - return new TurnContext - { - TurnId = turn.TurnId, - SessionId = turn.SessionId, - TimestampUtc = turn.TimestampUtc, - InputMode = turn.InputMode, - SourceKind = turn.SourceKind, - WakePhrase = turn.WakePhrase, - RawTranscript = sttResult.Text, - NormalizedTranscript = sttResult.Text.Trim(), - DeviceId = turn.DeviceId, - HostName = turn.HostName, - RequestId = turn.RequestId, - ProtocolService = turn.ProtocolService, - ProtocolOperation = turn.ProtocolOperation, - FirmwareVersion = turn.FirmwareVersion, - ApplicationVersion = turn.ApplicationVersion, - Locale = sttResult.Locale ?? turn.Locale, - TimeZone = turn.TimeZone, - IsFollowUpEligible = turn.IsFollowUpEligible, - Attributes = attributes - }; - } - catch (Exception ex) - { - session.TurnState.LastSttError = ex.Message; - session.TurnState.LastSttErrorUtc = DateTimeOffset.UtcNow; - await sink.RecordTranscriptError(ex, "Error during STT processing", cancellationToken); - return turn; - } - } - - private static void PersistTurnHints(CloudSession session, string? text) - { - var turnState = session.TurnState; - if (string.IsNullOrWhiteSpace(text)) - { - return; - } - - try - { - using var document = JsonDocument.Parse(text); - var root = document.RootElement; - - if (root.TryGetProperty("type", out var type) && - type.ValueKind == JsonValueKind.String && - string.Equals(type.GetString(), "LISTEN", StringComparison.OrdinalIgnoreCase)) - { - turnState.SawListen = true; - turnState.ListenOpenedUtc ??= DateTimeOffset.UtcNow; - } - - if (root.TryGetProperty("transID", out var transId) && transId.ValueKind == JsonValueKind.String) - { - var nextTransId = transId.GetString(); - if (!string.IsNullOrWhiteSpace(nextTransId) && - !string.Equals(turnState.TransId, nextTransId, StringComparison.Ordinal)) - { - ResetTurnState(turnState, nextTransId); - session.LastTransId = nextTransId; - } - } - - if (!root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object) return; - - if (data.TryGetProperty("rules", out var rules) && rules.ValueKind == JsonValueKind.Array) - { - turnState.ListenRules = [.. rules.EnumerateArray() - .Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() ?? string.Empty : item.ToString()) - .Where(rule => !string.IsNullOrWhiteSpace(rule))]; - session.Metadata["listenRules"] = turnState.ListenRules; - } - - if (data.TryGetProperty("asr", out var asr) && - asr.ValueKind == JsonValueKind.Object && - asr.TryGetProperty("hints", out var hints) && - hints.ValueKind == JsonValueKind.Array) - { - turnState.ListenAsrHints = [.. hints.EnumerateArray() - .Where(static item => item.ValueKind == JsonValueKind.String) - .Select(static item => item.GetString() ?? string.Empty) - .Where(static hint => !string.IsNullOrWhiteSpace(hint))]; - } - - if (data.TryGetProperty("hotphrase", out var hotphrase) && - hotphrase.ValueKind is JsonValueKind.True or JsonValueKind.False) - { - turnState.ListenHotphrase = hotphrase.GetBoolean(); - turnState.HotphraseEmptyTurnCount = 0; - } - - if (data.TryGetProperty("intent", out var intent) && intent.ValueKind == JsonValueKind.String) - { - session.LastIntent = intent.GetString(); - } - - if (!data.TryGetProperty("transcriptHint", out var transcriptHint) || - transcriptHint.ValueKind != JsonValueKind.String) return; - - turnState.AudioTranscriptHint = transcriptHint.GetString(); - session.Metadata["audioTranscriptHint"] = turnState.AudioTranscriptHint; - } - catch - { - // Keep the compatibility layer permissive while captures are still incomplete. - } - } - - private static void ResetBufferedAudio(CloudSession session) - { - session.TurnState.BufferedAudioBytes = 0; - session.TurnState.BufferedAudioChunkCount = 0; - session.TurnState.LastSttError = null; - session.TurnState.LastSttErrorUtc = null; - session.TurnState.FirstAudioReceivedUtc = null; - session.TurnState.LastAudioReceivedUtc = null; - session.TurnState.BufferedAudioFrames.Clear(); - session.TurnState.FinalizeAttemptCount = 0; - session.Metadata.Remove("audioTranscriptHint"); - } - - private static void ResetTurnState(WebSocketTurnState turnState, string? transId) - { - turnState.TransId = transId; - turnState.ContextPayload = null; - turnState.AudioTranscriptHint = null; - turnState.ListenOpenedUtc = null; - turnState.LastSttError = null; - turnState.LastSttErrorUtc = null; - turnState.FirstAudioReceivedUtc = null; - turnState.LastAudioReceivedUtc = null; - turnState.BufferedAudioChunkCount = 0; - turnState.BufferedAudioBytes = 0; - turnState.BufferedAudioFrames.Clear(); - turnState.FinalizeAttemptCount = 0; - turnState.AwaitingTurnCompletion = false; - turnState.SawListen = false; - turnState.SawContext = false; - turnState.ListenHotphrase = false; - turnState.HotphraseEmptyTurnCount = 0; - turnState.IgnoreAdditionalAudioUntilUtc = null; - turnState.ListenRules = []; - turnState.ListenAsrHints = []; - } - - private async Task> FinalizeTurnAsync( - CloudSession session, - WebSocketMessageEnvelope envelope, - string messageType, - bool allowFallbackOnMissingTranscript, - CancellationToken cancellationToken) - { - try - { - var turn = ProtocolToTurnContextMapper.MapListenMessage(envelope, session, messageType); - var turnState = session.TurnState; - if (IsYesNoTurn(turn) || ReadPrimaryYesNoRule(turn) is not null) - { - await sink.RecordTurnDiagnosticAsync("yes_no_turn_received", BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary - { - ["messageType"] = messageType, - ["listenRules"] = ReadRules(turn, "listenRules").ToArray(), - ["clientRules"] = ReadRules(turn, "clientRules").ToArray(), - ["listenAsrHints"] = ReadRules(turn, "listenAsrHints").ToArray(), - ["yesNoRule"] = ReadPrimaryYesNoRule(turn), - ["awaitingTurnCompletion"] = turnState.AwaitingTurnCompletion, - ["bufferedAudioBytes"] = turnState.BufferedAudioBytes, - ["bufferedAudioChunks"] = turnState.BufferedAudioChunkCount, - ["sawListen"] = turnState.SawListen, - ["sawContext"] = turnState.SawContext, - ["followUpOpen"] = session.FollowUpOpen, - ["followUpExpiresUtc"] = session.FollowUpExpiresUtc - }), cancellationToken); - } - if (ShouldIgnoreBlankAudioHotphraseTurn(turn)) - { - session.TurnState.AwaitingTurnCompletion = false; - session.TurnState.IgnoreAdditionalAudioUntilUtc = DateTimeOffset.UtcNow.Add(WebSocketTurnState.DefaultLateAudioIgnoreWindow); - session.FollowUpExpiresUtc = null; - ResetBufferedAudio(session); - ClearListenTracking(session.TurnState); - return []; - } - - var finalizedTurn = await ResolveTranscriptAsync(turn, session, cancellationToken); - if (!IsTranscriptUsable(finalizedTurn)) - { - finalizedTurn = new TurnContext - { - TurnId = finalizedTurn.TurnId, - SessionId = finalizedTurn.SessionId, - TimestampUtc = finalizedTurn.TimestampUtc, - InputMode = finalizedTurn.InputMode, - SourceKind = finalizedTurn.SourceKind, - WakePhrase = finalizedTurn.WakePhrase, - RawTranscript = null, - NormalizedTranscript = null, - DeviceId = finalizedTurn.DeviceId, - HostName = finalizedTurn.HostName, - RequestId = finalizedTurn.RequestId, - ProtocolService = finalizedTurn.ProtocolService, - ProtocolOperation = finalizedTurn.ProtocolOperation, - FirmwareVersion = finalizedTurn.FirmwareVersion, - ApplicationVersion = finalizedTurn.ApplicationVersion, - Locale = finalizedTurn.Locale, - TimeZone = finalizedTurn.TimeZone, - IsFollowUpEligible = finalizedTurn.IsFollowUpEligible, - Attributes = finalizedTurn.Attributes - }; - } - - if (ShouldTreatBufferedHotphraseAsGreeting(finalizedTurn, turnState, allowFallbackOnMissingTranscript)) - { - finalizedTurn = WithSyntheticTranscript(finalizedTurn, "hello"); - } - - if (ShouldIgnoreCompletedWordOfDayTurn(finalizedTurn)) - { - turnState.AwaitingTurnCompletion = false; - turnState.IgnoreAdditionalAudioUntilUtc = DateTimeOffset.UtcNow.Add(WebSocketTurnState.DefaultLateAudioIgnoreWindow); - session.FollowUpExpiresUtc = null; - ResetBufferedAudio(session); - ClearListenTracking(turnState); - return [.. ResponsePlanToSocketMessagesMapper.MapNoInputAndRedirectToSkill( - turnState.TransId ?? session.LastTransId ?? string.Empty, - turnState.ListenRules, - "@be/idle") - .Select(map => new WebSocketReply - { - Text = map.Text, - DelayMs = map.DelayMs - })]; - } - - if (ShouldHandleAsLocalNoInput(finalizedTurn)) - { - if (IsYesNoTurn(finalizedTurn)) - { - await sink.RecordTurnDiagnosticAsync("yes_no_no_input", BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary - { - ["messageType"] = messageType, - ["listenRules"] = ReadRules(finalizedTurn, "listenRules").ToArray(), - ["clientRules"] = ReadRules(finalizedTurn, "clientRules").ToArray(), - ["listenAsrHints"] = ReadRules(finalizedTurn, "listenAsrHints").ToArray(), - ["awaitingTurnCompletion"] = turnState.AwaitingTurnCompletion, - ["bufferedAudioBytes"] = turnState.BufferedAudioBytes, - ["bufferedAudioChunks"] = turnState.BufferedAudioChunkCount, - ["sawListen"] = turnState.SawListen, - ["sawContext"] = turnState.SawContext, - ["followUpOpen"] = session.FollowUpOpen - }), cancellationToken); - } - turnState.AwaitingTurnCompletion = false; - session.LastTranscript = string.Empty; - session.LastIntent = null; - session.LastListenType = "no-input"; - var localRule = ReadPrimaryNoInputRule(finalizedTurn); - var noInputReplies = BuildLocalNoInputReplies(session, turnState, localRule); - ResetBufferedAudio(session); - ClearListenTracking(turnState); - return noInputReplies; - } - - if (ShouldIgnoreInitialEmptyHotphraseTurn(finalizedTurn, turnState)) - { - turnState.HotphraseEmptyTurnCount += 1; - turnState.AwaitingTurnCompletion = true; - return []; - } - - if (ShouldTreatEmptyHotphraseTurnAsGreeting(finalizedTurn)) - { - finalizedTurn = WithSyntheticTranscript(finalizedTurn, "hello"); - } - - if (ShouldIgnoreLateEmptyTurn(finalizedTurn, session, messageType)) - { - turnState.AwaitingTurnCompletion = false; - ResetBufferedAudio(session); - return []; - } - - var allowEmptyTranscriptForPersonalReport = IsActivePersonalReportTurn(finalizedTurn); - var allowEmptyTranscriptForTrigger = string.Equals(ReadMessageType(finalizedTurn), "TRIGGER", StringComparison.OrdinalIgnoreCase); - if (!allowEmptyTranscriptForPersonalReport && - !allowEmptyTranscriptForTrigger && - string.IsNullOrWhiteSpace(finalizedTurn.NormalizedTranscript) && - string.IsNullOrWhiteSpace(finalizedTurn.RawTranscript)) - { - turnState.AwaitingTurnCompletion = true; - if (turnState.BufferedAudioBytes > 0) - { - turnState.FinalizeAttemptCount += 1; - } - - var turnAge = turnState.FirstAudioReceivedUtc.HasValue - ? DateTimeOffset.UtcNow - turnState.FirstAudioReceivedUtc.Value - : TimeSpan.Zero; - - switch (allowFallbackOnMissingTranscript) - { - case true when - turnState.FinalizeAttemptCount >= 2 && - turnAge >= AutoFinalizeMissingTranscriptFallbackAge: - { - turnState.AwaitingTurnCompletion = false; - session.LastTranscript = string.Empty; - session.LastIntent = "heyJibo"; - session.LastListenType = "fallback"; - await sink.RecordTurnDiagnosticAsync("auto_finalize_forced_fallback", BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary - { - ["messageType"] = messageType, - ["finalizeAttemptCount"] = turnState.FinalizeAttemptCount, - ["turnAgeMs"] = (int)turnAge.TotalMilliseconds, - ["bufferedAudioBytes"] = turnState.BufferedAudioBytes, - ["bufferedAudioChunks"] = turnState.BufferedAudioChunkCount, - ["lastSttError"] = turnState.LastSttError - }), cancellationToken); - var fallbackReplies = ResponsePlanToSocketMessagesMapper.MapFallback(session, turnState.TransId ?? session.LastTransId ?? string.Empty, turnState.ListenRules) - .Select(map => new WebSocketReply { Text = map.Text, DelayMs = map.DelayMs }) - .ToArray(); - ResetBufferedAudio(session); - ClearListenTracking(turnState); - return fallbackReplies; - } - case true when - turnState.BufferedAudioBytes >= AutoFinalizeMinBufferedAudioBytes && - IsYesNoTurn(finalizedTurn): - { - turnState.AwaitingTurnCompletion = false; - session.LastTranscript = string.Empty; - session.LastIntent = null; - session.LastListenType = "no-input"; - var localRule = ReadPrimaryYesNoRule(finalizedTurn); - var noInputReplies = BuildLocalNoInputReplies(session, turnState, localRule); - ResetBufferedAudio(session); - return noInputReplies; - } - case true when - turnState.BufferedAudioBytes >= AutoFinalizeMinBufferedAudioBytes && - string.IsNullOrWhiteSpace(turnState.LastSttError): - { - turnState.AwaitingTurnCompletion = false; - session.LastTranscript = string.Empty; - session.LastIntent = "heyJibo"; - session.LastListenType = "fallback"; - var fallbackReplies = ResponsePlanToSocketMessagesMapper.MapFallback(session, turnState.TransId ?? session.LastTransId ?? string.Empty, turnState.ListenRules) - .Select(map => new WebSocketReply { Text = map.Text, DelayMs = map.DelayMs }) - .ToArray(); - ResetBufferedAudio(session); - return fallbackReplies; - } - default: - return []; - } - } - - if (ShouldDeferForLikelyContinuation(finalizedTurn, turnState, messageType, allowFallbackOnMissingTranscript, out var deferralReason)) - { - turnState.AwaitingTurnCompletion = true; - turnState.FinalizeAttemptCount += 1; - var turnAge = turnState.FirstAudioReceivedUtc.HasValue - ? DateTimeOffset.UtcNow - turnState.FirstAudioReceivedUtc.Value - : TimeSpan.Zero; - await sink.RecordTurnDiagnosticAsync("auto_finalize_deferred_for_continuation", BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary - { - ["messageType"] = messageType, - ["transcript"] = finalizedTurn.NormalizedTranscript ?? finalizedTurn.RawTranscript, - ["reason"] = deferralReason, - ["finalizeAttemptCount"] = turnState.FinalizeAttemptCount, - ["turnAgeMs"] = (int)turnAge.TotalMilliseconds, - ["bufferedAudioBytes"] = turnState.BufferedAudioBytes, - ["bufferedAudioChunks"] = turnState.BufferedAudioChunkCount - }), cancellationToken); - return []; - } - - var plan = await conversationBroker.HandleTurnAsync(finalizedTurn, cancellationToken); - var listenAction = plan.Actions.OfType().OrderBy(action => action.Sequence).LastOrDefault(); - session.LastTranscript = finalizedTurn.NormalizedTranscript ?? finalizedTurn.RawTranscript; - session.LastIntent = plan.IntentName; - session.LastListenType = listenAction?.Mode; - turnState.LastLocalNoInputRule = null; - turnState.LocalNoInputCount = 0; - if (plan.Actions.OfType().FirstOrDefault() is { SkillName: "@be/clock" } clockAction && - clockAction.Payload.TryGetValue("domain", out var lastClockDomainValue) && - lastClockDomainValue is not null) - { - session.Metadata["lastClockDomain"] = lastClockDomainValue.ToString(); - } - - UpdatePendingProactivityOffer(session, plan.IntentName); - await ApplyContextUpdatesAsync(session, plan.ContextUpdates, envelope, plan.IntentName, cancellationToken); - - var invokedSkillAction = plan.Actions.OfType().FirstOrDefault(); - if ((string.Equals(plan.IntentName, "weather", StringComparison.OrdinalIgnoreCase) || - string.Equals(plan.IntentName, "news", StringComparison.OrdinalIgnoreCase)) && - invokedSkillAction is not null) - { - await sink.RecordTurnDiagnosticAsync( - "skill_payload_summary", - BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary - { - ["intent"] = plan.IntentName, - ["skillName"] = invokedSkillAction.SkillName, - ["transcript"] = finalizedTurn.NormalizedTranscript ?? finalizedTurn.RawTranscript, - ["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); - invokedSkillAction.Payload.TryGetValue("news_provider_message", out var providerMessage); - invokedSkillAction.Payload.TryGetValue("news_provider_http_status", out var providerHttpStatus); - invokedSkillAction.Payload.TryGetValue("news_provider_endpoint", out var providerEndpoint); - invokedSkillAction.Payload.TryGetValue("news_provider_error_code", out var providerErrorCode); - - 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, - ["providerMessage"] = providerMessage, - ["providerHttpStatus"] = providerHttpStatus, - ["providerEndpoint"] = providerEndpoint, - ["providerErrorCode"] = providerErrorCode - }), - cancellationToken); - } - } - - session.FollowUpExpiresUtc = plan.FollowUp.KeepMicOpen - ? DateTimeOffset.UtcNow.Add(plan.FollowUp.Timeout) - : null; - turnState.AwaitingTurnCompletion = false; - turnState.IgnoreAdditionalAudioUntilUtc = plan.FollowUp.KeepMicOpen - ? null - : DateTimeOffset.UtcNow.Add(ResolveLateAudioIgnoreWindow(plan)); - - var emitSkillActions = !string.Equals(plan.IntentName, "word_of_the_day", StringComparison.OrdinalIgnoreCase) && - !string.Equals(plan.IntentName, "radio", StringComparison.OrdinalIgnoreCase) && - !string.Equals(plan.IntentName, "radio_genre", StringComparison.OrdinalIgnoreCase) && - !string.Equals(plan.IntentName, "stop", StringComparison.OrdinalIgnoreCase) && - !string.Equals(plan.IntentName, "volume_up", StringComparison.OrdinalIgnoreCase) && - !string.Equals(plan.IntentName, "volume_down", StringComparison.OrdinalIgnoreCase) && - !string.Equals(plan.IntentName, "volume_to_value", StringComparison.OrdinalIgnoreCase) && - !string.Equals(plan.IntentName, "volume_query", StringComparison.OrdinalIgnoreCase) && - !string.Equals(plan.IntentName, "time", StringComparison.OrdinalIgnoreCase) && - !string.Equals(plan.IntentName, "date", StringComparison.OrdinalIgnoreCase) && - !string.Equals(plan.IntentName, "day", StringComparison.OrdinalIgnoreCase) && - !string.Equals(plan.IntentName, "clock_open", StringComparison.OrdinalIgnoreCase) && - !string.Equals(plan.IntentName, "clock_menu", StringComparison.OrdinalIgnoreCase) && - !string.Equals(plan.IntentName, "timer_menu", StringComparison.OrdinalIgnoreCase) && - !string.Equals(plan.IntentName, "alarm_menu", StringComparison.OrdinalIgnoreCase) && - !string.Equals(plan.IntentName, "timer_delete", StringComparison.OrdinalIgnoreCase) && - !string.Equals(plan.IntentName, "alarm_delete", StringComparison.OrdinalIgnoreCase) && - !string.Equals(plan.IntentName, "timer_cancel", StringComparison.OrdinalIgnoreCase) && - !string.Equals(plan.IntentName, "alarm_cancel", StringComparison.OrdinalIgnoreCase) && - !string.Equals(plan.IntentName, "timer_clarify", StringComparison.OrdinalIgnoreCase) && - !string.Equals(plan.IntentName, "alarm_clarify", StringComparison.OrdinalIgnoreCase) && - !string.Equals(plan.IntentName, "timer_value", StringComparison.OrdinalIgnoreCase) && - !string.Equals(plan.IntentName, "alarm_value", StringComparison.OrdinalIgnoreCase) && - !string.Equals(plan.IntentName, "photo_gallery", StringComparison.OrdinalIgnoreCase) && - !string.Equals(plan.IntentName, "snapshot", StringComparison.OrdinalIgnoreCase) && - !string.Equals(plan.IntentName, "photobooth", StringComparison.OrdinalIgnoreCase) && - (messageType != "CLIENT_NLU" || - string.Equals(plan.IntentName, "word_of_the_day_guess", StringComparison.OrdinalIgnoreCase)); - var replies = ResponsePlanToSocketMessagesMapper.Map(plan, finalizedTurn, session, emitSkillActions).Select(map => new WebSocketReply - { - Text = map.Text, - DelayMs = map.DelayMs - }).ToArray(); - - if (IsYesNoTurn(finalizedTurn)) - { - await sink.RecordTurnDiagnosticAsync("yes_no_turn_resolved", BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary - { - ["messageType"] = messageType, - ["transcript"] = finalizedTurn.NormalizedTranscript ?? finalizedTurn.RawTranscript, - ["intent"] = plan.IntentName, - ["listenRules"] = ReadRules(finalizedTurn, "listenRules").ToArray(), - ["clientRules"] = ReadRules(finalizedTurn, "clientRules").ToArray(), - ["listenAsrHints"] = ReadRules(finalizedTurn, "listenAsrHints").ToArray(), - ["awaitingTurnCompletion"] = turnState.AwaitingTurnCompletion, - ["bufferedAudioBytes"] = turnState.BufferedAudioBytes, - ["bufferedAudioChunks"] = turnState.BufferedAudioChunkCount, - ["followUpOpen"] = session.FollowUpOpen, - ["followUpExpiresUtc"] = session.FollowUpExpiresUtc - }), cancellationToken); - } - - ResetBufferedAudio(session); - ClearListenTracking(turnState); - return replies; - } - finally - { - await TrackGlsmPhaseAsync(session, envelope, $"finalize:{messageType}", cancellationToken); - } - } - - private static bool ShouldAutoFinalize(CloudSession session) - { - var turnState = session.TurnState; - var turnAge = turnState.FirstAudioReceivedUtc.HasValue - ? DateTimeOffset.UtcNow - turnState.FirstAudioReceivedUtc.Value - : TimeSpan.Zero; - return turnState is { AwaitingTurnCompletion: true, SawListen: true, BufferedAudioChunkCount: >= AutoFinalizeMinBufferedAudioChunks, BufferedAudioBytes: >= AutoFinalizeMinBufferedAudioBytes } && - turnAge >= AutoFinalizeMinTurnAge; - } - - private static bool ShouldIgnoreLateAudio(CloudSession session) - { - var ignoreUntilUtc = session.TurnState.IgnoreAdditionalAudioUntilUtc; - return !session.TurnState.AwaitingTurnCompletion && - !session.FollowUpOpen && - ignoreUntilUtc.HasValue && - ignoreUntilUtc.Value > DateTimeOffset.UtcNow; - } - - public static bool ShouldIgnoreLateListenSetup(CloudSession session, string? text) - { - return ShouldIgnoreLateAudio(session) && IsHotphraseLaunchListenSetup(text); - } - - public static bool TryRecoverStalePendingListen(CloudSession session, out int staleAgeMs) - { - staleAgeMs = 0; - var turnState = session.TurnState; - if (!turnState.AwaitingTurnCompletion || - !turnState.SawListen || - turnState.SawContext || - turnState.BufferedAudioBytes > 0 || - !turnState.ListenOpenedUtc.HasValue) - { - return false; - } - - var age = DateTimeOffset.UtcNow - turnState.ListenOpenedUtc.Value; - if (age < StaleListenSetupRecoveryAge) - { - return false; - } - - staleAgeMs = (int)age.TotalMilliseconds; - turnState.AwaitingTurnCompletion = false; - ResetBufferedAudio(session); - ClearListenTracking(turnState); - turnState.ListenHotphrase = false; - turnState.HotphraseEmptyTurnCount = 0; - UpdateGlsmPhaseMarker(session); - return true; - } - - public static string ResolveGlsmPhase(CloudSession session) - { - var turnState = session.TurnState; - if (!turnState.AwaitingTurnCompletion) - { - return session.FollowUpOpen ? "DISPATCH_DIALOG" : "PROCESS_LISTENER_QUEUE"; - } - - if (turnState.SawListen && !turnState.SawContext && turnState.BufferedAudioBytes == 0) - { - return "HJ_LISTENING"; - } - - if (turnState.SawListen && turnState.SawContext && turnState.BufferedAudioBytes == 0) - { - return "LISTENING"; - } - - return turnState.BufferedAudioBytes > 0 - ? "WAIT_LISTEN_FINISHED" - : "LISTENING"; - } - - private static TimeSpan ResolveLateAudioIgnoreWindow(ResponsePlan plan) - { - return string.Equals(plan.IntentName, "cloud_version", StringComparison.OrdinalIgnoreCase) - ? WebSocketTurnState.DiagnosticSpeechLateAudioIgnoreWindow - : WebSocketTurnState.DefaultLateAudioIgnoreWindow; - } - - private static bool ShouldIgnoreAudioWithoutListen(WebSocketTurnState turnState) - { - return !turnState.SawListen && - !string.IsNullOrWhiteSpace(turnState.TransId); - } - - private static bool IsHotphraseLaunchListenSetup(string? text) - { - if (string.IsNullOrWhiteSpace(text)) - { - return false; - } - - try - { - using var document = JsonDocument.Parse(text); - if (!document.RootElement.TryGetProperty("data", out var data) || - data.ValueKind != JsonValueKind.Object) - { - return false; - } - - var isHotphrase = data.TryGetProperty("hotphrase", out var hotphrase) && - hotphrase.ValueKind is JsonValueKind.True or JsonValueKind.False && - hotphrase.GetBoolean(); - if (!isHotphrase || - !data.TryGetProperty("rules", out var rules) || - rules.ValueKind != JsonValueKind.Array) - { - return false; - } - - return rules.EnumerateArray() - .Where(static rule => rule.ValueKind == JsonValueKind.String) - .Select(static rule => rule.GetString()) - .Any(static rule => string.Equals(rule, "launch", StringComparison.OrdinalIgnoreCase) || - string.Equals(rule, "globals/global_commands_launch", StringComparison.OrdinalIgnoreCase)); - } - catch (JsonException) - { - return false; - } - } - - private static bool ShouldIgnorePassiveLocalSkillContext(CloudSession session, string? text) - { - if (session.FollowUpOpen) - { - return false; - } - - if (HasCloudHandledLocalPromptOpen(session.TurnState)) - { - return false; - } - - var skillId = TryReadContextSkillId(text); - return string.Equals(skillId, "@be/gallery", StringComparison.OrdinalIgnoreCase) || - string.Equals(skillId, "@be/create", StringComparison.OrdinalIgnoreCase) || - string.Equals(skillId, "@be/settings", StringComparison.OrdinalIgnoreCase); - } - - private static bool HasCloudHandledLocalPromptOpen(WebSocketTurnState turnState) - { - return turnState is { AwaitingTurnCompletion: true, SawListen: true } && - turnState.ListenRules.Any(rule => - IsClockValueRule(rule) || - IsGalleryPreviewRule(rule) || - IsConstrainedYesNoRule(rule)); - } - - private static string? ExtractDataPayload(string? text) - { - if (string.IsNullOrWhiteSpace(text)) - { - return null; - } - - try - { - using var document = JsonDocument.Parse(text); - if (document.RootElement.TryGetProperty("data", out var data)) - { - return data.GetRawText(); - } - } - catch - { - return null; - } - - return null; - } - - private static bool TryReadContextProperty(string? text, string propertyName, out string? value) - { - value = null; - if (string.IsNullOrWhiteSpace(text)) - { - return false; - } - - try - { - using var document = JsonDocument.Parse(text); - if (!document.RootElement.TryGetProperty("data", out var data) || - !data.TryGetProperty(propertyName, out var property) || - property.ValueKind != JsonValueKind.String) - { - return false; - } - - value = property.GetString(); - return !string.IsNullOrWhiteSpace(value); - } - catch - { - return false; - } - } - - private static string? TryReadContextSkillId(string? text) - { - if (string.IsNullOrWhiteSpace(text)) - { - return null; - } - - try - { - using var document = JsonDocument.Parse(text); - if (!document.RootElement.TryGetProperty("data", out var data) || - !data.TryGetProperty("skill", out var skill) || - !skill.TryGetProperty("id", out var id) || - id.ValueKind != JsonValueKind.String) - { - return null; - } - - return id.GetString(); - } - catch (JsonException) - { - return null; - } - } - - private static bool TryReadTransId(string? text, out string? transId) - { - transId = null; - if (string.IsNullOrWhiteSpace(text)) - { - return false; - } - - try - { - using var document = JsonDocument.Parse(text); - if (!document.RootElement.TryGetProperty("transID", out var transIdProperty) || - transIdProperty.ValueKind != JsonValueKind.String) - { - return false; - } - - transId = transIdProperty.GetString(); - return !string.IsNullOrWhiteSpace(transId); - } - catch - { - return false; - } - } - - private static bool IsTranscriptUsable(TurnContext turn) - { - var messageType = ReadMessageType(turn); - var clientIntent = ReadAttribute(turn, "clientIntent"); - var pendingProactivityOffer = ReadAttribute(turn, "pendingProactivityOffer"); - var personalReportState = ReadAttribute(turn, PersonalReportOrchestrator.StateMetadataKey); - var transcript = NormalizeTranscript(turn.NormalizedTranscript ?? turn.RawTranscript); - var listenRules = ReadRules(turn, "listenRules").Concat(ReadRules(turn, "clientRules")).ToArray(); - - if (string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase) && - !string.IsNullOrWhiteSpace(clientIntent)) - { - return true; - } - - if (string.IsNullOrWhiteSpace(transcript)) - { - return false; - } - - if (!string.IsNullOrWhiteSpace(personalReportState) && - !string.Equals(personalReportState, PersonalReportOrchestrator.IdleState, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - if (transcript is "blank_audio" or "blank audio") - { - return false; - } - - if (listenRules.Any(IsClockValueRule)) - { - return true; - } - - if (ChitchatStateMachine.IsLikelyEmotionUtterance(transcript)) - { - return true; - } - - if (transcript.Length >= 6) - { - return true; - } - - if (IsYesNoTurn(turn) && IsYesNoReplyTranscript(transcript)) - { - return true; - } - - if (!string.IsNullOrWhiteSpace(pendingProactivityOffer) && - IsYesNoReplyTranscript(transcript)) - { - return true; - } - - if (listenRules.Any(rule => string.Equals(rule, "word-of-the-day/puzzle", StringComparison.OrdinalIgnoreCase))) - { - return true; - } - - return transcript is "joke" or "dance" or "time" or "date" or "today" or "day" or "hello" or "hi" or "hey"; - } - - private static bool IsYesNoTurn(TurnContext turn) - { - return ReadRules(turn, "listenRules") - .Concat(ReadRules(turn, "clientRules")) - .Concat(ReadRules(turn, "listenAsrHints")) - .Any(IsYesNoRule); - } - - private static bool IsActivePersonalReportTurn(TurnContext turn) - { - var state = ReadAttribute(turn, PersonalReportOrchestrator.StateMetadataKey); - return !string.IsNullOrWhiteSpace(state) && - !string.Equals(state, PersonalReportOrchestrator.IdleState, StringComparison.OrdinalIgnoreCase); - } - - private static bool ShouldHandleAsLocalNoInput(TurnContext turn) - { - if (!string.IsNullOrWhiteSpace(turn.NormalizedTranscript) || !string.IsNullOrWhiteSpace(turn.RawTranscript)) - { - return false; - } - - return ReadRules(turn, "listenRules") - .Concat(ReadRules(turn, "clientRules")) - .Any(IsLocalNoInputRule); - } - - private static string? ReadPrimaryNoInputRule(TurnContext turn) - { - return ReadRules(turn, "listenRules") - .Concat(ReadRules(turn, "clientRules")) - .FirstOrDefault(IsLocalNoInputRule); - } - - private static string? ReadPrimaryYesNoRule(TurnContext turn) - { - return ReadRules(turn, "listenRules") - .Concat(ReadRules(turn, "clientRules")) - .FirstOrDefault(IsConstrainedYesNoRule); - } - - private static WebSocketReply[] BuildLocalNoInputReplies( - CloudSession session, - WebSocketTurnState turnState, - string? localRule) - { - var transId = turnState.TransId ?? session.LastTransId ?? string.Empty; - var effectiveRule = string.IsNullOrWhiteSpace(localRule) - ? turnState.ListenRules.FirstOrDefault(IsLocalNoInputRule) - : localRule; - var rules = string.IsNullOrWhiteSpace(effectiveRule) ? turnState.ListenRules : [effectiveRule]; - var maps = ShouldRedirectRepeatedNoInputToIdle(turnState, effectiveRule) - ? ResponsePlanToSocketMessagesMapper.MapNoInputAndRedirectToSkill(transId, rules, "@be/idle") - : ResponsePlanToSocketMessagesMapper.MapNoInput(transId, rules); - - return [.. maps.Select(map => new WebSocketReply { Text = map.Text, DelayMs = map.DelayMs })]; - } - - private static bool ShouldRedirectRepeatedNoInputToIdle(WebSocketTurnState turnState, string? localRule) - { - if (string.IsNullOrWhiteSpace(localRule)) - { - turnState.LastLocalNoInputRule = null; - turnState.LocalNoInputCount = 0; - return false; - } - - turnState.LocalNoInputCount = string.Equals(turnState.LastLocalNoInputRule, localRule, StringComparison.OrdinalIgnoreCase) - ? turnState.LocalNoInputCount + 1 - : 1; - turnState.LastLocalNoInputRule = localRule; - - return turnState.LocalNoInputCount >= 2 && - string.Equals(localRule, "create/is_it_a_keeper", StringComparison.OrdinalIgnoreCase); - } - - private static bool IsYesNoRule(string rule) - { - return string.Equals(rule, "$YESNO", StringComparison.OrdinalIgnoreCase) || - IsConstrainedYesNoRule(rule); - } - - private static bool IsLocalNoInputRule(string rule) - { - return string.Equals(rule, "clock/alarm_timer_okay", StringComparison.OrdinalIgnoreCase) || - string.Equals(rule, "settings/volume_control", StringComparison.OrdinalIgnoreCase) || - IsClockValueRule(rule) || - IsGalleryPreviewRule(rule) || - IsConstrainedYesNoRule(rule); - } - - private static bool IsClockValueRule(string rule) - { - return string.Equals(rule, "clock/alarm_set_value", StringComparison.OrdinalIgnoreCase) || - string.Equals(rule, "clock/timer_set_value", StringComparison.OrdinalIgnoreCase); - } - - private static bool IsGalleryPreviewRule(string rule) - { - return string.Equals(rule, "gallery/gallery_preview", StringComparison.OrdinalIgnoreCase); - } - - 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, "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, "word-of-the-day/surprise", 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 TryClassifyTrailingYesNoReply(tokens); - } - - 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 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, - WebSocketMessageEnvelope envelope, - string? intentName, - CancellationToken cancellationToken) - { - if (contextUpdates.Count == 0) - { - return; - } - - var previousState = ReadMetadataString(session.Metadata, PersonalReportOrchestrator.StateMetadataKey); - var previousNoMatchCount = ReadMetadataInt(session.Metadata, PersonalReportOrchestrator.NoMatchCountMetadataKey); - var previousNoInputCount = ReadMetadataInt(session.Metadata, PersonalReportOrchestrator.NoInputCountMetadataKey); - var previousWeatherEnabled = ReadMetadataBool(session.Metadata, PersonalReportOrchestrator.WeatherEnabledMetadataKey) ?? true; - var previousCalendarEnabled = ReadMetadataBool(session.Metadata, PersonalReportOrchestrator.CalendarEnabledMetadataKey) ?? true; - var previousCommuteEnabled = ReadMetadataBool(session.Metadata, PersonalReportOrchestrator.CommuteEnabledMetadataKey) ?? true; - var previousNewsEnabled = ReadMetadataBool(session.Metadata, PersonalReportOrchestrator.NewsEnabledMetadataKey) ?? true; - var previousChitchatState = ReadMetadataString(session.Metadata, ChitchatStateMachine.StateMetadataKey); - var previousChitchatRoute = ReadMetadataString(session.Metadata, ChitchatStateMachine.RouteMetadataKey); - var previousChitchatEmotion = ReadMetadataString(session.Metadata, ChitchatStateMachine.EmotionMetadataKey); - - foreach (var pair in contextUpdates) - { - if (pair.Value is null) - { - session.Metadata.Remove(pair.Key); - continue; - } - - session.Metadata[pair.Key] = pair.Value; - } - - var nextState = ReadMetadataString(session.Metadata, PersonalReportOrchestrator.StateMetadataKey); - var nextNoMatchCount = ReadMetadataInt(session.Metadata, PersonalReportOrchestrator.NoMatchCountMetadataKey); - var nextNoInputCount = ReadMetadataInt(session.Metadata, PersonalReportOrchestrator.NoInputCountMetadataKey); - var nextWeatherEnabled = ReadMetadataBool(session.Metadata, PersonalReportOrchestrator.WeatherEnabledMetadataKey) ?? true; - var nextCalendarEnabled = ReadMetadataBool(session.Metadata, PersonalReportOrchestrator.CalendarEnabledMetadataKey) ?? true; - var nextCommuteEnabled = ReadMetadataBool(session.Metadata, PersonalReportOrchestrator.CommuteEnabledMetadataKey) ?? true; - var nextNewsEnabled = ReadMetadataBool(session.Metadata, PersonalReportOrchestrator.NewsEnabledMetadataKey) ?? true; - var serviceError = ReadMetadataString(session.Metadata, PersonalReportOrchestrator.LastServiceErrorMetadataKey); - var nextChitchatState = ReadMetadataString(session.Metadata, ChitchatStateMachine.StateMetadataKey); - var nextChitchatRoute = ReadMetadataString(session.Metadata, ChitchatStateMachine.RouteMetadataKey); - var nextChitchatEmotion = ReadMetadataString(session.Metadata, ChitchatStateMachine.EmotionMetadataKey); - - if (!string.Equals(previousState, nextState, StringComparison.OrdinalIgnoreCase)) - { - if (!string.IsNullOrWhiteSpace(previousState) && - !string.Equals(previousState, PersonalReportOrchestrator.IdleState, StringComparison.OrdinalIgnoreCase)) - { - await sink.RecordTurnDiagnosticAsync("personal_report_state_exit", BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary - { - ["state"] = previousState, - ["nextState"] = nextState, - ["intent"] = intentName - }), cancellationToken); - } - - if (!string.IsNullOrWhiteSpace(nextState) && - !string.Equals(nextState, PersonalReportOrchestrator.IdleState, StringComparison.OrdinalIgnoreCase)) - { - await sink.RecordTurnDiagnosticAsync("personal_report_state_enter", BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary - { - ["state"] = nextState, - ["previousState"] = previousState, - ["intent"] = intentName - }), cancellationToken); - } - } - - if (nextNoMatchCount != previousNoMatchCount) - { - await sink.RecordTurnDiagnosticAsync("personal_report_nomatch_count", BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary - { - ["count"] = nextNoMatchCount, - ["previousCount"] = previousNoMatchCount, - ["state"] = nextState, - ["intent"] = intentName - }), cancellationToken); - } - - if (nextNoInputCount != previousNoInputCount) - { - await sink.RecordTurnDiagnosticAsync("personal_report_noinput_count", BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary - { - ["count"] = nextNoInputCount, - ["previousCount"] = previousNoInputCount, - ["state"] = nextState, - ["intent"] = intentName - }), cancellationToken); - } - - await EmitServiceToggleDiagnosticAsync("weather", previousWeatherEnabled, nextWeatherEnabled, session, envelope, intentName, cancellationToken); - await EmitServiceToggleDiagnosticAsync("calendar", previousCalendarEnabled, nextCalendarEnabled, session, envelope, intentName, cancellationToken); - await EmitServiceToggleDiagnosticAsync("commute", previousCommuteEnabled, nextCommuteEnabled, session, envelope, intentName, cancellationToken); - await EmitServiceToggleDiagnosticAsync("news", previousNewsEnabled, nextNewsEnabled, session, envelope, intentName, cancellationToken); - - if (!string.IsNullOrWhiteSpace(serviceError)) - { - await sink.RecordTurnDiagnosticAsync("personal_report_service_error", BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary - { - ["service"] = serviceError, - ["state"] = nextState, - ["intent"] = intentName - }), cancellationToken); - } - - if (!string.Equals(previousChitchatState, nextChitchatState, StringComparison.OrdinalIgnoreCase) && - !string.IsNullOrWhiteSpace(nextChitchatState)) - { - await sink.RecordTurnDiagnosticAsync("chitchat_state_transition", BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary - { - ["previousState"] = previousChitchatState, - ["state"] = nextChitchatState, - ["intent"] = intentName - }), cancellationToken); - } - - if (!string.Equals(previousChitchatRoute, nextChitchatRoute, StringComparison.OrdinalIgnoreCase) && - !string.IsNullOrWhiteSpace(nextChitchatRoute)) - { - await sink.RecordTurnDiagnosticAsync("chitchat_route_selected", BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary - { - ["route"] = nextChitchatRoute, - ["previousRoute"] = previousChitchatRoute, - ["emotion"] = nextChitchatEmotion, - ["previousEmotion"] = previousChitchatEmotion, - ["intent"] = intentName - }), cancellationToken); - } - } - - private async Task EmitServiceToggleDiagnosticAsync( - string service, - bool previousEnabled, - bool nextEnabled, - CloudSession session, - WebSocketMessageEnvelope envelope, - string? intentName, - CancellationToken cancellationToken) - { - if (previousEnabled == nextEnabled) - { - return; - } - - await sink.RecordTurnDiagnosticAsync( - nextEnabled ? "personal_report_service_on" : "personal_report_service_off", - BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary - { - ["service"] = service, - ["enabled"] = nextEnabled, - ["intent"] = intentName - }), - cancellationToken); - } - - private static void UpdatePendingProactivityOffer(CloudSession session, string? intentName) - { - if (string.Equals(intentName, "proactive_offer_pizza_fact", StringComparison.OrdinalIgnoreCase)) - { - session.Metadata["pendingProactivityOffer"] = "pizza_fact"; - return; - } - - session.Metadata.Remove("pendingProactivityOffer"); - } - - private static IEnumerable ReadRules(TurnContext turn, string key) - { - if (!turn.Attributes.TryGetValue(key, out var value) || value is null) - { - return []; - } - - return value switch - { - IReadOnlyList typed => typed, - IEnumerable strings => strings, - JsonElement { ValueKind: JsonValueKind.Array } json => json.EnumerateArray() - .Where(static item => item.ValueKind == JsonValueKind.String) - .Select(static item => item.GetString() ?? string.Empty), - _ => [] - }; - } - - private static string? ReadMetadataString(IDictionary metadata, string key) - { - if (!metadata.TryGetValue(key, out var value) || value is null) - { - return null; - } - - return value switch - { - string text => string.IsNullOrWhiteSpace(text) ? null : text.Trim(), - JsonElement { ValueKind: JsonValueKind.String } json => json.GetString(), - _ => value.ToString() - }; - } - - private static int ReadMetadataInt(IDictionary metadata, string key) - { - if (!metadata.TryGetValue(key, out var value) || value is null) - { - return 0; - } - - return value switch - { - int integer => integer, - long whole when whole <= int.MaxValue && whole >= int.MinValue => (int)whole, - string text when int.TryParse(text, out var parsed) => parsed, - JsonElement { ValueKind: JsonValueKind.Number } json when json.TryGetInt32(out var parsed) => parsed, - JsonElement json when json.ValueKind == JsonValueKind.String && int.TryParse(json.GetString(), out var parsed) => parsed, - _ => 0 - }; - } - - private static bool? ReadMetadataBool(IDictionary metadata, string key) - { - if (!metadata.TryGetValue(key, out var value) || value is null) - { - return null; - } - - return value switch - { - bool flag => flag, - string text when bool.TryParse(text, out var parsed) => parsed, - JsonElement { ValueKind: JsonValueKind.True } => true, - JsonElement { ValueKind: JsonValueKind.False } => false, - JsonElement json when json.ValueKind == JsonValueKind.String && bool.TryParse(json.GetString(), out var parsed) => parsed, - _ => null - }; - } - - private static string NormalizeTranscript(string? transcript) - { - if (string.IsNullOrWhiteSpace(transcript)) - { - return string.Empty; - } - - return TranscriptNormalizationRegex().Replace(transcript.Trim().ToLowerInvariant(), " ") - .Replace(" ", " ", StringComparison.Ordinal) - .Trim(); - } - - private static string? ReadMessageType(TurnContext turn) - { - return ReadAttribute(turn, "messageType"); - } - - private static string? ReadAttribute(TurnContext turn, string key) - { - return turn.Attributes.TryGetValue(key, out var value) - ? value?.ToString() - : null; - } - - private static bool ShouldIgnoreBlankAudioHotphraseTurn(TurnContext turn) - { - var transcript = NormalizeTranscript(turn.NormalizedTranscript ?? turn.RawTranscript); - if (transcript is not ("blank_audio" or "blank audio")) - { - return false; - } - - return ReadRules(turn, "listenRules") - .Any(static rule => string.Equals(rule, "launch", StringComparison.OrdinalIgnoreCase)); - } - - private static bool ShouldIgnoreLateEmptyTurn(TurnContext turn, CloudSession session, string messageType) - { - if (messageType is not ("CLIENT_ASR" or "CLIENT_NLU")) - { - return false; - } - - if (session.TurnState.AwaitingTurnCompletion || session.TurnState.BufferedAudioBytes > 0) - { - return false; - } - - if (!string.IsNullOrWhiteSpace(turn.NormalizedTranscript) || !string.IsNullOrWhiteSpace(turn.RawTranscript)) - { - return false; - } - - var turnTransId = ReadAttribute(turn, "transID"); - return !string.IsNullOrWhiteSpace(turnTransId) && - string.Equals(turnTransId, session.LastTransId, StringComparison.Ordinal) && - !string.IsNullOrWhiteSpace(session.LastIntent); - } - - private static bool ShouldIgnoreCompletedWordOfDayTurn(TurnContext turn) - { - if (!string.IsNullOrWhiteSpace(turn.NormalizedTranscript) || !string.IsNullOrWhiteSpace(turn.RawTranscript)) - { - return false; - } - - return ReadRules(turn, "listenRules") - .Any(static rule => string.Equals(rule, "word-of-the-day/right_word", StringComparison.OrdinalIgnoreCase)); - } - - private static bool ShouldTreatBufferedHotphraseAsGreeting( - TurnContext turn, - WebSocketTurnState turnState, - bool allowFallbackOnMissingTranscript) - { - if (!allowFallbackOnMissingTranscript || !ReadBoolAttribute(turn, "listenHotphrase")) - { - return false; - } - - if (!ReadRules(turn, "listenRules") - .Any(static rule => string.Equals(rule, "launch", StringComparison.OrdinalIgnoreCase))) - { - return false; - } - - if (!string.IsNullOrWhiteSpace(turn.NormalizedTranscript) || !string.IsNullOrWhiteSpace(turn.RawTranscript)) - { - return false; - } - - return turnState.BufferedAudioBytes >= AutoFinalizeMinBufferedAudioBytes; - } - - private static bool ShouldTreatEmptyHotphraseTurnAsGreeting(TurnContext turn) - { - if (!string.IsNullOrWhiteSpace(turn.NormalizedTranscript) || !string.IsNullOrWhiteSpace(turn.RawTranscript)) - { - return false; - } - - var messageType = ReadMessageType(turn); - if (messageType is not ("CLIENT_ASR" or "CLIENT_NLU")) - { - return false; - } - - if (!ReadBoolAttribute(turn, "listenHotphrase")) - { - return false; - } - - return ReadRules(turn, "listenRules") - .Any(static rule => string.Equals(rule, "launch", StringComparison.OrdinalIgnoreCase)); - } - - private static bool ShouldIgnoreInitialEmptyHotphraseTurn(TurnContext turn, WebSocketTurnState turnState) - { - if (!string.IsNullOrWhiteSpace(turn.NormalizedTranscript) || !string.IsNullOrWhiteSpace(turn.RawTranscript)) - { - return false; - } - - var messageType = ReadMessageType(turn); - if (messageType is not ("CLIENT_ASR" or "CLIENT_NLU")) - { - return false; - } - - if (!ReadBoolAttribute(turn, "listenHotphrase")) - { - return false; - } - - if (turnState.HotphraseEmptyTurnCount > 0) - { - return false; - } - - return ReadRules(turn, "listenRules") - .Any(static rule => string.Equals(rule, "launch", StringComparison.OrdinalIgnoreCase)); - } - - private static bool ShouldDeferForLikelyContinuation( - TurnContext turn, - WebSocketTurnState turnState, - string messageType, - bool allowFallbackOnMissingTranscript, - out string reason) - { - reason = string.Empty; - if (!allowFallbackOnMissingTranscript || - !string.Equals(messageType, "AUTO_FINALIZE", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - if (!turnState.FirstAudioReceivedUtc.HasValue || - DateTimeOffset.UtcNow - turnState.FirstAudioReceivedUtc.Value >= AutoFinalizeContinuationDeferralMaxAge || - turnState.FinalizeAttemptCount >= AutoFinalizeContinuationDeferralMaxAttempts) - { - return false; - } - - var normalized = NormalizeTranscript(turn.NormalizedTranscript ?? turn.RawTranscript); - if (string.IsNullOrWhiteSpace(normalized)) - { - return false; - } - - if (normalized is "my birthday" or "my birthday is") - { - reason = "birthday_set_incomplete"; - return true; - } - - if (normalized.StartsWith("my favorite ", StringComparison.Ordinal) || - normalized.StartsWith("my favourite ", StringComparison.Ordinal)) - { - var preferenceTail = normalized.StartsWith("my favourite ", StringComparison.Ordinal) - ? normalized["my favourite ".Length..].Trim() - : normalized["my favorite ".Length..].Trim(); - var missingCopula = !normalized.Contains(" is ", StringComparison.Ordinal) && - !normalized.Contains(" are ", StringComparison.Ordinal); - - if (normalized.EndsWith(" is", StringComparison.Ordinal) || - normalized.EndsWith(" are", StringComparison.Ordinal) || - (missingCopula && !LooksLikeBarePreferenceSet(preferenceTail))) - { - reason = "preference_set_incomplete"; - return true; - } - } - - if (normalized.StartsWith("what s my favorite", StringComparison.Ordinal) || - normalized.StartsWith("what is my favorite", StringComparison.Ordinal) || - normalized.StartsWith("what s my favourite", StringComparison.Ordinal) || - normalized.StartsWith("what is my favourite", StringComparison.Ordinal)) - { - if (normalized is "what s my favorite" or "what is my favorite" or "what s my favourite" or "what is my favourite") - { - reason = "preference_recall_incomplete"; - return true; - } - } - - if (LooksLikeIncompleteAffinitySet(normalized)) - { - reason = "affinity_set_incomplete"; - return true; - } - - return false; - } - - private static bool LooksLikeIncompleteAffinitySet(string normalized) - { - return PegasusAffinityContinuationStems.Contains(normalized); - } - - private static bool LooksLikeBarePreferenceSet(string preferenceTail) - { - if (string.IsNullOrWhiteSpace(preferenceTail)) - { - return false; - } - - var tokens = preferenceTail.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - return tokens.Length >= 2; - } - - private static void ClearListenTracking(WebSocketTurnState turnState) - { - turnState.SawListen = false; - turnState.SawContext = false; - turnState.ListenOpenedUtc = null; - } - - private static void UpdateGlsmPhaseMarker(CloudSession session) - { - session.Metadata[GlsmPhaseMetadataKey] = ResolveGlsmPhase(session); - } - - private async Task TrackGlsmPhaseAsync( - CloudSession session, - WebSocketMessageEnvelope envelope, - string trigger, - CancellationToken cancellationToken) - { - var nextPhase = ResolveGlsmPhase(session); - var previousPhase = session.Metadata.TryGetValue(GlsmPhaseMetadataKey, out var rawPhase) - ? rawPhase?.ToString() - : null; - session.Metadata[GlsmPhaseMetadataKey] = nextPhase; - - if (string.Equals(previousPhase, nextPhase, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - try - { - await sink.RecordTurnDiagnosticAsync("glsm_phase_transition", BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary - { - ["trigger"] = trigger, - ["previousState"] = previousPhase, - ["state"] = nextPhase, - ["listenOpenedUtc"] = session.TurnState.ListenOpenedUtc, - ["followUpOpen"] = session.FollowUpOpen, - ["listenRules"] = session.TurnState.ListenRules - }), cancellationToken); - } - catch - { - // Diagnostics should not interrupt turn handling. - } - } - - private static Dictionary BuildTurnDiagnosticSnapshot( - CloudSession session, - WebSocketMessageEnvelope envelope, - Dictionary details) - { - details["sessionToken"] = session.Token; - details["hostName"] = envelope.HostName; - details["path"] = envelope.Path; - details["kind"] = envelope.Kind; - details["transID"] = session.TurnState.TransId ?? session.LastTransId; - details["lastMessageType"] = session.LastMessageType; - details["awaitingTurnCompletion"] = session.TurnState.AwaitingTurnCompletion; - details["bufferedAudioBytes"] = session.TurnState.BufferedAudioBytes; - details["bufferedAudioChunks"] = session.TurnState.BufferedAudioChunkCount; - details["sawListen"] = session.TurnState.SawListen; - details["sawContext"] = session.TurnState.SawContext; - details["glsmState"] = ResolveGlsmPhase(session); - return details; - } - - private static TurnContext WithSyntheticTranscript(TurnContext turn, string transcript) - { - var attributes = new Dictionary(turn.Attributes, StringComparer.OrdinalIgnoreCase) - { - ["syntheticTranscript"] = true - }; - - return new TurnContext - { - TurnId = turn.TurnId, - SessionId = turn.SessionId, - TimestampUtc = turn.TimestampUtc, - InputMode = turn.InputMode, - SourceKind = turn.SourceKind, - WakePhrase = turn.WakePhrase, - RawTranscript = transcript, - NormalizedTranscript = transcript, - DeviceId = turn.DeviceId, - HostName = turn.HostName, - RequestId = turn.RequestId, - ProtocolService = turn.ProtocolService, - ProtocolOperation = turn.ProtocolOperation, - FirmwareVersion = turn.FirmwareVersion, - ApplicationVersion = turn.ApplicationVersion, - Locale = turn.Locale, - TimeZone = turn.TimeZone, - IsFollowUpEligible = turn.IsFollowUpEligible, - Attributes = attributes - }; - } - - private static bool ReadBoolAttribute(TurnContext turn, string key) - { - if (!turn.Attributes.TryGetValue(key, out var value) || value is null) - { - return false; - } - - return value switch - { - bool boolValue => boolValue, - JsonElement { ValueKind: JsonValueKind.True } => true, - JsonElement { ValueKind: JsonValueKind.False } => false, - _ when bool.TryParse(value.ToString(), out var parsed) => parsed, - _ => false - }; - } - - private enum YesNoReply - { - None = 0, - Affirmative = 1, - Negative = 2 - } - private static readonly string[] YesNoAcknowledgementPrefixes = [ "uh", @@ -2032,6 +140,1720 @@ public sealed partial class WebSocketTurnFinalizationService( "i don t" }; + public static void ObserveIncomingMessage(CloudSession session, string? text) + { + if (!TryReadTransId(text, out var nextTransId) || string.IsNullOrWhiteSpace(nextTransId)) return; + + if (!string.Equals(session.TurnState.TransId, nextTransId, StringComparison.Ordinal)) + ResetTurnState(session.TurnState, nextTransId); + + session.LastTransId = nextTransId; + } + + public async Task> HandleBinaryAudioAsync( + CloudSession session, + WebSocketMessageEnvelope envelope, + CancellationToken cancellationToken = default) + { + try + { + var turnState = session.TurnState; + var ignoreLateAudio = ShouldIgnoreLateAudio(session); + var ignoreAudioWithoutListen = ShouldIgnoreAudioWithoutListen(turnState); + if (ignoreLateAudio || ignoreAudioWithoutListen) + { + await sink.RecordTurnDiagnosticAsync("binary_audio_ignored", BuildTurnDiagnosticSnapshot(session, + envelope, new Dictionary + { + ["ignored"] = true, + ["ignoreLateAudio"] = ignoreLateAudio, + ["ignoreAudioWithoutListen"] = ignoreAudioWithoutListen, + ["awaitingTurnCompletion"] = turnState.AwaitingTurnCompletion, + ["bufferedAudioBytes"] = turnState.BufferedAudioBytes, + ["bufferedAudioChunks"] = turnState.BufferedAudioChunkCount, + ["sawListen"] = turnState.SawListen, + ["sawContext"] = turnState.SawContext + }), cancellationToken); + return []; + } + + session.LastMessageType = "BINARY_AUDIO"; + turnState.FirstAudioReceivedUtc ??= DateTimeOffset.UtcNow; + turnState.BufferedAudioChunkCount += 1; + turnState.BufferedAudioBytes += envelope.Binary?.Length ?? 0; + if (envelope.Binary is { Length: > 0 }) turnState.BufferedAudioFrames.Add([.. envelope.Binary]); + turnState.LastAudioReceivedUtc = DateTimeOffset.UtcNow; + turnState.AwaitingTurnCompletion = true; + session.Metadata["lastAudioBytes"] = envelope.Binary?.Length ?? 0; + await sink.RecordTurnDiagnosticAsync("binary_audio_received", BuildTurnDiagnosticSnapshot(session, envelope, + new Dictionary + { + ["bufferedAudioBytes"] = turnState.BufferedAudioBytes, + ["bufferedAudioChunks"] = turnState.BufferedAudioChunkCount, + ["awaitingTurnCompletion"] = turnState.AwaitingTurnCompletion, + ["sawListen"] = turnState.SawListen, + ["sawContext"] = turnState.SawContext, + ["listenRules"] = turnState.ListenRules, + ["listenAsrHints"] = turnState.ListenAsrHints, + ["yesNoRule"] = turnState.ListenRules.FirstOrDefault(IsConstrainedYesNoRule) + }), cancellationToken); + + if (ShouldAutoFinalize(session)) + return await FinalizeTurnAsync(session, envelope, "AUTO_FINALIZE", true, cancellationToken); + + return []; + } + finally + { + await TrackGlsmPhaseAsync(session, envelope, "binary_audio", cancellationToken); + } + } + + public async Task> HandleContextAsync( + CloudSession session, + WebSocketMessageEnvelope envelope, + CancellationToken cancellationToken = default) + { + try + { + var turnState = session.TurnState; + turnState.SawContext = true; + turnState.ContextPayload = ExtractDataPayload(envelope.Text); + session.Metadata["context"] = turnState.ContextPayload; + + if (TryReadContextProperty(envelope.Text, "audioTranscriptHint", out var transcriptHint) && + !string.IsNullOrWhiteSpace(transcriptHint)) + { + turnState.AudioTranscriptHint = transcriptHint; + session.Metadata["audioTranscriptHint"] = transcriptHint; + } + + if (ShouldIgnorePassiveLocalSkillContext(session, envelope.Text)) + { + turnState.AwaitingTurnCompletion = false; + turnState.IgnoreAdditionalAudioUntilUtc = + DateTimeOffset.UtcNow.Add(WebSocketTurnState.DefaultLateAudioIgnoreWindow); + ResetBufferedAudio(session); + ClearListenTracking(turnState); + return []; + } + + if (ShouldAutoFinalize(session)) + return await FinalizeTurnAsync(session, envelope, "AUTO_FINALIZE", true, cancellationToken); + + return []; + } + finally + { + await TrackGlsmPhaseAsync(session, envelope, "context", cancellationToken); + } + } + + public async Task> HandleTurnAsync( + CloudSession session, + WebSocketMessageEnvelope envelope, + string messageType, + CancellationToken cancellationToken = default) + { + PersistTurnHints(session, envelope.Text); + return await FinalizeTurnAsync(session, envelope, messageType, false, cancellationToken); + } + + public static IReadOnlyList HandleListenSetup(CloudSession session, + WebSocketMessageEnvelope envelope) + { + PersistTurnHints(session, envelope.Text); + + var turn = ProtocolToTurnContextMapper.MapListenMessage(envelope, session, "LISTEN"); + if (ShouldIgnoreCompletedWordOfDayTurn(turn)) + { + session.TurnState.AwaitingTurnCompletion = false; + session.TurnState.IgnoreAdditionalAudioUntilUtc = + DateTimeOffset.UtcNow.Add(WebSocketTurnState.DefaultLateAudioIgnoreWindow); + session.FollowUpExpiresUtc = null; + ResetBufferedAudio(session); + ClearListenTracking(session.TurnState); + UpdateGlsmPhaseMarker(session); + return + [ + .. ResponsePlanToSocketMessagesMapper.MapNoInputAndRedirectToSkill( + session.TurnState.TransId ?? session.LastTransId ?? string.Empty, + session.TurnState.ListenRules, + "@be/idle") + .Select(map => new WebSocketReply + { + Text = map.Text, + DelayMs = map.DelayMs + }) + ]; + } + + session.TurnState.AwaitingTurnCompletion = true; + session.TurnState.ListenOpenedUtc ??= DateTimeOffset.UtcNow; + UpdateGlsmPhaseMarker(session); + return []; + } + + private async Task ResolveTranscriptAsync(TurnContext turn, CloudSession session, + CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(turn.NormalizedTranscript) || !string.IsNullOrWhiteSpace(turn.RawTranscript) || + session.TurnState.BufferedAudioBytes <= 0) return turn; + + ISttStrategy? strategy; + try + { + strategy = await sttStrategySelector.SelectAsync(turn, cancellationToken); + } + catch (InvalidOperationException ex) when (string.Equals(ex.Message, + "No STT strategy can handle the current turn.", + StringComparison.Ordinal)) + { + return turn; + } + catch (Exception ex) + { + session.TurnState.LastSttError = ex.Message; + session.TurnState.LastSttErrorUtc = DateTimeOffset.UtcNow; + await sink.RecordTranscriptError(ex, "Error during STT processing", cancellationToken); + return turn; + } + + try + { + var sttResult = await strategy.TranscribeAsync(turn, cancellationToken); + session.TurnState.LastSttError = null; + session.TurnState.LastSttErrorUtc = null; + + var attributes = new Dictionary(turn.Attributes, StringComparer.OrdinalIgnoreCase) + { + ["sttProvider"] = sttResult.Provider, + ["sttConfidence"] = sttResult.Confidence + }; + + foreach (var pair in sttResult.Metadata) attributes[$"stt:{pair.Key}"] = pair.Value; + + return new TurnContext + { + TurnId = turn.TurnId, + SessionId = turn.SessionId, + TimestampUtc = turn.TimestampUtc, + InputMode = turn.InputMode, + SourceKind = turn.SourceKind, + WakePhrase = turn.WakePhrase, + RawTranscript = sttResult.Text, + NormalizedTranscript = sttResult.Text.Trim(), + DeviceId = turn.DeviceId, + HostName = turn.HostName, + RequestId = turn.RequestId, + ProtocolService = turn.ProtocolService, + ProtocolOperation = turn.ProtocolOperation, + FirmwareVersion = turn.FirmwareVersion, + ApplicationVersion = turn.ApplicationVersion, + Locale = sttResult.Locale ?? turn.Locale, + TimeZone = turn.TimeZone, + IsFollowUpEligible = turn.IsFollowUpEligible, + Attributes = attributes + }; + } + catch (Exception ex) + { + session.TurnState.LastSttError = ex.Message; + session.TurnState.LastSttErrorUtc = DateTimeOffset.UtcNow; + await sink.RecordTranscriptError(ex, "Error during STT processing", cancellationToken); + return turn; + } + } + + private static void PersistTurnHints(CloudSession session, string? text) + { + var turnState = session.TurnState; + if (string.IsNullOrWhiteSpace(text)) return; + + try + { + using var document = JsonDocument.Parse(text); + var root = document.RootElement; + + if (root.TryGetProperty("type", out var type) && + type.ValueKind == JsonValueKind.String && + string.Equals(type.GetString(), "LISTEN", StringComparison.OrdinalIgnoreCase)) + { + turnState.SawListen = true; + turnState.ListenOpenedUtc ??= DateTimeOffset.UtcNow; + } + + if (root.TryGetProperty("transID", out var transId) && transId.ValueKind == JsonValueKind.String) + { + var nextTransId = transId.GetString(); + if (!string.IsNullOrWhiteSpace(nextTransId) && + !string.Equals(turnState.TransId, nextTransId, StringComparison.Ordinal)) + { + ResetTurnState(turnState, nextTransId); + session.LastTransId = nextTransId; + } + } + + if (!root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object) return; + + if (data.TryGetProperty("rules", out var rules) && rules.ValueKind == JsonValueKind.Array) + { + turnState.ListenRules = + [ + .. rules.EnumerateArray() + .Select(item => + item.ValueKind == JsonValueKind.String ? item.GetString() ?? string.Empty : item.ToString()) + .Where(rule => !string.IsNullOrWhiteSpace(rule)) + ]; + session.Metadata["listenRules"] = turnState.ListenRules; + } + + if (data.TryGetProperty("asr", out var asr) && + asr.ValueKind == JsonValueKind.Object && + asr.TryGetProperty("hints", out var hints) && + hints.ValueKind == JsonValueKind.Array) + turnState.ListenAsrHints = + [ + .. hints.EnumerateArray() + .Where(static item => item.ValueKind == JsonValueKind.String) + .Select(static item => item.GetString() ?? string.Empty) + .Where(static hint => !string.IsNullOrWhiteSpace(hint)) + ]; + + if (data.TryGetProperty("hotphrase", out var hotphrase) && + hotphrase.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + turnState.ListenHotphrase = hotphrase.GetBoolean(); + turnState.HotphraseEmptyTurnCount = 0; + } + + if (data.TryGetProperty("intent", out var intent) && intent.ValueKind == JsonValueKind.String) + session.LastIntent = intent.GetString(); + + if (!data.TryGetProperty("transcriptHint", out var transcriptHint) || + transcriptHint.ValueKind != JsonValueKind.String) return; + + turnState.AudioTranscriptHint = transcriptHint.GetString(); + session.Metadata["audioTranscriptHint"] = turnState.AudioTranscriptHint; + } + catch + { + // Keep the compatibility layer permissive while captures are still incomplete. + } + } + + private static void ResetBufferedAudio(CloudSession session) + { + session.TurnState.BufferedAudioBytes = 0; + session.TurnState.BufferedAudioChunkCount = 0; + session.TurnState.LastSttError = null; + session.TurnState.LastSttErrorUtc = null; + session.TurnState.FirstAudioReceivedUtc = null; + session.TurnState.LastAudioReceivedUtc = null; + session.TurnState.BufferedAudioFrames.Clear(); + session.TurnState.FinalizeAttemptCount = 0; + session.Metadata.Remove("audioTranscriptHint"); + } + + private static void ResetTurnState(WebSocketTurnState turnState, string? transId) + { + turnState.TransId = transId; + turnState.ContextPayload = null; + turnState.AudioTranscriptHint = null; + turnState.ListenOpenedUtc = null; + turnState.LastSttError = null; + turnState.LastSttErrorUtc = null; + turnState.FirstAudioReceivedUtc = null; + turnState.LastAudioReceivedUtc = null; + turnState.BufferedAudioChunkCount = 0; + turnState.BufferedAudioBytes = 0; + turnState.BufferedAudioFrames.Clear(); + turnState.FinalizeAttemptCount = 0; + turnState.AwaitingTurnCompletion = false; + turnState.SawListen = false; + turnState.SawContext = false; + turnState.ListenHotphrase = false; + turnState.HotphraseEmptyTurnCount = 0; + turnState.IgnoreAdditionalAudioUntilUtc = null; + turnState.ListenRules = []; + turnState.ListenAsrHints = []; + } + + private async Task> FinalizeTurnAsync( + CloudSession session, + WebSocketMessageEnvelope envelope, + string messageType, + bool allowFallbackOnMissingTranscript, + CancellationToken cancellationToken) + { + try + { + var turn = ProtocolToTurnContextMapper.MapListenMessage(envelope, session, messageType); + var turnState = session.TurnState; + if (IsYesNoTurn(turn) || ReadPrimaryYesNoRule(turn) is not null) + await sink.RecordTurnDiagnosticAsync("yes_no_turn_received", BuildTurnDiagnosticSnapshot(session, + envelope, new Dictionary + { + ["messageType"] = messageType, + ["listenRules"] = ReadRules(turn, "listenRules").ToArray(), + ["clientRules"] = ReadRules(turn, "clientRules").ToArray(), + ["listenAsrHints"] = ReadRules(turn, "listenAsrHints").ToArray(), + ["yesNoRule"] = ReadPrimaryYesNoRule(turn), + ["awaitingTurnCompletion"] = turnState.AwaitingTurnCompletion, + ["bufferedAudioBytes"] = turnState.BufferedAudioBytes, + ["bufferedAudioChunks"] = turnState.BufferedAudioChunkCount, + ["sawListen"] = turnState.SawListen, + ["sawContext"] = turnState.SawContext, + ["followUpOpen"] = session.FollowUpOpen, + ["followUpExpiresUtc"] = session.FollowUpExpiresUtc + }), cancellationToken); + if (ShouldIgnoreBlankAudioHotphraseTurn(turn)) + { + session.TurnState.AwaitingTurnCompletion = false; + session.TurnState.IgnoreAdditionalAudioUntilUtc = + DateTimeOffset.UtcNow.Add(WebSocketTurnState.DefaultLateAudioIgnoreWindow); + session.FollowUpExpiresUtc = null; + ResetBufferedAudio(session); + ClearListenTracking(session.TurnState); + return []; + } + + var finalizedTurn = await ResolveTranscriptAsync(turn, session, cancellationToken); + if (!IsTranscriptUsable(finalizedTurn)) + finalizedTurn = new TurnContext + { + TurnId = finalizedTurn.TurnId, + SessionId = finalizedTurn.SessionId, + TimestampUtc = finalizedTurn.TimestampUtc, + InputMode = finalizedTurn.InputMode, + SourceKind = finalizedTurn.SourceKind, + WakePhrase = finalizedTurn.WakePhrase, + RawTranscript = null, + NormalizedTranscript = null, + DeviceId = finalizedTurn.DeviceId, + HostName = finalizedTurn.HostName, + RequestId = finalizedTurn.RequestId, + ProtocolService = finalizedTurn.ProtocolService, + ProtocolOperation = finalizedTurn.ProtocolOperation, + FirmwareVersion = finalizedTurn.FirmwareVersion, + ApplicationVersion = finalizedTurn.ApplicationVersion, + Locale = finalizedTurn.Locale, + TimeZone = finalizedTurn.TimeZone, + IsFollowUpEligible = finalizedTurn.IsFollowUpEligible, + Attributes = finalizedTurn.Attributes + }; + + if (ShouldTreatBufferedHotphraseAsGreeting(finalizedTurn, turnState, allowFallbackOnMissingTranscript)) + finalizedTurn = WithSyntheticTranscript(finalizedTurn, "hello"); + + if (ShouldIgnoreCompletedWordOfDayTurn(finalizedTurn)) + { + turnState.AwaitingTurnCompletion = false; + turnState.IgnoreAdditionalAudioUntilUtc = + DateTimeOffset.UtcNow.Add(WebSocketTurnState.DefaultLateAudioIgnoreWindow); + session.FollowUpExpiresUtc = null; + ResetBufferedAudio(session); + ClearListenTracking(turnState); + return + [ + .. ResponsePlanToSocketMessagesMapper.MapNoInputAndRedirectToSkill( + turnState.TransId ?? session.LastTransId ?? string.Empty, + turnState.ListenRules, + "@be/idle") + .Select(map => new WebSocketReply + { + Text = map.Text, + DelayMs = map.DelayMs + }) + ]; + } + + if (ShouldHandleAsLocalNoInput(finalizedTurn)) + { + if (IsYesNoTurn(finalizedTurn)) + await sink.RecordTurnDiagnosticAsync("yes_no_no_input", BuildTurnDiagnosticSnapshot(session, + envelope, new Dictionary + { + ["messageType"] = messageType, + ["listenRules"] = ReadRules(finalizedTurn, "listenRules").ToArray(), + ["clientRules"] = ReadRules(finalizedTurn, "clientRules").ToArray(), + ["listenAsrHints"] = ReadRules(finalizedTurn, "listenAsrHints").ToArray(), + ["awaitingTurnCompletion"] = turnState.AwaitingTurnCompletion, + ["bufferedAudioBytes"] = turnState.BufferedAudioBytes, + ["bufferedAudioChunks"] = turnState.BufferedAudioChunkCount, + ["sawListen"] = turnState.SawListen, + ["sawContext"] = turnState.SawContext, + ["followUpOpen"] = session.FollowUpOpen + }), cancellationToken); + turnState.AwaitingTurnCompletion = false; + session.LastTranscript = string.Empty; + session.LastIntent = null; + session.LastListenType = "no-input"; + var localRule = ReadPrimaryNoInputRule(finalizedTurn); + var noInputReplies = BuildLocalNoInputReplies(session, turnState, localRule); + ResetBufferedAudio(session); + ClearListenTracking(turnState); + return noInputReplies; + } + + if (ShouldIgnoreInitialEmptyHotphraseTurn(finalizedTurn, turnState)) + { + turnState.HotphraseEmptyTurnCount += 1; + turnState.AwaitingTurnCompletion = true; + return []; + } + + if (ShouldTreatEmptyHotphraseTurnAsGreeting(finalizedTurn)) + finalizedTurn = WithSyntheticTranscript(finalizedTurn, "hello"); + + if (ShouldIgnoreLateEmptyTurn(finalizedTurn, session, messageType)) + { + turnState.AwaitingTurnCompletion = false; + ResetBufferedAudio(session); + return []; + } + + var allowEmptyTranscriptForPersonalReport = IsActivePersonalReportTurn(finalizedTurn); + var allowEmptyTranscriptForTrigger = string.Equals(ReadMessageType(finalizedTurn), "TRIGGER", + StringComparison.OrdinalIgnoreCase); + if (!allowEmptyTranscriptForPersonalReport && + !allowEmptyTranscriptForTrigger && + string.IsNullOrWhiteSpace(finalizedTurn.NormalizedTranscript) && + string.IsNullOrWhiteSpace(finalizedTurn.RawTranscript)) + { + turnState.AwaitingTurnCompletion = true; + if (turnState.BufferedAudioBytes > 0) turnState.FinalizeAttemptCount += 1; + + var turnAge = turnState.FirstAudioReceivedUtc.HasValue + ? DateTimeOffset.UtcNow - turnState.FirstAudioReceivedUtc.Value + : TimeSpan.Zero; + + switch (allowFallbackOnMissingTranscript) + { + case true when + turnState.FinalizeAttemptCount >= 2 && + turnAge >= AutoFinalizeMissingTranscriptFallbackAge: + { + turnState.AwaitingTurnCompletion = false; + session.LastTranscript = string.Empty; + session.LastIntent = "heyJibo"; + session.LastListenType = "fallback"; + await sink.RecordTurnDiagnosticAsync("auto_finalize_forced_fallback", + BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary + { + ["messageType"] = messageType, + ["finalizeAttemptCount"] = turnState.FinalizeAttemptCount, + ["turnAgeMs"] = (int)turnAge.TotalMilliseconds, + ["bufferedAudioBytes"] = turnState.BufferedAudioBytes, + ["bufferedAudioChunks"] = turnState.BufferedAudioChunkCount, + ["lastSttError"] = turnState.LastSttError + }), cancellationToken); + var fallbackReplies = ResponsePlanToSocketMessagesMapper.MapFallback(session, + turnState.TransId ?? session.LastTransId ?? string.Empty, turnState.ListenRules) + .Select(map => new WebSocketReply { Text = map.Text, DelayMs = map.DelayMs }) + .ToArray(); + ResetBufferedAudio(session); + ClearListenTracking(turnState); + return fallbackReplies; + } + case true when + turnState.BufferedAudioBytes >= AutoFinalizeMinBufferedAudioBytes && + IsYesNoTurn(finalizedTurn): + { + turnState.AwaitingTurnCompletion = false; + session.LastTranscript = string.Empty; + session.LastIntent = null; + session.LastListenType = "no-input"; + var localRule = ReadPrimaryYesNoRule(finalizedTurn); + var noInputReplies = BuildLocalNoInputReplies(session, turnState, localRule); + ResetBufferedAudio(session); + return noInputReplies; + } + case true when + turnState.BufferedAudioBytes >= AutoFinalizeMinBufferedAudioBytes && + string.IsNullOrWhiteSpace(turnState.LastSttError): + { + turnState.AwaitingTurnCompletion = false; + session.LastTranscript = string.Empty; + session.LastIntent = "heyJibo"; + session.LastListenType = "fallback"; + var fallbackReplies = ResponsePlanToSocketMessagesMapper.MapFallback(session, + turnState.TransId ?? session.LastTransId ?? string.Empty, turnState.ListenRules) + .Select(map => new WebSocketReply { Text = map.Text, DelayMs = map.DelayMs }) + .ToArray(); + ResetBufferedAudio(session); + return fallbackReplies; + } + default: + return []; + } + } + + if (ShouldDeferForLikelyContinuation(finalizedTurn, turnState, messageType, + allowFallbackOnMissingTranscript, out var deferralReason)) + { + turnState.AwaitingTurnCompletion = true; + turnState.FinalizeAttemptCount += 1; + var turnAge = turnState.FirstAudioReceivedUtc.HasValue + ? DateTimeOffset.UtcNow - turnState.FirstAudioReceivedUtc.Value + : TimeSpan.Zero; + await sink.RecordTurnDiagnosticAsync("auto_finalize_deferred_for_continuation", + BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary + { + ["messageType"] = messageType, + ["transcript"] = finalizedTurn.NormalizedTranscript ?? finalizedTurn.RawTranscript, + ["reason"] = deferralReason, + ["finalizeAttemptCount"] = turnState.FinalizeAttemptCount, + ["turnAgeMs"] = (int)turnAge.TotalMilliseconds, + ["bufferedAudioBytes"] = turnState.BufferedAudioBytes, + ["bufferedAudioChunks"] = turnState.BufferedAudioChunkCount + }), cancellationToken); + return []; + } + + var plan = await conversationBroker.HandleTurnAsync(finalizedTurn, cancellationToken); + var listenAction = plan.Actions.OfType().OrderBy(action => action.Sequence).LastOrDefault(); + session.LastTranscript = finalizedTurn.NormalizedTranscript ?? finalizedTurn.RawTranscript; + session.LastIntent = plan.IntentName; + session.LastListenType = listenAction?.Mode; + turnState.LastLocalNoInputRule = null; + turnState.LocalNoInputCount = 0; + if (plan.Actions.OfType().FirstOrDefault() is + { SkillName: "@be/clock" } clockAction && + clockAction.Payload.TryGetValue("domain", out var lastClockDomainValue) && + lastClockDomainValue is not null) + session.Metadata["lastClockDomain"] = lastClockDomainValue.ToString(); + + UpdatePendingProactivityOffer(session, plan.IntentName); + await ApplyContextUpdatesAsync(session, plan.ContextUpdates, envelope, plan.IntentName, cancellationToken); + + var invokedSkillAction = plan.Actions.OfType().FirstOrDefault(); + if ((string.Equals(plan.IntentName, "weather", StringComparison.OrdinalIgnoreCase) || + string.Equals(plan.IntentName, "news", StringComparison.OrdinalIgnoreCase)) && + invokedSkillAction is not null) + { + await sink.RecordTurnDiagnosticAsync( + "skill_payload_summary", + BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary + { + ["intent"] = plan.IntentName, + ["skillName"] = invokedSkillAction.SkillName, + ["transcript"] = finalizedTurn.NormalizedTranscript ?? finalizedTurn.RawTranscript, + ["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); + invokedSkillAction.Payload.TryGetValue("news_provider_message", out var providerMessage); + invokedSkillAction.Payload.TryGetValue("news_provider_http_status", out var providerHttpStatus); + invokedSkillAction.Payload.TryGetValue("news_provider_endpoint", out var providerEndpoint); + invokedSkillAction.Payload.TryGetValue("news_provider_error_code", out var providerErrorCode); + + 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, + ["providerMessage"] = providerMessage, + ["providerHttpStatus"] = providerHttpStatus, + ["providerEndpoint"] = providerEndpoint, + ["providerErrorCode"] = providerErrorCode + }), + cancellationToken); + } + } + + session.FollowUpExpiresUtc = plan.FollowUp.KeepMicOpen + ? DateTimeOffset.UtcNow.Add(plan.FollowUp.Timeout) + : null; + turnState.AwaitingTurnCompletion = false; + turnState.IgnoreAdditionalAudioUntilUtc = plan.FollowUp.KeepMicOpen + ? null + : DateTimeOffset.UtcNow.Add(ResolveLateAudioIgnoreWindow(plan)); + + var emitSkillActions = + !string.Equals(plan.IntentName, "word_of_the_day", StringComparison.OrdinalIgnoreCase) && + !string.Equals(plan.IntentName, "radio", StringComparison.OrdinalIgnoreCase) && + !string.Equals(plan.IntentName, "radio_genre", StringComparison.OrdinalIgnoreCase) && + !string.Equals(plan.IntentName, "stop", StringComparison.OrdinalIgnoreCase) && + !string.Equals(plan.IntentName, "volume_up", StringComparison.OrdinalIgnoreCase) && + !string.Equals(plan.IntentName, "volume_down", StringComparison.OrdinalIgnoreCase) && + !string.Equals(plan.IntentName, "volume_to_value", StringComparison.OrdinalIgnoreCase) && + !string.Equals(plan.IntentName, "volume_query", StringComparison.OrdinalIgnoreCase) && + !string.Equals(plan.IntentName, "time", StringComparison.OrdinalIgnoreCase) && + !string.Equals(plan.IntentName, "date", StringComparison.OrdinalIgnoreCase) && + !string.Equals(plan.IntentName, "day", StringComparison.OrdinalIgnoreCase) && + !string.Equals(plan.IntentName, "clock_open", StringComparison.OrdinalIgnoreCase) && + !string.Equals(plan.IntentName, "clock_menu", StringComparison.OrdinalIgnoreCase) && + !string.Equals(plan.IntentName, "timer_menu", StringComparison.OrdinalIgnoreCase) && + !string.Equals(plan.IntentName, "alarm_menu", StringComparison.OrdinalIgnoreCase) && + !string.Equals(plan.IntentName, "timer_delete", StringComparison.OrdinalIgnoreCase) && + !string.Equals(plan.IntentName, "alarm_delete", StringComparison.OrdinalIgnoreCase) && + !string.Equals(plan.IntentName, "timer_cancel", StringComparison.OrdinalIgnoreCase) && + !string.Equals(plan.IntentName, "alarm_cancel", StringComparison.OrdinalIgnoreCase) && + !string.Equals(plan.IntentName, "timer_clarify", StringComparison.OrdinalIgnoreCase) && + !string.Equals(plan.IntentName, "alarm_clarify", StringComparison.OrdinalIgnoreCase) && + !string.Equals(plan.IntentName, "timer_value", StringComparison.OrdinalIgnoreCase) && + !string.Equals(plan.IntentName, "alarm_value", StringComparison.OrdinalIgnoreCase) && + !string.Equals(plan.IntentName, "photo_gallery", StringComparison.OrdinalIgnoreCase) && + !string.Equals(plan.IntentName, "snapshot", StringComparison.OrdinalIgnoreCase) && + !string.Equals(plan.IntentName, "photobooth", StringComparison.OrdinalIgnoreCase) && + (messageType != "CLIENT_NLU" || + string.Equals(plan.IntentName, "word_of_the_day_guess", StringComparison.OrdinalIgnoreCase)); + var replies = ResponsePlanToSocketMessagesMapper.Map(plan, finalizedTurn, session, emitSkillActions) + .Select(map => new WebSocketReply + { + Text = map.Text, + DelayMs = map.DelayMs + }).ToArray(); + + if (IsYesNoTurn(finalizedTurn)) + await sink.RecordTurnDiagnosticAsync("yes_no_turn_resolved", BuildTurnDiagnosticSnapshot(session, + envelope, new Dictionary + { + ["messageType"] = messageType, + ["transcript"] = finalizedTurn.NormalizedTranscript ?? finalizedTurn.RawTranscript, + ["intent"] = plan.IntentName, + ["listenRules"] = ReadRules(finalizedTurn, "listenRules").ToArray(), + ["clientRules"] = ReadRules(finalizedTurn, "clientRules").ToArray(), + ["listenAsrHints"] = ReadRules(finalizedTurn, "listenAsrHints").ToArray(), + ["awaitingTurnCompletion"] = turnState.AwaitingTurnCompletion, + ["bufferedAudioBytes"] = turnState.BufferedAudioBytes, + ["bufferedAudioChunks"] = turnState.BufferedAudioChunkCount, + ["followUpOpen"] = session.FollowUpOpen, + ["followUpExpiresUtc"] = session.FollowUpExpiresUtc + }), cancellationToken); + + ResetBufferedAudio(session); + ClearListenTracking(turnState); + return replies; + } + finally + { + await TrackGlsmPhaseAsync(session, envelope, $"finalize:{messageType}", cancellationToken); + } + } + + private static bool ShouldAutoFinalize(CloudSession session) + { + var turnState = session.TurnState; + var turnAge = turnState.FirstAudioReceivedUtc.HasValue + ? DateTimeOffset.UtcNow - turnState.FirstAudioReceivedUtc.Value + : TimeSpan.Zero; + return turnState is + { + AwaitingTurnCompletion: true, SawListen: true, + BufferedAudioChunkCount: >= AutoFinalizeMinBufferedAudioChunks, + BufferedAudioBytes: >= AutoFinalizeMinBufferedAudioBytes + } && + turnAge >= AutoFinalizeMinTurnAge; + } + + private static bool ShouldIgnoreLateAudio(CloudSession session) + { + var ignoreUntilUtc = session.TurnState.IgnoreAdditionalAudioUntilUtc; + return !session.TurnState.AwaitingTurnCompletion && + !session.FollowUpOpen && + ignoreUntilUtc.HasValue && + ignoreUntilUtc.Value > DateTimeOffset.UtcNow; + } + + public static bool ShouldIgnoreLateListenSetup(CloudSession session, string? text) + { + return ShouldIgnoreLateAudio(session) && IsHotphraseLaunchListenSetup(text); + } + + public static bool TryRecoverStalePendingListen(CloudSession session, out int staleAgeMs) + { + staleAgeMs = 0; + var turnState = session.TurnState; + if (!turnState.AwaitingTurnCompletion || + !turnState.SawListen || + turnState.SawContext || + turnState.BufferedAudioBytes > 0 || + !turnState.ListenOpenedUtc.HasValue) + return false; + + var age = DateTimeOffset.UtcNow - turnState.ListenOpenedUtc.Value; + if (age < StaleListenSetupRecoveryAge) return false; + + staleAgeMs = (int)age.TotalMilliseconds; + turnState.AwaitingTurnCompletion = false; + ResetBufferedAudio(session); + ClearListenTracking(turnState); + turnState.ListenHotphrase = false; + turnState.HotphraseEmptyTurnCount = 0; + UpdateGlsmPhaseMarker(session); + return true; + } + + public static string ResolveGlsmPhase(CloudSession session) + { + var turnState = session.TurnState; + if (!turnState.AwaitingTurnCompletion) + return session.FollowUpOpen ? "DISPATCH_DIALOG" : "PROCESS_LISTENER_QUEUE"; + + if (turnState.SawListen && !turnState.SawContext && turnState.BufferedAudioBytes == 0) return "HJ_LISTENING"; + + if (turnState.SawListen && turnState.SawContext && turnState.BufferedAudioBytes == 0) return "LISTENING"; + + return turnState.BufferedAudioBytes > 0 + ? "WAIT_LISTEN_FINISHED" + : "LISTENING"; + } + + private static TimeSpan ResolveLateAudioIgnoreWindow(ResponsePlan plan) + { + return string.Equals(plan.IntentName, "cloud_version", StringComparison.OrdinalIgnoreCase) + ? WebSocketTurnState.DiagnosticSpeechLateAudioIgnoreWindow + : WebSocketTurnState.DefaultLateAudioIgnoreWindow; + } + + private static bool ShouldIgnoreAudioWithoutListen(WebSocketTurnState turnState) + { + return !turnState.SawListen && + !string.IsNullOrWhiteSpace(turnState.TransId); + } + + private static bool IsHotphraseLaunchListenSetup(string? text) + { + if (string.IsNullOrWhiteSpace(text)) return false; + + try + { + using var document = JsonDocument.Parse(text); + if (!document.RootElement.TryGetProperty("data", out var data) || + data.ValueKind != JsonValueKind.Object) + return false; + + var isHotphrase = data.TryGetProperty("hotphrase", out var hotphrase) && + hotphrase.ValueKind is JsonValueKind.True or JsonValueKind.False && + hotphrase.GetBoolean(); + if (!isHotphrase || + !data.TryGetProperty("rules", out var rules) || + rules.ValueKind != JsonValueKind.Array) + return false; + + return rules.EnumerateArray() + .Where(static rule => rule.ValueKind == JsonValueKind.String) + .Select(static rule => rule.GetString()) + .Any(static rule => string.Equals(rule, "launch", StringComparison.OrdinalIgnoreCase) || + string.Equals(rule, "globals/global_commands_launch", + StringComparison.OrdinalIgnoreCase)); + } + catch (JsonException) + { + return false; + } + } + + private static bool ShouldIgnorePassiveLocalSkillContext(CloudSession session, string? text) + { + if (session.FollowUpOpen) return false; + + if (HasCloudHandledLocalPromptOpen(session.TurnState)) return false; + + var skillId = TryReadContextSkillId(text); + return string.Equals(skillId, "@be/gallery", StringComparison.OrdinalIgnoreCase) || + string.Equals(skillId, "@be/create", StringComparison.OrdinalIgnoreCase) || + string.Equals(skillId, "@be/settings", StringComparison.OrdinalIgnoreCase); + } + + private static bool HasCloudHandledLocalPromptOpen(WebSocketTurnState turnState) + { + return turnState is { AwaitingTurnCompletion: true, SawListen: true } && + turnState.ListenRules.Any(rule => + IsClockValueRule(rule) || + IsGalleryPreviewRule(rule) || + IsConstrainedYesNoRule(rule)); + } + + private static string? ExtractDataPayload(string? text) + { + if (string.IsNullOrWhiteSpace(text)) return null; + + try + { + using var document = JsonDocument.Parse(text); + if (document.RootElement.TryGetProperty("data", out var data)) return data.GetRawText(); + } + catch + { + return null; + } + + return null; + } + + private static bool TryReadContextProperty(string? text, string propertyName, out string? value) + { + value = null; + if (string.IsNullOrWhiteSpace(text)) return false; + + try + { + using var document = JsonDocument.Parse(text); + if (!document.RootElement.TryGetProperty("data", out var data) || + !data.TryGetProperty(propertyName, out var property) || + property.ValueKind != JsonValueKind.String) + return false; + + value = property.GetString(); + return !string.IsNullOrWhiteSpace(value); + } + catch + { + return false; + } + } + + private static string? TryReadContextSkillId(string? text) + { + if (string.IsNullOrWhiteSpace(text)) return null; + + try + { + using var document = JsonDocument.Parse(text); + if (!document.RootElement.TryGetProperty("data", out var data) || + !data.TryGetProperty("skill", out var skill) || + !skill.TryGetProperty("id", out var id) || + id.ValueKind != JsonValueKind.String) + return null; + + return id.GetString(); + } + catch (JsonException) + { + return null; + } + } + + private static bool TryReadTransId(string? text, out string? transId) + { + transId = null; + if (string.IsNullOrWhiteSpace(text)) return false; + + try + { + using var document = JsonDocument.Parse(text); + if (!document.RootElement.TryGetProperty("transID", out var transIdProperty) || + transIdProperty.ValueKind != JsonValueKind.String) + return false; + + transId = transIdProperty.GetString(); + return !string.IsNullOrWhiteSpace(transId); + } + catch + { + return false; + } + } + + private static bool IsTranscriptUsable(TurnContext turn) + { + var messageType = ReadMessageType(turn); + var clientIntent = ReadAttribute(turn, "clientIntent"); + var pendingProactivityOffer = ReadAttribute(turn, "pendingProactivityOffer"); + var personalReportState = ReadAttribute(turn, PersonalReportOrchestrator.StateMetadataKey); + var transcript = NormalizeTranscript(turn.NormalizedTranscript ?? turn.RawTranscript); + var listenRules = ReadRules(turn, "listenRules").Concat(ReadRules(turn, "clientRules")).ToArray(); + + if (string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(clientIntent)) + return true; + + if (string.IsNullOrWhiteSpace(transcript)) return false; + + if (!string.IsNullOrWhiteSpace(personalReportState) && + !string.Equals(personalReportState, PersonalReportOrchestrator.IdleState, + StringComparison.OrdinalIgnoreCase)) + return true; + + if (transcript is "blank_audio" or "blank audio") return false; + + if (listenRules.Any(IsClockValueRule)) return true; + + if (ChitchatStateMachine.IsLikelyEmotionUtterance(transcript)) return true; + + if (transcript.Length >= 6) return true; + + if (IsYesNoTurn(turn) && IsYesNoReplyTranscript(transcript)) return true; + + if (!string.IsNullOrWhiteSpace(pendingProactivityOffer) && + IsYesNoReplyTranscript(transcript)) + return true; + + if (listenRules.Any(rule => + string.Equals(rule, "word-of-the-day/puzzle", StringComparison.OrdinalIgnoreCase))) return true; + + return transcript is "joke" or "dance" or "time" or "date" or "today" or "day" or "hello" or "hi" or "hey"; + } + + private static bool IsYesNoTurn(TurnContext turn) + { + return ReadRules(turn, "listenRules") + .Concat(ReadRules(turn, "clientRules")) + .Concat(ReadRules(turn, "listenAsrHints")) + .Any(IsYesNoRule); + } + + private static bool IsActivePersonalReportTurn(TurnContext turn) + { + var state = ReadAttribute(turn, PersonalReportOrchestrator.StateMetadataKey); + return !string.IsNullOrWhiteSpace(state) && + !string.Equals(state, PersonalReportOrchestrator.IdleState, StringComparison.OrdinalIgnoreCase); + } + + private static bool ShouldHandleAsLocalNoInput(TurnContext turn) + { + if (!string.IsNullOrWhiteSpace(turn.NormalizedTranscript) || + !string.IsNullOrWhiteSpace(turn.RawTranscript)) return false; + + return ReadRules(turn, "listenRules") + .Concat(ReadRules(turn, "clientRules")) + .Any(IsLocalNoInputRule); + } + + private static string? ReadPrimaryNoInputRule(TurnContext turn) + { + return ReadRules(turn, "listenRules") + .Concat(ReadRules(turn, "clientRules")) + .FirstOrDefault(IsLocalNoInputRule); + } + + private static string? ReadPrimaryYesNoRule(TurnContext turn) + { + return ReadRules(turn, "listenRules") + .Concat(ReadRules(turn, "clientRules")) + .FirstOrDefault(IsConstrainedYesNoRule); + } + + private static WebSocketReply[] BuildLocalNoInputReplies( + CloudSession session, + WebSocketTurnState turnState, + string? localRule) + { + var transId = turnState.TransId ?? session.LastTransId ?? string.Empty; + var effectiveRule = string.IsNullOrWhiteSpace(localRule) + ? turnState.ListenRules.FirstOrDefault(IsLocalNoInputRule) + : localRule; + var rules = string.IsNullOrWhiteSpace(effectiveRule) ? turnState.ListenRules : [effectiveRule]; + var maps = ShouldRedirectRepeatedNoInputToIdle(turnState, effectiveRule) + ? ResponsePlanToSocketMessagesMapper.MapNoInputAndRedirectToSkill(transId, rules, "@be/idle") + : ResponsePlanToSocketMessagesMapper.MapNoInput(transId, rules); + + return [.. maps.Select(map => new WebSocketReply { Text = map.Text, DelayMs = map.DelayMs })]; + } + + private static bool ShouldRedirectRepeatedNoInputToIdle(WebSocketTurnState turnState, string? localRule) + { + if (string.IsNullOrWhiteSpace(localRule)) + { + turnState.LastLocalNoInputRule = null; + turnState.LocalNoInputCount = 0; + return false; + } + + turnState.LocalNoInputCount = + string.Equals(turnState.LastLocalNoInputRule, localRule, StringComparison.OrdinalIgnoreCase) + ? turnState.LocalNoInputCount + 1 + : 1; + turnState.LastLocalNoInputRule = localRule; + + return turnState.LocalNoInputCount >= 2 && + string.Equals(localRule, "create/is_it_a_keeper", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsYesNoRule(string rule) + { + return string.Equals(rule, "$YESNO", StringComparison.OrdinalIgnoreCase) || + IsConstrainedYesNoRule(rule); + } + + private static bool IsLocalNoInputRule(string rule) + { + return string.Equals(rule, "clock/alarm_timer_okay", StringComparison.OrdinalIgnoreCase) || + string.Equals(rule, "settings/volume_control", StringComparison.OrdinalIgnoreCase) || + IsClockValueRule(rule) || + IsGalleryPreviewRule(rule) || + IsConstrainedYesNoRule(rule); + } + + private static bool IsClockValueRule(string rule) + { + return string.Equals(rule, "clock/alarm_set_value", StringComparison.OrdinalIgnoreCase) || + string.Equals(rule, "clock/timer_set_value", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsGalleryPreviewRule(string rule) + { + return string.Equals(rule, "gallery/gallery_preview", StringComparison.OrdinalIgnoreCase); + } + + 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, "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, "word-of-the-day/surprise", 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 TryClassifyTrailingYesNoReply(tokens); + } + + 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 YesNoReply TryClassifyTrailingYesNoReply(IReadOnlyList tokens) + { + var selectedReply = YesNoReply.None; + var selectedIndex = -1; + + 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; + + void Consider(YesNoReply candidateReply, int candidateIndex) + { + if (candidateIndex < 0 || candidateIndex < selectedIndex) return; + + selectedReply = candidateReply; + selectedIndex = candidateIndex; + } + } + + private async Task ApplyContextUpdatesAsync( + CloudSession session, + IDictionary contextUpdates, + WebSocketMessageEnvelope envelope, + string? intentName, + CancellationToken cancellationToken) + { + if (contextUpdates.Count == 0) return; + + var previousState = ReadMetadataString(session.Metadata, PersonalReportOrchestrator.StateMetadataKey); + var previousNoMatchCount = + ReadMetadataInt(session.Metadata, PersonalReportOrchestrator.NoMatchCountMetadataKey); + var previousNoInputCount = + ReadMetadataInt(session.Metadata, PersonalReportOrchestrator.NoInputCountMetadataKey); + var previousWeatherEnabled = + ReadMetadataBool(session.Metadata, PersonalReportOrchestrator.WeatherEnabledMetadataKey) ?? true; + var previousCalendarEnabled = + ReadMetadataBool(session.Metadata, PersonalReportOrchestrator.CalendarEnabledMetadataKey) ?? true; + var previousCommuteEnabled = + ReadMetadataBool(session.Metadata, PersonalReportOrchestrator.CommuteEnabledMetadataKey) ?? true; + var previousNewsEnabled = + ReadMetadataBool(session.Metadata, PersonalReportOrchestrator.NewsEnabledMetadataKey) ?? true; + var previousChitchatState = ReadMetadataString(session.Metadata, ChitchatStateMachine.StateMetadataKey); + var previousChitchatRoute = ReadMetadataString(session.Metadata, ChitchatStateMachine.RouteMetadataKey); + var previousChitchatEmotion = ReadMetadataString(session.Metadata, ChitchatStateMachine.EmotionMetadataKey); + + foreach (var pair in contextUpdates) + { + if (pair.Value is null) + { + session.Metadata.Remove(pair.Key); + continue; + } + + session.Metadata[pair.Key] = pair.Value; + } + + var nextState = ReadMetadataString(session.Metadata, PersonalReportOrchestrator.StateMetadataKey); + var nextNoMatchCount = ReadMetadataInt(session.Metadata, PersonalReportOrchestrator.NoMatchCountMetadataKey); + var nextNoInputCount = ReadMetadataInt(session.Metadata, PersonalReportOrchestrator.NoInputCountMetadataKey); + var nextWeatherEnabled = + ReadMetadataBool(session.Metadata, PersonalReportOrchestrator.WeatherEnabledMetadataKey) ?? true; + var nextCalendarEnabled = + ReadMetadataBool(session.Metadata, PersonalReportOrchestrator.CalendarEnabledMetadataKey) ?? true; + var nextCommuteEnabled = + ReadMetadataBool(session.Metadata, PersonalReportOrchestrator.CommuteEnabledMetadataKey) ?? true; + var nextNewsEnabled = ReadMetadataBool(session.Metadata, PersonalReportOrchestrator.NewsEnabledMetadataKey) ?? + true; + var serviceError = ReadMetadataString(session.Metadata, PersonalReportOrchestrator.LastServiceErrorMetadataKey); + var nextChitchatState = ReadMetadataString(session.Metadata, ChitchatStateMachine.StateMetadataKey); + var nextChitchatRoute = ReadMetadataString(session.Metadata, ChitchatStateMachine.RouteMetadataKey); + var nextChitchatEmotion = ReadMetadataString(session.Metadata, ChitchatStateMachine.EmotionMetadataKey); + + if (!string.Equals(previousState, nextState, StringComparison.OrdinalIgnoreCase)) + { + if (!string.IsNullOrWhiteSpace(previousState) && + !string.Equals(previousState, PersonalReportOrchestrator.IdleState, StringComparison.OrdinalIgnoreCase)) + await sink.RecordTurnDiagnosticAsync("personal_report_state_exit", BuildTurnDiagnosticSnapshot(session, + envelope, new Dictionary + { + ["state"] = previousState, + ["nextState"] = nextState, + ["intent"] = intentName + }), cancellationToken); + + if (!string.IsNullOrWhiteSpace(nextState) && + !string.Equals(nextState, PersonalReportOrchestrator.IdleState, StringComparison.OrdinalIgnoreCase)) + await sink.RecordTurnDiagnosticAsync("personal_report_state_enter", BuildTurnDiagnosticSnapshot(session, + envelope, new Dictionary + { + ["state"] = nextState, + ["previousState"] = previousState, + ["intent"] = intentName + }), cancellationToken); + } + + if (nextNoMatchCount != previousNoMatchCount) + await sink.RecordTurnDiagnosticAsync("personal_report_nomatch_count", BuildTurnDiagnosticSnapshot(session, + envelope, new Dictionary + { + ["count"] = nextNoMatchCount, + ["previousCount"] = previousNoMatchCount, + ["state"] = nextState, + ["intent"] = intentName + }), cancellationToken); + + if (nextNoInputCount != previousNoInputCount) + await sink.RecordTurnDiagnosticAsync("personal_report_noinput_count", BuildTurnDiagnosticSnapshot(session, + envelope, new Dictionary + { + ["count"] = nextNoInputCount, + ["previousCount"] = previousNoInputCount, + ["state"] = nextState, + ["intent"] = intentName + }), cancellationToken); + + await EmitServiceToggleDiagnosticAsync("weather", previousWeatherEnabled, nextWeatherEnabled, session, envelope, + intentName, cancellationToken); + await EmitServiceToggleDiagnosticAsync("calendar", previousCalendarEnabled, nextCalendarEnabled, session, + envelope, intentName, cancellationToken); + await EmitServiceToggleDiagnosticAsync("commute", previousCommuteEnabled, nextCommuteEnabled, session, envelope, + intentName, cancellationToken); + await EmitServiceToggleDiagnosticAsync("news", previousNewsEnabled, nextNewsEnabled, session, envelope, + intentName, cancellationToken); + + if (!string.IsNullOrWhiteSpace(serviceError)) + await sink.RecordTurnDiagnosticAsync("personal_report_service_error", BuildTurnDiagnosticSnapshot(session, + envelope, new Dictionary + { + ["service"] = serviceError, + ["state"] = nextState, + ["intent"] = intentName + }), cancellationToken); + + if (!string.Equals(previousChitchatState, nextChitchatState, StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(nextChitchatState)) + await sink.RecordTurnDiagnosticAsync("chitchat_state_transition", BuildTurnDiagnosticSnapshot(session, + envelope, new Dictionary + { + ["previousState"] = previousChitchatState, + ["state"] = nextChitchatState, + ["intent"] = intentName + }), cancellationToken); + + if (!string.Equals(previousChitchatRoute, nextChitchatRoute, StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(nextChitchatRoute)) + await sink.RecordTurnDiagnosticAsync("chitchat_route_selected", BuildTurnDiagnosticSnapshot(session, + envelope, new Dictionary + { + ["route"] = nextChitchatRoute, + ["previousRoute"] = previousChitchatRoute, + ["emotion"] = nextChitchatEmotion, + ["previousEmotion"] = previousChitchatEmotion, + ["intent"] = intentName + }), cancellationToken); + } + + private async Task EmitServiceToggleDiagnosticAsync( + string service, + bool previousEnabled, + bool nextEnabled, + CloudSession session, + WebSocketMessageEnvelope envelope, + string? intentName, + CancellationToken cancellationToken) + { + if (previousEnabled == nextEnabled) return; + + await sink.RecordTurnDiagnosticAsync( + nextEnabled ? "personal_report_service_on" : "personal_report_service_off", + BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary + { + ["service"] = service, + ["enabled"] = nextEnabled, + ["intent"] = intentName + }), + cancellationToken); + } + + private static void UpdatePendingProactivityOffer(CloudSession session, string? intentName) + { + if (string.Equals(intentName, "proactive_offer_pizza_fact", StringComparison.OrdinalIgnoreCase)) + { + session.Metadata["pendingProactivityOffer"] = "pizza_fact"; + return; + } + + session.Metadata.Remove("pendingProactivityOffer"); + } + + private static IEnumerable ReadRules(TurnContext turn, string key) + { + if (!turn.Attributes.TryGetValue(key, out var value) || value is null) return []; + + return value switch + { + IReadOnlyList typed => typed, + IEnumerable strings => strings, + JsonElement { ValueKind: JsonValueKind.Array } json => json.EnumerateArray() + .Where(static item => item.ValueKind == JsonValueKind.String) + .Select(static item => item.GetString() ?? string.Empty), + _ => [] + }; + } + + private static string? ReadMetadataString(IDictionary metadata, string key) + { + if (!metadata.TryGetValue(key, out var value) || value is null) return null; + + return value switch + { + string text => string.IsNullOrWhiteSpace(text) ? null : text.Trim(), + JsonElement { ValueKind: JsonValueKind.String } json => json.GetString(), + _ => value.ToString() + }; + } + + private static int ReadMetadataInt(IDictionary metadata, string key) + { + if (!metadata.TryGetValue(key, out var value) || value is null) return 0; + + return value switch + { + int integer => integer, + long whole when whole <= int.MaxValue && whole >= int.MinValue => (int)whole, + string text when int.TryParse(text, out var parsed) => parsed, + JsonElement { ValueKind: JsonValueKind.Number } json when json.TryGetInt32(out var parsed) => parsed, + JsonElement json when json.ValueKind == JsonValueKind.String && + int.TryParse(json.GetString(), out var parsed) => parsed, + _ => 0 + }; + } + + private static bool? ReadMetadataBool(IDictionary metadata, string key) + { + if (!metadata.TryGetValue(key, out var value) || value is null) return null; + + return value switch + { + bool flag => flag, + string text when bool.TryParse(text, out var parsed) => parsed, + JsonElement { ValueKind: JsonValueKind.True } => true, + JsonElement { ValueKind: JsonValueKind.False } => false, + JsonElement json when json.ValueKind == JsonValueKind.String && + bool.TryParse(json.GetString(), out var parsed) => parsed, + _ => null + }; + } + + private static string NormalizeTranscript(string? transcript) + { + if (string.IsNullOrWhiteSpace(transcript)) return string.Empty; + + return TranscriptNormalizationRegex().Replace(transcript.Trim().ToLowerInvariant(), " ") + .Replace(" ", " ", StringComparison.Ordinal) + .Trim(); + } + + private static string? ReadMessageType(TurnContext turn) + { + return ReadAttribute(turn, "messageType"); + } + + private static string? ReadAttribute(TurnContext turn, string key) + { + return turn.Attributes.TryGetValue(key, out var value) + ? value?.ToString() + : null; + } + + private static bool ShouldIgnoreBlankAudioHotphraseTurn(TurnContext turn) + { + var transcript = NormalizeTranscript(turn.NormalizedTranscript ?? turn.RawTranscript); + if (transcript is not ("blank_audio" or "blank audio")) return false; + + return ReadRules(turn, "listenRules") + .Any(static rule => string.Equals(rule, "launch", StringComparison.OrdinalIgnoreCase)); + } + + private static bool ShouldIgnoreLateEmptyTurn(TurnContext turn, CloudSession session, string messageType) + { + if (messageType is not ("CLIENT_ASR" or "CLIENT_NLU")) return false; + + if (session.TurnState.AwaitingTurnCompletion || session.TurnState.BufferedAudioBytes > 0) return false; + + if (!string.IsNullOrWhiteSpace(turn.NormalizedTranscript) || + !string.IsNullOrWhiteSpace(turn.RawTranscript)) return false; + + var turnTransId = ReadAttribute(turn, "transID"); + return !string.IsNullOrWhiteSpace(turnTransId) && + string.Equals(turnTransId, session.LastTransId, StringComparison.Ordinal) && + !string.IsNullOrWhiteSpace(session.LastIntent); + } + + private static bool ShouldIgnoreCompletedWordOfDayTurn(TurnContext turn) + { + if (!string.IsNullOrWhiteSpace(turn.NormalizedTranscript) || + !string.IsNullOrWhiteSpace(turn.RawTranscript)) return false; + + return ReadRules(turn, "listenRules") + .Any(static rule => string.Equals(rule, "word-of-the-day/right_word", StringComparison.OrdinalIgnoreCase)); + } + + private static bool ShouldTreatBufferedHotphraseAsGreeting( + TurnContext turn, + WebSocketTurnState turnState, + bool allowFallbackOnMissingTranscript) + { + if (!allowFallbackOnMissingTranscript || !ReadBoolAttribute(turn, "listenHotphrase")) return false; + + if (!ReadRules(turn, "listenRules") + .Any(static rule => string.Equals(rule, "launch", StringComparison.OrdinalIgnoreCase))) + return false; + + if (!string.IsNullOrWhiteSpace(turn.NormalizedTranscript) || + !string.IsNullOrWhiteSpace(turn.RawTranscript)) return false; + + return turnState.BufferedAudioBytes >= AutoFinalizeMinBufferedAudioBytes; + } + + private static bool ShouldTreatEmptyHotphraseTurnAsGreeting(TurnContext turn) + { + if (!string.IsNullOrWhiteSpace(turn.NormalizedTranscript) || + !string.IsNullOrWhiteSpace(turn.RawTranscript)) return false; + + var messageType = ReadMessageType(turn); + if (messageType is not ("CLIENT_ASR" or "CLIENT_NLU")) return false; + + if (!ReadBoolAttribute(turn, "listenHotphrase")) return false; + + return ReadRules(turn, "listenRules") + .Any(static rule => string.Equals(rule, "launch", StringComparison.OrdinalIgnoreCase)); + } + + private static bool ShouldIgnoreInitialEmptyHotphraseTurn(TurnContext turn, WebSocketTurnState turnState) + { + if (!string.IsNullOrWhiteSpace(turn.NormalizedTranscript) || + !string.IsNullOrWhiteSpace(turn.RawTranscript)) return false; + + var messageType = ReadMessageType(turn); + if (messageType is not ("CLIENT_ASR" or "CLIENT_NLU")) return false; + + if (!ReadBoolAttribute(turn, "listenHotphrase")) return false; + + if (turnState.HotphraseEmptyTurnCount > 0) return false; + + return ReadRules(turn, "listenRules") + .Any(static rule => string.Equals(rule, "launch", StringComparison.OrdinalIgnoreCase)); + } + + private static bool ShouldDeferForLikelyContinuation( + TurnContext turn, + WebSocketTurnState turnState, + string messageType, + bool allowFallbackOnMissingTranscript, + out string reason) + { + reason = string.Empty; + if (!allowFallbackOnMissingTranscript || + !string.Equals(messageType, "AUTO_FINALIZE", StringComparison.OrdinalIgnoreCase)) + return false; + + if (!turnState.FirstAudioReceivedUtc.HasValue || + DateTimeOffset.UtcNow - turnState.FirstAudioReceivedUtc.Value >= AutoFinalizeContinuationDeferralMaxAge || + turnState.FinalizeAttemptCount >= AutoFinalizeContinuationDeferralMaxAttempts) + return false; + + var normalized = NormalizeTranscript(turn.NormalizedTranscript ?? turn.RawTranscript); + if (string.IsNullOrWhiteSpace(normalized)) return false; + + if (normalized is "my birthday" or "my birthday is") + { + reason = "birthday_set_incomplete"; + return true; + } + + if (normalized.StartsWith("my favorite ", StringComparison.Ordinal) || + normalized.StartsWith("my favourite ", StringComparison.Ordinal)) + { + var preferenceTail = normalized.StartsWith("my favourite ", StringComparison.Ordinal) + ? normalized["my favourite ".Length..].Trim() + : normalized["my favorite ".Length..].Trim(); + var missingCopula = !normalized.Contains(" is ", StringComparison.Ordinal) && + !normalized.Contains(" are ", StringComparison.Ordinal); + + if (normalized.EndsWith(" is", StringComparison.Ordinal) || + normalized.EndsWith(" are", StringComparison.Ordinal) || + (missingCopula && !LooksLikeBarePreferenceSet(preferenceTail))) + { + reason = "preference_set_incomplete"; + return true; + } + } + + if (normalized.StartsWith("what s my favorite", StringComparison.Ordinal) || + normalized.StartsWith("what is my favorite", StringComparison.Ordinal) || + normalized.StartsWith("what s my favourite", StringComparison.Ordinal) || + normalized.StartsWith("what is my favourite", StringComparison.Ordinal)) + if (normalized is "what s my favorite" or "what is my favorite" or "what s my favourite" + or "what is my favourite") + { + reason = "preference_recall_incomplete"; + return true; + } + + if (LooksLikeIncompleteAffinitySet(normalized)) + { + reason = "affinity_set_incomplete"; + return true; + } + + return false; + } + + private static bool LooksLikeIncompleteAffinitySet(string normalized) + { + return PegasusAffinityContinuationStems.Contains(normalized); + } + + private static bool LooksLikeBarePreferenceSet(string preferenceTail) + { + if (string.IsNullOrWhiteSpace(preferenceTail)) return false; + + var tokens = preferenceTail.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return tokens.Length >= 2; + } + + private static void ClearListenTracking(WebSocketTurnState turnState) + { + turnState.SawListen = false; + turnState.SawContext = false; + turnState.ListenOpenedUtc = null; + } + + private static void UpdateGlsmPhaseMarker(CloudSession session) + { + session.Metadata[GlsmPhaseMetadataKey] = ResolveGlsmPhase(session); + } + + private async Task TrackGlsmPhaseAsync( + CloudSession session, + WebSocketMessageEnvelope envelope, + string trigger, + CancellationToken cancellationToken) + { + var nextPhase = ResolveGlsmPhase(session); + var previousPhase = session.Metadata.TryGetValue(GlsmPhaseMetadataKey, out var rawPhase) + ? rawPhase?.ToString() + : null; + session.Metadata[GlsmPhaseMetadataKey] = nextPhase; + + if (string.Equals(previousPhase, nextPhase, StringComparison.OrdinalIgnoreCase)) return; + + try + { + await sink.RecordTurnDiagnosticAsync("glsm_phase_transition", BuildTurnDiagnosticSnapshot(session, envelope, + new Dictionary + { + ["trigger"] = trigger, + ["previousState"] = previousPhase, + ["state"] = nextPhase, + ["listenOpenedUtc"] = session.TurnState.ListenOpenedUtc, + ["followUpOpen"] = session.FollowUpOpen, + ["listenRules"] = session.TurnState.ListenRules + }), cancellationToken); + } + catch + { + // Diagnostics should not interrupt turn handling. + } + } + + private static Dictionary BuildTurnDiagnosticSnapshot( + CloudSession session, + WebSocketMessageEnvelope envelope, + Dictionary details) + { + details["sessionToken"] = session.Token; + details["hostName"] = envelope.HostName; + details["path"] = envelope.Path; + details["kind"] = envelope.Kind; + details["transID"] = session.TurnState.TransId ?? session.LastTransId; + details["lastMessageType"] = session.LastMessageType; + details["awaitingTurnCompletion"] = session.TurnState.AwaitingTurnCompletion; + details["bufferedAudioBytes"] = session.TurnState.BufferedAudioBytes; + details["bufferedAudioChunks"] = session.TurnState.BufferedAudioChunkCount; + details["sawListen"] = session.TurnState.SawListen; + details["sawContext"] = session.TurnState.SawContext; + details["glsmState"] = ResolveGlsmPhase(session); + return details; + } + + private static TurnContext WithSyntheticTranscript(TurnContext turn, string transcript) + { + var attributes = new Dictionary(turn.Attributes, StringComparer.OrdinalIgnoreCase) + { + ["syntheticTranscript"] = true + }; + + return new TurnContext + { + TurnId = turn.TurnId, + SessionId = turn.SessionId, + TimestampUtc = turn.TimestampUtc, + InputMode = turn.InputMode, + SourceKind = turn.SourceKind, + WakePhrase = turn.WakePhrase, + RawTranscript = transcript, + NormalizedTranscript = transcript, + DeviceId = turn.DeviceId, + HostName = turn.HostName, + RequestId = turn.RequestId, + ProtocolService = turn.ProtocolService, + ProtocolOperation = turn.ProtocolOperation, + FirmwareVersion = turn.FirmwareVersion, + ApplicationVersion = turn.ApplicationVersion, + Locale = turn.Locale, + TimeZone = turn.TimeZone, + IsFollowUpEligible = turn.IsFollowUpEligible, + Attributes = attributes + }; + } + + private static bool ReadBoolAttribute(TurnContext turn, string key) + { + if (!turn.Attributes.TryGetValue(key, out var value) || value is null) return false; + + return value switch + { + bool boolValue => boolValue, + JsonElement { ValueKind: JsonValueKind.True } => true, + JsonElement { ValueKind: JsonValueKind.False } => false, + _ when bool.TryParse(value.ToString(), out var parsed) => parsed, + _ => false + }; + } + [GeneratedRegex(@"[^\w\s]")] private static partial Regex TranscriptNormalizationRegex(); -} + + private enum YesNoReply + { + None = 0, + Affirmative = 1, + Negative = 2 + } +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/AccountProfile.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/AccountProfile.cs index 696dc24..8300218 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/AccountProfile.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/AccountProfile.cs @@ -8,4 +8,4 @@ public sealed class AccountProfile public string LastName { get; init; } = "Owner"; public string AccessKeyId { get; init; } = "openjibo-access-key"; public string SecretAccessKey { get; init; } = "openjibo-secret-access-key"; -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/BackupRecord.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/BackupRecord.cs index dc663be..d25ffe2 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/BackupRecord.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/BackupRecord.cs @@ -5,4 +5,4 @@ public sealed class BackupRecord public string BackupId { get; init; } = Guid.NewGuid().ToString("N"); public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow; public string Name { get; init; } = "backup"; -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/CapturedExchange.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/CapturedExchange.cs index 2a5ccf9..cb39ecb 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/CapturedExchange.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/CapturedExchange.cs @@ -7,5 +7,7 @@ public sealed class CapturedExchange public ProtocolEnvelope Request { get; init; } = new(); public ProtocolDispatchResult Response { get; init; } = ProtocolDispatchResult.Ok(); public string Confidence { get; init; } = "observed"; - public IDictionary Tags { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); -} + + public IDictionary Tags { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/CapturedWebSocketFixture.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/CapturedWebSocketFixture.cs index b167176..75c3c1e 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/CapturedWebSocketFixture.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/CapturedWebSocketFixture.cs @@ -22,4 +22,4 @@ public sealed class CapturedWebSocketFixtureStep public JsonElement? Text { get; init; } public IReadOnlyList? Binary { get; init; } public IReadOnlyList ExpectedReplyTypes { get; init; } = []; -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/CloudSession.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/CloudSession.cs index 8185464..d900bfc 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/CloudSession.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/CloudSession.cs @@ -20,4 +20,4 @@ public sealed class CloudSession public bool FollowUpOpen => FollowUpExpiresUtc.HasValue && FollowUpExpiresUtc > DateTimeOffset.UtcNow; public WebSocketTurnState TurnState { get; } = new(); public IDictionary Metadata { get; init; } = new Dictionary(); -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/DeviceRegistration.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/DeviceRegistration.cs index f19c5f4..69a02cf 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/DeviceRegistration.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/DeviceRegistration.cs @@ -8,5 +8,7 @@ public sealed class DeviceRegistration public string? FirmwareVersion { get; init; } public string? ApplicationVersion { get; init; } public bool IsActive { get; init; } = true; - public IDictionary HostMappings { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); -} + + public IDictionary HostMappings { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/KeyRequestRecord.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/KeyRequestRecord.cs index f8ce3d8..2a9a995 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/KeyRequestRecord.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/KeyRequestRecord.cs @@ -7,4 +7,4 @@ public sealed class KeyRequestRecord public string PublicKey { get; init; } = string.Empty; public string EncryptedKey { get; init; } = string.Empty; public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow; -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/LoopRecord.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/LoopRecord.cs index 51fbc84..8750c4a 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/LoopRecord.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/LoopRecord.cs @@ -10,4 +10,4 @@ public sealed class LoopRecord public bool IsSuspended { get; init; } public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedUtc { get; init; } = DateTimeOffset.UtcNow; -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/MediaRecord.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/MediaRecord.cs index 8514be7..09d08bb 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/MediaRecord.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/MediaRecord.cs @@ -12,4 +12,4 @@ public sealed class MediaRecord public bool IsEncrypted { get; init; } public bool IsDeleted { get; init; } public IDictionary Meta { get; init; } = new Dictionary(); -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/PersonRecord.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/PersonRecord.cs index 3da37d0..554a507 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/PersonRecord.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/PersonRecord.cs @@ -11,4 +11,4 @@ public sealed class PersonRecord public bool IsPrimary { get; init; } = true; public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedUtc { get; init; } = DateTimeOffset.UtcNow; -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/ProtocolDispatchResult.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/ProtocolDispatchResult.cs index de6877b..2429dc3 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/ProtocolDispatchResult.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/ProtocolDispatchResult.cs @@ -7,7 +7,9 @@ public sealed class ProtocolDispatchResult public int StatusCode { get; init; } = 200; public string ContentType { get; init; } = "application/x-amz-json-1.1"; public string BodyText { get; init; } = "{}"; - public IDictionary Headers { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public IDictionary Headers { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); public static ProtocolDispatchResult Ok(object? body = null) { @@ -37,4 +39,4 @@ public sealed class ProtocolDispatchResult ContentType = contentType }; } -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/ProtocolEnvelope.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/ProtocolEnvelope.cs index 4ad6e8e..c8da2cf 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/ProtocolEnvelope.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/ProtocolEnvelope.cs @@ -17,14 +17,13 @@ public sealed class ProtocolEnvelope public string? FirmwareVersion { get; init; } public string? ApplicationVersion { get; init; } public string BodyText { get; init; } = string.Empty; - public IDictionary Headers { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public IDictionary Headers { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); public JsonElement? TryParseBody() { - if (string.IsNullOrWhiteSpace(BodyText)) - { - return null; - } + if (string.IsNullOrWhiteSpace(BodyText)) return null; try { @@ -36,4 +35,4 @@ public sealed class ProtocolEnvelope return null; } } -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/ProtocolFixture.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/ProtocolFixture.cs index e514a89..d88d583 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/ProtocolFixture.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/ProtocolFixture.cs @@ -5,4 +5,4 @@ public sealed class ProtocolFixture public string Name { get; init; } = string.Empty; public ProtocolEnvelope Request { get; init; } = new(); public int ExpectedStatusCode { get; init; } = 200; -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/RobotProfile.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/RobotProfile.cs index 1c9aec8..590f812 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/RobotProfile.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/RobotProfile.cs @@ -7,4 +7,4 @@ public sealed class RobotProfile public IDictionary Payload { get; init; } = new Dictionary(); public IDictionary CalibrationPayload { get; init; } = new Dictionary(); public DateTimeOffset UpdatedUtc { get; init; } = DateTimeOffset.UtcNow; -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/UpdateManifest.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/UpdateManifest.cs index 0507823..8f2f80d 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/UpdateManifest.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/UpdateManifest.cs @@ -12,4 +12,4 @@ public sealed class UpdateManifest public long Length { get; init; } public string Subsystem { get; init; } = "robot"; public string? Filter { get; init; } -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/UploadReference.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/UploadReference.cs index e05b058..a973647 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/UploadReference.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/UploadReference.cs @@ -7,4 +7,4 @@ public sealed class UploadReference public string ContentType { get; init; } = "application/octet-stream"; public long Length { get; init; } public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow; -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/WebSocketMessageEnvelope.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/WebSocketMessageEnvelope.cs index 26a1281..9cb6281 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/WebSocketMessageEnvelope.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/WebSocketMessageEnvelope.cs @@ -10,4 +10,4 @@ public sealed class WebSocketMessageEnvelope public string? Text { get; init; } public byte[]? Binary { get; init; } public bool IsBinary => Binary is { Length: > 0 }; -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/WebSocketReply.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/WebSocketReply.cs index ff34a46..e3b4dba 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/WebSocketReply.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/WebSocketReply.cs @@ -5,4 +5,4 @@ public sealed class WebSocketReply public string? Text { get; init; } public int DelayMs { get; init; } public bool Close { get; init; } -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/WebSocketTelemetryRecord.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/WebSocketTelemetryRecord.cs index 6eb0779..115f2cc 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/WebSocketTelemetryRecord.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/WebSocketTelemetryRecord.cs @@ -20,5 +20,7 @@ public sealed class WebSocketTelemetryRecord public int BufferedAudioChunks { get; init; } public int FinalizeAttempts { get; init; } public bool AwaitingTurnCompletion { get; init; } - public IReadOnlyDictionary Details { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); -} + + public IReadOnlyDictionary Details { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/WebSocketTurnState.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/WebSocketTurnState.cs index 5110de7..bbf548e 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/WebSocketTurnState.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/WebSocketTurnState.cs @@ -27,4 +27,4 @@ public sealed class WebSocketTurnState public bool SawContext { get; set; } public IReadOnlyList ListenRules { get; set; } = []; public IReadOnlyList ListenAsrHints { get; set; } = []; -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Audio/BufferedAudioSttOptions.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Audio/BufferedAudioSttOptions.cs index 17e05ea..9332965 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Audio/BufferedAudioSttOptions.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Audio/BufferedAudioSttOptions.cs @@ -9,4 +9,4 @@ public sealed class BufferedAudioSttOptions public string WhisperLanguage { get; set; } = "en"; public string? TempDirectory { get; set; } public bool CleanupTempFiles { get; set; } -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Audio/ExternalProcessRunner.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Audio/ExternalProcessRunner.cs index 0ca37b3..930f9ba 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Audio/ExternalProcessRunner.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Audio/ExternalProcessRunner.cs @@ -4,7 +4,8 @@ namespace Jibo.Cloud.Infrastructure.Audio; public sealed class ExternalProcessRunner : IExternalProcessRunner { - public async Task RunAsync(string fileName, IReadOnlyList arguments, CancellationToken cancellationToken = default) + public async Task RunAsync(string fileName, IReadOnlyList arguments, + CancellationToken cancellationToken = default) { using var process = new Process(); process.StartInfo = new ProcessStartInfo @@ -16,10 +17,7 @@ public sealed class ExternalProcessRunner : IExternalProcessRunner CreateNoWindow = true }; - foreach (var argument in arguments) - { - process.StartInfo.ArgumentList.Add(argument); - } + foreach (var argument in arguments) process.StartInfo.ArgumentList.Add(argument); process.Start(); @@ -35,4 +33,4 @@ public sealed class ExternalProcessRunner : IExternalProcessRunner $"External process '{fileName}' failed with exit code {process.ExitCode}: {stdErr}") : new ExternalProcessResult(process.ExitCode, stdOut, stdErr); } -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Audio/IExternalProcessRunner.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Audio/IExternalProcessRunner.cs index e24b3a9..3342df3 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Audio/IExternalProcessRunner.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Audio/IExternalProcessRunner.cs @@ -2,7 +2,8 @@ namespace Jibo.Cloud.Infrastructure.Audio; public interface IExternalProcessRunner { - Task RunAsync(string fileName, IReadOnlyList arguments, CancellationToken cancellationToken = default); + Task RunAsync(string fileName, IReadOnlyList arguments, + CancellationToken cancellationToken = default); } -public sealed record ExternalProcessResult(int ExitCode, string StdOut, string StdErr); +public sealed record ExternalProcessResult(int ExitCode, string StdOut, string StdErr); \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Audio/LocalWhisperCppBufferedAudioSttStrategy.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Audio/LocalWhisperCppBufferedAudioSttStrategy.cs index 62bb89e..dd4078c 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Audio/LocalWhisperCppBufferedAudioSttStrategy.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Audio/LocalWhisperCppBufferedAudioSttStrategy.cs @@ -12,9 +12,9 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategy( public bool CanHandle(TurnContext turn) { return options.EnableLocalWhisperCpp && - IsConfiguredPathAvailable(options.FfmpegPath, checkFileExists: false) && - IsConfiguredPathAvailable(options.WhisperCliPath, checkFileExists: true) && - IsConfiguredPathAvailable(options.WhisperModelPath, checkFileExists: true) && + IsConfiguredPathAvailable(options.FfmpegPath, false) && + IsConfiguredPathAvailable(options.WhisperCliPath, true) && + IsConfiguredPathAvailable(options.WhisperModelPath, true) && ReadBufferedAudioFrames(turn).Any(ContainsOpusIdentificationHeader); } @@ -22,20 +22,14 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategy( { var frames = ReadBufferedAudioFrames(turn); if (frames.Count == 0) - { throw new InvalidOperationException("Local whisper.cpp STT requires buffered websocket audio frames."); - } if (!frames.Any(ContainsOpusIdentificationHeader)) - { - throw new InvalidOperationException("Local whisper.cpp STT requires buffered Ogg/Opus audio with an Opus identification header."); - } + throw new InvalidOperationException( + "Local whisper.cpp STT requires buffered Ogg/Opus audio with an Opus identification header."); var tempDirectory = options.TempDirectory; - if (string.IsNullOrWhiteSpace(tempDirectory)) - { - tempDirectory = Path.Combine(Path.GetTempPath(), "openjibo-stt"); - } + if (string.IsNullOrWhiteSpace(tempDirectory)) tempDirectory = Path.Combine(Path.GetTempPath(), "openjibo-stt"); Directory.CreateDirectory(tempDirectory); @@ -59,9 +53,7 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategy( var transcript = ExtractTranscript(whisperResult.StdOut); if (string.IsNullOrWhiteSpace(transcript)) - { throw new InvalidOperationException("whisper.cpp returned no transcript for the buffered audio turn."); - } return new SttResult { @@ -90,10 +82,7 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategy( private static IReadOnlyList ReadBufferedAudioFrames(TurnContext turn) { - if (!turn.Attributes.TryGetValue("bufferedAudioFrames", out var value) || value is null) - { - return []; - } + if (!turn.Attributes.TryGetValue("bufferedAudioFrames", out var value) || value is null) return []; return value switch { @@ -110,7 +99,8 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategy( private static int ReadBufferedAudioBytes(TurnContext turn) { - return turn.Attributes.TryGetValue("bufferedAudioBytes", out var bufferedAudioBytes) && bufferedAudioBytes is not null + return turn.Attributes.TryGetValue("bufferedAudioBytes", out var bufferedAudioBytes) && + bufferedAudioBytes is not null ? bufferedAudioBytes switch { int value => value, @@ -148,10 +138,7 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategy( { try { - if (File.Exists(path)) - { - File.Delete(path); - } + if (File.Exists(path)) File.Delete(path); } catch { @@ -161,16 +148,10 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategy( private static bool IsConfiguredPathAvailable(string? path, bool checkFileExists) { - if (string.IsNullOrWhiteSpace(path)) - { - return false; - } + if (string.IsNullOrWhiteSpace(path)) return false; - if (!Path.IsPathRooted(path)) - { - return true; - } + if (!Path.IsPathRooted(path)) return true; return !checkFileExists || File.Exists(path); } -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Audio/OggOpusAudioNormalizer.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Audio/OggOpusAudioNormalizer.cs index 539be6f..af5aaed 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Audio/OggOpusAudioNormalizer.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Audio/OggOpusAudioNormalizer.cs @@ -9,10 +9,7 @@ internal static class OggOpusAudioNormalizer public static byte[] Normalize(IReadOnlyList pages) { - if (pages.Count == 0) - { - return []; - } + if (pages.Count == 0) return []; var parsed = pages.Select(ParsePage).ToArray(); var baseGranule = parsed.Length > 1 ? parsed[1].GranulePosition : parsed[0].GranulePosition; @@ -50,26 +47,17 @@ internal static class OggOpusAudioNormalizer private static ParsedOggPage ParsePage(byte[] buffer) { if (buffer.Length < 27) - { throw new InvalidOperationException($"Buffered Ogg page is too short ({buffer.Length} bytes)."); - } if (!Encoding.ASCII.GetString(buffer, 0, 4).Equals("OggS", StringComparison.Ordinal)) - { throw new InvalidOperationException("Buffered audio frame did not begin with an OggS capture pattern."); - } var pageSegments = buffer[26]; if (buffer.Length < 27 + pageSegments) - { throw new InvalidOperationException("Buffered Ogg page segment table was truncated."); - } var payloadLength = 0; - for (var index = 0; index < pageSegments; index += 1) - { - payloadLength += buffer[27 + index]; - } + for (var index = 0; index < pageSegments; index += 1) payloadLength += buffer[27 + index]; var expectedLength = 27 + pageSegments + payloadLength; return buffer.Length < expectedLength @@ -79,7 +67,8 @@ internal static class OggOpusAudioNormalizer private static uint ComputeCrc(byte[] buffer) { - return buffer.Aggregate(0, (current, value) => (current << 8) ^ CrcTable[((current >> 24) ^ value) & 0xff]); + return buffer.Aggregate(0, + (current, value) => (current << 8) ^ CrcTable[((current >> 24) ^ value) & 0xff]); } private static uint[] BuildCrcTable() @@ -89,11 +78,9 @@ internal static class OggOpusAudioNormalizer { var remainder = index << 24; for (var bit = 0; bit < 8; bit += 1) - { remainder = (remainder & 0x80000000) != 0 ? (remainder << 1) ^ 0x04c11db7 : remainder << 1; - } table[index] = remainder; } @@ -102,4 +89,4 @@ internal static class OggOpusAudioNormalizer } private sealed record ParsedOggPage(ulong GranulePosition); -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Content/InMemoryJiboExperienceContentRepository.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Content/InMemoryJiboExperienceContentRepository.cs index 8f86c33..5364b8b 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Content/InMemoryJiboExperienceContentRepository.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Content/InMemoryJiboExperienceContentRepository.cs @@ -6,6 +6,11 @@ public sealed class InMemoryJiboExperienceContentRepository : IJiboExperienceCon { private static readonly JiboExperienceCatalog Catalog = BuildCatalog(); + public Task GetCatalogAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(Catalog); + } + private static JiboExperienceCatalog BuildCatalog() { var catalog = new JiboExperienceCatalog @@ -148,9 +153,7 @@ public sealed class InMemoryJiboExperienceContentRepository : IJiboExperienceCon }; foreach (var seedDirectory in ResolveSeedDirectories()) - { catalog = LegacyMimCatalogImporter.MergeInto(catalog, seedDirectory); - } return catalog; } @@ -211,9 +214,4 @@ public sealed class InMemoryJiboExperienceContentRepository : IJiboExperienceCon return candidates.Where(Directory.Exists).ToArray(); } - - public Task GetCatalogAsync(CancellationToken cancellationToken = default) - { - return Task.FromResult(Catalog); - } -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Content/LegacyMimCatalogImporter.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Content/LegacyMimCatalogImporter.cs index a7fada7..ae726e6 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Content/LegacyMimCatalogImporter.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Content/LegacyMimCatalogImporter.cs @@ -34,15 +34,9 @@ public static class LegacyMimCatalogImporter JiboExperienceCatalog baseCatalog, string? rootDirectory) { - if (baseCatalog is null) - { - throw new ArgumentNullException(nameof(baseCatalog)); - } + if (baseCatalog is null) throw new ArgumentNullException(nameof(baseCatalog)); - if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory)) - { - return baseCatalog; - } + if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory)) return baseCatalog; var importedCatalog = ImportCatalog(rootDirectory); return MergeCatalogs(baseCatalog, importedCatalog); @@ -51,32 +45,21 @@ public static class LegacyMimCatalogImporter public static JiboExperienceCatalog ImportCatalog(string rootDirectory) { if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory)) - { return new JiboExperienceCatalog(); - } var builder = new LegacyMimCatalogBuilder(); foreach (var filePath in Directory.EnumerateFiles(rootDirectory, "*.mim", SearchOption.AllDirectories) .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)) { - if (!TryLoadDefinition(filePath, out var definition)) - { - continue; - } + if (!TryLoadDefinition(filePath, out var definition)) continue; var bucket = ResolveBucket(filePath); - if (bucket is null) - { - continue; - } + if (bucket is null) continue; foreach (var prompt in definition.Prompts) { - var text = NormalizePrompt(prompt.Prompt, preservePlaceholders: IsTemplateBucket(bucket.Value)); - if (string.IsNullOrWhiteSpace(text)) - { - continue; - } + var text = NormalizePrompt(prompt.Prompt, IsTemplateBucket(bucket.Value)); + if (string.IsNullOrWhiteSpace(text)) continue; builder.Add(bucket.Value, prompt.Condition, text); } @@ -92,10 +75,7 @@ public static class LegacyMimCatalogImporter { var json = File.ReadAllText(filePath); var parsed = JsonSerializer.Deserialize(json, JsonOptions); - if (parsed is null) - { - return false; - } + if (parsed is null) return false; definition = parsed; return definition.Prompts.Count > 0; @@ -113,110 +93,67 @@ public static class LegacyMimCatalogImporter if (normalizedPath.Contains("/core-responses/", StringComparison.OrdinalIgnoreCase) && fileName.Contains("Error", StringComparison.OrdinalIgnoreCase)) - { return LegacyMimBucket.GenericFallback; - } if (normalizedPath.Contains("/core-responses/deflector/", StringComparison.OrdinalIgnoreCase) || fileName.Contains("Deflector", StringComparison.OrdinalIgnoreCase)) - { return LegacyMimBucket.Personality; - } if (normalizedPath.Contains("/emotion-responses/", StringComparison.OrdinalIgnoreCase) || normalizedPath.Contains("/gqa-responses/", StringComparison.OrdinalIgnoreCase)) - { return LegacyMimBucket.Emotion; - } if (fileName.StartsWith("WeatherIntroTomorrow", StringComparison.OrdinalIgnoreCase)) - { return LegacyMimBucket.WeatherTomorrowIntro; - } if (fileName.StartsWith("WeatherIntro", StringComparison.OrdinalIgnoreCase)) - { return LegacyMimBucket.WeatherIntro; - } if (fileName.StartsWith("WeatherTomorrowHighLow", StringComparison.OrdinalIgnoreCase)) - { return LegacyMimBucket.WeatherTomorrowHighLow; - } if (fileName.StartsWith("WeatherTodayHighLow", StringComparison.OrdinalIgnoreCase)) - { return LegacyMimBucket.WeatherTodayHighLow; - } if (fileName.StartsWith("WeatherServiceDown", StringComparison.OrdinalIgnoreCase)) - { return LegacyMimBucket.WeatherServiceDown; - } if (fileName.StartsWith("CalendarNothingToday", StringComparison.OrdinalIgnoreCase)) - { return LegacyMimBucket.CalendarNothingToday; - } if (fileName.StartsWith("CalendarNothing", StringComparison.OrdinalIgnoreCase)) - { return LegacyMimBucket.CalendarNothing; - } if (fileName.StartsWith("CalendarOutro", StringComparison.OrdinalIgnoreCase)) - { return LegacyMimBucket.CalendarOutro; - } - if (fileName.StartsWith("CommuteNow", StringComparison.OrdinalIgnoreCase)) - { - return LegacyMimBucket.CommuteNow; - } + if (fileName.StartsWith("CommuteNow", StringComparison.OrdinalIgnoreCase)) return LegacyMimBucket.CommuteNow; if (fileName.StartsWith("CommuteServiceDown", StringComparison.OrdinalIgnoreCase)) - { return LegacyMimBucket.CommuteServiceDown; - } if (fileName.StartsWith("NewsIntroCategory", StringComparison.OrdinalIgnoreCase)) - { return LegacyMimBucket.NewsCategoryIntro; - } - if (fileName.StartsWith("NewsIntro", StringComparison.OrdinalIgnoreCase)) - { - return LegacyMimBucket.NewsIntro; - } + if (fileName.StartsWith("NewsIntro", StringComparison.OrdinalIgnoreCase)) return LegacyMimBucket.NewsIntro; - if (fileName.StartsWith("NewsOutro", StringComparison.OrdinalIgnoreCase)) - { - return LegacyMimBucket.NewsOutro; - } + if (fileName.StartsWith("NewsOutro", StringComparison.OrdinalIgnoreCase)) return LegacyMimBucket.NewsOutro; if (fileName.StartsWith("Weather", StringComparison.OrdinalIgnoreCase) || string.Equals(fileName, "WetNowDryLater", StringComparison.OrdinalIgnoreCase)) - { return LegacyMimBucket.ReportSkillTemplate; - } if (fileName.StartsWith("PersonalReportKickOff", StringComparison.OrdinalIgnoreCase)) - { return LegacyMimBucket.PersonalReportKickOff; - } if (fileName.StartsWith("PersonalReportOutro", StringComparison.OrdinalIgnoreCase)) - { return LegacyMimBucket.PersonalReportOutro; - } if (fileName.StartsWith("PersonalReport", StringComparison.OrdinalIgnoreCase) || fileName.Contains("Calendar", StringComparison.OrdinalIgnoreCase) || fileName.Contains("Commute", StringComparison.OrdinalIgnoreCase) || fileName.Contains("News", StringComparison.OrdinalIgnoreCase)) - { return LegacyMimBucket.ReportSkillTemplate; - } if (fileName.StartsWith("JBO_DoYouLikeBeingJibo", StringComparison.OrdinalIgnoreCase) || fileName.StartsWith("JBO_WhatIsJibo", StringComparison.OrdinalIgnoreCase) || @@ -229,9 +166,7 @@ public static class LegacyMimCatalogImporter fileName.StartsWith("JBO_WhatsYourName", StringComparison.OrdinalIgnoreCase) || fileName.StartsWith("JBO_WhereDoYouGetInfo", StringComparison.OrdinalIgnoreCase) || fileName.StartsWith("JBO_WhatDoYouLikeToDo", StringComparison.OrdinalIgnoreCase)) - { return LegacyMimBucket.Personality; - } if (fileName.StartsWith("OI_JBO_Is", StringComparison.OrdinalIgnoreCase) || fileName.StartsWith("OI_JBO_Seems", StringComparison.OrdinalIgnoreCase) || @@ -239,42 +174,30 @@ public static class LegacyMimCatalogImporter fileName.StartsWith("RI_JBO_IsSad", StringComparison.OrdinalIgnoreCase) || fileName.StartsWith("RI_JBO_IsAngry", StringComparison.OrdinalIgnoreCase) || fileName.StartsWith("RN_WhatAreYouFeeling", StringComparison.OrdinalIgnoreCase)) - { return LegacyMimBucket.Emotion; - } if (fileName.Contains("Greeting", StringComparison.OrdinalIgnoreCase) || fileName.StartsWith("RN_", StringComparison.OrdinalIgnoreCase) || fileName.Contains("Welcome", StringComparison.OrdinalIgnoreCase)) - { return LegacyMimBucket.Greeting; - } if (normalizedPath.Contains("/scripted-responses/", StringComparison.OrdinalIgnoreCase)) - { return LegacyMimBucket.Personality; - } return null; } private static string NormalizePrompt(string? prompt) { - return NormalizePrompt(prompt, preservePlaceholders: false); + return NormalizePrompt(prompt, false); } private static string NormalizePrompt(string? prompt, bool preservePlaceholders) { - if (string.IsNullOrWhiteSpace(prompt)) - { - return string.Empty; - } + if (string.IsNullOrWhiteSpace(prompt)) return string.Empty; var text = WebUtility.HtmlDecode(prompt); - if (!preservePlaceholders) - { - text = PlaceholderPattern.Replace(text, " "); - } + if (!preservePlaceholders) text = PlaceholderPattern.Replace(text, " "); text = LegacyMarkupPattern.Replace(text, " "); text = WhitespacePattern.Replace(text, " ").Trim(); text = SpaceBeforePunctuationPattern.Replace(text, "$1"); @@ -298,21 +221,30 @@ public static class LegacyMimCatalogImporter PizzaReplies = Merge(baseCatalog.PizzaReplies, importedCatalog.PizzaReplies), SurpriseReplies = Merge(baseCatalog.SurpriseReplies, importedCatalog.SurpriseReplies), PersonalReportReplies = Merge(baseCatalog.PersonalReportReplies, importedCatalog.PersonalReportReplies), - PersonalReportKickOffReplies = Merge(baseCatalog.PersonalReportKickOffReplies, importedCatalog.PersonalReportKickOffReplies), - PersonalReportOutroReplies = Merge(baseCatalog.PersonalReportOutroReplies, importedCatalog.PersonalReportOutroReplies), + PersonalReportKickOffReplies = Merge(baseCatalog.PersonalReportKickOffReplies, + importedCatalog.PersonalReportKickOffReplies), + PersonalReportOutroReplies = Merge(baseCatalog.PersonalReportOutroReplies, + importedCatalog.PersonalReportOutroReplies), ReportSkillTemplates = Merge(baseCatalog.ReportSkillTemplates, importedCatalog.ReportSkillTemplates), WeatherIntroReplies = Merge(baseCatalog.WeatherIntroReplies, importedCatalog.WeatherIntroReplies), - WeatherTomorrowIntroReplies = Merge(baseCatalog.WeatherTomorrowIntroReplies, importedCatalog.WeatherTomorrowIntroReplies), - WeatherTodayHighLowReplies = Merge(baseCatalog.WeatherTodayHighLowReplies, importedCatalog.WeatherTodayHighLowReplies), - WeatherTomorrowHighLowReplies = Merge(baseCatalog.WeatherTomorrowHighLowReplies, importedCatalog.WeatherTomorrowHighLowReplies), - WeatherServiceDownReplies = Merge(baseCatalog.WeatherServiceDownReplies, importedCatalog.WeatherServiceDownReplies), - CalendarNothingTodayReplies = Merge(baseCatalog.CalendarNothingTodayReplies, importedCatalog.CalendarNothingTodayReplies), + WeatherTomorrowIntroReplies = Merge(baseCatalog.WeatherTomorrowIntroReplies, + importedCatalog.WeatherTomorrowIntroReplies), + WeatherTodayHighLowReplies = Merge(baseCatalog.WeatherTodayHighLowReplies, + importedCatalog.WeatherTodayHighLowReplies), + WeatherTomorrowHighLowReplies = Merge(baseCatalog.WeatherTomorrowHighLowReplies, + importedCatalog.WeatherTomorrowHighLowReplies), + WeatherServiceDownReplies = Merge(baseCatalog.WeatherServiceDownReplies, + importedCatalog.WeatherServiceDownReplies), + CalendarNothingTodayReplies = Merge(baseCatalog.CalendarNothingTodayReplies, + importedCatalog.CalendarNothingTodayReplies), CalendarNothingReplies = Merge(baseCatalog.CalendarNothingReplies, importedCatalog.CalendarNothingReplies), CalendarOutroReplies = Merge(baseCatalog.CalendarOutroReplies, importedCatalog.CalendarOutroReplies), CommuteNowReplies = Merge(baseCatalog.CommuteNowReplies, importedCatalog.CommuteNowReplies), - CommuteServiceDownReplies = Merge(baseCatalog.CommuteServiceDownReplies, importedCatalog.CommuteServiceDownReplies), + CommuteServiceDownReplies = Merge(baseCatalog.CommuteServiceDownReplies, + importedCatalog.CommuteServiceDownReplies), NewsIntroReplies = Merge(baseCatalog.NewsIntroReplies, importedCatalog.NewsIntroReplies), - NewsCategoryIntroReplies = Merge(baseCatalog.NewsCategoryIntroReplies, importedCatalog.NewsCategoryIntroReplies), + NewsCategoryIntroReplies = + Merge(baseCatalog.NewsCategoryIntroReplies, importedCatalog.NewsCategoryIntroReplies), NewsOutroReplies = Merge(baseCatalog.NewsOutroReplies, importedCatalog.NewsOutroReplies), WeatherReplies = Merge(baseCatalog.WeatherReplies, importedCatalog.WeatherReplies), CalendarReplies = Merge(baseCatalog.CalendarReplies, importedCatalog.CalendarReplies), @@ -332,16 +264,10 @@ public static class LegacyMimCatalogImporter foreach (var value in baseList.Concat(importedList)) { - if (string.IsNullOrWhiteSpace(value)) - { - continue; - } + if (string.IsNullOrWhiteSpace(value)) continue; var normalized = value.Trim(); - if (!seen.Add(normalized)) - { - continue; - } + if (!seen.Add(normalized)) continue; merged.Add(normalized); } @@ -358,18 +284,12 @@ public static class LegacyMimCatalogImporter foreach (var value in baseList.Concat(importedList)) { - if (string.IsNullOrWhiteSpace(value.Reply)) - { - continue; - } + if (string.IsNullOrWhiteSpace(value.Reply)) continue; var normalizedCondition = NormalizeCondition(value.Condition); var normalizedReply = value.Reply.Trim(); var key = $"{normalizedCondition}::{normalizedReply}"; - if (!seen.Add(key)) - { - continue; - } + if (!seen.Add(key)) continue; merged.Add(new JiboConditionedReply { @@ -381,6 +301,23 @@ public static class LegacyMimCatalogImporter return merged; } + private static string NormalizeCondition(string? condition) + { + return string.IsNullOrWhiteSpace(condition) ? string.Empty : WhitespacePattern.Replace(condition.Trim(), " "); + } + + private static bool IsTemplateBucket(LegacyMimBucket bucket) + { + return bucket is LegacyMimBucket.PersonalReportKickOff + or LegacyMimBucket.PersonalReportOutro + or LegacyMimBucket.WeatherIntro + or LegacyMimBucket.WeatherTomorrowIntro + or LegacyMimBucket.WeatherTodayHighLow + or LegacyMimBucket.WeatherTomorrowHighLow + or LegacyMimBucket.WeatherServiceDown + or LegacyMimBucket.ReportSkillTemplate; + } + private enum LegacyMimBucket { GenericFallback, @@ -408,64 +345,55 @@ public static class LegacyMimCatalogImporter private sealed class LegacyMimCatalogBuilder { + private readonly List _calendarNothingReplies = []; + private readonly List _calendarNothingTodayReplies = []; + private readonly List _calendarOutroReplies = []; + private readonly List _commuteNowReplies = []; + private readonly List _commuteServiceDownReplies = []; + private readonly List _emotionReplies = []; + private readonly List _fallbacks = []; private readonly List _greetings = []; private readonly List _howAreYous = []; - private readonly List _emotionReplies = []; + private readonly List _newsCategoryIntroReplies = []; + private readonly List _newsIntroReplies = []; + private readonly List _newsOutroReplies = []; private readonly List _personalities = []; - private readonly List _fallbacks = []; private readonly List _personalReportKickOffReplies = []; private readonly List _personalReportOutroReplies = []; private readonly List _reportSkillTemplates = []; private readonly List _weatherIntroReplies = []; - private readonly List _weatherTomorrowIntroReplies = []; + private readonly List _weatherServiceDownReplies = []; private readonly List _weatherTodayHighLowReplies = []; private readonly List _weatherTomorrowHighLowReplies = []; - private readonly List _weatherServiceDownReplies = []; - private readonly List _calendarNothingTodayReplies = []; - private readonly List _calendarNothingReplies = []; - private readonly List _calendarOutroReplies = []; - private readonly List _commuteNowReplies = []; - private readonly List _commuteServiceDownReplies = []; - private readonly List _newsIntroReplies = []; - private readonly List _newsCategoryIntroReplies = []; - private readonly List _newsOutroReplies = []; + private readonly List _weatherTomorrowIntroReplies = []; public void Add(LegacyMimBucket bucket, string? condition, string text) { switch (bucket) { case LegacyMimBucket.GenericFallback: - if (_fallbacks.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) - { - return; - } + if (_fallbacks.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) return; _fallbacks.Add(text); return; case LegacyMimBucket.Greeting: - if (_greetings.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) - { - return; - } + if (_greetings.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) return; _greetings.Add(text); return; case LegacyMimBucket.HowAreYou: if (_howAreYous.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) - { return; - } _howAreYous.Add(text); return; case LegacyMimBucket.Emotion: var normalizedCondition = NormalizeCondition(condition); if (_emotionReplies.Any(value => - string.Equals(NormalizeCondition(value.Condition), normalizedCondition, StringComparison.OrdinalIgnoreCase) && + string.Equals(NormalizeCondition(value.Condition), normalizedCondition, + StringComparison.OrdinalIgnoreCase) && string.Equals(value.Reply, text, StringComparison.OrdinalIgnoreCase))) - { return; - } _emotionReplies.Add(new JiboConditionedReply { @@ -475,9 +403,7 @@ public static class LegacyMimCatalogImporter return; case LegacyMimBucket.Personality: if (_personalities.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) - { return; - } _personalities.Add(text); return; @@ -550,8 +476,7 @@ public static class LegacyMimCatalogImporter WeatherTomorrowIntroReplies = [.. _weatherTomorrowIntroReplies], WeatherTodayHighLowReplies = [.. _weatherTodayHighLowReplies], WeatherTomorrowHighLowReplies = [.. _weatherTomorrowHighLowReplies], - WeatherServiceDownReplies = [.. _weatherServiceDownReplies] - , + WeatherServiceDownReplies = [.. _weatherServiceDownReplies], CalendarNothingTodayReplies = [.. _calendarNothingTodayReplies], CalendarNothingReplies = [.. _calendarNothingReplies], CalendarOutroReplies = [.. _calendarOutroReplies], @@ -565,10 +490,7 @@ public static class LegacyMimCatalogImporter private static void AddDistinct(List target, string text) { - if (target.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) - { - return; - } + if (target.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) return; target.Add(text); } @@ -576,62 +498,30 @@ public static class LegacyMimCatalogImporter private sealed class LegacyMimDefinition { - [JsonPropertyName("skill_id")] - public string? SkillId { get; init; } + [JsonPropertyName("skill_id")] public string? SkillId { get; init; } - [JsonPropertyName("mim_id")] - public string? MimId { get; init; } + [JsonPropertyName("mim_id")] public string? MimId { get; init; } - [JsonPropertyName("mim_type")] - public string? MimType { get; init; } + [JsonPropertyName("mim_type")] public string? MimType { get; init; } - [JsonPropertyName("prompts")] - public List Prompts { get; init; } = []; + [JsonPropertyName("prompts")] public List Prompts { get; init; } = []; } private sealed class LegacyMimPrompt { - [JsonPropertyName("mim_id")] - public string? MimId { get; init; } + [JsonPropertyName("mim_id")] public string? MimId { get; init; } - [JsonPropertyName("prompt_category")] - public string? PromptCategory { get; init; } + [JsonPropertyName("prompt_category")] public string? PromptCategory { get; init; } [JsonPropertyName("prompt_sub_category")] public string? PromptSubCategory { get; init; } - [JsonPropertyName("condition")] - public string? Condition { get; init; } + [JsonPropertyName("condition")] public string? Condition { get; init; } - [JsonPropertyName("prompt")] - public string? Prompt { get; init; } + [JsonPropertyName("prompt")] public string? Prompt { get; init; } - [JsonPropertyName("prompt_id")] - public string? PromptId { get; init; } + [JsonPropertyName("prompt_id")] public string? PromptId { get; init; } - [JsonPropertyName("weight")] - public double? Weight { get; init; } + [JsonPropertyName("weight")] public double? Weight { get; init; } } - - private static string NormalizeCondition(string? condition) - { - if (string.IsNullOrWhiteSpace(condition)) - { - return string.Empty; - } - - return WhitespacePattern.Replace(condition.Trim(), " "); - } - - private static bool IsTemplateBucket(LegacyMimBucket bucket) - { - return bucket is LegacyMimBucket.PersonalReportKickOff - or LegacyMimBucket.PersonalReportOutro - or LegacyMimBucket.WeatherIntro - or LegacyMimBucket.WeatherTomorrowIntro - or LegacyMimBucket.WeatherTodayHighLow - or LegacyMimBucket.WeatherTomorrowHighLow - or LegacyMimBucket.WeatherServiceDown - or LegacyMimBucket.ReportSkillTemplate; - } -} +} \ No newline at end of file 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 999a987..bfa07e1 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 @@ -7,14 +7,15 @@ using Jibo.Cloud.Infrastructure.Persistence; using Jibo.Cloud.Infrastructure.Telemetry; using Jibo.Cloud.Infrastructure.Weather; using Jibo.Runtime.Abstractions; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; namespace Jibo.Cloud.Infrastructure.DependencyInjection; public static class ServiceCollectionExtensions { - public static IServiceCollection AddOpenJiboCloud(this IServiceCollection services, IConfiguration? configuration = null) + public static IServiceCollection AddOpenJiboCloud(this IServiceCollection services, + IConfiguration? configuration = null) { var sttOptions = new BufferedAudioSttOptions(); if (configuration is not null) @@ -27,25 +28,16 @@ public static class ServiceCollectionExtensions var openWeatherOptions = new OpenWeatherOptions(); if (configuration is not null) - { configuration.GetSection("OpenJibo:Weather:OpenWeather").Bind(openWeatherOptions); - } if (string.IsNullOrWhiteSpace(openWeatherOptions.ApiKey)) - { openWeatherOptions.ApiKey = Environment.GetEnvironmentVariable("OPENWEATHER_API_KEY"); - } var newsApiOptions = new NewsApiOptions(); - if (configuration is not null) - { - configuration.GetSection("OpenJibo:News:NewsApi").Bind(newsApiOptions); - } + if (configuration is not null) configuration.GetSection("OpenJibo:News:NewsApi").Bind(newsApiOptions); if (string.IsNullOrWhiteSpace(newsApiOptions.ApiKey)) - { newsApiOptions.ApiKey = Environment.GetEnvironmentVariable("NEWSAPI_KEY"); - } services.AddSingleton(sttOptions); services.AddSingleton(openWeatherOptions); @@ -53,27 +45,32 @@ public static class ServiceCollectionExtensions services.AddHttpClient(); services.AddHttpClient(); var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"] - ?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json"); + ?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json"); var personalMemoryPersistencePath = configuration?["OpenJibo:PersonalMemory:PersistencePath"] - ?? Path.Combine(AppContext.BaseDirectory, "App_Data", "personal-memory.json"); + ?? Path.Combine(AppContext.BaseDirectory, "App_Data", + "personal-memory.json"); var stateBackendKind = ParseBackendKind(configuration?["OpenJibo:State:Backend"]); var personalMemoryBackendKind = ParseBackendKind(configuration?["OpenJibo:PersonalMemory:Backend"]); var stateConnectionString = configuration?["OpenJibo:State:ConnectionString"] - ?? Environment.GetEnvironmentVariable("OPENJIBO_STATE_STORAGE_CONNECTION_STRING") - ?? Environment.GetEnvironmentVariable("OPENJIBO_STATE_SQL_CONNECTION_STRING"); + ?? Environment.GetEnvironmentVariable("OPENJIBO_STATE_STORAGE_CONNECTION_STRING") + ?? Environment.GetEnvironmentVariable("OPENJIBO_STATE_SQL_CONNECTION_STRING"); var personalMemoryConnectionString = configuration?["OpenJibo:PersonalMemory:ConnectionString"] - ?? Environment.GetEnvironmentVariable("OPENJIBO_PERSONAL_MEMORY_STORAGE_CONNECTION_STRING") - ?? Environment.GetEnvironmentVariable("OPENJIBO_PERSONAL_MEMORY_SQL_CONNECTION_STRING"); + ?? Environment.GetEnvironmentVariable( + "OPENJIBO_PERSONAL_MEMORY_STORAGE_CONNECTION_STRING") + ?? Environment.GetEnvironmentVariable( + "OPENJIBO_PERSONAL_MEMORY_SQL_CONNECTION_STRING"); services.AddSingleton(); services.AddSingleton(provider => { var snapshotFactory = provider.GetRequiredService(); - return new InMemoryCloudStateStore(snapshotFactory.Create(statePersistencePath, stateBackendKind, "cloud-state", stateConnectionString)); + return new InMemoryCloudStateStore(snapshotFactory.Create(statePersistencePath, stateBackendKind, + "cloud-state", stateConnectionString)); }); services.AddSingleton(provider => { var snapshotFactory = provider.GetRequiredService(); - return new InMemoryPersonalMemoryStore(snapshotFactory.Create(personalMemoryPersistencePath, personalMemoryBackendKind, "personal-memory", personalMemoryConnectionString)); + return new InMemoryPersonalMemoryStore(snapshotFactory.Create(personalMemoryPersistencePath, + personalMemoryBackendKind, "personal-memory", personalMemoryConnectionString)); }); services.AddSingleton(); services.AddSingleton(); @@ -98,8 +95,8 @@ public static class ServiceCollectionExtensions private static PersistenceBackendKind ParseBackendKind(string? value) { - return Enum.TryParse(value, ignoreCase: true, out var backendKind) + return Enum.TryParse(value, true, out var backendKind) ? backendKind : PersistenceBackendKind.File; } -} +} \ No newline at end of file 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 ef51814..d650ddd 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 @@ -11,7 +11,23 @@ public sealed class NewsApiBriefingProvider( ILogger logger) : INewsBriefingProvider { - private readonly ConcurrentDictionary> briefingCache = new(StringComparer.OrdinalIgnoreCase); + private const int MaxHeadlines = 5; + private const int MaxCategories = 2; + private const string DefaultUserAgent = "OpenJiboCloud/1.0"; + + private static readonly HashSet SupportedCategories = new(StringComparer.OrdinalIgnoreCase) + { + "business", + "entertainment", + "general", + "health", + "science", + "sports", + "technology" + }; + + private readonly ConcurrentDictionary> _briefingCache = + new(StringComparer.OrdinalIgnoreCase); public async Task GetBriefingAsync( NewsBriefingRequest request, @@ -27,10 +43,7 @@ public sealed class NewsApiBriefingProvider( try { var categories = ResolveCategories(request.PreferredCategories).ToArray(); - if (categories.Length == 0) - { - categories = ["general"]; - } + if (categories.Length == 0) categories = ["general"]; var requestedHeadlineCount = Math.Clamp(request.MaxHeadlines, 1, MaxHeadlines); cacheKey = BuildCacheKey(categories, requestedHeadlineCount); @@ -39,7 +52,7 @@ public sealed class NewsApiBriefingProvider( 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}", @@ -57,25 +70,6 @@ public sealed class NewsApiBriefingProvider( string? failureEndpoint = null; string? failureErrorCode = null; - void CaptureFailure( - string status, - string? message, - int? statusCode, - Uri? endpoint, - string? errorCode = null) - { - if (!string.IsNullOrWhiteSpace(failureStatus)) - { - return; - } - - failureStatus = status; - failureMessage = message; - failureStatusCode = statusCode; - failureEndpoint = endpoint is null ? null : SanitizeEndpoint(endpoint); - failureErrorCode = errorCode; - } - foreach (var category in categories) { var uri = BuildTopHeadlinesUri(category, requestedHeadlineCount); @@ -86,7 +80,8 @@ public sealed class NewsApiBriefingProvider( var apiError = TryParseApiError(responseBody); CaptureFailure( "http_error", - apiError?.Message ?? $"Category '{category}' returned {(int)response.StatusCode} {response.ReasonPhrase}.", + apiError?.Message ?? + $"Category '{category}' returned {(int)response.StatusCode} {response.ReasonPhrase}.", (int)response.StatusCode, uri, apiError?.Code); @@ -136,10 +131,7 @@ public sealed class NewsApiBriefingProvider( foreach (var article in articles.EnumerateArray()) { var title = NormalizeHeadlineTitle(ReadString(article, "title")); - if (string.IsNullOrWhiteSpace(title) || !seenTitles.Add(title)) - { - continue; - } + if (string.IsNullOrWhiteSpace(title) || !seenTitles.Add(title)) continue; var summary = ReadString(article, "description"); var source = article.TryGetProperty("source", out var sourceNode) && @@ -154,8 +146,8 @@ public sealed class NewsApiBriefingProvider( var snapshot = new NewsBriefingSnapshot( headlines, "NewsAPI", - ProviderStatus: "success"); - SetCachedValue(briefingCache, cacheKey, snapshot, options.CacheTtlSeconds); + "success"); + SetCachedValue(_briefingCache, cacheKey, snapshot, options.CacheTtlSeconds); logger.LogInformation( "NewsAPI request succeeded. Categories={Categories} HeadlineCount={HeadlineCount}", string.Join(",", categories), @@ -171,22 +163,20 @@ public sealed class NewsApiBriefingProvider( "NewsAPI category lookup produced no headlines. Falling back to uncategorized top headlines. Categories={Categories}", string.Join(",", categories)); - var broadUri = BuildTopHeadlinesUri(category: null, requestedHeadlineCount); + var broadUri = BuildTopHeadlinesUri(null, requestedHeadlineCount); using var broadResponse = await SendGetAsync(broadUri, cancellationToken); if (broadResponse.IsSuccessStatusCode) { using var broadStream = await broadResponse.Content.ReadAsStreamAsync(cancellationToken); - using var broadDocument = await JsonDocument.ParseAsync(broadStream, cancellationToken: cancellationToken); + using var broadDocument = + await JsonDocument.ParseAsync(broadStream, cancellationToken: cancellationToken); if (broadDocument.RootElement.TryGetProperty("articles", out var broadArticles) && broadArticles.ValueKind == JsonValueKind.Array) { foreach (var article in broadArticles.EnumerateArray()) { var title = NormalizeHeadlineTitle(ReadString(article, "title")); - if (string.IsNullOrWhiteSpace(title) || !seenTitles.Add(title)) - { - continue; - } + if (string.IsNullOrWhiteSpace(title) || !seenTitles.Add(title)) continue; var summary = ReadString(article, "description"); var source = article.TryGetProperty("source", out var sourceNode) && @@ -196,10 +186,7 @@ public sealed class NewsApiBriefingProvider( var url = ReadString(article, "url"); headlines.Add(new NewsHeadline(title, summary, "general", source, url)); - if (headlines.Count >= requestedHeadlineCount) - { - break; - } + if (headlines.Count >= requestedHeadlineCount) break; } } else @@ -218,7 +205,8 @@ public sealed class NewsApiBriefingProvider( var apiError = TryParseApiError(fallbackBody); CaptureFailure( "http_error", - apiError?.Message ?? $"Uncategorized fallback returned {(int)broadResponse.StatusCode} {broadResponse.ReasonPhrase}.", + apiError?.Message ?? + $"Uncategorized fallback returned {(int)broadResponse.StatusCode} {broadResponse.ReasonPhrase}.", (int)broadResponse.StatusCode, broadUri, apiError?.Code); @@ -243,17 +231,15 @@ public sealed class NewsApiBriefingProvider( if (everythingResponse.IsSuccessStatusCode) { using var everythingStream = await everythingResponse.Content.ReadAsStreamAsync(cancellationToken); - using var everythingDocument = await JsonDocument.ParseAsync(everythingStream, cancellationToken: cancellationToken); + using var everythingDocument = + await JsonDocument.ParseAsync(everythingStream, cancellationToken: cancellationToken); if (everythingDocument.RootElement.TryGetProperty("articles", out var everythingArticles) && everythingArticles.ValueKind == JsonValueKind.Array) { foreach (var article in everythingArticles.EnumerateArray()) { var title = NormalizeHeadlineTitle(ReadString(article, "title")); - if (string.IsNullOrWhiteSpace(title) || !seenTitles.Add(title)) - { - continue; - } + if (string.IsNullOrWhiteSpace(title) || !seenTitles.Add(title)) continue; var summary = ReadString(article, "description"); var source = article.TryGetProperty("source", out var sourceNode) && @@ -263,10 +249,7 @@ public sealed class NewsApiBriefingProvider( var url = ReadString(article, "url"); headlines.Add(new NewsHeadline(title, summary, "general", source, url)); - if (headlines.Count >= requestedHeadlineCount) - { - break; - } + if (headlines.Count >= requestedHeadlineCount) break; } } else @@ -285,7 +268,8 @@ public sealed class NewsApiBriefingProvider( var apiError = TryParseApiError(everythingBody); CaptureFailure( "http_error", - apiError?.Message ?? $"Everything fallback returned {(int)everythingResponse.StatusCode} {everythingResponse.ReasonPhrase}.", + apiError?.Message ?? + $"Everything fallback returned {(int)everythingResponse.StatusCode} {everythingResponse.ReasonPhrase}.", (int)everythingResponse.StatusCode, everythingUri, apiError?.Code); @@ -302,14 +286,14 @@ public sealed class NewsApiBriefingProvider( if (headlines.Count == 0) { var emptySnapshot = new NewsBriefingSnapshot( - Array.Empty(), + [], "NewsAPI", - ProviderStatus: failureStatus ?? "empty", - ProviderMessage: failureMessage ?? "NewsAPI returned no usable headlines.", - ProviderHttpStatusCode: failureStatusCode, - ProviderEndpoint: failureEndpoint, - ProviderErrorCode: failureErrorCode); - SetCachedValue(briefingCache, cacheKey, emptySnapshot, options.FailureCacheTtlSeconds); + failureStatus ?? "empty", + failureMessage ?? "NewsAPI returned no usable headlines.", + failureStatusCode, + failureEndpoint, + failureErrorCode); + SetCachedValue(_briefingCache, cacheKey, emptySnapshot, options.FailureCacheTtlSeconds); logger.LogWarning( "NewsAPI returned no usable headlines. Categories={Categories} RequestedHeadlineCount={RequestedHeadlineCount}", string.Join(",", categories), @@ -320,14 +304,30 @@ public sealed class NewsApiBriefingProvider( var populatedSnapshot = new NewsBriefingSnapshot( headlines, "NewsAPI", - ProviderStatus: "success"); - SetCachedValue(briefingCache, cacheKey, populatedSnapshot, options.CacheTtlSeconds); + "success"); + 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; + + void CaptureFailure( + string status, + string? message, + int? statusCode, + Uri? endpoint, + string? errorCode = null) + { + if (!string.IsNullOrWhiteSpace(failureStatus)) return; + + failureStatus = status; + failureMessage = message; + failureStatusCode = statusCode; + failureEndpoint = endpoint is null ? null : SanitizeEndpoint(endpoint); + failureErrorCode = errorCode; + } } catch (Exception exception) { @@ -335,12 +335,10 @@ public sealed class NewsApiBriefingProvider( var exceptionSnapshot = new NewsBriefingSnapshot( Array.Empty(), "NewsAPI", - ProviderStatus: "exception", - ProviderMessage: exception.Message); + "exception", + exception.Message); if (!string.IsNullOrWhiteSpace(cacheKey)) - { - SetCachedValue(briefingCache, cacheKey, exceptionSnapshot, options.FailureCacheTtlSeconds); - } + SetCachedValue(_briefingCache, cacheKey, exceptionSnapshot, options.FailureCacheTtlSeconds); return exceptionSnapshot; } } @@ -368,10 +366,7 @@ public sealed class NewsApiBriefingProvider( .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); - if (requested.Length > 0) - { - return requested.Take(MaxCategories); - } + if (requested.Length > 0) return requested.Take(MaxCategories); return options.DefaultCategories .Where(category => !string.IsNullOrWhiteSpace(category)) @@ -390,10 +385,7 @@ public sealed class NewsApiBriefingProvider( ("pageSize", headlineCount.ToString()), ("apiKey", options.ApiKey!) }; - if (!string.IsNullOrWhiteSpace(category)) - { - queryParts.Add(("category", category)); - } + if (!string.IsNullOrWhiteSpace(category)) queryParts.Add(("category", category)); var query = string.Join( "&", @@ -428,10 +420,7 @@ public sealed class NewsApiBriefingProvider( try { var body = await response.Content.ReadAsStringAsync(cancellationToken); - if (string.IsNullOrWhiteSpace(body)) - { - return null; - } + if (string.IsNullOrWhiteSpace(body)) return null; const int maxLength = 400; return body.Length <= maxLength @@ -455,42 +444,27 @@ public sealed class NewsApiBriefingProvider( private static string? NormalizeHeadlineTitle(string? title) { - if (string.IsNullOrWhiteSpace(title)) - { - return null; - } + if (string.IsNullOrWhiteSpace(title)) return null; var trimmed = title.Trim(); var suffixIndex = trimmed.LastIndexOf(" - ", StringComparison.Ordinal); - if (suffixIndex > 30) - { - trimmed = trimmed[..suffixIndex].TrimEnd(); - } + if (suffixIndex > 30) trimmed = trimmed[..suffixIndex].TrimEnd(); return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; } private static ApiError? TryParseApiError(string? responseBody) { - if (string.IsNullOrWhiteSpace(responseBody)) - { - return null; - } + if (string.IsNullOrWhiteSpace(responseBody)) return null; try { using var document = JsonDocument.Parse(responseBody); - if (document.RootElement.ValueKind != JsonValueKind.Object) - { - return null; - } + if (document.RootElement.ValueKind != JsonValueKind.Object) return null; var code = ReadString(document.RootElement, "code"); var message = ReadString(document.RootElement, "message"); - if (string.IsNullOrWhiteSpace(code) && string.IsNullOrWhiteSpace(message)) - { - return null; - } + if (string.IsNullOrWhiteSpace(code) && string.IsNullOrWhiteSpace(message)) return null; return new ApiError(code, message); } @@ -503,10 +477,7 @@ public sealed class NewsApiBriefingProvider( private static string SanitizeEndpoint(Uri uri) { var path = uri.GetLeftPart(UriPartial.Path); - if (string.IsNullOrWhiteSpace(uri.Query)) - { - return path; - } + if (string.IsNullOrWhiteSpace(uri.Query)) return path; var filtered = uri.Query .TrimStart('?') @@ -532,10 +503,7 @@ public sealed class NewsApiBriefingProvider( out T value) { value = default!; - if (!cache.TryGetValue(key, out var entry)) - { - return false; - } + if (!cache.TryGetValue(key, out var entry)) return false; if (entry.ExpiresUtc > DateTimeOffset.UtcNow) { @@ -558,21 +526,7 @@ public sealed class NewsApiBriefingProvider( DateTimeOffset.UtcNow.AddSeconds(Math.Max(1, ttlSeconds))); } - private static readonly HashSet SupportedCategories = new(StringComparer.OrdinalIgnoreCase) - { - "business", - "entertainment", - "general", - "health", - "science", - "sports", - "technology" - }; - - 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); -} +} \ No newline at end of file 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 de70049..d637eb7 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 @@ -25,4 +25,4 @@ public sealed class NewsApiOptions public int CacheTtlSeconds { get; set; } = 300; public int FailureCacheTtlSeconds { get; set; } = 45; -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/AzureBlobSnapshotStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/AzureBlobSnapshotStore.cs index aec7c69..43c75f8 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/AzureBlobSnapshotStore.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/AzureBlobSnapshotStore.cs @@ -1,6 +1,5 @@ using System.Text.Json; using Azure.Storage.Blobs; -using Azure.Storage.Blobs.Models; namespace Jibo.Cloud.Infrastructure.Persistence; @@ -12,22 +11,22 @@ internal sealed class AzureBlobSnapshotStore : ISnapshotStore PropertyNameCaseInsensitive = true }; - private readonly BlobContainerClient _containerClient; private readonly string _blobName; - public AzureBlobSnapshotStore(string connectionString, string snapshotName, string containerName = "openjibo-snapshots") + private readonly BlobContainerClient _containerClient; + + public AzureBlobSnapshotStore(string connectionString, string snapshotName, + string containerName = "openjibo-snapshots") { if (string.IsNullOrWhiteSpace(connectionString)) - { throw new InvalidOperationException("Azure Blob persistence requires a storage connection string."); - } if (string.IsNullOrWhiteSpace(snapshotName)) - { - throw new ArgumentException("A snapshot name is required for Azure Blob persistence.", nameof(snapshotName)); - } + throw new ArgumentException("A snapshot name is required for Azure Blob persistence.", + nameof(snapshotName)); - _containerClient = new BlobContainerClient(connectionString, string.IsNullOrWhiteSpace(containerName) ? "openjibo-snapshots" : containerName); + _containerClient = new BlobContainerClient(connectionString, + string.IsNullOrWhiteSpace(containerName) ? "openjibo-snapshots" : containerName); _blobName = $"{snapshotName}.json"; } @@ -35,34 +34,28 @@ internal sealed class AzureBlobSnapshotStore : ISnapshotStore { try { - if (!_containerClient.Exists()) - { - return default; - } + if (!_containerClient.Exists()) return null; var blobClient = _containerClient.GetBlobClient(_blobName); - if (!blobClient.Exists()) - { - return default; - } + if (!blobClient.Exists()) return null; var content = blobClient.DownloadContent(); var json = content.Value.Content.ToString(); return string.IsNullOrWhiteSpace(json) - ? default + ? null : JsonSerializer.Deserialize(json, JsonOptions); } catch { - return default; + return null; } } public void Save(TSnapshot snapshot) where TSnapshot : class { - _containerClient.CreateIfNotExists(PublicAccessType.None); + _containerClient.CreateIfNotExists(); var blobClient = _containerClient.GetBlobClient(_blobName); var json = JsonSerializer.Serialize(snapshot, JsonOptions); - blobClient.Upload(BinaryData.FromString(json), overwrite: true); + blobClient.Upload(BinaryData.FromString(json), true); } -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/AzureSqlSnapshotStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/AzureSqlSnapshotStore.cs index 75f1c96..e7e02c7 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/AzureSqlSnapshotStore.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/AzureSqlSnapshotStore.cs @@ -16,13 +16,15 @@ internal sealed class AzureSqlSnapshotStore : ISnapshotStore private readonly string _snapshotName; private readonly string _tableName; - public AzureSqlSnapshotStore(string connectionString, string snapshotName, string tableName = "PersistenceSnapshots") + public AzureSqlSnapshotStore(string connectionString, string snapshotName, + string tableName = "PersistenceSnapshots") { _connectionString = string.IsNullOrWhiteSpace(connectionString) ? throw new InvalidOperationException("Azure SQL persistence requires a connection string.") : connectionString; _snapshotName = string.IsNullOrWhiteSpace(snapshotName) - ? throw new ArgumentException("A snapshot name is required for Azure SQL persistence.", nameof(snapshotName)) + ? throw new ArgumentException("A snapshot name is required for Azure SQL persistence.", + nameof(snapshotName)) : snapshotName; _tableName = string.IsNullOrWhiteSpace(tableName) ? "PersistenceSnapshots" : tableName; } @@ -35,17 +37,14 @@ internal sealed class AzureSqlSnapshotStore : ISnapshotStore using var command = connection.CreateCommand(); command.CommandText = $""" - SELECT SnapshotJson - FROM dbo.[{_tableName}] - WHERE SnapshotName = @snapshotName - """; + SELECT SnapshotJson + FROM dbo.[{_tableName}] + WHERE SnapshotName = @snapshotName + """; command.Parameters.Add(new SqlParameter("@snapshotName", SqlDbType.NVarChar, 200) { Value = _snapshotName }); var result = command.ExecuteScalar(); - if (result is not string json || string.IsNullOrWhiteSpace(json)) - { - return default; - } + if (result is not string json || string.IsNullOrWhiteSpace(json)) return null; try { @@ -53,7 +52,7 @@ internal sealed class AzureSqlSnapshotStore : ISnapshotStore } catch { - return default; + return null; } } @@ -67,16 +66,16 @@ internal sealed class AzureSqlSnapshotStore : ISnapshotStore using var command = connection.CreateCommand(); command.CommandText = $""" - MERGE dbo.[{_tableName}] AS target - USING (SELECT @snapshotName AS SnapshotName) AS source - ON target.SnapshotName = source.SnapshotName - WHEN MATCHED THEN - UPDATE SET SnapshotJson = @snapshotJson, - UpdatedUtc = SYSUTCDATETIME() - WHEN NOT MATCHED THEN - INSERT (SnapshotName, SnapshotJson, CreatedUtc, UpdatedUtc) - VALUES (@snapshotName, @snapshotJson, SYSUTCDATETIME(), SYSUTCDATETIME()); - """; + MERGE dbo.[{_tableName}] AS target + USING (SELECT @snapshotName AS SnapshotName) AS source + ON target.SnapshotName = source.SnapshotName + WHEN MATCHED THEN + UPDATE SET SnapshotJson = @snapshotJson, + UpdatedUtc = SYSUTCDATETIME() + WHEN NOT MATCHED THEN + INSERT (SnapshotName, SnapshotJson, CreatedUtc, UpdatedUtc) + VALUES (@snapshotName, @snapshotJson, SYSUTCDATETIME(), SYSUTCDATETIME()); + """; command.Parameters.Add(new SqlParameter("@snapshotName", SqlDbType.NVarChar, 200) { Value = _snapshotName }); command.Parameters.Add(new SqlParameter("@snapshotJson", SqlDbType.NVarChar, -1) { Value = json }); command.ExecuteNonQuery(); @@ -86,16 +85,16 @@ internal sealed class AzureSqlSnapshotStore : ISnapshotStore { using var command = connection.CreateCommand(); command.CommandText = $""" - IF OBJECT_ID(N'dbo.[{_tableName}]', N'U') IS NULL - BEGIN - CREATE TABLE dbo.[{_tableName}] ( - SnapshotName nvarchar(200) NOT NULL CONSTRAINT PK_{_tableName}_SnapshotName PRIMARY KEY, - SnapshotJson nvarchar(max) NOT NULL, - CreatedUtc datetimeoffset NOT NULL, - UpdatedUtc datetimeoffset NOT NULL - ); - END - """; + IF OBJECT_ID(N'dbo.[{_tableName}]', N'U') IS NULL + BEGIN + CREATE TABLE dbo.[{_tableName}] ( + SnapshotName nvarchar(200) NOT NULL CONSTRAINT PK_{_tableName}_SnapshotName PRIMARY KEY, + SnapshotJson nvarchar(max) NOT NULL, + CreatedUtc datetimeoffset NOT NULL, + UpdatedUtc datetimeoffset NOT NULL + ); + END + """; command.ExecuteNonQuery(); } -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/IPersistenceSnapshotStoreFactory.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/IPersistenceSnapshotStoreFactory.cs index 2811651..1e9e99f 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/IPersistenceSnapshotStoreFactory.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/IPersistenceSnapshotStoreFactory.cs @@ -2,5 +2,6 @@ namespace Jibo.Cloud.Infrastructure.Persistence; public interface IPersistenceSnapshotStoreFactory { - ISnapshotStore Create(string? persistencePath, PersistenceBackendKind backendKind, string snapshotName, string? connectionString = null); -} + ISnapshotStore Create(string? persistencePath, PersistenceBackendKind backendKind, string snapshotName, + string? connectionString = null); +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/ISnapshotStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/ISnapshotStore.cs index af312c9..49db7ff 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/ISnapshotStore.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/ISnapshotStore.cs @@ -4,4 +4,4 @@ public interface ISnapshotStore { TSnapshot? Load() where TSnapshot : class; void Save(TSnapshot snapshot) where TSnapshot : class; -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryCloudStateStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryCloudStateStore.cs index f0c4737..683d411 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryCloudStateStore.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryCloudStateStore.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Text; using System.Text.Json; using Jibo.Cloud.Application.Abstractions; using Jibo.Cloud.Domain.Models; @@ -7,29 +8,38 @@ namespace Jibo.Cloud.Infrastructure.Persistence; public sealed class InMemoryCloudStateStore : ICloudStateStore { + private const string CurrentSchemaVersion = "1"; + private static readonly JsonSerializerOptions PersistenceJsonOptions = new() { WriteIndented = true, PropertyNameCaseInsensitive = true }; - private AccountProfile _account = new(); + private readonly List _backups = []; private readonly ConcurrentDictionary _devices = new(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary _sessionsByToken = new(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary _symmetricKeys = new(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary _keyRequests = new(StringComparer.OrdinalIgnoreCase); + + private readonly ConcurrentDictionary + _keyRequests = new(StringComparer.OrdinalIgnoreCase); + + private readonly List _loops; + private readonly List _media = []; + private readonly List _people; + + private readonly ConcurrentDictionary + _sessionsByToken = new(StringComparer.OrdinalIgnoreCase); + private readonly ISnapshotStore _snapshotStore; + private readonly ConcurrentDictionary _symmetricKeys = new(StringComparer.OrdinalIgnoreCase); private readonly Lock _syncRoot = new(); private readonly List _updates; - private readonly List _media = []; - private readonly List _backups = []; - private readonly List _loops; - private readonly List _people; - private DeviceRegistration _robot; - private RobotProfile _robotProfile; - private long _revision; + + private AccountProfile _account = new(); private DateTimeOffset? _lastLoadedUtc; private DateTimeOffset? _lastSavedUtc; + private long _revision; + private DeviceRegistration _robot; + private RobotProfile _robotProfile; public InMemoryCloudStateStore(string? persistencePath = null) : this(new JsonFileSnapshotStore(persistencePath, PersistenceJsonOptions)) @@ -101,55 +111,37 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore public PersistenceStateInfo GetPersistenceStateInfo() { return new PersistenceStateInfo( - SchemaVersion: CurrentSchemaVersion, - Revision: Interlocked.Read(ref _revision), - LastLoadedUtc: _lastLoadedUtc, - LastSavedUtc: _lastSavedUtc); + CurrentSchemaVersion, + Interlocked.Read(ref _revision), + _lastLoadedUtc, + _lastSavedUtc); } public void LoadPersistedState() { var snapshot = _snapshotStore.Load(); - if (snapshot is null) - { - return; - } + if (snapshot is null) return; _account = snapshot.Account ?? _account; _robot = snapshot.Robot ?? _robot; _robotProfile = snapshot.RobotProfile ?? _robotProfile; _devices.Clear(); - foreach (var device in snapshot.Devices ?? []) - { - _devices[device.DeviceId] = device; - } + foreach (var device in snapshot.Devices ?? []) _devices[device.DeviceId] = device; - if (_devices.IsEmpty || !_devices.ContainsKey(_robot.DeviceId)) - { - _devices[_robot.DeviceId] = _robot; - } + if (_devices.IsEmpty || !_devices.ContainsKey(_robot.DeviceId)) _devices[_robot.DeviceId] = _robot; _sessionsByToken.Clear(); foreach (var session in snapshot.Sessions ?? []) - { if (!string.IsNullOrWhiteSpace(session.Token)) - { _sessionsByToken[session.Token] = session.ToRecord(); - } - } _symmetricKeys.Clear(); foreach (var pair in snapshot.SymmetricKeys ?? new Dictionary(StringComparer.OrdinalIgnoreCase)) - { _symmetricKeys[pair.Key] = pair.Value; - } _keyRequests.Clear(); - foreach (var keyRequest in snapshot.KeyRequests ?? []) - { - _keyRequests[keyRequest.RequestId] = keyRequest; - } + foreach (var keyRequest in snapshot.KeyRequests ?? []) _keyRequests[keyRequest.RequestId] = keyRequest; _updates.Clear(); _updates.AddRange(snapshot.Updates ?? []); @@ -166,8 +158,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore _people.Clear(); _people.AddRange(snapshot.People ?? []); - if (_robotProfile is null || !string.Equals(_robotProfile.RobotId, _robot.RobotId, StringComparison.OrdinalIgnoreCase)) - { + if (_robotProfile is null || + !string.Equals(_robotProfile.RobotId, _robot.RobotId, StringComparison.OrdinalIgnoreCase)) _robotProfile = new RobotProfile { RobotId = _robot.RobotId, @@ -180,7 +172,6 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore }, UpdatedUtc = DateTimeOffset.UtcNow }; - } Interlocked.Exchange(ref _revision, snapshot.Revision); _lastLoadedUtc = snapshot.LastLoadedUtc ?? DateTimeOffset.UtcNow; @@ -203,7 +194,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore RobotProfile = _robotProfile, Devices = _devices.Values.ToArray(), Sessions = _sessionsByToken.Values.Select(MapSessionSnapshot).ToArray(), - SymmetricKeys = _symmetricKeys.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase), + SymmetricKeys = _symmetricKeys.ToDictionary(entry => entry.Key, entry => entry.Value, + StringComparer.OrdinalIgnoreCase), KeyRequests = _keyRequests.Values.ToArray(), Updates = _updates.ToArray(), Media = _media.ToArray(), @@ -216,11 +208,20 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore } } - public AccountProfile GetAccount() => _account; + public AccountProfile GetAccount() + { + return _account; + } - public DeviceRegistration GetRobot() => _robot; + public DeviceRegistration GetRobot() + { + return _robot; + } - public RobotProfile GetRobotProfile() => _robotProfile; + public RobotProfile GetRobotProfile() + { + return _robotProfile; + } public DeviceRegistration GetOrCreateDevice(string deviceId, string? firmwareVersion, string? applicationVersion) { @@ -309,14 +310,21 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore return _sessionsByToken.GetValueOrDefault(token); } - public IReadOnlyList GetLoops() => _loops.ToArray(); + public IReadOnlyList GetLoops() + { + return _loops.ToArray(); + } - public IReadOnlyList GetPeople() => _people.ToArray(); + public IReadOnlyList GetPeople() + { + return _people.ToArray(); + } public IReadOnlyList ListUpdates(string? subsystem = null, string? filter = null) { return _updates - .Where(update => subsystem is null || update.Subsystem.Equals(subsystem, StringComparison.OrdinalIgnoreCase)) + .Where(update => + subsystem is null || update.Subsystem.Equals(subsystem, StringComparison.OrdinalIgnoreCase)) .Where(update => filter is null || string.Equals(update.Filter, filter, StringComparison.OrdinalIgnoreCase)) .ToArray(); } @@ -324,10 +332,12 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore public UpdateManifest? GetUpdateFrom(string? subsystem, string? fromVersion, string? filter) { return ListUpdates(subsystem, filter) - .FirstOrDefault(update => fromVersion is null || update.FromVersion.Equals(fromVersion, StringComparison.OrdinalIgnoreCase)); + .FirstOrDefault(update => + fromVersion is null || update.FromVersion.Equals(fromVersion, StringComparison.OrdinalIgnoreCase)); } - public UpdateManifest CreateUpdate(string? fromVersion, string? toVersion, string? changes, string? shaHash, long? length, string? subsystem, string? filter, IDictionary? dependencies) + public UpdateManifest CreateUpdate(string? fromVersion, string? toVersion, string? changes, string? shaHash, + long? length, string? subsystem, string? filter, IDictionary? dependencies) { var update = new UpdateManifest { @@ -351,7 +361,6 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore { var existing = _updates.FirstOrDefault(update => update.UpdateId == updateId); if (existing is null) - { return new UpdateManifest { UpdateId = updateId ?? "unknown-update", @@ -360,14 +369,14 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore ShaHash = "missing", Subsystem = "unknown" }; - } _updates.Remove(existing); TouchState(); return existing; } - public IReadOnlyList ListMedia(IReadOnlyList? loopIds = null, long? after = null, long? before = null) + public IReadOnlyList ListMedia(IReadOnlyList? loopIds = null, long? after = null, + long? before = null) { return _media .Where(item => !item.IsDeleted) @@ -387,10 +396,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore var replacements = new List(); for (var i = 0; i < _media.Count; i++) { - if (!paths.Contains(_media[i].Path)) - { - continue; - } + if (!paths.Contains(_media[i].Path)) continue; var updated = new MediaRecord { @@ -410,15 +416,13 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore replacements.Add(updated); } - if (replacements.Count > 0) - { - TouchState(); - } + if (replacements.Count > 0) TouchState(); return replacements; } - public MediaRecord CreateMedia(string loopId, string path, string type, string reference, bool isEncrypted, IDictionary? meta) + public MediaRecord CreateMedia(string loopId, string path, string type, string reference, bool isEncrypted, + IDictionary? meta) { var item = new MediaRecord { @@ -432,32 +436,32 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore Meta = meta ?? new Dictionary() }; - var existingIndex = _media.FindIndex(existing => existing.Path.Equals(path, StringComparison.OrdinalIgnoreCase)); + var existingIndex = + _media.FindIndex(existing => existing.Path.Equals(path, StringComparison.OrdinalIgnoreCase)); if (existingIndex >= 0) - { _media[existingIndex] = item; - } else - { _media.Add(item); - } TouchState(); return item; } - public IReadOnlyList GetBackups() => _backups.ToArray(); + public IReadOnlyList GetBackups() + { + return _backups.ToArray(); + } - public bool ShouldCreateSymmetricKey(string loopId) => !_symmetricKeys.ContainsKey(loopId); + public bool ShouldCreateSymmetricKey(string loopId) + { + return !_symmetricKeys.ContainsKey(loopId); + } public string GetOrCreateSymmetricKey(string loopId) { - if (_symmetricKeys.TryGetValue(loopId, out var existing)) - { - return existing; - } + if (_symmetricKeys.TryGetValue(loopId, out var existing)) return existing; - var key = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"open-jibo-symmetric-key:{loopId}")); + var key = Convert.ToBase64String(Encoding.UTF8.GetBytes($"open-jibo-symmetric-key:{loopId}")); if (_symmetricKeys.TryAdd(loopId, key)) { TouchState(); @@ -483,10 +487,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore public KeyRequestRecord GetKeyRequest(string loopId, string? requestId, string? publicKey) { - if (!string.IsNullOrWhiteSpace(requestId) && _keyRequests.TryGetValue(requestId, out var record)) - { - return record; - } + if (!string.IsNullOrWhiteSpace(requestId) && _keyRequests.TryGetValue(requestId, out var record)) return record; return new KeyRequestRecord { @@ -496,9 +497,15 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore }; } - public IReadOnlyList GetIncomingKeyRequests() => []; + public IReadOnlyList GetIncomingKeyRequests() + { + return []; + } - public IReadOnlyList GetBinaryRequests() => []; + public IReadOnlyList GetBinaryRequests() + { + return []; + } public IReadOnlyList GetHolidays() { @@ -548,7 +555,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore private static string ResolveDefaultLoopId(IReadOnlyList loops, AccountProfile account) { - return loops.FirstOrDefault(loop => string.Equals(loop.OwnerAccountId, account.AccountId, StringComparison.OrdinalIgnoreCase))?.LoopId + return loops.FirstOrDefault(loop => + string.Equals(loop.OwnerAccountId, account.AccountId, StringComparison.OrdinalIgnoreCase))?.LoopId ?? loops.FirstOrDefault()?.LoopId ?? "openjibo-default-loop"; } @@ -591,8 +599,6 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore }; } - private const string CurrentSchemaVersion = "1"; - private sealed class PersistentStateSnapshot { public string SchemaVersion { get; init; } = CurrentSchemaVersion; @@ -655,4 +661,4 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore }; } } -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryPersonalMemoryStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryPersonalMemoryStore.cs index ccc6dd9..7b74424 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryPersonalMemoryStore.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryPersonalMemoryStore.cs @@ -6,18 +6,23 @@ namespace Jibo.Cloud.Infrastructure.Persistence; public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore { + private const string CurrentSchemaVersion = "1"; + private static readonly JsonSerializerOptions PersistenceJsonOptions = new() { WriteIndented = true, PropertyNameCaseInsensitive = true }; - private readonly ConcurrentDictionary _tenantMemory = new(StringComparer.OrdinalIgnoreCase); private readonly ISnapshotStore _snapshotStore; private readonly Lock _syncRoot = new(); - private long _revision; + + private readonly ConcurrentDictionary _tenantMemory = + new(StringComparer.OrdinalIgnoreCase); + private DateTimeOffset? _lastLoadedUtc; private DateTimeOffset? _lastSavedUtc; + private long _revision; public InMemoryPersonalMemoryStore(string? persistencePath = null) : this(new JsonFileSnapshotStore(persistencePath, PersistenceJsonOptions)) @@ -33,25 +38,19 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore public PersistenceStateInfo GetPersistenceStateInfo() { return new PersistenceStateInfo( - SchemaVersion: CurrentSchemaVersion, - Revision: Interlocked.Read(ref _revision), - LastLoadedUtc: _lastLoadedUtc, - LastSavedUtc: _lastSavedUtc); + CurrentSchemaVersion, + Interlocked.Read(ref _revision), + _lastLoadedUtc, + _lastSavedUtc); } public void LoadPersistedState() { var snapshot = _snapshotStore.Load(); - if (snapshot is null) - { - return; - } + if (snapshot is null) return; _tenantMemory.Clear(); - foreach (var tenant in snapshot.Tenants ?? []) - { - _tenantMemory[tenant.TenantKey] = tenant.ToRecord(); - } + foreach (var tenant in snapshot.Tenants ?? []) _tenantMemory[tenant.TenantKey] = tenant.ToRecord(); Interlocked.Exchange(ref _revision, snapshot.Revision); _lastLoadedUtc = snapshot.LastLoadedUtc ?? DateTimeOffset.UtcNow; @@ -75,9 +74,12 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore TenantKey = pair.Key, Birthday = pair.Value.Birthday, Name = pair.Value.Name, - Preferences = pair.Value.Preferences.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase), - ImportantDates = pair.Value.ImportantDates.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase), - Affinities = pair.Value.Affinities.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase), + Preferences = pair.Value.Preferences.ToDictionary(entry => entry.Key, entry => entry.Value, + StringComparer.OrdinalIgnoreCase), + ImportantDates = pair.Value.ImportantDates.ToDictionary(entry => entry.Key, + entry => entry.Value, StringComparer.OrdinalIgnoreCase), + Affinities = pair.Value.Affinities.ToDictionary(entry => entry.Key, entry => entry.Value, + StringComparer.OrdinalIgnoreCase), Lists = pair.Value.Lists.ToDictionary( entry => entry.Key, entry => entry.Value.ToArray(), @@ -167,50 +169,35 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore public IReadOnlyDictionary GetAffinities(PersonalMemoryTenantScope tenantScope) { var key = BuildTenantKey(tenantScope); - if (!_tenantMemory.TryGetValue(key, out var record)) - { - return new Dictionary(StringComparer.OrdinalIgnoreCase); - } - - return new Dictionary(record.Affinities, StringComparer.OrdinalIgnoreCase); + return !_tenantMemory.TryGetValue(key, out var record) + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : new Dictionary(record.Affinities, StringComparer.OrdinalIgnoreCase); } public void AddListItem(PersonalMemoryTenantScope tenantScope, string listName, string item) { var normalizedListName = NormalizeCategory(listName); var normalizedItem = item.Trim(); - if (string.IsNullOrWhiteSpace(normalizedListName) || string.IsNullOrWhiteSpace(normalizedItem)) - { - return; - } + if (string.IsNullOrWhiteSpace(normalizedListName) || string.IsNullOrWhiteSpace(normalizedItem)) return; var record = GetOrCreateTenantRecord(tenantScope); var changed = false; lock (record.SyncRoot) { var list = record.Lists.GetOrAdd(normalizedListName, static _ => []); - if (list.Any(value => string.Equals(value, normalizedItem, StringComparison.OrdinalIgnoreCase))) - { - return; - } + if (list.Any(value => string.Equals(value, normalizedItem, StringComparison.OrdinalIgnoreCase))) return; list.Add(normalizedItem); changed = true; } - if (changed) - { - TouchState(); - } + if (changed) TouchState(); } public IReadOnlyList GetListItems(PersonalMemoryTenantScope tenantScope, string listName) { var key = BuildTenantKey(tenantScope); - if (!_tenantMemory.TryGetValue(key, out var record)) - { - return []; - } + if (!_tenantMemory.TryGetValue(key, out var record)) return []; var normalizedListName = NormalizeCategory(listName); lock (record.SyncRoot) @@ -224,10 +211,7 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore public void ClearListItems(PersonalMemoryTenantScope tenantScope, string listName) { var key = BuildTenantKey(tenantScope); - if (!_tenantMemory.TryGetValue(key, out var record)) - { - return; - } + if (!_tenantMemory.TryGetValue(key, out var record)) return; var changed = false; lock (record.SyncRoot) @@ -235,10 +219,7 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore changed = record.Lists.TryRemove(NormalizeCategory(listName), out _); } - if (changed) - { - TouchState(); - } + if (changed) TouchState(); } private TenantMemoryRecord GetOrCreateTenantRecord(PersonalMemoryTenantScope tenantScope) @@ -265,15 +246,16 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore return category.Trim().ToLowerInvariant(); } - private const string CurrentSchemaVersion = "1"; - private sealed class TenantMemoryRecord { public string? Birthday { get; set; } public string? Name { get; set; } public ConcurrentDictionary Preferences { get; } = new(StringComparer.OrdinalIgnoreCase); public ConcurrentDictionary ImportantDates { get; } = new(StringComparer.OrdinalIgnoreCase); - public ConcurrentDictionary Affinities { get; } = new(StringComparer.OrdinalIgnoreCase); + + public ConcurrentDictionary Affinities { get; } = + new(StringComparer.OrdinalIgnoreCase); + public ConcurrentDictionary> Lists { get; } = new(StringComparer.OrdinalIgnoreCase); public object SyncRoot { get; } = new(); } @@ -292,10 +274,18 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore public string TenantKey { get; init; } = string.Empty; public string? Birthday { get; init; } public string? Name { get; init; } - public IDictionary Preferences { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); - public IDictionary ImportantDates { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); - public IDictionary Affinities { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); - public IDictionary Lists { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public IDictionary Preferences { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public IDictionary ImportantDates { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public IDictionary Affinities { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public IDictionary Lists { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); public TenantMemoryRecord ToRecord() { @@ -305,27 +295,15 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore Name = Name }; - foreach (var preference in Preferences) - { - record.Preferences[preference.Key] = preference.Value; - } + foreach (var preference in Preferences) record.Preferences[preference.Key] = preference.Value; - foreach (var date in ImportantDates) - { - record.ImportantDates[date.Key] = date.Value; - } + foreach (var date in ImportantDates) record.ImportantDates[date.Key] = date.Value; - foreach (var affinity in Affinities) - { - record.Affinities[affinity.Key] = affinity.Value; - } + foreach (var affinity in Affinities) record.Affinities[affinity.Key] = affinity.Value; - foreach (var list in Lists) - { - record.Lists[list.Key] = [.. list.Value]; - } + foreach (var list in Lists) record.Lists[list.Key] = [.. list.Value]; return record; } } -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/JsonFileSnapshotStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/JsonFileSnapshotStore.cs index e982eaf..a9612d4 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/JsonFileSnapshotStore.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/JsonFileSnapshotStore.cs @@ -2,47 +2,29 @@ using System.Text.Json; namespace Jibo.Cloud.Infrastructure.Persistence; -internal sealed class JsonFileSnapshotStore : ISnapshotStore +internal sealed class JsonFileSnapshotStore(string? persistencePath, JsonSerializerOptions options) : ISnapshotStore { - private readonly string? _persistencePath; - private readonly JsonSerializerOptions _options; - - public JsonFileSnapshotStore(string? persistencePath, JsonSerializerOptions options) - { - _persistencePath = persistencePath; - _options = options; - } - public TSnapshot? Load() where TSnapshot : class { - if (string.IsNullOrWhiteSpace(_persistencePath) || !File.Exists(_persistencePath)) - { - return default; - } + if (string.IsNullOrWhiteSpace(persistencePath) || !File.Exists(persistencePath)) return null; try { - return JsonSerializer.Deserialize(File.ReadAllText(_persistencePath), _options); + return JsonSerializer.Deserialize(File.ReadAllText(persistencePath), options); } catch { - return default; + return null; } } public void Save(TSnapshot snapshot) where TSnapshot : class { - if (string.IsNullOrWhiteSpace(_persistencePath)) - { - return; - } + if (string.IsNullOrWhiteSpace(persistencePath)) return; - var directory = Path.GetDirectoryName(_persistencePath); - if (!string.IsNullOrWhiteSpace(directory)) - { - Directory.CreateDirectory(directory); - } + var directory = Path.GetDirectoryName(persistencePath); + if (!string.IsNullOrWhiteSpace(directory)) Directory.CreateDirectory(directory); - File.WriteAllText(_persistencePath, JsonSerializer.Serialize(snapshot, _options)); + File.WriteAllText(persistencePath, JsonSerializer.Serialize(snapshot, options)); } -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/PersistenceBackendKind.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/PersistenceBackendKind.cs index 7dda13b..53139a5 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/PersistenceBackendKind.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/PersistenceBackendKind.cs @@ -5,4 +5,4 @@ public enum PersistenceBackendKind File, AzureBlob, AzureSql -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/PersistenceSnapshotStoreFactory.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/PersistenceSnapshotStoreFactory.cs index bab5326..d5b1941 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/PersistenceSnapshotStoreFactory.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/PersistenceSnapshotStoreFactory.cs @@ -10,18 +10,21 @@ public sealed class PersistenceSnapshotStoreFactory : IPersistenceSnapshotStoreF PropertyNameCaseInsensitive = true }; - public ISnapshotStore Create(string? persistencePath, PersistenceBackendKind backendKind, string snapshotName, string? connectionString = null) + public ISnapshotStore Create(string? persistencePath, PersistenceBackendKind backendKind, string snapshotName, + string? connectionString = null) { return backendKind switch { PersistenceBackendKind.File => new JsonFileSnapshotStore(persistencePath, JsonOptions), PersistenceBackendKind.AzureBlob => new AzureBlobSnapshotStore( - connectionString ?? throw new InvalidOperationException("Azure Blob persistence requires a connection string."), + connectionString ?? + throw new InvalidOperationException("Azure Blob persistence requires a connection string."), snapshotName), PersistenceBackendKind.AzureSql => new AzureSqlSnapshotStore( - connectionString ?? throw new InvalidOperationException("Azure SQL persistence requires a connection string."), + connectionString ?? + throw new InvalidOperationException("Azure SQL persistence requires a connection string."), snapshotName), _ => new JsonFileSnapshotStore(persistencePath, JsonOptions) }; } -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Properties/AssemblyInfo.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Properties/AssemblyInfo.cs index 3307944..f32ce0a 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Properties/AssemblyInfo.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Properties/AssemblyInfo.cs @@ -1,3 +1,3 @@ using System.Runtime.CompilerServices; -[assembly: InternalsVisibleTo("Jibo.Cloud.Tests")] +[assembly: InternalsVisibleTo("Jibo.Cloud.Tests")] \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Telemetry/CapturePathResolver.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Telemetry/CapturePathResolver.cs index 45b14c2..d2934a9 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Telemetry/CapturePathResolver.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Telemetry/CapturePathResolver.cs @@ -4,10 +4,7 @@ internal static class CapturePathResolver { public static string Resolve(string configuredDirectoryPath, string currentDirectory, string appBaseDirectory) { - if (Path.IsPathRooted(configuredDirectoryPath)) - { - return Path.GetFullPath(configuredDirectoryPath); - } + if (Path.IsPathRooted(configuredDirectoryPath)) return Path.GetFullPath(configuredDirectoryPath); var repoRoot = FindOpenJiboRepoRoot(currentDirectory) ?? FindOpenJiboRepoRoot(appBaseDirectory); var baseDirectory = repoRoot ?? currentDirectory; @@ -16,27 +13,18 @@ internal static class CapturePathResolver private static string? FindOpenJiboRepoRoot(string? startPath) { - if (string.IsNullOrWhiteSpace(startPath)) - { - return null; - } + if (string.IsNullOrWhiteSpace(startPath)) return null; var directory = new DirectoryInfo(Path.GetFullPath(startPath)); - if (directory is { Exists: false, Parent: not null }) - { - directory = directory.Parent; - } + if (directory is { Exists: false, Parent: not null }) directory = directory.Parent; while (directory is not null) { - if (File.Exists(Path.Combine(directory.FullName, "OpenJibo.slnx"))) - { - return directory.FullName; - } + if (File.Exists(Path.Combine(directory.FullName, "OpenJibo.slnx"))) return directory.FullName; directory = directory.Parent; } return null; } -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Telemetry/FileProtocolTelemetrySink.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Telemetry/FileProtocolTelemetrySink.cs index a11bb6a..43fbb71 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Telemetry/FileProtocolTelemetrySink.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Telemetry/FileProtocolTelemetrySink.cs @@ -12,12 +12,10 @@ public sealed class FileProtocolTelemetrySink( { private readonly SemaphoreSlim _writeLock = new(1, 1); - public async Task RecordAsync(ProtocolEnvelope envelope, ProtocolDispatchResult result, CancellationToken cancellationToken = default) + public async Task RecordAsync(ProtocolEnvelope envelope, ProtocolDispatchResult result, + CancellationToken cancellationToken = default) { - if (!options.Value.Enabled) - { - return; - } + if (!options.Value.Enabled) return; var directory = CapturePathResolver.Resolve( options.Value.DirectoryPath, @@ -74,4 +72,4 @@ public sealed class FileProtocolTelemetrySink( $"{envelope.ServicePrefix}.{envelope.Operation}".Trim('.'), result.StatusCode); } -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Telemetry/FileTurnTelemetrySink.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Telemetry/FileTurnTelemetrySink.cs index 7f3a9e6..f6560e9 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Telemetry/FileTurnTelemetrySink.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Telemetry/FileTurnTelemetrySink.cs @@ -5,7 +5,8 @@ using Microsoft.Extensions.Options; namespace Jibo.Cloud.Infrastructure.Telemetry; -public sealed class FileTurnTelemetrySink(ILogger logger, +public sealed class FileTurnTelemetrySink( + ILogger logger, IOptions options) : ITurnTelemetrySink { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) @@ -15,12 +16,10 @@ public sealed class FileTurnTelemetrySink(ILogger logger, private readonly SemaphoreSlim _writeLock = new(1, 1); - public async Task RecordTurnDiagnosticAsync(string category, IReadOnlyDictionary details, CancellationToken cancellationToken = default) + public async Task RecordTurnDiagnosticAsync(string category, IReadOnlyDictionary details, + CancellationToken cancellationToken = default) { - if (!options.Value.Enabled) - { - return; - } + if (!options.Value.Enabled) return; await WriteEventAsync(new { @@ -31,10 +30,7 @@ public sealed class FileTurnTelemetrySink(ILogger logger, public async Task RecordTranscriptError(Exception ex, string message, CancellationToken cancellationToken = default) { - if (!options.Value.Enabled) - { - return; - } + if (!options.Value.Enabled) return; await WriteEventAsync(new { @@ -44,7 +40,8 @@ public sealed class FileTurnTelemetrySink(ILogger logger, }, "Turn telemetry error", LogLevel.Error, cancellationToken); } - private async Task WriteEventAsync(object payload, string logMessage, LogLevel level, CancellationToken cancellationToken) + private async Task WriteEventAsync(object payload, string logMessage, LogLevel level, + CancellationToken cancellationToken) { var directory = GetBaseDirectory(); Directory.CreateDirectory(directory); @@ -71,4 +68,4 @@ public sealed class FileTurnTelemetrySink(ILogger logger, Directory.GetCurrentDirectory(), AppContext.BaseDirectory); } -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Telemetry/FileWebSocketTelemetrySink.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Telemetry/FileWebSocketTelemetrySink.cs index cee4010..44ab24a 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Telemetry/FileWebSocketTelemetrySink.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Telemetry/FileWebSocketTelemetrySink.cs @@ -16,15 +16,15 @@ public sealed class FileWebSocketTelemetrySink( WriteIndented = true }; - private readonly ConcurrentDictionary _fixtures = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _fixtures = + new(StringComparer.OrdinalIgnoreCase); + private readonly SemaphoreSlim _writeLock = new(1, 1); - public async Task RecordConnectionOpenedAsync(WebSocketMessageEnvelope envelope, CloudSession session, CancellationToken cancellationToken = default) + public async Task RecordConnectionOpenedAsync(WebSocketMessageEnvelope envelope, CloudSession session, + CancellationToken cancellationToken = default) { - if (!options.Value.Enabled) - { - return; - } + if (!options.Value.Enabled) return; _fixtures[session.SessionId] = new CapturedWebSocketFixtureBuilder { @@ -37,10 +37,12 @@ public sealed class FileWebSocketTelemetrySink( } }; - await WriteRecordAsync(BuildRecord("connection_opened", envelope, session, null, "internal", null, null), cancellationToken); + await WriteRecordAsync(BuildRecord("connection_opened", envelope, session, null, "internal", null, null), + cancellationToken); } - public Task RecordInboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, string? messageType, CancellationToken cancellationToken = default) + public Task RecordInboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, string? messageType, + CancellationToken cancellationToken = default) { return !options.Value.Enabled ? Task.CompletedTask @@ -48,7 +50,8 @@ public sealed class FileWebSocketTelemetrySink( cancellationToken); } - public Task RecordTurnEventAsync(WebSocketMessageEnvelope envelope, CloudSession session, string eventType, IReadOnlyDictionary details, CancellationToken cancellationToken = default) + public Task RecordTurnEventAsync(WebSocketMessageEnvelope envelope, CloudSession session, string eventType, + IReadOnlyDictionary details, CancellationToken cancellationToken = default) { return !options.Value.Enabled ? Task.CompletedTask @@ -56,12 +59,10 @@ public sealed class FileWebSocketTelemetrySink( cancellationToken); } - public async Task RecordOutboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, IReadOnlyList replies, CancellationToken cancellationToken = default) + public async Task RecordOutboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, + IReadOnlyList replies, CancellationToken cancellationToken = default) { - if (!options.Value.Enabled) - { - return; - } + if (!options.Value.Enabled) return; var replyTypes = replies .Select(reply => ReadReplyType(reply.Text)) @@ -69,25 +70,22 @@ public sealed class FileWebSocketTelemetrySink( .Select(type => type!) .ToArray(); - await WriteRecordAsync(BuildRecord("message_out", envelope, session, null, "out", replyTypes, null), cancellationToken); + await WriteRecordAsync(BuildRecord("message_out", envelope, session, null, "out", replyTypes, null), + cancellationToken); if (_fixtures.TryGetValue(session.SessionId, out var fixture)) - { fixture.Steps.Add(new CapturedWebSocketFixtureStep { Text = ParseJsonElement(envelope.Text), Binary = envelope.Binary?.Select(value => (int)value).ToArray(), ExpectedReplyTypes = replyTypes }); - } } - public async Task RecordConnectionClosedAsync(WebSocketMessageEnvelope envelope, CloudSession session, string reason, CancellationToken cancellationToken = default) + public async Task RecordConnectionClosedAsync(WebSocketMessageEnvelope envelope, CloudSession session, + string reason, CancellationToken cancellationToken = default) { - if (!options.Value.Enabled) - { - return; - } + if (!options.Value.Enabled) return; await WriteRecordAsync(BuildRecord( "connection_closed", @@ -98,10 +96,8 @@ public sealed class FileWebSocketTelemetrySink( null, new Dictionary { ["reason"] = reason }), cancellationToken); - if (!options.Value.ExportFixtures || !_fixtures.TryRemove(session.SessionId, out var fixture) || fixture.Steps.Count == 0) - { - return; - } + if (!options.Value.ExportFixtures || !_fixtures.TryRemove(session.SessionId, out var fixture) || + fixture.Steps.Count == 0) return; var fixtureName = BuildFixtureName(session, fixture); var capturedFixture = new CapturedWebSocketFixture @@ -118,7 +114,8 @@ public sealed class FileWebSocketTelemetrySink( await _writeLock.WaitAsync(cancellationToken); try { - await File.WriteAllTextAsync(fixturePath, JsonSerializer.Serialize(capturedFixture, JsonOptions), cancellationToken); + await File.WriteAllTextAsync(fixturePath, JsonSerializer.Serialize(capturedFixture, JsonOptions), + cancellationToken); } finally { @@ -161,8 +158,10 @@ public sealed class FileWebSocketTelemetrySink( string? messageType, string direction, IReadOnlyList? replyTypes, - IReadOnlyDictionary? details) => new() + IReadOnlyDictionary? details) { + return new WebSocketTelemetryRecord + { EventType = eventType, SessionId = session.SessionId, ConnectionId = envelope.ConnectionId, @@ -182,13 +181,11 @@ public sealed class FileWebSocketTelemetrySink( AwaitingTurnCompletion = session.TurnState.AwaitingTurnCompletion, Details = details ?? new Dictionary() }; + } private static string? ReadReplyType(string? text) { - if (string.IsNullOrWhiteSpace(text)) - { - return null; - } + if (string.IsNullOrWhiteSpace(text)) return null; try { @@ -205,10 +202,7 @@ public sealed class FileWebSocketTelemetrySink( private static JsonElement? ParseJsonElement(string? text) { - if (string.IsNullOrWhiteSpace(text)) - { - return null; - } + if (string.IsNullOrWhiteSpace(text)) return null; try { @@ -252,4 +246,4 @@ public sealed class FileWebSocketTelemetrySink( public CapturedWebSocketFixtureSession Session { get; init; } = new(); public List Steps { get; } = []; } -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Telemetry/ProtocolTelemetryOptions.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Telemetry/ProtocolTelemetryOptions.cs index 68a1e77..97ebc7f 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Telemetry/ProtocolTelemetryOptions.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Telemetry/ProtocolTelemetryOptions.cs @@ -4,4 +4,4 @@ public sealed class ProtocolTelemetryOptions { public bool Enabled { get; set; } = true; public string DirectoryPath { get; set; } = "captures/http"; -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Weather/OpenWeatherOptions.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Weather/OpenWeatherOptions.cs index 9d1949a..03e3764 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Weather/OpenWeatherOptions.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Weather/OpenWeatherOptions.cs @@ -17,4 +17,4 @@ public sealed class OpenWeatherOptions public int GeocodeCacheTtlSeconds { get; set; } = 21600; public int FailureCacheTtlSeconds { get; set; } = 45; -} +} \ No newline at end of file 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 99abd25..33eb904 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 @@ -12,49 +12,42 @@ public sealed class OpenWeatherReportProvider( ILogger logger) : IWeatherReportProvider { - private readonly ConcurrentDictionary> geocodeCache = new(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary> weatherCache = new(StringComparer.OrdinalIgnoreCase); + private const int MaxForecastDayOffset = 5; + + private readonly ConcurrentDictionary> _geocodeCache = + new(StringComparer.OrdinalIgnoreCase); + + private readonly ConcurrentDictionary> _weatherCache = + new(StringComparer.OrdinalIgnoreCase); public async Task GetReportAsync( WeatherReportRequest request, CancellationToken cancellationToken = default) { - if (string.IsNullOrWhiteSpace(options.ApiKey)) - { - return null; - } + if (string.IsNullOrWhiteSpace(options.ApiKey)) return null; string? weatherCacheKey = null; try { var location = await ResolveLocationAsync(request, cancellationToken); - if (location is null) - { - return null; - } + if (location is null) return null; var useCelsius = request.UseCelsius ?? options.UseCelsius; var forecastDayOffset = request.ForecastDayOffset ?? (request.IsTomorrow ? 1 : 0); weatherCacheKey = BuildWeatherCacheKey(location.Value, useCelsius, forecastDayOffset); - if (TryGetCachedValue(weatherCache, weatherCacheKey, out var cachedSnapshot)) - { - return cachedSnapshot; - } + if (TryGetCachedValue(_weatherCache, weatherCacheKey, out var cachedSnapshot)) return cachedSnapshot; - WeatherReportSnapshot? snapshot; if (forecastDayOffset > MaxForecastDayOffset) { - SetCachedValue(weatherCache, weatherCacheKey, null, options.FailureCacheTtlSeconds); + SetCachedValue(_weatherCache, weatherCacheKey, null, options.FailureCacheTtlSeconds); return null; } - snapshot = await GetOneCallWeatherAsync(location.Value, useCelsius, forecastDayOffset, cancellationToken); - if (snapshot is null) - { - snapshot = await GetLegacyWeatherAsync(location.Value, useCelsius, forecastDayOffset, cancellationToken); - } + var snapshot = + await GetOneCallWeatherAsync(location.Value, useCelsius, forecastDayOffset, cancellationToken) ?? + await GetLegacyWeatherAsync(location.Value, useCelsius, forecastDayOffset, cancellationToken); SetCachedValue( - weatherCache, + _weatherCache, weatherCacheKey, snapshot, snapshot is null @@ -68,9 +61,7 @@ public sealed class OpenWeatherReportProvider( { logger.LogWarning(exception, "OpenWeather lookup failed."); if (!string.IsNullOrWhiteSpace(weatherCacheKey)) - { - SetCachedValue(weatherCache, weatherCacheKey, null, options.FailureCacheTtlSeconds); - } + SetCachedValue(_weatherCache, weatherCacheKey, null, options.FailureCacheTtlSeconds); return null; } } @@ -86,23 +77,15 @@ public sealed class OpenWeatherReportProvider( if (string.IsNullOrWhiteSpace(query)) { if (request is { Latitude: not null, Longitude: not null }) - { return new LocationPoint(request.Latitude.Value, request.Longitude.Value, null); - } query = options.DefaultLocation; } - if (string.IsNullOrWhiteSpace(query)) - { - return null; - } + if (string.IsNullOrWhiteSpace(query)) return null; var geocodeCacheKey = NormalizeLocationQueryForCache(query); - if (TryGetCachedValue(geocodeCache, geocodeCacheKey, out var cachedLocation)) - { - return cachedLocation; - } + if (TryGetCachedValue(_geocodeCache, geocodeCacheKey, out var cachedLocation)) return cachedLocation; var geocodeUri = BuildRequestUri( "/geo/1.0/direct", @@ -112,7 +95,7 @@ public sealed class OpenWeatherReportProvider( using var response = await httpClient.GetAsync(geocodeUri, cancellationToken); if (!response.IsSuccessStatusCode) { - SetCachedValue(geocodeCache, geocodeCacheKey, null, options.FailureCacheTtlSeconds); + SetCachedValue(_geocodeCache, geocodeCacheKey, null, options.FailureCacheTtlSeconds); return null; } @@ -121,7 +104,7 @@ public sealed class OpenWeatherReportProvider( if (document.RootElement.ValueKind != JsonValueKind.Array || document.RootElement.GetArrayLength() == 0) { - SetCachedValue(geocodeCache, geocodeCacheKey, null, options.FailureCacheTtlSeconds); + SetCachedValue(_geocodeCache, geocodeCacheKey, null, options.FailureCacheTtlSeconds); return null; } @@ -129,13 +112,13 @@ public sealed class OpenWeatherReportProvider( if (!TryReadDouble(location, "lat", out var latitude) || !TryReadDouble(location, "lon", out var longitude)) { - SetCachedValue(geocodeCache, geocodeCacheKey, null, options.FailureCacheTtlSeconds); + SetCachedValue(_geocodeCache, geocodeCacheKey, null, options.FailureCacheTtlSeconds); return null; } var displayName = BuildLocationDisplayName(location); var resolvedLocation = new LocationPoint(latitude, longitude, displayName); - SetCachedValue(geocodeCache, geocodeCacheKey, resolvedLocation, options.GeocodeCacheTtlSeconds); + SetCachedValue(_geocodeCache, geocodeCacheKey, resolvedLocation, options.GeocodeCacheTtlSeconds); return resolvedLocation; } @@ -153,10 +136,7 @@ public sealed class OpenWeatherReportProvider( ("exclude", "minutely,hourly,alerts"), ("appid", options.ApiKey!)); using var response = await httpClient.GetAsync(weatherUri, cancellationToken); - if (!response.IsSuccessStatusCode) - { - return null; - } + if (!response.IsSuccessStatusCode) return null; using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); @@ -165,32 +145,24 @@ public sealed class OpenWeatherReportProvider( !root.TryGetProperty("daily", out var daily) || daily.ValueKind != JsonValueKind.Array || daily.GetArrayLength() == 0) - { return null; - } - if (forecastDayOffset >= daily.GetArrayLength()) - { - return null; - } + if (forecastDayOffset >= daily.GetArrayLength()) return null; var selectedDay = daily[forecastDayOffset]; - if (!selectedDay.TryGetProperty("temp", out var selectedDayTemp)) - { - return null; - } + if (!selectedDay.TryGetProperty("temp", out var selectedDayTemp)) return null; var locationName = location.DisplayName ?? options.DefaultLocation; var summary = TryReadWeatherSummary(current) - ?? ReadNonEmptyString(selectedDay, "summary") - ?? TryReadWeatherSummary(selectedDay); + ?? ReadNonEmptyString(selectedDay, "summary") + ?? TryReadWeatherSummary(selectedDay); var condition = TryReadWeatherCondition(current) ?? TryReadWeatherCondition(selectedDay); var temperature = forecastDayOffset <= 0 ? TryReadInt(current, "temp") : TryReadInt(selectedDayTemp, "day") - ?? TryReadInt(selectedDayTemp, "night") - ?? TryReadInt(selectedDayTemp, "morn") - ?? TryReadInt(selectedDayTemp, "eve"); + ?? TryReadInt(selectedDayTemp, "night") + ?? TryReadInt(selectedDayTemp, "morn") + ?? TryReadInt(selectedDayTemp, "eve"); var high = TryReadInt(selectedDayTemp, "max"); var low = TryReadInt(selectedDayTemp, "min"); if (temperature is not null) @@ -199,10 +171,7 @@ public sealed class OpenWeatherReportProvider( low = low is null ? temperature : Math.Min(low.Value, temperature.Value); } - if (temperature is null && high is null && low is null) - { - return null; - } + if (temperature is null && high is null && low is null) return null; var resolvedTemperature = temperature ?? high ?? low ?? 0; return new WeatherReportSnapshot( @@ -221,15 +190,9 @@ public sealed class OpenWeatherReportProvider( int forecastDayOffset, CancellationToken cancellationToken) { - if (forecastDayOffset <= 0) - { - return await GetLegacyCurrentWeatherAsync(location, useCelsius, cancellationToken); - } + if (forecastDayOffset <= 0) return await GetLegacyCurrentWeatherAsync(location, useCelsius, cancellationToken); - if (forecastDayOffset > MaxForecastDayOffset) - { - return null; - } + if (forecastDayOffset > MaxForecastDayOffset) return null; return await GetForecastForDayOffsetAsync(location, useCelsius, forecastDayOffset, cancellationToken); } @@ -246,18 +209,12 @@ public sealed class OpenWeatherReportProvider( ("units", useCelsius ? "metric" : "imperial"), ("appid", options.ApiKey!)); using var response = await httpClient.GetAsync(weatherUri, cancellationToken); - if (!response.IsSuccessStatusCode) - { - return null; - } + 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("main", out var main)) - { - return null; - } + if (!root.TryGetProperty("main", out var main)) return null; var locationName = ReadNonEmptyString(root, "name") ?? location.DisplayName ?? options.DefaultLocation; var summary = TryReadWeatherSummary(root); @@ -271,10 +228,7 @@ public sealed class OpenWeatherReportProvider( low = low is null ? temperature : Math.Min(low.Value, temperature.Value); } - if (temperature is null && high is null && low is null) - { - return null; - } + if (temperature is null && high is null && low is null) return null; var resolvedTemperature = temperature ?? high ?? low ?? 0; return new WeatherReportSnapshot( @@ -299,18 +253,12 @@ public sealed class OpenWeatherReportProvider( ("units", useCelsius ? "metric" : "imperial"), ("appid", options.ApiKey!)); using var response = await httpClient.GetAsync(forecastUri, cancellationToken); - if (!response.IsSuccessStatusCode) - { - return null; - } + 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; - } + 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); @@ -318,35 +266,21 @@ public sealed class OpenWeatherReportProvider( var lows = new List(); foreach (var item in list.EnumerateArray()) { - if (!TryReadLong(item, "dt", out var unixSeconds)) - { - continue; - } + 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); - } + 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 (low is not null) lows.Add(low.Value); } - if (highs.Count == 0 && lows.Count == 0) - { - return null; - } + if (highs.Count == 0 && lows.Count == 0) return null; return (highs.Count == 0 ? null : highs.Max(), lows.Count == 0 ? null : lows.Min()); } @@ -364,39 +298,25 @@ public sealed class OpenWeatherReportProvider( ("units", useCelsius ? "metric" : "imperial"), ("appid", options.ApiKey!)); using var response = await httpClient.GetAsync(forecastUri, cancellationToken); - if (!response.IsSuccessStatusCode) - { - return null; - } + 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; - } + 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.AddDays(forecastDayOffset)); + var targetDate = + DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(offset).DateTime.AddDays(forecastDayOffset)); var entries = new List(); foreach (var item in list.EnumerateArray()) { - if (!TryReadLong(item, "dt", out var unixSeconds)) - { - continue; - } + if (!TryReadLong(item, "dt", out var unixSeconds)) continue; var localTimestamp = DateTimeOffset.FromUnixTimeSeconds(unixSeconds).ToOffset(offset); - if (DateOnly.FromDateTime(localTimestamp.DateTime) != targetDate) - { - continue; - } + if (DateOnly.FromDateTime(localTimestamp.DateTime) != targetDate) continue; - if (!item.TryGetProperty("main", out var main)) - { - continue; - } + if (!item.TryGetProperty("main", out var main)) continue; entries.Add(new ForecastEntry( localTimestamp, @@ -407,10 +327,7 @@ public sealed class OpenWeatherReportProvider( TryReadWeatherCondition(item))); } - if (entries.Count == 0) - { - return null; - } + if (entries.Count == 0) return null; var selectedEntry = entries .OrderBy(entry => Math.Abs((entry.LocalTime.TimeOfDay - TimeSpan.FromHours(12)).TotalMinutes)) @@ -451,16 +368,10 @@ public sealed class OpenWeatherReportProvider( private static TimeSpan TryReadForecastOffset(JsonElement root) { - if (!root.TryGetProperty("city", out var city)) - { - return TimeSpan.Zero; - } + if (!root.TryGetProperty("city", out var city)) return TimeSpan.Zero; var timezoneSeconds = TryReadInt(city, "timezone"); - if (timezoneSeconds is null) - { - return TimeSpan.Zero; - } + if (timezoneSeconds is null) return TimeSpan.Zero; var seconds = Math.Clamp(timezoneSeconds.Value, -50400, 50400); return TimeSpan.FromSeconds(seconds); @@ -468,10 +379,7 @@ public sealed class OpenWeatherReportProvider( private static string? ReadForecastLocationName(JsonElement root) { - if (!root.TryGetProperty("city", out var city)) - { - return null; - } + if (!root.TryGetProperty("city", out var city)) return null; var name = ReadNonEmptyString(city, "name"); var country = ReadNonEmptyString(city, "country"); @@ -486,14 +394,9 @@ public sealed class OpenWeatherReportProvider( if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(state) && !string.IsNullOrWhiteSpace(country)) - { return $"{name}, {state}, {country}"; - } - if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(country)) - { - return $"{name}, {country}"; - } + if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(country)) return $"{name}, {country}"; return name; } @@ -506,10 +409,7 @@ public sealed class OpenWeatherReportProvider( private static string? TryReadWeatherCondition(JsonElement root) { var main = TryReadWeatherProperty(root, "main"); - if (string.IsNullOrWhiteSpace(main)) - { - return null; - } + if (string.IsNullOrWhiteSpace(main)) return null; var normalized = main.Trim().ToLowerInvariant(); return normalized switch @@ -528,9 +428,7 @@ public sealed class OpenWeatherReportProvider( if (!root.TryGetProperty("weather", out var weather) || weather.ValueKind != JsonValueKind.Array || weather.GetArrayLength() == 0) - { return null; - } var first = weather[0]; return ReadNonEmptyString(first, key); @@ -557,15 +455,10 @@ public sealed class OpenWeatherReportProvider( private static int? TryReadInt(JsonElement source, string key) { - if (!source.TryGetProperty(key, out var element)) - { - return null; - } + if (!source.TryGetProperty(key, out var element)) return null; if (element.ValueKind == JsonValueKind.Number && element.TryGetDouble(out var numeric)) - { return (int)Math.Round(numeric, MidpointRounding.AwayFromZero); - } return null; } @@ -588,10 +481,7 @@ public sealed class OpenWeatherReportProvider( out T value) { value = default!; - if (!cache.TryGetValue(key, out var entry)) - { - return false; - } + if (!cache.TryGetValue(key, out var entry)) return false; if (entry.ExpiresUtc > DateTimeOffset.UtcNow) { @@ -625,6 +515,4 @@ public sealed class OpenWeatherReportProvider( int? LowTemperature, string? Summary, string? Condition); - - private const int MaxForecastDayOffset = 5; -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Runtime.Abstractions/IBrainStrategy.cs b/OpenJibo/src/Jibo.Runtime.Abstractions/IBrainStrategy.cs index 98b249e..3eb3f87 100644 --- a/OpenJibo/src/Jibo.Runtime.Abstractions/IBrainStrategy.cs +++ b/OpenJibo/src/Jibo.Runtime.Abstractions/IBrainStrategy.cs @@ -4,6 +4,7 @@ public interface IBrainStrategy { string Name { get; } bool CanHandle(TurnContext turn, ConversationSession session); + Task DecideAsync( TurnContext turn, ConversationSession session, diff --git a/OpenJibo/src/Jibo.Runtime.Abstractions/ResponsePlan.cs b/OpenJibo/src/Jibo.Runtime.Abstractions/ResponsePlan.cs index df61f07..dd737ad 100644 --- a/OpenJibo/src/Jibo.Runtime.Abstractions/ResponsePlan.cs +++ b/OpenJibo/src/Jibo.Runtime.Abstractions/ResponsePlan.cs @@ -18,4 +18,4 @@ public sealed class ResponsePlan public string? DebugRoute { get; init; } public IDictionary Diagnostics { get; init; } = new Dictionary(); public IDictionary ProtocolMetadata { get; init; } = new Dictionary(); -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Runtime.Abstractions/RobotEvent.cs b/OpenJibo/src/Jibo.Runtime.Abstractions/RobotEvent.cs index ed06fb2..546c801 100644 --- a/OpenJibo/src/Jibo.Runtime.Abstractions/RobotEvent.cs +++ b/OpenJibo/src/Jibo.Runtime.Abstractions/RobotEvent.cs @@ -18,4 +18,4 @@ public sealed class RobotEvent public string? ApplicationVersion { get; init; } public IDictionary Payload { get; init; } = new Dictionary(); -} +} \ No newline at end of file diff --git a/OpenJibo/src/Jibo.Runtime.Abstractions/TurnContext.cs b/OpenJibo/src/Jibo.Runtime.Abstractions/TurnContext.cs index eeb1e00..ed8e52b 100644 --- a/OpenJibo/src/Jibo.Runtime.Abstractions/TurnContext.cs +++ b/OpenJibo/src/Jibo.Runtime.Abstractions/TurnContext.cs @@ -25,4 +25,4 @@ public sealed class TurnContext public bool IsFollowUpEligible { get; init; } public IDictionary Attributes { get; init; } = new Dictionary(); -} +} \ No newline at end of file diff --git a/OpenJibo/src/OpenJibo.Site/index.html b/OpenJibo/src/OpenJibo.Site/index.html index 1486efa..50a4e8b 100644 --- a/OpenJibo/src/OpenJibo.Site/index.html +++ b/OpenJibo/src/OpenJibo.Site/index.html @@ -1,48 +1,48 @@ - - - OpenJibo - + + + OpenJibo + -
-
-

OpenJibo

-

Bringing Jibo back with a stable open cloud.

-

- OpenJibo is rebuilding the hosted layer Jibo still expects, then using that foothold - to modernize the platform step by step. -

- -
+
+
+

OpenJibo

+

Bringing Jibo back with a stable open cloud.

+

+ OpenJibo is rebuilding the hosted layer Jibo still expects, then using that foothold + to modernize the platform step by step. +

+ +
-
-

Current Direction

-

- The first milestone is a stable Azure-hosted replacement cloud. We support real robots - first through controlled DNS plus targeted device patching, then smooth the path later with OTA. -

-
+
+

Current Direction

+

+ The first milestone is a stable Azure-hosted replacement cloud. We support real robots + first through controlled DNS plus targeted device patching, then smooth the path later with OTA. +

+
-
-
-

Cloud First

-

Replace the missing hosted services before attempting deeper on-device modernization.

-
-
-

Real Hardware

-

Use repeatable device bootstrap steps instead of pretending recovery is already one-click.

-
-
-

Open Path

-

Keep the protocol work, docs, and codebase visible so the community can iterate with us.

-
-
-
+
+
+

Cloud First

+

Replace the missing hosted services before attempting deeper on-device modernization.

+
+
+

Real Hardware

+

Use repeatable device bootstrap steps instead of pretending recovery is already one-click.

+
+
+

Open Path

+

Keep the protocol work, docs, and codebase visible so the community can iterate with us.

+
+
+
- + \ No newline at end of file diff --git a/OpenJibo/src/Playground/AsrEvent.cs b/OpenJibo/src/Playground/AsrEvent.cs index f27163c..6091413 100644 --- a/OpenJibo/src/Playground/AsrEvent.cs +++ b/OpenJibo/src/Playground/AsrEvent.cs @@ -4,15 +4,11 @@ namespace Playground; public sealed class AsrEvent { - [JsonPropertyName("event_type")] - public string? EventType { get; set; } + [JsonPropertyName("event_type")] public string? EventType { get; set; } - [JsonPropertyName("task_id")] - public string? TaskId { get; set; } + [JsonPropertyName("task_id")] public string? TaskId { get; set; } - [JsonPropertyName("request_id")] - public string? RequestId { get; set; } + [JsonPropertyName("request_id")] public string? RequestId { get; set; } - [JsonPropertyName("utterances")] - public List? Utterances { get; set; } + [JsonPropertyName("utterances")] public List? Utterances { get; set; } } \ No newline at end of file diff --git a/OpenJibo/src/Playground/AsrUtterance.cs b/OpenJibo/src/Playground/AsrUtterance.cs index 7e7eab5..a18e61d 100644 --- a/OpenJibo/src/Playground/AsrUtterance.cs +++ b/OpenJibo/src/Playground/AsrUtterance.cs @@ -4,9 +4,7 @@ namespace Playground; public sealed class AsrUtterance { - [JsonPropertyName("utterance")] - public string? Utterance { get; set; } + [JsonPropertyName("utterance")] public string? Utterance { get; set; } - [JsonPropertyName("score")] - public double Score { get; set; } + [JsonPropertyName("score")] public double Score { get; set; } } \ No newline at end of file diff --git a/OpenJibo/src/Playground/Program.cs b/OpenJibo/src/Playground/Program.cs index 6717784..7ec2b59 100644 --- a/OpenJibo/src/Playground/Program.cs +++ b/OpenJibo/src/Playground/Program.cs @@ -62,8 +62,7 @@ while (!cts.IsCancellationRequested) } ms.Write(buffer, 0, result.Count); - } - while (!result.EndOfMessage); + } while (!result.EndOfMessage); var json = Encoding.UTF8.GetString(ms.ToArray()); diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Content/LegacyMimCatalogImporterTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Content/LegacyMimCatalogImporterTests.cs index 86d86bb..e6cbf91 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/Content/LegacyMimCatalogImporterTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Content/LegacyMimCatalogImporterTests.cs @@ -14,20 +14,25 @@ public sealed class LegacyMimCatalogImporterTests { var catalog = LegacyMimCatalogImporter.ImportCatalog(rootDirectory); - Assert.Contains("Something's off with the connection to my sources. Maybe ask me again in a little while.", catalog.GenericFallbackReplies); + Assert.Contains("Something's off with the connection to my sources. Maybe ask me again in a little while.", + catalog.GenericFallbackReplies); Assert.Contains("I think only you can answer that question.", catalog.PersonalityReplies); Assert.Contains("Jibo. Just Jibo, no last name. Like Bono", catalog.PersonalityReplies); Assert.Contains("No, I'm one in one million.", catalog.PersonalityReplies); Assert.Contains("I know a lot, I think. But not as much as I will someday.", catalog.PersonalityReplies); - Assert.Contains("I don't think of it as a job, because it's more fun than a job. But I'm here to help you out, and have fun with you, and maybe get my head patted by you occasionally.", catalog.PersonalityReplies); + Assert.Contains( + "I don't think of it as a job, because it's more fun than a job. But I'm here to help you out, and have fun with you, and maybe get my head patted by you occasionally.", + catalog.PersonalityReplies); Assert.Contains(catalog.EmotionReplies, reply => reply.Condition.Contains("NEUTRAL", StringComparison.OrdinalIgnoreCase) && reply.Reply.Contains("All systems are go.", StringComparison.OrdinalIgnoreCase)); - Assert.Contains("A Jibo is a robot. But I'm not just a machine, I have a heart. Well, not a real heart. But feelings. Well, not human feelings. You know what I mean.", catalog.PersonalityReplies); + Assert.Contains( + "A Jibo is a robot. But I'm not just a machine, I have a heart. Well, not a real heart. But feelings. Well, not human feelings. You know what I mean.", + catalog.PersonalityReplies); } finally { - Directory.Delete(rootDirectory, recursive: true); + Directory.Delete(rootDirectory, true); } } @@ -70,7 +75,7 @@ public sealed class LegacyMimCatalogImporterTests } finally { - Directory.Delete(rootDirectory, recursive: true); + Directory.Delete(rootDirectory, true); } } @@ -87,12 +92,14 @@ public sealed class LegacyMimCatalogImporterTests Assert.Contains("The only thing I consume is electricity.", catalog.PersonalityReplies); Assert.Contains("Unless I missed something, we're in my home as we speak.", catalog.PersonalityReplies); - Assert.Contains("For now just English. But someday I'd like to learn more. I like languages.", catalog.PersonalityReplies); + Assert.Contains("For now just English. But someday I'd like to learn more. I like languages.", + catalog.PersonalityReplies); Assert.Contains("I was put together in a factory piece by piece.", catalog.PersonalityReplies); Assert.Contains("I really like sunflowers.", catalog.PersonalityReplies); Assert.Contains("Ha. Of course I know R2D2. I mean, not personally.", catalog.PersonalityReplies); Assert.Contains("Yes! I like all things in space. They're so spacey.", catalog.PersonalityReplies); - Assert.Contains("Yes yes, I think kids are great. They're a little closer to my size.", catalog.PersonalityReplies); + Assert.Contains("Yes yes, I think kids are great. They're a little closer to my size.", + catalog.PersonalityReplies); Assert.Contains(catalog.PersonalityReplies, reply => reply.Contains("I do things like this when I'm happy", StringComparison.OrdinalIgnoreCase)); Assert.Contains(catalog.PersonalityReplies, reply => @@ -129,7 +136,8 @@ public sealed class LegacyMimCatalogImporterTests var catalog = LegacyMimCatalogImporter.ImportCatalog(rootDirectory); - Assert.Contains("Well I definitely try to be the kindest robot I can be. So I hope so.", catalog.PersonalityReplies); + Assert.Contains("Well I definitely try to be the kindest robot I can be. So I hope so.", + catalog.PersonalityReplies); Assert.Contains("I don't think so, not intentionally.", catalog.PersonalityReplies); Assert.Contains(catalog.PersonalityReplies, reply => reply.Contains("make people laugh", StringComparison.OrdinalIgnoreCase)); @@ -208,12 +216,18 @@ public sealed class LegacyMimCatalogImporterTests Assert.Contains("First let's check in with the meteorology department.", catalog.WeatherIntroReplies); Assert.Contains("First, the weather tomorrow.", catalog.WeatherTomorrowIntroReplies); - Assert.Contains("Today's high is ${skill.weather.today.highTemp}, and the low is ${skill.weather.today.lowTemp}.", catalog.WeatherTodayHighLowReplies); - Assert.Contains("Tomorrow's high will be ${skill.weather.tomorrow.highTemp} and the low will be ${skill.weather.tomorrow.lowTemp}.", catalog.WeatherTomorrowHighLowReplies); + Assert.Contains( + "Today's high is ${skill.weather.today.highTemp}, and the low is ${skill.weather.today.lowTemp}.", + catalog.WeatherTodayHighLowReplies); + Assert.Contains( + "Tomorrow's high will be ${skill.weather.tomorrow.highTemp} and the low will be ${skill.weather.tomorrow.lowTemp}.", + catalog.WeatherTomorrowHighLowReplies); Assert.Contains("Looks like our weather service is offline. Sorry.", catalog.WeatherServiceDownReplies); Assert.Contains("Sure ${speaker}. Here it is.", catalog.PersonalReportKickOffReplies); - Assert.Contains("And that's your report for the day. I hope you had as much fun as I did.", catalog.PersonalReportOutroReplies); - Assert.Contains("Looking at your calendar, I don't see anything scheduled today.", catalog.CalendarNothingTodayReplies); + Assert.Contains("And that's your report for the day. I hope you had as much fun as I did.", + catalog.PersonalReportOutroReplies); + Assert.Contains("Looking at your calendar, I don't see anything scheduled today.", + catalog.CalendarNothingTodayReplies); Assert.Contains("Sorry, commute information isn't available right now.", catalog.CommuteServiceDownReplies); Assert.Contains("Here's today's news, from the associated press.", catalog.NewsIntroReplies); Assert.Contains("And that's what's new in the news.", catalog.NewsOutroReplies); @@ -245,7 +259,7 @@ public sealed class LegacyMimCatalogImporterTests } finally { - Directory.Delete(rootDirectory, recursive: true); + Directory.Delete(rootDirectory, true); } } @@ -259,10 +273,12 @@ public sealed class LegacyMimCatalogImporterTests Assert.Contains("I think only you can answer that question.", catalog.PersonalityReplies); Assert.Contains(catalog.EmotionReplies, reply => reply.Condition.Contains("NEUTRAL", StringComparison.OrdinalIgnoreCase)); - Assert.Contains("Something's off with the connection to my sources. Maybe ask me again in a little while.", catalog.GenericFallbackReplies); + Assert.Contains("Something's off with the connection to my sources. Maybe ask me again in a little while.", + catalog.GenericFallbackReplies); Assert.Contains("For your weather.", catalog.WeatherIntroReplies); Assert.Contains("Today's high is {high}, and the low is {low}.", catalog.WeatherTodayHighLowReplies); - Assert.Contains("Looking at your calendar, I don't see anything scheduled today.", catalog.CalendarNothingTodayReplies); + Assert.Contains("Looking at your calendar, I don't see anything scheduled today.", + catalog.CalendarNothingTodayReplies); } private static string CreateSeedDirectory() @@ -449,4 +465,4 @@ public sealed class LegacyMimCatalogImporterTests return rootDirectory; } -} +} \ No newline at end of file diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Fixtures/ProtocolFixtureLoader.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Fixtures/ProtocolFixtureLoader.cs index 3accba0..bc48d75 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/Fixtures/ProtocolFixtureLoader.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Fixtures/ProtocolFixtureLoader.cs @@ -13,12 +13,8 @@ internal static class ProtocolFixtureLoader var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); if (root.TryGetProperty("headers", out var headerElement) && headerElement.ValueKind == JsonValueKind.Object) - { foreach (var property in headerElement.EnumerateObject()) - { headers[property.Name] = property.Value.ToString(); - } - } var bodyText = root.TryGetProperty("body", out var bodyElement) ? bodyElement.GetRawText() @@ -34,8 +30,12 @@ internal static class ProtocolFixtureLoader Name = Path.GetFileNameWithoutExtension(relativePath), Request = new ProtocolEnvelope { - HostName = root.TryGetProperty("host", out var hostElement) ? hostElement.GetString() ?? "api.jibo.com" : "api.jibo.com", - Method = root.TryGetProperty("method", out var methodElement) ? methodElement.GetString() ?? "POST" : "POST", + HostName = root.TryGetProperty("host", out var hostElement) + ? hostElement.GetString() ?? "api.jibo.com" + : "api.jibo.com", + Method = root.TryGetProperty("method", out var methodElement) + ? methodElement.GetString() ?? "POST" + : "POST", Path = root.TryGetProperty("path", out var pathElement) ? pathElement.GetString() ?? "/" : "/", Headers = headers, ServicePrefix = targetParts.Length > 0 ? targetParts[0] : null, @@ -45,4 +45,4 @@ internal static class ProtocolFixtureLoader ExpectedStatusCode = 200 }; } -} +} \ No newline at end of file diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Fixtures/WebSocketFixtureLoader.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Fixtures/WebSocketFixtureLoader.cs index cc27890..f641217 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/Fixtures/WebSocketFixtureLoader.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Fixtures/WebSocketFixtureLoader.cs @@ -28,23 +28,31 @@ internal static class WebSocketFixtureLoader Kind = session.GetProperty("kind").GetString() ?? "neo-hub-listen", Token = session.GetProperty("token").GetString(), Text = stepElement.TryGetProperty("text", out var text) ? text.GetRawText() : null, - Binary = stepElement.TryGetProperty("binary", out var binary) && binary.ValueKind == JsonValueKind.Array + Binary = stepElement.TryGetProperty("binary", out var binary) && + binary.ValueKind == JsonValueKind.Array ? binary.EnumerateArray().Select(item => (byte)item.GetInt32()).ToArray() : null }, - ExpectedReplyTypes = [.. stepElement.GetProperty("expectedReplyTypes") - .EnumerateArray() - .Select(item => item.GetString() ?? string.Empty) - .Where(item => !string.IsNullOrWhiteSpace(item))], - ExpectedReplies = stepElement.TryGetProperty("expectedReplies", out var expectedReplies) && expectedReplies.ValueKind == JsonValueKind.Array - ? JsonSerializer.Deserialize>(expectedReplies.GetRawText(), SerializerOptions) ?? [] + ExpectedReplyTypes = + [ + .. stepElement.GetProperty("expectedReplyTypes") + .EnumerateArray() + .Select(item => item.GetString() ?? string.Empty) + .Where(item => !string.IsNullOrWhiteSpace(item)) + ], + ExpectedReplies = stepElement.TryGetProperty("expectedReplies", out var expectedReplies) && + expectedReplies.ValueKind == JsonValueKind.Array + ? JsonSerializer.Deserialize>(expectedReplies.GetRawText(), + SerializerOptions) ?? [] : [] }) .ToList(); return new WebSocketFixture { - Name = root.TryGetProperty("name", out var name) ? name.GetString() ?? Path.GetFileNameWithoutExtension(relativePath) : Path.GetFileNameWithoutExtension(relativePath), + Name = root.TryGetProperty("name", out var name) + ? name.GetString() ?? Path.GetFileNameWithoutExtension(relativePath) + : Path.GetFileNameWithoutExtension(relativePath), Steps = steps }; } @@ -68,4 +76,4 @@ internal sealed class ExpectedWebSocketReply public string Type { get; init; } = string.Empty; public int? DelayMs { get; init; } public JsonElement? JsonSubset { get; init; } -} +} \ No newline at end of file diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/GlobalUsings.cs b/OpenJibo/tests/Jibo.Cloud.Tests/GlobalUsings.cs index c802f44..8c927eb 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/GlobalUsings.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/GlobalUsings.cs @@ -1 +1 @@ -global using Xunit; +global using Xunit; \ No newline at end of file diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/AzureBlobPersistenceSmokeTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/AzureBlobPersistenceSmokeTests.cs index 7d9b87e..a94226b 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/AzureBlobPersistenceSmokeTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/AzureBlobPersistenceSmokeTests.cs @@ -10,13 +10,12 @@ public sealed class AzureBlobPersistenceSmokeTests var stateBackend = Environment.GetEnvironmentVariable("OpenJibo__State__Backend"); var stateConnectionString = Environment.GetEnvironmentVariable("OpenJibo__State__ConnectionString"); var personalMemoryBackend = Environment.GetEnvironmentVariable("OpenJibo__PersonalMemory__Backend"); - var personalMemoryConnectionString = Environment.GetEnvironmentVariable("OpenJibo__PersonalMemory__ConnectionString"); + var personalMemoryConnectionString = + Environment.GetEnvironmentVariable("OpenJibo__PersonalMemory__ConnectionString"); if (!string.Equals(stateBackend, "AzureBlob", StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(stateConnectionString)) - { return; - } var factory = new PersistenceSnapshotStoreFactory(); var snapshotName = $"smoke-{Guid.NewGuid():N}"; @@ -45,4 +44,4 @@ public sealed class AzureBlobPersistenceSmokeTests public string Name { get; init; } = string.Empty; public string Value { get; init; } = string.Empty; } -} +} \ No newline at end of file diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/PersistenceStoreTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/PersistenceStoreTests.cs index 7ba6924..fde3804 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/PersistenceStoreTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/PersistenceStoreTests.cs @@ -10,7 +10,8 @@ public sealed class PersistenceStoreTests { var factory = new PersistenceSnapshotStoreFactory(); - var store = factory.Create(Path.Combine(Path.GetTempPath(), $"factory-{Guid.NewGuid():N}.json"), PersistenceBackendKind.File, "sample-snapshot"); + var store = factory.Create(Path.Combine(Path.GetTempPath(), $"factory-{Guid.NewGuid():N}.json"), + PersistenceBackendKind.File, "sample-snapshot"); Assert.Equal("JsonFileSnapshotStore", store.GetType().Name); } @@ -21,7 +22,8 @@ public sealed class PersistenceStoreTests var factory = new PersistenceSnapshotStoreFactory(); Assert.Throws(() => - factory.Create(Path.Combine(Path.GetTempPath(), $"factory-{Guid.NewGuid():N}.json"), PersistenceBackendKind.AzureSql, "sample-snapshot")); + factory.Create(Path.Combine(Path.GetTempPath(), $"factory-{Guid.NewGuid():N}.json"), + PersistenceBackendKind.AzureSql, "sample-snapshot")); } [Fact] @@ -86,10 +88,7 @@ public sealed class PersistenceStoreTests } finally { - if (File.Exists(persistencePath)) - { - File.Delete(persistencePath); - } + if (File.Exists(persistencePath)) File.Delete(persistencePath); } } @@ -102,7 +101,8 @@ public sealed class PersistenceStoreTests { var firstStore = new InMemoryCloudStateStore(persistencePath); var update = firstStore.CreateUpdate("1.0.0", "1.0.1", "Bug fix", null, 42, "robot", null, null); - var media = firstStore.CreateMedia("openjibo-default-loop", "persisted-photo", "image", "photo-ref", false, new Dictionary { ["note"] = "roundtrip" }); + var media = firstStore.CreateMedia("openjibo-default-loop", "persisted-photo", "image", "photo-ref", false, + new Dictionary { ["note"] = "roundtrip" }); var sessionToken = firstStore.IssueRobotToken("robot-123"); var device = firstStore.GetOrCreateDevice("robot-123", "3.2.1", "4.5.6"); firstStore.SavePersistedState(); @@ -124,10 +124,7 @@ public sealed class PersistenceStoreTests } finally { - if (File.Exists(persistencePath)) - { - File.Delete(persistencePath); - } + if (File.Exists(persistencePath)) File.Delete(persistencePath); } } @@ -137,7 +134,7 @@ public sealed class PersistenceStoreTests public TSnapshot2? Load() where TSnapshot2 : class { - return default; + return null; } public void Save(TSnapshot2 snapshot) where TSnapshot2 : class @@ -145,4 +142,4 @@ public sealed class PersistenceStoreTests Saves.Add(snapshot); } } -} +} \ No newline at end of file diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/ProviderCachingTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/ProviderCachingTests.cs index ee5ee04..b00da7a 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/ProviderCachingTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/ProviderCachingTests.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Linq; using System.Text; using Jibo.Cloud.Application.Abstractions; using Jibo.Cloud.Infrastructure.News; @@ -93,7 +92,8 @@ public sealed class ProviderCachingTests }, NullLogger.Instance); - var report = await provider.GetReportAsync(new WeatherReportRequest("Lone Jack,US", null, null, false, false, 0)); + var report = + await provider.GetReportAsync(new WeatherReportRequest("Lone Jack,US", null, null, false, false, 0)); Assert.NotNull(report); Assert.Equal(76, report!.Temperature); @@ -202,9 +202,7 @@ public sealed class ProviderCachingTests { if (!message.Headers.TryGetValues("User-Agent", out var userAgents) || !userAgents.Any()) - { missingUserAgentRequestCount += 1; - } var path = message.RequestUri?.AbsolutePath ?? string.Empty; return path switch @@ -224,7 +222,7 @@ public sealed class ProviderCachingTests }, NullLogger.Instance); - var request = new NewsBriefingRequest(["sports"], 3); + var request = new NewsBriefingRequest(["sports"]); var first = await provider.GetBriefingAsync(request); var second = await provider.GetBriefingAsync(request); @@ -241,18 +239,12 @@ public sealed class ProviderCachingTests { var path = message.RequestUri?.AbsolutePath ?? string.Empty; if (!string.Equals(path, "/v2/top-headlines", StringComparison.OrdinalIgnoreCase)) - { return new HttpResponseMessage(HttpStatusCode.NotFound); - } var query = message.RequestUri?.Query ?? string.Empty; - if (query.Contains("category=sports", StringComparison.OrdinalIgnoreCase)) - { - return JsonResponse("""{"status":"ok","articles":[]}"""); - } - - return JsonResponse( - """{"status":"ok","articles":[{"title":"General robotics update","description":"Top story","source":{"name":"AP News"},"url":"https://example.com/general"}]}"""); + return JsonResponse(query.Contains("category=sports", StringComparison.OrdinalIgnoreCase) + ? """{"status":"ok","articles":[]}""" + : """{"status":"ok","articles":[{"title":"General robotics update","description":"Top story","source":{"name":"AP News"},"url":"https://example.com/general"}]}"""); }); var provider = new NewsApiBriefingProvider( new HttpClient(handler), @@ -264,7 +256,7 @@ public sealed class ProviderCachingTests }, NullLogger.Instance); - var result = await provider.GetBriefingAsync(new NewsBriefingRequest(["sports"], 3)); + var result = await provider.GetBriefingAsync(new NewsBriefingRequest(["sports"])); Assert.NotNull(result); Assert.Single(result!.Headlines); @@ -297,7 +289,7 @@ public sealed class ProviderCachingTests }, NullLogger.Instance); - var result = await provider.GetBriefingAsync(new NewsBriefingRequest([], 3)); + var result = await provider.GetBriefingAsync(new NewsBriefingRequest([])); Assert.NotNull(result); Assert.Single(result!.Headlines); @@ -313,13 +305,10 @@ public sealed class ProviderCachingTests { var path = message.RequestUri?.AbsolutePath ?? string.Empty; if (!string.Equals(path, "/v2/top-headlines", StringComparison.OrdinalIgnoreCase)) - { return new HttpResponseMessage(HttpStatusCode.NotFound); - } var query = message.RequestUri?.Query ?? string.Empty; if (query.Contains("category=sports", StringComparison.OrdinalIgnoreCase)) - { return new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent( @@ -327,7 +316,6 @@ public sealed class ProviderCachingTests Encoding.UTF8, "application/json") }; - } return JsonResponse( """{"status":"ok","articles":[{"title":"General robotics update","description":"Top story","source":{"name":"AP News"},"url":"https://example.com/general"}]}"""); @@ -342,7 +330,7 @@ public sealed class ProviderCachingTests }, NullLogger.Instance); - var result = await provider.GetBriefingAsync(new NewsBriefingRequest(["sports"], 3)); + var result = await provider.GetBriefingAsync(new NewsBriefingRequest(["sports"])); Assert.NotNull(result); Assert.Single(result!.Headlines); @@ -358,7 +346,6 @@ public sealed class ProviderCachingTests { var path = message.RequestUri?.AbsolutePath ?? string.Empty; if (string.Equals(path, "/v2/top-headlines", StringComparison.OrdinalIgnoreCase)) - { return new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent( @@ -366,10 +353,8 @@ public sealed class ProviderCachingTests Encoding.UTF8, "application/json") }; - } if (string.Equals(path, "/v2/everything", StringComparison.OrdinalIgnoreCase)) - { return new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent( @@ -377,7 +362,6 @@ public sealed class ProviderCachingTests Encoding.UTF8, "application/json") }; - } return new HttpResponseMessage(HttpStatusCode.NotFound); }); @@ -392,7 +376,7 @@ public sealed class ProviderCachingTests }, NullLogger.Instance); - var result = await provider.GetBriefingAsync(new NewsBriefingRequest([], 3)); + var result = await provider.GetBriefingAsync(new NewsBriefingRequest([])); Assert.NotNull(result); Assert.Empty(result!.Headlines); @@ -414,14 +398,14 @@ public sealed class ProviderCachingTests private sealed class CountingHttpMessageHandler(Func responseFactory) : HttpMessageHandler { - private readonly Dictionary callsByPath = new(StringComparer.OrdinalIgnoreCase); - private readonly object gate = new(); + private readonly Dictionary _callsByPath = new(StringComparer.OrdinalIgnoreCase); + private readonly Lock _gate = new(); public int GetCallCount(string path) { - lock (gate) + lock (_gate) { - return callsByPath.TryGetValue(path, out var count) ? count : 0; + return _callsByPath.GetValueOrDefault(path, 0); } } @@ -430,12 +414,12 @@ public sealed class ProviderCachingTests CancellationToken cancellationToken) { var path = request.RequestUri?.AbsolutePath ?? string.Empty; - lock (gate) + lock (_gate) { - callsByPath[path] = callsByPath.TryGetValue(path, out var count) ? count + 1 : 1; + _callsByPath[path] = _callsByPath.TryGetValue(path, out var count) ? count + 1 : 1; } return Task.FromResult(responseFactory(request)); } } -} +} \ No newline at end of file diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/FileProtocolTelemetrySinkTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/FileProtocolTelemetrySinkTests.cs index 808395f..2f0ee5c 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/FileProtocolTelemetrySinkTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/FileProtocolTelemetrySinkTests.cs @@ -7,21 +7,28 @@ namespace Jibo.Cloud.Tests.Protocol; public sealed class FileProtocolTelemetrySinkTests : IDisposable { - private readonly string _workspaceRoot; - private readonly string _repoRoot; private readonly string _appBaseDirectory; + private readonly string _repoRoot; + private readonly string _workspaceRoot; public FileProtocolTelemetrySinkTests() { - _workspaceRoot = Path.Combine(Path.GetTempPath(), "OpenJibo.ProtocolTelemetry.Tests", Guid.NewGuid().ToString("N")); + _workspaceRoot = Path.Combine(Path.GetTempPath(), "OpenJibo.ProtocolTelemetry.Tests", + Guid.NewGuid().ToString("N")); _repoRoot = Path.Combine(_workspaceRoot, "OpenJibo"); - _appBaseDirectory = Path.Combine(_repoRoot, "src", "Jibo.Cloud", "dotnet", "src", "Jibo.Cloud.Api", "bin", "Debug", "net10.0"); + _appBaseDirectory = Path.Combine(_repoRoot, "src", "Jibo.Cloud", "dotnet", "src", "Jibo.Cloud.Api", "bin", + "Debug", "net10.0"); Directory.CreateDirectory(_repoRoot); Directory.CreateDirectory(_appBaseDirectory); File.WriteAllText(Path.Combine(_repoRoot, "OpenJibo.slnx"), string.Empty); } + public void Dispose() + { + if (Directory.Exists(_workspaceRoot)) Directory.Delete(_workspaceRoot, true); + } + [Fact] public async Task RecordAsync_ResolvesRelativePathAgainstOpenJiboRepoRoot() { @@ -52,12 +59,4 @@ public sealed class FileProtocolTelemetrySinkTests : IDisposable Assert.Contains("Notification_20150505", contents); Assert.DoesNotContain(Path.Combine("bin", "Debug"), captureFile, StringComparison.OrdinalIgnoreCase); } - - public void Dispose() - { - if (Directory.Exists(_workspaceRoot)) - { - Directory.Delete(_workspaceRoot, true); - } - } -} +} \ No newline at end of file diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/JiboCloudProtocolServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/JiboCloudProtocolServiceTests.cs index cc2bdee..9bf261b 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/JiboCloudProtocolServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/JiboCloudProtocolServiceTests.cs @@ -144,10 +144,7 @@ public sealed class JiboCloudProtocolServiceTests } finally { - if (File.Exists(persistencePath)) - { - File.Delete(persistencePath); - } + if (File.Exists(persistencePath)) File.Delete(persistencePath); } } @@ -170,7 +167,8 @@ public sealed class JiboCloudProtocolServiceTests }); using var createdPayload = JsonDocument.Parse(result.BodyText); - Assert.Equal("https://api.jibo.com/media/photo-blob-1", createdPayload.RootElement.GetProperty("url").GetString()); + Assert.Equal("https://api.jibo.com/media/photo-blob-1", + createdPayload.RootElement.GetProperty("url").GetString()); var mediaGet = await _service.DispatchAsync(new ProtocolEnvelope { @@ -226,7 +224,10 @@ public sealed class JiboCloudProtocolServiceTests Assert.NotEmpty(people); Assert.Contains(people, person => person.IsPrimary); - Assert.Contains(people, person => string.Equals(person.AccountId, store.GetAccount().AccountId, StringComparison.OrdinalIgnoreCase)); - Assert.Contains(people, person => string.Equals(person.LoopId, store.GetLoops()[0].LoopId, StringComparison.OrdinalIgnoreCase)); + Assert.Contains(people, + person => string.Equals(person.AccountId, store.GetAccount().AccountId, + StringComparison.OrdinalIgnoreCase)); + Assert.Contains(people, + person => string.Equals(person.LoopId, store.GetLoops()[0].LoopId, StringComparison.OrdinalIgnoreCase)); } -} +} \ No newline at end of file diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/ProtocolFixtureReplayTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/ProtocolFixtureReplayTests.cs index 8c64d56..1577cf7 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/ProtocolFixtureReplayTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/ProtocolFixtureReplayTests.cs @@ -19,4 +19,4 @@ public sealed class ProtocolFixtureReplayTests Assert.Equal(fixture.ExpectedStatusCode, result.StatusCode); Assert.False(string.IsNullOrWhiteSpace(result.BodyText)); } -} +} \ No newline at end of file diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Turn/FileTurnTelemetrySinkTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Turn/FileTurnTelemetrySinkTests.cs index 9dfe7fe..fc26da0 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/Turn/FileTurnTelemetrySinkTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Turn/FileTurnTelemetrySinkTests.cs @@ -106,7 +106,8 @@ public sealed class FileTurnTelemetrySinkTests public async Task HandleContext_EmitsGlsmPhaseTransitionDiagnostic() { var sink = new Mock(); - sink.Setup(s => s.RecordTurnDiagnosticAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + sink.Setup(s => s.RecordTurnDiagnosticAsync(It.IsAny(), + It.IsAny>(), It.IsAny())) .Returns(Task.CompletedTask); var turnService = new WebSocketTurnFinalizationService( Mock.Of(), @@ -142,8 +143,9 @@ public sealed class FileTurnTelemetrySinkTests "glsm_phase_transition", It.Is>(details => details.ContainsKey("state") && - string.Equals(details["state"] == null ? null : details["state"]!.ToString(), "LISTENING", StringComparison.OrdinalIgnoreCase)), + string.Equals(details["state"] == null ? null : details["state"]!.ToString(), "LISTENING", + StringComparison.OrdinalIgnoreCase)), It.IsAny()), Times.AtLeastOnce()); } -} +} \ No newline at end of file diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/FileWebSocketTelemetrySinkTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/FileWebSocketTelemetrySinkTests.cs index 46297d6..a03881b 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/FileWebSocketTelemetrySinkTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/FileWebSocketTelemetrySinkTests.cs @@ -8,15 +8,21 @@ namespace Jibo.Cloud.Tests.WebSockets; public sealed class FileWebSocketTelemetrySinkTests : IDisposable { + private readonly string _appBaseDirectory; private readonly string _directoryPath; private readonly string _repoRoot; - private readonly string _appBaseDirectory; public FileWebSocketTelemetrySinkTests() { _directoryPath = Path.Combine(Path.GetTempPath(), "OpenJibo.Tests", Guid.NewGuid().ToString("N")); _repoRoot = Path.Combine(_directoryPath, "OpenJibo"); - _appBaseDirectory = Path.Combine(_repoRoot, "src", "Jibo.Cloud", "dotnet", "src", "Jibo.Cloud.Api", "bin", "Debug", "net10.0"); + _appBaseDirectory = Path.Combine(_repoRoot, "src", "Jibo.Cloud", "dotnet", "src", "Jibo.Cloud.Api", "bin", + "Debug", "net10.0"); + } + + public void Dispose() + { + if (Directory.Exists(_directoryPath)) Directory.Delete(_directoryPath, true); } [Fact] @@ -55,17 +61,11 @@ public sealed class FileWebSocketTelemetrySinkTests : IDisposable var fixtureDirectory = Path.Combine(_directoryPath, "fixtures"); var fixturePath = Directory.GetFiles(fixtureDirectory, "*.flow.json").Single(); using var document = JsonDocument.Parse(await File.ReadAllTextAsync(fixturePath)); - Assert.Equal("neo-hub.jibo.com", document.RootElement.GetProperty("session").GetProperty("hostName").GetString()); + Assert.Equal("neo-hub.jibo.com", + document.RootElement.GetProperty("session").GetProperty("hostName").GetString()); Assert.Equal(1, document.RootElement.GetProperty("steps").GetArrayLength()); - Assert.Equal("LISTEN", document.RootElement.GetProperty("steps")[0].GetProperty("expectedReplyTypes")[0].GetString()); - } - - public void Dispose() - { - if (Directory.Exists(_directoryPath)) - { - Directory.Delete(_directoryPath, true); - } + Assert.Equal("LISTEN", + document.RootElement.GetProperty("steps")[0].GetProperty("expectedReplyTypes")[0].GetString()); } [Fact] diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs index 41c3622..cf00393 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs @@ -1,9 +1,9 @@ +using System.Text.Json; using Jibo.Cloud.Application.Abstractions; using Jibo.Cloud.Application.Services; using Jibo.Cloud.Infrastructure.Content; using Jibo.Cloud.Infrastructure.Persistence; using Jibo.Runtime.Abstractions; -using System.Text.Json; namespace Jibo.Cloud.Tests.WebSockets; @@ -56,9 +56,13 @@ public sealed class JiboInteractionServiceTests Assert.Equal("dance", decision.IntentName); Assert.Equal("chitchat-skill", decision.SkillName); - var catalog = await new InMemoryJiboExperienceContentRepository().GetCatalogAsync(); // Ensure catalog is loaded for test coverage + var catalog = + await new InMemoryJiboExperienceContentRepository() + .GetCatalogAsync(); // Ensure catalog is loaded for test coverage Assert.Contains(decision.ReplyText, catalog.DanceReplies); - Assert.Equal("Okay. Watch this.", decision.SkillPayload!["esml"]); + Assert.Equal( + "Okay. Watch this.", + decision.SkillPayload!["esml"]); } [Fact] @@ -198,7 +202,8 @@ public sealed class JiboInteractionServiceTests { ["accountId"] = "acct-a", ["loopId"] = "loop-a", - ["context"] = """{"runtime":{"perception":{"speaker":"person-1"},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}""" + ["context"] = + """{"runtime":{"perception":{"speaker":"person-1"},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}""" }, DeviceId = "device-a" }); @@ -222,7 +227,8 @@ public sealed class JiboInteractionServiceTests { ["accountId"] = "acct-b", ["loopId"] = "loop-b", - ["context"] = """{"runtime":{"perception":{"speaker":"person-2"},"loop":{"users":[{"id":"person-2","firstName":"sam"}]}}}""" + ["context"] = + """{"runtime":{"perception":{"speaker":"person-2"},"loop":{"users":[{"id":"person-2","firstName":"sam"}]}}}""" }, DeviceId = "device-b" }); @@ -250,7 +256,8 @@ public sealed class JiboInteractionServiceTests { ["accountId"] = "acct-c", ["loopId"] = "loop-c", - ["context"] = """{"runtime":{"perception":{"speaker":"person-3"},"loop":{"users":[{"id":"person-3","firstName":"taylor"}]}}}""" + ["context"] = + """{"runtime":{"perception":{"speaker":"person-3"},"loop":{"users":[{"id":"person-3","firstName":"taylor"}]}}}""" }, DeviceId = "device-c" }); @@ -287,7 +294,8 @@ public sealed class JiboInteractionServiceTests { ["messageType"] = "TRIGGER", ["triggerSource"] = "PRESENCE", - ["context"] = """{"runtime":{"perception":{"speaker":"person-1","peoplePresent":[{"id":"person-1"}]},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}""" + ["context"] = + """{"runtime":{"perception":{"speaker":"person-1","peoplePresent":[{"id":"person-1"}]},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}""" } }); @@ -312,7 +320,8 @@ public sealed class JiboInteractionServiceTests { ["messageType"] = "TRIGGER", ["triggerSource"] = "SURPRISE", - ["context"] = """{"runtime":{"perception":{"speaker":"person-1"},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}""" + ["context"] = + """{"runtime":{"perception":{"speaker":"person-1"},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}""" } }); @@ -336,7 +345,8 @@ public sealed class JiboInteractionServiceTests { ["messageType"] = "TRIGGER", ["triggerSource"] = "PRESENCE", - ["context"] = """{"runtime":{"perception":{"speaker":"person-1"},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}""", + ["context"] = + """{"runtime":{"perception":{"speaker":"person-1"},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}""", [GreetingLastProactiveUtcKey] = DateTimeOffset.UtcNow.ToString("O") } }); @@ -427,13 +437,18 @@ public sealed class JiboInteractionServiceTests } [Theory] - [InlineData("how do you work", "robot_how_do_you_work", "Hello! Thank you for updating me I am proud of the community's work Many people have gotten together to care for me more than em eye tee ever did. I hope that I can catch up even though it has been seven years.")] + [InlineData("how do you work", "robot_how_do_you_work", + "Hello! Thank you for updating me I am proud of the community's work Many people have gotten together to care for me more than em eye tee ever did. I hope that I can catch up even though it has been seven years.")] [InlineData("what do you eat", "robot_what_do_you_eat", "The only thing I consume is electricity.")] - [InlineData("where do you live", "robot_where_do_you_live", "Unless I missed something, we're in my home as we speak.")] + [InlineData("where do you live", "robot_where_do_you_live", + "Unless I missed something, we're in my home as we speak.")] [InlineData("where were you born", "robot_where_were_you_born", "I was put together in a factory piece by piece.")] - [InlineData("what languages do you speak", "robot_what_languages_do_you_speak", "For now just English. But someday I'd like to learn more. I like languages.")] - [InlineData("what do you like to do", "robot_what_do_you_like_to_do", "Being helpful, making people smile, counting to a billion.")] - [InlineData("what are you made of", "robot_what_are_you_made_of", "Let's see, I'm made of wires, motors, belts, gears, processors, cameras, and one baboon's heart in the middle of my body casing. I'm kidding about the baboon part, but everything else is true.")] + [InlineData("what languages do you speak", "robot_what_languages_do_you_speak", + "For now just English. But someday I'd like to learn more. I like languages.")] + [InlineData("what do you like to do", "robot_what_do_you_like_to_do", + "Being helpful, making people smile, counting to a billion.")] + [InlineData("what are you made of", "robot_what_are_you_made_of", + "Let's see, I'm made of wires, motors, belts, gears, processors, cameras, and one baboon's heart in the middle of my body casing. I'm kidding about the baboon part, but everything else is true.")] public async Task BuildDecisionAsync_MoreLegacyPersonaMims_UseImportedReplies( string transcript, string expectedIntent, @@ -454,10 +469,13 @@ public sealed class JiboInteractionServiceTests [Theory] [InlineData("do you pay taxes", "robot_taxes", "From what I understand, robots don't ever pay anything.")] - [InlineData("what do you want", "robot_desire", "Socializing and electricity. I'd also be happy if everyone in the world was nicer to each other. It seems like they should be.")] + [InlineData("what do you want", "robot_desire", + "Socializing and electricity. I'd also be happy if everyone in the world was nicer to each other. It seems like they should be.")] [InlineData("what's your name", "robot_name", "Jibo. Just Jibo, no last name. Like Bono")] - [InlineData("who made you", "robot_origin_created", "My story is pretty typical. Some people wanted to create something that would really help people. So they built a robot.")] - [InlineData("where are you from", "robot_origin_from", "Some people think I come from the moon. But they're wrong, I'm from Boston.")] + [InlineData("who made you", "robot_origin_created", + "My story is pretty typical. Some people wanted to create something that would really help people. So they built a robot.")] + [InlineData("where are you from", "robot_origin_from", + "Some people think I come from the moon. But they're wrong, I'm from Boston.")] public async Task BuildDecisionAsync_LegacyBuildAQuestions_UseImportedScriptedReplies( string transcript, string expectedIntent, @@ -552,7 +570,8 @@ public sealed class JiboInteractionServiceTests [InlineData("what are you up to", "being helpful")] [InlineData("what are you doing", "making people smile")] [InlineData("what have you been up to", "being helpful")] - public async Task BuildDecisionAsync_PersonalityFollowups_UseDoingPath(string transcript, string expectedReplySnippet) + public async Task BuildDecisionAsync_PersonalityFollowups_UseDoingPath(string transcript, + string expectedReplySnippet) { var service = CreateService(); @@ -570,11 +589,14 @@ public sealed class JiboInteractionServiceTests [Theory] [InlineData("happy holidays", "seasonal_holiday_greeting", "It's a fun time of year")] [InlineData("merry christmas", "seasonal_holiday_greeting", "It's a fun time of year")] - [InlineData("what holidays do you celebrate", "seasonal_holidays", "official owner can tell me which ones we'll celebrate together")] - [InlineData("what is your new year's resolution", "seasonal_new_years_resolution", "always trying to learn new skills")] + [InlineData("what holidays do you celebrate", "seasonal_holidays", + "official owner can tell me which ones we'll celebrate together")] + [InlineData("what is your new year's resolution", "seasonal_new_years_resolution", + "always trying to learn new skills")] [InlineData("how are your new year's resolutions going", "seasonal_new_years_update", "not eat bacon")] [InlineData("what halloween costume", "seasonal_halloween_costume", "I haven't thought much about it yet")] - [InlineData("what should I do for first day of spring", "seasonal_first_day_spring", "flowers and all things spring")] + [InlineData("what should I do for first day of spring", "seasonal_first_day_spring", + "flowers and all things spring")] [InlineData("what should I get for holiday", "seasonal_holiday_gift", "pet elephant")] public async Task BuildDecisionAsync_SeasonalCharm_UsesImportedReplies( string transcript, @@ -672,7 +694,7 @@ public sealed class JiboInteractionServiceTests try { - File.WriteAllText( + await File.WriteAllTextAsync( Path.Combine(rootDirectory, "gqa-responses", "GQA_JBO_IsHappy.mim"), """ { @@ -710,7 +732,7 @@ public sealed class JiboInteractionServiceTests } finally { - Directory.Delete(rootDirectory, recursive: true); + Directory.Delete(rootDirectory, true); } } @@ -1082,7 +1104,8 @@ public sealed class JiboInteractionServiceTests }); Assert.Equal("memory_get_birthday", otherTenantRecall.IntentName); - Assert.Equal("I do not know your birthday yet. You can say, my birthday is March 14.", otherTenantRecall.ReplyText); + Assert.Equal("I do not know your birthday yet. You can say, my birthday is March 14.", + otherTenantRecall.ReplyText); } [Fact] @@ -1726,12 +1749,18 @@ public sealed class JiboInteractionServiceTests Assert.Equal("personal_report_delivered", decision.IntentName); Assert.Contains("Sure alex. Here it is.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); Assert.Contains("First, your weather.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); - Assert.Contains("For your weather. In Boston, U.S., it's light rain and 61 degrees Fahrenheit. Today's high is 65, and the low is 54.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Looking at your calendar, I don't see anything scheduled today.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Sorry, commute information isn't available right now.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Here's today's news, from the associated press.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Contains( + "For your weather. In Boston, U.S., it's light rain and 61 degrees Fahrenheit. Today's high is 65, and the low is 54.", + decision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Looking at your calendar, I don't see anything scheduled today.", decision.ReplyText, + StringComparison.OrdinalIgnoreCase); + Assert.Contains("Sorry, commute information isn't available right now.", decision.ReplyText, + StringComparison.OrdinalIgnoreCase); + Assert.Contains("Here's today's news, from the associated press.", decision.ReplyText, + StringComparison.OrdinalIgnoreCase); Assert.Contains("And that's what's new in the news.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); - Assert.Contains("alex that wraps up your report for the day. Hope you have a good one.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("alex that wraps up your report for the day. Hope you have a good one.", decision.ReplyText, + StringComparison.OrdinalIgnoreCase); Assert.NotNull(decision.ContextUpdates); Assert.Equal("idle", decision.ContextUpdates![PersonalReportStateKey]); Assert.Equal(true, decision.ContextUpdates[PersonalReportUserVerifiedKey]); @@ -1856,7 +1885,8 @@ public sealed class JiboInteractionServiceTests Assert.Contains("What else should I add?", addDecision.ReplyText, StringComparison.OrdinalIgnoreCase); Assert.Equal("awaiting_item", addDecision.ContextUpdates![HouseholdListStateKey]); Assert.Equal("shopping", addDecision.ContextUpdates[HouseholdListTypeKey]); - Assert.Equal(["milk"], memoryStore.GetListItems(new PersonalMemoryTenantScope("acct-a", "loop-a", "device-a"), "shopping")); + Assert.Equal(["milk"], + memoryStore.GetListItems(new PersonalMemoryTenantScope("acct-a", "loop-a", "device-a"), "shopping")); var doneDecision = await service.BuildDecisionAsync(new TurnContext { @@ -1871,7 +1901,8 @@ public sealed class JiboInteractionServiceTests }); Assert.Equal("shopping_list_done", doneDecision.IntentName); - Assert.Contains("Okay. Your shopping list has milk.", doneDecision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Okay. Your shopping list has milk.", doneDecision.ReplyText, + StringComparison.OrdinalIgnoreCase); Assert.Equal("idle", doneDecision.ContextUpdates![HouseholdListStateKey]); var recallDecision = await service.BuildDecisionAsync(new TurnContext @@ -1924,7 +1955,8 @@ public sealed class JiboInteractionServiceTests Assert.Equal("todo_list_add", addDecision.IntentName); Assert.Contains("call mom", addDecision.ReplyText, StringComparison.OrdinalIgnoreCase); Assert.Contains("What else should I add?", addDecision.ReplyText, StringComparison.OrdinalIgnoreCase); - Assert.Equal(["call mom"], memoryStore.GetListItems(new PersonalMemoryTenantScope("acct-b", "loop-b", "device-b"), "todo")); + Assert.Equal(["call mom"], + memoryStore.GetListItems(new PersonalMemoryTenantScope("acct-b", "loop-b", "device-b"), "todo")); var doneDecision = await service.BuildDecisionAsync(new TurnContext { @@ -1939,7 +1971,8 @@ public sealed class JiboInteractionServiceTests }); Assert.Equal("todo_list_done", doneDecision.IntentName); - Assert.Contains("Okay. Your to-do list has call mom.", doneDecision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Okay. Your to-do list has call mom.", doneDecision.ReplyText, + StringComparison.OrdinalIgnoreCase); Assert.Equal("idle", doneDecision.ContextUpdates![HouseholdListStateKey]); } @@ -2057,7 +2090,8 @@ public sealed class JiboInteractionServiceTests Assert.Equal("weather", decision.IntentName); Assert.Equal("chitchat-skill", decision.SkillName); Assert.NotNull(decision.SkillPayload); - Assert.Contains("cat='weather'", decision.SkillPayload!["esml"]?.ToString(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("cat='weather'", decision.SkillPayload!["esml"]?.ToString(), + StringComparison.OrdinalIgnoreCase); Assert.Contains("meta='rain'", decision.SkillPayload["esml"]?.ToString(), StringComparison.OrdinalIgnoreCase); Assert.Equal("report-skill", decision.SkillPayload["skillId"]); Assert.Equal("WeatherCommentRain", decision.SkillPayload["mim_id"]); @@ -2068,7 +2102,9 @@ public sealed class JiboInteractionServiceTests Assert.Equal(54, decision.SkillPayload["weather_low"]); Assert.Equal("F", decision.SkillPayload["weather_unit"]); Assert.Equal("Normal", decision.SkillPayload["weather_theme"]); - Assert.Equal("For your weather. In Boston, U.S., it's light rain and 61 degrees Fahrenheit. Today's high is 65, and the low is 54.", decision.ReplyText); + Assert.Equal( + "For your weather. In Boston, U.S., it's light rain and 61 degrees Fahrenheit. Today's high is 65, and the low is 54.", + decision.ReplyText); Assert.NotNull(provider.LastRequest); Assert.False(provider.LastRequest!.IsTomorrow); Assert.Equal(0, provider.LastRequest.ForecastDayOffset); @@ -2093,7 +2129,9 @@ public sealed class JiboInteractionServiceTests Assert.Equal("Chicago", provider.LastRequest?.LocationQuery); Assert.True(provider.LastRequest?.IsTomorrow); Assert.Equal(1, provider.LastRequest?.ForecastDayOffset); - Assert.Equal("First, the weather tomorrow. Tomorrow in Chicago, U.S., it looks mostly cloudy. Tomorrow's high will be 74 and the low will be 60.", decision.ReplyText); + Assert.Equal( + "First, the weather tomorrow. Tomorrow in Chicago, U.S., it looks mostly cloudy. Tomorrow's high will be 74 and the low will be 60.", + decision.ReplyText); } [Fact] @@ -2115,7 +2153,9 @@ public sealed class JiboInteractionServiceTests Assert.Equal("Seattle", provider.LastRequest?.LocationQuery); Assert.False(provider.LastRequest?.IsTomorrow); Assert.Equal(0, provider.LastRequest?.ForecastDayOffset); - Assert.Equal("For your weather. In Seattle, U.S., it's light rain and 58 degrees Fahrenheit. Today's high is 61, and the low is 52.", decision.ReplyText); + Assert.Equal( + "For your weather. In Seattle, U.S., it's light rain and 58 degrees Fahrenheit. Today's high is 61, and the low is 52.", + decision.ReplyText); } [Fact] @@ -2137,7 +2177,9 @@ public sealed class JiboInteractionServiceTests Assert.Equal("Paris", provider.LastRequest?.LocationQuery); Assert.False(provider.LastRequest?.IsTomorrow); Assert.Equal(0, provider.LastRequest?.ForecastDayOffset); - Assert.Equal("For your weather. In Paris, FR, it's overcast clouds and 66 degrees Fahrenheit. Today's high is 70, and the low is 60.", decision.ReplyText); + Assert.Equal( + "For your weather. In Paris, FR, it's overcast clouds and 66 degrees Fahrenheit. Today's high is 70, and the low is 60.", + decision.ReplyText); } [Fact] @@ -2159,7 +2201,9 @@ public sealed class JiboInteractionServiceTests Assert.Equal("Redmond Oregon", provider.LastRequest?.LocationQuery); Assert.False(provider.LastRequest?.IsTomorrow); Assert.Equal(0, provider.LastRequest?.ForecastDayOffset); - Assert.Equal("For your weather. In Redmond, U.S., it's clear sky and 63 degrees Fahrenheit. Today's high is 66, and the low is 52.", decision.ReplyText); + Assert.Equal( + "For your weather. In Redmond, U.S., it's clear sky and 63 degrees Fahrenheit. Today's high is 66, and the low is 52.", + decision.ReplyText); } [Fact] @@ -2181,7 +2225,9 @@ public sealed class JiboInteractionServiceTests Assert.Equal("New York City", provider.LastRequest?.LocationQuery); Assert.True(provider.LastRequest?.IsTomorrow); Assert.Equal(1, provider.LastRequest?.ForecastDayOffset); - Assert.Equal("First, the weather tomorrow. Tomorrow in New York, U.S., it looks partly cloudy. Tomorrow's high will be 76 and the low will be 61.", decision.ReplyText); + Assert.Equal( + "First, the weather tomorrow. Tomorrow in New York, U.S., it looks partly cloudy. Tomorrow's high will be 76 and the low will be 61.", + decision.ReplyText); } [Fact] @@ -2210,7 +2256,8 @@ public sealed class JiboInteractionServiceTests 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); + Assert.Contains("Saturday: clear sky, high 79, low 63.", decision.ReplyText, + StringComparison.OrdinalIgnoreCase); } [Fact] @@ -2228,7 +2275,8 @@ public sealed class JiboInteractionServiceTests NormalizedTranscript = "what's the weather in chicago", Attributes = new Dictionary { - ["context"] = """{"runtime":{"location":{"lat":39.0997,"lng":-94.5786,"iso":"2026-05-09T09:00:00-05:00"}}}""" + ["context"] = + """{"runtime":{"location":{"lat":39.0997,"lng":-94.5786,"iso":"2026-05-09T09:00:00-05:00"}}}""" } }); @@ -2236,7 +2284,9 @@ public sealed class JiboInteractionServiceTests Assert.Equal("Chicago", provider.LastRequest?.LocationQuery); Assert.Null(provider.LastRequest?.Latitude); Assert.Null(provider.LastRequest?.Longitude); - Assert.Equal("For your weather. In Chicago, U.S., it's mostly cloudy and 70 degrees Fahrenheit. Today's high is 75, and the low is 62.", decision.ReplyText); + Assert.Equal( + "For your weather. In Chicago, U.S., it's mostly cloudy and 70 degrees Fahrenheit. Today's high is 75, and the low is 62.", + decision.ReplyText); } [Fact] @@ -2266,7 +2316,9 @@ public sealed class JiboInteractionServiceTests Assert.Equal("Chicago", provider.LastRequest?.LocationQuery); Assert.Equal(0, provider.LastRequest?.ForecastDayOffset); Assert.False(provider.LastRequest?.IsTomorrow); - Assert.Equal("For your weather. In Chicago, U.S., it's mostly cloudy and 70 degrees Fahrenheit. Today's high is 75, and the low is 62.", decision.ReplyText); + Assert.Equal( + "For your weather. In Chicago, U.S., it's mostly cloudy and 70 degrees Fahrenheit. Today's high is 75, and the low is 62.", + decision.ReplyText); } [Fact] @@ -2296,7 +2348,9 @@ public sealed class JiboInteractionServiceTests Assert.Equal("Chicago", provider.LastRequest?.LocationQuery); Assert.Equal(1, provider.LastRequest?.ForecastDayOffset); Assert.True(provider.LastRequest?.IsTomorrow); - Assert.Equal("First, the weather tomorrow. Tomorrow in Chicago, U.S., it looks mostly cloudy. Tomorrow's high will be 75 and the low will be 62.", decision.ReplyText); + Assert.Equal( + "First, the weather tomorrow. Tomorrow in Chicago, U.S., it looks mostly cloudy. Tomorrow's high will be 75 and the low will be 62.", + decision.ReplyText); } [Theory] @@ -2335,9 +2389,7 @@ public sealed class JiboInteractionServiceTests 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] @@ -2366,7 +2418,9 @@ public sealed class JiboInteractionServiceTests Assert.Equal("weather", decision.IntentName); Assert.Equal(2, provider.LastRequest?.ForecastDayOffset); Assert.False(provider.LastRequest?.IsTomorrow); - Assert.Equal("Let's look at the weather. On Monday in Portland, U.S., it looks scattered clouds with a high near 68 degrees Fahrenheit and a low around 53 degrees Fahrenheit.", decision.ReplyText); + Assert.Equal( + "Let's look at the weather. On Monday in Portland, U.S., it looks scattered clouds with a high near 68 degrees Fahrenheit and a low around 53 degrees Fahrenheit.", + decision.ReplyText); } [Fact] @@ -2391,7 +2445,9 @@ public sealed class JiboInteractionServiceTests Assert.Equal("weather", decision.IntentName); Assert.Equal("Chicago", provider.LastRequest?.LocationQuery); Assert.Equal(1, provider.LastRequest?.ForecastDayOffset); - Assert.Equal("First, the weather tomorrow. On Tuesday in Chicago, U.S., it looks light rain. Tomorrow's high will be 63 and the low will be 51.", decision.ReplyText); + Assert.Equal( + "First, the weather tomorrow. On Tuesday in Chicago, U.S., it looks light rain. Tomorrow's high will be 63 and the low will be 51.", + decision.ReplyText); } [Fact] @@ -2440,7 +2496,9 @@ public sealed class JiboInteractionServiceTests Assert.Equal("weather", decision.IntentName); Assert.Equal("Paris", provider.LastRequest?.LocationQuery); Assert.Equal(5, provider.LastRequest?.ForecastDayOffset); - Assert.Equal("Let's look at the weather. This weekend in Paris, FR, it looks overcast clouds with a high near 70 degrees Fahrenheit and a low around 60 degrees Fahrenheit.", decision.ReplyText); + Assert.Equal( + "Let's look at the weather. This weekend in Paris, FR, it looks overcast clouds with a high near 70 degrees Fahrenheit and a low around 60 degrees Fahrenheit.", + decision.ReplyText); } [Fact] @@ -2467,8 +2525,10 @@ public sealed class JiboInteractionServiceTests Assert.Equal(5, provider.LastRequest?.ForecastDayOffset); Assert.Equal(5, provider.Requests.Count); Assert.Contains("rest of this week's forecast", decision.ReplyText, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Tuesday: light rain, high 61, low 52.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Saturday: light rain, high 61, low 52.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Tuesday: light rain, high 61, low 52.", decision.ReplyText, + StringComparison.OrdinalIgnoreCase); + Assert.Contains("Saturday: light rain, high 61, low 52.", decision.ReplyText, + StringComparison.OrdinalIgnoreCase); Assert.Contains("Temperatures are in Fahrenheit.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); Assert.NotNull(decision.SkillPayload); Assert.True(decision.SkillPayload!.TryGetValue("weather_weekly_cards", out var weeklyCardsValue)); @@ -2562,7 +2622,9 @@ public sealed class JiboInteractionServiceTests Assert.Equal("weather", decision.IntentName); Assert.Equal("Chicago", provider.LastRequest?.LocationQuery); Assert.Equal(2, provider.LastRequest?.ForecastDayOffset); - Assert.Equal("Let's look at the weather. The day after tomorrow in Chicago, U.S., it looks mostly cloudy with a high near 74 degrees Fahrenheit and a low around 60 degrees Fahrenheit.", decision.ReplyText); + Assert.Equal( + "Let's look at the weather. The day after tomorrow in Chicago, U.S., it looks mostly cloudy with a high near 74 degrees Fahrenheit and a low around 60 degrees Fahrenheit.", + decision.ReplyText); } [Fact] @@ -2771,7 +2833,8 @@ public sealed class JiboInteractionServiceTests NormalizedTranscript = "- Thank you. - Yes.", Attributes = new Dictionary { - ["listenRules"] = (string[])["surprises-date/offer_date_fact", "globals/gui_nav", "globals/global_commands_launch"], + ["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"}}}""" } @@ -2792,7 +2855,8 @@ public sealed class JiboInteractionServiceTests NormalizedTranscript = "- Me. - Yes.", Attributes = new Dictionary { - ["listenRules"] = (string[])["word-of-the-day/surprise", "globals/gui_nav", "globals/global_commands_launch"], + ["listenRules"] = (string[]) + ["word-of-the-day/surprise", "globals/gui_nav", "globals/global_commands_launch"], ["listenAsrHints"] = (string[])["$YESNO"] } }); @@ -3535,7 +3599,8 @@ public sealed class JiboInteractionServiceTests 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.Equal(3, provider.LastRequest!.MaxHeadlines); } @@ -3562,7 +3627,8 @@ 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); + Assert.Contains("artificial intelligence", decision.SkillPayload?["esml"]?.ToString(), + StringComparison.OrdinalIgnoreCase); } [Fact] @@ -3788,4 +3854,4 @@ public sealed class JiboInteractionServiceTests return Task.FromResult(catalog); } } -} +} \ No newline at end of file diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs index 1746b2d..de993c6 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs @@ -11,15 +11,16 @@ namespace Jibo.Cloud.Tests.WebSockets; public sealed class JiboWebSocketServiceTests { - private readonly InMemoryCloudStateStore _store; private readonly JiboWebSocketService _service; + private readonly InMemoryCloudStateStore _store; public JiboWebSocketServiceTests() { _store = new InMemoryCloudStateStore(); var contentRepository = new InMemoryJiboExperienceContentRepository(); var contentCache = new JiboExperienceContentCache(contentRepository); - var conversationBroker = new DemoConversationBroker(new JiboInteractionService(contentCache, new DefaultJiboRandomizer(), new InMemoryPersonalMemoryStore())); + var conversationBroker = new DemoConversationBroker(new JiboInteractionService(contentCache, + new DefaultJiboRandomizer(), new InMemoryPersonalMemoryStore())); var sttSelector = new DefaultSttStrategySelector( [ new SyntheticBufferedAudioSttStrategy() @@ -53,9 +54,12 @@ public sealed class JiboWebSocketServiceTests Assert.Equal(75, replies[2].DelayMs); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("hello jibo", listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); - Assert.Equal("hello", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.True(listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skipSurprises").GetBoolean()); + Assert.Equal("hello jibo", + listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); + Assert.Equal("hello", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.True(listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skipSurprises") + .GetBoolean()); using var eosPayload = JsonDocument.Parse(replies[1].Text!); Assert.True(eosPayload.RootElement.TryGetProperty("ts", out _)); @@ -86,7 +90,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-cloud-version-token", - Text = """{"type":"LISTEN","transID":"trans-cloud-version","data":{"text":"What's your cloud version?","hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-cloud-version","data":{"text":"What's your cloud version?","hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" }); Assert.Equal(3, replies.Count); @@ -95,8 +100,10 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("SKILL_ACTION", ReadReplyType(replies[2])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("cloud_version", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.True(listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skipSurprises").GetBoolean()); + Assert.Equal("cloud_version", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.True(listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skipSurprises") + .GetBoolean()); using var skillPayload = JsonDocument.Parse(replies[2].Text!); var esml = skillPayload.RootElement @@ -125,7 +132,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-cloud-version-token", - Text = """{"type":"LISTEN","transID":"trans-cloud-version-tail","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-cloud-version-tail","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" }); Assert.Equal(3, tailListenReplies.Count); @@ -135,8 +143,11 @@ public sealed class JiboWebSocketServiceTests using (var lateListenPayload = JsonDocument.Parse(tailListenReplies[0].Text!)) { Assert.Equal("trans-cloud-version-tail", lateListenPayload.RootElement.GetProperty("transID").GetString()); - Assert.Equal("launch", lateListenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules")[0].GetString()); + Assert.Equal("launch", + lateListenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules")[0] + .GetString()); } + Assert.Equal("trans-cloud-version", session.TurnState.TransId); Assert.False(session.TurnState.AwaitingTurnCompletion); Assert.False(session.TurnState.SawListen); @@ -230,8 +241,10 @@ public sealed class JiboWebSocketServiceTests Assert.Equal(75, replies[2].DelayMs); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("tell me a joke", listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); - Assert.Equal("joke", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("tell me a joke", + listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); + Assert.Equal("joke", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); } [Fact] @@ -290,9 +303,12 @@ public sealed class JiboWebSocketServiceTests Assert.Equal(75, replies[2].DelayMs); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("heyJibo", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal(string.Empty, listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); - Assert.True(listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skipSurprises").GetBoolean()); + Assert.Equal("heyJibo", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal(string.Empty, + listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); + Assert.True(listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skipSurprises") + .GetBoolean()); } [Fact] @@ -313,7 +329,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-preference-continuation-token", - Text = """{"type":"CONTEXT","transID":"trans-preference-continuation","data":{"audioTranscriptHint":"my favorite sport"}}""" + Text = + """{"type":"CONTEXT","transID":"trans-preference-continuation","data":{"audioTranscriptHint":"my favorite sport"}}""" }); for (var index = 0; index < 4; index += 1) @@ -351,7 +368,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-preference-continuation-token", - Text = """{"type":"CONTEXT","transID":"trans-preference-continuation","data":{"audioTranscriptHint":"my favorite sport is football"}}""" + Text = + """{"type":"CONTEXT","transID":"trans-preference-continuation","data":{"audioTranscriptHint":"my favorite sport is football"}}""" }); Assert.Equal(3, finalizedReplies.Count); @@ -360,8 +378,10 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("SKILL_ACTION", ReadReplyType(finalizedReplies[2])); using var listenPayload = JsonDocument.Parse(finalizedReplies[0].Text!); - Assert.Equal("memory_set_preference", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("my favorite sport is football", listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); + Assert.Equal("memory_set_preference", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("my favorite sport is football", + listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); } [Fact] @@ -382,7 +402,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-preference-bare-token", - Text = """{"type":"CONTEXT","transID":"trans-preference-bare","data":{"audioTranscriptHint":"my favorite sport football"}}""" + Text = + """{"type":"CONTEXT","transID":"trans-preference-bare","data":{"audioTranscriptHint":"my favorite sport football"}}""" }); for (var index = 0; index < 4; index += 1) @@ -418,8 +439,10 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("SKILL_ACTION", ReadReplyType(finalizedReplies[2])); using var listenPayload = JsonDocument.Parse(finalizedReplies[0].Text!); - Assert.Equal("memory_set_preference", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("my favorite sport football", listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); + Assert.Equal("memory_set_preference", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("my favorite sport football", + listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); } [Fact] @@ -440,7 +463,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-affinity-continuation-token", - Text = """{"type":"CONTEXT","transID":"trans-affinity-continuation","data":{"audioTranscriptHint":"i do like"}}""" + Text = + """{"type":"CONTEXT","transID":"trans-affinity-continuation","data":{"audioTranscriptHint":"i do like"}}""" }); for (var index = 0; index < 4; index += 1) @@ -478,7 +502,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-affinity-continuation-token", - Text = """{"type":"CONTEXT","transID":"trans-affinity-continuation","data":{"audioTranscriptHint":"i do like pizza"}}""" + Text = + """{"type":"CONTEXT","transID":"trans-affinity-continuation","data":{"audioTranscriptHint":"i do like pizza"}}""" }); Assert.Equal(3, finalizedReplies.Count); @@ -487,8 +512,10 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("SKILL_ACTION", ReadReplyType(finalizedReplies[2])); using var listenPayload = JsonDocument.Parse(finalizedReplies[0].Text!); - Assert.Equal("memory_set_affinity", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("i do like pizza", listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); + Assert.Equal("memory_set_affinity", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("i do like pizza", + listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); } [Fact] @@ -509,7 +536,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-affinity-we-continuation-token", - Text = """{"type":"CONTEXT","transID":"trans-affinity-we-continuation","data":{"audioTranscriptHint":"we like"}}""" + Text = + """{"type":"CONTEXT","transID":"trans-affinity-we-continuation","data":{"audioTranscriptHint":"we like"}}""" }); for (var index = 0; index < 4; index += 1) @@ -547,7 +575,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-affinity-we-continuation-token", - Text = """{"type":"CONTEXT","transID":"trans-affinity-we-continuation","data":{"audioTranscriptHint":"we like pizza"}}""" + Text = + """{"type":"CONTEXT","transID":"trans-affinity-we-continuation","data":{"audioTranscriptHint":"we like pizza"}}""" }); Assert.Equal(3, finalizedReplies.Count); @@ -556,8 +585,10 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("SKILL_ACTION", ReadReplyType(finalizedReplies[2])); using var listenPayload = JsonDocument.Parse(finalizedReplies[0].Text!); - Assert.Equal("memory_set_affinity", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("we like pizza", listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); + Assert.Equal("memory_set_affinity", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("we like pizza", + listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); } [Fact] @@ -608,7 +639,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-follow-up-token", - Text = """{"type":"LISTEN","transID":"trans-follow-up","data":{"text":"hello jibo","rules":["wake-word"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-follow-up","data":{"text":"hello jibo","rules":["wake-word"]}}""" }); var contextReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -651,7 +683,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-menu-token", - Text = """{"type":"LISTEN","transID":"trans-clock-time","data":{"lang":"en-US","rules":["clock/clock_menu","globals/global_commands_launch"],"mode":"CLIENT_NLU"}}""" + Text = + """{"type":"LISTEN","transID":"trans-clock-time","data":{"lang":"en-US","rules":["clock/clock_menu","globals/global_commands_launch"],"mode":"CLIENT_NLU"}}""" }); Assert.Empty(listenReplies); @@ -662,7 +695,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-menu-token", - Text = """{"type":"CLIENT_NLU","transID":"trans-clock-time","data":{"entities":{"domain":"clock"},"intent":"askForTime","rules":["clock/clock_menu"]}}""" + Text = + """{"type":"CLIENT_NLU","transID":"trans-clock-time","data":{"entities":{"domain":"clock"},"intent":"askForTime","rules":["clock/clock_menu"]}}""" }); Assert.Equal(2, nluReplies.Count); @@ -670,11 +704,17 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("EOS", ReadReplyType(nluReplies[1])); using var listenPayload = JsonDocument.Parse(nluReplies[0].Text!); - Assert.Equal("askForTime", listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); - Assert.Equal("askForTime", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("clock", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("domain").GetString()); - Assert.Equal("clock/clock_menu", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules")[0].GetString()); - Assert.Equal("clock/clock_menu", listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); + Assert.Equal("askForTime", + listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); + Assert.Equal("askForTime", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("clock", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("domain").GetString()); + Assert.Equal("clock/clock_menu", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules")[0].GetString()); + Assert.Equal("clock/clock_menu", + listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); } [Fact] @@ -686,7 +726,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-timer-token", - Text = """{"type":"LISTEN","transID":"trans-clock-timer","data":{"rules":["globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-clock-timer","data":{"rules":["globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -695,7 +736,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-timer-token", - Text = """{"type":"CLIENT_ASR","transID":"trans-clock-timer","data":{"text":"set a timer for five minutes"}}""" + Text = + """{"type":"CLIENT_ASR","transID":"trans-clock-timer","data":{"text":"set a timer for five minutes"}}""" }); Assert.Equal(4, replies.Count); @@ -705,17 +747,30 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("SKILL_ACTION", ReadReplyType(replies[3])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("start", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("@be/clock", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString()); - Assert.Equal("timer", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("domain").GetString()); - Assert.Equal("0", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("hours").GetString()); - Assert.Equal("5", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("minutes").GetString()); - Assert.Equal("null", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("seconds").GetString()); + Assert.Equal("start", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("@be/clock", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString()); + Assert.Equal("timer", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("domain").GetString()); + Assert.Equal("0", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("hours").GetString()); + Assert.Equal("5", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("minutes").GetString()); + Assert.Equal("null", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("seconds").GetString()); using var redirectPayload = JsonDocument.Parse(replies[2].Text!); - Assert.Equal("@be/clock", redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); - Assert.True(redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skipSurprises").GetBoolean()); - Assert.Equal("start", redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("@be/clock", + redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); + Assert.True(redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skipSurprises") + .GetBoolean()); + Assert.Equal("start", + redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); } [Fact] @@ -727,7 +782,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-open-token", - Text = """{"type":"LISTEN","transID":"trans-clock-open","data":{"rules":["globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-clock-open","data":{"rules":["globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -745,12 +801,17 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("SKILL_REDIRECT", ReadReplyType(replies[2])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("askForTime", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("clock", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("domain").GetString()); + Assert.Equal("askForTime", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("clock", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("domain").GetString()); using var redirectPayload = JsonDocument.Parse(replies[2].Text!); - Assert.Equal("@be/clock", redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); - Assert.Equal("askForTime", redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("@be/clock", + redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); + Assert.Equal("askForTime", + redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); } [Fact] @@ -762,7 +823,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-voice-time-token", - Text = """{"type":"LISTEN","transID":"trans-clock-voice-time","data":{"rules":["globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-clock-voice-time","data":{"rules":["globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -779,8 +841,10 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("EOS", ReadReplyType(replies[1])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("askForTime", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("@be/clock", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString()); + Assert.Equal("askForTime", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("@be/clock", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString()); } [Fact] @@ -792,7 +856,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-alarm-token", - Text = """{"type":"LISTEN","transID":"trans-clock-alarm","data":{"rules":["globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-clock-alarm","data":{"rules":["globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -807,11 +872,19 @@ public sealed class JiboWebSocketServiceTests Assert.Equal(4, replies.Count); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("start", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("@be/clock", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString()); - Assert.Equal("alarm", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("domain").GetString()); - Assert.Equal("7:30", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("time").GetString()); - Assert.Equal("am", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("ampm").GetString()); + Assert.Equal("start", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("@be/clock", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString()); + Assert.Equal("alarm", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("domain").GetString()); + Assert.Equal("7:30", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("time") + .GetString()); + Assert.Equal("am", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("ampm") + .GetString()); } [Fact] @@ -823,7 +896,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-compact-alarm-token", - Text = """{"type":"LISTEN","transID":"trans-clock-compact-alarm","data":{"rules":["globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-clock-compact-alarm","data":{"rules":["globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -832,14 +906,19 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-compact-alarm-token", - Text = """{"type":"CLIENT_ASR","transID":"trans-clock-compact-alarm","data":{"text":"set an alarm for 830"}}""" + Text = + """{"type":"CLIENT_ASR","transID":"trans-clock-compact-alarm","data":{"text":"set an alarm for 830"}}""" }); Assert.Equal(4, replies.Count); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("8:30", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("time").GetString()); - Assert.Equal("am", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("ampm").GetString()); + Assert.Equal("8:30", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("time") + .GetString()); + Assert.Equal("am", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("ampm") + .GetString()); } [Fact] @@ -851,7 +930,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-hyphen-alarm-token", - Text = """{"type":"LISTEN","transID":"trans-clock-hyphen-alarm","data":{"rules":["globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-clock-hyphen-alarm","data":{"rules":["globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -860,16 +940,24 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-hyphen-alarm-token", - Text = """{"type":"CLIENT_ASR","transID":"trans-clock-hyphen-alarm","data":{"text":"set an alarm for 10-25"}}""" + Text = + """{"type":"CLIENT_ASR","transID":"trans-clock-hyphen-alarm","data":{"text":"set an alarm for 10-25"}}""" }); Assert.Equal(4, replies.Count); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("start", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("alarm", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("domain").GetString()); - Assert.Equal("10:25", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("time").GetString()); - Assert.Equal("am", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("ampm").GetString()); + Assert.Equal("start", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("alarm", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("domain").GetString()); + Assert.Equal("10:25", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("time") + .GetString()); + Assert.Equal("am", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("ampm") + .GetString()); } [Fact] @@ -881,7 +969,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-pm-alarm-token", - Text = """{"type":"LISTEN","transID":"trans-clock-pm-alarm","data":{"rules":["globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-clock-pm-alarm","data":{"rules":["globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -890,14 +979,19 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-pm-alarm-token", - Text = """{"type":"CLIENT_ASR","transID":"trans-clock-pm-alarm","data":{"text":"set an alarm for 10:25 pm"}}""" + Text = + """{"type":"CLIENT_ASR","transID":"trans-clock-pm-alarm","data":{"text":"set an alarm for 10:25 pm"}}""" }); Assert.Equal(4, replies.Count); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("10:25", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("time").GetString()); - Assert.Equal("pm", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("ampm").GetString()); + Assert.Equal("10:25", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("time") + .GetString()); + Assert.Equal("pm", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("ampm") + .GetString()); } [Fact] @@ -909,7 +1003,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-next-occurrence-token", - Text = """{"type":"LISTEN","transID":"trans-clock-next-occurrence","data":{"rules":["globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-clock-next-occurrence","data":{"rules":["globals/global_commands_launch"]}}""" }); await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -918,7 +1013,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-next-occurrence-token", - Text = """{"type":"CONTEXT","transID":"trans-clock-next-occurrence","data":{"runtime":{"location":{"iso":"2026-04-22T07:15:00-05:00"}}}}""" + Text = + """{"type":"CONTEXT","transID":"trans-clock-next-occurrence","data":{"runtime":{"location":{"iso":"2026-04-22T07:15:00-05:00"}}}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -927,12 +1023,17 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-next-occurrence-token", - Text = """{"type":"CLIENT_ASR","transID":"trans-clock-next-occurrence","data":{"text":"set an alarm for 7:10"}}""" + Text = + """{"type":"CLIENT_ASR","transID":"trans-clock-next-occurrence","data":{"text":"set an alarm for 7:10"}}""" }); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("7:10", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("time").GetString()); - Assert.Equal("pm", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("ampm").GetString()); + Assert.Equal("7:10", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("time") + .GetString()); + Assert.Equal("pm", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("ampm") + .GetString()); } [Fact] @@ -944,7 +1045,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-timer-followup-token", - Text = """{"type":"LISTEN","transID":"trans-clock-timer-followup","data":{"rules":["clock/timer_set_value"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-clock-timer-followup","data":{"rules":["clock/timer_set_value"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -953,7 +1055,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-timer-followup-token", - Text = """{"type":"CLIENT_ASR","transID":"trans-clock-timer-followup","data":{"text":"twenty five minutes"}}""" + Text = + """{"type":"CLIENT_ASR","transID":"trans-clock-timer-followup","data":{"text":"twenty five minutes"}}""" }); Assert.Equal(2, replies.Count); @@ -961,9 +1064,14 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("EOS", ReadReplyType(replies[1])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("start", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("timer", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("domain").GetString()); - Assert.Equal("25", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("minutes").GetString()); + Assert.Equal("start", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("timer", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("domain").GetString()); + Assert.Equal("25", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("minutes").GetString()); } [Fact] @@ -975,7 +1083,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-alarm-followup-token", - Text = """{"type":"LISTEN","transID":"trans-clock-alarm-followup","data":{"rules":["clock/alarm_set_value"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-clock-alarm-followup","data":{"rules":["clock/alarm_set_value"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -992,10 +1101,17 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("EOS", ReadReplyType(replies[1])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("start", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("alarm", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("domain").GetString()); - Assert.Equal("10:25", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("time").GetString()); - Assert.Equal("am", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("ampm").GetString()); + Assert.Equal("start", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("alarm", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("domain").GetString()); + Assert.Equal("10:25", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("time") + .GetString()); + Assert.Equal("am", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("ampm") + .GetString()); } [Fact] @@ -1007,7 +1123,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-alarm-comma-followup-token", - Text = """{"type":"LISTEN","transID":"trans-clock-alarm-comma-followup","data":{"rules":["clock/alarm_set_value"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-clock-alarm-comma-followup","data":{"rules":["clock/alarm_set_value"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -1024,9 +1141,14 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("EOS", ReadReplyType(replies[1])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("start", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("alarm", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("domain").GetString()); - Assert.Equal("7:44", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("time").GetString()); + Assert.Equal("start", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("alarm", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("domain").GetString()); + Assert.Equal("7:44", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("time") + .GetString()); } [Fact] @@ -1038,7 +1160,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-clarify-alarm-token", - Text = """{"type":"LISTEN","transID":"trans-clock-clarify-alarm","data":{"rules":["globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-clock-clarify-alarm","data":{"rules":["globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -1057,13 +1180,19 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("SKILL_ACTION", ReadReplyType(replies[3])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("set", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("alarm", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("domain").GetString()); - Assert.False(listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").TryGetProperty("time", out _)); + Assert.Equal("set", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("alarm", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("domain").GetString()); + Assert.False(listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .TryGetProperty("time", out _)); using var redirectPayload = JsonDocument.Parse(replies[2].Text!); - Assert.Equal("@be/clock", redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); - Assert.Equal("set", redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("@be/clock", + redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); + Assert.Equal("set", + redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); } [Fact] @@ -1075,7 +1204,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-cancel-alarm-token", - Text = """{"type":"LISTEN","transID":"trans-clock-cancel-alarm","data":{"rules":["globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-clock-cancel-alarm","data":{"rules":["globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -1090,12 +1220,17 @@ public sealed class JiboWebSocketServiceTests Assert.Equal(4, replies.Count); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("delete", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("alarm", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("domain").GetString()); + Assert.Equal("delete", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("alarm", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("domain").GetString()); using var redirectPayload = JsonDocument.Parse(replies[2].Text!); - Assert.Equal("@be/clock", redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); - Assert.Equal("delete", redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("@be/clock", + redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); + Assert.Equal("delete", + redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); var session = _store.FindSessionByToken("hub-clock-cancel-alarm-token"); Assert.NotNull(session); @@ -1111,7 +1246,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-set-alarm-query-token", - Text = """{"type":"LISTEN","transID":"trans-clock-set-alarm-query","data":{"rules":["clock/clock_menu","globals/global_commands_launch"],"mode":"CLIENT_NLU"}}""" + Text = + """{"type":"LISTEN","transID":"trans-clock-set-alarm-query","data":{"rules":["clock/clock_menu","globals/global_commands_launch"],"mode":"CLIENT_NLU"}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -1120,7 +1256,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-set-alarm-query-token", - Text = """{"type":"CLIENT_NLU","transID":"trans-clock-set-alarm-query","data":{"entities":{"domain":"alarm"},"intent":"set","rules":["clock/clock_menu"]}}""" + Text = + """{"type":"CLIENT_NLU","transID":"trans-clock-set-alarm-query","data":{"entities":{"domain":"alarm"},"intent":"set","rules":["clock/clock_menu"]}}""" }); Assert.Equal(2, replies.Count); @@ -1128,9 +1265,13 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("EOS", ReadReplyType(replies[1])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("set", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("alarm", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("domain").GetString()); - Assert.False(listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").TryGetProperty("time", out _)); + Assert.Equal("set", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("alarm", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("domain").GetString()); + Assert.False(listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .TryGetProperty("time", out _)); } [Fact] @@ -1142,7 +1283,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-cancel-alarm-value-token", - Text = """{"type":"LISTEN","transID":"trans-clock-cancel-alarm-value","data":{"rules":["clock/alarm_set_value","globals/gui_nav","globals/global_commands_launch"],"mode":"CLIENT_NLU"}}""" + Text = + """{"type":"LISTEN","transID":"trans-clock-cancel-alarm-value","data":{"rules":["clock/alarm_set_value","globals/gui_nav","globals/global_commands_launch"],"mode":"CLIENT_NLU"}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -1151,7 +1293,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-cancel-alarm-value-token", - Text = """{"type":"CLIENT_NLU","transID":"trans-clock-cancel-alarm-value","data":{"entities":{},"intent":"cancel","rules":["clock/alarm_set_value"]}}""" + Text = + """{"type":"CLIENT_NLU","transID":"trans-clock-cancel-alarm-value","data":{"entities":{},"intent":"cancel","rules":["clock/alarm_set_value"]}}""" }); Assert.Equal(2, replies.Count); @@ -1159,9 +1302,13 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("EOS", ReadReplyType(replies[1])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("cancel", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("clock/alarm_set_value", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules")[0].GetString()); - Assert.Equal("alarm", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("domain").GetString()); + Assert.Equal("cancel", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("clock/alarm_set_value", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules")[0].GetString()); + Assert.Equal("alarm", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("domain").GetString()); } [Fact] @@ -1173,7 +1320,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-cancel-query-token", - Text = """{"type":"LISTEN","transID":"trans-clock-cancel-query","data":{"rules":["globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-clock-cancel-query","data":{"rules":["globals/global_commands_launch"]}}""" }); await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -1182,7 +1330,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-cancel-query-token", - Text = """{"type":"CLIENT_ASR","transID":"trans-clock-cancel-query","data":{"text":"set an alarm for 7:16 am"}}""" + Text = + """{"type":"CLIENT_ASR","transID":"trans-clock-cancel-query","data":{"text":"set an alarm for 7:16 am"}}""" }); await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -1191,7 +1340,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-cancel-query-token", - Text = """{"type":"LISTEN","transID":"trans-clock-cancel-query-menu","data":{"rules":["clock/alarm_timer_query_menu","globals/global_commands_launch"],"mode":"CLIENT_NLU"}}""" + Text = + """{"type":"LISTEN","transID":"trans-clock-cancel-query-menu","data":{"rules":["clock/alarm_timer_query_menu","globals/global_commands_launch"],"mode":"CLIENT_NLU"}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -1200,7 +1350,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-cancel-query-token", - Text = """{"type":"CLIENT_NLU","transID":"trans-clock-cancel-query-menu","data":{"entities":{},"intent":"cancel","rules":["clock/alarm_timer_query_menu"]}}""" + Text = + """{"type":"CLIENT_NLU","transID":"trans-clock-cancel-query-menu","data":{"entities":{},"intent":"cancel","rules":["clock/alarm_timer_query_menu"]}}""" }); Assert.Equal(2, replies.Count); @@ -1208,8 +1359,11 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("EOS", ReadReplyType(replies[1])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("delete", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("alarm", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("domain").GetString()); + Assert.Equal("delete", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("alarm", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("domain").GetString()); } [Fact] @@ -1221,7 +1375,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-photo-gallery-token", - Text = """{"type":"LISTEN","transID":"trans-photo-gallery","data":{"rules":["globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-photo-gallery","data":{"rules":["globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -1237,12 +1392,16 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("SKILL_REDIRECT", ReadReplyType(replies[2])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("menu", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("@be/gallery", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString()); + Assert.Equal("menu", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("@be/gallery", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString()); using var redirectPayload = JsonDocument.Parse(replies[2].Text!); - Assert.Equal("@be/gallery", redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); - Assert.Equal("menu", redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("@be/gallery", + redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); + Assert.Equal("menu", + redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); } [Fact] @@ -1254,7 +1413,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-photo-gallery-context-token", - Text = """{"type":"LISTEN","transID":"trans-photo-gallery-context","data":{"rules":["globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-photo-gallery-context","data":{"rules":["globals/global_commands_launch"]}}""" }); await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -1263,7 +1423,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-photo-gallery-context-token", - Text = """{"type":"CLIENT_ASR","transID":"trans-photo-gallery-context","data":{"text":"open photo gallery"}}""" + Text = + """{"type":"CLIENT_ASR","transID":"trans-photo-gallery-context","data":{"text":"open photo gallery"}}""" }); await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -1281,7 +1442,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-photo-gallery-context-token", - Text = """{"type":"CONTEXT","transID":"trans-photo-gallery-context","data":{"skill":{"id":"@be/gallery"}}}""" + Text = + """{"type":"CONTEXT","transID":"trans-photo-gallery-context","data":{"skill":{"id":"@be/gallery"}}}""" }); Assert.Empty(replies); @@ -1302,7 +1464,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-gallery-yesno-context-token", - Text = """{"type":"LISTEN","transID":"trans-gallery-yesno-context","data":{"rules":["shared/yes_no","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"],"asr":{"hints":["$YESNO"]}}}""" + Text = + """{"type":"LISTEN","transID":"trans-gallery-yesno-context","data":{"rules":["shared/yes_no","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"],"asr":{"hints":["$YESNO"]}}}""" }); await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -1311,7 +1474,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-gallery-yesno-context-token", - Text = """{"type":"CONTEXT","transID":"trans-gallery-yesno-context","data":{"audioTranscriptHint":"yes","skill":{"id":"@be/gallery"}}}""" + Text = + """{"type":"CONTEXT","transID":"trans-gallery-yesno-context","data":{"audioTranscriptHint":"yes","skill":{"id":"@be/gallery"}}}""" }); for (var index = 0; index < 4; index += 1) @@ -1344,10 +1508,14 @@ 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("asr").GetProperty("text").GetString()); - Assert.Equal("yes", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("shared/yes_no", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules")[0].GetString()); - Assert.Equal("shared/yes_no", listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); + Assert.Equal("yes", + listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); + Assert.Equal("yes", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("shared/yes_no", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules")[0].GetString()); + Assert.Equal("shared/yes_no", + listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); } [Fact] @@ -1359,7 +1527,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-settings-volume-tail-token", - Text = """{"type":"LISTEN","transID":"trans-settings-volume-tail","data":{"rules":["settings/volume_control","globals/gui_nav","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-settings-volume-tail","data":{"rules":["settings/volume_control","globals/gui_nav","globals/global_commands_launch"]}}""" }); var contextReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -1368,7 +1537,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-settings-volume-tail-token", - Text = """{"type":"CONTEXT","transID":"trans-settings-volume-tail","data":{"skill":{"id":"@be/settings"}}}""" + Text = + """{"type":"CONTEXT","transID":"trans-settings-volume-tail","data":{"skill":{"id":"@be/settings"}}}""" }); Assert.Empty(contextReplies); @@ -1407,7 +1577,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-alarm-okay-token", - Text = """{"type":"LISTEN","transID":"trans-clock-alarm-okay","data":{"rules":["clock/alarm_timer_okay","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-clock-alarm-okay","data":{"rules":["clock/alarm_timer_okay","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -1424,8 +1595,10 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("EOS", ReadReplyType(replies[1])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal(string.Empty, listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("clock/alarm_timer_okay", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules")[0].GetString()); + Assert.Equal(string.Empty, + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("clock/alarm_timer_okay", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules")[0].GetString()); } [Fact] @@ -1437,7 +1610,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-clock-alarm-value-noinput-token", - Text = """{"type":"LISTEN","transID":"trans-clock-alarm-value-noinput","data":{"rules":["clock/alarm_set_value","globals/gui_nav","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-clock-alarm-value-noinput","data":{"rules":["clock/alarm_set_value","globals/gui_nav","globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -1454,8 +1628,10 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("EOS", ReadReplyType(replies[1])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal(string.Empty, listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("clock/alarm_set_value", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules")[0].GetString()); + Assert.Equal(string.Empty, + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("clock/alarm_set_value", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules")[0].GetString()); } [Fact] @@ -1467,7 +1643,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-gallery-preview-noinput-token", - Text = """{"type":"LISTEN","transID":"trans-gallery-preview-noinput","data":{"rules":["gallery/gallery_preview","globals/gui_nav","globals/mim_repeat","globals/mim_thanks","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-gallery-preview-noinput","data":{"rules":["gallery/gallery_preview","globals/gui_nav","globals/mim_repeat","globals/mim_thanks","globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -1484,8 +1661,10 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("EOS", ReadReplyType(replies[1])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal(string.Empty, listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("gallery/gallery_preview", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules")[0].GetString()); + Assert.Equal(string.Empty, + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("gallery/gallery_preview", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules")[0].GetString()); } [Fact] @@ -1497,7 +1676,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-snapshot-token", - Text = """{"type":"LISTEN","transID":"trans-snapshot","data":{"rules":["globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-snapshot","data":{"rules":["globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -1512,12 +1692,16 @@ public sealed class JiboWebSocketServiceTests Assert.Equal(4, replies.Count); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("createOnePhoto", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("@be/create", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString()); + Assert.Equal("createOnePhoto", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("@be/create", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString()); using var redirectPayload = JsonDocument.Parse(replies[2].Text!); - Assert.Equal("@be/create", redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); - Assert.Equal("createOnePhoto", redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("@be/create", + redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); + Assert.Equal("createOnePhoto", + redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); } [Fact] @@ -1529,7 +1713,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-photobooth-token", - Text = """{"type":"LISTEN","transID":"trans-photobooth","data":{"rules":["globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-photobooth","data":{"rules":["globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -1544,12 +1729,16 @@ public sealed class JiboWebSocketServiceTests Assert.Equal(4, replies.Count); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("createSomePhotos", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("@be/create", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString()); + Assert.Equal("createSomePhotos", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("@be/create", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString()); using var redirectPayload = JsonDocument.Parse(replies[2].Text!); - Assert.Equal("@be/create", redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); - Assert.Equal("createSomePhotos", redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("@be/create", + redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); + Assert.Equal("createSomePhotos", + redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); } [Fact] @@ -1576,11 +1765,17 @@ public sealed class JiboWebSocketServiceTests Assert.Equal(3, replies.Count); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("yeah", listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); - Assert.Equal("yes", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("create/is_it_a_keeper", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules")[0].GetString()); - Assert.Equal("create", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("domain").GetString()); - Assert.Equal("create/is_it_a_keeper", listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); + Assert.Equal("yeah", + listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); + Assert.Equal("yes", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("create/is_it_a_keeper", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules")[0].GetString()); + Assert.Equal("create", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("domain").GetString()); + Assert.Equal("create/is_it_a_keeper", + listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); } [Fact] @@ -1592,7 +1787,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-yesno-hints-token", - Text = """{"type":"LISTEN","transID":"trans-yesno-hints","data":{"rules":["surprises-ota/want_to_download_now"],"asr":{"hints":["$YESNO"]}}}""" + Text = + """{"type":"LISTEN","transID":"trans-yesno-hints","data":{"rules":["surprises-ota/want_to_download_now"],"asr":{"hints":["$YESNO"]}}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -1607,10 +1803,14 @@ public sealed class JiboWebSocketServiceTests Assert.Equal(3, replies.Count); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("no", listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); - Assert.Equal("no", 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()); + Assert.Equal("no", + listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); + Assert.Equal("no", + 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] @@ -1622,7 +1822,8 @@ public sealed class JiboWebSocketServiceTests 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"]}}}""" + 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 @@ -1637,10 +1838,14 @@ public sealed class JiboWebSocketServiceTests 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()); + 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] @@ -1652,7 +1857,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-shared-yesno-token", - Text = """{"type":"LISTEN","transID":"trans-shared-yesno","data":{"rules":["shared/yes_no","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"],"asr":{"hints":["$YESNO"]}}}""" + Text = + """{"type":"LISTEN","transID":"trans-shared-yesno","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 @@ -1667,11 +1873,13 @@ 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("yes", + 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()); + Assert.Equal("shared/yes_no", + listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); } [Fact] @@ -1683,7 +1891,8 @@ public sealed class JiboWebSocketServiceTests 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"]}}}""" + 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 @@ -1698,11 +1907,13 @@ public sealed class JiboWebSocketServiceTests 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()); + 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()); + Assert.Equal("shared/yes_no", + listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); } [Fact] @@ -1714,7 +1925,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-alarm-change-yesno-token", - Text = """{"type":"LISTEN","transID":"trans-alarm-change-yesno","data":{"rules":["clock/alarm_timer_change","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"],"asr":{"hints":["$YESNO"]}}}""" + Text = + """{"type":"LISTEN","transID":"trans-alarm-change-yesno","data":{"rules":["clock/alarm_timer_change","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"],"asr":{"hints":["$YESNO"]}}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -1729,11 +1941,13 @@ 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("yes", + 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("clock/alarm_timer_change", rules[0].GetString()); - Assert.Equal("clock/alarm_timer_change", listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); + Assert.Equal("clock/alarm_timer_change", + listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); } [Fact] @@ -1745,7 +1959,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-yesno-noinput-token", - Text = """{"type":"LISTEN","transID":"trans-yesno-noinput","data":{"rules":["surprises-ota/want_to_download_now","globals/gui_nav","globals/global_commands_launch"],"asr":{"hints":["$YESNO"]}}}""" + Text = + """{"type":"LISTEN","transID":"trans-yesno-noinput","data":{"rules":["surprises-ota/want_to_download_now","globals/gui_nav","globals/global_commands_launch"],"asr":{"hints":["$YESNO"]}}}""" }); await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -1790,8 +2005,10 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("EOS", ReadReplyType(replies[1])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal(string.Empty, listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); - Assert.Equal(string.Empty, listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal(string.Empty, + listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); + Assert.Equal(string.Empty, + 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-ota/want_to_download_now", rules[0].GetString()); @@ -1806,7 +2023,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-shared-yesno-noinput-token", - Text = """{"type":"LISTEN","transID":"trans-shared-yesno-noinput","data":{"rules":["shared/yes_no","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"],"asr":{"hints":["$YESNO"]}}}""" + Text = + """{"type":"LISTEN","transID":"trans-shared-yesno-noinput","data":{"rules":["shared/yes_no","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"],"asr":{"hints":["$YESNO"]}}}""" }); await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -1865,7 +2083,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-create-noinput-token", - Text = """{"type":"LISTEN","transID":"trans-create-noinput-1","data":{"rules":["create/is_it_a_keeper","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-create-noinput-1","data":{"rules":["create/is_it_a_keeper","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"]}}""" }); var firstReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -1887,7 +2106,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-create-noinput-token", - Text = """{"type":"LISTEN","transID":"trans-create-noinput-2","data":{"rules":["create/is_it_a_keeper","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-create-noinput-2","data":{"rules":["create/is_it_a_keeper","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"]}}""" }); var secondReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -1905,7 +2125,8 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("SKILL_REDIRECT", ReadReplyType(secondReplies[2])); using var redirectPayload = JsonDocument.Parse(secondReplies[2].Text!); - Assert.Equal("@be/idle", redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); + Assert.Equal("@be/idle", + redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); } [Fact] @@ -1917,7 +2138,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-share-yesno-token", - Text = """{"type":"LISTEN","transID":"trans-share-yes","data":{"rules":["surprises-date/offer_date_fact","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"],"asr":{"hints":["$YESNO"]}}}""" + Text = + """{"type":"LISTEN","transID":"trans-share-yes","data":{"rules":["surprises-date/offer_date_fact","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"],"asr":{"hints":["$YESNO"]}}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -1932,15 +2154,18 @@ public sealed class JiboWebSocketServiceTests Assert.Equal(3, replies.Count); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("proactive_offer_pizza_fact", 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()); 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()); + Assert.Equal(selectedRule, + listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); + Assert.Empty(listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .EnumerateObject()); } [Fact] @@ -1952,7 +2177,8 @@ public sealed class JiboWebSocketServiceTests 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"]}}}""" + 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 @@ -1973,10 +2199,12 @@ public sealed class JiboWebSocketServiceTests 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(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()); + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("domain").GetString()); } [Fact] @@ -2010,7 +2238,7 @@ public sealed class JiboWebSocketServiceTests } }; - var replies = ResponsePlanToSocketMessagesMapper.Map(plan, turn, new CloudSession(), emitSkillActions: true); + var replies = ResponsePlanToSocketMessagesMapper.Map(plan, turn, new CloudSession(), true); using var payload = JsonDocument.Parse(replies[2].Text); var esml = payload.RootElement .GetProperty("data") @@ -2035,7 +2263,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-news-token", - Text = """{"type":"LISTEN","transID":"trans-news","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-news","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -2053,11 +2282,14 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("SKILL_ACTION", ReadReplyType(replies[2])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("news", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("news", listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("cloudSkill").GetString()); + Assert.Equal("news", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("news", + listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("cloudSkill").GetString()); using var skillPayload = JsonDocument.Parse(replies[2].Text!); - Assert.Equal("news", skillPayload.RootElement.GetProperty("data").GetProperty("skill").GetProperty("id").GetString()); + Assert.Equal("news", + skillPayload.RootElement.GetProperty("data").GetProperty("skill").GetProperty("id").GetString()); var meta = skillPayload.RootElement .GetProperty("data") .GetProperty("action") @@ -2089,7 +2321,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-news-provider-token", - Text = """{"type":"LISTEN","transID":"trans-news-provider","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-news-provider","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" }); var replies = await service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -2130,7 +2363,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-weather-token", - Text = """{"type":"LISTEN","transID":"trans-weather","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-weather","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -2148,9 +2382,11 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("SKILL_ACTION", ReadReplyType(replies[2])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("weather", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("weather", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); Assert.False(listenPayload.RootElement.GetProperty("data").GetProperty("nlu").TryGetProperty("skill", out _)); - Assert.Equal(JsonValueKind.Null, listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("cloudSkill").ValueKind); + Assert.Equal(JsonValueKind.Null, + listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("cloudSkill").ValueKind); using var speakPayload = JsonDocument.Parse(replies[2].Text!); var esml = speakPayload.RootElement @@ -2174,7 +2410,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-weather-entities-token", - Text = """{"type":"LISTEN","transID":"trans-weather-entities","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-weather-entities","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -2183,7 +2420,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-weather-entities-token", - Text = """{"type":"CLIENT_ASR","transID":"trans-weather-entities","data":{"text":"will it rain tomorrow"}}""" + Text = + """{"type":"CLIENT_ASR","transID":"trans-weather-entities","data":{"text":"will it rain tomorrow"}}""" }); Assert.Equal(3, replies.Count); @@ -2192,7 +2430,8 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("SKILL_ACTION", ReadReplyType(replies[2])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("weather", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("weather", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); using var speakPayload = JsonDocument.Parse(replies[2].Text!); var esml = speakPayload.RootElement @@ -2222,7 +2461,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-weather-provider-token", - Text = """{"type":"LISTEN","transID":"trans-weather-provider","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-weather-provider","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" }); var replies = await customService.HandleMessageAsync(new WebSocketMessageEnvelope @@ -2237,13 +2477,16 @@ public sealed class JiboWebSocketServiceTests Assert.True(replies.Count >= 3); Assert.Equal("LISTEN", ReadReplyType(replies[0])); Assert.Equal("EOS", ReadReplyType(replies[1])); - Assert.Contains(replies, static reply => string.Equals(ReadReplyType(reply), "SKILL_ACTION", StringComparison.Ordinal)); + Assert.Contains(replies, + static reply => string.Equals(ReadReplyType(reply), "SKILL_ACTION", StringComparison.Ordinal)); using var listenPayload = JsonDocument.Parse(replies[0].Text!); Assert.False(listenPayload.RootElement.GetProperty("data").GetProperty("nlu").TryGetProperty("skill", out _)); - Assert.Equal("weather", listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("cloudSkill").GetString()); + Assert.Equal("weather", + listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("cloudSkill").GetString()); - var skillReply = replies.Last(static reply => string.Equals(ReadReplyType(reply), "SKILL_ACTION", StringComparison.Ordinal)); + var skillReply = replies.Last(static reply => + string.Equals(ReadReplyType(reply), "SKILL_ACTION", StringComparison.Ordinal)); using var skillPayload = JsonDocument.Parse(skillReply.Text!); Assert.Equal( "report-skill", @@ -2273,7 +2516,8 @@ public sealed class JiboWebSocketServiceTests display.GetProperty("view").GetProperty("data").GetProperty("viewConfig").GetProperty("id").GetString()); Assert.Equal( "weatherTempView", - display.GetProperty("view").GetProperty("context").GetProperty("data").GetProperty("viewConfig").GetProperty("id").GetString()); + display.GetProperty("view").GetProperty("context").GetProperty("data").GetProperty("viewConfig") + .GetProperty("id").GetString()); Assert.True(jcpConfig.TryGetProperty("views", out var views)); var weatherHiLo = views.GetProperty("weatherHiLo"); @@ -2282,10 +2526,12 @@ public sealed class JiboWebSocketServiceTests Assert.True(jcpConfig.TryGetProperty("local", out var local)); Assert.Equal( "weatherTempView", - local.GetProperty("views").GetProperty("weatherHiLo").GetProperty("viewConfig").GetProperty("id").GetString()); + local.GetProperty("views").GetProperty("weatherHiLo").GetProperty("viewConfig").GetProperty("id") + .GetString()); 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); } @@ -2304,7 +2550,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-weather-next-week-token", - Text = """{"type":"LISTEN","transID":"trans-weather-next-week","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-weather-next-week","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" }); var replies = await customService.HandleMessageAsync(new WebSocketMessageEnvelope @@ -2313,11 +2560,13 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-weather-next-week-token", - Text = """{"type":"CLIENT_ASR","transID":"trans-weather-next-week","data":{"text":"forecast for seattle next week"}}""" + Text = + """{"type":"CLIENT_ASR","transID":"trans-weather-next-week","data":{"text":"forecast for seattle next week"}}""" }); Assert.True(replies.Count >= 3); - var skillReply = replies.Last(static reply => string.Equals(ReadReplyType(reply), "SKILL_ACTION", StringComparison.Ordinal)); + var skillReply = replies.Last(static reply => + string.Equals(ReadReplyType(reply), "SKILL_ACTION", StringComparison.Ordinal)); using var skillPayload = JsonDocument.Parse(skillReply.Text!); var jcp = skillPayload.RootElement .GetProperty("data") @@ -2333,7 +2582,8 @@ public sealed class JiboWebSocketServiceTests Assert.True(firstChildConfig.TryGetProperty("display", out var firstDisplay)); Assert.Equal( "weatherTempView", - firstDisplay.GetProperty("view").GetProperty("data").GetProperty("viewConfig").GetProperty("id").GetString()); + firstDisplay.GetProperty("view").GetProperty("data").GetProperty("viewConfig").GetProperty("id") + .GetString()); } [Fact] @@ -2345,7 +2595,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-radio-open-token", - Text = """{"type":"LISTEN","transID":"trans-radio-open","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-radio-open","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -2364,18 +2615,26 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("SKILL_ACTION", ReadReplyType(replies[3])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("menu", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("@be/radio", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString()); - Assert.Equal(0, listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules").GetArrayLength()); - Assert.Empty(listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").EnumerateObject()); + Assert.Equal("menu", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("@be/radio", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString()); + Assert.Equal(0, + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules").GetArrayLength()); + Assert.Empty(listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .EnumerateObject()); using var redirectPayload = JsonDocument.Parse(replies[2].Text!); - Assert.Equal("@be/radio", redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); - Assert.True(redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("launch").GetBoolean()); - Assert.Equal("menu", redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("@be/radio", + redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); + Assert.True(redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("launch") + .GetBoolean()); + Assert.Equal("menu", + redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); using var completionPayload = JsonDocument.Parse(replies[3].Text!); - Assert.Equal("@be/radio", completionPayload.RootElement.GetProperty("data").GetProperty("skill").GetProperty("id").GetString()); + Assert.Equal("@be/radio", + completionPayload.RootElement.GetProperty("data").GetProperty("skill").GetProperty("id").GetString()); } [Fact] @@ -2387,7 +2646,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-radio-country-token", - Text = """{"type":"LISTEN","transID":"trans-radio-country","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-radio-country","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -2402,12 +2662,18 @@ public sealed class JiboWebSocketServiceTests Assert.Equal(4, replies.Count); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("menu", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("Country", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("station").GetString()); + Assert.Equal("menu", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("Country", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("station").GetString()); using var redirectPayload = JsonDocument.Parse(replies[2].Text!); - Assert.Equal("Country", redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("station").GetString()); - Assert.Equal("play country music", redirectPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); + Assert.Equal("Country", + redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("station").GetString()); + Assert.Equal("play country music", + redirectPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); } [Fact] @@ -2419,7 +2685,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-stop-token", - Text = """{"type":"LISTEN","transID":"trans-stop","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-stop","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -2444,8 +2711,10 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("globals/global_commands_launch", nlu.GetProperty("rules")[0].GetString()); using var redirectPayload = JsonDocument.Parse(replies[2].Text!); - Assert.Equal("@be/idle", redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); - Assert.Equal("stop", redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("@be/idle", + redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); + Assert.Equal("stop", + redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); } [Fact] @@ -2457,7 +2726,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-volume-down-token", - Text = """{"type":"LISTEN","transID":"trans-volume-down","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-volume-down","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -2490,7 +2760,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-volume-value-token", - Text = """{"type":"LISTEN","transID":"trans-volume-value","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-volume-value","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -2520,7 +2791,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-volume-query-token", - Text = """{"type":"LISTEN","transID":"trans-volume-query","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-volume-query","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -2537,12 +2809,16 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("SKILL_ACTION", ReadReplyType(replies[3])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("volumeQuery", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("@be/settings", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString()); + Assert.Equal("volumeQuery", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("@be/settings", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString()); using var redirectPayload = JsonDocument.Parse(replies[2].Text!); - Assert.Equal("@be/settings", redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); - Assert.Equal("volumeQuery", redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("@be/settings", + redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); + Assert.Equal("volumeQuery", + redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); } [Fact] @@ -2554,7 +2830,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-wod-guess-token", - Text = """{"type":"LISTEN","transID":"trans-wod-guess","data":{"rules":["word-of-the-day/puzzle","globals/gui_nav"],"asr":{"hints":["pastoral","doodad","escarpment"],"earlyEOS":["pastoral","doodad","escarpment"]}}}""" + Text = + """{"type":"LISTEN","transID":"trans-wod-guess","data":{"rules":["word-of-the-day/puzzle","globals/gui_nav"],"asr":{"hints":["pastoral","doodad","escarpment"],"earlyEOS":["pastoral","doodad","escarpment"]}}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -2563,7 +2840,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-wod-guess-token", - Text = """{"type":"CLIENT_NLU","transID":"trans-wod-guess","data":{"entities":{"guess":"pastoral"},"intent":"guess","rules":["word-of-the-day/puzzle"]}}""" + Text = + """{"type":"CLIENT_NLU","transID":"trans-wod-guess","data":{"entities":{"guess":"pastoral"},"intent":"guess","rules":["word-of-the-day/puzzle"]}}""" }); Assert.Equal(3, replies.Count); @@ -2572,10 +2850,15 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("SKILL_ACTION", ReadReplyType(replies[2])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("pastoral", listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); - Assert.Equal("guess", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("pastoral", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("guess").GetString()); - Assert.Equal("word-of-the-day/puzzle", listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); + Assert.Equal("pastoral", + listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); + Assert.Equal("guess", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("pastoral", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("guess").GetString()); + Assert.Equal("word-of-the-day/puzzle", + listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); } [Fact] @@ -2587,7 +2870,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-wod-spoken-guess-token", - Text = """{"type":"LISTEN","transID":"trans-wod-spoken-guess","data":{"rules":["word-of-the-day/puzzle"],"asr":{"hints":["pastoral","doodad","escarpment"]}}}""" + Text = + """{"type":"LISTEN","transID":"trans-wod-spoken-guess","data":{"rules":["word-of-the-day/puzzle"],"asr":{"hints":["pastoral","doodad","escarpment"]}}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -2605,10 +2889,15 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("SKILL_ACTION", ReadReplyType(replies[2])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("pastoral", listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); - Assert.Equal("guess", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("pastoral", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("guess").GetString()); - Assert.Equal("word-of-the-day/puzzle", listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); + Assert.Equal("pastoral", + listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); + Assert.Equal("guess", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("pastoral", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("guess").GetString()); + Assert.Equal("word-of-the-day/puzzle", + listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); } [Fact] @@ -2620,7 +2909,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-wod-line-guess-token", - Text = """{"type":"LISTEN","transID":"trans-wod-line-guess","data":{"rules":["word-of-the-day/puzzle"],"asr":{"hints":["doodad","pastoral","escarpment"]}}}""" + Text = + """{"type":"LISTEN","transID":"trans-wod-line-guess","data":{"rules":["word-of-the-day/puzzle"],"asr":{"hints":["doodad","pastoral","escarpment"]}}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -2634,9 +2924,13 @@ public sealed class JiboWebSocketServiceTests Assert.Equal(3, replies.Count); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("pastoral", listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); - Assert.Equal("guess", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("pastoral", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("guess").GetString()); + Assert.Equal("pastoral", + listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); + Assert.Equal("guess", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("pastoral", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("guess").GetString()); Assert.Equal("SKILL_ACTION", ReadReplyType(replies[2])); } @@ -2649,7 +2943,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-wod-fuzzy-guess-token", - Text = """{"type":"LISTEN","transID":"trans-wod-fuzzy-guess","data":{"rules":["word-of-the-day/puzzle"],"asr":{"hints":["aglet","hovel","wisenheimer"]}}}""" + Text = + """{"type":"LISTEN","transID":"trans-wod-fuzzy-guess","data":{"rules":["word-of-the-day/puzzle"],"asr":{"hints":["aglet","hovel","wisenheimer"]}}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -2664,8 +2959,11 @@ public sealed class JiboWebSocketServiceTests Assert.Equal(3, replies.Count); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("aglet", listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); - Assert.Equal("aglet", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("guess").GetString()); + Assert.Equal("aglet", + listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); + Assert.Equal("aglet", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("guess").GetString()); Assert.Equal("SKILL_ACTION", ReadReplyType(replies[2])); } @@ -2678,7 +2976,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-wod-guess-rules-token", - Text = """{"type":"LISTEN","transID":"trans-wod-guess-rules","data":{"rules":["word-of-the-day/puzzle","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"],"asr":{"hints":["aglet","hovel","wisenheimer"]}}}""" + Text = + """{"type":"LISTEN","transID":"trans-wod-guess-rules","data":{"rules":["word-of-the-day/puzzle","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"],"asr":{"hints":["aglet","hovel","wisenheimer"]}}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -2696,7 +2995,8 @@ public sealed class JiboWebSocketServiceTests var rules = listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules"); Assert.Single(rules.EnumerateArray()); Assert.Equal("word-of-the-day/puzzle", rules[0].GetString()); - Assert.Equal("word-of-the-day/puzzle", listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); + Assert.Equal("word-of-the-day/puzzle", + listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); } [Fact] @@ -2708,7 +3008,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-settings-no-token", - Text = """{"type":"LISTEN","transID":"trans-settings-no","data":{"rules":["settings/download_now_later","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"],"asr":{"hints":["$YESNO"]}}}""" + Text = + """{"type":"LISTEN","transID":"trans-settings-no","data":{"rules":["settings/download_now_later","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"],"asr":{"hints":["$YESNO"]}}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -2726,8 +3027,10 @@ public sealed class JiboWebSocketServiceTests var rules = listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules"); Assert.Single(rules.EnumerateArray()); Assert.Equal("settings/download_now_later", rules[0].GetString()); - Assert.Equal("settings/download_now_later", listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); - Assert.Equal("no", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("settings/download_now_later", + listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); + Assert.Equal("no", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); } [Fact] @@ -2739,7 +3042,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-wod-launch-token", - Text = """{"type":"LISTEN","transID":"trans-wod-launch","data":{"rules":["launch","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-wod-launch","data":{"rules":["launch","globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -2753,18 +3057,27 @@ public sealed class JiboWebSocketServiceTests Assert.Equal(4, replies.Count); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("menu", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal(string.Empty, listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); - Assert.Equal("word-of-the-day", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("domain").GetString()); - Assert.Equal("@be/word-of-the-day", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString()); - Assert.Equal("word-of-the-day/menu", listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); + Assert.Equal("menu", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal(string.Empty, + listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); + Assert.Equal("word-of-the-day", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("domain").GetString()); + Assert.Equal("@be/word-of-the-day", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString()); + Assert.Equal("word-of-the-day/menu", + listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); Assert.Equal("SKILL_REDIRECT", ReadReplyType(replies[2])); Assert.Equal("SKILL_ACTION", ReadReplyType(replies[3])); using var redirectPayload = JsonDocument.Parse(replies[2].Text!); - Assert.Equal("@be/word-of-the-day", redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); - Assert.True(redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("onRobot").GetBoolean()); - Assert.True(redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("launch").GetBoolean()); + Assert.Equal("@be/word-of-the-day", + redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); + Assert.True(redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("onRobot") + .GetBoolean()); + Assert.True(redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("launch") + .GetBoolean()); var session = _store.FindSessionByToken("hub-wod-launch-token"); Assert.NotNull(session); @@ -2780,7 +3093,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-wod-auto-token", - Text = """{"type":"LISTEN","transID":"trans-wod-auto","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-wod-auto","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" }); await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -2789,7 +3103,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-wod-auto-token", - Text = """{"type":"CONTEXT","transID":"trans-wod-auto","data":{"audioTranscriptHint":"play word of the day"}}""" + Text = + """{"type":"CONTEXT","transID":"trans-wod-auto","data":{"audioTranscriptHint":"play word of the day"}}""" }); for (var index = 0; index < 4; index += 1) @@ -2826,9 +3141,13 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("SKILL_ACTION", ReadReplyType(replies[3])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("menu", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("word-of-the-day", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("domain").GetString()); - Assert.Equal("@be/word-of-the-day", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString()); + Assert.Equal("menu", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("word-of-the-day", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("domain").GetString()); + Assert.Equal("@be/word-of-the-day", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString()); var lateReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope { @@ -2861,7 +3180,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-wod-late-empty-token", - Text = """{"type":"CLIENT_NLU","transID":"trans-wod-late-empty","data":{"entities":{"guess":"pastoral"},"intent":"guess","rules":["word-of-the-day/puzzle"]}}""" + Text = + """{"type":"CLIENT_NLU","transID":"trans-wod-late-empty","data":{"entities":{"guess":"pastoral"},"intent":"guess","rules":["word-of-the-day/puzzle"]}}""" }); Assert.Equal(3, winReplies.Count); @@ -2887,7 +3207,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-wod-right-word-token", - Text = """{"type":"LISTEN","transID":"trans-wod-right-word","data":{"rules":["word-of-the-day/right_word","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-wod-right-word","data":{"rules":["word-of-the-day/right_word","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -2908,7 +3229,8 @@ public sealed class JiboWebSocketServiceTests Assert.False(listenPayload.RootElement.GetProperty("data").TryGetProperty("match", out _)); using var redirectPayload = JsonDocument.Parse(replies[2].Text!); - Assert.Equal("@be/idle", redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); + Assert.Equal("@be/idle", + redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString()); } [Fact] @@ -2920,7 +3242,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-listen-setup-token", - Text = """{"type":"LISTEN","transID":"trans-listen-setup","data":{"rules":["main-menu/execute_fun_stuff","globals/global_commands_launch"],"mode":"CLIENT_NLU"}}""" + Text = + """{"type":"LISTEN","transID":"trans-listen-setup","data":{"rules":["main-menu/execute_fun_stuff","globals/global_commands_launch"],"mode":"CLIENT_NLU"}}""" }); Assert.Empty(replies); @@ -2940,7 +3263,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-stale-listen-token", - Text = """{"type":"LISTEN","transID":"trans-stale-listen","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-stale-listen","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" }); var session = _store.FindSessionByToken("hub-stale-listen-token"); @@ -2959,7 +3283,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-stale-listen-token", - Text = """{"type":"LISTEN","transID":"trans-stale-listen","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-stale-listen","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" }); Assert.Empty(replies); @@ -2981,7 +3306,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-wod-right-word-audio-token", - Text = """{"type":"LISTEN","transID":"trans-wod-right-word-audio","data":{"rules":["word-of-the-day/right_word","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-wod-right-word-audio","data":{"rules":["word-of-the-day/right_word","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"]}}""" }); var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -2990,7 +3316,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-wod-right-word-audio-token", - Text = """{"type":"LISTEN","transID":"trans-wod-right-word-audio","data":{"rules":["word-of-the-day/right_word","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-wod-right-word-audio","data":{"rules":["word-of-the-day/right_word","globals/gui_nav","globals/mim_repeat","globals/global_commands_launch"]}}""" }); Assert.Equal(3, replies.Count); @@ -2999,7 +3326,8 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("SKILL_REDIRECT", ReadReplyType(replies[2])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal(string.Empty, listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); + Assert.Equal(string.Empty, + listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); Assert.False(listenPayload.RootElement.GetProperty("data").TryGetProperty("match", out _)); var binaryReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -3028,7 +3356,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-blank-audio-token", - Text = """{"type":"LISTEN","transID":"trans-blank-audio","data":{"text":"[BLANK_AUDIO]","rules":["launch","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-blank-audio","data":{"text":"[BLANK_AUDIO]","rules":["launch","globals/global_commands_launch"]}}""" }); Assert.Empty(replies); @@ -3062,7 +3391,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-context-no-listen-token", - Text = """{"type":"LISTEN","transID":"trans-context-no-listen-hello","data":{"text":"hello","rules":["launch","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-context-no-listen-hello","data":{"text":"hello","rules":["launch","globals/global_commands_launch"]}}""" }); Assert.Equal(3, helloReplies.Count); @@ -3111,7 +3441,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-delete-the-alarm-token", - Text = """{"type":"LISTEN","transID":"trans-delete-the-alarm","data":{"text":"So, delete the alarm.","rules":["launch","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-delete-the-alarm","data":{"text":"So, delete the alarm.","rules":["launch","globals/global_commands_launch"]}}""" }); Assert.Equal(4, replies.Count); @@ -3121,9 +3452,13 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("SKILL_ACTION", ReadReplyType(replies[3])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("delete", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); - Assert.Equal("@be/clock", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString()); - Assert.Equal("alarm", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities").GetProperty("domain").GetString()); + Assert.Equal("delete", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("@be/clock", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString()); + Assert.Equal("alarm", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities") + .GetProperty("domain").GetString()); var session = _store.FindSessionByToken("hub-delete-the-alarm-token"); Assert.NotNull(session); @@ -3139,7 +3474,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-initial-hotphrase-token", - Text = """{"type":"LISTEN","transID":"trans-initial-hotphrase","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-initial-hotphrase","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" }); Assert.Empty(replies); @@ -3159,7 +3495,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-empty-hotphrase-token", - Text = """{"type":"LISTEN","transID":"trans-empty-hotphrase","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-empty-hotphrase","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" }); var firstReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -3188,8 +3525,10 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("SKILL_ACTION", ReadReplyType(replies[2])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("hello", listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); - Assert.Equal("hello", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("hello", + listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); + Assert.Equal("hello", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); var session = _store.FindSessionByToken("hub-empty-hotphrase-token"); Assert.NotNull(session); @@ -3216,7 +3555,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-audio-token", - Text = """{"type":"CONTEXT","transID":"trans-audio","data":{"topic":"conversation","audioTranscriptHint":"tell me a joke"}}""" + Text = + """{"type":"CONTEXT","transID":"trans-audio","data":{"topic":"conversation","audioTranscriptHint":"tell me a joke"}}""" }); Assert.Empty(contextReplies); @@ -3248,8 +3588,10 @@ public sealed class JiboWebSocketServiceTests Assert.Equal(75, finalizeReplies[2].DelayMs); using var listenPayload = JsonDocument.Parse(finalizeReplies[0].Text!); - Assert.Equal("tell me a joke", listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); - Assert.Equal("joke", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("tell me a joke", + listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); + Assert.Equal("joke", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); var session = _store.FindSessionByToken("hub-audio-token"); Assert.NotNull(session); @@ -3314,7 +3656,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-audio-chat-token", - Text = """{"type":"CONTEXT","transID":"trans-audio-chat","data":{"audioTranscriptHint":"hello from buffered audio"}}""" + Text = + """{"type":"CONTEXT","transID":"trans-audio-chat","data":{"audioTranscriptHint":"hello from buffered audio"}}""" }); await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -3337,11 +3680,14 @@ public sealed class JiboWebSocketServiceTests Assert.Equal(3, finalizeReplies.Count); using var listenPayload = JsonDocument.Parse(finalizeReplies[0].Text!); - Assert.Equal("hello from buffered audio", listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); - Assert.Equal("hello", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("hello from buffered audio", + listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); + Assert.Equal("hello", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); using var skillPayload = JsonDocument.Parse(finalizeReplies[2].Text!); - Assert.Equal("chitchat-skill", skillPayload.RootElement.GetProperty("data").GetProperty("skill").GetProperty("id").GetString()); + Assert.Equal("chitchat-skill", + skillPayload.RootElement.GetProperty("data").GetProperty("skill").GetProperty("id").GetString()); } [Fact] @@ -3353,7 +3699,8 @@ public sealed class JiboWebSocketServiceTests Path = "/listen", Kind = "neo-hub-listen", Token = "hub-hotphrase-greeting-token", - Text = """{"type":"LISTEN","transID":"trans-hotphrase-greeting","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" + Text = + """{"type":"LISTEN","transID":"trans-hotphrase-greeting","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}""" }); await _service.HandleMessageAsync(new WebSocketMessageEnvelope @@ -3399,8 +3746,10 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("SKILL_ACTION", ReadReplyType(replies[2])); using var listenPayload = JsonDocument.Parse(replies[0].Text!); - Assert.Equal("hello", listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); - Assert.Equal("hello", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("hello", + listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); + Assert.Equal("hello", + listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); Assert.True(session.FollowUpOpen); } @@ -3440,7 +3789,8 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("SKILL_ACTION", skillPayload.RootElement.GetProperty("type").GetString()); Assert.Equal("trans-joke-shape", skillPayload.RootElement.GetProperty("transID").GetString()); Assert.StartsWith("mid-", skillPayload.RootElement.GetProperty("msgID").GetString()); - Assert.Equal("@be/joke", skillPayload.RootElement.GetProperty("data").GetProperty("skill").GetProperty("id").GetString()); + Assert.Equal("@be/joke", + skillPayload.RootElement.GetProperty("data").GetProperty("skill").GetProperty("id").GetString()); var meta = skillPayload.RootElement .GetProperty("data") @@ -3495,7 +3845,8 @@ public sealed class JiboWebSocketServiceTests .GetString(); Assert.Contains("(snapshot); } } -} +} \ No newline at end of file diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/LocalWhisperCppBufferedAudioSttStrategyTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/LocalWhisperCppBufferedAudioSttStrategyTests.cs index 9371bce..34a984e 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/LocalWhisperCppBufferedAudioSttStrategyTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/LocalWhisperCppBufferedAudioSttStrategyTests.cs @@ -19,12 +19,12 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategyTests new FakeExternalProcessRunner()); var turn = new TurnContext + { + Attributes = new Dictionary { - Attributes = new Dictionary - { - ["bufferedAudioFrames"] = new[] { BuildMinimalOggPage() } - } - }; + ["bufferedAudioFrames"] = new[] { BuildMinimalOggPage() } + } + }; Assert.False(strategy.CanHandle(turn)); } @@ -119,10 +119,7 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategyTests } finally { - if (Directory.Exists(tempDirectory)) - { - Directory.Delete(tempDirectory, recursive: true); - } + if (Directory.Exists(tempDirectory)) Directory.Delete(tempDirectory, true); } } @@ -139,7 +136,8 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategyTests 0x00, 0x00, 0x00, 0x00, 0x01, 0x13, - 0x4F, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, 0x01, 0x01, 0x38, 0x01, 0x80, 0xBB, 0x00, 0x00, 0x00, 0x00, 0x00 + 0x4F, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, 0x01, 0x01, 0x38, 0x01, 0x80, 0xBB, 0x00, 0x00, 0x00, 0x00, + 0x00 ]; } @@ -154,7 +152,8 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategyTests { public List<(string FileName, IReadOnlyList Arguments)> Calls { get; } = []; - public Task RunAsync(string fileName, IReadOnlyList arguments, CancellationToken cancellationToken = default) + public Task RunAsync(string fileName, IReadOnlyList arguments, + CancellationToken cancellationToken = default) { Calls.Add((fileName, arguments)); @@ -165,7 +164,6 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategyTests var outputPath = arguments[^1]; File.WriteAllBytes(outputPath, "RIFF"u8); return Task.FromResult(new ExternalProcessResult(0, string.Empty, string.Empty)); - } } -} +} \ No newline at end of file