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; } }