Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntity.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;

namespace CrestApps.Core;
Expand All @@ -8,10 +10,9 @@ namespace CrestApps.Core;
public abstract class ExtensibleEntity
{
/// <summary>
/// Gets or sets the dictionary of additional properties that are not explicitly
/// declared on the entity. Values are captured from JSON extension data during
/// deserialization and are round-tripped back on serialization.
/// Gets or sets the dictionary of additional properties stored under the
/// <c>Properties</c> JSON object.
/// </summary>
[JsonExtensionData]
public IDictionary<string, object> Properties { get; set; } = new Dictionary<string, object>();
[JsonConverter(typeof(ExtensibleEntityPropertiesJsonConverter))]
public IDictionary<string, object> Properties { get; set; } = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ public static T GetOrCreate<T>(this ExtensibleEntity entity, JsonSerializerOptio
var key = typeof(T).Name;

return entity.Properties.TryGetValue(key, out var value)
? DeserializeValue<T>(value, jsonSerializerOptions ?? _jsonOptions) ?? new T()
: new T();
? DeserializeValue<T>(value, jsonSerializerOptions ?? _jsonOptions) ?? new T()
: new T();
}

/// <summary>
Expand All @@ -57,8 +57,8 @@ public static T Get<T>(this ExtensibleEntity entity, string name, JsonSerializer
ArgumentException.ThrowIfNullOrEmpty(name);

return entity.Properties.TryGetValue(name, out var value)
? DeserializeValue<T>(value, jsonSerializerOptions ?? _jsonOptions)
: default;
? DeserializeValue<T>(value, jsonSerializerOptions ?? _jsonOptions)
: default;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;

namespace CrestApps.Core;

/// <summary>
/// Serializes and deserializes <see cref="ExtensibleEntity.Properties"/> as a normal nested
/// JSON object while keeping its values inside the property bag.
/// </summary>
internal sealed class ExtensibleEntityPropertiesJsonConverter : JsonConverter<IDictionary<string, object>>
{
/// <summary>
/// Reads the property bag from a nested JSON object.
/// </summary>
/// <param name="reader">The JSON reader.</param>
/// <param name="typeToConvert">The type to convert.</param>
/// <param name="options">The serializer options.</param>
/// <returns>The deserialized property bag.</returns>
public override IDictionary<string, object> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
{
return new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
}

var node = JsonNode.Parse(ref reader)?.AsObject();

if (node is null)
{
return new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
}

var properties = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);

foreach (var property in node)
{
properties[property.Key] = property.Value?.DeepClone();
}

return properties;
}

/// <summary>
/// Writes the property bag as a nested JSON object.
/// </summary>
/// <param name="writer">The JSON writer.</param>
/// <param name="value">The property bag value.</param>
/// <param name="options">The serializer options.</param>
public override void Write(Utf8JsonWriter writer, IDictionary<string, object> value, JsonSerializerOptions options)
{
writer.WriteStartObject();

foreach (var property in value ?? new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase))
{
writer.WritePropertyName(property.Key);
WritePropertyValue(writer, property.Value);
}

writer.WriteEndObject();
}

/// <summary>
/// Writes a single property bag value using the extensible entity serializer options.
/// </summary>
/// <param name="writer">The JSON writer.</param>
/// <param name="value">The property value.</param>
private static void WritePropertyValue(Utf8JsonWriter writer, object value)
{
if (value is null)
{
writer.WriteNullValue();

return;
}

if (value is JsonNode jsonNode)
{
jsonNode.WriteTo(writer, ExtensibleEntityExtensions.JsonSerializerOptions);

return;
}

if (value is JsonElement jsonElement)
{
jsonElement.WriteTo(writer);

return;
}

JsonSerializer.Serialize(writer, value, value.GetType(), ExtensibleEntityExtensions.JsonSerializerOptions);
}
}
1 change: 1 addition & 0 deletions src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,4 @@ description: Initial standalone release notes for the CrestApps.Core repository.
- treats valid post-session JSON with an empty `tasks` array as an explicit structured-result failure, persists that clearer error in `PostSessionResults`, and strengthens the shared post-session prompts so every configured task must still return a result even when no tool call is needed
- stops serializing redundant top-level post-session error fields on `PostSessionResult`, keeps attempt-specific failures in `AttemptHistory`, retries tool-enabled runs through structured recovery when the model returns invalid task entries such as blank names or blank values, and falls back to a no-tools structured retry when the tool path never actually invoked a tool
- refreshes site-settings-backed options through the standard `IOptionsMonitor<>` pipeline by documenting the minimal `IOptionsChangeTokenSource<>` pattern for custom hosts, and moves uploaded AI document vector indexing into a shared `DefaultAIDocumentIndexingService` so MVC and Blazor no longer carry duplicate sample-only indexer implementations
- writes and reads `ExtensibleEntity.Properties` only through the nested `Properties` JSON object instead of flattening typed metadata onto the document root
2 changes: 2 additions & 0 deletions src/CrestApps.Core.Docs/docs/core/extensible-entity.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ description: Learn how to use ExtensibleEntity for dynamic property storage and

`ExtensibleEntity` provides a `Properties` dictionary that allows you to attach arbitrary strongly-typed data to any entity without changing its schema. This powers features like AI profile metadata, chat session annotations, and custom application data.

Serialized entities keep extensible values inside the nested `Properties` object. Root-level flattened extension data is not treated as valid extensible metadata.

## Quick Reference

| Method | When to Use |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,17 @@ else
<div class="form-text">The default number of matched chunks or documents to include when a profile does not override it.</div>
</div>

<div class="mb-3">
<label for="docRetrievalMode" class="form-label">Default retrieval mode</label>
<InputSelect id="docRetrievalMode" @bind-Value="_model.DocumentRetrievalMode" class="form-select">
@foreach (var retrievalMode in Enum.GetValues<DocumentRetrievalMode>())
{
<option value="@retrievalMode">@retrievalMode</option>
}
</InputSelect>
<div class="form-text">Controls whether document retrieval returns matching chunks directly or expands results hierarchically by parent document.</div>
</div>

</div>
</div>

Expand Down Expand Up @@ -948,7 +959,7 @@ else
{
IndexProfileName = _model.DocumentIndexProfileName?.Trim(),
TopN = _model.DocumentTopN,
RetrievalMode = SiteSettings.Get<InteractionDocumentSettings>().RetrievalMode,
RetrievalMode = _model.DocumentRetrievalMode,
});

SiteSettings.Set(new AIDataSourceSettings
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ public async Task<IActionResult> Save(SettingsViewModel model)
{
IndexProfileName = model.DocumentIndexProfileName?.Trim(),
TopN = model.DocumentTopN,
RetrievalMode = _siteSettings.Get<InteractionDocumentSettings>().RetrievalMode,
RetrievalMode = model.DocumentRetrievalMode,
});

_siteSettings.Set(new AIDataSourceSettings
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@using CrestApps.Core.AI.Claude.Models
@using CrestApps.Core.AI.Documents.Models
@model CrestApps.Core.Mvc.Web.Areas.Admin.ViewModels.SettingsViewModel
@{
ViewData["Title"] = "AI Settings";
Expand Down Expand Up @@ -257,6 +258,17 @@
<div class="form-text">The default number of matched chunks or documents to include when a profile does not override it.</div>
</div>

<div class="mb-3">
<label asp-for="DocumentRetrievalMode" class="form-label">Default retrieval mode</label>
<select asp-for="DocumentRetrievalMode" class="form-select">
@foreach (var retrievalMode in Enum.GetValues<DocumentRetrievalMode>())
{
<option value="@((int)retrievalMode)">@retrievalMode</option>
}
</select>
<div class="form-text">Controls whether document retrieval returns matching chunks directly or expands results hierarchically by parent document.</div>
</div>

</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;

namespace CrestApps.Core.Tests.Core.Models;
Expand All @@ -22,6 +23,126 @@ public void Get_UsesProvidedSerializerOptions()
Assert.Equal(TestMode.SecondValue, settings.Mode);
}

[Fact]
public void Serialize_WritesTypedMetadataInsidePropertiesObject()
{
var entity = new TestExtensibleEntity
{
Id = "entity-1",
};

entity.Put(new TestSettings
{
Mode = TestMode.SecondValue,
});

var json = JsonSerializer.Serialize(entity);
var node = JsonNode.Parse(json)?.AsObject();

Assert.NotNull(node);
Assert.Equal("entity-1", node[nameof(TestExtensibleEntity.Id)]?.GetValue<string>());
Assert.Null(node[nameof(TestSettings)]);
Assert.Equal(
"SecondValue",
node[nameof(ExtensibleEntity.Properties)]?[nameof(TestSettings)]?[nameof(TestSettings.Mode)]?.GetValue<string>());
}

[Fact]
public void Deserialize_ReadsTypedMetadataFromNestedPropertiesObject()
{
const string json = """
{
"Id": "entity-1",
"Properties": {
"TestSettings": {
"Mode": "SecondValue"
}
}
}
""";

var entity = JsonSerializer.Deserialize<TestExtensibleEntity>(json);

Assert.NotNull(entity);
Assert.True(entity.TryGet<TestSettings>(out var settings));
Assert.NotNull(settings);
Assert.Equal(TestMode.SecondValue, settings.Mode);
}

[Fact]
public void Deserialize_IgnoresFlattenedPropertiesOutsideNestedPropertiesObject()
{
const string json = """
{
"Id": "entity-1",
"TestSettings": {
"Mode": "SecondValue"
}
}
""";

var entity = JsonSerializer.Deserialize<TestExtensibleEntity>(json);

Assert.NotNull(entity);
Assert.False(entity.TryGet<TestSettings>(out _));
}

[Fact]
public void Put_RoundTripsTypedMetadataOnlyThroughPropertiesObject()
{
var entity = new TestExtensibleEntity
{
Id = "entity-1",
};

entity.Put(new TestSettings
{
Mode = TestMode.SecondValue,
});

var serializedJson = JsonSerializer.Serialize(entity);
var serializedNode = JsonNode.Parse(serializedJson)?.AsObject();

Assert.NotNull(serializedNode);
Assert.Null(serializedNode[nameof(TestSettings)]);
Assert.Equal("entity-1", serializedNode[nameof(TestExtensibleEntity.Id)]?.GetValue<string>());

var propertiesNode = serializedNode[nameof(ExtensibleEntity.Properties)]?.AsObject();

Assert.NotNull(propertiesNode);
Assert.Single(propertiesNode);
Assert.Equal(
"SecondValue",
propertiesNode[nameof(TestSettings)]?[nameof(TestSettings.Mode)]?.GetValue<string>());
Assert.Null(propertiesNode[nameof(TestSettings.Mode)]);

const string roundTripJson = """
{
"Id": "entity-1",
"TestSettings": {
"Mode": "FirstValue"
},
"Properties": {
"TestSettings": {
"Mode": "SecondValue"
}
}
}
""";

var roundTrippedEntity = JsonSerializer.Deserialize<TestExtensibleEntity>(roundTripJson);

Assert.NotNull(roundTrippedEntity);
Assert.True(roundTrippedEntity.TryGet<TestSettings>(out var settings));
Assert.NotNull(settings);
Assert.Equal(TestMode.SecondValue, settings.Mode);

var property = Assert.Single(roundTrippedEntity.Properties);
Assert.Equal(nameof(TestSettings), property.Key);
Assert.False(roundTrippedEntity.Properties.ContainsKey(nameof(ExtensibleEntity.Properties)));
Assert.False(roundTrippedEntity.Properties.ContainsKey(nameof(TestSettings.Mode)));
}

private static JsonSerializerOptions CreateCamelCaseEnumOptions()
{
var options = new JsonSerializerOptions(ExtensibleEntityJsonOptions.CreateDefaultSerializerOptions());
Expand All @@ -34,6 +155,7 @@ private static JsonSerializerOptions CreateCamelCaseEnumOptions()

private sealed class TestExtensibleEntity : ExtensibleEntity
{
public string Id { get; set; }
}

private sealed class TestSettings
Expand Down
Loading
Loading