using System.Text.RegularExpressions; namespace AIStudio.Assistants.BeyondFactsLab; /// /// Suggests the names of the other participants which a session shared with the research team of the BeyondFacts Lab /// should not contain, from what the lab knows about the event. /// /// /// The lab knows names from three places: the field "Other participants", the headings of the research on the /// participants, which names each person in a heading of its own, and the names in bold in the section about the /// participants of a confirmed work state. A name is a run of two or three words with capital letters, or a single one /// when it stands alone; longer runs give their last two words. The rules miss names and find words which are none, /// so the person checks the suggestions and adds their own name. /// public static partial class BflShareNames { /// /// The key of the section of a work state about the other participants, in the steps "Event details" and /// "Strategy". /// private const string PARTICIPANTS_SECTION = "PARTICIPANTS"; /// /// The most words a suggested name has. /// private const int MAX_NAME_WORDS = 3; /// /// Words which start a run of capitals without belonging to a name: articles, and roles which often stand before a /// name, e.g., "Moderatorin Anna Müller". /// private static readonly IReadOnlySet LEADING_WORDS = new HashSet(StringComparer.OrdinalIgnoreCase) { "Der", "Die", "Das", "Dem", "Den", "Des", "Ein", "Eine", "Einer", "The", "A", "An", "Moderation", "Moderator", "Moderatorin", "Gast", "Gäste", "Referent", "Referentin", "Podium", "Panel", "Keynote", "Speaker", "Host", "Chair", "Moderated", "Mit", "With", }; /// /// Suggests the names of the other participants. /// /// The forms of the preparation, by their steps. /// The work states of the preparation, by their steps. /// The research on the participants, or null when there is none. /// The names, each once, in the order the lab found them. public static IReadOnlyList Suggest(IReadOnlyDictionary forms, IReadOnlyDictionary workStates, BflResearch? participantsResearch) { var candidates = new List(); foreach (var field in BflFieldCatalog.FIELDS.Where(field => field.NameRole is BflNameRole.PARTICIPANTS)) { if (forms.TryGetValue(field.Step, out var form)) candidates.AddRange(ListSeparatorPattern().Split(form.Get(field.Id)).SelectMany(NamesIn)); } if (participantsResearch is not null) candidates.AddRange(HeadingPattern().Matches(participantsResearch.Text).SelectMany(heading => NamesIn(heading.Groups["text"].Value))); foreach (var step in BflStep.All) { if (workStates.GetValueOrDefault(step)?.Confirmed?.Sections.GetValueOrDefault(PARTICIPANTS_SECTION) is { } section) candidates.AddRange(BoldPattern().Matches(section).SelectMany(bold => NamesIn(bold.Groups["text"].Value))); } return Distinct(candidates); } /// /// Gets the names in a short text, e.g., an entry of a list or a heading. /// /// The text. /// The names found. internal static IEnumerable NamesIn(string text) { var cleaned = MarkdownLinkPattern().Replace(text, "${label}"); cleaned = MarkupPattern().Replace(cleaned, " "); // "Moderation: Anna Müller" and "Anna Müller – TUM" put the name and its role side by side: foreach (var segment in SegmentSeparatorPattern().Split(cleaned)) { var words = segment.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Select(word => word.Trim(',', ';', '!', '?', '"', '„', '“', '”', '\'')).Where(word => word.Length > 0).ToList(); if (words.Count is 1) { if (IsNameWord(words[0]) && !LEADING_WORDS.Contains(words[0])) yield return words[0]; continue; } var run = new List(); foreach (var word in words.Append(string.Empty)) { // A particle continues a name, e.g., "Carla von der Heide", but never begins one: if (IsNameWord(word) || run.Count > 0 && BflAnonymizer.NAME_PARTICLES.Contains(word)) { run.Add(word); continue; } if (NameOf(run) is { } name) yield return name; run.Clear(); } } } /// /// Gets the name a run of words with capital letters stands for, if any. /// /// The run, which may end with particles. /// The name, or null when the run holds none. private static string? NameOf(List run) { var words = run.SkipWhile(LEADING_WORDS.Contains).ToList(); while (words.Count > 0 && BflAnonymizer.NAME_PARTICLES.Contains(words[^1])) words.RemoveAt(words.Count - 1); if (words.Count < 2) return null; // Particles do not count, so that "Carla von der Heide" stays whole: var significant = words.Count(word => !BflAnonymizer.NAME_PARTICLES.Contains(word)); return string.Join(' ', significant > MAX_NAME_WORDS ? words.TakeLast(2) : words); } /// /// Tells whether a word may be part of a name: it begins with a capital letter, has at least two letters or is an /// initial, holds nothing but letters, hyphens, and apostrophes, and is no title. /// /// The word. private static bool IsNameWord(string word) => NameWordPattern().IsMatch(word) && !BflAnonymizer.IsTitle(word); /// /// Removes names found twice, and a single word which is a part of a longer name found as well. /// /// The names, in the order found. private static IReadOnlyList Distinct(IReadOnlyList candidates) { var parts = candidates.Where(candidate => candidate.Contains(' ')).SelectMany(candidate => candidate.Split(' ')).ToHashSet(StringComparer.OrdinalIgnoreCase); return [.. candidates.Where(candidate => candidate.Contains(' ') || !parts.Contains(candidate)).Distinct(StringComparer.OrdinalIgnoreCase)]; } /// /// What separates the entries of a list of participants: lines, semicolons, commas, slashes, and "and". /// [GeneratedRegex(@"\r?\n|;|,|/|\s+(?:und|and|sowie|&)\s+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex ListSeparatorPattern(); /// /// What separates a name from its role within an entry: a colon, a dash between spaces, or a remark in parentheses. /// [GeneratedRegex(@":|\s[-–—]\s|\([^)]*\)|\(|\)", RegexOptions.CultureInvariant)] private static partial Regex SegmentSeparatorPattern(); /// /// A heading of the third level, under which the research on the participants describes one person. /// [GeneratedRegex(@"^[ \t]{0,3}###[ \t]+(?[^\n]+?)[ \t#]*$", RegexOptions.Multiline | RegexOptions.CultureInvariant)] private static partial Regex HeadingPattern(); /// /// A text in bold. /// [GeneratedRegex(@"\*\*(?[^*\n]+)\*\*|__(?[^_\n]+)__", RegexOptions.CultureInvariant)] private static partial Regex BoldPattern(); /// /// A link in Markdown, whose text stays. /// [GeneratedRegex(@"\[(?