using System.Text.RegularExpressions; namespace AIStudio.Assistants.BeyondFactsLab; /// /// Reads the work state a model wrote as Markdown, with a heading "## [KEY] Title" for each section. /// /// /// Markdown needs no escaping of line breaks and quotation marks, so even weaker models write it reliably, unlike /// JSON. The keys decide, not the titles, which the model may translate. A heading "## [FIELD:id]" holds a value the /// model suggests for a field of the form. The reader is strict: it never guesses what a section or a suggestion /// could mean. A wrong section becomes a problem, which the model may correct once; a suggestion which does not fit /// its field is left out, since the person fills in the field just as well. /// public static partial class BflMarkdownSections { /// /// The beginning of the key of a section which suggests a value for a field of the form. /// public const string FIELD_PREFIX = "FIELD:"; /// /// Splits an answer into its sections. /// /// /// Text before the first heading, such as an introduction of the model, belongs to no section and is left out, /// as are the lines of a code block around the whole answer. /// /// The answer of the model. /// The sections, in the order of the answer. public static IReadOnlyList Split(string answer) { var sections = new List(); string? key = null; var lines = new List(); foreach (var line in answer.ReplaceLineEndings("\n").Split('\n')) { if (CodeFencePattern().IsMatch(line)) continue; var heading = HeadingPattern().Match(line); if (heading.Success) { AddSection(); key = heading.Groups["key"].Value.Trim(); lines.Clear(); continue; } if (key is not null) lines.Add(line); } AddSection(); return sections; void AddSection() { if (key is not null) sections.Add(new(key, string.Join('\n', lines).Trim())); } } /// /// Reads the work state of a step and the suggestions for its form from an answer of the model. /// /// The answer of the model. /// The step whose work state the model wrote. /// The form of the step, whose empty fields may get suggestions. /// What the lab read, with the problems of the sections. public static BflWorkStateAnswer Read(string answer, BflStep step, BflStepFormData form) { var expected = BflWorkStateSections.ForStep(step).Select(section => section.Key).ToList(); var suggestable = SuggestableFields(step, form).ToDictionary(field => field.Id, StringComparer.Ordinal); var sections = new Dictionary(StringComparer.Ordinal); var suggestions = new Dictionary(StringComparer.Ordinal); var problems = new List(); var parsed = Split(answer); foreach (var section in parsed) { if (section.Key.StartsWith(FIELD_PREFIX, StringComparison.OrdinalIgnoreCase)) { var fieldId = section.Key[FIELD_PREFIX.Length..].Trim().ToLowerInvariant(); if (suggestable.TryGetValue(fieldId, out var field) && !suggestions.ContainsKey(fieldId) && Fits(field, section.Text)) suggestions[fieldId] = section.Text; continue; } var key = section.Key.ToUpperInvariant(); if (!expected.Contains(key)) problems.Add($"Den Abschnitt [{section.Key}] gibt es in diesem Schritt nicht."); else if (!sections.TryAdd(key, section.Text)) problems.Add($"Der Abschnitt [{key}] steht mehrmals in der Antwort."); } if (parsed.Count is 0) problems.Add("Die Antwort enthält keinen Abschnitt mit einer Überschrift der Form „## [SCHLÜSSEL] Titel“."); else problems.AddRange(expected.Where(key => !sections.ContainsKey(key)).Select(key => $"Der Abschnitt [{key}] fehlt.")); return new BflWorkStateAnswer { Sections = sections, FieldSuggestions = suggestions, Problems = problems }; } /// /// Gets the fields of a step for which the model may suggest a value: the empty text fields. /// /// /// A field which holds something personal gets no suggestion: the lab never ascribes such things to the person. /// The fields with a choice get none either; the person chooses quickly enough. /// /// The step. /// The form of the step. /// The fields, in the order the form shows them. public static IReadOnlyList SuggestableFields(BflStep step, BflStepFormData form) => [.. BflFieldCatalog.ForStep(step).Where(field => field.Kind is BflFieldKind.SINGLE_LINE or BflFieldKind.MULTI_LINE or BflFieldKind.LINK && !field.IsPersonalDisclosure && string.IsNullOrWhiteSpace(form.Get(field.Id)))]; /// /// Checks whether a suggestion fits its field. /// /// The field. /// The suggested value. /// True when the field takes the value as it is. private static bool Fits(BflFieldDefinition field, string value) { if (string.IsNullOrWhiteSpace(value) || value.Length > field.Kind.MaxLength) return false; return field.Kind switch { BflFieldKind.SINGLE_LINE => !value.Contains('\n'), BflFieldKind.LINK => Uri.TryCreate(value, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp), _ => true, }; } /// /// A heading with a key in brackets, such as "## [KEY] Title", "### [key]", or "## **[KEY] Title**". /// [GeneratedRegex(@"^\s{0,3}#{1,6}[ \t]*(?:\*\*)?[ \t]*\[(?[^\]\n]+)\]")] private static partial Regex HeadingPattern(); /// /// A line which opens or closes a code block, such as "```" or "```markdown". /// [GeneratedRegex(@"^\s*```[\w-]*\s*$")] private static partial Regex CodeFencePattern(); }