From 80c4ae38fbed2f7c114acb637e15ceb94fe8f994 Mon Sep 17 00:00:00 2001 From: Jacob Dubin Date: Sun, 10 May 2026 00:30:31 -0500 Subject: [PATCH] Add commit message generation prompt --- OpenJibo/docs/feature-backlog.md | 9 +- OpenJibo/docs/release-1.0.19-plan.md | 10 +- .../src/Jibo.Cloud.Api/appsettings.json | 16 +- .../Abstractions/INewsBriefingProvider.cs | 23 ++ .../Services/JiboInteractionService.cs | 212 +++++++++++++++-- .../ServiceCollectionExtensions.cs | 14 ++ .../News/NewsApiBriefingProvider.cs | 225 ++++++++++++++++++ .../News/NewsApiOptions.cs | 22 ++ .../Weather/OpenWeatherOptions.cs | 8 + .../Weather/OpenWeatherReportProvider.cs | 92 ++++++- .../Infrastructure/ProviderCachingTests.cs | 115 +++++++++ .../WebSockets/JiboInteractionServiceTests.cs | 96 +++++++- .../WebSockets/JiboWebSocketServiceTests.cs | 67 +++++- 13 files changed, 884 insertions(+), 25 deletions(-) create mode 100644 OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/INewsBriefingProvider.cs create mode 100644 OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/News/NewsApiBriefingProvider.cs create mode 100644 OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/News/NewsApiOptions.cs create mode 100644 OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/ProviderCachingTests.cs diff --git a/OpenJibo/docs/feature-backlog.md b/OpenJibo/docs/feature-backlog.md index 4f2c978..41fbb4d 100644 --- a/OpenJibo/docs/feature-backlog.md +++ b/OpenJibo/docs/feature-backlog.md @@ -715,7 +715,7 @@ Current release theme: ### 27. Personal Report Parity Track (Weather/News/Commute/Calendar) -- Status: `ready` +- Status: `in_progress` - Tags: `protocol`, `content`, `storage`, `docs` - Why now: - personal report is a core Jibo charm surface and currently split between implemented weather speech and placeholder calendar/commute/news content @@ -726,6 +726,11 @@ Current release theme: - provider-backed news ingestion and filtering - commute provider path and settings schema - coverage matrix for personal report parity gaps and test/capture exit criteria +- Progress update (`2026-05-10`): + - added provider-ready news briefing lane with Nimbus-compatible `news` skill payload continuity + - added memory/transcript category hint plumbing for provider requests (sports/technology/business/general) + - fallback synthetic news behavior remains active when no provider key is configured + - added TTL caching for weather/news provider calls to reduce repeated external requests - Source anchors: - `C:\Projects\jibo\pegasus\packages\report-skill\src\subskills\weather\WeatherMimLogic.ts` - `C:\Projects\jibo\pegasus\packages\report-skill\resources\views\weatherHiLo.json` @@ -775,7 +780,7 @@ For `1.0.19`: 4. Weather report-skill launch compatibility - implemented 5. Dialog parsing expansion and ambiguity guardrails - in progress (`2026-05-09` third guardrail slice implemented; Pegasus affinity phrase families + continuation guardrails expanded) 6. Presence-aware greetings and identity-triggered proactivity - implemented (trigger path, identity-aware reactive/proactive replies, cooldown metadata wiring, focused websocket coverage) -7. Personal report parity track (weather visuals, live news path, commute path, calendar parity matrix) - ready +7. Personal report parity track (weather visuals, live news path, commute path, calendar parity matrix) - in progress (`2026-05-10` first live-news provider slice implemented) 8. Holidays and seasonal personality behavior built on the new memory/proactivity foundation 9. Durable memory persistence path (multi-tenant backing store) 10. Update, backup, and restore proof diff --git a/OpenJibo/docs/release-1.0.19-plan.md b/OpenJibo/docs/release-1.0.19-plan.md index b8160a3..1ed5692 100644 --- a/OpenJibo/docs/release-1.0.19-plan.md +++ b/OpenJibo/docs/release-1.0.19-plan.md @@ -184,7 +184,15 @@ Third completed guardrail slice under this queue: Next queued implementation track after parser guardrails: -- presence-aware greetings and identity-triggered proactivity (Pegasus `@be/greetings` parity slice) +- personal report parity slices (weather visual parity, live news path, commute/calendar gap closure) + +First completed slice in this personal-report parity track: + +- added provider-ready news briefing path with Nimbus-compatible `news` payload continuity +- preserved fallback behavior when no live provider is configured +- added memory/transcript category hinting for provider requests (`sports`, `technology`, `business`, etc.) +- added provider-side request caching for both news and weather to reduce integration churn and repeated lookups +- added focused interaction + websocket tests for provider-backed news speech output and request-hint plumbing ## Next Slices diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/appsettings.json b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/appsettings.json index 66f2329..b1f6669 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/appsettings.json +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/appsettings.json @@ -27,7 +27,21 @@ "BaseUrl": "https://api.openweathermap.org", "ApiKey": "723667c9ab0318142227c5389900d087", "DefaultLocation": "Boston,US", - "UseCelsius": false + "UseCelsius": false, + "CurrentCacheTtlSeconds": 120, + "ForecastCacheTtlSeconds": 600, + "GeocodeCacheTtlSeconds": 21600, + "FailureCacheTtlSeconds": 45 + } + }, + "News": { + "NewsApi": { + "BaseUrl": "https://newsapi.org", + "ApiKey": "5df93a83db9c4c6888f3e06c4a53144f", + "Country": "us", + "DefaultCategories": [ "general", "technology", "sports", "business" ], + "CacheTtlSeconds": 300, + "FailureCacheTtlSeconds": 45 } } } diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/INewsBriefingProvider.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/INewsBriefingProvider.cs new file mode 100644 index 0000000..9f1c8d0 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/INewsBriefingProvider.cs @@ -0,0 +1,23 @@ +namespace Jibo.Cloud.Application.Abstractions; + +public interface INewsBriefingProvider +{ + Task GetBriefingAsync( + NewsBriefingRequest request, + CancellationToken cancellationToken = default); +} + +public sealed record NewsBriefingRequest( + IReadOnlyList PreferredCategories, + int MaxHeadlines = 3); + +public sealed record NewsHeadline( + string Title, + string? Summary = null, + string? Category = null, + string? SourceName = null, + string? Url = null); + +public sealed record NewsBriefingSnapshot( + IReadOnlyList Headlines, + string? SourceName = null); diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs index 40ff09a..4479122 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs @@ -10,7 +10,8 @@ public sealed class JiboInteractionService( JiboExperienceContentCache contentCache, IJiboRandomizer randomizer, IPersonalMemoryStore personalMemoryStore, - IWeatherReportProvider? weatherReportProvider = null) + IWeatherReportProvider? weatherReportProvider = null, + INewsBriefingProvider? newsBriefingProvider = null) { public async Task BuildDecisionAsync(TurnContext turn, CancellationToken cancellationToken = default) { @@ -158,7 +159,7 @@ public sealed class JiboInteractionService( "personal_report" => new JiboInteractionDecision("personal_report", randomizer.Choose(catalog.PersonalReportReplies)), "calendar" => new JiboInteractionDecision("calendar", randomizer.Choose(catalog.CalendarReplies)), "commute" => new JiboInteractionDecision("commute", randomizer.Choose(catalog.CommuteReplies)), - "news" => BuildNewsDecision(catalog), + "news" => await BuildNewsDecisionAsync(turn, transcript, catalog, cancellationToken), _ => new JiboInteractionDecision("chat", BuildGenericReply(catalog, transcript, lowered)) }; } @@ -900,20 +901,177 @@ public sealed class JiboInteractionService( }); } - private JiboInteractionDecision BuildNewsDecision(JiboExperienceCatalog catalog) + private async Task BuildNewsDecisionAsync( + TurnContext turn, + string transcript, + JiboExperienceCatalog catalog, + CancellationToken cancellationToken) { - var briefing = randomizer.Choose(catalog.NewsBriefings); - return new JiboInteractionDecision( - "news", - briefing, - "news", - new Dictionary(StringComparer.OrdinalIgnoreCase) + var preferredCategories = ResolvePreferredNewsCategories(turn, transcript); + if (newsBriefingProvider is not null) + { + try { - ["skillId"] = "news", - ["cloudSkill"] = "news", - ["mim_id"] = "runtime-news", - ["mim_type"] = "announcement" - }); + var snapshot = await newsBriefingProvider.GetBriefingAsync( + new NewsBriefingRequest(preferredCategories, MaxNewsHeadlines), + cancellationToken); + + if (snapshot?.Headlines.Count > 0) + { + return BuildProviderNewsDecision(snapshot, preferredCategories); + } + } + catch + { + // Provider failures should never block baseline news behavior. + } + } + + var fallbackBriefing = randomizer.Choose(catalog.NewsBriefings); + return BuildNewsDecision( + fallbackBriefing, + sourceName: null, + preferredCategories.Count > 0 ? preferredCategories : null, + headlineCount: null); + } + + private static JiboInteractionDecision BuildNewsDecision( + string spokenBriefing, + string? sourceName, + IReadOnlyList? categories, + int? headlineCount) + { + var payload = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["skillId"] = "news", + ["cloudSkill"] = "news", + ["mim_id"] = "runtime-news", + ["mim_type"] = "announcement" + }; + + if (!string.IsNullOrWhiteSpace(sourceName)) + { + payload["news_source"] = sourceName; + } + + if (headlineCount is > 0) + { + payload["news_headline_count"] = headlineCount.Value; + } + + if (categories is { Count: > 0 }) + { + payload["news_categories"] = categories.ToArray(); + } + + return new JiboInteractionDecision("news", spokenBriefing, "news", payload); + } + + private static JiboInteractionDecision BuildProviderNewsDecision( + NewsBriefingSnapshot snapshot, + IReadOnlyList preferredCategories) + { + var headlines = snapshot.Headlines + .Where(headline => !string.IsNullOrWhiteSpace(headline.Title)) + .Take(MaxNewsHeadlines) + .ToArray(); + if (headlines.Length == 0) + { + return BuildNewsDecision( + "I couldn't load fresh headlines right now.", + snapshot.SourceName, + preferredCategories, + headlineCount: 0); + } + + var leadIn = BuildNewsLeadIn(snapshot.SourceName, preferredCategories); + var joinedHeadlines = string.Join(" ", headlines.Select(static headline => $"{headline.Title}.")); + var spokenBriefing = $"{leadIn} {joinedHeadlines}".Trim(); + return BuildNewsDecision( + spokenBriefing, + snapshot.SourceName, + preferredCategories, + headlines.Length); + } + + private static string BuildNewsLeadIn(string? sourceName, IReadOnlyList preferredCategories) + { + var categoryLeadIn = preferredCategories.Count switch + { + <= 0 => "Here are a few headlines.", + 1 => $"Here are your {preferredCategories[0]} headlines.", + _ => $"Here are your {preferredCategories[0]} and {preferredCategories[1]} headlines." + }; + + return string.IsNullOrWhiteSpace(sourceName) + ? categoryLeadIn + : $"{categoryLeadIn} Source: {sourceName}."; + } + + private List ResolvePreferredNewsCategories(TurnContext turn, string transcript) + { + var categories = new List(); + var normalizedTranscript = NormalizeCommandPhrase(transcript); + + foreach (var (keyword, category) in NewsCategoryKeywordMap) + { + if (normalizedTranscript.Contains(keyword, StringComparison.Ordinal)) + { + AddNewsCategory(categories, category); + } + } + + var tenantScope = ResolveTenantScope(turn); + var explicitPreference = personalMemoryStore.GetPreference(tenantScope, "news"); + if (!string.IsNullOrWhiteSpace(explicitPreference)) + { + foreach (var category in MapNewsCategoryText(explicitPreference)) + { + AddNewsCategory(categories, category); + } + } + + foreach (var (item, affinity) in personalMemoryStore.GetAffinities(tenantScope)) + { + if (affinity == PersonalAffinity.Dislike) + { + continue; + } + + foreach (var category in MapNewsCategoryText(item)) + { + AddNewsCategory(categories, category); + } + } + + return categories.Take(MaxPreferredNewsCategories).ToList(); + } + + private static IEnumerable MapNewsCategoryText(string text) + { + var normalized = NormalizeCommandPhrase(text); + if (string.IsNullOrWhiteSpace(normalized)) + { + yield break; + } + + foreach (var (keyword, category) in NewsCategoryKeywordMap) + { + if (normalized.Contains(keyword, StringComparison.Ordinal)) + { + yield return category; + } + } + } + + private static void AddNewsCategory(ICollection categories, string category) + { + if (categories.Contains(category, StringComparer.OrdinalIgnoreCase)) + { + return; + } + + categories.Add(category); } private JiboInteractionDecision BuildSurpriseDecision( @@ -3928,6 +4086,32 @@ public sealed class JiboInteractionService( private static readonly TimeSpan ProactiveGreetingCooldown = TimeSpan.FromMinutes(20); private const int MaxWeatherForecastDayOffset = 5; + private const int MaxNewsHeadlines = 3; + private const int MaxPreferredNewsCategories = 2; + + private static readonly (string Keyword, string Category)[] NewsCategoryKeywordMap = + [ + ("sports", "sports"), + ("sport", "sports"), + ("football", "sports"), + ("baseball", "sports"), + ("basketball", "sports"), + ("hockey", "sports"), + ("technology", "technology"), + ("tech", "technology"), + ("ai", "technology"), + ("science", "science"), + ("business", "business"), + ("finance", "business"), + ("market", "business"), + ("stock", "business"), + ("politics", "general"), + ("political", "general"), + ("world", "general"), + ("entertainment", "entertainment"), + ("movie", "entertainment"), + ("music", "entertainment") + ]; private static readonly (string Phrase, string Station)[] RadioGenreAliases = [ diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs index 0cee4b5..f08b3ec 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using Jibo.Cloud.Application.Abstractions; using Jibo.Cloud.Application.Services; using Jibo.Cloud.Infrastructure.Audio; using Jibo.Cloud.Infrastructure.Content; +using Jibo.Cloud.Infrastructure.News; using Jibo.Cloud.Infrastructure.Persistence; using Jibo.Cloud.Infrastructure.Telemetry; using Jibo.Cloud.Infrastructure.Weather; @@ -35,9 +36,22 @@ public static class ServiceCollectionExtensions openWeatherOptions.ApiKey = Environment.GetEnvironmentVariable("OPENWEATHER_API_KEY"); } + var newsApiOptions = new NewsApiOptions(); + if (configuration is not null) + { + configuration.GetSection("OpenJibo:News:NewsApi").Bind(newsApiOptions); + } + + if (string.IsNullOrWhiteSpace(newsApiOptions.ApiKey)) + { + newsApiOptions.ApiKey = Environment.GetEnvironmentVariable("NEWSAPI_KEY"); + } + services.AddSingleton(sttOptions); services.AddSingleton(openWeatherOptions); + services.AddSingleton(newsApiOptions); services.AddHttpClient(); + services.AddHttpClient(); var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"] ?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json"); services.AddSingleton(_ => new InMemoryCloudStateStore(statePersistencePath)); diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/News/NewsApiBriefingProvider.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/News/NewsApiBriefingProvider.cs new file mode 100644 index 0000000..2d2ec32 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/News/NewsApiBriefingProvider.cs @@ -0,0 +1,225 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Jibo.Cloud.Application.Abstractions; +using Microsoft.Extensions.Logging; + +namespace Jibo.Cloud.Infrastructure.News; + +public sealed class NewsApiBriefingProvider( + HttpClient httpClient, + NewsApiOptions options, + ILogger logger) + : INewsBriefingProvider +{ + private readonly ConcurrentDictionary> briefingCache = new(StringComparer.OrdinalIgnoreCase); + + public async Task GetBriefingAsync( + NewsBriefingRequest request, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(options.ApiKey)) + { + return null; + } + + string? cacheKey = null; + try + { + var categories = ResolveCategories(request.PreferredCategories).ToArray(); + if (categories.Length == 0) + { + categories = ["general"]; + } + + var requestedHeadlineCount = Math.Clamp(request.MaxHeadlines, 1, MaxHeadlines); + cacheKey = BuildCacheKey(categories, requestedHeadlineCount); + if (TryGetCachedValue(briefingCache, cacheKey, out var cachedBriefing)) + { + return cachedBriefing; + } + + var headlines = new List(requestedHeadlineCount); + var seenTitles = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var category in categories) + { + var uri = BuildTopHeadlinesUri(category, requestedHeadlineCount); + using var response = await httpClient.GetAsync(uri, cancellationToken); + if (!response.IsSuccessStatusCode) + { + continue; + } + + using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + if (!document.RootElement.TryGetProperty("articles", out var articles) || + articles.ValueKind != JsonValueKind.Array) + { + continue; + } + + foreach (var article in articles.EnumerateArray()) + { + var title = NormalizeHeadlineTitle(ReadString(article, "title")); + if (string.IsNullOrWhiteSpace(title) || !seenTitles.Add(title)) + { + continue; + } + + var summary = ReadString(article, "description"); + var source = article.TryGetProperty("source", out var sourceNode) && + sourceNode.ValueKind == JsonValueKind.Object + ? ReadString(sourceNode, "name") + : null; + var url = ReadString(article, "url"); + headlines.Add(new NewsHeadline(title, summary, category, source, url)); + + if (headlines.Count >= requestedHeadlineCount) + { + var snapshot = new NewsBriefingSnapshot(headlines, "NewsAPI"); + SetCachedValue(briefingCache, cacheKey, snapshot, options.CacheTtlSeconds); + return snapshot; + } + } + } + + if (headlines.Count == 0) + { + SetCachedValue(briefingCache, cacheKey, null, options.FailureCacheTtlSeconds); + return null; + } + + var populatedSnapshot = new NewsBriefingSnapshot(headlines, "NewsAPI"); + SetCachedValue(briefingCache, cacheKey, populatedSnapshot, options.CacheTtlSeconds); + return populatedSnapshot; + } + catch (Exception exception) + { + logger.LogWarning(exception, "NewsAPI lookup failed."); + if (!string.IsNullOrWhiteSpace(cacheKey)) + { + SetCachedValue(briefingCache, cacheKey, null, options.FailureCacheTtlSeconds); + } + return null; + } + } + + private IEnumerable ResolveCategories(IReadOnlyList preferredCategories) + { + var requested = preferredCategories + .Where(category => !string.IsNullOrWhiteSpace(category)) + .Select(category => category.Trim().ToLowerInvariant()) + .Where(SupportedCategories.Contains) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (requested.Length > 0) + { + return requested.Take(MaxCategories); + } + + return options.DefaultCategories + .Where(category => !string.IsNullOrWhiteSpace(category)) + .Select(category => category.Trim().ToLowerInvariant()) + .Where(SupportedCategories.Contains) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(MaxCategories); + } + + private Uri BuildTopHeadlinesUri(string category, int headlineCount) + { + var baseUrl = options.BaseUrl.TrimEnd('/'); + var queryParts = new (string Key, string Value)[] + { + ("country", options.Country), + ("category", category), + ("pageSize", headlineCount.ToString()), + ("apiKey", options.ApiKey!) + }; + var query = string.Join( + "&", + queryParts.Select(part => + $"{Uri.EscapeDataString(part.Key)}={Uri.EscapeDataString(part.Value)}")); + return new Uri($"{baseUrl}/v2/top-headlines?{query}"); + } + + private static string? ReadString(JsonElement source, string propertyName) + { + return source.TryGetProperty(propertyName, out var value) && + value.ValueKind == JsonValueKind.String && + !string.IsNullOrWhiteSpace(value.GetString()) + ? value.GetString() + : null; + } + + private static string? NormalizeHeadlineTitle(string? title) + { + if (string.IsNullOrWhiteSpace(title)) + { + return null; + } + + var trimmed = title.Trim(); + var suffixIndex = trimmed.LastIndexOf(" - ", StringComparison.Ordinal); + if (suffixIndex > 30) + { + trimmed = trimmed[..suffixIndex].TrimEnd(); + } + + return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; + } + + private string BuildCacheKey(IReadOnlyList categories, int requestedHeadlineCount) + { + var categoryKey = string.Join(",", categories.Select(category => category.Trim().ToLowerInvariant())); + return $"{options.Country.Trim().ToLowerInvariant()}|{requestedHeadlineCount}|{categoryKey}"; + } + + private static bool TryGetCachedValue( + ConcurrentDictionary> cache, + string key, + out T value) + { + value = default!; + if (!cache.TryGetValue(key, out var entry)) + { + return false; + } + + if (entry.ExpiresUtc > DateTimeOffset.UtcNow) + { + value = entry.Value; + return true; + } + + cache.TryRemove(key, out _); + return false; + } + + private static void SetCachedValue( + ConcurrentDictionary> cache, + string key, + T value, + int ttlSeconds) + { + cache[key] = new CacheEntry( + value, + DateTimeOffset.UtcNow.AddSeconds(Math.Max(1, ttlSeconds))); + } + + private static readonly HashSet SupportedCategories = new(StringComparer.OrdinalIgnoreCase) + { + "business", + "entertainment", + "general", + "health", + "science", + "sports", + "technology" + }; + + private const int MaxHeadlines = 5; + private const int MaxCategories = 2; + + private sealed record CacheEntry(T Value, DateTimeOffset ExpiresUtc); +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/News/NewsApiOptions.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/News/NewsApiOptions.cs new file mode 100644 index 0000000..1e197a9 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/News/NewsApiOptions.cs @@ -0,0 +1,22 @@ +namespace Jibo.Cloud.Infrastructure.News; + +public sealed class NewsApiOptions +{ + public string BaseUrl { get; set; } = "https://newsapi.org"; + + public string? ApiKey { get; set; } + + public string Country { get; set; } = "us"; + + public string[] DefaultCategories { get; set; } = + [ + "general", + "technology", + "sports", + "business" + ]; + + public int CacheTtlSeconds { get; set; } = 300; + + public int FailureCacheTtlSeconds { get; set; } = 45; +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Weather/OpenWeatherOptions.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Weather/OpenWeatherOptions.cs index 75d84f9..9d1949a 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Weather/OpenWeatherOptions.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Weather/OpenWeatherOptions.cs @@ -9,4 +9,12 @@ public sealed class OpenWeatherOptions public string DefaultLocation { get; set; } = "Boston,US"; public bool UseCelsius { get; set; } + + public int CurrentCacheTtlSeconds { get; set; } = 120; + + public int ForecastCacheTtlSeconds { get; set; } = 600; + + public int GeocodeCacheTtlSeconds { get; set; } = 21600; + + public int FailureCacheTtlSeconds { get; set; } = 45; } diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Weather/OpenWeatherReportProvider.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Weather/OpenWeatherReportProvider.cs index 78dd8dd..47128b1 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Weather/OpenWeatherReportProvider.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Weather/OpenWeatherReportProvider.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Globalization; using System.Text.Json; using Jibo.Cloud.Application.Abstractions; @@ -11,6 +12,9 @@ public sealed class OpenWeatherReportProvider( ILogger logger) : IWeatherReportProvider { + private readonly ConcurrentDictionary> geocodeCache = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary> weatherCache = new(StringComparer.OrdinalIgnoreCase); + public async Task GetReportAsync( WeatherReportRequest request, CancellationToken cancellationToken = default) @@ -20,6 +24,7 @@ public sealed class OpenWeatherReportProvider( return null; } + string? weatherCacheKey = null; try { var location = await ResolveLocationAsync(request, cancellationToken); @@ -30,21 +35,45 @@ public sealed class OpenWeatherReportProvider( var useCelsius = request.UseCelsius ?? options.UseCelsius; var forecastDayOffset = request.ForecastDayOffset ?? (request.IsTomorrow ? 1 : 0); + weatherCacheKey = BuildWeatherCacheKey(location.Value, useCelsius, forecastDayOffset); + if (TryGetCachedValue(weatherCache, weatherCacheKey, out var cachedSnapshot)) + { + return cachedSnapshot; + } + + WeatherReportSnapshot? snapshot; if (forecastDayOffset <= 0) { - return await GetCurrentWeatherAsync(location.Value, useCelsius, cancellationToken); + snapshot = await GetCurrentWeatherAsync(location.Value, useCelsius, cancellationToken); + SetCachedValue( + weatherCache, + weatherCacheKey, + snapshot, + snapshot is null ? options.FailureCacheTtlSeconds : options.CurrentCacheTtlSeconds); + return snapshot; } if (forecastDayOffset > MaxForecastDayOffset) { + SetCachedValue(weatherCache, weatherCacheKey, null, options.FailureCacheTtlSeconds); return null; } - return await GetForecastForDayOffsetAsync(location.Value, useCelsius, forecastDayOffset, cancellationToken); + snapshot = await GetForecastForDayOffsetAsync(location.Value, useCelsius, forecastDayOffset, cancellationToken); + SetCachedValue( + weatherCache, + weatherCacheKey, + snapshot, + snapshot is null ? options.FailureCacheTtlSeconds : options.ForecastCacheTtlSeconds); + return snapshot; } catch (Exception exception) { logger.LogWarning(exception, "OpenWeather lookup failed."); + if (!string.IsNullOrWhiteSpace(weatherCacheKey)) + { + SetCachedValue(weatherCache, weatherCacheKey, null, options.FailureCacheTtlSeconds); + } return null; } } @@ -72,6 +101,12 @@ public sealed class OpenWeatherReportProvider( return null; } + var geocodeCacheKey = NormalizeLocationQueryForCache(query); + if (TryGetCachedValue(geocodeCache, geocodeCacheKey, out var cachedLocation)) + { + return cachedLocation; + } + var geocodeUri = BuildRequestUri( "/geo/1.0/direct", ("q", query), @@ -80,6 +115,7 @@ public sealed class OpenWeatherReportProvider( using var response = await httpClient.GetAsync(geocodeUri, cancellationToken); if (!response.IsSuccessStatusCode) { + SetCachedValue(geocodeCache, geocodeCacheKey, null, options.FailureCacheTtlSeconds); return null; } @@ -88,6 +124,7 @@ public sealed class OpenWeatherReportProvider( if (document.RootElement.ValueKind != JsonValueKind.Array || document.RootElement.GetArrayLength() == 0) { + SetCachedValue(geocodeCache, geocodeCacheKey, null, options.FailureCacheTtlSeconds); return null; } @@ -95,11 +132,14 @@ public sealed class OpenWeatherReportProvider( if (!TryReadDouble(location, "lat", out var latitude) || !TryReadDouble(location, "lon", out var longitude)) { + SetCachedValue(geocodeCache, geocodeCacheKey, null, options.FailureCacheTtlSeconds); return null; } var displayName = BuildLocationDisplayName(location); - return new LocationPoint(latitude, longitude, displayName); + var resolvedLocation = new LocationPoint(latitude, longitude, displayName); + SetCachedValue(geocodeCache, geocodeCacheKey, resolvedLocation, options.GeocodeCacheTtlSeconds); + return resolvedLocation; } private async Task GetCurrentWeatherAsync( @@ -368,8 +408,54 @@ public sealed class OpenWeatherReportProvider( return null; } + private static string BuildWeatherCacheKey(LocationPoint location, bool useCelsius, int forecastDayOffset) + { + return string.Create( + CultureInfo.InvariantCulture, + $"{location.Latitude:F4}|{location.Longitude:F4}|{(useCelsius ? "C" : "F")}|{forecastDayOffset}"); + } + + private static string NormalizeLocationQueryForCache(string query) + { + return query.Trim().ToLowerInvariant(); + } + + private static bool TryGetCachedValue( + ConcurrentDictionary> cache, + string key, + out T value) + { + value = default!; + if (!cache.TryGetValue(key, out var entry)) + { + return false; + } + + if (entry.ExpiresUtc > DateTimeOffset.UtcNow) + { + value = entry.Value; + return true; + } + + cache.TryRemove(key, out _); + return false; + } + + private static void SetCachedValue( + ConcurrentDictionary> cache, + string key, + T value, + int ttlSeconds) + { + cache[key] = new CacheEntry( + value, + DateTimeOffset.UtcNow.AddSeconds(Math.Max(1, ttlSeconds))); + } + private readonly record struct LocationPoint(double Latitude, double Longitude, string? DisplayName); + private sealed record CacheEntry(T Value, DateTimeOffset ExpiresUtc); + private sealed record ForecastEntry( DateTimeOffset LocalTime, int? Temperature, diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/ProviderCachingTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/ProviderCachingTests.cs new file mode 100644 index 0000000..bab3744 --- /dev/null +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/ProviderCachingTests.cs @@ -0,0 +1,115 @@ +using System.Net; +using System.Text; +using Jibo.Cloud.Application.Abstractions; +using Jibo.Cloud.Infrastructure.News; +using Jibo.Cloud.Infrastructure.Weather; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Jibo.Cloud.Tests.Infrastructure; + +public sealed class ProviderCachingTests +{ + [Fact] + public async Task OpenWeatherReportProvider_ReusesCachedWeatherAndGeocodeResponses() + { + var handler = new CountingHttpMessageHandler(message => + { + var path = message.RequestUri?.AbsolutePath ?? string.Empty; + return path switch + { + "/geo/1.0/direct" => JsonResponse( + """[{"name":"Boston","state":"Massachusetts","country":"US","lat":42.3601,"lon":-71.0589}]"""), + "/data/2.5/weather" => JsonResponse( + """{"name":"Boston","weather":[{"main":"Clouds","description":"overcast clouds"}],"main":{"temp":70.2,"temp_max":72.9,"temp_min":66.1}}"""), + _ => new HttpResponseMessage(HttpStatusCode.NotFound) + }; + }); + var provider = new OpenWeatherReportProvider( + new HttpClient(handler), + new OpenWeatherOptions + { + ApiKey = "test-key", + CurrentCacheTtlSeconds = 300, + GeocodeCacheTtlSeconds = 300, + FailureCacheTtlSeconds = 30 + }, + NullLogger.Instance); + + var request = new WeatherReportRequest("Boston,US", null, null, false, false, 0); + var first = await provider.GetReportAsync(request); + var second = await provider.GetReportAsync(request); + + Assert.NotNull(first); + Assert.NotNull(second); + Assert.Equal(1, handler.GetCallCount("/geo/1.0/direct")); + Assert.Equal(1, handler.GetCallCount("/data/2.5/weather")); + } + + [Fact] + public async Task NewsApiBriefingProvider_ReusesCachedHeadlinesForIdenticalRequests() + { + var handler = new CountingHttpMessageHandler(message => + { + var path = message.RequestUri?.AbsolutePath ?? string.Empty; + return path switch + { + "/v2/top-headlines" => JsonResponse( + """{"status":"ok","articles":[{"title":"Robotics team wins regional title","description":"A big local victory.","source":{"name":"AP News"},"url":"https://example.com/a"}]}"""), + _ => new HttpResponseMessage(HttpStatusCode.NotFound) + }; + }); + var provider = new NewsApiBriefingProvider( + new HttpClient(handler), + new NewsApiOptions + { + ApiKey = "test-key", + CacheTtlSeconds = 300, + FailureCacheTtlSeconds = 30 + }, + NullLogger.Instance); + + var request = new NewsBriefingRequest(["sports"], 3); + var first = await provider.GetBriefingAsync(request); + var second = await provider.GetBriefingAsync(request); + + Assert.NotNull(first); + Assert.NotNull(second); + Assert.Equal(1, handler.GetCallCount("/v2/top-headlines")); + } + + private static HttpResponseMessage JsonResponse(string body) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + } + + private sealed class CountingHttpMessageHandler(Func responseFactory) + : HttpMessageHandler + { + private readonly Dictionary callsByPath = new(StringComparer.OrdinalIgnoreCase); + private readonly object gate = new(); + + public int GetCallCount(string path) + { + lock (gate) + { + return callsByPath.TryGetValue(path, out var count) ? count : 0; + } + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var path = request.RequestUri?.AbsolutePath ?? string.Empty; + lock (gate) + { + callsByPath[path] = callsByPath.TryGetValue(path, out var count) ? count + 1 : 1; + } + + return Task.FromResult(responseFactory(request)); + } + } +} diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs index dc4213e..b9ff01b 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs @@ -2665,6 +2665,81 @@ public sealed class JiboInteractionServiceTests Assert.DoesNotContain("future cloud integration", decision.ReplyText, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task BuildDecisionAsync_TellMeTheNews_WithProvider_UsesProviderHeadlines() + { + var provider = new CapturingNewsBriefingProvider + { + Snapshot = new NewsBriefingSnapshot( + [ + new NewsHeadline("Local robotics team unveils weather-ready helper"), + new NewsHeadline("Community makerspace hosts weekend AI expo") + ], + "NewsAPI") + }; + var service = CreateService(newsBriefingProvider: provider); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "tell me the news", + NormalizedTranscript = "tell me the news" + }); + + Assert.Equal("news", decision.IntentName); + Assert.Equal("news", decision.SkillName); + Assert.Equal("news", decision.SkillPayload!["skillId"]); + Assert.Equal("news", decision.SkillPayload["cloudSkill"]); + Assert.Equal("runtime-news", decision.SkillPayload["mim_id"]); + Assert.Equal("NewsAPI", decision.SkillPayload["news_source"]); + Assert.Equal(2, decision.SkillPayload["news_headline_count"]); + Assert.Contains("Local robotics team unveils weather-ready helper", decision.ReplyText, StringComparison.OrdinalIgnoreCase); + Assert.NotNull(provider.LastRequest); + Assert.Equal(3, provider.LastRequest!.MaxHeadlines); + } + + [Fact] + public async Task BuildDecisionAsync_TellMeTheNews_WithMemoryPreference_UsesCategoryHints() + { + var memoryStore = new InMemoryPersonalMemoryStore(); + var provider = new CapturingNewsBriefingProvider + { + Snapshot = new NewsBriefingSnapshot( + [ + new NewsHeadline("City soccer clubs prepare for summer playoffs") + ], + "NewsAPI") + }; + var service = CreateService(memoryStore, newsBriefingProvider: provider); + + await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "i like sports", + NormalizedTranscript = "i like sports", + Attributes = new Dictionary + { + ["accountId"] = "acct-a", + ["loopId"] = "loop-a" + }, + DeviceId = "device-a" + }); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "tell me the news", + NormalizedTranscript = "tell me the news", + Attributes = new Dictionary + { + ["accountId"] = "acct-a", + ["loopId"] = "loop-a" + }, + DeviceId = "device-a" + }); + + Assert.Equal("news", decision.IntentName); + Assert.NotNull(provider.LastRequest); + Assert.Contains("sports", provider.LastRequest!.PreferredCategories, StringComparer.OrdinalIgnoreCase); + } + [Fact] public async Task BuildDecisionAsync_CloudVersion_UsesSharedBuildInfo() { @@ -2786,13 +2861,15 @@ public sealed class JiboInteractionServiceTests private static JiboInteractionService CreateService( IPersonalMemoryStore? personalMemoryStore = null, - IWeatherReportProvider? weatherReportProvider = null) + IWeatherReportProvider? weatherReportProvider = null, + INewsBriefingProvider? newsBriefingProvider = null) { return new JiboInteractionService( new JiboExperienceContentCache(new InMemoryJiboExperienceContentRepository()), new FirstItemRandomizer(), personalMemoryStore ?? new InMemoryPersonalMemoryStore(), - weatherReportProvider); + weatherReportProvider, + newsBriefingProvider); } private sealed class FirstItemRandomizer : IJiboRandomizer @@ -2817,4 +2894,19 @@ public sealed class JiboInteractionServiceTests return Task.FromResult(Snapshot); } } + + private sealed class CapturingNewsBriefingProvider : INewsBriefingProvider + { + public NewsBriefingRequest? LastRequest { get; private set; } + + public NewsBriefingSnapshot? Snapshot { get; init; } + + public Task GetBriefingAsync( + NewsBriefingRequest request, + CancellationToken cancellationToken = default) + { + LastRequest = request; + return Task.FromResult(Snapshot); + } + } } diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs index 1c4c19a..ba4ff11 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs @@ -1970,6 +1970,57 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("announcement", meta.GetProperty("mim_type").GetString()); } + [Fact] + public async Task ClientAsr_TellMeTheNews_WithProvider_UsesProviderHeadlinesInSpeech() + { + var service = CreateService( + new InMemoryCloudStateStore(), + newsBriefingProvider: new StubNewsBriefingProvider( + new NewsBriefingSnapshot( + [ + new NewsHeadline("Robotics club opens a new community lab"), + new NewsHeadline("Local students win a regional coding challenge") + ], + "NewsAPI"))); + + await service.HandleMessageAsync(new WebSocketMessageEnvelope + { + HostName = "neo-hub.jibo.com", + Path = "/listen", + Kind = "neo-hub-listen", + Token = "hub-news-provider-token", + Text = """{"type":"LISTEN","transID":"trans-news-provider","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-news-provider-token", + Text = """{"type":"CLIENT_ASR","transID":"trans-news-provider","data":{"text":"tell me the news"}}""" + }); + + Assert.Equal(3, replies.Count); + Assert.Equal("LISTEN", ReadReplyType(replies[0])); + Assert.Equal("EOS", ReadReplyType(replies[1])); + Assert.Equal("SKILL_ACTION", ReadReplyType(replies[2])); + + using var speakPayload = JsonDocument.Parse(replies[2].Text!); + var esml = speakPayload.RootElement + .GetProperty("data") + .GetProperty("action") + .GetProperty("config") + .GetProperty("jcp") + .GetProperty("config") + .GetProperty("play") + .GetProperty("esml") + .GetString(); + + Assert.Contains("Robotics club opens a new community lab", esml, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Source: NewsAPI.", esml, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task ClientAsr_HowIsTheWeather_EmitsSpokenWeatherFallbackWithoutRedirect() { @@ -3998,7 +4049,8 @@ public sealed class JiboWebSocketServiceTests private static JiboWebSocketService CreateService( InMemoryCloudStateStore stateStore, - IWeatherReportProvider? weatherReportProvider = null) + IWeatherReportProvider? weatherReportProvider = null, + INewsBriefingProvider? newsBriefingProvider = null) { var contentRepository = new InMemoryJiboExperienceContentRepository(); var contentCache = new JiboExperienceContentCache(contentRepository); @@ -4006,7 +4058,8 @@ public sealed class JiboWebSocketServiceTests contentCache, new DefaultJiboRandomizer(), new InMemoryPersonalMemoryStore(), - weatherReportProvider); + weatherReportProvider, + newsBriefingProvider); var conversationBroker = new DemoConversationBroker(interactionService); var sttSelector = new DefaultSttStrategySelector( [ @@ -4035,4 +4088,14 @@ public sealed class JiboWebSocketServiceTests return Task.FromResult(snapshot); } } + + private sealed class StubNewsBriefingProvider(NewsBriefingSnapshot snapshot) : INewsBriefingProvider + { + public Task GetBriefingAsync( + NewsBriefingRequest request, + CancellationToken cancellationToken = default) + { + return Task.FromResult(snapshot); + } + } }