From 0ccfa5db6888e6915aed09e8f2493507a87a9b8a Mon Sep 17 00:00:00 2001 From: Jacob Dubin Date: Wed, 6 May 2026 14:50:37 -0500 Subject: [PATCH] Add personal report state machine and turn telemetry --- .../Services/DemoConversationBroker.cs | 3 + .../Services/JiboInteractionService.cs | 20 +- .../Services/PersonalReportOrchestrator.cs | 660 ++++++++++++++++++ .../Services/ProtocolToTurnContextMapper.cs | 11 + .../WebSocketTurnFinalizationService.cs | 198 +++++- .../WebSockets/JiboInteractionServiceTests.cs | 141 ++++ .../WebSockets/JiboWebSocketServiceTests.cs | 67 ++ 7 files changed, 1098 insertions(+), 2 deletions(-) create mode 100644 OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/PersonalReportOrchestrator.cs 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 c5f0194..dfbb416 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 @@ -35,6 +35,9 @@ public sealed class DemoConversationBroker(JiboInteractionService interactionSer ExpectedTopic = "conversation" } : FollowUpPolicy.None, + ContextUpdates = decision.ContextUpdates is not null + ? new Dictionary(decision.ContextUpdates, StringComparer.OrdinalIgnoreCase) + : new Dictionary(), ProtocolMetadata = new Dictionary { ["host"] = turn.HostName, 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 b0e1a2d..fc29a29 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 @@ -47,6 +47,23 @@ public sealed class JiboInteractionService( isYesNoTurn, isTimerValueTurn, isAlarmValueTurn); + + var personalReportDecision = await PersonalReportOrchestrator.TryBuildDecisionAsync( + turn, + semanticIntent, + transcript, + lowered, + catalog, + randomizer, + personalMemoryStore, + BuildWeatherReportDecisionAsync, + ResolveTenantScope, + cancellationToken); + if (personalReportDecision is not null) + { + return personalReportDecision; + } + return semanticIntent switch { "joke" => BuildJokeDecision(catalog), @@ -2876,4 +2893,5 @@ public sealed record JiboInteractionDecision( string IntentName, string ReplyText, string? SkillName = null, - IDictionary? SkillPayload = null); + IDictionary? SkillPayload = null, + IDictionary? ContextUpdates = null); 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 new file mode 100644 index 0000000..00e14a1 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/PersonalReportOrchestrator.cs @@ -0,0 +1,660 @@ +using Jibo.Cloud.Application.Abstractions; +using Jibo.Runtime.Abstractions; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Jibo.Cloud.Application.Services; + +internal static class PersonalReportOrchestrator +{ + internal const string StateMetadataKey = "personalReportState"; + internal const string NoMatchCountMetadataKey = "personalReportNoMatchCount"; + internal const string NoInputCountMetadataKey = "personalReportNoInputCount"; + internal const string UserNameMetadataKey = "personalReportUserName"; + internal const string UserVerifiedMetadataKey = "personalReportUserVerified"; + internal const string WeatherEnabledMetadataKey = "personalReportWeatherEnabled"; + internal const string CalendarEnabledMetadataKey = "personalReportCalendarEnabled"; + internal const string CommuteEnabledMetadataKey = "personalReportCommuteEnabled"; + internal const string NewsEnabledMetadataKey = "personalReportNewsEnabled"; + internal const string LastServiceErrorMetadataKey = "personalReportLastServiceError"; + + internal const string IdleState = "idle"; + private const string AwaitingOptInState = "awaiting_opt_in"; + private const string AwaitingIdentityConfirmationState = "awaiting_identity_confirmation"; + private const string AwaitingIdentityNameState = "awaiting_identity_name"; + + private const int MaxNoMatchCount = 2; + private const int MaxNoInputCount = 2; + + private static readonly string[] CancelPhrases = + [ + "cancel", + "stop", + "never mind", + "nevermind", + "forget it" + ]; + + private static readonly string[] AffirmativePhrases = + [ + "yes", + "yeah", + "yep", + "yup", + "sure", + "ok", + "okay", + "do it", + "please do", + "go ahead" + ]; + + private static readonly string[] NegativePhrases = + [ + "no", + "nah", + "nope", + "not now", + "maybe later" + ]; + + public static async Task TryBuildDecisionAsync( + TurnContext turn, + string semanticIntent, + string transcript, + string loweredTranscript, + JiboExperienceCatalog catalog, + IJiboRandomizer randomizer, + IPersonalMemoryStore personalMemoryStore, + Func> buildWeatherDecisionAsync, + Func tenantScopeResolver, + CancellationToken cancellationToken) + { + var state = ReadState(turn); + var isActiveState = !string.Equals(state, IdleState, StringComparison.OrdinalIgnoreCase); + 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 (!isActiveState) + { + var contextUpdates = BuildContextUpdates( + AwaitingOptInState, + noMatchCount: 0, + noInputCount: 0, + toggles, + userName: ReadString(turn, UserNameMetadataKey), + userVerified: ReadBool(turn, UserVerifiedMetadataKey) ?? false, + lastServiceError: string.Empty); + + var reply = string.IsNullOrWhiteSpace(inlineToggleSummary) + ? "Would you like your personal report now?" + : $"{inlineToggleSummary} Would you like your personal report now?"; + + return new JiboInteractionDecision( + "personal_report_opt_in", + reply, + ContextUpdates: contextUpdates); + } + + if (string.IsNullOrWhiteSpace(loweredTranscript)) + { + return BuildNoInputDecision(turn, state, toggles); + } + + switch (state) + { + case AwaitingOptInState: + if (IsAffirmativeReply(loweredTranscript)) + { + 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, + toggles, + userName: knownName, + userVerified: false, + lastServiceError: string.Empty)); + } + + return new JiboInteractionDecision( + "personal_report_request_name", + "Who is this?", + ContextUpdates: BuildContextUpdates( + AwaitingIdentityNameState, + noMatchCount: 0, + noInputCount: 0, + toggles, + userName: null, + userVerified: false, + lastServiceError: string.Empty)); + } + + 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, + toggles, + userName: ReadString(turn, UserNameMetadataKey), + userVerified: false, + lastServiceError: 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); + + 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, + toggles, + userName: null, + userVerified: false, + lastServiceError: string.Empty)); + } + + if (IsAffirmativeReply(loweredTranscript)) + { + return await BuildDeliveredReportDecisionAsync( + turn, + catalog, + randomizer, + toggles, + currentName, + buildWeatherDecisionAsync, + cancellationToken); + } + + if (IsNegativeReply(loweredTranscript)) + { + return new JiboInteractionDecision( + "personal_report_request_name", + "Okay, who is this?", + ContextUpdates: BuildContextUpdates( + AwaitingIdentityNameState, + noMatchCount: 0, + noInputCount: 0, + toggles, + userName: null, + userVerified: false, + lastServiceError: string.Empty)); + } + + return BuildNoMatchDecision( + turn, + state, + $"Please answer yes or no. Is this {currentName}?", + toggles, + userName: currentName, + userVerified: 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); + } + + personalMemoryStore.SetName(tenantScopeResolver(turn), parsedName); + return await BuildDeliveredReportDecisionAsync( + turn, + catalog, + randomizer, + toggles, + parsedName, + buildWeatherDecisionAsync, + cancellationToken); + } + + default: + return BuildDeclinedDecision(toggles); + } + } + + private static async Task BuildDeliveredReportDecisionAsync( + TurnContext turn, + JiboExperienceCatalog catalog, + IJiboRandomizer randomizer, + PersonalReportServiceToggles toggles, + string userName, + Func> buildWeatherDecisionAsync, + CancellationToken cancellationToken) + { + var reportSections = new List { $"Great, {userName}. Here is your personal report." }; + var serviceError = string.Empty; + + if (toggles.WeatherEnabled) + { + var weatherDecision = await buildWeatherDecisionAsync(turn, "weather", cancellationToken); + reportSections.Add(weatherDecision.ReplyText); + if (IsWeatherErrorReply(weatherDecision.ReplyText)) + { + serviceError = "weather"; + } + } + + if (toggles.CalendarEnabled) + { + reportSections.Add(randomizer.Choose(catalog.CalendarReplies)); + } + + if (toggles.CommuteEnabled) + { + reportSections.Add(randomizer.Choose(catalog.CommuteReplies)); + } + + if (toggles.NewsEnabled) + { + reportSections.Add(randomizer.Choose(catalog.NewsBriefings)); + } + + reportSections.Add("That is your personal report."); + + return new JiboInteractionDecision( + "personal_report_delivered", + string.Join(" ", reportSections), + ContextUpdates: BuildContextUpdates( + IdleState, + noMatchCount: 0, + noInputCount: 0, + toggles, + userName, + userVerified: true, + lastServiceError: serviceError)); + } + + private static JiboInteractionDecision BuildNoInputDecision( + TurnContext turn, + string state, + PersonalReportServiceToggles toggles) + { + var noInputCount = Math.Max(0, ReadInt(turn, NoInputCountMetadataKey)) + 1; + 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), + noInputCount, + toggles, + userName: ReadString(turn, UserNameMetadataKey), + userVerified: ReadBool(turn, UserVerifiedMetadataKey) ?? false, + lastServiceError: string.Empty)); + } + + private static JiboInteractionDecision BuildNoMatchDecision( + TurnContext turn, + string state, + string repromptText, + PersonalReportServiceToggles toggles, + string? userName, + bool userVerified) + { + var noMatchCount = Math.Max(0, ReadInt(turn, NoMatchCountMetadataKey)) + 1; + if (noMatchCount >= MaxNoMatchCount) + { + return BuildDeclinedDecision(toggles); + } + + return new JiboInteractionDecision( + "personal_report_no_match", + repromptText, + ContextUpdates: BuildContextUpdates( + state, + noMatchCount, + noInputCount: 0, + toggles, + userName, + userVerified, + lastServiceError: string.Empty)); + } + + private static JiboInteractionDecision BuildDeclinedDecision(PersonalReportServiceToggles toggles) + { + return new JiboInteractionDecision( + "personal_report_declined", + "No problem. We can do your personal report another time.", + ContextUpdates: BuildContextUpdates( + IdleState, + noMatchCount: 0, + noInputCount: 0, + toggles, + userName: null, + userVerified: false, + lastServiceError: string.Empty)); + } + + private static JiboInteractionDecision BuildCancelledDecision(PersonalReportServiceToggles toggles) + { + return new JiboInteractionDecision( + "personal_report_cancelled", + "Okay, canceling personal report.", + ContextUpdates: BuildContextUpdates( + IdleState, + noMatchCount: 0, + noInputCount: 0, + toggles, + userName: null, + userVerified: false, + lastServiceError: string.Empty)); + } + + private static IDictionary BuildContextUpdates( + string state, + int noMatchCount, + int noInputCount, + PersonalReportServiceToggles toggles, + string? userName, + bool userVerified, + string lastServiceError) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [StateMetadataKey] = state, + [NoMatchCountMetadataKey] = noMatchCount, + [NoInputCountMetadataKey] = noInputCount, + [UserNameMetadataKey] = userName, + [UserVerifiedMetadataKey] = userVerified, + [WeatherEnabledMetadataKey] = toggles.WeatherEnabled, + [CalendarEnabledMetadataKey] = toggles.CalendarEnabled, + [CommuteEnabledMetadataKey] = toggles.CommuteEnabled, + [NewsEnabledMetadataKey] = toggles.NewsEnabled, + [LastServiceErrorMetadataKey] = lastServiceError + }; + } + + private static bool IsAffirmativeReply(string loweredTranscript) + { + return ContainsAnyPhrase(loweredTranscript, AffirmativePhrases); + } + + private static bool IsNegativeReply(string loweredTranscript) + { + return ContainsAnyPhrase(loweredTranscript, NegativePhrases); + } + + 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; + } + + return replyText.Contains("couldn't fetch the weather", StringComparison.OrdinalIgnoreCase) || + replyText.Contains("weather service is connected", StringComparison.OrdinalIgnoreCase); + } + + private static PersonalReportServiceToggles ReadServiceToggles(TurnContext turn) + { + return new PersonalReportServiceToggles( + ReadBool(turn, WeatherEnabledMetadataKey) ?? true, + ReadBool(turn, CalendarEnabledMetadataKey) ?? true, + ReadBool(turn, CommuteEnabledMetadataKey) ?? true, + ReadBool(turn, NewsEnabledMetadataKey) ?? true); + } + + private static PersonalReportServiceToggles ApplyInlineToggleHints( + PersonalReportServiceToggles toggles, + string loweredTranscript, + out string summary) + { + 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 }); + + 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)}."; + } + + return updated; + } + + private static PersonalReportServiceToggles ApplyToggleHint( + PersonalReportServiceToggles toggles, + string loweredTranscript, + string serviceLabel, + Func disable, + Func enable) + { + 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; + } + + private static string ReadState(TurnContext turn) + { + return ReadString(turn, StateMetadataKey) ?? IdleState; + } + + private static string? ReadString(TurnContext turn, string key) + { + if (!turn.Attributes.TryGetValue(key, out var value) || value is null) + { + return null; + } + + return value switch + { + string text => string.IsNullOrWhiteSpace(text) ? null : text.Trim(), + _ => value.ToString() + }; + } + + private static bool? ReadBool(TurnContext turn, string key) + { + if (!turn.Attributes.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 int ReadInt(TurnContext turn, string key) + { + if (!turn.Attributes.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 } number when number.TryGetInt32(out var parsed) => parsed, + JsonElement json when json.ValueKind == JsonValueKind.String && int.TryParse(json.GetString(), out var parsed) => parsed, + _ => 0 + }; + } + + private static string? TryExtractName(string loweredTranscript) + { + var normalized = NameNoiseRegex.Replace(loweredTranscript, " ") + .Replace(" ", " ", StringComparison.Ordinal) + .Trim(); + if (string.IsNullOrWhiteSpace(normalized)) + { + return null; + } + + var prefixes = new[] + { + "my name is ", + "it is ", + "it s ", + "it's ", + "i am ", + "im " + }; + + foreach (var prefix in prefixes) + { + if (!normalized.StartsWith(prefix, StringComparison.Ordinal)) + { + continue; + } + + var candidate = normalized[prefix.Length..].Trim(); + return NormalizeNameCandidate(candidate); + } + + return NormalizeNameCandidate(normalized); + } + + private static string? NormalizeNameCandidate(string candidate) + { + if (string.IsNullOrWhiteSpace(candidate)) + { + return null; + } + + var cleaned = NameNoiseRegex.Replace(candidate, " ") + .Replace(" ", " ", StringComparison.Ordinal) + .Trim(); + if (string.IsNullOrWhiteSpace(cleaned)) + { + 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.Any(static word => word.Any(char.IsDigit))) + { + return null; + } + + return 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); +} 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 6cd448e..d8e4b13 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 @@ -57,6 +57,17 @@ public sealed class ProtocolToTurnContextMapper attributes["pendingProactivityOffer"] = pendingProactivityOfferText; } + foreach (var pair in session.Metadata) + { + if (!pair.Key.StartsWith("personalReport", StringComparison.OrdinalIgnoreCase) || + pair.Value is null) + { + continue; + } + + attributes[pair.Key] = pair.Value; + } + attributes["listenHotphrase"] = turnState.ListenHotphrase; if (turnState.ListenRules.Count > 0) 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 d9503cc..5b45cac 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 @@ -485,7 +485,9 @@ public sealed partial class WebSocketTurnFinalizationService( return []; } - if (string.IsNullOrWhiteSpace(finalizedTurn.NormalizedTranscript) && + var allowEmptyTranscriptForPersonalReport = IsActivePersonalReportTurn(finalizedTurn); + if (!allowEmptyTranscriptForPersonalReport && + string.IsNullOrWhiteSpace(finalizedTurn.NormalizedTranscript) && string.IsNullOrWhiteSpace(finalizedTurn.RawTranscript)) { turnState.AwaitingTurnCompletion = true; @@ -592,6 +594,7 @@ public sealed partial class WebSocketTurnFinalizationService( } UpdatePendingProactivityOffer(session, plan.IntentName); + await ApplyContextUpdatesAsync(session, plan.ContextUpdates, envelope, plan.IntentName, cancellationToken); session.FollowUpExpiresUtc = plan.FollowUp.KeepMicOpen ? DateTimeOffset.UtcNow.Add(plan.FollowUp.Timeout) @@ -868,6 +871,7 @@ public sealed partial class WebSocketTurnFinalizationService( 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(); @@ -882,6 +886,12 @@ public sealed partial class WebSocketTurnFinalizationService( 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; @@ -924,6 +934,13 @@ public sealed partial class WebSocketTurnFinalizationService( .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)) @@ -1022,6 +1039,134 @@ public sealed partial class WebSocketTurnFinalizationService( string.Equals(rule, "surprises-ota/want_to_download_now", StringComparison.OrdinalIgnoreCase); } + 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; + + 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); + + 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); + } + } + + 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)) @@ -1051,6 +1196,57 @@ public sealed partial class WebSocketTurnFinalizationService( }; } + 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)) diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs index dd3e3e5..59b4337 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs @@ -9,6 +9,15 @@ namespace Jibo.Cloud.Tests.WebSockets; public sealed class JiboInteractionServiceTests { + private const string PersonalReportStateKey = "personalReportState"; + private const string PersonalReportNoMatchCountKey = "personalReportNoMatchCount"; + private const string PersonalReportUserNameKey = "personalReportUserName"; + private const string PersonalReportUserVerifiedKey = "personalReportUserVerified"; + private const string PersonalReportWeatherEnabledKey = "personalReportWeatherEnabled"; + private const string PersonalReportCalendarEnabledKey = "personalReportCalendarEnabled"; + private const string PersonalReportCommuteEnabledKey = "personalReportCommuteEnabled"; + private const string PersonalReportNewsEnabledKey = "personalReportNewsEnabled"; + [Fact] public async Task BuildDecisionAsync_Joke_UsesCatalogBackedRandomContent() { @@ -641,6 +650,138 @@ public sealed class JiboInteractionServiceTests Assert.Equal("RA_JBO_OrderPizza", decision.SkillPayload!["mim_id"]); } + [Fact] + public async Task BuildDecisionAsync_PersonalReport_StartsOptInStateMachine() + { + var service = CreateService(); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "personal report", + NormalizedTranscript = "personal report" + }); + + Assert.Equal("personal_report_opt_in", decision.IntentName); + Assert.Equal("Would you like your personal report now?", decision.ReplyText); + Assert.NotNull(decision.ContextUpdates); + Assert.Equal("awaiting_opt_in", decision.ContextUpdates![PersonalReportStateKey]); + Assert.Equal(true, decision.ContextUpdates[PersonalReportWeatherEnabledKey]); + Assert.Equal(true, decision.ContextUpdates[PersonalReportCalendarEnabledKey]); + Assert.Equal(true, decision.ContextUpdates[PersonalReportCommuteEnabledKey]); + Assert.Equal(true, decision.ContextUpdates[PersonalReportNewsEnabledKey]); + } + + [Fact] + public async Task BuildDecisionAsync_PersonalReport_OptInYesWithKnownName_AsksForIdentityConfirmation() + { + var memoryStore = new InMemoryPersonalMemoryStore(); + memoryStore.SetName(new PersonalMemoryTenantScope("acct-a", "loop-a", "device-a"), "alex"); + var service = CreateService(memoryStore); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "yes", + NormalizedTranscript = "yes", + DeviceId = "device-a", + Attributes = new Dictionary + { + ["accountId"] = "acct-a", + ["loopId"] = "loop-a", + [PersonalReportStateKey] = "awaiting_opt_in" + } + }); + + Assert.Equal("personal_report_verify_user", decision.IntentName); + Assert.Equal("I think this is alex. Is that right?", decision.ReplyText); + Assert.NotNull(decision.ContextUpdates); + Assert.Equal("awaiting_identity_confirmation", decision.ContextUpdates![PersonalReportStateKey]); + Assert.Equal("alex", decision.ContextUpdates[PersonalReportUserNameKey]); + } + + [Fact] + public async Task BuildDecisionAsync_PersonalReport_VerifiedIdentity_DeliversReportAndResetsState() + { + var provider = new CapturingWeatherReportProvider + { + Snapshot = new WeatherReportSnapshot("Boston, US", "light rain", 61, 65, 54, "rain", false) + }; + var service = CreateService(weatherReportProvider: provider); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "yes", + NormalizedTranscript = "yes", + Attributes = new Dictionary + { + [PersonalReportStateKey] = "awaiting_identity_confirmation", + [PersonalReportUserNameKey] = "alex" + } + }); + + Assert.Equal("personal_report_delivered", decision.IntentName); + Assert.Contains("Great, alex. Here is your personal report.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Right now in Boston, US, it is light rain and 61 degrees Fahrenheit.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("That is your personal report.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.NotNull(decision.ContextUpdates); + Assert.Equal("idle", decision.ContextUpdates![PersonalReportStateKey]); + Assert.Equal(true, decision.ContextUpdates[PersonalReportUserVerifiedKey]); + } + + [Fact] + public async Task BuildDecisionAsync_PersonalReport_NoMatchRetriesThenDeclines() + { + var service = CreateService(); + + var firstDecision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "maybe", + NormalizedTranscript = "maybe", + Attributes = new Dictionary + { + [PersonalReportStateKey] = "awaiting_opt_in", + [PersonalReportNoMatchCountKey] = 0 + } + }); + + Assert.Equal("personal_report_no_match", firstDecision.IntentName); + Assert.NotNull(firstDecision.ContextUpdates); + Assert.Equal(1, firstDecision.ContextUpdates![PersonalReportNoMatchCountKey]); + + var secondDecision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "maybe", + NormalizedTranscript = "maybe", + Attributes = new Dictionary + { + [PersonalReportStateKey] = "awaiting_opt_in", + [PersonalReportNoMatchCountKey] = 1 + } + }); + + Assert.Equal("personal_report_declined", secondDecision.IntentName); + Assert.NotNull(secondDecision.ContextUpdates); + Assert.Equal("idle", secondDecision.ContextUpdates![PersonalReportStateKey]); + } + + [Fact] + public async Task BuildDecisionAsync_PersonalReport_StartCanApplyToggleHints() + { + var service = CreateService(); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "personal report without weather and no news", + NormalizedTranscript = "personal report without weather and no news" + }); + + Assert.Equal("personal_report_opt_in", decision.IntentName); + Assert.NotNull(decision.ContextUpdates); + Assert.Equal(false, decision.ContextUpdates![PersonalReportWeatherEnabledKey]); + Assert.Equal(false, decision.ContextUpdates[PersonalReportNewsEnabledKey]); + Assert.Equal(true, decision.ContextUpdates[PersonalReportCalendarEnabledKey]); + Assert.Equal(true, decision.ContextUpdates[PersonalReportCommuteEnabledKey]); + } + [Fact] public async Task BuildDecisionAsync_WeatherQuery_WithoutProvider_UsesSpokenFallback() { diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs index a52b4fe..c25341e 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs @@ -3174,6 +3174,73 @@ public sealed class JiboWebSocketServiceTests Assert.False(session.Metadata.ContainsKey("pendingProactivityOffer")); } + [Fact] + public async Task ClientAsrPersonalReport_StateMachinePersistsAcrossTurns() + { + const string stateKey = "personalReportState"; + var token = _store.IssueRobotToken("personal-report-device-a"); + + var startReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope + { + HostName = "neo-hub.jibo.com", + Path = "/listen", + Kind = "neo-hub-listen", + Token = token, + Text = """{"type":"CLIENT_ASR","transID":"trans-personal-report-start","data":{"text":"personal report"}}""" + }); + + Assert.Equal(3, startReplies.Count); + using (var startListenPayload = JsonDocument.Parse(startReplies[0].Text!)) + { + Assert.Equal("personal_report_opt_in", startListenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + } + + var session = _store.FindSessionByToken(token); + Assert.NotNull(session); + Assert.True(session.Metadata.TryGetValue(stateKey, out var stateValue)); + Assert.Equal("awaiting_opt_in", stateValue?.ToString()); + + var optInReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope + { + HostName = "neo-hub.jibo.com", + Path = "/listen", + Kind = "neo-hub-listen", + Token = token, + Text = """{"type":"CLIENT_ASR","transID":"trans-personal-report-optin","data":{"text":"yes"}}""" + }); + + Assert.Equal(3, optInReplies.Count); + using (var optInListenPayload = JsonDocument.Parse(optInReplies[0].Text!)) + { + Assert.Equal("personal_report_request_name", optInListenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + } + + session = _store.FindSessionByToken(token); + Assert.NotNull(session); + Assert.True(session.Metadata.TryGetValue(stateKey, out stateValue)); + Assert.Equal("awaiting_identity_name", stateValue?.ToString()); + + var identifyReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope + { + HostName = "neo-hub.jibo.com", + Path = "/listen", + Kind = "neo-hub-listen", + Token = token, + Text = """{"type":"CLIENT_ASR","transID":"trans-personal-report-name","data":{"text":"my name is alex"}}""" + }); + + Assert.Equal(3, identifyReplies.Count); + using (var identifyListenPayload = JsonDocument.Parse(identifyReplies[0].Text!)) + { + Assert.Equal("personal_report_delivered", identifyListenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + } + + session = _store.FindSessionByToken(token); + Assert.NotNull(session); + Assert.True(session.Metadata.TryGetValue(stateKey, out stateValue)); + Assert.Equal("idle", stateValue?.ToString()); + } + [Fact] public async Task FollowUpTurn_UsesNewTurnStateWithoutLeakingBufferedAudio() {