Harden speaker identity samples
PR and Push Build/Test / build-and-test (push) Successful in 7m0s

This commit is contained in:
2026-05-28 12:02:44 +02:00
parent c84f8a984a
commit a72cda0c03
23 changed files with 1251 additions and 123 deletions
@@ -199,6 +199,60 @@ public sealed class SpeakerIdentityServiceTests
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()
{
@@ -450,6 +504,87 @@ public sealed class SpeakerIdentityServiceTests
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;
@@ -472,6 +607,8 @@ public sealed class SpeakerIdentityServiceTests
public FakeSpeakerIdentityMatcher Matcher { get; } = new();
public FakeSpeakerIdentityMatchValidator MatchValidator { get; } = new();
public FakeSpeakerSnippetExtractor SnippetExtractor { get; } = new();
public static async Task<SpeakerIdentityFixture> CreateAsync(
@@ -484,7 +621,8 @@ public sealed class SpeakerIdentityServiceTests
{
DatabasePath = dbPath,
MaxSnippetsPerSpeaker = 3,
MatchBatchSize = 6
MatchBatchSize = 6,
MinimumSampleSpeechDuration = TimeSpan.Zero
};
configureOptions?.Invoke(options);
var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
@@ -501,7 +639,8 @@ public sealed class SpeakerIdentityServiceTests
SnippetExtractor,
Matcher,
Options.Create(new MeetingAssistantOptions { SpeakerIdentification = options }),
NullLogger<SpeakerIdentityService>.Instance);
NullLogger<SpeakerIdentityService>.Instance,
MatchValidator);
}
public SpeakerIdentityAttendeeCanonicalizer CreateAttendeeCanonicalizer()
@@ -627,6 +766,11 @@ public sealed class SpeakerIdentityServiceTests
IReadOnlyList<TranscriptionSegment> speakerSegments,
CancellationToken cancellationToken)
{
if (speakerSegments.Count == 0)
{
return Task.FromResult<byte[]>([]);
}
ExtractCalls++;
return Task.FromResult<byte[]>([7, 8, 9]);
}
@@ -657,4 +801,23 @@ public sealed class SpeakerIdentityServiceTests
return Task.FromResult<SpeakerIdentityMatch?>(new SpeakerIdentityMatch(MatchIdentityId.Value));
}
}
private sealed class FakeSpeakerIdentityMatchValidator : ISpeakerIdentityMatchValidator
{
public bool SampleIsValid { get; set; } = true;
public Task<bool> ValidateSampleAsync(
byte[] wavBytes,
CancellationToken cancellationToken)
{
return Task.FromResult(SampleIsValid);
}
public Task<bool> ValidateMatchAsync(
SpeakerIdentityMatchValidationRequest request,
CancellationToken cancellationToken)
{
return Task.FromResult(true);
}
}
}