diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.NewsFormatting.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.NewsFormatting.cs new file mode 100644 index 0000000..2203f22 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.NewsFormatting.cs @@ -0,0 +1,196 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Jibo.Cloud.Application.Abstractions; +using Jibo.Cloud.Domain.Models; +using Jibo.Runtime.Abstractions; + +namespace Jibo.Cloud.Application.Services; + +public sealed partial class JiboInteractionService +{ + private static JiboInteractionDecision BuildNewsDecision( + string spokenBriefing, + string? sourceName, + IReadOnlyList? categories, + int? headlineCount, + IReadOnlyDictionary? providerDiagnostics = null) + { + var speakableBriefing = NormalizeNewsSpeechText(spokenBriefing); + var payload = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["skillId"] = "news", + ["cloudSkill"] = "news", + ["mim_id"] = "runtime-news", + ["mim_type"] = "announcement", + ["prompt_id"] = "NewsHeadline_AN_01", + ["prompt_sub_category"] = "AN", + ["esml"] = + $"{EscapeForEsml(speakableBriefing)}" + }; + + 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(); + + if (providerDiagnostics is not null) + foreach (var (key, value) in providerDiagnostics) + payload[key] = value; + + return new JiboInteractionDecision("news", spokenBriefing, "news", payload); + } + + private static JiboInteractionDecision BuildProviderNewsDecision( + NewsBriefingSnapshot snapshot, + JiboExperienceCatalog catalog, + IReadOnlyList preferredCategories, + int requestedHeadlineCount) + { + 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, + 0, + BuildNewsProviderDiagnostics( + "provider_empty", + preferredCategories, + requestedHeadlineCount, + 0)); + + var leadIn = BuildNewsLeadIn(snapshot.SourceName, preferredCategories); + var joinedHeadlines = string.Join(" ", headlines.Select(static headline => $"{headline.Title}.")); + var outroTemplate = ChooseShortestTemplate(catalog.NewsOutroReplies) ?? "And that's the news."; + var spokenBriefing = $"{leadIn} {joinedHeadlines} {outroTemplate}".Trim(); + return BuildNewsDecision( + spokenBriefing, + snapshot.SourceName, + preferredCategories, + headlines.Length, + BuildNewsProviderDiagnostics( + "provider_success", + preferredCategories, + requestedHeadlineCount, + headlines.Length)); + } + + private static IReadOnlyDictionary BuildNewsProviderDiagnostics( + string status, + IReadOnlyList preferredCategories, + int requestedHeadlineCount, + int? resolvedHeadlineCount = null, + string? providerMessage = null, + int? providerHttpStatusCode = null, + string? providerEndpoint = null, + string? providerErrorCode = null) + { + var diagnostics = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["news_provider_status"] = status, + ["news_provider_requested_headlines"] = requestedHeadlineCount, + ["news_provider_preferred_categories"] = preferredCategories.Count > 0 + ? [.. preferredCategories] + : Array.Empty() + }; + + if (resolvedHeadlineCount is not null) + diagnostics["news_provider_resolved_headlines"] = resolvedHeadlineCount.Value; + + if (!string.IsNullOrWhiteSpace(providerMessage)) diagnostics["news_provider_message"] = providerMessage; + + if (providerHttpStatusCode is not null) diagnostics["news_provider_http_status"] = providerHttpStatusCode.Value; + + if (!string.IsNullOrWhiteSpace(providerEndpoint)) diagnostics["news_provider_endpoint"] = providerEndpoint; + + if (!string.IsNullOrWhiteSpace(providerErrorCode)) diagnostics["news_provider_error_code"] = providerErrorCode; + + return diagnostics; + } + + private static string ResolveNewsProviderStatus(NewsBriefingSnapshot? snapshot) + { + var providerStatus = snapshot?.ProviderStatus?.Trim().ToLowerInvariant(); + return providerStatus switch + { + "success" => "provider_success", + "exception" => "provider_exception", + "http_error" or "api_error" or "schema_error" => "provider_error", + _ => "provider_empty" + }; + } + + 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 static string NormalizeNewsSpeechText(string text) + { + if (string.IsNullOrWhiteSpace(text)) return text; + + // Expand "AI" so Nimbus TTS does not collapse it to a single "aye" sound. + var normalized = Regex.Replace( + text, + @"\bA\.?\s*I\.?\b", + "artificial intelligence", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + return NormalizeLocationForSpeech(normalized); + } + + 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)]; + } + + 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); + } +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.ReportFormatting.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.ReportFormatting.cs index 16feec7..8535d49 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.ReportFormatting.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.ReportFormatting.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Linq; +using System.Text.RegularExpressions; using Jibo.Cloud.Application.Abstractions; using Jibo.Cloud.Domain.Models; @@ -566,6 +567,23 @@ public sealed partial class JiboInteractionService return template.Trim(); } + private static string NormalizeLocationForSpeech(string text) + { + if (string.IsNullOrWhiteSpace(text)) return text; + + return Regex.Replace( + text, + @"\b(?[A-Z]{2,3})\b", + static match => + { + var token = match.Groups["token"].Value; + if (!SpokenAbbreviationTokens.Contains(token)) return token; + + return string.Join(".", token.ToCharArray()) + "."; + }, + RegexOptions.CultureInvariant); + } + private static string ChooseCommuteServiceDownReply(JiboExperienceCatalog catalog) { var template = ChooseWeatherTemplate( 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 db282a1..ca77439 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 @@ -1420,207 +1420,6 @@ public sealed partial class JiboInteractionService( }); } - private static JiboInteractionDecision BuildNewsDecision( - string spokenBriefing, - string? sourceName, - IReadOnlyList? categories, - int? headlineCount, - IReadOnlyDictionary? providerDiagnostics = null) - { - var speakableBriefing = NormalizeNewsSpeechText(spokenBriefing); - var payload = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["skillId"] = "news", - ["cloudSkill"] = "news", - ["mim_id"] = "runtime-news", - ["mim_type"] = "announcement", - ["prompt_id"] = "NewsHeadline_AN_01", - ["prompt_sub_category"] = "AN", - ["esml"] = - $"{EscapeForEsml(speakableBriefing)}" - }; - - 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(); - - if (providerDiagnostics is not null) - foreach (var (key, value) in providerDiagnostics) - payload[key] = value; - - return new JiboInteractionDecision("news", spokenBriefing, "news", payload); - } - - private static JiboInteractionDecision BuildProviderNewsDecision( - NewsBriefingSnapshot snapshot, - JiboExperienceCatalog catalog, - IReadOnlyList preferredCategories, - int requestedHeadlineCount) - { - 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, - 0, - BuildNewsProviderDiagnostics( - "provider_empty", - preferredCategories, - requestedHeadlineCount, - 0)); - - var leadIn = BuildNewsLeadIn(snapshot.SourceName, preferredCategories); - var joinedHeadlines = string.Join(" ", headlines.Select(static headline => $"{headline.Title}.")); - var outroTemplate = ChooseShortestTemplate(catalog.NewsOutroReplies) ?? "And that's the news."; - var spokenBriefing = $"{leadIn} {joinedHeadlines} {outroTemplate}".Trim(); - return BuildNewsDecision( - spokenBriefing, - snapshot.SourceName, - preferredCategories, - headlines.Length, - BuildNewsProviderDiagnostics( - "provider_success", - preferredCategories, - requestedHeadlineCount, - headlines.Length)); - } - - private static IReadOnlyDictionary BuildNewsProviderDiagnostics( - string status, - IReadOnlyList preferredCategories, - int requestedHeadlineCount, - int? resolvedHeadlineCount = null, - string? providerMessage = null, - int? providerHttpStatusCode = null, - string? providerEndpoint = null, - string? providerErrorCode = null) - { - var diagnostics = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["news_provider_status"] = status, - ["news_provider_requested_headlines"] = requestedHeadlineCount, - ["news_provider_preferred_categories"] = preferredCategories.Count > 0 - ? [.. preferredCategories] - : Array.Empty() - }; - - if (resolvedHeadlineCount is not null) - diagnostics["news_provider_resolved_headlines"] = resolvedHeadlineCount.Value; - - if (!string.IsNullOrWhiteSpace(providerMessage)) diagnostics["news_provider_message"] = providerMessage; - - if (providerHttpStatusCode is not null) diagnostics["news_provider_http_status"] = providerHttpStatusCode.Value; - - if (!string.IsNullOrWhiteSpace(providerEndpoint)) diagnostics["news_provider_endpoint"] = providerEndpoint; - - if (!string.IsNullOrWhiteSpace(providerErrorCode)) diagnostics["news_provider_error_code"] = providerErrorCode; - - return diagnostics; - } - - private static string ResolveNewsProviderStatus(NewsBriefingSnapshot? snapshot) - { - var providerStatus = snapshot?.ProviderStatus?.Trim().ToLowerInvariant(); - return providerStatus switch - { - "success" => "provider_success", - "exception" => "provider_exception", - "http_error" or "api_error" or "schema_error" => "provider_error", - _ => "provider_empty" - }; - } - - 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 static string NormalizeNewsSpeechText(string text) - { - if (string.IsNullOrWhiteSpace(text)) return text; - - // Expand "AI" so Nimbus TTS does not collapse it to a single "aye" sound. - var normalized = Regex.Replace( - text, - @"\bA\.?\s*I\.?\b", - "artificial intelligence", - RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - return NormalizeLocationForSpeech(normalized); - } - - private static string NormalizeLocationForSpeech(string text) - { - if (string.IsNullOrWhiteSpace(text)) return text; - - return Regex.Replace( - text, - @"\b(?[A-Z]{2,3})\b", - static match => - { - var token = match.Groups["token"].Value; - if (!SpokenAbbreviationTokens.Contains(token)) return token; - - return string.Join(".", token.ToCharArray()) + "."; - }, - RegexOptions.CultureInvariant); - } - - 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)]; - } - - 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( JiboExperienceCatalog catalog, @@ -4858,3 +4657,4 @@ public sealed record JiboInteractionDecision( IDictionary? SkillPayload = null, IDictionary? ContextUpdates = null); +