Make meeting lifecycle stateful

This commit is contained in:
2026-05-27 12:55:17 +02:00
parent d607b957bb
commit e85274829a
91 changed files with 4076 additions and 479 deletions
@@ -1,3 +1,4 @@
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Transcription;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
@@ -54,9 +55,31 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken);
var attendees = NormalizeAttendees(request.MeetingNote.Frontmatter.Attendees);
var meetingReference = CreateReference(request.MeetingNote, DateTimeOffset.UtcNow);
var speakerMappings = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var attendeeMatches = new List<SpeakerIdentityAttendeeMatch>();
var knownSpeakerMappings = request.KnownSpeakerMappings ??
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var knownDiarizedSpeakers = knownSpeakerMappings.Keys
.Where(speaker => !string.IsNullOrWhiteSpace(speaker))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var alreadyIdentifiedNames = knownSpeakerMappings.Values
.Where(name => !string.IsNullOrWhiteSpace(name))
.Select(name => name.Trim())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var speaker in request.Segments
.Select(segment => segment.Speaker)
.Where(speaker => !string.IsNullOrWhiteSpace(speaker) && !IsDiarizedSpeakerLabel(speaker)))
{
alreadyIdentifiedNames.Add(speaker.Trim());
}
var matchedAcceptedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var name in alreadyIdentifiedNames)
{
matchedAcceptedNames.Add(name);
}
var unmatchedSpeakers = new List<(string Speaker, byte[] Snippet)>();
var samplesBySpeaker = request.Samples?
.Where(sample => !string.IsNullOrWhiteSpace(sample.Speaker) && sample.WavBytes.Length > 0)
@@ -73,6 +96,11 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
.OrderBy(group => group.Min(segment => segment.Start)))
{
var speaker = group.Key;
if (knownDiarizedSpeakers.Contains(speaker) || alreadyIdentifiedNames.Contains(speaker))
{
continue;
}
if (speakerMappings.ContainsKey(speaker))
{
continue;
@@ -90,7 +118,13 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
continue;
}
var match = await FindMatchAsync(context, attendees, speaker, snippet, cancellationToken);
var match = await FindMatchAsync(
context,
attendees,
alreadyIdentifiedNames,
speaker,
snippet,
cancellationToken);
if (match is null)
{
unmatchedSpeakers.Add((speaker, snippet));
@@ -106,7 +140,19 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
if (mode == SpeakerIdentityProcessingMode.Final)
{
var previousCanonicalName = identity.CanonicalName;
AddMeetingReference(identity, meetingReference);
UpdateMatchedIdentity(identity, attendees, snippet);
if (string.IsNullOrWhiteSpace(previousCanonicalName) &&
!string.IsNullOrWhiteSpace(identity.CanonicalName))
{
await SpeakerIdentityTranscriptAudit.AppendIdentifiedAsync(
identity.References,
speaker,
identity.CanonicalName,
cancellationToken);
}
foreach (var acceptedName in GetAcceptedNames(identity))
{
matchedAcceptedNames.Add(acceptedName);
@@ -117,6 +163,12 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
if (!string.IsNullOrWhiteSpace(speakerName))
{
speakerMappings[speaker] = speakerName;
alreadyIdentifiedNames.Add(speakerName);
foreach (var acceptedName in GetAcceptedNames(identity))
{
alreadyIdentifiedNames.Add(acceptedName);
}
attendeeMatches.Add(new SpeakerIdentityAttendeeMatch(
speakerName,
GetAcceptedNames(identity).ToList()));
@@ -125,7 +177,13 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
if (mode == SpeakerIdentityProcessingMode.Final)
{
await LearnUnmatchedSpeakersAsync(context, attendees, matchedAcceptedNames, unmatchedSpeakers, cancellationToken);
await LearnUnmatchedSpeakersAsync(
context,
attendees,
matchedAcceptedNames,
unmatchedSpeakers,
meetingReference,
cancellationToken);
}
await context.SaveChangesAsync(cancellationToken);
@@ -141,6 +199,7 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
private async Task<SpeakerIdentityMatch?> FindMatchAsync(
SpeakerIdentityDbContext context,
IReadOnlyList<string> attendees,
IReadOnlySet<string> alreadyIdentifiedNames,
string speaker,
byte[] snippet,
CancellationToken cancellationToken)
@@ -150,7 +209,8 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
var identities = await context.SpeakerIdentities
.Include(identity => identity.Snippets)
.Include(identity => identity.Aliases)
.OrderByDescending(identity => identity.TranscriptionCount)
.Include(identity => identity.References)
.OrderByDescending(identity => identity.References.Count)
.ThenBy(identity => identity.Id)
.ToListAsync(cancellationToken);
identities = identities
@@ -161,8 +221,9 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
IsActive = identity.UpdatedAt >= activeCutoff
})
.Where(candidate => candidate.IsAttendee || candidate.IsActive)
.Where(candidate => !MatchesAcceptedNames(candidate.Identity, alreadyIdentifiedNames))
.OrderByDescending(candidate => candidate.IsAttendee)
.ThenByDescending(candidate => candidate.Identity.TranscriptionCount)
.ThenByDescending(candidate => candidate.Identity.ReferenceCount)
.ThenBy(candidate => candidate.Identity.Id)
.Take(maxCandidates)
.Select(candidate => candidate.Identity)
@@ -176,7 +237,7 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
batch.Select(identity => new SpeakerIdentityMatchCandidate(
identity.Id,
identity.CanonicalName,
identity.TranscriptionCount,
identity.ReferenceCount,
identity.Snippets.Select(storedSnippet => storedSnippet.WavBytes).ToList()))
.ToList());
var match = await matcher.MatchAsync(request, cancellationToken);
@@ -189,6 +250,13 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
return null;
}
private static bool MatchesAcceptedNames(
SpeakerIdentity identity,
IReadOnlySet<string> names)
{
return GetAcceptedNames(identity).Any(names.Contains);
}
private static bool MatchesAttendees(
SpeakerIdentity identity,
IReadOnlyList<string> attendees)
@@ -206,6 +274,7 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
.Include(identity => identity.CandidateNames)
.Include(identity => identity.Aliases)
.Include(identity => identity.Snippets)
.Include(identity => identity.References)
.SingleOrDefaultAsync(identity => identity.Id == identityId, cancellationToken);
}
@@ -214,7 +283,6 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
IReadOnlyList<string> attendees,
byte[] snippet)
{
identity.TranscriptionCount++;
identity.UpdatedAt = DateTimeOffset.UtcNow;
if (string.IsNullOrWhiteSpace(identity.CanonicalName) && attendees.Count > 0)
@@ -265,6 +333,7 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
IReadOnlyList<string> attendees,
IEnumerable<string> matchedCanonicalNames,
IReadOnlyList<(string Speaker, byte[] Snippet)> unmatchedSpeakers,
SpeakerIdentityReference meetingReference,
CancellationToken cancellationToken)
{
var remainingCandidates = attendees
@@ -276,11 +345,13 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
return;
}
foreach (var (_, snippet) in unmatchedSpeakers.Where(speaker => speaker.Snippet.Length > 0))
foreach (var (speaker, snippet) in unmatchedSpeakers.Where(speaker => speaker.Snippet.Length > 0))
{
var now = DateTimeOffset.UtcNow;
context.SpeakerIdentities.Add(new SpeakerIdentity
var canonicalName = remainingCandidates.Count == 1 ? remainingCandidates[0] : null;
var identity = new SpeakerIdentity
{
CanonicalName = canonicalName,
CreatedAt = now,
UpdatedAt = now,
CandidateNames = remainingCandidates
@@ -293,8 +364,24 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
WavBytes = snippet,
CreatedAt = now
}
],
References =
[
SpeakerIdentityReferences.Create(
meetingReference.MeetingNotePath,
meetingReference.TranscriptPath,
now)
]
});
};
context.SpeakerIdentities.Add(identity);
if (!string.IsNullOrWhiteSpace(canonicalName))
{
await SpeakerIdentityTranscriptAudit.AppendIdentifiedAsync(
identity.References,
speaker,
canonicalName,
cancellationToken);
}
}
await Task.CompletedTask;
@@ -366,9 +453,30 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
private static string NormalizeAttendee(string attendee)
{
var trimmed = attendee.Trim();
var emailStart = trimmed.IndexOf('<', StringComparison.Ordinal);
return emailStart > 0 ? trimmed[..emailStart].Trim() : trimmed;
return MeetingAttendeeNames.NormalizeDisplayName(attendee);
}
private static bool IsDiarizedSpeakerLabel(string speaker)
{
var normalized = speaker.Trim();
return normalized.Equals("Unknown", StringComparison.OrdinalIgnoreCase) ||
normalized.StartsWith("Guest", StringComparison.OrdinalIgnoreCase) ||
normalized.StartsWith("Speaker", StringComparison.OrdinalIgnoreCase);
}
private static SpeakerIdentityReference CreateReference(MeetingNote meetingNote, DateTimeOffset timestamp)
{
return SpeakerIdentityReferences.Create(
meetingNote.Path,
meetingNote.Frontmatter.Transcript,
timestamp);
}
private static void AddMeetingReference(
SpeakerIdentity identity,
SpeakerIdentityReference reference)
{
SpeakerIdentityReferences.AddIfMissing(identity, reference, DateTimeOffset.UtcNow);
}
private enum SpeakerIdentityProcessingMode