namespace AIStudio.Tools;
///
/// Reads the JSON part out of a model answer.
///
///
/// Models like to wrap the JSON they were asked for in prose or in a code fence, however strictly a
/// prompt asks for JSON alone. This class only finds where the JSON starts and where it ends, and it
/// never checks the JSON itself: broken JSON reaches the deserializer of the caller as the model wrote
/// it, so that the message of the deserializer can tell the model where it breaks.
///
public static class ModelAnswerJson
{
///
/// What may follow the opening brace of an object, apart from whitespace: the quote of its first
/// name, the closing brace of an empty object, or a comment, which some callers allow.
///
private const string OBJECT_CONTENT_STARTS = "\"}/";
///
/// What may follow the opening bracket of a list of objects, apart from whitespace: the brace of
/// its first object, or the closing bracket of an empty list.
///
private const string OBJECT_LIST_CONTENT_STARTS = "{]";
///
/// Reads the first JSON object out of a model answer.
///
///
/// Braces in prose, such as a placeholder like {name}, are skipped, because an object starts with
/// a brace followed by a quote, a closing brace, or a comment. Only when the answer holds no such
/// brace, the first brace at all counts, so that broken JSON still reaches the deserializer.
///
/// The answer of the model.
/// The first complete JSON object, or an empty span when there is none, e.g., because the answer breaks off.
public static ReadOnlySpan ExtractObject(ReadOnlySpan answer) => Extract(answer, '{', '}', OBJECT_CONTENT_STARTS);
///
/// Reads the first JSON list of objects out of a model answer.
///
///
/// Brackets in prose, such as a reference like [1] or a link like [text](url), are skipped,
/// because a list of objects starts with a bracket followed by a brace or a closing bracket. Only
/// when the answer holds no such bracket, the first bracket at all counts, so that broken JSON
/// still reaches the deserializer.
///
/// The answer of the model.
/// The first complete JSON list, or an empty span when there is none, e.g., because the answer breaks off.
public static ReadOnlySpan ExtractObjectList(ReadOnlySpan answer) => Extract(answer, '[', ']', OBJECT_LIST_CONTENT_STARTS);
private static ReadOnlySpan Extract(ReadOnlySpan answer, char opening, char closing, ReadOnlySpan contentStarts)
{
var start = FindStart(answer, opening, contentStarts);
if (start is null)
return [];
var end = FindEnd(answer, start.Value, opening, closing);
return end is null ? [] : answer[start.Value..(end.Value + 1)];
}
///
/// Finds the first opening bracket which is followed by what may start the JSON content, or else
/// the first opening bracket at all.
///
private static int? FindStart(ReadOnlySpan answer, char opening, ReadOnlySpan contentStarts)
{
int? firstOpening = null;
for (var index = 0; index < answer.Length; index++)
{
if (answer[index] != opening)
continue;
firstOpening ??= index;
var content = answer[(index + 1)..].TrimStart();
if (!content.IsEmpty && contentStarts.Contains(content[0]))
return index;
}
return firstOpening;
}
///
/// Finds the bracket which closes the one at the start. Only brackets of the same kind count,
/// and only outside of strings.
///
private static int? FindEnd(ReadOnlySpan answer, int start, char opening, char closing)
{
var depth = 0;
var insideString = false;
for (var index = start; index < answer.Length; index++)
{
if (answer[index] == '"' && !IsEscaped(answer, index))
insideString = !insideString;
if (insideString)
continue;
if (answer[index] == opening)
depth++;
else if (answer[index] == closing && --depth == 0)
return index;
}
return null;
}
private static bool IsEscaped(ReadOnlySpan answer, int index)
{
var backslashCount = 0;
for (var i = index - 1; i >= 0 && answer[i] == '\\'; i--)
backslashCount++;
return backslashCount % 2 == 1;
}
}