Add commit message generation prompt

This commit is contained in:
Jacob Dubin
2026-05-10 00:30:31 -05:00
parent 8ae6d86a8c
commit 80c4ae38fb
13 changed files with 884 additions and 25 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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
}
}
}

View File

@@ -0,0 +1,23 @@
namespace Jibo.Cloud.Application.Abstractions;
public interface INewsBriefingProvider
{
Task<NewsBriefingSnapshot?> GetBriefingAsync(
NewsBriefingRequest request,
CancellationToken cancellationToken = default);
}
public sealed record NewsBriefingRequest(
IReadOnlyList<string> 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<NewsHeadline> Headlines,
string? SourceName = null);

View File

@@ -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<JiboInteractionDecision> 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<JiboInteractionDecision> BuildNewsDecisionAsync(
TurnContext turn,
string transcript,
JiboExperienceCatalog catalog,
CancellationToken cancellationToken)
{
var briefing = randomizer.Choose(catalog.NewsBriefings);
return new JiboInteractionDecision(
"news",
briefing,
"news",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
var preferredCategories = ResolvePreferredNewsCategories(turn, transcript);
if (newsBriefingProvider is not null)
{
try
{
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<string>? categories,
int? headlineCount)
{
var payload = new Dictionary<string, object?>(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<string> 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<string> 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<string> ResolvePreferredNewsCategories(TurnContext turn, string transcript)
{
var categories = new List<string>();
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<string> 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<string> 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 =
[

View File

@@ -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<IWeatherReportProvider, OpenWeatherReportProvider>();
services.AddHttpClient<INewsBriefingProvider, NewsApiBriefingProvider>();
var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"]
?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json");
services.AddSingleton<ICloudStateStore>(_ => new InMemoryCloudStateStore(statePersistencePath));

View File

@@ -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<NewsApiBriefingProvider> logger)
: INewsBriefingProvider
{
private readonly ConcurrentDictionary<string, CacheEntry<NewsBriefingSnapshot?>> briefingCache = new(StringComparer.OrdinalIgnoreCase);
public async Task<NewsBriefingSnapshot?> 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<NewsHeadline>(requestedHeadlineCount);
var seenTitles = new HashSet<string>(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<string> ResolveCategories(IReadOnlyList<string> 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<string> categories, int requestedHeadlineCount)
{
var categoryKey = string.Join(",", categories.Select(category => category.Trim().ToLowerInvariant()));
return $"{options.Country.Trim().ToLowerInvariant()}|{requestedHeadlineCount}|{categoryKey}";
}
private static bool TryGetCachedValue<T>(
ConcurrentDictionary<string, CacheEntry<T>> 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<T>(
ConcurrentDictionary<string, CacheEntry<T>> cache,
string key,
T value,
int ttlSeconds)
{
cache[key] = new CacheEntry<T>(
value,
DateTimeOffset.UtcNow.AddSeconds(Math.Max(1, ttlSeconds)));
}
private static readonly HashSet<string> 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>(T Value, DateTimeOffset ExpiresUtc);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<OpenWeatherReportProvider> logger)
: IWeatherReportProvider
{
private readonly ConcurrentDictionary<string, CacheEntry<LocationPoint?>> geocodeCache = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, CacheEntry<WeatherReportSnapshot?>> weatherCache = new(StringComparer.OrdinalIgnoreCase);
public async Task<WeatherReportSnapshot?> 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<WeatherReportSnapshot?> 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<T>(
ConcurrentDictionary<string, CacheEntry<T>> 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<T>(
ConcurrentDictionary<string, CacheEntry<T>> cache,
string key,
T value,
int ttlSeconds)
{
cache[key] = new CacheEntry<T>(
value,
DateTimeOffset.UtcNow.AddSeconds(Math.Max(1, ttlSeconds)));
}
private readonly record struct LocationPoint(double Latitude, double Longitude, string? DisplayName);
private sealed record CacheEntry<T>(T Value, DateTimeOffset ExpiresUtc);
private sealed record ForecastEntry(
DateTimeOffset LocalTime,
int? Temperature,

View File

@@ -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<OpenWeatherReportProvider>.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<NewsApiBriefingProvider>.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<HttpRequestMessage, HttpResponseMessage> responseFactory)
: HttpMessageHandler
{
private readonly Dictionary<string, int> 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<HttpResponseMessage> 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));
}
}
}

View File

@@ -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<string, object?>
{
["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<string, object?>
{
["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<NewsBriefingSnapshot?> GetBriefingAsync(
NewsBriefingRequest request,
CancellationToken cancellationToken = default)
{
LastRequest = request;
return Task.FromResult(Snapshot);
}
}
}

View File

@@ -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<WeatherReportSnapshot?>(snapshot);
}
}
private sealed class StubNewsBriefingProvider(NewsBriefingSnapshot snapshot) : INewsBriefingProvider
{
public Task<NewsBriefingSnapshot?> GetBriefingAsync(
NewsBriefingRequest request,
CancellationToken cancellationToken = default)
{
return Task.FromResult<NewsBriefingSnapshot?>(snapshot);
}
}
}