mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 22:07:21 +00:00
200 lines
6.7 KiB
C#
200 lines
6.7 KiB
C#
using System.Text.Json;
|
||
using System.Text.Json.Serialization;
|
||
|
||
// ReSharper disable UnusedAutoPropertyAccessor.Local
|
||
|
||
namespace AIStudio.Tools.PowerPoint;
|
||
|
||
internal sealed partial record SlideDeckSpec
|
||
{
|
||
private static readonly JsonSerializerOptions JSON_OPTIONS = new()
|
||
{
|
||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||
PropertyNameCaseInsensitive = true,
|
||
NumberHandling = JsonNumberHandling.AllowReadingFromString,
|
||
AllowTrailingCommas = true,
|
||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||
MaxDepth = 16,
|
||
};
|
||
|
||
/// <summary>
|
||
/// The deepest list level a paragraph may have; PowerPoint knows nine, starting at 0.
|
||
/// </summary>
|
||
private const int MAX_PARAGRAPH_LEVEL = 8;
|
||
|
||
/// <summary>
|
||
/// What models put in front of a bullet point although the template draws the bullets.
|
||
/// </summary>
|
||
private static readonly string[] BULLET_PREFIXES = ["- ", "* ", "• ", "– "];
|
||
|
||
/// <summary>
|
||
/// Reads the slide deck out of a model answer.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The JSON may be wrapped in prose or in a code fence. A text slot may be written as a list of
|
||
/// paragraphs or as a single string, and a paragraph as a string or as an object with a text and a
|
||
/// level. A string holding line breaks counts as one paragraph per line.
|
||
/// </remarks>
|
||
/// <param name="modelResponse">The answer of the model.</param>
|
||
/// <param name="spec">The slide deck, or an empty one when reading failed.</param>
|
||
/// <param name="issue">Why reading failed, written for the model; empty on success.</param>
|
||
/// <returns>Whether the answer could be read.</returns>
|
||
public static bool TryParse(string modelResponse, out SlideDeckSpec spec, out string issue)
|
||
{
|
||
spec = new SlideDeckSpec(string.Empty, []);
|
||
issue = string.Empty;
|
||
|
||
var json = ModelAnswerJson.ExtractObject(modelResponse);
|
||
if (json.IsWhiteSpace())
|
||
{
|
||
issue = "The answer contains no complete JSON object.";
|
||
return false;
|
||
}
|
||
|
||
DeckDto? deck;
|
||
try
|
||
{
|
||
deck = JsonSerializer.Deserialize<DeckDto>(json, JSON_OPTIONS);
|
||
}
|
||
catch (JsonException e)
|
||
{
|
||
issue = $"The JSON object cannot be read: {e.Message}";
|
||
return false;
|
||
}
|
||
|
||
if (deck is null)
|
||
{
|
||
issue = "The JSON object is empty.";
|
||
return false;
|
||
}
|
||
|
||
spec = new SlideDeckSpec(Clean(deck.Title), (deck.Slides ?? []).OfType<SlideDto>().Select(ToSlide).ToList());
|
||
return true;
|
||
}
|
||
|
||
private static SlideSpec ToSlide(SlideDto slide) => new(
|
||
Clean(slide.Layout),
|
||
slide.StartsChapter ?? false,
|
||
Clean(slide.Title),
|
||
Clean(slide.Subtitle),
|
||
(slide.Texts ?? []).Select(ToTextBlock).ToList(),
|
||
(slide.Labels ?? []).Select(TextOf).ToList(),
|
||
slide.Pictures ?? [],
|
||
slide.Table is null ? null : ToTable(slide.Table),
|
||
slide.TemplateSlide)
|
||
{
|
||
Removals = ToRemovals(slide.Remove),
|
||
};
|
||
|
||
/// <summary>
|
||
/// The names of what a slide removes from its template slide: a list of names, or a single one,
|
||
/// each named once.
|
||
/// </summary>
|
||
private static List<string> ToRemovals(JsonElement? remove)
|
||
{
|
||
List<JsonElement> names = remove switch
|
||
{
|
||
{ ValueKind: JsonValueKind.Array } list => list.EnumerateArray().ToList(),
|
||
{ } single => [single],
|
||
|
||
_ => [],
|
||
};
|
||
|
||
return names.Select(TextOf).Where(name => name.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||
}
|
||
|
||
private static SlideTextBlock ToTextBlock(JsonElement element) => element.ValueKind switch
|
||
{
|
||
JsonValueKind.Array => new SlideTextBlock(element.EnumerateArray().SelectMany(ToParagraphs).ToList()),
|
||
|
||
_ => new SlideTextBlock(ToParagraphs(element).ToList()),
|
||
};
|
||
|
||
private static IEnumerable<SlideParagraph> ToParagraphs(JsonElement element)
|
||
{
|
||
var level = 0;
|
||
var text = TextOf(element);
|
||
if (element.ValueKind is JsonValueKind.Object)
|
||
{
|
||
text = element.TryGetProperty("text", out var textElement) ? TextOf(textElement) : string.Empty;
|
||
if (element.TryGetProperty("level", out var levelElement) && levelElement.ValueKind is JsonValueKind.Number && levelElement.TryGetInt32(out var parsedLevel))
|
||
level = Math.Clamp(parsedLevel, 0, MAX_PARAGRAPH_LEVEL);
|
||
}
|
||
|
||
foreach (var line in text.ReplaceLineEndings("\n").Split('\n'))
|
||
{
|
||
var paragraph = WithoutBullet(line.Trim());
|
||
if (paragraph.Length > 0)
|
||
yield return new SlideParagraph(paragraph, level);
|
||
}
|
||
}
|
||
|
||
private static SlideTableSpec ToTable(TableDto table) => new(
|
||
(table.Headers ?? []).Select(TextOf).ToList(),
|
||
(table.Rows ?? []).OfType<List<JsonElement>>().Select(row => (IReadOnlyList<string>)row.Select(TextOf).ToList()).ToList());
|
||
|
||
/// <summary>
|
||
/// The text of a JSON value, whatever kind of value the model chose: a number in a table cell
|
||
/// is as good as a string.
|
||
/// </summary>
|
||
private static string TextOf(JsonElement element) => element.ValueKind switch
|
||
{
|
||
JsonValueKind.String => Clean(element.GetString()),
|
||
JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => element.GetRawText(),
|
||
JsonValueKind.Null or JsonValueKind.Undefined => string.Empty,
|
||
|
||
_ => element.GetRawText(),
|
||
};
|
||
|
||
private static string WithoutBullet(string text)
|
||
{
|
||
foreach (var prefix in BULLET_PREFIXES)
|
||
if (text.StartsWith(prefix, StringComparison.Ordinal))
|
||
return text[prefix.Length..].TrimStart();
|
||
|
||
return text;
|
||
}
|
||
|
||
private static string Clean(string? text) => text?.Trim() ?? string.Empty;
|
||
|
||
//
|
||
// What the model writes, before anything is checked. Every value may be missing or null.
|
||
//
|
||
|
||
private sealed class DeckDto
|
||
{
|
||
public string? Title { get; set; }
|
||
|
||
public List<SlideDto?>? Slides { get; set; }
|
||
}
|
||
|
||
private sealed class SlideDto
|
||
{
|
||
public int? TemplateSlide { get; set; }
|
||
|
||
public string? Layout { get; set; }
|
||
|
||
public bool? StartsChapter { get; set; }
|
||
|
||
public string? Title { get; set; }
|
||
|
||
public string? Subtitle { get; set; }
|
||
|
||
public List<JsonElement>? Texts { get; set; }
|
||
|
||
public List<JsonElement>? Labels { get; set; }
|
||
|
||
public List<int>? Pictures { get; set; }
|
||
|
||
public TableDto? Table { get; set; }
|
||
|
||
public JsonElement? Remove { get; set; }
|
||
}
|
||
|
||
private sealed class TableDto
|
||
{
|
||
public List<JsonElement>? Headers { get; set; }
|
||
|
||
public List<List<JsonElement>?>? Rows { get; set; }
|
||
}
|
||
} |