Public Access
937 lines
41 KiB
C#
937 lines
41 KiB
C#
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 <karl.work@example.com>", "Berger, Karl <karl.private@example.com>", "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<SpeakerIdentityService>();
|
|
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<string, string>(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<string, string>(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<SpeakerIdentityDbContext>()
|
|
.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<SpeakerIdentityDbContext>()
|
|
.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<SpeakerIdentityService>.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<SpeakerIdentityFixture> CreateAsync(
|
|
Action<SpeakerIdentificationOptions>? 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<SpeakerIdentityDbContext>()
|
|
.UseSqlite($"Data Source={dbPath};Pooling=False")
|
|
.Options);
|
|
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None);
|
|
return new SpeakerIdentityFixture(tempDirectory, dbPath, context, options);
|
|
}
|
|
|
|
public SpeakerIdentityService CreateService(ILogger<SpeakerIdentityService>? logger = null)
|
|
{
|
|
return new SpeakerIdentityService(
|
|
new TestSpeakerIdentityDbContextFactory(dbPath),
|
|
SnippetExtractor,
|
|
Matcher,
|
|
Options.Create(new MeetingAssistantOptions { SpeakerIdentification = options }),
|
|
logger ?? NullLogger<SpeakerIdentityService>.Instance,
|
|
MatchValidator);
|
|
}
|
|
|
|
public SpeakerIdentityAttendeeCanonicalizer CreateAttendeeCanonicalizer()
|
|
{
|
|
return new SpeakerIdentityAttendeeCanonicalizer(new TestSpeakerIdentityDbContextFactory(dbPath));
|
|
}
|
|
|
|
public SpeakerIdentificationRequest CreateRequest(
|
|
IReadOnlyList<string> attendees,
|
|
IReadOnlyList<TranscriptionSegment> segments,
|
|
IReadOnlyList<SpeakerAudioSample>? samples = null,
|
|
IReadOnlyDictionary<string, string>? 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<SpeakerIdentity> AddIdentityAsync(
|
|
IReadOnlyList<string> candidates,
|
|
byte[] snippet,
|
|
string? canonicalName = null,
|
|
int referenceCount = 0,
|
|
IReadOnlyList<string>? 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<SpeakerIdentity> 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<SpeakerIdentityDbContext>
|
|
{
|
|
private readonly string dbPath;
|
|
|
|
public TestSpeakerIdentityDbContextFactory(string dbPath)
|
|
{
|
|
this.dbPath = dbPath;
|
|
}
|
|
|
|
public SpeakerIdentityDbContext CreateDbContext()
|
|
{
|
|
return new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
|
|
.UseSqlite($"Data Source={dbPath};Pooling=False")
|
|
.Options);
|
|
}
|
|
}
|
|
|
|
private sealed class FakeSpeakerSnippetExtractor : ISpeakerSnippetExtractor
|
|
{
|
|
public int ExtractCalls { get; private set; }
|
|
|
|
public IReadOnlyList<TranscriptionSegment> LastSpeakerSegments { get; private set; } = [];
|
|
|
|
public Task<byte[]> ExtractSnippetAsync(
|
|
string audioPath,
|
|
IReadOnlyList<TranscriptionSegment> speakerSegments,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (speakerSegments.Count == 0)
|
|
{
|
|
return Task.FromResult<byte[]>([]);
|
|
}
|
|
|
|
ExtractCalls++;
|
|
LastSpeakerSegments = speakerSegments.ToList();
|
|
return Task.FromResult<byte[]>([7, 8, 9]);
|
|
}
|
|
}
|
|
|
|
private sealed class FakeSpeakerIdentityMatcher : ISpeakerIdentityMatcher
|
|
{
|
|
private bool hasMatched;
|
|
|
|
public int? MatchIdentityId { get; set; }
|
|
|
|
public byte[]? LastUnknownSnippet { get; private set; }
|
|
|
|
public List<SpeakerIdentityMatchRequest> Requests { get; } = [];
|
|
|
|
public Task<SpeakerIdentityMatch?> MatchAsync(
|
|
SpeakerIdentityMatchRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
LastUnknownSnippet = request.UnknownSnippet;
|
|
Requests.Add(request);
|
|
if (hasMatched || MatchIdentityId is null)
|
|
{
|
|
return Task.FromResult<SpeakerIdentityMatch?>(null);
|
|
}
|
|
|
|
hasMatched = true;
|
|
return Task.FromResult<SpeakerIdentityMatch?>(new SpeakerIdentityMatch(MatchIdentityId.Value));
|
|
}
|
|
}
|
|
|
|
private sealed class FakeSpeakerIdentityMatchValidator : ISpeakerIdentityMatchValidator
|
|
{
|
|
public bool SampleIsValid { get; set; } = true;
|
|
|
|
public int ValidateSampleCalls { get; private set; }
|
|
|
|
public Task<bool> ValidateSampleAsync(
|
|
byte[] wavBytes,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ValidateSampleCalls++;
|
|
return Task.FromResult(SampleIsValid);
|
|
}
|
|
|
|
public Task<bool> ValidateMatchAsync(
|
|
SpeakerIdentityMatchValidationRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return Task.FromResult(true);
|
|
}
|
|
}
|
|
|
|
private sealed class CapturingLogger<T> : ILogger<T>
|
|
{
|
|
public List<string> Messages { get; } = [];
|
|
|
|
public IDisposable? BeginScope<TState>(TState state)
|
|
where TState : notnull
|
|
{
|
|
return null;
|
|
}
|
|
|
|
public bool IsEnabled(LogLevel logLevel)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
public void Log<TState>(
|
|
LogLevel logLevel,
|
|
EventId eventId,
|
|
TState state,
|
|
Exception? exception,
|
|
Func<TState, Exception?, string> formatter)
|
|
{
|
|
Messages.Add(formatter(state, exception));
|
|
}
|
|
}
|
|
}
|