Add personal report state machine and turn telemetry
This commit is contained in:
@@ -35,6 +35,9 @@ public sealed class DemoConversationBroker(JiboInteractionService interactionSer
|
|||||||
ExpectedTopic = "conversation"
|
ExpectedTopic = "conversation"
|
||||||
}
|
}
|
||||||
: FollowUpPolicy.None,
|
: 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?>
|
ProtocolMetadata = new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["host"] = turn.HostName,
|
["host"] = turn.HostName,
|
||||||
|
|||||||
@@ -47,6 +47,23 @@ public sealed class JiboInteractionService(
|
|||||||
isYesNoTurn,
|
isYesNoTurn,
|
||||||
isTimerValueTurn,
|
isTimerValueTurn,
|
||||||
isAlarmValueTurn);
|
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
|
return semanticIntent switch
|
||||||
{
|
{
|
||||||
"joke" => BuildJokeDecision(catalog),
|
"joke" => BuildJokeDecision(catalog),
|
||||||
@@ -2876,4 +2893,5 @@ public sealed record JiboInteractionDecision(
|
|||||||
string IntentName,
|
string IntentName,
|
||||||
string ReplyText,
|
string ReplyText,
|
||||||
string? SkillName = null,
|
string? SkillName = null,
|
||||||
IDictionary<string, object?>? SkillPayload = null);
|
IDictionary<string, object?>? SkillPayload = null,
|
||||||
|
IDictionary<string, object?>? ContextUpdates = null);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -57,6 +57,17 @@ public sealed class ProtocolToTurnContextMapper
|
|||||||
attributes["pendingProactivityOffer"] = pendingProactivityOfferText;
|
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;
|
attributes["listenHotphrase"] = turnState.ListenHotphrase;
|
||||||
|
|
||||||
if (turnState.ListenRules.Count > 0)
|
if (turnState.ListenRules.Count > 0)
|
||||||
|
|||||||
@@ -485,7 +485,9 @@ public sealed partial class WebSocketTurnFinalizationService(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(finalizedTurn.NormalizedTranscript) &&
|
var allowEmptyTranscriptForPersonalReport = IsActivePersonalReportTurn(finalizedTurn);
|
||||||
|
if (!allowEmptyTranscriptForPersonalReport &&
|
||||||
|
string.IsNullOrWhiteSpace(finalizedTurn.NormalizedTranscript) &&
|
||||||
string.IsNullOrWhiteSpace(finalizedTurn.RawTranscript))
|
string.IsNullOrWhiteSpace(finalizedTurn.RawTranscript))
|
||||||
{
|
{
|
||||||
turnState.AwaitingTurnCompletion = true;
|
turnState.AwaitingTurnCompletion = true;
|
||||||
@@ -592,6 +594,7 @@ public sealed partial class WebSocketTurnFinalizationService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
UpdatePendingProactivityOffer(session, plan.IntentName);
|
UpdatePendingProactivityOffer(session, plan.IntentName);
|
||||||
|
await ApplyContextUpdatesAsync(session, plan.ContextUpdates, envelope, plan.IntentName, cancellationToken);
|
||||||
|
|
||||||
session.FollowUpExpiresUtc = plan.FollowUp.KeepMicOpen
|
session.FollowUpExpiresUtc = plan.FollowUp.KeepMicOpen
|
||||||
? DateTimeOffset.UtcNow.Add(plan.FollowUp.Timeout)
|
? DateTimeOffset.UtcNow.Add(plan.FollowUp.Timeout)
|
||||||
@@ -868,6 +871,7 @@ public sealed partial class WebSocketTurnFinalizationService(
|
|||||||
var messageType = ReadMessageType(turn);
|
var messageType = ReadMessageType(turn);
|
||||||
var clientIntent = ReadAttribute(turn, "clientIntent");
|
var clientIntent = ReadAttribute(turn, "clientIntent");
|
||||||
var pendingProactivityOffer = ReadAttribute(turn, "pendingProactivityOffer");
|
var pendingProactivityOffer = ReadAttribute(turn, "pendingProactivityOffer");
|
||||||
|
var personalReportState = ReadAttribute(turn, PersonalReportOrchestrator.StateMetadataKey);
|
||||||
var transcript = NormalizeTranscript(turn.NormalizedTranscript ?? turn.RawTranscript);
|
var transcript = NormalizeTranscript(turn.NormalizedTranscript ?? turn.RawTranscript);
|
||||||
var listenRules = ReadRules(turn, "listenRules").Concat(ReadRules(turn, "clientRules")).ToArray();
|
var listenRules = ReadRules(turn, "listenRules").Concat(ReadRules(turn, "clientRules")).ToArray();
|
||||||
|
|
||||||
@@ -882,6 +886,12 @@ public sealed partial class WebSocketTurnFinalizationService(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(personalReportState) &&
|
||||||
|
!string.Equals(personalReportState, PersonalReportOrchestrator.IdleState, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (transcript is "blank_audio" or "blank audio")
|
if (transcript is "blank_audio" or "blank audio")
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -924,6 +934,13 @@ public sealed partial class WebSocketTurnFinalizationService(
|
|||||||
.Any(IsYesNoRule);
|
.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)
|
private static bool ShouldHandleAsLocalNoInput(TurnContext turn)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(turn.NormalizedTranscript) || !string.IsNullOrWhiteSpace(turn.RawTranscript))
|
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);
|
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)
|
private static void UpdatePendingProactivityOffer(CloudSession session, string? intentName)
|
||||||
{
|
{
|
||||||
if (string.Equals(intentName, "proactive_offer_pizza_fact", StringComparison.OrdinalIgnoreCase))
|
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)
|
private static string NormalizeTranscript(string? transcript)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(transcript))
|
if (string.IsNullOrWhiteSpace(transcript))
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ namespace Jibo.Cloud.Tests.WebSockets;
|
|||||||
|
|
||||||
public sealed class JiboInteractionServiceTests
|
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]
|
[Fact]
|
||||||
public async Task BuildDecisionAsync_Joke_UsesCatalogBackedRandomContent()
|
public async Task BuildDecisionAsync_Joke_UsesCatalogBackedRandomContent()
|
||||||
{
|
{
|
||||||
@@ -641,6 +650,138 @@ public sealed class JiboInteractionServiceTests
|
|||||||
Assert.Equal("RA_JBO_OrderPizza", decision.SkillPayload!["mim_id"]);
|
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]
|
[Fact]
|
||||||
public async Task BuildDecisionAsync_WeatherQuery_WithoutProvider_UsesSpokenFallback()
|
public async Task BuildDecisionAsync_WeatherQuery_WithoutProvider_UsesSpokenFallback()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3174,6 +3174,73 @@ public sealed class JiboWebSocketServiceTests
|
|||||||
Assert.False(session.Metadata.ContainsKey("pendingProactivityOffer"));
|
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]
|
[Fact]
|
||||||
public async Task FollowUpTurn_UsesNewTurnStateWithoutLeakingBufferedAudio()
|
public async Task FollowUpTurn_UsesNewTurnStateWithoutLeakingBufferedAudio()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user