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