Fix personal report yes/no and weather hi/low handling

This commit is contained in:
Jacob Dubin
2026-05-17 20:20:48 -05:00
parent f2826253d5
commit af76cbaee2
5 changed files with 87 additions and 28 deletions

View File

@@ -942,13 +942,6 @@ public sealed class JiboInteractionService(
var name = personalMemoryStore.GetName(personScope); var name = personalMemoryStore.GetName(personScope);
if (string.IsNullOrWhiteSpace(name)) name = personalMemoryStore.GetName(ResolveTenantScope(turn)); if (string.IsNullOrWhiteSpace(name)) name = personalMemoryStore.GetName(ResolveTenantScope(turn));
if (string.IsNullOrWhiteSpace(name) &&
presence is not null &&
!string.IsNullOrWhiteSpace(presence.PrimaryPersonId) &&
presence.LoopUserFirstNames.TryGetValue(presence.PrimaryPersonId, out var firstName) &&
!string.IsNullOrWhiteSpace(firstName))
name = ToDisplayName(firstName);
name = ToDisplayName(name ?? string.Empty); name = ToDisplayName(name ?? string.Empty);
return string.IsNullOrWhiteSpace(name) return string.IsNullOrWhiteSpace(name)

View File

@@ -103,6 +103,7 @@ internal static class PersonalReportOrchestrator
return new JiboInteractionDecision( return new JiboInteractionDecision(
"personal_report_opt_in", "personal_report_opt_in",
reply, reply,
SkillPayload: BuildYesNoPromptPayload(),
ContextUpdates: contextUpdates); ContextUpdates: contextUpdates);
} }
@@ -119,6 +120,7 @@ internal static class PersonalReportOrchestrator
return new JiboInteractionDecision( return new JiboInteractionDecision(
"personal_report_verify_user", "personal_report_verify_user",
$"I think this is {knownName}. Is that right?", $"I think this is {knownName}. Is that right?",
SkillPayload: BuildYesNoPromptPayload(),
ContextUpdates: BuildContextUpdates( ContextUpdates: BuildContextUpdates(
AwaitingIdentityConfirmationState, AwaitingIdentityConfirmationState,
0, 0,
@@ -147,6 +149,7 @@ internal static class PersonalReportOrchestrator
return new JiboInteractionDecision( return new JiboInteractionDecision(
"personal_report_opt_in", "personal_report_opt_in",
$"{inlineToggleSummary} Would you like your personal report now?", $"{inlineToggleSummary} Would you like your personal report now?",
SkillPayload: BuildYesNoPromptPayload(),
ContextUpdates: BuildContextUpdates( ContextUpdates: BuildContextUpdates(
AwaitingOptInState, AwaitingOptInState,
0, 0,
@@ -425,6 +428,14 @@ internal static class PersonalReportOrchestrator
}; };
} }
private static IDictionary<string, object?> BuildYesNoPromptPayload()
{
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["listen_contexts"] = new[] { "shared/yes_no" }
};
}
private static bool IsAffirmativeReply(string loweredTranscript) private static bool IsAffirmativeReply(string loweredTranscript)
{ {
return ContainsAnyPhrase(loweredTranscript, AffirmativePhrases); return ContainsAnyPhrase(loweredTranscript, AffirmativePhrases);

View File

@@ -163,13 +163,11 @@ public sealed class OpenWeatherReportProvider(
?? TryReadInt(selectedDayTemp, "night") ?? TryReadInt(selectedDayTemp, "night")
?? TryReadInt(selectedDayTemp, "morn") ?? TryReadInt(selectedDayTemp, "morn")
?? TryReadInt(selectedDayTemp, "eve"); ?? TryReadInt(selectedDayTemp, "eve");
var high = TryReadInt(selectedDayTemp, "max"); var currentDayHighLow = forecastDayOffset <= 0
var low = TryReadInt(selectedDayTemp, "min"); ? await TryResolveCurrentDayHighLowFromForecastAsync(location, useCelsius, cancellationToken)
if (temperature is not null) : null;
{ var high = currentDayHighLow?.High ?? TryReadInt(selectedDayTemp, "max");
high = high is null ? temperature : Math.Max(high.Value, temperature.Value); var low = currentDayHighLow?.Low ?? TryReadInt(selectedDayTemp, "min");
low = low is null ? temperature : Math.Min(low.Value, temperature.Value);
}
if (temperature is null && high is null && low is null) return null; if (temperature is null && high is null && low is null) return null;
@@ -220,13 +218,9 @@ public sealed class OpenWeatherReportProvider(
var summary = TryReadWeatherSummary(root); var summary = TryReadWeatherSummary(root);
var condition = TryReadWeatherCondition(root); var condition = TryReadWeatherCondition(root);
var temperature = TryReadInt(main, "temp"); var temperature = TryReadInt(main, "temp");
var high = TryReadInt(main, "temp_max"); var currentDayHighLow = await TryResolveCurrentDayHighLowFromForecastAsync(location, useCelsius, cancellationToken);
var low = TryReadInt(main, "temp_min"); var high = currentDayHighLow?.High ?? TryReadInt(main, "temp_max");
if (temperature is not null) var low = currentDayHighLow?.Low ?? TryReadInt(main, "temp_min");
{
high = high is null ? temperature : Math.Max(high.Value, temperature.Value);
low = low is null ? temperature : Math.Min(low.Value, temperature.Value);
}
if (temperature is null && high is null && low is null) return null; if (temperature is null && high is null && low is null) return null;

View File

@@ -58,6 +58,9 @@ public sealed class ProviderCachingTests
[Fact] [Fact]
public async Task OpenWeatherReportProvider_UsesCurrentHiLoForCurrentDay_WhenCurrentBandDiffers() public async Task OpenWeatherReportProvider_UsesCurrentHiLoForCurrentDay_WhenCurrentBandDiffers()
{ {
var offset = TimeSpan.FromHours(-5);
var localNow = DateTimeOffset.UtcNow.ToOffset(offset);
var todayNoon = new DateTimeOffset(localNow.Date.AddHours(12), offset);
var handler = new CountingHttpMessageHandler(message => var handler = new CountingHttpMessageHandler(message =>
{ {
var path = message.RequestUri?.AbsolutePath ?? string.Empty; var path = message.RequestUri?.AbsolutePath ?? string.Empty;
@@ -71,12 +74,18 @@ public sealed class ProviderCachingTests
"lat":38.8708, "lat":38.8708,
"lon":-94.1733, "lon":-94.1733,
"timezone":"America/Chicago", "timezone":"America/Chicago",
"current":{"dt":1710000000,"temp":76.0,"weather":[{"main":"Clouds","description":"overcast clouds"}]}, "current":{"dt":1710000000,"temp":83.0,"weather":[{"main":"Clouds","description":"overcast clouds"}]},
"daily":[ "daily":[
{"dt":1710000000,"temp":{"day":76.0,"min":78.0,"max":82.0},"weather":[{"main":"Clouds","description":"overcast clouds"}]} {"dt":1710000000,"temp":{"day":83.0,"min":82.0,"max":83.0},"weather":[{"main":"Clouds","description":"overcast clouds"}]}
] ]
} }
"""), """),
"/data/2.5/forecast" => JsonResponse(
BuildForecastResponseJson(
"Lone Jack",
"US",
-18000,
[(todayNoon, 82, 82, 78, "Clouds", "overcast clouds")])),
_ => new HttpResponseMessage(HttpStatusCode.NotFound) _ => new HttpResponseMessage(HttpStatusCode.NotFound)
}; };
}); });
@@ -96,10 +105,11 @@ public sealed class ProviderCachingTests
await provider.GetReportAsync(new WeatherReportRequest("Lone Jack,US", null, null, false, false, 0)); await provider.GetReportAsync(new WeatherReportRequest("Lone Jack,US", null, null, false, false, 0));
Assert.NotNull(report); Assert.NotNull(report);
Assert.Equal(76, report!.Temperature); Assert.Equal(83, report!.Temperature);
Assert.Equal(82, report.HighTemperature); Assert.Equal(82, report.HighTemperature);
Assert.Equal(76, report.LowTemperature); Assert.Equal(78, report.LowTemperature);
Assert.Equal(1, handler.GetCallCount("/data/3.0/onecall")); Assert.Equal(1, handler.GetCallCount("/data/3.0/onecall"));
Assert.Equal(1, handler.GetCallCount("/data/2.5/forecast"));
} }
[Fact] [Fact]
@@ -153,6 +163,9 @@ public sealed class ProviderCachingTests
[Fact] [Fact]
public async Task OpenWeatherReportProvider_FallsBackToLegacyWeatherWhenOneCallIsUnauthorized() public async Task OpenWeatherReportProvider_FallsBackToLegacyWeatherWhenOneCallIsUnauthorized()
{ {
var offset = TimeSpan.FromHours(-5);
var localNow = DateTimeOffset.UtcNow.ToOffset(offset);
var todayNoon = new DateTimeOffset(localNow.Date.AddHours(12), offset);
var handler = new CountingHttpMessageHandler(message => var handler = new CountingHttpMessageHandler(message =>
{ {
var path = message.RequestUri?.AbsolutePath ?? string.Empty; var path = message.RequestUri?.AbsolutePath ?? string.Empty;
@@ -168,7 +181,13 @@ public sealed class ProviderCachingTests
"application/json") "application/json")
}, },
"/data/2.5/weather" => JsonResponse( "/data/2.5/weather" => JsonResponse(
"""{"name":"Boston","weather":[{"main":"Clouds","description":"overcast clouds"}],"main":{"temp":70.0,"temp_max":72.0,"temp_min":66.0}}"""), """{"name":"Boston","weather":[{"main":"Clouds","description":"overcast clouds"}],"main":{"temp":70.0,"temp_max":83.0,"temp_min":82.0}}"""),
"/data/2.5/forecast" => JsonResponse(
BuildForecastResponseJson(
"Boston",
"US",
-18000,
[(todayNoon, 70, 72, 66, "Clouds", "overcast clouds")])),
_ => new HttpResponseMessage(HttpStatusCode.NotFound) _ => new HttpResponseMessage(HttpStatusCode.NotFound)
}; };
}); });
@@ -192,6 +211,7 @@ public sealed class ProviderCachingTests
Assert.Equal(66, report.LowTemperature); Assert.Equal(66, report.LowTemperature);
Assert.Equal(1, handler.GetCallCount("/data/3.0/onecall")); Assert.Equal(1, handler.GetCallCount("/data/3.0/onecall"));
Assert.Equal(1, handler.GetCallCount("/data/2.5/weather")); Assert.Equal(1, handler.GetCallCount("/data/2.5/weather"));
Assert.Equal(1, handler.GetCallCount("/data/2.5/forecast"));
} }
[Fact] [Fact]
@@ -395,6 +415,20 @@ public sealed class ProviderCachingTests
}; };
} }
private static string BuildForecastResponseJson(
string cityName,
string country,
int timezoneSeconds,
IReadOnlyList<(DateTimeOffset Timestamp, int Temp, int High, int Low, string Main, string Description)> entries)
{
var list = string.Join(
",",
entries.Select(entry =>
$$"""{"dt":{{entry.Timestamp.ToUnixTimeSeconds()}},"main":{"temp":{{entry.Temp}},"temp_min":{{entry.Low}},"temp_max":{{entry.High}}},"weather":[{"main":"{{entry.Main}}","description":"{{entry.Description}}"}]}"""));
return $$"""{"city":{"name":"{{cityName}}","country":"{{country}}","timezone":{{timezoneSeconds}}},"list":[{{list}}]}""";
}
private sealed class CountingHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory) private sealed class CountingHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
: HttpMessageHandler : HttpMessageHandler
{ {

View File

@@ -282,6 +282,31 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("I do not know your name yet. You can say, my name is Alex.", decision.ReplyText); Assert.Equal("I do not know your name yet. You can say, my name is Alex.", decision.ReplyText);
} }
[Fact]
public async Task BuildDecisionAsync_IdentityFollowUp_DoesNotGuessFromLoopFirstNameWhenMemoryIsMissing()
{
var service = CreateService();
var decision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "who am i",
NormalizedTranscript = "who am i",
Attributes = new Dictionary<string, object?>
{
["accountId"] = "acct-d",
["loopId"] = "loop-d",
["context"] =
"""
{"runtime":{"perception":{"speaker":"person-9"},"loop":{"users":[{"id":"person-9","firstName":"hi"}]}}}
"""
},
DeviceId = "device-d"
});
Assert.Equal("memory_get_name", decision.IntentName);
Assert.Equal("I do not know your name yet. You can say, my name is Alex.", decision.ReplyText);
}
[Fact] [Fact]
public async Task BuildDecisionAsync_TriggerWithKnownIdentity_BuildsProactiveGreetingAndContext() public async Task BuildDecisionAsync_TriggerWithKnownIdentity_BuildsProactiveGreetingAndContext()
{ {
@@ -1712,6 +1737,7 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("personal_report_opt_in", decision.IntentName); Assert.Equal("personal_report_opt_in", decision.IntentName);
Assert.Equal("Would you like your personal report now?", decision.ReplyText); Assert.Equal("Would you like your personal report now?", decision.ReplyText);
Assert.Equal("shared/yes_no", ((IReadOnlyList<string>)decision.SkillPayload!["listen_contexts"])[0]);
Assert.NotNull(decision.ContextUpdates); Assert.NotNull(decision.ContextUpdates);
Assert.Equal("awaiting_opt_in", decision.ContextUpdates![PersonalReportStateKey]); Assert.Equal("awaiting_opt_in", decision.ContextUpdates![PersonalReportStateKey]);
Assert.Equal(true, decision.ContextUpdates[PersonalReportWeatherEnabledKey]); Assert.Equal(true, decision.ContextUpdates[PersonalReportWeatherEnabledKey]);
@@ -1742,6 +1768,7 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("personal_report_verify_user", decision.IntentName); Assert.Equal("personal_report_verify_user", decision.IntentName);
Assert.Equal("I think this is alex. Is that right?", decision.ReplyText); Assert.Equal("I think this is alex. Is that right?", decision.ReplyText);
Assert.Equal("shared/yes_no", ((IReadOnlyList<string>)decision.SkillPayload!["listen_contexts"])[0]);
Assert.NotNull(decision.ContextUpdates); Assert.NotNull(decision.ContextUpdates);
Assert.Equal("awaiting_identity_confirmation", decision.ContextUpdates![PersonalReportStateKey]); Assert.Equal("awaiting_identity_confirmation", decision.ContextUpdates![PersonalReportStateKey]);
Assert.Equal("alex", decision.ContextUpdates[PersonalReportUserNameKey]); Assert.Equal("alex", decision.ContextUpdates[PersonalReportUserNameKey]);