From 6f79c3802b3e8eb3d53acc6c8aeca1bcc16112d4 Mon Sep 17 00:00:00 2001 From: Nils Kruthoff Date: Tue, 4 Aug 2026 14:51:28 +0200 Subject: [PATCH] included a parser for JSON blocks that start with ` ```aistudio-chart ` --- .../Chat/ChartBlockParser.cs | 267 ++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 app/MindWork AI Studio/Chat/ChartBlockParser.cs diff --git a/app/MindWork AI Studio/Chat/ChartBlockParser.cs b/app/MindWork AI Studio/Chat/ChartBlockParser.cs new file mode 100644 index 00000000..c68ecba1 --- /dev/null +++ b/app/MindWork AI Studio/Chat/ChartBlockParser.cs @@ -0,0 +1,267 @@ +using System.Text; +using System.Text.Json; + +namespace AIStudio.Chat; + +/// +/// Parses and validates versioned AI Studio chart blocks without executing their content. +/// +public static class ChartBlockParser +{ + private const int MAX_JSON_BYTES = 32 * 1024; + private const int MAX_CATEGORIES = 50; + private const int MAX_SERIES = 10; + + private static readonly HashSet ROOT_PROPERTIES = ["schema_version", "type", "title", "caption", "data"]; + private static readonly HashSet DATA_PROPERTIES = ["categories", "series"]; + private static readonly HashSet SERIES_PROPERTIES = ["name", "values"]; + + /// + /// Parses a JSON chart definition and applies the complete local version 1 validation contract. + /// + public static ChartBlockParseResult Parse(string json) + { + if (Encoding.UTF8.GetByteCount(json) > MAX_JSON_BYTES) + return ChartBlockParseResult.Invalid(json, "The chart JSON exceeds the 32 KB limit."); + + try + { + using var document = JsonDocument.Parse(json, new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 8, + }); + + var root = document.RootElement; + if (root.ValueKind is not JsonValueKind.Object) + return ChartBlockParseResult.Invalid(json, "The chart definition must be a JSON object."); + + if (!HasOnlyKnownProperties(root, ROOT_PROPERTIES, out var propertyError)) + return ChartBlockParseResult.Invalid(json, propertyError); + + if (!TryGetRequiredInt(root, "schema_version", out var schemaVersion) || schemaVersion != 1) + return ChartBlockParseResult.Invalid(json, "schema_version must be 1."); + + if (!TryGetRequiredString(root, "type", out var typeText) + || !TryParseType(typeText, out var type)) + return ChartBlockParseResult.Invalid(json, "type must be bar, stacked_bar, line, pie, or donut."); + + if (!TryGetRequiredString(root, "title", out var title) || string.IsNullOrWhiteSpace(title)) + return ChartBlockParseResult.Invalid(json, "title must be a non-empty string."); + + string? caption = null; + if (root.TryGetProperty("caption", out var captionElement)) + { + if (captionElement.ValueKind is not JsonValueKind.String + || string.IsNullOrWhiteSpace(captionElement.GetString())) + return ChartBlockParseResult.Invalid(json, "caption must be a non-empty string when provided."); + + caption = captionElement.GetString(); + } + + if (!root.TryGetProperty("data", out var data) || data.ValueKind is not JsonValueKind.Object) + return ChartBlockParseResult.Invalid(json, "data must be a JSON object."); + + if (!HasOnlyKnownProperties(data, DATA_PROPERTIES, out propertyError)) + return ChartBlockParseResult.Invalid(json, propertyError); + + if (!TryReadCategories(data, out var categories, out var error)) + return ChartBlockParseResult.Invalid(json, error); + + if (!TryReadSeries(data, categories.Count, out var series, out error)) + return ChartBlockParseResult.Invalid(json, error); + + if (type is ChartDefinitionType.PIE or ChartDefinitionType.DONUT) + { + if (series.Count != 1) + return ChartBlockParseResult.Invalid(json, "Pie and donut charts require exactly one series."); + + if (series[0].Values.Any(value => value < 0)) + return ChartBlockParseResult.Invalid(json, "Pie and donut chart values must not be negative."); + } + + return ChartBlockParseResult.Valid(json, new(schemaVersion, type, title, caption, categories, series)); + } + catch (JsonException exception) + { + return ChartBlockParseResult.Invalid(json, $"The chart JSON is invalid: {exception.Message}"); + } + } + + private static bool TryReadCategories(JsonElement data, out IReadOnlyList categories, out string error) + { + categories = []; + error = string.Empty; + if (!data.TryGetProperty("categories", out var element) || element.ValueKind is not JsonValueKind.Array) + { + error = "data.categories must be an array."; + return false; + } + + var values = new List(); + foreach (var item in element.EnumerateArray()) + { + if (item.ValueKind is not JsonValueKind.String || string.IsNullOrWhiteSpace(item.GetString())) + { + error = "Every category must be a non-empty string."; + return false; + } + + values.Add(item.GetString()!); + if (values.Count > MAX_CATEGORIES) + { + error = $"A chart can contain at most {MAX_CATEGORIES} categories."; + return false; + } + } + + if (values.Count == 0) + { + error = "A chart requires at least one category."; + return false; + } + + categories = values; + return true; + } + + private static bool TryReadSeries(JsonElement data, int categoryCount, out IReadOnlyList series, out string error) + { + series = []; + error = string.Empty; + if (!data.TryGetProperty("series", out var element) || element.ValueKind is not JsonValueKind.Array) + { + error = "data.series must be an array."; + return false; + } + + var result = new List(); + foreach (var item in element.EnumerateArray()) + { + if (item.ValueKind is not JsonValueKind.Object) + { + error = "Every series must be a JSON object."; + return false; + } + + if (!HasOnlyKnownProperties(item, SERIES_PROPERTIES, out error)) + return false; + + if (!TryGetRequiredString(item, "name", out var name) || string.IsNullOrWhiteSpace(name)) + { + error = "Every series requires a non-empty name."; + return false; + } + + if (!item.TryGetProperty("values", out var valuesElement) || valuesElement.ValueKind is not JsonValueKind.Array) + { + error = "Every series requires a values array."; + return false; + } + + var values = new List(); + foreach (var valueElement in valuesElement.EnumerateArray()) + { + if (valueElement.ValueKind is not JsonValueKind.Number + || !valueElement.TryGetDouble(out var value) + || !double.IsFinite(value)) + { + error = "Series values must be finite JSON numbers."; + return false; + } + + values.Add(value); + } + + if (values.Count != categoryCount) + { + error = "Every series must contain exactly one value per category."; + return false; + } + + result.Add(new(name, values)); + if (result.Count > MAX_SERIES) + { + error = $"A chart can contain at most {MAX_SERIES} series."; + return false; + } + } + + if (result.Count == 0) + { + error = "A chart requires at least one series."; + return false; + } + + series = result; + return true; + } + + private static bool HasOnlyKnownProperties(JsonElement element, HashSet knownProperties, out string error) + { + var seenProperties = new HashSet(StringComparer.Ordinal); + foreach (var property in element.EnumerateObject()) + { + if (!knownProperties.Contains(property.Name)) + { + error = $"Unknown chart property: {property.Name}."; + return false; + } + + if (!seenProperties.Add(property.Name)) + { + error = $"Duplicate chart property: {property.Name}."; + return false; + } + } + + error = string.Empty; + return true; + } + + private static bool TryGetRequiredInt(JsonElement element, string propertyName, out int value) + { + value = 0; + return element.TryGetProperty(propertyName, out var property) + && property.ValueKind is JsonValueKind.Number + && property.TryGetInt32(out value); + } + + private static bool TryGetRequiredString(JsonElement element, string propertyName, out string value) + { + value = string.Empty; + if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind is not JsonValueKind.String) + return false; + + value = property.GetString() ?? string.Empty; + return true; + } + + private static bool TryParseType(string value, out ChartDefinitionType type) + { + type = value switch + { + "bar" => ChartDefinitionType.BAR, + "stacked_bar" => ChartDefinitionType.STACKED_BAR, + "line" => ChartDefinitionType.LINE, + "pie" => ChartDefinitionType.PIE, + "donut" => ChartDefinitionType.DONUT, + _ => default, + }; + + return value is "bar" or "stacked_bar" or "line" or "pie" or "donut"; + } +} + +/// +/// The safe result of parsing a chart block, including the original JSON for fallback display. +/// +public sealed record ChartBlockParseResult(string RawJson, ChartDefinition? Chart, string Error) +{ + public bool IsValid => this.Chart is not null; + + public static ChartBlockParseResult Valid(string rawJson, ChartDefinition chart) => new(rawJson, chart, string.Empty); + + public static ChartBlockParseResult Invalid(string rawJson, string error) => new(rawJson, null, error); +}