Files
meeting-assistant/MeetingAssistant/Speakers/SpeakerIdentityAttendeeCanonicalizer.cs
T
2026-05-27 12:55:17 +02:00

125 lines
4.4 KiB
C#

using Microsoft.EntityFrameworkCore;
using MeetingAssistant.MeetingNotes;
namespace MeetingAssistant.Speakers;
public interface ISpeakerIdentityAttendeeCanonicalizer
{
Task<IReadOnlyList<string>> CanonicalizeAsync(
IReadOnlyList<string> attendees,
CancellationToken cancellationToken);
}
public sealed class SpeakerIdentityAttendeeCanonicalizer : ISpeakerIdentityAttendeeCanonicalizer
{
private readonly IDbContextFactory<SpeakerIdentityDbContext> dbContextFactory;
public SpeakerIdentityAttendeeCanonicalizer(IDbContextFactory<SpeakerIdentityDbContext> dbContextFactory)
{
this.dbContextFactory = dbContextFactory;
}
public async Task<IReadOnlyList<string>> CanonicalizeAsync(
IReadOnlyList<string> 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<string>();
var seenNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var seenIdentityIds = new HashSet<int>();
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<string> 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<IReadOnlyList<string>> CanonicalizeAsync(
IReadOnlyList<string> attendees,
CancellationToken cancellationToken)
{
var distinctAttendees = attendees
.Select(attendee => attendee.Trim())
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return Task.FromResult<IReadOnlyList<string>>(distinctAttendees);
}
}