mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-02 23:27:21 +00:00
Generated the JSON contracts of the settings, chats, and SWOT analyses at compile time
This commit is contained in:
14 files changed
+180
-26
No files matched your search
@@ -10,13 +10,22 @@ internal static class SwotAnalysisContentPresentation
|
||||
/// Fails the reading of stored data which holds a null where the result expects a value, so that
|
||||
/// the result view never meets one.
|
||||
/// </summary>
|
||||
internal static readonly JsonSerializerOptions JSON_OPTIONS = new()
|
||||
internal static readonly JsonSerializerOptions JSON_OPTIONS = new JsonSerializerOptions
|
||||
{
|
||||
RespectNullableAnnotations = true,
|
||||
};
|
||||
TypeInfoResolver = SwotAnalysisJsonContext.Default,
|
||||
}.AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
/// Writes the analysis with the defaults of System.Text.Json, which let a null pass, unlike the reading.
|
||||
/// </summary>
|
||||
internal static readonly JsonSerializerOptions WRITE_OPTIONS = new JsonSerializerOptions
|
||||
{
|
||||
TypeInfoResolver = SwotAnalysisJsonContext.Default,
|
||||
}.AsReadOnly();
|
||||
|
||||
public static ContentPresentation Create(SwotAnalysisResult result) =>
|
||||
ContentPresentation.Create(ContentPresentationKind.SWOT_ANALYSIS, result);
|
||||
ContentPresentation.Create(ContentPresentationKind.SWOT_ANALYSIS, result, WRITE_OPTIONS);
|
||||
|
||||
/// <summary>
|
||||
/// Reads the SWOT analysis stored as the presentation of a text, so it can be shown as a matrix.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AIStudio.Assistants.SWOTAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// The contracts with which System.Text.Json reads and writes a SWOT analysis which a chat stores, built at
|
||||
/// compile time.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)]
|
||||
[JsonSerializable(typeof(SwotAnalysisResult))]
|
||||
internal sealed partial class SwotAnalysisJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
using ERIContentType = AIStudio.Tools.ERIClient.DataModel.ContentType;
|
||||
|
||||
namespace AIStudio.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// The contracts with which System.Text.Json reads and writes chats, built at compile time.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two types with the same name need a name of their own for their contract, else the generator builds
|
||||
/// only the first of them and warns with SYSLIB1031.
|
||||
/// </remarks>
|
||||
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)]
|
||||
[JsonSerializable(typeof(ChatThread))]
|
||||
[JsonSerializable(typeof(ERIContentType), TypeInfoPropertyName = "ERIContentType")]
|
||||
internal sealed partial class ChatJsonContext : JsonSerializerContext;
|
||||
@@ -33,10 +33,10 @@ public sealed class ContentPresentation
|
||||
}
|
||||
}
|
||||
|
||||
internal static ContentPresentation Create<T>(ContentPresentationKind kind, T data) => new()
|
||||
internal static ContentPresentation Create<T>(ContentPresentationKind kind, T data, JsonSerializerOptions options) => new()
|
||||
{
|
||||
Kind = kind,
|
||||
Data = JsonSerializer.SerializeToElement(data),
|
||||
Data = JsonSerializer.SerializeToElement(data, options),
|
||||
};
|
||||
|
||||
internal ContentPresentation DeepClone() => new()
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Settings.DataModel.PreviousModels;
|
||||
|
||||
using EMailWritingStyles = AIStudio.Assistants.EMail.WritingStyles;
|
||||
using ERIContentType = AIStudio.Tools.ERIClient.DataModel.ContentType;
|
||||
using ProviderV4 = AIStudio.Settings.DataModel.PreviousModels.Provider;
|
||||
using RewriteImproveWritingStyles = AIStudio.Assistants.RewriteImprove.WritingStyles;
|
||||
|
||||
namespace AIStudio.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// The contracts with which System.Text.Json reads and writes the settings, built at compile time.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Besides the settings and their older versions, which the migrations still read, the values of managed
|
||||
/// settings get written and read on their own, as the type of their setting. That type is often an interface,
|
||||
/// such as ISet<string>, which the settings themselves never use. A test fails for a managed setting
|
||||
/// whose type is missing here.<br/><br/>
|
||||
///
|
||||
/// Two types with the same name need a name of their own for their contract, else the generator builds
|
||||
/// only the first of them and warns with SYSLIB1031.
|
||||
/// </remarks>
|
||||
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)]
|
||||
[JsonSerializable(typeof(Data))]
|
||||
[JsonSerializable(typeof(DataV1V3))]
|
||||
[JsonSerializable(typeof(DataV4))]
|
||||
[JsonSerializable(typeof(DataV5))]
|
||||
[JsonSerializable(typeof(ISet<string>))]
|
||||
[JsonSerializable(typeof(ISet<PreviewFeatures>))]
|
||||
[JsonSerializable(typeof(ISet<LLMProviders>))]
|
||||
[JsonSerializable(typeof(ISet<ConfigurableAssistant>))]
|
||||
[JsonSerializable(typeof(IList<string>))]
|
||||
[JsonSerializable(typeof(IList<DataAssistantPluginEnterpriseApproval>))]
|
||||
[JsonSerializable(typeof(IDictionary<string, string>))]
|
||||
[JsonSerializable(typeof(ProviderV4), TypeInfoPropertyName = "ProviderV4")]
|
||||
[JsonSerializable(typeof(List<ProviderV4>), TypeInfoPropertyName = "ListProviderV4")]
|
||||
[JsonSerializable(typeof(EMailWritingStyles), TypeInfoPropertyName = "EMailWritingStyles")]
|
||||
[JsonSerializable(typeof(RewriteImproveWritingStyles), TypeInfoPropertyName = "RewriteImproveWritingStyles")]
|
||||
[JsonSerializable(typeof(ERIContentType), TypeInfoPropertyName = "ERIContentType")]
|
||||
internal sealed partial class SettingsJsonContext : JsonSerializerContext;
|
||||
@@ -26,11 +26,12 @@ public sealed class SettingsManager
|
||||
|
||||
private readonly record struct CurrentSettingsReadResult(Data? SettingsData, SettingsWriteBlockReason FailureReason);
|
||||
|
||||
internal static readonly JsonSerializerOptions JSON_OPTIONS = new()
|
||||
internal static readonly JsonSerializerOptions JSON_OPTIONS = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
Converters = { new TolerantEnumConverter() },
|
||||
};
|
||||
TypeInfoResolver = SettingsJsonContext.Default,
|
||||
}.AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
/// From this duration on, storing the settings is reported as slow.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
public static class JsonSerializerOptionsExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Makes the options read-only and returns them, so that a static field gets its options in one expression.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Options with contracts of their own, i.e., with a TypeInfoResolver, should never change after they were
|
||||
/// declared: whoever used them before such a change would have read or written with other contracts.
|
||||
/// </remarks>
|
||||
/// <param name="options">The options, with their TypeInfoResolver set.</param>
|
||||
/// <returns>The same options, now read-only.</returns>
|
||||
public static JsonSerializerOptions AsReadOnly(this JsonSerializerOptions options)
|
||||
{
|
||||
options.MakeReadOnly();
|
||||
return options;
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ public static partial class WorkspaceBehaviour
|
||||
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(WorkspaceBehaviour).Namespace, nameof(WorkspaceBehaviour));
|
||||
|
||||
public static readonly JsonSerializerOptions JSON_OPTIONS = new()
|
||||
public static readonly JsonSerializerOptions JSON_OPTIONS = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
AllowTrailingCommas = true,
|
||||
@@ -73,8 +73,9 @@ public static partial class WorkspaceBehaviour
|
||||
Converters =
|
||||
{
|
||||
new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseUpper),
|
||||
}
|
||||
};
|
||||
},
|
||||
TypeInfoResolver = ChatJsonContext.Default,
|
||||
}.AsReadOnly();
|
||||
|
||||
private static readonly TimeSpan PREFETCH_DELAY_DURATION = TimeSpan.FromMilliseconds(45);
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ public sealed class SwotAnalysisContentPresentationTests
|
||||
var content = new ContentText
|
||||
{
|
||||
Text = "Fallback Markdown",
|
||||
Presentation = ContentPresentation.Create(ContentPresentationKind.SWOT_ANALYSIS, "not a SWOT result"),
|
||||
Presentation = ContentPresentation.Create(ContentPresentationKind.SWOT_ANALYSIS, "not a SWOT result", JsonSerializerOptions.Default),
|
||||
};
|
||||
|
||||
var presentationWasRead = SwotAnalysisContentPresentation.TryRead(content.Presentation, out _);
|
||||
|
||||
@@ -10,7 +10,7 @@ public sealed class ContentPresentationTests
|
||||
[Test]
|
||||
public void TheDataHashCodeFollowsNewData()
|
||||
{
|
||||
var presentation = ContentPresentation.Create(ContentPresentationKind.SWOT_ANALYSIS, new { heading = "First" });
|
||||
var presentation = ContentPresentation.Create(ContentPresentationKind.SWOT_ANALYSIS, new { heading = "First" }, JsonSerializerOptions.Default);
|
||||
var firstHashCode = presentation.GetDataHashCode();
|
||||
|
||||
presentation.Data = JsonSerializer.SerializeToElement(new { heading = "Second" });
|
||||
@@ -33,7 +33,7 @@ public sealed class ContentPresentationTests
|
||||
[Test]
|
||||
public void ACloneHasTheSameDataHashCode()
|
||||
{
|
||||
var presentation = ContentPresentation.Create(ContentPresentationKind.SWOT_ANALYSIS, new { heading = "First" });
|
||||
var presentation = ContentPresentation.Create(ContentPresentationKind.SWOT_ANALYSIS, new { heading = "First" }, JsonSerializerOptions.Default);
|
||||
|
||||
Assert.That(presentation.DeepClone().GetDataHashCode(), Is.EqualTo(presentation.GetDataHashCode()));
|
||||
}
|
||||
|
||||
@@ -17,8 +17,20 @@ public static class JsonContractDescription
|
||||
/// <returns>One line for the type, and one line per property, polymorphism, and callback.</returns>
|
||||
public static string Describe(JsonTypeInfo typeInfo)
|
||||
{
|
||||
//
|
||||
// For a struct, reflection takes the default of the struct and sets every property, while source generation
|
||||
// passes the values to the constructor of a positional record struct. Both give the same struct, as long as
|
||||
// no parameter has a default value of its own, which JsonDefaultsTests checks. A class with required
|
||||
// properties is alike: source generation sets them while it creates the object, reflection right after.
|
||||
// Hence, the description leaves out how such a type gets created:
|
||||
//
|
||||
var isCreatedWithItsValues = typeInfo.Kind is JsonTypeInfoKind.Object && (typeInfo.Type.IsValueType || typeInfo.Properties.Any(property => property.IsRequired));
|
||||
var creation = isCreatedWithItsValues
|
||||
? "created with its values"
|
||||
: $"converter {ConverterName(typeInfo.Converter)}, created {(typeInfo.CreateObject is null ? "otherwise" : "without arguments")}";
|
||||
|
||||
var text = new StringBuilder();
|
||||
text.AppendLine($"{TypeName(typeInfo.Type)}: {typeInfo.Kind}, converter {ConverterName(typeInfo.Converter)}, created {(typeInfo.CreateObject is null ? "otherwise" : "without arguments")}");
|
||||
text.AppendLine($"{TypeName(typeInfo.Type)}: {typeInfo.Kind}, {creation}");
|
||||
text.AppendLine($" handling: numbers {typeInfo.NumberHandling}, unmapped members {typeInfo.UnmappedMemberHandling}, object creation {typeInfo.PreferredPropertyObjectCreationHandling}");
|
||||
text.AppendLine($" callbacks: serializing {typeInfo.OnSerializing is not null}, serialized {typeInfo.OnSerialized is not null}, deserializing {typeInfo.OnDeserializing is not null}, deserialized {typeInfo.OnDeserialized is not null}");
|
||||
if (typeInfo.KeyType is { } keyType)
|
||||
@@ -35,11 +47,32 @@ public static class JsonContractDescription
|
||||
}
|
||||
|
||||
foreach (var property in typeInfo.Properties)
|
||||
text.AppendLine($" property {property.Name}: {TypeName(property.PropertyType)}, order {property.Order}, get {property.Get is not null}, set {property.Set is not null}, required {property.IsRequired}, extension data {property.IsExtensionData}, conditional {property.ShouldSerialize is not null}, converter {ConverterName(property.CustomConverter)}, constructor parameter {property.AssociatedParameter?.Name ?? "none"}, nullable get {property.IsGetNullable}, nullable set {property.IsSetNullable}, numbers {property.NumberHandling}, object creation {property.ObjectCreationHandling}");
|
||||
{
|
||||
//
|
||||
// An ignored property keeps its name in both contracts, but only reflection keeps its type:
|
||||
//
|
||||
if (IsIgnored(property))
|
||||
{
|
||||
text.AppendLine($" property {property.Name}: ignored");
|
||||
continue;
|
||||
}
|
||||
|
||||
var setting = isCreatedWithItsValues
|
||||
?$"set {property.Set is not null || property.AssociatedParameter is not null}"
|
||||
: $"set {property.Set is not null}, constructor parameter {property.AssociatedParameter?.Name ?? "none"}";
|
||||
|
||||
text.AppendLine($" property {property.Name}: {TypeName(property.PropertyType)}, order {property.Order}, get {property.Get is not null}, {setting}, required {property.IsRequired}, extension data {property.IsExtensionData}, conditional {property.ShouldSerialize is not null}, converter {ConverterName(property.CustomConverter)}, nullable get {property.IsGetNullable}, nullable set {property.IsSetNullable}, numbers {property.NumberHandling}, object creation {property.ObjectCreationHandling}");
|
||||
}
|
||||
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether System.Text.Json neither writes nor reads a property, e.g., one marked with JsonIgnore.
|
||||
/// </summary>
|
||||
/// <param name="property">The property of a contract.</param>
|
||||
public static bool IsIgnored(JsonPropertyInfo property) => property is { Get: null, Set: null, AssociatedParameter: null };
|
||||
|
||||
/// <summary>
|
||||
/// Returns the name of a type the way C# writes it, e.g., Dictionary<String, Int32>.
|
||||
/// </summary>
|
||||
@@ -52,8 +85,19 @@ public static class JsonContractDescription
|
||||
}
|
||||
|
||||
//
|
||||
// Reflection reads the constructor parameters of a record through converters of its own, whose names end
|
||||
// with "WithReflection". Source generation does the same with the plain ones, which read the same JSON:
|
||||
// Reflection reads the constructor parameters of a record through converters of its own: one for up to four
|
||||
// parameters, and others whose names end with "WithReflection". Source generation reads them all with the
|
||||
// converter for many parameters, which reads the same JSON. The same goes for immutable collections:
|
||||
//
|
||||
private static string ConverterName(JsonConverter? converter) => converter is null ? "none" : TypeName(converter.GetType()).Replace("WithReflection", string.Empty, StringComparison.Ordinal);
|
||||
private static string ConverterName(JsonConverter? converter)
|
||||
{
|
||||
if (converter is null)
|
||||
return "none";
|
||||
|
||||
var name = converter.GetType().Name;
|
||||
if (name.Contains("ObjectWithParameterizedConstructorConverter", StringComparison.Ordinal))
|
||||
return $"ObjectWithParameterizedConstructorConverter<{TypeName(converter.Type!)}>";
|
||||
|
||||
return TypeName(converter.GetType()).Replace("WithReflection", string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,11 @@ namespace AIStudio.Tests.Serialization;
|
||||
/// those properties their defaults, as a new object has them, e.g., a sub-object of the settings instead of
|
||||
/// null. Source generation in .NET 10 breaks this for init-only properties: it sets them all while creating
|
||||
/// the object, and a property missing in the JSON gets the default of its type instead (dotnet/runtime#84484).
|
||||
/// Hence, the types AI Studio stores use set instead of init.
|
||||
/// Hence, the types AI Studio stores use set instead of init.<br/><br/>
|
||||
///
|
||||
/// A new object is created the way reflection creates it: with its parameterless constructor, or, for a
|
||||
/// struct, as the default of the struct. Source generation passes the values of a positional record struct
|
||||
/// to its constructor instead, where a parameter with a default value of its own would differ.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class JsonDefaultsTests
|
||||
@@ -19,15 +23,16 @@ public sealed class JsonDefaultsTests
|
||||
[TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.Stored))]
|
||||
public void AnEmptyObjectGetsTheDefaultsOfANewOne(JsonTestRoot root)
|
||||
{
|
||||
var baseline = JsonReflectionBaseline.Of(root.Options);
|
||||
var failures = new List<string>();
|
||||
foreach (var type in JsonTypeGraph.Collect(root.Type, root.Options))
|
||||
{
|
||||
//
|
||||
// A type which gets created through its constructor receives every value as an argument, so nothing
|
||||
// A class which gets created through its constructor receives every value as an argument, so nothing
|
||||
// gets lost there. A required property refuses an empty object anyway:
|
||||
//
|
||||
var typeInfo = root.Options.GetTypeInfo(type);
|
||||
if (typeInfo is not { Kind: JsonTypeInfoKind.Object, CreateObject: { } createObject } || typeInfo.Properties.Any(property => property.IsRequired))
|
||||
if (typeInfo.Kind is not JsonTypeInfoKind.Object || baseline.GetTypeInfo(type).CreateObject is not { } createObject || typeInfo.Properties.Any(property => property.IsRequired))
|
||||
continue;
|
||||
|
||||
var newObject = createObject();
|
||||
|
||||
@@ -44,7 +44,8 @@ public static class JsonTestRoots
|
||||
yield return Case(new(valueType, SettingsManager.JSON_OPTIONS));
|
||||
|
||||
// The SWOT analysis, which a chat stores as the presentation of a message:
|
||||
yield return Case(new(typeof(SwotAnalysisResult), SwotAnalysisContentPresentation.JSON_OPTIONS));
|
||||
yield return Case(new(typeof(SwotAnalysisResult), SwotAnalysisContentPresentation.JSON_OPTIONS), "read");
|
||||
yield return Case(new(typeof(SwotAnalysisResult), SwotAnalysisContentPresentation.WRITE_OPTIONS), "written");
|
||||
|
||||
// Backups and their restore:
|
||||
yield return Case(new(typeof(DataBackupManifest), DataBackupFormat.JSON_OPTIONS));
|
||||
@@ -56,14 +57,14 @@ public static class JsonTestRoots
|
||||
yield return Case(new(typeof(List<string>), SqliteProvenanceStoreClientImplementation.JSON_OPTIONS));
|
||||
}
|
||||
|
||||
private static TestCaseData Case(JsonTestRoot root)
|
||||
private static TestCaseData Case(JsonTestRoot root, string? purpose = null)
|
||||
{
|
||||
//
|
||||
// The same System.Text.Json does with the first call of Serialize or Deserialize. Only then, the
|
||||
// options hand out contracts:
|
||||
//
|
||||
root.Options.MakeReadOnly(populateMissingResolver: true);
|
||||
return new TestCaseData(root).SetArgDisplayNames(root.ToString());
|
||||
return new TestCaseData(root).SetArgDisplayNames(purpose is null ? root.ToString() : $"{root}, {purpose}");
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> ManagedSettingValueTypes()
|
||||
|
||||
@@ -9,7 +9,8 @@ public static class JsonTypeGraph
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the root and every type it holds: the types of its properties, the keys and elements of its
|
||||
/// collections, and its derived types, each of them once and in the order they were met.
|
||||
/// collections, and its derived types, each of them once and in the order they were met. The type of an
|
||||
/// ignored property doesn't count, since System.Text.Json never meets it.
|
||||
/// </summary>
|
||||
/// <param name="root">The type to start with.</param>
|
||||
/// <param name="options">The options whose contracts tell what a type holds.</param>
|
||||
@@ -27,7 +28,7 @@ public static class JsonTypeGraph
|
||||
|
||||
types.Add(type);
|
||||
var typeInfo = options.GetTypeInfo(type);
|
||||
foreach (var property in typeInfo.Properties)
|
||||
foreach (var property in typeInfo.Properties.Where(property => !JsonContractDescription.IsIgnored(property)))
|
||||
Visit(property.PropertyType, options, visited, types);
|
||||
|
||||
if (typeInfo.KeyType is { } keyType)
|
||||
|
||||
Reference in new issue
Block a user