Add OpenWeather-backed weather reports
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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?>
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user