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"
|
||||
}
|
||||
: 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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user