Public Access
Make meeting lifecycle stateful
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user