Add OpenWeather-backed weather reports

This commit is contained in:
Jacob Dubin
2026-05-06 08:13:26 -05:00
parent 699e0d5282
commit b74ef3bfa2
15 changed files with 2072 additions and 55 deletions

View File

@@ -610,9 +610,38 @@ Current release theme:
- preference set/recall works (`my favorite X is Y` / `what is my favorite X`)
- account/loop/device scoped lookup prevents cross-tenant leakage
- Follow-up:
- extend phrase parsing beyond first rule-based patterns
- add durable persistence path for personal facts
- broaden fact categories (names, important dates, household preferences)
- broaden fact categories further (multi-person household memory, relationship cues, and corrective updates)
### 24. Memory-Triggered Proactivity Baseline
- Status: `implemented`
- Tags: `content`, `storage`, `protocol`
- Result:
- `surprise me` now uses weighted candidate selection instead of only generic fallback text
- candidate weighting uses tenant-scoped memory signals and date triggers
- February 9 (`National Pizza Day`) can proactively launch the legacy pizza animation path
- proactive pizza fact offer flow stores pending offer state in session metadata and resolves direct short `yes/no` turns
- memory parsing now includes names, anniversary-style important dates, likes/dislikes variants, and reverse favorite phrasing (`pizza is my favorite food`)
- Follow-up:
- expand proactivity beyond pizza to additional Pegasus-backed categories
- add cooldown/throttle policy and observability around proactive offer frequency
- connect memory store to durable multi-tenant persistence
### 25. Weather Report-Skill Launch Compatibility
- Status: `implemented`
- Tags: `protocol`, `content`
- Result:
- weather requests now launch `report-skill` using Pegasus-aligned intent `requestWeatherPR`
- weather phrase coverage includes baseline forecast and condition-style questions (`will it rain`, `is it snowing`, tomorrow variants)
- weather launches emit `SKILL_REDIRECT` + completion and now also include cloud weather speech so weather turns remain useful even when local report providers are incomplete
- weather entity hints are carried in outbound NLU (`date = tomorrow`, `Weather = rain/snow/...`) for report-skill consumption
- OpenWeather provider integration is in place with configurable API key, default location, unit preference, and environment-variable fallback (`OPENWEATHER_API_KEY`)
- cloud weather speech now uses live provider summaries for current conditions and tomorrow high/low forecast when available
- Follow-up:
- connect weather units and location directly to user/report-skill settings parity instead of config defaults
- add richer condition-change commentary and view parity with original report-skill weather behaviors
## Suggested Order
@@ -628,17 +657,19 @@ Use [regression-test-plan.md](regression-test-plan.md) as the detailed checklist
For `1.0.19`:
1. Command-vs-question personality split (`dance` command vs `do you like to dance` question style; expand this pattern)
2. Expand memory-backed personal facts with tenant-scoped storage (beyond the first birthday/preferences foundation)
3. Proactivity selector baseline with source-backed first offers
4. Dialog parsing expansion and ambiguity guardrails
5. Holidays and seasonal personality behavior built on the new memory/proactivity foundation
6. Update, backup, and restore proof
7. STT upgrade and noise screening
8. Hosted capture/storage plan / indexing for group testing
9. Binary-safe media storage / sync to cloud drive: OneDrive, Google Drive, Box, etc.
10. Provider-backed news and weather
11. Lasso, identity, and onboarding as larger discovery-driven tracks
1. Command-vs-question personality split (`dance` command vs `do you like to dance` question style; expand this pattern) - implemented
2. Expand memory-backed personal facts with tenant-scoped storage (beyond the first birthday/preferences foundation) - implemented
3. Proactivity selector baseline with source-backed first offers - implemented
4. Weather report-skill launch compatibility - implemented
5. Dialog parsing expansion and ambiguity guardrails
6. Holidays and seasonal personality behavior built on the new memory/proactivity foundation
7. Durable memory persistence path (multi-tenant backing store)
8. Update, backup, and restore proof
9. STT upgrade and noise screening
10. Hosted capture/storage plan / indexing for group testing
11. Binary-safe media storage / sync to cloud drive: OneDrive, Google Drive, Box, etc.
12. Provider-backed news and weather parity polish
13. Lasso, identity, and onboarding as larger discovery-driven tracks
For `1.0.20` and beyond:

View File

@@ -68,17 +68,52 @@ The second delivered slice is first tenant-scoped personal memory:
Memory keys are scoped by account/loop/device tenant context so one tenant does not leak into another.
## Third Implemented Slice In `1.0.19`
The third delivered slice starts memory-triggered proactivity and broadens memory parsing:
- `surprise me` now runs a weighted proactivity selector
- selectors use tenant-scoped memory signals (favorites and likes/dislikes) plus date triggers
- February 9 (`National Pizza Day`) can proactively launch the pizza animation path
- proactive pizza fact offer flow now stores pending offer state and resolves direct `yes` / `no` follow-up answers
- memory parsing now covers:
- names (`my name is ...`, `what is my name`)
- important dates (`our anniversary is ...`, `when is our anniversary`)
- likes/dislikes (`i like ...`, `i love ...`, `i dislike ...`, `i don't like ...`)
- favorite phrase variants including reverse form (`pizza is my favorite food`)
## Fourth Implemented Slice In `1.0.19`
The fourth delivered slice starts weather compatibility using Pegasus-style report-skill routing:
- weather phrases now route to `report-skill` instead of generic placeholder chat
- outbound NLU launch uses legacy reactive intent `requestWeatherPR` (source-aligned with Pegasus manifests/tests)
- weather entity hints are added for:
- `date = tomorrow` on tomorrow phrasing
- `Weather = rain|snow|...` on condition questions (for example `will it rain tomorrow`)
- websocket output now emits local skill redirect + silent completion for weather launch, matching existing local-skill launch patterns
## Fifth Implemented Slice In `1.0.19`
The fifth delivered slice adds provider-backed weather content while preserving Pegasus launch compatibility:
- OpenWeather provider abstraction and infrastructure wiring are added to the hosted cloud
- weather requests still launch `report-skill` with `requestWeatherPR` and legacy weather/date entities
- weather replies now include cloud-generated spoken summaries from provider data:
- current conditions (`Right now in ...`)
- tomorrow forecast shape (`Tomorrow in ...`) with high/low temperatures when available
- simple location extraction is supported for phrasing like `what's the weather in Chicago tomorrow`
- provider config supports appsettings and `OPENWEATHER_API_KEY` environment fallback for deployment
## Next Slices
1. Command-vs-question personality split (start with dance/twerk-style prompts, keep commands action-oriented and questions conversational)
2. Expand memory-backed personal facts (tenant-scoped birthday/preferences coverage, persistence depth, and parsing breadth)
3. Proactivity selector baseline (source-backed first proactive offers with safe throttling and stock-compatible payloads)
4. Dialog parsing expansion (more phrase variants, ambiguity handling, and transcript-to-intent guardrails)
5. Holidays and seasonal personality slice (time-scoped content backed by the new memory/proactivity path)
6. Update/backup/restore end-to-end proof (operator-run and documented)
7. STT noise-screening and short-utterance reliability pass
8. Provider-backed news/weather expansion using Pegasus-backed contracts
9. Capture indexing and retention boundary for group testing
1. Dialog parsing expansion (more phrase variants, ambiguity handling, and transcript-to-intent guardrails)
2. Holidays and seasonal personality slice beyond pizza day (time-scoped content backed by memory/proactivity path)
3. Durable memory persistence path (swap in provider-backed multi-tenant storage while preserving behavior contracts)
4. Update/backup/restore end-to-end proof (operator-run and documented)
5. STT noise-screening and short-utterance reliability pass
6. Provider-backed news expansion and deeper weather parity using Pegasus-backed contracts
7. Capture indexing and retention boundary for group testing
For slices 1-5, use Pegasus phrase lists, MIM IDs, and behavior patterns as the source anchor before broadening into OpenJibo-native improvements.

View File

@@ -21,6 +21,14 @@
"WhisperLanguage": "en",
"TempDirectory": "/tmp/openjibo-stt",
"CleanupTempFiles": false
},
"Weather": {
"OpenWeather": {
"BaseUrl": "https://api.openweathermap.org",
"ApiKey": "723667c9ab0318142227c5389900d087",
"DefaultLocation": "Boston,US",
"UseCelsius": false
}
}
}
}

View File

@@ -6,6 +6,20 @@ public interface IPersonalMemoryStore
string? GetBirthday(PersonalMemoryTenantScope tenantScope);
void SetPreference(PersonalMemoryTenantScope tenantScope, string category, string value);
string? GetPreference(PersonalMemoryTenantScope tenantScope, string category);
void SetName(PersonalMemoryTenantScope tenantScope, string name);
string? GetName(PersonalMemoryTenantScope tenantScope);
void SetImportantDate(PersonalMemoryTenantScope tenantScope, string label, string value);
string? GetImportantDate(PersonalMemoryTenantScope tenantScope, string label);
void SetAffinity(PersonalMemoryTenantScope tenantScope, string item, PersonalAffinity affinity);
PersonalAffinity? GetAffinity(PersonalMemoryTenantScope tenantScope, string item);
IReadOnlyDictionary<string, PersonalAffinity> GetAffinities(PersonalMemoryTenantScope tenantScope);
}
public sealed record PersonalMemoryTenantScope(string AccountId, string LoopId, string DeviceId);
public enum PersonalAffinity
{
Like,
Love,
Dislike
}

View File

@@ -0,0 +1,24 @@
namespace Jibo.Cloud.Application.Abstractions;
public interface IWeatherReportProvider
{
Task<WeatherReportSnapshot?> GetReportAsync(
WeatherReportRequest request,
CancellationToken cancellationToken = default);
}
public sealed record WeatherReportRequest(
string? LocationQuery,
double? Latitude,
double? Longitude,
bool IsTomorrow,
bool? UseCelsius);
public sealed record WeatherReportSnapshot(
string LocationName,
string Summary,
int Temperature,
int? HighTemperature,
int? LowTemperature,
string? Condition,
bool UseCelsius);

View File

@@ -50,6 +50,13 @@ public sealed class ProtocolToTurnContextMapper
attributes["lastClockDomain"] = lastClockDomainText;
}
if (session.Metadata.TryGetValue("pendingProactivityOffer", out var pendingProactivityOffer) &&
pendingProactivityOffer is string pendingProactivityOfferText &&
!string.IsNullOrWhiteSpace(pendingProactivityOfferText))
{
attributes["pendingProactivityOffer"] = pendingProactivityOfferText;
}
attributes["listenHotphrase"] = turnState.ListenHotphrase;
if (turnState.ListenRules.Count > 0)

View File

@@ -37,6 +37,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
var isPhotoCreateLaunch = string.Equals(plan.IntentName, "snapshot", StringComparison.OrdinalIgnoreCase) ||
string.Equals(plan.IntentName, "photobooth", StringComparison.OrdinalIgnoreCase);
var isClockSkillLaunch = string.Equals(skill?.SkillName, "@be/clock", StringComparison.OrdinalIgnoreCase);
var isReportSkillLaunch = string.Equals(skill?.SkillName, "report-skill", StringComparison.OrdinalIgnoreCase);
var localIntent = ReadSkillPayloadString(skill, "localIntent");
var clockIntent = ReadSkillPayloadString(skill, "clockIntent");
var clockDomain = ReadSkillPayloadString(skill, "domain");
@@ -50,6 +51,8 @@ public sealed class ResponsePlanToSocketMessagesMapper
var globalIntent = ReadSkillPayloadString(skill, "globalIntent");
var nluDomain = ReadSkillPayloadString(skill, "nluDomain");
var volumeLevel = ReadSkillPayloadString(skill, "volumeLevel");
var reportDate = ReadSkillPayloadString(skill, "date");
var reportWeatherCondition = ReadSkillPayloadString(skill, "weatherCondition");
var nluGuess = ReadClientEntity(turn, "guess");
var wordOfDayGuess = ResolveWordOfDayGuess(turn, transcript, nluGuess);
var outboundIntent = isGlobalCommand && !string.IsNullOrWhiteSpace(globalIntent)
@@ -64,6 +67,8 @@ public sealed class ResponsePlanToSocketMessagesMapper
? localIntent
: isClockSkillLaunch && !string.IsNullOrWhiteSpace(clockIntent)
? clockIntent
: isReportSkillLaunch && !string.IsNullOrWhiteSpace(localIntent)
? localIntent
: isWordOfDayGuess
? "guess"
: string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase) &&
@@ -112,6 +117,8 @@ public sealed class ResponsePlanToSocketMessagesMapper
? string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase)
? rules
: []
: isReportSkillLaunch
? []
: isWordOfDayGuess
? ["word-of-the-day/puzzle"]
: isYesNoTurn && isYesNoIntent
@@ -136,7 +143,10 @@ public sealed class ResponsePlanToSocketMessagesMapper
timerMinutes,
timerSeconds,
alarmTime,
alarmAmPm);
alarmAmPm,
isReportSkillLaunch,
reportDate,
reportWeatherCondition);
var listenMessage = new
{
type = "LISTEN",
@@ -159,6 +169,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
isPhotoGalleryLaunch ? "@be/gallery" :
isPhotoCreateLaunch ? "@be/create" :
isClockSkillLaunch ? "@be/clock" :
isReportSkillLaunch ? "report-skill" :
null,
isGlobalCommand ? nluDomain ?? "global_commands" : null),
match = new
@@ -286,6 +297,22 @@ public sealed class ResponsePlanToSocketMessagesMapper
DelayMs: 125));
}
if (isReportSkillLaunch)
{
messages.Add(new SocketReplyPlan(
JsonSerializer.Serialize(BuildSkillRedirectPayload(
transId,
"report-skill",
outboundIntent,
outboundAsrText,
outboundRules,
entities)),
DelayMs: 75));
messages.Add(new SocketReplyPlan(
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "report-skill")),
DelayMs: 125));
}
if (emitSkillActions && speak is not null)
{
messages.Add(new SocketReplyPlan(
@@ -444,7 +471,10 @@ public sealed class ResponsePlanToSocketMessagesMapper
string? timerMinutes,
string? timerSeconds,
string? alarmTime,
string? alarmAmPm)
string? alarmAmPm,
bool reportSkillLaunch,
string? reportDate,
string? reportWeatherCondition)
{
if (yesNoTurn)
{
@@ -514,6 +544,22 @@ public sealed class ResponsePlanToSocketMessagesMapper
return entities;
}
if (reportSkillLaunch)
{
var entities = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(reportDate))
{
entities["date"] = reportDate;
}
if (!string.IsNullOrWhiteSpace(reportWeatherCondition))
{
entities["Weather"] = reportWeatherCondition;
}
return entities;
}
if (wordOfDayGuess)
{
return new Dictionary<string, object?>

View File

@@ -538,6 +538,9 @@ public sealed partial class WebSocketTurnFinalizationService(
{
session.Metadata["lastClockDomain"] = lastClockDomainValue.ToString();
}
UpdatePendingProactivityOffer(session, plan.IntentName);
session.FollowUpExpiresUtc = plan.FollowUp.KeepMicOpen
? DateTimeOffset.UtcNow.Add(plan.FollowUp.Timeout)
: null;
@@ -567,13 +570,13 @@ public sealed partial class WebSocketTurnFinalizationService(
!string.Equals(plan.IntentName, "alarm_cancel", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "timer_clarify", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "alarm_clarify", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "timer_value", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "alarm_value", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "photo_gallery", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "snapshot", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "photobooth", StringComparison.OrdinalIgnoreCase) &&
(messageType != "CLIENT_NLU" ||
string.Equals(plan.IntentName, "word_of_the_day_guess", StringComparison.OrdinalIgnoreCase));
!string.Equals(plan.IntentName, "timer_value", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "alarm_value", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "photo_gallery", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "snapshot", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "photobooth", StringComparison.OrdinalIgnoreCase) &&
(messageType != "CLIENT_NLU" ||
string.Equals(plan.IntentName, "word_of_the_day_guess", StringComparison.OrdinalIgnoreCase));
var replies = ResponsePlanToSocketMessagesMapper.Map(plan, finalizedTurn, session, emitSkillActions).Select(map => new WebSocketReply
{
Text = map.Text,
@@ -812,6 +815,7 @@ public sealed partial class WebSocketTurnFinalizationService(
{
var messageType = ReadMessageType(turn);
var clientIntent = ReadAttribute(turn, "clientIntent");
var pendingProactivityOffer = ReadAttribute(turn, "pendingProactivityOffer");
var transcript = NormalizeTranscript(turn.NormalizedTranscript ?? turn.RawTranscript);
var listenRules = ReadRules(turn, "listenRules").Concat(ReadRules(turn, "clientRules")).ToArray();
@@ -846,6 +850,12 @@ public sealed partial class WebSocketTurnFinalizationService(
return true;
}
if (!string.IsNullOrWhiteSpace(pendingProactivityOffer) &&
transcript is "yes" or "no" or "sure" or "nope" or "yup" or "uh huh" or "yeah" or "nah")
{
return true;
}
if (listenRules.Any(rule => string.Equals(rule, "word-of-the-day/puzzle", StringComparison.OrdinalIgnoreCase)))
{
return true;
@@ -960,6 +970,17 @@ public sealed partial class WebSocketTurnFinalizationService(
string.Equals(rule, "surprises-ota/want_to_download_now", StringComparison.OrdinalIgnoreCase);
}
private static void UpdatePendingProactivityOffer(CloudSession session, string? intentName)
{
if (string.Equals(intentName, "proactive_offer_pizza_fact", StringComparison.OrdinalIgnoreCase))
{
session.Metadata["pendingProactivityOffer"] = "pizza_fact";
return;
}
session.Metadata.Remove("pendingProactivityOffer");
}
private static IEnumerable<string> ReadRules(TurnContext turn, string key)
{
if (!turn.Attributes.TryGetValue(key, out var value) || value is null)

View File

@@ -4,6 +4,7 @@ using Jibo.Cloud.Infrastructure.Audio;
using Jibo.Cloud.Infrastructure.Content;
using Jibo.Cloud.Infrastructure.Persistence;
using Jibo.Cloud.Infrastructure.Telemetry;
using Jibo.Cloud.Infrastructure.Weather;
using Jibo.Runtime.Abstractions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
@@ -23,7 +24,20 @@ public static class ServiceCollectionExtensions
configuration.GetSection("OpenJibo:Stt").Bind(sttOptions);
}
var openWeatherOptions = new OpenWeatherOptions();
if (configuration is not null)
{
configuration.GetSection("OpenJibo:Weather:OpenWeather").Bind(openWeatherOptions);
}
if (string.IsNullOrWhiteSpace(openWeatherOptions.ApiKey))
{
openWeatherOptions.ApiKey = Environment.GetEnvironmentVariable("OPENWEATHER_API_KEY");
}
services.AddSingleton(sttOptions);
services.AddSingleton(openWeatherOptions);
services.AddHttpClient<IWeatherReportProvider, OpenWeatherReportProvider>();
var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"]
?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json");
services.AddSingleton<ICloudStateStore>(_ => new InMemoryCloudStateStore(statePersistencePath));

View File

@@ -36,6 +36,62 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
: null;
}
public void SetName(PersonalMemoryTenantScope tenantScope, string name)
{
var key = BuildTenantKey(tenantScope);
var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord());
record.Name = name;
}
public string? GetName(PersonalMemoryTenantScope tenantScope)
{
var key = BuildTenantKey(tenantScope);
return _tenantMemory.TryGetValue(key, out var record) ? record.Name : null;
}
public void SetImportantDate(PersonalMemoryTenantScope tenantScope, string label, string value)
{
var key = BuildTenantKey(tenantScope);
var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord());
record.ImportantDates[NormalizeCategory(label)] = value;
}
public string? GetImportantDate(PersonalMemoryTenantScope tenantScope, string label)
{
var key = BuildTenantKey(tenantScope);
return _tenantMemory.TryGetValue(key, out var record) &&
record.ImportantDates.TryGetValue(NormalizeCategory(label), out var value)
? value
: null;
}
public void SetAffinity(PersonalMemoryTenantScope tenantScope, string item, PersonalAffinity affinity)
{
var key = BuildTenantKey(tenantScope);
var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord());
record.Affinities[NormalizeCategory(item)] = affinity;
}
public PersonalAffinity? GetAffinity(PersonalMemoryTenantScope tenantScope, string item)
{
var key = BuildTenantKey(tenantScope);
return _tenantMemory.TryGetValue(key, out var record) &&
record.Affinities.TryGetValue(NormalizeCategory(item), out var affinity)
? affinity
: null;
}
public IReadOnlyDictionary<string, PersonalAffinity> GetAffinities(PersonalMemoryTenantScope tenantScope)
{
var key = BuildTenantKey(tenantScope);
if (!_tenantMemory.TryGetValue(key, out var record))
{
return new Dictionary<string, PersonalAffinity>(StringComparer.OrdinalIgnoreCase);
}
return new Dictionary<string, PersonalAffinity>(record.Affinities, StringComparer.OrdinalIgnoreCase);
}
private static string BuildTenantKey(PersonalMemoryTenantScope tenantScope)
{
return $"{tenantScope.AccountId}|{tenantScope.LoopId}|{tenantScope.DeviceId}";
@@ -49,6 +105,9 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
private sealed class TenantMemoryRecord
{
public string? Birthday { get; set; }
public string? Name { get; set; }
public ConcurrentDictionary<string, string> Preferences { get; } = new(StringComparer.OrdinalIgnoreCase);
public ConcurrentDictionary<string, string> ImportantDates { get; } = new(StringComparer.OrdinalIgnoreCase);
public ConcurrentDictionary<string, PersonalAffinity> Affinities { get; } = new(StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,12 @@
namespace Jibo.Cloud.Infrastructure.Weather;
public sealed class OpenWeatherOptions
{
public string BaseUrl { get; set; } = "https://api.openweathermap.org";
public string? ApiKey { get; set; }
public string DefaultLocation { get; set; } = "Boston,US";
public bool UseCelsius { get; set; }
}

View File

@@ -0,0 +1,364 @@
using System.Globalization;
using System.Text.Json;
using Jibo.Cloud.Application.Abstractions;
using Microsoft.Extensions.Logging;
namespace Jibo.Cloud.Infrastructure.Weather;
public sealed class OpenWeatherReportProvider(
HttpClient httpClient,
OpenWeatherOptions options,
ILogger<OpenWeatherReportProvider> logger)
: IWeatherReportProvider
{
public async Task<WeatherReportSnapshot?> GetReportAsync(
WeatherReportRequest request,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(options.ApiKey))
{
return null;
}
try
{
var location = await ResolveLocationAsync(request, cancellationToken);
if (location is null)
{
return null;
}
var useCelsius = request.UseCelsius ?? options.UseCelsius;
return request.IsTomorrow
? await GetTomorrowForecastAsync(location.Value, useCelsius, cancellationToken)
: await GetCurrentWeatherAsync(location.Value, useCelsius, cancellationToken);
}
catch (Exception exception)
{
logger.LogWarning(exception, "OpenWeather lookup failed.");
return null;
}
}
private async Task<LocationPoint?> ResolveLocationAsync(
WeatherReportRequest request,
CancellationToken cancellationToken)
{
if (request is { Latitude: not null, Longitude: not null })
{
return new LocationPoint(request.Latitude.Value, request.Longitude.Value, null);
}
var query = string.IsNullOrWhiteSpace(request.LocationQuery)
? options.DefaultLocation
: request.LocationQuery.Trim();
if (string.IsNullOrWhiteSpace(query))
{
return null;
}
var geocodeUri = BuildRequestUri(
"/geo/1.0/direct",
("q", query),
("limit", "1"),
("appid", options.ApiKey!));
using var response = await httpClient.GetAsync(geocodeUri, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return null;
}
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
if (document.RootElement.ValueKind != JsonValueKind.Array ||
document.RootElement.GetArrayLength() == 0)
{
return null;
}
var location = document.RootElement[0];
if (!TryReadDouble(location, "lat", out var latitude) ||
!TryReadDouble(location, "lon", out var longitude))
{
return null;
}
var displayName = BuildLocationDisplayName(location);
return new LocationPoint(latitude, longitude, displayName);
}
private async Task<WeatherReportSnapshot?> GetCurrentWeatherAsync(
LocationPoint location,
bool useCelsius,
CancellationToken cancellationToken)
{
var weatherUri = BuildRequestUri(
"/data/2.5/weather",
("lat", location.Latitude.ToString(CultureInfo.InvariantCulture)),
("lon", location.Longitude.ToString(CultureInfo.InvariantCulture)),
("units", useCelsius ? "metric" : "imperial"),
("appid", options.ApiKey!));
using var response = await httpClient.GetAsync(weatherUri, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return null;
}
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
var root = document.RootElement;
if (!root.TryGetProperty("main", out var main))
{
return null;
}
var locationName = ReadNonEmptyString(root, "name") ?? location.DisplayName ?? options.DefaultLocation;
var summary = TryReadWeatherSummary(root);
var condition = TryReadWeatherCondition(root);
var temperature = TryReadInt(main, "temp");
var high = TryReadInt(main, "temp_max");
var low = TryReadInt(main, "temp_min");
if (temperature is null && high is null && low is null)
{
return null;
}
var resolvedTemperature = temperature ?? high ?? low ?? 0;
return new WeatherReportSnapshot(
locationName,
summary ?? "partly cloudy",
resolvedTemperature,
high,
low,
condition,
useCelsius);
}
private async Task<WeatherReportSnapshot?> GetTomorrowForecastAsync(
LocationPoint location,
bool useCelsius,
CancellationToken cancellationToken)
{
var forecastUri = BuildRequestUri(
"/data/2.5/forecast",
("lat", location.Latitude.ToString(CultureInfo.InvariantCulture)),
("lon", location.Longitude.ToString(CultureInfo.InvariantCulture)),
("units", useCelsius ? "metric" : "imperial"),
("appid", options.ApiKey!));
using var response = await httpClient.GetAsync(forecastUri, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return null;
}
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
var root = document.RootElement;
if (!root.TryGetProperty("list", out var list) || list.ValueKind != JsonValueKind.Array)
{
return null;
}
var offset = TryReadForecastOffset(root);
var tomorrow = DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(offset).DateTime.AddDays(1));
var entries = new List<ForecastEntry>();
foreach (var item in list.EnumerateArray())
{
if (!TryReadLong(item, "dt", out var unixSeconds))
{
continue;
}
var localTimestamp = DateTimeOffset.FromUnixTimeSeconds(unixSeconds).ToOffset(offset);
if (DateOnly.FromDateTime(localTimestamp.DateTime) != tomorrow)
{
continue;
}
if (!item.TryGetProperty("main", out var main))
{
continue;
}
entries.Add(new ForecastEntry(
localTimestamp,
TryReadInt(main, "temp"),
TryReadInt(main, "temp_max"),
TryReadInt(main, "temp_min"),
TryReadWeatherSummary(item),
TryReadWeatherCondition(item)));
}
if (entries.Count == 0)
{
return null;
}
var selectedEntry = entries
.OrderBy(entry => Math.Abs((entry.LocalTime.TimeOfDay - TimeSpan.FromHours(12)).TotalMinutes))
.First();
var highs = entries
.Where(entry => entry.HighTemperature is not null)
.Select(entry => entry.HighTemperature!.Value)
.ToArray();
var lows = entries
.Where(entry => entry.LowTemperature is not null)
.Select(entry => entry.LowTemperature!.Value)
.ToArray();
var locationName = ReadForecastLocationName(root) ?? location.DisplayName ?? options.DefaultLocation;
var high = highs.Length > 0 ? highs.Max() : selectedEntry.HighTemperature;
var low = lows.Length > 0 ? lows.Min() : selectedEntry.LowTemperature;
var temperature = selectedEntry.Temperature ?? high ?? low ?? 0;
return new WeatherReportSnapshot(
locationName,
selectedEntry.Summary ?? "partly cloudy",
temperature,
high,
low,
selectedEntry.Condition,
useCelsius);
}
private Uri BuildRequestUri(string path, params (string Key, string Value)[] queryParts)
{
var baseUrl = options.BaseUrl.TrimEnd('/');
var query = string.Join(
"&",
queryParts.Select(part =>
$"{Uri.EscapeDataString(part.Key)}={Uri.EscapeDataString(part.Value)}"));
return new Uri($"{baseUrl}{path}?{query}");
}
private static TimeSpan TryReadForecastOffset(JsonElement root)
{
if (!root.TryGetProperty("city", out var city))
{
return TimeSpan.Zero;
}
var timezoneSeconds = TryReadInt(city, "timezone");
if (timezoneSeconds is null)
{
return TimeSpan.Zero;
}
var seconds = Math.Clamp(timezoneSeconds.Value, -50400, 50400);
return TimeSpan.FromSeconds(seconds);
}
private static string? ReadForecastLocationName(JsonElement root)
{
if (!root.TryGetProperty("city", out var city))
{
return null;
}
var name = ReadNonEmptyString(city, "name");
var country = ReadNonEmptyString(city, "country");
return string.IsNullOrWhiteSpace(country) ? name : $"{name}, {country}";
}
private static string? BuildLocationDisplayName(JsonElement location)
{
var name = ReadNonEmptyString(location, "name");
var state = ReadNonEmptyString(location, "state");
var country = ReadNonEmptyString(location, "country");
if (!string.IsNullOrWhiteSpace(name) &&
!string.IsNullOrWhiteSpace(state) &&
!string.IsNullOrWhiteSpace(country))
{
return $"{name}, {state}, {country}";
}
if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(country))
{
return $"{name}, {country}";
}
return name;
}
private static string? TryReadWeatherSummary(JsonElement root)
{
return TryReadWeatherProperty(root, "description");
}
private static string? TryReadWeatherCondition(JsonElement root)
{
var main = TryReadWeatherProperty(root, "main");
if (string.IsNullOrWhiteSpace(main))
{
return null;
}
var normalized = main.Trim().ToLowerInvariant();
return normalized switch
{
"rain" or "drizzle" or "thunderstorm" => "rain",
"snow" => "snow",
"clear" => "sunny",
"clouds" => "cloudy",
"mist" or "smoke" or "haze" or "fog" => "fog",
_ => normalized
};
}
private static string? TryReadWeatherProperty(JsonElement root, string key)
{
if (!root.TryGetProperty("weather", out var weather) ||
weather.ValueKind != JsonValueKind.Array ||
weather.GetArrayLength() == 0)
{
return null;
}
var first = weather[0];
return ReadNonEmptyString(first, key);
}
private static string? ReadNonEmptyString(JsonElement source, string key)
{
return source.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String
? value.GetString()
: null;
}
private static bool TryReadDouble(JsonElement source, string key, out double value)
{
value = 0;
return source.TryGetProperty(key, out var element) && element.TryGetDouble(out value);
}
private static bool TryReadLong(JsonElement source, string key, out long value)
{
value = 0;
return source.TryGetProperty(key, out var element) && element.TryGetInt64(out value);
}
private static int? TryReadInt(JsonElement source, string key)
{
if (!source.TryGetProperty(key, out var element))
{
return null;
}
if (element.ValueKind == JsonValueKind.Number && element.TryGetDouble(out var numeric))
{
return (int)Math.Round(numeric, MidpointRounding.AwayFromZero);
}
return null;
}
private readonly record struct LocationPoint(double Latitude, double Longitude, string? DisplayName);
private sealed record ForecastEntry(
DateTimeOffset LocalTime,
int? Temperature,
int? HighTemperature,
int? LowTemperature,
string? Summary,
string? Condition);
}

View File

@@ -246,6 +246,232 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("I do not know your birthday yet. You can say, my birthday is March 14.", otherTenantRecall.ReplyText);
}
[Fact]
public async Task BuildDecisionAsync_NameMemory_SetThenRecallWithinTenant()
{
var memoryStore = new InMemoryPersonalMemoryStore();
var service = CreateService(memoryStore);
var setDecision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "my name is Alex",
NormalizedTranscript = "my name is Alex",
Attributes = new Dictionary<string, object?>
{
["accountId"] = "acct-a",
["loopId"] = "loop-a"
},
DeviceId = "device-a"
});
Assert.Equal("memory_set_name", setDecision.IntentName);
Assert.Equal("Nice to meet you, alex. I will remember your name.", setDecision.ReplyText);
var recallDecision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "what is my name",
NormalizedTranscript = "what is my name",
Attributes = new Dictionary<string, object?>
{
["accountId"] = "acct-a",
["loopId"] = "loop-a"
},
DeviceId = "device-a"
});
Assert.Equal("memory_get_name", recallDecision.IntentName);
Assert.Equal("You told me your name is alex.", recallDecision.ReplyText);
}
[Fact]
public async Task BuildDecisionAsync_ImportantDateMemory_SetThenRecallWithinTenant()
{
var memoryStore = new InMemoryPersonalMemoryStore();
var service = CreateService(memoryStore);
var setDecision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "our anniversary is June 10",
NormalizedTranscript = "our anniversary is June 10",
Attributes = new Dictionary<string, object?>
{
["accountId"] = "acct-a",
["loopId"] = "loop-a"
},
DeviceId = "device-a"
});
Assert.Equal("memory_set_important_date", setDecision.IntentName);
Assert.Equal("Got it. I will remember your anniversary is june 10.", setDecision.ReplyText);
var recallDecision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "when is our anniversary",
NormalizedTranscript = "when is our anniversary",
Attributes = new Dictionary<string, object?>
{
["accountId"] = "acct-a",
["loopId"] = "loop-a"
},
DeviceId = "device-a"
});
Assert.Equal("memory_get_important_date", recallDecision.IntentName);
Assert.Equal("You told me your anniversary is june 10.", recallDecision.ReplyText);
}
[Fact]
public async Task BuildDecisionAsync_AffinityMemory_SetThenRecallWithinTenant()
{
var memoryStore = new InMemoryPersonalMemoryStore();
var service = CreateService(memoryStore);
var setDecision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "I dislike mushrooms",
NormalizedTranscript = "I dislike mushrooms",
Attributes = new Dictionary<string, object?>
{
["accountId"] = "acct-a",
["loopId"] = "loop-a"
},
DeviceId = "device-a"
});
Assert.Equal("memory_set_affinity", setDecision.IntentName);
Assert.Equal("Got it. I will remember you dislike mushrooms.", setDecision.ReplyText);
var recallDecision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "do i dislike mushrooms",
NormalizedTranscript = "do i dislike mushrooms",
Attributes = new Dictionary<string, object?>
{
["accountId"] = "acct-a",
["loopId"] = "loop-a"
},
DeviceId = "device-a"
});
Assert.Equal("memory_get_affinity", recallDecision.IntentName);
Assert.Equal("Yes. You told me you dislike mushrooms.", recallDecision.ReplyText);
}
[Fact]
public async Task BuildDecisionAsync_PreferenceReversePhrase_ParsesFavoriteVariant()
{
var memoryStore = new InMemoryPersonalMemoryStore();
var service = CreateService(memoryStore);
var setDecision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "pizza is my favorite food",
NormalizedTranscript = "pizza is my favorite food",
Attributes = new Dictionary<string, object?>
{
["accountId"] = "acct-a",
["loopId"] = "loop-a"
},
DeviceId = "device-a"
});
Assert.Equal("memory_set_preference", setDecision.IntentName);
Assert.Equal("Got it. I will remember your favorite food is pizza.", setDecision.ReplyText);
}
[Fact]
public async Task BuildDecisionAsync_Surprise_WithPizzaPreference_UsesPizzaProactivity()
{
var memoryStore = new InMemoryPersonalMemoryStore();
var service = CreateService(memoryStore);
await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "my favorite food is pizza",
NormalizedTranscript = "my favorite food is pizza",
Attributes = new Dictionary<string, object?>
{
["accountId"] = "acct-a",
["loopId"] = "loop-a"
},
DeviceId = "device-a"
});
var decision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "surprise me",
NormalizedTranscript = "surprise me",
Attributes = new Dictionary<string, object?>
{
["accountId"] = "acct-a",
["loopId"] = "loop-a"
},
DeviceId = "device-a"
});
Assert.Equal("proactive_pizza_preference", decision.IntentName);
Assert.Equal("chitchat-skill", decision.SkillName);
Assert.Equal("RA_JBO_MakePizza", decision.SkillPayload!["mim_id"]);
}
[Fact]
public async Task BuildDecisionAsync_Surprise_OnNationalPizzaDay_UsesHolidayProactivity()
{
var service = CreateService();
var decision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "surprise me",
NormalizedTranscript = "surprise me",
Attributes = new Dictionary<string, object?>
{
["context"] = """{"runtime":{"location":{"iso":"2026-02-09T10:45:00-06:00"}}}"""
}
});
Assert.Equal("proactive_pizza_day", decision.IntentName);
Assert.Equal("chitchat-skill", decision.SkillName);
Assert.Contains("National Pizza Day", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task BuildDecisionAsync_PendingPizzaFactOffer_YesMapsToFact()
{
var service = CreateService();
var decision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "yes",
NormalizedTranscript = "yes",
Attributes = new Dictionary<string, object?>
{
["pendingProactivityOffer"] = "pizza_fact"
}
});
Assert.Equal("proactive_pizza_fact", decision.IntentName);
Assert.Contains("350 slices per second", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task BuildDecisionAsync_PendingPizzaFactOffer_NoMapsToDecline()
{
var service = CreateService();
var decision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "no",
NormalizedTranscript = "no",
Attributes = new Dictionary<string, object?>
{
["pendingProactivityOffer"] = "pizza_fact"
}
});
Assert.Equal("proactive_offer_declined", decision.IntentName);
Assert.Equal("No problem. We can save the pizza fact for another time.", decision.ReplyText);
}
[Fact]
public async Task BuildDecisionAsync_MakePizza_UsesOriginalMimStylePayload()
{
@@ -339,6 +565,117 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("RA_JBO_OrderPizza", decision.SkillPayload!["mim_id"]);
}
[Fact]
public async Task BuildDecisionAsync_WeatherQuery_LaunchesReportSkillWithPegasusIntent()
{
var service = CreateService();
var decision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "how is the weather",
NormalizedTranscript = "how is the weather"
});
Assert.Equal("weather", decision.IntentName);
Assert.Equal("report-skill", decision.SkillName);
Assert.Equal("requestWeatherPR", decision.SkillPayload!["localIntent"]);
Assert.Equal("weather", decision.SkillPayload["cloudSkill"]);
}
[Fact]
public async Task BuildDecisionAsync_WeatherTomorrowQuery_SetsTomorrowEntity()
{
var service = CreateService();
var decision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "what's the weather tomorrow",
NormalizedTranscript = "what's the weather tomorrow"
});
Assert.Equal("weather", decision.IntentName);
Assert.Equal("tomorrow", decision.SkillPayload!["date"]);
}
[Fact]
public async Task BuildDecisionAsync_WeatherConditionQuery_SetsWeatherConditionEntity()
{
var service = CreateService();
var decision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "will it rain tomorrow",
NormalizedTranscript = "will it rain tomorrow"
});
Assert.Equal("weather", decision.IntentName);
Assert.Equal("rain", decision.SkillPayload!["weatherCondition"]);
Assert.Equal("tomorrow", decision.SkillPayload["date"]);
}
[Fact]
public async Task BuildDecisionAsync_ClientNluRequestWeatherPR_LaunchesReportSkill()
{
var service = CreateService();
var decision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "requestWeatherPR",
NormalizedTranscript = "requestWeatherPR",
Attributes = new Dictionary<string, object?>
{
["clientIntent"] = "requestWeatherPR"
}
});
Assert.Equal("weather", decision.IntentName);
Assert.Equal("report-skill", decision.SkillName);
Assert.Equal("requestWeatherPR", decision.SkillPayload!["localIntent"]);
}
[Fact]
public async Task BuildDecisionAsync_WeatherQuery_WithProvider_UsesProviderSummary()
{
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 = "how is the weather",
NormalizedTranscript = "how is the weather"
});
Assert.Equal("weather", decision.IntentName);
Assert.Equal("Right now in Boston, US, it is light rain and 61 degrees Fahrenheit.", decision.ReplyText);
Assert.Equal("openweather", decision.SkillPayload!["provider"]);
Assert.Equal(61, decision.SkillPayload["temperature"]);
Assert.Equal("rain", decision.SkillPayload["weatherCondition"]);
}
[Fact]
public async Task BuildDecisionAsync_WeatherLocationTomorrow_WithProvider_PassesLocationAndTomorrowRequest()
{
var provider = new CapturingWeatherReportProvider
{
Snapshot = new WeatherReportSnapshot("Chicago, US", "mostly cloudy", 72, 74, 60, "cloudy", false)
};
var service = CreateService(weatherReportProvider: provider);
var decision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "what's the weather in chicago tomorrow",
NormalizedTranscript = "what's the weather in chicago tomorrow"
});
Assert.Equal("weather", decision.IntentName);
Assert.Equal("Chicago", provider.LastRequest?.LocationQuery);
Assert.True(provider.LastRequest?.IsTomorrow);
Assert.Equal("Tomorrow in Chicago, US, expect mostly cloudy with a high near 74 degrees Fahrenheit and a low around 60 degrees Fahrenheit.", decision.ReplyText);
}
[Fact]
public async Task BuildDecisionAsync_ClientNluAskForDate_MapsToDateIntent()
{
@@ -1295,12 +1632,15 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("aglet", decision.SkillPayload!["guess"]);
}
private static JiboInteractionService CreateService(IPersonalMemoryStore? personalMemoryStore = null)
private static JiboInteractionService CreateService(
IPersonalMemoryStore? personalMemoryStore = null,
IWeatherReportProvider? weatherReportProvider = null)
{
return new JiboInteractionService(
new JiboExperienceContentCache(new InMemoryJiboExperienceContentRepository()),
new FirstItemRandomizer(),
personalMemoryStore ?? new InMemoryPersonalMemoryStore());
personalMemoryStore ?? new InMemoryPersonalMemoryStore(),
weatherReportProvider);
}
private sealed class FirstItemRandomizer : IJiboRandomizer
@@ -1310,4 +1650,19 @@ public sealed class JiboInteractionServiceTests
return items[0];
}
}
private sealed class CapturingWeatherReportProvider : IWeatherReportProvider
{
public WeatherReportRequest? LastRequest { get; private set; }
public WeatherReportSnapshot? Snapshot { get; init; }
public Task<WeatherReportSnapshot?> GetReportAsync(
WeatherReportRequest request,
CancellationToken cancellationToken = default)
{
LastRequest = request;
return Task.FromResult(Snapshot);
}
}
}

View File

@@ -1696,6 +1696,92 @@ public sealed class JiboWebSocketServiceTests
Assert.Equal("announcement", meta.GetProperty("mim_type").GetString());
}
[Fact]
public async Task ClientAsr_HowIsTheWeather_EmitsReportSkillRedirectAndCompletion()
{
await _service.HandleMessageAsync(new WebSocketMessageEnvelope
{
HostName = "neo-hub.jibo.com",
Path = "/listen",
Kind = "neo-hub-listen",
Token = "hub-weather-token",
Text = """{"type":"LISTEN","transID":"trans-weather","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}"""
});
var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope
{
HostName = "neo-hub.jibo.com",
Path = "/listen",
Kind = "neo-hub-listen",
Token = "hub-weather-token",
Text = """{"type":"CLIENT_ASR","transID":"trans-weather","data":{"text":"how is the weather"}}"""
});
Assert.Equal(5, replies.Count);
Assert.Equal("LISTEN", ReadReplyType(replies[0]));
Assert.Equal("EOS", ReadReplyType(replies[1]));
Assert.Equal("SKILL_REDIRECT", ReadReplyType(replies[2]));
Assert.Equal("SKILL_ACTION", ReadReplyType(replies[3]));
Assert.Equal("SKILL_ACTION", ReadReplyType(replies[4]));
using var listenPayload = JsonDocument.Parse(replies[0].Text!);
Assert.Equal("requestWeatherPR", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString());
Assert.Equal("report-skill", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("skill").GetString());
Assert.Equal("weather", listenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("cloudSkill").GetString());
using var redirectPayload = JsonDocument.Parse(replies[2].Text!);
Assert.Equal("report-skill", redirectPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("skillID").GetString());
Assert.Equal("requestWeatherPR", redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString());
using var completionPayload = JsonDocument.Parse(replies[3].Text!);
Assert.Equal("report-skill", completionPayload.RootElement.GetProperty("data").GetProperty("skill").GetProperty("id").GetString());
using var speakPayload = JsonDocument.Parse(replies[4].Text!);
var esml = speakPayload.RootElement
.GetProperty("data")
.GetProperty("action")
.GetProperty("config")
.GetProperty("jcp")
.GetProperty("config")
.GetProperty("play")
.GetProperty("esml")
.GetString();
Assert.Contains("Checking your weather report", esml, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ClientAsr_WillItRainTomorrow_EmitsReportSkillWeatherEntities()
{
await _service.HandleMessageAsync(new WebSocketMessageEnvelope
{
HostName = "neo-hub.jibo.com",
Path = "/listen",
Kind = "neo-hub-listen",
Token = "hub-weather-entities-token",
Text = """{"type":"LISTEN","transID":"trans-weather-entities","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}"""
});
var replies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope
{
HostName = "neo-hub.jibo.com",
Path = "/listen",
Kind = "neo-hub-listen",
Token = "hub-weather-entities-token",
Text = """{"type":"CLIENT_ASR","transID":"trans-weather-entities","data":{"text":"will it rain tomorrow"}}"""
});
Assert.Equal(5, replies.Count);
using var listenPayload = JsonDocument.Parse(replies[0].Text!);
var entities = listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities");
Assert.Equal("tomorrow", entities.GetProperty("date").GetString());
Assert.Equal("rain", entities.GetProperty("Weather").GetString());
using var redirectPayload = JsonDocument.Parse(replies[2].Text!);
var redirectEntities = redirectPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("entities");
Assert.Equal("tomorrow", redirectEntities.GetProperty("date").GetString());
Assert.Equal("rain", redirectEntities.GetProperty("Weather").GetString());
}
[Fact]
public async Task ClientAsr_OpenTheRadio_EmitsRadioRedirectAndSilentCompletion()
{
@@ -2974,6 +3060,51 @@ public sealed class JiboWebSocketServiceTests
Assert.Contains("I do not know your birthday yet", otherEsml, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ClientAsrSurpriseOffer_PersistsPendingOfferAndResolvesYesFollowUp()
{
var token = _store.IssueRobotToken("proactivity-device-a");
var offerReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope
{
HostName = "neo-hub.jibo.com",
Path = "/listen",
Kind = "neo-hub-listen",
Token = token,
Text = """{"type":"CLIENT_ASR","transID":"trans-proactive-offer","data":{"text":"surprise me"}}"""
});
Assert.Equal(3, offerReplies.Count);
using (var offerListenPayload = JsonDocument.Parse(offerReplies[0].Text!))
{
Assert.Equal("proactive_offer_pizza_fact", offerListenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString());
}
var session = _store.FindSessionByToken(token);
Assert.NotNull(session);
Assert.True(session.Metadata.TryGetValue("pendingProactivityOffer", out var pendingOffer));
Assert.Equal("pizza_fact", pendingOffer?.ToString());
var followUpReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope
{
HostName = "neo-hub.jibo.com",
Path = "/listen",
Kind = "neo-hub-listen",
Token = token,
Text = """{"type":"CLIENT_ASR","transID":"trans-proactive-offer-yes","data":{"text":"yes"}}"""
});
Assert.Equal(3, followUpReplies.Count);
using (var followUpListenPayload = JsonDocument.Parse(followUpReplies[0].Text!))
{
Assert.Equal("proactive_pizza_fact", followUpListenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString());
}
session = _store.FindSessionByToken(token);
Assert.NotNull(session);
Assert.False(session.Metadata.ContainsKey("pendingProactivityOffer"));
}
[Fact]
public async Task FollowUpTurn_UsesNewTurnStateWithoutLeakingBufferedAudio()
{