From cb27f8843ba32193078a8a3dc2d865f8a9054915 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 20 Jun 2023 18:26:11 -0400 Subject: [PATCH] Add CultureInfo to SKContext (#1519) ### Motivation and Context Enable callers to configure culture information that flows into native functions and that controls how implicit parsing / formatting is performed. https://github.com/microsoft/semantic-kernel/issues/1226 https://github.com/microsoft/semantic-kernel/issues/1374 ### Description The culture defaults to CurrentCulture but can be explicitly set to change it. Native functions can declare a CultureInfo or IFormatProvider argument, and the culture from the context will implicitly flow as the value of that argument, such that functions can then naturally use it for culture-related customization. The culture from the context is also used for all implicit parsing / formatting operations performed as part of function invocation. ### Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] The PR follows SK Contribution Guidelines (https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) - [x] The code follows the .NET coding conventions (https://learn.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions) verified with `dotnet format` - [x] All unit tests pass, and I have added new tests where possible - [ ] I didn't break anyone :smile: Co-authored-by: Shawn Callegari <36091529+shawncal@users.noreply.github.com> --- .../Orchestration/SKContext.cs | 21 +++++- .../CoreSkills/TimeSkillTests.cs | 6 +- .../SkillDefinition/SKFunctionTests2.cs | 31 ++++++++ .../SemanticKernel/CoreSkills/TextSkill.cs | 6 +- .../SemanticKernel/CoreSkills/TimeSkill.cs | 71 ++++++++++--------- .../SkillDefinition/SKFunction.cs | 36 ++++++---- 6 files changed, 116 insertions(+), 55 deletions(-) diff --git a/dotnet/src/SemanticKernel.Abstractions/Orchestration/SKContext.cs b/dotnet/src/SemanticKernel.Abstractions/Orchestration/SKContext.cs index a6173d9a9892..0b6c873652d6 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Orchestration/SKContext.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Orchestration/SKContext.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.Threading; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -17,6 +18,11 @@ namespace Microsoft.SemanticKernel.Orchestration; [DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class SKContext { + /// + /// The culture currently associated with this context. + /// + private CultureInfo _culture; + /// /// Print the processed input, aka the current data after any processing occurred. /// @@ -54,6 +60,15 @@ public sealed class SKContext /// public CancellationToken CancellationToken { get; } + /// + /// The culture currently associated with this context. + /// + public CultureInfo Culture + { + get => this._culture; + set => this._culture = value ?? CultureInfo.CurrentCulture; + } + /// /// Shortcut into user data, access variables by name /// @@ -138,6 +153,7 @@ public SKContext( this.Skills = skills ?? NullReadOnlySkillCollection.Instance; this.Log = logger ?? NullLogger.Instance; this.CancellationToken = cancellationToken; + this._culture = CultureInfo.CurrentCulture; } /// @@ -180,9 +196,10 @@ public SKContext Clone() logger: this.Log, cancellationToken: this.CancellationToken) { + Culture = this.Culture, ErrorOccurred = this.ErrorOccurred, LastErrorDescription = this.LastErrorDescription, - LastException = this.LastException + LastException = this.LastException, }; } @@ -209,6 +226,8 @@ private string DebuggerDisplay display += $", Memory = {memory.GetType().Name}"; } + display += $", Culture = {this.Culture.EnglishName}"; + return display; } } diff --git a/dotnet/src/SemanticKernel.UnitTests/CoreSkills/TimeSkillTests.cs b/dotnet/src/SemanticKernel.UnitTests/CoreSkills/TimeSkillTests.cs index 51cd59918f16..b38e32811745 100644 --- a/dotnet/src/SemanticKernel.UnitTests/CoreSkills/TimeSkillTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/CoreSkills/TimeSkillTests.cs @@ -36,7 +36,7 @@ public void DaysAgo() double interval = 2; DateTime expected = DateTime.Now.AddDays(-interval); var skill = new TimeSkill(); - string result = skill.DaysAgo(interval); + string result = skill.DaysAgo(interval, CultureInfo.CurrentCulture); DateTime returned = DateTime.Parse(result, CultureInfo.CurrentCulture); Assert.Equal(expected.Day, returned.Day); Assert.Equal(expected.Month, returned.Month); @@ -48,7 +48,7 @@ public void Day() { string expected = DateTime.Now.ToString("dd", CultureInfo.CurrentCulture); var skill = new TimeSkill(); - string result = skill.Day(); + string result = skill.Day(CultureInfo.CurrentCulture); Assert.Equal(expected, result); Assert.True(int.TryParse(result, out _)); } @@ -76,7 +76,7 @@ public void LastMatchingDay(DayOfWeek dayName) Assert.True(found); var skill = new TimeSkill(); - string result = skill.DateMatchingLastDayName(dayName); + string result = skill.DateMatchingLastDayName(dayName, CultureInfo.CurrentCulture); DateTime returned = DateTime.Parse(result, CultureInfo.CurrentCulture); Assert.Equal(date.Day, returned.Day); Assert.Equal(date.Month, returned.Month); diff --git a/dotnet/src/SemanticKernel.UnitTests/SkillDefinition/SKFunctionTests2.cs b/dotnet/src/SemanticKernel.UnitTests/SkillDefinition/SKFunctionTests2.cs index c8126431a013..0f1840ea2b87 100644 --- a/dotnet/src/SemanticKernel.UnitTests/SkillDefinition/SKFunctionTests2.cs +++ b/dotnet/src/SemanticKernel.UnitTests/SkillDefinition/SKFunctionTests2.cs @@ -852,6 +852,37 @@ static async Task AssertResult(Delegate d, SKContext context, string expected) await AssertResult((Uri input) => new Uri(input, "kernel"), context, "http://example.com/kernel"); } + [Fact] + public async Task ItUsesContextCultureForParsingFormatting() + { + // Arrange + var context = this.MockContext(""); + ISKFunction func = SKFunction.FromNativeFunction((double input) => input * 2, functionName: "Test"); + + // Act/Assert + + context.Culture = new CultureInfo("fr-FR"); + context.Variables.Update("12,34"); // tries first to parse with the specified culture + context = await func.InvokeAsync(context); + Assert.Equal("24,68", context.Variables.Input); + + context.Culture = new CultureInfo("fr-FR"); + context.Variables.Update("12.34"); // falls back to invariant culture + context = await func.InvokeAsync(context); + Assert.Equal("24,68", context.Variables.Input); + + context.Culture = new CultureInfo("en-US"); + context.Variables.Update("12.34"); // works with current culture + context = await func.InvokeAsync(context); + Assert.Equal("24.68", context.Variables.Input); + + context.Culture = new CultureInfo("en-US"); + context.Variables.Update("12,34"); // not parsable with current or invariant culture + context = await func.InvokeAsync(context); + Assert.True(context.ErrorOccurred); + Assert.IsType(context.LastException); + } + [Fact] public async Task ItThrowsWhenItFailsToConvertAnArgument() { diff --git a/dotnet/src/SemanticKernel/CoreSkills/TextSkill.cs b/dotnet/src/SemanticKernel/CoreSkills/TextSkill.cs index 20c84baef792..b484d3a773de 100644 --- a/dotnet/src/SemanticKernel/CoreSkills/TextSkill.cs +++ b/dotnet/src/SemanticKernel/CoreSkills/TextSkill.cs @@ -68,9 +68,10 @@ public sealed class TextSkill /// {{text.uppercase $input}} => "HELLO WORLD" /// /// The string to convert. + /// An object that supplies culture-specific casing rules. /// The converted string. [SKFunction, Description("Convert a string to uppercase.")] - public string Uppercase(string input) => input.ToUpper(CultureInfo.CurrentCulture); + public string Uppercase(string input, CultureInfo? cultureInfo = null) => input.ToUpper(cultureInfo); /// /// Convert a string to lowercase. @@ -80,9 +81,10 @@ public sealed class TextSkill /// {{text.lowercase $input}} => "hello world" /// /// The string to convert. + /// An object that supplies culture-specific casing rules. /// The converted string. [SKFunction, Description("Convert a string to lowercase.")] - public string Lowercase(string input) => input.ToLower(CultureInfo.CurrentCulture); + public string Lowercase(string input, CultureInfo? cultureInfo = null) => input.ToLower(cultureInfo); /// /// Get the length of a string. Returns 0 if null or empty diff --git a/dotnet/src/SemanticKernel/CoreSkills/TimeSkill.cs b/dotnet/src/SemanticKernel/CoreSkills/TimeSkill.cs index 8a8399a33f8a..92319a735207 100644 --- a/dotnet/src/SemanticKernel/CoreSkills/TimeSkill.cs +++ b/dotnet/src/SemanticKernel/CoreSkills/TimeSkill.cs @@ -2,7 +2,6 @@ using System; using System.ComponentModel; -using System.Globalization; using Microsoft.SemanticKernel.SkillDefinition; namespace Microsoft.SemanticKernel.CoreSkills; @@ -49,9 +48,9 @@ public sealed class TimeSkill /// /// The current date [SKFunction, Description("Get the current date")] - public string Date() => + public string Date(IFormatProvider? formatProvider = null) => // Example: Sunday, 12 January, 2025 - DateTimeOffset.Now.ToString("D", CultureInfo.CurrentCulture); + DateTimeOffset.Now.ToString("D", formatProvider); /// /// Get the current date @@ -61,7 +60,9 @@ public string Date() => /// /// The current date [SKFunction, Description("Get the current date")] - public string Today() => this.Date(); + public string Today(IFormatProvider? formatProvider = null) => + // Example: Sunday, 12 January, 2025 + this.Date(formatProvider); /// /// Get the current date and time in the local time zone" @@ -71,9 +72,9 @@ public string Date() => /// /// The current date and time in the local time zone [SKFunction, Description("Get the current date and time in the local time zone")] - public string Now() => + public string Now(IFormatProvider? formatProvider = null) => // Sunday, January 12, 2025 9:15 PM - DateTimeOffset.Now.ToString("f", CultureInfo.CurrentCulture); + DateTimeOffset.Now.ToString("f", formatProvider); /// /// Get the current UTC date and time @@ -83,9 +84,9 @@ public string Now() => /// /// The current UTC date and time [SKFunction, Description("Get the current UTC date and time")] - public string UtcNow() => + public string UtcNow(IFormatProvider? formatProvider = null) => // Sunday, January 13, 2025 5:15 AM - DateTimeOffset.UtcNow.ToString("f", CultureInfo.CurrentCulture); + DateTimeOffset.UtcNow.ToString("f", formatProvider); /// /// Get the current time @@ -95,9 +96,9 @@ public string UtcNow() => /// /// The current time [SKFunction, Description("Get the current time")] - public string Time() => + public string Time(IFormatProvider? formatProvider = null) => // Example: 09:15:07 PM - DateTimeOffset.Now.ToString("hh:mm:ss tt", CultureInfo.CurrentCulture); + DateTimeOffset.Now.ToString("hh:mm:ss tt", formatProvider); /// /// Get the current year @@ -107,9 +108,9 @@ public string Time() => /// /// The current year [SKFunction, Description("Get the current year")] - public string Year() => + public string Year(IFormatProvider? formatProvider = null) => // Example: 2025 - DateTimeOffset.Now.ToString("yyyy", CultureInfo.CurrentCulture); + DateTimeOffset.Now.ToString("yyyy", formatProvider); /// /// Get the current month name @@ -119,9 +120,9 @@ public string Year() => /// /// The current month name [SKFunction, Description("Get the current month name")] - public string Month() => + public string Month(IFormatProvider? formatProvider = null) => // Example: January - DateTimeOffset.Now.ToString("MMMM", CultureInfo.CurrentCulture); + DateTimeOffset.Now.ToString("MMMM", formatProvider); /// /// Get the current month number @@ -131,9 +132,9 @@ public string Month() => /// /// The current month number [SKFunction, Description("Get the current month number")] - public string MonthNumber() => + public string MonthNumber(IFormatProvider? formatProvider = null) => // Example: 01 - DateTimeOffset.Now.ToString("MM", CultureInfo.CurrentCulture); + DateTimeOffset.Now.ToString("MM", formatProvider); /// /// Get the current day of the month @@ -143,9 +144,9 @@ public string MonthNumber() => /// /// The current day of the month [SKFunction, Description("Get the current day of the month")] - public string Day() => + public string Day(IFormatProvider? formatProvider = null) => // Example: 12 - DateTimeOffset.Now.ToString("dd", CultureInfo.CurrentCulture); + DateTimeOffset.Now.ToString("dd", formatProvider); /// /// Get the date a provided number of days in the past @@ -157,8 +158,8 @@ public string Day() => /// The date the provided number of days before today [SKFunction] [Description("Get the date offset by a provided number of days from today")] - public string DaysAgo([Description("The number of days to offset from today"), SKName("input")] double daysOffset) => - DateTimeOffset.Now.AddDays(-daysOffset).ToString("D", CultureInfo.CurrentCulture); + public string DaysAgo([Description("The number of days to offset from today"), SKName("input")] double daysOffset, IFormatProvider? formatProvider = null) => + DateTimeOffset.Now.AddDays(-daysOffset).ToString("D", formatProvider); /// /// Get the current day of the week @@ -168,9 +169,9 @@ public string DaysAgo([Description("The number of days to offset from today"), S /// /// The current day of the week [SKFunction, Description("Get the current day of the week")] - public string DayOfWeek() => + public string DayOfWeek(IFormatProvider? formatProvider = null) => // Example: Sunday - DateTimeOffset.Now.ToString("dddd", CultureInfo.CurrentCulture); + DateTimeOffset.Now.ToString("dddd", formatProvider); /// /// Get the current clock hour @@ -180,9 +181,9 @@ public string DayOfWeek() => /// /// The current clock hour [SKFunction, Description("Get the current clock hour")] - public string Hour() => + public string Hour(IFormatProvider? formatProvider = null) => // Example: 9 PM - DateTimeOffset.Now.ToString("h tt", CultureInfo.CurrentCulture); + DateTimeOffset.Now.ToString("h tt", formatProvider); /// /// Get the current clock 24-hour number @@ -192,9 +193,9 @@ public string Hour() => /// /// The current clock 24-hour number [SKFunction, Description("Get the current clock 24-hour number")] - public string HourNumber() => + public string HourNumber(IFormatProvider? formatProvider = null) => // Example: 21 - DateTimeOffset.Now.ToString("HH", CultureInfo.CurrentCulture); + DateTimeOffset.Now.ToString("HH", formatProvider); /// /// Get the date of the previous day matching the supplied day name @@ -206,7 +207,9 @@ public string HourNumber() => /// dayName is not a recognized name of a day of the week [SKFunction] [Description("Get the date of the last day matching the supplied week day name in English. Example: Che giorno era 'Martedi' scorso -> dateMatchingLastDayName 'Tuesday' => Tuesday, 16 May, 2023")] - public string DateMatchingLastDayName([Description("The day name to match"), SKName("input")] DayOfWeek dayName) + public string DateMatchingLastDayName( + [Description("The day name to match"), SKName("input")] DayOfWeek dayName, + IFormatProvider? formatProvider = null) { DateTimeOffset dateTime = DateTimeOffset.Now; @@ -220,7 +223,7 @@ public string DateMatchingLastDayName([Description("The day name to match"), SKN } } - return dateTime.ToString("D", CultureInfo.CurrentCulture); + return dateTime.ToString("D", formatProvider); } /// @@ -231,9 +234,9 @@ public string DateMatchingLastDayName([Description("The day name to match"), SKN /// /// The minutes on the current hour [SKFunction, Description("Get the minutes on the current hour")] - public string Minute() => + public string Minute(IFormatProvider? formatProvider = null) => // Example: 15 - DateTimeOffset.Now.ToString("mm", CultureInfo.CurrentCulture); + DateTimeOffset.Now.ToString("mm", formatProvider); /// /// Get the seconds on the current minute @@ -243,9 +246,9 @@ public string Minute() => /// /// The seconds on the current minute [SKFunction, Description("Get the seconds on the current minute")] - public string Second() => + public string Second(IFormatProvider? formatProvider = null) => // Example: 07 - DateTimeOffset.Now.ToString("ss", CultureInfo.CurrentCulture); + DateTimeOffset.Now.ToString("ss", formatProvider); /// /// Get the local time zone offset from UTC @@ -255,9 +258,9 @@ public string Second() => /// /// The local time zone offset from UTC [SKFunction, Description("Get the local time zone offset from UTC")] - public string TimeZoneOffset() => + public string TimeZoneOffset(IFormatProvider? formatProvider = null) => // Example: -08:00 - DateTimeOffset.Now.ToString("%K", CultureInfo.CurrentCulture); + DateTimeOffset.Now.ToString("%K", formatProvider); /// /// Get the local time zone name diff --git a/dotnet/src/SemanticKernel/SkillDefinition/SKFunction.cs b/dotnet/src/SemanticKernel/SkillDefinition/SKFunction.cs index 0c238a7246eb..c1f7be346e04 100644 --- a/dotnet/src/SemanticKernel/SkillDefinition/SKFunction.cs +++ b/dotnet/src/SemanticKernel/SkillDefinition/SKFunction.cs @@ -504,12 +504,12 @@ private static (Func[parameters.Length]; - bool sawFirstParameter = false, hasSKContextParam = false, hasCancellationTokenParam = false, hasLoggerParam = false, hasMemoryParam = false; + bool sawFirstParameter = false, hasSKContextParam = false, hasCancellationTokenParam = false, hasLoggerParam = false, hasMemoryParam = false, hasCultureParam = false; for (int i = 0; i < parameters.Length; i++) { (parameterFuncs[i], ParameterView? parameterView) = GetParameterMarshalerDelegate( method, parameters[i], - ref sawFirstParameter, ref hasSKContextParam, ref hasCancellationTokenParam, ref hasLoggerParam, ref hasMemoryParam); + ref sawFirstParameter, ref hasSKContextParam, ref hasCancellationTokenParam, ref hasLoggerParam, ref hasMemoryParam, ref hasCultureParam); if (parameterView is not null) { stringParameterViews.Add(parameterView); @@ -556,7 +556,7 @@ private static (Func private static (Func, ParameterView?) GetParameterMarshalerDelegate( MethodInfo method, ParameterInfo parameter, - ref bool sawFirstParameter, ref bool hasSKContextParam, ref bool hasCancellationTokenParam, ref bool hasLoggerParam, ref bool hasMemoryParam) + ref bool sawFirstParameter, ref bool hasSKContextParam, ref bool hasCancellationTokenParam, ref bool hasLoggerParam, ref bool hasMemoryParam, ref bool hasCultureParam) { Type type = parameter.ParameterType; @@ -582,6 +582,12 @@ private static (Func, ParameterView?) GetParameterMarshalerD return (static (SKContext ctx) => ctx.Log, null); } + if (type == typeof(CultureInfo) || type == typeof(IFormatProvider)) + { + TrackUniqueParameterType(ref hasCultureParam, method, $"At most one {nameof(CultureInfo)}/{nameof(IFormatProvider)} parameter is permitted."); + return (static (SKContext ctx) => ctx.Culture, null); + } + if (type == typeof(CancellationToken)) { TrackUniqueParameterType(ref hasCancellationTokenParam, method, $"At most one {nameof(CancellationToken)} parameter is permitted."); @@ -590,7 +596,7 @@ private static (Func, ParameterView?) GetParameterMarshalerD // Handle context variables. These are supplied from the SKContext's Variables dictionary. - if (!type.IsByRef && GetParser(type) is Func parser) + if (!type.IsByRef && GetParser(type) is Func parser) { // Use either the parameter's name or an override from an applied SKName attribute. SKNameAttribute? nameAttr = parameter.GetCustomAttribute(inherit: true); @@ -676,7 +682,7 @@ private static (Func, ParameterView?) GetParameterMarshalerD try { - return parser(value, /*culture*/null); + return parser(value, ctx.Culture); } catch (Exception e) when (!e.IsCriticalException()) { @@ -782,14 +788,14 @@ private static (Func, ParameterView?) GetParameterMarshalerD if (!returnType.IsGenericType || returnType.GetGenericTypeDefinition() == typeof(Nullable<>)) { - if (GetFormatter(returnType) is not Func formatter) + if (GetFormatter(returnType) is not Func formatter) { throw GetExceptionForInvalidSignature(method, $"Unknown return type {returnType}"); } return (result, context) => { - context.Variables.UpdateKeepingTrustState(formatter(result, /*culture*/null)); + context.Variables.UpdateKeepingTrustState(formatter(result, context.Culture)); return Task.FromResult(context); }; } @@ -800,12 +806,12 @@ private static (Func, ParameterView?) GetParameterMarshalerD if (returnType.GetGenericTypeDefinition() is Type genericTask && genericTask == typeof(Task<>) && returnType.GetProperty("Result", BindingFlags.Public | BindingFlags.Instance)?.GetGetMethod() is MethodInfo taskResultGetter && - GetFormatter(taskResultGetter.ReturnType) is Func taskResultFormatter) + GetFormatter(taskResultGetter.ReturnType) is Func taskResultFormatter) { return async (result, context) => { await ((Task)ThrowIfNullResult(result)).ConfigureAwait(false); - context.Variables.UpdateKeepingTrustState(taskResultFormatter(taskResultGetter.Invoke(result!, Array.Empty()), /*culture*/null)); + context.Variables.UpdateKeepingTrustState(taskResultFormatter(taskResultGetter.Invoke(result!, Array.Empty()), context.Culture)); return context; }; } @@ -815,13 +821,13 @@ private static (Func, ParameterView?) GetParameterMarshalerD genericValueTask == typeof(ValueTask<>) && returnType.GetMethod("AsTask", BindingFlags.Public | BindingFlags.Instance) is MethodInfo valueTaskAsTask && valueTaskAsTask.ReturnType.GetProperty("Result", BindingFlags.Public | BindingFlags.Instance)?.GetGetMethod() is MethodInfo asTaskResultGetter && - GetFormatter(asTaskResultGetter.ReturnType) is Func asTaskResultFormatter) + GetFormatter(asTaskResultGetter.ReturnType) is Func asTaskResultFormatter) { return async (result, context) => { Task task = (Task)valueTaskAsTask.Invoke(ThrowIfNullResult(result), Array.Empty()); await task.ConfigureAwait(false); - context.Variables.Update(asTaskResultFormatter(asTaskResultGetter.Invoke(task!, Array.Empty()), /*culture*/null)); + context.Variables.Update(asTaskResultFormatter(asTaskResultGetter.Invoke(task!, Array.Empty()), context.Culture)); return context; }; } @@ -868,7 +874,7 @@ private static void TrackUniqueParameterType(ref bool hasParameterType, MethodIn /// Parsing is first attempted using the current culture, and if that fails, it tries again /// with the invariant culture. If both fail, an exception is thrown. /// - private static Func? GetParser(Type targetType) => + private static Func? GetParser(Type targetType) => s_parsers.GetOrAdd(targetType, static targetType => { // Strings just parse to themselves. @@ -933,7 +939,7 @@ private static void TrackUniqueParameterType(ref bool hasParameterType, MethodIn /// /// Formatting is performed in the invariant culture whenever possible. /// - private static Func? GetFormatter(Type targetType) => + private static Func? GetFormatter(Type targetType) => s_formatters.GetOrAdd(targetType, static targetType => { // For nullables, render as the underlying type. @@ -1022,10 +1028,10 @@ private static string SanitizeMetadataName(string methodName) => private static readonly Regex s_invalidNameCharsRegex = new("[^0-9A-Za-z_]"); /// Parser functions for converting strings to parameter types. - private static readonly ConcurrentDictionary?> s_parsers = new(); + private static readonly ConcurrentDictionary?> s_parsers = new(); /// Formatter functions for converting parameter types to strings. - private static readonly ConcurrentDictionary?> s_formatters = new(); + private static readonly ConcurrentDictionary?> s_formatters = new(); #endregion }