using Microsoft.EntityFrameworkCore; using MeetingAssistant.MeetingNotes; namespace MeetingAssistant.Speakers; public interface ISpeakerIdentityAttendeeCanonicalizer { Task> CanonicalizeAsync( IReadOnlyList attendees, CancellationToken cancellationToken); } public sealed class SpeakerIdentityAttendeeCanonicalizer : ISpeakerIdentityAttendeeCanonicalizer { private readonly IDbContextFactory dbContextFactory; public SpeakerIdentityAttendeeCanonicalizer(IDbContextFactory dbContextFactory) { this.dbContextFactory = dbContextFactory; } public async Task> CanonicalizeAsync( IReadOnlyList attendees, CancellationToken cancellationToken) { var attendeeEntries = attendees .Select(attendee => new { Raw = attendee.Trim(), DisplayName = NormalizeName(attendee) }) .Where(entry => !string.IsNullOrWhiteSpace(entry.Raw) && !string.IsNullOrWhiteSpace(entry.DisplayName)) .ToList(); if (attendeeEntries.Count == 0) { return []; } await using var context = await dbContextFactory.CreateDbContextAsync(cancellationToken); await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken); var identities = await context.SpeakerIdentities .Include(identity => identity.Aliases) .Include(identity => identity.References) .ToListAsync(cancellationToken); var identitiesByAcceptedName = identities .OrderBy(identity => string.IsNullOrWhiteSpace(identity.CanonicalName)) .ThenByDescending(identity => identity.ReferenceCount) .ThenByDescending(identity => identity.UpdatedAt) .ThenBy(identity => identity.Id) .SelectMany(identity => GetExactAcceptedNames(identity).Select(name => new { Name = name, Identity = identity })) .GroupBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.First().Identity, StringComparer.OrdinalIgnoreCase); var result = new List(); var seenNames = new HashSet(StringComparer.OrdinalIgnoreCase); var seenIdentityIds = new HashSet(); foreach (var attendee in attendeeEntries) { if (identitiesByAcceptedName.TryGetValue(attendee.DisplayName!, out var identity)) { var displayName = identity.GetDisplayName(); if (string.IsNullOrWhiteSpace(displayName) || !seenIdentityIds.Add(identity.Id)) { continue; } if (seenNames.Add(displayName)) { result.Add(displayName); } continue; } if (seenNames.Add(attendee.Raw)) { result.Add(attendee.Raw); } } return result; } private static IEnumerable GetExactAcceptedNames(SpeakerIdentity identity) { return new[] { identity.CanonicalName } .Concat(identity.Aliases.Select(alias => alias.Name)) .Select(NormalizeName) .Where(name => !string.IsNullOrWhiteSpace(name)) .Select(name => name!); } private static string? NormalizeName(string? name) { if (string.IsNullOrWhiteSpace(name)) { return null; } return MeetingAttendeeNames.NormalizeDisplayName(name); } } public sealed class PassthroughSpeakerIdentityAttendeeCanonicalizer : ISpeakerIdentityAttendeeCanonicalizer { public static PassthroughSpeakerIdentityAttendeeCanonicalizer Instance { get; } = new(); private PassthroughSpeakerIdentityAttendeeCanonicalizer() { } public Task> CanonicalizeAsync( IReadOnlyList attendees, CancellationToken cancellationToken) { var distinctAttendees = attendees .Select(attendee => attendee.Trim()) .Where(name => !string.IsNullOrWhiteSpace(name)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); return Task.FromResult>(distinctAttendees); } }