using System.Globalization; using System.Text; using System.Text.RegularExpressions; namespace AIStudio.Assistants.BeyondFactsLab; /// /// Removes the personal details it recognizes from a session of the BeyondFacts Lab before the person shares it: links, /// e-mail addresses, paths, phone numbers, and the names and terms the person lists. /// /// /// /// The anonymizer follows rules and asks no model, so it recognizes only what its rules describe. Names count only when /// the person lists them; the lab suggests the ones it knows, see BflShareNames. A name is replaced in full, with its /// titles, in the genitive, with umlauts written out or left out, and in the forms of web addresses, such as /// "anna-mueller"; its first and its last name are replaced on their own as well, but only as they are written with a /// capital letter, since many names are common words. A part which several people share becomes a placeholder /// without a number. /// /// /// Running the anonymizer again over its own result changes nothing: it leaves its placeholders alone, and the same /// lists give the same numbers. That is why the person may edit the result and have it anonymized once more. /// /// public static partial class BflAnonymizer { private static string TB(string fallbackEN) => Tools.PluginSystem.I18N.I.T(fallbackEN, typeof(BflAnonymizer).Namespace, nameof(BflAnonymizer)); /// /// The fewest digits a phone number has; a shorter number may be anything. /// private const int MIN_PHONE_DIGITS = 7; /// /// The fewest letters a part of a name needs to be replaced on its own, so that an initial stays. /// private const int MIN_PART_LETTERS = 2; /// /// The titles which may stand before a name, longer ones first. /// private const string TITLE = @"(?:Professorin|Professor|Prof|Dr\.?-Ing|Dipl\.?-Ing|Dr|PD|Herrn|Herr|Frau|Mrs|Mr|Ms|Mx|Sir|Dame)"; /// /// Any number of titles before a name, each followed by a period or a space, e.g., "Prof. Dr. ". /// private const string TITLES = $@"(?:{TITLE}(?:\.[ \t]*|[ \t]+))*"; /// /// What may stand between the parts of a name: spaces, or the characters with which web addresses join words. /// private const string PART_SEPARATOR = @"(?:\s+|[._-])?"; /// /// Where a name or a term may begin: not inside a word. /// private const string BEFORE = @"(? /// Where a name or a term may end: not inside a word. /// private const string AFTER = @"(?![\p{L}\p{M}\p{N}_])"; /// /// Where a part of a name on its own may begin: neither inside a word nor after the hyphen of a double name. /// private const string BEFORE_PART = @"(? /// Where a part of a name on its own may end: neither inside a word nor before the hyphen of a double name. /// private const string AFTER_PART = @"(?![\p{L}\p{M}\p{N}_]|[-‐‑]\p{L})"; /// /// The small words with which many last names begin, e.g., "von der Leyen". /// internal static readonly IReadOnlySet NAME_PARTICLES = new HashSet(StringComparer.OrdinalIgnoreCase) { "von", "vom", "van", "de", "der", "den", "del", "della", "di", "da", "du", "la", "le", "zu", "zum", "zur", "ten", "ter", }; /// /// Replaces the personal details the anonymizer recognizes. /// /// The text, e.g., a session of the lab in Markdown. /// The names of people to replace, one person each, e.g., "Prof. Dr. Anna Müller". /// Further terms to replace, e.g., an institution or a place. /// The text with placeholders, and what was replaced. public static BflAnonymization Anonymize(string text, IReadOnlyList people, IReadOnlyList terms) { var report = new List(); var link = Placeholder(TB("Link")); var email = Placeholder(TB("E-mail address")); var path = Placeholder(TB("Path")); var phone = Placeholder(TB("Phone number")); var result = text.Normalize(NormalizationForm.FormC); // Links first, since an address may hold an e-mail address, a path, or a name: result = MarkdownLinkPattern().Replace(result, match => { Record(report, link, match.Groups["url"].Value); var label = match.Groups["label"].Value.Trim(); return label.Length > 0 ? $"{label} {link}" : link; }); result = AutoLinkPattern().Replace(result, match => Record(report, link, match.Groups["url"].Value)); result = UrlPattern().Replace(result, match => ReplaceWithoutEnd(report, link, match.Value)); result = EmailPattern().Replace(result, match => Record(report, email, match.Value)); result = PathPattern().Replace(result, match => ReplaceWithoutEnd(report, path, match.Value)); result = PhonePattern().Replace(result, match => IsPhoneNumber(match.Value) ? Record(report, phone, match.Value) : match.Value); result = ReplaceNamesAndTerms(result, people, terms, report, [link, email, path, phone]); return new BflAnonymization(result, report); } /// /// Gets the parts of a name as the person listed it: without titles and remarks in parentheses, and in the order /// first name, last name, also when the person wrote "Müller, Anna". /// /// The name. /// The parts, empty when nothing like a name remains. internal static IReadOnlyList PartsOfName(string name) { var cleaned = ParenthesesPattern().Replace(name.Normalize(NormalizationForm.FormC), " ").Replace("**", " ").Trim().Trim(',', ';', ':', '-', '–', '*', '_').Trim(); var commaParts = cleaned.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); if (commaParts.Length > 1) { // "Müller, Anna" names the last name first; "Anna Müller, TUM" adds a remark: cleaned = commaParts.Length is 2 && !commaParts[0].Contains(' ') && !commaParts[1].Contains(' ') ? $"{commaParts[1]} {commaParts[0]}" : commaParts[0]; } var parts = cleaned.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Select(part => part.Trim(',', ';', ':')).Where(part => part.Any(char.IsLetter)).ToList(); while (parts.Count > 0 && TitlePattern().IsMatch(parts[0])) parts.RemoveAt(0); return parts; } /// /// Tells whether a word is a title which may stand before a name, e.g., "Prof." or "Dr.". /// /// The word. internal static bool IsTitle(string word) => TitlePattern().IsMatch(word); /// /// Replaces the names of people and the further terms. /// /// The text. /// The names of people, one person each. /// The further terms. /// What was replaced so far. /// The placeholders of links, e-mail addresses, paths, and phone numbers, which stay as they are. /// The text with placeholders. private static string ReplaceNamesAndTerms(string text, IReadOnlyList people, IReadOnlyList terms, List report, IReadOnlyList fixedPlaceholders) { var personLabel = TB("Person"); var termLabel = TB("Term"); var patterns = new List(); var singleParts = new Dictionary(StringComparer.Ordinal); var ambiguous = Placeholder(personLabel); var knownPeople = new HashSet(StringComparer.OrdinalIgnoreCase); var number = 0; foreach (var name in people) { var parts = PartsOfName(name); if (parts.Count is 0 || !knownPeople.Add(string.Join(' ', parts))) continue; var placeholder = Placeholder($"{personLabel} {++number}"); if (parts.Count is 1) { AddSinglePart(singleParts, parts[0], placeholder, ambiguous); continue; } var first = parts[0]; var lastName = LastNameOf(parts); patterns.Add(new(TITLES + string.Join(PART_SEPARATOR, parts.Select(PartPattern)), string.Join(' ', parts).Length, placeholder, true)); patterns.Add(new(PhrasePattern(lastName) + @"(?:,\s*|\s+|[._-])?" + PartPattern(first), lastName.Length + first.Length + 1, placeholder, true)); if (parts.Count > 2) patterns.Add(new(TITLES + PartPattern(first) + PART_SEPARATOR + PhrasePattern(lastName), first.Length + lastName.Length + 1, placeholder, true)); AddSinglePart(singleParts, first, placeholder, ambiguous); AddSinglePart(singleParts, lastName, placeholder, ambiguous); if (lastName.Contains(' ')) AddSinglePart(singleParts, parts[^1], placeholder, ambiguous); } foreach (var (part, placeholder) in singleParts) patterns.Add(new(TITLES + PhrasePattern(part), part.Length, placeholder, false)); var knownTerms = new HashSet(StringComparer.OrdinalIgnoreCase); number = 0; foreach (var term in terms) { var parts = term.Normalize(NormalizationForm.FormC).Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Where(part => part.Any(char.IsLetterOrDigit)).ToList(); if (parts.Count is 0 || !knownTerms.Add(string.Join(' ', parts))) continue; patterns.Add(new(string.Join(PART_SEPARATOR, parts.Select(PartPattern)), string.Join(' ', parts).Length, Placeholder($"{termLabel} {++number}"), true)); } if (patterns.Count is 0) return text; // The longest names first, so that "Anna Schmidt-Weber" is not taken for "Anna Schmidt": patterns = [.. patterns.OrderByDescending(pattern => pattern.Length)]; var expression = new StringBuilder(); expression.Append("(?"); foreach (var placeholder in fixedPlaceholders) expression.Append(Regex.Escape(placeholder)).Append('|'); expression.Append(Regex.Escape($"[{personLabel}")).Append(@"(?: \d+)?\]|").Append(Regex.Escape($"[{termLabel}")).Append(@"(?: \d+)?\])"); for (var index = 0; index < patterns.Count; index++) { // // A part of a name on its own must not be a part of a double name, e.g., "Schmidt" in "Müller-Schmidt", // which may be somebody else. A full name or a term may stand in a longer word with a hyphen, e.g., in // the address "anna-mueller-lab": // var pattern = patterns[index]; var groupIndex = index.ToString(CultureInfo.InvariantCulture); expression.Append('|').Append(pattern.IgnoresCase ? BEFORE : BEFORE_PART); expression.Append("(?').Append(pattern.IgnoresCase ? "(?i:" : "(?:").Append(pattern.Pattern).Append("))"); expression.Append("(?['’]s|['’]|s)?"); expression.Append(pattern.IgnoresCase ? AFTER : AFTER_PART); } var regex = new Regex(expression.ToString(), RegexOptions.CultureInvariant); var nameGroups = Enumerable.Range(0, patterns.Count).Select(index => regex.GroupNumberFromName($"v{index.ToString(CultureInfo.InvariantCulture)}")).ToArray(); var suffixGroups = Enumerable.Range(0, patterns.Count).Select(index => regex.GroupNumberFromName($"s{index.ToString(CultureInfo.InvariantCulture)}")).ToArray(); return regex.Replace(text, match => { if (match.Groups["keep"].Success) return match.Value; for (var index = 0; index < patterns.Count; index++) { // The genitive stays after the placeholder, e.g., "[Person 1]s": var group = match.Groups[nameGroups[index]]; if (group.Success) return Record(report, patterns[index].Placeholder, group.Value) + match.Groups[suffixGroups[index]].Value; } return match.Value; }); } /// /// Gets the last name of a name with several parts: from its first particle on, e.g., "von der Leyen", otherwise /// its last part. /// /// The parts of the name, at least two. private static string LastNameOf(IReadOnlyList parts) { for (var index = 1; index < parts.Count - 1; index++) { if (NAME_PARTICLES.Contains(parts[index])) return string.Join(' ', parts.Skip(index)); } return parts[^1]; } /// /// Adds a part of a name which the anonymizer replaces on its own; a part several people share gets the /// placeholder without a number. /// /// The parts so far, with their placeholders. /// The part, e.g., a first name. /// The placeholder of the person. /// The placeholder without a number. private static void AddSinglePart(Dictionary singleParts, string part, string placeholder, string ambiguous) { if (part.Count(char.IsLetter) < MIN_PART_LETTERS || NAME_PARTICLES.Contains(part)) return; singleParts[part] = singleParts.TryGetValue(part, out var known) && known != placeholder ? ambiguous : placeholder; } /// /// Gets the pattern of several parts of a name which stand one after the other, e.g., "von der Leyen". /// /// The parts, separated by spaces. private static string PhrasePattern(string phrase) => string.Join(@"\s+", phrase.Split(' ').Select(PartPattern)); /// /// Gets the pattern of one part of a name: umlauts match their spellings with and without them, letters with /// accents also without them, and the period of an initial may be missing. /// /// The part of the name. private static string PartPattern(string part) { var pattern = new StringBuilder(); for (var index = 0; index < part.Length; index++) { var character = part[index]; var next = index + 1 < part.Length ? part[index + 1] : '\0'; if (next is 'e' or 'E' && character is 'a' or 'o' or 'u' or 'A' or 'O' or 'U') { // A name written as "Mueller" also stands for "Müller": pattern.Append("(?:").Append(Regex.Escape(part.Substring(index, 2))).Append('|').Append(UmlautOf(character)).Append(')'); index++; continue; } if (character is 's' && next is 's') { pattern.Append("(?:ss|ß)"); index++; continue; } pattern.Append(character switch { 'ä' => "(?:ä|ae|a)", 'ö' => "(?:ö|oe|o)", 'ü' => "(?:ü|ue|u)", 'Ä' => "(?:Ä|Ae|AE|A)", 'Ö' => "(?:Ö|Oe|OE|O)", 'Ü' => "(?:Ü|Ue|UE|U)", 'ß' => "(?:ß|ss)", // A double name may lose its hyphen in an address, e.g., "MuellerLuedenscheidt": '-' or '‐' or '‑' => "[-‐‑._]?", '\'' or '’' => "['’]", '.' => @"\.?", _ => LetterPattern(character), }); } return pattern.ToString(); } /// /// Gets the umlaut of a vowel, e.g., "ü" for "u". /// /// The vowel a, o, or u, in either case. private static string UmlautOf(char vowel) => vowel switch { 'a' => "ä", 'o' => "ö", 'u' => "ü", 'A' => "Ä", 'O' => "Ö", _ => "Ü", }; /// /// Gets the pattern of a letter, which matches a letter with an accent also without it. /// /// The letter. private static string LetterPattern(char character) { var decomposed = character.ToString().Normalize(NormalizationForm.FormD); if (decomposed.Length > 1 && decomposed.Skip(1).All(mark => CharUnicodeInfo.GetUnicodeCategory(mark) is UnicodeCategory.NonSpacingMark)) return $"(?:{Regex.Escape(character.ToString())}|{Regex.Escape(decomposed[..1])})"; return Regex.Escape(character.ToString()); } /// /// Tells whether a run of digits is a phone number rather than a date, a time, or a decimal number. /// /// The run of digits, as the pattern found it. private static bool IsPhoneNumber(string candidate) => candidate.Count(char.IsDigit) >= MIN_PHONE_DIGITS && !NotAPhoneNumberPattern().IsMatch(candidate); /// /// Replaces an address or a path, but leaves the punctuation after it, which belongs to the sentence. /// /// What was replaced so far. /// The placeholder. /// What the pattern found. /// The placeholder and the punctuation after it. private static string ReplaceWithoutEnd(List report, string placeholder, string value) { var length = value.Length; while (length > 0) { var last = value[length - 1]; var opened = value[..length].Count(character => character == OpeningOf(last)); var closed = value[..length].Count(character => character == last); if (last is '.' or ',' or ';' or ':' or '!' or '?' or '*' or '_' or '\'' or '"' || last is ')' or ']' or '}' && closed > opened) length--; else break; } return length is 0 ? value : Record(report, placeholder, value[..length]) + value[length..]; } /// /// Gets the opening bracket of a closing one, or a character no text holds. /// /// The closing bracket. private static char OpeningOf(char closing) => closing switch { ')' => '(', ']' => '[', '}' => '{', _ => '\0', }; /// /// Notes a replacement in the report. /// /// What was replaced so far. /// The placeholder. /// What it replaces. /// The placeholder. private static string Record(List report, string placeholder, string original) { var index = report.FindIndex(replacement => replacement.Placeholder == placeholder && replacement.Original == original); if (index < 0) report.Add(new BflReplacement(placeholder, original, 1)); else report[index] = report[index] with { Count = report[index].Count + 1 }; return placeholder; } /// /// Gets a placeholder, e.g., "[Person 1]". /// /// What it stands for. private static string Placeholder(string label) => $"[{label}]"; /// /// A link in Markdown, whose text stays and whose address goes. /// [GeneratedRegex("""\[(?