Add commit message generation prompt
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
{
|
||||
["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<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 =
|
||||
[
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user