Public Access
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user