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("""\[(?