using MeetingAssistant; using MeetingAssistant.MeetingNotes; using MeetingAssistant.Speakers; using MeetingAssistant.Transcription; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; namespace MeetingAssistant.Tests; public sealed class SpeakerIdentityServiceTests { [Fact] public async Task MatchedUnnamedIdentityEliminatesCandidatesAndPromotesCanonicalName() { await using var fixture = await SpeakerIdentityFixture.CreateAsync(); var identity = await fixture.AddIdentityAsync(["John", "Mike"], [1, 2, 3], referenceCount: 2); fixture.Matcher.MatchIdentityId = identity.Id; var service = fixture.CreateService(); var result = await service.ProcessFinishedTranscriptAsync( fixture.CreateRequest( ["Jane", "John", "Chris"], [new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "hello")]), CancellationToken.None); var saved = await fixture.LoadIdentityAsync(identity.Id); Assert.Equal("John", saved.CanonicalName); Assert.Equal(["John"], saved.CandidateNames.Select(candidate => candidate.Name)); Assert.Equal(3, saved.References.Count); Assert.All(saved.References, reference => Assert.Contains("Guest01 was identified as John", File.ReadAllText(reference.TranscriptPath))); Assert.Equal("John", result.Segments.Single().Speaker); Assert.Equal("John", result.SpeakerMappings["Guest01"]); } [Fact] public async Task MatchedIdentityUpdatesLastModifiedTimestamp() { await using var fixture = await SpeakerIdentityFixture.CreateAsync(); var oldTimestamp = DateTimeOffset.UtcNow.AddDays(-30); var identity = await fixture.AddIdentityAsync( [], [1, 2, 3], "Chris", referenceCount: 5, updatedAt: oldTimestamp); fixture.Matcher.MatchIdentityId = identity.Id; var service = fixture.CreateService(); await service.ProcessFinishedTranscriptAsync( fixture.CreateRequest( ["Chris"], [new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "hello")]), CancellationToken.None); var saved = await fixture.LoadIdentityAsync(identity.Id); Assert.True(saved.UpdatedAt > oldTimestamp); Assert.Equal(6, saved.References.Count); } [Fact] public async Task MatchedIdentityWithEmptyCandidateIntersectionResetsCandidatesAndReplacesOldestSnippet() { await using var fixture = await SpeakerIdentityFixture.CreateAsync(options => options.MaxSnippetsPerSpeaker = 1); var identity = await fixture.AddIdentityAsync(["John", "Mike"], [9, 9, 9]); fixture.Matcher.MatchIdentityId = identity.Id; var service = fixture.CreateService(); await service.ProcessFinishedTranscriptAsync( fixture.CreateRequest( ["Jane", "Chris"], [new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "hello")]), CancellationToken.None); var saved = await fixture.LoadIdentityAsync(identity.Id); Assert.Null(saved.CanonicalName); Assert.Equal(["Chris", "Jane"], saved.CandidateNames.Select(candidate => candidate.Name).Order()); Assert.Single(saved.References); Assert.Equal([7, 8, 9], saved.Snippets.Single().WavBytes); } [Fact] public async Task MatchedUnnamedIdentityTreatsAliasesAsCandidateMatches() { await using var fixture = await SpeakerIdentityFixture.CreateAsync(); var identity = await fixture.AddIdentityAsync(["Michael"], [1, 2, 3], aliases: ["Mike"]); fixture.Matcher.MatchIdentityId = identity.Id; var service = fixture.CreateService(); await service.ProcessFinishedTranscriptAsync( fixture.CreateRequest( ["Mike", "Jane"], [new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "hello")]), CancellationToken.None); var saved = await fixture.LoadIdentityAsync(identity.Id); Assert.Equal("Michael", saved.CanonicalName); Assert.Equal(["Michael"], saved.CandidateNames.Select(candidate => candidate.Name)); Assert.Contains("Guest01 was identified as Michael", File.ReadAllText(saved.References.Single().TranscriptPath)); } [Fact] public async Task AttendeeCanonicalizerDeduplicatesExactIdentityAliases() { await using var fixture = await SpeakerIdentityFixture.CreateAsync(); await fixture.AddIdentityAsync([], [1, 2, 3], "Karl Berger", aliases: ["Berger, Karl"]); var canonicalizer = fixture.CreateAttendeeCanonicalizer(); var attendees = await canonicalizer.CanonicalizeAsync( ["Karl Berger ", "Berger, Karl ", "Ada"], CancellationToken.None); Assert.Equal(["Karl Berger", "Ada"], attendees); } [Fact] public async Task UnmatchedDiarizedSpeakersCreateCandidateIdentitiesFromUnmatchedAttendees() { await using var fixture = await SpeakerIdentityFixture.CreateAsync(); var chris = await fixture.AddIdentityAsync([], [1, 2, 3], "Chris", referenceCount: 5); fixture.Matcher.MatchIdentityId = chris.Id; var service = fixture.CreateService(); await service.ProcessFinishedTranscriptAsync( fixture.CreateRequest( ["John", "Mike", "Chris"], [ new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest03", "known"), new TranscriptionSegment(TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(8), "Guest01", "unknown one"), new TranscriptionSegment(TimeSpan.FromSeconds(8), TimeSpan.FromSeconds(12), "Guest02", "unknown two") ]), CancellationToken.None); var learned = await fixture.Context.SpeakerIdentities .Include(identity => identity.CandidateNames) .Include(identity => identity.References) .Where(identity => identity.CanonicalName == null) .OrderBy(identity => identity.Id) .ToListAsync(); Assert.Equal(2, learned.Count); Assert.All(learned, identity => { Assert.NotEqual(default, identity.CreatedAt); Assert.NotEqual(default, identity.UpdatedAt); Assert.Single(identity.References); Assert.Equal(["John", "Mike"], identity.CandidateNames.Select(candidate => candidate.Name).Order()); }); } [Fact] public async Task RejectedUnmatchedSpeakerSampleIsLoggedWithSpeakerAndCandidateContext() { await using var fixture = await SpeakerIdentityFixture.CreateAsync(); fixture.MatchValidator.SampleIsValid = false; var logger = new CapturingLogger(); var service = fixture.CreateService(logger); await service.ProcessFinishedTranscriptAsync( fixture.CreateRequest( ["John", "Mike"], [new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "unknown speaker")]), CancellationToken.None); var message = Assert.Single( logger.Messages, message => message.Contains("Skipping speaker identity candidate for Guest01", StringComparison.Ordinal)); Assert.Contains("Skipping speaker identity candidate for Guest01", message); Assert.Contains("candidate names John, Mike", message); Assert.Contains("sample bytes 3", message); } [Fact] public async Task UnmatchedSpeakersExcludeAliasesOfMatchedIdentitiesFromCandidates() { await using var fixture = await SpeakerIdentityFixture.CreateAsync(); var michael = await fixture.AddIdentityAsync([], [1, 2, 3], "Michael", referenceCount: 5, aliases: ["Mike"]); fixture.Matcher.MatchIdentityId = michael.Id; var service = fixture.CreateService(); await service.ProcessFinishedTranscriptAsync( fixture.CreateRequest( ["Mike", "Jane"], [ new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest03", "known"), new TranscriptionSegment(TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(8), "Guest01", "unknown") ]), CancellationToken.None); var learned = await fixture.Context.SpeakerIdentities .Include(identity => identity.CandidateNames) .Include(identity => identity.References) .Where(identity => identity.CanonicalName == "Jane") .SingleAsync(); Assert.Equal("Jane", learned.CanonicalName); Assert.Equal(["Jane"], learned.CandidateNames.Select(candidate => candidate.Name)); Assert.Single(learned.References); Assert.Contains("Guest01 was identified as Jane", File.ReadAllText(learned.References.Single().TranscriptPath)); } [Fact] public async Task UnmatchedSpeakerWithSingleRemainingCandidateIsPromotedOnInsert() { await using var fixture = await SpeakerIdentityFixture.CreateAsync(); var service = fixture.CreateService(); await service.ProcessFinishedTranscriptAsync( fixture.CreateRequest( ["Manuel"], [new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "unknown")]), CancellationToken.None); var learned = await fixture.Context.SpeakerIdentities .Include(identity => identity.CandidateNames) .Include(identity => identity.References) .SingleAsync(); Assert.Equal("Manuel", learned.CanonicalName); Assert.Equal(["Manuel"], learned.CandidateNames.Select(candidate => candidate.Name)); Assert.Single(learned.References); Assert.Contains("Guest01 was identified as Manuel", File.ReadAllText(learned.References.Single().TranscriptPath)); } [Fact] public async Task UnmatchedSpeakerIsNotLearnedWhenSecondaryValidationRejectsSample() { await using var fixture = await SpeakerIdentityFixture.CreateAsync(); fixture.MatchValidator.SampleIsValid = false; var service = fixture.CreateService(); await service.ProcessFinishedTranscriptAsync( fixture.CreateRequest( ["Manuel"], [new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "unknown")]), CancellationToken.None); Assert.Empty(fixture.Context.SpeakerIdentities); } [Fact] public async Task FinishedSnippetExtractionWaitsForMinimumContinuousSpeechDuration() { await using var fixture = await SpeakerIdentityFixture.CreateAsync(options => options.MinimumSampleSpeechDuration = TimeSpan.FromSeconds(30)); var service = fixture.CreateService(); await service.ProcessFinishedTranscriptAsync( fixture.CreateRequest( ["Manuel"], [new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(20), "Guest01", "short")]), CancellationToken.None); Assert.Equal(0, fixture.SnippetExtractor.ExtractCalls); Assert.Empty(fixture.Context.SpeakerIdentities); } [Fact] public async Task FinishedSnippetExtractionDoesNotCombineAcrossSpeakerInterruption() { await using var fixture = await SpeakerIdentityFixture.CreateAsync(options => options.MinimumSampleSpeechDuration = TimeSpan.FromSeconds(30)); var service = fixture.CreateService(); await service.ProcessFinishedTranscriptAsync( fixture.CreateRequest( ["Manuel"], [ new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(20), "Guest01", "first"), new TranscriptionSegment(TimeSpan.FromSeconds(20), TimeSpan.FromSeconds(22), "Guest02", "interrupting"), new TranscriptionSegment(TimeSpan.FromSeconds(22), TimeSpan.FromSeconds(35), "Guest01", "second") ]), CancellationToken.None); Assert.Equal(0, fixture.SnippetExtractor.ExtractCalls); Assert.Empty(fixture.Context.SpeakerIdentities); } [Fact] public async Task SuppliedSpeakerSamplesAreUsedBeforeExtractingFromAudioFile() { await using var fixture = await SpeakerIdentityFixture.CreateAsync(); var chris = await fixture.AddIdentityAsync([], [1, 2, 3], "Chris", referenceCount: 5); fixture.Matcher.MatchIdentityId = chris.Id; var service = fixture.CreateService(); var segment = new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(5), "Guest03", "hello from the live buffer"); var result = await service.IdentifyKnownSpeakersAsync( fixture.CreateRequest( ["Chris"], [segment], [new SpeakerAudioSample("Guest03", segment, [4, 5, 6], 90)]), CancellationToken.None); Assert.Equal("Chris", result.Segments.Single().Speaker); Assert.Equal([4, 5, 6], fixture.Matcher.LastUnknownSnippet); Assert.Equal(0, fixture.SnippetExtractor.ExtractCalls); } [Fact] public async Task SpeakerOverrideAttachesCurrentSampleToExistingNamedIdentity() { await using var fixture = await SpeakerIdentityFixture.CreateAsync(); var sabrina = await fixture.AddIdentityAsync([], [1, 2, 3], "Sabrina", referenceCount: 1); var service = fixture.CreateService(); var segment = new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(5), "Guest-01", "hello from Sabrina"); var request = fixture.CreateRequest( ["Sabrina"], [segment], [new SpeakerAudioSample("Guest-01", segment, [9, 9, 9], 95)]); await service.ApplySpeakerOverrideAsync(request, "Guest-01", "Sabrina", CancellationToken.None); await service.ProcessFinishedTranscriptAsync( fixture.CreateRequest( ["Sabrina"], [segment with { Speaker = "Sabrina" }], knownSpeakerMappings: new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Guest-01"] = "Sabrina" }), CancellationToken.None); var saved = await fixture.LoadIdentityAsync(sabrina.Id); Assert.Equal("Sabrina", saved.CanonicalName); Assert.Equal(2, saved.References.Count); Assert.Contains(saved.Snippets, snippet => snippet.WavBytes.SequenceEqual(new byte[] { 9, 9, 9 })); var allIdentities = await fixture.Context.SpeakerIdentities.ToListAsync(); Assert.Single(allIdentities); } [Fact] public async Task SpeakerOverrideCreatesNamedIdentityWhenNoAcceptedNameExists() { await using var fixture = await SpeakerIdentityFixture.CreateAsync(); var service = fixture.CreateService(); var segment = new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(5), "Guest-01", "hello from Sabrina"); var request = fixture.CreateRequest( ["Sabrina"], [segment], [new SpeakerAudioSample("Guest-01", segment, [8, 8, 8], 95)]); await service.ApplySpeakerOverrideAsync(request, "Guest-01", "Sabrina", CancellationToken.None); var saved = await fixture.Context.SpeakerIdentities .Include(identity => identity.References) .Include(identity => identity.Snippets) .SingleAsync(); Assert.Equal("Sabrina", saved.CanonicalName); Assert.Single(saved.References); Assert.Contains(saved.Snippets, snippet => snippet.WavBytes.SequenceEqual(new byte[] { 8, 8, 8 })); } [Fact] public async Task SpeakerOverrideExtractsOnlyBestContinuousSourceSpanWhenNoLiveSampleExists() { await using var fixture = await SpeakerIdentityFixture.CreateAsync(); var service = fixture.CreateService(); var request = fixture.CreateRequest( ["Sabrina"], [ new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(10), "Guest-01", "short start"), new TranscriptionSegment(TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(20), "Guest-02", "someone else"), new TranscriptionSegment(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(80), "Guest-01", "continuous first part"), new TranscriptionSegment(TimeSpan.FromSeconds(80.5), TimeSpan.FromSeconds(92), "Guest-01", "continuous second part"), new TranscriptionSegment(TimeSpan.FromSeconds(180), TimeSpan.FromSeconds(190), "Guest-01", "short end") ]); await service.ApplySpeakerOverrideAsync(request, "Guest-01", "Sabrina", CancellationToken.None); Assert.Equal(1, fixture.SnippetExtractor.ExtractCalls); Assert.Collection( fixture.SnippetExtractor.LastSpeakerSegments, segment => Assert.Equal(TimeSpan.FromSeconds(60), segment.Start), segment => Assert.Equal(TimeSpan.FromSeconds(80.5), segment.Start)); } [Fact] public async Task SpeakerOverrideDoesNotCreateNamedIdentityWhenSecondaryValidationRejectsSample() { await using var fixture = await SpeakerIdentityFixture.CreateAsync(); fixture.MatchValidator.SampleIsValid = false; var service = fixture.CreateService(); var request = fixture.CreateRequest( ["Sabrina"], [ new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(31), "Guest-01", "long enough source sample") ]); await service.ApplySpeakerOverrideAsync(request, "Guest-01", "Sabrina", CancellationToken.None); Assert.Equal(1, fixture.MatchValidator.ValidateSampleCalls); Assert.Empty(await fixture.Context.SpeakerIdentities.ToListAsync()); } [Fact] public async Task SpeakerOverrideDoesNotCreateNamedIdentityWithoutSourceSample() { await using var fixture = await SpeakerIdentityFixture.CreateAsync(); var service = fixture.CreateService(); var segment = new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(5), "Guest-01", "hello from Sabrina"); var request = fixture.CreateRequest( ["Sabrina"], [segment], [new SpeakerAudioSample("Guest-01", segment, [8, 8, 8], 95)]); await service.ApplySpeakerOverrideAsync(request, "Guest-5", "Sabrina", CancellationToken.None); Assert.Empty(await fixture.Context.SpeakerIdentities.ToListAsync()); } [Fact] public async Task SpeakerIdentityDeletionRemovesAcceptedNameFromDatabase() { await using var fixture = await SpeakerIdentityFixture.CreateAsync(); var sabrina = await fixture.AddIdentityAsync([], [1, 2, 3], "Sabrina", referenceCount: 1, aliases: ["Sabi"]); await fixture.AddIdentityAsync([], [4, 5, 6], "Manuel", referenceCount: 1); var service = fixture.CreateService(); await service.DeleteSpeakerIdentityAsync("Sabi", CancellationToken.None); var identities = await fixture.Context.SpeakerIdentities.ToListAsync(); Assert.DoesNotContain(identities, identity => identity.Id == sabrina.Id); Assert.Contains(identities, identity => identity.CanonicalName == "Manuel"); } [Fact] public async Task LiveKnownSpeakerIdentificationDoesNotMutateIdentityDatabase() { await using var fixture = await SpeakerIdentityFixture.CreateAsync(); var identity = await fixture.AddIdentityAsync(["John", "Mike"], [1, 2, 3], referenceCount: 5); fixture.Matcher.MatchIdentityId = identity.Id; var service = fixture.CreateService(); var result = await service.IdentifyKnownSpeakersAsync( fixture.CreateRequest( ["Jane", "John", "Chris"], [new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "hello")]), CancellationToken.None); var saved = await fixture.LoadIdentityAsync(identity.Id); Assert.Null(saved.CanonicalName); Assert.Equal(["John", "Mike"], saved.CandidateNames.Select(candidate => candidate.Name).Order()); Assert.Equal(5, saved.References.Count); Assert.Single(saved.Snippets); Assert.Equal("Guest01", result.Segments.Single().Speaker); Assert.Empty(result.SpeakerMappings); } [Fact] public async Task SpeakerMatchingPrioritizesAttendeeDisplayNamesAndAliases() { await using var fixture = await SpeakerIdentityFixture.CreateAsync(); var unrelated = await fixture.AddIdentityAsync([], [1], "Frequent", referenceCount: 99); var aliasMatch = await fixture.AddIdentityAsync([], [2], "Michael", referenceCount: 1, aliases: ["Mike"]); var displayNameMatch = await fixture.AddIdentityAsync([], [3], "Jane", referenceCount: 2); var service = fixture.CreateService(); await service.IdentifyKnownSpeakersAsync( fixture.CreateRequest( ["Jane", "Mike"], [new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "hello")]), CancellationToken.None); var requestedIds = fixture.Matcher.Requests.Single().Candidates.Select(candidate => candidate.IdentityId).ToList(); Assert.Equal([displayNameMatch.Id, aliasMatch.Id, unrelated.Id], requestedIds); } [Fact] public async Task SpeakerMatchingFiltersInactiveNonAttendeeIdentitiesAndLimitsCandidates() { await using var fixture = await SpeakerIdentityFixture.CreateAsync(options => { options.MaxMatchCandidates = 2; options.MatchIdentityActiveAge = TimeSpan.FromDays(365); }); var activeHigh = await fixture.AddIdentityAsync([], [1], "Active High", referenceCount: 50); await fixture.AddIdentityAsync([], [2], "Stale High", referenceCount: 100, updatedAt: DateTimeOffset.UtcNow.AddYears(-2)); var activeLow = await fixture.AddIdentityAsync([], [3], "Active Low", referenceCount: 5); var staleAttendee = await fixture.AddIdentityAsync([], [4], "Former Employee", referenceCount: 1, updatedAt: DateTimeOffset.UtcNow.AddYears(-2), aliases: ["Former"]); var service = fixture.CreateService(); await service.IdentifyKnownSpeakersAsync( fixture.CreateRequest( ["Former"], [new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "hello")]), CancellationToken.None); var requestedIds = fixture.Matcher.Requests.Single().Candidates.Select(candidate => candidate.IdentityId).ToList(); Assert.Equal([staleAttendee.Id, activeHigh.Id], requestedIds); Assert.DoesNotContain(activeLow.Id, requestedIds); } [Fact] public async Task KnownSpeakerMappingsExcludeMappedSpeakersAndIdentitiesFromMatching() { await using var fixture = await SpeakerIdentityFixture.CreateAsync(); var manuel = await fixture.AddIdentityAsync([], [1], "Manuel", referenceCount: 99); var daniel = await fixture.AddIdentityAsync([], [2], "Daniel", referenceCount: 5); var service = fixture.CreateService(); await service.IdentifyKnownSpeakersAsync( fixture.CreateRequest( ["Manuel", "Daniel"], [ new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest-2", "already identified"), new TranscriptionSegment(TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(8), "Guest-1", "unresolved") ], [ new SpeakerAudioSample( "Guest-2", new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest-2", "already identified"), [4], 90), new SpeakerAudioSample( "Guest-1", new TranscriptionSegment(TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(8), "Guest-1", "unresolved"), [5], 90) ], new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Guest-2"] = "Manuel" }), CancellationToken.None); var request = fixture.Matcher.Requests.Single(); Assert.Equal("Guest-1", request.DiarizedSpeaker); Assert.Equal([daniel.Id], request.Candidates.Select(candidate => candidate.IdentityId)); Assert.DoesNotContain(manuel.Id, request.Candidates.Select(candidate => candidate.IdentityId)); } [Fact] public async Task NamedSpeakersExcludeTheirIdentityFromFurtherMatching() { await using var fixture = await SpeakerIdentityFixture.CreateAsync(); var manuel = await fixture.AddIdentityAsync([], [1], "Manuel", referenceCount: 99); var daniel = await fixture.AddIdentityAsync([], [2], "Daniel", referenceCount: 5); var service = fixture.CreateService(); await service.ProcessFinishedTranscriptAsync( fixture.CreateRequest( ["Manuel", "Daniel"], [ new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Manuel", "already relabeled"), new TranscriptionSegment(TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(8), "Guest-1", "unresolved") ]), CancellationToken.None); var request = fixture.Matcher.Requests.Single(); Assert.Equal("Guest-1", request.DiarizedSpeaker); Assert.Equal([daniel.Id], request.Candidates.Select(candidate => candidate.IdentityId)); Assert.DoesNotContain(manuel.Id, request.Candidates.Select(candidate => candidate.IdentityId)); } [Fact] public async Task SchemaUpgradeAddsLastModifiedColumnInitializedFromCreatedAt() { var dbPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", $"{Guid.NewGuid():N}.db"); Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); var createdAt = DateTimeOffset.Parse("2026-05-01T10:00:00+00:00"); await using var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder() .UseSqlite($"Data Source={dbPath};Pooling=False") .Options); await context.Database.ExecuteSqlRawAsync( """ CREATE TABLE "SpeakerIdentities" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_SpeakerIdentities" PRIMARY KEY AUTOINCREMENT, "CanonicalName" TEXT NULL, "TranscriptionCount" INTEGER NOT NULL, "CreatedAt" TEXT NOT NULL ); """); await context.Database.ExecuteSqlInterpolatedAsync( $""" INSERT INTO "SpeakerIdentities" ("CanonicalName", "TranscriptionCount", "CreatedAt") VALUES ('Legacy Speaker', 3, {createdAt}); """); await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None); context.ChangeTracker.Clear(); var saved = await context.SpeakerIdentities.SingleAsync(); Assert.Equal(createdAt, saved.UpdatedAt); await context.DisposeAsync(); File.Delete(dbPath); } [Fact] public async Task SpeakerOverrideCreatesIdentityWhenLegacyTranscriptionCountHasNoDatabaseDefault() { var dbPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", $"{Guid.NewGuid():N}.db"); Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); await using var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder() .UseSqlite($"Data Source={dbPath};Pooling=False") .Options); await context.Database.ExecuteSqlRawAsync( """ CREATE TABLE "SpeakerIdentities" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_SpeakerIdentities" PRIMARY KEY AUTOINCREMENT, "CanonicalName" TEXT NULL, "TranscriptionCount" INTEGER NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL ); """); await context.Database.ExecuteSqlRawAsync( """ CREATE TABLE "SpeakerCandidateNames" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_SpeakerCandidateNames" PRIMARY KEY AUTOINCREMENT, "SpeakerIdentityId" INTEGER NOT NULL, "Name" TEXT NOT NULL, CONSTRAINT "FK_SpeakerCandidateNames_SpeakerIdentities_SpeakerIdentityId" FOREIGN KEY ("SpeakerIdentityId") REFERENCES "SpeakerIdentities" ("Id") ON DELETE CASCADE ); """); await context.Database.ExecuteSqlRawAsync( """ CREATE TABLE "SpeakerSnippets" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_SpeakerSnippets" PRIMARY KEY AUTOINCREMENT, "SpeakerIdentityId" INTEGER NOT NULL, "WavBytes" BLOB NOT NULL, "CreatedAt" TEXT NOT NULL, CONSTRAINT "FK_SpeakerSnippets_SpeakerIdentities_SpeakerIdentityId" FOREIGN KEY ("SpeakerIdentityId") REFERENCES "SpeakerIdentities" ("Id") ON DELETE CASCADE ); """); var options = new SpeakerIdentificationOptions { DatabasePath = dbPath, MinimumSampleSpeechDuration = TimeSpan.Zero }; var service = new SpeakerIdentityService( new TestSpeakerIdentityDbContextFactory(dbPath), new FakeSpeakerSnippetExtractor(), new FakeSpeakerIdentityMatcher(), Options.Create(new MeetingAssistantOptions { SpeakerIdentification = options }), NullLogger.Instance, new FakeSpeakerIdentityMatchValidator()); var segment = new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(5), "Guest-01", "hello from Daniel"); var root = Path.GetDirectoryName(dbPath)!; var transcriptPath = Path.Combine(root, "transcript.md"); await File.WriteAllTextAsync(transcriptPath, "Transcript"); var request = new SpeakerIdentificationRequest( "test.wav", new MeetingNote( Path.Combine(root, "meeting.md"), new MeetingNoteFrontmatter { Title = "Test Meeting", StartTime = DateTimeOffset.Parse("2026-05-28T10:00:00+02:00"), Attendees = ["Hecht, Daniel"], Transcript = transcriptPath, AssistantContext = Path.Combine(root, "context.md"), Summary = Path.Combine(root, "summary.md") }, ""), [segment], [new SpeakerAudioSample("Guest-01", segment, [8, 8, 8], 95)]); await service.ApplySpeakerOverrideAsync(request, "Guest-01", "Hecht, Daniel", CancellationToken.None); context.ChangeTracker.Clear(); var saved = await context.SpeakerIdentities.SingleAsync(); Assert.Equal("Hecht, Daniel", saved.CanonicalName); Assert.Equal(0, saved.TranscriptionCount); File.Delete(dbPath); } private sealed class SpeakerIdentityFixture : IAsyncDisposable { private readonly string tempDirectory; private readonly string dbPath; private readonly SpeakerIdentificationOptions options; private SpeakerIdentityFixture( string tempDirectory, string dbPath, SpeakerIdentityDbContext context, SpeakerIdentificationOptions options) { this.tempDirectory = tempDirectory; this.dbPath = dbPath; Context = context; this.options = options; } public SpeakerIdentityDbContext Context { get; } public FakeSpeakerIdentityMatcher Matcher { get; } = new(); public FakeSpeakerIdentityMatchValidator MatchValidator { get; } = new(); public FakeSpeakerSnippetExtractor SnippetExtractor { get; } = new(); public static async Task CreateAsync( Action? configureOptions = null) { var tempDirectory = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempDirectory); var dbPath = Path.Combine(tempDirectory, "speaker-identities.db"); var options = new SpeakerIdentificationOptions { DatabasePath = dbPath, MaxSnippetsPerSpeaker = 3, MatchBatchSize = 6, MinimumSampleSpeechDuration = TimeSpan.Zero }; configureOptions?.Invoke(options); var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder() .UseSqlite($"Data Source={dbPath};Pooling=False") .Options); await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None); return new SpeakerIdentityFixture(tempDirectory, dbPath, context, options); } public SpeakerIdentityService CreateService(ILogger? logger = null) { return new SpeakerIdentityService( new TestSpeakerIdentityDbContextFactory(dbPath), SnippetExtractor, Matcher, Options.Create(new MeetingAssistantOptions { SpeakerIdentification = options }), logger ?? NullLogger.Instance, MatchValidator); } public SpeakerIdentityAttendeeCanonicalizer CreateAttendeeCanonicalizer() { return new SpeakerIdentityAttendeeCanonicalizer(new TestSpeakerIdentityDbContextFactory(dbPath)); } public SpeakerIdentificationRequest CreateRequest( IReadOnlyList attendees, IReadOnlyList segments, IReadOnlyList? samples = null, IReadOnlyDictionary? knownSpeakerMappings = null) { var meetingNotePath = Path.Combine(tempDirectory, "meeting.md"); var transcriptPath = Path.Combine(tempDirectory, "transcript.md"); File.WriteAllText(transcriptPath, "Transcript"); return new SpeakerIdentificationRequest( "test.wav", new MeetingNote( meetingNotePath, new MeetingNoteFrontmatter { Title = "Test Meeting", StartTime = DateTimeOffset.Parse("2026-05-20T12:00:00+02:00"), Attendees = attendees.ToList(), Transcript = transcriptPath, AssistantContext = Path.Combine(tempDirectory, "context.md"), Summary = Path.Combine(tempDirectory, "summary.md") }, ""), segments, samples, knownSpeakerMappings); } public async Task AddIdentityAsync( IReadOnlyList candidates, byte[] snippet, string? canonicalName = null, int referenceCount = 0, IReadOnlyList? aliases = null, DateTimeOffset? updatedAt = null) { var timestamp = updatedAt ?? DateTimeOffset.UtcNow; var identity = new SpeakerIdentity { CanonicalName = canonicalName, CreatedAt = timestamp, UpdatedAt = timestamp, Aliases = aliases?.Select(alias => new SpeakerAlias { Name = alias }).ToList() ?? [], CandidateNames = candidates.Select(candidate => new SpeakerCandidateName { Name = candidate }).ToList(), Snippets = [ new SpeakerSnippet { WavBytes = snippet, CreatedAt = timestamp.AddMinutes(-10) } ], References = Enumerable.Range(0, referenceCount) .Select(index => { var transcriptPath = Path.Combine(tempDirectory, $"reference-{Guid.NewGuid():N}-{index}.md"); File.WriteAllText(transcriptPath, "Transcript"); return new SpeakerIdentityReference { MeetingNotePath = Path.Combine(tempDirectory, $"reference-{Guid.NewGuid():N}-{index}.md"), TranscriptPath = transcriptPath, CreatedAt = timestamp.AddMinutes(index) }; }) .ToList() }; Context.SpeakerIdentities.Add(identity); await Context.SaveChangesAsync(); return identity; } public Task LoadIdentityAsync(int id) { Context.ChangeTracker.Clear(); return Context.SpeakerIdentities .Include(identity => identity.CandidateNames) .Include(identity => identity.Aliases) .Include(identity => identity.Snippets) .Include(identity => identity.References) .SingleAsync(identity => identity.Id == id); } public async ValueTask DisposeAsync() { await Context.DisposeAsync(); if (Directory.Exists(tempDirectory)) { Directory.Delete(tempDirectory, recursive: true); } } } private sealed class TestSpeakerIdentityDbContextFactory : IDbContextFactory { private readonly string dbPath; public TestSpeakerIdentityDbContextFactory(string dbPath) { this.dbPath = dbPath; } public SpeakerIdentityDbContext CreateDbContext() { return new SpeakerIdentityDbContext(new DbContextOptionsBuilder() .UseSqlite($"Data Source={dbPath};Pooling=False") .Options); } } private sealed class FakeSpeakerSnippetExtractor : ISpeakerSnippetExtractor { public int ExtractCalls { get; private set; } public IReadOnlyList LastSpeakerSegments { get; private set; } = []; public Task ExtractSnippetAsync( string audioPath, IReadOnlyList speakerSegments, CancellationToken cancellationToken) { if (speakerSegments.Count == 0) { return Task.FromResult([]); } ExtractCalls++; LastSpeakerSegments = speakerSegments.ToList(); return Task.FromResult([7, 8, 9]); } } private sealed class FakeSpeakerIdentityMatcher : ISpeakerIdentityMatcher { private bool hasMatched; public int? MatchIdentityId { get; set; } public byte[]? LastUnknownSnippet { get; private set; } public List Requests { get; } = []; public Task MatchAsync( SpeakerIdentityMatchRequest request, CancellationToken cancellationToken) { LastUnknownSnippet = request.UnknownSnippet; Requests.Add(request); if (hasMatched || MatchIdentityId is null) { return Task.FromResult(null); } hasMatched = true; return Task.FromResult(new SpeakerIdentityMatch(MatchIdentityId.Value)); } } private sealed class FakeSpeakerIdentityMatchValidator : ISpeakerIdentityMatchValidator { public bool SampleIsValid { get; set; } = true; public int ValidateSampleCalls { get; private set; } public Task ValidateSampleAsync( byte[] wavBytes, CancellationToken cancellationToken) { ValidateSampleCalls++; return Task.FromResult(SampleIsValid); } public Task ValidateMatchAsync( SpeakerIdentityMatchValidationRequest request, CancellationToken cancellationToken) { return Task.FromResult(true); } } private sealed class CapturingLogger : ILogger { public List Messages { get; } = []; public IDisposable? BeginScope(TState state) where TState : notnull { return null; } public bool IsEnabled(LogLevel logLevel) { return true; } public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { Messages.Add(formatter(state, exception)); } } }