Files
meeting-assistant/MeetingAssistant.Tests/SpeakerIdentityServiceTests.cs
T
codex 7777b349a5
PR and Push Build/Test / build-and-test (push) Successful in 6m37s
Archive summary refinements and profile switching
2026-05-27 23:11:21 +02:00

661 lines
29 KiB
C#

using MeetingAssistant;
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Speakers;
using MeetingAssistant.Transcription;
using Microsoft.EntityFrameworkCore;
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 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 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 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);
}
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 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
};
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()
{
return new SpeakerIdentityService(
new TestSpeakerIdentityDbContextFactory(dbPath),
SnippetExtractor,
Matcher,
Options.Create(new MeetingAssistantOptions { SpeakerIdentification = options }),
NullLogger<SpeakerIdentityService>.Instance);
}
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 Task<byte[]> ExtractSnippetAsync(
string audioPath,
IReadOnlyList<TranscriptionSegment> speakerSegments,
CancellationToken cancellationToken)
{
ExtractCalls++;
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));
}
}
}