Add personal report state machine and turn telemetry

This commit is contained in:
Jacob Dubin
2026-05-06 14:50:37 -05:00
parent 60b8616239
commit 0ccfa5db68
7 changed files with 1098 additions and 2 deletions

View File

@@ -35,6 +35,9 @@ public sealed class DemoConversationBroker(JiboInteractionService interactionSer
ExpectedTopic = "conversation"
}
: FollowUpPolicy.None,
ContextUpdates = decision.ContextUpdates is not null
? new Dictionary<string, object?>(decision.ContextUpdates, StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, object?>(),
ProtocolMetadata = new Dictionary<string, object?>
{
["host"] = turn.HostName,

View File

@@ -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<string, object?>? SkillPayload = null);
IDictionary<string, object?>? SkillPayload = null,
IDictionary<string, object?>? ContextUpdates = null);

View File

@@ -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<JiboInteractionDecision?> TryBuildDecisionAsync(
TurnContext turn,
string semanticIntent,
string transcript,
string loweredTranscript,
JiboExperienceCatalog catalog,
IJiboRandomizer randomizer,
IPersonalMemoryStore personalMemoryStore,
Func<TurnContext, string, CancellationToken, Task<JiboInteractionDecision>> buildWeatherDecisionAsync,
Func<TurnContext, PersonalMemoryTenantScope> 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<JiboInteractionDecision> BuildDeliveredReportDecisionAsync(
TurnContext turn,
JiboExperienceCatalog catalog,
IJiboRandomizer randomizer,
PersonalReportServiceToggles toggles,
string userName,
Func<TurnContext, string, CancellationToken, Task<JiboInteractionDecision>> buildWeatherDecisionAsync,
CancellationToken cancellationToken)
{
var reportSections = new List<string> { $"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<string, object?> BuildContextUpdates(
string state,
int noMatchCount,
int noInputCount,
PersonalReportServiceToggles toggles,
string? userName,
bool userVerified,
string lastServiceError)
{
return new Dictionary<string, object?>(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<string> 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<string>();
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<PersonalReportServiceToggles, PersonalReportServiceToggles> disable,
Func<PersonalReportServiceToggles, PersonalReportServiceToggles> 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);
}

View File

@@ -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)

View File

@@ -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<string, object?> 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<string, object?>
{
["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<string, object?>
{
["state"] = nextState,
["previousState"] = previousState,
["intent"] = intentName
}), cancellationToken);
}
}
if (nextNoMatchCount != previousNoMatchCount)
{
await sink.RecordTurnDiagnosticAsync("personal_report_nomatch_count", BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary<string, object?>
{
["count"] = nextNoMatchCount,
["previousCount"] = previousNoMatchCount,
["state"] = nextState,
["intent"] = intentName
}), cancellationToken);
}
if (nextNoInputCount != previousNoInputCount)
{
await sink.RecordTurnDiagnosticAsync("personal_report_noinput_count", BuildTurnDiagnosticSnapshot(session, envelope, new Dictionary<string, object?>
{
["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<string, object?>
{
["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<string, object?>
{
["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<string, object?> 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<string, object?> 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<string, object?> 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))

View File

@@ -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<string, object?>
{
["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<string, object?>
{
[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<string, object?>
{
[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<string, object?>
{
[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()
{

View File

@@ -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()
{