Public Access
Make meeting lifecycle stateful
This commit is contained in:
@@ -14,7 +14,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
public async Task MatchedUnnamedIdentityEliminatesCandidatesAndPromotesCanonicalName()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var identity = await fixture.AddIdentityAsync(["John", "Mike"], [1, 2, 3]);
|
||||
var identity = await fixture.AddIdentityAsync(["John", "Mike"], [1, 2, 3], referenceCount: 2);
|
||||
fixture.Matcher.MatchIdentityId = identity.Id;
|
||||
var service = fixture.CreateService();
|
||||
|
||||
@@ -27,7 +27,9 @@ public sealed class SpeakerIdentityServiceTests
|
||||
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
||||
Assert.Equal("John", saved.CanonicalName);
|
||||
Assert.Equal(["John"], saved.CandidateNames.Select(candidate => candidate.Name));
|
||||
Assert.Equal(1, saved.TranscriptionCount);
|
||||
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"]);
|
||||
}
|
||||
@@ -41,7 +43,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
[],
|
||||
[1, 2, 3],
|
||||
"Chris",
|
||||
transcriptionCount: 5,
|
||||
referenceCount: 5,
|
||||
updatedAt: oldTimestamp);
|
||||
fixture.Matcher.MatchIdentityId = identity.Id;
|
||||
var service = fixture.CreateService();
|
||||
@@ -54,6 +56,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
|
||||
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
||||
Assert.True(saved.UpdatedAt > oldTimestamp);
|
||||
Assert.Equal(6, saved.References.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -73,6 +76,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -93,13 +97,28 @@ public sealed class SpeakerIdentityServiceTests
|
||||
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", transcriptionCount: 5);
|
||||
var chris = await fixture.AddIdentityAsync([], [1, 2, 3], "Chris", referenceCount: 5);
|
||||
fixture.Matcher.MatchIdentityId = chris.Id;
|
||||
var service = fixture.CreateService();
|
||||
|
||||
@@ -115,6 +134,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
|
||||
var learned = await fixture.Context.SpeakerIdentities
|
||||
.Include(identity => identity.CandidateNames)
|
||||
.Include(identity => identity.References)
|
||||
.Where(identity => identity.CanonicalName == null)
|
||||
.OrderBy(identity => identity.Id)
|
||||
.ToListAsync();
|
||||
@@ -124,6 +144,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
{
|
||||
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());
|
||||
});
|
||||
}
|
||||
@@ -132,7 +153,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
public async Task UnmatchedSpeakersExcludeAliasesOfMatchedIdentitiesFromCandidates()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var michael = await fixture.AddIdentityAsync([], [1, 2, 3], "Michael", transcriptionCount: 5, aliases: ["Mike"]);
|
||||
var michael = await fixture.AddIdentityAsync([], [1, 2, 3], "Michael", referenceCount: 5, aliases: ["Mike"]);
|
||||
fixture.Matcher.MatchIdentityId = michael.Id;
|
||||
var service = fixture.CreateService();
|
||||
|
||||
@@ -147,16 +168,42 @@ public sealed class SpeakerIdentityServiceTests
|
||||
|
||||
var learned = await fixture.Context.SpeakerIdentities
|
||||
.Include(identity => identity.CandidateNames)
|
||||
.Where(identity => identity.CanonicalName == null)
|
||||
.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", transcriptionCount: 5);
|
||||
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");
|
||||
@@ -177,7 +224,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
public async Task LiveKnownSpeakerIdentificationDoesNotMutateIdentityDatabase()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var identity = await fixture.AddIdentityAsync(["John", "Mike"], [1, 2, 3], transcriptionCount: 5);
|
||||
var identity = await fixture.AddIdentityAsync(["John", "Mike"], [1, 2, 3], referenceCount: 5);
|
||||
fixture.Matcher.MatchIdentityId = identity.Id;
|
||||
var service = fixture.CreateService();
|
||||
|
||||
@@ -190,7 +237,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
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.TranscriptionCount);
|
||||
Assert.Equal(5, saved.References.Count);
|
||||
Assert.Single(saved.Snippets);
|
||||
Assert.Equal("Guest01", result.Segments.Single().Speaker);
|
||||
Assert.Empty(result.SpeakerMappings);
|
||||
@@ -200,9 +247,9 @@ public sealed class SpeakerIdentityServiceTests
|
||||
public async Task SpeakerMatchingPrioritizesAttendeeDisplayNamesAndAliases()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var unrelated = await fixture.AddIdentityAsync([], [1], "Frequent", transcriptionCount: 99);
|
||||
var aliasMatch = await fixture.AddIdentityAsync([], [2], "Michael", transcriptionCount: 1, aliases: ["Mike"]);
|
||||
var displayNameMatch = await fixture.AddIdentityAsync([], [3], "Jane", transcriptionCount: 2);
|
||||
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(
|
||||
@@ -223,10 +270,10 @@ public sealed class SpeakerIdentityServiceTests
|
||||
options.MaxMatchCandidates = 2;
|
||||
options.MatchIdentityActiveAge = TimeSpan.FromDays(365);
|
||||
});
|
||||
var activeHigh = await fixture.AddIdentityAsync([], [1], "Active High", transcriptionCount: 50);
|
||||
await fixture.AddIdentityAsync([], [2], "Stale High", transcriptionCount: 100, updatedAt: DateTimeOffset.UtcNow.AddYears(-2));
|
||||
var activeLow = await fixture.AddIdentityAsync([], [3], "Active Low", transcriptionCount: 5);
|
||||
var staleAttendee = await fixture.AddIdentityAsync([], [4], "Former Employee", transcriptionCount: 1, updatedAt: DateTimeOffset.UtcNow.AddYears(-2), aliases: ["Former"]);
|
||||
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(
|
||||
@@ -240,6 +287,68 @@ public sealed class SpeakerIdentityServiceTests
|
||||
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()
|
||||
{
|
||||
@@ -275,14 +384,17 @@ public sealed class SpeakerIdentityServiceTests
|
||||
|
||||
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;
|
||||
@@ -297,8 +409,9 @@ public sealed class SpeakerIdentityServiceTests
|
||||
public static async Task<SpeakerIdentityFixture> CreateAsync(
|
||||
Action<SpeakerIdentificationOptions>? configureOptions = null)
|
||||
{
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", $"{Guid.NewGuid():N}.db");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
|
||||
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,
|
||||
@@ -310,7 +423,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
.UseSqlite($"Data Source={dbPath};Pooling=False")
|
||||
.Options);
|
||||
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None);
|
||||
return new SpeakerIdentityFixture(dbPath, context, options);
|
||||
return new SpeakerIdentityFixture(tempDirectory, dbPath, context, options);
|
||||
}
|
||||
|
||||
public SpeakerIdentityService CreateService()
|
||||
@@ -323,29 +436,44 @@ public sealed class SpeakerIdentityServiceTests
|
||||
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)
|
||||
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",
|
||||
MeetingNoteTemplate.Create(
|
||||
"Test Meeting",
|
||||
DateTimeOffset.Parse("2026-05-20T12:00:00+02:00"),
|
||||
attendees: attendees,
|
||||
transcriptPath: "transcript.md",
|
||||
assistantContextPath: "context.md",
|
||||
summaryPath: "summary.md"),
|
||||
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);
|
||||
samples,
|
||||
knownSpeakerMappings);
|
||||
}
|
||||
|
||||
public async Task<SpeakerIdentity> AddIdentityAsync(
|
||||
IReadOnlyList<string> candidates,
|
||||
byte[] snippet,
|
||||
string? canonicalName = null,
|
||||
int transcriptionCount = 0,
|
||||
int referenceCount = 0,
|
||||
IReadOnlyList<string>? aliases = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
@@ -353,7 +481,6 @@ public sealed class SpeakerIdentityServiceTests
|
||||
var identity = new SpeakerIdentity
|
||||
{
|
||||
CanonicalName = canonicalName,
|
||||
TranscriptionCount = transcriptionCount,
|
||||
CreatedAt = timestamp,
|
||||
UpdatedAt = timestamp,
|
||||
Aliases = aliases?.Select(alias => new SpeakerAlias { Name = alias }).ToList() ?? [],
|
||||
@@ -365,7 +492,20 @@ public sealed class SpeakerIdentityServiceTests
|
||||
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();
|
||||
@@ -379,15 +519,16 @@ public sealed class SpeakerIdentityServiceTests
|
||||
.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 (File.Exists(dbPath))
|
||||
if (Directory.Exists(tempDirectory))
|
||||
{
|
||||
File.Delete(dbPath);
|
||||
Directory.Delete(tempDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user